From 2fe3ab0075a9a3013363ef2030d8de015ab06d36 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 30 Oct 2026 22:34:03 +0000 Subject: [PATCH 1/2] feat: optional codebase-context integration for plan and execute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add off-by-default integrations.codebaseContext config, helper commands that record plan context and edit preflight receipts under .work/, workflow docs, and unit tests with a stubbed CLI runner. Co-authored-by: Patrick Rosselló Colom --- README.md | 4 +- bin/gsdd.mjs | 3 + bin/lib/config.mjs | 30 +- bin/lib/init-runtime.mjs | 10 + bin/lib/integrations-cli.mjs | 86 ++++ bin/lib/integrations/codebase-context.mjs | 386 ++++++++++++++++++ bin/lib/rendering.mjs | 5 + distilled/workflows/execute.md | 4 + distilled/workflows/plan.md | 7 + docs/USER-GUIDE.md | 15 + ...sdd.integrations-codebase-context.test.cjs | 136 ++++++ 11 files changed, 683 insertions(+), 3 deletions(-) create mode 100644 bin/lib/integrations-cli.mjs create mode 100644 bin/lib/integrations/codebase-context.mjs create mode 100644 tests/gsdd.integrations-codebase-context.test.cjs 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..f3f1e7f 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(INIT_CONTEXT); 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..c8cd992 100644 --- a/bin/lib/config.mjs +++ b/bin/lib/config.mjs @@ -18,6 +18,13 @@ export const DEFAULT_GIT_PROTOCOL = { pr: 'Follow the existing repo or team review workflow. Do not assume PR creation, timing, or naming unless explicitly requested.', }; +export const DEFAULT_INTEGRATIONS = Object.freeze({ + codebaseContext: Object.freeze({ + enabled: false, + command: 'codebase-context', + }), +}); + // Rigor controls the current workflow's alignment and quality gates. Model profiles // remain a separate cost/quality selection axis. `max` is accepted only for stored // configuration and CLI compatibility; it resolves to the currently implemented @@ -94,7 +101,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 +110,7 @@ function buildConfigDefaults({ autoAdvance = false, rigorProfile }) { ...cost, commitDocs: true, gitProtocol: { ...DEFAULT_GIT_PROTOCOL }, + integrations, initVersion: 'v1.1', }; if (autoAdvance) config.autoAdvance = true; @@ -110,7 +118,23 @@ 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 { + codebaseContext: { ...DEFAULT_INTEGRATIONS.codebaseContext }, + }; +} + +function mergeIntegrations(existing = {}) { + return { + codebaseContext: { + ...DEFAULT_INTEGRATIONS.codebaseContext, + ...(existing.codebaseContext ?? {}), + enabled: existing.codebaseContext?.enabled === true, + }, + }; } function buildExistingConfigDefaults(existing = {}, { autoAdvance = false } = {}) { @@ -139,6 +163,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 +191,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..e20a0c9 --- /dev/null +++ b/bin/lib/integrations-cli.mjs @@ -0,0 +1,86 @@ +import { + cmdCodebaseContextSubcommand, + createCodebaseContextRunner, + showIntegrationStatus, +} from './integrations/codebase-context.mjs'; +import { resolveWorkspaceContext } from './workspace-root.mjs'; +import { validateCommandShape } from './init-runtime.mjs'; +import { execFileSync } from 'child_process'; +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; +} + +export function createCmdIntegrations({ cwd = process.cwd(), runner = null } = {}) { + const defaultRunner = runner ?? createCodebaseContextRunner({ execFile: execFileSync }); + + return function cmdIntegrations(...integrationArgs) { + const grammarError = validateCommandShape('integrations', integrationArgs); + if (grammarError) { + console.error(grammarError); + process.exitCode = 1; + return; + } + + const json = integrationArgs.includes('--json'); + const workspaceRoot = resolveIntegrationsRoot(integrationArgs, cwd); + 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}`); + } + return; + } + + if (first === 'codebase-context') { + return cmdCodebaseContextSubcommand(second, [...rest, ...(json ? ['--json'] : [])], { + cwd: workspaceRoot.workspaceRoot, + json, + runner: defaultRunner, + }); + } + + console.error('Usage: workspine integrations [show] | codebase-context [flags]'); + process.exitCode = 1; + }; +} diff --git a/bin/lib/integrations/codebase-context.mjs b/bin/lib/integrations/codebase-context.mjs new file mode 100644 index 0000000..c2475c5 --- /dev/null +++ b/bin/lib/integrations/codebase-context.mjs @@ -0,0 +1,386 @@ +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, writeProjectConfig } from '../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'; + +function normalizeSlashes(value) { + return String(value || '').replace(/\\/g, '/'); +} + +export function mergeCodebaseContextIntegration(config = {}) { + const existing = config?.integrations?.codebaseContext ?? {}; + return { + ...DEFAULT_INTEGRATIONS.codebaseContext, + ...existing, + enabled: existing.enabled === true, + }; +} + +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 directoryScopes(files, workspaceRoot) { + const scopes = new Set(); + for (const file of files) { + const absolute = resolve(workspaceRoot, file); + const rel = normalizeSlashes(relative(workspaceRoot, absolute)); + if (!rel || rel.startsWith('..')) continue; + const dir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '.'; + scopes.add(dir === '' ? '.' : dir); + } + return [...scopes].slice(0, 12); +} + +function parseCliJson(stdout) { + const trimmed = String(stdout || '').trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed); + } catch { + return { raw: trimmed }; + } +} + +export function createCodebaseContextRunner({ execFile, exists = existsSync } = {}) { + if (typeof execFile !== 'function') { + throw new Error('createCodebaseContextRunner requires execFile'); + } + + function probeCli(command, cwd) { + try { + execFile(command, ['--version'], { + cwd, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + return true; + } catch { + return false; + } + } + + function runCli(command, args, cwd) { + return execFile(command, args, { + cwd, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + env: { ...process.env, CODEBASE_ROOT: cwd }, + }); + } + + return { probeCli, runCli, exists }; +} + +export function buildSkippedReceipt(reason, extra = {}) { + return { + schema_version: 1, + integration: 'codebase-context', + status: reason === 'disabled' ? 'disabled' : 'unavailable', + reason, + ...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)); + + 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, + }; + if (json) output(body); + else console.error(body.message); + process.exitCode = 1; + return body; + } + + const patternsStdout = runner.runCli(command, ['patterns', '--json'], workspaceRoot); + const patterns = parseCliJson(patternsStdout); + + const scopes = directoryScopes(files, workspaceRoot); + const scopedSearches = []; + for (const scope of scopes) { + const query = scope === '.' + ? 'team patterns and conventions for repository root files' + : `team patterns and conventions for files under ${scope}`; + const stdout = runner.runCli(command, [ + 'search', + '--query', + query, + '--intent', + 'explore', + '--json', + '--limit', + '5', + ], workspaceRoot); + scopedSearches.push({ + scope, + query, + response: parseCliJson(stdout), + }); + } + + const body = { + schema_version: 1, + integration: 'codebase-context', + status: 'ok', + recorded_at: now.toISOString(), + command, + plan_path: planPath, + files, + directory_scopes: scopes, + patterns, + scoped_searches: scopedSearches, + 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'); + + 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, + }; + 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 stdout = runner.runCli(command, [ + 'search', + '--query', + searchQuery, + '--intent', + 'edit', + '--json', + '--limit', + '10', + ], workspaceRoot); + const response = parseCliJson(stdout); + + const body = { + schema_version: 1, + integration: 'codebase-context', + status: 'ok', + recorded_at: now.toISOString(), + command, + 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 = integration.enabled ? runner.probeCli(command, workspaceRoot) : false; + return { + schema_version: 1, + integrations: { + codebaseContext: { + enabled: integration.enabled, + command, + cli_available: cliAvailable, + artifact_dir: `.work/${INTEGRATION_DIR}`, + }, + }, + }; +} + +export function setIntegrationEnabled(cwd, enabled, { writeConfig = writeProjectConfig, loadConfig = loadProjectModelConfig } = {}) { + const config = loadConfig(cwd); + config.integrations = config.integrations || {}; + config.integrations.codebaseContext = { + ...mergeCodebaseContextIntegration(config), + enabled: Boolean(enabled), + }; + writeConfig(config, cwd); + return config.integrations.codebaseContext; +} + +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, { loadConfig, writeConfig: writeProjectConfig }); + console.log(' - enabled integrations.codebaseContext'); + return null; + case 'disable': + setIntegrationEnabled(workspaceRoot, false, { loadConfig, writeConfig: writeProjectConfig }); + 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]'); + process.exitCode = 1; + return null; + } +} diff --git a/bin/lib/rendering.mjs b/bin/lib/rendering.mjs index 2e85795..e83a84a 100644 --- a/bin/lib/rendering.mjs +++ b/bin/lib/rendering.mjs @@ -74,6 +74,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 +88,7 @@ const PACKAGE_NAME = ${embeddedPackageName}; const PACKAGE_VERSION = ${embeddedPackageVersion}; const cmdNext = createCmdNext(HELPER_CONTEXT); const cmdGitIdentity = createCmdGitIdentity(HELPER_CONTEXT); +const cmdIntegrations = createCmdIntegrations(HELPER_CONTEXT); function cmdControlMap(...controlArgs) { const context = resolveWorkspaceContext([], { cwd: HELPER_CONTEXT.cwd }); @@ -121,6 +123,7 @@ const COMMANDS = { remember: cmdRememberCandidate, verify: cmdVerify, next: cmdNext, + integrations: cmdIntegrations, }; const RESOLVER_BASED_COMMANDS = new Set([ @@ -166,6 +169,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..921865d 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`. 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..81dbf8e 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. +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..4b46f22 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,15 @@ 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. + 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..c71183c --- /dev/null +++ b/tests/gsdd.integrations-codebase-context.test.cjs @@ -0,0 +1,136 @@ +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 } = {}) { + 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: 'stub-codebase-context' } }, + }, null, 2)}\n`); + return workDir; +} + +function stubRunner() { + const calls = []; + return { + calls, + probeCli: () => true, + runCli: (_command, args) => { + calls.push(args); + if (args[0] === 'patterns') return JSON.stringify({ teamPatterns: [{ name: 'Vitest', adoption: 0.9 }] }); + if (args[0] === 'search') { + const intent = args[args.indexOf('--intent') + 1]; + if (intent === 'edit') { + return JSON.stringify({ preflight: { ready: true, patterns: { do: ['match repo style'] } }, results: [] }); + } + return JSON.stringify({ results: [{ filePath: 'src/example.ts' }] }); + } + 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.equal(fs.existsSync(path.join(workDir, 'integrations', 'codebase-context', 'plan-context.json')), false); + } finally { + cleanup(tmp); + } +}); + +test('plan-context records patterns and scoped 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(runner.calls.some((args) => args[0] === 'patterns')); + assert.ok(runner.calls.some((args) => args[0] === 'search' && args.includes('explore'))); + const artifact = readJson(path.join(workDir, 'integrations', 'codebase-context', 'plan-context.json')); + assert.equal(artifact.status, 'ok'); + assert.deepEqual(artifact.files, ['src/features/a.ts', 'src/features/b.ts']); + } finally { + cleanup(tmp); + } +}); + +test('edit-preflight records preflight receipt 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.preflight.ready, true); + assert.ok(fs.existsSync(path.join(workDir, 'integrations', 'codebase-context', 'preflight', 'task-1.json'))); + assert.ok(fs.existsSync(path.join(workDir, 'integrations', 'codebase-context', 'last-preflight.json'))); + assert.ok(fs.existsSync(path.join(workDir, 'integrations', 'codebase-context', 'preflight-log.jsonl'))); + } 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); + } finally { + cleanup(tmp); + } +}); From be61a66d1fa1a9e83a3210c123cf10976a1cca00 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 30 Oct 2026 22:39:03 +0000 Subject: [PATCH 2/2] fix: map integration to real codebase-context CLI commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document Workspine-only plan-context/edit-preflight helpers, invoke patterns and search --intent explore|edit (MCP get_team_patterns / search_codebase), bundle integration modules in .work/bin, forward CLI flags correctly, and run node for .js command paths. Tests mirror real JSON shapes; optional CC_E2E_BIN e2e passes against main build. Co-authored-by: Patrick Rosselló Colom --- bin/gsdd.mjs | 2 +- bin/lib/config.mjs | 24 +-- bin/lib/integrations-cli.mjs | 44 +++++- bin/lib/integrations-config.mjs | 64 ++++++++ bin/lib/integrations/codebase-context.mjs | 147 +++++++++--------- bin/lib/rendering.mjs | 5 +- distilled/workflows/execute.md | 2 +- distilled/workflows/plan.md | 2 +- docs/USER-GUIDE.md | 2 + ...sdd.integrations-codebase-context.test.cjs | 109 +++++++++++-- 10 files changed, 286 insertions(+), 115 deletions(-) create mode 100644 bin/lib/integrations-config.mjs diff --git a/bin/gsdd.mjs b/bin/gsdd.mjs index f3f1e7f..df4e3a8 100644 --- a/bin/gsdd.mjs +++ b/bin/gsdd.mjs @@ -33,7 +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(INIT_CONTEXT); +const cmdIntegrations = createCmdIntegrations({ getCwd: () => INIT_CONTEXT.cwd }); const cmdUpdate = (...updateArgs) => { if (updateArgs.includes('--global') || updateArgs.includes('-g')) { diff --git a/bin/lib/config.mjs b/bin/lib/config.mjs index c8cd992..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.', @@ -18,13 +21,6 @@ export const DEFAULT_GIT_PROTOCOL = { pr: 'Follow the existing repo or team review workflow. Do not assume PR creation, timing, or naming unless explicitly requested.', }; -export const DEFAULT_INTEGRATIONS = Object.freeze({ - codebaseContext: Object.freeze({ - enabled: false, - command: 'codebase-context', - }), -}); - // Rigor controls the current workflow's alignment and quality gates. Model profiles // remain a separate cost/quality selection axis. `max` is accepted only for stored // configuration and CLI compatibility; it resolves to the currently implemented @@ -122,19 +118,7 @@ export function buildDefaultConfig({ autoAdvance = false } = {}) { } function defaultIntegrations() { - return { - codebaseContext: { ...DEFAULT_INTEGRATIONS.codebaseContext }, - }; -} - -function mergeIntegrations(existing = {}) { - return { - codebaseContext: { - ...DEFAULT_INTEGRATIONS.codebaseContext, - ...(existing.codebaseContext ?? {}), - enabled: existing.codebaseContext?.enabled === true, - }, - }; + return mergeIntegrations({}); } function buildExistingConfigDefaults(existing = {}, { autoAdvance = false } = {}) { diff --git a/bin/lib/integrations-cli.mjs b/bin/lib/integrations-cli.mjs index e20a0c9..6d44163 100644 --- a/bin/lib/integrations-cli.mjs +++ b/bin/lib/integrations-cli.mjs @@ -3,9 +3,14 @@ import { 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 { validateCommandShape } from './init-runtime.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) { @@ -41,11 +46,37 @@ function positionalArgs(flagArgs) { return out; } -export function createCmdIntegrations({ cwd = process.cwd(), runner = null } = {}) { +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 = validateCommandShape('integrations', integrationArgs); + const grammarError = validateIntegrationsShape(integrationArgs); if (grammarError) { console.error(grammarError); process.exitCode = 1; @@ -53,7 +84,7 @@ export function createCmdIntegrations({ cwd = process.cwd(), runner = null } = { } const json = integrationArgs.includes('--json'); - const workspaceRoot = resolveIntegrationsRoot(integrationArgs, cwd); + const workspaceRoot = resolveIntegrationsRoot(integrationArgs, resolveCwd()); if (!workspaceRoot) return; const positionals = positionalArgs(workspaceRoot.flagArgs); @@ -68,12 +99,15 @@ export function createCmdIntegrations({ cwd = process.cwd(), runner = null } = { 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') { - return cmdCodebaseContextSubcommand(second, [...rest, ...(json ? ['--json'] : [])], { + const flagArgs = subcommandFlagArgs(workspaceRoot.flagArgs); + if (json && !flagArgs.includes('--json')) flagArgs.push('--json'); + return cmdCodebaseContextSubcommand(second, flagArgs, { cwd: workspaceRoot.workspaceRoot, json, runner: defaultRunner, 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 index c2475c5..b9d4d85 100644 --- a/bin/lib/integrations/codebase-context.mjs +++ b/bin/lib/integrations/codebase-context.mjs @@ -2,25 +2,39 @@ 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, writeProjectConfig } from '../config.mjs'; +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 function mergeCodebaseContextIntegration(config = {}) { - const existing = config?.integrations?.codebaseContext ?? {}; - return { - ...DEFAULT_INTEGRATIONS.codebaseContext, - ...existing, - enabled: existing.enabled === true, - }; -} +export { mergeCodebaseContextIntegration }; export function integrationArtifactDir(workDir) { return join(workDir, INTEGRATION_DIR); @@ -40,18 +54,6 @@ function parseFilesFlag(flagArgs) { return raw.split(',').map((part) => normalizeSlashes(part).trim()).filter(Boolean); } -function directoryScopes(files, workspaceRoot) { - const scopes = new Set(); - for (const file of files) { - const absolute = resolve(workspaceRoot, file); - const rel = normalizeSlashes(relative(workspaceRoot, absolute)); - if (!rel || rel.startsWith('..')) continue; - const dir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '.'; - scopes.add(dir === '' ? '.' : dir); - } - return [...scopes].slice(0, 12); -} - function parseCliJson(stdout) { const trimmed = String(stdout || '').trim(); if (!trimmed) return null; @@ -62,6 +64,22 @@ function parseCliJson(stdout) { } } +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'); @@ -69,7 +87,8 @@ export function createCodebaseContextRunner({ execFile, exists = existsSync } = function probeCli(command, cwd) { try { - execFile(command, ['--version'], { + const { executable, argv } = resolveCliInvocation(command, ['--version']); + execFile(executable, argv, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], @@ -82,7 +101,8 @@ export function createCodebaseContextRunner({ execFile, exists = existsSync } = } function runCli(command, args, cwd) { - return execFile(command, args, { + const { executable, argv } = resolveCliInvocation(command, args); + return execFile(executable, argv, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], @@ -91,7 +111,7 @@ export function createCodebaseContextRunner({ execFile, exists = existsSync } = }); } - return { probeCli, runCli, exists }; + return { probeCli, runCli, exists, resolveCliInvocation }; } export function buildSkippedReceipt(reason, extra = {}) { @@ -100,6 +120,7 @@ export function buildSkippedReceipt(reason, extra = {}) { integration: 'codebase-context', status: reason === 'disabled' ? 'disabled' : 'unavailable', reason, + bridge: CODEBASE_CONTEXT_BRIDGE, ...extra, }; } @@ -117,6 +138,7 @@ export function runPlanContext({ 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 }); @@ -146,6 +168,7 @@ export function runPlanContext({ 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); @@ -153,29 +176,19 @@ export function runPlanContext({ return body; } - const patternsStdout = runner.runCli(command, ['patterns', '--json'], workspaceRoot); - const patterns = parseCliJson(patternsStdout); - - const scopes = directoryScopes(files, workspaceRoot); - const scopedSearches = []; - for (const scope of scopes) { - const query = scope === '.' - ? 'team patterns and conventions for repository root files' - : `team patterns and conventions for files under ${scope}`; - const stdout = runner.runCli(command, [ - 'search', - '--query', - query, - '--intent', - 'explore', - '--json', - '--limit', - '5', - ], workspaceRoot); - scopedSearches.push({ - scope, + 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(stdout), + response: parseCliJson(runner.runCli(command, searchArgv, workspaceRoot)), }); } @@ -185,11 +198,12 @@ export function runPlanContext({ status: 'ok', recorded_at: now.toISOString(), command, + bridge: CODEBASE_CONTEXT_BRIDGE, + codebase_context_invocations: ccInvocations, plan_path: planPath, files, - directory_scopes: scopes, patterns, - scoped_searches: scopedSearches, + file_searches: fileSearches, artifact: relativeArtifact, }; @@ -216,6 +230,7 @@ export function runEditPreflight({ 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', { @@ -251,6 +266,7 @@ export function runEditPreflight({ 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); @@ -260,17 +276,9 @@ export function runEditPreflight({ const fileHint = files.length > 0 ? ` Files: ${files.join(', ')}.` : ''; const searchQuery = `${String(query).trim()}.${fileHint}`; - const stdout = runner.runCli(command, [ - 'search', - '--query', - searchQuery, - '--intent', - 'edit', - '--json', - '--limit', - '10', - ], workspaceRoot); - const response = parseCliJson(stdout); + 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, @@ -278,6 +286,8 @@ export function runEditPreflight({ status: 'ok', recorded_at: now.toISOString(), command, + bridge: CODEBASE_CONTEXT_BRIDGE, + codebase_context_invocations: ccInvocations, task_id: taskId, files, query: String(query).trim(), @@ -308,7 +318,7 @@ export function runEditPreflight({ export function showIntegrationStatus({ config, workspaceRoot, runner } = {}) { const integration = mergeCodebaseContextIntegration(config); const command = integration.command || DEFAULT_INTEGRATIONS.codebaseContext.command; - const cliAvailable = integration.enabled ? runner.probeCli(command, workspaceRoot) : false; + const cliAvailable = runner.probeCli(command, workspaceRoot); return { schema_version: 1, integrations: { @@ -317,20 +327,14 @@ export function showIntegrationStatus({ config, workspaceRoot, runner } = {}) { command, cli_available: cliAvailable, artifact_dir: `.work/${INTEGRATION_DIR}`, + bridge: CODEBASE_CONTEXT_BRIDGE, }, }, }; } -export function setIntegrationEnabled(cwd, enabled, { writeConfig = writeProjectConfig, loadConfig = loadProjectModelConfig } = {}) { - const config = loadConfig(cwd); - config.integrations = config.integrations || {}; - config.integrations.codebaseContext = { - ...mergeCodebaseContextIntegration(config), - enabled: Boolean(enabled), - }; - writeConfig(config, cwd); - return config.integrations.codebaseContext; +export function setIntegrationEnabled(cwd, enabled) { + return patchIntegrations(cwd, { codebaseContext: { enabled: Boolean(enabled) } }); } export function cmdCodebaseContextSubcommand(subcommand, flagArgs, { @@ -368,11 +372,11 @@ export function cmdCodebaseContextSubcommand(subcommand, flagArgs, { runner: execRunner, }); case 'enable': - setIntegrationEnabled(workspaceRoot, true, { loadConfig, writeConfig: writeProjectConfig }); + setIntegrationEnabled(workspaceRoot, true); console.log(' - enabled integrations.codebaseContext'); return null; case 'disable': - setIntegrationEnabled(workspaceRoot, false, { loadConfig, writeConfig: writeProjectConfig }); + setIntegrationEnabled(workspaceRoot, false); console.log(' - disabled integrations.codebaseContext'); return null; case 'show': @@ -380,6 +384,7 @@ export function cmdCodebaseContextSubcommand(subcommand, flagArgs, { 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 e83a84a..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'; @@ -88,7 +91,7 @@ const PACKAGE_NAME = ${embeddedPackageName}; const PACKAGE_VERSION = ${embeddedPackageVersion}; const cmdNext = createCmdNext(HELPER_CONTEXT); const cmdGitIdentity = createCmdGitIdentity(HELPER_CONTEXT); -const cmdIntegrations = createCmdIntegrations(HELPER_CONTEXT); +const cmdIntegrations = createCmdIntegrations({ getCwd: () => HELPER_CONTEXT.cwd }); function cmdControlMap(...controlArgs) { const context = resolveWorkspaceContext([], { cwd: HELPER_CONTEXT.cwd }); diff --git a/distilled/workflows/execute.md b/distilled/workflows/execute.md index 921865d..d49d54e 100644 --- a/distilled/workflows/execute.md +++ b/distilled/workflows/execute.md @@ -94,7 +94,7 @@ 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`. 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. +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. diff --git a/distilled/workflows/plan.md b/distilled/workflows/plan.md index 81dbf8e..b1e83fe 100644 --- a/distilled/workflows/plan.md +++ b/distilled/workflows/plan.md @@ -97,7 +97,7 @@ Use these sections: 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. +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. diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 4b46f22..989dcd8 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -492,6 +492,8 @@ Each adds quality but costs tokens and time: 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 index c71183c..adea98e 100644 --- a/tests/gsdd.integrations-codebase-context.test.cjs +++ b/tests/gsdd.integrations-codebase-context.test.cjs @@ -13,30 +13,55 @@ async function importConfigModule() { return import(`${pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'config.mjs')).href}?t=${Date.now()}`); } -function initWorkDir(tmp, { enabled = false } = {}) { +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: 'stub-codebase-context' } }, + 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({ teamPatterns: [{ name: 'Vitest', adoption: 0.9 }] }); + if (args[0] === 'patterns') return JSON.stringify(patternsPayload); if (args[0] === 'search') { const intent = args[args.indexOf('--intent') + 1]; - if (intent === 'edit') { - return JSON.stringify({ preflight: { ready: true, patterns: { do: ['match repo style'] } }, results: [] }); - } - return JSON.stringify({ results: [{ filePath: 'src/example.ts' }] }); + return JSON.stringify(intent === 'edit' ? editPayload : explorePayload); } return '{}'; }, @@ -64,13 +89,14 @@ test('plan-context skips when disabled', async () => { 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 scoped search when enabled', async () => { +test('plan-context records patterns and per-file explore search when enabled', async () => { const tmp = createTempProject(); try { const workDir = initWorkDir(tmp, { enabled: true }); @@ -86,17 +112,20 @@ test('plan-context records patterns and scoped search when enabled', async () => runner, }); assert.equal(body.status, 'ok'); - assert.ok(runner.calls.some((args) => args[0] === 'patterns')); - assert.ok(runner.calls.some((args) => args[0] === 'search' && args.includes('explore'))); + 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'); - assert.deepEqual(artifact.files, ['src/features/a.ts', 'src/features/b.ts']); } finally { cleanup(tmp); } }); -test('edit-preflight records preflight receipt when enabled', async () => { +test('edit-preflight records search_codebase preflight when enabled', async () => { const tmp = createTempProject(); try { const workDir = initWorkDir(tmp, { enabled: true }); @@ -113,10 +142,11 @@ test('edit-preflight records preflight receipt when enabled', async () => { runner, }); assert.equal(body.status, 'ok'); - assert.equal(body.preflight.ready, true); + 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'))); - assert.ok(fs.existsSync(path.join(workDir, 'integrations', 'codebase-context', 'last-preflight.json'))); - assert.ok(fs.existsSync(path.join(workDir, 'integrations', 'codebase-context', 'preflight-log.jsonl'))); } finally { cleanup(tmp); } @@ -130,6 +160,55 @@ test('integrations show via CLI on fresh project reports disabled', async () => 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); }