@paths.design/caws-cli 10.1.0 → 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/dist/commands/agents.js +124 -0
- package/dist/commands/specs.js +214 -6
- package/dist/commands/status.js +21 -0
- package/dist/commands/worktree.js +134 -18
- package/dist/gates/scope-boundary.js +26 -7
- package/dist/index.js +29 -0
- package/dist/policy/PolicyManager.js +5 -0
- package/dist/templates/.caws/schemas/policy.schema.json +5 -0
- package/dist/templates/.caws/schemas/working-spec.schema.json +2 -2
- package/dist/templates/.claude/rules/worktree-isolation.md +21 -3
- package/dist/templates/CLAUDE.md +22 -0
- package/dist/templates/agents.md +26 -0
- package/dist/utils/agent-display.js +210 -0
- package/dist/utils/agent-session.js +142 -0
- package/dist/validation/spec-validation.js +7 -4
- package/dist/worktree/worktree-manager.js +407 -46
- package/package.json +1 -1
- package/templates/.caws/schemas/policy.schema.json +5 -0
- package/templates/.caws/schemas/working-spec.schema.json +2 -2
- package/templates/.claude/rules/worktree-isolation.md +21 -3
- package/templates/CLAUDE.md +22 -0
- package/templates/agents.md +26 -0
|
@@ -16,31 +16,34 @@ const { createValidator, getSchemaPath } = require('../utils/schema-validator');
|
|
|
16
16
|
* Accepts:
|
|
17
17
|
* - Single-segment: FEAT-001, EVLOG-002, CAWSFIX-06 (legacy shape)
|
|
18
18
|
* - Multi-segment: P03-IMPL-01, ALG-001A-HARDEN-01, CAWS-FIX-03
|
|
19
|
+
* - Lowercase suffix: APC-01a, ALG-01b (CAWSFIX-25 / D2)
|
|
19
20
|
*
|
|
20
21
|
* Rejects:
|
|
21
|
-
* - lowercase (feat-001)
|
|
22
|
+
* - lowercase prefix (feat-001)
|
|
23
|
+
* - lowercase in a non-final segment (AB-cd-01)
|
|
22
24
|
* - leading digit (01-FEAT)
|
|
23
25
|
* - missing number suffix (FEAT-)
|
|
24
26
|
* - trailing hyphen (FEAT-01-)
|
|
25
27
|
* - leading/double hyphen (--FEAT-01, FEAT--001)
|
|
26
28
|
* - empty string
|
|
27
29
|
*
|
|
28
|
-
* Grammar: [PREFIX](-[SEGMENT])*-NUMBER
|
|
30
|
+
* Grammar: [PREFIX](-[SEGMENT])*-NUMBER[SUFFIX]?
|
|
29
31
|
* - PREFIX = [A-Z] followed by zero+ [A-Z0-9]
|
|
30
32
|
* - SEGMENT = one+ [A-Z0-9] (alphanumeric, uppercase only)
|
|
31
33
|
* - NUMBER = one+ digits
|
|
34
|
+
* - SUFFIX = zero+ [a-z] (optional lowercase tail on final segment only)
|
|
32
35
|
*
|
|
33
36
|
* Defined once per A4 invariant; referenced by both the basic validator
|
|
34
37
|
* (line ~125 pre-fix) and the enhanced validator (line ~307 pre-fix).
|
|
35
38
|
*/
|
|
36
|
-
const SPEC_ID_PATTERN = /^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*-\d
|
|
39
|
+
const SPEC_ID_PATTERN = /^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*-\d+[a-z]*$/;
|
|
37
40
|
|
|
38
41
|
/**
|
|
39
42
|
* User-facing error message for bad spec IDs (CAWSFIX-10 A5).
|
|
40
43
|
* Kept as a module constant so the message stays in sync with the pattern.
|
|
41
44
|
*/
|
|
42
45
|
const SPEC_ID_ERROR_MESSAGE =
|
|
43
|
-
'Project ID should be in format: PREFIX-NUMBER or PREFIX-SEGMENT-NUMBER (e.g., FEAT-001, P03-IMPL-01)';
|
|
46
|
+
'Project ID should be in format: PREFIX-NUMBER or PREFIX-SEGMENT-NUMBER with optional lowercase suffix (e.g., FEAT-001, P03-IMPL-01, APC-01a)';
|
|
44
47
|
|
|
45
48
|
/**
|
|
46
49
|
* Get actual budget statistics from git history
|
|
@@ -9,7 +9,13 @@ const fs = require('fs-extra');
|
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const chalk = require('chalk');
|
|
11
11
|
const { createValidator, getSchemaPath } = require('../utils/schema-validator');
|
|
12
|
-
const {
|
|
12
|
+
const {
|
|
13
|
+
getAgentSessionId,
|
|
14
|
+
loadAgentRegistry,
|
|
15
|
+
findSessionLogs,
|
|
16
|
+
refreshAgentClaim,
|
|
17
|
+
} = require('../utils/agent-session');
|
|
18
|
+
const { formatClaimNotice, formatOrphanLogHint } = require('../utils/agent-display');
|
|
13
19
|
const { lifecycle, EVENTS } = require('../utils/lifecycle-events');
|
|
14
20
|
|
|
15
21
|
const WORKTREES_DIR = '.caws/worktrees';
|
|
@@ -27,6 +33,48 @@ function findFeatureSpecPath(root, specId) {
|
|
|
27
33
|
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
|
|
28
34
|
}
|
|
29
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Resolve a feature spec path, preferring a worktree-local copy when cwd
|
|
38
|
+
* is inside a worktree. Falls back to the main repo.
|
|
39
|
+
*
|
|
40
|
+
* Why two-step: `caws worktree bind` may be invoked from inside a worktree
|
|
41
|
+
* that was forked off a non-main base branch (Option C fork-off-sibling
|
|
42
|
+
* pattern). In that workflow the spec is committed on the worktree's own
|
|
43
|
+
* branch and never lands on main. The pre-CAWSFIX-25 behavior of looking
|
|
44
|
+
* only in `root/.caws/specs/` made bind unusable there (D8 ledger entry).
|
|
45
|
+
*
|
|
46
|
+
* @param {string} root - Main repo root (from getRepoRoot())
|
|
47
|
+
* @param {string} specId - Spec identifier
|
|
48
|
+
* @param {string} [cwd=process.cwd()] - Directory to resolve from
|
|
49
|
+
* @returns {string|null} Absolute path to the spec file, or null
|
|
50
|
+
*/
|
|
51
|
+
function findFeatureSpecPathFromCwd(root, specId, cwd) {
|
|
52
|
+
if (!specId) return null;
|
|
53
|
+
const effectiveCwd = cwd || process.cwd();
|
|
54
|
+
|
|
55
|
+
// Normalize both sides against symlinks before comparing. On macOS
|
|
56
|
+
// `/tmp` and `/var/folders` are symlinks under `/private`, so the literal
|
|
57
|
+
// `startsWith` check fails intermittently in the test fixture. Fall back
|
|
58
|
+
// to the pre-resolution path if realpath throws (e.g., cwd removed).
|
|
59
|
+
const resolve = (p) => {
|
|
60
|
+
try { return fs.realpathSync(p); } catch { return p; }
|
|
61
|
+
};
|
|
62
|
+
const resolvedCwd = resolve(effectiveCwd);
|
|
63
|
+
const worktreesBase = resolve(path.join(root, '.caws', 'worktrees'));
|
|
64
|
+
|
|
65
|
+
if (resolvedCwd.startsWith(worktreesBase + path.sep)) {
|
|
66
|
+
const relative = path.relative(worktreesBase, resolvedCwd);
|
|
67
|
+
const worktreeName = relative.split(path.sep)[0];
|
|
68
|
+
if (worktreeName) {
|
|
69
|
+
const worktreeRoot = path.join(worktreesBase, worktreeName);
|
|
70
|
+
const local = findFeatureSpecPath(worktreeRoot, specId);
|
|
71
|
+
if (local) return local;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return findFeatureSpecPath(root, specId);
|
|
76
|
+
}
|
|
77
|
+
|
|
30
78
|
function writeSpecWithWorktree(filePath, worktreeName) {
|
|
31
79
|
const yaml = require('js-yaml');
|
|
32
80
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
@@ -35,6 +83,18 @@ function writeSpecWithWorktree(filePath, worktreeName) {
|
|
|
35
83
|
return content;
|
|
36
84
|
}
|
|
37
85
|
|
|
86
|
+
// CAWSFIX-24 / D10: if the on-disk spec already declares the target
|
|
87
|
+
// worktree and reloads to an equivalent object, return the original
|
|
88
|
+
// bytes untouched. js-yaml.dump re-wraps folded scalars at its own
|
|
89
|
+
// line-width preference, which otherwise produces spurious bytes-only
|
|
90
|
+
// diffs on every bind/create. That mechanical churn (a) leaves dirty
|
|
91
|
+
// files on main after worktree create, and (b) causes merge conflicts
|
|
92
|
+
// when two validator invocations wrap the same title at different
|
|
93
|
+
// widths.
|
|
94
|
+
if (parsed.worktree === worktreeName) {
|
|
95
|
+
return content;
|
|
96
|
+
}
|
|
97
|
+
|
|
38
98
|
parsed.worktree = worktreeName;
|
|
39
99
|
return yaml.dump(parsed, { lineWidth: 120, noRefs: true });
|
|
40
100
|
}
|
|
@@ -84,27 +144,38 @@ function materializeWorktreeSpec(root, cawsDest, specId, worktreeName, scope) {
|
|
|
84
144
|
if (!specId) return;
|
|
85
145
|
|
|
86
146
|
const canonicalSpecPath = findFeatureSpecPath(root, specId);
|
|
87
|
-
const
|
|
147
|
+
const destSpecsDir = path.join(cawsDest, 'specs');
|
|
88
148
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
149
|
+
// CAWSFIX-24 / D5: never write to .caws/working-spec.yaml inside the
|
|
150
|
+
// worktree. That file is the shared project baseline and must remain
|
|
151
|
+
// byte-identical to what was checked out from HEAD. The feature spec
|
|
152
|
+
// is materialized only under .caws/specs/<id>.yaml, which is what
|
|
153
|
+
// spec-resolver and commands actually read via --spec-id / registry.
|
|
94
154
|
|
|
95
155
|
if (canonicalSpecPath) {
|
|
96
|
-
const destSpecsDir = path.join(cawsDest, 'specs');
|
|
97
156
|
const destSpecPath = path.join(destSpecsDir, path.basename(canonicalSpecPath));
|
|
98
157
|
fs.ensureDirSync(destSpecsDir);
|
|
99
158
|
|
|
100
|
-
// Keep a canonical feature-spec copy inside the worktree
|
|
101
|
-
// working-spec.yaml to that exact content for legacy-compatible commands.
|
|
159
|
+
// Keep a canonical feature-spec copy inside the worktree.
|
|
102
160
|
const specContent = writeSpecWithWorktree(canonicalSpecPath, worktreeName);
|
|
103
|
-
|
|
104
|
-
|
|
161
|
+
// writeSpecWithWorktree is idempotent (CAWSFIX-24 / D10): if the spec
|
|
162
|
+
// already has the worktree field and reloads to equivalent YAML, the
|
|
163
|
+
// returned content matches what's on disk. Skip the write in that case
|
|
164
|
+
// so `git status` stays clean.
|
|
165
|
+
const existing = fs.existsSync(destSpecPath) ? fs.readFileSync(destSpecPath, 'utf8') : null;
|
|
166
|
+
if (existing !== specContent) {
|
|
167
|
+
fs.writeFileSync(destSpecPath, specContent);
|
|
168
|
+
}
|
|
105
169
|
return;
|
|
106
170
|
}
|
|
107
171
|
|
|
172
|
+
// specId given but no canonical spec found — generate a default feature
|
|
173
|
+
// spec at .caws/specs/<specId>.yaml so the worktree has something to
|
|
174
|
+
// resolve against. Do not touch .caws/working-spec.yaml.
|
|
175
|
+
console.warn(
|
|
176
|
+
chalk.yellow(`Warning: spec '${specId}' not found in .caws/specs/ — generating default feature spec for worktree`)
|
|
177
|
+
);
|
|
178
|
+
|
|
108
179
|
const { generateWorkingSpec } = require('../generators/working-spec');
|
|
109
180
|
let specContent = generateWorkingSpec({
|
|
110
181
|
projectId: specId,
|
|
@@ -150,8 +221,9 @@ function materializeWorktreeSpec(root, cawsDest, specId, worktreeName, scope) {
|
|
|
150
221
|
// Keep generated spec content if augmentation fails.
|
|
151
222
|
}
|
|
152
223
|
|
|
153
|
-
fs.ensureDirSync(
|
|
154
|
-
|
|
224
|
+
fs.ensureDirSync(destSpecsDir);
|
|
225
|
+
const generatedSpecPath = path.join(destSpecsDir, `${specId}.yaml`);
|
|
226
|
+
fs.writeFileSync(generatedSpecPath, specContent);
|
|
155
227
|
}
|
|
156
228
|
|
|
157
229
|
function parseSpecIdFromYamlFile(filePath) {
|
|
@@ -332,6 +404,132 @@ function getCurrentBranch() {
|
|
|
332
404
|
// floods stderr and contributes to Claude Code context-window exhaustion.
|
|
333
405
|
let _schemaWarned = false;
|
|
334
406
|
|
|
407
|
+
/**
|
|
408
|
+
* CAWSFIX-31: Assert that the current agent session may operate on
|
|
409
|
+
* worktree `name`. The decision is purely session-id-equality based —
|
|
410
|
+
* never TTL, never log freshness — because rolled-over and resumed
|
|
411
|
+
* sessions should not be auto-blocked just because their registry
|
|
412
|
+
* entry was pruned.
|
|
413
|
+
*
|
|
414
|
+
* Returns `{ allowed, warning?, priorOwner? }`. The caller decides
|
|
415
|
+
* how to react:
|
|
416
|
+
*
|
|
417
|
+
* - allowed=true, no warning → silent proceed (same session id, no claim, etc.)
|
|
418
|
+
* - allowed=true, warning present → soft notice (orphan session log, no block)
|
|
419
|
+
* - allowed=false, warning present → soft-block; surface warning, exit non-zero
|
|
420
|
+
*
|
|
421
|
+
* On takeover (`allowTakeover: true`), the function rewrites the
|
|
422
|
+
* worktree entry's owner to the current session id and appends the
|
|
423
|
+
* prior owner to a `prior_owners` audit array (with lastSeen captured
|
|
424
|
+
* from agents.json at takeover time, or null if pruned).
|
|
425
|
+
*
|
|
426
|
+
* @param {string} root - Project root
|
|
427
|
+
* @param {string} name - Worktree name
|
|
428
|
+
* @param {object} [opts]
|
|
429
|
+
* @param {boolean} [opts.allowTakeover=false] - Apply takeover when true
|
|
430
|
+
* @param {string} [opts.takeoverCommandHint] - Suggested command for the warning
|
|
431
|
+
* @returns {{ allowed: boolean, warning?: string, priorOwner?: object }}
|
|
432
|
+
*/
|
|
433
|
+
function assertWorktreeOwnership(root, name, opts = {}) {
|
|
434
|
+
const { allowTakeover = false, takeoverCommandHint } = opts;
|
|
435
|
+
const registry = loadRegistry(root);
|
|
436
|
+
const entry = registry.worktrees[name];
|
|
437
|
+
if (!entry) {
|
|
438
|
+
return { allowed: true };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const currentSession = getAgentSessionId(root);
|
|
442
|
+
const owner = entry.owner;
|
|
443
|
+
|
|
444
|
+
// No CAWS-tracked owner — surface session-log hint if present, but
|
|
445
|
+
// allow the operation to proceed.
|
|
446
|
+
if (!owner) {
|
|
447
|
+
const branch = entry.branch || null;
|
|
448
|
+
const logs = branch ? findSessionLogs(root, { branch }) : [];
|
|
449
|
+
if (logs.length > 0) {
|
|
450
|
+
return {
|
|
451
|
+
allowed: true,
|
|
452
|
+
warning: formatOrphanLogHint({ worktree: name, sessionLogs: logs, root }),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
return { allowed: true };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Same session id → silent proceed. Roll-over case included: an
|
|
459
|
+
// agent that resumed with the same session id is its own claimant.
|
|
460
|
+
if (currentSession && owner === currentSession) {
|
|
461
|
+
return { allowed: true };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Foreign claim — gather context.
|
|
465
|
+
const agentRegistry = loadAgentRegistry(root);
|
|
466
|
+
const priorOwnerEntry = agentRegistry.agents[owner] || null;
|
|
467
|
+
const priorOwnerLastSeen = priorOwnerEntry ? priorOwnerEntry.lastSeen : null;
|
|
468
|
+
const priorOwnerPlatform = priorOwnerEntry ? priorOwnerEntry.platform : 'unknown';
|
|
469
|
+
|
|
470
|
+
// Surface session-log pointers (by sid OR by branch).
|
|
471
|
+
const branch = entry.branch || null;
|
|
472
|
+
const seen = new Set();
|
|
473
|
+
const sessionLogs = [];
|
|
474
|
+
for (const log of findSessionLogs(root, { sessionId: owner })) {
|
|
475
|
+
if (seen.has(log.path)) continue;
|
|
476
|
+
seen.add(log.path);
|
|
477
|
+
sessionLogs.push(log);
|
|
478
|
+
}
|
|
479
|
+
if (branch) {
|
|
480
|
+
for (const log of findSessionLogs(root, { branch })) {
|
|
481
|
+
if (seen.has(log.path)) continue;
|
|
482
|
+
seen.add(log.path);
|
|
483
|
+
sessionLogs.push(log);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const takeoverCommand =
|
|
488
|
+
takeoverCommandHint || `caws worktree claim ${name} --takeover`;
|
|
489
|
+
const warning = formatClaimNotice({
|
|
490
|
+
worktree: name,
|
|
491
|
+
priorOwnerEntry,
|
|
492
|
+
priorOwnerSessionId: owner,
|
|
493
|
+
sessionLogs,
|
|
494
|
+
root,
|
|
495
|
+
takeoverCommand,
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
if (!allowTakeover) {
|
|
499
|
+
return { allowed: false, warning };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Takeover: rewrite owner, append prior_owners audit entry.
|
|
503
|
+
const priorOwners = Array.isArray(entry.prior_owners) ? entry.prior_owners : [];
|
|
504
|
+
priorOwners.push({
|
|
505
|
+
sessionId: owner,
|
|
506
|
+
platform: priorOwnerPlatform,
|
|
507
|
+
lastSeen: priorOwnerLastSeen,
|
|
508
|
+
takenOver_at: new Date().toISOString(),
|
|
509
|
+
});
|
|
510
|
+
registry.worktrees[name] = {
|
|
511
|
+
...entry,
|
|
512
|
+
owner: currentSession || null,
|
|
513
|
+
prior_owners: priorOwners,
|
|
514
|
+
};
|
|
515
|
+
saveRegistry(root, registry);
|
|
516
|
+
|
|
517
|
+
// Heartbeat the new owner so agents.json reflects the takeover too.
|
|
518
|
+
// Without this, `caws status` and `caws agents list` would show the
|
|
519
|
+
// takeover'd worktree with an "unknown / pruned" current owner until
|
|
520
|
+
// some other lifecycle verb fires.
|
|
521
|
+
refreshAgentClaim(root, { worktree: name });
|
|
522
|
+
|
|
523
|
+
return {
|
|
524
|
+
allowed: true,
|
|
525
|
+
priorOwner: {
|
|
526
|
+
sessionId: owner,
|
|
527
|
+
platform: priorOwnerPlatform,
|
|
528
|
+
lastSeen: priorOwnerLastSeen,
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
335
533
|
/**
|
|
336
534
|
* Load the worktree registry
|
|
337
535
|
* @param {string} root - Repository root
|
|
@@ -369,10 +567,22 @@ function loadRegistry(root) {
|
|
|
369
567
|
* @param {Object} registry - Registry object
|
|
370
568
|
*/
|
|
371
569
|
function saveRegistry(root, registry) {
|
|
372
|
-
// Auto-prune
|
|
373
|
-
//
|
|
570
|
+
// Auto-prune ghost entries: any registry entry whose path directory AND
|
|
571
|
+
// stored branch are BOTH gone. Previously this only fired for entries
|
|
572
|
+
// explicitly marked `status: destroyed`, which missed two common cases:
|
|
573
|
+
// 1. A worktree removed via `git worktree remove` (not `caws worktree
|
|
574
|
+
// destroy`) that later had its branch manually deleted with
|
|
575
|
+
// `git branch -D`.
|
|
576
|
+
// 2. A worktree whose create failed partway, leaving a registry entry
|
|
577
|
+
// at `fresh`/`active` but no artifacts on disk.
|
|
578
|
+
// Both are pure ghost state — no recoverable work remains in either
|
|
579
|
+
// the directory or the branch. Pruning is safe. (CAWSFIX-25 / D7)
|
|
580
|
+
//
|
|
581
|
+
// Entries with ONE artifact intact (dir gone but branch still present,
|
|
582
|
+
// or vice versa) are preserved. The branch may still hold unmerged
|
|
583
|
+
// commits, or the directory may still hold uncommitted work — the user
|
|
584
|
+
// should merge or explicitly destroy.
|
|
374
585
|
for (const [name, entry] of Object.entries(registry.worktrees || {})) {
|
|
375
|
-
if (entry.status !== 'destroyed') continue;
|
|
376
586
|
const dirGone = !fs.existsSync(entry.path);
|
|
377
587
|
let branchGone = true;
|
|
378
588
|
if (entry.branch) {
|
|
@@ -550,7 +760,21 @@ function createWorktree(name, options = {}) {
|
|
|
550
760
|
const worktreePath = path.join(root, WORKTREES_DIR, name);
|
|
551
761
|
const branchName = BRANCH_PREFIX + name;
|
|
552
762
|
const base = baseBranch || getCurrentBranch();
|
|
553
|
-
|
|
763
|
+
|
|
764
|
+
// CAWSFIX-27: resolve the bound specId (explicit --spec-id OR auto-bind
|
|
765
|
+
// via worktree-name match) BEFORE creating the worktree, so the
|
|
766
|
+
// draft→active flip + bind commit land on the base branch before the
|
|
767
|
+
// worktree forks. Pre-CAWSFIX-27 the auto-bind path activated the spec
|
|
768
|
+
// but never committed it, leaving main with a dirty spec after
|
|
769
|
+
// `caws worktree create <name>` (no --spec-id).
|
|
770
|
+
let resolvedSpecId = specId || null;
|
|
771
|
+
if (!resolvedSpecId) {
|
|
772
|
+
resolvedSpecId = findSpecByWorktreeName(root, name);
|
|
773
|
+
if (resolvedSpecId) {
|
|
774
|
+
console.log(chalk.gray(` Auto-bound spec: ${resolvedSpecId}`));
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
const canonicalSpecPath = findFeatureSpecPath(root, resolvedSpecId);
|
|
554
778
|
|
|
555
779
|
// Check if the branch already exists in git (even if not in registry)
|
|
556
780
|
// This catches cases where another agent created the branch outside CAWS
|
|
@@ -577,8 +801,14 @@ function createWorktree(name, options = {}) {
|
|
|
577
801
|
// Create the worktree directory
|
|
578
802
|
fs.ensureDirSync(path.dirname(worktreePath));
|
|
579
803
|
|
|
580
|
-
if (canonicalSpecPath) {
|
|
581
|
-
|
|
804
|
+
if (canonicalSpecPath && resolvedSpecId) {
|
|
805
|
+
// CAWSFIX-23: flip draft→active BEFORE the bind commit so the spec
|
|
806
|
+
// lifecycle transition lands in the same commit as the worktree field.
|
|
807
|
+
// CAWSFIX-27: this block now handles BOTH the explicit --spec-id path
|
|
808
|
+
// and the auto-bind (findSpecByWorktreeName) path — previously only
|
|
809
|
+
// the explicit path committed the flip.
|
|
810
|
+
autoActivateBoundSpec(root, resolvedSpecId);
|
|
811
|
+
ensureCanonicalSpecCommitted(root, canonicalSpecPath, resolvedSpecId, name);
|
|
582
812
|
}
|
|
583
813
|
|
|
584
814
|
// Create git worktree with new branch
|
|
@@ -642,16 +872,10 @@ function createWorktree(name, options = {}) {
|
|
|
642
872
|
}
|
|
643
873
|
}
|
|
644
874
|
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
|
|
649
|
-
if (!resolvedSpecId) {
|
|
650
|
-
resolvedSpecId = findSpecByWorktreeName(root, name);
|
|
651
|
-
if (resolvedSpecId) {
|
|
652
|
-
console.log(chalk.gray(` Auto-bound spec: ${resolvedSpecId}`));
|
|
653
|
-
}
|
|
654
|
-
}
|
|
875
|
+
// CAWSFIX-27: resolvedSpecId is now computed before the worktree is
|
|
876
|
+
// added (see block above the `fs.ensureDirSync` call). The activation
|
|
877
|
+
// and bind-commit already ran on the base branch, so the worktree forks
|
|
878
|
+
// from a base that already includes the flip commit.
|
|
655
879
|
|
|
656
880
|
// Materialize a worktree-local working spec. Prefer the canonical feature
|
|
657
881
|
// spec when it exists so isolated worktrees stay aligned with the main
|
|
@@ -683,6 +907,11 @@ function createWorktree(name, options = {}) {
|
|
|
683
907
|
registry.worktrees[name] = entry;
|
|
684
908
|
saveRegistry(root, registry);
|
|
685
909
|
|
|
910
|
+
// CAWSFIX-32: heartbeat the current session into agents.json so the
|
|
911
|
+
// worktree+spec context is visible to other agents and to
|
|
912
|
+
// `caws status` / `caws agents list` immediately after create.
|
|
913
|
+
refreshAgentClaim(root, { worktree: name, specId: resolvedSpecId || null });
|
|
914
|
+
|
|
686
915
|
return entry;
|
|
687
916
|
}
|
|
688
917
|
|
|
@@ -1075,7 +1304,7 @@ function destroyWorktree(name, options = {}) {
|
|
|
1075
1304
|
function mergeWorktree(name, options = {}) {
|
|
1076
1305
|
const root = getRepoRoot();
|
|
1077
1306
|
const registry = loadRegistry(root);
|
|
1078
|
-
const { dryRun = false, deleteBranch = true, message } = options;
|
|
1307
|
+
const { dryRun = false, deleteBranch = true, message, takeover = false } = options;
|
|
1079
1308
|
|
|
1080
1309
|
let entry = registry.worktrees[name];
|
|
1081
1310
|
if (!entry) {
|
|
@@ -1090,6 +1319,26 @@ function mergeWorktree(name, options = {}) {
|
|
|
1090
1319
|
}
|
|
1091
1320
|
}
|
|
1092
1321
|
|
|
1322
|
+
// CAWSFIX-32: assert ownership BEFORE any merge/git work. Foreign
|
|
1323
|
+
// claim soft-blocks unless --takeover is supplied, matching the
|
|
1324
|
+
// bind/claim semantics. Throws on refusal so the CLI command handler
|
|
1325
|
+
// surfaces the structured warning.
|
|
1326
|
+
const ownership = assertWorktreeOwnership(root, name, {
|
|
1327
|
+
allowTakeover: takeover,
|
|
1328
|
+
takeoverCommandHint: `caws worktree merge ${name} --takeover`,
|
|
1329
|
+
});
|
|
1330
|
+
if (!ownership.allowed) {
|
|
1331
|
+
const err = new Error(ownership.warning);
|
|
1332
|
+
err.claimWarning = true;
|
|
1333
|
+
throw err;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// CAWSFIX-32: heartbeat the current session into agents.json now that
|
|
1337
|
+
// ownership is confirmed. Same-session merges need this since the
|
|
1338
|
+
// takeover branch only fires inside assertWorktreeOwnership when a
|
|
1339
|
+
// takeover actually occurs.
|
|
1340
|
+
refreshAgentClaim(root, { worktree: name, specId: entry.specId || null });
|
|
1341
|
+
|
|
1093
1342
|
const baseBranch = entry.baseBranch || 'main';
|
|
1094
1343
|
|
|
1095
1344
|
// Check for uncommitted work in the worktree.
|
|
@@ -1222,17 +1471,58 @@ function mergeWorktree(name, options = {}) {
|
|
|
1222
1471
|
|
|
1223
1472
|
// Auto-close the bound spec if one exists. A worktree merge is the
|
|
1224
1473
|
// lifecycle signal that the spec's work is done; leaving the spec
|
|
1225
|
-
// `active` after merge accumulates stale
|
|
1226
|
-
// YAML status flip bypasses the ownership +
|
|
1227
|
-
// in `closeSpec` — the caller has already
|
|
1228
|
-
|
|
1474
|
+
// `active` (or `draft`, pre-CAWSFIX-23) after merge accumulates stale
|
|
1475
|
+
// entries (D6). Direct YAML status flip bypasses the ownership +
|
|
1476
|
+
// worktree-reference checks in `closeSpec` — the caller has already
|
|
1477
|
+
// proven authority by merging.
|
|
1478
|
+
let autoClose = {
|
|
1479
|
+
specId: null, acsPassing: null, acsFailureCount: 0, acsTotal: 0, acsFailureIds: [],
|
|
1480
|
+
didWrite: false, specPath: null,
|
|
1481
|
+
};
|
|
1229
1482
|
if (entry.specId) {
|
|
1230
|
-
|
|
1483
|
+
autoClose = autoCloseBoundSpec(root, entry.specId);
|
|
1484
|
+
if (autoClose.acsPassing === false && autoClose.acsFailureCount > 0) {
|
|
1485
|
+
console.warn(chalk.yellow(
|
|
1486
|
+
` ⚠ Spec ${entry.specId} closed with ${autoClose.acsFailureCount}/${autoClose.acsTotal} failing AC(s): ${autoClose.acsFailureIds.join(', ')}`
|
|
1487
|
+
));
|
|
1488
|
+
console.warn(chalk.yellow(
|
|
1489
|
+
` Merge succeeded — the spec reflects that — but follow up to address the failing ACs.`
|
|
1490
|
+
));
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// CAWSFIX-24 / D6: if the auto-close flipped the status, commit the
|
|
1494
|
+
// change on the base branch before returning. Leaving it uncommitted
|
|
1495
|
+
// was the "dirty main" footgun: the next worktree merge would abort
|
|
1496
|
+
// on "local changes would be overwritten," after the prior worktree
|
|
1497
|
+
// was already destroyed. Use --no-verify to match the merge commit's
|
|
1498
|
+
// hook-skip discipline (the content was verified when the merge ran).
|
|
1499
|
+
if (autoClose.didWrite && autoClose.specPath) {
|
|
1500
|
+
try {
|
|
1501
|
+
const relPath = path.relative(root, autoClose.specPath);
|
|
1502
|
+
execFileSync('git', ['add', '--', relPath], { cwd: root, stdio: 'pipe' });
|
|
1503
|
+
execFileSync(
|
|
1504
|
+
'git',
|
|
1505
|
+
['commit', '--no-verify', '-m', `chore(caws): close ${autoClose.specId} spec post-merge`, '--', relPath],
|
|
1506
|
+
{ cwd: root, stdio: 'pipe' }
|
|
1507
|
+
);
|
|
1508
|
+
} catch (commitErr) {
|
|
1509
|
+
// Non-fatal: a failed auto-commit leaves the spec dirty but the
|
|
1510
|
+
// merge itself already succeeded. Warn so the caller can clean up.
|
|
1511
|
+
console.warn(chalk.yellow(
|
|
1512
|
+
` ⚠ Auto-commit of ${autoClose.specId} close flip failed: ${commitErr.message}. Commit manually.`
|
|
1513
|
+
));
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1231
1516
|
}
|
|
1232
1517
|
|
|
1233
1518
|
const mergeResult = {
|
|
1234
1519
|
name, branch: entry.branch, baseBranch, merged: true, conflicts: [],
|
|
1235
|
-
specId: entry.specId || null,
|
|
1520
|
+
specId: entry.specId || null,
|
|
1521
|
+
autoClosedSpecId: autoClose.specId,
|
|
1522
|
+
acsPassing: autoClose.acsPassing,
|
|
1523
|
+
acsFailureCount: autoClose.acsFailureCount,
|
|
1524
|
+
acsTotal: autoClose.acsTotal,
|
|
1525
|
+
acsFailureIds: autoClose.acsFailureIds,
|
|
1236
1526
|
};
|
|
1237
1527
|
try {
|
|
1238
1528
|
lifecycle.emit(EVENTS.MERGE_POST, { ...mergeResult, timestamp: new Date().toISOString() });
|
|
@@ -1240,27 +1530,95 @@ function mergeWorktree(name, options = {}) {
|
|
|
1240
1530
|
return mergeResult;
|
|
1241
1531
|
}
|
|
1242
1532
|
|
|
1533
|
+
/**
|
|
1534
|
+
* Flip a spec's status from `draft` to `active` by rewriting just the
|
|
1535
|
+
* `status:` line. Called on worktree-bind so specs whose work is
|
|
1536
|
+
* starting transition out of draft without manual intervention.
|
|
1537
|
+
* Idempotent: no-op when the spec is already active/closed/etc.
|
|
1538
|
+
* @param {string} root - Repo root
|
|
1539
|
+
* @param {string} specId - Spec identifier
|
|
1540
|
+
* @returns {string|null} specId on flip, null if no change
|
|
1541
|
+
*/
|
|
1542
|
+
function autoActivateBoundSpec(root, specId, specPathOverride = null) {
|
|
1543
|
+
try {
|
|
1544
|
+
// CAWSFIX-25 / D8: callers that resolved a worktree-local spec path
|
|
1545
|
+
// (via findFeatureSpecPathFromCwd) pass it in so the flip lands on
|
|
1546
|
+
// the worktree's copy, not main's. Falls through to main resolution
|
|
1547
|
+
// for backward compatibility when override is null.
|
|
1548
|
+
const specPath = specPathOverride || findFeatureSpecPath(root, specId);
|
|
1549
|
+
if (!specPath || !fs.existsSync(specPath)) return null;
|
|
1550
|
+
const original = fs.readFileSync(specPath, 'utf8');
|
|
1551
|
+
// Idempotent: already active/closed/archived → no write.
|
|
1552
|
+
if (/^status:[ \t]*active[ \t]*$/m.test(original)) return specId;
|
|
1553
|
+
const patched = original.replace(/^status:[ \t]*draft[ \t]*$/m, 'status: active');
|
|
1554
|
+
if (patched === original) return null;
|
|
1555
|
+
fs.writeFileSync(specPath, patched, 'utf8');
|
|
1556
|
+
return specId;
|
|
1557
|
+
} catch {
|
|
1558
|
+
return null;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1243
1562
|
/**
|
|
1244
1563
|
* Flip a spec's status to `closed` by rewriting just the `status:` line.
|
|
1245
|
-
*
|
|
1246
|
-
*
|
|
1564
|
+
* Accepts both `draft` and `active` as source states — merge is the
|
|
1565
|
+
* authoritative "work done" signal regardless of whether the spec ever
|
|
1566
|
+
* transitioned through active. Runs verify-acs in collect-only mode
|
|
1567
|
+
* before the flip and returns AC health so the caller can warn.
|
|
1247
1568
|
* @param {string} root - Repo root
|
|
1248
1569
|
* @param {string} specId - Spec identifier (e.g. CAWSFIX-14)
|
|
1249
|
-
* @returns {string|null}
|
|
1570
|
+
* @returns {{specId: string|null, acsPassing: boolean|null, acsFailureCount: number, acsTotal: number, acsFailureIds: string[]}}
|
|
1250
1571
|
*/
|
|
1251
1572
|
function autoCloseBoundSpec(root, specId) {
|
|
1573
|
+
const result = {
|
|
1574
|
+
specId: null,
|
|
1575
|
+
acsPassing: null,
|
|
1576
|
+
acsFailureCount: 0,
|
|
1577
|
+
acsTotal: 0,
|
|
1578
|
+
acsFailureIds: [],
|
|
1579
|
+
didWrite: false,
|
|
1580
|
+
specPath: null,
|
|
1581
|
+
};
|
|
1252
1582
|
try {
|
|
1253
1583
|
const specPath = findFeatureSpecPath(root, specId);
|
|
1254
|
-
if (!specPath || !fs.existsSync(specPath)) return
|
|
1584
|
+
if (!specPath || !fs.existsSync(specPath)) return result;
|
|
1585
|
+
result.specPath = specPath;
|
|
1255
1586
|
const original = fs.readFileSync(specPath, 'utf8');
|
|
1256
1587
|
// Idempotent: already closed → no-op, no write, no diff.
|
|
1257
|
-
if (/^status
|
|
1258
|
-
|
|
1259
|
-
|
|
1588
|
+
if (/^status:[ \t]*closed[ \t]*$/m.test(original)) {
|
|
1589
|
+
result.specId = specId;
|
|
1590
|
+
return result;
|
|
1591
|
+
}
|
|
1592
|
+
// Run verify-acs in collect-only mode before flipping. Never throws —
|
|
1593
|
+
// any error (missing tests, unavailable runner, malformed spec) leaves
|
|
1594
|
+
// acsPassing: null so the caller knows verification didn't run.
|
|
1595
|
+
try {
|
|
1596
|
+
const yaml = require('js-yaml');
|
|
1597
|
+
const { verifySpec } = require('../commands/verify-acs');
|
|
1598
|
+
const parsed = yaml.load(original);
|
|
1599
|
+
if (parsed && typeof parsed === 'object') {
|
|
1600
|
+
const verdict = verifySpec(parsed, root, { run: false });
|
|
1601
|
+
const fails = (verdict.results || []).filter((r) => r.status === 'FAIL');
|
|
1602
|
+
result.acsTotal = (verdict.results || []).length;
|
|
1603
|
+
result.acsFailureCount = fails.length;
|
|
1604
|
+
result.acsFailureIds = fails.map((r) => r.id);
|
|
1605
|
+
result.acsPassing = fails.length === 0;
|
|
1606
|
+
}
|
|
1607
|
+
} catch {
|
|
1608
|
+
// verify-acs unavailable — don't block close
|
|
1609
|
+
}
|
|
1610
|
+
// Flip status. Accept both draft and active as source so specs that
|
|
1611
|
+
// never transitioned through active (D6 pre-CAWSFIX-23 drift) still close.
|
|
1612
|
+
const patched = original.replace(/^status:[ \t]*(?:draft|active)[ \t]*$/m, 'status: closed');
|
|
1613
|
+
if (patched === original) {
|
|
1614
|
+
return result; // status was archived/unknown — leave alone
|
|
1615
|
+
}
|
|
1260
1616
|
fs.writeFileSync(specPath, patched, 'utf8');
|
|
1261
|
-
|
|
1617
|
+
result.specId = specId;
|
|
1618
|
+
result.didWrite = true;
|
|
1619
|
+
return result;
|
|
1262
1620
|
} catch {
|
|
1263
|
-
return
|
|
1621
|
+
return result;
|
|
1264
1622
|
}
|
|
1265
1623
|
}
|
|
1266
1624
|
|
|
@@ -1351,12 +1709,14 @@ module.exports = {
|
|
|
1351
1709
|
listWorktrees,
|
|
1352
1710
|
destroyWorktree,
|
|
1353
1711
|
mergeWorktree,
|
|
1712
|
+
autoActivateBoundSpec,
|
|
1354
1713
|
autoCloseBoundSpec,
|
|
1355
1714
|
pruneWorktrees,
|
|
1356
1715
|
repairWorktrees,
|
|
1357
1716
|
reconcileRegistry,
|
|
1358
1717
|
loadRegistry,
|
|
1359
1718
|
saveRegistry,
|
|
1719
|
+
assertWorktreeOwnership,
|
|
1360
1720
|
getRepoRoot,
|
|
1361
1721
|
getLastCommitInfo,
|
|
1362
1722
|
isBranchMerged,
|
|
@@ -1368,6 +1728,7 @@ module.exports = {
|
|
|
1368
1728
|
REGISTRY_FILE,
|
|
1369
1729
|
BRANCH_PREFIX,
|
|
1370
1730
|
findFeatureSpecPath,
|
|
1731
|
+
findFeatureSpecPathFromCwd,
|
|
1371
1732
|
materializeWorktreeSpec,
|
|
1372
1733
|
inferSpecIdForWorktree,
|
|
1373
1734
|
findSpecByWorktreeName,
|
package/package.json
CHANGED
|
@@ -105,6 +105,11 @@
|
|
|
105
105
|
},
|
|
106
106
|
"additionalProperties": false,
|
|
107
107
|
"description": "Quality gate configurations"
|
|
108
|
+
},
|
|
109
|
+
"non_governed_zones": {
|
|
110
|
+
"type": "array",
|
|
111
|
+
"items": { "type": "string" },
|
|
112
|
+
"description": "Glob patterns (picomatch, dot:true) for paths declared outside CAWS scope enforcement. Any file matching a pattern is exempt from scope-boundary checks — neither spec.scope.in nor spec.scope.out are consulted. Intended for research, playground, or experimental subtrees where governance is explicitly off by design. Example: [\"research/**\", \"playground/**\"]. (CAWSFIX-26 / D9)"
|
|
108
113
|
}
|
|
109
114
|
},
|
|
110
115
|
"additionalProperties": false,
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"properties": {
|
|
24
24
|
"id": {
|
|
25
25
|
"type": "string",
|
|
26
|
-
"pattern": "^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*-\\d
|
|
27
|
-
"description": "Unique identifier for the change. Format: uppercase/digit PREFIX segments separated by dashes, final segment is one+ digits (e.g. CAWSFIX-16, P03-TRUTH-001, ALG-001A-HARDEN-01). Matches SPEC_ID_PATTERN in spec-validation.js (CAWSFIX-
|
|
26
|
+
"pattern": "^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*-\\d+[a-z]*$",
|
|
27
|
+
"description": "Unique identifier for the change. Format: uppercase/digit PREFIX segments separated by dashes, final segment is one+ digits with an optional lowercase suffix (e.g. CAWSFIX-16, P03-TRUTH-001, ALG-001A-HARDEN-01, APC-01a). Matches SPEC_ID_PATTERN in spec-validation.js (CAWSFIX-25 alignment)."
|
|
28
28
|
},
|
|
29
29
|
"title": {
|
|
30
30
|
"type": "string",
|