@smartmemory/compose 0.2.4-beta → 0.2.6-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 +112 -0
- package/server/vision-routes.js +67 -18
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.6-beta",
|
|
4
4
|
"description": "Structured AI dev pipeline — goal-to-product orchestration with gates, iteration loops, and feature lifecycle management.",
|
|
5
5
|
"author": "SmartMemory",
|
|
6
6
|
"license": "MIT",
|
|
@@ -9,7 +9,108 @@ import fs from 'node:fs';
|
|
|
9
9
|
import http from 'node:http';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { ArtifactManager, ARTIFACT_SCHEMAS } from './artifact-manager.js';
|
|
12
|
-
import { getTargetRoot, getDataDir, resolveProjectPath, switchProject, setCurrentWorkspaceId } from './project-root.js';
|
|
12
|
+
import { getTargetRoot, getDataDir, resolveProjectPath, switchProject, setCurrentWorkspaceId, loadProjectConfig } from './project-root.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* COMP-MCP-ENFORCE Slice 3 — kill the `force` escape hatch at the MCP tool
|
|
16
|
+
* boundary. When capabilities.guard is on, a caller-supplied force:true on a
|
|
17
|
+
* status/roadmap mutation is the bypass STRAT-GUARD exists to close, so it is
|
|
18
|
+
* rejected unless it carries a valid out-of-band override token (the agent
|
|
19
|
+
* cannot mint it). Guard off → legacy behavior (no-op). Internal callers
|
|
20
|
+
* (recordCompletion → setFeatureStatus directly) never pass through here.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} args tool args (may carry force / override_token)
|
|
23
|
+
* @param {string} toolName for the error message
|
|
24
|
+
* @param {{guard?: boolean}} [capsOverride] test seam; otherwise read from config
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* COMP-MCP-ENFORCE Slice 3 — close the record_completion bypass. record_completion
|
|
28
|
+
* is a public MCP tool that flips status to COMPLETE; under capabilities.guard it
|
|
29
|
+
* must satisfy the SAME evidence as /lifecycle/complete (real commit + attested
|
|
30
|
+
* tests), else a rogue client could complete a feature without a guard verdict or
|
|
31
|
+
* evidence. Guard off → legacy behavior (no-op).
|
|
32
|
+
*
|
|
33
|
+
* @param {object} args record_completion args (commit_sha, tests_pass)
|
|
34
|
+
* @param {{guard?: boolean}} [capsOverride] test seam
|
|
35
|
+
* @param {string} [cwd]
|
|
36
|
+
*/
|
|
37
|
+
export async function assertCompletionEvidence(args, capsOverride, cwd = getTargetRoot()) {
|
|
38
|
+
let guardOn;
|
|
39
|
+
if (capsOverride && typeof capsOverride.guard === 'boolean') {
|
|
40
|
+
guardOn = capsOverride.guard;
|
|
41
|
+
} else {
|
|
42
|
+
try { guardOn = loadProjectConfig()?.capabilities?.guard === true; } catch { guardOn = false; }
|
|
43
|
+
}
|
|
44
|
+
if (!guardOn) return;
|
|
45
|
+
const { verifyCompletionEvidence, guardTestCommand } = await import('./lifecycle-guard.js');
|
|
46
|
+
const ev = await verifyCompletionEvidence({
|
|
47
|
+
commitSha: args?.commit_sha,
|
|
48
|
+
cwd,
|
|
49
|
+
testCommand: guardTestCommand(cwd),
|
|
50
|
+
testsPassClaim: args?.tests_pass,
|
|
51
|
+
});
|
|
52
|
+
if (!ev.ok) {
|
|
53
|
+
const e = new Error(
|
|
54
|
+
`record_completion: completion evidence not satisfied under capabilities.guard: ${ev.reasons.join('; ')}`,
|
|
55
|
+
);
|
|
56
|
+
e.code = 'COMPLETION_EVIDENCE_REQUIRED';
|
|
57
|
+
throw e;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Terminal statuses owned by the lifecycle (projected from phase, Slice 2). */
|
|
62
|
+
const LIFECYCLE_OWNED_STATUS = new Set(['COMPLETE', 'KILLED']);
|
|
63
|
+
|
|
64
|
+
function _guardOn(capsOverride) {
|
|
65
|
+
if (capsOverride && typeof capsOverride.guard === 'boolean') return capsOverride.guard;
|
|
66
|
+
try { return loadProjectConfig()?.capabilities?.guard === true; } catch { return false; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** True iff a valid, non-agent-mintable override token accompanies the call. */
|
|
70
|
+
function _overrideOk(args) {
|
|
71
|
+
const expected = process.env.STRATUM_GUARD_OVERRIDE_TOKEN;
|
|
72
|
+
return !!expected && args?.override_token === expected;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function assertForceAuthorized(args, toolName, capsOverride) {
|
|
76
|
+
if (!args?.force) return;
|
|
77
|
+
if (!_guardOn(capsOverride)) return;
|
|
78
|
+
if (!_overrideOk(args)) {
|
|
79
|
+
const e = new Error(
|
|
80
|
+
`${toolName}: force is disabled under capabilities.guard — supply a valid override_token ` +
|
|
81
|
+
`(out-of-band STRATUM_GUARD_OVERRIDE_TOKEN; not agent-mintable) to deviate, or drive the ` +
|
|
82
|
+
`change through the lifecycle.`,
|
|
83
|
+
);
|
|
84
|
+
e.code = 'FORCE_REQUIRES_OVERRIDE';
|
|
85
|
+
throw e;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* COMP-MCP-ENFORCE Slice 3 — terminal statuses (COMPLETE/KILLED) are owned by the
|
|
91
|
+
* lifecycle (Slice 2: status is a projection of phase). Under capabilities.guard,
|
|
92
|
+
* a public MCP caller cannot set/mint them directly — that would bypass the
|
|
93
|
+
* evidence-gated /lifecycle/complete and the guarded /lifecycle/kill. The single
|
|
94
|
+
* authorized escape is an out-of-band override token. Guard off → legacy.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} args carries `status` (set_feature_status / add_roadmap_entry)
|
|
97
|
+
* @param {string} toolName
|
|
98
|
+
* @param {{guard?: boolean}} [capsOverride] test seam
|
|
99
|
+
*/
|
|
100
|
+
export function assertTerminalStatusAuthorized(args, toolName, capsOverride) {
|
|
101
|
+
const status = args?.status;
|
|
102
|
+
if (!status || !LIFECYCLE_OWNED_STATUS.has(status)) return;
|
|
103
|
+
if (!_guardOn(capsOverride)) return;
|
|
104
|
+
if (!_overrideOk(args)) {
|
|
105
|
+
const e = new Error(
|
|
106
|
+
`${toolName}: status ${status} is lifecycle-owned under capabilities.guard — drive it through ` +
|
|
107
|
+
`/lifecycle (evidence-gated for complete, guarded for kill) instead of setting it directly, ` +
|
|
108
|
+
`or supply a valid override_token.`,
|
|
109
|
+
);
|
|
110
|
+
e.code = 'STATUS_OWNED_BY_LIFECYCLE';
|
|
111
|
+
throw e;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
13
114
|
import { resolveWorkspace } from '../lib/resolve-workspace.js';
|
|
14
115
|
import { discoverWorkspaces } from '../lib/discover-workspaces.js';
|
|
15
116
|
|
|
@@ -195,11 +296,15 @@ export async function toolGetCurrentSession({ featureCode } = {}) {
|
|
|
195
296
|
// ---------------------------------------------------------------------------
|
|
196
297
|
|
|
197
298
|
export async function toolAddRoadmapEntry(args) {
|
|
299
|
+
assertForceAuthorized(args, 'add_roadmap_entry');
|
|
300
|
+
assertTerminalStatusAuthorized(args, 'add_roadmap_entry');
|
|
198
301
|
const { addRoadmapEntry } = await import('../lib/feature-writer.js');
|
|
199
302
|
return addRoadmapEntry(getTargetRoot(), args);
|
|
200
303
|
}
|
|
201
304
|
|
|
202
305
|
export async function toolSetFeatureStatus(args) {
|
|
306
|
+
assertForceAuthorized(args, 'set_feature_status');
|
|
307
|
+
assertTerminalStatusAuthorized(args, 'set_feature_status');
|
|
203
308
|
const { setFeatureStatus } = await import('../lib/feature-writer.js');
|
|
204
309
|
return setFeatureStatus(getTargetRoot(), args);
|
|
205
310
|
}
|
|
@@ -234,6 +339,9 @@ export async function toolGetFeatureLinks(args) {
|
|
|
234
339
|
// ---------------------------------------------------------------------------
|
|
235
340
|
|
|
236
341
|
export async function toolProposeFollowup(args) {
|
|
342
|
+
// propose_followup also accepts a caller-supplied `status` and routes to
|
|
343
|
+
// addRoadmapEntry — gate lifecycle-owned terminal statuses the same way.
|
|
344
|
+
assertTerminalStatusAuthorized(args, 'propose_followup');
|
|
237
345
|
const { proposeFollowup } = await import('../lib/followup-writer.js');
|
|
238
346
|
return proposeFollowup(getTargetRoot(), args);
|
|
239
347
|
}
|
|
@@ -301,6 +409,7 @@ export async function toolGetJournalEntries(args) {
|
|
|
301
409
|
// ---------------------------------------------------------------------------
|
|
302
410
|
|
|
303
411
|
export async function toolRecordCompletion(args) {
|
|
412
|
+
await assertCompletionEvidence(args);
|
|
304
413
|
const { recordCompletion } = await import('../lib/completion-writer.js');
|
|
305
414
|
return recordCompletion(getTargetRoot(), args);
|
|
306
415
|
}
|
|
@@ -14,12 +14,14 @@
|
|
|
14
14
|
|
|
15
15
|
import { createHash } from 'node:crypto';
|
|
16
16
|
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
17
18
|
import path from 'node:path';
|
|
18
19
|
|
|
19
20
|
import {
|
|
20
21
|
guardRegister as _guardRegister,
|
|
21
22
|
guardTransition as _guardTransition,
|
|
22
23
|
} from './stratum-client.js';
|
|
24
|
+
import { setFeatureStatus as _setFeatureStatus } from '../lib/feature-writer.js';
|
|
23
25
|
|
|
24
26
|
// ---------------------------------------------------------------------------
|
|
25
27
|
// Canonical phase graph (compose-owned data — single source of truth)
|
|
@@ -108,6 +110,100 @@ export function resourceId(featureCode, workspaceRoot) {
|
|
|
108
110
|
return `compose:${hash}:${featureCode}`;
|
|
109
111
|
}
|
|
110
112
|
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Lifecycle-as-truth: roadmap STATUS is a projection of lifecycle phase (Slice 2)
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Project a lifecycle phase onto the roadmap STATUS enum. The lifecycle is the
|
|
119
|
+
* source of truth; STATUS is derived. `complete`/`killed` are terminal; every
|
|
120
|
+
* active phase (explore_design…ship) is IN_PROGRESS. PLANNED is the PRE-lifecycle
|
|
121
|
+
* state (no projection needed); BLOCKED/PARKED/PARTIAL/SUPERSEDED have no phase
|
|
122
|
+
* and stay set_feature_status's domain.
|
|
123
|
+
*/
|
|
124
|
+
export function phaseToStatus(phase) {
|
|
125
|
+
if (phase === 'complete') return 'COMPLETE';
|
|
126
|
+
if (phase === 'killed') return 'KILLED';
|
|
127
|
+
return 'IN_PROGRESS';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let _statusWriter = _setFeatureStatus;
|
|
131
|
+
/** @internal test seam */
|
|
132
|
+
export function _testOnly_setStatusWriter(fn) { _statusWriter = fn; }
|
|
133
|
+
/** @internal test seam */
|
|
134
|
+
export function _testOnly_resetStatusWriter() { _statusWriter = _setFeatureStatus; }
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Write the phase-projected STATUS through to feature.json (closes the
|
|
138
|
+
* COMP-PARITY-7 one-way-sync gap for the lifecycle-driven path). Best-effort:
|
|
139
|
+
* a missing feature or a writer error is captured and returned, never thrown —
|
|
140
|
+
* status projection must not roll back a lifecycle transition that already
|
|
141
|
+
* applied. setFeatureStatus is itself idempotent (from===to → noop), so calling
|
|
142
|
+
* it on every transition only writes on a real status change.
|
|
143
|
+
*/
|
|
144
|
+
export async function projectFeatureStatus({ featureCode, phase, cwd, commitSha }) {
|
|
145
|
+
if (!featureCode) return { skipped: true };
|
|
146
|
+
const status = phaseToStatus(phase);
|
|
147
|
+
try {
|
|
148
|
+
// derived:true — the lifecycle is authoritative, so the roadmap transition
|
|
149
|
+
// table does not gate this projection (e.g. PARKED→IN_PROGRESS on resume).
|
|
150
|
+
const args = { code: featureCode, status, reason: `lifecycle:${phase}`, derived: true };
|
|
151
|
+
if (commitSha) args.commit_sha = commitSha;
|
|
152
|
+
const result = await _statusWriter(cwd, args);
|
|
153
|
+
return { status, result };
|
|
154
|
+
} catch (e) {
|
|
155
|
+
return { status, error: e.message };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Evidence-bound completion (Slice 3)
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/** True if `sha` resolves to a real commit object in the repo at `cwd`. */
|
|
164
|
+
function gitCommitExists(sha, cwd) {
|
|
165
|
+
const r = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`],
|
|
166
|
+
{ cwd, encoding: 'utf8' });
|
|
167
|
+
return r.status === 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Verify the evidence required to complete a feature under the guard — the
|
|
172
|
+
* substrate confirms it, not a caller boolean (the design's "trusted evidence"
|
|
173
|
+
* principle, evaluated compose-side because compose owns the repo + test runner):
|
|
174
|
+
* - `commit_sha` must exist as a real git commit (server-read, not syntax).
|
|
175
|
+
* - tests must be ATTESTED: a configured `testCommand` exits 0 (real exit code),
|
|
176
|
+
* OR `testsPassClaim` is explicitly true. There is NO silent default-to-true.
|
|
177
|
+
*
|
|
178
|
+
* @returns {Promise<{ok:boolean, reasons:string[], testsAttested:boolean}>}
|
|
179
|
+
*/
|
|
180
|
+
export async function verifyCompletionEvidence({ commitSha, cwd, testCommand, testsPassClaim }) {
|
|
181
|
+
const reasons = [];
|
|
182
|
+
|
|
183
|
+
if (!commitSha || typeof commitSha !== 'string' || !commitSha.trim()) {
|
|
184
|
+
reasons.push('commit_sha is required for evidence-bound completion');
|
|
185
|
+
} else if (!gitCommitExists(commitSha.trim(), cwd)) {
|
|
186
|
+
reasons.push(`commit ${commitSha.trim()} not found in repository (server-read git verification)`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let testsAttested = false;
|
|
190
|
+
if (Array.isArray(testCommand) && testCommand.length > 0) {
|
|
191
|
+
const [bin, ...rest] = testCommand;
|
|
192
|
+
const r = spawnSync(bin, rest, { cwd, encoding: 'utf8' });
|
|
193
|
+
if (r.error) {
|
|
194
|
+
reasons.push(`test command failed to run: ${r.error.message}`);
|
|
195
|
+
} else if (r.status !== 0) {
|
|
196
|
+
reasons.push(`test command exited ${r.status} (not 0)`);
|
|
197
|
+
} else {
|
|
198
|
+
testsAttested = true;
|
|
199
|
+
}
|
|
200
|
+
} else if (testsPassClaim !== true) {
|
|
201
|
+
reasons.push('tests_pass must be explicitly true (no configured test command to attest test results)');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { ok: reasons.length === 0, reasons, testsAttested };
|
|
205
|
+
}
|
|
206
|
+
|
|
111
207
|
// ---------------------------------------------------------------------------
|
|
112
208
|
// Guard client (injectable for tests)
|
|
113
209
|
// ---------------------------------------------------------------------------
|
|
@@ -138,6 +234,22 @@ function _featureRelDir(featureCode, workspaceRoot) {
|
|
|
138
234
|
return `${featuresRel}/${featureCode}`;
|
|
139
235
|
}
|
|
140
236
|
|
|
237
|
+
/**
|
|
238
|
+
* The configured evidence-bound-completion test command (array form, e.g.
|
|
239
|
+
* ["npm","test"]) from `<workspaceRoot>/.compose/compose.json` `guard.testCommand`,
|
|
240
|
+
* or null when unconfigured. When null, evidence-bound completion falls back to
|
|
241
|
+
* requiring an explicit tests_pass=true (still no silent default).
|
|
242
|
+
*/
|
|
243
|
+
export function guardTestCommand(workspaceRoot) {
|
|
244
|
+
try {
|
|
245
|
+
const cfg = JSON.parse(readFileSync(path.join(workspaceRoot, '.compose', 'compose.json'), 'utf-8'));
|
|
246
|
+
const cmd = cfg?.guard?.testCommand;
|
|
247
|
+
return Array.isArray(cmd) && cmd.length > 0 ? cmd : null;
|
|
248
|
+
} catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
141
253
|
/**
|
|
142
254
|
* Idempotently register the feature as a guarded resource, seeding `initial`
|
|
143
255
|
* from the item's CURRENT phase (so items already mid-lifecycle at rollout don't
|
package/server/vision-routes.js
CHANGED
|
@@ -53,8 +53,10 @@ import { appendGateLogEntry, readGateLog, mapResolveOutcomeToSchema } from './ga
|
|
|
53
53
|
import { addOpenLoop, resolveOpenLoop, listOpenLoops } from './open-loops-store.js';
|
|
54
54
|
import {
|
|
55
55
|
BASE_TRANSITIONS, SKIPPABLE, TERMINAL,
|
|
56
|
-
guardedTransition, ensureGuard,
|
|
56
|
+
guardedTransition, ensureGuard, projectFeatureStatus,
|
|
57
|
+
verifyCompletionEvidence, guardTestCommand,
|
|
57
58
|
} from './lifecycle-guard.js';
|
|
59
|
+
import { requireSensitiveToken } from './security.js';
|
|
58
60
|
|
|
59
61
|
const PROJECT_ROOT = getTargetRoot();
|
|
60
62
|
|
|
@@ -68,6 +70,19 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
68
70
|
// COMP-MCP-ENFORCE: when enabled, lifecycle transitions are verdict-gated by
|
|
69
71
|
// stratum's STRAT-GUARD (fail-closed). Default OFF — legacy behavior intact.
|
|
70
72
|
const guardEnabled = capabilities?.guard === true;
|
|
73
|
+
|
|
74
|
+
// COMP-MCP-ENFORCE Slice 4: opt-in loopback REST auth on vision MUTATION
|
|
75
|
+
// endpoints (lifecycle transitions, iterations, gate resolve, item CRUD,
|
|
76
|
+
// branch-lineage). Default OFF (the cockpit does not yet send the token, so
|
|
77
|
+
// forcing it would break the UI) — defense-in-depth for headless/CI surfaces.
|
|
78
|
+
// Reads stay open. Fail-closed: when guardAuth is on, mutations require
|
|
79
|
+
// x-compose-token; if COMPOSE_API_TOKEN is NOT configured, requireSensitiveToken
|
|
80
|
+
// returns 503 (mutations disabled) rather than silently allowing them — enabling
|
|
81
|
+
// auth without a token is a misconfiguration, not an open door. Pass-through
|
|
82
|
+
// when off, so route wiring is unconditional.
|
|
83
|
+
const guardAuthEnabled = capabilities?.guardAuth === true;
|
|
84
|
+
const guardAuth = (req, res, next) =>
|
|
85
|
+
guardAuthEnabled ? requireSensitiveToken(req, res, next) : next();
|
|
71
86
|
// GET /api/vision/items — full state (optional ?group= filter)
|
|
72
87
|
app.get('/api/vision/items', (req, res) => {
|
|
73
88
|
let state = store.getState();
|
|
@@ -78,7 +93,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
78
93
|
});
|
|
79
94
|
|
|
80
95
|
// POST /api/vision/items — create item
|
|
81
|
-
app.post('/api/vision/items', (req, res) => {
|
|
96
|
+
app.post('/api/vision/items', guardAuth, (req, res) => {
|
|
82
97
|
try {
|
|
83
98
|
const item = store.createItem(req.body);
|
|
84
99
|
scheduleBroadcast();
|
|
@@ -89,7 +104,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
89
104
|
});
|
|
90
105
|
|
|
91
106
|
// PATCH /api/vision/items/:id — update item
|
|
92
|
-
app.patch('/api/vision/items/:id', (req, res) => {
|
|
107
|
+
app.patch('/api/vision/items/:id', guardAuth, (req, res) => {
|
|
93
108
|
try {
|
|
94
109
|
const item = store.updateItem(req.params.id, req.body);
|
|
95
110
|
// If group changed, write back to docs/features/<code>/feature.json
|
|
@@ -110,7 +125,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
110
125
|
});
|
|
111
126
|
|
|
112
127
|
// DELETE /api/vision/items/:id — delete item + connections
|
|
113
|
-
app.delete('/api/vision/items/:id', (req, res) => {
|
|
128
|
+
app.delete('/api/vision/items/:id', guardAuth, (req, res) => {
|
|
114
129
|
try {
|
|
115
130
|
store.deleteItem(req.params.id);
|
|
116
131
|
scheduleBroadcast();
|
|
@@ -189,7 +204,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
189
204
|
}
|
|
190
205
|
});
|
|
191
206
|
|
|
192
|
-
app.post('/api/vision/items/:id/lifecycle/start', async (req, res) => {
|
|
207
|
+
app.post('/api/vision/items/:id/lifecycle/start', guardAuth, async (req, res) => {
|
|
193
208
|
try {
|
|
194
209
|
const { featureCode } = req.body;
|
|
195
210
|
if (!featureCode) return res.status(400).json({ error: 'featureCode is required' });
|
|
@@ -216,6 +231,9 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
216
231
|
if (guardEnabled) {
|
|
217
232
|
try { await ensureGuard(featureCode, 'explore_design', projectRoot); }
|
|
218
233
|
catch (e) { console.warn(`[lifecycle/start] guard register for ${featureCode} failed: ${e.message}`); }
|
|
234
|
+
// Slice 2: starting a lifecycle projects explore_design → IN_PROGRESS so
|
|
235
|
+
// the first active phase is not left stuck at PLANNED in feature.json.
|
|
236
|
+
await projectFeatureStatus({ featureCode, phase: 'explore_design', cwd: projectRoot });
|
|
219
237
|
}
|
|
220
238
|
scheduleBroadcast();
|
|
221
239
|
broadcastMessage({ type: 'lifecycleStarted', itemId: req.params.id, phase: 'explore_design', featureCode, timestamp: now });
|
|
@@ -233,7 +251,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
233
251
|
});
|
|
234
252
|
|
|
235
253
|
// COMP-OBS-BRANCH: accept BranchLineage payloads from the CC-session watcher.
|
|
236
|
-
app.post('/api/vision/items/:id/lifecycle/branch-lineage', (req, res) => {
|
|
254
|
+
app.post('/api/vision/items/:id/lifecycle/branch-lineage', guardAuth, (req, res) => {
|
|
237
255
|
try {
|
|
238
256
|
const item = store.items.get(req.params.id);
|
|
239
257
|
if (!item) return res.status(404).json({ error: `Item not found: ${req.params.id}` });
|
|
@@ -264,7 +282,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
264
282
|
}
|
|
265
283
|
});
|
|
266
284
|
|
|
267
|
-
app.post('/api/vision/items/:id/lifecycle/advance', async (req, res) => {
|
|
285
|
+
app.post('/api/vision/items/:id/lifecycle/advance', guardAuth, async (req, res) => {
|
|
268
286
|
try {
|
|
269
287
|
const { targetPhase, outcome } = req.body;
|
|
270
288
|
const item = store.items.get(req.params.id);
|
|
@@ -285,6 +303,9 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
285
303
|
const now = new Date().toISOString();
|
|
286
304
|
appendPhaseHistory(item, { from, to: targetPhase, outcome: outcome ?? null, timestamp: now });
|
|
287
305
|
store.updateLifecycle(req.params.id, item.lifecycle);
|
|
306
|
+
// COMP-MCP-ENFORCE Slice 2 (lifecycle-as-truth): project the new phase onto
|
|
307
|
+
// feature.json STATUS (best-effort; idempotent — only writes on a real change).
|
|
308
|
+
if (guardEnabled) await projectFeatureStatus({ featureCode: item.lifecycle.featureCode, phase: targetPhase, cwd: projectRoot });
|
|
288
309
|
scheduleBroadcast();
|
|
289
310
|
broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from, to: targetPhase, outcome, timestamp: now });
|
|
290
311
|
emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode: item.lifecycle.featureCode, from, to: targetPhase, outcome, timestamp: now }));
|
|
@@ -301,7 +322,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
301
322
|
}
|
|
302
323
|
});
|
|
303
324
|
|
|
304
|
-
app.post('/api/vision/items/:id/lifecycle/skip', async (req, res) => {
|
|
325
|
+
app.post('/api/vision/items/:id/lifecycle/skip', guardAuth, async (req, res) => {
|
|
305
326
|
try {
|
|
306
327
|
const { targetPhase, reason } = req.body;
|
|
307
328
|
const item = store.items.get(req.params.id);
|
|
@@ -323,6 +344,8 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
323
344
|
const now = new Date().toISOString();
|
|
324
345
|
appendPhaseHistory(item, { from, to: targetPhase, outcome: 'skipped', timestamp: now });
|
|
325
346
|
store.updateLifecycle(req.params.id, item.lifecycle);
|
|
347
|
+
// COMP-MCP-ENFORCE Slice 2 (lifecycle-as-truth): project phase → STATUS.
|
|
348
|
+
if (guardEnabled) await projectFeatureStatus({ featureCode: item.lifecycle.featureCode, phase: targetPhase, cwd: projectRoot });
|
|
326
349
|
scheduleBroadcast();
|
|
327
350
|
broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from, to: targetPhase, outcome: 'skipped', timestamp: now });
|
|
328
351
|
emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode: item.lifecycle.featureCode, from, to: targetPhase, outcome: 'skipped', timestamp: now }));
|
|
@@ -339,7 +362,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
339
362
|
}
|
|
340
363
|
});
|
|
341
364
|
|
|
342
|
-
app.post('/api/vision/items/:id/lifecycle/kill', async (req, res) => {
|
|
365
|
+
app.post('/api/vision/items/:id/lifecycle/kill', guardAuth, async (req, res) => {
|
|
343
366
|
try {
|
|
344
367
|
const { reason } = req.body;
|
|
345
368
|
const item = store.items.get(req.params.id);
|
|
@@ -364,6 +387,9 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
364
387
|
appendPhaseHistory(item, { from, to: 'killed', outcome: 'killed', timestamp: now });
|
|
365
388
|
store.updateLifecycle(req.params.id, item.lifecycle);
|
|
366
389
|
store.updateItem(req.params.id, { status: 'killed' });
|
|
390
|
+
// COMP-MCP-ENFORCE Slice 2: project kill → KILLED onto feature.json
|
|
391
|
+
// (closes the COMP-PARITY-7 gap — kill previously wrote vision-state only).
|
|
392
|
+
if (guardEnabled) await projectFeatureStatus({ featureCode: item.lifecycle.featureCode, phase: 'killed', cwd: projectRoot });
|
|
367
393
|
scheduleBroadcast();
|
|
368
394
|
broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from, to: 'killed', outcome: 'killed', timestamp: now });
|
|
369
395
|
emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode: item.lifecycle.featureCode, from, to: 'killed', outcome: 'killed', timestamp: now }));
|
|
@@ -380,7 +406,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
380
406
|
}
|
|
381
407
|
});
|
|
382
408
|
|
|
383
|
-
app.post('/api/vision/items/:id/lifecycle/complete', async (req, res) => {
|
|
409
|
+
app.post('/api/vision/items/:id/lifecycle/complete', guardAuth, async (req, res) => {
|
|
384
410
|
try {
|
|
385
411
|
const item = store.items.get(req.params.id);
|
|
386
412
|
if (!item?.lifecycle) return res.status(404).json({ error: 'No lifecycle on this item' });
|
|
@@ -388,10 +414,24 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
388
414
|
return res.status(400).json({ error: `Can only complete from ship phase, currently in: ${item.lifecycle.currentPhase}` });
|
|
389
415
|
}
|
|
390
416
|
|
|
391
|
-
// COMP-MCP-ENFORCE:
|
|
392
|
-
//
|
|
393
|
-
// git
|
|
417
|
+
// COMP-MCP-ENFORCE Slice 3: evidence-bound completion. Under the guard,
|
|
418
|
+
// ship→complete requires REAL evidence — the commit must exist (server-read
|
|
419
|
+
// git) and tests must be attested (configured test command exits 0, or
|
|
420
|
+
// tests_pass is explicitly true; never a silent default). Then the guard
|
|
421
|
+
// verdict gates the transition (fail-closed).
|
|
422
|
+
let verifiedTestsPass = req.body?.tests_pass;
|
|
394
423
|
if (guardEnabled) {
|
|
424
|
+
const ev = await verifyCompletionEvidence({
|
|
425
|
+
commitSha: req.body?.commit_sha,
|
|
426
|
+
cwd: projectRoot,
|
|
427
|
+
testCommand: guardTestCommand(projectRoot),
|
|
428
|
+
testsPassClaim: req.body?.tests_pass,
|
|
429
|
+
});
|
|
430
|
+
if (!ev.ok) {
|
|
431
|
+
return res.status(422).json({ error: 'completion evidence not satisfied', reasons: ev.reasons });
|
|
432
|
+
}
|
|
433
|
+
verifiedTestsPass = ev.testsAttested ? true : (req.body?.tests_pass === true);
|
|
434
|
+
|
|
395
435
|
const g = await guardedTransition({ featureCode: item.lifecycle.featureCode, from: 'ship', to: 'complete', workspaceRoot: projectRoot, commitSha: req.body?.commit_sha, resolvedBy: 'agent' });
|
|
396
436
|
if (!g.applied) return res.status(422).json({ error: 'completion refused by guard', from: 'ship', to: 'complete', verdict: g.verdict, guardError: g.error });
|
|
397
437
|
}
|
|
@@ -403,6 +443,10 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
403
443
|
appendPhaseHistory(item, { from: 'ship', to: 'complete', outcome: 'approved', timestamp: now });
|
|
404
444
|
store.updateLifecycle(req.params.id, item.lifecycle);
|
|
405
445
|
store.updateItem(req.params.id, { status: 'complete' });
|
|
446
|
+
// COMP-MCP-ENFORCE Slice 2: status projection for the no-commit path is
|
|
447
|
+
// applied BELOW (in the `else` branch) so it does not pre-empt and mask the
|
|
448
|
+
// recordCompletion bridge, which is the authority + partial-write reporter
|
|
449
|
+
// on the commit_sha path.
|
|
406
450
|
scheduleBroadcast();
|
|
407
451
|
broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from: 'ship', to: 'complete', outcome: 'approved', timestamp: now });
|
|
408
452
|
emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode: item.lifecycle.featureCode, from: 'ship', to: 'complete', outcome: 'approved', timestamp: now }));
|
|
@@ -426,7 +470,9 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
426
470
|
await recordCompletion(projectRoot, {
|
|
427
471
|
feature_code: featureCode,
|
|
428
472
|
commit_sha,
|
|
429
|
-
|
|
473
|
+
// Slice 3: under the guard, tests_pass reflects verified evidence
|
|
474
|
+
// (attested or explicit), NOT a silent default-to-true.
|
|
475
|
+
tests_pass: guardEnabled ? (verifiedTestsPass === true) : (tests_pass ?? true),
|
|
430
476
|
files_changed: files_changed ?? [],
|
|
431
477
|
notes: notes ?? `cockpit lifecycle: ${featureCode} complete`,
|
|
432
478
|
});
|
|
@@ -458,6 +504,9 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
458
504
|
reason: 'no_commit_sha',
|
|
459
505
|
});
|
|
460
506
|
} catch { /* decision event emit best-effort */ }
|
|
507
|
+
// No recordCompletion bridge ran — project complete → COMPLETE so
|
|
508
|
+
// lifecycle-as-truth still reaches feature.json (best-effort).
|
|
509
|
+
if (guardEnabled) await projectFeatureStatus({ featureCode, phase: 'complete', cwd: projectRoot });
|
|
461
510
|
}
|
|
462
511
|
}
|
|
463
512
|
|
|
@@ -470,7 +519,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
470
519
|
|
|
471
520
|
// ── Iteration loop endpoints ──────────────────────────────────────────
|
|
472
521
|
|
|
473
|
-
app.post('/api/vision/items/:id/lifecycle/iteration/start', (req, res) => {
|
|
522
|
+
app.post('/api/vision/items/:id/lifecycle/iteration/start', guardAuth, (req, res) => {
|
|
474
523
|
try {
|
|
475
524
|
const item = store.items.get(req.params.id);
|
|
476
525
|
if (!item?.lifecycle) return res.status(404).json({ error: 'No lifecycle on this item' });
|
|
@@ -535,7 +584,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
535
584
|
}
|
|
536
585
|
});
|
|
537
586
|
|
|
538
|
-
app.post('/api/vision/items/:id/lifecycle/iteration/report', (req, res) => {
|
|
587
|
+
app.post('/api/vision/items/:id/lifecycle/iteration/report', guardAuth, (req, res) => {
|
|
539
588
|
try {
|
|
540
589
|
const item = store.items.get(req.params.id);
|
|
541
590
|
if (!item?.lifecycle?.iterationState) return res.status(404).json({ error: 'No iteration loop active' });
|
|
@@ -622,7 +671,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
622
671
|
}
|
|
623
672
|
});
|
|
624
673
|
|
|
625
|
-
app.post('/api/vision/items/:id/lifecycle/iteration/abort', (req, res) => {
|
|
674
|
+
app.post('/api/vision/items/:id/lifecycle/iteration/abort', guardAuth, (req, res) => {
|
|
626
675
|
try {
|
|
627
676
|
const item = store.items.get(req.params.id);
|
|
628
677
|
if (!item?.lifecycle?.iterationState) return res.status(404).json({ error: 'No iteration loop active' });
|
|
@@ -798,7 +847,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
798
847
|
}
|
|
799
848
|
});
|
|
800
849
|
|
|
801
|
-
app.post('/api/vision/gates/:id/resolve', (req, res) => {
|
|
850
|
+
app.post('/api/vision/gates/:id/resolve', guardAuth, (req, res) => {
|
|
802
851
|
try {
|
|
803
852
|
const { outcome: rawOutcome, comment, resolvedBy } = req.body;
|
|
804
853
|
if (!rawOutcome) return res.status(400).json({ error: 'outcome is required' });
|