@smartmemory/compose 0.2.57-beta → 0.3.0
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/README.md +2 -2
- package/bin/compose.js +55 -54
- package/contracts/feature-json.schema.json +16 -0
- package/dist/assets/{App-BmhlHOXF.js → App-DJ5xk_Wx.js} +219 -219
- package/dist/assets/{abnfDiagram-VRR7QNED-CMOTYAnt.js → abnfDiagram-VRR7QNED-BPWGdCFx.js} +1 -1
- package/dist/assets/{arc-BJAv_6dL.js → arc-DX5jqmcO.js} +1 -1
- package/dist/assets/{architectureDiagram-ZJ3FMSHR-xa4DLUcp.js → architectureDiagram-ZJ3FMSHR-B6jmNVw7.js} +1 -1
- package/dist/assets/{blockDiagram-677ZJIJ3-BdkDqeQF.js → blockDiagram-677ZJIJ3-DAx2i-sB.js} +1 -1
- package/dist/assets/{c4Diagram-LMCZKHZV-DiNrLIuL.js → c4Diagram-LMCZKHZV-zmsZCplj.js} +1 -1
- package/dist/assets/channel-vsDnTvkh.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-BsjtgrSL.js → chunk-2Q5K7J3B-3QSh6sI7.js} +1 -1
- package/dist/assets/{chunk-32BRIVSS-DpImULWy.js → chunk-32BRIVSS-BiH5Di_y.js} +1 -1
- package/dist/assets/{chunk-5VM5RSS4-DTdG5JnR.js → chunk-5VM5RSS4-C0EfjFLd.js} +1 -1
- package/dist/assets/{chunk-EX3LRPZG-Cj1jNuKY.js → chunk-EX3LRPZG-BGyfmVm-.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-DwQ0wwWn.js → chunk-JWPE2WC7-Ba0mrNlg.js} +1 -1
- package/dist/assets/{chunk-MOJQB5TN-DZZa7unc.js → chunk-MOJQB5TN-D3QO9EzH.js} +1 -1
- package/dist/assets/{chunk-RYQCIY6F-CgdjqYQj.js → chunk-RYQCIY6F-D1uvNA6d.js} +1 -1
- package/dist/assets/{chunk-V7JOEXUC-DrPIj6XI.js → chunk-V7JOEXUC-fihlnodT.js} +1 -1
- package/dist/assets/{chunk-VR4S4FIN-BBUOVC5W.js → chunk-VR4S4FIN-nmRUEo1H.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-BEIiZ6gS.js → chunk-XXDRQBXY-CM693yEg.js} +1 -1
- package/dist/assets/classDiagram-OUVF2IWQ-DO-wdSFZ.js +1 -0
- package/dist/assets/classDiagram-v2-EOCWNBFH-DO-wdSFZ.js +1 -0
- package/dist/assets/{cose-bilkent-JH36ORCC-ClFTQEyD.js → cose-bilkent-JH36ORCC-CkacPn8W.js} +1 -1
- package/dist/assets/{cynefin-VYW2F7L2-5Bp7xFO0.js → cynefin-VYW2F7L2-BiHaIptG.js} +1 -1
- package/dist/assets/{cynefinDiagram-TSTJHNR4-C-0QlSda.js → cynefinDiagram-TSTJHNR4-CdKxMmiy.js} +1 -1
- package/dist/assets/{dagre-VKFMJZFB-DLkqsfBy.js → dagre-VKFMJZFB-BQLY5y_W.js} +1 -1
- package/dist/assets/{diagram-FQU43EPY-Cwh_tJ_r.js → diagram-FQU43EPY-D7uMBHvq.js} +1 -1
- package/dist/assets/{diagram-G47NLZAW-CvvAjulf.js → diagram-G47NLZAW-B3Z1cuH7.js} +1 -1
- package/dist/assets/{diagram-NH7WQ7WH-CCoyx8_e.js → diagram-NH7WQ7WH-BZyRD45e.js} +1 -1
- package/dist/assets/{diagram-OA4YK3LP-DfjhKYCp.js → diagram-OA4YK3LP-DcThOTt7.js} +1 -1
- package/dist/assets/{diagram-WEI45ONY-Vjz0ygfB.js → diagram-WEI45ONY-BcgRAkqY.js} +1 -1
- package/dist/assets/{ebnfDiagram-CCIWWBDH-GsA8c6g2.js → ebnfDiagram-CCIWWBDH-CGwfO_xH.js} +1 -1
- package/dist/assets/{erDiagram-Q63AITRT-Cc0lJ9sQ.js → erDiagram-Q63AITRT-pV58-Ncc.js} +1 -1
- package/dist/assets/{flowDiagram-23GEKE2U-C_sotK-k.js → flowDiagram-23GEKE2U-DJ_SqE8h.js} +1 -1
- package/dist/assets/{ganttDiagram-NO4QXBWP-BBtu_WUe.js → ganttDiagram-NO4QXBWP-Dgy0Iyss.js} +1 -1
- package/dist/assets/{gitGraphDiagram-IHSO6WYX-BV_aSB8-.js → gitGraphDiagram-IHSO6WYX-CbiZw9fb.js} +1 -1
- package/dist/assets/{index-CKfOVv2N.js → index-DZTJEk-y.js} +2 -2
- package/dist/assets/{infoDiagram-FWYZ7A6U-DMAvNGtX.js → infoDiagram-FWYZ7A6U-CtsyyEc-.js} +1 -1
- package/dist/assets/{ishikawaDiagram-FXEZZL3T-DfQB91MZ.js → ishikawaDiagram-FXEZZL3T-BJilNFkK.js} +1 -1
- package/dist/assets/{journeyDiagram-5HDEW3XC-Bq4Llm9o.js → journeyDiagram-5HDEW3XC-C2UCMP4t.js} +1 -1
- package/dist/assets/{kanban-definition-HUTT4EX6-DTXHiDiE.js → kanban-definition-HUTT4EX6-DmLDJBRy.js} +1 -1
- package/dist/assets/{linear-BQSYZcVg.js → linear-C1paCqE7.js} +1 -1
- package/dist/assets/{mindmap-definition-LN4V7U3C-KuH2NIj0.js → mindmap-definition-LN4V7U3C-CX-RxKVn.js} +1 -1
- package/dist/assets/{pegDiagram-2B236MQR-BKoNGQFr.js → pegDiagram-2B236MQR-CTU32H2W.js} +1 -1
- package/dist/assets/{pieDiagram-ENE6RG2P-EpYv162Y.js → pieDiagram-ENE6RG2P-D8L1aYoo.js} +1 -1
- package/dist/assets/{quadrantDiagram-ABIIQ3AL-C9deUjQ2.js → quadrantDiagram-ABIIQ3AL-hQ3bXosy.js} +1 -1
- package/dist/assets/{railroadDiagram-RFXS5EU6-Csqw2hK9.js → railroadDiagram-RFXS5EU6--_vuYcda.js} +1 -1
- package/dist/assets/{requirementDiagram-TGXJPOKE-DCuz3emr.js → requirementDiagram-TGXJPOKE-C6h_m3Az.js} +1 -1
- package/dist/assets/{sankeyDiagram-HTMAVEWB-BTeTL24f.js → sankeyDiagram-HTMAVEWB-Dpc7CfIQ.js} +1 -1
- package/dist/assets/{sequenceDiagram-DBY2YBRQ-Cf1ZkTwO.js → sequenceDiagram-DBY2YBRQ-CK5ZzbvJ.js} +1 -1
- package/dist/assets/{sizeCapture-X5ZJPWSS-D3Lbewzg.js → sizeCapture-X5ZJPWSS-BAxVvM9G.js} +1 -1
- package/dist/assets/{stateDiagram-2N3HPSRC-O8l37yJI.js → stateDiagram-2N3HPSRC-7j33_2PY.js} +1 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-GviN0FpJ.js +1 -0
- package/dist/assets/{swimlanes-5IMT3BWC-BikmTh8b.js → swimlanes-5IMT3BWC-CBG2yOob.js} +2 -2
- package/dist/assets/swimlanesDiagram-G3AALYLV-D0wMoxl1.js +8 -0
- package/dist/assets/{timeline-definition-FHXFAJF6-BOxSosG0.js → timeline-definition-FHXFAJF6-BOnZkQvc.js} +1 -1
- package/dist/assets/{vennDiagram-L72KCM5P-BSClOErZ.js → vennDiagram-L72KCM5P-D52NhIfj.js} +1 -1
- package/dist/assets/{wardleyDiagram-EHGQE667-DHFRjOK0.js → wardleyDiagram-EHGQE667-YoPY2ZU-.js} +1 -1
- package/dist/assets/{xychartDiagram-FW5EYKEG-Dh1v4x-O.js → xychartDiagram-FW5EYKEG-BanNfgtO.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/build-all.js +0 -5
- package/lib/build-stream-schema.js +1 -1
- package/lib/build.js +1809 -2646
- package/lib/consumer-fanout.js +1317 -0
- package/lib/escalation.js +69 -0
- package/lib/feature-validator.js +6 -4
- package/lib/feature-writer.js +58 -3
- package/lib/flow-state.js +15 -14
- package/lib/gsd-budget.js +48 -10
- package/lib/gsd-prompt.js +3 -4
- package/lib/gsd-stuck.js +1 -1
- package/lib/gsd.js +303 -149
- package/lib/lane-gate.js +200 -0
- package/lib/local-claude-connector.js +149 -0
- package/lib/new.js +162 -307
- package/lib/result-normalizer.js +233 -18
- package/lib/review-lenses.js +1 -1
- package/lib/step-prompt.js +41 -119
- package/lib/stratum-engine.js +297 -0
- package/lib/stratum-mcp-client.js +224 -213
- package/lib/triage.js +144 -0
- package/lib/vocabulary-compliance.js +268 -0
- package/lib/vocabulary-inject.js +1 -36
- package/package.json +2 -1
- package/pipelines/build-quick.stratum.yaml +5 -17
- package/pipelines/build.profiles.json +11 -0
- package/pipelines/build.stratum.yaml +288 -467
- package/pipelines/gsd.stratum.yaml +73 -125
- package/pipelines/new.stratum.yaml +68 -149
- package/server/build-routes.js +4 -1
- package/server/design-routes.js +37 -21
- package/server/index.js +13 -21
- package/server/lifecycle-guard.js +1 -1
- package/server/pipeline-routes.js +113 -31
- package/server/stratum-client.js +33 -47
- package/server/stratum-sync.js +3 -4
- package/server/vision-server.js +1 -1
- package/dist/assets/channel-CYErfopw.js +0 -1
- package/dist/assets/classDiagram-OUVF2IWQ-qCC_hnXu.js +0 -1
- package/dist/assets/classDiagram-v2-EOCWNBFH-qCC_hnXu.js +0 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-BWqk_9py.js +0 -1
- package/dist/assets/swimlanesDiagram-G3AALYLV-C60ICdks.js +0 -8
- package/lib/connector-factory-shim.js +0 -167
- package/server/agent-mcp.js +0 -10
package/lib/triage.js
CHANGED
|
@@ -174,6 +174,150 @@ function deriveProfile(signals) {
|
|
|
174
174
|
};
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Front-seam scope estimation (doc-free)
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
// Lane order, smaller index = smaller scope / more conservative.
|
|
182
|
+
const LANE_ORDER = { trivial: 0, standard: 1, complex: 2 };
|
|
183
|
+
|
|
184
|
+
// Verbs that denote a single, well-defined action. Presence (+ an explicit
|
|
185
|
+
// file path) is what earns 'high' confidence; absence (+ no path) is what
|
|
186
|
+
// earns 'low' confidence and triggers the safety clamp.
|
|
187
|
+
const UNAMBIGUOUS_VERB_RE =
|
|
188
|
+
/\b(fix(?:e[ds]|ing)?|renam(?:e[ds]?|ing)|add(?:ed|s|ing)?|remov(?:e[ds]?|ing)|delet(?:e[ds]?|ing)|updat(?:e[ds]?|ing)|creat(?:e[ds]?|ing)|implement(?:ed|s|ing)?|refactor(?:ed|s|ing)?|bump(?:ed|s|ing)?|patch(?:ed|es|ing)?|mov(?:e[ds]?|ing)|extract(?:ed|s|ing)?|replac(?:e[ds]?|ing)|revert(?:ed|s|ing)?|rewrit(?:e|es|ing)|rewrote)\b/i;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Map a triage tier (0-4) to a coarse lane.
|
|
192
|
+
*
|
|
193
|
+
* @param {number} tier
|
|
194
|
+
* @returns {'trivial'|'standard'|'complex'}
|
|
195
|
+
*/
|
|
196
|
+
export function tierToLane(tier) {
|
|
197
|
+
if (tier <= 1) return 'trivial';
|
|
198
|
+
if (tier === 2) return 'standard';
|
|
199
|
+
return 'complex';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Clamp a lane up to at least `minimum` (never narrows).
|
|
204
|
+
*
|
|
205
|
+
* @param {string} lane
|
|
206
|
+
* @param {string} minimum
|
|
207
|
+
* @returns {string}
|
|
208
|
+
*/
|
|
209
|
+
function clampLaneMin(lane, minimum) {
|
|
210
|
+
return LANE_ORDER[lane] < LANE_ORDER[minimum] ? minimum : lane;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Estimate scope from request text alone — the DOC-FREE front seam.
|
|
215
|
+
*
|
|
216
|
+
* Unlike `runTriage`, this never reads plan.md/blueprint.md/design.md (or
|
|
217
|
+
* any file at all). It derives the same `signals` shape `deriveProfile`
|
|
218
|
+
* expects purely from the request text and caller-supplied repo-signal
|
|
219
|
+
* hints, so it can run before any design doc exists — before the genesis
|
|
220
|
+
* phase, at feature-creation time.
|
|
221
|
+
*
|
|
222
|
+
* @param {string} request - Free-text feature/bug request description.
|
|
223
|
+
* @param {{ files?: string[] }} [repoSignals] - Candidate file paths the
|
|
224
|
+
* request plausibly touches (caller-supplied, e.g. from a git-diff or
|
|
225
|
+
* grep hint). Minimal shape: `{ files: string[] }`.
|
|
226
|
+
* @returns {{ tier: number, profile: object, lane: 'trivial'|'standard'|'complex', confidence: 'high'|'medium'|'low', rationale: string }}
|
|
227
|
+
*/
|
|
228
|
+
// Reconcile the tier-derived profile with the (possibly clamped) lane so the two
|
|
229
|
+
// never disagree. The safety clamp raises the LANE, but skip_if is driven by the
|
|
230
|
+
// PROFILE — without this floor a clamped-to-standard lane would still skip
|
|
231
|
+
// verification, defeating the clamp. Standard keeps verification; complex keeps
|
|
232
|
+
// every phase; trivial is left as deriveProfile set it.
|
|
233
|
+
export function floorProfileToLane(profile, lane) {
|
|
234
|
+
if (lane === 'complex') {
|
|
235
|
+
return { needs_prd: true, needs_architecture: true, needs_verification: true, needs_report: true };
|
|
236
|
+
}
|
|
237
|
+
if (lane === 'standard') {
|
|
238
|
+
return { ...profile, needs_verification: true };
|
|
239
|
+
}
|
|
240
|
+
return { ...profile };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function estimateScope(request, repoSignals = {}) {
|
|
244
|
+
const text = request ?? '';
|
|
245
|
+
const hintedFiles = Array.isArray(repoSignals?.files) ? repoSignals.files : [];
|
|
246
|
+
|
|
247
|
+
const textPaths = extractFilePaths(text);
|
|
248
|
+
const uniquePaths = new Set([...textPaths, ...hintedFiles]);
|
|
249
|
+
const pathList = [...uniquePaths];
|
|
250
|
+
|
|
251
|
+
const taskCount = countTasks(text);
|
|
252
|
+
const securityPaths = anyMatch(pathList, SECURITY_PATTERNS);
|
|
253
|
+
const corePaths = anyMatch(pathList, CORE_PATTERNS);
|
|
254
|
+
|
|
255
|
+
const signals = {
|
|
256
|
+
fileCount: uniquePaths.size,
|
|
257
|
+
securityPaths,
|
|
258
|
+
corePaths,
|
|
259
|
+
taskCount,
|
|
260
|
+
hasDesignDoc: false,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const { tier, profile, rationale } = deriveProfile(signals);
|
|
264
|
+
|
|
265
|
+
let lane = tierToLane(tier);
|
|
266
|
+
// Belt-and-suspenders: a security/core path hit must never resolve below
|
|
267
|
+
// 'standard', independent of how deriveProfile's own tier logic evolves.
|
|
268
|
+
if (securityPaths || corePaths) {
|
|
269
|
+
lane = clampLaneMin(lane, 'standard');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const hasPaths = uniquePaths.size > 0;
|
|
273
|
+
const hasUnambiguousVerb = UNAMBIGUOUS_VERB_RE.test(text);
|
|
274
|
+
let confidence;
|
|
275
|
+
if (hasPaths && hasUnambiguousVerb) {
|
|
276
|
+
confidence = 'high';
|
|
277
|
+
} else if (!hasPaths && !hasUnambiguousVerb) {
|
|
278
|
+
confidence = 'low';
|
|
279
|
+
} else {
|
|
280
|
+
confidence = 'medium';
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Safety clamp: under-scoping is the only dangerous error. Low confidence
|
|
284
|
+
// (no named paths, no clear verb) can never resolve to 'trivial'.
|
|
285
|
+
if (confidence === 'low') {
|
|
286
|
+
lane = clampLaneMin(lane, 'standard');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { tier, profile: floorProfileToLane(profile, lane), lane, confidence, rationale };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Map a triage tier to a persisted complexity label.
|
|
294
|
+
*
|
|
295
|
+
* @param {number} tier - 0-4
|
|
296
|
+
* @returns {'S'|'M'|'L'|'XL'}
|
|
297
|
+
*/
|
|
298
|
+
export function tierToComplexity(tier) {
|
|
299
|
+
if (tier <= 1) return 'S';
|
|
300
|
+
if (tier === 2) return 'M';
|
|
301
|
+
if (tier === 3) return 'L';
|
|
302
|
+
return 'XL';
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Return the more conservative (smaller-scope) of two lanes.
|
|
307
|
+
* Order: trivial < standard < complex.
|
|
308
|
+
*
|
|
309
|
+
* Used by refinement/escalation call sites to enforce "narrow-only" — a
|
|
310
|
+
* later pass may only shrink scope, never widen it (widening is
|
|
311
|
+
* escalation's job, via `lib/escalation.js`, not refinement's).
|
|
312
|
+
*
|
|
313
|
+
* @param {'trivial'|'standard'|'complex'} a
|
|
314
|
+
* @param {'trivial'|'standard'|'complex'} b
|
|
315
|
+
* @returns {'trivial'|'standard'|'complex'}
|
|
316
|
+
*/
|
|
317
|
+
export function narrowerLane(a, b) {
|
|
318
|
+
return LANE_ORDER[a] <= LANE_ORDER[b] ? a : b;
|
|
319
|
+
}
|
|
320
|
+
|
|
177
321
|
// ---------------------------------------------------------------------------
|
|
178
322
|
// Public API
|
|
179
323
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vocabulary-compliance.js — deterministic Compose-side vocabulary checks.
|
|
3
|
+
*
|
|
4
|
+
* E3/F5: the TS engine's `judged:` ensure cannot see the changed files or the
|
|
5
|
+
* vocabulary (the judge receives only {result, input} and fails when evidence is
|
|
6
|
+
* missing), so it is unevaluable as a merge guard. v1 vocabulary enforcement is
|
|
7
|
+
* therefore moved here: compose evaluates it over the ACTUAL changed files at the
|
|
8
|
+
* review_merge step and, on violation, sends a FAILURE step_done envelope so the
|
|
9
|
+
* engine's attempts/retry lifecycle governs (never a throw past the step handler).
|
|
10
|
+
*
|
|
11
|
+
* Inert when contracts/vocabulary.yaml is missing / empty / comments-only. Message
|
|
12
|
+
* Stable message formats let tagVocabularyViolations classify them.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, statSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { normalize, isAbsolute, join } from 'node:path';
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
import YAML from 'yaml';
|
|
18
|
+
|
|
19
|
+
const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
20
|
+
const VOCAB_SIZE_LIMIT = 10 * 1024 * 1024; // 10 MB
|
|
21
|
+
|
|
22
|
+
/** Raised by loadVocabulary; `.violations` mirrors the Python ValueError([...]). */
|
|
23
|
+
export class VocabularyError extends Error {
|
|
24
|
+
constructor(violations) {
|
|
25
|
+
super(Array.isArray(violations) ? violations.join('; ') : String(violations));
|
|
26
|
+
this.name = 'VocabularyError';
|
|
27
|
+
this.violations = Array.isArray(violations) ? violations : [String(violations)];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Load and validate vocabulary.yaml. Returns {} for missing/empty/comments-only
|
|
33
|
+
* files (the no-op case). Throws VocabularyError([messages]) on malformed YAML or
|
|
34
|
+
* schema errors.
|
|
35
|
+
* @returns {Record<string, {reject: string[], reason: string}>}
|
|
36
|
+
*/
|
|
37
|
+
export function loadVocabulary(path) {
|
|
38
|
+
if (!existsSync(path) || !statSync(path).isFile()) return {};
|
|
39
|
+
|
|
40
|
+
let raw;
|
|
41
|
+
try {
|
|
42
|
+
raw = readFileSync(path, 'utf-8');
|
|
43
|
+
} catch (exc) {
|
|
44
|
+
throw new VocabularyError([`vocabulary.yaml malformed: cannot read ${path}: ${exc.message}`]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = YAML.parse(raw);
|
|
50
|
+
} catch (exc) {
|
|
51
|
+
throw new VocabularyError([`vocabulary.yaml malformed: ${exc.message}`]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Empty file or comments-only → null/undefined → treat as empty.
|
|
55
|
+
if (parsed === null || parsed === undefined) return {};
|
|
56
|
+
|
|
57
|
+
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
58
|
+
throw new VocabularyError(['vocabulary.yaml schema error: top-level must be a mapping']);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const validated = {};
|
|
62
|
+
for (const [canonical, entry] of Object.entries(parsed)) {
|
|
63
|
+
if (typeof canonical !== 'string' || !IDENTIFIER_RE.test(canonical)) {
|
|
64
|
+
throw new VocabularyError([
|
|
65
|
+
`vocabulary.yaml schema error: canonical name ${JSON.stringify(canonical)} `
|
|
66
|
+
+ `must match identifier syntax (letters, digits, underscore; starts with letter or underscore)`,
|
|
67
|
+
]);
|
|
68
|
+
}
|
|
69
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
70
|
+
throw new VocabularyError([
|
|
71
|
+
`vocabulary.yaml schema error: entry for ${JSON.stringify(canonical)} `
|
|
72
|
+
+ `must be a mapping, got ${Array.isArray(entry) ? 'array' : typeof entry}`,
|
|
73
|
+
]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const allowedFields = new Set(['reject', 'reason']);
|
|
77
|
+
const unknown = Object.keys(entry).filter((k) => !allowedFields.has(k)).sort();
|
|
78
|
+
if (unknown.length > 0) {
|
|
79
|
+
throw new VocabularyError([
|
|
80
|
+
`vocabulary.yaml schema error: entry for ${JSON.stringify(canonical)} `
|
|
81
|
+
+ `has unknown fields: ${JSON.stringify(unknown)}. Allowed: ["reject","reason"]`,
|
|
82
|
+
]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const reject = entry.reject;
|
|
86
|
+
if (!Array.isArray(reject) || reject.length === 0) {
|
|
87
|
+
throw new VocabularyError([
|
|
88
|
+
`vocabulary.yaml schema error: entry for ${JSON.stringify(canonical)} must have non-empty 'reject' list`,
|
|
89
|
+
]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const alias of reject) {
|
|
93
|
+
if (typeof alias !== 'string' || !IDENTIFIER_RE.test(alias)) {
|
|
94
|
+
throw new VocabularyError([
|
|
95
|
+
`vocabulary.yaml schema error: alias ${JSON.stringify(alias)} in ${JSON.stringify(canonical)} `
|
|
96
|
+
+ `must match identifier syntax`,
|
|
97
|
+
]);
|
|
98
|
+
}
|
|
99
|
+
if (alias === canonical) {
|
|
100
|
+
throw new VocabularyError([
|
|
101
|
+
`vocabulary.yaml schema error: canonical ${JSON.stringify(canonical)} cannot reject itself`,
|
|
102
|
+
]);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const reason = entry.reason ?? '';
|
|
107
|
+
if (reason !== null && typeof reason !== 'string') {
|
|
108
|
+
throw new VocabularyError([
|
|
109
|
+
`vocabulary.yaml schema error: reason for ${JSON.stringify(canonical)} must be a string`,
|
|
110
|
+
]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
validated[canonical] = { reject: [...reject], reason: reason || '' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Cross-entry validation: no duplicate aliases, no canonical-as-alias.
|
|
117
|
+
const canonicals = new Set(Object.keys(validated));
|
|
118
|
+
const aliasToCanonical = new Map();
|
|
119
|
+
for (const [canonical, entry] of Object.entries(validated)) {
|
|
120
|
+
for (const alias of entry.reject) {
|
|
121
|
+
if (canonicals.has(alias)) {
|
|
122
|
+
throw new VocabularyError([
|
|
123
|
+
`vocabulary.yaml schema error: ${JSON.stringify(alias)} is both a canonical `
|
|
124
|
+
+ `and a rejected alias for ${JSON.stringify(canonical)}`,
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
127
|
+
if (aliasToCanonical.has(alias)) {
|
|
128
|
+
throw new VocabularyError([
|
|
129
|
+
`vocabulary.yaml schema error: alias ${JSON.stringify(alias)} is rejected by `
|
|
130
|
+
+ `multiple canonicals (${aliasToCanonical.get(alias)}, ${canonical})`,
|
|
131
|
+
]);
|
|
132
|
+
}
|
|
133
|
+
aliasToCanonical.set(alias, canonical);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return validated;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** git diff --name-only <base> plus untracked files. Returns null on any failure. */
|
|
141
|
+
function gitChangedFiles(base, cwd) {
|
|
142
|
+
try {
|
|
143
|
+
const diff = execFileSync('git', ['diff', '--name-only', base], {
|
|
144
|
+
cwd, encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
145
|
+
});
|
|
146
|
+
const tracked = diff.split('\n').filter(Boolean);
|
|
147
|
+
let untracked = [];
|
|
148
|
+
try {
|
|
149
|
+
const others = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], {
|
|
150
|
+
cwd, encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
151
|
+
});
|
|
152
|
+
untracked = others.split('\n').filter(Boolean);
|
|
153
|
+
} catch { /* untracked listing best-effort */ }
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
const combined = [];
|
|
156
|
+
for (const f of [...tracked, ...untracked]) {
|
|
157
|
+
if (!seen.has(f)) { seen.add(f); combined.push(f); }
|
|
158
|
+
}
|
|
159
|
+
return combined;
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function scanFileForAliases(displayPath, absPath, aliasToCanonical, canonicalToReason, matchers) {
|
|
166
|
+
const violations = [];
|
|
167
|
+
let size;
|
|
168
|
+
try {
|
|
169
|
+
size = statSync(absPath).size;
|
|
170
|
+
} catch {
|
|
171
|
+
return violations; // disappeared between checks
|
|
172
|
+
}
|
|
173
|
+
if (size > VOCAB_SIZE_LIMIT) return violations; // skip large files silently
|
|
174
|
+
|
|
175
|
+
let lines;
|
|
176
|
+
try {
|
|
177
|
+
lines = readFileSync(absPath, 'utf-8').split(/\r?\n/);
|
|
178
|
+
} catch {
|
|
179
|
+
return violations;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
183
|
+
const lineNo = i + 1;
|
|
184
|
+
for (const [alias, pattern] of matchers) {
|
|
185
|
+
// G5: emit one violation per OCCURRENCE (python parity — the builtin uses
|
|
186
|
+
// finditer), not one per line. matchAll requires the global flag on pattern.
|
|
187
|
+
for (const _match of lines[i].matchAll(pattern)) {
|
|
188
|
+
void _match;
|
|
189
|
+
const canonical = aliasToCanonical.get(alias);
|
|
190
|
+
const reason = canonicalToReason.get(canonical) ?? '';
|
|
191
|
+
let msg = `vocabulary violation: ${displayPath}:${lineNo} uses '${alias}' — canonical is '${canonical}'`;
|
|
192
|
+
if (reason) msg += ` (reason: ${reason})`;
|
|
193
|
+
violations.push(msg);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return violations;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Scan `filesChanged` for rejected vocabulary aliases. Returns an array of
|
|
202
|
+
* violation strings — EMPTY means compliant or inert (no vocabulary, nothing to
|
|
203
|
+
* scan). Never throws for violations: schema/malformed vocab errors are returned
|
|
204
|
+
* as (blocking) violation strings too, so the caller converts them into a failure
|
|
205
|
+
* envelope rather than crashing the step handler.
|
|
206
|
+
*
|
|
207
|
+
* @param {string} vocabPath path to vocabulary.yaml (resolved against cwd)
|
|
208
|
+
* @param {string[]} filesChanged authoritative list of touched files
|
|
209
|
+
* @param {object} [opts]
|
|
210
|
+
* @param {boolean} [opts.gitFallback=false] scan `git diff <base>` when filesChanged is empty
|
|
211
|
+
* @param {string} [opts.base='HEAD']
|
|
212
|
+
* @param {string} [opts.cwd=process.cwd()]
|
|
213
|
+
* @returns {string[]}
|
|
214
|
+
*/
|
|
215
|
+
export function vocabularyCompliance(vocabPath, filesChanged, { gitFallback = false, base = 'HEAD', cwd = process.cwd() } = {}) {
|
|
216
|
+
let vocab;
|
|
217
|
+
try {
|
|
218
|
+
vocab = loadVocabulary(vocabPath);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
// A malformed / invalid vocabulary blocks the step (matches the Python builtin
|
|
221
|
+
// raising) — surface the schema errors as violations.
|
|
222
|
+
return err instanceof VocabularyError ? err.violations : [String(err?.message ?? err)];
|
|
223
|
+
}
|
|
224
|
+
if (!vocab || Object.keys(vocab).length === 0) return []; // no vocabulary → nothing to check
|
|
225
|
+
|
|
226
|
+
const aliasToCanonical = new Map();
|
|
227
|
+
const canonicalToReason = new Map();
|
|
228
|
+
for (const [canonical, entry] of Object.entries(vocab)) {
|
|
229
|
+
canonicalToReason.set(canonical, entry.reason);
|
|
230
|
+
for (const alias of entry.reject) aliasToCanonical.set(alias, canonical);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let fileList;
|
|
234
|
+
if (Array.isArray(filesChanged) && filesChanged.length > 0) {
|
|
235
|
+
fileList = filesChanged;
|
|
236
|
+
} else if (gitFallback) {
|
|
237
|
+
const fallback = gitChangedFiles(base, cwd);
|
|
238
|
+
if (fallback === null) return []; // git failed; nothing we can do
|
|
239
|
+
fileList = fallback;
|
|
240
|
+
} else {
|
|
241
|
+
return []; // no files to scan
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Normalize + dedupe; keep only existing regular files.
|
|
245
|
+
const seen = new Set();
|
|
246
|
+
const files = []; // [displayPath, absPath]
|
|
247
|
+
for (const f of fileList) {
|
|
248
|
+
const disp = normalize(f);
|
|
249
|
+
if (seen.has(disp)) continue;
|
|
250
|
+
seen.add(disp);
|
|
251
|
+
const abs = isAbsolute(disp) ? disp : join(cwd, disp);
|
|
252
|
+
try {
|
|
253
|
+
if (statSync(abs).isFile()) files.push([disp, abs]);
|
|
254
|
+
} catch { /* missing/deleted — skip */ }
|
|
255
|
+
}
|
|
256
|
+
if (files.length === 0) return [];
|
|
257
|
+
|
|
258
|
+
const matchers = new Map();
|
|
259
|
+
for (const alias of aliasToCanonical.keys()) {
|
|
260
|
+
matchers.set(alias, new RegExp(`\\b${alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const violations = [];
|
|
264
|
+
for (const [disp, abs] of files) {
|
|
265
|
+
violations.push(...scanFileForAliases(disp, abs, aliasToCanonical, canonicalToReason, matchers));
|
|
266
|
+
}
|
|
267
|
+
return violations;
|
|
268
|
+
}
|
package/lib/vocabulary-inject.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* STRAT-VOCAB-3 — Compose integration for vocabulary enforcement.
|
|
3
3
|
*
|
|
4
|
-
* Wires
|
|
5
|
-
* (stratum-mcp/src/stratum_mcp/spec.py) into the compose lifecycle:
|
|
4
|
+
* Wires Compose's deterministic vocabulary compliance check into the lifecycle:
|
|
6
5
|
* - VOCABULARY_TEMPLATE : starter contracts/vocabulary.yaml (compose init)
|
|
7
6
|
* - vocabularyEnabled() : gate — capability not disabled AND a vocab file exists
|
|
8
|
-
* - injectVocabularyEnsure() : append the vocab ensure to the build flow's `review` step
|
|
9
7
|
* - tagVocabularyViolations() : mark vocab violation strings as must-fix for display
|
|
10
8
|
*
|
|
11
9
|
* Design: docs/features/STRAT-VOCAB-3/design.md
|
|
@@ -16,15 +14,6 @@ import { join } from 'node:path';
|
|
|
16
14
|
/** Project-relative location of the vocabulary file (also the path baked into the ensure). */
|
|
17
15
|
export const VOCABULARY_FILE = 'contracts/vocabulary.yaml';
|
|
18
16
|
|
|
19
|
-
/**
|
|
20
|
-
* The ensure expression injected into the build flow's `review` step.
|
|
21
|
-
* Empty-list literal + git_fallback=True: never references a possibly-missing
|
|
22
|
-
* `result.*` attribute; scans the uncommitted working-tree diff vs HEAD (the
|
|
23
|
-
* implementation changes are merged but not yet committed at review time).
|
|
24
|
-
* Python-evaluated by the Stratum executor — hence `[]` and `True`.
|
|
25
|
-
*/
|
|
26
|
-
export const VOCABULARY_ENSURE = `vocabulary_compliance('${VOCABULARY_FILE}', [], True)`;
|
|
27
|
-
|
|
28
17
|
/** Starter vocabulary file: all comments, so `_load_vocabulary` returns {} (inert) until edited. */
|
|
29
18
|
export const VOCABULARY_TEMPLATE = `# contracts/vocabulary.yaml — project naming vocabulary (STRAT-VOCAB)
|
|
30
19
|
#
|
|
@@ -58,30 +47,6 @@ export function vocabularyEnabled(cwd, composeConfig) {
|
|
|
58
47
|
return existsSync(join(cwd, VOCABULARY_FILE));
|
|
59
48
|
}
|
|
60
49
|
|
|
61
|
-
/**
|
|
62
|
-
* Append the vocabulary ensure to the EXECUTED flow's `review` step (idempotent).
|
|
63
|
-
* Mutates and returns specObj. No-op when that flow has no `review` step.
|
|
64
|
-
*
|
|
65
|
-
* `flowName` must be the flow Stratum will actually run (build.js resolves it via
|
|
66
|
-
* extractFlowName). Targeting it precisely matters because a sub-flow
|
|
67
|
-
* (`review_check`) ALSO has a step id'd `review`; injecting there would be wrong.
|
|
68
|
-
* When `flowName` is omitted/unknown, fall back to the `build` flow (or the first),
|
|
69
|
-
* matching the shipped templates.
|
|
70
|
-
*/
|
|
71
|
-
export function injectVocabularyEnsure(specObj, flowName) {
|
|
72
|
-
const flows = specObj?.flows ?? {};
|
|
73
|
-
const keys = Object.keys(flows);
|
|
74
|
-
const flowKey = flowName && keys.includes(flowName)
|
|
75
|
-
? flowName
|
|
76
|
-
: (keys.includes('build') ? 'build' : keys[0]);
|
|
77
|
-
const steps = flows[flowKey]?.steps ?? [];
|
|
78
|
-
const review = steps.find((s) => s?.id === 'review');
|
|
79
|
-
if (!review) return specObj;
|
|
80
|
-
if (!Array.isArray(review.ensure)) review.ensure = [];
|
|
81
|
-
if (!review.ensure.includes(VOCABULARY_ENSURE)) review.ensure.push(VOCABULARY_ENSURE);
|
|
82
|
-
return specObj;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
50
|
/**
|
|
86
51
|
* Tag vocabulary failure strings as must-fix for the findings display.
|
|
87
52
|
* The cli-progress parser classifies a string by keyword (defaults to `nit`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smartmemory/compose",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
@@ -85,6 +85,7 @@
|
|
|
85
85
|
"@radix-ui/react-toggle": "^1.1.10",
|
|
86
86
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
|
87
87
|
"@radix-ui/react-tooltip": "^1.2.8",
|
|
88
|
+
"@smartmemory/stratum": "0.3.2",
|
|
88
89
|
"@tanstack/react-virtual": "^3.13.23",
|
|
89
90
|
"ajv": "^8.18.0",
|
|
90
91
|
"ajv-formats": "^3.0.1",
|
|
@@ -112,7 +112,6 @@ flows:
|
|
|
112
112
|
task: {type: string}
|
|
113
113
|
blueprint: {type: string}
|
|
114
114
|
diff: {type: string}
|
|
115
|
-
prior_dirty_lenses: {type: array, optional: true}
|
|
116
115
|
output: ReviewResult
|
|
117
116
|
steps:
|
|
118
117
|
- id: triage
|
|
@@ -120,19 +119,9 @@ flows:
|
|
|
120
119
|
intent: >
|
|
121
120
|
Decide which review lenses to activate.
|
|
122
121
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
- Activate all lenses listed in that array.
|
|
127
|
-
- Always also include diff-quality and contract-compliance (baseline lenses —
|
|
128
|
-
they re-run on every retry to catch regressions introduced by the fix, even
|
|
129
|
-
if they passed clean last time).
|
|
130
|
-
- Skip all other lenses — they already passed clean.
|
|
131
|
-
|
|
132
|
-
FIRST RUN PATH — If .compose/prior_dirty_lenses.json does not exist:
|
|
133
|
-
- Always include diff-quality and contract-compliance.
|
|
134
|
-
- Add security if files touch auth, crypto, SQL, HTTP handlers.
|
|
135
|
-
- Add framework if detected framework files (React, Express, Next.js, etc).
|
|
122
|
+
Always include diff-quality and contract-compliance.
|
|
123
|
+
Add security if files touch auth, crypto, SQL, HTTP handlers.
|
|
124
|
+
Add framework if detected framework files (React, Express, Next.js, etc).
|
|
136
125
|
|
|
137
126
|
Return JSON: { "tasks": LensTask[] } where each task has id, lens_name,
|
|
138
127
|
lens_focus, confidence_gate, exclusions.
|
|
@@ -314,8 +303,7 @@ flows:
|
|
|
314
303
|
depends_on: [decompose]
|
|
315
304
|
|
|
316
305
|
# Sub-flow: Review (parallel multi-lens review; falls back to review_check)
|
|
317
|
-
# Parent-step ensure
|
|
318
|
-
# retry the whole parallel_review sub-flow, which re-runs fresh triage/lenses/merge.
|
|
306
|
+
# Parent-step ensure retries the whole review flow after a failed result.
|
|
319
307
|
# Quick path: review reference is the design doc (no blueprint step exists).
|
|
320
308
|
- id: review
|
|
321
309
|
flow: parallel_review
|
|
@@ -337,7 +325,7 @@ flows:
|
|
|
337
325
|
- "result.clean == True"
|
|
338
326
|
depends_on: [review]
|
|
339
327
|
|
|
340
|
-
# Sub-flow: Coverage (claude runs tests
|
|
328
|
+
# Sub-flow: Coverage (claude runs tests)
|
|
341
329
|
- id: coverage
|
|
342
330
|
flow: coverage_check
|
|
343
331
|
inputs:
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "D6 (STRAT-TS-FANOUT-CONSUMER): compose-owned agent profiles for build.stratum.yaml. The TS engine accepts only the literal claude|codex agent, so the full profile strings (tool restrictions + model tier) that the v0.3->v1 conversion stripped live here, keyed by step id, and are applied compose-side at invocation (lib/build.js loadPipelineProfiles). The isolation:none review_lenses fanout MUST bind the read-only-reviewer restriction.",
|
|
3
|
+
"_reduceSteps": ["review_merge"],
|
|
4
|
+
"_reduceSteps_comment": "H1: ReviewResult-out steps that MERGE/deduplicate rather than review. They get review normalization (confidence handling) but NOT the reviewer scaffold. This is python's stripped reduce_mode input, restored compose-side.",
|
|
5
|
+
"blueprint": "claude::critical",
|
|
6
|
+
"review_triage": "claude:orchestrator",
|
|
7
|
+
"review_lenses": "claude:read-only-reviewer",
|
|
8
|
+
"review_merge": "claude:orchestrator",
|
|
9
|
+
"run_tests": "claude::fast",
|
|
10
|
+
"ship": "claude::critical"
|
|
11
|
+
}
|