Merge branch 'next' into feat/1820-specless-predicate-rail
This commit is contained in:
@@ -46,7 +46,7 @@ const { loadConfig } = configLoaderMod;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import installProfilesMod = require('./install-profiles.cjs');
|
||||
const { readActiveProfile, loadSkillsManifest, resolveProfile, parseRequires } = installProfilesMod;
|
||||
const { readActiveProfile, loadSkillsManifest, resolveProfile, parseRequires, parseCallsAgents } = installProfilesMod;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import surfaceMod = require('./surface.cjs');
|
||||
@@ -384,22 +384,84 @@ function _loadInstalledSkillsManifest(configDir: string): Map<string, string[]>
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* #1858 — Build a skill dependency manifest from a FLAT commands/gsd-<stem>.md
|
||||
* source layout (the Claude local project install shape, where the `gsd-`
|
||||
* prefix is baked into each filename at the commands/ level and there is no
|
||||
* commands/gsd/ subdir). Strips the `gsd-` prefix so stems match the nested
|
||||
* loader's output (gsd-validate-phase.md → validate-phase, same as nested
|
||||
* validate-phase.md).
|
||||
*
|
||||
* Map shape is identical to loadSkillsManifest: each stem maps to its
|
||||
* `requires` deps (parsed via the same shared parseRequires) and carries a
|
||||
* companion `_calls_agents_<stem>` key (parsed via parseCallsAgents) so the
|
||||
* flat and nested paths cannot drift.
|
||||
*
|
||||
* Returns an empty Map when the parent directory does not exist or contains
|
||||
* no gsd-*.md files (so _resolveManifest can use size>0 as the "flat layout
|
||||
* present" signal and fall through to the installed-skills branch otherwise).
|
||||
*/
|
||||
function _loadFlatCommandsGsdManifest(commandsParentDir: string): Map<string, string[]> {
|
||||
const manifest = new Map<string, string[]>();
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(commandsParentDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return manifest;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.startsWith('gsd-')) continue;
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
// Strip 'gsd-' prefix (4 chars) and '.md' suffix (3 chars) → stem.
|
||||
const stem = entry.name.slice(4, -3);
|
||||
if (!stem) continue;
|
||||
// Mirror loadSkillsManifest's try/catch structure exactly: wrap read +
|
||||
// parse + set together so an unreadable file OR a thrown parser degrades
|
||||
// both keys to [] (parity; closes the latent catch-scope drift a reviewer
|
||||
// flagged — both parsers are non-throwing today, but the structural
|
||||
// match future-proofs the "identical Map shape" contract).
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(commandsParentDir, entry.name), 'utf8');
|
||||
manifest.set(stem, parseRequires(content));
|
||||
manifest.set(`_calls_agents_${stem}`, parseCallsAgents(content));
|
||||
} catch {
|
||||
manifest.set(stem, []);
|
||||
manifest.set(`_calls_agents_${stem}`, []);
|
||||
}
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the skill dependency manifest for capability-state resolution.
|
||||
*
|
||||
* Resolution order (fixes #1160 — installed-runtime capability surface):
|
||||
* 1. If commandsGsdDir exists, load from source (repo-checkout behavior).
|
||||
* 2. Otherwise, fall back to installed skills at configDir/skills/gsd-[stem]/SKILL.md.
|
||||
* Resolution order:
|
||||
* 1. If commandsGsdDir exists, load from the nested source layout
|
||||
* (repo-checkout behavior: <repo>/commands/gsd/*.md).
|
||||
* 2. #1858 — otherwise, if the flat source layout is present (gsd-<stem>.md
|
||||
* files in dirname(commandsGsdDir)), load from there. This is the Claude
|
||||
* local project install shape where commands/gsd/ does not exist but
|
||||
* commands/gsd-<stem>.md files do.
|
||||
* 3. #1160 — otherwise, fall back to installed skills at
|
||||
* configDir/skills/gsd-[stem]/SKILL.md.
|
||||
*
|
||||
* In an installed runtime the commands/gsd source tree is absent; only the
|
||||
* skills/ layout exists. Returning an empty manifest caused resolveSurface to
|
||||
* materialise the full-sentinel to an empty Set, making every capability appear
|
||||
* unsurfaced even when the skill was physically installed.
|
||||
* In an installed runtime both source trees are absent; only the skills/
|
||||
* layout exists. Returning an empty manifest caused resolveSurface to
|
||||
* materialise the full-sentinel to an empty Set, making every skill-bearing
|
||||
* capability appear unsurfaced even when the skill was physically installed
|
||||
* (#1160) or authored as a flat command file (#1858).
|
||||
*/
|
||||
function _resolveManifest(commandsGsdDir: string, configDir: string): Map<string, string[]> {
|
||||
if (fs.existsSync(commandsGsdDir)) {
|
||||
return loadSkillsManifest(commandsGsdDir);
|
||||
}
|
||||
// #1858: flat source layout — gsd-<stem>.md files at dirname(commandsGsdDir).
|
||||
// Only claim the flat branch when it actually has gsd-*.md files; otherwise
|
||||
// fall through to the installed-skills branch (a commands/ dir with no gsd
|
||||
// files must not shadow an installed skills/ tree).
|
||||
const flat = _loadFlatCommandsGsdManifest(path.dirname(commandsGsdDir));
|
||||
if (flat.size > 0) return flat;
|
||||
return _loadInstalledSkillsManifest(configDir);
|
||||
}
|
||||
|
||||
@@ -441,6 +503,7 @@ function resolveCapabilityRuntimeState(
|
||||
cwd: string,
|
||||
runtimeConfigDir: string | undefined | null,
|
||||
configOverride?: Record<string, unknown>,
|
||||
runtimeOverride?: string,
|
||||
): ResolveCapabilityRuntimeStateResult {
|
||||
const warnings: string[] = [];
|
||||
|
||||
@@ -460,15 +523,45 @@ function resolveCapabilityRuntimeState(
|
||||
const runtimeHomes = require('./runtime-homes.cjs') as {
|
||||
getGlobalConfigDir: (runtime: string) => string;
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const runtimeSlash = require('./runtime-slash.cjs') as {
|
||||
resolveRuntime: (projectDir: string | null | undefined) => string;
|
||||
};
|
||||
// Detect the active runtime via GSD_RUNTIME → config.runtime → 'claude'.
|
||||
// resolveRuntime reads config.json directly (no side effects) and returns
|
||||
// a lowercased canonical runtime name.
|
||||
const detectedRuntime = runtimeSlash.resolveRuntime(cwd);
|
||||
resolvedConfigDir = runtimeHomes.getGlobalConfigDir(detectedRuntime);
|
||||
// #2003: an explicit --runtime override bypasses the persisted-runtime
|
||||
// fallback (GSD_RUNTIME → config.runtime → 'claude') so, e.g., a repo with
|
||||
// persisted runtime:"codex" resolves the Claude config dir when the operator
|
||||
// is driving from Claude Code. Canonicalize via runtime-name-policy (handles
|
||||
// aliases like codex-app → codex); if canonicalization yields nothing, fall
|
||||
// through to the persisted-runtime resolution below. Mirrors the update-
|
||||
// context / effort sync precedent (read/diagnostic paths accepting both
|
||||
// --config-dir and --runtime).
|
||||
if (typeof runtimeOverride === 'string' && runtimeOverride.trim() !== '') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const runtimeNamePolicy = require('./runtime-name-policy.cjs') as {
|
||||
canonicalizeRuntimeName: (value: unknown) => string | null;
|
||||
};
|
||||
const canonical = runtimeNamePolicy.canonicalizeRuntimeName(runtimeOverride);
|
||||
if (canonical) {
|
||||
resolvedConfigDir = runtimeHomes.getGlobalConfigDir(canonical);
|
||||
} else {
|
||||
// #2003: unknown runtime override — warn (don't silently ignore the
|
||||
// explicit input) and fall through to persisted-runtime resolution.
|
||||
// Avoids a silent-wrong-result on this diagnostic command for typos
|
||||
// (e.g. "cluade") or runtimes known to runtime-homes but not yet to
|
||||
// the alias manifest (e.g. "grok"). The warning surfaces via the
|
||||
// `warnings[]` channel consumed by cmdCapabilityState/cmdLoopRenderHooks.
|
||||
warnings.push(
|
||||
`--runtime "${runtimeOverride}" is not a known runtime; falling back to auto-detected/persisted runtime resolution`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!resolvedConfigDir) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const runtimeSlash = require('./runtime-slash.cjs') as {
|
||||
resolveRuntime: (projectDir: string | null | undefined) => string;
|
||||
};
|
||||
// Detect the active runtime via GSD_RUNTIME → config.runtime → 'claude'.
|
||||
// resolveRuntime reads config.json directly (no side effects) and returns
|
||||
// a lowercased canonical runtime name.
|
||||
const detectedRuntime = runtimeSlash.resolveRuntime(cwd);
|
||||
resolvedConfigDir = runtimeHomes.getGlobalConfigDir(detectedRuntime);
|
||||
}
|
||||
} catch {
|
||||
// Defensive fallback: use ~/.claude if the canonical resolver throws.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
@@ -569,9 +662,12 @@ function cmdCapabilityState(
|
||||
cwd: string,
|
||||
runtimeConfigDir: string | undefined | null,
|
||||
raw: boolean,
|
||||
_options: Record<string, unknown> = {},
|
||||
options: Record<string, unknown> = {},
|
||||
): void {
|
||||
const result = resolveCapabilityRuntimeState(cwd, runtimeConfigDir);
|
||||
// #2003: thread an explicit --runtime override so the config-dir resolution
|
||||
// bypasses the persisted-runtime fallback (GSD_RUNTIME → config.runtime).
|
||||
const runtimeOverride = typeof options['runtime'] === 'string' ? options['runtime'] : undefined;
|
||||
const result = resolveCapabilityRuntimeState(cwd, runtimeConfigDir, undefined, runtimeOverride);
|
||||
for (const warning of result.warnings) {
|
||||
coreError(`capability state: ${warning}`);
|
||||
}
|
||||
@@ -619,6 +715,7 @@ export = {
|
||||
// Exported for tests
|
||||
_resolveCommandsGsdDir,
|
||||
_loadInstalledSkillsManifest,
|
||||
_loadFlatCommandsGsdManifest,
|
||||
_resolveManifest,
|
||||
_isSafePropKey,
|
||||
};
|
||||
|
||||
159
src/claude-orchestration-command-router.cts
Normal file
159
src/claude-orchestration-command-router.cts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Claude orchestration command router — CLI dispatcher for
|
||||
* `gsd-tools claude-orchestration <subcommand>`.
|
||||
*
|
||||
* #1143 — thin CLI adapter over the pure `claude-orchestration.cjs` module.
|
||||
* Lets execute-phase (or any orchestrator) invoke the Workflow-backend
|
||||
* detection and the Workflow-script emitter through the standard capability
|
||||
* command surface (ADR-959) instead of a bare `require()`.
|
||||
*
|
||||
* Router signature: { args, cwd, raw, error } — identical to the other host
|
||||
* routers; discovered by dispatchCapabilityCommand via the registry's
|
||||
* commandFamilies index.
|
||||
*
|
||||
* Subcommands:
|
||||
* detect-backend [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch]
|
||||
* Resolves whether the Workflow backend should activate. `--runtime`
|
||||
* defaults to the GSD_RUNTIME env var (or 'unknown'). Reads the
|
||||
* `claude_orchestration.*` keys from .planning/config.json. Emits
|
||||
* { available, backend, reason }.
|
||||
*
|
||||
* emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]
|
||||
* Reads a wave/plan manifest JSON file and emits the generated Workflow
|
||||
* script + summary. The manifest shape matches emitWorkflowScript's input:
|
||||
* { waves: [{ id, plans: [{ id, brief, files_modified: string[] }] }] }.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import io = require('./io.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import core = require('./claude-orchestration.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import configLoader = require('./config-loader.cjs');
|
||||
|
||||
const { output } = io;
|
||||
const { detectWorkflowBackend, emitWorkflowScript } = core;
|
||||
|
||||
const CAPABLE_HOST = { dispatch: { nested: true, background: true } };
|
||||
|
||||
interface RouterOpts {
|
||||
args: string[];
|
||||
cwd: string;
|
||||
raw: boolean;
|
||||
error: (msg: string, reason?: string) => void;
|
||||
}
|
||||
|
||||
function usage(error: (msg: string, reason?: string) => void): void {
|
||||
error(
|
||||
'Usage: gsd-tools claude-orchestration <detect-backend|emit-workflow> [...]\n' +
|
||||
' detect-backend [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch]\n' +
|
||||
' emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]',
|
||||
);
|
||||
}
|
||||
|
||||
function argValue(args: string[], flag: string): string | undefined {
|
||||
const i = args.indexOf(flag);
|
||||
return i !== -1 && i + 1 < args.length ? args[i + 1] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether the Workflow backend should activate for the current/given
|
||||
* runtime. Reads `claude_orchestration.*` from the project config; runtime and
|
||||
* SDK version come from flags (the orchestrator already knows these) or env.
|
||||
*/
|
||||
function cmdDetectBackend(args: string[], cwd: string, raw: boolean): void {
|
||||
const runtimeId = argValue(args, '--runtime') || process.env['GSD_RUNTIME'] || 'unknown';
|
||||
const agentSdkVersion = argValue(args, '--agent-sdk-version');
|
||||
const noNested = args.includes('--no-nested-dispatch');
|
||||
const hostIntegration = noNested ? { dispatch: { nested: false, background: true } } : CAPABLE_HOST;
|
||||
|
||||
// Resolve the claude_orchestration.* slice from the project config (federated
|
||||
// keys are merged by loadConfig as a nested object). A config read failure
|
||||
// degrades to inline — it must not break the core loop.
|
||||
let claudeSlice: Record<string, unknown> = {};
|
||||
try {
|
||||
const loaded = configLoader.loadConfig(cwd);
|
||||
const slice = loaded['claude_orchestration'];
|
||||
if (slice && typeof slice === 'object' && !Array.isArray(slice)) {
|
||||
claudeSlice = slice as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
claudeSlice = {};
|
||||
}
|
||||
|
||||
// Flatten the nested slice into the dotted-key shape detectWorkflowBackend expects.
|
||||
const flatConfig: Record<string, unknown> = {};
|
||||
for (const k of Object.keys(claudeSlice)) {
|
||||
flatConfig['claude_orchestration.' + k] = claudeSlice[k];
|
||||
}
|
||||
|
||||
const result = detectWorkflowBackend({ runtimeId, hostIntegration, config: flatConfig, agentSdkVersion });
|
||||
output(result, raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a Workflow script from a wave/plan manifest file.
|
||||
*/
|
||||
function cmdEmitWorkflow(args: string[], _cwd: string, raw: boolean, error: (msg: string, reason?: string) => void): void {
|
||||
const wavesPath = argValue(args, '--waves');
|
||||
const runId = argValue(args, '--run-id');
|
||||
const phaseDir = argValue(args, '--phase-dir') || '.planning/phases/current';
|
||||
const budgetRaw = argValue(args, '--budget');
|
||||
|
||||
if (!wavesPath) {
|
||||
error('emit-workflow requires --waves <path>');
|
||||
return;
|
||||
}
|
||||
if (!runId) {
|
||||
error('emit-workflow requires --run-id <id>');
|
||||
return;
|
||||
}
|
||||
|
||||
let waves: unknown;
|
||||
try {
|
||||
const content = fs.readFileSync(path.resolve(wavesPath), 'utf8');
|
||||
const parsed = JSON.parse(content) as Record<string, unknown>;
|
||||
waves = parsed['waves'];
|
||||
} catch (e) {
|
||||
error('emit-workflow: could not read/parse --waves file "' + wavesPath + '": ' + (e instanceof Error ? e.message : String(e)));
|
||||
return;
|
||||
}
|
||||
|
||||
const budgetTokens = budgetRaw !== undefined ? parseInt(budgetRaw, 10) : undefined;
|
||||
const budget = (typeof budgetTokens === 'number' && !Number.isNaN(budgetTokens)) ? budgetTokens : undefined;
|
||||
|
||||
const result = emitWorkflowScript({
|
||||
phaseDir,
|
||||
runId,
|
||||
waves: waves as EmitInput['waves'],
|
||||
budgetTokens: budget,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
error('emit-workflow: ' + result.reason);
|
||||
return;
|
||||
}
|
||||
output({ script: result.script, summary: result.summary }, raw);
|
||||
}
|
||||
|
||||
// Re-declared minimal input type for the cast above (avoids importing private types).
|
||||
interface EmitInput {
|
||||
waves: Array<{ id: string; plans: Array<{ id: string; brief: string; files_modified: string[] }> }>;
|
||||
}
|
||||
|
||||
function routeClaudeOrchestrationCommand(opts: RouterOpts): void {
|
||||
const { args, cwd, raw, error } = opts;
|
||||
// args[0] is the family ('claude-orchestration'); the subcommand is args[1].
|
||||
const subcommand = args[1];
|
||||
if (subcommand === 'detect-backend') {
|
||||
cmdDetectBackend(args, cwd, raw);
|
||||
} else if (subcommand === 'emit-workflow') {
|
||||
cmdEmitWorkflow(args, cwd, raw, error);
|
||||
} else {
|
||||
usage(error);
|
||||
}
|
||||
}
|
||||
|
||||
export = { routeClaudeOrchestrationCommand };
|
||||
485
src/claude-orchestration.cts
Normal file
485
src/claude-orchestration.cts
Normal file
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* Claude Orchestration Capability — Workflow-tool backend detection + emitter
|
||||
*
|
||||
* #1143 — adopts Claude Code's Workflow tool (the engine behind `/effort ultracode`)
|
||||
* as an optional, runtime-gated parallel-execution backend for the GSD loop.
|
||||
*
|
||||
* This module is the pure, testable core of the capability. It owns two seams:
|
||||
*
|
||||
* detectWorkflowBackend({ runtimeId, hostIntegration, config, agentSdkVersion })
|
||||
* → { available: boolean, backend: 'workflow'|'inline', reason: string }
|
||||
* Fail-closed: every miss degrades to `inline` (today's behaviour), so the
|
||||
* core loop is byte-identical unless every gate opens. This is criteria 3 + 6.
|
||||
*
|
||||
* emitWorkflowScript({ phaseDir, waves, runId, budgetTokens? })
|
||||
* → { ok:true, script, summary } | { ok:false, reason }
|
||||
* Maps GSD's wave/plan model 1:1 onto Workflow primitives:
|
||||
* wave → sequential `parallel()` stage barriers,
|
||||
* plan → `agent(brief, { agentType:'gsd-executor', isolation:'worktree' })`,
|
||||
* files_modified overlap → forces plans into separate sequential stages
|
||||
* (the same overlap rule execute-phase already applies inline),
|
||||
* resumeFromRunId → wired to the phase run id,
|
||||
* budgetTokens → a shared token pool.
|
||||
* The emitted script composes the SAME gsd-executor agent and worktree
|
||||
* isolation the inline path uses, so it produces the same artifacts/commits
|
||||
* (criterion 2). It is a generated string consumed by the orchestrator; this
|
||||
* module never invokes the Workflow tool itself.
|
||||
*
|
||||
* Design laws:
|
||||
* - Gall's Law: ship a small working slice that composes existing primitives
|
||||
* (gsd-executor + worktree isolation) rather than reinventing them.
|
||||
* - Greenspun's Tenth Rule (cited in #1143): adopt the Workflow tool's
|
||||
* barrier/pipeline/budget/resume semantics instead of hand-rolling them.
|
||||
* - Postel's Law: liberal in input (missing fields → inline), conservative in
|
||||
* output (workflow only when every gate opens).
|
||||
* - Fail-closed: an unknown version, a missing descriptor, or a disabled
|
||||
* toggle all resolve to `inline`, never to `workflow`.
|
||||
*
|
||||
* Zero external dependencies. Pure functions. Never throws on bad input.
|
||||
*/
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The Agent SDK version that introduced the Workflow tool (#1143 prior art).
|
||||
* Used as the default floor when config does not override it. A runtime reporting
|
||||
* an agentSdkVersion below this cannot host the Workflow backend.
|
||||
*/
|
||||
const WORKFLOW_TOOL_FLOOR_VERSION = '0.3.149';
|
||||
|
||||
/** Closed enum for the `claude_orchestration.execution_backend` config key. */
|
||||
const BACKEND_VALUES = new Set<string>(['auto', 'workflow', 'inline']);
|
||||
|
||||
/** Only this runtime can host the Workflow tool (Claude Code / Agent SDK). */
|
||||
const WORKFLOW_RUNTIME = 'claude';
|
||||
|
||||
// ─── Semver helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Official-ish strict SemVer 2.0.0 numeric triple (+ optional pre/build). */
|
||||
const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
|
||||
/** True for a syntactically valid semver string. */
|
||||
function isValidSemver(s: unknown): s is string {
|
||||
return typeof s === 'string' && SEMVER_RE.test(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two semver strings.
|
||||
* Returns -1/0/1 in the usual sense. Garbage in either position → -1 (fail-closed:
|
||||
* an unparseable version is treated as "less than" any real floor, so detection
|
||||
* never accidentally enables the preview backend on an unknown SDK).
|
||||
*
|
||||
* Pre-release/build metadata are ignored for the comparison — only the numeric
|
||||
* major.minor.patch triple participates, matching how the Workflow-tool floor is
|
||||
* specified (a plain "0.3.149").
|
||||
*/
|
||||
function compareSemver(a: string, b: string): number {
|
||||
if (!isValidSemver(a) || !isValidSemver(b)) return -1;
|
||||
// Split numeric triple from pre-release/build metadata.
|
||||
const parseTriple = (s: string): number[] => {
|
||||
const core = s.split('-')[0].split('+')[0].split('.');
|
||||
return [parseInt(core[0], 10), parseInt(core[1], 10), parseInt(core[2], 10)];
|
||||
};
|
||||
const hasPre = (s: string): boolean => s.indexOf('-') !== -1;
|
||||
const preIdentifiers = (s: string): string[] => (s.split('-')[1] || '').split('+')[0].split('.').filter((x) => x.length > 0);
|
||||
const am = parseTriple(a);
|
||||
const bm = parseTriple(b);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (am[i] < bm[i]) return -1;
|
||||
if (am[i] > bm[i]) return 1;
|
||||
}
|
||||
// Numeric triple is equal. SemVer 2.0.0 §11 precedence:
|
||||
// - a version WITH a pre-release tag is LOWER than the same triple WITHOUT one
|
||||
// (keeps the floor fail-closed for pre-release builds of the GA floor);
|
||||
// - two pre-releases of the same triple are ordered by their dot-separated
|
||||
// identifiers (numeric < alphanumeric; numeric compared numerically,
|
||||
// alphanumeric lexically; fewer identifiers < more).
|
||||
const aPre = hasPre(a);
|
||||
const bPre = hasPre(b);
|
||||
if (aPre && !bPre) return -1;
|
||||
if (!aPre && bPre) return 1;
|
||||
if (aPre && bPre) {
|
||||
const ai = preIdentifiers(a);
|
||||
const bi = preIdentifiers(b);
|
||||
const len = Math.min(ai.length, bi.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const ax = ai[i];
|
||||
const bx = bi[i];
|
||||
const aNum = /^\d+$/.test(ax);
|
||||
const bNum = /^\d+$/.test(bx);
|
||||
if (aNum && bNum) {
|
||||
const an = parseInt(ax, 10);
|
||||
const bn = parseInt(bx, 10);
|
||||
if (an < bn) return -1;
|
||||
if (an > bn) return 1;
|
||||
} else if (aNum && !bNum) {
|
||||
return -1; // numeric identifiers always lower than alphanumeric
|
||||
} else if (!aNum && bNum) {
|
||||
return 1;
|
||||
} else {
|
||||
if (ax < bx) return -1;
|
||||
if (ax > bx) return 1;
|
||||
}
|
||||
}
|
||||
if (ai.length < bi.length) return -1;
|
||||
if (ai.length > bi.length) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ─── detectWorkflowBackend ────────────────────────────────────────────────────
|
||||
|
||||
interface HostIntegration {
|
||||
dispatch?: {
|
||||
nested?: boolean;
|
||||
background?: boolean;
|
||||
backgroundDispatch?: boolean;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
interface BackendConfig {
|
||||
'claude_orchestration.enabled'?: unknown;
|
||||
'claude_orchestration.execution_backend'?: unknown;
|
||||
'claude_orchestration.min_agent_sdk_version'?: unknown;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
interface DetectInput {
|
||||
runtimeId?: string;
|
||||
hostIntegration?: HostIntegration | null;
|
||||
config?: BackendConfig | null;
|
||||
agentSdkVersion?: string;
|
||||
}
|
||||
|
||||
interface DetectResult {
|
||||
available: boolean;
|
||||
backend: 'workflow' | 'inline';
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Inline result shorthand. */
|
||||
function inline(reason: string, available = false): DetectResult {
|
||||
return { available, backend: 'inline', reason };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve whether the Workflow-tool backend should activate.
|
||||
*
|
||||
* Gate ladder (all must pass for `workflow`; first miss wins, fail-closed):
|
||||
* 1. capability enabled (claude_orchestration.enabled truthy)
|
||||
* 2. runtime is Claude (the only runtime that exposes the Workflow tool)
|
||||
* 3. execution_backend !== 'inline'
|
||||
* 4. host descriptor signals nested+background dispatch (Workflow-tool capable)
|
||||
* 5. agentSdkVersion is a known, valid semver
|
||||
* 6. agentSdkVersion >= the configured floor (default WORKFLOW_TOOL_FLOOR_VERSION)
|
||||
* 7. execution_backend === 'workflow' OR 'auto' (both reach here; 'inline' exited at 3)
|
||||
*
|
||||
* Never throws. Destructures defensively.
|
||||
*/
|
||||
function detectWorkflowBackend(input: DetectInput | null | undefined): DetectResult {
|
||||
if (input === null || input === undefined || typeof input !== 'object') {
|
||||
return inline('capability_disabled');
|
||||
}
|
||||
|
||||
const cfg: BackendConfig =
|
||||
(input.config !== null && input.config !== undefined && typeof input.config === 'object')
|
||||
? input.config
|
||||
: {};
|
||||
|
||||
// 1. capability must be opted in (default-off — ships disabled).
|
||||
if (!cfg['claude_orchestration.enabled']) {
|
||||
return inline('capability_disabled');
|
||||
}
|
||||
|
||||
// 2. only Claude can host the Workflow tool.
|
||||
if (input.runtimeId !== WORKFLOW_RUNTIME) {
|
||||
return inline('runtime_not_claude');
|
||||
}
|
||||
|
||||
// 3. explicit inline opt-out short-circuits.
|
||||
let backendRaw = cfg['claude_orchestration.execution_backend'];
|
||||
if (typeof backendRaw !== 'string' || !BACKEND_VALUES.has(backendRaw)) {
|
||||
backendRaw = 'auto';
|
||||
}
|
||||
if (backendRaw === 'inline') {
|
||||
return inline('backend_inline');
|
||||
}
|
||||
|
||||
// 4. the host dispatch descriptor must be the nesting-capable Claude-Code shape
|
||||
// (a proxy for Workflow-tool presence). This is Claude-specific and already
|
||||
// gated at step 2; `background:true` alone is true on several non-Claude hosts,
|
||||
// so the proxy is only meaningful after the runtime check above. Note: this is
|
||||
// NOT the canonical `shouldFlattenDispatch` rule (which keys on
|
||||
// `backgroundDispatch`); the Workflow backend works precisely because a single
|
||||
// tool-call orchestrates internally, sidestepping the backgroundDispatch:false
|
||||
// limitation. Missing/false/foreign descriptor → fail-closed.
|
||||
const hi = input.hostIntegration;
|
||||
if (hi === null || hi === undefined || typeof hi !== 'object' || Array.isArray(hi)) {
|
||||
return inline('workflow_tool_unavailable');
|
||||
}
|
||||
const dispatch = (hi as { dispatch?: Record<string, unknown> }).dispatch;
|
||||
if (typeof dispatch !== 'object' || dispatch === null || Array.isArray(dispatch)) {
|
||||
return inline('workflow_tool_unavailable');
|
||||
}
|
||||
const nested = dispatch['nested'];
|
||||
const background = dispatch['background'];
|
||||
if (nested !== true || background !== true) {
|
||||
return inline('workflow_tool_unavailable');
|
||||
}
|
||||
|
||||
// 5. an unknown agentSdkVersion cannot be trusted to meet the floor.
|
||||
if (!isValidSemver(input.agentSdkVersion)) {
|
||||
return inline('agent_sdk_version_unknown');
|
||||
}
|
||||
|
||||
// 6. version floor (config override > default constant).
|
||||
const floorRaw = cfg['claude_orchestration.min_agent_sdk_version'];
|
||||
const floor = typeof floorRaw === 'string' && isValidSemver(floorRaw) ? floorRaw : WORKFLOW_TOOL_FLOOR_VERSION;
|
||||
if (compareSemver(input.agentSdkVersion, floor) < 0) {
|
||||
return inline('agent_sdk_version_below_floor');
|
||||
}
|
||||
|
||||
// 7. auto/workflow both reach the workflow backend once every gate passes.
|
||||
return { available: true, backend: 'workflow', reason: 'workflow_backend_active' };
|
||||
}
|
||||
|
||||
// ─── emitWorkflowScript ───────────────────────────────────────────────────────
|
||||
|
||||
interface Plan {
|
||||
id: string;
|
||||
brief: string;
|
||||
files_modified: string[];
|
||||
}
|
||||
|
||||
interface Wave {
|
||||
id: string;
|
||||
plans: Plan[];
|
||||
}
|
||||
|
||||
interface EmitInput {
|
||||
phaseDir: string;
|
||||
waves: Wave[];
|
||||
runId: string;
|
||||
budgetTokens?: number;
|
||||
}
|
||||
|
||||
interface EmitOk {
|
||||
ok: true;
|
||||
script: string;
|
||||
summary: {
|
||||
waves: number;
|
||||
plans: number;
|
||||
stagesByWave: string[][][]; // wave → stage → planId[]
|
||||
resumeRunId: string;
|
||||
budgetTokens: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface EmitErr {
|
||||
ok: false;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Partition a wave's plans into a near-minimal number of sequential stages (via
|
||||
* greedy first-fit — not guaranteed optimal for arbitrary overlap graphs, but
|
||||
* correct: no two plans sharing a file ever cohabit a stage) such that no two
|
||||
* plans in the same stage share a modified file. Each plan goes into the earliest
|
||||
* stage where it does not overlap any plan already there.
|
||||
*
|
||||
* A plan with an EMPTY files_modified set declares no files; it overlaps nothing
|
||||
* and coalesces into stage 0 (same behavior as the inline path, which also cannot
|
||||
* guard against undeclared concurrent writes — declare filesModified accurately).
|
||||
*
|
||||
* This is the same overlap rule execute-phase applies inline — the only difference
|
||||
* is the execution vehicle (Workflow `parallel()` vs one-agent-per-message).
|
||||
*/
|
||||
function partitionStages(plans: Plan[]): string[][] {
|
||||
const stages: { plans: Plan[]; files: Set<string> }[] = [];
|
||||
for (const plan of plans) {
|
||||
const fileSet = new Set(plan.files_modified);
|
||||
let placed = false;
|
||||
for (const stage of stages) {
|
||||
let overlap = false;
|
||||
for (const f of fileSet) {
|
||||
if (stage.files.has(f)) { overlap = true; break; }
|
||||
}
|
||||
if (!overlap) {
|
||||
stage.plans.push(plan);
|
||||
for (const f of fileSet) stage.files.add(f);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
stages.push({ plans: [plan], files: new Set(fileSet) });
|
||||
}
|
||||
}
|
||||
return stages.map((s) => s.plans.map((p) => p.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote a free-text value for safe embedding as a JavaScript/Workflow double-quoted
|
||||
* string literal. Uses JSON.stringify so every JS-relevant escape (backslash, quote,
|
||||
* newline, tab, NUL, U+2028/U+2029, all control chars) is handled by the language
|
||||
* itself — there is no hand-rolled escape table to drift. Returns the value already
|
||||
* wrapped in its surrounding quotes.
|
||||
*/
|
||||
function quoteString(s: string): string {
|
||||
return JSON.stringify(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `s` is a safe identifier/path token to interpolate into the generated
|
||||
* script WITHOUT requiring a string-literal context — i.e. it contains no
|
||||
* character that could terminate a comment line (`\n`/`\r`), break out of a
|
||||
* string literal (`"` / `\`), or smuggle a NUL/control sequence. Used for
|
||||
* `phaseDir`, `runId`, `wave.id`, and `plan.id`, which are identifiers/paths and
|
||||
* must never legitimately contain such characters. Rejecting them at validation
|
||||
* (rather than silently flattening) keeps the emitted script faithful to input.
|
||||
*/
|
||||
const UNSCRIPTABLE_CHAR_RE = /[\r\n"\\\x00-\x1f\x7f\u2028\u2029]/;
|
||||
function isScriptableIdentifier(s: unknown): boolean {
|
||||
if (typeof s !== 'string' || s.length === 0) return false;
|
||||
return !UNSCRIPTABLE_CHAR_RE.test(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a Workflow script mapping the phase's wave/plan model onto Workflow
|
||||
* primitives. Pure and deterministic: identical input yields an identical string.
|
||||
*
|
||||
* Returns ok:false (never throws) on invalid input — empty waves, missing runId,
|
||||
* a wave with no plans, etc.
|
||||
*/
|
||||
function emitWorkflowScript(input: EmitInput | null | undefined): EmitOk | EmitErr {
|
||||
if (input === null || input === undefined || typeof input !== 'object') {
|
||||
return { ok: false, reason: 'invalid_input' };
|
||||
}
|
||||
const { phaseDir, waves, runId } = input;
|
||||
// Identifiers/paths interpolated into the generated script must be free of any
|
||||
// character that could terminate a comment, break out of a string literal, or
|
||||
// smuggle control bytes — reject up front (security: #1143 review Finding 1).
|
||||
if (!isScriptableIdentifier(phaseDir)) {
|
||||
return { ok: false, reason: 'phaseDir must be a non-empty string without newlines/quotes/backslash/control chars' };
|
||||
}
|
||||
if (!isScriptableIdentifier(runId)) {
|
||||
return { ok: false, reason: 'runId must be a non-empty string without newlines/quotes/backslash/control chars' };
|
||||
}
|
||||
if (!Array.isArray(waves) || waves.length === 0) {
|
||||
return { ok: false, reason: 'waves must be a non-empty array' };
|
||||
}
|
||||
for (let i = 0; i < waves.length; i++) {
|
||||
const w = waves[i];
|
||||
if (w === null || typeof w !== 'object' || typeof w.id !== 'string') {
|
||||
return { ok: false, reason: 'waves[' + i + '] must be { id, plans: non-empty[] }' };
|
||||
}
|
||||
if (!isScriptableIdentifier(w.id)) {
|
||||
return { ok: false, reason: 'waves[' + i + '].id must not contain newlines/quotes/backslash/control chars' };
|
||||
}
|
||||
if (!Array.isArray(w.plans) || w.plans.length === 0) {
|
||||
return { ok: false, reason: 'waves[' + i + '] must have a non-empty plans array' };
|
||||
}
|
||||
const seenIds = new Set<string>();
|
||||
for (let j = 0; j < w.plans.length; j++) {
|
||||
const p = w.plans[j];
|
||||
if (p === null || typeof p !== 'object' || typeof p.id !== 'string' || typeof p.brief !== 'string' || !Array.isArray(p.files_modified)) {
|
||||
return { ok: false, reason: 'waves[' + i + '].plans[' + j + '] must be { id, brief, files_modified[] }' };
|
||||
}
|
||||
if (!isScriptableIdentifier(p.id)) {
|
||||
return { ok: false, reason: 'waves[' + i + '].plans[' + j + '].id must not contain newlines/quotes/backslash/control chars' };
|
||||
}
|
||||
if (seenIds.has(p.id)) {
|
||||
return { ok: false, reason: 'waves[' + i + '] has duplicate plan id "' + p.id + '"' };
|
||||
}
|
||||
seenIds.add(p.id);
|
||||
for (const f of p.files_modified) {
|
||||
if (typeof f !== 'string' || f.length === 0) {
|
||||
return { ok: false, reason: 'waves[' + i + '].plans[' + j + '].files_modified entries must be non-empty strings' };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const budgetTokens = (typeof input.budgetTokens === 'number' && Number.isFinite(input.budgetTokens) && input.budgetTokens > 0)
|
||||
? Math.floor(input.budgetTokens)
|
||||
: null;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('// GSD Workflow script — generated by the claude-orchestration capability (#1143)');
|
||||
lines.push('// phase: ' + phaseDir);
|
||||
lines.push('// BETA: preview-grade; on any failure the orchestrator falls back to inline dispatch.');
|
||||
lines.push('// Composes the SAME gsd-executor agent + worktree isolation as the inline path,');
|
||||
lines.push('// so artifacts (SUMMARY.md) and commits are produced identically.');
|
||||
lines.push('resumeFromRunId(' + quoteString(runId) + ')');
|
||||
if (budgetTokens !== null) {
|
||||
lines.push('budget(' + budgetTokens + ')');
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
const stagesByWave: string[][][] = [];
|
||||
let totalPlans = 0;
|
||||
|
||||
for (let wi = 0; wi < waves.length; wi++) {
|
||||
const wave = waves[wi];
|
||||
const stages = partitionStages(wave.plans);
|
||||
stagesByWave.push(stages);
|
||||
totalPlans += wave.plans.length;
|
||||
|
||||
lines.push('// Wave ' + wave.id);
|
||||
for (let si = 0; si < stages.length; si++) {
|
||||
const stagePlanIds = stages[si];
|
||||
// Resolve back to plan objects for briefs (ids are unique within a wave — validated above).
|
||||
const stagePlans = stagePlanIds.map((id) => wave.plans.find((p) => p.id === id) as Plan);
|
||||
if (stages.length > 1) {
|
||||
lines.push('// Stage ' + si + (si > 0 ? ' (sequential — files_modified overlap)' : ''));
|
||||
}
|
||||
if (stagePlans.length === 1) {
|
||||
const p = stagePlans[0];
|
||||
lines.push('parallel(');
|
||||
lines.push(' agent(' + quoteString(p.brief) + ', { agentType: "gsd-executor", isolation: "worktree" })');
|
||||
lines.push(')');
|
||||
} else {
|
||||
lines.push('parallel(');
|
||||
for (const p of stagePlans) {
|
||||
lines.push(' agent(' + quoteString(p.brief) + ', { agentType: "gsd-executor", isolation: "worktree" }),');
|
||||
}
|
||||
// Replace trailing comma on the last agent line with nothing.
|
||||
const lastIdx = lines.length - 1;
|
||||
lines[lastIdx] = lines[lastIdx].replace(/,$/, '');
|
||||
lines.push(')');
|
||||
}
|
||||
}
|
||||
if (wi < waves.length - 1) lines.push('');
|
||||
}
|
||||
|
||||
lines.push('// Each agent writes SUMMARY.md on its worktree branch; commits land there');
|
||||
lines.push('// and are merged by the orchestrator exactly as in inline wave dispatch.');
|
||||
|
||||
const script = lines.join('\n');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
script,
|
||||
summary: {
|
||||
waves: waves.length,
|
||||
plans: totalPlans,
|
||||
stagesByWave,
|
||||
resumeRunId: runId,
|
||||
budgetTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Exports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export = {
|
||||
detectWorkflowBackend,
|
||||
emitWorkflowScript,
|
||||
compareSemver,
|
||||
isValidSemver,
|
||||
WORKFLOW_TOOL_FLOOR_VERSION,
|
||||
BACKEND_VALUES,
|
||||
WORKFLOW_RUNTIME,
|
||||
};
|
||||
@@ -33,6 +33,7 @@ export const CLUSTERS: ClusterMap = Object.freeze({
|
||||
core_loop: Object.freeze([
|
||||
'next',
|
||||
'new-project',
|
||||
'onboard',
|
||||
'discuss-phase',
|
||||
'plan-phase',
|
||||
'execute-phase',
|
||||
|
||||
@@ -296,6 +296,14 @@ export const INIT_COMMAND_ALIASES: CommandAlias[] = [
|
||||
"subcommand": "new-milestone",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "init.onboard",
|
||||
"aliases": [
|
||||
"init onboard"
|
||||
],
|
||||
"subcommand": "onboard",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "init.quick",
|
||||
"aliases": [
|
||||
|
||||
@@ -28,6 +28,7 @@ interface InitModule {
|
||||
cmdInitPlanPhase(cwd: string, phase: string | undefined, raw: boolean, opts: Record<string, string | boolean | null>): void;
|
||||
cmdInitNewProject(cwd: string, raw: boolean): void;
|
||||
cmdInitNewMilestone(cwd: string, raw: boolean): void;
|
||||
cmdInitOnboard(cwd: string, raw: boolean, opts?: Record<string, string | boolean | null>): void;
|
||||
cmdInitQuick(cwd: string, name: string, raw: boolean): void;
|
||||
cmdInitIngestDocs(cwd: string, raw: boolean): void;
|
||||
cmdInitResume(cwd: string, raw: boolean): void;
|
||||
@@ -71,6 +72,10 @@ function routeInitCommand({ init, args, cwd, raw, error }: RouteInitCommandOptio
|
||||
},
|
||||
'new-project': () => init.cmdInitNewProject(cwd, raw),
|
||||
'new-milestone': () => init.cmdInitNewMilestone(cwd, raw),
|
||||
onboard: () => {
|
||||
const namedArgs = parseNamedArgs(args, [], ['fast', 'text']);
|
||||
init.cmdInitOnboard(cwd, raw, { fast: namedArgs['fast'], text: namedArgs['text'] });
|
||||
},
|
||||
quick: () => init.cmdInitQuick(cwd, args.slice(2).join(' '), raw),
|
||||
'ingest-docs': () => init.cmdInitIngestDocs(cwd, raw),
|
||||
resume: () => init.cmdInitResume(cwd, raw),
|
||||
|
||||
103
src/init.cts
103
src/init.cts
@@ -51,6 +51,15 @@ const { checkAgentsInstalled } = agentInstallCheck;
|
||||
import gitBaseBranch = require('./git-base-branch.cjs');
|
||||
const { gitWorktreeInfoInternal } = gitBaseBranch;
|
||||
import { makeResolution } from './resolution.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- onboard-projection.cjs is an export= CommonJS module
|
||||
import onboardProjection = require('./onboard-projection.cjs');
|
||||
const {
|
||||
REQUIRED_CODEBASE_MAP_FILES,
|
||||
buildOnboardProjection,
|
||||
hasCodeFilesInternal,
|
||||
hasPackageFileInternal,
|
||||
listCodebaseMapFiles,
|
||||
} = onboardProjection;
|
||||
|
||||
const { output, error } = io;
|
||||
const { loadConfig, loadConfigResolved } = configLoader;
|
||||
@@ -625,68 +634,11 @@ function cmdInitNewProject(cwd: string, raw: boolean): void {
|
||||
const exaKeyFile = path.join(homedir, '.gsd', 'exa_api_key');
|
||||
const hasExaSearch = !!(process.env['EXA_API_KEY'] || fs.existsSync(exaKeyFile));
|
||||
|
||||
let hasCode = false;
|
||||
let hasPackageFile = false;
|
||||
try {
|
||||
const codeExtensions = new Set([
|
||||
'.ts', '.js', '.py', '.go', '.rs', '.swift', '.java',
|
||||
'.kt', '.kts',
|
||||
'.c', '.cpp', '.h',
|
||||
'.cs',
|
||||
'.rb',
|
||||
'.php',
|
||||
'.dart',
|
||||
'.m', '.mm',
|
||||
'.scala',
|
||||
'.groovy',
|
||||
'.lua',
|
||||
'.r', '.R',
|
||||
'.zig',
|
||||
'.ex', '.exs',
|
||||
'.clj',
|
||||
]);
|
||||
const skipDirs = new Set([
|
||||
'node_modules', '.git', '.planning', '.claude', '.codex',
|
||||
'__pycache__', 'target', 'dist', 'build',
|
||||
]);
|
||||
function findCodeFiles(dir: string, depth: number): boolean {
|
||||
if (depth > 3) return false;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && codeExtensions.has(path.extname(entry.name))) return true;
|
||||
if (entry.isDirectory() && !skipDirs.has(entry.name)) {
|
||||
if (findCodeFiles(path.join(dir, entry.name), depth + 1)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
hasCode = findCodeFiles(cwd, 0);
|
||||
} catch {
|
||||
/* intentionally empty — best-effort detection */
|
||||
}
|
||||
|
||||
hasPackageFile =
|
||||
pathExistsInternal(cwd, 'package.json') ||
|
||||
pathExistsInternal(cwd, 'requirements.txt') ||
|
||||
pathExistsInternal(cwd, 'Cargo.toml') ||
|
||||
pathExistsInternal(cwd, 'go.mod') ||
|
||||
pathExistsInternal(cwd, 'Package.swift') ||
|
||||
pathExistsInternal(cwd, 'build.gradle') ||
|
||||
pathExistsInternal(cwd, 'build.gradle.kts') ||
|
||||
pathExistsInternal(cwd, 'pom.xml') ||
|
||||
pathExistsInternal(cwd, 'Gemfile') ||
|
||||
pathExistsInternal(cwd, 'composer.json') ||
|
||||
pathExistsInternal(cwd, 'pubspec.yaml') ||
|
||||
pathExistsInternal(cwd, 'CMakeLists.txt') ||
|
||||
pathExistsInternal(cwd, 'Makefile') ||
|
||||
pathExistsInternal(cwd, 'build.zig') ||
|
||||
pathExistsInternal(cwd, 'mix.exs') ||
|
||||
pathExistsInternal(cwd, 'project.clj');
|
||||
const hasCode = hasCodeFilesInternal(cwd);
|
||||
const hasPackageFile = hasPackageFileInternal(cwd);
|
||||
const isBrownfield = hasCode || hasPackageFile;
|
||||
const codebaseMapFiles = listCodebaseMapFiles(cwd);
|
||||
const hasCodebaseMap = codebaseMapFiles.length === REQUIRED_CODEBASE_MAP_FILES.length;
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
researcher_model: resolveModelInternal(cwd, 'gsd-project-researcher'),
|
||||
@@ -696,14 +648,13 @@ function cmdInitNewProject(cwd: string, raw: boolean): void {
|
||||
commit_docs: config.commit_docs,
|
||||
|
||||
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
|
||||
has_codebase_map: pathExistsInternal(cwd, '.planning/codebase'),
|
||||
has_codebase_map: hasCodebaseMap,
|
||||
planning_exists: pathExistsInternal(cwd, '.planning'),
|
||||
|
||||
has_existing_code: hasCode,
|
||||
has_package_file: hasPackageFile,
|
||||
is_brownfield: hasCode || hasPackageFile,
|
||||
needs_codebase_map:
|
||||
(hasCode || hasPackageFile) && !pathExistsInternal(cwd, '.planning/codebase'),
|
||||
is_brownfield: isBrownfield,
|
||||
needs_codebase_map: isBrownfield && !hasCodebaseMap,
|
||||
|
||||
...getInitGitState(cwd),
|
||||
|
||||
@@ -840,6 +791,25 @@ function cmdInitIngestDocs(cwd: string, raw: boolean): void {
|
||||
output(withProjectRoot(cwd, result), raw);
|
||||
}
|
||||
|
||||
function cmdInitOnboard(
|
||||
cwd: string,
|
||||
raw: boolean,
|
||||
options: Record<string, unknown> = {},
|
||||
): void {
|
||||
const config = loadConfig(cwd);
|
||||
const workflowConfig = (config.workflow ?? {}) as Record<string, unknown>;
|
||||
const result = {
|
||||
...buildOnboardProjection(cwd, {
|
||||
commitDocs: !!config.commit_docs,
|
||||
fast: options['fast'] === true,
|
||||
textMode: options['text'] === true || !!config.text_mode || !!workflowConfig['text_mode'],
|
||||
}),
|
||||
...getInitGitState(cwd),
|
||||
};
|
||||
|
||||
output(withProjectRoot(cwd, result), raw);
|
||||
}
|
||||
|
||||
function cmdInitResume(cwd: string, raw: boolean): void {
|
||||
const config = loadConfig(cwd);
|
||||
|
||||
@@ -2606,6 +2576,7 @@ export = {
|
||||
cmdInitNewMilestone,
|
||||
cmdInitQuick,
|
||||
cmdInitIngestDocs,
|
||||
cmdInitOnboard,
|
||||
cmdInitResume,
|
||||
cmdInitVerifyWork,
|
||||
cmdInitPhaseOp,
|
||||
|
||||
@@ -51,6 +51,7 @@ const PROFILES = Object.freeze({
|
||||
standard: Object.freeze([
|
||||
// Core loop
|
||||
'new-project',
|
||||
'onboard',
|
||||
'discuss-phase',
|
||||
'plan-phase',
|
||||
'execute-phase',
|
||||
@@ -875,6 +876,7 @@ export = {
|
||||
writeActiveProfile,
|
||||
// Shared internals
|
||||
parseRequires,
|
||||
parseCallsAgents,
|
||||
cleanupStagedSkills,
|
||||
// Back-compat / deprecated
|
||||
MINIMAL_SKILL_ALLOWLIST,
|
||||
|
||||
@@ -475,6 +475,12 @@ function cmdLoopRenderHooks(
|
||||
const runtimeConfigDir = typeof options['configDir'] === 'string'
|
||||
? options['configDir']
|
||||
: undefined;
|
||||
// #2003: thread an explicit --runtime override into the capability-state
|
||||
// resolver so the config-dir resolution bypasses the persisted-runtime
|
||||
// fallback (GSD_RUNTIME → config.runtime). Without this, a repo with persisted
|
||||
// runtime:"codex" resolves the config dir to ~/.codex and execute:post /
|
||||
// verify:post hooks silently no-op when the operator drives from Claude Code.
|
||||
const runtimeOverride = typeof options['runtime'] === 'string' ? options['runtime'] : undefined;
|
||||
// Load the config snapshot ONCE and share it with both the capability-state
|
||||
// resolver (via configOverride) and loop-hook resolution, so federated keys
|
||||
// present in loadConfig resolve identically for `active` and for hook when/
|
||||
@@ -488,7 +494,7 @@ function cmdLoopRenderHooks(
|
||||
} catch {
|
||||
config = {};
|
||||
}
|
||||
const state = resolveCapabilityRuntimeState(cwd, runtimeConfigDir, config) as {
|
||||
const state = resolveCapabilityRuntimeState(cwd, runtimeConfigDir, config, runtimeOverride) as {
|
||||
warnings?: string[];
|
||||
capabilities: Array<{ id: string; enabled?: boolean; active: boolean }>;
|
||||
};
|
||||
|
||||
@@ -105,6 +105,66 @@ function _resetModelPolicyWarningCacheForTests(): void {
|
||||
_modelPolicyUnmappableWarned.clear();
|
||||
}
|
||||
|
||||
// Dedupe stderr warnings for unmappable model_overrides Claude IDs (#2041).
|
||||
const _modelOverrideUnmappableWarned = new Set<string>();
|
||||
function warnModelOverrideUnmappable(agentType: string, overrideValue: string): void {
|
||||
const key = `${agentType}::${overrideValue}`;
|
||||
if (_modelOverrideUnmappableWarned.has(key)) return;
|
||||
_modelOverrideUnmappableWarned.add(key);
|
||||
// Cap emission length so an oversized or secret-shaped value cannot leak in
|
||||
// full to stderr/logs (#2041 security review). MUST go to stderr — resolve-
|
||||
// model's JSON result is parsed from stdout.
|
||||
const safe = overrideValue.length > 64 ? overrideValue.slice(0, 64) + '…' : overrideValue;
|
||||
process.stderr.write(
|
||||
`gsd: warning — model_overrides value "${safe}" for ${agentType} ` +
|
||||
`has no Claude agent alias; falling through to tier resolution.\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// Test-only: reset the model_overrides warn-dedupe cache between cases (#2041).
|
||||
function _resetModelOverrideWarningCacheForTests(): void {
|
||||
_modelOverrideUnmappableWarned.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* #2041 — Map a `model_overrides` value to its Claude Agent-tool alias on the
|
||||
* claude runtime, mirroring the `model_policy` path (#1144). Claude Code's
|
||||
* Agent tool `model` parameter documents only tier aliases (opus/sonnet/haiku/
|
||||
* fable); a full Claude model ID returned verbatim is silently dropped by the
|
||||
* spawner. Returns the value to return verbatim, or null to signal "fall
|
||||
* through to normal tier/dynamic-routing resolution" (used when a Claude full
|
||||
* ID has no alias — matches model_policy's warn-and-fall-through). Non-Claude
|
||||
* runtimes and non-Claude values always pass through verbatim.
|
||||
*
|
||||
* Hardening (code+security review): a `typeof` guard preserves the pre-fix
|
||||
* no-crash behavior if a malformed config surfaces a non-string value, and an
|
||||
* `Object.hasOwn` lookup defeats `__proto__`/`constructor` lookups on the plain
|
||||
* object literal so those reserved keys cannot return a truthy non-string.
|
||||
*/
|
||||
function mapClaudeOverrideForRuntime(
|
||||
override: string,
|
||||
configRuntime: string | null | undefined,
|
||||
agentType: string,
|
||||
): string | null {
|
||||
// Defensive: model_overrides is typed Record<string,string> but a malformed
|
||||
// config could surface a non-string; pass through verbatim (preserving the
|
||||
// pre-fix no-crash behaviour) and let the downstream Agent tool reject it.
|
||||
if (typeof override !== 'string') return override;
|
||||
const onClaude = !configRuntime || configRuntime === 'claude';
|
||||
if (!onClaude) return override;
|
||||
// Object.hasOwn guards against __proto__/constructor returning a truthy
|
||||
// non-string from the plain object literal (#2041 security review).
|
||||
if (Object.hasOwn(CLAUDE_POLICY_ID_TO_ALIAS, override)) {
|
||||
return CLAUDE_POLICY_ID_TO_ALIAS[override];
|
||||
}
|
||||
if (CLAUDE_AGENT_ALIASES.has(override)) return override;
|
||||
if (override.startsWith('claude-')) {
|
||||
warnModelOverrideUnmappable(agentType, override);
|
||||
return null;
|
||||
}
|
||||
return override;
|
||||
}
|
||||
|
||||
/**
|
||||
* #49 — Provider-neutral model policy preset resolution.
|
||||
*/
|
||||
@@ -159,11 +219,15 @@ function resolveModelPolicy(policy: Record<string, unknown> | null | undefined,
|
||||
function resolveModelInternal(cwd: string, agentType: string): string {
|
||||
const config = loadConfig(cwd);
|
||||
|
||||
// 1. Per-agent override
|
||||
// 1. Per-agent override (#2041: map Claude full IDs → Agent-tool aliases on
|
||||
// the claude runtime, mirroring the model_policy path #1144; non-Claude
|
||||
// runtimes and non-Claude values pass through verbatim).
|
||||
const modelOverrides = config['model_overrides'] as Record<string, string> | null | undefined;
|
||||
const override = modelOverrides?.[agentType];
|
||||
if (override) {
|
||||
return override;
|
||||
const mapped = mapClaudeOverrideForRuntime(override, config['runtime'] as string | null | undefined, agentType);
|
||||
if (mapped !== null) return mapped;
|
||||
// Unmappable Claude ID — fall through to tier resolution (matches model_policy).
|
||||
}
|
||||
|
||||
// 2. Compute the tier
|
||||
@@ -287,7 +351,11 @@ function resolveModelForTier(cwd: string, agentType: string, attempt?: number):
|
||||
|
||||
const modelOverrides = config['model_overrides'] as Record<string, string> | null | undefined;
|
||||
const override = modelOverrides?.[agentType];
|
||||
if (override) return override;
|
||||
if (override) {
|
||||
const mapped = mapClaudeOverrideForRuntime(override, config['runtime'] as string | null | undefined, agentType);
|
||||
if (mapped !== null) return mapped;
|
||||
// Unmappable Claude ID — fall through to dynamic_routing / model_policy resolution.
|
||||
}
|
||||
|
||||
if (config['model_policy'] && config['runtime'] && config['runtime'] !== 'claude') {
|
||||
return resolveModelInternal(cwd, agentType);
|
||||
@@ -508,6 +576,7 @@ export = {
|
||||
resolveModelPolicy,
|
||||
resolveModelInternal,
|
||||
_resetModelPolicyWarningCacheForTests,
|
||||
_resetModelOverrideWarningCacheForTests,
|
||||
VALID_GRANULARITIES,
|
||||
resolveGranularityInternal,
|
||||
assertValidGranularityOverride,
|
||||
|
||||
437
src/onboard-projection.cts
Normal file
437
src/onboard-projection.cts
Normal file
@@ -0,0 +1,437 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module
|
||||
import coreUtils = require('./core-utils.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||||
|
||||
const { pathExistsInternal, toPosixPath } = coreUtils;
|
||||
const { planningDir, planningRoot } = planningWorkspace;
|
||||
|
||||
const CODE_EXTENSIONS = new Set([
|
||||
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.swift', '.java',
|
||||
'.kt', '.kts', '.c', '.cpp', '.cc', '.h', '.hpp', '.cs', '.rb', '.php', '.dart',
|
||||
'.m', '.mm', '.scala', '.groovy', '.lua', '.r', '.R', '.zig', '.ex', '.exs', '.clj',
|
||||
]);
|
||||
|
||||
const CODE_SCAN_SKIP_DIRS = new Set([
|
||||
'node_modules', '.git', '.planning', '.claude', '.codex', '__pycache__', 'target',
|
||||
'dist', 'build', '.next', '.nuxt', '.svelte-kit', 'coverage', 'vendor', '.venv', 'venv',
|
||||
]);
|
||||
|
||||
const PACKAGE_FILES = [
|
||||
'package.json', 'requirements.txt', 'pyproject.toml', 'Cargo.toml', 'go.mod',
|
||||
'Package.swift', 'build.gradle', 'build.gradle.kts', 'pom.xml', 'Gemfile',
|
||||
'composer.json', 'pubspec.yaml', 'CMakeLists.txt', 'Makefile', 'build.zig',
|
||||
'mix.exs', 'project.clj',
|
||||
];
|
||||
|
||||
const REQUIRED_CODEBASE_MAP_FILES = [
|
||||
'STACK.md', 'ARCHITECTURE.md', 'STRUCTURE.md', 'CONVENTIONS.md', 'TESTING.md',
|
||||
'INTEGRATIONS.md', 'CONCERNS.md',
|
||||
];
|
||||
|
||||
const FAST_CODEBASE_MAP_FILES = [
|
||||
'STACK.md', 'INTEGRATIONS.md', 'ARCHITECTURE.md', 'STRUCTURE.md',
|
||||
];
|
||||
|
||||
const PLANNING_DOC_SEGMENTS = new Set([
|
||||
'adr', 'adrs', 'prd', 'prds', 'spec', 'specs', 'rfc', 'rfcs',
|
||||
]);
|
||||
|
||||
type MapReadiness = 'none' | 'fast' | 'complete';
|
||||
|
||||
interface OnboardHandoffCommands {
|
||||
ingest_docs: string;
|
||||
manager: string;
|
||||
map_codebase: string;
|
||||
map_codebase_fast: string;
|
||||
new_project: string;
|
||||
onboard: string;
|
||||
}
|
||||
|
||||
type OnboardNextAction =
|
||||
| { kind: 'map-codebase'; command: string; reason: string }
|
||||
| { kind: 'ingest-docs'; command: string; reason: string }
|
||||
| { kind: 'new-project'; command: string; reason: string }
|
||||
| { kind: 'complete-map-before-new-project'; command: string; reason: string }
|
||||
| { kind: 'partial-planning'; missing: string[]; reason: string }
|
||||
| { kind: 'write-summary'; summary_path: string; reason: string }
|
||||
| { kind: 'ready'; reason: string };
|
||||
|
||||
interface BuildOnboardProjectionOptions {
|
||||
commitDocs: boolean;
|
||||
fast: boolean;
|
||||
textMode: boolean;
|
||||
}
|
||||
|
||||
interface OnboardProjection {
|
||||
commit_docs: boolean;
|
||||
text_mode: boolean;
|
||||
|
||||
project_exists: boolean;
|
||||
planning_exists: boolean;
|
||||
requirements_exists: boolean;
|
||||
roadmap_exists: boolean;
|
||||
state_exists: boolean;
|
||||
config_exists: boolean;
|
||||
|
||||
handoff_commands: OnboardHandoffCommands;
|
||||
|
||||
has_existing_code: boolean;
|
||||
has_package_file: boolean;
|
||||
is_brownfield: boolean;
|
||||
fast_mode: boolean;
|
||||
map_readiness: MapReadiness;
|
||||
next_action: OnboardNextAction;
|
||||
needs_codebase_map: boolean;
|
||||
needs_fast_codebase_map: boolean;
|
||||
has_codebase_map: boolean;
|
||||
has_fast_codebase_map: boolean;
|
||||
codebase_dir_exists: boolean;
|
||||
fast_codebase_map_files_required: string[];
|
||||
codebase_map_files_present: string[];
|
||||
missing_codebase_map_files: string[];
|
||||
missing_fast_codebase_map_files: string[];
|
||||
codebase_map_summary_status: string;
|
||||
codebase_map_final_status: string;
|
||||
|
||||
has_docs_candidates: boolean;
|
||||
doc_candidate_count: number;
|
||||
doc_candidates: string[];
|
||||
|
||||
onboarding_summary_exists: boolean;
|
||||
onboarding_summary_path: string;
|
||||
|
||||
project_path: string;
|
||||
requirements_path: string;
|
||||
roadmap_path: string;
|
||||
state_path: string;
|
||||
codebase_dir: string;
|
||||
onboarding_dir: string;
|
||||
}
|
||||
|
||||
function hasCodeFilesInternal(dir: string, depth = 0): boolean {
|
||||
if (depth > 3) return false;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && CODE_EXTENSIONS.has(path.extname(entry.name))) return true;
|
||||
if (entry.isDirectory() && !CODE_SCAN_SKIP_DIRS.has(entry.name)) {
|
||||
if (hasCodeFilesInternal(path.join(dir, entry.name), depth + 1)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasPackageFileInternal(cwd: string): boolean {
|
||||
return PACKAGE_FILES.some((file) => pathExistsInternal(cwd, file));
|
||||
}
|
||||
|
||||
function listPlanningDocCandidates(cwd: string): string[] {
|
||||
const roots = ['docs', 'adr', 'adrs', 'prd', 'prds', 'spec', 'specs', 'rfc', 'rfcs'];
|
||||
const candidates = new Set<string>();
|
||||
|
||||
function isPlanningDocCandidate(rel: string, name: string): boolean {
|
||||
const upperName = name.toUpperCase();
|
||||
const relLower = rel.toLowerCase();
|
||||
const pathSegments = relLower.split('/');
|
||||
return (
|
||||
/(^|[-_ ])(ADR|PRD|SPEC|RFC)([-_ ]|\.)/i.test(name) ||
|
||||
/^\d{4}[-_].+\.md$/i.test(name) ||
|
||||
pathSegments.some((segment) => PLANNING_DOC_SEGMENTS.has(segment)) ||
|
||||
upperName === 'REQUIREMENTS.MD'
|
||||
);
|
||||
}
|
||||
|
||||
function addCandidate(rel: string, name: string): void {
|
||||
if (name.toLowerCase().endsWith('.md') && isPlanningDocCandidate(rel, name)) {
|
||||
candidates.add(toPosixPath(rel));
|
||||
}
|
||||
}
|
||||
|
||||
function visit(dir: string, relDir: string, depth: number): void {
|
||||
if (depth > 3) return;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
if (!CODE_SCAN_SKIP_DIRS.has(entry.name)) {
|
||||
visit(path.join(dir, entry.name), rel, depth + 1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile()) addCandidate(rel, entry.name);
|
||||
}
|
||||
}
|
||||
|
||||
let rootEntries: fs.Dirent[] = [];
|
||||
try {
|
||||
rootEntries = fs.readdirSync(cwd, { withFileTypes: true });
|
||||
} catch {
|
||||
rootEntries = [];
|
||||
}
|
||||
|
||||
for (const entry of rootEntries) {
|
||||
if (entry.isFile()) addCandidate(entry.name, entry.name);
|
||||
}
|
||||
|
||||
for (const root of roots) {
|
||||
const full = path.join(cwd, root);
|
||||
if (fs.existsSync(full)) visit(full, root, 0);
|
||||
}
|
||||
|
||||
return [...candidates].sort();
|
||||
}
|
||||
|
||||
function listCodebaseMapFiles(cwd: string): string[] {
|
||||
const codebaseDir = path.join(planningRoot(cwd), 'codebase');
|
||||
if (!fs.existsSync(codebaseDir)) return [];
|
||||
return REQUIRED_CODEBASE_MAP_FILES.filter((file) =>
|
||||
fs.existsSync(path.join(codebaseDir, file)),
|
||||
);
|
||||
}
|
||||
|
||||
function getMapReadiness(hasCompleteMap: boolean, hasFastMap: boolean): MapReadiness {
|
||||
if (hasCompleteMap) return 'complete';
|
||||
if (hasFastMap) return 'fast';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function mapSummaryStatus(mapReadiness: MapReadiness): string {
|
||||
if (mapReadiness === 'complete') return '.planning/codebase/ (complete codebase map)';
|
||||
if (mapReadiness === 'fast') return '.planning/codebase/ (fast/partial codebase map; complete map still required for project setup)';
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
function mapFinalStatus(mapReadiness: MapReadiness): string {
|
||||
if (mapReadiness === 'complete') return 'complete';
|
||||
if (mapReadiness === 'fast') return 'fast/partial; complete map still required for project setup';
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
function planningMissing(
|
||||
projectExists: boolean,
|
||||
requirementsExists: boolean,
|
||||
roadmapExists: boolean,
|
||||
stateExists: boolean,
|
||||
): string[] {
|
||||
const missing: string[] = [];
|
||||
if (!projectExists) missing.push('PROJECT.md');
|
||||
if (!requirementsExists) missing.push('REQUIREMENTS.md');
|
||||
if (!roadmapExists) missing.push('ROADMAP.md');
|
||||
if (!stateExists) missing.push('STATE.md');
|
||||
return missing;
|
||||
}
|
||||
|
||||
function nextAction(params: {
|
||||
fastMode: boolean;
|
||||
isBrownfield: boolean;
|
||||
needsOnboardCodebaseMap: boolean;
|
||||
hasDocsCandidates: boolean;
|
||||
projectExists: boolean;
|
||||
mapReadiness: MapReadiness;
|
||||
onboardingSummaryExists: boolean;
|
||||
onboardingSummaryPath: string;
|
||||
hasPlanningArtifacts: boolean;
|
||||
missingPlanningFiles: string[];
|
||||
handoffCommands: OnboardHandoffCommands;
|
||||
}): OnboardNextAction {
|
||||
if (params.isBrownfield && params.needsOnboardCodebaseMap) {
|
||||
return {
|
||||
kind: 'map-codebase',
|
||||
command: params.fastMode ? params.handoffCommands.map_codebase_fast : params.handoffCommands.map_codebase,
|
||||
reason: 'Existing code was detected, but the required .planning/codebase/ map is missing.',
|
||||
};
|
||||
}
|
||||
|
||||
if (params.hasPlanningArtifacts && params.missingPlanningFiles.length > 0) {
|
||||
return {
|
||||
kind: 'partial-planning',
|
||||
missing: params.missingPlanningFiles,
|
||||
reason: 'Project planning exists but required planning files are missing.',
|
||||
};
|
||||
}
|
||||
|
||||
if (params.fastMode && params.mapReadiness === 'fast' && !params.projectExists) {
|
||||
return {
|
||||
kind: 'complete-map-before-new-project',
|
||||
command: params.handoffCommands.map_codebase,
|
||||
reason: 'The fast map is enough for lightweight onboarding, but project setup still requires the complete codebase map.',
|
||||
};
|
||||
}
|
||||
|
||||
if (params.hasDocsCandidates && !params.projectExists) {
|
||||
return {
|
||||
kind: 'ingest-docs',
|
||||
command: params.handoffCommands.ingest_docs,
|
||||
reason: 'Detected existing ADR/PRD/SPEC/RFC document(s) before project setup.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!params.isBrownfield && !params.projectExists && !params.hasDocsCandidates) {
|
||||
return {
|
||||
kind: 'new-project',
|
||||
command: params.handoffCommands.new_project,
|
||||
reason: 'No existing code or planning docs were detected.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!params.projectExists) {
|
||||
return {
|
||||
kind: 'new-project',
|
||||
command: params.handoffCommands.new_project,
|
||||
reason: 'Codebase context is ready for project initialization.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!params.onboardingSummaryExists) {
|
||||
return {
|
||||
kind: 'write-summary',
|
||||
summary_path: params.onboardingSummaryPath,
|
||||
reason: 'Onboarding summary is missing.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'ready',
|
||||
reason: 'Onboarding summary already exists.',
|
||||
};
|
||||
}
|
||||
|
||||
function buildHandoffCommands(cwd: string): OnboardHandoffCommands {
|
||||
const runtime = resolveRuntime(cwd);
|
||||
return {
|
||||
ingest_docs: formatGsdSlash('ingest-docs', runtime) as string,
|
||||
manager: formatGsdSlash('manager', runtime) as string,
|
||||
map_codebase: formatGsdSlash('map-codebase', runtime) as string,
|
||||
map_codebase_fast: formatGsdSlash('map-codebase --fast', runtime) as string,
|
||||
new_project: formatGsdSlash('new-project', runtime) as string,
|
||||
onboard: formatGsdSlash('onboard', runtime) as string,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOnboardProjection(cwd: string, options: BuildOnboardProjectionOptions): OnboardProjection {
|
||||
const handoffCommands = buildHandoffCommands(cwd);
|
||||
const codebaseMapFiles = listCodebaseMapFiles(cwd);
|
||||
const missingCodebaseMapFiles = REQUIRED_CODEBASE_MAP_FILES.filter(
|
||||
(file) => !codebaseMapFiles.includes(file),
|
||||
);
|
||||
const missingFastCodebaseMapFiles = FAST_CODEBASE_MAP_FILES.filter(
|
||||
(file) => !codebaseMapFiles.includes(file),
|
||||
);
|
||||
const docCandidates = listPlanningDocCandidates(cwd);
|
||||
const hasCode = hasCodeFilesInternal(cwd);
|
||||
const hasPackageFile = hasPackageFileInternal(cwd);
|
||||
const isBrownfield = hasCode || hasPackageFile;
|
||||
const hasCodebaseMap = codebaseMapFiles.length === REQUIRED_CODEBASE_MAP_FILES.length;
|
||||
const hasFastCodebaseMap = missingFastCodebaseMapFiles.length === 0;
|
||||
const mapReadinessValue = getMapReadiness(hasCodebaseMap, hasFastCodebaseMap);
|
||||
const needsCodebaseMap = isBrownfield && !hasCodebaseMap;
|
||||
const needsFastCodebaseMap = isBrownfield && !hasFastCodebaseMap;
|
||||
const needsOnboardCodebaseMap = options.fast ? needsFastCodebaseMap : needsCodebaseMap;
|
||||
const projectRootPath = path.join(planningRoot(cwd), 'PROJECT.md');
|
||||
const projectScopedPath = path.join(planningDir(cwd), 'PROJECT.md');
|
||||
const projectExists =
|
||||
fs.existsSync(projectRootPath) || fs.existsSync(projectScopedPath);
|
||||
const requirementsExists = fs.existsSync(path.join(planningDir(cwd), 'REQUIREMENTS.md'));
|
||||
const roadmapExists = fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md'));
|
||||
const stateExists = fs.existsSync(path.join(planningDir(cwd), 'STATE.md'));
|
||||
const onboardingSummaryPath = path.join(planningRoot(cwd), 'onboarding', 'SUMMARY.md');
|
||||
const onboardingSummaryExists = fs.existsSync(onboardingSummaryPath);
|
||||
const hasPlanningArtifacts = projectExists || requirementsExists || roadmapExists || stateExists;
|
||||
const missingPlanningFiles = planningMissing(
|
||||
projectExists,
|
||||
requirementsExists,
|
||||
roadmapExists,
|
||||
stateExists,
|
||||
);
|
||||
|
||||
return {
|
||||
commit_docs: options.commitDocs,
|
||||
text_mode: options.textMode,
|
||||
|
||||
project_exists: projectExists,
|
||||
planning_exists: fs.existsSync(planningRoot(cwd)),
|
||||
requirements_exists: requirementsExists,
|
||||
roadmap_exists: roadmapExists,
|
||||
state_exists: stateExists,
|
||||
config_exists: fs.existsSync(path.join(planningDir(cwd), 'config.json')),
|
||||
|
||||
handoff_commands: handoffCommands,
|
||||
|
||||
has_existing_code: hasCode,
|
||||
has_package_file: hasPackageFile,
|
||||
is_brownfield: isBrownfield,
|
||||
fast_mode: options.fast,
|
||||
map_readiness: mapReadinessValue,
|
||||
next_action: nextAction({
|
||||
fastMode: options.fast,
|
||||
isBrownfield,
|
||||
needsOnboardCodebaseMap,
|
||||
hasDocsCandidates: docCandidates.length > 0,
|
||||
projectExists,
|
||||
mapReadiness: mapReadinessValue,
|
||||
onboardingSummaryExists,
|
||||
onboardingSummaryPath: toPosixPath(path.relative(cwd, onboardingSummaryPath)),
|
||||
hasPlanningArtifacts,
|
||||
missingPlanningFiles,
|
||||
handoffCommands,
|
||||
}),
|
||||
needs_codebase_map: needsCodebaseMap,
|
||||
needs_fast_codebase_map: needsFastCodebaseMap,
|
||||
has_codebase_map: hasCodebaseMap,
|
||||
has_fast_codebase_map: hasFastCodebaseMap,
|
||||
codebase_dir_exists: fs.existsSync(path.join(planningRoot(cwd), 'codebase')),
|
||||
fast_codebase_map_files_required: FAST_CODEBASE_MAP_FILES,
|
||||
codebase_map_files_present: codebaseMapFiles,
|
||||
missing_codebase_map_files: missingCodebaseMapFiles,
|
||||
missing_fast_codebase_map_files: missingFastCodebaseMapFiles,
|
||||
codebase_map_summary_status: mapSummaryStatus(mapReadinessValue),
|
||||
codebase_map_final_status: mapFinalStatus(mapReadinessValue),
|
||||
|
||||
has_docs_candidates: docCandidates.length > 0,
|
||||
doc_candidate_count: docCandidates.length,
|
||||
doc_candidates: docCandidates,
|
||||
|
||||
onboarding_summary_exists: onboardingSummaryExists,
|
||||
onboarding_summary_path: toPosixPath(path.relative(cwd, onboardingSummaryPath)),
|
||||
|
||||
project_path: toPosixPath(path.relative(
|
||||
cwd,
|
||||
fs.existsSync(projectRootPath) ? projectRootPath : projectScopedPath,
|
||||
)),
|
||||
requirements_path: toPosixPath(
|
||||
path.relative(cwd, path.join(planningDir(cwd), 'REQUIREMENTS.md')),
|
||||
),
|
||||
roadmap_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'ROADMAP.md'))),
|
||||
state_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'STATE.md'))),
|
||||
codebase_dir: toPosixPath(path.relative(cwd, path.join(planningRoot(cwd), 'codebase'))),
|
||||
onboarding_dir: toPosixPath(path.relative(cwd, path.join(planningRoot(cwd), 'onboarding'))),
|
||||
};
|
||||
}
|
||||
|
||||
export = {
|
||||
REQUIRED_CODEBASE_MAP_FILES,
|
||||
FAST_CODEBASE_MAP_FILES,
|
||||
buildOnboardProjection,
|
||||
hasCodeFilesInternal,
|
||||
hasPackageFileInternal,
|
||||
listCodebaseMapFiles,
|
||||
listPlanningDocCandidates,
|
||||
};
|
||||
Reference in New Issue
Block a user