@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,223 @@
1
+ 'use strict';
2
+
3
+ // TypeScript migration (agent-docs/BRIEF_GOAL_DOFLOW.md): run directly by Node native type-stripping.
4
+
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+ const crypto = require('node:crypto');
8
+ const { taskStoreDir, resolveTaskScope } = require('./task-scope.js');
9
+ const { assertSafeTaskId } = require('./evidence-ledger.js');
10
+ const { readTaskState, updateTaskState } = require('./task-state.js');
11
+ const { storeFile, activeAttempt } = require('./task-attempts.js');
12
+ const { projectRoot: lifecycleProjectRoot } = require('./lifecycle/root.js');
13
+
14
+ /**
15
+ * Path of a task's retrieval-plan store.
16
+ *
17
+ * The task id is validated by the evidence ledger's own rule, unchanged: it becomes a filename, so
18
+ * it must never be able to name a path.
19
+ * @param {string} projectRoot
20
+ * @param {string} taskId
21
+ * @param {string|null} [slug]
22
+ * @returns {string}
23
+ */
24
+ function planPath(projectRoot , taskId , slug = null) {
25
+ const dir = taskStoreDir({ projectRoot, store: 'retrieval', taskId: assertSafeTaskId(taskId), slug });
26
+ return path.join(dir, `${taskId}.json`);
27
+ }
28
+
29
+ /**
30
+ * Reads and validates one task's retrieval plan via readTaskState.
31
+ * Returns null when the plan file does not exist.
32
+ * @param {string} projectRoot
33
+ * @param {string} taskId
34
+ * @param {string|null} [slug]
35
+ * @param {any} [fsImpl]
36
+ * @returns {any|null}
37
+ */
38
+ function readPlan(projectRoot , taskId , slug = null, fsImpl = fs) {
39
+ const file = planPath(projectRoot, taskId, slug);
40
+ return readTaskState(fsImpl, file);
41
+ }
42
+
43
+ /**
44
+ * Updates a task's retrieval plan under the task-state lock.
45
+ * @param {string} projectRoot
46
+ * @param {string} taskId
47
+ * @param {string|null} slug
48
+ * @param {(disk: any|null) => any} build
49
+ * @param {any} [fsImpl]
50
+ * @returns {string} the written file path
51
+ */
52
+ function updatePlan(projectRoot , taskId , slug , build , fsImpl = fs, prepare ) {
53
+ const file = planPath(projectRoot, taskId, slug);
54
+ return updateTaskState({ fsImpl, file, build, prepare });
55
+ }
56
+
57
+ /** Move a syntactically unreadable plan aside before its replacement is written under the lock. */
58
+ function moveCorruptPlanAside(projectRoot , taskId , slug = null, fsImpl = fs) {
59
+ const file = planPath(projectRoot, taskId, slug);
60
+ if (!fsImpl.existsSync(file)) return null;
61
+ try {
62
+ JSON.parse(fsImpl.readFileSync(file, 'utf8'));
63
+ return null;
64
+ } catch (error ) {
65
+ if (!(error instanceof SyntaxError)) throw error;
66
+ const corruptFile = `${file}.corrupt-${new Date().toISOString().replace(/[:.]/g, '-')}`;
67
+ fsImpl.renameSync(file, corruptFile);
68
+ return corruptFile;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Mints a new plan id: rp_<base36 ms>_<4 hex>.
74
+ * @returns {string}
75
+ */
76
+ function mintPlanId() {
77
+ return `rp_${Date.now().toString(36)}_${crypto.randomBytes(2).toString('hex')}`;
78
+ }
79
+
80
+ /**
81
+ * Formats declared intents from a plan: sorted, comma-joined, or 'none declared'.
82
+ *
83
+ * @param {any} plan
84
+ * @returns {string}
85
+ */
86
+ function declaredIntentList(plan , sep = ', ') {
87
+ const declaredIntents = Array.isArray(plan?.needs)
88
+ ? plan.needs.map((n ) => n.intent).filter(Boolean).sort()
89
+ : [];
90
+ return declaredIntents.length > 0 ? declaredIntents.join(sep) : 'none declared';
91
+ }
92
+
93
+ /**
94
+ * Resolves a declared need from a plan for a given intent.
95
+ *
96
+ * @param {any} plan
97
+ * @param {string} intent
98
+ * @param {string} taskId
99
+ * @returns {{ ok: true, queryRef: string, need: any } | { ok: false, message: string }}
100
+ */
101
+ function resolveNeed(plan , intent , taskId ) {
102
+ if (!plan) {
103
+ const message = `no retrieval plan is declared for task '${taskId}', so --need '${intent}' names nothing. Declare one first: doflow retrieval-plan --task-id ${taskId} --action declare --need ${intent}, then re-run.`;
104
+ return { ok: false, message };
105
+ }
106
+ const list = declaredIntentList(plan);
107
+
108
+ if (!plan.planId) {
109
+ const message = `the retrieval plan for task '${taskId}' was declared before query binding and has no plan id. Re-declare it: doflow retrieval-plan --task-id ${taskId} --action declare --need ${declaredIntentList(plan, ',')}, then re-run.`;
110
+ return { ok: false, message };
111
+ }
112
+
113
+ const need = Array.isArray(plan.needs) ? plan.needs.find((n ) => n.intent === intent) : null;
114
+ if (!need) {
115
+ const message = `'${intent}' is not a declared need of task '${taskId}' (plan ${plan.planId}). Declared: ${list}. Re-run with one of them, or re-declare the plan with this need.`;
116
+ return { ok: false, message };
117
+ }
118
+
119
+ if (!need.queryRef) {
120
+ const message = `the retrieval plan for task '${taskId}' was declared before query binding and has no plan id. Re-declare it: doflow retrieval-plan --task-id ${taskId} --action declare --need ${declaredIntentList(plan, ',')}, then re-run.`;
121
+ return { ok: false, message };
122
+ }
123
+
124
+ return {
125
+ ok: true,
126
+ queryRef: need.queryRef,
127
+ need,
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Reads the 070 active attempt ref for a task, returning `${taskId}#${token}` or null.
133
+ *
134
+ * Resolves the feature slug via resolveTaskScope, reads storeFile(lifecycleProjectRoot(projectRoot), featureSlug),
135
+ * and finds the active attempt for the task. If no slug, no store, no entry or no active attempt exists,
136
+ * or if any failure occurs finding or reading the store, returns null (IC-005: "no attempt" never fails the write).
137
+ *
138
+ * @param {string} projectRoot
139
+ * @param {string} taskId
140
+ * @param {string|null} [slug]
141
+ * @param {any} [fsImpl]
142
+ * @returns {string|null}
143
+ */
144
+ function activeAttemptRef(projectRoot , taskId , slug = null, fsImpl = fs, attemptRoot = null) {
145
+ try {
146
+ const featureSlug = resolveTaskScope({ projectRoot, taskId, slug }).slug;
147
+ if (!featureSlug) return null;
148
+
149
+ const file = storeFile(attemptRoot || lifecycleProjectRoot(projectRoot), featureSlug);
150
+ if (!fsImpl.existsSync(file)) return null;
151
+
152
+ const data = readTaskState(fsImpl, file);
153
+ if (!data || !data.tasks || !data.tasks[taskId]) return null;
154
+
155
+ const active = activeAttempt(data.tasks[taskId]);
156
+ if (!active || active.token === undefined || active.token === null) return null;
157
+
158
+ return `${taskId}#${active.token}`;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ /** Binds active evidence to declared query refs and explains evidence that could have matched. */
165
+ function bindEvidence(plan , items ) {
166
+ const byNeed = new Map ();
167
+ const unbound = [];
168
+ const needs = Array.isArray(plan?.needs) ? plan.needs : [];
169
+ for (const need of needs) byNeed.set(need.intent, []);
170
+ for (const item of items) {
171
+ if (item.status !== 'active') continue;
172
+ const source = item.source || {};
173
+ const exact = needs.find((need ) => need.queryRef && need.queryRef === source.queryRef);
174
+ if (exact) {
175
+ if (plan.attemptRef && source.attemptRef && plan.attemptRef !== source.attemptRef) {
176
+ unbound.push({ id: item.id, cause: 'other-attempt' });
177
+ } else {
178
+ const matches = byNeed.get(exact.intent);
179
+ if (matches) matches.push(item);
180
+ }
181
+ continue;
182
+ }
183
+ if (source.queryRef) {
184
+ unbound.push({ id: item.id, cause: 'replaced-plan' });
185
+ } else if (needs.some((need ) => need.capability && need.capability === source.capability)) {
186
+ unbound.push({ id: item.id, cause: 'no-query-ref' });
187
+ }
188
+ }
189
+ return { byNeed, unbound };
190
+ }
191
+
192
+ /** Selects the highest-precedence recorded attempt, with the earliest attempt winning ties. */
193
+ function deriveOutcome(need ) {
194
+ const precedence = { RETRIEVED: 0, EMPTY: 1, UNVERIFIED: 3, UNREACHED: 4 };
195
+ const attempts = (Array.isArray(need?.attempts) ? need.attempts : []).filter((attempt ) =>
196
+ !(attempt.recordedBy === 'derived' && attempt.basis !== 'report-need'
197
+ && (!Array.isArray(attempt.evidenceIds) || attempt.evidenceIds.length === 0)));
198
+ const ranked = [...attempts].sort((a, b) => {
199
+ const rank = (attempt ) => attempt.status === 'EMPTY' && ['STALE', 'UNKNOWN'].includes(attempt.indexState)
200
+ ? (attempt.indexState === 'STALE' ? 2 : 3)
201
+ : (precedence[attempt.status] ?? 4);
202
+ return rank(a) - rank(b) || a.n - b.n;
203
+ });
204
+ const winner = ranked[0];
205
+ return {
206
+ result: winner ? winner.status : 'UNREACHED',
207
+ resolvedBy: winner ? winner.n : null,
208
+ viaFallback: Boolean(winner && winner.role === 'fallback'),
209
+ };
210
+ }
211
+
212
+ module.exports = {
213
+ planPath,
214
+ readPlan,
215
+ updatePlan,
216
+ moveCorruptPlanAside,
217
+ mintPlanId,
218
+ resolveNeed,
219
+ activeAttemptRef,
220
+ declaredIntentList,
221
+ bindEvidence,
222
+ deriveOutcome,
223
+ };
@@ -211,6 +211,9 @@ function parsePlan(text ) {
211
211
  }
212
212
 
213
213
  module.exports = {
214
+ section,
215
+ row,
216
+ joinWrapped,
214
217
  parseRequirement,
215
218
  parseDesign,
216
219
  parsePlan,
@@ -0,0 +1,289 @@
1
+ 'use strict';
2
+
3
+ // TypeScript migration (agent-docs/BRIEF_GOAL_DOFLOW.md): run directly by Node native type-stripping.
4
+
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+ const crypto = require('node:crypto');
8
+ const { execFileSync } = require('node:child_process');
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+ const treeStateMemo = new Map ();
26
+
27
+ function clearTreeStateCache() {
28
+ treeStateMemo.clear();
29
+ }
30
+
31
+ /**
32
+ * Finds the top of the git work tree and root's relative prefix below it using filesystem reads only.
33
+ */
34
+ function findWorkTreeTop(root ) {
35
+ let current = path.resolve(root);
36
+ while (true) {
37
+ try {
38
+ if (fs.existsSync(path.join(current, '.git'))) {
39
+ const rel = path.relative(current, path.resolve(root));
40
+ const prefix = rel && rel !== '.' ? (rel.split(path.sep).join('/') + '/') : '';
41
+ return { top: current, prefix };
42
+ }
43
+ } catch {
44
+ break;
45
+ }
46
+ const parent = path.dirname(current);
47
+ if (parent === current) break;
48
+ current = parent;
49
+ }
50
+ return { top: null, prefix: null };
51
+ }
52
+
53
+ function defaultGitRunner(args , cwd ) {
54
+ try {
55
+ return execFileSync('git', args, {
56
+ cwd,
57
+ encoding: 'utf8',
58
+ timeout: 2000,
59
+ maxBuffer: 256 * 1024 * 1024,
60
+ stdio: ['ignore', 'pipe', 'ignore'],
61
+ });
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Measures the repository's HEAD commit, dirty paths and tree fingerprint with one git status call.
69
+ */
70
+ function measureTreeState(root , runner ) {
71
+ const absRoot = path.resolve(root);
72
+ if (!runner && treeStateMemo.has(absRoot)) {
73
+ return treeStateMemo.get(absRoot) ;
74
+ }
75
+
76
+ const { top: workTreeTop, prefix } = findWorkTreeTop(absRoot);
77
+ if (!workTreeTop) {
78
+ const unmeasured = {
79
+ gitCommit: null,
80
+ dirty: null,
81
+ prefix: null,
82
+ fingerprint: null,
83
+ };
84
+ if (!runner) treeStateMemo.set(absRoot, unmeasured);
85
+ return unmeasured;
86
+ }
87
+
88
+ const args = ['-C', absRoot, 'status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all', '--ignored=matching'];
89
+ const raw = runner ? runner(args, absRoot) : defaultGitRunner(args, absRoot);
90
+
91
+ if (raw === null || raw === undefined) {
92
+ const commit = runner
93
+ ? runner(['-C', absRoot, 'rev-parse', 'HEAD'], absRoot)
94
+ : defaultGitRunner(['-C', absRoot, 'rev-parse', 'HEAD'], absRoot);
95
+ const failed = {
96
+ gitCommit: commit?.trim() || null,
97
+ dirty: null,
98
+ prefix,
99
+ fingerprint: null,
100
+ };
101
+ if (!runner) treeStateMemo.set(absRoot, failed);
102
+ return failed;
103
+ }
104
+
105
+ const tokens = raw.split('\0');
106
+ let gitCommit = null;
107
+ const dirty = new Set ();
108
+ const entryRecords = [];
109
+
110
+ for (let i = 0; i < tokens.length; i++) {
111
+ const token = tokens[i];
112
+ if (!token) continue;
113
+
114
+ if (token.startsWith('# branch.oid ')) {
115
+ const oid = token.slice('# branch.oid '.length).trim();
116
+ if (oid && oid !== '(initial)') {
117
+ gitCommit = oid;
118
+ }
119
+ continue;
120
+ }
121
+ if (token.startsWith('# ')) {
122
+ continue;
123
+ }
124
+
125
+ if (token.startsWith('! ')) {
126
+ const ignoredPath = token.slice(2);
127
+ if (prefix && (ignoredPath === prefix.slice(0, -1) || ignoredPath === `${prefix.slice(0, -1)}/`)) {
128
+ const ignored = { gitCommit, dirty: null, prefix, fingerprint: null };
129
+ if (!runner) treeStateMemo.set(absRoot, ignored);
130
+ return ignored;
131
+ }
132
+ continue;
133
+ }
134
+
135
+ let dirtyPath = null;
136
+ let entryText = token;
137
+
138
+ if (token.startsWith('1 ')) {
139
+ const parts = token.split(' ');
140
+ dirtyPath = parts.slice(8).join(' ');
141
+ dirty.add(dirtyPath);
142
+ } else if (token.startsWith('2 ')) {
143
+ const parts = token.split(' ');
144
+ dirtyPath = parts.slice(9).join(' ');
145
+ dirty.add(dirtyPath);
146
+ if (i + 1 < tokens.length && tokens[i + 1]) {
147
+ const origPath = tokens[i + 1];
148
+ dirty.add(origPath);
149
+ entryText = `${token}\0${origPath}`;
150
+ i++;
151
+ }
152
+ } else if (token.startsWith('u ')) {
153
+ const parts = token.split(' ');
154
+ dirtyPath = parts.slice(10).join(' ');
155
+ dirty.add(dirtyPath);
156
+ } else if (token.startsWith('? ')) {
157
+ dirtyPath = token.slice(2);
158
+ dirty.add(dirtyPath);
159
+ }
160
+
161
+ if (dirtyPath !== null) {
162
+ const statePath = `${prefix}.doflow/state/`;
163
+ const normalizedDirtyPath = dirtyPath.split(path.sep).join('/');
164
+ const shouldFingerprint = !normalizedDirtyPath.startsWith(statePath);
165
+ if (shouldFingerprint && /^(?:1|2|u) \S+ S\.M /.test(token)) {
166
+ const nestedRoot = path.resolve(workTreeTop, dirtyPath);
167
+ const nested = measureNestedRepository(nestedRoot);
168
+ if (nested === null) {
169
+ const unknown = { gitCommit, dirty: null, prefix, fingerprint: null };
170
+ if (!runner) treeStateMemo.set(absRoot, unknown);
171
+ return unknown;
172
+ }
173
+ entryRecords.push(`${entryText}:nested:${nested}`);
174
+ continue;
175
+ }
176
+ if (shouldFingerprint && token.startsWith('? ')) {
177
+ const nestedRoot = path.resolve(workTreeTop, dirtyPath.replace(/\/$/, ''));
178
+ if (fs.existsSync(path.join(nestedRoot, '.git'))) {
179
+ const nested = measureNestedRepository(nestedRoot);
180
+ if (nested === null) {
181
+ const unknown = { gitCommit, dirty: null, prefix, fingerprint: null };
182
+ if (!runner) treeStateMemo.set(absRoot, unknown);
183
+ return unknown;
184
+ }
185
+ entryRecords.push(`${entryText}:nested:${nested}`);
186
+ continue;
187
+ }
188
+ }
189
+ if (!shouldFingerprint) continue;
190
+ let statTerm = 'absent';
191
+ try {
192
+ const absPath = path.resolve(workTreeTop, dirtyPath);
193
+ const st = fs.statSync(absPath);
194
+ statTerm = `${st.size}:${st.mtimeMs}`;
195
+ } catch {
196
+ statTerm = 'absent';
197
+ }
198
+ entryRecords.push(`${entryText}:${statTerm}`);
199
+ }
200
+ }
201
+
202
+ if (!gitCommit) {
203
+ const uncommitted = {
204
+ gitCommit: null,
205
+ dirty: null,
206
+ prefix,
207
+ fingerprint: null,
208
+ };
209
+ if (!runner) treeStateMemo.set(absRoot, uncommitted);
210
+ return uncommitted;
211
+ }
212
+
213
+ const hash = crypto.createHash('sha256');
214
+ hash.update(gitCommit);
215
+ hash.update('\0');
216
+ hash.update(entryRecords.sort().join('\0'));
217
+ const fingerprint = `sha256:${hash.digest('hex')}`;
218
+
219
+ const state = {
220
+ gitCommit,
221
+ dirty,
222
+ prefix,
223
+ fingerprint,
224
+ };
225
+ if (!runner) treeStateMemo.set(absRoot, state);
226
+ return state;
227
+ }
228
+
229
+ function measureNestedRepository(root ) {
230
+ try {
231
+ const gitEntry = path.join(root, '.git');
232
+ const gitStat = fs.statSync(gitEntry);
233
+ const gitDir = gitStat.isFile()
234
+ ? path.resolve(root, fs.readFileSync(gitEntry, 'utf8').trim().replace(/^gitdir:\s*/, ''))
235
+ : gitEntry;
236
+ const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
237
+ const headRef = head.startsWith('ref: ')
238
+ ? fs.readFileSync(path.join(gitDir, head.slice('ref: '.length)), 'utf8').trim()
239
+ : head;
240
+ const index = fs.statSync(path.join(gitDir, 'index'));
241
+ return `${head}:${headRef}:${index.mtimeMs}:${index.size}`;
242
+ } catch {
243
+ return null;
244
+ }
245
+ }
246
+
247
+ function fileHash(root , locator ) {
248
+ if (!locator?.file) return null;
249
+ try {
250
+ const absolute = path.resolve(root, locator.file);
251
+ if (!fs.statSync(absolute).isFile()) return null;
252
+ return `sha256:${crypto.createHash('sha256').update(fs.readFileSync(absolute)).digest('hex')}`;
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Derives source version { gitCommit, fileHash, isDirty } for a given locator and tree state.
260
+ */
261
+ function sourceVersionFor(root , locator , state ) {
262
+ const hash = fileHash(root, locator);
263
+ if (!locator?.file || !state || !state.dirty) {
264
+ return {
265
+ gitCommit: state?.gitCommit ?? null,
266
+ fileHash: hash,
267
+ isDirty: null,
268
+ };
269
+ }
270
+
271
+ const prefix = state.prefix || '';
272
+ if (path.isAbsolute(locator.file)) {
273
+ return { gitCommit: state.gitCommit ?? null, fileHash: hash, isDirty: null };
274
+ }
275
+ const candidate = path.posix.normalize(`${prefix}${locator.file.split(path.sep).join('/')}`).replace(/^\.\//, '');
276
+ const isDirty = state.dirty.has(candidate);
277
+
278
+ return {
279
+ gitCommit: state.gitCommit ?? null,
280
+ fileHash: hash,
281
+ isDirty,
282
+ };
283
+ }
284
+
285
+ module.exports = {
286
+ measureTreeState,
287
+ sourceVersionFor,
288
+ clearTreeStateCache,
289
+ };
@@ -411,5 +411,5 @@ function handleTaskAttemptCommand({
411
411
 
412
412
  module.exports = {
413
413
  handleTaskAttemptCommand, checkCompletion, claim, finish, release, status, loadPlan, taskLineHash,
414
- storeFile, TaskAttemptError, MODES, RESULTS, ACTIONS,
414
+ storeFile, TaskAttemptError, MODES, RESULTS, ACTIONS, activeAttempt,
415
415
  };
@@ -4,7 +4,7 @@
4
4
 
5
5
  const fs = require('node:fs');
6
6
  const path = require('node:path');
7
- const { resolveActiveFeature } = require('./feature-resolve.js');
7
+ const featureResolve = require('./feature-resolve.js');
8
8
 
9
9
  // Which feature a task record belongs to (feature 045, IC-001/IC-002).
10
10
  //
@@ -37,6 +37,9 @@ function invalidSlugRefusal(slug )
37
37
  /** (projectRoot, slug) -> { slug, hasRegister } | { reason }. One resolver spawn per key per process. */
38
38
  const featureCache = new Map ();
39
39
 
40
+ /** The resolver's raw answer, including thrown failures, shared by every consumer in this process. */
41
+ const resolvedFeatureCache = new Map ();
42
+
40
43
  /** A slug the CLI named explicitly (`--slug`), applied to every store call in this process that was
41
44
  * not given its own. The dispatcher sets it once so the verbs whose handlers build their ledger
42
45
  * internally (readiness, evidence) route the same way as the ones that take `slug` directly. */
@@ -48,6 +51,28 @@ function setDefaultSlug(slug ) {
48
51
 
49
52
  function clearTaskScopeCache() {
50
53
  featureCache.clear();
54
+ resolvedFeatureCache.clear();
55
+ }
56
+
57
+ /**
58
+ * Resolve once per process per (root, slug). The resolver spawns do-paths.sh, and every store and
59
+ * the decisions lookup must see the same answer.
60
+ */
61
+ function resolveFeature({ projectRoot, slug = null } ) {
62
+ const key = `${path.resolve(projectRoot)}\0${slug || ''}`;
63
+ if (resolvedFeatureCache.has(key)) {
64
+ const cached = resolvedFeatureCache.get(key) ;
65
+ if ('error' in cached) throw cached.error;
66
+ return cached.value;
67
+ }
68
+ try {
69
+ const value = featureResolve.resolveActiveFeature({ projectRoot, slug: slug || null });
70
+ resolvedFeatureCache.set(key, { value });
71
+ return value;
72
+ } catch (error) {
73
+ resolvedFeatureCache.set(key, { error });
74
+ throw error;
75
+ }
51
76
  }
52
77
 
53
78
  /** The resolver reports the git top level, which is the project root or one of its ancestors, and
@@ -71,7 +96,7 @@ function featureFor(projectRoot , slug ) {
71
96
  let value = { reason: 'no-feature' };
72
97
  try {
73
98
  if (hasFeatureFolderAbove(projectRoot)) {
74
- const found = resolveActiveFeature({ projectRoot, slug: slug || null });
99
+ const found = resolveFeature({ projectRoot, slug: slug || null });
75
100
  if (!found.error && found.paths && isSafeSlug(found.paths.feature_slug)) {
76
101
  value = { slug: found.paths.feature_slug, hasRegister: found.paths.has_decisions === true, exists: fs.existsSync(found.featureDir) };
77
102
  }
@@ -127,4 +152,4 @@ function taskStoreDir({ projectRoot, store, taskId, slug = null }
127
152
  return namespace ? path.join(base, namespace) : base;
128
153
  }
129
154
 
130
- module.exports = { resolveTaskScope, taskStoreDir, setDefaultSlug, clearTaskScopeCache, isSafeSlug, invalidSlugRefusal, slugNamesNoFeature };
155
+ module.exports = { resolveFeature, resolveTaskScope, taskStoreDir, setDefaultSlug, clearTaskScopeCache, isSafeSlug, invalidSlugRefusal, slugNamesNoFeature };