@paths.design/caws-cli 9.3.2 → 10.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -32
- package/dist/budget-derivation.js +221 -74
- package/dist/commands/archive.js +67 -28
- package/dist/commands/burnup.js +20 -11
- package/dist/commands/diagnose.js +34 -22
- package/dist/commands/evaluate.js +41 -15
- package/dist/commands/gates.js +149 -0
- package/dist/commands/init.js +150 -19
- package/dist/commands/iterate.js +81 -4
- package/dist/commands/parallel.js +4 -0
- package/dist/commands/plan.js +9 -19
- package/dist/commands/provenance.js +53 -17
- package/dist/commands/quality-monitor.js +64 -45
- package/dist/commands/scope.js +264 -0
- package/dist/commands/sidecar.js +74 -0
- package/dist/commands/specs.js +381 -45
- package/dist/commands/status.js +117 -9
- package/dist/commands/templates.js +0 -8
- package/dist/commands/tutorial.js +10 -9
- package/dist/commands/validate.js +70 -6
- package/dist/commands/verify-acs.js +48 -76
- package/dist/commands/waivers.js +212 -13
- package/dist/commands/worktree.js +131 -26
- package/dist/error-handler.js +2 -13
- package/dist/gates/budget-limit.js +121 -0
- package/dist/gates/feedback.js +260 -0
- package/dist/gates/format.js +179 -0
- package/dist/gates/god-object.js +117 -0
- package/dist/gates/pipeline.js +167 -0
- package/dist/gates/scope-boundary.js +93 -0
- package/dist/gates/spec-completeness.js +109 -0
- package/dist/gates/todo-detection.js +205 -0
- package/dist/index.js +157 -151
- package/dist/parallel/parallel-manager.js +3 -3
- package/dist/policy/PolicyManager.js +51 -17
- package/dist/scaffold/claude-hooks.js +24 -1
- package/dist/scaffold/git-hooks.js +45 -102
- package/dist/scaffold/index.js +4 -3
- package/dist/session/session-manager.js +105 -14
- package/dist/sidecars/index.js +33 -0
- package/dist/sidecars/listeners.js +40 -0
- package/dist/sidecars/provenance-summary.js +238 -0
- package/dist/sidecars/quality-gaps.js +258 -0
- package/dist/sidecars/schema.js +149 -0
- package/dist/sidecars/spec-drift.js +151 -0
- package/dist/sidecars/waiver-draft.js +176 -0
- package/dist/templates/.caws/schemas/policy.schema.json +112 -0
- package/dist/templates/.caws/schemas/scope.schema.json +3 -3
- package/dist/templates/.caws/schemas/waivers.schema.json +96 -20
- package/dist/templates/.caws/schemas/working-spec.schema.json +264 -57
- package/dist/templates/.caws/schemas/worktrees.schema.json +3 -1
- package/dist/templates/.caws/templates/working-spec.template.yml +10 -4
- package/dist/templates/.caws/tools/scope-guard.js +66 -15
- package/dist/templates/.claude/README.md +1 -1
- package/dist/templates/.claude/hooks/audit.sh +0 -0
- package/dist/templates/.claude/hooks/block-dangerous.sh +52 -11
- package/dist/templates/.claude/hooks/classify_command.py +592 -0
- package/dist/templates/.claude/hooks/doc-frontmatter-check.sh +173 -0
- package/dist/templates/.claude/hooks/protected-paths.sh +39 -0
- package/dist/templates/.claude/hooks/quality-check.sh +23 -10
- package/dist/templates/.claude/hooks/scope-guard.sh +136 -55
- package/dist/templates/.claude/hooks/session-caws-status.sh +2 -2
- package/dist/templates/.claude/hooks/session-log.sh +76 -3
- package/dist/templates/.claude/hooks/stop-worktree-check.sh +1 -1
- package/dist/templates/.claude/hooks/test_classify_command.py +370 -0
- package/dist/templates/.claude/hooks/test_wrapper_smoke.sh +96 -0
- package/dist/templates/.claude/hooks/worktree-guard.sh +2 -2
- package/dist/templates/.claude/hooks/worktree-write-guard.sh +97 -4
- package/dist/templates/.claude/settings.json +31 -0
- package/dist/templates/.cursor/hooks/caws-quality-check.sh +4 -4
- package/dist/templates/.cursor/hooks/caws-scope-guard.sh +1 -1
- package/dist/templates/.cursor/hooks/session-log.sh +924 -0
- package/dist/templates/.cursor/hooks.json +25 -0
- package/dist/templates/.cursor/rules/02-quality-gates.mdc +3 -5
- package/dist/templates/.cursor/rules/10-documentation-quality-standards.mdc +6 -11
- package/dist/templates/.cursor/rules/11-scope-management-waivers.mdc +14 -18
- package/dist/templates/.cursor/rules/12-implementation-completeness.mdc +4 -4
- package/dist/templates/.cursor/rules/13-language-agnostic-standards.mdc +3 -13
- package/dist/templates/.github/copilot-instructions.md +5 -5
- package/dist/templates/.idea/runConfigurations/CAWS_Evaluate.xml +1 -1
- package/dist/templates/.junie/guidelines.md +2 -2
- package/dist/templates/.vscode/settings.json +3 -1
- package/dist/templates/.windsurf/rules/caws-quality-standards.md +2 -2
- package/dist/templates/.windsurf/workflows/caws-guided-development.md +3 -3
- package/dist/templates/CLAUDE.md +77 -8
- package/dist/templates/agents.md +50 -9
- package/dist/templates/docs/README.md +8 -7
- package/dist/templates/scripts/new_feature.sh +80 -0
- package/dist/test-analysis.js +43 -30
- package/dist/tool-loader.js +1 -1
- package/dist/utils/agent-session.js +202 -0
- package/dist/utils/detection.js +8 -2
- package/dist/utils/event-log.js +584 -0
- package/dist/utils/event-renderer.js +521 -0
- package/dist/utils/finalization.js +7 -6
- package/dist/utils/gitignore-updater.js +3 -0
- package/dist/utils/lifecycle-events.js +94 -0
- package/dist/utils/quality-gates-utils.js +29 -44
- package/dist/utils/schema-validator.js +50 -0
- package/dist/utils/spec-resolver.js +93 -21
- package/dist/utils/working-state.js +530 -0
- package/dist/validation/spec-validation.js +191 -31
- package/dist/waivers-manager.js +144 -6
- package/dist/worktree/worktree-manager.js +598 -95
- package/package.json +9 -8
- package/templates/.caws/schemas/policy.schema.json +112 -0
- package/templates/.caws/schemas/scope.schema.json +3 -3
- package/templates/.caws/schemas/waivers.schema.json +96 -20
- package/templates/.caws/schemas/working-spec.schema.json +264 -57
- package/templates/.caws/schemas/worktrees.schema.json +3 -1
- package/templates/.caws/templates/working-spec.template.yml +10 -4
- package/templates/.caws/tools/scope-guard.js +66 -15
- package/templates/.claude/README.md +1 -1
- package/templates/.claude/hooks/block-dangerous.sh +52 -11
- package/templates/.claude/hooks/classify_command.py +592 -0
- package/templates/.claude/hooks/doc-frontmatter-check.sh +173 -0
- package/templates/.claude/hooks/protected-paths.sh +39 -0
- package/templates/.claude/hooks/quality-check.sh +23 -10
- package/templates/.claude/hooks/scope-guard.sh +136 -55
- package/templates/.claude/hooks/session-caws-status.sh +2 -2
- package/templates/.claude/hooks/session-log.sh +76 -3
- package/templates/.claude/hooks/stop-worktree-check.sh +1 -1
- package/templates/.claude/hooks/test_classify_command.py +370 -0
- package/templates/.claude/hooks/test_wrapper_smoke.sh +96 -0
- package/templates/.claude/hooks/worktree-guard.sh +2 -2
- package/templates/.claude/hooks/worktree-write-guard.sh +97 -4
- package/templates/.claude/settings.json +31 -0
- package/templates/.cursor/hooks/caws-quality-check.sh +4 -4
- package/templates/.cursor/hooks/caws-scope-guard.sh +1 -1
- package/templates/.cursor/hooks/session-log.sh +924 -0
- package/templates/.cursor/hooks.json +25 -0
- package/templates/.cursor/rules/02-quality-gates.mdc +3 -5
- package/templates/.cursor/rules/10-documentation-quality-standards.mdc +6 -11
- package/templates/.cursor/rules/11-scope-management-waivers.mdc +14 -18
- package/templates/.cursor/rules/12-implementation-completeness.mdc +4 -4
- package/templates/.cursor/rules/13-language-agnostic-standards.mdc +3 -13
- package/templates/.github/copilot-instructions.md +5 -5
- package/templates/.idea/runConfigurations/CAWS_Evaluate.xml +1 -1
- package/templates/.junie/guidelines.md +2 -2
- package/templates/.vscode/settings.json +3 -1
- package/templates/.windsurf/rules/caws-quality-standards.md +2 -2
- package/templates/.windsurf/workflows/caws-guided-development.md +3 -3
- package/templates/CLAUDE.md +77 -8
- package/templates/{AGENTS.md → agents.md} +50 -9
- package/templates/docs/README.md +8 -7
- package/templates/scripts/new_feature.sh +80 -0
- package/dist/budget-derivation.d.ts +0 -74
- package/dist/budget-derivation.d.ts.map +0 -1
- package/dist/cicd-optimizer.d.ts +0 -142
- package/dist/cicd-optimizer.d.ts.map +0 -1
- package/dist/commands/archive.d.ts +0 -51
- package/dist/commands/archive.d.ts.map +0 -1
- package/dist/commands/burnup.d.ts +0 -6
- package/dist/commands/burnup.d.ts.map +0 -1
- package/dist/commands/diagnose.d.ts +0 -52
- package/dist/commands/diagnose.d.ts.map +0 -1
- package/dist/commands/evaluate.d.ts +0 -8
- package/dist/commands/evaluate.d.ts.map +0 -1
- package/dist/commands/init.d.ts +0 -5
- package/dist/commands/init.d.ts.map +0 -1
- package/dist/commands/iterate.d.ts +0 -8
- package/dist/commands/iterate.d.ts.map +0 -1
- package/dist/commands/mode.d.ts +0 -25
- package/dist/commands/mode.d.ts.map +0 -1
- package/dist/commands/parallel.d.ts +0 -7
- package/dist/commands/parallel.d.ts.map +0 -1
- package/dist/commands/plan.d.ts +0 -49
- package/dist/commands/plan.d.ts.map +0 -1
- package/dist/commands/provenance.d.ts +0 -32
- package/dist/commands/provenance.d.ts.map +0 -1
- package/dist/commands/quality-gates.d.ts +0 -6
- package/dist/commands/quality-gates.d.ts.map +0 -1
- package/dist/commands/quality-gates.js +0 -444
- package/dist/commands/quality-monitor.d.ts +0 -17
- package/dist/commands/quality-monitor.d.ts.map +0 -1
- package/dist/commands/session.d.ts +0 -7
- package/dist/commands/session.d.ts.map +0 -1
- package/dist/commands/specs.d.ts +0 -77
- package/dist/commands/specs.d.ts.map +0 -1
- package/dist/commands/status.d.ts +0 -44
- package/dist/commands/status.d.ts.map +0 -1
- package/dist/commands/templates.d.ts +0 -74
- package/dist/commands/templates.d.ts.map +0 -1
- package/dist/commands/tool.d.ts +0 -13
- package/dist/commands/tool.d.ts.map +0 -1
- package/dist/commands/troubleshoot.d.ts +0 -8
- package/dist/commands/troubleshoot.d.ts.map +0 -1
- package/dist/commands/troubleshoot.js +0 -104
- package/dist/commands/tutorial.d.ts +0 -55
- package/dist/commands/tutorial.d.ts.map +0 -1
- package/dist/commands/validate.d.ts +0 -15
- package/dist/commands/validate.d.ts.map +0 -1
- package/dist/commands/waivers.d.ts +0 -8
- package/dist/commands/waivers.d.ts.map +0 -1
- package/dist/commands/workflow.d.ts +0 -85
- package/dist/commands/workflow.d.ts.map +0 -1
- package/dist/commands/worktree.d.ts +0 -7
- package/dist/commands/worktree.d.ts.map +0 -1
- package/dist/config/index.d.ts +0 -29
- package/dist/config/index.d.ts.map +0 -1
- package/dist/config/lite-scope.d.ts +0 -33
- package/dist/config/lite-scope.d.ts.map +0 -1
- package/dist/config/modes.d.ts +0 -264
- package/dist/config/modes.d.ts.map +0 -1
- package/dist/constants/spec-types.d.ts +0 -93
- package/dist/constants/spec-types.d.ts.map +0 -1
- package/dist/error-handler.d.ts +0 -151
- package/dist/error-handler.d.ts.map +0 -1
- package/dist/generators/jest-config-generator.d.ts +0 -32
- package/dist/generators/jest-config-generator.d.ts.map +0 -1
- package/dist/generators/jest-config.d.ts +0 -32
- package/dist/generators/jest-config.d.ts.map +0 -1
- package/dist/generators/jest-config.js +0 -242
- package/dist/generators/working-spec.d.ts +0 -13
- package/dist/generators/working-spec.d.ts.map +0 -1
- package/dist/index-new.d.ts +0 -5
- package/dist/index-new.d.ts.map +0 -1
- package/dist/index-new.js +0 -317
- package/dist/index.d.ts +0 -5
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.backup +0 -4711
- package/dist/minimal-cli.d.ts +0 -3
- package/dist/minimal-cli.d.ts.map +0 -1
- package/dist/parallel/parallel-manager.d.ts +0 -67
- package/dist/parallel/parallel-manager.d.ts.map +0 -1
- package/dist/policy/PolicyManager.d.ts +0 -104
- package/dist/policy/PolicyManager.d.ts.map +0 -1
- package/dist/scaffold/claude-hooks.d.ts +0 -28
- package/dist/scaffold/claude-hooks.d.ts.map +0 -1
- package/dist/scaffold/cursor-hooks.d.ts +0 -7
- package/dist/scaffold/cursor-hooks.d.ts.map +0 -1
- package/dist/scaffold/git-hooks.d.ts +0 -38
- package/dist/scaffold/git-hooks.d.ts.map +0 -1
- package/dist/scaffold/index.d.ts +0 -17
- package/dist/scaffold/index.d.ts.map +0 -1
- package/dist/session/session-manager.d.ts +0 -94
- package/dist/session/session-manager.d.ts.map +0 -1
- package/dist/spec/SpecFileManager.d.ts +0 -146
- package/dist/spec/SpecFileManager.d.ts.map +0 -1
- package/dist/templates/.cursor/hooks/caws-tool-validation.sh +0 -121
- package/dist/templates/.github/copilot/instructions.md +0 -311
- package/dist/test-analysis.d.ts +0 -231
- package/dist/test-analysis.d.ts.map +0 -1
- package/dist/tool-interface.d.ts +0 -236
- package/dist/tool-interface.d.ts.map +0 -1
- package/dist/tool-loader.d.ts +0 -77
- package/dist/tool-loader.d.ts.map +0 -1
- package/dist/tool-validator.d.ts +0 -72
- package/dist/tool-validator.d.ts.map +0 -1
- package/dist/utils/async-utils.d.ts +0 -73
- package/dist/utils/async-utils.d.ts.map +0 -1
- package/dist/utils/command-wrapper.d.ts +0 -66
- package/dist/utils/command-wrapper.d.ts.map +0 -1
- package/dist/utils/detection.d.ts +0 -14
- package/dist/utils/detection.d.ts.map +0 -1
- package/dist/utils/error-categories.d.ts +0 -52
- package/dist/utils/error-categories.d.ts.map +0 -1
- package/dist/utils/finalization.d.ts +0 -17
- package/dist/utils/finalization.d.ts.map +0 -1
- package/dist/utils/git-lock.d.ts +0 -13
- package/dist/utils/git-lock.d.ts.map +0 -1
- package/dist/utils/gitignore-updater.d.ts +0 -39
- package/dist/utils/gitignore-updater.d.ts.map +0 -1
- package/dist/utils/ide-detection.d.ts +0 -89
- package/dist/utils/ide-detection.d.ts.map +0 -1
- package/dist/utils/project-analysis.d.ts +0 -34
- package/dist/utils/project-analysis.d.ts.map +0 -1
- package/dist/utils/promise-utils.d.ts +0 -30
- package/dist/utils/promise-utils.d.ts.map +0 -1
- package/dist/utils/quality-gates-utils.d.ts +0 -49
- package/dist/utils/quality-gates-utils.d.ts.map +0 -1
- package/dist/utils/quality-gates.d.ts +0 -49
- package/dist/utils/quality-gates.d.ts.map +0 -1
- package/dist/utils/quality-gates.js +0 -402
- package/dist/utils/spec-resolver.d.ts +0 -80
- package/dist/utils/spec-resolver.d.ts.map +0 -1
- package/dist/utils/typescript-detector.d.ts +0 -66
- package/dist/utils/typescript-detector.d.ts.map +0 -1
- package/dist/utils/yaml-validation.d.ts +0 -32
- package/dist/utils/yaml-validation.d.ts.map +0 -1
- package/dist/validation/spec-validation.d.ts +0 -43
- package/dist/validation/spec-validation.d.ts.map +0 -1
- package/dist/waivers-manager.d.ts +0 -167
- package/dist/waivers-manager.d.ts.map +0 -1
- package/dist/worktree/worktree-manager.d.ts +0 -54
- package/dist/worktree/worktree-manager.d.ts.map +0 -1
|
@@ -8,11 +8,215 @@ const { execFileSync } = require('child_process');
|
|
|
8
8
|
const fs = require('fs-extra');
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const chalk = require('chalk');
|
|
11
|
+
const { createValidator, getSchemaPath } = require('../utils/schema-validator');
|
|
12
|
+
const { getAgentSessionId } = require('../utils/agent-session');
|
|
13
|
+
const { lifecycle, EVENTS } = require('../utils/lifecycle-events');
|
|
11
14
|
|
|
12
15
|
const WORKTREES_DIR = '.caws/worktrees';
|
|
13
16
|
const REGISTRY_FILE = '.caws/worktrees.json';
|
|
14
17
|
const BRANCH_PREFIX = 'caws/';
|
|
15
18
|
|
|
19
|
+
function findFeatureSpecPath(root, specId) {
|
|
20
|
+
if (!specId) return null;
|
|
21
|
+
|
|
22
|
+
const candidates = [
|
|
23
|
+
path.join(root, '.caws', 'specs', `${specId}.yaml`),
|
|
24
|
+
path.join(root, '.caws', 'specs', `${specId}.yml`),
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeSpecWithWorktree(filePath, worktreeName) {
|
|
31
|
+
const yaml = require('js-yaml');
|
|
32
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
33
|
+
const parsed = yaml.load(content);
|
|
34
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
35
|
+
return content;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
parsed.worktree = worktreeName;
|
|
39
|
+
return yaml.dump(parsed, { lineWidth: 120, noRefs: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hasPathChanges(root, relativePath) {
|
|
43
|
+
try {
|
|
44
|
+
const output = execFileSync(
|
|
45
|
+
'git',
|
|
46
|
+
['status', '--porcelain', '--', relativePath],
|
|
47
|
+
{ cwd: root, encoding: 'utf8', stdio: 'pipe' }
|
|
48
|
+
).trim();
|
|
49
|
+
return output.length > 0;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ensureCanonicalSpecCommitted(root, specPath, specId, worktreeName) {
|
|
56
|
+
const relativeSpecPath = path.relative(root, specPath);
|
|
57
|
+
const nextContent = writeSpecWithWorktree(specPath, worktreeName);
|
|
58
|
+
const currentContent = fs.readFileSync(specPath, 'utf8');
|
|
59
|
+
|
|
60
|
+
if (currentContent !== nextContent) {
|
|
61
|
+
fs.writeFileSync(specPath, nextContent);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!hasPathChanges(root, relativeSpecPath)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
execFileSync('git', ['add', '--', relativeSpecPath], {
|
|
69
|
+
cwd: root,
|
|
70
|
+
stdio: 'pipe',
|
|
71
|
+
});
|
|
72
|
+
execFileSync(
|
|
73
|
+
'git',
|
|
74
|
+
['commit', '-m', `chore(caws): bind spec ${specId} to worktree ${worktreeName}`, '--', relativeSpecPath],
|
|
75
|
+
{
|
|
76
|
+
cwd: root,
|
|
77
|
+
stdio: 'pipe',
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function materializeWorktreeSpec(root, cawsDest, specId, worktreeName, scope) {
|
|
84
|
+
if (!specId) return;
|
|
85
|
+
|
|
86
|
+
const canonicalSpecPath = findFeatureSpecPath(root, specId);
|
|
87
|
+
const workingSpecPath = path.join(cawsDest, 'working-spec.yaml');
|
|
88
|
+
|
|
89
|
+
if (!canonicalSpecPath) {
|
|
90
|
+
console.warn(
|
|
91
|
+
chalk.yellow(`Warning: spec '${specId}' not found in .caws/specs/ — generating default working spec for worktree`)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (canonicalSpecPath) {
|
|
96
|
+
const destSpecsDir = path.join(cawsDest, 'specs');
|
|
97
|
+
const destSpecPath = path.join(destSpecsDir, path.basename(canonicalSpecPath));
|
|
98
|
+
fs.ensureDirSync(destSpecsDir);
|
|
99
|
+
|
|
100
|
+
// Keep a canonical feature-spec copy inside the worktree and align
|
|
101
|
+
// working-spec.yaml to that exact content for legacy-compatible commands.
|
|
102
|
+
const specContent = writeSpecWithWorktree(canonicalSpecPath, worktreeName);
|
|
103
|
+
fs.writeFileSync(destSpecPath, specContent);
|
|
104
|
+
fs.writeFileSync(workingSpecPath, specContent);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const { generateWorkingSpec } = require('../generators/working-spec');
|
|
109
|
+
let specContent = generateWorkingSpec({
|
|
110
|
+
projectId: specId,
|
|
111
|
+
projectTitle: `Worktree: ${worktreeName}`,
|
|
112
|
+
projectDescription: `Isolated worktree for ${worktreeName}`,
|
|
113
|
+
riskTier: 3,
|
|
114
|
+
projectMode: 'feature',
|
|
115
|
+
scopeIn: scope || 'src/',
|
|
116
|
+
scopeOut: 'node_modules/, dist/, build/',
|
|
117
|
+
maxFiles: 25,
|
|
118
|
+
maxLoc: 1000,
|
|
119
|
+
blastModules: scope || 'src',
|
|
120
|
+
dataMigration: false,
|
|
121
|
+
rollbackSlo: '5m',
|
|
122
|
+
projectThreats: '',
|
|
123
|
+
projectInvariants: 'System maintains data consistency',
|
|
124
|
+
acceptanceCriteria: 'Given current state, when action occurs, then expected result',
|
|
125
|
+
a11yRequirements: 'keyboard',
|
|
126
|
+
perfBudget: 250,
|
|
127
|
+
securityRequirements: 'validation',
|
|
128
|
+
contractType: '',
|
|
129
|
+
contractPath: '',
|
|
130
|
+
observabilityLogs: '',
|
|
131
|
+
observabilityMetrics: '',
|
|
132
|
+
observabilityTraces: '',
|
|
133
|
+
migrationPlan: '',
|
|
134
|
+
rollbackPlan: '',
|
|
135
|
+
needsOverride: false,
|
|
136
|
+
isExperimental: false,
|
|
137
|
+
aiConfidence: 0.8,
|
|
138
|
+
uncertaintyAreas: '',
|
|
139
|
+
complexityFactors: '',
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const yaml = require('js-yaml');
|
|
144
|
+
const parsed = yaml.load(specContent);
|
|
145
|
+
if (parsed && typeof parsed === 'object') {
|
|
146
|
+
parsed.worktree = worktreeName;
|
|
147
|
+
specContent = yaml.dump(parsed, { lineWidth: 120, noRefs: true });
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// Keep generated spec content if augmentation fails.
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
fs.ensureDirSync(path.dirname(workingSpecPath));
|
|
154
|
+
fs.writeFileSync(workingSpecPath, specContent);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseSpecIdFromYamlFile(filePath) {
|
|
158
|
+
try {
|
|
159
|
+
const yaml = require('js-yaml');
|
|
160
|
+
const doc = yaml.load(fs.readFileSync(filePath, 'utf8'));
|
|
161
|
+
if (doc && typeof doc.id === 'string' && doc.id.trim()) {
|
|
162
|
+
return doc.id.trim();
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// Ignore malformed YAML during inference
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Scan .caws/specs/ for a spec that declares `worktree: <name>`.
|
|
172
|
+
* Returns the spec's id if found, null otherwise.
|
|
173
|
+
* This enables auto-binding: when a spec already names the worktree
|
|
174
|
+
* it expects, the registry entry gets the specId automatically.
|
|
175
|
+
* @param {string} root - Repository root
|
|
176
|
+
* @param {string} worktreeName - Worktree name to match
|
|
177
|
+
* @returns {string|null} Spec ID or null
|
|
178
|
+
*/
|
|
179
|
+
function findSpecByWorktreeName(root, worktreeName) {
|
|
180
|
+
const yaml = require('js-yaml');
|
|
181
|
+
const specsDir = path.join(root, '.caws', 'specs');
|
|
182
|
+
if (!fs.existsSync(specsDir)) return null;
|
|
183
|
+
|
|
184
|
+
const specFiles = fs.readdirSync(specsDir)
|
|
185
|
+
.filter((name) => name.endsWith('.yaml') || name.endsWith('.yml'));
|
|
186
|
+
|
|
187
|
+
for (const specFile of specFiles) {
|
|
188
|
+
try {
|
|
189
|
+
const doc = yaml.load(fs.readFileSync(path.join(specsDir, specFile), 'utf8'));
|
|
190
|
+
if (doc && doc.worktree === worktreeName && typeof doc.id === 'string') {
|
|
191
|
+
return doc.id.trim();
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// Skip malformed spec files
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function inferSpecIdForWorktree(worktreePath) {
|
|
201
|
+
if (!worktreePath) return null;
|
|
202
|
+
|
|
203
|
+
const specsDir = path.join(worktreePath, '.caws', 'specs');
|
|
204
|
+
if (fs.existsSync(specsDir)) {
|
|
205
|
+
const specFiles = fs.readdirSync(specsDir)
|
|
206
|
+
.filter((name) => name.endsWith('.yaml') || name.endsWith('.yml'))
|
|
207
|
+
.sort();
|
|
208
|
+
|
|
209
|
+
for (const specFile of specFiles) {
|
|
210
|
+
const inferred = parseSpecIdFromYamlFile(path.join(specsDir, specFile));
|
|
211
|
+
if (inferred) {
|
|
212
|
+
return inferred;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return parseSpecIdFromYamlFile(path.join(worktreePath, '.caws', 'working-spec.yaml'));
|
|
218
|
+
}
|
|
219
|
+
|
|
16
220
|
/**
|
|
17
221
|
* Get the last commit info for a branch
|
|
18
222
|
* @param {string} branch - Branch name
|
|
@@ -53,6 +257,44 @@ function isBranchMerged(branch, target, root) {
|
|
|
53
257
|
}
|
|
54
258
|
}
|
|
55
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Check if a branch has divergent commits from target (commits on branch not on target).
|
|
262
|
+
* @param {string} branch - Branch to check
|
|
263
|
+
* @param {string} target - Target branch (e.g., "main")
|
|
264
|
+
* @param {string} root - Repository root
|
|
265
|
+
* @returns {boolean}
|
|
266
|
+
*/
|
|
267
|
+
function hasDivergentCommits(branch, target, root) {
|
|
268
|
+
try {
|
|
269
|
+
const count = execFileSync(
|
|
270
|
+
'git',
|
|
271
|
+
['rev-list', '--count', `${target}..${branch}`],
|
|
272
|
+
{ cwd: root, encoding: 'utf8', stdio: 'pipe' }
|
|
273
|
+
).trim();
|
|
274
|
+
return parseInt(count, 10) > 0;
|
|
275
|
+
} catch {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Check if a worktree directory has dirty (uncommitted) files.
|
|
282
|
+
* @param {string} worktreePath - Path to the worktree
|
|
283
|
+
* @returns {boolean}
|
|
284
|
+
*/
|
|
285
|
+
function hasDirtyFiles(worktreePath) {
|
|
286
|
+
try {
|
|
287
|
+
const status = execFileSync(
|
|
288
|
+
'git',
|
|
289
|
+
['status', '--porcelain'],
|
|
290
|
+
{ cwd: worktreePath, encoding: 'utf8', stdio: 'pipe' }
|
|
291
|
+
).trim();
|
|
292
|
+
return status.length > 0;
|
|
293
|
+
} catch {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
56
298
|
/**
|
|
57
299
|
* Get the canonical git repository root (main worktree, not a linked worktree).
|
|
58
300
|
*
|
|
@@ -85,6 +327,11 @@ function getCurrentBranch() {
|
|
|
85
327
|
}).trim();
|
|
86
328
|
}
|
|
87
329
|
|
|
330
|
+
// Track whether we've already warned about schema violations this process.
|
|
331
|
+
// loadRegistry() is called multiple times per command; warning every time
|
|
332
|
+
// floods stderr and contributes to Claude Code context-window exhaustion.
|
|
333
|
+
let _schemaWarned = false;
|
|
334
|
+
|
|
88
335
|
/**
|
|
89
336
|
* Load the worktree registry
|
|
90
337
|
* @param {string} root - Repository root
|
|
@@ -94,7 +341,21 @@ function loadRegistry(root) {
|
|
|
94
341
|
const registryPath = path.join(root, REGISTRY_FILE);
|
|
95
342
|
try {
|
|
96
343
|
if (fs.existsSync(registryPath)) {
|
|
97
|
-
|
|
344
|
+
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
345
|
+
try {
|
|
346
|
+
const validate = createValidator(getSchemaPath('worktrees.schema.json', root));
|
|
347
|
+
const result = validate(data);
|
|
348
|
+
if (!result.valid && !_schemaWarned) {
|
|
349
|
+
_schemaWarned = true;
|
|
350
|
+
console.warn('Worktree registry has schema violations:', result.errors);
|
|
351
|
+
}
|
|
352
|
+
} catch (schemaErr) {
|
|
353
|
+
if (!_schemaWarned) {
|
|
354
|
+
_schemaWarned = true;
|
|
355
|
+
console.warn('Could not validate worktree registry schema:', schemaErr.message);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return data;
|
|
98
359
|
}
|
|
99
360
|
} catch {
|
|
100
361
|
// Corrupted registry, start fresh
|
|
@@ -108,6 +369,27 @@ function loadRegistry(root) {
|
|
|
108
369
|
* @param {Object} registry - Registry object
|
|
109
370
|
*/
|
|
110
371
|
function saveRegistry(root, registry) {
|
|
372
|
+
// Auto-prune destroyed entries whose branch and directory are both gone.
|
|
373
|
+
// This prevents the registry from accumulating ghost entries over time.
|
|
374
|
+
for (const [name, entry] of Object.entries(registry.worktrees || {})) {
|
|
375
|
+
if (entry.status !== 'destroyed') continue;
|
|
376
|
+
const dirGone = !fs.existsSync(entry.path);
|
|
377
|
+
let branchGone = true;
|
|
378
|
+
if (entry.branch) {
|
|
379
|
+
try {
|
|
380
|
+
execFileSync('git', ['rev-parse', '--verify', entry.branch], {
|
|
381
|
+
cwd: root, stdio: 'pipe',
|
|
382
|
+
});
|
|
383
|
+
branchGone = false;
|
|
384
|
+
} catch {
|
|
385
|
+
branchGone = true;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (dirGone && branchGone) {
|
|
389
|
+
delete registry.worktrees[name];
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
111
393
|
const registryPath = path.join(root, REGISTRY_FILE);
|
|
112
394
|
fs.ensureDirSync(path.dirname(registryPath));
|
|
113
395
|
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2));
|
|
@@ -201,7 +483,7 @@ function autoRegisterWorktree(root, registry, discovered) {
|
|
|
201
483
|
branch: discovered.branch,
|
|
202
484
|
baseBranch,
|
|
203
485
|
scope: null,
|
|
204
|
-
specId:
|
|
486
|
+
specId: inferSpecIdForWorktree(discovered.path),
|
|
205
487
|
owner: null,
|
|
206
488
|
createdAt: new Date().toISOString(),
|
|
207
489
|
status: 'active',
|
|
@@ -233,18 +515,72 @@ function createWorktree(name, options = {}) {
|
|
|
233
515
|
|
|
234
516
|
const registry = loadRegistry(root);
|
|
235
517
|
|
|
236
|
-
// Check for duplicate
|
|
518
|
+
// Check for duplicate in registry
|
|
237
519
|
if (registry.worktrees[name]) {
|
|
238
|
-
|
|
520
|
+
const existing = registry.worktrees[name];
|
|
521
|
+
if (existing.status !== 'destroyed') {
|
|
522
|
+
const ownerInfo = existing.owner ? ` (owned by session ${existing.owner})` : '';
|
|
523
|
+
throw new Error(
|
|
524
|
+
`Worktree '${name}' already exists with status '${existing.status}'${ownerInfo}.\n` +
|
|
525
|
+
`Use 'caws worktree destroy ${name}' first, or choose a different name.`
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
// Destroyed entries: check if another session owns the branch
|
|
529
|
+
if (existing.owner && existing.owner !== getAgentSessionId(root)) {
|
|
530
|
+
// Branch may still be in use by the owning session for merge
|
|
531
|
+
try {
|
|
532
|
+
const branchExists = execFileSync('git', ['rev-parse', '--verify', BRANCH_PREFIX + name], {
|
|
533
|
+
cwd: root, stdio: 'pipe',
|
|
534
|
+
}).toString().trim();
|
|
535
|
+
if (branchExists) {
|
|
536
|
+
throw new Error(
|
|
537
|
+
`Worktree '${name}' was destroyed but branch '${BRANCH_PREFIX}${name}' still exists ` +
|
|
538
|
+
`(owned by session ${existing.owner}).\n` +
|
|
539
|
+
`The owning session may still need this branch for merging.\n` +
|
|
540
|
+
`Choose a different name, or delete the branch first: git branch -d ${BRANCH_PREFIX}${name}`
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
} catch (e) {
|
|
544
|
+
if (e.message.includes('owned by session')) throw e;
|
|
545
|
+
// Branch doesn't exist — safe to reuse the name
|
|
546
|
+
}
|
|
547
|
+
}
|
|
239
548
|
}
|
|
240
549
|
|
|
241
550
|
const worktreePath = path.join(root, WORKTREES_DIR, name);
|
|
242
551
|
const branchName = BRANCH_PREFIX + name;
|
|
243
552
|
const base = baseBranch || getCurrentBranch();
|
|
553
|
+
const canonicalSpecPath = findFeatureSpecPath(root, specId);
|
|
554
|
+
|
|
555
|
+
// Check if the branch already exists in git (even if not in registry)
|
|
556
|
+
// This catches cases where another agent created the branch outside CAWS
|
|
557
|
+
try {
|
|
558
|
+
execFileSync('git', ['rev-parse', '--verify', branchName], {
|
|
559
|
+
cwd: root, stdio: 'pipe',
|
|
560
|
+
});
|
|
561
|
+
// Branch exists — refuse unless it's fully merged into base
|
|
562
|
+
const currentSession = getAgentSessionId(root);
|
|
563
|
+
const registryOwner = registry.worktrees[name]?.owner;
|
|
564
|
+
if (registryOwner && registryOwner !== currentSession) {
|
|
565
|
+
throw new Error(
|
|
566
|
+
`Branch '${branchName}' already exists and is owned by another session (${registryOwner}).\n` +
|
|
567
|
+
`Another agent may be using this branch. Choose a different worktree name.`
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
// Branch exists but no owner conflict — warn and reuse
|
|
571
|
+
console.warn(`Warning: Branch '${branchName}' already exists, reusing it.`);
|
|
572
|
+
} catch (e) {
|
|
573
|
+
if (e.message.includes('already exists and is owned')) throw e;
|
|
574
|
+
// Branch doesn't exist — this is the normal path
|
|
575
|
+
}
|
|
244
576
|
|
|
245
577
|
// Create the worktree directory
|
|
246
578
|
fs.ensureDirSync(path.dirname(worktreePath));
|
|
247
579
|
|
|
580
|
+
if (canonicalSpecPath) {
|
|
581
|
+
ensureCanonicalSpecCommitted(root, canonicalSpecPath, specId, name);
|
|
582
|
+
}
|
|
583
|
+
|
|
248
584
|
// Create git worktree with new branch
|
|
249
585
|
try {
|
|
250
586
|
execFileSync('git', ['worktree', 'add', '-b', branchName, worktreePath, base], {
|
|
@@ -252,7 +588,7 @@ function createWorktree(name, options = {}) {
|
|
|
252
588
|
stdio: 'pipe',
|
|
253
589
|
});
|
|
254
590
|
} catch (error) {
|
|
255
|
-
// Branch
|
|
591
|
+
// Branch already exists (caught above and allowed) — attach to it
|
|
256
592
|
if (error.message.includes('already exists')) {
|
|
257
593
|
execFileSync('git', ['worktree', 'add', worktreePath, branchName], {
|
|
258
594
|
cwd: root,
|
|
@@ -306,46 +642,27 @@ function createWorktree(name, options = {}) {
|
|
|
306
642
|
}
|
|
307
643
|
}
|
|
308
644
|
|
|
309
|
-
//
|
|
310
|
-
|
|
645
|
+
// Auto-bind specId: if no explicit --spec-id was passed, scan .caws/specs/
|
|
646
|
+
// for a spec that declares `worktree: <name>`. This establishes the mutual
|
|
647
|
+
// reference that the scope guard uses to treat one spec as authoritative.
|
|
648
|
+
let resolvedSpecId = specId || null;
|
|
649
|
+
if (!resolvedSpecId) {
|
|
650
|
+
resolvedSpecId = findSpecByWorktreeName(root, name);
|
|
651
|
+
if (resolvedSpecId) {
|
|
652
|
+
console.log(chalk.gray(` Auto-bound spec: ${resolvedSpecId}`));
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Materialize a worktree-local working spec. Prefer the canonical feature
|
|
657
|
+
// spec when it exists so isolated worktrees stay aligned with the main
|
|
658
|
+
// registry/resolver model.
|
|
659
|
+
if (resolvedSpecId) {
|
|
311
660
|
try {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
riskTier: 3,
|
|
318
|
-
projectMode: 'feature',
|
|
319
|
-
scopeIn: scope || 'src/',
|
|
320
|
-
scopeOut: 'node_modules/, dist/, build/',
|
|
321
|
-
maxFiles: 25,
|
|
322
|
-
maxLoc: 1000,
|
|
323
|
-
blastModules: scope || 'src',
|
|
324
|
-
dataMigration: false,
|
|
325
|
-
rollbackSlo: '5m',
|
|
326
|
-
projectThreats: '',
|
|
327
|
-
projectInvariants: 'System maintains data consistency',
|
|
328
|
-
acceptanceCriteria: 'Given current state, when action occurs, then expected result',
|
|
329
|
-
a11yRequirements: 'keyboard',
|
|
330
|
-
perfBudget: 250,
|
|
331
|
-
securityRequirements: 'validation',
|
|
332
|
-
contractType: '',
|
|
333
|
-
contractPath: '',
|
|
334
|
-
observabilityLogs: '',
|
|
335
|
-
observabilityMetrics: '',
|
|
336
|
-
observabilityTraces: '',
|
|
337
|
-
migrationPlan: '',
|
|
338
|
-
rollbackPlan: '',
|
|
339
|
-
needsOverride: false,
|
|
340
|
-
isExperimental: false,
|
|
341
|
-
aiConfidence: 0.8,
|
|
342
|
-
uncertaintyAreas: '',
|
|
343
|
-
complexityFactors: '',
|
|
344
|
-
});
|
|
345
|
-
const specPath = path.join(cawsDest, 'working-spec.yaml');
|
|
346
|
-
fs.ensureDirSync(path.dirname(specPath));
|
|
347
|
-
fs.writeFileSync(specPath, specContent);
|
|
348
|
-
} catch {
|
|
661
|
+
materializeWorktreeSpec(root, cawsDest, resolvedSpecId, name, scope);
|
|
662
|
+
} catch (error) {
|
|
663
|
+
console.warn(
|
|
664
|
+
chalk.yellow(`Could not materialize spec '${resolvedSpecId}' for worktree '${name}': ${error.message}`)
|
|
665
|
+
);
|
|
349
666
|
// Non-fatal: spec generation is optional
|
|
350
667
|
}
|
|
351
668
|
}
|
|
@@ -357,10 +674,10 @@ function createWorktree(name, options = {}) {
|
|
|
357
674
|
branch: branchName,
|
|
358
675
|
baseBranch: base,
|
|
359
676
|
scope: scope || null,
|
|
360
|
-
specId:
|
|
361
|
-
owner: options.owner ||
|
|
677
|
+
specId: resolvedSpecId,
|
|
678
|
+
owner: options.owner || getAgentSessionId(root) || null,
|
|
362
679
|
createdAt: new Date().toISOString(),
|
|
363
|
-
status: '
|
|
680
|
+
status: 'fresh',
|
|
364
681
|
};
|
|
365
682
|
|
|
366
683
|
registry.worktrees[name] = entry;
|
|
@@ -415,26 +732,44 @@ function reconcileRegistry(root) {
|
|
|
415
732
|
(wt) => path.resolve(wt) === path.resolve(entry.path)
|
|
416
733
|
);
|
|
417
734
|
|
|
735
|
+
const merged = entry.branch && entry.baseBranch
|
|
736
|
+
? isBranchMerged(entry.branch, entry.baseBranch, root)
|
|
737
|
+
: false;
|
|
738
|
+
const divergent = entry.branch && entry.baseBranch
|
|
739
|
+
? hasDivergentCommits(entry.branch, entry.baseBranch, root)
|
|
740
|
+
: false;
|
|
741
|
+
const dirty = exists ? hasDirtyFiles(entry.path) : false;
|
|
742
|
+
|
|
418
743
|
let status;
|
|
419
744
|
if (entry.status === 'destroyed') {
|
|
420
745
|
status = 'destroyed';
|
|
421
746
|
} else if (exists && inGit) {
|
|
422
|
-
|
|
747
|
+
// Worktree directory exists and is tracked by git
|
|
748
|
+
if (divergent || dirty) {
|
|
749
|
+
// Has commits beyond base or uncommitted work → active
|
|
750
|
+
status = 'active';
|
|
751
|
+
} else if (merged) {
|
|
752
|
+
// No divergent commits, branch aligned with base.
|
|
753
|
+
// Use stored status as history to distinguish fresh vs merged:
|
|
754
|
+
// - stored 'fresh' → never had divergent commits → still fresh
|
|
755
|
+
// - stored 'active' → had work that's now merged → merged
|
|
756
|
+
if (entry.status === 'active') {
|
|
757
|
+
status = 'merged';
|
|
758
|
+
} else {
|
|
759
|
+
status = 'fresh';
|
|
760
|
+
}
|
|
761
|
+
} else {
|
|
762
|
+
status = 'fresh';
|
|
763
|
+
}
|
|
423
764
|
} else if (exists) {
|
|
424
765
|
status = 'orphaned';
|
|
425
766
|
} else {
|
|
426
|
-
const merged = entry.branch && entry.baseBranch
|
|
427
|
-
? isBranchMerged(entry.branch, entry.baseBranch, root)
|
|
428
|
-
: false;
|
|
429
767
|
status = merged ? 'stale-merged' : 'missing';
|
|
430
768
|
}
|
|
431
769
|
|
|
432
770
|
const lastCommit = entry.branch ? getLastCommitInfo(entry.branch, root) : null;
|
|
433
|
-
const merged = entry.branch && entry.baseBranch
|
|
434
|
-
? isBranchMerged(entry.branch, entry.baseBranch, root)
|
|
435
|
-
: false;
|
|
436
771
|
|
|
437
|
-
return { ...entry, status, lastCommit, merged };
|
|
772
|
+
return { ...entry, status, lastCommit, merged, divergent, dirty };
|
|
438
773
|
});
|
|
439
774
|
|
|
440
775
|
// Append unregistered worktrees discovered from git
|
|
@@ -466,15 +801,17 @@ function reconcileRegistry(root) {
|
|
|
466
801
|
* prunes stale entries. Reports the delta before persisting.
|
|
467
802
|
*
|
|
468
803
|
* @param {Object} options
|
|
469
|
-
* @param {boolean} [options.prune=false] - Remove destroyed
|
|
804
|
+
* @param {boolean} [options.prune=false] - Remove destroyed, stale-merged, and missing entries
|
|
470
805
|
* @param {boolean} [options.dryRun=false] - Report only, do not persist
|
|
806
|
+
* @param {boolean} [options.force=false] - Allow pruning entries owned by other sessions
|
|
471
807
|
* @returns {{ repaired: Array, pruned: Array, skipped: Array }}
|
|
472
808
|
*/
|
|
473
809
|
function repairWorktrees(options = {}) {
|
|
474
|
-
const { prune: shouldPrune = false, dryRun = false } = options;
|
|
810
|
+
const { prune: shouldPrune = false, dryRun = false, force = false } = options;
|
|
475
811
|
const root = getRepoRoot();
|
|
476
812
|
const registry = loadRegistry(root);
|
|
477
813
|
const { entries } = reconcileRegistry(root);
|
|
814
|
+
const currentSession = getAgentSessionId(root);
|
|
478
815
|
|
|
479
816
|
const repaired = [];
|
|
480
817
|
const pruned = [];
|
|
@@ -494,18 +831,47 @@ function repairWorktrees(options = {}) {
|
|
|
494
831
|
if (!regEntry) continue;
|
|
495
832
|
|
|
496
833
|
// Update registry status to match filesystem reality
|
|
497
|
-
|
|
498
|
-
|
|
834
|
+
const wasAlive = regEntry.status === 'active' || regEntry.status === 'fresh';
|
|
835
|
+
const nowDead = entry.status === 'missing' || entry.status === 'stale-merged';
|
|
836
|
+
if (wasAlive && nowDead) {
|
|
837
|
+
repaired.push({
|
|
838
|
+
name: entry.name,
|
|
839
|
+
action: 'status-updated',
|
|
840
|
+
from: regEntry.status,
|
|
841
|
+
to: entry.status,
|
|
842
|
+
owner: entry.owner || null,
|
|
843
|
+
});
|
|
499
844
|
}
|
|
500
845
|
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
846
|
+
// Determine if entry is prunable (destroyed, stale-merged, or missing)
|
|
847
|
+
const isPrunable = entry.status === 'destroyed' ||
|
|
848
|
+
entry.status === 'stale-merged' ||
|
|
849
|
+
entry.status === 'missing';
|
|
850
|
+
|
|
851
|
+
if (!isPrunable) continue;
|
|
852
|
+
|
|
853
|
+
// Ownership check: refuse to prune another session's entries without --force
|
|
854
|
+
const isOwnedByOther = entry.owner && currentSession && entry.owner !== currentSession;
|
|
855
|
+
|
|
856
|
+
if (shouldPrune && isPrunable) {
|
|
857
|
+
if (isOwnedByOther && !force) {
|
|
858
|
+
skipped.push({
|
|
859
|
+
name: entry.name,
|
|
860
|
+
reason: `owned by another session (${entry.owner}). Use --force to override`,
|
|
861
|
+
owner: entry.owner,
|
|
862
|
+
});
|
|
863
|
+
} else {
|
|
864
|
+
if (!dryRun) {
|
|
865
|
+
delete registry.worktrees[entry.name];
|
|
866
|
+
}
|
|
867
|
+
pruned.push({ name: entry.name, status: entry.status, owner: entry.owner || null });
|
|
505
868
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
869
|
+
} else if (!shouldPrune && isPrunable) {
|
|
870
|
+
skipped.push({
|
|
871
|
+
name: entry.name,
|
|
872
|
+
reason: entry.status + ' (use --prune to remove)',
|
|
873
|
+
owner: entry.owner || null,
|
|
874
|
+
});
|
|
509
875
|
}
|
|
510
876
|
}
|
|
511
877
|
|
|
@@ -524,11 +890,29 @@ function repairWorktrees(options = {}) {
|
|
|
524
890
|
/**
|
|
525
891
|
* List all registered worktrees with filesystem validation.
|
|
526
892
|
* Delegates to reconcileRegistry() for state classification.
|
|
893
|
+
* Persists status transitions (fresh → active, active → merged) so
|
|
894
|
+
* future calls can distinguish "never had work" from "work was merged back".
|
|
527
895
|
* @returns {Array} Worktree entries with status
|
|
528
896
|
*/
|
|
529
897
|
function listWorktrees() {
|
|
530
898
|
const root = getRepoRoot();
|
|
899
|
+
const registry = loadRegistry(root);
|
|
531
900
|
const { entries } = reconcileRegistry(root);
|
|
901
|
+
|
|
902
|
+
// Persist status transitions so future reconcile can use stored status as history
|
|
903
|
+
let dirty = false;
|
|
904
|
+
for (const entry of entries) {
|
|
905
|
+
const regEntry = registry.worktrees[entry.name];
|
|
906
|
+
if (regEntry && regEntry.status !== entry.status &&
|
|
907
|
+
entry.status !== 'unregistered') {
|
|
908
|
+
regEntry.status = entry.status;
|
|
909
|
+
dirty = true;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
if (dirty) {
|
|
913
|
+
saveRegistry(root, registry);
|
|
914
|
+
}
|
|
915
|
+
|
|
532
916
|
return entries;
|
|
533
917
|
}
|
|
534
918
|
|
|
@@ -541,6 +925,9 @@ function listWorktrees() {
|
|
|
541
925
|
*/
|
|
542
926
|
function destroyWorktree(name, options = {}) {
|
|
543
927
|
const root = getRepoRoot();
|
|
928
|
+
// Ensure CWD is not inside the worktree we're about to destroy.
|
|
929
|
+
// If CWD is the worktree directory, removing it crashes subsequent commands.
|
|
930
|
+
try { process.chdir(root); } catch { /* non-fatal */ }
|
|
544
931
|
const registry = loadRegistry(root);
|
|
545
932
|
const { deleteBranch = false, force = false } = options;
|
|
546
933
|
|
|
@@ -557,11 +944,12 @@ function destroyWorktree(name, options = {}) {
|
|
|
557
944
|
}
|
|
558
945
|
}
|
|
559
946
|
|
|
560
|
-
// Ownership check: refuse to destroy another agent's
|
|
561
|
-
const currentSession =
|
|
947
|
+
// Ownership check: refuse to destroy another agent's worktree without --force
|
|
948
|
+
const currentSession = getAgentSessionId(root);
|
|
949
|
+
const isLiveStatus = entry.status === 'active' || entry.status === 'fresh' || entry.status === 'merged';
|
|
562
950
|
if (
|
|
563
951
|
!force &&
|
|
564
|
-
|
|
952
|
+
isLiveStatus &&
|
|
565
953
|
entry.owner &&
|
|
566
954
|
currentSession &&
|
|
567
955
|
entry.owner !== currentSession
|
|
@@ -580,7 +968,7 @@ function destroyWorktree(name, options = {}) {
|
|
|
580
968
|
// Even with --force, warn loudly when destroying another session's worktree
|
|
581
969
|
if (
|
|
582
970
|
force &&
|
|
583
|
-
|
|
971
|
+
isLiveStatus &&
|
|
584
972
|
entry.owner &&
|
|
585
973
|
currentSession &&
|
|
586
974
|
entry.owner !== currentSession
|
|
@@ -647,9 +1035,31 @@ function destroyWorktree(name, options = {}) {
|
|
|
647
1035
|
}
|
|
648
1036
|
|
|
649
1037
|
// Update registry
|
|
1038
|
+
const wasAlreadyDestroyed = registry.worktrees[name].status === 'destroyed';
|
|
650
1039
|
registry.worktrees[name].status = 'destroyed';
|
|
651
1040
|
registry.worktrees[name].destroyedAt = new Date().toISOString();
|
|
652
1041
|
saveRegistry(root, registry);
|
|
1042
|
+
|
|
1043
|
+
// CAWSFIX-18: auto-commit the registry so the working tree stays clean
|
|
1044
|
+
if (!wasAlreadyDestroyed) {
|
|
1045
|
+
try {
|
|
1046
|
+
const status = execFileSync('git', ['status', '--porcelain', '.caws/worktrees.json'], {
|
|
1047
|
+
cwd: root, stdio: ['pipe', 'pipe', 'pipe'],
|
|
1048
|
+
}).toString().trim();
|
|
1049
|
+
if (status) {
|
|
1050
|
+
const otherActive = Object.values(registry.worktrees || {}).some(
|
|
1051
|
+
(e) => e.status === 'active' || e.status === 'fresh'
|
|
1052
|
+
);
|
|
1053
|
+
const prefix = otherActive ? 'wip(checkpoint)' : 'chore(worktree)';
|
|
1054
|
+
execFileSync('git', ['add', '.caws/worktrees.json'], { cwd: root, stdio: 'pipe' });
|
|
1055
|
+
execFileSync('git', ['commit', '-m', `${prefix}: record destroyed ${name}`], {
|
|
1056
|
+
cwd: root, stdio: 'pipe',
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
} catch (err) {
|
|
1060
|
+
console.warn(chalk.yellow(` Warning: could not auto-commit .caws/worktrees.json: ${err.message}`));
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
653
1063
|
}
|
|
654
1064
|
|
|
655
1065
|
/**
|
|
@@ -682,17 +1092,32 @@ function mergeWorktree(name, options = {}) {
|
|
|
682
1092
|
|
|
683
1093
|
const baseBranch = entry.baseBranch || 'main';
|
|
684
1094
|
|
|
685
|
-
// Check for uncommitted work in the worktree
|
|
1095
|
+
// Check for uncommitted work in the worktree.
|
|
1096
|
+
// Ignore .caws/ changes (provenance chain, registry) — these are
|
|
1097
|
+
// infrastructure artifacts written by git hooks, not user work.
|
|
1098
|
+
// The post-commit hook appends to .caws/provenance/chain.json after
|
|
1099
|
+
// every commit, which immediately dirties the tree and blocks merges.
|
|
686
1100
|
if (fs.existsSync(entry.path)) {
|
|
687
1101
|
try {
|
|
688
|
-
const
|
|
1102
|
+
const rawStatus = execFileSync(
|
|
689
1103
|
'git',
|
|
690
1104
|
['status', '--porcelain'],
|
|
691
1105
|
{ cwd: entry.path, encoding: 'utf8', stdio: 'pipe' }
|
|
692
|
-
)
|
|
693
|
-
|
|
1106
|
+
);
|
|
1107
|
+
// Filter out .caws/ infrastructure changes (provenance, registry).
|
|
1108
|
+
// Git porcelain format: "XY PATH" — 2 status chars, space, path.
|
|
1109
|
+
// IMPORTANT: do NOT .trim() the raw output — it strips the leading
|
|
1110
|
+
// space from " M file" (unstaged), corrupting the XY prefix and
|
|
1111
|
+
// breaking substring(3) path extraction.
|
|
1112
|
+
const statusLines = rawStatus.split('\n').filter(l => l.length > 0);
|
|
1113
|
+
const userChanges = statusLines
|
|
1114
|
+
.filter(line => {
|
|
1115
|
+
const filePath = line.substring(3);
|
|
1116
|
+
return !filePath.startsWith('.caws/');
|
|
1117
|
+
}).join('\n');
|
|
1118
|
+
if (userChanges) {
|
|
694
1119
|
throw new Error(
|
|
695
|
-
`Worktree '${name}' has uncommitted changes:\n${
|
|
1120
|
+
`Worktree '${name}' has uncommitted changes:\n${userChanges}\n` +
|
|
696
1121
|
`Commit or discard changes before merging.`
|
|
697
1122
|
);
|
|
698
1123
|
}
|
|
@@ -736,32 +1161,54 @@ function mergeWorktree(name, options = {}) {
|
|
|
736
1161
|
};
|
|
737
1162
|
}
|
|
738
1163
|
|
|
1164
|
+
// Emit merge:pre event
|
|
1165
|
+
try {
|
|
1166
|
+
lifecycle.emit(EVENTS.MERGE_PRE, {
|
|
1167
|
+
worktreeName: name, branch: entry.branch, baseBranch, conflicts,
|
|
1168
|
+
timestamp: new Date().toISOString(),
|
|
1169
|
+
});
|
|
1170
|
+
} catch { /* non-fatal */ }
|
|
1171
|
+
|
|
1172
|
+
// Ensure CWD is the repo root BEFORE destroying the worktree.
|
|
1173
|
+
// If the caller's CWD is inside the worktree directory, destroying it
|
|
1174
|
+
// removes the CWD out from under the process, causing all subsequent
|
|
1175
|
+
// git commands to fail with "Unable to read current working directory".
|
|
1176
|
+
try { process.chdir(root); } catch { /* non-fatal */ }
|
|
1177
|
+
|
|
739
1178
|
// Destroy the worktree (auto-forces since we're about to merge)
|
|
740
1179
|
destroyWorktree(name, { deleteBranch: false, force: true });
|
|
741
1180
|
|
|
742
|
-
// Switch to base branch
|
|
743
|
-
const currentBranch =
|
|
1181
|
+
// Switch to base branch (use cwd: root since getCurrentBranch has no cwd param)
|
|
1182
|
+
const currentBranch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
1183
|
+
cwd: root, encoding: 'utf8', stdio: 'pipe',
|
|
1184
|
+
}).trim();
|
|
744
1185
|
if (currentBranch !== baseBranch) {
|
|
745
1186
|
execFileSync('git', ['checkout', baseBranch], { cwd: root, stdio: 'pipe' });
|
|
746
1187
|
}
|
|
747
1188
|
|
|
748
1189
|
// Merge
|
|
1190
|
+
// Use --no-verify to skip pre-commit/commit-msg hooks during merge.
|
|
1191
|
+
// The worktree commits were already validated by those hooks when originally
|
|
1192
|
+
// committed. Re-running them here adds seconds of blocking time (especially
|
|
1193
|
+
// in projects with heavy hooks like quality gates, YAML validation, etc.)
|
|
1194
|
+
// and can trigger OAuth token expiry races in long-running sessions.
|
|
749
1195
|
const mergeMessage = message || `merge(worktree): ${name}`;
|
|
750
1196
|
try {
|
|
751
1197
|
execFileSync(
|
|
752
1198
|
'git',
|
|
753
|
-
['merge', '--no-ff', entry.branch, '-m', mergeMessage],
|
|
1199
|
+
['merge', '--no-ff', '--no-verify', entry.branch, '-m', mergeMessage],
|
|
754
1200
|
{ cwd: root, stdio: 'pipe' }
|
|
755
1201
|
);
|
|
756
1202
|
} catch (error) {
|
|
757
|
-
|
|
758
|
-
name,
|
|
759
|
-
branch: entry.branch,
|
|
760
|
-
baseBranch,
|
|
761
|
-
merged: false,
|
|
1203
|
+
const failResult = {
|
|
1204
|
+
name, branch: entry.branch, baseBranch, merged: false,
|
|
762
1205
|
conflicts: [`Merge failed: ${error.message}`],
|
|
763
1206
|
message: 'Merge conflicts detected. Resolve with git and commit.',
|
|
764
1207
|
};
|
|
1208
|
+
try {
|
|
1209
|
+
lifecycle.emit(EVENTS.MERGE_POST, { ...failResult, timestamp: new Date().toISOString() });
|
|
1210
|
+
} catch { /* non-fatal */ }
|
|
1211
|
+
return failResult;
|
|
765
1212
|
}
|
|
766
1213
|
|
|
767
1214
|
// Delete branch after successful merge
|
|
@@ -773,13 +1220,48 @@ function mergeWorktree(name, options = {}) {
|
|
|
773
1220
|
}
|
|
774
1221
|
}
|
|
775
1222
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
1223
|
+
// Auto-close the bound spec if one exists. A worktree merge is the
|
|
1224
|
+
// lifecycle signal that the spec's work is done; leaving the spec
|
|
1225
|
+
// `active` after merge accumulates stale-active entries (D6). Direct
|
|
1226
|
+
// YAML status flip bypasses the ownership + worktree-reference checks
|
|
1227
|
+
// in `closeSpec` — the caller has already proven authority by merging.
|
|
1228
|
+
let autoClosedSpecId = null;
|
|
1229
|
+
if (entry.specId) {
|
|
1230
|
+
autoClosedSpecId = autoCloseBoundSpec(root, entry.specId);
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const mergeResult = {
|
|
1234
|
+
name, branch: entry.branch, baseBranch, merged: true, conflicts: [],
|
|
1235
|
+
specId: entry.specId || null, autoClosedSpecId,
|
|
782
1236
|
};
|
|
1237
|
+
try {
|
|
1238
|
+
lifecycle.emit(EVENTS.MERGE_POST, { ...mergeResult, timestamp: new Date().toISOString() });
|
|
1239
|
+
} catch { /* non-fatal */ }
|
|
1240
|
+
return mergeResult;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* Flip a spec's status to `closed` by rewriting just the `status:` line.
|
|
1245
|
+
* Idempotent: no-op when the spec is already closed or the file is missing.
|
|
1246
|
+
* Returns the spec ID on success, null if skipped or failed.
|
|
1247
|
+
* @param {string} root - Repo root
|
|
1248
|
+
* @param {string} specId - Spec identifier (e.g. CAWSFIX-14)
|
|
1249
|
+
* @returns {string|null}
|
|
1250
|
+
*/
|
|
1251
|
+
function autoCloseBoundSpec(root, specId) {
|
|
1252
|
+
try {
|
|
1253
|
+
const specPath = findFeatureSpecPath(root, specId);
|
|
1254
|
+
if (!specPath || !fs.existsSync(specPath)) return null;
|
|
1255
|
+
const original = fs.readFileSync(specPath, 'utf8');
|
|
1256
|
+
// Idempotent: already closed → no-op, no write, no diff.
|
|
1257
|
+
if (/^status:\s*closed\s*$/m.test(original)) return specId;
|
|
1258
|
+
const patched = original.replace(/^status:\s*active\s*$/m, 'status: closed');
|
|
1259
|
+
if (patched === original) return null; // status was e.g. draft/archived
|
|
1260
|
+
fs.writeFileSync(specPath, patched, 'utf8');
|
|
1261
|
+
return specId;
|
|
1262
|
+
} catch {
|
|
1263
|
+
return null;
|
|
1264
|
+
}
|
|
783
1265
|
}
|
|
784
1266
|
|
|
785
1267
|
/**
|
|
@@ -787,12 +1269,14 @@ function mergeWorktree(name, options = {}) {
|
|
|
787
1269
|
* @param {Object} options - Prune options
|
|
788
1270
|
* @param {number} [options.maxAgeDays] - Remove entries older than this many days
|
|
789
1271
|
* @param {number} [options.recentCommitMinutes] - Protect branches with commits newer than this (default: 60)
|
|
790
|
-
* @
|
|
1272
|
+
* @param {boolean} [options.force] - Allow pruning entries owned by other sessions
|
|
1273
|
+
* @returns {{ pruned: Array, skipped: Array }} Pruned and skipped entries
|
|
791
1274
|
*/
|
|
792
1275
|
function pruneWorktrees(options = {}) {
|
|
793
1276
|
const root = getRepoRoot();
|
|
794
1277
|
const registry = loadRegistry(root);
|
|
795
|
-
const { maxAgeDays = 30, recentCommitMinutes = 60 } = options;
|
|
1278
|
+
const { maxAgeDays = 30, recentCommitMinutes = 60, force = false } = options;
|
|
1279
|
+
const currentSession = getAgentSessionId(root);
|
|
796
1280
|
|
|
797
1281
|
const now = new Date();
|
|
798
1282
|
const pruned = [];
|
|
@@ -806,14 +1290,25 @@ function pruneWorktrees(options = {}) {
|
|
|
806
1290
|
const shouldPrune =
|
|
807
1291
|
// Always prune destroyed entries
|
|
808
1292
|
entry.status === 'destroyed' ||
|
|
809
|
-
// Prune active entries whose directory is gone (filesystem-registry desync)
|
|
810
|
-
(entry.status === 'active' && !dirExists) ||
|
|
1293
|
+
// Prune active/fresh entries whose directory is gone (filesystem-registry desync)
|
|
1294
|
+
((entry.status === 'active' || entry.status === 'fresh') && !dirExists) ||
|
|
811
1295
|
// Prune old missing entries
|
|
812
1296
|
(!dirExists && ageDays > maxAgeDays);
|
|
813
1297
|
|
|
814
1298
|
if (shouldPrune) {
|
|
815
|
-
//
|
|
816
|
-
|
|
1299
|
+
// Ownership check: skip entries owned by other sessions unless --force
|
|
1300
|
+
const isOwnedByOther = entry.owner && currentSession && entry.owner !== currentSession;
|
|
1301
|
+
if (isOwnedByOther && entry.status !== 'destroyed' && !force) {
|
|
1302
|
+
skipped.push({
|
|
1303
|
+
name,
|
|
1304
|
+
reason: `owned by another session (${entry.owner})`,
|
|
1305
|
+
entry,
|
|
1306
|
+
});
|
|
1307
|
+
continue;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// Before pruning a non-destroyed entry, check for recent commits (skip if --force)
|
|
1311
|
+
if (!force && entry.status !== 'destroyed' && entry.branch) {
|
|
817
1312
|
const lastCommit = getLastCommitInfo(entry.branch, root);
|
|
818
1313
|
if (lastCommit) {
|
|
819
1314
|
const commitAgeMinutes = (now - lastCommit.timestamp) / (1000 * 60);
|
|
@@ -856,16 +1351,24 @@ module.exports = {
|
|
|
856
1351
|
listWorktrees,
|
|
857
1352
|
destroyWorktree,
|
|
858
1353
|
mergeWorktree,
|
|
1354
|
+
autoCloseBoundSpec,
|
|
859
1355
|
pruneWorktrees,
|
|
860
1356
|
repairWorktrees,
|
|
861
1357
|
reconcileRegistry,
|
|
862
1358
|
loadRegistry,
|
|
1359
|
+
saveRegistry,
|
|
863
1360
|
getRepoRoot,
|
|
864
1361
|
getLastCommitInfo,
|
|
865
1362
|
isBranchMerged,
|
|
1363
|
+
hasDivergentCommits,
|
|
1364
|
+
hasDirtyFiles,
|
|
866
1365
|
discoverUnregisteredWorktrees,
|
|
867
1366
|
autoRegisterWorktree,
|
|
868
1367
|
WORKTREES_DIR,
|
|
869
1368
|
REGISTRY_FILE,
|
|
870
1369
|
BRANCH_PREFIX,
|
|
1370
|
+
findFeatureSpecPath,
|
|
1371
|
+
materializeWorktreeSpec,
|
|
1372
|
+
inferSpecIdForWorktree,
|
|
1373
|
+
findSpecByWorktreeName,
|
|
871
1374
|
};
|