@opengsd/gsd-core 1.7.0-rc.4 → 1.7.0-rc.6
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 +30 -0
- package/agents/gsd-ui-researcher.md +1 -0
- package/bin/install.js +1568 -622
- 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 +761 -84
- package/gsd-core/bin/lib/capability-validator.cjs +56 -18
- 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 +45 -4
- package/gsd-core/bin/lib/init.cjs +76 -39
- package/gsd-core/bin/lib/install-effort-resolver.cjs +213 -0
- package/gsd-core/bin/lib/install-engine.cjs +228 -18
- package/gsd-core/bin/lib/installer-migration-report.cjs +7 -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/mcp-server.cjs +18 -7
- 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/review-reviewer-selection.cjs +24 -7
- 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 +248 -44
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +2 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +39 -23
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
- package/gsd-core/bin/lib/runtime-homes.cjs +30 -0
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +534 -37
- package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
- package/gsd-core/bin/lib/security.cjs +6 -36
- package/gsd-core/bin/lib/shell-command-projection.cjs +115 -2
- package/gsd-core/bin/lib/spec-section.cjs +111 -0
- package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
- 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 +40 -6
- package/gsd-core/bin/lib/uat.cjs +4 -1
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
- 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 +14 -9
- 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/references/ui-consideration-probe.md +73 -0
- package/gsd-core/templates/UI-SPEC.md +25 -0
- package/gsd-core/templates/VALIDATION.md +2 -0
- package/gsd-core/templates/config.json +2 -1
- package/gsd-core/workflows/audit-fix.md +9 -1
- package/gsd-core/workflows/audit-milestone.md +7 -4
- 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 +37 -2
- package/gsd-core/workflows/quick.md +2 -2
- package/gsd-core/workflows/review.md +59 -13
- package/gsd-core/workflows/settings-advanced.md +12 -9
- package/gsd-core/workflows/settings.md +2 -2
- package/gsd-core/workflows/ui-phase.md +146 -1
- package/gsd-core/workflows/validate-phase.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/gsd-windsurf-pre-command.js +275 -0
- package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
- package/hooks/dist/managed-hooks-registry.cjs +6 -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/gsd-windsurf-pre-command.js +275 -0
- package/hooks/gsd-windsurf-pre-write.js +132 -0
- package/hooks/managed-hooks-registry.cjs +6 -0
- package/package.json +9 -4
- package/pi/gsd.cjs +354 -0
- package/scripts/build-hooks.js +8 -1
- package/scripts/gen-golden-install-parity-zcode.cjs +11 -1
- package/scripts/gen-registry.cjs +128 -0
- package/scripts/lint-phase-id-drift.cjs +150 -0
- package/scripts/lint-test-file-count.allowlist.json +2 -1
- package/scripts/registry-schema.cjs +565 -0
- package/scripts/run-tests.cjs +21 -1
- package/scripts/validate-registry.cjs +117 -0
- package/vscode/browser.js +197 -0
- package/vscode/extension.js +383 -0
- package/vscode/host-binding.js +113 -0
- package/vscode/package.json +96 -0
|
@@ -697,26 +697,35 @@ const VALID_CONVERTER_NAMES = new Set([
|
|
|
697
697
|
'convertClaudeAgentToCodebuddyAgent',
|
|
698
698
|
'convertClaudeAgentToClineAgent',
|
|
699
699
|
'convertClaudeAgentToCodexAgent',
|
|
700
|
+
// ADR-1239 / #2092 Phase B Upgrade 1 — native .qwen/agents/*.md subagent projection.
|
|
701
|
+
'convertClaudeAgentToQwenAgent',
|
|
700
702
|
]);
|
|
701
703
|
|
|
702
704
|
// C3: Validate role:runtime body
|
|
703
705
|
const VALID_CONFIG_FORMATS = new Set(['settings-json', 'toml', 'markdown', 'markdown-dir', 'none']);
|
|
704
|
-
|
|
706
|
+
// 'none' added #2103 — Marketplace/VSIX-distributed hosts (e.g. VS Code) with
|
|
707
|
+
// no file-projected config directory at all.
|
|
708
|
+
const VALID_CONFIG_HOME_KINDS = new Set(['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root', 'none']);
|
|
705
709
|
const VALID_COMMAND_STYLES = new Set(['slash-hyphen', 'shell-var']);
|
|
706
|
-
const VALID_HOOKS_SURFACES = new Set(['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'none']);
|
|
710
|
+
const VALID_HOOKS_SURFACES = new Set(['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'kimi-hooks-toml', 'windsurf-hooks-json', 'none']);
|
|
707
711
|
const VALID_HOOK_EVENTS = new Set(['claude', 'gemini']);
|
|
708
712
|
// extensionEvents — the plugin/extension-system event dialect (ADR-1239 amendment / #1943).
|
|
709
713
|
// DISTINCT from hookEvents (managed-hook dialect): extensionEvents describes the
|
|
710
714
|
// plugin-owned event subset imperative hosts expose (opencode / pi); 'none' = the
|
|
711
715
|
// host exposes no extension surface (engine owns the bus, e.g. VS Code).
|
|
712
|
-
const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'none']);
|
|
716
|
+
const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'hermes', 'kilo', 'none']);
|
|
713
717
|
const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']);
|
|
714
718
|
const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']);
|
|
715
719
|
const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']);
|
|
716
720
|
const FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks', 'activationKey'];
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
const
|
|
721
|
+
// 'none' added #2103 — Marketplace/VSIX-distributed hosts (e.g. VS Code) that
|
|
722
|
+
// are never CLI-installed (no allRuntimes membership, no install flag).
|
|
723
|
+
const VALID_INSTALL_SURFACES = new Set(['settings-json', 'codex-toml', 'copilot-instructions', 'cline-rules', 'cursor-hooks-json', 'profile-marker-only', 'none']);
|
|
724
|
+
// 'antigravity' added #2096 Phase B Upgrade 1 — settings.json permissions.allow writer.
|
|
725
|
+
const VALID_PERMISSION_WRITERS = new Set(['opencode', 'kilo', 'antigravity']);
|
|
726
|
+
// SubagentStart added #2092 Phase B Upgrade 2 (qwen-only today — see
|
|
727
|
+
// capabilities/qwen/capability.json's extendedHookEvents).
|
|
728
|
+
const VALID_EXTENDED_HOOK_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel', 'SubagentStart']);
|
|
720
729
|
|
|
721
730
|
// ADR-1239 Phase A: hostIntegration axes (MUST stay parity-identical to HOST_INTEGRATION_AXES in src/host-integration.cts)
|
|
722
731
|
const VALID_EMBEDDING_MODES = new Set(['imperative', 'declarative']);
|
|
@@ -736,13 +745,18 @@ const INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES = new Map([
|
|
|
736
745
|
['copilot-instructions', new Set(['copilot-inline'])],
|
|
737
746
|
['cline-rules', new Set(['cline-rules'])],
|
|
738
747
|
['cursor-hooks-json', new Set(['cursor-hooks-json'])],
|
|
739
|
-
['profile-marker-only', new Set(['none'])],
|
|
748
|
+
['profile-marker-only', new Set(['none', 'kimi-hooks-toml', 'windsurf-hooks-json'])],
|
|
749
|
+
// 'none' added #2103 — VS Code has no CLI install surface at all; its only
|
|
750
|
+
// valid hooksSurface pairing is the other 'none' (engine owns the hook bus).
|
|
751
|
+
['none', new Set(['none'])],
|
|
740
752
|
]);
|
|
741
753
|
|
|
742
754
|
// GATE B: extended hook event families → required hookEvents value
|
|
743
755
|
// Gemini agent-events require hookEvents='gemini'; Claude-family events require hookEvents='claude'.
|
|
744
756
|
const GEMINI_AGENT_EVENTS = new Set(['BeforeAgent', 'AfterAgent', 'BeforeModel']);
|
|
745
|
-
|
|
757
|
+
// SubagentStart added #2092 Phase B Upgrade 2 — Claude hook-event dialect
|
|
758
|
+
// counterpart of SubagentStop (qwen-only today).
|
|
759
|
+
const CLAUDE_FAMILY_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'SubagentStart']);
|
|
746
760
|
|
|
747
761
|
/**
|
|
748
762
|
* Validate a runtime.configHome object per ADR-1016 Decision 1.
|
|
@@ -771,9 +785,18 @@ function validateConfigHome(capId, ch) {
|
|
|
771
785
|
);
|
|
772
786
|
}
|
|
773
787
|
|
|
774
|
-
// name — required string
|
|
775
|
-
|
|
776
|
-
|
|
788
|
+
// name — required string, except when kind === 'none': the runtime has no
|
|
789
|
+
// file-projected config directory at all, so a descriptive name is
|
|
790
|
+
// optional (a carve-out mirroring the dot-home-nested⇒parent conditional
|
|
791
|
+
// below, not a new validation mechanism). If present it must still be a
|
|
792
|
+
// non-empty string (e.g. vscode's configHome.name stays a descriptive
|
|
793
|
+
// "vscode" string even though it is never used to build a path).
|
|
794
|
+
if (ch.kind !== 'none') {
|
|
795
|
+
if (typeof ch.name !== 'string' || ch.name.length === 0) {
|
|
796
|
+
errors.push(ctx + '.name must be a non-empty string');
|
|
797
|
+
}
|
|
798
|
+
} else if (ch.name !== undefined && (typeof ch.name !== 'string' || ch.name.length === 0)) {
|
|
799
|
+
errors.push(ctx + '.name must be a non-empty string if present when kind is "none"');
|
|
777
800
|
}
|
|
778
801
|
|
|
779
802
|
// parent — required when kind == dot-home-nested
|
|
@@ -973,7 +996,7 @@ function validateRuntimeBody(cap) {
|
|
|
973
996
|
);
|
|
974
997
|
}
|
|
975
998
|
|
|
976
|
-
// hooksSurface — closed
|
|
999
|
+
// hooksSurface — closed 7-enum (ADR-1016 Decision 5); inline literal guard (CodeQL barrier)
|
|
977
1000
|
if (r.hooksSurface === '__proto__' || r.hooksSurface === 'constructor' || r.hooksSurface === 'prototype') {
|
|
978
1001
|
errors.push('runtime.hooksSurface "' + r.hooksSurface + '" is a reserved name');
|
|
979
1002
|
} else if (!VALID_HOOKS_SURFACES.has(r.hooksSurface)) {
|
|
@@ -1054,15 +1077,27 @@ function validateRuntimeBody(cap) {
|
|
|
1054
1077
|
// localConfigDir — REQUIRED non-empty dot-dir string (ADR-1239 Phase B #1679)
|
|
1055
1078
|
// Must start with '.' (e.g. ".claude", ".cursor"). Validated here so the registry
|
|
1056
1079
|
// generator catches any descriptor missing the field before regenerating.
|
|
1057
|
-
|
|
1080
|
+
//
|
|
1081
|
+
// #2103: conditional on configHome.kind !== 'none' — a Marketplace/VSIX
|
|
1082
|
+
// host with no file-projected config directory (e.g. VS Code) has no
|
|
1083
|
+
// local dir to name; localConfigDir may be null/absent for such runtimes.
|
|
1084
|
+
const configHomeKind = (r.configHome && typeof r.configHome === 'object') ? r.configHome.kind : undefined;
|
|
1085
|
+
if (configHomeKind !== 'none') {
|
|
1086
|
+
if (typeof r.localConfigDir !== 'string' || r.localConfigDir.length === 0) {
|
|
1087
|
+
errors.push(
|
|
1088
|
+
'runtime.localConfigDir is required and must be a non-empty string (e.g. ".claude"); ' +
|
|
1089
|
+
'got: ' + JSON.stringify(r.localConfigDir),
|
|
1090
|
+
);
|
|
1091
|
+
} else if (!r.localConfigDir.startsWith('.')) {
|
|
1092
|
+
errors.push(
|
|
1093
|
+
'runtime.localConfigDir must start with "." (a dot-dir); got: ' + JSON.stringify(r.localConfigDir),
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
} else if (r.localConfigDir !== null && r.localConfigDir !== undefined) {
|
|
1058
1097
|
errors.push(
|
|
1059
|
-
'runtime.localConfigDir
|
|
1098
|
+
'runtime.localConfigDir must be null or absent when configHome.kind is "none"; ' +
|
|
1060
1099
|
'got: ' + JSON.stringify(r.localConfigDir),
|
|
1061
1100
|
);
|
|
1062
|
-
} else if (!r.localConfigDir.startsWith('.')) {
|
|
1063
|
-
errors.push(
|
|
1064
|
-
'runtime.localConfigDir must start with "." (a dot-dir); got: ' + JSON.stringify(r.localConfigDir),
|
|
1065
|
-
);
|
|
1066
1101
|
}
|
|
1067
1102
|
|
|
1068
1103
|
// extendedHookEvents — required array; every element must be in closed enum
|
|
@@ -2168,6 +2203,9 @@ const INSTALL_SURFACE_TO_CONFIG_FORMAT = new Map([
|
|
|
2168
2203
|
['cline-rules', 'markdown-dir'],
|
|
2169
2204
|
['cursor-hooks-json', 'none'],
|
|
2170
2205
|
['profile-marker-only', 'none'],
|
|
2206
|
+
// 'none' added #2103 — a runtime with NO CLI install surface at all (e.g.
|
|
2207
|
+
// VS Code) has no config-file format to write either.
|
|
2208
|
+
['none', 'none'],
|
|
2171
2209
|
]);
|
|
2172
2210
|
|
|
2173
2211
|
/**
|
|
@@ -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]}`;
|