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:
@@ -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.
|
||||
|
||||
136
get-shit-done/bin/lib/cjs-sdk-bridge.cjs
Normal file
136
get-shit-done/bin/lib/cjs-sdk-bridge.cjs
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -1012,6 +1012,7 @@ function cmdCheckCommit(cwd, raw) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
determinePhaseStatus,
|
||||
cmdGenerateSlug,
|
||||
cmdCurrentTimestamp,
|
||||
cmdListTodos,
|
||||
|
||||
@@ -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');
|
||||
|
||||
121
get-shit-done/bin/lib/decisions.generated.cjs
Normal file
121
get-shit-done/bin/lib/decisions.generated.cjs
Normal 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 };
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
97
get-shit-done/bin/lib/plan-scan.generated.cjs
Normal file
97
get-shit-done/bin/lib/plan-scan.generated.cjs
Normal 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;
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
170
get-shit-done/bin/lib/schema-detect.generated.cjs
Normal file
170
get-shit-done/bin/lib/schema-detect.generated.cjs
Normal 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,
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
37
get-shit-done/bin/lib/secrets.generated.cjs
Normal file
37
get-shit-done/bin/lib/secrets.generated.cjs
Normal 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 };
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
|
||||
61
get-shit-done/bin/lib/workstream-name-policy.generated.cjs
Normal file
61
get-shit-done/bin/lib/workstream-name-policy.generated.cjs
Normal 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,
|
||||
};
|
||||
@@ -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`,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user