@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.
- package/dist/index.js +55 -58
- package/dist/init/hook-packs/manifest-claude-code.d.ts +1 -1
- package/dist/init/hook-packs/manifest-claude-code.d.ts.map +1 -1
- package/dist/init/hook-packs/manifest-claude-code.js +260 -2
- package/dist/init/hook-packs/manifest-claude-code.js.map +1 -1
- package/dist/shell/binding/resolve-binding.d.ts.map +1 -1
- package/dist/shell/binding/resolve-binding.js +105 -1
- package/dist/shell/binding/resolve-binding.js.map +1 -1
- package/dist/shell/binding/types.d.ts +47 -3
- package/dist/shell/binding/types.d.ts.map +1 -1
- package/dist/shell/command-metadata.d.ts +93 -0
- package/dist/shell/command-metadata.d.ts.map +1 -0
- package/dist/shell/command-metadata.js +687 -0
- package/dist/shell/command-metadata.js.map +1 -0
- package/dist/shell/commands/agents.d.ts +1 -2
- package/dist/shell/commands/agents.d.ts.map +1 -1
- package/dist/shell/commands/claim.d.ts +16 -0
- package/dist/shell/commands/claim.d.ts.map +1 -1
- package/dist/shell/commands/claim.js +85 -26
- package/dist/shell/commands/claim.js.map +1 -1
- package/dist/shell/commands/events.d.ts +106 -0
- package/dist/shell/commands/events.d.ts.map +1 -0
- package/dist/shell/commands/events.js +510 -0
- package/dist/shell/commands/events.js.map +1 -0
- package/dist/shell/commands/gates.d.ts +2 -2
- package/dist/shell/commands/gates.d.ts.map +1 -1
- package/dist/shell/commands/gates.js +106 -25
- package/dist/shell/commands/gates.js.map +1 -1
- package/dist/shell/commands/init.d.ts.map +1 -1
- package/dist/shell/commands/init.js +26 -0
- package/dist/shell/commands/init.js.map +1 -1
- package/dist/shell/commands/prepush.d.ts +26 -0
- package/dist/shell/commands/prepush.d.ts.map +1 -0
- package/dist/shell/commands/prepush.js +373 -0
- package/dist/shell/commands/prepush.js.map +1 -0
- package/dist/shell/commands/scope.d.ts.map +1 -1
- package/dist/shell/commands/scope.js +31 -1
- package/dist/shell/commands/scope.js.map +1 -1
- package/dist/shell/commands/specs.d.ts +44 -3
- package/dist/shell/commands/specs.d.ts.map +1 -1
- package/dist/shell/commands/specs.js +411 -15
- package/dist/shell/commands/specs.js.map +1 -1
- package/dist/shell/commands/worktree.d.ts.map +1 -1
- package/dist/shell/commands/worktree.js +51 -1
- package/dist/shell/commands/worktree.js.map +1 -1
- package/dist/shell/gates/disposition.d.ts.map +1 -1
- package/dist/shell/gates/disposition.js +43 -2
- package/dist/shell/gates/disposition.js.map +1 -1
- package/dist/shell/index.d.ts +10 -4
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +22 -2
- package/dist/shell/index.js.map +1 -1
- package/dist/shell/legacy-command-map.js +832 -0
- package/dist/shell/push-range/classify-range.d.ts +99 -0
- package/dist/shell/push-range/classify-range.d.ts.map +1 -0
- package/dist/shell/push-range/classify-range.js +155 -0
- package/dist/shell/push-range/classify-range.js.map +1 -0
- package/dist/shell/push-range/scope-match.d.ts +13 -0
- package/dist/shell/push-range/scope-match.d.ts.map +1 -0
- package/dist/shell/push-range/scope-match.js +53 -0
- package/dist/shell/push-range/scope-match.js.map +1 -0
- package/dist/shell/register.d.ts.map +1 -1
- package/dist/shell/register.js +263 -228
- package/dist/shell/register.js.map +1 -1
- package/dist/shell/registered-command-groups.js +48 -0
- package/dist/shell/rules.d.ts +19 -0
- package/dist/shell/rules.d.ts.map +1 -1
- package/dist/shell/rules.js +27 -0
- package/dist/shell/rules.js.map +1 -1
- package/dist/shell/session/resolve-session.d.ts +29 -1
- package/dist/shell/session/resolve-session.d.ts.map +1 -1
- package/dist/shell/session/resolve-session.js +817 -11
- package/dist/shell/session/resolve-session.js.map +1 -1
- package/dist/shell/session/types.d.ts +127 -1
- package/dist/shell/session/types.d.ts.map +1 -1
- package/dist/shell/session/types.js +10 -4
- package/dist/shell/session/types.js.map +1 -1
- package/dist/store/doctor-snapshot.d.ts.map +1 -1
- package/dist/store/doctor-snapshot.js +26 -0
- package/dist/store/doctor-snapshot.js.map +1 -1
- package/dist/store/events-migration.d.ts +207 -0
- package/dist/store/events-migration.d.ts.map +1 -0
- package/dist/store/events-migration.js +358 -0
- package/dist/store/events-migration.js.map +1 -0
- package/dist/store/events-store.d.ts +47 -1
- package/dist/store/events-store.d.ts.map +1 -1
- package/dist/store/events-store.js +278 -0
- package/dist/store/events-store.js.map +1 -1
- package/dist/store/git-autocommit.d.ts +46 -0
- package/dist/store/git-autocommit.d.ts.map +1 -0
- package/dist/store/git-autocommit.js +198 -0
- package/dist/store/git-autocommit.js.map +1 -0
- package/dist/store/index.d.ts +4 -1
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +7 -1
- package/dist/store/index.js.map +1 -1
- package/dist/store/leases-store.d.ts.map +1 -1
- package/dist/store/leases-store.js +58 -0
- package/dist/store/leases-store.js.map +1 -1
- package/dist/store/rules.d.ts +53 -0
- package/dist/store/rules.d.ts.map +1 -1
- package/dist/store/rules.js +54 -0
- package/dist/store/rules.js.map +1 -1
- package/dist/store/specs-migration.d.ts +128 -0
- package/dist/store/specs-migration.d.ts.map +1 -0
- package/dist/store/specs-migration.js +481 -0
- package/dist/store/specs-migration.js.map +1 -0
- package/dist/store/specs-store.d.ts.map +1 -1
- package/dist/store/specs-store.js +14 -2
- package/dist/store/specs-store.js.map +1 -1
- package/dist/store/specs-writer.d.ts +130 -3
- package/dist/store/specs-writer.d.ts.map +1 -1
- package/dist/store/specs-writer.js +941 -102
- package/dist/store/specs-writer.js.map +1 -1
- package/dist/store/types.d.ts +6 -0
- package/dist/store/types.d.ts.map +1 -1
- package/dist/store/waivers-store.d.ts.map +1 -1
- package/dist/store/waivers-store.js +8 -1
- package/dist/store/waivers-store.js.map +1 -1
- package/dist/store/worktrees-writer.d.ts +28 -0
- package/dist/store/worktrees-writer.d.ts.map +1 -1
- package/dist/store/worktrees-writer.js +110 -12
- package/dist/store/worktrees-writer.js.map +1 -1
- package/package.json +5 -2
- package/templates/hook-packs/claude-code/CLAUDE.md +7 -1
- package/templates/hook-packs/claude-code/agent-heartbeat.sh +1 -1
- package/templates/hook-packs/claude-code/agent-register.sh +1 -1
- package/templates/hook-packs/claude-code/agent-stop.sh +1 -1
- package/templates/hook-packs/claude-code/audit.sh +1 -1
- package/templates/hook-packs/claude-code/block-dangerous.sh +1 -1
- package/templates/hook-packs/claude-code/classify_command.py +1 -1
- package/templates/hook-packs/claude-code/cwd-guard.sh +30 -0
- package/templates/hook-packs/claude-code/dispatch/post_tool_use.sh +15 -4
- package/templates/hook-packs/claude-code/dispatch/pre_tool_use.sh +10 -2
- package/templates/hook-packs/claude-code/dispatch/session_start.sh +1 -1
- package/templates/hook-packs/claude-code/dispatch/stop.sh +2 -2
- package/templates/hook-packs/claude-code/duplicate-export-check.sh +156 -0
- package/templates/hook-packs/claude-code/god-object-check.sh +102 -0
- package/templates/hook-packs/claude-code/guard-strikes.sh +1 -1
- package/templates/hook-packs/claude-code/lib/parse-input.sh +115 -1
- package/templates/hook-packs/claude-code/lib/run-handlers.sh +1 -1
- package/templates/hook-packs/claude-code/loc-delta-check.sh +91 -0
- package/templates/hook-packs/claude-code/naming-check.sh +128 -0
- package/templates/hook-packs/claude-code/plan-transcript-finalize.sh +59 -0
- package/templates/hook-packs/claude-code/plan-transcript-snapshot.sh +86 -0
- package/templates/hook-packs/claude-code/protected-paths.sh +59 -0
- package/templates/hook-packs/claude-code/quiet-merge.sh +68 -0
- package/templates/hook-packs/claude-code/reset-danger-latch.sh +1 -1
- package/templates/hook-packs/claude-code/reset-strikes.sh +1 -1
- package/templates/hook-packs/claude-code/runtime-paths.sh +1 -1
- package/templates/hook-packs/claude-code/scan-secrets.sh +98 -0
- package/templates/hook-packs/claude-code/scope-guard.sh +47 -65
- package/templates/hook-packs/claude-code/session-caws-status.sh +1 -1
- package/templates/hook-packs/claude-code/session-log.sh +1 -1
- package/templates/hook-packs/claude-code/session_log_renderer.py +956 -0
- package/templates/hook-packs/claude-code/shortcut-language-check.sh +147 -0
- package/templates/hook-packs/claude-code/worktree-guard.sh +1 -1
- 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
|
-
|
|
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
|
-
//
|
|
193
|
-
//
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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:
|
|
712
|
+
data: eventData,
|
|
391
713
|
};
|
|
392
|
-
//
|
|
393
|
-
|
|
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: [
|
|
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
|
-
//
|
|
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, `
|
|
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
|
-
|
|
438
|
-
recovery_instruction: `Manually remove ${fromPath}
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
946
|
+
const activeIds = new Set(active.map((s) => s.id));
|
|
947
|
+
let archived = [];
|
|
474
948
|
if (options.includeArchived === true) {
|
|
475
|
-
|
|
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
|
-
/**
|
|
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
|
|
513
|
-
if (
|
|
514
|
-
|
|
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)(
|
|
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:
|
|
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.
|