@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/GEMINI.md +25 -23
- package/README.md +3 -3
- package/agents/gsd-advisor-researcher.md +1 -1
- package/agents/gsd-ai-researcher.md +1 -1
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-debug-session-manager.md +1 -1
- package/agents/gsd-debugger.md +1 -1
- package/agents/gsd-domain-researcher.md +1 -1
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +4 -4
- package/agents/gsd-intel-updater.md +1 -1
- package/agents/gsd-mempalace-curator.md +1 -1
- package/agents/gsd-phase-researcher.md +2 -2
- package/agents/gsd-plan-checker.md +1 -1
- package/agents/gsd-planner.md +2 -2
- package/agents/gsd-project-researcher.md +2 -2
- package/agents/gsd-research-synthesizer.md +1 -1
- package/agents/gsd-ui-researcher.md +2 -2
- package/agents/gsd-verifier.md +2 -2
- package/bin/install.js +108 -458
- package/commands/gsd/mempalace-capture.md +1 -1
- package/commands/gsd/mempalace-recall.md +9 -5
- package/commands/gsd/next.md +29 -0
- package/commands/gsd/ns-workflow.md +5 -2
- package/gsd-core/bin/ensure-runtime-build.cjs +246 -0
- package/gsd-core/bin/gsd-tools.cjs +25 -1
- package/gsd-core/bin/lib/capability-registry.cjs +207 -205
- package/gsd-core/bin/lib/check-command-router.cjs +111 -1
- package/gsd-core/bin/lib/clusters.cjs +1 -0
- package/gsd-core/bin/lib/config.cjs +19 -1
- package/gsd-core/bin/lib/core-utils.cjs +41 -0
- package/gsd-core/bin/lib/external-job.cjs +287 -0
- package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +151 -0
- package/gsd-core/bin/lib/graphify.cjs +41 -9
- package/gsd-core/bin/lib/milestone.cjs +7 -1
- package/gsd-core/bin/lib/phase.cjs +14 -2
- package/gsd-core/bin/lib/plan-scan.cjs +9 -1
- package/gsd-core/bin/lib/profile-output.cjs +1 -1
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +21 -19
- package/gsd-core/bin/lib/roadmap.cjs +17 -2
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +18 -13
- package/gsd-core/bin/lib/runtime-name-policy.cjs +8 -10
- package/gsd-core/bin/lib/shell-command-projection.cjs +12 -8
- package/gsd-core/bin/lib/smart-entry.cjs +521 -0
- package/gsd-core/bin/lib/surface.cjs +18 -9
- package/gsd-core/bin/lib/update-context.cjs +0 -5
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
- package/gsd-core/bin/shared/model-catalog.json +0 -5
- package/gsd-core/bin/shared/runtime-aliases.manifest.json +0 -5
- package/gsd-core/references/gates.md +1 -1
- package/gsd-core/references/planning-config.md +1 -1
- package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/gsd-core/workflows/add-backlog.md +1 -1
- package/gsd-core/workflows/add-phase.md +1 -1
- package/gsd-core/workflows/add-tests.md +1 -1
- package/gsd-core/workflows/add-todo.md +1 -1
- package/gsd-core/workflows/ai-integration-phase.md +1 -1
- package/gsd-core/workflows/audit-fix.md +1 -1
- package/gsd-core/workflows/audit-milestone.md +1 -1
- package/gsd-core/workflows/audit-uat.md +1 -1
- package/gsd-core/workflows/autonomous.md +1 -1
- package/gsd-core/workflows/check-todos.md +1 -1
- package/gsd-core/workflows/cleanup.md +1 -1
- package/gsd-core/workflows/code-review-fix.md +1 -1
- package/gsd-core/workflows/code-review.md +1 -1
- package/gsd-core/workflows/complete-milestone.md +1 -1
- package/gsd-core/workflows/debug.md +1 -1
- package/gsd-core/workflows/diagnose-issues.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/auto.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
- package/gsd-core/workflows/discuss-phase.md +2 -2
- package/gsd-core/workflows/do.md +1 -1
- package/gsd-core/workflows/docs-update.md +1 -1
- package/gsd-core/workflows/edit-phase.md +1 -1
- package/gsd-core/workflows/eval-review.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
- package/gsd-core/workflows/execute-phase.md +4 -5
- package/gsd-core/workflows/execute-plan.md +1 -1
- package/gsd-core/workflows/explore.md +1 -1
- package/gsd-core/workflows/extract-learnings.md +1 -1
- package/gsd-core/workflows/forensics.md +1 -1
- package/gsd-core/workflows/graduation.md +1 -1
- package/gsd-core/workflows/health.md +1 -1
- package/gsd-core/workflows/help/modes/full.md +14 -0
- package/gsd-core/workflows/help/modes/topic.md +1 -0
- package/gsd-core/workflows/import.md +1 -1
- package/gsd-core/workflows/ingest-docs.md +1 -1
- package/gsd-core/workflows/insert-phase.md +1 -1
- package/gsd-core/workflows/list-seeds.md +1 -1
- package/gsd-core/workflows/list-workspaces.md +1 -1
- package/gsd-core/workflows/manager.md +1 -1
- package/gsd-core/workflows/map-codebase.md +1 -1
- package/gsd-core/workflows/milestone-summary.md +1 -1
- package/gsd-core/workflows/mvp-phase.md +1 -1
- package/gsd-core/workflows/new-milestone.md +1 -1
- package/gsd-core/workflows/new-project.md +1 -1
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +1 -1
- package/gsd-core/workflows/pause-work.md +1 -1
- package/gsd-core/workflows/plan-milestone-gaps.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +1 -1
- package/gsd-core/workflows/plan-phase.md +9 -2
- package/gsd-core/workflows/plan-review-convergence.md +1 -1
- package/gsd-core/workflows/plant-seed.md +1 -1
- package/gsd-core/workflows/pr-branch.md +1 -1
- package/gsd-core/workflows/profile-user.md +1 -1
- package/gsd-core/workflows/progress.md +1 -1
- package/gsd-core/workflows/quick.md +1 -1
- package/gsd-core/workflows/reapply-patches.md +1 -1
- package/gsd-core/workflows/remove-phase.md +1 -1
- package/gsd-core/workflows/remove-workspace.md +1 -1
- package/gsd-core/workflows/resume-project.md +1 -1
- package/gsd-core/workflows/review.md +43 -7
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/settings-advanced.md +3 -1
- package/gsd-core/workflows/settings-integrations.md +1 -1
- package/gsd-core/workflows/settings.md +1 -1
- package/gsd-core/workflows/ship.md +1 -1
- package/gsd-core/workflows/sketch-wrap-up.md +1 -1
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/smart-entry.md +120 -0
- package/gsd-core/workflows/spec-phase.md +8 -6
- package/gsd-core/workflows/spike-wrap-up.md +1 -1
- package/gsd-core/workflows/spike.md +1 -1
- package/gsd-core/workflows/stats.md +1 -1
- package/gsd-core/workflows/thread.md +1 -1
- package/gsd-core/workflows/transition.md +1 -1
- package/gsd-core/workflows/ui-phase.md +1 -1
- package/gsd-core/workflows/ui-review.md +1 -1
- package/gsd-core/workflows/ultraplan-phase.md +1 -1
- package/gsd-core/workflows/update.md +3 -6
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-phase.md +1 -1
- package/gsd-core/workflows/verify-work.md +62 -4
- package/package.json +3 -5
- package/scripts/ci-test-scope.cjs +19 -1
- package/scripts/generate-package-identity.cjs +27 -2
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -148
- package/scripts/lint-regression-test-names.allowlist.json +1 -273
- package/scripts/lint-test-file-count.allowlist.json +7 -91
- package/scripts/research-profiles.cjs +6 -6
- package/scripts/run-tests.cjs +16 -3
- package/scripts/slurm-adapter.cjs +269 -0
- package/scripts/sync-manifest-versions.cjs +3 -2
- package/skills/gsd-mempalace-capture/SKILL.md +1 -1
- package/skills/gsd-mempalace-recall/SKILL.md +9 -5
- package/skills/gsd-next/SKILL.md +30 -0
- package/skills/gsd-ns-workflow/SKILL.md +4 -1
- 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
|
};
|
|
@@ -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
|
-
|
|
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
|
+
};
|