@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.
Files changed (54) hide show
  1. package/README.md +13 -5
  2. package/dist/budget-derivation.js +221 -74
  3. package/dist/commands/evaluate.js +26 -12
  4. package/dist/commands/gates.js +31 -4
  5. package/dist/commands/init.js +7 -4
  6. package/dist/commands/iterate.js +7 -3
  7. package/dist/commands/scope.js +264 -0
  8. package/dist/commands/sidecar.js +6 -3
  9. package/dist/commands/specs.js +148 -1
  10. package/dist/commands/status.js +8 -4
  11. package/dist/commands/templates.js +0 -8
  12. package/dist/commands/validate.js +34 -13
  13. package/dist/commands/verify-acs.js +25 -10
  14. package/dist/commands/waivers.js +147 -5
  15. package/dist/commands/worktree.js +81 -1
  16. package/dist/gates/budget-limit.js +6 -1
  17. package/dist/gates/spec-completeness.js +8 -1
  18. package/dist/index.js +27 -0
  19. package/dist/policy/PolicyManager.js +9 -7
  20. package/dist/session/session-manager.js +34 -0
  21. package/dist/templates/.caws/schemas/policy.schema.json +96 -34
  22. package/dist/templates/.caws/schemas/scope.schema.json +3 -3
  23. package/dist/templates/.caws/schemas/waivers.schema.json +91 -21
  24. package/dist/templates/.caws/schemas/working-spec.schema.json +253 -89
  25. package/dist/templates/.caws/templates/working-spec.template.yml +3 -1
  26. package/dist/templates/.caws/tools/scope-guard.js +66 -15
  27. package/dist/templates/.claude/README.md +1 -1
  28. package/dist/templates/.claude/hooks/protected-paths.sh +39 -0
  29. package/dist/templates/.claude/hooks/scope-guard.sh +106 -27
  30. package/dist/templates/.claude/hooks/worktree-write-guard.sh +96 -3
  31. package/dist/templates/.claude/settings.json +5 -0
  32. package/dist/templates/CLAUDE.md +34 -0
  33. package/dist/templates/agents.md +21 -0
  34. package/dist/utils/event-log.js +584 -0
  35. package/dist/utils/event-renderer.js +521 -0
  36. package/dist/utils/schema-validator.js +10 -2
  37. package/dist/utils/working-state.js +25 -0
  38. package/dist/validation/spec-validation.js +99 -9
  39. package/dist/waivers-manager.js +84 -0
  40. package/dist/worktree/worktree-manager.js +214 -8
  41. package/package.json +5 -4
  42. package/templates/.caws/schemas/policy.schema.json +96 -34
  43. package/templates/.caws/schemas/scope.schema.json +3 -3
  44. package/templates/.caws/schemas/waivers.schema.json +91 -21
  45. package/templates/.caws/schemas/working-spec.schema.json +253 -89
  46. package/templates/.caws/templates/working-spec.template.yml +3 -1
  47. package/templates/.caws/tools/scope-guard.js +66 -15
  48. package/templates/.claude/README.md +1 -1
  49. package/templates/.claude/hooks/protected-paths.sh +39 -0
  50. package/templates/.claude/hooks/scope-guard.sh +106 -27
  51. package/templates/.claude/hooks/worktree-write-guard.sh +96 -3
  52. package/templates/.claude/settings.json +5 -0
  53. package/templates/CLAUDE.md +34 -0
  54. package/templates/agents.md +21 -0
package/README.md CHANGED
@@ -110,6 +110,19 @@ caws worktree merge <name>
110
110
 
111
111
  # Destroy a worktree
112
112
  caws worktree destroy <name>
113
+
114
+ # Bind a spec to a worktree (fixes authoritative scope mode)
115
+ caws worktree bind <spec-id>
116
+
117
+ # Repair registry inconsistencies
118
+ caws worktree repair
119
+ ```
120
+
121
+ ### Scope Management
122
+
123
+ ```bash
124
+ # Inspect effective scope boundaries, mode, and binding health
125
+ caws scope show
113
126
  ```
114
127
 
115
128
  ### Session Management
@@ -186,14 +199,9 @@ caws-cli/
186
199
  │ │
187
200
  └───────────────────────┘
188
201
 
189
- ┌─────────────────┐
190
- │ caws-mcp-server │
191
- │ (Agent Bridge) │
192
- └─────────────────┘
193
202
  ```
194
203
 
195
204
  - **caws-template**: Provides the tools and configurations that CLI manages
196
- - **caws-mcp-server**: Exposes CLI functionality to AI agents via MCP protocol
197
205
 
198
206
  ### Quality Gates Integration
199
207
 
@@ -156,6 +156,168 @@ function getDefaultPolicy() {
156
156
  };
157
157
  }
158
158
 
159
+ /**
160
+ * Load policy.yaml synchronously for contexts that can't go async.
161
+ * Falls back to the bundled default policy when the file is absent or invalid.
162
+ * NOTE: bypasses PolicyManager's TTL cache by design — callers that need
163
+ * caching should use the async `deriveBudget` path.
164
+ * @param {string} projectRoot - Project root directory
165
+ * @returns {Object} Policy object (validated, with _isDefault flag if fallback used)
166
+ */
167
+ function loadPolicySync(projectRoot) {
168
+ const policyPath = path.join(projectRoot, '.caws', 'policy.yaml');
169
+ if (!fs.existsSync(policyPath)) {
170
+ return { ...getDefaultPolicy(), _isDefault: true };
171
+ }
172
+
173
+ let policyContent;
174
+ try {
175
+ policyContent = yaml.load(fs.readFileSync(policyPath, 'utf-8'));
176
+ } catch (error) {
177
+ console.warn(`Could not parse policy.yaml (${error.message}); using defaults`);
178
+ return { ...getDefaultPolicy(), _isDefault: true };
179
+ }
180
+
181
+ if (!policyContent || typeof policyContent !== 'object') {
182
+ return { ...getDefaultPolicy(), _isDefault: true };
183
+ }
184
+
185
+ try {
186
+ validatePolicy(policyContent);
187
+ } catch (error) {
188
+ // Policy file exists but is structurally invalid — surface as warning and
189
+ // fall back to defaults so validation can continue. The PolicyManager
190
+ // async path uses console.warn for the same shape.
191
+ console.warn(`Policy has structure violations (${error.message}); using defaults`);
192
+ return { ...getDefaultPolicy(), _isDefault: true };
193
+ }
194
+
195
+ return policyContent;
196
+ }
197
+
198
+ /**
199
+ * Normalize spec.risk_tier to a canonical lookup key.
200
+ * Accepts numeric tier (2), numeric-string ("2"), or "T2"/"t2" forms.
201
+ * Returns the numeric tier (2) when the input is recognizable, otherwise
202
+ * returns the original value so downstream "missing tier" logic can report it.
203
+ * @param {*} riskTier - spec.risk_tier
204
+ * @returns {number|*} numeric tier or original value
205
+ */
206
+ function normalizeRiskTier(riskTier) {
207
+ if (typeof riskTier === 'number') {
208
+ return riskTier;
209
+ }
210
+ if (typeof riskTier === 'string') {
211
+ const match = riskTier.match(/^T?(\d)$/i);
212
+ if (match) {
213
+ return parseInt(match[1], 10);
214
+ }
215
+ }
216
+ return riskTier;
217
+ }
218
+
219
+ /**
220
+ * Look up a tier in policy.risk_tiers, tolerant of numeric vs string keys.
221
+ * policy.yaml serializes tier keys as strings ("1", "2", "3") while specs
222
+ * may use numeric risk_tier. Check both representations.
223
+ * @param {Object} policy - Policy object with risk_tiers map
224
+ * @param {number|string} tier - Normalized tier key
225
+ * @returns {Object|undefined} Tier budget config or undefined if missing
226
+ */
227
+ function lookupTierBudget(policy, tier) {
228
+ if (!policy || !policy.risk_tiers) {
229
+ return undefined;
230
+ }
231
+ return policy.risk_tiers[tier] ?? policy.risk_tiers[String(tier)];
232
+ }
233
+
234
+ /**
235
+ * Build a derived-budget result from a spec, policy, and optional project root.
236
+ * Shared by both `deriveBudget` (async) and `deriveBudgetSync`. Pure function
237
+ * over already-loaded policy — no I/O.
238
+ *
239
+ * Baseline resolution order (per CAWSFIX-07 A1/A2):
240
+ * 1. If spec.change_budget has numeric max_files and max_loc, use it.
241
+ * Legacy specs still in the tree may carry change_budget; CAWSFIX-03
242
+ * forbade it in the schema but not the runtime, so we honor it when
243
+ * present.
244
+ * 2. Otherwise, fall back to policy.risk_tiers[spec.risk_tier].
245
+ *
246
+ * Throws a named-tier error (A3) if the tier isn't present in policy and no
247
+ * spec-level change_budget is available.
248
+ *
249
+ * @param {Object} spec - Working spec
250
+ * @param {Object} policy - Loaded policy object
251
+ * @param {string} projectRoot - Project root (for waiver loading)
252
+ * @returns {Object} { baseline, effective, waivers_applied, derived_at }
253
+ */
254
+ function applyBudgetDerivation(spec, policy, projectRoot) {
255
+ const riskTier = normalizeRiskTier(spec.risk_tier);
256
+ const tierBudget = lookupTierBudget(policy, riskTier);
257
+ const specBudget = spec && spec.change_budget;
258
+ const hasSpecBudget =
259
+ specBudget &&
260
+ typeof specBudget.max_files === 'number' &&
261
+ typeof specBudget.max_loc === 'number';
262
+
263
+ let baseline;
264
+ if (hasSpecBudget) {
265
+ baseline = {
266
+ max_files: specBudget.max_files,
267
+ max_loc: specBudget.max_loc,
268
+ };
269
+ } else if (tierBudget) {
270
+ baseline = {
271
+ max_files: tierBudget.max_files,
272
+ max_loc: tierBudget.max_loc,
273
+ };
274
+ } else {
275
+ const available = policy && policy.risk_tiers ? Object.keys(policy.risk_tiers).join(', ') : 'none';
276
+ throw new Error(
277
+ `Risk tier ${spec.risk_tier} not defined in policy.yaml\n` +
278
+ `Policy only defines tiers: ${available}\n` +
279
+ `Valid tiers are: 1 (critical), 2 (standard), 3 (low-risk)` +
280
+ (typeof spec.risk_tier === 'string'
281
+ ? `\nHint: use numeric risk_tier (e.g., 2) instead of "${spec.risk_tier}"`
282
+ : '')
283
+ );
284
+ }
285
+
286
+ let effectiveBudget = { ...baseline };
287
+
288
+ if (spec.waiver_ids && Array.isArray(spec.waiver_ids)) {
289
+ for (const waiverId of spec.waiver_ids) {
290
+ const waiver = loadWaiver(waiverId, projectRoot);
291
+ if (waiver && waiver.status === 'active' && isWaiverValid(waiver)) {
292
+ if (!waiver.gates || !waiver.gates.includes('budget_limit')) {
293
+ console.warn(
294
+ `\nWaiver ${waiverId} does not cover 'budget_limit' gate\n` +
295
+ ` Current gates: [${waiver.gates ? waiver.gates.join(', ') : 'none'}]\n` +
296
+ ` Add 'budget_limit' to gates array to apply to budget violations\n`
297
+ );
298
+ continue;
299
+ }
300
+
301
+ if (waiver.delta) {
302
+ if (waiver.delta.max_files) {
303
+ effectiveBudget.max_files += waiver.delta.max_files;
304
+ }
305
+ if (waiver.delta.max_loc) {
306
+ effectiveBudget.max_loc += waiver.delta.max_loc;
307
+ }
308
+ }
309
+ }
310
+ }
311
+ }
312
+
313
+ return {
314
+ baseline,
315
+ effective: effectiveBudget,
316
+ waivers_applied: spec.waiver_ids || [],
317
+ derived_at: new Date().toISOString(),
318
+ };
319
+ }
320
+
159
321
  /**
160
322
  * Derive budget for a working spec based on policy and waivers
161
323
  * Enhanced to use PolicyManager for caching
@@ -204,70 +366,32 @@ async function deriveBudget(spec, projectRoot = process.cwd(), options = {}) {
204
366
  }
205
367
  }
206
368
 
207
- // Normalize risk_tier: accept "T1"/"T2"/"T3" strings and convert to numeric
208
- let riskTier = spec.risk_tier;
209
- if (typeof riskTier === 'string') {
210
- const match = riskTier.match(/^T?(\d)$/i);
211
- if (match) {
212
- riskTier = parseInt(match[1], 10);
213
- }
214
- }
215
-
216
- // Check if risk tier exists in policy
217
- if (!policy.risk_tiers[riskTier]) {
218
- throw new Error(
219
- `Risk tier ${spec.risk_tier} not defined in policy.yaml\n` +
220
- `Policy only defines tiers: ${Object.keys(policy.risk_tiers).join(', ')}\n` +
221
- `Valid tiers are: 1 (critical), 2 (standard), 3 (low-risk)` +
222
- (typeof spec.risk_tier === 'string'
223
- ? `\nHint: use numeric risk_tier (e.g., 2) instead of "${spec.risk_tier}"`
224
- : '')
225
- );
226
- }
227
-
228
- const tierBudget = policy.risk_tiers[riskTier];
229
- const baseline = {
230
- max_files: tierBudget.max_files,
231
- max_loc: tierBudget.max_loc,
232
- };
233
-
234
- // Start with baseline budget
235
- let effectiveBudget = { ...baseline };
236
-
237
- // Apply waivers if any
238
- if (spec.waiver_ids && Array.isArray(spec.waiver_ids)) {
239
- for (const waiverId of spec.waiver_ids) {
240
- const waiver = loadWaiver(waiverId, projectRoot);
241
- if (waiver && waiver.status === 'active' && isWaiverValid(waiver)) {
242
- // Validate waiver covers budget_limit gate
243
- if (!waiver.gates || !waiver.gates.includes('budget_limit')) {
244
- console.warn(
245
- `\nWaiver ${waiverId} does not cover 'budget_limit' gate\n` +
246
- ` Current gates: [${waiver.gates ? waiver.gates.join(', ') : 'none'}]\n` +
247
- ` Add 'budget_limit' to gates array to apply to budget violations\n`
248
- );
249
- continue;
250
- }
251
-
252
- // Apply additive deltas
253
- if (waiver.delta) {
254
- if (waiver.delta.max_files) {
255
- effectiveBudget.max_files += waiver.delta.max_files;
256
- }
257
- if (waiver.delta.max_loc) {
258
- effectiveBudget.max_loc += waiver.delta.max_loc;
259
- }
260
- }
261
- }
262
- }
263
- }
369
+ return applyBudgetDerivation(spec, policy, projectRoot);
370
+ } catch (error) {
371
+ throw new Error(`Budget derivation failed: ${error.message}`);
372
+ }
373
+ }
264
374
 
265
- return {
266
- baseline,
267
- effective: effectiveBudget,
268
- waivers_applied: spec.waiver_ids || [],
269
- derived_at: new Date().toISOString(),
270
- };
375
+ /**
376
+ * Synchronous version of deriveBudget for callers that cannot go async.
377
+ * Uses `loadPolicySync` (no PolicyManager caching) and otherwise shares
378
+ * the same derivation semantics as the async variant.
379
+ *
380
+ * Added for CAWSFIX-07: the legacy synchronous call site in
381
+ * `validation/spec-validation.js` was passing the un-awaited Promise from
382
+ * `deriveBudget` into `checkBudgetCompliance`, which then read
383
+ * `derivedBudget.effective.max_files` on an undefined `.effective` —
384
+ * producing the "Cannot read properties of undefined (reading 'max_files')"
385
+ * warning on every schema-compliant spec.
386
+ *
387
+ * @param {Object} spec - Working spec object
388
+ * @param {string} projectRoot - Project root directory
389
+ * @returns {Object} Derived budget with baseline and effective limits
390
+ */
391
+ function deriveBudgetSync(spec, projectRoot = process.cwd()) {
392
+ try {
393
+ const policy = loadPolicySync(projectRoot);
394
+ return applyBudgetDerivation(spec, policy, projectRoot);
271
395
  } catch (error) {
272
396
  throw new Error(`Budget derivation failed: ${error.message}`);
273
397
  }
@@ -278,15 +402,25 @@ async function deriveBudget(spec, projectRoot = process.cwd(), options = {}) {
278
402
  * @param {Object} waiver - Waiver document to validate
279
403
  * @throws {Error} If waiver structure is invalid
280
404
  */
281
- function validateWaiverStructure(waiver) {
282
- const requiredFields = ['id', 'title', 'reason', 'status', 'gates', 'expires_at', 'approvers'];
405
+ const WAIVER_REQUIRED_FIELDS = [
406
+ 'id',
407
+ 'applies_to',
408
+ 'gates',
409
+ 'delta',
410
+ 'reason_code',
411
+ 'expires_at',
412
+ 'risk_owner',
413
+ 'approvers',
414
+ 'status',
415
+ ];
283
416
 
417
+ function validateWaiverStructure(waiver) {
284
418
  // Check all required fields present
285
- for (const field of requiredFields) {
419
+ for (const field of WAIVER_REQUIRED_FIELDS) {
286
420
  if (!(field in waiver)) {
287
421
  throw new Error(
288
422
  `Waiver missing required field: ${field}\n` +
289
- `Required fields: ${requiredFields.join(', ')}\n` +
423
+ `Required fields: ${WAIVER_REQUIRED_FIELDS.join(', ')}\n` +
290
424
  `Fix the waiver file at .caws/waivers/${waiver.id || 'unknown'}.yaml`
291
425
  );
292
426
  }
@@ -302,8 +436,8 @@ function validateWaiverStructure(waiver) {
302
436
  );
303
437
  }
304
438
 
305
- // Validate status
306
- const validStatuses = ['active', 'expired', 'revoked'];
439
+ // Validate status (proposed is valid per schema but not applied by derivation)
440
+ const validStatuses = ['proposed', 'active', 'expired', 'revoked'];
307
441
  if (!validStatuses.includes(waiver.status)) {
308
442
  throw new Error(
309
443
  `Invalid waiver status: ${waiver.status}\n` +
@@ -322,15 +456,25 @@ function validateWaiverStructure(waiver) {
322
456
  );
323
457
  }
324
458
 
325
- // Validate approvers is array
459
+ // Validate approvers is array of {handle, approved_at?} objects
326
460
  if (!Array.isArray(waiver.approvers) || waiver.approvers.length === 0) {
327
461
  throw new Error(
328
462
  `Invalid waiver approvers: ${JSON.stringify(waiver.approvers)}\n` +
329
- 'approvers must be a non-empty array of approver names/emails\n' +
330
- 'Example: approvers: ["tech-lead@company.com"]\n' +
463
+ 'approvers must be a non-empty array of objects with a `handle` field\n' +
464
+ 'Example: approvers: [{ handle: "tech-lead", approved_at: "2025-01-01T00:00:00Z" }]\n' +
331
465
  `Fix the approvers field in .caws/waivers/${waiver.id}.yaml`
332
466
  );
333
467
  }
468
+ for (const approver of waiver.approvers) {
469
+ if (typeof approver !== 'object' || approver === null || typeof approver.handle !== 'string') {
470
+ throw new Error(
471
+ `Invalid waiver approver entry: ${JSON.stringify(approver)}\n` +
472
+ 'Each approver must be an object with a required `handle` field (string).\n' +
473
+ 'Expected shape: { handle: "github-or-email", approved_at: "ISO-8601" }\n' +
474
+ `Fix the approvers field in .caws/waivers/${waiver.id}.yaml`
475
+ );
476
+ }
477
+ }
334
478
 
335
479
  // Validate expires_at is valid date string
336
480
  const expiryDate = new Date(waiver.expires_at);
@@ -451,8 +595,9 @@ function isWaiverValid(waiver, policy = null) {
451
595
  }
452
596
  }
453
597
 
454
- // Check required fields
455
- if (!waiver.id || !waiver.title || !waiver.gates) {
598
+ // Shallow sanity check. Full schema conformance is enforced by
599
+ // validateWaiverStructure at load time (see loadWaiver).
600
+ if (!waiver.id || !waiver.gates) {
456
601
  console.warn(`Waiver ${waiver.id || 'unknown'} missing required fields`);
457
602
  return false;
458
603
  }
@@ -592,6 +737,7 @@ function generateBurnupReport(derivedBudget, currentStats) {
592
737
 
593
738
  module.exports = {
594
739
  deriveBudget,
740
+ deriveBudgetSync,
595
741
  loadWaiver,
596
742
  isWaiverValid,
597
743
  checkBudgetCompliance,
@@ -601,4 +747,5 @@ module.exports = {
601
747
  validatePolicy,
602
748
  getDefaultPolicy,
603
749
  validateWaiverStructure,
750
+ WAIVER_REQUIRED_FIELDS,
604
751
  };
@@ -12,6 +12,7 @@ const chalk = require('chalk');
12
12
  const { initializeGlobalSetup } = require('../config');
13
13
  const { resolveSpec } = require('../utils/spec-resolver');
14
14
  const { recordEvaluation } = require('../utils/working-state');
15
+ const { appendEvent } = require('../utils/event-log');
15
16
 
16
17
  /**
17
18
  * Evaluate command handler
@@ -202,18 +203,31 @@ async function evaluateCommand(specFile, options = {}) {
202
203
  ? 'D'
203
204
  : 'F';
204
205
 
205
- // Record to working state
206
- try {
207
- const checksPassed = results.checks.filter(c => c.status === 'pass').length;
208
- recordEvaluation(spec.id, {
209
- score: results.score,
210
- max_score: results.maxScore,
211
- percentage,
212
- grade,
213
- checks_passed: checksPassed,
214
- checks_total: results.checks.length,
215
- });
216
- } catch { /* non-fatal */ }
206
+ // Record to working state (Phase 1 dual-write: state layer + event log)
207
+ const checksPassed = results.checks.filter(c => c.status === 'pass').length;
208
+ const evaluationPayload = {
209
+ score: results.score,
210
+ max_score: results.maxScore,
211
+ percentage,
212
+ grade,
213
+ checks_passed: checksPassed,
214
+ checks_total: results.checks.length,
215
+ };
216
+ // CAWSFIX-02: guard recordEvaluation with `spec && spec.id` check to
217
+ // prevent the .caws/state/undefined.json bug class. Matches the pattern
218
+ // gates.js already uses and the appendEvent call below.
219
+ if (spec && spec.id) {
220
+ try {
221
+ recordEvaluation(spec.id, evaluationPayload);
222
+ } catch { /* non-fatal */ }
223
+ }
224
+
225
+ // EVLOG-001: emit evaluation_completed event alongside state write.
226
+ if (spec && spec.id) {
227
+ await appendEvent(
228
+ { actor: 'cli', event: 'evaluation_completed', spec_id: spec.id, data: evaluationPayload }
229
+ );
230
+ }
217
231
 
218
232
  console.log('\n' + '-'.repeat(60));
219
233
  console.log(
@@ -10,7 +10,13 @@ const { formatText, formatJson, formatEnrichedText } = require('../gates/format'
10
10
  const { enrichGateResults } = require('../gates/feedback');
11
11
  const { resolveSpec } = require('../utils/spec-resolver');
12
12
  const { commandWrapper } = require('../utils/command-wrapper');
13
- const { recordGates, loadState } = require('../utils/working-state');
13
+ // EVLOG-002 Phase 2 read flip: gates still writes via recordGates (state layer)
14
+ // AND emits a gates_evaluated event (from Phase 1), but the feedback-enrichment
15
+ // READ at line ~116 now goes through the event log renderer instead of loadState.
16
+ // The write path is deliberately unchanged in Phase 2 — only reads flip.
17
+ const { recordGates } = require('../utils/working-state');
18
+ const { loadStateFromEvents } = require('../utils/event-renderer');
19
+ const { appendEvent } = require('../utils/event-log');
14
20
 
15
21
  /**
16
22
  * Run quality gates via the v2 pipeline
@@ -80,18 +86,39 @@ async function gatesCommand(options = {}) {
80
86
 
81
87
  const report = await evaluateGates({ projectRoot, stagedFiles, spec, context });
82
88
 
83
- // Record to working state
89
+ // Record to working state (Phase 1 dual-write: state layer + event log)
84
90
  if (spec && spec.id) {
85
91
  try { recordGates(spec.id, report, context, projectRoot); } catch { /* non-fatal */ }
92
+
93
+ // EVLOG-001: emit gates_evaluated event alongside state write.
94
+ // Payload shape mirrors the `gates` field produced by recordGates.
95
+ await appendEvent(
96
+ {
97
+ actor: 'cli',
98
+ event: 'gates_evaluated',
99
+ spec_id: spec.id,
100
+ data: {
101
+ context,
102
+ passed: report.passed,
103
+ summary: report.summary || {},
104
+ gates: (report.gates || []).map((g) => ({
105
+ name: g.name,
106
+ status: g.status,
107
+ mode: g.mode,
108
+ })),
109
+ },
110
+ },
111
+ { projectRoot }
112
+ );
86
113
  }
87
114
 
88
115
  if (options.json || options.format === 'json') {
89
116
  console.log(formatJson(report));
90
117
  } else if (!options.quiet) {
91
- // Enrich feedback on failure or --verbose
118
+ // Enrich feedback on failure or --verbose (EVLOG-002: from event log)
92
119
  if (!report.passed || options.verbose) {
93
120
  try {
94
- const state = spec?.id ? loadState(spec.id, projectRoot) : null;
121
+ const state = spec?.id ? loadStateFromEvents(spec.id, { projectRoot }) : null;
95
122
  const enrichments = enrichGateResults(report, { spec, state, projectRoot });
96
123
  if (enrichments.size > 0) {
97
124
  console.log(formatEnrichedText(report, enrichments));
@@ -28,6 +28,8 @@ const {
28
28
  getRecommendedIDEs,
29
29
  parseIDESelection,
30
30
  } = require('../utils/ide-detection');
31
+ // CAWSFIX-10: share the canonical spec-ID regex with the validator
32
+ const { SPEC_ID_PATTERN } = require('../validation/spec-validation');
31
33
 
32
34
  function buildInitialFeatureSpec(specContent, fallbackId) {
33
35
  const parsed = yaml.load(specContent);
@@ -519,11 +521,12 @@ async function initProject(projectName, options) {
519
521
  return `PROJ-${randomNum.toString().padStart(3, '0')}`;
520
522
  },
521
523
  validate: (input) => {
522
- if (!input.match(/^[A-Z]+-\d+$/)) {
523
- return 'Project ID must be in format PREFIX-NUMBER (e.g., PROJ-001)';
524
+ // CAWSFIX-10: accept multi-segment IDs like P03-IMPL-01
525
+ if (!SPEC_ID_PATTERN.test(input)) {
526
+ return 'Project ID must be in format PREFIX-NUMBER or PREFIX-SEGMENT-NUMBER (e.g., PROJ-001, P03-IMPL-01)';
524
527
  }
525
- if (input.length > 20) {
526
- return 'Project ID must be less than 20 characters';
528
+ if (input.length > 40) {
529
+ return 'Project ID must be less than 40 characters';
527
530
  }
528
531
  return true;
529
532
  },
@@ -14,7 +14,11 @@ const { initializeGlobalSetup } = require('../config');
14
14
 
15
15
  // Import spec resolution system
16
16
  const { resolveSpec } = require('../utils/spec-resolver');
17
- const { loadState } = require('../utils/working-state');
17
+ // EVLOG-002 Phase 2 read flip: iterate reads from the event log instead of
18
+ // the state layer. loadStateFromEvents matches loadState's contract exactly
19
+ // (returns null for specs with no events), so the `workingState &&` guard
20
+ // below stays correct without code changes.
21
+ const { loadStateFromEvents } = require('../utils/event-renderer');
18
22
 
19
23
  /**
20
24
  * Iterate command handler
@@ -58,9 +62,9 @@ async function iterateCommand(specFile, options = {}) {
58
62
  console.log(`ID: ${spec.id} | Tier: ${spec.risk_tier} | Mode: ${spec.mode}`);
59
63
  console.log(`Current State: ${stateDescription}\n`);
60
64
 
61
- // Load working state for evidence-based guidance
65
+ // Load working state for evidence-based guidance (EVLOG-002: from event log)
62
66
  let workingState = null;
63
- try { workingState = loadState(spec.id); } catch { /* non-fatal */ }
67
+ try { workingState = loadStateFromEvents(spec.id); } catch { /* non-fatal */ }
64
68
 
65
69
  // Analyze progress based on mode
66
70
  const guidance = generateGuidance(spec, currentState, options);