@smartmemory/compose 0.3.7 → 0.3.8

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 (215) hide show
  1. package/.compose-deps.json +1 -13
  2. package/README.md +72 -5
  3. package/bin/compose.js +470 -351
  4. package/bin/judgment-migrate.js +387 -0
  5. package/contracts/comp-obs-contract.schema.json +9 -3
  6. package/contracts/fluid-record.schema.json +209 -0
  7. package/contracts/lifecycle-backfill.schema.json +322 -0
  8. package/dist/assets/App-Z4MU-H_F.js +916 -0
  9. package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
  10. package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
  11. package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
  12. package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
  13. package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
  14. package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  15. package/dist/assets/channel-SnZzzh7k.js +1 -0
  16. package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  17. package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
  18. package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  19. package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  20. package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  21. package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
  22. package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  23. package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  24. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  25. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  26. package/dist/assets/clone-DgklGjHm.js +1 -0
  27. package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  28. package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
  29. package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
  30. package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
  31. package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
  32. package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
  33. package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
  34. package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
  35. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
  36. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
  37. package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
  38. package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
  39. package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
  40. package/dist/assets/index-yyrA5OZd.css +1 -0
  41. package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
  42. package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
  43. package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  44. package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
  45. package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
  46. package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
  47. package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
  48. package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
  49. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  50. package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
  51. package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
  52. package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
  53. package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
  54. package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
  55. package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
  56. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  57. package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
  58. package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
  59. package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
  60. package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
  61. package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
  62. package/dist/index.html +3 -3
  63. package/lib/agent-string.js +7 -5
  64. package/lib/append-integrity.js +81 -0
  65. package/lib/backfill-evidence.js +109 -0
  66. package/lib/bug-escalation.js +9 -0
  67. package/lib/build-stream-schema.js +3 -1
  68. package/lib/build-stream-writer.js +25 -0
  69. package/lib/build.js +874 -170
  70. package/lib/canon-guard.js +28 -6
  71. package/lib/canon-override.js +196 -0
  72. package/lib/canon-registry.js +104 -0
  73. package/lib/cli-commands.js +144 -0
  74. package/lib/codex-preflight.js +26 -13
  75. package/lib/colleague/context.js +215 -0
  76. package/lib/colleague/writeback.js +95 -0
  77. package/lib/completion-gate.js +1421 -0
  78. package/lib/completion-writer.js +47 -47
  79. package/lib/consumer-fanout.js +105 -11
  80. package/lib/coverage-gate.js +200 -0
  81. package/lib/dir-lock.js +170 -0
  82. package/lib/dispatch-ledger.js +3 -3
  83. package/lib/feature-json.js +1 -1
  84. package/lib/feature-reconciler.js +8 -0
  85. package/lib/feature-validator.js +64 -1
  86. package/lib/feature-writer.js +57 -2
  87. package/lib/fluid/factory.js +167 -0
  88. package/lib/fluid/ideabox-dates.js +73 -0
  89. package/lib/fluid/ideabox-migrate.js +154 -0
  90. package/lib/fluid/ideabox-ops.js +585 -0
  91. package/lib/fluid/ideabox-view.js +146 -0
  92. package/lib/fluid/import-ideabox.js +186 -0
  93. package/lib/fluid/local-provider.js +606 -0
  94. package/lib/fluid/provider.js +684 -0
  95. package/lib/fluid/record-shape.js +214 -0
  96. package/lib/fluid/record-store.js +328 -0
  97. package/lib/fluid/render-ideabox.js +261 -0
  98. package/lib/fluid/schema.js +40 -0
  99. package/lib/fluid/smartmemory-provider.js +1695 -0
  100. package/lib/gsd.js +63 -23
  101. package/lib/guard-cli.js +175 -0
  102. package/lib/guard-custody.js +141 -0
  103. package/lib/guard-descriptors.js +530 -0
  104. package/lib/guard-enrol.js +254 -0
  105. package/lib/health-score.js +1 -1
  106. package/lib/ideabox-cli.js +315 -0
  107. package/lib/ideabox.js +121 -21
  108. package/lib/judgment/store/index.js +9 -1
  109. package/lib/judgment/store/records.js +1 -1
  110. package/lib/judgment/trace.js +380 -0
  111. package/lib/judgment-decision-write.js +277 -0
  112. package/lib/judgment-decisions.js +466 -0
  113. package/lib/judgment-gen.js +5 -1
  114. package/lib/judgment-writer.js +56 -2
  115. package/lib/lifecycle-modes.js +4 -4
  116. package/lib/lineage.js +400 -0
  117. package/lib/local-claude-connector.js +52 -1
  118. package/lib/maya-client.js +302 -0
  119. package/lib/maya-config.js +53 -0
  120. package/lib/maya-identity.js +283 -0
  121. package/lib/migrate-anon.js +5 -0
  122. package/lib/migrate-roadmap.js +15 -0
  123. package/lib/new.js +13 -1
  124. package/lib/pipeline-compat.js +104 -0
  125. package/lib/policy-catalog.js +295 -0
  126. package/lib/policy-check.js +0 -0
  127. package/lib/process-termination.js +98 -0
  128. package/lib/resolve-workspace.js +5 -1
  129. package/lib/result-normalizer.js +396 -199
  130. package/lib/roadmap-errors.js +65 -0
  131. package/lib/roadmap-preservers.js +24 -4
  132. package/lib/roadmap-residue.js +299 -0
  133. package/lib/smartmemory-client.js +614 -78
  134. package/lib/smartmemory-config.js +54 -0
  135. package/lib/smartmemory-ingest.js +19 -2
  136. package/lib/step-prompt.js +7 -6
  137. package/lib/stratum-engine.js +53 -4
  138. package/lib/stratum-mcp-client.js +271 -36
  139. package/lib/test-bootstrap.js +31 -0
  140. package/lib/tool-inventory.js +122 -0
  141. package/lib/version-check.js +91 -19
  142. package/lib/vision-writer.js +88 -1
  143. package/package.json +7 -6
  144. package/pipelines/bug-fix.stratum.yaml +205 -211
  145. package/pipelines/build-quick.profiles.json +12 -0
  146. package/pipelines/build-quick.stratum.yaml +263 -350
  147. package/pipelines/content.stratum.yaml +81 -77
  148. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  149. package/pipelines/plan.stratum.yaml +76 -86
  150. package/pipelines/refactor.stratum.yaml +125 -125
  151. package/pipelines/research.stratum.yaml +56 -58
  152. package/pipelines/review-fix.profiles.json +6 -0
  153. package/pipelines/review-fix.stratum.yaml +110 -83
  154. package/presets/team-feature.profiles.json +6 -0
  155. package/presets/team-feature.stratum.yaml +93 -66
  156. package/presets/team-research.profiles.json +6 -0
  157. package/presets/team-research.stratum.yaml +89 -80
  158. package/presets/team-review.profiles.json +8 -0
  159. package/presets/team-review.stratum.yaml +98 -80
  160. package/scripts/cost-census.mjs +70 -0
  161. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  162. package/server/agent-health.js +22 -0
  163. package/server/agent-hooks.js +14 -1
  164. package/server/agent-server.js +5 -248
  165. package/server/agent-spawn.js +3 -4
  166. package/server/agent-workspace.js +294 -0
  167. package/server/build-routes.js +6 -5
  168. package/server/build-stream-bridge.js +53 -0
  169. package/server/cc-session-watcher.js +4 -1
  170. package/server/coalescing-buffer.js +7 -1
  171. package/server/completion-projection.js +228 -0
  172. package/server/compose-mcp-tools.js +109 -23
  173. package/server/compose-mcp.js +88 -882
  174. package/server/decision-event-emit.js +41 -2
  175. package/server/decision-event-id.js +17 -0
  176. package/server/decision-events-snapshot.js +3 -0
  177. package/server/design-routes.js +14 -8
  178. package/server/feature-scan.js +76 -2
  179. package/server/file-watcher.js +170 -21
  180. package/server/ideabox-routes.js +166 -224
  181. package/server/index.js +70 -100
  182. package/server/lifecycle-guard.js +240 -10
  183. package/server/lifecycle-phase-history.js +276 -0
  184. package/server/maya-routes.js +507 -0
  185. package/server/mcp-tool-defs.js +940 -0
  186. package/server/mcp-tool-policy.js +34 -2
  187. package/server/model-tiers.js +22 -5
  188. package/server/pipeline-routes.js +21 -11
  189. package/server/project-root.js +58 -19
  190. package/server/remote-utils.js +3 -1
  191. package/server/schema-validator.js +7 -1
  192. package/server/session-manager.js +5 -6
  193. package/server/session-routes.js +3 -1
  194. package/server/stratum-client.js +57 -10
  195. package/server/stratum-sync.js +6 -3
  196. package/server/summarizer.js +3 -4
  197. package/server/supervisor.js +0 -1
  198. package/server/vision-routes.js +208 -98
  199. package/server/vision-server.js +86 -23
  200. package/server/vision-store.js +60 -6
  201. package/server/vision-utils.js +3 -4
  202. package/server/workspace-activity.js +18 -0
  203. package/server/workspace-middleware.js +2 -2
  204. package/server/workspace-runtime.js +243 -0
  205. package/server/worktree-gc.js +1 -0
  206. package/dist/assets/App-PkZzHeMj.js +0 -894
  207. package/dist/assets/channel-qVK_qn4E.js +0 -1
  208. package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
  209. package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
  210. package/dist/assets/clone-Pu3RyLUh.js +0 -1
  211. package/dist/assets/index-LIwREYgH.css +0 -1
  212. package/dist/assets/mobile-BnXEOE3U.js +0 -17
  213. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
  214. package/lib/staleness.js +0 -87
  215. package/server/ideabox-cache.js +0 -77
@@ -0,0 +1,530 @@
1
+ import { chmod, copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, symlink, writeFile } from 'node:fs/promises';
2
+ import { createHash } from 'node:crypto';
3
+ import path from 'node:path';
4
+
5
+ import { loadFeaturesDir } from './project-paths.js';
6
+ import { getMode } from './lifecycle-modes.js';
7
+ import { guardPolicy, guardList } from '../server/stratum-client.js';
8
+ import { resourceId } from '../server/lifecycle-guard.js';
9
+ import { HINT_ENROL } from './guard-custody.js';
10
+
11
+ const POLICY_KEYS = ['graph', 'edge_predicates', 'terminal', 'stakes'];
12
+ const MODES = ['build', 'fix', 'plan', 'judgment'];
13
+
14
+ export function descriptorIdFor(fromChecksum, mode) {
15
+ return `backfill-${mode}-${fromChecksum.slice(0, 12)}`;
16
+ }
17
+
18
+ /** Derive the signed target policy directly from a stored guard policy. */
19
+ export function deriveBackfillPolicy(storedPolicy, mode) {
20
+ const terminal = new Set(storedPolicy.terminal || []);
21
+ const graph = {};
22
+ for (const [phase, targets] of Object.entries(storedPolicy.graph || {})) {
23
+ if (terminal.has(phase)) {
24
+ graph[phase] = [...targets];
25
+ continue;
26
+ }
27
+ const next = [...targets].filter((target) => target !== 'complete_backfilled');
28
+ const killedAt = next.indexOf('killed');
29
+ if (killedAt === -1) next.push('complete_backfilled');
30
+ else next.splice(killedAt, 0, 'complete_backfilled');
31
+ graph[phase] = next;
32
+ }
33
+ graph.complete_backfilled = [];
34
+ return {
35
+ graph,
36
+ edge_predicates: storedPolicy.edge_predicates,
37
+ terminal: [...(storedPolicy.terminal || []).filter((phase) => phase !== 'complete_backfilled'), 'complete_backfilled'],
38
+ stakes: storedPolicy.stakes,
39
+ };
40
+ }
41
+
42
+ function assertPolicyShape(policy) {
43
+ if (!policy || typeof policy !== 'object' || Array.isArray(policy)) throw new TypeError('to_policy must be an object');
44
+ for (const key of Object.keys(policy)) {
45
+ if (!POLICY_KEYS.includes(key)) throw new TypeError(`unknown policy key ${JSON.stringify(key)}`);
46
+ }
47
+ for (const key of POLICY_KEYS) {
48
+ if (!Object.hasOwn(policy, key)) throw new TypeError(`to_policy is missing ${JSON.stringify(key)}`);
49
+ }
50
+ }
51
+
52
+ /** Create the exact bytes that the human operator signs. */
53
+ export function buildDescriptorFile(entries) {
54
+ const candidates = entries.map((entry) => {
55
+ const fromChecksum = entry.from_checksum ?? entry.fromChecksum;
56
+ const mode = entry.mode;
57
+ const toPolicy = entry.to_policy ?? entry.toPolicy ?? deriveBackfillPolicy(entry.storedPolicy, mode);
58
+ assertPolicyShape(toPolicy);
59
+ return {
60
+ id: descriptorIdFor(fromChecksum, mode),
61
+ rationale: `Add complete_backfilled to ${mode} guard policies; authorization scope is policy-wide.`,
62
+ from_checksum: fromChecksum,
63
+ to_policy: toPolicy,
64
+ };
65
+ }).sort((a, b) => a.id.localeCompare(b.id));
66
+ const unique = new Map();
67
+ for (const descriptor of candidates) {
68
+ if (!unique.has(descriptor.from_checksum)) unique.set(descriptor.from_checksum, descriptor);
69
+ }
70
+ const descriptors = [...unique.values()].sort((a, b) => a.id.localeCompare(b.id));
71
+ return `${JSON.stringify({ version: 1, descriptors }, null, 2)}\n`;
72
+ }
73
+
74
+ async function directoriesAt(root) {
75
+ try {
76
+ return (await readdir(root, { withFileTypes: true }))
77
+ .filter((entry) => entry.isDirectory())
78
+ .map((entry) => entry.name);
79
+ } catch (error) {
80
+ if (error?.code === 'ENOENT') return [];
81
+ throw error;
82
+ }
83
+ }
84
+
85
+ /** Same 12-hex workspace hash `resourceId` (server/lifecycle-guard.js) embeds in every id. */
86
+ function workspaceHash(workspaceRoot) {
87
+ return createHash('sha256').update(path.resolve(workspaceRoot)).digest('hex').slice(0, 12);
88
+ }
89
+
90
+ /**
91
+ * Parse a `compose:<hash>:<code>` (build, legacy) or `compose:<hash>:<mode>:<code>`
92
+ * (namespaced) resource id for this workspace's hash. Returns null for ids that don't
93
+ * belong to this workspace or whose mode segment isn't one of MODES.
94
+ */
95
+ function parseWorkspaceResourceId(resourceIdValue, hash) {
96
+ const prefix = `compose:${hash}:`;
97
+ if (typeof resourceIdValue !== 'string' || !resourceIdValue.startsWith(prefix)) return null;
98
+ const rest = resourceIdValue.slice(prefix.length);
99
+ const colonAt = rest.indexOf(':');
100
+ if (colonAt === -1) return { mode: 'build', featureCode: rest };
101
+ const maybeMode = rest.slice(0, colonAt);
102
+ if (maybeMode === 'build' || !MODES.includes(maybeMode)) return null;
103
+ return { mode: maybeMode, featureCode: rest.slice(colonAt + 1) };
104
+ }
105
+
106
+ /** Probe known lifecycle artifact roots because stratum intentionally has no list API. */
107
+ export async function enumerateByProbe(workspaceRoot, deps = {}) {
108
+ const { guardPolicy: guardPolicyFn = guardPolicy } = deps;
109
+ const results = [];
110
+ for (const mode of MODES) {
111
+ const artifactRoot = getMode(mode).runner.artifactRoot;
112
+ const root = artifactRoot === 'features'
113
+ ? path.resolve(workspaceRoot, loadFeaturesDir(workspaceRoot))
114
+ : path.resolve(workspaceRoot, artifactRoot);
115
+ for (const featureCode of await directoriesAt(root)) {
116
+ const resource = await guardPolicyFn(resourceId(featureCode, workspaceRoot, mode));
117
+ if (resource?.status === 'error' && resource.error_type === 'guard_not_found') continue;
118
+ if (!resource || resource.error || resource.status === 'error') {
119
+ throw new Error(`could not read guard policy for ${mode}:${featureCode}: ${resource?.message || resource?.error?.message || 'no guard response'}`);
120
+ }
121
+ if ((resource.terminal || []).includes(resource.current_state)) continue;
122
+ if ((resource.terminal || []).includes('complete_backfilled')) continue;
123
+ results.push({ from_checksum: resource.checksum, mode, storedPolicy: resource });
124
+ }
125
+ }
126
+ return results;
127
+ }
128
+
129
+ /**
130
+ * Discover registered guard resources for this workspace. Tries the fast path first —
131
+ * a single `stratum guard list` call scoped to this workspace's id prefix — and falls
132
+ * back to the historical per-directory probe when list is unsupported (older stratum,
133
+ * or any non-ok response).
134
+ */
135
+ export async function enumerateRegisteredResources(workspaceRoot, deps = {}) {
136
+ const { guardList: guardListFn = guardList, guardPolicy: guardPolicyFn = guardPolicy } = deps;
137
+ const hash = workspaceHash(workspaceRoot);
138
+ const listed = await guardListFn({ prefix: `compose:${hash}:` });
139
+ if (listed?.status !== 'ok' || !Array.isArray(listed.resources)) {
140
+ return enumerateByProbe(workspaceRoot, { guardPolicy: guardPolicyFn });
141
+ }
142
+ const results = [];
143
+ for (const resource of listed.resources) {
144
+ const parsed = parseWorkspaceResourceId(resource.resource_id, hash);
145
+ if (!parsed) continue;
146
+ if ((resource.terminal || []).includes(resource.current_state)) continue;
147
+ if ((resource.terminal || []).includes('complete_backfilled')) continue;
148
+ const full = await guardPolicyFn(resource.resource_id);
149
+ if (full?.status === 'error' && full.error_type === 'guard_not_found') continue;
150
+ if (!full || full.error || full.status === 'error') {
151
+ throw new Error(`could not read guard policy for ${parsed.mode}:${parsed.featureCode}: ${full?.message || full?.error?.message || 'no guard response'}`);
152
+ }
153
+ results.push({ from_checksum: full.checksum, mode: parsed.mode, storedPolicy: full });
154
+ }
155
+ return results;
156
+ }
157
+
158
+ export const GENERATIONS_DIR = '.compose/guard-upgrades';
159
+ export const LEGACY_FLAT = '.compose/guard-upgrades.json';
160
+ const DESCRIPTOR_FILE = 'descriptors.json';
161
+ const DESCRIPTOR_NAMESPACE = 'stratum-guard-descriptors';
162
+ const SHA_RE = /^[a-f0-9]{64}$/;
163
+
164
+ export function generationPaths(workspaceRoot, sha) {
165
+ const dir = path.resolve(workspaceRoot, GENERATIONS_DIR, sha);
166
+ return { dir, file: path.join(dir, DESCRIPTOR_FILE), sig: path.join(dir, `${DESCRIPTOR_FILE}.sig`) };
167
+ }
168
+
169
+ async function exists(file) {
170
+ try { await realpath(file); return true; } catch (error) { if (error?.code === 'ENOENT') return false; throw error; }
171
+ }
172
+
173
+ /**
174
+ * True for ANY directory entry at `sig` — a regular file, a working symlink, or a DANGLING
175
+ * symlink. Unlike exists() (which realpaths and so treats a dangling symlink as absent), this
176
+ * is the right check before deciding "no signature yet": a dangling .sig symlink must never be
177
+ * read as "unsigned candidate", because writing a signature through it would create a file
178
+ * wherever the symlink points, possibly outside the workspace.
179
+ */
180
+ async function sigEntryExists(sig) {
181
+ try { await lstat(sig); return true; } catch (error) { if (error?.code === 'ENOENT') return false; throw error; }
182
+ }
183
+
184
+ /**
185
+ * The generations directory itself must be a real directory inside the workspace, never a
186
+ * symlink (which would make every containment check below meaningless) and never resolve
187
+ * outside the workspace root. Returns its realpath.
188
+ */
189
+ async function assertGenerationsDirSafe(workspaceRoot) {
190
+ const base = path.resolve(workspaceRoot, GENERATIONS_DIR);
191
+ await mkdir(base, { recursive: true });
192
+ const baseLstat = await lstat(base);
193
+ if (baseLstat.isSymbolicLink()) {
194
+ throw new Error('generations directory is a symlink; remove it and retry');
195
+ }
196
+ const workspaceReal = await realpath(workspaceRoot);
197
+ const baseReal = await realpath(base);
198
+ if (baseReal !== workspaceReal && !baseReal.startsWith(workspaceReal + path.sep)) {
199
+ throw new Error('generations directory escapes the workspace');
200
+ }
201
+ return baseReal;
202
+ }
203
+
204
+ async function setCurrent(workspaceRoot, sha) {
205
+ const base = await assertGenerationsDirSafe(workspaceRoot);
206
+ const current = path.join(base, 'current');
207
+ const temporary = path.join(base, 'current.tmp');
208
+ await rm(temporary, { recursive: true, force: true });
209
+ await symlink(sha, temporary);
210
+ await rename(temporary, current);
211
+ }
212
+
213
+ /** Require a resolved (realpath'd) generation path to live inside the workspace generations directory. */
214
+ async function assertContained(workspaceRoot, resolvedPath) {
215
+ const baseReal = await assertGenerationsDirSafe(workspaceRoot);
216
+ if (resolvedPath !== baseReal && !resolvedPath.startsWith(baseReal + path.sep)) {
217
+ throw new Error('generation path escapes the workspace generations directory');
218
+ }
219
+ }
220
+
221
+ /** Resolve the immutable pair selected by the mutable current symlink. */
222
+ export async function currentGeneration(workspaceRoot) {
223
+ const base = path.resolve(workspaceRoot, GENERATIONS_DIR);
224
+ try {
225
+ const dir = await realpath(path.join(base, 'current'));
226
+ const sha = path.basename(dir);
227
+ if (!SHA_RE.test(sha)) return null;
228
+ await assertContained(workspaceRoot, dir);
229
+ const file = await realpath(path.join(dir, DESCRIPTOR_FILE));
230
+ await assertContained(workspaceRoot, file);
231
+ const sig = await realpath(path.join(dir, `${DESCRIPTOR_FILE}.sig`));
232
+ await assertContained(workspaceRoot, sig);
233
+ const bytes = await readFile(file);
234
+ const actualSha = createHash('sha256').update(bytes).digest('hex');
235
+ if (actualSha !== sha) throw new Error(`generation ${sha} bytes do not hash to its name`);
236
+ return { sha, file, sig };
237
+ } catch (error) {
238
+ if (error?.code === 'ENOENT') return null;
239
+ throw error;
240
+ }
241
+ }
242
+
243
+ /** Copy the historical flat pair into a generation, without deleting operator-owned legacy files.
244
+ * Never adopts (or makes current) a pair that does not verify. */
245
+ export async function adoptLegacyFlatPair(workspaceRoot, verifier) {
246
+ const legacy = path.resolve(workspaceRoot, LEGACY_FLAT);
247
+ const legacySig = `${legacy}.sig`;
248
+ if (!(await exists(legacy)) || !(await exists(legacySig))) return { adopted: false };
249
+
250
+ const base = await assertGenerationsDirSafe(workspaceRoot);
251
+ const staging = await mkdtemp(path.join(base, '.staging-'));
252
+ let stagingRemoved = false;
253
+ try {
254
+ const stagedFile = path.join(staging, DESCRIPTOR_FILE);
255
+ const stagedSig = path.join(staging, `${DESCRIPTOR_FILE}.sig`);
256
+ await copyFile(legacy, stagedFile);
257
+ await copyFile(legacySig, stagedSig);
258
+ await chmod(stagedFile, 0o600);
259
+ await chmod(stagedSig, 0o600);
260
+ await chmod(staging, 0o700);
261
+
262
+ const verdict = await verifyGeneration(workspaceRoot, stagedFile, verifier);
263
+ if (!verdict.verified) {
264
+ await rm(staging, { recursive: true, force: true });
265
+ stagingRemoved = true;
266
+ return { adopted: false, reason: 'legacy descriptor pair does not verify' };
267
+ }
268
+
269
+ const bytes = await readFile(stagedFile);
270
+ const sha = createHash('sha256').update(bytes).digest('hex');
271
+ const target = generationPaths(workspaceRoot, sha);
272
+ if (await exists(target.dir)) {
273
+ const existingLstat = await lstat(target.dir);
274
+ if (existingLstat.isSymbolicLink()) {
275
+ await rm(staging, { recursive: true, force: true });
276
+ stagingRemoved = true;
277
+ return { adopted: false, reason: `generation ${sha} is a symlink; remove it and retry` };
278
+ }
279
+ try {
280
+ await assertContained(workspaceRoot, await realpath(target.dir));
281
+ await assertContained(workspaceRoot, await realpath(target.file));
282
+ if (await sigEntryExists(target.sig)) {
283
+ if ((await lstat(target.sig)).isSymbolicLink()) {
284
+ throw new Error(`generation ${sha} signature is a symlink; remove it and retry`);
285
+ }
286
+ await assertContained(workspaceRoot, await realpath(target.sig));
287
+ }
288
+ } catch (error) {
289
+ await rm(staging, { recursive: true, force: true });
290
+ stagingRemoved = true;
291
+ return { adopted: false, reason: error.message };
292
+ }
293
+ const existingVerdict = await verifyGeneration(workspaceRoot, target.file, verifier);
294
+ // A generation with this content already exists; keep it, discard the redundant staging copy.
295
+ await rm(staging, { recursive: true, force: true });
296
+ stagingRemoved = true;
297
+ if (!existingVerdict.verified) {
298
+ return { adopted: false, reason: `existing generation ${sha} does not verify` };
299
+ }
300
+ } else {
301
+ await rename(staging, target.dir);
302
+ stagingRemoved = true;
303
+ }
304
+
305
+ if (!(await exists(path.join(workspaceRoot, GENERATIONS_DIR, 'current')))) {
306
+ await setCurrent(workspaceRoot, sha);
307
+ return { adopted: true };
308
+ }
309
+ return { adopted: false };
310
+ } finally {
311
+ if (!stagingRemoved) await rm(staging, { recursive: true, force: true });
312
+ }
313
+ }
314
+
315
+ /** Ask the real verifier and make its three-part safety verdict explicit. */
316
+ export async function verifyGeneration(_workspaceRoot, file, verifier) {
317
+ const result = await verifier(file);
318
+ return {
319
+ ...result,
320
+ verified: result?.status === 'ok'
321
+ && typeof result.signature === 'string'
322
+ && result.signature.startsWith('verified:')
323
+ && result.group_or_world_writable === false,
324
+ };
325
+ }
326
+
327
+ async function coversChecksums(file, needChecksums) {
328
+ const parsed = JSON.parse(await readFile(file, 'utf8'));
329
+ const descriptors = Array.isArray(parsed?.descriptors) ? parsed.descriptors : [];
330
+ return needChecksums.every((checksum) => descriptors.some((descriptor) =>
331
+ descriptor?.from_checksum === checksum || descriptor?.id === descriptorIdFor(checksum, descriptor?.id?.split('-')[1])));
332
+ }
333
+
334
+ function verificationText(result) {
335
+ return result?.signature || result?.message || 'not verified';
336
+ }
337
+
338
+ function refusal(code, message, hint = HINT_ENROL) {
339
+ return { status: 'refused', code, message, hint };
340
+ }
341
+
342
+ async function resolvedFile(file) {
343
+ return realpath(file);
344
+ }
345
+
346
+ /**
347
+ * Ensure an immutable, verified descriptor generation exists for these checksums.
348
+ * The caller owns the workspace descriptor lock; this function intentionally does not take one.
349
+ */
350
+ export async function ensureSignedDescriptors({ workspaceRoot, needChecksums, custody, verifier, enumerate = enumerateRegisteredResources }) {
351
+ let staging = null;
352
+ try {
353
+ await adoptLegacyFlatPair(workspaceRoot, verifier);
354
+ const bytes = Buffer.from(buildDescriptorFile(await enumerate(workspaceRoot)));
355
+ const sha = createHash('sha256').update(bytes).digest('hex');
356
+ const current = await currentGeneration(workspaceRoot);
357
+ if (current) {
358
+ const verdict = await verifyGeneration(workspaceRoot, current.file, verifier);
359
+ if (verdict.verified && await coversChecksums(current.file, needChecksums)) {
360
+ return { status: 'fresh', path: await resolvedFile(current.file), prompts: 0, sha: current.sha };
361
+ }
362
+ }
363
+
364
+ const target = generationPaths(workspaceRoot, sha);
365
+ const targetExists = await exists(target.dir);
366
+ let useExistingUnsigned = false;
367
+ if (targetExists) {
368
+ const targetLstat = await lstat(target.dir);
369
+ if (targetLstat.isSymbolicLink()) {
370
+ return refusal('upgrade_descriptor_unavailable', `generation ${sha} is a symlink; remove it and retry`, HINT_ENROL);
371
+ }
372
+ await assertContained(workspaceRoot, await realpath(target.dir));
373
+ await assertContained(workspaceRoot, await realpath(target.file));
374
+ if (await sigEntryExists(target.sig)) {
375
+ if ((await lstat(target.sig)).isSymbolicLink()) {
376
+ return refusal('upgrade_descriptor_unavailable', `generation ${sha} signature is a symlink; remove it and retry`, HINT_ENROL);
377
+ }
378
+ await assertContained(workspaceRoot, await realpath(target.sig));
379
+ const verdict = await verifyGeneration(workspaceRoot, target.file, verifier);
380
+ if (verdict.verified && await coversChecksums(target.file, needChecksums)) {
381
+ await setCurrent(workspaceRoot, sha);
382
+ return { status: 'fresh', path: await resolvedFile(target.file), prompts: 0, sha };
383
+ }
384
+ const reason = verdict?.group_or_world_writable
385
+ ? 'is group or world writable'
386
+ : `signature does not verify: ${verificationText(verdict)}`;
387
+ return refusal('upgrade_descriptor_unavailable', `generation ${sha} exists but ${reason}; remove it and retry`);
388
+ }
389
+ const candidateBytes = await readFile(target.file);
390
+ if (!candidateBytes.equals(bytes)) {
391
+ return refusal('upgrade_descriptor_unavailable', `generation ${sha} exists but its descriptor bytes do not match; remove it and retry`);
392
+ }
393
+ useExistingUnsigned = true;
394
+ } else {
395
+ const safeBase = await assertGenerationsDirSafe(workspaceRoot);
396
+ staging = await mkdtemp(path.join(safeBase, '.staging-'));
397
+ const stagedFile = path.join(staging, DESCRIPTOR_FILE);
398
+ await writeFile(stagedFile, bytes, { mode: 0o600 });
399
+ await chmod(stagedFile, 0o600);
400
+ }
401
+
402
+ const signed = await custody.sign({ bytes, namespace: DESCRIPTOR_NAMESPACE });
403
+ if (!signed?.ok) {
404
+ return refusal(signed?.code ?? 'upgrade_descriptor_unavailable', signed?.message ?? 'no signing custody on this platform', signed?.hint ?? HINT_ENROL);
405
+ }
406
+
407
+ const candidate = useExistingUnsigned ? target : { dir: staging, file: path.join(staging, DESCRIPTOR_FILE), sig: path.join(staging, `${DESCRIPTOR_FILE}.sig`) };
408
+ await writeFile(candidate.sig, signed.armored, { mode: 0o600 });
409
+ await chmod(candidate.sig, 0o600);
410
+ const verdict = await verifyGeneration(workspaceRoot, candidate.file, verifier);
411
+ if (!verdict.verified) {
412
+ if (staging) await rm(staging, { recursive: true, force: true });
413
+ else await rm(candidate.sig, { force: true });
414
+ staging = null;
415
+ return refusal('upgrade_descriptor_unavailable', `signature did not verify: ${verificationText(verdict)}`);
416
+ }
417
+
418
+ if (!useExistingUnsigned) {
419
+ try {
420
+ await rename(staging, target.dir);
421
+ staging = null;
422
+ } catch (error) {
423
+ // ENOTDIR covers renaming a directory onto a path that now exists as a non-directory
424
+ // (e.g. a symlink an attacker planted while we were staging) — the recovery path below
425
+ // re-inspects whatever now occupies target.dir instead of trusting it.
426
+ if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY' && error?.code !== 'ENOTDIR') throw error;
427
+ await rm(staging, { recursive: true, force: true });
428
+ staging = null;
429
+ const raceLstat = await lstat(target.dir);
430
+ if (raceLstat.isSymbolicLink()) {
431
+ return refusal('upgrade_descriptor_unavailable', `generation ${sha} is a symlink; remove it and retry`, HINT_ENROL);
432
+ }
433
+ await assertContained(workspaceRoot, await realpath(target.dir));
434
+ await assertContained(workspaceRoot, await realpath(target.file));
435
+ if (await sigEntryExists(target.sig)) {
436
+ if ((await lstat(target.sig)).isSymbolicLink()) {
437
+ return refusal('upgrade_descriptor_unavailable', `generation ${sha} signature is a symlink; remove it and retry`, HINT_ENROL);
438
+ }
439
+ await assertContained(workspaceRoot, await realpath(target.sig));
440
+ }
441
+ const raced = await verifyGeneration(workspaceRoot, target.file, verifier);
442
+ if (!raced.verified || !(await coversChecksums(target.file, needChecksums))) {
443
+ return refusal('upgrade_descriptor_unavailable', `generation ${sha} exists but its signature does not verify; remove it and retry`);
444
+ }
445
+ await setCurrent(workspaceRoot, sha);
446
+ return { status: 'fresh', path: await resolvedFile(target.file), prompts: 0, sha };
447
+ }
448
+ }
449
+ await setCurrent(workspaceRoot, sha);
450
+ return { status: 'signed', path: await resolvedFile(target.file), prompts: 1, sha };
451
+ } catch (error) {
452
+ return refusal('upgrade_descriptor_unavailable', error?.message || String(error));
453
+ } finally {
454
+ if (staging) await rm(staging, { recursive: true, force: true });
455
+ }
456
+ }
457
+
458
+ /** Write a durable but unsigned manual-signing candidate, without changing current. */
459
+ export async function prepareUnsignedCandidate(workspaceRoot, { enumerate = enumerateRegisteredResources } = {}) {
460
+ await assertGenerationsDirSafe(workspaceRoot);
461
+ const bytes = Buffer.from(buildDescriptorFile(await enumerate(workspaceRoot)));
462
+ const sha = createHash('sha256').update(bytes).digest('hex');
463
+ const target = generationPaths(workspaceRoot, sha);
464
+ if (await exists(target.dir)) {
465
+ const existing = await readFile(target.file);
466
+ if (!existing.equals(bytes)) throw new Error(`generation ${sha} exists but its descriptor bytes do not match`);
467
+ if (await sigEntryExists(target.sig)) {
468
+ if ((await lstat(target.sig)).isSymbolicLink()) {
469
+ throw new Error(`generation ${sha} signature is a symlink; remove it and retry`);
470
+ }
471
+ throw new Error(`generation ${sha} already has a signature; remove it and retry`);
472
+ }
473
+ return { sha, path: await resolvedFile(target.file) };
474
+ }
475
+ await mkdir(target.dir, { recursive: true, mode: 0o700 });
476
+ await writeFile(target.file, bytes, { mode: 0o600 });
477
+ await chmod(target.file, 0o600);
478
+ return { sha, path: await resolvedFile(target.file) };
479
+ }
480
+
481
+ function findGenerationReferences(value, found = new Set()) {
482
+ if (typeof value === 'string') {
483
+ const match = value.match(/guard-upgrades\/([a-f0-9]{64})(?:\/|$)/);
484
+ if (match) found.add(match[1]);
485
+ } else if (Array.isArray(value)) {
486
+ for (const item of value) findGenerationReferences(item, found);
487
+ } else if (value && typeof value === 'object') {
488
+ for (const [key, item] of Object.entries(value)) {
489
+ if (key === 'generation' && typeof item === 'string' && SHA_RE.test(item)) found.add(item);
490
+ findGenerationReferences(item, found);
491
+ }
492
+ }
493
+ return found;
494
+ }
495
+
496
+ /** Remove unreferenced immutable generations. The caller owns the descriptor lock. */
497
+ export async function pruneGenerations(workspaceRoot) {
498
+ await assertGenerationsDirSafe(workspaceRoot);
499
+ const base = path.resolve(workspaceRoot, GENERATIONS_DIR);
500
+ let currentSha = null;
501
+ try {
502
+ const currentDir = await realpath(path.join(base, 'current'));
503
+ if (SHA_RE.test(path.basename(currentDir))) currentSha = path.basename(currentDir);
504
+ } catch (error) {
505
+ if (error?.code !== 'ENOENT') throw error;
506
+ }
507
+ const keep = new Set(currentSha ? [currentSha] : []);
508
+ const intents = path.resolve(workspaceRoot, '.compose', 'data', 'completion-intents');
509
+ try {
510
+ for (const entry of await readdir(intents)) {
511
+ if (!entry.endsWith('.json')) continue;
512
+ try { findGenerationReferences(JSON.parse(await readFile(path.join(intents, entry), 'utf8')), keep); } catch { /* malformed intents are not deletion authority */ }
513
+ }
514
+ } catch (error) {
515
+ if (error?.code !== 'ENOENT') throw error;
516
+ }
517
+ const removed = [];
518
+ try {
519
+ for (const entry of await readdir(base, { withFileTypes: true })) {
520
+ // Dirent.isDirectory() is false for symlinks (it reports the dirent's own type without
521
+ // following the link), so a symlink entry is never a deletion candidate here.
522
+ if (!entry.isDirectory() || !SHA_RE.test(entry.name) || keep.has(entry.name)) continue;
523
+ await rm(path.join(base, entry.name), { recursive: true, force: true });
524
+ removed.push(entry.name);
525
+ }
526
+ } catch (error) {
527
+ if (error?.code !== 'ENOENT') throw error;
528
+ }
529
+ return { removed };
530
+ }