@opengsd/gsd-core 1.7.0-rc.4 → 1.7.0-rc.5
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/.opencode/plugins/gsd-core.js +20 -0
- package/agents/gsd-doc-classifier.md +105 -0
- package/agents/gsd-doc-synthesizer.md +61 -0
- package/agents/gsd-ui-checker.md +28 -0
- package/bin/install.js +624 -456
- package/gsd-core/bin/gsd-tools.cjs +40 -1
- package/gsd-core/bin/lib/api-coverage.cjs +466 -0
- package/gsd-core/bin/lib/audit.cjs +6 -3
- package/gsd-core/bin/lib/capability-loader.cjs +11 -9
- package/gsd-core/bin/lib/capability-registry.cjs +325 -64
- package/gsd-core/bin/lib/capability-validator.cjs +1 -1
- package/gsd-core/bin/lib/capability-writer.cjs +10 -1
- package/gsd-core/bin/lib/check-command-router.cjs +242 -3
- package/gsd-core/bin/lib/commands.cjs +7 -5
- package/gsd-core/bin/lib/config-loader.cjs +1 -0
- package/gsd-core/bin/lib/config.cjs +96 -0
- package/gsd-core/bin/lib/core-utils.cjs +4 -1
- package/gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs +234 -0
- package/gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs +145 -0
- package/gsd-core/bin/lib/host-integration.cjs +16 -0
- package/gsd-core/bin/lib/init.cjs +74 -30
- package/gsd-core/bin/lib/install-effort-resolver.cjs +213 -0
- package/gsd-core/bin/lib/install-engine.cjs +151 -11
- package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +68 -17
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +50 -11
- package/gsd-core/bin/lib/milestone.cjs +3 -3
- package/gsd-core/bin/lib/normalize-test-command.cjs +187 -0
- package/gsd-core/bin/lib/phase-id.cjs +132 -3
- package/gsd-core/bin/lib/phase.cjs +78 -16
- package/gsd-core/bin/lib/planning-workspace.cjs +17 -0
- package/gsd-core/bin/lib/roadmap-command-router.cjs +5 -4
- package/gsd-core/bin/lib/roadmap-parser.cjs +21 -30
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +9 -9
- package/gsd-core/bin/lib/roadmap.cjs +42 -56
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +13 -6
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +2 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +17 -10
- package/gsd-core/bin/lib/runtime-homes.cjs +8 -0
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +33 -25
- package/gsd-core/bin/lib/spec-section.cjs +111 -0
- package/gsd-core/bin/lib/state-transition.cjs +1 -1
- package/gsd-core/bin/lib/state.cjs +24 -24
- package/gsd-core/bin/lib/surface.cjs +30 -0
- package/gsd-core/bin/lib/uat.cjs +4 -1
- package/gsd-core/bin/lib/validate.cjs +15 -6
- package/gsd-core/bin/lib/verify.cjs +33 -37
- package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
- package/gsd-core/bin/shared/model-catalog.json +6 -6
- package/gsd-core/references/api-coverage.md +104 -0
- package/gsd-core/references/model-profiles.md +2 -2
- package/gsd-core/references/planning-config.md +2 -0
- package/gsd-core/references/specless-probe-fallback.md +172 -0
- package/gsd-core/templates/config.json +2 -1
- package/gsd-core/workflows/audit-fix.md +9 -1
- package/gsd-core/workflows/code-review-fix.md +7 -3
- package/gsd-core/workflows/code-review.md +4 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +4 -1
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +8 -4
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +42 -0
- package/gsd-core/workflows/execute-phase.md +1 -25
- package/gsd-core/workflows/plan-phase.md +31 -2
- package/gsd-core/workflows/quick.md +2 -2
- package/gsd-core/workflows/review.md +59 -13
- package/gsd-core/workflows/settings-advanced.md +5 -5
- package/gsd-core/workflows/settings.md +2 -2
- package/gsd-core/workflows/verify-phase.md +3 -2
- package/gsd-core/workflows/verify-work.md +38 -0
- package/hooks/dist/gsd-cursor-pre-tool.js +76 -0
- package/hooks/dist/gsd-cursor-stop.js +48 -0
- package/hooks/dist/gsd-cursor-subagent-start.js +50 -0
- package/hooks/dist/gsd-cursor-subagent-stop.js +40 -0
- package/hooks/dist/managed-hooks-registry.cjs +4 -0
- package/hooks/gsd-cursor-pre-tool.js +76 -0
- package/hooks/gsd-cursor-stop.js +48 -0
- package/hooks/gsd-cursor-subagent-start.js +50 -0
- package/hooks/gsd-cursor-subagent-stop.js +40 -0
- package/hooks/managed-hooks-registry.cjs +4 -0
- package/package.json +2 -1
- package/scripts/build-hooks.js +5 -1
- package/scripts/lint-phase-id-drift.cjs +150 -0
- package/scripts/run-tests.cjs +21 -1
|
@@ -709,7 +709,7 @@ const VALID_HOOK_EVENTS = new Set(['claude', 'gemini']);
|
|
|
709
709
|
// DISTINCT from hookEvents (managed-hook dialect): extensionEvents describes the
|
|
710
710
|
// plugin-owned event subset imperative hosts expose (opencode / pi); 'none' = the
|
|
711
711
|
// host exposes no extension surface (engine owns the bus, e.g. VS Code).
|
|
712
|
-
const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'none']);
|
|
712
|
+
const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'hermes', 'none']);
|
|
713
713
|
const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']);
|
|
714
714
|
const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']);
|
|
715
715
|
const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']);
|
|
@@ -77,8 +77,17 @@ function setCapabilityState(cwd, runtimeConfigDir, desired, opts) {
|
|
|
77
77
|
const before = resolveCapabilityRuntimeState(cwd, runtimeConfigDir);
|
|
78
78
|
const resolvedConfigDir = before.runtimeConfigDir;
|
|
79
79
|
// ── Load registry ─────────────────────────────────────────────────────────
|
|
80
|
+
// Issue #2045 (DEFECT 2): validate against the COMPOSED overlay-aware registry
|
|
81
|
+
// (first-party ∪ accepted overlays), mirroring capability-state.cts:547-551.
|
|
82
|
+
// The frozen capability-registry.cjs only knows first-party ids, so a third-
|
|
83
|
+
// party cap failed the membership check below → "unknown capability" even
|
|
84
|
+
// though resolveCapabilityRuntimeState (the `before` snapshot, line 150) already
|
|
85
|
+
// knew about it. loadRegistry is non-throwing and first-party-wins, so a
|
|
86
|
+
// malformed overlay is skipped (never crashes the writer); a truly-unknown id
|
|
87
|
+
// is STILL rejected because it is absent from the composed capabilities map.
|
|
80
88
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
81
|
-
const
|
|
89
|
+
const { loadRegistry } = require('./capability-loader.cjs');
|
|
90
|
+
const registry = loadRegistry({ includeInstalled: true, cwd, gsdHome: process.env['GSD_HOME'] });
|
|
82
91
|
const capabilitiesMap = (registry['capabilities'] && typeof registry['capabilities'] === 'object' && !Array.isArray(registry['capabilities'])
|
|
83
92
|
? registry['capabilities']
|
|
84
93
|
: {});
|
|
@@ -37,6 +37,9 @@ const prohibition_enforcement_cjs_1 = require("./prohibition-enforcement.cjs");
|
|
|
37
37
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
38
38
|
const gatePredicateEval = require("./gate-predicate-evaluator.cjs");
|
|
39
39
|
const { evaluatePredicate } = gatePredicateEval;
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
41
|
+
const apiCoverageMod = require("./api-coverage.cjs");
|
|
42
|
+
const { detectApiIntegration, validateCoverageMatrix } = apiCoverageMod;
|
|
40
43
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
41
44
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
42
45
|
function normalizePhrase(text) {
|
|
@@ -131,10 +134,13 @@ function loadPlanContents(phaseDir) {
|
|
|
131
134
|
}
|
|
132
135
|
}
|
|
133
136
|
const DESIGNATED_HEADINGS_RE = /^#{1,6}\s+(?:must[_ ]haves?|truths?|tasks?|objective)\b/i;
|
|
134
|
-
const XML_DECISION_TAGS_RE = /<(?:objective|tasks?|action)(?:\s[^>]
|
|
137
|
+
const XML_DECISION_TAGS_RE = /<(?:objective|tasks?|action)(?:\s[^>]{0,1000})?>((?:(?!<(?:objective|tasks?|action)[\s>])[\s\S])*?)<\/(?:objective|tasks?|action)>/gi;
|
|
135
138
|
function stripCommentsAndFences(text) {
|
|
136
139
|
// HTML-comment stripping stays caller-side (the seam does not strip HTML comments).
|
|
137
|
-
|
|
140
|
+
// Stop-at-next-open body (ReDoS-safe, #2128); an UNCLOSED `<!--` does not match,
|
|
141
|
+
// so downstream tags are preserved (unlike a `(?:-->|$)` fallback, which would
|
|
142
|
+
// wipe to EOF and fail-close the decision-coverage gate).
|
|
143
|
+
const htmlStripped = text.replace(/<!--(?:(?!<!--)[\s\S])*?-->/g, ' ');
|
|
138
144
|
// Fenced-code stripping: delegate to the canonical CommonMark-correct seam.
|
|
139
145
|
// replaces the prior independent regex copy (```` ``` ``` ```` + `~~~ ~~~`).
|
|
140
146
|
return (0, markdown_sectionizer_cjs_1.stripFencedCode)(htmlStripped).text;
|
|
@@ -876,6 +882,233 @@ function cmdCheckPredicate(projectDir, args, raw) {
|
|
|
876
882
|
}
|
|
877
883
|
output(result, raw, undefined);
|
|
878
884
|
}
|
|
885
|
+
// ─── api-coverage-verify-pre ──────────────────────────────────────────────────
|
|
886
|
+
/**
|
|
887
|
+
* api-coverage.verify-pre: BLOCKING seal-time gate for the ai-integration
|
|
888
|
+
* capability (#1562). Enforces "Full API Coverage by Default — Opt Out, Never
|
|
889
|
+
* Opt In." A phase that integrates an external API/SDK/service may not seal
|
|
890
|
+
* until a COVERAGE.md matrix enumerates the surface and every non-integrated
|
|
891
|
+
* capability is an explicit, reasoned opt-out.
|
|
892
|
+
*
|
|
893
|
+
* Contract (two touch points composed into one check):
|
|
894
|
+
* 1. If COVERAGE.md exists in the phase dir → validate it (acceptance #2).
|
|
895
|
+
* Block on any validation error (empty matrix, OPT-OUT without reason,
|
|
896
|
+
* duplicate/empty capability).
|
|
897
|
+
* 2. If COVERAGE.md is absent → run detectApiIntegration over the phase scope
|
|
898
|
+
* (PLAN.md body, then ROADMAP phase section as fallback). If a strong
|
|
899
|
+
* external-API-integration signal is detected → BLOCK ("integration
|
|
900
|
+
* detected without coverage matrix"). If no signal → PASS (treat as a
|
|
901
|
+
* non-API phase; acceptance #4 — low false positives).
|
|
902
|
+
*
|
|
903
|
+
* The detector is the FALLBACK for the "nobody decided / forgot the matrix"
|
|
904
|
+
* case; the primary path is the plan:pre contribution prompting COVERAGE.md.
|
|
905
|
+
*
|
|
906
|
+
* Args: check api-coverage.verify-pre <phase-dir>
|
|
907
|
+
* Emits the uniform gate contract: { block, passed, message, ...details }.
|
|
908
|
+
*/
|
|
909
|
+
function cmdApiCoverageVerifyPre(projectDir, args, raw) {
|
|
910
|
+
const phaseArg = typeof args[2] === 'string' ? args[2] : '';
|
|
911
|
+
if (!phaseArg) {
|
|
912
|
+
error('api-coverage.verify-pre requires a phase argument: check api-coverage.verify-pre <phase-dir-or-token>', ERROR_REASON.SDK_MISSING_ARG);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
const pDir = planningDir(projectDir);
|
|
916
|
+
const phasesRoot = node_path_1.default.join(pDir, 'phases');
|
|
917
|
+
// SECURITY (path traversal): the phase argument is taken ONLY as a phase
|
|
918
|
+
// token — its basename — and resolved by findPhaseInternal strictly under
|
|
919
|
+
// .planning/phases/ (or a milestone archive). The raw arg is never used as a
|
|
920
|
+
// path, so `..`, absolute paths, and arbitrary directories cannot reach a
|
|
921
|
+
// file read. Mirrors cmdVerifySchemaDrift's token-match approach.
|
|
922
|
+
let token = phaseArg.replace(/\\/g, '/').split('/').filter(Boolean).pop() || '';
|
|
923
|
+
// A token like ".." or "." carries no phase identity → unresolvable.
|
|
924
|
+
if (token === '.' || token === '..')
|
|
925
|
+
token = '';
|
|
926
|
+
// Not a GSD project (no phases tree at all) → fail-open: nothing to gate.
|
|
927
|
+
if (!node_fs_1.default.existsSync(phasesRoot)) {
|
|
928
|
+
output({
|
|
929
|
+
block: false,
|
|
930
|
+
passed: true,
|
|
931
|
+
coverage_present: false,
|
|
932
|
+
detected: false,
|
|
933
|
+
message: 'api-coverage: no .planning/phases directory; gate skipped (not a GSD project layout)',
|
|
934
|
+
}, raw, undefined);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
// Resolve the phase dir under the contained phases root.
|
|
938
|
+
let resolvedDir = null;
|
|
939
|
+
let phaseNumber = '';
|
|
940
|
+
if (token) {
|
|
941
|
+
const found = findPhaseInternal(projectDir, token);
|
|
942
|
+
if (found && found.directory) {
|
|
943
|
+
resolvedDir = found.directory;
|
|
944
|
+
phaseNumber = found.phase_number || '';
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
if (!resolvedDir) {
|
|
948
|
+
// The phases tree EXISTS but THIS phase could not be resolved. For a
|
|
949
|
+
// BLOCKING gate, fail-closed: a missing phase dir must not silently bypass
|
|
950
|
+
// the coverage requirement. (Distinguished from "no .planning at all"
|
|
951
|
+
// above, which is a genuine non-GSD-project → pass.)
|
|
952
|
+
output({
|
|
953
|
+
block: true,
|
|
954
|
+
passed: false,
|
|
955
|
+
coverage_present: false,
|
|
956
|
+
detected: false,
|
|
957
|
+
phase_lookup_failed: true,
|
|
958
|
+
message: `api-coverage: could not resolve phase "${phaseArg}" under .planning/phases/. ` +
|
|
959
|
+
'Resolve the phase directory (or produce COVERAGE.md) before sealing.',
|
|
960
|
+
}, raw, undefined);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
// Defense-in-depth: the resolved dir must be inside the phases root (or a
|
|
964
|
+
// milestone archive under .planning/milestones).
|
|
965
|
+
const milestonesRoot = node_path_1.default.join(pDir, 'milestones');
|
|
966
|
+
if (!isInsideRoot(resolvedDir, phasesRoot) && !isInsideRoot(resolvedDir, milestonesRoot)) {
|
|
967
|
+
output({
|
|
968
|
+
block: true,
|
|
969
|
+
passed: false,
|
|
970
|
+
coverage_present: false,
|
|
971
|
+
detected: false,
|
|
972
|
+
message: 'api-coverage: resolved phase dir escapes .planning/ — refusing to evaluate',
|
|
973
|
+
}, raw, undefined);
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
// (1) locate COVERAGE.md — prefer the exact name, then a single *-COVERAGE.md.
|
|
977
|
+
let coverageFile = '';
|
|
978
|
+
let suffixed = [];
|
|
979
|
+
try {
|
|
980
|
+
const entries = node_fs_1.default.readdirSync(resolvedDir, { withFileTypes: true });
|
|
981
|
+
const files = entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
982
|
+
const exact = files.find((f) => /^COVERAGE\.md$/i.test(f));
|
|
983
|
+
if (exact) {
|
|
984
|
+
coverageFile = exact;
|
|
985
|
+
}
|
|
986
|
+
else {
|
|
987
|
+
suffixed = files.filter((f) => /-COVERAGE\.md$/i.test(f)).sort();
|
|
988
|
+
if (suffixed.length === 1)
|
|
989
|
+
coverageFile = suffixed[0];
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
catch {
|
|
993
|
+
// readdir failure → treat as no matrix readable; fall through to detection.
|
|
994
|
+
}
|
|
995
|
+
if (coverageFile) {
|
|
996
|
+
let matrixText;
|
|
997
|
+
try {
|
|
998
|
+
matrixText = node_fs_1.default.readFileSync(node_path_1.default.join(resolvedDir, coverageFile), 'utf8');
|
|
999
|
+
}
|
|
1000
|
+
catch {
|
|
1001
|
+
// COVERAGE.md exists but is unreadable (EACCES/EIO/encoding). Fail-closed
|
|
1002
|
+
// with a useful message rather than a raw throw.
|
|
1003
|
+
output({
|
|
1004
|
+
block: true,
|
|
1005
|
+
passed: false,
|
|
1006
|
+
coverage_present: true,
|
|
1007
|
+
message: `api-coverage: COVERAGE.md exists but is unreadable — fix file permissions/encoding before sealing`,
|
|
1008
|
+
}, raw, undefined);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
const v = validateCoverageMatrix(matrixText);
|
|
1012
|
+
if (v.valid) {
|
|
1013
|
+
output({
|
|
1014
|
+
block: false,
|
|
1015
|
+
passed: true,
|
|
1016
|
+
coverage_present: true,
|
|
1017
|
+
matrix: coverageFile,
|
|
1018
|
+
counts: v.counts,
|
|
1019
|
+
message: `api-coverage: matrix present (${v.counts.surface} capabilities, ${v.counts.optout} opt-out)`,
|
|
1020
|
+
}, raw, undefined);
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
// Fixed-template message (no raw cell content echoed into the LLM-facing
|
|
1024
|
+
// message). The structured `errors` array is safe (row-indexed, no cell
|
|
1025
|
+
// values) and travels as data for tooling that wants detail.
|
|
1026
|
+
output({
|
|
1027
|
+
block: true,
|
|
1028
|
+
passed: false,
|
|
1029
|
+
coverage_present: true,
|
|
1030
|
+
matrix: coverageFile,
|
|
1031
|
+
error_count: v.errors.length,
|
|
1032
|
+
errors: v.errors,
|
|
1033
|
+
message: `api-coverage: COVERAGE.md has ${v.errors.length} problem(s) — fix the matrix (every capability INTEGRATE or OPT-OUT with a reason) before sealing`,
|
|
1034
|
+
}, raw, undefined);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
if (suffixed.length > 1) {
|
|
1038
|
+
output({
|
|
1039
|
+
block: true,
|
|
1040
|
+
passed: false,
|
|
1041
|
+
coverage_present: false,
|
|
1042
|
+
message: `api-coverage: multiple *-COVERAGE.md files found (${suffixed.length}) — consolidate into one COVERAGE.md before sealing`,
|
|
1043
|
+
}, raw, undefined);
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
// (2) no matrix — detect whether this phase integrates an external API.
|
|
1047
|
+
const scopeText = readPhaseScope(projectDir, resolvedDir, phaseNumber);
|
|
1048
|
+
const detection = detectApiIntegration(scopeText);
|
|
1049
|
+
if (detection.detected) {
|
|
1050
|
+
// Surface only verb/noun (typed, bounded) — NOT raw prose snippets — so the
|
|
1051
|
+
// gate output cannot relay injected PLAN.md instructions to the orchestrator.
|
|
1052
|
+
const signals = detection.signals.map((s) => ({ verb: s.verb, noun: s.noun }));
|
|
1053
|
+
output({
|
|
1054
|
+
block: true,
|
|
1055
|
+
passed: false,
|
|
1056
|
+
coverage_present: false,
|
|
1057
|
+
detected: true,
|
|
1058
|
+
signals,
|
|
1059
|
+
message: 'api-coverage: external-API integration detected without a coverage matrix. ' +
|
|
1060
|
+
'Produce COVERAGE.md enumerating the API surface (every capability INTEGRATE or ' +
|
|
1061
|
+
'OPT-OUT with a reason) before sealing. Full coverage is the default.',
|
|
1062
|
+
}, raw, undefined);
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
output({
|
|
1066
|
+
block: false,
|
|
1067
|
+
passed: true,
|
|
1068
|
+
coverage_present: false,
|
|
1069
|
+
detected: false,
|
|
1070
|
+
message: 'api-coverage: no external-API integration detected; coverage matrix not required',
|
|
1071
|
+
}, raw, undefined);
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Read the phase-scope text used for API-integration detection. Uses the
|
|
1075
|
+
* resolved plan files (PLAN.md bodies — the planner's own words about what the
|
|
1076
|
+
* phase does) and, as a fallback, ONLY THIS PHASE'S ROADMAP section (not the
|
|
1077
|
+
* whole roadmap, which would cross-contaminate sibling phases). Strips nothing
|
|
1078
|
+
* here — detectApiIntegration strips fenced code itself.
|
|
1079
|
+
*/
|
|
1080
|
+
function readPhaseScope(projectDir, phaseDir, phaseNumber) {
|
|
1081
|
+
const chunks = [];
|
|
1082
|
+
try {
|
|
1083
|
+
const entries = node_fs_1.default.readdirSync(phaseDir, { withFileTypes: true });
|
|
1084
|
+
const plans = entries
|
|
1085
|
+
.filter((e) => e.isFile() && /-PLAN\.md$/i.test(e.name))
|
|
1086
|
+
.map((e) => e.name)
|
|
1087
|
+
.sort();
|
|
1088
|
+
for (const p of plans) {
|
|
1089
|
+
chunks.push(node_fs_1.default.readFileSync(node_path_1.default.join(phaseDir, p), 'utf8'));
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
catch {
|
|
1093
|
+
// ignore — fall through to roadmap
|
|
1094
|
+
}
|
|
1095
|
+
if (chunks.join('').trim().length > 0)
|
|
1096
|
+
return chunks.join('\n\n');
|
|
1097
|
+
// Fallback: ONLY this phase's ROADMAP section (not the whole file, which
|
|
1098
|
+
// would pollute detection with sibling-phase prose). Best-effort; absence or
|
|
1099
|
+
// an unresolvable section is non-fatal (detector returns not-detected).
|
|
1100
|
+
if (phaseNumber) {
|
|
1101
|
+
try {
|
|
1102
|
+
const section = getRoadmapPhaseWithFallback(projectDir, phaseNumber);
|
|
1103
|
+
if (section)
|
|
1104
|
+
return section;
|
|
1105
|
+
}
|
|
1106
|
+
catch {
|
|
1107
|
+
// ignore
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
return '';
|
|
1111
|
+
}
|
|
879
1112
|
function routeCheckCommand({ args, cwd, raw }) {
|
|
880
1113
|
// Normalize dots to hyphens in the subcommand so both forms are accepted.
|
|
881
1114
|
// This makes `check.query = "ui.plan-gate"` (dotted form in capability.json gates)
|
|
@@ -905,6 +1138,12 @@ function routeCheckCommand({ args, cwd, raw }) {
|
|
|
905
1138
|
cmdGapAnalysisPlanPost(cwd, args, raw);
|
|
906
1139
|
return;
|
|
907
1140
|
}
|
|
1141
|
+
if (subcommand === 'api-coverage-verify-pre') {
|
|
1142
|
+
// ai-integration capability blocking gate at verify:pre (#1562). Dot-to-
|
|
1143
|
+
// hyphen normalization means query "api-coverage.verify-pre" routes here.
|
|
1144
|
+
cmdApiCoverageVerifyPre(cwd, args, raw);
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
908
1147
|
if (subcommand === 'tdd-review-checkpoint') {
|
|
909
1148
|
cmdTddReviewCheckpoint(cwd, args, raw);
|
|
910
1149
|
return;
|
|
@@ -945,7 +1184,7 @@ function routeCheckCommand({ args, cwd, raw }) {
|
|
|
945
1184
|
(0, prohibition_enforcement_cjs_1.routeProhibitionEnforcement)(args, raw);
|
|
946
1185
|
return;
|
|
947
1186
|
}
|
|
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);
|
|
1187
|
+
error('Unknown check subcommand. Available: api-coverage-verify-pre, 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);
|
|
949
1188
|
}
|
|
950
1189
|
module.exports = {
|
|
951
1190
|
routeCheckCommand,
|
|
@@ -471,9 +471,11 @@ function cmdEffortSync(cwd, raw, opts) {
|
|
|
471
471
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
|
|
472
472
|
const { getGlobalConfigDir } = require('./runtime-homes.cjs');
|
|
473
473
|
// Use install-time resolvers: they merge ~/.gsd/defaults.json with project config,
|
|
474
|
-
// matching the exact logic used when agents were originally installed.
|
|
474
|
+
// matching the exact logic used when agents were originally installed. #2071: these
|
|
475
|
+
// live in the shipped sibling install-effort-resolver.cjs (extracted from the
|
|
476
|
+
// package-root bin/install.js, which the installer never copies into a runtime home).
|
|
475
477
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
|
|
476
|
-
const { readGsdEffectiveEffortConfig, resolveInstallTimeEffort } = require('
|
|
478
|
+
const { readGsdEffectiveEffortConfig, resolveInstallTimeEffort } = require('./install-effort-resolver.cjs');
|
|
477
479
|
const effortCfg = readGsdEffectiveEffortConfig(cwd);
|
|
478
480
|
const agentsDir = node_path_1.default.join(opts.configDir || getGlobalConfigDir(runtime), 'agents');
|
|
479
481
|
if (!node_fs_1.default.existsSync(agentsDir)) {
|
|
@@ -1157,7 +1159,7 @@ function cmdTodoMatchPhase(cwd, phase, raw) {
|
|
|
1157
1159
|
const planContent = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(phaseDir, pf));
|
|
1158
1160
|
if (planContent === null)
|
|
1159
1161
|
continue;
|
|
1160
|
-
const fmFiles = planContent.match(/files_modified:\s*\[([^\]]
|
|
1162
|
+
const fmFiles = planContent.match(/files_modified:\s*\[([^\]]{0,8000})\]/);
|
|
1161
1163
|
if (fmFiles) {
|
|
1162
1164
|
phasePlans.push(...fmFiles[1].split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean));
|
|
1163
1165
|
}
|
|
@@ -1302,8 +1304,8 @@ function cmdStats(cwd, format, raw) {
|
|
|
1302
1304
|
const roadmapContent = extractCurrentMilestone(roadmapRaw, cwd);
|
|
1303
1305
|
// Matches both plain numeric (Phase 1:) and milestone-prefixed (Phase 2-01:) headings.
|
|
1304
1306
|
// Also tolerates optional [bracket-token] scope prefix on phase headings.
|
|
1305
|
-
// #1729: `(?:\s*\([^)\n]
|
|
1306
|
-
const headingPattern = /#{2,4}\s*(?:\[[^\]]
|
|
1307
|
+
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
|
1308
|
+
const headingPattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi;
|
|
1307
1309
|
let match;
|
|
1308
1310
|
while ((match = headingPattern.exec(roadmapContent)) !== null) {
|
|
1309
1311
|
const key = normalizePhaseName(match[1]);
|
|
@@ -104,6 +104,7 @@ const CONFIG_DEFAULTS = {
|
|
|
104
104
|
verifier: _getNestedConfigDefault('workflow', 'verifier'),
|
|
105
105
|
nyquist_validation: _getNestedConfigDefault('workflow', 'nyquist_validation'),
|
|
106
106
|
ai_integration_phase: _getNestedConfigDefault('workflow', 'ai_integration_phase'),
|
|
107
|
+
api_coverage_gate: _getNestedConfigDefault('workflow', 'api_coverage_gate'),
|
|
107
108
|
parallelization: _getConfigDefault('parallelization'),
|
|
108
109
|
brave_search: _getConfigDefault('brave_search'),
|
|
109
110
|
firecrawl: _getConfigDefault('firecrawl'),
|
|
@@ -209,6 +209,7 @@ function buildNewProjectConfig(userChoices) {
|
|
|
209
209
|
ui_phase: true,
|
|
210
210
|
ui_safety_gate: true,
|
|
211
211
|
ai_integration_phase: true,
|
|
212
|
+
api_coverage_gate: true,
|
|
212
213
|
human_verify_mode: 'end-of-phase',
|
|
213
214
|
context_guard_mode: 'warn',
|
|
214
215
|
text_mode: false,
|
|
@@ -408,6 +409,81 @@ function _setNestedValue(config, keyPath, parsedValue) {
|
|
|
408
409
|
current[lastKey] = parsedValue;
|
|
409
410
|
return previousValue;
|
|
410
411
|
}
|
|
412
|
+
/**
|
|
413
|
+
* Deletes a value from the config object, allowing nested values via dot
|
|
414
|
+
* notation (e.g., "review.models.gemini"). Mirrors `_setNestedValue`'s
|
|
415
|
+
* prototype-pollution guard on every path segment (including intermediates).
|
|
416
|
+
*
|
|
417
|
+
* Unlike `_setNestedValue`, this NEVER creates missing intermediate objects —
|
|
418
|
+
* if any segment along the path is missing (or not a plain, non-array
|
|
419
|
+
* object), the key doesn't exist and we return early without mutating
|
|
420
|
+
* `config` at all.
|
|
421
|
+
*
|
|
422
|
+
* Does not prune now-empty parent objects after deletion (matches the
|
|
423
|
+
* conservative, structure-preserving behaviour callers expect from a bare
|
|
424
|
+
* unset).
|
|
425
|
+
*
|
|
426
|
+
* Returns { previousValue, existed } — existed is false when the leaf key
|
|
427
|
+
* (or an intermediate segment) was never present.
|
|
428
|
+
* Calls error() (process.exit(1)) on prototype-pollution attempts.
|
|
429
|
+
*/
|
|
430
|
+
function _unsetNestedValue(config, keyPath) {
|
|
431
|
+
const keys = keyPath.split('.');
|
|
432
|
+
let current = config;
|
|
433
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
434
|
+
const key = keys[i];
|
|
435
|
+
if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
|
|
436
|
+
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
|
|
437
|
+
}
|
|
438
|
+
const existingChild = current[key];
|
|
439
|
+
if (existingChild === undefined || existingChild === null || typeof existingChild !== 'object' || Array.isArray(existingChild)) {
|
|
440
|
+
// Path doesn't exist — nothing to unset, and we must not create it.
|
|
441
|
+
return { previousValue: undefined, existed: false };
|
|
442
|
+
}
|
|
443
|
+
current = existingChild;
|
|
444
|
+
}
|
|
445
|
+
const lastKey = keys[keys.length - 1];
|
|
446
|
+
if (lastKey === '__proto__' || lastKey === 'prototype' || lastKey === 'constructor') {
|
|
447
|
+
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
|
|
448
|
+
}
|
|
449
|
+
const existed = Object.prototype.hasOwnProperty.call(current, lastKey);
|
|
450
|
+
const previousValue = current[lastKey];
|
|
451
|
+
if (existed) {
|
|
452
|
+
delete current[lastKey];
|
|
453
|
+
}
|
|
454
|
+
return { previousValue, existed };
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Deletes a key from the config file, allowing nested values via dot
|
|
458
|
+
* notation. Mirrors `setConfigValue`'s load/lock/write cycle.
|
|
459
|
+
*
|
|
460
|
+
* Does not call `output()`, so can be used as one step in a command without triggering `exit(0)` in
|
|
461
|
+
* the happy path. But note that `error()` will still `exit(1)` out of the process.
|
|
462
|
+
*/
|
|
463
|
+
function unsetConfigValue(cwd, keyPath) {
|
|
464
|
+
const configPath = node_path_1.default.join(planningDir(cwd), 'config.json');
|
|
465
|
+
return withPlanningLock(cwd, () => {
|
|
466
|
+
// Load existing config or start with empty object
|
|
467
|
+
let config = {};
|
|
468
|
+
try {
|
|
469
|
+
if (node_fs_1.default.existsSync(configPath)) {
|
|
470
|
+
config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8'));
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
catch (err) {
|
|
474
|
+
error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED);
|
|
475
|
+
}
|
|
476
|
+
const { previousValue, existed } = _unsetNestedValue(config, keyPath);
|
|
477
|
+
// Write back
|
|
478
|
+
try {
|
|
479
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(configPath, JSON.stringify(config, null, 2));
|
|
480
|
+
return { updated: existed, unset: true, key: keyPath, value: null, previousValue };
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
error('Failed to write config.json: ' + err.message);
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
}
|
|
411
487
|
/**
|
|
412
488
|
* Sets a value in the config file, allowing nested values via dot notation (e.g.,
|
|
413
489
|
* "workflow.research").
|
|
@@ -531,6 +607,8 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
|
|
|
531
607
|
parsedValue = true;
|
|
532
608
|
else if (val === 'false')
|
|
533
609
|
parsedValue = false;
|
|
610
|
+
else if (val === 'null')
|
|
611
|
+
parsedValue = null;
|
|
534
612
|
// #1581: Number.isFinite (not !isNaN) so 'Infinity'/'-Infinity' are NOT
|
|
535
613
|
// coerced to non-finite numbers that JSON.stringify later renders as `null`
|
|
536
614
|
// (disk=null while the CLI echoed 'Infinity'). They fall through to the
|
|
@@ -544,6 +622,24 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
|
|
|
544
622
|
}
|
|
545
623
|
catch { /* keep as string */ }
|
|
546
624
|
}
|
|
625
|
+
// #2046: a bare `null` unsets (deletes) the key — the documented "Clear" action.
|
|
626
|
+
// Short-circuits before every typed per-key validator so clearing a typed key
|
|
627
|
+
// (enum/boolean/number) removes it rather than being rejected. Deleting (not
|
|
628
|
+
// persisting JSON null) is the correct "clear": a persisted null is still a
|
|
629
|
+
// present, truthy-adjacent value that consumers must special-case — worst for
|
|
630
|
+
// secret keys where a leftover value can be passed as a real credential.
|
|
631
|
+
if (parsedValue === null) {
|
|
632
|
+
const unsetResult = unsetConfigValue(cwd, kp);
|
|
633
|
+
if ((0, secrets_cjs_1.isSecretKey)(kp)) {
|
|
634
|
+
const maskedPrev = unsetResult.previousValue === undefined
|
|
635
|
+
? undefined
|
|
636
|
+
: (0, secrets_cjs_1.maskSecret)(unsetResult.previousValue);
|
|
637
|
+
output({ ...unsetResult, value: null, previousValue: maskedPrev, masked: true }, raw, `${kp} unset`);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
output(unsetResult, raw, `${kp} unset`);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
547
643
|
// #1581: project_code is an identifier string — never number-coerce it. A
|
|
548
644
|
// leading-zero code like '007' must persist verbatim (not collapse to 7).
|
|
549
645
|
if (kp === 'project_code') {
|
|
@@ -176,7 +176,10 @@ function timeAgo(date) {
|
|
|
176
176
|
function extractCanonicalPlanId(filename) {
|
|
177
177
|
const base = filename.replace(/-PLAN\.md$/i, '').replace(/-SUMMARY\.md$/i, '').replace(/\.md$/i, '');
|
|
178
178
|
const parts = base.split('-').filter(Boolean);
|
|
179
|
-
|
|
179
|
+
// #2043: a phase/plan token component is either a zero-padded number (≥2 digits)
|
|
180
|
+
// or a single-digit-plus-letter id ("3A"); a *bare* single digit is a slug word,
|
|
181
|
+
// so "46-6-rs-…" is not paired into a "46-6" id while "3A-01" stays intact.
|
|
182
|
+
const tokenRe = /^(?:\d{2,}[A-Z]?|\d[A-Z])(?:\.\d+)*$/i;
|
|
180
183
|
const phaseIdx = parts.findIndex(p => tokenRe.test(p));
|
|
181
184
|
if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) {
|
|
182
185
|
return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
|