agentxchain 2.155.73 → 2.157.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.
Files changed (39) hide show
  1. package/bin/agentxchain.js +22 -0
  2. package/dashboard/app.js +54 -0
  3. package/dashboard/components/org-audit-trail.js +161 -0
  4. package/dashboard/components/org-history.js +140 -0
  5. package/dashboard/components/org-overview.js +145 -0
  6. package/dashboard/components/org-runs.js +168 -0
  7. package/dashboard/index.html +4 -0
  8. package/package.json +2 -1
  9. package/src/commands/ci-report.js +80 -0
  10. package/src/commands/doctor.js +22 -1
  11. package/src/commands/intake-approve.js +1 -0
  12. package/src/commands/replay.js +1 -0
  13. package/src/commands/run.js +47 -0
  14. package/src/commands/serve.js +64 -0
  15. package/src/commands/step.js +16 -0
  16. package/src/commands/verify.js +1 -0
  17. package/src/lib/adapters/local-cli-adapter.js +184 -0
  18. package/src/lib/api/execution-worker.js +192 -0
  19. package/src/lib/api/hosted-runner.js +494 -0
  20. package/src/lib/api/job-queue.js +152 -0
  21. package/src/lib/api/org-state-aggregator.js +428 -0
  22. package/src/lib/api/project-registry.js +148 -0
  23. package/src/lib/api/protocol-bridge.js +476 -0
  24. package/src/lib/approval-policy.js +12 -0
  25. package/src/lib/ci-reporter.js +200 -0
  26. package/src/lib/claude-local-auth.js +75 -1
  27. package/src/lib/connector-probe.js +21 -0
  28. package/src/lib/continuous-run.js +75 -4
  29. package/src/lib/dashboard/bridge-server.js +10 -5
  30. package/src/lib/governed-state.js +1 -1
  31. package/src/lib/intake.js +32 -6
  32. package/src/lib/normalized-config.js +2 -0
  33. package/src/lib/scope-overlap.js +214 -0
  34. package/src/lib/stream-json-cost-parser.js +169 -0
  35. package/src/lib/turn-checkpoint.js +21 -0
  36. package/src/lib/turn-result-validator.js +25 -0
  37. package/src/lib/validation.js +11 -3
  38. package/src/lib/verification-replay.js +125 -4
  39. package/src/lib/vision-reader.js +7 -2
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Stream-JSON cost parser — extracts usage/cost metadata from Claude Code's
3
+ * stream-json NDJSON stdout output.
4
+ *
5
+ * Claude Code with `--output-format stream-json --verbose` emits newline-
6
+ * delimited JSON events. The final `result` event contains a `usage` object
7
+ * with `input_tokens`, `output_tokens`, and optionally cache token counts,
8
+ * plus a top-level `cost_usd` and `model` field.
9
+ *
10
+ * This parser accumulates stdout chunks, splits by newline, parses each
11
+ * complete line as JSON, and stores the last `result` event's cost data.
12
+ *
13
+ * Resilience guarantees:
14
+ * - Partial JSON lines are buffered until the next newline arrives.
15
+ * - Non-JSON lines are silently skipped (no throw, no error log).
16
+ * - Multiple `result` events: last one wins.
17
+ * - Missing `usage` in a `result` event: getResult() returns null.
18
+ */
19
+
20
+ // Claude-specific bundled cost rates (USD per million tokens).
21
+ // Duplicated from api-proxy-adapter.js for this module's use only — DEC-004
22
+ // defers extraction to a shared module.
23
+ const CLAUDE_COST_RATES = {
24
+ 'claude-sonnet-4-6': { input_per_1m: 3.00, output_per_1m: 15.00 },
25
+ 'claude-opus-4-6': { input_per_1m: 5.00, output_per_1m: 25.00 },
26
+ 'claude-haiku-4-5-20251001': { input_per_1m: 1.00, output_per_1m: 5.00 },
27
+ };
28
+
29
+ /**
30
+ * Resolve cost rates for a model. Checks operator-supplied overrides first,
31
+ * then falls back to the bundled Claude rates.
32
+ *
33
+ * @param {string} model
34
+ * @param {object} [config]
35
+ * @returns {{ input_per_1m: number, output_per_1m: number } | null}
36
+ */
37
+ export function getClaudeCostRates(model, config) {
38
+ const operatorRates = config?.budget?.cost_rates;
39
+ if (operatorRates && typeof operatorRates === 'object' && operatorRates[model]) {
40
+ const r = operatorRates[model];
41
+ if (Number.isFinite(r.input_per_1m) && Number.isFinite(r.output_per_1m)) {
42
+ return r;
43
+ }
44
+ }
45
+ return CLAUDE_COST_RATES[model] || null;
46
+ }
47
+
48
+ /**
49
+ * @typedef {Object} StreamJsonCostResult
50
+ * @property {number} input_tokens
51
+ * @property {number} output_tokens
52
+ * @property {number|null} cost_usd - Claude Code's self-reported cost (if present)
53
+ * @property {string|null} model - Model identifier for rate lookup
54
+ * @property {number|null} cache_creation_input_tokens
55
+ * @property {number|null} cache_read_input_tokens
56
+ */
57
+
58
+ /**
59
+ * Create a stateful parser that accumulates stream-json chunks and extracts
60
+ * the final result event containing usage metadata.
61
+ *
62
+ * @returns {{ push(chunk: string): void, getResult(): StreamJsonCostResult | null }}
63
+ */
64
+ export function createStreamJsonCostParser() {
65
+ let buffer = '';
66
+ /** @type {StreamJsonCostResult | null} */
67
+ let lastResult = null;
68
+
69
+ return {
70
+ /**
71
+ * Feed a stdout chunk into the parser.
72
+ * @param {string} chunk
73
+ */
74
+ push(chunk) {
75
+ buffer += chunk;
76
+ const lines = buffer.split('\n');
77
+ // Last element is either empty (line ended with \n) or a partial line
78
+ buffer = lines.pop() || '';
79
+
80
+ for (const line of lines) {
81
+ const trimmed = line.trim();
82
+ if (!trimmed) continue;
83
+
84
+ let parsed;
85
+ try {
86
+ parsed = JSON.parse(trimmed);
87
+ } catch {
88
+ // Non-JSON line (tool output, diagnostic text) — skip silently
89
+ continue;
90
+ }
91
+
92
+ if (
93
+ parsed &&
94
+ typeof parsed === 'object' &&
95
+ parsed.type === 'result' &&
96
+ parsed.usage &&
97
+ typeof parsed.usage === 'object' &&
98
+ Number.isFinite(parsed.usage.input_tokens) &&
99
+ Number.isFinite(parsed.usage.output_tokens)
100
+ ) {
101
+ lastResult = {
102
+ input_tokens: parsed.usage.input_tokens,
103
+ output_tokens: parsed.usage.output_tokens,
104
+ cost_usd: typeof parsed.cost_usd === 'number' && Number.isFinite(parsed.cost_usd)
105
+ ? parsed.cost_usd
106
+ : null,
107
+ model: typeof parsed.model === 'string' ? parsed.model : null,
108
+ cache_creation_input_tokens: Number.isFinite(parsed.usage.cache_creation_input_tokens)
109
+ ? parsed.usage.cache_creation_input_tokens
110
+ : null,
111
+ cache_read_input_tokens: Number.isFinite(parsed.usage.cache_read_input_tokens)
112
+ ? parsed.usage.cache_read_input_tokens
113
+ : null,
114
+ };
115
+ }
116
+ }
117
+ },
118
+
119
+ /**
120
+ * Return the extracted cost data, or null if no valid result event was found.
121
+ * @returns {StreamJsonCostResult | null}
122
+ */
123
+ getResult() {
124
+ return lastResult;
125
+ },
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Build a cost object from parsed stream-json data for turn-result enrichment.
131
+ *
132
+ * @param {StreamJsonCostResult} parsedCost
133
+ * @param {object} [config] - normalized config (for operator cost_rates overrides)
134
+ * @returns {{ input_tokens: number, output_tokens: number, usd: number, source: string, model?: string, cache_creation_input_tokens?: number, cache_read_input_tokens?: number }}
135
+ */
136
+ export function buildCostFromStreamJson(parsedCost, config) {
137
+ const cost = {
138
+ input_tokens: parsedCost.input_tokens,
139
+ output_tokens: parsedCost.output_tokens,
140
+ source: 'stream_json',
141
+ };
142
+
143
+ // Prefer Claude Code's own cost_usd when available
144
+ if (typeof parsedCost.cost_usd === 'number' && Number.isFinite(parsedCost.cost_usd)) {
145
+ cost.usd = Math.round(parsedCost.cost_usd * 1000) / 1000;
146
+ } else if (parsedCost.model) {
147
+ const rates = getClaudeCostRates(parsedCost.model, config);
148
+ if (rates) {
149
+ cost.usd = Math.round(
150
+ ((parsedCost.input_tokens / 1_000_000) * rates.input_per_1m +
151
+ (parsedCost.output_tokens / 1_000_000) * rates.output_per_1m) * 1000
152
+ ) / 1000;
153
+ } else {
154
+ cost.usd = 0;
155
+ }
156
+ } else {
157
+ cost.usd = 0;
158
+ }
159
+
160
+ if (parsedCost.model) cost.model = parsedCost.model;
161
+ if (parsedCost.cache_creation_input_tokens != null) {
162
+ cost.cache_creation_input_tokens = parsedCost.cache_creation_input_tokens;
163
+ }
164
+ if (parsedCost.cache_read_input_tokens != null) {
165
+ cost.cache_read_input_tokens = parsedCost.cache_read_input_tokens;
166
+ }
167
+
168
+ return cost;
169
+ }
@@ -352,6 +352,27 @@ export function checkpointAcceptedTurn(root, opts = {}) {
352
352
  }
353
353
 
354
354
  const entry = resolved.entry;
355
+
356
+ // Proposed turns materialize their files under .agentxchain/proposed/<turn_id>/, never
357
+ // the workspace — there is nothing to `git add` until `proposal apply` promotes them.
358
+ // Attempting to stage the declared workspace paths fails with "pathspec did not match",
359
+ // so checkpoint cleanly skips proposed/patch turns.
360
+ if (
361
+ entry?.artifact
362
+ && typeof entry.artifact === 'object'
363
+ && !Array.isArray(entry.artifact)
364
+ && entry.artifact.type === 'patch'
365
+ && typeof entry.artifact.ref === 'string'
366
+ && entry.artifact.ref.startsWith('.agentxchain/proposed/')
367
+ ) {
368
+ return {
369
+ ok: true,
370
+ skipped: true,
371
+ turn: entry,
372
+ reason: 'Proposed turn materialized under .agentxchain/proposed/; no workspace checkpoint needed until proposal apply.',
373
+ };
374
+ }
375
+
355
376
  const supplementalFilesChanged = entry.checkpoint_sha
356
377
  ? recoverSupplementalCheckpointFiles(root, entry)
357
378
  : [];
@@ -737,6 +737,16 @@ function validateArtifact(tr, config, state = null) {
737
737
  `Role "${tr.role}" completed an implementation turn without product code changes in files_changed. ` +
738
738
  'Implementation-phase completion requires at least one non-planning, non-review repo path; planning artifacts alone are not sufficient.'
739
739
  );
740
+ } else if (productFiles.every(f => isTestPath(f))) {
741
+ // Work-substance signal: an implementation turn that changed ONLY test files produced
742
+ // no implementation source. That is legitimate for acceptance/verification objectives,
743
+ // but thin for an "implement X" objective — surface it for QA/operator review instead
744
+ // of silently accepting (this test-only pattern slipped through QA during dogfooding).
745
+ warnings.push(
746
+ `Role "${tr.role}" completed an implementation turn that changed only test files ` +
747
+ `(${productFiles.join(', ')}) with no implementation source. This is valid for acceptance or ` +
748
+ 'verification work; if the objective was to implement behavior, review whether the turn is test-only before accepting.'
749
+ );
740
750
  }
741
751
  }
742
752
 
@@ -792,6 +802,16 @@ function isProductChangePath(filePath) {
792
802
  && !filePath.startsWith('.agentxchain/staging/');
793
803
  }
794
804
 
805
+ // A test/spec file path — used to flag implementation turns that produced only tests
806
+ // (no implementation source) so test-only work is reviewed rather than silently accepted.
807
+ function isTestPath(filePath) {
808
+ if (typeof filePath !== 'string') {
809
+ return false;
810
+ }
811
+ return /(^|\/)(tests?|__tests__|spec)\//.test(filePath)
812
+ || /\.(test|spec)\.[cm]?[jt]sx?$/.test(filePath);
813
+ }
814
+
795
815
  function hasCheckpointableProducedFiles(tr) {
796
816
  return Array.isArray(tr.verification?.produced_files)
797
817
  && tr.verification.produced_files.some((entry) => (
@@ -1523,6 +1543,11 @@ export function normalizeTurnResult(tr, config, context = {}) {
1523
1543
  && (
1524
1544
  context.forceReviewArtifact
1525
1545
  || hasExplicitNoEditLifecycleSignal
1546
+ // review_only roles (PM/QA) legitimately produce no-edit reviews — an empty
1547
+ // workspace from a COMPLETED review_only turn normalizes to a review. Authoritative
1548
+ // /code-writing roles claiming a completed workspace artifact with no files stay
1549
+ // fail-closed, and non-completed (blocked/failed) turns never normalize (BUG-78).
1550
+ || (isReviewOnly && normalized.status === 'completed')
1526
1551
  || (normalized.status === 'needs_human' && normalized.proposed_next_role === 'human')
1527
1552
  )
1528
1553
  ) {
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, readdirSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { validateStagedTurnResult, STAGING_PATH } from './turn-result-validator.js';
4
4
  import { getActiveTurn } from './governed-state.js';
5
+ import { getTurnStagingResultPath } from './turn-paths.js';
5
6
  import {
6
7
  validateGovernedProjectTemplate,
7
8
  validateGovernedTemplateRegistry,
@@ -191,11 +192,18 @@ export function validateGovernedProject(root, rawConfig, config, opts = {}) {
191
192
 
192
193
  // ── Staged turn-result validation (the acceptance boundary) ─────────────
193
194
  if (mode === 'turn' && state) {
194
- const stagingAbs = join(root, STAGING_PATH);
195
+ // RB-12: prefer the turn-scoped staging path for the active turn; the shared
196
+ // legacy path is only a fallback. Reading the legacy path unconditionally
197
+ // made `validate --mode turn` look at the wrong file and falsely report a
198
+ // missing turn result for turns staged at the turn-scoped path.
199
+ const activeTurn = getActiveTurn(state) || state.current_turn || null;
200
+ const turnScoped = activeTurn?.turn_id ? getTurnStagingResultPath(activeTurn.turn_id) : null;
201
+ const stagingRel = (turnScoped && existsSync(join(root, turnScoped))) ? turnScoped : STAGING_PATH;
202
+ const stagingAbs = join(root, stagingRel);
195
203
  if (!existsSync(stagingAbs)) {
196
- warnings.push(`No staged turn result found at ${STAGING_PATH}. Agent has not yet emitted a turn result.`);
204
+ warnings.push(`No staged turn result found at ${stagingRel}. Agent has not yet emitted a turn result.`);
197
205
  } else {
198
- const turnValidation = validateStagedTurnResult(root, state, config);
206
+ const turnValidation = validateStagedTurnResult(root, state, config, { stagingPath: stagingRel });
199
207
  if (!turnValidation.ok) {
200
208
  errors.push(
201
209
  `Staged turn result failed at stage "${turnValidation.stage}" (${turnValidation.error_class}):`,
@@ -8,7 +8,7 @@ import { isOperationalPath } from './repo-observer.js';
8
8
 
9
9
  export const DEFAULT_VERIFICATION_REPLAY_TIMEOUT_MS = 30_000;
10
10
 
11
- export function replayVerificationMachineEvidence({ root, verification, timeoutMs = DEFAULT_VERIFICATION_REPLAY_TIMEOUT_MS }) {
11
+ export function replayVerificationMachineEvidence({ root, verification, timeoutMs = DEFAULT_VERIFICATION_REPLAY_TIMEOUT_MS, allowCommandExecution = false }) {
12
12
  const verifiedAt = new Date().toISOString();
13
13
  const machineEvidence = Array.isArray(verification?.machine_evidence)
14
14
  ? verification.machine_evidence
@@ -28,6 +28,27 @@ export function replayVerificationMachineEvidence({ root, verification, timeoutM
28
28
  return payload;
29
29
  }
30
30
 
31
+ // SECURITY (verification-replay hardening): agent-declared machine_evidence
32
+ // commands are UNTRUSTED. Fail-safe default — they are recorded but NOT executed
33
+ // unless the operator has explicitly opted in (allowCommandExecution). When
34
+ // executed, they never run through a shell (see replayEvidenceCommand).
35
+ if (!allowCommandExecution) {
36
+ payload.overall = 'not_executed';
37
+ payload.reason = 'Command replay disabled: agent-declared verification commands are not executed unless explicitly enabled by the operator.';
38
+ payload.commands = machineEvidence.map((entry, index) => ({
39
+ index,
40
+ command: entry?.command ?? null,
41
+ declared_exit_code: entry?.exit_code,
42
+ actual_exit_code: null,
43
+ matched: false,
44
+ executed: false,
45
+ timed_out: false,
46
+ signal: null,
47
+ error: null,
48
+ }));
49
+ return payload;
50
+ }
51
+
31
52
  const workspaceGuard = createReplayWorkspaceGuard(root);
32
53
  try {
33
54
  payload.commands = machineEvidence.map((entry, index) => replayEvidenceCommand(root, entry, index, timeoutMs));
@@ -41,11 +62,33 @@ export function replayVerificationMachineEvidence({ root, verification, timeoutM
41
62
  return payload;
42
63
  }
43
64
 
65
+ // SECURITY: agent-declared commands are NEVER run through a shell. The command is
66
+ // resolved to an argv vector and executed with shell:false, so shell metacharacters
67
+ // (; | && $() backticks globs) cannot chain, pipe, expand, or substitute. (A single
68
+ // declared binary still runs on the host — that is gated by the explicit
69
+ // allowCommandExecution opt-in upstream; a future sandbox/allowlist can constrain
70
+ // which binaries are permitted.)
44
71
  export function replayEvidenceCommand(root, entry, index, timeoutMs = DEFAULT_VERIFICATION_REPLAY_TIMEOUT_MS) {
45
- const result = spawnSync(entry.command, {
72
+ const argv = resolveEvidenceArgv(entry);
73
+ if (argv.length === 0) {
74
+ return {
75
+ index,
76
+ command: entry?.command ?? null,
77
+ declared_exit_code: entry?.exit_code,
78
+ actual_exit_code: null,
79
+ matched: false,
80
+ executed: false,
81
+ timed_out: false,
82
+ signal: null,
83
+ error: 'No executable command or argv vector could be resolved for this evidence entry.',
84
+ };
85
+ }
86
+
87
+ const [file, ...args] = argv;
88
+ const result = spawnSync(file, args, {
46
89
  cwd: root,
47
90
  encoding: 'utf8',
48
- shell: true,
91
+ shell: false,
49
92
  timeout: timeoutMs,
50
93
  maxBuffer: 1024 * 1024,
51
94
  });
@@ -56,16 +99,94 @@ export function replayEvidenceCommand(root, entry, index, timeoutMs = DEFAULT_VE
56
99
 
57
100
  return {
58
101
  index,
59
- command: entry.command,
102
+ command: entry.command ?? argv.join(' '),
60
103
  declared_exit_code: entry.exit_code,
61
104
  actual_exit_code: actualExitCode,
62
105
  matched: actualExitCode === entry.exit_code,
106
+ executed: true,
63
107
  timed_out: timedOut,
64
108
  signal: result.signal || null,
65
109
  error: errorMessage,
66
110
  };
67
111
  }
68
112
 
113
+ // Resolve an evidence entry to an argv vector for shell-free execution.
114
+ // Prefers a structured `argv` string array; falls back to safe-tokenizing `command`.
115
+ export function resolveEvidenceArgv(entry) {
116
+ if (Array.isArray(entry?.argv) && entry.argv.length > 0 && entry.argv.every((a) => typeof a === 'string')) {
117
+ return entry.argv.slice();
118
+ }
119
+ if (typeof entry?.command === 'string' && entry.command.trim()) {
120
+ return tokenizeCommand(entry.command.trim());
121
+ }
122
+ return [];
123
+ }
124
+
125
+ // Minimal SAFE tokenizer: splits on whitespace, honoring single and double quotes
126
+ // and backslash escapes (so e.g. `node -e "{\"ok\":true}"` keeps its escaped quotes).
127
+ // Performs NO shell expansion — variables, globs, pipes, chaining, and command
128
+ // substitution are preserved as literal characters, never interpreted.
129
+ export function tokenizeCommand(command) {
130
+ if (typeof command !== 'string') {
131
+ return [];
132
+ }
133
+ const tokens = [];
134
+ let current = '';
135
+ let hasToken = false;
136
+ let i = 0;
137
+ const n = command.length;
138
+ while (i < n) {
139
+ const ch = command[i];
140
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
141
+ if (hasToken) {
142
+ tokens.push(current);
143
+ current = '';
144
+ hasToken = false;
145
+ }
146
+ i += 1;
147
+ continue;
148
+ }
149
+ hasToken = true;
150
+ if (ch === "'") {
151
+ // Single-quoted: literal run until the next single quote (no escapes, POSIX-style).
152
+ i += 1;
153
+ while (i < n && command[i] !== "'") {
154
+ current += command[i];
155
+ i += 1;
156
+ }
157
+ i += 1; // consume closing quote (or run off the end)
158
+ continue;
159
+ }
160
+ if (ch === '"') {
161
+ // Double-quoted: backslash escapes only the structural `"` and `\` characters.
162
+ i += 1;
163
+ while (i < n && command[i] !== '"') {
164
+ if (command[i] === '\\' && i + 1 < n && (command[i + 1] === '"' || command[i + 1] === '\\')) {
165
+ current += command[i + 1];
166
+ i += 2;
167
+ } else {
168
+ current += command[i];
169
+ i += 1;
170
+ }
171
+ }
172
+ i += 1; // consume closing quote (or run off the end)
173
+ continue;
174
+ }
175
+ if (ch === '\\' && i + 1 < n) {
176
+ // Bare backslash escape: take the next character literally.
177
+ current += command[i + 1];
178
+ i += 2;
179
+ continue;
180
+ }
181
+ current += ch;
182
+ i += 1;
183
+ }
184
+ if (hasToken) {
185
+ tokens.push(current);
186
+ }
187
+ return tokens;
188
+ }
189
+
69
190
  export function summarizeVerificationReplay(payload) {
70
191
  if (!payload) {
71
192
  return null;
@@ -16,6 +16,11 @@ import { join, resolve as pathResolve, isAbsolute } from 'node:path';
16
16
  import { createHash } from 'node:crypto';
17
17
 
18
18
  const ROADMAP_TRACKING_ANNOTATION_PATTERN = /<!--\s*tracking\s*:[\s\S]*?-->/i;
19
+ // RB-3: a milestone left unchecked but annotated as delivered/done/shipped is
20
+ // already-built work — skip it so the loop derives genuinely undelivered work
21
+ // instead of re-picking finished items. Distinct from generic comments like
22
+ // `<!-- owner: dev -->`, which stay actionable.
23
+ const ROADMAP_DELIVERED_ANNOTATION_PATTERN = /<!--[^>]*\b(delivered|shipped|implemented|completed|done)\b[^>]*-->/i;
19
24
 
20
25
  export function stripRoadmapTrackingAnnotations(text) {
21
26
  if (typeof text !== 'string') return '';
@@ -266,7 +271,7 @@ export function deriveRoadmapCandidates(root, roadmapPath = '.planning/ROADMAP.m
266
271
 
267
272
  const uncheckedMatch = line.match(/^\s*[-*]\s+\[\s\]\s+(.+?)\s*$/);
268
273
  if (!uncheckedMatch || !currentMilestone) continue;
269
- if (ROADMAP_TRACKING_ANNOTATION_PATTERN.test(line)) continue;
274
+ if (ROADMAP_TRACKING_ANNOTATION_PATTERN.test(line) || ROADMAP_DELIVERED_ANNOTATION_PATTERN.test(line)) continue;
270
275
 
271
276
  const goal = stripRoadmapTrackingAnnotations(uncheckedMatch[1]);
272
277
  if (!goal) continue;
@@ -493,7 +498,7 @@ export function detectRoadmapExhaustedVisionOpen(root, visionPath, roadmapPath =
493
498
  continue;
494
499
  }
495
500
  if (currentMilestone && /^\s*[-*]\s+\[\s\]/.test(line)) {
496
- if (ROADMAP_TRACKING_ANNOTATION_PATTERN.test(line)) continue;
501
+ if (ROADMAP_TRACKING_ANNOTATION_PATTERN.test(line) || ROADMAP_DELIVERED_ANNOTATION_PATTERN.test(line)) continue;
497
502
  hasUnchecked = true;
498
503
  }
499
504
  }