@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
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview CAWS Scope CLI Command
|
|
3
|
+
* Inspects and displays effective scope boundaries for the current context
|
|
4
|
+
* @author @darianrosebrook
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const chalk = require('chalk');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const fs = require('fs-extra');
|
|
10
|
+
const yaml = require('js-yaml');
|
|
11
|
+
const {
|
|
12
|
+
getRepoRoot,
|
|
13
|
+
loadRegistry,
|
|
14
|
+
findFeatureSpecPath,
|
|
15
|
+
WORKTREES_DIR,
|
|
16
|
+
} = require('../worktree/worktree-manager');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Handle scope subcommands
|
|
20
|
+
* @param {string} subcommand - Subcommand name
|
|
21
|
+
* @param {Object} options - Command options
|
|
22
|
+
*/
|
|
23
|
+
async function scopeCommand(subcommand, options = {}) {
|
|
24
|
+
try {
|
|
25
|
+
switch (subcommand) {
|
|
26
|
+
case 'show':
|
|
27
|
+
return handleShow(options);
|
|
28
|
+
default:
|
|
29
|
+
console.error(chalk.red(`Unknown scope subcommand: ${subcommand}`));
|
|
30
|
+
console.log(chalk.blue('Available: show'));
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.error(chalk.red(`${error.message}`));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Detect if current working directory is inside a worktree
|
|
41
|
+
* @param {string} root - Repository root
|
|
42
|
+
* @returns {{ inWorktree: boolean, worktreeName: string|null }}
|
|
43
|
+
*/
|
|
44
|
+
function detectWorktreeContext(root) {
|
|
45
|
+
const cwd = process.cwd();
|
|
46
|
+
const worktreesBase = path.join(root, WORKTREES_DIR);
|
|
47
|
+
|
|
48
|
+
if (!cwd.startsWith(worktreesBase + path.sep) && cwd !== worktreesBase) {
|
|
49
|
+
return { inWorktree: false, worktreeName: null };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Extract worktree name: first path segment after the worktrees dir
|
|
53
|
+
const relative = path.relative(worktreesBase, cwd);
|
|
54
|
+
const worktreeName = relative.split(path.sep)[0];
|
|
55
|
+
|
|
56
|
+
if (!worktreeName) {
|
|
57
|
+
return { inWorktree: false, worktreeName: null };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { inWorktree: true, worktreeName };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Load a spec file and return its parsed contents
|
|
65
|
+
* @param {string} specPath - Absolute path to spec YAML
|
|
66
|
+
* @returns {Object|null}
|
|
67
|
+
*/
|
|
68
|
+
function loadSpec(specPath) {
|
|
69
|
+
try {
|
|
70
|
+
if (!fs.existsSync(specPath)) return null;
|
|
71
|
+
const content = fs.readFileSync(specPath, 'utf8');
|
|
72
|
+
return yaml.load(content);
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Find all active spec files in .caws/specs/
|
|
80
|
+
* @param {string} root - Repository root
|
|
81
|
+
* @returns {Array<{ id: string, path: string, data: Object }>}
|
|
82
|
+
*/
|
|
83
|
+
function findAllActiveSpecs(root) {
|
|
84
|
+
const specsDir = path.join(root, '.caws', 'specs');
|
|
85
|
+
if (!fs.existsSync(specsDir)) return [];
|
|
86
|
+
|
|
87
|
+
const files = fs.readdirSync(specsDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
|
|
88
|
+
const specs = [];
|
|
89
|
+
|
|
90
|
+
for (const file of files) {
|
|
91
|
+
const specPath = path.join(specsDir, file);
|
|
92
|
+
const data = loadSpec(specPath);
|
|
93
|
+
if (!data) continue;
|
|
94
|
+
|
|
95
|
+
// Skip closed/archived specs
|
|
96
|
+
const status = (data.status || '').toLowerCase();
|
|
97
|
+
if (status === 'closed' || status === 'archived') continue;
|
|
98
|
+
|
|
99
|
+
const id = path.basename(file, path.extname(file));
|
|
100
|
+
specs.push({ id, path: specPath, data });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return specs;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Print scope patterns for a spec
|
|
108
|
+
* @param {Object} data - Parsed spec YAML
|
|
109
|
+
* @param {string} indent - Indentation prefix
|
|
110
|
+
*/
|
|
111
|
+
function printScopePatterns(data, indent = ' ') {
|
|
112
|
+
const scope = data.scope || {};
|
|
113
|
+
const scopeIn = scope.in || scope.include || [];
|
|
114
|
+
const scopeOut = scope.out || scope.exclude || [];
|
|
115
|
+
|
|
116
|
+
if (scopeIn.length > 0) {
|
|
117
|
+
console.log(chalk.green(`${indent}scope.in:`));
|
|
118
|
+
for (const pattern of scopeIn) {
|
|
119
|
+
console.log(chalk.gray(`${indent} - ${pattern}`));
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
console.log(chalk.yellow(`${indent}scope.in: (none defined)`));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (scopeOut.length > 0) {
|
|
126
|
+
console.log(chalk.red(`${indent}scope.out:`));
|
|
127
|
+
for (const pattern of scopeOut) {
|
|
128
|
+
console.log(chalk.gray(`${indent} - ${pattern}`));
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
console.log(chalk.gray(`${indent}scope.out: (none)`));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Handle the 'show' subcommand
|
|
137
|
+
* @param {Object} options - Command options
|
|
138
|
+
*/
|
|
139
|
+
function handleShow(_options) {
|
|
140
|
+
const root = getRepoRoot();
|
|
141
|
+
const { inWorktree, worktreeName } = detectWorktreeContext(root);
|
|
142
|
+
|
|
143
|
+
console.log(chalk.bold.cyan('CAWS Scope Inspector'));
|
|
144
|
+
console.log(chalk.cyan('='.repeat(50)));
|
|
145
|
+
console.log('');
|
|
146
|
+
|
|
147
|
+
if (inWorktree) {
|
|
148
|
+
return handleAuthoritativeMode(root, worktreeName);
|
|
149
|
+
} else {
|
|
150
|
+
return handleUnionMode(root);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Handle authoritative mode: agent is inside a worktree with a bound spec
|
|
156
|
+
* @param {string} root - Repository root
|
|
157
|
+
* @param {string} worktreeName - Name of the worktree
|
|
158
|
+
*/
|
|
159
|
+
function handleAuthoritativeMode(root, worktreeName) {
|
|
160
|
+
console.log(chalk.white(`Worktree: ${chalk.bold(worktreeName)}`));
|
|
161
|
+
|
|
162
|
+
const registry = loadRegistry(root);
|
|
163
|
+
const entry = registry.worktrees ? registry.worktrees[worktreeName] : null;
|
|
164
|
+
|
|
165
|
+
if (!entry) {
|
|
166
|
+
console.log(chalk.red(`Worktree '${worktreeName}' not found in registry.`));
|
|
167
|
+
console.log(chalk.yellow('The scope guard is operating in union mode (all active specs).'));
|
|
168
|
+
return handleUnionMode(root);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const specId = entry.specId;
|
|
172
|
+
|
|
173
|
+
if (!specId) {
|
|
174
|
+
console.log(chalk.yellow('Mode: union (no spec bound to this worktree)'));
|
|
175
|
+
console.log('');
|
|
176
|
+
console.log(chalk.yellow('This worktree has no spec binding. The scope guard checks'));
|
|
177
|
+
console.log(chalk.yellow('against the union of all active specs.'));
|
|
178
|
+
console.log('');
|
|
179
|
+
console.log(chalk.blue('To bind a spec: caws worktree bind <spec-id>'));
|
|
180
|
+
console.log('');
|
|
181
|
+
return handleUnionMode(root);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Load the spec
|
|
185
|
+
const specPath = findFeatureSpecPath(root, specId);
|
|
186
|
+
if (!specPath) {
|
|
187
|
+
console.log(chalk.red(`Bound spec '${specId}' not found on disk.`));
|
|
188
|
+
console.log(chalk.yellow('Fix: recreate the spec or rebind with a valid spec ID.'));
|
|
189
|
+
console.log(chalk.blue(` caws worktree bind <valid-spec-id>`));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const specData = loadSpec(specPath);
|
|
194
|
+
if (!specData) {
|
|
195
|
+
console.log(chalk.red(`Failed to parse spec file: ${specPath}`));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
console.log(chalk.green(`Mode: authoritative (single bound spec)`));
|
|
200
|
+
console.log(chalk.white(`Spec: ${chalk.bold(specId)}`));
|
|
201
|
+
if (specData.title) {
|
|
202
|
+
console.log(chalk.gray(`Title: ${specData.title}`));
|
|
203
|
+
}
|
|
204
|
+
console.log('');
|
|
205
|
+
|
|
206
|
+
// Print scope patterns
|
|
207
|
+
printScopePatterns(specData);
|
|
208
|
+
console.log('');
|
|
209
|
+
|
|
210
|
+
// Check binding health: mutual reference
|
|
211
|
+
const specWorktreeRef = specData.worktree || null;
|
|
212
|
+
const registrySpecRef = specId;
|
|
213
|
+
|
|
214
|
+
if (specWorktreeRef !== worktreeName) {
|
|
215
|
+
console.log(chalk.yellow('Binding health: BROKEN'));
|
|
216
|
+
console.log(chalk.yellow(` Registry points to spec '${registrySpecRef}'`));
|
|
217
|
+
console.log(chalk.yellow(` Spec 'worktree' field: ${specWorktreeRef || '(missing)'} (expected: ${worktreeName})`));
|
|
218
|
+
console.log('');
|
|
219
|
+
console.log(chalk.blue(`Fix: caws worktree bind ${specId}`));
|
|
220
|
+
} else {
|
|
221
|
+
console.log(chalk.green('Binding health: OK'));
|
|
222
|
+
console.log(chalk.gray(` Registry -> spec: ${registrySpecRef}`));
|
|
223
|
+
console.log(chalk.gray(` Spec -> worktree: ${specWorktreeRef}`));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Handle union mode: no worktree or no spec binding
|
|
229
|
+
* @param {string} root - Repository root
|
|
230
|
+
*/
|
|
231
|
+
function handleUnionMode(root) {
|
|
232
|
+
const specs = findAllActiveSpecs(root);
|
|
233
|
+
|
|
234
|
+
if (specs.length === 0) {
|
|
235
|
+
console.log(chalk.gray('Mode: union (no active specs found)'));
|
|
236
|
+
console.log('');
|
|
237
|
+
console.log(chalk.gray('No active feature specs in .caws/specs/.'));
|
|
238
|
+
console.log(chalk.gray('The scope guard has no patterns to enforce.'));
|
|
239
|
+
console.log('');
|
|
240
|
+
console.log(chalk.blue('Create a spec: caws specs create <id> --title "description"'));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
console.log(chalk.white('Mode: union (checking all active specs)'));
|
|
245
|
+
console.log(chalk.gray(`Active specs: ${specs.length}`));
|
|
246
|
+
console.log('');
|
|
247
|
+
|
|
248
|
+
for (const spec of specs) {
|
|
249
|
+
const statusLabel = spec.data.status || 'draft';
|
|
250
|
+
console.log(chalk.white(` ${chalk.bold(spec.id)} [${statusLabel}]`));
|
|
251
|
+
if (spec.data.title) {
|
|
252
|
+
console.log(chalk.gray(` Title: ${spec.data.title}`));
|
|
253
|
+
}
|
|
254
|
+
printScopePatterns(spec.data, ' ');
|
|
255
|
+
|
|
256
|
+
// Check if this spec has a worktree binding
|
|
257
|
+
if (spec.data.worktree) {
|
|
258
|
+
console.log(chalk.gray(` worktree: ${spec.data.worktree}`));
|
|
259
|
+
}
|
|
260
|
+
console.log('');
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
module.exports = { scopeCommand };
|
package/dist/commands/sidecar.js
CHANGED
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
const chalk = require('chalk');
|
|
9
9
|
const { resolveSpec } = require('../utils/spec-resolver');
|
|
10
|
-
|
|
10
|
+
// EVLOG-002 Phase 2 read flip: sidecars read state from the event log via the
|
|
11
|
+
// pure renderer. loadStateFromEvents matches loadState's null contract exactly,
|
|
12
|
+
// so the existing "state may be null — sidecars handle that" behavior stays.
|
|
13
|
+
const { loadStateFromEvents } = require('../utils/event-renderer');
|
|
11
14
|
const { commandWrapper } = require('../utils/command-wrapper');
|
|
12
15
|
const { SIDECARS, formatSidecarText } = require('../sidecars');
|
|
13
16
|
|
|
@@ -45,8 +48,8 @@ async function sidecarCommand(subcommand, options = {}) {
|
|
|
45
48
|
process.exit(1);
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
// Load working state (may be null — sidecars handle that)
|
|
49
|
-
const state =
|
|
51
|
+
// Load working state (may be null — sidecars handle that; EVLOG-002: from event log)
|
|
52
|
+
const state = loadStateFromEvents(spec.id);
|
|
50
53
|
|
|
51
54
|
// Build sidecar-specific options
|
|
52
55
|
const sidecarOptions = {};
|
package/dist/commands/specs.js
CHANGED
|
@@ -18,6 +18,7 @@ const { findProjectRoot } = require('../utils/detection');
|
|
|
18
18
|
const { loadRegistry: loadWorktreeRegistry, getRepoRoot } = require('../worktree/worktree-manager');
|
|
19
19
|
const { getAgentSessionId } = require('../utils/agent-session');
|
|
20
20
|
const { initializeState, saveState, deleteState } = require('../utils/working-state');
|
|
21
|
+
const { appendEvent } = require('../utils/event-log');
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Check if a spec is referenced by any active worktree.
|
|
@@ -56,6 +57,35 @@ function getSpecsDir() {
|
|
|
56
57
|
function getSpecsRegistry() {
|
|
57
58
|
return path.join(findProjectRoot(), '.caws', 'specs', 'registry.json');
|
|
58
59
|
}
|
|
60
|
+
|
|
61
|
+
function detectCurrentWorktreeName() {
|
|
62
|
+
const cwd = process.cwd().replace(/\\/g, '/');
|
|
63
|
+
const worktreeMatch = cwd.match(/\/\.caws\/worktrees\/([^/]+)(?:\/|$)/);
|
|
64
|
+
if (worktreeMatch) {
|
|
65
|
+
return worktreeMatch[1];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const root = getRepoRoot();
|
|
70
|
+
const branch = require('child_process')
|
|
71
|
+
.execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
72
|
+
cwd: root,
|
|
73
|
+
encoding: 'utf8',
|
|
74
|
+
stdio: 'pipe',
|
|
75
|
+
})
|
|
76
|
+
.trim();
|
|
77
|
+
const registry = loadWorktreeRegistry(root);
|
|
78
|
+
for (const [name, entry] of Object.entries(registry.worktrees || {})) {
|
|
79
|
+
if (entry.branch === branch && entry.status !== 'destroyed' && entry.status !== 'merged') {
|
|
80
|
+
return name;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// Best-effort only; specs can still be created outside a worktree.
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
59
89
|
// Legacy constants kept for backward compatibility in tests
|
|
60
90
|
const SPECS_DIR = '.caws/specs';
|
|
61
91
|
const SPECS_REGISTRY = '.caws/specs/registry.json';
|
|
@@ -374,6 +404,11 @@ async function createSpec(id, options = {}) {
|
|
|
374
404
|
contracts: [],
|
|
375
405
|
};
|
|
376
406
|
|
|
407
|
+
const detectedWorktree = detectCurrentWorktreeName();
|
|
408
|
+
if (detectedWorktree) {
|
|
409
|
+
defaultSpec.worktree = detectedWorktree;
|
|
410
|
+
}
|
|
411
|
+
|
|
377
412
|
// Merge template, but preserve required structure
|
|
378
413
|
// Map template.criteria to acceptance if present
|
|
379
414
|
const templateAcceptance = template?.criteria || template?.acceptance;
|
|
@@ -456,6 +491,63 @@ async function createSpec(id, options = {}) {
|
|
|
456
491
|
saveState(id, initialState, findProjectRoot());
|
|
457
492
|
} catch { /* non-fatal */ }
|
|
458
493
|
|
|
494
|
+
// CAWSFIX-06: warn when a feature spec is created without contracts.
|
|
495
|
+
// Contract-first development is a CAWS value proposition; empty `contracts`
|
|
496
|
+
// on a feature-type spec is discouraged but not fatal. Emit a non-fatal
|
|
497
|
+
// warning to stderr so agents and humans notice and can update the spec.
|
|
498
|
+
//
|
|
499
|
+
// Note: the spec's acceptance text uses "mode=feature" colloquially, but in
|
|
500
|
+
// CAWS the discriminator is the `type` field (feature/fix/refactor/chore),
|
|
501
|
+
// not the `mode` field (development/pilot/etc.). We key off `type` to match
|
|
502
|
+
// the --type CLI flag and the schema.
|
|
503
|
+
const specType = parsedSpec.type || type;
|
|
504
|
+
const specContracts = Array.isArray(parsedSpec.contracts) ? parsedSpec.contracts : [];
|
|
505
|
+
if (specType === 'feature' && specContracts.length === 0) {
|
|
506
|
+
console.warn(
|
|
507
|
+
chalk.yellow(
|
|
508
|
+
`⚠ Spec ${id} has mode=feature but no contracts. ` +
|
|
509
|
+
`mode=feature without contracts is discouraged — ` +
|
|
510
|
+
`run 'caws specs update ${id}' to add a contract reference.`
|
|
511
|
+
)
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// EVLOG-001: emit spec_created event alongside state write.
|
|
516
|
+
//
|
|
517
|
+
// Spec-lifecycle events (spec_created / spec_closed / spec_deleted) are
|
|
518
|
+
// **informational redundancy** with the spec file + registry, which are
|
|
519
|
+
// the true sources of truth for spec identity. In contrast, the
|
|
520
|
+
// validation/evaluation/gates/verify_acs events are the ONLY record of
|
|
521
|
+
// those verification runs and losing them is real data loss.
|
|
522
|
+
//
|
|
523
|
+
// So we deliberately wrap spec-lifecycle emits in try/catch: a
|
|
524
|
+
// filesystem error here (test mocks, readonly fs, etc.) must not crash
|
|
525
|
+
// the spec create/close/delete flow, because the spec file itself is
|
|
526
|
+
// already persisted by the time we get here. This is a principled
|
|
527
|
+
// divergence from the strict contract for the observation events —
|
|
528
|
+
// see docs/internal/EVENTS_LOG_MIGRATION.md §4.5 and EVLOG-001 spec.
|
|
529
|
+
try {
|
|
530
|
+
await appendEvent(
|
|
531
|
+
{
|
|
532
|
+
actor: 'cli',
|
|
533
|
+
event: 'spec_created',
|
|
534
|
+
spec_id: id,
|
|
535
|
+
data: {
|
|
536
|
+
id,
|
|
537
|
+
type: parsedSpec.type || type,
|
|
538
|
+
title: parsedSpec.title || title,
|
|
539
|
+
risk_tier: parsedSpec.risk_tier || numericRiskTier,
|
|
540
|
+
mode: parsedSpec.mode || mode,
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
{ projectRoot: findProjectRoot() }
|
|
544
|
+
);
|
|
545
|
+
} catch (err) {
|
|
546
|
+
// Surface on stderr but don't propagate — the spec is already created.
|
|
547
|
+
|
|
548
|
+
console.error(`event-log: failed to record spec_created for ${id}: ${err.message}`);
|
|
549
|
+
}
|
|
550
|
+
|
|
459
551
|
return {
|
|
460
552
|
id,
|
|
461
553
|
path: fileName,
|
|
@@ -717,6 +809,19 @@ async function deleteSpec(id) {
|
|
|
717
809
|
delete registry.specs[id];
|
|
718
810
|
await saveSpecsRegistry(registry);
|
|
719
811
|
|
|
812
|
+
// EVLOG-001: emit spec_deleted event in best-effort mode. See the
|
|
813
|
+
// createSpec commentary for why spec-lifecycle events diverge from
|
|
814
|
+
// the strict fail-loud contract used by the observation events.
|
|
815
|
+
try {
|
|
816
|
+
await appendEvent(
|
|
817
|
+
{ actor: 'cli', event: 'spec_deleted', spec_id: id, data: { id } },
|
|
818
|
+
{ projectRoot: findProjectRoot() }
|
|
819
|
+
);
|
|
820
|
+
} catch (err) {
|
|
821
|
+
|
|
822
|
+
console.error(`event-log: failed to record spec_deleted for ${id}: ${err.message}`);
|
|
823
|
+
}
|
|
824
|
+
|
|
720
825
|
return true;
|
|
721
826
|
}
|
|
722
827
|
|
|
@@ -769,7 +874,49 @@ async function closeSpec(id) {
|
|
|
769
874
|
return false;
|
|
770
875
|
}
|
|
771
876
|
|
|
772
|
-
|
|
877
|
+
// CAWSFIX-15: status-only flip uses targeted line-replace so the diff
|
|
878
|
+
// stays a single line. Full `updateSpec` reserializes the whole YAML,
|
|
879
|
+
// reordering fields and injecting `*ref_0` anchors for the
|
|
880
|
+
// acceptance/acceptance_criteria alias — ~20 lines of noise for what
|
|
881
|
+
// should be a one-word change.
|
|
882
|
+
const specPath = path.join(getSpecsDir(), registry.specs[id].path);
|
|
883
|
+
const original = await fs.readFile(specPath, 'utf8');
|
|
884
|
+
const nowIso = new Date().toISOString();
|
|
885
|
+
let patched = original.replace(/^status:\s*\S+\s*$/m, 'status: closed');
|
|
886
|
+
patched = patched.replace(/^updated_at:.*$/m, `updated_at: '${nowIso}'`);
|
|
887
|
+
let ok = false;
|
|
888
|
+
if (patched !== original) {
|
|
889
|
+
await fs.writeFile(specPath, patched);
|
|
890
|
+
registry.specs[id] = {
|
|
891
|
+
...registry.specs[id],
|
|
892
|
+
status: 'closed',
|
|
893
|
+
updated_at: nowIso,
|
|
894
|
+
};
|
|
895
|
+
await saveSpecsRegistry(registry);
|
|
896
|
+
ok = true;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// EVLOG-001: emit spec_closed event after the status update succeeds.
|
|
900
|
+
// Records the prior status so the renderer can reconstruct the lifecycle.
|
|
901
|
+
// Best-effort mode — see createSpec commentary.
|
|
902
|
+
if (ok) {
|
|
903
|
+
try {
|
|
904
|
+
await appendEvent(
|
|
905
|
+
{
|
|
906
|
+
actor: 'cli',
|
|
907
|
+
event: 'spec_closed',
|
|
908
|
+
spec_id: id,
|
|
909
|
+
data: { id, prior_status: currentStatus },
|
|
910
|
+
},
|
|
911
|
+
{ projectRoot: findProjectRoot() }
|
|
912
|
+
);
|
|
913
|
+
} catch (err) {
|
|
914
|
+
|
|
915
|
+
console.error(`event-log: failed to record spec_closed for ${id}: ${err.message}`);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
return ok;
|
|
773
920
|
}
|
|
774
921
|
|
|
775
922
|
/**
|
package/dist/commands/status.js
CHANGED
|
@@ -12,7 +12,11 @@ const chalk = require('chalk');
|
|
|
12
12
|
const { safeAsync, outputResult } = require('../error-handler');
|
|
13
13
|
const { parallel } = require('../utils/async-utils');
|
|
14
14
|
const { resolveSpec } = require('../utils/spec-resolver');
|
|
15
|
-
|
|
15
|
+
// EVLOG-002 Phase 2 read flip: status reads working state from the event log
|
|
16
|
+
// via the pure renderer. loadStateFromEvents matches loadState's null contract
|
|
17
|
+
// exactly, so the `ws && ws.phase !== 'not-started'` guard and the
|
|
18
|
+
// `loadState(id) || null` coalesce below stay semantically correct.
|
|
19
|
+
const { loadStateFromEvents } = require('../utils/event-renderer');
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
22
|
* Load working specification (legacy single file approach)
|
|
@@ -375,10 +379,10 @@ function displayStatus(data) {
|
|
|
375
379
|
|
|
376
380
|
console.log('');
|
|
377
381
|
|
|
378
|
-
// Working State
|
|
382
|
+
// Working State (EVLOG-002: from event log)
|
|
379
383
|
if (spec && spec.id) {
|
|
380
384
|
let ws = null;
|
|
381
|
-
try { ws =
|
|
385
|
+
try { ws = loadStateFromEvents(spec.id); } catch { /* non-fatal */ }
|
|
382
386
|
if (ws && ws.phase !== 'not-started') {
|
|
383
387
|
const phaseLabels = {
|
|
384
388
|
'not-started': 'Not Started',
|
|
@@ -1052,7 +1056,7 @@ async function statusCommand(options = {}) {
|
|
|
1052
1056
|
passed: gates.passed,
|
|
1053
1057
|
message: gates.message,
|
|
1054
1058
|
},
|
|
1055
|
-
workingState: spec && spec.id ? (
|
|
1059
|
+
workingState: spec && spec.id ? (loadStateFromEvents(spec.id) || null) : null,
|
|
1056
1060
|
overallProgress: calculateOverallProgress({
|
|
1057
1061
|
spec,
|
|
1058
1062
|
specSelection,
|
|
@@ -53,14 +53,6 @@ const BUILTIN_TEMPLATES = {
|
|
|
53
53
|
features: ['React', 'TypeScript', 'Storybook', 'Jest', 'Publishing'],
|
|
54
54
|
path: 'templates/react/component-library',
|
|
55
55
|
},
|
|
56
|
-
'vscode-extension': {
|
|
57
|
-
name: 'VS Code Extension',
|
|
58
|
-
description: 'VS Code extension with TypeScript',
|
|
59
|
-
category: 'Extension',
|
|
60
|
-
tier: 2,
|
|
61
|
-
features: ['TypeScript', 'VS Code API', 'Jest', 'Publishing'],
|
|
62
|
-
path: 'templates/vscode-extension',
|
|
63
|
-
},
|
|
64
56
|
};
|
|
65
57
|
|
|
66
58
|
/**
|
|
@@ -20,6 +20,7 @@ const {
|
|
|
20
20
|
loadSpecsRegistry,
|
|
21
21
|
} = require('../utils/spec-resolver');
|
|
22
22
|
const { recordValidation } = require('../utils/working-state');
|
|
23
|
+
const { appendEvent } = require('../utils/event-log');
|
|
23
24
|
const { lifecycle, EVENTS } = require('../utils/lifecycle-events');
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -143,19 +144,39 @@ async function validateCommand(specFile, options = {}) {
|
|
|
143
144
|
|
|
144
145
|
const finalResult = enhancedValidation;
|
|
145
146
|
|
|
146
|
-
// Record to working state
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
147
|
+
// Record to working state (Phase 1 dual-write: state layer + event log)
|
|
148
|
+
const validationGrade = finalResult.complianceScore !== undefined
|
|
149
|
+
? getComplianceGrade(finalResult.complianceScore)
|
|
150
|
+
: null;
|
|
151
|
+
const validationPayload = {
|
|
152
|
+
passed: finalResult.valid,
|
|
153
|
+
compliance_score: finalResult.complianceScore ?? null,
|
|
154
|
+
grade: validationGrade,
|
|
155
|
+
error_count: (finalResult.errors || []).length,
|
|
156
|
+
warning_count: (finalResult.warnings || []).length,
|
|
157
|
+
};
|
|
158
|
+
// CAWSFIX-02: guard recordValidation with the same `spec && spec.id`
|
|
159
|
+
// check that gates.js already uses and that the appendEvent call below
|
|
160
|
+
// enforces. Without this, legacy working-specs without an id silently
|
|
161
|
+
// wrote `.caws/state/undefined.json` — now blocked by the state-layer
|
|
162
|
+
// fence, but guarding here keeps the intent explicit.
|
|
163
|
+
if (spec && spec.id) {
|
|
164
|
+
try {
|
|
165
|
+
recordValidation(spec.id, validationPayload);
|
|
166
|
+
} catch { /* non-fatal */ }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// EVLOG-001: emit event log entry alongside state write. Only if
|
|
170
|
+
// spec.id is present — the fence in appendEvent would throw otherwise,
|
|
171
|
+
// which is intentional for the undefined.json bug class but wrong for
|
|
172
|
+
// legitimate legacy specs without an id field. Errors here are NOT
|
|
173
|
+
// swallowed: a failure to append an event is a real defect we want to
|
|
174
|
+
// surface, not a silent data-loss event.
|
|
175
|
+
if (spec && spec.id) {
|
|
176
|
+
await appendEvent(
|
|
177
|
+
{ actor: 'cli', event: 'validation_completed', spec_id: spec.id, data: validationPayload }
|
|
178
|
+
);
|
|
179
|
+
}
|
|
159
180
|
|
|
160
181
|
// Emit lifecycle event
|
|
161
182
|
try {
|
|
@@ -12,6 +12,7 @@ const { execFileSync } = require('child_process');
|
|
|
12
12
|
const { findProjectRoot } = require('../utils/detection');
|
|
13
13
|
const { resolveSpec } = require('../utils/spec-resolver');
|
|
14
14
|
const { recordACVerification } = require('../utils/working-state');
|
|
15
|
+
const { appendEvent } = require('../utils/event-log');
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Detect the project's test runner from config files.
|
|
@@ -350,16 +351,30 @@ async function verifyAcsCommand(options = {}) {
|
|
|
350
351
|
else { totalUnchecked++; }
|
|
351
352
|
}
|
|
352
353
|
|
|
353
|
-
// Record to working state
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
354
|
+
// Record to working state (Phase 1 dual-write: state layer + event log)
|
|
355
|
+
const acPayload = {
|
|
356
|
+
total: totalAcs,
|
|
357
|
+
pass: totalPass,
|
|
358
|
+
fail: totalFail,
|
|
359
|
+
unchecked: totalUnchecked,
|
|
360
|
+
results: result.results,
|
|
361
|
+
};
|
|
362
|
+
// CAWSFIX-02: guard recordACVerification with `resolved.spec && resolved.spec.id`
|
|
363
|
+
// check to prevent the .caws/state/undefined.json bug class. Matches the
|
|
364
|
+
// pattern gates.js already uses and the appendEvent call below.
|
|
365
|
+
if (resolved.spec && resolved.spec.id) {
|
|
366
|
+
try {
|
|
367
|
+
recordACVerification(resolved.spec.id, acPayload, projectRoot);
|
|
368
|
+
} catch { /* non-fatal */ }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// EVLOG-001: emit verify_acs_completed event alongside state write.
|
|
372
|
+
if (resolved.spec && resolved.spec.id) {
|
|
373
|
+
await appendEvent(
|
|
374
|
+
{ actor: 'cli', event: 'verify_acs_completed', spec_id: resolved.spec.id, data: acPayload },
|
|
375
|
+
{ projectRoot }
|
|
376
|
+
);
|
|
377
|
+
}
|
|
363
378
|
|
|
364
379
|
// Output
|
|
365
380
|
if (options.format === 'json') {
|