@paths.design/caws-cli 9.3.1 → 10.0.1
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 +58 -27
- 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 +27 -15
- package/dist/commands/gates.js +122 -0
- package/dist/commands/init.js +143 -15
- package/dist/commands/iterate.js +77 -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/sidecar.js +71 -0
- package/dist/commands/specs.js +233 -44
- package/dist/commands/status.js +113 -9
- package/dist/commands/tutorial.js +10 -9
- package/dist/commands/validate.js +49 -6
- package/dist/commands/verify-acs.js +35 -78
- package/dist/commands/waivers.js +69 -12
- package/dist/commands/worktree.js +104 -26
- package/dist/error-handler.js +2 -13
- package/dist/gates/budget-limit.js +116 -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 +102 -0
- package/dist/gates/todo-detection.js +205 -0
- package/dist/index.js +136 -150
- package/dist/parallel/parallel-manager.js +3 -3
- package/dist/policy/PolicyManager.js +42 -10
- 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 +71 -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 +50 -0
- package/dist/templates/.caws/schemas/waivers.schema.json +30 -24
- package/dist/templates/.caws/schemas/working-spec.schema.json +51 -8
- package/dist/templates/.caws/schemas/worktrees.schema.json +3 -1
- package/dist/templates/.caws/templates/working-spec.template.yml +7 -3
- 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/quality-check.sh +23 -10
- package/dist/templates/.claude/hooks/scope-guard.sh +34 -32
- 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 +36 -23
- package/dist/templates/.claude/hooks/worktree-write-guard.sh +6 -5
- package/dist/templates/.claude/settings.json +26 -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 +43 -8
- package/dist/templates/agents.md +29 -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/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 +42 -0
- package/dist/utils/spec-resolver.js +93 -21
- package/dist/utils/working-state.js +505 -0
- package/dist/validation/spec-validation.js +92 -22
- package/dist/waivers-manager.js +60 -6
- package/dist/worktree/worktree-manager.js +496 -95
- package/package.json +6 -6
- package/templates/.caws/schemas/policy.schema.json +50 -0
- package/templates/.caws/schemas/waivers.schema.json +30 -24
- package/templates/.caws/schemas/working-spec.schema.json +51 -8
- package/templates/.caws/schemas/worktrees.schema.json +3 -1
- package/templates/.caws/templates/working-spec.template.yml +7 -3
- 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/quality-check.sh +23 -10
- package/templates/.claude/hooks/scope-guard.sh +34 -32
- 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 +36 -23
- package/templates/.claude/hooks/worktree-write-guard.sh +6 -5
- package/templates/.claude/settings.json +26 -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 +43 -8
- package/templates/{AGENTS.md → agents.md} +29 -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,88 @@ 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 materializeWorktreeSpec(root, cawsDest, specId, worktreeName, scope) {
|
|
31
|
+
if (!specId) return;
|
|
32
|
+
|
|
33
|
+
const canonicalSpecPath = findFeatureSpecPath(root, specId);
|
|
34
|
+
const workingSpecPath = path.join(cawsDest, 'working-spec.yaml');
|
|
35
|
+
|
|
36
|
+
if (!canonicalSpecPath) {
|
|
37
|
+
console.warn(
|
|
38
|
+
chalk.yellow(`Warning: spec '${specId}' not found in .caws/specs/ — generating default working spec for worktree`)
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (canonicalSpecPath) {
|
|
43
|
+
const destSpecsDir = path.join(cawsDest, 'specs');
|
|
44
|
+
const destSpecPath = path.join(destSpecsDir, path.basename(canonicalSpecPath));
|
|
45
|
+
fs.ensureDirSync(destSpecsDir);
|
|
46
|
+
|
|
47
|
+
// Keep a canonical feature-spec copy inside the worktree and align
|
|
48
|
+
// working-spec.yaml to that exact content for legacy-compatible commands.
|
|
49
|
+
const specContent = fs.readFileSync(canonicalSpecPath, 'utf8');
|
|
50
|
+
fs.writeFileSync(destSpecPath, specContent);
|
|
51
|
+
fs.writeFileSync(workingSpecPath, specContent);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { generateWorkingSpec } = require('../generators/working-spec');
|
|
56
|
+
const specContent = generateWorkingSpec({
|
|
57
|
+
projectId: specId,
|
|
58
|
+
projectTitle: `Worktree: ${worktreeName}`,
|
|
59
|
+
projectDescription: `Isolated worktree for ${worktreeName}`,
|
|
60
|
+
riskTier: 3,
|
|
61
|
+
projectMode: 'feature',
|
|
62
|
+
scopeIn: scope || 'src/',
|
|
63
|
+
scopeOut: 'node_modules/, dist/, build/',
|
|
64
|
+
maxFiles: 25,
|
|
65
|
+
maxLoc: 1000,
|
|
66
|
+
blastModules: scope || 'src',
|
|
67
|
+
dataMigration: false,
|
|
68
|
+
rollbackSlo: '5m',
|
|
69
|
+
projectThreats: '',
|
|
70
|
+
projectInvariants: 'System maintains data consistency',
|
|
71
|
+
acceptanceCriteria: 'Given current state, when action occurs, then expected result',
|
|
72
|
+
a11yRequirements: 'keyboard',
|
|
73
|
+
perfBudget: 250,
|
|
74
|
+
securityRequirements: 'validation',
|
|
75
|
+
contractType: '',
|
|
76
|
+
contractPath: '',
|
|
77
|
+
observabilityLogs: '',
|
|
78
|
+
observabilityMetrics: '',
|
|
79
|
+
observabilityTraces: '',
|
|
80
|
+
migrationPlan: '',
|
|
81
|
+
rollbackPlan: '',
|
|
82
|
+
needsOverride: false,
|
|
83
|
+
isExperimental: false,
|
|
84
|
+
aiConfidence: 0.8,
|
|
85
|
+
uncertaintyAreas: '',
|
|
86
|
+
complexityFactors: '',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
fs.ensureDirSync(path.dirname(workingSpecPath));
|
|
90
|
+
fs.writeFileSync(workingSpecPath, specContent);
|
|
91
|
+
}
|
|
92
|
+
|
|
16
93
|
/**
|
|
17
94
|
* Get the last commit info for a branch
|
|
18
95
|
* @param {string} branch - Branch name
|
|
@@ -54,13 +131,63 @@ function isBranchMerged(branch, target, root) {
|
|
|
54
131
|
}
|
|
55
132
|
|
|
56
133
|
/**
|
|
57
|
-
*
|
|
58
|
-
* @
|
|
134
|
+
* Check if a branch has divergent commits from target (commits on branch not on target).
|
|
135
|
+
* @param {string} branch - Branch to check
|
|
136
|
+
* @param {string} target - Target branch (e.g., "main")
|
|
137
|
+
* @param {string} root - Repository root
|
|
138
|
+
* @returns {boolean}
|
|
139
|
+
*/
|
|
140
|
+
function hasDivergentCommits(branch, target, root) {
|
|
141
|
+
try {
|
|
142
|
+
const count = execFileSync(
|
|
143
|
+
'git',
|
|
144
|
+
['rev-list', '--count', `${target}..${branch}`],
|
|
145
|
+
{ cwd: root, encoding: 'utf8', stdio: 'pipe' }
|
|
146
|
+
).trim();
|
|
147
|
+
return parseInt(count, 10) > 0;
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Check if a worktree directory has dirty (uncommitted) files.
|
|
155
|
+
* @param {string} worktreePath - Path to the worktree
|
|
156
|
+
* @returns {boolean}
|
|
157
|
+
*/
|
|
158
|
+
function hasDirtyFiles(worktreePath) {
|
|
159
|
+
try {
|
|
160
|
+
const status = execFileSync(
|
|
161
|
+
'git',
|
|
162
|
+
['status', '--porcelain'],
|
|
163
|
+
{ cwd: worktreePath, encoding: 'utf8', stdio: 'pipe' }
|
|
164
|
+
).trim();
|
|
165
|
+
return status.length > 0;
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get the canonical git repository root (main worktree, not a linked worktree).
|
|
173
|
+
*
|
|
174
|
+
* `git rev-parse --show-toplevel` returns the root of whichever worktree
|
|
175
|
+
* the CWD is inside. In a linked worktree that is NOT the main repo root,
|
|
176
|
+
* so CAWS would read the wrong (or missing) .caws/worktrees.json.
|
|
177
|
+
*
|
|
178
|
+
* `--git-common-dir` always resolves to the main repo's .git directory,
|
|
179
|
+
* even from inside a linked worktree. Its parent is the canonical repo root.
|
|
180
|
+
*
|
|
181
|
+
* @returns {string} Absolute path to the main repo root
|
|
59
182
|
*/
|
|
60
183
|
function getRepoRoot() {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
184
|
+
const gitCommonDir = execFileSync(
|
|
185
|
+
'git',
|
|
186
|
+
['rev-parse', '--path-format=absolute', '--git-common-dir'],
|
|
187
|
+
{ encoding: 'utf8' }
|
|
188
|
+
).trim();
|
|
189
|
+
// gitCommonDir is /path/to/main-repo/.git — parent is the repo root
|
|
190
|
+
return path.dirname(gitCommonDir);
|
|
64
191
|
}
|
|
65
192
|
|
|
66
193
|
/**
|
|
@@ -73,6 +200,11 @@ function getCurrentBranch() {
|
|
|
73
200
|
}).trim();
|
|
74
201
|
}
|
|
75
202
|
|
|
203
|
+
// Track whether we've already warned about schema violations this process.
|
|
204
|
+
// loadRegistry() is called multiple times per command; warning every time
|
|
205
|
+
// floods stderr and contributes to Claude Code context-window exhaustion.
|
|
206
|
+
let _schemaWarned = false;
|
|
207
|
+
|
|
76
208
|
/**
|
|
77
209
|
* Load the worktree registry
|
|
78
210
|
* @param {string} root - Repository root
|
|
@@ -82,7 +214,21 @@ function loadRegistry(root) {
|
|
|
82
214
|
const registryPath = path.join(root, REGISTRY_FILE);
|
|
83
215
|
try {
|
|
84
216
|
if (fs.existsSync(registryPath)) {
|
|
85
|
-
|
|
217
|
+
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
218
|
+
try {
|
|
219
|
+
const validate = createValidator(getSchemaPath('worktrees.schema.json', root));
|
|
220
|
+
const result = validate(data);
|
|
221
|
+
if (!result.valid && !_schemaWarned) {
|
|
222
|
+
_schemaWarned = true;
|
|
223
|
+
console.warn('Worktree registry has schema violations:', result.errors);
|
|
224
|
+
}
|
|
225
|
+
} catch (schemaErr) {
|
|
226
|
+
if (!_schemaWarned) {
|
|
227
|
+
_schemaWarned = true;
|
|
228
|
+
console.warn('Could not validate worktree registry schema:', schemaErr.message);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return data;
|
|
86
232
|
}
|
|
87
233
|
} catch {
|
|
88
234
|
// Corrupted registry, start fresh
|
|
@@ -96,6 +242,27 @@ function loadRegistry(root) {
|
|
|
96
242
|
* @param {Object} registry - Registry object
|
|
97
243
|
*/
|
|
98
244
|
function saveRegistry(root, registry) {
|
|
245
|
+
// Auto-prune destroyed entries whose branch and directory are both gone.
|
|
246
|
+
// This prevents the registry from accumulating ghost entries over time.
|
|
247
|
+
for (const [name, entry] of Object.entries(registry.worktrees || {})) {
|
|
248
|
+
if (entry.status !== 'destroyed') continue;
|
|
249
|
+
const dirGone = !fs.existsSync(entry.path);
|
|
250
|
+
let branchGone = true;
|
|
251
|
+
if (entry.branch) {
|
|
252
|
+
try {
|
|
253
|
+
execFileSync('git', ['rev-parse', '--verify', entry.branch], {
|
|
254
|
+
cwd: root, stdio: 'pipe',
|
|
255
|
+
});
|
|
256
|
+
branchGone = false;
|
|
257
|
+
} catch {
|
|
258
|
+
branchGone = true;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (dirGone && branchGone) {
|
|
262
|
+
delete registry.worktrees[name];
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
99
266
|
const registryPath = path.join(root, REGISTRY_FILE);
|
|
100
267
|
fs.ensureDirSync(path.dirname(registryPath));
|
|
101
268
|
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2));
|
|
@@ -221,15 +388,64 @@ function createWorktree(name, options = {}) {
|
|
|
221
388
|
|
|
222
389
|
const registry = loadRegistry(root);
|
|
223
390
|
|
|
224
|
-
// Check for duplicate
|
|
391
|
+
// Check for duplicate in registry
|
|
225
392
|
if (registry.worktrees[name]) {
|
|
226
|
-
|
|
393
|
+
const existing = registry.worktrees[name];
|
|
394
|
+
if (existing.status !== 'destroyed') {
|
|
395
|
+
const ownerInfo = existing.owner ? ` (owned by session ${existing.owner})` : '';
|
|
396
|
+
throw new Error(
|
|
397
|
+
`Worktree '${name}' already exists with status '${existing.status}'${ownerInfo}.\n` +
|
|
398
|
+
`Use 'caws worktree destroy ${name}' first, or choose a different name.`
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
// Destroyed entries: check if another session owns the branch
|
|
402
|
+
if (existing.owner && existing.owner !== getAgentSessionId(root)) {
|
|
403
|
+
// Branch may still be in use by the owning session for merge
|
|
404
|
+
try {
|
|
405
|
+
const branchExists = execFileSync('git', ['rev-parse', '--verify', BRANCH_PREFIX + name], {
|
|
406
|
+
cwd: root, stdio: 'pipe',
|
|
407
|
+
}).toString().trim();
|
|
408
|
+
if (branchExists) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
`Worktree '${name}' was destroyed but branch '${BRANCH_PREFIX}${name}' still exists ` +
|
|
411
|
+
`(owned by session ${existing.owner}).\n` +
|
|
412
|
+
`The owning session may still need this branch for merging.\n` +
|
|
413
|
+
`Choose a different name, or delete the branch first: git branch -d ${BRANCH_PREFIX}${name}`
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
} catch (e) {
|
|
417
|
+
if (e.message.includes('owned by session')) throw e;
|
|
418
|
+
// Branch doesn't exist — safe to reuse the name
|
|
419
|
+
}
|
|
420
|
+
}
|
|
227
421
|
}
|
|
228
422
|
|
|
229
423
|
const worktreePath = path.join(root, WORKTREES_DIR, name);
|
|
230
424
|
const branchName = BRANCH_PREFIX + name;
|
|
231
425
|
const base = baseBranch || getCurrentBranch();
|
|
232
426
|
|
|
427
|
+
// Check if the branch already exists in git (even if not in registry)
|
|
428
|
+
// This catches cases where another agent created the branch outside CAWS
|
|
429
|
+
try {
|
|
430
|
+
execFileSync('git', ['rev-parse', '--verify', branchName], {
|
|
431
|
+
cwd: root, stdio: 'pipe',
|
|
432
|
+
});
|
|
433
|
+
// Branch exists — refuse unless it's fully merged into base
|
|
434
|
+
const currentSession = getAgentSessionId(root);
|
|
435
|
+
const registryOwner = registry.worktrees[name]?.owner;
|
|
436
|
+
if (registryOwner && registryOwner !== currentSession) {
|
|
437
|
+
throw new Error(
|
|
438
|
+
`Branch '${branchName}' already exists and is owned by another session (${registryOwner}).\n` +
|
|
439
|
+
`Another agent may be using this branch. Choose a different worktree name.`
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
// Branch exists but no owner conflict — warn and reuse
|
|
443
|
+
console.warn(`Warning: Branch '${branchName}' already exists, reusing it.`);
|
|
444
|
+
} catch (e) {
|
|
445
|
+
if (e.message.includes('already exists and is owned')) throw e;
|
|
446
|
+
// Branch doesn't exist — this is the normal path
|
|
447
|
+
}
|
|
448
|
+
|
|
233
449
|
// Create the worktree directory
|
|
234
450
|
fs.ensureDirSync(path.dirname(worktreePath));
|
|
235
451
|
|
|
@@ -240,7 +456,7 @@ function createWorktree(name, options = {}) {
|
|
|
240
456
|
stdio: 'pipe',
|
|
241
457
|
});
|
|
242
458
|
} catch (error) {
|
|
243
|
-
// Branch
|
|
459
|
+
// Branch already exists (caught above and allowed) — attach to it
|
|
244
460
|
if (error.message.includes('already exists')) {
|
|
245
461
|
execFileSync('git', ['worktree', 'add', worktreePath, branchName], {
|
|
246
462
|
cwd: root,
|
|
@@ -294,46 +510,16 @@ function createWorktree(name, options = {}) {
|
|
|
294
510
|
}
|
|
295
511
|
}
|
|
296
512
|
|
|
297
|
-
//
|
|
513
|
+
// Materialize a worktree-local working spec. Prefer the canonical feature
|
|
514
|
+
// spec when it exists so isolated worktrees stay aligned with the main
|
|
515
|
+
// registry/resolver model.
|
|
298
516
|
if (specId) {
|
|
299
517
|
try {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
riskTier: 3,
|
|
306
|
-
projectMode: 'feature',
|
|
307
|
-
scopeIn: scope || 'src/',
|
|
308
|
-
scopeOut: 'node_modules/, dist/, build/',
|
|
309
|
-
maxFiles: 25,
|
|
310
|
-
maxLoc: 1000,
|
|
311
|
-
blastModules: scope || 'src',
|
|
312
|
-
dataMigration: false,
|
|
313
|
-
rollbackSlo: '5m',
|
|
314
|
-
projectThreats: '',
|
|
315
|
-
projectInvariants: 'System maintains data consistency',
|
|
316
|
-
acceptanceCriteria: 'Given current state, when action occurs, then expected result',
|
|
317
|
-
a11yRequirements: 'keyboard',
|
|
318
|
-
perfBudget: 250,
|
|
319
|
-
securityRequirements: 'validation',
|
|
320
|
-
contractType: '',
|
|
321
|
-
contractPath: '',
|
|
322
|
-
observabilityLogs: '',
|
|
323
|
-
observabilityMetrics: '',
|
|
324
|
-
observabilityTraces: '',
|
|
325
|
-
migrationPlan: '',
|
|
326
|
-
rollbackPlan: '',
|
|
327
|
-
needsOverride: false,
|
|
328
|
-
isExperimental: false,
|
|
329
|
-
aiConfidence: 0.8,
|
|
330
|
-
uncertaintyAreas: '',
|
|
331
|
-
complexityFactors: '',
|
|
332
|
-
});
|
|
333
|
-
const specPath = path.join(cawsDest, 'working-spec.yaml');
|
|
334
|
-
fs.ensureDirSync(path.dirname(specPath));
|
|
335
|
-
fs.writeFileSync(specPath, specContent);
|
|
336
|
-
} catch {
|
|
518
|
+
materializeWorktreeSpec(root, cawsDest, specId, name, scope);
|
|
519
|
+
} catch (error) {
|
|
520
|
+
console.warn(
|
|
521
|
+
chalk.yellow(`Could not materialize spec '${specId}' for worktree '${name}': ${error.message}`)
|
|
522
|
+
);
|
|
337
523
|
// Non-fatal: spec generation is optional
|
|
338
524
|
}
|
|
339
525
|
}
|
|
@@ -346,9 +532,9 @@ function createWorktree(name, options = {}) {
|
|
|
346
532
|
baseBranch: base,
|
|
347
533
|
scope: scope || null,
|
|
348
534
|
specId: specId || null,
|
|
349
|
-
owner: options.owner ||
|
|
535
|
+
owner: options.owner || getAgentSessionId(root) || null,
|
|
350
536
|
createdAt: new Date().toISOString(),
|
|
351
|
-
status: '
|
|
537
|
+
status: 'fresh',
|
|
352
538
|
};
|
|
353
539
|
|
|
354
540
|
registry.worktrees[name] = entry;
|
|
@@ -358,19 +544,31 @@ function createWorktree(name, options = {}) {
|
|
|
358
544
|
}
|
|
359
545
|
|
|
360
546
|
/**
|
|
361
|
-
*
|
|
362
|
-
*
|
|
547
|
+
* Reconcile registry state against git worktree list and filesystem.
|
|
548
|
+
*
|
|
549
|
+
* Non-destructive read that classifies every known worktree entry
|
|
550
|
+
* (from registry + git discovery) into one of:
|
|
551
|
+
* active — directory exists AND in git worktree list
|
|
552
|
+
* orphaned — directory exists but NOT in git worktree list
|
|
553
|
+
* missing — directory gone, branch may or may not exist
|
|
554
|
+
* destroyed — explicitly destroyed via CAWS
|
|
555
|
+
* unregistered — in git worktree list but not in registry
|
|
556
|
+
* stale-merged — missing + branch already merged to base
|
|
557
|
+
*
|
|
558
|
+
* Does NOT mutate the registry. Callers decide what to persist.
|
|
559
|
+
*
|
|
560
|
+
* @param {string} root - Repository root
|
|
561
|
+
* @returns {{ entries: Array, gitWorktrees: string[] }}
|
|
363
562
|
*/
|
|
364
|
-
function
|
|
365
|
-
const root = getRepoRoot();
|
|
563
|
+
function reconcileRegistry(root) {
|
|
366
564
|
const registry = loadRegistry(root);
|
|
367
565
|
|
|
368
|
-
// Get actual git worktrees for validation
|
|
369
566
|
let gitWorktrees = [];
|
|
370
567
|
try {
|
|
371
568
|
const output = execFileSync('git', ['worktree', 'list', '--porcelain'], {
|
|
372
569
|
cwd: root,
|
|
373
570
|
encoding: 'utf8',
|
|
571
|
+
stdio: 'pipe',
|
|
374
572
|
});
|
|
375
573
|
gitWorktrees = output
|
|
376
574
|
.split('\n\n')
|
|
@@ -390,22 +588,45 @@ function listWorktrees() {
|
|
|
390
588
|
const inGit = gitWorktrees.some(
|
|
391
589
|
(wt) => path.resolve(wt) === path.resolve(entry.path)
|
|
392
590
|
);
|
|
393
|
-
const status = exists && inGit ? 'active' : exists ? 'orphaned' : 'missing';
|
|
394
|
-
|
|
395
|
-
// Enrich with commit recency
|
|
396
|
-
const lastCommit = entry.branch ? getLastCommitInfo(entry.branch, root) : null;
|
|
397
591
|
|
|
398
|
-
// Check if branch is already merged to base
|
|
399
592
|
const merged = entry.branch && entry.baseBranch
|
|
400
593
|
? isBranchMerged(entry.branch, entry.baseBranch, root)
|
|
401
594
|
: false;
|
|
595
|
+
const divergent = entry.branch && entry.baseBranch
|
|
596
|
+
? hasDivergentCommits(entry.branch, entry.baseBranch, root)
|
|
597
|
+
: false;
|
|
598
|
+
const dirty = exists ? hasDirtyFiles(entry.path) : false;
|
|
599
|
+
|
|
600
|
+
let status;
|
|
601
|
+
if (entry.status === 'destroyed') {
|
|
602
|
+
status = 'destroyed';
|
|
603
|
+
} else if (exists && inGit) {
|
|
604
|
+
// Worktree directory exists and is tracked by git
|
|
605
|
+
if (divergent || dirty) {
|
|
606
|
+
// Has commits beyond base or uncommitted work → active
|
|
607
|
+
status = 'active';
|
|
608
|
+
} else if (merged) {
|
|
609
|
+
// No divergent commits, branch aligned with base.
|
|
610
|
+
// Use stored status as history to distinguish fresh vs merged:
|
|
611
|
+
// - stored 'fresh' → never had divergent commits → still fresh
|
|
612
|
+
// - stored 'active' → had work that's now merged → merged
|
|
613
|
+
if (entry.status === 'active') {
|
|
614
|
+
status = 'merged';
|
|
615
|
+
} else {
|
|
616
|
+
status = 'fresh';
|
|
617
|
+
}
|
|
618
|
+
} else {
|
|
619
|
+
status = 'fresh';
|
|
620
|
+
}
|
|
621
|
+
} else if (exists) {
|
|
622
|
+
status = 'orphaned';
|
|
623
|
+
} else {
|
|
624
|
+
status = merged ? 'stale-merged' : 'missing';
|
|
625
|
+
}
|
|
402
626
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
lastCommit,
|
|
407
|
-
merged,
|
|
408
|
-
};
|
|
627
|
+
const lastCommit = entry.branch ? getLastCommitInfo(entry.branch, root) : null;
|
|
628
|
+
|
|
629
|
+
return { ...entry, status, lastCommit, merged, divergent, dirty };
|
|
409
630
|
});
|
|
410
631
|
|
|
411
632
|
// Append unregistered worktrees discovered from git
|
|
@@ -427,6 +648,128 @@ function listWorktrees() {
|
|
|
427
648
|
});
|
|
428
649
|
}
|
|
429
650
|
|
|
651
|
+
return { entries, gitWorktrees };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Repair registry drift caused by manual git operations outside CAWS.
|
|
656
|
+
*
|
|
657
|
+
* Scans registry vs git vs filesystem, classifies each entry, and optionally
|
|
658
|
+
* prunes stale entries. Reports the delta before persisting.
|
|
659
|
+
*
|
|
660
|
+
* @param {Object} options
|
|
661
|
+
* @param {boolean} [options.prune=false] - Remove destroyed, stale-merged, and missing entries
|
|
662
|
+
* @param {boolean} [options.dryRun=false] - Report only, do not persist
|
|
663
|
+
* @param {boolean} [options.force=false] - Allow pruning entries owned by other sessions
|
|
664
|
+
* @returns {{ repaired: Array, pruned: Array, skipped: Array }}
|
|
665
|
+
*/
|
|
666
|
+
function repairWorktrees(options = {}) {
|
|
667
|
+
const { prune: shouldPrune = false, dryRun = false, force = false } = options;
|
|
668
|
+
const root = getRepoRoot();
|
|
669
|
+
const registry = loadRegistry(root);
|
|
670
|
+
const { entries } = reconcileRegistry(root);
|
|
671
|
+
const currentSession = getAgentSessionId(root);
|
|
672
|
+
|
|
673
|
+
const repaired = [];
|
|
674
|
+
const pruned = [];
|
|
675
|
+
const skipped = [];
|
|
676
|
+
|
|
677
|
+
for (const entry of entries) {
|
|
678
|
+
const regEntry = registry.worktrees[entry.name];
|
|
679
|
+
|
|
680
|
+
if (entry.status === 'unregistered') {
|
|
681
|
+
if (!dryRun) {
|
|
682
|
+
autoRegisterWorktree(root, registry, entry);
|
|
683
|
+
}
|
|
684
|
+
repaired.push({ name: entry.name, action: 'registered', status: entry.status });
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (!regEntry) continue;
|
|
689
|
+
|
|
690
|
+
// Update registry status to match filesystem reality
|
|
691
|
+
const wasAlive = regEntry.status === 'active' || regEntry.status === 'fresh';
|
|
692
|
+
const nowDead = entry.status === 'missing' || entry.status === 'stale-merged';
|
|
693
|
+
if (wasAlive && nowDead) {
|
|
694
|
+
repaired.push({
|
|
695
|
+
name: entry.name,
|
|
696
|
+
action: 'status-updated',
|
|
697
|
+
from: regEntry.status,
|
|
698
|
+
to: entry.status,
|
|
699
|
+
owner: entry.owner || null,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Determine if entry is prunable (destroyed, stale-merged, or missing)
|
|
704
|
+
const isPrunable = entry.status === 'destroyed' ||
|
|
705
|
+
entry.status === 'stale-merged' ||
|
|
706
|
+
entry.status === 'missing';
|
|
707
|
+
|
|
708
|
+
if (!isPrunable) continue;
|
|
709
|
+
|
|
710
|
+
// Ownership check: refuse to prune another session's entries without --force
|
|
711
|
+
const isOwnedByOther = entry.owner && currentSession && entry.owner !== currentSession;
|
|
712
|
+
|
|
713
|
+
if (shouldPrune && isPrunable) {
|
|
714
|
+
if (isOwnedByOther && !force) {
|
|
715
|
+
skipped.push({
|
|
716
|
+
name: entry.name,
|
|
717
|
+
reason: `owned by another session (${entry.owner}). Use --force to override`,
|
|
718
|
+
owner: entry.owner,
|
|
719
|
+
});
|
|
720
|
+
} else {
|
|
721
|
+
if (!dryRun) {
|
|
722
|
+
delete registry.worktrees[entry.name];
|
|
723
|
+
}
|
|
724
|
+
pruned.push({ name: entry.name, status: entry.status, owner: entry.owner || null });
|
|
725
|
+
}
|
|
726
|
+
} else if (!shouldPrune && isPrunable) {
|
|
727
|
+
skipped.push({
|
|
728
|
+
name: entry.name,
|
|
729
|
+
reason: entry.status + ' (use --prune to remove)',
|
|
730
|
+
owner: entry.owner || null,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (!dryRun) {
|
|
736
|
+
saveRegistry(root, registry);
|
|
737
|
+
try {
|
|
738
|
+
execFileSync('git', ['worktree', 'prune'], { cwd: root, stdio: 'pipe' });
|
|
739
|
+
} catch {
|
|
740
|
+
// Non-fatal
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
return { repaired, pruned, skipped };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* List all registered worktrees with filesystem validation.
|
|
749
|
+
* Delegates to reconcileRegistry() for state classification.
|
|
750
|
+
* Persists status transitions (fresh → active, active → merged) so
|
|
751
|
+
* future calls can distinguish "never had work" from "work was merged back".
|
|
752
|
+
* @returns {Array} Worktree entries with status
|
|
753
|
+
*/
|
|
754
|
+
function listWorktrees() {
|
|
755
|
+
const root = getRepoRoot();
|
|
756
|
+
const registry = loadRegistry(root);
|
|
757
|
+
const { entries } = reconcileRegistry(root);
|
|
758
|
+
|
|
759
|
+
// Persist status transitions so future reconcile can use stored status as history
|
|
760
|
+
let dirty = false;
|
|
761
|
+
for (const entry of entries) {
|
|
762
|
+
const regEntry = registry.worktrees[entry.name];
|
|
763
|
+
if (regEntry && regEntry.status !== entry.status &&
|
|
764
|
+
entry.status !== 'unregistered') {
|
|
765
|
+
regEntry.status = entry.status;
|
|
766
|
+
dirty = true;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
if (dirty) {
|
|
770
|
+
saveRegistry(root, registry);
|
|
771
|
+
}
|
|
772
|
+
|
|
430
773
|
return entries;
|
|
431
774
|
}
|
|
432
775
|
|
|
@@ -439,6 +782,9 @@ function listWorktrees() {
|
|
|
439
782
|
*/
|
|
440
783
|
function destroyWorktree(name, options = {}) {
|
|
441
784
|
const root = getRepoRoot();
|
|
785
|
+
// Ensure CWD is not inside the worktree we're about to destroy.
|
|
786
|
+
// If CWD is the worktree directory, removing it crashes subsequent commands.
|
|
787
|
+
try { process.chdir(root); } catch { /* non-fatal */ }
|
|
442
788
|
const registry = loadRegistry(root);
|
|
443
789
|
const { deleteBranch = false, force = false } = options;
|
|
444
790
|
|
|
@@ -455,11 +801,12 @@ function destroyWorktree(name, options = {}) {
|
|
|
455
801
|
}
|
|
456
802
|
}
|
|
457
803
|
|
|
458
|
-
// Ownership check: refuse to destroy another agent's
|
|
459
|
-
const currentSession =
|
|
804
|
+
// Ownership check: refuse to destroy another agent's worktree without --force
|
|
805
|
+
const currentSession = getAgentSessionId(root);
|
|
806
|
+
const isLiveStatus = entry.status === 'active' || entry.status === 'fresh' || entry.status === 'merged';
|
|
460
807
|
if (
|
|
461
808
|
!force &&
|
|
462
|
-
|
|
809
|
+
isLiveStatus &&
|
|
463
810
|
entry.owner &&
|
|
464
811
|
currentSession &&
|
|
465
812
|
entry.owner !== currentSession
|
|
@@ -478,7 +825,7 @@ function destroyWorktree(name, options = {}) {
|
|
|
478
825
|
// Even with --force, warn loudly when destroying another session's worktree
|
|
479
826
|
if (
|
|
480
827
|
force &&
|
|
481
|
-
|
|
828
|
+
isLiveStatus &&
|
|
482
829
|
entry.owner &&
|
|
483
830
|
currentSession &&
|
|
484
831
|
entry.owner !== currentSession
|
|
@@ -580,17 +927,32 @@ function mergeWorktree(name, options = {}) {
|
|
|
580
927
|
|
|
581
928
|
const baseBranch = entry.baseBranch || 'main';
|
|
582
929
|
|
|
583
|
-
// Check for uncommitted work in the worktree
|
|
930
|
+
// Check for uncommitted work in the worktree.
|
|
931
|
+
// Ignore .caws/ changes (provenance chain, registry) — these are
|
|
932
|
+
// infrastructure artifacts written by git hooks, not user work.
|
|
933
|
+
// The post-commit hook appends to .caws/provenance/chain.json after
|
|
934
|
+
// every commit, which immediately dirties the tree and blocks merges.
|
|
584
935
|
if (fs.existsSync(entry.path)) {
|
|
585
936
|
try {
|
|
586
|
-
const
|
|
937
|
+
const rawStatus = execFileSync(
|
|
587
938
|
'git',
|
|
588
939
|
['status', '--porcelain'],
|
|
589
940
|
{ cwd: entry.path, encoding: 'utf8', stdio: 'pipe' }
|
|
590
|
-
)
|
|
591
|
-
|
|
941
|
+
);
|
|
942
|
+
// Filter out .caws/ infrastructure changes (provenance, registry).
|
|
943
|
+
// Git porcelain format: "XY PATH" — 2 status chars, space, path.
|
|
944
|
+
// IMPORTANT: do NOT .trim() the raw output — it strips the leading
|
|
945
|
+
// space from " M file" (unstaged), corrupting the XY prefix and
|
|
946
|
+
// breaking substring(3) path extraction.
|
|
947
|
+
const statusLines = rawStatus.split('\n').filter(l => l.length > 0);
|
|
948
|
+
const userChanges = statusLines
|
|
949
|
+
.filter(line => {
|
|
950
|
+
const filePath = line.substring(3);
|
|
951
|
+
return !filePath.startsWith('.caws/');
|
|
952
|
+
}).join('\n');
|
|
953
|
+
if (userChanges) {
|
|
592
954
|
throw new Error(
|
|
593
|
-
`Worktree '${name}' has uncommitted changes:\n${
|
|
955
|
+
`Worktree '${name}' has uncommitted changes:\n${userChanges}\n` +
|
|
594
956
|
`Commit or discard changes before merging.`
|
|
595
957
|
);
|
|
596
958
|
}
|
|
@@ -634,32 +996,54 @@ function mergeWorktree(name, options = {}) {
|
|
|
634
996
|
};
|
|
635
997
|
}
|
|
636
998
|
|
|
999
|
+
// Emit merge:pre event
|
|
1000
|
+
try {
|
|
1001
|
+
lifecycle.emit(EVENTS.MERGE_PRE, {
|
|
1002
|
+
worktreeName: name, branch: entry.branch, baseBranch, conflicts,
|
|
1003
|
+
timestamp: new Date().toISOString(),
|
|
1004
|
+
});
|
|
1005
|
+
} catch { /* non-fatal */ }
|
|
1006
|
+
|
|
1007
|
+
// Ensure CWD is the repo root BEFORE destroying the worktree.
|
|
1008
|
+
// If the caller's CWD is inside the worktree directory, destroying it
|
|
1009
|
+
// removes the CWD out from under the process, causing all subsequent
|
|
1010
|
+
// git commands to fail with "Unable to read current working directory".
|
|
1011
|
+
try { process.chdir(root); } catch { /* non-fatal */ }
|
|
1012
|
+
|
|
637
1013
|
// Destroy the worktree (auto-forces since we're about to merge)
|
|
638
1014
|
destroyWorktree(name, { deleteBranch: false, force: true });
|
|
639
1015
|
|
|
640
|
-
// Switch to base branch
|
|
641
|
-
const currentBranch =
|
|
1016
|
+
// Switch to base branch (use cwd: root since getCurrentBranch has no cwd param)
|
|
1017
|
+
const currentBranch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
1018
|
+
cwd: root, encoding: 'utf8', stdio: 'pipe',
|
|
1019
|
+
}).trim();
|
|
642
1020
|
if (currentBranch !== baseBranch) {
|
|
643
1021
|
execFileSync('git', ['checkout', baseBranch], { cwd: root, stdio: 'pipe' });
|
|
644
1022
|
}
|
|
645
1023
|
|
|
646
1024
|
// Merge
|
|
1025
|
+
// Use --no-verify to skip pre-commit/commit-msg hooks during merge.
|
|
1026
|
+
// The worktree commits were already validated by those hooks when originally
|
|
1027
|
+
// committed. Re-running them here adds seconds of blocking time (especially
|
|
1028
|
+
// in projects with heavy hooks like quality gates, YAML validation, etc.)
|
|
1029
|
+
// and can trigger OAuth token expiry races in long-running sessions.
|
|
647
1030
|
const mergeMessage = message || `merge(worktree): ${name}`;
|
|
648
1031
|
try {
|
|
649
1032
|
execFileSync(
|
|
650
1033
|
'git',
|
|
651
|
-
['merge', '--no-ff', entry.branch, '-m', mergeMessage],
|
|
1034
|
+
['merge', '--no-ff', '--no-verify', entry.branch, '-m', mergeMessage],
|
|
652
1035
|
{ cwd: root, stdio: 'pipe' }
|
|
653
1036
|
);
|
|
654
1037
|
} catch (error) {
|
|
655
|
-
|
|
656
|
-
name,
|
|
657
|
-
branch: entry.branch,
|
|
658
|
-
baseBranch,
|
|
659
|
-
merged: false,
|
|
1038
|
+
const failResult = {
|
|
1039
|
+
name, branch: entry.branch, baseBranch, merged: false,
|
|
660
1040
|
conflicts: [`Merge failed: ${error.message}`],
|
|
661
1041
|
message: 'Merge conflicts detected. Resolve with git and commit.',
|
|
662
1042
|
};
|
|
1043
|
+
try {
|
|
1044
|
+
lifecycle.emit(EVENTS.MERGE_POST, { ...failResult, timestamp: new Date().toISOString() });
|
|
1045
|
+
} catch { /* non-fatal */ }
|
|
1046
|
+
return failResult;
|
|
663
1047
|
}
|
|
664
1048
|
|
|
665
1049
|
// Delete branch after successful merge
|
|
@@ -671,13 +1055,11 @@ function mergeWorktree(name, options = {}) {
|
|
|
671
1055
|
}
|
|
672
1056
|
}
|
|
673
1057
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
conflicts: [],
|
|
680
|
-
};
|
|
1058
|
+
const mergeResult = { name, branch: entry.branch, baseBranch, merged: true, conflicts: [] };
|
|
1059
|
+
try {
|
|
1060
|
+
lifecycle.emit(EVENTS.MERGE_POST, { ...mergeResult, timestamp: new Date().toISOString() });
|
|
1061
|
+
} catch { /* non-fatal */ }
|
|
1062
|
+
return mergeResult;
|
|
681
1063
|
}
|
|
682
1064
|
|
|
683
1065
|
/**
|
|
@@ -685,12 +1067,14 @@ function mergeWorktree(name, options = {}) {
|
|
|
685
1067
|
* @param {Object} options - Prune options
|
|
686
1068
|
* @param {number} [options.maxAgeDays] - Remove entries older than this many days
|
|
687
1069
|
* @param {number} [options.recentCommitMinutes] - Protect branches with commits newer than this (default: 60)
|
|
688
|
-
* @
|
|
1070
|
+
* @param {boolean} [options.force] - Allow pruning entries owned by other sessions
|
|
1071
|
+
* @returns {{ pruned: Array, skipped: Array }} Pruned and skipped entries
|
|
689
1072
|
*/
|
|
690
1073
|
function pruneWorktrees(options = {}) {
|
|
691
1074
|
const root = getRepoRoot();
|
|
692
1075
|
const registry = loadRegistry(root);
|
|
693
|
-
const { maxAgeDays = 30, recentCommitMinutes = 60 } = options;
|
|
1076
|
+
const { maxAgeDays = 30, recentCommitMinutes = 60, force = false } = options;
|
|
1077
|
+
const currentSession = getAgentSessionId(root);
|
|
694
1078
|
|
|
695
1079
|
const now = new Date();
|
|
696
1080
|
const pruned = [];
|
|
@@ -704,14 +1088,25 @@ function pruneWorktrees(options = {}) {
|
|
|
704
1088
|
const shouldPrune =
|
|
705
1089
|
// Always prune destroyed entries
|
|
706
1090
|
entry.status === 'destroyed' ||
|
|
707
|
-
// Prune active entries whose directory is gone (filesystem-registry desync)
|
|
708
|
-
(entry.status === 'active' && !dirExists) ||
|
|
1091
|
+
// Prune active/fresh entries whose directory is gone (filesystem-registry desync)
|
|
1092
|
+
((entry.status === 'active' || entry.status === 'fresh') && !dirExists) ||
|
|
709
1093
|
// Prune old missing entries
|
|
710
1094
|
(!dirExists && ageDays > maxAgeDays);
|
|
711
1095
|
|
|
712
1096
|
if (shouldPrune) {
|
|
713
|
-
//
|
|
714
|
-
|
|
1097
|
+
// Ownership check: skip entries owned by other sessions unless --force
|
|
1098
|
+
const isOwnedByOther = entry.owner && currentSession && entry.owner !== currentSession;
|
|
1099
|
+
if (isOwnedByOther && entry.status !== 'destroyed' && !force) {
|
|
1100
|
+
skipped.push({
|
|
1101
|
+
name,
|
|
1102
|
+
reason: `owned by another session (${entry.owner})`,
|
|
1103
|
+
entry,
|
|
1104
|
+
});
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Before pruning a non-destroyed entry, check for recent commits (skip if --force)
|
|
1109
|
+
if (!force && entry.status !== 'destroyed' && entry.branch) {
|
|
715
1110
|
const lastCommit = getLastCommitInfo(entry.branch, root);
|
|
716
1111
|
if (lastCommit) {
|
|
717
1112
|
const commitAgeMinutes = (now - lastCommit.timestamp) / (1000 * 60);
|
|
@@ -755,13 +1150,19 @@ module.exports = {
|
|
|
755
1150
|
destroyWorktree,
|
|
756
1151
|
mergeWorktree,
|
|
757
1152
|
pruneWorktrees,
|
|
1153
|
+
repairWorktrees,
|
|
1154
|
+
reconcileRegistry,
|
|
758
1155
|
loadRegistry,
|
|
759
1156
|
getRepoRoot,
|
|
760
1157
|
getLastCommitInfo,
|
|
761
1158
|
isBranchMerged,
|
|
1159
|
+
hasDivergentCommits,
|
|
1160
|
+
hasDirtyFiles,
|
|
762
1161
|
discoverUnregisteredWorktrees,
|
|
763
1162
|
autoRegisterWorktree,
|
|
764
1163
|
WORKTREES_DIR,
|
|
765
1164
|
REGISTRY_FILE,
|
|
766
1165
|
BRANCH_PREFIX,
|
|
1166
|
+
findFeatureSpecPath,
|
|
1167
|
+
materializeWorktreeSpec,
|
|
767
1168
|
};
|