@dtlabs/ai-toolkit 0.16.4 → 0.16.5

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,14 @@
1
1
  ## 0.1.4 (2026-07-21)
2
+ ## [0.16.5](https://github.com/dtlab-tech/ai-toolkit/compare/v0.16.4...v0.16.5) (2026-10-05)
3
+
4
+
5
+ ### Bug Fixes
6
+
7
+ * **task-executor:** implement the real executor status command ([58710aa](https://github.com/dtlab-tech/ai-toolkit/commit/58710aa5824108355076df697291a39dc8cd22f1))
8
+ * **task-executor:** permissions, ledger finalize, review verdicts ([91fedef](https://github.com/dtlab-tech/ai-toolkit/commit/91fedef1e3adb0b8ca64114f786d324e43991d64))
9
+ * **task-executor:** restore permission bypass and close ledger activities ([40aa1d5](https://github.com/dtlab-tech/ai-toolkit/commit/40aa1d5ba115a2438b6af66ff26a50956b9dcd3e))
10
+ * **task-executor:** surface build/test-driven review FAIL reasons ([09c0052](https://github.com/dtlab-tech/ai-toolkit/commit/09c0052109195d2b2b2f336a7b0fa1a892512787))
11
+
2
12
  ## [0.16.4](https://github.com/dtlab-tech/ai-toolkit/compare/v0.16.3...v0.16.4) (2026-10-02)
3
13
 
4
14
 
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,18 @@ 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 --permission-prompts none` appended to its CLI args
62
+ (`EXECUTOR_PERMISSION_BYPASS_ARGS` in `lib/task-executor/index.js`). A non-interactive `--print`
63
+ invocation has no TTY to answer a permission prompt, so without this every Write/Edit/mkdir tool
64
+ call is denied by default, regardless of task content — confirmed against a real run where every
65
+ BE/FE task failed verification because its expected files/directories were never created. This
66
+ restores exactly the invocation Tech-Spec OQ-01's pre-Gate-1 spike validated ("Percorso C") and
67
+ Gate 1 was approved against; it had gone missing from the actual `dispatchTaskAttempt`/`runReview`
68
+ implementation. The mode is deliberately unscoped within the task's own `--project` directory —
69
+ the executor's job is letting a developer agent write anywhere under that project, so (unlike
70
+ pm-phase1/2's read-only-text workers) a narrower allowlist is not an option here.
71
+
60
72
  ### `status`
61
73
 
62
74
  ```bash
@@ -66,11 +78,40 @@ ai-toolkit executor status --project <path> --run-id <uuid>
66
78
  `--run-id` is required (usage error, exit 2, if omitted). Read-only summary of a run: task
67
79
  status counts, current run status, and total cost from the ledger.
68
80
 
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.
81
+ Backs `lib/task-executor/index.js`'s `status()`. Returns:
82
+
83
+ ```json
84
+ {
85
+ "protocolVersion": 1,
86
+ "runId": "...",
87
+ "runStatus": "running",
88
+ "taskCounts": { "pending": 0, "active": 0, "checkpointed": 0, "integrated": 0, "skipped": 0, "blocked": 0 },
89
+ "totalTokens": null,
90
+ "totalCostUsd": null
91
+ }
92
+ ```
93
+
94
+ `taskCounts` is derived straight from the persisted `state.json` (`store.readState`) — never
95
+ mutates it. `totalTokens` sums every numeric `phase_delta_tokens` entry in the run's own
96
+ `<runId>-token-ledger.json`; `totalCostUsd` additionally requires the target project's own
97
+ `docs/token-pricing.json` (same file and 80/20 input/output split `lib/workflow-artifacts.js`
98
+ uses for feature Token-Estimate documents) and a non-null `model` on each ledger entry to attach
99
+ a rate to. `dispatchTaskAttempt`/`runReview` (`lib/task-executor/index.js`) always open their
100
+ `implementation`/`review` ledger activities with `model: null`, so `totalCostUsd` is `null` until
101
+ that changes — this is a known gap, not a bug: **null means "unavailable", never a fabricated
102
+ zero** (same null-compatibility convention as the Token-Estimate document). `totalTokens` does
103
+ not share that gap: `_runTaskToResolution` now closes every `implementation`/`review` activity
104
+ (`done` with its measured tokens on a normal return, `failed` with `tokens: null` on a genuine
105
+ spawn-level error) immediately after each dispatch, so a completed or still-running real run
106
+ reports real summed tokens — previously these activities were opened and never closed at all, so
107
+ every run's `totalTokens` was unconditionally `null` regardless of outcome. If `runStatus`
108
+ happens to be `'paused'`, the CLI exits 8, same as `execute`/`resume` reporting that status
109
+ directly — the exit code reflects the run's actual state, not which subcommand observed it.
110
+
111
+ If the run is missing or its `state.json` is corrupted, `STATE_NOT_FOUND`/`STATE_CORRUPTED`
112
+ propagate unmodified (exit 4 / exit 1 respectively). An unreadable or unparseable ledger or
113
+ pricing file throws `LEDGER_READ_FAILED`/`LEDGER_CORRUPTED`/`PRICING_READ_FAILED`/
114
+ `PRICING_CORRUPTED` (exit 1) rather than silently reporting a partial or zeroed-out total.
74
115
 
75
116
  ### `diagnose`
76
117
 
@@ -74,6 +74,29 @@ 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 flags required for every real (non-test-fixture) worker
78
+ // spawn — implementation AND review alike. Without them, 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: this was validated
81
+ // (Tech-Spec OQ-01, "Percorso C", resolved 2026-09-18, pre-Gate-1 spike) as
82
+ // `--permission-mode auto --permission-prompts none` — "Exit 0 confirmed
83
+ // with structured JSON output and full usage data" — and is NOT a new or
84
+ // speculative flag choice, it restores exactly what Gate 1 was approved
85
+ // against (see internal_docs/features/FTR-018-deterministic-task-execution-
86
+ // checkpoints-and-resume/history/pre-consolidation-2026-09-20-*.md). It was
87
+ // missing from dispatchTaskAttempt's/runReview's actual baseSpawnArgs/
88
+ // spawnArgs literals — every real implementation/review dispatch was
89
+ // therefore guaranteed to have every write denied, regardless of task
90
+ // content (confirmed against a real blocked run: INFRA-TASK-INFRA-01's own
91
+ // post-dispatch verification found none of its expected directories had
92
+ // been created). `--permission-mode auto` is deliberately broad (unscoped
93
+ // within the spawned cwd) — the executor's whole job is letting a developer
94
+ // agent write anywhere under the task's project, so a narrower scoped
95
+ // allowlist is not an option here the way it was for pm-phase1/2's
96
+ // read-only-text workers (see lib/workflow-control.js's own doc comment on
97
+ // why THAT subsystem sidesteps Write entirely instead).
98
+ const EXECUTOR_PERMISSION_BYPASS_ARGS = ['--permission-mode', 'auto', '--permission-prompts', 'none'];
99
+
77
100
  // Real, positive-evidence-only liveness query for a tagged process, using
78
101
  // the exact E-02-qualified Win32_Process/CIM technique proven in the Gate-1
79
102
  // evidence spike, adapted from `Name='node.exe'` to the caller-supplied
@@ -255,6 +278,44 @@ function _err(code, message) {
255
278
  return e;
256
279
  }
257
280
 
281
+ // Mirrors lib/workflow-control.js's worker() token-measurement convention
282
+ // exactly (same four usage fields, same "only trust a whole, non-negative
283
+ // total" rule) so this module reports token usage under the same shared
284
+ // definition of "measured" vs "unavailable" the rest of the toolkit uses.
285
+ // Returns null (never 0) when usage is missing, partial, or computes to a
286
+ // non-positive total.
287
+ function _measureTokensFromSpawnResult(spawnResult) {
288
+ const usage = spawnResult && spawnResult.result && spawnResult.result.usage;
289
+ if (!usage) return null;
290
+ const counts = [
291
+ usage.input_tokens, usage.output_tokens,
292
+ usage.cache_read_input_tokens || 0, usage.cache_creation_input_tokens || 0,
293
+ ];
294
+ if (!counts.every(function (n) { return Number.isInteger(n) && n >= 0; })) return null;
295
+ const total = counts.reduce(function (a, b) { return a + b; }, 0);
296
+ return Number.isSafeInteger(total) && total > 0 ? total : null;
297
+ }
298
+
299
+ // Finalizes the 'implementation' or 'review' ledger activity dispatchTaskAttempt/
300
+ // runReview already opened for this attempt (Tech-Spec section 6 ledger-kind
301
+ // strings) — previously left open forever by _runTaskToResolution regardless
302
+ // of outcome (confirmed against a real run: every 'implementation'/'review'
303
+ // entry stayed status:'running' indefinitely, even for permanently-failed
304
+ // tasks). `kind` must be 'implementation' or 'review'; the agent key this
305
+ // reconstructs must match, byte for byte, the literal string
306
+ // dispatchTaskAttempt/runReview used when calling ledger.open (their own
307
+ // 'executor:'+runId+':'+task.id+':'+kind construction) — finalizeActivity
308
+ // requires an exact operation_id match, no name-only fallback.
309
+ function _finalizeDispatchLedgerActivity(executionRoot, runId, taskId, attemptNumber, kind, options) {
310
+ return ledger.finalizeActivity(
311
+ store._executionPaths(executionRoot, runId).runDir,
312
+ runId,
313
+ 'executor:' + runId + ':' + taskId + ':' + kind,
314
+ attemptNumber,
315
+ options
316
+ );
317
+ }
318
+
258
319
  function _requireNonEmptyString(value, label) {
259
320
  if (typeof value !== 'string' || value.length === 0) {
260
321
  throw _err('DISPATCH_VALIDATION_ERROR', label + ' must be a non-empty string');
@@ -529,7 +590,7 @@ async function dispatchTaskAttempt(args) {
529
590
  const prompt = typeof args.prompt === 'string' ? args.prompt : _buildTaskPrompt(task);
530
591
  const baseSpawnArgs = Array.isArray(args.spawnArgs)
531
592
  ? args.spawnArgs
532
- : ['--print', '--output-format', 'json', '--agent', identity.nativeName];
593
+ : ['--print', '--output-format', 'json', '--agent', identity.nativeName].concat(EXECUTOR_PERMISSION_BYPASS_ARGS);
533
594
  // Append the same processIdentity tag already persisted above as one extra
534
595
  // trailing argv element, so it appears verbatim in the spawned process's
535
596
  // own command line — this is what makes _findLiveTaggedProcess's later OS
@@ -625,6 +686,12 @@ function _buildReviewPrompt(task, diff) {
625
686
  // text> }. When the reply does not match the template (missing/malformed
626
687
  // envelope, or free text that doesn't follow it), `passed` is reported as
627
688
  // `null` rather than guessed — this function never fabricates a pass.
689
+ // `criticalFindings` captures both the "CRITICAL (blocks merge):" section AND
690
+ // any "Build:"/"Tests:" empirical-verification line carrying the template's
691
+ // own ❌ marker — the agent's contract allows FAIL via either path ("FAIL =
692
+ // 1+ CRITICAL findings OR build/test failure"), so a build/test-driven FAIL
693
+ // is reported just as explainably as a findings-driven one, never as an
694
+ // empty, unexplained list.
628
695
  // Authoritative pass/fail enforcement against the Tech-Spec's binding rule
629
696
  // ("review passed=true with no blocking finding") is intentionally left to
630
697
  // US-04-TASK-BE-03 (closing / combined verification+review outcome
@@ -655,6 +722,26 @@ function _extractReviewVerdict(spawnResult) {
655
722
  });
656
723
  }
657
724
 
725
+ // The agent's own contract (src/claude/agents/gaia-review-solution.md,
726
+ // "## Output": "FAIL = 1+ CRITICAL findings OR build/test failure") allows
727
+ // a FAIL verdict with zero CRITICAL findings, driven instead by its
728
+ // "Build: ✅/❌ ..." / "Tests: ✅/❌ ..." empirical-verification lines.
729
+ // Without surfacing those too, a build/test-driven FAIL was
730
+ // indistinguishable from an unexplained one — criticalFindings: [] either
731
+ // way (confirmed against a real run: a review-only task with zero code
732
+ // changes failed review twice with reviewPassed:false, criticalFindings:
733
+ // [], and no other evidence persisted anywhere to tell the two cases
734
+ // apart). Any Build/Tests line carrying the template's own ❌ failure
735
+ // marker is folded in here — it is exactly as blocking per the agent's own
736
+ // documented contract, so the outcome receipt now always explains a FAIL,
737
+ // not only a CRITICAL-findings one.
738
+ const empiricalMatches = text.matchAll(/^[ \t]*(Build|Tests):[ \t]*(.*)$/gim);
739
+ for (const m of empiricalMatches) {
740
+ if (m[2].indexOf('❌') !== -1) {
741
+ criticalFindings.push(m[1] + ': ' + m[2].trim());
742
+ }
743
+ }
744
+
658
745
  return { passed: passed, criticalFindings: criticalFindings };
659
746
  }
660
747
 
@@ -796,7 +883,7 @@ async function runReview(args) {
796
883
  const prompt = _buildReviewPrompt(task, diff);
797
884
  const spawnArgs = Array.isArray(args.spawnArgs)
798
885
  ? args.spawnArgs
799
- : ['--print', '--output-format', 'json', '--agent', identity.nativeName];
886
+ : ['--print', '--output-format', 'json', '--agent', identity.nativeName].concat(EXECUTOR_PERMISSION_BYPASS_ARGS);
800
887
  const spawnResult = await claudeProcess.spawnClaudeAgent({
801
888
  claudePath: args.claudePath,
802
889
  args: spawnArgs,
@@ -1924,6 +2011,14 @@ async function _runTaskToResolution(ctx) {
1924
2011
  // via its own try/finally).
1925
2012
  throw dispatchErr;
1926
2013
  }
2014
+ // Close the 'implementation' ledger activity dispatchTaskAttempt already
2015
+ // opened before this threw — the same persist-before-invoke ordering
2016
+ // that proves the attempt was durably recorded (the check just above)
2017
+ // also proves ledger.open already ran for it. Otherwise this activity
2018
+ // would stay open forever, exactly the gap the success path below also
2019
+ // fixes.
2020
+ _finalizeDispatchLedgerActivity(executionRoot, runId, taskId, attemptNumber, 'implementation',
2021
+ { status: 'failed', tokens: null, reason: dispatchErr.code || 'UNKNOWN' });
1927
2022
  // Genuine spawn-level failure (spawnClaudeAgent itself threw) AFTER
1928
2023
  // the attempt was already durably recorded — this DOES consume the
1929
2024
  // attempt, exactly like a verification/review failure does.
@@ -1952,7 +2047,19 @@ async function _runTaskToResolution(ctx) {
1952
2047
  continue;
1953
2048
  }
1954
2049
 
1955
- void dispatchResult; // raw spawn capture only — not otherwise consulted here.
2050
+ // Close the 'implementation' ledger activity opened by dispatchTaskAttempt
2051
+ // — previously left open forever (confirmed against a real run: every
2052
+ // 'implementation'/'review' entry stayed status:'running' indefinitely,
2053
+ // even for tasks that had already reached a terminal 'failed' stage).
2054
+ // "done" here means the subprocess ran to completion and returned a
2055
+ // result, independent of whether verification/review later judges that
2056
+ // result a pass or fail — that judgment is recorded separately, on the
2057
+ // task's own outcome receipt (persistVerificationReviewOutcome below),
2058
+ // never on this activity's ledger status. Mirrors the 'task'-kind
2059
+ // control-only close below (finalizeTaskCheckpoint) in structure, but
2060
+ // carries real measured tokens instead of an always-null control signal.
2061
+ _finalizeDispatchLedgerActivity(executionRoot, runId, taskId, attemptNumber, 'implementation',
2062
+ { status: 'done', tokens: _measureTokensFromSpawnResult(dispatchResult.spawnResult) });
1956
2063
 
1957
2064
  const verification = await runVerifications({
1958
2065
  executionRoot: executionRoot, runId: runId, task: task, attemptNumber: attemptNumber,
@@ -1972,6 +2079,12 @@ async function _runTaskToResolution(ctx) {
1972
2079
  claudePath: ctx.claudePath, projectDir: projectDir, diff: diff, taskTimeoutMs: ctx.taskTimeoutMs,
1973
2080
  spawnArgs: ctx.reviewSpawnArgs,
1974
2081
  });
2082
+ // Close the 'review' ledger activity runReview already opened — same
2083
+ // previously-open-forever gap the 'implementation' close above fixes,
2084
+ // same reasoning: "done" reflects the subprocess completing, not the
2085
+ // review's own pass/fail verdict (that lives on the outcome receipt).
2086
+ _finalizeDispatchLedgerActivity(executionRoot, runId, taskId, attemptNumber, 'review',
2087
+ { status: 'done', tokens: _measureTokensFromSpawnResult(review.spawnResult) });
1975
2088
  }
1976
2089
 
1977
2090
  const outcome = store.persistVerificationReviewOutcome(executionRoot, runId, taskId, attemptNumber, verification, review);
@@ -2792,14 +2905,121 @@ async function execute(args) {
2792
2905
  }
2793
2906
  }
2794
2907
 
2908
+ // Reads the run's own token ledger (<executionRoot>/runs/<runId>/<runId>-
2909
+ // token-ledger.json, written by ../execution-ledger.js's open/close/fail/
2910
+ // skip) as a flat array of entries. No activity opened yet is a normal,
2911
+ // non-error state (returns []) — a present-but-unparseable file fails closed
2912
+ // instead, since that is evidence of a corrupted run, not an empty one.
2913
+ function _readRunLedgerEntries(executionRoot, runId) {
2914
+ const ledgerPath = path.join(executionRoot, 'runs', runId, runId + '-token-ledger.json');
2915
+ let raw;
2916
+ try {
2917
+ raw = fs.readFileSync(ledgerPath, 'utf8');
2918
+ } catch (err) {
2919
+ if (err.code === 'ENOENT') return [];
2920
+ throw _err('LEDGER_READ_FAILED', 'status: failed to read token ledger at ' + ledgerPath + ': ' + err.message);
2921
+ }
2922
+ let parsed;
2923
+ try {
2924
+ parsed = JSON.parse(raw);
2925
+ } catch (err) {
2926
+ throw _err('LEDGER_CORRUPTED', 'status: token ledger at ' + ledgerPath + ' is not valid JSON: ' + err.message);
2927
+ }
2928
+ if (!Array.isArray(parsed)) {
2929
+ throw _err('LEDGER_CORRUPTED', 'status: token ledger at ' + ledgerPath + ' must be a JSON array, got ' + typeof parsed);
2930
+ }
2931
+ return parsed;
2932
+ }
2933
+
2934
+ // Loads docs/token-pricing.json from the TARGET project (args.project), not
2935
+ // this toolkit's own checkout — the same file and project-relative
2936
+ // resolution lib/workflow-artifacts.js's pricing() uses for feature
2937
+ // Token-Estimate documents (see lib/workflow-control.js). A missing file
2938
+ // means "cost unavailable", not an error: not every consuming project ships
2939
+ // pricing data, and status must still report token counts for one that
2940
+ // doesn't. A present-but-invalid file fails closed.
2941
+ function _loadTokenPricing(projectDir) {
2942
+ const pricingPath = path.join(projectDir, 'docs', 'token-pricing.json');
2943
+ let raw;
2944
+ try {
2945
+ raw = fs.readFileSync(pricingPath, 'utf8');
2946
+ } catch (err) {
2947
+ if (err.code === 'ENOENT') return null;
2948
+ throw _err('PRICING_READ_FAILED', 'status: failed to read token pricing at ' + pricingPath + ': ' + err.message);
2949
+ }
2950
+ let parsed;
2951
+ try {
2952
+ parsed = JSON.parse(raw);
2953
+ } catch (err) {
2954
+ throw _err('PRICING_CORRUPTED', 'status: token pricing at ' + pricingPath + ' is not valid JSON: ' + err.message);
2955
+ }
2956
+ if (!parsed || !parsed.models || !Number.isFinite(parsed.usd_to_eur) || parsed.usd_to_eur <= 0) {
2957
+ throw _err('PRICING_CORRUPTED', 'status: token pricing at ' + pricingPath + ' is missing "models" or a valid "usd_to_eur" rate');
2958
+ }
2959
+ return parsed;
2960
+ }
2961
+
2962
+ // USD cost for one ledger entry's measured tokens, using the estimated
2963
+ // 80/20 input/output split already established by lib/workflow-artifacts.js's
2964
+ // cost() for the same pricing file — unlike that helper, this never converts
2965
+ // to EUR (the field this feeds is totalCostUsd). Returns null (not 0) when
2966
+ // the entry's model is missing/unknown or its tokens are not a finite number
2967
+ // — an unattributable entry is excluded from the total, never coerced into a
2968
+ // zero-cost one.
2969
+ function _usdCostForLedgerEntry(tokens, model, pricing) {
2970
+ if (!Number.isFinite(tokens) || typeof model !== 'string' || model.length === 0) return null;
2971
+ const rate = pricing.models[model];
2972
+ if (!rate || !Number.isFinite(rate.input_per_1m_usd) || !Number.isFinite(rate.output_per_1m_usd)) return null;
2973
+ return tokens * (0.8 * rate.input_per_1m_usd + 0.2 * rate.output_per_1m_usd) / 1000000;
2974
+ }
2975
+
2976
+ // Null-compatibility (mirrors the convention already established by
2977
+ // src/claude/skills/implement-feature/SKILL.md's Step 7 and
2978
+ // lib/workflow-artifacts.js's pricing/cost helpers): a token value that is
2979
+ // null/undefined/not a finite number is "not yet measured", never coerced
2980
+ // into a real, observable zero. totalTokens/totalCostUsd stay null — not 0 —
2981
+ // when nothing measurable exists yet, so a caller can tell "nothing spent"
2982
+ // apart from "nothing reported". Every executor ledger entry is currently
2983
+ // opened with model: null (see _runTaskToResolution's ledger.open calls
2984
+ // above), so totalCostUsd is null whenever no pricing file is present OR
2985
+ // every entry's model is still unattributed — both are legitimate,
2986
+ // non-error outcomes, not a bug in this function.
2987
+ function _summarizeLedgerTotals(entries, projectDir) {
2988
+ let totalTokens = null;
2989
+ let totalCostUsd = null;
2990
+ let pricing;
2991
+ let pricingLoaded = false;
2992
+
2993
+ entries.forEach(function (entry) {
2994
+ const tokens = entry.phase_delta_tokens;
2995
+ if (!Number.isFinite(tokens)) return;
2996
+ totalTokens = (totalTokens || 0) + tokens;
2997
+
2998
+ if (!pricingLoaded) {
2999
+ pricing = _loadTokenPricing(projectDir);
3000
+ pricingLoaded = true;
3001
+ }
3002
+ if (!pricing) return;
3003
+
3004
+ const entryCost = _usdCostForLedgerEntry(tokens, entry.model, pricing);
3005
+ if (entryCost === null) return;
3006
+ totalCostUsd = (totalCostUsd || 0) + entryCost;
3007
+ });
3008
+
3009
+ return { totalTokens: totalTokens, totalCostUsd: totalCostUsd };
3010
+ }
3011
+
2795
3012
  /**
2796
3013
  * `status` command. Read-only summary of a run: counts of tasks per
2797
3014
  * status, current run status, and total cost as reported by the ledger
2798
3015
  * (the sole cost source). Never mutates state.
2799
3016
  *
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).
3017
+ * Built entirely from three already-implemented data sources — no new Work
3018
+ * Breakdown task was required: store.readState (INFRA-TASK-BE-01) for task
3019
+ * statuses/runStatus, the run's own token-ledger.json
3020
+ * (INFRA-TASK-BE-02/../execution-ledger.js) for token counts, and the
3021
+ * target project's docs/token-pricing.json (already read the same way by
3022
+ * lib/workflow-artifacts.js) for USD cost attribution.
2803
3023
  *
2804
3024
  * @param {object} args
2805
3025
  * @param {string} args.project - Absolute or project-relative repo path.
@@ -2808,9 +3028,50 @@ async function execute(args) {
2808
3028
  * { protocolVersion, runId, runStatus,
2809
3029
  * taskCounts: { pending, active, checkpointed, integrated, skipped, blocked },
2810
3030
  * totalTokens, totalCostUsd }.
3031
+ * totalTokens/totalCostUsd are null (never 0) when nothing measurable has
3032
+ * been recorded yet, or (totalCostUsd only) when the project ships no
3033
+ * docs/token-pricing.json.
3034
+ * @throws {Error} DISPATCH_VALIDATION_ERROR for a malformed args.project/
3035
+ * args.runId (via _requireNonEmptyString, same as every other command in
3036
+ * this module); any error store.readState throws (STATE_NOT_FOUND,
3037
+ * STATE_CORRUPTED) propagates unmodified — a corrupted/missing run is
3038
+ * never silently defaulted; LEDGER_READ_FAILED/LEDGER_CORRUPTED/
3039
+ * PRICING_READ_FAILED/PRICING_CORRUPTED for an unreadable or unparseable
3040
+ * ledger or pricing file.
2811
3041
  */
2812
3042
  async function status(args) {
2813
- return _notImplemented('status', 'US-06-TASK-BE-01');
3043
+ args = args || {};
3044
+ _requireNonEmptyString(args.project, 'status: args.project');
3045
+ _requireNonEmptyString(args.runId, 'status: args.runId');
3046
+
3047
+ const projectDir = args.project;
3048
+ const runId = args.runId;
3049
+ const commonDir = _resolveCommonGitDir(projectDir);
3050
+ const executionRoot = path.join(commonDir, 'ai-toolkit', 'execution');
3051
+
3052
+ // Propagates STATE_NOT_FOUND / STATE_CORRUPTED unmodified — never
3053
+ // fabricates an empty/default run for a corrupted/missing one.
3054
+ const state = store.readState(executionRoot, runId);
3055
+
3056
+ const taskCounts = { pending: 0, active: 0, checkpointed: 0, integrated: 0, skipped: 0, blocked: 0 };
3057
+ Object.keys(state.tasks).forEach(function (taskId) {
3058
+ const st = state.tasks[taskId].status;
3059
+ if (Object.prototype.hasOwnProperty.call(taskCounts, st)) {
3060
+ taskCounts[st] += 1;
3061
+ }
3062
+ });
3063
+
3064
+ const ledgerEntries = _readRunLedgerEntries(executionRoot, runId);
3065
+ const totals = _summarizeLedgerTotals(ledgerEntries, projectDir);
3066
+
3067
+ return {
3068
+ protocolVersion: store.PROTOCOL_VERSION,
3069
+ runId: runId,
3070
+ runStatus: state.runStatus,
3071
+ taskCounts: taskCounts,
3072
+ totalTokens: totals.totalTokens,
3073
+ totalCostUsd: totals.totalCostUsd,
3074
+ };
2814
3075
  }
2815
3076
 
2816
3077
  /**
@@ -3726,4 +3987,6 @@ module.exports = {
3726
3987
  DEFAULT_TAGGED_PROCESS_EXE_BASENAME,
3727
3988
  _buildProcessTag,
3728
3989
  _findLiveTaggedProcess,
3990
+ _measureTokensFromSpawnResult,
3991
+ _finalizeDispatchLedgerActivity,
3729
3992
  };
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.5",
4
4
  "description": "AI Toolkit — reusable agents and procedures for developers to build AI-powered applications.",
5
5
  "files": [
6
6
  "docs/",