@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.
- package/lib/feature-writer.js +6 -1
- package/package.json +1 -1
- package/server/compose-mcp-tools.js +110 -1
- package/server/lifecycle-guard.js +337 -0
- package/server/stratum-client.js +140 -0
- package/server/vision-routes.js +112 -30
- package/server/vision-server.js +2 -0
package/lib/feature-writer.js
CHANGED
|
@@ -311,7 +311,11 @@ export async function setFeatureStatus(cwd, args) {
|
|
|
311
311
|
}
|
|
312
312
|
|
|
313
313
|
const allowed = TRANSITIONS[from] ?? [];
|
|
314
|
-
|
|
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
|
+
"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
|
+
}
|
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,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
|
-
|
|
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']);
|
|
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
|
-
|
|
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' });
|
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 ─────────────────────────────────────────────
|