@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.
Files changed (124) hide show
  1. package/.claude/skills/bug-fix/SKILL.md +143 -0
  2. package/.claude/skills/compose/SKILL.md +604 -0
  3. package/.compose-deps.json +89 -0
  4. package/README.md +14 -3
  5. package/bin/compose.js +473 -0
  6. package/contracts/comp-obs-contract.schema.json +362 -0
  7. package/contracts/cross-model-review-result.json +78 -0
  8. package/contracts/review-result.json +126 -0
  9. package/dist/assets/{_baseUniq-CQwX6VLz.js → _baseUniq-D-avYfn5.js} +1 -1
  10. package/dist/assets/{arc-SxJ2J1sh.js → arc-BC4dfQ-X.js} +1 -1
  11. package/dist/assets/{architectureDiagram-Q4EWVU46-BykunY1F.js → architectureDiagram-Q4EWVU46-BZmFXnGI.js} +1 -1
  12. package/dist/assets/{blockDiagram-DXYQGD6D-ohAKBOUw.js → blockDiagram-DXYQGD6D-DlfWSuux.js} +1 -1
  13. package/dist/assets/{c4Diagram-AHTNJAMY-DBDC3ENB.js → c4Diagram-AHTNJAMY-Y__uJrRx.js} +1 -1
  14. package/dist/assets/channel-LRG9kHqJ.js +1 -0
  15. package/dist/assets/{chunk-4BX2VUAB-Cv93Z7uM.js → chunk-4BX2VUAB-BfMePfTp.js} +1 -1
  16. package/dist/assets/{chunk-4TB4RGXK-DE0WBDkj.js → chunk-4TB4RGXK-BdlMSdEA.js} +1 -1
  17. package/dist/assets/{chunk-55IACEB6-CE1EXenG.js → chunk-55IACEB6-vrQHZTdv.js} +1 -1
  18. package/dist/assets/{chunk-EDXVE4YY-DA7Ana6H.js → chunk-EDXVE4YY-B8wioVlW.js} +1 -1
  19. package/dist/assets/{chunk-FMBD7UC4-CTDIPA3p.js → chunk-FMBD7UC4-Cd6Hrux2.js} +1 -1
  20. package/dist/assets/{chunk-OYMX7WX6-uGBaPaTX.js → chunk-OYMX7WX6-CfrhdQXY.js} +1 -1
  21. package/dist/assets/{chunk-QZHKN3VN-CYlnXuUO.js → chunk-QZHKN3VN-B9JQerOU.js} +1 -1
  22. package/dist/assets/{chunk-YZCP3GAM-ojGkzcZK.js → chunk-YZCP3GAM-DFN9X99H.js} +1 -1
  23. package/dist/assets/classDiagram-6PBFFD2Q-BC9a6pDE.js +1 -0
  24. package/dist/assets/classDiagram-v2-HSJHXN6E-BC9a6pDE.js +1 -0
  25. package/dist/assets/clone-dRxgFrBv.js +1 -0
  26. package/dist/assets/{cose-bilkent-S5V4N54A-Bktn9hL-.js → cose-bilkent-S5V4N54A-BAn0ap_E.js} +1 -1
  27. package/dist/assets/{dagre-KV5264BT-DFaSzuRF.js → dagre-KV5264BT-DyxnVq1g.js} +1 -1
  28. package/dist/assets/{diagram-5BDNPKRD-DnfmDzEm.js → diagram-5BDNPKRD-XCrzqski.js} +1 -1
  29. package/dist/assets/{diagram-G4DWMVQ6-Bm8W9YnG.js → diagram-G4DWMVQ6-MBCAXft_.js} +1 -1
  30. package/dist/assets/{diagram-MMDJMWI5-B5-TSKvp.js → diagram-MMDJMWI5-DbtB2yS6.js} +1 -1
  31. package/dist/assets/{diagram-TYMM5635-ls4rqlky.js → diagram-TYMM5635-Bb5NzX61.js} +1 -1
  32. package/dist/assets/{erDiagram-SMLLAGMA-giG6WO-r.js → erDiagram-SMLLAGMA-CpIeCOh2.js} +1 -1
  33. package/dist/assets/{flowDiagram-DWJPFMVM-XvlUuz-7.js → flowDiagram-DWJPFMVM-CHyoKnhW.js} +1 -1
  34. package/dist/assets/{ganttDiagram-T4ZO3ILL-hLBV57oV.js → ganttDiagram-T4ZO3ILL-DErKteO_.js} +1 -1
  35. package/dist/assets/{gitGraphDiagram-UUTBAWPF-BHu3s_Gn.js → gitGraphDiagram-UUTBAWPF-KFVAtj2F.js} +1 -1
  36. package/dist/assets/{graph-D0Cfv00Y.js → graph-CRnO_ifT.js} +1 -1
  37. package/dist/assets/index-DKBsEUJ-.css +1 -0
  38. package/dist/assets/index-DkRKLuNr.js +1144 -0
  39. package/dist/assets/{infoDiagram-42DDH7IO-DbqRsOo3.js → infoDiagram-42DDH7IO-BZFnuSp5.js} +1 -1
  40. package/dist/assets/{ishikawaDiagram-UXIWVN3A-DnCdx7zb.js → ishikawaDiagram-UXIWVN3A-4Xe2Szde.js} +1 -1
  41. package/dist/assets/{journeyDiagram-VCZTEJTY-CfD7eNcP.js → journeyDiagram-VCZTEJTY-CZRByfS-.js} +1 -1
  42. package/dist/assets/{kanban-definition-6JOO6SKY-BYaO9-mK.js → kanban-definition-6JOO6SKY-B95sk6Fk.js} +1 -1
  43. package/dist/assets/{layout-Bj72wOEB.js → layout-BqNQzxWT.js} +1 -1
  44. package/dist/assets/{linear-BRFo114D.js → linear-CUh7qb64.js} +1 -1
  45. package/dist/assets/{min-GCHnKlJS.js → min-wXgOS3ig.js} +1 -1
  46. package/dist/assets/{mindmap-definition-QFDTVHPH-n0PMebY4.js → mindmap-definition-QFDTVHPH-DB6iaAbO.js} +1 -1
  47. package/dist/assets/{pieDiagram-DEJITSTG-pN4CljHF.js → pieDiagram-DEJITSTG-CHkZHrTW.js} +1 -1
  48. package/dist/assets/{quadrantDiagram-34T5L4WZ-DNoAy8-D.js → quadrantDiagram-34T5L4WZ-DoTEO8e3.js} +1 -1
  49. package/dist/assets/{requirementDiagram-MS252O5E-BhtY05PT.js → requirementDiagram-MS252O5E-Dn8peXYp.js} +1 -1
  50. package/dist/assets/{sankeyDiagram-XADWPNL6-B6AD-16A.js → sankeyDiagram-XADWPNL6-DRXs6Ipb.js} +1 -1
  51. package/dist/assets/{sequenceDiagram-FGHM5R23-DShHM-uk.js → sequenceDiagram-FGHM5R23-wBBYZ0aq.js} +1 -1
  52. package/dist/assets/{stateDiagram-FHFEXIEX-DMxn7HTo.js → stateDiagram-FHFEXIEX-DPlBNGmf.js} +1 -1
  53. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BW0ezXb4.js +1 -0
  54. package/dist/assets/{timeline-definition-GMOUNBTQ-Cdu6uq52.js → timeline-definition-GMOUNBTQ-CbbyTlHk.js} +1 -1
  55. package/dist/assets/{vennDiagram-DHZGUBPP-CpK29iRe.js → vennDiagram-DHZGUBPP-Bj4GaFfj.js} +1 -1
  56. package/dist/assets/{wardley-RL74JXVD-BQgSkdcO.js → wardley-RL74JXVD-RtNzq8KU.js} +55 -55
  57. package/dist/assets/{wardleyDiagram-NUSXRM2D-DJHYev6O.js → wardleyDiagram-NUSXRM2D-CDfE3zSj.js} +1 -1
  58. package/dist/assets/{xychartDiagram-5P7HB3ND-1d75pbaO.js → xychartDiagram-5P7HB3ND-CZXHHYD5.js} +1 -1
  59. package/dist/index.html +2 -2
  60. package/lib/budget-ledger.js +45 -0
  61. package/lib/bug-bisect.js +292 -0
  62. package/lib/bug-checkpoint.js +191 -0
  63. package/lib/bug-escalation.js +306 -0
  64. package/lib/bug-index-gen.js +136 -0
  65. package/lib/bug-ledger.js +126 -0
  66. package/lib/build-stream-schema.js +176 -0
  67. package/lib/build-stream-writer.js +3 -1
  68. package/lib/build.js +854 -284
  69. package/lib/connector-factory-shim.js +167 -0
  70. package/lib/constants.js +18 -0
  71. package/lib/debug-discipline.js +176 -27
  72. package/lib/deps.js +205 -0
  73. package/lib/health-score.js +4 -4
  74. package/lib/import.js +26 -13
  75. package/lib/inject-schema.js +21 -0
  76. package/lib/new.js +27 -53
  77. package/lib/result-normalizer.js +160 -144
  78. package/lib/review-lenses.js +5 -5
  79. package/lib/review-normalize.js +413 -0
  80. package/lib/review-prompt.js +163 -0
  81. package/lib/sections.js +325 -0
  82. package/lib/step-prompt.js +21 -1
  83. package/lib/step-validator.js +5 -3
  84. package/lib/stratum-mcp-client.js +172 -7
  85. package/package.json +14 -3
  86. package/pipelines/bug-fix.stratum.yaml +39 -1
  87. package/pipelines/build.stratum.yaml +28 -45
  88. package/pipelines/review-fix.stratum.yaml +1 -1
  89. package/presets/team-review.stratum.yaml +21 -14
  90. package/server/build-stream-bridge.js +28 -0
  91. package/server/cc-session-feature-resolver.js +111 -0
  92. package/server/cc-session-reader.js +327 -0
  93. package/server/cc-session-watcher.js +318 -0
  94. package/server/compose-mcp-tools.js +0 -125
  95. package/server/compose-mcp.js +2 -4
  96. package/server/contract-diff.js +192 -0
  97. package/server/decision-event-emit.js +175 -0
  98. package/server/decision-event-id.js +64 -0
  99. package/server/decision-events-snapshot.js +166 -0
  100. package/server/design-routes.js +92 -49
  101. package/server/drift-axes.js +365 -0
  102. package/server/drift-emit.js +121 -0
  103. package/server/gate-log-store.js +102 -0
  104. package/server/lifecycle-phase-history.js +44 -0
  105. package/server/open-loops-store.js +102 -0
  106. package/server/schema-validator.js +49 -0
  107. package/server/status-emit.js +27 -0
  108. package/server/status-snapshot.js +218 -0
  109. package/server/vision-routes.js +332 -4
  110. package/server/vision-server.js +104 -12
  111. package/server/vision-store.js +21 -0
  112. package/dist/assets/channel-DGElom1e.js +0 -1
  113. package/dist/assets/classDiagram-6PBFFD2Q-KqWP9wWZ.js +0 -1
  114. package/dist/assets/classDiagram-v2-HSJHXN6E-KqWP9wWZ.js +0 -1
  115. package/dist/assets/clone-DUJKJXd7.js +0 -1
  116. package/dist/assets/index-CUd6pFGF.css +0 -1
  117. package/dist/assets/index-DReRlzZI.js +0 -1144
  118. package/dist/assets/stateDiagram-v2-QKLJ7IA2-o6PnCs4e.js +0 -1
  119. package/server/connectors/agent-connector.js +0 -78
  120. package/server/connectors/claude-sdk-connector.js +0 -198
  121. package/server/connectors/codex-connector.js +0 -240
  122. package/server/connectors/connector-discovery.js +0 -18
  123. package/server/connectors/connector-runtime.js +0 -13
  124. package/server/connectors/opencode-connector.js +0 -200
@@ -0,0 +1,102 @@
1
+ /**
2
+ * open-loops-store.js — COMP-OBS-LOOPS persistence layer.
3
+ *
4
+ * Reads/writes item.lifecycle.lifecycle_ext.open_loops[] via the existing
5
+ * updateLifecycleExt path (same as BRANCH for branch_lineage).
6
+ *
7
+ * Design decisions:
8
+ * - Server fills id (UUID v4), created_at, parent_feature from item.lifecycle.featureCode.
9
+ * - parent_feature is REQUIRED by schema — route returns 400 if item lacks featureCode.
10
+ * - Append-only: resolution is set in-place; entries are never deleted.
11
+ * - isStaleLoop is exported here and imported by status-snapshot.js so the
12
+ * panel and STATUS agree on the stale predicate (Decision 3).
13
+ */
14
+
15
+ import { randomUUID } from 'node:crypto';
16
+
17
+ /**
18
+ * Returns true if a loop is unresolved and past its TTL.
19
+ * @param {object} loop — OpenLoop object
20
+ * @param {number} nowMs — epoch ms (Date.parse result or Date.now())
21
+ */
22
+ export function isStaleLoop(loop, nowMs) {
23
+ if (loop.resolution) return false; // resolved — never stale
24
+ const ttl = loop.ttl_days ?? 90;
25
+ const ageMs = nowMs - Date.parse(loop.created_at);
26
+ return ageMs > ttl * 24 * 60 * 60 * 1000;
27
+ }
28
+
29
+ /**
30
+ * Add an OpenLoop to an item.
31
+ * The caller must persist the result via store.updateLifecycleExt(item.id, 'open_loops', next).
32
+ *
33
+ * @param {object} item — vision store item (must have lifecycle.featureCode)
34
+ * @param {{ kind, summary, parent_branch?, ttl_days? }} fields
35
+ * @returns {{ loop: OpenLoop, nextLoops: OpenLoop[] }}
36
+ * @throws {Error} if item has no featureCode (400 guard)
37
+ */
38
+ export function addOpenLoop(item, { kind, summary, parent_branch, ttl_days }) {
39
+ const featureCode = item.lifecycle?.featureCode;
40
+ if (!featureCode) {
41
+ throw Object.assign(new Error('item has no lifecycle.featureCode — cannot create feature-scoped open loop'), { status: 400 });
42
+ }
43
+
44
+ const loop = {
45
+ id: randomUUID(),
46
+ kind,
47
+ summary,
48
+ created_at: new Date().toISOString(),
49
+ parent_feature: featureCode,
50
+ parent_branch: parent_branch ?? null,
51
+ resolution: null,
52
+ ttl_days: ttl_days ?? 90,
53
+ };
54
+
55
+ const existing = item.lifecycle?.lifecycle_ext?.open_loops ?? [];
56
+ const nextLoops = [...existing, loop];
57
+ return { loop, nextLoops };
58
+ }
59
+
60
+ /**
61
+ * Resolve an open loop in place.
62
+ * @param {object} item — vision store item
63
+ * @param {string} loopId
64
+ * @param {{ note: string, resolved_by: string }} resolution
65
+ * @returns {{ loop: OpenLoop, nextLoops: OpenLoop[] }}
66
+ * @throws {Error} if loopId not found (404) or already resolved (400)
67
+ */
68
+ export function resolveOpenLoop(item, loopId, { note, resolved_by }) {
69
+ const existing = item.lifecycle?.lifecycle_ext?.open_loops ?? [];
70
+ const idx = existing.findIndex(l => l.id === loopId);
71
+ if (idx === -1) {
72
+ throw Object.assign(new Error(`loop not found: ${loopId}`), { status: 404 });
73
+ }
74
+ const loop = existing[idx];
75
+ if (loop.resolution !== null && loop.resolution !== undefined) {
76
+ throw Object.assign(new Error(`loop already resolved: ${loopId}`), { status: 400 });
77
+ }
78
+
79
+ const resolved = {
80
+ ...loop,
81
+ resolution: {
82
+ resolved_at: new Date().toISOString(),
83
+ resolved_by: resolved_by || 'unknown',
84
+ note: note || '',
85
+ },
86
+ };
87
+
88
+ const nextLoops = existing.map((l, i) => (i === idx ? resolved : l));
89
+ return { loop: resolved, nextLoops };
90
+ }
91
+
92
+ /**
93
+ * List open loops for an item.
94
+ * @param {object} item
95
+ * @param {{ includeResolved?: boolean }} opts
96
+ * @returns {OpenLoop[]}
97
+ */
98
+ export function listOpenLoops(item, { includeResolved = false } = {}) {
99
+ const loops = item.lifecycle?.lifecycle_ext?.open_loops ?? [];
100
+ if (includeResolved) return [...loops];
101
+ return loops.filter(l => l.resolution == null);
102
+ }
@@ -0,0 +1,49 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, resolve } from 'node:path';
4
+ import Ajv from 'ajv';
5
+ import addFormats from 'ajv-formats';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const SCHEMA_PATH = resolve(__dirname, '../contracts/comp-obs-contract.schema.json');
9
+
10
+ let cached = null;
11
+
12
+ function load() {
13
+ if (cached) return cached;
14
+ const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'));
15
+ const ajv = new Ajv({ strict: false, allErrors: true });
16
+ addFormats(ajv);
17
+ ajv.addSchema(schema);
18
+ cached = { schema, ajv };
19
+ return cached;
20
+ }
21
+
22
+ export class SchemaValidator {
23
+ constructor() {
24
+ const { schema, ajv } = load();
25
+ this.schema = schema;
26
+ this.ajv = ajv;
27
+ this._validators = new Map();
28
+ }
29
+
30
+ _getValidator(defName) {
31
+ if (this._validators.has(defName)) return this._validators.get(defName);
32
+ if (!this.schema.definitions || !(defName in this.schema.definitions)) {
33
+ throw new Error(`unknown schema definition: ${defName}`);
34
+ }
35
+ const ref = `${this.schema.$id}#/definitions/${defName}`;
36
+ let v = this.ajv.getSchema(ref);
37
+ if (!v) v = this.ajv.compile({ $ref: ref });
38
+ this._validators.set(defName, v);
39
+ return v;
40
+ }
41
+
42
+ validate(defName, obj) {
43
+ const v = this._getValidator(defName);
44
+ const valid = v(obj);
45
+ return { valid: !!valid, errors: valid ? [] : (v.errors || []) };
46
+ }
47
+ }
48
+
49
+ export const SCHEMA_VERSION = load().schema.version;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * status-emit.js — COMP-OBS-STATUS broadcast dispatcher (A2).
3
+ *
4
+ * Single choke point: emitStatusSnapshot(broadcastMessage, state, featureCode, now)
5
+ * Recomputes the StatusSnapshot from current store state and broadcasts:
6
+ * { type: 'statusSnapshot', featureCode, snapshot }
7
+ *
8
+ * Returns the snapshot for caller convenience (mirrors decision-event-emit.js pattern).
9
+ * No caching — recomputed on every call. The snapshot is cheap (~200 bytes, single scan).
10
+ */
11
+
12
+ import { computeStatusSnapshot } from './status-snapshot.js';
13
+
14
+ /**
15
+ * Emit a StatusSnapshot broadcast.
16
+ *
17
+ * @param {function} broadcastMessage — fn(msg) to dispatch to all WS clients
18
+ * @param {object} state — VisionStore (getItemByFeatureCode, getPendingGates)
19
+ * @param {string|null} featureCode — the feature whose snapshot to recompute
20
+ * @param {string} [now] — ISO timestamp (defaults to Date.now())
21
+ * @returns {StatusSnapshot}
22
+ */
23
+ export function emitStatusSnapshot(broadcastMessage, state, featureCode, now) {
24
+ const snapshot = computeStatusSnapshot(state, featureCode, now || new Date().toISOString());
25
+ broadcastMessage({ type: 'statusSnapshot', featureCode, snapshot });
26
+ return snapshot;
27
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * status-snapshot.js — COMP-OBS-STATUS producer (A1).
3
+ *
4
+ * Pure function: computeStatusSnapshot(state, featureCode, now) → StatusSnapshot
5
+ *
6
+ * Decision 2 (design.md): 8 rule branches, first-match wins.
7
+ * 1. No feature selected → "Select a feature to see status."
8
+ * 2. Killed → "{fc} killed. No further action."
9
+ * 3. Complete → "{fc} complete."
10
+ * 4. Pending gate → "Holding {phase}. Next: approve {gateId}."
11
+ * 5. Drift breach → "{phase} — N drift alert(s)."
12
+ * 6. Stale open loops → "{phase}. N loop(s) past TTL."
13
+ * 7. Iteration in flight → "Iterating {loopType} (attempt {count})."
14
+ * 8. Idle baseline → "Building {phase}. Open loops: {count}."
15
+ *
16
+ * Null/unknown phase fallbacks (Decision 2 note):
17
+ * - null phase + feature selected → "{fc}: phase pending."
18
+ * - unknown phase string → "{fc}: {phase} (unrecognized phase)."
19
+ * Both short-circuit branches 4–7.
20
+ */
21
+
22
+ import { readGateLog } from './gate-log-store.js';
23
+ import { isStaleLoop } from './open-loops-store.js';
24
+
25
+ // Known lifecycle phases — must match LIFECYCLE_PHASE_LABELS in constants.js
26
+ const KNOWN_PHASES = new Set([
27
+ 'explore_design', 'prd', 'architecture', 'blueprint',
28
+ 'verification', 'plan', 'execute', 'report', 'docs', 'ship',
29
+ 'complete', 'killed',
30
+ ]);
31
+
32
+ const TERMINAL_PHASES = new Set(['complete', 'killed']);
33
+ const MAX_SENTENCE = 280;
34
+
35
+ /**
36
+ * Truncate a string to fit within `headroom` chars, appending '…' if cut.
37
+ * If the string already fits, it is returned unchanged.
38
+ */
39
+ export function truncateForSentence(s, headroom) {
40
+ if (s.length <= headroom) return s;
41
+ if (headroom <= 1) return '…';
42
+ return s.slice(0, headroom - 1) + '…';
43
+ }
44
+
45
+ /**
46
+ * Build the sentence string from Decision 2 branches.
47
+ *
48
+ * @param {object} params
49
+ * @param {string|null} featureCode
50
+ * @param {string|null} activePhase
51
+ * @param {Array} pendingGates — gate objects with .id
52
+ * @param {Array} driftAlerts — breached DriftAxis objects
53
+ * @param {number} staleLoopCount
54
+ * @param {object|null} iterationState — lifecycle.iterationState
55
+ * @param {boolean} featureExists — whether the feature was found in the store
56
+ */
57
+ function buildStatusSentence({
58
+ featureCode,
59
+ activePhase,
60
+ pendingGates,
61
+ driftAlerts,
62
+ staleLoopCount,
63
+ iterationState,
64
+ openLoopsCount,
65
+ featureExists,
66
+ }) {
67
+ // Branch 1: no feature selected
68
+ if (!featureCode || !featureExists) {
69
+ return 'Select a feature to see status.';
70
+ }
71
+
72
+ // Null phase guard (Decision 2 note) — short-circuits 4–7
73
+ if (activePhase === null || activePhase === undefined) {
74
+ return `${featureCode}: phase pending.`;
75
+ }
76
+
77
+ // Unknown phase guard — short-circuits 4–7
78
+ if (!KNOWN_PHASES.has(activePhase)) {
79
+ return truncateForSentence(`${featureCode}: ${activePhase} (unrecognized phase).`, MAX_SENTENCE);
80
+ }
81
+
82
+ // Branch 2: killed (terminal, checked BEFORE non-terminal branches)
83
+ if (activePhase === 'killed') {
84
+ return `${featureCode} killed. No further action.`;
85
+ }
86
+
87
+ // Branch 3: complete (terminal)
88
+ if (activePhase === 'complete') {
89
+ return `${featureCode} complete.`;
90
+ }
91
+
92
+ // Branch 4: pending gate
93
+ if (pendingGates.length > 0) {
94
+ const gateId = pendingGates[0].id;
95
+ const prefix = `Holding ${activePhase}. Next: approve `;
96
+ const suffix = '.';
97
+ const headroom = MAX_SENTENCE - prefix.length - suffix.length;
98
+ const gateIdShort = truncateForSentence(gateId, headroom);
99
+ return `${prefix}${gateIdShort}${suffix}`;
100
+ }
101
+
102
+ // Branch 5: drift breach
103
+ if (driftAlerts.length > 0) {
104
+ const n = driftAlerts.length;
105
+ const s = n === 1 ? '' : 's';
106
+ return truncateForSentence(`${activePhase} — ${n} drift alert${s}.`, MAX_SENTENCE);
107
+ }
108
+
109
+ // Branch 6: stale open loops
110
+ if (staleLoopCount > 0) {
111
+ const n = staleLoopCount;
112
+ const s = n === 1 ? '' : 's';
113
+ return truncateForSentence(`${activePhase}. ${n} loop${s} past TTL.`, MAX_SENTENCE);
114
+ }
115
+
116
+ // Branch 7: iteration in flight
117
+ if (iterationState?.status === 'running') {
118
+ const { loopType, count } = iterationState;
119
+ return truncateForSentence(`Iterating ${loopType} (attempt ${count}).`, MAX_SENTENCE);
120
+ }
121
+
122
+ // Branch 8: idle baseline
123
+ return truncateForSentence(`Building ${activePhase}. Open loops: ${openLoopsCount}.`, MAX_SENTENCE);
124
+ }
125
+
126
+ /**
127
+ * Compute a full StatusSnapshot for the given featureCode from current store state.
128
+ *
129
+ * @param {object} state — VisionStore (must expose getItemByFeatureCode, getPendingGates)
130
+ * @param {string|null} featureCode
131
+ * @param {string} now — ISO timestamp (injected for testability)
132
+ * @returns {StatusSnapshot}
133
+ */
134
+ export function computeStatusSnapshot(state, featureCode, now) {
135
+ const nowStr = now || new Date().toISOString();
136
+
137
+ // No feature selected → branch 1 baseline
138
+ if (!featureCode) {
139
+ return {
140
+ sentence: 'Select a feature to see status.',
141
+ active_goal: null,
142
+ active_phase: null,
143
+ pending_gates: [],
144
+ drift_alerts: [],
145
+ open_loops_count: 0,
146
+ gate_load_24h: 0, // TODO: real value when COMP-OBS-GATELOG ships
147
+ cta: null,
148
+ computed_at: nowStr,
149
+ };
150
+ }
151
+
152
+ const item = state.getItemByFeatureCode(featureCode);
153
+
154
+ // Feature not found in store — return no-feature snapshot
155
+ if (!item) {
156
+ return {
157
+ sentence: 'Select a feature to see status.',
158
+ active_goal: null,
159
+ active_phase: null,
160
+ pending_gates: [],
161
+ drift_alerts: [],
162
+ open_loops_count: 0,
163
+ gate_load_24h: 0,
164
+ cta: null,
165
+ computed_at: nowStr,
166
+ };
167
+ }
168
+
169
+ const lc = item.lifecycle;
170
+ const activePhase = lc?.currentPhase ?? null;
171
+ const ext = lc?.lifecycle_ext ?? {};
172
+
173
+ // Pending gates for this item
174
+ const pendingGates = state.getPendingGates(item.id);
175
+ const pendingGateIds = pendingGates.map(g => g.id);
176
+
177
+ // Drift alerts: only axes with breached:true
178
+ const driftAxes = ext.drift_axes ?? [];
179
+ const driftAlerts = driftAxes.filter(a => a.breached === true);
180
+
181
+ // Open loops — only unresolved entries count (COMP-OBS-LOOPS semantic fix)
182
+ const openLoops = ext.open_loops ?? [];
183
+ const openLoopsCount = openLoops.filter(l => l.resolution == null).length;
184
+
185
+ // Stale open loops: unresolved and past TTL — use shared isStaleLoop helper
186
+ const nowMs = Date.parse(nowStr);
187
+ const staleLoops = openLoops.filter(loop => isStaleLoop(loop, nowMs));
188
+ const staleLoopCount = staleLoops.length;
189
+
190
+ const iterationState = lc?.iterationState ?? null;
191
+
192
+ // Is active phase known? (for null/unknown guard)
193
+ const phaseKnown = activePhase === null || KNOWN_PHASES.has(activePhase);
194
+
195
+ // Build sentence — pass iterationState only when phase is non-terminal+known
196
+ const sentence = buildStatusSentence({
197
+ featureCode,
198
+ activePhase,
199
+ pendingGates,
200
+ driftAlerts,
201
+ staleLoopCount,
202
+ iterationState: phaseKnown && !TERMINAL_PHASES.has(activePhase) ? iterationState : null,
203
+ openLoopsCount,
204
+ featureExists: true,
205
+ });
206
+
207
+ return {
208
+ sentence,
209
+ active_goal: item.title || null,
210
+ active_phase: activePhase,
211
+ pending_gates: pendingGateIds,
212
+ drift_alerts: driftAlerts,
213
+ open_loops_count: openLoopsCount,
214
+ gate_load_24h: readGateLog({ since: nowMs - 86400000 }).length,
215
+ cta: null,
216
+ computed_at: nowStr,
217
+ };
218
+ }