@smartmemory/compose 0.2.2-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/contracts/checkpoint.schema.json +85 -0
- package/dist/assets/{App-QGVt8tH2.js → App-j8fWZcGr.js} +5 -5
- package/dist/assets/{arc-yX1Dy9Ls.js → arc-BFqOo_jJ.js} +1 -1
- package/dist/assets/{architectureDiagram-3BPJPVTR-BhtVN7Go.js → architectureDiagram-3BPJPVTR-D722w0RE.js} +1 -1
- package/dist/assets/{blockDiagram-GPEHLZMM-Do_uWvAL.js → blockDiagram-GPEHLZMM-B4w0mOAJ.js} +1 -1
- package/dist/assets/{c4Diagram-AAUBKEIU-DhjfNEZ_.js → c4Diagram-AAUBKEIU-D6LE8-j8.js} +1 -1
- package/dist/assets/channel-BD-5_hPW.js +1 -0
- package/dist/assets/{chunk-2J33WTMH-ZLuzLSr5.js → chunk-2J33WTMH-CrazA7xu.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-BkfYx42O.js → chunk-4BX2VUAB-Cp90GiCM.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-UGWHui37.js → chunk-55IACEB6-Bnais1SK.js} +1 -1
- package/dist/assets/{chunk-727SXJPM-DENLKVEd.js → chunk-727SXJPM-kD07Sqp5.js} +1 -1
- package/dist/assets/{chunk-AQP2D5EJ-BV-AIq0h.js → chunk-AQP2D5EJ-DmIxhJc8.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-BO5q1BN_.js → chunk-FMBD7UC4-Jti_und8.js} +1 -1
- package/dist/assets/{chunk-ND2GUHAM-rAAtIsqf.js → chunk-ND2GUHAM-Ipx3noKz.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-DmZo3sCU.js → chunk-QZHKN3VN-CeblRnPF.js} +1 -1
- package/dist/assets/classDiagram-4FO5ZUOK-mSW5R7DY.js +1 -0
- package/dist/assets/classDiagram-v2-Q7XG4LA2-mSW5R7DY.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BlBShGhI.js → cose-bilkent-S5V4N54A-fNQlSmHt.js} +1 -1
- package/dist/assets/{dagre-BM42HDAG-urcL7_B8.js → dagre-BM42HDAG-D27D6YAL.js} +1 -1
- package/dist/assets/{diagram-2AECGRRQ-DlfCvqLR.js → diagram-2AECGRRQ-CtXeohzN.js} +1 -1
- package/dist/assets/{diagram-5GNKFQAL-h5gTsYDo.js → diagram-5GNKFQAL-C_BqZkx0.js} +1 -1
- package/dist/assets/{diagram-KO2AKTUF-BbEbRrVo.js → diagram-KO2AKTUF-B29ynQz4.js} +1 -1
- package/dist/assets/{diagram-LMA3HP47-jog00Zl2.js → diagram-LMA3HP47-DAYJMc2I.js} +1 -1
- package/dist/assets/{diagram-OG6HWLK6-B0JVsR6S.js → diagram-OG6HWLK6-CBJMis3l.js} +1 -1
- package/dist/assets/{erDiagram-TEJ5UH35-DkUnalKg.js → erDiagram-TEJ5UH35-nd3GWiPn.js} +1 -1
- package/dist/assets/{flowDiagram-I6XJVG4X-DewNd_kM.js → flowDiagram-I6XJVG4X-HFUno_nV.js} +1 -1
- package/dist/assets/{ganttDiagram-6RSMTGT7-DzDBcVj5.js → ganttDiagram-6RSMTGT7-CPPAAjwR.js} +1 -1
- package/dist/assets/{gitGraphDiagram-PVQCEYII-D0CX5WgP.js → gitGraphDiagram-PVQCEYII-NBq1F6K2.js} +1 -1
- package/dist/assets/{index-D4GJb_6L.js → index-uHKnp74B.js} +2 -2
- package/dist/assets/{infoDiagram-5YYISTIA-B1zzuW9l.js → infoDiagram-5YYISTIA-D-TOBtCq.js} +1 -1
- package/dist/assets/{ishikawaDiagram-YF4QCWOH-3hFmuv1F.js → ishikawaDiagram-YF4QCWOH-nXOztZiZ.js} +1 -1
- package/dist/assets/{journeyDiagram-JHISSGLW-w9c-l95A.js → journeyDiagram-JHISSGLW-Bko3tTdh.js} +1 -1
- package/dist/assets/{kanban-definition-UN3LZRKU-9cL90JL0.js → kanban-definition-UN3LZRKU-1e-7i8st.js} +1 -1
- package/dist/assets/{linear-DyDb5wz8.js → linear-Dx5ZJB7F.js} +1 -1
- package/dist/assets/{mindmap-definition-RKZ34NQL-DBQqsZiD.js → mindmap-definition-RKZ34NQL-CNwNkDqN.js} +1 -1
- package/dist/assets/{pieDiagram-4H26LBE5-BbIHZku5.js → pieDiagram-4H26LBE5-C5fvCej-.js} +1 -1
- package/dist/assets/{quadrantDiagram-W4KKPZXB-DEQSG_lM.js → quadrantDiagram-W4KKPZXB-4NoQsF61.js} +1 -1
- package/dist/assets/{requirementDiagram-4Y6WPE33-BeVnwIwF.js → requirementDiagram-4Y6WPE33-q5WxB9LO.js} +1 -1
- package/dist/assets/{sankeyDiagram-5OEKKPKP-Be-ROw_I.js → sankeyDiagram-5OEKKPKP-DlQNB367.js} +1 -1
- package/dist/assets/{sequenceDiagram-3UESZ5HK-E-tnxahu.js → sequenceDiagram-3UESZ5HK-BzHclOKt.js} +1 -1
- package/dist/assets/{stateDiagram-AJRCARHV-3rgFN7hL.js → stateDiagram-AJRCARHV-BvWRI9zK.js} +1 -1
- package/dist/assets/stateDiagram-v2-BHNVJYJU-CDlF0VA8.js +1 -0
- package/dist/assets/{timeline-definition-PNZ67QCA-Dcs4QFbE.js → timeline-definition-PNZ67QCA-j2wKjAti.js} +1 -1
- package/dist/assets/{vennDiagram-CIIHVFJN-BstUQ900.js → vennDiagram-CIIHVFJN-B77g7htC.js} +1 -1
- package/dist/assets/{wardley-L42UT6IY-CO77hXwj.js → wardley-L42UT6IY-83Im2mo2.js} +1 -1
- package/dist/assets/{wardleyDiagram-YWT4CUSO-BvrP3shF.js → wardleyDiagram-YWT4CUSO-CK-XB-bO.js} +1 -1
- package/dist/assets/{xychartDiagram-2RQKCTM6-Btu4JcQO.js → xychartDiagram-2RQKCTM6-D42FcVOY.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/checkpoint/anchor.js +66 -0
- package/lib/checkpoint/atomic.js +83 -0
- package/lib/checkpoint/checkpoint-writer.js +131 -0
- package/lib/checkpoint/fingerprint.js +145 -0
- package/lib/checkpoint/git.js +58 -0
- package/lib/checkpoint/prompts.js +206 -0
- package/lib/checkpoint/reconciler.js +207 -0
- package/lib/checkpoint/render.js +107 -0
- package/lib/checkpoint/store/index.js +67 -0
- package/lib/checkpoint/store/jsonl.js +80 -0
- package/package.json +1 -1
- package/server/compose-mcp-tools.js +30 -0
- package/server/compose-mcp.js +40 -0
- package/server/lifecycle-guard.js +225 -0
- package/server/session-routes.js +65 -0
- package/server/stratum-client.js +140 -0
- package/server/vision-routes.js +68 -20
- package/server/vision-server.js +2 -0
- package/dist/assets/channel-D6hNrRZ2.js +0 -1
- package/dist/assets/classDiagram-4FO5ZUOK-DvsVLUph.js +0 -1
- package/dist/assets/classDiagram-v2-Q7XG4LA2-DvsVLUph.js +0 -1
- package/dist/assets/stateDiagram-v2-BHNVJYJU-DY_OtnIg.js +0 -1
|
@@ -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/session-routes.js
CHANGED
|
@@ -9,7 +9,13 @@
|
|
|
9
9
|
* GET /api/session/current
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import path from 'node:path';
|
|
12
13
|
import { readSessionsByFeature } from './session-store.js';
|
|
14
|
+
import { getTargetRoot, getDataDir } from './project-root.js';
|
|
15
|
+
import { appendPhaseHistory } from './lifecycle-phase-history.js';
|
|
16
|
+
import { reconcile } from '../lib/checkpoint/reconciler.js';
|
|
17
|
+
import { checkpointConfig } from '../lib/checkpoint/checkpoint-writer.js';
|
|
18
|
+
import { createCheckpointStore } from '../lib/checkpoint/store/index.js';
|
|
13
19
|
|
|
14
20
|
/**
|
|
15
21
|
* Attach session lifecycle routes to an Express app.
|
|
@@ -112,6 +118,65 @@ export function attachSessionRoutes(app, { sessionManager, scheduleBroadcast, br
|
|
|
112
118
|
}
|
|
113
119
|
});
|
|
114
120
|
|
|
121
|
+
// POST /api/session/bind/reconcile — COMP-RESUME: reconcile a build against
|
|
122
|
+
// ground-truth environment state and return the resume decision. reconcile()
|
|
123
|
+
// is deterministic and computes lifecycleMutations; THIS ROUTE persists them
|
|
124
|
+
// (store.updateLifecycle) and broadcasts — the persistence boundary (Codex #2).
|
|
125
|
+
app.post('/api/session/bind/reconcile', (req, res) => {
|
|
126
|
+
try {
|
|
127
|
+
const { featureCode } = req.body || {};
|
|
128
|
+
if (!featureCode) return res.status(400).json({ error: 'featureCode required' });
|
|
129
|
+
if (!/^[A-Za-z0-9_-]+$/.test(featureCode)) return res.status(400).json({ error: 'Invalid featureCode' });
|
|
130
|
+
|
|
131
|
+
const item = store.getItemByFeatureCode(featureCode);
|
|
132
|
+
if (!item) return res.status(404).json({ error: `No lifecycle item for feature code: ${featureCode}` });
|
|
133
|
+
|
|
134
|
+
const targetRoot = getTargetRoot();
|
|
135
|
+
const dataDir = getDataDir();
|
|
136
|
+
const cfg = checkpointConfig(targetRoot);
|
|
137
|
+
const cpStore = createCheckpointStore(cfg.backend, { dataDir });
|
|
138
|
+
|
|
139
|
+
const result = reconcile({
|
|
140
|
+
featureCode,
|
|
141
|
+
item,
|
|
142
|
+
cwd: targetRoot,
|
|
143
|
+
featureDir: path.join(targetRoot, 'docs', 'features', featureCode),
|
|
144
|
+
composeDir: path.join(targetRoot, '.compose'),
|
|
145
|
+
dataDir,
|
|
146
|
+
store: cpStore,
|
|
147
|
+
confidenceThreshold: cfg.confidenceThreshold,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Apply the mutations reconcile computed (it does not persist itself).
|
|
151
|
+
// v1 reconcile only emits 'phaseHistory.append'; other mutation types
|
|
152
|
+
// (e.g. a future 'currentPhase.set' derived from active-build.json) would
|
|
153
|
+
// be added here together with the reconciler that emits them.
|
|
154
|
+
let mutated = false;
|
|
155
|
+
for (const m of result.lifecycleMutations ?? []) {
|
|
156
|
+
if (m.type === 'phaseHistory.append' && m.entry) {
|
|
157
|
+
appendPhaseHistory(item, m.entry);
|
|
158
|
+
mutated = true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (mutated) {
|
|
162
|
+
store.updateLifecycle(item.id, item.lifecycle);
|
|
163
|
+
broadcastMessage({
|
|
164
|
+
type: 'lifecycleReconciled',
|
|
165
|
+
itemId: item.id,
|
|
166
|
+
featureCode,
|
|
167
|
+
action: result.action,
|
|
168
|
+
drift: result.drift,
|
|
169
|
+
timestamp: new Date().toISOString(),
|
|
170
|
+
});
|
|
171
|
+
scheduleBroadcast();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
res.json(result);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
res.status(500).json({ error: err.message });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
115
180
|
// GET /api/session/history — sessions bound to a feature
|
|
116
181
|
app.get('/api/session/history', (req, res) => {
|
|
117
182
|
try {
|
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
|
@@ -48,8 +48,13 @@ function getSchemaValidator() {
|
|
|
48
48
|
|
|
49
49
|
import { randomUUID } from 'node:crypto';
|
|
50
50
|
import { getTargetRoot, resolveProjectPath, loadProjectConfig } from './project-root.js';
|
|
51
|
+
import { anchorBoundary } from '../lib/checkpoint/checkpoint-writer.js';
|
|
51
52
|
import { appendGateLogEntry, readGateLog, mapResolveOutcomeToSchema } from './gate-log-store.js';
|
|
52
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';
|
|
53
58
|
|
|
54
59
|
const PROJECT_ROOT = getTargetRoot();
|
|
55
60
|
|
|
@@ -57,9 +62,12 @@ const PROJECT_ROOT = getTargetRoot();
|
|
|
57
62
|
* Attach vision CRUD and plan/parse REST routes to an Express app.
|
|
58
63
|
*
|
|
59
64
|
* @param {object} app — Express app
|
|
60
|
-
* @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
|
|
61
66
|
*/
|
|
62
|
-
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;
|
|
63
71
|
// GET /api/vision/items — full state (optional ?group= filter)
|
|
64
72
|
app.get('/api/vision/items', (req, res) => {
|
|
65
73
|
let state = store.getState();
|
|
@@ -152,21 +160,10 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
152
160
|
? path.join(projectRoot, loadProjectConfig().paths?.features || 'docs/features')
|
|
153
161
|
: resolveProjectPath('features');
|
|
154
162
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
architecture: ['blueprint'],
|
|
159
|
-
blueprint: ['verification'],
|
|
160
|
-
verification: ['plan', 'blueprint'],
|
|
161
|
-
plan: ['execute'],
|
|
162
|
-
execute: ['report', 'docs'],
|
|
163
|
-
report: ['docs'],
|
|
164
|
-
docs: ['ship'],
|
|
165
|
-
ship: [],
|
|
166
|
-
};
|
|
167
|
-
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;
|
|
168
166
|
const ITERATION_TYPES = new Set(['review', 'coverage']);
|
|
169
|
-
const TERMINAL = new Set(['complete', 'killed']);
|
|
170
167
|
|
|
171
168
|
app.get('/api/vision/items/:id/lifecycle', (req, res) => {
|
|
172
169
|
try {
|
|
@@ -192,7 +189,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
192
189
|
}
|
|
193
190
|
});
|
|
194
191
|
|
|
195
|
-
app.post('/api/vision/items/:id/lifecycle/start', (req, res) => {
|
|
192
|
+
app.post('/api/vision/items/:id/lifecycle/start', async (req, res) => {
|
|
196
193
|
try {
|
|
197
194
|
const { featureCode } = req.body;
|
|
198
195
|
if (!featureCode) return res.status(400).json({ error: 'featureCode is required' });
|
|
@@ -212,6 +209,14 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
212
209
|
// COMP-OBS-TIMELINE: populate phaseHistory before storing
|
|
213
210
|
appendPhaseHistory({ lifecycle }, { from: null, to: 'explore_design', outcome: null, timestamp: now });
|
|
214
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
|
+
}
|
|
215
220
|
scheduleBroadcast();
|
|
216
221
|
broadcastMessage({ type: 'lifecycleStarted', itemId: req.params.id, phase: 'explore_design', featureCode, timestamp: now });
|
|
217
222
|
emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode, from: null, to: 'explore_design', outcome: null, timestamp: now }));
|
|
@@ -259,7 +264,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
259
264
|
}
|
|
260
265
|
});
|
|
261
266
|
|
|
262
|
-
app.post('/api/vision/items/:id/lifecycle/advance', (req, res) => {
|
|
267
|
+
app.post('/api/vision/items/:id/lifecycle/advance', async (req, res) => {
|
|
263
268
|
try {
|
|
264
269
|
const { targetPhase, outcome } = req.body;
|
|
265
270
|
const item = store.items.get(req.params.id);
|
|
@@ -269,6 +274,12 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
269
274
|
const valid = TRANSITIONS[from];
|
|
270
275
|
if (!valid?.includes(targetPhase)) return res.status(400).json({ error: `Invalid transition: ${from} → ${targetPhase}` });
|
|
271
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
|
+
|
|
272
283
|
item.lifecycle.currentPhase = targetPhase;
|
|
273
284
|
// COMP-OBS-TIMELINE: populate phaseHistory + emit phase_transition DecisionEvent
|
|
274
285
|
const now = new Date().toISOString();
|
|
@@ -281,6 +292,8 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
281
292
|
emitDriftAxes(broadcastMessage, store, item, projectRoot, now);
|
|
282
293
|
// COMP-OBS-STATUS: emit status snapshot after lifecycle transition (advance)
|
|
283
294
|
emitStatusSnapshot(broadcastMessage, store, item.lifecycle.featureCode, now);
|
|
295
|
+
// COMP-RESUME: best-effort anchor checkpoint at the phase boundary
|
|
296
|
+
anchorBoundary(getTargetRoot(), { item, trigger: 'phase-transition' });
|
|
284
297
|
res.json({ from, to: targetPhase, outcome });
|
|
285
298
|
} catch (err) {
|
|
286
299
|
const status = err.message.includes('not found') ? 404 : 400;
|
|
@@ -288,7 +301,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
288
301
|
}
|
|
289
302
|
});
|
|
290
303
|
|
|
291
|
-
app.post('/api/vision/items/:id/lifecycle/skip', (req, res) => {
|
|
304
|
+
app.post('/api/vision/items/:id/lifecycle/skip', async (req, res) => {
|
|
292
305
|
try {
|
|
293
306
|
const { targetPhase, reason } = req.body;
|
|
294
307
|
const item = store.items.get(req.params.id);
|
|
@@ -299,6 +312,12 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
299
312
|
const valid = TRANSITIONS[from];
|
|
300
313
|
if (!valid?.includes(targetPhase)) return res.status(400).json({ error: `Invalid transition: ${from} → ${targetPhase}` });
|
|
301
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
|
+
|
|
302
321
|
item.lifecycle.currentPhase = targetPhase;
|
|
303
322
|
// COMP-OBS-TIMELINE: populate phaseHistory + emit phase_transition DecisionEvent
|
|
304
323
|
const now = new Date().toISOString();
|
|
@@ -311,6 +330,8 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
311
330
|
emitDriftAxes(broadcastMessage, store, item, projectRoot, now);
|
|
312
331
|
// COMP-OBS-STATUS: emit status snapshot after lifecycle transition (skip)
|
|
313
332
|
emitStatusSnapshot(broadcastMessage, store, item.lifecycle.featureCode, now);
|
|
333
|
+
// COMP-RESUME: best-effort anchor checkpoint at the phase boundary
|
|
334
|
+
anchorBoundary(getTargetRoot(), { item, trigger: 'phase-transition' });
|
|
314
335
|
res.json({ from, to: targetPhase, outcome: 'skipped', reason });
|
|
315
336
|
} catch (err) {
|
|
316
337
|
const status = err.message.includes('not found') ? 404 : 400;
|
|
@@ -318,7 +339,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
318
339
|
}
|
|
319
340
|
});
|
|
320
341
|
|
|
321
|
-
app.post('/api/vision/items/:id/lifecycle/kill', (req, res) => {
|
|
342
|
+
app.post('/api/vision/items/:id/lifecycle/kill', async (req, res) => {
|
|
322
343
|
try {
|
|
323
344
|
const { reason } = req.body;
|
|
324
345
|
const item = store.items.get(req.params.id);
|
|
@@ -326,6 +347,15 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
326
347
|
const from = item.lifecycle.currentPhase;
|
|
327
348
|
if (TERMINAL.has(from)) return res.status(400).json({ error: `Cannot kill from terminal state: ${from}` });
|
|
328
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
|
+
|
|
329
359
|
const now = new Date().toISOString();
|
|
330
360
|
item.lifecycle.currentPhase = 'killed';
|
|
331
361
|
item.lifecycle.killedAt = now;
|
|
@@ -341,6 +371,8 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
341
371
|
emitDriftAxes(broadcastMessage, store, item, projectRoot, now);
|
|
342
372
|
// COMP-OBS-STATUS: emit status snapshot after lifecycle transition (kill)
|
|
343
373
|
emitStatusSnapshot(broadcastMessage, store, item.lifecycle.featureCode, now);
|
|
374
|
+
// COMP-RESUME: best-effort anchor checkpoint at the (terminal) phase boundary
|
|
375
|
+
anchorBoundary(getTargetRoot(), { item, trigger: 'phase-transition' });
|
|
344
376
|
res.json({ phase: from, reason });
|
|
345
377
|
} catch (err) {
|
|
346
378
|
const status = err.message.includes('not found') ? 404 : 400;
|
|
@@ -356,6 +388,14 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
356
388
|
return res.status(400).json({ error: `Can only complete from ship phase, currently in: ${item.lifecycle.currentPhase}` });
|
|
357
389
|
}
|
|
358
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
|
+
|
|
359
399
|
const now = new Date().toISOString();
|
|
360
400
|
item.lifecycle.currentPhase = 'complete';
|
|
361
401
|
item.lifecycle.completedAt = now;
|
|
@@ -370,6 +410,8 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
370
410
|
emitDriftAxes(broadcastMessage, store, item, projectRoot, now);
|
|
371
411
|
// COMP-OBS-STATUS: emit status snapshot after lifecycle transition (complete)
|
|
372
412
|
emitStatusSnapshot(broadcastMessage, store, item.lifecycle.featureCode, now);
|
|
413
|
+
// COMP-RESUME: best-effort anchor checkpoint at the (terminal) phase boundary
|
|
414
|
+
anchorBoundary(getTargetRoot(), { item, trigger: 'phase-transition' });
|
|
373
415
|
|
|
374
416
|
// COMP-MCP-MIGRATION: reconcile cockpit complete with record_completion
|
|
375
417
|
// (commit-bound completion writer). Best-effort: failures emit a decision
|
|
@@ -561,6 +603,8 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
561
603
|
emitDriftAxes(broadcastMessage, store, item, projectRoot, now);
|
|
562
604
|
// COMP-OBS-STATUS: emit status snapshot after iteration complete
|
|
563
605
|
emitStatusSnapshot(broadcastMessage, store, item.lifecycle.featureCode, now);
|
|
606
|
+
// COMP-RESUME: best-effort anchor checkpoint at iteration-complete boundary
|
|
607
|
+
anchorBoundary(getTargetRoot(), { item, trigger: 'iteration-complete' });
|
|
564
608
|
}
|
|
565
609
|
} else {
|
|
566
610
|
// per-attempt update: NO DecisionEvent (Decision 2 — would flood strip)
|
|
@@ -614,6 +658,8 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
614
658
|
emitDriftAxes(broadcastMessage, store, item, projectRoot, now);
|
|
615
659
|
// COMP-OBS-STATUS: emit status snapshot after iteration abort
|
|
616
660
|
emitStatusSnapshot(broadcastMessage, store, item.lifecycle.featureCode, now);
|
|
661
|
+
// COMP-RESUME: best-effort anchor checkpoint at iteration-complete (abort) boundary
|
|
662
|
+
anchorBoundary(getTargetRoot(), { item, trigger: 'iteration-complete' });
|
|
617
663
|
}
|
|
618
664
|
res.json({ aborted: true });
|
|
619
665
|
} catch (err) {
|
|
@@ -835,6 +881,8 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
835
881
|
// COMP-OBS-DRIFT: emit drift axes before status so STATUS reads fresh drift_axes
|
|
836
882
|
emitDriftAxes(broadcastMessage, store, resolvedItem, projectRoot, resolvedAt);
|
|
837
883
|
emitStatusSnapshot(broadcastMessage, store, resolvedItem.lifecycle.featureCode, resolvedAt);
|
|
884
|
+
// COMP-RESUME: best-effort anchor checkpoint at gate-resolution boundary
|
|
885
|
+
anchorBoundary(getTargetRoot(), { item: resolvedItem, trigger: 'gate-resolution' });
|
|
838
886
|
}
|
|
839
887
|
}
|
|
840
888
|
res.json({ gateId: req.params.id, gateOutcome: outcome });
|