@opengsd/gsd-core 1.9.0 → 1.10.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 (223) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -3
  3. package/.opencode/plugins/gsd-core.js +8 -1
  4. package/agents/gsd-code-fixer.md +131 -34
  5. package/agents/gsd-debugger.md +12 -246
  6. package/agents/gsd-executor.md +7 -5
  7. package/agents/gsd-integration-checker.md +3 -0
  8. package/agents/gsd-plan-checker.md +9 -0
  9. package/agents/gsd-planner.md +5 -8
  10. package/agents/gsd-roadmapper.md +21 -3
  11. package/agents/gsd-verifier.md +14 -70
  12. package/bin/install.js +503 -341
  13. package/commands/gsd/mempalace-capture.md +1 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +1 -1
  16. package/gsd-core/bin/gsd-tools.cjs +607 -63
  17. package/gsd-core/bin/lib/active-workstream-store.cjs +25 -0
  18. package/gsd-core/bin/lib/agent-install-check.cjs +38 -6
  19. package/gsd-core/bin/lib/api-coverage.cjs +120 -0
  20. package/gsd-core/bin/lib/audit.cjs +89 -1
  21. package/gsd-core/bin/lib/broken-windows.cjs +36 -6
  22. package/gsd-core/bin/lib/capability-registry.cjs +96 -110
  23. package/gsd-core/bin/lib/capability-validator.cjs +12 -2
  24. package/gsd-core/bin/lib/check-command-router.cjs +43 -1
  25. package/gsd-core/bin/lib/command-aliases.cjs +72 -0
  26. package/gsd-core/bin/lib/commands.cjs +26 -25
  27. package/gsd-core/bin/lib/commonjs-marker.cjs +136 -0
  28. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  29. package/gsd-core/bin/lib/config.cjs +12 -1
  30. package/gsd-core/bin/lib/context-composer.cjs +278 -0
  31. package/gsd-core/bin/lib/context-predicates.cjs +506 -0
  32. package/gsd-core/bin/lib/core-utils.cjs +91 -12
  33. package/gsd-core/bin/lib/docs.cjs +3 -2
  34. package/gsd-core/bin/lib/external-job.cjs +19 -4
  35. package/gsd-core/bin/lib/frontmatter.cjs +84 -12
  36. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
  37. package/gsd-core/bin/lib/git-base-branch.cjs +58 -15
  38. package/gsd-core/bin/lib/graphify.cjs +142 -27
  39. package/gsd-core/bin/lib/gsd2-import.cjs +27 -4
  40. package/gsd-core/bin/lib/host-integration.cjs +13 -1
  41. package/gsd-core/bin/lib/init-command-router.cjs +83 -8
  42. package/gsd-core/bin/lib/init.cjs +1021 -57
  43. package/gsd-core/bin/lib/install-engine.cjs +64 -10
  44. package/gsd-core/bin/lib/install-profiles.cjs +27 -1
  45. package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
  46. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  47. package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
  48. package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
  49. package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
  50. package/gsd-core/bin/lib/installer-migrations.cjs +87 -1
  51. package/gsd-core/bin/lib/io.cjs +28 -3
  52. package/gsd-core/bin/lib/markdown-sectionizer.cjs +6 -0
  53. package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
  54. package/gsd-core/bin/lib/mcp-server.cjs +135 -3
  55. package/gsd-core/bin/lib/milestone.cjs +106 -51
  56. package/gsd-core/bin/lib/phase-id.cjs +63 -0
  57. package/gsd-core/bin/lib/phase-locator.cjs +138 -45
  58. package/gsd-core/bin/lib/phase.cjs +260 -25
  59. package/gsd-core/bin/lib/plan-dependency-graph.cjs +232 -0
  60. package/gsd-core/bin/lib/planning-workspace.cjs +4 -0
  61. package/gsd-core/bin/lib/project-root.cjs +48 -0
  62. package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
  63. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +80 -0
  64. package/gsd-core/bin/lib/review-lane-descriptor.cjs +99 -0
  65. package/gsd-core/bin/lib/review-lane-runner.cjs +30 -6
  66. package/gsd-core/bin/lib/roadmap-command-router.cjs +42 -9
  67. package/gsd-core/bin/lib/roadmap-parser.cjs +100 -18
  68. package/gsd-core/bin/lib/roadmap.cjs +37 -7
  69. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +195 -62
  70. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +15 -3
  71. package/gsd-core/bin/lib/runtime-homes.cjs +154 -41
  72. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +105 -41
  73. package/gsd-core/bin/lib/section-manifest.cjs +209 -0
  74. package/gsd-core/bin/lib/shell-command-projection.cjs +113 -27
  75. package/gsd-core/bin/lib/smart-entry.cjs +12 -0
  76. package/gsd-core/bin/lib/state-transition.cjs +73 -8
  77. package/gsd-core/bin/lib/state.cjs +151 -62
  78. package/gsd-core/bin/lib/surface.cjs +12 -1
  79. package/gsd-core/bin/lib/uat-predicate.cjs +11 -1
  80. package/gsd-core/bin/lib/uat.cjs +320 -21
  81. package/gsd-core/bin/lib/unusable-input.cjs +9 -0
  82. package/gsd-core/bin/lib/verification.cjs +29 -12
  83. package/gsd-core/bin/lib/verify.cjs +29 -5
  84. package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
  85. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +181 -18
  86. package/gsd-core/bin/lib/workstream-inventory.cjs +519 -27
  87. package/gsd-core/bin/lib/workstream.cjs +6 -0
  88. package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
  89. package/gsd-core/bin/lib/worktree-safety.cjs +276 -118
  90. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  91. package/gsd-core/references/artifact-types.md +10 -3
  92. package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
  93. package/gsd-core/references/debugger-techniques.md +255 -0
  94. package/gsd-core/references/research-documentation-lookup.md +5 -3
  95. package/gsd-core/references/specless-probe-fallback.md +7 -6
  96. package/gsd-core/references/verifier-wiring-patterns.md +100 -0
  97. package/gsd-core/references/worktree-branch-check.md +2 -2
  98. package/gsd-core/templates/summary-complex.md +2 -0
  99. package/gsd-core/templates/summary-minimal.md +2 -0
  100. package/gsd-core/templates/summary-standard.md +2 -0
  101. package/gsd-core/templates/summary.md +2 -0
  102. package/gsd-core/workflows/audit-milestone.md +3 -0
  103. package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
  104. package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
  105. package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
  106. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
  107. package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
  108. package/gsd-core/workflows/autonomous.md +32 -69
  109. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
  110. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +83 -0
  111. package/gsd-core/workflows/code-review.md +42 -145
  112. package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
  113. package/gsd-core/workflows/complete-milestone.md +23 -81
  114. package/gsd-core/workflows/debug.md +9 -12
  115. package/gsd-core/workflows/diagnose-issues.md +22 -0
  116. package/gsd-core/workflows/discovery-phase.md +4 -4
  117. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
  118. package/gsd-core/workflows/discuss-phase-assumptions.md +5 -16
  119. package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
  120. package/gsd-core/workflows/docs-update.md +8 -51
  121. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +34 -2
  122. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
  123. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
  124. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +19 -0
  125. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
  127. package/gsd-core/workflows/execute-phase.md +65 -137
  128. package/gsd-core/workflows/execute-plan.md +1 -1
  129. package/gsd-core/workflows/help/modes/full.md +6 -1
  130. package/gsd-core/workflows/ingest-docs.md +2 -1
  131. package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
  132. package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
  133. package/gsd-core/workflows/new-milestone.md +21 -38
  134. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
  135. package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
  136. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
  137. package/gsd-core/workflows/new-project.md +13 -226
  138. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
  139. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
  140. package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
  141. package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
  142. package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
  143. package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
  144. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
  145. package/gsd-core/workflows/plan-phase.md +49 -193
  146. package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
  147. package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
  148. package/gsd-core/workflows/progress.md +11 -153
  149. package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
  150. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
  151. package/gsd-core/workflows/quick/steps/quick-verification.md +46 -0
  152. package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
  153. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
  154. package/gsd-core/workflows/quick.md +20 -390
  155. package/gsd-core/workflows/resume-project.md +3 -0
  156. package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
  157. package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
  158. package/gsd-core/workflows/review.md +15 -8
  159. package/gsd-core/workflows/section-manifest.json +219 -0
  160. package/gsd-core/workflows/sketch.md +1 -1
  161. package/gsd-core/workflows/spec-phase.md +17 -14
  162. package/gsd-core/workflows/spike-wrap-up.md +20 -5
  163. package/gsd-core/workflows/spike.md +50 -16
  164. package/gsd-core/workflows/sync-skills.md +49 -11
  165. package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
  166. package/gsd-core/workflows/transition.md +8 -21
  167. package/gsd-core/workflows/ui-phase.md +8 -7
  168. package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
  169. package/gsd-core/workflows/update.md +18 -7
  170. package/gsd-core/workflows/verify-phase.md +4 -7
  171. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
  172. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
  173. package/gsd-core/workflows/verify-work.md +8 -58
  174. package/hooks/dist/gsd-agent-isolation-guard.js +428 -0
  175. package/hooks/dist/gsd-check-update-worker.js +14 -5
  176. package/hooks/dist/gsd-cursor-subagent-start.js +532 -26
  177. package/hooks/dist/gsd-read-injection-scanner.js +7 -0
  178. package/hooks/dist/gsd-statusline.js +72 -6
  179. package/hooks/dist/gsd-worktree-path-guard.js +2 -1
  180. package/hooks/dist/gsd-write-guard.js +359 -0
  181. package/hooks/dist/lib/isolation-sentinel.js +268 -0
  182. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  183. package/hooks/gsd-agent-isolation-guard.js +428 -0
  184. package/hooks/gsd-check-update-worker.js +14 -5
  185. package/hooks/gsd-cursor-subagent-start.js +532 -26
  186. package/hooks/gsd-read-injection-scanner.js +7 -0
  187. package/hooks/gsd-statusline.js +72 -6
  188. package/hooks/gsd-worktree-path-guard.js +2 -1
  189. package/hooks/gsd-write-guard.js +359 -0
  190. package/hooks/hooks.json +12 -0
  191. package/hooks/lib/isolation-sentinel.js +268 -0
  192. package/hooks/managed-hooks-registry.cjs +2 -0
  193. package/package.json +14 -5
  194. package/pi/gsd.cjs +57 -12
  195. package/scripts/build-hooks.js +9 -0
  196. package/scripts/changeset/lint.cjs +9 -2
  197. package/scripts/changeset/serialize.cjs +5 -1
  198. package/scripts/gen-capability-matrix.cjs +1 -1
  199. package/scripts/gen-context-index.cjs +448 -0
  200. package/scripts/gen-inventory-manifest.cjs +101 -1
  201. package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
  202. package/scripts/gen-registry.cjs +39 -15
  203. package/scripts/gen-section-manifest.cjs +638 -0
  204. package/scripts/generate-package-identity.cjs +4 -2
  205. package/scripts/lint-allow-test-rule-refs.allowlist.json +17 -31
  206. package/scripts/lint-compiled-artifact-sync.cjs +6 -1
  207. package/scripts/lint-docs-command-form.cjs +195 -0
  208. package/scripts/lint-docs-required.cjs +9 -1
  209. package/scripts/lint-emitted-drift-ack.cjs +215 -20
  210. package/scripts/lint-example-parser-parity.cjs +395 -0
  211. package/scripts/lint-test-file-count.allowlist.json +27 -1
  212. package/scripts/mutation-matrix.cjs +13 -0
  213. package/scripts/prompt-injection-scan.sh +27 -6
  214. package/scripts/registry-schema.cjs +323 -94
  215. package/scripts/run-tests.cjs +3 -2
  216. package/scripts/validate-registry.cjs +10 -6
  217. package/skills/gsd-autonomous/SKILL.md +1 -1
  218. package/skills/gsd-execute-phase/SKILL.md +1 -1
  219. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  220. package/skills/gsd-new-milestone/SKILL.md +1 -1
  221. package/skills/gsd-plan-phase/SKILL.md +2 -2
  222. package/vscode/package.json +1 -1
  223. package/scripts/gen-emitted-baseline.cjs +0 -145
@@ -199,6 +199,30 @@ function getActiveWorkstream(cwd, opts = {}) {
199
199
  }
200
200
  return name;
201
201
  }
202
+ /**
203
+ * Read-only sibling of getActiveWorkstream (#2850): identical resolution —
204
+ * adapter -> stored name -> validate format -> workstream dir exists — but
205
+ * NEVER calls adapter.clear(). getActiveWorkstream's self-heal (deleting a
206
+ * stale/invalid pointer) is correct for a command that is actively acting on
207
+ * the active workstream; it is wrong for a read-only consumer invoked on
208
+ * every render (e.g. the statusline hook), which must never mutate
209
+ * persistent, possibly cross-session state as a side effect of drawing a
210
+ * screen. A stale or invalid pointer simply resolves to null here — the
211
+ * caller decides what "unresolvable" means for its own render, and the
212
+ * pointer file is left exactly as it was for whatever created it to fix.
213
+ */
214
+ function peekActiveWorkstream(cwd, opts = {}) {
215
+ const adapter = pickActiveWorkstreamAdapter(cwd, opts);
216
+ if (!adapter)
217
+ return null;
218
+ const name = adapter.read();
219
+ if (!name || !validateWorkstreamName(name))
220
+ return null;
221
+ const wsDir = node_path_1.default.join(planningRoot(cwd), 'workstreams', name);
222
+ if (!node_fs_1.default.existsSync(wsDir))
223
+ return null;
224
+ return name;
225
+ }
202
226
  function setActiveWorkstream(cwd, name, opts = {}) {
203
227
  const adapter = pickActiveWorkstreamAdapter(cwd, opts);
204
228
  if (!adapter)
@@ -288,6 +312,7 @@ module.exports = {
288
312
  createMemoryPointerAdapter,
289
313
  pickActiveWorkstreamAdapter,
290
314
  getActiveWorkstream,
315
+ peekActiveWorkstream,
291
316
  setActiveWorkstream,
292
317
  clearActiveWorkstream,
293
318
  parseCliWorkstream,
@@ -3,8 +3,8 @@
3
3
  * Agent Install Check — moved from core.cts (ADR-857 T0 #1268 phase rehome-core-squatters).
4
4
  *
5
5
  * Owns:
6
- * - getAgentsDir(runtime?): string
7
- * - checkAgentsInstalled(runtime?): AgentsInstalledResult
6
+ * - getAgentsDir(runtime?, projectRoot?): string
7
+ * - checkAgentsInstalled(runtime?, projectRoot?): AgentsInstalledResult
8
8
  *
9
9
  * The core.cjs re-export spine was retired in epic #1267; callers import
10
10
  * these symbols from agent-install-check.cjs directly.
@@ -18,6 +18,7 @@ const node_path_1 = __importDefault(require("node:path"));
18
18
  const modelProfiles = require("./model-profiles.cjs");
19
19
  const { MODEL_PROFILES } = modelProfiles;
20
20
  const runtime_homes_cjs_1 = require("./runtime-homes.cjs");
21
+ const runtime_name_policy_cjs_1 = require("./runtime-name-policy.cjs");
21
22
  /**
22
23
  * Resolve the agents directory for the given runtime.
23
24
  *
@@ -26,11 +27,17 @@ const runtime_homes_cjs_1 = require("./runtime-homes.cjs");
26
27
  * 2. For claude runtime: __dirname-relative path (agents/ sibling of gsd-core/)
27
28
  * This is correct for both repo runs and real installs (the runtime config dir's
28
29
  * agents/ folder) because gsd-tools.cjs lives inside gsd-core/bin/ in both cases.
29
- * 3. For non-claude runtimes: getGlobalConfigDir(runtime)/agents
30
+ * 3. For non-claude runtimes with a manifest-backed project-local install:
31
+ * <projectRoot>/<localConfigDir>/agents (or <projectRoot>/agents when
32
+ * the runtime's local install targets the project root). Requiring the
33
+ * GSD manifest prevents runtime-native project agents from shadowing a
34
+ * working global GSD install. Symlinked local agent directories are ignored.
35
+ * 4. For non-claude runtimes: getGlobalConfigDir(runtime)/agents
30
36
  *
31
37
  * @param runtime - the active runtime name; defaults to GSD_RUNTIME env, then 'claude'
38
+ * @param projectRoot - canonical project root for local-install discovery
32
39
  */
33
- function getAgentsDir(runtime) {
40
+ function getAgentsDir(runtime, projectRoot) {
34
41
  if (process.env['GSD_AGENTS_DIR']) {
35
42
  return process.env['GSD_AGENTS_DIR'];
36
43
  }
@@ -38,16 +45,41 @@ function getAgentsDir(runtime) {
38
45
  if (resolved === 'claude') {
39
46
  return node_path_1.default.join(__dirname, '..', '..', '..', 'agents');
40
47
  }
48
+ if (projectRoot) {
49
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
50
+ const { runtimes } = require('./capability-registry.cjs');
51
+ const runtimeConfig = runtimes[resolved]?.runtime;
52
+ const localConfigDirName = (0, runtime_name_policy_cjs_1.getDirName)(resolved);
53
+ const localConfigDir = localConfigDirName === runtime_name_policy_cjs_1.NO_LOCAL_CONFIG_DIR_SENTINEL
54
+ ? undefined
55
+ : runtimeConfig?.hostBehaviors?.localTargetIsProjectRoot
56
+ ? projectRoot
57
+ : node_path_1.default.join(projectRoot, localConfigDirName);
58
+ if (!localConfigDir) {
59
+ return node_path_1.default.join((0, runtime_homes_cjs_1.getGlobalConfigDir)(resolved), 'agents');
60
+ }
61
+ const localAgentsDir = node_path_1.default.join(localConfigDir, 'agents');
62
+ const manifestPath = node_path_1.default.join(localConfigDir, 'gsd-file-manifest.json');
63
+ try {
64
+ if (node_fs_1.default.lstatSync(localAgentsDir).isDirectory() && node_fs_1.default.lstatSync(manifestPath).isFile()) {
65
+ return localAgentsDir;
66
+ }
67
+ }
68
+ catch {
69
+ // Local discovery is best-effort; any probe failure preserves global fallback.
70
+ }
71
+ }
41
72
  return node_path_1.default.join((0, runtime_homes_cjs_1.getGlobalConfigDir)(resolved), 'agents');
42
73
  }
43
74
  /**
44
75
  * Check which GSD agents are installed on disk.
45
76
  *
46
77
  * @param runtime - the active runtime name; defaults to GSD_RUNTIME env, then 'claude'
78
+ * @param projectRoot - canonical project root for local-install discovery
47
79
  */
48
- function checkAgentsInstalled(runtime) {
80
+ function checkAgentsInstalled(runtime, projectRoot) {
49
81
  const resolvedRuntime = runtime ?? (process.env['GSD_RUNTIME'] || 'claude');
50
- const agentsDir = getAgentsDir(resolvedRuntime);
82
+ const agentsDir = getAgentsDir(resolvedRuntime, projectRoot);
51
83
  const expectedAgents = Object.keys(MODEL_PROFILES);
52
84
  const installed = [];
53
85
  const missing = [];
@@ -238,6 +238,30 @@ const SURFACE_DESCRIPTOR_WORDS = new Set([
238
238
  * EXTERNAL-API surface signal. Only unambiguously-internal words: "external"
239
239
  * is deliberately absent (an external API IS external). */
240
240
  const INTERNAL_DESCRIPTORS = new Set(['internal', 'in-house', 'local', 'first-party', 'private']);
241
+ /** #2784: negation suppression. A clause that pairs an integration verb with an
242
+ * API noun but the verb itself is directly negated (e.g. "does not integrate",
243
+ * "integrates no external API") is suppressed. Two windows are checked: a
244
+ * negation qualifier within 2 words directly before the verb, or "no"/"zero"/
245
+ * "none" between the verb and a following noun.
246
+ * KNOWN LIMIT (deliberate, not a bug to fix later): a negation further than 2
247
+ * words before the verb, or a clause where the noun precedes the verb, is NOT
248
+ * suppressed — e.g. "Ships without any API integration." still reports
249
+ * detected:true, because "without" sits outside the verb's 2-word lookback
250
+ * and the noun precedes the verb. This is intentional: detectApiIntegration
251
+ * is fail-closed — an unsuppressed false positive costs a one-line
252
+ * COVERAGE.md declaration, while widening the suppression window risks a
253
+ * false negative that silently lets a real external-API phase past a
254
+ * blocking gate.
255
+ * Hoisted to module scope (#3127 regression fix): this was previously
256
+ * allocated fresh on every source line, which is wasted work on documents
257
+ * with many lines. */
258
+ const NEGATION_QUALIFIERS = new Set([
259
+ 'no', 'not', 'without', 'zero', 'neither', 'nor', 'none', "don't", "doesn't", "didn't", "won't", "can't", "cannot",
260
+ ]);
261
+ /** Negation tokens checked BETWEEN a verb and a later noun (narrower than
262
+ * NEGATION_QUALIFIERS — "not" and "without" are checked only immediately
263
+ * before the verb, via NEGATION_QUALIFIERS above). */
264
+ const NEGATION_NOUN_TOKENS = new Set(['no', 'zero', 'none']);
241
265
  /** A capitalized compound modifier ("Resolver-only", "Read-only", "E-commerce"
242
266
  * — lowercase letter right after the hyphen) is an adjective phrase, not a
243
267
  * service name. Real hyphenated services capitalize the second segment
@@ -421,11 +445,45 @@ function detectApiIntegration(text, terms) {
421
445
  // boundary is the whole relationship test. Nouns are NOT filtered on
422
446
  // "internal" qualification here — "integrate the internal API" is a
423
447
  // fail-closed positive; the declaration dismisses it if wrong.
448
+ //
449
+ // #2784: negation suppression. A clause that pairs an integration verb with
450
+ // an API noun but the verb itself is directly negated (e.g. "does not
451
+ // integrate", "integrates no external API") is suppressed. The check is
452
+ // scoped to the verb's immediate context (the word directly before the
453
+ // verb, or the word directly between verb and noun) — NOT a blanket
454
+ // clause-wide scan, because "without changing runtime dependencies" in a
455
+ // long clause does NOT negate the integration.
456
+ // KNOWN LIMIT (deliberate, not a bug to fix later): a negation further than
457
+ // 2 words before the verb, or a clause where the noun precedes the verb, is
458
+ // NOT suppressed — e.g. "Ships without any API integration." is NOT
459
+ // suppressed today (pinned by a test in tests/api-coverage.test.cjs).
460
+ // detectApiIntegration is fail-closed by design: an unsuppressed false
461
+ // positive costs a one-line COVERAGE.md declaration, while widening the
462
+ // window trades that for a silent false negative on a blocking gate.
424
463
  if (verbRe && nounRe) {
425
464
  for (const clause of clauses) {
426
465
  const verbs = collectTermMatches(verbRe, clause.text);
427
466
  if (verbs.length === 0)
428
467
  continue;
468
+ // #2784: check if any verb is immediately preceded by a negation
469
+ // qualifier (within 2 words before the verb match).
470
+ //
471
+ // OFFSET NOTE: `v.start`/`n.start` (from collectTermMatches below and
472
+ // above) are already CLAUSE-LOCAL — collectTermMatches was called with
473
+ // `clause.text`, not the full line — and so is `clauseText`
474
+ // (`clause.text.toLowerCase()`). They must be used AS-IS to index into
475
+ // `clauseText`; do not re-base them against `clause.start` (that field
476
+ // is the clause's offset within the LINE, a different coordinate space,
477
+ // used only to map line-level spans like `extraNouns`/`masked` into a
478
+ // clause). Subtracting `clause.start` here double-offsets the slice
479
+ // bounds for every clause after the first on a line (#3127 follow-up).
480
+ const clauseText = clause.text.toLowerCase();
481
+ const hasNegatedVerb = verbs.some((v) => {
482
+ const before = clauseText.slice(Math.max(0, v.start - 20), v.start);
483
+ const beforeWords = before.split(/\s+/).filter(Boolean).slice(-2);
484
+ return beforeWords.some((w) => NEGATION_QUALIFIERS.has(w.replace(/[^a-z']/g, '')));
485
+ });
486
+ // Also check if "no"/"zero"/"none" appears between the verb and the noun.
429
487
  const nouns = collectTermMatches(nounRe, clause.text);
430
488
  const nounTerms = new Set(nouns.map((t) => t.term));
431
489
  for (const u of extraNouns) {
@@ -435,6 +493,68 @@ function detectApiIntegration(text, terms) {
435
493
  }
436
494
  if (nounTerms.size === 0)
437
495
  continue;
496
+ // Check for negation between verb and noun.
497
+ //
498
+ // #3127 regression: the original form of this check was
499
+ // O(verbs × nouns), re-slicing and re-splitting the clause text for
500
+ // every (verb, noun) pair — effectively cubic in clause length (a
501
+ // clause of N repeated "integrate api" pairs did O(N^2) pair checks,
502
+ // each doing an O(N) slice/split). On a clause with 800 repeated
503
+ // pairs this took ~8.5s; fast-check's property test then generated
504
+ // documents large enough to hang the whole test file past node:test's
505
+ // 600s timeout. It ALSO subtracted `clause.start` from `v.start`/
506
+ // `n.start` before slicing `clauseText` — but `v.start`/`n.start` are
507
+ // already local to `clause.text` (collectTermMatches was called with
508
+ // clause.text, not the full line), and `clauseText` is exactly
509
+ // `clause.text.toLowerCase()`. So that subtraction double-offset the
510
+ // slice bounds for every clause after the first on a line, sliding
511
+ // (and for negative results, JS's negative-index slice() wraparound
512
+ // non-monotonically re-mapping) the window to characters unrelated to
513
+ // the verb/noun pair — an independent latent bug, fixed here as part
514
+ // of establishing a well-defined O(1) predicate (a piecewise/clamped
515
+ // window has no single "widest span" to reason about at all).
516
+ //
517
+ // EXACT-EQUIVALENCE, single pass: the predicate is "does any pair
518
+ // (v, n) with n.start > v.start have a negation token in the span
519
+ // (v.end, n.start)". Every such span is a SUBSET of the widest
520
+ // possible span for a given noun: [min(v.end) over verbs valid for
521
+ // that noun, n.start). And since that window only widens as a
522
+ // noun's start increases (more verbs become valid, and the noun
523
+ // bound itself grows), the single widest span across the WHOLE
524
+ // clause is anchored at the noun with the maximum start, using the
525
+ // minimum verb-end among verbs valid for THAT noun (not the global
526
+ // minimum verb-end, which could belong to a verb that starts after
527
+ // this noun and so is never a valid pairing with it — a mismatch
528
+ // that would either miss or falsely include a negation). If that one
529
+ // substring contains no negation token, no narrower pair-specific
530
+ // substring can either; if it does, the (minVerb, maxNoun) pair
531
+ // itself contains it. This drops the check to O(verbs + nouns).
532
+ let hasNegatedNoun = false;
533
+ if (nouns.length > 0) {
534
+ let minVerbStart = Infinity;
535
+ for (const v of verbs)
536
+ if (v.start < minVerbStart)
537
+ minVerbStart = v.start;
538
+ let maxNounStart = -Infinity;
539
+ for (const n of nouns)
540
+ if (n.start > maxNounStart)
541
+ maxNounStart = n.start;
542
+ if (maxNounStart > minVerbStart) {
543
+ let minQualifyingVerbEnd = Infinity;
544
+ for (const v of verbs) {
545
+ if (v.start < maxNounStart) {
546
+ const vEnd = v.start + v.term.length;
547
+ if (vEnd < minQualifyingVerbEnd)
548
+ minQualifyingVerbEnd = vEnd;
549
+ }
550
+ }
551
+ const between = clauseText.slice(minQualifyingVerbEnd, maxNounStart);
552
+ const betweenWords = between.split(/\s+/).filter(Boolean);
553
+ hasNegatedNoun = betweenWords.some((w) => NEGATION_NOUN_TOKENS.has(w.replace(/[^a-z']/g, '')));
554
+ }
555
+ }
556
+ if (hasNegatedVerb || hasNegatedNoun)
557
+ continue;
438
558
  for (const vTerm of new Set(verbs.map((t) => t.term))) {
439
559
  for (const nTerm of nounTerms)
440
560
  emitPair(vTerm, nTerm, rawLine);
@@ -28,6 +28,9 @@ const { extractFrontmatter } = frontmatter;
28
28
  const phaseIdMod = require("./phase-id.cjs");
29
29
  const { PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
30
30
  const security_cjs_1 = require("./security.cjs");
31
+ // The SCOPE BOUNDARY convention's filename (`agents/gsd-executor.md`), shared
32
+ // verbatim with the #2287 phase-boundary reader in `uat.cts`.
33
+ const DEFERRED_ITEMS_FILENAME = 'deferred-items.md';
31
34
  // Terminal UAT states: `complete` (legacy) and `resolved` (post-gap-closure
32
35
  // per workflows/execute-phase.md). Hoisted outside scanUatGaps so the Set is
33
36
  // not recreated on each loop iteration.
@@ -534,6 +537,72 @@ function scanContextQuestions(planDir) {
534
537
  }
535
538
  return results;
536
539
  }
540
+ // ─── scanDeferredItems ────────────────────────────────────────────────────────
541
+ /**
542
+ * Scan phase directories for UNRESOLVED entries in `deferred-items.md` (#2646).
543
+ *
544
+ * The SCOPE BOUNDARY convention (`agents/gsd-executor.md`) has a phase agent
545
+ * log an out-of-scope discovery here rather than fix it. #2287 made that file
546
+ * readable at the PHASE boundary (`/gsd-progress` check 7, `audit-uat`); this
547
+ * scanner closes the remaining reader gap one boundary up, so an entry still
548
+ * unresolved at MILESTONE close surfaces in the pre-close audit alongside the
549
+ * other eight categories and the existing `[R]/[A]/[C]` prompt applies to it.
550
+ * Without this, phase directories archive to `milestones/vX.Y-phases/` (#1871)
551
+ * and the entry leaves the live tree having never been triaged.
552
+ *
553
+ * The resolved/unresolved predicate is NOT reimplemented here: `uat.cjs`
554
+ * already exports `parseDeferredItems`, which owns the parsing rule (entries
555
+ * under a `## Deferred Items` level-2 heading, else the whole file fail-safe;
556
+ * RESOLVED only on an explicit case-insensitive `status: resolved` field).
557
+ * Duplicating that inequality is how two readers of the same file drift into
558
+ * disagreeing about what "open" means. The require is deliberately LAZY,
559
+ * inside the scan, to preserve `audit-command-router.cts`'s property that a
560
+ * route never loads the module it does not need.
561
+ */
562
+ function scanDeferredItems(planDir) {
563
+ const phasesDir = node_path_1.default.join(planDir, 'phases');
564
+ if (!node_fs_1.default.existsSync(phasesDir))
565
+ return [];
566
+ let dirs;
567
+ try {
568
+ dirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
569
+ .filter(e => e.isDirectory())
570
+ .map(e => e.name)
571
+ .sort();
572
+ }
573
+ catch {
574
+ return [{ scan_error: true, phase: '', file: '', text: '' }];
575
+ }
576
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
577
+ const uat = require('./uat.cjs');
578
+ const results = [];
579
+ for (const dir of dirs) {
580
+ const phaseDir = node_path_1.default.join(phasesDir, dir);
581
+ const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
582
+ const phaseNum = phaseMatch ? phaseMatch[1] : dir;
583
+ const filePath = node_path_1.default.join(phaseDir, DEFERRED_ITEMS_FILENAME);
584
+ if (!node_fs_1.default.existsSync(filePath))
585
+ continue;
586
+ let safeFilePath;
587
+ try {
588
+ safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'deferred items file', { allowAbsolute: true });
589
+ }
590
+ catch {
591
+ continue;
592
+ }
593
+ const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
594
+ if (content === null)
595
+ continue;
596
+ for (const item of uat.parseDeferredItems(content)) {
597
+ results.push({
598
+ phase: (0, security_cjs_1.sanitizeForDisplay)(phaseNum),
599
+ file: DEFERRED_ITEMS_FILENAME,
600
+ text: (0, security_cjs_1.sanitizeForDisplay)(item.name),
601
+ });
602
+ }
603
+ }
604
+ return results;
605
+ }
537
606
  // ─── auditOpenArtifacts ───────────────────────────────────────────────────────
538
607
  /**
539
608
  * Main audit function. Scans all .planning/ artifact categories.
@@ -607,6 +676,14 @@ function auditOpenArtifacts(cwd) {
607
676
  return [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }];
608
677
  }
609
678
  })();
679
+ const deferredItems = (() => {
680
+ try {
681
+ return scanDeferredItems(planDir);
682
+ }
683
+ catch {
684
+ return [{ scan_error: true, phase: '', file: '', text: '' }];
685
+ }
686
+ })();
610
687
  // Count real items (not scan_error sentinels)
611
688
  const countReal = (arr) => arr.filter(i => !i.scan_error && !i._remainder_count).length;
612
689
  const counts = {
@@ -618,9 +695,10 @@ function auditOpenArtifacts(cwd) {
618
695
  uat_gaps: countReal(uatGaps),
619
696
  verification_gaps: countReal(verificationGaps),
620
697
  context_questions: countReal(contextQuestions),
698
+ deferred_items: countReal(deferredItems),
621
699
  total: 0,
622
700
  };
623
- counts.total = counts.debug_sessions + counts.quick_tasks + counts.threads + counts.todos + counts.seeds + counts.uat_gaps + counts.verification_gaps + counts.context_questions;
701
+ counts.total = counts.debug_sessions + counts.quick_tasks + counts.threads + counts.todos + counts.seeds + counts.uat_gaps + counts.verification_gaps + counts.context_questions + counts.deferred_items;
624
702
  return {
625
703
  scanned_at: new Date().toISOString(),
626
704
  has_open_items: counts.total > 0,
@@ -634,6 +712,7 @@ function auditOpenArtifacts(cwd) {
634
712
  uat_gaps: uatGaps,
635
713
  verification_gaps: verificationGaps,
636
714
  context_questions: contextQuestions,
715
+ deferred_items: deferredItems,
637
716
  },
638
717
  };
639
718
  }
@@ -738,6 +817,15 @@ function formatAuditReport(auditResult) {
738
817
  }
739
818
  }
740
819
  }
820
+ // Deferred items (deferred decisions — blue). Out-of-scope discoveries a
821
+ // phase agent recorded rather than fixed, still unresolved at close (#2646).
822
+ if (counts.deferred_items > 0) {
823
+ lines.push('');
824
+ lines.push(`🔵 Deferred Items (${counts.deferred_items} unresolved)`);
825
+ for (const item of items.deferred_items.filter(i => !i.scan_error)) {
826
+ lines.push(` • Phase ${item.phase}: ${item.text}`);
827
+ }
828
+ }
741
829
  lines.push('');
742
830
  lines.push(hr);
743
831
  lines.push(` ${counts.total} item${counts.total !== 1 ? 's' : ''} require decisions before close.`);
@@ -1,11 +1,11 @@
1
1
  "use strict";
2
2
  /**
3
- * Broken-windows ledger — enforced cross-phase defect register (issue #1950).
3
+ * Broken-windows ledger — optionally enforced cross-phase defect register (issue #1950).
4
4
  *
5
5
  * Manages `.planning/WINDOWS.md`: a cross-phase ledger of small defects (stubs,
6
6
  * TODOs, skipped tests, lint warnings, unrun verifies, unmet truths, deviations).
7
- * `/gsd-ship` blocks while any entry is `open`; an entry can be `waived` only
8
- * with a recorded reason or `fixed`.
7
+ * When `workflow.windows_enforce` is true, `/gsd-ship` blocks while any entry is
8
+ * `open`; an entry can be `waived` only with a recorded reason or `fixed`.
9
9
  *
10
10
  * LEAF MODULE — imports ONLY: node:fs, node:path. No other src/ imports.
11
11
  *
@@ -302,7 +302,12 @@ function parseFrontmatterStrict(raw) {
302
302
  }
303
303
  const yamlBody = raw.slice(headerEnd, closeIdx);
304
304
  const out = {};
305
- for (const line of yamlBody.split(/\r?\n/)) {
305
+ for (const rawLine of yamlBody.split(/\r?\n/)) {
306
+ // #3116: the `\n---` scan leaves the final line's CR attached on a CRLF
307
+ // ledger, and `.` never matches CR, so the key: value regex below fails on
308
+ // it. Strip the trailing CR per line so the rest of `raw` (which
309
+ // parseJsonBlock also slices by byte offset) is unaffected.
310
+ const line = rawLine.replace(/\r$/, '');
306
311
  if (line.trim() === '')
307
312
  continue;
308
313
  const m = line.match(/^([a-zA-Z0-9_]+):\s*(.*)$/);
@@ -447,7 +452,7 @@ function renderLedger(ledger) {
447
452
  const header = [
448
453
  '# Broken Windows Ledger',
449
454
  '',
450
- '> Cross-phase defect register. `/gsd-ship` blocks while `open_count > 0`.',
455
+ '> Cross-phase defect register. With `workflow.windows_enforce` enabled, `/gsd-ship` blocks while `open_count > 0`.',
451
456
  '> Waive with `gsd-tools windows waive <id> "<reason>"` (reason required).',
452
457
  '> Mark fixed with `gsd-tools windows fixed <id>`.',
453
458
  '',
@@ -559,7 +564,32 @@ function writeLedgerAtomic(cwd, ledger) {
559
564
  ensurePlanningDir(cwd);
560
565
  const p = ledgerPath(cwd);
561
566
  const tmp = `${p}.${process.pid}.tmp`;
562
- node_fs_1.default.writeFileSync(tmp, renderLedger(ledger), 'utf8');
567
+ // #2893: preserve any prose below the JSON ledger block. renderLedger
568
+ // reconstructs frontmatter + header + table + JSON — it does not include
569
+ // trailing prose that users may have written below the closing fence.
570
+ // Without this, every append/waive/fixed silently destroys that prose.
571
+ let trailingProse = '';
572
+ try {
573
+ const existing = node_fs_1.default.readFileSync(p, 'utf8');
574
+ // #2893: search for the CLOSING fence starting AFTER the opening fence,
575
+ // mirroring parseJsonBlock — indexOf(JSON_FENCE_CLOSE) alone would match
576
+ // the opening fence ('````json' starts with '````').
577
+ const openIdx = existing.indexOf(JSON_FENCE_OPEN);
578
+ if (openIdx !== -1) {
579
+ const fenceEnd = existing.indexOf(JSON_FENCE_CLOSE, openIdx + JSON_FENCE_OPEN.length);
580
+ if (fenceEnd !== -1) {
581
+ const afterFence = existing.slice(fenceEnd + JSON_FENCE_CLOSE.length);
582
+ // Drop leading newlines; keep the rest as prose.
583
+ trailingProse = afterFence.replace(/^\n+/, '');
584
+ }
585
+ }
586
+ }
587
+ catch {
588
+ // File doesn't exist yet (first write) — no prose to preserve.
589
+ }
590
+ const rendered = renderLedger(ledger);
591
+ const content = trailingProse ? `${rendered}${trailingProse}` : rendered;
592
+ node_fs_1.default.writeFileSync(tmp, content, 'utf8');
563
593
  try {
564
594
  renameWithRetry(tmp, p);
565
595
  }