@paths.design/caws-cli 10.0.1 → 10.1.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/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 +148 -1
- package/dist/commands/status.js +8 -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 +81 -1
- package/dist/gates/budget-limit.js +6 -1
- package/dist/gates/spec-completeness.js +8 -1
- package/dist/index.js +27 -0
- package/dist/policy/PolicyManager.js +9 -7
- package/dist/session/session-manager.js +34 -0
- package/dist/templates/.caws/schemas/policy.schema.json +96 -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/settings.json +5 -0
- package/dist/templates/CLAUDE.md +34 -0
- package/dist/templates/agents.md +21 -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 +99 -9
- package/dist/waivers-manager.js +84 -0
- package/dist/worktree/worktree-manager.js +214 -8
- package/package.json +5 -4
- package/templates/.caws/schemas/policy.schema.json +96 -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/settings.json +5 -0
- package/templates/CLAUDE.md +34 -0
- package/templates/agents.md +21 -0
|
@@ -6,10 +6,42 @@
|
|
|
6
6
|
|
|
7
7
|
const fs = require('fs');
|
|
8
8
|
const path = require('path');
|
|
9
|
-
const {
|
|
9
|
+
const { deriveBudgetSync, checkBudgetCompliance } = require('../budget-derivation');
|
|
10
10
|
const { execSync } = require('child_process');
|
|
11
11
|
const { createValidator, getSchemaPath } = require('../utils/schema-validator');
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* CAWSFIX-10: Canonical regex for valid spec IDs.
|
|
15
|
+
*
|
|
16
|
+
* Accepts:
|
|
17
|
+
* - Single-segment: FEAT-001, EVLOG-002, CAWSFIX-06 (legacy shape)
|
|
18
|
+
* - Multi-segment: P03-IMPL-01, ALG-001A-HARDEN-01, CAWS-FIX-03
|
|
19
|
+
*
|
|
20
|
+
* Rejects:
|
|
21
|
+
* - lowercase (feat-001)
|
|
22
|
+
* - leading digit (01-FEAT)
|
|
23
|
+
* - missing number suffix (FEAT-)
|
|
24
|
+
* - trailing hyphen (FEAT-01-)
|
|
25
|
+
* - leading/double hyphen (--FEAT-01, FEAT--001)
|
|
26
|
+
* - empty string
|
|
27
|
+
*
|
|
28
|
+
* Grammar: [PREFIX](-[SEGMENT])*-NUMBER
|
|
29
|
+
* - PREFIX = [A-Z] followed by zero+ [A-Z0-9]
|
|
30
|
+
* - SEGMENT = one+ [A-Z0-9] (alphanumeric, uppercase only)
|
|
31
|
+
* - NUMBER = one+ digits
|
|
32
|
+
*
|
|
33
|
+
* Defined once per A4 invariant; referenced by both the basic validator
|
|
34
|
+
* (line ~125 pre-fix) and the enhanced validator (line ~307 pre-fix).
|
|
35
|
+
*/
|
|
36
|
+
const SPEC_ID_PATTERN = /^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*-\d+$/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* User-facing error message for bad spec IDs (CAWSFIX-10 A5).
|
|
40
|
+
* Kept as a module constant so the message stays in sync with the pattern.
|
|
41
|
+
*/
|
|
42
|
+
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)';
|
|
44
|
+
|
|
13
45
|
/**
|
|
14
46
|
* Get actual budget statistics from git history
|
|
15
47
|
* Analyzes changes since last tag or initial commit
|
|
@@ -66,6 +98,42 @@ function getActualBudgetStats(specDir) {
|
|
|
66
98
|
}
|
|
67
99
|
}
|
|
68
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Alias the modern `acceptance_criteria` key into `acceptance` so the semantic
|
|
103
|
+
* validator (which historically keys off `acceptance`) accepts both shapes.
|
|
104
|
+
*
|
|
105
|
+
* Precedence (per CAWSFIX-09 A3 invariant):
|
|
106
|
+
* - If `acceptance` is present (legacy shape: {id,given,when,then}), it wins.
|
|
107
|
+
* - Otherwise `acceptance_criteria` (modern shape: {id,description,test_nodeids,status})
|
|
108
|
+
* is copied into `acceptance`.
|
|
109
|
+
*
|
|
110
|
+
* IMPORTANT: this function mutates the spec in place. The existing validator
|
|
111
|
+
* also mutates in place (risk_tier string→number coercion at line ~141; auto-fix
|
|
112
|
+
* writes via `current[pathParts[...]] = fix.value`). Callers of
|
|
113
|
+
* `validateWorkingSpecWithSuggestions({...}, {autoFix:true})` observe those
|
|
114
|
+
* mutations on the object they passed in — see `Multiple Auto-Fixes` tests.
|
|
115
|
+
* Returning a clone here would silently break that contract.
|
|
116
|
+
*
|
|
117
|
+
* @param {Object} spec - Raw spec object (mutated in place)
|
|
118
|
+
* @returns {Object} Same spec reference
|
|
119
|
+
*/
|
|
120
|
+
function aliasAcceptanceCriteria(spec) {
|
|
121
|
+
if (!spec || typeof spec !== 'object') return spec;
|
|
122
|
+
|
|
123
|
+
const hasLegacy = Array.isArray(spec.acceptance) && spec.acceptance.length > 0;
|
|
124
|
+
const hasModern =
|
|
125
|
+
Array.isArray(spec.acceptance_criteria) && spec.acceptance_criteria.length > 0;
|
|
126
|
+
|
|
127
|
+
// Only alias when: legacy is absent AND modern has content.
|
|
128
|
+
// (Legacy wins when both present; empty modern arrays do not satisfy the
|
|
129
|
+
// required-field check — see edge-case tests in acceptance-criteria-alias.test.js.)
|
|
130
|
+
if (!hasLegacy && hasModern) {
|
|
131
|
+
spec.acceptance = spec.acceptance_criteria;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return spec;
|
|
135
|
+
}
|
|
136
|
+
|
|
69
137
|
/**
|
|
70
138
|
* Basic validation of working spec
|
|
71
139
|
* @param {Object} spec - Working spec object
|
|
@@ -74,6 +142,11 @@ function getActualBudgetStats(specDir) {
|
|
|
74
142
|
*/
|
|
75
143
|
const validateWorkingSpec = (spec, _options = {}) => {
|
|
76
144
|
try {
|
|
145
|
+
// CAWSFIX-09: Alias `acceptance_criteria` -> `acceptance` before any
|
|
146
|
+
// semantic checks so specs using the modern shape don't trigger
|
|
147
|
+
// "Missing required field: acceptance" false negatives.
|
|
148
|
+
aliasAcceptanceCriteria(spec);
|
|
149
|
+
|
|
77
150
|
// First pass: AJV schema validation (non-blocking — results collected as warnings)
|
|
78
151
|
let schemaWarnings = [];
|
|
79
152
|
try {
|
|
@@ -121,14 +194,14 @@ const validateWorkingSpec = (spec, _options = {}) => {
|
|
|
121
194
|
}
|
|
122
195
|
}
|
|
123
196
|
|
|
124
|
-
// Validate specific field formats
|
|
125
|
-
if (
|
|
197
|
+
// Validate specific field formats (CAWSFIX-10: DRY regex via SPEC_ID_PATTERN)
|
|
198
|
+
if (!SPEC_ID_PATTERN.test(spec.id)) {
|
|
126
199
|
return {
|
|
127
200
|
valid: false,
|
|
128
201
|
errors: [
|
|
129
202
|
{
|
|
130
203
|
instancePath: '/id',
|
|
131
|
-
message:
|
|
204
|
+
message: SPEC_ID_ERROR_MESSAGE,
|
|
132
205
|
},
|
|
133
206
|
],
|
|
134
207
|
};
|
|
@@ -252,6 +325,12 @@ function validateWorkingSpecWithSuggestions(spec, options = {}) {
|
|
|
252
325
|
const { autoFix = false, checkBudget = false, projectRoot } = options;
|
|
253
326
|
|
|
254
327
|
try {
|
|
328
|
+
// CAWSFIX-09: Alias `acceptance_criteria` -> `acceptance` so the
|
|
329
|
+
// required-field check and the "No acceptance criteria defined" warning
|
|
330
|
+
// recognize the modern shape as valid. Mutates in place to preserve the
|
|
331
|
+
// existing auto-fix contract (callers observe fixes on their object).
|
|
332
|
+
aliasAcceptanceCriteria(spec);
|
|
333
|
+
|
|
255
334
|
let errors = [];
|
|
256
335
|
let warnings = [];
|
|
257
336
|
let fixes = [];
|
|
@@ -303,12 +382,12 @@ function validateWorkingSpecWithSuggestions(spec, options = {}) {
|
|
|
303
382
|
|
|
304
383
|
// Semantic checks that AJV can't express
|
|
305
384
|
|
|
306
|
-
// Validate specific field formats
|
|
307
|
-
if (spec.id &&
|
|
385
|
+
// Validate specific field formats (CAWSFIX-10: DRY regex via SPEC_ID_PATTERN)
|
|
386
|
+
if (spec.id && !SPEC_ID_PATTERN.test(spec.id)) {
|
|
308
387
|
errors.push({
|
|
309
388
|
instancePath: '/id',
|
|
310
|
-
message:
|
|
311
|
-
suggestion: 'Use format like: PROJ-001, FEAT-002,
|
|
389
|
+
message: SPEC_ID_ERROR_MESSAGE,
|
|
390
|
+
suggestion: 'Use format like: PROJ-001, FEAT-002, P03-IMPL-01, ALG-001A-HARDEN-01',
|
|
312
391
|
canAutoFix: false,
|
|
313
392
|
});
|
|
314
393
|
}
|
|
@@ -646,10 +725,18 @@ function validateWorkingSpecWithSuggestions(spec, options = {}) {
|
|
|
646
725
|
}
|
|
647
726
|
|
|
648
727
|
// Derive and check budget if requested
|
|
728
|
+
//
|
|
729
|
+
// CAWSFIX-07: use `deriveBudgetSync` here. The async `deriveBudget`
|
|
730
|
+
// returns a Promise; this synchronous function previously passed the
|
|
731
|
+
// Promise straight into `checkBudgetCompliance`, which then read
|
|
732
|
+
// `derivedBudget.effective.max_files` on an undefined `.effective` and
|
|
733
|
+
// threw "Cannot read properties of undefined (reading 'max_files')" —
|
|
734
|
+
// surfaced as the "Budget derivation failed" warning on every
|
|
735
|
+
// schema-compliant spec.
|
|
649
736
|
let budgetCheck = null;
|
|
650
737
|
if (checkBudget && projectRoot) {
|
|
651
738
|
try {
|
|
652
|
-
const derivedBudget =
|
|
739
|
+
const derivedBudget = deriveBudgetSync(spec, projectRoot);
|
|
653
740
|
|
|
654
741
|
// Get actual stats from git history
|
|
655
742
|
const actualStats = getActualBudgetStats(projectRoot) || {
|
|
@@ -828,4 +915,7 @@ module.exports = {
|
|
|
828
915
|
canAutoFixField,
|
|
829
916
|
calculateComplianceScore,
|
|
830
917
|
getComplianceGrade,
|
|
918
|
+
// CAWSFIX-10: exported so init.js and tests reference the same regex
|
|
919
|
+
SPEC_ID_PATTERN,
|
|
920
|
+
SPEC_ID_ERROR_MESSAGE,
|
|
831
921
|
};
|
package/dist/waivers-manager.js
CHANGED
|
@@ -275,6 +275,90 @@ class WaiversManager {
|
|
|
275
275
|
return activeWaivers;
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Enumerate individual waiver files (WV-XXXX.yaml) on disk and return
|
|
280
|
+
* their parsed contents. These files are the source of truth per the
|
|
281
|
+
* CAWSFIX-04 invariants; active-waivers.yaml is an aggregate index.
|
|
282
|
+
*
|
|
283
|
+
* @returns {Array<{id: string, path: string, data: object}>}
|
|
284
|
+
*/
|
|
285
|
+
enumerateWaiverFiles() {
|
|
286
|
+
const out = [];
|
|
287
|
+
if (!fs.existsSync(this.waiversDir)) return out;
|
|
288
|
+
|
|
289
|
+
const files = fs.readdirSync(this.waiversDir);
|
|
290
|
+
for (const file of files) {
|
|
291
|
+
const match = file.match(/^(WV-\d{4})\.yaml$/);
|
|
292
|
+
if (!match) continue;
|
|
293
|
+
|
|
294
|
+
const filePath = path.join(this.waiversDir, file);
|
|
295
|
+
let data;
|
|
296
|
+
try {
|
|
297
|
+
data = yaml.load(fs.readFileSync(filePath, 'utf8'));
|
|
298
|
+
} catch (err) {
|
|
299
|
+
// Skip unparseable files; do not swallow — warn the caller.
|
|
300
|
+
console.warn(`Warning: could not parse ${file}: ${err.message}`);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (data && typeof data === 'object') {
|
|
304
|
+
out.push({ id: match[1], path: filePath, data });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Identify waivers that are candidates for expiry-based pruning.
|
|
312
|
+
* A waiver is prunable iff `status === 'active'` AND
|
|
313
|
+
* `expires_at < now`. Already-expired or revoked waivers are skipped
|
|
314
|
+
* (their status is correct; pruning wouldn't change anything).
|
|
315
|
+
*
|
|
316
|
+
* @param {Date} [nowOverride] — inject clock for tests
|
|
317
|
+
* @returns {Array<{id: string, path: string, expires_at: string}>}
|
|
318
|
+
*/
|
|
319
|
+
findExpiredWaivers(nowOverride) {
|
|
320
|
+
const now = nowOverride instanceof Date ? nowOverride : new Date();
|
|
321
|
+
const records = this.enumerateWaiverFiles();
|
|
322
|
+
const candidates = [];
|
|
323
|
+
|
|
324
|
+
for (const rec of records) {
|
|
325
|
+
const w = rec.data;
|
|
326
|
+
const status = w.status;
|
|
327
|
+
// Only active waivers are prunable. Waivers with no status field are
|
|
328
|
+
// treated as active (matches existing loadActiveWaivers() assumption).
|
|
329
|
+
if (status && status !== 'active') continue;
|
|
330
|
+
if (!w.expires_at) continue;
|
|
331
|
+
|
|
332
|
+
const expiresAt = new Date(w.expires_at);
|
|
333
|
+
if (!Number.isFinite(expiresAt.getTime())) continue; // malformed date
|
|
334
|
+
if (expiresAt < now) {
|
|
335
|
+
candidates.push({
|
|
336
|
+
id: rec.id,
|
|
337
|
+
path: rec.path,
|
|
338
|
+
expires_at: w.expires_at,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return candidates;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Transition a single waiver file from `status: active` to
|
|
347
|
+
* `status: expired` in place. The file is rewritten with its existing
|
|
348
|
+
* field order where possible; a `status` field is added or replaced.
|
|
349
|
+
*
|
|
350
|
+
* @param {string} filePath
|
|
351
|
+
* @returns {object} the updated waiver object
|
|
352
|
+
*/
|
|
353
|
+
markWaiverExpired(filePath) {
|
|
354
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
355
|
+
const data = yaml.load(raw) || {};
|
|
356
|
+
data.status = 'expired';
|
|
357
|
+
data.expired_at = new Date().toISOString();
|
|
358
|
+
fs.writeFileSync(filePath, yaml.dump(data, { lineWidth: -1 }), 'utf8');
|
|
359
|
+
return data;
|
|
360
|
+
}
|
|
361
|
+
|
|
278
362
|
/**
|
|
279
363
|
* Revoke a waiver
|
|
280
364
|
*/
|
|
@@ -27,6 +27,59 @@ function findFeatureSpecPath(root, specId) {
|
|
|
27
27
|
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
function writeSpecWithWorktree(filePath, worktreeName) {
|
|
31
|
+
const yaml = require('js-yaml');
|
|
32
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
33
|
+
const parsed = yaml.load(content);
|
|
34
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
35
|
+
return content;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
parsed.worktree = worktreeName;
|
|
39
|
+
return yaml.dump(parsed, { lineWidth: 120, noRefs: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hasPathChanges(root, relativePath) {
|
|
43
|
+
try {
|
|
44
|
+
const output = execFileSync(
|
|
45
|
+
'git',
|
|
46
|
+
['status', '--porcelain', '--', relativePath],
|
|
47
|
+
{ cwd: root, encoding: 'utf8', stdio: 'pipe' }
|
|
48
|
+
).trim();
|
|
49
|
+
return output.length > 0;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ensureCanonicalSpecCommitted(root, specPath, specId, worktreeName) {
|
|
56
|
+
const relativeSpecPath = path.relative(root, specPath);
|
|
57
|
+
const nextContent = writeSpecWithWorktree(specPath, worktreeName);
|
|
58
|
+
const currentContent = fs.readFileSync(specPath, 'utf8');
|
|
59
|
+
|
|
60
|
+
if (currentContent !== nextContent) {
|
|
61
|
+
fs.writeFileSync(specPath, nextContent);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!hasPathChanges(root, relativeSpecPath)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
execFileSync('git', ['add', '--', relativeSpecPath], {
|
|
69
|
+
cwd: root,
|
|
70
|
+
stdio: 'pipe',
|
|
71
|
+
});
|
|
72
|
+
execFileSync(
|
|
73
|
+
'git',
|
|
74
|
+
['commit', '-m', `chore(caws): bind spec ${specId} to worktree ${worktreeName}`, '--', relativeSpecPath],
|
|
75
|
+
{
|
|
76
|
+
cwd: root,
|
|
77
|
+
stdio: 'pipe',
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
30
83
|
function materializeWorktreeSpec(root, cawsDest, specId, worktreeName, scope) {
|
|
31
84
|
if (!specId) return;
|
|
32
85
|
|
|
@@ -46,14 +99,14 @@ function materializeWorktreeSpec(root, cawsDest, specId, worktreeName, scope) {
|
|
|
46
99
|
|
|
47
100
|
// Keep a canonical feature-spec copy inside the worktree and align
|
|
48
101
|
// working-spec.yaml to that exact content for legacy-compatible commands.
|
|
49
|
-
const specContent =
|
|
102
|
+
const specContent = writeSpecWithWorktree(canonicalSpecPath, worktreeName);
|
|
50
103
|
fs.writeFileSync(destSpecPath, specContent);
|
|
51
104
|
fs.writeFileSync(workingSpecPath, specContent);
|
|
52
105
|
return;
|
|
53
106
|
}
|
|
54
107
|
|
|
55
108
|
const { generateWorkingSpec } = require('../generators/working-spec');
|
|
56
|
-
|
|
109
|
+
let specContent = generateWorkingSpec({
|
|
57
110
|
projectId: specId,
|
|
58
111
|
projectTitle: `Worktree: ${worktreeName}`,
|
|
59
112
|
projectDescription: `Isolated worktree for ${worktreeName}`,
|
|
@@ -86,10 +139,84 @@ function materializeWorktreeSpec(root, cawsDest, specId, worktreeName, scope) {
|
|
|
86
139
|
complexityFactors: '',
|
|
87
140
|
});
|
|
88
141
|
|
|
142
|
+
try {
|
|
143
|
+
const yaml = require('js-yaml');
|
|
144
|
+
const parsed = yaml.load(specContent);
|
|
145
|
+
if (parsed && typeof parsed === 'object') {
|
|
146
|
+
parsed.worktree = worktreeName;
|
|
147
|
+
specContent = yaml.dump(parsed, { lineWidth: 120, noRefs: true });
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// Keep generated spec content if augmentation fails.
|
|
151
|
+
}
|
|
152
|
+
|
|
89
153
|
fs.ensureDirSync(path.dirname(workingSpecPath));
|
|
90
154
|
fs.writeFileSync(workingSpecPath, specContent);
|
|
91
155
|
}
|
|
92
156
|
|
|
157
|
+
function parseSpecIdFromYamlFile(filePath) {
|
|
158
|
+
try {
|
|
159
|
+
const yaml = require('js-yaml');
|
|
160
|
+
const doc = yaml.load(fs.readFileSync(filePath, 'utf8'));
|
|
161
|
+
if (doc && typeof doc.id === 'string' && doc.id.trim()) {
|
|
162
|
+
return doc.id.trim();
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// Ignore malformed YAML during inference
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Scan .caws/specs/ for a spec that declares `worktree: <name>`.
|
|
172
|
+
* Returns the spec's id if found, null otherwise.
|
|
173
|
+
* This enables auto-binding: when a spec already names the worktree
|
|
174
|
+
* it expects, the registry entry gets the specId automatically.
|
|
175
|
+
* @param {string} root - Repository root
|
|
176
|
+
* @param {string} worktreeName - Worktree name to match
|
|
177
|
+
* @returns {string|null} Spec ID or null
|
|
178
|
+
*/
|
|
179
|
+
function findSpecByWorktreeName(root, worktreeName) {
|
|
180
|
+
const yaml = require('js-yaml');
|
|
181
|
+
const specsDir = path.join(root, '.caws', 'specs');
|
|
182
|
+
if (!fs.existsSync(specsDir)) return null;
|
|
183
|
+
|
|
184
|
+
const specFiles = fs.readdirSync(specsDir)
|
|
185
|
+
.filter((name) => name.endsWith('.yaml') || name.endsWith('.yml'));
|
|
186
|
+
|
|
187
|
+
for (const specFile of specFiles) {
|
|
188
|
+
try {
|
|
189
|
+
const doc = yaml.load(fs.readFileSync(path.join(specsDir, specFile), 'utf8'));
|
|
190
|
+
if (doc && doc.worktree === worktreeName && typeof doc.id === 'string') {
|
|
191
|
+
return doc.id.trim();
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// Skip malformed spec files
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function inferSpecIdForWorktree(worktreePath) {
|
|
201
|
+
if (!worktreePath) return null;
|
|
202
|
+
|
|
203
|
+
const specsDir = path.join(worktreePath, '.caws', 'specs');
|
|
204
|
+
if (fs.existsSync(specsDir)) {
|
|
205
|
+
const specFiles = fs.readdirSync(specsDir)
|
|
206
|
+
.filter((name) => name.endsWith('.yaml') || name.endsWith('.yml'))
|
|
207
|
+
.sort();
|
|
208
|
+
|
|
209
|
+
for (const specFile of specFiles) {
|
|
210
|
+
const inferred = parseSpecIdFromYamlFile(path.join(specsDir, specFile));
|
|
211
|
+
if (inferred) {
|
|
212
|
+
return inferred;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return parseSpecIdFromYamlFile(path.join(worktreePath, '.caws', 'working-spec.yaml'));
|
|
218
|
+
}
|
|
219
|
+
|
|
93
220
|
/**
|
|
94
221
|
* Get the last commit info for a branch
|
|
95
222
|
* @param {string} branch - Branch name
|
|
@@ -356,7 +483,7 @@ function autoRegisterWorktree(root, registry, discovered) {
|
|
|
356
483
|
branch: discovered.branch,
|
|
357
484
|
baseBranch,
|
|
358
485
|
scope: null,
|
|
359
|
-
specId:
|
|
486
|
+
specId: inferSpecIdForWorktree(discovered.path),
|
|
360
487
|
owner: null,
|
|
361
488
|
createdAt: new Date().toISOString(),
|
|
362
489
|
status: 'active',
|
|
@@ -423,6 +550,7 @@ function createWorktree(name, options = {}) {
|
|
|
423
550
|
const worktreePath = path.join(root, WORKTREES_DIR, name);
|
|
424
551
|
const branchName = BRANCH_PREFIX + name;
|
|
425
552
|
const base = baseBranch || getCurrentBranch();
|
|
553
|
+
const canonicalSpecPath = findFeatureSpecPath(root, specId);
|
|
426
554
|
|
|
427
555
|
// Check if the branch already exists in git (even if not in registry)
|
|
428
556
|
// This catches cases where another agent created the branch outside CAWS
|
|
@@ -449,6 +577,10 @@ function createWorktree(name, options = {}) {
|
|
|
449
577
|
// Create the worktree directory
|
|
450
578
|
fs.ensureDirSync(path.dirname(worktreePath));
|
|
451
579
|
|
|
580
|
+
if (canonicalSpecPath) {
|
|
581
|
+
ensureCanonicalSpecCommitted(root, canonicalSpecPath, specId, name);
|
|
582
|
+
}
|
|
583
|
+
|
|
452
584
|
// Create git worktree with new branch
|
|
453
585
|
try {
|
|
454
586
|
execFileSync('git', ['worktree', 'add', '-b', branchName, worktreePath, base], {
|
|
@@ -510,15 +642,26 @@ function createWorktree(name, options = {}) {
|
|
|
510
642
|
}
|
|
511
643
|
}
|
|
512
644
|
|
|
645
|
+
// Auto-bind specId: if no explicit --spec-id was passed, scan .caws/specs/
|
|
646
|
+
// for a spec that declares `worktree: <name>`. This establishes the mutual
|
|
647
|
+
// reference that the scope guard uses to treat one spec as authoritative.
|
|
648
|
+
let resolvedSpecId = specId || null;
|
|
649
|
+
if (!resolvedSpecId) {
|
|
650
|
+
resolvedSpecId = findSpecByWorktreeName(root, name);
|
|
651
|
+
if (resolvedSpecId) {
|
|
652
|
+
console.log(chalk.gray(` Auto-bound spec: ${resolvedSpecId}`));
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
513
656
|
// Materialize a worktree-local working spec. Prefer the canonical feature
|
|
514
657
|
// spec when it exists so isolated worktrees stay aligned with the main
|
|
515
658
|
// registry/resolver model.
|
|
516
|
-
if (
|
|
659
|
+
if (resolvedSpecId) {
|
|
517
660
|
try {
|
|
518
|
-
materializeWorktreeSpec(root, cawsDest,
|
|
661
|
+
materializeWorktreeSpec(root, cawsDest, resolvedSpecId, name, scope);
|
|
519
662
|
} catch (error) {
|
|
520
663
|
console.warn(
|
|
521
|
-
chalk.yellow(`Could not materialize spec '${
|
|
664
|
+
chalk.yellow(`Could not materialize spec '${resolvedSpecId}' for worktree '${name}': ${error.message}`)
|
|
522
665
|
);
|
|
523
666
|
// Non-fatal: spec generation is optional
|
|
524
667
|
}
|
|
@@ -531,7 +674,7 @@ function createWorktree(name, options = {}) {
|
|
|
531
674
|
branch: branchName,
|
|
532
675
|
baseBranch: base,
|
|
533
676
|
scope: scope || null,
|
|
534
|
-
specId:
|
|
677
|
+
specId: resolvedSpecId,
|
|
535
678
|
owner: options.owner || getAgentSessionId(root) || null,
|
|
536
679
|
createdAt: new Date().toISOString(),
|
|
537
680
|
status: 'fresh',
|
|
@@ -892,9 +1035,31 @@ function destroyWorktree(name, options = {}) {
|
|
|
892
1035
|
}
|
|
893
1036
|
|
|
894
1037
|
// Update registry
|
|
1038
|
+
const wasAlreadyDestroyed = registry.worktrees[name].status === 'destroyed';
|
|
895
1039
|
registry.worktrees[name].status = 'destroyed';
|
|
896
1040
|
registry.worktrees[name].destroyedAt = new Date().toISOString();
|
|
897
1041
|
saveRegistry(root, registry);
|
|
1042
|
+
|
|
1043
|
+
// CAWSFIX-18: auto-commit the registry so the working tree stays clean
|
|
1044
|
+
if (!wasAlreadyDestroyed) {
|
|
1045
|
+
try {
|
|
1046
|
+
const status = execFileSync('git', ['status', '--porcelain', '.caws/worktrees.json'], {
|
|
1047
|
+
cwd: root, stdio: ['pipe', 'pipe', 'pipe'],
|
|
1048
|
+
}).toString().trim();
|
|
1049
|
+
if (status) {
|
|
1050
|
+
const otherActive = Object.values(registry.worktrees || {}).some(
|
|
1051
|
+
(e) => e.status === 'active' || e.status === 'fresh'
|
|
1052
|
+
);
|
|
1053
|
+
const prefix = otherActive ? 'wip(checkpoint)' : 'chore(worktree)';
|
|
1054
|
+
execFileSync('git', ['add', '.caws/worktrees.json'], { cwd: root, stdio: 'pipe' });
|
|
1055
|
+
execFileSync('git', ['commit', '-m', `${prefix}: record destroyed ${name}`], {
|
|
1056
|
+
cwd: root, stdio: 'pipe',
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
} catch (err) {
|
|
1060
|
+
console.warn(chalk.yellow(` Warning: could not auto-commit .caws/worktrees.json: ${err.message}`));
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
898
1063
|
}
|
|
899
1064
|
|
|
900
1065
|
/**
|
|
@@ -1055,13 +1220,50 @@ function mergeWorktree(name, options = {}) {
|
|
|
1055
1220
|
}
|
|
1056
1221
|
}
|
|
1057
1222
|
|
|
1058
|
-
|
|
1223
|
+
// Auto-close the bound spec if one exists. A worktree merge is the
|
|
1224
|
+
// lifecycle signal that the spec's work is done; leaving the spec
|
|
1225
|
+
// `active` after merge accumulates stale-active entries (D6). Direct
|
|
1226
|
+
// YAML status flip bypasses the ownership + worktree-reference checks
|
|
1227
|
+
// in `closeSpec` — the caller has already proven authority by merging.
|
|
1228
|
+
let autoClosedSpecId = null;
|
|
1229
|
+
if (entry.specId) {
|
|
1230
|
+
autoClosedSpecId = autoCloseBoundSpec(root, entry.specId);
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const mergeResult = {
|
|
1234
|
+
name, branch: entry.branch, baseBranch, merged: true, conflicts: [],
|
|
1235
|
+
specId: entry.specId || null, autoClosedSpecId,
|
|
1236
|
+
};
|
|
1059
1237
|
try {
|
|
1060
1238
|
lifecycle.emit(EVENTS.MERGE_POST, { ...mergeResult, timestamp: new Date().toISOString() });
|
|
1061
1239
|
} catch { /* non-fatal */ }
|
|
1062
1240
|
return mergeResult;
|
|
1063
1241
|
}
|
|
1064
1242
|
|
|
1243
|
+
/**
|
|
1244
|
+
* Flip a spec's status to `closed` by rewriting just the `status:` line.
|
|
1245
|
+
* Idempotent: no-op when the spec is already closed or the file is missing.
|
|
1246
|
+
* Returns the spec ID on success, null if skipped or failed.
|
|
1247
|
+
* @param {string} root - Repo root
|
|
1248
|
+
* @param {string} specId - Spec identifier (e.g. CAWSFIX-14)
|
|
1249
|
+
* @returns {string|null}
|
|
1250
|
+
*/
|
|
1251
|
+
function autoCloseBoundSpec(root, specId) {
|
|
1252
|
+
try {
|
|
1253
|
+
const specPath = findFeatureSpecPath(root, specId);
|
|
1254
|
+
if (!specPath || !fs.existsSync(specPath)) return null;
|
|
1255
|
+
const original = fs.readFileSync(specPath, 'utf8');
|
|
1256
|
+
// Idempotent: already closed → no-op, no write, no diff.
|
|
1257
|
+
if (/^status:\s*closed\s*$/m.test(original)) return specId;
|
|
1258
|
+
const patched = original.replace(/^status:\s*active\s*$/m, 'status: closed');
|
|
1259
|
+
if (patched === original) return null; // status was e.g. draft/archived
|
|
1260
|
+
fs.writeFileSync(specPath, patched, 'utf8');
|
|
1261
|
+
return specId;
|
|
1262
|
+
} catch {
|
|
1263
|
+
return null;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1065
1267
|
/**
|
|
1066
1268
|
* Prune stale worktree entries
|
|
1067
1269
|
* @param {Object} options - Prune options
|
|
@@ -1149,10 +1351,12 @@ module.exports = {
|
|
|
1149
1351
|
listWorktrees,
|
|
1150
1352
|
destroyWorktree,
|
|
1151
1353
|
mergeWorktree,
|
|
1354
|
+
autoCloseBoundSpec,
|
|
1152
1355
|
pruneWorktrees,
|
|
1153
1356
|
repairWorktrees,
|
|
1154
1357
|
reconcileRegistry,
|
|
1155
1358
|
loadRegistry,
|
|
1359
|
+
saveRegistry,
|
|
1156
1360
|
getRepoRoot,
|
|
1157
1361
|
getLastCommitInfo,
|
|
1158
1362
|
isBranchMerged,
|
|
@@ -1165,4 +1369,6 @@ module.exports = {
|
|
|
1165
1369
|
BRANCH_PREFIX,
|
|
1166
1370
|
findFeatureSpecPath,
|
|
1167
1371
|
materializeWorktreeSpec,
|
|
1372
|
+
inferSpecIdForWorktree,
|
|
1373
|
+
findSpecByWorktreeName,
|
|
1168
1374
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paths.design/caws-cli",
|
|
3
|
-
"version": "10.0
|
|
3
|
+
"version": "10.1.0",
|
|
4
4
|
"description": "CAWS CLI - Coding Agent Workflow System command-line tools for spec management, quality gates, and AI-assisted development",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"lint:staged": "npx eslint src/**/*.js tests/**/*.js",
|
|
33
33
|
"format": "prettier --write src/**/*.js tests/**/*.js",
|
|
34
34
|
"validate": "echo 'CLI package validation not required'",
|
|
35
|
-
"caws:validate": "node
|
|
35
|
+
"caws:validate": "node dist/index.js validate",
|
|
36
36
|
"clean": "rm -rf dist test-caws-project .agent && npm run test:cleanup",
|
|
37
37
|
"prepare": "husky >/dev/null 2>&1 || true"
|
|
38
38
|
},
|
|
@@ -61,7 +61,9 @@
|
|
|
61
61
|
"fs-extra": "^11.0.0",
|
|
62
62
|
"inquirer": "8.2.7",
|
|
63
63
|
"ajv": "8.17.1",
|
|
64
|
-
"js-yaml": "4.1.0"
|
|
64
|
+
"js-yaml": "4.1.0",
|
|
65
|
+
"micromatch": "4.0.8",
|
|
66
|
+
"minimatch": "^10.0.1"
|
|
65
67
|
},
|
|
66
68
|
"devDependencies": {
|
|
67
69
|
"@eslint/js": "^9.0.0",
|
|
@@ -78,7 +80,6 @@
|
|
|
78
80
|
"husky": "9.1.7",
|
|
79
81
|
"jest": "30.1.3",
|
|
80
82
|
"lint-staged": "15.5.2",
|
|
81
|
-
"micromatch": "4.0.8",
|
|
82
83
|
"prettier": "^3.0.0",
|
|
83
84
|
"semantic-release": "25.0.0-beta.6",
|
|
84
85
|
"typescript": "^5.0.0"
|