@smartmemory/compose 0.2.4-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.4-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
  }
@@ -14,12 +14,14 @@
14
14
 
15
15
  import { createHash } from 'node:crypto';
16
16
  import { readFileSync } from 'node:fs';
17
+ import { spawnSync } from 'node:child_process';
17
18
  import path from 'node:path';
18
19
 
19
20
  import {
20
21
  guardRegister as _guardRegister,
21
22
  guardTransition as _guardTransition,
22
23
  } from './stratum-client.js';
24
+ import { setFeatureStatus as _setFeatureStatus } from '../lib/feature-writer.js';
23
25
 
24
26
  // ---------------------------------------------------------------------------
25
27
  // Canonical phase graph (compose-owned data — single source of truth)
@@ -108,6 +110,100 @@ export function resourceId(featureCode, workspaceRoot) {
108
110
  return `compose:${hash}:${featureCode}`;
109
111
  }
110
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
+
111
207
  // ---------------------------------------------------------------------------
112
208
  // Guard client (injectable for tests)
113
209
  // ---------------------------------------------------------------------------
@@ -138,6 +234,22 @@ function _featureRelDir(featureCode, workspaceRoot) {
138
234
  return `${featuresRel}/${featureCode}`;
139
235
  }
140
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
+
141
253
  /**
142
254
  * Idempotently register the feature as a guarded resource, seeding `initial`
143
255
  * from the item's CURRENT phase (so items already mid-lifecycle at rollout don't
@@ -53,8 +53,10 @@ import { appendGateLogEntry, readGateLog, mapResolveOutcomeToSchema } from './ga
53
53
  import { addOpenLoop, resolveOpenLoop, listOpenLoops } from './open-loops-store.js';
54
54
  import {
55
55
  BASE_TRANSITIONS, SKIPPABLE, TERMINAL,
56
- guardedTransition, ensureGuard,
56
+ guardedTransition, ensureGuard, projectFeatureStatus,
57
+ verifyCompletionEvidence, guardTestCommand,
57
58
  } from './lifecycle-guard.js';
59
+ import { requireSensitiveToken } from './security.js';
58
60
 
59
61
  const PROJECT_ROOT = getTargetRoot();
60
62
 
@@ -68,6 +70,19 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
68
70
  // COMP-MCP-ENFORCE: when enabled, lifecycle transitions are verdict-gated by
69
71
  // stratum's STRAT-GUARD (fail-closed). Default OFF — legacy behavior intact.
70
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();
71
86
  // GET /api/vision/items — full state (optional ?group= filter)
72
87
  app.get('/api/vision/items', (req, res) => {
73
88
  let state = store.getState();
@@ -78,7 +93,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
78
93
  });
79
94
 
80
95
  // POST /api/vision/items — create item
81
- app.post('/api/vision/items', (req, res) => {
96
+ app.post('/api/vision/items', guardAuth, (req, res) => {
82
97
  try {
83
98
  const item = store.createItem(req.body);
84
99
  scheduleBroadcast();
@@ -89,7 +104,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
89
104
  });
90
105
 
91
106
  // PATCH /api/vision/items/:id — update item
92
- app.patch('/api/vision/items/:id', (req, res) => {
107
+ app.patch('/api/vision/items/:id', guardAuth, (req, res) => {
93
108
  try {
94
109
  const item = store.updateItem(req.params.id, req.body);
95
110
  // If group changed, write back to docs/features/<code>/feature.json
@@ -110,7 +125,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
110
125
  });
111
126
 
112
127
  // DELETE /api/vision/items/:id — delete item + connections
113
- app.delete('/api/vision/items/:id', (req, res) => {
128
+ app.delete('/api/vision/items/:id', guardAuth, (req, res) => {
114
129
  try {
115
130
  store.deleteItem(req.params.id);
116
131
  scheduleBroadcast();
@@ -189,7 +204,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
189
204
  }
190
205
  });
191
206
 
192
- app.post('/api/vision/items/:id/lifecycle/start', async (req, res) => {
207
+ app.post('/api/vision/items/:id/lifecycle/start', guardAuth, async (req, res) => {
193
208
  try {
194
209
  const { featureCode } = req.body;
195
210
  if (!featureCode) return res.status(400).json({ error: 'featureCode is required' });
@@ -216,6 +231,9 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
216
231
  if (guardEnabled) {
217
232
  try { await ensureGuard(featureCode, 'explore_design', projectRoot); }
218
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 });
219
237
  }
220
238
  scheduleBroadcast();
221
239
  broadcastMessage({ type: 'lifecycleStarted', itemId: req.params.id, phase: 'explore_design', featureCode, timestamp: now });
@@ -233,7 +251,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
233
251
  });
234
252
 
235
253
  // COMP-OBS-BRANCH: accept BranchLineage payloads from the CC-session watcher.
236
- app.post('/api/vision/items/:id/lifecycle/branch-lineage', (req, res) => {
254
+ app.post('/api/vision/items/:id/lifecycle/branch-lineage', guardAuth, (req, res) => {
237
255
  try {
238
256
  const item = store.items.get(req.params.id);
239
257
  if (!item) return res.status(404).json({ error: `Item not found: ${req.params.id}` });
@@ -264,7 +282,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
264
282
  }
265
283
  });
266
284
 
267
- app.post('/api/vision/items/:id/lifecycle/advance', async (req, res) => {
285
+ app.post('/api/vision/items/:id/lifecycle/advance', guardAuth, async (req, res) => {
268
286
  try {
269
287
  const { targetPhase, outcome } = req.body;
270
288
  const item = store.items.get(req.params.id);
@@ -285,6 +303,9 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
285
303
  const now = new Date().toISOString();
286
304
  appendPhaseHistory(item, { from, to: targetPhase, outcome: outcome ?? null, timestamp: now });
287
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 });
288
309
  scheduleBroadcast();
289
310
  broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from, to: targetPhase, outcome, timestamp: now });
290
311
  emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode: item.lifecycle.featureCode, from, to: targetPhase, outcome, timestamp: now }));
@@ -301,7 +322,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
301
322
  }
302
323
  });
303
324
 
304
- app.post('/api/vision/items/:id/lifecycle/skip', async (req, res) => {
325
+ app.post('/api/vision/items/:id/lifecycle/skip', guardAuth, async (req, res) => {
305
326
  try {
306
327
  const { targetPhase, reason } = req.body;
307
328
  const item = store.items.get(req.params.id);
@@ -323,6 +344,8 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
323
344
  const now = new Date().toISOString();
324
345
  appendPhaseHistory(item, { from, to: targetPhase, outcome: 'skipped', timestamp: now });
325
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 });
326
349
  scheduleBroadcast();
327
350
  broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from, to: targetPhase, outcome: 'skipped', timestamp: now });
328
351
  emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode: item.lifecycle.featureCode, from, to: targetPhase, outcome: 'skipped', timestamp: now }));
@@ -339,7 +362,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
339
362
  }
340
363
  });
341
364
 
342
- app.post('/api/vision/items/:id/lifecycle/kill', async (req, res) => {
365
+ app.post('/api/vision/items/:id/lifecycle/kill', guardAuth, async (req, res) => {
343
366
  try {
344
367
  const { reason } = req.body;
345
368
  const item = store.items.get(req.params.id);
@@ -364,6 +387,9 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
364
387
  appendPhaseHistory(item, { from, to: 'killed', outcome: 'killed', timestamp: now });
365
388
  store.updateLifecycle(req.params.id, item.lifecycle);
366
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 });
367
393
  scheduleBroadcast();
368
394
  broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from, to: 'killed', outcome: 'killed', timestamp: now });
369
395
  emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode: item.lifecycle.featureCode, from, to: 'killed', outcome: 'killed', timestamp: now }));
@@ -380,7 +406,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
380
406
  }
381
407
  });
382
408
 
383
- app.post('/api/vision/items/:id/lifecycle/complete', async (req, res) => {
409
+ app.post('/api/vision/items/:id/lifecycle/complete', guardAuth, async (req, res) => {
384
410
  try {
385
411
  const item = store.items.get(req.params.id);
386
412
  if (!item?.lifecycle) return res.status(404).json({ error: 'No lifecycle on this item' });
@@ -388,10 +414,24 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
388
414
  return res.status(400).json({ error: `Can only complete from ship phase, currently in: ${item.lifecycle.currentPhase}` });
389
415
  }
390
416
 
391
- // COMP-MCP-ENFORCE: verdict-gate ship→complete (fail-closed). commit_sha is
392
- // recorded in the ledger via artifacts; evidence-bound completion (real
393
- // git/commit/test attestation) is Slice 3.
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;
394
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
+
395
435
  const g = await guardedTransition({ featureCode: item.lifecycle.featureCode, from: 'ship', to: 'complete', workspaceRoot: projectRoot, commitSha: req.body?.commit_sha, resolvedBy: 'agent' });
396
436
  if (!g.applied) return res.status(422).json({ error: 'completion refused by guard', from: 'ship', to: 'complete', verdict: g.verdict, guardError: g.error });
397
437
  }
@@ -403,6 +443,10 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
403
443
  appendPhaseHistory(item, { from: 'ship', to: 'complete', outcome: 'approved', timestamp: now });
404
444
  store.updateLifecycle(req.params.id, item.lifecycle);
405
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.
406
450
  scheduleBroadcast();
407
451
  broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from: 'ship', to: 'complete', outcome: 'approved', timestamp: now });
408
452
  emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode: item.lifecycle.featureCode, from: 'ship', to: 'complete', outcome: 'approved', timestamp: now }));
@@ -426,7 +470,9 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
426
470
  await recordCompletion(projectRoot, {
427
471
  feature_code: featureCode,
428
472
  commit_sha,
429
- 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),
430
476
  files_changed: files_changed ?? [],
431
477
  notes: notes ?? `cockpit lifecycle: ${featureCode} complete`,
432
478
  });
@@ -458,6 +504,9 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
458
504
  reason: 'no_commit_sha',
459
505
  });
460
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 });
461
510
  }
462
511
  }
463
512
 
@@ -470,7 +519,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
470
519
 
471
520
  // ── Iteration loop endpoints ──────────────────────────────────────────
472
521
 
473
- app.post('/api/vision/items/:id/lifecycle/iteration/start', (req, res) => {
522
+ app.post('/api/vision/items/:id/lifecycle/iteration/start', guardAuth, (req, res) => {
474
523
  try {
475
524
  const item = store.items.get(req.params.id);
476
525
  if (!item?.lifecycle) return res.status(404).json({ error: 'No lifecycle on this item' });
@@ -535,7 +584,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
535
584
  }
536
585
  });
537
586
 
538
- app.post('/api/vision/items/:id/lifecycle/iteration/report', (req, res) => {
587
+ app.post('/api/vision/items/:id/lifecycle/iteration/report', guardAuth, (req, res) => {
539
588
  try {
540
589
  const item = store.items.get(req.params.id);
541
590
  if (!item?.lifecycle?.iterationState) return res.status(404).json({ error: 'No iteration loop active' });
@@ -622,7 +671,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
622
671
  }
623
672
  });
624
673
 
625
- app.post('/api/vision/items/:id/lifecycle/iteration/abort', (req, res) => {
674
+ app.post('/api/vision/items/:id/lifecycle/iteration/abort', guardAuth, (req, res) => {
626
675
  try {
627
676
  const item = store.items.get(req.params.id);
628
677
  if (!item?.lifecycle?.iterationState) return res.status(404).json({ error: 'No iteration loop active' });
@@ -798,7 +847,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
798
847
  }
799
848
  });
800
849
 
801
- app.post('/api/vision/gates/:id/resolve', (req, res) => {
850
+ app.post('/api/vision/gates/:id/resolve', guardAuth, (req, res) => {
802
851
  try {
803
852
  const { outcome: rawOutcome, comment, resolvedBy } = req.body;
804
853
  if (!rawOutcome) return res.status(400).json({ error: 'outcome is required' });