@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 +1 -1
- package/server/lifecycle-guard.js +225 -0
- package/server/stratum-client.js +140 -0
- package/server/vision-routes.js +53 -20
- package/server/vision-server.js +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smartmemory/compose",
|
|
3
|
-
"version": "0.2.
|
|
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
|
+
}
|
package/server/stratum-client.js
CHANGED
|
@@ -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
|
+
}
|
package/server/vision-routes.js
CHANGED
|
@@ -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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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;
|
package/server/vision-server.js
CHANGED
|
@@ -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 ─────────────────────────────────────────────
|