diff --git a/README.md b/README.md index ee5a6f4..5cfa68c 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,9 @@ lifecycle and helper machinery stays in the repository for the agents and for in - [Verification Discipline](docs/VERIFICATION-DISCIPLINE.md) for what the checks do and do not establish. - [Changelog](CHANGELOG.md) for migration and compatibility notes. -Workspine requires Node `>=22`. The compatibility command `workspine init` remains available throughout +README requires Node `>=22`. The compatibility command `workspine init` remains available throughout the `0.35.x` release line. The `gsdd` binary alias is removed in the next minor release. +Optional [codebase-context](https://github.com/PatrickSys/codebase-context) integration (off by default) can enrich `/work-plan` and `/work-execute` when enabled in `.work/config.json`; see [User Guide](docs/USER-GUIDE.md#optional-integrations). + MIT. See [LICENSE](LICENSE). diff --git a/bin/gsdd.mjs b/bin/gsdd.mjs index a0086a6..df4e3a8 100644 --- a/bin/gsdd.mjs +++ b/bin/gsdd.mjs @@ -16,6 +16,7 @@ import { createCmdNext } from './lib/next.mjs'; import { resolveWorkspaceContext } from './lib/workspace-root.mjs'; import { createCmdGitIdentity } from './lib/git-identity.mjs'; import { maybeShowUpdateNotice } from './lib/update-awareness.mjs'; +import { createCmdIntegrations } from './lib/integrations-cli.mjs'; import { classifyInformationRequest, reportCommandShapeError, validateCommandShape } from './lib/init-runtime.mjs'; import { FRAMEWORK_VERSION } from './lib/workflows.mjs'; const __filename = fileURLToPath(import.meta.url); @@ -32,6 +33,7 @@ const cmdHealth = createCmdHealth(INIT_CONTEXT); const cmdNext = createCmdNext(INIT_CONTEXT); const cmdJourney = createCmdJourney(INIT_CONTEXT); const cmdGitIdentity = createCmdGitIdentity(INIT_CONTEXT); +const cmdIntegrations = createCmdIntegrations({ getCwd: () => INIT_CONTEXT.cwd }); const cmdUpdate = (...updateArgs) => { if (updateArgs.includes('--global') || updateArgs.includes('-g')) { @@ -64,6 +66,7 @@ const COMMANDS = { 'phase-status': cmdPhaseStatus, verify: cmdVerify, scaffold: cmdScaffold, + integrations: cmdIntegrations, help: cmdHelp, }; // Setup is the first-run facade; keep it outside the retained compatibility table while help diff --git a/bin/lib/config.mjs b/bin/lib/config.mjs index 2f68f36..953858d 100644 --- a/bin/lib/config.mjs +++ b/bin/lib/config.mjs @@ -11,6 +11,9 @@ import { parseFlagValue, output } from './cli-utils.mjs'; import { validateCommandShape } from './init-runtime.mjs'; import { assertStateAuthority, resolveStateDir } from './state-dir.mjs'; import { resolveWorkspaceContext } from './workspace-root.mjs'; +import { DEFAULT_INTEGRATIONS, mergeIntegrations } from './integrations-config.mjs'; + +export { DEFAULT_INTEGRATIONS }; export const DEFAULT_GIT_PROTOCOL = { branch: 'Follow the existing repo or team branching convention. Use a feature branch for significant changes when no convention exists.', @@ -94,7 +97,7 @@ export function normalizeModelProfile(value) { return VALID_MODEL_PROFILES.includes(value) ? value : 'balanced'; } -function buildConfigDefaults({ autoAdvance = false, rigorProfile }) { +function buildConfigDefaults({ autoAdvance = false, rigorProfile, integrations = defaultIntegrations() }) { const rigor = resolveRigor(rigorProfile); const cost = resolveCost('balanced'); const config = { @@ -103,6 +106,7 @@ function buildConfigDefaults({ autoAdvance = false, rigorProfile }) { ...cost, commitDocs: true, gitProtocol: { ...DEFAULT_GIT_PROTOCOL }, + integrations, initVersion: 'v1.1', }; if (autoAdvance) config.autoAdvance = true; @@ -110,7 +114,11 @@ function buildConfigDefaults({ autoAdvance = false, rigorProfile }) { } export function buildDefaultConfig({ autoAdvance = false } = {}) { - return buildConfigDefaults({ autoAdvance, rigorProfile: 'high' }); + return buildConfigDefaults({ autoAdvance, rigorProfile: 'high', integrations: defaultIntegrations() }); +} + +function defaultIntegrations() { + return mergeIntegrations({}); } function buildExistingConfigDefaults(existing = {}, { autoAdvance = false } = {}) { @@ -139,6 +147,7 @@ export function loadProjectModelConfig(cwd = process.cwd()) { ...defaults, ...existing, workflow: { ...defaults.workflow, ...(existing.workflow ?? {}) }, + integrations: mergeIntegrations(existing.integrations), }; } catch (e) { console.error(`WARNING: ${configPathLabel(cwd)} is malformed (${e.message}). Using defaults.`); @@ -166,6 +175,7 @@ function loadConfigForMutation(cwd = process.cwd()) { ...defaults, ...existing, workflow: { ...defaults.workflow, ...(existing.workflow ?? {}) }, + integrations: mergeIntegrations(existing.integrations), }, }; } catch (e) { diff --git a/bin/lib/init-runtime.mjs b/bin/lib/init-runtime.mjs index a349946..ba3cf78 100644 --- a/bin/lib/init-runtime.mjs +++ b/bin/lib/init-runtime.mjs @@ -291,6 +291,7 @@ export const COMMAND_FLAGS = { next: { '--json': false, '--format': true, '--init': false, '--id': true, '--prompt': true, '--default': true, '--gate': true, '--blocking': true, '--answer': true, '--replace': false, '--title': true, '--body': true, '--supersedes': true, '--privacy': true, '--backlog': true }, models: { '--agent': true, '--profile': true, '--runtime': true, '--model': true }, rigor: {}, + integrations: { '--json': false, '--workspace-root': true, '--files': true, '--plan': true, '--query': true, '--task': true }, }; // Bare words count only in the command position, so a positional argument that happens to read @@ -464,6 +465,7 @@ export function validateCommandShape(command, commandArgs = []) { next: 'Usage: workspine next [graph rebuild|question add|question answer|decision record|dogfood capture] [flags]', models: 'Usage: workspine models [show|profile|agent-profile|clear-agent-profile|set|clear] [flags]', rigor: 'Usage: workspine rigor [show|low|medium|high|max| ]', + integrations: 'Usage: workspine integrations [show] | codebase-context [flags]', }[command]; if (!usage) return null; @@ -522,6 +524,14 @@ export function validateCommandShape(command, commandArgs = []) { || RIGOR_STEP_NAMES.has(first) && RIGOR_LEVEL_NAMES.has(second) && rest.length === 0; if (!valid) return shapeError(command, usage); } + if (command === 'integrations') { + const valid = positionals.length === 0 + || positionals[0] === 'show' + || (positionals[0] === 'codebase-context' + && positionals.length >= 2 + && ['plan-context', 'edit-preflight', 'enable', 'disable', 'show'].includes(positionals[1])); + if (!valid) return shapeError(command, usage); + } return commandSpecificFlagError(command, positionals, commandArgs); } diff --git a/bin/lib/integrations-cli.mjs b/bin/lib/integrations-cli.mjs new file mode 100644 index 0000000..6d44163 --- /dev/null +++ b/bin/lib/integrations-cli.mjs @@ -0,0 +1,120 @@ +import { + cmdCodebaseContextSubcommand, + createCodebaseContextRunner, + showIntegrationStatus, +} from './integrations/codebase-context.mjs'; +import { loadProjectModelConfig } from './integrations-config.mjs'; +import { output } from './cli-utils.mjs'; +import { assertStateAuthority } from './state-dir.mjs'; +import { resolveWorkspaceContext } from './workspace-root.mjs'; +import { execFileSync } from 'child_process'; + +const CC_SUBCOMMANDS = new Set(['plan-context', 'edit-preflight', 'enable', 'disable', 'show']); + +function resolveIntegrationsRoot(flagArgs, cwd) { + const workspace = resolveWorkspaceContext(flagArgs, { cwd }); + if (workspace.invalid) { + console.error(`ERROR: ${workspace.error}`); + process.exitCode = 1; + return null; + } + try { + assertStateAuthority(workspace.state); + } catch (error) { + console.error(`ERROR: ${error.message}`); + process.exitCode = 1; + return null; + } + return { workspaceRoot: workspace.workspaceRoot, flagArgs: workspace.args ?? flagArgs }; +} + +function positionalArgs(flagArgs) { + const skip = new Set(['--json', '--workspace-root']); + const out = []; + for (let i = 0; i < flagArgs.length; i += 1) { + const token = flagArgs[i]; + if (skip.has(token)) { + if (token === '--workspace-root') i += 1; + continue; + } + if (token.startsWith('--')) { + if (['--files', '--plan', '--query', '--task'].includes(token)) i += 1; + continue; + } + out.push(token); + } + return out; +} + +function subcommandFlagArgs(integrationArgs) { + const positionals = positionalArgs(integrationArgs); + const [first, second] = positionals; + const drop = new Set(['integrations', first, second].filter(Boolean)); + const out = []; + for (let i = 0; i < integrationArgs.length; i += 1) { + const token = integrationArgs[i]; + if (drop.has(token)) continue; + if (token === '--workspace-root') { + i += 1; + continue; + } + out.push(token); + } + return out; +} + +function validateIntegrationsShape(integrationArgs) { + const positionals = positionalArgs(integrationArgs); + const [first, second] = positionals; + if (positionals.length === 0 || first === 'show') return null; + if (first === 'codebase-context' && CC_SUBCOMMANDS.has(second)) return null; + return 'Usage: workspine integrations [show] | codebase-context [flags]'; +} + +export function createCmdIntegrations({ cwd = process.cwd(), getCwd = null, runner = null } = {}) { + const defaultRunner = runner ?? createCodebaseContextRunner({ execFile: execFileSync }); + const resolveCwd = () => (typeof getCwd === 'function' ? getCwd() : cwd); + + return function cmdIntegrations(...integrationArgs) { + const grammarError = validateIntegrationsShape(integrationArgs); + if (grammarError) { + console.error(grammarError); + process.exitCode = 1; + return; + } + + const json = integrationArgs.includes('--json'); + const workspaceRoot = resolveIntegrationsRoot(integrationArgs, resolveCwd()); + if (!workspaceRoot) return; + + const positionals = positionalArgs(workspaceRoot.flagArgs); + const [first, second, ...rest] = positionals; + + if (!first || first === 'show') { + const config = loadProjectModelConfig(workspaceRoot.workspaceRoot); + const status = showIntegrationStatus({ config, workspaceRoot: workspaceRoot.workspaceRoot, runner: defaultRunner }); + if (json) output(status); + else { + const cc = status.integrations.codebaseContext; + console.log(`codebase-context: ${cc.enabled ? 'enabled' : 'disabled'} (cli: ${cc.cli_available ? 'available' : 'missing'})`); + console.log(` command: ${cc.command}`); + console.log(` artifacts: ${cc.artifact_dir}`); + console.log(' bridge: Workspine plan-context → CC `patterns` + `search --intent explore`; edit-preflight → `search --intent edit`'); + } + return; + } + + if (first === 'codebase-context') { + const flagArgs = subcommandFlagArgs(workspaceRoot.flagArgs); + if (json && !flagArgs.includes('--json')) flagArgs.push('--json'); + return cmdCodebaseContextSubcommand(second, flagArgs, { + cwd: workspaceRoot.workspaceRoot, + json, + runner: defaultRunner, + }); + } + + console.error('Usage: workspine integrations [show] | codebase-context [flags]'); + process.exitCode = 1; + }; +} diff --git a/bin/lib/integrations-config.mjs b/bin/lib/integrations-config.mjs new file mode 100644 index 0000000..d17e957 --- /dev/null +++ b/bin/lib/integrations-config.mjs @@ -0,0 +1,64 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { resolveStateDir } from './state-dir.mjs'; + +export const DEFAULT_INTEGRATIONS = Object.freeze({ + codebaseContext: Object.freeze({ + enabled: false, + command: 'codebase-context', + }), +}); + +export function mergeIntegrations(existing = {}) { + const cc = existing.codebaseContext ?? {}; + return { + codebaseContext: { + ...DEFAULT_INTEGRATIONS.codebaseContext, + ...cc, + enabled: cc.enabled === true, + }, + }; +} + +export function mergeCodebaseContextIntegration(config = {}) { + return mergeIntegrations(config.integrations).codebaseContext; +} + +function readConfigObject(cwd) { + const configPath = join(resolveStateDir(cwd).dir, 'config.json'); + if (!existsSync(configPath)) return null; + try { + return JSON.parse(readFileSync(configPath, 'utf-8')); + } catch { + return null; + } +} + +/** Minimal config surface for the generated `.work/bin` helper (no full models/rigor stack). */ +export function loadProjectModelConfig(cwd = process.cwd()) { + const existing = readConfigObject(cwd); + return { + integrations: mergeIntegrations(existing?.integrations), + }; +} + +export function writeProjectConfig(config, cwd = process.cwd()) { + const configPath = join(resolveStateDir(cwd).dir, 'config.json'); + const existing = readConfigObject(cwd) ?? {}; + writeFileSync(configPath, JSON.stringify({ ...existing, ...config, integrations: config.integrations ?? mergeIntegrations(existing.integrations) }, null, 2)); +} + +export function patchIntegrations(cwd, patch) { + const configPath = join(resolveStateDir(cwd).dir, 'config.json'); + const existing = readConfigObject(cwd) ?? {}; + const nextIntegrations = mergeIntegrations(existing.integrations); + if (patch.codebaseContext) { + nextIntegrations.codebaseContext = { + ...nextIntegrations.codebaseContext, + ...patch.codebaseContext, + enabled: patch.codebaseContext.enabled === true, + }; + } + writeFileSync(configPath, `${JSON.stringify({ ...existing, integrations: nextIntegrations }, null, 2)}\n`); + return nextIntegrations.codebaseContext; +} diff --git a/bin/lib/integrations/codebase-context.mjs b/bin/lib/integrations/codebase-context.mjs new file mode 100644 index 0000000..b9d4d85 --- /dev/null +++ b/bin/lib/integrations/codebase-context.mjs @@ -0,0 +1,391 @@ +import { execFileSync } from 'child_process'; +import { existsSync, mkdirSync, writeFileSync } from 'fs'; +import { dirname, join, relative, resolve } from 'path'; +import { createHash } from 'crypto'; +import { + DEFAULT_INTEGRATIONS, + loadProjectModelConfig, + mergeCodebaseContextIntegration, + patchIntegrations, +} from '../integrations-config.mjs'; +import { output, parseFlagValue } from '../cli-utils.mjs'; +import { resolveStateDir } from '../state-dir.mjs'; +import { writeFileAtomic } from '../atomic-write.mjs'; + +export const INTEGRATION_DIR = 'integrations/codebase-context'; + +/** + * Workspine helper subcommands (plan-context, edit-preflight) are not codebase-context CLI + * commands. They invoke the published CLI, which wraps MCP tools: + * - plan-context → `patterns` (get_team_patterns) + per-file `search --intent explore` (search_codebase) + * - edit-preflight → `search --intent edit` (search_codebase preflight card) + */ +export const CODEBASE_CONTEXT_BRIDGE = Object.freeze({ + planContext: Object.freeze([ + { argv: ['patterns', '--json'], mcpTool: 'get_team_patterns' }, + { argv: ['search', '--query', '', '--intent', 'explore', '--json', '--limit', '3'], mcpTool: 'search_codebase' }, + ]), + editPreflight: Object.freeze([ + { argv: ['search', '--query', '', '--intent', 'edit', '--json', '--limit', '10'], mcpTool: 'search_codebase' }, + ]), +}); + +function normalizeSlashes(value) { + return String(value || '').replace(/\\/g, '/'); +} + +export { mergeCodebaseContextIntegration }; + +export function integrationArtifactDir(workDir) { + return join(workDir, INTEGRATION_DIR); +} + +function slugify(value) { + return String(value || 'task') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48) || 'task'; +} + +function parseFilesFlag(flagArgs) { + const raw = parseFlagValue(flagArgs, '--files').value; + if (!raw) return []; + return raw.split(',').map((part) => normalizeSlashes(part).trim()).filter(Boolean); +} + +function parseCliJson(stdout) { + const trimmed = String(stdout || '').trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed); + } catch { + return { raw: trimmed }; + } +} + +function resolveCliInvocation(command, args) { + if (command.endsWith('.js')) { + return { executable: process.execPath, argv: [command, ...args], displayCommand: command }; + } + return { executable: command, argv: args, displayCommand: command }; +} + +function recordInvocation(invocations, command, argv, mcpTool) { + const { executable, argv: execArgv, displayCommand } = resolveCliInvocation(command, argv); + invocations.push({ + command: displayCommand, + argv: executable === process.execPath ? [process.execPath, ...execArgv] : [displayCommand, ...argv], + mcpTool, + }); +} + +export function createCodebaseContextRunner({ execFile, exists = existsSync } = {}) { + if (typeof execFile !== 'function') { + throw new Error('createCodebaseContextRunner requires execFile'); + } + + function probeCli(command, cwd) { + try { + const { executable, argv } = resolveCliInvocation(command, ['--version']); + execFile(executable, argv, { + cwd, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + return true; + } catch { + return false; + } + } + + function runCli(command, args, cwd) { + const { executable, argv } = resolveCliInvocation(command, args); + return execFile(executable, argv, { + cwd, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + env: { ...process.env, CODEBASE_ROOT: cwd }, + }); + } + + return { probeCli, runCli, exists, resolveCliInvocation }; +} + +export function buildSkippedReceipt(reason, extra = {}) { + return { + schema_version: 1, + integration: 'codebase-context', + status: reason === 'disabled' ? 'disabled' : 'unavailable', + reason, + bridge: CODEBASE_CONTEXT_BRIDGE, + ...extra, + }; +} + +export function runPlanContext({ + workspaceRoot, + workDir, + config, + files = [], + planPath = null, + json = false, + now = new Date(), + runner, +} = {}) { + const integration = mergeCodebaseContextIntegration(config); + const artifactPath = join(integrationArtifactDir(workDir), 'plan-context.json'); + const relativeArtifact = normalizeSlashes(relative(workspaceRoot, artifactPath)); + const ccInvocations = []; + + if (!integration.enabled) { + const body = buildSkippedReceipt('disabled', { artifact: relativeArtifact, files, plan_path: planPath }); + if (json) output(body); + else console.log('codebase-context integration is disabled in .work/config.json'); + return body; + } + + const command = integration.command || DEFAULT_INTEGRATIONS.codebaseContext.command; + if (!runner.probeCli(command, workspaceRoot)) { + const body = buildSkippedReceipt('cli_not_found', { + command, + artifact: relativeArtifact, + files, + plan_path: planPath, + }); + if (json) output(body); + else console.log(`codebase-context CLI not found (${command}); skipping plan context`); + return body; + } + + if (files.length === 0) { + const body = { + schema_version: 1, + integration: 'codebase-context', + status: 'error', + error: 'missing_files', + message: 'plan-context requires --files with at least one repo-relative path', + artifact: relativeArtifact, + bridge: CODEBASE_CONTEXT_BRIDGE, + }; + if (json) output(body); + else console.error(body.message); + process.exitCode = 1; + return body; + } + + const patternsArgv = ['patterns', '--json']; + recordInvocation(ccInvocations, command, patternsArgv, 'get_team_patterns'); + const patterns = parseCliJson(runner.runCli(command, patternsArgv, workspaceRoot)); + + const fileSearches = []; + for (const file of files.slice(0, 8)) { + const query = `implementation and conventions for ${file}`; + const searchArgv = ['search', '--query', query, '--intent', 'explore', '--json', '--limit', '3']; + recordInvocation(ccInvocations, command, searchArgv, 'search_codebase'); + fileSearches.push({ + file, + query, + response: parseCliJson(runner.runCli(command, searchArgv, workspaceRoot)), + }); + } + + const body = { + schema_version: 1, + integration: 'codebase-context', + status: 'ok', + recorded_at: now.toISOString(), + command, + bridge: CODEBASE_CONTEXT_BRIDGE, + codebase_context_invocations: ccInvocations, + plan_path: planPath, + files, + patterns, + file_searches: fileSearches, + artifact: relativeArtifact, + }; + + mkdirSync(dirname(artifactPath), { recursive: true }); + writeFileAtomic(artifactPath, `${JSON.stringify(body, null, 2)}\n`); + if (json) output(body); + else console.log(`Recorded plan context at ${relativeArtifact}`); + return body; +} + +export function runEditPreflight({ + workspaceRoot, + workDir, + config, + files = [], + query, + taskId = null, + json = false, + now = new Date(), + runner, +} = {}) { + const integration = mergeCodebaseContextIntegration(config); + const safeTask = slugify(taskId || now.toISOString()); + const artifactPath = join(integrationArtifactDir(workDir), 'preflight', `${safeTask}.json`); + const relativeArtifact = normalizeSlashes(relative(workspaceRoot, artifactPath)); + const logPath = join(integrationArtifactDir(workDir), 'preflight-log.jsonl'); + const ccInvocations = []; + + if (!integration.enabled) { + const body = buildSkippedReceipt('disabled', { + artifact: relativeArtifact, + files, + query: query || null, + task_id: taskId, + }); + if (json) output(body); + else console.log('codebase-context integration is disabled in .work/config.json'); + return body; + } + + const command = integration.command || DEFAULT_INTEGRATIONS.codebaseContext.command; + if (!runner.probeCli(command, workspaceRoot)) { + const body = buildSkippedReceipt('cli_not_found', { + command, + artifact: relativeArtifact, + files, + query: query || null, + task_id: taskId, + }); + if (json) output(body); + else console.log(`codebase-context CLI not found (${command}); skipping edit preflight`); + return body; + } + + if (!String(query || '').trim()) { + const body = { + schema_version: 1, + integration: 'codebase-context', + status: 'error', + error: 'missing_query', + message: 'edit-preflight requires --query describing the intended edit', + artifact: relativeArtifact, + bridge: CODEBASE_CONTEXT_BRIDGE, + }; + if (json) output(body); + else console.error(body.message); + process.exitCode = 1; + return body; + } + + const fileHint = files.length > 0 ? ` Files: ${files.join(', ')}.` : ''; + const searchQuery = `${String(query).trim()}.${fileHint}`; + const searchArgv = ['search', '--query', searchQuery, '--intent', 'edit', '--json', '--limit', '10']; + recordInvocation(ccInvocations, command, searchArgv, 'search_codebase'); + const response = parseCliJson(runner.runCli(command, searchArgv, workspaceRoot)); + + const body = { + schema_version: 1, + integration: 'codebase-context', + status: 'ok', + recorded_at: now.toISOString(), + command, + bridge: CODEBASE_CONTEXT_BRIDGE, + codebase_context_invocations: ccInvocations, + task_id: taskId, + files, + query: String(query).trim(), + search_query: searchQuery, + response, + preflight: response?.preflight ?? null, + artifact: relativeArtifact, + digest: createHash('sha256').update(JSON.stringify(response ?? {})).digest('hex'), + }; + + mkdirSync(dirname(artifactPath), { recursive: true }); + writeFileAtomic(artifactPath, `${JSON.stringify(body, null, 2)}\n`); + writeFileSync(logPath, `${JSON.stringify({ + recorded_at: body.recorded_at, + task_id: taskId, + files, + query: body.query, + ready: body.preflight?.ready ?? null, + artifact: relativeArtifact, + })}\n`, { flag: 'a' }); + writeFileAtomic(join(integrationArtifactDir(workDir), 'last-preflight.json'), `${JSON.stringify(body, null, 2)}\n`); + + if (json) output(body); + else console.log(`Recorded edit preflight at ${relativeArtifact}`); + return body; +} + +export function showIntegrationStatus({ config, workspaceRoot, runner } = {}) { + const integration = mergeCodebaseContextIntegration(config); + const command = integration.command || DEFAULT_INTEGRATIONS.codebaseContext.command; + const cliAvailable = runner.probeCli(command, workspaceRoot); + return { + schema_version: 1, + integrations: { + codebaseContext: { + enabled: integration.enabled, + command, + cli_available: cliAvailable, + artifact_dir: `.work/${INTEGRATION_DIR}`, + bridge: CODEBASE_CONTEXT_BRIDGE, + }, + }, + }; +} + +export function setIntegrationEnabled(cwd, enabled) { + return patchIntegrations(cwd, { codebaseContext: { enabled: Boolean(enabled) } }); +} + +export function cmdCodebaseContextSubcommand(subcommand, flagArgs, { + cwd = process.cwd(), + json = flagArgs.includes('--json'), + runner = null, + loadConfig = loadProjectModelConfig, +} = {}) { + const workspace = resolveStateDir(cwd); + const workspaceRoot = resolve(workspace.dir, '..'); + const workDir = workspace.dir; + const config = loadConfig(workspaceRoot); + const execRunner = runner ?? createCodebaseContextRunner({ execFile: execFileSync }); + + switch (subcommand) { + case 'plan-context': + return runPlanContext({ + workspaceRoot, + workDir, + config, + files: parseFilesFlag(flagArgs), + planPath: parseFlagValue(flagArgs, '--plan').value, + json, + runner: execRunner, + }); + case 'edit-preflight': + return runEditPreflight({ + workspaceRoot, + workDir, + config, + files: parseFilesFlag(flagArgs), + query: parseFlagValue(flagArgs, '--query').value, + taskId: parseFlagValue(flagArgs, '--task').value, + json, + runner: execRunner, + }); + case 'enable': + setIntegrationEnabled(workspaceRoot, true); + console.log(' - enabled integrations.codebaseContext'); + return null; + case 'disable': + setIntegrationEnabled(workspaceRoot, false); + console.log(' - disabled integrations.codebaseContext'); + return null; + case 'show': + output(showIntegrationStatus({ config, workspaceRoot, runner: execRunner })); + return null; + default: + console.error('Usage: integrations codebase-context [flags]'); + console.error(' (Workspine helpers; codebase-context CLI: patterns, search --intent explore|edit, map, …)'); + process.exitCode = 1; + return null; + } +} diff --git a/bin/lib/rendering.mjs b/bin/lib/rendering.mjs index 2e85795..1d75c35 100644 --- a/bin/lib/rendering.mjs +++ b/bin/lib/rendering.mjs @@ -23,6 +23,9 @@ const HELPER_LIB_FILES = Object.freeze([ 'work-context.mjs', 'workflows.mjs', 'workspace-root.mjs', + 'integrations-config.mjs', + 'integrations-cli.mjs', + 'integrations/codebase-context.mjs', ]); const DEFAULT_STATE_DIR_NAME = '.work'; @@ -74,6 +77,7 @@ import { cmdPhaseStatus, cmdVerify } from './lib/phase.mjs'; import { buildControlMap } from './lib/control-map.mjs'; import { cmdDecisionsQuery, cmdRememberCandidate } from './lib/decision-cli.mjs'; import { createCmdNext } from './lib/next.mjs'; +import { createCmdIntegrations } from './lib/integrations-cli.mjs'; import { maybeShowUpdateNotice } from './lib/update-awareness.mjs'; import { bootstrapHelperWorkspace, consumeWorkspaceRootArg, resolveWorkspaceContext } from './lib/workspace-root.mjs'; import { assertStateAuthority } from './lib/state-dir.mjs'; @@ -87,6 +91,7 @@ const PACKAGE_NAME = ${embeddedPackageName}; const PACKAGE_VERSION = ${embeddedPackageVersion}; const cmdNext = createCmdNext(HELPER_CONTEXT); const cmdGitIdentity = createCmdGitIdentity(HELPER_CONTEXT); +const cmdIntegrations = createCmdIntegrations({ getCwd: () => HELPER_CONTEXT.cwd }); function cmdControlMap(...controlArgs) { const context = resolveWorkspaceContext([], { cwd: HELPER_CONTEXT.cwd }); @@ -121,6 +126,7 @@ const COMMANDS = { remember: cmdRememberCandidate, verify: cmdVerify, next: cmdNext, + integrations: cmdIntegrations, }; const RESOLVER_BASED_COMMANDS = new Set([ @@ -166,6 +172,8 @@ function printHelp() { ' Example: node ${helperPath} lifecycle-transition verify --plan .work/phases/{phase_dir}/{plan_id}-PLAN.md --artifact .work/phases/{phase_dir}/{plan_id}-SUMMARY.md --authority workflow --json', ' next [--json] [--format auto|json|human]', ' Route to the next safe Workspine action from ${normalizeStateDirName(stateDirName)}, brownfield, planning, and repo truth', + ' integrations [show] | codebase-context ', + ' Optional codebase-context bridge (off unless enabled in ${normalizeStateDirName(stateDirName)}/config.json)', ' supported package runtime floor: Node >=22', ' next/verify update notice: sequential/best-effort network-bounded checks; no lock or cross-process concurrency guarantee; use --no-update-notice, WORKSPINE_UPDATE_AWARENESS=0, or the legacy GSDD_UPDATE_AWARENESS=0 to opt out', ' health and update remain network-free; run \`npx -y workspine update\` for explicit repair', diff --git a/distilled/workflows/execute.md b/distilled/workflows/execute.md index 66437af..5dc7c47 100644 --- a/distilled/workflows/execute.md +++ b/distilled/workflows/execute.md @@ -67,7 +67,6 @@ Plans to execute: Wave 2: {plan-03-NAME.md} Plans already complete (have SUMMARY): {list} ``` - Confirm with the user before proceeding if any existing SUMMARYs look stale or incomplete. ### Execute Wave by Wave For each wave in ascending order: @@ -94,12 +93,16 @@ For each task in the plan, follow this loop: ```text 1. Read the plan frontmatter and current task. 2. Read the task_scoped files and focused references needed for that task. +2b. Optional codebase-context edit preflight (off by default): when `integrations.codebaseContext.enabled` is true in `.work/config.json` and the CLI is installed, before mutating files for this task run `node .work/bin/gsdd.mjs integrations codebase-context edit-preflight --files --query "" --task --json`. `edit-preflight` is a Workspine helper that calls `codebase-context search --intent edit` (MCP `search_codebase` preflight card). Read `.work/integrations/codebase-context/last-preflight.json` when `status` is `ok`; honor `preflight` warnings but do not treat them as a hard gate unless the plan says so. When `status` is `disabled` or `unavailable`, skip with no behavior change. 3. For roadmap phases, before implementation, run `node .work/bin/gsdd.mjs lifecycle-transition execute --plan .work/phases/{phase_dir}/{plan_id}-PLAN.md --authority workflow --json`; stop if it refuses, then implement the task action. Do not pass `--approved` or `--approval-ref` here: explicit approval is an owner-only transition, and workflow execution consumes the plan's already-durable approval. 4. Run the task's verify steps. 5. Handle any git actions using repo or user conventions. 6. Re-read the plan's Objective and `non_goals`. Confirm the work just completed serves the objective and crossed no boundary; if not, record a deviation before continuing. 7. Record task completion in your working notes and final SUMMARY.md. ``` + +Same opt-in bridge as `/work-plan`: install Codebase Context and enable `integrations.codebaseContext.enabled`. Receipts: `.work/integrations/codebase-context/`. + ### Frontmatter And Task Semantics The executor consumes the plan schema defined by `/work-plan`: - frontmatter keys: `phase`, `plan`, `type`, `wave`, `depends_on`, `files-modified`, `autonomous`, `requirements`, `browser_proof_required`, `browser_proof_rationale`, `must_haves`, `tdd` @@ -200,9 +203,7 @@ Git rules: Reality rarely matches the plan perfectly. Handle deviations with these rules in priority order: - ### Structured mismatch taxonomy - All execution-time deltas must be classified as one of: - `factual_discovery` - the repo/runtime reality differs from the plan in a concrete, local way (wrong path, stale API shape, moved module, outdated dependency assumption) - `intent_scope_change` - the requested outcome or scope needs to change diff --git a/distilled/workflows/plan.md b/distilled/workflows/plan.md index 5815e17..1c18e48 100644 --- a/distilled/workflows/plan.md +++ b/distilled/workflows/plan.md @@ -65,7 +65,6 @@ Before planning, acknowledge what is locked: - Patterns from previous phases - match existing conventions. Do not introduce new patterns without cause. - Deferred items - items marked v2, nice-to-have, or out of scope in SPEC.md or APPROACH.md. Do not plan for them. - Cross-check: if SPEC.md and APPROACH.md disagree on whether an item is must-have or deferred, stop and ask the user before planning. Do not silently adopt one classification over the other. - If you need to challenge a locked decision: stop, ask the developer, and document the new decision explicitly. @@ -96,6 +95,9 @@ Use these sections: - The phase uses only technologies already established in previous phases Quality gate: do not proceed to goal-backward planning if you have unresolved uncertainties about the implementation approach. If research was skipped (skip conditions above apply), document the skip reason in the plan Notes section so the plan checker can verify the skip was justified. + +Optional, off by default: after selecting the write set and enabling `integrations.codebaseContext.enabled` with the CLI installed, run `node .work/bin/gsdd.mjs integrations codebase-context plan-context --files --plan --json`. The helper uses Codebase Context patterns and explore search. On `status: ok`, read `.work/integrations/codebase-context/plan-context.json`; otherwise continue unchanged. Setup: `docs/USER-GUIDE.md`. + ### When This Runs After research_check, before goal_backward_planning. @@ -272,9 +274,7 @@ Task type semantics: - `type="checkpoint:review"` - executor stops for explicit review before continuing If any task uses `checkpoint:*`, the plan frontmatter must set `autonomous: false`. - ### Specificity Rules - | Too Vague | Just Right | |-------------|-------------| | "Set up the database" | "Create the user schema, wire it into the repository layer, then run `npm test -- --runInBand tests/user-schema.test.ts`" | diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 57bf355..49b6cde 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -346,6 +346,7 @@ Details worth knowing before you script it: | `npx -y workspine journey` | Inspect journey receipts | | `npx -y workspine git-identity` | Inspect Git identity and root | | `npx -y workspine phase-status` | Inspect phase status | +| `npx -y workspine integrations show --json` | **After this integration is released** (not in current npm `latest`): inspect optional integrations. Until then use `node bin/gsdd.mjs integrations show --json` from a checkout of this branch, or `node .work/bin/gsdd.mjs integrations show --json` in a project set up from this build. Codebase Context stays disabled by default. | | `npx -y workspine help` | Show the concise first-use and core-command summary; this guide owns the detailed command reference | If `workspine` is globally installed, you can use the shorter `gsdd ...` form for the same commands. Generated workflow helper calls do not use the global binary; they run through `node .work/bin/gsdd.mjs ...` from the repo root. The `gsdd` binary alias is removed at the next minor release, so new scripts and CI should call `workspine ...` directly. @@ -452,6 +453,12 @@ only brief-driven `SPEC.md`/`ROADMAP.md` bootstrap; it does not authorize or cha "branch": "Follow existing repo conventions", "commit": "Logical grouping, no phase/task IDs", "pr": "Follow existing review workflow" + }, + "integrations": { + "codebaseContext": { + "enabled": false, + "command": "codebase-context" + } } } ``` @@ -477,6 +484,17 @@ Each adds quality but costs tokens and time: | `workflow.planCheck` | `true` | Fresh-context adversarial plan checking (max-3 cycle loop) | | `workflow.verifier` | `true` | 3-level verification gate after execution | +### Optional integrations + +| Setting | Default | What it Controls | +|---------|---------|------------------| +| `integrations.codebaseContext.enabled` | `false` | When `true` and the CLI is on `PATH`, `/work-plan` and `/work-execute` may call [codebase-context](https://github.com/PatrickSys/codebase-context) helpers and record receipts under `.work/integrations/codebase-context/` | +| `integrations.codebaseContext.command` | `codebase-context` | Executable used for `patterns` / `search --intent edit` calls | + +Inspect status with `node .work/bin/gsdd.mjs integrations show --json`. Enable with `node .work/bin/gsdd.mjs integrations codebase-context enable` after installing the CLI. When disabled or unavailable, workflows skip the bridge with no behavior change. + +`plan-context` and `edit-preflight` are Workspine helper subcommands only. They call the real codebase-context CLI: `patterns` and `search --intent explore` for planning (MCP `get_team_patterns` / `search_codebase`), and `search --intent edit` before edits (MCP `search_codebase` preflight). + Disable these to speed up phases in familiar domains or when conserving tokens. Disabling `planCheck` engages reduced-assurance mode: the planner self-checks without the independent reviewer. ### Model Control diff --git a/tests/gsdd.integrations-codebase-context.test.cjs b/tests/gsdd.integrations-codebase-context.test.cjs new file mode 100644 index 0000000..adea98e --- /dev/null +++ b/tests/gsdd.integrations-codebase-context.test.cjs @@ -0,0 +1,215 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { createTempProject, cleanup, readJson, runCliAsMain } = require('./gsdd.helpers.cjs'); +const { pathToFileURL } = require('url'); + +async function importIntegrationModule() { + return import(`${pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'integrations', 'codebase-context.mjs')).href}?t=${Date.now()}`); +} + +async function importConfigModule() { + return import(`${pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'config.mjs')).href}?t=${Date.now()}`); +} + +function initWorkDir(tmp, { enabled = false, command = 'stub-codebase-context' } = {}) { + const workDir = path.join(tmp, '.work'); + fs.mkdirSync(workDir, { recursive: true }); + fs.writeFileSync(path.join(workDir, 'config.json'), `${JSON.stringify({ + rigorProfile: 'medium', + integrations: { codebaseContext: { enabled, command } }, + }, null, 2)}\n`); + return workDir; +} + +/** Shapes aligned with codebase-context main CLI JSON (SearchResponse / PatternResponse). */ +function stubRunner() { + const calls = []; + const patternsPayload = { + status: 'success', + patterns: {}, + goldenFiles: [], + }; + const explorePayload = { + status: 'success', + searchQuality: { status: 'ok', confidence: 0.42 }, + budget: { mode: 'compact', resultCount: 1 }, + results: [{ + file: 'src/example.ts:1-10', + summary: "typescript function 'example' in example.ts.", + score: 0.5, + }], + }; + const editPayload = { + status: 'success', + searchQuality: { status: 'low_confidence', confidence: 0.17 }, + budget: { mode: 'compact', resultCount: 2 }, + preflight: { + ready: false, + abstain: true, + nextAction: 'Search quality is low. Refine query or add concrete symbols before editing.', + impact: { coverage: '1/1 callers in results', files: ['src/validate.ts'] }, + }, + results: [{ file: 'src/math.ts:1-3', summary: "typescript function 'add' in math.ts.", score: 0.25 }], + }; + return { + calls, + probeCli: () => true, + runCli: (_command, args) => { + calls.push(args); + if (args[0] === 'patterns') return JSON.stringify(patternsPayload); + if (args[0] === 'search') { + const intent = args[args.indexOf('--intent') + 1]; + return JSON.stringify(intent === 'edit' ? editPayload : explorePayload); + } + return '{}'; + }, + exists: () => true, + }; +} + +test('default config keeps codebase-context integration off', async () => { + const { buildDefaultConfig } = await importConfigModule(); + const config = buildDefaultConfig(); + assert.equal(config.integrations.codebaseContext.enabled, false); +}); + +test('plan-context skips when disabled', async () => { + const tmp = createTempProject(); + try { + const workDir = initWorkDir(tmp, { enabled: false }); + const { runPlanContext } = await importIntegrationModule(); + const body = runPlanContext({ + workspaceRoot: tmp, + workDir, + config: readJson(path.join(workDir, 'config.json')), + files: ['src/a.ts'], + json: true, + runner: stubRunner(), + }); + assert.equal(body.status, 'disabled'); + assert.ok(body.bridge); + assert.equal(fs.existsSync(path.join(workDir, 'integrations', 'codebase-context', 'plan-context.json')), false); + } finally { + cleanup(tmp); + } +}); + +test('plan-context records patterns and per-file explore search when enabled', async () => { + const tmp = createTempProject(); + try { + const workDir = initWorkDir(tmp, { enabled: true }); + const runner = stubRunner(); + const { runPlanContext } = await importIntegrationModule(); + const body = runPlanContext({ + workspaceRoot: tmp, + workDir, + config: readJson(path.join(workDir, 'config.json')), + files: ['src/features/a.ts', 'src/features/b.ts'], + planPath: '.work/phases/01-PLAN.md', + json: true, + runner, + }); + assert.equal(body.status, 'ok'); + assert.ok(body.codebase_context_invocations.some((row) => row.mcpTool === 'get_team_patterns')); + assert.ok(body.codebase_context_invocations.some((row) => row.mcpTool === 'search_codebase')); + assert.equal(body.patterns.status, 'success'); + assert.equal(body.file_searches.length, 2); + assert.equal(body.file_searches[0].response.status, 'success'); + assert.ok(Array.isArray(body.file_searches[0].response.results)); + const artifact = readJson(path.join(workDir, 'integrations', 'codebase-context', 'plan-context.json')); + assert.equal(artifact.status, 'ok'); + } finally { + cleanup(tmp); + } +}); + +test('edit-preflight records search_codebase preflight when enabled', async () => { + const tmp = createTempProject(); + try { + const workDir = initWorkDir(tmp, { enabled: true }); + const runner = stubRunner(); + const { runEditPreflight } = await importIntegrationModule(); + const body = runEditPreflight({ + workspaceRoot: tmp, + workDir, + config: readJson(path.join(workDir, 'config.json')), + files: ['src/a.ts'], + query: 'add validation helper', + taskId: 'task-1', + json: true, + runner, + }); + assert.equal(body.status, 'ok'); + assert.equal(body.response.status, 'success'); + assert.equal(typeof body.preflight.ready, 'boolean'); + assert.equal(body.preflight.abstain, true); + assert.ok(runner.calls.some((args) => args.includes('edit'))); + assert.ok(fs.existsSync(path.join(workDir, 'integrations', 'codebase-context', 'preflight', 'task-1.json'))); + } finally { + cleanup(tmp); + } +}); + +test('integrations show via CLI on fresh project reports disabled', async () => { + const tmp = createTempProject(); + try { + initWorkDir(tmp); + const result = await runCliAsMain(tmp, ['integrations', 'show', '--json']); + assert.equal(result.exitCode, 0); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.integrations.codebaseContext.enabled, false); + assert.ok(payload.integrations.codebaseContext.bridge); + } finally { + cleanup(tmp); + } +}); + +test('e2e against built codebase-context when CC_E2E_BIN is set', { skip: !process.env.CC_E2E_BIN }, async () => { + const ccBin = process.env.CC_E2E_BIN; + const tmp = createTempProject(); + try { + fs.mkdirSync(path.join(tmp, 'src'), { recursive: true }); + fs.writeFileSync(path.join(tmp, 'src', 'math.ts'), 'export function add(a: number, b: number) { return a + b; }\n'); + fs.writeFileSync(path.join(tmp, 'package.json'), '{"name":"e2e","type":"module"}\n'); + const { execFileSync } = require('child_process'); + const reindex = ccBin.endsWith('.js') + ? { file: process.execPath, args: [ccBin, 'reindex', '--no-embeddings'] } + : { file: ccBin, args: ['reindex', '--no-embeddings'] }; + execFileSync(reindex.file, reindex.args, { cwd: tmp, encoding: 'utf-8', env: { ...process.env, CODEBASE_ROOT: tmp } }); + + const workDir = initWorkDir(tmp, { enabled: true, command: ccBin }); + const { runPlanContext, runEditPreflight } = await importIntegrationModule(); + const { createCodebaseContextRunner } = await importIntegrationModule(); + const runner = createCodebaseContextRunner({ execFile: execFileSync }); + const config = readJson(path.join(workDir, 'config.json')); + + const plan = runPlanContext({ + workspaceRoot: tmp, + workDir, + config, + files: ['src/math.ts'], + json: true, + runner, + }); + assert.equal(plan.status, 'ok'); + assert.equal(plan.patterns.status, 'success'); + + const preflight = runEditPreflight({ + workspaceRoot: tmp, + workDir, + config, + files: ['src/math.ts'], + query: 'change add function', + taskId: 'e2e-task', + json: true, + runner, + }); + assert.equal(preflight.status, 'ok'); + assert.equal(preflight.response.status, 'success'); + assert.ok(preflight.response.preflight); + } finally { + cleanup(tmp); + } +});