Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ code/cli/tmp/
.claude/skills/speckit-*/
.github/agents/speckit.*
.github/prompts/speckit.*
# Inquiry — local workspace & cycle state
docs/requisitions/
18 changes: 18 additions & 0 deletions code/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command> --help`** renders that command's full contract — parameters with type, required/optional, default and allowed values — positionals included, *without* having to supply them. **`iq <module> --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 <en|es> (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).
Expand Down
4 changes: 3 additions & 1 deletion code/cli/assets/artifacts/issue.template.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ covers: []
`iq issue publish <slug> <name> --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

Expand Down
4 changes: 3 additions & 1 deletion code/cli/assets/artifacts/issue.template.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ covers: []
`iq issue publish <slug> <nombre> --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

Expand Down
9 changes: 5 additions & 4 deletions code/cli/assets/artifacts/specification.template.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@ _This specification is the **business agreement** — like a project charter —

#### Acceptance Criteria

<!-- The AC column holds ONLY the number (1, 2, …); the id is AC-<number>. Keep
the separator dashes moderate — do not widen them to the text width, or the
PDF export splits the column. Each AC is also its acceptance test (tests are
written in development). -->
<!-- The AC column holds ONLY the number (1, 2, …). Numbering restarts in every
story, so the id is qualified by it: the 3rd AC of US-2 is US2-AC3 — that
is what an issue's `covers:` must list. Keep the separator dashes moderate
— do not widen them to the text width, or the PDF export splits the column.
Each AC is also its acceptance test (tests are written in development). -->

| AC | Given (context) | When (action) | Then (expected result) |
| --- | ----------------------- | -------------------- | ---------------------- |
Expand Down
8 changes: 5 additions & 3 deletions code/cli/assets/artifacts/specification.template.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ _Esta especificación es el **acuerdo de negocio** —a la manera de un project

#### Acceptance Criteria

<!-- La columna AC lleva SOLO el número (1, 2, …); el id es AC-<número>. Mantén
los guiones del separador moderados — no los amplíes al ancho del texto, o
la exportación a PDF parte la columna. Cada AC es, además, su test de
<!-- La columna AC lleva SOLO el número (1, 2, …). La numeración reinicia en cada
historia, así que el id se califica con ella: el 3er AC de la HU-2 es
US2-AC3 — eso es lo que debe listar el `covers:` de una issue. Mantén los
guiones del separador moderados — no los amplíes al ancho del texto, o la
exportación a PDF parte la columna. Cada AC es, además, su test de
aceptación (los tests se escriben en desarrollo). -->

| AC | Given (Dado que) | When (Cuando) | Then (Entonces) |
Expand Down
2 changes: 1 addition & 1 deletion code/cli/lib/hosts/skill_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> [--repo owner/repo]`** — it scaffolds `issue-<name>.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 <slug>` targets a different requisition.)
6. Derive the issues with **`iq issue new <name> [--repo owner/repo]`** — it scaffolds `issue-<name>.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 <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 "<keywords>"`. If a too-similar issue exists, edit that one instead.
9. Publish with **`iq issue publish <name> --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.
Expand Down
16 changes: 10 additions & 6 deletions code/cli/lib/inquiry_cli.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command> --help`, which this could not.
List<String> normalizeInquiryArgs(List<String> 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'];
}
Expand All @@ -30,8 +29,13 @@ List<String> normalizeInquiryArgs(List<String> args) {

/// Configures the CLI, registers all commands, and dispatches [args].
///
/// Returns a process exit code.
Future<int> runInquiry(List<String> 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<int> runInquiry(
List<String> args, {
IOSink? stdout,
IOSink? stderr,
}) async {
final cli = ModularCli();

final assetsRoot = p.dirname(p.dirname(Platform.resolvedExecutable));
Expand Down Expand Up @@ -66,5 +70,5 @@ Future<int> runInquiry(List<String> args) async {
);
cli.module('issue', (m) => buildIssueModule(m, assets: assets));

return cli.run(normalizeInquiryArgs(args));
return cli.run(normalizeInquiryArgs(args), stdout: stdout, stderr: stderr);
}
5 changes: 4 additions & 1 deletion code/cli/lib/modules/ape/ape_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApeStateInput, ApeStateOutput>(
'state',
(req) => ApeStateCommand(ApeStateInput.fromCliRequest(req), assets: assets),
description: 'Show current APE sub-state and valid internal transitions',
params: ApeStateInput.params,
);

m.command<ApeTransitionInput, ApeTransitionOutput>(
'transition',
(req) => ApeTransitionCommand(ApeTransitionInput.fromCliRequest(req), assets: assets),
description: 'Execute APE internal transition by --event',
description: 'Execute an APE-internal transition',
params: ApeTransitionInput.params,
);
}
16 changes: 16 additions & 0 deletions code/cli/lib/modules/ape/commands/prompt.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ class ApePromptInput extends Input {
required this.workingDirectory,
});

static final List<CliParam> 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']),
Expand All @@ -42,6 +55,9 @@ class ApePromptInput extends Input {
);
}

@override
List<CliParam> get schemaFields => params;

@override
Map<String, dynamic> toJson() => {
if (name != null) 'name': name,
Expand Down
9 changes: 9 additions & 0 deletions code/cli/lib/modules/ape/commands/state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<CliParam> params = [];

@override
List<CliParam> get schemaFields => params;

@override
Map<String, dynamic> toJson() => {'workingDirectory': workingDirectory};
}
Expand Down
12 changes: 12 additions & 0 deletions code/cli/lib/modules/ape/commands/transition.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,25 @@ class ApeTransitionInput extends Input {

ApeTransitionInput({required this.event, required this.workingDirectory});

static final List<CliParam> 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']),
workingDirectory: Directory.current.path,
);
}

@override
List<CliParam> get schemaFields => params;

@override
Map<String, dynamic> toJson() => {
if (event != null) 'event': event,
Expand Down
9 changes: 9 additions & 0 deletions code/cli/lib/modules/fsm/commands/state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<CliParam> params = [];

@override
List<CliParam> get schemaFields => params;

@override
Map<String, dynamic> toJson() => {'workingDirectory': workingDirectory};
}
Expand Down
24 changes: 24 additions & 0 deletions code/cli/lib/modules/fsm/commands/transition.dart
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,27 @@ class StateTransitionInput extends Input {
required this.workingDirectory,
});

static final List<CliParam> 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']),
Expand All @@ -146,6 +167,9 @@ class StateTransitionInput extends Input {
);
}

@override
List<CliParam> get schemaFields => params;

@override
Map<String, dynamic> toJson() => {
'currentState': currentState,
Expand Down
4 changes: 3 additions & 1 deletion code/cli/lib/modules/fsm/fsm_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<StateTransitionInput, StateTransitionOutput>(
'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,
);
}
36 changes: 29 additions & 7 deletions code/cli/lib/modules/global/commands/doctor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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<CliParam> 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<CliParam> get schemaFields => params;

@override
Map<String, dynamic> toJson() => {'fix': fix};
Expand Down Expand Up @@ -419,13 +429,25 @@ class DoctorCommand implements Command<DoctorInput, DoctorOutput> {
);
}

/// 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<String> _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;
}
}

Expand Down
Loading
Loading