Merge origin/main into feat/3597-split-suites-node-matrix

Resolves conflict in .github/workflows/test.yml: keep the 6 new drift-check
steps from main (plan-scan, secrets, schema-detect, decisions,
workstream-name-policy, Shared Module hand-sync) before the split-lane test
runs from this PR. PR's dedicated `coverage` job replaces main's per-matrix
`Run tests with coverage` step.

Other conflicting files (CONTRIBUTING.md, get-shit-done/bin/lib/init.cjs,
package.json) auto-merged cleanly. Changeset files (.changeset/*) brought in
from main as adds.
This commit is contained in:
Tom Boucher
2026-05-16 13:22:07 -04:00
132 changed files with 9379 additions and 1405 deletions

View File

@@ -199,6 +199,82 @@ const { routePhasesCommand } = require('./lib/phases-command-router.cjs');
const { routeValidateCommand } = require('./lib/validate-command-router.cjs');
const { routeRoadmapCommand } = require('./lib/roadmap-command-router.cjs');
// ─── SDK bridge (Phase 6 inline family / non-family delegation) ───────────────
// For inline case blocks that have SDK counterparts (frontmatter, config, and
// non-family commands), we attempt to dispatch via executeForCjs (the sync
// bridge). CJS handlers are retained as fallback when SDK is unavailable.
//
// NOTE: migrate-config, detect-custom-files, config-path, and find-phase
// are CJS-native special cases; see comments inline.
// Shared loader for the synchronous SDK runtime bridge; see
// `bin/lib/cjs-sdk-bridge.cjs`. All canonical-command CJS dispatchers (the
// per-family routers and the non-family helper below) consume the same loader
// so a change to the SDK-load contract lands in one place.
const { tryLoadSdk: _tryLoadSdkBridge, getExecuteForCjs } = require('./lib/cjs-sdk-bridge.cjs');
/**
* Attempt SDK dispatch for a non-family command.
*
* Returns true when the SDK was available and handled the command (success or
* typed error). Returns false when the SDK is unavailable, signalling the
* caller to fall through to the CJS handler.
*
* @param {object} opts
* @param {string} opts.registryCommand - canonical command name in the SDK registry
* @param {string[]} opts.registryArgs - args to pass to the SDK handler
* @param {string} opts.legacyCommand - original gsd-tools command name (for error messages)
* @param {string[]} opts.legacyArgs - original args (for error messages)
* @param {string} opts.cwd - project dir
* @param {boolean} opts.raw - raw output mode
* @param {Function} opts.error - error reporter
* @param {Function} opts.output - output emitter (core.output)
*/
function _dispatchNonFamily({ registryCommand, registryArgs, legacyCommand, legacyArgs, cwd, raw, error, output }) {
if (!_tryLoadSdkBridge()) return false;
const result = getExecuteForCjs()({
registryCommand,
registryArgs,
legacyCommand,
legacyArgs,
// Always request typed JSON from the bridge; CJS `output(data, raw)` handles
// user-facing rendering. Passing `mode: 'raw'` would make the bridge
// pre-render result.data to a JSON string that the CJS output path then
// double-stringifies (returning a JSON string of a JSON string).
mode: 'json',
projectDir: cwd,
workstream: process.env.GSD_WORKSTREAM || undefined,
});
if (!result.ok) {
const message = (result.errorDetails && result.errorDetails.message)
|| `${legacyCommand} (${registryCommand}) failed (${result.errorKind})`;
// Propagate the structured reason code through to CJS `error()` so the
// `--json-errors` JSON-shaped stderr carries the typed reason (e.g.
// 'config_key_not_found') instead of the generic 'unknown'. Handlers
// tag the GSDError with `.reason` and the worker forwards it via
// errorDetails.reason. (Bugs #2943, #3086.)
const reason = result.errorDetails && result.errorDetails.reason;
if (reason) {
error(message, reason);
} else {
error(message);
}
return true; // handled (error reported)
}
// CJS parity for --raw output (config.cjs:525 `output(value, raw, String(value))`):
// when the caller asked for --raw and the SDK returned a scalar, pass that
// scalar through as `rawValue` so core.output() emits the bare string
// representation instead of JSON-stringifying it. Non-scalar shapes fall
// through to the structured JSON path, matching `output(obj, raw)`.
const data = result.data;
if (raw && (typeof data === 'string' || typeof data === 'number' || typeof data === 'boolean')) {
output(data, raw, String(data));
} else {
output(data, raw);
}
return true;
}
// ─── Arg parsing helpers ──────────────────────────────────────────────────────
/**
@@ -524,7 +600,19 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
}
case 'find-phase': {
phase.cmdFindPhase(cwd, args[1], raw);
// Phase 6 (#3575): dispatch via SDK executeForCjs when available.
// SDK handler: findPhase in sdk/src/query/phase.ts.
const handled = _dispatchNonFamily({
registryCommand: 'find-phase',
registryArgs: args.slice(1),
legacyCommand: 'find-phase',
legacyArgs: args.slice(1),
cwd,
raw,
error,
output: core.output,
});
if (!handled) phase.cmdFindPhase(cwd, args[1], raw);
break;
}
@@ -590,8 +678,31 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
}
case 'frontmatter': {
// Phase 6 (#3575): dispatch via SDK executeForCjs when available.
// SDK handler: sdk/src/query/frontmatter.ts + frontmatter-mutation.ts.
// CJS fallback: frontmatter.cjs (cooperating sibling).
const subcommand = args[1];
const file = args[2];
const FRONTMATTER_SDK_MAP = {
get: 'frontmatter.get',
set: 'frontmatter.set',
merge: 'frontmatter.merge',
validate: 'frontmatter.validate',
};
if (subcommand in FRONTMATTER_SDK_MAP) {
const handled = _dispatchNonFamily({
registryCommand: FRONTMATTER_SDK_MAP[subcommand],
registryArgs: args.slice(2),
legacyCommand: 'frontmatter',
legacyArgs: args.slice(1),
cwd,
raw,
error,
output: core.output,
});
if (handled) break;
}
// CJS fallback (SDK unavailable or unknown subcommand)
if (subcommand === 'get') {
frontmatter.cmdFrontmatterGet(cwd, file, parseNamedArgs(args, ['field']).field, raw);
} else if (subcommand === 'set') {
@@ -619,12 +730,36 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
}
case 'generate-slug': {
commands.cmdGenerateSlug(args[1], raw);
// Phase 6 (#3575): dispatch via SDK executeForCjs when available.
// SDK handler: generateSlug in sdk/src/query/utils.ts.
const handled = _dispatchNonFamily({
registryCommand: 'generate-slug',
registryArgs: args.slice(1),
legacyCommand: 'generate-slug',
legacyArgs: args.slice(1),
cwd,
raw,
error,
output: core.output,
});
if (!handled) commands.cmdGenerateSlug(args[1], raw);
break;
}
case 'current-timestamp': {
commands.cmdCurrentTimestamp(args[1] || 'full', raw);
// Phase 6 (#3575): dispatch via SDK executeForCjs when available.
// SDK handler: currentTimestamp in sdk/src/query/utils.ts.
const handled = _dispatchNonFamily({
registryCommand: 'current-timestamp',
registryArgs: args.slice(1),
legacyCommand: 'current-timestamp',
legacyArgs: args.slice(1),
cwd,
raw,
error,
output: core.output,
});
if (!handled) commands.cmdCurrentTimestamp(args[1] || 'full', raw);
break;
}
@@ -639,38 +774,112 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
}
case 'config-ensure-section': {
config.cmdConfigEnsureSection(cwd, raw);
// Phase 6 (#3575): dispatch via SDK executeForCjs. The catalog rebinds
// 'config-ensure-section' to configNewProject in
// sdk/src/query/command-static-catalog-foundation.ts, restoring the
// legacy "no-arg full default init" contract on the SDK path
// (configEnsureSection itself stays available as an unbound single-
// section helper for future SDK callers).
const handled = _dispatchNonFamily({
registryCommand: 'config-ensure-section',
registryArgs: args.slice(1),
legacyCommand: 'config-ensure-section',
legacyArgs: args.slice(1),
cwd,
raw,
error,
output: core.output,
});
if (!handled) config.cmdConfigEnsureSection(cwd, raw);
break;
}
case 'config-set': {
config.cmdConfigSet(cwd, args[1], args[2], raw);
// Phase 6 (#3575): dispatch via SDK executeForCjs when available.
const handled = _dispatchNonFamily({
registryCommand: 'config-set',
registryArgs: args.slice(1),
legacyCommand: 'config-set',
legacyArgs: args.slice(1),
cwd,
raw,
error,
output: core.output,
});
if (!handled) config.cmdConfigSet(cwd, args[1], args[2], raw);
break;
}
case "config-set-model-profile": {
config.cmdConfigSetModelProfile(cwd, args[1], raw);
// Phase 6 (#3575): dispatch via SDK executeForCjs when available.
const handled = _dispatchNonFamily({
registryCommand: 'config-set-model-profile',
registryArgs: args.slice(1),
legacyCommand: 'config-set-model-profile',
legacyArgs: args.slice(1),
cwd,
raw,
error,
output: core.output,
});
if (!handled) config.cmdConfigSetModelProfile(cwd, args[1], raw);
break;
}
case 'config-get': {
config.cmdConfigGet(cwd, args[1], raw, defaultValue);
// Phase 6 (#3575): dispatch via SDK executeForCjs when available.
// The SDK handler supports --default via the registry args (args.slice(1)
// contains the key; defaultValue is handled by the SDK via the --default
// flag which was already stripped from args and held in defaultValue).
// Pass the full original args.slice(1) so the SDK sees the key; the
// defaultValue from the flag is in the global defaultValue variable above.
// Since the SDK handler reads --default from registryArgs, re-inject it.
const configGetSdkArgs = defaultValue !== undefined
? [args[1], '--default', defaultValue]
: args.slice(1);
const handled = _dispatchNonFamily({
registryCommand: 'config-get',
registryArgs: configGetSdkArgs,
legacyCommand: 'config-get',
legacyArgs: args.slice(1),
cwd,
raw,
error,
output: core.output,
});
if (!handled) config.cmdConfigGet(cwd, args[1], raw, defaultValue);
break;
}
case 'config-new-project': {
config.cmdConfigNewProject(cwd, args[1], raw);
// Phase 6 (#3575): dispatch via SDK executeForCjs when available.
const handled = _dispatchNonFamily({
registryCommand: 'config-new-project',
registryArgs: args.slice(1),
legacyCommand: 'config-new-project',
legacyArgs: args.slice(1),
cwd,
raw,
error,
output: core.output,
});
if (!handled) config.cmdConfigNewProject(cwd, args[1], raw);
break;
}
case 'config-path': {
// CJS-native: config-path returns the filesystem path to config.json.
// The SDK handler (configPath) also exists but requires a projectDir that
// is already resolved. Both produce identical output; keeping CJS here is
// simpler and avoids sync-bridge overhead for a trivial path lookup.
config.cmdConfigPath(cwd, raw);
break;
}
case 'migrate-config': {
// Explicit on-disk migration of legacy config keys to canonical shape (#3536).
// Wraps Configuration Module migrateOnDisk(); idempotent. async — must await.
// CJS-native: migrate-config wraps the Configuration Module migrateOnDisk()
// which is async and mutates the filesystem. No SDK counterpart exists in
// the command registry (it's a one-shot migration utility). Must await.
await config.cmdMigrateConfig(cwd, raw);
break;
}
@@ -1077,7 +1286,19 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
// ─── Documentation ────────────────────────────────────────────────────
case 'docs-init': {
docs.cmdDocsInit(cwd, raw);
// Phase 6 (#3575): dispatch via SDK executeForCjs when available.
// SDK handler: docsInit in sdk/src/query/docs-init.ts.
const handled = _dispatchNonFamily({
registryCommand: 'docs-init',
registryArgs: args.slice(1),
legacyCommand: 'docs-init',
legacyArgs: args.slice(1),
cwd,
raw,
error,
output: core.output,
});
if (!handled) docs.cmdDocsInit(cwd, raw);
break;
}
@@ -1110,6 +1331,11 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
}
// ─── detect-custom-files ───────────────────────────────────────────────
// CJS-native: no SDK counterpart exists in the command registry.
// detect-custom-files reads a gsd-file-manifest.json against the
// live filesystem to identify user-added files. It is installer-specific
// logic that has no async query equivalent in the SDK.
//
// Detect user-added files inside GSD-managed directories that are not
// tracked in gsd-file-manifest.json. Used by the update workflow to back
// up custom files before the installer wipes those directories.

View File

@@ -0,0 +1,136 @@
'use strict';
/**
* CJS↔SDK Sync Runtime Bridge Adapter — Phase 5/6 of #3524.
*
* Single shared loader for the synchronous SDK runtime bridge that every CJS
* command-router family file and `gsd-tools.cjs` non-family dispatcher
* delegates through. Centralizing the load prevents the seven-fold duplicated
* `tryLoadSdk` blocks that existed across the routers from drifting against
* each other (the exact anti-pattern the Phase 6 hand-sync lint is meant to
* stop, applied to the SDK-load logic itself).
*
* Load path policy: the bridge resolves the bundled SDK by package-relative
* filesystem path, NOT by the `@gsd-build/sdk` package name. The package name
* is not installed in the root `node_modules` (it lives as a sibling workspace
* package, not a dependency), and the SDK's public entry doesn't re-export
* `executeForCjs` or `formatStateLoadRawStdout` anyway. Using the relative
* path means the loader works identically in (a) the development checkout
* (`<repo>/sdk/dist/...`) and (b) the published package layout
* (`node_modules/get-shit-done-cc/sdk/dist/...`) because the `files` array in
* `package.json` keeps `sdk/dist` at the same path inside the published
* tarball.
*
* The previous implementation used `require('@gsd-build/sdk')`, which always
* failed because the package was unresolvable from the consumer location.
* That cached `_loadFailed = true` for the lifetime of the process and made
* every router silently fall through to CJS — defeating Phase 5/6's entire
* goal. The integration test at `tests/cjs-sdk-bridge-integration.test.cjs`
* locks the load-success invariant so this regression cannot recur.
*
* Usage:
* const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs');
* if (tryLoadSdk()) {
* const result = getExecuteForCjs()({ ... });
* }
*
* Plus `getFormatStateLoadRawStdout()` for the `state load --raw` adapter and
* `getSdkModule()` for routers that need the raw runtime-bridge-sync module.
*/
const path = require('path');
// Computed once at module load. Resolves the bundled SDK relative to this
// file's on-disk location, so both dev and post-install layouts work.
// <root>/get-shit-done/bin/lib/cjs-sdk-bridge.cjs
// <root>/sdk/dist/runtime-bridge-sync/index.js
// <root>/sdk/dist/query/state-project-load.js
const RUNTIME_BRIDGE_PATH = path.resolve(
__dirname,
'..',
'..',
'..',
'sdk',
'dist',
'runtime-bridge-sync',
'index.js',
);
const STATE_PROJECT_LOAD_PATH = path.resolve(
__dirname,
'..',
'..',
'..',
'sdk',
'dist',
'query',
'state-project-load.js',
);
let _runtimeBridge = null;
let _formatStateLoadRawStdout = null;
let _loadFailed = false;
/**
* Load the bundled SDK runtime bridge once and cache the result. Returns true
* on success, false if the dist artifacts are missing (e.g. `npm run
* build:sdk` has not been executed in a fresh dev checkout) or if the
* expected `executeForCjs` export is absent. Cached result is reused on
* subsequent calls.
*/
function tryLoadSdk() {
if (_runtimeBridge) return true;
if (_loadFailed) return false;
try {
// eslint-disable-next-line global-require
const bridge = require(RUNTIME_BRIDGE_PATH);
if (typeof bridge.executeForCjs !== 'function') {
_loadFailed = true;
return false;
}
// eslint-disable-next-line global-require
const stateProjectLoad = require(STATE_PROJECT_LOAD_PATH);
if (typeof stateProjectLoad.formatStateLoadRawStdout !== 'function') {
_loadFailed = true;
return false;
}
_runtimeBridge = bridge;
_formatStateLoadRawStdout = stateProjectLoad.formatStateLoadRawStdout;
return true;
} catch {
_loadFailed = true;
return false;
}
}
/**
* Returns the cached `executeForCjs` function, or null if `tryLoadSdk()` has
* not been called or returned false. Callers must check `tryLoadSdk()` first.
*/
function getExecuteForCjs() {
return _runtimeBridge ? _runtimeBridge.executeForCjs : null;
}
/**
* Returns the cached `formatStateLoadRawStdout` function, or null. Used by
* the state command router for the `state load --raw` adapter that projects
* SDK return data into the legacy key=value lines format.
*/
function getFormatStateLoadRawStdout() {
return _formatStateLoadRawStdout;
}
/**
* Returns the cached runtime-bridge-sync module object after a successful
* `tryLoadSdk()`, or null. Provided for callers that need additional named
* exports beyond `executeForCjs`.
*/
function getSdkModule() {
return _runtimeBridge;
}
module.exports = {
tryLoadSdk,
getExecuteForCjs,
getFormatStateLoadRawStdout,
getSdkModule,
};

View File

@@ -230,14 +230,6 @@ const VERIFY_COMMAND_ALIASES = [
],
"subcommand": "schema-drift",
"mutation": false
},
{
"canonical": "verify.codebase-drift",
"aliases": [
"verify codebase-drift"
],
"subcommand": "codebase-drift",
"mutation": false
}
];
@@ -651,20 +643,6 @@ const NON_FAMILY_COMMAND_ALIASES = [
"aliases": [],
"mutation": true
},
{
"canonical": "intel.patch-meta",
"aliases": [
"intel patch-meta"
],
"mutation": true
},
{
"canonical": "intel.snapshot",
"aliases": [
"intel snapshot"
],
"mutation": true
},
{
"canonical": "learnings.copy",
"aliases": [
@@ -835,4 +813,4 @@ module.exports = {
PHASES_SUBCOMMANDS,
VALIDATE_SUBCOMMANDS,
ROADMAP_SUBCOMMANDS,
};
};

View File

@@ -1012,6 +1012,7 @@ function cmdCheckCommit(cwd, raw) {
}
module.exports = {
determinePhaseStatus,
cmdGenerateSlug,
cmdCurrentTimestamp,
cmdListTodos,

View File

@@ -1,48 +1,19 @@
'use strict';
/**
* Shared parser for CONTEXT.md `<decisions>` blocks.
* Decisions Module — CJS adapter.
*
* Used by:
* - gap-checker.cjs (#2493 post-planning gap analysis)
* - intended for #2492 (plan-phase decision gate, verify-phase decision validator)
* The implementation is generated from sdk/src/query/decisions.ts and
* lives in decisions.generated.cjs. This file is a thin re-export so
* that existing call sites (gap-checker.cjs, tests) can continue to
* require('./decisions') unchanged.
*
* Format produced by discuss-phase.md:
* Exports (from generated file):
* - parseDecisions(content) — parse <decisions> blocks, returns {id, text, category, tags, trackable}[]
* CJS callers using only {id, text} safely ignore the extra fields.
* Accepts both numeric (D-42) and alphanumeric (D-INFRA-01) IDs.
*
* <decisions>
* ## Implementation Decisions
*
* ### Category
* - **D-01:** Decision text
* - **D-02:** Another decision
* </decisions>
*
* D-IDs outside the <decisions> block are ignored. Missing block returns [].
* Regenerate: cd sdk && npm run gen:decisions
*/
/**
* Parse the <decisions> section of a CONTEXT.md string.
*
* @param {string|null|undefined} contextMd - File contents, may be empty/missing.
* @returns {Array<{id: string, text: string}>}
*/
function parseDecisions(contextMd) {
if (!contextMd || typeof contextMd !== 'string') return [];
const blockMatch = contextMd.match(/<decisions>([\s\S]*?)<\/decisions>/);
if (!blockMatch) return [];
const block = blockMatch[1];
const decisionRe = /^\s*-\s*\*\*(D-[A-Za-z0-9_-]+):\*\*\s*(.+?)\s*$/gm;
const out = [];
const seen = new Set();
let m;
while ((m = decisionRe.exec(block)) !== null) {
const id = m[1];
if (seen.has(id)) continue;
seen.add(id);
out.push({ id, text: m[2] });
}
return out;
}
module.exports = { parseDecisions };
module.exports = require('./decisions.generated.cjs');

View File

@@ -0,0 +1,121 @@
'use strict';
/**
* GENERATED FILE — DO NOT EDIT.
*
* Source: sdk/src/query/decisions.ts
* Regenerate: cd sdk && npm run gen:decisions
*
* Shared parser for CONTEXT.md <decisions> blocks.
* Accepts both numeric (D-42) and alphanumeric (D-INFRA-01) IDs.
* Returns {id, text, category, tags, trackable} per decision.
* CJS callers that only use {id, text} safely ignore the extra fields.
*/
const DISCRETION_HEADINGS = new Set([
"claude's discretion",
'claudes discretion',
'claude discretion',
]);
const NON_TRACKABLE_TAGS = new Set(['informational', 'folded', 'deferred']);
/**
* Strip fenced code blocks from `content` so example `<decisions>` snippets
* inside ```` ``` ```` do not pollute the parser (review F11).
*/
function stripFencedCode(content) {
return content.replace(/```[\s\S]*?```/g, ' ').replace(/~~~[\s\S]*?~~~/g, ' ');
}
/**
* Extract the inner text of EVERY `<decisions>...</decisions>` block in
* order, concatenated by `\n\n`. Returns null when no block is present.
*
* CONTEXT.md may legitimately contain more than one block (for example, a
* "current decisions" block plus a "carry-over from prior phase" block);
* dropping all-but-the-first silently lost the second batch (review F13).
*/
function extractDecisionsBlock(content) {
const cleaned = stripFencedCode(content);
const matches = [...cleaned.matchAll(/<decisions>([\s\S]*?)<\/decisions>/g)];
if (matches.length === 0)
return null;
return matches.map((m) => m[1]).join('\n\n');
}
/**
* Parse trackable decisions from CONTEXT.md content.
*
* Returns ALL D-NN decisions found inside `<decisions>` (including
* non-trackable ones, with `trackable: false`). Callers that only want the
* gate-enforced decisions should filter `.filter(d => d.trackable)`.
*/
function parseDecisions(content) {
if (!content || typeof content !== 'string')
return [];
const block = extractDecisionsBlock(content);
if (block === null)
return [];
const lines = block.split(/\r?\n/);
const out = [];
let category = '';
let inDiscretion = false;
// Bullet line: `- **D-NN[ [tags]]:** text`
// Phase 6 (#3575): aligned to CJS regex — accepts alphanumeric IDs (D-01, D-INFRA-01, D-FOO_BAR)
// in addition to numeric-only IDs (D-42). The first character after `D-` must
// be alphanumeric, so malformed shapes like `D--foo` or `D-_bar` are rejected.
// CJS callers consume {id, text} and ignore the optional extras.
const bulletRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?\s*:\*\*\s*(.*)$/;
let current = null;
const flush = () => {
if (current) {
current.text = current.text.trim();
out.push(current);
current = null;
}
};
for (const line of lines) {
const trimmed = line.trim();
// Track category headings (`### Heading`)
const headingMatch = trimmed.match(/^###\s+(.+?)\s*$/);
if (headingMatch) {
flush();
category = headingMatch[1];
// Strip the full unicode-quote family so any rendering of "Claude's
// Discretion" (ASCII apostrophe, curly U+2019, U+2018, U+201A, U+201B,
// double-quote variants U+201C/D/E/F, etc.) collapses to the same key
// (review F20).
const normalized = category
.toLowerCase()
.replace(/[\u2018\u2019\u201A\u201B\u201C\u201D\u201E\u201F'"`]/g, '')
.trim();
inDiscretion = DISCRETION_HEADINGS.has(normalized);
continue;
}
const bulletMatch = line.match(bulletRe);
if (bulletMatch) {
flush();
const id = `D-${bulletMatch[1]}`;
const tags = bulletMatch[2]
? bulletMatch[2]
.split(',')
.map((t) => t.trim().toLowerCase())
.filter(Boolean)
: [];
const trackable = !inDiscretion && !tags.some((t) => NON_TRACKABLE_TAGS.has(t));
current = { id, text: bulletMatch[3], category, tags, trackable };
continue;
}
// Continuation line for current decision (indented with space OR tab,
// non-bullet, non-empty) — tab indentation must work too (review F12).
if (current && trimmed !== '' && !trimmed.startsWith('-') && /^[ \t]/.test(line)) {
current.text += ' ' + trimmed;
continue;
}
// Blank line or unrelated content terminates the current decision
if (trimmed === '') {
flush();
}
}
flush();
return out;
}
module.exports = { parseDecisions };

View File

@@ -1,68 +1,172 @@
'use strict';
const { INIT_SUBCOMMANDS } = require('./command-aliases.generated.cjs');
const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs');
const { output } = require('./core.cjs');
// ─── SDK bridge (Phase 6) — shared loader via cjs-sdk-bridge.cjs ──────────────
const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs');
/**
* Manifest-backed init subcommand router.
* Keeps gsd-tools.cjs thin while preserving existing command semantics.
*
* Phase 6: all init.* subcommands have SDK equivalents and are dispatched
* via executeForCjs (the sync bridge). CJS fallback retained when:
* - GSD_WORKSTREAM is active (workstream-scoped requests fall through to CJS).
* - SDK is unavailable (build not present).
*
* CJS-only subcommands: none.
* SDK-only (unsupported in CJS router): none.
*/
function routeInitCommand({ init, args, cwd, raw, parseNamedArgs, error }) {
const workflow = args[1];
switch (workflow) {
case 'execute-phase': {
const { validate: epValidate, tdd: epTdd } = parseNamedArgs(args, [], ['validate', 'tdd']);
init.cmdInitExecutePhase(cwd, args[2], raw, { validate: epValidate, tdd: epTdd });
break;
}
case 'plan-phase': {
const { validate: ppValidate, tdd: ppTdd } = parseNamedArgs(args, [], ['validate', 'tdd']);
init.cmdInitPlanPhase(cwd, args[2], raw, { validate: ppValidate, tdd: ppTdd });
break;
}
case 'new-project':
init.cmdInitNewProject(cwd, raw);
break;
case 'new-milestone':
init.cmdInitNewMilestone(cwd, raw);
break;
case 'quick':
init.cmdInitQuick(cwd, args.slice(2).join(' '), raw);
break;
case 'ingest-docs':
init.cmdInitIngestDocs(cwd, raw);
break;
case 'resume':
init.cmdInitResume(cwd, raw);
break;
case 'verify-work':
init.cmdInitVerifyWork(cwd, args[2], raw);
break;
case 'phase-op':
init.cmdInitPhaseOp(cwd, args[2], raw);
break;
case 'todos':
init.cmdInitTodos(cwd, args[2], raw);
break;
case 'milestone-op':
init.cmdInitMilestoneOp(cwd, raw);
break;
case 'map-codebase':
init.cmdInitMapCodebase(cwd, raw);
break;
case 'progress':
init.cmdInitProgress(cwd, raw);
break;
case 'manager':
init.cmdInitManager(cwd, raw);
break;
case 'new-workspace':
init.cmdInitNewWorkspace(cwd, raw);
break;
case 'list-workspaces':
init.cmdInitListWorkspaces(cwd, raw);
break;
case 'remove-workspace':
init.cmdInitRemoveWorkspace(cwd, args[2], raw);
break;
default:
error(`Unknown init workflow: ${workflow}\nAvailable: ${INIT_SUBCOMMANDS.join(', ')}`);
const activeWorkstream = process.env.GSD_WORKSTREAM;
const sdkAvailable = !activeWorkstream && tryLoadSdk();
function sdkHandler(registryCommand, registryArgs, legacyArgs, cjsFallback) {
if (!sdkAvailable) return cjsFallback;
return () => {
const result = getExecuteForCjs()({
registryCommand,
registryArgs,
legacyCommand: 'init',
legacyArgs,
// #3631: under --raw, request mode:'raw' so the bridge runs the SDK's
// raw projection (formatQueryRawOutput) and returns the scalar string
// CJS callers used to print. We then bypass output()'s JSON-stringify
// path by passing rawValue (the third positional). With mode:'json',
// output() emits the JSON IR as before.
mode: raw ? 'raw' : 'json',
projectDir: cwd,
});
if (!result.ok) {
error(result.errorDetails && result.errorDetails.message
? result.errorDetails.message
: `init ${registryCommand} failed (${result.errorKind})`);
return;
}
if (raw) {
output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? ''));
} else {
output(result.data);
}
};
}
routeCjsCommandFamily({
args,
subcommands: INIT_SUBCOMMANDS,
unsupported: {},
error,
unknownMessage: (_subcommand, available) => `Unknown init workflow: ${_subcommand}\nAvailable: ${available.join(', ')}`,
handlers: {
'execute-phase': sdkHandler(
'init.execute-phase',
args.slice(2),
args.slice(1),
() => {
const { validate: epValidate, tdd: epTdd } = parseNamedArgs(args, [], ['validate', 'tdd']);
init.cmdInitExecutePhase(cwd, args[2], raw, { validate: epValidate, tdd: epTdd });
},
),
'plan-phase': sdkHandler(
'init.plan-phase',
args.slice(2),
args.slice(1),
() => {
const { validate: ppValidate, tdd: ppTdd } = parseNamedArgs(args, [], ['validate', 'tdd']);
init.cmdInitPlanPhase(cwd, args[2], raw, { validate: ppValidate, tdd: ppTdd });
},
),
'new-project': sdkHandler(
'init.new-project',
args.slice(2),
args.slice(1),
() => init.cmdInitNewProject(cwd, raw),
),
'new-milestone': sdkHandler(
'init.new-milestone',
args.slice(2),
args.slice(1),
() => init.cmdInitNewMilestone(cwd, raw),
),
quick: sdkHandler(
'init.quick',
args.slice(2),
args.slice(1),
() => init.cmdInitQuick(cwd, args.slice(2).join(' '), raw),
),
'ingest-docs': sdkHandler(
'init.ingest-docs',
args.slice(2),
args.slice(1),
() => init.cmdInitIngestDocs(cwd, raw),
),
resume: sdkHandler(
'init.resume',
args.slice(2),
args.slice(1),
() => init.cmdInitResume(cwd, raw),
),
'verify-work': sdkHandler(
'init.verify-work',
args.slice(2),
args.slice(1),
() => init.cmdInitVerifyWork(cwd, args[2], raw),
),
'phase-op': sdkHandler(
'init.phase-op',
args.slice(2),
args.slice(1),
() => init.cmdInitPhaseOp(cwd, args[2], raw),
),
todos: sdkHandler(
'init.todos',
args.slice(2),
args.slice(1),
() => init.cmdInitTodos(cwd, args[2], raw),
),
'milestone-op': sdkHandler(
'init.milestone-op',
args.slice(2),
args.slice(1),
() => init.cmdInitMilestoneOp(cwd, raw),
),
'map-codebase': sdkHandler(
'init.map-codebase',
args.slice(2),
args.slice(1),
() => init.cmdInitMapCodebase(cwd, raw),
),
progress: sdkHandler(
'init.progress',
args.slice(2),
args.slice(1),
() => init.cmdInitProgress(cwd, raw),
),
// Keep manager on CJS for now so runtime-specific command rendering
// (e.g. $gsd-* for codex) stays consistent with runtime-slash helpers.
manager: () => init.cmdInitManager(cwd, raw),
'new-workspace': sdkHandler(
'init.new-workspace',
args.slice(2),
args.slice(1),
() => init.cmdInitNewWorkspace(cwd, raw),
),
'list-workspaces': sdkHandler(
'init.list-workspaces',
args.slice(2),
args.slice(1),
() => init.cmdInitListWorkspaces(cwd, raw),
),
'remove-workspace': sdkHandler(
'init.remove-workspace',
args.slice(2),
args.slice(1),
() => init.cmdInitRemoveWorkspace(cwd, args[2], raw),
),
},
});
}
module.exports = {

View File

@@ -11,6 +11,7 @@ const { maskIfSecret } = require('./secrets.cjs');
const scanPhasePlans = require('./plan-scan.cjs');
const { stateExtractField } = require('./state-document.cjs');
const { formatGsdSlash, resolveRuntime } = require('./runtime-slash.cjs');
const { determinePhaseStatus } = require('./commands.cjs');
// Accept all bold/colon variants of the Requirements header (#2769):
// **Requirements:** / **Requirements**: / **Requirements** : render the
@@ -296,6 +297,20 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
padded_phase: phaseNumberPlan ? normalizePhaseName(phaseNumberPlan) : null,
phase_req_ids,
// #3569: surface phase lifecycle status so /gsd:plan-phase can short-circuit
// on closed (Complete) phases instead of silently replanning over shipped
// code. Reuses determinePhaseStatus — the project-wide vocabulary
// (Pending | Planned | In Progress | Executed | Complete | Needs Review).
// No directory yet → Pending (phase has not been started).
phase_status: phaseDirPlan
? determinePhaseStatus(
phaseInfo?.plans?.length || 0,
phaseInfo?.summaries?.length || 0,
path.join(cwd, phaseDirPlan),
'Pending',
)
: 'Pending',
// Existing artifacts
has_research: phaseInfo?.has_research || false,
has_context: phaseInfo?.has_context || false,

View File

@@ -2,8 +2,61 @@
const { PHASE_SUBCOMMANDS } = require('./command-aliases.generated.cjs');
const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs');
const { output } = require('./core.cjs');
// ─── SDK bridge (Phase 6) — shared loader via cjs-sdk-bridge.cjs ──────────────
const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs');
/**
* Manifest-backed phase subcommand router.
* Keeps gsd-tools.cjs thin while preserving existing command semantics.
*
* Phase 6: all CJS-handled phase subcommands are dispatched via executeForCjs
* when the SDK is available. CJS fallback retained when:
* - GSD_WORKSTREAM is active (workstream-scoped requests fall through to CJS).
* - SDK is unavailable (build not present).
*
* SDK-only (unsupported in CJS router):
* - list-plans: SDK-only.
* - list-artifacts: SDK-only.
* - scaffold: routed through top-level scaffold command.
*
* CJS-only subcommands: none.
*/
function routePhaseCommand({ phase, args, cwd, raw, error }) {
const activeWorkstream = process.env.GSD_WORKSTREAM;
const sdkAvailable = !activeWorkstream && tryLoadSdk();
function sdkHandler(registryCommand, registryArgs, legacyArgs, cjsFallback) {
if (!sdkAvailable) return cjsFallback;
return () => {
// #3631: under --raw, request mode:'raw' so the bridge runs the SDK's
// raw projection (formatQueryRawOutput) and returns the scalar string
// CJS callers used to print. We then bypass output()'s JSON-stringify
// path by passing rawValue (the third positional). With mode:'json',
// output() emits the JSON IR as before.
const result = getExecuteForCjs()({
registryCommand,
registryArgs,
legacyCommand: 'phase',
legacyArgs,
mode: raw ? 'raw' : 'json',
projectDir: cwd,
});
if (!result.ok) {
error(result.errorDetails && result.errorDetails.message
? result.errorDetails.message
: `phase ${registryCommand} failed (${result.errorKind})`);
return;
}
if (raw) {
output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? ''));
} else {
output(result.data);
}
};
}
routeCjsCommandFamily({
args,
subcommands: PHASE_SUBCOMMANDS,
@@ -16,77 +69,115 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
unknownMessage: (_subcommand, available) => `Unknown phase subcommand. Available: ${available.join(', ')}`,
handlers: {
'mvp-mode': () => phase.cmdPhaseMvpMode(cwd, args.slice(2), raw),
'next-decimal': () => phase.cmdPhaseNextDecimal(cwd, args[2], raw),
add: () => {
let customId = null;
const descArgs = [];
for (let i = 2; i < args.length; i++) {
const token = args[i];
if (token === '--raw') {
continue;
}
if (token === '--id') {
const id = args[i + 1];
if (!id || id.startsWith('--')) {
error('--id requires a value');
'next-decimal': sdkHandler(
'phase.next-decimal',
args.slice(2),
args.slice(1),
() => phase.cmdPhaseNextDecimal(cwd, args[2], raw),
),
add: sdkHandler(
'phase.add',
args.slice(2),
args.slice(1),
() => {
let customId = null;
const descArgs = [];
for (let i = 2; i < args.length; i++) {
const token = args[i];
if (token === '--raw') {
continue;
}
if (token === '--id') {
const id = args[i + 1];
if (!id || id.startsWith('--')) {
error('--id requires a value');
return;
}
customId = id;
i++;
} else if (token.startsWith('--')) {
error(`phase add does not support ${token}`);
return;
} else {
descArgs.push(token);
}
}
phase.cmdPhaseAdd(cwd, descArgs.join(' '), raw, customId);
},
),
'add-batch': sdkHandler(
'phase.add-batch',
args.slice(2),
args.slice(1),
() => {
const descFlagIdx = args.indexOf('--descriptions');
let descriptions;
if (descFlagIdx !== -1) {
const rawDescriptions = args[descFlagIdx + 1];
if (!rawDescriptions || rawDescriptions.startsWith('--')) {
error('--descriptions must be a JSON array');
return;
}
try {
descriptions = JSON.parse(rawDescriptions);
} catch {
error('--descriptions must be a JSON array');
return;
}
if (!Array.isArray(descriptions)) {
error('--descriptions must be a JSON array');
return;
}
customId = id;
i++;
} else if (token.startsWith('--')) {
error(`phase add does not support ${token}`);
} else {
descArgs.push(token);
descriptions = args.slice(2).filter(a => a !== '--raw');
}
}
phase.cmdPhaseAdd(cwd, descArgs.join(' '), raw, customId);
},
'add-batch': () => {
const descFlagIdx = args.indexOf('--descriptions');
let descriptions;
if (descFlagIdx !== -1) {
const rawDescriptions = args[descFlagIdx + 1];
if (!rawDescriptions || rawDescriptions.startsWith('--')) {
error('--descriptions must be a JSON array');
phase.cmdPhaseAddBatch(cwd, descriptions, raw);
},
),
insert: sdkHandler(
'phase.insert',
args.slice(2),
args.slice(1),
() => {
if (args.includes('--dry-run')) {
error('phase insert does not support --dry-run');
return;
}
try {
descriptions = JSON.parse(rawDescriptions);
} catch {
error('--descriptions must be a JSON array');
phase.cmdPhaseInsert(cwd, args[2], args.slice(3).join(' '), raw);
},
),
remove: sdkHandler(
'phase.remove',
args.slice(2),
args.slice(1),
() => {
const removeArgs = args.slice(2).filter(token => token !== '--raw');
let forceFlag = false;
const positional = [];
for (const token of removeArgs) {
if (token === '--force') {
forceFlag = true;
continue;
}
if (token.startsWith('--')) {
error(`phase remove does not support ${token}`);
return;
}
positional.push(token);
}
if (!Array.isArray(descriptions)) {
error('--descriptions must be a JSON array');
if (positional.length !== 1) {
error('phase remove accepts exactly one phase number');
return;
}
} else {
descriptions = args.slice(2).filter(a => a !== '--raw');
}
phase.cmdPhaseAddBatch(cwd, descriptions, raw);
},
insert: () => {
if (args.includes('--dry-run')) {
error('phase insert does not support --dry-run');
}
phase.cmdPhaseInsert(cwd, args[2], args.slice(3).join(' '), raw);
},
remove: () => {
const removeArgs = args.slice(2).filter(token => token !== '--raw');
let forceFlag = false;
const positional = [];
for (const token of removeArgs) {
if (token === '--force') {
forceFlag = true;
continue;
}
if (token.startsWith('--')) {
error(`phase remove does not support ${token}`);
}
positional.push(token);
}
if (positional.length > 1) {
error('phase remove accepts exactly one phase number');
}
phase.cmdPhaseRemove(cwd, positional[0], { force: forceFlag }, raw);
},
complete: () => phase.cmdPhaseComplete(cwd, args[2], raw),
phase.cmdPhaseRemove(cwd, positional[0], { force: forceFlag }, raw);
},
),
complete: sdkHandler(
'phase.complete',
args.slice(2),
args.slice(1),
() => phase.cmdPhaseComplete(cwd, args[2], raw),
),
},
});
}

View File

@@ -2,34 +2,92 @@
const { PHASES_SUBCOMMANDS } = require('./command-aliases.generated.cjs');
const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs');
const { output } = require('./core.cjs');
// ─── SDK bridge (Phase 6) — shared loader via cjs-sdk-bridge.cjs ──────────────
const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs');
/**
* Manifest-backed phases subcommand router.
* Keeps gsd-tools.cjs thin while preserving current CJS semantics:
* - list
* - clear
* Keeps gsd-tools.cjs thin while preserving current CJS semantics.
*
* Note: `archive` is currently SDK-only (`phases.archive` handler in SDK query
* registry). CJS `gsd-tools phases` intentionally supports list/clear only.
* Phase 6: phases.list and phases.clear are dispatched via executeForCjs when
* the SDK is available. CJS fallback retained when:
* - GSD_WORKSTREAM is active (workstream-scoped requests fall through to CJS).
* - SDK is unavailable (build not present).
*
* SDK-only (not in CJS router, treated as unknown):
* - archive: `phases archive` is SDK-only (`phases.archive` handler in SDK
* query registry). CJS `gsd-tools phases` intentionally supports list/clear only.
* `archive` is excluded from the subcommands list so it falls through to the
* "unknown subcommand" error path (matching pre-Phase 6 behavior).
*
* CJS-only subcommands: none.
*/
function routePhasesCommand({ phase, milestone, args, cwd, raw, error }) {
const activeWorkstream = process.env.GSD_WORKSTREAM;
const sdkAvailable = !activeWorkstream && tryLoadSdk();
function sdkHandler(registryCommand, registryArgs, legacyArgs, cjsFallback) {
if (!sdkAvailable) return cjsFallback;
return () => {
const result = getExecuteForCjs()({
registryCommand,
registryArgs,
legacyCommand: 'phases',
legacyArgs,
// #3631: under --raw, request mode:'raw' so the bridge runs the SDK's
// raw projection (formatQueryRawOutput) and returns the scalar string
// CJS callers used to print. We then bypass output()'s JSON-stringify
// path by passing rawValue (the third positional). With mode:'json',
// output() emits the JSON IR as before.
mode: raw ? 'raw' : 'json',
projectDir: cwd,
});
if (!result.ok) {
error(result.errorDetails && result.errorDetails.message
? result.errorDetails.message
: `phases ${registryCommand} failed (${result.errorKind})`);
return;
}
if (raw) {
output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? ''));
} else {
output(result.data);
}
};
}
routeCjsCommandFamily({
args,
// Exclude 'archive' — it's SDK-only and not supported in CJS. Excluding
// from this list causes it to hit the unknownMessage path, preserving the
// pre-Phase 6 error message for callers that pass 'archive'.
subcommands: PHASES_SUBCOMMANDS.filter((s) => s !== 'archive'),
error,
unknownMessage: (_subcommand, available) => `Unknown phases subcommand. Available: ${available.join(', ')}`,
handlers: {
list: () => {
const typeIndex = args.indexOf('--type');
const phaseIndex = args.indexOf('--phase');
const options = {
type: typeIndex !== -1 ? args[typeIndex + 1] : null,
phase: phaseIndex !== -1 ? args[phaseIndex + 1] : null,
includeArchived: args.includes('--include-archived'),
};
phase.cmdPhasesList(cwd, options, raw);
},
clear: () => milestone.cmdPhasesClear(cwd, raw, args.slice(2)),
list: sdkHandler(
'phases.list',
args.slice(2),
args.slice(1),
() => {
const typeIndex = args.indexOf('--type');
const phaseIndex = args.indexOf('--phase');
const options = {
type: typeIndex !== -1 ? args[typeIndex + 1] : null,
phase: phaseIndex !== -1 ? args[phaseIndex + 1] : null,
includeArchived: args.includes('--include-archived'),
};
phase.cmdPhasesList(cwd, options, raw);
},
),
clear: sdkHandler(
'phases.clear',
args.slice(2),
args.slice(1),
() => milestone.cmdPhasesClear(cwd, raw, args.slice(2)),
),
},
});
}

View File

@@ -1,138 +1,26 @@
'use strict';
/**
* plan-scan — canonical phase-plan scanner (k014)
*
* Single source of truth for detecting plan and summary files in a phase
* directory, replacing four divergent copies in state.cjs, roadmap.cjs,
* init.cjs, and phase.cjs (#3262).
*
* Layout support:
* Flat (pre-#3139): phases/<N>/*-PLAN.md, *-SUMMARY.md
* Nested (post-#3139): phases/<N>/plans/PLAN-<NN>-*.md, SUMMARY-<NN>-*.md
*
* @module plan-scan
*/
const fs = require('fs');
const path = require('path');
// Excluded derivative files — present alongside real plans but must not be
// counted. OUTLINE exclusion catches both flat (-PLAN-OUTLINE.md) and nested
// (PLAN-NN-OUTLINE.md) forms via a broad -OUTLINE.md$ pattern. The
// pre-bounce pattern is intentionally broad (matches any *.pre-bounce.md) so
// stale bounce files never inflate plan counts (#3257 regression root cause).
const PLAN_OUTLINE_RE = /-OUTLINE\.md$/i;
const PLAN_PRE_BOUNCE_RE = /\.pre-bounce\.md$/i;
/**
* Determine whether a filename from the flat phase root is a plan file.
* Plan Scan Module — CJS adapter.
*
* Accepts:
* - Bare PLAN.md
* - Canonical padded 01-01-PLAN.md
* - Extended layout 5-PLAN-01-setup.md (the format gsd-plan-phase writes;
* looksLikePlanFile in phase.cjs / isPlanFile in roadmap.cjs)
* The implementation is generated from sdk/src/query/plan-scan.ts and
* lives in plan-scan.generated.cjs. This file is a thin re-export so
* that existing call sites (state.cjs, roadmap.cjs, init.cjs,
* workstream-inventory.cjs, and tests) can continue to require('./plan-scan')
* unchanged.
*
* Rejects: -PLAN-OUTLINE.md, *.pre-bounce.md
*/
function isRootPlanFile(f) {
if (PLAN_OUTLINE_RE.test(f)) return false;
if (PLAN_PRE_BOUNCE_RE.test(f)) return false;
// Canonical suffix or bare name
if (f.endsWith('-PLAN.md') || f === 'PLAN.md') return true;
// Extended layout: any .md that contains PLAN (case-insensitive) in the name
return /\.md$/i.test(f) && /PLAN/i.test(f);
}
/**
* Determine whether a filename from the nested plans/ subdir is a plan file.
* Exports (from generated file):
* - scanPhasePlans(phaseDir) — canonical phase-plan scanner
* - isRootPlanFile(fileName) — extended filter including /PLAN/i slug layouts
* - isNestedPlanFile(fileName) — nested plans/ subdir filter
* - isRootSummaryFile(fileName) — flat summary file filter
* - isNestedSummaryFile(fileName) — nested summary file filter
*
* Nested layout names: PLAN-NN-slug.md or N-PLAN-NN-slug.md.
* Excludes OUTLINE and pre-bounce suffixes.
*/
function isNestedPlanFile(f) {
if (PLAN_OUTLINE_RE.test(f)) return false;
if (PLAN_PRE_BOUNCE_RE.test(f)) return false;
return /^PLAN-\d+.*\.md$/i.test(f) || /-PLAN-\d+.*\.md$/i.test(f);
}
/**
* Determine whether a filename from the flat phase root is a summary file.
*/
function isRootSummaryFile(f) {
return f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md';
}
/**
* Determine whether a filename from the nested plans/ subdir is a summary.
*/
function isNestedSummaryFile(f) {
return /^SUMMARY-\d+.*\.md$/i.test(f) || /-SUMMARY-\d+.*\.md$/i.test(f);
}
/**
* Scan a single phase directory for plan and summary files.
* The isRootPlanFile helper uses /PLAN/i to match the extended slug layout
* (e.g. 5-PLAN-01-setup-database.md) in addition to bare and canonical forms.
* This was the fix for bug #3128 (roadmap.cjs plan-count regression).
*
* @param {string} phaseDir — absolute path to the phase directory
* @returns {{
* planCount: number,
* summaryCount: number,
* completed: boolean,
* hasNestedPlans: boolean,
* planFiles: string[],
* summaryFiles: string[],
* }}
* Regenerate: cd sdk && npm run gen:plan-scan
*/
function scanPhasePlans(phaseDir) {
let rootFiles;
try {
rootFiles = fs.readdirSync(phaseDir);
} catch {
return {
planCount: 0,
summaryCount: 0,
completed: false,
hasNestedPlans: false,
planFiles: [],
summaryFiles: [],
};
}
const rootPlanFiles = rootFiles.filter(isRootPlanFile);
const rootSummaryFiles = rootFiles.filter(isRootSummaryFile);
let nestedPlanFiles = [];
let nestedSummaryFiles = [];
let hasNestedPlans = false;
const nestedDir = path.join(phaseDir, 'plans');
if (fs.existsSync(nestedDir)) {
try {
const nested = fs.readdirSync(nestedDir);
nestedPlanFiles = nested.filter(isNestedPlanFile);
nestedSummaryFiles = nested.filter(isNestedSummaryFile);
hasNestedPlans = nestedPlanFiles.length > 0;
} catch { /* ignore if plans/ is not a readable directory */ }
}
const planFiles = rootPlanFiles.concat(nestedPlanFiles);
const summaryFiles = rootSummaryFiles.concat(nestedSummaryFiles);
const planCount = planFiles.length;
const summaryCount = summaryFiles.length;
return {
planCount,
summaryCount,
completed: planCount > 0 && summaryCount >= planCount,
hasNestedPlans,
planFiles,
summaryFiles,
};
}
module.exports = scanPhasePlans;
module.exports.scanPhasePlans = scanPhasePlans;
module.exports.isRootPlanFile = isRootPlanFile;
module.exports.isNestedPlanFile = isNestedPlanFile;
module.exports.isRootSummaryFile = isRootSummaryFile;
module.exports.isNestedSummaryFile = isNestedSummaryFile;
module.exports = require('./plan-scan.generated.cjs');

View File

@@ -0,0 +1,97 @@
'use strict';
/**
* GENERATED FILE — DO NOT EDIT.
*
* Source: sdk/src/query/plan-scan.ts
* Regenerate: cd sdk && npm run gen:plan-scan
*
* Plan Scan Module — detects plan and summary files in a phase directory.
* Supports both flat (pre-#3139) and nested (post-#3139) layouts.
*/
const { existsSync, readdirSync } = require('node:fs');
const { join } = require('node:path');
// Excluded derivative files
const PLAN_OUTLINE_RE = /-OUTLINE\.md$/i;
const PLAN_PRE_BOUNCE_RE = /\.pre-bounce\.md$/i;
function isRootPlanFile(fileName) {
if (PLAN_OUTLINE_RE.test(fileName))
return false;
if (PLAN_PRE_BOUNCE_RE.test(fileName))
return false;
if (fileName.endsWith('-PLAN.md') || fileName === 'PLAN.md')
return true;
return /\.md$/i.test(fileName) && /PLAN/i.test(fileName);
}
function isNestedPlanFile(fileName) {
if (PLAN_OUTLINE_RE.test(fileName))
return false;
if (PLAN_PRE_BOUNCE_RE.test(fileName))
return false;
return /^PLAN-\d+.*\.md$/i.test(fileName) || /-PLAN-\d+.*\.md$/i.test(fileName);
}
function isRootSummaryFile(fileName) {
return fileName.endsWith('-SUMMARY.md') || fileName === 'SUMMARY.md';
}
function isNestedSummaryFile(fileName) {
return /^SUMMARY-\d+.*\.md$/i.test(fileName) || /-SUMMARY-\d+.*\.md$/i.test(fileName);
}
function scanPhasePlans(phaseDir) {
let rootFiles;
try {
rootFiles = readdirSync(phaseDir);
}
catch {
return {
planCount: 0,
summaryCount: 0,
completed: false,
hasNestedPlans: false,
planFiles: [],
summaryFiles: [],
};
}
const rootPlanFiles = rootFiles.filter(isRootPlanFile);
const rootSummaryFiles = rootFiles.filter(isRootSummaryFile);
let nestedPlanFiles = [];
let nestedSummaryFiles = [];
let hasNestedPlans = false;
const nestedDir = join(phaseDir, 'plans');
if (existsSync(nestedDir)) {
try {
const nestedFiles = readdirSync(nestedDir);
nestedPlanFiles = nestedFiles.filter(isNestedPlanFile);
nestedSummaryFiles = nestedFiles.filter(isNestedSummaryFile);
hasNestedPlans = nestedPlanFiles.length > 0;
}
catch { /* ignore unreadable nested layout */ }
}
const planFiles = rootPlanFiles.concat(nestedPlanFiles);
const summaryFiles = rootSummaryFiles.concat(nestedSummaryFiles);
const planCount = planFiles.length;
const summaryCount = summaryFiles.length;
return {
planCount,
summaryCount,
completed: planCount > 0 && summaryCount >= planCount,
hasNestedPlans,
planFiles,
summaryFiles,
};
}
// CJS callers do: const scanPhasePlans = require('./plan-scan.cjs')
// and also destructure named exports — support both call styles.
module.exports = scanPhasePlans;
module.exports.scanPhasePlans = scanPhasePlans;
module.exports.isRootPlanFile = isRootPlanFile;
module.exports.isNestedPlanFile = isNestedPlanFile;
module.exports.isRootSummaryFile = isRootSummaryFile;
module.exports.isNestedSummaryFile = isNestedSummaryFile;

View File

@@ -1,21 +1,97 @@
'use strict';
const { ROADMAP_SUBCOMMANDS } = require('./command-aliases.generated.cjs');
const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs');
const { output } = require('./core.cjs');
// ─── SDK bridge (Phase 6) — shared loader via cjs-sdk-bridge.cjs ──────────────
const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs');
/**
* Manifest-backed roadmap subcommand router.
* Keeps gsd-tools.cjs thin while preserving existing command semantics.
*
* Phase 6: all roadmap.* subcommands have SDK equivalents and are dispatched
* via executeForCjs (the sync bridge). CJS fallback retained when:
* - GSD_WORKSTREAM is active (workstream-scoped requests fall through to CJS).
* - SDK is unavailable (build not present).
*
* CJS-only subcommands: none.
* SDK-only (unsupported in CJS router): none.
*/
function routeRoadmapCommand({ roadmap, args, cwd, raw, error }) {
const subcommand = args[1];
const activeWorkstream = process.env.GSD_WORKSTREAM;
// GSD_SDK_NESTED is set by SDK handlers that spawn gsd-tools.cjs as a
// child process (e.g. roadmapAnnotateDependencies). Without this guard
// the child process re-dispatches through the SDK bridge, which spawns
// again, ad infinitum until the synckit 15s timeout fires. Bug #3537
// annotate-dependencies parity.
const nested = process.env.GSD_SDK_NESTED === '1';
const sdkAvailable = !activeWorkstream && !nested && tryLoadSdk();
if (subcommand === 'get-phase') {
roadmap.cmdRoadmapGetPhase(cwd, args[2], raw);
} else if (subcommand === 'analyze') {
roadmap.cmdRoadmapAnalyze(cwd, raw);
} else if (subcommand === 'update-plan-progress') {
roadmap.cmdRoadmapUpdatePlanProgress(cwd, args[2], raw);
} else if (subcommand === 'annotate-dependencies') {
roadmap.cmdRoadmapAnnotateDependencies(cwd, args[2], raw);
} else {
error(`Unknown roadmap subcommand. Available: ${ROADMAP_SUBCOMMANDS.join(', ')}`);
function sdkHandler(registryCommand, registryArgs, legacyArgs, cjsFallback) {
if (!sdkAvailable) return cjsFallback;
return () => {
const result = getExecuteForCjs()({
registryCommand,
registryArgs,
legacyCommand: 'roadmap',
legacyArgs,
// #3631: under --raw, request mode:'raw' so the bridge runs the SDK's
// raw projection (formatQueryRawOutput) and returns the scalar string
// CJS callers used to print. We then bypass output()'s JSON-stringify
// path by passing rawValue (the third positional). With mode:'json',
// output() emits the JSON IR as before.
mode: raw ? 'raw' : 'json',
projectDir: cwd,
});
if (!result.ok) {
error(result.errorDetails && result.errorDetails.message
? result.errorDetails.message
: `roadmap ${registryCommand} failed (${result.errorKind})`);
return;
}
if (raw) {
output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? ''));
} else {
output(result.data);
}
};
}
routeCjsCommandFamily({
args,
subcommands: ROADMAP_SUBCOMMANDS,
unsupported: {},
error,
unknownMessage: (_subcommand, available) => `Unknown roadmap subcommand. Available: ${available.join(', ')}`,
handlers: {
'get-phase': sdkHandler(
'roadmap.get-phase',
args.slice(2),
args.slice(1),
() => roadmap.cmdRoadmapGetPhase(cwd, args[2], raw),
),
analyze: sdkHandler(
'roadmap.analyze',
args.slice(2),
args.slice(1),
() => roadmap.cmdRoadmapAnalyze(cwd, raw),
),
'update-plan-progress': sdkHandler(
'roadmap.update-plan-progress',
args.slice(2),
args.slice(1),
() => roadmap.cmdRoadmapUpdatePlanProgress(cwd, args[2], raw),
),
'annotate-dependencies': sdkHandler(
'roadmap.annotate-dependencies',
args.slice(2),
args.slice(1),
() => roadmap.cmdRoadmapAnnotateDependencies(cwd, args[2], raw),
),
},
});
}
module.exports = {

View File

@@ -62,6 +62,13 @@ function getGlobalConfigDir(runtime) {
case 'codex':
return env.CODEX_HOME ? expandTilde(env.CODEX_HOME) : path.join(home, '.codex');
// ── Grok Build ───────────────────────────────────────────────────────────
// Uses the unified ~/.agents layout (skills + agents + engine) shared with
// Codex-style harnesses. This is the pragmatic primary target for users
// running GSD inside Grok Build.
case 'grok':
return env.GROK_AGENTS_HOME ? expandTilde(env.GROK_AGENTS_HOME) : path.join(home, '.agents');
// ── Copilot (VS Code) ────────────────────────────────────────────────────
case 'copilot':
return env.COPILOT_CONFIG_DIR ? expandTilde(env.COPILOT_CONFIG_DIR) : path.join(home, '.copilot');

View File

@@ -1,238 +1,21 @@
/**
* Schema Drift Detection — Detects schema-relevant file changes and verifies
* that the appropriate database push command was executed during a phase.
*
* Prevents false-positive verification when schema files change but no push
* occurs — TypeScript types come from config, not the live database, so
* build/types pass on a broken state.
*/
'use strict';
// ─── ORM Patterns ────────────────────────────────────────────────────────────
//
// Each entry maps a glob-like pattern to an ORM name. Patterns use forward
// slashes internally — Windows backslash paths are normalized before matching.
const SCHEMA_PATTERNS = [
// Payload CMS
{ pattern: /^src\/collections\/.*\.ts$/, orm: 'payload' },
{ pattern: /^src\/globals\/.*\.ts$/, orm: 'payload' },
// Prisma
{ pattern: /^prisma\/schema\.prisma$/, orm: 'prisma' },
{ pattern: /^prisma\/schema\/.*\.prisma$/, orm: 'prisma' },
// Drizzle
{ pattern: /^drizzle\/schema\.ts$/, orm: 'drizzle' },
{ pattern: /^src\/db\/schema\.ts$/, orm: 'drizzle' },
{ pattern: /^drizzle\/.*\.ts$/, orm: 'drizzle' },
// Supabase
{ pattern: /^supabase\/migrations\/.*\.sql$/, orm: 'supabase' },
// TypeORM
{ pattern: /^src\/entities\/.*\.ts$/, orm: 'typeorm' },
{ pattern: /^src\/migrations\/.*\.ts$/, orm: 'typeorm' },
];
// ─── Push Commands & Evidence Patterns ───────────────────────────────────────
//
// For each ORM, the push command that agents should run, plus regex patterns
// that indicate the push was actually executed (matched against execution logs,
// SUMMARY.md content, and git commit messages).
const ORM_INFO = {
payload: {
pushCommand: 'npx payload migrate',
envHint: 'CI=true PAYLOAD_MIGRATING=true npx payload migrate',
interactiveWarning: 'Payload migrate may require interactive prompts — use CI=true PAYLOAD_MIGRATING=true to suppress',
evidencePatterns: [
/payload\s+migrate/i,
/PAYLOAD_MIGRATING/,
],
},
prisma: {
pushCommand: 'npx prisma db push',
envHint: 'npx prisma db push --accept-data-loss (if destructive changes are intended)',
interactiveWarning: 'Prisma db push may prompt for confirmation on destructive changes — use --accept-data-loss to bypass',
evidencePatterns: [
/prisma\s+db\s+push/i,
/prisma\s+migrate\s+deploy/i,
/prisma\s+migrate\s+dev/i,
],
},
drizzle: {
pushCommand: 'npx drizzle-kit push',
envHint: 'npx drizzle-kit push',
interactiveWarning: null,
evidencePatterns: [
/drizzle-kit\s+push/i,
/drizzle-kit\s+migrate/i,
],
},
supabase: {
pushCommand: 'supabase db push',
envHint: 'supabase db push',
interactiveWarning: 'Supabase db push may require authentication — ensure SUPABASE_ACCESS_TOKEN is set',
evidencePatterns: [
/supabase\s+db\s+push/i,
/supabase\s+migration\s+up/i,
],
},
typeorm: {
pushCommand: 'npx typeorm migration:run',
envHint: 'npx typeorm migration:run -d src/data-source.ts',
interactiveWarning: null,
evidencePatterns: [
/typeorm\s+migration:run/i,
/typeorm\s+schema:sync/i,
],
},
};
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Detect schema-relevant files in a list of file paths.
* Schema Detect Module — CJS adapter.
*
* @param {string[]} files - List of file paths (relative to project root)
* @returns {{ detected: boolean, matches: string[], orms: string[] }}
*/
function detectSchemaFiles(files) {
const matches = [];
const orms = new Set();
for (const rawFile of files) {
// Normalize Windows backslash paths
const file = rawFile.replace(/\\/g, '/');
for (const { pattern, orm } of SCHEMA_PATTERNS) {
if (pattern.test(file)) {
matches.push(rawFile);
orms.add(orm);
break; // One match per file is enough
}
}
}
return {
detected: matches.length > 0,
matches,
orms: Array.from(orms),
};
}
/**
* Get ORM-specific push command info.
* The implementation is generated from sdk/src/query/schema-detect.ts and
* lives in schema-detect.generated.cjs. This file is a thin re-export so
* that existing call sites (verify.cjs and tests) can continue to
* require('./schema-detect') unchanged.
*
* @param {string} ormName - ORM identifier (payload, prisma, drizzle, supabase, typeorm)
* @returns {{ pushCommand: string, envHint: string, interactiveWarning: string|null, evidencePatterns: RegExp[] } | null}
*/
function detectSchemaOrm(ormName) {
return ORM_INFO[ormName] || null;
}
/**
* Check for schema drift: schema files changed but no push evidence found.
* Exports (from generated file):
* - SCHEMA_PATTERNS — ORM file pattern list
* - ORM_INFO — ORM push commands and evidence patterns
* - detectSchemaFiles(files) — detect schema-relevant files
* - detectSchemaOrm(ormName) — get ORM-specific push command info
* - checkSchemaDrift(changedFiles, executionLog, options) — check for drift
*
* @param {string[]} changedFiles - Files changed during the phase
* @param {string} executionLog - Combined text from SUMMARY.md, commit messages, and execution logs
* @param {{ skipCheck?: boolean }} [options] - Options
* @returns {{ driftDetected: boolean, blocking: boolean, schemaFiles: string[], orms: string[], unpushedOrms: string[], message: string, skipped?: boolean }}
* Regenerate: cd sdk && npm run gen:schema-detect
*/
function checkSchemaDrift(changedFiles, executionLog, options = {}) {
const { skipCheck = false } = options;
const detection = detectSchemaFiles(changedFiles);
if (!detection.detected) {
return {
driftDetected: false,
blocking: false,
schemaFiles: [],
orms: [],
unpushedOrms: [],
message: '',
};
}
// Check which ORMs have push evidence in the execution log
const pushedOrms = new Set();
const unpushedOrms = [];
for (const orm of detection.orms) {
const info = ORM_INFO[orm];
if (!info) continue;
const hasPushEvidence = info.evidencePatterns.some(p => p.test(executionLog));
if (hasPushEvidence) {
pushedOrms.add(orm);
} else {
unpushedOrms.push(orm);
}
}
const driftDetected = unpushedOrms.length > 0;
if (!driftDetected) {
return {
driftDetected: false,
blocking: false,
schemaFiles: detection.matches,
orms: detection.orms,
unpushedOrms: [],
message: '',
};
}
// Build actionable message
const pushCommands = unpushedOrms
.map(orm => {
const info = ORM_INFO[orm];
return info ? ` ${orm}: ${info.envHint || info.pushCommand}` : null;
})
.filter(Boolean)
.join('\n');
const message = [
'Schema drift detected: schema-relevant files changed but no database push was executed.',
'',
`Schema files changed: ${detection.matches.join(', ')}`,
`ORMs requiring push: ${unpushedOrms.join(', ')}`,
'',
'Required push commands:',
pushCommands,
'',
'Run the appropriate push command, or set GSD_SKIP_SCHEMA_CHECK=true to bypass this gate.',
].join('\n');
if (skipCheck) {
return {
driftDetected: true,
blocking: false,
skipped: true,
schemaFiles: detection.matches,
orms: detection.orms,
unpushedOrms,
message: 'Schema drift detected but check was skipped (GSD_SKIP_SCHEMA_CHECK=true).',
};
}
return {
driftDetected: true,
blocking: true,
schemaFiles: detection.matches,
orms: detection.orms,
unpushedOrms,
message,
};
}
module.exports = {
SCHEMA_PATTERNS,
ORM_INFO,
detectSchemaFiles,
detectSchemaOrm,
checkSchemaDrift,
};
module.exports = require('./schema-detect.generated.cjs');

View File

@@ -0,0 +1,170 @@
'use strict';
/**
* GENERATED FILE — DO NOT EDIT.
*
* Source: sdk/src/query/schema-detect.ts
* Regenerate: cd sdk && npm run gen:schema-detect
*
* Schema Drift Detection — detects schema-relevant file changes and verifies
* that the appropriate database push command was executed during a phase.
* This module does not read the filesystem directly.
*/
// ─── ORM Patterns ───────────────────────────────────────────────────────────
const SCHEMA_PATTERNS = [
{ pattern: /^src\/collections\/.*\.ts$/, orm: 'payload' },
{ pattern: /^src\/globals\/.*\.ts$/, orm: 'payload' },
{ pattern: /^prisma\/schema\.prisma$/, orm: 'prisma' },
{ pattern: /^prisma\/schema\/.*\.prisma$/, orm: 'prisma' },
{ pattern: /^drizzle\/schema\.ts$/, orm: 'drizzle' },
{ pattern: /^src\/db\/schema\.ts$/, orm: 'drizzle' },
{ pattern: /^drizzle\/.*\.ts$/, orm: 'drizzle' },
{ pattern: /^supabase\/migrations\/.*\.sql$/, orm: 'supabase' },
{ pattern: /^src\/entities\/.*\.ts$/, orm: 'typeorm' },
{ pattern: /^src\/migrations\/.*\.ts$/, orm: 'typeorm' },
];
// ─── Push Commands & Evidence Patterns ──────────────────────────────────────
const ORM_INFO = {
payload: {
pushCommand: 'npx payload migrate',
envHint: 'CI=true PAYLOAD_MIGRATING=true npx payload migrate',
interactiveWarning: 'Payload migrate may require interactive prompts — use CI=true PAYLOAD_MIGRATING=true to suppress',
evidencePatterns: [/payload\s+migrate/i, /PAYLOAD_MIGRATING/],
},
prisma: {
pushCommand: 'npx prisma db push',
envHint: 'npx prisma db push --accept-data-loss (if destructive changes are intended)',
interactiveWarning: 'Prisma db push may prompt for confirmation on destructive changes — use --accept-data-loss to bypass',
evidencePatterns: [/prisma\s+db\s+push/i, /prisma\s+migrate\s+deploy/i, /prisma\s+migrate\s+dev/i],
},
drizzle: {
pushCommand: 'npx drizzle-kit push',
envHint: 'npx drizzle-kit push',
interactiveWarning: null,
evidencePatterns: [/drizzle-kit\s+push/i, /drizzle-kit\s+migrate/i],
},
supabase: {
pushCommand: 'supabase db push',
envHint: 'supabase db push',
interactiveWarning: 'Supabase db push may require authentication — ensure SUPABASE_ACCESS_TOKEN is set',
evidencePatterns: [/supabase\s+db\s+push/i, /supabase\s+migration\s+up/i],
},
typeorm: {
pushCommand: 'npx typeorm migration:run',
envHint: 'npx typeorm migration:run -d src/data-source.ts',
interactiveWarning: null,
evidencePatterns: [/typeorm\s+migration:run/i, /typeorm\s+schema:sync/i],
},
};
// ─── Public API ──────────────────────────────────────────────────────────────
function detectSchemaFiles(files) {
const matches = [];
const orms = new Set();
for (const rawFile of files) {
const file = rawFile.replace(/\\/g, '/');
for (const { pattern, orm } of SCHEMA_PATTERNS) {
if (pattern.test(file)) {
matches.push(rawFile);
orms.add(orm);
break;
}
}
}
return {
detected: matches.length > 0,
matches,
orms: [...orms],
};
}
function detectSchemaOrm(ormName) {
return ORM_INFO[ormName] || null;
}
function checkSchemaDrift(changedFiles, executionLog, options = {}) {
const { skipCheck = false } = options;
const detection = detectSchemaFiles(changedFiles);
if (!detection.detected) {
return {
driftDetected: false,
blocking: false,
schemaFiles: [],
orms: [],
unpushedOrms: [],
message: '',
};
}
const pushedOrms = new Set();
const unpushedOrms = [];
for (const orm of detection.orms) {
const info = ORM_INFO[orm];
if (!info)
continue;
const hasPushEvidence = info.evidencePatterns.some(p => p.test(executionLog));
if (hasPushEvidence) {
pushedOrms.add(orm);
}
else {
unpushedOrms.push(orm);
}
}
const driftDetected = unpushedOrms.length > 0;
if (!driftDetected) {
return {
driftDetected: false,
blocking: false,
schemaFiles: detection.matches,
orms: detection.orms,
unpushedOrms: [],
message: '',
};
}
const pushCommands = unpushedOrms
.map(orm => {
const info = ORM_INFO[orm];
return info ? ` ${orm}: ${info.envHint || info.pushCommand}` : null;
})
.filter(Boolean)
.join('\n');
const message = [
'Schema drift detected: schema-relevant files changed but no database push was executed.',
'',
`Schema files changed: ${detection.matches.join(', ')}`,
`ORMs requiring push: ${unpushedOrms.join(', ')}`,
'',
'Required push commands:',
pushCommands,
'',
'Run the appropriate push command, or set GSD_SKIP_SCHEMA_CHECK=true to bypass this gate.',
].join('\n');
if (skipCheck) {
return {
driftDetected: true,
blocking: false,
skipped: true,
schemaFiles: detection.matches,
orms: detection.orms,
unpushedOrms,
message: 'Schema drift detected but check was skipped (GSD_SKIP_SCHEMA_CHECK=true).',
};
}
return {
driftDetected: true,
blocking: true,
schemaFiles: detection.matches,
orms: detection.orms,
unpushedOrms,
message,
};
}
module.exports = {
SCHEMA_PATTERNS,
ORM_INFO,
detectSchemaFiles,
detectSchemaOrm,
checkSchemaDrift,
};

View File

@@ -1,33 +1,20 @@
'use strict';
/**
* Secrets handling — masking convention for API keys and other
* credentials managed via /gsd-settings-integrations.
* Secrets Module — CJS adapter.
*
* Convention: strings 8+ chars long render as `****<last-4>`; shorter
* strings render as `****` with no tail (to avoid leaking a meaningful
* fraction of a short secret). null/empty renders as `(unset)`.
* The implementation is generated from sdk/src/query/secrets.ts and
* lives in secrets.generated.cjs. This file is a thin re-export so
* that existing call sites (config.cjs, init.cjs, and tests) can
* continue to require('./secrets') unchanged.
*
* Keys considered sensitive are listed in SECRET_CONFIG_KEYS and matched
* at the exact key-path level. The list is intentionally narrow — these
* are the fields documented as secrets in docs/CONFIGURATION.md.
* Exports (from generated file):
* - SECRET_CONFIG_KEYS — Set of secret key paths
* - isSecretKey(keyPath) — returns true if keyPath is a secret
* - maskSecret(value) — masks a secret value
* - maskIfSecret(keyPath, value) — masks value only if keyPath is secret
*
* Regenerate: cd sdk && npm run gen:secrets
*/
const SECRET_CONFIG_KEYS = new Set([
'brave_search',
'firecrawl',
'exa_search',
]);
function isSecretKey(keyPath) {
return SECRET_CONFIG_KEYS.has(keyPath);
}
function maskSecret(value) {
if (value === null || value === undefined || value === '') return '(unset)';
const s = String(value);
if (s.length < 8) return '****';
return '****' + s.slice(-4);
}
module.exports = { SECRET_CONFIG_KEYS, isSecretKey, maskSecret };
module.exports = require('./secrets.generated.cjs');

View File

@@ -0,0 +1,37 @@
'use strict';
/**
* GENERATED FILE — DO NOT EDIT.
*
* Source: sdk/src/query/secrets.ts
* Regenerate: cd sdk && npm run gen:secrets
*
* Secrets handling — masking convention for API keys and other
* credentials managed via /gsd-settings-integrations.
* This module does not read the filesystem.
*/
const SECRET_CONFIG_KEYS = new Set([
'brave_search',
'firecrawl',
'exa_search',
]);
function isSecretKey(keyPath) {
return SECRET_CONFIG_KEYS.has(keyPath);
}
function maskSecret(value) {
if (value === null || value === undefined || value === '')
return '(unset)';
const s = String(value);
if (s.length < 8)
return '****';
return '****' + s.slice(-4);
}
function maskIfSecret(keyPath, value) {
return isSecretKey(keyPath) ? maskSecret(value) : value;
}
module.exports = { SECRET_CONFIG_KEYS, isSecretKey, maskSecret, maskIfSecret };

View File

@@ -3,29 +3,25 @@
const { STATE_SUBCOMMANDS } = require('./command-aliases.generated.cjs');
const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs');
const { output } = require('./core.cjs');
const {
tryLoadSdk,
getExecuteForCjs,
getFormatStateLoadRawStdout,
} = require('./cjs-sdk-bridge.cjs');
// ─── SDK bridge (Phase 5.1) ─────────────────────────────────────────────────
// executeForCjs is loaded lazily from the SDK public package export so this
// router does not rely on private dist subpaths that are not exported.
let _executeForCjs = null;
let _formatStateLoadRawStdout = null;
// Subcommands whose CJS contract is exit-non-zero (stderr) ONLY when the
// underlying STATE.md is missing — not for in-state errors like
// "field not found". CJS `cmdStateGet` calls `error('STATE.md not found')` →
// exit 1 for the missing-file case but `output({ error: 'Section or field
// "X" not found' }, raw)` → exit 0 for the missing-field case. Mutation
// commands always use output() (exit 0) even when STATE.md is missing, so
// they are absent from this set entirely.
const EXIT_ON_STATE_MD_MISSING = new Set(['state.get']);
const STATE_MD_MISSING_MESSAGE = 'STATE.md not found';
function tryLoadSdk() {
if (_executeForCjs !== null) return true;
try {
const sdkModule = require('@gsd-build/sdk');
_executeForCjs = sdkModule.executeForCjs;
_formatStateLoadRawStdout = sdkModule.formatStateLoadRawStdout;
if (typeof _executeForCjs !== 'function' || typeof _formatStateLoadRawStdout !== 'function') {
_executeForCjs = null;
_formatStateLoadRawStdout = null;
return false;
}
return true;
} catch {
return false;
}
}
// The bridge loader verifies both `executeForCjs` and `formatStateLoadRawStdout`
// are present before returning success, so this router can call `tryLoadSdk()`
// directly without an additional capability check.
/**
* Dispatch a subcommand via the SDK sync bridge.
@@ -44,16 +40,25 @@ function tryLoadSdk() {
function dispatchViaSdk(registryCommand, registryArgs, legacyArgs, cwd, raw, error, rawFormatter) {
if (!tryLoadSdk()) return false;
const result = _executeForCjs({
// When a CJS-side rawFormatter is supplied (e.g. state.load --raw → key=value
// lines), always request 'json' from the bridge so the SDK returns the typed
// data object. Passing mode: 'raw' would make the bridge pre-render to a
// string and the formatter would no-op. For subcommands without a rawFormatter,
// honor the user's --raw flag and let the bridge do default rendering.
const bridgeMode = rawFormatter ? 'json' : (raw ? 'raw' : 'json');
const result = getExecuteForCjs()({
registryCommand,
registryArgs,
legacyCommand: 'state',
legacyArgs,
mode: raw ? 'raw' : 'json',
mode: bridgeMode,
projectDir: cwd,
// workstream: not threaded here — GSDTransport forces subprocess for workstream
// requests and subprocess is disabled in the worker. Workstream commands fall
// back to the CJS path (see routeStateCommand guard below).
// Phase 6 fix: workstream is now threaded through to the native handler.
// GSDTransport no longer forces subprocess for workstream-scoped requests —
// the worker's dispatchNative closure correctly passes workstream to
// registry.dispatch() (Phase 5.1 fix), enabling native workstream dispatch.
workstream: process.env.GSD_WORKSTREAM || undefined,
});
if (!result.ok) {
@@ -63,10 +68,31 @@ function dispatchViaSdk(registryCommand, registryArgs, legacyArgs, cwd, raw, err
return true; // handled (error was reported)
}
// Surface STATE.md-missing as a CJS-style fatal error (exit non-zero,
// stderr) for the specific subcommands whose CJS contract uses error() not
// output() for that case. The exact "STATE.md not found" message is the
// canonical signal both CJS and SDK use — other "error" shapes (e.g.
// "Section or field X not found" from state.get with present STATE.md)
// stay as exit-0 JSON output so shell-script consumers JSON.parse the
// output and branch on the error field without process-exit handling.
if (
EXIT_ON_STATE_MD_MISSING.has(registryCommand)
&& result.data
&& typeof result.data === 'object'
&& result.data.error === STATE_MD_MISSING_MESSAGE
) {
error(result.data.error);
return true;
}
if (raw && rawFormatter) {
const rawText = rawFormatter(result.data);
const fs = require('fs');
fs.writeSync(1, rawText);
} else if (raw) {
// #3631: bridge was called with mode:'raw', so result.data is the scalar
// string the CJS path would have printed. Bypass output()'s JSON path.
output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? ''));
} else {
output(result.data);
}
@@ -94,12 +120,10 @@ function routeStateCommand({ state, args, cwd, raw, parseNamedArgs, error }) {
return parsedPlans;
};
// Workstream guard: if GSD_WORKSTREAM is set, the sync bridge worker cannot
// handle the request (GSDTransport.subprocessReason returns 'workstream_forced'
// and subprocess is disabled in the worker). Fall back to CJS path for all
// workstream-scoped state commands.
const activeWorkstream = process.env.GSD_WORKSTREAM;
const sdkAvailable = !activeWorkstream && tryLoadSdk();
// Phase 6 fix: workstream commands are now handled natively in the sync bridge
// worker. GSDTransport no longer forces subprocess for workstream-scoped requests;
// the worker threads workstream through to registry.dispatch() correctly.
const sdkAvailable = tryLoadSdk();
// Helper: build SDK-backed handler that falls through to CJS on SDK failure.
// cjsFallback is called when SDK is unavailable or when the subcommand has no
@@ -128,7 +152,11 @@ function routeStateCommand({ state, args, cwd, raw, parseNamedArgs, error }) {
'state.load',
[],
args.slice(1),
_formatStateLoadRawStdout,
// Resolved lazily — the formatter getter returns null until
// tryLoadSdk() runs inside dispatchViaSdk. sdkHandler only invokes
// this formatter when SDK dispatch succeeds, so by then the bridge
// has cached the formatter and the getter returns the real function.
(...formatterArgs) => getFormatStateLoadRawStdout()(...formatterArgs),
() => state.cmdStateLoad(cwd, raw),
),
json: sdkHandler(

View File

@@ -2,54 +2,126 @@
const { VALIDATE_SUBCOMMANDS } = require('./command-aliases.generated.cjs');
const { formatGsdSlash, resolveRuntime } = require('./runtime-slash.cjs');
const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs');
const { output } = require('./core.cjs');
function routeValidateCommand({ verify, args, cwd, raw, parseNamedArgs, output, error }) {
const subcommand = args[1];
// ─── SDK bridge (Phase 6) — shared loader via cjs-sdk-bridge.cjs ──────────────
const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs');
if (subcommand === 'consistency') {
verify.cmdValidateConsistency(cwd, raw);
} else if (subcommand === 'health') {
const repairFlag = args.includes('--repair');
const backfillFlag = args.includes('--backfill');
verify.cmdValidateHealth(cwd, { repair: repairFlag, backfill: backfillFlag }, raw);
} else if (subcommand === 'agents') {
verify.cmdValidateAgents(cwd, raw);
} else if (subcommand === 'context') {
const opts = parseNamedArgs(args, ['tokens-used', 'context-window']);
if (opts['tokens-used'] === null) {
error('--tokens-used <integer> is required for `validate context`');
return;
}
if (opts['context-window'] === null) {
error('--context-window <integer> is required for `validate context`');
return;
}
const { classifyContextUtilization, STATES } = require('./context-utilization.cjs');
const threadCmd = formatGsdSlash('thread', resolveRuntime(cwd));
const RECOMMENDATIONS = {
[STATES.HEALTHY]: null,
[STATES.WARNING]: `Context is approaching the fracture zone — consider ${threadCmd} to continue in a fresh window.`,
[STATES.CRITICAL]: `Reasoning quality may degrade past 70% utilization (fracture point). Run ${threadCmd} now to preserve output quality.`,
/**
* Manifest-backed validate subcommand router.
* Keeps gsd-tools.cjs thin while preserving existing command semantics.
*
* Phase 6: validate.consistency, validate.health, validate.agents are
* dispatched via executeForCjs when the SDK is available. CJS fallback
* retained when:
* - GSD_WORKSTREAM is active (workstream-scoped requests fall through to CJS).
* - SDK is unavailable (build not present).
*
* CJS-only subcommands:
* - context: complex inline logic using classifyContextUtilization and
* output formatting that has no direct SDK counterpart. Remains CJS-native.
*
* SDK-only (unsupported in CJS router): none.
*/
function routeValidateCommand({ verify, args, cwd, raw, parseNamedArgs, output: outputFn, error }) {
const activeWorkstream = process.env.GSD_WORKSTREAM;
const sdkAvailable = !activeWorkstream && tryLoadSdk();
function sdkHandler(registryCommand, registryArgs, legacyArgs, cjsFallback) {
if (!sdkAvailable) return cjsFallback;
return () => {
const result = getExecuteForCjs()({
registryCommand,
registryArgs,
legacyCommand: 'validate',
legacyArgs,
// #3631: under --raw, request mode:'raw' so the bridge runs the SDK's
// raw projection (formatQueryRawOutput) and returns the scalar string
// CJS callers used to print. We then bypass output()'s JSON-stringify
// path by passing rawValue (the third positional). With mode:'json',
// output() emits the JSON IR as before.
mode: raw ? 'raw' : 'json',
projectDir: cwd,
});
if (!result.ok) {
error(result.errorDetails && result.errorDetails.message
? result.errorDetails.message
: `validate ${registryCommand} failed (${result.errorKind})`);
return;
}
if (raw) {
output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? ''));
} else {
output(result.data);
}
};
let classified;
try {
classified = classifyContextUtilization(Number(opts['tokens-used']), Number(opts['context-window']));
} catch (e) {
const flag = /tokensUsed/.test(e.message) ? '--tokens-used' : '--context-window';
error(`${flag} must be a non-negative integer (window > 0), got the values supplied`);
return;
}
const result = { ...classified, recommendation: RECOMMENDATIONS[classified.state] };
if (args.includes('--json')) {
output(result, raw);
} else {
const lines = [`Context utilization: ${result.percent}% (${result.state})`];
if (result.recommendation) lines.push(result.recommendation);
output(result, true, lines.join('\n'));
}
} else {
error(`Unknown validate subcommand. Available: ${VALIDATE_SUBCOMMANDS.join(', ')}`);
}
routeCjsCommandFamily({
args,
subcommands: VALIDATE_SUBCOMMANDS,
unsupported: {},
error,
unknownMessage: (_subcommand, available) => `Unknown validate subcommand. Available: ${available.join(', ')}`,
handlers: {
consistency: sdkHandler(
'validate.consistency',
args.slice(2),
args.slice(1),
() => verify.cmdValidateConsistency(cwd, raw),
),
// Keep health on CJS for now so fix hints are rendered via runtime-slash
// helpers (codex expects $gsd-* command shape).
health: () => {
const repairFlag = args.includes('--repair');
const backfillFlag = args.includes('--backfill');
verify.cmdValidateHealth(cwd, { repair: repairFlag, backfill: backfillFlag }, raw);
},
agents: sdkHandler(
'validate.agents',
args.slice(2),
args.slice(1),
() => verify.cmdValidateAgents(cwd, raw),
),
// context: CJS-only — complex inline logic using classifyContextUtilization
// with custom output formatting that has no direct SDK counterpart.
context: () => {
const opts = parseNamedArgs(args, ['tokens-used', 'context-window']);
if (opts['tokens-used'] === null) {
error('--tokens-used <integer> is required for `validate context`');
return;
}
if (opts['context-window'] === null) {
error('--context-window <integer> is required for `validate context`');
return;
}
const { classifyContextUtilization, STATES } = require('./context-utilization.cjs');
const threadCmd = formatGsdSlash('thread', resolveRuntime(cwd));
const RECOMMENDATIONS = {
[STATES.HEALTHY]: null,
[STATES.WARNING]: `Context is approaching the fracture zone — consider ${threadCmd} to continue in a fresh window.`,
[STATES.CRITICAL]: `Reasoning quality may degrade past 70% utilization (fracture point). Run ${threadCmd} now to preserve output quality.`,
};
let classified;
try {
classified = classifyContextUtilization(Number(opts['tokens-used']), Number(opts['context-window']));
} catch (e) {
const flag = /tokensUsed/.test(e.message) ? '--tokens-used' : '--context-window';
error(`${flag} must be a non-negative integer (window > 0), got the values supplied`);
return;
}
const result = { ...classified, recommendation: RECOMMENDATIONS[classified.state] };
if (args.includes('--json')) {
outputFn(result, raw);
} else {
const lines = [`Context utilization: ${result.percent}% (${result.state})`];
if (result.recommendation) lines.push(result.recommendation);
outputFn(result, true, lines.join('\n'));
}
},
},
});
}
module.exports = {

View File

@@ -1,32 +1,120 @@
'use strict';
const { VERIFY_SUBCOMMANDS } = require('./command-aliases.generated.cjs');
const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs');
const { output } = require('./core.cjs');
// ─── SDK bridge (Phase 6) — shared loader via cjs-sdk-bridge.cjs ──────────────
const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs');
/**
* Manifest-backed verify subcommand router.
* Keeps gsd-tools.cjs thin while preserving existing command semantics.
*
* Phase 6: all verify.* subcommands have SDK equivalents and are dispatched
* via executeForCjs (the sync bridge). CJS fallback retained when:
* - GSD_WORKSTREAM is active (workstream-scoped requests fall through to CJS).
* - SDK is unavailable (build not present).
*
* CJS-only subcommands: none.
* SDK-only (unsupported in CJS router): none.
*/
function routeVerifyCommand({ verify, args, cwd, raw, error }) {
const subcommand = args[1];
const activeWorkstream = process.env.GSD_WORKSTREAM;
const sdkAvailable = !activeWorkstream && tryLoadSdk();
if (subcommand === 'plan-structure') {
verify.cmdVerifyPlanStructure(cwd, args[2], raw);
} else if (subcommand === 'phase-completeness') {
verify.cmdVerifyPhaseCompleteness(cwd, args[2], raw);
} else if (subcommand === 'references') {
verify.cmdVerifyReferences(cwd, args[2], raw);
} else if (subcommand === 'commits') {
verify.cmdVerifyCommits(cwd, args.slice(2), raw);
} else if (subcommand === 'artifacts') {
verify.cmdVerifyArtifacts(cwd, args[2], raw);
} else if (subcommand === 'key-links') {
verify.cmdVerifyKeyLinks(cwd, args[2], raw);
} else if (subcommand === 'schema-drift') {
const rest = args.slice(2);
const skipFlag = rest.includes('--skip');
const phaseArg = rest.find((arg) => !arg.startsWith('-'));
verify.cmdVerifySchemaDrift(cwd, phaseArg, skipFlag, raw);
} else if (subcommand === 'codebase-drift') {
verify.cmdVerifyCodebaseDrift(cwd, raw);
} else {
error(`Unknown verify subcommand. Available: ${VERIFY_SUBCOMMANDS.join(', ')}`);
function sdkHandler(registryCommand, registryArgs, legacyArgs, cjsFallback) {
if (!sdkAvailable) return cjsFallback;
return () => {
const result = getExecuteForCjs()({
registryCommand,
registryArgs,
legacyCommand: 'verify',
legacyArgs,
// #3631: under --raw, request mode:'raw' so the bridge runs the SDK's
// raw projection (formatQueryRawOutput) and returns the scalar string
// CJS callers used to print. We then bypass output()'s JSON-stringify
// path by passing rawValue (the third positional). With mode:'json',
// output() emits the JSON IR as before.
mode: raw ? 'raw' : 'json',
projectDir: cwd,
});
if (!result.ok) {
error(result.errorDetails && result.errorDetails.message
? result.errorDetails.message
: `verify ${registryCommand} failed (${result.errorKind})`);
return;
}
if (raw) {
output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? ''));
} else {
output(result.data);
}
};
}
routeCjsCommandFamily({
args,
subcommands: VERIFY_SUBCOMMANDS,
unsupported: {},
error,
unknownMessage: (_subcommand, available) => `Unknown verify subcommand. Available: ${available.join(', ')}`,
handlers: {
'plan-structure': sdkHandler(
'verify.plan-structure',
args.slice(2),
args.slice(1),
() => verify.cmdVerifyPlanStructure(cwd, args[2], raw),
),
'phase-completeness': sdkHandler(
'verify.phase-completeness',
args.slice(2),
args.slice(1),
() => verify.cmdVerifyPhaseCompleteness(cwd, args[2], raw),
),
references: sdkHandler(
'verify.references',
args.slice(2),
args.slice(1),
() => verify.cmdVerifyReferences(cwd, args[2], raw),
),
commits: sdkHandler(
'verify.commits',
args.slice(2),
args.slice(1),
() => verify.cmdVerifyCommits(cwd, args.slice(2), raw),
),
artifacts: sdkHandler(
'verify.artifacts',
args.slice(2),
args.slice(1),
() => verify.cmdVerifyArtifacts(cwd, args[2], raw),
),
'key-links': sdkHandler(
'verify.key-links',
args.slice(2),
args.slice(1),
() => verify.cmdVerifyKeyLinks(cwd, args[2], raw),
),
'schema-drift': sdkHandler(
'verify.schema-drift',
args.slice(2),
args.slice(1),
() => {
const rest = args.slice(2);
const skipFlag = rest.includes('--skip');
const phaseArg = rest.find((arg) => !arg.startsWith('-'));
verify.cmdVerifySchemaDrift(cwd, phaseArg, skipFlag, raw);
},
),
// verify codebase-drift dispatches direct to CJS — drift is out-of-seam
// per ADR/PRD 3524 §3 / L160 (CJS-only by design). Routing through
// sdkHandler would re-enter the SDK bridge, and Phase 6's removed
// verifyCodebaseDrift stub used to execFileSync back to the CLI,
// creating an infinite spawn loop.
'codebase-drift': () => verify.cmdVerifyCodebaseDrift(cwd, raw),
},
});
}
module.exports = {

View File

@@ -1,33 +1,19 @@
/**
* Workstream Name Policy Module
* Workstream Name Policy Module — CJS adapter.
*
* Owns canonical name validation and slug normalization used by workstream and
* active-pointer callers.
* The implementation is generated from sdk/src/workstream-name-policy.ts and
* lives in workstream-name-policy.generated.cjs. This file is a thin re-export
* so that existing call sites (active-workstream-store.cjs,
* planning-workspace.cjs, workstream.cjs, and tests) can continue to
* require('./workstream-name-policy') unchanged.
*
* Exports (from generated file):
* - toWorkstreamSlug(name) — normalize to URL/filesystem slug
* - hasInvalidPathSegment(name) — true if name has slashes or dot-dot
* - isValidActiveWorkstreamName(name) — true if name passes all policy rules
* - validateWorkstreamName(name) — SDK alias for isValidActiveWorkstreamName
*
* Regenerate: cd sdk && npm run gen:workstream-name-policy
*/
const ACTIVE_WORKSTREAM_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
function toWorkstreamSlug(name) {
return String(name || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function hasInvalidPathSegment(name) {
const value = String(name || '');
return /[/\\]/.test(value) || value === '.' || value === '..' || value.includes('..');
}
function isValidActiveWorkstreamName(name) {
const value = String(name || '');
if (value === '..' || value.startsWith('../') || value.includes('..')) return false;
return ACTIVE_WORKSTREAM_RE.test(value);
}
module.exports = {
toWorkstreamSlug,
hasInvalidPathSegment,
isValidActiveWorkstreamName,
};
module.exports = require('./workstream-name-policy.generated.cjs');

View File

@@ -0,0 +1,61 @@
'use strict';
/**
* GENERATED FILE — DO NOT EDIT.
*
* Source: sdk/src/workstream-name-policy.ts
* Regenerate: cd sdk && npm run gen:workstream-name-policy
*
* Canonical workstream name validation and slug normalization.
* Used by active-workstream-store.cjs, planning-workspace.cjs, workstream.cjs.
*/
const ACTIVE_WORKSTREAM_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
/**
* Validate a workstream name.
* Allowed: alphanumeric, hyphens, underscores, dots.
* Disallowed: empty, spaces, slashes, special chars, path traversal.
*
* Alias for isValidActiveWorkstreamName; provided for SDK-layer callers.
*/
function validateWorkstreamName(name) {
return isValidActiveWorkstreamName(name);
}
/**
* Convert a display name to a URL/filesystem-safe workstream slug.
* Lowercases, collapses non-alphanumeric runs to hyphens, strips leading/trailing hyphens.
*/
function toWorkstreamSlug(name) {
return String(name || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
/**
* Returns true when `name` contains a path separator, a bare dot, or a
* dot-dot sequence — any of which would make the name unsafe for use as a
* filesystem path segment.
*/
function hasInvalidPathSegment(name) {
const value = String(name || '');
return /[/\\]/.test(value) || value === '.' || value === '..' || value.includes('..');
}
/**
* Returns true when `name` is a valid active workstream name:
* - Must start with alphanumeric
* - May contain alphanumeric, dots, underscores, hyphens
* - Must not contain path traversal sequences (..)
*/
function isValidActiveWorkstreamName(name) {
const value = String(name || '');
if (value === '..' || value.startsWith('../') || value.includes('..'))
return false;
return ACTIVE_WORKSTREAM_RE.test(value);
}
module.exports = {
validateWorkstreamName,
toWorkstreamSlug,
hasInvalidPathSegment,
isValidActiveWorkstreamName,
};

View File

@@ -6,7 +6,7 @@ error here MUST fall through and continue to `verify_phase_goal`. The phase
is never failed by this gate.
```bash
DRIFT=$(gsd-sdk query verify.codebase-drift 2>/dev/null || echo '{"skipped":true,"reason":"sdk-failed"}')
DRIFT=$(gsd-tools verify codebase-drift 2>/dev/null || echo '{"skipped":true,"reason":"sdk-failed"}')
```
Parse JSON for: `skipped`, `reason`, `action_required`, `directive`,

View File

@@ -45,7 +45,7 @@ When `TDD_MODE` is `true`, the planner agent is instructed to apply `type: tdd`
When `CONTEXT_WINDOW >= 500000`, the planner prompt includes the 3 most recent prior phase CONTEXT.md and SUMMARY.md files PLUS any phases explicitly listed in the current phase's `Depends on:` field in ROADMAP.md. Explicit dependencies always load regardless of recency (e.g., Phase 7 declaring `Depends on: Phase 2` always sees Phase 2's context). Bounded recency keeps the planner's context budget focused on recent work.
Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_enabled`, `plan_checker_enabled`, `nyquist_validation_enabled`, `commit_docs`, `text_mode`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_reviews`, `has_plans`, `plan_count`, `planning_exists`, `roadmap_exists`, `phase_req_ids`, `response_language`.
Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_enabled`, `plan_checker_enabled`, `nyquist_validation_enabled`, `commit_docs`, `text_mode`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_reviews`, `has_plans`, `plan_count`, `phase_status` (#3569), `planning_exists`, `roadmap_exists`, `phase_req_ids`, `response_language`.
**If `response_language` is set:** Include `response_language: {value}` in all spawned subagent prompts so any user-facing output stays in the configured language.
@@ -53,9 +53,52 @@ Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_
**If `planning_exists` is false:** Error — run `/gsd:new-project` first.
## 1.5. Closed-Phase Gate (#3569)
The init JSON includes `phase_status` — one of `Pending | Planned | In Progress | Executed | Complete | Needs Review`. `Complete` means the phase has all summaries AND a `VERIFICATION.md` with `status: passed`. Replanning a closed phase silently rewrites plan docs that no longer match the shipped code, so the workflow must hard-stop here unless the operator explicitly overrides.
Parse `phase_status` from the init JSON, then:
```bash
FORCE_REPLAN=false
if [[ "$ARGUMENTS" =~ (^|[[:space:]])--force([[:space:]]|$) ]]; then
FORCE_REPLAN=true
fi
if [ "${phase_status}" = "Complete" ]; then
if [[ "$ARGUMENTS" =~ (^|[[:space:]])--reviews([[:space:]]|$) ]]; then
# --reviews on a closed phase is never legitimate — concerns belong in a
# new phase or issue against the closed phase's commits.
cat <<EOF >&2
Phase ${phase_number} (${phase_name}) is already CLOSED (VERIFICATION status: passed).
/gsd:plan-phase --reviews cannot replan a closed phase. If the review surfaced
real concerns, open a follow-up phase or file an issue against the closed
phase's commits. There is no --force override for --reviews on a closed phase.
EOF
exit 1
fi
if [ "$FORCE_REPLAN" != "true" ]; then
cat <<EOF >&2
Phase ${phase_number} (${phase_name}) is already CLOSED (VERIFICATION status: passed).
Replanning a closed phase will overwrite plan docs that no longer match the
shipped code. If you intentionally want to replan over closed work, re-run
with: /gsd:plan-phase ${phase_number} --force
Otherwise, to view what shipped, see: ${verification_path}
EOF
exit 1
fi
# FORCE_REPLAN=true: continue, but emit a banner so the operator sees the
# decision in the transcript and in any committed plan docs.
echo "WARNING: Replanning CLOSED phase ${phase_number} under --force. Verify the closeout was wrong before committing new plan docs." >&2
fi
```
The gate fires only on `Complete`. `Executed` and `Needs Review` are not gated — those states mean planning was finished but verification did not pass, and replanning is a legitimate next step.
## 2. Parse and Normalize Arguments
Extract from $ARGUMENTS: phase number (integer or decimal like `2.1`), flags (`--research`, `--skip-research`, `--research-phase <N>`, `--gaps`, `--skip-verify`, `--skip-ui`, `--prd <filepath>`, `--ingest <path-or-glob>`, `--ingest-format <auto|nygard|madr|narrative>`, `--reviews`, `--text`, `--bounce`, `--skip-bounce`, `--chunked`, `--mvp`).
Extract from $ARGUMENTS: phase number (integer or decimal like `2.1`), flags (`--research`, `--skip-research`, `--research-phase <N>`, `--gaps`, `--skip-verify`, `--skip-ui`, `--prd <filepath>`, `--ingest <path-or-glob>`, `--ingest-format <auto|nygard|madr|narrative>`, `--reviews`, `--text`, `--bounce`, `--skip-bounce`, `--chunked`, `--mvp`, `--force` (override closed-phase gate, see §1.5)).
**`--research-phase <N>` — research-only mode (#3042 + #3044).** When this flag is present, parse `<N>` as the phase number (overrides any positional phase argument), set `RESEARCH_ONLY=true`, and treat the rest of this workflow as a research-dispatch only — the planner spawn (step 8), plan-checker, verification, gaps, bounce, and post-planning-gaps blocks all skip on `RESEARCH_ONLY`. Use this for cross-phase research, doc review before committing to a planning approach, and correction-without-replanning loops. Replaces the deleted `/gsd-research-phase` command.

View File

@@ -17,7 +17,7 @@ Sync managed `gsd-*` skill directories from one canonical runtime's skills root
If neither `--dry-run` nor `--apply` is specified, dry-run is the default.
**Supported runtime names:** `claude`, `codex`, `copilot`, `cursor`, `windsurf`, `opencode`, `gemini`, `kilo`, `augment`, `trae`, `qwen`, `codebuddy`, `cline`, `antigravity`
**Supported runtime names:** `claude`, `codex`, `grok`, `copilot`, `cursor`, `windsurf`, `opencode`, `gemini`, `kilo`, `augment`, `trae`, `qwen`, `codebuddy`, `cline`, `antigravity` (grok uses the `~/.agents` layout)
---
@@ -35,7 +35,7 @@ fi
# Parse --to
if [[ "$@" == *"--to all"* ]]; then
TO_RUNTIMES=(claude codex copilot cursor windsurf opencode gemini kilo augment trae qwen codebuddy cline antigravity)
TO_RUNTIMES=(claude codex grok copilot cursor windsurf opencode gemini kilo augment trae qwen codebuddy cline antigravity)
elif [[ "$@" == *"--to"* ]]; then
TO_RUNTIMES=( $(echo "$@" | grep -oP '(?<=--to )\S+') )
fi