@smartmemory/compose 0.2.3-beta → 0.2.5-beta

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.
@@ -311,7 +311,11 @@ export async function setFeatureStatus(cwd, args) {
311
311
  }
312
312
 
313
313
  const allowed = TRANSITIONS[from] ?? [];
314
- if (!allowed.includes(to) && !args.force) {
314
+ // `derived: true` marks a lifecycle-authoritative projection (COMP-MCP-ENFORCE
315
+ // Slice 2, lifecycle-as-truth): the roadmap transition table is not the
316
+ // authority for lifecycle-driven status, so the table check is skipped — but
317
+ // the roundtrip fixed-point guard below still applies (this is NOT `force`).
318
+ if (!allowed.includes(to) && !args.force && !args.derived) {
315
319
  throw new Error(
316
320
  `feature-writer: invalid transition for ${args.code}: ${from} → ${to}. ` +
317
321
  `Allowed from ${from}: [${allowed.join(', ') || 'none'}]. ` +
@@ -353,6 +357,7 @@ export async function setFeatureStatus(cwd, args) {
353
357
  if (args.reason) event.reason = args.reason;
354
358
  if (args.commit_sha) event.commit_sha = args.commit_sha;
355
359
  if (args.force && !allowed.includes(to)) event.forced = true;
360
+ if (args.derived && !allowed.includes(to)) event.derived = true;
356
361
  await safeAppendEvent(cwd, event);
357
362
 
358
363
  return { code: args.code, from, to, ts: new Date().toISOString(), roundtrip };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartmemory/compose",
3
- "version": "0.2.3-beta",
3
+ "version": "0.2.5-beta",
4
4
  "description": "Structured AI dev pipeline — goal-to-product orchestration with gates, iteration loops, and feature lifecycle management.",
5
5
  "author": "SmartMemory",
6
6
  "license": "MIT",
@@ -9,7 +9,108 @@ import fs from 'node:fs';
9
9
  import http from 'node:http';
10
10
  import path from 'node:path';
11
11
  import { ArtifactManager, ARTIFACT_SCHEMAS } from './artifact-manager.js';
12
- import { getTargetRoot, getDataDir, resolveProjectPath, switchProject, setCurrentWorkspaceId } from './project-root.js';
12
+ import { getTargetRoot, getDataDir, resolveProjectPath, switchProject, setCurrentWorkspaceId, loadProjectConfig } from './project-root.js';
13
+
14
+ /**
15
+ * COMP-MCP-ENFORCE Slice 3 — kill the `force` escape hatch at the MCP tool
16
+ * boundary. When capabilities.guard is on, a caller-supplied force:true on a
17
+ * status/roadmap mutation is the bypass STRAT-GUARD exists to close, so it is
18
+ * rejected unless it carries a valid out-of-band override token (the agent
19
+ * cannot mint it). Guard off → legacy behavior (no-op). Internal callers
20
+ * (recordCompletion → setFeatureStatus directly) never pass through here.
21
+ *
22
+ * @param {object} args tool args (may carry force / override_token)
23
+ * @param {string} toolName for the error message
24
+ * @param {{guard?: boolean}} [capsOverride] test seam; otherwise read from config
25
+ */
26
+ /**
27
+ * COMP-MCP-ENFORCE Slice 3 — close the record_completion bypass. record_completion
28
+ * is a public MCP tool that flips status to COMPLETE; under capabilities.guard it
29
+ * must satisfy the SAME evidence as /lifecycle/complete (real commit + attested
30
+ * tests), else a rogue client could complete a feature without a guard verdict or
31
+ * evidence. Guard off → legacy behavior (no-op).
32
+ *
33
+ * @param {object} args record_completion args (commit_sha, tests_pass)
34
+ * @param {{guard?: boolean}} [capsOverride] test seam
35
+ * @param {string} [cwd]
36
+ */
37
+ export async function assertCompletionEvidence(args, capsOverride, cwd = getTargetRoot()) {
38
+ let guardOn;
39
+ if (capsOverride && typeof capsOverride.guard === 'boolean') {
40
+ guardOn = capsOverride.guard;
41
+ } else {
42
+ try { guardOn = loadProjectConfig()?.capabilities?.guard === true; } catch { guardOn = false; }
43
+ }
44
+ if (!guardOn) return;
45
+ const { verifyCompletionEvidence, guardTestCommand } = await import('./lifecycle-guard.js');
46
+ const ev = await verifyCompletionEvidence({
47
+ commitSha: args?.commit_sha,
48
+ cwd,
49
+ testCommand: guardTestCommand(cwd),
50
+ testsPassClaim: args?.tests_pass,
51
+ });
52
+ if (!ev.ok) {
53
+ const e = new Error(
54
+ `record_completion: completion evidence not satisfied under capabilities.guard: ${ev.reasons.join('; ')}`,
55
+ );
56
+ e.code = 'COMPLETION_EVIDENCE_REQUIRED';
57
+ throw e;
58
+ }
59
+ }
60
+
61
+ /** Terminal statuses owned by the lifecycle (projected from phase, Slice 2). */
62
+ const LIFECYCLE_OWNED_STATUS = new Set(['COMPLETE', 'KILLED']);
63
+
64
+ function _guardOn(capsOverride) {
65
+ if (capsOverride && typeof capsOverride.guard === 'boolean') return capsOverride.guard;
66
+ try { return loadProjectConfig()?.capabilities?.guard === true; } catch { return false; }
67
+ }
68
+
69
+ /** True iff a valid, non-agent-mintable override token accompanies the call. */
70
+ function _overrideOk(args) {
71
+ const expected = process.env.STRATUM_GUARD_OVERRIDE_TOKEN;
72
+ return !!expected && args?.override_token === expected;
73
+ }
74
+
75
+ export function assertForceAuthorized(args, toolName, capsOverride) {
76
+ if (!args?.force) return;
77
+ if (!_guardOn(capsOverride)) return;
78
+ if (!_overrideOk(args)) {
79
+ const e = new Error(
80
+ `${toolName}: force is disabled under capabilities.guard — supply a valid override_token ` +
81
+ `(out-of-band STRATUM_GUARD_OVERRIDE_TOKEN; not agent-mintable) to deviate, or drive the ` +
82
+ `change through the lifecycle.`,
83
+ );
84
+ e.code = 'FORCE_REQUIRES_OVERRIDE';
85
+ throw e;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * COMP-MCP-ENFORCE Slice 3 — terminal statuses (COMPLETE/KILLED) are owned by the
91
+ * lifecycle (Slice 2: status is a projection of phase). Under capabilities.guard,
92
+ * a public MCP caller cannot set/mint them directly — that would bypass the
93
+ * evidence-gated /lifecycle/complete and the guarded /lifecycle/kill. The single
94
+ * authorized escape is an out-of-band override token. Guard off → legacy.
95
+ *
96
+ * @param {object} args carries `status` (set_feature_status / add_roadmap_entry)
97
+ * @param {string} toolName
98
+ * @param {{guard?: boolean}} [capsOverride] test seam
99
+ */
100
+ export function assertTerminalStatusAuthorized(args, toolName, capsOverride) {
101
+ const status = args?.status;
102
+ if (!status || !LIFECYCLE_OWNED_STATUS.has(status)) return;
103
+ if (!_guardOn(capsOverride)) return;
104
+ if (!_overrideOk(args)) {
105
+ const e = new Error(
106
+ `${toolName}: status ${status} is lifecycle-owned under capabilities.guard — drive it through ` +
107
+ `/lifecycle (evidence-gated for complete, guarded for kill) instead of setting it directly, ` +
108
+ `or supply a valid override_token.`,
109
+ );
110
+ e.code = 'STATUS_OWNED_BY_LIFECYCLE';
111
+ throw e;
112
+ }
113
+ }
13
114
  import { resolveWorkspace } from '../lib/resolve-workspace.js';
14
115
  import { discoverWorkspaces } from '../lib/discover-workspaces.js';
15
116
 
@@ -195,11 +296,15 @@ export async function toolGetCurrentSession({ featureCode } = {}) {
195
296
  // ---------------------------------------------------------------------------
196
297
 
197
298
  export async function toolAddRoadmapEntry(args) {
299
+ assertForceAuthorized(args, 'add_roadmap_entry');
300
+ assertTerminalStatusAuthorized(args, 'add_roadmap_entry');
198
301
  const { addRoadmapEntry } = await import('../lib/feature-writer.js');
199
302
  return addRoadmapEntry(getTargetRoot(), args);
200
303
  }
201
304
 
202
305
  export async function toolSetFeatureStatus(args) {
306
+ assertForceAuthorized(args, 'set_feature_status');
307
+ assertTerminalStatusAuthorized(args, 'set_feature_status');
203
308
  const { setFeatureStatus } = await import('../lib/feature-writer.js');
204
309
  return setFeatureStatus(getTargetRoot(), args);
205
310
  }
@@ -234,6 +339,9 @@ export async function toolGetFeatureLinks(args) {
234
339
  // ---------------------------------------------------------------------------
235
340
 
236
341
  export async function toolProposeFollowup(args) {
342
+ // propose_followup also accepts a caller-supplied `status` and routes to
343
+ // addRoadmapEntry — gate lifecycle-owned terminal statuses the same way.
344
+ assertTerminalStatusAuthorized(args, 'propose_followup');
237
345
  const { proposeFollowup } = await import('../lib/followup-writer.js');
238
346
  return proposeFollowup(getTargetRoot(), args);
239
347
  }
@@ -301,6 +409,7 @@ export async function toolGetJournalEntries(args) {
301
409
  // ---------------------------------------------------------------------------
302
410
 
303
411
  export async function toolRecordCompletion(args) {
412
+ await assertCompletionEvidence(args);
304
413
  const { recordCompletion } = await import('../lib/completion-writer.js');
305
414
  return recordCompletion(getTargetRoot(), args);
306
415
  }
@@ -0,0 +1,337 @@
1
+ // server/lifecycle-guard.js
2
+ //
3
+ // COMP-MCP-ENFORCE Slice 1 — compose-owned STRAT-GUARD policy.
4
+ //
5
+ // Compose owns the phase semantics (the graph + which evidence each edge
6
+ // requires); stratum's STRAT-GUARD primitive enforces them. This module:
7
+ // - declares the canonical lifecycle phase graph as DATA (single source of
8
+ // truth, imported by vision-routes.js for its own legality check),
9
+ // - binds per-edge evidence predicates to server-read artifacts,
10
+ // - lazily + idempotently registers each feature as a guarded resource,
11
+ // - drives guarded transitions, fail-closed.
12
+ //
13
+ // See docs/features/COMP-MCP-ENFORCE/{design,blueprint,plan}.md.
14
+
15
+ import { createHash } from 'node:crypto';
16
+ import { readFileSync } from 'node:fs';
17
+ import { spawnSync } from 'node:child_process';
18
+ import path from 'node:path';
19
+
20
+ import {
21
+ guardRegister as _guardRegister,
22
+ guardTransition as _guardTransition,
23
+ } from './stratum-client.js';
24
+ import { setFeatureStatus as _setFeatureStatus } from '../lib/feature-writer.js';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Canonical phase graph (compose-owned data — single source of truth)
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** Forward transitions between non-terminal lifecycle phases. */
31
+ export const BASE_TRANSITIONS = {
32
+ explore_design: ['prd', 'architecture', 'blueprint'],
33
+ prd: ['architecture', 'blueprint'],
34
+ architecture: ['blueprint'],
35
+ blueprint: ['verification'],
36
+ verification: ['plan', 'blueprint'],
37
+ plan: ['execute'],
38
+ execute: ['report', 'docs'],
39
+ report: ['docs'],
40
+ docs: ['ship'],
41
+ ship: [],
42
+ };
43
+
44
+ /** Phases whose forward edge may be skipped (not killed). */
45
+ export const SKIPPABLE = new Set(['prd', 'architecture', 'report']);
46
+
47
+ /** Terminal phases — no outgoing edges. */
48
+ export const TERMINAL = new Set(['complete', 'killed']);
49
+
50
+ /**
51
+ * Assemble the FULL guarded graph the design requires: the forward
52
+ * `BASE_TRANSITIONS` PLUS the `ship → complete` edge and a `<any non-terminal>
53
+ * → killed` edge from every reachable non-terminal phase. The guard graph must
54
+ * be a superset of every edge vision-routes will legally request, or the guard
55
+ * would reject a transition the app considers valid.
56
+ */
57
+ export function buildPhaseGraph(transitions = BASE_TRANSITIONS) {
58
+ const graph = {};
59
+ const nodes = new Set();
60
+ for (const [from, tos] of Object.entries(transitions)) {
61
+ graph[from] = [...tos];
62
+ nodes.add(from);
63
+ for (const t of tos) nodes.add(t);
64
+ }
65
+ // ship → complete (the highest-consequence edge; implemented separately in
66
+ // vision-routes at /lifecycle/complete, so absent from BASE_TRANSITIONS).
67
+ graph.ship = [...(graph.ship || []), 'complete'];
68
+ // Every non-terminal phase → killed (vision-routes /lifecycle/kill allows
69
+ // kill from any non-terminal phase, including ship).
70
+ for (const s of nodes) {
71
+ if (TERMINAL.has(s)) continue;
72
+ graph[s] = graph[s] || [];
73
+ if (!graph[s].includes('killed')) graph[s].push('killed');
74
+ }
75
+ graph.complete = [];
76
+ graph.killed = [];
77
+ return graph;
78
+ }
79
+
80
+ /**
81
+ * Per-edge `deterministic` (trusted, server-read) evidence predicates. Paths are
82
+ * RELATIVE to the guard's workspace_root and derived from the configured feature
83
+ * directory (never hardcoded `docs/features`). Edges not listed here carry no
84
+ * predicate — they still get graph-legality + per-resource serialization +
85
+ * tamper-evident ledgering. Evidence-bound `ship → complete` is Slice 3.
86
+ *
87
+ * @param {string} featureRelDir e.g. "docs/features/FEAT-1"
88
+ */
89
+ export function edgePredicates(featureRelDir) {
90
+ const det = (id, file) => ({
91
+ id,
92
+ type: 'deterministic',
93
+ statement: `server_file_exists('${featureRelDir}/${file}')`,
94
+ });
95
+ return {
96
+ 'explore_design->blueprint': [det('design_md', 'design.md')],
97
+ 'blueprint->verification': [det('blueprint_md', 'blueprint.md')],
98
+ 'plan->execute': [det('plan_md', 'plan.md')],
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Project-scoped, opaque resource id. STRAT-GUARD state is stored globally keyed
104
+ * only by resource_id and workspace_root is NOT part of the checksum, so a bare
105
+ * `compose:<FC>` would let two compose projects sharing a feature code collide on
106
+ * one ledger/current-state. The project-path hash prevents that.
107
+ */
108
+ export function resourceId(featureCode, workspaceRoot) {
109
+ const hash = createHash('sha256').update(path.resolve(workspaceRoot)).digest('hex').slice(0, 12);
110
+ return `compose:${hash}:${featureCode}`;
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Lifecycle-as-truth: roadmap STATUS is a projection of lifecycle phase (Slice 2)
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * Project a lifecycle phase onto the roadmap STATUS enum. The lifecycle is the
119
+ * source of truth; STATUS is derived. `complete`/`killed` are terminal; every
120
+ * active phase (explore_design…ship) is IN_PROGRESS. PLANNED is the PRE-lifecycle
121
+ * state (no projection needed); BLOCKED/PARKED/PARTIAL/SUPERSEDED have no phase
122
+ * and stay set_feature_status's domain.
123
+ */
124
+ export function phaseToStatus(phase) {
125
+ if (phase === 'complete') return 'COMPLETE';
126
+ if (phase === 'killed') return 'KILLED';
127
+ return 'IN_PROGRESS';
128
+ }
129
+
130
+ let _statusWriter = _setFeatureStatus;
131
+ /** @internal test seam */
132
+ export function _testOnly_setStatusWriter(fn) { _statusWriter = fn; }
133
+ /** @internal test seam */
134
+ export function _testOnly_resetStatusWriter() { _statusWriter = _setFeatureStatus; }
135
+
136
+ /**
137
+ * Write the phase-projected STATUS through to feature.json (closes the
138
+ * COMP-PARITY-7 one-way-sync gap for the lifecycle-driven path). Best-effort:
139
+ * a missing feature or a writer error is captured and returned, never thrown —
140
+ * status projection must not roll back a lifecycle transition that already
141
+ * applied. setFeatureStatus is itself idempotent (from===to → noop), so calling
142
+ * it on every transition only writes on a real status change.
143
+ */
144
+ export async function projectFeatureStatus({ featureCode, phase, cwd, commitSha }) {
145
+ if (!featureCode) return { skipped: true };
146
+ const status = phaseToStatus(phase);
147
+ try {
148
+ // derived:true — the lifecycle is authoritative, so the roadmap transition
149
+ // table does not gate this projection (e.g. PARKED→IN_PROGRESS on resume).
150
+ const args = { code: featureCode, status, reason: `lifecycle:${phase}`, derived: true };
151
+ if (commitSha) args.commit_sha = commitSha;
152
+ const result = await _statusWriter(cwd, args);
153
+ return { status, result };
154
+ } catch (e) {
155
+ return { status, error: e.message };
156
+ }
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Evidence-bound completion (Slice 3)
161
+ // ---------------------------------------------------------------------------
162
+
163
+ /** True if `sha` resolves to a real commit object in the repo at `cwd`. */
164
+ function gitCommitExists(sha, cwd) {
165
+ const r = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`],
166
+ { cwd, encoding: 'utf8' });
167
+ return r.status === 0;
168
+ }
169
+
170
+ /**
171
+ * Verify the evidence required to complete a feature under the guard — the
172
+ * substrate confirms it, not a caller boolean (the design's "trusted evidence"
173
+ * principle, evaluated compose-side because compose owns the repo + test runner):
174
+ * - `commit_sha` must exist as a real git commit (server-read, not syntax).
175
+ * - tests must be ATTESTED: a configured `testCommand` exits 0 (real exit code),
176
+ * OR `testsPassClaim` is explicitly true. There is NO silent default-to-true.
177
+ *
178
+ * @returns {Promise<{ok:boolean, reasons:string[], testsAttested:boolean}>}
179
+ */
180
+ export async function verifyCompletionEvidence({ commitSha, cwd, testCommand, testsPassClaim }) {
181
+ const reasons = [];
182
+
183
+ if (!commitSha || typeof commitSha !== 'string' || !commitSha.trim()) {
184
+ reasons.push('commit_sha is required for evidence-bound completion');
185
+ } else if (!gitCommitExists(commitSha.trim(), cwd)) {
186
+ reasons.push(`commit ${commitSha.trim()} not found in repository (server-read git verification)`);
187
+ }
188
+
189
+ let testsAttested = false;
190
+ if (Array.isArray(testCommand) && testCommand.length > 0) {
191
+ const [bin, ...rest] = testCommand;
192
+ const r = spawnSync(bin, rest, { cwd, encoding: 'utf8' });
193
+ if (r.error) {
194
+ reasons.push(`test command failed to run: ${r.error.message}`);
195
+ } else if (r.status !== 0) {
196
+ reasons.push(`test command exited ${r.status} (not 0)`);
197
+ } else {
198
+ testsAttested = true;
199
+ }
200
+ } else if (testsPassClaim !== true) {
201
+ reasons.push('tests_pass must be explicitly true (no configured test command to attest test results)');
202
+ }
203
+
204
+ return { ok: reasons.length === 0, reasons, testsAttested };
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Guard client (injectable for tests)
209
+ // ---------------------------------------------------------------------------
210
+
211
+ let _client = { register: _guardRegister, transition: _guardTransition };
212
+ /** @internal test seam */
213
+ export function _testOnly_setGuardClient(c) { _client = c; }
214
+
215
+ // Per-process registration cache — register is idempotent server-side, but the
216
+ // cache avoids a subprocess per request once a resource is known-registered.
217
+ const _registered = new Set();
218
+ /** @internal test seam */
219
+ export function _testOnly_resetGuardCache() { _registered.clear(); }
220
+
221
+ /**
222
+ * Resolve the feature directory RELATIVE to the served `workspaceRoot` — read
223
+ * from `<workspaceRoot>/.compose/compose.json` (NOT the process-global config,
224
+ * which is pinned to getTargetRoot() and would drift for a non-current project
225
+ * root). The relative dir is baked into the immutable guard registration, so it
226
+ * must reflect the tree the routes actually serve.
227
+ */
228
+ function _featureRelDir(featureCode, workspaceRoot) {
229
+ let featuresRel = 'docs/features';
230
+ try {
231
+ const cfg = JSON.parse(readFileSync(path.join(workspaceRoot, '.compose', 'compose.json'), 'utf-8'));
232
+ featuresRel = cfg?.paths?.features || 'docs/features';
233
+ } catch { /* missing/invalid config → default */ }
234
+ return `${featuresRel}/${featureCode}`;
235
+ }
236
+
237
+ /**
238
+ * The configured evidence-bound-completion test command (array form, e.g.
239
+ * ["npm","test"]) from `<workspaceRoot>/.compose/compose.json` `guard.testCommand`,
240
+ * or null when unconfigured. When null, evidence-bound completion falls back to
241
+ * requiring an explicit tests_pass=true (still no silent default).
242
+ */
243
+ export function guardTestCommand(workspaceRoot) {
244
+ try {
245
+ const cfg = JSON.parse(readFileSync(path.join(workspaceRoot, '.compose', 'compose.json'), 'utf-8'));
246
+ const cmd = cfg?.guard?.testCommand;
247
+ return Array.isArray(cmd) && cmd.length > 0 ? cmd : null;
248
+ } catch {
249
+ return null;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Idempotently register the feature as a guarded resource, seeding `initial`
255
+ * from the item's CURRENT phase (so items already mid-lifecycle at rollout don't
256
+ * trip stale_from_state). Only the first registration ever seeds current_state;
257
+ * later calls (different `initial`) are no-ops because `initial` is not part of
258
+ * the policy checksum.
259
+ *
260
+ * @returns the register result, or {error} on guard failure.
261
+ */
262
+ export async function ensureGuard(featureCode, currentPhase, workspaceRoot) {
263
+ const rid = resourceId(featureCode, workspaceRoot);
264
+ if (_registered.has(rid)) return { guard_id: rid, status: 'cached' };
265
+
266
+ let res;
267
+ try {
268
+ res = await _client.register({
269
+ resourceId: rid,
270
+ graph: buildPhaseGraph(),
271
+ edgePredicates: edgePredicates(_featureRelDir(featureCode, workspaceRoot)),
272
+ initial: currentPhase,
273
+ terminal: ['complete', 'killed'],
274
+ stakes: {},
275
+ workspaceRoot,
276
+ });
277
+ } catch (e) {
278
+ // A thrown spawn failure (e.g. stratum-mcp not installed) must NOT escape as
279
+ // a generic 500/400 — normalise to a fail-closed error result.
280
+ return { error: { code: 'GUARD_UNREACHABLE', message: e.message } };
281
+ }
282
+ if (res && (res.status === 'registered' || res.status === 'exists')) {
283
+ _registered.add(rid);
284
+ }
285
+ return res;
286
+ }
287
+
288
+ /**
289
+ * Attempt a guarded lifecycle transition. Registers (idempotently) then
290
+ * transitions. FAIL-CLOSED: any guard error (unreachable, illegal edge, error
291
+ * dict) yields `{applied:false, error}` — the caller must NOT mutate state.
292
+ *
293
+ * @returns {Promise<{applied:boolean, refused?:boolean, verdict?:object,
294
+ * ledgerRef?:string, currentState?:string, error?:object}>}
295
+ */
296
+ export async function guardedTransition({ featureCode, from, to, workspaceRoot, commitSha, resolvedBy = 'agent' }) {
297
+ const reg = await ensureGuard(featureCode, from, workspaceRoot);
298
+ if (reg && (reg.error || reg.status === 'error')) {
299
+ return { applied: false, error: reg.error || reg };
300
+ }
301
+
302
+ const rid = resourceId(featureCode, workspaceRoot);
303
+ const artifacts = commitSha ? { commit_sha: commitSha } : {};
304
+ // No idempotency_key: a refuse→fix→retry is a NEW logical attempt that must
305
+ // re-evaluate evidence, but it carries an identical (from,to,artifacts)
306
+ // payload — an idempotency_key would make the guard replay the prior refusal.
307
+ // Double-apply is already prevented server-side: once applied, current_state
308
+ // advances and a duplicate call fails the from_state == current_state check.
309
+ let res;
310
+ try {
311
+ res = await _client.transition({
312
+ resourceId: rid,
313
+ fromState: from,
314
+ toState: to,
315
+ artifacts,
316
+ resolvedBy,
317
+ });
318
+ } catch (e) {
319
+ // Fail-closed on a thrown spawn failure (see ensureGuard).
320
+ return { applied: false, error: { code: 'GUARD_UNREACHABLE', message: e.message } };
321
+ }
322
+
323
+ if (!res || res.error || res.status === 'error') {
324
+ return { applied: false, error: (res && (res.error || res)) || { code: 'UNKNOWN', message: 'no guard response' } };
325
+ }
326
+ if (res.status === 'applied') {
327
+ return { applied: true, verdict: res.verdict, ledgerRef: res.ledger_ref, currentState: res.current_state };
328
+ }
329
+ // refused | replayed
330
+ return {
331
+ applied: res.status === 'replayed' ? true : false,
332
+ refused: res.status === 'refused',
333
+ verdict: res.verdict,
334
+ ledgerRef: res.ledger_ref,
335
+ currentState: res.current_state,
336
+ };
337
+ }
@@ -127,6 +127,78 @@ async function runMutation(args) {
127
127
  }
128
128
  }
129
129
 
130
+ /**
131
+ * Spawn stratum-mcp with args and pipe `inputJson` (a string) on stdin.
132
+ * Used by the STRAT-GUARD adapter, whose CLI reads one JSON kwargs object from
133
+ * stdin. Same resolve contract as spawnStratum.
134
+ *
135
+ * @param {string[]} args
136
+ * @param {string} inputJson
137
+ * @param {number} timeoutMs
138
+ * @returns {Promise<{ stdout: string, stderr: string, code: number }>}
139
+ */
140
+ function spawnStratumStdin(args, inputJson, timeoutMs) {
141
+ return new Promise((resolve, reject) => {
142
+ const proc = _execFile(STRATUM_BIN, args, { timeout: timeoutMs }, (err, out, err2) => {
143
+ const code = err?.code === 'ETIMEDOUT' ? -1
144
+ : (typeof err?.code === 'number' ? err.code : 0);
145
+ resolve({ stdout: out || '', stderr: err2 || '', code });
146
+ });
147
+
148
+ proc.on('error', (err) => {
149
+ if (err.code === 'ENOENT') {
150
+ reject(new Error(`stratum-mcp not found. Install with: pip install stratum-mcp`));
151
+ } else {
152
+ reject(err);
153
+ }
154
+ });
155
+
156
+ // Feed the JSON kwargs on stdin. The test mock supplies a fake stdin; a
157
+ // real child always has one. Guard so neither path throws.
158
+ if (proc.stdin) {
159
+ try {
160
+ proc.stdin.write(inputJson);
161
+ proc.stdin.end();
162
+ } catch { /* child already exited / stdin closed — execFile cb still fires */ }
163
+ }
164
+ });
165
+ }
166
+
167
+ /**
168
+ * Run a guard mutation: pipe `kwargs` as JSON on stdin, no retry (mutations are
169
+ * not safe to blindly retry). Maps exit codes like runMutation. A guard refusal
170
+ * is a NORMAL exit-0 result ({status:"refused"}), not an error.
171
+ *
172
+ * @returns {Promise<any>} parsed JSON result or { error }
173
+ */
174
+ async function runGuard(action, kwargs, timeoutMs = MUTATION_TIMEOUT_MS) {
175
+ const result = await spawnStratumStdin(['guard', action], JSON.stringify(kwargs), timeoutMs);
176
+
177
+ if (result.code === -1) {
178
+ return { error: { code: 'TIMEOUT', message: 'stratum-mcp guard timed out', detail: '' } };
179
+ }
180
+ if (result.code !== 0) {
181
+ console.error('[stratum-client] guard error stderr:', result.stderr);
182
+ try {
183
+ return JSON.parse(result.stdout); // canonical { status:"error", ... }
184
+ } catch {
185
+ return { error: { code: 'UNKNOWN', message: 'stratum-mcp guard failed', detail: '' } };
186
+ }
187
+ }
188
+ try {
189
+ return JSON.parse(result.stdout);
190
+ } catch {
191
+ return { error: { code: 'PARSE_ERROR', message: 'stratum-mcp returned invalid JSON', detail: '' } };
192
+ }
193
+ }
194
+
195
+ /** Strip undefined values so the JSON kwargs object stays minimal. */
196
+ function _compact(obj) {
197
+ const out = {};
198
+ for (const [k, v] of Object.entries(obj)) if (v !== undefined) out[k] = v;
199
+ return out;
200
+ }
201
+
130
202
  // ---------------------------------------------------------------------------
131
203
  // Public API
132
204
  // ---------------------------------------------------------------------------
@@ -190,3 +262,71 @@ export async function gateRevise(flowId, stepId, note = '', resolvedBy = 'human'
190
262
  if (resolvedBy !== 'human') args.push('--resolved-by', resolvedBy);
191
263
  return runMutation(args);
192
264
  }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // STRAT-GUARD adapter (COMP-MCP-ENFORCE Slice 1)
268
+ //
269
+ // Reaches stratum's guarded-transition primitive over the same CLI-subprocess
270
+ // seam. Each function translates camelCase params into the snake_case JSON
271
+ // kwargs the `stratum-mcp guard <action>` CLI forwards verbatim to the guard
272
+ // library, and pipes them on stdin.
273
+ // ---------------------------------------------------------------------------
274
+
275
+ /**
276
+ * Register (idempotently) a guarded resource. Re-registering an identical policy
277
+ * is a no-op ({status:"exists"}); a different policy is rejected (use migrate).
278
+ * @returns {Promise<{guard_id:string,checksum:string,status:string}|ErrorResult>}
279
+ */
280
+ export async function guardRegister({ resourceId, graph, edgePredicates, initial, terminal, stakes, workspaceRoot }) {
281
+ return runGuard('register', _compact({
282
+ resource_id: resourceId,
283
+ graph,
284
+ edge_predicates: edgePredicates,
285
+ initial,
286
+ terminal,
287
+ stakes,
288
+ workspace_root: workspaceRoot,
289
+ }));
290
+ }
291
+
292
+ /**
293
+ * Attempt a guarded transition. Applies only if the edge is legal and its
294
+ * predicates verify server-side. A refusal is a normal result (status:"refused").
295
+ * @returns {Promise<{status:string,verdict:object,ledger_ref:string,current_state:string}|ErrorResult>}
296
+ */
297
+ export async function guardTransition({ resourceId, fromState, toState, artifacts, modifiedFiles, idempotencyKey, resolvedBy }) {
298
+ return runGuard('transition', _compact({
299
+ resource_id: resourceId,
300
+ from_state: fromState,
301
+ to_state: toState,
302
+ artifacts,
303
+ modified_files: modifiedFiles,
304
+ idempotency_key: idempotencyKey,
305
+ resolved_by: resolvedBy,
306
+ }));
307
+ }
308
+
309
+ /**
310
+ * The single sanctioned bypass of predicate verification. Requires an
311
+ * out-of-band override token (server env STRATUM_GUARD_OVERRIDE_TOKEN), a human
312
+ * resolver, and a rationale. Records a 'deviation' ledger entry.
313
+ * @returns {Promise<{status:string,ledger_ref:string,current_state:string}|ErrorResult>}
314
+ */
315
+ export async function guardOverride({ resourceId, fromState, toState, overrideToken, rationale, resolvedBy = 'human' }) {
316
+ return runGuard('override', _compact({
317
+ resource_id: resourceId,
318
+ from_state: fromState,
319
+ to_state: toState,
320
+ override_token: overrideToken,
321
+ rationale,
322
+ resolved_by: resolvedBy,
323
+ }));
324
+ }
325
+
326
+ /**
327
+ * Read a resource's current state + append-only, hash-chained transition ledger.
328
+ * @returns {Promise<{resource_id:string,current_state:string,ledger:object[]}|ErrorResult>}
329
+ */
330
+ export async function guardHistory(resourceId) {
331
+ return runGuard('history', { resource_id: resourceId }, QUERY_TIMEOUT_MS);
332
+ }
@@ -51,6 +51,12 @@ import { getTargetRoot, resolveProjectPath, loadProjectConfig } from './project-
51
51
  import { anchorBoundary } from '../lib/checkpoint/checkpoint-writer.js';
52
52
  import { appendGateLogEntry, readGateLog, mapResolveOutcomeToSchema } from './gate-log-store.js';
53
53
  import { addOpenLoop, resolveOpenLoop, listOpenLoops } from './open-loops-store.js';
54
+ import {
55
+ BASE_TRANSITIONS, SKIPPABLE, TERMINAL,
56
+ guardedTransition, ensureGuard, projectFeatureStatus,
57
+ verifyCompletionEvidence, guardTestCommand,
58
+ } from './lifecycle-guard.js';
59
+ import { requireSensitiveToken } from './security.js';
54
60
 
55
61
  const PROJECT_ROOT = getTargetRoot();
56
62
 
@@ -58,9 +64,25 @@ const PROJECT_ROOT = getTargetRoot();
58
64
  * Attach vision CRUD and plan/parse REST routes to an Express app.
59
65
  *
60
66
  * @param {object} app — Express app
61
- * @param {{ store: object, scheduleBroadcast: function, broadcastMessage: function, projectRoot: string }} deps
67
+ * @param {{ store: object, scheduleBroadcast: function, broadcastMessage: function, projectRoot: string, settingsStore?: object, capabilities?: object }} deps
62
68
  */
63
- export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMessage, projectRoot = PROJECT_ROOT, settingsStore }) {
69
+ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMessage, projectRoot = PROJECT_ROOT, settingsStore, capabilities }) {
70
+ // COMP-MCP-ENFORCE: when enabled, lifecycle transitions are verdict-gated by
71
+ // stratum's STRAT-GUARD (fail-closed). Default OFF — legacy behavior intact.
72
+ const guardEnabled = capabilities?.guard === true;
73
+
74
+ // COMP-MCP-ENFORCE Slice 4: opt-in loopback REST auth on vision MUTATION
75
+ // endpoints (lifecycle transitions, iterations, gate resolve, item CRUD,
76
+ // branch-lineage). Default OFF (the cockpit does not yet send the token, so
77
+ // forcing it would break the UI) — defense-in-depth for headless/CI surfaces.
78
+ // Reads stay open. Fail-closed: when guardAuth is on, mutations require
79
+ // x-compose-token; if COMPOSE_API_TOKEN is NOT configured, requireSensitiveToken
80
+ // returns 503 (mutations disabled) rather than silently allowing them — enabling
81
+ // auth without a token is a misconfiguration, not an open door. Pass-through
82
+ // when off, so route wiring is unconditional.
83
+ const guardAuthEnabled = capabilities?.guardAuth === true;
84
+ const guardAuth = (req, res, next) =>
85
+ guardAuthEnabled ? requireSensitiveToken(req, res, next) : next();
64
86
  // GET /api/vision/items — full state (optional ?group= filter)
65
87
  app.get('/api/vision/items', (req, res) => {
66
88
  let state = store.getState();
@@ -71,7 +93,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
71
93
  });
72
94
 
73
95
  // POST /api/vision/items — create item
74
- app.post('/api/vision/items', (req, res) => {
96
+ app.post('/api/vision/items', guardAuth, (req, res) => {
75
97
  try {
76
98
  const item = store.createItem(req.body);
77
99
  scheduleBroadcast();
@@ -82,7 +104,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
82
104
  });
83
105
 
84
106
  // PATCH /api/vision/items/:id — update item
85
- app.patch('/api/vision/items/:id', (req, res) => {
107
+ app.patch('/api/vision/items/:id', guardAuth, (req, res) => {
86
108
  try {
87
109
  const item = store.updateItem(req.params.id, req.body);
88
110
  // If group changed, write back to docs/features/<code>/feature.json
@@ -103,7 +125,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
103
125
  });
104
126
 
105
127
  // DELETE /api/vision/items/:id — delete item + connections
106
- app.delete('/api/vision/items/:id', (req, res) => {
128
+ app.delete('/api/vision/items/:id', guardAuth, (req, res) => {
107
129
  try {
108
130
  store.deleteItem(req.params.id);
109
131
  scheduleBroadcast();
@@ -153,21 +175,10 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
153
175
  ? path.join(projectRoot, loadProjectConfig().paths?.features || 'docs/features')
154
176
  : resolveProjectPath('features');
155
177
 
156
- const TRANSITIONS = {
157
- explore_design: ['prd', 'architecture', 'blueprint'],
158
- prd: ['architecture', 'blueprint'],
159
- architecture: ['blueprint'],
160
- blueprint: ['verification'],
161
- verification: ['plan', 'blueprint'],
162
- plan: ['execute'],
163
- execute: ['report', 'docs'],
164
- report: ['docs'],
165
- docs: ['ship'],
166
- ship: [],
167
- };
168
- const SKIPPABLE = new Set(['prd', 'architecture', 'report']);
178
+ // Phase graph + SKIPPABLE + TERMINAL are owned by lifecycle-guard.js (single
179
+ // source of truth shared with the STRAT-GUARD graph) — see COMP-MCP-ENFORCE.
180
+ const TRANSITIONS = BASE_TRANSITIONS;
169
181
  const ITERATION_TYPES = new Set(['review', 'coverage']);
170
- const TERMINAL = new Set(['complete', 'killed']);
171
182
 
172
183
  app.get('/api/vision/items/:id/lifecycle', (req, res) => {
173
184
  try {
@@ -193,7 +204,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
193
204
  }
194
205
  });
195
206
 
196
- app.post('/api/vision/items/:id/lifecycle/start', (req, res) => {
207
+ app.post('/api/vision/items/:id/lifecycle/start', guardAuth, async (req, res) => {
197
208
  try {
198
209
  const { featureCode } = req.body;
199
210
  if (!featureCode) return res.status(400).json({ error: 'featureCode is required' });
@@ -213,6 +224,17 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
213
224
  // COMP-OBS-TIMELINE: populate phaseHistory before storing
214
225
  appendPhaseHistory({ lifecycle }, { from: null, to: 'explore_design', outcome: null, timestamp: now });
215
226
  store.updateLifecycle(req.params.id, lifecycle);
227
+ // COMP-MCP-ENFORCE: eager guard registration seeded at the genesis phase
228
+ // (the clean bootstrap path; backfill for pre-rollout items happens lazily
229
+ // on first transition). Best-effort — a registration hiccup must not block
230
+ // starting a lifecycle; the next guarded transition re-attempts ensureGuard.
231
+ if (guardEnabled) {
232
+ try { await ensureGuard(featureCode, 'explore_design', projectRoot); }
233
+ catch (e) { console.warn(`[lifecycle/start] guard register for ${featureCode} failed: ${e.message}`); }
234
+ // Slice 2: starting a lifecycle projects explore_design → IN_PROGRESS so
235
+ // the first active phase is not left stuck at PLANNED in feature.json.
236
+ await projectFeatureStatus({ featureCode, phase: 'explore_design', cwd: projectRoot });
237
+ }
216
238
  scheduleBroadcast();
217
239
  broadcastMessage({ type: 'lifecycleStarted', itemId: req.params.id, phase: 'explore_design', featureCode, timestamp: now });
218
240
  emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode, from: null, to: 'explore_design', outcome: null, timestamp: now }));
@@ -229,7 +251,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
229
251
  });
230
252
 
231
253
  // COMP-OBS-BRANCH: accept BranchLineage payloads from the CC-session watcher.
232
- app.post('/api/vision/items/:id/lifecycle/branch-lineage', (req, res) => {
254
+ app.post('/api/vision/items/:id/lifecycle/branch-lineage', guardAuth, (req, res) => {
233
255
  try {
234
256
  const item = store.items.get(req.params.id);
235
257
  if (!item) return res.status(404).json({ error: `Item not found: ${req.params.id}` });
@@ -260,7 +282,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
260
282
  }
261
283
  });
262
284
 
263
- app.post('/api/vision/items/:id/lifecycle/advance', (req, res) => {
285
+ app.post('/api/vision/items/:id/lifecycle/advance', guardAuth, async (req, res) => {
264
286
  try {
265
287
  const { targetPhase, outcome } = req.body;
266
288
  const item = store.items.get(req.params.id);
@@ -270,11 +292,20 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
270
292
  const valid = TRANSITIONS[from];
271
293
  if (!valid?.includes(targetPhase)) return res.status(400).json({ error: `Invalid transition: ${from} → ${targetPhase}` });
272
294
 
295
+ // COMP-MCP-ENFORCE: verdict-gate the transition (fail-closed) before mutating.
296
+ if (guardEnabled) {
297
+ const g = await guardedTransition({ featureCode: item.lifecycle.featureCode, from, to: targetPhase, workspaceRoot: projectRoot, resolvedBy: 'agent' });
298
+ if (!g.applied) return res.status(422).json({ error: 'transition refused by guard', from, to: targetPhase, verdict: g.verdict, guardError: g.error });
299
+ }
300
+
273
301
  item.lifecycle.currentPhase = targetPhase;
274
302
  // COMP-OBS-TIMELINE: populate phaseHistory + emit phase_transition DecisionEvent
275
303
  const now = new Date().toISOString();
276
304
  appendPhaseHistory(item, { from, to: targetPhase, outcome: outcome ?? null, timestamp: now });
277
305
  store.updateLifecycle(req.params.id, item.lifecycle);
306
+ // COMP-MCP-ENFORCE Slice 2 (lifecycle-as-truth): project the new phase onto
307
+ // feature.json STATUS (best-effort; idempotent — only writes on a real change).
308
+ if (guardEnabled) await projectFeatureStatus({ featureCode: item.lifecycle.featureCode, phase: targetPhase, cwd: projectRoot });
278
309
  scheduleBroadcast();
279
310
  broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from, to: targetPhase, outcome, timestamp: now });
280
311
  emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode: item.lifecycle.featureCode, from, to: targetPhase, outcome, timestamp: now }));
@@ -291,7 +322,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
291
322
  }
292
323
  });
293
324
 
294
- app.post('/api/vision/items/:id/lifecycle/skip', (req, res) => {
325
+ app.post('/api/vision/items/:id/lifecycle/skip', guardAuth, async (req, res) => {
295
326
  try {
296
327
  const { targetPhase, reason } = req.body;
297
328
  const item = store.items.get(req.params.id);
@@ -302,11 +333,19 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
302
333
  const valid = TRANSITIONS[from];
303
334
  if (!valid?.includes(targetPhase)) return res.status(400).json({ error: `Invalid transition: ${from} → ${targetPhase}` });
304
335
 
336
+ // COMP-MCP-ENFORCE: verdict-gate the skip (fail-closed) before mutating.
337
+ if (guardEnabled) {
338
+ const g = await guardedTransition({ featureCode: item.lifecycle.featureCode, from, to: targetPhase, workspaceRoot: projectRoot, resolvedBy: 'agent' });
339
+ if (!g.applied) return res.status(422).json({ error: 'transition refused by guard', from, to: targetPhase, verdict: g.verdict, guardError: g.error });
340
+ }
341
+
305
342
  item.lifecycle.currentPhase = targetPhase;
306
343
  // COMP-OBS-TIMELINE: populate phaseHistory + emit phase_transition DecisionEvent
307
344
  const now = new Date().toISOString();
308
345
  appendPhaseHistory(item, { from, to: targetPhase, outcome: 'skipped', timestamp: now });
309
346
  store.updateLifecycle(req.params.id, item.lifecycle);
347
+ // COMP-MCP-ENFORCE Slice 2 (lifecycle-as-truth): project phase → STATUS.
348
+ if (guardEnabled) await projectFeatureStatus({ featureCode: item.lifecycle.featureCode, phase: targetPhase, cwd: projectRoot });
310
349
  scheduleBroadcast();
311
350
  broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from, to: targetPhase, outcome: 'skipped', timestamp: now });
312
351
  emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode: item.lifecycle.featureCode, from, to: targetPhase, outcome: 'skipped', timestamp: now }));
@@ -323,7 +362,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
323
362
  }
324
363
  });
325
364
 
326
- app.post('/api/vision/items/:id/lifecycle/kill', (req, res) => {
365
+ app.post('/api/vision/items/:id/lifecycle/kill', guardAuth, async (req, res) => {
327
366
  try {
328
367
  const { reason } = req.body;
329
368
  const item = store.items.get(req.params.id);
@@ -331,6 +370,15 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
331
370
  const from = item.lifecycle.currentPhase;
332
371
  if (TERMINAL.has(from)) return res.status(400).json({ error: `Cannot kill from terminal state: ${from}` });
333
372
 
373
+ // COMP-MCP-ENFORCE: record the kill in the tamper-evident ledger (fail-closed).
374
+ // The `<from>→killed` edge has no predicate, so it only fails if the guard
375
+ // is unreachable — consistent with advance/skip/complete. Authorized
376
+ // bypass remains stratum_guard_override (Slice 3).
377
+ if (guardEnabled) {
378
+ const g = await guardedTransition({ featureCode: item.lifecycle.featureCode, from, to: 'killed', workspaceRoot: projectRoot, resolvedBy: 'agent' });
379
+ if (!g.applied) return res.status(422).json({ error: 'kill refused by guard', from, to: 'killed', verdict: g.verdict, guardError: g.error });
380
+ }
381
+
334
382
  const now = new Date().toISOString();
335
383
  item.lifecycle.currentPhase = 'killed';
336
384
  item.lifecycle.killedAt = now;
@@ -339,6 +387,9 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
339
387
  appendPhaseHistory(item, { from, to: 'killed', outcome: 'killed', timestamp: now });
340
388
  store.updateLifecycle(req.params.id, item.lifecycle);
341
389
  store.updateItem(req.params.id, { status: 'killed' });
390
+ // COMP-MCP-ENFORCE Slice 2: project kill → KILLED onto feature.json
391
+ // (closes the COMP-PARITY-7 gap — kill previously wrote vision-state only).
392
+ if (guardEnabled) await projectFeatureStatus({ featureCode: item.lifecycle.featureCode, phase: 'killed', cwd: projectRoot });
342
393
  scheduleBroadcast();
343
394
  broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from, to: 'killed', outcome: 'killed', timestamp: now });
344
395
  emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode: item.lifecycle.featureCode, from, to: 'killed', outcome: 'killed', timestamp: now }));
@@ -355,7 +406,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
355
406
  }
356
407
  });
357
408
 
358
- app.post('/api/vision/items/:id/lifecycle/complete', async (req, res) => {
409
+ app.post('/api/vision/items/:id/lifecycle/complete', guardAuth, async (req, res) => {
359
410
  try {
360
411
  const item = store.items.get(req.params.id);
361
412
  if (!item?.lifecycle) return res.status(404).json({ error: 'No lifecycle on this item' });
@@ -363,6 +414,28 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
363
414
  return res.status(400).json({ error: `Can only complete from ship phase, currently in: ${item.lifecycle.currentPhase}` });
364
415
  }
365
416
 
417
+ // COMP-MCP-ENFORCE Slice 3: evidence-bound completion. Under the guard,
418
+ // ship→complete requires REAL evidence — the commit must exist (server-read
419
+ // git) and tests must be attested (configured test command exits 0, or
420
+ // tests_pass is explicitly true; never a silent default). Then the guard
421
+ // verdict gates the transition (fail-closed).
422
+ let verifiedTestsPass = req.body?.tests_pass;
423
+ if (guardEnabled) {
424
+ const ev = await verifyCompletionEvidence({
425
+ commitSha: req.body?.commit_sha,
426
+ cwd: projectRoot,
427
+ testCommand: guardTestCommand(projectRoot),
428
+ testsPassClaim: req.body?.tests_pass,
429
+ });
430
+ if (!ev.ok) {
431
+ return res.status(422).json({ error: 'completion evidence not satisfied', reasons: ev.reasons });
432
+ }
433
+ verifiedTestsPass = ev.testsAttested ? true : (req.body?.tests_pass === true);
434
+
435
+ const g = await guardedTransition({ featureCode: item.lifecycle.featureCode, from: 'ship', to: 'complete', workspaceRoot: projectRoot, commitSha: req.body?.commit_sha, resolvedBy: 'agent' });
436
+ if (!g.applied) return res.status(422).json({ error: 'completion refused by guard', from: 'ship', to: 'complete', verdict: g.verdict, guardError: g.error });
437
+ }
438
+
366
439
  const now = new Date().toISOString();
367
440
  item.lifecycle.currentPhase = 'complete';
368
441
  item.lifecycle.completedAt = now;
@@ -370,6 +443,10 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
370
443
  appendPhaseHistory(item, { from: 'ship', to: 'complete', outcome: 'approved', timestamp: now });
371
444
  store.updateLifecycle(req.params.id, item.lifecycle);
372
445
  store.updateItem(req.params.id, { status: 'complete' });
446
+ // COMP-MCP-ENFORCE Slice 2: status projection for the no-commit path is
447
+ // applied BELOW (in the `else` branch) so it does not pre-empt and mask the
448
+ // recordCompletion bridge, which is the authority + partial-write reporter
449
+ // on the commit_sha path.
373
450
  scheduleBroadcast();
374
451
  broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from: 'ship', to: 'complete', outcome: 'approved', timestamp: now });
375
452
  emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode: item.lifecycle.featureCode, from: 'ship', to: 'complete', outcome: 'approved', timestamp: now }));
@@ -393,7 +470,9 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
393
470
  await recordCompletion(projectRoot, {
394
471
  feature_code: featureCode,
395
472
  commit_sha,
396
- tests_pass: tests_pass ?? true,
473
+ // Slice 3: under the guard, tests_pass reflects verified evidence
474
+ // (attested or explicit), NOT a silent default-to-true.
475
+ tests_pass: guardEnabled ? (verifiedTestsPass === true) : (tests_pass ?? true),
397
476
  files_changed: files_changed ?? [],
398
477
  notes: notes ?? `cockpit lifecycle: ${featureCode} complete`,
399
478
  });
@@ -425,6 +504,9 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
425
504
  reason: 'no_commit_sha',
426
505
  });
427
506
  } catch { /* decision event emit best-effort */ }
507
+ // No recordCompletion bridge ran — project complete → COMPLETE so
508
+ // lifecycle-as-truth still reaches feature.json (best-effort).
509
+ if (guardEnabled) await projectFeatureStatus({ featureCode, phase: 'complete', cwd: projectRoot });
428
510
  }
429
511
  }
430
512
 
@@ -437,7 +519,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
437
519
 
438
520
  // ── Iteration loop endpoints ──────────────────────────────────────────
439
521
 
440
- app.post('/api/vision/items/:id/lifecycle/iteration/start', (req, res) => {
522
+ app.post('/api/vision/items/:id/lifecycle/iteration/start', guardAuth, (req, res) => {
441
523
  try {
442
524
  const item = store.items.get(req.params.id);
443
525
  if (!item?.lifecycle) return res.status(404).json({ error: 'No lifecycle on this item' });
@@ -502,7 +584,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
502
584
  }
503
585
  });
504
586
 
505
- app.post('/api/vision/items/:id/lifecycle/iteration/report', (req, res) => {
587
+ app.post('/api/vision/items/:id/lifecycle/iteration/report', guardAuth, (req, res) => {
506
588
  try {
507
589
  const item = store.items.get(req.params.id);
508
590
  if (!item?.lifecycle?.iterationState) return res.status(404).json({ error: 'No iteration loop active' });
@@ -589,7 +671,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
589
671
  }
590
672
  });
591
673
 
592
- app.post('/api/vision/items/:id/lifecycle/iteration/abort', (req, res) => {
674
+ app.post('/api/vision/items/:id/lifecycle/iteration/abort', guardAuth, (req, res) => {
593
675
  try {
594
676
  const item = store.items.get(req.params.id);
595
677
  if (!item?.lifecycle?.iterationState) return res.status(404).json({ error: 'No iteration loop active' });
@@ -765,7 +847,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
765
847
  }
766
848
  });
767
849
 
768
- app.post('/api/vision/gates/:id/resolve', (req, res) => {
850
+ app.post('/api/vision/gates/:id/resolve', guardAuth, (req, res) => {
769
851
  try {
770
852
  const { outcome: rawOutcome, comment, resolvedBy } = req.body;
771
853
  if (!rawOutcome) return res.status(400).json({ error: 'outcome is required' });
@@ -90,6 +90,8 @@ export class VisionServer {
90
90
  broadcastMessage: (msg) => this.broadcastMessage(msg),
91
91
  projectRoot: getTargetRoot(),
92
92
  settingsStore: this.settingsStore,
93
+ // COMP-MCP-ENFORCE: capabilities.guard gates STRAT-GUARD enforcement (default OFF).
94
+ capabilities: this._config.capabilities,
93
95
  });
94
96
 
95
97
  // ── Activity + error routes ─────────────────────────────────────────────