@smartmemory/compose 0.4.1 → 0.5.1
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/agents/compose-architect.md +40 -0
- package/.claude/agents/compose-explorer.md +35 -0
- package/.claude/hooks/canon-guard.mjs +52 -0
- package/README.md +15 -1
- package/bin/compose.js +57 -17
- package/bin/git-hooks/pre-push.template +26 -1
- package/bin/receipts-gate.js +39 -0
- package/contracts/fluid-record.schema.json +5 -0
- package/dist/assets/{App-Z4MU-H_F.js → App-DC7paCZv.js} +190 -190
- package/dist/assets/{_baseUniq-ClWoCPFl.js → _baseUniq-Czad7yiy.js} +1 -1
- package/dist/assets/{arc-DY26UIVo.js → arc-EquvLk8y.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-6Ggq4DqJ.js → architectureDiagram-Q4EWVU46-Dr_qinWi.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-CH3Ked0l.js → blockDiagram-DXYQGD6D-D2z46ED_.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-Bk8dYilu.js → c4Diagram-AHTNJAMY-BHob1Yt0.js} +1 -1
- package/dist/assets/channel-B-7ZRCKC.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-BMR0XaAQ.js → chunk-4BX2VUAB-DomWBRa_.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-JytR14a9.js → chunk-4TB4RGXK-WyC_x_DH.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-B4Q97BCP.js → chunk-55IACEB6-BajRv3zx.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-R_qarkSf.js → chunk-EDXVE4YY-rMnedK_r.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-C9s7KR9m.js → chunk-FMBD7UC4-BPi03Hcb.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-BySQzVxc.js → chunk-OYMX7WX6-B7J_mKX0.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-DdpSYZsW.js → chunk-QZHKN3VN-BLXTVr8N.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-iE_tzriw.js → chunk-YZCP3GAM-BYWjo2OJ.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-Balz1OEB.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-Balz1OEB.js +1 -0
- package/dist/assets/clone-CfNV0lUO.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BdlU6ZX_.js → cose-bilkent-S5V4N54A-Coaq0xaU.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-Cp3F5KTn.js → dagre-KV5264BT-DvUvAxlj.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DiR6_2q_.js → diagram-5BDNPKRD-70bXRUXV.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-w0i-p5HX.js → diagram-G4DWMVQ6-hMA8wgzx.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-tIHhwUv3.js → diagram-MMDJMWI5-BNir7C6i.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-BAeY3B19.js → diagram-TYMM5635-BCYl1xrE.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-Ckx_Knko.js → erDiagram-SMLLAGMA-bjxP0_bt.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-DeoNka6J.js → flowDiagram-DWJPFMVM-CBn9fhEp.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-BmGnFbEg.js → ganttDiagram-T4ZO3ILL-y1O7mWzn.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Dk48IHsx.js → gitGraphDiagram-UUTBAWPF-DIxwDXHB.js} +1 -1
- package/dist/assets/{graph-BNzKGvoy.js → graph-9D1ZumWp.js} +1 -1
- package/dist/assets/{index-BEfrNBp8.js → index-Ds_IXQo3.js} +2 -2
- package/dist/assets/{infoDiagram-42DDH7IO-BRf827i0.js → infoDiagram-42DDH7IO-DsWLGhaY.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-0kCZaeCM.js → ishikawaDiagram-UXIWVN3A-CipZIE90.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-rvU7ayRt.js → journeyDiagram-VCZTEJTY-Vr5xqcQm.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-DpQwX1C5.js → kanban-definition-6JOO6SKY-EqUYneyh.js} +1 -1
- package/dist/assets/{layout-BI8cXFPI.js → layout-hfWIIs0-.js} +1 -1
- package/dist/assets/{linear-a0glcDiw.js → linear-BdDWoN0t.js} +1 -1
- package/dist/assets/{min-vPHfnXcC.js → min-Bn_xAS7n.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-D14eF-7C.js → mindmap-definition-QFDTVHPH-qsgubzCF.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-Cno-gETh.js → pieDiagram-DEJITSTG-Bv1xq_58.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-BUQM1Hfm.js → quadrantDiagram-34T5L4WZ-DwMbAegF.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-pOXlN2-q.js → requirementDiagram-MS252O5E-BJVmLNcp.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-Crynd3_b.js → sankeyDiagram-XADWPNL6-o5GZb8Y1.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-D9fZdCM8.js → sequenceDiagram-FGHM5R23-ocqJp2qk.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-CW9qVec8.js → stateDiagram-FHFEXIEX-DGaDUFxP.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dz-15i-r.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-BcHzhm_8.js → timeline-definition-GMOUNBTQ-C4YwFvAn.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-BfytJcWk.js → vennDiagram-DHZGUBPP-uOKn9j-y.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-DLj-IjyB.js → wardley-RL74JXVD-DIQSmQde.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Ds0Ue68c.js → wardleyDiagram-NUSXRM2D-CdamsEDC.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-vjWDXFL6.js → xychartDiagram-5P7HB3ND-DhLs41yk.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/agent-string.js +9 -4
- package/lib/build-cancel.js +205 -0
- package/lib/build-stream-writer.js +6 -0
- package/lib/build.js +1189 -165
- package/lib/canon-guard.js +3 -24
- package/lib/canon-registry.js +2 -71
- package/lib/codex-preflight.js +8 -0
- package/lib/colleague/context.js +123 -0
- package/lib/consumer-fanout.js +427 -17
- package/lib/decision-blocks.js +38 -0
- package/lib/dispatch-ledger.js +7 -0
- package/lib/experiment-pricing.js +5 -1
- package/lib/flow-state.js +38 -0
- package/lib/fluid/factory.js +112 -1
- package/lib/fluid/ideabox-manifest.js +203 -0
- package/lib/fluid/ideabox-migrate.js +177 -29
- package/lib/fluid/ideabox-preamble.js +155 -0
- package/lib/fluid/ideabox-readable.js +83 -0
- package/lib/fluid/ideabox-recover.js +393 -0
- package/lib/fluid/import-ideabox.js +188 -45
- package/lib/fluid/local-provider.js +6 -0
- package/lib/fluid/portfolio.js +255 -0
- package/lib/fluid/record-shape.js +7 -0
- package/lib/fluid/render-ideabox.js +153 -7
- package/lib/fluid/smartmemory-provider.js +6 -0
- package/lib/gate-prompt.js +14 -7
- package/lib/gsd.js +95 -48
- package/lib/ideabox-cli.js +68 -0
- package/lib/ideabox.js +209 -9
- package/lib/maya-identity.js +16 -2
- package/lib/model-pricing.js +4 -1
- package/lib/output-gate.js +81 -0
- package/lib/pipeline-profiles.js +200 -0
- package/lib/process-termination.js +121 -3
- package/lib/receipts-gate.js +268 -0
- package/lib/result-normalizer.js +41 -1
- package/lib/smartmemory-client.js +68 -1
- package/lib/stratum-mcp-client.js +104 -5
- package/lib/team-flag.js +1 -1
- package/lib/tool-inventory.js +0 -1
- package/lib/version-check.js +9 -3
- package/lib/wave-checkpoint.js +100 -0
- package/package.json +7 -5
- package/presets/team-fable-astra.profiles.json +18 -0
- package/presets/team-fable-astra.stratum.yaml +236 -0
- package/server/build-stream-bridge.js +43 -1
- package/server/cc-session-watcher.js +54 -5
- package/server/compose-mcp-tools.js +48 -50
- package/server/compose-mcp.js +0 -2
- package/server/design-routes.js +1 -1
- package/server/file-watcher.js +14 -0
- package/server/ideabox-routes.js +10 -0
- package/server/index.js +5 -1
- package/server/lifecycle-guard.js +13 -0
- package/server/maya-routes.js +111 -7
- package/server/mcp-tool-defs.js +0 -25
- package/server/mcp-tool-policy.js +6 -13
- package/server/model-tiers.js +14 -6
- package/server/stratum-client.js +61 -15
- package/server/supervisor.js +18 -4
- package/server/vision-routes.js +9 -3
- package/dist/assets/channel-SnZzzh7k.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +0 -1
- package/dist/assets/clone-DgklGjHm.js +0 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +0 -1
- package/lib/append-integrity.js +0 -81
- package/lib/canon-override.js +0 -196
package/lib/gsd.js
CHANGED
|
@@ -26,7 +26,7 @@ import { validateBoundaryMap } from './boundary-map.js';
|
|
|
26
26
|
import { enrichTaskGraph } from './gsd-decompose-enrich.js';
|
|
27
27
|
import { buildTaskDescription } from './gsd-prompt.js';
|
|
28
28
|
import { writeAll, validate as validateTaskResult, read as readBlackboard } from './gsd-blackboard.js';
|
|
29
|
-
import { executeShipStep, toPhaseResultOutput, runConsumerIssuance, ConsumerStuckError, filesOwnedConflict, toEngineUsage, reportUsageReceipts } from './build.js';
|
|
29
|
+
import { executeShipStep, toPhaseResultOutput, runConsumerIssuance, ConsumerStuckError, filesOwnedConflict, toEngineUsage, reportUsageReceipts, loadPipelineProfiles, preflightPipelineProfiles, admitConsumerWave, publishConsumerCheckpoint, evaluateConfiguredGate, reportWaveEvidence, waveProfilesEnabled } from './build.js';
|
|
30
30
|
import {
|
|
31
31
|
ConsumerFanoutArtifacts,
|
|
32
32
|
ConsumerMergeDecisionError,
|
|
@@ -144,7 +144,8 @@ export async function runGsd(featureCode, opts = {}) {
|
|
|
144
144
|
const preMergeGate = resolvePreMergeGate(cwd, opts.preMergeGate);
|
|
145
145
|
|
|
146
146
|
// 4. Load pipeline spec
|
|
147
|
-
const
|
|
147
|
+
const localPath = join(cwd, 'pipelines', 'gsd.stratum.yaml');
|
|
148
|
+
const specPath = existsSync(localPath) ? localPath : join(PACKAGE_ROOT, 'pipelines', 'gsd.stratum.yaml');
|
|
148
149
|
// 4a. COMP-GSD-4: inject the stratum flow budget block from `gsd.budget.*`.
|
|
149
150
|
// injectBudget is IDENTITY when nothing is configured, so an un-budgeted gsd
|
|
150
151
|
// run (and plain `compose build`) is byte-identical.
|
|
@@ -152,6 +153,9 @@ export async function runGsd(featureCode, opts = {}) {
|
|
|
152
153
|
const specYaml = injectBudget(readFileSync(specPath, 'utf-8'), budgetCfg);
|
|
153
154
|
const localSpec = YAML.parse(specYaml);
|
|
154
155
|
const localSpecDigest = sha256(JSON.stringify(localSpec));
|
|
156
|
+
const profileCheck = preflightPipelineProfiles(loadPipelineProfiles(specPath), localSpec, specPath);
|
|
157
|
+
const pipelineProfiles = profileCheck.normalized;
|
|
158
|
+
const profileDigest = waveProfilesEnabled(pipelineProfiles) ? profileCheck.profilesDigest : undefined;
|
|
155
159
|
|
|
156
160
|
// 4a. COMP-GSD-4: cumulative cross-session ceiling pre-check (tokens/cost).
|
|
157
161
|
// Refuse to start/resume a run that has already spent its lifetime budget —
|
|
@@ -235,7 +239,7 @@ export async function runGsd(featureCode, opts = {}) {
|
|
|
235
239
|
stepCtx = {
|
|
236
240
|
stratum, cwd, featureCode, blueprintText, gateCommands, preMergeGate,
|
|
237
241
|
receiptsMode,
|
|
238
|
-
localSpec, localSpecDigest,
|
|
242
|
+
localSpec, localSpecDigest, pipelineProfiles, profileDigest,
|
|
239
243
|
filesChanged: [],
|
|
240
244
|
stuckDetector,
|
|
241
245
|
// D2(a): per-ITEM wall-clock ceiling from gsd.budget.per_task_ms, enforced
|
|
@@ -294,6 +298,11 @@ export async function runGsd(featureCode, opts = {}) {
|
|
|
294
298
|
}, { workspaceRoot: cwd });
|
|
295
299
|
const flowId = response.runId;
|
|
296
300
|
stepCtx.flowId = flowId;
|
|
301
|
+
if (waveProfilesEnabled(pipelineProfiles)) {
|
|
302
|
+
stepCtx.consumerArtifacts = new ConsumerFanoutArtifacts({ runId: flowId, targetCwd: cwd,
|
|
303
|
+
revisionDigest: response.revisionDigest, specDigest: localSpecDigest, profilesDigest: profileDigest });
|
|
304
|
+
stepCtx.artifacts = stepCtx.consumerArtifacts;
|
|
305
|
+
}
|
|
297
306
|
flushState(stepCtx, { flowId, phase: 'decompose' });
|
|
298
307
|
emitPhaseOnce(stepCtx, 'decompose'); // COMP-GSD-7-EVENTLOG
|
|
299
308
|
|
|
@@ -303,11 +312,17 @@ export async function runGsd(featureCode, opts = {}) {
|
|
|
303
312
|
response.status !== 'completed' &&
|
|
304
313
|
response.status !== 'failed' &&
|
|
305
314
|
response.status !== 'stuck' &&
|
|
315
|
+
response.status !== 'waiting_gate' &&
|
|
306
316
|
response.status !== 'budget_exhausted'
|
|
307
317
|
) {
|
|
308
318
|
response = await runOneStep(response, stepCtx);
|
|
309
319
|
}
|
|
310
320
|
|
|
321
|
+
if (response.status === 'waiting_gate') {
|
|
322
|
+
flushState(stepCtx, { status: 'waiting_gate', gateToken: response.gateToken, reason: response.reason });
|
|
323
|
+
return response;
|
|
324
|
+
}
|
|
325
|
+
|
|
311
326
|
if (response.status === 'stuck') {
|
|
312
327
|
// Artifacts (stuck.md/json + pause.json) were written by runOneStep.
|
|
313
328
|
// COMP-GSD-7-EVENTLOG: flush any completions that finished before the stuck
|
|
@@ -494,25 +509,35 @@ async function runOneStep(response, ctx) {
|
|
|
494
509
|
targetCwd: cwd,
|
|
495
510
|
revisionDigest: descriptor.revisionDigest,
|
|
496
511
|
specDigest: localSpecDigest,
|
|
512
|
+
profilesDigest: ctx.profileDigest,
|
|
497
513
|
});
|
|
498
514
|
}
|
|
499
515
|
ctx.consumerArtifacts.bindRunRevision({
|
|
500
516
|
revisionDigest: descriptor.revisionDigest,
|
|
501
517
|
specDigest: localSpecDigest,
|
|
518
|
+
profilesDigest: ctx.profileDigest,
|
|
502
519
|
});
|
|
520
|
+
ctx.artifacts = ctx.consumerArtifacts;
|
|
521
|
+
// This audit already serves issuance recovery on legacy runs; admission
|
|
522
|
+
// uses it only when this step opts into wave policies.
|
|
523
|
+
const audit = await stratum.audit(flowId);
|
|
524
|
+
const configured = ctx.pipelineProfiles?.[descriptor.step]?.tier_from || ctx.pipelineProfiles?._consumer?.[descriptor.step];
|
|
525
|
+
const admission = configured ? await admitConsumerWave({ descriptor, descriptors: ready.filter(isConsumerDescriptor), audit,
|
|
526
|
+
localSpec, profiles: ctx.pipelineProfiles, artifacts: ctx.consumerArtifacts, stratum, flowId }) : null;
|
|
503
527
|
// D7(a): render the exact TaskResult path from the item's task id (the
|
|
504
528
|
// fanout `over` is decompose_gsd.output.tasks, indexed by itemIndex).
|
|
505
|
-
const consumerItem =
|
|
529
|
+
const consumerItem = descriptor.item;
|
|
506
530
|
const taskResultPath = consumerItem?.id
|
|
507
531
|
? gsdTaskResultPath(featureCode, consumerItem.id)
|
|
508
532
|
: undefined;
|
|
509
533
|
try {
|
|
510
534
|
return await runConsumerIssuance({
|
|
511
535
|
descriptor,
|
|
536
|
+
admission,
|
|
512
537
|
flowId,
|
|
513
538
|
stratum,
|
|
514
539
|
artifacts: ctx.consumerArtifacts,
|
|
515
|
-
audit
|
|
540
|
+
audit,
|
|
516
541
|
localSpec,
|
|
517
542
|
// D2(b): onUsage debits each item's agent usage into the cumulative
|
|
518
543
|
// ledger. D2(a): per-item wall-clock ceiling from gsd.budget.per_task_ms.
|
|
@@ -523,12 +548,15 @@ async function runOneStep(response, ctx) {
|
|
|
523
548
|
// by definition a GSD task, so timing.json + diffs/<id>.diff are written
|
|
524
549
|
// for the milestone report. The build consumer path omits it (no marker),
|
|
525
550
|
// so build-mode fanout stays byte-identical.
|
|
526
|
-
context: { cwd, projectCwd: cwd, featureCode, flowId, receiptsMode: ctx.receiptsMode, gsd: true, gsdTaskId: consumerItem?.id, filesChanged: ctx.filesChanged, onUsage: (usage, meta) => recordTsAgentUsage(ctx, usage, meta), taskResultPath },
|
|
551
|
+
context: { pipelineProfiles: ctx.pipelineProfiles, artifacts: ctx.consumerArtifacts, cwd, projectCwd: cwd, featureCode, flowId, receiptsMode: ctx.receiptsMode, gsd: true, gsdTaskId: consumerItem?.id, filesChanged: ctx.filesChanged, onUsage: (usage, meta) => recordTsAgentUsage(ctx, usage, meta), taskResultPath },
|
|
527
552
|
// runAndNormalize narrates via progress.debug/warn/info/toolUse — gsd
|
|
528
553
|
// has no cockpit, so a COMPLETE no-op (not just stepStart/stepDone) is
|
|
529
554
|
// required or the item throws "progress.debug is not a function".
|
|
530
555
|
progress: NOOP_PROGRESS,
|
|
531
|
-
streamWriter: { write() {
|
|
556
|
+
streamWriter: { write(event) {
|
|
557
|
+
if (event.type === 'step_model') appendGsdEvent(cwd, featureCode, 'step_model', event);
|
|
558
|
+
} },
|
|
559
|
+
profile: ctx.pipelineProfiles?.[descriptor.step]?.default ?? ctx.pipelineProfiles?.[descriptor.step],
|
|
532
560
|
perItemTimeoutMs: ctx.perItemTimeoutMs,
|
|
533
561
|
stuckDetector: ctx.stuckDetector,
|
|
534
562
|
});
|
|
@@ -555,7 +583,7 @@ async function runOneStep(response, ctx) {
|
|
|
555
583
|
featureCode,
|
|
556
584
|
cwd,
|
|
557
585
|
cwd,
|
|
558
|
-
{ cwd, featureCode, mode: 'feature', filesChanged: ctx.filesChanged ?? [] },
|
|
586
|
+
{ ...ctx, cwd, featureCode, mode: 'feature', artifacts: ctx.consumerArtifacts, filesChanged: ctx.filesChanged ?? [] },
|
|
559
587
|
'',
|
|
560
588
|
null,
|
|
561
589
|
);
|
|
@@ -663,46 +691,65 @@ async function runOneStep(response, ctx) {
|
|
|
663
691
|
const gateStep = steps.find((step) => step.id === gateStepId);
|
|
664
692
|
const predecessorIds = new Set(gateStep?.after ?? []);
|
|
665
693
|
const fanoutStep = steps.find((step) => predecessorIds.has(step.id) && step.fanout?.dispatch === 'consumer');
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
694
|
+
const outputDecision = ctx.artifacts ? await evaluateConfiguredGate(ctx, { localSpec, gateStepId,
|
|
695
|
+
gateToken: gateState.gateToken, audit }) : null;
|
|
696
|
+
if (outputDecision && !outputDecision.outcome) return { status: 'waiting_gate', flowId,
|
|
697
|
+
gateToken: gateState.gateToken, reason: outputDecision.reason };
|
|
698
|
+
const resolveGateWithConsumerMerge = async (requestedOutcome, requestedRationale) => {
|
|
699
|
+
if (!fanoutStep && !outputDecision) throw new Error(`runGsd: unexpected non-consumer gate ${gateStepId} on the TS path`);
|
|
700
|
+
if (fanoutStep && !ctx.consumerArtifacts) {
|
|
701
|
+
ctx.consumerArtifacts = new ConsumerFanoutArtifacts({ runId: flowId, targetCwd: cwd,
|
|
702
|
+
revisionDigest: response.revisionDigest, specDigest: localSpecDigest, profilesDigest: ctx.profileDigest });
|
|
703
|
+
}
|
|
704
|
+
const artifacts = fanoutStep ? ctx.consumerArtifacts : null;
|
|
705
|
+
if (artifacts) ctx.artifacts = artifacts;
|
|
706
|
+
if (ctx.pipelineProfiles?._consumer?.[fanoutStep?.id]?.checkpoint_gate === gateStepId) {
|
|
707
|
+
artifacts.initializeWave({ ref: `refs/heads/compose/wave/${flowId}`, profilesDigest: ctx.profileDigest });
|
|
708
|
+
}
|
|
709
|
+
let transaction;
|
|
710
|
+
let outcome = requestedOutcome;
|
|
711
|
+
let rationale = requestedRationale;
|
|
712
|
+
if (artifacts) {
|
|
713
|
+
artifacts.recordGateBinding({ gateStepId, fanoutStepId: fanoutStep.id });
|
|
714
|
+
try {
|
|
715
|
+
transaction = artifacts.prepareMerge({ gateStepId, gateToken: gateState.gateToken, fanoutStepId: fanoutStep.id, audit });
|
|
716
|
+
if (outcome === 'approve') await artifacts.applyMerge(transaction);
|
|
717
|
+
else artifacts.restoreMergeBaseline(transaction, audit);
|
|
718
|
+
} catch (error) {
|
|
719
|
+
if (!(error instanceof ConsumerMergeDecisionError)) throw error;
|
|
720
|
+
outcome = gateStep?.gate?.on_revise ? 'revise' : 'kill';
|
|
721
|
+
rationale = `${error.code}: ${error.message}`;
|
|
722
|
+
transaction ??= artifacts.journal.mergeTransactions.find(e => e.gateToken === gateState.gateToken);
|
|
723
|
+
if (transaction) artifacts.restoreMergeBaseline(transaction, audit);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
let next;
|
|
727
|
+
try {
|
|
728
|
+
next = await stratum.gateResolve(flowId, gateStepId, outcome, rationale, 'system', gateState.gateToken);
|
|
729
|
+
} catch (error) {
|
|
730
|
+
const current = await stratum.audit(flowId);
|
|
731
|
+
const ordinal = (audit.events ?? []).filter(e => e.type === 'gate_resolved' && e.stepId === gateStepId).length;
|
|
732
|
+
const confirmed = (current.events ?? []).filter(e => e.type === 'gate_resolved' && e.stepId === gateStepId)[ordinal]?.detail?.decision;
|
|
733
|
+
if (!confirmed) throw error;
|
|
734
|
+
outcome = confirmed;
|
|
735
|
+
if (artifacts && outcome === 'approve') {
|
|
736
|
+
artifacts.markGateResolved(transaction, outcome);
|
|
737
|
+
await publishConsumerCheckpoint(ctx, transaction);
|
|
738
|
+
}
|
|
739
|
+
next = await stratum.resume(flowId);
|
|
740
|
+
}
|
|
741
|
+
if (artifacts) {
|
|
742
|
+
artifacts.markGateResolved(transaction, outcome);
|
|
743
|
+
if (outcome === 'approve') await publishConsumerCheckpoint(ctx, transaction);
|
|
744
|
+
if (outcome !== 'revise') artifacts.cleanupWorktrees(`GSD merge gate ${outcome}`,
|
|
745
|
+
artifacts.journal.wave ? { dispatchTokens: transaction.acceptedDispatchTokens } : {});
|
|
746
|
+
if (outcome === 'approve' && !artifacts.journal.wave) ctx.filesChanged = collectChangedFiles(cwd);
|
|
747
|
+
}
|
|
748
|
+
if (outputDecision) await reportWaveEvidence(ctx, 'gate_decision', gateState.gateToken,
|
|
749
|
+
{ ...outputDecision, outcome, rationale }, 'accepted');
|
|
750
|
+
return next;
|
|
751
|
+
};
|
|
752
|
+
return resolveGateWithConsumerMerge(outputDecision?.outcome ?? 'approve', outputDecision?.rationale ?? 'consumer fanout artifacts merged');
|
|
706
753
|
}
|
|
707
754
|
|
|
708
755
|
return response;
|
package/lib/ideabox-cli.js
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
import { toMarkdownDate } from './fluid/ideabox-dates.js';
|
|
49
49
|
import { KIND } from './fluid/provider.js';
|
|
50
50
|
import { ensureIdeaboxMigrated } from './fluid/ideabox-migrate.js';
|
|
51
|
+
import { adoptFile, discardEdits } from './fluid/ideabox-recover.js';
|
|
51
52
|
import { writeIdeaboxProjection } from './fluid/render-ideabox.js';
|
|
52
53
|
|
|
53
54
|
const USAGE = [
|
|
@@ -63,6 +64,10 @@ const USAGE = [
|
|
|
63
64
|
' discuss <ID> "<comment>" Add a discussion comment',
|
|
64
65
|
' triage [--lens <name>] Walk untriaged ideas and assign priorities',
|
|
65
66
|
' render Rewrite the ideabox file from the records',
|
|
67
|
+
'',
|
|
68
|
+
'Recovering an interrupted migration whose file then changed:',
|
|
69
|
+
' adopt-file The file on disk is right — finish the migration against it',
|
|
70
|
+
' discard-edits The migration is right — restore the file (a copy is saved first)',
|
|
66
71
|
];
|
|
67
72
|
|
|
68
73
|
const PRIORITIES = ['P0', 'P1', 'P2'];
|
|
@@ -91,6 +96,20 @@ function reportOpFailure(err) {
|
|
|
91
96
|
console.error(err.message);
|
|
92
97
|
return 1;
|
|
93
98
|
}
|
|
99
|
+
// Every refusal in the migration family — unreadable, conflict, stale
|
|
100
|
+
// manifest, changed under the lock, nothing to recover — is a message written
|
|
101
|
+
// FOR the user, naming what was found and what to do about it. Rethrowing
|
|
102
|
+
// meant the CLI died with a Node stack trace and the guidance buried in the
|
|
103
|
+
// middle of it. That is not a hypothetical cost: the stale-manifest message
|
|
104
|
+
// is the one place the two recovery commands are named, and a supported exit
|
|
105
|
+
// nobody can read is not an exit.
|
|
106
|
+
//
|
|
107
|
+
// Matched by code prefix rather than by class so a refusal added later is
|
|
108
|
+
// reported the same way without anyone having to remember this list.
|
|
109
|
+
if (typeof err?.code === 'string' && err.code.startsWith('IDEABOX_')) {
|
|
110
|
+
console.error(err.message);
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
94
113
|
throw err;
|
|
95
114
|
}
|
|
96
115
|
|
|
@@ -304,6 +323,55 @@ export async function runIdeaboxCommand(cwd, args, opts = {}) {
|
|
|
304
323
|
return 0;
|
|
305
324
|
}
|
|
306
325
|
|
|
326
|
+
// The two recovery commands. Deliberately NOT preceded by
|
|
327
|
+
// `ensureIdeaboxMigrated`: that gate throws on exactly the state these
|
|
328
|
+
// exist to leave. They run their own, narrower check instead — both
|
|
329
|
+
// refuse unless an interrupted migration is recorded AND its document has
|
|
330
|
+
// changed, so neither is a general "make the file canon" door.
|
|
331
|
+
case 'adopt-file': {
|
|
332
|
+
const { updated, discussed, imported, kept, reclustered } = await adoptFile(provider, ideaboxPath);
|
|
333
|
+
console.log(`Adopted ${ideaboxPath} as the source of the interrupted migration.`);
|
|
334
|
+
// A recovery that does not say what it did is not a recovery.
|
|
335
|
+
console.log(` Updated to match the file: ${updated.length ? updated.join(', ') : 'none'}`);
|
|
336
|
+
if (discussed.length) console.log(` Discussion entries added: ${discussed.join(', ')}`);
|
|
337
|
+
if (reclustered.length) console.log(` Umbrellas updated: ${reclustered.join(', ')}`);
|
|
338
|
+
console.log(` Imported: ${imported.length ? imported.join(', ') : 'none'}`);
|
|
339
|
+
if (kept.length) {
|
|
340
|
+
console.log(` Kept, though absent from the file: ${kept.join(', ')}`);
|
|
341
|
+
console.log(' Nothing is ever deleted by this command. They are back in the file now;');
|
|
342
|
+
console.log(' remove them deliberately with `compose ideabox kill <ID>` if they are stale.');
|
|
343
|
+
}
|
|
344
|
+
return 0;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
case 'discard-edits': {
|
|
348
|
+
// Printed from the callback rather than the return value: if the resume
|
|
349
|
+
// throws after the overwrite, the user has just lost the text and this
|
|
350
|
+
// line is the only thing telling them where the copy is.
|
|
351
|
+
const { result, reverted, leftover } = await discardEdits(provider, ideaboxPath, {
|
|
352
|
+
onBackup: (path) => console.log(`Saved a copy of the discarded file: ${path}`),
|
|
353
|
+
});
|
|
354
|
+
console.log(`Restored ${ideaboxPath} to the document the migration read, and finished it.`);
|
|
355
|
+
// Records are restored as well as the file — an adoption that got as far
|
|
356
|
+
// as patching them is what makes this more than a file copy.
|
|
357
|
+
if (reverted.updated.length) {
|
|
358
|
+
console.log(` Put back to the migrated version: ${reverted.updated.join(', ')}`);
|
|
359
|
+
}
|
|
360
|
+
console.log(` Imported: ${result.imported.length ? result.imported.join(', ') : 'none'}`);
|
|
361
|
+
// Nothing is deleted here either, so anything an interrupted adoption
|
|
362
|
+
// added and this cannot take back is named rather than left as a
|
|
363
|
+
// surprise in the file.
|
|
364
|
+
if (leftover.clusters.length) {
|
|
365
|
+
console.log(` Umbrellas left in place, unnamed by the restored file: ${leftover.clusters.join(', ')}`);
|
|
366
|
+
console.log(' They render with no ideas under them. Nothing was deleted to make room.');
|
|
367
|
+
}
|
|
368
|
+
if (leftover.discussed.length) {
|
|
369
|
+
console.log(` Comments that stay on their idea: ${leftover.discussed.join(', ')}`);
|
|
370
|
+
console.log(' The discussion trail is append-only, so a comment typed during the outage is kept.');
|
|
371
|
+
}
|
|
372
|
+
return 0;
|
|
373
|
+
}
|
|
374
|
+
|
|
307
375
|
default:
|
|
308
376
|
console.error(`Unknown ideabox subcommand: ${sub}`);
|
|
309
377
|
console.error('Run: compose ideabox --help');
|
package/lib/ideabox.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
18
18
|
import { join, dirname } from 'node:path'
|
|
19
|
+
import { assertIdeaboxReadable } from './fluid/ideabox-readable.js'
|
|
19
20
|
import { resolvePathValue } from './paths-core.js'
|
|
20
21
|
|
|
21
22
|
// ---------------------------------------------------------------------------
|
|
@@ -47,6 +48,36 @@ export const IDEABOX_TEMPLATE = `# Ideabox
|
|
|
47
48
|
// Matches: #### IDEA-42 — Some Title (or "- " variant)
|
|
48
49
|
const IDEA_HEADING_RE = /^####\s+(IDEA-(\d+))\s+[—–-]+\s+(.+)$/
|
|
49
50
|
|
|
51
|
+
// The LEGACY (pre-umbrella) dialect: ideas are H3 under arbitrary `## Topic`
|
|
52
|
+
// headings, with no `## Ideas` wrapper and no cluster level. Published installs
|
|
53
|
+
// upgrading from before COMP-PLAN-IDEA-UNIFY have this shape, so it is the
|
|
54
|
+
// dialect the migration path actually meets — see COMP-IDEABOX-MIGRATE-DIALECT,
|
|
55
|
+
// where failing to read it destroyed 18 ideas in one command.
|
|
56
|
+
const LEGACY_IDEA_HEADING_RE = /^###\s+(IDEA-(\d+))\s+[—–-]+\s+(.+)$/
|
|
57
|
+
|
|
58
|
+
// An idea heading at EITHER level. A third vintage in the wild is a hybrid: it
|
|
59
|
+
// has the modern `## Ideas` wrapper (so it is not the flat dialect) but writes
|
|
60
|
+
// both its ideas AND its umbrellas at H3. Those two are still unambiguous —
|
|
61
|
+
// an H3 that starts with an `IDEA-N` id is an idea, and any other H3 is an
|
|
62
|
+
// umbrella — so the level alone was never what distinguished them.
|
|
63
|
+
// Measured 2026-09-06: three projects on this machine are in this dialect
|
|
64
|
+
// (books 9 ideas, ScaleMate 2, trustflow 1). Before the guard landed they were
|
|
65
|
+
// silently destroyed; with the guard but without this they are refused, which
|
|
66
|
+
// is safe but leaves them unable to use the ideabox at all.
|
|
67
|
+
const IDEA_HEADING_ANY_RE = /^#{3,4}\s+(IDEA-(\d+))\s+[—–-]+\s+(.+)$/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A line SHAPED like an idea declaration, at any heading level.
|
|
71
|
+
*
|
|
72
|
+
* Used only to notice a heading the dialect's own idea pattern could not
|
|
73
|
+
* consume — the "I can see something here and cannot read it" case.
|
|
74
|
+
*/
|
|
75
|
+
const IDEA_DECL_SHAPE_RE = /^\s*#{1,6}\s+(IDEA-\d+)\b/
|
|
76
|
+
|
|
77
|
+
/** The bullet form, for a document that uses no idea headings at all. */
|
|
78
|
+
const IDEA_BULLET_SHAPE_RE = /^\s*[-*]\s+(IDEA-\d+)\s*[—–:-]/
|
|
79
|
+
|
|
80
|
+
|
|
50
81
|
// Matches a field line: **FieldName:** value (colon inside bold markers)
|
|
51
82
|
const FIELD_RE = /^\*\*([^*:]+):\*\*\s*(.*)$/
|
|
52
83
|
|
|
@@ -80,6 +111,41 @@ const DISCUSSION_ENTRY_RE = /^-\s+\[(\d{4}-\d{2}-\d{2})\]\s+([^:\n]+?):\s+(.+)$/
|
|
|
80
111
|
export function parseIdeabox(markdown) {
|
|
81
112
|
const lines = markdown.split('\n')
|
|
82
113
|
|
|
114
|
+
// Legacy dialect detection, fenced as narrowly as possible: it activates ONLY
|
|
115
|
+
// when the document has no `## Ideas` section AND carries H3 idea headings.
|
|
116
|
+
// A new-dialect file always opens its ideas with `## Ideas`, so legacy mode
|
|
117
|
+
// can never engage on one. A file with BOTH shapes is deliberately left to
|
|
118
|
+
// the modern path — a half-converted document is not something to guess at,
|
|
119
|
+
// and the migration gate refuses it by declaration count instead.
|
|
120
|
+
// A COMPLETE heading, not a prefix: `/^##\s+Ideas/` also matched a legitimate
|
|
121
|
+
// legacy topic called `## Ideas for Later`, which switched legacy mode off and
|
|
122
|
+
// made that document's ideas unreadable. Fenced blocks are skipped for the
|
|
123
|
+
// same reason — an example containing `## Ideas` must not decide the dialect
|
|
124
|
+
// of the file quoting it.
|
|
125
|
+
const structural = []
|
|
126
|
+
let fenced = false
|
|
127
|
+
for (const l of lines) {
|
|
128
|
+
if (/^\s*```/.test(l)) { fenced = !fenced; continue }
|
|
129
|
+
if (!fenced) structural.push(l)
|
|
130
|
+
}
|
|
131
|
+
const hasIdeasSection = structural.some((l) => /^##\s+Ideas\s*$/.test(l))
|
|
132
|
+
const legacy = !hasIdeasSection && structural.some((l) => LEGACY_IDEA_HEADING_RE.test(l))
|
|
133
|
+
|
|
134
|
+
// The hybrid dialect only exists in a document the tools have NEVER written:
|
|
135
|
+
// every write emits ideas at H4, so a document containing any `#### IDEA-` is
|
|
136
|
+
// modern, and an H3 there is an umbrella no matter what it is called.
|
|
137
|
+
//
|
|
138
|
+
// That distinction is load-bearing rather than cosmetic. `compose ideabox`
|
|
139
|
+
// will happily create a cluster named `IDEA-9 — Cache research`, and reading
|
|
140
|
+
// that back as an idea made the next command fail: the guard saw a declared
|
|
141
|
+
// id with no record behind it and refused. Recognising H3 ideas everywhere
|
|
142
|
+
// broke round-tripping the tool's own output.
|
|
143
|
+
const hasModernIdeaHeadings = structural.some((l) => IDEA_HEADING_RE.test(l))
|
|
144
|
+
const hybrid = hasIdeasSection && !hasModernIdeaHeadings
|
|
145
|
+
const ideaHeadingRe = legacy
|
|
146
|
+
? LEGACY_IDEA_HEADING_RE
|
|
147
|
+
: (hybrid ? IDEA_HEADING_ANY_RE : IDEA_HEADING_RE)
|
|
148
|
+
|
|
83
149
|
const ideas = []
|
|
84
150
|
const killed = []
|
|
85
151
|
// Cluster-scoped data. An umbrella heading carries a hand-authored multi-
|
|
@@ -89,11 +155,25 @@ export function parseIdeabox(markdown) {
|
|
|
89
155
|
// parse→serialize cycle (i.e. every `compose ideabox` mutation) deleted it.
|
|
90
156
|
const clusters = []
|
|
91
157
|
const clusterIndex = new Map()
|
|
158
|
+
// Idea-shaped headings that NO branch consumed. The migration gate compares
|
|
159
|
+
// these against the ideas actually produced, so that "the parser could not
|
|
160
|
+
// read this" can never be mistaken for "there was nothing here". Collected
|
|
161
|
+
// HERE, by the parser itself, rather than by a second scanner: the whole class
|
|
162
|
+
// of bug this module keeps producing is two readers disagreeing about what the
|
|
163
|
+
// document says, and a separate scanner is a second reader by construction.
|
|
164
|
+
const shapes = []
|
|
165
|
+
// Idea-shaped headings taken as UMBRELLA names. Legitimate on its own — the
|
|
166
|
+
// tools will create a cluster called `IDEA-9 — Cache research` — but an
|
|
167
|
+
// umbrella named after an id that is ALSO a real idea in the same document is
|
|
168
|
+
// the half-converted file, not a naming choice.
|
|
169
|
+
const clusterNamedIds = []
|
|
92
170
|
// Everything before `## Ideas`. Regenerating this from IDEABOX_TEMPLATE
|
|
93
171
|
// instead of preserving it drops hand-authored convention bullets.
|
|
94
172
|
const preambleLines = []
|
|
95
173
|
|
|
96
|
-
|
|
174
|
+
// In the legacy dialect there is no `## Ideas` wrapper — the ideas simply
|
|
175
|
+
// live under topic headings — so the whole document is the ideas section.
|
|
176
|
+
let inIdeasSection = legacy
|
|
97
177
|
let inKilledSection = false
|
|
98
178
|
let currentCluster = null
|
|
99
179
|
let currentIdea = null
|
|
@@ -111,11 +191,43 @@ export function parseIdeabox(markdown) {
|
|
|
111
191
|
currentIdea = null
|
|
112
192
|
}
|
|
113
193
|
|
|
194
|
+
// Fenced blocks are documentation, not content. Detection and the declaration
|
|
195
|
+
// scan already skipped them; the parse loop did not, so an example in a
|
|
196
|
+
// ```markdown``` block was imported as a real idea — and, worse, satisfied the
|
|
197
|
+
// readability guard on behalf of a genuine entry with the same id that the
|
|
198
|
+
// parser could NOT read, letting the destructive write through. All three
|
|
199
|
+
// readers have to agree on what is content.
|
|
200
|
+
let inFence = false
|
|
201
|
+
|
|
114
202
|
for (let i = 0; i < lines.length; i++) {
|
|
115
203
|
const line = lines[i]
|
|
116
204
|
|
|
205
|
+
if (/^\s*```/.test(line)) {
|
|
206
|
+
inFence = !inFence
|
|
207
|
+
if (!inIdeasSection && !inKilledSection && !seenAnySection) preambleLines.push(line)
|
|
208
|
+
// The DELIMITERS are content too. Keeping the enclosed lines while
|
|
209
|
+
// dropping the fence turned a fenced `#### IDEA-2 — example` inside an
|
|
210
|
+
// idea's body into a real heading on the next projection, and the render
|
|
211
|
+
// after that refused the file it had just written.
|
|
212
|
+
else if (currentIdea) currentIdea._extraLines.push(line)
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
if (inFence) {
|
|
216
|
+
if (!inIdeasSection && !inKilledSection && !seenAnySection) preambleLines.push(line)
|
|
217
|
+
else if (currentIdea) currentIdea._extraLines.push(line)
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Every idea-shaped heading in the document, recorded BEFORE any branch can
|
|
222
|
+
// swallow it. What is consumed as a real idea or as an umbrella is
|
|
223
|
+
// subtracted at the end; whatever is left is content the parser could see
|
|
224
|
+
// and could not read, which the migration gate must refuse rather than
|
|
225
|
+
// treat as absent.
|
|
226
|
+
const shapeHere = line.match(IDEA_DECL_SHAPE_RE)
|
|
227
|
+
if (shapeHere) shapes.push(shapeHere[1])
|
|
228
|
+
|
|
117
229
|
// Detect section boundaries
|
|
118
|
-
if (/^##\s+Ideas
|
|
230
|
+
if (/^##\s+Ideas\s*$/.test(line)) {
|
|
119
231
|
flushCurrentIdea()
|
|
120
232
|
inIdeasSection = true
|
|
121
233
|
inKilledSection = false
|
|
@@ -123,7 +235,7 @@ export function parseIdeabox(markdown) {
|
|
|
123
235
|
currentCluster = null
|
|
124
236
|
continue
|
|
125
237
|
}
|
|
126
|
-
if (/^##\s+Killed\s+Ideas
|
|
238
|
+
if (/^##\s+Killed\s+Ideas\s*$/.test(line)) {
|
|
127
239
|
flushCurrentIdea()
|
|
128
240
|
inIdeasSection = false
|
|
129
241
|
inKilledSection = true
|
|
@@ -132,8 +244,34 @@ export function parseIdeabox(markdown) {
|
|
|
132
244
|
continue
|
|
133
245
|
}
|
|
134
246
|
// Other H2 sections end both
|
|
135
|
-
if (/^##\s/.test(line) && !(/^##\s+Ideas
|
|
247
|
+
if (/^##\s/.test(line) && !(/^##\s+Ideas\s*$/.test(line)) && !(/^##\s+Killed\s+Ideas\s*$/.test(line))) {
|
|
136
248
|
flushCurrentIdea()
|
|
249
|
+
if (legacy) {
|
|
250
|
+
// A `## Topic` heading in the legacy dialect is not the end of the
|
|
251
|
+
// ideas — it is how that dialect GROUPED them, which is precisely what
|
|
252
|
+
// an umbrella is. Mapping it to a cluster instead of discarding it
|
|
253
|
+
// means the upgrade preserves the author's grouping rather than
|
|
254
|
+
// flattening ten topics into one undifferentiated list. Treating it as
|
|
255
|
+
// preamble (the previous behaviour) collected every topic heading at
|
|
256
|
+
// the top of the file, detached from its ideas.
|
|
257
|
+
const name = line.replace(/^##\s+/, '').trim()
|
|
258
|
+
seenAnySection = true
|
|
259
|
+
// Leaving the killed section matters: without this, every idea under a
|
|
260
|
+
// topic heading that happened to follow `## Killed Ideas` was imported
|
|
261
|
+
// as KILLED. A topic heading opens a group, it does not inherit the
|
|
262
|
+
// previous section's disposition — and it re-enters the ideas section,
|
|
263
|
+
// which `## Killed Ideas` had closed, or the ideas below it are read as
|
|
264
|
+
// neither live nor killed and vanish entirely.
|
|
265
|
+
inKilledSection = false
|
|
266
|
+
inIdeasSection = true
|
|
267
|
+
currentCluster = name
|
|
268
|
+
if (!clusterIndex.has(name)) {
|
|
269
|
+
const entry = { name, theme: '', order: clusters.length }
|
|
270
|
+
clusters.push(entry)
|
|
271
|
+
clusterIndex.set(name, entry)
|
|
272
|
+
}
|
|
273
|
+
continue
|
|
274
|
+
}
|
|
137
275
|
inIdeasSection = false
|
|
138
276
|
inKilledSection = false
|
|
139
277
|
// Still part of the preamble when it precedes the first real section —
|
|
@@ -142,6 +280,15 @@ export function parseIdeabox(markdown) {
|
|
|
142
280
|
continue
|
|
143
281
|
}
|
|
144
282
|
|
|
283
|
+
// Legacy preamble: everything before the first topic heading or idea. The
|
|
284
|
+
// modern collector below is unreachable here because legacy mode is inside
|
|
285
|
+
// the ideas section from line one, so without this the document's title and
|
|
286
|
+
// introduction were dropped on the first render after migration.
|
|
287
|
+
if (legacy && !seenAnySection && !currentIdea) {
|
|
288
|
+
preambleLines.push(line)
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
|
|
145
292
|
if (!inIdeasSection && !inKilledSection) {
|
|
146
293
|
// Preamble = everything before the first section heading.
|
|
147
294
|
if (!seenAnySection) preambleLines.push(line)
|
|
@@ -156,9 +303,14 @@ export function parseIdeabox(markdown) {
|
|
|
156
303
|
continue
|
|
157
304
|
}
|
|
158
305
|
|
|
159
|
-
// H3 = cluster heading
|
|
160
|
-
|
|
306
|
+
// H3 = cluster heading (modern dialect only — in the legacy dialect H3 IS
|
|
307
|
+
// the idea heading, handled below, and there is no cluster level at all).
|
|
308
|
+
// An H3 carrying an idea id is an IDEA at this level too, not an umbrella
|
|
309
|
+
// named after one; the hybrid dialect writes both at H3.
|
|
310
|
+
if (!legacy && /^###\s/.test(line) && !(hybrid && IDEA_HEADING_ANY_RE.test(line))) {
|
|
161
311
|
flushCurrentIdea()
|
|
312
|
+
const eaten = line.match(IDEA_DECL_SHAPE_RE)
|
|
313
|
+
if (eaten) clusterNamedIds.push(eaten[1])
|
|
162
314
|
currentCluster = line.replace(/^###\s+/, '').trim()
|
|
163
315
|
if (!clusterIndex.has(currentCluster)) {
|
|
164
316
|
const entry = { name: currentCluster, theme: '', order: clusters.length }
|
|
@@ -179,7 +331,7 @@ export function parseIdeabox(markdown) {
|
|
|
179
331
|
}
|
|
180
332
|
|
|
181
333
|
// H4 = idea heading
|
|
182
|
-
const headingMatch = line.match(
|
|
334
|
+
const headingMatch = line.match(ideaHeadingRe)
|
|
183
335
|
if (headingMatch) {
|
|
184
336
|
flushCurrentIdea()
|
|
185
337
|
currentIdea = {
|
|
@@ -273,7 +425,47 @@ export function parseIdeabox(markdown) {
|
|
|
273
425
|
// serializer re-adds the separator itself.
|
|
274
426
|
while (preambleLines.length && preambleLines.at(-1).trim() === '') preambleLines.pop()
|
|
275
427
|
|
|
276
|
-
|
|
428
|
+
// The bullet form is a fallback, not a rule: it counts ONLY for a document
|
|
429
|
+
// with no idea headings whatsoever. Counting bullets unconditionally made
|
|
430
|
+
// `- IDEA-1 needs research`, written inside IDEA-1's own body, a second
|
|
431
|
+
// declaration of IDEA-1 — and the gate then refused a perfectly readable file.
|
|
432
|
+
// A reference is not a declaration, and the only document where a bullet
|
|
433
|
+
// plausibly IS one is a document that declares nothing any other way.
|
|
434
|
+
const parsedIds = [...ideas, ...killed].map((i) => i.id)
|
|
435
|
+
|
|
436
|
+
// By COUNT, not by membership. A half-converted document declares the same id
|
|
437
|
+
// twice — once in each dialect — and the parser consumes only one of them; a
|
|
438
|
+
// membership test sees the id in `parsedIds` and calls it accounted for, while
|
|
439
|
+
// the other copy (routinely the older, richer one) is invisible and would be
|
|
440
|
+
// deleted by the next render. Counting says two were written and one was read.
|
|
441
|
+
const count = (arr, id) => arr.filter((x) => x === id).length
|
|
442
|
+
const unconsumed = [...new Set(shapes)].filter(
|
|
443
|
+
(id) => count(shapes, id) > count(parsedIds, id) + count(clusterNamedIds, id),
|
|
444
|
+
)
|
|
445
|
+
// An umbrella named after an id that is also a real idea here.
|
|
446
|
+
const collisions = clusterNamedIds.filter((id) => parsedIds.includes(id))
|
|
447
|
+
|
|
448
|
+
if (!ideas.length && !killed.length && !unconsumed.length) {
|
|
449
|
+
let bulletFence = false
|
|
450
|
+
for (const line of lines) {
|
|
451
|
+
if (/^\s*```/.test(line)) { bulletFence = !bulletFence; continue }
|
|
452
|
+
if (bulletFence) continue
|
|
453
|
+
const m = line.match(IDEA_BULLET_SHAPE_RE)
|
|
454
|
+
if (m && !unconsumed.includes(m[1])) unconsumed.push(m[1])
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
ideas,
|
|
460
|
+
killed,
|
|
461
|
+
nextId,
|
|
462
|
+
clusters,
|
|
463
|
+
preamble: preambleLines.join('\n'),
|
|
464
|
+
// Idea-shaped content this parse could NOT turn into an idea.
|
|
465
|
+
unconsumed,
|
|
466
|
+
// Umbrellas named after ids that are also real ideas in this document.
|
|
467
|
+
collisions,
|
|
468
|
+
}
|
|
277
469
|
}
|
|
278
470
|
|
|
279
471
|
function extractStatus(raw) {
|
|
@@ -647,7 +839,15 @@ export function readIdeabox(cwd, ideaboxPath) {
|
|
|
647
839
|
return { ideas: [], killed: [], nextId: 1 }
|
|
648
840
|
}
|
|
649
841
|
const markdown = readFileSync(fullPath, 'utf-8')
|
|
650
|
-
|
|
842
|
+
// FU-2. Returning a partial parse from here made "I could not read this file"
|
|
843
|
+
// indistinguishable from "this file has nothing in it" for every caller —
|
|
844
|
+
// most visibly `compose new --from-idea`, which reported "idea not found" for
|
|
845
|
+
// an idea plainly present in the file and then built a feature without the
|
|
846
|
+
// content it had been asked for. The assertion lives in its own leaf module
|
|
847
|
+
// (`fluid/ideabox-readable.js`) precisely so the parser's module can call it:
|
|
848
|
+
// the migration gate imports `parseIdeabox` from here, so keeping it there
|
|
849
|
+
// would close an import cycle.
|
|
850
|
+
return assertIdeaboxReadable(parseIdeabox(markdown), fullPath)
|
|
651
851
|
}
|
|
652
852
|
|
|
653
853
|
/**
|