Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@
},
{
"name": "task-utils",
"description": "Task discipline hooks (0-or-1 in_progress invariant, write-tool gating, validation-steps + RESULT cites) + task-manage skill for atomic-task workflow",
"version": "0.2.0",
"description": "Task discipline hooks (0-or-1 in_progress invariant, write-tool gating, validation-steps + RESULT cites) + task-manage skill for atomic-task workflow. Blocking behaviour is configurable via plugins.settings.yaml.",
"version": "0.3.0",
"author": {
"name": "Nathan Heaps"
},
Expand Down
4 changes: 2 additions & 2 deletions plugins/claude-code/task-utils/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "task-utils",
"version": "0.2.0",
"description": "Task discipline hooks (0-or-1 in_progress invariant, write-tool gating, validation-steps + RESULT cites) + task-manage skill for atomic-task workflow",
"version": "0.3.0",
"description": "Task discipline hooks (0-or-1 in_progress invariant, write-tool gating, validation-steps + RESULT cites) + task-manage skill for atomic-task workflow. Blocking behaviour is configurable via plugins.settings.yaml.",
"author": {
"name": "Nathan Heaps",
"email": "nsheaps@gmail.com",
Expand Down
45 changes: 45 additions & 0 deletions plugins/claude-code/task-utils/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,51 @@ Task discipline hooks and the `task-manage` skill (renamed from `manage-tasks` p

- **`task-manage`** — doctrine for the atomicity check, breakdown pattern, status-transition table, validation-steps mechanism, background-subagent (`AGENT(<n>)`) prefix, MONITORING(<monitor-id>) prefix, and behavior-changing-jumps-the-queue rule. Forks into an isolated context via `context: fork` so the parent's window stays lean and returns a ≤5-sentence imperative instruction.

## Configuration

All blocking behaviour is opt-in via `plugins.settings.yaml` in your project's `.claude/` directory. Add a `task-utils:` section to tune or disable enforcement:

```yaml
# .claude/plugins.settings.yaml
task-utils:
# Master switch — set false to disable all task-utils hooks entirely.
# Default: true
enabled: true

# Enforce the 0-or-1 in_progress invariant (task-invariant.sh).
# Set false to allow multiple concurrent in_progress tasks (e.g. multi-workstream setups).
# Default: true
singleTaskBlocking: true

# Require a <validation-steps> block before a task can move to in_progress,
# and require RESULT(...) lines on checked steps before completing.
# Default: true
requireValidationSteps: true

# Require at least one task in in_progress before Write/Edit/MultiEdit/NotebookEdit
# tools are permitted (require-task-in-progress.sh).
# Default: true
requireInProgress: true
```

**Defaults are all `true`** — existing installs with no `task-utils:` section in their settings file continue to behave exactly as before. Agents that need to relax the rules can set any combination of the above to `false` without touching the plugin source.

Example — disable all blocking while keeping coaching:

```yaml
task-utils:
singleTaskBlocking: false
requireValidationSteps: false
requireInProgress: false
```

Example — disable everything (full pass-through):

```yaml
task-utils:
enabled: false
```

## Installation

Add to your `.claude/settings.json`:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
# correspond to an actively-tracked atomic task so progress is
# observable and validatable.
#
# Configuration (plugins.settings.yaml):
# task-utils:
# enabled: true # master switch — false disables all task-utils hooks
# requireInProgress: true # this hook — false allows writes without an in_progress task
#
# Output contract (per claude-code docs hooks.md PreToolUse):
# - Exit 0 with JSON on STDOUT for policy decisions.
# - `permissionDecisionReason` carries the deny text (clean prose).
Expand Down Expand Up @@ -55,6 +60,44 @@ emit_decision() {
}'
}

# read_config KEY DEFAULT
# Reads a key from the task-utils section of plugins.settings.yaml.
# Returns the DEFAULT value if the file or key is missing.
# Keys are read as strings: "true"/"false" for booleans.
read_config() {
local key="$1" default="$2"
local config_file="${CLAUDE_PROJECT_DIR:-.}/.claude/plugins.settings.yaml"
if [[ ! -f "$config_file" ]]; then
echo "$default"
return
fi
python3 - "$config_file" "$key" "$default" <<'PYEOF' 2>/dev/null || echo "$default"
import sys
try:
import yaml
cfg_file, key, default = sys.argv[1], sys.argv[2], sys.argv[3]
with open(cfg_file) as f:
cfg = yaml.safe_load(f) or {}
section = cfg.get('task-utils', {}) or {}
val = section.get(key)
if val is None:
print(default)
else:
print('true' if val is True else ('false' if val is False else str(val)))
except Exception:
print(sys.argv[3] if len(sys.argv) > 3 else 'true')
PYEOF
}

# Check master switch and this hook's specific flag
ENABLED="$(read_config enabled true)"
REQUIRE_IN_PROGRESS="$(read_config requireInProgress true)"

if [[ "$ENABLED" == "false" || "$REQUIRE_IN_PROGRESS" == "false" ]]; then
log_fire "allow-config-disabled" "tool=${TOOL_NAME} enabled=${ENABLED} requireInProgress=${REQUIRE_IN_PROGRESS}"
exit 0
fi

# Only gate the write-class tools
case "$TOOL_NAME" in
Write|Edit|MultiEdit|NotebookEdit) : ;;
Expand Down
132 changes: 91 additions & 41 deletions plugins/claude-code/task-utils/hooks/task-invariant.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
# 4. Coach on atomicity / breakdown / parallelism / stakeholder ping.
# Three coach variants (STARTED, COMPLETED, GENERIC) — see below.
#
# Configuration (plugins.settings.yaml):
# task-utils:
# enabled: true # master switch — false disables all task-utils hooks
# singleTaskBlocking: true # enforce 0-or-1 in_progress invariant
# requireValidationSteps: true # require <validation-steps> block before in_progress
#
# Output contract (per claude-code docs hooks.md PreToolUse):
# - Exit 0 with JSON on STDOUT for policy decisions.
# - `permissionDecisionReason` carries deny text (clean prose).
Expand Down Expand Up @@ -56,6 +62,46 @@ esac
CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
TASKS_DIR="$CLAUDE_DIR/tasks/$SESSION_ID"

# read_config KEY DEFAULT
# Reads a key from the task-utils section of plugins.settings.yaml.
# Returns the DEFAULT value if the file or key is missing.
# Keys are returned as strings: "true"/"false" for booleans.
read_config() {
local key="$1" default="$2"
local config_file="${CLAUDE_PROJECT_DIR:-.}/.claude/plugins.settings.yaml"
if [[ ! -f "$config_file" ]]; then
echo "$default"
return
fi
python3 - "$config_file" "$key" "$default" <<'PYEOF' 2>/dev/null || echo "$default"
import sys
try:
import yaml
cfg_file, key, default = sys.argv[1], sys.argv[2], sys.argv[3]
with open(cfg_file) as f:
cfg = yaml.safe_load(f) or {}
section = cfg.get('task-utils', {}) or {}
val = section.get(key)
if val is None:
print(default)
else:
print('true' if val is True else ('false' if val is False else str(val)))
except Exception:
print(sys.argv[3] if len(sys.argv) > 3 else 'true')
PYEOF
}

# Check master switch first — if disabled, exit immediately (no coaching either)
ENABLED="$(read_config enabled true)"
if [[ "$ENABLED" == "false" ]]; then
log_fire "allow-config-disabled" "tool=${TOOL_NAME} enabled=false"
exit 0
fi

# Read individual blocking flags (defaults: both true)
SINGLE_TASK_BLOCKING="$(read_config singleTaskBlocking true)"
REQUIRE_VALIDATION_STEPS="$(read_config requireValidationSteps true)"

# ---- Reminder text (verbatim per Nate 2026-05-17 02:53Z + 04:06Z) -----------

GENERIC_REMINDER='Remember, only work on one task at a time. Any task that isn'\''t atomic becomes a "break down" task to make atomic tasks. If the next task is too vague, break it down into new smaller atomic Tasks, complete the breakdown task, and work on a single atomic task.'
Expand Down Expand Up @@ -201,35 +247,37 @@ esac
# ---- Deny path: pending→in_progress validation-steps required ---------------

if [[ "$NEW_STATUS" == "in_progress" ]]; then
# 0-or-1 invariant first — count only tasks where metadata.assignee is
# empty or "alex" (sub-agent-assigned tasks live in their own accounting
# per assignee-design.md §3 and don't gate the parent).
OTHERS=""
if [[ -d "$TASKS_DIR" ]]; then
while IFS= read -r -d '' f; do
f_id="$(jq -r '.id // empty' "$f" 2>/dev/null)"
[[ "$f_id" == "$TASK_ID" ]] && continue
f_status="$(jq -r '.status // empty' "$f" 2>/dev/null)"
f_assignee="$(jq -r '.metadata.assignee // empty' "$f" 2>/dev/null)"
case "$f_assignee" in
""|alex) : ;;
*) continue ;;
esac
if [[ "$f_status" == "in_progress" ]]; then
f_subj="$(jq -r '.subject // empty' "$f" 2>/dev/null)"
OTHERS+="#${f_id} (${f_subj}); "
fi
done < <(find "$TASKS_DIR" -maxdepth 1 -name '*.json' -print0 2>/dev/null)
fi
if [[ -n "$OTHERS" ]]; then
REASON="Cannot move task #${TASK_ID} to in_progress — already in_progress: ${OTHERS%; }. Project rule: exactly 0 or 1 task may be in_progress. Pick one: (a) complete current via TaskUpdate status=completed, (b) move it back to pending via TaskUpdate status=pending, (c) create a sub-task via TaskCreate to capture the new direction (the in-progress task may itself be a planning/break-down task — see worked example at https://github.com/nsheaps/agents/blob/main/docs/journal/2026/05/16/managing-tasks-example.md). Use sequential-thinking; append a dated event-log line to the in-progress task's description noting the pivot before changing state."
log_fire "deny" "task=${TASK_ID} reason=in_progress-conflict conflict=${OTHERS%; }"
emit_decision "deny" "$REASON" ""
exit 0
# 0-or-1 invariant — only enforced when singleTaskBlocking is true.
# Count only tasks where metadata.assignee is empty or "alex" (sub-agent-assigned
# tasks live in their own accounting per assignee-design.md §3 and don't gate parent).
if [[ "$SINGLE_TASK_BLOCKING" == "true" ]]; then
OTHERS=""
if [[ -d "$TASKS_DIR" ]]; then
while IFS= read -r -d '' f; do
f_id="$(jq -r '.id // empty' "$f" 2>/dev/null)"
[[ "$f_id" == "$TASK_ID" ]] && continue
f_status="$(jq -r '.status // empty' "$f" 2>/dev/null)"
f_assignee="$(jq -r '.metadata.assignee // empty' "$f" 2>/dev/null)"
case "$f_assignee" in
""|alex) : ;;
*) continue ;;
esac
if [[ "$f_status" == "in_progress" ]]; then
f_subj="$(jq -r '.subject // empty' "$f" 2>/dev/null)"
OTHERS+="#${f_id} (${f_subj}); "
fi
done < <(find "$TASKS_DIR" -maxdepth 1 -name '*.json' -print0 2>/dev/null)
fi
if [[ -n "$OTHERS" ]]; then
REASON="Cannot move task #${TASK_ID} to in_progress — already in_progress: ${OTHERS%; }. Project rule: exactly 0 or 1 task may be in_progress. Pick one: (a) complete current via TaskUpdate status=completed, (b) move it back to pending via TaskUpdate status=pending, (c) create a sub-task via TaskCreate to capture the new direction (the in-progress task may itself be a planning/break-down task — see worked example at https://github.com/nsheaps/agents/blob/main/docs/journal/2026/05/16/managing-tasks-example.md). Use sequential-thinking; append a dated event-log line to the in-progress task's description noting the pivot before changing state."
log_fire "deny" "task=${TASK_ID} reason=in_progress-conflict conflict=${OTHERS%; }"
emit_decision "deny" "$REASON" ""
exit 0
fi
fi

# Validation-steps required (rule 2)
if [[ "$VS_UNCHECKED" -lt 1 ]]; then
# Validation-steps required (rule 2) — only enforced when requireValidationSteps is true.
if [[ "$REQUIRE_VALIDATION_STEPS" == "true" && "$VS_UNCHECKED" -lt 1 ]]; then
REASON="Cannot move task #${TASK_ID} to in_progress — the task description has no <validation-steps> block with at least one unchecked \"- [ ]\" item. Add validation steps that capture the pass/fail criteria for this task, in this format inside the description:

<validation-steps>
Expand All @@ -247,19 +295,21 @@ fi
# ---- Deny path: in_progress→completed validation-steps complete + RESULT ----

if [[ "$NEW_STATUS" == "completed" && "$CURRENT_STATUS" == "in_progress" ]]; then
# Rule 3: every step must be checked
if [[ "$VS_UNCHECKED" -gt 0 ]]; then
REASON="Cannot move task #${TASK_ID} to completed — ${VS_UNCHECKED} validation step(s) remain unchecked in <validation-steps>. Either complete the work (and check each item + add a RESULT(...) line as evidence), or park the task back to pending via TaskUpdate(status=pending) to keep the lifecycle honest. Note: pending→completed (without ever transitioning to in_progress) IS allowed when no validation is intended (cancellation, immediate-no-op tasks, etc.) — rule 4."
log_fire "deny" "task=${TASK_ID} reason=validation-incomplete unchecked=${VS_UNCHECKED}"
emit_decision "deny" "$REASON" ""
exit 0
fi
# Rule 3 part 2: each [x] step must have a RESULT line
if [[ -n "$VS_MISSING_RESULT" ]]; then
REASON="Cannot move task #${TASK_ID} to completed — the following checked validation step(s) are missing RESULT lines (1-based item indices): ${VS_MISSING_RESULT}. Add a RESULT(<timestamp>[, by (\$agentName|Agent(\$subAgentId))]): <evidence> line directly after each \"- [x]\" item documenting how you verified it. Example: RESULT(2026-05-17 04:06Z, by alex): log line \"X\" in /path/to/log confirms."
log_fire "deny" "task=${TASK_ID} reason=missing-result indices=${VS_MISSING_RESULT}"
emit_decision "deny" "$REASON" ""
exit 0
# Rule 3: every step must be checked — only enforced when requireValidationSteps is true.
if [[ "$REQUIRE_VALIDATION_STEPS" == "true" ]]; then
if [[ "$VS_UNCHECKED" -gt 0 ]]; then
REASON="Cannot move task #${TASK_ID} to completed — ${VS_UNCHECKED} validation step(s) remain unchecked in <validation-steps>. Either complete the work (and check each item + add a RESULT(...) line as evidence), or park the task back to pending via TaskUpdate(status=pending) to keep the lifecycle honest. Note: pending→completed (without ever transitioning to in_progress) IS allowed when no validation is intended (cancellation, immediate-no-op tasks, etc.) — rule 4."
log_fire "deny" "task=${TASK_ID} reason=validation-incomplete unchecked=${VS_UNCHECKED}"
emit_decision "deny" "$REASON" ""
exit 0
fi
# Rule 3 part 2: each [x] step must have a RESULT line
if [[ -n "$VS_MISSING_RESULT" ]]; then
REASON="Cannot move task #${TASK_ID} to completed — the following checked validation step(s) are missing RESULT lines (1-based item indices): ${VS_MISSING_RESULT}. Add a RESULT(<timestamp>[, by (\$agentName|Agent(\$subAgentId))]): <evidence> line directly after each \"- [x]\" item documenting how you verified it. Example: RESULT(2026-05-17 04:06Z, by alex): log line \"X\" in /path/to/log confirms."
log_fire "deny" "task=${TASK_ID} reason=missing-result indices=${VS_MISSING_RESULT}"
emit_decision "deny" "$REASON" ""
exit 0
fi
fi
fi

Expand All @@ -271,6 +321,6 @@ else
ADDITIONAL_CONTEXT="${GENERIC_REMINDER}"
fi

log_fire "allow" "tool=${TOOL_NAME}${NEW_STATUS:+ new_status=$NEW_STATUS} current_status=${CURRENT_STATUS:-none} unchecked=${VS_UNCHECKED} checked=${VS_CHECKED}"
log_fire "allow" "tool=${TOOL_NAME}${NEW_STATUS:+ new_status=$NEW_STATUS} current_status=${CURRENT_STATUS:-none} unchecked=${VS_UNCHECKED} checked=${VS_CHECKED} singleTaskBlocking=${SINGLE_TASK_BLOCKING} requireValidationSteps=${REQUIRE_VALIDATION_STEPS}"
emit_decision "allow" "" "$ADDITIONAL_CONTEXT"
exit 0
Loading