@khoavu882/doflow 2.0.0-beta.4 → 2.0.0-beta.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.
Files changed (101) hide show
  1. package/core/.antigravity-plugin/plugin.json +0 -1
  2. package/core/.claude-plugin/plugin.json +1 -1
  3. package/core/.codex-plugin/plugin.json +1 -1
  4. package/core/.plugin/plugin.json +1 -1
  5. package/core/harnesses/claude/hooks/mcp-tool-guard.sh +7 -17
  6. package/core/harnesses/claude/hooks/pre-bash-guard.sh +7 -17
  7. package/core/harnesses/claude/hooks/pre-implement-gate.sh +7 -18
  8. package/core/harnesses/claude/hooks/session-start.sh +5 -3
  9. package/core/harnesses/claude/hooks/stop-check.sh +5 -3
  10. package/core/harnesses/claude/hooks/subagent-audit.sh +5 -5
  11. package/core/harnesses/claude/hooks/user-prompt-submit.sh +5 -3
  12. package/core/harnesses/codex/hooks/mcp-tool-guard.sh +7 -15
  13. package/core/harnesses/codex/hooks/pre-bash-guard.sh +7 -15
  14. package/core/harnesses/codex/hooks/pre-implement-gate.sh +7 -16
  15. package/core/harnesses/codex/hooks/session-start.sh +5 -3
  16. package/core/harnesses/codex/hooks/stop-check.sh +6 -5
  17. package/core/harnesses/codex/hooks/subagent-audit.sh +17 -6
  18. package/core/harnesses/codex/hooks/user-prompt-submit.sh +5 -4
  19. package/core/harnesses/gemini/hooks/mcp-tool-guard.sh +7 -18
  20. package/core/harnesses/gemini/hooks/pre-bash-guard.sh +7 -18
  21. package/core/harnesses/gemini/hooks/pre-implement-gate.sh +7 -19
  22. package/core/harnesses/gemini/hooks/session-start.sh +5 -3
  23. package/core/harnesses/kiro/hooks/mcp-tool-guard.sh +20 -5
  24. package/core/harnesses/kiro/hooks/pre-bash-guard.sh +20 -5
  25. package/core/harnesses/kiro/hooks/pre-implement-gate.sh +20 -5
  26. package/core/harnesses/kiro/hooks/session-start.sh +11 -5
  27. package/core/harnesses/kiro/hooks/stop-check.sh +21 -30
  28. package/core/harnesses/pi/extensions/doflow-guard.js +159 -0
  29. package/core/harnesses/shared/hooks/ENVELOPE_MIGRATION.md +27 -11
  30. package/core/harnesses/shared/hooks/adapters/antigravity.js +14 -57
  31. package/core/harnesses/shared/hooks/bridge/bridge.sh +184 -0
  32. package/core/harnesses/shared/hooks/bridge/envelope.jq +60 -0
  33. package/core/harnesses/shared/hooks/bridge/respond.sh +62 -0
  34. package/core/harnesses/shared/hooks/policies/capture-failure.sh +4 -2
  35. package/core/harnesses/shared/hooks/policies/contract.sh +75 -0
  36. package/core/harnesses/shared/hooks/policies/deny-json.sh +39 -4
  37. package/core/harnesses/shared/hooks/policies/lib.sh +4 -2
  38. package/core/harnesses/shared/hooks/policies/mcp-tool-guard.sh +46 -34
  39. package/core/harnesses/shared/hooks/policies/pre-bash-guard.sh +75 -25
  40. package/core/harnesses/shared/hooks/policies/pre-implementation-gate.sh +67 -73
  41. package/core/harnesses/shared/hooks/policies/session-context.sh +37 -7
  42. package/core/harnesses/shared/hooks/policies/stop-check.sh +42 -15
  43. package/core/harnesses/shared/hooks/policies/subagent-audit.sh +35 -5
  44. package/core/harnesses/shared/hooks/policies/user-prompt-submit.sh +40 -7
  45. package/core/harnesses/shared/hooks/stream-hook-runner.js +69 -181
  46. package/core/registry/assets.json +19 -0
  47. package/core/registry/contracts.json +2 -2
  48. package/core/registry/harnesses.json +196 -10
  49. package/core/registry/lifecycle.json +2626 -7
  50. package/core/registry/storage.json +24 -1
  51. package/core/shared/scripts/doflow/bin/doflow-run +10 -6
  52. package/core/shared/skills/do-design/SKILL.md +6 -6
  53. package/core/shared/skills/do-execute-plan/SKILL.md +11 -10
  54. package/core/shared/skills/do-execute-plan/references/readiness_gate.md +2 -0
  55. package/core/shared/skills/do-implement/SKILL.md +3 -2
  56. package/core/shared/skills/do-plan/SKILL.md +7 -8
  57. package/package.json +1 -1
  58. package/src/adapters/antigravity/index.js +83 -47
  59. package/src/adapters/claude/index.js +15 -1
  60. package/src/adapters/codex/hooks.js +40 -5
  61. package/src/adapters/codex/index.js +52 -9
  62. package/src/adapters/copy-tree.js +4 -4
  63. package/src/adapters/gemini/hooks.js +57 -16
  64. package/src/adapters/gemini/index.js +57 -18
  65. package/src/adapters/kiro/index.js +15 -1
  66. package/src/adapters/pi/hooks.js +119 -0
  67. package/src/adapters/pi/index.js +34 -9
  68. package/src/cli/commands/install.js +1 -1
  69. package/src/cli/commands/update.js +1 -1
  70. package/src/cli/index.js +17 -5
  71. package/src/cli/runtime-commands.js +51 -12
  72. package/src/lifecycle/coverage.js +57 -0
  73. package/src/lifecycle/hook-step.js +61 -0
  74. package/src/lifecycle/index.js +28 -14
  75. package/src/registry/index.js +136 -7
  76. package/src/runtime/candidate.js +505 -0
  77. package/src/runtime/claims.js +14 -5
  78. package/src/runtime/cli-result.js +11 -4
  79. package/src/runtime/cli.js +35 -268
  80. package/src/runtime/context/budget.js +203 -0
  81. package/src/runtime/context/compose.js +1367 -0
  82. package/src/runtime/context/delivery-store.js +102 -0
  83. package/src/runtime/context/mandatory.js +594 -0
  84. package/src/runtime/context/pointer.js +142 -0
  85. package/src/runtime/context-pack.js +1029 -244
  86. package/src/runtime/evidence-fields.js +39 -0
  87. package/src/runtime/evidence-ledger.js +7 -0
  88. package/src/runtime/freshness.js +44 -7
  89. package/src/runtime/outcome.js +1 -1
  90. package/src/runtime/readiness.js +83 -13
  91. package/src/runtime/retrieval-plan.js +422 -73
  92. package/src/runtime/retrieval-record.js +223 -0
  93. package/src/runtime/scaffold/artifacts.js +3 -0
  94. package/src/runtime/source-version.js +289 -0
  95. package/src/runtime/task-attempts.js +1 -1
  96. package/src/runtime/task-scope.js +28 -3
  97. package/src/runtime/task-state.js +72 -7
  98. package/core/.antigravity-plugin/hooks.json +0 -39
  99. package/core/harnesses/shared/hooks/adapters/arg-fields.js +0 -19
  100. package/core/harnesses/shared/hooks/adapters/gemini.js +0 -114
  101. package/core/harnesses/shared/hooks/hooks.json +0 -47
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ // TypeScript migration (agent-docs/BRIEF_GOAL_DOFLOW.md): run directly by Node native type-stripping.
4
+
5
+ /** The names a retrieval score arrives under, in camelCase (`--relevance-score` normalises here).
6
+ * Exported because `bin/doflow.js` refuses the same names on the command line, and because
7
+ * `retrieval-plan.ts` and `outcome.ts` refuse them on the option object a declared plan and a
8
+ * terminal record are built from: one definition, three input surfaces (argv, a `--batch` JSON
9
+ * file, and a verb's own options), each with exactly one enforcement point. A second copy of
10
+ * this list is how one of them drifts. */
11
+ const EVIDENCE_SCORE_FIELDS = new Set([
12
+ 'confidence', 'score', 'relevance', 'relevanceScore', 'similarity',
13
+ 'certainty', 'probability', 'rank', 'distance', 'weight',
14
+ ]);
15
+
16
+ /** The one refusal message for a score offered as a property of a fact. */
17
+ function scoreFieldRefusal(name ) {
18
+ return `'${name}' is a retrieval score and this boundary records none (FR-007). Relevance is a `
19
+ + 'property of a search, not of a fact: stored beside the fact it is read as confidence by the '
20
+ + 'next worker, which is the specific interpretation the requirement forbids. Record the '
21
+ + 'locator and the content instead — a reader who can see where a fact came from does not need '
22
+ + 'to be told how sure to be.';
23
+ }
24
+
25
+ /** Refuses a score-shaped key anywhere in an item, including nested under source or locator. */
26
+ function assertNoScoreFields(value , prefix ) {
27
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return;
28
+ for (const [key, inner] of Object.entries(value )) {
29
+ const dotted = prefix ? `${prefix}.${key}` : key;
30
+ if (EVIDENCE_SCORE_FIELDS.has(key)) throw new Error(scoreFieldRefusal(dotted));
31
+ assertNoScoreFields(inner, dotted);
32
+ }
33
+ }
34
+
35
+ module.exports = {
36
+ EVIDENCE_SCORE_FIELDS,
37
+ scoreFieldRefusal,
38
+ assertNoScoreFields,
39
+ };
@@ -8,6 +8,7 @@ const crypto = require('node:crypto');
8
8
  const { REPO_ROOT } = require('../helper/repo-root.js');
9
9
  const { updateTaskState, readTaskState, mergeRecords } = require('./task-state.js');
10
10
  const { taskStoreDir } = require('./task-scope.js');
11
+ const { assertNoScoreFields } = require('./evidence-fields.js');
11
12
 
12
13
  const VALID_EVIDENCE_KINDS = new Set([
13
14
  'exact-search',
@@ -129,6 +130,7 @@ class EvidenceLedger {
129
130
  if (!item || typeof item !== 'object') {
130
131
  throw new Error('Evidence item must be an object');
131
132
  }
133
+ assertNoScoreFields(item, '');
132
134
 
133
135
  if (!item.kind || !VALID_EVIDENCE_KINDS.has(item.kind)) {
134
136
  throw new Error(`Invalid evidence kind '${item.kind}'. Allowed: ${[...VALID_EVIDENCE_KINDS].join(', ')}`);
@@ -152,6 +154,8 @@ class EvidenceLedger {
152
154
  fileHash: item.freshness?.fileHash || null,
153
155
  observedAt: item.freshness?.observedAt || new Date().toISOString(),
154
156
  status: item.freshness?.status || 'FRESH',
157
+ isDirty: item.freshness?.isDirty !== undefined ? item.freshness.isDirty : null,
158
+ ...(item.freshness?.treeFingerprint ? { treeFingerprint: item.freshness.treeFingerprint } : {}),
155
159
  };
156
160
 
157
161
  const evidenceRecord = {
@@ -163,6 +167,7 @@ class EvidenceLedger {
163
167
  provenance,
164
168
  content: item.content || null,
165
169
  freshness,
170
+ ...(item.result !== undefined ? { result: item.result } : {}),
166
171
  // Which template requirements this item claims to establish. Readiness counts an item toward
167
172
  // a requirement only when the writer named it here — kind-matching alone let an item saying
168
173
  // "tests failed" satisfy a passing-baseline requirement (architecture review R1, P1).
@@ -208,6 +213,8 @@ class EvidenceLedger {
208
213
  if (filter.capability && item.source.capability !== filter.capability) continue;
209
214
  if (filter.file && item.locator.file !== filter.file) continue;
210
215
  if (filter.status && item.freshness.status !== filter.status) continue;
216
+ if (filter.queryRef && item.source?.queryRef !== filter.queryRef) continue;
217
+ if (filter.attemptRef && item.source?.attemptRef !== filter.attemptRef) continue;
211
218
  results.push(item);
212
219
  }
213
220
  return results;
@@ -7,6 +7,8 @@ const path = require('node:path');
7
7
  const crypto = require('node:crypto');
8
8
  const { execFileSync } = require('node:child_process');
9
9
  const { REPO_ROOT } = require('../helper/repo-root.js');
10
+ const { measureTreeState } = require('./source-version.js');
11
+
10
12
 
11
13
  // Freshness is measured against the bytes observed, not whether the file was clean at HEAD.
12
14
  // Git identity remains provenance and a conservative fallback for older records without hashes.
@@ -25,17 +27,23 @@ function measureFreshness(root , locator , gitCommit ) {
25
27
 
26
28
  class FreshnessValidator {
27
29
  repoRoot ;
30
+ treeRoot ;
28
31
  gitRunner ;
29
32
 
33
+
34
+ validatingLedger ;
30
35
 
31
36
  /**
32
37
  * @param {Object} [options]
33
38
  * @param {string} [options.repoRoot]
39
+ * @param {string} [options.treeRoot]
34
40
  * @param {Function} [options.gitRunner]
35
41
  */
36
42
  constructor(options = {}) {
37
43
  this.repoRoot = options.repoRoot || REPO_ROOT;
44
+ this.treeRoot = options.treeRoot || this.repoRoot;
38
45
  this.gitRunner = options.gitRunner || this.defaultGitRunner.bind(this);
46
+ this.validatingLedger = false;
39
47
  }
40
48
 
41
49
  defaultGitRunner(args , cwd = this.repoRoot) {
@@ -121,6 +129,28 @@ class FreshnessValidator {
121
129
  return { status: 'STALE', reason: 'Already marked stale' };
122
130
  }
123
131
 
132
+ if (item.result === 'EMPTY' && item.freshness?.status === 'FRESH') {
133
+ let currentFingerprint;
134
+ if (this.validatingLedger) {
135
+ if (this.treeFingerprint === undefined) {
136
+ this.treeFingerprint = measureTreeState(this.treeRoot, this.gitRunner).fingerprint;
137
+ }
138
+ currentFingerprint = this.treeFingerprint;
139
+ } else {
140
+ currentFingerprint = measureTreeState(this.treeRoot, this.gitRunner).fingerprint;
141
+ }
142
+
143
+ if (!item.freshness?.treeFingerprint || !currentFingerprint) {
144
+ return { status: 'STALE', reason: 'tree-unverifiable' };
145
+ }
146
+
147
+ if (item.freshness.treeFingerprint !== currentFingerprint) {
148
+ return { status: 'STALE', reason: 'tree-changed-since-recorded' };
149
+ }
150
+
151
+ return { status: 'FRESH' };
152
+ }
153
+
124
154
  const targetFile = item.locator?.file;
125
155
  if (!targetFile) {
126
156
  // Evidence without a file locator (e.g. general documentation) remains fresh
@@ -157,15 +187,22 @@ class FreshnessValidator {
157
187
 
158
188
  let count = 0;
159
189
  const allEvidence = ledger.getAllEvidence();
160
-
161
- for (const item of allEvidence) {
162
- if (item.freshness?.status === 'FRESH') {
163
- const check = this.checkEvidenceFreshness(item);
164
- if (check.status === 'STALE') {
165
- item.freshness.status = 'STALE';
166
- count += 1;
190
+ this.treeFingerprint = undefined;
191
+ this.validatingLedger = true;
192
+ try {
193
+ for (const item of allEvidence) {
194
+ if (item.freshness?.status === 'FRESH') {
195
+ const check = this.checkEvidenceFreshness(item);
196
+ if (check.status === 'STALE') {
197
+ item.freshness.status = 'STALE';
198
+ item.freshness.staleReason = check.reason;
199
+ count += 1;
200
+ }
167
201
  }
168
202
  }
203
+ } finally {
204
+ this.treeFingerprint = undefined;
205
+ this.validatingLedger = false;
169
206
  }
170
207
  return count;
171
208
  }
@@ -55,7 +55,7 @@ const { planPath, UNREACHED } = require('./retrieval-plan.js');
55
55
  // One definition of the score refusal, applied at a further boundary. See the note on
56
56
  // EVIDENCE_SCORE_FIELDS in cli.js: a terminal state that carried a confidence would be exactly the
57
57
  // number NFR-001 exists to keep out of the one record the next worker reads first.
58
- const { assertNoScoreFields } = require('./cli.js');
58
+ const { assertNoScoreFields } = require('./evidence-fields.js');
59
59
  const { finishRuntime, usageError } = require('./cli-result.js');
60
60
  const { REPO_ROOT } = require('../helper/repo-root.js');
61
61
 
@@ -6,6 +6,7 @@ const fs = require('node:fs');
6
6
  const path = require('node:path');
7
7
  const { parseYamlFile } = require('./capability-router.js');
8
8
  const { resolveLocator, describeResolution } = require('./locator-resolve.js');
9
+ const { readPlan, bindEvidence, declaredIntentList } = require('./retrieval-record.js');
9
10
  const { REPO_ROOT } = require('../helper/repo-root.js');
10
11
 
11
12
  const READINESS_STATES = new Set([
@@ -211,7 +212,20 @@ class ReadinessEngine {
211
212
  hasBlocked = true;
212
213
  }
213
214
 
214
- const taskEvidence = evidenceLedger ? evidenceLedger.queryEvidence({ taskId, status: 'FRESH' }) : [];
215
+ const allTaskEvidence = evidenceLedger ? evidenceLedger.queryEvidence({ taskId }) : [];
216
+ let plan = null;
217
+ let unreadablePlan = null;
218
+ try {
219
+ plan = readPlan(this.projectRoot, taskId);
220
+ } catch (error ) {
221
+ if (error.code !== 'store-unreadable') throw error;
222
+ unreadablePlan = { unreadable: true, reason: error.message };
223
+ }
224
+ const queryBound = Boolean(plan?.planId);
225
+ const binding = queryBound ? bindEvidence(plan, allTaskEvidence) : null;
226
+ const unboundIds = new Set(binding ? binding.unbound.map((item ) => item.id) : []);
227
+ const taskEvidence = allTaskEvidence.filter((item ) => item.freshness?.status === 'FRESH'
228
+ && (!queryBound || !unboundIds.has(item.id)));
215
229
 
216
230
  // An item accepted before FR-004 existed, or one whose target file has since shrunk, can be
217
231
  // FRESH and still point at nothing. *Stale* (the file changed at all) and *unresolvable* (the
@@ -225,7 +239,8 @@ class ReadinessEngine {
225
239
  // in evaluateClaim — it is not a claim that the file never changed. An item that genuinely went
226
240
  // stale and was never superseded still forces NEEDS_EVIDENCE exactly as before.
227
241
  const staleEvidence = (evidenceLedger ? evidenceLedger.getAllEvidence() : [])
228
- .filter((item ) => item.taskId === taskId && item.freshness?.status === 'STALE' && item.status !== 'superseded')
242
+ .filter((item ) => item.taskId === taskId && item.result !== 'EMPTY'
243
+ && item.freshness?.status === 'STALE' && item.status !== 'superseded')
229
244
  .map((item ) => ({ evidenceId: item.id, locator: item.locator, reason: 'file-modified-since-recorded' }));
230
245
 
231
246
  const unresolvableEvidence = [];
@@ -242,9 +257,30 @@ class ReadinessEngine {
242
257
  }
243
258
  }
244
259
 
260
+ const staleAbsence = [];
261
+ const staleAbsenceByRequirement = new Map ();
262
+ for (const item of allTaskEvidence) {
263
+ if (item.result !== 'EMPTY' || item.status !== 'active'
264
+ || !['STALE', 'UNKNOWN'].includes(item.freshness?.status)
265
+ || (queryBound && unboundIds.has(item.id))) continue;
266
+ for (const requirement of Array.isArray(item.establishes) ? item.establishes : []) {
267
+ const entry = { evidenceId: item.id, requirement, provider: item.source?.provider || null,
268
+ freshness: item.freshness.status, refresh: plan?.providers?.[item.source?.provider]?.refresh || null };
269
+ staleAbsence.push(entry);
270
+ const entries = staleAbsenceByRequirement.get(requirement) || [];
271
+ entries.push({ item, entry });
272
+ staleAbsenceByRequirement.set(requirement, entries);
273
+ }
274
+ }
275
+
276
+ const unboundEvidence = queryBound
277
+ ? { count: binding.unbound.length, ids: binding.unbound.slice(0, 5).map((item ) => item.id) }
278
+ : undefined;
279
+
245
280
  for (const [reqId, reqDef] of Object.entries (requirements)) {
246
281
  let isSatisfied = false;
247
282
  let reason = null;
283
+ let reasonCode = null;
248
284
  let matchedEvidenceIds = [];
249
285
 
250
286
  // Check verification plan requirement
@@ -286,18 +322,18 @@ class ReadinessEngine {
286
322
  // (review R1, P1 — reproduced through the public CLI).
287
323
  else if (Array.isArray(reqDef.evidenceKinds) && reqDef.evidenceKinds.length > 0) {
288
324
  const allowedKinds = new Set(reqDef.evidenceKinds);
325
+ const rawKindMatched = allTaskEvidence.filter((ev ) => allowedKinds.has(ev.kind));
289
326
  const kindMatched = taskEvidence.filter((ev ) => allowedKinds.has(ev.kind));
290
327
  const extracted = kindMatched.filter((ev ) => ev.provenance === 'extracted');
291
328
  const bound = extracted.filter((ev ) => Array.isArray(ev.establishes) && ev.establishes.includes(reqId));
292
- let qualifying = bound;
293
- if (reqDef.requiresObservation) {
294
- qualifying = qualifying.filter((ev ) => ev.observation && Number.isInteger(ev.observation.exitCode));
295
- if (reqDef.expectedExit === 'zero') {
296
- qualifying = qualifying.filter((ev ) => ev.observation.exitCode === 0);
297
- } else if (reqDef.expectedExit === 'nonzero') {
298
- qualifying = qualifying.filter((ev ) => ev.observation.exitCode !== 0);
299
- }
300
- }
329
+ const observationOk = (ev ) => !reqDef.requiresObservation || (ev.observation
330
+ && Number.isInteger(ev.observation.exitCode)
331
+ && (reqDef.expectedExit !== 'zero' || ev.observation.exitCode === 0)
332
+ && (reqDef.expectedExit !== 'nonzero' || ev.observation.exitCode !== 0));
333
+ const qualifies = (ev ) => ev.provenance === 'extracted'
334
+ && Array.isArray(ev.establishes) && ev.establishes.includes(reqId)
335
+ && observationOk(ev);
336
+ const qualifying = taskEvidence.filter((ev ) => allowedKinds.has(ev.kind) && qualifies(ev));
301
337
  if (qualifying.length > 0) {
302
338
  isSatisfied = true;
303
339
  matchedEvidenceIds = qualifying.map((ev ) => ev.id);
@@ -319,6 +355,32 @@ class ReadinessEngine {
319
355
  + `${reqDef.expectedExit ? ` with a${reqDef.expectedExit === 'zero' ? ' passing (0)' : ' non-zero'} exit status` : ''} — `
320
356
  + 'record the execution itself, not a description of one.';
321
357
  }
358
+ if (!isSatisfied && reqDef.required) {
359
+ const staleAbsences = staleAbsenceByRequirement.get(reqId) || [];
360
+ if (staleAbsences.length > 0) {
361
+ const { item, entry } = staleAbsences[0];
362
+ const refresh = entry.refresh || 'none declared';
363
+ reasonCode = 'stale-absence-unverified';
364
+ const prefix = `'${reqId}' rests only on an empty answer from ${entry.provider || 'unknown provider'}, whose index was ${entry.freshness} when it was recorded (${item.id}). An empty answer from that index cannot establish absence. `;
365
+ if (item.freshness?.staleReason === 'tree-changed-since-recorded'
366
+ || item.freshness?.staleReason === 'tree-unverifiable') {
367
+ reason = `${prefix}the working tree changed since it was recorded; re-run the lookup and record the absence again.`;
368
+ } else if (item.freshness?.gitCommit === null) {
369
+ reason = `${prefix}absence cannot be established outside a git work tree; record a positive item.`;
370
+ } else {
371
+ reason = `${prefix}Run the declared refresh command (${refresh}) and record the absence again, or re-run the lookup with a live provider such as native.rg: doflow evidence --task-id ${taskId} --action add --absence --need <intent> --provider native.rg ...`;
372
+ }
373
+ } else if (queryBound) {
374
+ const unboundKind = rawKindMatched.filter((ev ) => unboundIds.has(ev.id));
375
+ const unboundQualifying = unboundKind.filter((ev ) =>
376
+ ev.freshness?.status === 'FRESH' && qualifies(ev));
377
+ if (unboundQualifying.length > 0) {
378
+ const ids = unboundQualifying.slice(0, 5).map((item ) => item.id).join(', ');
379
+ reasonCode = 'unbound-retrieval-evidence';
380
+ reason = `${unboundQualifying.length} item(s) of the right kind are not bound to a declared need of plan ${plan.planId} (${ids}), so they do not count. Re-record them with --need <intent>; declared: ${declaredIntentList(plan)}.`;
381
+ }
382
+ }
383
+ }
322
384
  }
323
385
  // Scope verified / scope clear
324
386
  else if (reqId === 'scope_clear' || reqId === 'scope_verified' || reqId === 'invariants_captured') {
@@ -347,6 +409,7 @@ class ReadinessEngine {
347
409
  required: Boolean(reqDef.required),
348
410
  satisfied: isSatisfied,
349
411
  reason,
412
+ reasonCode,
350
413
  evidenceIds: matchedEvidenceIds,
351
414
  recommendedAction: isSatisfied ? null : this.suggestAction(reqId, reqDef, taskProfile),
352
415
  });
@@ -383,6 +446,10 @@ class ReadinessEngine {
383
446
  + 'Re-record them against the tree as it stands.';
384
447
  }
385
448
 
449
+ if (unreadablePlan) {
450
+ summary += ` Warning: retrieval plan is unreadable and was treated as absent: ${unreadablePlan.reason}`;
451
+ }
452
+
386
453
  return {
387
454
  taskId,
388
455
  taskClass,
@@ -399,6 +466,9 @@ class ReadinessEngine {
399
466
  evidenceCount: taskEvidence.length,
400
467
  unresolvableEvidence,
401
468
  staleEvidence,
469
+ staleAbsence,
470
+ ...(unreadablePlan ? { retrievalPlan: unreadablePlan } : {}),
471
+ ...(unboundEvidence ? { unboundEvidence } : {}),
402
472
  };
403
473
  }
404
474
  }
@@ -406,7 +476,7 @@ class ReadinessEngine {
406
476
  /** One live snapshot for readiness inspection and orchestration transitions. Reads never persist
407
477
  * derived freshness or claim status; every invocation evaluates the current project again.
408
478
  * `mode` defaults to 'workflow' — fail closed: the standalone exemption must be declared. */
409
- function evaluateTaskReadiness({ taskProfile, repoRoot = REPO_ROOT, projectRoot = process.cwd(), mode = 'workflow' } ) {
479
+ function evaluateTaskReadiness({ taskProfile, repoRoot = REPO_ROOT, projectRoot = process.cwd(), treeRoot = projectRoot, mode = 'workflow' } ) {
410
480
  // Validated before any ledger work, not just inside stageEntryFor at the end — an invalid mode
411
481
  // used to pay a full freshness/claims evaluation before erroring (review suggestion, 026).
412
482
  if (!EXECUTION_MODES.has(mode)) {
@@ -417,7 +487,7 @@ function evaluateTaskReadiness({ taskProfile, repoRoot = REPO_ROOT, projectRoot
417
487
  const { FreshnessValidator } = require('./freshness.js');
418
488
  const ledger = new EvidenceLedger({ repoRoot: projectRoot });
419
489
  ledger.load(taskProfile.taskId);
420
- new FreshnessValidator({ repoRoot: projectRoot }).validateLedgerFreshness(ledger);
490
+ new FreshnessValidator({ repoRoot: projectRoot, treeRoot }).validateLedgerFreshness(ledger);
421
491
  const claims = new ClaimsManager({ evidenceLedger: ledger, repoRoot: projectRoot });
422
492
  claims.load(taskProfile.taskId);
423
493
  claims.evaluateAll();