@paths.design/caws-cli 10.1.0 → 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.
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @fileoverview CAWSFIX-31 — caws agents command.
3
+ *
4
+ * Surfaces the agent registry (`.caws/agents.json`) so operators and
5
+ * other agents can see who is currently registered, what they are
6
+ * working on, and where their session logs live. Reads only — write
7
+ * paths are owned by the session-log hook and the lifecycle ops in
8
+ * specs/worktree.
9
+ *
10
+ * Subcommands:
11
+ * - list — table of all live entries (no platform filter)
12
+ * - show <session-id> — full detail for one entry, including session-log pointer
13
+ *
14
+ * @author @darianrosebrook
15
+ */
16
+
17
+ const chalk = require('chalk');
18
+
19
+ const { findProjectRoot } = require('../utils/detection');
20
+ const {
21
+ loadAgentRegistry,
22
+ findSessionLogs,
23
+ } = require('../utils/agent-session');
24
+ const {
25
+ formatAgentRef,
26
+ formatHeartbeatAge,
27
+ formatSessionLogPointer,
28
+ } = require('../utils/agent-display');
29
+
30
+ /**
31
+ * Top-level dispatcher.
32
+ * @param {string} subcommand
33
+ * @param {Object} options
34
+ */
35
+ function agentsCommand(subcommand, options = {}) {
36
+ switch (subcommand) {
37
+ case 'list':
38
+ return handleList(options);
39
+ case 'show':
40
+ return handleShow(options);
41
+ default:
42
+ console.error(chalk.red(`Unknown agents subcommand: ${subcommand}`));
43
+ console.log(chalk.blue('Available: list, show'));
44
+ process.exit(1);
45
+ }
46
+ }
47
+
48
+ function handleList() {
49
+ const root = findProjectRoot();
50
+ const registry = loadAgentRegistry(root);
51
+ const entries = Object.values(registry.agents || {});
52
+
53
+ console.log(chalk.bold.cyan('CAWS Agents'));
54
+ console.log(chalk.cyan('='.repeat(80)));
55
+
56
+ if (entries.length === 0) {
57
+ console.log(chalk.gray('No active agents.'));
58
+ return;
59
+ }
60
+
61
+ // Sort by lastSeen desc so most recent appears first.
62
+ entries.sort((a, b) => new Date(b.lastSeen || 0) - new Date(a.lastSeen || 0));
63
+
64
+ for (const entry of entries) {
65
+ const ref = formatAgentRef(entry.sessionId, entry.platform);
66
+ const age = formatHeartbeatAge(entry.lastSeen);
67
+ console.log(chalk.bold(ref));
68
+ console.log(chalk.gray(` Heartbeat: ${entry.lastSeen || 'unknown'} (${age})`));
69
+ if (entry.specId) {
70
+ console.log(chalk.gray(` Spec: ${entry.specId}`));
71
+ }
72
+ if (entry.worktree) {
73
+ console.log(chalk.gray(` Worktree: ${entry.worktree}`));
74
+ }
75
+ if (entry.model) {
76
+ console.log(chalk.gray(` Model: ${entry.model}`));
77
+ }
78
+ console.log('');
79
+ }
80
+ }
81
+
82
+ function handleShow(options) {
83
+ const id = options.id;
84
+ if (!id) {
85
+ console.error(chalk.red('Session ID is required'));
86
+ console.log(chalk.blue('Usage: caws agents show <session-id>'));
87
+ process.exit(1);
88
+ }
89
+
90
+ const root = findProjectRoot();
91
+ const registry = loadAgentRegistry(root);
92
+ const entry = registry.agents[id];
93
+ if (!entry) {
94
+ console.error(chalk.red(`No agent registered with session id: ${id}`));
95
+ console.log(
96
+ chalk.blue('Run `caws agents list` to see active sessions, or `tmp/<id>/.meta.json` for archived ones.')
97
+ );
98
+ process.exit(1);
99
+ }
100
+
101
+ const ref = formatAgentRef(entry.sessionId, entry.platform);
102
+ console.log(chalk.bold.cyan(ref));
103
+ console.log(chalk.cyan('='.repeat(70)));
104
+ console.log(chalk.gray(`First seen: ${entry.firstSeen || 'unknown'}`));
105
+ console.log(
106
+ chalk.gray(`Last seen: ${entry.lastSeen || 'unknown'} (${formatHeartbeatAge(entry.lastSeen)})`)
107
+ );
108
+ if (entry.specId) console.log(chalk.gray(`Spec: ${entry.specId}`));
109
+ if (entry.worktree) console.log(chalk.gray(`Worktree: ${entry.worktree}`));
110
+ if (entry.model) console.log(chalk.gray(`Model: ${entry.model}`));
111
+ if (entry.ttl) console.log(chalk.gray(`TTL: ${Math.round(entry.ttl / 1000 / 60)} min`));
112
+
113
+ // Surface any session-log pointers for this id.
114
+ const logs = findSessionLogs(root, { sessionId: id });
115
+ if (logs.length > 0) {
116
+ console.log('');
117
+ console.log(chalk.bold('Session logs:'));
118
+ for (const log of logs) {
119
+ console.log(formatSessionLogPointer(log, root));
120
+ }
121
+ }
122
+ }
123
+
124
+ module.exports = { agentsCommand };
@@ -16,7 +16,7 @@ const { SPEC_TYPES } = require('../constants/spec-types');
16
16
  const { suggestFeatureBreakdown } = require('../utils/spec-resolver');
17
17
  const { findProjectRoot } = require('../utils/detection');
18
18
  const { loadRegistry: loadWorktreeRegistry, getRepoRoot } = require('../worktree/worktree-manager');
19
- const { getAgentSessionId } = require('../utils/agent-session');
19
+ const { getAgentSessionId, refreshAgentClaim } = require('../utils/agent-session');
20
20
  const { initializeState, saveState, deleteState } = require('../utils/working-state');
21
21
  const { appendEvent } = require('../utils/event-log');
22
22
 
@@ -224,6 +224,17 @@ function normalizeSpecForValidation(spec = {}) {
224
224
  * List all spec files in the specs directory
225
225
  * @returns {Promise<Array>} Array of spec file info
226
226
  */
227
+ // Files under this subdir of `.caws/specs/` are treated as archived,
228
+ // regardless of what the YAML's `status` field says (CAWSFIX-29 invariant:
229
+ // directory location is authoritative for archive state).
230
+ const ARCHIVE_SUBDIR = '.archive';
231
+
232
+ function isArchivePath(relPath) {
233
+ if (!relPath) return false;
234
+ const normalized = relPath.replace(/\\/g, '/');
235
+ return normalized === ARCHIVE_SUBDIR || normalized.startsWith(`${ARCHIVE_SUBDIR}/`);
236
+ }
237
+
227
238
  async function listSpecFiles() {
228
239
  const specsDir = getSpecsDir();
229
240
  if (!(await fs.pathExists(specsDir))) {
@@ -239,13 +250,14 @@ async function listSpecFiles() {
239
250
  try {
240
251
  const content = await fs.readFile(filePath, 'utf8');
241
252
  const spec = yaml.load(content);
253
+ const inArchive = isArchivePath(file);
242
254
 
243
255
  specs.push({
244
256
  id: spec.id || path.basename(file, path.extname(file)),
245
257
  path: file,
246
258
  type: spec.type || 'feature',
247
259
  title: spec.title || 'Untitled',
248
- status: spec.status || 'draft',
260
+ status: inArchive ? 'archived' : spec.status || 'draft',
249
261
  risk_tier: spec.risk_tier || 'T3',
250
262
  mode: spec.mode || 'development',
251
263
  created_at: spec.created_at || new Date().toISOString(),
@@ -289,6 +301,40 @@ async function createSpec(id, options = {}) {
289
301
  const existingSpecPath = path.join(specsDir, `${id}.yaml`);
290
302
  const specExists = await fs.pathExists(existingSpecPath);
291
303
 
304
+ // CAWSFIX-30: archive-collision guard. An id that lives in `.archive/`
305
+ // is still a taken id — surface it before going further. Detection is
306
+ // filesystem-driven (not registry-driven) so manually-moved legacy
307
+ // specs are also caught.
308
+ const archivedSpecPath = path.join(specsDir, ARCHIVE_SUBDIR, `${id}.yaml`);
309
+ const archivedExists = !specExists && (await fs.pathExists(archivedSpecPath));
310
+ if (archivedExists && !force) {
311
+ console.error(
312
+ chalk.red(
313
+ `Spec '${id}' already exists in archive: ${path.relative(findProjectRoot(), archivedSpecPath)}`
314
+ )
315
+ );
316
+ console.error(
317
+ chalk.yellow(
318
+ `Use --force to remove the archived copy and resurrect the id, ` +
319
+ `or pick a different id.`
320
+ )
321
+ );
322
+ throw new Error(
323
+ `Spec '${id}' collides with archived spec at .archive/${id}.yaml. ` +
324
+ `Use --force to resurrect or choose another id.`
325
+ );
326
+ }
327
+ if (archivedExists && force) {
328
+ // Resurrection: drop the archived YAML and any registry pointer so
329
+ // the rest of createSpec can write a fresh draft cleanly.
330
+ await fs.remove(archivedSpecPath);
331
+ const registry = await loadSpecsRegistry();
332
+ if (registry.specs[id]) {
333
+ delete registry.specs[id];
334
+ await saveSpecsRegistry(registry);
335
+ }
336
+ }
337
+
292
338
  // Handle conflict resolution
293
339
  let answer = null;
294
340
 
@@ -544,10 +590,14 @@ async function createSpec(id, options = {}) {
544
590
  );
545
591
  } catch (err) {
546
592
  // Surface on stderr but don't propagate — the spec is already created.
547
-
593
+
548
594
  console.error(`event-log: failed to record spec_created for ${id}: ${err.message}`);
549
595
  }
550
596
 
597
+ // CAWSFIX-31: refresh the current agent's claim so agents.json reflects
598
+ // the current spec context. Best-effort — failures must not break the op.
599
+ refreshAgentClaim(findProjectRoot(), { specId: id });
600
+
551
601
  return {
552
602
  id,
553
603
  path: fileName,
@@ -818,10 +868,14 @@ async function deleteSpec(id) {
818
868
  { projectRoot: findProjectRoot() }
819
869
  );
820
870
  } catch (err) {
821
-
871
+
822
872
  console.error(`event-log: failed to record spec_deleted for ${id}: ${err.message}`);
823
873
  }
824
874
 
875
+ // CAWSFIX-31: every lifecycle verb refreshes for consistency, even
876
+ // delete (no other cleanup runs on delete; this is signal-of-presence).
877
+ refreshAgentClaim(findProjectRoot(), { specId: id });
878
+
825
879
  return true;
826
880
  }
827
881
 
@@ -911,14 +965,143 @@ async function closeSpec(id) {
911
965
  { projectRoot: findProjectRoot() }
912
966
  );
913
967
  } catch (err) {
914
-
968
+
915
969
  console.error(`event-log: failed to record spec_closed for ${id}: ${err.message}`);
916
970
  }
971
+
972
+ // CAWSFIX-31: refresh agent claim — also fires on no-op closes
973
+ // (already-closed specs return ok=true after an early exit) only
974
+ // when the status actually flipped, since this is the signal that
975
+ // matters: the agent is currently working on this spec.
976
+ refreshAgentClaim(findProjectRoot(), { specId: id });
917
977
  }
918
978
 
919
979
  return ok;
920
980
  }
921
981
 
982
+ /**
983
+ * Archive a spec: move its YAML to `.caws/specs/.archive/<id>.yaml`,
984
+ * flip status to `archived`, update the registry, and emit a `spec_archived`
985
+ * event. The archive directory is the canonical truth for archive state —
986
+ * the listing layer (listSpecFiles) treats any file under `.archive/` as
987
+ * archived regardless of the YAML literal.
988
+ *
989
+ * @param {string} id - Spec identifier
990
+ * @returns {Promise<boolean>} true on success (including idempotent no-ops),
991
+ * false on validation/lookup failure.
992
+ */
993
+ async function archiveSpec(id) {
994
+ // Path-traversal guard: ids must be plain filenames, not paths.
995
+ // Reject before touching any filesystem state.
996
+ if (!id || typeof id !== 'string' || path.basename(id) !== id || id.includes('..')) {
997
+ console.error(chalk.red(`Invalid spec id '${id}': must be a plain identifier`));
998
+ return false;
999
+ }
1000
+
1001
+ const registry = await loadSpecsRegistry();
1002
+ const entry = registry.specs[id];
1003
+ if (!entry) {
1004
+ console.error(chalk.red(`Spec '${id}' not found`));
1005
+ return false;
1006
+ }
1007
+
1008
+ // Block if owned by another session (mirror closeSpec/deleteSpec).
1009
+ const currentSession = getAgentSessionId(findProjectRoot());
1010
+ if (entry.owner && currentSession && entry.owner !== currentSession) {
1011
+ console.error(
1012
+ chalk.red(
1013
+ `Cannot archive spec '${id}': owned by another session (${entry.owner}). ` +
1014
+ `Only the creator session can archive a spec.`
1015
+ )
1016
+ );
1017
+ return false;
1018
+ }
1019
+
1020
+ // Block if active worktrees still reference the spec — archiving removes
1021
+ // scope enforcement and would invalidate in-flight work.
1022
+ const referencingWorktrees = getWorktreesReferencingSpec(id);
1023
+ if (referencingWorktrees.length > 0) {
1024
+ const names = referencingWorktrees.join(', ');
1025
+ console.error(
1026
+ chalk.red(
1027
+ `Cannot archive spec '${id}': active worktree(s) [${names}] reference it. ` +
1028
+ `Destroy the worktree(s) first with 'caws worktree destroy <name>'.`
1029
+ )
1030
+ );
1031
+ return false;
1032
+ }
1033
+
1034
+ const specsDir = getSpecsDir();
1035
+ const priorPath = entry.path;
1036
+ const currentSpecPath = path.join(specsDir, priorPath);
1037
+
1038
+ // If the file is already in the archive directory, the canonical
1039
+ // location is satisfied — just ensure the registry status agrees and exit.
1040
+ if (isArchivePath(priorPath)) {
1041
+ if (entry.status !== 'archived') {
1042
+ registry.specs[id] = { ...entry, status: 'archived' };
1043
+ await saveSpecsRegistry(registry);
1044
+ }
1045
+ console.log(chalk.yellow(`Spec '${id}' is already archived.`));
1046
+ return true;
1047
+ }
1048
+
1049
+ if (!(await fs.pathExists(currentSpecPath))) {
1050
+ console.error(
1051
+ chalk.red(`Cannot archive spec '${id}': file missing at ${currentSpecPath}`)
1052
+ );
1053
+ return false;
1054
+ }
1055
+
1056
+ // CAWSFIX-15-style targeted rewrite: only `status:` and `updated_at:`
1057
+ // lines move. Comments, ordering, and YAML aliases survive untouched.
1058
+ const original = await fs.readFile(currentSpecPath, 'utf8');
1059
+ const priorStatus = entry.status || 'draft';
1060
+ const nowIso = new Date().toISOString();
1061
+ let patched = original.replace(/^status:\s*\S+\s*$/m, 'status: archived');
1062
+ patched = patched.replace(/^updated_at:.*$/m, `updated_at: '${nowIso}'`);
1063
+
1064
+ const archiveDir = path.join(specsDir, ARCHIVE_SUBDIR);
1065
+ await fs.ensureDir(archiveDir);
1066
+ const newRelPath = `${ARCHIVE_SUBDIR}/${id}.yaml`;
1067
+ const newAbsPath = path.join(specsDir, newRelPath);
1068
+
1069
+ // Write the patched content to the archive location, then remove the
1070
+ // original. fs-extra's writeFile is atomic-enough for single-file moves
1071
+ // on the same filesystem; we avoid `move` because we already mutated content.
1072
+ await fs.writeFile(newAbsPath, patched);
1073
+ await fs.remove(currentSpecPath);
1074
+
1075
+ registry.specs[id] = {
1076
+ ...entry,
1077
+ path: newRelPath,
1078
+ status: 'archived',
1079
+ updated_at: nowIso,
1080
+ };
1081
+ await saveSpecsRegistry(registry);
1082
+
1083
+ // Best-effort event emission, matching spec_closed/spec_deleted policy:
1084
+ // event-log failure does not roll back the archive operation.
1085
+ try {
1086
+ await appendEvent(
1087
+ {
1088
+ actor: 'cli',
1089
+ event: 'spec_archived',
1090
+ spec_id: id,
1091
+ data: { id, prior_status: priorStatus, prior_path: priorPath },
1092
+ },
1093
+ { projectRoot: findProjectRoot() }
1094
+ );
1095
+ } catch (err) {
1096
+ console.error(`event-log: failed to record spec_archived for ${id}: ${err.message}`);
1097
+ }
1098
+
1099
+ // CAWSFIX-31: refresh agent claim after a successful archive transition.
1100
+ refreshAgentClaim(findProjectRoot(), { specId: id });
1101
+
1102
+ return true;
1103
+ }
1104
+
922
1105
  /**
923
1106
  * Display specs in a formatted table
924
1107
  * @param {Array} specs - Array of spec objects
@@ -1402,6 +1585,28 @@ async function specsCommand(action, options = {}) {
1402
1585
  });
1403
1586
  }
1404
1587
 
1588
+ case 'archive': {
1589
+ if (!options.id) {
1590
+ throw new Error('Spec ID is required. Usage: caws specs archive <id>');
1591
+ }
1592
+
1593
+ const archived = await archiveSpec(options.id);
1594
+ if (!archived) {
1595
+ throw new Error(`Could not archive spec '${options.id}'`);
1596
+ }
1597
+
1598
+ console.log(
1599
+ chalk.green(
1600
+ `Archived spec: ${options.id} -- moved to .caws/specs/.archive/`
1601
+ )
1602
+ );
1603
+
1604
+ return outputResult({
1605
+ command: 'specs archive',
1606
+ spec: options.id,
1607
+ });
1608
+ }
1609
+
1405
1610
  case 'types': {
1406
1611
  console.log(chalk.bold.cyan('\nAvailable Spec Types'));
1407
1612
  console.log(chalk.cyan('==============================================\n'));
@@ -1420,7 +1625,7 @@ async function specsCommand(action, options = {}) {
1420
1625
 
1421
1626
  default:
1422
1627
  throw new Error(
1423
- `Unknown specs action: ${action}. Use: list, create, show, update, delete, close, conflicts, migrate, types`
1628
+ `Unknown specs action: ${action}. Use: list, create, show, update, delete, close, archive, conflicts, migrate, types`
1424
1629
  );
1425
1630
  }
1426
1631
  },
@@ -1439,10 +1644,13 @@ module.exports = {
1439
1644
  updateSpec,
1440
1645
  deleteSpec,
1441
1646
  closeSpec,
1647
+ archiveSpec,
1442
1648
  displaySpecsTable,
1443
1649
  displaySpecDetails,
1444
1650
  askConflictResolution,
1651
+ isArchivePath,
1445
1652
  SPECS_DIR,
1446
1653
  SPECS_REGISTRY,
1654
+ ARCHIVE_SUBDIR,
1447
1655
  SPEC_TYPES,
1448
1656
  };
@@ -363,6 +363,27 @@ function displayStatus(data) {
363
363
  console.log(chalk.bold.cyan('\nCAWS Project Status'));
364
364
  console.log(chalk.cyan('==============================================\n'));
365
365
 
366
+ // CAWSFIX-31: Surface worktree claim if cwd is inside a worktree.
367
+ // Best-effort — failures (no .caws, no registry, etc.) are silent.
368
+ try {
369
+ const path = require('path');
370
+ const { findProjectRoot } = require('../utils/detection');
371
+ const { renderClaimPanel } = require('../utils/agent-display');
372
+ const root = findProjectRoot();
373
+ const cwd = process.cwd();
374
+ const worktreesBase = path.join(root, '.caws', 'worktrees');
375
+ if (cwd.startsWith(worktreesBase + path.sep)) {
376
+ const worktreeName = path.relative(worktreesBase, cwd).split(path.sep)[0];
377
+ const panel = renderClaimPanel(root, worktreeName);
378
+ if (panel) {
379
+ console.log(chalk.green(panel));
380
+ console.log('');
381
+ }
382
+ }
383
+ } catch {
384
+ // best-effort
385
+ }
386
+
366
387
  // Working Spec Status
367
388
  if (spec) {
368
389
  console.log(chalk.green('Working Spec'));
@@ -14,10 +14,12 @@ const {
14
14
  repairWorktrees,
15
15
  loadRegistry,
16
16
  saveRegistry,
17
+ assertWorktreeOwnership,
17
18
  getRepoRoot,
18
- findFeatureSpecPath,
19
+ findFeatureSpecPathFromCwd,
20
+ autoActivateBoundSpec,
19
21
  } = require('../worktree/worktree-manager');
20
- const { getAgentSessionId } = require('../utils/agent-session');
22
+ const { getAgentSessionId, refreshAgentClaim } = require('../utils/agent-session');
21
23
 
22
24
  /**
23
25
  * Handle worktree subcommands
@@ -41,9 +43,11 @@ async function worktreeCommand(subcommand, options = {}) {
41
43
  return handleRepair(options);
42
44
  case 'bind':
43
45
  return handleBind(options);
46
+ case 'claim':
47
+ return handleClaim(options);
44
48
  default:
45
49
  console.error(chalk.red(`Unknown worktree subcommand: ${subcommand}`));
46
- console.log(chalk.blue('Available: create, list, destroy, merge, prune, repair, bind'));
50
+ console.log(chalk.blue('Available: create, list, destroy, merge, prune, repair, bind, claim'));
47
51
  process.exit(1);
48
52
  }
49
53
  } catch (error) {
@@ -175,7 +179,7 @@ function handleDestroy(options) {
175
179
  }
176
180
 
177
181
  function handleMerge(options) {
178
- const { name, dryRun, deleteBranch = true, message } = options;
182
+ const { name, dryRun, deleteBranch = true, message, takeover = false } = options;
179
183
 
180
184
  if (!name) {
181
185
  console.error(chalk.red('Worktree name is required'));
@@ -193,7 +197,7 @@ function handleMerge(options) {
193
197
  console.log(chalk.cyan(`Merging worktree: ${name}`));
194
198
  }
195
199
 
196
- const result = mergeWorktree(name, { dryRun, deleteBranch, message });
200
+ const result = mergeWorktree(name, { dryRun, deleteBranch, message, takeover });
197
201
 
198
202
  if (dryRun) {
199
203
  if (result.conflicts.length > 0) {
@@ -313,11 +317,11 @@ function handleBind(options) {
313
317
  const path = require('path');
314
318
  const fs = require('fs-extra');
315
319
  const yaml = require('js-yaml');
316
- const { specId, name } = options;
320
+ const { specId, name, takeover } = options;
317
321
 
318
322
  if (!specId) {
319
323
  console.error(chalk.red('Spec ID is required'));
320
- console.log(chalk.blue('Usage: caws worktree bind <spec-id> [--name <worktree-name>]'));
324
+ console.log(chalk.blue('Usage: caws worktree bind <spec-id> [--name <worktree-name>] [--takeover]'));
321
325
  process.exit(1);
322
326
  }
323
327
 
@@ -341,19 +345,41 @@ function handleBind(options) {
341
345
  }
342
346
 
343
347
  const root = getRepoRoot();
344
- const registry = loadRegistry(root);
345
-
346
- // Find the worktree entry in the registry
347
- if (!registry.worktrees || !registry.worktrees[worktreeName]) {
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]) {
348
354
  console.error(chalk.red(`Worktree '${worktreeName}' not found in registry.`));
349
355
  console.log(chalk.blue('Run: caws worktree list to see available worktrees'));
350
356
  process.exit(1);
357
+ return;
351
358
  }
352
359
 
353
- // Load the spec file
354
- const specPath = findFeatureSpecPath(root, specId);
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());
355
381
  if (!specPath) {
356
- console.error(chalk.red(`Spec '${specId}' not found in .caws/specs/`));
382
+ console.error(chalk.red(`Spec '${specId}' not found in .caws/specs/ (checked worktree-local first, then main)`));
357
383
  console.log(chalk.blue('Run: caws specs list to see available specs'));
358
384
  process.exit(1);
359
385
  }
@@ -371,16 +397,106 @@ function handleBind(options) {
371
397
  registry.worktrees[worktreeName].specId = specId;
372
398
  saveRegistry(root, registry);
373
399
 
374
- // Update spec side: set worktree field
375
- specData.worktree = worktreeName;
376
- const updatedYaml = yaml.dump(specData, { lineWidth: 120, noRefs: true });
377
- fs.writeFileSync(specPath, updatedYaml, 'utf8');
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 });
378
419
 
379
420
  console.log(chalk.green(`Binding established`));
380
421
  console.log(chalk.gray(` Worktree: ${worktreeName} -> spec: ${specId}`));
381
422
  console.log(chalk.gray(` Spec: ${specId} -> worktree: ${worktreeName}`));
423
+ if (activated) {
424
+ console.log(chalk.gray(` Status: draft -> active`));
425
+ }
382
426
  console.log(chalk.gray(` Registry: ${path.join(root, '.caws', 'worktrees.json')}`));
383
427
  console.log(chalk.gray(` Spec file: ${specPath}`));
384
428
  }
385
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
+
386
502
  module.exports = { worktreeCommand };