@opengsd/gsd-core 1.20.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +33 -2
  4. package/agents/gsd-executor.md +1 -1
  5. package/agents/gsd-plan-checker.md +1 -1
  6. package/agents/gsd-planner.md +3 -3
  7. package/agents/gsd-verifier.md +2 -2
  8. package/bin/install.js +265 -161
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/commands/gsd/new-milestone.md +2 -2
  11. package/commands/gsd/new-project.md +2 -2
  12. package/gsd-core/bin/gsd-tools.cjs +554 -823
  13. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  14. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  15. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  16. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  17. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  18. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  19. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  20. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  21. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  22. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  23. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  24. package/gsd-core/bin/lib/commands.cjs +99 -2
  25. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  26. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  27. package/gsd-core/bin/lib/config.cjs +139 -101
  28. package/gsd-core/bin/lib/configuration.cjs +41 -2
  29. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  30. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  31. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  32. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  33. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  34. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  35. package/gsd-core/bin/lib/graphify.cjs +19 -2
  36. package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
  37. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  38. package/gsd-core/bin/lib/init.cjs +20 -18
  39. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  40. package/gsd-core/bin/lib/install-engine.cjs +20 -28
  41. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  42. package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
  43. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  44. package/gsd-core/bin/lib/install-profiles.cjs +4 -2
  45. package/gsd-core/bin/lib/io.cjs +6 -0
  46. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  47. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  48. package/gsd-core/bin/lib/milestone.cjs +8 -6
  49. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  50. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  51. package/gsd-core/bin/lib/phase-id.cjs +26 -9
  52. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  53. package/gsd-core/bin/lib/phase.cjs +170 -45
  54. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  55. package/gsd-core/bin/lib/planning-snapshot.cjs +10 -6
  56. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  57. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  58. package/gsd-core/bin/lib/project-context.cjs +439 -0
  59. package/gsd-core/bin/lib/project-root.cjs +9 -230
  60. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  61. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +357 -50
  63. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  64. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  65. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +317 -134
  66. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  67. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
  68. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  69. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  71. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  72. package/gsd-core/bin/lib/state-transition.cjs +103 -46
  73. package/gsd-core/bin/lib/state.cjs +448 -63
  74. package/gsd-core/bin/lib/surface.cjs +4 -7
  75. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  76. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  77. package/gsd-core/bin/lib/verify.cjs +49 -6
  78. package/gsd-core/bin/lib/workstream-inventory.cjs +4 -4
  79. package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
  80. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  81. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  82. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  83. package/gsd-core/references/checkpoints.md +1 -1
  84. package/gsd-core/references/planner-guidance.md +11 -0
  85. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  86. package/gsd-core/references/planner-reversibility.md +2 -0
  87. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  88. package/gsd-core/workflows/autonomous.md +4 -4
  89. package/gsd-core/workflows/code-review-fix.md +5 -7
  90. package/gsd-core/workflows/execute-phase.md +21 -7
  91. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  92. package/gsd-core/workflows/plan-phase.md +18 -19
  93. package/hooks/dist/gsd-agent-isolation-guard.js +4 -3
  94. package/hooks/dist/gsd-config-reload.js +2 -2
  95. package/hooks/dist/gsd-context-monitor.js +2 -1
  96. package/hooks/dist/gsd-cursor-subagent-start.js +9 -6
  97. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  98. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  99. package/hooks/dist/gsd-statusline.js +3 -2
  100. package/hooks/dist/gsd-workflow-guard.js +2 -1
  101. package/hooks/dist/lib/config-read.js +49 -0
  102. package/hooks/dist/lib/git-cmd.js +3 -1
  103. package/hooks/dist/lib/git-head-tree.js +474 -0
  104. package/hooks/dist/lib/isolation-sentinel.js +168 -13
  105. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  106. package/hooks/gsd-agent-isolation-guard.js +4 -3
  107. package/hooks/gsd-config-reload.js +2 -2
  108. package/hooks/gsd-context-monitor.js +2 -1
  109. package/hooks/gsd-cursor-subagent-start.js +9 -6
  110. package/hooks/gsd-read-injection-scanner.js +2 -2
  111. package/hooks/gsd-secret-read-guard.js +141 -6
  112. package/hooks/gsd-statusline.js +3 -2
  113. package/hooks/gsd-workflow-guard.js +2 -1
  114. package/hooks/lib/config-read.js +49 -0
  115. package/hooks/lib/git-cmd.js +3 -1
  116. package/hooks/lib/git-head-tree.js +474 -0
  117. package/hooks/lib/isolation-sentinel.js +168 -13
  118. package/hooks/lib/secret-read-exemptions.js +161 -0
  119. package/package.json +7 -2
  120. package/pi/package-agents/gsd-executor.md +1 -1
  121. package/pi/package-agents/gsd-plan-checker.md +1 -1
  122. package/pi/package-agents/gsd-planner.md +3 -3
  123. package/pi/package-agents/gsd-verifier.md +2 -2
  124. package/pi/package-agents/manifest.json +4 -4
  125. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  126. package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
  127. package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
  128. package/scripts/changeset/cli.cjs +2 -2
  129. package/scripts/changeset/lint.cjs +28 -3
  130. package/scripts/changeset/serialize.cjs +12 -2
  131. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  132. package/scripts/ci-fix-next-rerun.cjs +149 -0
  133. package/scripts/ci-next-health.cjs +52 -9
  134. package/scripts/ci-test-scope.cjs +77 -71
  135. package/scripts/command-contract-helpers.cjs +56 -7
  136. package/scripts/docs-guard-registry.cjs +3 -0
  137. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  138. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  139. package/scripts/lib/macos-conformance-tier.generated.cjs +59 -47
  140. package/scripts/lib/platform-conformance-tier.generated.cjs +23 -64
  141. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  142. package/scripts/lint-removed-but-needed.cjs +562 -50
  143. package/scripts/merge-commit-pr.cjs +186 -0
  144. package/scripts/release-ancestry.cjs +86 -0
  145. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  146. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  147. package/scripts/release-pipeline-pr.cjs +92 -0
  148. package/scripts/require-issue-link-policy.cjs +18 -9
  149. package/scripts/sync-next-version.cjs +11 -2
  150. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  151. package/skills/gsd-autonomous/SKILL.md +1 -1
  152. package/skills/gsd-new-milestone/SKILL.md +1 -1
  153. package/skills/gsd-new-project/SKILL.md +1 -1
  154. package/vscode/package.json +1 -1
@@ -31,7 +31,7 @@ const gate_verdict_cjs_1 = require("./gate-verdict.cjs");
31
31
  const gate_evidence_cjs_1 = require("./gate-evidence.cjs");
32
32
  // eslint-disable-next-line @typescript-eslint/no-require-imports
33
33
  const planningWorkspaceMod = require("./planning-workspace.cjs");
34
- const { planningDir } = planningWorkspaceMod;
34
+ const { planningDir, planningRoot } = planningWorkspaceMod;
35
35
  // eslint-disable-next-line @typescript-eslint/no-require-imports
36
36
  const phaseLocatorMod = require("./phase-locator.cjs");
37
37
  const { phaseDirectoryOptions, findPhaseInternal, resolvePhaseDirectoryLookup, matchPhaseDirsForLookup } = phaseLocatorMod;
@@ -40,12 +40,24 @@ const roadmapModule = require("./roadmap.cjs");
40
40
  const { getRoadmapPhaseWithFallback } = roadmapModule;
41
41
  /**
42
42
  * Resolve a caller-supplied path (absolute, or relative to `projectDir`) and require the result to
43
- * stay inside `projectDir` (realpath containment, ADR-4650). Returns the contained path the
44
- * predicate produced, or a `GateUsageFailure` (`usage`, `path escapes its allowed directory: <arg>`).
43
+ * stay inside `projectDir` or inside the realpath of the project's OWN `.planning` (#5474: it may be
44
+ * a symlink to an out-of-repo store, so its phases realpath outside `projectDir`; ADR-5057 "Two
45
+ * containment roots"). Both decisions go through `tryWithinRoot` (realpath containment, ADR-4650);
46
+ * a symlink inside the store that leaves it, `..` traversal and a name-prefix sibling of the store
47
+ * still fail both. Returns the contained path the predicate produced, or a `GateUsageFailure`
48
+ * (`usage`, `path escapes its allowed directory: <arg>`).
49
+ *
50
+ * Trust assumption: the `.planning` link is trusted like the rest of the project tree. Whatever it
51
+ * resolves to becomes a containment root, so a `.planning` linked to a broad directory (a home
52
+ * directory, `/etc`) makes every path under that directory contained. This is accepted by design
53
+ * (#5474 scope): the link is part of the checkout the user chose to run gates on, and the gates
54
+ * read paths, they do not grant access the user's own shell lacks. Only the project's own
55
+ * `.planning` is honoured; any other symlink target is still refused.
45
56
  */
46
57
  function resolveContainedPath(inputPath, projectDir) {
47
58
  const candidate = node_path_1.default.isAbsolute(inputPath) ? inputPath : node_path_1.default.join(projectDir, inputPath);
48
- const contained = (0, security_cjs_1.tryWithinRoot)(candidate, projectDir, security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
59
+ const contained = (0, security_cjs_1.tryWithinRoot)(candidate, projectDir, security_cjs_1.PathAcceptance.AbsoluteInsideRoot) ??
60
+ (0, security_cjs_1.tryWithinRoot)(candidate, planningRoot(projectDir), security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
49
61
  if (contained === null) {
50
62
  return (0, gate_verdict_cjs_1.gateUsageFailure)(gate_verdict_cjs_1.GATE_FAILURE_CODE.USAGE, `path escapes its allowed directory: ${inputPath}`);
51
63
  }
@@ -14,6 +14,9 @@ const node_fs_1 = __importDefault(require("node:fs"));
14
14
  const node_path_1 = __importDefault(require("node:path"));
15
15
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
16
16
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
17
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- config-file-read.cjs is an export= CommonJS module
18
+ const configFileReadMod = require("./config-file-read.cjs");
19
+ const { readConfigFileBounded } = configFileReadMod;
17
20
  // eslint-disable-next-line @typescript-eslint/no-require-imports
18
21
  const capabilityStateMod = require("./capability-state.cjs");
19
22
  const { isCapabilityActive } = capabilityStateMod;
@@ -175,6 +178,20 @@ function safeReadJson(filePath) {
175
178
  return null;
176
179
  }
177
180
  }
181
+ /**
182
+ * Safely read and parse `<planningDir>/config.json` as a bounded regular file (#5759). Returns
183
+ * null on a missing, non-regular (FIFO/device), oversized, unreadable or malformed file — never
184
+ * blocks. Not `safeReadJson`: that reader is for graph artifacts and opens blocking.
185
+ */
186
+ function safeReadConfigJson(planningDir) {
187
+ try {
188
+ const raw = readConfigFileBounded(node_path_1.default.join(planningDir, 'config.json'));
189
+ return raw === null ? null : JSON.parse(raw);
190
+ }
191
+ catch {
192
+ return null;
193
+ }
194
+ }
178
195
  /**
179
196
  * Build a bidirectional adjacency map from graph nodes and edges.
180
197
  * Each node ID maps to an array of { target, edge } entries.
@@ -390,7 +407,7 @@ const SNAPSHOT_FILENAME = '.last-build-snapshot.json';
390
407
  * default `<planningDir>/graphs/graph.json` when unset/blank/non-string.
391
408
  */
392
409
  function resolveGraphLocation(cwd, planningDir) {
393
- const config = safeReadJson(node_path_1.default.join(planningDir, 'config.json'));
410
+ const config = safeReadConfigJson(planningDir);
394
411
  const graphify = (config && config['graphify']);
395
412
  const configuredValue = graphify && graphify['graph_path'];
396
413
  if (typeof configuredValue === 'string' && configuredValue.trim().length > 0) {
@@ -564,7 +581,7 @@ function graphifyBuild(cwd) {
564
581
  const graphsDir = node_path_1.default.join(planningDir, 'graphs');
565
582
  node_fs_1.default.mkdirSync(graphsDir, { recursive: true });
566
583
  // Read build timeout from config -- default 300s per D-02
567
- const config = safeReadJson(node_path_1.default.join(planningDir, 'config.json')) || {};
584
+ const config = safeReadConfigJson(planningDir) || {};
568
585
  const graphifyConfig = config.graphify;
569
586
  const timeoutSec = (graphifyConfig && graphifyConfig.build_timeout) || 300;
570
587
  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
+ }
@@ -324,7 +324,11 @@ function runRepairAction(cwd, action, paths, args = {}) {
324
324
  case REMEDY_ACTION.ADD_NYQUIST_KEY:
325
325
  case REMEDY_ACTION.ADD_AI_INTEGRATION_PHASE_KEY: {
326
326
  const key = action === REMEDY_ACTION.ADD_NYQUIST_KEY ? 'nyquist_validation' : 'ai_integration_phase';
327
- const configRaw = node_fs_1.default.readFileSync(configPath, 'utf-8');
327
+ // Bounded read (#5759): a FIFO/device/oversized config throws (the repair fails, never blocks).
328
+ const configRaw = configLoaderMod.readConfigFileBounded(configPath);
329
+ if (configRaw === null) {
330
+ throw Object.assign(new Error(`ENOENT: no such file or directory, open '${configPath}'`), { code: 'ENOENT' });
331
+ }
328
332
  const configParsed = JSON.parse(configRaw);
329
333
  if (!configParsed['workflow'])
330
334
  configParsed['workflow'] = {};
@@ -48,7 +48,7 @@ const secrets_cjs_1 = require("./secrets.cjs");
48
48
  const scanPhasePlans = require("./plan-scan.cjs");
49
49
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-document.cjs is an export= CommonJS module
50
50
  const planDocument = require("./plan-document.cjs");
51
- const state_document_cjs_1 = require("./state-document.cjs");
51
+ const state_transition_cjs_1 = require("./state-transition.cjs");
52
52
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
53
53
  const host_runtime_detection_cjs_1 = require("./host-runtime-detection.cjs");
54
54
  const security_cjs_1 = require("./security.cjs");
@@ -99,7 +99,7 @@ const { resolveModelInternal, resolveGranularityInternal, assertValidGranularity
99
99
  const { phaseDirectoryOptions, findPhaseInternal, listMilestonePhaseDirs, listAllPhaseDirs, } = phaseLocator;
100
100
  const { getRoadmapPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, } = roadmapParser;
101
101
  const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils;
102
- const { comparePhaseNum, phaseDirectoryName, customPhaseQuery, customDirectoryOwner, phaseScanTokenSource, phaseKeyFromToken, phaseDirectoryContext, normalizePhaseName, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE, PHASE_DEP_REF_SOURCE, extractPhaseDependencyTokens, isForeignPrefixedPhaseQuery, isSentinelPhaseId, extractPhaseToken, scopeToPhase, renderPhaseBranchName, parsePhaseId, renderPhaseId, bracketQualifiedKey, phaseHeadingPrefixSrcFor, parsePhaseChecklistLine, PHASE_HEADING_BASELINE, buildPhaseHeadingScanRegex, } = phaseId;
102
+ const { comparePhaseNum, phaseDirectoryName, customPhaseQuery, customDirectoryOwner, phaseScanTokenSource, phaseNumberTokenSource, phaseKeyFromToken, phaseDirectoryContext, normalizePhaseName, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE, PHASE_DEP_REF_SOURCE, extractPhaseDependencyTokens, isForeignPrefixedPhaseQuery, isSentinelPhaseId, extractPhaseToken, scopeToPhase, renderPhaseBranchName, parsePhaseId, renderPhaseId, bracketQualifiedKey, phaseHeadingPrefixSrcFor, parsePhaseChecklistLine, PHASE_HEADING_BASELINE, buildPhaseHeadingScanRegex, } = phaseId;
103
103
  const { phaseToken } = phaseIdDisplayMod;
104
104
  const { pruneOrphanedWorktrees } = worktreeSafety;
105
105
  const { planningPaths, planningDir, planningRoot, todosDir, listAvailableWorkstreams, peekActiveWorkstream, resolveEnvWorkstream, diagnoseUnresolvedActiveWorkstream, describeUnresolvedWorkstreamReason, findContextMdIn, findPhaseSpecIn, resolvePhaseIdConvention, worktreesOptedOut, } = planningWorkspace;
@@ -540,7 +540,10 @@ function detectHasPriorPhases(cwd, phaseInfo) {
540
540
  */
541
541
  function readConfigJsonBoolean(cwd, keyPath) {
542
542
  try {
543
- const raw = node_fs_1.default.readFileSync(node_path_1.default.join(planningDir(cwd), 'config.json'), 'utf8');
543
+ // Bounded read (#5759): a FIFO/device/oversized config throws and degrades to false below.
544
+ const raw = configLoader.readConfigFileBounded(node_path_1.default.join(planningDir(cwd), 'config.json'));
545
+ if (raw === null)
546
+ return false;
544
547
  let cursor = JSON.parse(raw);
545
548
  for (const segment of keyPath) {
546
549
  if (cursor === null || typeof cursor !== 'object' || Array.isArray(cursor))
@@ -576,7 +579,10 @@ function readNonEmptyFileOrNull(filePath) {
576
579
  */
577
580
  function readConfigJsonValue(cwd, keyPath) {
578
581
  try {
579
- const raw = node_fs_1.default.readFileSync(node_path_1.default.join(planningDir(cwd), 'config.json'), 'utf8');
582
+ // Bounded read (#5759): a FIFO/device/oversized config throws and degrades to undefined below.
583
+ const raw = configLoader.readConfigFileBounded(node_path_1.default.join(planningDir(cwd), 'config.json'));
584
+ if (raw === null)
585
+ return undefined;
580
586
  let cursor = JSON.parse(raw);
581
587
  for (const segment of keyPath) {
582
588
  if (cursor === null || typeof cursor !== 'object' || Array.isArray(cursor))
@@ -1058,8 +1064,7 @@ function cmdInitExecutePhase(cwd, phase, raw, options = {}) {
1058
1064
  const stateWarnings = [];
1059
1065
  if (phaseInfo?.['directory'] && node_fs_1.default.existsSync(node_path_1.default.join(cwd, phaseInfo['directory']))) {
1060
1066
  const diskPlans = listPhasePlanFiles(node_path_1.default.join(cwd, phaseInfo['directory'])).length;
1061
- const totalPlansRaw = (0, state_document_cjs_1.stateExtractField)(stateContent, 'Total Plans in Phase');
1062
- const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
1067
+ const totalPlansInPhase = (0, state_transition_cjs_1.readRecordedPlanTotal)(stateContent);
1063
1068
  if (totalPlansInPhase !== null && diskPlans !== totalPlansInPhase) {
1064
1069
  stateWarnings.push(`Plan count mismatch: STATE.md says ${totalPlansInPhase}, disk has ${diskPlans}`);
1065
1070
  }
@@ -1286,8 +1291,7 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
1286
1291
  if (stateContent !== null) {
1287
1292
  const stateWarnings = [];
1288
1293
  result['state_validation_ran'] = true;
1289
- const totalPlansRaw = (0, state_document_cjs_1.stateExtractField)(stateContent, 'Total Plans in Phase');
1290
- const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
1294
+ const totalPlansInPhase = (0, state_transition_cjs_1.readRecordedPlanTotal)(stateContent);
1291
1295
  if (totalPlansInPhase !== null &&
1292
1296
  phaseInfo &&
1293
1297
  totalPlansInPhase !==
@@ -2626,7 +2630,7 @@ function cmdInitManager(cwd, raw) {
2626
2630
  }
2627
2631
  }
2628
2632
  // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
2629
- const phasePattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefix}(${phaseScanTokenSource(directoryContext)})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
2633
+ const phasePattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefix}(${phaseScanTokenSource(directoryContext, phaseIdConvention)})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
2630
2634
  const phases = [];
2631
2635
  const bracketIdsByPhaseNumber = new Map();
2632
2636
  let match;
@@ -2635,9 +2639,7 @@ function cmdInitManager(cwd, raw) {
2635
2639
  const phaseNum = capturesBracketId ? match[2] : match[1];
2636
2640
  if (bracketId)
2637
2641
  bracketIdsByPhaseNumber.set(phaseNum, bracketId);
2638
- const phaseName = (capturesBracketId ? match[3] : match[2])
2639
- .replace(/\(INSERTED\)/i, '')
2640
- .trim();
2642
+ const phaseName = (0, phase_entry_grammar_cjs_1.stripInsertedMarker)(capturesBracketId ? match[3] : match[2]);
2641
2643
  let displayId;
2642
2644
  if (bracketId) {
2643
2645
  try {
@@ -2650,7 +2652,7 @@ function cmdInitManager(cwd, raw) {
2650
2652
  }
2651
2653
  const sectionStart = match.index;
2652
2654
  const restOfContent = content.slice(sectionStart);
2653
- const nextHeader = restOfContent.match(new RegExp(`\\n#{2,4}\\s+${phaseHeadingPrefixNoCapture}${phaseScanTokenSource(directoryContext)}`, 'i'));
2655
+ const nextHeader = restOfContent.match(new RegExp(`\\n#{2,4}\\s+${phaseHeadingPrefixNoCapture}${phaseScanTokenSource(directoryContext, phaseIdConvention)}`, 'i'));
2654
2656
  const sectionEnd = nextHeader
2655
2657
  ? sectionStart + nextHeader.index
2656
2658
  : content.length;
@@ -2849,7 +2851,7 @@ function cmdInitManager(cwd, raw) {
2849
2851
  }
2850
2852
  else {
2851
2853
  // Preserve the historical greedy non-bracket fallback byte-for-byte.
2852
- const _allCompletedPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`.*${phaseHeadingPrefixNoCapture}(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi');
2854
+ const _allCompletedPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`.*${phaseHeadingPrefixNoCapture}(${phaseNumberTokenSource(phaseIdConvention)})[:\\s]`, 'gi');
2853
2855
  let _allMatch;
2854
2856
  while ((_allMatch = _allCompletedPattern.exec(rawContent)) !== null) {
2855
2857
  if (!(0, phase_entry_grammar_cjs_1.isPhaseEntryChecked)(_allMatch[0]))
@@ -3433,9 +3435,9 @@ function cmdInitProgress(cwd, raw, options = {}) {
3433
3435
  const roadmapPhaseNums = new Set();
3434
3436
  const roadmapPhaseNames = new Map();
3435
3437
  const roadmapCheckboxStates = new Map();
3438
+ const progressConvention = resolvePhaseIdConvention(cwd);
3436
3439
  try {
3437
3440
  const roadmapContent = extractCurrentMilestone(node_fs_1.default.readFileSync(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'), cwd);
3438
- const progressConvention = resolvePhaseIdConvention(cwd);
3439
3441
  // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag, owned by
3440
3442
  // buildPhaseHeadingScanRegex (phase-id.cts) so this scan also recognizes
3441
3443
  // bracket-convention headings instead of hand-rolling a literal `Phase\s+`.
@@ -3443,7 +3445,7 @@ function cmdInitProgress(cwd, raw, options = {}) {
3443
3445
  let hm;
3444
3446
  while ((hm = headingPattern.exec(roadmapContent)) !== null) {
3445
3447
  roadmapPhaseNums.add(hm[hNumGroup]);
3446
- roadmapPhaseNames.set(hm[hNumGroup], hm[hNameGroup].replace(/\(INSERTED\)/i, '').trim());
3448
+ roadmapPhaseNames.set(hm[hNumGroup], (0, phase_entry_grammar_cjs_1.stripInsertedMarker)(hm[hNameGroup]));
3447
3449
  }
3448
3450
  // #4906 Phase 5 (#4984): NOT migrated onto phaseHeadingPrefixSrcFor — same
3449
3451
  // out-of-scope reasoning as roadmapPhaseMatchesExactPrefix/detectPhaseMvpMode
@@ -3472,7 +3474,7 @@ function cmdInitProgress(cwd, raw, options = {}) {
3472
3474
  // matched (by absorbing the bracket text). This does NOT migrate the site
3473
3475
  // onto phaseHeadingPrefixSrcFor / the bracket convention — that stays
3474
3476
  // out of scope per the comment above (#4984 revert, pinned census).
3475
- const cbPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`(?:\\[[^\\]\\n]{0,200}\\]\\s*)?Phase\\s+(${phaseScanTokenSource(directoryContext)})[:\\s]`, 'gim', { state: 'captured' });
3477
+ const cbPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`(?:\\[[^\\]\\n]{0,200}\\]\\s*)?Phase\\s+(${phaseScanTokenSource(directoryContext, progressConvention)})[:\\s]`, 'gim', { state: 'captured' });
3476
3478
  let cbm;
3477
3479
  while ((cbm = cbPattern.exec(roadmapContent)) !== null) {
3478
3480
  const cbKey = cbm[2];
@@ -3494,7 +3496,7 @@ function cmdInitProgress(cwd, raw, options = {}) {
3494
3496
  // behavior-preserving).
3495
3497
  const dirs = listMilestonePhaseDirs(phasesDir, { cwd }).value;
3496
3498
  for (const dir of dirs) {
3497
- const dirMatch = dir.match(new RegExp(`^(${phaseScanTokenSource(directoryContext)})-?(.*)`, 'i'));
3499
+ const dirMatch = dir.match(new RegExp(`^(${phaseScanTokenSource(directoryContext, progressConvention)})-?(.*)`, 'i'));
3498
3500
  const query = customPhaseQuery(dir, directoryContext);
3499
3501
  const owner = query && customDirectoryOwner(dir, query);
3500
3502
  const phaseNumber = owner ? owner.id : dirMatch ? dirMatch[1] : dir;
@@ -36,9 +36,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  const node_fs_1 = __importDefault(require("node:fs"));
37
37
  const node_path_1 = __importDefault(require("node:path"));
38
38
  const runtime_homes_cjs_1 = require("./runtime-homes.cjs");
39
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- config-file-read.cjs is an export= CommonJS module
40
+ const configFileReadMod = require("./config-file-read.cjs");
41
+ const { unboundedConfigFileReason, printablePath, readConfigFileBounded } = configFileReadMod;
39
42
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- install-fs-adapter.cjs is an export= CommonJS module
40
43
  const installFsAdapter = require("./install-fs-adapter.cjs");
41
- const { installFs } = installFsAdapter;
44
+ const { installFs, isRealInstallFs } = installFsAdapter;
42
45
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- model-resolver.cjs is an export= CommonJS module
43
46
  const modelResolver = require("./model-resolver.cjs");
44
47
  const { EFFORT_SET: GSD_EFFORT_SET } = modelResolver;
@@ -54,19 +57,36 @@ const { mergeEffortTierDefaults } = modelCatalog;
54
57
  function _readGsdConfigFile(absPath, label) {
55
58
  if (!installFs().existsSync(absPath))
56
59
  return null;
60
+ // #5759: refuse a FIFO/device/socket or an oversized file BEFORE the adapter's blocking read
61
+ // (shared guard; a path that is not on the real fs is left to the adapter).
57
62
  let raw;
58
63
  try {
59
- raw = installFs().readFileSync(absPath, 'utf-8');
64
+ if (isRealInstallFs()) {
65
+ // The real fs: the descriptor-bound reader (non-blocking open, fstat, cap).
66
+ const text = readConfigFileBounded(absPath);
67
+ if (text === null)
68
+ return null;
69
+ raw = text;
70
+ }
71
+ else {
72
+ // An injected adapter cannot give a descriptor: stat the real path first (best effort).
73
+ const unbounded = unboundedConfigFileReason(absPath);
74
+ if (unbounded !== null) {
75
+ process.stderr.write(`gsd: warning — could not read ${label} (${printablePath(absPath)}): ${unbounded}\n`);
76
+ return null;
77
+ }
78
+ raw = installFs().readFileSync(absPath, 'utf-8');
79
+ }
60
80
  }
61
81
  catch (err) {
62
- process.stderr.write(`gsd: warning — could not read ${label} (${absPath}): ${err.message}\n`);
82
+ process.stderr.write(`gsd: warning — could not read ${label} (${printablePath(absPath)}): ${printablePath(err.message)}\n`);
63
83
  return null;
64
84
  }
65
85
  try {
66
86
  return JSON.parse(raw);
67
87
  }
68
88
  catch (err) {
69
- process.stderr.write(`gsd: warning — invalid JSON in ${label} (${absPath}): ${err.message}\n`);
89
+ process.stderr.write(`gsd: warning — invalid JSON in ${label} (${printablePath(absPath)}): ${printablePath(err.message)}\n`);
70
90
  return null;
71
91
  }
72
92
  }
@@ -122,7 +122,7 @@ function pruneEmptyCorpusParents(start, stop) {
122
122
  }
123
123
  function syncRuntimeSurfaceCorpus(source, destination, configDir, manifestPrefix) {
124
124
  if (hasExistingSymlinkBetween(node_path_1.default.resolve(configDir), destination, { allowOptInFollow: isSymlinkedDestOptIn() })) {
125
- throw new Error(`syncRuntimeSurfaceCorpus: destination "${destination}" contains a symlink the install root "${configDir}" does not trust — refusing to write.`);
125
+ throw new Error(runtimeArtifactInstallPlan.syncRuntimeSurfaceCorpusSymlinkRefusalMessage(destination, configDir));
126
126
  }
127
127
  assertCorpusTreeHasNoSymlinks(destination);
128
128
  // Remove only paths the previous manifest proves GSD owned and which the
@@ -160,15 +160,7 @@ function syncRuntimeSurfaceCorpus(source, destination, configDir, manifestPrefix
160
160
  * The compatibility marker does not replace that installed-corpus authority.
161
161
  */
162
162
  function provisionRuntimeSurfaceCorpus(layout, configDir, scope) {
163
- const required = new Set();
164
- if ((0, install_scope_cjs_1.isGlobalScope)(scope)) {
165
- for (const kind of layout.kinds) {
166
- if (kind.kind === 'commands' || kind.kind === 'skills')
167
- required.add('commands');
168
- if (kind.kind === 'agents' || kind.kind === 'kimi-agents')
169
- required.add('agents');
170
- }
171
- }
163
+ const required = runtimeArtifactInstallPlan.requiredRuntimeSurfaceSources(layout, scope);
172
164
  if (required.size === 0)
173
165
  return;
174
166
  const corpusRoot = node_path_1.default.join(configDir, 'gsd-core');
@@ -229,11 +221,8 @@ const USER_OWNED_ARTIFACTS = ['USER-PROFILE.md'];
229
221
  * with the computed pathPrefix for the install.
230
222
  */
231
223
  function applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix) {
232
- content = content.replace(/~\/\.claude\//g, pathPrefix);
233
- content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
234
- content = content.replace(/\.\/\.claude\//g, `./${getDirName(runtime)}/`);
235
- content = content.replace(/~\/\.opencode\//g, pathPrefix);
236
- content = content.replace(/~\/\.kilo\//g, pathPrefix);
224
+ // #5715: one pass with literal replacement (see rewriteOpencodeFamilyConfigDirForms).
225
+ content = runtimeArtifactConversion._rewriteOpencodeFamilyConfigDirForms(content, pathPrefix, getDirName(runtime));
237
226
  // #5637: the typed-text capture heredoc ends at a terminator the typed text cannot know.
238
227
  return (0, args_terminator_cjs_1.stampInstalledContent)(content);
239
228
  }
@@ -560,7 +549,7 @@ function _resolveDevPreferencesSkillTarget(targetDir, runtime, scope = 'global')
560
549
  // legacy dev-preferences migration lands in the SAME tree the installer
561
550
  // and surface-apply use. Runtimes with no `home` override are unaffected.
562
551
  hasHomeOverride = skillsKindEntry.home != null;
563
- installRoot = skillsKindEntry.home ?? targetDir;
552
+ installRoot = runtimeArtifactInstallPlan.resolveRuntimeArtifactInstallRoot(targetDir, skillsKindEntry);
564
553
  skillDir = node_path_1.default.join(runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, skillsKindEntry.destSubpath), stemName);
565
554
  }
566
555
  else {
@@ -669,7 +658,7 @@ function _copyStaged(stagedDir, destDir, kind, configDir, runtime) {
669
658
  // that case this defense-in-depth check must confine against the resolved
670
659
  // alternate root instead, matching the upstream gate's own root selection in
671
660
  // createRuntimeArtifactInstallPlan.
672
- const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
661
+ const installRoot = runtimeArtifactInstallPlan.resolveRuntimeArtifactInstallRoot(configDir, kind);
673
662
  // Strict-subpath + NUL containment via the canonical gate (shared with the
674
663
  // layout-driven install plan); throws if destDir escapes the install root.
675
664
  // destDir here is an absolute path; path.resolve(installRoot, absoluteDest) returns it unchanged, so the gate's strict-subpath check still correctly confines it to installRoot.
@@ -1303,7 +1292,7 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, res
1303
1292
  // previously lived inline in bin/install.js.
1304
1293
  const behaviors = hostBehaviorsFor(runtime);
1305
1294
  const projectDir = scope === 'global' ? process.cwd() : configDir;
1306
- if (behaviors.combinedFamilyInstall) {
1295
+ if (runtimeArtifactInstallPlan.shouldInstallCombinedFamily(behaviors)) {
1307
1296
  // #2329: combined-family runtimes (OpenCode/Kilo) bypass
1308
1297
  // _runLegacyInstallMigrations below entirely (early return), so their
1309
1298
  // legacy-directory cleanup needs its own pre-materialization hook here.
@@ -1381,12 +1370,12 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, res
1381
1370
  // A fake adapter can change what those probes observe for paths that
1382
1371
  // were never real to begin with; it cannot make this `if` pass for a
1383
1372
  // path the real filesystem would refuse.
1384
- const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
1373
+ const installRoot = runtimeArtifactInstallPlan.resolveRuntimeArtifactInstallRoot(configDir, kind);
1385
1374
  // #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
1386
1375
  // Threat model from #1704 / ADR-1239 Phase B preserved: path-traversal and
1387
1376
  // resolved-target-equals-root still refuse regardless of opt-in.
1388
1377
  if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
1389
- throw new Error(`installRuntimeArtifacts: destDir "${dest}" contains a symlink the install root "${installRoot}" does not trust — refusing to create. If this is an intentional user-owned symlink layout (e.g. externalized skills/hooks dir, multi-account configHome, or a dotfiles-managed configHome), re-run with GSD_ALLOW_SYMLINKED_DEST=1.`);
1378
+ throw new Error(runtimeArtifactInstallPlan.installRuntimeArtifactsSymlinkRefusalMessage(dest, installRoot));
1390
1379
  }
1391
1380
  // #2875 defect fix (--minimal regression closed): a restricted profile
1392
1381
  // (e.g. --minimal) can legitimately stage ZERO agents — no skill in
@@ -1580,6 +1569,7 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
1580
1569
  const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
1581
1570
  if (!skillsKindEntry)
1582
1571
  return 0;
1572
+ const installRoot = runtimeArtifactInstallPlan.resolveRuntimeArtifactInstallRoot(targetDir, skillsKindEntry);
1583
1573
  // #3712: combined-family runtimes take installRuntimeArtifacts' early return
1584
1574
  // BEFORE its guard runs, and this writer honors `skillsKindEntry.home` below and
1585
1575
  // then prunes that destination. opencode/kilo declare no `home` today, so there
@@ -1606,13 +1596,12 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
1606
1596
  // OpenCode/Kilo writer lands in the SAME tree the installer and surface-apply use.
1607
1597
  // Runtimes with no `home` override (opencode, kilo today) are unaffected. Must stay
1608
1598
  // in lockstep with the sibling writers — the destination-parity test enforces it.
1609
- const installRoot = skillsKindEntry.home ?? targetDir;
1610
1599
  const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, skillsKindEntry.destSubpath);
1611
1600
  // Symlink-escape guard: reject if any path component between installRoot and
1612
1601
  // dest is a symlink that would redirect writes outside the install root.
1613
1602
  // #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
1614
1603
  if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
1615
- throw new Error(`installOpencodeFamilySkills: destDir "${dest}" contains a symlink the install root "${installRoot}" does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`);
1604
+ throw new Error(runtimeArtifactInstallPlan.installOpencodeFamilySkillsSymlinkRefusalMessage(dest, installRoot));
1616
1605
  }
1617
1606
  installFs().mkdirSync(dest, { recursive: true });
1618
1607
  // Preserve user-owned GSD-prefixed skill dirs across the gsd-* prune.
@@ -1653,7 +1642,8 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
1653
1642
  let content = installFs().readFileSync(node_path_1.default.join(rawDir, entry.name), 'utf8');
1654
1643
  content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
1655
1644
  content = processAttribution(content, resolveAttribution(runtime));
1656
- content = converter(content, skillName);
1645
+ // #5715: the converter runs after the prefix pass; shield the emitted prefix from it.
1646
+ content = runtimeArtifactConversion._shieldEmittedPrefix(content, pathPrefix, (c) => converter(c, skillName));
1657
1647
  const skillDir = node_path_1.default.join(dest, skillName);
1658
1648
  installFs().mkdirSync(skillDir, { recursive: true });
1659
1649
  installFs().writeFileSync(node_path_1.default.join(skillDir, 'SKILL.md'), content);
@@ -1800,14 +1790,14 @@ function installAgentsKindStandalone(runtime, targetDir, scope, resolvedProfile,
1800
1790
  const stagedAgentFiles = installFs().existsSync(stagedDir)
1801
1791
  ? installFs().readdirSync(stagedDir).filter((f) => f.endsWith('.md'))
1802
1792
  : [];
1803
- const installRoot = (typeof agentsKindEntry.home === 'string' && agentsKindEntry.home !== '') ? agentsKindEntry.home : targetDir;
1793
+ const installRoot = runtimeArtifactInstallPlan.resolveRuntimeArtifactInstallRoot(targetDir, agentsKindEntry);
1804
1794
  const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, agentsKindEntry.destSubpath);
1805
1795
  // Symlink-escape guard — same gate _copyStaged/installOpencodeFamilySkills apply
1806
1796
  // to their own writes (#2393 GSD_ALLOW_SYMLINKED_DEST opt-in preserved). Runs
1807
1797
  // even when nothing will be written this call — the stale-agent prune below
1808
1798
  // (`_removeGsdEntries`) still touches `dest` whenever it already exists.
1809
1799
  if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
1810
- throw new Error(`installAgentsKindStandalone: destDir "${dest}" contains a symlink the install root "${installRoot}" does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`);
1800
+ throw new Error(runtimeArtifactInstallPlan.installAgentsKindStandaloneSymlinkRefusalMessage(dest, installRoot));
1811
1801
  }
1812
1802
  // #2875 defect fix, corrected: the ORIGINAL fix returned `null` (no-op)
1813
1803
  // whenever a restricted profile (e.g. --minimal) staged ZERO agents,
@@ -1881,9 +1871,11 @@ function installOpencodeFamilyCommands(runtime, destDir, srcDir, pathPrefix, res
1881
1871
  // already satisfies the fold-to-descriptor requirement. Only the SKILLS
1882
1872
  // converter site above (installOpencodeFamilySkills) has a real
1883
1873
  // `converter` string to key on via SKILLS_CONVERTER_REGISTRY.
1884
- content = hostBehaviorsFor(runtime).frontmatterDialect === 'kilo'
1885
- ? runtimeArtifactConversion.convertClaudeToKiloFrontmatter(content)
1886
- : runtimeArtifactConversion.convertClaudeToOpencodeFrontmatter(content);
1874
+ // #5715: the converter runs after the prefix pass; shield the emitted prefix from it.
1875
+ const frontmatterConverter = hostBehaviorsFor(runtime).frontmatterDialect === 'kilo'
1876
+ ? (c) => runtimeArtifactConversion.convertClaudeToKiloFrontmatter(c)
1877
+ : (c) => runtimeArtifactConversion.convertClaudeToOpencodeFrontmatter(c);
1878
+ content = runtimeArtifactConversion._shieldEmittedPrefix(content, pathPrefix, frontmatterConverter);
1887
1879
  installFs().writeFileSync(node_path_1.default.join(destDir, destName), content);
1888
1880
  }
1889
1881
  }
@@ -259,4 +259,8 @@ function mkInstallTempDir(prefix) {
259
259
  installFs().mkdirSync(dir, { recursive: true });
260
260
  return dir;
261
261
  }
262
- module.exports = { installFs, withInstallFs, mkInstallTempDir };
262
+ /** True when no fake adapter is active, i.e. installFs() is the real `node:fs` (#5759). */
263
+ function isRealInstallFs() {
264
+ return current === REAL_ADAPTER;
265
+ }
266
+ module.exports = { installFs, withInstallFs, mkInstallTempDir, isRealInstallFs };