diff --git a/TODO.md b/TODO.md index ab73d69..c9bbd0e 100644 --- a/TODO.md +++ b/TODO.md @@ -5,6 +5,9 @@ - [ ] [`ticket-083`](project/ticket-083/README.md) — keep workspace comparison artifacts and caches outside analysed repository state. Current state: `IN_PROGRESS / PUBLICATION`. +- [ ] [`ticket-084`](project/ticket-084/README.md) — honor explicit + deterministic NL mode in compare-workspace CLI. Current state: + `IN_PROGRESS / PUBLICATION`. - [ ] [`ticket-054`](project/ticket-054/README.md) — restore skills-agent discovery, prove a todo2code → Repair PR → independent Validator hand-off, then add three bounded todo2code-grounded skills. Current state: diff --git a/project/ticket-084/README.md b/project/ticket-084/README.md new file mode 100644 index 0000000..8211744 --- /dev/null +++ b/project/ticket-084/README.md @@ -0,0 +1,53 @@ +# Ticket 084: Honor deterministic NL mode in workspace comparison CLI + +- **ID**: ticket-084 +- **Owner**: unresolved:human +- **Status**: IN_PROGRESS +- **Workflow state**: PUBLICATION +- **Created**: 2026-08-21 + +## Goal and scope + +Make `t2c compare-workspace --nl-mode deterministic` authoritative for both +workspace pipelines and their deadline calculation. The command must run a +task-file comparison without an LLM credential when every selected semantic +mode is deterministic, and its help must expose the option. + +## Acceptance criteria + +- [x] AC-01: The human owner explicitly requested implementation and continued + autonomous testing on 2026-08-21. +- [x] AC-02: CLI `--nl-mode deterministic` overrides an ambient + `require-llm` default for compare-workspace. +- [x] AC-03: CLI help documents the accepted NL modes. +- [x] AC-04: Focused, full Node, governance and Docker checks pass. + +## Participants + +- Human participant: unresolved; no user-* file was created by this script. +- Agent participant: [ai-codex.md](ai-codex.md) + +## Approval gate + +The user's implementation request authorizes this bounded CLI correction. +Protected exact-head Validator evidence remains the separate merge boundary. + +## Non-goals + +- No change to pipeline, extractor, graph or comparison contracts. +- No weakening of `require-llm`; deterministic mode remains explicit opt-in. +- No automatic execution or merge authority. + +## Verification evidence + +- The focused CLI suite passes 2/2 with ambient `require-llm`, an empty API key + and explicit deterministic NL mode. +- `npm run verify` passes 423 tests with one existing JDK-only skip; type, + module, environment, workflow, generated-analysis, structured-response and + schema gates pass. +- `make docker-smoke`, governance and diff checks pass locally. Protected + exact-head review remains pending. +- Koru correctly rejected the first published head because changing `src/cli.ts` + exposed the file's pre-existing `handleExtract` CC=22. The handler is now a + small dispatcher over behavior-preserving command functions; Lizard reports + no threshold violations and the focused CLI suite still passes. diff --git a/project/ticket-084/ai-codex-logs.txt b/project/ticket-084/ai-codex-logs.txt new file mode 100644 index 0000000..4948031 --- /dev/null +++ b/project/ticket-084/ai-codex-logs.txt @@ -0,0 +1,14 @@ +$ npm run build && node --test dist/test/cli-compare-workspace.test.js +PASS: 2 tests + +$ npm run verify +PASS: 423 tests, 1 JDK-only skip + +$ make docker-smoke +docker smoke: PASS + +$ lizard src/cli.ts +PASS: no thresholds exceeded; handleExtract CC=11 (previously 22) + +$ node --test dist/test/cli-compare-workspace.test.js dist/test/cli-help.test.js dist/test/cli-intake.test.js dist/test/cli-summary.test.js dist/test/cli-todo.test.js +PASS: 6 tests diff --git a/project/ticket-084/ai-codex.md b/project/ticket-084/ai-codex.md new file mode 100644 index 0000000..d5cc5ae --- /dev/null +++ b/project/ticket-084/ai-codex.md @@ -0,0 +1,39 @@ +--- +participant-id: agent:codex +participant: codex +role: agent +ticket: ticket-084 +--- +# Participant: codex (AI agent) + +## Understanding + +The compare-workspace handler parses markdown and communication modes but not +`--nl-mode`. The comparison then receives ambient `config.nlMode`, so an +operator's explicit deterministic request is ignored and a task file can fail +closed for a missing provider credential. The comparison API itself already +uses configuration consistently; the missing boundary is the CLI adapter. + +## Execution plan + +1. Add a CLI fixture whose ambient NL mode requires LLM and whose command line + explicitly selects deterministic mode. +2. Pass the parsed mode in a command-scoped configuration object. +3. Document the option in help and run repository verification. + +## Actual changes + +- Read governance and the CLI/comparison boundary, reproduced the ignored flag, + and recorded explicit approval before implementation. +- Passed the parsed NL mode through a command-scoped comparison config and + documented the option in command help. +- Added a real Git/CLI regression and passed focused, full Node and Docker + verification. +- Transitioned to `IN_PROGRESS / PUBLICATION`; no merge is claimed locally. +- Investigated Koru's rejected exact head, separated every extract subcommand + from the dispatcher, and reduced its measured complexity below the gate + without changing extractor behavior. + +## Blockers + +- None for implementation; protected publication remains independent. diff --git a/project/ticket-084/changelog.md b/project/ticket-084/changelog.md new file mode 100644 index 0000000..d084303 --- /dev/null +++ b/project/ticket-084/changelog.md @@ -0,0 +1,10 @@ +# Ticket Changelog (ticket-084) + +## [0.1.0] - 2026-08-21 + +- Initial governance scaffold created. +- No human participant identity or content was generated. +- Bound the accepted plan for explicit deterministic comparison mode. +- Made compare-workspace honor the explicit NL mode and documented the flag. +- Split the pre-existing high-complexity extraction dispatcher into bounded + command functions so the complete changed CLI artifact passes review. diff --git a/project/ticket-084/intent.json b/project/ticket-084/intent.json new file mode 100644 index 0000000..cfb749c --- /dev/null +++ b/project/ticket-084/intent.json @@ -0,0 +1,60 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-084", + "summary": "Honor explicit deterministic NL mode in the workspace comparison CLI", + "workstream": "interfaces", + "classification": { + "kind": "BUG", + "priority": "P1", + "origin": "regression" + }, + "allowedPaths": [ + "project/ticket-084/**", + "TODO.md", + "project/TICKETS.md", + "src/cli.ts", + "test/cli-compare-workspace.test.ts" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md", + "src/comparison/**", + "src/pipeline/**", + "src/extractors/**", + "package.json", + "package-lock.json" + ], + "stacks": ["node", "docker"], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "d14bb260581e9c3f0fbe04f9f475a185476dd358", + "targetBranch": "main", + "outcome": "compare-workspace honors explicit deterministic NL mode without requiring provider configuration", + "nonGoals": ["No comparison engine change", "No default-mode weakening", "No new dependency"], + "complexity": "XS", + "estimatedMinutes": 15, + "budgets": { + "maxImplementationFiles": 2, + "maxAffectedComponents": 1, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Create a command-scoped config containing the parsed NL mode before invoking the unchanged comparison API", + "components": [{"name": "cli", "paths": ["src/cli.ts", "test/cli-compare-workspace.test.ts"]}], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": {"impact": "none", "states": [], "evidence": []}, + "rollback": "Revert the implementation commit; environment configuration again controls comparison NL extraction" + }, + "runtimeDependencies": [], + "validation": [ + {"criterion": "AC-02", "commands": ["node --test dist/test/cli-compare-workspace.test.js"], "evidence": "CLI succeeds with an empty provider credential and deterministic NL mode"}, + {"criterion": "AC-03", "commands": ["node --test dist/test/cli-compare-workspace.test.js"], "evidence": "Help exposes --nl-mode"}, + {"criterion": "AC-04", "commands": ["npm run verify", "./project/governance-check.sh", "make docker-smoke"], "evidence": "Repository gates pass on exact head"} + ] + } +} diff --git a/project/ticket-084/preprompt.md b/project/ticket-084/preprompt.md new file mode 100644 index 0000000..6d54556 --- /dev/null +++ b/project/ticket-084/preprompt.md @@ -0,0 +1,8 @@ +# Ticket preprompt + +- **Task ID**: ticket-084 +- **Task title**: Honor deterministic NL mode in workspace comparison CLI +- **Created**: 2026-08-21T21:02:10Z + +Keep executable implementation outside this governance/evidence directory. +Read a human-owned user-*.md file only when one exists. diff --git a/src/cli.ts b/src/cli.ts index 727e4aa..fe577ec 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -322,6 +322,7 @@ export async function main(argv = process.argv.slice(2)): Promise { } if (command === 'compare-workspace') { const root = path.resolve(parsed.positionals[0] ?? config.root); + const comparisonConfig = { ...config, nlMode: optionNlMode(parsed, config.nlMode) }; const result = await compareWorkspaceIntent({ root, baseRef: optionString(parsed, 'base') ?? 'origin/main', @@ -335,7 +336,7 @@ export async function main(argv = process.argv.slice(2)): Promise { communicationMode: optionLlmMode(parsed, 'communication-mode', config.communicationMode), outputDir: optionString(parsed, 'out') ?? config.outputDir, gitCommitCount: optionNumber(parsed, 'git-count', config.gitCommitCount, 1, 100), - }, config); + }, comparisonConfig); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); return; } @@ -527,72 +528,90 @@ async function handleExtract(parsed: ParsedArgs, config: ReturnType [--text "..."] [--out records.jsonl]'); - const result = await extractNlIntentAudited( - { root, sourcePath: file ?? 'cli-input.md', ...(inline ? { text: inline } : {}) }, - config, - optionNlMode(parsed, config.nlMode), - ); - await emitExtraction(result, out); - process.stderr.write(`NL -> DSL: ${result.audit.status} (${result.audit.effectiveMode})\n`); - return; - } - if (extractor === 'git') { - const result = await extractGitIntent({ root, count: optionNumber(parsed, 'count', config.gitCommitCount, 1, 100) }, config); - await emitExtraction(result, out); - return; - } - if (extractor === 'ast') { - const result = await extractAstIntent({ root: path.resolve(parsed.positionals[0] ?? root) }, config); - await emitExtraction(result, out); - return; + const context = { parsed, config, root, out }; + switch (extractor) { + case 'nl': return extractNlCommand(context); + case 'git': return extractGitCommand(context); + case 'ast': return extractAstCommand(context); + case 'config': return extractConfigCommand(context); + case 'runtime': return extractRuntimeCommand(context); + case 'markdown': return extractMarkdownCommand(context); + case 'docs': return extractDocsCommand(context); + case 'communication': return extractCommunicationCommand(context); + default: throw new Error('Usage: t2c extract ...'); } - if (extractor === 'config') { - const result = await extractConfigurationIntent(path.resolve(parsed.positionals[0] ?? root), config); - await emitExtraction(result, out); - return; - } - if (extractor === 'runtime') { - const cycle = parsed.positionals[0]; - if (!cycle) throw new Error('Usage: t2c extract runtime [--out runtime.intent.jsonl]'); - const result = await extractRuntimeCycleIntent(cycle, config, root); - await emitExtraction(result, out); - return; - } - if (extractor === 'markdown') { - const result = await extractMarkdownIntentAudited({ - root, - todoPath: optionNullableString(parsed, 'todo', 'TODO.md'), - changelogPath: optionNullableString(parsed, 'changelog', 'CHANGELOG.md'), - }, config, optionLlmMode(parsed, 'markdown-mode', config.markdownMode)); - await emitExtraction(result, out); - process.stderr.write(`TODO/CHANGELOG -> DSL: ${result.audit.status} (${result.audit.effectiveMode})\n`); - return; - } - if (extractor === 'docs') { - const result = await extractDocumentationIntent({ - root, - patterns: optionList(parsed, 'patterns', config.documentPatterns), - excludes: optionList(parsed, 'excludes', config.documentExcludes), - }, config); - await emitExtraction(result, out); - process.stderr.write(`documentation -> DSL: ${result.audit.status} (${result.audit.effectiveMode}), runtime ${result.audit.runtimeVersion}\n`); - return; - } - if (extractor === 'communication') { - const result = await extractCommunicationIntentAudited({ - root, - projectDir: optionString(parsed, 'project-dir') ?? 'project', - ticket: optionNullableString(parsed, 'ticket', null), - }, config, optionLlmMode(parsed, 'communication-mode', config.communicationMode)); - await emitExtraction(result, out); - process.stderr.write(`communication -> DSL: ${result.audit.status} (${result.audit.effectiveMode})\n`); - return; - } - throw new Error('Usage: t2c extract ...'); +} + +interface ExtractCommandContext { + parsed: ParsedArgs; + config: ReturnType; + root: string; + out: string | null; +} + +async function extractNlCommand({ parsed, config, root, out }: ExtractCommandContext): Promise { + const file = parsed.positionals[0]; + const inline = optionString(parsed, 'text'); + if (!file && !inline) throw new Error('Usage: t2c extract nl [--text "..."] [--out records.jsonl]'); + const result = await extractNlIntentAudited( + { root, sourcePath: file ?? 'cli-input.md', ...(inline ? { text: inline } : {}) }, + config, + optionNlMode(parsed, config.nlMode), + ); + await emitExtraction(result, out); + process.stderr.write(`NL -> DSL: ${result.audit.status} (${result.audit.effectiveMode})\n`); +} + +async function extractGitCommand({ parsed, config, root, out }: ExtractCommandContext): Promise { + const result = await extractGitIntent({ root, count: optionNumber(parsed, 'count', config.gitCommitCount, 1, 100) }, config); + await emitExtraction(result, out); +} + +async function extractAstCommand({ parsed, config, root, out }: ExtractCommandContext): Promise { + const result = await extractAstIntent({ root: path.resolve(parsed.positionals[0] ?? root) }, config); + await emitExtraction(result, out); +} + +async function extractConfigCommand({ parsed, config, root, out }: ExtractCommandContext): Promise { + const result = await extractConfigurationIntent(path.resolve(parsed.positionals[0] ?? root), config); + await emitExtraction(result, out); +} + +async function extractRuntimeCommand({ parsed, config, root, out }: ExtractCommandContext): Promise { + const cycle = parsed.positionals[0]; + if (!cycle) throw new Error('Usage: t2c extract runtime [--out runtime.intent.jsonl]'); + const result = await extractRuntimeCycleIntent(cycle, config, root); + await emitExtraction(result, out); +} + +async function extractMarkdownCommand({ parsed, config, root, out }: ExtractCommandContext): Promise { + const result = await extractMarkdownIntentAudited({ + root, + todoPath: optionNullableString(parsed, 'todo', 'TODO.md'), + changelogPath: optionNullableString(parsed, 'changelog', 'CHANGELOG.md'), + }, config, optionLlmMode(parsed, 'markdown-mode', config.markdownMode)); + await emitExtraction(result, out); + process.stderr.write(`TODO/CHANGELOG -> DSL: ${result.audit.status} (${result.audit.effectiveMode})\n`); +} + +async function extractDocsCommand({ parsed, config, root, out }: ExtractCommandContext): Promise { + const result = await extractDocumentationIntent({ + root, + patterns: optionList(parsed, 'patterns', config.documentPatterns), + excludes: optionList(parsed, 'excludes', config.documentExcludes), + }, config); + await emitExtraction(result, out); + process.stderr.write(`documentation -> DSL: ${result.audit.status} (${result.audit.effectiveMode}), runtime ${result.audit.runtimeVersion}\n`); +} + +async function extractCommunicationCommand({ parsed, config, root, out }: ExtractCommandContext): Promise { + const result = await extractCommunicationIntentAudited({ + root, + projectDir: optionString(parsed, 'project-dir') ?? 'project', + ticket: optionNullableString(parsed, 'ticket', null), + }, config, optionLlmMode(parsed, 'communication-mode', config.communicationMode)); + await emitExtraction(result, out); + process.stderr.write(`communication -> DSL: ${result.audit.status} (${result.audit.effectiveMode})\n`); } async function handleCommunication(parsed: ParsedArgs, config: ReturnType): Promise { @@ -857,7 +876,7 @@ function printHelp(): void { process.stdout.write(` [--nl-mode require-llm] [--markdown-mode require-llm] [--docs 'README.md,docs/**/*.md'] [--doc-excludes '...']\n`); process.stdout.write(` [--no-docs-llm] [--no-summary-llm] [--task-mode disabled|prefer-llm|require-llm]\n`); process.stdout.write(` [--cycle cycle.json] [--project-dir project] [--communication-ticket TICKET] [--communication-mode deterministic|prefer-llm|require-llm] [--no-communication] [--out .intent]\n`); - process.stdout.write(` t2c compare-workspace [root] [--base origin/main] [--task TASK.md] [--markdown-mode require-llm] [--docs-llm]\n`); + process.stdout.write(` t2c compare-workspace [root] [--base origin/main] [--task TASK.md] [--nl-mode deterministic|prefer-llm|require-llm] [--markdown-mode require-llm] [--docs-llm]\n`); process.stdout.write(` [--docs 'README.md,docs/**/*.md'] [--doc-excludes '...'] [--out .intent]\n`); process.stdout.write(` t2c mcp\n`); process.stdout.write(` t2c a2a\n\n`); diff --git a/test/cli-compare-workspace.test.ts b/test/cli-compare-workspace.test.ts new file mode 100644 index 0000000..a85c1a7 --- /dev/null +++ b/test/cli-compare-workspace.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { promisify } from 'node:util'; +import type { PipelineManifest } from '../src/core/types.js'; + +const exec = promisify(execFile); + +test('compare-workspace CLI honors explicit deterministic NL mode', async (t) => { + const parent = await fs.mkdtemp(path.join(os.tmpdir(), 't2c-cli-compare-')); + t.after(async () => fs.rm(parent, { recursive: true, force: true })); + const root = path.join(parent, 'repo'); + const remote = path.join(parent, 'origin.git'); + await fs.mkdir(root); + await exec('git', ['init', '-q', '--initial-branch=main'], { cwd: root }); + await exec('git', ['config', 'user.email', 'compare@todo2code.local'], { cwd: root }); + await exec('git', ['config', 'user.name', 't2c compare test'], { cwd: root }); + await fs.writeFile(path.join(root, 'TASK.md'), 'System must validate the ticket intent.\n'); + await fs.writeFile(path.join(root, '.gitignore'), '.intent-compare/\n.intent/\n'); + await exec('git', ['add', '.'], { cwd: root }); + await exec('git', ['commit', '-q', '-m', 'initial comparison fixture'], { cwd: root }); + await exec('git', ['init', '-q', '--bare', remote], { cwd: parent }); + await exec('git', ['remote', 'add', 'origin', remote], { cwd: root }); + await exec('git', ['push', '-q', '-u', 'origin', 'main'], { cwd: root }); + + const cli = path.resolve('dist/src/cli.js'); + const result = await exec(process.execPath, [ + cli, + 'compare-workspace', + root, + '--task', 'TASK.md', + '--todo', 'none', + '--changelog', 'none', + '--docs', 'README-does-not-exist.md', + '--nl-mode', 'deterministic', + '--markdown-mode', 'deterministic', + '--communication-mode', 'deterministic', + '--out', '.intent-compare', + ], { + cwd: root, + env: { + ...process.env, + OPENROUTER_API_KEY: '', + T2C_ENV_FILE: 'missing.env', + T2C_NL_MODE: 'require-llm', + T2C_ENABLE_PYTHON_AST: 'false', + T2C_ENABLE_GO_AST: 'false', + T2C_ENABLE_JAVA_AST: 'false', + T2C_ENABLE_RUST_AST: 'false', + }, + }); + + const comparison = JSON.parse(result.stdout) as { artifacts: Record }; + for (const key of ['baseManifest', 'workspaceManifest']) { + const manifest = JSON.parse( + await fs.readFile(path.resolve(root, comparison.artifacts[key] ?? ''), 'utf8'), + ) as PipelineManifest; + assert.equal(manifest.configuration.nlMode, 'deterministic'); + assert.equal(manifest.stages.naturalLanguageExtraction.status, 'succeeded'); + assert.equal(manifest.stages.naturalLanguageExtraction.effectiveMode, 'deterministic'); + } +}); + +test('compare-workspace help exposes explicit NL modes', async () => { + const cli = path.resolve('dist/src/cli.js'); + const result = await exec(process.execPath, [cli, 'compare-workspace', '--help'], { + env: { ...process.env, T2C_ENV_FILE: 'missing.env' }, + }); + assert.match(result.stdout, /compare-workspace[^\n]+--nl-mode deterministic\|prefer-llm\|require-llm/); +});