@opengsd/gsd-core 1.7.0-rc.2 → 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 (155) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/GEMINI.md +25 -23
  4. package/README.md +3 -3
  5. package/agents/gsd-advisor-researcher.md +1 -1
  6. package/agents/gsd-ai-researcher.md +1 -1
  7. package/agents/gsd-code-fixer.md +1 -1
  8. package/agents/gsd-debug-session-manager.md +1 -1
  9. package/agents/gsd-debugger.md +1 -1
  10. package/agents/gsd-domain-researcher.md +1 -1
  11. package/agents/gsd-eval-auditor.md +1 -1
  12. package/agents/gsd-executor.md +4 -4
  13. package/agents/gsd-intel-updater.md +1 -1
  14. package/agents/gsd-mempalace-curator.md +1 -1
  15. package/agents/gsd-phase-researcher.md +2 -2
  16. package/agents/gsd-plan-checker.md +1 -1
  17. package/agents/gsd-planner.md +2 -2
  18. package/agents/gsd-project-researcher.md +2 -2
  19. package/agents/gsd-research-synthesizer.md +1 -1
  20. package/agents/gsd-ui-researcher.md +2 -2
  21. package/agents/gsd-verifier.md +2 -2
  22. package/bin/install.js +108 -458
  23. package/commands/gsd/mempalace-capture.md +1 -1
  24. package/commands/gsd/mempalace-recall.md +9 -5
  25. package/commands/gsd/next.md +29 -0
  26. package/commands/gsd/ns-workflow.md +5 -2
  27. package/gsd-core/bin/ensure-runtime-build.cjs +246 -0
  28. package/gsd-core/bin/gsd-tools.cjs +25 -1
  29. package/gsd-core/bin/lib/capability-registry.cjs +207 -205
  30. package/gsd-core/bin/lib/check-command-router.cjs +111 -1
  31. package/gsd-core/bin/lib/clusters.cjs +1 -0
  32. package/gsd-core/bin/lib/config.cjs +19 -1
  33. package/gsd-core/bin/lib/core-utils.cjs +41 -0
  34. package/gsd-core/bin/lib/external-job.cjs +287 -0
  35. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +151 -0
  36. package/gsd-core/bin/lib/graphify.cjs +41 -9
  37. package/gsd-core/bin/lib/milestone.cjs +7 -1
  38. package/gsd-core/bin/lib/phase.cjs +14 -2
  39. package/gsd-core/bin/lib/plan-scan.cjs +9 -1
  40. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  41. package/gsd-core/bin/lib/prohibition-enforcement.cjs +21 -19
  42. package/gsd-core/bin/lib/roadmap.cjs +17 -2
  43. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +18 -13
  44. package/gsd-core/bin/lib/runtime-name-policy.cjs +8 -10
  45. package/gsd-core/bin/lib/shell-command-projection.cjs +12 -8
  46. package/gsd-core/bin/lib/smart-entry.cjs +521 -0
  47. package/gsd-core/bin/lib/surface.cjs +18 -9
  48. package/gsd-core/bin/lib/update-context.cjs +0 -5
  49. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  50. package/gsd-core/bin/shared/model-catalog.json +0 -5
  51. package/gsd-core/bin/shared/runtime-aliases.manifest.json +0 -5
  52. package/gsd-core/references/gates.md +1 -1
  53. package/gsd-core/references/planning-config.md +1 -1
  54. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  55. package/gsd-core/workflows/add-backlog.md +1 -1
  56. package/gsd-core/workflows/add-phase.md +1 -1
  57. package/gsd-core/workflows/add-tests.md +1 -1
  58. package/gsd-core/workflows/add-todo.md +1 -1
  59. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  60. package/gsd-core/workflows/audit-fix.md +1 -1
  61. package/gsd-core/workflows/audit-milestone.md +1 -1
  62. package/gsd-core/workflows/audit-uat.md +1 -1
  63. package/gsd-core/workflows/autonomous.md +1 -1
  64. package/gsd-core/workflows/check-todos.md +1 -1
  65. package/gsd-core/workflows/cleanup.md +1 -1
  66. package/gsd-core/workflows/code-review-fix.md +1 -1
  67. package/gsd-core/workflows/code-review.md +1 -1
  68. package/gsd-core/workflows/complete-milestone.md +1 -1
  69. package/gsd-core/workflows/debug.md +1 -1
  70. package/gsd-core/workflows/diagnose-issues.md +1 -1
  71. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  72. package/gsd-core/workflows/discuss-phase/modes/auto.md +1 -1
  73. package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
  74. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  75. package/gsd-core/workflows/discuss-phase.md +2 -2
  76. package/gsd-core/workflows/do.md +1 -1
  77. package/gsd-core/workflows/docs-update.md +1 -1
  78. package/gsd-core/workflows/edit-phase.md +1 -1
  79. package/gsd-core/workflows/eval-review.md +1 -1
  80. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  81. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
  82. package/gsd-core/workflows/execute-phase.md +4 -5
  83. package/gsd-core/workflows/execute-plan.md +1 -1
  84. package/gsd-core/workflows/explore.md +1 -1
  85. package/gsd-core/workflows/extract-learnings.md +1 -1
  86. package/gsd-core/workflows/forensics.md +1 -1
  87. package/gsd-core/workflows/graduation.md +1 -1
  88. package/gsd-core/workflows/health.md +1 -1
  89. package/gsd-core/workflows/help/modes/full.md +14 -0
  90. package/gsd-core/workflows/help/modes/topic.md +1 -0
  91. package/gsd-core/workflows/import.md +1 -1
  92. package/gsd-core/workflows/ingest-docs.md +1 -1
  93. package/gsd-core/workflows/insert-phase.md +1 -1
  94. package/gsd-core/workflows/list-seeds.md +1 -1
  95. package/gsd-core/workflows/list-workspaces.md +1 -1
  96. package/gsd-core/workflows/manager.md +1 -1
  97. package/gsd-core/workflows/map-codebase.md +1 -1
  98. package/gsd-core/workflows/milestone-summary.md +1 -1
  99. package/gsd-core/workflows/mvp-phase.md +1 -1
  100. package/gsd-core/workflows/new-milestone.md +1 -1
  101. package/gsd-core/workflows/new-project.md +1 -1
  102. package/gsd-core/workflows/new-workspace.md +1 -1
  103. package/gsd-core/workflows/next.md +1 -1
  104. package/gsd-core/workflows/pause-work.md +1 -1
  105. package/gsd-core/workflows/plan-milestone-gaps.md +1 -1
  106. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +1 -1
  107. package/gsd-core/workflows/plan-phase.md +9 -2
  108. package/gsd-core/workflows/plan-review-convergence.md +1 -1
  109. package/gsd-core/workflows/plant-seed.md +1 -1
  110. package/gsd-core/workflows/pr-branch.md +1 -1
  111. package/gsd-core/workflows/profile-user.md +1 -1
  112. package/gsd-core/workflows/progress.md +1 -1
  113. package/gsd-core/workflows/quick.md +1 -1
  114. package/gsd-core/workflows/reapply-patches.md +1 -1
  115. package/gsd-core/workflows/remove-phase.md +1 -1
  116. package/gsd-core/workflows/remove-workspace.md +1 -1
  117. package/gsd-core/workflows/resume-project.md +1 -1
  118. package/gsd-core/workflows/review.md +43 -7
  119. package/gsd-core/workflows/scan.md +1 -1
  120. package/gsd-core/workflows/secure-phase.md +1 -1
  121. package/gsd-core/workflows/settings-advanced.md +3 -1
  122. package/gsd-core/workflows/settings-integrations.md +1 -1
  123. package/gsd-core/workflows/settings.md +1 -1
  124. package/gsd-core/workflows/ship.md +1 -1
  125. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  126. package/gsd-core/workflows/sketch.md +1 -1
  127. package/gsd-core/workflows/smart-entry.md +120 -0
  128. package/gsd-core/workflows/spec-phase.md +8 -6
  129. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  130. package/gsd-core/workflows/spike.md +1 -1
  131. package/gsd-core/workflows/stats.md +1 -1
  132. package/gsd-core/workflows/thread.md +1 -1
  133. package/gsd-core/workflows/transition.md +1 -1
  134. package/gsd-core/workflows/ui-phase.md +1 -1
  135. package/gsd-core/workflows/ui-review.md +1 -1
  136. package/gsd-core/workflows/ultraplan-phase.md +1 -1
  137. package/gsd-core/workflows/update.md +3 -6
  138. package/gsd-core/workflows/validate-phase.md +1 -1
  139. package/gsd-core/workflows/verify-phase.md +1 -1
  140. package/gsd-core/workflows/verify-work.md +62 -4
  141. package/package.json +3 -5
  142. package/scripts/ci-test-scope.cjs +19 -1
  143. package/scripts/generate-package-identity.cjs +27 -2
  144. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -148
  145. package/scripts/lint-regression-test-names.allowlist.json +1 -273
  146. package/scripts/lint-test-file-count.allowlist.json +7 -91
  147. package/scripts/research-profiles.cjs +6 -6
  148. package/scripts/run-tests.cjs +16 -3
  149. package/scripts/slurm-adapter.cjs +269 -0
  150. package/scripts/sync-manifest-versions.cjs +3 -2
  151. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  152. package/skills/gsd-mempalace-recall/SKILL.md +9 -5
  153. package/skills/gsd-next/SKILL.md +30 -0
  154. package/skills/gsd-ns-workflow/SKILL.md +4 -1
  155. package/gemini-extension.json +0 -6
@@ -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',
@@ -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
+ };