@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
|
@@ -0,0 +1,1421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/completion-gate.js — COMP-COMPLETION-GATE (slices 1–3).
|
|
3
|
+
*
|
|
4
|
+
* The one door a feature passes through to become COMPLETE.
|
|
5
|
+
*
|
|
6
|
+
* Context: the lifecycle guard has never guarded a real feature. 321 managed
|
|
7
|
+
* features, 31 registered guard resources, zero overlap — every one of the 31
|
|
8
|
+
* is a leaked test fixture. The reason is not that registration is impossible
|
|
9
|
+
* (`guardedTransition` registers lazily); it is that the writers which actually
|
|
10
|
+
* complete features never call a guarded transition at all. See
|
|
11
|
+
* docs/features/COMP-COMPLETION-GATE/design.md §1.
|
|
12
|
+
*
|
|
13
|
+
* SLICE 3 (design.md §2.3a, Decision 8/11): after the guard applies, THIS
|
|
14
|
+
* module performs every write a completion consists of, in this order —
|
|
15
|
+
* completion record → status COMPLETE → ROADMAP regen → vision projection →
|
|
16
|
+
* events. Steps 1–2 are the durable truth and abort the completion if they
|
|
17
|
+
* fail (the write-ahead intent stays, so a retry recovers). Steps 3–5 are
|
|
18
|
+
* projections: re-drivable, so a failure is COLLECTED and reported as
|
|
19
|
+
* `{ok:true, partial:true, failures:[…]}`, never swallowed — silent best-effort
|
|
20
|
+
* is what produced the drift this feature exists to end.
|
|
21
|
+
*
|
|
22
|
+
* `setFeatureStatus` refuses COMPLETE unconditionally (AC-9), so the status
|
|
23
|
+
* write here goes through `persistFeatureRaw` — the policy-free primitive whose
|
|
24
|
+
* contract is now "callers must have passed the gate" (Decision 7; enforced by
|
|
25
|
+
* the allowlist test, test/completion-write-allowlist.test.js). No marker lets
|
|
26
|
+
* another module through: anything this file exported to identify itself could
|
|
27
|
+
* be imported by the callers it exists to refuse.
|
|
28
|
+
*
|
|
29
|
+
* The vision projection is the self-verifying seam of §2.3b
|
|
30
|
+
* (`VisionWriter.completeItem` → `server/completion-projection.js`): it
|
|
31
|
+
* re-reads feature.json and the guard ledger and refuses unless the completion
|
|
32
|
+
* is ALREADY recorded there, so it grants no authority of its own.
|
|
33
|
+
*
|
|
34
|
+
* Three things here are not obvious:
|
|
35
|
+
*
|
|
36
|
+
* 1. **Late registration, always.** No feature is registered today, and headless
|
|
37
|
+
* builds advance phases without the guard, so a mid-lifecycle guard state
|
|
38
|
+
* never exists. The gate registers at the mode's completable phase and takes
|
|
39
|
+
* ONE `→ complete` edge. Walking the full graph instead would refuse 91% of
|
|
40
|
+
* features (only 30 of 321 have design+blueprint+plan), and a gate that
|
|
41
|
+
* refuses nine of ten legitimate completions gets forced — which is how
|
|
42
|
+
* coverage reached zero in the first place. The ledger stamp records the
|
|
43
|
+
* weaker basis so it is never mistaken for lifecycle enforcement.
|
|
44
|
+
*
|
|
45
|
+
* 2. **Recovery is a write-ahead intent, not a derived id.** A derived id
|
|
46
|
+
* (`feature:sha`) identifies the RETRY, not the transition that reached the
|
|
47
|
+
* ledger: commit A applies, the process dies before the record is written, a
|
|
48
|
+
* retry on commit B finds no record for B and would be waved through as
|
|
49
|
+
* "recovery" against a ledger entry that attested A. The intent record is
|
|
50
|
+
* written before the transition and carries an `operation_id` that is also
|
|
51
|
+
* sent as a guard artifact — artifacts feed the payload digest, so two
|
|
52
|
+
* attempts are distinguishable even when both have no commit at all.
|
|
53
|
+
*
|
|
54
|
+
* 3. **The test command runs OUTSIDE the lock.** `spawnSync` blocks the event
|
|
55
|
+
* loop, and a blocked event loop cannot fire the lock's heartbeat timer, so
|
|
56
|
+
* a long test run inside the lock would get the lock declared stale and
|
|
57
|
+
* stolen from a live owner.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
61
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
62
|
+
import path from 'node:path';
|
|
63
|
+
|
|
64
|
+
import { acquireDirLock } from './dir-lock.js';
|
|
65
|
+
import { deriveConfidence, resolveEvidenceRef } from './backfill-evidence.js';
|
|
66
|
+
import {
|
|
67
|
+
completablePhaseOf, genesisOf, getMode, terminalOf, transitionsOf,
|
|
68
|
+
} from './lifecycle-modes.js';
|
|
69
|
+
import {
|
|
70
|
+
applyBackfillUpgrade,
|
|
71
|
+
ensureGuard,
|
|
72
|
+
guardedTransition,
|
|
73
|
+
guardErrorMessage,
|
|
74
|
+
guardErrorType,
|
|
75
|
+
guardTestCommand,
|
|
76
|
+
isGuardError,
|
|
77
|
+
resourceId,
|
|
78
|
+
verifyCompletionEvidence,
|
|
79
|
+
verifyCompletionEvidenceAsync,
|
|
80
|
+
} from '../server/lifecycle-guard.js';
|
|
81
|
+
import { custodyBackend } from './guard-custody.js';
|
|
82
|
+
import { insertBackfilledPhases, occurrenceKey } from '../server/lifecycle-phase-history.js';
|
|
83
|
+
|
|
84
|
+
/** Statuses with no legal outgoing transition — completing one is a policy violation. */
|
|
85
|
+
const TERMINAL_STATUSES = new Set(['KILLED', 'SUPERSEDED']);
|
|
86
|
+
|
|
87
|
+
/** The sentinel `completion-writer` stamps for a commit-less (non-git) completion. */
|
|
88
|
+
const NULL_SHA = '0'.repeat(40);
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Config
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read `capabilities.guard` from the SERVED workspace root — not the process
|
|
96
|
+
* global. A gate invoked over the CLI with `--workspace` must judge the guard by
|
|
97
|
+
* the tree it is actually completing in.
|
|
98
|
+
*/
|
|
99
|
+
export function guardEnabled(workspaceRoot) {
|
|
100
|
+
try {
|
|
101
|
+
const cfg = JSON.parse(
|
|
102
|
+
readFileSync(path.join(workspaceRoot, '.compose', 'compose.json'), 'utf-8'),
|
|
103
|
+
);
|
|
104
|
+
return cfg?.capabilities?.guard === true;
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Write-ahead intent (design.md §2.4a)
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
function intentPath(workspaceRoot, featureCode) {
|
|
115
|
+
return path.join(
|
|
116
|
+
workspaceRoot, '.compose', 'data', 'completion-intents', `${featureCode}.json`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function readIntent(workspaceRoot, featureCode) {
|
|
121
|
+
const p = intentPath(workspaceRoot, featureCode);
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
124
|
+
} catch {
|
|
125
|
+
// Missing is the common case; malformed is treated the same way — an intent
|
|
126
|
+
// we cannot read cannot authorize a recovery, and §2.4a's "no intent + guard
|
|
127
|
+
// complete" row already fails closed.
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function writeIntent(workspaceRoot, featureCode, record) {
|
|
133
|
+
const p = intentPath(workspaceRoot, featureCode);
|
|
134
|
+
mkdirSync(path.dirname(p), { recursive: true });
|
|
135
|
+
const tmp = `${p}.tmp`;
|
|
136
|
+
writeFileSync(tmp, JSON.stringify(record, null, 2));
|
|
137
|
+
// Rename so a crash mid-write never leaves a half-parsed intent behind.
|
|
138
|
+
writeFileSync(p, readFileSync(tmp));
|
|
139
|
+
rmSync(tmp, { force: true });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function clearIntent(workspaceRoot, featureCode) {
|
|
143
|
+
try { rmSync(intentPath(workspaceRoot, featureCode), { force: true }); } catch { /* best-effort */ }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Guard state
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
let _history = null;
|
|
151
|
+
/** @internal test seam */
|
|
152
|
+
export function _testOnly_setHistoryClient(fn) { _history = fn; }
|
|
153
|
+
/** @internal test seam */
|
|
154
|
+
export function _testOnly_resetHistoryClient() { _history = null; }
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The guard's current state for this resource, or null when it has never been
|
|
158
|
+
* registered. Distinguishes "not found" (legacy/unregistered — the normal case)
|
|
159
|
+
* from every other failure, which must fail CLOSED: an unreachable guard that
|
|
160
|
+
* silently reads as "unregistered" would be a bypass wearing the legacy rule as
|
|
161
|
+
* a disguise.
|
|
162
|
+
*
|
|
163
|
+
* @returns {Promise<{state: string|null, error?: object}>}
|
|
164
|
+
*/
|
|
165
|
+
export async function currentGuardState(rid) {
|
|
166
|
+
const client = _history || (await import('../server/stratum-client.js')).guardHistory;
|
|
167
|
+
let res;
|
|
168
|
+
try {
|
|
169
|
+
res = await client(rid);
|
|
170
|
+
} catch (e) {
|
|
171
|
+
return { state: null, error: { code: 'GUARD_UNREACHABLE', message: e.message } };
|
|
172
|
+
}
|
|
173
|
+
if (res && (res.error || res.status === 'error')) {
|
|
174
|
+
const err = res.error || res;
|
|
175
|
+
// `error_type` is the field the REAL client returns (server/stratum-client.js
|
|
176
|
+
// parses stratum's canonical `{status:'error', error_type, message}`); `code`
|
|
177
|
+
// and `kind` are the shapes injected by _testOnly_setHistoryClient. Reading
|
|
178
|
+
// only the latter two made the not-found branch below DEAD against the real
|
|
179
|
+
// producer: every unregistered feature refused with "guard unreachable",
|
|
180
|
+
// which is every feature that never ran a lifecycle. Found 2026-08-24 trying
|
|
181
|
+
// to complete COMP-COVERAGE-GATE; see the real-shape test in
|
|
182
|
+
// test/completion-gate.test.js.
|
|
183
|
+
const code = String(err.code || err.kind || err.error_type || '');
|
|
184
|
+
// Never registered — the expected state for every feature today. The
|
|
185
|
+
// message fallback applies ONLY when no code was returned at all: a SPAWN
|
|
186
|
+
// failure whose message happens to say "stratum-mcp: command not found"
|
|
187
|
+
// must not read as a legacy feature (found by the slice-3 fail-closed test).
|
|
188
|
+
if (/not_found|NOT_FOUND|GuardNotFound/.test(code)
|
|
189
|
+
|| (!code && /not found|no guard registered/i.test(err.message || ''))) {
|
|
190
|
+
return { state: null };
|
|
191
|
+
}
|
|
192
|
+
return { state: null, error: err };
|
|
193
|
+
}
|
|
194
|
+
return { state: res?.current_state ?? null };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// The gate
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Verify a feature may complete, record it through the guard, and write it.
|
|
203
|
+
*
|
|
204
|
+
* FAIL-CLOSED throughout: evidence that does not verify, a guard that refuses,
|
|
205
|
+
* and a guard that cannot be reached all return `{ok:false}` with nothing
|
|
206
|
+
* written. Guard *disabled* is different from guard *unreachable* — the first
|
|
207
|
+
* still runs the evidence check (it is compose-local and needs no stratum), the
|
|
208
|
+
* second refuses.
|
|
209
|
+
*
|
|
210
|
+
* @param {object} a
|
|
211
|
+
* @param {string} a.featureCode
|
|
212
|
+
* @param {string} [a.commitSha] omitted ⇒ commit-less (non-git) completion
|
|
213
|
+
* @param {boolean} [a.testsPass] explicit attestation; ignored when a testCommand attests
|
|
214
|
+
* @param {string[]} [a.filesChanged]
|
|
215
|
+
* @param {string} [a.notes]
|
|
216
|
+
* @param {boolean} [a.force]
|
|
217
|
+
* @param {string} [a.builtVia] preserved — the build-quick validator exemption reads it
|
|
218
|
+
* @param {string} [a.idempotencyKey]
|
|
219
|
+
* @param {string} a.workspaceRoot provider, capabilities, guard resourceId, feature.json
|
|
220
|
+
* @param {string} [a.evidenceRoot] git + tests; defaults to workspaceRoot (single-repo case)
|
|
221
|
+
* @param {string} [a.mode]
|
|
222
|
+
* @param {'complete'|'evidence-only'} [a.intent]
|
|
223
|
+
* @param {string} [a.visionItemId] the item to project; resolved via findFeatureItem when absent
|
|
224
|
+
* @param {Function} [a.visionProjector] transport override for §2.3a step 4 — the server passes an
|
|
225
|
+
* in-process projector against its live store; default is
|
|
226
|
+
* VisionWriter.completeItem (REST when a server is up, direct
|
|
227
|
+
* file write otherwise)
|
|
228
|
+
* @returns {Promise<object>}
|
|
229
|
+
*/
|
|
230
|
+
export async function completionGate({
|
|
231
|
+
featureCode,
|
|
232
|
+
commitSha,
|
|
233
|
+
testsPass,
|
|
234
|
+
filesChanged = [],
|
|
235
|
+
notes,
|
|
236
|
+
force,
|
|
237
|
+
builtVia,
|
|
238
|
+
idempotencyKey,
|
|
239
|
+
workspaceRoot,
|
|
240
|
+
evidenceRoot,
|
|
241
|
+
mode = 'build',
|
|
242
|
+
intent = 'complete',
|
|
243
|
+
visionItemId,
|
|
244
|
+
visionProjector,
|
|
245
|
+
// --- COMP-LIFECYCLE-BACKFILL (intent:'backfill' only) --------------------
|
|
246
|
+
reason,
|
|
247
|
+
occurrences,
|
|
248
|
+
item,
|
|
249
|
+
store,
|
|
250
|
+
actor,
|
|
251
|
+
}) {
|
|
252
|
+
if (!featureCode) throw new Error('completion-gate: featureCode is required');
|
|
253
|
+
if (!workspaceRoot) throw new Error('completion-gate: workspaceRoot is required');
|
|
254
|
+
|
|
255
|
+
// Backfill is a PARAMETERISATION of this gate, not a sibling door (§5). It
|
|
256
|
+
// shares the lock, the intent file, the write sequence and the refusal
|
|
257
|
+
// vocabulary; what differs is that its evidence is historical, so it runs the
|
|
258
|
+
// ASYNC evidence runner INSIDE the lock (§5.2) and merges by valid time.
|
|
259
|
+
if (intent === 'backfill') {
|
|
260
|
+
return backfillGate({
|
|
261
|
+
featureCode, commitSha, testsPass, filesChanged, notes, reason, occurrences,
|
|
262
|
+
workspaceRoot, evidenceRoot, mode, item, store, visionItemId, visionProjector,
|
|
263
|
+
actor: actor || 'agent:rest',
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Two roots. They are equal in the single-repo case, but a cross-repo build
|
|
268
|
+
// runs git and tests in the agent's tree while feature metadata lives in the
|
|
269
|
+
// project tree — verifying the wrong repo's HEAD is silent and wrong.
|
|
270
|
+
const evRoot = evidenceRoot || workspaceRoot;
|
|
271
|
+
const guarded = guardEnabled(workspaceRoot);
|
|
272
|
+
const reasons = [];
|
|
273
|
+
|
|
274
|
+
// --- 1. Evidence, BEFORE the lock (note 3 in the header) -----------------
|
|
275
|
+
//
|
|
276
|
+
// Scoped to `capabilities.guard`, matching the contract `assertCompletionEvidence`
|
|
277
|
+
// already established (server/compose-mcp-tools.js:46). An earlier draft of this
|
|
278
|
+
// design (AC-5) had the evidence check run even with the guard off, on the
|
|
279
|
+
// reasoning that a fabricated SHA is worthless either way. That is true, but
|
|
280
|
+
// `capabilities.guard: false` is a deliberate opt-OUT, and enforcing evidence
|
|
281
|
+
// against a project that opted out is a breaking change for every such project —
|
|
282
|
+
// including non-git workspaces, where the check can never pass at all. Respecting
|
|
283
|
+
// the flag keeps slice 1 non-breaking; strengthening it is its own decision, not
|
|
284
|
+
// a side effect of adding the gate.
|
|
285
|
+
// Guard off: pass the caller's claim through untouched so `recordCompletion`'s
|
|
286
|
+
// own validation still applies (it requires a strict boolean). Coercing an
|
|
287
|
+
// omitted value to `false` here would quietly rewrite the record.
|
|
288
|
+
let attestedTestsPass = testsPass;
|
|
289
|
+
if (guarded) {
|
|
290
|
+
const ev = await verifyCompletionEvidence({
|
|
291
|
+
commitSha,
|
|
292
|
+
cwd: evRoot,
|
|
293
|
+
testCommand: guardTestCommand(workspaceRoot),
|
|
294
|
+
testsPassClaim: testsPass,
|
|
295
|
+
});
|
|
296
|
+
if (!ev.ok) {
|
|
297
|
+
return { ok: false, guarded, refusedAt: 'evidence', reasons: ev.reasons };
|
|
298
|
+
}
|
|
299
|
+
// A configured test command that exited 0 outranks any caller claim; without
|
|
300
|
+
// one, only an explicit `true` counts. There is no silent default.
|
|
301
|
+
attestedTestsPass = ev.testsAttested ? true : testsPass === true;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Evidence-only callers (record_completion with set_status:false) are not
|
|
305
|
+
// completing anything, so they must not drive the guard to a terminal state.
|
|
306
|
+
if (intent === 'evidence-only') {
|
|
307
|
+
return { ok: true, guarded, evidenceOnly: true, attestedTestsPass, reasons: [] };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const rid = resourceId(featureCode, workspaceRoot, mode);
|
|
311
|
+
const lockDir = path.join(workspaceRoot, '.compose', 'data', 'locks', `completion-${featureCode}`);
|
|
312
|
+
const release = await acquireDirLock(lockDir);
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
// --- 2. Preflight ------------------------------------------------------
|
|
316
|
+
// Lazy import: feature-writer ↔ completion-writer ↔ this module form a cycle
|
|
317
|
+
// at load time, and completion-writer already dodges it the same way.
|
|
318
|
+
const { getProvider } = await import('./feature-writer.js');
|
|
319
|
+
const provider = await getProvider(workspaceRoot);
|
|
320
|
+
const feature = await provider.getFeature(featureCode);
|
|
321
|
+
if (!feature) {
|
|
322
|
+
return { ok: false, guarded, refusedAt: 'preflight', reasons: [`feature "${featureCode}" not found`] };
|
|
323
|
+
}
|
|
324
|
+
// Terminal-status legality. The gate writes through a policy-free path, so
|
|
325
|
+
// the policy `setFeatureStatus` enforces (KILLED/SUPERSEDED are terminal)
|
|
326
|
+
// has to be re-asserted here or a killed feature could be completed.
|
|
327
|
+
if (TERMINAL_STATUSES.has(feature.status)) {
|
|
328
|
+
return {
|
|
329
|
+
ok: false, guarded, refusedAt: 'preflight',
|
|
330
|
+
reasons: [`feature "${featureCode}" is ${feature.status} — a terminal status has no legal completion`],
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// --- 3. Guard state + recovery decision (§2.4a) ------------------------
|
|
335
|
+
const priorIntent = readIntent(workspaceRoot, featureCode);
|
|
336
|
+
let guardState = null;
|
|
337
|
+
if (guarded) {
|
|
338
|
+
const g = await currentGuardState(rid);
|
|
339
|
+
if (g.error) {
|
|
340
|
+
// Configured but unreachable ⇒ refuse. Never degrade to the disabled path.
|
|
341
|
+
return { ok: false, guarded, refusedAt: 'guard', reasons: ['guard unreachable'], error: g.error };
|
|
342
|
+
}
|
|
343
|
+
guardState = g.state;
|
|
344
|
+
}
|
|
345
|
+
const guardComplete = guardState === 'complete';
|
|
346
|
+
|
|
347
|
+
let operationId = randomUUID();
|
|
348
|
+
let recovering = false;
|
|
349
|
+
|
|
350
|
+
if (guardComplete) {
|
|
351
|
+
if (!priorIntent) {
|
|
352
|
+
// The ledger says complete and nothing records why. Not ours to repair.
|
|
353
|
+
return {
|
|
354
|
+
ok: false, guarded, refusedAt: 'recovery',
|
|
355
|
+
reasons: [
|
|
356
|
+
`guard for "${featureCode}" is already complete with no completion intent on record — ` +
|
|
357
|
+
`a prior completion cannot be automatically resumed; operator action required`,
|
|
358
|
+
],
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
recovering = true;
|
|
362
|
+
operationId = priorIntent.operation_id;
|
|
363
|
+
// Same operation ⇒ re-drive the writes. Different ⇒ the ledger attested
|
|
364
|
+
// something else and this is a second completion, not a retry.
|
|
365
|
+
const sameCommit = (priorIntent.commit_sha || null) === (commitSha || null);
|
|
366
|
+
if (!sameCommit) {
|
|
367
|
+
return {
|
|
368
|
+
ok: false, guarded, refusedAt: 'recovery',
|
|
369
|
+
reasons: [
|
|
370
|
+
`guard for "${featureCode}" already completed operation ${priorIntent.operation_id} ` +
|
|
371
|
+
`against commit ${priorIntent.commit_sha || '(none)'}, but this attempt carries ` +
|
|
372
|
+
`${commitSha || '(none)'} — refusing to complete twice against different evidence`,
|
|
373
|
+
],
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
} else if (priorIntent) {
|
|
377
|
+
// Intent with no applied transition: a crash BEFORE the guard. Stale.
|
|
378
|
+
clearIntent(workspaceRoot, featureCode);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// --- 4. Write-ahead intent --------------------------------------------
|
|
382
|
+
if (!recovering) {
|
|
383
|
+
writeIntent(workspaceRoot, featureCode, {
|
|
384
|
+
operation_id: operationId,
|
|
385
|
+
feature_code: featureCode,
|
|
386
|
+
commit_sha: commitSha || null,
|
|
387
|
+
tests_attested: attestedTestsPass,
|
|
388
|
+
started_at: new Date().toISOString(),
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// --- 5. The guarded transition ----------------------------------------
|
|
393
|
+
let ledgerRef;
|
|
394
|
+
if (guarded && !recovering) {
|
|
395
|
+
const completable = completablePhaseOf(mode);
|
|
396
|
+
const reg = await ensureGuard(featureCode, completable, workspaceRoot, mode);
|
|
397
|
+
if (reg && (reg.error || reg.status === 'error')) {
|
|
398
|
+
clearIntent(workspaceRoot, featureCode);
|
|
399
|
+
return { ok: false, guarded, refusedAt: 'guard', reasons: ['guard registration failed'], error: reg.error || reg };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// The stamp records what this actually is. Late registration attests the
|
|
403
|
+
// evidence present at completion, never lifecycle history — nothing here
|
|
404
|
+
// knows whether a design doc existed when the work was done.
|
|
405
|
+
// Stratum >= 0.4.0 validates resolved_by strictly as "agent" | "human"
|
|
406
|
+
// (guard/transition.ts) — a tagged resolver like "agent:late-registration"
|
|
407
|
+
// is refused with evidence_parse_error, which is how every completion
|
|
408
|
+
// failed silently after the upgrade. The tags therefore ride in
|
|
409
|
+
// `artifacts`, where they still land in the ledger's payload digest.
|
|
410
|
+
const tags = ['late-registration'];
|
|
411
|
+
if (!commitSha || commitSha === NULL_SHA) tags.push('no-repo-exemption');
|
|
412
|
+
const resolvedBy = 'agent';
|
|
413
|
+
|
|
414
|
+
const g = await guardedTransition({
|
|
415
|
+
featureCode,
|
|
416
|
+
from: completable,
|
|
417
|
+
to: 'complete',
|
|
418
|
+
workspaceRoot,
|
|
419
|
+
commitSha,
|
|
420
|
+
resolvedBy,
|
|
421
|
+
mode,
|
|
422
|
+
// operation_id rides in the artifacts so it lands in the payload digest —
|
|
423
|
+
// without it two commit-less completions are indistinguishable in the ledger.
|
|
424
|
+
artifacts: { operation_id: operationId, resolver_tags: tags.join('+') },
|
|
425
|
+
});
|
|
426
|
+
if (!g.applied) {
|
|
427
|
+
clearIntent(workspaceRoot, featureCode);
|
|
428
|
+
return {
|
|
429
|
+
ok: false, guarded, refusedAt: 'guard',
|
|
430
|
+
reasons: [
|
|
431
|
+
(g.refused ? 'completion refused by guard' : 'guard transition failed')
|
|
432
|
+
+ (g.error?.message ? `: ${g.error.message}` : ''),
|
|
433
|
+
],
|
|
434
|
+
verdict: g.verdict, error: g.error,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
ledgerRef = g.ledgerRef;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// --- 6. The write sequence (§2.3a) — the gate is the single COMPLETE writer
|
|
441
|
+
const { recordCompletion } = await import('./completion-writer.js');
|
|
442
|
+
const { isLocalProvider, roundtripGuard, safeAppendEvent } = await import('./feature-writer.js');
|
|
443
|
+
const failures = [];
|
|
444
|
+
|
|
445
|
+
// 6.1 completion record. `set_status:false` is the record-only path; the
|
|
446
|
+
// writer's completing path is the one that calls THIS function.
|
|
447
|
+
let rec;
|
|
448
|
+
try {
|
|
449
|
+
rec = await recordCompletion(workspaceRoot, {
|
|
450
|
+
feature_code: featureCode,
|
|
451
|
+
...(commitSha ? { commit_sha: commitSha } : {}),
|
|
452
|
+
tests_pass: attestedTestsPass,
|
|
453
|
+
files_changed: filesChanged,
|
|
454
|
+
...(notes ? { notes } : {}),
|
|
455
|
+
...(force ? { force } : {}),
|
|
456
|
+
...(builtVia ? { built_via: builtVia } : {}),
|
|
457
|
+
...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
|
|
458
|
+
set_status: false,
|
|
459
|
+
});
|
|
460
|
+
} catch (e) {
|
|
461
|
+
// The guard has applied and nothing durable is written: keep the intent so
|
|
462
|
+
// a retry on the same evidence recovers (§2.4a) instead of completing twice.
|
|
463
|
+
return {
|
|
464
|
+
ok: false, guarded, refusedAt: 'write', operationId, ledgerRef, error: e,
|
|
465
|
+
reasons: [`completion record could not be written: ${e.message}`],
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// 6.2 status → COMPLETE, raw — through the single authorized writer.
|
|
470
|
+
//
|
|
471
|
+
// The re-read and the preparation stay OUTSIDE the catch, exactly where they
|
|
472
|
+
// were before the shared helper existed: a rejected read or a `null` feature
|
|
473
|
+
// is a broken precondition, not a failed write, and it must keep throwing.
|
|
474
|
+
const fresh = await provider.getFeature(featureCode);
|
|
475
|
+
const prepared = prepareCompleteStatus(fresh, commitSha);
|
|
476
|
+
let statusChanged = null;
|
|
477
|
+
if (prepared) {
|
|
478
|
+
try {
|
|
479
|
+
await persistCompleteStatus({
|
|
480
|
+
provider, workspaceRoot, featureCode, updated: prepared.updated,
|
|
481
|
+
});
|
|
482
|
+
} catch (e) {
|
|
483
|
+
return {
|
|
484
|
+
ok: false, guarded, refusedAt: 'write', operationId, ledgerRef, error: e,
|
|
485
|
+
reasons: [`completion recorded but status could not be set: ${e.message}`],
|
|
486
|
+
result: rec,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
statusChanged = { from: prepared.from, to: 'COMPLETE' };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// 6.3 ROADMAP regen — projection; collect on failure.
|
|
493
|
+
try {
|
|
494
|
+
await provider.renderRoadmap();
|
|
495
|
+
} catch (e) {
|
|
496
|
+
failures.push({ step: 'roadmap', message: e.message, recover: 'compose roadmap generate' });
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// 6.4 vision projection — projection; collect on failure.
|
|
500
|
+
let visionProjection = null;
|
|
501
|
+
try {
|
|
502
|
+
const project = visionProjector || defaultVisionProjector;
|
|
503
|
+
visionProjection = await project({
|
|
504
|
+
workspaceRoot, featureCode, visionItemId, commitSha, ledgerRef, mode,
|
|
505
|
+
});
|
|
506
|
+
} catch (e) {
|
|
507
|
+
failures.push({ step: 'vision', message: e.message, recover: 'compose validate --fix' });
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// 6.5 events — never fails a completion (safeAppendEvent swallows and warns).
|
|
511
|
+
if (statusChanged) {
|
|
512
|
+
await safeAppendEvent(workspaceRoot, {
|
|
513
|
+
tool: 'set_feature_status',
|
|
514
|
+
code: featureCode,
|
|
515
|
+
from: statusChanged.from,
|
|
516
|
+
to: 'COMPLETE',
|
|
517
|
+
reason: 'completion_gate',
|
|
518
|
+
via: 'completion_gate',
|
|
519
|
+
operation_id: operationId,
|
|
520
|
+
...(commitSha ? { commit_sha: commitSha } : {}),
|
|
521
|
+
...(ledgerRef ? { ledger_ref: ledgerRef } : {}),
|
|
522
|
+
...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
clearIntent(workspaceRoot, featureCode);
|
|
527
|
+
return {
|
|
528
|
+
ok: true, guarded, recovered: recovering, operationId, ledgerRef,
|
|
529
|
+
attestedTestsPass, reasons,
|
|
530
|
+
partial: failures.length > 0, failures, visionProjection,
|
|
531
|
+
// The writer-shaped result every existing caller (MCP tool, CLI, cockpit)
|
|
532
|
+
// returns as-is, so it must carry the whole partial story — not just the
|
|
533
|
+
// ROADMAP half (Codex r1 #3): a vision projection that failed is a
|
|
534
|
+
// completion the cockpit does not show.
|
|
535
|
+
result: {
|
|
536
|
+
...rec,
|
|
537
|
+
status_changed: statusChanged,
|
|
538
|
+
status_flip_partial: failures.length > 0,
|
|
539
|
+
partial: failures.length > 0,
|
|
540
|
+
failures,
|
|
541
|
+
},
|
|
542
|
+
};
|
|
543
|
+
} finally {
|
|
544
|
+
release();
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
// Default vision projector (§2.3a step 4) — VisionWriter.completeItem, which is
|
|
550
|
+
// REST when a server is up and a direct verified file write otherwise.
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
async function defaultVisionProjector({
|
|
554
|
+
workspaceRoot, featureCode, visionItemId, commitSha, ledgerRef, mode, store, guarded,
|
|
555
|
+
}) {
|
|
556
|
+
const { VisionWriter } = await import('./vision-writer.js');
|
|
557
|
+
const writer = new VisionWriter(path.join(workspaceRoot, '.compose', 'data'));
|
|
558
|
+
const itemId = visionItemId || (await writer.findFeatureItem(featureCode))?.id;
|
|
559
|
+
// No item is not a failure: paths 1–2 (record_completion) have never carried
|
|
560
|
+
// an item id, and a feature with no cockpit item has nothing to project.
|
|
561
|
+
if (!itemId) return { skipped: true, reason: 'no vision item for feature' };
|
|
562
|
+
|
|
563
|
+
// A caller that owns a LIVE store projects through it (Codex r1 #2/#4).
|
|
564
|
+
//
|
|
565
|
+
// Two defects made this necessary, and both were invisible from the outside.
|
|
566
|
+
// First, with no server up `VisionWriter` falls through to a DIRECT write
|
|
567
|
+
// against a separately loaded disk snapshot; the caller's in-memory item never
|
|
568
|
+
// learns it was completed, and finalization then serializes that stale item
|
|
569
|
+
// straight over the successful projection — the operation clears its intent
|
|
570
|
+
// with the item still reading `in_progress` and carrying no stamp. Second, the
|
|
571
|
+
// writer verifies against LIVE config, so the operation's persisted guard flag
|
|
572
|
+
// never reached the verifier at all; only a hand-wired test callback made
|
|
573
|
+
// §5.10a look wired. Both are fixed by projecting through the store the caller
|
|
574
|
+
// already handed us, with the flag threaded to the projector AND the verifier.
|
|
575
|
+
if (store) {
|
|
576
|
+
const { applyVerifiedProjection } = await import('../server/completion-projection.js');
|
|
577
|
+
return applyVerifiedProjection(store, {
|
|
578
|
+
itemId, featureCode, cwd: workspaceRoot,
|
|
579
|
+
// `?? true` keeps every caller that sends no flag byte-identical.
|
|
580
|
+
consultGuard: guarded ?? true,
|
|
581
|
+
guardEnabledOverride: guarded,
|
|
582
|
+
evidence: { commitSha, ledgerRef, source: 'completion-gate' },
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
return writer.completeItem(itemId, { featureCode, cwd: workspaceRoot, commitSha, ledgerRef, mode });
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* §2.3a step 2 / §5.10 step 6.2 — the ONE authorized COMPLETE status write, split
|
|
590
|
+
* into PREPARE and PERSIST because the two sit on opposite sides of an error
|
|
591
|
+
* boundary the callers must keep.
|
|
592
|
+
*
|
|
593
|
+
* Both doors share these rather than carrying two copies of the same five lines.
|
|
594
|
+
* That is not tidiness: the AC-19 allowlist scan asserts there is exactly ONE
|
|
595
|
+
* `status: 'COMPLETE'` callsite in the repo and that it is the gate, so a second
|
|
596
|
+
* copy would either fail that test or force it to be weakened into a per-FILE
|
|
597
|
+
* check.
|
|
598
|
+
*
|
|
599
|
+
* The SPLIT is load-bearing (Codex r2 #2). The live path's re-read and status
|
|
600
|
+
* preparation have always sat OUTSIDE the persistence `try`: a provider read
|
|
601
|
+
* that rejects, or a `null` feature where one was just written, is a broken
|
|
602
|
+
* precondition and throws — `/lifecycle/complete` answers 400 and
|
|
603
|
+
* `record_completion` propagates the original error. Folding the re-read into
|
|
604
|
+
* the helper quietly moved both under the catch and turned them into
|
|
605
|
+
* `refusedAt:'write'` (422, and `COMPLETION_GATE_REFUSED` in place of the real
|
|
606
|
+
* exception). `prepareCompleteStatus` is therefore pure and callers invoke it
|
|
607
|
+
* before entering their own `try`; only `persistCompleteStatus` may throw a
|
|
608
|
+
* write failure.
|
|
609
|
+
*/
|
|
610
|
+
function prepareCompleteStatus(fresh, commitSha) {
|
|
611
|
+
if (fresh.status === 'COMPLETE') return null;
|
|
612
|
+
const updated = { ...fresh, status: 'COMPLETE' };
|
|
613
|
+
if (commitSha) updated.commit_sha = commitSha;
|
|
614
|
+
return { updated, from: fresh.status };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function persistCompleteStatus({ provider, workspaceRoot, featureCode, updated }) {
|
|
618
|
+
const { isLocalProvider, roundtripGuard } = await import('./feature-writer.js');
|
|
619
|
+
if (isLocalProvider(provider)) {
|
|
620
|
+
// The same prose-loss fixed-point check `setFeatureStatus` runs. It is a
|
|
621
|
+
// preflight against the ROADMAP, not a write; a refusal here aborts the
|
|
622
|
+
// status flip with the record present and the intent kept — recoverable.
|
|
623
|
+
await roundtripGuard(workspaceRoot, provider,
|
|
624
|
+
(feats) => feats.map((f) => (f.code === featureCode ? updated : f)),
|
|
625
|
+
{ force: false, label: 'completion_gate' });
|
|
626
|
+
}
|
|
627
|
+
await provider.persistFeatureRaw(featureCode, updated);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
// ===========================================================================
|
|
633
|
+
// COMP-LIFECYCLE-BACKFILL — completionGate({intent:'backfill'}) (blueprint §5)
|
|
634
|
+
//
|
|
635
|
+
// The shape of this function is the design. Five rules explain every ordering
|
|
636
|
+
// decision in it, and each one exists because getting it wrong is silent:
|
|
637
|
+
//
|
|
638
|
+
// 1. NOTHING DURABLE IS WRITTEN UNTIL THE HISTORY VALIDATES (BP-4/R2B-8).
|
|
639
|
+
// Registration creates a guard resource and a ledger on disk. Registering
|
|
640
|
+
// first and validating second leaves a permanent registration behind for a
|
|
641
|
+
// request that was then refused. So: decide the initial state, validate
|
|
642
|
+
// evidence and history, and only then register, upgrade and transition.
|
|
643
|
+
//
|
|
644
|
+
// 2. THE INTENT IS THE COMPLETE WRITE PLAN, NOT A BREADCRUMB (BP-3/R2B-3).
|
|
645
|
+
// A resumed attempt recomputes NOTHING. Every value §5.10 writes comes out
|
|
646
|
+
// of `writeContext`, and `writeContext` is either built fresh (§5.7) or
|
|
647
|
+
// restored wholesale from the intent (§5.4a). If a value is not in
|
|
648
|
+
// `writeContext`, the write sequence cannot see it — which is the only
|
|
649
|
+
// structural way to stop a resume from writing different bytes than the
|
|
650
|
+
// crash intended.
|
|
651
|
+
//
|
|
652
|
+
// 3. TIMESTAMPS ARE MINTED ONCE (BP-6). `startedAt` and every timestamp inside
|
|
653
|
+
// the occurrences are stamped on the first attempt and persisted. A resume
|
|
654
|
+
// stamps only `finalizedAt`, because that one really is now.
|
|
655
|
+
//
|
|
656
|
+
// 4. RECOVERY GOES STRAIGHT TO THE TRANSPORT (R2B-2), carrying the PERSISTED
|
|
657
|
+
// policy checksum (R3-2/R4-1). `guardedTransition` calls `ensureGuard`,
|
|
658
|
+
// which fails closed when the stored policy has moved — fatal on exactly
|
|
659
|
+
// the recovery it would otherwise have completed. And the checksum must be
|
|
660
|
+
// sent, or a crash BEFORE the transition would later apply under whatever
|
|
661
|
+
// policy is current, moving the guard to a terminal state the plan was
|
|
662
|
+
// never validated against.
|
|
663
|
+
//
|
|
664
|
+
// 5. EVERY RAW-TRANSPORT RESULT GOES THROUGH isGuardError (R4-2). The raw
|
|
665
|
+
// verbs return stratum's canonical `{status:'error', error_type, message}`
|
|
666
|
+
// unchanged; a check on `.error` alone misses every stratum-side refusal,
|
|
667
|
+
// `policy_checksum_mismatch` included, and falls through to the success
|
|
668
|
+
// branch.
|
|
669
|
+
// ===========================================================================
|
|
670
|
+
|
|
671
|
+
const BACKFILL_TERMINAL = 'complete_backfilled';
|
|
672
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
673
|
+
|
|
674
|
+
/** Every `writeContext` key §5.10 may read. A missing one is a corrupt intent. */
|
|
675
|
+
const WRITE_CONTEXT_FIELDS = [
|
|
676
|
+
'operationId', 'requestDigest', 'featureCode', 'mode', 'reason', 'commitSha',
|
|
677
|
+
'filesChanged', 'notes', 'attested', 'startedAt', 'occurrences', 'terminalOcc',
|
|
678
|
+
'history', 'writtenKeys', 'skippedKeys', 'guardInitial', 'upgrade', 'guarded',
|
|
679
|
+
'policyChecksum', 'envelope',
|
|
680
|
+
];
|
|
681
|
+
|
|
682
|
+
const refusal = (refusedAt, reasons, extra = {}) => ({
|
|
683
|
+
ok: false, refusedAt, reasons: Array.isArray(reasons) ? reasons : [reasons], ...extra,
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
const deepClone = (x) => JSON.parse(JSON.stringify(x));
|
|
687
|
+
|
|
688
|
+
/** Stable key order, so the digest is a function of the CLAIM and not of typing order. */
|
|
689
|
+
function canonicalJson(value) {
|
|
690
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
691
|
+
if (value && typeof value === 'object') {
|
|
692
|
+
return `{${Object.keys(value).sort()
|
|
693
|
+
.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
|
|
694
|
+
}
|
|
695
|
+
return JSON.stringify(value ?? null);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* §5.1 — canonicalise the request and digest it BEFORE anything else.
|
|
700
|
+
*
|
|
701
|
+
* `notes` is excluded: prose that does not change what is claimed. `observedTime`
|
|
702
|
+
* is excluded because it is server-derived — it is instead part of the
|
|
703
|
+
* per-occurrence claim inside `insertBackfilledPhases`.
|
|
704
|
+
*/
|
|
705
|
+
export function backfillRequestDigest({
|
|
706
|
+
featureCode, commitSha, testsPass, mode, filesChanged, reason, occurrences,
|
|
707
|
+
}) {
|
|
708
|
+
const canonical = {
|
|
709
|
+
feature_code: featureCode,
|
|
710
|
+
commit_sha: commitSha ?? null,
|
|
711
|
+
tests_pass: testsPass ?? null,
|
|
712
|
+
mode,
|
|
713
|
+
files_changed: [...new Set(filesChanged ?? [])].sort(),
|
|
714
|
+
reason: (reason ?? '').trim(),
|
|
715
|
+
occurrences: [...(occurrences ?? [])]
|
|
716
|
+
.map((o) => ({ phase: o.phase, kind: o.evidence?.kind ?? null, ref: o.evidence?.ref ?? null }))
|
|
717
|
+
.sort((a, b) => `${a.phase}\u001f${a.ref}`.localeCompare(`${b.phase}\u001f${b.ref}`)),
|
|
718
|
+
};
|
|
719
|
+
return createHash('sha256').update(canonicalJson(canonical)).digest('hex');
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function inGraph(graph, phase) {
|
|
723
|
+
if (Object.hasOwn(graph, phase)) return true;
|
|
724
|
+
return Object.values(graph).some((tos) => (tos || []).includes(phase));
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** The raw guard transport. Imported lazily so the module graph stays acyclic. */
|
|
728
|
+
async function rawGuard() {
|
|
729
|
+
return import('../server/stratum-client.js');
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// ---------------------------------------------------------------------------
|
|
733
|
+
|
|
734
|
+
async function backfillGate({
|
|
735
|
+
featureCode, commitSha, testsPass, filesChanged = [], notes, reason, occurrences = [],
|
|
736
|
+
workspaceRoot, evidenceRoot, mode = 'build', item, store, visionItemId, visionProjector, actor,
|
|
737
|
+
}) {
|
|
738
|
+
const evRoot = evidenceRoot || workspaceRoot;
|
|
739
|
+
const guarded = guardEnabled(workspaceRoot);
|
|
740
|
+
|
|
741
|
+
// --- §5.1 request shape, then the digest, BEFORE anything else -----------
|
|
742
|
+
if (typeof reason !== 'string' || reason.trim().length === 0) {
|
|
743
|
+
return refusal('request', 'a backfill requires a non-empty reason', { guarded });
|
|
744
|
+
}
|
|
745
|
+
if (typeof commitSha !== 'string' || commitSha.trim().length === 0) {
|
|
746
|
+
return refusal('request', 'a backfill requires commit_sha', { guarded });
|
|
747
|
+
}
|
|
748
|
+
const requestDigest = backfillRequestDigest({
|
|
749
|
+
featureCode, commitSha, testsPass, mode, filesChanged, reason, occurrences,
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
const rid = resourceId(featureCode, workspaceRoot, mode);
|
|
753
|
+
const lockDir = path.join(workspaceRoot, '.compose', 'data', 'locks', `completion-${featureCode}`);
|
|
754
|
+
const release = await acquireDirLock(lockDir);
|
|
755
|
+
|
|
756
|
+
try {
|
|
757
|
+
// --- §5.3 mode-aware preflight ---------------------------------------
|
|
758
|
+
const tracksJson = getMode(mode).runner.tracksFeatureJson;
|
|
759
|
+
const { getProvider } = await import('./feature-writer.js');
|
|
760
|
+
if (tracksJson) {
|
|
761
|
+
const provider = await getProvider(workspaceRoot);
|
|
762
|
+
const feature = await provider.getFeature(featureCode);
|
|
763
|
+
if (!feature) {
|
|
764
|
+
return refusal('preflight', `feature "${featureCode}" not found`, { guarded });
|
|
765
|
+
}
|
|
766
|
+
if (TERMINAL_STATUSES.has(feature.status)) {
|
|
767
|
+
return refusal('preflight',
|
|
768
|
+
`feature "${featureCode}" is ${feature.status} — a terminal status has no legal completion`,
|
|
769
|
+
{ guarded });
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (!item || !item.lifecycle) {
|
|
773
|
+
return refusal('preflight',
|
|
774
|
+
`no vision item with a lifecycle for "${featureCode}" — run scaffold_feature first`,
|
|
775
|
+
{ guarded });
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// --- §5.3 lookup order: finalized -> pending/intent -> new ------------
|
|
779
|
+
const records = item.lifecycle.backfills ?? [];
|
|
780
|
+
const finalized = records.find((r) => r.request_digest === requestDigest && r.state === 'finalized');
|
|
781
|
+
if (finalized) {
|
|
782
|
+
return {
|
|
783
|
+
ok: true, guarded, backfill: finalized, status: 'finalized',
|
|
784
|
+
operationId: finalized.operation_id, ledgerRef: finalized.guardRef ?? null, reasons: [],
|
|
785
|
+
replayed: true,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const priorIntent = readIntent(workspaceRoot, featureCode);
|
|
790
|
+
const pending = records.find((r) => r.request_digest === requestDigest && r.state === 'pending');
|
|
791
|
+
|
|
792
|
+
// BP-3: the INTENT is the only recovery source. A pending record is a marker.
|
|
793
|
+
if (pending && (!priorIntent || priorIntent.request_digest !== requestDigest)) {
|
|
794
|
+
return refusal('recovery',
|
|
795
|
+
'a pending backfill record exists for this request with no matching intent — '
|
|
796
|
+
+ 'the operation cannot be resumed automatically; operator action required', { guarded });
|
|
797
|
+
}
|
|
798
|
+
if (priorIntent && priorIntent.intent === 'backfill' && priorIntent.request_digest !== requestDigest) {
|
|
799
|
+
return refusal('recovery', 'a different backfill operation is in flight for this feature', { guarded });
|
|
800
|
+
}
|
|
801
|
+
if (priorIntent && priorIntent.intent !== 'backfill') {
|
|
802
|
+
clearIntent(workspaceRoot, featureCode); // a stale live-completion intent
|
|
803
|
+
}
|
|
804
|
+
const recovering = Boolean(priorIntent && priorIntent.intent === 'backfill'
|
|
805
|
+
&& priorIntent.request_digest === requestDigest);
|
|
806
|
+
|
|
807
|
+
let writeContext;
|
|
808
|
+
let skipHistoryWrite = false;
|
|
809
|
+
|
|
810
|
+
if (recovering) {
|
|
811
|
+
// ================= §5.4a RECOVERY ==================================
|
|
812
|
+
const op = priorIntent;
|
|
813
|
+
writeContext = {
|
|
814
|
+
operationId: op.operation_id, requestDigest: op.request_digest,
|
|
815
|
+
featureCode: op.feature_code, mode: op.mode, reason: op.reason,
|
|
816
|
+
commitSha: op.commit_sha, filesChanged: op.files_changed,
|
|
817
|
+
notes: op.notes, attested: op.tests_attested, startedAt: op.started_at,
|
|
818
|
+
occurrences: op.occurrences, terminalOcc: op.terminal_occurrence,
|
|
819
|
+
history: op.write_plan?.history,
|
|
820
|
+
writtenKeys: op.write_plan?.written, skippedKeys: op.write_plan?.skipped,
|
|
821
|
+
guardInitial: op.guard_initial, upgrade: op.upgrade,
|
|
822
|
+
guarded: op.guarded, policyChecksum: op.policy_checksum,
|
|
823
|
+
envelope: op.envelope, ledgerRef: null,
|
|
824
|
+
};
|
|
825
|
+
// R3-4: a missing field is a CORRUPT intent, not a defaultable zero. The
|
|
826
|
+
// four nullable ones are checked with `hasOwn` on the intent itself, so
|
|
827
|
+
// "the operation had none" stays distinguishable from "the intent omitted it".
|
|
828
|
+
for (const field of WRITE_CONTEXT_FIELDS) {
|
|
829
|
+
if (writeContext[field] === undefined) {
|
|
830
|
+
return refusal('recovery',
|
|
831
|
+
`the persisted intent is missing ${field} — clear it and re-run`, { guarded });
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
for (const key of ['notes', 'guard_initial', 'upgrade', 'policy_checksum']) {
|
|
835
|
+
if (!Object.hasOwn(op, key)) {
|
|
836
|
+
return refusal('recovery',
|
|
837
|
+
`the persisted intent is missing ${key} — clear it and re-run`, { guarded });
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// --- History divergence on resume (R2B-3, R3-5) ---------------------
|
|
842
|
+
const replay = insertBackfilledPhases(item, [...writeContext.occurrences, writeContext.terminalOcc]);
|
|
843
|
+
if (!replay.ok) return refusal('history', replay.reasons, { guarded });
|
|
844
|
+
if (canonicalJson(replay.history) === canonicalJson(writeContext.history)) {
|
|
845
|
+
// Nothing moved — keep the plan exactly as persisted.
|
|
846
|
+
} else if (replay.written.length === 0
|
|
847
|
+
&& [...writeContext.occurrences, writeContext.terminalOcc]
|
|
848
|
+
.every((o) => replay.skipped.includes(occurrenceKey(o)))) {
|
|
849
|
+
// The history already reached disk, terminal occurrence included — which
|
|
850
|
+
// §4.1 step 3a recognises by operation_id rather than by claim (R3-5).
|
|
851
|
+
writeContext.history = item.lifecycle.phaseHistory;
|
|
852
|
+
skipHistoryWrite = true;
|
|
853
|
+
} else {
|
|
854
|
+
return refusal('history',
|
|
855
|
+
"the item's phase history changed while a backfill was in flight", { guarded });
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (writeContext.guarded) {
|
|
859
|
+
const resolved = await replayGuardTransition(rid, writeContext);
|
|
860
|
+
if (!resolved.ok) return { ...resolved, guarded: writeContext.guarded };
|
|
861
|
+
writeContext.ledgerRef = resolved.ledgerRef;
|
|
862
|
+
}
|
|
863
|
+
} else {
|
|
864
|
+
// ================= §5.4b BOOTSTRAP =================================
|
|
865
|
+
const operationId = randomUUID();
|
|
866
|
+
const now = new Date().toISOString(); // minted ONCE (BP-6)
|
|
867
|
+
|
|
868
|
+
let guardInitial = null;
|
|
869
|
+
let fromState = null;
|
|
870
|
+
let policyChecksum = null;
|
|
871
|
+
let upgrade = null;
|
|
872
|
+
let needsRegistration = false;
|
|
873
|
+
let proposedInitial = null;
|
|
874
|
+
|
|
875
|
+
if (guarded) {
|
|
876
|
+
const g = await currentGuardState(rid);
|
|
877
|
+
if (g.error) return refusal('guard', 'guard unreachable', { guarded, error: g.error });
|
|
878
|
+
if (g.state === null) {
|
|
879
|
+
// R2B-8: COMPUTE the initial, do NOT register yet.
|
|
880
|
+
const lp = item.lifecycle.currentPhase;
|
|
881
|
+
proposedInitial = inGraph(transitionsOf(mode), lp) ? lp : genesisOf(mode);
|
|
882
|
+
needsRegistration = true;
|
|
883
|
+
fromState = proposedInitial;
|
|
884
|
+
guardInitial = { registered: proposedInitial, lifecycle_phase: lp };
|
|
885
|
+
} else {
|
|
886
|
+
fromState = g.state;
|
|
887
|
+
}
|
|
888
|
+
} else {
|
|
889
|
+
// Unguarded: the lifecycle IS the source of truth, and legality is local.
|
|
890
|
+
fromState = item.lifecycle.currentPhase;
|
|
891
|
+
}
|
|
892
|
+
if (terminalOf(mode).includes(fromState)) {
|
|
893
|
+
return refusal('guard', `${featureCode} is already terminal at "${fromState}"`, { guarded });
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// --- §5.5 materialise and VALIDATE before any guard mutation -------
|
|
897
|
+
const ev = await verifyCompletionEvidenceAsync({
|
|
898
|
+
commitSha, cwd: evRoot, testCommand: guardTestCommand(workspaceRoot), testsPassClaim: testsPass,
|
|
899
|
+
});
|
|
900
|
+
if (!ev.ok) return refusal('evidence', ev.reasons, { guarded });
|
|
901
|
+
const attested = ev.testsAttested ? true : testsPass === true;
|
|
902
|
+
|
|
903
|
+
const fwd = transitionsOf(mode);
|
|
904
|
+
for (const o of occurrences) {
|
|
905
|
+
if (!inGraph(fwd, o.phase)) {
|
|
906
|
+
return refusal('request', `"${o.phase}" is not a phase of mode ${mode}`, { guarded });
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
let materialised;
|
|
911
|
+
try {
|
|
912
|
+
materialised = occurrences.map((o) => {
|
|
913
|
+
const resolved = resolveEvidenceRef(evRoot, o.evidence);
|
|
914
|
+
return {
|
|
915
|
+
phase: o.phase, step: o.phase, to: o.phase, from: null,
|
|
916
|
+
enteredAt: resolved.observedTime, timestamp: resolved.observedTime, exitedAt: null,
|
|
917
|
+
outcome: 'backfilled', recordedAt: now, origin: 'backfill',
|
|
918
|
+
confidence: deriveConfidence(resolved.kind), episode: 1, evidence: resolved,
|
|
919
|
+
};
|
|
920
|
+
});
|
|
921
|
+
} catch (e) {
|
|
922
|
+
// A path that escapes the repo, a symlink that leaves it, a missing file:
|
|
923
|
+
// the occurrence cannot be placed in the history at all.
|
|
924
|
+
return refusal('history', e.message, { guarded });
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const terminalOcc = {
|
|
928
|
+
phase: BACKFILL_TERMINAL, step: BACKFILL_TERMINAL, to: BACKFILL_TERMINAL, from: fromState,
|
|
929
|
+
enteredAt: now, timestamp: now, exitedAt: null,
|
|
930
|
+
outcome: 'backfilled', recordedAt: now, origin: 'live',
|
|
931
|
+
confidence: 1.0, episode: 2, operation_id: operationId,
|
|
932
|
+
evidence: {
|
|
933
|
+
kind: 'commit', ref: commitSha, verifiedAt: now,
|
|
934
|
+
observedTime: now, observedEpochMs: Date.parse(now),
|
|
935
|
+
},
|
|
936
|
+
};
|
|
937
|
+
// The terminal occurrence IS live — it happened now. The terminal STATE is
|
|
938
|
+
// the provenance signal (Decision 11), not this field.
|
|
939
|
+
|
|
940
|
+
const probe = insertBackfilledPhases(item, [...materialised, terminalOcc]);
|
|
941
|
+
if (!probe.ok) return refusal('history', probe.reasons, { guarded });
|
|
942
|
+
if (probe.history[probe.history.length - 1]?.phase !== BACKFILL_TERMINAL) {
|
|
943
|
+
return refusal('history',
|
|
944
|
+
'backfilled evidence is dated after the completion being recorded', { guarded });
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// --- §5.6 register, legacy compatibility, lazy upgrade (guarded only)
|
|
948
|
+
if (guarded) {
|
|
949
|
+
const reg = await ensureGuard(featureCode, needsRegistration ? proposedInitial : fromState,
|
|
950
|
+
workspaceRoot, mode);
|
|
951
|
+
if (isGuardError(reg)) {
|
|
952
|
+
return refusal('guard', ['guard registration failed'], { guarded, error: reg.error || reg });
|
|
953
|
+
}
|
|
954
|
+
if (needsRegistration) {
|
|
955
|
+
const g = await currentGuardState(rid);
|
|
956
|
+
if (g.error || g.state === null) {
|
|
957
|
+
return refusal('guard', ['guard registration could not be confirmed'],
|
|
958
|
+
{ guarded, error: g.error });
|
|
959
|
+
}
|
|
960
|
+
if (g.state !== fromState) {
|
|
961
|
+
return refusal('guard', `registration seeded "${g.state}", not "${fromState}"`, { guarded });
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
if (reg.status === 'legacy') {
|
|
965
|
+
const u = await applyBackfillUpgrade({ featureCode, workspaceRoot, mode });
|
|
966
|
+
if (!u.ok) {
|
|
967
|
+
const manual = u.error?.code === 'upgrade_descriptor_unavailable' && custodyBackend() === 'none';
|
|
968
|
+
return refusal('upgrade', [
|
|
969
|
+
...u.reasons,
|
|
970
|
+
...(u.error?.hint ? [u.error.hint] : []),
|
|
971
|
+
...(manual ? ['regenerate with `compose guard descriptors`, have the operator re-sign it, and commit both files'] : []),
|
|
972
|
+
], { guarded, error: u.error });
|
|
973
|
+
}
|
|
974
|
+
upgrade = { descriptor_id: u.descriptorId ?? null, status: u.status, ledger_ref: u.ledgerRef ?? null };
|
|
975
|
+
}
|
|
976
|
+
// BP-1: read the checksum AFTER any upgrade and BEFORE the transition.
|
|
977
|
+
const { guardPolicy } = await rawGuard();
|
|
978
|
+
const pol = await guardPolicy(rid);
|
|
979
|
+
if (isGuardError(pol)) {
|
|
980
|
+
return refusal('guard', [`guard policy unreadable: ${guardErrorMessage(pol)}`], { guarded });
|
|
981
|
+
}
|
|
982
|
+
policyChecksum = pol.checksum ?? null;
|
|
983
|
+
if (!HEX64.test(String(policyChecksum))) {
|
|
984
|
+
return refusal('guard', ['guard policy returned no usable checksum'], { guarded });
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// --- §5.7 write-ahead intent: the COMPLETE validated write plan ----
|
|
989
|
+
writeContext = {
|
|
990
|
+
operationId, requestDigest, featureCode, mode, reason,
|
|
991
|
+
commitSha: commitSha ?? null, filesChanged, notes: notes ?? null,
|
|
992
|
+
attested, startedAt: now,
|
|
993
|
+
occurrences: materialised, terminalOcc,
|
|
994
|
+
history: probe.history, writtenKeys: probe.written, skippedKeys: probe.skipped,
|
|
995
|
+
guardInitial, upgrade, guarded, policyChecksum,
|
|
996
|
+
envelope: {
|
|
997
|
+
from: fromState, to: BACKFILL_TERMINAL,
|
|
998
|
+
artifacts: {
|
|
999
|
+
operation_id: operationId,
|
|
1000
|
+
request_digest: requestDigest,
|
|
1001
|
+
resolver_tags: 'late-registration+backfill',
|
|
1002
|
+
...(commitSha ? { commit_sha: commitSha } : {}),
|
|
1003
|
+
},
|
|
1004
|
+
modified_files: [], resolved_by: 'agent',
|
|
1005
|
+
idempotency_key: operationId,
|
|
1006
|
+
...(guarded ? { expected_policy_checksum: policyChecksum } : {}),
|
|
1007
|
+
},
|
|
1008
|
+
ledgerRef: null,
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
writeIntent(workspaceRoot, featureCode, {
|
|
1012
|
+
operation_id: writeContext.operationId, feature_code: writeContext.featureCode,
|
|
1013
|
+
mode: writeContext.mode, intent: 'backfill',
|
|
1014
|
+
request_digest: writeContext.requestDigest, reason: writeContext.reason,
|
|
1015
|
+
commit_sha: writeContext.commitSha, files_changed: writeContext.filesChanged,
|
|
1016
|
+
notes: writeContext.notes,
|
|
1017
|
+
tests_attested: writeContext.attested, started_at: writeContext.startedAt,
|
|
1018
|
+
guarded: writeContext.guarded,
|
|
1019
|
+
occurrences: writeContext.occurrences,
|
|
1020
|
+
terminal_occurrence: writeContext.terminalOcc,
|
|
1021
|
+
write_plan: {
|
|
1022
|
+
history: writeContext.history,
|
|
1023
|
+
written: writeContext.writtenKeys,
|
|
1024
|
+
skipped: writeContext.skippedKeys,
|
|
1025
|
+
},
|
|
1026
|
+
guard_initial: writeContext.guardInitial,
|
|
1027
|
+
upgrade: writeContext.upgrade,
|
|
1028
|
+
policy_checksum: writeContext.policyChecksum,
|
|
1029
|
+
envelope: writeContext.envelope,
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
// --- §5.8 the fresh guarded transition -----------------------------
|
|
1033
|
+
if (guarded) {
|
|
1034
|
+
const applied = await freshGuardTransition(rid, writeContext, workspaceRoot, featureCode);
|
|
1035
|
+
if (!applied.ok) return { ...applied, guarded };
|
|
1036
|
+
writeContext.ledgerRef = applied.ledgerRef;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// --- §5.10 the write sequence ---------------------------------------
|
|
1041
|
+
//
|
|
1042
|
+
// `return await`, NOT `return`. `try { return p } finally { release() }`
|
|
1043
|
+
// runs the finally as soon as the expression is EVALUATED, so returning the
|
|
1044
|
+
// bare promise released the lock at the write sequence's first suspension
|
|
1045
|
+
// and let the completion record, the status flip, the audit event and
|
|
1046
|
+
// finalization all run unlocked — with a concurrent retry free to enter the
|
|
1047
|
+
// same pending operation while they were still in flight (Codex r1 #1).
|
|
1048
|
+
return await backfillWriteSequence({
|
|
1049
|
+
wc: writeContext, item, store, workspaceRoot, visionItemId, visionProjector,
|
|
1050
|
+
actor, recovering, skipHistoryWrite, getProvider,
|
|
1051
|
+
});
|
|
1052
|
+
} finally {
|
|
1053
|
+
release();
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* §5.8 — the FRESH transition.
|
|
1059
|
+
*
|
|
1060
|
+
* DEVIATION from the blueprint's pseudocode, deliberately: this uses the RAW
|
|
1061
|
+
* `guardTransition` transport rather than `guardedTransition`. §5.6 has already
|
|
1062
|
+
* run `ensureGuard`, so the wrapper's only remaining contribution is a second
|
|
1063
|
+
* (cached) registration call plus collapsing `replayed` into `applied:true` —
|
|
1064
|
+
* and that collapsing is exactly what R2B-6 forbids, because a replay returns a
|
|
1065
|
+
* HISTORICAL verdict and says nothing about what happened afterwards. Surfacing
|
|
1066
|
+
* `status` verbatim would need a change to `server/lifecycle-guard.js`, which
|
|
1067
|
+
* this slice may not touch; going straight to the transport gets the same
|
|
1068
|
+
* guarantee with no cross-slice edit.
|
|
1069
|
+
*/
|
|
1070
|
+
async function freshGuardTransition(rid, wc, workspaceRoot, featureCode) {
|
|
1071
|
+
const { guardTransition } = await rawGuard();
|
|
1072
|
+
const env = wc.envelope;
|
|
1073
|
+
let g;
|
|
1074
|
+
try {
|
|
1075
|
+
g = await guardTransition({
|
|
1076
|
+
resourceId: rid, fromState: env.from, toState: env.to,
|
|
1077
|
+
artifacts: env.artifacts, modifiedFiles: env.modified_files,
|
|
1078
|
+
idempotencyKey: env.idempotency_key, resolvedBy: env.resolved_by,
|
|
1079
|
+
expectedPolicyChecksum: env.expected_policy_checksum,
|
|
1080
|
+
});
|
|
1081
|
+
} catch (e) {
|
|
1082
|
+
return refusal('guard', [`guard transition failed: ${e.message}`]);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
if (guardErrorType(g) === 'policy_checksum_mismatch') {
|
|
1086
|
+
// R2B-7: refused ATOMICALLY under stratum's resource lock; nothing written.
|
|
1087
|
+
// This is the ONE refusal that is retryable with no operator action.
|
|
1088
|
+
clearIntent(workspaceRoot, featureCode);
|
|
1089
|
+
return refusal('guard',
|
|
1090
|
+
`the guard policy for ${featureCode} changed while this backfill was in flight `
|
|
1091
|
+
+ `(expected ${env.expected_policy_checksum}) — nothing was written; retry`);
|
|
1092
|
+
}
|
|
1093
|
+
if (isGuardError(g)) {
|
|
1094
|
+
return refusal('guard', [`guard transition failed: ${guardErrorMessage(g)}`], { error: g });
|
|
1095
|
+
}
|
|
1096
|
+
if (g.status === 'applied') return { ok: true, ledgerRef: g.ledger_ref ?? null };
|
|
1097
|
+
if (g.status === 'replayed') {
|
|
1098
|
+
// §5.9b: a fresh attempt that meets an existing entry is a recovery in
|
|
1099
|
+
// disguise, and is resolved read-only like any other.
|
|
1100
|
+
return verifyGuardLedger(rid, wc);
|
|
1101
|
+
}
|
|
1102
|
+
return refusal('guard', [g.status === 'refused' ? 'refused by guard' : 'guard transition failed'],
|
|
1103
|
+
{ verdict: g.verdict });
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* §5.9a — the RECOVERY replay. Raw transport, no `ensureGuard`, no policy
|
|
1108
|
+
* re-derivation, and ALWAYS carrying the persisted checksum (R3-2/R4-1).
|
|
1109
|
+
*/
|
|
1110
|
+
async function replayGuardTransition(rid, wc) {
|
|
1111
|
+
const env = wc.envelope;
|
|
1112
|
+
if (wc.policyChecksum === null || !HEX64.test(String(wc.policyChecksum))) {
|
|
1113
|
+
return refusal('recovery', 'intent has no policy checksum; clear it or re-run');
|
|
1114
|
+
}
|
|
1115
|
+
if ((env.expected_policy_checksum ?? null) !== wc.policyChecksum) {
|
|
1116
|
+
return refusal('recovery', 'intent envelope checksum disagrees with intent policy_checksum');
|
|
1117
|
+
}
|
|
1118
|
+
const { guardTransition } = await rawGuard();
|
|
1119
|
+
let g;
|
|
1120
|
+
try {
|
|
1121
|
+
g = await guardTransition({
|
|
1122
|
+
resourceId: rid, fromState: env.from, toState: env.to,
|
|
1123
|
+
artifacts: env.artifacts, modifiedFiles: env.modified_files,
|
|
1124
|
+
idempotencyKey: env.idempotency_key, resolvedBy: env.resolved_by,
|
|
1125
|
+
expectedPolicyChecksum: wc.policyChecksum,
|
|
1126
|
+
});
|
|
1127
|
+
} catch (e) {
|
|
1128
|
+
return refusal('recovery', `guard transition failed: ${e.message}`);
|
|
1129
|
+
}
|
|
1130
|
+
if (guardErrorType(g) === 'policy_checksum_mismatch') {
|
|
1131
|
+
return verifyGuardLedger(rid, wc); // READ-ONLY. Nothing was written.
|
|
1132
|
+
}
|
|
1133
|
+
if (isGuardError(g)) {
|
|
1134
|
+
return refusal('recovery', `guard transition failed: ${guardErrorMessage(g)}`);
|
|
1135
|
+
}
|
|
1136
|
+
if (g.status === 'applied') return { ok: true, ledgerRef: g.ledger_ref ?? null };
|
|
1137
|
+
// `replayed` is NOT `applied` (R2B-6): the ledger is read on EVERY recovery
|
|
1138
|
+
// success, because a kill, an override or a migrate could have moved the
|
|
1139
|
+
// resource on after the historical verdict this replay is echoing.
|
|
1140
|
+
return verifyGuardLedger(rid, wc);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* §5.9c — read-only verification. No transition is issued: everything is decided
|
|
1145
|
+
* from `guard digest` (a pure function) and ONE `guard history` read, so all
|
|
1146
|
+
* three conditions describe one consistent moment.
|
|
1147
|
+
*/
|
|
1148
|
+
async function verifyGuardLedger(rid, wc) {
|
|
1149
|
+
if (wc.policyChecksum === null) return refusal('recovery', 'no policy checksum on record');
|
|
1150
|
+
const { guardDigest, guardHistory, guardPolicy } = await rawGuard();
|
|
1151
|
+
const env = wc.envelope;
|
|
1152
|
+
|
|
1153
|
+
const d = await guardDigest({
|
|
1154
|
+
fromState: env.from, toState: env.to, artifacts: env.artifacts,
|
|
1155
|
+
modifiedFiles: env.modified_files, resolvedBy: env.resolved_by,
|
|
1156
|
+
policyChecksum: wc.policyChecksum,
|
|
1157
|
+
});
|
|
1158
|
+
if (isGuardError(d)) {
|
|
1159
|
+
return refusal('recovery', `payload digest could not be computed: ${guardErrorMessage(d)}`);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const h = await guardHistory(rid);
|
|
1163
|
+
if (isGuardError(h)) {
|
|
1164
|
+
return refusal('recovery', `guard history unreadable: ${guardErrorMessage(h)}`);
|
|
1165
|
+
}
|
|
1166
|
+
const ledger = h.ledger ?? [];
|
|
1167
|
+
|
|
1168
|
+
// (1) THE ENTRY EXISTS AND IS OURS. Matching on id + to_state + outcome alone
|
|
1169
|
+
// is R2-1, which BP-1 rejected — the payload digest is what makes it ours.
|
|
1170
|
+
const index = ledger.findIndex((e) => e.kind === 'transition'
|
|
1171
|
+
&& e.idempotency_key === wc.operationId
|
|
1172
|
+
&& e.outcome === 'applied'
|
|
1173
|
+
&& e.to_state === BACKFILL_TERMINAL
|
|
1174
|
+
&& e.payload_digest === d.payload_digest);
|
|
1175
|
+
if (index === -1) {
|
|
1176
|
+
// R3-2: the operation NEVER applied. This is where the design deliberately
|
|
1177
|
+
// stops — the intent stays, the guard is untouched, and a HUMAN decides.
|
|
1178
|
+
const livePolicy = await guardPolicy(rid);
|
|
1179
|
+
const liveChecksum = isGuardError(livePolicy) ? '(unreadable)' : (livePolicy.checksum ?? '(none)');
|
|
1180
|
+
if (liveChecksum !== wc.policyChecksum) {
|
|
1181
|
+
return refusal('recovery',
|
|
1182
|
+
`this backfill never reached the guard, and the policy for ${wc.featureCode} has changed `
|
|
1183
|
+
+ `since the intent was written (recorded ${wc.policyChecksum}, now ${liveChecksum}). `
|
|
1184
|
+
+ 'Nothing has been written. Either clear the intent at '
|
|
1185
|
+
+ `.compose/data/completion-intents/${wc.featureCode}.json and re-run the backfill under `
|
|
1186
|
+
+ 'the current policy, or restore the policy the intent was written against.');
|
|
1187
|
+
}
|
|
1188
|
+
return refusal('recovery',
|
|
1189
|
+
'no applied ledger entry under this operation id matches the persisted envelope');
|
|
1190
|
+
}
|
|
1191
|
+
const entry = ledger[index];
|
|
1192
|
+
|
|
1193
|
+
// (2) THE RESOURCE IS STILL WHERE THAT ENTRY LEFT IT.
|
|
1194
|
+
if (h.current_state !== BACKFILL_TERMINAL) {
|
|
1195
|
+
return refusal('recovery',
|
|
1196
|
+
`the guard has moved to "${h.current_state}" since this operation applied`);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// (3) NOTHING MUTATED IT AFTERWARDS. The ledger is append-only and ordered, so
|
|
1200
|
+
// this is a suffix scan. R3-6: the kinds are the ones stratum ACTUALLY
|
|
1201
|
+
// writes — there is no 'override' kind and no 'migrate' kind, and the
|
|
1202
|
+
// first draft scanned for both, so an override slipped past silently.
|
|
1203
|
+
for (const e of ledger.slice(index + 1)) {
|
|
1204
|
+
if (e.kind === 'transition' || e.kind === 'deviation') {
|
|
1205
|
+
return refusal('recovery',
|
|
1206
|
+
`the guard was mutated after this operation applied (a ${e.kind} entry `
|
|
1207
|
+
+ `${e.from_state} -> ${e.to_state})`);
|
|
1208
|
+
}
|
|
1209
|
+
if (e.kind === 'graph_version') {
|
|
1210
|
+
// State-preserving BY CONSTRUCTION — verify rather than assume, so a
|
|
1211
|
+
// future stratum that moved state in a policy entry breaks loudly.
|
|
1212
|
+
if (e.from_state !== e.to_state) {
|
|
1213
|
+
return refusal('recovery', 'a policy entry moved the guard state after this operation applied');
|
|
1214
|
+
}
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
return refusal('recovery', `unrecognised ledger entry kind "${e.kind}" after this operation`);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
return { ok: true, ledgerRef: entry.entry_digest ?? null };
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* §5.10 — the write sequence. EVERY value comes from `wc`: no probe, no `now`,
|
|
1225
|
+
* no request, no live `guardEnabled`. Steps 6.0–6.2 are durable truth and abort
|
|
1226
|
+
* with the intent KEPT; 6.3–6.4 are re-drivable projections and are collected.
|
|
1227
|
+
*/
|
|
1228
|
+
async function backfillWriteSequence({
|
|
1229
|
+
wc, item, store, workspaceRoot, visionItemId, visionProjector, actor, recovering,
|
|
1230
|
+
skipHistoryWrite, getProvider,
|
|
1231
|
+
}) {
|
|
1232
|
+
const tracksJson = getMode(wc.mode).runner.tracksFeatureJson;
|
|
1233
|
+
const failures = [];
|
|
1234
|
+
|
|
1235
|
+
// --- 6.0 history + PENDING marker, ONE store write -----------------------
|
|
1236
|
+
const snapshot = deepClone(item.lifecycle);
|
|
1237
|
+
if (!skipHistoryWrite) item.lifecycle.phaseHistory = wc.history;
|
|
1238
|
+
item.lifecycle.currentPhase = BACKFILL_TERMINAL;
|
|
1239
|
+
item.lifecycle.completedAt = wc.terminalOcc.enteredAt; // stable (BP-6)
|
|
1240
|
+
if (!Array.isArray(item.lifecycle.backfills)) item.lifecycle.backfills = [];
|
|
1241
|
+
const record = {
|
|
1242
|
+
operation_id: wc.operationId, request_digest: wc.requestDigest, state: 'pending',
|
|
1243
|
+
reason: wc.reason,
|
|
1244
|
+
recordedAt: wc.startedAt, // R3-4: the OPERATION's instant, not the retry's
|
|
1245
|
+
finalizedAt: null,
|
|
1246
|
+
completionEvidence: {
|
|
1247
|
+
commit_sha: wc.commitSha, tests_attested: wc.attested, verified_at: wc.startedAt,
|
|
1248
|
+
},
|
|
1249
|
+
guardRef: wc.ledgerRef ?? null, guard_initial: wc.guardInitial, upgrade: wc.upgrade,
|
|
1250
|
+
actor,
|
|
1251
|
+
occurrenceKeys: [...wc.writtenKeys, ...wc.skippedKeys],
|
|
1252
|
+
};
|
|
1253
|
+
const at = item.lifecycle.backfills.findIndex((r) => r.operation_id === wc.operationId);
|
|
1254
|
+
if (at === -1) item.lifecycle.backfills.push(record);
|
|
1255
|
+
else item.lifecycle.backfills[at] = { ...item.lifecycle.backfills[at], ...record };
|
|
1256
|
+
const liveRecord = item.lifecycle.backfills.find((r) => r.operation_id === wc.operationId);
|
|
1257
|
+
|
|
1258
|
+
store.updateLifecycle(item.id, item.lifecycle);
|
|
1259
|
+
if (store.lastSaveOk === false) {
|
|
1260
|
+
// BP-5: roll the in-memory item back so memory and disk agree.
|
|
1261
|
+
item.lifecycle = snapshot;
|
|
1262
|
+
store.items.set(item.id, item);
|
|
1263
|
+
return refusal('write', 'vision-state could not be persisted',
|
|
1264
|
+
{ guarded: wc.guarded, operationId: wc.operationId, ledgerRef: wc.ledgerRef });
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
const provider = await getProvider(workspaceRoot);
|
|
1268
|
+
|
|
1269
|
+
// --- 6.1 completion record — BP-11: only for modes that track feature.json
|
|
1270
|
+
let rec = null;
|
|
1271
|
+
if (tracksJson) {
|
|
1272
|
+
const { recordCompletion } = await import('./completion-writer.js');
|
|
1273
|
+
try {
|
|
1274
|
+
rec = await recordCompletion(workspaceRoot, {
|
|
1275
|
+
feature_code: wc.featureCode,
|
|
1276
|
+
...(wc.commitSha ? { commit_sha: wc.commitSha } : {}),
|
|
1277
|
+
tests_pass: wc.attested,
|
|
1278
|
+
files_changed: wc.filesChanged,
|
|
1279
|
+
...(wc.notes ? { notes: wc.notes } : {}),
|
|
1280
|
+
idempotency_key: wc.operationId,
|
|
1281
|
+
set_status: false,
|
|
1282
|
+
});
|
|
1283
|
+
} catch (e) {
|
|
1284
|
+
// Durable write failed: the intent is KEPT so a retry resumes.
|
|
1285
|
+
return refusal('write', `completion record could not be written: ${e.message}`,
|
|
1286
|
+
{ guarded: wc.guarded, operationId: wc.operationId, ledgerRef: wc.ledgerRef, error: e });
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// --- 6.2 status -> COMPLETE, raw ----------------------------------------
|
|
1291
|
+
let statusChanged = null;
|
|
1292
|
+
if (tracksJson) {
|
|
1293
|
+
// The backfill door keeps the re-read INSIDE the catch, deliberately and
|
|
1294
|
+
// unlike the live path above. A backfill holds a write-ahead intent, and
|
|
1295
|
+
// §5.11 says a durable-write failure refuses at `write` and KEEPS it so a
|
|
1296
|
+
// retry resumes — a thrown read here would escape the gate with the intent
|
|
1297
|
+
// stranded and no `refusedAt` for the caller to act on.
|
|
1298
|
+
try {
|
|
1299
|
+
const fresh = await provider.getFeature(wc.featureCode);
|
|
1300
|
+
const prepared = prepareCompleteStatus(fresh, wc.commitSha);
|
|
1301
|
+
if (prepared) {
|
|
1302
|
+
await persistCompleteStatus({
|
|
1303
|
+
provider, workspaceRoot, featureCode: wc.featureCode, updated: prepared.updated,
|
|
1304
|
+
});
|
|
1305
|
+
statusChanged = { from: prepared.from, to: 'COMPLETE' };
|
|
1306
|
+
}
|
|
1307
|
+
} catch (e) {
|
|
1308
|
+
return refusal('write', `completion recorded but status could not be set: ${e.message}`,
|
|
1309
|
+
{ guarded: wc.guarded, operationId: wc.operationId, ledgerRef: wc.ledgerRef, error: e, result: rec });
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// --- 6.3 ROADMAP regen — projection; collected --------------------------
|
|
1314
|
+
if (tracksJson) {
|
|
1315
|
+
try {
|
|
1316
|
+
await provider.renderRoadmap();
|
|
1317
|
+
} catch (e) {
|
|
1318
|
+
failures.push({ step: 'roadmap', message: e.message, recover: 'compose roadmap generate' });
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// --- 6.4 vision projection ----------------------------------------------
|
|
1323
|
+
let visionProjection = null;
|
|
1324
|
+
if (tracksJson) {
|
|
1325
|
+
try {
|
|
1326
|
+
const project = visionProjector || defaultVisionProjector;
|
|
1327
|
+
visionProjection = await project({
|
|
1328
|
+
workspaceRoot, featureCode: wc.featureCode, visionItemId: visionItemId ?? item.id,
|
|
1329
|
+
commitSha: wc.commitSha, ledgerRef: wc.ledgerRef, mode: wc.mode,
|
|
1330
|
+
// R2B-4/R3-7: the EFFECTIVE flag for THIS operation, not live config.
|
|
1331
|
+
guarded: wc.guarded,
|
|
1332
|
+
// The LIVE store, so the projection writes the same item finalization
|
|
1333
|
+
// is about to serialize (Codex r1 #2).
|
|
1334
|
+
store,
|
|
1335
|
+
});
|
|
1336
|
+
// `applyVerifiedProjection` REPORTS a refused or unpersisted projection as
|
|
1337
|
+
// `{ok:false}`; it does not throw. Handling only exceptions finalized the
|
|
1338
|
+
// operation and cleared its intent with the item still incomplete
|
|
1339
|
+
// (Codex r1 #3). A projector that returns nothing at all is the same
|
|
1340
|
+
// failure wearing a quieter disguise.
|
|
1341
|
+
if (!visionProjection || visionProjection.ok === false) {
|
|
1342
|
+
failures.push({
|
|
1343
|
+
step: 'vision',
|
|
1344
|
+
message: (visionProjection?.reasons ?? ['the vision projection reported no result']).join('; '),
|
|
1345
|
+
recover: 'compose validate --fix',
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
} catch (e) {
|
|
1349
|
+
failures.push({ step: 'vision', message: e.message, recover: 'compose validate --fix' });
|
|
1350
|
+
}
|
|
1351
|
+
} else {
|
|
1352
|
+
// R2B-9: modes without feature.json have no projector, and updateLifecycle
|
|
1353
|
+
// does NOT touch item.status — so without this the item finalizes still
|
|
1354
|
+
// reading its old status while its lifecycle says complete_backfilled. This
|
|
1355
|
+
// is DURABLE, not a projection: a failure is rolled back and collected.
|
|
1356
|
+
const priorStatus = item.status;
|
|
1357
|
+
store.updateItem(item.id, { status: 'complete' });
|
|
1358
|
+
if (store.lastSaveOk === false) {
|
|
1359
|
+
store.updateItem(item.id, { status: priorStatus });
|
|
1360
|
+
failures.push({
|
|
1361
|
+
step: 'item-status',
|
|
1362
|
+
message: 'vision item status could not be persisted',
|
|
1363
|
+
recover: 'retry the backfill',
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// --- 6.5 audit — BP-5: NON-SWALLOWING, emitted regardless of statusChanged
|
|
1369
|
+
let auditOk = false;
|
|
1370
|
+
try {
|
|
1371
|
+
await provider.appendEvent(wc.featureCode, {
|
|
1372
|
+
tool: 'backfill_completion', code: wc.featureCode,
|
|
1373
|
+
from: statusChanged?.from ?? null, to: 'COMPLETE',
|
|
1374
|
+
reason: 'backfill', via: 'completion_gate',
|
|
1375
|
+
operation_id: wc.operationId, backfill_request_digest: wc.requestDigest,
|
|
1376
|
+
...(wc.commitSha ? { commit_sha: wc.commitSha } : {}),
|
|
1377
|
+
...(wc.ledgerRef ? { ledger_ref: wc.ledgerRef } : {}),
|
|
1378
|
+
});
|
|
1379
|
+
auditOk = true;
|
|
1380
|
+
} catch (e) {
|
|
1381
|
+
failures.push({ step: 'audit', message: e.message, recover: 'retry the backfill' });
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
const common = {
|
|
1385
|
+
ok: true, guarded: wc.guarded, operationId: wc.operationId,
|
|
1386
|
+
ledgerRef: wc.ledgerRef ?? null, recovered: recovering,
|
|
1387
|
+
attestedTestsPass: wc.attested, visionProjection,
|
|
1388
|
+
written: wc.writtenKeys, skipped: wc.skippedKeys,
|
|
1389
|
+
result: rec ? { ...rec, status_changed: statusChanged } : null,
|
|
1390
|
+
};
|
|
1391
|
+
|
|
1392
|
+
// --- 6.6 finalize — ONLY with an empty failure set AND a confirmed audit --
|
|
1393
|
+
if (failures.length === 0 && auditOk) {
|
|
1394
|
+
const snapshot2 = deepClone(item.lifecycle);
|
|
1395
|
+
liveRecord.state = 'finalized';
|
|
1396
|
+
// The ONLY timestamp in this section minted at write time, deliberately: it
|
|
1397
|
+
// records when the operation FINISHED, which on a resume really is now.
|
|
1398
|
+
liveRecord.finalizedAt = new Date().toISOString();
|
|
1399
|
+
store.updateLifecycle(item.id, item.lifecycle);
|
|
1400
|
+
if (store.lastSaveOk === false) {
|
|
1401
|
+
item.lifecycle = snapshot2;
|
|
1402
|
+
store.items.set(item.id, item);
|
|
1403
|
+
// Return the RESTORED record, not the detached one this block already
|
|
1404
|
+
// mutated to `finalized` (Codex r1 #6). The caller was being handed a
|
|
1405
|
+
// record that says finalized while disk and memory both say pending.
|
|
1406
|
+
const restored = (item.lifecycle.backfills ?? [])
|
|
1407
|
+
.find((r) => r.operation_id === wc.operationId) ?? null;
|
|
1408
|
+
return {
|
|
1409
|
+
...common,
|
|
1410
|
+
partial: true,
|
|
1411
|
+
failures: [{ step: 'finalize', message: 'vision-state could not be persisted', recover: 'retry the backfill' }],
|
|
1412
|
+
backfill: restored, status: 'pending', reasons: [],
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
clearIntent(workspaceRoot, wc.featureCode); // LAST
|
|
1416
|
+
return { ...common, partial: false, failures: [], backfill: liveRecord, status: 'finalized', reasons: [] };
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// The record stays `pending`, the intent stays. A retry resumes.
|
|
1420
|
+
return { ...common, partial: true, failures, backfill: liveRecord, status: 'pending', reasons: [] };
|
|
1421
|
+
}
|