@opengsd/gsd-core 1.7.0-rc.1 → 1.7.0-rc.3

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 (191) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +711 -0
  4. package/GEMINI.md +25 -23
  5. package/README.md +3 -3
  6. package/agents/gsd-advisor-researcher.md +3 -1
  7. package/agents/gsd-ai-researcher.md +2 -2
  8. package/agents/gsd-assumptions-analyzer.md +2 -0
  9. package/agents/gsd-code-fixer.md +3 -1
  10. package/agents/gsd-code-reviewer.md +2 -0
  11. package/agents/gsd-codebase-mapper.md +2 -0
  12. package/agents/gsd-debug-session-manager.md +1 -1
  13. package/agents/gsd-debugger.md +3 -1
  14. package/agents/gsd-doc-writer.md +2 -0
  15. package/agents/gsd-domain-researcher.md +1 -1
  16. package/agents/gsd-eval-auditor.md +3 -1
  17. package/agents/gsd-executor.md +12 -9
  18. package/agents/gsd-integration-checker.md +2 -0
  19. package/agents/gsd-intel-updater.md +1 -1
  20. package/agents/gsd-mempalace-curator.md +1 -1
  21. package/agents/gsd-nyquist-auditor.md +2 -0
  22. package/agents/gsd-phase-researcher.md +4 -2
  23. package/agents/gsd-plan-checker.md +3 -1
  24. package/agents/gsd-planner.md +4 -2
  25. package/agents/gsd-project-researcher.md +4 -2
  26. package/agents/gsd-research-synthesizer.md +3 -1
  27. package/agents/gsd-roadmapper.md +2 -0
  28. package/agents/gsd-security-auditor.md +2 -0
  29. package/agents/gsd-ui-auditor.md +2 -0
  30. package/agents/gsd-ui-checker.md +2 -0
  31. package/agents/gsd-ui-researcher.md +4 -2
  32. package/agents/gsd-verifier.md +6 -4
  33. package/bin/install.js +226 -459
  34. package/commands/gsd/mempalace-capture.md +1 -1
  35. package/commands/gsd/mempalace-recall.md +9 -5
  36. package/commands/gsd/next.md +29 -0
  37. package/commands/gsd/ns-workflow.md +5 -2
  38. package/gsd-core/bin/ensure-runtime-build.cjs +246 -0
  39. package/gsd-core/bin/gsd-tools.cjs +43 -8
  40. package/gsd-core/bin/lib/capability-loader.cjs +27 -9
  41. package/gsd-core/bin/lib/capability-registry.cjs +209 -205
  42. package/gsd-core/bin/lib/capability-source.cjs +22 -7
  43. package/gsd-core/bin/lib/capability-validator.cjs +24 -2
  44. package/gsd-core/bin/lib/check-command-router.cjs +111 -1
  45. package/gsd-core/bin/lib/clusters.cjs +1 -0
  46. package/gsd-core/bin/lib/commands.cjs +2 -1
  47. package/gsd-core/bin/lib/config.cjs +19 -1
  48. package/gsd-core/bin/lib/core-utils.cjs +41 -0
  49. package/gsd-core/bin/lib/external-job.cjs +287 -0
  50. package/gsd-core/bin/lib/frontmatter.cjs +53 -6
  51. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +151 -0
  52. package/gsd-core/bin/lib/graphify.cjs +41 -9
  53. package/gsd-core/bin/lib/handshake-serialized.cjs +70 -0
  54. package/gsd-core/bin/lib/host-integration-sdk.cjs +53 -0
  55. package/gsd-core/bin/lib/host-integration.cjs +61 -0
  56. package/gsd-core/bin/lib/init.cjs +34 -6
  57. package/gsd-core/bin/lib/milestone.cjs +56 -11
  58. package/gsd-core/bin/lib/phase-id.cjs +18 -0
  59. package/gsd-core/bin/lib/phase.cjs +51 -29
  60. package/gsd-core/bin/lib/phases-command-router.cjs +4 -3
  61. package/gsd-core/bin/lib/plan-scan.cjs +9 -1
  62. package/gsd-core/bin/lib/probe-core.cjs +44 -4
  63. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  64. package/gsd-core/bin/lib/prohibition-enforcement.cjs +21 -19
  65. package/gsd-core/bin/lib/roadmap-command-router.cjs +3 -2
  66. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -11
  67. package/gsd-core/bin/lib/roadmap.cjs +45 -22
  68. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +18 -13
  69. package/gsd-core/bin/lib/runtime-name-policy.cjs +8 -10
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +12 -8
  71. package/gsd-core/bin/lib/smart-entry.cjs +521 -0
  72. package/gsd-core/bin/lib/state-transition.cjs +15 -0
  73. package/gsd-core/bin/lib/state.cjs +27 -8
  74. package/gsd-core/bin/lib/surface.cjs +18 -9
  75. package/gsd-core/bin/lib/update-context.cjs +0 -5
  76. package/gsd-core/bin/lib/validate.cjs +2 -1
  77. package/gsd-core/bin/lib/verify.cjs +6 -4
  78. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +12 -2
  79. package/gsd-core/bin/lib/workstream-inventory.cjs +28 -0
  80. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  81. package/gsd-core/bin/shared/model-catalog.json +8 -13
  82. package/gsd-core/bin/shared/runtime-aliases.manifest.json +0 -5
  83. package/gsd-core/references/agent-skills-bootstrap.md +60 -0
  84. package/gsd-core/references/gates.md +1 -1
  85. package/gsd-core/references/model-profiles.md +27 -0
  86. package/gsd-core/references/planning-config.md +1 -1
  87. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  88. package/gsd-core/workflows/add-backlog.md +1 -1
  89. package/gsd-core/workflows/add-phase.md +1 -1
  90. package/gsd-core/workflows/add-tests.md +1 -1
  91. package/gsd-core/workflows/add-todo.md +1 -1
  92. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  93. package/gsd-core/workflows/audit-fix.md +1 -1
  94. package/gsd-core/workflows/audit-milestone.md +1 -1
  95. package/gsd-core/workflows/audit-uat.md +1 -1
  96. package/gsd-core/workflows/autonomous.md +23 -25
  97. package/gsd-core/workflows/check-todos.md +1 -1
  98. package/gsd-core/workflows/cleanup.md +1 -1
  99. package/gsd-core/workflows/code-review-fix.md +1 -1
  100. package/gsd-core/workflows/code-review.md +1 -1
  101. package/gsd-core/workflows/complete-milestone.md +7 -11
  102. package/gsd-core/workflows/debug.md +1 -1
  103. package/gsd-core/workflows/diagnose-issues.md +1 -1
  104. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  105. package/gsd-core/workflows/discuss-phase/modes/auto.md +1 -1
  106. package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
  107. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  108. package/gsd-core/workflows/discuss-phase.md +2 -2
  109. package/gsd-core/workflows/do.md +1 -1
  110. package/gsd-core/workflows/docs-update.md +1 -1
  111. package/gsd-core/workflows/edit-phase.md +1 -1
  112. package/gsd-core/workflows/eval-review.md +1 -1
  113. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  114. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
  115. package/gsd-core/workflows/execute-phase.md +5 -6
  116. package/gsd-core/workflows/execute-plan.md +1 -1
  117. package/gsd-core/workflows/explore.md +1 -1
  118. package/gsd-core/workflows/extract-learnings.md +1 -1
  119. package/gsd-core/workflows/forensics.md +4 -4
  120. package/gsd-core/workflows/graduation.md +1 -1
  121. package/gsd-core/workflows/health.md +1 -1
  122. package/gsd-core/workflows/help/modes/full.md +15 -1
  123. package/gsd-core/workflows/help/modes/topic.md +1 -0
  124. package/gsd-core/workflows/import.md +1 -1
  125. package/gsd-core/workflows/ingest-docs.md +1 -1
  126. package/gsd-core/workflows/insert-phase.md +1 -1
  127. package/gsd-core/workflows/list-seeds.md +1 -1
  128. package/gsd-core/workflows/list-workspaces.md +1 -1
  129. package/gsd-core/workflows/manager.md +1 -1
  130. package/gsd-core/workflows/map-codebase.md +1 -1
  131. package/gsd-core/workflows/milestone-summary.md +4 -4
  132. package/gsd-core/workflows/mvp-phase.md +1 -1
  133. package/gsd-core/workflows/new-milestone.md +7 -1
  134. package/gsd-core/workflows/new-project.md +1 -1
  135. package/gsd-core/workflows/new-workspace.md +1 -1
  136. package/gsd-core/workflows/next.md +1 -1
  137. package/gsd-core/workflows/pause-work.md +1 -1
  138. package/gsd-core/workflows/plan-milestone-gaps.md +1 -1
  139. package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +42 -0
  140. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +102 -0
  141. package/gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md +23 -0
  142. package/gsd-core/workflows/plan-phase.md +12 -160
  143. package/gsd-core/workflows/plan-review-convergence.md +1 -1
  144. package/gsd-core/workflows/plant-seed.md +1 -1
  145. package/gsd-core/workflows/pr-branch.md +1 -1
  146. package/gsd-core/workflows/profile-user.md +1 -1
  147. package/gsd-core/workflows/progress.md +1 -1
  148. package/gsd-core/workflows/quick.md +1 -1
  149. package/gsd-core/workflows/reapply-patches.md +1 -1
  150. package/gsd-core/workflows/remove-phase.md +1 -1
  151. package/gsd-core/workflows/remove-workspace.md +1 -1
  152. package/gsd-core/workflows/resume-project.md +1 -1
  153. package/gsd-core/workflows/review.md +50 -9
  154. package/gsd-core/workflows/scan.md +1 -1
  155. package/gsd-core/workflows/secure-phase.md +1 -1
  156. package/gsd-core/workflows/settings-advanced.md +13 -11
  157. package/gsd-core/workflows/settings-integrations.md +1 -1
  158. package/gsd-core/workflows/settings.md +1 -1
  159. package/gsd-core/workflows/ship.md +1 -1
  160. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  161. package/gsd-core/workflows/sketch.md +1 -1
  162. package/gsd-core/workflows/smart-entry.md +120 -0
  163. package/gsd-core/workflows/spec-phase.md +8 -6
  164. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  165. package/gsd-core/workflows/spike.md +1 -1
  166. package/gsd-core/workflows/stats.md +1 -1
  167. package/gsd-core/workflows/thread.md +1 -1
  168. package/gsd-core/workflows/transition.md +1 -1
  169. package/gsd-core/workflows/ui-phase.md +1 -1
  170. package/gsd-core/workflows/ui-review.md +1 -1
  171. package/gsd-core/workflows/ultraplan-phase.md +1 -1
  172. package/gsd-core/workflows/update.md +3 -6
  173. package/gsd-core/workflows/validate-phase.md +1 -1
  174. package/gsd-core/workflows/verify-phase.md +1 -1
  175. package/gsd-core/workflows/verify-work.md +63 -6
  176. package/package.json +5 -5
  177. package/scripts/ci-test-scope.cjs +19 -1
  178. package/scripts/generate-package-identity.cjs +27 -2
  179. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -148
  180. package/scripts/lint-regression-test-names.allowlist.json +1 -273
  181. package/scripts/lint-test-file-count.allowlist.json +7 -91
  182. package/scripts/research-profiles.cjs +6 -6
  183. package/scripts/run-tests.cjs +67 -4
  184. package/scripts/slurm-adapter.cjs +269 -0
  185. package/scripts/sync-manifest-versions.cjs +67 -14
  186. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  187. package/skills/gsd-mempalace-recall/SKILL.md +9 -5
  188. package/skills/gsd-next/SKILL.md +30 -0
  189. package/skills/gsd-ns-workflow/SKILL.md +4 -1
  190. package/skills/gsd-review/SKILL.md +6 -0
  191. package/gemini-extension.json +0 -6
@@ -153,16 +153,31 @@ function _setCapabilitySourceHttpGet(fn) {
153
153
  // ---------------------------------------------------------------------------
154
154
  // Helpers
155
155
  // ---------------------------------------------------------------------------
156
- /** Resolve the running GSD version; fail-closed to '0.0.0'. */
157
- function readHostVersion() {
156
+ /**
157
+ * Resolve the running GSD version; fail-closed to '0.0.0'.
158
+ *
159
+ * Prefer the authoritative `gsd-core/VERSION` the installer writes for EVERY runtime
160
+ * (libDir = gsd-core/bin/lib/, so `../../VERSION` = gsd-core/VERSION), so installed
161
+ * layouts report the true version even when the walked-up `../../../package.json` is
162
+ * absent or belongs to the user's own project (#1920). Fall back to the runtime-root
163
+ * package.json (dev/source tree), then fail-closed. Mirrors resolveVersionFrom() (#1383).
164
+ */
165
+ function readHostVersion(libDir = __dirname) {
166
+ const SEMVER_PREFIX = /^\d+\.\d+\.\d+/;
158
167
  try {
159
- // eslint-disable-next-line @typescript-eslint/no-require-imports
160
- const pkg = require('../../../package.json');
161
- return typeof pkg.version === 'string' && pkg.version ? pkg.version : '0.0.0';
168
+ const v = node_fs_1.default.readFileSync(node_path_1.default.join(libDir, '..', '..', 'VERSION'), 'utf8').trim();
169
+ if (SEMVER_PREFIX.test(v))
170
+ return v;
162
171
  }
163
- catch {
164
- return '0.0.0';
172
+ catch { /* not an installed tree (no gsd-core/VERSION) */ }
173
+ try {
174
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
175
+ const pkg = require(node_path_1.default.join(libDir, '..', '..', '..', 'package.json'));
176
+ if (pkg && typeof pkg.version === 'string' && SEMVER_PREFIX.test(pkg.version))
177
+ return pkg.version;
165
178
  }
179
+ catch { /* runtime root has no package.json */ }
180
+ return '0.0.0';
166
181
  }
167
182
  /** Compute sha512-<base64> integrity over a buffer. */
168
183
  function computeIntegrity(buf) {
@@ -704,7 +704,12 @@ const VALID_CONFIG_FORMATS = new Set(['settings-json', 'toml', 'markdown', 'mark
704
704
  const VALID_CONFIG_HOME_KINDS = new Set(['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root']);
705
705
  const VALID_COMMAND_STYLES = new Set(['slash-hyphen', 'shell-var']);
706
706
  const VALID_HOOKS_SURFACES = new Set(['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'none']);
707
- const VALID_HOOK_EVENTS = new Set(['claude', 'gemini', 'opencode-subset']);
707
+ const VALID_HOOK_EVENTS = new Set(['claude', 'gemini']);
708
+ // extensionEvents — the plugin/extension-system event dialect (ADR-1239 amendment / #1943).
709
+ // DISTINCT from hookEvents (managed-hook dialect): extensionEvents describes the
710
+ // plugin-owned event subset imperative hosts expose (opencode / pi); 'none' = the
711
+ // host exposes no extension surface (engine owns the bus, e.g. VS Code).
712
+ const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'none']);
708
713
  const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']);
709
714
  const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']);
710
715
  const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']);
@@ -978,7 +983,9 @@ function validateRuntimeBody(cap) {
978
983
  );
979
984
  }
980
985
 
981
- // hookEvents — optional; if present must be in closed 3-enum (ADR-1016 Decision 5)
986
+ // hookEvents — optional; if present must be in closed enum (ADR-1016 Decision 5).
987
+ // Managed-hook dialect only (claude/gemini). The OpenCode extension-system event
988
+ // subset is NOT a hookEvents value — it is `extensionEvents` (ADR-1239 / #1943).
982
989
  if (r.hookEvents !== undefined) {
983
990
  if (r.hookEvents === '__proto__' || r.hookEvents === 'constructor' || r.hookEvents === 'prototype') {
984
991
  errors.push('runtime.hookEvents "' + r.hookEvents + '" is a reserved name');
@@ -990,6 +997,20 @@ function validateRuntimeBody(cap) {
990
997
  }
991
998
  }
992
999
 
1000
+ // extensionEvents — optional; the extension-system event dialect (ADR-1239 amendment / #1943).
1001
+ // Distinct from hookEvents. Only imperative-embedding hosts (with a plugin/extension
1002
+ // API) set it; declarative hosts do not.
1003
+ if (r.extensionEvents !== undefined) {
1004
+ if (r.extensionEvents === '__proto__' || r.extensionEvents === 'constructor' || r.extensionEvents === 'prototype') {
1005
+ errors.push('runtime.extensionEvents "' + r.extensionEvents + '" is a reserved name');
1006
+ } else if (!VALID_EXTENSION_EVENTS.has(r.extensionEvents)) {
1007
+ errors.push(
1008
+ 'runtime.extensionEvents must be one of: ' + [...VALID_EXTENSION_EVENTS].join(', ') +
1009
+ ' (got: ' + JSON.stringify(r.extensionEvents) + ')',
1010
+ );
1011
+ }
1012
+ }
1013
+
993
1014
  // sandboxTier — closed 2-enum (ADR-1016 Decision 6); inline literal guard (CodeQL barrier)
994
1015
  if (r.sandboxTier === '__proto__' || r.sandboxTier === 'constructor' || r.sandboxTier === 'prototype') {
995
1016
  errors.push('runtime.sandboxTier "' + r.sandboxTier + '" is a reserved name');
@@ -2225,6 +2246,7 @@ module.exports = {
2225
2246
  VALID_COMMAND_STYLES,
2226
2247
  VALID_HOOKS_SURFACES,
2227
2248
  VALID_HOOK_EVENTS,
2249
+ VALID_EXTENSION_EVENTS,
2228
2250
  VALID_SANDBOX_TIERS,
2229
2251
  VALID_ARTIFACT_KIND_NAMES,
2230
2252
  VALID_ARTIFACT_NESTINGS,
@@ -34,6 +34,10 @@ const { getRoadmapPhaseWithFallback } = roadmapModule;
34
34
  const gapCheckerModule = require("./gap-checker.cjs");
35
35
  const { runGapAnalysis } = gapCheckerModule;
36
36
  const prohibition_enforcement_cjs_1 = require("./prohibition-enforcement.cjs");
37
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
38
+ const gatePredicateEval = require("./gate-predicate-evaluator.cjs");
39
+ const { evaluatePredicate } = gatePredicateEval;
40
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
37
41
  // ─── Helpers ──────────────────────────────────────────────────────────────────
38
42
  function normalizePhrase(text) {
39
43
  // eslint-disable-next-line @typescript-eslint/no-base-to-string
@@ -778,6 +782,100 @@ function cmdGapAnalysisPlanPost(projectDir, args, raw) {
778
782
  message: result.table || result.summary || '',
779
783
  }, raw, undefined);
780
784
  }
785
+ // ─── predicate (generic gate-predicate evaluator, #2008) ──────────────────────
786
+ /**
787
+ * Production subprocess binding for the gate-predicate evaluator. Wraps the
788
+ * bounded `execTool` seam (shell-command-projection) as a `runBoundedShell`
789
+ * the pure evaluator consumes. `sh -c` runs the interpolated command; the
790
+ * subprocess inherits the process env and is killed (SIGTERM) on timeout.
791
+ *
792
+ * `timedOut` is derived from the kill signal: spawnSync sets `signal: 'SIGTERM'`
793
+ * when the `timeout` fires, distinct from a normal non-zero exit code. A command
794
+ * that self-terminates with SIGTERM is indistinguishable at this seam and is
795
+ * reported as a timeout — either way the gate blocks (non-zero), so the outcome
796
+ * is fail-closed and correct. See ADR-2008.
797
+ */
798
+ function buildPredicateDeps() {
799
+ return {
800
+ runBoundedShell(opts) {
801
+ const r = (0, shell_command_projection_cjs_1.execTool)('sh', ['-c', opts.command], { cwd: opts.cwd, timeout: opts.timeoutMs });
802
+ return {
803
+ exitCode: r.exitCode,
804
+ stdout: r.stdout,
805
+ stderr: r.stderr,
806
+ signal: r.signal,
807
+ timedOut: r.signal === 'SIGTERM',
808
+ };
809
+ },
810
+ };
811
+ }
812
+ /** Parse `--flag value` pairs from an args array into a map (last write wins). */
813
+ function parsePredicateFlags(args) {
814
+ const out = {};
815
+ for (let i = 0; i < args.length; i++) {
816
+ const a = args[i];
817
+ if (typeof a !== 'string')
818
+ continue;
819
+ if (!a.startsWith('--'))
820
+ continue;
821
+ const key = a.slice(2);
822
+ const next = args[i + 1];
823
+ if (key.length > 0 && typeof next === 'string' && !next.startsWith('--')) {
824
+ out[key] = next;
825
+ i++;
826
+ }
827
+ }
828
+ return out;
829
+ }
830
+ /**
831
+ * `check predicate` — generic evaluator for capability gate `check.predicate`
832
+ * blocks (#2008). The workflow gate-dispatch invokes this for any gate whose
833
+ * `check` carries a `predicate` (instead of a `query`); the predicate object is
834
+ * passed as `--predicate '<json>'`. Emits the standard `{ block, message,
835
+ * details? }` gate contract on success. A malformed predicate / unknown kind
836
+ * THROWS inside the evaluator and is mapped here to `error()` (non-zero exit),
837
+ * which the workflow's two-step gate contract treats as a step-1 command failure
838
+ * routed per the gate's `onError`.
839
+ *
840
+ * Invocation:
841
+ * gsd_run check predicate --predicate '<json>' \
842
+ * [--phase-dir <dir>] [--phase-number <n>] [--phase-req-ids <ids>] --raw
843
+ *
844
+ * The subprocess runs at the runtime project root (the `cwd` passed to this
845
+ * router), inheriting the process env. Interpolation placeholders
846
+ * ${PHASE_NUMBER}/${PHASE_DIR}/${PHASE_REQ_IDS} are substituted from the flags.
847
+ */
848
+ function cmdCheckPredicate(projectDir, args, raw) {
849
+ const flags = parsePredicateFlags(args);
850
+ const predicateJson = flags['predicate'];
851
+ if (!predicateJson) {
852
+ error('predicate requires --predicate <json> (the gate hook check.predicate object)', ERROR_REASON.SDK_MISSING_ARG);
853
+ return;
854
+ }
855
+ let predicate;
856
+ try {
857
+ predicate = JSON.parse(predicateJson);
858
+ }
859
+ catch {
860
+ error('predicate --predicate value must be valid JSON', ERROR_REASON.USAGE);
861
+ return;
862
+ }
863
+ const ctx = {
864
+ cwd: projectDir,
865
+ phaseNumber: flags['phase-number'],
866
+ phaseDir: flags['phase-dir'],
867
+ phaseReqIds: flags['phase-req-ids'],
868
+ };
869
+ let result;
870
+ try {
871
+ result = evaluatePredicate(predicate, ctx, buildPredicateDeps());
872
+ }
873
+ catch (e) {
874
+ error(`gate predicate evaluation failed: ${e.message}`, ERROR_REASON.USAGE);
875
+ return;
876
+ }
877
+ output(result, raw, undefined);
878
+ }
781
879
  function routeCheckCommand({ args, cwd, raw }) {
782
880
  // Normalize dots to hyphens in the subcommand so both forms are accepted.
783
881
  // This makes `check.query = "ui.plan-gate"` (dotted form in capability.json gates)
@@ -830,6 +928,15 @@ function routeCheckCommand({ args, cwd, raw }) {
830
928
  cmdVerifyCodebaseDrift(cwd, raw);
831
929
  return;
832
930
  }
931
+ if (subcommand === 'predicate') {
932
+ // Generic gate-predicate evaluator (#2008). The workflow gate-dispatch calls
933
+ // this for any gate whose `check` carries a `predicate` (instead of a `query`),
934
+ // passing the predicate object as --predicate '<json>'. NOTE: unlike the
935
+ // `check.query` subcommands above (which take positional phase args), this
936
+ // subcommand parses --flag value pairs.
937
+ cmdCheckPredicate(cwd, args, raw);
938
+ return;
939
+ }
833
940
  if (subcommand === 'prohibition-enforcement') {
834
941
  // The deterministic test-tier prohibition PRODUCER/gate (#1259, ADR-550 D5d). Locates the
835
942
  // wired mechanical check (node-test or lint-rule), confirms fail-first, runs it, builds
@@ -838,7 +945,7 @@ function routeCheckCommand({ args, cwd, raw }) {
838
945
  (0, prohibition_enforcement_cjs_1.routeProhibitionEnforcement)(args, raw);
839
946
  return;
840
947
  }
841
- error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, prohibition-enforcement, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND);
948
+ error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, predicate, prohibition-enforcement, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND);
842
949
  }
843
950
  module.exports = {
844
951
  routeCheckCommand,
@@ -848,4 +955,7 @@ module.exports = {
848
955
  computeUiSafetyGate,
849
956
  cmdGapAnalysisPlanPost,
850
957
  cmdTddReviewCheckpoint,
958
+ cmdCheckPredicate,
959
+ buildPredicateDeps,
960
+ parsePredicateFlags,
851
961
  };
@@ -20,6 +20,7 @@ exports.CLUSTERS = void 0;
20
20
  exports.allClusteredSkills = allClusteredSkills;
21
21
  exports.CLUSTERS = Object.freeze({
22
22
  core_loop: Object.freeze([
23
+ 'next',
23
24
  'new-project',
24
25
  'discuss-phase',
25
26
  'plan-phase',
@@ -1302,7 +1302,8 @@ function cmdStats(cwd, format, raw) {
1302
1302
  const roadmapContent = extractCurrentMilestone(roadmapRaw, cwd);
1303
1303
  // Matches both plain numeric (Phase 1:) and milestone-prefixed (Phase 2-01:) headings.
1304
1304
  // Also tolerates optional [bracket-token] scope prefix on phase headings.
1305
- const headingPattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:\s*([^\n]+)/gi;
1305
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1306
+ const headingPattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
1306
1307
  let match;
1307
1308
  while ((match = headingPattern.exec(roadmapContent)) !== null) {
1308
1309
  const key = normalizePhaseName(match[1]);
@@ -531,7 +531,12 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
531
531
  parsedValue = true;
532
532
  else if (val === 'false')
533
533
  parsedValue = false;
534
- else if (!isNaN(Number(val)) && val !== '')
534
+ // #1581: Number.isFinite (not !isNaN) so 'Infinity'/'-Infinity' are NOT
535
+ // coerced to non-finite numbers that JSON.stringify later renders as `null`
536
+ // (disk=null while the CLI echoed 'Infinity'). They fall through to the
537
+ // JSON branch (which rejects them) and stay strings, then per-key validators
538
+ // reject them with a non-zero exit.
539
+ else if (Number.isFinite(Number(val)) && val !== '')
535
540
  parsedValue = Number(val);
536
541
  else if (typeof val === 'string' && (val.startsWith('[') || val.startsWith('{'))) {
537
542
  try {
@@ -539,6 +544,11 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
539
544
  }
540
545
  catch { /* keep as string */ }
541
546
  }
547
+ // #1581: project_code is an identifier string — never number-coerce it. A
548
+ // leading-zero code like '007' must persist verbatim (not collapse to 7).
549
+ if (kp === 'project_code') {
550
+ parsedValue = val;
551
+ }
542
552
  const VALID_CONTEXT_VALUES = ['dev', 'research', 'review'];
543
553
  if (kp === 'context')
544
554
  assertEnumValue(parsedValue, val, VALID_CONTEXT_VALUES, 'context value');
@@ -551,6 +561,14 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
551
561
  error(`Invalid workflow.drift_threshold '${val}'. Must be a positive integer.`);
552
562
  }
553
563
  }
564
+ // #1581: context_window must be a finite positive integer. 'Infinity' is no
565
+ // longer number-coerced (see the parse block above) so it reaches here as a
566
+ // string and is rejected; '0', negatives, and non-integers are also rejected.
567
+ if (kp === 'context_window') {
568
+ if (typeof parsedValue !== 'number' || !Number.isFinite(parsedValue) || !Number.isInteger(parsedValue) || parsedValue < 1) {
569
+ error(`Invalid context_window '${val}'. Must be a positive integer (token count).`, ERROR_REASON.USAGE);
570
+ }
571
+ }
554
572
  // Post-planning gap checker (#2493)
555
573
  if (kp === 'workflow.post_planning_gaps') {
556
574
  if (typeof parsedValue !== 'boolean') {
@@ -183,6 +183,46 @@ function extractCanonicalPlanId(filename) {
183
183
  }
184
184
  return base;
185
185
  }
186
+ /**
187
+ * Count summaries that correspond to a real plan (#1988).
188
+ *
189
+ * A summary counts toward phase completion iff it pairs with an existing plan
190
+ * file. This excludes stray non-plan summaries — e.g. `30-FIX-CR02-SUMMARY.md`,
191
+ * `30-GAPCLOSURE-SUMMARY.md` — that inflate the raw `*-SUMMARY.md` count and
192
+ * silently flip a phase to Complete when plans are actually missing summaries.
193
+ *
194
+ * Pairing is layout-agnostic. For each plan, up to three candidate summary
195
+ * filenames are generated and any match suffices:
196
+ * 1. marker swap `PLAN`→`SUMMARY` on the basename — root padded
197
+ * (`30-01-PLAN.md`↔`30-01-SUMMARY.md`), nested (`PLAN-01.md`↔
198
+ * `SUMMARY-01.md`, incl. a `plans/` prefix), and bare (`PLAN.md`↔
199
+ * `SUMMARY.md`);
200
+ * 2. `<stem>-SUMMARY.md` — bare (`PLAN.md`↔`PLAN-SUMMARY.md`) and legacy
201
+ * (`14-PLAN-01.md`↔`14-PLAN-01-SUMMARY.md`);
202
+ * 3. extended `<n>-PLAN-<m>…`→`<n>-<m>-SUMMARY.md`
203
+ * (`3-PLAN-01-setup.md`↔`3-01-SUMMARY.md`).
204
+ * The swap is applied to the basename only so a lowercase `plans/` dir prefix
205
+ * isn't corrupted to `SUMMARYs/…`.
206
+ */
207
+ function countMatchedSummaries(planFiles, summaryFiles) {
208
+ const summarySet = new Set(summaryFiles);
209
+ let matched = 0;
210
+ for (const plan of planFiles) {
211
+ const slashIdx = plan.lastIndexOf('/');
212
+ const dir = slashIdx >= 0 ? plan.slice(0, slashIdx + 1) : '';
213
+ const base = (dir ? plan.slice(dir.length) : plan).replace(/\.md$/i, '');
214
+ const candidates = [
215
+ dir + base.replace(/PLAN/i, 'SUMMARY') + '.md',
216
+ dir + base + '-SUMMARY.md',
217
+ ];
218
+ const extended = base.match(/^(\d+)-PLAN-(\d+)/i);
219
+ if (extended)
220
+ candidates.push(dir + extended[1] + '-' + extended[2] + '-SUMMARY.md');
221
+ if (candidates.some((c) => summarySet.has(c)))
222
+ matched++;
223
+ }
224
+ return matched;
225
+ }
186
226
  module.exports = {
187
227
  toPosixPath,
188
228
  detectSubRepos,
@@ -195,4 +235,5 @@ module.exports = {
195
235
  readSubdirectories,
196
236
  timeAgo,
197
237
  extractCanonicalPlanId,
238
+ countMatchedSummaries,
198
239
  };
@@ -0,0 +1,287 @@
1
+ "use strict";
2
+ /**
3
+ * external-job.cts — scheduler-adapter producer module for the async
4
+ * external-job contract (#1164 / #1105).
5
+ *
6
+ * The CORE loop CONSUMES manifests at `.planning/async-jobs/<job>.json`
7
+ * (#1165 — external_job_waiting half-state); this module is the Capability
8
+ * half that PRODUCES them. SLURM is the first backend; the design stays
9
+ * scheduler-pluggable via the `backend` field (planning-artifacts.md).
10
+ *
11
+ * Pure helpers (state map, build, validate, parsers) take no I/O; the writer
12
+ * takes injected `fs` and `clock` seams so tests drive it without touching disk
13
+ * or wall-clock time (CLAUDE.md clock-seam + injectable-deps conventions).
14
+ *
15
+ * Build: `src/*.cts` -> `gsd-core/bin/lib/*.cjs` (ADR-457 build-at-publish).
16
+ */
17
+ var __importDefault = (this && this.__importDefault) || function (mod) {
18
+ return (mod && mod.__esModule) ? mod : { "default": mod };
19
+ };
20
+ const node_path_1 = __importDefault(require("node:path"));
21
+ const node_fs_1 = __importDefault(require("node:fs"));
22
+ // ─── Closed status enum (stability contract — Hyrum's Law) ────────────────────
23
+ const MANIFEST_VERSION = '1.0';
24
+ const MANIFEST_STATUS = [
25
+ 'submitted',
26
+ 'running',
27
+ 'completed-unverified',
28
+ 'failed',
29
+ 'cancelled',
30
+ 'timeout',
31
+ ];
32
+ const NON_TERMINAL_STATUSES = ['submitted', 'running'];
33
+ const TERMINAL_FAILURE_STATUSES = ['failed', 'cancelled', 'timeout'];
34
+ // ─── SLURM state -> manifest status ───────────────────────────────────────────
35
+ //
36
+ // Source: SLURM job state codes (squeue/sacct State column). Producers for
37
+ // other backends map their own states onto the closed enum above; this table
38
+ // is SLURM-specific and lives behind the `backend: 'slurm'` field.
39
+ const SLURM_STATE_MAP = {
40
+ PENDING: 'submitted',
41
+ CONFIGURING: 'submitted',
42
+ RUNNING: 'running',
43
+ COMPLETING: 'running',
44
+ COMPLETED: 'completed-unverified',
45
+ FAILED: 'failed',
46
+ CANCELLED: 'cancelled',
47
+ TIMEOUT: 'timeout',
48
+ OUT_OF_MEMORY: 'failed',
49
+ BOOT_FAIL: 'failed',
50
+ NODE_FAIL: 'failed',
51
+ PREEMPTED: 'failed',
52
+ };
53
+ /**
54
+ * Map a raw SLURM state string to the closed, scheduler-agnostic manifest
55
+ * status. Case-insensitive; trims whitespace; strips a trailing by-part
56
+ * ("CANCELLED by 1001" -> "CANCELLED"). Returns `null` for any unknown
57
+ * state so the caller can decide whether to surface or fail — never guesses
58
+ * (CLAUDE.md anti-guessing).
59
+ */
60
+ function mapSlurmState(raw) {
61
+ if (typeof raw !== 'string')
62
+ return null;
63
+ const key = raw.trim().toUpperCase();
64
+ const head = key.split(/\s+/)[0];
65
+ if (head && Object.prototype.hasOwnProperty.call(SLURM_STATE_MAP, head)) {
66
+ return SLURM_STATE_MAP[head];
67
+ }
68
+ return null;
69
+ }
70
+ const REQUIRED_FIELDS = [
71
+ 'plan_id',
72
+ 'phase',
73
+ 'job_id',
74
+ 'backend',
75
+ 'submit_command',
76
+ 'status',
77
+ 'expected_artifacts',
78
+ 'verification_command',
79
+ 'resume_command',
80
+ ];
81
+ function assertString(v, key) {
82
+ if (typeof v !== 'string' || v.length === 0) {
83
+ throw new Error(`buildManifest: field "${key}" must be a non-empty string`);
84
+ }
85
+ }
86
+ const STATUS_LIST = MANIFEST_STATUS;
87
+ /**
88
+ * Build a versioned, frozen manifest. Stamps `version` and `submitted_at`
89
+ * (via the injected clock seam) and normalises `terminal_details`:
90
+ * `null` unless the status is a terminal failure AND details were supplied.
91
+ * Throws on missing required fields or an out-of-enum status.
92
+ */
93
+ function buildManifest(input, opts = {}) {
94
+ const inputRecord = input;
95
+ for (const key of REQUIRED_FIELDS) {
96
+ const v = inputRecord[key];
97
+ if (key === 'expected_artifacts') {
98
+ if (!Array.isArray(v) || v.length === 0 || !v.every((x) => typeof x === 'string')) {
99
+ throw new Error('buildManifest: field "expected_artifacts" must be a non-empty string[]');
100
+ }
101
+ continue;
102
+ }
103
+ assertString(v, key);
104
+ }
105
+ if (!STATUS_LIST.includes(input.status)) {
106
+ throw new Error(`buildManifest: field "status" must be one of ${MANIFEST_STATUS.join(', ')}`);
107
+ }
108
+ const clock = opts.clock ?? { nowIso: () => new Date().toISOString() };
109
+ const isTerminalFailure = TERMINAL_FAILURE_STATUSES.includes(input.status);
110
+ const terminal_details = isTerminalFailure && input.terminal_details ? input.terminal_details : null;
111
+ return Object.freeze({
112
+ version: MANIFEST_VERSION,
113
+ job_id: input.job_id,
114
+ plan_id: input.plan_id,
115
+ phase: input.phase,
116
+ backend: input.backend,
117
+ submit_command: input.submit_command,
118
+ status: input.status,
119
+ expected_artifacts: [...input.expected_artifacts],
120
+ verification_command: input.verification_command,
121
+ resume_command: input.resume_command,
122
+ submitted_at: clock.nowIso(),
123
+ terminal_details,
124
+ });
125
+ }
126
+ /**
127
+ * Producer-side schema validator — the mirror of the consumer trust boundary
128
+ * (planning-artifacts.md). Producers MUST emit a manifest this accepts; the
129
+ * core loop re-validates defensively on read.
130
+ */
131
+ function validateManifest(value) {
132
+ if (!value || typeof value !== 'object') {
133
+ return { ok: false, errors: ['manifest must be an object'] };
134
+ }
135
+ const m = value;
136
+ const errors = [];
137
+ if (m.version !== MANIFEST_VERSION)
138
+ errors.push(`version must be "${MANIFEST_VERSION}"`);
139
+ for (const f of ['job_id', 'plan_id', 'phase', 'backend', 'submit_command', 'verification_command', 'resume_command', 'submitted_at']) {
140
+ if (typeof m[f] !== 'string' || m[f].length === 0) {
141
+ errors.push(`field "${f}" must be a non-empty string`);
142
+ }
143
+ }
144
+ if (typeof m.status !== 'string' || !STATUS_LIST.includes(m.status)) {
145
+ errors.push(`status must be one of ${MANIFEST_STATUS.join(', ')}`);
146
+ }
147
+ if (!Array.isArray(m.expected_artifacts) || !m.expected_artifacts.every((x) => typeof x === 'string')) {
148
+ errors.push('expected_artifacts must be a string[]');
149
+ }
150
+ if (m.terminal_details !== null && typeof m.terminal_details !== 'object') {
151
+ errors.push('terminal_details must be null or an object');
152
+ }
153
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
154
+ }
155
+ /**
156
+ * Parse `sbatch --parsable` output. Accepts either a bare job id
157
+ * (`"12345"`) or the `"12345;clustername"` form. Rejects prose like
158
+ * `"Submitted batch job 12345"` (that is the non-parsable default format).
159
+ */
160
+ function parseSbatchParsable(stdout) {
161
+ if (typeof stdout !== 'string')
162
+ return { ok: false, kind: 'non_string', raw: String(stdout) };
163
+ const trimmed = stdout.trim();
164
+ if (!trimmed)
165
+ return { ok: false, kind: 'empty', raw: stdout };
166
+ const head = trimmed.split(';')[0].split(/\s+/)[0];
167
+ if (!/^\d+$/.test(head))
168
+ return { ok: false, kind: 'not_numeric', raw: trimmed };
169
+ return { ok: true, job_id: head };
170
+ }
171
+ /**
172
+ * Parse a single `squeue` line of the form `"<jobid> <state>"`. Returns `null`
173
+ * for a header or any row that does not have at least two tokens.
174
+ */
175
+ function parseSqueueLine(line) {
176
+ if (typeof line !== 'string')
177
+ return null;
178
+ const parts = line.trim().split(/\s+/);
179
+ if (parts.length < 2)
180
+ return null;
181
+ const [job_id, state] = parts;
182
+ if (!/^\d+$/.test(job_id))
183
+ return null;
184
+ return { job_id, state };
185
+ }
186
+ /**
187
+ * Parse a `sacct -P` row given as pre-split columns where index 0 is the job
188
+ * id and index 1 is the state. Returns `null` for malformed rows.
189
+ */
190
+ function parseSacctRow(cols) {
191
+ if (cols.length < 2)
192
+ return null;
193
+ const job_id = cols[0];
194
+ const state = cols[1];
195
+ if (typeof job_id !== 'string' || typeof state !== 'string')
196
+ return null;
197
+ if (!/^\d+$/.test(job_id))
198
+ return null;
199
+ return { job_id, state };
200
+ }
201
+ /**
202
+ * Pure path projection: `.planning/async-jobs/<job_id>.json`.
203
+ */
204
+ function manifestPath(planningDir, jobId) {
205
+ return node_path_1.default.join(planningDir, 'async-jobs', `${jobId}.json`);
206
+ }
207
+ function _isNonTerminal(status) {
208
+ return typeof status === 'string' && NON_TERMINAL_STATUSES.includes(status);
209
+ }
210
+ /**
211
+ * Write a manifest to `.planning/async-jobs/<job_id>.json`.
212
+ *
213
+ * Fail-closed rules (mirror of the consumer contract, planning-artifacts.md):
214
+ * - If any existing manifest in the dir shares `plan_id` but has a different
215
+ * `job_id` AND is non-terminal -> refuse (`duplicate_plan_id`); dispatching
216
+ * again would duplicate the external job.
217
+ * - If the target file exists but is not valid JSON -> refuse
218
+ * (`malformed_existing`); never silently clobber.
219
+ * - Same `job_id` for the same `plan_id` -> allowed (status progression).
220
+ * - A prior job for the same `plan_id` that is already terminal -> allowed
221
+ * (the duplicate guard only protects against re-dispatching live work).
222
+ */
223
+ function writeManifest(manifest, planningDir, opts = {}) {
224
+ const fs = opts.fs ?? node_fs_1.default;
225
+ const dir = node_path_1.default.join(planningDir, 'async-jobs');
226
+ const target = manifestPath(planningDir, manifest.job_id);
227
+ let names;
228
+ try {
229
+ fs.mkdirSync(dir, { recursive: true });
230
+ names = fs.readdirSync(dir);
231
+ }
232
+ catch (e) {
233
+ return { ok: false, kind: 'io_error', message: e.message };
234
+ }
235
+ for (const name of names) {
236
+ if (!name.endsWith('.json'))
237
+ continue;
238
+ const p = node_path_1.default.join(dir, name);
239
+ let raw;
240
+ try {
241
+ raw = String(fs.readFileSync(p));
242
+ }
243
+ catch {
244
+ continue;
245
+ }
246
+ let existing;
247
+ try {
248
+ existing = JSON.parse(raw);
249
+ }
250
+ catch {
251
+ if (p === target) {
252
+ return { ok: false, kind: 'malformed_existing', message: `target manifest ${p} is not valid JSON` };
253
+ }
254
+ continue;
255
+ }
256
+ const samePlan = existing.plan_id === manifest.plan_id;
257
+ const sameJob = existing.job_id === manifest.job_id;
258
+ if (samePlan && !sameJob && _isNonTerminal(existing.status)) {
259
+ return {
260
+ ok: false,
261
+ kind: 'duplicate_plan_id',
262
+ message: `plan_id "${manifest.plan_id}" already has non-terminal job "${String(existing.job_id)}" at ${p}; dispatching again would duplicate the external job`,
263
+ };
264
+ }
265
+ }
266
+ try {
267
+ fs.writeFileSync(target, JSON.stringify(manifest, null, 2) + '\n');
268
+ }
269
+ catch (e) {
270
+ return { ok: false, kind: 'io_error', message: e.message };
271
+ }
272
+ return { ok: true, path: target };
273
+ }
274
+ module.exports = {
275
+ MANIFEST_VERSION,
276
+ MANIFEST_STATUS,
277
+ NON_TERMINAL_STATUSES,
278
+ TERMINAL_FAILURE_STATUSES,
279
+ mapSlurmState,
280
+ buildManifest,
281
+ validateManifest,
282
+ parseSbatchParsable,
283
+ parseSqueueLine,
284
+ parseSacctRow,
285
+ writeManifest,
286
+ manifestPath,
287
+ };