@opengsd/gsd-core 1.19.0 → 1.20.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-code-fixer.compact.md +5 -5
- package/agents/gsd-code-fixer.md +9 -4
- package/agents/gsd-code-reviewer.compact.md +49 -8
- package/agents/gsd-code-reviewer.md +53 -14
- package/agents/gsd-executor.md +8 -4
- package/bin/install.js +36 -40
- package/commands/gsd/new-milestone.md +2 -2
- package/commands/gsd/new-project.md +2 -2
- package/commands/gsd/update.md +5 -2
- package/gsd-core/bin/gsd-tools.cjs +177 -93
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
- package/gsd-core/bin/lib/capability-registry.cjs +284 -93
- package/gsd-core/bin/lib/capability-validator.cjs +15 -0
- package/gsd-core/bin/lib/commands.cjs +46 -17
- package/gsd-core/bin/lib/config-loader.cjs +3 -3
- package/gsd-core/bin/lib/core-utils.cjs +11 -0
- package/gsd-core/bin/lib/frontmatter.cjs +21 -3
- package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
- package/gsd-core/bin/lib/gate-context-drift.cjs +1 -1
- package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
- package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
- package/gsd-core/bin/lib/git-info-exclude.cjs +1 -6
- package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +13 -9
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
- package/gsd-core/bin/lib/init.cjs +181 -68
- package/gsd-core/bin/lib/install-engine.cjs +9 -9
- package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
- package/gsd-core/bin/lib/install-profiles.cjs +4 -2
- package/gsd-core/bin/lib/io.cjs +6 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +34 -1
- package/gsd-core/bin/lib/markdown-table.cjs +23 -0
- package/gsd-core/bin/lib/mcp-server.cjs +8 -5
- package/gsd-core/bin/lib/milestone.cjs +18 -50
- package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
- package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
- package/gsd-core/bin/lib/phase-id.cjs +627 -36
- package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -74
- package/gsd-core/bin/lib/phase-locator.cjs +328 -19
- package/gsd-core/bin/lib/phase.cjs +3039 -666
- package/gsd-core/bin/lib/planning-document.cjs +26 -5
- package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
- package/gsd-core/bin/lib/planning-snapshot.cjs +12 -7
- package/gsd-core/bin/lib/planning-workspace.cjs +3 -3
- package/gsd-core/bin/lib/read-only-git-env.cjs +23 -2
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
- package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
- package/gsd-core/bin/lib/roadmap-parser.cjs +759 -144
- package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +8 -5
- package/gsd-core/bin/lib/roadmap.cjs +358 -106
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +310 -130
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +36 -9
- package/gsd-core/bin/lib/smart-entry.cjs +22 -11
- package/gsd-core/bin/lib/state-contract.cjs +11 -7
- package/gsd-core/bin/lib/state-document.cjs +332 -23
- package/gsd-core/bin/lib/state-transition.cjs +51 -15
- package/gsd-core/bin/lib/state.cjs +499 -180
- package/gsd-core/bin/lib/template.cjs +2 -2
- package/gsd-core/bin/lib/validate.cjs +3 -2
- package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
- package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
- package/gsd-core/bin/lib/verify.cjs +2 -2
- package/gsd-core/bin/lib/workstream-inventory.cjs +27 -21
- package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
- package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
- package/gsd-core/references/cicd-review-patterns.md +45 -0
- package/gsd-core/references/necessity-review.md +48 -0
- package/gsd-core/workflows/code-review-fix.md +46 -5
- package/gsd-core/workflows/code-review.md +3 -0
- package/gsd-core/workflows/execute-phase.md +1 -1
- package/gsd-core/workflows/execute-plan.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
- package/gsd-core/workflows/plan-phase.md +1 -1
- package/gsd-core/workflows/pr-branch.md +2 -0
- package/gsd-core/workflows/reapply-patches.md +104 -0
- package/gsd-core/workflows/review.md +54 -141
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/settings-advanced.md +57 -9
- package/gsd-core/workflows/ship.md +12 -0
- package/hooks/dist/gsd-agent-isolation-guard.js +1 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +1 -1
- package/hooks/dist/gsd-graphify-update.sh +6 -0
- package/hooks/dist/gsd-statusline.js +3 -2
- package/hooks/dist/gsd-windsurf-pre-write.js +4 -4
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/gsd-worktree-path-guard.js +4 -4
- package/hooks/dist/lib/git-probe.js +16 -0
- package/hooks/dist/lib/isolation-sentinel.js +77 -2
- package/hooks/gsd-agent-isolation-guard.js +1 -1
- package/hooks/gsd-cursor-subagent-start.js +1 -1
- package/hooks/gsd-graphify-update.sh +6 -0
- package/hooks/gsd-statusline.js +3 -2
- package/hooks/gsd-windsurf-pre-write.js +4 -4
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/gsd-worktree-path-guard.js +4 -4
- package/hooks/lib/git-probe.js +16 -0
- package/hooks/lib/isolation-sentinel.js +77 -2
- package/package.json +3 -3
- package/pi/package-agents/gsd-code-fixer.md +9 -4
- package/pi/package-agents/gsd-code-reviewer.md +53 -14
- package/pi/package-agents/gsd-executor.md +8 -4
- package/pi/package-agents/manifest.json +3 -3
- package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
- package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
- package/pi/package-skills/gsd-update/SKILL.md +4 -1
- package/scripts/changeset/lint.cjs +28 -3
- package/scripts/ci-next-health.cjs +246 -23
- package/scripts/docs-guard-registry.cjs +2 -0
- package/scripts/gen-features.cjs +14 -2
- package/scripts/lib/drift-scan.cjs +27 -0
- package/scripts/lib/macos-conformance-tier.generated.cjs +10 -0
- package/scripts/lib/platform-conformance-tier.generated.cjs +15 -0
- package/scripts/lint-phase-enumeration-drift.cjs +8 -4
- package/scripts/lint-roadmap-progress-drift.cjs +421 -0
- package/scripts/merge-commit-pr.cjs +186 -0
- package/scripts/mutation-matrix.cjs +23 -1
- package/scripts/release-ancestry.cjs +86 -0
- package/scripts/release-pipeline-pr.cjs +92 -0
- package/scripts/require-issue-link-policy.cjs +18 -9
- package/scripts/sync-next-version.cjs +48 -7
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-new-project/SKILL.md +1 -1
- package/skills/gsd-update/SKILL.md +5 -2
- package/vscode/package.json +1 -1
|
@@ -1874,6 +1874,11 @@ const KNOWN_REVIEWER_FIELDS = new Set([
|
|
|
1874
1874
|
// effort", which emits no effort argument at all.
|
|
1875
1875
|
'effortConfigKey',
|
|
1876
1876
|
'defaultEffort',
|
|
1877
|
+
// `contextWindowTokens` / `contextWindowConfigKey` added by #4559, same optional/backward-compatible
|
|
1878
|
+
// shape as `promptBudgetKey`: a manifest authored before they existed keeps validating, and an
|
|
1879
|
+
// absent or non-positive window resolves to "no derived prompt cap" at the invocation boundary.
|
|
1880
|
+
'contextWindowTokens',
|
|
1881
|
+
'contextWindowConfigKey',
|
|
1877
1882
|
'handler',
|
|
1878
1883
|
]);
|
|
1879
1884
|
|
|
@@ -2505,6 +2510,16 @@ function validateReviewerBodyFields(cap) {
|
|
|
2505
2510
|
);
|
|
2506
2511
|
}
|
|
2507
2512
|
|
|
2513
|
+
// #4559: optional. Absent or null is "no declared window"; a present key must be a dotted
|
|
2514
|
+
// config key string. The window's VALUE is narrowed (never rejected) by `resolveLaneCap`.
|
|
2515
|
+
if (r.contextWindowConfigKey !== undefined && r.contextWindowConfigKey !== null
|
|
2516
|
+
&& (typeof r.contextWindowConfigKey !== 'string' || r.contextWindowConfigKey.length === 0)) {
|
|
2517
|
+
errors.push(
|
|
2518
|
+
ctx + ' reviewer.contextWindowConfigKey must be a dotted config key or null ' +
|
|
2519
|
+
'(got: ' + describeValue(r.contextWindowConfigKey) + ')',
|
|
2520
|
+
);
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2508
2523
|
// ── handler (D6) — closed first-party enum; null is the default ────────────
|
|
2509
2524
|
if (r.handler !== null && !VALID_LANE_HANDLERS.has(r.handler)) {
|
|
2510
2525
|
errors.push(
|
|
@@ -14,6 +14,7 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
14
14
|
const text_lines_cjs_1 = require("./text-lines.cjs");
|
|
15
15
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
16
16
|
const pattern_cjs_1 = require("./pattern.cjs");
|
|
17
|
+
const install_manifest_record_cjs_1 = require("./install-manifest-record.cjs");
|
|
17
18
|
const frontmatter_fence_cjs_1 = require("./frontmatter-fence.cjs");
|
|
18
19
|
const security_cjs_1 = require("./security.cjs");
|
|
19
20
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -31,13 +32,13 @@ const coreUtilsMod = require("./core-utils.cjs");
|
|
|
31
32
|
const { toPosixPath, generateSlugInternal, extractOneLinerFromBody } = coreUtilsMod;
|
|
32
33
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
33
34
|
const phaseIdMod = require("./phase-id.cjs");
|
|
34
|
-
const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId, renderPhaseBranchName, parsePhaseId, renderPhaseId, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, } = phaseIdMod;
|
|
35
|
+
const { customPhaseQuery, customDirectoryOwner, isCustomPhaseId, normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId, renderPhaseBranchName, parsePhaseId, renderPhaseId, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, } = phaseIdMod;
|
|
35
36
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
36
37
|
const phaseIdDisplayMod = require("./phase-id-display.cjs");
|
|
37
38
|
const { renderBracketMilestoneDisplay } = phaseIdDisplayMod;
|
|
38
39
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
39
40
|
const phaseLocatorMod = require("./phase-locator.cjs");
|
|
40
|
-
const { getArchivedPhaseDirs, findPhaseInternal, listMilestonePhaseDirs } = phaseLocatorMod;
|
|
41
|
+
const { phaseDirectoryOptions, getArchivedPhaseDirs, findPhaseInternal, listMilestonePhaseDirs } = phaseLocatorMod;
|
|
41
42
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
42
43
|
const roadmapParserMod = require("./roadmap-parser.cjs");
|
|
43
44
|
const { extractCurrentMilestone, stripShippedMilestones: _stripShippedMilestones, getMilestoneInfo, getRoadmapPhaseInternal } = roadmapParserMod;
|
|
@@ -835,6 +836,7 @@ function cmdEffortSync(cwd, raw, opts) {
|
|
|
835
836
|
// cmdEffortSyncCodex and cmdEffortSyncOpencode, so a fault mid-write can
|
|
836
837
|
// never leave an agent file truncated or empty.
|
|
837
838
|
let fileFailureCount = 0;
|
|
839
|
+
const rewrittenFiles = [];
|
|
838
840
|
for (const file of files) {
|
|
839
841
|
const agentName = file.replace(/\.md$/, '');
|
|
840
842
|
const filePath = node_path_1.default.join(agentsDir, file);
|
|
@@ -852,6 +854,8 @@ function cmdEffortSync(cwd, raw, opts) {
|
|
|
852
854
|
fileFailureCount++;
|
|
853
855
|
continue;
|
|
854
856
|
}
|
|
857
|
+
// #5683: hash BEFORE any rewrite — only a GSD-owned file gets re-recorded.
|
|
858
|
+
const preHash = (0, install_manifest_record_cjs_1.tryFileHash)(filePath);
|
|
855
859
|
// Resolve using install-time logic: home defaults merged with project config.
|
|
856
860
|
const universalEffort = resolveInstallTimeEffort(effortCfg, agentName);
|
|
857
861
|
// #3533 (10d): 'inherit' means the key must NOT exist. An absent key is
|
|
@@ -915,6 +919,7 @@ function cmdEffortSync(cwd, raw, opts) {
|
|
|
915
919
|
}
|
|
916
920
|
catch { /* non-fatal: proceed with default tmp-file mode */ }
|
|
917
921
|
(0, shell_command_projection_cjs_1.retryRenameSync)(tmpPathInherit, filePath);
|
|
922
|
+
rewrittenFiles.push({ path: filePath, preHash });
|
|
918
923
|
}
|
|
919
924
|
catch {
|
|
920
925
|
try {
|
|
@@ -992,6 +997,7 @@ function cmdEffortSync(cwd, raw, opts) {
|
|
|
992
997
|
}
|
|
993
998
|
catch { /* non-fatal: proceed with default tmp-file mode */ }
|
|
994
999
|
(0, shell_command_projection_cjs_1.retryRenameSync)(tmpPathSet, filePath);
|
|
1000
|
+
rewrittenFiles.push({ path: filePath, preHash });
|
|
995
1001
|
}
|
|
996
1002
|
catch {
|
|
997
1003
|
try {
|
|
@@ -1006,6 +1012,9 @@ function cmdEffortSync(cwd, raw, opts) {
|
|
|
1006
1012
|
changes.push({ agent: agentName, from: currentEffort, to: newEffortValue });
|
|
1007
1013
|
synced++;
|
|
1008
1014
|
}
|
|
1015
|
+
// #5683: keep the install manifest in step with the agents just rewritten.
|
|
1016
|
+
if (!dryRun)
|
|
1017
|
+
(0, install_manifest_record_cjs_1.recordManifestFileHashes)(node_path_1.default.dirname(agentsDir), rewrittenFiles);
|
|
1009
1018
|
output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir }, raw, fileFailureCount > 0 ? 'failed' : synced > 0 ? 'changed' : 'ok');
|
|
1010
1019
|
}
|
|
1011
1020
|
/**
|
|
@@ -1048,6 +1057,7 @@ function cmdEffortSyncCodex(raw, dryRun, configDir) {
|
|
|
1048
1057
|
const refused = [];
|
|
1049
1058
|
const writeFailures = [];
|
|
1050
1059
|
const readFailures = [];
|
|
1060
|
+
const rewrittenFiles = [];
|
|
1051
1061
|
let synced = 0;
|
|
1052
1062
|
let skipped = 0;
|
|
1053
1063
|
for (const file of files) {
|
|
@@ -1066,6 +1076,7 @@ function cmdEffortSyncCodex(raw, dryRun, configDir) {
|
|
|
1066
1076
|
readFailures.push({ agent: agentName, file: filePath, error: err instanceof Error ? err.message : String(err) });
|
|
1067
1077
|
continue;
|
|
1068
1078
|
}
|
|
1079
|
+
const preHash = (0, install_manifest_record_cjs_1.tryFileHash)(filePath); // #5683
|
|
1069
1080
|
const parsed = (0, codex_agent_toml_cjs_1.parseCodexAgentToml)(content);
|
|
1070
1081
|
if (!parsed.ok) {
|
|
1071
1082
|
// Never partially rewritten (40-design.md, ADR-2313 reader/writer
|
|
@@ -1134,6 +1145,7 @@ function cmdEffortSyncCodex(raw, dryRun, configDir) {
|
|
|
1134
1145
|
}
|
|
1135
1146
|
catch { /* non-fatal: proceed with default tmp-file mode */ }
|
|
1136
1147
|
(0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, filePath);
|
|
1148
|
+
rewrittenFiles.push({ path: filePath, preHash });
|
|
1137
1149
|
}
|
|
1138
1150
|
catch (err) {
|
|
1139
1151
|
// Reported, not thrown — the remaining agents still get processed.
|
|
@@ -1150,6 +1162,8 @@ function cmdEffortSyncCodex(raw, dryRun, configDir) {
|
|
|
1150
1162
|
changes.push(...pendingChanges);
|
|
1151
1163
|
synced++;
|
|
1152
1164
|
}
|
|
1165
|
+
if (!dryRun)
|
|
1166
|
+
(0, install_manifest_record_cjs_1.recordManifestFileHashes)(node_path_1.default.dirname(agentsDir), rewrittenFiles);
|
|
1153
1167
|
output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir, refused, write_failures: writeFailures, read_failures: readFailures }, raw, writeFailures.length > 0 || readFailures.length > 0 ? 'failed' : synced > 0 ? 'changed' : 'ok');
|
|
1154
1168
|
}
|
|
1155
1169
|
/**
|
|
@@ -1198,6 +1212,7 @@ function cmdEffortSyncOpencode(cwd, raw, dryRun, configDir) {
|
|
|
1198
1212
|
const changes = [];
|
|
1199
1213
|
const writeFailures = [];
|
|
1200
1214
|
const readFailures = [];
|
|
1215
|
+
const rewrittenFiles = [];
|
|
1201
1216
|
let synced = 0;
|
|
1202
1217
|
let skipped = 0;
|
|
1203
1218
|
for (const file of files) {
|
|
@@ -1216,6 +1231,7 @@ function cmdEffortSyncOpencode(cwd, raw, dryRun, configDir) {
|
|
|
1216
1231
|
readFailures.push({ agent: agentName, file: filePath, error: err instanceof Error ? err.message : String(err) });
|
|
1217
1232
|
continue;
|
|
1218
1233
|
}
|
|
1234
|
+
const preHash = (0, install_manifest_record_cjs_1.tryFileHash)(filePath); // #5683
|
|
1219
1235
|
// `target === null` covers both "no effort configured" (inherit) and "a
|
|
1220
1236
|
// level OpenCode does not accept" — both must produce NO `variant:` key,
|
|
1221
1237
|
// exactly what install writes.
|
|
@@ -1312,6 +1328,7 @@ function cmdEffortSyncOpencode(cwd, raw, dryRun, configDir) {
|
|
|
1312
1328
|
}
|
|
1313
1329
|
catch { /* non-fatal: proceed with default tmp-file mode */ }
|
|
1314
1330
|
(0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, filePath);
|
|
1331
|
+
rewrittenFiles.push({ path: filePath, preHash });
|
|
1315
1332
|
}
|
|
1316
1333
|
catch (err) {
|
|
1317
1334
|
try {
|
|
@@ -1340,6 +1357,8 @@ function cmdEffortSyncOpencode(cwd, raw, dryRun, configDir) {
|
|
|
1340
1357
|
// Making the exit code reflect failure would be a CLI-contract change
|
|
1341
1358
|
// affecting all three cmdEffortSync* branches (claude, codex, opencode) and
|
|
1342
1359
|
// is out of scope for this fix.
|
|
1360
|
+
if (!dryRun)
|
|
1361
|
+
(0, install_manifest_record_cjs_1.recordManifestFileHashes)(node_path_1.default.dirname(agentsDir), rewrittenFiles);
|
|
1343
1362
|
output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir, write_failures: writeFailures, read_failures: readFailures }, raw, writeFailures.length > 0 || readFailures.length > 0 ? 'failed' : synced > 0 ? 'changed' : 'ok');
|
|
1344
1363
|
}
|
|
1345
1364
|
/**
|
|
@@ -1822,7 +1841,7 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify, filesRemoved) {
|
|
|
1822
1841
|
// phase — the phase arm must never branch-mutate for it (isSentinelPhaseId
|
|
1823
1842
|
// is the invariant's single owner, src/phase-id.cts).
|
|
1824
1843
|
if (phaseNum && !isSentinelPhaseId(phaseNum)) {
|
|
1825
|
-
const phaseInfo = findPhaseInternal(cwd, phaseNum);
|
|
1844
|
+
const phaseInfo = findPhaseInternal(cwd, phaseNum, undefined, phaseDirectoryOptions(cwd, config));
|
|
1826
1845
|
if (phaseInfo) {
|
|
1827
1846
|
// #4126: shared with init.cts's cmdInitExecutePhase branch_name field
|
|
1828
1847
|
// via the one canonical renderer (src/phase-id.cts) so an undeliverable
|
|
@@ -3053,6 +3072,7 @@ function cmdProgressRender(cwd, format, raw) {
|
|
|
3053
3072
|
? renderBracketMilestoneDisplay(milestone?.version, loadConfig(cwd).project_code)
|
|
3054
3073
|
: null;
|
|
3055
3074
|
const milestoneVersion = milestoneDisplay ?? milestone?.version ?? null;
|
|
3075
|
+
const directoryContext = phaseDirectoryOptions(cwd);
|
|
3056
3076
|
const phases = [];
|
|
3057
3077
|
let totalPlans = 0;
|
|
3058
3078
|
let totalSummaries = 0;
|
|
@@ -3091,9 +3111,11 @@ function cmdProgressRender(cwd, format, raw) {
|
|
|
3091
3111
|
}
|
|
3092
3112
|
}
|
|
3093
3113
|
else {
|
|
3114
|
+
const custom = customPhaseQuery(dir, directoryContext);
|
|
3115
|
+
const owner = custom && customDirectoryOwner(dir, custom);
|
|
3094
3116
|
const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
|
|
3095
|
-
phaseNum = dm ? dm[1] : dir;
|
|
3096
|
-
phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
|
|
3117
|
+
phaseNum = owner ? owner.id : dm ? dm[1] : dir;
|
|
3118
|
+
phaseName = owner ? owner.slug.replace(/-/g, ' ') : dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
|
|
3097
3119
|
}
|
|
3098
3120
|
// #3183/#5060: canonical plan/summary counts and lifecycle status
|
|
3099
3121
|
// (root+nested, superseded-excluded, canonical pairing) from the
|
|
@@ -3212,7 +3234,7 @@ function cmdTodoMatchPhase(cwd, phase, raw) {
|
|
|
3212
3234
|
.map(w => w.replace(/[^a-z0-9]/g, ''))
|
|
3213
3235
|
.filter(w => w.length > 2 && !stopWords.has(w)));
|
|
3214
3236
|
// Find phase directory to get expected file paths
|
|
3215
|
-
const phaseInfoDisk = findPhaseInternal(cwd, phase);
|
|
3237
|
+
const phaseInfoDisk = findPhaseInternal(cwd, phase, undefined, phaseDirectoryOptions(cwd));
|
|
3216
3238
|
const phasePlans = [];
|
|
3217
3239
|
if (phaseInfoDisk && phaseInfoDisk['found']) {
|
|
3218
3240
|
try {
|
|
@@ -3394,7 +3416,7 @@ function cmdScaffold(cwd, type, options, raw) {
|
|
|
3394
3416
|
const padded = phase ? normalizePhaseName(phase) : '00';
|
|
3395
3417
|
const today = clock_cjs_1.realClock.localToday();
|
|
3396
3418
|
// Find phase directory
|
|
3397
|
-
const phaseInfo = phase ? findPhaseInternal(cwd, phase) : null;
|
|
3419
|
+
const phaseInfo = phase && type !== 'phase-dir' ? findPhaseInternal(cwd, phase, undefined, phaseDirectoryOptions(cwd)) : null;
|
|
3398
3420
|
const phaseDir = phaseInfo ? node_path_1.default.join(cwd, phaseInfo['directory']) : null;
|
|
3399
3421
|
if (phase && !phaseDir && type !== 'phase-dir') {
|
|
3400
3422
|
error(`Phase ${phase} directory not found`);
|
|
@@ -3422,14 +3444,14 @@ function cmdScaffold(cwd, type, options, raw) {
|
|
|
3422
3444
|
}
|
|
3423
3445
|
const slug = generateSlugInternal(name);
|
|
3424
3446
|
// #3287: apply project_code prefix to stay consistent with phase.add/phase.insert
|
|
3425
|
-
const scaffoldConfig =
|
|
3426
|
-
const
|
|
3427
|
-
const
|
|
3428
|
-
|
|
3429
|
-
const
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3447
|
+
const scaffoldConfig = phaseDirectoryOptions(cwd, undefined, '');
|
|
3448
|
+
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
3449
|
+
const roadmap = scaffoldConfig.phaseNaming === 'custom' && node_fs_1.default.existsSync(roadmapPath)
|
|
3450
|
+
? node_fs_1.default.readFileSync(roadmapPath, 'utf8') : '';
|
|
3451
|
+
const [{ dirPath }] = phaseLocatorMod.createPhaseDirectories(cwd, [{
|
|
3452
|
+
id: phase, slug: slug || '',
|
|
3453
|
+
directoryId: scaffoldConfig.phaseNaming === 'custom' && isCustomPhaseId(phase) ? phase : padded,
|
|
3454
|
+
}], { ...scaffoldConfig, roadmap }, false);
|
|
3433
3455
|
output({ created: true, directory: toPosixPath(node_path_1.default.relative(cwd, dirPath)), path: dirPath }, raw, dirPath);
|
|
3434
3456
|
return;
|
|
3435
3457
|
}
|
|
@@ -3728,12 +3750,19 @@ function cmdStats(cwd, format, raw) {
|
|
|
3728
3750
|
*/
|
|
3729
3751
|
function cmdCheckCommit(cwd, raw) {
|
|
3730
3752
|
const config = loadConfig(cwd);
|
|
3731
|
-
|
|
3753
|
+
// #5548: execGit scrubs the repo-location vars, but inside the pre-commit hook git sets them
|
|
3754
|
+
// (GIT_DIR/GIT_WORK_TREE of an explicit --git-dir commit, GIT_INDEX_FILE of a `-a` / `<path>`
|
|
3755
|
+
// commit) to name the repo being committed: hand them back or the guard finds no repo and allows.
|
|
3756
|
+
const hookEnv = {};
|
|
3757
|
+
for (const k of shell_command_projection_cjs_1.GIT_LOCATION_ENV_VARS)
|
|
3758
|
+
if (process.env[k] !== undefined)
|
|
3759
|
+
hookEnv[k] = process.env[k];
|
|
3760
|
+
const stagedResult = (0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only', '-z'], { cwd, env: hookEnv });
|
|
3732
3761
|
if (stagedResult.exitCode === 0) {
|
|
3733
3762
|
const files = stagedResult.stdout.split('\0').filter(Boolean);
|
|
3734
3763
|
const planningFiles = files.filter(f => f.startsWith('.planning/'));
|
|
3735
3764
|
if (planningFiles.length > 0) {
|
|
3736
|
-
const policy = resolveCommitDocsPolicy(config, detectPhaseNumberFromFiles(planningFiles), () => isGitIgnored(cwd, '.planning'));
|
|
3765
|
+
const policy = resolveCommitDocsPolicy(config, detectPhaseNumberFromFiles(planningFiles), () => isGitIgnored(cwd, '.planning', hookEnv));
|
|
3737
3766
|
if (!policy.resolved) {
|
|
3738
3767
|
error(`commit_docs is false but ${planningFiles.length} .planning/ file(s) are staged:\n` +
|
|
3739
3768
|
planningFiles.map(f => ` ${f}`).join('\n') +
|
|
@@ -213,16 +213,16 @@ function _deepMergeConfig(base, overlay) {
|
|
|
213
213
|
const _warnedUnknownConfigKeys = new Set();
|
|
214
214
|
// ─── Git utilities ────────────────────────────────────────────────────────────
|
|
215
215
|
const _gitIgnoredCache = new Map();
|
|
216
|
-
function isGitIgnored(cwd, targetPath) {
|
|
216
|
+
function isGitIgnored(cwd, targetPath, env) {
|
|
217
217
|
// #2206: strip trailing slashes — `git check-ignore` has a quirk where a
|
|
218
218
|
// CRLF .gitignore with blank lines falsely reports a trailing-slash path
|
|
219
219
|
// (e.g. `.planning/`) as ignored. Normalizing here protects every call site.
|
|
220
220
|
const normalized = targetPath.replace(/\/+$/, '');
|
|
221
|
-
const key = cwd + '::' + normalized;
|
|
221
|
+
const key = cwd + '::' + normalized + (env ? '::env:' + JSON.stringify(Object.entries(env).sort()) : '');
|
|
222
222
|
if (_gitIgnoredCache.has(key))
|
|
223
223
|
return _gitIgnoredCache.get(key);
|
|
224
224
|
// --no-index checks .gitignore rules regardless of whether the file is tracked.
|
|
225
|
-
const result = (0, shell_command_projection_cjs_1.execGit)(['check-ignore', '-q', '--no-index', '--', normalized], { cwd });
|
|
225
|
+
const result = (0, shell_command_projection_cjs_1.execGit)(['check-ignore', '-q', '--no-index', '--', normalized], { cwd, env });
|
|
226
226
|
const ignored = result.exitCode === 0;
|
|
227
227
|
_gitIgnoredCache.set(key, ignored);
|
|
228
228
|
return ignored;
|
|
@@ -42,6 +42,7 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
42
42
|
const frontmatter_fence_cjs_1 = require("./frontmatter-fence.cjs");
|
|
43
43
|
// pattern.cjs is a leaf module (escapeRegex only), so this adds no cycle.
|
|
44
44
|
const pattern_cjs_1 = require("./pattern.cjs");
|
|
45
|
+
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
45
46
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
46
47
|
const phaseIdModule = require("./phase-id.cjs");
|
|
47
48
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -553,6 +554,15 @@ function planRowPattern(planId, opts = {}) {
|
|
|
553
554
|
const lead = opts.anchored ? '^(\\s*-\\s*\\[)' : '(-\\s*\\[)';
|
|
554
555
|
return new RegExp(`${lead}${box}(\\]\\s*(?:\\*\\*)?${(0, pattern_cjs_1.escapeRegex)(planId)}${PLAN_ID_ROW_BOUNDARY_SOURCE}(?:\\*\\*)?)`, 'i');
|
|
555
556
|
}
|
|
557
|
+
/**
|
|
558
|
+
* Tick the first line-anchored unchecked row for `planId` in `text` (the
|
|
559
|
+
* `phase complete` / `roadmap update-plan-progress` bullet flip): a bullet
|
|
560
|
+
* mutation through `updateBullet`, so never a fenced line or mid-line match.
|
|
561
|
+
*/
|
|
562
|
+
function tickAnchoredPlanRow(text, planId) {
|
|
563
|
+
const row = planRowPattern(planId, { anchored: true });
|
|
564
|
+
return (0, markdown_sectionizer_cjs_1.updateBullet)(text, (_bulletText, rawLine) => row.test(rawLine), (rawLine) => rawLine.replace(row, '$1x$2'));
|
|
565
|
+
}
|
|
556
566
|
module.exports = {
|
|
557
567
|
toPosixPath,
|
|
558
568
|
normalizeLineEndings,
|
|
@@ -570,4 +580,5 @@ module.exports = {
|
|
|
570
580
|
findOrphanSummaries,
|
|
571
581
|
summaryPairsWithAnyPlan,
|
|
572
582
|
planRowPattern,
|
|
583
|
+
tickAnchoredPlanRow,
|
|
573
584
|
};
|
|
@@ -851,6 +851,17 @@ function frontmatterListEntries(content, key) {
|
|
|
851
851
|
* no closed, parseable frontmatter mapping or the key is not an own key of it.
|
|
852
852
|
*/
|
|
853
853
|
function rawFrontmatterField(content, key) {
|
|
854
|
+
const mapping = rawFrontmatterMapping(content);
|
|
855
|
+
if (!mapping || !Object.prototype.hasOwnProperty.call(mapping, key))
|
|
856
|
+
return null;
|
|
857
|
+
return { value: mapping[key] };
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* The top-level frontmatter mapping VERBATIM, off `rawFrontmatterField`'s guarded parse path.
|
|
861
|
+
* Null when the document has no closed, parseable frontmatter mapping — so a caller can tell
|
|
862
|
+
* "parsed, without that key" (#4998 review) from "no usable frontmatter".
|
|
863
|
+
*/
|
|
864
|
+
function rawFrontmatterMapping(content) {
|
|
854
865
|
const found = frontmatterRegion(content);
|
|
855
866
|
if (!found || !found.terminated)
|
|
856
867
|
return null;
|
|
@@ -867,9 +878,7 @@ function rawFrontmatterField(content, key) {
|
|
|
867
878
|
}
|
|
868
879
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
869
880
|
return null;
|
|
870
|
-
|
|
871
|
-
return null;
|
|
872
|
-
return { value: raw[key] };
|
|
881
|
+
return raw;
|
|
873
882
|
}
|
|
874
883
|
/**
|
|
875
884
|
* Escape a string for emission inside a YAML double-quoted scalar (#1779). ADR-3473 §8.1
|
|
@@ -940,6 +949,14 @@ function scalarNeedsDoubleQuoting(s) {
|
|
|
940
949
|
// round-trip.
|
|
941
950
|
if (/[\uD800-\uDFFF]/.test(s))
|
|
942
951
|
return true;
|
|
952
|
+
// #4998: U+2028/U+2029 are not YAML line breaks, so a bare one round-trips
|
|
953
|
+
// through js-yaml, but every line-anchored reader of a planning document
|
|
954
|
+
// (`^key:\s*(.+)` with the `m` flag) ends the line there and reads only the
|
|
955
|
+
// part before it. (A value holding `:` was already quoted by the callers, so
|
|
956
|
+
// no key could be forged this way.) The quoted path escapes them (`\L`,
|
|
957
|
+
// `\P`), which no such reader splits on.
|
|
958
|
+
if (/[\u2028\u2029]/.test(s))
|
|
959
|
+
return true;
|
|
943
960
|
return false;
|
|
944
961
|
}
|
|
945
962
|
/**
|
|
@@ -1806,6 +1823,7 @@ module.exports = {
|
|
|
1806
1823
|
// parse path — the decision-coverage gates read `must_haves`/`truths`/`objective` and the
|
|
1807
1824
|
// SUMMARY `files_modified` list through it instead of a hand-rolled `^key:` scan / regex.
|
|
1808
1825
|
rawFrontmatterField,
|
|
1826
|
+
rawFrontmatterMapping,
|
|
1809
1827
|
// #5139: one key's block as unparsed text, for a citation scan that must survive frontmatter
|
|
1810
1828
|
// the YAML parser refuses.
|
|
1811
1829
|
frontmatterKeyBlockText,
|
|
@@ -39,7 +39,7 @@ const planningWorkspaceMod = require("./planning-workspace.cjs");
|
|
|
39
39
|
const { planningDir } = planningWorkspaceMod;
|
|
40
40
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
41
41
|
const phaseLocatorMod = require("./phase-locator.cjs");
|
|
42
|
-
const { findPhaseInternal } = phaseLocatorMod;
|
|
42
|
+
const { phaseDirectoryOptions, findPhaseInternal } = phaseLocatorMod;
|
|
43
43
|
const security_cjs_1 = require("./security.cjs");
|
|
44
44
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
45
45
|
const roadmapModule = require("./roadmap.cjs");
|
|
@@ -171,7 +171,7 @@ function evaluateApiCoverageVerifyPre(input) {
|
|
|
171
171
|
let resolvedDir = null;
|
|
172
172
|
let phaseNumber = '';
|
|
173
173
|
if (token) {
|
|
174
|
-
const found = findPhaseInternal(projectDir, token);
|
|
174
|
+
const found = findPhaseInternal(projectDir, token, undefined, phaseDirectoryOptions(projectDir));
|
|
175
175
|
if (found && found.directory) {
|
|
176
176
|
// findPhaseInternal's `directory` is relative to the project root; anchor it to `projectDir`
|
|
177
177
|
// (not the process cwd) so the gate reads the same files in-process as it does from the CLI.
|
|
@@ -85,7 +85,7 @@ function runContextDriftGate(cwd, phaseArg) {
|
|
|
85
85
|
}
|
|
86
86
|
// Same phase-directory resolution rule the schema-drift gate uses (#1571, #2528):
|
|
87
87
|
// matchPhaseDirs, never a naive substring test.
|
|
88
|
-
const phaseDir = (0, gate_phase_context_cjs_1.resolvePhaseDirByToken)(phasesDir, phaseArg);
|
|
88
|
+
const phaseDir = (0, gate_phase_context_cjs_1.resolvePhaseDirByToken)(cwd, phasesDir, phaseArg);
|
|
89
89
|
if (!phaseDir) {
|
|
90
90
|
return unresolvable();
|
|
91
91
|
}
|
|
@@ -34,10 +34,7 @@ const planningWorkspaceMod = require("./planning-workspace.cjs");
|
|
|
34
34
|
const { planningDir } = planningWorkspaceMod;
|
|
35
35
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
36
36
|
const phaseLocatorMod = require("./phase-locator.cjs");
|
|
37
|
-
const { findPhaseInternal } = phaseLocatorMod;
|
|
38
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
39
|
-
const phaseIdMod = require("./phase-id.cjs");
|
|
40
|
-
const { normalizePhaseName, matchPhaseDirs } = phaseIdMod;
|
|
37
|
+
const { phaseDirectoryOptions, findPhaseInternal, resolvePhaseDirectoryLookup, matchPhaseDirsForLookup } = phaseLocatorMod;
|
|
41
38
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
42
39
|
const roadmapModule = require("./roadmap.cjs");
|
|
43
40
|
const { getRoadmapPhaseWithFallback } = roadmapModule;
|
|
@@ -78,7 +75,7 @@ function unresolvableProbeVerdict(readError) {
|
|
|
78
75
|
function resolvePhaseDir(projectDir, phase) {
|
|
79
76
|
let result;
|
|
80
77
|
try {
|
|
81
|
-
result = findPhaseInternal(projectDir, phase);
|
|
78
|
+
result = findPhaseInternal(projectDir, phase, undefined, phaseDirectoryOptions(projectDir));
|
|
82
79
|
}
|
|
83
80
|
catch (err) {
|
|
84
81
|
return (0, gate_evidence_cjs_1.evidenceFromError)(err, `phase ${phase}`);
|
|
@@ -109,11 +106,13 @@ function resolvePhaseDir(projectDir, phase) {
|
|
|
109
106
|
* `resolvePhaseDir` would widen what those gates inspect, so the two stay apart and each states its
|
|
110
107
|
* scope. Throws when `phasesDir` cannot be listed; both callers sit inside their non-blocking catch.
|
|
111
108
|
*/
|
|
112
|
-
function resolvePhaseDirByToken(phasesDir, phaseArg) {
|
|
113
|
-
const
|
|
109
|
+
function resolvePhaseDirByToken(cwd, phasesDir, phaseArg, directoryContext = phaseDirectoryOptions(cwd)) {
|
|
110
|
+
const lookup = resolvePhaseDirectoryLookup(cwd, phaseArg, undefined, directoryContext);
|
|
114
111
|
const dirEntries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
|
|
115
112
|
const dirNames = dirEntries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
116
|
-
|
|
113
|
+
// #4304: both drift gates resolve a directory in the current checkout.
|
|
114
|
+
// Bracket is the only opt-in grammar; all other conventions remain legacy.
|
|
115
|
+
const matched = matchPhaseDirsForLookup(dirNames, lookup, undefined, directoryContext).matches[0];
|
|
117
116
|
if (matched)
|
|
118
117
|
return node_path_1.default.join(phasesDir, matched);
|
|
119
118
|
const contained = (0, security_cjs_1.tryWithinRoot)(phaseArg, phasesDir);
|
|
@@ -68,7 +68,7 @@ function runSchemaDriftGate(cwd, phaseArg, skipFlag) {
|
|
|
68
68
|
// matching "11-expansion"), making the drift gate inspect the wrong phase.
|
|
69
69
|
// This shares the one selection rule with find-phase / verify
|
|
70
70
|
// phase-completeness rather than restating it. (#1571, #2528)
|
|
71
|
-
const phaseDir = (0, gate_phase_context_cjs_1.resolvePhaseDirByToken)(phasesDir, phaseArg);
|
|
71
|
+
const phaseDir = (0, gate_phase_context_cjs_1.resolvePhaseDirByToken)(cwd, phasesDir, phaseArg);
|
|
72
72
|
if (!phaseDir) {
|
|
73
73
|
// An unresolvable phase is "could not look" (#5170): there is no phase directory to evaluate.
|
|
74
74
|
return schemaDriftUnreadable(`Phase directory not found: ${phaseArg}`);
|
|
@@ -32,14 +32,9 @@ function excludeFromGit(dir, filename) {
|
|
|
32
32
|
// gitignore cannot express a newline in a pattern, and git's line output could not be split.
|
|
33
33
|
if (/[\r\n]/.test(dir))
|
|
34
34
|
return;
|
|
35
|
-
// cwd must pick the repo: drop inherited repository-location variables (e.g. inside a git hook).
|
|
36
|
-
const env = { GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined, GIT_COMMON_DIR: undefined };
|
|
37
35
|
// The prefix goes last: stdout is trimmed, and a non-empty prefix ends in `/`, so only an
|
|
38
36
|
// empty prefix (dir is the git root) loses its line; a leading space survives.
|
|
39
|
-
const revParse = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--git-path', 'info/exclude', '--show-prefix'], {
|
|
40
|
-
cwd: dir,
|
|
41
|
-
env: env,
|
|
42
|
-
});
|
|
37
|
+
const revParse = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--git-path', 'info/exclude', '--show-prefix'], { cwd: dir });
|
|
43
38
|
const [excludeRel, prefix = ''] = (0, text_lines_cjs_1.splitLines)(String(revParse.stdout));
|
|
44
39
|
if (revParse.exitCode !== 0 || !excludeRel)
|
|
45
40
|
return;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Tool-owned `.gsd/` directory owner (#5757).
|
|
4
|
+
*
|
|
5
|
+
* gsd-core keeps small tool state under `<project>/.gsd/` (the dispatch
|
|
6
|
+
* isolation sentinel, the harness fork-probe cache). A user project does not
|
|
7
|
+
* inherit gsd-core's own root `.gitignore`, and the project may contain a
|
|
8
|
+
* `.gsd` symlink, so every writer needs the same two guarantees before it
|
|
9
|
+
* writes anything there:
|
|
10
|
+
*
|
|
11
|
+
* 1. the directory is a real directory that resolves inside the project
|
|
12
|
+
* (never write through a project-supplied symlink or ancestor redirect);
|
|
13
|
+
* 2. the directory is ignored by git (`.gsd/.gitignore`), without clobbering
|
|
14
|
+
* rules a user already put there.
|
|
15
|
+
*
|
|
16
|
+
* This module is the single owner of both. Callers pass their own ignore-file
|
|
17
|
+
* `marker` comment and the `rules` they need; the owner appends only the rules
|
|
18
|
+
* that are missing.
|
|
19
|
+
*/
|
|
20
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
21
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
22
|
+
};
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.GSD_IGNORE_SYMLINK = exports.GSD_DIR_UNSAFE = void 0;
|
|
25
|
+
exports.ignoreAdditions = ignoreAdditions;
|
|
26
|
+
exports.ensureToolOwnedGsdDir = ensureToolOwnedGsdDir;
|
|
27
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
28
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
29
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
30
|
+
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
31
|
+
const SYMLINK_IGNORE_MESSAGE = '.gsd/.gitignore is a symlink';
|
|
32
|
+
/** Stable `code` values on the refusal errors; callers translate by code, never by message. */
|
|
33
|
+
exports.GSD_DIR_UNSAFE = 'GSD_DIR_UNSAFE';
|
|
34
|
+
exports.GSD_IGNORE_SYMLINK = 'GSD_IGNORE_SYMLINK';
|
|
35
|
+
function codedError(message, code) {
|
|
36
|
+
return Object.assign(new Error(message), { code });
|
|
37
|
+
}
|
|
38
|
+
function hasLine(text, line) {
|
|
39
|
+
return text.split('\n').some((l) => l.replace(/\r$/, '') === line);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Pure merge: the text to append to an existing ignore file so that `marker`
|
|
43
|
+
* and every rule are present, or '' when nothing is missing. A file that
|
|
44
|
+
* already ignores everything (`*` line) needs nothing.
|
|
45
|
+
*/
|
|
46
|
+
function ignoreAdditions(current, { marker, rules }) {
|
|
47
|
+
if (hasLine(current, '*'))
|
|
48
|
+
return '';
|
|
49
|
+
const missing = rules.filter((rule) => !hasLine(current, rule));
|
|
50
|
+
if (missing.length === 0)
|
|
51
|
+
return '';
|
|
52
|
+
const lead = current === '' || current.endsWith('\n') ? '' : '\n';
|
|
53
|
+
const markerLine = current.includes(marker) ? '' : `${marker}\n`;
|
|
54
|
+
return `${lead}${markerLine}${missing.join('\n')}\n`;
|
|
55
|
+
}
|
|
56
|
+
function isLinkError(err) {
|
|
57
|
+
const e = err;
|
|
58
|
+
return !!e && (e.code === 'ELOOP' || e.message === SYMLINK_IGNORE_MESSAGE);
|
|
59
|
+
}
|
|
60
|
+
function mergeExistingIgnore(ignorePath, options) {
|
|
61
|
+
let fd;
|
|
62
|
+
let tmpPath = null;
|
|
63
|
+
try {
|
|
64
|
+
if (node_fs_1.default.lstatSync(ignorePath).isSymbolicLink())
|
|
65
|
+
throw new Error(SYMLINK_IGNORE_MESSAGE);
|
|
66
|
+
// Read through an fd opened without following links where supported
|
|
67
|
+
// (O_NOFOLLOW is absent on Windows, hence `|| 0`), then replace by rename
|
|
68
|
+
// rather than append through the path, so a link swapped in after the
|
|
69
|
+
// check cannot redirect a write elsewhere.
|
|
70
|
+
fd = node_fs_1.default.openSync(ignorePath, node_fs_1.default.constants.O_RDONLY | (node_fs_1.default.constants.O_NOFOLLOW || 0));
|
|
71
|
+
if (!node_fs_1.default.fstatSync(fd).isFile())
|
|
72
|
+
throw new Error('.gsd/.gitignore is not a regular file');
|
|
73
|
+
const current = node_fs_1.default.readFileSync(fd, 'utf8');
|
|
74
|
+
node_fs_1.default.closeSync(fd);
|
|
75
|
+
fd = undefined;
|
|
76
|
+
const additions = ignoreAdditions(current, options);
|
|
77
|
+
if (additions === '')
|
|
78
|
+
return;
|
|
79
|
+
// `tmpPath` is assigned only once the exclusive create succeeded, so an
|
|
80
|
+
// EEXIST never unlinks an entry this process did not create.
|
|
81
|
+
const candidateTmp = `${ignorePath}.tmp-${process.pid}-${node_crypto_1.default.randomBytes(6).toString('hex')}`;
|
|
82
|
+
const tmpFd = node_fs_1.default.openSync(candidateTmp, 'wx');
|
|
83
|
+
tmpPath = candidateTmp;
|
|
84
|
+
try {
|
|
85
|
+
node_fs_1.default.writeFileSync(tmpFd, current + additions);
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
node_fs_1.default.closeSync(tmpFd);
|
|
89
|
+
}
|
|
90
|
+
if (node_fs_1.default.lstatSync(ignorePath).isSymbolicLink())
|
|
91
|
+
throw new Error(SYMLINK_IGNORE_MESSAGE);
|
|
92
|
+
(0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, ignorePath);
|
|
93
|
+
tmpPath = null;
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
if (isLinkError(err))
|
|
97
|
+
throw codedError(SYMLINK_IGNORE_MESSAGE, exports.GSD_IGNORE_SYMLINK);
|
|
98
|
+
// Ignore seeding is best-effort; the caller's state is still useful if
|
|
99
|
+
// this path cannot be read or updated.
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
if (fd !== undefined)
|
|
103
|
+
node_fs_1.default.closeSync(fd);
|
|
104
|
+
if (tmpPath) {
|
|
105
|
+
try {
|
|
106
|
+
node_fs_1.default.unlinkSync(tmpPath);
|
|
107
|
+
}
|
|
108
|
+
catch { /* best-effort cleanup */ }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Ensure `<cwd>/.gsd` exists as a real, project-contained, git-ignored
|
|
114
|
+
* directory and return its path. Throws when `.gsd` is a symlink, resolves
|
|
115
|
+
* outside the project, or `.gsd/.gitignore` is a symlink.
|
|
116
|
+
*/
|
|
117
|
+
function ensureToolOwnedGsdDir(cwd, options) {
|
|
118
|
+
const dir = node_path_1.default.join(cwd, '.gsd');
|
|
119
|
+
node_fs_1.default.mkdirSync(dir, { recursive: true });
|
|
120
|
+
if (node_fs_1.default.lstatSync(dir).isSymbolicLink() ||
|
|
121
|
+
node_path_1.default.relative(node_fs_1.default.realpathSync(cwd), node_fs_1.default.realpathSync(dir)) !== '.gsd') {
|
|
122
|
+
throw codedError('.gsd directory is a symlink or resolves outside the project', exports.GSD_DIR_UNSAFE);
|
|
123
|
+
}
|
|
124
|
+
const ignorePath = node_path_1.default.join(dir, '.gitignore');
|
|
125
|
+
try {
|
|
126
|
+
node_fs_1.default.writeFileSync(ignorePath, '*\n', { flag: 'wx' });
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
if (err.code === 'EEXIST')
|
|
130
|
+
mergeExistingIgnore(ignorePath, options);
|
|
131
|
+
// Any other ignore-file IO failure must not prevent the caller's write.
|
|
132
|
+
}
|
|
133
|
+
return dir;
|
|
134
|
+
}
|
|
@@ -54,7 +54,7 @@ const validateMod = require("../validate.cjs");
|
|
|
54
54
|
const { isPhaseDirName } = validateMod;
|
|
55
55
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
56
56
|
const phaseIdMod = require("../phase-id.cjs");
|
|
57
|
-
const { extractPhaseToken, normalizePhaseName, comparePhaseNum } = phaseIdMod;
|
|
57
|
+
const { extractPhaseToken, normalizePhaseName, phaseKeyFromDir, comparePhaseNum } = phaseIdMod;
|
|
58
58
|
// #5060: the Phase Status Module owns the status-label ladder.
|
|
59
59
|
const phase_status_cjs_1 = require("../phase-status.cjs");
|
|
60
60
|
// #5291: W030 asks whether a raw report status has a known repair. The lookup is
|
|
@@ -63,8 +63,11 @@ const verification_cjs_1 = require("../verification.cjs");
|
|
|
63
63
|
// ─── W005 — phase directory doesn't follow NN-name format (verify.cts:1893-1902) ─
|
|
64
64
|
function checkW005(snapshot) {
|
|
65
65
|
const diagnostics = [];
|
|
66
|
+
const context = snapshot.directoryContext ?? phaseIdMod.phaseDirectoryContext(snapshot.config.value ?? {}, '', snapshot.roadmapDeclaredPhases.value.map(({ phaseId }) => phaseId));
|
|
66
67
|
for (const name of snapshot.phaseDirs.value) {
|
|
67
|
-
|
|
68
|
+
const custom = snapshot.phaseIdConvention === 'bracket' ? null : phaseIdMod.customPhaseQuery(name, context);
|
|
69
|
+
const owner = custom && phaseIdMod.customDirectoryOwner(name, custom);
|
|
70
|
+
if (!owner && !isPhaseDirName(name, snapshot.phaseIdConvention)) {
|
|
68
71
|
diagnostics.push({
|
|
69
72
|
code: 'W005',
|
|
70
73
|
severity: SEVERITY.WARNING,
|
|
@@ -77,17 +80,18 @@ function checkW005(snapshot) {
|
|
|
77
80
|
}
|
|
78
81
|
// ─── W023 — phase directories collide on normalized key (verify.cts:1904-1950) ─
|
|
79
82
|
//
|
|
80
|
-
// Groups
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// `comparePhaseNum` + a `localeCompare` tiebreak, mirroring
|
|
83
|
+
// Groups directories by the canonical identity owner, preserving recorded
|
|
84
|
+
// custom IDs instead of collapsing their shared token prefixes. Sorted with
|
|
85
|
+
// contextual `comparePhaseNum` + a `localeCompare` tiebreak, mirroring
|
|
84
86
|
// `verify.cts:1930-1932`'s deterministic-output rationale. See the file-level
|
|
85
87
|
// comment for the disclosed "described" fidelity reduction.
|
|
86
88
|
function checkW023(snapshot) {
|
|
89
|
+
const context = snapshot.directoryContext;
|
|
87
90
|
const groups = new Map();
|
|
88
91
|
for (const name of snapshot.phaseDirs.value) {
|
|
89
|
-
const
|
|
90
|
-
|
|
92
|
+
const key = context?.phaseNaming === 'custom'
|
|
93
|
+
? phaseKeyFromDir(name, undefined, context)
|
|
94
|
+
: normalizePhaseName(extractPhaseToken(name));
|
|
91
95
|
const list = groups.get(key);
|
|
92
96
|
if (list)
|
|
93
97
|
list.push(name);
|
|
@@ -101,7 +105,7 @@ function checkW023(snapshot) {
|
|
|
101
105
|
continue;
|
|
102
106
|
const described = dirs
|
|
103
107
|
.slice()
|
|
104
|
-
.sort((a, b) => comparePhaseNum(a, b) || String(a).localeCompare(String(b)))
|
|
108
|
+
.sort((a, b) => comparePhaseNum(a, b, context) || String(a).localeCompare(String(b)))
|
|
105
109
|
.map((d) => {
|
|
106
110
|
const phase = phaseByDir.get(d);
|
|
107
111
|
const plans = phase ? phase.planCount : 0;
|