@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.
Files changed (60) hide show
  1. package/README.md +13 -5
  2. package/dist/budget-derivation.js +221 -74
  3. package/dist/commands/agents.js +124 -0
  4. package/dist/commands/evaluate.js +26 -12
  5. package/dist/commands/gates.js +31 -4
  6. package/dist/commands/init.js +7 -4
  7. package/dist/commands/iterate.js +7 -3
  8. package/dist/commands/scope.js +264 -0
  9. package/dist/commands/sidecar.js +6 -3
  10. package/dist/commands/specs.js +359 -4
  11. package/dist/commands/status.js +29 -4
  12. package/dist/commands/templates.js +0 -8
  13. package/dist/commands/validate.js +34 -13
  14. package/dist/commands/verify-acs.js +25 -10
  15. package/dist/commands/waivers.js +147 -5
  16. package/dist/commands/worktree.js +200 -4
  17. package/dist/gates/budget-limit.js +6 -1
  18. package/dist/gates/scope-boundary.js +26 -7
  19. package/dist/gates/spec-completeness.js +8 -1
  20. package/dist/index.js +56 -0
  21. package/dist/policy/PolicyManager.js +14 -7
  22. package/dist/session/session-manager.js +34 -0
  23. package/dist/templates/.caws/schemas/policy.schema.json +101 -34
  24. package/dist/templates/.caws/schemas/scope.schema.json +3 -3
  25. package/dist/templates/.caws/schemas/waivers.schema.json +91 -21
  26. package/dist/templates/.caws/schemas/working-spec.schema.json +253 -89
  27. package/dist/templates/.caws/templates/working-spec.template.yml +3 -1
  28. package/dist/templates/.caws/tools/scope-guard.js +66 -15
  29. package/dist/templates/.claude/README.md +1 -1
  30. package/dist/templates/.claude/hooks/protected-paths.sh +39 -0
  31. package/dist/templates/.claude/hooks/scope-guard.sh +106 -27
  32. package/dist/templates/.claude/hooks/worktree-write-guard.sh +96 -3
  33. package/dist/templates/.claude/rules/worktree-isolation.md +21 -3
  34. package/dist/templates/.claude/settings.json +5 -0
  35. package/dist/templates/CLAUDE.md +56 -0
  36. package/dist/templates/agents.md +47 -0
  37. package/dist/utils/agent-display.js +210 -0
  38. package/dist/utils/agent-session.js +142 -0
  39. package/dist/utils/event-log.js +584 -0
  40. package/dist/utils/event-renderer.js +521 -0
  41. package/dist/utils/schema-validator.js +10 -2
  42. package/dist/utils/working-state.js +25 -0
  43. package/dist/validation/spec-validation.js +102 -9
  44. package/dist/waivers-manager.js +84 -0
  45. package/dist/worktree/worktree-manager.js +593 -26
  46. package/package.json +5 -4
  47. package/templates/.caws/schemas/policy.schema.json +101 -34
  48. package/templates/.caws/schemas/scope.schema.json +3 -3
  49. package/templates/.caws/schemas/waivers.schema.json +91 -21
  50. package/templates/.caws/schemas/working-spec.schema.json +253 -89
  51. package/templates/.caws/templates/working-spec.template.yml +3 -1
  52. package/templates/.caws/tools/scope-guard.js +66 -15
  53. package/templates/.claude/README.md +1 -1
  54. package/templates/.claude/hooks/protected-paths.sh +39 -0
  55. package/templates/.claude/hooks/scope-guard.sh +106 -27
  56. package/templates/.claude/hooks/worktree-write-guard.sh +96 -3
  57. package/templates/.claude/rules/worktree-isolation.md +21 -3
  58. package/templates/.claude/settings.json +5 -0
  59. package/templates/CLAUDE.md +56 -0
  60. package/templates/agents.md +47 -0
@@ -6,10 +6,45 @@
6
6
 
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
- const { deriveBudget, checkBudgetCompliance } = require('../budget-derivation');
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
+ * - Lowercase suffix: APC-01a, ALG-01b (CAWSFIX-25 / D2)
20
+ *
21
+ * Rejects:
22
+ * - lowercase prefix (feat-001)
23
+ * - lowercase in a non-final segment (AB-cd-01)
24
+ * - leading digit (01-FEAT)
25
+ * - missing number suffix (FEAT-)
26
+ * - trailing hyphen (FEAT-01-)
27
+ * - leading/double hyphen (--FEAT-01, FEAT--001)
28
+ * - empty string
29
+ *
30
+ * Grammar: [PREFIX](-[SEGMENT])*-NUMBER[SUFFIX]?
31
+ * - PREFIX = [A-Z] followed by zero+ [A-Z0-9]
32
+ * - SEGMENT = one+ [A-Z0-9] (alphanumeric, uppercase only)
33
+ * - NUMBER = one+ digits
34
+ * - SUFFIX = zero+ [a-z] (optional lowercase tail on final segment only)
35
+ *
36
+ * Defined once per A4 invariant; referenced by both the basic validator
37
+ * (line ~125 pre-fix) and the enhanced validator (line ~307 pre-fix).
38
+ */
39
+ const SPEC_ID_PATTERN = /^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*-\d+[a-z]*$/;
40
+
41
+ /**
42
+ * User-facing error message for bad spec IDs (CAWSFIX-10 A5).
43
+ * Kept as a module constant so the message stays in sync with the pattern.
44
+ */
45
+ const SPEC_ID_ERROR_MESSAGE =
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)';
47
+
13
48
  /**
14
49
  * Get actual budget statistics from git history
15
50
  * Analyzes changes since last tag or initial commit
@@ -66,6 +101,42 @@ function getActualBudgetStats(specDir) {
66
101
  }
67
102
  }
68
103
 
104
+ /**
105
+ * Alias the modern `acceptance_criteria` key into `acceptance` so the semantic
106
+ * validator (which historically keys off `acceptance`) accepts both shapes.
107
+ *
108
+ * Precedence (per CAWSFIX-09 A3 invariant):
109
+ * - If `acceptance` is present (legacy shape: {id,given,when,then}), it wins.
110
+ * - Otherwise `acceptance_criteria` (modern shape: {id,description,test_nodeids,status})
111
+ * is copied into `acceptance`.
112
+ *
113
+ * IMPORTANT: this function mutates the spec in place. The existing validator
114
+ * also mutates in place (risk_tier string→number coercion at line ~141; auto-fix
115
+ * writes via `current[pathParts[...]] = fix.value`). Callers of
116
+ * `validateWorkingSpecWithSuggestions({...}, {autoFix:true})` observe those
117
+ * mutations on the object they passed in — see `Multiple Auto-Fixes` tests.
118
+ * Returning a clone here would silently break that contract.
119
+ *
120
+ * @param {Object} spec - Raw spec object (mutated in place)
121
+ * @returns {Object} Same spec reference
122
+ */
123
+ function aliasAcceptanceCriteria(spec) {
124
+ if (!spec || typeof spec !== 'object') return spec;
125
+
126
+ const hasLegacy = Array.isArray(spec.acceptance) && spec.acceptance.length > 0;
127
+ const hasModern =
128
+ Array.isArray(spec.acceptance_criteria) && spec.acceptance_criteria.length > 0;
129
+
130
+ // Only alias when: legacy is absent AND modern has content.
131
+ // (Legacy wins when both present; empty modern arrays do not satisfy the
132
+ // required-field check — see edge-case tests in acceptance-criteria-alias.test.js.)
133
+ if (!hasLegacy && hasModern) {
134
+ spec.acceptance = spec.acceptance_criteria;
135
+ }
136
+
137
+ return spec;
138
+ }
139
+
69
140
  /**
70
141
  * Basic validation of working spec
71
142
  * @param {Object} spec - Working spec object
@@ -74,6 +145,11 @@ function getActualBudgetStats(specDir) {
74
145
  */
75
146
  const validateWorkingSpec = (spec, _options = {}) => {
76
147
  try {
148
+ // CAWSFIX-09: Alias `acceptance_criteria` -> `acceptance` before any
149
+ // semantic checks so specs using the modern shape don't trigger
150
+ // "Missing required field: acceptance" false negatives.
151
+ aliasAcceptanceCriteria(spec);
152
+
77
153
  // First pass: AJV schema validation (non-blocking — results collected as warnings)
78
154
  let schemaWarnings = [];
79
155
  try {
@@ -121,14 +197,14 @@ const validateWorkingSpec = (spec, _options = {}) => {
121
197
  }
122
198
  }
123
199
 
124
- // Validate specific field formats
125
- if (!/^[A-Z]+-\d+$/.test(spec.id)) {
200
+ // Validate specific field formats (CAWSFIX-10: DRY regex via SPEC_ID_PATTERN)
201
+ if (!SPEC_ID_PATTERN.test(spec.id)) {
126
202
  return {
127
203
  valid: false,
128
204
  errors: [
129
205
  {
130
206
  instancePath: '/id',
131
- message: 'Project ID should be in format: PREFIX-NUMBER (e.g., FEAT-1234)',
207
+ message: SPEC_ID_ERROR_MESSAGE,
132
208
  },
133
209
  ],
134
210
  };
@@ -252,6 +328,12 @@ function validateWorkingSpecWithSuggestions(spec, options = {}) {
252
328
  const { autoFix = false, checkBudget = false, projectRoot } = options;
253
329
 
254
330
  try {
331
+ // CAWSFIX-09: Alias `acceptance_criteria` -> `acceptance` so the
332
+ // required-field check and the "No acceptance criteria defined" warning
333
+ // recognize the modern shape as valid. Mutates in place to preserve the
334
+ // existing auto-fix contract (callers observe fixes on their object).
335
+ aliasAcceptanceCriteria(spec);
336
+
255
337
  let errors = [];
256
338
  let warnings = [];
257
339
  let fixes = [];
@@ -303,12 +385,12 @@ function validateWorkingSpecWithSuggestions(spec, options = {}) {
303
385
 
304
386
  // Semantic checks that AJV can't express
305
387
 
306
- // Validate specific field formats
307
- if (spec.id && !/^[A-Z]+-\d+$/.test(spec.id)) {
388
+ // Validate specific field formats (CAWSFIX-10: DRY regex via SPEC_ID_PATTERN)
389
+ if (spec.id && !SPEC_ID_PATTERN.test(spec.id)) {
308
390
  errors.push({
309
391
  instancePath: '/id',
310
- message: 'Project ID should be in format: PREFIX-NUMBER (e.g., FEAT-1234)',
311
- suggestion: 'Use format like: PROJ-001, FEAT-002, FIX-003',
392
+ message: SPEC_ID_ERROR_MESSAGE,
393
+ suggestion: 'Use format like: PROJ-001, FEAT-002, P03-IMPL-01, ALG-001A-HARDEN-01',
312
394
  canAutoFix: false,
313
395
  });
314
396
  }
@@ -646,10 +728,18 @@ function validateWorkingSpecWithSuggestions(spec, options = {}) {
646
728
  }
647
729
 
648
730
  // Derive and check budget if requested
731
+ //
732
+ // CAWSFIX-07: use `deriveBudgetSync` here. The async `deriveBudget`
733
+ // returns a Promise; this synchronous function previously passed the
734
+ // Promise straight into `checkBudgetCompliance`, which then read
735
+ // `derivedBudget.effective.max_files` on an undefined `.effective` and
736
+ // threw "Cannot read properties of undefined (reading 'max_files')" —
737
+ // surfaced as the "Budget derivation failed" warning on every
738
+ // schema-compliant spec.
649
739
  let budgetCheck = null;
650
740
  if (checkBudget && projectRoot) {
651
741
  try {
652
- const derivedBudget = deriveBudget(spec, projectRoot);
742
+ const derivedBudget = deriveBudgetSync(spec, projectRoot);
653
743
 
654
744
  // Get actual stats from git history
655
745
  const actualStats = getActualBudgetStats(projectRoot) || {
@@ -828,4 +918,7 @@ module.exports = {
828
918
  canAutoFixField,
829
919
  calculateComplianceScore,
830
920
  getComplianceGrade,
921
+ // CAWSFIX-10: exported so init.js and tests reference the same regex
922
+ SPEC_ID_PATTERN,
923
+ SPEC_ID_ERROR_MESSAGE,
831
924
  };
@@ -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
  */