@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.
- package/.compose-deps.json +1 -13
- package/README.md +72 -5
- package/bin/compose.js +470 -351
- 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/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
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/fluid/ideabox-ops.js — the ideabox mutations, owned once.
|
|
3
|
+
*
|
|
4
|
+
* COMP-PLAN-IDEA-UNIFY S3b-2 (D20).
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS MODULE EXISTS
|
|
7
|
+
* ----------------------
|
|
8
|
+
* S3b-1 cut the CLI over to the record store and closed the cockpit's write
|
|
9
|
+
* path with a 409 rather than let it overwrite generated output. The obvious way
|
|
10
|
+
* to reopen it is to have `server/ideabox-routes.js` call the provider "the way
|
|
11
|
+
* the CLI does". That is the S3b-1 mistake, repeated.
|
|
12
|
+
*
|
|
13
|
+
* S3b-1 put serialized mutation and `reclaimAborted` into `local-provider.js`
|
|
14
|
+
* instead of the seam, so `smartmemory-provider.js` satisfied the interface
|
|
15
|
+
* completely while having neither — a second implementation that looks finished
|
|
16
|
+
* and silently lacks the guarantees. Two independent call sites that must each
|
|
17
|
+
* remember to run the migration gate before writing, and to re-render after,
|
|
18
|
+
* fail in exactly that shape: the route looks correct, passes review, and
|
|
19
|
+
* destroys an upgrading user's ideabox the first time it is used.
|
|
20
|
+
*
|
|
21
|
+
* So the two invariants live HERE, in code neither surface can skip:
|
|
22
|
+
*
|
|
23
|
+
* 1. `ensureIdeaboxMigrated` runs FIRST, before any write. Without it, a
|
|
24
|
+
* project with a populated markdown ideabox and an empty store has its ideas
|
|
25
|
+
* replaced by whatever the caller just typed.
|
|
26
|
+
* 2. The projection is rewritten AFTER the record is durable, never before. If
|
|
27
|
+
* the render throws, the record is already saved and `compose ideabox
|
|
28
|
+
* render` completes the job. The other order writes a file describing a
|
|
29
|
+
* state that was never stored.
|
|
30
|
+
*
|
|
31
|
+
* WHAT AN OP RETURNS, AND WHY IT IS NOT A MESSAGE
|
|
32
|
+
* ----------------------------------------------
|
|
33
|
+
* `{ record, markdown, ...facts }`. The op does no printing and no serializing:
|
|
34
|
+
* the CLI needs a line of text and the API needs a JSON body, and an op that
|
|
35
|
+
* chose for them would force one surface to parse the other's presentation. The
|
|
36
|
+
* `markdown` it hands back is the exact projection it just wrote, which is what
|
|
37
|
+
* lets the API derive its response by parsing that string instead of re-reading
|
|
38
|
+
* a file whose mtime it would have to race (D21).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { existsSync } from 'node:fs';
|
|
42
|
+
import { join } from 'node:path';
|
|
43
|
+
|
|
44
|
+
import { fluidProviderFor } from './factory.js';
|
|
45
|
+
import { ensureIdeaboxMigrated } from './ideabox-migrate.js';
|
|
46
|
+
import { writeIdeaboxProjection } from './render-ideabox.js';
|
|
47
|
+
import { FluidAmbiguousMatch, FluidRecordNotFound, KIND } from './provider.js';
|
|
48
|
+
import { resolveIdeaboxPath, resolveFeaturesPathFromConfig } from '../project-paths.js';
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Typed failures
|
|
52
|
+
//
|
|
53
|
+
// Typed rather than string-matched. The routes previously decided their status
|
|
54
|
+
// code with `err.message.includes('not found')`, which makes every error message
|
|
55
|
+
// load-bearing: rewording one silently turns a 404 into a 500. Each op names its
|
|
56
|
+
// failure and the caller maps it once.
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/** The caller addressed a record that does not exist. HTTP 404. */
|
|
60
|
+
export class IdeaboxNotFound extends Error {
|
|
61
|
+
constructor(id) {
|
|
62
|
+
super(`Idea not found: ${id}`);
|
|
63
|
+
this.name = 'IdeaboxNotFound';
|
|
64
|
+
this.code = 'IDEA_NOT_FOUND';
|
|
65
|
+
this.id = id;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The caller's input is malformed or out of range. HTTP 400. */
|
|
70
|
+
export class IdeaboxInvalid extends Error {
|
|
71
|
+
constructor(message, field) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = 'IdeaboxInvalid';
|
|
74
|
+
this.code = 'IDEA_INVALID';
|
|
75
|
+
this.field = field;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The record exists but is in a state that forbids the operation. HTTP 409. */
|
|
80
|
+
export class IdeaboxConflict extends Error {
|
|
81
|
+
constructor(message, detail = {}) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.name = 'IdeaboxConflict';
|
|
84
|
+
this.code = 'IDEA_CONFLICT';
|
|
85
|
+
this.detail = detail;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The record was written and the projection was not.
|
|
91
|
+
*
|
|
92
|
+
* A distinct type because it is the one failure where the caller MUST NOT tell
|
|
93
|
+
* the user the operation did not happen. It did; only the generated file is
|
|
94
|
+
* stale, and `compose ideabox render` fixes it without touching a record.
|
|
95
|
+
*/
|
|
96
|
+
export class IdeaboxRenderFailed extends Error {
|
|
97
|
+
constructor(cause, record) {
|
|
98
|
+
super(
|
|
99
|
+
`The idea was saved, but the ideabox file could not be regenerated from the records: ` +
|
|
100
|
+
`${cause?.message ?? cause}. Nothing is lost — fix the cause and run ` +
|
|
101
|
+
`\`compose ideabox render\` to rebuild the file.`
|
|
102
|
+
);
|
|
103
|
+
this.name = 'IdeaboxRenderFailed';
|
|
104
|
+
this.code = 'IDEA_RENDER_FAILED';
|
|
105
|
+
this.cause = cause;
|
|
106
|
+
this.record = record;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Context
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build the context every op takes.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} cwd project root
|
|
118
|
+
* @param {object} [opts]
|
|
119
|
+
* @param {string} [opts.origin] provenance door — `cli:ideabox` or `ui:ideabox`.
|
|
120
|
+
* Stamped at write time and never retrofitted, so a record captured in the
|
|
121
|
+
* cockpit stays distinguishable from one typed at a terminal for its lifetime.
|
|
122
|
+
* @param {object} [opts.config] already-loaded `.compose/compose.json`
|
|
123
|
+
* @param {import('./provider.js').FluidProvider} [opts.provider] an existing
|
|
124
|
+
* provider, for tests and for callers that hold one already
|
|
125
|
+
*/
|
|
126
|
+
export async function ideaboxContext(cwd, opts = {}) {
|
|
127
|
+
return {
|
|
128
|
+
cwd,
|
|
129
|
+
provider: opts.provider ?? await fluidProviderFor(cwd),
|
|
130
|
+
ideaboxPath: opts.ideaboxPath ?? resolveIdeaboxPath(cwd),
|
|
131
|
+
config: opts.config ?? {},
|
|
132
|
+
origin: opts.origin ?? 'cli:ideabox',
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Internals shared by every op
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
const PRIORITIES = ['P0', 'P1', 'P2'];
|
|
141
|
+
const EFFORTS = ['S', 'M', 'L'];
|
|
142
|
+
const IMPACTS = ['low', 'medium', 'high'];
|
|
143
|
+
|
|
144
|
+
/** Invariant 1. Every mutating op calls this before it writes anything. */
|
|
145
|
+
const gate = (ctx) => ensureIdeaboxMigrated(ctx.provider, ctx.ideaboxPath);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Invariant 2. Called only after the record is durable.
|
|
149
|
+
*
|
|
150
|
+
* Wrapped so the failure carries the record: a caller that reported "could not
|
|
151
|
+
* save the idea" here would be telling the user the opposite of what happened.
|
|
152
|
+
*/
|
|
153
|
+
async function project(ctx, record) {
|
|
154
|
+
try {
|
|
155
|
+
return await writeIdeaboxProjection(ctx.provider, ctx.ideaboxPath);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
throw new IdeaboxRenderFailed(err, record);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Find by handle, case-insensitively, the way the CLI has always accepted
|
|
163
|
+
* `idea-3`. Killed ideas are included: a kill can be discussed, re-prioritised
|
|
164
|
+
* and resurrected, and only promotion refuses one (see `promoteIdea`).
|
|
165
|
+
*/
|
|
166
|
+
export async function findIdea(provider, id) {
|
|
167
|
+
const wanted = String(id ?? '').toUpperCase();
|
|
168
|
+
const records = await provider.listRecords({ kind: KIND.IDEA });
|
|
169
|
+
return records.find((r) => r.handle.toUpperCase() === wanted) ?? null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function requireIdea(ctx, id) {
|
|
173
|
+
const idea = await findIdea(ctx.provider, id);
|
|
174
|
+
if (!idea) throw new IdeaboxNotFound(id);
|
|
175
|
+
return idea;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Tags are stored bare — the parser accepts a leading `#` verbatim but no record
|
|
180
|
+
* on disk uses one, and the projection's own convention line documents them as
|
|
181
|
+
* bare words.
|
|
182
|
+
*/
|
|
183
|
+
export function normalizeTags(raw) {
|
|
184
|
+
const list = Array.isArray(raw) ? raw : String(raw ?? '').split(',');
|
|
185
|
+
return list.map((t) => String(t).trim().replace(/^#/, '')).filter(Boolean);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** `—`, empty and null all mean untriaged; the record stores that as null. */
|
|
189
|
+
function normalizePriority(raw) {
|
|
190
|
+
if (raw === null || raw === undefined || raw === '' || raw === '—') return null;
|
|
191
|
+
const p = String(raw).toUpperCase();
|
|
192
|
+
if (!PRIORITIES.includes(p)) {
|
|
193
|
+
throw new IdeaboxInvalid(`Invalid priority: ${raw}. Use P0, P1, P2 or —`, 'priority');
|
|
194
|
+
}
|
|
195
|
+
return p;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function normalizeEnum(raw, allowed, field) {
|
|
199
|
+
if (raw === null || raw === undefined || raw === '') return null;
|
|
200
|
+
const v = String(raw);
|
|
201
|
+
if (!allowed.includes(v)) {
|
|
202
|
+
throw new IdeaboxInvalid(`${field} must be ${allowed.join(', ')}, or null`, field);
|
|
203
|
+
}
|
|
204
|
+
return v;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Resolve a cluster argument to a cluster HANDLE, creating one if the name is
|
|
209
|
+
* new.
|
|
210
|
+
*
|
|
211
|
+
* The renderer matches members by handle, so storing a raw name puts the idea in
|
|
212
|
+
* neither its cluster nor the unclustered bucket: it vanishes from the file
|
|
213
|
+
* while its record sits on disk. Resolution therefore happens BEFORE the write,
|
|
214
|
+
* not after — a durable record that cannot be rendered is the one failure this
|
|
215
|
+
* module cannot undo for the caller.
|
|
216
|
+
*
|
|
217
|
+
* @returns {Promise<{handle: string|null, created: object|null}>}
|
|
218
|
+
*/
|
|
219
|
+
export async function resolveCluster(provider, name) {
|
|
220
|
+
if (name === null || name === undefined || name === '') return { handle: null, created: null };
|
|
221
|
+
|
|
222
|
+
// A handle is accepted as-is but must exist. This lookup does not race: a
|
|
223
|
+
// handle either names a record or it does not, and nothing here creates one.
|
|
224
|
+
const clusters = await provider.listRecords({ kind: KIND.CLUSTER });
|
|
225
|
+
const byHandle = clusters.find((c) => c.handle.toUpperCase() === String(name).toUpperCase());
|
|
226
|
+
if (byHandle) return { handle: byHandle.handle, created: null };
|
|
227
|
+
|
|
228
|
+
// The by-name path DOES race, and used to lose (COMP-FLUID-SEAM-GUARANTEES
|
|
229
|
+
// F6-1): looking up and then creating are two operations, and the provider's
|
|
230
|
+
// lock covers each one but not the pair, so two concurrent
|
|
231
|
+
// `add --cluster "Umbrella A"` both missed and both created — leaving two
|
|
232
|
+
// clusters with one name and the ideas split between them. `findOrCreateRecord`
|
|
233
|
+
// is the seam's single atomic operation for exactly this.
|
|
234
|
+
try {
|
|
235
|
+
const { record, created } = await provider.findOrCreateRecord(
|
|
236
|
+
{ kind: KIND.CLUSTER, title: String(name) },
|
|
237
|
+
{}
|
|
238
|
+
);
|
|
239
|
+
return { handle: record.handle, created: created ? record : null };
|
|
240
|
+
} catch (err) {
|
|
241
|
+
if (err instanceof FluidAmbiguousMatch) {
|
|
242
|
+
throw new IdeaboxInvalid(
|
|
243
|
+
`"${name}" matches ${err.handles.length} clusters (${err.handles.join(', ')}). ` +
|
|
244
|
+
`Pass the handle instead.`,
|
|
245
|
+
'cluster'
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Operations
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Capture a new idea.
|
|
258
|
+
* @returns {Promise<{record: object, markdown: string, createdCluster: object|null}>}
|
|
259
|
+
*/
|
|
260
|
+
export async function addIdea(ctx, { title, body = '', source = '', tags = [], cluster = null } = {}) {
|
|
261
|
+
if (!title || !String(title).trim()) throw new IdeaboxInvalid('title is required', 'title');
|
|
262
|
+
await gate(ctx);
|
|
263
|
+
|
|
264
|
+
const { handle: clusterHandle, created: createdCluster } = await resolveCluster(ctx.provider, cluster);
|
|
265
|
+
const record = await ctx.provider.createRecord({
|
|
266
|
+
kind: KIND.IDEA,
|
|
267
|
+
title: String(title).trim(),
|
|
268
|
+
body: body ?? '',
|
|
269
|
+
source: source ?? '',
|
|
270
|
+
tags: normalizeTags(tags),
|
|
271
|
+
cluster: clusterHandle,
|
|
272
|
+
provenance: { origin: ctx.origin },
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
return { record, markdown: await project(ctx, record), createdCluster };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Patch an idea's editable fields.
|
|
280
|
+
*
|
|
281
|
+
* `status` is deliberately absent from the allow-list: promotion and kill are
|
|
282
|
+
* lifecycle events with their own consequences (a feature folder, a dated
|
|
283
|
+
* reason), and a status set through a generic field patch would produce a record
|
|
284
|
+
* claiming an outcome that never happened.
|
|
285
|
+
*
|
|
286
|
+
* @param {object} fields any of title, description/body, source, tags, cluster,
|
|
287
|
+
* mapsTo, effort, impact, priority
|
|
288
|
+
*/
|
|
289
|
+
export async function updateIdea(ctx, id, fields = {}) {
|
|
290
|
+
if (fields.status !== undefined) {
|
|
291
|
+
throw new IdeaboxInvalid(
|
|
292
|
+
'Status changes must go through promote, kill or resurrect, not a field update',
|
|
293
|
+
'status'
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
await gate(ctx);
|
|
298
|
+
const idea = await requireIdea(ctx, id);
|
|
299
|
+
|
|
300
|
+
const patch = {};
|
|
301
|
+
if (fields.title !== undefined) {
|
|
302
|
+
if (!String(fields.title ?? '').trim()) throw new IdeaboxInvalid('title cannot be empty', 'title');
|
|
303
|
+
patch.title = String(fields.title).trim();
|
|
304
|
+
}
|
|
305
|
+
// `description` is the client-facing name for the record's `body`; both are
|
|
306
|
+
// accepted so a caller need not know which side of the seam it is on.
|
|
307
|
+
const body = fields.body !== undefined ? fields.body : fields.description;
|
|
308
|
+
if (body !== undefined) patch.body = body ?? '';
|
|
309
|
+
if (fields.source !== undefined) patch.source = fields.source ?? null;
|
|
310
|
+
if (fields.tags !== undefined) patch.tags = normalizeTags(fields.tags);
|
|
311
|
+
if (fields.priority !== undefined) patch.priority = normalizePriority(fields.priority);
|
|
312
|
+
if (fields.effort !== undefined) patch.effort = normalizeEnum(fields.effort, EFFORTS, 'effort');
|
|
313
|
+
if (fields.impact !== undefined) patch.impact = normalizeEnum(fields.impact, IMPACTS, 'impact');
|
|
314
|
+
|
|
315
|
+
let createdCluster = null;
|
|
316
|
+
if (fields.cluster !== undefined) {
|
|
317
|
+
const resolved = await resolveCluster(ctx.provider, fields.cluster);
|
|
318
|
+
patch.cluster = resolved.handle;
|
|
319
|
+
createdCluster = resolved.created;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (fields.mapsTo !== undefined) {
|
|
323
|
+
const rest = idea.links.filter((l) => l.type !== 'maps_to');
|
|
324
|
+
patch.links = fields.mapsTo
|
|
325
|
+
? [...rest, { type: 'maps_to', target: String(fields.mapsTo) }]
|
|
326
|
+
: rest;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Nothing to do is not an error — a PATCH carrying only unknown keys has
|
|
330
|
+
// simply asked for nothing. Returning the record unchanged, and re-rendering,
|
|
331
|
+
// keeps the caller's "what does it look like now" answer correct.
|
|
332
|
+
const record = Object.keys(patch).length
|
|
333
|
+
? await ctx.provider.updateRecord(idea.handle, patch)
|
|
334
|
+
: idea;
|
|
335
|
+
|
|
336
|
+
return { record, markdown: await project(ctx, record), createdCluster };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Set or clear triage priority. `—`, `''` and null all mean untriaged. */
|
|
340
|
+
export async function setPriority(ctx, id, priority) {
|
|
341
|
+
// Validated before the migration gate runs: a bad argument should not be the
|
|
342
|
+
// thing that triggers a project's one-time import.
|
|
343
|
+
normalizePriority(priority);
|
|
344
|
+
return updateIdea(ctx, id, { priority });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Kill an idea, with a dated reason.
|
|
349
|
+
*
|
|
350
|
+
* Killing an already-killed idea is a no-op that still re-renders. Not
|
|
351
|
+
* politeness: every op writes its record before the projection, so "record
|
|
352
|
+
* committed, render failed" invites a retry — and an unconditional write would
|
|
353
|
+
* replace the original date and reason, most likely with "(no reason given)". A
|
|
354
|
+
* kill is dated evidence; a retry must not rewrite it. The render still runs, so
|
|
355
|
+
* the retry finishes the job it failed at.
|
|
356
|
+
*
|
|
357
|
+
* @returns {Promise<{record, markdown, alreadyKilled: boolean}>}
|
|
358
|
+
*/
|
|
359
|
+
export async function killIdea(ctx, id, reason = '') {
|
|
360
|
+
await gate(ctx);
|
|
361
|
+
const idea = await requireIdea(ctx, id);
|
|
362
|
+
|
|
363
|
+
if (idea.status === 'killed') {
|
|
364
|
+
return { record: idea, markdown: await project(ctx, idea), alreadyKilled: true };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const record = await ctx.provider.updateRecord(idea.handle, {
|
|
368
|
+
status: 'killed',
|
|
369
|
+
killed: { at: new Date().toISOString(), reason: reason || '(no reason given)' },
|
|
370
|
+
});
|
|
371
|
+
return { record, markdown: await project(ctx, record), alreadyKilled: false };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Return a killed idea to the live set, preserving its handle.
|
|
376
|
+
*
|
|
377
|
+
* `killed` and `status_label` are both cleared. Leaving the label would render a
|
|
378
|
+
* resurrected idea under whatever free-form token it carried when it died, and
|
|
379
|
+
* leaving `killed` would leave a live idea holding a dated kill reason — a
|
|
380
|
+
* record that contradicts itself.
|
|
381
|
+
*/
|
|
382
|
+
export async function resurrectIdea(ctx, id) {
|
|
383
|
+
await gate(ctx);
|
|
384
|
+
const idea = await requireIdea(ctx, id);
|
|
385
|
+
|
|
386
|
+
if (idea.status !== 'killed') {
|
|
387
|
+
throw new IdeaboxConflict(`${idea.handle} is not killed, so there is nothing to resurrect`, {
|
|
388
|
+
handle: idea.handle,
|
|
389
|
+
status: idea.status,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const record = await ctx.provider.updateRecord(idea.handle, {
|
|
394
|
+
status: 'new',
|
|
395
|
+
status_label: null,
|
|
396
|
+
killed: null,
|
|
397
|
+
});
|
|
398
|
+
return { record, markdown: await project(ctx, record) };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Promote an idea to a feature, creating the feature folder if it is absent.
|
|
403
|
+
*
|
|
404
|
+
* A killed idea is refused (D22). The CLI's lookup finds killed records, so
|
|
405
|
+
* without this guard `compose ideabox promote` on a killed idea would quietly
|
|
406
|
+
* flip it to `promoted` — undoing a dated kill through a command that never
|
|
407
|
+
* mentions kills. The old REST route refused by accident, having searched only
|
|
408
|
+
* the live array; here it is refused on purpose, and both surfaces agree.
|
|
409
|
+
*
|
|
410
|
+
* The promotion is recorded as a typed `promoted_to` link rather than a
|
|
411
|
+
* formatted status string, so the idea-to-feature edge is data in the graph
|
|
412
|
+
* instead of prose to re-parse.
|
|
413
|
+
*/
|
|
414
|
+
export async function promoteIdea(ctx, id, featureCode = '') {
|
|
415
|
+
await gate(ctx);
|
|
416
|
+
const idea = await requireIdea(ctx, id);
|
|
417
|
+
|
|
418
|
+
if (idea.status === 'killed') {
|
|
419
|
+
throw new IdeaboxConflict(
|
|
420
|
+
`${idea.handle} was killed on ${String(idea.killed?.at ?? '').slice(0, 10)} and cannot be promoted. ` +
|
|
421
|
+
`Resurrect it first if the kill was wrong.`,
|
|
422
|
+
{ handle: idea.handle, status: idea.status }
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
let code = featureCode || '';
|
|
427
|
+
if (!code) {
|
|
428
|
+
const slug = idea.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 20).replace(/-+$/, '');
|
|
429
|
+
code = `${idea.handle}-${slug}`.toUpperCase();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const featuresBase = resolveFeaturesPathFromConfig(ctx.cwd, ctx.config ?? {});
|
|
433
|
+
const featurePath = join(featuresBase, code);
|
|
434
|
+
let createdFeature = false;
|
|
435
|
+
if (!existsSync(featurePath)) {
|
|
436
|
+
// COMP-MCP-VALIDATE-1: route through the validated writer rather than a raw
|
|
437
|
+
// write, so a promoted feature.json is schema-guarded like any other.
|
|
438
|
+
const { writeFeature } = await import('../feature-json.js');
|
|
439
|
+
writeFeature(ctx.cwd, {
|
|
440
|
+
code,
|
|
441
|
+
description: idea.title,
|
|
442
|
+
status: 'PLANNED',
|
|
443
|
+
promotedFrom: idea.handle,
|
|
444
|
+
createdAt: new Date().toISOString(),
|
|
445
|
+
}, featuresBase);
|
|
446
|
+
createdFeature = true;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const record = await ctx.provider.updateRecord(idea.handle, {
|
|
450
|
+
status: 'promoted',
|
|
451
|
+
links: [...idea.links.filter((l) => l.type !== 'promoted_to'), { type: 'promoted_to', target: code }],
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
record,
|
|
456
|
+
markdown: await project(ctx, record),
|
|
457
|
+
featureCode: code,
|
|
458
|
+
featurePath,
|
|
459
|
+
createdFeature,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Append to an idea's deliberation trail. Append-only by contract. */
|
|
464
|
+
export async function addDiscussion(ctx, id, { author = null, text } = {}) {
|
|
465
|
+
if (!text || !String(text).trim()) throw new IdeaboxInvalid('text is required', 'text');
|
|
466
|
+
await gate(ctx);
|
|
467
|
+
const idea = await requireIdea(ctx, id);
|
|
468
|
+
|
|
469
|
+
const record = await ctx.provider.appendDiscussion(idea.handle, {
|
|
470
|
+
text: String(text),
|
|
471
|
+
author: author ?? null,
|
|
472
|
+
});
|
|
473
|
+
return { record, markdown: await project(ctx, record) };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Push-back (FOH-3): contradiction-detect a decision or idea against same-kind
|
|
478
|
+
* records, via the provider's CHALLENGE capability.
|
|
479
|
+
*
|
|
480
|
+
* Read-only — no gate, no projection write. Resolution is kind-agnostic (a
|
|
481
|
+
* decision handle resolves, not just an idea) and case-insensitive, matching the
|
|
482
|
+
* other ops; the provider owns the capability check and the challengeable-kind
|
|
483
|
+
* gate, so a miss surfaces as `IdeaboxNotFound` and everything else propagates.
|
|
484
|
+
*
|
|
485
|
+
* @returns {Promise<import('./provider.js').ChallengeResult>}
|
|
486
|
+
*/
|
|
487
|
+
export async function challengeIdea(ctx, id, opts = {}) {
|
|
488
|
+
const handle = String(id ?? '').toUpperCase(); // getRecord rejects lowercase
|
|
489
|
+
try {
|
|
490
|
+
return await ctx.provider.challenge(handle, opts);
|
|
491
|
+
} catch (err) {
|
|
492
|
+
if (err instanceof FluidRecordNotFound) throw new IdeaboxNotFound(id);
|
|
493
|
+
throw err; // FluidCapabilityUnavailable / FluidKindUnsupported propagate as-is
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Belief-strength read (FOH-4): a record's current confidence and its decay
|
|
499
|
+
* history, via the provider's CONVICTION capability.
|
|
500
|
+
*
|
|
501
|
+
* Unlike `challengeIdea` this DOES run the migration gate first: on a
|
|
502
|
+
* markdown-only project the record only exists after migration, and answering
|
|
503
|
+
* "not found" for an idea sitting right there in the markdown would be wrong.
|
|
504
|
+
* No projection write — nothing in the record body changes on a read.
|
|
505
|
+
*
|
|
506
|
+
* @returns {Promise<import('./provider.js').ConvictionResult>}
|
|
507
|
+
*/
|
|
508
|
+
export async function convictionOf(ctx, id) {
|
|
509
|
+
await gate(ctx);
|
|
510
|
+
const handle = String(id ?? '').toUpperCase();
|
|
511
|
+
try {
|
|
512
|
+
return await ctx.provider.conviction(handle);
|
|
513
|
+
} catch (err) {
|
|
514
|
+
if (err instanceof FluidRecordNotFound) throw new IdeaboxNotFound(id);
|
|
515
|
+
throw err;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* The records that contradict `id` (FOH-5), via the provider's CONTRADICTION
|
|
521
|
+
* capability — the itemized, resolvable form of what conviction shows only as
|
|
522
|
+
* truncated history text. A best-effort LOWER BOUND (see the seam contract on
|
|
523
|
+
* `contradictions()`): an abandoned edge write can under-report, and the
|
|
524
|
+
* authoritative decay record is `convictionOf(id).history`.
|
|
525
|
+
*
|
|
526
|
+
* Runs the migration gate for the same reason `convictionOf` does: on a
|
|
527
|
+
* markdown-only project the record only exists after migration.
|
|
528
|
+
*
|
|
529
|
+
* @returns {Promise<import('./provider.js').ContradictionHit[]>}
|
|
530
|
+
*/
|
|
531
|
+
export async function contradictionsOf(ctx, id) {
|
|
532
|
+
await gate(ctx);
|
|
533
|
+
const handle = String(id ?? '').toUpperCase();
|
|
534
|
+
try {
|
|
535
|
+
return await ctx.provider.contradictions(handle);
|
|
536
|
+
} catch (err) {
|
|
537
|
+
if (err instanceof FluidRecordNotFound) throw new IdeaboxNotFound(id);
|
|
538
|
+
throw err;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Gated resolution of a detected contradiction (FOH-4): decay `against`'s
|
|
544
|
+
* confidence because `id` supersedes it.
|
|
545
|
+
*
|
|
546
|
+
* `id` is the record whose challenge surfaced the conflict; `against` is a
|
|
547
|
+
* conflict handle from that `challengeIdea(id)` result. The caller is TRUSTED
|
|
548
|
+
* on that provenance (v1 boundary — the provider verifies `against` is a real
|
|
549
|
+
* same-kind, non-self record, but cannot verify it was genuinely challenged:
|
|
550
|
+
* detection is LLM-based and there is no durable challenge record yet).
|
|
551
|
+
*
|
|
552
|
+
* Mutation contract, restated where the caller reads it:
|
|
553
|
+
* - the strategy is always explicit (v1: `accept_new` only) — never a default;
|
|
554
|
+
* - a 0.5 decay is near-irreversible (no fluid reinforce path exists);
|
|
555
|
+
* - calling it again decays again (1.0 → 0.5 → 0.0), deliberately un-deduped;
|
|
556
|
+
* - of the typed failures only `FluidResolutionNoOp` is safe to retry —
|
|
557
|
+
* `FluidResolutionIndeterminate` means the decay may still land, and a
|
|
558
|
+
* retry can double-decay.
|
|
559
|
+
*
|
|
560
|
+
* No projection write: the record body is untouched; only the SmartMemory-side
|
|
561
|
+
* confidence moved.
|
|
562
|
+
*
|
|
563
|
+
* @param {object} ctx
|
|
564
|
+
* @param {string} id the challenged (surviving) record
|
|
565
|
+
* @param {{against: string, strategy: string}} opts
|
|
566
|
+
* @returns {Promise<import('./provider.js').ConvictionResult>} `against`'s
|
|
567
|
+
* post-decay conviction
|
|
568
|
+
*/
|
|
569
|
+
export async function resolveIdeaChallenge(ctx, id, { against, strategy } = {}) {
|
|
570
|
+
await gate(ctx); // FIRST, like every mutating op — before even input checks
|
|
571
|
+
if (!against || !String(against).trim()) {
|
|
572
|
+
throw new IdeaboxInvalid('against is required — the conflict handle from a prior challenge', 'against');
|
|
573
|
+
}
|
|
574
|
+
const sourceHandle = String(id ?? '').toUpperCase();
|
|
575
|
+
const targetHandle = String(against).toUpperCase();
|
|
576
|
+
try {
|
|
577
|
+
return await ctx.provider.resolveConflict(sourceHandle, targetHandle, { strategy });
|
|
578
|
+
} catch (err) {
|
|
579
|
+
if (err instanceof FluidRecordNotFound) throw new IdeaboxNotFound(err.handle ?? id);
|
|
580
|
+
// FluidInvalidStrategy / FluidInvalidTarget / FluidResolutionNoOp /
|
|
581
|
+
// FluidResolutionConflict / FluidResolutionIndeterminate are typed for the
|
|
582
|
+
// caller and propagate as-is (same convention as challengeIdea).
|
|
583
|
+
throw err;
|
|
584
|
+
}
|
|
585
|
+
}
|