@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/lib/deps.js
CHANGED
|
@@ -45,7 +45,16 @@ export function loadDeps(packageRoot) {
|
|
|
45
45
|
const installOk = typeof dep?.install === 'string'
|
|
46
46
|
const fallbackOk = dep?.fallback === null || typeof dep?.fallback === 'string'
|
|
47
47
|
const optOk = typeof dep?.optional === 'boolean'
|
|
48
|
-
|
|
48
|
+
// COMP-DEPS-AUTOINSTALL: `plugin` is optional and additive. When present it
|
|
49
|
+
// is the `<plugin>[@marketplace]` spec passed to `claude plugin install`.
|
|
50
|
+
// Its absence means "no automated install path" (several deps are prose,
|
|
51
|
+
// e.g. "user-installed skill at ~/.claude/skills/refactor/"), never an error.
|
|
52
|
+
const pluginOk = dep?.plugin === undefined || typeof dep?.plugin === 'string'
|
|
53
|
+
// `marketplace_source` is the `owner/repo` (or URL) used to register the
|
|
54
|
+
// marketplace when the plugin's marketplace is not configured yet — the
|
|
55
|
+
// normal state on a brand-new machine.
|
|
56
|
+
const srcOk = dep?.marketplace_source === undefined || typeof dep?.marketplace_source === 'string'
|
|
57
|
+
if (!(idOk && reqOk && installOk && fallbackOk && optOk && pluginOk && srcOk)) {
|
|
49
58
|
console.warn(`Warning: skipping invalid dep entry in .compose-deps.json: ${JSON.stringify(dep)}`)
|
|
50
59
|
continue
|
|
51
60
|
}
|
|
@@ -93,6 +102,36 @@ export function loadDeps(packageRoot) {
|
|
|
93
102
|
* - <home>/.claude/plugins/marketplaces/<m>/.claude/commands/<n>.md (pattern B')
|
|
94
103
|
* - <home>/.claude/plugins/cache/<marketplace>/<plugin>/<version>/skills/<s>/SKILL.md (pattern C)
|
|
95
104
|
*/
|
|
105
|
+
/**
|
|
106
|
+
* Read `~/.claude/plugins/installed_plugins.json` — Claude Code's authoritative
|
|
107
|
+
* record of which plugins are installed and where their tree lives.
|
|
108
|
+
*
|
|
109
|
+
* Returns `[{ plugin, installPath }]`, or `null` when the file is absent or
|
|
110
|
+
* unreadable, which the caller treats as "fall back to walking the cache".
|
|
111
|
+
* `null` and `[]` mean different things: `[]` is "nothing is installed".
|
|
112
|
+
*/
|
|
113
|
+
function readInstalledPluginPaths(home) {
|
|
114
|
+
const path = join(home, '.claude', 'plugins', 'installed_plugins.json')
|
|
115
|
+
if (!existsSync(path)) return null
|
|
116
|
+
let parsed
|
|
117
|
+
try {
|
|
118
|
+
parsed = JSON.parse(readFileSync(path, 'utf-8'))
|
|
119
|
+
} catch {
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
const plugins = parsed?.plugins
|
|
123
|
+
if (!plugins || typeof plugins !== 'object') return null
|
|
124
|
+
const out = []
|
|
125
|
+
for (const [key, entries] of Object.entries(plugins)) {
|
|
126
|
+
// Key is "<plugin>@<marketplace>"; the namespace agents use is the plugin half.
|
|
127
|
+
const plugin = key.split('@')[0]
|
|
128
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
129
|
+
if (typeof entry?.installPath === 'string') out.push({ plugin, installPath: entry.installPath })
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out
|
|
133
|
+
}
|
|
134
|
+
|
|
96
135
|
export function checkExternalSkills(deps, home = homedir()) {
|
|
97
136
|
const userSkillsRoot = join(home, '.claude', 'skills')
|
|
98
137
|
const marketplacesRoot = join(home, '.claude', 'plugins', 'marketplaces')
|
|
@@ -159,15 +198,34 @@ export function checkExternalSkills(deps, home = homedir()) {
|
|
|
159
198
|
}
|
|
160
199
|
|
|
161
200
|
// 3. Cache — pattern C: cache/<marketplace>/<plugin>/<version>/skills/<s>/SKILL.md
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
201
|
+
//
|
|
202
|
+
// COMP-DEPS-AUTOINSTALL: the cache OUTLIVES an uninstall — `claude plugin
|
|
203
|
+
// uninstall` drops the plugin from installed_plugins.json but leaves its
|
|
204
|
+
// cached tree on disk, so walking the cache alone reports an uninstalled
|
|
205
|
+
// plugin as present. Claude Code will not load it, and auto-install would
|
|
206
|
+
// never re-install it (nothing looks missing). When installed_plugins.json is
|
|
207
|
+
// readable it is the authority: only its recorded installPaths count. Older
|
|
208
|
+
// Claude Code versions have no such file — there, fall back to the cache walk
|
|
209
|
+
// rather than reporting every plugin missing.
|
|
210
|
+
const installedPaths = readInstalledPluginPaths(home)
|
|
211
|
+
if (installedPaths === null) {
|
|
212
|
+
for (const marketplace of listDirs(cacheRoot)) {
|
|
213
|
+
for (const plugin of listDirs(join(cacheRoot, marketplace))) {
|
|
214
|
+
for (const version of listDirs(join(cacheRoot, marketplace, plugin))) {
|
|
215
|
+
const skillsDir = join(cacheRoot, marketplace, plugin, version, 'skills')
|
|
216
|
+
for (const s of listDirs(skillsDir)) {
|
|
217
|
+
if (existsSync(join(skillsDir, s, 'SKILL.md'))) addNs(plugin, s)
|
|
218
|
+
}
|
|
168
219
|
}
|
|
169
220
|
}
|
|
170
221
|
}
|
|
222
|
+
} else {
|
|
223
|
+
for (const { plugin, installPath } of installedPaths) {
|
|
224
|
+
const skillsDir = join(installPath, 'skills')
|
|
225
|
+
for (const s of listDirs(skillsDir)) {
|
|
226
|
+
if (existsSync(join(skillsDir, s, 'SKILL.md'))) addNs(plugin, s)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
171
229
|
}
|
|
172
230
|
|
|
173
231
|
const present = []
|
|
@@ -206,6 +264,8 @@ export function buildDepReport(result) {
|
|
|
206
264
|
id: d.id,
|
|
207
265
|
required_for: d.required_for,
|
|
208
266
|
install: d.install,
|
|
267
|
+
plugin: d.plugin ?? null,
|
|
268
|
+
marketplace_source: d.marketplace_source ?? null,
|
|
209
269
|
fallback: d.fallback ?? null,
|
|
210
270
|
optional: d.optional,
|
|
211
271
|
})
|
|
@@ -263,6 +323,103 @@ export function buildBinaryReport(result) {
|
|
|
263
323
|
}
|
|
264
324
|
}
|
|
265
325
|
|
|
326
|
+
/**
|
|
327
|
+
* COMP-DEPS-AUTOINSTALL — install the plugins that provide missing REQUIRED deps.
|
|
328
|
+
*
|
|
329
|
+
* Only deps carrying a `plugin` spec are installable; everything else stays a
|
|
330
|
+
* printed hint. Optional deps are never auto-installed: the ones we ship
|
|
331
|
+
* (interface-design, openai-codex) live in third-party marketplaces that would
|
|
332
|
+
* each need a `claude plugin marketplace add` first, which is a consent step of
|
|
333
|
+
* a different size than pulling a plugin from a marketplace the user already has.
|
|
334
|
+
*
|
|
335
|
+
* Several deps resolve to the SAME plugin (six superpowers skills, one plugin),
|
|
336
|
+
* so specs are deduped — six missing deps produce one install.
|
|
337
|
+
*
|
|
338
|
+
* Never throws and never blocks: a missing `claude` binary, a non-zero exit or a
|
|
339
|
+
* timeout all come back as data for the caller to print. `-y` is required
|
|
340
|
+
* whenever stdin/stdout is not a TTY, so it is always passed.
|
|
341
|
+
*
|
|
342
|
+
* @param {object} result - from checkExternalSkills
|
|
343
|
+
* @param {object} [opts]
|
|
344
|
+
* @param {(cmd:string,args:string[])=>{status:number|null,stderr?:string,error?:Error}} [opts.spawn]
|
|
345
|
+
* @param {boolean} [opts.enabled] - false short-circuits to a skip (opt-out flag / env)
|
|
346
|
+
* @returns {{installed:string[], failed:{spec:string,reason:string}[], skipped:string|null}}
|
|
347
|
+
*/
|
|
348
|
+
export function installMissingPlugins(result, opts = {}) {
|
|
349
|
+
const none = { installed: [], failed: [], skipped: null }
|
|
350
|
+
if (opts.enabled === false) return { ...none, skipped: 'disabled' }
|
|
351
|
+
|
|
352
|
+
const specs = [...new Set(
|
|
353
|
+
result.missing.filter(d => !d.optional && typeof d.plugin === 'string' && d.plugin).map(d => d.plugin),
|
|
354
|
+
)]
|
|
355
|
+
if (specs.length === 0) return none
|
|
356
|
+
|
|
357
|
+
const spawn = opts.spawn ?? ((cmd, args) => {
|
|
358
|
+
// 120s: installing clones a git repo, which is slow on a cold cache.
|
|
359
|
+
try {
|
|
360
|
+
return spawnSync(cmd, args, { encoding: 'utf-8', timeout: 120000, stdio: 'pipe' })
|
|
361
|
+
} catch (error) {
|
|
362
|
+
return { status: null, error }
|
|
363
|
+
}
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
// spec -> marketplace source, for the add-then-retry path below.
|
|
367
|
+
const sourceFor = new Map()
|
|
368
|
+
for (const d of result.missing) {
|
|
369
|
+
if (d.plugin && d.marketplace_source && !sourceFor.has(d.plugin)) {
|
|
370
|
+
sourceFor.set(d.plugin, d.marketplace_source)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const installed = []
|
|
375
|
+
const failed = []
|
|
376
|
+
const install = (spec) => spawn('claude', ['plugin', 'install', spec, '-y', '--scope', 'user'])
|
|
377
|
+
|
|
378
|
+
for (const spec of specs) {
|
|
379
|
+
let res = install(spec)
|
|
380
|
+
|
|
381
|
+
// A brand-new machine has no marketplaces registered, so the first install
|
|
382
|
+
// fails with "not found in marketplace". Register the declared source and
|
|
383
|
+
// retry exactly once — measured against a clean HOME, where the un-retried
|
|
384
|
+
// path leaves every required dep missing. Only ever adds the marketplace the
|
|
385
|
+
// manifest names, never one derived from the failure text.
|
|
386
|
+
const notFound = /not found in marketplace|marketplace .* not found|unknown marketplace/i
|
|
387
|
+
.test(`${res?.stderr ?? ''}${res?.stdout ?? ''}`)
|
|
388
|
+
if (res?.status !== 0 && notFound && sourceFor.has(spec)) {
|
|
389
|
+
const add = spawn('claude', ['plugin', 'marketplace', 'add', sourceFor.get(spec)])
|
|
390
|
+
if (add?.status === 0) res = install(spec)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (res?.status === 0) { installed.push(spec); continue }
|
|
394
|
+
// ENOENT on the `claude` binary is a skip for the whole batch, not a failure
|
|
395
|
+
// of this spec: there is no install path at all on this machine.
|
|
396
|
+
if (res?.error?.code === 'ENOENT') {
|
|
397
|
+
return { installed, failed, skipped: 'the `claude` CLI is not on PATH' }
|
|
398
|
+
}
|
|
399
|
+
const reason = (res?.stderr || '').trim()
|
|
400
|
+
|| res?.error?.message
|
|
401
|
+
|| `exit ${res?.status ?? 'unknown'}`
|
|
402
|
+
failed.push({ spec, reason })
|
|
403
|
+
}
|
|
404
|
+
return { installed, failed, skipped: null }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Print what installMissingPlugins did. Returns nothing; the caller re-scans and
|
|
409
|
+
* prints the authoritative dep report afterwards.
|
|
410
|
+
*/
|
|
411
|
+
export function printInstallReport(report) {
|
|
412
|
+
if (report.skipped === 'disabled') return
|
|
413
|
+
if (!report.installed.length && !report.failed.length && !report.skipped) return
|
|
414
|
+
|
|
415
|
+
console.log('\nInstalling missing required plugins:')
|
|
416
|
+
for (const spec of report.installed) console.log(` + ${spec}`)
|
|
417
|
+
for (const { spec, reason } of report.failed) {
|
|
418
|
+
console.log(` ! ${spec} — install failed: ${reason}`)
|
|
419
|
+
}
|
|
420
|
+
if (report.skipped) console.log(` - skipped: ${report.skipped}`)
|
|
421
|
+
}
|
|
422
|
+
|
|
266
423
|
/**
|
|
267
424
|
* Print a human-readable external-binary report. Returns true if all required
|
|
268
425
|
* (non-optional) binaries are present. Prints nothing when no binaries are declared.
|
package/lib/dir-lock.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/dir-lock.js — the one advisory lock, extracted.
|
|
3
|
+
*
|
|
4
|
+
* COMP-PLAN-IDEA-UNIFY S3b-1 (D17).
|
|
5
|
+
*
|
|
6
|
+
* `mkdir` is the primitive: it is atomic on every filesystem this runs on, and
|
|
7
|
+
* it fails with EEXIST rather than clobbering. Everything below exists to make
|
|
8
|
+
* that primitive survive the cases a bare `mkdirSync` does not.
|
|
9
|
+
*
|
|
10
|
+
* There were six independent copy-pasted versions of this in `lib/` when this
|
|
11
|
+
* module was written, and they did NOT agree — the weakest set its stale
|
|
12
|
+
* threshold equal to its acquire timeout, which makes ordinary contention
|
|
13
|
+
* indistinguishable from a crashed holder, so a busy lock gets stolen from a
|
|
14
|
+
* live owner. This is the hardened shape (from `lib/judgment-writer.js`),
|
|
15
|
+
* lifted so there is one implementation to reason about instead of six.
|
|
16
|
+
*
|
|
17
|
+
* The four things that are not obvious:
|
|
18
|
+
*
|
|
19
|
+
* 1. **An owner token inside the dir.** Without it, release cannot tell "my
|
|
20
|
+
* lock" from "the lock that replaced mine after it was declared stale", so
|
|
21
|
+
* a slow holder deletes the new owner's lock on the way out. This is the
|
|
22
|
+
* ABA case and it is the reason release reads before it removes.
|
|
23
|
+
*
|
|
24
|
+
* 2. **A partial acquisition is undone.** If the dir is created but the token
|
|
25
|
+
* write fails, every later release declines to remove a lock it cannot
|
|
26
|
+
* prove is its own — stranding it until the stale window expires. So that
|
|
27
|
+
* window is closed by hand rather than waited out.
|
|
28
|
+
*
|
|
29
|
+
* 3. **A heartbeat, and why the threshold is high.** The holder touches the
|
|
30
|
+
* dir's mtime on a timer so a long-but-live operation is not mistaken for a
|
|
31
|
+
* dead one. The timer runs on the event loop, so a SYNCHRONOUS block longer
|
|
32
|
+
* than the stale threshold defeats it. 20s is set well above any sync
|
|
33
|
+
* section this codebase produces (small-file fs I/O; long work is async).
|
|
34
|
+
*
|
|
35
|
+
* 4. **Stale reclaim is by mtime, not by pid.** A pid check cannot see across
|
|
36
|
+
* containers or a reused pid, and this lock guards files that two clones
|
|
37
|
+
* can reach.
|
|
38
|
+
*
|
|
39
|
+
* The lock is advisory: it coordinates writers that agree to use it. Nothing
|
|
40
|
+
* stops a writer that does not, which is why tool-owned canon also has the
|
|
41
|
+
* guard layer.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import {
|
|
45
|
+
mkdirSync,
|
|
46
|
+
readFileSync,
|
|
47
|
+
rmSync,
|
|
48
|
+
statSync,
|
|
49
|
+
utimesSync,
|
|
50
|
+
writeFileSync,
|
|
51
|
+
} from 'node:fs';
|
|
52
|
+
import { dirname, join } from 'node:path';
|
|
53
|
+
|
|
54
|
+
/** A holder is presumed dead once its mtime is this old. See note 3. */
|
|
55
|
+
const LOCK_STALE_MS = 20000;
|
|
56
|
+
/** How long to keep trying before giving up on a live holder. */
|
|
57
|
+
const LOCK_ACQUIRE_TIMEOUT_MS = 30000;
|
|
58
|
+
/** Keeps a live holder from being declared stale. */
|
|
59
|
+
const LOCK_HEARTBEAT_MS = 1000;
|
|
60
|
+
/** Poll interval while waiting on a holder. */
|
|
61
|
+
const LOCK_RETRY_MS = 25;
|
|
62
|
+
|
|
63
|
+
/** The holder's token, or null if it cannot be read (racing, or never written). */
|
|
64
|
+
function readOwner(ownerFile) {
|
|
65
|
+
try { return readFileSync(ownerFile, 'utf8'); } catch { return null; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class DirLockTimeout extends Error {
|
|
69
|
+
constructor(path, ms) {
|
|
70
|
+
super(`dir-lock: timed out after ${ms}ms waiting for ${path}`);
|
|
71
|
+
this.name = 'DirLockTimeout';
|
|
72
|
+
this.code = 'DIR_LOCK_TIMEOUT';
|
|
73
|
+
this.path = path;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Acquire the lock at `path`, returning a release function.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} path directory to create as the lock
|
|
81
|
+
* @returns {Promise<() => void>} release — idempotent, and a no-op if the lock
|
|
82
|
+
* is no longer provably ours
|
|
83
|
+
*/
|
|
84
|
+
export async function acquireDirLock(path, { timeoutMs = LOCK_ACQUIRE_TIMEOUT_MS } = {}) {
|
|
85
|
+
const ownerFile = join(path, 'owner');
|
|
86
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
87
|
+
const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
88
|
+
const start = Date.now();
|
|
89
|
+
|
|
90
|
+
for (;;) {
|
|
91
|
+
try {
|
|
92
|
+
mkdirSync(path);
|
|
93
|
+
try {
|
|
94
|
+
writeFileSync(ownerFile, token);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
// Note 2: undo our own partial acquisition rather than strand the lock.
|
|
97
|
+
rmSync(path, { recursive: true, force: true });
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
const heartbeat = setInterval(() => {
|
|
101
|
+
try {
|
|
102
|
+
utimesSync(path, new Date(), new Date());
|
|
103
|
+
} catch { /* stolen or gone — release will no-op */ }
|
|
104
|
+
}, LOCK_HEARTBEAT_MS);
|
|
105
|
+
heartbeat.unref?.();
|
|
106
|
+
|
|
107
|
+
let released = false;
|
|
108
|
+
return () => {
|
|
109
|
+
if (released) return;
|
|
110
|
+
released = true;
|
|
111
|
+
clearInterval(heartbeat);
|
|
112
|
+
try {
|
|
113
|
+
// Note 1: only remove a lock still provably ours.
|
|
114
|
+
if (readFileSync(ownerFile, 'utf8') === token) {
|
|
115
|
+
rmSync(path, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
} catch { /* not ours anymore — leave it */ }
|
|
118
|
+
};
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (err.code !== 'EEXIST') throw err;
|
|
121
|
+
try {
|
|
122
|
+
const { mtimeMs } = statSync(path);
|
|
123
|
+
if (Date.now() - mtimeMs > LOCK_STALE_MS) {
|
|
124
|
+
// Reclaim is compare-and-delete, not blind delete. Two contenders can
|
|
125
|
+
// both stat the same stale lock and both decide to remove it; if the
|
|
126
|
+
// first then acquires a fresh one, a blind `rmSync` from the second
|
|
127
|
+
// deletes the NEW owner's lock and both proceed into the critical
|
|
128
|
+
// section. That is the ABA case on the acquire side — the release side
|
|
129
|
+
// already guarded against it, and this side did not.
|
|
130
|
+
//
|
|
131
|
+
// Re-reading the owner token and removing only if it still matches
|
|
132
|
+
// what we saw when we judged it stale closes the window: the winner
|
|
133
|
+
// rewrote the token, so the loser's compare fails and it retries.
|
|
134
|
+
const staleOwner = readOwner(ownerFile);
|
|
135
|
+
const { mtimeMs: recheck } = statSync(path);
|
|
136
|
+
if (recheck === mtimeMs && readOwner(ownerFile) === staleOwner) {
|
|
137
|
+
rmSync(path, { recursive: true, force: true });
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
} catch { /* stat raced the holder's release; loop and retry */ }
|
|
142
|
+
if (Date.now() - start > timeoutMs) {
|
|
143
|
+
throw new DirLockTimeout(path, timeoutMs);
|
|
144
|
+
}
|
|
145
|
+
await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Run `fn` holding the lock at `path`. The lock is always released, including
|
|
152
|
+
* when `fn` throws.
|
|
153
|
+
*
|
|
154
|
+
* Not reentrant: calling this for the same path from inside `fn` deadlocks
|
|
155
|
+
* until the acquire timeout. Callers compose by locking once at the outermost
|
|
156
|
+
* mutating boundary.
|
|
157
|
+
*
|
|
158
|
+
* @template T
|
|
159
|
+
* @param {string} path
|
|
160
|
+
* @param {() => Promise<T>|T} fn
|
|
161
|
+
* @returns {Promise<T>}
|
|
162
|
+
*/
|
|
163
|
+
export async function withDirLock(path, fn) {
|
|
164
|
+
const release = await acquireDirLock(path);
|
|
165
|
+
try {
|
|
166
|
+
return await fn();
|
|
167
|
+
} finally {
|
|
168
|
+
release();
|
|
169
|
+
}
|
|
170
|
+
}
|
package/lib/dispatch-ledger.js
CHANGED
|
@@ -37,9 +37,9 @@ export function resolveDispatchLedgerCwd(explicitCwd) {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
export const DISPATCH_SITES = Object.freeze([
|
|
40
|
-
'build-step', 'consumer', 'review', 'review-repair', '
|
|
41
|
-
'escalation', 'preflight', 'judge', 'design-chat', 'import',
|
|
42
|
-
'new-project', 'unattributed',
|
|
40
|
+
'build-step', 'consumer', 'review', 'review-repair', 'policy-revision', 'gsd',
|
|
41
|
+
'gate-qa', 'escalation', 'preflight', 'judge', 'design-chat', 'import',
|
|
42
|
+
'validation', 'new-project', 'unattributed',
|
|
43
43
|
]);
|
|
44
44
|
export const DISPATCH_OUTCOMES = Object.freeze(['ok', 'error', 'blocked']);
|
|
45
45
|
export const SETTLEMENT_FAILURE_CLASSES = Object.freeze([
|
package/lib/feature-json.js
CHANGED
|
@@ -18,7 +18,7 @@ import { resolvePathValue } from './paths-core.js';
|
|
|
18
18
|
* Identical to join(cwd, featuresDir) for plain relative dirs.
|
|
19
19
|
* @returns {string} absolute features dir
|
|
20
20
|
*/
|
|
21
|
-
function featuresBase(cwd, featuresDir) {
|
|
21
|
+
export function featuresBase(cwd, featuresDir) {
|
|
22
22
|
return resolvePathValue(cwd, featuresDir, 'features');
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -239,6 +239,14 @@ async function applyEntry(cwd, entry) {
|
|
|
239
239
|
const writer = new VisionWriter(join(cwd, '.compose', 'data'));
|
|
240
240
|
const item = await writer.findFeatureItem(entry.feature_code);
|
|
241
241
|
if (!item) return { changed: false };
|
|
242
|
+
if (entry._visStatus === 'complete') {
|
|
243
|
+
// COMP-COMPLETION-GATE slice 3 (AC-16a): repair is a projection of
|
|
244
|
+
// canonical truth, so it routes through the self-verifying seam rather
|
|
245
|
+
// than a carve-out. completeItem re-reads feature.json (and the guard
|
|
246
|
+
// ledger when a resource exists) before writing, and stamps the tier.
|
|
247
|
+
await writer.completeItem(item.id, { featureCode: entry.feature_code, cwd });
|
|
248
|
+
return { changed: true };
|
|
249
|
+
}
|
|
242
250
|
await writer.updateItemStatus(item.id, entry._visStatus);
|
|
243
251
|
return { changed: true };
|
|
244
252
|
}
|
package/lib/feature-validator.js
CHANGED
|
@@ -24,6 +24,16 @@
|
|
|
24
24
|
* - XREF_URL_UNCHECKED (info) url + reserved url-class providers
|
|
25
25
|
* (jira|linear|notion|obsidian) — recorded,
|
|
26
26
|
* not resolved
|
|
27
|
+
* Plus the 5 COMP-COVERAGE-GATE project-scope kinds, which describe Compose's
|
|
28
|
+
* OWN MCP tool surface rather than the workspace (see lib/coverage-gate.js):
|
|
29
|
+
* - MISSING_EFFECT (error) tool definition declares no `effect`
|
|
30
|
+
* - UNGATED_MUTATION (warning) mutating tool named by no profile list
|
|
31
|
+
* - ORPHAN_REGISTRY_TOOL (warning) canon entry names a tool that is gone
|
|
32
|
+
* - UNCOVERED_WRITE (info) declared canon write absent from that
|
|
33
|
+
* entry's tool list (deny-message honesty)
|
|
34
|
+
* - COVERAGE_CHECK_SKIPPED (warning) the check itself errored — never aborts
|
|
35
|
+
* These appear in `findings` AND, structured, under `result.coverage`.
|
|
36
|
+
*
|
|
27
37
|
* Full catalog + trigger/degrade/gating contract:
|
|
28
38
|
* docs/features/COMP-MCP-VALIDATE/design.md
|
|
29
39
|
*/
|
|
@@ -46,6 +56,14 @@ import { SchemaValidator } from '../server/schema-validator.js';
|
|
|
46
56
|
import {
|
|
47
57
|
resolveRoadmapPathFromConfig, resolveFeaturesPathFromConfig, resolveJournalPathFromConfig,
|
|
48
58
|
} from './project-paths.js';
|
|
59
|
+
// COMP-COVERAGE-GATE slice 2. Imported from server/mcp-tool-defs.js, NEVER from
|
|
60
|
+
// server/compose-mcp.js — the latter connects a StdioServerTransport at module
|
|
61
|
+
// load and hangs any importer.
|
|
62
|
+
import { TOOLS as MCP_TOOL_DEFS } from '../server/mcp-tool-defs.js';
|
|
63
|
+
import { loadToolInventory } from './tool-inventory.js';
|
|
64
|
+
import { canonEntries } from './canon-registry.js';
|
|
65
|
+
import { PROFILE_POLICY, PHASE_REFINEMENT } from '../server/mcp-tool-policy.js';
|
|
66
|
+
import { checkAuthorizationCoverage } from './coverage-gate.js';
|
|
49
67
|
|
|
50
68
|
const __filename = fileURLToPath(import.meta.url);
|
|
51
69
|
const __dirname = path.dirname(__filename);
|
|
@@ -1130,6 +1148,44 @@ async function runExternalRefChecks(ctx, findings, options = {}) {
|
|
|
1130
1148
|
}
|
|
1131
1149
|
}
|
|
1132
1150
|
|
|
1151
|
+
/**
|
|
1152
|
+
* COMP-COVERAGE-GATE slice 2 — authorization coverage of Compose's OWN MCP tool
|
|
1153
|
+
* surface.
|
|
1154
|
+
*
|
|
1155
|
+
* Project-scoped, not feature-scoped: it is a property of the installation, not
|
|
1156
|
+
* of any one feature, so it is deliberately absent from validateFeature.
|
|
1157
|
+
*
|
|
1158
|
+
* Pushes its findings into the main `findings` array (so the CLI exit code,
|
|
1159
|
+
* --block-on and the REST severity rollup all pick them up with no fork) AND
|
|
1160
|
+
* returns the structured section for callers that want the codes and
|
|
1161
|
+
* remediations without re-parsing prose.
|
|
1162
|
+
*
|
|
1163
|
+
* Never throws: a coverage bug must not take down a validate run that is mostly
|
|
1164
|
+
* about the workspace.
|
|
1165
|
+
*
|
|
1166
|
+
* @param {Array} findings — mutated in place
|
|
1167
|
+
* @returns {{ findings: Array }}
|
|
1168
|
+
*/
|
|
1169
|
+
function runCoverageCheck(findings) {
|
|
1170
|
+
try {
|
|
1171
|
+
const inventory = loadToolInventory(MCP_TOOL_DEFS);
|
|
1172
|
+
const result = checkAuthorizationCoverage({
|
|
1173
|
+
inventory,
|
|
1174
|
+
registry: canonEntries(),
|
|
1175
|
+
policy: { PROFILE_POLICY, PHASE_REFINEMENT },
|
|
1176
|
+
});
|
|
1177
|
+
for (const f of result.findings) {
|
|
1178
|
+
findings.push(finding(f.severity, f.code, null, f.remediation, 'coverage'));
|
|
1179
|
+
}
|
|
1180
|
+
return result;
|
|
1181
|
+
} catch (e) {
|
|
1182
|
+
findings.push(finding('warning', 'COVERAGE_CHECK_SKIPPED', null,
|
|
1183
|
+
`authorization coverage check skipped (unexpected error): ${e && e.message ? e.message : e}`,
|
|
1184
|
+
'coverage'));
|
|
1185
|
+
return { findings: [] };
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1133
1189
|
export async function validateProject(cwd, options = {}) {
|
|
1134
1190
|
const ctx = loadValidationContext(cwd, options);
|
|
1135
1191
|
const findings = [];
|
|
@@ -1234,5 +1290,12 @@ export async function validateProject(cwd, options = {}) {
|
|
|
1234
1290
|
// false positive. Strip them in one place (robust against new such checks) and
|
|
1235
1291
|
// record the skip as a single info finding. feature.json↔vision drift is left
|
|
1236
1292
|
// intact — it doesn't involve the roadmap.
|
|
1237
|
-
|
|
1293
|
+
const coverage = runCoverageCheck(findings);
|
|
1294
|
+
|
|
1295
|
+
return {
|
|
1296
|
+
scope: 'project',
|
|
1297
|
+
validated_at: nowIso(),
|
|
1298
|
+
findings: applyNarrativeSuppression(findings, ctx),
|
|
1299
|
+
coverage,
|
|
1300
|
+
};
|
|
1238
1301
|
}
|
package/lib/feature-writer.js
CHANGED
|
@@ -193,6 +193,38 @@ export async function addRoadmapEntry(cwd, args) {
|
|
|
193
193
|
if (!STATUSES.has(status)) {
|
|
194
194
|
throw new Error(`feature-writer: invalid status "${status}"`);
|
|
195
195
|
}
|
|
196
|
+
// COMP-COMPLETION-GATE: a feature cannot be BORN complete. This closed the
|
|
197
|
+
// single worst bypass found in the coverage audit — `compose roadmap add
|
|
198
|
+
// --status COMPLETE` (and proposeFollowup, which forwards caller status here)
|
|
199
|
+
// minted an already-finished feature in one command: no evidence, no
|
|
200
|
+
// lifecycle, no ledger entry, nothing to audit. Completion is a transition
|
|
201
|
+
// with evidence, never an initial condition.
|
|
202
|
+
//
|
|
203
|
+
// MIGRATION EXEMPTION. A migration is not minting a completion — it is
|
|
204
|
+
// transcribing one that already happened, from a ROADMAP row or an older
|
|
205
|
+
// layout into feature.json. Refusing those would make the migration tools
|
|
206
|
+
// unable to represent history that predates the gate. The exemption is
|
|
207
|
+
// explicit (callers pass a reason), narrow (creation only), and logged, so an
|
|
208
|
+
// exempt write is visible rather than silent. It is NOT a general escape
|
|
209
|
+
// hatch: ordinary callers have no reason to pass it, and `compose roadmap add`
|
|
210
|
+
// does not.
|
|
211
|
+
if (status === 'COMPLETE') {
|
|
212
|
+
if (!args._migration?.reason) {
|
|
213
|
+
const e = new Error(
|
|
214
|
+
'feature-writer: cannot create a feature with status COMPLETE. A completion must be ' +
|
|
215
|
+
'recorded through the completion gate (record_completion / `compose record-completion`), ' +
|
|
216
|
+
'which verifies the commit and test evidence and writes a guarded ledger entry. ' +
|
|
217
|
+
'Create the feature first, then complete it.',
|
|
218
|
+
);
|
|
219
|
+
e.code = 'COMPLETE_ON_CREATE_REFUSED';
|
|
220
|
+
throw e;
|
|
221
|
+
}
|
|
222
|
+
// eslint-disable-next-line no-console
|
|
223
|
+
console.warn(
|
|
224
|
+
`[feature-writer] migration exemption: creating "${args.code}" as COMPLETE ` +
|
|
225
|
+
`without a completion record — ${args._migration.reason}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
196
228
|
// COMP-ROADMAP-PLAN: minimal type validation for the plan-handshake fields.
|
|
197
229
|
if (args.profile !== undefined &&
|
|
198
230
|
(typeof args.profile !== 'object' || args.profile === null || Array.isArray(args.profile))) {
|
|
@@ -343,7 +375,7 @@ function readRoadmapBase(roadmapPath) {
|
|
|
343
375
|
// losslessness is surfaced by the validator (Task 6 / validate_project), not
|
|
344
376
|
// blocked here. Return only the small diagnostic fields; `canonical` is the full
|
|
345
377
|
// regenerated ROADMAP and must not leak into MCP writer results.
|
|
346
|
-
async function roundtripGuard(cwd, provider, mutate, { force, label }) {
|
|
378
|
+
export async function roundtripGuard(cwd, provider, mutate, { force, label }) {
|
|
347
379
|
const current = await provider.listFeatures();
|
|
348
380
|
const projected = mutate(current.map(f => ({ ...f })));
|
|
349
381
|
const roadmapPath = resolveRoadmapPath(cwd);
|
|
@@ -379,7 +411,7 @@ async function roundtripGuard(cwd, provider, mutate, { force, label }) {
|
|
|
379
411
|
// Routes through provider.appendEvent so GitHubProvider can post
|
|
380
412
|
// <!--compose-event--> comments + mirror Projects v2. LocalFileProvider
|
|
381
413
|
// delegates to feature-events.js#appendEvent producing byte-identical output.
|
|
382
|
-
async function safeAppendEvent(cwd, event) {
|
|
414
|
+
export async function safeAppendEvent(cwd, event) {
|
|
383
415
|
try {
|
|
384
416
|
const provider = await getProvider(cwd);
|
|
385
417
|
await provider.appendEvent(event.code, event);
|
|
@@ -424,6 +456,29 @@ export async function setFeatureStatus(cwd, args) {
|
|
|
424
456
|
return { code: args.code, from, to, ts: new Date().toISOString(), noop: true };
|
|
425
457
|
}
|
|
426
458
|
|
|
459
|
+
// COMP-COMPLETION-GATE slice 3 (AC-9, Decision 8): COMPLETE is not a status
|
|
460
|
+
// this writer can set. Not with `force`, not with `derived`, not with an
|
|
461
|
+
// override token — unconditionally. A completion is a transition WITH
|
|
462
|
+
// evidence (a real commit, an attested test run, a guarded ledger entry),
|
|
463
|
+
// and the one place that verifies those is the completion gate, which then
|
|
464
|
+
// performs the COMPLETE write itself through `persistFeatureRaw`. Every
|
|
465
|
+
// other caller that used to reach COMPLETE through here was a bypass:
|
|
466
|
+
// `set_feature_status` (path 5), the reconciler's derived projection (path
|
|
467
|
+
// 10), `projectFeatureStatus(phase:'complete')`, and the sibling-repo
|
|
468
|
+
// xref-push. There is no marker that lets a caller through, because any
|
|
469
|
+
// marker this module exported would be importable by the callers it exists
|
|
470
|
+
// to refuse.
|
|
471
|
+
if (to === 'COMPLETE') {
|
|
472
|
+
const e = new Error(
|
|
473
|
+
`feature-writer: refusing to set ${args.code} to COMPLETE — status flips to COMPLETE go ` +
|
|
474
|
+
`through the completion gate only (record_completion / \`compose record-completion\` / ` +
|
|
475
|
+
`the build runner at terminalization), which verifies commit + test evidence and takes ` +
|
|
476
|
+
`the guarded transition. \`force\` and \`derived\` do not apply.`,
|
|
477
|
+
);
|
|
478
|
+
e.code = 'COMPLETE_VIA_GATE_ONLY';
|
|
479
|
+
throw e;
|
|
480
|
+
}
|
|
481
|
+
|
|
427
482
|
const allowed = TRANSITIONS[from] ?? [];
|
|
428
483
|
// `derived: true` marks a lifecycle-authoritative projection (COMP-MCP-ENFORCE
|
|
429
484
|
// Slice 2, lifecycle-as-truth): the roadmap transition table is not the
|