@haposoft/cafekit 0.16.3 → 0.16.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/__tests__/codex-hooks.test.js +45 -4
- package/bin/__tests__/package-inventory.test.js +43 -7
- package/bin/__tests__/spec-narrowing.test.js +381 -0
- package/package.json +1 -1
- package/src/claude/hooks/__tests__/completion-authority.test.js +8 -1
- package/src/claude/hooks/__tests__/spec-gate.test.js +55 -23
- package/src/claude/hooks/completion-authority.cjs +4 -1
- package/src/claude/hooks/spec-gate.cjs +11 -6
- package/src/claude/hooks/spec-state.cjs +1 -1
- package/src/claude/rules/state-sync.md +5 -2
- package/src/claude/scripts/spec-receipt.cjs +13 -27
- package/src/claude/scripts/spec-resolver.cjs +72 -6
- package/src/claude/skills/specs/SKILL.md +1 -1
- package/src/codex/hooks/completion-authority-check.cjs +4 -0
- package/src/codex/hooks/completion-authority.cjs +4 -1
- package/src/codex/hooks/lib/spec-utils.cjs +21 -0
- package/src/codex/hooks/spec-gate.cjs +5 -1
- package/src/codex/hooks/spec-state.cjs +1 -1
- package/src/codex/rules/state-sync.md +5 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Native spec-driven workflow and runtime bundle for Claude Code and Codex CLI.
|
|
4
4
|
|
|
5
|
-
[](https://github.com/haposoft/cafekit)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://claude.ai/code)
|
|
8
8
|
[](https://developers.openai.com/codex)
|
|
@@ -782,6 +782,41 @@ test('Codex process-v3 Receipt command must match the exact Verification Plan co
|
|
|
782
782
|
});
|
|
783
783
|
});
|
|
784
784
|
|
|
785
|
+
test('Codex recorded active feature turns the identity block into receipt validation', () => {
|
|
786
|
+
// Issue #79 end to end on this runtime. Two legacy packets both claiming closeout make
|
|
787
|
+
// the gate answer a missing-receipt violation with an identity complaint whose stated
|
|
788
|
+
// remedy nothing wrote. Naming the feature resolves it and the violation surfaces.
|
|
789
|
+
inHookFixture((root, hooks) => {
|
|
790
|
+
for (const name of ['alpha', 'beta']) {
|
|
791
|
+
const feature = path.join(root, 'specs', name);
|
|
792
|
+
fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
|
|
793
|
+
fs.writeFileSync(path.join(feature, 'spec.json'), `${JSON.stringify({
|
|
794
|
+
feature_name: name,
|
|
795
|
+
status: 'done',
|
|
796
|
+
current_phase: 'closeout',
|
|
797
|
+
task_registry: { 'tasks/task-R0-01-x.md': { status: 'done', completed_at: '2026-01-01T00:00:00.000Z' } },
|
|
798
|
+
}, null, 2)}\n`);
|
|
799
|
+
fs.writeFileSync(path.join(feature, 'tasks', 'task-R0-01-x.md'), '# Task\n\nStatus: done\n');
|
|
800
|
+
}
|
|
801
|
+
const payload = { cwd: root, session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
|
|
802
|
+
|
|
803
|
+
const blocked = runHook(path.join(hooks, 'spec-gate.cjs'), root, payload);
|
|
804
|
+
assert.equal(blocked.status, 0, blocked.stderr);
|
|
805
|
+
assert.match(JSON.parse(blocked.stdout).reason, /multiple active specs detected/);
|
|
806
|
+
|
|
807
|
+
const shared = path.join(root, 'specs', '_shared');
|
|
808
|
+
fs.mkdirSync(shared, { recursive: true });
|
|
809
|
+
fs.writeFileSync(path.join(shared, 'active-feature.json'), '{"featureName": "beta"}\n');
|
|
810
|
+
|
|
811
|
+
const resolved = runHook(path.join(hooks, 'spec-gate.cjs'), root, payload);
|
|
812
|
+
assert.equal(resolved.status, 0, resolved.stderr);
|
|
813
|
+
const reason = JSON.parse(resolved.stdout).reason;
|
|
814
|
+
assert.doesNotMatch(reason, /multiple active specs/, 'the recorded feature must resolve it');
|
|
815
|
+
assert.match(reason, /lack a verification receipt/, 'the gate must now reach receipt validation');
|
|
816
|
+
assert.match(reason, /specs\/beta\//, 'and validate the named feature, not a sibling');
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
|
|
785
820
|
test('Codex process-v3 Stop ignores a completed packet when one packet remains active', () => {
|
|
786
821
|
inHookFixture((root, hooks) => {
|
|
787
822
|
const completed = path.join(root, 'specs', 'auth');
|
|
@@ -1831,13 +1866,19 @@ test('Codex emits the required decision for all six receipt states', () => {
|
|
|
1831
1866
|
assert.equal(decide(), 'accept', 'a committed, unchanged receipt on a clean tree must be accepted');
|
|
1832
1867
|
assert.equal(sharedDecides(), 'accept', 'the shared checker must agree');
|
|
1833
1868
|
|
|
1834
|
-
// 4.
|
|
1835
|
-
// the
|
|
1869
|
+
// 4. Contract change: an uncommitted file outside the specs root no longer
|
|
1870
|
+
// rebinds a committed receipt. Base is the last commit touching anything
|
|
1871
|
+
// outside the specs root, so every receipt written before a later commit
|
|
1872
|
+
// records an older Base by construction — measured at 52 of 52 on the
|
|
1873
|
+
// CafeKit repository. The condition therefore failed every historical
|
|
1874
|
+
// receipt at once whenever any unrelated file was dirty, and could not
|
|
1875
|
+
// tell a tampered receipt from an untouched one. Case 6 below still
|
|
1876
|
+
// catches the receipt that actually changed.
|
|
1836
1877
|
fs.writeFileSync(outsideDirt, 'uncommitted\n');
|
|
1837
1878
|
assert.ok(!path.relative(nested, outsideDirt).startsWith('..') === false,
|
|
1838
1879
|
'the dirt must lie outside the working directory used for the run');
|
|
1839
|
-
assert.equal(decide(), '
|
|
1840
|
-
assert.equal(sharedDecides(), '
|
|
1880
|
+
assert.equal(decide(), 'accept', 'unrelated dirt must not reopen a committed receipt');
|
|
1881
|
+
assert.equal(sharedDecides(), 'accept', 'the shared checker must agree');
|
|
1841
1882
|
git('checkout', '--', 'src/outside.txt');
|
|
1842
1883
|
|
|
1843
1884
|
// 5. work confined to the specs root does not reopen a committed receipt.
|
|
@@ -2162,6 +2162,10 @@ process.stdout.write(JSON.stringify({ ok: result.ok, active: result.active, reas
|
|
|
2162
2162
|
});
|
|
2163
2163
|
}
|
|
2164
2164
|
|
|
2165
|
+
// `assertInstalledCompletion` leaves this fixture claiming closeout, which is what makes
|
|
2166
|
+
// it the one packet closeout approval resolves among the scaffolded siblings.
|
|
2167
|
+
const CLOSING_FIXTURE = 'compact-installed';
|
|
2168
|
+
|
|
2165
2169
|
function assertInstalledResolution(paths, root, platform, explicitFeature) {
|
|
2166
2170
|
const code = `
|
|
2167
2171
|
const checker = require(process.argv[1]); const resolver = require(process.argv[2]);
|
|
@@ -2170,13 +2174,45 @@ const base = { projectRoot: root, runtime: {}, payload: {} };
|
|
|
2170
2174
|
if (platform === 'claude') base.resolver = resolver;
|
|
2171
2175
|
const explicit = checker.resolveCandidate({ ...base, payload: { featureName: feature } });
|
|
2172
2176
|
const ambiguous = checker.resolveCandidate(base);
|
|
2173
|
-
process.stdout.write(JSON.stringify({ explicit: explicit && explicit.featureName, error: ambiguous && ambiguous.error, candidates: ambiguous && ambiguous.candidates }));`;
|
|
2174
|
-
const
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2177
|
+
process.stdout.write(JSON.stringify({ explicit: explicit && explicit.featureName, isNull: ambiguous === null, name: ambiguous && ambiguous.featureName, error: ambiguous && ambiguous.error, candidates: ambiguous && ambiguous.candidates }));`;
|
|
2178
|
+
const run = () => {
|
|
2179
|
+
const result = spawnSync(process.execPath, ['-e', code, paths.completion, paths.resolver, root, platform, explicitFeature], { cwd: root, encoding: 'utf8' });
|
|
2180
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
2181
|
+
return JSON.parse(result.stdout);
|
|
2182
|
+
};
|
|
2183
|
+
|
|
2184
|
+
// Contract change: closeout approval counts only packets actually claiming closeout.
|
|
2185
|
+
// This used to assert `multiple_persisted` from these fixtures, which pinned the
|
|
2186
|
+
// over-strict behaviour issue #79 reports. `assertInstalledCompletion` above drove
|
|
2187
|
+
// `compact-installed` to closeout, so exactly one of the three is claiming it; the
|
|
2188
|
+
// scaffolded siblings are not rivals and must not block the approval.
|
|
2189
|
+
const narrowed = run();
|
|
2190
|
+
assert.equal(narrowed.explicit, explicitFeature);
|
|
2191
|
+
assert.equal(narrowed.name, CLOSING_FIXTURE, `the one packet claiming closeout must resolve: ${JSON.stringify(narrowed)}`);
|
|
2192
|
+
assert.equal(narrowed.error, undefined);
|
|
2193
|
+
|
|
2194
|
+
// Genuine ambiguity still blocks, and the installed copy must prove it rather than
|
|
2195
|
+
// being assumed from the source tree this test deliberately deleted.
|
|
2196
|
+
const closing = ['closing-a-installed', 'closing-b-installed'];
|
|
2197
|
+
for (const name of closing) {
|
|
2198
|
+
const dir = path.join(root, 'specs', name);
|
|
2199
|
+
fs.mkdirSync(path.join(dir, 'tasks'), { recursive: true });
|
|
2200
|
+
fs.writeFileSync(path.join(dir, 'spec.json'), `${JSON.stringify({
|
|
2201
|
+
feature_name: name,
|
|
2202
|
+
status: 'done',
|
|
2203
|
+
current_phase: 'closeout',
|
|
2204
|
+
task_registry: { 'tasks/task-R0-01-x.md': { status: 'done', completed_at: '2026-01-01T00:00:00.000Z' } },
|
|
2205
|
+
}, null, 2)}\n`);
|
|
2206
|
+
fs.writeFileSync(path.join(dir, 'tasks', 'task-R0-01-x.md'), '# Task\n\nStatus: done\n');
|
|
2207
|
+
}
|
|
2208
|
+
try {
|
|
2209
|
+
const ambiguous = run();
|
|
2210
|
+
assert.equal(ambiguous.explicit, explicitFeature, 'an explicit target still wins');
|
|
2211
|
+
assert.equal(ambiguous.error, 'multiple_persisted');
|
|
2212
|
+
assert.deepEqual([...ambiguous.candidates].sort(), [...closing, CLOSING_FIXTURE].sort(), 'only claiming packets are named');
|
|
2213
|
+
} finally {
|
|
2214
|
+
for (const name of closing) fs.rmSync(path.join(root, 'specs', name), { recursive: true, force: true });
|
|
2215
|
+
}
|
|
2180
2216
|
}
|
|
2181
2217
|
|
|
2182
2218
|
function assertStrictSimulatedHandlerGuardrail(paths, root, fixture) {
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Two Stop hooks ask two different questions about the same repository, so they narrow an
|
|
4
|
+
// ambiguous scan differently. The gate asks which packet still has unfinished work, so it
|
|
5
|
+
// can validate that packet's receipts. Closeout approval asks which packet is claiming
|
|
6
|
+
// closeout, so it can demand a one-time approval for it. Counting finished history as
|
|
7
|
+
// ambiguity is what left a repository of accumulated features permanently blocked, with
|
|
8
|
+
// the documented escape — an explicit feature target — unavailable.
|
|
9
|
+
|
|
10
|
+
const assert = require('node:assert/strict');
|
|
11
|
+
const { spawnSync } = require('node:child_process');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const { test } = require('node:test');
|
|
16
|
+
|
|
17
|
+
const PACKAGE_ROOT = path.join(__dirname, '..', '..');
|
|
18
|
+
const RESOLVER = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'));
|
|
19
|
+
|
|
20
|
+
function withProject(run) {
|
|
21
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-narrowing-'));
|
|
22
|
+
try {
|
|
23
|
+
return run(fs.realpathSync(dir));
|
|
24
|
+
} finally {
|
|
25
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A legacy packet: spec.json plus nested tasks, the shape issue #79 reports. */
|
|
30
|
+
function legacyPacket(root, name, { status, phase, taskStatus = 'done' } = {}) {
|
|
31
|
+
const dir = path.join(root, 'specs', name);
|
|
32
|
+
fs.mkdirSync(path.join(dir, 'tasks'), { recursive: true });
|
|
33
|
+
const spec = { feature_name: name, task_registry: { 'tasks/task-R0-01-x.md': { status: taskStatus, completed_at: '2026-01-01T00:00:00.000Z' } } };
|
|
34
|
+
if (status !== undefined) spec.status = status;
|
|
35
|
+
if (phase !== undefined) spec.current_phase = phase;
|
|
36
|
+
fs.writeFileSync(path.join(dir, 'spec.json'), `${JSON.stringify(spec, null, 2)}\n`);
|
|
37
|
+
fs.writeFileSync(path.join(dir, 'tasks', 'task-R0-01-x.md'), `# Task\n\nStatus: ${taskStatus}\n`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A process-first packet: plan.md plus one flat task file. */
|
|
41
|
+
function workflowPacket(root, name, taskStatus) {
|
|
42
|
+
const dir = path.join(root, 'specs', name);
|
|
43
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
44
|
+
fs.writeFileSync(path.join(dir, 'plan.md'), '# Plan\nSpecs-Contract: process-first-ready-v1\n');
|
|
45
|
+
fs.writeFileSync(
|
|
46
|
+
path.join(dir, 'task-01-x.md'),
|
|
47
|
+
`# Task 01\n\nStatus: ${taskStatus}\n\n## Dependencies\n- none\n`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function recordActiveFeature(root, contents) {
|
|
52
|
+
const dir = path.join(root, 'specs', '_shared');
|
|
53
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
54
|
+
fs.writeFileSync(path.join(dir, 'active-feature.json'), contents);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function gateResolution(root) {
|
|
58
|
+
const resolved = RESOLVER.resolveWorkflowCandidate({
|
|
59
|
+
projectRoot: root, runtime: {}, target: null, includeCompleted: true,
|
|
60
|
+
});
|
|
61
|
+
return RESOLVER.refineWorkflowGateResolution(resolved);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── The gate: which packet still has work ───────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
test('a legacy repository resolves its one unfinished packet', () => {
|
|
67
|
+
withProject((root) => {
|
|
68
|
+
for (let index = 1; index <= 3; index += 1) {
|
|
69
|
+
legacyPacket(root, `done-${index}`, { status: 'done', phase: 'closeout' });
|
|
70
|
+
}
|
|
71
|
+
legacyPacket(root, 'active-now', { status: 'in_progress', phase: 'execution', taskStatus: 'pending' });
|
|
72
|
+
const resolved = gateResolution(root);
|
|
73
|
+
assert.equal(resolved.error, undefined, `expected a resolution, got ${resolved.error}`);
|
|
74
|
+
assert.equal(resolved.featureName, 'active-now');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('a legacy registry written with the older vocabulary counts as finished', () => {
|
|
79
|
+
// Issue #79's repository was upgraded from an older release whose task_registry used
|
|
80
|
+
// `completed`. Today's validator allows only pending/in_progress/blocked/done, so
|
|
81
|
+
// narrowing read those packets as unfinished and the block survived the fix that was
|
|
82
|
+
// supposed to clear it. Measured on the reported shape before this: 18 candidates.
|
|
83
|
+
for (const status of ['done', 'completed', 'complete']) {
|
|
84
|
+
withProject((root) => {
|
|
85
|
+
for (let index = 1; index <= 3; index += 1) {
|
|
86
|
+
legacyPacket(root, `history-${index}`, { taskStatus: status });
|
|
87
|
+
}
|
|
88
|
+
legacyPacket(root, 'active-now', { status: 'in_progress', phase: 'execution', taskStatus: 'pending' });
|
|
89
|
+
assert.equal(gateResolution(root).featureName, 'active-now', `task status ${status} must read as finished`);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a process-first packet keeps the strict task vocabulary', () => {
|
|
95
|
+
// The widened set is legacy-only. A process-first task saying `completed` is malformed,
|
|
96
|
+
// not finished, and must not be silently read as done.
|
|
97
|
+
withProject((root) => {
|
|
98
|
+
workflowPacket(root, 'done-1', 'done');
|
|
99
|
+
workflowPacket(root, 'odd', 'completed');
|
|
100
|
+
const resolved = gateResolution(root);
|
|
101
|
+
assert.notEqual(resolved.layoutKind, 'process-v3-completed-set', 'an invalid status is not done');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a legacy candidate never reaches the bulk branch', () => {
|
|
106
|
+
// The bulk branch exits before the semantic-digest, FLASH_UNVERIFIED, feature-receipt
|
|
107
|
+
// and completion-policy layers, so a legacy packet reaching it would turn a block into
|
|
108
|
+
// a silent pass. With every legacy packet finished there is nothing to narrow to, and
|
|
109
|
+
// the existing ambiguity must stand rather than the set being audited.
|
|
110
|
+
withProject((root) => {
|
|
111
|
+
legacyPacket(root, 'done-1', { status: 'done', phase: 'closeout' });
|
|
112
|
+
legacyPacket(root, 'done-2', { status: 'done', phase: 'closeout' });
|
|
113
|
+
const resolved = gateResolution(root);
|
|
114
|
+
assert.notEqual(resolved.layoutKind, 'process-v3-completed-set');
|
|
115
|
+
assert.ok(resolved.error, 'an all-legacy finished set must stay ambiguous');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('a process-first set still reaches the bulk branch', () => {
|
|
120
|
+
withProject((root) => {
|
|
121
|
+
workflowPacket(root, 'done-1', 'done');
|
|
122
|
+
workflowPacket(root, 'done-2', 'done');
|
|
123
|
+
assert.equal(gateResolution(root).layoutKind, 'process-v3-completed-set');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('one unfinished process-first packet still narrows', () => {
|
|
128
|
+
withProject((root) => {
|
|
129
|
+
workflowPacket(root, 'done-1', 'done');
|
|
130
|
+
workflowPacket(root, 'active-now', 'pending');
|
|
131
|
+
assert.equal(gateResolution(root).featureName, 'active-now');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ── Closeout approval: which packet is claiming closeout ────────────────────────────
|
|
136
|
+
|
|
137
|
+
test('one packet claiming closeout among finished ones resolves', () => {
|
|
138
|
+
withProject((root) => {
|
|
139
|
+
legacyPacket(root, 'shipped', { status: 'in_progress', phase: 'execution', taskStatus: 'done' });
|
|
140
|
+
legacyPacket(root, 'closing', { status: 'done', phase: 'closeout' });
|
|
141
|
+
const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
|
|
142
|
+
assert.equal(resolved.error, undefined);
|
|
143
|
+
assert.equal(resolved.featureName, 'closing');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('a lone finished packet still resolves', () => {
|
|
148
|
+
// Four existing cases depend on this: closeout approval is claimed against a finished
|
|
149
|
+
// spec, so a single packet must resolve whatever its status.
|
|
150
|
+
for (const status of ['done', 'completed']) {
|
|
151
|
+
withProject((root) => {
|
|
152
|
+
legacyPacket(root, 'only', { status });
|
|
153
|
+
const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
|
|
154
|
+
assert.equal(resolved.featureName, 'only', `status ${status} must still resolve`);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('a lone packet mid-execution still resolves', () => {
|
|
160
|
+
// The closeout filter would drop this one, so the single-candidate return above it is
|
|
161
|
+
// what keeps a repository holding exactly one in-flight packet resolvable. Without it
|
|
162
|
+
// the hook goes silent for that repository instead of checking the packet.
|
|
163
|
+
withProject((root) => {
|
|
164
|
+
legacyPacket(root, 'solo', { status: 'in_progress', phase: 'implementation', taskStatus: 'pending' });
|
|
165
|
+
const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
|
|
166
|
+
assert.equal(resolved.featureName, 'solo');
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('no packet claiming closeout resolves to null', () => {
|
|
171
|
+
// null means nothing is in flight and the hook stays silent; an error would keep
|
|
172
|
+
// blocking a repository where no closeout is being claimed at all.
|
|
173
|
+
withProject((root) => {
|
|
174
|
+
legacyPacket(root, 'a', { status: 'in_progress', phase: 'execution' });
|
|
175
|
+
legacyPacket(root, 'b', { status: 'in_progress', phase: 'execution' });
|
|
176
|
+
assert.equal(RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} }), null);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('several packets claiming closeout stay ambiguous and name only those', () => {
|
|
181
|
+
withProject((root) => {
|
|
182
|
+
legacyPacket(root, 'closing-a', { status: 'done', phase: 'closeout' });
|
|
183
|
+
legacyPacket(root, 'closing-b', { status: 'done', phase: 'closeout' });
|
|
184
|
+
legacyPacket(root, 'working', { status: 'in_progress', phase: 'execution' });
|
|
185
|
+
const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
|
|
186
|
+
assert.equal(resolved.error, 'multiple_persisted');
|
|
187
|
+
assert.deepEqual(resolved.candidates, ['closing-a', 'closing-b']);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('codex resolveCandidate agrees with the shared resolver', () => {
|
|
192
|
+
// Codex kept its own candidate count, reachable because spec-utils did not export the
|
|
193
|
+
// shared function. A Claude-only change would leave every Codex turn blocked.
|
|
194
|
+
const codexCheck = require(path.join(PACKAGE_ROOT, 'src/codex/hooks/completion-authority-check.cjs'));
|
|
195
|
+
const utils = require(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-utils.cjs'));
|
|
196
|
+
assert.equal(typeof utils.resolvePersistedSpec, 'function', 'the wrapper must be exported');
|
|
197
|
+
withProject((root) => {
|
|
198
|
+
legacyPacket(root, 'shipped', { status: 'in_progress', phase: 'execution', taskStatus: 'done' });
|
|
199
|
+
legacyPacket(root, 'closing', { status: 'done', phase: 'closeout' });
|
|
200
|
+
const shared = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
|
|
201
|
+
const viaCodex = codexCheck.resolveCandidate({ projectRoot: root, runtime: {}, payload: {} });
|
|
202
|
+
assert.equal(viaCodex.featureName, shared.featureName);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ── The recorded active feature: the escape the block told users to take ────────────
|
|
207
|
+
|
|
208
|
+
test('a recorded active feature resolves two packets claiming closeout', () => {
|
|
209
|
+
withProject((root) => {
|
|
210
|
+
legacyPacket(root, 'closing-a', { status: 'done', phase: 'closeout' });
|
|
211
|
+
legacyPacket(root, 'closing-b', { status: 'done', phase: 'closeout' });
|
|
212
|
+
assert.equal(RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} }).error, 'multiple_persisted');
|
|
213
|
+
recordActiveFeature(root, '{"featureName": "closing-b"}\n');
|
|
214
|
+
const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
|
|
215
|
+
assert.equal(resolved.error, undefined, `expected a resolution, got ${resolved.error}`);
|
|
216
|
+
assert.equal(resolved.featureName, 'closing-b');
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('a payload target beats the recorded file', () => {
|
|
221
|
+
withProject((root) => {
|
|
222
|
+
legacyPacket(root, 'alpha', { status: 'done', phase: 'closeout' });
|
|
223
|
+
legacyPacket(root, 'beta', { status: 'done', phase: 'closeout' });
|
|
224
|
+
recordActiveFeature(root, '{"featureName": "alpha"}\n');
|
|
225
|
+
const resolved = RESOLVER.resolvePersistedSpec({
|
|
226
|
+
projectRoot: root, runtime: {}, target: { featureName: 'beta' },
|
|
227
|
+
});
|
|
228
|
+
assert.equal(resolved.featureName, 'beta', 'the caller must win');
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('a blank recorded feature is ignored rather than malformed', () => {
|
|
233
|
+
// explicitTargetValue turns a blank into null, and a null target becomes an
|
|
234
|
+
// explicit_malformed error that blocks. The reader must return null instead, or every
|
|
235
|
+
// project holding a stray file would be blocked by the very escape hatch.
|
|
236
|
+
for (const contents of ['{"featureName": " "}', '{"featureName": 42}', '{}', 'not json', '[]']) {
|
|
237
|
+
withProject((root) => {
|
|
238
|
+
legacyPacket(root, 'only', { status: 'done', phase: 'closeout' });
|
|
239
|
+
recordActiveFeature(root, contents);
|
|
240
|
+
assert.equal(
|
|
241
|
+
RESOLVER.readActiveFeatureTarget({ projectRoot: root, runtime: {} }), null,
|
|
242
|
+
`${contents} must be invisible`
|
|
243
|
+
);
|
|
244
|
+
assert.equal(RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} }).featureName, 'only');
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('a recorded feature cannot escape the specs root', () => {
|
|
250
|
+
// The reader adds no trust: the value goes through the resolver's existing checks.
|
|
251
|
+
withProject((root) => {
|
|
252
|
+
legacyPacket(root, 'a', { status: 'done', phase: 'closeout' });
|
|
253
|
+
legacyPacket(root, 'b', { status: 'done', phase: 'closeout' });
|
|
254
|
+
recordActiveFeature(root, '{"featureName": "../../etc"}');
|
|
255
|
+
const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
|
|
256
|
+
assert.ok(resolved.error, 'an escaping name must produce the existing error, not a resolution');
|
|
257
|
+
assert.notEqual(resolved.featureName, '../../etc');
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('a symlinked recorded feature is refused', () => {
|
|
262
|
+
// The file is read from inside the project, so a symlink could name a target the
|
|
263
|
+
// project does not contain. The repository already refuses symlinked runtime
|
|
264
|
+
// dependencies elsewhere; this reader follows the same rule.
|
|
265
|
+
withProject((root) => {
|
|
266
|
+
legacyPacket(root, 'only', { status: 'done', phase: 'closeout' });
|
|
267
|
+
const outside = path.join(root, 'outside.json');
|
|
268
|
+
fs.writeFileSync(outside, '{"featureName": "only"}');
|
|
269
|
+
const dir = path.join(root, 'specs', '_shared');
|
|
270
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
271
|
+
fs.symlinkSync(outside, path.join(dir, 'active-feature.json'));
|
|
272
|
+
assert.equal(RESOLVER.readActiveFeatureTarget({ projectRoot: root, runtime: {} }), null);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('an absent file leaves every project exactly as it was', () => {
|
|
277
|
+
withProject((root) => {
|
|
278
|
+
legacyPacket(root, 'only', { status: 'in_progress', phase: 'execution' });
|
|
279
|
+
assert.equal(RESOLVER.readActiveFeatureTarget({ projectRoot: root, runtime: {} }), null);
|
|
280
|
+
assert.equal(RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} }).featureName, 'only');
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('the approval prompt names its feature', () => {
|
|
285
|
+
// Without the name a user types a bare nonce and cannot tell which closeout they are
|
|
286
|
+
// approving, while the recorded file can influence which packet resolves.
|
|
287
|
+
for (const runtimeDir of ['claude', 'codex']) {
|
|
288
|
+
const hook = fs.readFileSync(
|
|
289
|
+
path.join(PACKAGE_ROOT, 'src', runtimeDir, 'hooks', 'completion-authority.cjs'), 'utf8'
|
|
290
|
+
);
|
|
291
|
+
assert.match(hook, /const feature = result\.candidate\?\.featureName;/, `${runtimeDir} must read the name`);
|
|
292
|
+
assert.match(hook, /closeout approval is required for \$\{feature \?/, `${runtimeDir} must print it`);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('the shared workflow resolver ignores the recorded file', () => {
|
|
297
|
+
// resolveWorkflowCandidate has five consumers, including spec-state.cjs and
|
|
298
|
+
// precompact.cjs. Reading the file inside it would retarget hooks that never asked,
|
|
299
|
+
// so the two gates apply it at their own call sites instead. Neither of those suites
|
|
300
|
+
// writes the file, so nothing else pins this containment.
|
|
301
|
+
withProject((root) => {
|
|
302
|
+
legacyPacket(root, 'alpha', { status: 'done', phase: 'closeout' });
|
|
303
|
+
legacyPacket(root, 'beta', { status: 'done', phase: 'closeout' });
|
|
304
|
+
recordActiveFeature(root, '{"featureName": "beta"}');
|
|
305
|
+
const resolved = RESOLVER.resolveWorkflowCandidate({
|
|
306
|
+
projectRoot: root, runtime: {}, target: null, includeCompleted: true,
|
|
307
|
+
});
|
|
308
|
+
assert.ok(resolved.error, 'the shared resolver must stay ambiguous on its own');
|
|
309
|
+
assert.notEqual(resolved.featureName, 'beta');
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('the codex gate consults the file only after its payload', () => {
|
|
314
|
+
const utils = require(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-utils.cjs'));
|
|
315
|
+
assert.equal(typeof utils.readActiveFeatureTarget, 'function', 'the wrapper must be exported');
|
|
316
|
+
assert.equal(typeof utils.extractExplicitTarget, 'function', 'the gate needs the payload extractor');
|
|
317
|
+
withProject((root) => {
|
|
318
|
+
legacyPacket(root, 'alpha', { status: 'done', phase: 'closeout' });
|
|
319
|
+
recordActiveFeature(root, '{"featureName": "alpha"}');
|
|
320
|
+
assert.deepEqual(
|
|
321
|
+
utils.extractExplicitTarget({ featureName: 'beta' }) || utils.readActiveFeatureTarget({ projectRoot: root, runtime: {} }),
|
|
322
|
+
{ explicitFeature: 'beta' }
|
|
323
|
+
);
|
|
324
|
+
assert.deepEqual(
|
|
325
|
+
utils.extractExplicitTarget({}) || utils.readActiveFeatureTarget({ projectRoot: root, runtime: {} }),
|
|
326
|
+
{ explicitFeature: 'alpha' }
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// ── End to end: the block the report describes, and the escape from it ──────────────
|
|
332
|
+
|
|
333
|
+
/** A git project the Stop gate will accept as a real project root. */
|
|
334
|
+
function gitProject(run) {
|
|
335
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-gate-')));
|
|
336
|
+
try {
|
|
337
|
+
for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'],
|
|
338
|
+
['config', 'user.name', 'CafeKit Test'], ['commit', '--allow-empty', '-qm', 'fixture']]) {
|
|
339
|
+
const result = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
|
|
340
|
+
assert.equal(result.status, 0, result.stderr);
|
|
341
|
+
}
|
|
342
|
+
return run(dir);
|
|
343
|
+
} finally {
|
|
344
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function runGate(hook, dir) {
|
|
349
|
+
const result = spawnSync(process.execPath, [path.join(PACKAGE_ROOT, hook)], {
|
|
350
|
+
input: JSON.stringify({
|
|
351
|
+
hook_event_name: 'Stop', session_id: 'test', transcript_path: '/tmp/t',
|
|
352
|
+
stop_hook_active: false, cwd: dir,
|
|
353
|
+
}),
|
|
354
|
+
// PROJECT_ROOT wins over cwd in the hook, so the host's own specs cannot leak in.
|
|
355
|
+
encoding: 'utf8', env: { ...process.env, PROJECT_ROOT: dir },
|
|
356
|
+
});
|
|
357
|
+
return (result.stdout || '').trim();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// The Codex gate resolves PROJECT_ROOT from its own installed location, so its end-to-end
|
|
361
|
+
// twin lives in bin/__tests__/codex-hooks.test.js, which owns the installer fixture.
|
|
362
|
+
for (const [runtimeName, hook] of [['claude', 'src/claude/hooks/spec-gate.cjs']]) {
|
|
363
|
+
test(`${runtimeName}: the recorded feature turns the identity block into receipt validation`, () => {
|
|
364
|
+
// This is issue #79 end to end. Ambiguity answers a missing-receipt violation with an
|
|
365
|
+
// identity complaint whose stated remedy nothing could supply; naming the feature
|
|
366
|
+
// makes the gate resolve it and report the violation that was there all along.
|
|
367
|
+
gitProject((dir) => {
|
|
368
|
+
legacyPacket(dir, 'alpha', { status: 'done', phase: 'closeout' });
|
|
369
|
+
legacyPacket(dir, 'beta', { status: 'done', phase: 'closeout' });
|
|
370
|
+
|
|
371
|
+
const blocked = runGate(hook, dir);
|
|
372
|
+
assert.match(blocked, /multiple active specs detected/, 'the reported block must reproduce');
|
|
373
|
+
|
|
374
|
+
recordActiveFeature(dir, '{"featureName": "beta"}\n');
|
|
375
|
+
const resolved = runGate(hook, dir);
|
|
376
|
+
assert.doesNotMatch(resolved, /multiple active specs/, 'the recorded feature must resolve it');
|
|
377
|
+
assert.match(resolved, /lack a verification receipt/, 'the gate must now reach receipt validation');
|
|
378
|
+
assert.match(resolved, /specs\/beta\//, 'and validate the named feature, not a sibling');
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haposoft/cafekit",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.5",
|
|
4
4
|
"description": "Native spec-driven workflows for Claude Code and Codex CLI. Bundles CafeKit skills, runtime hooks, agents, and installer scaffolding.",
|
|
5
5
|
"author": "Haposoft <nghialt@haposoft.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -700,7 +700,14 @@ for (const kind of ['claude', 'codex']) {
|
|
|
700
700
|
assert.match(block(stop(runtime, { featureName: 'other-feature' })).reason, /explicit/i);
|
|
701
701
|
prepare(root, { feature: 'other-feature', phase: 'implementation' });
|
|
702
702
|
assert.ok(nonceFrom(stop(runtime, { featureName: FEATURE })).nonce);
|
|
703
|
-
|
|
703
|
+
// Contract change: a sibling that is not claiming closeout is no longer a rival.
|
|
704
|
+
// This used to assert ambiguity, which is the over-strict behaviour issue #79
|
|
705
|
+
// reports: a repository that had merely accumulated features could never name its
|
|
706
|
+
// current one again. Only FEATURE is at closeout here, so it resolves and its
|
|
707
|
+
// approval is demanded. Two packets both claiming closeout still block, which
|
|
708
|
+
// `several packets claiming closeout stay ambiguous and name only those` covers in
|
|
709
|
+
// bin/__tests__/spec-narrowing.test.js.
|
|
710
|
+
assert.ok(nonceFrom(stop(runtime)).nonce, 'the one packet claiming closeout resolves');
|
|
704
711
|
for (const malformed of [
|
|
705
712
|
{ featureName: '' },
|
|
706
713
|
{ featureName: 42 },
|
|
@@ -1621,43 +1621,61 @@ test('41. unborn HEAD fails closed before any receipt check', () => {
|
|
|
1621
1621
|
}
|
|
1622
1622
|
});
|
|
1623
1623
|
|
|
1624
|
-
test('42. dirty tree outside specs
|
|
1625
|
-
|
|
1624
|
+
test('42. a dirty tree outside specs does not reopen a committed receipt', () => {
|
|
1625
|
+
// Contract change. This used to require a block. `Base` is the last commit
|
|
1626
|
+
// touching anything outside the specs root, so a receipt written before any
|
|
1627
|
+
// later commit records an older `Base` by construction — 52 of 52 done
|
|
1628
|
+
// receipts on the CafeKit repository. Reopening on a dirty tree therefore
|
|
1629
|
+
// failed every historical receipt at once, every time, which blocked projects
|
|
1630
|
+
// on their first turn of a session with nothing changed. What still blocks is
|
|
1631
|
+
// the task file that actually differs from its committed bytes, asserted
|
|
1632
|
+
// below from both working directories, since the git call must pin the
|
|
1633
|
+
// project root rather than the working directory.
|
|
1634
|
+
const dir = makeWorkflowFixture(boundReceipt(STALE_BASE, STALE_HEAD));
|
|
1626
1635
|
try {
|
|
1627
1636
|
commitAll(dir, 'receipt');
|
|
1628
1637
|
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
1629
1638
|
fs.writeFileSync(path.join(dir, 'src', 'dirty.txt'), 'uncommitted\n');
|
|
1630
1639
|
clearCache();
|
|
1631
|
-
|
|
1632
|
-
assert.ok(fromRoot && fromRoot.decision === 'block', 'an uncommitted change outside specs must rebind');
|
|
1633
|
-
assert.match(fromRoot.reason, /\bprovenance\b/);
|
|
1640
|
+
assert.strictEqual(runHook({}, dir).stdout, '', 'unrelated dirt must not reopen a committed receipt');
|
|
1634
1641
|
clearCache();
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1642
|
+
assert.strictEqual(runHookFrom(dir, path.join(dir, 'specs', FEATURE)).stdout, '',
|
|
1643
|
+
'and must not reopen it when the hook runs from inside the specs root either');
|
|
1644
|
+
|
|
1645
|
+
const taskFile = path.join(dir, 'specs', FEATURE, 'task-01-demo.md');
|
|
1646
|
+
fs.writeFileSync(taskFile, fs.readFileSync(taskFile, 'utf8').replace(STALE_HEAD, 'd'.repeat(64)));
|
|
1647
|
+
for (const [label, run] of [['project root', () => runHook({}, dir)],
|
|
1648
|
+
['specs root', () => runHookFrom(dir, path.join(dir, 'specs', FEATURE))]]) {
|
|
1649
|
+
clearCache();
|
|
1650
|
+
const body = parseBlock(run().stdout);
|
|
1651
|
+
assert.ok(body && body.decision === 'block', `an edited task file must rebind, run from the ${label}`);
|
|
1652
|
+
assert.match(body.reason, /\bprovenance\b/);
|
|
1653
|
+
}
|
|
1638
1654
|
} finally {
|
|
1639
1655
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
1640
1656
|
}
|
|
1641
1657
|
});
|
|
1642
1658
|
|
|
1643
|
-
test('42b.
|
|
1644
|
-
// `git status`
|
|
1645
|
-
//
|
|
1646
|
-
//
|
|
1659
|
+
test('42b. skip-worktree and assume-unchanged cannot hide an edited task file', () => {
|
|
1660
|
+
// Both index flags make `git status` report a modified file as clean. The mode
|
|
1661
|
+
// selector no longer consults status at all, but it must not become hideable
|
|
1662
|
+
// some other way: the committed-bytes check reads the file from disk and asks
|
|
1663
|
+
// git for `HEAD:<path>`, and neither consults the index. Marking the task file
|
|
1664
|
+
// with either flag must therefore change nothing.
|
|
1647
1665
|
for (const flag of ['--skip-worktree', '--assume-unchanged']) {
|
|
1648
1666
|
const dir = makeWorkflowFixture(boundReceipt(STALE_BASE, STALE_HEAD));
|
|
1649
1667
|
try {
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
const marked = spawnSync('git', ['-C', dir, 'update-index', flag, 'src/hidden.txt'], { encoding: 'utf8' });
|
|
1668
|
+
commitAll(dir, 'receipt');
|
|
1669
|
+
const taskRelative = `specs/${FEATURE}/task-01-demo.md`;
|
|
1670
|
+
const marked = spawnSync('git', ['-C', dir, 'update-index', flag, taskRelative], { encoding: 'utf8' });
|
|
1654
1671
|
assert.strictEqual(marked.status, 0, marked.stderr);
|
|
1655
|
-
|
|
1672
|
+
const taskFile = path.join(dir, taskRelative);
|
|
1673
|
+
fs.writeFileSync(taskFile, fs.readFileSync(taskFile, 'utf8').replace(STALE_HEAD, 'd'.repeat(64)));
|
|
1656
1674
|
const status = spawnSync('git', ['-C', dir, 'status', '--porcelain'], { encoding: 'utf8' });
|
|
1657
1675
|
assert.strictEqual(status.stdout.trim(), '', `${flag} must make the tree look clean for this case to mean anything`);
|
|
1658
1676
|
clearCache();
|
|
1659
1677
|
const body = parseBlock(runHook({}, dir).stdout);
|
|
1660
|
-
assert.ok(body && body.decision === 'block', `${flag} must not
|
|
1678
|
+
assert.ok(body && body.decision === 'block', `${flag} must not hide an edited task file`);
|
|
1661
1679
|
assert.match(body.reason, /\bprovenance\b/);
|
|
1662
1680
|
} finally {
|
|
1663
1681
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
@@ -1771,10 +1789,10 @@ test('46. a dirty file inside the specs root does not force binding', () => {
|
|
|
1771
1789
|
}
|
|
1772
1790
|
});
|
|
1773
1791
|
|
|
1774
|
-
test('47. completed-set branch accepts committed receipts and blocks
|
|
1792
|
+
test('47. completed-set branch accepts committed receipts and blocks an edited one', () => {
|
|
1775
1793
|
// Two finished packets resolve to the completed-set branch, which is the path
|
|
1776
|
-
// that revalidated every done receipt on every Stop. Prove
|
|
1777
|
-
//
|
|
1794
|
+
// that revalidated every done receipt on every Stop. Prove committed receipts
|
|
1795
|
+
// survive both a clean and a dirty tree, then prove an edited one still blocks.
|
|
1778
1796
|
const dir = makeWorkflowFixture(boundReceipt(STALE_BASE, STALE_HEAD));
|
|
1779
1797
|
try {
|
|
1780
1798
|
const secondDir = path.join(dir, 'specs', 'second');
|
|
@@ -1788,13 +1806,27 @@ test('47. completed-set branch accepts committed receipts and blocks a tampered
|
|
|
1788
1806
|
clearCache();
|
|
1789
1807
|
const accepted = runHook({}, dir);
|
|
1790
1808
|
assert.strictEqual(accepted.stdout, '', 'the completed-set branch must accept committed receipts on a clean tree');
|
|
1809
|
+
// Contract change: unrelated dirt no longer reopens committed receipts. This
|
|
1810
|
+
// used to assert a block, which is what made the gate unusable in a project
|
|
1811
|
+
// holding several finished packets — one untracked file failed every receipt
|
|
1812
|
+
// in the repository at once. Base is the last commit touching anything
|
|
1813
|
+
// outside the specs root, so a historical receipt records an older Base by
|
|
1814
|
+
// construction and the check fired on all of them, always.
|
|
1791
1815
|
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
1792
1816
|
fs.writeFileSync(path.join(dir, 'src', 'dirty.txt'), 'uncommitted\n');
|
|
1793
1817
|
clearCache();
|
|
1818
|
+
assert.strictEqual(runHook({}, dir).stdout, '', 'unrelated dirt must not reopen committed receipts');
|
|
1819
|
+
|
|
1820
|
+
// What still blocks: the receipt that actually changed after being committed.
|
|
1821
|
+
fs.writeFileSync(
|
|
1822
|
+
path.join(secondDir, 'task-01-second.md'),
|
|
1823
|
+
fs.readFileSync(path.join(secondDir, 'task-01-second.md'), 'utf8').replace(STALE_HEAD, 'd'.repeat(64)),
|
|
1824
|
+
);
|
|
1825
|
+
clearCache();
|
|
1794
1826
|
const body = parseBlock(runHook({}, dir).stdout);
|
|
1795
|
-
assert.ok(body && body.decision === 'block', '
|
|
1827
|
+
assert.ok(body && body.decision === 'block', 'a receipt edited after commit must still be rebound');
|
|
1796
1828
|
assert.match(body.reason, /\bprovenance\b/);
|
|
1797
|
-
assert.match(body.reason, /second
|
|
1829
|
+
assert.match(body.reason, /second/);
|
|
1798
1830
|
} finally {
|
|
1799
1831
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
1800
1832
|
}
|
|
@@ -131,7 +131,10 @@ function stop(payload) {
|
|
|
131
131
|
const consumed = STATE.consumeGrant(root, result.binding);
|
|
132
132
|
if (consumed.ok) return;
|
|
133
133
|
const pending = STATE.createPending(root, result.binding);
|
|
134
|
-
|
|
134
|
+
// Name the feature: a recorded target can influence which packet resolves, so an
|
|
135
|
+
// approval the user cannot identify is an approval they cannot withhold.
|
|
136
|
+
const feature = result.candidate?.featureName;
|
|
137
|
+
emitBlock(`Completion authority: technical proofs pass, but user closeout approval is required for ${feature ? `"${feature}"` : 'the resolved feature'}. Enter exactly "${STATE.PREFIX}${pending.nonce}" as your next user prompt. This approval is one-time and expires in five minutes.`);
|
|
135
138
|
}
|
|
136
139
|
|
|
137
140
|
let mode = process.argv[2] || null;
|
|
@@ -119,9 +119,14 @@ try {
|
|
|
119
119
|
} catch { /* gate stays on */ }
|
|
120
120
|
// Active-spec discovery via shared resolver — explicit target if present, else fail on ambiguity.
|
|
121
121
|
const baseDir = cwd;
|
|
122
|
-
const target = typeof RESOLVER.extractExplicitTarget === 'function'
|
|
122
|
+
const target = (typeof RESOLVER.extractExplicitTarget === 'function'
|
|
123
123
|
? RESOLVER.extractExplicitTarget(payload)
|
|
124
|
-
: null
|
|
124
|
+
: null)
|
|
125
|
+
// Only when the host named nothing. The recorded target is what makes "Provide
|
|
126
|
+
// explicit feature target" an instruction a user can actually follow.
|
|
127
|
+
|| (typeof RESOLVER.readActiveFeatureTarget === 'function'
|
|
128
|
+
? RESOLVER.readActiveFeatureTarget({ projectRoot: baseDir, runtime })
|
|
129
|
+
: null);
|
|
125
130
|
let resolved = typeof RESOLVER.resolveWorkflowCandidate === 'function'
|
|
126
131
|
? RESOLVER.resolveWorkflowCandidate({ projectRoot: baseDir, runtime, target, includeCompleted: true })
|
|
127
132
|
: FINAL_STATE.resolveCandidate({ resolver: RESOLVER, projectRoot: baseDir, runtime, payload });
|
|
@@ -235,10 +240,10 @@ try {
|
|
|
235
240
|
// task currently marked done, so a cached PASS cannot hide later mutations
|
|
236
241
|
// (deletion, placeholder, changed Verification/Command/Exit, missing
|
|
237
242
|
// output, edited artifact bytes). Base/Head are compared against the live
|
|
238
|
-
// runtime only while the task file differs from its committed bytes
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
// truth.
|
|
243
|
+
// runtime only while the task file differs from its committed bytes; once it
|
|
244
|
+
// is committed and unchanged the receipt is checked on structure alone, and
|
|
245
|
+
// work elsewhere in the tree no longer reopens it. Cache is an optimization,
|
|
246
|
+
// not truth.
|
|
242
247
|
const featureCache = cacheExists ? (cache[featureName] || {}) : {};
|
|
243
248
|
const allDoneTasks = Object.keys(taskRegistry).filter((tp) =>
|
|
244
249
|
(taskRegistry[tp]?.status || 'pending') === 'done'
|
|
@@ -226,7 +226,7 @@ try {
|
|
|
226
226
|
lines.push('- Migration required: add `Specs-Contract: process-first-ready-v1` after the plan title before tasks can enter Next.');
|
|
227
227
|
}
|
|
228
228
|
lines.push('- Sync each flat task `Status:` only after verified work; task proof belongs in that task\'s inline `## Receipt`.');
|
|
229
|
-
lines.push(`- Workflow source: \`specs/${featureName}/plan.md\`; Stop re-checks every done receipt, binding Base/Head to the runtime until the task file is committed
|
|
229
|
+
lines.push(`- Workflow source: \`specs/${featureName}/plan.md\`; Stop re-checks every done receipt, binding Base/Head to the runtime until the task file is committed and unchanged.`);
|
|
230
230
|
} else {
|
|
231
231
|
lines.push('- Sync `spec.json` + task Markdown status after verified work; task proof belongs in `receipts/<task-basename>.md`.');
|
|
232
232
|
lines.push(`- Create \`feature-receipt.md\` once after final integration proof${featureReceiptPresent ? ' (present)' : ' (not required before closeout)'}.`);
|
|
@@ -33,8 +33,11 @@ Set `Status: done` only after the inline Receipt passes the
|
|
|
33
33
|
fenced block of current command output.
|
|
34
34
|
|
|
35
35
|
A done task keeps that binding while its file differs from its committed bytes,
|
|
36
|
-
and is validated on structure alone once
|
|
37
|
-
|
|
36
|
+
and is validated on structure alone once that file is committed and unchanged. Work
|
|
37
|
+
elsewhere in the tree does not reopen it: Base is the last commit touching anything
|
|
38
|
+
outside the specs root, so every finished receipt records an older Base by
|
|
39
|
+
construction and reopening on a dirty tree failed all of them at once.
|
|
40
|
+
The gate detects drift, not invention: a valid
|
|
38
41
|
Base and Head pair costs one command and no verification run, so neither mode proves
|
|
39
42
|
that the command was executed. Missing, stale, contradictory,
|
|
40
43
|
placeholder, copied, failure, or zero-test evidence leaves the task unfinished.
|
|
@@ -5,7 +5,6 @@ const fs = require('fs');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { spawnSync } = require('child_process');
|
|
7
7
|
const { annotatedMarkdownLines } = require('./spec-resolver.cjs');
|
|
8
|
-
const { RUNTIME_STATE_ROOTS } = require('./provenance.cjs');
|
|
9
8
|
|
|
10
9
|
const EVIDENCE_HEADINGS = new Set([
|
|
11
10
|
'Evidence',
|
|
@@ -185,14 +184,19 @@ function tryGitBytes(root, args) {
|
|
|
185
184
|
return result.stdout;
|
|
186
185
|
}
|
|
187
186
|
|
|
188
|
-
// A receipt is bound to live Base/Head while
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
// from
|
|
187
|
+
// A receipt is bound to live Base/Head while its own task file is being written
|
|
188
|
+
// or altered, and validates on structure alone once that file matches its
|
|
189
|
+
// committed bytes. The binding used to require the whole tree outside the specs
|
|
190
|
+
// root to be clean as well. That condition was removed because it carried no
|
|
191
|
+
// information: Base is the last commit touching anything outside the specs root,
|
|
192
|
+
// so every receipt written before a later commit records an older Base by
|
|
193
|
+
// construction. Measured on this repository, 52 of 52 done receipts differed
|
|
194
|
+
// from the live Base, so one uncommitted unrelated file failed all of them at
|
|
195
|
+
// once and a tampered receipt was indistinguishable from an untouched one. The
|
|
196
|
+
// signal it was meant to carry — marking a task done and then editing the code —
|
|
197
|
+
// is still caught, because doing that leaves the task file uncommitted. Every
|
|
198
|
+
// state that cannot be read as committed-and-unchanged keeps the binding,
|
|
199
|
+
// including an unborn HEAD, a staged-only file, and any git failure.
|
|
196
200
|
function receiptBindingMode(featureDir, taskPath, taskBytes, runtimeContext) {
|
|
197
201
|
const root = runtimeContext && typeof runtimeContext.project_root === 'string' ? runtimeContext.project_root : null;
|
|
198
202
|
const specsRoot = runtimeContext && typeof runtimeContext.specs_root === 'string' ? runtimeContext.specs_root : null;
|
|
@@ -208,24 +212,6 @@ function receiptBindingMode(featureDir, taskPath, taskBytes, runtimeContext) {
|
|
|
208
212
|
// so a checkout that converts line endings can still reach structure mode.
|
|
209
213
|
const committed = tryGitBytes(root, ['cat-file', '--filters', `HEAD:${taskRelative}`]);
|
|
210
214
|
if (committed === null || !committed.equals(taskBytes)) return 'binding';
|
|
211
|
-
// The same roots the provenance manifest calls generated runtime state rather
|
|
212
|
-
// than source evidence. Without them the gate defeats itself: writing its own
|
|
213
|
-
// cache dirties the tree, so a project that tracks its runtime directory could
|
|
214
|
-
// never reach structure mode.
|
|
215
|
-
const outsideSpecs = ['--', '.', `:(exclude,literal)${specsRelative}`,
|
|
216
|
-
...RUNTIME_STATE_ROOTS.map((entry) => `:(exclude,literal)${entry}`)];
|
|
217
|
-
const status = tryGitBytes(root, ['status', '--porcelain', '--untracked-files=all', ...outsideSpecs]);
|
|
218
|
-
if (status === null || status.length !== 0) return 'binding';
|
|
219
|
-
// `git status` obeys skip-worktree and assume-unchanged, so a tracked file can
|
|
220
|
-
// be modified while the tree reports clean. Require every index entry outside
|
|
221
|
-
// the specs root to carry the plain cached tag `H`; any other tag, including
|
|
222
|
-
// `S` and the lowercase assume-unchanged tags, keeps the binding.
|
|
223
|
-
const indexTags = tryGitBytes(root, ['ls-files', '-v', ...outsideSpecs]);
|
|
224
|
-
if (indexTags === null) return 'binding';
|
|
225
|
-
const hidden = indexTags.toString('utf8').split('\n')
|
|
226
|
-
.filter((line) => line.trim() !== '')
|
|
227
|
-
.some((line) => line[0] !== 'H');
|
|
228
|
-
if (hidden) return 'binding';
|
|
229
215
|
return 'structure';
|
|
230
216
|
}
|
|
231
217
|
|
|
@@ -289,6 +289,12 @@ function inspectWorkflowFeature(specsDir, canonicalSpecs, requestedName) {
|
|
|
289
289
|
};
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
+
// Legacy registries written before the current vocabulary use `completed`, which today's
|
|
293
|
+
// validator rejects but which exists in upgraded repositories. Reading it as finished is
|
|
294
|
+
// what lets those packets stop competing for the gate's attention; `spec-final-state.cjs`
|
|
295
|
+
// already reads spec status the same way. Process-first packets keep the strict set.
|
|
296
|
+
const LEGACY_TASK_DONE = new Set(['done', 'completed', 'complete']);
|
|
297
|
+
|
|
292
298
|
function normalizeLegacyWorkflowCandidate(candidate) {
|
|
293
299
|
const registry = candidate.spec?.task_registry || {};
|
|
294
300
|
const tasks = Object.entries(registry).map(([taskPath, task]) => ({
|
|
@@ -302,7 +308,7 @@ function normalizeLegacyWorkflowCandidate(candidate) {
|
|
|
302
308
|
phase: candidate.spec?.current_phase || candidate.spec?.phase || 'unknown',
|
|
303
309
|
taskRegistry: registry,
|
|
304
310
|
tasks,
|
|
305
|
-
allTasksDone: tasks.length > 0 && tasks.every((task) => task.status
|
|
311
|
+
allTasksDone: tasks.length > 0 && tasks.every((task) => LEGACY_TASK_DONE.has(task.status)),
|
|
306
312
|
};
|
|
307
313
|
}
|
|
308
314
|
|
|
@@ -491,6 +497,47 @@ function explicitTargetValue(value) {
|
|
|
491
497
|
return typeof value === 'string' ? value.trim() : value;
|
|
492
498
|
}
|
|
493
499
|
|
|
500
|
+
/**
|
|
501
|
+
* Read the feature a project has recorded as the one being worked on. This answers a
|
|
502
|
+
* genuine ambiguity that no host payload resolves, so it is deliberately not a source
|
|
503
|
+
* inside `extractExplicitTarget`: that function recurses and is shared with consumers
|
|
504
|
+
* that are not the Stop hooks. Anything other than a non-empty single-line
|
|
505
|
+
* `featureName` string is invisible rather than an error, because an error here would
|
|
506
|
+
* block every turn in a project that merely has a stray file. The value is then treated
|
|
507
|
+
* exactly like a host-supplied feature name and still faces containment, existence, and
|
|
508
|
+
* JSON checks.
|
|
509
|
+
*/
|
|
510
|
+
function readActiveFeatureTarget({ projectRoot, runtime } = {}) {
|
|
511
|
+
if (!projectRoot) return null;
|
|
512
|
+
let specsDir;
|
|
513
|
+
try {
|
|
514
|
+
specsDir = specsDirectory(projectRoot, runtime || {});
|
|
515
|
+
} catch {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
let raw;
|
|
519
|
+
try {
|
|
520
|
+
const file = path.join(specsDir, '_shared', 'active-feature.json');
|
|
521
|
+
// A symlink here could name a file outside the project, so only a regular file counts.
|
|
522
|
+
const info = lstatOptional(file);
|
|
523
|
+
if (!info.exists || info.isSymlink || !info.stat.isFile()) return null;
|
|
524
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
525
|
+
} catch {
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
let parsed;
|
|
529
|
+
try {
|
|
530
|
+
parsed = JSON.parse(raw);
|
|
531
|
+
} catch {
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
535
|
+
const value = parsed.featureName;
|
|
536
|
+
if (typeof value !== 'string' || /[\r\n]/.test(value)) return null;
|
|
537
|
+
const trimmed = value.trim();
|
|
538
|
+
return trimmed ? { explicitFeature: trimmed } : null;
|
|
539
|
+
}
|
|
540
|
+
|
|
494
541
|
/**
|
|
495
542
|
* Normalize host-provided feature targets without making runtime adapters
|
|
496
543
|
* guess from the first active directory. The resolver remains the authority
|
|
@@ -708,14 +755,27 @@ function resolvePersistedSpec({ projectRoot, runtime, explicitFeature, explicitP
|
|
|
708
755
|
}
|
|
709
756
|
return resolveActiveSpec({ projectRoot, runtime, ...normalized });
|
|
710
757
|
}
|
|
758
|
+
// Only now, with the caller having named nothing: a recorded target must never
|
|
759
|
+
// redirect a hook that already knows which feature its turn is about.
|
|
760
|
+
const recorded = readActiveFeatureTarget({ projectRoot, runtime });
|
|
761
|
+
if (recorded) return resolveActiveSpec({ projectRoot, runtime, ...recorded });
|
|
711
762
|
try {
|
|
712
763
|
const candidates = findAllSpecCandidates(projectRoot, runtime);
|
|
713
764
|
if (candidates.length === 0) return null;
|
|
765
|
+
// One packet resolves on its own terms. Closeout approval is claimed against a
|
|
766
|
+
// finished spec, so status must never decide identity here.
|
|
714
767
|
if (candidates.length === 1) return candidates[0];
|
|
768
|
+
// This path serves closeout approval, so only a packet actually claiming closeout is
|
|
769
|
+
// a real rival. Counting finished history as ambiguity is what made the gate
|
|
770
|
+
// unsatisfiable in a repository that had simply accumulated features.
|
|
771
|
+
const { isDurableCloseout } = require('./spec-final-state.cjs');
|
|
772
|
+
const claiming = candidates.filter((candidate) => isDurableCloseout(candidate.spec));
|
|
773
|
+
if (claiming.length === 1) return claiming[0];
|
|
774
|
+
if (claiming.length === 0) return null;
|
|
715
775
|
return {
|
|
716
776
|
error: 'multiple_persisted',
|
|
717
|
-
candidates:
|
|
718
|
-
reason: `Multiple persisted specs found: ${
|
|
777
|
+
candidates: claiming.map((candidate) => candidate.featureName),
|
|
778
|
+
reason: `Multiple persisted specs found: ${claiming.map((candidate) => candidate.featureName).join(', ')}. Provide explicit feature.`,
|
|
719
779
|
};
|
|
720
780
|
} catch (error) {
|
|
721
781
|
return {
|
|
@@ -893,10 +953,11 @@ function refineWorkflowGateResolution(resolved) {
|
|
|
893
953
|
return resolved;
|
|
894
954
|
}
|
|
895
955
|
const candidates = resolved.active;
|
|
896
|
-
if (candidates.length === 0
|
|
897
|
-
return resolved;
|
|
898
|
-
}
|
|
956
|
+
if (candidates.length === 0) return resolved;
|
|
899
957
|
|
|
958
|
+
// Which packet still has work decides this, not which layout it uses: a repository of
|
|
959
|
+
// legacy packets was permanently ambiguous only because the check below used to sit
|
|
960
|
+
// here and reject the whole set on sight.
|
|
900
961
|
const unfinished = candidates.filter((candidate) => !candidate.allTasksDone);
|
|
901
962
|
if (unfinished.length === 1) return unfinished[0];
|
|
902
963
|
if (unfinished.length > 1) {
|
|
@@ -908,6 +969,10 @@ function refineWorkflowGateResolution(resolved) {
|
|
|
908
969
|
reason: `Multiple active workflows found: ${unfinished.map((candidate) => candidate.featureName).join(', ')}. Provide explicit feature.`,
|
|
909
970
|
};
|
|
910
971
|
}
|
|
972
|
+
// The bulk-audit branch exits before the semantic-digest, FLASH_UNVERIFIED,
|
|
973
|
+
// feature-receipt, and completion-policy layers that the single-candidate path runs, so
|
|
974
|
+
// only process-first packets may reach it. Anything else keeps the existing ambiguity.
|
|
975
|
+
if (candidates.some((candidate) => candidate.layoutKind !== 'process-v3')) return resolved;
|
|
911
976
|
return {
|
|
912
977
|
layoutKind: 'process-v3-completed-set',
|
|
913
978
|
candidates,
|
|
@@ -923,6 +988,7 @@ module.exports = {
|
|
|
923
988
|
findAllSpecCandidates,
|
|
924
989
|
extractExplicitTarget,
|
|
925
990
|
resolveActiveSpec,
|
|
991
|
+
readActiveFeatureTarget,
|
|
926
992
|
resolvePersistedSpec,
|
|
927
993
|
resolveWorkflowCandidate,
|
|
928
994
|
refineWorkflowGateResolution,
|
|
@@ -128,7 +128,7 @@ The shared workflow resolver recognizes only a regular `plan.md` plus one or mor
|
|
|
128
128
|
regular flat `task-*.md` files in one direct feature directory, and projects exact
|
|
129
129
|
`Status:` values without inferring blockers from prose. The Stop gate re-reads every
|
|
130
130
|
done Receipt, binding Base and Head to the live runtime until the task file is
|
|
131
|
-
committed
|
|
131
|
+
committed and unchanged; work elsewhere in the tree does not reopen it. It detects drift
|
|
132
132
|
between a receipt and the tree, not invention: a valid pair costs one command and no
|
|
133
133
|
verification run, so a receipt for a command that never ran satisfies it in either
|
|
134
134
|
mode. C3 is where a human weighs the evidence. These checks are a final safety net,
|
|
@@ -37,6 +37,10 @@ function explicitTarget(payload) {
|
|
|
37
37
|
function resolveCandidate({ projectRoot, runtime, payload }) {
|
|
38
38
|
const target = explicitTarget(payload);
|
|
39
39
|
if (target) return RESOLVER.resolveActiveSpec({ projectRoot, runtime, ...target });
|
|
40
|
+
// Delegate so both runtimes answer the same question; the count below stays only as a
|
|
41
|
+
// fallback for an installed resolver older than this wrapper.
|
|
42
|
+
const shared = RESOLVER.resolvePersistedSpec({ projectRoot, runtime });
|
|
43
|
+
if (shared !== null) return shared;
|
|
40
44
|
const candidates = RESOLVER.findAllSpecCandidates(projectRoot, runtime);
|
|
41
45
|
if (candidates.length > 1) return { error: 'multiple_persisted', candidates: candidates.map((candidate) => candidate.featureName) };
|
|
42
46
|
return candidates[0] || null;
|
|
@@ -79,7 +79,10 @@ function stop(payload) {
|
|
|
79
79
|
const consumed = STATE.consumeGrant(projectRoot, result.binding);
|
|
80
80
|
if (consumed.ok) return;
|
|
81
81
|
const pending = STATE.createPending(projectRoot, result.binding);
|
|
82
|
-
|
|
82
|
+
// Name the feature: a recorded target can influence which packet resolves, so an
|
|
83
|
+
// approval the user cannot identify is an approval they cannot withhold.
|
|
84
|
+
const feature = result.candidate?.featureName;
|
|
85
|
+
emitBlock(`Completion authority: technical proofs pass, but user closeout approval is required for ${feature ? `"${feature}"` : 'the resolved feature'}. Enter exactly "${STATE.PREFIX}${pending.nonce}" as your next user prompt. This approval is one-time and expires in five minutes.`);
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
let mode = process.argv[2] || null;
|
|
@@ -140,8 +140,29 @@ function taskStatusMap(spec) {
|
|
|
140
140
|
);
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
function extractExplicitTarget(...sources) {
|
|
144
|
+
const resolver = sharedResolver();
|
|
145
|
+
if (typeof resolver.extractExplicitTarget !== 'function') return null;
|
|
146
|
+
return resolver.extractExplicitTarget(...sources);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readActiveFeatureTarget(options) {
|
|
150
|
+
const resolver = sharedResolver();
|
|
151
|
+
if (typeof resolver.readActiveFeatureTarget !== 'function') return null;
|
|
152
|
+
return resolver.readActiveFeatureTarget(options);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function resolvePersistedSpec(options) {
|
|
156
|
+
const resolver = sharedResolver();
|
|
157
|
+
if (typeof resolver.resolvePersistedSpec !== 'function') return null;
|
|
158
|
+
return resolver.resolvePersistedSpec(options);
|
|
159
|
+
}
|
|
160
|
+
|
|
143
161
|
module.exports = {
|
|
144
162
|
findActiveSpec,
|
|
163
|
+
extractExplicitTarget,
|
|
164
|
+
readActiveFeatureTarget,
|
|
165
|
+
resolvePersistedSpec,
|
|
145
166
|
findAllActiveSpecs,
|
|
146
167
|
findAllSpecCandidates,
|
|
147
168
|
resolveActiveSpec,
|
|
@@ -49,8 +49,12 @@ try {
|
|
|
49
49
|
throw new Error('shared workflow policy lacks completion authority functions');
|
|
50
50
|
}
|
|
51
51
|
const resolver = require('./lib/spec-utils.cjs');
|
|
52
|
+
// The payload is still the authority. The recorded target only answers the ambiguity
|
|
53
|
+
// the payload leaves, which is what made the block unescapable on this runtime too.
|
|
54
|
+
const target = resolver.extractExplicitTarget(payload)
|
|
55
|
+
|| resolver.readActiveFeatureTarget({ projectRoot, runtime });
|
|
52
56
|
let resolved = typeof resolver.resolveWorkflowCandidate === 'function'
|
|
53
|
-
? resolver.resolveWorkflowCandidate({ projectRoot, runtime, target
|
|
57
|
+
? resolver.resolveWorkflowCandidate({ projectRoot, runtime, target, includeCompleted: true })
|
|
54
58
|
: FINAL_STATE.resolveCandidate({ resolver, projectRoot, runtime, payload });
|
|
55
59
|
if (typeof resolver.refineWorkflowGateResolution === 'function') {
|
|
56
60
|
resolved = resolver.refineWorkflowGateResolution(resolved);
|
|
@@ -156,7 +156,7 @@ try {
|
|
|
156
156
|
}
|
|
157
157
|
lines.push(
|
|
158
158
|
'- Sync each flat task `Status:` only after verified work; task proof belongs in that task\'s inline `## Receipt`.',
|
|
159
|
-
`- Workflow source: \`specs/${active.featureName}/plan.md\`; Stop re-checks every done receipt, binding Base/Head to the runtime until the task file is committed
|
|
159
|
+
`- Workflow source: \`specs/${active.featureName}/plan.md\`; Stop re-checks every done receipt, binding Base/Head to the runtime until the task file is committed and unchanged.`,
|
|
160
160
|
'- Hooks revalidate receipt bytes but never grant approval.'
|
|
161
161
|
);
|
|
162
162
|
} else {
|
|
@@ -33,8 +33,11 @@ Set `Status: done` only after the inline Receipt passes the
|
|
|
33
33
|
fenced block of current command output.
|
|
34
34
|
|
|
35
35
|
A done task keeps that binding while its file differs from its committed bytes,
|
|
36
|
-
and is validated on structure alone once
|
|
37
|
-
|
|
36
|
+
and is validated on structure alone once that file is committed and unchanged. Work
|
|
37
|
+
elsewhere in the tree does not reopen it: Base is the last commit touching anything
|
|
38
|
+
outside the specs root, so every finished receipt records an older Base by
|
|
39
|
+
construction and reopening on a dirty tree failed all of them at once.
|
|
40
|
+
The gate detects drift, not invention: a valid
|
|
38
41
|
Base and Head pair costs one command and no verification run, so neither mode proves
|
|
39
42
|
that the command was executed. Missing, stale, contradictory,
|
|
40
43
|
placeholder, copied, failure, or zero-test evidence leaves the task unfinished.
|