@paths.design/caws-cli 11.1.7 → 11.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/dist/index.js +55 -58
  2. package/dist/init/hook-packs/manifest-claude-code.d.ts +1 -1
  3. package/dist/init/hook-packs/manifest-claude-code.d.ts.map +1 -1
  4. package/dist/init/hook-packs/manifest-claude-code.js +260 -2
  5. package/dist/init/hook-packs/manifest-claude-code.js.map +1 -1
  6. package/dist/shell/binding/resolve-binding.d.ts.map +1 -1
  7. package/dist/shell/binding/resolve-binding.js +105 -1
  8. package/dist/shell/binding/resolve-binding.js.map +1 -1
  9. package/dist/shell/binding/types.d.ts +47 -3
  10. package/dist/shell/binding/types.d.ts.map +1 -1
  11. package/dist/shell/command-metadata.d.ts +93 -0
  12. package/dist/shell/command-metadata.d.ts.map +1 -0
  13. package/dist/shell/command-metadata.js +687 -0
  14. package/dist/shell/command-metadata.js.map +1 -0
  15. package/dist/shell/commands/agents.d.ts +1 -2
  16. package/dist/shell/commands/agents.d.ts.map +1 -1
  17. package/dist/shell/commands/claim.d.ts +16 -0
  18. package/dist/shell/commands/claim.d.ts.map +1 -1
  19. package/dist/shell/commands/claim.js +85 -26
  20. package/dist/shell/commands/claim.js.map +1 -1
  21. package/dist/shell/commands/events.d.ts +106 -0
  22. package/dist/shell/commands/events.d.ts.map +1 -0
  23. package/dist/shell/commands/events.js +510 -0
  24. package/dist/shell/commands/events.js.map +1 -0
  25. package/dist/shell/commands/gates.d.ts +2 -2
  26. package/dist/shell/commands/gates.d.ts.map +1 -1
  27. package/dist/shell/commands/gates.js +106 -25
  28. package/dist/shell/commands/gates.js.map +1 -1
  29. package/dist/shell/commands/init.d.ts.map +1 -1
  30. package/dist/shell/commands/init.js +26 -0
  31. package/dist/shell/commands/init.js.map +1 -1
  32. package/dist/shell/commands/prepush.d.ts +26 -0
  33. package/dist/shell/commands/prepush.d.ts.map +1 -0
  34. package/dist/shell/commands/prepush.js +373 -0
  35. package/dist/shell/commands/prepush.js.map +1 -0
  36. package/dist/shell/commands/scope.d.ts.map +1 -1
  37. package/dist/shell/commands/scope.js +31 -1
  38. package/dist/shell/commands/scope.js.map +1 -1
  39. package/dist/shell/commands/specs.d.ts +44 -3
  40. package/dist/shell/commands/specs.d.ts.map +1 -1
  41. package/dist/shell/commands/specs.js +411 -15
  42. package/dist/shell/commands/specs.js.map +1 -1
  43. package/dist/shell/commands/worktree.d.ts.map +1 -1
  44. package/dist/shell/commands/worktree.js +51 -1
  45. package/dist/shell/commands/worktree.js.map +1 -1
  46. package/dist/shell/gates/disposition.d.ts.map +1 -1
  47. package/dist/shell/gates/disposition.js +43 -2
  48. package/dist/shell/gates/disposition.js.map +1 -1
  49. package/dist/shell/index.d.ts +10 -4
  50. package/dist/shell/index.d.ts.map +1 -1
  51. package/dist/shell/index.js +22 -2
  52. package/dist/shell/index.js.map +1 -1
  53. package/dist/shell/legacy-command-map.js +832 -0
  54. package/dist/shell/push-range/classify-range.d.ts +99 -0
  55. package/dist/shell/push-range/classify-range.d.ts.map +1 -0
  56. package/dist/shell/push-range/classify-range.js +155 -0
  57. package/dist/shell/push-range/classify-range.js.map +1 -0
  58. package/dist/shell/push-range/scope-match.d.ts +13 -0
  59. package/dist/shell/push-range/scope-match.d.ts.map +1 -0
  60. package/dist/shell/push-range/scope-match.js +53 -0
  61. package/dist/shell/push-range/scope-match.js.map +1 -0
  62. package/dist/shell/register.d.ts.map +1 -1
  63. package/dist/shell/register.js +263 -228
  64. package/dist/shell/register.js.map +1 -1
  65. package/dist/shell/registered-command-groups.js +48 -0
  66. package/dist/shell/rules.d.ts +19 -0
  67. package/dist/shell/rules.d.ts.map +1 -1
  68. package/dist/shell/rules.js +27 -0
  69. package/dist/shell/rules.js.map +1 -1
  70. package/dist/shell/session/resolve-session.d.ts +29 -1
  71. package/dist/shell/session/resolve-session.d.ts.map +1 -1
  72. package/dist/shell/session/resolve-session.js +817 -11
  73. package/dist/shell/session/resolve-session.js.map +1 -1
  74. package/dist/shell/session/types.d.ts +127 -1
  75. package/dist/shell/session/types.d.ts.map +1 -1
  76. package/dist/shell/session/types.js +10 -4
  77. package/dist/shell/session/types.js.map +1 -1
  78. package/dist/store/doctor-snapshot.d.ts.map +1 -1
  79. package/dist/store/doctor-snapshot.js +26 -0
  80. package/dist/store/doctor-snapshot.js.map +1 -1
  81. package/dist/store/events-migration.d.ts +207 -0
  82. package/dist/store/events-migration.d.ts.map +1 -0
  83. package/dist/store/events-migration.js +358 -0
  84. package/dist/store/events-migration.js.map +1 -0
  85. package/dist/store/events-store.d.ts +47 -1
  86. package/dist/store/events-store.d.ts.map +1 -1
  87. package/dist/store/events-store.js +278 -0
  88. package/dist/store/events-store.js.map +1 -1
  89. package/dist/store/git-autocommit.d.ts +46 -0
  90. package/dist/store/git-autocommit.d.ts.map +1 -0
  91. package/dist/store/git-autocommit.js +198 -0
  92. package/dist/store/git-autocommit.js.map +1 -0
  93. package/dist/store/index.d.ts +4 -1
  94. package/dist/store/index.d.ts.map +1 -1
  95. package/dist/store/index.js +7 -1
  96. package/dist/store/index.js.map +1 -1
  97. package/dist/store/leases-store.d.ts.map +1 -1
  98. package/dist/store/leases-store.js +58 -0
  99. package/dist/store/leases-store.js.map +1 -1
  100. package/dist/store/rules.d.ts +53 -0
  101. package/dist/store/rules.d.ts.map +1 -1
  102. package/dist/store/rules.js +54 -0
  103. package/dist/store/rules.js.map +1 -1
  104. package/dist/store/specs-migration.d.ts +128 -0
  105. package/dist/store/specs-migration.d.ts.map +1 -0
  106. package/dist/store/specs-migration.js +481 -0
  107. package/dist/store/specs-migration.js.map +1 -0
  108. package/dist/store/specs-store.d.ts.map +1 -1
  109. package/dist/store/specs-store.js +14 -2
  110. package/dist/store/specs-store.js.map +1 -1
  111. package/dist/store/specs-writer.d.ts +130 -3
  112. package/dist/store/specs-writer.d.ts.map +1 -1
  113. package/dist/store/specs-writer.js +941 -102
  114. package/dist/store/specs-writer.js.map +1 -1
  115. package/dist/store/types.d.ts +6 -0
  116. package/dist/store/types.d.ts.map +1 -1
  117. package/dist/store/waivers-store.d.ts.map +1 -1
  118. package/dist/store/waivers-store.js +8 -1
  119. package/dist/store/waivers-store.js.map +1 -1
  120. package/dist/store/worktrees-writer.d.ts +28 -0
  121. package/dist/store/worktrees-writer.d.ts.map +1 -1
  122. package/dist/store/worktrees-writer.js +110 -12
  123. package/dist/store/worktrees-writer.js.map +1 -1
  124. package/package.json +5 -2
  125. package/templates/hook-packs/claude-code/CLAUDE.md +7 -1
  126. package/templates/hook-packs/claude-code/agent-heartbeat.sh +1 -1
  127. package/templates/hook-packs/claude-code/agent-register.sh +1 -1
  128. package/templates/hook-packs/claude-code/agent-stop.sh +1 -1
  129. package/templates/hook-packs/claude-code/audit.sh +1 -1
  130. package/templates/hook-packs/claude-code/block-dangerous.sh +1 -1
  131. package/templates/hook-packs/claude-code/classify_command.py +1 -1
  132. package/templates/hook-packs/claude-code/cwd-guard.sh +30 -0
  133. package/templates/hook-packs/claude-code/dispatch/post_tool_use.sh +15 -4
  134. package/templates/hook-packs/claude-code/dispatch/pre_tool_use.sh +10 -2
  135. package/templates/hook-packs/claude-code/dispatch/session_start.sh +1 -1
  136. package/templates/hook-packs/claude-code/dispatch/stop.sh +2 -2
  137. package/templates/hook-packs/claude-code/duplicate-export-check.sh +156 -0
  138. package/templates/hook-packs/claude-code/god-object-check.sh +102 -0
  139. package/templates/hook-packs/claude-code/guard-strikes.sh +1 -1
  140. package/templates/hook-packs/claude-code/lib/parse-input.sh +115 -1
  141. package/templates/hook-packs/claude-code/lib/run-handlers.sh +1 -1
  142. package/templates/hook-packs/claude-code/loc-delta-check.sh +91 -0
  143. package/templates/hook-packs/claude-code/naming-check.sh +128 -0
  144. package/templates/hook-packs/claude-code/plan-transcript-finalize.sh +59 -0
  145. package/templates/hook-packs/claude-code/plan-transcript-snapshot.sh +86 -0
  146. package/templates/hook-packs/claude-code/protected-paths.sh +59 -0
  147. package/templates/hook-packs/claude-code/quiet-merge.sh +68 -0
  148. package/templates/hook-packs/claude-code/reset-danger-latch.sh +1 -1
  149. package/templates/hook-packs/claude-code/reset-strikes.sh +1 -1
  150. package/templates/hook-packs/claude-code/runtime-paths.sh +1 -1
  151. package/templates/hook-packs/claude-code/scan-secrets.sh +98 -0
  152. package/templates/hook-packs/claude-code/scope-guard.sh +47 -65
  153. package/templates/hook-packs/claude-code/session-caws-status.sh +1 -1
  154. package/templates/hook-packs/claude-code/session-log.sh +1 -1
  155. package/templates/hook-packs/claude-code/session_log_renderer.py +956 -0
  156. package/templates/hook-packs/claude-code/shortcut-language-check.sh +147 -0
  157. package/templates/hook-packs/claude-code/worktree-guard.sh +1 -1
  158. package/templates/hook-packs/claude-code/worktree-write-guard.sh +1 -1
@@ -6,6 +6,7 @@
6
6
  // operations:
7
7
  //
8
8
  // createSpec — write a new .caws/specs/<id>.yaml (active) + spec_created
9
+ // activateSpec — raw-byte patch lifecycle_state draft→active + spec_activated
9
10
  // closeSpec — raw-byte patch lifecycle_state/resolution/closure_notes
10
11
  // /updated_at on existing spec + spec_closed
11
12
  // archiveSpec — move .caws/specs/<id>.yaml → .caws/specs/.archive/<id>.yaml,
@@ -57,25 +58,50 @@ var __importStar = (this && this.__importStar) || (function () {
57
58
  })();
58
59
  Object.defineProperty(exports, "__esModule", { value: true });
59
60
  exports.createSpec = createSpec;
61
+ exports.activateSpec = activateSpec;
60
62
  exports.closeSpec = closeSpec;
61
63
  exports.archiveSpec = archiveSpec;
64
+ exports.retireDraftSpec = retireDraftSpec;
62
65
  exports.listSpecs = listSpecs;
63
66
  exports.showSpec = showSpec;
67
+ exports.recoverArchivedSpec = recoverArchivedSpec;
68
+ exports.pruneArchive = pruneArchive;
69
+ const child_process_1 = require("child_process");
64
70
  const fs = __importStar(require("fs"));
65
71
  const path = __importStar(require("path"));
66
72
  const caws_kernel_1 = require("@paths.design/caws-kernel");
67
73
  const events_store_1 = require("./events-store");
74
+ const specs_store_1 = require("./specs-store");
75
+ const git_autocommit_1 = require("./git-autocommit");
68
76
  const lifecycle_transaction_1 = require("./lifecycle-transaction");
69
77
  const lifecycle_lock_1 = require("./lifecycle-lock");
70
78
  const repo_root_1 = require("./repo-root");
71
79
  const rules_1 = require("./rules");
72
- const specs_store_1 = require("./specs-store");
73
80
  const yaml_patch_1 = require("./yaml-patch");
74
81
  const yaml_store_1 = require("./yaml-store");
75
82
  // ─── Path helpers ────────────────────────────────────────────────────────
76
83
  function specPath(cawsDir, id) {
77
84
  return path.join(cawsDir, 'specs', `${id}.yaml`);
78
85
  }
86
+ function repoRootFromCawsDir(cawsDir) {
87
+ return path.dirname(cawsDir);
88
+ }
89
+ function specRelPath(cawsDir, id, repoRoot) {
90
+ return path.relative(repoRoot, specPath(cawsDir, id));
91
+ }
92
+ function hasComplexTopLevelValue(source, key) {
93
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
94
+ const match = source.match(new RegExp(`^${escapedKey}:(.*)$`, 'm'));
95
+ if (match === null)
96
+ return false;
97
+ const rest = (match[1] ?? '').trim();
98
+ return (rest === '' ||
99
+ rest.startsWith('#') ||
100
+ rest.startsWith('|') ||
101
+ rest.startsWith('>') ||
102
+ rest.startsWith('{') ||
103
+ rest.startsWith('['));
104
+ }
79
105
  function archivedSpecPath(cawsDir, id) {
80
106
  return path.join(cawsDir, 'specs', '.archive', `${id}.yaml`);
81
107
  }
@@ -89,6 +115,139 @@ function findSpecPath(cawsDir, id) {
89
115
  return archived;
90
116
  return null;
91
117
  }
118
+ /**
119
+ * TOMBSTONE-SHELL-TEST-RECONCILIATION-001: detect whether a spec id
120
+ * has been archived via the tombstone path (spec_archived event in
121
+ * the event log). CAWS-ARCHIVE-AS-TOMBSTONE-001 removed the
122
+ * `.caws/specs/.archive/<id>.yaml` body write; the spec_archived
123
+ * event is now the authoritative archive signal.
124
+ *
125
+ * Cold-path predicate used only when both `specs/<id>.yaml` AND
126
+ * `specs/.archive/<id>.yaml` are absent. Scans the event log
127
+ * sequentially for a matching `spec_archived` event. Returns true on
128
+ * any match. Returns false if the event log is unreadable or empty
129
+ * (no events means no archive can have happened in this repo).
130
+ *
131
+ * CAWS-SPECS-ARCHIVE-COLLISION-REFUSAL-001: this predicate is now
132
+ * enforcement-grade, not diagnostic-only. createSpec consults it to
133
+ * refuse re-creation of an archived id (tombstone identity). closeSpec
134
+ * still uses it to choose between "archived; cannot close" vs
135
+ * "not found" diagnostics. recover/show/list continue to depend on
136
+ * the event log directly for archived-body retrieval.
137
+ */
138
+ function isArchivedViaTombstone(cawsDir, id) {
139
+ const result = (0, events_store_1.loadEvents)(cawsDir);
140
+ if (!result.ok)
141
+ return false;
142
+ for (const event of result.value.events) {
143
+ const body = event;
144
+ if (body.event === 'spec_archived' && body.spec_id === id) {
145
+ return true;
146
+ }
147
+ }
148
+ return false;
149
+ }
150
+ // ─── Git query helpers (CAWS-ARCHIVE-AS-TOMBSTONE-001) ─────────────────
151
+ //
152
+ // archiveSpec needs to capture the spec yaml's blob_sha + optional
153
+ // source_commit_sha BEFORE removing the file. These helpers wrap
154
+ // execFileSync with the CAWS shell discipline (array args, never raw
155
+ // shell strings) and return null on failure rather than throwing.
156
+ // Failure-tolerant by design: a missing blob means "not in HEAD," not
157
+ // "system is broken."
158
+ function runGitQuery(args, repoRoot, opts = {}) {
159
+ const trim = opts.trim !== false; // default true
160
+ try {
161
+ const output = (0, child_process_1.execFileSync)('git', [...args], {
162
+ cwd: repoRoot,
163
+ encoding: 'utf8',
164
+ stdio: ['ignore', 'pipe', 'pipe'],
165
+ }).toString();
166
+ return trim ? output.trim() : output;
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ }
172
+ /**
173
+ * Return the git blob_sha of a path at HEAD, or null if the path is
174
+ * not tracked at HEAD. Output is a 40-hex string when present.
175
+ *
176
+ * The blob_sha is content-addressed and topology-independent: once
177
+ * recorded in a spec_archived event, `git show <blob_sha>` recovers
178
+ * the body regardless of subsequent commit graph rewrites.
179
+ */
180
+ function gitBlobShaAtHead(repoRoot, relPath) {
181
+ const output = runGitQuery(['ls-tree', 'HEAD', '--', relPath], repoRoot);
182
+ if (output === null || output.length === 0)
183
+ return null;
184
+ // Output shape: "<mode> <type> <sha>\t<path>"
185
+ const parts = output.split(/\s+/);
186
+ if (parts.length < 3)
187
+ return null;
188
+ const sha = parts[2];
189
+ return /^[0-9a-f]{40}$/.test(sha ?? '') ? sha : null;
190
+ }
191
+ /**
192
+ * Return the sha of the commit that last modified the given path, or
193
+ * null if no such commit exists (file never tracked). Recorded for
194
+ * human audit on spec_archived events; NOT used by recover.
195
+ */
196
+ function gitLastCommitForPath(repoRoot, relPath) {
197
+ const output = runGitQuery(['log', '-1', '--format=%H', '--', relPath], repoRoot);
198
+ if (output === null || output.length === 0)
199
+ return null;
200
+ return /^[0-9a-f]{40}$/.test(output) ? output : null;
201
+ }
202
+ // ─── Auto-commit helper (CAWS-SPECS-WRITER-AUTOCOMMIT-001) ──────────────
203
+ //
204
+ // Every successful spec-writer lifecycle transaction commits its yaml
205
+ // change as the final step. Parity with worktrees-writer's
206
+ // autoCommitTransition (worktrees-writer.ts:209). The shared
207
+ // git-autocommit utility handles the three observable states
208
+ // (committed / refused_dirty / skipped_no_git); this helper computes
209
+ // the right inputs and never throws.
210
+ //
211
+ // Pre-write dirty state must be captured by the CALLER, before any
212
+ // writer mutation lands. The utility cannot rederive it after the
213
+ // fact.
214
+ //
215
+ // Root cause this addresses (observed 2026-05-27 during
216
+ // CAWS-WORKTREE-DESTROY-SESSION-RESOLUTION-001 close): without this
217
+ // autocommit, `caws specs close` leaves the spec yaml dirty in the
218
+ // working tree, which then causes a subsequent
219
+ // `caws worktree destroy` to refuse its own audit commit because
220
+ // the dirty spec yaml fails its capturePreWriteState check.
221
+ function autoCommitSpecWrite(cawsDir, specId, action, wasDirtyBeforeWrite, extraPaths = []) {
222
+ const repoRoot = repoRootFromCawsDir(cawsDir);
223
+ const primaryPath = specRelPath(cawsDir, specId, repoRoot);
224
+ const paths = [primaryPath, ...extraPaths];
225
+ const message = `chore(caws): ${action} ${specId}`;
226
+ return (0, git_autocommit_1.autoCommit)({
227
+ repoRoot,
228
+ paths,
229
+ message,
230
+ wasDirtyBeforeWrite,
231
+ });
232
+ }
233
+ /**
234
+ * Wrap a Result<SpecWriterOutcome> with an autoCommit attempt, mirroring
235
+ * the worktrees-writer post-transaction commit pattern. Only attaches
236
+ * data.audit_commit when the inner outcome is `kind: 'success'`.
237
+ * Partial failure or err results pass through unchanged — there is
238
+ * nothing valid to commit when the transaction rolled back.
239
+ */
240
+ function attachAutoCommit(outcome, cawsDir, specId, action, wasDirtyBeforeWrite, extraPaths = []) {
241
+ if (!(0, caws_kernel_1.isOk)(outcome))
242
+ return outcome;
243
+ if (outcome.value.kind !== 'success')
244
+ return outcome;
245
+ const audit = autoCommitSpecWrite(cawsDir, specId, action, wasDirtyBeforeWrite, extraPaths);
246
+ return (0, caws_kernel_1.ok)({
247
+ ...outcome.value,
248
+ data: { audit_commit: audit },
249
+ });
250
+ }
92
251
  // ─── ID validation (mirrors kernel regex) ────────────────────────────────
93
252
  const SPEC_ID_PATTERN = /^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*-\d+[a-z]*$/;
94
253
  function validateSpecId(id) {
@@ -148,6 +307,18 @@ function createSpec(cawsDir, input) {
148
307
  if (existing !== null) {
149
308
  return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, `Spec "${input.id}" already exists at ${existing}.`, { subject: input.id, data: { existing_path: existing } }));
150
309
  }
310
+ // CAWS-SPECS-ARCHIVE-COLLISION-REFUSAL-001: tombstone identity.
311
+ // findSpecPath above checks both active and pre-tombstone archive
312
+ // locations on disk. Post-CAWS-ARCHIVE-AS-TOMBSTONE-001 the active
313
+ // file is deleted and no archive body is written, so an
314
+ // archived-then-erased spec leaves no on-disk trace; only the
315
+ // spec_archived event remains. Without this second check, a
316
+ // re-created spec would silently reuse a tombstoned id, breaking
317
+ // the audit narrative on .caws/events.jsonl (recover/show by id
318
+ // would become ambiguous between archived and current bodies).
319
+ if (isArchivedViaTombstone(cawsDir, input.id)) {
320
+ return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, `Spec "${input.id}" has a prior spec_archived event in .caws/events.jsonl; archived spec ids are tombstoned identities and cannot be re-created. Use \`caws specs recover ${input.id}\` to retrieve the archived body, or choose a different id.`, { subject: input.id, data: { reason: 'archived_tombstone' } }));
321
+ }
151
322
  const targetPath = specPath(cawsDir, input.id);
152
323
  const newBytes = renderInitialSpecYaml(input);
153
324
  // Validate the planned YAML through the kernel BEFORE we write or
@@ -172,6 +343,16 @@ function createSpec(cawsDir, input) {
172
343
  lifecycle_state: input.initialState ?? 'active',
173
344
  },
174
345
  };
346
+ // CAWS-SPECS-WRITER-AUTOCOMMIT-001: capture pre-write dirty state
347
+ // BEFORE the transaction runs. For createSpec on a fresh id, the
348
+ // target path does not yet exist, so isPathDirty returns false —
349
+ // the autocommit will succeed cleanly. We still call it because
350
+ // (a) a stale conflict marker or hand-authored draft at the target
351
+ // path could exist (we already refused that case above via
352
+ // findSpecPath, but defense-in-depth), and (b) the contract is
353
+ // that callers always observe data.audit_commit on success.
354
+ const repoRoot = repoRootFromCawsDir(cawsDir);
355
+ const wasDirtyBeforeWrite = (0, git_autocommit_1.isPathDirty)(repoRoot, specRelPath(cawsDir, input.id, repoRoot));
175
356
  const txnResult = (0, lifecycle_lock_1.withLifecycleLock)(cawsDir, () => (0, lifecycle_transaction_1.runLifecycleTransaction)({
176
357
  cawsDir,
177
358
  plannedWrites: [{ path: targetPath, contents: newBytes }],
@@ -180,7 +361,87 @@ function createSpec(cawsDir, input) {
180
361
  if (!txnResult.ok) {
181
362
  return (0, caws_kernel_1.err)(txnResult.errors);
182
363
  }
183
- return mapTxnToOutcome(txnResult.value, input.id, targetPath);
364
+ const outcome = mapTxnToOutcome(txnResult.value, input.id, targetPath);
365
+ return attachAutoCommit(outcome, cawsDir, input.id, 'create', wasDirtyBeforeWrite);
366
+ }
367
+ // ─── activateSpec ────────────────────────────────────────────────────────
368
+ function activateSpec(cawsDir, input) {
369
+ const idValidation = validateSpecId(input.id);
370
+ if (!idValidation.ok)
371
+ return idValidation;
372
+ const targetPath = specPath(cawsDir, input.id);
373
+ if (!fs.existsSync(targetPath)) {
374
+ return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, `Spec "${input.id}" not found at ${targetPath}.`, { subject: input.id }));
375
+ }
376
+ const sourceResult = (0, yaml_store_1.readYamlSource)(targetPath);
377
+ if (!(0, caws_kernel_1.isOk)(sourceResult))
378
+ return (0, caws_kernel_1.err)(sourceResult.errors);
379
+ const originalBytes = sourceResult.value;
380
+ const parsed = (0, caws_kernel_1.parseAndValidateSpec)(originalBytes);
381
+ if (!(0, caws_kernel_1.isOk)(parsed)) {
382
+ return (0, caws_kernel_1.err)(parsed.errors.map((d) => (0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, d.message, {
383
+ subject: d.subject ?? input.id,
384
+ data: { source_rule: d.rule },
385
+ })));
386
+ }
387
+ const spec = parsed.value;
388
+ if (spec.lifecycle_state !== 'draft') {
389
+ const alternative = spec.lifecycle_state === 'active'
390
+ ? `Spec "${input.id}" is already active.`
391
+ : spec.lifecycle_state === 'closed'
392
+ ? `Use \`caws specs archive ${input.id}\` to archive a closed spec.`
393
+ : `Archived specs cannot be activated. Use \`caws specs recover ${input.id}\` to inspect the archived body.`;
394
+ return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, `Spec "${input.id}" is in lifecycle_state "${spec.lifecycle_state}"; activate only activates drafts. ${alternative}`, { subject: input.id, data: { current_state: spec.lifecycle_state } }));
395
+ }
396
+ const now = (input.now ?? (() => new Date()))().toISOString();
397
+ let patched = originalBytes;
398
+ const step1 = (0, yaml_patch_1.setTopLevelScalar)(patched, 'lifecycle_state', 'active');
399
+ if (!step1.ok)
400
+ return (0, caws_kernel_1.err)(step1.errors);
401
+ patched = step1.value;
402
+ const hasUpdatedAt = /^updated_at:/m.test(patched);
403
+ if (hasUpdatedAt) {
404
+ const step2 = (0, yaml_patch_1.setTopLevelScalar)(patched, 'updated_at', `'${now}'`);
405
+ if (!step2.ok)
406
+ return (0, caws_kernel_1.err)(step2.errors);
407
+ patched = step2.value;
408
+ }
409
+ else {
410
+ const anchor = /^created_at:/m.test(patched) ? 'created_at' : 'lifecycle_state';
411
+ const step2 = (0, yaml_patch_1.insertTopLevelScalarAfter)(patched, anchor, 'updated_at', `'${now}'`);
412
+ if (!step2.ok)
413
+ return (0, caws_kernel_1.err)(step2.errors);
414
+ patched = step2.value;
415
+ }
416
+ const reparsed = (0, caws_kernel_1.parseAndValidateSpec)(patched);
417
+ if (!(0, caws_kernel_1.isOk)(reparsed)) {
418
+ return (0, caws_kernel_1.err)(reparsed.errors.map((d) => (0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, d.message, {
419
+ subject: d.subject ?? input.id,
420
+ data: { source_rule: d.rule, hint: 'planned-bytes validation failed' },
421
+ })));
422
+ }
423
+ const event = {
424
+ event: 'spec_activated',
425
+ ts: now,
426
+ actor: input.actor,
427
+ spec_id: input.id,
428
+ data: {
429
+ previous_lifecycle_state: 'draft',
430
+ lifecycle_state: 'active',
431
+ },
432
+ };
433
+ const repoRoot = repoRootFromCawsDir(cawsDir);
434
+ const wasDirtyBeforeWrite = (0, git_autocommit_1.isPathDirty)(repoRoot, specRelPath(cawsDir, input.id, repoRoot));
435
+ const txnResult = (0, lifecycle_lock_1.withLifecycleLock)(cawsDir, () => (0, lifecycle_transaction_1.runLifecycleTransaction)({
436
+ cawsDir,
437
+ plannedWrites: [{ path: targetPath, contents: patched }],
438
+ events: [event],
439
+ }));
440
+ if (!txnResult.ok) {
441
+ return (0, caws_kernel_1.err)(txnResult.errors);
442
+ }
443
+ const outcome = mapTxnToOutcome(txnResult.value, input.id, targetPath);
444
+ return attachAutoCommit(outcome, cawsDir, input.id, 'activate', wasDirtyBeforeWrite);
184
445
  }
185
446
  // ─── closeSpec ───────────────────────────────────────────────────────────
186
447
  function closeSpec(cawsDir, input) {
@@ -189,10 +450,22 @@ function closeSpec(cawsDir, input) {
189
450
  return idValidation;
190
451
  const targetPath = specPath(cawsDir, input.id);
191
452
  if (!fs.existsSync(targetPath)) {
192
- // Check archive too — if it's already archived, this is a different
193
- // kind of error than "not found."
453
+ // TOMBSTONE-SHELL-TEST-RECONCILIATION-001: archived-id detection
454
+ // moved from `.caws/specs/.archive/<id>.yaml` file existence to
455
+ // the event log. CAWS-ARCHIVE-AS-TOMBSTONE-001 made archive a
456
+ // deletion + spec_archived event (no body written under .archive/),
457
+ // so the legacy file check always returned false post-tombstone
458
+ // and the diagnostic fell through to generic "not found at <path>".
459
+ //
460
+ // The legacy check is preserved as a first pass (pre-tombstone
461
+ // archives may still exist as on-disk bodies; legitimate
462
+ // backward-compat). If the legacy file is absent, scan the event
463
+ // log for a `spec_archived` event matching this id — the
464
+ // authoritative tombstone signal. The scan is O(events) and only
465
+ // happens on the cold path (active file absent), not on every
466
+ // close.
194
467
  const archived = archivedSpecPath(cawsDir, input.id);
195
- if (fs.existsSync(archived)) {
468
+ if (fs.existsSync(archived) || isArchivedViaTombstone(cawsDir, input.id)) {
196
469
  return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, `Spec "${input.id}" is archived; cannot close (legal transitions: active → closed → archived).`, { subject: input.id }));
197
470
  }
198
471
  return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, `Spec "${input.id}" not found at ${targetPath}.`, { subject: input.id }));
@@ -243,10 +516,12 @@ function closeSpec(cawsDir, input) {
243
516
  const escaped = `'${input.reason.replace(/'/g, "''")}'`;
244
517
  const hasNotes = /^closure_notes:/m.test(patched);
245
518
  if (hasNotes) {
246
- const step3 = (0, yaml_patch_1.setTopLevelScalar)(patched, 'closure_notes', escaped);
247
- if (!step3.ok)
248
- return (0, caws_kernel_1.err)(step3.errors);
249
- patched = step3.value;
519
+ if (!hasComplexTopLevelValue(patched, 'closure_notes')) {
520
+ const step3 = (0, yaml_patch_1.setTopLevelScalar)(patched, 'closure_notes', escaped);
521
+ if (!step3.ok)
522
+ return (0, caws_kernel_1.err)(step3.errors);
523
+ patched = step3.value;
524
+ }
250
525
  }
251
526
  else {
252
527
  const step3 = (0, yaml_patch_1.insertTopLevelScalarAfter)(patched, 'resolution', 'closure_notes', escaped);
@@ -255,10 +530,35 @@ function closeSpec(cawsDir, input) {
255
530
  patched = step3.value;
256
531
  }
257
532
  }
258
- const step4 = (0, yaml_patch_1.setTopLevelScalar)(patched, 'updated_at', `'${now}'`);
259
- if (!step4.ok)
260
- return (0, caws_kernel_1.err)(step4.errors);
261
- patched = step4.value;
533
+ // CAWS-MERGE-CLOSE-MISSING-UPDATED-AT-001: insert-or-update fallback
534
+ // for updated_at. Legacy / v10-migrated / hand-authored specs may lack
535
+ // this optional field (it's not in spec.v1.json required[]). Without
536
+ // the fallback, setTopLevelScalar returns YAML_PATCH_KEY_NOT_FOUND
537
+ // here and the close transaction rolls back; the composed
538
+ // mergeWorktree → closeSpec path then reports
539
+ // partial_failure_unrecovered with the underlying patch-key error
540
+ // buried in close_errors. Mirror the has*-check +
541
+ // insertTopLevelScalarAfter pattern used for resolution and
542
+ // closure_notes above. Inline rather than extracted to a helper per
543
+ // the spec's out-of-scope note (the parallel pattern is intentional;
544
+ // helper extraction is a hygiene concern, not a closure blocker).
545
+ const hasUpdatedAt = /^updated_at:/m.test(patched);
546
+ if (hasUpdatedAt) {
547
+ const step4 = (0, yaml_patch_1.setTopLevelScalar)(patched, 'updated_at', `'${now}'`);
548
+ if (!step4.ok)
549
+ return (0, caws_kernel_1.err)(step4.errors);
550
+ patched = step4.value;
551
+ }
552
+ else {
553
+ // Anchor preference: after created_at (natural timestamp pairing)
554
+ // when present, otherwise after lifecycle_state. createSpec always
555
+ // writes created_at so this fallback fires only for legacy specs.
556
+ const anchor = /^created_at:/m.test(patched) ? 'created_at' : 'lifecycle_state';
557
+ const step4 = (0, yaml_patch_1.insertTopLevelScalarAfter)(patched, anchor, 'updated_at', `'${now}'`);
558
+ if (!step4.ok)
559
+ return (0, caws_kernel_1.err)(step4.errors);
560
+ patched = step4.value;
561
+ }
262
562
  // Step 5 (WORKTREE-MERGE-CLEARS-SPEC-BINDING-001):
263
563
  // Clear any top-level `worktree:` binding. A closed spec cannot have
264
564
  // a live worktree binding by definition; leaving the field is what
@@ -303,6 +603,15 @@ function closeSpec(cawsDir, input) {
303
603
  spec_id: input.id,
304
604
  data: eventData,
305
605
  };
606
+ // CAWS-SPECS-WRITER-AUTOCOMMIT-001: capture pre-write dirty state
607
+ // BEFORE the transaction. closeSpec patches an existing yaml, so
608
+ // the path almost always pre-exists; dirty means the user
609
+ // hand-edited it before the close call. autoCommit refuses to
610
+ // overwrite uncommitted user work (data.audit_commit.kind ===
611
+ // 'refused_dirty'); the close itself still applies to the working
612
+ // tree.
613
+ const repoRoot = repoRootFromCawsDir(cawsDir);
614
+ const wasDirtyBeforeWrite = (0, git_autocommit_1.isPathDirty)(repoRoot, specRelPath(cawsDir, input.id, repoRoot));
306
615
  const txnResult = (0, lifecycle_lock_1.withLifecycleLock)(cawsDir, () => (0, lifecycle_transaction_1.runLifecycleTransaction)({
307
616
  cawsDir,
308
617
  plannedWrites: [{ path: targetPath, contents: patched }],
@@ -311,7 +620,8 @@ function closeSpec(cawsDir, input) {
311
620
  if (!txnResult.ok) {
312
621
  return (0, caws_kernel_1.err)(txnResult.errors);
313
622
  }
314
- return mapTxnToOutcome(txnResult.value, input.id, targetPath);
623
+ const outcome = mapTxnToOutcome(txnResult.value, input.id, targetPath);
624
+ return attachAutoCommit(outcome, cawsDir, input.id, 'close', wasDirtyBeforeWrite);
315
625
  }
316
626
  // ─── archiveSpec ─────────────────────────────────────────────────────────
317
627
  function archiveSpec(cawsDir, input) {
@@ -343,67 +653,84 @@ function archiveSpec(cawsDir, input) {
343
653
  return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, `Spec "${input.id}" is in lifecycle_state "${spec.lifecycle_state}"; only closed specs can be archived.`, { subject: input.id, data: { current_state: spec.lifecycle_state } }));
344
654
  }
345
655
  const now = (input.now ?? (() => new Date()))().toISOString();
346
- const toPath = archivedSpecPath(cawsDir, input.id);
347
- // Patch lifecycle_state archived and bump updated_at on a copy.
348
- let patched = originalBytes;
349
- const s1 = (0, yaml_patch_1.setTopLevelScalar)(patched, 'lifecycle_state', 'archived');
350
- if (!s1.ok)
351
- return (0, caws_kernel_1.err)(s1.errors);
352
- patched = s1.value;
353
- const s2 = (0, yaml_patch_1.setTopLevelScalar)(patched, 'updated_at', `'${now}'`);
354
- if (!s2.ok)
355
- return (0, caws_kernel_1.err)(s2.errors);
356
- patched = s2.value;
357
- // Validate.
358
- const reparsed = (0, caws_kernel_1.parseAndValidateSpec)(patched);
359
- if (!(0, caws_kernel_1.isOk)(reparsed)) {
360
- return (0, caws_kernel_1.err)(reparsed.errors.map((d) => (0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, d.message, {
361
- subject: d.subject ?? input.id,
362
- data: { source_rule: d.rule, hint: 'planned-bytes validation failed' },
363
- })));
656
+ // CAWS-ARCHIVE-AS-TOMBSTONE-001 invariant: archive does NOT write
657
+ // a body to .caws/specs/.archive/<id>.yaml. The body is recoverable
658
+ // via git history; the only on-disk mutation is the deletion of the
659
+ // active path.
660
+ //
661
+ // Step ordering (locked inside the lifecycle txn):
662
+ // 1. Capture blob_sha + source_commit_sha BEFORE any mutation
663
+ // (so even if the txn fails, no state has been written).
664
+ // 2. unlink fromPath inside the txn's plannedWrites (modelled as
665
+ // a delete via the new lifecycle-transaction shape, OR
666
+ // executed inside the txn callback for v1).
667
+ // 3. Append the spec_archived event carrying blob_sha (new
668
+ // tombstone shape) NOT to_path.
669
+ // 4. Post-txn, autoCommit stages the deletion via `git add` (which
670
+ // stages deletions when the file is gone).
671
+ //
672
+ // v1 ordering note: lifecycle-transaction.plannedWrites expects
673
+ // {path, contents} pairs (creates/overwrites). It does not model
674
+ // deletions natively. For v1 we execute the unlink inside the
675
+ // txn callback AFTER the event write succeeds; if the unlink
676
+ // fails post-event, we surface partial_failure_unrecovered
677
+ // (same shape as the legacy code did).
678
+ //
679
+ // void input.reason: archive accepts --reason for parity with
680
+ // close but the spec_archived schema does not carry it.
681
+ //
682
+ // CAWS-MERGE-CLOSE-MISSING-UPDATED-AT-001 reconciliation: the
683
+ // archiveSpec absent-`updated_at` patch from this slice was
684
+ // superseded by CAWS-ARCHIVE-AS-TOMBSTONE-001 (merge 2a4cc30).
685
+ // Tombstone eliminates archiveSpec's YAML patch step entirely —
686
+ // there is no `updated_at` to insert into a body that no longer
687
+ // gets written. The closeSpec absent-`updated_at` fix at line ~540
688
+ // remains in force; the archiveSpec branch is now dead code in
689
+ // tombstone-world and has been removed from this slice on merge.
690
+ const repoRoot = repoRootFromCawsDir(cawsDir);
691
+ const fromRel = path.relative(repoRoot, fromPath);
692
+ // Capture BEFORE any mutation. blob_sha is the authoritative
693
+ // recovery target. source_commit_sha is optional human audit.
694
+ const blobSha = gitBlobShaAtHead(repoRoot, fromRel);
695
+ if (blobSha === null) {
696
+ return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, `Spec "${input.id}" is not tracked at HEAD. Cannot archive: blob_sha is the authoritative recovery target, and without it the archive event would have no recovery path. Commit the spec first (or run \`caws specs close <id>\` which auto-commits per CAWS-SPECS-WRITER-AUTOCOMMIT-001), then re-run archive.`, { subject: input.id, data: { from_path: fromRel } }));
364
697
  }
365
- // Ensure the archive dir exists. fs.renameSync requires it.
366
- try {
367
- fs.mkdirSync(path.dirname(toPath), { recursive: true });
698
+ const sourceCommitSha = gitLastCommitForPath(repoRoot, fromRel);
699
+ // Build the event payload in tombstone shape.
700
+ const eventData = {
701
+ from_path: fromRel,
702
+ blob_sha: blobSha,
703
+ };
704
+ if (sourceCommitSha !== null) {
705
+ eventData.source_commit_sha = sourceCommitSha;
368
706
  }
369
- catch (e) {
370
- const cause = e;
371
- return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_WRITE_FAILED, `Failed to create archive directory: ${cause.message ?? 'unknown'}.`, { subject: path.dirname(toPath) }));
372
- }
373
- // Filesystem-move pattern: we write the patched bytes to the new
374
- // path via the transaction, then delete the source. The transaction
375
- // doesn't model moves natively, so we do this in two phases inside
376
- // the same lock:
377
- // 1. lifecycle-transaction: write toPath + append spec_archived
378
- // 2. AFTER transaction success: unlink fromPath
379
- //
380
- // If step 2 fails, we surface the partial-failure but the audit log
381
- // already records the move intent. The next doctor pass will see
382
- // the spec in BOTH locations and surface that as a doctor finding.
383
- const fromRel = path.relative(path.join(cawsDir, '..'), fromPath);
384
- const toRel = path.relative(path.join(cawsDir, '..'), toPath);
385
707
  const event = {
386
708
  event: 'spec_archived',
387
709
  ts: now,
388
710
  actor: input.actor,
389
711
  spec_id: input.id,
390
- data: { from_path: fromRel, to_path: toRel },
712
+ data: eventData,
391
713
  };
392
- // Capture original bytes so we can roll back the unlink in
393
- // emergencies (rare but worth tracking).
714
+ // Pre-write dirty state on the path being deleted.
715
+ const wasDirtyBeforeWrite = (0, git_autocommit_1.isPathDirty)(repoRoot, fromRel);
716
+ // The "fake plannedWrite" pattern: lifecycle-transaction's contract
717
+ // is "write these files atomically and append these events." We have
718
+ // no file to write, so we feed it an empty plannedWrites and append
719
+ // the event only. The unlink happens AFTER txn success but BEFORE
720
+ // autocommit, so the autocommit's `git add` stages the deletion.
394
721
  let unlinkOk = false;
395
722
  let unlinkError = null;
396
723
  const txnResult = (0, lifecycle_lock_1.withLifecycleLock)(cawsDir, () => {
397
724
  const r = (0, lifecycle_transaction_1.runLifecycleTransaction)({
398
725
  cawsDir,
399
- plannedWrites: [{ path: toPath, contents: patched }],
726
+ plannedWrites: [],
400
727
  events: [event],
401
728
  });
402
729
  if (!r.ok)
403
730
  return r;
404
731
  if (r.value.kind !== 'success')
405
732
  return r;
406
- // Transaction wrote toPath + appended event. Now remove fromPath.
733
+ // Event appended. Now unlink the active path.
407
734
  try {
408
735
  fs.unlinkSync(fromPath);
409
736
  unlinkOk = true;
@@ -414,11 +741,6 @@ function archiveSpec(cawsDir, input) {
414
741
  }
415
742
  return r;
416
743
  });
417
- // Reason flows into closure_notes ONLY if the user passed one; archive
418
- // event schema does NOT take closure_notes, but we attach the reason
419
- // to a follow-up evidence record path in future versions. For v11.1
420
- // archive, we accept the --reason for parity with close but the
421
- // schema does not carry it.
422
744
  void input.reason;
423
745
  if (!txnResult.ok) {
424
746
  return (0, caws_kernel_1.err)(txnResult.errors);
@@ -430,16 +752,150 @@ function archiveSpec(cawsDir, input) {
430
752
  });
431
753
  }
432
754
  if (!unlinkOk) {
433
- return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PARTIAL_FAILURE_UNRECOVERED, `Archive write succeeded and spec_archived event appended, but original file unlink failed (${unlinkError}). Spec now exists in BOTH active and archived locations.`, {
755
+ return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PARTIAL_FAILURE_UNRECOVERED, `spec_archived event appended (blob_sha=${blobSha}) but unlink of ${fromPath} failed (${unlinkError}). The body is recoverable via \`git show ${blobSha}\` but the active file still exists on disk.`, {
434
756
  subject: input.id,
435
757
  data: {
436
758
  from_path: fromPath,
437
- to_path: toPath,
438
- recovery_instruction: `Manually remove ${fromPath} once you've confirmed ${toPath} is intact.`,
759
+ blob_sha: blobSha,
760
+ recovery_instruction: `Manually remove ${fromPath}; the body is in git history at blob ${blobSha}.`,
439
761
  },
440
762
  }));
441
763
  }
442
- return (0, caws_kernel_1.ok)({ kind: 'success', id: input.id, path: toPath });
764
+ // CAWS-SPECS-WRITER-AUTOCOMMIT-001: autoCommit the deletion. `git
765
+ // add -- <fromRel>` stages a deletion when the file is gone, so the
766
+ // resulting commit records the removal.
767
+ const audit = (0, git_autocommit_1.autoCommit)({
768
+ repoRoot,
769
+ paths: [fromRel],
770
+ message: `chore(caws): archive ${input.id}`,
771
+ wasDirtyBeforeWrite,
772
+ });
773
+ return (0, caws_kernel_1.ok)({
774
+ kind: 'success',
775
+ id: input.id,
776
+ path: fromPath,
777
+ data: { audit_commit: audit },
778
+ });
779
+ }
780
+ // ─── retireDraftSpec ─────────────────────────────────────────────────────
781
+ //
782
+ // CAWS-SPECS-RETIRE-DRAFT-001. Governed retirement of a never-activated
783
+ // DRAFT spec. Mirrors archiveSpec's tombstone flow exactly, with two
784
+ // differences: the precondition is lifecycle_state === 'draft' (not
785
+ // 'closed'), and the event is spec_retired (which DOES carry an optional
786
+ // reason, unlike spec_archived). No new lifecycle_state value — the
787
+ // spec_retired event is the durable signal, and the body is deleted +
788
+ // recoverable via `git show <blob_sha>`.
789
+ function retireDraftSpec(cawsDir, input) {
790
+ const idValidation = validateSpecId(input.id);
791
+ if (!idValidation.ok)
792
+ return idValidation;
793
+ const fromPath = specPath(cawsDir, input.id);
794
+ if (!fs.existsSync(fromPath)) {
795
+ return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, `Spec "${input.id}" not found at ${fromPath}.`, { subject: input.id }));
796
+ }
797
+ // Validate current state: must be draft. Active → close; closed → archive.
798
+ const sourceResult = (0, yaml_store_1.readYamlSource)(fromPath);
799
+ if (!(0, caws_kernel_1.isOk)(sourceResult))
800
+ return (0, caws_kernel_1.err)(sourceResult.errors);
801
+ const parsed = (0, caws_kernel_1.parseAndValidateSpec)(sourceResult.value);
802
+ if (!(0, caws_kernel_1.isOk)(parsed)) {
803
+ return (0, caws_kernel_1.err)(parsed.errors.map((d) => (0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, d.message, {
804
+ subject: d.subject ?? input.id,
805
+ data: { source_rule: d.rule },
806
+ })));
807
+ }
808
+ const spec = parsed.value;
809
+ if (spec.lifecycle_state !== 'draft') {
810
+ const alternative = spec.lifecycle_state === 'active'
811
+ ? `Use \`caws specs close ${input.id}\` to close an active spec.`
812
+ : spec.lifecycle_state === 'closed'
813
+ ? `Use \`caws specs archive ${input.id}\` to archive a closed spec.`
814
+ : `Only draft specs can be retired.`;
815
+ return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, `Spec "${input.id}" is in lifecycle_state "${spec.lifecycle_state}"; retire-draft only retires drafts. ${alternative}`, { subject: input.id, data: { current_state: spec.lifecycle_state } }));
816
+ }
817
+ const now = (input.now ?? (() => new Date()))().toISOString();
818
+ const repoRoot = repoRootFromCawsDir(cawsDir);
819
+ const fromRel = path.relative(repoRoot, fromPath);
820
+ // Capture blob_sha BEFORE any mutation — the authoritative recovery
821
+ // target. Refuse if the draft is not tracked at HEAD (no recovery path),
822
+ // mirroring archiveSpec's null-blob refusal.
823
+ const blobSha = gitBlobShaAtHead(repoRoot, fromRel);
824
+ if (blobSha === null) {
825
+ return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, `Spec "${input.id}" is not tracked at HEAD. Cannot retire: blob_sha is the authoritative recovery target, and without it the retirement event would have no recovery path. Commit the draft first, then re-run retire-draft.`, { subject: input.id, data: { from_path: fromRel } }));
826
+ }
827
+ const sourceCommitSha = gitLastCommitForPath(repoRoot, fromRel);
828
+ const eventData = {
829
+ from_path: fromRel,
830
+ blob_sha: blobSha,
831
+ };
832
+ if (sourceCommitSha !== null) {
833
+ eventData.source_commit_sha = sourceCommitSha;
834
+ }
835
+ if (input.reason !== undefined && input.reason.length > 0) {
836
+ eventData.reason = input.reason;
837
+ }
838
+ const event = {
839
+ event: 'spec_retired',
840
+ ts: now,
841
+ actor: input.actor,
842
+ spec_id: input.id,
843
+ data: eventData,
844
+ };
845
+ const wasDirtyBeforeWrite = (0, git_autocommit_1.isPathDirty)(repoRoot, fromRel);
846
+ let unlinkOk = false;
847
+ let unlinkError = null;
848
+ const txnResult = (0, lifecycle_lock_1.withLifecycleLock)(cawsDir, () => {
849
+ const r = (0, lifecycle_transaction_1.runLifecycleTransaction)({
850
+ cawsDir,
851
+ plannedWrites: [],
852
+ events: [event],
853
+ });
854
+ if (!r.ok)
855
+ return r;
856
+ if (r.value.kind !== 'success')
857
+ return r;
858
+ try {
859
+ fs.unlinkSync(fromPath);
860
+ unlinkOk = true;
861
+ }
862
+ catch (e) {
863
+ const cause = e;
864
+ unlinkError = cause.message ?? 'unknown unlink error';
865
+ }
866
+ return r;
867
+ });
868
+ if (!txnResult.ok) {
869
+ return (0, caws_kernel_1.err)(txnResult.errors);
870
+ }
871
+ if (txnResult.value.kind !== 'success') {
872
+ return (0, caws_kernel_1.ok)({
873
+ kind: 'partial_failure_recovered',
874
+ cause: txnResult.value.cause,
875
+ });
876
+ }
877
+ if (!unlinkOk) {
878
+ return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PARTIAL_FAILURE_UNRECOVERED, `spec_retired event appended (blob_sha=${blobSha}) but unlink of ${fromPath} failed (${unlinkError}). The body is recoverable via \`git show ${blobSha}\` but the draft file still exists on disk.`, {
879
+ subject: input.id,
880
+ data: {
881
+ from_path: fromPath,
882
+ blob_sha: blobSha,
883
+ recovery_instruction: `Manually remove ${fromPath}; the body is in git history at blob ${blobSha}.`,
884
+ },
885
+ }));
886
+ }
887
+ const audit = (0, git_autocommit_1.autoCommit)({
888
+ repoRoot,
889
+ paths: [fromRel],
890
+ message: `chore(caws): retire-draft ${input.id}`,
891
+ wasDirtyBeforeWrite,
892
+ });
893
+ return (0, caws_kernel_1.ok)({
894
+ kind: 'success',
895
+ id: input.id,
896
+ path: fromPath,
897
+ data: { audit_commit: audit },
898
+ });
443
899
  }
444
900
  // ─── Outcome mapper ──────────────────────────────────────────────────────
445
901
  function mapTxnToOutcome(result, id, targetPath) {
@@ -461,7 +917,24 @@ function mapTxnToOutcome(result, id, targetPath) {
461
917
  },
462
918
  }));
463
919
  }
464
- /** List specs by lifecycle state, optionally including archived ones. */
920
+ /**
921
+ * List specs by lifecycle state, optionally including archived ones.
922
+ *
923
+ * CAWS-ARCHIVE-AS-TOMBSTONE-001: the `--include-archived` path now
924
+ * reads from `.caws/events.jsonl` (most recent spec_archived event
925
+ * per spec_id), NOT from `.caws/specs/.archive/`. Post-tombstone the
926
+ * .archive/ directory is not populated; reading it would either
927
+ * surface nothing (steady state) or surface legacy bodies the
928
+ * doctor warning already flags for migration.
929
+ *
930
+ * Includes legacy events (with only from_path + to_path, no
931
+ * blob_sha) → blob_sha is reported as null; recover falls back to
932
+ * git log --follow.
933
+ *
934
+ * Latest-write-wins per spec_id: if a spec was archived, recovered,
935
+ * recreated, and re-archived, only the most recent spec_archived
936
+ * event surfaces.
937
+ */
465
938
  function listSpecs(cawsDir, options = {}) {
466
939
  const activeResult = (0, specs_store_1.loadSpecs)(cawsDir);
467
940
  const active = activeResult.specs.map((spec) => ({
@@ -470,56 +943,422 @@ function listSpecs(cawsDir, options = {}) {
470
943
  lifecycle_state: spec.lifecycle_state,
471
944
  path: specPath(cawsDir, spec.id),
472
945
  }));
473
- const archived = [];
946
+ const activeIds = new Set(active.map((s) => s.id));
947
+ let archived = [];
474
948
  if (options.includeArchived === true) {
475
- const archiveDir = path.join(cawsDir, 'specs', '.archive');
476
- if (fs.existsSync(archiveDir)) {
477
- try {
478
- const entries = fs.readdirSync(archiveDir, { withFileTypes: true });
479
- for (const entry of entries) {
480
- if (!entry.isFile())
481
- continue;
482
- if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.yml'))
483
- continue;
484
- const fullPath = path.join(archiveDir, entry.name);
485
- const src = (0, yaml_store_1.readYamlSource)(fullPath);
486
- if (!(0, caws_kernel_1.isOk)(src))
487
- continue;
488
- const parsed = (0, caws_kernel_1.parseAndValidateSpec)(src.value);
489
- if (!(0, caws_kernel_1.isOk)(parsed))
490
- continue;
491
- const spec = parsed.value;
492
- archived.push({
493
- id: spec.id,
494
- title: spec.title,
495
- lifecycle_state: spec.lifecycle_state,
496
- path: fullPath,
497
- });
498
- }
499
- }
500
- catch {
501
- // Best-effort archive listing.
502
- }
503
- }
949
+ archived = readArchivedFromEventLog(cawsDir, activeIds);
504
950
  }
505
951
  return (0, caws_kernel_1.ok)({ active, archived });
506
952
  }
507
- /** Find a spec by id under active or archive locations. */
953
+ /**
954
+ * Walk events.jsonl for spec_archived events; collect the most recent
955
+ * one per spec_id; emit entries. Excludes any spec_id that has been
956
+ * re-created since (presence in activeIds wins — that means the
957
+ * archive was undone by a subsequent createSpec).
958
+ */
959
+ function readArchivedFromEventLog(cawsDir, activeIds) {
960
+ const eventsPath = path.join(cawsDir, 'events.jsonl');
961
+ if (!fs.existsSync(eventsPath))
962
+ return [];
963
+ let raw;
964
+ try {
965
+ raw = fs.readFileSync(eventsPath, 'utf8');
966
+ }
967
+ catch {
968
+ return [];
969
+ }
970
+ // Map: spec_id → most recent spec_archived event payload+ts.
971
+ const latest = new Map();
972
+ for (const line of raw.split('\n')) {
973
+ if (line.length === 0)
974
+ continue;
975
+ let parsed;
976
+ try {
977
+ parsed = JSON.parse(line);
978
+ }
979
+ catch {
980
+ continue;
981
+ }
982
+ if (typeof parsed !== 'object' ||
983
+ parsed === null ||
984
+ parsed.event !== 'spec_archived') {
985
+ continue;
986
+ }
987
+ const evt = parsed;
988
+ if (typeof evt.ts !== 'string' ||
989
+ typeof evt.spec_id !== 'string' ||
990
+ typeof evt.data !== 'object' ||
991
+ evt.data === null ||
992
+ typeof evt.data.from_path !== 'string') {
993
+ continue;
994
+ }
995
+ latest.set(evt.spec_id, {
996
+ ts: evt.ts,
997
+ from_path: evt.data.from_path,
998
+ blob_sha: typeof evt.data.blob_sha === 'string' ? evt.data.blob_sha : null,
999
+ });
1000
+ }
1001
+ const out = [];
1002
+ for (const [specId, info] of latest) {
1003
+ if (activeIds.has(specId))
1004
+ continue; // re-created after archive — active wins
1005
+ out.push({
1006
+ id: specId,
1007
+ path: info.from_path,
1008
+ archived_at: info.ts,
1009
+ blob_sha: info.blob_sha,
1010
+ });
1011
+ }
1012
+ // Stable sort: by id ascending.
1013
+ out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1014
+ return out;
1015
+ }
1016
+ /**
1017
+ * Find a spec by id in the ACTIVE location only
1018
+ * (.caws/specs/<id>.yaml). CAWS-ARCHIVE-AS-TOMBSTONE-001 invariant:
1019
+ * `caws specs show` defaults to active specs only. Archived specs
1020
+ * require explicit opt-in via `--archived` (which routes through
1021
+ * `recoverArchivedSpec` below).
1022
+ *
1023
+ * Pre-tombstone: showSpec searched both active AND .caws/specs/.archive/
1024
+ * transparently. That transparent fallback was a context-rot vector
1025
+ * (agents grep'd and cited stale specs as authority); it is removed
1026
+ * by design.
1027
+ */
508
1028
  function showSpec(cawsDir, id) {
509
1029
  const idValidation = validateSpecId(id);
510
1030
  if (!idValidation.ok)
511
1031
  return idValidation;
512
- const fullPath = findSpecPath(cawsDir, id);
513
- if (fullPath === null) {
514
- return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, `Spec "${id}" not found in .caws/specs/ or .caws/specs/.archive/.`, { subject: id }));
1032
+ const activePath = specPath(cawsDir, id);
1033
+ if (!fs.existsSync(activePath)) {
1034
+ // Distinguish "never existed" from "exists but archived". The
1035
+ // event log tells us if there's a spec_archived event; if yes,
1036
+ // surface a typed diagnostic pointing the user at --archived /
1037
+ // recover. If no, the spec is genuinely unknown.
1038
+ const archived = findArchivedSpecEvent(cawsDir, id);
1039
+ if (archived !== null) {
1040
+ return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, `Spec "${id}" is not in active specs. It was archived; to view its body, use \`caws specs show ${id} --archived\` or \`caws specs recover ${id}\`.`, {
1041
+ subject: id,
1042
+ data: {
1043
+ archived_at: archived.ts,
1044
+ blob_sha: archived.blob_sha,
1045
+ from_path: archived.from_path,
1046
+ },
1047
+ }));
1048
+ }
1049
+ return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, `Spec "${id}" not found in .caws/specs/.`, { subject: id }));
515
1050
  }
516
- const sourceResult = (0, yaml_store_1.readYamlSource)(fullPath);
1051
+ const sourceResult = (0, yaml_store_1.readYamlSource)(activePath);
517
1052
  if (!(0, caws_kernel_1.isOk)(sourceResult))
518
1053
  return (0, caws_kernel_1.err)(sourceResult.errors);
519
1054
  const parsed = (0, caws_kernel_1.parseAndValidateSpec)(sourceResult.value);
520
1055
  if (!(0, caws_kernel_1.isOk)(parsed))
521
1056
  return (0, caws_kernel_1.err)(parsed.errors);
522
- return (0, caws_kernel_1.ok)({ spec: parsed.value, path: fullPath, source: sourceResult.value });
1057
+ return (0, caws_kernel_1.ok)({ spec: parsed.value, path: activePath, source: sourceResult.value });
1058
+ }
1059
+ /**
1060
+ * Walk .caws/events.jsonl, return the most recent spec_archived
1061
+ * event for the given spec_id, or null if none exists. Handles both
1062
+ * legacy (from_path + to_path) and tombstone (from_path + blob_sha)
1063
+ * shapes.
1064
+ *
1065
+ * "Most recent" semantics: events.jsonl is append-only; the LAST
1066
+ * matching event wins. If a spec was archived, recovered, recreated,
1067
+ * and re-archived, only the latest spec_archived is relevant for
1068
+ * recovery.
1069
+ */
1070
+ function findArchivedSpecEvent(cawsDir, specId) {
1071
+ const eventsPath = path.join(cawsDir, 'events.jsonl');
1072
+ if (!fs.existsSync(eventsPath))
1073
+ return null;
1074
+ let raw;
1075
+ try {
1076
+ raw = fs.readFileSync(eventsPath, 'utf8');
1077
+ }
1078
+ catch {
1079
+ return null;
1080
+ }
1081
+ let latest = null;
1082
+ for (const line of raw.split('\n')) {
1083
+ if (line.length === 0)
1084
+ continue;
1085
+ let parsed;
1086
+ try {
1087
+ parsed = JSON.parse(line);
1088
+ }
1089
+ catch {
1090
+ continue;
1091
+ }
1092
+ // CAWS-SPECS-RETIRE-DRAFT-001 A5: recovery also resolves a retired
1093
+ // draft. spec_retired shares the identical {from_path, blob_sha}
1094
+ // tombstone shape as spec_archived, so the same git-show recovery
1095
+ // path reconstructs it. Accept either event type.
1096
+ const evtType = parsed.event;
1097
+ if (typeof parsed !== 'object' ||
1098
+ parsed === null ||
1099
+ (evtType !== 'spec_archived' && evtType !== 'spec_retired') ||
1100
+ parsed.spec_id !== specId) {
1101
+ continue;
1102
+ }
1103
+ const evt = parsed;
1104
+ if (typeof evt.ts !== 'string' ||
1105
+ typeof evt.data !== 'object' ||
1106
+ evt.data === null ||
1107
+ typeof evt.data.from_path !== 'string') {
1108
+ continue;
1109
+ }
1110
+ latest = {
1111
+ ts: evt.ts,
1112
+ from_path: evt.data.from_path,
1113
+ blob_sha: typeof evt.data.blob_sha === 'string' ? evt.data.blob_sha : null,
1114
+ ...(typeof evt.data.to_path === 'string' ? { to_path: evt.data.to_path } : {}),
1115
+ };
1116
+ }
1117
+ return latest;
1118
+ }
1119
+ /**
1120
+ * Recover an archived spec's body from git history.
1121
+ *
1122
+ * Resolution order:
1123
+ * 1. New-shape event with blob_sha → `git show <blob_sha>`.
1124
+ * 2. Legacy event with to_path only → `git log --all --follow --
1125
+ * <from_path>` to find a containing commit, then
1126
+ * `git show <commit>:<from_path>`. If zero commits, Err.
1127
+ *
1128
+ * NEVER mutates .caws/specs/. Returns the raw yaml bytes.
1129
+ */
1130
+ function recoverArchivedSpec(cawsDir, id) {
1131
+ const idValidation = validateSpecId(id);
1132
+ if (!idValidation.ok)
1133
+ return idValidation;
1134
+ const evt = findArchivedSpecEvent(cawsDir, id);
1135
+ if (evt === null) {
1136
+ return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, `Spec "${id}" was never archived (no spec_archived event in .caws/events.jsonl).`, { subject: id }));
1137
+ }
1138
+ const repoRoot = repoRootFromCawsDir(cawsDir);
1139
+ // Tombstone shape: recover via blob_sha (topology-independent).
1140
+ if (evt.blob_sha !== null) {
1141
+ if (!/^[0-9a-f]{40}$/.test(evt.blob_sha)) {
1142
+ return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, `spec_archived event for "${id}" has malformed blob_sha "${evt.blob_sha}" (expected 40-hex).`, { subject: id }));
1143
+ }
1144
+ // trim:false preserves the spec yaml's trailing newline so the
1145
+ // recovered body is byte-identical to the pre-archive content.
1146
+ const body = runGitQuery(['show', evt.blob_sha], repoRoot, { trim: false });
1147
+ if (body === null) {
1148
+ return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, `Blob ${evt.blob_sha} for spec "${id}" is not in the local git object store. Try \`git fetch --unshallow\` or \`git fetch --all\` if this is a shallow clone.`, { subject: id, data: { blob_sha: evt.blob_sha, from_path: evt.from_path } }));
1149
+ }
1150
+ return (0, caws_kernel_1.ok)({ source: body, blob_sha: evt.blob_sha, from_path: evt.from_path });
1151
+ }
1152
+ // Legacy shape: fall back to git log --follow on from_path. Walk
1153
+ // commits in newest-first order and pick the first one where the
1154
+ // file exists at from_path (skip deletion commits). The `git log
1155
+ // --follow` output includes BOTH commits where the file existed
1156
+ // and the commit that removed it; we want the first one before
1157
+ // the deletion.
1158
+ const commitListing = runGitQuery(['log', '--all', '--follow', '--format=%H', '--', evt.from_path], repoRoot);
1159
+ if (commitListing === null || commitListing.length === 0) {
1160
+ return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, `Spec "${id}" is a legacy archive (event has no blob_sha) and no commit on the current branch contains "${evt.from_path}". The body is unrecoverable from this clone.`, { subject: id, data: { from_path: evt.from_path } }));
1161
+ }
1162
+ const commits = commitListing
1163
+ .split('\n')
1164
+ .map((c) => c.trim())
1165
+ .filter((c) => /^[0-9a-f]{40}$/.test(c));
1166
+ if (commits.length === 0) {
1167
+ return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, `git log returned no valid commit shas for "${evt.from_path}".`, { subject: id }));
1168
+ }
1169
+ // Walk newest-first; pick the first commit where the file blob
1170
+ // exists at from_path. trim:false preserves trailing newlines.
1171
+ for (const commit of commits) {
1172
+ const body = runGitQuery(['show', `${commit}:${evt.from_path}`], repoRoot, { trim: false });
1173
+ if (body !== null) {
1174
+ return (0, caws_kernel_1.ok)({ source: body, blob_sha: null, from_path: evt.from_path });
1175
+ }
1176
+ }
1177
+ return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, `Spec "${id}" is a legacy archive; git log --follow returned ${commits.length} commits referencing "${evt.from_path}" but none contained the file body (all were deletion commits or renames). Body unrecoverable from this clone.`, { subject: id, data: { from_path: evt.from_path, candidates: commits.length } }));
1178
+ }
1179
+ /**
1180
+ * Scan .caws/specs/.archive/ for legacy bodies. Returns per-id status
1181
+ * (dry-run) or executes the migration (--apply).
1182
+ *
1183
+ * Recoverability check: `git log --all --follow -- <fromPath>`. If
1184
+ * any commit contains the file, the body is recoverable from git
1185
+ * history; we extract the blob_sha + most-recent containing commit
1186
+ * and that becomes the recovery target. If zero commits, the body
1187
+ * is local-only and gets quarantined.
1188
+ */
1189
+ function pruneArchive(cawsDir, input) {
1190
+ const archiveDir = path.join(cawsDir, 'specs', '.archive');
1191
+ if (!fs.existsSync(archiveDir)) {
1192
+ // No legacy archive to prune; succeed with empty plan.
1193
+ return (0, caws_kernel_1.ok)({ plans: [], applied: input.apply === true, events_appended: 0 });
1194
+ }
1195
+ const repoRoot = repoRootFromCawsDir(cawsDir);
1196
+ const apply = input.apply === true;
1197
+ const now = (input.now ?? (() => new Date()))().toISOString();
1198
+ // Enumerate yaml files at the top of .archive/, excluding the
1199
+ // .unrecoverable/ subdir (which is the destination, not a source).
1200
+ let entries;
1201
+ try {
1202
+ entries = fs.readdirSync(archiveDir, { withFileTypes: true });
1203
+ }
1204
+ catch (e) {
1205
+ return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PLAN_REJECTED, `Failed to read ${archiveDir}: ${e.message}`, { subject: archiveDir }));
1206
+ }
1207
+ const plans = [];
1208
+ for (const entry of entries) {
1209
+ if (!entry.isFile())
1210
+ continue;
1211
+ if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.yml'))
1212
+ continue;
1213
+ const id = entry.name.replace(/\.ya?ml$/, '');
1214
+ const fromPath = path.join(archiveDir, entry.name);
1215
+ // The legacy archive bodies' from_path (where the spec was BEFORE
1216
+ // archiving) is .caws/specs/<id>.yaml — that's what git log
1217
+ // --follow searches.
1218
+ const activeRel = path.relative(repoRoot, specPath(cawsDir, id));
1219
+ // Find any commit that contained the file at activeRel.
1220
+ const commitListing = runGitQuery(['log', '--all', '--follow', '--format=%H', '--', activeRel], repoRoot);
1221
+ if (commitListing === null || commitListing.length === 0) {
1222
+ plans.push({
1223
+ id,
1224
+ fromPath,
1225
+ fromRel: path.relative(repoRoot, fromPath),
1226
+ status: 'unrecoverable',
1227
+ reason: `git log --all --follow -- ${activeRel} returned no commits`,
1228
+ });
1229
+ continue;
1230
+ }
1231
+ // Walk commits newest-first; pick the first one where the blob
1232
+ // actually exists at activeRel (skip deletion commits).
1233
+ let recovered = null;
1234
+ for (const commit of commitListing.split('\n')) {
1235
+ const c = commit.trim();
1236
+ if (!/^[0-9a-f]{40}$/.test(c))
1237
+ continue;
1238
+ const lsTree = runGitQuery(['ls-tree', c, '--', activeRel], repoRoot);
1239
+ if (lsTree === null || lsTree.length === 0)
1240
+ continue;
1241
+ const blobParts = lsTree.split(/\s+/);
1242
+ if (blobParts.length < 3)
1243
+ continue;
1244
+ const sha = blobParts[2];
1245
+ if (sha !== undefined && /^[0-9a-f]{40}$/.test(sha)) {
1246
+ recovered = { commit: c, blob: sha };
1247
+ break;
1248
+ }
1249
+ }
1250
+ if (recovered === null) {
1251
+ plans.push({
1252
+ id,
1253
+ fromPath,
1254
+ fromRel: path.relative(repoRoot, fromPath),
1255
+ status: 'unrecoverable',
1256
+ reason: `git log returned commits but none contained the blob at ${activeRel}`,
1257
+ });
1258
+ }
1259
+ else {
1260
+ plans.push({
1261
+ id,
1262
+ fromPath,
1263
+ fromRel: path.relative(repoRoot, fromPath),
1264
+ status: 'recoverable',
1265
+ blob_sha: recovered.blob,
1266
+ commit_sha: recovered.commit,
1267
+ });
1268
+ }
1269
+ }
1270
+ if (!apply) {
1271
+ return (0, caws_kernel_1.ok)({ plans, applied: false, events_appended: 0 });
1272
+ }
1273
+ // --apply: execute the migration. For each plan:
1274
+ // recoverable → fs.unlinkSync(fromPath) + emit removed event
1275
+ // unrecoverable → fs.renameSync to .unrecoverable/<id>.yaml + emit
1276
+ // quarantined event
1277
+ // Quarantine dir is created lazily on first unrecoverable.
1278
+ const unrecoverableDir = path.join(archiveDir, '.unrecoverable');
1279
+ let eventsAppended = 0;
1280
+ for (const plan of plans) {
1281
+ if (plan.status === 'recoverable') {
1282
+ try {
1283
+ fs.unlinkSync(plan.fromPath);
1284
+ }
1285
+ catch (e) {
1286
+ // Best-effort: surface as Err but allow event-append to skip.
1287
+ // The plan reported the intent; the operator can inspect.
1288
+ return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PARTIAL_FAILURE_UNRECOVERED, `Failed to unlink ${plan.fromPath}: ${e.message}`, { subject: plan.id }));
1289
+ }
1290
+ const event = {
1291
+ event: 'spec_archive_pruned',
1292
+ ts: now,
1293
+ actor: input.actor,
1294
+ spec_id: plan.id,
1295
+ data: {
1296
+ from_path: plan.fromRel,
1297
+ action: 'removed',
1298
+ blob_sha: plan.blob_sha,
1299
+ from_commit_sha: plan.commit_sha,
1300
+ },
1301
+ };
1302
+ const txn = (0, lifecycle_lock_1.withLifecycleLock)(cawsDir, () => (0, lifecycle_transaction_1.runLifecycleTransaction)({
1303
+ cawsDir,
1304
+ plannedWrites: [],
1305
+ events: [event],
1306
+ }));
1307
+ if (!txn.ok)
1308
+ return (0, caws_kernel_1.err)(txn.errors);
1309
+ if (txn.value.kind !== 'success') {
1310
+ return (0, caws_kernel_1.ok)({
1311
+ plans,
1312
+ applied: true,
1313
+ events_appended: eventsAppended,
1314
+ });
1315
+ }
1316
+ eventsAppended++;
1317
+ }
1318
+ else {
1319
+ try {
1320
+ fs.mkdirSync(unrecoverableDir, { recursive: true });
1321
+ }
1322
+ catch (e) {
1323
+ return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_WRITE_FAILED, `Failed to create quarantine dir ${unrecoverableDir}: ${e.message}`, { subject: unrecoverableDir }));
1324
+ }
1325
+ const toPath = path.join(unrecoverableDir, path.basename(plan.fromPath));
1326
+ const toRel = path.relative(repoRoot, toPath);
1327
+ try {
1328
+ fs.renameSync(plan.fromPath, toPath);
1329
+ }
1330
+ catch (e) {
1331
+ return (0, caws_kernel_1.err)((0, repo_root_1.storeDiagnostic)(rules_1.STORE_RULES.LIFECYCLE_PARTIAL_FAILURE_UNRECOVERED, `Failed to quarantine ${plan.fromPath} → ${toPath}: ${e.message}`, { subject: plan.id }));
1332
+ }
1333
+ const event = {
1334
+ event: 'spec_archive_pruned',
1335
+ ts: now,
1336
+ actor: input.actor,
1337
+ spec_id: plan.id,
1338
+ data: {
1339
+ from_path: plan.fromRel,
1340
+ action: 'quarantined',
1341
+ to_path: toRel,
1342
+ },
1343
+ };
1344
+ const txn = (0, lifecycle_lock_1.withLifecycleLock)(cawsDir, () => (0, lifecycle_transaction_1.runLifecycleTransaction)({
1345
+ cawsDir,
1346
+ plannedWrites: [],
1347
+ events: [event],
1348
+ }));
1349
+ if (!txn.ok)
1350
+ return (0, caws_kernel_1.err)(txn.errors);
1351
+ if (txn.value.kind !== 'success') {
1352
+ return (0, caws_kernel_1.ok)({
1353
+ plans,
1354
+ applied: true,
1355
+ events_appended: eventsAppended,
1356
+ });
1357
+ }
1358
+ eventsAppended++;
1359
+ }
1360
+ }
1361
+ return (0, caws_kernel_1.ok)({ plans, applied: true, events_appended: eventsAppended });
523
1362
  }
524
1363
  // Unused import elimination: surface appendEvent so future direct-event
525
1364
  // flows (if any) compile against the same surface as evidence/waiver.