@opengsd/gsd-core 1.13.0 → 1.14.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 (257) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-advisor-researcher.compact.md +85 -0
  4. package/agents/gsd-ai-researcher.compact.md +96 -0
  5. package/agents/gsd-assumptions-analyzer.compact.md +81 -0
  6. package/agents/gsd-code-fixer.compact.md +458 -0
  7. package/agents/gsd-code-fixer.md +5 -5
  8. package/agents/gsd-code-reviewer.compact.md +269 -0
  9. package/agents/gsd-code-reviewer.md +15 -3
  10. package/agents/gsd-codebase-mapper.compact.md +760 -0
  11. package/agents/gsd-debug-session-manager.compact.md +345 -0
  12. package/agents/gsd-doc-classifier.compact.md +192 -0
  13. package/agents/gsd-doc-synthesizer.compact.md +200 -0
  14. package/agents/gsd-doc-verifier.compact.md +143 -0
  15. package/agents/gsd-doc-writer.compact.md +440 -0
  16. package/agents/gsd-dom-verifier.compact.md +138 -0
  17. package/agents/gsd-domain-researcher.compact.md +141 -0
  18. package/agents/gsd-eval-auditor.compact.md +160 -0
  19. package/agents/gsd-eval-planner.compact.md +137 -0
  20. package/agents/gsd-framework-selector.compact.md +82 -0
  21. package/agents/gsd-integration-checker.compact.md +245 -0
  22. package/agents/gsd-intel-updater.compact.md +226 -0
  23. package/agents/gsd-mempalace-curator.compact.md +45 -0
  24. package/agents/gsd-nyquist-auditor.compact.md +179 -0
  25. package/agents/gsd-pattern-mapper.compact.md +275 -0
  26. package/agents/gsd-project-researcher.compact.md +587 -0
  27. package/agents/gsd-research-synthesizer.compact.md +212 -0
  28. package/agents/gsd-roadmapper.compact.md +454 -0
  29. package/agents/gsd-roadmapper.md +13 -0
  30. package/agents/gsd-security-auditor.compact.md +162 -0
  31. package/agents/gsd-ui-auditor.compact.md +404 -0
  32. package/agents/gsd-ui-checker.compact.md +277 -0
  33. package/agents/gsd-ui-researcher.compact.md +282 -0
  34. package/agents/gsd-user-profiler.compact.md +108 -0
  35. package/bin/install.js +206 -68
  36. package/commands/gsd/cleanup.md +1 -0
  37. package/commands/gsd/code-review.md +2 -1
  38. package/commands/gsd/complete-milestone.md +1 -0
  39. package/commands/gsd/config.md +1 -0
  40. package/commands/gsd/debug.md +1 -0
  41. package/commands/gsd/graphify.md +1 -0
  42. package/commands/gsd/health.md +1 -0
  43. package/commands/gsd/mempalace-capture.md +1 -0
  44. package/commands/gsd/mempalace-recall.md +1 -0
  45. package/commands/gsd/new-milestone.md +1 -0
  46. package/commands/gsd/new-project.md +1 -0
  47. package/commands/gsd/next.md +1 -0
  48. package/commands/gsd/pause-work.md +1 -0
  49. package/commands/gsd/phase.md +1 -0
  50. package/commands/gsd/pr-branch.md +1 -0
  51. package/commands/gsd/resume-work.md +1 -0
  52. package/commands/gsd/review-backlog.md +1 -0
  53. package/commands/gsd/settings.md +2 -1
  54. package/commands/gsd/stats.md +1 -0
  55. package/commands/gsd/thread.md +1 -0
  56. package/commands/gsd/workspace.md +1 -0
  57. package/commands/gsd/workstreams.md +1 -0
  58. package/gsd-core/bin/check-latest-version.cjs +8 -3
  59. package/gsd-core/bin/gsd-tools.cjs +338 -125
  60. package/gsd-core/bin/lib/adr-parser.cjs +1 -1
  61. package/gsd-core/bin/lib/artifacts.cjs +2 -1
  62. package/gsd-core/bin/lib/audit.cjs +39 -22
  63. package/gsd-core/bin/lib/broken-windows.cjs +168 -49
  64. package/gsd-core/bin/lib/capability-lifecycle.cjs +10 -6
  65. package/gsd-core/bin/lib/capability-loader.cjs +135 -1
  66. package/gsd-core/bin/lib/capability-registry.cjs +79 -67
  67. package/gsd-core/bin/lib/capability-source.cjs +19 -2
  68. package/gsd-core/bin/lib/capability-validator.cjs +14 -1
  69. package/gsd-core/bin/lib/check-command-router.cjs +113 -36
  70. package/gsd-core/bin/lib/code-review-depth.cjs +2 -2
  71. package/gsd-core/bin/lib/commands.cjs +650 -72
  72. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  73. package/gsd-core/bin/lib/config.cjs +153 -38
  74. package/gsd-core/bin/lib/coverage.cjs +1 -1
  75. package/gsd-core/bin/lib/decisions.cjs +137 -34
  76. package/gsd-core/bin/lib/external-descriptor-trust.cjs +29 -14
  77. package/gsd-core/bin/lib/gsd2-import.cjs +1 -2
  78. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +12 -1
  79. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +1 -1
  80. package/gsd-core/bin/lib/init.cjs +409 -47
  81. package/gsd-core/bin/lib/install-engine.cjs +16 -3
  82. package/gsd-core/bin/lib/install-profiles.cjs +14 -0
  83. package/gsd-core/bin/lib/installer-migrations.cjs +33 -4
  84. package/gsd-core/bin/lib/loop-resolver.cjs +50 -31
  85. package/gsd-core/bin/lib/mcp-catalog.cjs +2 -2
  86. package/gsd-core/bin/lib/milestone.cjs +19 -8
  87. package/gsd-core/bin/lib/model-resolver.cjs +101 -10
  88. package/gsd-core/bin/lib/phase-command-router.cjs +7 -1
  89. package/gsd-core/bin/lib/phase-id.cjs +161 -22
  90. package/gsd-core/bin/lib/phase-lifecycle.cjs +61 -0
  91. package/gsd-core/bin/lib/phase.cjs +167 -63
  92. package/gsd-core/bin/lib/planning-inspect.cjs +34 -18
  93. package/gsd-core/bin/lib/planning-snapshot.cjs +61 -12
  94. package/gsd-core/bin/lib/planning-workspace.cjs +50 -1
  95. package/gsd-core/bin/lib/pristine-baseline.cjs +182 -0
  96. package/gsd-core/bin/lib/prohibition-enforcement.cjs +91 -4
  97. package/gsd-core/bin/lib/quick-batch.cjs +1 -1
  98. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +61 -2
  99. package/gsd-core/bin/lib/research-store.cjs +11 -12
  100. package/gsd-core/bin/lib/review-lane-invocation.cjs +23 -0
  101. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +337 -0
  102. package/gsd-core/bin/lib/roadmap-parser.cjs +56 -15
  103. package/gsd-core/bin/lib/roadmap.cjs +108 -14
  104. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +27 -10
  105. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +12 -3
  106. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +13 -5
  107. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +193 -4
  108. package/gsd-core/bin/lib/security.cjs +126 -7
  109. package/gsd-core/bin/lib/state-document.cjs +130 -28
  110. package/gsd-core/bin/lib/state-md-schema.cjs +21 -14
  111. package/gsd-core/bin/lib/state-transition.cjs +142 -28
  112. package/gsd-core/bin/lib/state.cjs +223 -27
  113. package/gsd-core/bin/lib/surface.cjs +60 -2
  114. package/gsd-core/bin/lib/task-command-router.cjs +12 -6
  115. package/gsd-core/bin/lib/uat.cjs +1 -1
  116. package/gsd-core/bin/lib/update-context.cjs +30 -24
  117. package/gsd-core/bin/lib/vendor/js-yaml.cjs +11 -3
  118. package/gsd-core/bin/lib/verification.cjs +47 -15
  119. package/gsd-core/bin/lib/verify-command-grounding.cjs +1 -1
  120. package/gsd-core/bin/lib/verify.cjs +188 -23
  121. package/gsd-core/bin/lib/workstream-inventory.cjs +1 -0
  122. package/gsd-core/bin/lib/worktree-safety.cjs +13 -7
  123. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  124. package/gsd-core/bin/shared/config-schema.manifest.json +5 -0
  125. package/gsd-core/bin/verify-reapply-patches.cjs +439 -80
  126. package/gsd-core/references/compact-content-gate.md +66 -0
  127. package/gsd-core/references/loop-hook-dispatch.md +18 -0
  128. package/gsd-core/references/model-profiles.md +12 -3
  129. package/gsd-core/references/planning-config.md +3 -0
  130. package/gsd-core/references/tdd.md +5 -2
  131. package/gsd-core/references/thinking-models-planning.md +18 -2
  132. package/gsd-core/references/verification-patterns.md +17 -4
  133. package/gsd-core/references/worktree-path-safety.md +112 -2
  134. package/gsd-core/templates/README.md +7 -1
  135. package/gsd-core/templates/state.md +6 -3
  136. package/gsd-core/templates/summary.compact.md +212 -0
  137. package/gsd-core/templates/user-setup.compact.md +199 -0
  138. package/gsd-core/templates/user-setup.md +0 -9
  139. package/gsd-core/workflows/add-todo.md +3 -2
  140. package/gsd-core/workflows/autonomous.md +13 -10
  141. package/gsd-core/workflows/check-todos.md +4 -2
  142. package/gsd-core/workflows/cleanup.md +3 -1
  143. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +7 -0
  144. package/gsd-core/workflows/code-review-fix.md +3 -3
  145. package/gsd-core/workflows/code-review.md +156 -30
  146. package/gsd-core/workflows/complete-milestone/detail/elaboration.md +274 -0
  147. package/gsd-core/workflows/complete-milestone.md +39 -262
  148. package/gsd-core/workflows/docs-update/detail/elaboration.md +179 -0
  149. package/gsd-core/workflows/docs-update.md +14 -155
  150. package/gsd-core/workflows/execute-phase/detail/elaboration.md +124 -0
  151. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +18 -3
  152. package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +56 -0
  153. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +7 -2
  154. package/gsd-core/workflows/execute-phase/steps/executor-progress-policy.md +43 -0
  155. package/gsd-core/workflows/execute-phase/steps/sequential-root-pin.md +35 -0
  156. package/gsd-core/workflows/execute-phase.md +53 -152
  157. package/gsd-core/workflows/execute-plan.md +20 -7
  158. package/gsd-core/workflows/help/modes/full.compact.md +398 -0
  159. package/gsd-core/workflows/help.md +1 -1
  160. package/gsd-core/workflows/map-codebase.md +50 -3
  161. package/gsd-core/workflows/new-milestone.md +54 -12
  162. package/gsd-core/workflows/new-project/detail/elaboration.md +216 -0
  163. package/gsd-core/workflows/new-project.md +32 -202
  164. package/gsd-core/workflows/plan-phase/detail/elaboration.md +209 -0
  165. package/gsd-core/workflows/plan-phase.md +22 -181
  166. package/gsd-core/workflows/pr-branch.md +19 -7
  167. package/gsd-core/workflows/quick.md +8 -1
  168. package/gsd-core/workflows/reapply-patches.md +77 -3
  169. package/gsd-core/workflows/settings.md +18 -5
  170. package/gsd-core/workflows/update.md +7 -5
  171. package/gsd-core/workflows/verify-work/detail/elaboration.md +230 -0
  172. package/gsd-core/workflows/verify-work.md +20 -180
  173. package/hooks/dist/gsd-agent-isolation-guard.js +42 -16
  174. package/hooks/dist/gsd-context-monitor.js +88 -15
  175. package/hooks/dist/gsd-cursor-subagent-start.js +34 -14
  176. package/hooks/dist/gsd-secret-read-guard.js +44 -18
  177. package/hooks/dist/gsd-statusline.js +11 -7
  178. package/hooks/dist/gsd-validate-commit.sh +34 -4
  179. package/hooks/dist/gsd-worktree-path-guard.js +25 -14
  180. package/hooks/dist/gsd-write-guard.js +46 -1
  181. package/hooks/dist/lib/dispatch-identity.js +187 -0
  182. package/hooks/dist/lib/filename-classification.js +64 -0
  183. package/hooks/dist/lib/isolation-deny-reason.js +53 -1
  184. package/hooks/dist/lib/isolation-sentinel.js +58 -19
  185. package/hooks/gsd-agent-isolation-guard.js +42 -16
  186. package/hooks/gsd-context-monitor.js +88 -15
  187. package/hooks/gsd-cursor-subagent-start.js +34 -14
  188. package/hooks/gsd-secret-read-guard.js +44 -18
  189. package/hooks/gsd-statusline.js +11 -7
  190. package/hooks/gsd-validate-commit.sh +34 -4
  191. package/hooks/gsd-worktree-path-guard.js +25 -14
  192. package/hooks/gsd-write-guard.js +46 -1
  193. package/hooks/lib/dispatch-identity.js +187 -0
  194. package/hooks/lib/filename-classification.js +64 -0
  195. package/hooks/lib/isolation-deny-reason.js +53 -1
  196. package/hooks/lib/isolation-sentinel.js +58 -19
  197. package/package.json +10 -6
  198. package/scripts/benchmark-compact-content-variants.cjs +298 -0
  199. package/scripts/benchmark-compact-content.cjs +368 -0
  200. package/scripts/check-contract-drift.cjs +4 -1
  201. package/scripts/check-env.cjs +36 -8
  202. package/scripts/check-glossary-refs.cjs +25 -21
  203. package/scripts/ci-next-health.cjs +271 -0
  204. package/scripts/ci-prepare-test-scope.cjs +7 -7
  205. package/scripts/ci-test-scope.cjs +126 -20
  206. package/scripts/ci-timeout-report.cjs +1 -1
  207. package/scripts/diff-touches-shipped-paths.cjs +1 -1
  208. package/scripts/docs-guard-registry.cjs +7 -2
  209. package/scripts/gen-adr-index.cjs +8 -2
  210. package/scripts/gen-inventory-manifest.cjs +12 -0
  211. package/scripts/gen-platform-conformance-tier.cjs +557 -0
  212. package/scripts/lib/drift-scan.cjs +1 -1
  213. package/scripts/lib/macos-conformance-tier.generated.cjs +210 -0
  214. package/scripts/lib/npm-version-check-diagnosis.cjs +59 -0
  215. package/scripts/lib/platform-conformance-tier.generated.cjs +276 -0
  216. package/scripts/lib/suite-detection.cjs +32 -0
  217. package/scripts/lint-allowed-tools-parity.cjs +221 -0
  218. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +19 -2
  219. package/scripts/lint-phase-id-drift.cjs +338 -13
  220. package/scripts/lint-response-language-coverage.cjs +9 -3
  221. package/scripts/lint-source-test-name-collision.cjs +1 -1
  222. package/scripts/lint-test-file-count.allowlist.json +1 -0
  223. package/scripts/lint-vendored-deps.cjs +128 -17
  224. package/scripts/lint-workflow-shellcheck-baseline.json +85 -0
  225. package/scripts/prompt-injection-scan.sh +14 -0
  226. package/scripts/workflow-size.cjs +139 -0
  227. package/skills/gsd-cleanup/SKILL.md +1 -0
  228. package/skills/gsd-code-review/SKILL.md +2 -1
  229. package/skills/gsd-complete-milestone/SKILL.md +1 -0
  230. package/skills/gsd-config/SKILL.md +1 -0
  231. package/skills/gsd-debug/SKILL.md +1 -0
  232. package/skills/gsd-graphify/SKILL.md +1 -0
  233. package/skills/gsd-health/SKILL.md +1 -0
  234. package/skills/gsd-mempalace-capture/SKILL.md +1 -0
  235. package/skills/gsd-mempalace-recall/SKILL.md +1 -0
  236. package/skills/gsd-new-milestone/SKILL.md +1 -0
  237. package/skills/gsd-new-project/SKILL.md +1 -0
  238. package/skills/gsd-next/SKILL.md +1 -0
  239. package/skills/gsd-pause-work/SKILL.md +1 -0
  240. package/skills/gsd-phase/SKILL.md +1 -0
  241. package/skills/gsd-pr-branch/SKILL.md +1 -0
  242. package/skills/gsd-resume-work/SKILL.md +1 -0
  243. package/skills/gsd-review-backlog/SKILL.md +1 -0
  244. package/skills/gsd-settings/SKILL.md +2 -1
  245. package/skills/gsd-stats/SKILL.md +1 -0
  246. package/skills/gsd-thread/SKILL.md +1 -0
  247. package/skills/gsd-workspace/SKILL.md +1 -0
  248. package/skills/gsd-workstreams/SKILL.md +1 -0
  249. package/vscode/package.json +1 -1
  250. package/gsd-core/templates/claude-md.md +0 -145
  251. package/gsd-core/templates/codebase/concerns.md +0 -310
  252. package/gsd-core/templates/codebase/conventions.md +0 -307
  253. package/gsd-core/templates/codebase/integrations.md +0 -280
  254. package/gsd-core/templates/codebase/structure.md +0 -285
  255. package/gsd-core/templates/codebase/testing.md +0 -480
  256. package/gsd-core/templates/debug-subagent-prompt.md +0 -91
  257. package/gsd-core/templates/discovery.md +0 -146
@@ -25,7 +25,7 @@ const coreUtils = require("./core-utils.cjs");
25
25
  const { normalizeLineEndings } = coreUtils;
26
26
  // eslint-disable-next-line @typescript-eslint/no-require-imports
27
27
  const planningWorkspace = require("./planning-workspace.cjs");
28
- const { planningDir, quickDirFrom } = planningWorkspace;
28
+ const { planningDir, quickDirFrom, todosDir } = planningWorkspace;
29
29
  // eslint-disable-next-line @typescript-eslint/no-require-imports
30
30
  const frontmatter = require("./frontmatter.cjs");
31
31
  const { extractFrontmatter, spliceFrontmatter } = frontmatter;
@@ -277,7 +277,7 @@ function scanDebugSessions(planDir) {
277
277
  const filePath = node_path_1.default.join(debugDir, entry.name);
278
278
  let safeFilePath;
279
279
  try {
280
- safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'debug session file', { allowAbsolute: true });
280
+ safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'debug session file', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
281
281
  }
282
282
  catch {
283
283
  continue;
@@ -387,7 +387,7 @@ function scanQuickTasks(planDir) {
387
387
  const taskDir = node_path_1.default.join(quickDir, dirName);
388
388
  let safeTaskDir;
389
389
  try {
390
- safeTaskDir = (0, security_cjs_1.requireSafePath)(taskDir, planDir, 'quick task dir', { allowAbsolute: true });
390
+ safeTaskDir = (0, security_cjs_1.requireSafePath)(taskDir, planDir, 'quick task dir', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
391
391
  }
392
392
  catch {
393
393
  continue;
@@ -399,7 +399,7 @@ function scanQuickTasks(planDir) {
399
399
  if (summaryPath && node_fs_1.default.existsSync(summaryPath)) {
400
400
  let safeSum;
401
401
  try {
402
- safeSum = (0, security_cjs_1.requireSafePath)(summaryPath, planDir, 'quick task summary', { allowAbsolute: true });
402
+ safeSum = (0, security_cjs_1.requireSafePath)(summaryPath, planDir, 'quick task summary', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
403
403
  }
404
404
  catch {
405
405
  continue;
@@ -475,7 +475,7 @@ function scanThreads(planDir) {
475
475
  const filePath = node_path_1.default.join(threadsDir, entry.name);
476
476
  let safeFilePath;
477
477
  try {
478
- safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'thread file', { allowAbsolute: true });
478
+ safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'thread file', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
479
479
  }
480
480
  catch {
481
481
  continue;
@@ -522,9 +522,16 @@ function scanThreads(planDir) {
522
522
  * Scan .planning/todos/pending/ for pending todos.
523
523
  * Returns array of { filename, priority, area, summary }.
524
524
  * Display limited to first 5 + count of remainder.
525
+ *
526
+ * #4256: takes the ROOT-scoped todos base (`todosDir(cwd)`), NOT the
527
+ * workstream-scoped planning dir the other scans use — todos are shared
528
+ * project state (the migrateToWorkstreams contract keeps them at
529
+ * .planning/todos/), so the close gate must read the root or it clears
530
+ * vacuously under a workstream. The requireSafePath boundary below moves
531
+ * with the base.
525
532
  */
526
- function scanTodos(planDir) {
527
- const pendingDir = node_path_1.default.join(planDir, 'todos', 'pending');
533
+ function scanTodos(todosBase) {
534
+ const pendingDir = node_path_1.default.join(todosBase, 'pending');
528
535
  if (!node_fs_1.default.existsSync(pendingDir))
529
536
  return { items: [], acknowledged: 0 };
530
537
  let files;
@@ -550,7 +557,7 @@ function scanTodos(planDir) {
550
557
  const filePath = node_path_1.default.join(pendingDir, entry.name);
551
558
  let safeFilePath;
552
559
  try {
553
- safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'todo file', { allowAbsolute: true });
560
+ safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, todosBase, 'todo file', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
554
561
  }
555
562
  catch {
556
563
  continue;
@@ -622,7 +629,7 @@ function scanSeeds(planDir) {
622
629
  const filePath = node_path_1.default.join(seedsDir, entry.name);
623
630
  let safeFilePath;
624
631
  try {
625
- safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'seed file', { allowAbsolute: true });
632
+ safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'seed file', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
626
633
  }
627
634
  catch {
628
635
  continue;
@@ -785,7 +792,7 @@ function scanUatGaps(planDir, cwd) {
785
792
  const filePath = node_path_1.default.join(target.fullPath, file);
786
793
  let safeFilePath;
787
794
  try {
788
- safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'UAT file', { allowAbsolute: true });
795
+ safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'UAT file', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
789
796
  }
790
797
  catch {
791
798
  continue;
@@ -858,7 +865,7 @@ function scanVerificationGaps(planDir, cwd) {
858
865
  const filePath = node_path_1.default.join(target.fullPath, file);
859
866
  let safeFilePath;
860
867
  try {
861
- safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'VERIFICATION file', { allowAbsolute: true });
868
+ safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'VERIFICATION file', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
862
869
  }
863
870
  catch {
864
871
  continue;
@@ -920,7 +927,7 @@ function scanContextQuestions(planDir, cwd) {
920
927
  const filePath = node_path_1.default.join(target.fullPath, file);
921
928
  let safeFilePath;
922
929
  try {
923
- safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'CONTEXT file', { allowAbsolute: true });
930
+ safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'CONTEXT file', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
924
931
  }
925
932
  catch {
926
933
  continue;
@@ -1008,7 +1015,7 @@ function scanDeferredItems(planDir, cwd) {
1008
1015
  continue;
1009
1016
  let safeFilePath;
1010
1017
  try {
1011
- safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'deferred items file', { allowAbsolute: true });
1018
+ safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'deferred items file', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
1012
1019
  }
1013
1020
  catch {
1014
1021
  continue;
@@ -1076,8 +1083,13 @@ function auditOpenArtifacts(cwd) {
1076
1083
  }
1077
1084
  })();
1078
1085
  const todos = (() => {
1086
+ // #4256: the ONE root-scoped category — todos are shared project state,
1087
+ // so the close gate reads todosDir(cwd) (the root), not the workstream-
1088
+ // scoped planDir every other scan below receives. Reading planDir here
1089
+ // made audit-open print "All artifact types clear. Safe to proceed."
1090
+ // with pending todos on disk under a workstream.
1079
1091
  try {
1080
- return scanTodos(planDir);
1092
+ return scanTodos(todosDir(cwd));
1081
1093
  }
1082
1094
  catch {
1083
1095
  return { items: [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }], acknowledged: 0 };
@@ -1421,7 +1433,7 @@ function cmdAuditAcknowledge(cwd, args, raw) {
1421
1433
  ioError(`no phase directory found for phase "${phase}"${archivedMilestone ? ` (archived-milestone "${archivedMilestone}")` : ''}`);
1422
1434
  }
1423
1435
  const filePath = node_path_1.default.join(targetDir, file);
1424
- const safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'audit acknowledge target', { allowAbsolute: true });
1436
+ const safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'audit acknowledge target', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
1425
1437
  if (!node_fs_1.default.existsSync(safeFilePath))
1426
1438
  ioError(`file not found: ${file}`);
1427
1439
  if (category === 'deferred_items') {
@@ -1503,7 +1515,7 @@ function cmdAuditAcknowledge(cwd, args, raw) {
1503
1515
  if (category === 'debug_sessions') {
1504
1516
  if (!slug)
1505
1517
  ioError('--slug is required for --category debug_sessions');
1506
- safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'debug', `${slug}.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
1518
+ safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'debug', `${slug}.md`), planDir, 'audit acknowledge target', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
1507
1519
  if (!node_fs_1.default.existsSync(safeFilePath))
1508
1520
  ioError(`file not found: debug/${slug}.md`);
1509
1521
  const content = node_fs_1.default.readFileSync(safeFilePath, 'utf-8');
@@ -1512,7 +1524,7 @@ function cmdAuditAcknowledge(cwd, args, raw) {
1512
1524
  else if (category === 'threads') {
1513
1525
  if (!slug)
1514
1526
  ioError('--slug is required for --category threads');
1515
- safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'threads', `${slug}.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
1527
+ safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'threads', `${slug}.md`), planDir, 'audit acknowledge target', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
1516
1528
  if (!node_fs_1.default.existsSync(safeFilePath))
1517
1529
  ioError(`file not found: threads/${slug}.md`);
1518
1530
  const content = node_fs_1.default.readFileSync(safeFilePath, 'utf-8');
@@ -1521,7 +1533,7 @@ function cmdAuditAcknowledge(cwd, args, raw) {
1521
1533
  else if (category === 'seeds') {
1522
1534
  if (!seedId)
1523
1535
  ioError('--seed-id is required for --category seeds');
1524
- safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'seeds', `${seedId}.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
1536
+ safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'seeds', `${seedId}.md`), planDir, 'audit acknowledge target', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
1525
1537
  if (!node_fs_1.default.existsSync(safeFilePath))
1526
1538
  ioError(`file not found: seeds/${seedId}.md`);
1527
1539
  const content = node_fs_1.default.readFileSync(safeFilePath, 'utf-8');
@@ -1530,7 +1542,12 @@ function cmdAuditAcknowledge(cwd, args, raw) {
1530
1542
  else if (category === 'todos') {
1531
1543
  if (!filename)
1532
1544
  ioError('--filename is required for --category todos');
1533
- safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'todos', 'pending', filename), planDir, 'audit acknowledge target', { allowAbsolute: true });
1545
+ // #4256: todos are root-scoped shared state — derive the todos base and
1546
+ // pass it as BOTH the path base and the requireSafePath boundary. The
1547
+ // old workstream-scoped planDir boundary would refuse a root todos file
1548
+ // outright, and even a path fix alone would have thrown here.
1549
+ const rootTodos = todosDir(cwd);
1550
+ safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(rootTodos, 'pending', filename), rootTodos, 'audit acknowledge target', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
1534
1551
  if (!node_fs_1.default.existsSync(safeFilePath))
1535
1552
  ioError(`file not found: todos/pending/${filename}`);
1536
1553
  currentValue = ''; // presence-only — see scanTodos
@@ -1538,14 +1555,14 @@ function cmdAuditAcknowledge(cwd, args, raw) {
1538
1555
  else if (category === 'quick_tasks') {
1539
1556
  if (!quickDir)
1540
1557
  ioError('--dir is required for --category quick_tasks');
1541
- const taskDir = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'quick', quickDir), planDir, 'audit acknowledge target dir', { allowAbsolute: true });
1558
+ const taskDir = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'quick', quickDir), planDir, 'audit acknowledge target dir', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
1542
1559
  if (!node_fs_1.default.existsSync(taskDir))
1543
1560
  ioError(`directory not found: quick/${quickDir}`);
1544
1561
  // Shared with scanQuickTasks (#3458 follow-up) so the reader and this
1545
1562
  // writer can never disagree about which file is the task's record.
1546
1563
  const resolvedSummaryPath = resolveQuickTaskSummaryFile(taskDir, quickDir);
1547
1564
  if (resolvedSummaryPath) {
1548
- safeFilePath = (0, security_cjs_1.requireSafePath)(resolvedSummaryPath, planDir, 'audit acknowledge target', { allowAbsolute: true });
1565
+ safeFilePath = (0, security_cjs_1.requireSafePath)(resolvedSummaryPath, planDir, 'audit acknowledge target', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
1549
1566
  const content = node_fs_1.default.readFileSync(safeFilePath, 'utf-8');
1550
1567
  currentValue = (extractFrontmatter(content, safeFilePath).status || 'unknown').toLowerCase();
1551
1568
  }
@@ -1556,7 +1573,7 @@ function cmdAuditAcknowledge(cwd, args, raw) {
1556
1573
  // acknowledgment's own snapshot of "no summary exists yet", which
1557
1574
  // self-invalidates the moment a real SUMMARY.md is written (the
1558
1575
  // scanner then reads THAT file's own status instead).
1559
- safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(taskDir, `${quickDir}-SUMMARY.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
1576
+ safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(taskDir, `${quickDir}-SUMMARY.md`), planDir, 'audit acknowledge target', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
1560
1577
  currentValue = 'missing';
1561
1578
  createIfMissing = true;
1562
1579
  fmForCreate = { status: 'missing' };
@@ -7,7 +7,10 @@
7
7
  * When `workflow.windows_enforce` is true, `/gsd-ship` blocks while any entry is
8
8
  * `open`; an entry can be `waived` only with a recorded reason or `fixed`.
9
9
  *
10
- * LEAF MODULE — imports ONLY: node:fs, node:path. No other src/ imports.
10
+ * LEAF MODULE — imports node:fs + node:path, plus two compiled sibling lib
11
+ * modules require()d at runtime: workstream-inventory.cjs (the #4487
12
+ * milestone stamp) and capability-lock.cjs (the #3780 cross-process ledger
13
+ * lock). No other src/ imports.
11
14
  *
12
15
  * Storage format (`.planning/WINDOWS.md`):
13
16
  * ---
@@ -58,12 +61,15 @@ exports.parseLedger = parseLedger;
58
61
  exports.renderLedger = renderLedger;
59
62
  exports.renderTable = renderTable;
60
63
  exports.extractTableRegion = extractTableRegion;
64
+ exports.withLedgerLock = withLedgerLock;
61
65
  exports.cmdWindowsStatus = cmdWindowsStatus;
62
66
  exports.cmdWindowsAppend = cmdWindowsAppend;
63
67
  exports.cmdWindowsWaive = cmdWindowsWaive;
64
68
  exports.cmdWindowsMarkFixed = cmdWindowsMarkFixed;
65
69
  const node_fs_1 = __importDefault(require("node:fs"));
66
70
  const node_path_1 = __importDefault(require("node:path"));
71
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
72
+ const workstreamInventory = require("./workstream-inventory.cjs");
67
73
  // ─── Constants ─────────────────────────────────────────────────────────────
68
74
  exports.LEDGER_FILE_NAME = 'WINDOWS.md';
69
75
  exports.SCHEMA_VERSION = 1;
@@ -90,6 +96,11 @@ exports.REASON = Object.freeze({
90
96
  // sole source of truth) at the pre-write seam — refuse rather than silently
91
97
  // reconcile by overwriting the operator's hand-edit or dropping a row.
92
98
  WINDOWS_LEDGER_TABLE_DRIFT: 'windows_ledger_table_drift',
99
+ // #3780: the read-compute-write cycle is serialized on a cross-process
100
+ // ledger lock; this fires only when another writer held the lock past the
101
+ // whole bounded retry budget — a typed, actionable refusal instead of a
102
+ // silently-lost mutation reported as success.
103
+ WINDOWS_LEDGER_LOCK: 'windows_ledger_lock',
93
104
  });
94
105
  /** Allowed window kinds. Aligned with the issue's enumerated sources. */
95
106
  exports.KINDS = Object.freeze([
@@ -219,12 +230,14 @@ function nextId(entries) {
219
230
  * Append a window to the ledger. Assigns the next dense id (max+1), sets
220
231
  * status=open, timestamps via opts.now.
221
232
  *
222
- * Concurrency (issue #1950 review L2): NOT safe for concurrent writers. Two
223
- * parallel `gsd_run windows append` invocations both read the same snapshot,
224
- * both compute the same nextId, both write — the second atomic rename wins
225
- * and the first append (and the entry it added) is silently lost. This is
226
- * acceptable in the current single-executor-per-phase model; document if the
227
- * executor ever gains parallel wave-level append.
233
+ * Concurrency (issue #1950 review L2, superseded by #3780): as a PURE
234
+ * function this operates on whatever ledger snapshot it is passed and cannot
235
+ * see concurrent writers — serialization is the CALLER's job. The I/O entry
236
+ * points below (cmdWindowsAppend/Waive/MarkFixed) now discharge that duty by
237
+ * holding the cross-process ledger lock across their whole
238
+ * read-compute-write cycle, so the previously-documented loss (two parallel
239
+ * writers, second rename wins, first entry silently gone) can no longer
240
+ * occur through the CLI.
228
241
  */
229
242
  function appendWindow(ledger, input, opts = { now: new Date().toISOString() }) {
230
243
  validateKind(input.kind);
@@ -243,6 +256,7 @@ function appendWindow(ledger, input, opts = { now: new Date().toISOString() }) {
243
256
  reason: '',
244
257
  recorded_at: opts.now,
245
258
  resolved_at: null,
259
+ milestone: input.milestone ?? null,
246
260
  };
247
261
  const entries = [...ledger.entries, entry];
248
262
  const result = recomputeCounts({ ...ledger, entries, last_updated: opts.now });
@@ -475,6 +489,19 @@ function validateEntryShape(e, i) {
475
489
  reason: o.reason,
476
490
  recorded_at: recordedStr,
477
491
  resolved_at: resolvedStr,
492
+ // #4487: NOT in `required` above -- an entry recorded before this field
493
+ // existed has no `milestone` key at all, and that must parse cleanly.
494
+ // Preserve the absence itself -- by not materializing the property at
495
+ // all, via the conditional spread below, rather than assigning it
496
+ // `milestone: undefined` (an object literal property set to `undefined`
497
+ // is still an OWN property; `'milestone' in entry` reads true either
498
+ // way) -- so a genuinely absent key stays absent both to `in` and to
499
+ // JSON.stringify on re-render. Collapsing absence to an explicit null
500
+ // instead would stamp every legacy entry with permanent
501
+ // `"milestone": null` noise the moment the ledger is next touched. A
502
+ // genuinely-recorded-but-unresolvable milestone (set by appendWindow)
503
+ // is still an explicit null and round-trips as one.
504
+ ...('milestone' in o ? { milestone: typeof o.milestone === 'string' ? o.milestone : null } : {}),
478
505
  };
479
506
  }
480
507
  function parseLedger(raw) {
@@ -685,6 +712,78 @@ function diffTableRowIds(expectedTable, actualTable) {
685
712
  function ledgerPath(cwd) {
686
713
  return node_path_1.default.join(cwd, '.planning', exports.LEDGER_FILE_NAME);
687
714
  }
715
+ /**
716
+ * The SHARED hardened cross-process lock primitive (single source of truth
717
+ * for capability-lifecycle + capability-consent, extracted so locks cannot
718
+ * diverge). Required LAZILY: capability-lock captures the process start time
719
+ * at module load — a `ps` subprocess on macOS, PowerShell on win32 — and
720
+ * this module is loaded by lock-free readers (`windows status`, the
721
+ * /gsd-ship gate) that must not pay that per-invocation cost (#3780
722
+ * review). `require` is cached, so writers pay it once per process.
723
+ */
724
+ let _lockMod = null;
725
+ function ledgerLock() {
726
+ if (_lockMod === null) {
727
+ /* eslint-disable @typescript-eslint/no-require-imports */
728
+ _lockMod = require('./capability-lock.cjs');
729
+ /* eslint-enable @typescript-eslint/no-require-imports */
730
+ }
731
+ return _lockMod;
732
+ }
733
+ /**
734
+ * Budget mirrors capability-consent's CONSENT_LOCK_MAX_ATTEMPTS: two
735
+ * genuinely-racing writers must SERIALIZE, not fail. The ledger's critical
736
+ * section is sub-millisecond and the primitive backs off ~25-50ms per
737
+ * attempt, so 50 attempts is orders of magnitude beyond any real contention
738
+ * while keeping the worst case (a holder that never releases until the
739
+ * primitive's own liveness/deadman protocol reclaims it) bounded at ~2s
740
+ * before the typed refusal below.
741
+ */
742
+ const LEDGER_LOCK_MAX_ATTEMPTS = 50;
743
+ function ledgerLockPath(cwd) {
744
+ return node_path_1.default.join(cwd, '.planning', '.WINDOWS.lock');
745
+ }
746
+ function acquireLedgerLock(cwd) {
747
+ return ledgerLock().acquireLock(ledgerLockPath(cwd), {
748
+ maxAttempts: LEDGER_LOCK_MAX_ATTEMPTS,
749
+ // A contended fresh/live holder is WAITED FOR (back off + retry), not
750
+ // failed-fast — racing wave-level executors serialize (issue #3780).
751
+ waitForFresh: true,
752
+ });
753
+ }
754
+ function releaseLedgerLock(handle) {
755
+ ledgerLock().releaseLock(handle);
756
+ }
757
+ /**
758
+ * Run `fn` (a full ledger read-compute-write cycle) while holding the
759
+ * cross-process ledger lock. Throws a typed WindowsError — never falls back
760
+ * to an unlocked mutation — when the lock cannot be acquired within the
761
+ * budget, mirroring capability-consent finding 3: a locked store must refuse
762
+ * the write rather than silently race for it. Readers (cmdWindowsStatus, the
763
+ * ship gate) deliberately do NOT take this lock: the atomic rename already
764
+ * gives them a whole-file snapshot.
765
+ *
766
+ * EXPORTED (#3780) because `withLedgerLock` is the ONE serialization seam
767
+ * for WINDOWS.md: every writer of the ledger — the cmd* entry points here
768
+ * and any sibling module with its own read-compute-write cycle on the same
769
+ * file (refactor-trigger-command-router's strict-window record/resolve) —
770
+ * must hold this lock, or the lost-update race #3780 fixed survives on that
771
+ * path.
772
+ */
773
+ function withLedgerLock(cwd, fn) {
774
+ const handle = acquireLedgerLock(cwd);
775
+ if (!handle) {
776
+ throw new WindowsError(exports.REASON.WINDOWS_LEDGER_LOCK, `Another writer holds the ledger lock at ${ledgerLockPath(cwd)}; WINDOWS.md ` +
777
+ 'mutations are serialized per project. Re-run the command once the other ' +
778
+ 'writer finishes — the lock is reclaimed automatically if its holder died.');
779
+ }
780
+ try {
781
+ return fn();
782
+ }
783
+ finally {
784
+ releaseLedgerLock(handle);
785
+ }
786
+ }
688
787
  function readLedgerOrNull(cwd) {
689
788
  const p = ledgerPath(cwd);
690
789
  let raw;
@@ -910,24 +1009,36 @@ function cmdWindowsAppend(cwd, args, opts = {}) {
910
1009
  flags: ['--kind', '--phase', '--file', '--line', '--description'],
911
1010
  required: ['--kind', '--phase', '--description'],
912
1011
  });
913
- let ledger;
914
- try {
915
- ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso());
916
- }
917
- catch (e) {
918
- if (e instanceof WindowsError)
919
- throw e;
920
- throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, e.message);
921
- }
922
- const result = appendWindow(ledger, {
923
- kind: parsed.values['--kind'],
924
- phase: parsed.values['--phase'] ?? '',
925
- file: parsed.values['--file'] ?? '',
926
- line: parsed.values['--line'] == null ? null : Number(parsed.values['--line']),
927
- description: parsed.values['--description'] ?? '',
928
- }, { now: nowIso() });
929
- writeLedgerAtomic(cwd, result.ledger);
930
- emit({ ok: true, ledger: result.ledger, entry: result.entry });
1012
+ // #3780: the whole read-compute-write cycle — snapshot, milestone stamp,
1013
+ // id allocation, atomic rename — holds the ledger lock, so two parallel
1014
+ // invocations can no longer compute the same nextId from the same snapshot
1015
+ // and silently lose the first append to the second rename.
1016
+ withLedgerLock(cwd, () => {
1017
+ let ledger;
1018
+ try {
1019
+ ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso());
1020
+ }
1021
+ catch (e) {
1022
+ if (e instanceof WindowsError)
1023
+ throw e;
1024
+ throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, e.message);
1025
+ }
1026
+ // #4487: stamp the workstream's resolved milestone at record time -- the
1027
+ // same STATE.md-first, ROADMAP-fallback resolution workstream-inventory.cts
1028
+ // already uses. Best-effort: an unreadable/missing STATE.md or ROADMAP.md
1029
+ // resolves to null, same as an entry recorded before this field existed.
1030
+ const milestone = workstreamInventory.readCurrentMilestoneVersion(node_path_1.default.join(cwd, '.planning', 'STATE.md'), node_path_1.default.join(cwd, '.planning', 'ROADMAP.md'));
1031
+ const result = appendWindow(ledger, {
1032
+ kind: parsed.values['--kind'],
1033
+ phase: parsed.values['--phase'] ?? '',
1034
+ file: parsed.values['--file'] ?? '',
1035
+ line: parsed.values['--line'] == null ? null : Number(parsed.values['--line']),
1036
+ description: parsed.values['--description'] ?? '',
1037
+ milestone,
1038
+ }, { now: nowIso() });
1039
+ writeLedgerAtomic(cwd, result.ledger);
1040
+ emit({ ok: true, ledger: result.ledger, entry: result.entry });
1041
+ });
931
1042
  }
932
1043
  /** `gsd-tools windows waive <id> "<reason>"`. */
933
1044
  function cmdWindowsWaive(cwd, args, opts = {}) {
@@ -936,36 +1047,44 @@ function cmdWindowsWaive(cwd, args, opts = {}) {
936
1047
  const idStr = positionals[0];
937
1048
  const reason = positionals[1];
938
1049
  const id = parseIdOrThrow(idStr);
939
- let ledger;
940
- try {
941
- ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso());
942
- }
943
- catch (e) {
944
- if (e instanceof WindowsError)
945
- throw e;
946
- throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, e.message);
947
- }
948
- const updated = markWaived(ledger, id, reason ?? '', { now: nowIso() });
949
- writeLedgerAtomic(cwd, updated);
950
- emit({ ok: true, ledger: updated });
1050
+ // #3780: same serialization as append — a concurrent append holding a stale
1051
+ // snapshot would otherwise overwrite the waive and resurrect the entry.
1052
+ withLedgerLock(cwd, () => {
1053
+ let ledger;
1054
+ try {
1055
+ ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso());
1056
+ }
1057
+ catch (e) {
1058
+ if (e instanceof WindowsError)
1059
+ throw e;
1060
+ throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, e.message);
1061
+ }
1062
+ const updated = markWaived(ledger, id, reason ?? '', { now: nowIso() });
1063
+ writeLedgerAtomic(cwd, updated);
1064
+ emit({ ok: true, ledger: updated });
1065
+ });
951
1066
  }
952
1067
  /** `gsd-tools windows fixed <id>`. */
953
1068
  function cmdWindowsMarkFixed(cwd, args, opts = {}) {
954
1069
  void opts;
955
1070
  const { positionals } = parseArgs(args, { flags: [], required: [], positionals: 1 });
956
1071
  const id = parseIdOrThrow(positionals[0]);
957
- let ledger;
958
- try {
959
- ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso());
960
- }
961
- catch (e) {
962
- if (e instanceof WindowsError)
963
- throw e;
964
- throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, e.message);
965
- }
966
- const updated = markFixed(ledger, id, { now: nowIso() });
967
- writeLedgerAtomic(cwd, updated);
968
- emit({ ok: true, ledger: updated });
1072
+ // #3780: same serialization as append — a concurrent writer holding a
1073
+ // stale snapshot would otherwise overwrite the resolved status.
1074
+ withLedgerLock(cwd, () => {
1075
+ let ledger;
1076
+ try {
1077
+ ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso());
1078
+ }
1079
+ catch (e) {
1080
+ if (e instanceof WindowsError)
1081
+ throw e;
1082
+ throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, e.message);
1083
+ }
1084
+ const updated = markFixed(ledger, id, { now: nowIso() });
1085
+ writeLedgerAtomic(cwd, updated);
1086
+ emit({ ok: true, ledger: updated });
1087
+ });
969
1088
  }
970
1089
  function parseIdOrThrow(raw) {
971
1090
  if (raw == null || raw === '') {
@@ -25,6 +25,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
25
25
  const node_fs_1 = __importDefault(require("node:fs"));
26
26
  const node_path_1 = __importDefault(require("node:path"));
27
27
  const node_crypto_1 = __importDefault(require("node:crypto"));
28
+ const security_cjs_1 = require("./security.cjs");
28
29
  /* eslint-disable @typescript-eslint/no-require-imports */
29
30
  const sourceMod = require('./capability-source.cjs');
30
31
  const ledgerMod = require('./capability-ledger.cjs');
@@ -202,7 +203,10 @@ function safeRmUnder(runtimeDir, rel) {
202
203
  catch {
203
204
  return false;
204
205
  }
205
- if (realParent !== realRoot && !realParent.startsWith(realRoot + node_path_1.default.sep))
206
+ // Containment decision is the canonical LEXICAL predicate (ADR-4650 decision 6); lexical because
207
+ // both operands are already realpath-resolved here and the final component is deliberately
208
+ // handled as a link (see above).
209
+ if ((0, security_cjs_1.tryWithinRootLexical)(realParent, realRoot) === null)
206
210
  return false;
207
211
  const realTarget = node_path_1.default.join(realParent, node_path_1.default.basename(target));
208
212
  let st;
@@ -250,11 +254,11 @@ function confinedSharedFile(runtimeDir, relFile) {
250
254
  catch {
251
255
  // Parent does not exist yet (created inside the scope on write): a non-existent path cannot be a
252
256
  // symlink escaping the root, so a lexical containment check is sufficient.
253
- if (parentDir !== realRoot && !parentDir.startsWith(realRoot + node_path_1.default.sep))
257
+ if ((0, security_cjs_1.tryWithinRootLexical)(parentDir, realRoot) === null)
254
258
  return null;
255
259
  return target;
256
260
  }
257
- if (realParent !== realRoot && !realParent.startsWith(realRoot + node_path_1.default.sep))
261
+ if ((0, security_cjs_1.tryWithinRootLexical)(realParent, realRoot) === null)
258
262
  return null;
259
263
  return node_path_1.default.join(realParent, node_path_1.default.basename(target));
260
264
  }
@@ -363,7 +367,7 @@ function confinedBundleScript(capDirPath, script) {
363
367
  // disk): a non-existent root cannot be a symlink escaping itself, so confine lexically.
364
368
  realCapRoot = node_path_1.default.resolve(capDirPath);
365
369
  const targetLex = node_path_1.default.resolve(realCapRoot, script);
366
- if (targetLex !== realCapRoot && !targetLex.startsWith(realCapRoot + node_path_1.default.sep))
370
+ if ((0, security_cjs_1.tryWithinRootLexical)(targetLex, realCapRoot) === null)
367
371
  return null;
368
372
  return targetLex;
369
373
  }
@@ -376,13 +380,13 @@ function confinedBundleScript(capDirPath, script) {
376
380
  catch {
377
381
  // Parent does not exist yet (created inside the bundle): lexical containment is sufficient
378
382
  // because a non-existent path cannot be a symlink escaping the root.
379
- if (parentDir !== realCapRoot && !parentDir.startsWith(realCapRoot + node_path_1.default.sep))
383
+ if ((0, security_cjs_1.tryWithinRootLexical)(parentDir, realCapRoot) === null)
380
384
  return null;
381
385
  return target;
382
386
  }
383
387
  // The realpath'd parent chain must remain inside the bundle — an ancestor symlink escaping the
384
388
  // bundle is refused here (the symlink is followed by realpathSync, so its real location is checked).
385
- if (realParent !== realCapRoot && !realParent.startsWith(realCapRoot + node_path_1.default.sep))
389
+ if ((0, security_cjs_1.tryWithinRootLexical)(realParent, realCapRoot) === null)
386
390
  return null;
387
391
  return node_path_1.default.join(realParent, node_path_1.default.basename(target));
388
392
  }