@smartmemory/compose 0.1.1-beta → 0.1.2-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/.claude/skills/bug-fix/SKILL.md +143 -0
- package/.claude/skills/compose/SKILL.md +604 -0
- package/.compose-deps.json +89 -0
- package/README.md +14 -3
- package/bin/compose.js +473 -0
- package/contracts/comp-obs-contract.schema.json +362 -0
- package/contracts/cross-model-review-result.json +78 -0
- package/contracts/review-result.json +126 -0
- package/dist/assets/{_baseUniq-CQwX6VLz.js → _baseUniq-D-avYfn5.js} +1 -1
- package/dist/assets/{arc-SxJ2J1sh.js → arc-BC4dfQ-X.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BykunY1F.js → architectureDiagram-Q4EWVU46-BZmFXnGI.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-ohAKBOUw.js → blockDiagram-DXYQGD6D-DlfWSuux.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-DBDC3ENB.js → c4Diagram-AHTNJAMY-Y__uJrRx.js} +1 -1
- package/dist/assets/channel-LRG9kHqJ.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-Cv93Z7uM.js → chunk-4BX2VUAB-BfMePfTp.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-DE0WBDkj.js → chunk-4TB4RGXK-BdlMSdEA.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-CE1EXenG.js → chunk-55IACEB6-vrQHZTdv.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-DA7Ana6H.js → chunk-EDXVE4YY-B8wioVlW.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-CTDIPA3p.js → chunk-FMBD7UC4-Cd6Hrux2.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-uGBaPaTX.js → chunk-OYMX7WX6-CfrhdQXY.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-CYlnXuUO.js → chunk-QZHKN3VN-B9JQerOU.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-ojGkzcZK.js → chunk-YZCP3GAM-DFN9X99H.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BC9a6pDE.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-BC9a6pDE.js +1 -0
- package/dist/assets/clone-dRxgFrBv.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-Bktn9hL-.js → cose-bilkent-S5V4N54A-BAn0ap_E.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-DFaSzuRF.js → dagre-KV5264BT-DyxnVq1g.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DnfmDzEm.js → diagram-5BDNPKRD-XCrzqski.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-Bm8W9YnG.js → diagram-G4DWMVQ6-MBCAXft_.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-B5-TSKvp.js → diagram-MMDJMWI5-DbtB2yS6.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-ls4rqlky.js → diagram-TYMM5635-Bb5NzX61.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-giG6WO-r.js → erDiagram-SMLLAGMA-CpIeCOh2.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-XvlUuz-7.js → flowDiagram-DWJPFMVM-CHyoKnhW.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-hLBV57oV.js → ganttDiagram-T4ZO3ILL-DErKteO_.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-BHu3s_Gn.js → gitGraphDiagram-UUTBAWPF-KFVAtj2F.js} +1 -1
- package/dist/assets/{graph-D0Cfv00Y.js → graph-CRnO_ifT.js} +1 -1
- package/dist/assets/index-DKBsEUJ-.css +1 -0
- package/dist/assets/index-DkRKLuNr.js +1144 -0
- package/dist/assets/{infoDiagram-42DDH7IO-DbqRsOo3.js → infoDiagram-42DDH7IO-BZFnuSp5.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-DnCdx7zb.js → ishikawaDiagram-UXIWVN3A-4Xe2Szde.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-CfD7eNcP.js → journeyDiagram-VCZTEJTY-CZRByfS-.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-BYaO9-mK.js → kanban-definition-6JOO6SKY-B95sk6Fk.js} +1 -1
- package/dist/assets/{layout-Bj72wOEB.js → layout-BqNQzxWT.js} +1 -1
- package/dist/assets/{linear-BRFo114D.js → linear-CUh7qb64.js} +1 -1
- package/dist/assets/{min-GCHnKlJS.js → min-wXgOS3ig.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-n0PMebY4.js → mindmap-definition-QFDTVHPH-DB6iaAbO.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-pN4CljHF.js → pieDiagram-DEJITSTG-CHkZHrTW.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-DNoAy8-D.js → quadrantDiagram-34T5L4WZ-DoTEO8e3.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-BhtY05PT.js → requirementDiagram-MS252O5E-Dn8peXYp.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-B6AD-16A.js → sankeyDiagram-XADWPNL6-DRXs6Ipb.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-DShHM-uk.js → sequenceDiagram-FGHM5R23-wBBYZ0aq.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-DMxn7HTo.js → stateDiagram-FHFEXIEX-DPlBNGmf.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-BW0ezXb4.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-Cdu6uq52.js → timeline-definition-GMOUNBTQ-CbbyTlHk.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-CpK29iRe.js → vennDiagram-DHZGUBPP-Bj4GaFfj.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-BQgSkdcO.js → wardley-RL74JXVD-RtNzq8KU.js} +55 -55
- package/dist/assets/{wardleyDiagram-NUSXRM2D-DJHYev6O.js → wardleyDiagram-NUSXRM2D-CDfE3zSj.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-1d75pbaO.js → xychartDiagram-5P7HB3ND-CZXHHYD5.js} +1 -1
- package/dist/index.html +2 -2
- package/lib/budget-ledger.js +45 -0
- package/lib/bug-bisect.js +292 -0
- package/lib/bug-checkpoint.js +191 -0
- package/lib/bug-escalation.js +306 -0
- package/lib/bug-index-gen.js +136 -0
- package/lib/bug-ledger.js +126 -0
- package/lib/build-stream-schema.js +176 -0
- package/lib/build-stream-writer.js +3 -1
- package/lib/build.js +854 -284
- package/lib/connector-factory-shim.js +167 -0
- package/lib/constants.js +18 -0
- package/lib/debug-discipline.js +176 -27
- package/lib/deps.js +205 -0
- package/lib/health-score.js +4 -4
- package/lib/import.js +26 -13
- package/lib/inject-schema.js +21 -0
- package/lib/new.js +27 -53
- package/lib/result-normalizer.js +160 -144
- package/lib/review-lenses.js +5 -5
- package/lib/review-normalize.js +413 -0
- package/lib/review-prompt.js +163 -0
- package/lib/sections.js +325 -0
- package/lib/step-prompt.js +21 -1
- package/lib/step-validator.js +5 -3
- package/lib/stratum-mcp-client.js +172 -7
- package/package.json +14 -3
- package/pipelines/bug-fix.stratum.yaml +39 -1
- package/pipelines/build.stratum.yaml +28 -45
- package/pipelines/review-fix.stratum.yaml +1 -1
- package/presets/team-review.stratum.yaml +21 -14
- package/server/build-stream-bridge.js +28 -0
- package/server/cc-session-feature-resolver.js +111 -0
- package/server/cc-session-reader.js +327 -0
- package/server/cc-session-watcher.js +318 -0
- package/server/compose-mcp-tools.js +0 -125
- package/server/compose-mcp.js +2 -4
- package/server/contract-diff.js +192 -0
- package/server/decision-event-emit.js +175 -0
- package/server/decision-event-id.js +64 -0
- package/server/decision-events-snapshot.js +166 -0
- package/server/design-routes.js +92 -49
- package/server/drift-axes.js +365 -0
- package/server/drift-emit.js +121 -0
- package/server/gate-log-store.js +102 -0
- package/server/lifecycle-phase-history.js +44 -0
- package/server/open-loops-store.js +102 -0
- package/server/schema-validator.js +49 -0
- package/server/status-emit.js +27 -0
- package/server/status-snapshot.js +218 -0
- package/server/vision-routes.js +332 -4
- package/server/vision-server.js +104 -12
- package/server/vision-store.js +21 -0
- package/dist/assets/channel-DGElom1e.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-KqWP9wWZ.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-KqWP9wWZ.js +0 -1
- package/dist/assets/clone-DUJKJXd7.js +0 -1
- package/dist/assets/index-CUd6pFGF.css +0 -1
- package/dist/assets/index-DReRlzZI.js +0 -1144
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-o6PnCs4e.js +0 -1
- package/server/connectors/agent-connector.js +0 -78
- package/server/connectors/claude-sdk-connector.js +0 -198
- package/server/connectors/codex-connector.js +0 -240
- package/server/connectors/connector-discovery.js +0 -18
- package/server/connectors/connector-runtime.js +0 -13
- package/server/connectors/opencode-connector.js +0 -200
|
@@ -10,8 +10,6 @@ import http from 'node:http';
|
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { ArtifactManager, ARTIFACT_SCHEMAS } from './artifact-manager.js';
|
|
12
12
|
import { getTargetRoot, getDataDir, resolveProjectPath } from './project-root.js';
|
|
13
|
-
import { ClaudeSDKConnector } from './connectors/claude-sdk-connector.js';
|
|
14
|
-
import { CodexConnector } from './connectors/codex-connector.js';
|
|
15
13
|
|
|
16
14
|
export const PROJECT_ROOT = getTargetRoot();
|
|
17
15
|
export const VISION_FILE = path.join(getDataDir(), 'vision-state.json');
|
|
@@ -354,126 +352,3 @@ export function toolGetPendingGates({ itemId }) {
|
|
|
354
352
|
return { count: pending.length, gates: pending };
|
|
355
353
|
}
|
|
356
354
|
|
|
357
|
-
// ---------------------------------------------------------------------------
|
|
358
|
-
// Agent run — dispatch prompts to claude or codex
|
|
359
|
-
// ---------------------------------------------------------------------------
|
|
360
|
-
|
|
361
|
-
const VALID_AGENT_TYPES = new Set(['claude', 'codex']);
|
|
362
|
-
|
|
363
|
-
/**
|
|
364
|
-
* Build a context preamble for agent_run prompts.
|
|
365
|
-
* The spawned agent (especially codex via opencode run) has no project context —
|
|
366
|
-
* no CLAUDE.md, no feature folder awareness, no compose/stratum semantics.
|
|
367
|
-
* This function reads project files and prepends them so the agent can do useful work.
|
|
368
|
-
*/
|
|
369
|
-
function _buildContext({ featureCode }) {
|
|
370
|
-
const sections = [];
|
|
371
|
-
|
|
372
|
-
// Project instructions
|
|
373
|
-
const claudeMd = path.join(PROJECT_ROOT, 'CLAUDE.md');
|
|
374
|
-
if (fs.existsSync(claudeMd)) {
|
|
375
|
-
try {
|
|
376
|
-
const content = fs.readFileSync(claudeMd, 'utf-8');
|
|
377
|
-
sections.push(`## Project Instructions (CLAUDE.md)\n\n${content}`);
|
|
378
|
-
} catch { /* ignore read errors */ }
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// Feature artifacts (if feature code detected)
|
|
382
|
-
// Cap total feature context to ~20KB to avoid exceeding model input limits.
|
|
383
|
-
// Prioritize design.md first, then most recent files by mtime.
|
|
384
|
-
const MAX_FEATURE_BYTES = 20_000;
|
|
385
|
-
if (featureCode) {
|
|
386
|
-
const featureRoot = resolveProjectPath('features');
|
|
387
|
-
const featureDir = path.join(featureRoot, featureCode);
|
|
388
|
-
if (fs.existsSync(featureDir)) {
|
|
389
|
-
const artifacts = [];
|
|
390
|
-
let totalBytes = 0;
|
|
391
|
-
try {
|
|
392
|
-
const files = fs.readdirSync(featureDir)
|
|
393
|
-
.filter(f => f.endsWith('.md') || f.endsWith('.json'))
|
|
394
|
-
.map(f => ({ name: f, path: path.join(featureDir, f), stat: fs.statSync(path.join(featureDir, f)) }))
|
|
395
|
-
.filter(f => f.stat.isFile())
|
|
396
|
-
// design.md first, then most recently modified
|
|
397
|
-
.sort((a, b) => {
|
|
398
|
-
if (a.name === 'design.md') return -1;
|
|
399
|
-
if (b.name === 'design.md') return 1;
|
|
400
|
-
return b.stat.mtimeMs - a.stat.mtimeMs;
|
|
401
|
-
});
|
|
402
|
-
for (const file of files) {
|
|
403
|
-
if (totalBytes >= MAX_FEATURE_BYTES) break;
|
|
404
|
-
try {
|
|
405
|
-
const content = fs.readFileSync(file.path, 'utf-8');
|
|
406
|
-
const trimmed = content.slice(0, MAX_FEATURE_BYTES - totalBytes);
|
|
407
|
-
artifacts.push(`### ${file.name}\n\n${trimmed}`);
|
|
408
|
-
totalBytes += trimmed.length;
|
|
409
|
-
} catch { /* skip unreadable files */ }
|
|
410
|
-
}
|
|
411
|
-
} catch { /* ignore readdir errors */ }
|
|
412
|
-
if (artifacts.length > 0) {
|
|
413
|
-
sections.push(`## Feature: ${featureCode}\n\n${artifacts.join('\n\n---\n\n')}`);
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
if (sections.length === 0) return '';
|
|
419
|
-
return `# Context\n\n${sections.join('\n\n---\n\n')}\n\n---\n\n`;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/**
|
|
423
|
-
* Extract a feature code from the prompt if one is referenced.
|
|
424
|
-
* Looks for common patterns like "FEAT-1", "AUTH-2", or feature folder paths.
|
|
425
|
-
*/
|
|
426
|
-
function _extractFeatureCode(prompt) {
|
|
427
|
-
// Match uppercase CODE-N patterns (e.g. FEAT-1, AUTH-2, STRAT-COMP-3)
|
|
428
|
-
const codeMatch = prompt.match(/\b([A-Z][\w-]*-\d+)\b/);
|
|
429
|
-
if (codeMatch) return codeMatch[1];
|
|
430
|
-
|
|
431
|
-
// Match feature folder references
|
|
432
|
-
const pathMatch = prompt.match(/features\/([a-zA-Z][\w-]+)/);
|
|
433
|
-
if (pathMatch) return pathMatch[1];
|
|
434
|
-
|
|
435
|
-
return null;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
export async function toolAgentRun({ type = 'claude', prompt, schema, modelID, cwd, featureCode }) {
|
|
439
|
-
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
|
|
440
|
-
throw new Error('agent_run: prompt is required');
|
|
441
|
-
}
|
|
442
|
-
if (!VALID_AGENT_TYPES.has(type)) {
|
|
443
|
-
throw new Error(`agent_run: unknown type '${type}'. Valid: ${[...VALID_AGENT_TYPES].join(', ')}`);
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// Resolve feature code from explicit param or prompt text
|
|
447
|
-
const resolvedFeature = featureCode || _extractFeatureCode(prompt);
|
|
448
|
-
|
|
449
|
-
// Build context preamble and prepend to prompt
|
|
450
|
-
const context = _buildContext({ featureCode: resolvedFeature });
|
|
451
|
-
const fullPrompt = context ? `${context}# Task\n\n${prompt}` : prompt;
|
|
452
|
-
|
|
453
|
-
const resolvedCwd = cwd || PROJECT_ROOT;
|
|
454
|
-
const connector = type === 'codex'
|
|
455
|
-
? new CodexConnector({ modelID, cwd: resolvedCwd })
|
|
456
|
-
: new ClaudeSDKConnector({ model: modelID, cwd: resolvedCwd });
|
|
457
|
-
|
|
458
|
-
const parts = [];
|
|
459
|
-
for await (const event of connector.run(fullPrompt, { schema, modelID, cwd: resolvedCwd })) {
|
|
460
|
-
if (event.type === 'assistant' && event.content) {
|
|
461
|
-
parts.push(event.content);
|
|
462
|
-
} else if (event.type === 'error') {
|
|
463
|
-
throw new Error(`agent_run (${type}): ${event.message}`);
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
const text = parts.join('');
|
|
468
|
-
|
|
469
|
-
if (schema) {
|
|
470
|
-
try {
|
|
471
|
-
return { text, result: JSON.parse(text) };
|
|
472
|
-
} catch {
|
|
473
|
-
return { text, result: null, parseError: 'Response was not valid JSON' };
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
return { text };
|
|
478
|
-
}
|
|
479
|
-
|
package/server/compose-mcp.js
CHANGED
|
@@ -256,10 +256,8 @@ const TOOLS = [
|
|
|
256
256
|
},
|
|
257
257
|
},
|
|
258
258
|
},
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
// `toolAgentRun` (and the Node connectors it uses) remain exported for Compose's
|
|
262
|
-
// internal callers (build.js, vision-server, pipelines).
|
|
259
|
+
// `agent_run` tool removed 2026-04-18 (STRAT-DEDUP-AGENTRUN v1); LLM-facing
|
|
260
|
+
// dispatch goes through `mcp__stratum__stratum_agent_run`.
|
|
263
261
|
];
|
|
264
262
|
|
|
265
263
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* contract-diff.js — COMP-OBS-DRIFT contract_drift axis helper.
|
|
3
|
+
*
|
|
4
|
+
* Compares JSON Schema files between an anchor git ref and the current working
|
|
5
|
+
* tree. Returns field-change counts (added / removed / retyped / total) for use
|
|
6
|
+
* by drift-axes.js in computing the contract_drift ratio.
|
|
7
|
+
*
|
|
8
|
+
* Field-walk strategy: recursively collect leaf property names from
|
|
9
|
+
* schema.properties, counting each as one "field". additionalProperties: false
|
|
10
|
+
* itself counts as a single field (stability signal). Combines all files so
|
|
11
|
+
* the denominator is global-per-feature, not per-file.
|
|
12
|
+
*
|
|
13
|
+
* All git invocations go through execSync; errors are caught and returned as
|
|
14
|
+
* empty field sets (axis falls back to threshold: null in the caller).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { execSync } from 'node:child_process';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Walk a JSON Schema object and return the set of "fields" it declares.
|
|
23
|
+
* Each property name is included; additionalProperties:false adds a sentinel
|
|
24
|
+
* '__additionalProperties_closed__' so it contributes to the count.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} schema
|
|
27
|
+
* @param {string} [prefix]
|
|
28
|
+
* @returns {Set<string>}
|
|
29
|
+
*/
|
|
30
|
+
function walkSchema(schema, prefix = '') {
|
|
31
|
+
const fields = new Set();
|
|
32
|
+
if (!schema || typeof schema !== 'object') return fields;
|
|
33
|
+
|
|
34
|
+
if (schema.additionalProperties === false) {
|
|
35
|
+
fields.add(`${prefix}__additionalProperties_closed__`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const props = schema.properties;
|
|
39
|
+
if (props && typeof props === 'object') {
|
|
40
|
+
for (const [key, value] of Object.entries(props)) {
|
|
41
|
+
const qualifiedKey = prefix ? `${prefix}.${key}` : key;
|
|
42
|
+
fields.add(qualifiedKey);
|
|
43
|
+
// Recurse into nested object schemas
|
|
44
|
+
if (value && typeof value === 'object') {
|
|
45
|
+
const nested = walkSchema(value, qualifiedKey);
|
|
46
|
+
for (const f of nested) fields.add(f);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Walk allOf / anyOf / oneOf for completeness
|
|
52
|
+
for (const key of ['allOf', 'anyOf', 'oneOf']) {
|
|
53
|
+
if (Array.isArray(schema[key])) {
|
|
54
|
+
for (const sub of schema[key]) {
|
|
55
|
+
const nested = walkSchema(sub, prefix);
|
|
56
|
+
for (const f of nested) fields.add(f);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return fields;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Collect a Map<fullyQualifiedPath, typeString> for every field at any depth.
|
|
66
|
+
* Mirrors walkSchema's traversal so paths are comparable across versions.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} schema
|
|
69
|
+
* @param {string} prefix
|
|
70
|
+
* @returns {Map<string, string>}
|
|
71
|
+
*/
|
|
72
|
+
function collectFieldTypes(schema, prefix = '') {
|
|
73
|
+
const types = new Map();
|
|
74
|
+
if (!schema || typeof schema !== 'object') return types;
|
|
75
|
+
|
|
76
|
+
if (schema.properties && typeof schema.properties === 'object') {
|
|
77
|
+
for (const [key, value] of Object.entries(schema.properties)) {
|
|
78
|
+
const qualifiedKey = prefix ? `${prefix}.${key}` : key;
|
|
79
|
+
const t = JSON.stringify(value?.type ?? null);
|
|
80
|
+
types.set(qualifiedKey, t);
|
|
81
|
+
if (value && typeof value === 'object') {
|
|
82
|
+
const nested = collectFieldTypes(value, qualifiedKey);
|
|
83
|
+
for (const [k, v] of nested) types.set(k, v);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const key of ['allOf', 'anyOf', 'oneOf']) {
|
|
89
|
+
if (Array.isArray(schema[key])) {
|
|
90
|
+
for (const sub of schema[key]) {
|
|
91
|
+
const nested = collectFieldTypes(sub, prefix);
|
|
92
|
+
for (const [k, v] of nested) types.set(k, v);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return types;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read a file at an anchor git ref via `git show <ref>:<path>`.
|
|
102
|
+
* Returns null if the file did not exist at that ref.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} ref — git commit ref (sha1 or symbolic)
|
|
105
|
+
* @param {string} filePath — path relative to repo root
|
|
106
|
+
* @param {string} projectRoot — absolute path of the git repo root
|
|
107
|
+
* @returns {string|null}
|
|
108
|
+
*/
|
|
109
|
+
function gitShow(ref, filePath, projectRoot) {
|
|
110
|
+
// Normalize path separators to forward slashes for git
|
|
111
|
+
const gitPath = filePath.replace(/\\/g, '/');
|
|
112
|
+
try {
|
|
113
|
+
return execSync(`git show ${ref}:${gitPath}`, {
|
|
114
|
+
cwd: projectRoot,
|
|
115
|
+
encoding: 'utf8',
|
|
116
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
117
|
+
});
|
|
118
|
+
} catch {
|
|
119
|
+
return null; // file didn't exist at that ref — treat as empty schema
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Diff contract files for a feature between an anchor ref and the current
|
|
125
|
+
* working tree.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} anchorRef — git commit ref (result of git rev-list)
|
|
128
|
+
* @param {string[]} headPaths — absolute paths to current JSON schema files
|
|
129
|
+
* @param {string} projectRoot — absolute root of the git repo
|
|
130
|
+
* @returns {{ added: number, removed: number, retyped: number, total: number }}
|
|
131
|
+
* Returns { added:0, removed:0, retyped:0, total:0 } on any parse failure.
|
|
132
|
+
*/
|
|
133
|
+
export function diffContracts(anchorRef, headPaths, projectRoot) {
|
|
134
|
+
let added = 0;
|
|
135
|
+
let removed = 0;
|
|
136
|
+
let retyped = 0;
|
|
137
|
+
let totalCurrentFields = 0;
|
|
138
|
+
|
|
139
|
+
for (const absPath of headPaths) {
|
|
140
|
+
// Read current (HEAD working-tree) version
|
|
141
|
+
let currentSchema;
|
|
142
|
+
try {
|
|
143
|
+
currentSchema = JSON.parse(fs.readFileSync(absPath, 'utf8'));
|
|
144
|
+
} catch {
|
|
145
|
+
continue; // unparseable — skip this file
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Compute path relative to projectRoot for git show
|
|
149
|
+
const relPath = path.relative(projectRoot, absPath);
|
|
150
|
+
|
|
151
|
+
// Read anchor version
|
|
152
|
+
const anchorContent = gitShow(anchorRef, relPath, projectRoot);
|
|
153
|
+
let anchorSchema = null;
|
|
154
|
+
if (anchorContent) {
|
|
155
|
+
try {
|
|
156
|
+
anchorSchema = JSON.parse(anchorContent);
|
|
157
|
+
} catch {
|
|
158
|
+
// anchor version unparseable — treat as if it didn't exist
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const currentFields = walkSchema(currentSchema);
|
|
163
|
+
const anchorFields = anchorSchema ? walkSchema(anchorSchema) : new Set();
|
|
164
|
+
|
|
165
|
+
totalCurrentFields += currentFields.size;
|
|
166
|
+
|
|
167
|
+
for (const field of currentFields) {
|
|
168
|
+
if (!anchorFields.has(field)) added++;
|
|
169
|
+
}
|
|
170
|
+
for (const field of anchorFields) {
|
|
171
|
+
if (!currentFields.has(field)) removed++;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// "Retyped" detection: walk both schemas recursively and compare the JSON
|
|
175
|
+
// type of every fully-qualified field path that exists in both versions.
|
|
176
|
+
// This catches retypes nested inside object properties (where the path
|
|
177
|
+
// stays the same but the type changes), which a top-level-only diff
|
|
178
|
+
// would miss and silently undercount.
|
|
179
|
+
if (anchorSchema) {
|
|
180
|
+
const currentTypes = collectFieldTypes(currentSchema);
|
|
181
|
+
const anchorTypes = collectFieldTypes(anchorSchema);
|
|
182
|
+
for (const [path, curType] of currentTypes) {
|
|
183
|
+
if (anchorTypes.has(path)) {
|
|
184
|
+
const anType = anchorTypes.get(path);
|
|
185
|
+
if (curType !== anType) retyped++;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { added, removed, retyped, total: totalCurrentFields };
|
|
192
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* decision-event-emit.js — Shared emit helper for COMP-OBS-TIMELINE.
|
|
3
|
+
*
|
|
4
|
+
* Central choke point for all DecisionEvent broadcasts. Builders produce
|
|
5
|
+
* contract-clean event shapes; emitDecisionEvent wraps them in the canonical
|
|
6
|
+
* envelope matching BRANCH's existing pattern (cc-session-watcher.js:151-166):
|
|
7
|
+
* { type: 'decisionEvent', event: { id, feature_code, timestamp, kind, title, metadata, roles } }
|
|
8
|
+
*
|
|
9
|
+
* Outcome mapping for iteration.metadata.outcome:
|
|
10
|
+
* server outcome → schema enum
|
|
11
|
+
* 'clean' → 'pass'
|
|
12
|
+
* 'max_reached' → 'fail'
|
|
13
|
+
* 'aborted' → 'fail'
|
|
14
|
+
* 'timeout' → 'fail'
|
|
15
|
+
* 'action_limit' → 'fail'
|
|
16
|
+
* 'retry' / null → 'retry'
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
phaseTransitionDecisionEventId,
|
|
21
|
+
iterationDecisionEventId,
|
|
22
|
+
gateDecisionEventId,
|
|
23
|
+
driftThresholdDecisionEventId,
|
|
24
|
+
} from './decision-event-id.js';
|
|
25
|
+
import { mapResolveOutcomeToSchema } from './gate-log-store.js';
|
|
26
|
+
|
|
27
|
+
// Map server-side iteration outcome strings to the schema enum {pass|fail|retry}
|
|
28
|
+
function mapIterationOutcome(outcome) {
|
|
29
|
+
if (!outcome || outcome === 'retry') return 'retry';
|
|
30
|
+
if (outcome === 'clean' || outcome === 'pass') return 'pass';
|
|
31
|
+
// max_reached | aborted | timeout | action_limit → fail
|
|
32
|
+
return 'fail';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Emit a DecisionEvent over broadcastMessage using the canonical envelope.
|
|
37
|
+
* The envelope matches BRANCH's _buildForkEvent / broadcastMessage pattern.
|
|
38
|
+
*/
|
|
39
|
+
export function emitDecisionEvent(broadcastMessage, event) {
|
|
40
|
+
broadcastMessage({ type: 'decisionEvent', event });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build a kind=phase_transition DecisionEvent.
|
|
45
|
+
*
|
|
46
|
+
* @param {{ featureCode, from, to, outcome, agent_id, timestamp }} params
|
|
47
|
+
* - from: previous phase string, or null for the initial lifecycle start
|
|
48
|
+
* - to: new phase string
|
|
49
|
+
* - outcome: optional outcome string (for context)
|
|
50
|
+
* - agent_id: optional operator/agent identifier
|
|
51
|
+
*/
|
|
52
|
+
export function buildPhaseTransitionEvent({ featureCode, from, to, outcome, agent_id, timestamp }) {
|
|
53
|
+
const now = timestamp || new Date().toISOString();
|
|
54
|
+
const fromStr = from == null ? 'null' : String(from);
|
|
55
|
+
const id = phaseTransitionDecisionEventId(featureCode, from, to, now);
|
|
56
|
+
return {
|
|
57
|
+
id,
|
|
58
|
+
feature_code: featureCode,
|
|
59
|
+
timestamp: now,
|
|
60
|
+
kind: 'phase_transition',
|
|
61
|
+
title: from == null
|
|
62
|
+
? `Lifecycle started: ${to}`
|
|
63
|
+
: `Phase: ${from} → ${to}${outcome === 'skipped' ? ' (skipped)' : outcome === 'killed' ? ' (killed)' : ''}`,
|
|
64
|
+
metadata: {
|
|
65
|
+
from_phase: fromStr,
|
|
66
|
+
to_phase: String(to),
|
|
67
|
+
},
|
|
68
|
+
roles: [{ name: 'PRODUCER', agent_id: agent_id || null }],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build a kind=iteration DecisionEvent.
|
|
74
|
+
* Only called at loop start and loop complete — NOT per-attempt.
|
|
75
|
+
*
|
|
76
|
+
* @param {{ featureCode, loopId, loopType, stage, attempt, outcome, timestamp }} params
|
|
77
|
+
* - stage: 'start' | 'complete'
|
|
78
|
+
* - loopType: 'review' | 'coverage' | other
|
|
79
|
+
* - attempt: iteration count at the time of this event (0 for start)
|
|
80
|
+
* - outcome: server outcome string (see mapping above)
|
|
81
|
+
*/
|
|
82
|
+
export function buildIterationEvent({ featureCode, loopId, loopType, stage, attempt, outcome, timestamp }) {
|
|
83
|
+
const now = timestamp || new Date().toISOString();
|
|
84
|
+
const id = iterationDecisionEventId(featureCode, loopId, stage);
|
|
85
|
+
const schemaOutcome = mapIterationOutcome(outcome);
|
|
86
|
+
|
|
87
|
+
// Role assignment per design Decision 5
|
|
88
|
+
let roles = [];
|
|
89
|
+
if (loopType === 'review') {
|
|
90
|
+
roles = [{ name: 'REVIEWER', agent_id: null }];
|
|
91
|
+
} else if (loopType === 'coverage') {
|
|
92
|
+
roles = [{ name: 'IMPLEMENTER', agent_id: null }];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Title: descriptive for start vs complete
|
|
96
|
+
let title;
|
|
97
|
+
if (stage === 'start') {
|
|
98
|
+
title = `Iteration loop started — ${loopType}`;
|
|
99
|
+
} else {
|
|
100
|
+
const cnt = attempt != null ? ` (${attempt} attempt${attempt !== 1 ? 's' : ''})` : '';
|
|
101
|
+
title = `Iteration loop complete — ${loopType}${cnt}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const metadata = { iteration_id: loopId };
|
|
105
|
+
// attempt is schema-optional; schema requires minimum: 1 so only include when >= 1
|
|
106
|
+
if (attempt != null && attempt >= 1) metadata.attempt = attempt;
|
|
107
|
+
if (stage !== 'start') metadata.outcome = schemaOutcome;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
id,
|
|
111
|
+
feature_code: featureCode,
|
|
112
|
+
timestamp: now,
|
|
113
|
+
kind: 'iteration',
|
|
114
|
+
title,
|
|
115
|
+
metadata,
|
|
116
|
+
roles,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Build a kind=drift_threshold DecisionEvent.
|
|
122
|
+
*
|
|
123
|
+
* Called by emitDriftAxes on the rising edge (breached false → true).
|
|
124
|
+
* id and timestamp are taken from the persisted breach_event_id and
|
|
125
|
+
* breach_started_at — NOT recomputed — so live-emit and snapshot rehydration
|
|
126
|
+
* produce byte-for-byte identical events.
|
|
127
|
+
*
|
|
128
|
+
* @param {{ featureCode, axisId, ratio, threshold, breachStartedAt, breachEventId }} params
|
|
129
|
+
*/
|
|
130
|
+
export function buildDriftThresholdEvent({ featureCode, axisId, ratio, threshold, breachStartedAt, breachEventId }) {
|
|
131
|
+
// If caller omits breachEventId, derive it deterministically (fallback safety).
|
|
132
|
+
const id = breachEventId || driftThresholdDecisionEventId(featureCode, axisId, breachStartedAt);
|
|
133
|
+
return {
|
|
134
|
+
id,
|
|
135
|
+
feature_code: featureCode,
|
|
136
|
+
timestamp: breachStartedAt,
|
|
137
|
+
kind: 'drift_threshold',
|
|
138
|
+
title: `Drift threshold crossed: ${axisId} (${Math.round(ratio * 100)}% ≥ ${Math.round(threshold * 100)}%)`,
|
|
139
|
+
metadata: {
|
|
140
|
+
axis_id: axisId,
|
|
141
|
+
ratio,
|
|
142
|
+
threshold,
|
|
143
|
+
},
|
|
144
|
+
roles: [],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Build a kind=gate DecisionEvent.
|
|
150
|
+
*
|
|
151
|
+
* @param {{ featureCode, gateLogEntryId, gateId, decision, timestamp }} params
|
|
152
|
+
* - decision: route outcome string (approve|revise|kill) — translated to schema enum internally
|
|
153
|
+
*/
|
|
154
|
+
export function buildGateEvent({ featureCode, gateLogEntryId, gateId, decision, timestamp }) {
|
|
155
|
+
const now = timestamp || new Date().toISOString();
|
|
156
|
+
const id = gateDecisionEventId(featureCode, gateLogEntryId);
|
|
157
|
+
const schemaDecision = mapResolveOutcomeToSchema(decision);
|
|
158
|
+
|
|
159
|
+
const outcomeLabels = { approve: 'approved', interrupt: 'interrupted', deny: 'denied' };
|
|
160
|
+
const label = outcomeLabels[schemaDecision] || schemaDecision;
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
id,
|
|
164
|
+
feature_code: featureCode,
|
|
165
|
+
timestamp: now,
|
|
166
|
+
kind: 'gate',
|
|
167
|
+
title: `Gate ${label}: ${gateId}`,
|
|
168
|
+
metadata: {
|
|
169
|
+
gate_id: gateId,
|
|
170
|
+
decision: schemaDecision,
|
|
171
|
+
gate_log_entry_id: gateLogEntryId,
|
|
172
|
+
},
|
|
173
|
+
roles: [],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { v5 as uuidv5 } from 'uuid';
|
|
2
|
+
|
|
3
|
+
const ROOT_NAMESPACE = '3a7c1b12-9c10-4d02-ae9e-5f0e8bf3b2e1';
|
|
4
|
+
|
|
5
|
+
export function branchDecisionEventId(featureCode, branchId) {
|
|
6
|
+
const featureNs = uuidv5(String(featureCode), ROOT_NAMESPACE);
|
|
7
|
+
return uuidv5(`branch:${branchId}`, featureNs);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function shouldEmit(eventId, emittedSet) {
|
|
11
|
+
if (!emittedSet) return true;
|
|
12
|
+
if (emittedSet instanceof Set) return !emittedSet.has(eventId);
|
|
13
|
+
if (Array.isArray(emittedSet)) return !emittedSet.includes(eventId);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Deterministic id for a phase_transition DecisionEvent.
|
|
19
|
+
* Unique per (featureCode, fromPhase, toPhase, timestamp).
|
|
20
|
+
*/
|
|
21
|
+
export function phaseTransitionDecisionEventId(featureCode, fromPhase, toPhase, timestamp) {
|
|
22
|
+
const featureNs = uuidv5(String(featureCode), ROOT_NAMESPACE);
|
|
23
|
+
const from = fromPhase == null ? 'null' : String(fromPhase);
|
|
24
|
+
return uuidv5(`phase_transition:${from}:${toPhase}:${timestamp}`, featureNs);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Deterministic id for an iteration DecisionEvent.
|
|
29
|
+
* stage ∈ 'start' | 'complete'
|
|
30
|
+
*/
|
|
31
|
+
export function iterationDecisionEventId(featureCode, loopId, stage) {
|
|
32
|
+
const featureNs = uuidv5(String(featureCode), ROOT_NAMESPACE);
|
|
33
|
+
return uuidv5(`iteration:${loopId}:${stage}`, featureNs);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Deterministic id for a gate DecisionEvent (kind='gate').
|
|
38
|
+
* Unique per (featureCode, gateLogEntryId).
|
|
39
|
+
* Once entry.id is fixed, the event id is fixed — enabling reconciliation.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} featureCode
|
|
42
|
+
* @param {string} gateLogEntryId — UUID v4 of the GateLogEntry
|
|
43
|
+
* @returns {string} UUID v5
|
|
44
|
+
*/
|
|
45
|
+
export function gateDecisionEventId(featureCode, gateLogEntryId) {
|
|
46
|
+
const featureNs = uuidv5(String(featureCode), ROOT_NAMESPACE);
|
|
47
|
+
return uuidv5(`gate:${gateLogEntryId}`, featureNs);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Deterministic id for a drift_threshold DecisionEvent.
|
|
52
|
+
* Unique per (featureCode, axisId, breachStartedAt).
|
|
53
|
+
* Persisted on the DriftAxis as breach_event_id so rehydration produces the
|
|
54
|
+
* same id as the live emit without recomputing from current computed_at.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} featureCode
|
|
57
|
+
* @param {string} axisId — 'path_drift' | 'contract_drift' | 'review_debt_drift'
|
|
58
|
+
* @param {string} breachStartedAtIso — ISO timestamp of the rising-edge breach
|
|
59
|
+
* @returns {string} UUID v5
|
|
60
|
+
*/
|
|
61
|
+
export function driftThresholdDecisionEventId(featureCode, axisId, breachStartedAtIso) {
|
|
62
|
+
const featureNs = uuidv5(String(featureCode), ROOT_NAMESPACE);
|
|
63
|
+
return uuidv5(`drift_threshold:${axisId}:${breachStartedAtIso}`, featureNs);
|
|
64
|
+
}
|