@dtlabs/ai-toolkit 0.16.4 → 0.16.6

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
@@ -1,4 +1,22 @@
1
1
  ## 0.1.4 (2026-07-21)
2
+ ## [0.16.6](https://github.com/dtlab-tech/ai-toolkit/compare/v0.16.5...v0.16.6) (2026-10-06)
3
+
4
+
5
+ ### Bug Fixes
6
+
7
+ * **task-executor:** drop --permission-prompts, it doesn't exist ([903dea5](https://github.com/dtlab-tech/ai-toolkit/commit/903dea58fba475e3bf5caa83a41723977f6d742e))
8
+ * **task-executor:** drop --permission-prompts, it doesn't exist ([9c49c78](https://github.com/dtlab-tech/ai-toolkit/commit/9c49c789f385e88763d606d70bdfd9653e37a948))
9
+
10
+ ## [0.16.5](https://github.com/dtlab-tech/ai-toolkit/compare/v0.16.4...v0.16.5) (2026-10-05)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **task-executor:** implement the real executor status command ([58710aa](https://github.com/dtlab-tech/ai-toolkit/commit/58710aa5824108355076df697291a39dc8cd22f1))
16
+ * **task-executor:** permissions, ledger finalize, review verdicts ([91fedef](https://github.com/dtlab-tech/ai-toolkit/commit/91fedef1e3adb0b8ca64114f786d324e43991d64))
17
+ * **task-executor:** restore permission bypass and close ledger activities ([40aa1d5](https://github.com/dtlab-tech/ai-toolkit/commit/40aa1d5ba115a2438b6af66ff26a50956b9dcd3e))
18
+ * **task-executor:** surface build/test-driven review FAIL reasons ([09c0052](https://github.com/dtlab-tech/ai-toolkit/commit/09c0052109195d2b2b2f336a7b0fa1a892512787))
19
+
2
20
  ## [0.16.4](https://github.com/dtlab-tech/ai-toolkit/compare/v0.16.3...v0.16.4) (2026-10-02)
3
21
 
4
22
 
package/bin/cli.js CHANGED
@@ -2671,14 +2671,18 @@ function _executorCurrentBranchRef(projectDir) {
2671
2671
  // METADATA_RESERVED_FIELD, METADATA_UNKNOWN_KEY, INVALID_STATUS.
2672
2672
  //
2673
2673
  // 1 (unexpected I/O) — the DEFAULT for everything else, including every
2674
- // *_CORRUPTED/CORRUPT_* code, every GIT_*_FAILED/*_FAILED git-command-
2675
- // spawn code, STATE_UNRECOVERABLE, and — explicitly, per this task's own
2676
- // brief — NOT_IMPLEMENTED. `status`/`diagnose` are still NOT_IMPLEMENTED
2677
- // stubs in lib/task-executor/index.js (attributed to US-06-TASK-BE-01/02,
2678
- // neither of which has actually built their real body yet); this CLI
2679
- // wires them through exactly like the other five commands and lets that
2680
- // error surface through this same mapping table honestly — it is not
2681
- // hidden or special-cased, it simply falls into the default bucket.
2674
+ // *_CORRUPTED/CORRUPT_* code (LEDGER_CORRUPTED/PRICING_CORRUPTED among
2675
+ // them — status()'s own ledger/pricing-file reads), every
2676
+ // LEDGER_READ_FAILED/PRICING_READ_FAILED/GIT_*_FAILED/*_FAILED
2677
+ // git-command-spawn code, STATE_UNRECOVERABLE, and — explicitly, per this
2678
+ // task's own brief — NOT_IMPLEMENTED. `diagnose` is still a
2679
+ // NOT_IMPLEMENTED stub in lib/task-executor/index.js (attributed to
2680
+ // US-06-TASK-BE-02, which has not actually built its real body yet);
2681
+ // `status` now has a real implementation (it never was actually assigned
2682
+ // a Work Breakdown task of its own — its stub's US-06-TASK-BE-01
2683
+ // attribution was stale, that task is resume/reconcile) and is wired
2684
+ // through exactly like the other commands, its errors surfacing through
2685
+ // this same mapping table honestly.
2682
2686
  //
2683
2687
  // 8 (paused awaiting human decision) has no throw site and is deliberately
2684
2688
  // absent from this table: execute()'s/resume()'s 'paused' run status is a
@@ -2725,6 +2729,7 @@ const EXECUTOR_EXIT_CODE_BY_ERROR_CODE = {
2725
2729
  // though 1 is also the default fallback below)
2726
2730
  STATE_CORRUPTED: 1, RECEIPT_CORRUPTED: 1, INTENT_CORRUPTED: 1, LEASE_CORRUPTED: 1,
2727
2731
  CORRUPT_LEDGER: 1, STATE_UNRECOVERABLE: 1, NOT_IMPLEMENTED: 1,
2732
+ LEDGER_READ_FAILED: 1, LEDGER_CORRUPTED: 1, PRICING_READ_FAILED: 1, PRICING_CORRUPTED: 1,
2728
2733
  GIT_SPAWN_FAILED: 1, GIT_STATUS_FAILED: 1, GIT_ADD_FAILED: 1, GIT_WRITE_TREE_FAILED: 1,
2729
2734
  GIT_DIFF_FAILED: 1, GIT_COMMIT_TREE_FAILED: 1, REF_UPDATE_FAILED: 1, GIT_CAT_FILE_FAILED: 1,
2730
2735
  GIT_MERGE_BASE_FAILED: 1, GIT_REV_PARSE_FAILED: 1, GIT_CHERRY_PICK_FAILED: 1,
@@ -2785,13 +2790,17 @@ async function handleExecutorCommand(argv) {
2785
2790
  function emitSuccess(result) {
2786
2791
  process.stdout.write(JSON.stringify(result, null, 2) + '\n');
2787
2792
  // Tech-Spec section 10 exit code 8 ("paused awaiting human decision") is a
2788
- // SUCCESS-path outcome, not an error — only execute()'s and resume()'s
2789
- // result objects can legitimately carry `runStatus: 'paused'` (see the
2790
- // exit-code mapping comment above EXECUTOR_EXIT_CODE_BY_ERROR_CODE for why
2791
- // no *_ERROR code maps to 8). reconcile/stop/replan results never carry a
2792
- // `runStatus` field at all, and status/diagnose never reach this success
2793
- // path (still NOT_IMPLEMENTED) — so this check is a no-op for every other
2794
- // subcommand's result shape and never needs to be scoped by subcommand.
2793
+ // SUCCESS-path outcome, not an error — execute()'s, resume()'s and now
2794
+ // status()'s result objects can legitimately carry `runStatus: 'paused'`
2795
+ // (see the exit-code mapping comment above EXECUTOR_EXIT_CODE_BY_ERROR_CODE
2796
+ // for why no *_ERROR code maps to 8). A `status` call truthfully reporting
2797
+ // a paused run surfaces the same exit 8 signal a direct execute()/resume()
2798
+ // call would — deliberate, not a special case: the exit code reflects the
2799
+ // run's actual state, not which subcommand observed it. reconcile/stop/
2800
+ // replan results never carry a `runStatus` field at all, and diagnose
2801
+ // never reaches this success path (still NOT_IMPLEMENTED) — so this check
2802
+ // stays a no-op for those subcommands' result shapes without needing to be
2803
+ // scoped by subcommand.
2795
2804
  process.exitCode = result && result.runStatus === 'paused' ? 8 : 0;
2796
2805
  }
2797
2806
 
@@ -57,12 +57,12 @@ conversation at all — the operator uses the read-only `status` subcommand docu
57
57
  ai-toolkit executor status --project <path> --run-id <uuid>
58
58
  ```
59
59
 
60
- Note, per the CLI reference, that `status` (like `diagnose`) is currently a stub that always
61
- throws `NOT_IMPLEMENTED` (exit 1) — the command surface and its intended read-only summary shape
62
- (task status counts, run status, total cost from the ledger) are defined, but the real body has
63
- not been built yet (attributed there to `US-06-TASK-BE-01`). Until it lands, checking on a
64
- running or finished executor run means reading the ledger and per-run state directly, or using
65
- `reconcile`/`resume`, both of which are fully implemented and report each task's classification.
60
+ `status` returns task status counts, run status, and (when measurable) total tokens/cost from
61
+ the run's own ledger — see `docs/task-executor-cli.md` for the exact result shape and the
62
+ null-compatibility rules around `totalTokens`/`totalCostUsd`. `diagnose` is still a stub that
63
+ always throws `NOT_IMPLEMENTED` (exit 1, attributed to `US-06-TASK-BE-02`); until it lands,
64
+ deeper per-attempt evidence inspection means using `reconcile`/`resume`, both fully implemented
65
+ and reporting each task's classification.
66
66
 
67
67
  For the full flag reference, exit-code table, and JSON result schemas of every subcommand
68
68
  (`start`, `status`, `diagnose`, `stop`, `reconcile`, `resume`, `replan`), see
@@ -57,6 +57,23 @@ Only `maxConcurrency=1` is implemented — any other requested value is **reject
57
57
  on any platform other than `win32` (see "Platform qualification" below), before touching any
58
58
  other validation.
59
59
 
60
+ **Permission bypass.** Every real implementation/review worker this loop dispatches is spawned
61
+ with `--permission-mode auto` appended to its CLI args (`EXECUTOR_PERMISSION_BYPASS_ARGS` in
62
+ `lib/task-executor/index.js`). A non-interactive `--print` invocation has no TTY to answer a
63
+ permission prompt, so without this every Write/Edit/mkdir tool call is denied by default,
64
+ regardless of task content — confirmed against a real run where every BE/FE task failed
65
+ verification because its expected files/directories were never created. `auto` alone was then
66
+ independently confirmed sufficient (a live dispatch created all of its task's expected
67
+ directories and files). An earlier version of this fix also appended `--permission-prompts
68
+ none`, citing Tech-Spec OQ-01's pre-Gate-1 spike note as having validated that exact pair —
69
+ reproduced directly against the real, installed CLI (v2.1.220): `--permission-prompts` is not a
70
+ recognized flag at all (`claude --help` lists no such option; passing it exits 1 with `error:
71
+ unknown option '--permission-prompts'` before any model/API call), so that spike note is stale or
72
+ was never run against the real executable as claimed. The mode is deliberately unscoped within
73
+ the task's own `--project` directory — the executor's job is letting a developer agent write
74
+ anywhere under that project, so (unlike pm-phase1/2's read-only-text workers) a narrower
75
+ allowlist is not an option here.
76
+
60
77
  ### `status`
61
78
 
62
79
  ```bash
@@ -66,11 +83,40 @@ ai-toolkit executor status --project <path> --run-id <uuid>
66
83
  `--run-id` is required (usage error, exit 2, if omitted). Read-only summary of a run: task
67
84
  status counts, current run status, and total cost from the ledger.
68
85
 
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.
86
+ Backs `lib/task-executor/index.js`'s `status()`. Returns:
87
+
88
+ ```json
89
+ {
90
+ "protocolVersion": 1,
91
+ "runId": "...",
92
+ "runStatus": "running",
93
+ "taskCounts": { "pending": 0, "active": 0, "checkpointed": 0, "integrated": 0, "skipped": 0, "blocked": 0 },
94
+ "totalTokens": null,
95
+ "totalCostUsd": null
96
+ }
97
+ ```
98
+
99
+ `taskCounts` is derived straight from the persisted `state.json` (`store.readState`) — never
100
+ mutates it. `totalTokens` sums every numeric `phase_delta_tokens` entry in the run's own
101
+ `<runId>-token-ledger.json`; `totalCostUsd` additionally requires the target project's own
102
+ `docs/token-pricing.json` (same file and 80/20 input/output split `lib/workflow-artifacts.js`
103
+ uses for feature Token-Estimate documents) and a non-null `model` on each ledger entry to attach
104
+ a rate to. `dispatchTaskAttempt`/`runReview` (`lib/task-executor/index.js`) always open their
105
+ `implementation`/`review` ledger activities with `model: null`, so `totalCostUsd` is `null` until
106
+ that changes — this is a known gap, not a bug: **null means "unavailable", never a fabricated
107
+ zero** (same null-compatibility convention as the Token-Estimate document). `totalTokens` does
108
+ not share that gap: `_runTaskToResolution` now closes every `implementation`/`review` activity
109
+ (`done` with its measured tokens on a normal return, `failed` with `tokens: null` on a genuine
110
+ spawn-level error) immediately after each dispatch, so a completed or still-running real run
111
+ reports real summed tokens — previously these activities were opened and never closed at all, so
112
+ every run's `totalTokens` was unconditionally `null` regardless of outcome. If `runStatus`
113
+ happens to be `'paused'`, the CLI exits 8, same as `execute`/`resume` reporting that status
114
+ directly — the exit code reflects the run's actual state, not which subcommand observed it.
115
+
116
+ If the run is missing or its `state.json` is corrupted, `STATE_NOT_FOUND`/`STATE_CORRUPTED`
117
+ propagate unmodified (exit 4 / exit 1 respectively). An unreadable or unparseable ledger or
118
+ pricing file throws `LEDGER_READ_FAILED`/`LEDGER_CORRUPTED`/`PRICING_READ_FAILED`/
119
+ `PRICING_CORRUPTED` (exit 1) rather than silently reporting a partial or zeroed-out total.
74
120
 
75
121
  ### `diagnose`
76
122
 
@@ -74,6 +74,41 @@ function _buildProcessTag(runId, taskId, attemptNumber) {
74
74
  // the tagged "worker" under test, per this task's own testing constraint.
75
75
  const DEFAULT_TAGGED_PROCESS_EXE_BASENAME = 'claude.exe';
76
76
 
77
+ // Permission-bypass flag required for every real (non-test-fixture) worker
78
+ // spawn — implementation AND review alike. Without it, a non-interactive
79
+ // `--print` invocation has no TTY to answer a permission prompt, so every
80
+ // Write/Edit/mkdir tool call is denied by default: confirmed against a real
81
+ // blocked run (INFRA-TASK-INFRA-01's own post-dispatch verification found
82
+ // none of its expected directories had been created) before this constant
83
+ // existed at all. `--permission-mode auto` is a real, documented choice on
84
+ // the installed CLI (`claude --help`: "(choices: \"acceptEdits\", \"auto\",
85
+ // \"bypassPermissions\", \"manual\", \"dontAsk\", \"plan\")") and is
86
+ // deliberately broad (unscoped within the spawned cwd) — the executor's
87
+ // whole job is letting a developer agent write anywhere under the task's
88
+ // project, so a narrower scoped allowlist is not an option here the way it
89
+ // was for pm-phase1/2's read-only-text workers (see
90
+ // lib/workflow-control.js's own doc comment on why THAT subsystem sidesteps
91
+ // Write entirely instead).
92
+ //
93
+ // CORRECTION: an earlier version of this constant also appended
94
+ // `--permission-prompts none`, citing Tech-Spec OQ-01's pre-Gate-1 spike
95
+ // note ("Percorso C", resolved 2026-09-18) as having validated that exact
96
+ // pair. Reproduced directly against the real, currently-installed CLI
97
+ // (v2.1.220): `--permission-prompts` is NOT a recognized flag at all —
98
+ // `claude --print --permission-mode auto --permission-prompts none` fails
99
+ // immediately with `error: unknown option '--permission-prompts'` (exit 1,
100
+ // commander.js argv validation, before any model/API call). Every dispatch
101
+ // would have failed closed again, just with a different symptom (an argv
102
+ // crash instead of a denied Write) — so the historical spike note is either
103
+ // stale against a since-changed CLI surface or was never run against the
104
+ // real executable as literally claimed (this Tech-Spec history already
105
+ // contains one prior instance of an overclaimed, later-audited-away result —
106
+ // see FTR-018-Gate1-Review-Actions.md's own "Correction (2026-09-22)"). Only
107
+ // `--permission-mode auto` is carried forward; it alone was independently
108
+ // confirmed sufficient (a live dispatch created all of its task's expected
109
+ // directories and files).
110
+ const EXECUTOR_PERMISSION_BYPASS_ARGS = ['--permission-mode', 'auto'];
111
+
77
112
  // Real, positive-evidence-only liveness query for a tagged process, using
78
113
  // the exact E-02-qualified Win32_Process/CIM technique proven in the Gate-1
79
114
  // evidence spike, adapted from `Name='node.exe'` to the caller-supplied
@@ -255,6 +290,44 @@ function _err(code, message) {
255
290
  return e;
256
291
  }
257
292
 
293
+ // Mirrors lib/workflow-control.js's worker() token-measurement convention
294
+ // exactly (same four usage fields, same "only trust a whole, non-negative
295
+ // total" rule) so this module reports token usage under the same shared
296
+ // definition of "measured" vs "unavailable" the rest of the toolkit uses.
297
+ // Returns null (never 0) when usage is missing, partial, or computes to a
298
+ // non-positive total.
299
+ function _measureTokensFromSpawnResult(spawnResult) {
300
+ const usage = spawnResult && spawnResult.result && spawnResult.result.usage;
301
+ if (!usage) return null;
302
+ const counts = [
303
+ usage.input_tokens, usage.output_tokens,
304
+ usage.cache_read_input_tokens || 0, usage.cache_creation_input_tokens || 0,
305
+ ];
306
+ if (!counts.every(function (n) { return Number.isInteger(n) && n >= 0; })) return null;
307
+ const total = counts.reduce(function (a, b) { return a + b; }, 0);
308
+ return Number.isSafeInteger(total) && total > 0 ? total : null;
309
+ }
310
+
311
+ // Finalizes the 'implementation' or 'review' ledger activity dispatchTaskAttempt/
312
+ // runReview already opened for this attempt (Tech-Spec section 6 ledger-kind
313
+ // strings) — previously left open forever by _runTaskToResolution regardless
314
+ // of outcome (confirmed against a real run: every 'implementation'/'review'
315
+ // entry stayed status:'running' indefinitely, even for permanently-failed
316
+ // tasks). `kind` must be 'implementation' or 'review'; the agent key this
317
+ // reconstructs must match, byte for byte, the literal string
318
+ // dispatchTaskAttempt/runReview used when calling ledger.open (their own
319
+ // 'executor:'+runId+':'+task.id+':'+kind construction) — finalizeActivity
320
+ // requires an exact operation_id match, no name-only fallback.
321
+ function _finalizeDispatchLedgerActivity(executionRoot, runId, taskId, attemptNumber, kind, options) {
322
+ return ledger.finalizeActivity(
323
+ store._executionPaths(executionRoot, runId).runDir,
324
+ runId,
325
+ 'executor:' + runId + ':' + taskId + ':' + kind,
326
+ attemptNumber,
327
+ options
328
+ );
329
+ }
330
+
258
331
  function _requireNonEmptyString(value, label) {
259
332
  if (typeof value !== 'string' || value.length === 0) {
260
333
  throw _err('DISPATCH_VALIDATION_ERROR', label + ' must be a non-empty string');
@@ -529,7 +602,7 @@ async function dispatchTaskAttempt(args) {
529
602
  const prompt = typeof args.prompt === 'string' ? args.prompt : _buildTaskPrompt(task);
530
603
  const baseSpawnArgs = Array.isArray(args.spawnArgs)
531
604
  ? args.spawnArgs
532
- : ['--print', '--output-format', 'json', '--agent', identity.nativeName];
605
+ : ['--print', '--output-format', 'json', '--agent', identity.nativeName].concat(EXECUTOR_PERMISSION_BYPASS_ARGS);
533
606
  // Append the same processIdentity tag already persisted above as one extra
534
607
  // trailing argv element, so it appears verbatim in the spawned process's
535
608
  // own command line — this is what makes _findLiveTaggedProcess's later OS
@@ -625,6 +698,12 @@ function _buildReviewPrompt(task, diff) {
625
698
  // text> }. When the reply does not match the template (missing/malformed
626
699
  // envelope, or free text that doesn't follow it), `passed` is reported as
627
700
  // `null` rather than guessed — this function never fabricates a pass.
701
+ // `criticalFindings` captures both the "CRITICAL (blocks merge):" section AND
702
+ // any "Build:"/"Tests:" empirical-verification line carrying the template's
703
+ // own ❌ marker — the agent's contract allows FAIL via either path ("FAIL =
704
+ // 1+ CRITICAL findings OR build/test failure"), so a build/test-driven FAIL
705
+ // is reported just as explainably as a findings-driven one, never as an
706
+ // empty, unexplained list.
628
707
  // Authoritative pass/fail enforcement against the Tech-Spec's binding rule
629
708
  // ("review passed=true with no blocking finding") is intentionally left to
630
709
  // US-04-TASK-BE-03 (closing / combined verification+review outcome
@@ -655,6 +734,26 @@ function _extractReviewVerdict(spawnResult) {
655
734
  });
656
735
  }
657
736
 
737
+ // The agent's own contract (src/claude/agents/gaia-review-solution.md,
738
+ // "## Output": "FAIL = 1+ CRITICAL findings OR build/test failure") allows
739
+ // a FAIL verdict with zero CRITICAL findings, driven instead by its
740
+ // "Build: ✅/❌ ..." / "Tests: ✅/❌ ..." empirical-verification lines.
741
+ // Without surfacing those too, a build/test-driven FAIL was
742
+ // indistinguishable from an unexplained one — criticalFindings: [] either
743
+ // way (confirmed against a real run: a review-only task with zero code
744
+ // changes failed review twice with reviewPassed:false, criticalFindings:
745
+ // [], and no other evidence persisted anywhere to tell the two cases
746
+ // apart). Any Build/Tests line carrying the template's own ❌ failure
747
+ // marker is folded in here — it is exactly as blocking per the agent's own
748
+ // documented contract, so the outcome receipt now always explains a FAIL,
749
+ // not only a CRITICAL-findings one.
750
+ const empiricalMatches = text.matchAll(/^[ \t]*(Build|Tests):[ \t]*(.*)$/gim);
751
+ for (const m of empiricalMatches) {
752
+ if (m[2].indexOf('❌') !== -1) {
753
+ criticalFindings.push(m[1] + ': ' + m[2].trim());
754
+ }
755
+ }
756
+
658
757
  return { passed: passed, criticalFindings: criticalFindings };
659
758
  }
660
759
 
@@ -796,7 +895,7 @@ async function runReview(args) {
796
895
  const prompt = _buildReviewPrompt(task, diff);
797
896
  const spawnArgs = Array.isArray(args.spawnArgs)
798
897
  ? args.spawnArgs
799
- : ['--print', '--output-format', 'json', '--agent', identity.nativeName];
898
+ : ['--print', '--output-format', 'json', '--agent', identity.nativeName].concat(EXECUTOR_PERMISSION_BYPASS_ARGS);
800
899
  const spawnResult = await claudeProcess.spawnClaudeAgent({
801
900
  claudePath: args.claudePath,
802
901
  args: spawnArgs,
@@ -1924,6 +2023,14 @@ async function _runTaskToResolution(ctx) {
1924
2023
  // via its own try/finally).
1925
2024
  throw dispatchErr;
1926
2025
  }
2026
+ // Close the 'implementation' ledger activity dispatchTaskAttempt already
2027
+ // opened before this threw — the same persist-before-invoke ordering
2028
+ // that proves the attempt was durably recorded (the check just above)
2029
+ // also proves ledger.open already ran for it. Otherwise this activity
2030
+ // would stay open forever, exactly the gap the success path below also
2031
+ // fixes.
2032
+ _finalizeDispatchLedgerActivity(executionRoot, runId, taskId, attemptNumber, 'implementation',
2033
+ { status: 'failed', tokens: null, reason: dispatchErr.code || 'UNKNOWN' });
1927
2034
  // Genuine spawn-level failure (spawnClaudeAgent itself threw) AFTER
1928
2035
  // the attempt was already durably recorded — this DOES consume the
1929
2036
  // attempt, exactly like a verification/review failure does.
@@ -1952,7 +2059,19 @@ async function _runTaskToResolution(ctx) {
1952
2059
  continue;
1953
2060
  }
1954
2061
 
1955
- void dispatchResult; // raw spawn capture only — not otherwise consulted here.
2062
+ // Close the 'implementation' ledger activity opened by dispatchTaskAttempt
2063
+ // — previously left open forever (confirmed against a real run: every
2064
+ // 'implementation'/'review' entry stayed status:'running' indefinitely,
2065
+ // even for tasks that had already reached a terminal 'failed' stage).
2066
+ // "done" here means the subprocess ran to completion and returned a
2067
+ // result, independent of whether verification/review later judges that
2068
+ // result a pass or fail — that judgment is recorded separately, on the
2069
+ // task's own outcome receipt (persistVerificationReviewOutcome below),
2070
+ // never on this activity's ledger status. Mirrors the 'task'-kind
2071
+ // control-only close below (finalizeTaskCheckpoint) in structure, but
2072
+ // carries real measured tokens instead of an always-null control signal.
2073
+ _finalizeDispatchLedgerActivity(executionRoot, runId, taskId, attemptNumber, 'implementation',
2074
+ { status: 'done', tokens: _measureTokensFromSpawnResult(dispatchResult.spawnResult) });
1956
2075
 
1957
2076
  const verification = await runVerifications({
1958
2077
  executionRoot: executionRoot, runId: runId, task: task, attemptNumber: attemptNumber,
@@ -1972,6 +2091,12 @@ async function _runTaskToResolution(ctx) {
1972
2091
  claudePath: ctx.claudePath, projectDir: projectDir, diff: diff, taskTimeoutMs: ctx.taskTimeoutMs,
1973
2092
  spawnArgs: ctx.reviewSpawnArgs,
1974
2093
  });
2094
+ // Close the 'review' ledger activity runReview already opened — same
2095
+ // previously-open-forever gap the 'implementation' close above fixes,
2096
+ // same reasoning: "done" reflects the subprocess completing, not the
2097
+ // review's own pass/fail verdict (that lives on the outcome receipt).
2098
+ _finalizeDispatchLedgerActivity(executionRoot, runId, taskId, attemptNumber, 'review',
2099
+ { status: 'done', tokens: _measureTokensFromSpawnResult(review.spawnResult) });
1975
2100
  }
1976
2101
 
1977
2102
  const outcome = store.persistVerificationReviewOutcome(executionRoot, runId, taskId, attemptNumber, verification, review);
@@ -2792,14 +2917,121 @@ async function execute(args) {
2792
2917
  }
2793
2918
  }
2794
2919
 
2920
+ // Reads the run's own token ledger (<executionRoot>/runs/<runId>/<runId>-
2921
+ // token-ledger.json, written by ../execution-ledger.js's open/close/fail/
2922
+ // skip) as a flat array of entries. No activity opened yet is a normal,
2923
+ // non-error state (returns []) — a present-but-unparseable file fails closed
2924
+ // instead, since that is evidence of a corrupted run, not an empty one.
2925
+ function _readRunLedgerEntries(executionRoot, runId) {
2926
+ const ledgerPath = path.join(executionRoot, 'runs', runId, runId + '-token-ledger.json');
2927
+ let raw;
2928
+ try {
2929
+ raw = fs.readFileSync(ledgerPath, 'utf8');
2930
+ } catch (err) {
2931
+ if (err.code === 'ENOENT') return [];
2932
+ throw _err('LEDGER_READ_FAILED', 'status: failed to read token ledger at ' + ledgerPath + ': ' + err.message);
2933
+ }
2934
+ let parsed;
2935
+ try {
2936
+ parsed = JSON.parse(raw);
2937
+ } catch (err) {
2938
+ throw _err('LEDGER_CORRUPTED', 'status: token ledger at ' + ledgerPath + ' is not valid JSON: ' + err.message);
2939
+ }
2940
+ if (!Array.isArray(parsed)) {
2941
+ throw _err('LEDGER_CORRUPTED', 'status: token ledger at ' + ledgerPath + ' must be a JSON array, got ' + typeof parsed);
2942
+ }
2943
+ return parsed;
2944
+ }
2945
+
2946
+ // Loads docs/token-pricing.json from the TARGET project (args.project), not
2947
+ // this toolkit's own checkout — the same file and project-relative
2948
+ // resolution lib/workflow-artifacts.js's pricing() uses for feature
2949
+ // Token-Estimate documents (see lib/workflow-control.js). A missing file
2950
+ // means "cost unavailable", not an error: not every consuming project ships
2951
+ // pricing data, and status must still report token counts for one that
2952
+ // doesn't. A present-but-invalid file fails closed.
2953
+ function _loadTokenPricing(projectDir) {
2954
+ const pricingPath = path.join(projectDir, 'docs', 'token-pricing.json');
2955
+ let raw;
2956
+ try {
2957
+ raw = fs.readFileSync(pricingPath, 'utf8');
2958
+ } catch (err) {
2959
+ if (err.code === 'ENOENT') return null;
2960
+ throw _err('PRICING_READ_FAILED', 'status: failed to read token pricing at ' + pricingPath + ': ' + err.message);
2961
+ }
2962
+ let parsed;
2963
+ try {
2964
+ parsed = JSON.parse(raw);
2965
+ } catch (err) {
2966
+ throw _err('PRICING_CORRUPTED', 'status: token pricing at ' + pricingPath + ' is not valid JSON: ' + err.message);
2967
+ }
2968
+ if (!parsed || !parsed.models || !Number.isFinite(parsed.usd_to_eur) || parsed.usd_to_eur <= 0) {
2969
+ throw _err('PRICING_CORRUPTED', 'status: token pricing at ' + pricingPath + ' is missing "models" or a valid "usd_to_eur" rate');
2970
+ }
2971
+ return parsed;
2972
+ }
2973
+
2974
+ // USD cost for one ledger entry's measured tokens, using the estimated
2975
+ // 80/20 input/output split already established by lib/workflow-artifacts.js's
2976
+ // cost() for the same pricing file — unlike that helper, this never converts
2977
+ // to EUR (the field this feeds is totalCostUsd). Returns null (not 0) when
2978
+ // the entry's model is missing/unknown or its tokens are not a finite number
2979
+ // — an unattributable entry is excluded from the total, never coerced into a
2980
+ // zero-cost one.
2981
+ function _usdCostForLedgerEntry(tokens, model, pricing) {
2982
+ if (!Number.isFinite(tokens) || typeof model !== 'string' || model.length === 0) return null;
2983
+ const rate = pricing.models[model];
2984
+ if (!rate || !Number.isFinite(rate.input_per_1m_usd) || !Number.isFinite(rate.output_per_1m_usd)) return null;
2985
+ return tokens * (0.8 * rate.input_per_1m_usd + 0.2 * rate.output_per_1m_usd) / 1000000;
2986
+ }
2987
+
2988
+ // Null-compatibility (mirrors the convention already established by
2989
+ // src/claude/skills/implement-feature/SKILL.md's Step 7 and
2990
+ // lib/workflow-artifacts.js's pricing/cost helpers): a token value that is
2991
+ // null/undefined/not a finite number is "not yet measured", never coerced
2992
+ // into a real, observable zero. totalTokens/totalCostUsd stay null — not 0 —
2993
+ // when nothing measurable exists yet, so a caller can tell "nothing spent"
2994
+ // apart from "nothing reported". Every executor ledger entry is currently
2995
+ // opened with model: null (see _runTaskToResolution's ledger.open calls
2996
+ // above), so totalCostUsd is null whenever no pricing file is present OR
2997
+ // every entry's model is still unattributed — both are legitimate,
2998
+ // non-error outcomes, not a bug in this function.
2999
+ function _summarizeLedgerTotals(entries, projectDir) {
3000
+ let totalTokens = null;
3001
+ let totalCostUsd = null;
3002
+ let pricing;
3003
+ let pricingLoaded = false;
3004
+
3005
+ entries.forEach(function (entry) {
3006
+ const tokens = entry.phase_delta_tokens;
3007
+ if (!Number.isFinite(tokens)) return;
3008
+ totalTokens = (totalTokens || 0) + tokens;
3009
+
3010
+ if (!pricingLoaded) {
3011
+ pricing = _loadTokenPricing(projectDir);
3012
+ pricingLoaded = true;
3013
+ }
3014
+ if (!pricing) return;
3015
+
3016
+ const entryCost = _usdCostForLedgerEntry(tokens, entry.model, pricing);
3017
+ if (entryCost === null) return;
3018
+ totalCostUsd = (totalCostUsd || 0) + entryCost;
3019
+ });
3020
+
3021
+ return { totalTokens: totalTokens, totalCostUsd: totalCostUsd };
3022
+ }
3023
+
2795
3024
  /**
2796
3025
  * `status` command. Read-only summary of a run: counts of tasks per
2797
3026
  * status, current run status, and total cost as reported by the ledger
2798
3027
  * (the sole cost source). Never mutates state.
2799
3028
  *
2800
- * Real implementation: US-06-TASK-BE-01 (loads persisted state/evidence and
2801
- * classifies task status per the edge-cases table this summary is built
2802
- * from).
3029
+ * Built entirely from three already-implemented data sources — no new Work
3030
+ * Breakdown task was required: store.readState (INFRA-TASK-BE-01) for task
3031
+ * statuses/runStatus, the run's own token-ledger.json
3032
+ * (INFRA-TASK-BE-02/../execution-ledger.js) for token counts, and the
3033
+ * target project's docs/token-pricing.json (already read the same way by
3034
+ * lib/workflow-artifacts.js) for USD cost attribution.
2803
3035
  *
2804
3036
  * @param {object} args
2805
3037
  * @param {string} args.project - Absolute or project-relative repo path.
@@ -2808,9 +3040,50 @@ async function execute(args) {
2808
3040
  * { protocolVersion, runId, runStatus,
2809
3041
  * taskCounts: { pending, active, checkpointed, integrated, skipped, blocked },
2810
3042
  * totalTokens, totalCostUsd }.
3043
+ * totalTokens/totalCostUsd are null (never 0) when nothing measurable has
3044
+ * been recorded yet, or (totalCostUsd only) when the project ships no
3045
+ * docs/token-pricing.json.
3046
+ * @throws {Error} DISPATCH_VALIDATION_ERROR for a malformed args.project/
3047
+ * args.runId (via _requireNonEmptyString, same as every other command in
3048
+ * this module); any error store.readState throws (STATE_NOT_FOUND,
3049
+ * STATE_CORRUPTED) propagates unmodified — a corrupted/missing run is
3050
+ * never silently defaulted; LEDGER_READ_FAILED/LEDGER_CORRUPTED/
3051
+ * PRICING_READ_FAILED/PRICING_CORRUPTED for an unreadable or unparseable
3052
+ * ledger or pricing file.
2811
3053
  */
2812
3054
  async function status(args) {
2813
- return _notImplemented('status', 'US-06-TASK-BE-01');
3055
+ args = args || {};
3056
+ _requireNonEmptyString(args.project, 'status: args.project');
3057
+ _requireNonEmptyString(args.runId, 'status: args.runId');
3058
+
3059
+ const projectDir = args.project;
3060
+ const runId = args.runId;
3061
+ const commonDir = _resolveCommonGitDir(projectDir);
3062
+ const executionRoot = path.join(commonDir, 'ai-toolkit', 'execution');
3063
+
3064
+ // Propagates STATE_NOT_FOUND / STATE_CORRUPTED unmodified — never
3065
+ // fabricates an empty/default run for a corrupted/missing one.
3066
+ const state = store.readState(executionRoot, runId);
3067
+
3068
+ const taskCounts = { pending: 0, active: 0, checkpointed: 0, integrated: 0, skipped: 0, blocked: 0 };
3069
+ Object.keys(state.tasks).forEach(function (taskId) {
3070
+ const st = state.tasks[taskId].status;
3071
+ if (Object.prototype.hasOwnProperty.call(taskCounts, st)) {
3072
+ taskCounts[st] += 1;
3073
+ }
3074
+ });
3075
+
3076
+ const ledgerEntries = _readRunLedgerEntries(executionRoot, runId);
3077
+ const totals = _summarizeLedgerTotals(ledgerEntries, projectDir);
3078
+
3079
+ return {
3080
+ protocolVersion: store.PROTOCOL_VERSION,
3081
+ runId: runId,
3082
+ runStatus: state.runStatus,
3083
+ taskCounts: taskCounts,
3084
+ totalTokens: totals.totalTokens,
3085
+ totalCostUsd: totals.totalCostUsd,
3086
+ };
2814
3087
  }
2815
3088
 
2816
3089
  /**
@@ -3726,4 +3999,6 @@ module.exports = {
3726
3999
  DEFAULT_TAGGED_PROCESS_EXE_BASENAME,
3727
4000
  _buildProcessTag,
3728
4001
  _findLiveTaggedProcess,
4002
+ _measureTokensFromSpawnResult,
4003
+ _finalizeDispatchLedgerActivity,
3729
4004
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtlabs/ai-toolkit",
3
- "version": "0.16.4",
3
+ "version": "0.16.6",
4
4
  "description": "AI Toolkit — reusable agents and procedures for developers to build AI-powered applications.",
5
5
  "files": [
6
6
  "docs/",