@opengsd/gsd-core 1.20.1 → 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 (135) 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 +234 -126
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/gsd-core/bin/gsd-tools.cjs +450 -753
  11. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  12. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  13. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  14. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  15. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  16. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  17. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  18. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  19. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  20. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  21. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  22. package/gsd-core/bin/lib/commands.cjs +80 -2
  23. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  24. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  25. package/gsd-core/bin/lib/config.cjs +139 -101
  26. package/gsd-core/bin/lib/configuration.cjs +41 -2
  27. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  28. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  29. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  30. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  31. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  32. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  33. package/gsd-core/bin/lib/graphify.cjs +19 -2
  34. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  35. package/gsd-core/bin/lib/init.cjs +13 -11
  36. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  37. package/gsd-core/bin/lib/install-engine.cjs +11 -19
  38. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  39. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  40. package/gsd-core/bin/lib/io.cjs +6 -0
  41. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  42. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  43. package/gsd-core/bin/lib/milestone.cjs +8 -6
  44. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  45. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  46. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  47. package/gsd-core/bin/lib/phase.cjs +107 -31
  48. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  49. package/gsd-core/bin/lib/planning-snapshot.cjs +8 -4
  50. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  51. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  52. package/gsd-core/bin/lib/project-context.cjs +439 -0
  53. package/gsd-core/bin/lib/project-root.cjs +9 -230
  54. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  55. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  56. package/gsd-core/bin/lib/roadmap-parser.cjs +328 -50
  57. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  58. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  59. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -4
  60. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  61. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  62. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  63. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  64. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  65. package/gsd-core/bin/lib/state-transition.cjs +91 -41
  66. package/gsd-core/bin/lib/state.cjs +446 -61
  67. package/gsd-core/bin/lib/surface.cjs +4 -7
  68. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  69. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  70. package/gsd-core/bin/lib/verify.cjs +49 -6
  71. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  72. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  73. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  74. package/gsd-core/references/checkpoints.md +1 -1
  75. package/gsd-core/references/planner-guidance.md +11 -0
  76. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  77. package/gsd-core/references/planner-reversibility.md +2 -0
  78. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  79. package/gsd-core/workflows/autonomous.md +4 -4
  80. package/gsd-core/workflows/code-review-fix.md +5 -7
  81. package/gsd-core/workflows/execute-phase.md +21 -7
  82. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  83. package/gsd-core/workflows/plan-phase.md +18 -19
  84. package/hooks/dist/gsd-agent-isolation-guard.js +3 -2
  85. package/hooks/dist/gsd-config-reload.js +2 -2
  86. package/hooks/dist/gsd-context-monitor.js +2 -1
  87. package/hooks/dist/gsd-cursor-subagent-start.js +8 -5
  88. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  89. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  90. package/hooks/dist/gsd-statusline.js +3 -2
  91. package/hooks/dist/gsd-workflow-guard.js +2 -1
  92. package/hooks/dist/lib/config-read.js +49 -0
  93. package/hooks/dist/lib/git-cmd.js +3 -1
  94. package/hooks/dist/lib/git-head-tree.js +474 -0
  95. package/hooks/dist/lib/isolation-sentinel.js +97 -17
  96. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  97. package/hooks/gsd-agent-isolation-guard.js +3 -2
  98. package/hooks/gsd-config-reload.js +2 -2
  99. package/hooks/gsd-context-monitor.js +2 -1
  100. package/hooks/gsd-cursor-subagent-start.js +8 -5
  101. package/hooks/gsd-read-injection-scanner.js +2 -2
  102. package/hooks/gsd-secret-read-guard.js +141 -6
  103. package/hooks/gsd-statusline.js +3 -2
  104. package/hooks/gsd-workflow-guard.js +2 -1
  105. package/hooks/lib/config-read.js +49 -0
  106. package/hooks/lib/git-cmd.js +3 -1
  107. package/hooks/lib/git-head-tree.js +474 -0
  108. package/hooks/lib/isolation-sentinel.js +97 -17
  109. package/hooks/lib/secret-read-exemptions.js +161 -0
  110. package/package.json +7 -2
  111. package/pi/package-agents/gsd-executor.md +1 -1
  112. package/pi/package-agents/gsd-plan-checker.md +1 -1
  113. package/pi/package-agents/gsd-planner.md +3 -3
  114. package/pi/package-agents/gsd-verifier.md +2 -2
  115. package/pi/package-agents/manifest.json +4 -4
  116. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  117. package/scripts/changeset/cli.cjs +2 -2
  118. package/scripts/changeset/serialize.cjs +12 -2
  119. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  120. package/scripts/ci-fix-next-rerun.cjs +149 -0
  121. package/scripts/ci-next-health.cjs +52 -9
  122. package/scripts/ci-test-scope.cjs +77 -71
  123. package/scripts/command-contract-helpers.cjs +56 -7
  124. package/scripts/docs-guard-registry.cjs +3 -0
  125. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  126. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  127. package/scripts/lib/macos-conformance-tier.generated.cjs +55 -47
  128. package/scripts/lib/platform-conformance-tier.generated.cjs +19 -64
  129. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  130. package/scripts/lint-removed-but-needed.cjs +562 -50
  131. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  132. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  133. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  134. package/skills/gsd-autonomous/SKILL.md +1 -1
  135. 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 {
@@ -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");
@@ -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 !==
@@ -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 {
@@ -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
@@ -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');
@@ -557,7 +549,7 @@ function _resolveDevPreferencesSkillTarget(targetDir, runtime, scope = 'global')
557
549
  // legacy dev-preferences migration lands in the SAME tree the installer
558
550
  // and surface-apply use. Runtimes with no `home` override are unaffected.
559
551
  hasHomeOverride = skillsKindEntry.home != null;
560
- installRoot = skillsKindEntry.home ?? targetDir;
552
+ installRoot = runtimeArtifactInstallPlan.resolveRuntimeArtifactInstallRoot(targetDir, skillsKindEntry);
561
553
  skillDir = node_path_1.default.join(runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, skillsKindEntry.destSubpath), stemName);
562
554
  }
563
555
  else {
@@ -666,7 +658,7 @@ function _copyStaged(stagedDir, destDir, kind, configDir, runtime) {
666
658
  // that case this defense-in-depth check must confine against the resolved
667
659
  // alternate root instead, matching the upstream gate's own root selection in
668
660
  // createRuntimeArtifactInstallPlan.
669
- const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
661
+ const installRoot = runtimeArtifactInstallPlan.resolveRuntimeArtifactInstallRoot(configDir, kind);
670
662
  // Strict-subpath + NUL containment via the canonical gate (shared with the
671
663
  // layout-driven install plan); throws if destDir escapes the install root.
672
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.
@@ -1300,7 +1292,7 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, res
1300
1292
  // previously lived inline in bin/install.js.
1301
1293
  const behaviors = hostBehaviorsFor(runtime);
1302
1294
  const projectDir = scope === 'global' ? process.cwd() : configDir;
1303
- if (behaviors.combinedFamilyInstall) {
1295
+ if (runtimeArtifactInstallPlan.shouldInstallCombinedFamily(behaviors)) {
1304
1296
  // #2329: combined-family runtimes (OpenCode/Kilo) bypass
1305
1297
  // _runLegacyInstallMigrations below entirely (early return), so their
1306
1298
  // legacy-directory cleanup needs its own pre-materialization hook here.
@@ -1378,12 +1370,12 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, res
1378
1370
  // A fake adapter can change what those probes observe for paths that
1379
1371
  // were never real to begin with; it cannot make this `if` pass for a
1380
1372
  // path the real filesystem would refuse.
1381
- const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
1373
+ const installRoot = runtimeArtifactInstallPlan.resolveRuntimeArtifactInstallRoot(configDir, kind);
1382
1374
  // #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
1383
1375
  // Threat model from #1704 / ADR-1239 Phase B preserved: path-traversal and
1384
1376
  // resolved-target-equals-root still refuse regardless of opt-in.
1385
1377
  if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
1386
- 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));
1387
1379
  }
1388
1380
  // #2875 defect fix (--minimal regression closed): a restricted profile
1389
1381
  // (e.g. --minimal) can legitimately stage ZERO agents — no skill in
@@ -1577,6 +1569,7 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
1577
1569
  const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
1578
1570
  if (!skillsKindEntry)
1579
1571
  return 0;
1572
+ const installRoot = runtimeArtifactInstallPlan.resolveRuntimeArtifactInstallRoot(targetDir, skillsKindEntry);
1580
1573
  // #3712: combined-family runtimes take installRuntimeArtifacts' early return
1581
1574
  // BEFORE its guard runs, and this writer honors `skillsKindEntry.home` below and
1582
1575
  // then prunes that destination. opencode/kilo declare no `home` today, so there
@@ -1603,13 +1596,12 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
1603
1596
  // OpenCode/Kilo writer lands in the SAME tree the installer and surface-apply use.
1604
1597
  // Runtimes with no `home` override (opencode, kilo today) are unaffected. Must stay
1605
1598
  // in lockstep with the sibling writers — the destination-parity test enforces it.
1606
- const installRoot = skillsKindEntry.home ?? targetDir;
1607
1599
  const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, skillsKindEntry.destSubpath);
1608
1600
  // Symlink-escape guard: reject if any path component between installRoot and
1609
1601
  // dest is a symlink that would redirect writes outside the install root.
1610
1602
  // #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
1611
1603
  if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
1612
- 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));
1613
1605
  }
1614
1606
  installFs().mkdirSync(dest, { recursive: true });
1615
1607
  // Preserve user-owned GSD-prefixed skill dirs across the gsd-* prune.
@@ -1798,14 +1790,14 @@ function installAgentsKindStandalone(runtime, targetDir, scope, resolvedProfile,
1798
1790
  const stagedAgentFiles = installFs().existsSync(stagedDir)
1799
1791
  ? installFs().readdirSync(stagedDir).filter((f) => f.endsWith('.md'))
1800
1792
  : [];
1801
- const installRoot = (typeof agentsKindEntry.home === 'string' && agentsKindEntry.home !== '') ? agentsKindEntry.home : targetDir;
1793
+ const installRoot = runtimeArtifactInstallPlan.resolveRuntimeArtifactInstallRoot(targetDir, agentsKindEntry);
1802
1794
  const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, agentsKindEntry.destSubpath);
1803
1795
  // Symlink-escape guard — same gate _copyStaged/installOpencodeFamilySkills apply
1804
1796
  // to their own writes (#2393 GSD_ALLOW_SYMLINKED_DEST opt-in preserved). Runs
1805
1797
  // even when nothing will be written this call — the stale-agent prune below
1806
1798
  // (`_removeGsdEntries`) still touches `dest` whenever it already exists.
1807
1799
  if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
1808
- 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));
1809
1801
  }
1810
1802
  // #2875 defect fix, corrected: the ORIGINAL fix returned `null` (no-op)
1811
1803
  // whenever a restricted profile (e.g. --minimal) staged ZERO agents,
@@ -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 };
@@ -35,9 +35,33 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
35
35
  const node_path_1 = __importDefault(require("node:path"));
36
36
  const node_os_1 = __importDefault(require("node:os"));
37
37
  const runtime_homes_cjs_1 = require("./runtime-homes.cjs");
38
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- config-file-read.cjs is an export= CommonJS module
39
+ const configFileReadMod = require("./config-file-read.cjs");
40
+ const { unboundedConfigFileReason, printablePath, readConfigFileBounded } = configFileReadMod;
41
+ /**
42
+ * Text of an install-time config file, or null (with a printable-path warning) when it is not a
43
+ * regular file within the cap or cannot be read. The real fs uses the descriptor-bound reader;
44
+ * an injected adapter gets the stat guard before its own read (#5759).
45
+ */
46
+ function readInstallConfigText(absPath, label) {
47
+ try {
48
+ if (isRealInstallFs())
49
+ return readConfigFileBounded(absPath);
50
+ const unbounded = unboundedConfigFileReason(absPath);
51
+ if (unbounded !== null) {
52
+ process.stderr.write(`gsd: warning — could not read ${label} (${printablePath(absPath)}): ${unbounded}\n`);
53
+ return null;
54
+ }
55
+ return installFs().readFileSync(absPath, 'utf-8');
56
+ }
57
+ catch (err) {
58
+ process.stderr.write(`gsd: warning — could not read ${label} (${printablePath(absPath)}): ${printablePath(err.message)}\n`);
59
+ return null;
60
+ }
61
+ }
38
62
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- install-fs-adapter.cjs is an export= CommonJS module
39
63
  const installFsAdapter = require("./install-fs-adapter.cjs");
40
- const { installFs } = installFsAdapter;
64
+ const { installFs, isRealInstallFs } = installFsAdapter;
41
65
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- install-effort-resolver.cjs is an export= CommonJS module
42
66
  const installEffortResolver = require("./install-effort-resolver.cjs");
43
67
  const { _readGsdConfigFile, _findAncestorGsdConfigPath } = installEffortResolver;
@@ -62,7 +86,9 @@ function readGsdGlobalModelOverrides(options = {}) {
62
86
  const defaultsPath = node_path_1.default.join(home, '.gsd', 'defaults.json');
63
87
  if (!installFs().existsSync(defaultsPath))
64
88
  return null;
65
- const raw = installFs().readFileSync(defaultsPath, 'utf-8');
89
+ const raw = readInstallConfigText(defaultsPath, 'global defaults'); // #5759: never block on a planted FIFO
90
+ if (raw === null)
91
+ return null;
66
92
  const parsed = JSON.parse(raw);
67
93
  const overrides = parsed.model_overrides;
68
94
  if (!overrides || typeof overrides !== 'object')
@@ -99,7 +125,11 @@ function readGsdEffectiveModelOverrides(targetDir = null, options = {}) {
99
125
  const candidate = _findAncestorGsdConfigPath(targetDir);
100
126
  if (candidate) {
101
127
  try {
102
- const parsed = JSON.parse(installFs().readFileSync(candidate, 'utf-8'));
128
+ // #5759: refuse a FIFO/device/oversized project config before the adapter's blocking read.
129
+ const text = readInstallConfigText(candidate, 'project config');
130
+ if (text === null)
131
+ throw new Error('unusable project config');
132
+ const parsed = JSON.parse(text);
103
133
  if (parsed && typeof parsed === 'object' && parsed.model_overrides && typeof parsed.model_overrides === 'object') {
104
134
  projectOverrides = parsed.model_overrides;
105
135
  }
@@ -364,6 +364,10 @@ const ERROR_REASON = Object.freeze({
364
364
  COMMIT_DOCS_GUARD_NOT_A_REPO: 'commit_docs_guard_not_a_repo',
365
365
  COMMIT_DOCS_GUARD_FOREIGN_HOOK: 'commit_docs_guard_foreign_hook',
366
366
  COMMIT_DOCS_GUARD_HOOKS_PATH_SET: 'commit_docs_guard_hooks_path_set',
367
+ // commit verb (#6043, ADR-4908 §3): it commits in the worktree the process
368
+ // runs in, or refuses with one of these. Wire values are the typed `reason`.
369
+ COMMIT_NOT_GIT_REPO: 'not_git_repo',
370
+ COMMIT_OUTSIDE_WORKTREE: 'outside_worktree',
367
371
  // security-scan
368
372
  SECURITY_SCAN_FAILED: 'security_scan_failed',
369
373
  // --pick (#3365 / #3358, ADR-3473 §8.4): an absent field or non-JSON
@@ -499,6 +503,8 @@ const REASON_TO_OUTCOME = Object.freeze({
499
503
  [ERROR_REASON.COMMIT_DOCS_GUARD_NOT_A_REPO]: 'UNAVAILABLE',
500
504
  [ERROR_REASON.COMMIT_DOCS_GUARD_FOREIGN_HOOK]: 'UNAVAILABLE',
501
505
  [ERROR_REASON.COMMIT_DOCS_GUARD_HOOKS_PATH_SET]: 'UNAVAILABLE',
506
+ [ERROR_REASON.COMMIT_NOT_GIT_REPO]: 'UNAVAILABLE',
507
+ [ERROR_REASON.COMMIT_OUTSIDE_WORKTREE]: 'FAIL',
502
508
  // Its own docstring: "an absent field or non-JSON command output is a
503
509
  // failure, never a demotion to an empty answer" — the field/output was
504
510
  // supposed to be there and was not; a prerequisite of the query failed.
@@ -12,6 +12,7 @@
12
12
  */
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.XML_DECISION_TAG_NAMES = void 0;
15
+ exports.matchFenceDelimiter = matchFenceDelimiter;
15
16
  exports.stripFencedCode = stripFencedCode;
16
17
  exports.stripInlineCode = stripInlineCode;
17
18
  exports.scanInlineCodeSpans = scanInlineCodeSpans;
@@ -24,6 +25,7 @@ exports.iterateBullets = iterateBullets;
24
25
  exports.updateBullet = updateBullet;
25
26
  exports.updateHeading = updateHeading;
26
27
  exports.extractTaggedBlocks = extractTaggedBlocks;
28
+ exports.extractTaggedBlockSpans = extractTaggedBlockSpans;
27
29
  exports.stripTaggedBlocks = stripTaggedBlocks;
28
30
  exports.editOutsideTaggedBlocks = editOutsideTaggedBlocks;
29
31
  exports.maskFences = maskFences;
@@ -35,6 +37,30 @@ exports.editHeadingSlice = editHeadingSlice;
35
37
  exports.deleteSection = deleteSection;
36
38
  exports.extractXmlTagBodies = extractXmlTagBodies;
37
39
  const pattern_cjs_1 = require("./pattern.cjs");
40
+ const FENCE_DELIMITER_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
41
+ /**
42
+ * The single CommonMark fence-delimiter grammar: ≤3 spaces of indent, a run of
43
+ * ≥3 backticks or tildes, then trailing text. A trailing `\r` is stripped first
44
+ * (CRLF safety). Returns `null` when the line is not a delimiter line at all.
45
+ * `stripFencedCode`, `scanFencedBlocks`, `tokenizeHeadings` and the roadmap
46
+ * parser's comment/fence blanking all classify delimiter lines through this one
47
+ * matcher.
48
+ */
49
+ function matchFenceDelimiter(line) {
50
+ const m = FENCE_DELIMITER_RE.exec(line.replace(/\r$/, ''));
51
+ if (m === null)
52
+ return null;
53
+ const char = m[2][0];
54
+ const len = m[2].length;
55
+ const trailing = m[3];
56
+ return {
57
+ char,
58
+ len,
59
+ trailing,
60
+ canOpen: !(char === '`' && trailing.includes('`')),
61
+ closes: (opener) => char === opener.char && len >= opener.len && /^\s*$/.test(trailing),
62
+ };
63
+ }
38
64
  // ─── stripFencedCode ──────────────────────────────────────────────────────────
39
65
  /**
40
66
  * CommonMark-correct fenced-code-block stripper.
@@ -62,27 +88,20 @@ function stripFencedCode(content) {
62
88
  const lines = content.split('\n');
63
89
  const kept = [];
64
90
  let openFence = null;
65
- // Matches: optional indent (≤3 spaces per CommonMark), fence run, optional info string
66
- const delimRe = /^( {0,3})(`{3,}|~{3,})(.*)$/;
67
91
  for (const rawLine of lines) {
68
- // Strip trailing \r for delimiter matching (CRLF safety)
69
- const line = rawLine.replace(/\r$/, '');
70
- const m = delimRe.exec(line);
92
+ const m = matchFenceDelimiter(rawLine);
71
93
  if (m) {
72
- const char = m[2][0];
73
- const len = m[2].length;
74
- const trailing = m[3];
75
94
  if (openFence === null) {
76
95
  // CommonMark §4.5: backtick fence info string must not contain a backtick.
77
96
  // If it does, this line is NOT a valid fence opener (treat as ordinary content).
78
- if (char === '`' && trailing.includes('`')) {
97
+ if (!m.canOpen) {
79
98
  kept.push(rawLine);
80
99
  continue;
81
100
  }
82
101
  // Opening delimiter — record fence state, drop this line
83
- openFence = { char, len };
102
+ openFence = { char: m.char, len: m.len };
84
103
  }
85
- else if (char === openFence.char && len >= openFence.len && /^\s*$/.test(trailing)) {
104
+ else if (m.closes(openFence)) {
86
105
  // Closing delimiter (same char, sufficient length, no trailing content) — close and drop
87
106
  openFence = null;
88
107
  }
@@ -213,9 +232,8 @@ function stripInlineCodeLine(line) {
213
232
  * different-info-string fence, a fence nested/indented inside another fence,
214
233
  * and a `~~~` fence are all classified exactly as `stripFencedCode` would.
215
234
  *
216
- * Tracked duplication (same status as `tokenizeHeadings`'s copy, see its
217
- * comment above): this is a second independent copy of the fence state
218
- * machine, pending a T-tier consolidation.
235
+ * Delimiter classification goes through `matchFenceDelimiter`, the one shared
236
+ * grammar (also used by `stripFencedCode` and `tokenizeHeadings`).
219
237
  *
220
238
  * Exported so `context-predicates.cts` can consume this seam directly for its
221
239
  * line-preserving fenced-line skip detection, instead of carrying a third
@@ -223,24 +241,19 @@ function stripInlineCodeLine(line) {
223
241
  * comment).
224
242
  */
225
243
  function scanFencedBlocks(lines) {
226
- const delimRe = /^( {0,3})(`{3,}|~{3,})(.*)$/;
227
244
  const blocks = [];
228
245
  let open = null;
229
246
  for (let i = 0; i < lines.length; i++) {
230
- const line = lines[i].replace(/\r$/, '');
231
- const m = delimRe.exec(line);
247
+ const m = matchFenceDelimiter(lines[i]);
232
248
  if (!m)
233
249
  continue;
234
- const char = m[2][0];
235
- const len = m[2].length;
236
- const trailing = m[3];
237
250
  if (open === null) {
238
251
  // CommonMark §4.5: backtick fence info string must not contain a backtick.
239
- if (char === '`' && trailing.includes('`'))
252
+ if (!m.canOpen)
240
253
  continue; // not a valid opener — ordinary content
241
- open = { char, len, infoString: trailing.trim(), openLineIdx: i };
254
+ open = { char: m.char, len: m.len, infoString: m.trailing.trim(), openLineIdx: i };
242
255
  }
243
- else if (char === open.char && len >= open.len && /^\s*$/.test(trailing)) {
256
+ else if (m.closes(open)) {
244
257
  blocks.push({
245
258
  char: open.char,
246
259
  len: open.len,
@@ -319,30 +332,26 @@ function tokenizeHeadings(content) {
319
332
  const tokens = [];
320
333
  // We re-run the fence state machine to know which lines are "kept", so we
321
334
  // can map line index in original to whether it survived.
322
- const delimRe = /^( {0,3})(`{3,}|~{3,})(.*)$/;
323
335
  let openFence = null;
324
336
  // Accumulate character offset as we iterate lines
325
337
  let charOffset = 0;
326
338
  for (let i = 0; i < originalLines.length; i++) {
327
339
  const rawLine = originalLines[i];
328
340
  const line = rawLine.replace(/\r$/, '');
329
- const dm = delimRe.exec(line);
341
+ const dm = matchFenceDelimiter(rawLine);
330
342
  if (dm) {
331
- const char = dm[2][0];
332
- const len = dm[2].length;
333
- const trailing = dm[3];
334
343
  if (openFence === null) {
335
344
  // CommonMark §4.5: backtick fence info string must not contain a backtick.
336
- if (char === '`' && trailing.includes('`')) {
345
+ if (!dm.canOpen) {
337
346
  // Not a valid fence opener — check for heading on this line (will fall through)
338
347
  }
339
348
  else {
340
- openFence = { char, len };
349
+ openFence = { char: dm.char, len: dm.len };
341
350
  charOffset += rawLine.length + 1;
342
351
  continue;
343
352
  }
344
353
  }
345
- else if (char === openFence.char && len >= openFence.len && /^\s*$/.test(trailing)) {
354
+ else if (dm.closes(openFence)) {
346
355
  openFence = null;
347
356
  charOffset += rawLine.length + 1;
348
357
  continue;
@@ -818,6 +827,28 @@ function extractTaggedBlocks(content, tagName, allowAttributes = false) {
818
827
  }
819
828
  return results;
820
829
  }
830
+ /**
831
+ * Offset spans of the bodies `extractTaggedBlocks` returns (#4309): same
832
+ * pattern, same blocks, but each body is reported as `{ start, end }` into
833
+ * `content` so a caller can test whether an arbitrary offset lies inside a
834
+ * block (and dedupe nested blocks) without re-harvesting the text.
835
+ * `content.slice(span.start, span.end)` equals the matching extracted body.
836
+ */
837
+ function extractTaggedBlockSpans(content, tagName, allowAttributes = false) {
838
+ if (typeof content !== 'string' || content.length === 0)
839
+ return [];
840
+ if (typeof tagName !== 'string' || tagName.length === 0)
841
+ return [];
842
+ const closeLength = `</${tagName}>`.length;
843
+ const pattern = taggedBlockPattern(tagName, 'g', allowAttributes);
844
+ const spans = [];
845
+ let match;
846
+ while ((match = pattern.exec(content)) !== null) {
847
+ const end = match.index + match[0].length - closeLength;
848
+ spans.push({ start: end - match[1].length, end });
849
+ }
850
+ return spans;
851
+ }
821
852
  /**
822
853
  * Build the single, ReDoS-safe `<tag>…</tag>` block regex shared by
823
854
  * `extractTaggedBlocks` (extract bodies) and `stripTaggedBlocks` (remove blocks).