@opengsd/gsd-core 1.7.0-rc.3 → 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/README.ja-JP.md +4 -3
- package/README.ko-KR.md +4 -3
- package/README.md +4 -3
- package/README.pt-BR.md +4 -3
- package/README.zh-CN.md +4 -3
- 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 +652 -466
- package/commands/gsd/map-codebase.md +4 -4
- package/commands/gsd/ns-project.md +2 -1
- package/commands/gsd/onboard.md +46 -0
- package/gsd-core/bin/gsd-tools.cjs +87 -3
- 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 +741 -62
- package/gsd-core/bin/lib/capability-state.cjs +117 -18
- package/gsd-core/bin/lib/capability-validator.cjs +1 -1
- package/gsd-core/bin/lib/capability-writer.cjs +21 -2
- package/gsd-core/bin/lib/check-command-router.cjs +242 -3
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +136 -0
- package/gsd-core/bin/lib/claude-orchestration.cjs +404 -0
- package/gsd-core/bin/lib/clusters.cjs +1 -0
- package/gsd-core/bin/lib/command-aliases.cjs +8 -0
- 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-command-router.cjs +4 -0
- package/gsd-core/bin/lib/init.cjs +99 -99
- package/gsd-core/bin/lib/install-effort-resolver.cjs +213 -0
- package/gsd-core/bin/lib/install-engine.cjs +159 -16
- package/gsd-core/bin/lib/install-profiles.cjs +2 -0
- package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +75 -18
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +50 -11
- package/gsd-core/bin/lib/milestone.cjs +3 -3
- package/gsd-core/bin/lib/model-resolver.cjs +69 -4
- package/gsd-core/bin/lib/normalize-test-command.cjs +187 -0
- package/gsd-core/bin/lib/onboard-projection.cjs +309 -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/runtime-name-policy.cjs +3 -1
- 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 +81 -30
- 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 +11 -6
- package/gsd-core/references/api-coverage.md +104 -0
- package/gsd-core/references/gsd-run-resolver.md +8 -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/templates/project.md +1 -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/do.md +4 -3
- 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/help/modes/brief.md +2 -1
- package/gsd-core/workflows/help/modes/default.md +2 -1
- package/gsd-core/workflows/help/modes/full.md +11 -1
- package/gsd-core/workflows/help/modes/topic.md +1 -1
- package/gsd-core/workflows/onboard.md +277 -0
- package/gsd-core/workflows/plan-phase.md +31 -2
- package/gsd-core/workflows/quick.md +22 -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/gen-golden-install-parity-zcode.cjs +77 -0
- package/scripts/lint-phase-id-drift.cjs +150 -0
- package/scripts/run-tests.cjs +26 -4
- package/scripts/sync-runtime-launcher.cjs +20 -2
- package/skills/gsd-map-codebase/SKILL.md +3 -3
- package/skills/gsd-ns-project/SKILL.md +1 -0
- package/skills/gsd-onboard/SKILL.md +46 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Claude Orchestration Capability — Workflow-tool backend detection + emitter
|
|
4
|
+
*
|
|
5
|
+
* #1143 — adopts Claude Code's Workflow tool (the engine behind `/effort ultracode`)
|
|
6
|
+
* as an optional, runtime-gated parallel-execution backend for the GSD loop.
|
|
7
|
+
*
|
|
8
|
+
* This module is the pure, testable core of the capability. It owns two seams:
|
|
9
|
+
*
|
|
10
|
+
* detectWorkflowBackend({ runtimeId, hostIntegration, config, agentSdkVersion })
|
|
11
|
+
* → { available: boolean, backend: 'workflow'|'inline', reason: string }
|
|
12
|
+
* Fail-closed: every miss degrades to `inline` (today's behaviour), so the
|
|
13
|
+
* core loop is byte-identical unless every gate opens. This is criteria 3 + 6.
|
|
14
|
+
*
|
|
15
|
+
* emitWorkflowScript({ phaseDir, waves, runId, budgetTokens? })
|
|
16
|
+
* → { ok:true, script, summary } | { ok:false, reason }
|
|
17
|
+
* Maps GSD's wave/plan model 1:1 onto Workflow primitives:
|
|
18
|
+
* wave → sequential `parallel()` stage barriers,
|
|
19
|
+
* plan → `agent(brief, { agentType:'gsd-executor', isolation:'worktree' })`,
|
|
20
|
+
* files_modified overlap → forces plans into separate sequential stages
|
|
21
|
+
* (the same overlap rule execute-phase already applies inline),
|
|
22
|
+
* resumeFromRunId → wired to the phase run id,
|
|
23
|
+
* budgetTokens → a shared token pool.
|
|
24
|
+
* The emitted script composes the SAME gsd-executor agent and worktree
|
|
25
|
+
* isolation the inline path uses, so it produces the same artifacts/commits
|
|
26
|
+
* (criterion 2). It is a generated string consumed by the orchestrator; this
|
|
27
|
+
* module never invokes the Workflow tool itself.
|
|
28
|
+
*
|
|
29
|
+
* Design laws:
|
|
30
|
+
* - Gall's Law: ship a small working slice that composes existing primitives
|
|
31
|
+
* (gsd-executor + worktree isolation) rather than reinventing them.
|
|
32
|
+
* - Greenspun's Tenth Rule (cited in #1143): adopt the Workflow tool's
|
|
33
|
+
* barrier/pipeline/budget/resume semantics instead of hand-rolling them.
|
|
34
|
+
* - Postel's Law: liberal in input (missing fields → inline), conservative in
|
|
35
|
+
* output (workflow only when every gate opens).
|
|
36
|
+
* - Fail-closed: an unknown version, a missing descriptor, or a disabled
|
|
37
|
+
* toggle all resolve to `inline`, never to `workflow`.
|
|
38
|
+
*
|
|
39
|
+
* Zero external dependencies. Pure functions. Never throws on bad input.
|
|
40
|
+
*/
|
|
41
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
42
|
+
/**
|
|
43
|
+
* The Agent SDK version that introduced the Workflow tool (#1143 prior art).
|
|
44
|
+
* Used as the default floor when config does not override it. A runtime reporting
|
|
45
|
+
* an agentSdkVersion below this cannot host the Workflow backend.
|
|
46
|
+
*/
|
|
47
|
+
const WORKFLOW_TOOL_FLOOR_VERSION = '0.3.149';
|
|
48
|
+
/** Closed enum for the `claude_orchestration.execution_backend` config key. */
|
|
49
|
+
const BACKEND_VALUES = new Set(['auto', 'workflow', 'inline']);
|
|
50
|
+
/** Only this runtime can host the Workflow tool (Claude Code / Agent SDK). */
|
|
51
|
+
const WORKFLOW_RUNTIME = 'claude';
|
|
52
|
+
// ─── Semver helpers ───────────────────────────────────────────────────────────
|
|
53
|
+
/** Official-ish strict SemVer 2.0.0 numeric triple (+ optional pre/build). */
|
|
54
|
+
const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
55
|
+
/** True for a syntactically valid semver string. */
|
|
56
|
+
function isValidSemver(s) {
|
|
57
|
+
return typeof s === 'string' && SEMVER_RE.test(s);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Compare two semver strings.
|
|
61
|
+
* Returns -1/0/1 in the usual sense. Garbage in either position → -1 (fail-closed:
|
|
62
|
+
* an unparseable version is treated as "less than" any real floor, so detection
|
|
63
|
+
* never accidentally enables the preview backend on an unknown SDK).
|
|
64
|
+
*
|
|
65
|
+
* Pre-release/build metadata are ignored for the comparison — only the numeric
|
|
66
|
+
* major.minor.patch triple participates, matching how the Workflow-tool floor is
|
|
67
|
+
* specified (a plain "0.3.149").
|
|
68
|
+
*/
|
|
69
|
+
function compareSemver(a, b) {
|
|
70
|
+
if (!isValidSemver(a) || !isValidSemver(b))
|
|
71
|
+
return -1;
|
|
72
|
+
// Split numeric triple from pre-release/build metadata.
|
|
73
|
+
const parseTriple = (s) => {
|
|
74
|
+
const core = s.split('-')[0].split('+')[0].split('.');
|
|
75
|
+
return [parseInt(core[0], 10), parseInt(core[1], 10), parseInt(core[2], 10)];
|
|
76
|
+
};
|
|
77
|
+
const hasPre = (s) => s.indexOf('-') !== -1;
|
|
78
|
+
const preIdentifiers = (s) => (s.split('-')[1] || '').split('+')[0].split('.').filter((x) => x.length > 0);
|
|
79
|
+
const am = parseTriple(a);
|
|
80
|
+
const bm = parseTriple(b);
|
|
81
|
+
for (let i = 0; i < 3; i++) {
|
|
82
|
+
if (am[i] < bm[i])
|
|
83
|
+
return -1;
|
|
84
|
+
if (am[i] > bm[i])
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
// Numeric triple is equal. SemVer 2.0.0 §11 precedence:
|
|
88
|
+
// - a version WITH a pre-release tag is LOWER than the same triple WITHOUT one
|
|
89
|
+
// (keeps the floor fail-closed for pre-release builds of the GA floor);
|
|
90
|
+
// - two pre-releases of the same triple are ordered by their dot-separated
|
|
91
|
+
// identifiers (numeric < alphanumeric; numeric compared numerically,
|
|
92
|
+
// alphanumeric lexically; fewer identifiers < more).
|
|
93
|
+
const aPre = hasPre(a);
|
|
94
|
+
const bPre = hasPre(b);
|
|
95
|
+
if (aPre && !bPre)
|
|
96
|
+
return -1;
|
|
97
|
+
if (!aPre && bPre)
|
|
98
|
+
return 1;
|
|
99
|
+
if (aPre && bPre) {
|
|
100
|
+
const ai = preIdentifiers(a);
|
|
101
|
+
const bi = preIdentifiers(b);
|
|
102
|
+
const len = Math.min(ai.length, bi.length);
|
|
103
|
+
for (let i = 0; i < len; i++) {
|
|
104
|
+
const ax = ai[i];
|
|
105
|
+
const bx = bi[i];
|
|
106
|
+
const aNum = /^\d+$/.test(ax);
|
|
107
|
+
const bNum = /^\d+$/.test(bx);
|
|
108
|
+
if (aNum && bNum) {
|
|
109
|
+
const an = parseInt(ax, 10);
|
|
110
|
+
const bn = parseInt(bx, 10);
|
|
111
|
+
if (an < bn)
|
|
112
|
+
return -1;
|
|
113
|
+
if (an > bn)
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
else if (aNum && !bNum) {
|
|
117
|
+
return -1; // numeric identifiers always lower than alphanumeric
|
|
118
|
+
}
|
|
119
|
+
else if (!aNum && bNum) {
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
if (ax < bx)
|
|
124
|
+
return -1;
|
|
125
|
+
if (ax > bx)
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (ai.length < bi.length)
|
|
130
|
+
return -1;
|
|
131
|
+
if (ai.length > bi.length)
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
/** Inline result shorthand. */
|
|
137
|
+
function inline(reason, available = false) {
|
|
138
|
+
return { available, backend: 'inline', reason };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Resolve whether the Workflow-tool backend should activate.
|
|
142
|
+
*
|
|
143
|
+
* Gate ladder (all must pass for `workflow`; first miss wins, fail-closed):
|
|
144
|
+
* 1. capability enabled (claude_orchestration.enabled truthy)
|
|
145
|
+
* 2. runtime is Claude (the only runtime that exposes the Workflow tool)
|
|
146
|
+
* 3. execution_backend !== 'inline'
|
|
147
|
+
* 4. host descriptor signals nested+background dispatch (Workflow-tool capable)
|
|
148
|
+
* 5. agentSdkVersion is a known, valid semver
|
|
149
|
+
* 6. agentSdkVersion >= the configured floor (default WORKFLOW_TOOL_FLOOR_VERSION)
|
|
150
|
+
* 7. execution_backend === 'workflow' OR 'auto' (both reach here; 'inline' exited at 3)
|
|
151
|
+
*
|
|
152
|
+
* Never throws. Destructures defensively.
|
|
153
|
+
*/
|
|
154
|
+
function detectWorkflowBackend(input) {
|
|
155
|
+
if (input === null || input === undefined || typeof input !== 'object') {
|
|
156
|
+
return inline('capability_disabled');
|
|
157
|
+
}
|
|
158
|
+
const cfg = (input.config !== null && input.config !== undefined && typeof input.config === 'object')
|
|
159
|
+
? input.config
|
|
160
|
+
: {};
|
|
161
|
+
// 1. capability must be opted in (default-off — ships disabled).
|
|
162
|
+
if (!cfg['claude_orchestration.enabled']) {
|
|
163
|
+
return inline('capability_disabled');
|
|
164
|
+
}
|
|
165
|
+
// 2. only Claude can host the Workflow tool.
|
|
166
|
+
if (input.runtimeId !== WORKFLOW_RUNTIME) {
|
|
167
|
+
return inline('runtime_not_claude');
|
|
168
|
+
}
|
|
169
|
+
// 3. explicit inline opt-out short-circuits.
|
|
170
|
+
let backendRaw = cfg['claude_orchestration.execution_backend'];
|
|
171
|
+
if (typeof backendRaw !== 'string' || !BACKEND_VALUES.has(backendRaw)) {
|
|
172
|
+
backendRaw = 'auto';
|
|
173
|
+
}
|
|
174
|
+
if (backendRaw === 'inline') {
|
|
175
|
+
return inline('backend_inline');
|
|
176
|
+
}
|
|
177
|
+
// 4. the host dispatch descriptor must be the nesting-capable Claude-Code shape
|
|
178
|
+
// (a proxy for Workflow-tool presence). This is Claude-specific and already
|
|
179
|
+
// gated at step 2; `background:true` alone is true on several non-Claude hosts,
|
|
180
|
+
// so the proxy is only meaningful after the runtime check above. Note: this is
|
|
181
|
+
// NOT the canonical `shouldFlattenDispatch` rule (which keys on
|
|
182
|
+
// `backgroundDispatch`); the Workflow backend works precisely because a single
|
|
183
|
+
// tool-call orchestrates internally, sidestepping the backgroundDispatch:false
|
|
184
|
+
// limitation. Missing/false/foreign descriptor → fail-closed.
|
|
185
|
+
const hi = input.hostIntegration;
|
|
186
|
+
if (hi === null || hi === undefined || typeof hi !== 'object' || Array.isArray(hi)) {
|
|
187
|
+
return inline('workflow_tool_unavailable');
|
|
188
|
+
}
|
|
189
|
+
const dispatch = hi.dispatch;
|
|
190
|
+
if (typeof dispatch !== 'object' || dispatch === null || Array.isArray(dispatch)) {
|
|
191
|
+
return inline('workflow_tool_unavailable');
|
|
192
|
+
}
|
|
193
|
+
const nested = dispatch['nested'];
|
|
194
|
+
const background = dispatch['background'];
|
|
195
|
+
if (nested !== true || background !== true) {
|
|
196
|
+
return inline('workflow_tool_unavailable');
|
|
197
|
+
}
|
|
198
|
+
// 5. an unknown agentSdkVersion cannot be trusted to meet the floor.
|
|
199
|
+
if (!isValidSemver(input.agentSdkVersion)) {
|
|
200
|
+
return inline('agent_sdk_version_unknown');
|
|
201
|
+
}
|
|
202
|
+
// 6. version floor (config override > default constant).
|
|
203
|
+
const floorRaw = cfg['claude_orchestration.min_agent_sdk_version'];
|
|
204
|
+
const floor = typeof floorRaw === 'string' && isValidSemver(floorRaw) ? floorRaw : WORKFLOW_TOOL_FLOOR_VERSION;
|
|
205
|
+
if (compareSemver(input.agentSdkVersion, floor) < 0) {
|
|
206
|
+
return inline('agent_sdk_version_below_floor');
|
|
207
|
+
}
|
|
208
|
+
// 7. auto/workflow both reach the workflow backend once every gate passes.
|
|
209
|
+
return { available: true, backend: 'workflow', reason: 'workflow_backend_active' };
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Partition a wave's plans into a near-minimal number of sequential stages (via
|
|
213
|
+
* greedy first-fit — not guaranteed optimal for arbitrary overlap graphs, but
|
|
214
|
+
* correct: no two plans sharing a file ever cohabit a stage) such that no two
|
|
215
|
+
* plans in the same stage share a modified file. Each plan goes into the earliest
|
|
216
|
+
* stage where it does not overlap any plan already there.
|
|
217
|
+
*
|
|
218
|
+
* A plan with an EMPTY files_modified set declares no files; it overlaps nothing
|
|
219
|
+
* and coalesces into stage 0 (same behavior as the inline path, which also cannot
|
|
220
|
+
* guard against undeclared concurrent writes — declare filesModified accurately).
|
|
221
|
+
*
|
|
222
|
+
* This is the same overlap rule execute-phase applies inline — the only difference
|
|
223
|
+
* is the execution vehicle (Workflow `parallel()` vs one-agent-per-message).
|
|
224
|
+
*/
|
|
225
|
+
function partitionStages(plans) {
|
|
226
|
+
const stages = [];
|
|
227
|
+
for (const plan of plans) {
|
|
228
|
+
const fileSet = new Set(plan.files_modified);
|
|
229
|
+
let placed = false;
|
|
230
|
+
for (const stage of stages) {
|
|
231
|
+
let overlap = false;
|
|
232
|
+
for (const f of fileSet) {
|
|
233
|
+
if (stage.files.has(f)) {
|
|
234
|
+
overlap = true;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (!overlap) {
|
|
239
|
+
stage.plans.push(plan);
|
|
240
|
+
for (const f of fileSet)
|
|
241
|
+
stage.files.add(f);
|
|
242
|
+
placed = true;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (!placed) {
|
|
247
|
+
stages.push({ plans: [plan], files: new Set(fileSet) });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return stages.map((s) => s.plans.map((p) => p.id));
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Quote a free-text value for safe embedding as a JavaScript/Workflow double-quoted
|
|
254
|
+
* string literal. Uses JSON.stringify so every JS-relevant escape (backslash, quote,
|
|
255
|
+
* newline, tab, NUL, U+2028/U+2029, all control chars) is handled by the language
|
|
256
|
+
* itself — there is no hand-rolled escape table to drift. Returns the value already
|
|
257
|
+
* wrapped in its surrounding quotes.
|
|
258
|
+
*/
|
|
259
|
+
function quoteString(s) {
|
|
260
|
+
return JSON.stringify(s);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* True if `s` is a safe identifier/path token to interpolate into the generated
|
|
264
|
+
* script WITHOUT requiring a string-literal context — i.e. it contains no
|
|
265
|
+
* character that could terminate a comment line (`\n`/`\r`), break out of a
|
|
266
|
+
* string literal (`"` / `\`), or smuggle a NUL/control sequence. Used for
|
|
267
|
+
* `phaseDir`, `runId`, `wave.id`, and `plan.id`, which are identifiers/paths and
|
|
268
|
+
* must never legitimately contain such characters. Rejecting them at validation
|
|
269
|
+
* (rather than silently flattening) keeps the emitted script faithful to input.
|
|
270
|
+
*/
|
|
271
|
+
const UNSCRIPTABLE_CHAR_RE = /[\r\n"\\\x00-\x1f\x7f\u2028\u2029]/;
|
|
272
|
+
function isScriptableIdentifier(s) {
|
|
273
|
+
if (typeof s !== 'string' || s.length === 0)
|
|
274
|
+
return false;
|
|
275
|
+
return !UNSCRIPTABLE_CHAR_RE.test(s);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Emit a Workflow script mapping the phase's wave/plan model onto Workflow
|
|
279
|
+
* primitives. Pure and deterministic: identical input yields an identical string.
|
|
280
|
+
*
|
|
281
|
+
* Returns ok:false (never throws) on invalid input — empty waves, missing runId,
|
|
282
|
+
* a wave with no plans, etc.
|
|
283
|
+
*/
|
|
284
|
+
function emitWorkflowScript(input) {
|
|
285
|
+
if (input === null || input === undefined || typeof input !== 'object') {
|
|
286
|
+
return { ok: false, reason: 'invalid_input' };
|
|
287
|
+
}
|
|
288
|
+
const { phaseDir, waves, runId } = input;
|
|
289
|
+
// Identifiers/paths interpolated into the generated script must be free of any
|
|
290
|
+
// character that could terminate a comment, break out of a string literal, or
|
|
291
|
+
// smuggle control bytes — reject up front (security: #1143 review Finding 1).
|
|
292
|
+
if (!isScriptableIdentifier(phaseDir)) {
|
|
293
|
+
return { ok: false, reason: 'phaseDir must be a non-empty string without newlines/quotes/backslash/control chars' };
|
|
294
|
+
}
|
|
295
|
+
if (!isScriptableIdentifier(runId)) {
|
|
296
|
+
return { ok: false, reason: 'runId must be a non-empty string without newlines/quotes/backslash/control chars' };
|
|
297
|
+
}
|
|
298
|
+
if (!Array.isArray(waves) || waves.length === 0) {
|
|
299
|
+
return { ok: false, reason: 'waves must be a non-empty array' };
|
|
300
|
+
}
|
|
301
|
+
for (let i = 0; i < waves.length; i++) {
|
|
302
|
+
const w = waves[i];
|
|
303
|
+
if (w === null || typeof w !== 'object' || typeof w.id !== 'string') {
|
|
304
|
+
return { ok: false, reason: 'waves[' + i + '] must be { id, plans: non-empty[] }' };
|
|
305
|
+
}
|
|
306
|
+
if (!isScriptableIdentifier(w.id)) {
|
|
307
|
+
return { ok: false, reason: 'waves[' + i + '].id must not contain newlines/quotes/backslash/control chars' };
|
|
308
|
+
}
|
|
309
|
+
if (!Array.isArray(w.plans) || w.plans.length === 0) {
|
|
310
|
+
return { ok: false, reason: 'waves[' + i + '] must have a non-empty plans array' };
|
|
311
|
+
}
|
|
312
|
+
const seenIds = new Set();
|
|
313
|
+
for (let j = 0; j < w.plans.length; j++) {
|
|
314
|
+
const p = w.plans[j];
|
|
315
|
+
if (p === null || typeof p !== 'object' || typeof p.id !== 'string' || typeof p.brief !== 'string' || !Array.isArray(p.files_modified)) {
|
|
316
|
+
return { ok: false, reason: 'waves[' + i + '].plans[' + j + '] must be { id, brief, files_modified[] }' };
|
|
317
|
+
}
|
|
318
|
+
if (!isScriptableIdentifier(p.id)) {
|
|
319
|
+
return { ok: false, reason: 'waves[' + i + '].plans[' + j + '].id must not contain newlines/quotes/backslash/control chars' };
|
|
320
|
+
}
|
|
321
|
+
if (seenIds.has(p.id)) {
|
|
322
|
+
return { ok: false, reason: 'waves[' + i + '] has duplicate plan id "' + p.id + '"' };
|
|
323
|
+
}
|
|
324
|
+
seenIds.add(p.id);
|
|
325
|
+
for (const f of p.files_modified) {
|
|
326
|
+
if (typeof f !== 'string' || f.length === 0) {
|
|
327
|
+
return { ok: false, reason: 'waves[' + i + '].plans[' + j + '].files_modified entries must be non-empty strings' };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const budgetTokens = (typeof input.budgetTokens === 'number' && Number.isFinite(input.budgetTokens) && input.budgetTokens > 0)
|
|
333
|
+
? Math.floor(input.budgetTokens)
|
|
334
|
+
: null;
|
|
335
|
+
const lines = [];
|
|
336
|
+
lines.push('// GSD Workflow script — generated by the claude-orchestration capability (#1143)');
|
|
337
|
+
lines.push('// phase: ' + phaseDir);
|
|
338
|
+
lines.push('// BETA: preview-grade; on any failure the orchestrator falls back to inline dispatch.');
|
|
339
|
+
lines.push('// Composes the SAME gsd-executor agent + worktree isolation as the inline path,');
|
|
340
|
+
lines.push('// so artifacts (SUMMARY.md) and commits are produced identically.');
|
|
341
|
+
lines.push('resumeFromRunId(' + quoteString(runId) + ')');
|
|
342
|
+
if (budgetTokens !== null) {
|
|
343
|
+
lines.push('budget(' + budgetTokens + ')');
|
|
344
|
+
}
|
|
345
|
+
lines.push('');
|
|
346
|
+
const stagesByWave = [];
|
|
347
|
+
let totalPlans = 0;
|
|
348
|
+
for (let wi = 0; wi < waves.length; wi++) {
|
|
349
|
+
const wave = waves[wi];
|
|
350
|
+
const stages = partitionStages(wave.plans);
|
|
351
|
+
stagesByWave.push(stages);
|
|
352
|
+
totalPlans += wave.plans.length;
|
|
353
|
+
lines.push('// Wave ' + wave.id);
|
|
354
|
+
for (let si = 0; si < stages.length; si++) {
|
|
355
|
+
const stagePlanIds = stages[si];
|
|
356
|
+
// Resolve back to plan objects for briefs (ids are unique within a wave — validated above).
|
|
357
|
+
const stagePlans = stagePlanIds.map((id) => wave.plans.find((p) => p.id === id));
|
|
358
|
+
if (stages.length > 1) {
|
|
359
|
+
lines.push('// Stage ' + si + (si > 0 ? ' (sequential — files_modified overlap)' : ''));
|
|
360
|
+
}
|
|
361
|
+
if (stagePlans.length === 1) {
|
|
362
|
+
const p = stagePlans[0];
|
|
363
|
+
lines.push('parallel(');
|
|
364
|
+
lines.push(' agent(' + quoteString(p.brief) + ', { agentType: "gsd-executor", isolation: "worktree" })');
|
|
365
|
+
lines.push(')');
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
lines.push('parallel(');
|
|
369
|
+
for (const p of stagePlans) {
|
|
370
|
+
lines.push(' agent(' + quoteString(p.brief) + ', { agentType: "gsd-executor", isolation: "worktree" }),');
|
|
371
|
+
}
|
|
372
|
+
// Replace trailing comma on the last agent line with nothing.
|
|
373
|
+
const lastIdx = lines.length - 1;
|
|
374
|
+
lines[lastIdx] = lines[lastIdx].replace(/,$/, '');
|
|
375
|
+
lines.push(')');
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (wi < waves.length - 1)
|
|
379
|
+
lines.push('');
|
|
380
|
+
}
|
|
381
|
+
lines.push('// Each agent writes SUMMARY.md on its worktree branch; commits land there');
|
|
382
|
+
lines.push('// and are merged by the orchestrator exactly as in inline wave dispatch.');
|
|
383
|
+
const script = lines.join('\n');
|
|
384
|
+
return {
|
|
385
|
+
ok: true,
|
|
386
|
+
script,
|
|
387
|
+
summary: {
|
|
388
|
+
waves: waves.length,
|
|
389
|
+
plans: totalPlans,
|
|
390
|
+
stagesByWave,
|
|
391
|
+
resumeRunId: runId,
|
|
392
|
+
budgetTokens,
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
module.exports = {
|
|
397
|
+
detectWorkflowBackend,
|
|
398
|
+
emitWorkflowScript,
|
|
399
|
+
compareSemver,
|
|
400
|
+
isValidSemver,
|
|
401
|
+
WORKFLOW_TOOL_FLOOR_VERSION,
|
|
402
|
+
BACKEND_VALUES,
|
|
403
|
+
WORKFLOW_RUNTIME,
|
|
404
|
+
};
|
|
@@ -283,6 +283,14 @@ exports.INIT_COMMAND_ALIASES = [
|
|
|
283
283
|
"subcommand": "new-milestone",
|
|
284
284
|
"mutation": false
|
|
285
285
|
},
|
|
286
|
+
{
|
|
287
|
+
"canonical": "init.onboard",
|
|
288
|
+
"aliases": [
|
|
289
|
+
"init onboard"
|
|
290
|
+
],
|
|
291
|
+
"subcommand": "onboard",
|
|
292
|
+
"mutation": false
|
|
293
|
+
},
|
|
286
294
|
{
|
|
287
295
|
"canonical": "init.quick",
|
|
288
296
|
"aliases": [
|
|
@@ -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]}`;
|