@paths.design/caws-cli 10.0.1 → 10.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -5
- package/dist/budget-derivation.js +221 -74
- package/dist/commands/agents.js +124 -0
- package/dist/commands/evaluate.js +26 -12
- package/dist/commands/gates.js +31 -4
- package/dist/commands/init.js +7 -4
- package/dist/commands/iterate.js +7 -3
- package/dist/commands/scope.js +264 -0
- package/dist/commands/sidecar.js +6 -3
- package/dist/commands/specs.js +359 -4
- package/dist/commands/status.js +29 -4
- package/dist/commands/templates.js +0 -8
- package/dist/commands/validate.js +34 -13
- package/dist/commands/verify-acs.js +25 -10
- package/dist/commands/waivers.js +147 -5
- package/dist/commands/worktree.js +200 -4
- package/dist/gates/budget-limit.js +6 -1
- package/dist/gates/scope-boundary.js +26 -7
- package/dist/gates/spec-completeness.js +8 -1
- package/dist/index.js +56 -0
- package/dist/policy/PolicyManager.js +14 -7
- package/dist/session/session-manager.js +34 -0
- package/dist/templates/.caws/schemas/policy.schema.json +101 -34
- package/dist/templates/.caws/schemas/scope.schema.json +3 -3
- package/dist/templates/.caws/schemas/waivers.schema.json +91 -21
- package/dist/templates/.caws/schemas/working-spec.schema.json +253 -89
- package/dist/templates/.caws/templates/working-spec.template.yml +3 -1
- package/dist/templates/.caws/tools/scope-guard.js +66 -15
- package/dist/templates/.claude/README.md +1 -1
- package/dist/templates/.claude/hooks/protected-paths.sh +39 -0
- package/dist/templates/.claude/hooks/scope-guard.sh +106 -27
- package/dist/templates/.claude/hooks/worktree-write-guard.sh +96 -3
- package/dist/templates/.claude/rules/worktree-isolation.md +21 -3
- package/dist/templates/.claude/settings.json +5 -0
- package/dist/templates/CLAUDE.md +56 -0
- package/dist/templates/agents.md +47 -0
- package/dist/utils/agent-display.js +210 -0
- package/dist/utils/agent-session.js +142 -0
- package/dist/utils/event-log.js +584 -0
- package/dist/utils/event-renderer.js +521 -0
- package/dist/utils/schema-validator.js +10 -2
- package/dist/utils/working-state.js +25 -0
- package/dist/validation/spec-validation.js +102 -9
- package/dist/waivers-manager.js +84 -0
- package/dist/worktree/worktree-manager.js +593 -26
- package/package.json +5 -4
- package/templates/.caws/schemas/policy.schema.json +101 -34
- package/templates/.caws/schemas/scope.schema.json +3 -3
- package/templates/.caws/schemas/waivers.schema.json +91 -21
- package/templates/.caws/schemas/working-spec.schema.json +253 -89
- package/templates/.caws/templates/working-spec.template.yml +3 -1
- package/templates/.caws/tools/scope-guard.js +66 -15
- package/templates/.claude/README.md +1 -1
- package/templates/.claude/hooks/protected-paths.sh +39 -0
- package/templates/.claude/hooks/scope-guard.sh +106 -27
- package/templates/.claude/hooks/worktree-write-guard.sh +96 -3
- package/templates/.claude/rules/worktree-isolation.md +21 -3
- package/templates/.claude/settings.json +5 -0
- package/templates/CLAUDE.md +56 -0
- package/templates/agents.md +47 -0
package/dist/commands/specs.js
CHANGED
|
@@ -16,8 +16,9 @@ const { SPEC_TYPES } = require('../constants/spec-types');
|
|
|
16
16
|
const { suggestFeatureBreakdown } = require('../utils/spec-resolver');
|
|
17
17
|
const { findProjectRoot } = require('../utils/detection');
|
|
18
18
|
const { loadRegistry: loadWorktreeRegistry, getRepoRoot } = require('../worktree/worktree-manager');
|
|
19
|
-
const { getAgentSessionId } = require('../utils/agent-session');
|
|
19
|
+
const { getAgentSessionId, refreshAgentClaim } = require('../utils/agent-session');
|
|
20
20
|
const { initializeState, saveState, deleteState } = require('../utils/working-state');
|
|
21
|
+
const { appendEvent } = require('../utils/event-log');
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Check if a spec is referenced by any active worktree.
|
|
@@ -56,6 +57,35 @@ function getSpecsDir() {
|
|
|
56
57
|
function getSpecsRegistry() {
|
|
57
58
|
return path.join(findProjectRoot(), '.caws', 'specs', 'registry.json');
|
|
58
59
|
}
|
|
60
|
+
|
|
61
|
+
function detectCurrentWorktreeName() {
|
|
62
|
+
const cwd = process.cwd().replace(/\\/g, '/');
|
|
63
|
+
const worktreeMatch = cwd.match(/\/\.caws\/worktrees\/([^/]+)(?:\/|$)/);
|
|
64
|
+
if (worktreeMatch) {
|
|
65
|
+
return worktreeMatch[1];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const root = getRepoRoot();
|
|
70
|
+
const branch = require('child_process')
|
|
71
|
+
.execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
72
|
+
cwd: root,
|
|
73
|
+
encoding: 'utf8',
|
|
74
|
+
stdio: 'pipe',
|
|
75
|
+
})
|
|
76
|
+
.trim();
|
|
77
|
+
const registry = loadWorktreeRegistry(root);
|
|
78
|
+
for (const [name, entry] of Object.entries(registry.worktrees || {})) {
|
|
79
|
+
if (entry.branch === branch && entry.status !== 'destroyed' && entry.status !== 'merged') {
|
|
80
|
+
return name;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// Best-effort only; specs can still be created outside a worktree.
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
59
89
|
// Legacy constants kept for backward compatibility in tests
|
|
60
90
|
const SPECS_DIR = '.caws/specs';
|
|
61
91
|
const SPECS_REGISTRY = '.caws/specs/registry.json';
|
|
@@ -194,6 +224,17 @@ function normalizeSpecForValidation(spec = {}) {
|
|
|
194
224
|
* List all spec files in the specs directory
|
|
195
225
|
* @returns {Promise<Array>} Array of spec file info
|
|
196
226
|
*/
|
|
227
|
+
// Files under this subdir of `.caws/specs/` are treated as archived,
|
|
228
|
+
// regardless of what the YAML's `status` field says (CAWSFIX-29 invariant:
|
|
229
|
+
// directory location is authoritative for archive state).
|
|
230
|
+
const ARCHIVE_SUBDIR = '.archive';
|
|
231
|
+
|
|
232
|
+
function isArchivePath(relPath) {
|
|
233
|
+
if (!relPath) return false;
|
|
234
|
+
const normalized = relPath.replace(/\\/g, '/');
|
|
235
|
+
return normalized === ARCHIVE_SUBDIR || normalized.startsWith(`${ARCHIVE_SUBDIR}/`);
|
|
236
|
+
}
|
|
237
|
+
|
|
197
238
|
async function listSpecFiles() {
|
|
198
239
|
const specsDir = getSpecsDir();
|
|
199
240
|
if (!(await fs.pathExists(specsDir))) {
|
|
@@ -209,13 +250,14 @@ async function listSpecFiles() {
|
|
|
209
250
|
try {
|
|
210
251
|
const content = await fs.readFile(filePath, 'utf8');
|
|
211
252
|
const spec = yaml.load(content);
|
|
253
|
+
const inArchive = isArchivePath(file);
|
|
212
254
|
|
|
213
255
|
specs.push({
|
|
214
256
|
id: spec.id || path.basename(file, path.extname(file)),
|
|
215
257
|
path: file,
|
|
216
258
|
type: spec.type || 'feature',
|
|
217
259
|
title: spec.title || 'Untitled',
|
|
218
|
-
status: spec.status || 'draft',
|
|
260
|
+
status: inArchive ? 'archived' : spec.status || 'draft',
|
|
219
261
|
risk_tier: spec.risk_tier || 'T3',
|
|
220
262
|
mode: spec.mode || 'development',
|
|
221
263
|
created_at: spec.created_at || new Date().toISOString(),
|
|
@@ -259,6 +301,40 @@ async function createSpec(id, options = {}) {
|
|
|
259
301
|
const existingSpecPath = path.join(specsDir, `${id}.yaml`);
|
|
260
302
|
const specExists = await fs.pathExists(existingSpecPath);
|
|
261
303
|
|
|
304
|
+
// CAWSFIX-30: archive-collision guard. An id that lives in `.archive/`
|
|
305
|
+
// is still a taken id — surface it before going further. Detection is
|
|
306
|
+
// filesystem-driven (not registry-driven) so manually-moved legacy
|
|
307
|
+
// specs are also caught.
|
|
308
|
+
const archivedSpecPath = path.join(specsDir, ARCHIVE_SUBDIR, `${id}.yaml`);
|
|
309
|
+
const archivedExists = !specExists && (await fs.pathExists(archivedSpecPath));
|
|
310
|
+
if (archivedExists && !force) {
|
|
311
|
+
console.error(
|
|
312
|
+
chalk.red(
|
|
313
|
+
`Spec '${id}' already exists in archive: ${path.relative(findProjectRoot(), archivedSpecPath)}`
|
|
314
|
+
)
|
|
315
|
+
);
|
|
316
|
+
console.error(
|
|
317
|
+
chalk.yellow(
|
|
318
|
+
`Use --force to remove the archived copy and resurrect the id, ` +
|
|
319
|
+
`or pick a different id.`
|
|
320
|
+
)
|
|
321
|
+
);
|
|
322
|
+
throw new Error(
|
|
323
|
+
`Spec '${id}' collides with archived spec at .archive/${id}.yaml. ` +
|
|
324
|
+
`Use --force to resurrect or choose another id.`
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
if (archivedExists && force) {
|
|
328
|
+
// Resurrection: drop the archived YAML and any registry pointer so
|
|
329
|
+
// the rest of createSpec can write a fresh draft cleanly.
|
|
330
|
+
await fs.remove(archivedSpecPath);
|
|
331
|
+
const registry = await loadSpecsRegistry();
|
|
332
|
+
if (registry.specs[id]) {
|
|
333
|
+
delete registry.specs[id];
|
|
334
|
+
await saveSpecsRegistry(registry);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
262
338
|
// Handle conflict resolution
|
|
263
339
|
let answer = null;
|
|
264
340
|
|
|
@@ -374,6 +450,11 @@ async function createSpec(id, options = {}) {
|
|
|
374
450
|
contracts: [],
|
|
375
451
|
};
|
|
376
452
|
|
|
453
|
+
const detectedWorktree = detectCurrentWorktreeName();
|
|
454
|
+
if (detectedWorktree) {
|
|
455
|
+
defaultSpec.worktree = detectedWorktree;
|
|
456
|
+
}
|
|
457
|
+
|
|
377
458
|
// Merge template, but preserve required structure
|
|
378
459
|
// Map template.criteria to acceptance if present
|
|
379
460
|
const templateAcceptance = template?.criteria || template?.acceptance;
|
|
@@ -456,6 +537,67 @@ async function createSpec(id, options = {}) {
|
|
|
456
537
|
saveState(id, initialState, findProjectRoot());
|
|
457
538
|
} catch { /* non-fatal */ }
|
|
458
539
|
|
|
540
|
+
// CAWSFIX-06: warn when a feature spec is created without contracts.
|
|
541
|
+
// Contract-first development is a CAWS value proposition; empty `contracts`
|
|
542
|
+
// on a feature-type spec is discouraged but not fatal. Emit a non-fatal
|
|
543
|
+
// warning to stderr so agents and humans notice and can update the spec.
|
|
544
|
+
//
|
|
545
|
+
// Note: the spec's acceptance text uses "mode=feature" colloquially, but in
|
|
546
|
+
// CAWS the discriminator is the `type` field (feature/fix/refactor/chore),
|
|
547
|
+
// not the `mode` field (development/pilot/etc.). We key off `type` to match
|
|
548
|
+
// the --type CLI flag and the schema.
|
|
549
|
+
const specType = parsedSpec.type || type;
|
|
550
|
+
const specContracts = Array.isArray(parsedSpec.contracts) ? parsedSpec.contracts : [];
|
|
551
|
+
if (specType === 'feature' && specContracts.length === 0) {
|
|
552
|
+
console.warn(
|
|
553
|
+
chalk.yellow(
|
|
554
|
+
`⚠ Spec ${id} has mode=feature but no contracts. ` +
|
|
555
|
+
`mode=feature without contracts is discouraged — ` +
|
|
556
|
+
`run 'caws specs update ${id}' to add a contract reference.`
|
|
557
|
+
)
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// EVLOG-001: emit spec_created event alongside state write.
|
|
562
|
+
//
|
|
563
|
+
// Spec-lifecycle events (spec_created / spec_closed / spec_deleted) are
|
|
564
|
+
// **informational redundancy** with the spec file + registry, which are
|
|
565
|
+
// the true sources of truth for spec identity. In contrast, the
|
|
566
|
+
// validation/evaluation/gates/verify_acs events are the ONLY record of
|
|
567
|
+
// those verification runs and losing them is real data loss.
|
|
568
|
+
//
|
|
569
|
+
// So we deliberately wrap spec-lifecycle emits in try/catch: a
|
|
570
|
+
// filesystem error here (test mocks, readonly fs, etc.) must not crash
|
|
571
|
+
// the spec create/close/delete flow, because the spec file itself is
|
|
572
|
+
// already persisted by the time we get here. This is a principled
|
|
573
|
+
// divergence from the strict contract for the observation events —
|
|
574
|
+
// see docs/internal/EVENTS_LOG_MIGRATION.md §4.5 and EVLOG-001 spec.
|
|
575
|
+
try {
|
|
576
|
+
await appendEvent(
|
|
577
|
+
{
|
|
578
|
+
actor: 'cli',
|
|
579
|
+
event: 'spec_created',
|
|
580
|
+
spec_id: id,
|
|
581
|
+
data: {
|
|
582
|
+
id,
|
|
583
|
+
type: parsedSpec.type || type,
|
|
584
|
+
title: parsedSpec.title || title,
|
|
585
|
+
risk_tier: parsedSpec.risk_tier || numericRiskTier,
|
|
586
|
+
mode: parsedSpec.mode || mode,
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
{ projectRoot: findProjectRoot() }
|
|
590
|
+
);
|
|
591
|
+
} catch (err) {
|
|
592
|
+
// Surface on stderr but don't propagate — the spec is already created.
|
|
593
|
+
|
|
594
|
+
console.error(`event-log: failed to record spec_created for ${id}: ${err.message}`);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// CAWSFIX-31: refresh the current agent's claim so agents.json reflects
|
|
598
|
+
// the current spec context. Best-effort — failures must not break the op.
|
|
599
|
+
refreshAgentClaim(findProjectRoot(), { specId: id });
|
|
600
|
+
|
|
459
601
|
return {
|
|
460
602
|
id,
|
|
461
603
|
path: fileName,
|
|
@@ -717,6 +859,23 @@ async function deleteSpec(id) {
|
|
|
717
859
|
delete registry.specs[id];
|
|
718
860
|
await saveSpecsRegistry(registry);
|
|
719
861
|
|
|
862
|
+
// EVLOG-001: emit spec_deleted event in best-effort mode. See the
|
|
863
|
+
// createSpec commentary for why spec-lifecycle events diverge from
|
|
864
|
+
// the strict fail-loud contract used by the observation events.
|
|
865
|
+
try {
|
|
866
|
+
await appendEvent(
|
|
867
|
+
{ actor: 'cli', event: 'spec_deleted', spec_id: id, data: { id } },
|
|
868
|
+
{ projectRoot: findProjectRoot() }
|
|
869
|
+
);
|
|
870
|
+
} catch (err) {
|
|
871
|
+
|
|
872
|
+
console.error(`event-log: failed to record spec_deleted for ${id}: ${err.message}`);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// CAWSFIX-31: every lifecycle verb refreshes for consistency, even
|
|
876
|
+
// delete (no other cleanup runs on delete; this is signal-of-presence).
|
|
877
|
+
refreshAgentClaim(findProjectRoot(), { specId: id });
|
|
878
|
+
|
|
720
879
|
return true;
|
|
721
880
|
}
|
|
722
881
|
|
|
@@ -769,7 +928,178 @@ async function closeSpec(id) {
|
|
|
769
928
|
return false;
|
|
770
929
|
}
|
|
771
930
|
|
|
772
|
-
|
|
931
|
+
// CAWSFIX-15: status-only flip uses targeted line-replace so the diff
|
|
932
|
+
// stays a single line. Full `updateSpec` reserializes the whole YAML,
|
|
933
|
+
// reordering fields and injecting `*ref_0` anchors for the
|
|
934
|
+
// acceptance/acceptance_criteria alias — ~20 lines of noise for what
|
|
935
|
+
// should be a one-word change.
|
|
936
|
+
const specPath = path.join(getSpecsDir(), registry.specs[id].path);
|
|
937
|
+
const original = await fs.readFile(specPath, 'utf8');
|
|
938
|
+
const nowIso = new Date().toISOString();
|
|
939
|
+
let patched = original.replace(/^status:\s*\S+\s*$/m, 'status: closed');
|
|
940
|
+
patched = patched.replace(/^updated_at:.*$/m, `updated_at: '${nowIso}'`);
|
|
941
|
+
let ok = false;
|
|
942
|
+
if (patched !== original) {
|
|
943
|
+
await fs.writeFile(specPath, patched);
|
|
944
|
+
registry.specs[id] = {
|
|
945
|
+
...registry.specs[id],
|
|
946
|
+
status: 'closed',
|
|
947
|
+
updated_at: nowIso,
|
|
948
|
+
};
|
|
949
|
+
await saveSpecsRegistry(registry);
|
|
950
|
+
ok = true;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// EVLOG-001: emit spec_closed event after the status update succeeds.
|
|
954
|
+
// Records the prior status so the renderer can reconstruct the lifecycle.
|
|
955
|
+
// Best-effort mode — see createSpec commentary.
|
|
956
|
+
if (ok) {
|
|
957
|
+
try {
|
|
958
|
+
await appendEvent(
|
|
959
|
+
{
|
|
960
|
+
actor: 'cli',
|
|
961
|
+
event: 'spec_closed',
|
|
962
|
+
spec_id: id,
|
|
963
|
+
data: { id, prior_status: currentStatus },
|
|
964
|
+
},
|
|
965
|
+
{ projectRoot: findProjectRoot() }
|
|
966
|
+
);
|
|
967
|
+
} catch (err) {
|
|
968
|
+
|
|
969
|
+
console.error(`event-log: failed to record spec_closed for ${id}: ${err.message}`);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// CAWSFIX-31: refresh agent claim — also fires on no-op closes
|
|
973
|
+
// (already-closed specs return ok=true after an early exit) only
|
|
974
|
+
// when the status actually flipped, since this is the signal that
|
|
975
|
+
// matters: the agent is currently working on this spec.
|
|
976
|
+
refreshAgentClaim(findProjectRoot(), { specId: id });
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
return ok;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Archive a spec: move its YAML to `.caws/specs/.archive/<id>.yaml`,
|
|
984
|
+
* flip status to `archived`, update the registry, and emit a `spec_archived`
|
|
985
|
+
* event. The archive directory is the canonical truth for archive state —
|
|
986
|
+
* the listing layer (listSpecFiles) treats any file under `.archive/` as
|
|
987
|
+
* archived regardless of the YAML literal.
|
|
988
|
+
*
|
|
989
|
+
* @param {string} id - Spec identifier
|
|
990
|
+
* @returns {Promise<boolean>} true on success (including idempotent no-ops),
|
|
991
|
+
* false on validation/lookup failure.
|
|
992
|
+
*/
|
|
993
|
+
async function archiveSpec(id) {
|
|
994
|
+
// Path-traversal guard: ids must be plain filenames, not paths.
|
|
995
|
+
// Reject before touching any filesystem state.
|
|
996
|
+
if (!id || typeof id !== 'string' || path.basename(id) !== id || id.includes('..')) {
|
|
997
|
+
console.error(chalk.red(`Invalid spec id '${id}': must be a plain identifier`));
|
|
998
|
+
return false;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const registry = await loadSpecsRegistry();
|
|
1002
|
+
const entry = registry.specs[id];
|
|
1003
|
+
if (!entry) {
|
|
1004
|
+
console.error(chalk.red(`Spec '${id}' not found`));
|
|
1005
|
+
return false;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// Block if owned by another session (mirror closeSpec/deleteSpec).
|
|
1009
|
+
const currentSession = getAgentSessionId(findProjectRoot());
|
|
1010
|
+
if (entry.owner && currentSession && entry.owner !== currentSession) {
|
|
1011
|
+
console.error(
|
|
1012
|
+
chalk.red(
|
|
1013
|
+
`Cannot archive spec '${id}': owned by another session (${entry.owner}). ` +
|
|
1014
|
+
`Only the creator session can archive a spec.`
|
|
1015
|
+
)
|
|
1016
|
+
);
|
|
1017
|
+
return false;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Block if active worktrees still reference the spec — archiving removes
|
|
1021
|
+
// scope enforcement and would invalidate in-flight work.
|
|
1022
|
+
const referencingWorktrees = getWorktreesReferencingSpec(id);
|
|
1023
|
+
if (referencingWorktrees.length > 0) {
|
|
1024
|
+
const names = referencingWorktrees.join(', ');
|
|
1025
|
+
console.error(
|
|
1026
|
+
chalk.red(
|
|
1027
|
+
`Cannot archive spec '${id}': active worktree(s) [${names}] reference it. ` +
|
|
1028
|
+
`Destroy the worktree(s) first with 'caws worktree destroy <name>'.`
|
|
1029
|
+
)
|
|
1030
|
+
);
|
|
1031
|
+
return false;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const specsDir = getSpecsDir();
|
|
1035
|
+
const priorPath = entry.path;
|
|
1036
|
+
const currentSpecPath = path.join(specsDir, priorPath);
|
|
1037
|
+
|
|
1038
|
+
// If the file is already in the archive directory, the canonical
|
|
1039
|
+
// location is satisfied — just ensure the registry status agrees and exit.
|
|
1040
|
+
if (isArchivePath(priorPath)) {
|
|
1041
|
+
if (entry.status !== 'archived') {
|
|
1042
|
+
registry.specs[id] = { ...entry, status: 'archived' };
|
|
1043
|
+
await saveSpecsRegistry(registry);
|
|
1044
|
+
}
|
|
1045
|
+
console.log(chalk.yellow(`Spec '${id}' is already archived.`));
|
|
1046
|
+
return true;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if (!(await fs.pathExists(currentSpecPath))) {
|
|
1050
|
+
console.error(
|
|
1051
|
+
chalk.red(`Cannot archive spec '${id}': file missing at ${currentSpecPath}`)
|
|
1052
|
+
);
|
|
1053
|
+
return false;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// CAWSFIX-15-style targeted rewrite: only `status:` and `updated_at:`
|
|
1057
|
+
// lines move. Comments, ordering, and YAML aliases survive untouched.
|
|
1058
|
+
const original = await fs.readFile(currentSpecPath, 'utf8');
|
|
1059
|
+
const priorStatus = entry.status || 'draft';
|
|
1060
|
+
const nowIso = new Date().toISOString();
|
|
1061
|
+
let patched = original.replace(/^status:\s*\S+\s*$/m, 'status: archived');
|
|
1062
|
+
patched = patched.replace(/^updated_at:.*$/m, `updated_at: '${nowIso}'`);
|
|
1063
|
+
|
|
1064
|
+
const archiveDir = path.join(specsDir, ARCHIVE_SUBDIR);
|
|
1065
|
+
await fs.ensureDir(archiveDir);
|
|
1066
|
+
const newRelPath = `${ARCHIVE_SUBDIR}/${id}.yaml`;
|
|
1067
|
+
const newAbsPath = path.join(specsDir, newRelPath);
|
|
1068
|
+
|
|
1069
|
+
// Write the patched content to the archive location, then remove the
|
|
1070
|
+
// original. fs-extra's writeFile is atomic-enough for single-file moves
|
|
1071
|
+
// on the same filesystem; we avoid `move` because we already mutated content.
|
|
1072
|
+
await fs.writeFile(newAbsPath, patched);
|
|
1073
|
+
await fs.remove(currentSpecPath);
|
|
1074
|
+
|
|
1075
|
+
registry.specs[id] = {
|
|
1076
|
+
...entry,
|
|
1077
|
+
path: newRelPath,
|
|
1078
|
+
status: 'archived',
|
|
1079
|
+
updated_at: nowIso,
|
|
1080
|
+
};
|
|
1081
|
+
await saveSpecsRegistry(registry);
|
|
1082
|
+
|
|
1083
|
+
// Best-effort event emission, matching spec_closed/spec_deleted policy:
|
|
1084
|
+
// event-log failure does not roll back the archive operation.
|
|
1085
|
+
try {
|
|
1086
|
+
await appendEvent(
|
|
1087
|
+
{
|
|
1088
|
+
actor: 'cli',
|
|
1089
|
+
event: 'spec_archived',
|
|
1090
|
+
spec_id: id,
|
|
1091
|
+
data: { id, prior_status: priorStatus, prior_path: priorPath },
|
|
1092
|
+
},
|
|
1093
|
+
{ projectRoot: findProjectRoot() }
|
|
1094
|
+
);
|
|
1095
|
+
} catch (err) {
|
|
1096
|
+
console.error(`event-log: failed to record spec_archived for ${id}: ${err.message}`);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// CAWSFIX-31: refresh agent claim after a successful archive transition.
|
|
1100
|
+
refreshAgentClaim(findProjectRoot(), { specId: id });
|
|
1101
|
+
|
|
1102
|
+
return true;
|
|
773
1103
|
}
|
|
774
1104
|
|
|
775
1105
|
/**
|
|
@@ -1255,6 +1585,28 @@ async function specsCommand(action, options = {}) {
|
|
|
1255
1585
|
});
|
|
1256
1586
|
}
|
|
1257
1587
|
|
|
1588
|
+
case 'archive': {
|
|
1589
|
+
if (!options.id) {
|
|
1590
|
+
throw new Error('Spec ID is required. Usage: caws specs archive <id>');
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
const archived = await archiveSpec(options.id);
|
|
1594
|
+
if (!archived) {
|
|
1595
|
+
throw new Error(`Could not archive spec '${options.id}'`);
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
console.log(
|
|
1599
|
+
chalk.green(
|
|
1600
|
+
`Archived spec: ${options.id} -- moved to .caws/specs/.archive/`
|
|
1601
|
+
)
|
|
1602
|
+
);
|
|
1603
|
+
|
|
1604
|
+
return outputResult({
|
|
1605
|
+
command: 'specs archive',
|
|
1606
|
+
spec: options.id,
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1258
1610
|
case 'types': {
|
|
1259
1611
|
console.log(chalk.bold.cyan('\nAvailable Spec Types'));
|
|
1260
1612
|
console.log(chalk.cyan('==============================================\n'));
|
|
@@ -1273,7 +1625,7 @@ async function specsCommand(action, options = {}) {
|
|
|
1273
1625
|
|
|
1274
1626
|
default:
|
|
1275
1627
|
throw new Error(
|
|
1276
|
-
`Unknown specs action: ${action}. Use: list, create, show, update, delete, close, conflicts, migrate, types`
|
|
1628
|
+
`Unknown specs action: ${action}. Use: list, create, show, update, delete, close, archive, conflicts, migrate, types`
|
|
1277
1629
|
);
|
|
1278
1630
|
}
|
|
1279
1631
|
},
|
|
@@ -1292,10 +1644,13 @@ module.exports = {
|
|
|
1292
1644
|
updateSpec,
|
|
1293
1645
|
deleteSpec,
|
|
1294
1646
|
closeSpec,
|
|
1647
|
+
archiveSpec,
|
|
1295
1648
|
displaySpecsTable,
|
|
1296
1649
|
displaySpecDetails,
|
|
1297
1650
|
askConflictResolution,
|
|
1651
|
+
isArchivePath,
|
|
1298
1652
|
SPECS_DIR,
|
|
1299
1653
|
SPECS_REGISTRY,
|
|
1654
|
+
ARCHIVE_SUBDIR,
|
|
1300
1655
|
SPEC_TYPES,
|
|
1301
1656
|
};
|
package/dist/commands/status.js
CHANGED
|
@@ -12,7 +12,11 @@ const chalk = require('chalk');
|
|
|
12
12
|
const { safeAsync, outputResult } = require('../error-handler');
|
|
13
13
|
const { parallel } = require('../utils/async-utils');
|
|
14
14
|
const { resolveSpec } = require('../utils/spec-resolver');
|
|
15
|
-
|
|
15
|
+
// EVLOG-002 Phase 2 read flip: status reads working state from the event log
|
|
16
|
+
// via the pure renderer. loadStateFromEvents matches loadState's null contract
|
|
17
|
+
// exactly, so the `ws && ws.phase !== 'not-started'` guard and the
|
|
18
|
+
// `loadState(id) || null` coalesce below stay semantically correct.
|
|
19
|
+
const { loadStateFromEvents } = require('../utils/event-renderer');
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
22
|
* Load working specification (legacy single file approach)
|
|
@@ -359,6 +363,27 @@ function displayStatus(data) {
|
|
|
359
363
|
console.log(chalk.bold.cyan('\nCAWS Project Status'));
|
|
360
364
|
console.log(chalk.cyan('==============================================\n'));
|
|
361
365
|
|
|
366
|
+
// CAWSFIX-31: Surface worktree claim if cwd is inside a worktree.
|
|
367
|
+
// Best-effort — failures (no .caws, no registry, etc.) are silent.
|
|
368
|
+
try {
|
|
369
|
+
const path = require('path');
|
|
370
|
+
const { findProjectRoot } = require('../utils/detection');
|
|
371
|
+
const { renderClaimPanel } = require('../utils/agent-display');
|
|
372
|
+
const root = findProjectRoot();
|
|
373
|
+
const cwd = process.cwd();
|
|
374
|
+
const worktreesBase = path.join(root, '.caws', 'worktrees');
|
|
375
|
+
if (cwd.startsWith(worktreesBase + path.sep)) {
|
|
376
|
+
const worktreeName = path.relative(worktreesBase, cwd).split(path.sep)[0];
|
|
377
|
+
const panel = renderClaimPanel(root, worktreeName);
|
|
378
|
+
if (panel) {
|
|
379
|
+
console.log(chalk.green(panel));
|
|
380
|
+
console.log('');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
} catch {
|
|
384
|
+
// best-effort
|
|
385
|
+
}
|
|
386
|
+
|
|
362
387
|
// Working Spec Status
|
|
363
388
|
if (spec) {
|
|
364
389
|
console.log(chalk.green('Working Spec'));
|
|
@@ -375,10 +400,10 @@ function displayStatus(data) {
|
|
|
375
400
|
|
|
376
401
|
console.log('');
|
|
377
402
|
|
|
378
|
-
// Working State
|
|
403
|
+
// Working State (EVLOG-002: from event log)
|
|
379
404
|
if (spec && spec.id) {
|
|
380
405
|
let ws = null;
|
|
381
|
-
try { ws =
|
|
406
|
+
try { ws = loadStateFromEvents(spec.id); } catch { /* non-fatal */ }
|
|
382
407
|
if (ws && ws.phase !== 'not-started') {
|
|
383
408
|
const phaseLabels = {
|
|
384
409
|
'not-started': 'Not Started',
|
|
@@ -1052,7 +1077,7 @@ async function statusCommand(options = {}) {
|
|
|
1052
1077
|
passed: gates.passed,
|
|
1053
1078
|
message: gates.message,
|
|
1054
1079
|
},
|
|
1055
|
-
workingState: spec && spec.id ? (
|
|
1080
|
+
workingState: spec && spec.id ? (loadStateFromEvents(spec.id) || null) : null,
|
|
1056
1081
|
overallProgress: calculateOverallProgress({
|
|
1057
1082
|
spec,
|
|
1058
1083
|
specSelection,
|
|
@@ -53,14 +53,6 @@ const BUILTIN_TEMPLATES = {
|
|
|
53
53
|
features: ['React', 'TypeScript', 'Storybook', 'Jest', 'Publishing'],
|
|
54
54
|
path: 'templates/react/component-library',
|
|
55
55
|
},
|
|
56
|
-
'vscode-extension': {
|
|
57
|
-
name: 'VS Code Extension',
|
|
58
|
-
description: 'VS Code extension with TypeScript',
|
|
59
|
-
category: 'Extension',
|
|
60
|
-
tier: 2,
|
|
61
|
-
features: ['TypeScript', 'VS Code API', 'Jest', 'Publishing'],
|
|
62
|
-
path: 'templates/vscode-extension',
|
|
63
|
-
},
|
|
64
56
|
};
|
|
65
57
|
|
|
66
58
|
/**
|
|
@@ -20,6 +20,7 @@ const {
|
|
|
20
20
|
loadSpecsRegistry,
|
|
21
21
|
} = require('../utils/spec-resolver');
|
|
22
22
|
const { recordValidation } = require('../utils/working-state');
|
|
23
|
+
const { appendEvent } = require('../utils/event-log');
|
|
23
24
|
const { lifecycle, EVENTS } = require('../utils/lifecycle-events');
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -143,19 +144,39 @@ async function validateCommand(specFile, options = {}) {
|
|
|
143
144
|
|
|
144
145
|
const finalResult = enhancedValidation;
|
|
145
146
|
|
|
146
|
-
// Record to working state
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
147
|
+
// Record to working state (Phase 1 dual-write: state layer + event log)
|
|
148
|
+
const validationGrade = finalResult.complianceScore !== undefined
|
|
149
|
+
? getComplianceGrade(finalResult.complianceScore)
|
|
150
|
+
: null;
|
|
151
|
+
const validationPayload = {
|
|
152
|
+
passed: finalResult.valid,
|
|
153
|
+
compliance_score: finalResult.complianceScore ?? null,
|
|
154
|
+
grade: validationGrade,
|
|
155
|
+
error_count: (finalResult.errors || []).length,
|
|
156
|
+
warning_count: (finalResult.warnings || []).length,
|
|
157
|
+
};
|
|
158
|
+
// CAWSFIX-02: guard recordValidation with the same `spec && spec.id`
|
|
159
|
+
// check that gates.js already uses and that the appendEvent call below
|
|
160
|
+
// enforces. Without this, legacy working-specs without an id silently
|
|
161
|
+
// wrote `.caws/state/undefined.json` — now blocked by the state-layer
|
|
162
|
+
// fence, but guarding here keeps the intent explicit.
|
|
163
|
+
if (spec && spec.id) {
|
|
164
|
+
try {
|
|
165
|
+
recordValidation(spec.id, validationPayload);
|
|
166
|
+
} catch { /* non-fatal */ }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// EVLOG-001: emit event log entry alongside state write. Only if
|
|
170
|
+
// spec.id is present — the fence in appendEvent would throw otherwise,
|
|
171
|
+
// which is intentional for the undefined.json bug class but wrong for
|
|
172
|
+
// legitimate legacy specs without an id field. Errors here are NOT
|
|
173
|
+
// swallowed: a failure to append an event is a real defect we want to
|
|
174
|
+
// surface, not a silent data-loss event.
|
|
175
|
+
if (spec && spec.id) {
|
|
176
|
+
await appendEvent(
|
|
177
|
+
{ actor: 'cli', event: 'validation_completed', spec_id: spec.id, data: validationPayload }
|
|
178
|
+
);
|
|
179
|
+
}
|
|
159
180
|
|
|
160
181
|
// Emit lifecycle event
|
|
161
182
|
try {
|
|
@@ -12,6 +12,7 @@ const { execFileSync } = require('child_process');
|
|
|
12
12
|
const { findProjectRoot } = require('../utils/detection');
|
|
13
13
|
const { resolveSpec } = require('../utils/spec-resolver');
|
|
14
14
|
const { recordACVerification } = require('../utils/working-state');
|
|
15
|
+
const { appendEvent } = require('../utils/event-log');
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Detect the project's test runner from config files.
|
|
@@ -350,16 +351,30 @@ async function verifyAcsCommand(options = {}) {
|
|
|
350
351
|
else { totalUnchecked++; }
|
|
351
352
|
}
|
|
352
353
|
|
|
353
|
-
// Record to working state
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
354
|
+
// Record to working state (Phase 1 dual-write: state layer + event log)
|
|
355
|
+
const acPayload = {
|
|
356
|
+
total: totalAcs,
|
|
357
|
+
pass: totalPass,
|
|
358
|
+
fail: totalFail,
|
|
359
|
+
unchecked: totalUnchecked,
|
|
360
|
+
results: result.results,
|
|
361
|
+
};
|
|
362
|
+
// CAWSFIX-02: guard recordACVerification with `resolved.spec && resolved.spec.id`
|
|
363
|
+
// check to prevent the .caws/state/undefined.json bug class. Matches the
|
|
364
|
+
// pattern gates.js already uses and the appendEvent call below.
|
|
365
|
+
if (resolved.spec && resolved.spec.id) {
|
|
366
|
+
try {
|
|
367
|
+
recordACVerification(resolved.spec.id, acPayload, projectRoot);
|
|
368
|
+
} catch { /* non-fatal */ }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// EVLOG-001: emit verify_acs_completed event alongside state write.
|
|
372
|
+
if (resolved.spec && resolved.spec.id) {
|
|
373
|
+
await appendEvent(
|
|
374
|
+
{ actor: 'cli', event: 'verify_acs_completed', spec_id: resolved.spec.id, data: acPayload },
|
|
375
|
+
{ projectRoot }
|
|
376
|
+
);
|
|
377
|
+
}
|
|
363
378
|
|
|
364
379
|
// Output
|
|
365
380
|
if (options.format === 'json') {
|