@skillcap/gdh 0.23.0 → 0.24.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.
Files changed (128) hide show
  1. package/INSTALL-BUNDLE.json +1 -1
  2. package/RELEASE-SPAN-UPDATE-CONTRACTS.json +64 -0
  3. package/node_modules/@gdh/adapters/dist/authoring-hook-render.d.ts.map +1 -1
  4. package/node_modules/@gdh/adapters/dist/authoring-hook-render.js +2 -1
  5. package/node_modules/@gdh/adapters/dist/authoring-hook-render.js.map +1 -1
  6. package/node_modules/@gdh/adapters/dist/claude-statusline-render.d.ts.map +1 -1
  7. package/node_modules/@gdh/adapters/dist/claude-statusline-render.js +2 -1
  8. package/node_modules/@gdh/adapters/dist/claude-statusline-render.js.map +1 -1
  9. package/node_modules/@gdh/adapters/dist/claude-update-hook-render.d.ts.map +1 -1
  10. package/node_modules/@gdh/adapters/dist/claude-update-hook-render.js +2 -1
  11. package/node_modules/@gdh/adapters/dist/claude-update-hook-render.js.map +1 -1
  12. package/node_modules/@gdh/adapters/dist/claude-update-worker-render.d.ts.map +1 -1
  13. package/node_modules/@gdh/adapters/dist/claude-update-worker-render.js +2 -1
  14. package/node_modules/@gdh/adapters/dist/claude-update-worker-render.js.map +1 -1
  15. package/node_modules/@gdh/adapters/dist/deferred-actions-advisory.d.ts +71 -0
  16. package/node_modules/@gdh/adapters/dist/deferred-actions-advisory.d.ts.map +1 -0
  17. package/node_modules/@gdh/adapters/dist/deferred-actions-advisory.js +89 -0
  18. package/node_modules/@gdh/adapters/dist/deferred-actions-advisory.js.map +1 -0
  19. package/node_modules/@gdh/adapters/dist/durable-backup.d.ts +209 -0
  20. package/node_modules/@gdh/adapters/dist/durable-backup.d.ts.map +1 -0
  21. package/node_modules/@gdh/adapters/dist/durable-backup.js +346 -0
  22. package/node_modules/@gdh/adapters/dist/durable-backup.js.map +1 -0
  23. package/node_modules/@gdh/adapters/dist/index.d.ts +10 -1
  24. package/node_modules/@gdh/adapters/dist/index.d.ts.map +1 -1
  25. package/node_modules/@gdh/adapters/dist/index.js +24 -2
  26. package/node_modules/@gdh/adapters/dist/index.js.map +1 -1
  27. package/node_modules/@gdh/adapters/dist/inventory-sweep.d.ts +53 -0
  28. package/node_modules/@gdh/adapters/dist/inventory-sweep.d.ts.map +1 -0
  29. package/node_modules/@gdh/adapters/dist/inventory-sweep.js +98 -0
  30. package/node_modules/@gdh/adapters/dist/inventory-sweep.js.map +1 -0
  31. package/node_modules/@gdh/adapters/dist/process-orchestration.d.ts +223 -0
  32. package/node_modules/@gdh/adapters/dist/process-orchestration.d.ts.map +1 -0
  33. package/node_modules/@gdh/adapters/dist/process-orchestration.js +368 -0
  34. package/node_modules/@gdh/adapters/dist/process-orchestration.js.map +1 -0
  35. package/node_modules/@gdh/adapters/dist/self-update-mechanics.d.ts +157 -14
  36. package/node_modules/@gdh/adapters/dist/self-update-mechanics.d.ts.map +1 -1
  37. package/node_modules/@gdh/adapters/dist/self-update-mechanics.js +570 -89
  38. package/node_modules/@gdh/adapters/dist/self-update-mechanics.js.map +1 -1
  39. package/node_modules/@gdh/adapters/dist/skill-rendering.d.ts.map +1 -1
  40. package/node_modules/@gdh/adapters/dist/skill-rendering.js +25 -0
  41. package/node_modules/@gdh/adapters/dist/skill-rendering.js.map +1 -1
  42. package/node_modules/@gdh/adapters/package.json +8 -8
  43. package/node_modules/@gdh/authoring/package.json +2 -2
  44. package/node_modules/@gdh/cli/dist/index.d.ts +9 -0
  45. package/node_modules/@gdh/cli/dist/index.d.ts.map +1 -1
  46. package/node_modules/@gdh/cli/dist/index.js +244 -2
  47. package/node_modules/@gdh/cli/dist/index.js.map +1 -1
  48. package/node_modules/@gdh/cli/dist/migrate.d.ts +152 -1
  49. package/node_modules/@gdh/cli/dist/migrate.d.ts.map +1 -1
  50. package/node_modules/@gdh/cli/dist/migrate.js +290 -6
  51. package/node_modules/@gdh/cli/dist/migrate.js.map +1 -1
  52. package/node_modules/@gdh/cli/dist/self-update.d.ts +14 -0
  53. package/node_modules/@gdh/cli/dist/self-update.d.ts.map +1 -1
  54. package/node_modules/@gdh/cli/dist/self-update.js +197 -15
  55. package/node_modules/@gdh/cli/dist/self-update.js.map +1 -1
  56. package/node_modules/@gdh/cli/package.json +10 -10
  57. package/node_modules/@gdh/core/dist/index.d.ts +97 -5
  58. package/node_modules/@gdh/core/dist/index.d.ts.map +1 -1
  59. package/node_modules/@gdh/core/dist/index.js +23 -5
  60. package/node_modules/@gdh/core/dist/index.js.map +1 -1
  61. package/node_modules/@gdh/core/dist/migrations/entries/s2c2_to_s2c3_rules_schema_v2_to_v3.d.ts +3 -0
  62. package/node_modules/@gdh/core/dist/migrations/entries/s2c2_to_s2c3_rules_schema_v2_to_v3.d.ts.map +1 -0
  63. package/node_modules/@gdh/core/dist/migrations/entries/s2c2_to_s2c3_rules_schema_v2_to_v3.js +247 -0
  64. package/node_modules/@gdh/core/dist/migrations/entries/s2c2_to_s2c3_rules_schema_v2_to_v3.js.map +1 -0
  65. package/node_modules/@gdh/core/dist/migrations/entries/s3c8_to_s3c9_register_runtime_bridge_autoload.d.ts +3 -0
  66. package/node_modules/@gdh/core/dist/migrations/entries/s3c8_to_s3c9_register_runtime_bridge_autoload.d.ts.map +1 -0
  67. package/node_modules/@gdh/core/dist/migrations/entries/s3c8_to_s3c9_register_runtime_bridge_autoload.js +152 -0
  68. package/node_modules/@gdh/core/dist/migrations/entries/s3c8_to_s3c9_register_runtime_bridge_autoload.js.map +1 -0
  69. package/node_modules/@gdh/core/dist/migrations/envelopes/envelope-output-validator.d.ts +3 -0
  70. package/node_modules/@gdh/core/dist/migrations/envelopes/envelope-output-validator.d.ts.map +1 -0
  71. package/node_modules/@gdh/core/dist/migrations/envelopes/envelope-output-validator.js +67 -0
  72. package/node_modules/@gdh/core/dist/migrations/envelopes/envelope-output-validator.js.map +1 -0
  73. package/node_modules/@gdh/core/dist/migrations/envelopes/index.d.ts +37 -0
  74. package/node_modules/@gdh/core/dist/migrations/envelopes/index.d.ts.map +1 -0
  75. package/node_modules/@gdh/core/dist/migrations/envelopes/index.js +60 -0
  76. package/node_modules/@gdh/core/dist/migrations/envelopes/index.js.map +1 -0
  77. package/node_modules/@gdh/core/dist/migrations/envelopes/types.d.ts +121 -0
  78. package/node_modules/@gdh/core/dist/migrations/envelopes/types.d.ts.map +1 -0
  79. package/node_modules/@gdh/core/dist/migrations/envelopes/types.js +2 -0
  80. package/node_modules/@gdh/core/dist/migrations/envelopes/types.js.map +1 -0
  81. package/node_modules/@gdh/core/dist/migrations/golden-harness.d.ts +40 -0
  82. package/node_modules/@gdh/core/dist/migrations/golden-harness.d.ts.map +1 -0
  83. package/node_modules/@gdh/core/dist/migrations/golden-harness.js +71 -0
  84. package/node_modules/@gdh/core/dist/migrations/golden-harness.js.map +1 -0
  85. package/node_modules/@gdh/core/dist/migrations/managed-surface-classes.d.ts +322 -0
  86. package/node_modules/@gdh/core/dist/migrations/managed-surface-classes.d.ts.map +1 -0
  87. package/node_modules/@gdh/core/dist/migrations/managed-surface-classes.js +384 -0
  88. package/node_modules/@gdh/core/dist/migrations/managed-surface-classes.js.map +1 -0
  89. package/node_modules/@gdh/core/dist/migrations/probes.d.ts +58 -0
  90. package/node_modules/@gdh/core/dist/migrations/probes.d.ts.map +1 -0
  91. package/node_modules/@gdh/core/dist/migrations/probes.js +112 -0
  92. package/node_modules/@gdh/core/dist/migrations/probes.js.map +1 -0
  93. package/node_modules/@gdh/core/dist/migrations/registry.d.ts +205 -0
  94. package/node_modules/@gdh/core/dist/migrations/registry.d.ts.map +1 -0
  95. package/node_modules/@gdh/core/dist/migrations/registry.js +214 -0
  96. package/node_modules/@gdh/core/dist/migrations/registry.js.map +1 -0
  97. package/node_modules/@gdh/core/dist/state/atomic-write.d.ts +19 -0
  98. package/node_modules/@gdh/core/dist/state/atomic-write.d.ts.map +1 -0
  99. package/node_modules/@gdh/core/dist/state/atomic-write.js +34 -0
  100. package/node_modules/@gdh/core/dist/state/atomic-write.js.map +1 -0
  101. package/node_modules/@gdh/core/dist/state/migration-state.d.ts +135 -0
  102. package/node_modules/@gdh/core/dist/state/migration-state.d.ts.map +1 -0
  103. package/node_modules/@gdh/core/dist/state/migration-state.js +186 -0
  104. package/node_modules/@gdh/core/dist/state/migration-state.js.map +1 -0
  105. package/node_modules/@gdh/core/dist/state/processes-snapshot.d.ts +72 -0
  106. package/node_modules/@gdh/core/dist/state/processes-snapshot.d.ts.map +1 -0
  107. package/node_modules/@gdh/core/dist/state/processes-snapshot.js +113 -0
  108. package/node_modules/@gdh/core/dist/state/processes-snapshot.js.map +1 -0
  109. package/node_modules/@gdh/core/dist/state/render-inventory.d.ts +54 -0
  110. package/node_modules/@gdh/core/dist/state/render-inventory.d.ts.map +1 -0
  111. package/node_modules/@gdh/core/dist/state/render-inventory.js +77 -0
  112. package/node_modules/@gdh/core/dist/state/render-inventory.js.map +1 -0
  113. package/node_modules/@gdh/core/package.json +1 -1
  114. package/node_modules/@gdh/docs/dist/agent-contract.d.ts.map +1 -1
  115. package/node_modules/@gdh/docs/dist/agent-contract.js +2 -1
  116. package/node_modules/@gdh/docs/dist/agent-contract.js.map +1 -1
  117. package/node_modules/@gdh/docs/dist/guidance.d.ts.map +1 -1
  118. package/node_modules/@gdh/docs/dist/guidance.js +3 -1
  119. package/node_modules/@gdh/docs/dist/guidance.js.map +1 -1
  120. package/node_modules/@gdh/docs/package.json +2 -2
  121. package/node_modules/@gdh/mcp/package.json +8 -8
  122. package/node_modules/@gdh/observability/package.json +2 -2
  123. package/node_modules/@gdh/runtime/dist/bridge-surface.js +63 -2
  124. package/node_modules/@gdh/runtime/dist/bridge-surface.js.map +1 -1
  125. package/node_modules/@gdh/runtime/package.json +2 -2
  126. package/node_modules/@gdh/scan/package.json +3 -3
  127. package/node_modules/@gdh/verify/package.json +7 -7
  128. package/package.json +11 -11
@@ -1,7 +1,10 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { resolvePinnedVersion, resolvePinnedVersionOrNull, resolveProjectRoot, writePinnedVersion, } from "@gdh/authoring";
4
- import { invalidateGdhUpdateCache, resolveEffectiveDevRepoPath } from "@gdh/core";
3
+ import { readProjectConfig, resolvePinnedVersion, resolvePinnedVersionOrNull, resolveProjectRoot, writePinnedVersion, } from "@gdh/authoring";
4
+ import { GDH_AGENT_CONTRACT_VERSION, GDH_PROJECT_CONFIG_VERSION, MIGRATION_REGISTRY_ENTRIES, applyMigrationChain, deleteProcessesSnapshot, invalidateGdhUpdateCache, readMigrationState, readProcessesSnapshot, readRenderInventory, resolveEffectiveDevRepoPath, writeMigrationState, writeProcessesSnapshot, writeRenderInventory, } from "@gdh/core";
5
+ import { detectClass1MarkerDrift, driftResultToPreservationNotes, readBackupManifest, restoreFromDurableBackup, writeDurableBackup, } from "./durable-backup.js";
6
+ import { runInventorySweep } from "./inventory-sweep.js";
7
+ import { captureProcessSnapshot, defaultProcessOrchestrationEnvironment, restartFromSnapshot, stopCapturedProcesses, } from "./process-orchestration.js";
5
8
  // Dynamic import breaks the circular dependency with ./index.ts.
6
9
  //
7
10
  // self-update-mechanics.ts is re-exported from adapters/index.ts (the barrel).
@@ -18,20 +21,91 @@ async function loadInstallSupportedAgentAdapters() {
18
21
  const { installSupportedAgentAdapters } = await import("./index.js");
19
22
  return installSupportedAgentAdapters;
20
23
  }
24
+ // ---------------------------------------------------------------------------
25
+ // Plan 72-08 — pair resolution helpers (D-10 step 2)
26
+ // ---------------------------------------------------------------------------
21
27
  /**
22
- * Phase 12 MIG-01 + MIG-02 orchestrator. Atomically bumps the .gdh/project.yaml
23
- * `gdh_version` pin and re-bakes every managed surface at the new pin.
28
+ * Resolve the {schema, agentContract} pair for an onboarded target.
24
29
  *
25
- * Sequencing invariant (MIG-02): writePinnedVersion is called BEFORE
26
- * installSupportedAgentAdapters. Because installSupportedAgentAdapters reads
27
- * the pin from disk once per top-level call (adapters/index.ts:173), pinning
28
- * first guarantees every renderer bakes the new value.
30
+ * Returns:
31
+ * - `null` when `.gdh/project.yaml` is absent or unparseable (project not
32
+ * onboarded; bumpAndRebakePin treats this as `blocked` with reason
33
+ * `current_pair_unresolvable`)
34
+ * - the supported-range floor pair `{schema: 2, agentContract: 2}` for any
35
+ * readable target
29
36
  *
30
- * Transactional invariant (MIG-02): on re-bake failure, every captured original
31
- * is restored. Partial state is not acceptable.
37
+ * Why a constant floor instead of the on-disk version? The supported floor
38
+ * pair gates the s2c2_to_s2c3 registry entry's range filter
39
+ * (entry.from = {2,2} must be ≥ fromPair). Per Plan 72-02 audit, every entry
40
+ * in the supported range is idempotent — running it on a target that's
41
+ * already past the entry's `to` pair is a verify-true no-op. Using the
42
+ * constant floor means the chain considers every entry on every run; the
43
+ * entry transforms self-detect "already at HEAD" and skip work, and the
44
+ * entry verify probes self-confirm "rules.yaml at version: 3" regardless of
45
+ * whether the entry just rewrote it or it was already there.
32
46
  *
33
- * SELF-03 leak barrier: dev-mode check gates at the mechanics layer, not only at
34
- * Phase 13 entry points. Single source of truth.
47
+ * The on-disk schema version (`version:` field of `.gdh/project.yaml`) is
48
+ * read defensively to confirm the config is parseable, but is not used to
49
+ * narrow the pair: per the audit, project.yaml `version: 3` has been
50
+ * forward-compatible since v0.18 and does not gate registry entries.
51
+ */
52
+ export async function resolveCurrentMigrationPairOrNull(targetPath) {
53
+ const configPath = path.join(targetPath, ".gdh/project.yaml");
54
+ const content = await fs.readFile(configPath, "utf8").catch(() => null);
55
+ if (content === null)
56
+ return null;
57
+ // Defensive parse: ensure the file has at minimum a recognizable schema
58
+ // header. If parse fails, treat as unresolvable.
59
+ const firstLine = content.split("\n", 1)[0] ?? "";
60
+ if (!/^version:\s*\d+/.test(firstLine))
61
+ return null;
62
+ return { schema: 2, agentContract: 2 };
63
+ }
64
+ /**
65
+ * Resolve the {schema, agentContract} pair for the running GDH version.
66
+ *
67
+ * Always the package's compile-time pair: `GDH_PROJECT_CONFIG_VERSION` and
68
+ * `GDH_AGENT_CONTRACT_VERSION` from `@gdh/core`. This is the chain's `to`
69
+ * pair ceiling — applyMigrationChain runs every entry whose `to` ≤ this pair.
70
+ */
71
+ export function resolveTargetMigrationPair() {
72
+ return {
73
+ schema: GDH_PROJECT_CONFIG_VERSION,
74
+ agentContract: GDH_AGENT_CONTRACT_VERSION,
75
+ };
76
+ }
77
+ /**
78
+ * Phase 12 MIG-01 + MIG-02 + Plan 72-08 D-10 orchestrator. Atomically bumps the
79
+ * `.gdh/project.yaml` `gdh_version` pin, re-bakes every managed surface at the
80
+ * new pin (class-1), runs the migration registry chain (class-2/3 transforms +
81
+ * verify probes), sweeps orphan files, and orchestrates managed-process
82
+ * stop/restart with a deferred-action recovery path on restart failure.
83
+ *
84
+ * The full 12-step ordering (D-10):
85
+ * 1. SELF-03 dev-mode short-circuit (preserved unchanged)
86
+ * 2. Plan: dry-run installSupportedAgentAdapters + resolve from/to pair
87
+ * 3. Capture process snapshot (writeProcessesSnapshot)
88
+ * 4. Stop processes (broker → pruneBroker; mcp → graceful → hard kill)
89
+ * 5. Write durable backup (.gdh-state/backup/) with WFL-02 marker drift detection
90
+ * 6. Write pin (writePinnedVersion) — MIG-02 sequencing precedes final re-bake
91
+ * 7. Re-bake adapters (installSupportedAgentAdapters apply)
92
+ * 8. Apply migration chain (registry entries class-2/3); failure → 8a rollback
93
+ * 9. Inventory set-diff sweep (orphan deletion)
94
+ * 10. Write new render-inventory.json + migration.json
95
+ * 11. Restart processes from snapshot; on all_restarted delete snapshot,
96
+ * on some_failed write process_restart deferred action (D-19)
97
+ * 12. Emit structured BumpAndRebakePinResult
98
+ *
99
+ * On step-8 chain failure: restoreFromDurableBackup re-creates files byte-equal
100
+ * pre-update (writePinnedVersion is rolled back automatically because
101
+ * `.gdh/project.yaml` is in the captured paths). Processes stay stopped per
102
+ * WFL-01 D-18 step 6. Inventory is NOT updated (the new render set was never
103
+ * reached coherently). Result: `rolled_back_from_durable_backup`.
104
+ *
105
+ * Sequencing invariant (MIG-02 + validate-docs Check 43): writePinnedVersion
106
+ * is called textually BEFORE the FINAL installSupportedAgentAdapters call in
107
+ * this file. The dry-run plan call at step 2 reads the current pin; the apply
108
+ * call at step 7 reads the new pin from disk after writePinnedVersion.
35
109
  */
36
110
  export async function bumpAndRebakePin(targetPath, options) {
37
111
  const rawTargetPath = path.resolve(targetPath);
@@ -46,23 +120,74 @@ export async function bumpAndRebakePin(targetPath, options) {
46
120
  currentVersion: await resolvePinnedVersionOrNull(effectiveTargetPath),
47
121
  };
48
122
  }
49
- // Step 2: Read current pin (throws with gdh-migrate-apply hint if unonboarded).
123
+ // Step 2 (plan + pair resolution).
124
+ const fromPair = await resolveCurrentMigrationPairOrNull(effectiveTargetPath);
125
+ if (fromPair === null) {
126
+ return {
127
+ state: "blocked",
128
+ reason: "current_pair_unresolvable: .gdh/project.yaml is missing or unparseable",
129
+ };
130
+ }
131
+ const toPair = resolveTargetMigrationPair();
132
+ // resolvePinnedVersion throws with the gdh-migrate-apply hint if the pin
133
+ // line is missing — but resolveCurrentMigrationPairOrNull already gated
134
+ // the absence-of-config case. Pin-line absence after that is genuine.
50
135
  const fromVersion = await resolvePinnedVersion(effectiveTargetPath);
51
- if (fromVersion === options.targetVersion) {
136
+ // ── Phase 73 D-11 — resume detection ──
137
+ // Read migration.json once for the resume marker. If a marker exists and
138
+ // its `to_pin` matches options.targetVersion, treat this run as a resume of
139
+ // a previously-paused chain: skip the noop early-return AND skip steps 3-7
140
+ // (snapshot capture, stop, durable backup, pin write, re-bake) since they
141
+ // already ran in the original attempt. Re-running steps 5-7 would either be
142
+ // a no-op (idempotent pin write, idempotent re-bake of already-baked
143
+ // surfaces) OR destructive (overwriting the durable backup with post-pause
144
+ // content, which would defeat the all-or-nothing rollback contract). The
145
+ // marker check MUST run BEFORE the `fromVersion === options.targetVersion`
146
+ // early-return below, because on resume the pin has already been written
147
+ // (step 6 of the original attempt) and would otherwise short-circuit as
148
+ // noop.
149
+ const priorState = await readMigrationState(effectiveTargetPath);
150
+ const resumeMarker = priorState?.pending_envelope_resume ?? null;
151
+ const wasResume = resumeMarker !== null && resumeMarker.to_pin === options.targetVersion;
152
+ // Defensive: marker present but for a different target (e.g., manual
153
+ // downgrade or hand-edited migration.json with mismatched to_pin). Clear
154
+ // the stale marker and fall through to the standard pin-equality check —
155
+ // the run continues as a fresh self-update attempt. Mitigates T-73-04-01.
156
+ if (resumeMarker !== null && !wasResume) {
157
+ // exactOptionalPropertyTypes: cannot assign undefined; rebuild without
158
+ // the marker field. envelopes carries forward as-is (recorded-result
159
+ // evidence is independent of the in-flight marker).
160
+ const cleared = {
161
+ migration_version: 1,
162
+ last_applied_at: priorState.last_applied_at,
163
+ deferred_actions: priorState.deferred_actions,
164
+ ...(priorState.envelopes !== undefined
165
+ ? { envelopes: priorState.envelopes }
166
+ : {}),
167
+ };
168
+ await writeMigrationState(effectiveTargetPath, cleared);
169
+ }
170
+ if (!wasResume && fromVersion === options.targetVersion) {
52
171
  return { state: "noop", currentVersion: fromVersion };
53
172
  }
54
- // Step 3: Plan — dry-run to collect actions. File SET is determined by adapter
55
- // status + guidance readiness, not by pin value; planning against current pin
56
- // tells us which files to capture. Per research recommendation (c).
57
173
  const installSupportedAgentAdapters = await loadInstallSupportedAgentAdapters();
58
174
  const plan = await installSupportedAgentAdapters(effectiveTargetPath, {
59
175
  dryRun: true,
60
176
  allowBootstrap: true,
61
177
  });
62
- // Step 4: Capture originals (including .gdh/project.yaml) before any mutation.
63
- const configPath = path.join(effectiveTargetPath, ".gdh/project.yaml");
64
- const originals = await captureOriginals(plan.actions, configPath);
65
178
  if (options.dryRun === true) {
179
+ // WR-04: when the chain is paused on an envelope (resumeMarker is set and
180
+ // wasResume is true), a dry-run cannot truthfully report `updated`:
181
+ // pending_envelope_resume is still on disk and gdh status will continue to
182
+ // emit the envelope-pending advisory. Surface as `blocked` so the agent
183
+ // sees the real next action — record the envelope result and re-run
184
+ // without --dry-run.
185
+ if (wasResume && resumeMarker !== null) {
186
+ return {
187
+ state: "blocked",
188
+ reason: `dry_run_during_envelope_pause: chain is paused on envelope_ref="${resumeMarker.envelope_ref}"; record the result and re-run without --dry-run`,
189
+ };
190
+ }
66
191
  return {
67
192
  state: "updated",
68
193
  fromVersion,
@@ -70,95 +195,451 @@ export async function bumpAndRebakePin(targetPath, options) {
70
195
  actions: plan.actions,
71
196
  };
72
197
  }
73
- // Step 5: COMMIT POINT — write new pin first (MIG-02 sequencing).
74
- // Phase 12 Check 43 asserts writePinnedVersion call textually precedes the
75
- // final installSupportedAgentAdapters call in this source file. Both calls
76
- // are inside bumpAndRebakePin; the grep/awk check targets the function body.
77
- try {
78
- await writePinnedVersion(effectiveTargetPath, options.targetVersion);
198
+ const env = options.processOrchestrationEnvironment ?? defaultProcessOrchestrationEnvironment();
199
+ // Hoisted across the wasResume branch so step 11 (restart) and step 12
200
+ // (emit report) can read them on both fresh and resume paths.
201
+ let preservationNotes = [];
202
+ let rebakeActions = [];
203
+ // captureResult/stopResult are intentionally null on resume; step 11 reads
204
+ // the snapshot via readProcessesSnapshot(effectiveTargetPath) instead.
205
+ let captureResult = null;
206
+ let stopResult = null;
207
+ // Computed inside the !wasResume branch (steps 5/9/10 reference it). On
208
+ // resume, step 9 has already run in the original attempt — but we still
209
+ // re-derive renderedRelativePaths from the planned actions before writing
210
+ // render-inventory.json and migration.json so success-write parity is
211
+ // preserved.
212
+ let allPlannedRelativePaths = [];
213
+ if (wasResume) {
214
+ // Steps 3-7 already ran in the original attempt:
215
+ // - Step 3 (capture snapshot) — processes-snapshot.json persists on disk
216
+ // - Step 4 (stop) — stop_method-per-row recorded in that snapshot
217
+ // - Step 5 (drift detect + durable backup) — backup persists; manifest
218
+ // carries `preservation_notes` from the original drift detection
219
+ // - Step 6 (pin write) — pin already at options.targetVersion (so the
220
+ // noop early-return above had to be guarded by !wasResume). Even if
221
+ // re-invoked, writePinnedVersion is idempotent for the same target.
222
+ // - Step 7 (re-bake) — surfaces are already re-baked
223
+ // Re-taking the durable backup here would clobber the pre-update content
224
+ // captured originally — defeating the rollback substrate (T-73-04-02).
225
+ //
226
+ // Load preservation_notes from the durable backup manifest written in the
227
+ // original step 5. If the manifest is unreadable (missing or schema-
228
+ // mismatched), fall back to empty notes — the agent already saw the
229
+ // original report when the chain paused, so this is informational only.
230
+ const manifest = await readBackupManifest(effectiveTargetPath);
231
+ preservationNotes = manifest?.preservation_notes ?? [];
232
+ // Also re-derive the planned action paths so steps 9 and 10 (inventory
233
+ // sweep + render-inventory write) see the same surfaces the original
234
+ // attempt computed.
235
+ allPlannedRelativePaths = computePlannedRelativePaths(plan.actions, effectiveTargetPath);
79
236
  }
80
- catch (error) {
81
- // Nothing else mutated yet — no rollback needed.
82
- return { state: "blocked", reason: `Pin write failed: ${formatError(error)}` };
83
- }
84
- // Step 6: Re-bake — installSupportedAgentAdapters reads the new pin from disk.
85
- try {
86
- const rebake = await installSupportedAgentAdapters(effectiveTargetPath, {
87
- allowBootstrap: true,
237
+ else {
238
+ // Step 3: Capture process snapshot.
239
+ captureResult = await captureProcessSnapshot({
240
+ targetPath: effectiveTargetPath,
241
+ env,
88
242
  });
89
- const blocked = rebake.actions.filter((a) => a.state === "blocked");
90
- if (blocked.length > 0) {
91
- throw new Error(`Re-bake had ${blocked.length} blocked action(s): ${blocked
92
- .map((a) => a.summary)
93
- .join("; ")}`);
243
+ // WR-01: Snapshot write is the recovery anchor for the stop step. If we
244
+ // cannot persist the captured snapshot, do NOT proceed to stop processes —
245
+ // a SIGTERM with no recorded snapshot leaves the operator with stopped
246
+ // processes and no rows to drive restart. Convert to blocked so the
247
+ // structured BumpAndRebakePinResult shape is preserved through the CLI.
248
+ try {
249
+ await writeProcessesSnapshot(effectiveTargetPath, captureResult.snapshot);
94
250
  }
95
- // Phase 16 UPD-05 Fix B: invalidate the shared update-check cache so the
96
- // Claude statusline (which reads cache.updateAvailable directly, bypassing
97
- // readGdhUpdateMetaOrNull) clears its update indicator in the same session
98
- // and every other reader gets a clean-slate re-probe on the next
99
- // SessionStart hook. Structurally gated to the "updated" return only:
100
- // rolled_back / blocked / noop / skipped_dev_mode all return earlier.
101
- // Kept inside the try so any future throw here routes through rollback.
102
- await invalidateGdhUpdateCache();
251
+ catch (error) {
252
+ return {
253
+ state: "blocked",
254
+ reason: `processes_snapshot_write_failed: ${formatError(error)}`,
255
+ };
256
+ }
257
+ // Step 4: Stop processes; persist updated snapshot (with stop_method per row).
258
+ stopResult = await stopCapturedProcesses({
259
+ snapshot: captureResult.snapshot,
260
+ env,
261
+ targetPath: effectiveTargetPath,
262
+ });
263
+ // WR-01: Stop has already happened — by the time we get here processes are
264
+ // SIGTERM'd. A failed snapshot write at this point is still recoverable
265
+ // contractually (the captured snapshot from step 3 is on disk) but we
266
+ // surface the failure as blocked so downstream consumers (D-15/D-18) see
267
+ // the structured shape rather than a thrown exception.
268
+ try {
269
+ await writeProcessesSnapshot(effectiveTargetPath, stopResult.updatedSnapshot);
270
+ }
271
+ catch (error) {
272
+ return {
273
+ state: "blocked",
274
+ reason: `processes_snapshot_write_failed: ${formatError(error)}`,
275
+ };
276
+ }
277
+ // Step 5: WFL-02 drift detection + durable backup.
278
+ allPlannedRelativePaths = computePlannedRelativePaths(plan.actions, effectiveTargetPath);
279
+ // Approximate class-1 set: the plan's actions include skill rendering
280
+ // (per-target paths under .claude/.codex/.cursor + .gdh/runtime-knowledge)
281
+ // which are deterministic class-1 surfaces. We feed every plan-derived
282
+ // relative path through detectClass1MarkerDrift; KNOWN_GDH_MARKERS only
283
+ // matches files that GDH actually rendered (those carry the marker), so
284
+ // false positives degrade to manifest entries — never silent loss.
285
+ const driftResult = await detectClass1MarkerDrift({
286
+ targetPath: effectiveTargetPath,
287
+ plannedClass1Paths: allPlannedRelativePaths,
288
+ });
289
+ preservationNotes = driftResultToPreservationNotes(driftResult);
290
+ const allPlannedPaths = uniquePaths([
291
+ ".gdh/project.yaml",
292
+ ...allPlannedRelativePaths,
293
+ ]);
294
+ const fromPin = (await resolvePinnedVersionOrNull(effectiveTargetPath)) ?? "";
295
+ // WR-03: defensive cross-check before clobbering the durable backup. If we
296
+ // are on the fresh-run path (!wasResume), no resume marker is set, but a
297
+ // backup manifest already exists, the most likely explanation is that
298
+ // migration.json was deleted (or hand-edited) between a paused chain and
299
+ // this run. Overwriting the existing backup with post-pause content would
300
+ // destroy the rollback substrate (T-73-04-02). Surface as blocked so the
301
+ // operator can run `gdh migration clear-backups` after confirming state.
302
+ const existingManifest = await readBackupManifest(effectiveTargetPath);
303
+ if (existingManifest !== null) {
304
+ return {
305
+ state: "blocked",
306
+ reason: "stale_durable_backup_no_marker: a durable backup exists but migration.json has no pending_envelope_resume marker; run `gdh migration clear-backups` after confirming the workspace state",
307
+ };
308
+ }
309
+ await writeDurableBackup({
310
+ targetPath: effectiveTargetPath,
311
+ fromPair,
312
+ toPair,
313
+ fromPin,
314
+ toPin: options.targetVersion,
315
+ plannedPaths: allPlannedPaths,
316
+ preservationNotes,
317
+ });
318
+ // Step 6: COMMIT POINT — write new pin first (MIG-02 sequencing; Check 43).
319
+ try {
320
+ await writePinnedVersion(effectiveTargetPath, options.targetVersion);
321
+ }
322
+ catch (error) {
323
+ // Pin write failed before the chain ran. Backup is intact; restore so
324
+ // .gdh/project.yaml is unchanged and the operator can re-run.
325
+ await restoreFromDurableBackup({ targetPath: effectiveTargetPath });
326
+ return {
327
+ state: "blocked",
328
+ reason: `Pin write failed: ${formatError(error)}`,
329
+ };
330
+ }
331
+ // Step 7: Re-bake adapters (installSupportedAgentAdapters reads the new pin).
332
+ try {
333
+ const rebake = await installSupportedAgentAdapters(effectiveTargetPath, {
334
+ allowBootstrap: true,
335
+ });
336
+ rebakeActions = rebake.actions;
337
+ const blocked = rebake.actions.filter((a) => a.state === "blocked");
338
+ if (blocked.length > 0) {
339
+ throw new Error(`Re-bake had ${blocked.length} blocked action(s): ${blocked
340
+ .map((a) => a.summary)
341
+ .join("; ")}`);
342
+ }
343
+ // Step 7b: Repair the runtime bridge surface. Without this, self-update
344
+ // re-bakes the managed adapters and addon files but leaves project.godot
345
+ // missing the GDHBridge autoload, which `inspectProjectLifecycleCompatibility`
346
+ // surfaces as `compatibility_degraded` with reasons
347
+ // `runtime_bridge_plugin_enable_required` /
348
+ // `runtime_bridge_autoload_missing_or_drifted`. The repair pass runs the
349
+ // v1.18 register_autoload class-3 op deterministically (see
350
+ // GDH_MANAGED_SURFACE_CLASSES `project_godot.allowedOps`), so the chain
351
+ // matrix can prove "every supported version reaches HEAD via gdh
352
+ // self-update without manual intervention".
353
+ const projectConfigForBridge = await readProjectConfig(effectiveTargetPath);
354
+ if (projectConfigForBridge !== null) {
355
+ const { repairRuntimeBridgeSurface } = await import("@gdh/runtime");
356
+ await repairRuntimeBridgeSurface({
357
+ targetPath: effectiveTargetPath,
358
+ projectConfig: projectConfigForBridge,
359
+ dryRun: false,
360
+ });
361
+ }
362
+ // Phase 16 UPD-05 Fix B: invalidate the shared update-check cache so the
363
+ // Claude statusline clears its update indicator in the same session.
364
+ await invalidateGdhUpdateCache();
365
+ }
366
+ catch (error) {
367
+ // Re-bake failure is treated as a chain-equivalent rollback path: restore
368
+ // from durable backup so pin + class-1 surfaces revert atomically.
369
+ const restore = await restoreFromDurableBackup({
370
+ targetPath: effectiveTargetPath,
371
+ });
372
+ return {
373
+ state: "rolled_back_from_durable_backup",
374
+ // WR-03: surface the resolved fromVersion (line 266) — never `fromPin`,
375
+ // which uses the OrNull `?? ""` fallback and would silently coerce a
376
+ // contractually-impossible null into "" and violate the typed
377
+ // `BumpAndRebakePinResult.fromVersion: string` semver invariant.
378
+ fromVersion,
379
+ attemptedToVersion: options.targetVersion,
380
+ failureReason: {
381
+ kind: "transform_threw",
382
+ entryId: "rebake_pre_chain",
383
+ error: formatError(error),
384
+ },
385
+ preservationNotes,
386
+ restoredPaths: restore.state === "restored" ? restore.restored : [],
387
+ removedPaths: restore.state === "restored" ? restore.removed : [],
388
+ // WR-06: surface aggregate restore-failure count so callers don't
389
+ // mistake a totally-failed restore for a clean rollback.
390
+ restoreFailedCount: restore.state === "restored" ? restore.failedCount : 0,
391
+ };
392
+ }
393
+ }
394
+ // Step 8: Apply migration chain. Pause on needs_envelope (Phase 73 D-09).
395
+ // Verify/transform failure → 8a rollback.
396
+ const chainResult = await applyMigrationChain(effectiveTargetPath, fromPair, toPair, undefined, // entries — default to MIGRATION_REGISTRY_ENTRIES
397
+ // Plan 73-03 envelopePinContext: pin the chain's D-10 staleness check to
398
+ // this bump's pair. Production callers always pass this so the gate
399
+ // doesn't fail closed unconditionally.
400
+ { fromPin: fromVersion, toPin: options.targetVersion });
401
+ // Phase 73 D-11 — chain paused on an unresolved envelope_ref. Atomically
402
+ // write the in-flight resume marker (mirrors WR-02 receipt-before-cleanup:
403
+ // the marker is the resume substrate, distinct from the migration.json
404
+ // success receipt). Read fresh state to preserve any existing envelopes
405
+ // slot history (D-08) and deferred actions across the marker write.
406
+ if (chainResult.state === "needs_envelope") {
407
+ const currentState = await readMigrationState(effectiveTargetPath);
408
+ const pauseState = {
409
+ migration_version: 1,
410
+ last_applied_at: currentState?.last_applied_at ?? {
411
+ package: fromVersion,
412
+ schema: fromPair.schema,
413
+ agentContract: fromPair.agentContract,
414
+ },
415
+ deferred_actions: currentState?.deferred_actions ?? [],
416
+ ...(currentState?.envelopes !== undefined
417
+ ? { envelopes: currentState.envelopes }
418
+ : {}),
419
+ pending_envelope_resume: {
420
+ envelope_ref: chainResult.envelopeRef,
421
+ from_pin: fromVersion,
422
+ to_pin: options.targetVersion,
423
+ },
424
+ };
425
+ await writeMigrationState(effectiveTargetPath, pauseState);
426
+ // Durable backup persists; processes-snapshot persists; we do NOT call
427
+ // deleteProcessesSnapshot or restoreFromDurableBackup here. The agent
428
+ // runs the envelope, calls `gdh migration record-envelope-result` (Plan
429
+ // 73-05), then re-runs `gdh self-update` which resumes from step 8 with
430
+ // wasResume === true.
103
431
  return {
104
- state: "updated",
432
+ state: "needs_envelope",
105
433
  fromVersion,
106
- toVersion: options.targetVersion,
107
- actions: rebake.actions,
434
+ attemptedToVersion: options.targetVersion,
435
+ envelopeRef: chainResult.envelopeRef,
436
+ envelope: chainResult.envelope,
437
+ preservationNotes,
438
+ priorlyApplied: chainResult.applied,
108
439
  };
109
440
  }
110
- catch (error) {
111
- const restoreFailures = await rollbackOriginals(originals);
441
+ if (chainResult.state === "verify_failed" ||
442
+ chainResult.state === "transform_threw") {
443
+ const restore = await restoreFromDurableBackup({
444
+ targetPath: effectiveTargetPath,
445
+ });
446
+ const failureReason = chainResult.state === "verify_failed"
447
+ ? ({
448
+ kind: "verify_failed",
449
+ entryId: chainResult.failedEntryId,
450
+ reason: chainResult.reason,
451
+ })
452
+ : ({
453
+ kind: "transform_threw",
454
+ entryId: chainResult.failedEntryId,
455
+ error: chainResult.error,
456
+ });
112
457
  return {
113
- state: "rolled_back",
458
+ state: "rolled_back_from_durable_backup",
459
+ // WR-03: see WR-03 comment above — use the resolved fromVersion from
460
+ // line 266 (typed `string` semver), not `fromPin` which falls back to "".
114
461
  fromVersion,
115
462
  attemptedToVersion: options.targetVersion,
116
- failureReason: formatError(error),
117
- restoreFailures,
463
+ failureReason,
464
+ preservationNotes,
465
+ restoredPaths: restore.state === "restored" ? restore.restored : [],
466
+ removedPaths: restore.state === "restored" ? restore.removed : [],
467
+ // WR-06: same as above — surface aggregate restore-failure count.
468
+ restoreFailedCount: restore.state === "restored" ? restore.failedCount : 0,
469
+ };
470
+ }
471
+ // Step 9: Inventory set-diff sweep.
472
+ const oldInventory = await readRenderInventory(effectiveTargetPath);
473
+ const supplementaryDeletions = chainResult.state === "applied"
474
+ ? chainResult.applied.flatMap((id) => {
475
+ const entry = MIGRATION_REGISTRY_ENTRIES.find((e) => e.id === id);
476
+ return entry?.supplementaryDeletions ?? [];
477
+ })
478
+ : [];
479
+ await runInventorySweep({
480
+ targetPath: effectiveTargetPath,
481
+ oldInventory,
482
+ supplementaryDeletions,
483
+ });
484
+ // Step 10: Write new render-inventory.json + bootstrap migration.json.
485
+ const renderedRelativePaths = uniquePaths(allPlannedRelativePaths);
486
+ await writeRenderInventory(effectiveTargetPath, {
487
+ inventory_version: 1,
488
+ rendered_at: {
489
+ package: options.targetVersion,
490
+ schema: toPair.schema,
491
+ agentContract: toPair.agentContract,
492
+ },
493
+ paths: renderedRelativePaths,
494
+ });
495
+ const baseMigrationState = {
496
+ migration_version: 1,
497
+ last_applied_at: {
498
+ package: options.targetVersion,
499
+ schema: toPair.schema,
500
+ agentContract: toPair.agentContract,
501
+ },
502
+ deferred_actions: [],
503
+ };
504
+ // Step 11: Restart processes from snapshot.
505
+ //
506
+ // On the fresh-run path, stopResult.updatedSnapshot is the in-memory
507
+ // snapshot whose stop_method-per-row was set by the step-4 stop loop. On
508
+ // resume, stopResult is null — step 4 ran in the original attempt, and the
509
+ // on-disk processes-snapshot.json carries the stop_method state we need.
510
+ // Read it fresh from disk in that case (Phase 73 D-11 LOCKED resume-path
511
+ // refactor — captureResult.snapshot.captured_at is undefined on resume, so
512
+ // any deferred-action id below MUST derive from the resume-read snapshot).
513
+ const restartSnapshot = wasResume
514
+ ? await readProcessesSnapshot(effectiveTargetPath)
515
+ : stopResult.updatedSnapshot;
516
+ if (wasResume && restartSnapshot === null) {
517
+ // Defensive degraded-state path: the marker said we were mid-resume but
518
+ // processes-snapshot.json is gone. Cannot restart processes blindly;
519
+ // surface as blocked so the agent re-runs a fresh self-update. Mitigates
520
+ // T-73-04-06.
521
+ return {
522
+ state: "blocked",
523
+ reason: "resume_snapshot_missing: processes-snapshot.json was deleted between pause and resume",
524
+ };
525
+ }
526
+ const restartResult = await restartFromSnapshot({
527
+ snapshot: restartSnapshot,
528
+ env,
529
+ targetPath: effectiveTargetPath,
530
+ });
531
+ if (restartResult.state === "all_restarted") {
532
+ // WR-02: Receipt-before-cleanup ordering. migration.json is the durable
533
+ // success marker (Plan 72-08 D-10 step 12); processes-snapshot.json is the
534
+ // work artifact. If writeMigrationState throws BETWEEN deleteProcessesSnapshot
535
+ // and the migration-state write, the operator is left with snapshot deleted
536
+ // AND migration.json missing — the very state-corruption scenario the
537
+ // staged-write strategy in this phase is designed to prevent. Land the
538
+ // receipt first, then delete the work artifact.
539
+ //
540
+ // Phase 73 D-11: on resume, the success-write must ALSO clear
541
+ // pending_envelope_resume (chain advanced past the envelope). Preserve
542
+ // any existing `envelopes` slot history (D-08) — that is recorded-result
543
+ // evidence and is independent of the in-flight marker.
544
+ const priorAtSuccess = await readMigrationState(effectiveTargetPath);
545
+ const successState = {
546
+ ...baseMigrationState,
547
+ ...(priorAtSuccess?.envelopes !== undefined
548
+ ? { envelopes: priorAtSuccess.envelopes }
549
+ : {}),
550
+ // pending_envelope_resume omitted → cleared. Mitigates T-73-04-03.
551
+ };
552
+ await writeMigrationState(effectiveTargetPath, successState);
553
+ await deleteProcessesSnapshot(effectiveTargetPath);
554
+ // Step 12: Emit report (success).
555
+ return {
556
+ state: "updated",
557
+ fromVersion,
558
+ toVersion: options.targetVersion,
559
+ actions: rebakeActions,
118
560
  };
119
561
  }
562
+ // some_failed → D-19: create a process_restart deferred action; snapshot persists.
563
+ // Phase 73: on resume, captureResult is null, so the deferred-action id
564
+ // MUST derive from restartSnapshot.captured_at — the resume-read snapshot
565
+ // we just used to drive restart, which carries the original step-3 capture
566
+ // timestamp. On the fresh-run path the two values are identical.
567
+ const failedRows = restartResult.failed;
568
+ const snapshotCapturedAt = wasResume
569
+ ? restartSnapshot.captured_at
570
+ : captureResult.snapshot.captured_at;
571
+ const deferredAction = {
572
+ id: `process_restart_${snapshotCapturedAt}`,
573
+ description: "One or more managed processes failed to restart after self-update; the runtime probe will re-attempt on the next gdh status invocation.",
574
+ verify: {
575
+ kind: "process_restart",
576
+ payload: {
577
+ failed_rows: failedRows.map((row) => ({
578
+ kind: row.kind,
579
+ pid: row.pid,
580
+ cmd: row.cmd,
581
+ })),
582
+ },
583
+ },
584
+ status: "pending",
585
+ };
586
+ // Phase 73: clear pending_envelope_resume on the some_failed path too —
587
+ // the chain advanced past the envelope; only the restart was partial.
588
+ // Preserve envelopes slot history (D-08).
589
+ const priorAtPartial = await readMigrationState(effectiveTargetPath);
590
+ const updatedMigrationState = {
591
+ ...baseMigrationState,
592
+ deferred_actions: [deferredAction],
593
+ ...(priorAtPartial?.envelopes !== undefined
594
+ ? { envelopes: priorAtPartial.envelopes }
595
+ : {}),
596
+ // pending_envelope_resume omitted → cleared.
597
+ };
598
+ await writeMigrationState(effectiveTargetPath, updatedMigrationState);
599
+ return {
600
+ state: "updated_with_deferred_actions",
601
+ fromVersion,
602
+ toVersion: options.targetVersion,
603
+ actions: rebakeActions,
604
+ migrationChainResult: chainResult,
605
+ deferredActions: [{ id: deferredAction.id, kind: deferredAction.verify.kind }],
606
+ restartFailedRows: failedRows,
607
+ preservationNotes,
608
+ };
120
609
  }
121
610
  /**
122
- * Capture current content of every file the planned actions will touch, plus the
123
- * pin config. A `null` value means the file did not exist — rollback is `fs.rm`.
124
- * Skips actions with null absolutePath (run_command, write_local_hints abstract actions).
611
+ * Compute workspace-relative POSIX paths for every plan action that has a
612
+ * concrete absolutePath. Skips abstract actions (run_command,
613
+ * write_local_hints) whose absolutePath is null.
614
+ *
615
+ * WR-08: When an action's `absolutePath` is non-null but resolves OUTSIDE
616
+ * `effectiveTargetPath`, this is a defensive invariant violation — adapter
617
+ * outputs are GDH-controlled, so a violation indicates a real upstream bug
618
+ * (e.g., wrong targetPath resolution upstream) that would silently leave the
619
+ * surface uncaptured by the durable backup and defeat the rollback contract
620
+ * for that surface. Fail loud rather than silently dropping the action.
125
621
  */
126
- async function captureOriginals(plannedActions, pinConfigPath) {
127
- const originals = new Map();
128
- originals.set(pinConfigPath, await fs.readFile(pinConfigPath, "utf8"));
622
+ function computePlannedRelativePaths(plannedActions, effectiveTargetPath) {
623
+ const relativePaths = [];
129
624
  for (const action of plannedActions) {
130
625
  if (action.absolutePath === null)
131
626
  continue;
132
- if (originals.has(action.absolutePath))
133
- continue;
134
- const existing = await fs
135
- .readFile(action.absolutePath, "utf8")
136
- .catch(() => null);
137
- originals.set(action.absolutePath, existing);
138
- }
139
- return originals;
140
- }
141
- /**
142
- * Best-effort restore. Iterates captured originals; restores content or rm's
143
- * previously-absent files. Collects failure messages but continues (rollback-soldier
144
- * pattern: partial recovery > aborting mid-rollback).
145
- */
146
- async function rollbackOriginals(originals) {
147
- const failures = [];
148
- for (const [absolutePath, content] of originals) {
149
- try {
150
- if (content === null) {
151
- await fs.rm(absolutePath, { force: true });
152
- }
153
- else {
154
- await fs.writeFile(absolutePath, content, "utf8");
155
- }
156
- }
157
- catch (error) {
158
- failures.push(`${absolutePath}: ${error instanceof Error ? error.message : String(error)}`);
627
+ const rel = path.relative(effectiveTargetPath, action.absolutePath);
628
+ // WR-08: defensive invariant — adapter outputs are GDH-controlled.
629
+ // Out-of-workspace absolutePath signals an upstream bug; throw rather
630
+ // than silently dropping the action (which would defeat the durable-
631
+ // backup rollback contract for that surface).
632
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
633
+ throw new Error(`assertion: action ${action.agent}/${action.kind}/${action.scope}` +
634
+ ` (relativePath=${action.relativePath ?? "<null>"}) has absolutePath` +
635
+ ` outside target ${effectiveTargetPath}: ${action.absolutePath}`);
159
636
  }
637
+ relativePaths.push(rel.split(path.sep).join("/"));
160
638
  }
161
- return failures;
639
+ return relativePaths;
640
+ }
641
+ function uniquePaths(paths) {
642
+ return [...new Set(paths)].sort();
162
643
  }
163
644
  function formatError(error) {
164
645
  return error instanceof Error ? error.message : String(error);