From 6061d77573e265e8fdf256b31022d9b4a3a42988 Mon Sep 17 00:00:00 2001 From: ccisnedev Date: Wed, 22 Jul 2026 19:22:41 -0500 Subject: [PATCH] =?UTF-8?q?feat(cli):=20iq=20implementation=20start=20--is?= =?UTF-8?q?sue=20=20=E2=80=94=20open=20a=20cycle=20in=20one=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #295. Replaces the manual bootstrap that inquiry-start.md handed to the model step by step (derive slug, git checkout -b, mkdir cleanroom, hand-write index.md, transition) with a single deterministic command: it resolves the project root, auto-initializes the workspace when .inquiry/ is missing, reads the issue title from GitHub, derives and checks out the - branch, and fires start_analyze (whose existing effect scaffolds the cleanroom). main -> one command -> linked branch + cleanroom + ANALYZE. - First module built to the module-per-stage direction (iq ), recorded in docs/roadmap.md; --issue is an explicit required flag. - Branch-policy failure now teaches: expected pattern + example + actual branch + the command that fixes it, instead of a bare assertion. - inquiry-start.md no longer teaches git/mkdir/hand-writing scaffold; it invokes the command (mechanical processes are iq commands, not model instructions). - Tests: branch_name (slug/pad/compose) + implementation_start integration (branch, cleanroom, ANALYZE, auto-init, idempotency, teaching errors). 593 pass. - Bump 0.20.0 -> 0.21.0 across version.dart, pubspec, site badge. --- code/cli/CHANGELOG.md | 8 + code/cli/assets/instructions/inquiry-start.md | 94 ++--- code/cli/lib/inquiry_cli.dart | 2 + .../lib/modules/fsm/commands/transition.dart | 18 +- .../modules/implementation/branch_name.dart | 64 ++++ .../implementation/commands/start.dart | 333 ++++++++++++++++++ .../implementation_builder.dart | 21 ++ code/cli/lib/src/version.dart | 2 +- code/cli/pubspec.yaml | 2 +- code/cli/test/branch_name_test.dart | 65 ++++ code/cli/test/implementation_start_test.dart | 133 +++++++ .../test/instruction_prompt_loader_test.dart | 4 +- code/site/index.html | 2 +- docs/roadmap.md | 29 ++ 14 files changed, 702 insertions(+), 75 deletions(-) create mode 100644 code/cli/lib/modules/implementation/branch_name.dart create mode 100644 code/cli/lib/modules/implementation/commands/start.dart create mode 100644 code/cli/lib/modules/implementation/implementation_builder.dart create mode 100644 code/cli/test/branch_name_test.dart create mode 100644 code/cli/test/implementation_start_test.dart diff --git a/code/cli/CHANGELOG.md b/code/cli/CHANGELOG.md index 281a0e82..21cf7439 100644 --- a/code/cli/CHANGELOG.md +++ b/code/cli/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format loosely follows [Keep a Changelog](https://keepachangelog.com/) and the project adheres to [Semantic Versioning](https://semver.org/). +## [0.21.0] +### Added +- **`iq implementation start --issue ` — one explicit command opens a cycle.** It is the whole mechanical bootstrap that used to be handed to the model step by step in `inquiry-start.md` (derive the slug, `git checkout -b`, `mkdir` the cleanroom, hand-write `index.md`, then transition): now a single deterministic command. It resolves the project root, **initializes the workspace itself if `.inquiry/` is missing** (no separate `iq init`), reads the issue title from GitHub, derives the `-` branch and checks it out, and fires `start_analyze` — whose existing effect scaffolds `cleanrooms//analyze/`. The result: `main` → one command → linked branch + cleanroom + ANALYZE. This is the first module built to the **module-per-stage** direction (`iq `, every argument explicit and named) recorded in `docs/roadmap.md`. + +### Changed +- **The branch-policy failure now teaches instead of asserting.** `feature_branch_selected` returned a bare `Use issue-linked feature branch matching active issue`. It now names the expected pattern, gives an example, shows the actual branch, states why a `feat/-…` prefix is rejected (the name must be a single segment), and points at `iq implementation start --issue ` — the command that produces a valid branch in one step. +- **`inquiry-start.md` no longer teaches the model to run `git`/`mkdir` or hand-write scaffold files.** The bootstrap is owned by the CLI; the instruction simply invokes `iq implementation start --issue `, honoring the principle that every mechanical process is an `iq` command. + ## [0.20.0] ### Added - **Every command declares its contract, and the help *is* that contract.** Each command declares its parameters (`CliParam`) on its `Input`, so one declaration feeds three things that were maintained by hand and drifted apart: the help text, the machine-readable catalog, and the parser. diff --git a/code/cli/assets/instructions/inquiry-start.md b/code/cli/assets/instructions/inquiry-start.md index 7bc0cf90..501d629d 100644 --- a/code/cli/assets/instructions/inquiry-start.md +++ b/code/cli/assets/instructions/inquiry-start.md @@ -1,6 +1,6 @@ --- name: inquiry-start -description: 'Protocol for starting work on an existing GitHub issue. Verifies the issue, prepares the branch and cleanroom, and transitions to ANALYZE state.' +description: 'Protocol for starting work on an existing GitHub issue. Verifies the issue and opens the cycle with a single command, which prepares the branch and cleanroom and transitions to ANALYZE.' --- # inquiry-start - Operational Start Protocol @@ -9,9 +9,7 @@ description: 'Protocol for starting work on an existing GitHub issue. Verifies t Run iq doctor first and stop on any failed check. Verify the issue already exists with gh issue view. -Create branch NNN-slug and cleanrooms/NNN-slug/analyze. -Create analyze/index.md for the cleanroom. -Transition with iq fsm transition --event start_analyze --issue NNN. +Open the implementation with iq implementation start --issue NNN, which derives the NNN-slug branch, checks it out, scaffolds the cleanroom, and transitions to ANALYZE. Confirm iq fsm state reports ANALYZE for that issue. ## When to Use @@ -49,91 +47,51 @@ All checks must pass. Do not proceed if any check fails. gh issue view --json number,title,state ``` -Confirm the issue already exists and extract `number` and `title` from the JSON response. +Confirm the issue already exists. You do NOT need to read the title to build a +branch name — `iq implementation start` derives it from the issue itself. -### Step 3: Generate Slug - -Transform the confirmed issue title into a slug: - -1. Lowercase the title -2. Replace spaces with hyphens (`-`) -3. Remove special characters (keep only `a-z`, `0-9`, `-`) -4. Limit to 50 characters -5. Trim trailing hyphens - -**Examples:** -- "Fix login timeout" -> `fix-login-timeout` -- "Add dark mode support!!!" -> `add-dark-mode-support` -- "URGENT: Database migration script" -> `urgent-database-migration-script` - -### Step 4: Create Branch - -Format: `-` - -```bash -git checkout -b - -``` - -**Examples:** -- Issue #37 "Fix login timeout" -> `git checkout -b 037-fix-login-timeout` -- Issue #142 "Add dark mode" -> `git checkout -b 142-add-dark-mode` - -Note: Pad issue numbers less than 100 with leading zeros for sort consistency. - -### Step 5: Create Working Directory +### Step 3: Open the Cycle ```bash -mkdir -p cleanrooms/-/analyze/ +iq implementation start --issue ``` -This creates the analysis directory for SOCRATES to work in during ANALYZE phase. - -### Step 6: Create index.md +This single command owns every mechanical step of the bootstrap — there is no +`git checkout -b`, no `mkdir`, no hand-writing of scaffold files: -Create `cleanrooms/-/analyze/index.md` with this template: +1. Initializes the Inquiry workspace if it is missing (no separate `iq init`). +2. Reads the issue title and derives the branch `-` (issue number + left-padded to three digits, e.g. `#37 → 037-fix-login-timeout`). +3. Creates and checks out that branch (or checks it out if it already exists). +4. Fires the `start_analyze` transition, whose effect scaffolds + `cleanrooms/-/analyze/` (index.md, confirmations.md, diagnosis.md) + and the issue mirror. -```markdown -# Analyze Phase - Index +The command reports the branch, the cleanroom path, and the new state. Do NOT +write `.inquiry` state directly — all state mutations go through `iq` commands. -**Issue:** # - -**Branch:** <NNN>-<slug> -**Phase:** ANALYZE -**Status:** In progress +### Step 4: Verify ---- - -## Documents - -| # | File | Description | -|---|------|-------------| -``` - -### Step 7: Transition to ANALYZE - -Execute the CLI transition command with the issue number: - -``` -iq fsm transition --event start_analyze --issue <NNN> +```bash +iq fsm state ``` -This transitions the FSM to ANALYZE and auto-activates the analysis sub-agent. Do NOT write `.inquiry/state.yaml` directly - all state mutations go through `iq` commands. - -### Step 8: Verify Transition - -Run `iq fsm state` to confirm the state is now ANALYZE with the correct issue number. +Confirm the state is now ANALYZE with the correct issue number. ## Verification After completing all steps, verify: - [ ] The issue already exists and `gh issue view <NNN> --json number,title,state` succeeds +- [ ] `iq implementation start --issue <NNN>` reported ANALYZE - [ ] Branch exists: `git branch --show-current` returns `<NNN>-<slug>` -- [ ] Directory exists: `cleanrooms/<NNN>-<slug>/analyze/index.md` +- [ ] Directory exists: `cleanrooms/<NNN>-<slug>/analyze/` - [ ] State updated: `iq fsm state` shows ANALYZE with the issue number ## Notes - This skill is executed by the scheduler APE, not by a human - TRIAGE owns issue creation or confirmation through `issue-create` -- The scheduler reads this document and executes commands step by step -- If any step fails, the scheduler should report the error and remain in IDLE \ No newline at end of file +- Mechanical bootstrap (slug, branch, cleanroom, transition) is owned by + `iq implementation start` — the scheduler invokes it, it does not reproduce those steps +- If any step fails, the scheduler should report the error and remain in IDLE diff --git a/code/cli/lib/inquiry_cli.dart b/code/cli/lib/inquiry_cli.dart index 0d3ede5f..bb48b88e 100644 --- a/code/cli/lib/inquiry_cli.dart +++ b/code/cli/lib/inquiry_cli.dart @@ -12,6 +12,7 @@ import 'assets.dart'; import 'modules/global/global_builder.dart'; import 'modules/fsm/fsm_builder.dart'; import 'modules/ape/ape_builder.dart'; +import 'modules/implementation/implementation_builder.dart'; import 'modules/host/host_builder.dart'; import 'modules/issue/issue_builder.dart'; import 'modules/specification/specification_builder.dart'; @@ -64,6 +65,7 @@ Future<int> runInquiry( cli.module('host', (m) => buildHostModule(m, deployer: deployer, cleaner: cleaner)); cli.module('fsm', (m) => buildFsmModule(m, assets: assets)); cli.module('ape', (m) => buildApeModule(m, assets: assets)); + cli.module('implementation', (m) => buildImplementationModule(m, assets: assets)); cli.module( 'specification', (m) => buildSpecificationModule(m, assets: assets), diff --git a/code/cli/lib/modules/fsm/commands/transition.dart b/code/cli/lib/modules/fsm/commands/transition.dart index be79e7d3..589341b7 100644 --- a/code/cli/lib/modules/fsm/commands/transition.dart +++ b/code/cli/lib/modules/fsm/commands/transition.dart @@ -720,7 +720,7 @@ class StateTransitionCommand issue: issue, promptFragmentId: promptFragmentId, ); - return 'ERROR_PRECONDITION_BRANCH_POLICY: Use issue-linked feature branch matching active issue'; + return _branchPolicyError(branch: branch, issue: issue); } _recordPrecheckSensor( executor, @@ -1264,6 +1264,22 @@ class StateTransitionCommand ); } + /// The branch-policy failure, written to teach: it names the expected pattern, + /// gives a concrete example, shows the actual branch, and points at the command + /// that produces a valid branch in one step (AC2). + String _branchPolicyError({required String branch, String? issue}) { + final issueRef = (issue != null && issue.trim().isNotEmpty) + ? issue.trim() + : '<issue>'; + final actual = branch.trim().isEmpty ? '(no branch)' : branch.trim(); + return 'ERROR_PRECONDITION_BRANCH_POLICY: the current branch "$actual" is not ' + 'linked to issue #$issueRef.\n' + 'Expected a branch named "<NNN>-<slug>" that starts with "$issueRef-" ' + '(e.g. "$issueRef-fix-login"). A branch under a prefix like ' + '"feat/$issueRef-…" does NOT qualify — the name must be a single segment.\n' + 'Create it and enter ANALYZE in one step with: iq implementation start --issue $issueRef'; + } + bool _isIssueLinkedFeatureBranch(String branch, String? issue) { final normalizedBranch = branch.trim(); if (normalizedBranch.isEmpty || diff --git a/code/cli/lib/modules/implementation/branch_name.dart b/code/cli/lib/modules/implementation/branch_name.dart new file mode 100644 index 00000000..8fc10f63 --- /dev/null +++ b/code/cli/lib/modules/implementation/branch_name.dart @@ -0,0 +1,64 @@ +/// Deterministic branch-name derivation for a cycle. +/// +/// The `<NNN>-<slug>` convention is what the FSM's `feature_branch_selected` +/// precheck validates (`fsm/commands/transition.dart` → `_isIssueLinkedFeatureBranch`). +/// Historically the LLM derived this by hand following `assets/instructions/ +/// inquiry-start.md`; moving it here makes it a deterministic CLI step (a +/// "mechanical process is an `iq` command", not an instruction). +library; + +/// Common Spanish/Latin accented letters folded to ASCII, so a title like +/// "incluir código" yields `incluir-codigo`, not `incluir-c-digo`. +const Map<String, String> _accentFolds = { + 'á': 'a', 'à': 'a', 'â': 'a', 'ä': 'a', 'ã': 'a', + 'é': 'e', 'è': 'e', 'ê': 'e', 'ë': 'e', + 'í': 'i', 'ì': 'i', 'î': 'i', 'ï': 'i', + 'ó': 'o', 'ò': 'o', 'ô': 'o', 'ö': 'o', 'õ': 'o', + 'ú': 'u', 'ù': 'u', 'û': 'u', 'ü': 'u', + 'ñ': 'n', 'ç': 'c', +}; + +/// Derives a kebab-case slug from an issue [title]. +/// +/// Rules (matching the documented convention, now enforced in code): lowercase, +/// fold accents, replace every run of non-alphanumerics with a single hyphen, +/// trim leading/trailing hyphens, cap at 50 characters (re-trimming any hyphen +/// the cut leaves dangling). Returns an empty string when the title has no +/// alphanumeric content. +String deriveSlug(String title) { + final lowered = title.toLowerCase(); + final folded = StringBuffer(); + for (final ch in lowered.split('')) { + folded.write(_accentFolds[ch] ?? ch); + } + + var slug = folded + .toString() + .replaceAll(RegExp(r'[^a-z0-9]+'), '-') + .replaceAll(RegExp(r'^-+|-+$'), ''); + + if (slug.length > 50) { + slug = slug.substring(0, 50).replaceAll(RegExp(r'-+$'), ''); + } + return slug; +} + +/// Left-pads an issue number to at least three digits, matching the convention +/// (`#37 → 037`, `#142 → 142`, `#295 → 295`). +String padIssueNumber(String issue) { + final n = int.tryParse(issue.trim()); + if (n == null) return issue.trim(); + return n.toString().padLeft(3, '0'); +} + +/// The full branch name for an [issue] with the given [title]: +/// `<NNN>-<slug>`. Throws [ArgumentError] when the title yields no slug. +String branchNameFor({required String issue, required String title}) { + final slug = deriveSlug(title); + if (slug.isEmpty) { + throw ArgumentError( + 'issue title "$title" has no alphanumeric content to build a branch slug from', + ); + } + return '${padIssueNumber(issue)}-$slug'; +} diff --git a/code/cli/lib/modules/implementation/commands/start.dart b/code/cli/lib/modules/implementation/commands/start.dart new file mode 100644 index 00000000..074a9811 --- /dev/null +++ b/code/cli/lib/modules/implementation/commands/start.dart @@ -0,0 +1,333 @@ +/// `iq implementation start --issue <N>` — one command that takes a published +/// issue from "nothing" to a ready ANALYZE cycle. +/// +/// It owns the whole mechanical bootstrap that `assets/instructions/ +/// inquiry-start.md` used to hand to the LLM step by step (derive slug, create +/// the branch, scaffold the cleanroom, transition): a mechanical process is an +/// `iq` command, not an instruction. Concretely it: +/// +/// 1. resolves the git project root (auto-runs `init` if the workspace is missing), +/// 2. reads the issue title from GitHub (`gh issue view`), +/// 3. derives the `<NNN>-<slug>` branch and checks it out (creating it if new), +/// 4. fires the `start_analyze` transition, whose existing `open_analysis_context` +/// effect scaffolds `cleanrooms/<branch>/analyze/`. +/// +/// Its errors are exemplary (AC2): every failure states what went wrong and how +/// to fix it, with a concrete example. Its happy path does NOT print a "next +/// step" — that would bias the user and make the command flow-aware. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:cli_router/cli_router.dart'; +import 'package:modular_cli_sdk/modular_cli_sdk.dart'; +import 'package:path/path.dart' as p; + +import '../../../assets.dart'; +import '../../../src/git_utils.dart'; +import '../../fsm/commands/transition.dart'; +import '../../global/commands/init.dart'; +import '../branch_name.dart'; + +typedef GitCommandRunner = + ProcessResult Function(String workingDirectory, List<String> arguments); + +/// The title and state of a GitHub issue, or `null` when it cannot be read. +typedef IssueInfoProvider = + ({String title, String state})? Function( + String issue, + String workingDirectory, + ); + +/// Runs the `start_analyze` transition and reports success + message. +typedef StartAnalyzeRunner = + Future<({bool ok, String message})> Function({ + required String issue, + required String workingDirectory, + Assets? assets, + }); + +// ─── Input ────────────────────────────────────────────────────────────────── + +class ImplementationStartInput extends Input { + final String issue; + final String workingDirectory; + + ImplementationStartInput({ + required this.issue, + required this.workingDirectory, + }); + + static final List<CliParam> params = [ + CliParam.string( + 'issue', + required: true, + description: 'The GitHub issue number to implement (e.g. --issue 40)', + ), + ]; + + factory ImplementationStartInput.fromCliRequest(CliRequest req) => + ImplementationStartInput( + issue: (req.flagString('issue') ?? '').trim(), + workingDirectory: Directory.current.path, + ); + + @override + List<CliParam> get schemaFields => params; + + @override + Map<String, dynamic> toJson() => { + 'issue': issue, + 'workingDirectory': workingDirectory, + }; +} + +// ─── Output ───────────────────────────────────────────────────────────────── + +class ImplementationStartOutput extends Output { + final String issue; + final String branch; + final String cleanroom; + final bool branchCreated; + final bool initialized; + + ImplementationStartOutput({ + required this.issue, + required this.branch, + required this.cleanroom, + required this.branchCreated, + required this.initialized, + }); + + @override + Map<String, dynamic> toJson() => { + 'issue': issue, + 'branch': branch, + 'cleanroom': cleanroom, + 'branch_created': branchCreated, + 'initialized': initialized, + }; + + @override + int get exitCode => ExitCode.ok; + + @override + String? toText() { + final buffer = StringBuffer(); + if (initialized) { + buffer.writeln('Initialized Inquiry workspace (.inquiry/).'); + } + buffer.writeln('Implementation started for issue #$issue:'); + buffer.writeln( + ' branch: $branch ${branchCreated ? '(created)' : '(existing)'}', + ); + buffer.writeln(' cleanroom: $cleanroom'); + buffer.write(' state: ANALYZE'); + return buffer.toString(); + } +} + +// ─── Command ──────────────────────────────────────────────────────────────── + +class ImplementationStartCommand + implements Command<ImplementationStartInput, ImplementationStartOutput> { + @override + final ImplementationStartInput input; + final Assets? _assets; + final GitCommandRunner _git; + final IssueInfoProvider _issueInfo; + final StartAnalyzeRunner _startAnalyze; + + ImplementationStartCommand( + this.input, { + Assets? assets, + GitCommandRunner? gitCommandRunner, + IssueInfoProvider? issueInfoProvider, + StartAnalyzeRunner? startAnalyzeRunner, + }) : _assets = assets, + _git = gitCommandRunner ?? _defaultGit, + _issueInfo = issueInfoProvider ?? _defaultIssueInfo, + _startAnalyze = startAnalyzeRunner ?? _defaultStartAnalyze; + + @override + String? validate() { + if (input.issue.isEmpty) { + return 'Missing --issue.\n' + 'Usage: iq implementation start --issue <number>\n' + 'Example: iq implementation start --issue 40'; + } + if (int.tryParse(input.issue) == null) { + return 'The --issue value must be a number, got "${input.issue}".\n' + 'Usage: iq implementation start --issue <number>\n' + 'Example: iq implementation start --issue 40'; + } + return null; + } + + @override + Future<ImplementationStartOutput> execute() async { + final projectRoot = getProjectRoot(input.workingDirectory); + if (projectRoot == null) { + throw CommandException( + code: 'NOT_A_GIT_REPO', + message: + 'Not inside a git repository, so there is no place to open a cycle.\n' + 'Run `iq implementation start --issue ${input.issue}` from within your project, ' + 'or initialize one first with `git init`.', + exitCode: ExitCode.validationFailed, + ); + } + + // AC3 — the workspace initializes itself when missing; the user never has + // to remember `iq init`. + final initialized = _ensureWorkspace(projectRoot); + + // Read the issue so the branch slug comes from its real title. + final info = _issueInfo(input.issue, projectRoot); + if (info == null) { + throw CommandException( + code: 'ISSUE_NOT_FOUND', + message: + 'Could not read issue #${input.issue} from GitHub.\n' + 'Check that it exists and that `gh` is authenticated:\n' + ' gh issue view ${input.issue}\n' + ' gh auth status', + exitCode: ExitCode.validationFailed, + ); + } + + final String branch; + try { + branch = branchNameFor(issue: input.issue, title: info.title); + } on ArgumentError catch (e) { + throw CommandException( + code: 'EMPTY_SLUG', + message: + '${e.message}\n' + 'Rename the issue to something with letters or numbers, then retry.', + exitCode: ExitCode.validationFailed, + ); + } + + final branchCreated = _checkoutBranch(projectRoot, branch); + + // Fire the real transition; its open_analysis_context effect scaffolds the + // cleanroom under cleanrooms/<branch>/. + final result = await _startAnalyze( + issue: input.issue, + workingDirectory: projectRoot, + assets: _assets, + ); + if (!result.ok) { + throw CommandException( + code: 'TRANSITION_FAILED', + message: + 'Branch "$branch" is ready but the transition to ANALYZE failed:\n' + '${result.message}', + exitCode: ExitCode.genericError, + ); + } + + return ImplementationStartOutput( + issue: input.issue, + branch: branch, + cleanroom: p.posix.join('cleanrooms', branch), + branchCreated: branchCreated, + initialized: initialized, + ); + } + + /// Ensures `.inquiry/config.yaml` exists; returns whether it had to create it. + bool _ensureWorkspace(String projectRoot) { + final config = File(p.join(projectRoot, '.inquiry', 'config.yaml')); + if (config.existsSync()) return false; + // InitCommand is idempotent and repo-scoped; reuse it rather than + // reimplementing the workspace layout. + InitCommand(InitInput(workingDirectory: projectRoot)).execute(); + return true; + } + + /// Checks out [branch], creating it when it does not yet exist. Returns + /// whether the branch was newly created. + bool _checkoutBranch(String projectRoot, String branch) { + final current = getCurrentBranch(projectRoot); + if (current == branch) return false; + + final exists = _git(projectRoot, [ + 'rev-parse', + '--verify', + '--quiet', + 'refs/heads/$branch', + ]).exitCode == 0; + + final checkout = exists + ? _git(projectRoot, ['checkout', branch]) + : _git(projectRoot, ['checkout', '-b', branch]); + + if (checkout.exitCode != 0) { + throw CommandException( + code: 'BRANCH_CHECKOUT_FAILED', + message: + 'Could not switch to branch "$branch":\n' + '${_gitError(checkout)}\n' + 'Commit or stash your changes, then retry `iq implementation start --issue ${input.issue}`.', + exitCode: ExitCode.genericError, + ); + } + return !exists; + } + + static String _gitError(ProcessResult r) { + final err = r.stderr.toString().trim(); + if (err.isNotEmpty) return err; + final out = r.stdout.toString().trim(); + return out.isNotEmpty ? out : 'git exited with code ${r.exitCode}'; + } + + static ProcessResult _defaultGit( + String workingDirectory, + List<String> arguments, + ) => Process.runSync('git', arguments, workingDirectory: workingDirectory); + + static ({String title, String state})? _defaultIssueInfo( + String issue, + String workingDirectory, + ) { + try { + final result = Process.runSync('gh', [ + 'issue', + 'view', + issue, + '--json', + 'title,state', + ], workingDirectory: workingDirectory); + if (result.exitCode != 0) return null; + final decoded = + jsonDecode(result.stdout.toString()) as Map<String, dynamic>; + final title = (decoded['title'] as String?)?.trim() ?? ''; + if (title.isEmpty) return null; + return (title: title, state: (decoded['state'] as String?) ?? ''); + } catch (_) { + return null; + } + } + + static Future<({bool ok, String message})> _defaultStartAnalyze({ + required String issue, + required String workingDirectory, + Assets? assets, + }) async { + final command = StateTransitionCommand( + StateTransitionInput( + currentState: null, + event: 'start_analyze', + issue: issue, + workingDirectory: workingDirectory, + ), + assets: assets, + ); + final output = await command.execute(); + return (ok: output.allowed, message: output.message); + } +} diff --git a/code/cli/lib/modules/implementation/implementation_builder.dart b/code/cli/lib/modules/implementation/implementation_builder.dart new file mode 100644 index 00000000..58d8fa79 --- /dev/null +++ b/code/cli/lib/modules/implementation/implementation_builder.dart @@ -0,0 +1,21 @@ +import 'package:modular_cli_sdk/modular_cli_sdk.dart'; + +import '../../assets.dart'; +import 'commands/start.dart'; + +/// Registers the `implementation` module — the build phase of a cycle +/// (ANALYZE → PLAN → EXECUTE). `iq implementation start --issue <N>` opens it: +/// it creates the issue-linked branch, scaffolds the cleanroom, and enters +/// ANALYZE — the whole mechanical bootstrap in one explicit command. +void buildImplementationModule(ModuleBuilder m, {Assets? assets}) { + m.command<ImplementationStartInput, ImplementationStartOutput>( + 'start', + (req) => ImplementationStartCommand( + ImplementationStartInput.fromCliRequest(req), + assets: assets, + ), + description: + 'Start implementing an issue: create the linked branch, scaffold the cleanroom, and enter ANALYZE', + params: ImplementationStartInput.params, + ); +} diff --git a/code/cli/lib/src/version.dart b/code/cli/lib/src/version.dart index 76573a4a..2f38cc04 100644 --- a/code/cli/lib/src/version.dart +++ b/code/cli/lib/src/version.dart @@ -2,4 +2,4 @@ /// /// Update this constant when bumping version in pubspec.yaml. /// Both are kept in sync manually to avoid build-time complexity. -const String inquiryVersion = '0.20.0'; +const String inquiryVersion = '0.21.0'; diff --git a/code/cli/pubspec.yaml b/code/cli/pubspec.yaml index cbf66970..429b4272 100644 --- a/code/cli/pubspec.yaml +++ b/code/cli/pubspec.yaml @@ -1,7 +1,7 @@ name: inquiry_cli description: > CLI for Inquiry — structured development through the APE methodology. -version: 0.20.0 +version: 0.21.0 environment: sdk: ^3.8.1 diff --git a/code/cli/test/branch_name_test.dart b/code/cli/test/branch_name_test.dart new file mode 100644 index 00000000..ec66f4a3 --- /dev/null +++ b/code/cli/test/branch_name_test.dart @@ -0,0 +1,65 @@ +import 'package:test/test.dart'; + +import 'package:inquiry_cli/modules/implementation/branch_name.dart'; + +void main() { + group('deriveSlug', () { + test('lowercases and hyphenates a simple title', () { + expect(deriveSlug('Fix login timeout'), 'fix-login-timeout'); + }); + + test('collapses punctuation and specials into single hyphens', () { + expect(deriveSlug('Add dark mode support!!!'), 'add-dark-mode-support'); + expect(deriveSlug('URGENT: Database migration script'), + 'urgent-database-migration-script'); + }); + + test('folds Spanish accents to ASCII instead of dropping them', () { + expect(deriveSlug('incluir código grupal'), 'incluir-codigo-grupal'); + expect(deriveSlug('renovación del comité'), 'renovacion-del-comite'); + }); + + test('strips a leading bracketed tag into a clean segment', () { + expect(deriveSlug('[api] Recordatorio preventivo'), + 'api-recordatorio-preventivo'); + }); + + test('caps at 50 chars without leaving a dangling hyphen', () { + final slug = deriveSlug('a ' * 40); // many single-letter words + expect(slug.length, lessThanOrEqualTo(50)); + expect(slug.endsWith('-'), isFalse); + }); + + test('returns empty when the title has no alphanumerics', () { + expect(deriveSlug('!!! ---'), ''); + }); + }); + + group('padIssueNumber', () { + test('pads to three digits', () { + expect(padIssueNumber('40'), '040'); + expect(padIssueNumber('7'), '007'); + }); + + test('leaves 3+ digit numbers unchanged', () { + expect(padIssueNumber('295'), '295'); + expect(padIssueNumber('1101'), '1101'); + }); + }); + + group('branchNameFor', () { + test('composes <NNN>-<slug>', () { + expect( + branchNameFor(issue: '40', title: 'Fix login timeout'), + '040-fix-login-timeout', + ); + }); + + test('throws when the title yields no slug', () { + expect( + () => branchNameFor(issue: '40', title: '###'), + throwsArgumentError, + ); + }); + }); +} diff --git a/code/cli/test/implementation_start_test.dart b/code/cli/test/implementation_start_test.dart new file mode 100644 index 00000000..e2c19e21 --- /dev/null +++ b/code/cli/test/implementation_start_test.dart @@ -0,0 +1,133 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import 'package:inquiry_cli/assets.dart'; +import 'package:inquiry_cli/modules/implementation/commands/start.dart'; + +void main() { + group('ImplementationStartCommand', () { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('inquiry_impl_start_'); + _copyAssets(tempDir.path); + _initGitRepo(tempDir.path); + }); + + tearDown(() { + if (tempDir.existsSync()) tempDir.deleteSync(recursive: true); + }); + + ImplementationStartCommand command(String issue) => ImplementationStartCommand( + ImplementationStartInput(issue: issue, workingDirectory: tempDir.path), + assets: Assets(root: tempDir.path), + issueInfoProvider: (_, _) => (title: 'Fix login timeout', state: 'OPEN'), + ); + + test('creates the linked branch, scaffolds the cleanroom, enters ANALYZE', + () async { + final out = await command('40').execute(); + + expect(out.branch, '040-fix-login-timeout'); + expect(out.branchCreated, isTrue); + + // Branch really checked out. + final branch = Process.runSync( + 'git', + ['rev-parse', '--abbrev-ref', 'HEAD'], + workingDirectory: tempDir.path, + ).stdout.toString().trim(); + expect(branch, '040-fix-login-timeout'); + + // Cleanroom scaffolded by the transition's open_analysis_context effect. + final analyzeDir = + p.join(tempDir.path, 'cleanrooms', '040-fix-login-timeout', 'analyze'); + expect(File(p.join(analyzeDir, 'diagnosis.md')).existsSync(), isTrue); + expect(File(p.join(analyzeDir, 'index.md')).existsSync(), isTrue); + expect(File(p.join(analyzeDir, 'confirmations.md')).existsSync(), isTrue); + + // State advanced to ANALYZE. + final stateFile = File(p.join( + tempDir.path, 'cleanrooms', '040-fix-login-timeout', '.iq.state.yaml')); + expect(stateFile.existsSync(), isTrue); + expect(stateFile.readAsStringSync(), contains('ANALYZE')); + }); + + test('auto-initializes the workspace when .inquiry is missing', () async { + expect( + File(p.join(tempDir.path, '.inquiry', 'config.yaml')).existsSync(), + isFalse, + ); + + final out = await command('40').execute(); + + expect(out.initialized, isTrue); + expect( + File(p.join(tempDir.path, '.inquiry', 'config.yaml')).existsSync(), + isTrue, + ); + }); + + test('is idempotent: re-running lands on the same branch without error', + () async { + await command('40').execute(); + final second = await command('40').execute(); + + expect(second.branch, '040-fix-login-timeout'); + expect(second.branchCreated, isFalse); + }); + + test('fails with a teaching message when the issue cannot be read', + () async { + final cmd = ImplementationStartCommand( + ImplementationStartInput(issue: '999', workingDirectory: tempDir.path), + assets: Assets(root: tempDir.path), + issueInfoProvider: (_, _) => null, // simulate gh failure / missing issue + ); + + expect( + () => cmd.execute(), + throwsA(predicate((e) => + e.toString().contains('gh issue view') && + e.toString().contains('gh auth status'))), + ); + }); + + test('rejects a non-numeric issue at validation', () { + final cmd = ImplementationStartCommand( + ImplementationStartInput(issue: 'abc', workingDirectory: tempDir.path), + ); + final error = cmd.validate(); + expect(error, isNotNull); + expect(error, contains('iq implementation start --issue 40')); + }); + }); +} + +void _initGitRepo(String root) { + _git(root, ['init']); + _git(root, ['config', 'user.email', 'test@test.com']); + _git(root, ['config', 'user.name', 'Test']); + File(p.join(root, '.gitkeep')).writeAsStringSync(''); + _git(root, ['add', '.']); + _git(root, ['commit', '-m', 'init']); +} + +ProcessResult _git(String root, List<String> args) => + Process.runSync('git', args, workingDirectory: root); + +/// Copies the assets the transition needs (contract + inspection template) into +/// the temp repo so `Assets(root: tempDir)` resolves them. +void _copyAssets(String root) { + for (final rel in const [ + 'fsm/transition_contract.yaml', + 'inspection/pre_pr_inspection_template.md', + ]) { + final source = File(p.join(Directory.current.path, 'assets', rel)); + final destination = File(p.join(root, 'assets', rel)); + destination.createSync(recursive: true); + destination.writeAsStringSync(source.readAsStringSync()); + } +} diff --git a/code/cli/test/instruction_prompt_loader_test.dart b/code/cli/test/instruction_prompt_loader_test.dart index 270d9029..f7562fe7 100644 --- a/code/cli/test/instruction_prompt_loader_test.dart +++ b/code/cli/test/instruction_prompt_loader_test.dart @@ -125,9 +125,7 @@ Ignored. equals( 'Run iq doctor first and stop on any failed check.\n' 'Verify the issue already exists with gh issue view.\n' - 'Create branch NNN-slug and cleanrooms/NNN-slug/analyze.\n' - 'Create analyze/index.md for the cleanroom.\n' - 'Transition with iq fsm transition --event start_analyze --issue NNN.\n' + 'Open the implementation with iq implementation start --issue NNN, which derives the NNN-slug branch, checks it out, scaffolds the cleanroom, and transitions to ANALYZE.\n' 'Confirm iq fsm state reports ANALYZE for that issue.', ), ); diff --git a/code/site/index.html b/code/site/index.html index 9677d589..badc6eea 100644 --- a/code/site/index.html +++ b/code/site/index.html @@ -31,7 +31,7 @@ <h1><span class="ape">Inquiry</span></h1> <p class="primary-tagline">Analyze. Plan. Execute.</p> <p class="secondary-tagline">Available for OpenCode and GitHub Copilot. More hosts coming soon.</p> - <span class="badge">v0.20.0</span> + <span class="badge">v0.21.0</span> <p class="secondary-tagline">Public entry surface. For the canonical documentation map, start in the repository docs.</p> <div class="hero-install" id="install"> diff --git a/docs/roadmap.md b/docs/roadmap.md index 9888e93c..bf929ba9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -207,6 +207,35 @@ The original [lore.md](lore.md) sketched 9+ apes. After two months of building A The lesson: **the framework wants fewer, sharper agents, not more**. Each absorption was driven by a real cycle where two agents were doing what one could do better. +## Design direction: a module per stage (`iq <stage> <verb>`) + +Adopted 2026-07. The CLI is being reorganized so its command surface mirrors the +cycle's stages instead of its internals. Today commands are grouped by mechanism +(`iq fsm transition`, `iq ape prompt`) — names that leak how the engine works and +force the user to translate "I want to start analyzing" into "run an FSM +transition". The direction is one **module per stage**, each with a small set of +common verbs: + +- `iq requisition`, `iq specification`, `iq implementation`, `iq analyze`, + `iq plan`, `iq execute`, `iq verification` — one module each. +- Shared verbs where they make sense: `start` (enter the stage), + `skill` (show the stage's operating instruction), `check` (run the stage gate). + e.g. `iq analyze start`, `iq analyze skill`, `iq plan check`. +- Every argument explicit and named: `--issue 40`, not a positional. A required + input is declared `required`, so the CLI refuses to run without it. + +**`iq implementation start --issue <N>` is the first module built to this +pattern** (issue #295): it replaces the cryptic `iq cycle start 40` and folds the +whole manual bootstrap (slug + branch + cleanroom + transition) into one explicit +command. It is the template the remaining stages migrate toward — incrementally, +each stage keeping its old command working until its module lands. + +Two principles this direction locks in (see the `mejora-ux` requisition): +- **Every mechanical process is an `iq` command**, never an instruction that has + the model run `git`/`mkdir`/write scaffold by hand. +- **Commands are not flow-aware**: they do their job and report it; they do not + print a "next step" that would bias what the user does next. + ## How this roadmap is updated - Keep this file forward-looking.