pi-claude-supervisor 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to this project will be documented here.
4
4
 
5
+ ## [0.5.0](https://github.com/btnalit/pi-claude-supervisor/compare/v0.4.1...v0.5.0) (2026-09-14)
6
+
7
+
8
+ ### Features
9
+
10
+ * add acceptance review repair loop ([fe29c78](https://github.com/btnalit/pi-claude-supervisor/commit/fe29c78a228494768aa52b03ee1bf1545b079119))
11
+
12
+ ## [Unreleased]
13
+
14
+ ### Added
15
+
16
+ - Structured task specifications with Goal, scope, constraints, forbidden actions and multiple argv-based acceptance checks.
17
+ - Independent read-only Reviewer results with bounded findings and automatic repair rounds.
18
+ - JSONL malformed-record handling and duplicate result/permission suppression fixtures.
19
+ - Active JSONL request shutdown coverage and deterministic acceptance/review tests.
20
+
21
+ ## [0.4.1](https://github.com/btnalit/pi-claude-supervisor/compare/v0.4.0...v0.4.1) (2026-09-14)
22
+
23
+
24
+ ### Bug Fixes
25
+
26
+ * reconcile detached tmux leases and validate prompts ([d20262c](https://github.com/btnalit/pi-claude-supervisor/commit/d20262c8481ca761a48f7dc2b877951173d4681e))
27
+
5
28
  ## [0.4.0](https://github.com/btnalit/pi-claude-supervisor/compare/v0.3.0...v0.4.0) (2026-09-13)
6
29
 
7
30
 
package/README.cn.md CHANGED
@@ -51,6 +51,7 @@ Claude CLI `2.1.270` 运行,跨版本兼容性不在本轮范围内。
51
51
  ```text
52
52
  /supervise capabilities
53
53
  /supervise start inspect the current repository
54
+ /supervise start --spec ./task.json
54
55
  /supervise poll
55
56
  /supervise sessions
56
57
  /supervise recover <task-id>
@@ -61,6 +62,21 @@ Claude CLI `2.1.270` 运行,跨版本兼容性不在本轮范围内。
61
62
  /supervise resume-auto <task-id>
62
63
  ```
63
64
 
65
+ `--spec` 接受 JSON 文件;验收命令始终使用 argv 执行,不经过 shell。例如:
66
+
67
+ ```json
68
+ {
69
+ "goal": "实现请求的修改",
70
+ "scope": ["src/"],
71
+ "constraints": ["保持公共 API 兼容"],
72
+ "forbidden": ["不要发布构建产物"],
73
+ "acceptance": [
74
+ { "id": "tests", "name": "tests", "command": "npm", "args": ["test"], "required": true }
75
+ ],
76
+ "maxRepairRounds": 3
77
+ }
78
+ ```
79
+
64
80
  人工升级通知的 generic JSON 格式为:
65
81
 
66
82
  ```json
@@ -84,6 +100,12 @@ Claude CLI `2.1.270` 运行,跨版本兼容性不在本轮范围内。
84
100
  再根据任务和仓库证据自动回答;无法确定时才升级人工。如需微信内闭环,需要另建带签名验证、
85
101
  一次性 action token 和重放保护的入站 callback 服务。
86
102
 
103
+ 近期自动化目标是先稳定完成“多命令验收—独立只读 Reviewer—结构化修复轮次—再次验收”闭环。
104
+ 任务可通过 API 或 JSON spec 提供 `goal`、`scope`、`constraints`、`forbidden` 和多个
105
+ `acceptance` 命令;旧的纯文本任务继续使用默认 `git diff --check`。Reviewer 只能使用
106
+ `read`、`grep`、`find`、`ls`,不会修改工作树或批准权限。当前稳定性验证固定针对 Claude Code
107
+ `2.1.270`,暂不把多版本兼容、sandbox、低权限和网络隔离作为本阶段门禁。
108
+
87
109
  ### tmux/PTY 交互模式
88
110
 
89
111
  如果希望在可见的 Claude Code 终端中工作,可显式启用 tmux transport:
@@ -108,7 +130,9 @@ export PI_CLAUDE_SUPERVISOR_WORKER='claude --permission-mode plan'
108
130
  /supervise adopt-tmux <tmux-session-name> <task description>
109
131
  ```
110
132
 
111
- 接管会检查 cwd、pane 中的进程,并拒绝已有其他输出 pipe 的 pane;但不宣称拥有该 session。对被接管的 session,
133
+ 接管会检查 cwd、pane 中的进程,并拒绝已有其他输出 pipe 的 pane;但不宣称拥有该 session。owned session 使用自动生成的私有 tmux socket,
134
+ 请保存 `start` 输出的完整 `attach=...` 命令。Pi 重启后重新接管时,先把该命令中的 socket 路径设置到
135
+ `PI_CLAUDE_SUPERVISOR_TMUX_SOCKET`;只有默认 server 才能只使用 session 名称。对被接管的 session,
112
136
  `/supervise stop` 和 Pi 关闭只会断开监督,不会杀掉你的 tmux 窗口;需要关闭时请由你执行
113
137
  `tmux kill-session`。`/supervise takeover <task-id>` 会暂停 Decision Worker 自动发送,只有
114
138
  `/supervise resume-auto <task-id>` 才恢复。
package/README.md CHANGED
@@ -57,6 +57,7 @@ export PI_CLAUDE_SUPERVISOR_WORKER=claude
57
57
  ```text
58
58
  /supervise capabilities
59
59
  /supervise start inspect the current repository and report what should be changed
60
+ /supervise start --spec ./task.json
60
61
  /supervise sessions
61
62
  /supervise recover <task-id>
62
63
  /supervise poll
@@ -68,6 +69,22 @@ export PI_CLAUDE_SUPERVISOR_WORKER=claude
68
69
  /supervise verify
69
70
  ```
70
71
 
72
+ `--spec` accepts a JSON file; checks are always executed with argv (never through
73
+ a shell), for example:
74
+
75
+ ```json
76
+ {
77
+ "goal": "Implement the requested change",
78
+ "scope": ["src/"],
79
+ "constraints": ["Keep the public API compatible"],
80
+ "forbidden": ["Do not publish artifacts"],
81
+ "acceptance": [
82
+ { "id": "tests", "name": "tests", "command": "npm", "args": ["test"], "required": true }
83
+ ],
84
+ "maxRepairRounds": 3
85
+ }
86
+ ```
87
+
71
88
  The default MVP writes the task to the worker's stdin as plain process-pipe
72
89
  text. After running the transport spike for the target CLI, JSONL framing can
73
90
  be selected explicitly:
@@ -88,6 +105,14 @@ remaining lifecycle, signal and recovery checks. Host permissions and network
88
105
  access follow explicit caller authorization and host policy; there is no
89
106
  automatic merge, deploy, release or publish.
90
107
 
108
+ The near-term automation milestone adds a structured acceptance pipeline:
109
+ multiple argv-based checks, an independent read-only Reviewer, bounded structured
110
+ findings and repair rounds. Legacy text tasks keep the default `git diff --check`.
111
+ The Reviewer only has `read`, `grep`, `find` and `ls`; it cannot edit files or grant
112
+ permissions. Stability evidence is pinned to Claude Code `2.1.270`; CLI
113
+ multi-version compatibility, sandboxing, low-privilege execution and network
114
+ isolation are not part of this milestone.
115
+
91
116
  Automatic mode persists the Pi Decision Worker session under the configured state
92
117
  directory. After an unclean Pi restart, `/supervise sessions` lists recoverable
93
118
  tasks; `/supervise recover <task-id>` explicitly restores the Decision Worker
@@ -127,11 +152,16 @@ the task:
127
152
  ```
128
153
 
129
154
  Adoption checks the session's working directory and pane command, and refuses a
130
- pane that already has another output pipe. It does not claim ownership: `/supervise stop` and Pi shutdown detach supervision rather
131
- than killing the user's tmux session. Use `tmux kill-session` yourself when the
132
- adopted window should be closed. `/supervise takeover <task-id>` disables
133
- automatic Decision Worker messages; resume them only with
134
- `/supervise resume-auto <task-id>`.
155
+ pane that already has another output pipe. Owned sessions use a generated
156
+ private tmux socket, so preserve the complete `attach=...` command printed by
157
+ `start`. When re-adopting after a Pi restart, set
158
+ `PI_CLAUDE_SUPERVISOR_TMUX_SOCKET` to the socket path from that command before
159
+ running `adopt-tmux`; the session name alone is sufficient only for the default
160
+ server. Adoption does not claim ownership: `/supervise stop` and Pi shutdown
161
+ detach supervision rather than killing the user's tmux session. Use `tmux
162
+ kill-session` yourself when the adopted window should be closed.
163
+ `/supervise takeover <task-id>` disables automatic Decision Worker messages;
164
+ resume them only with `/supervise resume-auto <task-id>`.
135
165
 
136
166
  PTY screen text is not Claude JSONL. Permission dialogs, trust prompts and
137
167
  ambiguous TUI states are escalated to a human; tmux mode must not be treated as
@@ -136,7 +136,10 @@ cannot consume another task's lease. If post-start worker identity registration
136
136
  fails, owned workers are stopped before the lease is released; cleanup failure
137
137
  retains both the worker and lease fail-closed. Explicit `stop` cleans owned tmux
138
138
  sessions, while Pi shutdown detaches persistent sessions so they remain explicitly
139
- re-adoptable.
139
+ re-adoptable. A detached adopted session retains its lease while the verified pane
140
+ is alive; the extension periodically rechecks released sessions and removes the
141
+ lease only after the pane is confirmed gone. If that check fails, the lease is
142
+ retained rather than allowing a cwd overlap.
140
143
 
141
144
  Startup owns an `AbortController` and passes its signal to the adapter. A stop
142
145
  or shutdown request aborts the controller and calls the adapter's out-of-band
@@ -178,6 +181,29 @@ human operator; it does not attempt a second LLM fallback. Alert delivery is
178
181
  kept independent from event-log persistence so an audit write failure cannot
179
182
  suppress the alert.
180
183
 
184
+ ## Acceptance, review and repair loop
185
+
186
+ A task may provide a structured `TaskSpec` with `goal`, `scope`, `constraints`,
187
+ `forbidden` and an ordered list of required or optional acceptance checks. A
188
+ legacy plain-text task is normalized to a goal with the default `git diff
189
+ --check` acceptance check. The verifier runs every configured check with argv,
190
+ bounded output and the same deterministic command policy; a Worker completion
191
+ claim never substitutes for these results.
192
+
193
+ When automatic supervision is enabled, a successful check set is passed to a
194
+ fresh read-only Reviewer session. The Reviewer receives the task specification, repository status/diff evidence,
195
+ check results and bounded Worker completion evidence, but not the Decision Worker
196
+ conversation or control channel. It can inspect only `read`, `grep`, `find` and `ls`, and must return
197
+ `pass`, `revise` or `human` with bounded structured findings. Invalid Reviewer
198
+ output or a Reviewer API failure is a human-required condition.
199
+
200
+ A `revise` result produces an audited repair round and sends a bounded corrective
201
+ instruction to a still-live JSONL/tmux Worker. Checks and review then run again.
202
+ The repair budget defaults to three rounds; repeated findings and P0/P1 findings
203
+ stop automation and escalate. A non-persistent Worker that has already exited
204
+ cannot be silently recreated for repair; it remains failed/recoverable rather
205
+ than replaying the original task.
206
+
181
207
  ## Deliberate non-goals
182
208
 
183
209
  - automatic merge/deploy/release;
@@ -189,4 +215,7 @@ suppress the alert.
189
215
  - shell command interpolation;
190
216
  - automatic network denial or a fake domain allowlist. Network access follows
191
217
  Claude's own permission model and the command policy; suspicious download-to-
192
- shell patterns require human review rather than blanket network rejection.
218
+ shell patterns require human review rather than blanket network rejection;
219
+ - Claude CLI multi-version compatibility in the current stability milestone;
220
+ - OS sandbox, low-privilege execution and network isolation in the current
221
+ lifecycle milestone.
@@ -916,3 +916,66 @@ PTY 和 headless JSONL 只能选择一个作为 MVP 的主 transport,禁止两
916
916
  ## 附录 B:一句话版本
917
917
 
918
918
  > 先用最小、可审计、可接管的 Supervisor 闭环证明可靠性,再逐步开放 LLM 判断和自动化权限;不要从“自动化最多”开始,而要从“边界最清楚、证据最可靠”开始。
919
+
920
+ ## 20. 近期落地与剩余门禁:稳定的自动验收闭环
921
+
922
+ 本轮已落地 TaskSpec、多命令验收、独立只读 Reviewer、结构化 repair round、重复 finding/P0/P1 人工升级、JSONL 去重和确定性 replay fixture。剩余门禁是固定 CLI 的重复运行统计,而不是继续扩大安全边界。近期目标从“扩大安全边界”调整为先证明单一已验证 Claude CLI 版本上的真实功能稳定性。当前只验证固定的 Claude Code `2.1.270`,不把多版本兼容作为本阶段任务。OS sandbox、低权限用户、网络 allowlist、SBOM 和更深的供应链加固后置,不作为本阶段门禁;现有 no-shell、Policy Gate、人工接管和独立验收边界继续保留。
923
+
924
+ ### 20.1 Goal / Evidence / Sign-off 模型
925
+
926
+ 任务规格统一为:
927
+
928
+ ```yaml
929
+ goal: 实现用户邀请接口
930
+ scope:
931
+ - 新增接口
932
+ - 添加权限校验
933
+ constraints:
934
+ - 不修改核心数据库模型
935
+ forbidden:
936
+ - 不执行生产部署
937
+ acceptance:
938
+ - id: tests
939
+ command: npm
940
+ args: [test]
941
+ - id: typecheck
942
+ command: npm
943
+ args: [run, typecheck]
944
+ - id: diff-check
945
+ command: git
946
+ args: [diff, --check]
947
+ maxRepairRounds: 3
948
+ ```
949
+
950
+ 兼容旧任务时,普通任务文本作为 `goal`,默认验收仍为 `git diff --check`。所有验收命令使用 argv 和确定性 Policy Gate,不经过 shell。
951
+
952
+ 验收流程固定为:
953
+
954
+ ```text
955
+ Worker result
956
+ → 多命令 acceptance checks
957
+ → 独立只读 Reviewer
958
+ ├── pass → completed
959
+ ├── revise → 结构化修复指令 → Worker → 重新验收
960
+ └── human → 人工接管
961
+ ```
962
+
963
+ Reviewer 必须使用独立 Pi session,只允许 `read`、`grep`、`find`、`ls`,输出结构化 verdict 和 findings;不能修改工作树或直接批准权限。默认最多三轮修复;相同 finding 重复出现或出现 P0/P1 问题时升级人工。
964
+
965
+ ### 20.2 JSONL 稳定性证据
966
+
967
+ 只对 Claude Code `2.1.270` 建立证据,覆盖:
968
+
969
+ - JSONL 跨 chunk 拆分、单 chunk 多记录和 malformed 行;malformed 行不能触发完成事件;
970
+ - 重复 result、重复 permission request、重复 Supervisor idempotency key 不产生重复动作;
971
+ - active request 期间的 SIGTERM、SIGINT、stop 和 Pi shutdown;
972
+ - 普通完成、低风险 Bash allow、`AskUserQuestion` deny-to-text、多轮、验收失败修复和恢复回放。
973
+
974
+ 真实 Claude Spike 不进入普通 CI;确定性 fake Worker/replay fixture 进入 CI。自动模式继续保持显式 opt-in,直到以下门禁通过:普通任务连续十次成功,权限和问题转文本场景各至少五次成功,无重复动作、无错误 complete、无未清理 Worker,且关键事件可以完整回放。
975
+
976
+ ### 20.3 明确不属于本阶段
977
+
978
+ - Claude CLI 多版本兼容;
979
+ - OS sandbox、低权限执行和网络隔离;
980
+ - 自动 merge、deploy、release、publish;
981
+ - 多 Worker 在同一工作树协作。
package/docs/testing.md CHANGED
@@ -49,9 +49,9 @@ non-sensitive prompt, and prints protocol metadata rather than raw model output.
49
49
  It must not be added to the normal CI gate because authentication is an owner
50
50
  controlled prerequisite.
51
51
 
52
- The historical fixtures validate one prompt, multiple turns, session resume,
53
- permission allow/deny and SIGTERM/SIGINT behavior with Claude Code 2.1.268.
54
- The current release validation uses Claude Code 2.1.270 at
52
+ The transport fixtures validate one prompt, multiple turns, session resume,
53
+ permission allow/deny and SIGTERM/SIGINT behavior. The current release validation
54
+ uses Claude Code 2.1.270 at
55
55
  `/home/yancao/.local/share/mise/installs/claude/2.1.270/claude`, including real
56
56
  owned tmux turns, pause/resume, and restart re-adoption.
57
57
  The adapter regression suite also verifies event subscription, parsed
@@ -59,6 +59,11 @@ The adapter regression suite also verifies event subscription, parsed
59
59
  The automation spike additionally exercises a real Pi SDK Decision Worker with
60
60
  Claude: ordinary completion, harmless Bash permission approval, and an
61
61
  `AskUserQuestion` denial-to-text fallback followed by automatic verification.
62
+ A local pinned-CLI run completed all three scenarios with `state=completed`,
63
+ `verified=true`, and zero human interventions. Provider/model latency can still
64
+ cause a later run to fail closed as human-required after the bounded Decision
65
+ Worker or Reviewer timeout; this is evidence for the manual spike only, not a CI
66
+ guarantee.
62
67
  The extension persists each automatic Decision Worker session as Pi JSONL plus a
63
68
  0600 task mapping. Recovery is explicit and safe: after an unclean Pi restart,
64
69
  `/supervise sessions` shows the task as `recoverable`, and `/supervise recover
@@ -73,15 +78,24 @@ npm run spike:signals
73
78
  npm run spike:automation
74
79
  SPIKE_AUTOMATION_PERMISSION=1 npm run spike:automation
75
80
  SPIKE_AUTOMATION_QUESTION=1 npm run spike:automation
81
+ PI_CLAUDE_SUPERVISOR_REAL_CLAUDE_PATH=/home/yancao/.local/share/mise/installs/claude/2.1.270/claude \
76
82
  PI_CLAUDE_SUPERVISOR_REAL_CLAUDE=1 npm run spike:tmux
83
+ PI_CLAUDE_SUPERVISOR_REAL_CLAUDE_PATH=/home/yancao/.local/share/mise/installs/claude/2.1.270/claude \
84
+ PI_CLAUDE_SUPERVISOR_REAL_CLAUDE=1 npm run spike:tmux-interactive
77
85
  ```
78
86
 
79
87
  The tmux spike is gated, authenticated, and excluded from normal CI. It uses
80
- plan mode, records only protocol metadata, and verifies two real Claude turns,
81
- pause/resume, owned detach, and identity-bound restart re-adoption.
82
-
83
- For each release, pin and record the validated Claude Code version and resolved
84
- executable path. For this release the validated version is `2.1.270`. Record:
88
+ plan mode with a fixed `opus` model, records only protocol metadata, and
89
+ verifies three real Claude turns, exact screen-result markers, pause/resume,
90
+ automation enabled with human takeover, direct human PTY input, owned detach,
91
+ and identity-bound restart re-adoption. The interactive spike uses a fresh temporary
92
+ cwd to verify Claude's trust prompt, a real Bash permission prompt, an allow-once
93
+ response, and an exact result marker; it also records metadata only.
94
+
95
+ For each release, pin and record the validated Claude Code version, resolved
96
+ executable path, and model. The spikes reject an unpinned/mismatched executable
97
+ version. For this release the validated version is `2.1.270` with model `opus`.
98
+ Record:
85
99
 
86
100
  1. exact version and resolved executable path;
87
101
  2. license and source revision;
@@ -103,14 +117,17 @@ approval callbacks are deliberately not accepted without a separately
103
117
  authenticated endpoint.
104
118
 
105
119
  The tmux transport is selected with `PI_CLAUDE_SUPERVISOR_TRANSPORT=tmux`. Before
106
- release, manually verify: private-socket attach, multi-line paste, prompt
107
- stability while Claude is busy, trust/permission dialog takeover, duplicate
108
- send prevention, pane replacement refusal, pause/resume, owned-session stop,
109
- adopted-session detach/re-adoption, bounded shutdown, and Pi shutdown without
110
- closing an attached window. Use `--permission-mode plan`
111
- and read-only tools for live Claude checks. Do not run JSONL and tmux control
112
- against the same Claude process, and do not treat `capture-pane` text as a
113
- structured permission response.
120
+ release, verify: private-socket attach, multi-line paste, prompt stability while
121
+ Claude is busy, trust/permission dialog takeover, duplicate send prevention, pane
122
+ replacement refusal, pause/resume, owned-session stop, adopted-session
123
+ detach/re-adoption, bounded shutdown, and Pi shutdown without closing an
124
+ attached window. The two gated real-Claude spikes above cover the trust prompt,
125
+ permission prompt, exact output, human takeover, and adopted detach paths. Use
126
+ `--permission-mode plan` and read-only tools for ordinary live Claude checks;
127
+ the interactive spike is restricted to one harmless `rm -f` in a disposable
128
+ fresh directory. Do not run JSONL and tmux control against the same Claude
129
+ process, and do not treat `capture-pane` text as a structured permission
130
+ response.
114
131
 
115
132
  ## Failure injection
116
133
 
@@ -132,3 +149,43 @@ not eliminate the post-spawn attachment window. `SIGSTOP` and `SIGKILL` of the P
132
149
  verify and document the resulting orphan behavior.
133
150
  Default behavior must be fail-closed and leave no orphaned worker process within
134
151
  the managed process group.
152
+
153
+ ## Near-term automation acceptance gate
154
+
155
+ The next implementation milestone focuses on real stability for the pinned
156
+ Claude Code `2.1.270` CLI. It does not add a multi-version matrix or wait for
157
+ OS sandbox, low-privilege or network-isolation work.
158
+
159
+ ### Acceptance and Reviewer fixtures
160
+
161
+ Deterministic tests must cover:
162
+
163
+ - legacy text tasks normalized to a Goal with the default `git diff --check`;
164
+ - multiple required/optional checks with bounded output, timeout and exit-code evidence;
165
+ - independent read-only Reviewer pass/revise/human results;
166
+ - invalid Reviewer JSON and Reviewer API failure escalating to human;
167
+ - repair rounds, repeated finding detection, P0/P1 escalation and repair-budget exhaustion;
168
+ - completion being impossible without passing all required checks and review.
169
+
170
+ Reviewer sessions use only `read`, `grep`, `find` and `ls`; they must not modify
171
+ the worktree or send Worker input. Decision Worker and Reviewer model calls are
172
+ bounded; timeout or API failure escalates instead of auto-completing. Review reports
173
+ are persisted as bounded event payloads and are not treated as permission grants.
174
+
175
+ ### JSONL protocol and replay fixtures
176
+
177
+ The adapter/replay matrix must cover:
178
+
179
+ - JSON split across stdout chunks and multiple records in one chunk;
180
+ - malformed JSON between valid records without a false completion event;
181
+ - duplicate result and permission records without duplicate actions;
182
+ - duplicate Supervisor idempotency keys without duplicate input;
183
+ - stop, SIGTERM, SIGINT and Pi shutdown while a JSONL request is active;
184
+ - ordinary completion, low-risk permission allow, AskUserQuestion deny-to-text,
185
+ multi-turn, verifier failure/repair, takeover and explicit recovery.
186
+
187
+ Real Claude tests remain authenticated manual Spikes and are pinned to
188
+ `2.1.270`; they are not part of normal CI. Normal CI runs deterministic fake
189
+ Worker and replay fixtures. Stability evidence should include ten consecutive
190
+ ordinary automatic runs and at least five runs each for permission and question
191
+ handling, with no duplicate action, false completion or unreaped Worker.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-supervisor",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "A policy-gated Pi supervisor for observing and verifying Claude Code workers.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -57,6 +57,7 @@
57
57
  "test:pi": "node scripts/test-pi.mjs",
58
58
  "spike:transport": "node scripts/spike-claude-transport.mjs",
59
59
  "spike:tmux": "node scripts/spike-claude-tmux.mjs",
60
+ "spike:tmux-interactive": "node scripts/spike-claude-tmux-interactive.mjs",
60
61
  "spike:permissions": "node scripts/spike-claude-permissions.mjs",
61
62
  "spike:signals": "node scripts/spike-claude-signals.mjs",
62
63
  "spike:automation": "node scripts/spike-claude-automation.mjs",
@@ -0,0 +1,84 @@
1
+ import type { AcceptanceCheck, TaskSpec } from "./types.ts";
2
+
3
+ const DEFAULT_TIMEOUT_MS = 120_000;
4
+ const DEFAULT_MAX_REPAIR_ROUNDS = 3;
5
+
6
+ /** Normalize legacy plain-text tasks into the structured acceptance model. */
7
+ export function normalizeTaskSpec(value: unknown, fallbackGoal: string): TaskSpec {
8
+ if (value !== undefined && (!value || typeof value !== "object" || Array.isArray(value))) {
9
+ throw new Error("task spec must be a JSON object");
10
+ }
11
+ const source = (value ?? {}) as Record<string, unknown>;
12
+ const goal = typeof source.goal === "string" && source.goal.trim() ? source.goal.trim() : fallbackGoal.trim();
13
+ if (!goal) throw new Error("task goal must not be empty");
14
+ return {
15
+ goal,
16
+ scope: stringList(source.scope, "scope"),
17
+ constraints: stringList(source.constraints, "constraints"),
18
+ forbidden: stringList(source.forbidden, "forbidden"),
19
+ acceptance: normalizeChecks(source.acceptance),
20
+ maxRepairRounds: normalizeRepairRounds(source.maxRepairRounds),
21
+ };
22
+ }
23
+
24
+ export function defaultAcceptanceChecks(): AcceptanceCheck[] {
25
+ return [{
26
+ id: "diff-check",
27
+ name: "git diff check",
28
+ command: "git",
29
+ args: ["diff", "--check"],
30
+ required: true,
31
+ timeoutMs: DEFAULT_TIMEOUT_MS,
32
+ }];
33
+ }
34
+
35
+ function normalizeChecks(value: unknown): AcceptanceCheck[] {
36
+ if (value === undefined) return defaultAcceptanceChecks();
37
+ if (!Array.isArray(value)) throw new Error("task spec acceptance must be an array");
38
+ if (value.length === 0) return defaultAcceptanceChecks();
39
+ const checks = value.map((item, index) => normalizeCheck(item, index));
40
+ const ids = new Set<string>();
41
+ for (const check of checks) {
42
+ if (ids.has(check.id)) throw new Error(`duplicate acceptance check id: ${check.id}`);
43
+ ids.add(check.id);
44
+ }
45
+ return checks;
46
+ }
47
+
48
+ function normalizeCheck(value: unknown, index: number): AcceptanceCheck {
49
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`acceptance[${index}] must be an object`);
50
+ const source = value as Record<string, unknown>;
51
+ const id = typeof source.id === "string" && /^[A-Za-z0-9._-]+$/u.test(source.id.trim())
52
+ ? source.id.trim()
53
+ : `check-${index + 1}`;
54
+ const name = typeof source.name === "string" && source.name.trim() ? source.name.trim() : id;
55
+ const command = typeof source.command === "string" ? source.command.trim() : "";
56
+ if (!command) throw new Error(`acceptance[${index}] command must not be empty`);
57
+ const args = source.args === undefined
58
+ ? []
59
+ : Array.isArray(source.args) && source.args.every((arg) => typeof arg === "string")
60
+ ? source.args.map((arg) => arg)
61
+ : undefined;
62
+ if (!args) throw new Error(`acceptance[${index}] args must be an array of strings`);
63
+ const timeoutValue = source.timeoutMs;
64
+ const timeoutMs = timeoutValue === undefined ? DEFAULT_TIMEOUT_MS : timeoutValue;
65
+ if (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60 * 60_000) {
66
+ throw new Error(`acceptance[${index}] timeoutMs must be between 1 and 3600000`);
67
+ }
68
+ if (typeof source.required !== "undefined" && typeof source.required !== "boolean") {
69
+ throw new Error(`acceptance[${index}] required must be boolean`);
70
+ }
71
+ return { id, name, command, args, required: source.required !== false, timeoutMs };
72
+ }
73
+
74
+ function stringList(value: unknown, field: string): string[] {
75
+ if (value === undefined) return [];
76
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new Error(`task spec ${field} must be an array of strings`);
77
+ return value.map((item) => item.trim()).filter(Boolean);
78
+ }
79
+
80
+ function normalizeRepairRounds(value: unknown): number {
81
+ if (value === undefined) return DEFAULT_MAX_REPAIR_ROUNDS;
82
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > 10) throw new Error("maxRepairRounds must be between 0 and 10");
83
+ return value;
84
+ }
@@ -1,11 +1,14 @@
1
1
  import { chmod, lstat, mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import { basename, dirname, join, resolve } from "node:path";
3
3
  import { redactSensitive } from "./redaction.ts";
4
+ import { normalizeTaskSpec } from "./acceptance.ts";
5
+ import type { TaskSpec } from "./types.ts";
4
6
 
5
7
  export interface DecisionSessionRecord {
6
8
  version: 1;
7
9
  taskId: string;
8
10
  task: string;
11
+ spec?: TaskSpec;
9
12
  cwd: string;
10
13
  command: string;
11
14
  args: string[];
@@ -16,6 +19,8 @@ export interface DecisionSessionRecord {
16
19
  noOutputTimeoutMs: number;
17
20
  startedAt: string;
18
21
  turn: number;
22
+ repairRound?: number;
23
+ lastFindingSignature?: string;
19
24
  state: "active" | "closed";
20
25
  updatedAt: string;
21
26
  }
@@ -71,7 +76,7 @@ export class DecisionSessionStore {
71
76
  await this.save({ ...record, state: "closed", updatedAt: new Date().toISOString() });
72
77
  }
73
78
 
74
- async update(taskId: string, patch: Partial<Pick<DecisionSessionRecord, "turn" | "updatedAt">>): Promise<void> {
79
+ async update(taskId: string, patch: Partial<Pick<DecisionSessionRecord, "turn" | "repairRound" | "lastFindingSignature" | "updatedAt">>): Promise<void> {
75
80
  const record = await this.load(taskId);
76
81
  if (!record || record.state !== "active") return;
77
82
  await this.save({ ...record, ...patch, updatedAt: patch.updatedAt ?? new Date().toISOString() });
@@ -94,7 +99,7 @@ export class DecisionSessionStore {
94
99
  try {
95
100
  const value = JSON.parse(await readFile(this.#recordPath(taskId), "utf8")) as Partial<DecisionSessionRecord>;
96
101
  assertNoCredentialPath(typeof value.decisionSessionFile === "string" ? resolve(value.decisionSessionFile) : "");
97
- return normalizeRecord(redactRecord(value), this.#directory);
102
+ return normalizeRecord(redactRecord(value), this.#directory, taskId);
98
103
  } catch (error) {
99
104
  if (error instanceof Error && /ENOENT/u.test(error.message)) return undefined;
100
105
  throw error;
@@ -109,7 +114,8 @@ export class DecisionSessionStore {
109
114
  try {
110
115
  const value = JSON.parse(await readFile(join(this.#directory, name), "utf8")) as Partial<DecisionSessionRecord>;
111
116
  assertNoCredentialPath(typeof value.decisionSessionFile === "string" ? resolve(value.decisionSessionFile) : "");
112
- const record = normalizeRecord(redactRecord(value), this.#directory);
117
+ const expectedTaskId = name.slice(0, -".json".length);
118
+ const record = normalizeRecord(redactRecord(value), this.#directory, expectedTaskId);
113
119
  if (!options.activeOnly || record.state === "active") records.push(record);
114
120
  } catch {
115
121
  // A torn or manually edited registry record is not recoverable.
@@ -132,14 +138,17 @@ async function writeJson(path: string, value: unknown): Promise<void> {
132
138
  await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
133
139
  }
134
140
 
135
- function normalizeRecord(value: Partial<DecisionSessionRecord>, directory: string): DecisionSessionRecord {
141
+ function normalizeRecord(value: Partial<DecisionSessionRecord>, directory: string, expectedTaskId?: string): DecisionSessionRecord {
142
+ if (expectedTaskId !== undefined && value.taskId !== expectedTaskId) throw new Error("Decision Worker session task id mismatch");
136
143
  if (value.version !== 1 || typeof value.taskId !== "string" || !/^[0-9a-f-]{36}$/iu.test(value.taskId)
137
144
  || typeof value.task !== "string" || typeof value.cwd !== "string" || typeof value.command !== "string"
138
145
  || !Array.isArray(value.args) || value.args.some((arg) => typeof arg !== "string")
139
146
  || typeof value.decisionSessionFile !== "string" || (value.state !== "active" && value.state !== "closed")
140
147
  || typeof value.updatedAt !== "string" || !Number.isFinite(Date.parse(value.updatedAt))
141
148
  || !validLimit(value.maxTurns, 0) || !validLimit(value.deadlineMs, 0) || !validLimit(value.noOutputTimeoutMs, 0)
142
- || !validLimit(value.turn, 0) || (value.startedAt !== undefined && (typeof value.startedAt !== "string" || !Number.isFinite(Date.parse(value.startedAt))))) {
149
+ || !validLimit(value.turn, 0) || !validLimit(value.repairRound, 0)
150
+ || (value.lastFindingSignature !== undefined && (typeof value.lastFindingSignature !== "string" || value.lastFindingSignature.length > 128))
151
+ || (value.startedAt !== undefined && (typeof value.startedAt !== "string" || !Number.isFinite(Date.parse(value.startedAt))))) {
143
152
  throw new Error("invalid Decision Worker session record");
144
153
  }
145
154
  const decisionSessionFile = resolve(value.decisionSessionFile);
@@ -150,6 +159,7 @@ function normalizeRecord(value: Partial<DecisionSessionRecord>, directory: strin
150
159
  version: 1,
151
160
  taskId: value.taskId,
152
161
  task: value.task,
162
+ spec: normalizeTaskSpec(value.spec, value.task),
153
163
  cwd: value.cwd,
154
164
  command: value.command,
155
165
  args: [...value.args],
@@ -160,6 +170,8 @@ function normalizeRecord(value: Partial<DecisionSessionRecord>, directory: strin
160
170
  noOutputTimeoutMs: value.noOutputTimeoutMs ?? 20 * 60_000,
161
171
  startedAt: value.startedAt ?? value.updatedAt,
162
172
  turn: value.turn ?? 0,
173
+ repairRound: value.repairRound ?? 0,
174
+ ...(typeof value.lastFindingSignature === "string" ? { lastFindingSignature: value.lastFindingSignature } : {}),
163
175
  state: value.state,
164
176
  updatedAt: value.updatedAt,
165
177
  };