diff --git a/.gitignore b/.gitignore index fbdf4366..8817b902 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ code/cli/tmp/ .claude/skills/speckit-*/ .github/agents/speckit.* .github/prompts/speckit.* +# Inquiry — local workspace & cycle state +docs/requisitions/ diff --git a/code/cli/CHANGELOG.md b/code/cli/CHANGELOG.md index eb06df5f..281a0e82 100644 --- a/code/cli/CHANGELOG.md +++ b/code/cli/CHANGELOG.md @@ -4,6 +4,24 @@ 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.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. + - **`iq --help`** renders that command's full contract — parameters with type, required/optional, default and allowed values — positionals included, *without* having to supply them. **`iq --help`** renders every command under it. + - **`iq help --json`** emits the whole catalog as JSON: the machine twin of the text help, and the CLI analogue of the OpenAPI document `modular_api` generates from its registered use cases. + - **An option a command never declared is refused, not ignored.** `iq init --host claude` used to run and exit 0 — letting you believe a flag had done something. It now exits 7 and hands back the contract it violated. The 8 commands that take no options declare an **empty** contract, which is a statement, not an absence. + - Descriptions no longer restate their own flags (`--lang (default: en)`): the contract renders that, and a description repeating it is one more thing to drift. + +### Fixed +- **`iq doctor` could hang forever.** `checkLatestVersion` bounded only the TCP handshake (`HttpClient.connectionTimeout`); neither the response headers nor the body read had a deadline, so a connection that was *accepted but never answered* waited indefinitely — observed as a 1m40s hang, killed by hand. Nothing threw, so the silent-on-failure `catch` never fired. The whole exchange is now bounded. +- **`iq doctor` was blind to the skills that matter.** It discovered its expectations from the asset tree alone, so it reported `agent + 3 skills deployed` on a host carrying 7 — the four **generated** `iq-*` phase skills (`iq-analyze`, `iq-plan`, `iq-execute`, `iq-specification`) went unverified, and a host missing `iq-plan` read as healthy. +- **The help had drifted, and could not stop drifting.** It was a hand-written string: the `specification` and `issue` modules shipped and were never listed in it, for two releases. It is now rendered from the command catalog every registration feeds, so a registered command *cannot* be absent from it. +- **The `specification_ready` gate passed specs with an entire user story unimplemented.** Acceptance-criterion ids were bare `AC-N`, but numbering restarts in every story — so US-1/AC-1 and US-2/AC-1 collapsed onto one id, and an issue covering `AC-1` silently traced both. Reproduced end-to-end: a login issue green-lit a spec whose CSV-export story had no issue at all. **AC ids are now qualified by their story (`US2-AC3`)**, read from the heading (`US-2:` / `HU-2:`) so the id is the same token in both languages. The spec tables are unchanged — the cell still holds the bare number, which is what keeps the PDF column narrow. An issue's `covers:` must now list qualified ids. + +### Changed +- **The whole `cleanrooms/` working area is git-ignored** (only `cleanrooms/**/.iq.state.yaml` was, so `diagnosis.md` and `plan.md` accumulated in the repo). A cleanroom is a per-cycle working area: the durable artifacts of a cycle are the published issue, the code and the tests — the same reasoning that already makes `docs/requisitions/` local. +- Requires **`modular_cli_sdk` 0.3.3** and **`cli_router` 0.1.0**. Integrating the SDK surfaced three defects in it, each fixed and released upstream: the bare invocation hijacked a registered root route (`iq` printed the help instead of the banner); an *empty* parameter contract was inexpressible, so zero-option commands could not be enforced; and a command with positionals could not answer `--help` without being handed the very argument the user was asking about. + ## [0.19.0] ### Changed - **Requisitions moved to a git-ignored, chronologically-ordered local workspace, and the downstream commands became slug-less** (found by dogfooding — the repo-root `requisitions/` accumulated *alphabetically, not by age*, and committing each requisition forced a redundant "requisition-only" PR when the durable artifacts are really the published GitHub issues). diff --git a/code/cli/assets/artifacts/issue.template.en.md b/code/cli/assets/artifacts/issue.template.en.md index 2f962353..4f8f7fd9 100644 --- a/code/cli/assets/artifacts/issue.template.en.md +++ b/code/cli/assets/artifacts/issue.template.en.md @@ -12,7 +12,9 @@ covers: [] `iq issue publish --plan` shows the `gh issue create` it would assemble from the front-matter; `--apply` creates it. The body (everything after the second `---`) is what gets published; the front-matter is NOT. - `covers:` must list the AC this issue covers — the gate enforces it. --> + `covers:` must list the AC this issue covers, each qualified by its user + story — `covers: [US1-AC1, US1-AC2, US2-AC3]`. The gate enforces it; a bare + `AC-1` is ambiguous (every story restarts at 1) and traces nothing. --> # [repo] Short issue title diff --git a/code/cli/assets/artifacts/issue.template.es.md b/code/cli/assets/artifacts/issue.template.es.md index 8a2efdf8..78e23737 100644 --- a/code/cli/assets/artifacts/issue.template.es.md +++ b/code/cli/assets/artifacts/issue.template.es.md @@ -12,7 +12,9 @@ covers: [] `iq issue publish --plan` muestra el `gh issue create` que armaría con el front-matter; `--apply` lo crea. El cuerpo (todo lo que sigue al segundo `---`) es lo que se publica; el front-matter NO se publica. - `covers:` debe listar los AC que cubre esta issue — el gate lo exige. --> + `covers:` debe listar los AC que cubre esta issue, cada uno calificado por + su historia — `covers: [US1-AC1, US1-AC2, US2-AC3]`. El gate lo exige; un + `AC-1` pelado es ambiguo (cada historia reinicia en 1) y no traza nada. --> # [repo] Título corto de la issue diff --git a/code/cli/assets/artifacts/specification.template.en.md b/code/cli/assets/artifacts/specification.template.en.md index d718b22f..08866a34 100644 --- a/code/cli/assets/artifacts/specification.template.en.md +++ b/code/cli/assets/artifacts/specification.template.en.md @@ -43,10 +43,11 @@ _This specification is the **business agreement** — like a project charter — #### Acceptance Criteria - + | AC | Given (context) | When (action) | Then (expected result) | | --- | ----------------------- | -------------------- | ---------------------- | diff --git a/code/cli/assets/artifacts/specification.template.es.md b/code/cli/assets/artifacts/specification.template.es.md index c241fe63..b8a61247 100644 --- a/code/cli/assets/artifacts/specification.template.es.md +++ b/code/cli/assets/artifacts/specification.template.es.md @@ -43,9 +43,11 @@ _Esta especificación es el **acuerdo de negocio** —a la manera de un project #### Acceptance Criteria - | AC | Given (Dado que) | When (Cuando) | Then (Entonces) | diff --git a/code/cli/lib/hosts/skill_builder.dart b/code/cli/lib/hosts/skill_builder.dart index 987333fe..de0c19b1 100644 --- a/code/cli/lib/hosts/skill_builder.dart +++ b/code/cli/lib/hosts/skill_builder.dart @@ -166,7 +166,7 @@ Turn a raw requisition (email, document, chat) into a coherent, actionable speci 3. Gather the raw requisition from ALL its sources into `requisition.md` (AS-IS / TO-BE). Capture exactly what was asked — do not invent scope. 4. For every decision you are unsure of, run a **throwaway experiment** to decide by EVIDENCE, not inference: read the DB, run code in a container, probe the API. These validate decisions; they are NOT product code. Record each **technical** decision with a re-checkable handle in the **issues** (step 6) — the specification stays business-level. 5. Fill `specification.md` — a lean **business charter** in the domain language (DDD), not implementation. Set a committed delivery date (§1, ISO `YYYY-MM-DD` — the gate requires it); write each user story with ≥1 Given-When-Then acceptance criterion (§2); state the explicit scope (§3, includes / does NOT include); capture the domain glossary and business rules (§4). -6. Derive the issues with **`iq issue new [--repo owner/repo]`** — it scaffolds `issue-.md` in the active requisition ("issue as code") **inheriting the spec's `iq:lang`** (so a Spanish spec yields Spanish issues). Fill each from evidence and list the acceptance criteria it covers in the front-matter `covers:`. One issue per unit of work (e.g. one per repo). (`--slug ` targets a different requisition.) +6. Derive the issues with **`iq issue new [--repo owner/repo]`** — it scaffolds `issue-.md` in the active requisition ("issue as code") **inheriting the spec's `iq:lang`** (so a Spanish spec yields Spanish issues). Fill each from evidence and list the acceptance criteria it covers in the front-matter `covers:`, **each qualified by its user story** — `covers: [US1-AC1, US1-AC2, US2-AC3]` (numbering restarts in every story, so a bare `AC-1` is ambiguous and traces nothing). One issue per unit of work — split only what can ship independently; a feature whose parts make no sense apart is ONE issue. (`--slug ` targets a different requisition.) 7. `iq specification check` — the CLI runs the `specification_ready` gate over the active requisition. Fix exactly what it reports (do NOT skip a violation) until it exits 0. 8. Dedup-check before creating: `gh issue list --search ""`. If a too-similar issue exists, edit that one instead. 9. Publish with **`iq issue publish --plan`** (Terraform-style: previews the `gh issue create` from the front-matter) and, once the human approves, `--apply` to create it. The **published GitHub issues are the durable artifacts**; the requisition stays a local (git-ignored) workspace. Present the specification + issues to the human for review. diff --git a/code/cli/lib/inquiry_cli.dart b/code/cli/lib/inquiry_cli.dart index 18f86d0b..0d3ede5f 100644 --- a/code/cli/lib/inquiry_cli.dart +++ b/code/cli/lib/inquiry_cli.dart @@ -18,10 +18,9 @@ import 'modules/specification/specification_builder.dart'; import 'hosts/all_adapters.dart'; import 'hosts/deployer.dart'; +/// `--help` / `-h` are NOT normalized here: the SDK routes every help request +/// itself, including the focused `iq --help`, which this could not. List normalizeInquiryArgs(List args) { - if (args.length == 1 && (args.first == '--help' || args.first == '-h')) { - return const ['help']; - } if (args.length == 1 && (args.first == '--version' || args.first == '-v')) { return const ['version']; } @@ -30,8 +29,13 @@ List normalizeInquiryArgs(List args) { /// Configures the CLI, registers all commands, and dispatches [args]. /// -/// Returns a process exit code. -Future runInquiry(List args) async { +/// Returns a process exit code. [stdout] / [stderr] default to the process +/// streams; tests pass their own sinks to assert on what the user actually sees. +Future runInquiry( + List args, { + IOSink? stdout, + IOSink? stderr, +}) async { final cli = ModularCli(); final assetsRoot = p.dirname(p.dirname(Platform.resolvedExecutable)); @@ -66,5 +70,5 @@ Future runInquiry(List args) async { ); cli.module('issue', (m) => buildIssueModule(m, assets: assets)); - return cli.run(normalizeInquiryArgs(args)); + return cli.run(normalizeInquiryArgs(args), stdout: stdout, stderr: stderr); } diff --git a/code/cli/lib/modules/ape/ape_builder.dart b/code/cli/lib/modules/ape/ape_builder.dart index 3a2eeeb4..36ffc051 100644 --- a/code/cli/lib/modules/ape/ape_builder.dart +++ b/code/cli/lib/modules/ape/ape_builder.dart @@ -10,17 +10,20 @@ void buildApeModule(ModuleBuilder m, {Assets? assets}) { 'prompt', (req) => ApePromptCommand(ApePromptInput.fromCliRequest(req), assets: assets), description: 'Assemble a sub-agent prompt from YAML + current FSM state', + params: ApePromptInput.params, ); m.command( 'state', (req) => ApeStateCommand(ApeStateInput.fromCliRequest(req), assets: assets), description: 'Show current APE sub-state and valid internal transitions', + params: ApeStateInput.params, ); m.command( 'transition', (req) => ApeTransitionCommand(ApeTransitionInput.fromCliRequest(req), assets: assets), - description: 'Execute APE internal transition by --event', + description: 'Execute an APE-internal transition', + params: ApeTransitionInput.params, ); } diff --git a/code/cli/lib/modules/ape/commands/prompt.dart b/code/cli/lib/modules/ape/commands/prompt.dart index c19c0106..f4f2784f 100644 --- a/code/cli/lib/modules/ape/commands/prompt.dart +++ b/code/cli/lib/modules/ape/commands/prompt.dart @@ -34,6 +34,19 @@ class ApePromptInput extends Input { required this.workingDirectory, }); + static final List params = [ + CliParam.string( + 'name', + abbr: 'n', + description: 'APE whose prompt to assemble (socrates, dewey, descartes, ada, darwin)', + ), + CliParam.string( + 'state', + abbr: 's', + description: 'APE sub-state to assemble the prompt for', + ), + ]; + factory ApePromptInput.fromCliRequest(CliRequest req) { return ApePromptInput( name: req.flagString('name', aliases: const ['n']), @@ -42,6 +55,9 @@ class ApePromptInput extends Input { ); } + @override + List get schemaFields => params; + @override Map toJson() => { if (name != null) 'name': name, diff --git a/code/cli/lib/modules/ape/commands/state.dart b/code/cli/lib/modules/ape/commands/state.dart index 7240125f..09fef902 100644 --- a/code/cli/lib/modules/ape/commands/state.dart +++ b/code/cli/lib/modules/ape/commands/state.dart @@ -23,6 +23,15 @@ class ApeStateInput extends Input { return ApeStateInput(workingDirectory: Directory.current.path); } + /// Declares an EMPTY contract: this command accepts no option at all, so any + /// option passed to it is refused. Omitting `params` would mean "declares + /// nothing" — which is how `iq init --host claude` used to run, doing nothing + /// the flag implied. + static const List params = []; + + @override + List get schemaFields => params; + @override Map toJson() => {'workingDirectory': workingDirectory}; } diff --git a/code/cli/lib/modules/ape/commands/transition.dart b/code/cli/lib/modules/ape/commands/transition.dart index b97612b4..a9ebb784 100644 --- a/code/cli/lib/modules/ape/commands/transition.dart +++ b/code/cli/lib/modules/ape/commands/transition.dart @@ -19,6 +19,15 @@ class ApeTransitionInput extends Input { ApeTransitionInput({required this.event, required this.workingDirectory}); + static final List params = [ + CliParam.string( + 'event', + abbr: 'e', + required: true, + description: 'The APE-internal transition to execute', + ), + ]; + factory ApeTransitionInput.fromCliRequest(CliRequest req) { return ApeTransitionInput( event: req.flagString('event', aliases: const ['e']), @@ -26,6 +35,9 @@ class ApeTransitionInput extends Input { ); } + @override + List get schemaFields => params; + @override Map toJson() => { if (event != null) 'event': event, diff --git a/code/cli/lib/modules/fsm/commands/state.dart b/code/cli/lib/modules/fsm/commands/state.dart index 54831324..e2c40558 100644 --- a/code/cli/lib/modules/fsm/commands/state.dart +++ b/code/cli/lib/modules/fsm/commands/state.dart @@ -24,6 +24,15 @@ class FsmStateInput extends Input { return FsmStateInput(workingDirectory: Directory.current.path); } + /// Declares an EMPTY contract: this command accepts no option at all, so any + /// option passed to it is refused. Omitting `params` would mean "declares + /// nothing" — which is how `iq init --host claude` used to run, doing nothing + /// the flag implied. + static const List params = []; + + @override + List get schemaFields => params; + @override Map toJson() => {'workingDirectory': workingDirectory}; } diff --git a/code/cli/lib/modules/fsm/commands/transition.dart b/code/cli/lib/modules/fsm/commands/transition.dart index b17e207e..be79e7d3 100644 --- a/code/cli/lib/modules/fsm/commands/transition.dart +++ b/code/cli/lib/modules/fsm/commands/transition.dart @@ -137,6 +137,27 @@ class StateTransitionInput extends Input { required this.workingDirectory, }); + static final List params = [ + CliParam.string( + 'event', + abbr: 'e', + required: true, + allowed: [for (final e in FsmEvent.values) e.value], + description: 'The transition to execute', + ), + CliParam.string( + 'state', + abbr: 's', + allowed: [for (final s in FsmState.values) s.value], + description: 'State to transition from; read from the cycle when omitted', + ), + CliParam.string( + 'issue', + abbr: 'i', + description: 'Issue the cycle is opened for (start_analyze)', + ), + ]; + factory StateTransitionInput.fromCliRequest(CliRequest req) { return StateTransitionInput( currentState: req.flagString('state', aliases: const ['s']), @@ -146,6 +167,9 @@ class StateTransitionInput extends Input { ); } + @override + List get schemaFields => params; + @override Map toJson() => { 'currentState': currentState, diff --git a/code/cli/lib/modules/fsm/fsm_builder.dart b/code/cli/lib/modules/fsm/fsm_builder.dart index 068bbbdb..88297c37 100644 --- a/code/cli/lib/modules/fsm/fsm_builder.dart +++ b/code/cli/lib/modules/fsm/fsm_builder.dart @@ -9,12 +9,14 @@ void buildFsmModule(ModuleBuilder m, {Assets? assets}) { 'state', (req) => FsmStateCommand(FsmStateInput.fromCliRequest(req), assets: assets), description: 'Show current FSM state, valid transitions, and active APEs', + params: FsmStateInput.params, ); m.command( 'transition', (req) => StateTransitionCommand(StateTransitionInput.fromCliRequest(req), assets: assets), description: - 'Run deterministic FSM transition by --event (optional --state)', + 'Run a deterministic FSM transition', + params: StateTransitionInput.params, ); } diff --git a/code/cli/lib/modules/global/commands/doctor.dart b/code/cli/lib/modules/global/commands/doctor.dart index a388f54a..b7f15ba7 100644 --- a/code/cli/lib/modules/global/commands/doctor.dart +++ b/code/cli/lib/modules/global/commands/doctor.dart @@ -15,6 +15,7 @@ import '../../../src/version_check.dart'; import '../../../hosts/all_adapters.dart'; import '../../../hosts/host_adapter.dart'; import '../../../hosts/ollama_context.dart'; +import '../../../hosts/skill_builder.dart'; /// Function type for running external processes. /// @@ -95,9 +96,18 @@ class DoctorInput extends Input { DoctorInput({this.fix = false}); - factory DoctorInput.fromCliRequest(CliRequest req) => DoctorInput( - fix: req.flagBool('fix'), - ); + static final List params = [ + CliParam.boolean( + 'fix', + description: 'Re-download the internal assets when they are missing', + ), + ]; + + factory DoctorInput.fromCliRequest(CliRequest req) => + DoctorInput(fix: req.flagBool('fix')); + + @override + List get schemaFields => params; @override Map toJson() => {'fix': fix}; @@ -419,13 +429,25 @@ class DoctorCommand implements Command { ); } - /// Discovers expected skills from the asset tree. + /// The skills a deployed host is expected to carry — exactly what the + /// deployer installs: the asset-tree skills **plus** the ones it generates + /// from the FSM/APE contracts (`SkillBuilder.phaseSkillNames`, see + /// deployer.dart `_deploySkills`). Discovering only the asset tree left the + /// `iq-*` phase skills unverified, so a host missing `iq-plan` reported clean. List _getExpectedSkills() { - if (_assets == null) return []; + final assets = _assets; + if (assets == null) return []; + // The generated skills are a FIXED contract, not a function of what happens + // to build today: the deployer skips a phase whose contract assets are + // missing, and mirroring that would make doctor quietly expect less and + // call a host with no `iq-plan` healthy — the very blindness this check + // exists to remove. A phase skill that cannot be built is a real defect, + // reported here as missing (and by the asset-integrity check as its cause). + final generated = SkillBuilder(assets).phaseSkillNames; try { - return _assets.listDirectory('skills'); + return [...assets.listDirectory('skills'), ...generated]; } catch (_) { - return []; + return generated; } } diff --git a/code/cli/lib/modules/global/commands/help.dart b/code/cli/lib/modules/global/commands/help.dart deleted file mode 100644 index 3826ec91..00000000 --- a/code/cli/lib/modules/global/commands/help.dart +++ /dev/null @@ -1,69 +0,0 @@ -/// `inquiry help` — prints the global CLI help summary. -library; - -import 'package:cli_router/cli_router.dart'; -import 'package:modular_cli_sdk/modular_cli_sdk.dart'; - -const String inquiryHelpText = - 'Usage:\n' - ' inquiry Display Inquiry status and FSM diagram\n' - ' inquiry help Show this help\n' - ' inquiry --help Show this help\n' - ' inquiry -h Show this help\n' - ' inquiry ...\n' - '\n' - 'Root commands:\n' - ' help Show available commands\n' - ' init Initialize a new .inquiry/ workspace\n' - ' version Print the current CLI version\n' - ' doctor Verify prerequisites (inquiry, git, gh, gh auth, gh copilot)\n' - ' upgrade Download and install the latest Inquiry release\n' - ' uninstall Remove Inquiry CLI from the system\n' - '\n' - 'Modules:\n' - ' host get Deploy Inquiry skills to the specified host (default: copilot)\n' - ' host clean Remove deployed Inquiry files from all hosts\n' - ' fsm state Show current FSM state, valid transitions, and active APEs\n' - ' fsm transition Run deterministic FSM transition by --event (optional --state)\n' - ' ape prompt Assemble a sub-agent prompt from YAML + current FSM state\n' - ' ape state Show current APE sub-state and valid internal transitions\n' - ' ape transition Execute APE internal transition by --event\n'; - -class HelpInput extends Input { - HelpInput(); - - factory HelpInput.fromCliRequest(CliRequest req) => HelpInput(); - - @override - Map toJson() => {}; -} - -class HelpOutput extends Output { - final String text; - - HelpOutput({required this.text}); - - @override - Map toJson() => {'help': text}; - - @override - int get exitCode => ExitCode.ok; - - @override - String? toText() => text; -} - -class HelpCommand implements Command { - @override - final HelpInput input; - - HelpCommand(this.input); - - @override - String? validate() => null; - - @override - Future execute() async { - return HelpOutput(text: inquiryHelpText); - } -} \ No newline at end of file diff --git a/code/cli/lib/modules/global/commands/init.dart b/code/cli/lib/modules/global/commands/init.dart index 335a42d5..de6e5076 100644 --- a/code/cli/lib/modules/global/commands/init.dart +++ b/code/cli/lib/modules/global/commands/init.dart @@ -31,6 +31,15 @@ class InitInput extends Input { factory InitInput.fromCliRequest(CliRequest req) => InitInput(workingDirectory: Directory.current.path); + /// Declares an EMPTY contract: this command accepts no option at all, so any + /// option passed to it is refused. Omitting `params` would mean "declares + /// nothing" — which is how `iq init --host claude` used to run, doing nothing + /// the flag implied. + static const List params = []; + + @override + List get schemaFields => params; + @override Map toJson() => {'workingDirectory': workingDirectory}; } diff --git a/code/cli/lib/modules/global/commands/tui.dart b/code/cli/lib/modules/global/commands/tui.dart index 8d83ca8d..67ca6657 100644 --- a/code/cli/lib/modules/global/commands/tui.dart +++ b/code/cli/lib/modules/global/commands/tui.dart @@ -17,6 +17,15 @@ class TuiInput extends Input { factory TuiInput.fromCliRequest(CliRequest req) => TuiInput(); + /// Declares an EMPTY contract: this command accepts no option at all, so any + /// option passed to it is refused. Omitting `params` would mean "declares + /// nothing" — which is how `iq init --host claude` used to run, doing nothing + /// the flag implied. + static const List params = []; + + @override + List get schemaFields => params; + @override Map toJson() => {}; } diff --git a/code/cli/lib/modules/global/commands/uninstall.dart b/code/cli/lib/modules/global/commands/uninstall.dart index 0a85b28c..1ceac45b 100644 --- a/code/cli/lib/modules/global/commands/uninstall.dart +++ b/code/cli/lib/modules/global/commands/uninstall.dart @@ -26,6 +26,15 @@ class UninstallInput extends Input { return UninstallInput(installDir: installDir); } + /// Declares an EMPTY contract: this command accepts no option at all, so any + /// option passed to it is refused. Omitting `params` would mean "declares + /// nothing" — which is how `iq init --host claude` used to run, doing nothing + /// the flag implied. + static const List params = []; + + @override + List get schemaFields => params; + @override Map toJson() => {'installDir': installDir}; } diff --git a/code/cli/lib/modules/global/commands/upgrade.dart b/code/cli/lib/modules/global/commands/upgrade.dart index b175d924..42dbd925 100644 --- a/code/cli/lib/modules/global/commands/upgrade.dart +++ b/code/cli/lib/modules/global/commands/upgrade.dart @@ -28,6 +28,15 @@ class UpgradeInput extends Input { return UpgradeInput(installDir: installDir); } + /// Declares an EMPTY contract: this command accepts no option at all, so any + /// option passed to it is refused. Omitting `params` would mean "declares + /// nothing" — which is how `iq init --host claude` used to run, doing nothing + /// the flag implied. + static const List params = []; + + @override + List get schemaFields => params; + @override Map toJson() => {'installDir': installDir}; } diff --git a/code/cli/lib/modules/global/commands/version.dart b/code/cli/lib/modules/global/commands/version.dart index cf00cfc9..342e529f 100644 --- a/code/cli/lib/modules/global/commands/version.dart +++ b/code/cli/lib/modules/global/commands/version.dart @@ -15,6 +15,15 @@ class VersionInput extends Input { factory VersionInput.fromCliRequest(CliRequest req) => VersionInput(); + /// Declares an EMPTY contract: this command accepts no option at all, so any + /// option passed to it is refused. Omitting `params` would mean "declares + /// nothing" — which is how `iq init --host claude` used to run, doing nothing + /// the flag implied. + static const List params = []; + + @override + List get schemaFields => params; + @override Map toJson() => {}; } diff --git a/code/cli/lib/modules/global/global_builder.dart b/code/cli/lib/modules/global/global_builder.dart index c805614c..c34ca85e 100644 --- a/code/cli/lib/modules/global/global_builder.dart +++ b/code/cli/lib/modules/global/global_builder.dart @@ -2,7 +2,6 @@ import 'package:modular_cli_sdk/modular_cli_sdk.dart'; import '../../assets.dart'; import 'commands/doctor.dart'; -import 'commands/help.dart'; import 'commands/init.dart'; import 'commands/tui.dart'; import 'commands/uninstall.dart'; @@ -10,6 +9,10 @@ import 'commands/upgrade.dart'; import 'commands/version.dart'; import '../../hosts/deployer.dart'; +/// `help` is not registered here: `modular_cli_sdk` renders it from the command +/// catalog these registrations feed, so a command cannot ship without appearing +/// in it. Inquiry's own hand-written help had already drifted — the +/// `specification` and `issue` modules were absent from it for two releases. void buildGlobalModule( ModuleBuilder m, { required HostDeployer cleaner, @@ -19,12 +22,7 @@ void buildGlobalModule( '', (req) => TuiCommand(TuiInput.fromCliRequest(req)), description: 'Display Inquiry status and FSM diagram', - ); - - m.command( - 'help', - (req) => HelpCommand(HelpInput.fromCliRequest(req)), - description: 'Show available commands', + params: TuiInput.params, ); m.command( @@ -32,24 +30,28 @@ void buildGlobalModule( (req) => InitCommand(InitInput.fromCliRequest(req)), description: 'Set up the Inquiry workspace in this repo (cleanrooms + .inquiry). Install a host first with `iq host get`.', + params: InitInput.params, ); m.command( 'version', (req) => VersionCommand(VersionInput.fromCliRequest(req)), description: 'Print the current CLI version', + params: VersionInput.params, ); m.command( 'doctor', (req) => DoctorCommand(DoctorInput.fromCliRequest(req), assets: assets), description: 'Verify prerequisites (inquiry, git, gh, gh auth, gh copilot)', + params: DoctorInput.params, ); m.command( 'upgrade', (req) => UpgradeCommand(UpgradeInput.fromCliRequest(req)), description: 'Download and install the latest Inquiry release', + params: UpgradeInput.params, ); m.command( @@ -59,5 +61,6 @@ void buildGlobalModule( deployer: cleaner, ), description: 'Remove Inquiry CLI from the system', + params: UninstallInput.params, ); } diff --git a/code/cli/lib/modules/host/commands/clean.dart b/code/cli/lib/modules/host/commands/clean.dart index 4fd839cf..f84c3270 100644 --- a/code/cli/lib/modules/host/commands/clean.dart +++ b/code/cli/lib/modules/host/commands/clean.dart @@ -17,6 +17,15 @@ class HostCleanInput extends Input { factory HostCleanInput.fromCliRequest(CliRequest req) => HostCleanInput(); + /// Declares an EMPTY contract: this command accepts no option at all, so any + /// option passed to it is refused. Omitting `params` would mean "declares + /// nothing" — which is how `iq init --host claude` used to run, doing nothing + /// the flag implied. + static const List params = []; + + @override + List get schemaFields => params; + @override Map toJson() => {}; } diff --git a/code/cli/lib/modules/host/commands/get.dart b/code/cli/lib/modules/host/commands/get.dart index 1e1929a5..70ed7a36 100644 --- a/code/cli/lib/modules/host/commands/get.dart +++ b/code/cli/lib/modules/host/commands/get.dart @@ -21,6 +21,18 @@ class HostGetInput extends Input { factory HostGetInput.fromCliRequest(CliRequest req) => HostGetInput(host: req.flagString('host') ?? 'opencode'); + static final List params = [ + CliParam.string( + 'host', + defaultValue: 'opencode', + allowed: ['opencode', 'claude'], + description: 'AI coding host to install the agent + skills into', + ), + ]; + + @override + List get schemaFields => params; + @override Map toJson() => {'host': host}; } diff --git a/code/cli/lib/modules/host/host_builder.dart b/code/cli/lib/modules/host/host_builder.dart index 898a4412..a69fd031 100644 --- a/code/cli/lib/modules/host/host_builder.dart +++ b/code/cli/lib/modules/host/host_builder.dart @@ -24,7 +24,8 @@ void buildHostModule( ), ), description: - 'Install Inquiry (agent + skills) globally for a host. --host (default: opencode)', + 'Install Inquiry (agent + skills) globally for a host', + params: HostGetInput.params, ); m.command( @@ -34,5 +35,6 @@ void buildHostModule( deployer: cleaner, ), description: 'Remove deployed Inquiry files from all hosts', + params: HostCleanInput.params, ); } diff --git a/code/cli/lib/modules/issue/commands/new.dart b/code/cli/lib/modules/issue/commands/new.dart index 9a78533b..c7ea0087 100644 --- a/code/cli/lib/modules/issue/commands/new.dart +++ b/code/cli/lib/modules/issue/commands/new.dart @@ -37,6 +37,20 @@ class IssueNewInput extends Input { IssueNewInput({this.slug, required this.name, this.repo, this.lang}); + static final List params = [ + CliParam.positional('name', description: 'Name of the issue → issue-.md'), + CliParam.string( + 'slug', + description: 'Requisition to scaffold into; defaults to the active one', + ), + CliParam.string('repo', description: 'Target GitHub repo, as owner/repo'), + CliParam.string( + 'lang', + allowed: ['en', 'es'], + description: "Language of the issue; inherits the specification's when omitted", + ), + ]; + factory IssueNewInput.fromCliRequest(CliRequest req) => IssueNewInput( slug: optionalSlug(req.flagString('slug')), name: (req.param('name') ?? '').trim(), @@ -44,6 +58,9 @@ class IssueNewInput extends Input { lang: req.flagString('lang'), ); + @override + List get schemaFields => params; + @override Map toJson() => {'slug': slug, 'name': name, 'repo': repo, 'lang': lang}; diff --git a/code/cli/lib/modules/issue/commands/publish.dart b/code/cli/lib/modules/issue/commands/publish.dart index 518168ad..6712b104 100644 --- a/code/cli/lib/modules/issue/commands/publish.dart +++ b/code/cli/lib/modules/issue/commands/publish.dart @@ -37,12 +37,27 @@ class IssuePublishInput extends Input { IssuePublishInput({this.slug, required this.name, this.apply = false}); + static final List params = [ + CliParam.positional('name', description: 'Name of the issue → issue-.md'), + CliParam.string( + 'slug', + description: 'Requisition to publish from; defaults to the active one', + ), + // `--plan` is the default and carries no value the command reads, but it is + // a documented way to ask for it — declared so it is accepted, not refused. + CliParam.boolean('plan', description: 'Preview the gh issue create (default)'), + CliParam.boolean('apply', description: 'Actually create the GitHub issue'), + ]; + factory IssuePublishInput.fromCliRequest(CliRequest req) => IssuePublishInput( slug: optionalSlug(req.flagString('slug')), name: (req.param('name') ?? '').trim(), apply: req.flagBool('apply'), ); + @override + List get schemaFields => params; + @override Map toJson() => {'slug': slug, 'name': name, 'apply': apply}; diff --git a/code/cli/lib/modules/issue/issue_builder.dart b/code/cli/lib/modules/issue/issue_builder.dart index 9ff4c82c..ae2ee6bb 100644 --- a/code/cli/lib/modules/issue/issue_builder.dart +++ b/code/cli/lib/modules/issue/issue_builder.dart @@ -24,8 +24,8 @@ void buildIssueModule(ModuleBuilder m, {required Assets assets}) { workingDirectory: Directory.current.path, ), description: - 'Scaffold issue-.md in the active requisition (or --slug ). ' - '--repo owner/repo pre-fills the target; language inherits from the spec.', + 'Scaffold issue-.md in the active requisition ("issue as code")', + params: IssueNewInput.params, ); m.command( @@ -35,7 +35,7 @@ void buildIssueModule(ModuleBuilder m, {required Assets assets}) { workingDirectory: Directory.current.path, ), description: - 'Create the GitHub issue from issue-.md via gh (active requisition ' - 'or --slug ). --plan (default) previews; --apply runs it.', + 'Create the GitHub issue from issue-.md via gh — previews by default', + params: IssuePublishInput.params, ); } diff --git a/code/cli/lib/modules/specification/commands/check.dart b/code/cli/lib/modules/specification/commands/check.dart index 785663af..c03e013b 100644 --- a/code/cli/lib/modules/specification/commands/check.dart +++ b/code/cli/lib/modules/specification/commands/check.dart @@ -27,9 +27,19 @@ class SpecificationCheckInput extends Input { SpecificationCheckInput({this.slug}); + static final List params = [ + CliParam.string( + 'slug', + description: 'Requisition to check; defaults to the active one', + ), + ]; + factory SpecificationCheckInput.fromCliRequest(CliRequest req) => SpecificationCheckInput(slug: optionalSlug(req.flagString('slug'))); + @override + List get schemaFields => params; + @override Map toJson() => {'slug': slug}; } diff --git a/code/cli/lib/modules/specification/commands/new.dart b/code/cli/lib/modules/specification/commands/new.dart index 0308de18..75c4c53c 100644 --- a/code/cli/lib/modules/specification/commands/new.dart +++ b/code/cli/lib/modules/specification/commands/new.dart @@ -32,12 +32,25 @@ class SpecificationNewInput extends Input { SpecificationNewInput({required this.slug, this.lang = 'en'}); + static final List params = [ + CliParam.positional('slug', description: 'Kebab-case name of the requisition'), + CliParam.string( + 'lang', + defaultValue: 'en', + allowed: ['en', 'es'], + description: 'Language of the specification and every artifact derived from it', + ), + ]; + factory SpecificationNewInput.fromCliRequest(CliRequest req) => SpecificationNewInput( slug: normalizeSlug(req.param('slug') ?? ''), lang: req.flagString('lang') ?? 'en', ); + @override + List get schemaFields => params; + @override Map toJson() => {'slug': slug, 'lang': lang}; } diff --git a/code/cli/lib/modules/specification/specification_builder.dart b/code/cli/lib/modules/specification/specification_builder.dart index f2ed47f2..4b297c76 100644 --- a/code/cli/lib/modules/specification/specification_builder.dart +++ b/code/cli/lib/modules/specification/specification_builder.dart @@ -10,8 +10,9 @@ import 'commands/new.dart'; /// Registers the `specification` module — the QA-facing specification phase. /// /// `iq specification new [--lang ]` scaffolds the QA workspace -/// `requisitions//` with `requisition.md` + `specification.md` from the -/// single-source templates (Constitution I: the CLI is the hands). +/// `docs/requisitions/-/` with `requisition.md` + +/// `specification.md` from the single-source templates (Constitution I: the CLI +/// is the hands), and records it as the active requisition. void buildSpecificationModule(ModuleBuilder m, {required Assets assets}) { final resolver = TemplateResolver(assets); @@ -23,8 +24,9 @@ void buildSpecificationModule(ModuleBuilder m, {required Assets assets}) { workingDirectory: Directory.current.path, ), description: - 'Scaffold a QA specification workspace requisitions// ' - '(requisition.md + specification.md). --lang (default: en)', + 'Scaffold a QA specification workspace under docs/requisitions/ ' + '(requisition.md + specification.md) and make it the active requisition', + params: SpecificationNewInput.params, ); m.command( @@ -34,7 +36,8 @@ void buildSpecificationModule(ModuleBuilder m, {required Assets assets}) { workingDirectory: Directory.current.path, ), description: - 'Run the specification_ready gate over the active requisition ' - '(or --slug ) — exits 0 only when the spec is healthy.', + 'Run the specification_ready gate over the active requisition — ' + 'exits 0 only when the spec is healthy', + params: SpecificationCheckInput.params, ); } diff --git a/code/cli/lib/modules/specification/specification_gate.dart b/code/cli/lib/modules/specification/specification_gate.dart index 19b4db19..50579837 100644 --- a/code/cli/lib/modules/specification/specification_gate.dart +++ b/code/cli/lib/modules/specification/specification_gate.dart @@ -107,7 +107,7 @@ class SpecificationGate { } for (final story in stories) { - if (_acRows(story.body).isEmpty) { + if (!_hasAcRow(story.body)) { violations.add(SpecificationViolation( 'SPEC_STORY_MISSING_AC', 'User story "${story.title}" has no filled Given-When-Then ' @@ -150,8 +150,10 @@ class SpecificationGate { if (body == null) return; final declared = {}; - for (final story in _splitStories(body)) { - declared.addAll(_acRows(story.body)); // canonical AC-N ids + final stories = _splitStories(body); + for (var i = 0; i < stories.length; i++) { + final story = stories[i]; + declared.addAll(_acIds(story.title, story.body, i)); // US-AC } final traceTexts = issues.map((issue) { @@ -257,34 +259,51 @@ class SpecificationGate { return true; } - /// The canonical AC ids of the **filled** acceptance-criteria rows in [block] - /// (`| id | given | when | then |` with the three cells filled). The id cell - /// is read in either form — inline `AC-3`, or a bare number `3` under an "AC" - /// column header — and normalized to `AC-3`, so the rendered table can use the - /// short numeric form (which survives a narrow PDF column) without breaking - /// traceability. - List _acRows(String block) { + /// The canonical ids of the **filled** acceptance-criteria rows of the story + /// titled [title] (`| id | given | when | then |`, the three cells filled). + /// + /// The id is qualified by its story — `US2-AC3` — because AC numbering + /// restarts inside every story (the templates say the id cell holds only the + /// number). Bare `AC-N` ids collapsed US-1/AC-1 onto US-2/AC-1, so one issue + /// covering `AC-1` traced both and the gate passed a spec whose second story + /// had no issue at all. + /// + /// The story number is read from the heading (`US-2:` in English, `HU-2:` in + /// Spanish), falling back to the story's position, so the id is the same in + /// both languages — like `AC`, it is a machine token, not prose. + List _acIds(String title, String block, int index) { + final story = 'US${_storyNumber(title, index)}'; final ids = []; for (final line in block.split('\n')) { if (!line.trimLeft().startsWith('|')) continue; final cells = _cells(line); if (cells.length < 4) continue; - final id = _acId(cells[0]); - if (id == null) continue; // header / separator / non-AC row + final number = _acNumber(cells[0]); + if (number == null) continue; // header / separator / non-AC row if (_isFilled(cells[1]) && _isFilled(cells[2]) && _isFilled(cells[3])) { - ids.add(id); + ids.add('$story-AC$number'); } } return ids; } - /// Normalizes an AC-id cell to its canonical `AC-N` form: `AC-3` → `AC-3`, - /// `3` → `AC-3`. Returns null for headers, separators, or other cells. - String? _acId(String cell) { + /// Whether [block] declares at least one filled acceptance-criteria row. + bool _hasAcRow(String block) => _acIds('', block, 0).isNotEmpty; + + /// The story's number, from `US-2: …` / `HU-2: …`; its 0-based [index] + /// position is the fallback when the heading carries no number. + int _storyNumber(String title, int index) { + final match = RegExp(r'\d+').firstMatch(title); + return match != null ? int.parse(match.group(0)!) : index + 1; + } + + /// The AC number in an id cell: `AC-3` → `3`, `3` → `3`. Null for headers, + /// separators, and other cells. + String? _acNumber(String cell) { final inline = RegExp(r'^AC-(\d+)$', caseSensitive: false).firstMatch(cell); - if (inline != null) return 'AC-${inline.group(1)}'; + if (inline != null) return inline.group(1); final numeric = RegExp(r'^(\d+)$').firstMatch(cell); - if (numeric != null) return 'AC-${numeric.group(1)}'; + if (numeric != null) return numeric.group(1); return null; } diff --git a/code/cli/lib/src/gitignore.dart b/code/cli/lib/src/gitignore.dart index cefe4dc3..93af1e08 100644 --- a/code/cli/lib/src/gitignore.dart +++ b/code/cli/lib/src/gitignore.dart @@ -12,7 +12,11 @@ import 'dart:io'; /// The `.gitignore` entries Inquiry manages (idempotently). const inquiryGitignoreEntries = [ '.inquiry/', - 'cleanrooms/**/.iq.state.yaml', + // The cleanroom is a per-cycle working area, not documentation: the durable + // artifacts of a cycle are the published issue, the code and the tests. The + // whole directory is local — previously only `.iq.state.yaml` was ignored, + // which left diagnosis.md / plan.md accumulating in the repo. + 'cleanrooms/', 'docs/requisitions/', ]; diff --git a/code/cli/lib/src/version.dart b/code/cli/lib/src/version.dart index 23f6f365..76573a4a 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.19.0'; +const String inquiryVersion = '0.20.0'; diff --git a/code/cli/lib/src/version_check.dart b/code/cli/lib/src/version_check.dart index 3bb58d42..f3085cc8 100644 --- a/code/cli/lib/src/version_check.dart +++ b/code/cli/lib/src/version_check.dart @@ -19,48 +19,66 @@ class VersionCheckResult { }); } +/// Default deadline for the whole version check. This is advisory telemetry on +/// the side of `iq doctor` / `iq upgrade` — it must never delay the user. +const Duration kVersionCheckTimeout = Duration(seconds: 5); + /// Checks if a newer version is available on GitHub releases. /// /// Returns [VersionCheckResult] with [updateAvailable] = true if /// [currentVersion] differs from the latest release tag. /// Silent on network failures — returns [updateAvailable] = false. +/// +/// [timeout] bounds the **entire** exchange, not just the TCP handshake: +/// `HttpClient.connectionTimeout` alone leaves a connection that is accepted but +/// never answered waiting forever, which hung `iq doctor` indefinitely. Future checkLatestVersion({ required String currentVersion, HttpClient? httpClient, + Duration timeout = kVersionCheckTimeout, }) async { final client = httpClient ?? HttpClient(); + final ownsClient = httpClient == null; try { - client.connectionTimeout = const Duration(seconds: 5); - final releaseUrl = Uri.parse( - 'https://api.github.com/repos/$_repo/releases/latest', - ); - final request = await client.getUrl(releaseUrl); - request.headers.set('Accept', 'application/vnd.github+json'); - request.headers.set('User-Agent', 'inquiry-cli/$currentVersion'); - final response = await request.close(); - - if (response.statusCode != 200) { - await response.drain(); - return const VersionCheckResult(updateAvailable: false); - } - - final body = await response.transform(utf8.decoder).join(); - final release = jsonDecode(body) as Map; - final tagName = release['tag_name'] as String; - final latestVersion = - tagName.startsWith('v') ? tagName.substring(1) : tagName; - - final hasUpdate = _isNewerVersion(latestVersion, currentVersion); - return VersionCheckResult( - latestVersion: latestVersion, - updateAvailable: hasUpdate, - ); + client.connectionTimeout = timeout; + return await _fetchLatest(client, currentVersion).timeout(timeout); } catch (_) { - // Network failure, timeout, DNS, etc. — silent. + // Network failure, stalled response, timeout, DNS, etc. — silent. return const VersionCheckResult(updateAvailable: false); } finally { - if (httpClient == null) client.close(); + // force: a plain close() waits for in-flight connections to finish, which + // is precisely what a stalled request never does. + if (ownsClient) client.close(force: true); + } +} + +Future _fetchLatest( + HttpClient client, + String currentVersion, +) async { + final releaseUrl = Uri.parse( + 'https://api.github.com/repos/$_repo/releases/latest', + ); + final request = await client.getUrl(releaseUrl); + request.headers.set('Accept', 'application/vnd.github+json'); + request.headers.set('User-Agent', 'inquiry-cli/$currentVersion'); + final response = await request.close(); + + if (response.statusCode != 200) { + await response.drain(); + return const VersionCheckResult(updateAvailable: false); } + + final body = await response.transform(utf8.decoder).join(); + final release = jsonDecode(body) as Map; + final tagName = release['tag_name'] as String; + final latestVersion = + tagName.startsWith('v') ? tagName.substring(1) : tagName; + + return VersionCheckResult( + latestVersion: latestVersion, + updateAvailable: _isNewerVersion(latestVersion, currentVersion), + ); } /// Returns true only if [remote] is strictly greater than [current] diff --git a/code/cli/pubspec.lock b/code/cli/pubspec.lock index 820f04a9..8d147a5b 100644 --- a/code/cli/pubspec.lock +++ b/code/cli/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c + sha256: "9a3386eea899815698dd55995277cf7cb8572ee52b399a6edfb7ae2b50e5fc19" url: "https://pub.dev" source: hosted - version: "99.0.0" + version: "105.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7" + sha256: "62993bed6eadbe9596c5c20d5c167e7bc563c5fe266657a04ddeb93bdb84f4c9" url: "https://pub.dev" source: hosted - version: "12.1.0" + version: "14.1.0" args: dependency: transitive description: @@ -53,10 +53,10 @@ packages: dependency: "direct main" description: name: cli_router - sha256: "0720f477ba4912511374b8a255eb9edfa34ddcfca94dbee096a64cf1299751d4" + sha256: "0b1a3fca196c0ccab19d83fb284ee32a24bb0c3ab37ef219e00458cdaf4d2fcb" url: "https://pub.dev" source: hosted - version: "0.0.3" + version: "0.1.0" collection: dependency: transitive description: @@ -77,10 +77,10 @@ packages: dependency: transitive description: name: coverage - sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.15.1" crypto: dependency: transitive description: @@ -157,18 +157,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" meta: dependency: transitive description: name: meta - sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739 + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.2" + version: "1.19.0" mime: dependency: transitive description: @@ -181,10 +181,10 @@ packages: dependency: "direct main" description: name: modular_cli_sdk - sha256: "1d405472db223dd8a9d559b1bd937c8ee98262a0badb4c6ce3f1492d2a7d620e" + sha256: e88a11929fc54f0610cbbf3461d57f95aa69dba22ce29d249a9402377cd84008 url: "https://pub.dev" source: hosted - version: "0.2.1" + version: "0.3.3" node_preamble: dependency: transitive description: @@ -197,10 +197,10 @@ packages: dependency: transitive description: name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "3.0.0" path: dependency: "direct main" description: @@ -317,26 +317,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + sha256: "0d5ba5602ec3baa28c8ce365e1efc5575969c765f45c554a3e167dc7945b9c30" url: "https://pub.dev" source: hosted - version: "1.31.0" + version: "1.31.2" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "475610b2aa23c19687cce2961e44b0cc57cafe220f67c2b80201231b2a07fbe7" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.13" test_core: dependency: transitive description: name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + sha256: a39c204a4fc7a7ccb04a2b985e359fda3cc37e45e0b8ac61c3fb1a05aa832132 url: "https://pub.dev" source: hosted - version: "0.6.17" + version: "0.6.19" typed_data: dependency: transitive description: @@ -349,10 +349,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499" + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" url: "https://pub.dev" source: hosted - version: "15.1.0" + version: "15.2.0" watcher: dependency: transitive description: @@ -402,4 +402,4 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" + dart: ">=3.11.0 <4.0.0" diff --git a/code/cli/pubspec.yaml b/code/cli/pubspec.yaml index 656433b2..cbf66970 100644 --- a/code/cli/pubspec.yaml +++ b/code/cli/pubspec.yaml @@ -1,13 +1,13 @@ name: inquiry_cli description: > CLI for Inquiry — structured development through the APE methodology. -version: 0.19.0 +version: 0.20.0 environment: sdk: ^3.8.1 dependencies: - cli_router: ^0.0.3 - modular_cli_sdk: ^0.2.1 + cli_router: ^0.1.0 + modular_cli_sdk: ^0.3.3 path: ^1.9.1 yaml: ^3.1.3 dev_dependencies: diff --git a/code/cli/test/cli_contract_test.dart b/code/cli/test/cli_contract_test.dart new file mode 100644 index 00000000..106a2bcb --- /dev/null +++ b/code/cli/test/cli_contract_test.dart @@ -0,0 +1,98 @@ +import 'dart:convert'; + +import 'package:inquiry_cli/inquiry_cli.dart'; +import 'package:modular_cli_sdk/modular_cli_sdk.dart'; +import 'package:test/test.dart'; + +import 'support/string_io_sink.dart'; + +/// Every `iq` command declares its parameters, so the help *is* the contract and +/// the contract *is* what gets parsed — they cannot drift. An option the command +/// never declared is refused instead of silently ignored, which is how +/// `iq init --host claude` used to run while doing nothing the flag implied. +Future<({int code, String out, String err})> _run(List args) async { + final out = StringIOSink(); + final err = StringIOSink(); + final code = await runInquiry(args, stdout: out, stderr: err); + return (code: code, out: out.toString(), err: err.toString()); +} + +void main() { + group('a command refuses what it never declared', () { + test('`iq init --host claude` is rejected, not silently accepted', () async { + final r = await _run(const ['init', '--host', 'claude']); + + expect(r.code, ExitCode.validationFailed); + expect(r.err, contains('unknown option --host')); + }); + + test('`iq specification check --bogus x` is rejected', () async { + final r = await _run(const ['specification', 'check', '--bogus', 'x']); + + expect(r.code, ExitCode.validationFailed); + expect(r.err, contains('unknown option --bogus')); + }); + + test('the global options stay accepted everywhere', () async { + final r = await _run(const ['version', '--json']); + + expect(r.code, 0); + expect(jsonDecode(r.out), isA>()); + }); + }); + + group('the help renders each command contract', () { + test('`iq specification new --help` shows --lang, its default and its values', + () async { + final r = await _run(const ['specification', 'new', '--help']); + + expect(r.code, 0); + expect(r.out, contains('--lang')); + expect(r.out, contains('default: en')); + expect(r.out, contains('es')); + expect(r.out, contains('')); + }); + + test('`iq host get --help` shows the allowed hosts', () async { + final r = await _run(const ['host', 'get', '--help']); + + expect(r.code, 0); + expect(r.out, contains('--host')); + expect(r.out, contains('opencode')); + expect(r.out, contains('claude')); + }); + + test('`iq fsm --help` describes every command in the module', () async { + final r = await _run(const ['fsm', '--help']); + + expect(r.code, 0); + expect(r.out, contains('fsm state')); + expect(r.out, contains('fsm transition')); + expect(r.out, contains('--event')); + }); + }); + + group('iq help --json is the machine-readable contract', () { + test('every command appears with its declared parameters', () async { + final r = await _run(const ['help', '--json']); + + expect(r.code, 0); + final catalog = jsonDecode(r.out) as Map; + final commands = (catalog['commands'] as List).cast>(); + final routes = commands.map((c) => c['route']).toList(); + + expect(routes, contains('specification new ')); + expect(routes, contains('issue publish ')); + + final specNew = commands.firstWhere( + (c) => c['route'] == 'specification new ', + ); + final params = (specNew['params'] as List).cast>(); + final lang = params.firstWhere((p) => p['name'] == 'lang'); + + expect(lang['default'], 'en'); + expect(lang['allowed'], containsAll(['en', 'es'])); + expect(params.any((p) => p['kind'] == 'positional'), isTrue); + }); + }); +} diff --git a/code/cli/test/doctor_test.dart b/code/cli/test/doctor_test.dart index 3b4e5a26..b4de1bc5 100644 --- a/code/cli/test/doctor_test.dart +++ b/code/cli/test/doctor_test.dart @@ -143,6 +143,18 @@ void main() { 'inquiry-end', ]; + /// The skills the deployer *generates* from the FSM/APE contracts — they + /// live in no asset directory (deployer.dart `_deploySkills`). + const phaseSkills = [ + 'iq-analyze', + 'iq-plan', + 'iq-execute', + 'iq-specification', + ]; + + /// Everything `iq host get` installs: asset-tree skills + generated ones. + final deployedSkills = [...testSkills, ...phaseSkills]; + Assets seedAssets(Directory root, {required List apes}) { final skillsDir = Directory(p.join(root.path, 'assets', 'skills')); for (final skill in testSkills) { @@ -197,7 +209,7 @@ void main() { DoctorInput(), runProcess: runProcess ?? fakeRunner(), inquiryVersionOverride: version, - fileSystemOps: fs ?? allPassFs(resolvedWd, homeDir, testSkills), + fileSystemOps: fs ?? allPassFs(resolvedWd, homeDir, deployedSkills), assets: assets ?? testAssets, versionChecker: ({required String currentVersion}) async => const VersionCheckResult(updateAvailable: false), @@ -362,7 +374,7 @@ void main() { final text = output.toText()!; expect(text, contains('Checking hosts...')); - expect(text, contains('✓ opencode: agent + 9 skills deployed')); + expect(text, contains('✓ opencode: agent + ${deployedSkills.length} skills deployed')); expect(text, contains('- claude: not deployed (inactive)')); expect(text, contains('All checks passed.')); }); @@ -419,16 +431,7 @@ void main() { true, ); // Deploy everything except issue-create (global skills). - for (final skill in [ - 'doc-read', - 'doc-write', - 'inquiry-install', - 'kritik', - 'legion', - 'research', - 'inquiry-start', - 'inquiry-end', - ]) { + for (final skill in deployedSkills.where((s) => s != 'issue-create')) { fs.setFileExists( p.join(homeDir, '.config', 'opencode', 'skills', skill, 'SKILL.md'), true, @@ -502,7 +505,7 @@ void main() { // ─── E3: repo-scoped agent tests ─────────────────────────────────────── test('doctor passes when inquiry.agent.md is in .github/agents/', () async { - final fs = allPassFs(workingDir, homeDir, testSkills); + final fs = allPassFs(workingDir, homeDir, deployedSkills); final cmd = makeCmd(fs: fs); final output = await cmd.execute(); @@ -562,7 +565,7 @@ void main() { p.join(homeDir, '.config', 'opencode', 'agent', 'inquiry.md'), true, ); - for (final skill in testSkills) { + for (final skill in deployedSkills) { fs.setFileExists( p.join(homeDir, '.config', 'opencode', 'skills', skill, 'SKILL.md'), true, @@ -585,14 +588,17 @@ void main() { reason: 'Claude not deployed → inactive, not a failure'); final text = output.toText()!; - expect(text, contains('✓ opencode: agent + 9 skills deployed')); + expect(text, contains('✓ opencode: agent + ${deployedSkills.length} skills deployed')); expect(text, contains('- claude: not deployed (inactive)')); expect(text, contains('All checks passed.')); }); - test('no assets available → hosts still checked, 0 skills expected', () async { - final fs = allPassFs(workingDir, homeDir, []); - // Assets with empty skills dir + test('empty skills asset dir → only the generated iq-* skills are expected', + () async { + // The host carries the generated phase skills but the asset tree ships + // no skills of its own: the generated set is a fixed contract, so it is + // still expected — and satisfied here. + final fs = allPassFs(workingDir, homeDir, phaseSkills); final emptyTempDir = Directory.systemTemp.createTempSync('empty_assets_'); Directory(p.join(emptyTempDir.path, 'assets', 'skills')).createSync(recursive: true); final emptyAssets = Assets(root: emptyTempDir.path); @@ -600,8 +606,8 @@ void main() { final cmd = makeCmd(fs: fs, assets: emptyAssets); final output = await cmd.execute(); - // Agent exists, 0 skills expected → passes - expect(output.hostChecks.first.totalSkills, 0); + expect(output.hostChecks.first.totalSkills, phaseSkills.length); + expect(output.hostChecks.first.missingSkills, isEmpty); expect(output.hostChecks.first.agentExists, isTrue); expect(output.hostChecks.first.passed, isTrue); @@ -629,7 +635,7 @@ void main() { p.join(homeDir, '.config', 'opencode', 'agent', 'inquiry.md'), true, ); - for (final s in testSkills) { + for (final s in deployedSkills) { fs.setFileExists( p.join(homeDir, '.config', 'opencode', 'skills', s, 'SKILL.md'), true, @@ -686,7 +692,7 @@ void main() { p.join(homeDir, '.claude', 'agents', 'inquiry.md'), true, ); - for (final s in testSkills) { + for (final s in deployedSkills) { fs.setFileExists( p.join(homeDir, '.claude', 'skills', s, 'SKILL.md'), true, @@ -706,5 +712,44 @@ void main() { ); }); }); + + // The deployer installs BOTH the asset-tree skills and the skills it + // *generates* from the FSM/APE contracts (deployer.dart:48-75: + // `assets.listDirectory('skills')` + `SkillBuilder.phaseSkillNames`). + // Doctor used to discover expected skills from the asset tree ONLY, so a + // missing iq-plan / iq-specification was reported as a healthy host. + group('generated iq-* phase skills', () { + test('are expected, not only the asset-tree skills', () async { + // Home has ONLY the asset-tree skills — every generated skill is absent. + final cmd = makeCmd(fs: allPassFs(workingDir, homeDir, testSkills)); + final output = await cmd.execute(); + + final opencode = + output.hostChecks.firstWhere((h) => h.hostName == 'opencode'); + + expect(opencode.missingSkills, containsAll(phaseSkills)); + expect(opencode.totalSkills, testSkills.length + phaseSkills.length); + expect(opencode.passed, isFalse); + }); + + test('a host with every skill deployed still passes', () async { + final cmd = makeCmd( + fs: allPassFs(workingDir, homeDir, deployedSkills), + ); + final output = await cmd.execute(); + + final opencode = + output.hostChecks.firstWhere((h) => h.hostName == 'opencode'); + + expect(opencode.missingSkills, isEmpty); + expect(opencode.passed, isTrue); + expect( + output.toText()!, + contains( + '✓ opencode: agent + ${deployedSkills.length} skills deployed', + ), + ); + }); + }); }); } diff --git a/code/cli/test/help_command_test.dart b/code/cli/test/help_command_test.dart index 4d2bdca3..e57d3dce 100644 --- a/code/cli/test/help_command_test.dart +++ b/code/cli/test/help_command_test.dart @@ -1,32 +1,112 @@ import 'package:inquiry_cli/inquiry_cli.dart'; -import 'package:inquiry_cli/modules/global/commands/help.dart'; -import 'package:modular_cli_sdk/modular_cli_sdk.dart'; import 'package:test/test.dart'; +import 'support/string_io_sink.dart'; + +/// The help is the SDK's — `modular_cli_sdk` renders it from the command +/// catalog every registration feeds. Inquiry maintains no help text of its own: +/// the hand-written one drifted, and the `specification` and `issue` modules +/// shipped without ever appearing in it. +Future<({int code, String out, String err})> _run(List args) async { + final out = StringIOSink(); + final err = StringIOSink(); + final code = await runInquiry(args, stdout: out, stderr: err); + return (code: code, out: out.toString(), err: err.toString()); +} + void main() { - group('HelpCommand', () { - test('returns the global CLI help summary', () async { - final output = await HelpCommand(HelpInput()).execute(); - - expect(output.exitCode, ExitCode.ok); - expect(output.toText(), contains('Usage:')); - expect(output.toText(), contains('inquiry help')); - expect(output.toText(), contains('Root commands:')); - expect(output.toText(), contains('Modules:')); - expect(output.toText(), contains('host get')); - expect(output.toText(), isNot(contains('target get'))); - expect(output.toText(), contains('fsm transition')); - expect(output.toText(), contains('ape prompt')); + group('inquiry help', () { + test('lists every registered command', () async { + final r = await _run(const ['help']); + + const registered = [ + 'init', + 'version', + 'doctor', + 'upgrade', + 'uninstall', + 'host get', + 'host clean', + 'fsm state', + 'fsm transition', + 'ape prompt', + 'ape state', + 'ape transition', + 'specification new', + 'specification check', + 'issue new', + 'issue publish', + ]; + + expect(r.code, 0); + expect(r.err, isEmpty); + for (final route in registered) { + expect( + r.out, + contains(route), + reason: '`$route` is registered but missing from the help', + ); + } + }); + + test('names the TUI by how it is invoked, and documents global options', + () async { + final r = await _run(const ['help']); + + expect(r.out, contains('(no arguments)')); + expect(r.out, contains('Global options')); + }); + + test('carries each command description, not just its route', () async { + final r = await _run(const ['help']); + + expect(r.out, contains('Print the current CLI version')); + expect(r.out, contains('specification_ready gate')); + }); + + /// The `specification new` description pointed at the pre-0.19.0 layout. + test('describes specification new with the path it actually creates', + () async { + final r = await _run(const ['help']); + + expect(r.out, contains('docs/requisitions/')); + expect(r.out, isNot(contains('requisitions//'))); + }); + + /// Every deliberate help request is a success on stdout — never an error. + for (final args in [ + ['help'], + ['--help'], + ['-h'], + ]) { + test('`iq ${args.join(' ')}` prints help on stdout, exit 0', () async { + final r = await _run(args); + + expect(r.code, 0); + expect(r.err, isEmpty); + expect(r.out, contains('Global options')); + }); + } + + /// `iq` with no arguments is NOT a help request — it is a registered route: + /// the banner + FSM diagram. + test('bare `iq` runs the TUI, not the help', () async { + final r = await _run(const []); + + expect(r.code, 0); + expect(r.out, contains('Inquiry'), reason: 'the banner should print'); + expect(r.out, contains('Analyze'), reason: 'the FSM diagram should print'); + expect(r.out, isNot(contains('Global options')), + reason: 'the empty invocation was hijacked by the help'); }); - test('normalizes global help flags to the help command', () { - expect(normalizeInquiryArgs(const ['--help']), equals(const ['help'])); - expect(normalizeInquiryArgs(const ['-h']), equals(const ['help'])); - expect(normalizeInquiryArgs(const ['help']), equals(const ['help'])); + test('normalizes the version flags to the version command', () { + expect(normalizeInquiryArgs(const ['--version']), equals(const ['version'])); + expect(normalizeInquiryArgs(const ['-v']), equals(const ['version'])); expect( normalizeInquiryArgs(const ['fsm', 'state']), equals(const ['fsm', 'state']), ); }); }); -} \ No newline at end of file +} diff --git a/code/cli/test/init_command_test.dart b/code/cli/test/init_command_test.dart index e3729136..fb33d3db 100644 --- a/code/cli/test/init_command_test.dart +++ b/code/cli/test/init_command_test.dart @@ -70,12 +70,15 @@ void main() { }, ); - test('ignores cycle-local state files under cleanrooms/', () async { + // The cleanroom is a per-cycle working area; the durable artifacts are the + // published issue, the code and the tests. Nothing under it is versioned. + test('ignores the whole cleanrooms/ working area', () async { final command = InitCommand(InitInput(workingDirectory: tempDir.path)); await command.execute(); final content = File('${tempDir.path}/.gitignore').readAsStringSync(); - expect(content, contains('cleanrooms/**/.iq.state.yaml')); + expect(content, matches(RegExp(r'^cleanrooms/\s*$', multiLine: true))); + expect(content, isNot(contains('cleanrooms/**/.iq.state.yaml'))); }); test('ignores docs/requisitions/ (local authoring workspace)', () async { @@ -95,7 +98,7 @@ void main() { final content = File('${tempDir.path}/.gitignore').readAsStringSync(); expect(content, contains('node_modules/')); expect(content, contains('.inquiry/')); - expect(content, contains('cleanrooms/**/.iq.state.yaml')); + expect(content, matches(RegExp(r'^cleanrooms/\s*$', multiLine: true))); }); test( diff --git a/code/cli/test/specification_check_command_test.dart b/code/cli/test/specification_check_command_test.dart index c9459854..d703004b 100644 --- a/code/cli/test/specification_check_command_test.dart +++ b/code/cli/test/specification_check_command_test.dart @@ -58,7 +58,7 @@ void main() { f.writeAsStringSync(content); } - void writeIssue(String slug, String name, {String covers = 'AC-1'}) { + void writeIssue(String slug, String name, {String covers = 'US1-AC1'}) { File(p.join(tempDir.path, 'requisitions', slug, name)) ..createSync(recursive: true) ..writeAsStringSync('# issue\n\nCovers $covers.\n'); @@ -101,7 +101,7 @@ void main() { test('an issue that does not reference the AC → SPEC_AC_NOT_TRACED', () async { - writeSpec('trace', _filledSpec); // declares AC-1 + writeSpec('trace', _filledSpec); // declares US1-AC1 writeIssue('trace', 'issue-trace.md', covers: 'nothing'); final out = await cmd('trace').execute(); expect(out.exitCode, isNot(0)); @@ -116,7 +116,7 @@ void main() { ..writeAsStringSync(_filledSpec); File(p.join(tempDir.path, 'docs', 'requisitions', folder, 'issue-a.md')) ..createSync(recursive: true) - ..writeAsStringSync('# issue\n\nCovers AC-1.\n'); + ..writeAsStringSync('# issue\n\nCovers US1-AC1.\n'); writeActiveRequisition(tempDir.path, slug: 'active', relDir: 'docs/requisitions/$folder', diff --git a/code/cli/test/specification_gate_test.dart b/code/cli/test/specification_gate_test.dart index a350b72a..4c66568d 100644 --- a/code/cli/test/specification_gate_test.dart +++ b/code/cli/test/specification_gate_test.dart @@ -103,8 +103,8 @@ void main() { final gate = SpecificationGate(); group('SpecificationGate', () { - // An issue body that traces both ACs of _filledSpec (AC-1, AC-2). - const tracingIssues = ['# Issue\n\nCovers AC-1 and AC-2.\n']; + // An issue body that traces both ACs of _filledSpec (US1-AC1, US1-AC2). + const tracingIssues = ['# Issue\n\nCovers US1-AC1 and US1-AC2.\n']; test('the unfilled scaffold (template) is rejected with violations', () { final template = @@ -125,23 +125,25 @@ void main() { }); test('a fully-filled Spanish (--lang es) spec passes (bilingual gate)', () { - final r = gate.evaluate(_filledSpecEs, issues: const ['Cubre AC-1.']); + final r = gate.evaluate(_filledSpecEs, issues: const ['Cubre US1-AC1.']); expect(r.passed, isTrue, reason: r.violations.join('\n')); expect(r.violations, isEmpty); }); - test('numeric AC column (header "AC", cell "1") is read as AC-1', () { + test('numeric AC column (header "AC", cell "1") is read as US1-AC1', () { // The rendered table uses a short numeric id cell so a narrow PDF column - // does not wrap "AC-1" character by character; the gate reconstructs AC-1. + // does not wrap the id character by character; the gate reconstructs it. final numericAc = _filledSpec.replaceAll('| AC-1 |', '| 1 |').replaceAll( '| # ', '| AC ', ); - final r = gate.evaluate(numericAc, issues: const ['traces AC-1 and AC-2']); + final r = + gate.evaluate(numericAc, issues: const ['traces US1-AC1 and US1-AC2']); expect(r.violations.map((v) => v.code), isNot(contains('SPEC_STORY_MISSING_AC'))); - // AC-1 (now cell "1") must still be traceable as "AC-1". - final notTraced = gate.evaluate(numericAc, issues: const ['only AC-2 here']); + // AC 1 (now cell "1") must still be traceable as "US1-AC1". + final notTraced = + gate.evaluate(numericAc, issues: const ['only US1-AC2 here']); expect(notTraced.violations.map((v) => v.code), contains('SPEC_AC_NOT_TRACED')); }); @@ -153,11 +155,11 @@ void main() { }); test('an AC referenced by no issue → SPEC_AC_NOT_TRACED', () { - // Issue traces AC-1 only; AC-2 is left untraced. - final r = gate.evaluate(_filledSpec, issues: const ['Covers AC-1.']); + // Issue traces US1-AC1 only; US1-AC2 is left untraced. + final r = gate.evaluate(_filledSpec, issues: const ['Covers US1-AC1.']); final traced = r.violations.where((v) => v.code == 'SPEC_AC_NOT_TRACED'); expect(traced, isNotEmpty); - expect(traced.first.message, contains('AC-2')); + expect(traced.first.message, contains('US1-AC2')); }); test('every AC traced by some issue → no SPEC_AC_NOT_TRACED', () { @@ -168,7 +170,7 @@ void main() { test('issue-as-code traces via the front-matter covers: list', () { const issue = - '---\nkind: iq-issue\ncovers: [AC-1, AC-2]\n---\n\nA body with no ids.\n'; + '---\nkind: iq-issue\ncovers: [US1-AC1, US1-AC2]\n---\n\nA body with no ids.\n'; final r = gate.evaluate(_filledSpec, issues: const [issue]); expect(r.violations.map((v) => v.code), isNot(contains('SPEC_AC_NOT_TRACED'))); @@ -176,16 +178,125 @@ void main() { test('an AC mentioned only in the body prose (not in covers:) does NOT trace', () { - // covers declares AC-1; AC-2 appears in the body but is not declared — - // the fix: prose/examples must not trace falsely (issue-as-code source of + // covers declares US1-AC1; US1-AC2 appears in the body but is not declared + // — prose/examples must not trace falsely (the issue-as-code source of // truth is covers:). const issue = - '---\nkind: iq-issue\ncovers: [AC-1]\n---\n\nRelated to AC-2 somehow.\n'; + '---\nkind: iq-issue\ncovers: [US1-AC1]\n---\n\nRelated to US1-AC2 somehow.\n'; final r = gate.evaluate(_filledSpec, issues: const [issue]); final untraced = r.violations.where((v) => v.code == 'SPEC_AC_NOT_TRACED'); - expect(untraced.map((v) => v.message).join('\n'), contains('AC-2')); - expect(untraced.map((v) => v.message).join('\n'), isNot(contains('AC-1'))); + expect(untraced.map((v) => v.message).join('\n'), contains('US1-AC2')); + expect( + untraced.map((v) => v.message).join('\n'), isNot(contains('US1-AC1'))); + }); + + group('AC ids are qualified by their user story', () { + /// AC numbering restarts inside every story (the templates say the id cell + /// holds only the number). With bare `AC-N` ids, US-1/AC-1 and US-2/AC-1 + /// collapsed onto one id: an issue covering only the first story silently + /// traced the second, and the gate passed a spec with a whole story + /// unimplemented. + const twoStories = ''' +# Specification + +## Metadata + +| Field | Value | +| ----- | ----- | +| ID | REQ-1 | + +## 1. Commitment date + +| Milestone | Date | +| ------------------ | ---------- | +| Committed delivery | 2026-08-15 | + +## 2. User Stories + +### US-1: Log in + +**As a** user, +**I want** to log in, +**So that** I can see my account. + +| AC | Given | When | Then | +| --- | ---------------- | -------- | -------------- | +| 1 | valid password | I submit | I am logged in | +| 2 | invalid password | I submit | I see an error | + +### US-2: Export invoices + +**As an** accountant, +**I want** to export invoices to CSV, +**So that** I can reconcile them. + +| AC | Given | When | Then | +| --- | ----------------- | -------------- | -------------------- | +| 1 | invoices exist | I click export | a CSV is downloaded | +| 2 | no invoices exist | I click export | I see an empty state | + +## 3. Explicit Scope + +### Includes + +- Login and CSV export. + +### Does NOT include + +- Anything else. + +## 4. Domain and business rules + +- **Glossary:** an *invoice* is a billing document. +'''; + + test('an issue covering only the first story leaves the second untraced', + () { + const loginOnly = + '---\nkind: iq-issue\ncovers: [US1-AC1, US1-AC2]\n---\n\nLogin only.\n'; + + final r = gate.evaluate(twoStories, issues: const [loginOnly]); + final untraced = r.violations + .where((v) => v.code == 'SPEC_AC_NOT_TRACED') + .map((v) => v.message) + .join('\n'); + + expect(r.passed, isFalse); + expect(untraced, contains('US2-AC1')); + expect(untraced, contains('US2-AC2')); + expect(untraced, isNot(contains('US1-AC'))); + }); + + test('covering every story passes', () { + const both = + '---\nkind: iq-issue\ncovers: [US1-AC1, US1-AC2, US2-AC1, US2-AC2]\n---\n\nBoth.\n'; + + final r = gate.evaluate(twoStories, issues: const [both]); + expect(r.violations.map((v) => v.code), + isNot(contains('SPEC_AC_NOT_TRACED'))); + expect(r.passed, isTrue); + }); + + test('a bare AC-1 no longer traces anything — it is ambiguous', () { + const bare = + '---\nkind: iq-issue\ncovers: [AC-1, AC-2]\n---\n\nWhich story?\n'; + + final r = gate.evaluate(twoStories, issues: const [bare]); + final untraced = r.violations + .where((v) => v.code == 'SPEC_AC_NOT_TRACED') + .map((v) => v.message) + .join('\n'); + + expect(untraced, contains('US1-AC1')); + expect(untraced, contains('US2-AC1')); + }); + + test('the id is language-agnostic: a Spanish HU-1 yields US1-AC1', () { + final r = gate.evaluate(_filledSpecEs, issues: const ['Cubre US1-AC1.']); + expect(r.violations.map((v) => v.code), + isNot(contains('SPEC_AC_NOT_TRACED'))); + }); }); test('a user story with no acceptance criterion → SPEC_STORY_MISSING_AC', () { diff --git a/code/cli/test/support/string_io_sink.dart b/code/cli/test/support/string_io_sink.dart new file mode 100644 index 00000000..5b35db6f --- /dev/null +++ b/code/cli/test/support/string_io_sink.dart @@ -0,0 +1,33 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +/// Collects [IOSink] writes into a string, so what the CLI prints can be +/// asserted instead of only its exit code. +class StringIOSink implements IOSink { + final StringBuffer _buffer = StringBuffer(); + + @override + void write(Object? obj) => _buffer.write(obj); + + @override + void writeln([Object? obj = '']) => _buffer.writeln(obj); + + @override + Encoding encoding = utf8; + + @override + Future get done => Future.value(); + + @override + Future flush() async {} + + @override + Future close() async {} + + @override + String toString() => _buffer.toString(); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/code/cli/test/version_check_test.dart b/code/cli/test/version_check_test.dart new file mode 100644 index 00000000..44c934f4 --- /dev/null +++ b/code/cli/test/version_check_test.dart @@ -0,0 +1,59 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:inquiry_cli/src/version_check.dart'; +import 'package:test/test.dart'; + +/// An [HttpClient] whose connection is *established* but which never produces a +/// response — the real-world failure that hung `iq doctor` for 1m40s. Only +/// `getUrl`/`close` are meaningful; everything else is unreachable in this path. +class _StallingHttpClient implements HttpClient { + bool closed = false; + + /// A real field: the code under test sets it, and a `noSuchMethod` fallback + /// would throw and make the test pass for the wrong reason. + @override + Duration? connectionTimeout; + + @override + Future getUrl(Uri url) => Completer().future; + + @override + void close({bool force = false}) => closed = true; + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + group('checkLatestVersion', () { + test('gives up when the server accepts but never responds (no hang)', () async { + final client = _StallingHttpClient(); + + final result = await checkLatestVersion( + currentVersion: '0.19.0', + httpClient: client, + timeout: const Duration(milliseconds: 200), + ).timeout( + const Duration(seconds: 5), + onTimeout: () => fail( + 'checkLatestVersion never returned — the request has no overall ' + 'deadline (connectionTimeout only bounds the TCP handshake).', + ), + ); + + expect(result.updateAvailable, isFalse); + expect(result.latestVersion, isNull); + }); + + test('a stalled check is silent — it reports no update, not an error', () async { + final result = await checkLatestVersion( + currentVersion: '0.19.0', + httpClient: _StallingHttpClient(), + timeout: const Duration(milliseconds: 200), + ).timeout(const Duration(seconds: 5)); + + expect(result.updateAvailable, isFalse); + }); + }); +} diff --git a/code/paper/field-evidence-dogfood-2026-07.md b/code/paper/field-evidence-dogfood-2026-07.md index e80a0f2f..3ee3ba4b 100644 --- a/code/paper/field-evidence-dogfood-2026-07.md +++ b/code/paper/field-evidence-dogfood-2026-07.md @@ -17,8 +17,12 @@ harness *in production use*, not a claim of effect size. existing screen, taken **all the way through development**. Specification → 2 issues → 2 PRs, every AC covered by a passing test. Cross-**org**: the app fix lives in `bitstream-sac/socia`, the API fix in `cacsi-dev/impulsa_api`. +- **modular_cli_sdk — "native help command"** (`--lang en`): a requirement on the *dependency* + Inquiry's own CLI is built on. 3 user stories, 15 AC, one issue, one PR. Run with Inquiry + v0.19.0 against a foreign repo — the first time the tool was pointed at a package it does not + live in. -Both requirements evolved the tool itself: Inquiry went **0.15.0 → 0.18.0 (8 releases)**, each +All three requirements evolved the tool itself: Inquiry went **0.15.0 → 0.20.0**, each release driven by a concrete friction surfaced by real use — not by speculation. ## Observations relevant to the thesis @@ -64,6 +68,35 @@ driven by a concrete friction surfaced by real use — not by speculation. The thinking — Deweyan investigation, authoring, the content-vs-presentation layer decision — was the model's. The tool never reasoned; it enforced completeness and left a reviewable trail. +7. **Use is a discovery procedure that tests and review are not.** The `modular_cli_sdk` case is + the sharpest datum in this record, because the defects it surfaced were *unreachable* by the + two methods a normal project would trust. The SDK was **already published**, with **102 green + tests** and a passing review. Integrating it into a real consumer — Inquiry's 18 commands — + surfaced **three defects in three days**, each shipped upstream as a release: + - **0.3.1** — the bare invocation (`iq`) hijacked a *registered root route*. The SDK's own + `example/` had no root command, so no test could see it. Found the moment a real CLI whose + bare form is a TUI took the dependency. + - **0.3.2** — an **empty** parameter contract was inexpressible (`params: []` was + indistinguishable from "declares nothing"), so a zero-option command could not be enforced. + This is the *original* reported bug: `iq init --host claude` accepted a parameter it does not + have and exited 0 — "I ran something, but it did something else." + - **0.3.3** — a command with positionals could not answer `--help` without being handed the + very argument the user was asking about (`iq specification new --help` → exit 64). + None of the three is a coding error visible in isolation; each is a **contract error at the + integration boundary**, and the boundary is the only place it exists. The SDK's tests were green + *and correct* — they tested the CLI the example described, not the CLI a user builds. This is + the empirical form of the paper's own commitment: **evidence over inference**. A green suite is + inference about a system's behaviour; use is evidence of it. Dogfood is not a virtue signal + here, it is the measuring instrument. + +8. **The requirement propagated the right way: upstream, not around.** The requisition targeted a + *dependency*, and the three defects were fixed **in the SDK and published**, not patched around + in the consumer. Two of them (0.3.2, 0.3.3) then became enforced behaviour for every command in + Inquiry v0.20.0 — an unknown parameter now *fails* instead of being silently accepted. The + traceability spine held across the repository boundary (spec → AC → issue in *another* repo → + test → release), which is the same shape as the cross-org Socia case and the external-ERP + prerequisite in Impulsa: the method does not require the work to live where the story does. + ## Honest limitations and open items (antifragility inputs) - **Field N is tiny and uncontrolled.** These cases show the method *delivering*; they do not @@ -81,7 +114,8 @@ driven by a concrete friction surfaced by real use — not by speculation. ## One-line takeaway -Across two real requirements, Inquiry behaved as a **coherent methodology for evidence-based, -traceable delivery** whose separation of *definedness* (specification) from *construction* -(TDD-applied FSM) held — and it is earning that design by use, which is precisely the claim an -agentic-development paper must substantiate. +Across three real requirements — one of them against the tool's own dependency — Inquiry behaved +as a **coherent methodology for evidence-based, traceable delivery** whose separation of +*definedness* (specification) from *construction* (TDD-applied FSM) held; and in the SDK case the +methodology *found what a green test suite could not*, which is the claim an agentic-development +paper must substantiate: not that the harness produces confidence, but that it produces evidence. diff --git a/code/site/index.html b/code/site/index.html index 901f969d..9677d589 100644 --- a/code/site/index.html +++ b/code/site/index.html @@ -31,7 +31,7 @@

Inquiry

Analyze. Plan. Execute.

Available for OpenCode and GitHub Copilot. More hosts coming soon.

- v0.19.0 + v0.20.0

Public entry surface. For the canonical documentation map, start in the repository docs.