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..d49d54e 100644 --- a/distilled/workflows/execute.md +++ b/distilled/workflows/execute.md @@ -94,12 +94,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 optional bridge as `/work-plan`: enable with `integrations.codebaseContext.enabled: true` after installing [codebase-context](https://github.com/PatrickSys/codebase-context). Artifacts live under `.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` diff --git a/distilled/workflows/plan.md b/distilled/workflows/plan.md index 5815e17..b1e83fe 100644 --- a/distilled/workflows/plan.md +++ b/distilled/workflows/plan.md @@ -96,6 +96,13 @@ 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 bridge to the [codebase-context](https://github.com/PatrickSys/codebase-context) CLI when it is installed and enabled in \`.work/config.json\` (\`integrations.codebaseContext.enabled: true\`). Default is off; when disabled or the CLI is missing, skip this block with no behavior change. \`plan-context\` is a Workspine helper name (not a codebase-context subcommand); it runs \`codebase-context patterns\` (MCP \`get_team_patterns\`) and per-file \`codebase-context search --intent explore\` (MCP \`search_codebase\`). +After the planned write set is known (repo-relative paths the tasks will touch), run: +\`node .work/bin/gsdd.mjs integrations codebase-context plan-context --files --plan --json\` +Read \`.work/integrations/codebase-context/plan-context.json\` when \`status\` is \`ok\` and fold relevant team patterns into planning context. When \`status\` is \`disabled\` or \`unavailable\`, continue without codebase-context. +Enable once per repo with \`node .work/bin/gsdd.mjs integrations codebase-context enable\` after installing the CLI (\`npx -y codebase-context\` or a local checkout). + ### When This Runs After research_check, before goal_backward_planning. diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 57bf355..989dcd8 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -452,6 +452,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 +483,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); + } +});