@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.
- package/dist/commands/agents.js +124 -0
- package/dist/commands/specs.js +214 -6
- package/dist/commands/status.js +21 -0
- package/dist/commands/worktree.js +134 -18
- package/dist/gates/scope-boundary.js +26 -7
- package/dist/index.js +29 -0
- package/dist/policy/PolicyManager.js +5 -0
- package/dist/templates/.caws/schemas/policy.schema.json +5 -0
- package/dist/templates/.caws/schemas/working-spec.schema.json +2 -2
- package/dist/templates/.claude/rules/worktree-isolation.md +21 -3
- package/dist/templates/CLAUDE.md +22 -0
- package/dist/templates/agents.md +26 -0
- package/dist/utils/agent-display.js +210 -0
- package/dist/utils/agent-session.js +142 -0
- package/dist/validation/spec-validation.js +7 -4
- package/dist/worktree/worktree-manager.js +407 -46
- package/package.json +1 -1
- package/templates/.caws/schemas/policy.schema.json +5 -0
- package/templates/.caws/schemas/working-spec.schema.json +2 -2
- package/templates/.claude/rules/worktree-isolation.md +21 -3
- package/templates/CLAUDE.md +22 -0
- package/templates/agents.md +26 -0
|
@@ -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 };
|
package/dist/commands/specs.js
CHANGED
|
@@ -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
|
};
|
package/dist/commands/status.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
//
|
|
347
|
-
|
|
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
|
-
//
|
|
354
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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 };
|