@haposoft/cafekit 0.16.2 → 0.16.4

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 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
- [![Version](https://img.shields.io/badge/version-0.16.2-blue.svg)](https://github.com/haposoft/cafekit)
5
+ [![Version](https://img.shields.io/badge/version-0.16.4-blue.svg)](https://github.com/haposoft/cafekit)
6
6
  [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
7
  [![Claude%20Code](https://img.shields.io/badge/Claude%20Code-Native-orange.svg)](https://claude.ai/code)
8
8
  [![Codex%20CLI](https://img.shields.io/badge/Codex%20CLI-Native-111111.svg)](https://developers.openai.com/codex)
@@ -126,9 +126,12 @@ AGENTS.md
126
126
  CafeKit owns only its marked block in root `AGENTS.md`. Codex discovers skills
127
127
  and custom agents natively after the repository is trusted; no project
128
128
  `config.toml` is generated. Review and trust project hooks with `/hooks`.
129
- Installed hook commands are bound to the canonical project path on every platform,
130
- so they need no Git repository, stay correct for a project nested inside a larger
131
- repository, and remain stable when a session starts in a subdirectory.
129
+ Installed Codex hook commands are bound to the canonical project path, so they need no
130
+ Git repository, stay correct for a project nested inside a larger repository, and remain
131
+ stable when a session starts in a subdirectory. They also carry a PATH floor pointing at
132
+ the Node.js that installed them, so a host launched from the macOS GUI — whose children
133
+ inherit only `/usr/bin:/bin:/usr/sbin:/sbin` — can still find the interpreter. The floor
134
+ is appended, so a version manager's current Node still wins in a normal terminal.
132
135
  CafeKit uses Codex's native status and usage UI instead of installing the
133
136
  Claude statusline.
134
137
 
@@ -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');
@@ -2035,10 +2035,13 @@ test('Codex Windows hook launchers stay project-bound without Git from nested cw
2035
2035
  });
2036
2036
  });
2037
2037
 
2038
- // Run an installed POSIX launcher with no PATH at all, so neither `git` nor a `node` on
2039
- // PATH can rescue it. The launcher must already carry everything it needs.
2040
- function runPosixLauncher(command, cwd) {
2041
- return spawnSync(command.replace(/^node /, `"${process.execPath}" `), {
2038
+ // Run an installed POSIX launcher verbatim under the environment a macOS GUI host hands
2039
+ // its children: no `git`, and no `node` on PATH. Nothing here rewrites the command, so a
2040
+ // launcher that cannot find its own interpreter fails the way it fails in the wild.
2041
+ const GUI_PATH = '/usr/bin:/bin:/usr/sbin:/sbin';
2042
+
2043
+ function runPosixLauncher(command, cwd, pathValue = GUI_PATH) {
2044
+ return spawnSync(command, {
2042
2045
  cwd,
2043
2046
  encoding: 'utf8',
2044
2047
  input: JSON.stringify({
@@ -2047,7 +2050,7 @@ function runPosixLauncher(command, cwd) {
2047
2050
  hook_event_name: 'SessionStart',
2048
2051
  source: 'startup'
2049
2052
  }),
2050
- env: { ...process.env, PATH: '' },
2053
+ env: { PATH: pathValue, HOME: os.homedir() },
2051
2054
  shell: true
2052
2055
  });
2053
2056
  }
@@ -2076,13 +2079,13 @@ test('Codex POSIX hook launchers run without Git and outside the repository root
2076
2079
  for (const projectRoot of [plain, nested]) {
2077
2080
  const { config, command } = sessionLauncher(projectRoot);
2078
2081
  for (const { handler } of allHookLaunchers(config)) {
2079
- // `node '<single-quoted absolute path>'` and nothing else. Inside single quotes
2080
- // the shell expands nothing, so a project path may safely contain $, a backtick,
2081
- // or the word git — this fixture's own path contains it.
2082
+ // A PATH floor, then `node`, then one single-quoted absolute path. Inside single
2083
+ // quotes the shell expands nothing, so a project path may safely contain $, a
2084
+ // backtick, or the word git — this fixture's own path contains it.
2082
2085
  assert.match(
2083
2086
  handler.command,
2084
- /^node '(?:[^']|'\\'')+'$/,
2085
- `launcher must be a plain quoted path: ${handler.command}`
2087
+ /^PATH="\$PATH"(?::'(?:[^']|'\\'')+')+ node '(?:[^']|'\\'')+'$/,
2088
+ `launcher must carry a PATH floor and a quoted path: ${handler.command}`
2086
2089
  );
2087
2090
  assert.doesNotMatch(handler.command, /rev-parse/, `launcher must not consult Git: ${handler.command}`);
2088
2091
  assert.ok(
@@ -2097,38 +2100,49 @@ test('Codex POSIX hook launchers run without Git and outside the repository root
2097
2100
  });
2098
2101
  });
2099
2102
 
2100
- test('a reinstall repairs Codex launchers that resolved their path through Git', () => {
2101
- inTempProject((root) => {
2102
- assert.equal(install(root).status, 0);
2103
- const configPath = path.join(root, '.codex', 'hooks.json');
2104
-
2105
- // Exactly what an installer before 0.16.2 wrote.
2106
- const downgraded = JSON.parse(fs.readFileSync(configPath, 'utf8'));
2107
- for (const { handler } of allHookLaunchers(downgraded)) {
2108
- const script = path.basename(handler.command.replace(/'$/, ''));
2109
- handler.command = `node "$(git rev-parse --show-toplevel)/.codex/hooks/${script}"`;
2110
- }
2111
- const foreign = { type: 'command', command: 'node "$(git rev-parse --show-toplevel)/tools/mine.cjs"' };
2112
- downgraded.hooks.SessionStart[0].hooks.push(foreign);
2113
- fs.writeFileSync(configPath, `${JSON.stringify(downgraded, null, 2)}\n`);
2114
-
2115
- const again = install(root);
2116
- assert.equal(again.status, 0, again.stderr);
2117
- assert.match(again.stdout, /repaired \d+ launcher/, 'the repair must be reported, not silent');
2103
+ // A reinstall treats an already-registered script as the user's placement, so a launcher
2104
+ // an older installer wrote would otherwise keep its broken command forever.
2105
+ for (const [label, legacyCommand] of [
2106
+ ['resolved their path through Git', (script) => `node "$(git rev-parse --show-toplevel)/.codex/hooks/${script}"`],
2107
+ ['looked for node on PATH', (script, root) => `node '${path.join(fs.realpathSync(root), '.codex', 'hooks', script)}'`],
2108
+ ]) {
2109
+ test(`a reinstall rebinds Codex launchers that ${label}`, () => {
2110
+ inTempProject((root) => {
2111
+ assert.equal(install(root).status, 0);
2112
+ const configPath = path.join(root, '.codex', 'hooks.json');
2113
+
2114
+ const downgraded = JSON.parse(fs.readFileSync(configPath, 'utf8'));
2115
+ for (const { handler } of allHookLaunchers(downgraded)) {
2116
+ handler.command = legacyCommand(path.basename(handler.command.replace(/'$/, '')), root);
2117
+ }
2118
+ // A hook the user wrote, in the same shape CafeKit used to emit, must never be
2119
+ // rewritten: it points somewhere else entirely.
2120
+ const foreign = { type: 'command', command: 'node "$(git rev-parse --show-toplevel)/tools/mine.cjs"' };
2121
+ downgraded.hooks.SessionStart[0].hooks.push(foreign);
2122
+ fs.writeFileSync(configPath, `${JSON.stringify(downgraded, null, 2)}\n`);
2123
+
2124
+ const again = install(root);
2125
+ assert.equal(again.status, 0, again.stderr);
2126
+ assert.match(again.stdout, /rebound \d+ launcher/, 'the rebind must be reported, not silent');
2127
+
2128
+ const { config, command } = sessionLauncher(root);
2129
+ const commands = allHookLaunchers(config).map(({ handler }) => handler.command);
2130
+ assert.ok(commands.includes(foreign.command), 'a foreign hook must survive untouched');
2131
+ assert.equal(
2132
+ commands.filter((entry) => !entry.startsWith('PATH=')).length,
2133
+ 1,
2134
+ 'every CafeKit launcher must be rebound, leaving only the foreign one'
2135
+ );
2136
+ const launched = runPosixLauncher(command, root);
2137
+ assert.equal(launched.status, 0, launched.stderr);
2138
+ assert.match(launched.stdout, /Session startup\./);
2118
2139
 
2119
- const { config, command } = sessionLauncher(root);
2120
- const commands = allHookLaunchers(config).map(({ handler }) => handler.command);
2121
- assert.equal(
2122
- commands.filter((entry) => /rev-parse/.test(entry)).length,
2123
- 1,
2124
- 'only the hook CafeKit did not author may keep its own command'
2125
- );
2126
- assert.ok(commands.includes(foreign.command), 'a foreign hook must survive untouched');
2127
- const launched = runPosixLauncher(command, root);
2128
- assert.equal(launched.status, 0, launched.stderr);
2129
- assert.match(launched.stdout, /Session startup\./);
2140
+ // Running the installer again must change nothing.
2141
+ const third = install(root);
2142
+ assert.doesNotMatch(third.stdout, /rebound \d+ launcher/, 'rebinding must be idempotent');
2143
+ });
2130
2144
  });
2131
- });
2145
+ }
2132
2146
 
2133
2147
  test('Codex installed Specs and spec-maker reject adaptive coverage mutations', () => {
2134
2148
  inTempProject((root) => {
@@ -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 result = spawnSync(process.execPath, ['-e', code, paths.completion, paths.resolver, root, platform, explicitFeature], { cwd: root, encoding: 'utf8' });
2175
- assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
2176
- const resolution = JSON.parse(result.stdout);
2177
- assert.equal(resolution.explicit, explicitFeature);
2178
- assert.equal(resolution.error, 'multiple_persisted');
2179
- assert.ok(resolution.candidates.length >= 2);
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
+ }
@@ -17,8 +17,22 @@ const { PLATFORMS } = require('../lib/context');
17
17
 
18
18
  const CODEX_SRC = path.join(__dirname, '../../src/codex');
19
19
  const HOOK_COMMAND_TEMPLATE = /^node "\.codex\/hooks\/([a-z0-9-]+\.cjs)"$/;
20
- // What an installer before 0.16.2 wrote for POSIX. Kept only to recognize and repair it.
21
- const LEGACY_GIT_COMMAND = /^node "\$\(git rev-parse --show-toplevel\)\/\.codex\/hooks\/([a-z0-9-]+\.cjs)"$/;
20
+ // Exact shapes earlier installers wrote, kept only to recognize and upgrade them. Before
21
+ // 0.16.2 the path came from Git; 0.16.2 fixed the path but still resolved `node` through
22
+ // PATH. Matching the whole command keeps a hook the user wrote from ever being rewritten.
23
+ const LEGACY_POSIX_COMMANDS = [
24
+ /^node "\$\(git rev-parse --show-toplevel\)\/\.codex\/hooks\/([a-z0-9-]+\.cjs)"$/,
25
+ /^node '(?:[^']|'\\'')*[/\\]hooks[/\\]([a-z0-9-]+\.cjs)'$/,
26
+ ];
27
+
28
+ function legacyPosixScript(command) {
29
+ if (typeof command !== 'string') return null;
30
+ for (const pattern of LEGACY_POSIX_COMMANDS) {
31
+ const match = command.match(pattern);
32
+ if (match) return match[1];
33
+ }
34
+ return null;
35
+ }
22
36
 
23
37
  /** The hook script a command runs, which is stable across installs and platforms. */
24
38
  function hookScript(command) {
@@ -46,8 +60,26 @@ function hookPathFor(script, projectRoot) {
46
60
  * directly. The literal `/hooks/<script>.cjs` segment must survive, because hookScript()
47
61
  * reads it as the identity that lets a reinstall recognize CafeKit's own entries.
48
62
  */
63
+ /**
64
+ * Directories to fall back on when PATH carries no `node`.
65
+ *
66
+ * `process.execPath` is realpath-resolved, so under Homebrew or a version manager it names
67
+ * a versioned directory that disappears on the next node upgrade. The stable aliases are
68
+ * listed after it, and a directory that does not exist costs nothing in PATH.
69
+ */
70
+ function nodeFallbackDirectories() {
71
+ const candidates = [path.dirname(process.execPath), '/usr/local/bin', '/opt/homebrew/bin'];
72
+ return [...new Set(candidates.filter(Boolean))];
73
+ }
74
+
49
75
  function posixHookCommand(script, projectRoot) {
50
- return `node ${shellQuote(hookPathFor(script, projectRoot))}`;
76
+ // `node` is resolved through PATH, and a host launched from the macOS GUI hands its
77
+ // children the minimal `/usr/bin:/bin:/usr/sbin:/sbin`. Node installed by Homebrew, nvm,
78
+ // fnm, or Volta is not on that list, so the shell answered 127 and the hook never
79
+ // started. The fallbacks are appended, never prepended, so a version manager's current
80
+ // node still wins in a normal terminal and these act only as the floor.
81
+ const fallbacks = nodeFallbackDirectories().map((dir) => `:${shellQuote(dir)}`).join('');
82
+ return `PATH="$PATH"${fallbacks} node ${shellQuote(hookPathFor(script, projectRoot))}`;
51
83
  }
52
84
 
53
85
  /**
@@ -88,12 +120,12 @@ function repairLegacyCodexLaunchers(config, projectRoot) {
88
120
  return {
89
121
  ...group,
90
122
  hooks: group.hooks.map((handler) => {
91
- const legacy = typeof handler?.command === 'string' && handler.command.match(LEGACY_GIT_COMMAND);
123
+ const legacy = legacyPosixScript(handler?.command);
92
124
  const template = typeof handler?.commandWindows === 'string'
93
125
  && handler.commandWindows.match(HOOK_COMMAND_TEMPLATE);
94
126
  if (!legacy && !template) return handler;
95
127
  const next = { ...handler };
96
- if (legacy) next.command = posixHookCommand(legacy[1], projectRoot);
128
+ if (legacy) next.command = posixHookCommand(legacy, projectRoot);
97
129
  if (template) next.commandWindows = windowsHookCommand(template[1], projectRoot);
98
130
  repaired += 1;
99
131
  return next;
@@ -166,7 +198,7 @@ function mergeCodexHooks(ctx, platformKey, projectRoot = process.cwd()) {
166
198
 
167
199
  const { config: base, repaired } = repairLegacyCodexLaunchers(pruned, projectRoot);
168
200
  if (repaired > 0) {
169
- ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Codex hooks: repaired ${repaired} launcher(s) that resolved their path through Git`);
201
+ ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Codex hooks: rebound ${repaired} launcher(s) written by an older installer`);
170
202
  ctx.results.updated++;
171
203
  }
172
204
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haposoft/cafekit",
3
- "version": "0.16.2",
3
+ "version": "0.16.4",
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
- assert.match(block(stop(runtime)).reason, /multiple_persisted|multiple/i);
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 },
@@ -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
- emitBlock(`Completion authority: technical proofs pass, but user closeout approval is required. Enter exactly "${STATE.PREFIX}${pending.nonce}" as your next user prompt. This approval is one-time and expires in five minutes.`);
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 });
@@ -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 === 'done'),
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: candidates.map((candidate) => candidate.featureName),
718
- reason: `Multiple persisted specs found: ${candidates.map((candidate) => candidate.featureName).join(', ')}. Provide explicit feature.`,
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 || candidates.some((candidate) => candidate.layoutKind !== 'process-v3')) {
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,
@@ -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
- emitBlock(`Completion authority: technical proofs pass, but user closeout approval is required. Enter exactly "${STATE.PREFIX}${pending.nonce}" as your next user prompt. This approval is one-time and expires in five minutes.`);
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: payload, includeCompleted: true })
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);