@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.
- package/README.md +13 -5
- package/dist/budget-derivation.js +221 -74
- package/dist/commands/agents.js +124 -0
- 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 +359 -4
- package/dist/commands/status.js +29 -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 +200 -4
- package/dist/gates/budget-limit.js +6 -1
- package/dist/gates/scope-boundary.js +26 -7
- package/dist/gates/spec-completeness.js +8 -1
- package/dist/index.js +56 -0
- package/dist/policy/PolicyManager.js +14 -7
- package/dist/session/session-manager.js +34 -0
- package/dist/templates/.caws/schemas/policy.schema.json +101 -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/rules/worktree-isolation.md +21 -3
- package/dist/templates/.claude/settings.json +5 -0
- package/dist/templates/CLAUDE.md +56 -0
- package/dist/templates/agents.md +47 -0
- package/dist/utils/agent-display.js +210 -0
- package/dist/utils/agent-session.js +142 -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 +102 -9
- package/dist/waivers-manager.js +84 -0
- package/dist/worktree/worktree-manager.js +593 -26
- package/package.json +5 -4
- package/templates/.caws/schemas/policy.schema.json +101 -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/rules/worktree-isolation.md +21 -3
- package/templates/.claude/settings.json +5 -0
- package/templates/CLAUDE.md +56 -0
- package/templates/agents.md +47 -0
package/dist/commands/waivers.js
CHANGED
|
@@ -66,10 +66,12 @@ async function waiversCommand(subcommand = 'list', options = {}) {
|
|
|
66
66
|
return await showWaiver(options.id, options);
|
|
67
67
|
case 'revoke':
|
|
68
68
|
return await revokeWaiver(options.id, options);
|
|
69
|
+
case 'prune':
|
|
70
|
+
return await pruneWaivers(options);
|
|
69
71
|
default:
|
|
70
72
|
throw new Error(
|
|
71
73
|
`Unknown waiver subcommand: ${subcommand}.\n` +
|
|
72
|
-
'Available subcommands: create, list, show, revoke'
|
|
74
|
+
'Available subcommands: create, list, show, revoke, prune'
|
|
73
75
|
);
|
|
74
76
|
}
|
|
75
77
|
},
|
|
@@ -178,9 +180,8 @@ async function createWaiver(options) {
|
|
|
178
180
|
const { createValidator, getSchemaPath } = require('../utils/schema-validator');
|
|
179
181
|
const schemaPath = getSchemaPath('waivers.schema.json', process.cwd());
|
|
180
182
|
const validate = createValidator(schemaPath);
|
|
181
|
-
// waivers.schema.json
|
|
182
|
-
const
|
|
183
|
-
const result = validate(waiverDoc);
|
|
183
|
+
// waivers.schema.json validates a single waiver document directly (CAWSFIX-17)
|
|
184
|
+
const result = validate(waiver);
|
|
184
185
|
if (!result.valid) {
|
|
185
186
|
console.warn(chalk.yellow('Waiver has schema violations:'));
|
|
186
187
|
result.errors.forEach((err) => {
|
|
@@ -248,7 +249,7 @@ async function listWaivers(_options) {
|
|
|
248
249
|
|
|
249
250
|
// Validate each loaded waiver against schema
|
|
250
251
|
if (waiverValidate && waiver && waiver.id) {
|
|
251
|
-
const result = waiverValidate(
|
|
252
|
+
const result = waiverValidate(waiver);
|
|
252
253
|
if (!result.valid) {
|
|
253
254
|
console.warn(chalk.yellow(`Schema warning for ${file}:`));
|
|
254
255
|
result.errors.forEach((err) => {
|
|
@@ -405,6 +406,147 @@ async function revokeWaiver(waiverId, options) {
|
|
|
405
406
|
console.log(` Reason: ${waiver.revocation_reason}\n`);
|
|
406
407
|
}
|
|
407
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Prune expired waivers.
|
|
411
|
+
*
|
|
412
|
+
* Behavior per CAWSFIX-04 AC3-A6:
|
|
413
|
+
* --expired : dry run — list prunable waivers, no disk changes,
|
|
414
|
+
* no events emitted. Exit 0.
|
|
415
|
+
* --expired --apply : transition each prunable waiver from
|
|
416
|
+
* `status: active` to `status: expired` in place
|
|
417
|
+
* (file is updated, NOT deleted, to preserve the
|
|
418
|
+
* audit trail) and append a `waiver_pruned` event
|
|
419
|
+
* to the event log for each.
|
|
420
|
+
*
|
|
421
|
+
* A waiver is "prunable" iff `status === 'active'` AND `expires_at < now`.
|
|
422
|
+
* Waivers already `expired` or `revoked` are untouched (A4). Non-expired
|
|
423
|
+
* active waivers are untouched (A5). Empty registries exit 0 with a
|
|
424
|
+
* friendly message (A6).
|
|
425
|
+
*
|
|
426
|
+
* @param {object} options
|
|
427
|
+
* @param {boolean} [options.expired] — currently the only prune criterion
|
|
428
|
+
* @param {boolean} [options.apply] — if false, dry-run (default)
|
|
429
|
+
* @param {boolean} [options.json] — machine-readable output
|
|
430
|
+
*/
|
|
431
|
+
async function pruneWaivers(options = {}) {
|
|
432
|
+
if (!options.expired) {
|
|
433
|
+
console.error(chalk.red('\n`caws waivers prune` requires --expired\n'));
|
|
434
|
+
console.log(chalk.yellow('Usage: caws waivers prune --expired [--apply]\n'));
|
|
435
|
+
process.exit(1);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const waiversManager = new WaiversManager();
|
|
439
|
+
|
|
440
|
+
// Fast path: no waivers directory or no waiver files at all.
|
|
441
|
+
const allWaivers = waiversManager.enumerateWaiverFiles();
|
|
442
|
+
if (allWaivers.length === 0) {
|
|
443
|
+
const msg = 'No active waivers to check.';
|
|
444
|
+
if (options.json) {
|
|
445
|
+
console.log(JSON.stringify({ status: 'ok', pruned: [], message: msg }));
|
|
446
|
+
} else {
|
|
447
|
+
console.log(chalk.yellow(`\n${msg}\n`));
|
|
448
|
+
}
|
|
449
|
+
return { pruned: [], applied: false };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const candidates = waiversManager.findExpiredWaivers();
|
|
453
|
+
|
|
454
|
+
// No prunable waivers — report and return.
|
|
455
|
+
if (candidates.length === 0) {
|
|
456
|
+
const msg = 'No expired waivers to prune.';
|
|
457
|
+
if (options.json) {
|
|
458
|
+
console.log(JSON.stringify({ status: 'ok', pruned: [], message: msg }));
|
|
459
|
+
} else {
|
|
460
|
+
console.log(chalk.green(`\n${msg}\n`));
|
|
461
|
+
}
|
|
462
|
+
return { pruned: [], applied: false };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const apply = Boolean(options.apply);
|
|
466
|
+
|
|
467
|
+
if (!apply) {
|
|
468
|
+
// Dry run — report only, no disk changes, no events.
|
|
469
|
+
if (options.json) {
|
|
470
|
+
console.log(
|
|
471
|
+
JSON.stringify({
|
|
472
|
+
status: 'dry_run',
|
|
473
|
+
applied: false,
|
|
474
|
+
pruned: candidates.map((c) => ({ id: c.id, expires_at: c.expires_at })),
|
|
475
|
+
})
|
|
476
|
+
);
|
|
477
|
+
} else {
|
|
478
|
+
console.log(chalk.yellow(`\nDry run — ${candidates.length} waiver(s) would be pruned:\n`));
|
|
479
|
+
candidates.forEach((c) => {
|
|
480
|
+
console.log(` ${chalk.bold(c.id)} expired at ${c.expires_at}`);
|
|
481
|
+
});
|
|
482
|
+
console.log(chalk.dim('\nRe-run with --apply to transition status to expired.\n'));
|
|
483
|
+
}
|
|
484
|
+
return { pruned: candidates, applied: false };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Apply path — transition each file and emit events.
|
|
488
|
+
const { appendEvent } = require('../utils/event-log');
|
|
489
|
+
const pruned = [];
|
|
490
|
+
const failures = [];
|
|
491
|
+
|
|
492
|
+
for (const c of candidates) {
|
|
493
|
+
try {
|
|
494
|
+
const updated = waiversManager.markWaiverExpired(c.path);
|
|
495
|
+
|
|
496
|
+
// Emit waiver_pruned event. spec_id is optional for this event
|
|
497
|
+
// (waivers may or may not be tied to a spec). Using the waiver's
|
|
498
|
+
// applies_to field when available, otherwise omitting.
|
|
499
|
+
const spec_id =
|
|
500
|
+
updated && typeof updated.applies_to === 'string' && updated.applies_to.trim() !== ''
|
|
501
|
+
? updated.applies_to
|
|
502
|
+
: undefined;
|
|
503
|
+
|
|
504
|
+
await appendEvent({
|
|
505
|
+
actor: 'cli',
|
|
506
|
+
event: 'waiver_pruned',
|
|
507
|
+
spec_id,
|
|
508
|
+
data: {
|
|
509
|
+
waiver_id: c.id,
|
|
510
|
+
expires_at: c.expires_at,
|
|
511
|
+
previous_status: 'active',
|
|
512
|
+
new_status: 'expired',
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
pruned.push({ id: c.id, expires_at: c.expires_at });
|
|
517
|
+
} catch (err) {
|
|
518
|
+
failures.push({ id: c.id, error: err.message });
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (options.json) {
|
|
523
|
+
console.log(
|
|
524
|
+
JSON.stringify({
|
|
525
|
+
status: failures.length === 0 ? 'ok' : 'partial',
|
|
526
|
+
applied: true,
|
|
527
|
+
pruned,
|
|
528
|
+
failures,
|
|
529
|
+
})
|
|
530
|
+
);
|
|
531
|
+
} else {
|
|
532
|
+
console.log(
|
|
533
|
+
chalk.green(`\nPruned ${pruned.length} expired waiver(s):\n`)
|
|
534
|
+
);
|
|
535
|
+
pruned.forEach((p) => {
|
|
536
|
+
console.log(` ${chalk.bold(p.id)} (was expired at ${p.expires_at})`);
|
|
537
|
+
});
|
|
538
|
+
if (failures.length > 0) {
|
|
539
|
+
console.log(chalk.red(`\n${failures.length} failure(s):\n`));
|
|
540
|
+
failures.forEach((f) => {
|
|
541
|
+
console.log(` ${chalk.bold(f.id)}: ${f.error}`);
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
console.log();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return { pruned, applied: true, failures };
|
|
548
|
+
}
|
|
549
|
+
|
|
408
550
|
/**
|
|
409
551
|
* Add waiver to active waivers file for quality gates integration
|
|
410
552
|
*/
|
|
@@ -12,8 +12,14 @@ const {
|
|
|
12
12
|
mergeWorktree,
|
|
13
13
|
pruneWorktrees,
|
|
14
14
|
repairWorktrees,
|
|
15
|
+
loadRegistry,
|
|
16
|
+
saveRegistry,
|
|
17
|
+
assertWorktreeOwnership,
|
|
18
|
+
getRepoRoot,
|
|
19
|
+
findFeatureSpecPathFromCwd,
|
|
20
|
+
autoActivateBoundSpec,
|
|
15
21
|
} = require('../worktree/worktree-manager');
|
|
16
|
-
const { getAgentSessionId } = require('../utils/agent-session');
|
|
22
|
+
const { getAgentSessionId, refreshAgentClaim } = require('../utils/agent-session');
|
|
17
23
|
|
|
18
24
|
/**
|
|
19
25
|
* Handle worktree subcommands
|
|
@@ -35,9 +41,13 @@ async function worktreeCommand(subcommand, options = {}) {
|
|
|
35
41
|
return handlePrune(options);
|
|
36
42
|
case 'repair':
|
|
37
43
|
return handleRepair(options);
|
|
44
|
+
case 'bind':
|
|
45
|
+
return handleBind(options);
|
|
46
|
+
case 'claim':
|
|
47
|
+
return handleClaim(options);
|
|
38
48
|
default:
|
|
39
49
|
console.error(chalk.red(`Unknown worktree subcommand: ${subcommand}`));
|
|
40
|
-
console.log(chalk.blue('Available: create, list, destroy, merge, prune, repair'));
|
|
50
|
+
console.log(chalk.blue('Available: create, list, destroy, merge, prune, repair, bind, claim'));
|
|
41
51
|
process.exit(1);
|
|
42
52
|
}
|
|
43
53
|
} catch (error) {
|
|
@@ -169,7 +179,7 @@ function handleDestroy(options) {
|
|
|
169
179
|
}
|
|
170
180
|
|
|
171
181
|
function handleMerge(options) {
|
|
172
|
-
const { name, dryRun, deleteBranch = true, message } = options;
|
|
182
|
+
const { name, dryRun, deleteBranch = true, message, takeover = false } = options;
|
|
173
183
|
|
|
174
184
|
if (!name) {
|
|
175
185
|
console.error(chalk.red('Worktree name is required'));
|
|
@@ -187,7 +197,7 @@ function handleMerge(options) {
|
|
|
187
197
|
console.log(chalk.cyan(`Merging worktree: ${name}`));
|
|
188
198
|
}
|
|
189
199
|
|
|
190
|
-
const result = mergeWorktree(name, { dryRun, deleteBranch, message });
|
|
200
|
+
const result = mergeWorktree(name, { dryRun, deleteBranch, message, takeover });
|
|
191
201
|
|
|
192
202
|
if (dryRun) {
|
|
193
203
|
if (result.conflicts.length > 0) {
|
|
@@ -303,4 +313,190 @@ function handleRepair(options) {
|
|
|
303
313
|
}
|
|
304
314
|
}
|
|
305
315
|
|
|
316
|
+
function handleBind(options) {
|
|
317
|
+
const path = require('path');
|
|
318
|
+
const fs = require('fs-extra');
|
|
319
|
+
const yaml = require('js-yaml');
|
|
320
|
+
const { specId, name, takeover } = options;
|
|
321
|
+
|
|
322
|
+
if (!specId) {
|
|
323
|
+
console.error(chalk.red('Spec ID is required'));
|
|
324
|
+
console.log(chalk.blue('Usage: caws worktree bind <spec-id> [--name <worktree-name>] [--takeover]'));
|
|
325
|
+
process.exit(1);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Determine worktree name: from option, or detect from cwd
|
|
329
|
+
let worktreeName = name;
|
|
330
|
+
if (!worktreeName) {
|
|
331
|
+
const root = getRepoRoot();
|
|
332
|
+
const cwd = process.cwd();
|
|
333
|
+
const worktreesBase = path.join(root, '.caws', 'worktrees');
|
|
334
|
+
|
|
335
|
+
if (cwd.startsWith(worktreesBase + path.sep)) {
|
|
336
|
+
const relative = path.relative(worktreesBase, cwd);
|
|
337
|
+
worktreeName = relative.split(path.sep)[0];
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (!worktreeName) {
|
|
342
|
+
console.error(chalk.red('Could not determine worktree name.'));
|
|
343
|
+
console.log(chalk.blue('Either run this from inside a worktree, or pass --name <worktree-name>'));
|
|
344
|
+
process.exit(1);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const root = getRepoRoot();
|
|
348
|
+
// CAWSFIX-32: probe the registry for existence first, but do NOT load
|
|
349
|
+
// the full registry into a variable yet — assertWorktreeOwnership may
|
|
350
|
+
// mutate it on takeover, and we'd overwrite the takeover write later
|
|
351
|
+
// with our stale in-memory copy. Re-load after the ownership check.
|
|
352
|
+
const probe = loadRegistry(root);
|
|
353
|
+
if (!probe.worktrees || !probe.worktrees[worktreeName]) {
|
|
354
|
+
console.error(chalk.red(`Worktree '${worktreeName}' not found in registry.`));
|
|
355
|
+
console.log(chalk.blue('Run: caws worktree list to see available worktrees'));
|
|
356
|
+
process.exit(1);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// CAWSFIX-32: assert ownership BEFORE any registry/spec mutation.
|
|
361
|
+
// Foreign claim soft-blocks unless --takeover is supplied, mirroring
|
|
362
|
+
// `caws worktree claim`.
|
|
363
|
+
const ownership = assertWorktreeOwnership(root, worktreeName, {
|
|
364
|
+
allowTakeover: !!takeover,
|
|
365
|
+
takeoverCommandHint: `caws worktree bind ${specId} --name ${worktreeName} --takeover`,
|
|
366
|
+
});
|
|
367
|
+
if (!ownership.allowed) {
|
|
368
|
+
console.error(chalk.yellow(ownership.warning));
|
|
369
|
+
process.exit(1);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Now load the registry fresh — assertWorktreeOwnership may have
|
|
374
|
+
// rewritten owner + appended prior_owners on takeover.
|
|
375
|
+
const registry = loadRegistry(root);
|
|
376
|
+
|
|
377
|
+
// Load the spec file. CAWSFIX-25 / D8: when bind runs from inside a
|
|
378
|
+
// worktree, prefer the worktree's own .caws/specs/ copy so specs that
|
|
379
|
+
// live only on a feature branch (never mirrored to main) can be bound.
|
|
380
|
+
const specPath = findFeatureSpecPathFromCwd(root, specId, process.cwd());
|
|
381
|
+
if (!specPath) {
|
|
382
|
+
console.error(chalk.red(`Spec '${specId}' not found in .caws/specs/ (checked worktree-local first, then main)`));
|
|
383
|
+
console.log(chalk.blue('Run: caws specs list to see available specs'));
|
|
384
|
+
process.exit(1);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const specContent = fs.readFileSync(specPath, 'utf8');
|
|
388
|
+
const specData = yaml.load(specContent);
|
|
389
|
+
|
|
390
|
+
// Warn if spec already bound to a different worktree
|
|
391
|
+
if (specData.worktree && specData.worktree !== worktreeName) {
|
|
392
|
+
console.log(chalk.yellow(`Warning: Spec '${specId}' is currently bound to worktree '${specData.worktree}'.`));
|
|
393
|
+
console.log(chalk.yellow(`Rebinding to '${worktreeName}'.`));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Update registry side: set specId on the worktree entry
|
|
397
|
+
registry.worktrees[worktreeName].specId = specId;
|
|
398
|
+
saveRegistry(root, registry);
|
|
399
|
+
|
|
400
|
+
// Update spec side: set worktree field. CAWSFIX-24 / D10: skip the write
|
|
401
|
+
// if the parsed spec already declares the target worktree — yaml.dump
|
|
402
|
+
// would otherwise re-wrap folded scalars with no semantic change.
|
|
403
|
+
if (specData.worktree !== worktreeName) {
|
|
404
|
+
specData.worktree = worktreeName;
|
|
405
|
+
const updatedYaml = yaml.dump(specData, { lineWidth: 120, noRefs: true });
|
|
406
|
+
fs.writeFileSync(specPath, updatedYaml, 'utf8');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// CAWSFIX-23: activate the spec if it's still at draft — bind is the
|
|
410
|
+
// lifecycle signal that work is starting. CAWSFIX-25 / D8: pass the
|
|
411
|
+
// resolved specPath so the flip lands on whichever copy (worktree-local
|
|
412
|
+
// or main) was actually bound.
|
|
413
|
+
const activated = autoActivateBoundSpec(root, specId, specPath);
|
|
414
|
+
|
|
415
|
+
// CAWSFIX-32: heartbeat the current session into agents.json so the
|
|
416
|
+
// bound worktree+spec context is visible to other agents and to
|
|
417
|
+
// `caws status` / `caws agents list`.
|
|
418
|
+
refreshAgentClaim(root, { worktree: worktreeName, specId });
|
|
419
|
+
|
|
420
|
+
console.log(chalk.green(`Binding established`));
|
|
421
|
+
console.log(chalk.gray(` Worktree: ${worktreeName} -> spec: ${specId}`));
|
|
422
|
+
console.log(chalk.gray(` Spec: ${specId} -> worktree: ${worktreeName}`));
|
|
423
|
+
if (activated) {
|
|
424
|
+
console.log(chalk.gray(` Status: draft -> active`));
|
|
425
|
+
}
|
|
426
|
+
console.log(chalk.gray(` Registry: ${path.join(root, '.caws', 'worktrees.json')}`));
|
|
427
|
+
console.log(chalk.gray(` Spec file: ${specPath}`));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* CAWSFIX-31: caws worktree claim <name> [--takeover]
|
|
432
|
+
*
|
|
433
|
+
* Without --takeover: read-only context surface. Prints the current
|
|
434
|
+
* claim (owner, heartbeat, session-log pointers) and exits 1 when the
|
|
435
|
+
* worktree is owned by a different session id. Modifies nothing.
|
|
436
|
+
*
|
|
437
|
+
* With --takeover: rewrites the owner to the current session id,
|
|
438
|
+
* appends the prior owner to the worktree entry's prior_owners audit
|
|
439
|
+
* array (including their lastSeen-at-takeover from agents.json), and
|
|
440
|
+
* exits 0.
|
|
441
|
+
*
|
|
442
|
+
* Same session-id silent-proceed: if the current session already owns
|
|
443
|
+
* the worktree, the command is a successful no-op (exit 0, brief
|
|
444
|
+
* confirmation).
|
|
445
|
+
*/
|
|
446
|
+
function handleClaim(options) {
|
|
447
|
+
const { name, takeover } = options;
|
|
448
|
+
|
|
449
|
+
if (!name) {
|
|
450
|
+
console.error(chalk.red('Worktree name is required'));
|
|
451
|
+
console.log(chalk.blue('Usage: caws worktree claim <name> [--takeover]'));
|
|
452
|
+
process.exit(1);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const root = getRepoRoot();
|
|
456
|
+
const registry = loadRegistry(root);
|
|
457
|
+
if (!registry.worktrees || !registry.worktrees[name]) {
|
|
458
|
+
console.error(chalk.red(`Worktree '${name}' not found in registry.`));
|
|
459
|
+
console.log(chalk.blue('Run: caws worktree list to see available worktrees'));
|
|
460
|
+
process.exit(1);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const result = assertWorktreeOwnership(root, name, {
|
|
464
|
+
allowTakeover: !!takeover,
|
|
465
|
+
takeoverCommandHint: `caws worktree claim ${name} --takeover`,
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
if (!result.allowed) {
|
|
469
|
+
// Foreign claim, no --takeover. Print the structured warning that
|
|
470
|
+
// assertWorktreeOwnership built (claimer, heartbeat, session-log
|
|
471
|
+
// pointers, takeover hint) and exit 1. Modifies nothing.
|
|
472
|
+
console.error(chalk.yellow(result.warning));
|
|
473
|
+
process.exit(1);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// allowed = true. Three sub-cases:
|
|
477
|
+
// 1. takeover happened (priorOwner present)
|
|
478
|
+
// 2. orphan-log soft notice (warning present, no priorOwner)
|
|
479
|
+
// 3. clean / same-session (no warning)
|
|
480
|
+
if (result.priorOwner) {
|
|
481
|
+
console.log(
|
|
482
|
+
chalk.green(`Took over worktree '${name}'.`)
|
|
483
|
+
);
|
|
484
|
+
console.log(
|
|
485
|
+
chalk.gray(
|
|
486
|
+
` Prior owner ${result.priorOwner.sessionId}:${result.priorOwner.platform || 'unknown'} recorded in prior_owners audit.`
|
|
487
|
+
)
|
|
488
|
+
);
|
|
489
|
+
} else if (result.warning) {
|
|
490
|
+
console.log(chalk.yellow(result.warning));
|
|
491
|
+
console.log(chalk.green(`Proceeding — no CAWS-tracked claim on '${name}'.`));
|
|
492
|
+
} else {
|
|
493
|
+
const entry = registry.worktrees[name];
|
|
494
|
+
if (entry.owner === getAgentSessionId(root)) {
|
|
495
|
+
console.log(chalk.green(`Worktree '${name}' is already claimed by the current session.`));
|
|
496
|
+
} else {
|
|
497
|
+
console.log(chalk.green(`Worktree '${name}' has no active claim.`));
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
306
502
|
module.exports = { worktreeCommand };
|
|
@@ -60,8 +60,13 @@ async function run({ stagedFiles, spec, _policy, projectRoot, riskTier, context
|
|
|
60
60
|
|
|
61
61
|
// Budget limits apply to changes, not to the entire repo.
|
|
62
62
|
// In cli context with all tracked files, budget check is meaningless.
|
|
63
|
+
// Return 'skipped' (not 'pass') so the pipeline summary counts this gate under
|
|
64
|
+
// `skipped`, matching pipeline.js semantics for gates that did not actually run.
|
|
63
65
|
if (context === 'cli') {
|
|
64
|
-
return {
|
|
66
|
+
return {
|
|
67
|
+
status: 'skipped',
|
|
68
|
+
messages: ['Budget check skipped in CLI context (budget applies to changes, not full repo)'],
|
|
69
|
+
};
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
// Build a minimal spec for deriveBudget
|
|
@@ -21,14 +21,27 @@ function matchesAny(filePath, patterns) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* Check if a file is
|
|
25
|
-
*
|
|
24
|
+
* Check if a file is infrastructure or lives in a policy-declared
|
|
25
|
+
* non-governed zone. Exempt files bypass both scope.in and scope.out.
|
|
26
|
+
*
|
|
26
27
|
* @param {string} filePath - File path to check
|
|
27
|
-
* @
|
|
28
|
+
* @param {string[]} [nonGovernedZones=[]] - Glob patterns from
|
|
29
|
+
* policy.non_governed_zones. Paths matching any pattern are exempt
|
|
30
|
+
* from scope enforcement entirely. (CAWSFIX-26 / D9)
|
|
31
|
+
* @returns {boolean} Whether the file is exempt from scope checks
|
|
28
32
|
*/
|
|
29
|
-
function isExempt(filePath) {
|
|
33
|
+
function isExempt(filePath, nonGovernedZones = []) {
|
|
30
34
|
// .caws and .claude directories always pass (infrastructure)
|
|
31
35
|
if (filePath.startsWith('.caws/') || filePath.startsWith('.claude/')) return true;
|
|
36
|
+
|
|
37
|
+
// Policy-declared non-governed zones short-circuit scope enforcement.
|
|
38
|
+
// Intentionally wins over scope.out: the contract is that these
|
|
39
|
+
// subtrees are outside the governance model, not merely excluded
|
|
40
|
+
// from one spec's scope.
|
|
41
|
+
if (nonGovernedZones.length > 0 && matchesAny(filePath, nonGovernedZones)) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
32
45
|
return false;
|
|
33
46
|
}
|
|
34
47
|
|
|
@@ -47,14 +60,20 @@ function isRootLevel(filePath) {
|
|
|
47
60
|
* @param {Object} params - Gate parameters
|
|
48
61
|
* @param {string[]} params.stagedFiles - Staged file paths
|
|
49
62
|
* @param {Object} params.spec - Working spec with scope.in/scope.out
|
|
63
|
+
* @param {Object} [params.policy] - Optional CAWS policy. Reads
|
|
64
|
+
* policy.non_governed_zones for path exemption (CAWSFIX-26 / D9).
|
|
65
|
+
* When absent or the field is empty, only infra dirs are exempt.
|
|
50
66
|
* @returns {Promise<Object>} Gate result with status and messages
|
|
51
67
|
*/
|
|
52
|
-
async function run({ stagedFiles, spec }) {
|
|
68
|
+
async function run({ stagedFiles, spec, policy }) {
|
|
53
69
|
const messages = [];
|
|
54
70
|
const violations = [];
|
|
55
71
|
|
|
56
72
|
const scopeIn = spec?.scope?.in || [];
|
|
57
73
|
const scopeOut = spec?.scope?.out || [];
|
|
74
|
+
const nonGovernedZones = Array.isArray(policy?.non_governed_zones)
|
|
75
|
+
? policy.non_governed_zones
|
|
76
|
+
: [];
|
|
58
77
|
|
|
59
78
|
// If no scope defined, pass
|
|
60
79
|
if (scopeIn.length === 0 && scopeOut.length === 0) {
|
|
@@ -62,8 +81,8 @@ async function run({ stagedFiles, spec }) {
|
|
|
62
81
|
}
|
|
63
82
|
|
|
64
83
|
for (const file of stagedFiles) {
|
|
65
|
-
// Infrastructure dirs are
|
|
66
|
-
if (isExempt(file)) continue;
|
|
84
|
+
// Infrastructure dirs AND policy-declared non-governed zones are exempt.
|
|
85
|
+
if (isExempt(file, nonGovernedZones)) continue;
|
|
67
86
|
|
|
68
87
|
// Check scope.out first (explicit exclusion) — applies to ALL files including root-level
|
|
69
88
|
if (scopeOut.length > 0 && matchesAny(file, scopeOut)) {
|
|
@@ -42,10 +42,17 @@ async function run({ projectRoot, spec }) {
|
|
|
42
42
|
return { status: 'fail', messages: ['Resolved spec is empty'] };
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
// Try to find and load the schema
|
|
45
|
+
// Try to find and load the schema.
|
|
46
|
+
// CAWSFIX-03: The canonical project-level schema lives at
|
|
47
|
+
// `.caws/working-spec.schema.json` (flat layout, matches the pattern used for
|
|
48
|
+
// policy.schema.json and waiver.schema.json). Older projects may still have
|
|
49
|
+
// a `.caws/schemas/` directory from scaffolded templates, so we check both.
|
|
50
|
+
// Bundled fallbacks cover packaged installs.
|
|
46
51
|
const schemaPaths = [
|
|
52
|
+
path.join(projectRoot, '.caws', 'working-spec.schema.json'),
|
|
47
53
|
path.join(projectRoot, '.caws', 'schemas', 'working-spec.schema.json'),
|
|
48
54
|
path.join(projectRoot, 'node_modules', '@caws', 'cli', 'templates', '.caws', 'schemas', 'working-spec.schema.json'),
|
|
55
|
+
path.join(projectRoot, 'node_modules', '@paths.design', 'caws-cli', 'templates', '.caws', 'schemas', 'working-spec.schema.json'),
|
|
49
56
|
];
|
|
50
57
|
|
|
51
58
|
let schema = null;
|
package/dist/index.js
CHANGED
|
@@ -55,6 +55,7 @@ const { sessionCommand } = require('./commands/session');
|
|
|
55
55
|
const { parallelCommand } = require('./commands/parallel');
|
|
56
56
|
const { verifyAcsCommand } = require('./commands/verify-acs');
|
|
57
57
|
const { sidecarCommand } = require('./commands/sidecar');
|
|
58
|
+
const { scopeCommand } = require('./commands/scope');
|
|
58
59
|
|
|
59
60
|
// Import scaffold functionality
|
|
60
61
|
const { scaffoldProject, setScaffoldDependencies } = require('./scaffold');
|
|
@@ -232,6 +233,11 @@ specsCmd
|
|
|
232
233
|
.description('Close a completed spec (removes scope enforcement)')
|
|
233
234
|
.action((id) => specsCommand('close', { id }));
|
|
234
235
|
|
|
236
|
+
specsCmd
|
|
237
|
+
.command('archive <id>')
|
|
238
|
+
.description('Archive a spec — move to .caws/specs/.archive/ and flip status to archived')
|
|
239
|
+
.action((id) => specsCommand('archive', { id }));
|
|
240
|
+
|
|
235
241
|
specsCmd
|
|
236
242
|
.command('conflicts')
|
|
237
243
|
.description('Check for scope conflicts between specs')
|
|
@@ -363,6 +369,7 @@ worktreeCmd
|
|
|
363
369
|
.option('--dry-run', 'Preview conflicts without merging', false)
|
|
364
370
|
.option('--message <msg>', 'Custom merge commit message')
|
|
365
371
|
.option('--no-delete-branch', 'Keep the branch after merging')
|
|
372
|
+
.option('--takeover', 'Force takeover of a foreign worktree claim (writes prior_owners audit)', false)
|
|
366
373
|
.action((name, options) => worktreeCommand('merge', { name, ...options }));
|
|
367
374
|
|
|
368
375
|
worktreeCmd
|
|
@@ -380,6 +387,45 @@ worktreeCmd
|
|
|
380
387
|
.option('--force', 'Allow pruning entries owned by other sessions', false)
|
|
381
388
|
.action((options) => worktreeCommand('repair', options));
|
|
382
389
|
|
|
390
|
+
worktreeCmd
|
|
391
|
+
.command('bind <spec-id>')
|
|
392
|
+
.description('Bind a spec to this worktree (fixes mutual reference)')
|
|
393
|
+
.option('--name <name>', 'Worktree name (auto-detected from cwd if omitted)')
|
|
394
|
+
.option('--takeover', 'Force takeover of a foreign worktree claim (writes prior_owners audit)', false)
|
|
395
|
+
.action((specId, options) => worktreeCommand('bind', { specId, ...options }));
|
|
396
|
+
|
|
397
|
+
worktreeCmd
|
|
398
|
+
.command('claim <name>')
|
|
399
|
+
.description('Claim worktree session ownership (read-only without --takeover)')
|
|
400
|
+
.option('--takeover', 'Force takeover of a foreign claim (writes prior_owners audit)', false)
|
|
401
|
+
.action((name, options) => worktreeCommand('claim', { name, ...options }));
|
|
402
|
+
|
|
403
|
+
// Agents command group
|
|
404
|
+
const { agentsCommand } = require('./commands/agents');
|
|
405
|
+
const agentsCmd = program
|
|
406
|
+
.command('agents')
|
|
407
|
+
.description('Inspect the agent registry and session-log pointers');
|
|
408
|
+
|
|
409
|
+
agentsCmd
|
|
410
|
+
.command('list')
|
|
411
|
+
.description('List active CAWS-registered agent sessions')
|
|
412
|
+
.action(() => agentsCommand('list', {}));
|
|
413
|
+
|
|
414
|
+
agentsCmd
|
|
415
|
+
.command('show <session-id>')
|
|
416
|
+
.description('Show details for a specific agent session, including session-log pointer')
|
|
417
|
+
.action((id) => agentsCommand('show', { id }));
|
|
418
|
+
|
|
419
|
+
// Scope command group
|
|
420
|
+
const scopeCmd = program
|
|
421
|
+
.command('scope')
|
|
422
|
+
.description('Inspect and manage scope boundaries');
|
|
423
|
+
|
|
424
|
+
scopeCmd
|
|
425
|
+
.command('show')
|
|
426
|
+
.description('Show effective scope for the current context')
|
|
427
|
+
.action(() => scopeCommand('show'));
|
|
428
|
+
|
|
383
429
|
// Session command group
|
|
384
430
|
const sessionCmd = program
|
|
385
431
|
.command('session')
|
|
@@ -552,6 +598,15 @@ waiversCmd
|
|
|
552
598
|
.option('-v, --verbose', 'Show detailed error information', false)
|
|
553
599
|
.action((id, options) => waiversCommand('revoke', { ...options, id }));
|
|
554
600
|
|
|
601
|
+
waiversCmd
|
|
602
|
+
.command('prune')
|
|
603
|
+
.description('Prune expired waivers (dry-run by default; use --apply to persist)')
|
|
604
|
+
.option('--expired', 'Prune active waivers whose expires_at is in the past')
|
|
605
|
+
.option('--apply', 'Actually transition status (default: dry run)')
|
|
606
|
+
.option('--json', 'Emit machine-readable JSON output')
|
|
607
|
+
.option('-v, --verbose', 'Show detailed error information', false)
|
|
608
|
+
.action((options) => waiversCommand('prune', options));
|
|
609
|
+
|
|
555
610
|
// Workflow command group
|
|
556
611
|
program
|
|
557
612
|
.command('workflow <type>')
|
|
@@ -745,6 +800,7 @@ const VALID_COMMANDS = [
|
|
|
745
800
|
'session',
|
|
746
801
|
'parallel',
|
|
747
802
|
'verify-acs',
|
|
803
|
+
'scope',
|
|
748
804
|
];
|
|
749
805
|
|
|
750
806
|
program.exitOverride((err) => {
|
|
@@ -270,15 +270,17 @@ class PolicyManager {
|
|
|
270
270
|
throw new Error('Policy missing risk_tiers configuration');
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
273
|
+
const tierKeys = Object.keys(policy.risk_tiers);
|
|
274
|
+
if (tierKeys.length === 0) {
|
|
275
|
+
throw new Error('Policy risk_tiers must define at least one risk tier (1, 2, or 3)');
|
|
276
|
+
}
|
|
277
|
+
for (const key of tierKeys) {
|
|
278
|
+
if (!/^[1-3]$/.test(key)) {
|
|
279
|
+
throw new Error(`Policy risk_tiers has unknown tier '${key}' (expected 1, 2, or 3)`);
|
|
278
280
|
}
|
|
279
|
-
|
|
281
|
+
const budget = policy.risk_tiers[key];
|
|
280
282
|
if (typeof budget.max_files !== 'number' || typeof budget.max_loc !== 'number') {
|
|
281
|
-
throw new Error(`Risk tier ${
|
|
283
|
+
throw new Error(`Risk tier ${key} missing or invalid budget limits`);
|
|
282
284
|
}
|
|
283
285
|
}
|
|
284
286
|
|
|
@@ -354,6 +356,11 @@ class PolicyManager {
|
|
|
354
356
|
description: 'Scan for TODO/FIXME/HACK/XXX markers',
|
|
355
357
|
},
|
|
356
358
|
},
|
|
359
|
+
// CAWSFIX-26 / D9: empty by default. Projects that need to opt a
|
|
360
|
+
// subtree out of scope enforcement (e.g., research/, playground/)
|
|
361
|
+
// add glob patterns here, e.g. ['research/**']. Paths matching any
|
|
362
|
+
// pattern bypass scope-boundary checks entirely.
|
|
363
|
+
non_governed_zones: [],
|
|
357
364
|
};
|
|
358
365
|
}
|
|
359
366
|
|