@opengsd/gsd-core 1.5.0 → 1.6.0-rc.2
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/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-plan-checker.md +34 -0
- package/agents/gsd-planner.md +2 -0
- package/agents/gsd-roadmapper.md +6 -0
- package/bin/install.js +199 -365
- package/commands/gsd/capture.md +5 -1
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +695 -5
- package/gsd-core/bin/lib/adr-parser.cjs +45 -23
- package/gsd-core/bin/lib/audit.cjs +2 -2
- package/gsd-core/bin/lib/capability-consent.cjs +763 -0
- package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
- package/gsd-core/bin/lib/capability-loader.cjs +764 -0
- package/gsd-core/bin/lib/capability-lock.cjs +553 -0
- package/gsd-core/bin/lib/capability-registry.cjs +198 -4
- package/gsd-core/bin/lib/capability-source.cjs +1242 -0
- package/gsd-core/bin/lib/capability-state.cjs +9 -6
- package/gsd-core/bin/lib/capability-trust.cjs +550 -0
- package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
- package/gsd-core/bin/lib/capability-writer.cjs +14 -5
- package/gsd-core/bin/lib/check-command-router.cjs +69 -18
- package/gsd-core/bin/lib/command-aliases.cjs +8 -0
- package/gsd-core/bin/lib/commands.cjs +247 -0
- package/gsd-core/bin/lib/config-loader.cjs +98 -84
- package/gsd-core/bin/lib/config-schema.cjs +26 -7
- package/gsd-core/bin/lib/config.cjs +7 -1
- package/gsd-core/bin/lib/decisions.cjs +149 -60
- package/gsd-core/bin/lib/frontmatter.cjs +7 -3
- package/gsd-core/bin/lib/gap-checker.cjs +126 -11
- package/gsd-core/bin/lib/init.cjs +91 -22
- package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
- package/gsd-core/bin/lib/milestone.cjs +41 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
- package/gsd-core/bin/lib/phase-id.cjs +25 -11
- package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
- package/gsd-core/bin/lib/phase.cjs +33 -4
- package/gsd-core/bin/lib/probe-core.cjs +7 -0
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +59 -26
- package/gsd-core/bin/lib/project-root.cjs +89 -2
- package/gsd-core/bin/lib/resolution.cjs +26 -0
- package/gsd-core/bin/lib/roadmap-command-router.cjs +16 -3
- package/gsd-core/bin/lib/roadmap-parser.cjs +73 -106
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -17
- package/gsd-core/bin/lib/roadmap.cjs +5 -2
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +423 -3
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +77 -0
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -28
- package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
- package/gsd-core/bin/lib/runtime-name-policy.cjs +44 -0
- package/gsd-core/bin/lib/semver-compare.cjs +127 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +55 -1
- package/gsd-core/bin/lib/state-document.cjs +4 -2
- package/gsd-core/bin/lib/state.cjs +317 -161
- package/gsd-core/bin/lib/surface.cjs +12 -19
- package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
- package/gsd-core/bin/lib/uat.cjs +39 -26
- package/gsd-core/bin/lib/validate.cjs +5 -2
- package/gsd-core/bin/lib/verify.cjs +40 -15
- package/gsd-core/bin/lib/worktree-safety.cjs +202 -0
- package/gsd-core/bin/shared/config-defaults.manifest.json +6 -1
- package/gsd-core/bin/shared/config-schema.manifest.json +5 -1
- package/gsd-core/references/context-budget.md +8 -8
- package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
- package/gsd-core/references/execute-phase-context-guard.md +16 -0
- package/gsd-core/references/execute-phase-wave-guard.md +33 -0
- package/gsd-core/references/planner-antipatterns.md +48 -0
- package/gsd-core/references/planning-config.md +4 -0
- package/gsd-core/references/prohibition-probe.md +15 -9
- package/gsd-core/references/scout-codebase.md +2 -2
- package/gsd-core/workflows/autonomous.md +33 -33
- package/gsd-core/workflows/diagnose-issues.md +6 -1
- package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
- package/gsd-core/workflows/discuss-phase.md +1 -2
- package/gsd-core/workflows/execute-phase.md +12 -12
- package/gsd-core/workflows/help/modes/full.md +10 -0
- package/gsd-core/workflows/list-seeds.md +63 -0
- package/gsd-core/workflows/manager.md +37 -37
- package/gsd-core/workflows/pr-branch.md +156 -0
- package/gsd-core/workflows/quick.md +6 -1
- package/gsd-core/workflows/review.md +10 -2
- package/gsd-core/workflows/spec-phase.md +8 -3
- package/gsd-core/workflows/verify-phase.md +2 -2
- package/package.json +6 -3
- package/scripts/gen-capability-matrix.cjs +284 -0
- package/scripts/gen-capability-registry.cjs +96 -1853
- package/scripts/lint-regression-test-names.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.cjs +192 -0
- package/scripts/lint-test-file-count.allowlist.json +9 -0
- package/scripts/prompt-injection-scan.sh +1 -0
- package/scripts/run-tests.cjs +14 -0
- package/scripts/sync-manifest-versions.cjs +77 -5
|
@@ -33,7 +33,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
33
33
|
};
|
|
34
34
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
35
35
|
const node_path_1 = __importDefault(require("node:path"));
|
|
36
|
-
const node_os_1 = __importDefault(require("node:os"));
|
|
37
36
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
38
37
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
39
38
|
const installProfiles = require("./install-profiles.cjs");
|
|
@@ -41,7 +40,9 @@ const { readActiveProfile, resolveProfile, loadSkillsManifest, } = installProfil
|
|
|
41
40
|
const clusters_cjs_1 = require("./clusters.cjs");
|
|
42
41
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
43
42
|
const runtimeArtifactLayout = require("./runtime-artifact-layout.cjs");
|
|
44
|
-
const { findInstallSourceRoot
|
|
43
|
+
const { findInstallSourceRoot } = runtimeArtifactLayout;
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
45
|
+
const runtimeArtifactConversion = require("./runtime-artifact-conversion.cjs");
|
|
45
46
|
const SURFACE_FILE_NAME = '.gsd-surface.json';
|
|
46
47
|
/**
|
|
47
48
|
* Read the surface state from a runtime config directory.
|
|
@@ -265,26 +266,18 @@ function applySurface(runtimeConfigDir, layout, manifest, clusterMap, registry)
|
|
|
265
266
|
const resolved = resolveSurface(layout.configDir, skillManifest, clusterMap, registry);
|
|
266
267
|
// Mirror installRuntimeArtifacts: skills kinds get per-runtime path rewrites
|
|
267
268
|
// so SKILL.md bodies reference the install target (pathPrefix), not the
|
|
268
|
-
// converter's default ~/.claude paths (#813).
|
|
269
|
-
//
|
|
270
|
-
|
|
269
|
+
// converter's default ~/.claude paths (#813). Delegated to the conversion
|
|
270
|
+
// module's deep seam (ADR-1508 / #1511 Phase 2) — no attribution resolver
|
|
271
|
+
// needed here (proven: Co-Authored-By never appears in staged content; see
|
|
272
|
+
// brief PROVEN KEY FACT). No getInstallExports() call required.
|
|
271
273
|
for (const kind of layout.kinds) {
|
|
272
274
|
const staged = kind.stage(resolved);
|
|
273
275
|
if (kind.kind === 'skills') {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
pathPrefix = installExports.computePathPrefix({
|
|
280
|
-
isGlobal: scope === 'global',
|
|
281
|
-
isOpencode: layout.runtime === 'opencode',
|
|
282
|
-
isWindowsHost: process.platform === 'win32',
|
|
283
|
-
resolvedTarget,
|
|
284
|
-
homeDir,
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
installExports.applyRuntimeContentRewritesInPlace(staged, layout.runtime, pathPrefix);
|
|
276
|
+
runtimeArtifactConversion.rewriteStagedSkillBodies(staged, {
|
|
277
|
+
runtime: layout.runtime,
|
|
278
|
+
configDir: layout.configDir,
|
|
279
|
+
scope: layout.scope ?? 'global',
|
|
280
|
+
});
|
|
288
281
|
}
|
|
289
282
|
const dest = node_path_1.default.join(layout.configDir, kind.destSubpath);
|
|
290
283
|
_syncGsdDir(staged, dest, kind, skillManifest);
|
|
@@ -19,6 +19,9 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
19
19
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
20
20
|
const frontmatter = require("./frontmatter.cjs");
|
|
21
21
|
const { extractFrontmatter } = frontmatter;
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
23
|
+
const markdownSectionizer = require("./markdown-sectionizer.cjs");
|
|
24
|
+
const { stripFencedCode } = markdownSectionizer;
|
|
22
25
|
// ─── Blocking state sets (documented for maintainability) ─────────────────────
|
|
23
26
|
// UAT file frontmatter `status` values that indicate the file is not fully done
|
|
24
27
|
const BLOCKING_UAT_FM_STATUSES = new Set([
|
|
@@ -53,8 +56,8 @@ function stripFalsePositiveContexts(content) {
|
|
|
53
56
|
let stripped = content.replace(/^---\r?\n[\s\S]*?\r?\n---[ \t]*(\r?\n|$)/, '');
|
|
54
57
|
// Step (b): remove HTML comments anywhere; unterminated comment swallows to EOF
|
|
55
58
|
stripped = stripped.replace(/<!--[\s\S]*?(?:-->|$)/g, '');
|
|
56
|
-
// Step (c): remove fenced code blocks via
|
|
57
|
-
stripped =
|
|
59
|
+
// Step (c): remove fenced code blocks via the canonical seam (ADR-1372 T5)
|
|
60
|
+
stripped = stripFencedCode(stripped).text;
|
|
58
61
|
// Step (d): remove blockquote lines
|
|
59
62
|
stripped = stripped
|
|
60
63
|
.split('\n')
|
|
@@ -62,49 +65,6 @@ function stripFalsePositiveContexts(content) {
|
|
|
62
65
|
.join('\n');
|
|
63
66
|
return stripped;
|
|
64
67
|
}
|
|
65
|
-
/**
|
|
66
|
-
* CommonMark-style fenced-code-block stripper.
|
|
67
|
-
* Tracks the opening delimiter char and length so that a ~~~ line inside a
|
|
68
|
-
* ``` fence is correctly treated as fence content, not a closing delimiter.
|
|
69
|
-
*
|
|
70
|
-
* Opening rule: first delimiter line with char+len sets openFence.
|
|
71
|
-
* Closing rule: delimiter line with SAME char, run length >= openFence.len,
|
|
72
|
-
* and NO trailing non-whitespace text closes the fence.
|
|
73
|
-
* All delimiter and content lines are dropped; non-fence lines are kept.
|
|
74
|
-
* Returns the kept text plus unterminatedFence:true if EOF inside a fence.
|
|
75
|
-
*/
|
|
76
|
-
function _stripFencedBlocks(content) {
|
|
77
|
-
const lines = content.split('\n');
|
|
78
|
-
const kept = [];
|
|
79
|
-
let openFence = null;
|
|
80
|
-
const delimRe = /^(\s*)(`{3,}|~{3,})(.*)$/;
|
|
81
|
-
for (const rawLine of lines) {
|
|
82
|
-
// Tolerate CRLF: strip trailing \r for matching, but we work on split-by-\n lines
|
|
83
|
-
// (the outer caller joined by \n already; we just handle a stray \r in the last char)
|
|
84
|
-
const line = rawLine.replace(/\r$/, '');
|
|
85
|
-
const m = delimRe.exec(line);
|
|
86
|
-
if (m) {
|
|
87
|
-
const char = m[2][0];
|
|
88
|
-
const len = m[2].length;
|
|
89
|
-
const trailing = m[3];
|
|
90
|
-
if (openFence === null) {
|
|
91
|
-
// Opening delimiter — drop this line and record the fence
|
|
92
|
-
openFence = { char, len };
|
|
93
|
-
}
|
|
94
|
-
else if (char === openFence.char && len >= openFence.len && /^\s*$/.test(trailing)) {
|
|
95
|
-
// Closing delimiter (same char, sufficient length, no trailing text) — drop and close
|
|
96
|
-
openFence = null;
|
|
97
|
-
}
|
|
98
|
-
// else: mismatched delimiter inside fence (e.g. ~~~ inside ```) — drop as content
|
|
99
|
-
continue; // delimiter lines are always dropped
|
|
100
|
-
}
|
|
101
|
-
if (openFence === null) {
|
|
102
|
-
kept.push(rawLine);
|
|
103
|
-
}
|
|
104
|
-
// Lines inside fence are dropped
|
|
105
|
-
}
|
|
106
|
-
return { text: kept.join('\n'), unterminatedFence: openFence !== null };
|
|
107
|
-
}
|
|
108
68
|
/**
|
|
109
69
|
* Analyse raw markdown for structural anomalies (unterminated fence / comment).
|
|
110
70
|
* Exported for unit-testability and used by evaluateUatPassed for per-file malformed detection.
|
|
@@ -129,8 +89,8 @@ function analyzeMarkdown(raw) {
|
|
|
129
89
|
}
|
|
130
90
|
i = close + 3;
|
|
131
91
|
}
|
|
132
|
-
// Fence state machine gives the accurate unterminated-fence signal.
|
|
133
|
-
const { unterminatedFence } =
|
|
92
|
+
// Fence state machine gives the accurate unterminated-fence signal (seam, ADR-1372 T5).
|
|
93
|
+
const { unterminatedFence } = stripFencedCode(raw);
|
|
134
94
|
return { unterminatedFence, unterminatedComment };
|
|
135
95
|
}
|
|
136
96
|
// ─── parseUatResultItems ──────────────────────────────────────────────────────
|
package/gsd-core/bin/lib/uat.cjs
CHANGED
|
@@ -18,6 +18,9 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
18
18
|
const io = require("./io.cjs");
|
|
19
19
|
const { output, error } = io;
|
|
20
20
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
21
|
+
const markdownSectionizer = require("./markdown-sectionizer.cjs");
|
|
22
|
+
const { collectSection, tokenizeHeadings } = markdownSectionizer;
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
21
24
|
const roadmapParser = require("./roadmap-parser.cjs");
|
|
22
25
|
const { getMilestonePhaseFilter } = roadmapParser;
|
|
23
26
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -128,11 +131,16 @@ function cmdRenderCheckpoint(cwd, options = {}, raw) {
|
|
|
128
131
|
}
|
|
129
132
|
// ─── parseCurrentTest ─────────────────────────────────────────────────────────
|
|
130
133
|
function parseCurrentTest(content) {
|
|
131
|
-
|
|
132
|
-
|
|
134
|
+
// Use the seam to locate the ## Current Test section (ADR-1372 T5).
|
|
135
|
+
// HTML-comment stripping within the section body is UAT-specific, so we keep
|
|
136
|
+
// the comment removal caller-side after extracting the body.
|
|
137
|
+
const currentTestSection = collectSection(content, (h) => /^current\s+test$/i.test(h.text) && h.level === 2, { levelBounded: true });
|
|
138
|
+
if (!currentTestSection) {
|
|
133
139
|
error('UAT file is missing a Current Test section');
|
|
134
140
|
}
|
|
135
|
-
|
|
141
|
+
// Remove any leading HTML comment block (UAT-specific document structure)
|
|
142
|
+
const rawBody = currentTestSection.body.replace(/^<!--[\s\S]*?-->\s*\n?/, '');
|
|
143
|
+
const section = rawBody.trimEnd();
|
|
136
144
|
if (!section.trim()) {
|
|
137
145
|
error('Current Test section is empty');
|
|
138
146
|
}
|
|
@@ -173,36 +181,41 @@ function parseCurrentTest(content) {
|
|
|
173
181
|
};
|
|
174
182
|
}
|
|
175
183
|
function parseFirstPendingTest(content) {
|
|
176
|
-
|
|
177
|
-
|
|
184
|
+
// Use the seam to locate the ## Tests section (ADR-1372 T5).
|
|
185
|
+
const testsSection = collectSection(content, (h) => /^tests$/i.test(h.text) && h.level === 2, { levelBounded: true });
|
|
186
|
+
if (!testsSection) {
|
|
178
187
|
return null;
|
|
179
188
|
}
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const next = headings[i + 1];
|
|
194
|
-
const block = testsSection.slice(current.index, next ? next.index : undefined);
|
|
189
|
+
const sectionBody = testsSection.body;
|
|
190
|
+
// Within the Tests section body, find ### N. Name sub-headings.
|
|
191
|
+
// tokenizeHeadings operates on the section body as a standalone document,
|
|
192
|
+
// filtering to level-3 headings matching the UAT-specific "N. Name" pattern.
|
|
193
|
+
// The UAT-specific item parsing (number extraction, result parsing) stays caller-side.
|
|
194
|
+
const subHeadings = tokenizeHeadings(sectionBody).filter((h) => h.level === 3 && /^\d+\.\s+/.test(h.text));
|
|
195
|
+
for (let i = 0; i < subHeadings.length; i += 1) {
|
|
196
|
+
const current = subHeadings[i];
|
|
197
|
+
const next = subHeadings[i + 1];
|
|
198
|
+
// Slice the block for this sub-test from the section body text
|
|
199
|
+
const block = next
|
|
200
|
+
? sectionBody.slice(current.offset, next.offset)
|
|
201
|
+
: sectionBody.slice(current.offset);
|
|
195
202
|
if (!/^result:\s*\[?pending\]?\s*$/im.test(block)) {
|
|
196
203
|
continue;
|
|
197
204
|
}
|
|
205
|
+
// Extract the UAT-specific number and name from the heading text
|
|
206
|
+
const headingParts = current.text.match(/^(\d+)\.\s+(.+)$/);
|
|
207
|
+
if (!headingParts)
|
|
208
|
+
continue;
|
|
209
|
+
const testNumber = parseInt(headingParts[1], 10);
|
|
210
|
+
const testName = headingParts[2].trim();
|
|
198
211
|
const expected = parseExpectedFromTestBlock(block);
|
|
199
212
|
if (!expected) {
|
|
200
|
-
error(`Pending UAT test ${
|
|
213
|
+
error(`Pending UAT test ${testNumber} is missing an expected field`);
|
|
201
214
|
}
|
|
202
215
|
return {
|
|
203
216
|
complete: false,
|
|
204
|
-
number:
|
|
205
|
-
name: (0, security_cjs_1.sanitizeForDisplay)(
|
|
217
|
+
number: testNumber,
|
|
218
|
+
name: (0, security_cjs_1.sanitizeForDisplay)(testName),
|
|
206
219
|
expected: (0, security_cjs_1.sanitizeForDisplay)(expected),
|
|
207
220
|
};
|
|
208
221
|
}
|
|
@@ -273,10 +286,10 @@ function parseUatItems(content) {
|
|
|
273
286
|
function parseVerificationItems(content, status) {
|
|
274
287
|
const items = [];
|
|
275
288
|
if (status === 'human_needed') {
|
|
276
|
-
//
|
|
277
|
-
const hvSection = content
|
|
289
|
+
// Use the seam to locate the ## Human Verification section (ADR-1372 T5).
|
|
290
|
+
const hvSection = collectSection(content, (h) => /^human\s+verification/i.test(h.text) && h.level === 2, { levelBounded: true });
|
|
278
291
|
if (hvSection) {
|
|
279
|
-
const lines = hvSection
|
|
292
|
+
const lines = hvSection.body.split('\n');
|
|
280
293
|
for (const line of lines) {
|
|
281
294
|
// Match table rows: | N | description | ... |
|
|
282
295
|
const tableMatch = line.match(/\|\s*(\d+)\s*\|\s*([^|]+)/);
|
|
@@ -37,14 +37,17 @@ exports.canonicalPlanStem = canonicalPlanStem;
|
|
|
37
37
|
exports.phaseVariants = phaseVariants;
|
|
38
38
|
exports.buildRoadmapPhaseVariants = buildRoadmapPhaseVariants;
|
|
39
39
|
exports.buildNotStartedPhaseVariants = buildNotStartedPhaseVariants;
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
41
|
+
const phaseIdMod = require("./phase-id.cjs");
|
|
42
|
+
const { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE } = phaseIdMod;
|
|
40
43
|
// ── Issue #26: regex constants (W005, W006-archived) ────────────────────────
|
|
41
44
|
// Matches legacy numeric dirs (01-setup), milestone-prefixed dirs (02-01-setup),
|
|
42
45
|
// deep dirs (02-04-01-deep), and project-code-prefixed variants (GSD-02-01-setup).
|
|
43
|
-
exports.phaseDirNameRe =
|
|
46
|
+
exports.phaseDirNameRe = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}\\d{2,}(?:-\\d+)*(?:\\.\\d+)*-[\\w-]+$`, 'i');
|
|
44
47
|
// Extracts the full phase token from a directory name, including milestone-prefixed
|
|
45
48
|
// multi-segment tokens like "02-01" from "02-01-setup" or "GSD-02-01-setup".
|
|
46
49
|
// Greedily captures all leading all-digit segments before the first letter-start segment.
|
|
47
|
-
exports.PHASE_TOKEN_FROM_DIR_RE =
|
|
50
|
+
exports.PHASE_TOKEN_FROM_DIR_RE = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+(?:-\\d+)*[A-Z]?(?:\\.\\d+)*)(?:-[a-z]|$)`, 'i');
|
|
48
51
|
exports.MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i;
|
|
49
52
|
// ── Issue #26: I001 canonicalization ────────────────────────────────────────
|
|
50
53
|
function canonicalPlanStem(stem) {
|
|
@@ -50,7 +50,7 @@ const { getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone } = ro
|
|
|
50
50
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
51
51
|
const worktreeSafetyMod = require("./worktree-safety.cjs");
|
|
52
52
|
const { inspectWorktreeHealth } = worktreeSafetyMod;
|
|
53
|
-
const { planningDir } = planningWorkspace;
|
|
53
|
+
const { planningDir, planningRoot } = planningWorkspace;
|
|
54
54
|
const { extractFrontmatter, parseMustHavesBlock } = frontmatterMod;
|
|
55
55
|
const { writeStateMd } = stateMod;
|
|
56
56
|
const { MODEL_PROFILES } = modelProfilesMod;
|
|
@@ -1112,12 +1112,18 @@ function cmdValidateHealth(cwd, options, raw) {
|
|
|
1112
1112
|
}, raw);
|
|
1113
1113
|
return;
|
|
1114
1114
|
}
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
const
|
|
1118
|
-
const
|
|
1119
|
-
|
|
1120
|
-
|
|
1115
|
+
// rootBase always resolves to .planning/ (shared root — PROJECT.md, config.json live here)
|
|
1116
|
+
// wsBase resolves to .planning/workstreams/<ws>/ when GSD_WORKSTREAM is set (STATE.md, ROADMAP.md, phases/)
|
|
1117
|
+
const rootBase = planningRoot(cwd);
|
|
1118
|
+
const wsBase = planningDir(cwd);
|
|
1119
|
+
// planBase is kept as an alias for wsBase for all the internal helpers (collectDiskPhases, etc.)
|
|
1120
|
+
// that are already parameterised on the workstream-aware path.
|
|
1121
|
+
const planBase = wsBase;
|
|
1122
|
+
const projectPath = node_path_1.default.join(rootBase, 'PROJECT.md');
|
|
1123
|
+
const roadmapPath = node_path_1.default.join(wsBase, 'ROADMAP.md');
|
|
1124
|
+
const statePath = node_path_1.default.join(wsBase, 'STATE.md');
|
|
1125
|
+
const configPath = node_path_1.default.join(rootBase, 'config.json');
|
|
1126
|
+
const phasesDir = node_path_1.default.join(wsBase, 'phases');
|
|
1121
1127
|
const _slashRuntime = (0, runtime_slash_cjs_1.resolveRuntime)(cwd);
|
|
1122
1128
|
const slash = (name) => (0, runtime_slash_cjs_1.formatGsdSlash)(name, _slashRuntime);
|
|
1123
1129
|
const errors = [];
|
|
@@ -1133,7 +1139,7 @@ function cmdValidateHealth(cwd, options, raw) {
|
|
|
1133
1139
|
else
|
|
1134
1140
|
info.push(issue);
|
|
1135
1141
|
};
|
|
1136
|
-
if (!node_fs_1.default.existsSync(
|
|
1142
|
+
if (!node_fs_1.default.existsSync(rootBase)) {
|
|
1137
1143
|
addIssue('error', 'E001', '.planning/ directory not found', `Run ${slash('new-project')} to initialize`);
|
|
1138
1144
|
output({ status: 'broken', errors, warnings, info, repairable_count: 0 }, raw);
|
|
1139
1145
|
return;
|
|
@@ -1404,7 +1410,17 @@ function cmdValidateHealth(cwd, options, raw) {
|
|
|
1404
1410
|
continue;
|
|
1405
1411
|
}
|
|
1406
1412
|
if (finding['kind'] === 'stale') {
|
|
1407
|
-
|
|
1413
|
+
// Do not flag the active session's worktree — removing it would be harmful.
|
|
1414
|
+
const worktreePath = finding['path'];
|
|
1415
|
+
const activeCwd = process.cwd();
|
|
1416
|
+
const normalizedWorktree = node_path_1.default.resolve(worktreePath);
|
|
1417
|
+
const normalizedCwd = node_path_1.default.resolve(activeCwd);
|
|
1418
|
+
// Skip if the worktree IS the cwd or is an ancestor of it.
|
|
1419
|
+
const isActiveWorktree = normalizedCwd === normalizedWorktree ||
|
|
1420
|
+
normalizedCwd.startsWith(normalizedWorktree + node_path_1.default.sep);
|
|
1421
|
+
if (isActiveWorktree)
|
|
1422
|
+
continue;
|
|
1423
|
+
addIssue('warning', 'W017', `Stale git worktree: ${worktreePath} (last modified ${finding['ageMinutes']} minutes ago)`, `Run: git worktree remove ${worktreePath} --force`);
|
|
1408
1424
|
}
|
|
1409
1425
|
}
|
|
1410
1426
|
}
|
|
@@ -1440,8 +1456,8 @@ function cmdValidateHealth(cwd, options, raw) {
|
|
|
1440
1456
|
catch {
|
|
1441
1457
|
/* W021 check is advisory — skip on error */
|
|
1442
1458
|
}
|
|
1443
|
-
const milestonesPath = node_path_1.default.join(
|
|
1444
|
-
const milestonesArchiveDir = node_path_1.default.join(
|
|
1459
|
+
const milestonesPath = node_path_1.default.join(rootBase, 'MILESTONES.md');
|
|
1460
|
+
const milestonesArchiveDir = node_path_1.default.join(rootBase, 'milestones');
|
|
1445
1461
|
const missingFromRegistry = [];
|
|
1446
1462
|
try {
|
|
1447
1463
|
if (node_fs_1.default.existsSync(milestonesArchiveDir)) {
|
|
@@ -1470,7 +1486,7 @@ function cmdValidateHealth(cwd, options, raw) {
|
|
|
1470
1486
|
/* intentionally empty — milestone sync check is advisory */
|
|
1471
1487
|
}
|
|
1472
1488
|
try {
|
|
1473
|
-
const entries = node_fs_1.default.readdirSync(
|
|
1489
|
+
const entries = node_fs_1.default.readdirSync(rootBase, { withFileTypes: true });
|
|
1474
1490
|
for (const entry of entries) {
|
|
1475
1491
|
if (!entry.isFile())
|
|
1476
1492
|
continue;
|
|
@@ -1566,7 +1582,7 @@ function cmdValidateHealth(cwd, options, raw) {
|
|
|
1566
1582
|
}
|
|
1567
1583
|
const milestone = getMilestoneInfo(cwd);
|
|
1568
1584
|
const projectRef = node_path_1.default
|
|
1569
|
-
.relative(cwd, node_path_1.default.join(
|
|
1585
|
+
.relative(cwd, node_path_1.default.join(rootBase, 'PROJECT.md'))
|
|
1570
1586
|
.split(node_path_1.default.sep)
|
|
1571
1587
|
.join('/');
|
|
1572
1588
|
let stateContent = `# Session State\n\n`;
|
|
@@ -1871,8 +1887,17 @@ function cmdVerifyCodebaseDrift(cwd, raw) {
|
|
|
1871
1887
|
else if (status === 'D')
|
|
1872
1888
|
deleted.push(file);
|
|
1873
1889
|
}
|
|
1874
|
-
|
|
1875
|
-
|
|
1890
|
+
// loadConfig() returns a flattened object — there is no nested `workflow`
|
|
1891
|
+
// key. Read the raw config.json directly to access workflow-scoped keys,
|
|
1892
|
+
// matching the pattern used in check-command-router.cts:readWorkflowConfig.
|
|
1893
|
+
let wf;
|
|
1894
|
+
try {
|
|
1895
|
+
const rawCfg = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(planningDir(cwd), 'config.json'), 'utf-8'));
|
|
1896
|
+
wf = rawCfg['workflow'];
|
|
1897
|
+
}
|
|
1898
|
+
catch {
|
|
1899
|
+
wf = undefined;
|
|
1900
|
+
}
|
|
1876
1901
|
const threshold = Number.isInteger(wf?.drift_threshold) && wf?.drift_threshold >= 1
|
|
1877
1902
|
? wf?.drift_threshold
|
|
1878
1903
|
: 3;
|
|
@@ -693,6 +693,206 @@ function cmdWorktreeCleanupWave(cwd, args = []) {
|
|
|
693
693
|
process.exitCode = 1;
|
|
694
694
|
}
|
|
695
695
|
}
|
|
696
|
+
/**
|
|
697
|
+
* Pure planner for the per-agent wave-manifest append.
|
|
698
|
+
*
|
|
699
|
+
* Validates the candidate entry at write time using the SAME rules the
|
|
700
|
+
* cleanup-wave reader enforces (via `normalizeCleanupManifestEntry`), so an
|
|
701
|
+
* entry that `record-agent` accepts is guaranteed to survive
|
|
702
|
+
* `normalizeCleanupManifest` on read — a field that would be silently dropped
|
|
703
|
+
* at cleanup time fails loudly here instead.
|
|
704
|
+
*
|
|
705
|
+
* `agent_id` is treated write-strict (required) even though the reader is
|
|
706
|
+
* lenient (nullable): the whole point of this verb is to catch an
|
|
707
|
+
* under-populated entry at write time, and an entry whose author cannot be
|
|
708
|
+
* identified defeats that. A duplicate `(worktree_path, branch)` is also
|
|
709
|
+
* rejected loudly — the reader dedups on that key, so a re-record would be
|
|
710
|
+
* silently dropped (the failure mode this verb exists to eliminate). The
|
|
711
|
+
* on-disk shape stays the existing 4-field entry (`agent_id`, `worktree_path`,
|
|
712
|
+
* `branch`, `expected_base`) — no schema change; the reader re-derives
|
|
713
|
+
* `allowed_bases`.
|
|
714
|
+
*/
|
|
715
|
+
function planWorktreeRecordAgent(manifestRaw, fields) {
|
|
716
|
+
// 1. Write-strict required-field check (loud, with which flag is missing).
|
|
717
|
+
// Trim first so a whitespace-only value (" ") is rejected here rather
|
|
718
|
+
// than deferred to a guaranteed `git worktree remove` failure at cleanup.
|
|
719
|
+
const agentId = (fields.agentId || '').trim();
|
|
720
|
+
const worktreePath = (fields.worktreePath || '').trim();
|
|
721
|
+
const branch = (fields.branch || '').trim();
|
|
722
|
+
const base = (fields.base || '').trim();
|
|
723
|
+
const missing = [];
|
|
724
|
+
if (!agentId)
|
|
725
|
+
missing.push('--agent-id');
|
|
726
|
+
if (!worktreePath)
|
|
727
|
+
missing.push('--path');
|
|
728
|
+
if (!branch)
|
|
729
|
+
missing.push('--branch');
|
|
730
|
+
if (!base)
|
|
731
|
+
missing.push('--base');
|
|
732
|
+
if (missing.length > 0) {
|
|
733
|
+
return {
|
|
734
|
+
ok: false,
|
|
735
|
+
reason: 'missing_field',
|
|
736
|
+
hint: `record-agent requires ${missing.join(', ')}. Re-run with all of --agent-id, --path, --branch, --base set to non-empty (non-whitespace) values.`,
|
|
737
|
+
entry: null,
|
|
738
|
+
manifest: null,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
// 2. Shared validation: run the candidate through the reader's normalizer.
|
|
742
|
+
// If it returns null the reader would drop this entry on read — reject now.
|
|
743
|
+
const candidate = {
|
|
744
|
+
agent_id: agentId,
|
|
745
|
+
worktree_path: worktreePath,
|
|
746
|
+
branch,
|
|
747
|
+
expected_base: base,
|
|
748
|
+
};
|
|
749
|
+
const entry = normalizeCleanupManifestEntry(candidate);
|
|
750
|
+
if (!entry) {
|
|
751
|
+
return {
|
|
752
|
+
ok: false,
|
|
753
|
+
reason: 'invalid_entry',
|
|
754
|
+
hint: `Entry failed cleanup-manifest validation: --path/--branch/--base must be non-empty and --branch must match ^worktree-agent-[A-Za-z0-9._/-]+$ (got branch="${branch}"). Fix the field and re-run.`,
|
|
755
|
+
entry: null,
|
|
756
|
+
manifest: null,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
// 3. Parse the existing manifest. The init shell ({orchestrator_root, worktrees: []})
|
|
760
|
+
// is written inline by the orchestrator before any agent spawns; a missing or
|
|
761
|
+
// malformed manifest is a loud failure here, not a silent under-populated write.
|
|
762
|
+
let parsed;
|
|
763
|
+
try {
|
|
764
|
+
parsed = JSON.parse(manifestRaw);
|
|
765
|
+
}
|
|
766
|
+
catch {
|
|
767
|
+
return {
|
|
768
|
+
ok: false,
|
|
769
|
+
reason: 'invalid_manifest_json',
|
|
770
|
+
hint: 'Manifest is not valid JSON. The orchestrator must initialize it as {"orchestrator_root": "...", "worktrees": []} before recording agents.',
|
|
771
|
+
entry: null,
|
|
772
|
+
manifest: null,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
// Accept the canonical {worktrees: []} shell or a bare top-level array (both
|
|
776
|
+
// are read by normalizeCleanupManifest); preserve any other top-level keys.
|
|
777
|
+
let worktrees;
|
|
778
|
+
let writeBack;
|
|
779
|
+
if (Array.isArray(parsed)) {
|
|
780
|
+
worktrees = parsed;
|
|
781
|
+
writeBack = worktrees;
|
|
782
|
+
}
|
|
783
|
+
else if (parsed && typeof parsed === 'object') {
|
|
784
|
+
const container = parsed;
|
|
785
|
+
if (container.worktrees === undefined)
|
|
786
|
+
container.worktrees = [];
|
|
787
|
+
if (!Array.isArray(container.worktrees)) {
|
|
788
|
+
return {
|
|
789
|
+
ok: false,
|
|
790
|
+
reason: 'manifest_shape_invalid',
|
|
791
|
+
hint: 'Manifest "worktrees" must be an array. Re-initialize as {"orchestrator_root": "...", "worktrees": []}.',
|
|
792
|
+
entry: null,
|
|
793
|
+
manifest: null,
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
worktrees = container.worktrees;
|
|
797
|
+
writeBack = container;
|
|
798
|
+
}
|
|
799
|
+
else {
|
|
800
|
+
return {
|
|
801
|
+
ok: false,
|
|
802
|
+
reason: 'manifest_shape_invalid',
|
|
803
|
+
hint: 'Manifest must be a JSON object {"worktrees": []} or a top-level array.',
|
|
804
|
+
entry: null,
|
|
805
|
+
manifest: null,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
// 4. Reject a duplicate (worktree_path, branch). The reader dedups on this
|
|
809
|
+
// exact key, but only over entries that NORMALIZE successfully — so an
|
|
810
|
+
// existing malformed same-key entry (which the reader would drop) must NOT
|
|
811
|
+
// block recording a valid one. Run each existing entry through the reader's
|
|
812
|
+
// own normalizer and compare only the entries the reader would keep; this
|
|
813
|
+
// matches its dedup behavior exactly. A real duplicate signals an upstream
|
|
814
|
+
// double-spawn — surface it loudly instead of silently dropping it.
|
|
815
|
+
const dupKey = `${entry.worktree_path}\0${entry.branch}`;
|
|
816
|
+
const isDuplicate = worktrees.some((existing) => {
|
|
817
|
+
const normalized = normalizeCleanupManifestEntry(existing);
|
|
818
|
+
return normalized !== null && `${normalized.worktree_path}\0${normalized.branch}` === dupKey;
|
|
819
|
+
});
|
|
820
|
+
if (isDuplicate) {
|
|
821
|
+
return {
|
|
822
|
+
ok: false,
|
|
823
|
+
reason: 'duplicate_entry',
|
|
824
|
+
hint: `The manifest already records worktree_path="${entry.worktree_path}" branch="${entry.branch}". The cleanup reader dedups on (worktree_path, branch), so re-recording would be silently dropped — this usually signals an upstream double-spawn. Investigate rather than re-record.`,
|
|
825
|
+
entry: null,
|
|
826
|
+
manifest: null,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
// 5. Append the minimal 4-field entry, matching the existing on-disk format.
|
|
830
|
+
const recorded = {
|
|
831
|
+
agent_id: entry.agent_id,
|
|
832
|
+
worktree_path: entry.worktree_path,
|
|
833
|
+
branch: entry.branch,
|
|
834
|
+
expected_base: entry.expected_base,
|
|
835
|
+
};
|
|
836
|
+
worktrees.push(recorded);
|
|
837
|
+
return {
|
|
838
|
+
ok: true,
|
|
839
|
+
reason: 'ok',
|
|
840
|
+
entry: recorded,
|
|
841
|
+
manifest: `${JSON.stringify(writeBack, null, 2)}\n`,
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* CLI command: append a validated per-agent entry to a wave cleanup manifest.
|
|
846
|
+
*
|
|
847
|
+
* Usage: worktree record-agent --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>
|
|
848
|
+
*
|
|
849
|
+
* Fails loudly (non-zero exit + recovery hint on stderr) when a field is
|
|
850
|
+
* missing/garbled or the manifest is absent/malformed, rather than appending an
|
|
851
|
+
* under-populated entry that the cleanup reader would silently drop.
|
|
852
|
+
*/
|
|
853
|
+
function cmdWorktreeRecordAgent(cwd, args = [], deps = {}) {
|
|
854
|
+
const flag = (name) => {
|
|
855
|
+
const i = args.indexOf(name);
|
|
856
|
+
return i >= 0 && i + 1 < args.length ? args[i + 1] : '';
|
|
857
|
+
};
|
|
858
|
+
const write = deps.write || ((s) => process.stdout.write(s));
|
|
859
|
+
const writeErr = deps.writeErr || ((s) => process.stderr.write(s));
|
|
860
|
+
const manifestPath = flag('--manifest');
|
|
861
|
+
if (!manifestPath) {
|
|
862
|
+
writeErr('Usage: worktree record-agent --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>\n');
|
|
863
|
+
process.exitCode = 2;
|
|
864
|
+
return { ok: false, reason: 'usage', entry: null };
|
|
865
|
+
}
|
|
866
|
+
const resolved = node_path_1.default.resolve(cwd, manifestPath);
|
|
867
|
+
const readFile = deps.readFile || ((p) => node_fs_1.default.readFileSync(p, 'utf8'));
|
|
868
|
+
let manifestRaw;
|
|
869
|
+
try {
|
|
870
|
+
manifestRaw = readFile(resolved);
|
|
871
|
+
}
|
|
872
|
+
catch (err) {
|
|
873
|
+
const hint = `Manifest not found or unreadable at ${manifestPath}. The orchestrator must initialize it ({"orchestrator_root": "...", "worktrees": []}) before recording agents.`;
|
|
874
|
+
writeErr(`[gsd] worktree.record-agent: manifest_read_failed — ${hint}\n`);
|
|
875
|
+
write(`${JSON.stringify({ ok: false, reason: 'manifest_read_failed', hint, error: err.message }, null, 2)}\n`);
|
|
876
|
+
process.exitCode = 1;
|
|
877
|
+
return { ok: false, reason: 'manifest_read_failed', hint, entry: null };
|
|
878
|
+
}
|
|
879
|
+
const plan = planWorktreeRecordAgent(manifestRaw, {
|
|
880
|
+
agentId: flag('--agent-id'),
|
|
881
|
+
worktreePath: flag('--path'),
|
|
882
|
+
branch: flag('--branch'),
|
|
883
|
+
base: flag('--base'),
|
|
884
|
+
});
|
|
885
|
+
if (!plan.ok || plan.manifest === null) {
|
|
886
|
+
writeErr(`[gsd] worktree.record-agent: ${plan.reason} — ${plan.hint || ''}\n`);
|
|
887
|
+
write(`${JSON.stringify({ ok: false, reason: plan.reason, hint: plan.hint }, null, 2)}\n`);
|
|
888
|
+
process.exitCode = 1;
|
|
889
|
+
return { ok: false, reason: plan.reason, hint: plan.hint, entry: null };
|
|
890
|
+
}
|
|
891
|
+
const writeFile = deps.writeFile || ((p, content) => node_fs_1.default.writeFileSync(p, content, 'utf8'));
|
|
892
|
+
writeFile(resolved, plan.manifest);
|
|
893
|
+
write(`${JSON.stringify({ ok: true, reason: 'ok', entry: plan.entry, manifest_path: resolved }, null, 2)}\n`);
|
|
894
|
+
return { ok: true, reason: 'ok', entry: plan.entry, manifest_path: resolved };
|
|
895
|
+
}
|
|
696
896
|
/**
|
|
697
897
|
* Reap orphaned linked worktrees whose lock owner process is dead, whose
|
|
698
898
|
* branch tip is fully merged into the default branch, and whose lock file
|
|
@@ -978,6 +1178,8 @@ module.exports = {
|
|
|
978
1178
|
planWorktreeWaveCleanup,
|
|
979
1179
|
executeWorktreeWaveCleanupPlan,
|
|
980
1180
|
cmdWorktreeCleanupWave,
|
|
1181
|
+
planWorktreeRecordAgent,
|
|
1182
|
+
cmdWorktreeRecordAgent,
|
|
981
1183
|
reapOrphanWorktrees,
|
|
982
1184
|
cmdWorktreeReapOrphans,
|
|
983
1185
|
resolveWorktreeRoot,
|
|
@@ -53,7 +53,8 @@
|
|
|
53
53
|
"post_planning_gaps": true,
|
|
54
54
|
"security_enforcement": true,
|
|
55
55
|
"security_asvs_level": 1,
|
|
56
|
-
"security_block_on": "high"
|
|
56
|
+
"security_block_on": "high",
|
|
57
|
+
"context_guard_mode": "warn"
|
|
57
58
|
},
|
|
58
59
|
"planning": {
|
|
59
60
|
"commit_docs": true,
|
|
@@ -93,5 +94,9 @@
|
|
|
93
94
|
"plan_review": {
|
|
94
95
|
"source_grounding": true,
|
|
95
96
|
"source_grounding_authority": "grep"
|
|
97
|
+
},
|
|
98
|
+
"capabilities": {
|
|
99
|
+
"strict_known_registries": null,
|
|
100
|
+
"auto_update": false
|
|
96
101
|
}
|
|
97
102
|
}
|
|
@@ -55,6 +55,8 @@
|
|
|
55
55
|
"workflow.subagent_timeout",
|
|
56
56
|
"workflow.test_command",
|
|
57
57
|
"workflow.build_command",
|
|
58
|
+
"workflow.mvp_mode",
|
|
59
|
+
"workflow.context_guard_mode",
|
|
58
60
|
"executor.stall_detect_interval_minutes",
|
|
59
61
|
"executor.stall_threshold_minutes",
|
|
60
62
|
"workflow.inline_plan_threshold",
|
|
@@ -91,7 +93,9 @@
|
|
|
91
93
|
"model_policy.high",
|
|
92
94
|
"model_policy.medium",
|
|
93
95
|
"model_policy.low",
|
|
94
|
-
"agent_skills_security.trusted_global_roots"
|
|
96
|
+
"agent_skills_security.trusted_global_roots",
|
|
97
|
+
"capabilities.strict_known_registries",
|
|
98
|
+
"capabilities.auto_update"
|
|
95
99
|
],
|
|
96
100
|
"runtimeStateKeys": [
|
|
97
101
|
"workflow._auto_chain_active"
|