@dtlabs/ai-toolkit 0.13.0 → 0.14.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.
@@ -0,0 +1,319 @@
1
+ # Task Executor CLI
2
+
3
+ > Documents the **real, currently-implemented** behavior of `ai-toolkit executor <subcommand>`
4
+ > (`bin/cli.js`'s `handleExecutorCommand`, wired to `lib/task-executor/index.js`), part of
5
+ > FTR-018 (deterministic task execution, checkpoints and resume).
6
+ >
7
+ > This file describes what the code actually does today, not the aspirational CLI contract in
8
+ > `FTR-018-Tech-Spec.md` section 10. Every discrepancy between the two is called out explicitly
9
+ > below rather than silently reconciled.
10
+
11
+ ---
12
+
13
+ ## Invocation
14
+
15
+ ```bash
16
+ ai-toolkit executor <subcommand> [flags]
17
+ # or, inside this repo:
18
+ node bin/cli.js executor <subcommand> [flags]
19
+ ```
20
+
21
+ `--project <path>` is accepted by every subcommand and defaults to `process.cwd()` when omitted.
22
+ All other flags are per-subcommand (see below). Any flag not explicitly recognized for a
23
+ subcommand is silently ignored — there is no generic flag-forwarding path, so nothing reaches a
24
+ filesystem path or lock primitive except the whitelisted flags read via `getFlag()`/`hasFlag()`.
25
+
26
+ **Discrepancy vs. Tech-Spec:** section 10 documents `--format json or text (default)` as a
27
+ common flag. The real CLI has no `--format` flag at all — `handleExecutorCommand` always emits
28
+ JSON (`JSON.stringify(result, null, 2)`) on success and always emits JSON on error. There is no
29
+ text-mode output in the current implementation.
30
+
31
+ ---
32
+
33
+ ## Commands
34
+
35
+ ### `start`
36
+
37
+ ```bash
38
+ ai-toolkit executor start --project <path> --feature <path> [--max-concurrency N] [--claude-path <path>] [--task-timeout-ms <ms>] [--agent-budget-usd <amount>]
39
+ ```
40
+
41
+ | Flag | Required | Default | Notes |
42
+ |---|---|---|---|
43
+ | `--project` | no | `process.cwd()` | Repo root; also resolves the common Git directory |
44
+ | `--feature` | **yes** | — | Path to the feature's `feature.md`; the CLI usage-errors (exit 2, no JSON) if omitted |
45
+ | `--max-concurrency` | no | `1` | Forwarded to `execute()` as a Number; only `1` is currently supported (see below) |
46
+ | `--claude-path` | no | — | Absolute path to the qualified Claude CLI executable |
47
+ | `--task-timeout-ms` | no | — | Forwarded as a Number |
48
+ | `--agent-budget-usd` | no | — | Forwarded as a Number; persisted into run config, not itself enforced |
49
+
50
+ Backs `lib/task-executor/index.js`'s `execute()`. Validates gates/config, acquires the
51
+ repo-wide execution lease for a brand-new run, builds an immutable plan snapshot from the
52
+ feature's own `{PREFIX}-Work-Breakdown.md/.csv`, and drives a sequential (slot count 1)
53
+ dispatch → verify → review → checkpoint loop to completion or block.
54
+
55
+ Only `maxConcurrency=1` is implemented — any other requested value is **rejected**
56
+ (`UNSUPPORTED_CONCURRENCY`), never silently downgraded. `execute()` also refuses to run at all
57
+ on any platform other than `win32` (see "Platform qualification" below), before touching any
58
+ other validation.
59
+
60
+ ### `status`
61
+
62
+ ```bash
63
+ ai-toolkit executor status --project <path> --run-id <uuid>
64
+ ```
65
+
66
+ `--run-id` is required (usage error, exit 2, if omitted). Read-only summary of a run: task
67
+ status counts, current run status, and total cost from the ledger.
68
+
69
+ **Not implemented.** `status()` in `lib/task-executor/index.js` is currently a stub that always
70
+ throws `NOT_IMPLEMENTED` (attributed to US-06-TASK-BE-01, which has not built its real body
71
+ yet). Calling `ai-toolkit executor status` today always fails with exit code 1 and an error
72
+ JSON body — it does not return the summary shape documented in its own JSDoc until that task is
73
+ implemented.
74
+
75
+ ### `diagnose`
76
+
77
+ ```bash
78
+ ai-toolkit executor diagnose --project <path> --run-id <uuid>
79
+ ```
80
+
81
+ `--run-id` is required. Intended as a read-only detailed evidence dump (per-task/attempt
82
+ evidence, ownership/lease state, repair suggestions).
83
+
84
+ **Not implemented.** Same situation as `status`: `diagnose()` is a stub that always throws
85
+ `NOT_IMPLEMENTED` (attributed to US-06-TASK-BE-02). Calling it today always exits 1 with an
86
+ error JSON body.
87
+
88
+ ### `stop`
89
+
90
+ ```bash
91
+ ai-toolkit executor stop --project <path> --run-id <uuid> [--mode graceful|immediate]
92
+ ```
93
+
94
+ | Flag | Required | Default | Notes |
95
+ |---|---|---|---|
96
+ | `--run-id` | **yes** | — | |
97
+ | `--mode` | no | `'graceful'` | Only `'graceful'` or `'immediate'` are accepted; anything else throws `STOP_VALIDATION_ERROR` |
98
+
99
+ Persists a stop request and acknowledges receipt only — it never itself claims the run has
100
+ stopped.
101
+
102
+ - `graceful` (default): persists the request; the already-running `execute()` loop is the only
103
+ thing that actually enforces the block, at its next natural iteration boundary.
104
+ - `immediate`: additionally attempts real termination of every in-flight attempt (stage
105
+ `dispatching`/`running` with a recorded process tag), via the same Windows-only tagged-process
106
+ tree-kill technique used elsewhere in this module, then polls for confirmed termination.
107
+
108
+ Idempotent: if the run's current status is already `stopping` or settled
109
+ (`completed`/`blocked`/`superseded`/`paused`), this call is a pure no-op and reports the
110
+ originally-recorded mode.
111
+
112
+ ### `reconcile`
113
+
114
+ ```bash
115
+ ai-toolkit executor reconcile --run-id <uuid> [--project <path>]
116
+ ```
117
+
118
+ `--run-id` is required. `--project` resolves the execution root and the current-branch task ref
119
+ automatically (via `git rev-parse --abbrev-ref HEAD` in the given `--project`, or its default,
120
+ the current directory) — there is no separate flag for these; the CLI derives them itself.
121
+
122
+ Reconciles persisted state and evidence for a run and classifies every task's current attempt.
123
+ **Only repairs proven gaps — it never dispatches new work and never creates new integrations.**
124
+ The only two repairs it can apply are: registering a commit SHA when a checkpoint-prepared
125
+ attempt's tip exactly matches its own persisted checkpoint intent, and finalizing a task
126
+ checkpoint when a committed attempt's registered SHA is confirmed reachable but the task's
127
+ status was not yet advanced. Refuses to proceed (`COMPETING_COORDINATOR_LIVE` /
128
+ `COMPETING_COORDINATOR_LIVENESS_UNKNOWN`) if a different, live-or-unknown-liveness coordinator
129
+ holds the repo-wide execution lease.
130
+
131
+ ### `resume`
132
+
133
+ ```bash
134
+ ai-toolkit executor resume --run-id <uuid> [--project <path>]
135
+ ```
136
+
137
+ Same flags/derivation as `reconcile` (in fact the CLI dispatches both `reconcile` and `resume`
138
+ through the identical flag-parsing branch, selecting the target function by subcommand name).
139
+ Enforces the same "no live competing coordinator" precondition, then calls `reconcile()` to
140
+ apply its repairs, then reports each task's current status plus the next-safe-action
141
+ classification `reconcile()` already computed. Does **not** dispatch new work itself — there is
142
+ no run loop wired into `resume()` yet (the real dispatch loop lives in `execute()`); its scope
143
+ ends at "reconcile, then report what is safe to continue."
144
+
145
+ ### `replan`
146
+
147
+ ```bash
148
+ ai-toolkit executor replan --run-id <uuid> --feature <path> [--project <path>] [--task-mapping-json '<json>']
149
+ ```
150
+
151
+ | Flag | Required | Default | Notes |
152
+ |---|---|---|---|
153
+ | `--run-id` | **yes** | — | The original run being replanned |
154
+ | `--feature` | **yes** | — | Path to the **successor** feature's `feature.md` (the approved successor plan) |
155
+ | `--project` | no | `process.cwd()` | Resolves execution root + current-branch task ref, same as `reconcile`/`resume` |
156
+ | `--task-mapping-json` | no | `'[]'` (empty array) | JSON array of `{oldTaskId, newTaskId}` objects |
157
+
158
+ **Discrepancy vs. Tech-Spec:** the table in section 10 lists only `--run-id`/`--feature` for
159
+ `replan`. The real `replan()` in `lib/task-executor/index.js` requires `args.taskMapping` to be
160
+ an array (throwing `DISPATCH_VALIDATION_ERROR` otherwise) and never invents one itself. The CLI
161
+ therefore added `--task-mapping-json` as a necessary extension beyond the literal table, using
162
+ the same `--*-json` structured-input convention the `ledger` subcommand already established
163
+ (`--metadata-json`). This is documented here as an intentional, flagged gap — not silently
164
+ papered over — per the implementing agent's own note at the flag's parse site in `bin/cli.js`.
165
+ Omitting the flag defaults to an empty mapping (a valid "no task carries forward" array), never
166
+ a rejection.
167
+
168
+ Grants **no approval itself** — the successor's Gate 2 approval must already exist on disk
169
+ (`## Gate 2` heading in its `Approvals.md`, produced entirely outside the executor by the normal
170
+ `implement-feature` gate protocol). Does not dispatch, start, or touch the successor run in any
171
+ way. Validates the caller-supplied old→new task mapping against both the original run's recorded
172
+ tasks and the successor's plan snapshot, computes carry-completion eligibility per pair
173
+ (read-only), refuses if any original-run attempt's worker liveness is not confirmed dead
174
+ (`LIVE_WORKER_BLOCKS_REPLAN`), then records the mapping as a durable intent and marks the
175
+ original run `superseded`. Never deletes or rewrites any task/attempt evidence.
176
+
177
+ ---
178
+
179
+ ## Exit codes
180
+
181
+ | Exit code | Meaning |
182
+ |---|---|
183
+ | 0 | Successful operation/request |
184
+ | 1 | Unexpected I/O (also the default for any unmapped/unknown error code) |
185
+ | 2 | Invalid config/input |
186
+ | 3 | Ownership/live worker conflict |
187
+ | 4 | Evidence/plan/approval mismatch |
188
+ | 5 | Worker/verification/review failure |
189
+ | 6 | Integration conflict |
190
+ | 7 | Runtime not qualified |
191
+ | 8 | Paused awaiting human decision |
192
+
193
+ This table matches `bin/cli.js`'s `EXECUTOR_EXIT_CODE_BY_ERROR_CODE` mapping (plus a
194
+ suffix-based catch-all: any error code ending in `_VALIDATION_ERROR` maps to exit 2). A few
195
+ notable groupings from that mapping:
196
+
197
+ - **3** — every lease/ownership code: `LEASE_HELD`, `LEASE_NOT_OWNER`, `LEASE_STILL_LIVE`,
198
+ `LEASE_LIVENESS_UNKNOWN`, `LEASE_NOT_FOUND`, `GUARD_HELD`, `NO_LOCK_HELD`,
199
+ `COMPETING_COORDINATOR_LIVE`, `COMPETING_COORDINATOR_LIVENESS_UNKNOWN`,
200
+ `LIVE_WORKER_BLOCKS_REPLAN`.
201
+ - **4** — every "plan/evidence/approval doesn't match" code, including
202
+ `SUCCESSOR_NOT_APPROVED`, `PLAN_NOT_FOUND`, `PLAN_PARSE_ERROR`, `REPLAN_UNKNOWN_TASK_MAPPING`,
203
+ `STATE_NOT_FOUND`/`STATE_TASK_NOT_FOUND`/`STATE_ATTEMPT_NOT_FOUND`, `TREE_MISMATCH`,
204
+ `RECEIPT_CONFLICT`, `INTENT_CONFLICT`, and many more (see the source comment for the full
205
+ list).
206
+ - **5** — `CLAUDE_SPAWN_FAILED`, `AGENT_NOT_VERIFIED`, `RESOLUTION_FAILED`.
207
+ - **6** — `INTEGRATION_CONFLICT`.
208
+ - **7** — `PLATFORM_NOT_QUALIFIED` (see "Platform qualification" below).
209
+ - **1 (default)** — includes `NOT_IMPLEMENTED` explicitly, so `status`/`diagnose` calls exit 1
210
+ today, not some special code.
211
+
212
+ **Exit code 8 is a success-path outcome, never a thrown error** — unlike every other code
213
+ above, which is assigned only on the error path via `mapExecutorErrorToExitCode`. Per the
214
+ Tech-Spec, 8 means "paused awaiting human decision": `execute()`'s and `resume()`'s result
215
+ objects can both legitimately carry `runStatus: 'paused'` (set when a `stop()` request — either
216
+ mode — was observed at a loop boundary; `resume()` simply passes through whatever `runStatus`
217
+ was last persisted, including `'paused'`). `handleExecutorCommand`'s shared `emitSuccess()`
218
+ inspects `result.runStatus` and sets exit code **8** when it is `'paused'`, else **0** — this is
219
+ a no-op check for `reconcile`/`stop`/`replan` (their result shapes never carry a `runStatus`
220
+ field at all) and never applies to `status`/`diagnose` (still `NOT_IMPLEMENTED`, so they never
221
+ reach the success path).
222
+
223
+ ---
224
+
225
+ ## JSON result schema
226
+
227
+ ### Success (stdout, exit matches the table above)
228
+
229
+ Every command that has a real implementation returns a **versioned result object** —
230
+ `{ protocolVersion, ... }` — written to stdout as pretty-printed JSON. Per
231
+ `lib/task-executor/index.js`'s own JSDoc/return statements:
232
+
233
+ **`start` (`execute()`) and `resume`:**
234
+ ```json
235
+ {
236
+ "protocolVersion": 1,
237
+ "runId": "<uuid>",
238
+ "runStatus": "completed | blocked | paused",
239
+ "tasks": [ { "taskId": "US-01-T01", "status": "checkpointed" } ]
240
+ }
241
+ ```
242
+ `resume`'s object additionally carries `tasks[].nextAction` (the next-safe-action classification
243
+ from `reconcile()`) and a top-level `repairsApplied` array — both additive beyond the minimum
244
+ shape above.
245
+
246
+ **`stop`:**
247
+ ```json
248
+ {
249
+ "protocolVersion": 1,
250
+ "runId": "<uuid>",
251
+ "requestAccepted": true,
252
+ "mode": "graceful | immediate",
253
+ "idempotent": true,
254
+ "terminationConfirmed": true,
255
+ "killAttempts": [
256
+ { "taskId": "US-01-T01", "attemptNumber": 1, "processIdentity": "ai-toolkit-task:...", "confirmed": true }
257
+ ]
258
+ }
259
+ ```
260
+ `idempotent` is present only on an idempotent replay. `terminationConfirmed`/`killAttempts` are
261
+ present only for a newly-accepted `immediate` request — never for `graceful`, never for an
262
+ idempotent replay.
263
+
264
+ **`reconcile`:**
265
+ ```json
266
+ {
267
+ "protocolVersion": 1,
268
+ "runId": "<uuid>",
269
+ "repairsApplied": [ { "taskId": "US-01-T01", "action": "registerCommitSHA" } ],
270
+ "classifications": [ { "taskId": "US-01-T01", "classification": "...", "action": "..." } ]
271
+ }
272
+ ```
273
+
274
+ **`replan`:**
275
+ ```json
276
+ {
277
+ "protocolVersion": 1,
278
+ "originalRunId": "<uuid>",
279
+ "successorPlanDigest": "<digest>",
280
+ "taskMapping": [
281
+ { "oldTaskId": "US-01-T01", "newTaskId": "US-01-T01", "carriesCompletion": true }
282
+ ]
283
+ }
284
+ ```
285
+
286
+ **`status`/`diagnose`:** no success shape exists yet — both are `NOT_IMPLEMENTED` stubs (see
287
+ above); every call surfaces the error shape below with `code: "NOT_IMPLEMENTED"`.
288
+
289
+ ### Error (stderr, exit per the table above)
290
+
291
+ ```json
292
+ {
293
+ "status": "error",
294
+ "code": "NO_LOCK_HELD",
295
+ "message": "dispatchTaskAttempt: no repo-wide execution lease held by runId \"...\" at ..."
296
+ }
297
+ ```
298
+
299
+ This is `bin/cli.js`'s real `emitError()` shape: `code` falls back to `'UNKNOWN'` if the thrown
300
+ error carries no `.code`; `message` falls back to `String(err)` if the error carries no
301
+ `.message`.
302
+
303
+ A malformed CLI invocation (e.g. missing a required flag) is a **usage error**, not one of the
304
+ JSON error shapes above: it writes a plain `Error: <message>` line to stderr and sets exit code 2
305
+ directly, without going through `mapExecutorErrorToExitCode`.
306
+
307
+ ---
308
+
309
+ ## Platform qualification
310
+
311
+ `execute()` checks `process.platform !== 'win32'` as its very first validation step — before any
312
+ other argument validation, before deriving the execution root, before touching the plan
313
+ snapshot, and before acquiring the ownership lease — and throws `PLATFORM_NOT_QUALIFIED` (exit
314
+ 7) on any non-Windows platform. This reflects FTR-018's Gate-1 binding constraint: the E-02
315
+ process-supervision evidence (tree-kill via `taskkill`, tag-based liveness scanning via
316
+ `Win32_Process` CIM queries) that the executor's crash-recovery/stop/worker-liveness machinery
317
+ depends on is qualified for Windows only (US-08-TASK-BE-05). There is no implicit
318
+ cross-platform guarantee — other platforms require their own dedicated qualification proof
319
+ before `start` will run on them at all.
@@ -0,0 +1,95 @@
1
+ # Deterministic workflow control plane
2
+
3
+ The toolkit owns workflow execution in Node. An LLM is used only for document
4
+ generation, review, and assessment. A workflow script is not a registered worker
5
+ agent: never pass `pm-phase1`, `pm-phase2`, `pm-phase3`, `am-phase1`, or `am-phase2`
6
+ to `agentType`, `subagent_type`, or Claude's `--agent`.
7
+
8
+ ## Invocation
9
+
10
+ Reinstall the toolkit runtime assets after upgrading the package. The host rejects
11
+ older installed workflows without `controlPlaneVersion: 1`, even if their old
12
+ manifest hashes match. Use one installation scope (local or global); ambiguous or
13
+ unverified agent installations fail closed.
14
+
15
+ ```text
16
+ ai-toolkit workflow run pm-phase1 --project . --claude-path "C:\path\claude.exe" -- "internal_docs/features/FTR-123-example/feature.md"
17
+ ai-toolkit workflow run pm-phase2 --project . --claude-path "C:\path\claude.exe" -- "internal_docs/features/FTR-123-example/feature.md"
18
+ ai-toolkit workflow run am-phase1 --project . --claude-path "C:\path\claude.exe" -- . --scope=security,devops --prefix ASSESS-123
19
+ ai-toolkit workflow run am-phase2 --project . -- --prefix ASSESS-123 --flagged INT-001 --ack "Reviewed by the user"
20
+ ```
21
+
22
+ Resolve the actual Claude executable as described in `task-executor-bootstrap.md`.
23
+ `--home DIR` is available for explicit installation-scope resolution. Paths are
24
+ individual argv elements, never interpolated shell commands. Raw string arguments
25
+ in the JavaScript API support quoted paths and preserve Windows backslashes.
26
+
27
+ Exit 0 returns a JSON payload on stdout. A nonzero exit stops the pipeline; do not
28
+ present the next approval gate. Gate 1/Gate 2 and Findings Gate decisions remain in
29
+ the main loop. `am-phase2` requires explicit acknowledgement and a validated
30
+ intervention selection; it does not invent approval.
31
+
32
+ `pm-phase3` is a retired compatibility entry point and refuses execution. Since
33
+ FTR-018, implementation belongs to `ai-toolkit tasks start/run`, with its task
34
+ ownership, checkpoints, verification, review and resume protocol. The old workflow
35
+ must not provide a fallback execution path. No JobScheduler feature is involved
36
+ in this migration.
37
+
38
+ ## Audit and replacements
39
+
40
+ | Original location | Deterministic operation formerly delegated to `agent()` | Replacement |
41
+ |---|---|---|
42
+ | pm-phase1 | agent resolution; self/worker ledger open/close | Verified host dispatch and owned activity lifecycle |
43
+ | pm-phase1 | feature discovery, prefix, existence/mtime checks, ledger creation | Direct filesystem operations; freshness includes upstream documents; corrupt ledgers are preserved and rejected |
44
+ | pm-phase1 | validation report existence/fabrication, process log writes | Require a nonempty fresh report from the validator; append log directly; structured validation verdict |
45
+ | pm-phase2 | resolution and ledger operations | Same host lifecycle; unsuccessful results cannot close the owner as done |
46
+ | pm-phase2 | validator/renderer execution, exit/output parsing and file checks | Toolkit CLI subprocess, `shell: false`, argv arrays, actual exit status, JSON parsing and disk reads |
47
+ | pm-phase2 | raw pricing read, WB metrics, Token/Effort estimates, process log | Direct JSON reads and deterministic templates; unavailable usage/cost stays unavailable |
48
+ | pm-phase3 | resolution, all ledger mutations, raw CSV/ledger/pricing reads, issue counts, test/git/PR commands, actuals writes | Legacy execution removed; implementation and CSV parsing already belong to the FTR-018 executor |
49
+ | am-phase1 | discovery CLI, catalog/file reads, scope filtering, estimates/index counts | Canonical assessor map, verified resolution, strict scope filtering and deterministic Markdown parsing/templates |
50
+ | am-phase2 | approval file, intervention selection, registry read/write/counts | Deterministic recorder with explicit input and readback |
51
+ | FTR-018 process helper | worker identity cache and dispatch identity | Reject orchestrators; require requested canonical ID/native name agreement; cache by project + home + ID |
52
+
53
+ Architecture, concurrency and quality have dedicated assessors. Security, DevOps,
54
+ domain-model and dependencies use the generic assessor with explicit scope
55
+ instructions. Multiple generic scopes are combined into one dispatch to avoid
56
+ parallel writers overwriting the same report. Remediation agents are recommendations
57
+ in intervention documents only; they are never used as assessors.
58
+
59
+ ## Identity, state and ownership
60
+
61
+ Worker resolution checks the canonical catalog ID, verified status, exact native
62
+ name, current definition hash, agent frontmatter name/model, and worker kind.
63
+ Workflow resolution is separate and can never authorize worker dispatch.
64
+
65
+ Every invocation has a unique owner and activity IDs. A per-prefix lock prevents
66
+ two workflow runs from writing the same feature/assessment concurrently. The owner
67
+ opens before work, drains parallel workers, validates outputs and serializes the
68
+ result before its terminal transition. Worker usage and provenance are persisted.
69
+ Legacy `open/close/fail/skip/finalizeActivity` calls cannot mutate owned entries;
70
+ owned finalization requires the matching owner and a running entry. No name-only
71
+ fallback is used for owned transitions. Later invocations cannot reopen an old run.
72
+
73
+ The ownership token prevents accidental cross-finalization, not hostile filesystem
74
+ access: workers and the host still run under the user's OS account. This is not a
75
+ security sandbox.
76
+
77
+ A caught failure produces `failed`. A hard OS/process kill can leave `running`,
78
+ never a speculative `done`; workflow automatic resume/reconciliation is not added
79
+ by this change. Retrying uses a new owner and preserves the interrupted record.
80
+ If persistence itself fails, the error propagates rather than reporting success.
81
+
82
+ ## Verification and limits
83
+
84
+ Regression tests execute the installed workflow source through the real host,
85
+ resolver, filesystem and ledger. Integration tests invoke the actual structural
86
+ validator/renderer and workflow CLI; LLM workers are deterministic test doubles.
87
+ Coverage includes worker failure, missing outputs, corrupt ledger, late terminal
88
+ calls, parallel rejection/drain, identity mismatch, cross-project cache leakage,
89
+ negative semantic verdict, unknown scopes, paths with spaces and explicit approval.
90
+
91
+ The live paid Claude model/permission flow has not been exercised by these tests.
92
+ The subprocess adapter reuses FTR-018's existing qualification; no new cross-platform
93
+ process-supervision guarantee is claimed. On Windows, the full executor test suite
94
+ requires Git Bash ahead of the WSL launcher in PATH. User-level installations are
95
+ not modified by running the isolated test fixtures.
@@ -33,7 +33,8 @@ function _readLedger(ledgerPath) {
33
33
  }
34
34
  throw parseErr;
35
35
  }
36
- return { entries: Array.isArray(parsed) ? parsed : [] };
36
+ if (!Array.isArray(parsed)) throw new Error('Invalid ledger: expected JSON array');
37
+ return { entries: parsed };
37
38
  }
38
39
 
39
40
  function _writeLedger(ledgerPath, entries) {
@@ -340,6 +341,7 @@ function open(dir, prefix, agent, phase, model, attempt, metadata) {
340
341
  });
341
342
 
342
343
  if (existingIndex !== -1) {
344
+ _assertUnowned(entries[existingIndex]);
343
345
  // Idempotent re-open: check metadata conflict BEFORE writing anything.
344
346
  if (metadata != null) {
345
347
  if (!_metadataEqual(entries[existingIndex], metadata)) {
@@ -422,7 +424,8 @@ function close(dir, prefix, agent, tokens, attempt) {
422
424
  idx = agentIndexes[0];
423
425
  }
424
426
 
425
- const existing = entries[idx];
427
+ const existing = entries[idx];
428
+ _assertUnowned(existing);
426
429
  const completed_at = new Date().toISOString();
427
430
 
428
431
  // Token preservation (AC-10):
@@ -496,7 +499,8 @@ function fail(dir, prefix, agent, error, attempt) {
496
499
  idx = agentIndexes[0];
497
500
  }
498
501
 
499
- const existing = entries[idx];
502
+ const existing = entries[idx];
503
+ _assertUnowned(existing);
500
504
  const completed_at = new Date().toISOString();
501
505
 
502
506
  const update = {
@@ -572,7 +576,8 @@ function skip(dir, prefix, agent, phase, model, attempt) {
572
576
  }
573
577
 
574
578
  // Case 2: EXACTLY ONE match — update in place, preserving started_at and all other fields.
575
- const existing = entries[idx];
579
+ const existing = entries[idx];
580
+ _assertUnowned(existing);
576
581
  const completed_at = new Date().toISOString();
577
582
 
578
583
  entries[idx] = Object.assign({}, existing, {
@@ -589,11 +594,188 @@ function skip(dir, prefix, agent, phase, model, attempt) {
589
594
  }
590
595
  }
591
596
 
597
+ // ── finalizeActivity (INFRA-TASK-BE-02) ──────────────────────────────────────
598
+ //
599
+ // Additive terminal-state API used by the executor. Unlike close()/fail()/skip(),
600
+ // it requires an exact operation_id match (no name-only agent fallback), can
601
+ // record tokens on ANY terminal status (including 'failed'), accepts an optional
602
+ // usage_reason (additive ledger field, NOT an open() metadata whitelist key),
603
+ // and is replay-safe: finalizing an already-terminal entry with identical data
604
+ // is a no-op that returns the existing entry unchanged, while conflicting data
605
+ // fails closed without writing. It does not introduce a second ledger file or
606
+ // a duplicate token counter — it reuses phase_delta_tokens like close()/fail().
607
+
608
+ const FINALIZE_STATUSES = new Set(['done', 'failed', 'skipped']);
609
+ const TERMINAL_STATUSES = new Set(['done', 'failed', 'skipped']);
610
+
611
+ function finalizeActivity(dir, prefix, agent, attempt, options) {
612
+ if (attempt == null) attempt = 1;
613
+ options = options || {};
614
+
615
+ const status = options.status;
616
+ const tokens = (options.tokens === undefined) ? null : options.tokens;
617
+ const reason = options.reason;
618
+ const completedAtArg = options.completedAt;
619
+
620
+ if (!FINALIZE_STATUSES.has(status)) {
621
+ const err = new Error(
622
+ 'finalizeActivity: status must be one of "done", "failed", "skipped"; got ' +
623
+ JSON.stringify(status)
624
+ );
625
+ err.code = 'INVALID_STATUS';
626
+ throw err;
627
+ }
628
+
629
+ const ledgerPath = path.join(dir, prefix + '-token-ledger.json');
630
+ const lockPath = ledgerPath + '.lock';
631
+
632
+ const lockHandle = _acquireLock(lockPath);
633
+ try {
634
+ const { entries } = _readLedger(ledgerPath);
635
+ const operation_id = computeOperationId(prefix, agent, attempt);
636
+
637
+ // Exact-ID lookup only — no name-only agent fallback (unlike close/fail/skip).
638
+ const idx = entries.findIndex(function (e) {
639
+ return e.operation_id === operation_id;
640
+ });
641
+
642
+ if (idx === -1) {
643
+ const err = new Error(
644
+ 'finalizeActivity: no entry found for operation_id ' + operation_id +
645
+ ' in ' + ledgerPath + ' — exact operation_id match required, ' +
646
+ 'no name-only fallback; operation was never opened'
647
+ );
648
+ err.code = 'ACTIVITY_NOT_FOUND';
649
+ err.operation_id = operation_id;
650
+ throw err;
651
+ }
652
+
653
+ const existing = entries[idx];
654
+ _assertUnowned(existing);
655
+
656
+ const suppliedTokens = (tokens === undefined) ? null : tokens;
657
+ const suppliedReason = (reason === undefined) ? null : reason;
658
+
659
+ if (TERMINAL_STATUSES.has(existing.status)) {
660
+ // Already finalized: this is either an idempotent replay of identical
661
+ // data (return unchanged, no write) or conflicting data (fail closed).
662
+ const existingTokens = (existing.phase_delta_tokens === undefined)
663
+ ? null : existing.phase_delta_tokens;
664
+ const existingReason = (existing.usage_reason === undefined)
665
+ ? null : existing.usage_reason;
666
+ const effectiveCompletedAt = (completedAtArg != null)
667
+ ? completedAtArg : existing.completed_at;
668
+
669
+ const isIdentical =
670
+ existing.status === status &&
671
+ existingTokens === suppliedTokens &&
672
+ existingReason === suppliedReason &&
673
+ existing.completed_at === effectiveCompletedAt;
674
+
675
+ if (isIdentical) {
676
+ return { status: 'ok', operation_id: operation_id, entry: existing, replay: true };
677
+ }
678
+
679
+ const err = new Error(
680
+ 'finalizeActivity: conflicting terminal data for operation_id ' + operation_id +
681
+ ' in ' + ledgerPath + ' — existing terminal entry differs from the supplied ' +
682
+ 'status/tokens/reason/completedAt; refusing to overwrite (fail-closed)'
683
+ );
684
+ err.code = 'TERMINAL_CONFLICT';
685
+ err.operation_id = operation_id;
686
+ throw err;
687
+ }
688
+
689
+ // Not yet terminal: finalize it. Tokens are recorded even when status is
690
+ // 'failed' — first finalization records known tokens, or null plus an
691
+ // optional usage_reason explaining why usage is unavailable. Matching the
692
+ // close() token-preservation invariant: a null/undefined supplied value
693
+ // never clobbers an already-stored positive phase_delta_tokens.
694
+ const completed_at = (completedAtArg != null) ? completedAtArg : new Date().toISOString();
695
+
696
+ let phase_delta_tokens = existing.phase_delta_tokens;
697
+ if (suppliedTokens !== null) {
698
+ phase_delta_tokens = suppliedTokens;
699
+ }
700
+
701
+ const update = {
702
+ status: status,
703
+ completed_at: completed_at,
704
+ phase_delta_tokens: phase_delta_tokens,
705
+ };
706
+ if (reason !== undefined) {
707
+ update.usage_reason = reason;
708
+ }
709
+
710
+ // Merge update: Object.assign preserves unknown/legacy fields and metadata verbatim.
711
+ entries[idx] = Object.assign({}, existing, update);
712
+
713
+ _writeLedger(ledgerPath, entries);
714
+
715
+ return { status: 'ok', operation_id: operation_id, entry: entries[idx] };
716
+ } finally {
717
+ _releaseLock(lockPath, lockHandle);
718
+ }
719
+ }
720
+
721
+ // Workflow-owned entries cannot be finalized by worker/legacy CLI calls.
722
+ function _assertUnowned(entry) {
723
+ if (entry && entry.workflow_owner) {
724
+ const error = new Error('Workflow entry requires its owner capability');
725
+ error.code = 'LEDGER_OWNER_REQUIRED';
726
+ throw error;
727
+ }
728
+ }
729
+
730
+ function openOwned(dir, prefix, agent, phase, model, owner, metadata) {
731
+ if (typeof owner !== 'string' || !owner) throw new Error('Workflow owner required');
732
+ if (metadata) _validateMetadata(metadata);
733
+ const ledgerPath = path.join(dir, prefix + '-token-ledger.json');
734
+ const lockPath = ledgerPath + '.lock';
735
+ const handle = _acquireLock(lockPath);
736
+ try {
737
+ const { entries } = _readLedger(ledgerPath);
738
+ const operation_id = computeOperationId(prefix, agent, 1);
739
+ if (entries.some(e => e.operation_id === operation_id)) throw new Error('Owned operation already opened');
740
+ const entry = { operation_id, agent, phase, model, workflow_owner: owner,
741
+ status: 'running', started_at: new Date().toISOString(), completed_at: null, phase_delta_tokens: null };
742
+ if (metadata) Object.assign(entry, metadata);
743
+ entries.push(entry);
744
+ _writeLedger(ledgerPath, entries);
745
+ return entry;
746
+ } finally { _releaseLock(lockPath, handle); }
747
+ }
748
+
749
+ function finalizeOwned(dir, prefix, agent, owner, status, error, tokens) {
750
+ if (!['done', 'failed', 'skipped'].includes(status)) throw new Error('Invalid owned terminal status');
751
+ const ledgerPath = path.join(dir, prefix + '-token-ledger.json');
752
+ const lockPath = ledgerPath + '.lock';
753
+ const handle = _acquireLock(lockPath);
754
+ try {
755
+ const { entries } = _readLedger(ledgerPath);
756
+ const id = computeOperationId(prefix, agent, 1);
757
+ const entry = entries.find(e => e.operation_id === id);
758
+ if (!entry || !owner || entry.workflow_owner !== owner) throw new Error('Ledger ownership mismatch');
759
+ if (entry.status !== 'running') throw new Error('Owned terminal transition conflict');
760
+ if (tokens != null && (!Number.isInteger(tokens) || tokens <= 0)) throw new Error('Invalid owned token measurement');
761
+ if (tokens != null) entry.phase_delta_tokens = tokens;
762
+ entry.status = status;
763
+ entry.completed_at = new Date().toISOString();
764
+ if (error) entry.error = _sanitizeError(error);
765
+ _writeLedger(ledgerPath, entries);
766
+ return entry;
767
+ } finally { _releaseLock(lockPath, handle); }
768
+ }
769
+
770
+
592
771
  module.exports = {
772
+ openOwned,
773
+ finalizeOwned,
593
774
  open,
594
775
  close,
595
776
  fail,
596
777
  skip,
778
+ finalizeActivity,
597
779
  computeOperationId,
598
780
  _readLedger,
599
781
  _writeLedger,