@smartmemory/compose 0.3.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/compose/SKILL.md +12 -3
- package/.compose-deps.json +51 -25
- package/README.md +79 -7
- package/bin/compose.js +495 -360
- package/bin/judgment-migrate.js +387 -0
- package/contracts/comp-obs-contract.schema.json +9 -3
- package/contracts/fluid-record.schema.json +209 -0
- package/contracts/lifecycle-backfill.schema.json +322 -0
- package/dist/assets/App-Z4MU-H_F.js +916 -0
- package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
- package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
- package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
- package/dist/assets/channel-SnZzzh7k.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
- package/dist/assets/clone-DgklGjHm.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
- package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
- package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
- package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
- package/dist/assets/index-yyrA5OZd.css +1 -0
- package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
- package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
- package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
- package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
- package/dist/assets/mobile-B7m9EO9D.js +17 -0
- package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
- package/dist/index.html +3 -3
- package/lib/agent-string.js +7 -5
- package/lib/append-integrity.js +81 -0
- package/lib/backfill-evidence.js +109 -0
- package/lib/bug-escalation.js +9 -0
- package/lib/build-stream-schema.js +3 -1
- package/lib/build-stream-writer.js +25 -0
- package/lib/build.js +874 -170
- package/lib/canon-guard.js +28 -6
- package/lib/canon-override.js +196 -0
- package/lib/canon-registry.js +104 -0
- package/lib/cli-commands.js +144 -0
- package/lib/codex-preflight.js +26 -13
- package/lib/colleague/context.js +215 -0
- package/lib/colleague/writeback.js +95 -0
- package/lib/completion-gate.js +1421 -0
- package/lib/completion-writer.js +47 -47
- package/lib/consumer-fanout.js +105 -11
- package/lib/coverage-gate.js +200 -0
- package/lib/deps.js +164 -7
- package/lib/dir-lock.js +170 -0
- package/lib/dispatch-ledger.js +3 -3
- package/lib/feature-json.js +1 -1
- package/lib/feature-reconciler.js +8 -0
- package/lib/feature-validator.js +64 -1
- package/lib/feature-writer.js +57 -2
- package/lib/fluid/factory.js +167 -0
- package/lib/fluid/ideabox-dates.js +73 -0
- package/lib/fluid/ideabox-migrate.js +154 -0
- package/lib/fluid/ideabox-ops.js +585 -0
- package/lib/fluid/ideabox-view.js +146 -0
- package/lib/fluid/import-ideabox.js +186 -0
- package/lib/fluid/local-provider.js +606 -0
- package/lib/fluid/provider.js +684 -0
- package/lib/fluid/record-shape.js +214 -0
- package/lib/fluid/record-store.js +328 -0
- package/lib/fluid/render-ideabox.js +261 -0
- package/lib/fluid/schema.js +40 -0
- package/lib/fluid/smartmemory-provider.js +1695 -0
- package/lib/gsd.js +63 -23
- package/lib/guard-cli.js +175 -0
- package/lib/guard-custody.js +141 -0
- package/lib/guard-descriptors.js +530 -0
- package/lib/guard-enrol.js +254 -0
- package/lib/health-score.js +1 -1
- package/lib/ideabox-cli.js +315 -0
- package/lib/ideabox.js +121 -21
- package/lib/judgment/store/index.js +9 -1
- package/lib/judgment/store/records.js +1 -1
- package/lib/judgment/trace.js +380 -0
- package/lib/judgment-decision-write.js +277 -0
- package/lib/judgment-decisions.js +466 -0
- package/lib/judgment-gen.js +5 -1
- package/lib/judgment-writer.js +56 -2
- package/lib/lifecycle-modes.js +4 -4
- package/lib/lineage.js +400 -0
- package/lib/local-claude-connector.js +52 -1
- package/lib/maya-client.js +302 -0
- package/lib/maya-config.js +53 -0
- package/lib/maya-identity.js +283 -0
- package/lib/migrate-anon.js +5 -0
- package/lib/migrate-roadmap.js +15 -0
- package/lib/new.js +13 -1
- package/lib/pipeline-compat.js +104 -0
- package/lib/policy-catalog.js +295 -0
- package/lib/policy-check.js +0 -0
- package/lib/process-termination.js +98 -0
- package/lib/resolve-workspace.js +5 -1
- package/lib/result-normalizer.js +396 -199
- package/lib/roadmap-errors.js +65 -0
- package/lib/roadmap-preservers.js +24 -4
- package/lib/roadmap-residue.js +299 -0
- package/lib/smartmemory-client.js +614 -78
- package/lib/smartmemory-config.js +54 -0
- package/lib/smartmemory-ingest.js +19 -2
- package/lib/step-prompt.js +7 -6
- package/lib/stratum-engine.js +53 -4
- package/lib/stratum-mcp-client.js +271 -36
- package/lib/test-bootstrap.js +31 -0
- package/lib/tool-inventory.js +122 -0
- package/lib/version-check.js +91 -19
- package/lib/vision-writer.js +88 -1
- package/package.json +7 -6
- package/pipelines/bug-fix.stratum.yaml +205 -211
- package/pipelines/build-quick.profiles.json +12 -0
- package/pipelines/build-quick.stratum.yaml +263 -350
- package/pipelines/content.stratum.yaml +81 -77
- package/pipelines/coverage-sweep.stratum.yaml +49 -30
- package/pipelines/plan.stratum.yaml +76 -86
- package/pipelines/refactor.stratum.yaml +125 -125
- package/pipelines/research.stratum.yaml +56 -58
- package/pipelines/review-fix.profiles.json +6 -0
- package/pipelines/review-fix.stratum.yaml +110 -83
- package/presets/team-feature.profiles.json +6 -0
- package/presets/team-feature.stratum.yaml +93 -66
- package/presets/team-research.profiles.json +6 -0
- package/presets/team-research.stratum.yaml +89 -80
- package/presets/team-review.profiles.json +8 -0
- package/presets/team-review.stratum.yaml +98 -80
- package/scripts/cost-census.mjs +70 -0
- package/scripts/guard-sign/compose-guard-sign.sh +62 -0
- package/server/agent-health.js +22 -0
- package/server/agent-hooks.js +14 -1
- package/server/agent-server.js +5 -248
- package/server/agent-spawn.js +3 -4
- package/server/agent-workspace.js +294 -0
- package/server/build-routes.js +6 -5
- package/server/build-stream-bridge.js +53 -0
- package/server/cc-session-watcher.js +4 -1
- package/server/coalescing-buffer.js +7 -1
- package/server/completion-projection.js +228 -0
- package/server/compose-mcp-tools.js +109 -23
- package/server/compose-mcp.js +88 -882
- package/server/decision-event-emit.js +41 -2
- package/server/decision-event-id.js +17 -0
- package/server/decision-events-snapshot.js +3 -0
- package/server/design-routes.js +14 -8
- package/server/feature-scan.js +76 -2
- package/server/file-watcher.js +170 -21
- package/server/ideabox-routes.js +166 -224
- package/server/index.js +70 -100
- package/server/lifecycle-guard.js +240 -10
- package/server/lifecycle-phase-history.js +276 -0
- package/server/maya-routes.js +507 -0
- package/server/mcp-tool-defs.js +940 -0
- package/server/mcp-tool-policy.js +34 -2
- package/server/model-tiers.js +22 -5
- package/server/pipeline-routes.js +21 -11
- package/server/project-root.js +58 -19
- package/server/remote-utils.js +3 -1
- package/server/schema-validator.js +7 -1
- package/server/session-manager.js +5 -6
- package/server/session-routes.js +3 -1
- package/server/stratum-client.js +57 -10
- package/server/stratum-sync.js +6 -3
- package/server/summarizer.js +3 -4
- package/server/supervisor.js +0 -1
- package/server/vision-routes.js +208 -98
- package/server/vision-server.js +86 -23
- package/server/vision-store.js +60 -6
- package/server/vision-utils.js +3 -4
- package/server/workspace-activity.js +18 -0
- package/server/workspace-middleware.js +2 -2
- package/server/workspace-runtime.js +243 -0
- package/server/worktree-gc.js +1 -0
- package/dist/assets/App-PkZzHeMj.js +0 -894
- package/dist/assets/channel-qVK_qn4E.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
- package/dist/assets/clone-Pu3RyLUh.js +0 -1
- package/dist/assets/index-LIwREYgH.css +0 -1
- package/dist/assets/mobile-BnXEOE3U.js +0 -17
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
- package/lib/staleness.js +0 -87
- package/server/ideabox-cache.js +0 -77
package/server/vision-store.js
CHANGED
|
@@ -124,7 +124,10 @@ export class VisionStore {
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
/** Save state to disk atomically (temp file + rename)
|
|
127
|
+
/** Save state to disk atomically (temp file + rename).
|
|
128
|
+
* Returns true when the state reached disk, false when it did not. Existing
|
|
129
|
+
* callers ignore the return and keep their prior log-and-continue behavior;
|
|
130
|
+
* callers that must not report success on a failed write check it. */
|
|
128
131
|
_save() {
|
|
129
132
|
try {
|
|
130
133
|
fs.mkdirSync(this._dataDir, { recursive: true });
|
|
@@ -132,8 +135,10 @@ export class VisionStore {
|
|
|
132
135
|
const tmp = path.join(this._dataDir, `vision-state.json.tmp.${Date.now()}`);
|
|
133
136
|
fs.writeFileSync(tmp, data, 'utf-8');
|
|
134
137
|
fs.renameSync(tmp, this._dataFile);
|
|
138
|
+
return true;
|
|
135
139
|
} catch (err) {
|
|
136
140
|
console.error('[vision] Failed to save state:', err.message);
|
|
141
|
+
return false;
|
|
137
142
|
}
|
|
138
143
|
}
|
|
139
144
|
|
|
@@ -200,7 +205,7 @@ export class VisionStore {
|
|
|
200
205
|
throw new Error(`Invalid phase: ${updates.phase}`);
|
|
201
206
|
}
|
|
202
207
|
|
|
203
|
-
const allowed = ['type', 'title', 'description', 'confidence', 'status', 'phase', 'position', 'parentId', 'summary', 'files', 'featureCode', 'stratumFlowId', 'evidence', 'group', 'priority'];
|
|
208
|
+
const allowed = ['type', 'title', 'description', 'confidence', 'status', 'phase', 'position', 'parentId', 'summary', 'files', 'featureCode', 'stratumFlowId', 'evidence', 'group', 'priority', 'completion_projection'];
|
|
204
209
|
for (const key of allowed) {
|
|
205
210
|
if (updates[key] !== undefined) {
|
|
206
211
|
item[key] = updates[key];
|
|
@@ -221,7 +226,7 @@ export class VisionStore {
|
|
|
221
226
|
item.updatedAt = new Date().toISOString();
|
|
222
227
|
|
|
223
228
|
this.items.set(id, item);
|
|
224
|
-
this._save();
|
|
229
|
+
this.lastSaveOk = this._save();
|
|
225
230
|
return item;
|
|
226
231
|
}
|
|
227
232
|
|
|
@@ -243,7 +248,7 @@ export class VisionStore {
|
|
|
243
248
|
}
|
|
244
249
|
item.updatedAt = new Date().toISOString();
|
|
245
250
|
this.items.set(id, item);
|
|
246
|
-
this._save();
|
|
251
|
+
this.lastSaveOk = this._save();
|
|
247
252
|
return item;
|
|
248
253
|
}
|
|
249
254
|
|
|
@@ -262,14 +267,54 @@ export class VisionStore {
|
|
|
262
267
|
return item;
|
|
263
268
|
}
|
|
264
269
|
|
|
265
|
-
/**
|
|
270
|
+
/** Set the additive `fluid_ext` namespace on an item — bypasses the generic
|
|
271
|
+
* updateItem allowlist, exactly as updateLifecycle/updateLifecycleExt do for
|
|
272
|
+
* the lifecycle namespace.
|
|
273
|
+
*
|
|
274
|
+
* The fluid-store provider seam (lib/fluid/, PROVIDER-SEAM ruling) is the only
|
|
275
|
+
* caller. It exists because a fluid record carries fields the vision store has
|
|
276
|
+
* no generic slot for (handle, cluster, tags, source, links) and the ruling
|
|
277
|
+
* forbids a second store — so the record rides in one additive namespace on
|
|
278
|
+
* the item rather than leaking record-specific fields into the allowlist.
|
|
279
|
+
*
|
|
280
|
+
* `touch: false` preserves updatedAt, which the provider uses when writing the
|
|
281
|
+
* namespace as part of record CREATION so created_at and updated_at agree. */
|
|
282
|
+
setFluidExt(id, ext, { touch = true } = {}) {
|
|
283
|
+
const item = this.items.get(id);
|
|
284
|
+
if (!item) throw new Error(`Item not found: ${id}`);
|
|
285
|
+
const priorExt = item.fluid_ext;
|
|
286
|
+
const priorUpdatedAt = item.updatedAt;
|
|
287
|
+
item.fluid_ext = ext;
|
|
288
|
+
if (touch) item.updatedAt = new Date().toISOString();
|
|
289
|
+
this.items.set(id, item);
|
|
290
|
+
if (!this._save()) {
|
|
291
|
+
// Roll the in-memory change back and fail. Reporting success here would
|
|
292
|
+
// let the provider append a lifecycle event asserting a change that
|
|
293
|
+
// vanishes on restart — a log that disagrees with the store is worse than
|
|
294
|
+
// a write that visibly failed.
|
|
295
|
+
item.fluid_ext = priorExt;
|
|
296
|
+
item.updatedAt = priorUpdatedAt;
|
|
297
|
+
this.items.set(id, item);
|
|
298
|
+
throw new Error(`[vision] failed to persist fluid_ext for item ${id}`);
|
|
299
|
+
}
|
|
300
|
+
return item;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Delete an item and all its connections and gates.
|
|
304
|
+
* Throws when the deletion does not reach disk — reporting success on a
|
|
305
|
+
* failed save would let a caller believe a record is gone while it is still
|
|
306
|
+
* on disk to reappear at the next load. */
|
|
266
307
|
deleteItem(id) {
|
|
267
308
|
if (!this.items.has(id)) throw new Error(`Item not found: ${id}`);
|
|
309
|
+
const removedItem = this.items.get(id);
|
|
310
|
+
const removedConnections = [];
|
|
311
|
+
const removedGates = [];
|
|
268
312
|
this.items.delete(id);
|
|
269
313
|
|
|
270
314
|
// Remove connections referencing this item
|
|
271
315
|
for (const [connId, conn] of this.connections) {
|
|
272
316
|
if (conn.fromId === id || conn.toId === id) {
|
|
317
|
+
removedConnections.push([connId, conn]);
|
|
273
318
|
this.connections.delete(connId);
|
|
274
319
|
}
|
|
275
320
|
}
|
|
@@ -277,11 +322,20 @@ export class VisionStore {
|
|
|
277
322
|
// Remove gates associated with this item
|
|
278
323
|
for (const [gateId, gate] of this.gates) {
|
|
279
324
|
if (gate.itemId === id) {
|
|
325
|
+
removedGates.push([gateId, gate]);
|
|
280
326
|
this.gates.delete(gateId);
|
|
281
327
|
}
|
|
282
328
|
}
|
|
283
329
|
|
|
284
|
-
this._save()
|
|
330
|
+
if (!this._save()) {
|
|
331
|
+
// Restore everything the delete removed, then fail. The alternative is an
|
|
332
|
+
// in-memory state that disagrees with disk and a caller told the record
|
|
333
|
+
// is gone when the next load will bring it back.
|
|
334
|
+
this.items.set(id, removedItem);
|
|
335
|
+
for (const [connId, conn] of removedConnections) this.connections.set(connId, conn);
|
|
336
|
+
for (const [gateId, gate] of removedGates) this.gates.set(gateId, gate);
|
|
337
|
+
throw new Error(`[vision] failed to persist deletion of item ${id}`);
|
|
338
|
+
}
|
|
285
339
|
return { ok: true };
|
|
286
340
|
}
|
|
287
341
|
|
package/server/vision-utils.js
CHANGED
|
@@ -13,7 +13,6 @@ import { fileURLToPath } from 'node:url';
|
|
|
13
13
|
import { getTargetRoot } from './project-root.js';
|
|
14
14
|
import { resolveJournalPathFromConfig, relForDisplay } from '../lib/project-paths.js';
|
|
15
15
|
|
|
16
|
-
const PROJECT_ROOT = getTargetRoot();
|
|
17
16
|
|
|
18
17
|
/**
|
|
19
18
|
* Read the compose.json config for an ARBITRARY workspace root (not the bound
|
|
@@ -83,9 +82,9 @@ export function detectError(tool, input, responseText) {
|
|
|
83
82
|
*
|
|
84
83
|
* @param {object} session — serialized session object
|
|
85
84
|
* @param {string} transcriptPath
|
|
86
|
-
* @param {string} [projectRoot] — defaults to
|
|
85
|
+
* @param {string} [projectRoot] — defaults to the current request workspace
|
|
87
86
|
*/
|
|
88
|
-
export function spawnJournalAgent(session, transcriptPath, projectRoot =
|
|
87
|
+
export function spawnJournalAgent(session, transcriptPath, projectRoot = getTargetRoot()) {
|
|
89
88
|
// Resolve the journal path against the PASSED projectRoot's own config — not
|
|
90
89
|
// the process-global cached config of the bound target (COMP-PATHS-EXTERNAL).
|
|
91
90
|
const journalAbs = resolveJournalPathFromConfig(projectRoot, loadComposeConfig(projectRoot));
|
|
@@ -126,7 +125,7 @@ Session data:
|
|
|
126
125
|
After writing the entry, update ${journalRel}/README.md with the new entry row.
|
|
127
126
|
Then commit both files.`;
|
|
128
127
|
|
|
129
|
-
const cleanEnv = { ...process.env, NO_COLOR: '1' };
|
|
128
|
+
const cleanEnv = { ...process.env, COMPOSE_TARGET: projectRoot, NO_COLOR: '1' };
|
|
130
129
|
delete cleanEnv.CLAUDECODE;
|
|
131
130
|
const proc = spawn('claude', ['-p', prompt, '--dangerously-skip-permissions'], {
|
|
132
131
|
cwd: projectRoot,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** Persisted work is shared by CLI, vision and SDK processes. Missing is idle;
|
|
5
|
+
* unreadable state is uncertain and must never authorize eviction. */
|
|
6
|
+
export function hasPersistedWork(dataDir) {
|
|
7
|
+
const read = file => {
|
|
8
|
+
try { return JSON.parse(readFileSync(path.join(dataDir, file), 'utf8')); }
|
|
9
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
10
|
+
};
|
|
11
|
+
try {
|
|
12
|
+
const build = read('active-build.json');
|
|
13
|
+
if (build && !['complete', 'completed', 'failed', 'aborted', 'cancelled'].includes(build.status)) return true;
|
|
14
|
+
const agents = read('agents.json');
|
|
15
|
+
const records = Array.isArray(agents) ? agents : Object.values(agents ?? {});
|
|
16
|
+
return records.some(agent => agent?.status === 'running');
|
|
17
|
+
} catch { return true; }
|
|
18
|
+
}
|
|
@@ -41,7 +41,7 @@ const EXEMPT_PATHS = new Set([
|
|
|
41
41
|
* the workspace header soft-fallback to the target root with a hint header.
|
|
42
42
|
* When false, missing header surfaces as a WorkspaceUnknown(null) → 400.
|
|
43
43
|
*/
|
|
44
|
-
export function createWorkspaceMiddleware({ allowGetFallback = true } = {}) {
|
|
44
|
+
export function createWorkspaceMiddleware({ allowGetFallback = true, resolveKnownWorkspace = () => null } = {}) {
|
|
45
45
|
return function workspaceMiddleware(req, res, next) {
|
|
46
46
|
if (EXEMPT_PATHS.has(req.path)) {
|
|
47
47
|
req.workspace = { id: null, root: getTargetRoot(), source: 'exempt' };
|
|
@@ -60,7 +60,7 @@ export function createWorkspaceMiddleware({ allowGetFallback = true } = {}) {
|
|
|
60
60
|
// Hard-fail mode: missing header is treated as an unknown id.
|
|
61
61
|
throw new WorkspaceUnknown(null);
|
|
62
62
|
}
|
|
63
|
-
const resolved = resolveWorkspace({
|
|
63
|
+
const resolved = resolveKnownWorkspace(headerId) || resolveWorkspace({
|
|
64
64
|
workspaceId: headerId,
|
|
65
65
|
cwd: getTargetRoot(),
|
|
66
66
|
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/** A workspace owns its stores, routes and background services for its lifetime.
|
|
2
|
+
* Requests and async work keep their binding even when the UI switches projects.
|
|
3
|
+
*/
|
|
4
|
+
import express from 'express';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { realpathSync } from 'node:fs';
|
|
7
|
+
import { hasPersistedWork } from './workspace-activity.js';
|
|
8
|
+
import { hasDesignWork, closeDesignStratum } from './design-routes.js';
|
|
9
|
+
import { FileWatcherServer } from './file-watcher.js';
|
|
10
|
+
import { VisionStore } from './vision-store.js';
|
|
11
|
+
import { VisionServer } from './vision-server.js';
|
|
12
|
+
import { SessionManager } from './session-manager.js';
|
|
13
|
+
import { attachGraphLayoutRoutes } from './graph-layout-routes.js';
|
|
14
|
+
import { attachAgentProxy } from './remote-utils.js';
|
|
15
|
+
import { scanFeatures, seedFeatures, scanSubPackages, seedSubPackages } from './feature-scan.js';
|
|
16
|
+
import { prepareProject, withProjectContext, resolveProjectPath, switchProject } from './project-root.js';
|
|
17
|
+
import { deriveId } from '../lib/discover-workspaces.js';
|
|
18
|
+
import { probeStratumBin, resolveStratumBin } from '../lib/stratum-engine.js';
|
|
19
|
+
|
|
20
|
+
export class WorkspaceRuntime {
|
|
21
|
+
constructor(httpServer, { agentPort = 4002, maxWorkspaces = 8 } = {}) {
|
|
22
|
+
this.server = httpServer;
|
|
23
|
+
this.agentPort = agentPort;
|
|
24
|
+
if (!Number.isInteger(maxWorkspaces) || maxWorkspaces < 1) throw new Error("maxWorkspaces must be a positive integer");
|
|
25
|
+
this.maxWorkspaces = maxWorkspaces;
|
|
26
|
+
this.contexts = new Map();
|
|
27
|
+
this.active = null;
|
|
28
|
+
// Monotonic stamp for LRU ordering; Date.now() ties within a millisecond.
|
|
29
|
+
this._tick = 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Record a workspace as most-recently-used. */
|
|
33
|
+
touch(context) { if (context) context.usedAt = ++this._tick; return context; }
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether a retained workspace still has work that eviction would destroy.
|
|
37
|
+
* The active workspace is never evictable. Everything else is judged on
|
|
38
|
+
* signals the runtime can see: live vision/file sockets, an open session, a
|
|
39
|
+
* running build (`active-build.json`) and running spawned agents
|
|
40
|
+
* (`agents.json`). `isBusy` lets the host add its own signal.
|
|
41
|
+
*/
|
|
42
|
+
busy(context) {
|
|
43
|
+
if (context === this.active) return true;
|
|
44
|
+
if (context.visionServer?.clients?.size) return true;
|
|
45
|
+
if (context.fileWatcher?.clients?.size) return true;
|
|
46
|
+
if (context.sessionManager?.currentSession) return true;
|
|
47
|
+
if (context.requests || context.binding.activeWork) return true;
|
|
48
|
+
if (context.visionServer?._healthMonitor?.hasActiveProcesses) return true;
|
|
49
|
+
if (hasDesignWork(context.binding.targetRoot)) return true;
|
|
50
|
+
if (hasPersistedWork(context.binding.dataDir)) return true;
|
|
51
|
+
return Boolean(this.isBusy?.(context));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Close the least-recently-used idle workspace to make room for another.
|
|
56
|
+
* Retention is an LRU cache, not a hard wall: refusing at the cap stranded
|
|
57
|
+
* users behind "restart to release retained sessions" while every retained
|
|
58
|
+
* workspace sat idle. The cap still refuses when nothing is evictable.
|
|
59
|
+
* @returns {boolean} whether a workspace was evicted
|
|
60
|
+
*/
|
|
61
|
+
evictIdle() {
|
|
62
|
+
let victimKey = null; let victim = null;
|
|
63
|
+
for (const [key, context] of this.contexts) {
|
|
64
|
+
if (this.busy(context)) continue;
|
|
65
|
+
if (!victim || (context.usedAt ?? 0) < (victim.usedAt ?? 0)) { victimKey = key; victim = context; }
|
|
66
|
+
}
|
|
67
|
+
if (!victim) return false;
|
|
68
|
+
victim.fileWatcher.close();
|
|
69
|
+
victim.visionServer.close();
|
|
70
|
+
void closeDesignStratum(victim.binding.targetRoot);
|
|
71
|
+
this.contexts.delete(victimKey);
|
|
72
|
+
console.error(`[workspace] Evicted idle workspace ${victim.binding.targetRoot} (cap ${this.maxWorkspaces})`);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Context-map key. The agent server (`createAgentApp`) realpaths its target
|
|
78
|
+
* root, so keying on `path.resolve` alone made `/var/...` and
|
|
79
|
+
* `/private/var/...` two entries for ONE directory — the two capacity counts
|
|
80
|
+
* then disagreed. Both sides now key on the real path.
|
|
81
|
+
*/
|
|
82
|
+
key(root) {
|
|
83
|
+
const resolved = path.resolve(root);
|
|
84
|
+
try { return realpathSync(resolved); } catch { return resolved; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The retained context for a root, keyed on its real path. */
|
|
88
|
+
get(root) { return this.contexts.get(this.key(root)); }
|
|
89
|
+
|
|
90
|
+
prepare(root, config) {
|
|
91
|
+
const existing = this.get(root);
|
|
92
|
+
// Validate the destination before evicting any retained state.
|
|
93
|
+
const binding = prepareProject(root);
|
|
94
|
+
if (!existing && this.contexts.size >= this.maxWorkspaces && !this.evictIdle()) {
|
|
95
|
+
throw Object.assign(new Error(`Workspace capacity reached (${this.maxWorkspaces}); every retained workspace is busy (open sockets, a live session, a running build or agent)`), { code: 'WorkspaceCapacityExceeded' });
|
|
96
|
+
}
|
|
97
|
+
if (config) binding.config = config;
|
|
98
|
+
else if (binding.config.capabilities?.stratum) {
|
|
99
|
+
const probe = probeStratumBin(resolveStratumBin('cli', binding.targetRoot));
|
|
100
|
+
if (!probe.ok) binding.config.capabilities.stratum = false;
|
|
101
|
+
}
|
|
102
|
+
if (existing) {
|
|
103
|
+
withProjectContext(existing.binding, () => existing.visionServer.refreshConfig(binding.config));
|
|
104
|
+
// One holder, two references: the binding and the vision server must not
|
|
105
|
+
// drift apart, or a request resolved through the binding reads a config
|
|
106
|
+
// the routes no longer use.
|
|
107
|
+
existing.binding.config = existing.visionServer.config;
|
|
108
|
+
return this.touch(existing);
|
|
109
|
+
}
|
|
110
|
+
return withProjectContext(binding, () => {
|
|
111
|
+
const router = express.Router();
|
|
112
|
+
const fileWatcher = new FileWatcherServer({ projectRoot: binding.targetRoot });
|
|
113
|
+
const store = new VisionStore(binding.dataDir);
|
|
114
|
+
const sessionManager = new SessionManager({
|
|
115
|
+
projectRoot: binding.targetRoot,
|
|
116
|
+
sessionsFile: path.join(binding.dataDir, 'sessions.json'),
|
|
117
|
+
featureRoot: resolveProjectPath('features'),
|
|
118
|
+
getFeaturePhase: (code) => store.getItemByFeatureCode(code)?.lifecycle?.currentPhase || null,
|
|
119
|
+
});
|
|
120
|
+
const visionServer = new VisionServer(store, sessionManager, { config: binding.config });
|
|
121
|
+
const context = { binding, router, fileWatcher, store, sessionManager, visionServer };
|
|
122
|
+
try {
|
|
123
|
+
attachGraphLayoutRoutes(router);
|
|
124
|
+
attachAgentProxy(router, { agentPort: this.agentPort });
|
|
125
|
+
fileWatcher.attach(this.server, router);
|
|
126
|
+
visionServer.attach(this.server, router);
|
|
127
|
+
const reseed = () => {
|
|
128
|
+
seedFeatures(scanFeatures(), store);
|
|
129
|
+
seedSubPackages(scanSubPackages(), store);
|
|
130
|
+
};
|
|
131
|
+
reseed();
|
|
132
|
+
fileWatcher.onFeatureChanged = () => {
|
|
133
|
+
try { reseed(); visionServer.scheduleBroadcast(); }
|
|
134
|
+
catch (err) { console.error('[compose] Feature reseed error:', err.message); }
|
|
135
|
+
};
|
|
136
|
+
fileWatcher.onBuildStateChanged = (state) => {
|
|
137
|
+
if (state) visionServer.broadcastMessage({ type: 'buildState', ...state });
|
|
138
|
+
};
|
|
139
|
+
fileWatcher.onSpecChanged = fileWatcher.onIdeaboxChanged = (msg) => visionServer.broadcastMessage(msg);
|
|
140
|
+
return context;
|
|
141
|
+
} catch (err) {
|
|
142
|
+
fileWatcher.close();
|
|
143
|
+
visionServer.close();
|
|
144
|
+
throw err;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
switch(root, config) {
|
|
150
|
+
// Preparation may fail. Never mutate the current root/services until it succeeds.
|
|
151
|
+
const next = this.prepare(root, config);
|
|
152
|
+
if (next === this.active) {
|
|
153
|
+
// C8: switching to the workspace already active is a config refresh, not
|
|
154
|
+
// a workspace change. suspend() closes every vision WebSocket with 1000,
|
|
155
|
+
// so the old suspend/resume pair dropped the whole cockpit on a no-op
|
|
156
|
+
// switch. prepare() has already refreshed the config in place; restart
|
|
157
|
+
// only the OS watchers (paths may have moved) and re-arm the background
|
|
158
|
+
// services that a config refresh stopped. Neither closes a socket.
|
|
159
|
+
withProjectContext(next.binding, () => {
|
|
160
|
+
next.fileWatcher.stopWatching(); next.fileWatcher.startWatching();
|
|
161
|
+
next.visionServer.resume(); switchProject(next.binding.targetRoot);
|
|
162
|
+
});
|
|
163
|
+
return next;
|
|
164
|
+
}
|
|
165
|
+
const retained = this.get(next.binding.targetRoot) !== undefined;
|
|
166
|
+
try {
|
|
167
|
+
if (retained) withProjectContext(next.binding, () => {
|
|
168
|
+
next.fileWatcher.startWatching();
|
|
169
|
+
next.visionServer.resume();
|
|
170
|
+
});
|
|
171
|
+
switchProject(next.binding.targetRoot);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
if (retained) {
|
|
174
|
+
next.fileWatcher.stopWatching(); next.visionServer.suspend();
|
|
175
|
+
} else {
|
|
176
|
+
next.fileWatcher.close(); next.visionServer.close();
|
|
177
|
+
}
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
180
|
+
if (this.active) {
|
|
181
|
+
this.active.fileWatcher.stopWatching();
|
|
182
|
+
for (const client of this.active.fileWatcher.clients) client.close(1000, 'Workspace changed');
|
|
183
|
+
this.active.fileWatcher.clients.clear();
|
|
184
|
+
this.active.visionServer.suspend();
|
|
185
|
+
}
|
|
186
|
+
this.contexts.set(this.key(next.binding.targetRoot), next);
|
|
187
|
+
this.active = this.touch(next);
|
|
188
|
+
return next;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
resolveKnownWorkspace(id) {
|
|
192
|
+
const matches = [...this.contexts.values()].filter(c => deriveId({ root: c.binding.targetRoot }).id === id);
|
|
193
|
+
if (matches.length > 1) {
|
|
194
|
+
const err = new Error(`Workspace id collision: ${id}`);
|
|
195
|
+
Object.assign(err, { code: 'WorkspaceIdCollision', id, roots: matches.map(c => c.binding.targetRoot) });
|
|
196
|
+
throw err;
|
|
197
|
+
}
|
|
198
|
+
return matches.length ? { id, root: matches[0].binding.targetRoot, source: 'explicit-header' } : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
handle(req, res, next) {
|
|
202
|
+
let context = req.workspace?.root ? this.get(req.workspace.root) : this.active;
|
|
203
|
+
// C6: ONLY workspace preparation is translated into a workspace error. A
|
|
204
|
+
// route that throws is a route bug, not an unavailable workspace, and
|
|
205
|
+
// reporting it as `400 {error: <internal text>}` both mislabels it and
|
|
206
|
+
// leaks internals past the error handler.
|
|
207
|
+
try {
|
|
208
|
+
if (!context && req.workspace?.root) {
|
|
209
|
+
context = this.prepare(req.workspace.root);
|
|
210
|
+
context.fileWatcher.stopWatching(); context.visionServer.suspend();
|
|
211
|
+
this.contexts.set(this.key(context.binding.targetRoot), context);
|
|
212
|
+
}
|
|
213
|
+
} catch (error) {
|
|
214
|
+
return res.status(error.code === 'WorkspaceCapacityExceeded' ? 409 : 400).json({ error: error.message, code: error.code ?? 'WorkspaceUnavailable' });
|
|
215
|
+
}
|
|
216
|
+
if (!context) return res.status(409).json({ error: 'No workspace selected', code: 'WorkspaceUnset', root: null });
|
|
217
|
+
this.touch(context);
|
|
218
|
+
context.requests = (context.requests ?? 0) + 1;
|
|
219
|
+
let released = false;
|
|
220
|
+
const release = () => {
|
|
221
|
+
if (released) return;
|
|
222
|
+
released = true;
|
|
223
|
+
context.requests--;
|
|
224
|
+
res.off('finish', release); res.off('close', release);
|
|
225
|
+
};
|
|
226
|
+
res.once('finish', release); res.once('close', release);
|
|
227
|
+
try {
|
|
228
|
+
return withProjectContext(context.binding, () => context.router(req, res, error => { release(); next(error); }));
|
|
229
|
+
} catch (error) {
|
|
230
|
+
release();
|
|
231
|
+
return next(error);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
close() {
|
|
236
|
+
for (const context of this.contexts.values()) {
|
|
237
|
+
context.fileWatcher.close(); context.visionServer.close();
|
|
238
|
+
void closeDesignStratum(context.binding.targetRoot);
|
|
239
|
+
}
|
|
240
|
+
this.contexts.clear();
|
|
241
|
+
this.active = null;
|
|
242
|
+
}
|
|
243
|
+
}
|
package/server/worktree-gc.js
CHANGED
|
@@ -37,6 +37,7 @@ export class WorktreeGC {
|
|
|
37
37
|
|
|
38
38
|
/** Start periodic scanning. Also runs an initial scan. */
|
|
39
39
|
start() {
|
|
40
|
+
if (this.#timer) return;
|
|
40
41
|
this.runNow().catch(() => {}); // fire-and-forget initial scan
|
|
41
42
|
this.#timer = setInterval(() => {
|
|
42
43
|
this.runNow().catch(() => {});
|