@smartmemory/compose 0.2.3-beta → 0.2.4-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.
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.4-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",
@@ -0,0 +1,225 @@
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 path from 'node:path';
18
+
19
+ import {
20
+ guardRegister as _guardRegister,
21
+ guardTransition as _guardTransition,
22
+ } from './stratum-client.js';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Canonical phase graph (compose-owned data — single source of truth)
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /** Forward transitions between non-terminal lifecycle phases. */
29
+ export const BASE_TRANSITIONS = {
30
+ explore_design: ['prd', 'architecture', 'blueprint'],
31
+ prd: ['architecture', 'blueprint'],
32
+ architecture: ['blueprint'],
33
+ blueprint: ['verification'],
34
+ verification: ['plan', 'blueprint'],
35
+ plan: ['execute'],
36
+ execute: ['report', 'docs'],
37
+ report: ['docs'],
38
+ docs: ['ship'],
39
+ ship: [],
40
+ };
41
+
42
+ /** Phases whose forward edge may be skipped (not killed). */
43
+ export const SKIPPABLE = new Set(['prd', 'architecture', 'report']);
44
+
45
+ /** Terminal phases — no outgoing edges. */
46
+ export const TERMINAL = new Set(['complete', 'killed']);
47
+
48
+ /**
49
+ * Assemble the FULL guarded graph the design requires: the forward
50
+ * `BASE_TRANSITIONS` PLUS the `ship → complete` edge and a `<any non-terminal>
51
+ * → killed` edge from every reachable non-terminal phase. The guard graph must
52
+ * be a superset of every edge vision-routes will legally request, or the guard
53
+ * would reject a transition the app considers valid.
54
+ */
55
+ export function buildPhaseGraph(transitions = BASE_TRANSITIONS) {
56
+ const graph = {};
57
+ const nodes = new Set();
58
+ for (const [from, tos] of Object.entries(transitions)) {
59
+ graph[from] = [...tos];
60
+ nodes.add(from);
61
+ for (const t of tos) nodes.add(t);
62
+ }
63
+ // ship → complete (the highest-consequence edge; implemented separately in
64
+ // vision-routes at /lifecycle/complete, so absent from BASE_TRANSITIONS).
65
+ graph.ship = [...(graph.ship || []), 'complete'];
66
+ // Every non-terminal phase → killed (vision-routes /lifecycle/kill allows
67
+ // kill from any non-terminal phase, including ship).
68
+ for (const s of nodes) {
69
+ if (TERMINAL.has(s)) continue;
70
+ graph[s] = graph[s] || [];
71
+ if (!graph[s].includes('killed')) graph[s].push('killed');
72
+ }
73
+ graph.complete = [];
74
+ graph.killed = [];
75
+ return graph;
76
+ }
77
+
78
+ /**
79
+ * Per-edge `deterministic` (trusted, server-read) evidence predicates. Paths are
80
+ * RELATIVE to the guard's workspace_root and derived from the configured feature
81
+ * directory (never hardcoded `docs/features`). Edges not listed here carry no
82
+ * predicate — they still get graph-legality + per-resource serialization +
83
+ * tamper-evident ledgering. Evidence-bound `ship → complete` is Slice 3.
84
+ *
85
+ * @param {string} featureRelDir e.g. "docs/features/FEAT-1"
86
+ */
87
+ export function edgePredicates(featureRelDir) {
88
+ const det = (id, file) => ({
89
+ id,
90
+ type: 'deterministic',
91
+ statement: `server_file_exists('${featureRelDir}/${file}')`,
92
+ });
93
+ return {
94
+ 'explore_design->blueprint': [det('design_md', 'design.md')],
95
+ 'blueprint->verification': [det('blueprint_md', 'blueprint.md')],
96
+ 'plan->execute': [det('plan_md', 'plan.md')],
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Project-scoped, opaque resource id. STRAT-GUARD state is stored globally keyed
102
+ * only by resource_id and workspace_root is NOT part of the checksum, so a bare
103
+ * `compose:<FC>` would let two compose projects sharing a feature code collide on
104
+ * one ledger/current-state. The project-path hash prevents that.
105
+ */
106
+ export function resourceId(featureCode, workspaceRoot) {
107
+ const hash = createHash('sha256').update(path.resolve(workspaceRoot)).digest('hex').slice(0, 12);
108
+ return `compose:${hash}:${featureCode}`;
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Guard client (injectable for tests)
113
+ // ---------------------------------------------------------------------------
114
+
115
+ let _client = { register: _guardRegister, transition: _guardTransition };
116
+ /** @internal test seam */
117
+ export function _testOnly_setGuardClient(c) { _client = c; }
118
+
119
+ // Per-process registration cache — register is idempotent server-side, but the
120
+ // cache avoids a subprocess per request once a resource is known-registered.
121
+ const _registered = new Set();
122
+ /** @internal test seam */
123
+ export function _testOnly_resetGuardCache() { _registered.clear(); }
124
+
125
+ /**
126
+ * Resolve the feature directory RELATIVE to the served `workspaceRoot` — read
127
+ * from `<workspaceRoot>/.compose/compose.json` (NOT the process-global config,
128
+ * which is pinned to getTargetRoot() and would drift for a non-current project
129
+ * root). The relative dir is baked into the immutable guard registration, so it
130
+ * must reflect the tree the routes actually serve.
131
+ */
132
+ function _featureRelDir(featureCode, workspaceRoot) {
133
+ let featuresRel = 'docs/features';
134
+ try {
135
+ const cfg = JSON.parse(readFileSync(path.join(workspaceRoot, '.compose', 'compose.json'), 'utf-8'));
136
+ featuresRel = cfg?.paths?.features || 'docs/features';
137
+ } catch { /* missing/invalid config → default */ }
138
+ return `${featuresRel}/${featureCode}`;
139
+ }
140
+
141
+ /**
142
+ * Idempotently register the feature as a guarded resource, seeding `initial`
143
+ * from the item's CURRENT phase (so items already mid-lifecycle at rollout don't
144
+ * trip stale_from_state). Only the first registration ever seeds current_state;
145
+ * later calls (different `initial`) are no-ops because `initial` is not part of
146
+ * the policy checksum.
147
+ *
148
+ * @returns the register result, or {error} on guard failure.
149
+ */
150
+ export async function ensureGuard(featureCode, currentPhase, workspaceRoot) {
151
+ const rid = resourceId(featureCode, workspaceRoot);
152
+ if (_registered.has(rid)) return { guard_id: rid, status: 'cached' };
153
+
154
+ let res;
155
+ try {
156
+ res = await _client.register({
157
+ resourceId: rid,
158
+ graph: buildPhaseGraph(),
159
+ edgePredicates: edgePredicates(_featureRelDir(featureCode, workspaceRoot)),
160
+ initial: currentPhase,
161
+ terminal: ['complete', 'killed'],
162
+ stakes: {},
163
+ workspaceRoot,
164
+ });
165
+ } catch (e) {
166
+ // A thrown spawn failure (e.g. stratum-mcp not installed) must NOT escape as
167
+ // a generic 500/400 — normalise to a fail-closed error result.
168
+ return { error: { code: 'GUARD_UNREACHABLE', message: e.message } };
169
+ }
170
+ if (res && (res.status === 'registered' || res.status === 'exists')) {
171
+ _registered.add(rid);
172
+ }
173
+ return res;
174
+ }
175
+
176
+ /**
177
+ * Attempt a guarded lifecycle transition. Registers (idempotently) then
178
+ * transitions. FAIL-CLOSED: any guard error (unreachable, illegal edge, error
179
+ * dict) yields `{applied:false, error}` — the caller must NOT mutate state.
180
+ *
181
+ * @returns {Promise<{applied:boolean, refused?:boolean, verdict?:object,
182
+ * ledgerRef?:string, currentState?:string, error?:object}>}
183
+ */
184
+ export async function guardedTransition({ featureCode, from, to, workspaceRoot, commitSha, resolvedBy = 'agent' }) {
185
+ const reg = await ensureGuard(featureCode, from, workspaceRoot);
186
+ if (reg && (reg.error || reg.status === 'error')) {
187
+ return { applied: false, error: reg.error || reg };
188
+ }
189
+
190
+ const rid = resourceId(featureCode, workspaceRoot);
191
+ const artifacts = commitSha ? { commit_sha: commitSha } : {};
192
+ // No idempotency_key: a refuse→fix→retry is a NEW logical attempt that must
193
+ // re-evaluate evidence, but it carries an identical (from,to,artifacts)
194
+ // payload — an idempotency_key would make the guard replay the prior refusal.
195
+ // Double-apply is already prevented server-side: once applied, current_state
196
+ // advances and a duplicate call fails the from_state == current_state check.
197
+ let res;
198
+ try {
199
+ res = await _client.transition({
200
+ resourceId: rid,
201
+ fromState: from,
202
+ toState: to,
203
+ artifacts,
204
+ resolvedBy,
205
+ });
206
+ } catch (e) {
207
+ // Fail-closed on a thrown spawn failure (see ensureGuard).
208
+ return { applied: false, error: { code: 'GUARD_UNREACHABLE', message: e.message } };
209
+ }
210
+
211
+ if (!res || res.error || res.status === 'error') {
212
+ return { applied: false, error: (res && (res.error || res)) || { code: 'UNKNOWN', message: 'no guard response' } };
213
+ }
214
+ if (res.status === 'applied') {
215
+ return { applied: true, verdict: res.verdict, ledgerRef: res.ledger_ref, currentState: res.current_state };
216
+ }
217
+ // refused | replayed
218
+ return {
219
+ applied: res.status === 'replayed' ? true : false,
220
+ refused: res.status === 'refused',
221
+ verdict: res.verdict,
222
+ ledgerRef: res.ledger_ref,
223
+ currentState: res.current_state,
224
+ };
225
+ }
@@ -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,10 @@ 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,
57
+ } from './lifecycle-guard.js';
54
58
 
55
59
  const PROJECT_ROOT = getTargetRoot();
56
60
 
@@ -58,9 +62,12 @@ const PROJECT_ROOT = getTargetRoot();
58
62
  * Attach vision CRUD and plan/parse REST routes to an Express app.
59
63
  *
60
64
  * @param {object} app — Express app
61
- * @param {{ store: object, scheduleBroadcast: function, broadcastMessage: function, projectRoot: string }} deps
65
+ * @param {{ store: object, scheduleBroadcast: function, broadcastMessage: function, projectRoot: string, settingsStore?: object, capabilities?: object }} deps
62
66
  */
63
- export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMessage, projectRoot = PROJECT_ROOT, settingsStore }) {
67
+ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMessage, projectRoot = PROJECT_ROOT, settingsStore, capabilities }) {
68
+ // COMP-MCP-ENFORCE: when enabled, lifecycle transitions are verdict-gated by
69
+ // stratum's STRAT-GUARD (fail-closed). Default OFF — legacy behavior intact.
70
+ const guardEnabled = capabilities?.guard === true;
64
71
  // GET /api/vision/items — full state (optional ?group= filter)
65
72
  app.get('/api/vision/items', (req, res) => {
66
73
  let state = store.getState();
@@ -153,21 +160,10 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
153
160
  ? path.join(projectRoot, loadProjectConfig().paths?.features || 'docs/features')
154
161
  : resolveProjectPath('features');
155
162
 
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']);
163
+ // Phase graph + SKIPPABLE + TERMINAL are owned by lifecycle-guard.js (single
164
+ // source of truth shared with the STRAT-GUARD graph) — see COMP-MCP-ENFORCE.
165
+ const TRANSITIONS = BASE_TRANSITIONS;
169
166
  const ITERATION_TYPES = new Set(['review', 'coverage']);
170
- const TERMINAL = new Set(['complete', 'killed']);
171
167
 
172
168
  app.get('/api/vision/items/:id/lifecycle', (req, res) => {
173
169
  try {
@@ -193,7 +189,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
193
189
  }
194
190
  });
195
191
 
196
- app.post('/api/vision/items/:id/lifecycle/start', (req, res) => {
192
+ app.post('/api/vision/items/:id/lifecycle/start', async (req, res) => {
197
193
  try {
198
194
  const { featureCode } = req.body;
199
195
  if (!featureCode) return res.status(400).json({ error: 'featureCode is required' });
@@ -213,6 +209,14 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
213
209
  // COMP-OBS-TIMELINE: populate phaseHistory before storing
214
210
  appendPhaseHistory({ lifecycle }, { from: null, to: 'explore_design', outcome: null, timestamp: now });
215
211
  store.updateLifecycle(req.params.id, lifecycle);
212
+ // COMP-MCP-ENFORCE: eager guard registration seeded at the genesis phase
213
+ // (the clean bootstrap path; backfill for pre-rollout items happens lazily
214
+ // on first transition). Best-effort — a registration hiccup must not block
215
+ // starting a lifecycle; the next guarded transition re-attempts ensureGuard.
216
+ if (guardEnabled) {
217
+ try { await ensureGuard(featureCode, 'explore_design', projectRoot); }
218
+ catch (e) { console.warn(`[lifecycle/start] guard register for ${featureCode} failed: ${e.message}`); }
219
+ }
216
220
  scheduleBroadcast();
217
221
  broadcastMessage({ type: 'lifecycleStarted', itemId: req.params.id, phase: 'explore_design', featureCode, timestamp: now });
218
222
  emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode, from: null, to: 'explore_design', outcome: null, timestamp: now }));
@@ -260,7 +264,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
260
264
  }
261
265
  });
262
266
 
263
- app.post('/api/vision/items/:id/lifecycle/advance', (req, res) => {
267
+ app.post('/api/vision/items/:id/lifecycle/advance', async (req, res) => {
264
268
  try {
265
269
  const { targetPhase, outcome } = req.body;
266
270
  const item = store.items.get(req.params.id);
@@ -270,6 +274,12 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
270
274
  const valid = TRANSITIONS[from];
271
275
  if (!valid?.includes(targetPhase)) return res.status(400).json({ error: `Invalid transition: ${from} → ${targetPhase}` });
272
276
 
277
+ // COMP-MCP-ENFORCE: verdict-gate the transition (fail-closed) before mutating.
278
+ if (guardEnabled) {
279
+ const g = await guardedTransition({ featureCode: item.lifecycle.featureCode, from, to: targetPhase, workspaceRoot: projectRoot, resolvedBy: 'agent' });
280
+ if (!g.applied) return res.status(422).json({ error: 'transition refused by guard', from, to: targetPhase, verdict: g.verdict, guardError: g.error });
281
+ }
282
+
273
283
  item.lifecycle.currentPhase = targetPhase;
274
284
  // COMP-OBS-TIMELINE: populate phaseHistory + emit phase_transition DecisionEvent
275
285
  const now = new Date().toISOString();
@@ -291,7 +301,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
291
301
  }
292
302
  });
293
303
 
294
- app.post('/api/vision/items/:id/lifecycle/skip', (req, res) => {
304
+ app.post('/api/vision/items/:id/lifecycle/skip', async (req, res) => {
295
305
  try {
296
306
  const { targetPhase, reason } = req.body;
297
307
  const item = store.items.get(req.params.id);
@@ -302,6 +312,12 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
302
312
  const valid = TRANSITIONS[from];
303
313
  if (!valid?.includes(targetPhase)) return res.status(400).json({ error: `Invalid transition: ${from} → ${targetPhase}` });
304
314
 
315
+ // COMP-MCP-ENFORCE: verdict-gate the skip (fail-closed) before mutating.
316
+ if (guardEnabled) {
317
+ const g = await guardedTransition({ featureCode: item.lifecycle.featureCode, from, to: targetPhase, workspaceRoot: projectRoot, resolvedBy: 'agent' });
318
+ if (!g.applied) return res.status(422).json({ error: 'transition refused by guard', from, to: targetPhase, verdict: g.verdict, guardError: g.error });
319
+ }
320
+
305
321
  item.lifecycle.currentPhase = targetPhase;
306
322
  // COMP-OBS-TIMELINE: populate phaseHistory + emit phase_transition DecisionEvent
307
323
  const now = new Date().toISOString();
@@ -323,7 +339,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
323
339
  }
324
340
  });
325
341
 
326
- app.post('/api/vision/items/:id/lifecycle/kill', (req, res) => {
342
+ app.post('/api/vision/items/:id/lifecycle/kill', async (req, res) => {
327
343
  try {
328
344
  const { reason } = req.body;
329
345
  const item = store.items.get(req.params.id);
@@ -331,6 +347,15 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
331
347
  const from = item.lifecycle.currentPhase;
332
348
  if (TERMINAL.has(from)) return res.status(400).json({ error: `Cannot kill from terminal state: ${from}` });
333
349
 
350
+ // COMP-MCP-ENFORCE: record the kill in the tamper-evident ledger (fail-closed).
351
+ // The `<from>→killed` edge has no predicate, so it only fails if the guard
352
+ // is unreachable — consistent with advance/skip/complete. Authorized
353
+ // bypass remains stratum_guard_override (Slice 3).
354
+ if (guardEnabled) {
355
+ const g = await guardedTransition({ featureCode: item.lifecycle.featureCode, from, to: 'killed', workspaceRoot: projectRoot, resolvedBy: 'agent' });
356
+ if (!g.applied) return res.status(422).json({ error: 'kill refused by guard', from, to: 'killed', verdict: g.verdict, guardError: g.error });
357
+ }
358
+
334
359
  const now = new Date().toISOString();
335
360
  item.lifecycle.currentPhase = 'killed';
336
361
  item.lifecycle.killedAt = now;
@@ -363,6 +388,14 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
363
388
  return res.status(400).json({ error: `Can only complete from ship phase, currently in: ${item.lifecycle.currentPhase}` });
364
389
  }
365
390
 
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.
394
+ if (guardEnabled) {
395
+ const g = await guardedTransition({ featureCode: item.lifecycle.featureCode, from: 'ship', to: 'complete', workspaceRoot: projectRoot, commitSha: req.body?.commit_sha, resolvedBy: 'agent' });
396
+ if (!g.applied) return res.status(422).json({ error: 'completion refused by guard', from: 'ship', to: 'complete', verdict: g.verdict, guardError: g.error });
397
+ }
398
+
366
399
  const now = new Date().toISOString();
367
400
  item.lifecycle.currentPhase = 'complete';
368
401
  item.lifecycle.completedAt = now;
@@ -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 ─────────────────────────────────────────────