@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.
- package/core/.antigravity-plugin/plugin.json +0 -1
- package/core/.claude-plugin/plugin.json +1 -1
- package/core/.codex-plugin/plugin.json +1 -1
- package/core/.plugin/plugin.json +1 -1
- package/core/harnesses/claude/hooks/mcp-tool-guard.sh +7 -17
- package/core/harnesses/claude/hooks/pre-bash-guard.sh +7 -17
- package/core/harnesses/claude/hooks/pre-implement-gate.sh +7 -18
- package/core/harnesses/claude/hooks/session-start.sh +5 -3
- package/core/harnesses/claude/hooks/stop-check.sh +5 -3
- package/core/harnesses/claude/hooks/subagent-audit.sh +5 -5
- package/core/harnesses/claude/hooks/user-prompt-submit.sh +5 -3
- package/core/harnesses/codex/hooks/mcp-tool-guard.sh +7 -15
- package/core/harnesses/codex/hooks/pre-bash-guard.sh +7 -15
- package/core/harnesses/codex/hooks/pre-implement-gate.sh +7 -16
- package/core/harnesses/codex/hooks/session-start.sh +5 -3
- package/core/harnesses/codex/hooks/stop-check.sh +6 -5
- package/core/harnesses/codex/hooks/subagent-audit.sh +17 -6
- package/core/harnesses/codex/hooks/user-prompt-submit.sh +5 -4
- package/core/harnesses/gemini/hooks/mcp-tool-guard.sh +7 -18
- package/core/harnesses/gemini/hooks/pre-bash-guard.sh +7 -18
- package/core/harnesses/gemini/hooks/pre-implement-gate.sh +7 -19
- package/core/harnesses/gemini/hooks/session-start.sh +5 -3
- package/core/harnesses/kiro/hooks/mcp-tool-guard.sh +20 -5
- package/core/harnesses/kiro/hooks/pre-bash-guard.sh +20 -5
- package/core/harnesses/kiro/hooks/pre-implement-gate.sh +20 -5
- package/core/harnesses/kiro/hooks/session-start.sh +11 -5
- package/core/harnesses/kiro/hooks/stop-check.sh +21 -30
- package/core/harnesses/pi/extensions/doflow-guard.js +159 -0
- package/core/harnesses/shared/hooks/ENVELOPE_MIGRATION.md +27 -11
- package/core/harnesses/shared/hooks/adapters/antigravity.js +14 -57
- package/core/harnesses/shared/hooks/bridge/bridge.sh +184 -0
- package/core/harnesses/shared/hooks/bridge/envelope.jq +60 -0
- package/core/harnesses/shared/hooks/bridge/respond.sh +62 -0
- package/core/harnesses/shared/hooks/policies/capture-failure.sh +4 -2
- package/core/harnesses/shared/hooks/policies/contract.sh +75 -0
- package/core/harnesses/shared/hooks/policies/deny-json.sh +39 -4
- package/core/harnesses/shared/hooks/policies/lib.sh +4 -2
- package/core/harnesses/shared/hooks/policies/mcp-tool-guard.sh +46 -34
- package/core/harnesses/shared/hooks/policies/pre-bash-guard.sh +75 -25
- package/core/harnesses/shared/hooks/policies/pre-implementation-gate.sh +67 -73
- package/core/harnesses/shared/hooks/policies/session-context.sh +37 -7
- package/core/harnesses/shared/hooks/policies/stop-check.sh +42 -15
- package/core/harnesses/shared/hooks/policies/subagent-audit.sh +35 -5
- package/core/harnesses/shared/hooks/policies/user-prompt-submit.sh +40 -7
- package/core/harnesses/shared/hooks/stream-hook-runner.js +69 -181
- package/core/registry/assets.json +19 -0
- package/core/registry/contracts.json +2 -2
- package/core/registry/harnesses.json +196 -10
- package/core/registry/lifecycle.json +2626 -7
- package/core/registry/storage.json +24 -1
- package/core/shared/scripts/doflow/bin/doflow-run +10 -6
- package/core/shared/skills/do-design/SKILL.md +6 -6
- package/core/shared/skills/do-execute-plan/SKILL.md +11 -10
- package/core/shared/skills/do-execute-plan/references/readiness_gate.md +2 -0
- package/core/shared/skills/do-implement/SKILL.md +3 -2
- package/core/shared/skills/do-plan/SKILL.md +7 -8
- package/package.json +1 -1
- package/src/adapters/antigravity/index.js +83 -47
- package/src/adapters/claude/index.js +15 -1
- package/src/adapters/codex/hooks.js +40 -5
- package/src/adapters/codex/index.js +52 -9
- package/src/adapters/copy-tree.js +4 -4
- package/src/adapters/gemini/hooks.js +57 -16
- package/src/adapters/gemini/index.js +57 -18
- package/src/adapters/kiro/index.js +15 -1
- package/src/adapters/pi/hooks.js +119 -0
- package/src/adapters/pi/index.js +34 -9
- package/src/cli/commands/install.js +1 -1
- package/src/cli/commands/update.js +1 -1
- package/src/cli/index.js +17 -5
- package/src/cli/runtime-commands.js +51 -12
- package/src/lifecycle/coverage.js +57 -0
- package/src/lifecycle/hook-step.js +61 -0
- package/src/lifecycle/index.js +28 -14
- package/src/registry/index.js +136 -7
- package/src/runtime/candidate.js +505 -0
- package/src/runtime/claims.js +14 -5
- package/src/runtime/cli-result.js +11 -4
- package/src/runtime/cli.js +35 -268
- package/src/runtime/context/budget.js +203 -0
- package/src/runtime/context/compose.js +1367 -0
- package/src/runtime/context/delivery-store.js +102 -0
- package/src/runtime/context/mandatory.js +594 -0
- package/src/runtime/context/pointer.js +142 -0
- package/src/runtime/context-pack.js +1029 -244
- package/src/runtime/evidence-fields.js +39 -0
- package/src/runtime/evidence-ledger.js +7 -0
- package/src/runtime/freshness.js +44 -7
- package/src/runtime/outcome.js +1 -1
- package/src/runtime/readiness.js +83 -13
- package/src/runtime/retrieval-plan.js +422 -73
- package/src/runtime/retrieval-record.js +223 -0
- package/src/runtime/scaffold/artifacts.js +3 -0
- package/src/runtime/source-version.js +289 -0
- package/src/runtime/task-attempts.js +1 -1
- package/src/runtime/task-scope.js +28 -3
- package/src/runtime/task-state.js +72 -7
- package/core/.antigravity-plugin/hooks.json +0 -39
- package/core/harnesses/shared/hooks/adapters/arg-fields.js +0 -19
- package/core/harnesses/shared/hooks/adapters/gemini.js +0 -114
- 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
|
+
};
|
|
@@ -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
|
|
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 =
|
|
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 };
|