@opengsd/gsd-core 1.3.1 → 1.4.0-rc.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/agents/gsd-advisor-researcher.md +1 -20
- package/agents/gsd-ai-researcher.md +1 -20
- package/agents/gsd-domain-researcher.md +1 -20
- package/agents/gsd-executor.md +1 -1
- package/agents/gsd-phase-researcher.md +92 -166
- package/agents/gsd-planner.md +9 -36
- package/agents/gsd-project-researcher.md +62 -141
- package/agents/gsd-ui-researcher.md +2 -21
- package/agents/gsd-verifier.md +8 -2
- package/bin/install.js +85 -4
- package/commands/gsd/graphify.md +11 -6
- package/commands/gsd/import.md +6 -2
- package/commands/gsd/plan-phase.md +2 -2
- package/gsd-core/bin/check-latest-version.cjs +3 -2
- package/gsd-core/bin/gsd-tools.cjs +238 -32
- package/gsd-core/bin/lib/check-command-router.cjs +1 -0
- package/gsd-core/bin/lib/cli-exit.cjs +42 -0
- package/gsd-core/bin/lib/command-routing-hub.cjs +1 -1
- package/gsd-core/bin/lib/commands.cjs +5 -4
- package/gsd-core/bin/lib/config.cjs +28 -4
- package/gsd-core/bin/lib/core.cjs +72 -28
- package/gsd-core/bin/lib/graphify.cjs +2 -2
- package/gsd-core/bin/lib/init-command-router.cjs +2 -2
- package/gsd-core/bin/lib/init.cjs +19 -3
- package/gsd-core/bin/lib/intel.cjs +3 -20
- package/gsd-core/bin/lib/package-legitimacy.cjs +368 -0
- package/gsd-core/bin/lib/phase.cjs +3 -3
- package/gsd-core/bin/lib/research-provider.cjs +137 -0
- package/gsd-core/bin/lib/research-store.cjs +167 -0
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +4 -19
- package/gsd-core/bin/lib/security.cjs +73 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +3 -0
- package/gsd-core/bin/lib/validate.cjs +2 -2
- package/gsd-core/bin/lib/verification-command-router.cjs +31 -0
- package/gsd-core/bin/lib/verification.cjs +193 -0
- package/gsd-core/bin/lib/verify.cjs +2 -2
- package/gsd-core/bin/lib/workstream-inventory.cjs +1 -1
- package/gsd-core/bin/lib/worktree-base-ref.cjs +325 -0
- package/gsd-core/bin/lib/worktree-safety.cjs +31 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +2 -1
- package/gsd-core/bin/verify-reapply-patches.cjs +8 -11
- package/gsd-core/references/planner-load-graph-context.md +36 -0
- package/gsd-core/references/planning-config.md +3 -1
- package/gsd-core/references/research-documentation-lookup.md +29 -0
- package/gsd-core/references/research-philosophy.md +29 -0
- package/gsd-core/references/research-verification-protocol.md +27 -0
- package/gsd-core/workflows/execute-phase.md +19 -8
- package/gsd-core/workflows/help/modes/full.md +2 -2
- package/gsd-core/workflows/ingest-docs.md +3 -2
- package/gsd-core/workflows/plan-phase.md +14 -10
- package/gsd-core/workflows/plan-review-convergence.md +3 -3
- package/gsd-core/workflows/review.md +22 -5
- package/gsd-core/workflows/ship.md +5 -8
- package/gsd-core/workflows/spec-phase.md +2 -1
- package/gsd-core/workflows/update.md +2 -1
- package/hooks/dist/gsd-context-monitor.js +1 -1
- package/hooks/dist/gsd-workflow-guard.js +1 -0
- package/hooks/dist/gsd-worktree-path-guard.js +1 -1
- package/hooks/gsd-context-monitor.js +1 -1
- package/hooks/gsd-workflow-guard.js +1 -0
- package/hooks/gsd-worktree-path-guard.js +1 -1
- package/package.json +4 -1
- package/scripts/affected-tests-lib.cjs +3 -2
- package/scripts/changeset/cli.cjs +183 -28
- package/scripts/changeset/lint.cjs +5 -4
- package/scripts/changeset/new.cjs +4 -4
- package/scripts/check-alias-drift.cjs +77 -71
- package/scripts/check-env.cjs +185 -179
- package/scripts/check-npm-integrity.cjs +115 -109
- package/scripts/ci-guard-runner.cjs +11 -5
- package/scripts/ci-prepare-test-scope.cjs +27 -22
- package/scripts/ci-rebase-check.cjs +46 -45
- package/scripts/ci-test-scope.cjs +6 -4
- package/scripts/diff-touches-shipped-paths.cjs +52 -44
- package/scripts/gen-inventory-manifest.cjs +38 -32
- package/scripts/gen-research-agents.cjs +276 -0
- package/scripts/lib/cli-exit.cjs +56 -0
- package/scripts/lint-command-contract.cjs +28 -22
- package/scripts/lint-descriptions.cjs +32 -28
- package/scripts/lint-docs-required.cjs +4 -4
- package/scripts/lint-legacy-dir-name.cjs +56 -52
- package/scripts/lint-pr-check-project-dir.cjs +3 -1
- package/scripts/lint-shell-command-projection-drift.cjs +27 -22
- package/scripts/lint-skill-deps.cjs +31 -26
- package/scripts/lint-test-file-count.allowlist.json +1 -0
- package/scripts/lint-test-file-count.cjs +5 -4
- package/scripts/mutation-matrix.cjs +6 -3
- package/scripts/prompt-injection-scan.sh +1 -1
- package/scripts/release-notes/format-github-release-notes.cjs +8 -3
- package/scripts/release-tarball-smoke.cjs +6 -4
- package/scripts/research-profiles.cjs +149 -0
- package/scripts/run-affected-tests.cjs +2 -1
- package/scripts/run-cross-platform-tests.cjs +11 -7
- package/scripts/run-tests.cjs +8 -7
- package/scripts/strip-prose-atrefs.cjs +1 -1
- package/scripts/sync-runtime-launcher.cjs +0 -3
- package/scripts/verify-npm-publish.cjs +14 -26
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Verification Status — single queryable home for verification-status routing.
|
|
4
|
+
*
|
|
5
|
+
* Issue #651: consolidate the pass/gaps_found/human_needed routing that was
|
|
6
|
+
* previously scattered across ship.md and execute-phase.md into a single
|
|
7
|
+
* tested module. Both workflow files will later consume this module's routing
|
|
8
|
+
* table as the single source of truth.
|
|
9
|
+
*
|
|
10
|
+
* ADR-457 build-at-publish: source in src/verification.cts, compiled to
|
|
11
|
+
* gsd-core/bin/lib/verification.cjs (gitignored).
|
|
12
|
+
*
|
|
13
|
+
* DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix: status extraction is scoped to
|
|
14
|
+
* the leading YAML frontmatter block only. A `status:` line in the body (e.g.
|
|
15
|
+
* inside a fenced code block) is ignored — this is the exact failure mode that
|
|
16
|
+
* issue #586 / PR #650 identified. The shared extractFrontmatter parser anchors
|
|
17
|
+
* its regex at byte 0 of the document, which provides this guarantee.
|
|
18
|
+
*/
|
|
19
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
20
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
21
|
+
};
|
|
22
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
23
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core.cjs is an export= CommonJS module
|
|
25
|
+
const core = require("./core.cjs");
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
|
27
|
+
const frontmatterMod = require("./frontmatter.cjs");
|
|
28
|
+
const { output, extractPhaseToken } = core;
|
|
29
|
+
const { extractFrontmatter } = frontmatterMod;
|
|
30
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
31
|
+
/** The set of status values that the gsd-verifier agent emits. */
|
|
32
|
+
const VERIFIER_STATUSES = ['passed', 'gaps_found', 'human_needed'];
|
|
33
|
+
/**
|
|
34
|
+
* Canonical routing table for verification statuses.
|
|
35
|
+
*
|
|
36
|
+
* This is the single source of truth — ship.md and execute-phase.md will
|
|
37
|
+
* later import from here instead of embedding their own message strings.
|
|
38
|
+
*
|
|
39
|
+
* INTERNAL SENTINELS: 'missing' and 'unknown' are operational states constructed
|
|
40
|
+
* internally — the verifier (gsd-verifier.md) never emits them. The verifier only
|
|
41
|
+
* emits values in VERIFIER_STATUSES (passed|gaps_found|human_needed). The guard in
|
|
42
|
+
* readVerificationStatus excludes 'missing' and 'unknown' from raw-status table
|
|
43
|
+
* lookup so they can only be reached via internal construction paths.
|
|
44
|
+
*
|
|
45
|
+
* For 'gaps_found', next_command is built at call time in readVerificationStatus
|
|
46
|
+
* by substituting the phase number — it is NOT stored as a function in the table.
|
|
47
|
+
*/
|
|
48
|
+
const VERIFICATION_ROUTING_TABLE = {
|
|
49
|
+
passed: {
|
|
50
|
+
status: 'passed',
|
|
51
|
+
next_action: 'Verification passed — continue.',
|
|
52
|
+
next_command: '',
|
|
53
|
+
},
|
|
54
|
+
gaps_found: {
|
|
55
|
+
status: 'gaps_found',
|
|
56
|
+
next_action: 'Gaps found. Plan the fixes, then re-run execute-phase before shipping.',
|
|
57
|
+
// next_command is computed at call time; this entry is never returned directly.
|
|
58
|
+
next_command: '',
|
|
59
|
+
},
|
|
60
|
+
human_needed: {
|
|
61
|
+
status: 'human_needed',
|
|
62
|
+
next_action: "Human verification required. Complete the manual tests in the phase's *-UAT.md, then re-run the verify step until status is passed.",
|
|
63
|
+
next_command: '',
|
|
64
|
+
},
|
|
65
|
+
// INTERNAL SENTINEL: constructed when no *-VERIFICATION.md file exists or when
|
|
66
|
+
// the file has no parseable frontmatter status. Never emitted by the verifier.
|
|
67
|
+
missing: {
|
|
68
|
+
status: 'missing',
|
|
69
|
+
next_action: 'No verification report found — the verify step never completed. Re-run execute-phase.',
|
|
70
|
+
next_command: '/gsd:execute-phase',
|
|
71
|
+
},
|
|
72
|
+
// INTERNAL SENTINEL: constructed when the file has a status value not in
|
|
73
|
+
// VERIFIER_STATUSES. Never emitted by the verifier.
|
|
74
|
+
unknown: {
|
|
75
|
+
status: 'unknown',
|
|
76
|
+
next_action: '', // filled in dynamically with the raw value
|
|
77
|
+
next_command: '/gsd:execute-phase',
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Build a 'missing' result from the routing table.
|
|
82
|
+
* Used for two early-return paths: no *-VERIFICATION.md file found, and
|
|
83
|
+
* file present but no parseable frontmatter status.
|
|
84
|
+
*/
|
|
85
|
+
function missingResult() {
|
|
86
|
+
const route = VERIFICATION_ROUTING_TABLE['missing'];
|
|
87
|
+
return {
|
|
88
|
+
status: route.status,
|
|
89
|
+
next_action: route.next_action,
|
|
90
|
+
next_command: route.next_command,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Read the verification status from the first `*-VERIFICATION.md` file in
|
|
95
|
+
* phaseDir and return the routing result.
|
|
96
|
+
*
|
|
97
|
+
* Behavior:
|
|
98
|
+
* 1. Find the first file matching `*-VERIFICATION.md` (sorted, take first).
|
|
99
|
+
* If none → status 'missing'.
|
|
100
|
+
* 2. Extract `status` from FRONTMATTER ONLY via the shared extractFrontmatter
|
|
101
|
+
* parser (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix — parser anchors at byte 0).
|
|
102
|
+
* If no frontmatter block or no `status` key → status 'missing'.
|
|
103
|
+
* 3. Map to routing table. Unknown non-empty value → status 'unknown'.
|
|
104
|
+
*
|
|
105
|
+
* @param phaseDir - Absolute path to the phase directory.
|
|
106
|
+
* @param opts - Options. `opts.fs` allows test injection (defaults to node:fs).
|
|
107
|
+
*/
|
|
108
|
+
function readVerificationStatus(phaseDir, opts = {}) {
|
|
109
|
+
const fsImpl = opts.fs ?? node_fs_1.default;
|
|
110
|
+
// Phase token for the gaps_found command
|
|
111
|
+
const baseName = node_path_1.default.basename(phaseDir);
|
|
112
|
+
const phaseToken = extractPhaseToken(baseName);
|
|
113
|
+
const phaseNumber = phaseToken.length > 0 ? phaseToken : baseName;
|
|
114
|
+
// 1. Find *-VERIFICATION.md
|
|
115
|
+
let verificationFile = null;
|
|
116
|
+
try {
|
|
117
|
+
const entries = fsImpl.readdirSync(phaseDir);
|
|
118
|
+
const candidates = entries.filter((f) => f.endsWith('-VERIFICATION.md')).sort();
|
|
119
|
+
verificationFile = candidates.length > 0 ? candidates[0] : null;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Directory unreadable → treat as missing
|
|
123
|
+
verificationFile = null;
|
|
124
|
+
}
|
|
125
|
+
if (!verificationFile) {
|
|
126
|
+
return missingResult();
|
|
127
|
+
}
|
|
128
|
+
// 2. Read and parse frontmatter using the shared parser.
|
|
129
|
+
// extractFrontmatter anchors at byte 0, so body `status:` lines are ignored.
|
|
130
|
+
const filePath = node_path_1.default.join(phaseDir, verificationFile);
|
|
131
|
+
let rawStatus = null;
|
|
132
|
+
try {
|
|
133
|
+
const content = fsImpl.readFileSync(filePath, 'utf-8');
|
|
134
|
+
const fm = extractFrontmatter(content);
|
|
135
|
+
const statusVal = fm['status'];
|
|
136
|
+
// status is always a scalar string in a well-formed VERIFICATION.md frontmatter;
|
|
137
|
+
// only accept string values — arrays and objects are not valid status values.
|
|
138
|
+
if (typeof statusVal === 'string') {
|
|
139
|
+
const trimmed = statusVal.trim();
|
|
140
|
+
rawStatus = trimmed.length > 0 ? trimmed : null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
rawStatus = null;
|
|
145
|
+
}
|
|
146
|
+
if (!rawStatus) {
|
|
147
|
+
return missingResult();
|
|
148
|
+
}
|
|
149
|
+
// 3. Route — exclude internal sentinels from raw-file lookup (they are
|
|
150
|
+
// constructed internally above, never written by the verifier).
|
|
151
|
+
if (rawStatus in VERIFICATION_ROUTING_TABLE && rawStatus !== 'missing' && rawStatus !== 'unknown') {
|
|
152
|
+
const entry = VERIFICATION_ROUTING_TABLE[rawStatus];
|
|
153
|
+
// gaps_found: build the phase-specific command here rather than in the table.
|
|
154
|
+
const next_command = rawStatus === 'gaps_found'
|
|
155
|
+
? `/gsd:plan-phase ${phaseNumber} --gaps`
|
|
156
|
+
: entry.next_command;
|
|
157
|
+
return {
|
|
158
|
+
status: entry.status,
|
|
159
|
+
next_action: entry.next_action,
|
|
160
|
+
next_command,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// Unknown value
|
|
164
|
+
const unknownRoute = VERIFICATION_ROUTING_TABLE['unknown'];
|
|
165
|
+
return {
|
|
166
|
+
status: unknownRoute.status,
|
|
167
|
+
next_action: `Unexpected verification status '${rawStatus}'. Re-run execute-phase verification.`,
|
|
168
|
+
next_command: unknownRoute.next_command,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* CLI command handler: resolve phaseDir against cwd, call readVerificationStatus,
|
|
173
|
+
* emit via core.output().
|
|
174
|
+
*
|
|
175
|
+
* @param cwd - Current working directory (used to resolve phaseDirArg).
|
|
176
|
+
* @param phaseDirArg - Phase directory path (absolute or relative to cwd).
|
|
177
|
+
* @param raw - Whether to emit raw (non-JSON) output.
|
|
178
|
+
*/
|
|
179
|
+
function cmdVerificationStatus(cwd, phaseDirArg, raw) {
|
|
180
|
+
if (!phaseDirArg) {
|
|
181
|
+
core.error('phase directory required for verification.status');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const phaseDir = node_path_1.default.resolve(cwd, phaseDirArg);
|
|
185
|
+
const result = readVerificationStatus(phaseDir);
|
|
186
|
+
output(result, raw);
|
|
187
|
+
}
|
|
188
|
+
module.exports = {
|
|
189
|
+
VERIFIER_STATUSES,
|
|
190
|
+
VERIFICATION_ROUTING_TABLE,
|
|
191
|
+
readVerificationStatus,
|
|
192
|
+
cmdVerificationStatus,
|
|
193
|
+
};
|
|
@@ -550,13 +550,13 @@ function cmdValidateConsistency(cwd, raw) {
|
|
|
550
550
|
const roadmapContentRaw = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
|
|
551
551
|
const roadmapContent = extractCurrentMilestone(roadmapContentRaw, cwd);
|
|
552
552
|
const roadmapPhases = new Set();
|
|
553
|
-
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*
|
|
553
|
+
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
|
|
554
554
|
let m;
|
|
555
555
|
while ((m = phasePattern.exec(roadmapContent)) !== null) {
|
|
556
556
|
roadmapPhases.add(m[1]);
|
|
557
557
|
}
|
|
558
558
|
const fullRoadmapPhases = new Set();
|
|
559
|
-
const fullPhasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*
|
|
559
|
+
const fullPhasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
|
|
560
560
|
let fm;
|
|
561
561
|
while ((fm = fullPhasePattern.exec(roadmapContentRaw)) !== null) {
|
|
562
562
|
fullRoadmapPhases.add(fm[1]);
|
|
@@ -20,7 +20,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
|
|
|
20
20
|
const node_path_1 = __importDefault(require("node:path"));
|
|
21
21
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
22
22
|
const core = require("./core.cjs");
|
|
23
|
-
const {
|
|
23
|
+
const { readSubdirectories } = core;
|
|
24
24
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
25
25
|
const planScan = require("./plan-scan.cjs");
|
|
26
26
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Worktree base-ref detection and degradation logic (issue #683).
|
|
4
|
+
*
|
|
5
|
+
* Determines whether a worktree's HEAD has drifted from the fork base that the
|
|
6
|
+
* Claude Code harness would use to create a 'fresh' parallel worktree. When
|
|
7
|
+
* drift is detected the caller should fall back to sequential execution on the
|
|
8
|
+
* main working tree to avoid a base mismatch.
|
|
9
|
+
*
|
|
10
|
+
* Pure/testable module: all I/O is injectable via the `deps` argument so unit
|
|
11
|
+
* tests can run without touching the real filesystem or spawning real git.
|
|
12
|
+
*/
|
|
13
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
14
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.shortSha = shortSha;
|
|
18
|
+
exports.readBaseRefFromSettings = readBaseRefFromSettings;
|
|
19
|
+
exports.applyWorktreeBaseRef = applyWorktreeBaseRef;
|
|
20
|
+
exports.resolveEffectiveBaseRef = resolveEffectiveBaseRef;
|
|
21
|
+
exports.cmdWorktreeBaseCheck = cmdWorktreeBaseCheck;
|
|
22
|
+
exports.cmdWorktreeSetBaseRef = cmdWorktreeSetBaseRef;
|
|
23
|
+
exports.evaluateWorktreeBaseDegrade = evaluateWorktreeBaseDegrade;
|
|
24
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
25
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
26
|
+
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
27
|
+
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
|
28
|
+
/**
|
|
29
|
+
* Strip JSONC comments (line and block forms) from a string to produce valid JSON.
|
|
30
|
+
* Handles comments inside strings correctly (does not strip them).
|
|
31
|
+
* Mirrors the same logic in bin/install.js:stripJsonComments.
|
|
32
|
+
*/
|
|
33
|
+
function stripJsonComments(text) {
|
|
34
|
+
let result = '';
|
|
35
|
+
let i = 0;
|
|
36
|
+
let inString = false;
|
|
37
|
+
let stringChar = '';
|
|
38
|
+
while (i < text.length) {
|
|
39
|
+
// Handle string literals — don't strip comments inside strings
|
|
40
|
+
if (inString) {
|
|
41
|
+
if (text[i] === '\\') {
|
|
42
|
+
result += text[i] + (text[i + 1] || '');
|
|
43
|
+
i += 2;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (text[i] === stringChar) {
|
|
47
|
+
inString = false;
|
|
48
|
+
}
|
|
49
|
+
result += text[i];
|
|
50
|
+
i++;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
// Start of string
|
|
54
|
+
if (text[i] === '"' || text[i] === "'") {
|
|
55
|
+
inString = true;
|
|
56
|
+
stringChar = text[i];
|
|
57
|
+
result += text[i];
|
|
58
|
+
i++;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
// Line comment
|
|
62
|
+
if (text[i] === '/' && text[i + 1] === '/') {
|
|
63
|
+
// Skip to end of line
|
|
64
|
+
while (i < text.length && text[i] !== '\n')
|
|
65
|
+
i++;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// Block comment
|
|
69
|
+
if (text[i] === '/' && text[i + 1] === '*') {
|
|
70
|
+
i += 2;
|
|
71
|
+
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/'))
|
|
72
|
+
i++;
|
|
73
|
+
i += 2; // skip closing */
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
result += text[i];
|
|
77
|
+
i++;
|
|
78
|
+
}
|
|
79
|
+
// Remove trailing commas before } or ] (common in JSONC)
|
|
80
|
+
return result.replace(/,\s*([}\]])/g, '$1');
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Parse a string as JSONC (JSON with comments). Returns the parsed value or
|
|
84
|
+
* throws a SyntaxError if the content is genuinely malformed.
|
|
85
|
+
*/
|
|
86
|
+
function parseJsonc(text) {
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(text);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return JSON.parse(stripJsonComments(text));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// ─── Message constants (verbatim — downstream docs/tests depend on these) ─────
|
|
95
|
+
function buildMsgDiverged(headSha, forkRef, forkSha) {
|
|
96
|
+
return `⚠ Worktree base mismatch: HEAD (${shortSha(headSha)}) differs from ${forkRef} (${shortSha(forkSha)}). Running this phase sequentially on the main working tree. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`;
|
|
97
|
+
}
|
|
98
|
+
const MSG_UNKNOWN = `⚠ Cannot determine the worktree fork base (origin/HEAD unresolved). Running this phase sequentially on the main working tree to avoid a base mismatch. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`;
|
|
99
|
+
// ─── Exports ──────────────────────────────────────────────────────────────────
|
|
100
|
+
/**
|
|
101
|
+
* Returns the first 8 characters of a SHA, or '' if null/empty.
|
|
102
|
+
*/
|
|
103
|
+
function shortSha(sha) {
|
|
104
|
+
if (!sha)
|
|
105
|
+
return '';
|
|
106
|
+
return sha.slice(0, 8);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Extracts settings.worktree.baseRef if it is a string; otherwise null.
|
|
110
|
+
* Defensive: settings may be null/undefined, worktree may be missing or
|
|
111
|
+
* not an object.
|
|
112
|
+
*/
|
|
113
|
+
function readBaseRefFromSettings(settings) {
|
|
114
|
+
if (settings == null || typeof settings !== 'object')
|
|
115
|
+
return null;
|
|
116
|
+
const s = settings;
|
|
117
|
+
if (s.worktree == null || typeof s.worktree !== 'object' || Array.isArray(s.worktree))
|
|
118
|
+
return null;
|
|
119
|
+
const worktree = s.worktree;
|
|
120
|
+
if (typeof worktree.baseRef !== 'string')
|
|
121
|
+
return null;
|
|
122
|
+
return worktree.baseRef;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* No-clobber application of worktree.baseRef = 'head'.
|
|
126
|
+
*
|
|
127
|
+
* - If baseRef is absent/null/undefined → set to 'head', return changed:true.
|
|
128
|
+
* - If already 'head' → skip, return skipped:'already-head'.
|
|
129
|
+
* - If any other string → skip without overwriting, return skipped:'explicit-other'.
|
|
130
|
+
*
|
|
131
|
+
* Mutates `settings` in place and also returns it.
|
|
132
|
+
*/
|
|
133
|
+
function applyWorktreeBaseRef(settings) {
|
|
134
|
+
// Defensive: caller must pass a plain object — reject null, arrays, and primitives.
|
|
135
|
+
if (settings === null || Array.isArray(settings) || typeof settings !== 'object') {
|
|
136
|
+
throw new TypeError(`applyWorktreeBaseRef: expected a plain object, got ${settings === null ? 'null' : Array.isArray(settings) ? 'array' : typeof settings}`);
|
|
137
|
+
}
|
|
138
|
+
// Ensure worktree object exists, preserving any existing keys
|
|
139
|
+
if (settings.worktree == null || typeof settings.worktree !== 'object' || Array.isArray(settings.worktree)) {
|
|
140
|
+
settings.worktree = {};
|
|
141
|
+
}
|
|
142
|
+
const worktree = settings.worktree;
|
|
143
|
+
const current = typeof worktree.baseRef === 'string' ? worktree.baseRef : null;
|
|
144
|
+
if (current === 'head') {
|
|
145
|
+
return { changed: false, settings, skipped: 'already-head', previous: 'head' };
|
|
146
|
+
}
|
|
147
|
+
if (current !== null) {
|
|
148
|
+
// Some other explicit string value — don't overwrite
|
|
149
|
+
return { changed: false, settings, skipped: 'explicit-other', previous: current };
|
|
150
|
+
}
|
|
151
|
+
// Absent/null/undefined → set to 'head'
|
|
152
|
+
worktree.baseRef = 'head';
|
|
153
|
+
return { changed: true, settings, skipped: null, previous: null };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Reads settings.local.json then settings.json under claudeDir, extracts
|
|
157
|
+
* worktree.baseRef from the first file that provides a non-null string value.
|
|
158
|
+
*
|
|
159
|
+
* deps.readFile(path) must return the file contents or null on any error.
|
|
160
|
+
*/
|
|
161
|
+
function resolveEffectiveBaseRef(claudeDir, deps) {
|
|
162
|
+
const readFile = deps?.readFile ?? ((p) => {
|
|
163
|
+
try {
|
|
164
|
+
return node_fs_1.default.readFileSync(p, 'utf8');
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
const localPath = node_path_1.default.join(claudeDir, 'settings.local.json');
|
|
171
|
+
const sharedPath = node_path_1.default.join(claudeDir, 'settings.json');
|
|
172
|
+
function parseBaseRef(filePath) {
|
|
173
|
+
const contents = readFile(filePath);
|
|
174
|
+
if (contents == null)
|
|
175
|
+
return null;
|
|
176
|
+
try {
|
|
177
|
+
const parsed = parseJsonc(contents);
|
|
178
|
+
return readBaseRefFromSettings(parsed);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const localRef = parseBaseRef(localPath);
|
|
185
|
+
if (localRef !== null)
|
|
186
|
+
return localRef;
|
|
187
|
+
return parseBaseRef(sharedPath);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* CLI command: check current worktree base-ref degradation status.
|
|
191
|
+
*
|
|
192
|
+
* Reads effective baseRef from <cwd>/.claude settings, runs degradation
|
|
193
|
+
* evaluation, writes JSON result to stdout (or injected write), and returns
|
|
194
|
+
* the result object.
|
|
195
|
+
*/
|
|
196
|
+
function cmdWorktreeBaseCheck(cwd, _args, deps) {
|
|
197
|
+
const claudeDir = node_path_1.default.join(cwd, '.claude');
|
|
198
|
+
const effectiveBaseRef = resolveEffectiveBaseRef(claudeDir, deps?.readFile ? { readFile: deps.readFile } : undefined);
|
|
199
|
+
const result = evaluateWorktreeBaseDegrade({
|
|
200
|
+
cwd,
|
|
201
|
+
effectiveBaseRef,
|
|
202
|
+
execGit: deps?.execGit,
|
|
203
|
+
});
|
|
204
|
+
const write = deps?.write ?? ((s) => process.stdout.write(s));
|
|
205
|
+
write(JSON.stringify(result, null, 2) + '\n');
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* CLI command: write worktree.baseRef = 'head' into <cwd>/.claude/settings.local.json.
|
|
210
|
+
*
|
|
211
|
+
* No-clobber: if the file already has an explicit baseRef that is not 'head',
|
|
212
|
+
* the existing value is preserved and output reflects skipped:'explicit-other'.
|
|
213
|
+
* If the file contains malformed JSON, throws a clear error rather than
|
|
214
|
+
* silently clobbering the user's file.
|
|
215
|
+
*/
|
|
216
|
+
function cmdWorktreeSetBaseRef(cwd, _args, deps) {
|
|
217
|
+
const file = node_path_1.default.join(cwd, '.claude', 'settings.local.json');
|
|
218
|
+
const readFile = deps?.readFile ??
|
|
219
|
+
((p) => { try {
|
|
220
|
+
return node_fs_1.default.readFileSync(p, 'utf8');
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return null;
|
|
224
|
+
} });
|
|
225
|
+
const raw = readFile(file);
|
|
226
|
+
let settings = {};
|
|
227
|
+
if (raw != null) {
|
|
228
|
+
let parsed;
|
|
229
|
+
try {
|
|
230
|
+
parsed = parseJsonc(raw);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
throw new Error(`Refusing to modify ${file}: existing JSON is malformed`);
|
|
234
|
+
}
|
|
235
|
+
if (parsed === null || Array.isArray(parsed) || typeof parsed !== 'object') {
|
|
236
|
+
throw new Error(`Refusing to modify ${file}: expected a JSON object at the top level`);
|
|
237
|
+
}
|
|
238
|
+
settings = parsed;
|
|
239
|
+
}
|
|
240
|
+
const apply = applyWorktreeBaseRef(settings);
|
|
241
|
+
if (apply.changed) {
|
|
242
|
+
const dir = node_path_1.default.dirname(file);
|
|
243
|
+
const existsSync = deps?.existsSync ?? node_fs_1.default.existsSync;
|
|
244
|
+
const mkdirFn = deps?.mkdir ??
|
|
245
|
+
((p, opts) => { node_fs_1.default.mkdirSync(p, opts); });
|
|
246
|
+
if (!existsSync(dir)) {
|
|
247
|
+
mkdirFn(dir, { recursive: true });
|
|
248
|
+
}
|
|
249
|
+
const writeFile = deps?.writeFile ??
|
|
250
|
+
((p, content) => { node_fs_1.default.writeFileSync(p, content, 'utf8'); });
|
|
251
|
+
writeFile(file, JSON.stringify(settings, null, 2) + '\n');
|
|
252
|
+
}
|
|
253
|
+
const output = {
|
|
254
|
+
changed: apply.changed,
|
|
255
|
+
skipped: apply.skipped,
|
|
256
|
+
previous: apply.previous,
|
|
257
|
+
baseRef: 'head',
|
|
258
|
+
file,
|
|
259
|
+
};
|
|
260
|
+
const write = deps?.write ?? ((s) => process.stdout.write(s));
|
|
261
|
+
write(JSON.stringify(output, null, 2) + '\n');
|
|
262
|
+
return output;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Evaluates whether the current worktree HEAD has diverged from the fork base
|
|
266
|
+
* (origin/HEAD) that the Claude Code harness would use when creating a 'fresh'
|
|
267
|
+
* parallel worktree.
|
|
268
|
+
*
|
|
269
|
+
* Returns a structured result with shouldDegrade, reason, and a user-visible
|
|
270
|
+
* message when degradation is warranted.
|
|
271
|
+
*/
|
|
272
|
+
function evaluateWorktreeBaseDegrade(deps) {
|
|
273
|
+
const execGit = deps?.execGit ?? shell_command_projection_cjs_1.execGit;
|
|
274
|
+
const cwd = deps?.cwd;
|
|
275
|
+
const cwdOpts = cwd ? { cwd } : {};
|
|
276
|
+
// a. If baseRef is explicitly 'head' the harness forks from HEAD — no mismatch possible.
|
|
277
|
+
// Claude Code's worktree.baseRef accepts only "fresh" (= origin/HEAD, the default) or "head".
|
|
278
|
+
// Therefore special-casing "head" here and otherwise comparing HEAD against origin/HEAD is
|
|
279
|
+
// complete: any non-"head" value (including "fresh" and absent/null) has fresh/origin-HEAD
|
|
280
|
+
// semantics and must be evaluated against origin/HEAD. (Reference: Claude Code worktrees docs, #683.)
|
|
281
|
+
if (deps?.effectiveBaseRef === 'head') {
|
|
282
|
+
return { shouldDegrade: false, reason: 'baseref-head', message: null, headSha: null, forkRef: null, forkSha: null };
|
|
283
|
+
}
|
|
284
|
+
// b. Resolve HEAD sha.
|
|
285
|
+
const headResult = execGit(['rev-parse', 'HEAD'], cwdOpts);
|
|
286
|
+
const headStdout = headResult.stdout ? headResult.stdout.trim() : '';
|
|
287
|
+
if (headResult.exitCode !== 0 || !headStdout) {
|
|
288
|
+
return { shouldDegrade: false, reason: 'no-head', message: null, headSha: null, forkRef: null, forkSha: null };
|
|
289
|
+
}
|
|
290
|
+
const headSha = headStdout;
|
|
291
|
+
// c. Resolve fork base (what the harness forks 'fresh' worktrees from = origin/HEAD).
|
|
292
|
+
let forkRef = null;
|
|
293
|
+
let forkSha = null;
|
|
294
|
+
// Try direct origin/HEAD rev-parse first.
|
|
295
|
+
const directResult = execGit(['rev-parse', '--verify', '--quiet', 'origin/HEAD'], cwdOpts);
|
|
296
|
+
const directStdout = directResult.stdout ? directResult.stdout.trim() : '';
|
|
297
|
+
if (directResult.exitCode === 0 && directStdout) {
|
|
298
|
+
forkRef = 'origin/HEAD';
|
|
299
|
+
forkSha = directStdout;
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
// Fall back via symbolic-ref → refs/remotes/origin/HEAD
|
|
303
|
+
const symResult = execGit(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], cwdOpts);
|
|
304
|
+
const symStdout = symResult.stdout ? symResult.stdout.trim() : '';
|
|
305
|
+
if (symResult.exitCode === 0 && symStdout) {
|
|
306
|
+
const ref = symStdout;
|
|
307
|
+
const symShaResult = execGit(['rev-parse', '--verify', '--quiet', ref], cwdOpts);
|
|
308
|
+
const symShaStdout = symShaResult.stdout ? symShaResult.stdout.trim() : '';
|
|
309
|
+
if (symShaResult.exitCode === 0 && symShaStdout) {
|
|
310
|
+
// Strip leading 'refs/remotes/' to get e.g. 'origin/next'
|
|
311
|
+
forkRef = ref.replace(/^refs\/remotes\//, '');
|
|
312
|
+
forkSha = symShaStdout;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// d. Evaluate.
|
|
317
|
+
if (forkSha === null) {
|
|
318
|
+
return { shouldDegrade: true, reason: 'fork-ref-unknown', message: MSG_UNKNOWN, headSha, forkRef: null, forkSha: null };
|
|
319
|
+
}
|
|
320
|
+
if (forkSha === headSha) {
|
|
321
|
+
return { shouldDegrade: false, reason: 'head-matches-fork', message: null, headSha, forkRef, forkSha };
|
|
322
|
+
}
|
|
323
|
+
const message = buildMsgDiverged(headSha, forkRef, forkSha);
|
|
324
|
+
return { shouldDegrade: true, reason: 'head-diverged-from-fork', message, headSha, forkRef, forkSha };
|
|
325
|
+
}
|
|
@@ -406,6 +406,12 @@ function defaultFindSummaryFiles(worktreePath) {
|
|
|
406
406
|
*
|
|
407
407
|
* For each *SUMMARY.md found under <worktreePath>/.planning/:
|
|
408
408
|
* - compute relative path from worktree root → .planning/<id>-SUMMARY.md
|
|
409
|
+
* - if the file is ALREADY COMMITTED on the worktree branch
|
|
410
|
+
* (`git cat-file -e HEAD:<relPath>` returns exit 0), skip the copy entirely:
|
|
411
|
+
* the merge will carry it naturally and copying it as an untracked file would
|
|
412
|
+
* cause a "untracked working tree files would be overwritten by merge" collision.
|
|
413
|
+
* On timeout or fatal exit (128) the rescue is also skipped (fail-closed).
|
|
414
|
+
* (#706 — execute-phase committed-SUMMARY contract)
|
|
409
415
|
* - destination = <repoRoot>/<relPath>
|
|
410
416
|
* - copy when dest is absent or content differs
|
|
411
417
|
*
|
|
@@ -419,6 +425,7 @@ function defaultFindSummaryFiles(worktreePath) {
|
|
|
419
425
|
* failure — it sets needsCopy=true and the copy is attempted normally.
|
|
420
426
|
*/
|
|
421
427
|
function rescueSummaryArtifacts(worktreePath, repoRoot, deps) {
|
|
428
|
+
const execGit = deps.execGit || execGitDefault;
|
|
422
429
|
const findSummaryFiles = deps.findSummaryFiles || defaultFindSummaryFiles;
|
|
423
430
|
const existsSync = deps.existsSync || node_fs_1.default.existsSync;
|
|
424
431
|
const readFileSync = deps.readFileSync || ((p) => node_fs_1.default.readFileSync(p, 'utf8'));
|
|
@@ -432,6 +439,30 @@ function rescueSummaryArtifacts(worktreePath, repoRoot, deps) {
|
|
|
432
439
|
// Normalize to forward slashes so the Set comparison against `git status --porcelain`
|
|
433
440
|
// output works on Windows too (git always emits forward slashes in porcelain output).
|
|
434
441
|
const relPath = absPath.slice(worktreePath.length).replace(/^[/\\]/, '').replace(/\\/g, '/');
|
|
442
|
+
// #706: skip rescue when the SUMMARY is already committed on the branch.
|
|
443
|
+
// Use `git cat-file -e HEAD:<relPath>` (not `ls-files --error-unmatch`) so
|
|
444
|
+
// the check is against the committed tree, not the index. ls-files also
|
|
445
|
+
// matches staged-but-uncommitted files, which would skip rescue when the
|
|
446
|
+
// file is staged but not yet committed — the merge wouldn't carry it, and
|
|
447
|
+
// the executor's content could be lost. cat-file -e HEAD:<path> returns
|
|
448
|
+
// exit 0 only when the object exists in the committed HEAD tree.
|
|
449
|
+
//
|
|
450
|
+
// Fail-closed on timeout/fatal git errors: if we cannot determine whether
|
|
451
|
+
// the file is committed, do NOT rescue it (rescuing an actually-committed
|
|
452
|
+
// file would re-create the untracked collision; the merge will surface the
|
|
453
|
+
// issue). The cleanup will be blocked by merge_failed in the worst case,
|
|
454
|
+
// which is the observable behaviour before this fix and is recoverable.
|
|
455
|
+
const catFileResult = execGit(['-C', worktreePath, 'cat-file', '-e', `HEAD:${relPath}`], { cwd: repoRoot });
|
|
456
|
+
if (catFileResult.exitCode !== 1) {
|
|
457
|
+
// Rescue only when cat-file definitively reports the object is absent (exit 1).
|
|
458
|
+
// exit 0 → object exists (committed on HEAD) — merge will carry it, skip.
|
|
459
|
+
// exit 128 → fatal git error (corrupt store, unborn HEAD, etc.) — uncertain,
|
|
460
|
+
// fail-closed: do NOT rescue to avoid recreating the #706 collision.
|
|
461
|
+
// timedOut / null / other → unreliable result — same fail-closed policy.
|
|
462
|
+
// In all non-1 cases the merge will either succeed naturally (0) or surface
|
|
463
|
+
// the problem safely (128/timeout), which is the recoverable pre-fix behaviour.
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
435
466
|
const dest = node_path_1.default.join(repoRoot, relPath);
|
|
436
467
|
let needsCopy = !existsSync(dest);
|
|
437
468
|
if (!needsCopy) {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
const fs = require('node:fs');
|
|
33
33
|
const path = require('node:path');
|
|
34
34
|
const crypto = require('node:crypto');
|
|
35
|
+
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
|
35
36
|
|
|
36
37
|
const SIGNIFICANT_MIN_CHARS = 12;
|
|
37
38
|
const GSD_HOOK_VERSION_LINE_RE = /^(?:\/\/|#)\s*gsd-hook-version:\s*\S+\s*$/i;
|
|
@@ -48,10 +49,9 @@ function parseArgs(argv) {
|
|
|
48
49
|
process.stdout.write(
|
|
49
50
|
'usage: verify-reapply-patches.cjs --patches-dir <path> --config-dir <path> [--pristine-dir <path>] [--json]\n',
|
|
50
51
|
);
|
|
51
|
-
|
|
52
|
+
throw new ExitError(0);
|
|
52
53
|
} else {
|
|
53
|
-
|
|
54
|
-
process.exit(2);
|
|
54
|
+
throw new ExitError(2, `unknown argument: ${arg}`);
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
return opts;
|
|
@@ -281,16 +281,13 @@ function verifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHashe
|
|
|
281
281
|
function main() {
|
|
282
282
|
const opts = parseArgs(process.argv.slice(2));
|
|
283
283
|
if (!opts.patchesDir || !opts.configDir) {
|
|
284
|
-
|
|
285
|
-
process.exit(2);
|
|
284
|
+
throw new ExitError(2, '--patches-dir and --config-dir are required');
|
|
286
285
|
}
|
|
287
286
|
if (!fs.existsSync(opts.patchesDir)) {
|
|
288
|
-
|
|
289
|
-
process.exit(2);
|
|
287
|
+
throw new ExitError(2, `patches dir not found: ${opts.patchesDir}`);
|
|
290
288
|
}
|
|
291
289
|
if (!fs.existsSync(opts.configDir)) {
|
|
292
|
-
|
|
293
|
-
process.exit(2);
|
|
290
|
+
throw new ExitError(2, `config dir not found: ${opts.configDir}`);
|
|
294
291
|
}
|
|
295
292
|
|
|
296
293
|
const files = walk(opts.patchesDir).filter((f) => !f.endsWith('backup-meta.json'));
|
|
@@ -342,11 +339,11 @@ function main() {
|
|
|
342
339
|
}
|
|
343
340
|
}
|
|
344
341
|
|
|
345
|
-
|
|
342
|
+
return failures.length > 0 ? 1 : 0;
|
|
346
343
|
}
|
|
347
344
|
|
|
348
345
|
if (require.main === module) {
|
|
349
|
-
main
|
|
346
|
+
runMain(main);
|
|
350
347
|
}
|
|
351
348
|
|
|
352
349
|
module.exports = { computeUserAddedLines, isSignificantLine, verifyFile, walk, REASON, readPristineHashes, sha256 };
|