From 945f4bf46e33b1fbbe590d61f472a777d179b902 Mon Sep 17 00:00:00 2001 From: Decebal Dobrica <352761+decebal@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:54:32 +0000 Subject: [PATCH] feat(skills): add harness lifecycle skills and digest-bound spec gate The Harness Edition proposal needs an entry router across seven stages, a Spec stage reviewed by a reader without the author's context, a human approval before work is planned, and a Reflect stage. allstack already installs skills into both hosts, so each piece ships as a bundled skill from METHODS: allstack-harness, allstack-spec, allstack-reflect and allstack-investigate. allstack spec gives the Spec stage a mechanism: - review runs a read-only host process that receives only the draft path and repository root, and writes a receipt under .allstack/spec/reviews. Receipt names carry a zero-padded nanosecond timestamp so the newest clean receipt wins by name order. - approve records an event binding the design's SHA-256 digest to the approver and to a clean receipt for that digest. A findings or unknown verdict is refused; --without-review is the explicit override. - check exits nonzero with a reason (unapproved, changed since approval, unreviewed) unless the latest approval matches the current digest. It takes the run coordinator lock. claude-beads calls it before cutting beads. Approval events live in the AllSource store under a per-design entity, separate from run history. hosts.rs shares its read-only argv and final-text parsing with the review path; invoke behaviour is unchanged. Operation errors now carry the child's own message. The supervisor reported every failure as "failed or exceeded its deadline" with the child's JSON error nested inside; a timeout and a refusal now read differently. Docs: docs/concepts/agentic-harness.md glossary, the evaluation report docs/reports/2026-11-07-harness-edition-evaluation.md, and index, README, native-skills and CLI reference updates. Verified: cargo test --locked (94 passed, 2 ignored needing real Chronis), cargo clippy --all-targets --locked -D warnings, cargo fmt --check. A scratch repository ran init --skills-only (11 skills into both hosts) and a real Claude review: findings blocked approve, a clean re-review allowed it, check passed, and an edit after approval made check fail. --- README.md | 4 + docs/README.md | 6 + docs/concepts/agentic-harness.md | 35 +++ docs/reference/cli.md | 14 ++ docs/reference/native-skills.md | 6 + .../2026-11-07-harness-edition-evaluation.md | 140 ++++++++++++ skills/allstack-harness/SKILL.md | 42 ++++ skills/allstack-investigate/SKILL.md | 33 +++ skills/allstack-reflect/SKILL.md | 43 ++++ skills/allstack-spec/SKILL.md | 47 ++++ src/application/mod.rs | 1 + src/application/spec.rs | 26 +++ src/domain.rs | 52 +++++ src/infrastructure/hosts.rs | 52 +++-- src/infrastructure/mod.rs | 1 + src/infrastructure/skills.rs | 13 ++ src/infrastructure/spec.rs | 200 +++++++++++++++++ src/infrastructure/store.rs | 41 ++++ src/main.rs | 14 +- src/presentation.rs | 48 ++++- templates/native-skills.md | 4 + tests/native_skills.rs | 18 +- tests/spec.rs | 204 ++++++++++++++++++ 23 files changed, 1015 insertions(+), 29 deletions(-) create mode 100644 docs/concepts/agentic-harness.md create mode 100644 docs/reports/2026-11-07-harness-edition-evaluation.md create mode 100644 skills/allstack-harness/SKILL.md create mode 100644 skills/allstack-investigate/SKILL.md create mode 100644 skills/allstack-reflect/SKILL.md create mode 100644 skills/allstack-spec/SKILL.md create mode 100644 src/application/spec.rs create mode 100644 src/infrastructure/spec.rs create mode 100644 tests/spec.rs diff --git a/README.md b/README.md index 85fef16..1da4796 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,10 @@ Then invoke a skill by name. Codex accepts `$allstack-plan`; Claude Code accepts | `allstack-ship` | Verify and publish the authorized change through managed delivery. | | `allstack-recall` | Recover relevant project knowledge and inspect its sources. | | `allstack-handoff` | Explicitly continue an active delivery in another host. | +| `allstack-harness` | Name the current lifecycle stage (Think through Reflect) and load the next skill. | +| `allstack-spec` | Draft a design, obtain a fresh-context review, and record a digest-bound approval. | +| `allstack-investigate` | Diagnose a reported defect from evidence and produce a repair plan. | +| `allstack-reflect` | Write a retrospective and flag approved designs whose code moved since approval. | Skills-only setup needs no Rust project, Chronis executable, task, or delivery run. Independent shaping, planning, review, and QA use the native host's tools and permissions. Shared original methods live in [skills/](skills); Rust packages them for both hosts with capability overlays. `allstack skills` inspects the bundled interface without opening stores. diff --git a/docs/README.md b/docs/README.md index 8ff52ea..2addaca 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,9 @@ # Documentation +## Concepts + +- [Agentic harness glossary](concepts/agentic-harness.md) — harness, skills, guards, spec review and approval, ledger, verdicts and confidence tiers mapped to Allstack concepts. + ## Design - [2026-10-28: Native runtime evidence and Prime control values](plans/2026-10-28-native-runtime-evidence-design.md) — installed-runtime identity, final artifact qualification, archived memory recovery and the next real AllSource delivery. @@ -20,6 +24,8 @@ ## Reference +- [Harness Edition evaluation: gap matrix and build plan](reports/2026-11-07-harness-edition-evaluation.md) — proposal compared with Allstack, decebal-claude-skills and local host configuration; what this change builds and what remains open. + - [Native runtime evidence qualification](reports/2026-10-28-native-runtime-evidence-verification.md) — installed methods, independent C0-value regression, real Codex/Claude handoffs, final artifacts and archived Prime recovery. - [Native runtime evidence request](reference/native-runtime-evidence-request.md) — accepted method changes, packaging and real AllSource qualification. diff --git a/docs/concepts/agentic-harness.md b/docs/concepts/agentic-harness.md new file mode 100644 index 0000000..903d61d --- /dev/null +++ b/docs/concepts/agentic-harness.md @@ -0,0 +1,35 @@ +# Agentic harness glossary + +This glossary maps the terms used in the harness proposal to the concepts that exist in Allstack today, and says where a term has no implementation yet. The lifecycle runs Think, Spec, Plan, Build, Review, Ship, Reflect. + +| Term | Meaning here | Allstack concept | Status | +| --- | --- | --- | --- | +| Harness | The set of skills, hooks, and CLI commands that carry one change through the lifecycle | `allstack init --skills-only` plus the managed delivery commands | Implemented for skills and delivery. Guard hooks live outside this repository. | +| Skill / role | One stage or specialist, invoked by name in Codex or Claude Code | `skills/allstack-*/SKILL.md`, registered in `METHODS` in `src/infrastructure/skills.rs` | Implemented for 11 skills | +| Rule / guard | A deterministic check that denies or allows a tool call, independent of the model | Repository gates in `workflows/*.toml`; host hooks are configured outside Allstack | Gates implemented. Host hook installation is not part of Allstack. | +| Elephant | The drafting session that writes a design and resolves review findings | `allstack-spec` skill, which writes `docs/plans/YYYY-MM-DD--design.md` | Implemented as a skill | +| Goldfish | A fresh-context reviewer that sees only the draft path and the repository root | `allstack spec review --host codex\|claude`, which starts a read-only host process | Implemented. The reviewer host runs in a new process with no caller conversation. | +| PRD | A product requirements document for a planned change | `docs/plans/` design documents, plus the external `claude-prd` skill | Partly implemented. Allstack has no PRD template. | +| Bead / epic | A tracker task and its parent grouping | Chronis (`cn`) tasks and epics, read through `cn ready --toon` | Delivery claims a single task per run. Epic-level bridging is not implemented. | +| Ledger | The append-only record of decisions, events, and receipts | AllSource run history under `.allstack/events`, approval facts in the same store | Implemented for run history and spec approvals | +| Kernel / ISA | A deterministic layer that admits or refuses typed operations | Not implemented. The `semantic-isa` skill describes the design; no kernel exists in this repository. | Water. Requires a spike on one agent. | +| Verdict | The reviewer's explicit outcome: `clean`, `findings`, or `unknown` | `VERDICT:` line parsed by `application::spec::verdict` | Implemented. A missing verdict is `unknown`, which is never treated as clean. | +| Evidence report | A retained record of gate outcomes and their digests | `allstack evidence ` for gate receipts; reports under `docs/reports/` | Implemented for managed runs | +| Confidence (rock / sand / water) | How well the work is understood, not how large it is | Used in design documents and reports to classify open risks | Documentation convention. Rock means bet it, sand means shape it first, water means spike it first. | + +## Approval binding + +A design is approved for one exact byte sequence. `allstack spec approve` records the SHA-256 digest of the file, the approver name, and the review receipt that covered that digest. `allstack spec check` exits 0 only while the latest approval's digest equals the file's current digest and the cited receipt still exists and names the same digest. Any edit changes the digest, so the approval stops matching and the design must be reviewed again. + +`--without-review` records an approval with no receipt. It exists for an explicit override and is visible in the approval record. + +## Stage ownership + +- Think: `allstack-shape` +- Spec: `allstack-spec`, with `allstack spec review`, `approve`, and `check` +- Plan: `allstack-plan`. The external `claude-beads` skill is expected to run `allstack spec check` before it cuts beads. Allstack does not enforce that call. +- Build and Ship: `allstack-ship` through managed delivery +- Review: `allstack-review` and `allstack-qa` +- Reflect: `allstack-reflect` +- Routing: `allstack-harness` names the current stage and loads the next skill +- Diagnosis: `allstack-investigate`, which may skip Think and Spec for a reproduced defect diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 208d4a4..6c8249a 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -24,6 +24,20 @@ Run `allstack -C /path/to/target-repository doctor` (add `--policy ` for a `init --check` returns `ready: false` with planned paths, digests, conflicts, and missing prerequisites when setup cannot be applied. Existing Chronis engine readback is explicitly deferred until apply; opening its engine could write storage metadata. Check-mode readiness covers resource and executable preflight, with that readback still outstanding. Applying failed preflight stops before writing managed resources or initializing Chronis. Symlinks, traversal, reserved policy destinations, and nonregular/multiply-linked files are refused. File creation cannot replace a destination introduced after preflight. Index/ignore updates append bytes after comparing the original contents. Concurrent edits can require inspection and a rerun. +## Spec review and approval + +`spec` commands run from the repository root and take a repository-relative Markdown path. They open only the AllSource store under `.allstack/`; they create no run, task, or Prime store. + +```sh +allstack -C /path/to/repository spec review docs/plans/change-design.md --host codex +allstack -C /path/to/repository spec approve docs/plans/change-design.md --by "Approver Name" +allstack -C /path/to/repository spec check docs/plans/change-design.md +``` + +- `review` starts a read-only host process (`codex exec --sandbox read-only` or `claude --print` with Read, Glob, and Grep only) with a 240-second deadline. Its prompt contains the draft path and repository root. The receipt is written under `.allstack/spec/reviews/-.json`, and the JSON output includes its `receipt` path and `verdict` (`clean`, `findings`, or `unknown`). A failed host exits nonzero and writes no receipt. +- `approve` records the file's SHA-256 digest, the approver, and the matching receipt path. It refuses when no receipt exists for the current digest. `--without-review` records an explicit override with no receipt. Approval requires a receipt whose verdict is `clean`; `findings` and `unknown` receipts are refused. `--without-review` is the only override. Approval history per design holds at most 1000 events; a further approval fails with `incomplete or oversized fact history`. +- `check` exits 0 only when the latest approval's digest equals the current digest and its receipt, if one was cited, still exists, names that digest, and has verdict `clean`. Otherwise it exits nonzero with the reason: `unapproved`, `changed since approval`, or `unreviewed`. `check` takes the run coordinator lock and fails with `run coordinator busy` during a managed run. When `.allstack` exists it may create store files in it. + ## Workflow selection Use `allstack catalog` to list repository workflows or `allstack catalog bugfix` to inspect evidence-led diagnosis and repair. Pass `start --workflow bugfix` to select it; the default remains `delivery`. See the [catalog reference](workflow-catalog.md) for contracts and additive initialization. Catalog discovery opens no mutable run store. Existing repositories without a catalog and retained runs without selection metadata preserve legacy delivery behavior. diff --git a/docs/reference/native-skills.md b/docs/reference/native-skills.md index abaa1e9..0774701 100644 --- a/docs/reference/native-skills.md +++ b/docs/reference/native-skills.md @@ -4,6 +4,12 @@ Start with the [installed guide](../../templates/native-skills.md), which is bun Original methods are versioned under [skills](../../skills). The installer packages identical method bodies for both hosts and adds a host-specific capability note. Native methods use the host's current tools and permissions. Existing managed plan/build calls retain their stricter tool scope; their restrictions do not apply to an independent native invocation. +## Spec gate + +`allstack-harness` names the lifecycle stage and loads the next skill. `allstack-spec` drafts a design under `docs/plans/`, and `allstack spec review --host codex|claude` obtains a read-only review from a fresh host process. That process receives only the draft path and repository root. `allstack spec approve --by ` records an approval bound to the file's SHA-256 digest, and `allstack spec check ` exits 0 only while the latest approval still matches the file. Approval refuses a digest without a review receipt unless `--without-review` is passed. The approval store is the AllSource store under `.allstack/`. Receipts live under `.allstack/spec/reviews/`. Terms are listed in the [agentic harness glossary](../concepts/agentic-harness.md). + +`allstack-investigate` diagnoses a reported defect, and `allstack-reflect` writes a retrospective and flags approved designs whose named code moved after approval. + ## Durable services AllSource continues to validate managed-run event chains and exact-commit receipts. Chronis owns delivery claims and completion. Prime retains source-linked context. Standalone `remember` records a bounded summary plus the digest of an existing Markdown document under docs, then reads the actual Prime node back. Repeated identical writes reuse the binding. Source changes create a distinct historical binding rather than silently replacing earlier evidence. diff --git a/docs/reports/2026-11-07-harness-edition-evaluation.md b/docs/reports/2026-11-07-harness-edition-evaluation.md new file mode 100644 index 0000000..4bc4ab6 --- /dev/null +++ b/docs/reports/2026-11-07-harness-edition-evaluation.md @@ -0,0 +1,140 @@ +# Harness Edition evaluation: gap matrix and build plan + +Date: 2026-11-07. Scope: the Harness Edition proposal compared with the Allstack repository, the decebal-claude-skills checkout, and the local host configuration. This report records what was checked, what was built in this repository, and what remains open. + +## Headline + +The proposal says the `pr-guard` hook is not wired. On the machine where this was checked, `claude-guard pr-guard` is wired in `~/.claude/settings.json`, and all five claude-guard hooks are wired. The real gaps are the template repository, a stale local checkout, skill drift, and the missing spec gate. The approval-before-beads loop was also a real gap: nothing in code enforced it. This repository now provides the spec gate and the lifecycle skills. Guard hooks, the `skillsync` installer, and the beads-side enforcement are not in this repository. + +## 1. Gap matrix + +Status is one of `have`, `part`, `gap`, `conflict`, `water`, or `unverified`. Evidence cites file and line where the check was made. The `Home` column names the repository that owns the fix. + +| Proposal item | Status | Evidence | Home | +| --- | --- | --- | --- | +| Seven stages, one skill each | part | allstack `skills/` held shape, plan, review, qa, ship, recall, handoff. No Think, Spec, or Reflect names. | both | +| Think: brainstorm to concepts_brief.md | part | decebal `skills/brainstorming`. allstack-shape writes an indexed brief with no `concepts_brief.md` name (shape SKILL.md:27). | neither | +| Spec: design draft plus Goldfish review | gap | No egm-* skills in allstack, `~/.claude/skills`, or `~/.codex/skills`. Plan review was same-context (allstack-plan SKILL.md:8, 12-25). | allstack | +| Plan: claude-prd to claude-beads to cn | part | claude-prd and claude-beads exist in decebal (symlinked). allstack-plan creates no cn task (SKILL.md:8, 33). | both | +| Build: cn ready loop or cos spawn | part | allstack-delivery through the allstack CLI (hosts/codex/SKILL.md:12-20). No standalone cn-ready skill. | allstack | +| Review: prove-it and security-review, fresh context | part | allstack-review had no fresh-context rule (SKILL.md:8, 21). security-review lives in decebal. | allstack | +| Ship: pr-proof, one PR one commit, pr-guard | part | allstack-ship single-PR reuse (SKILL.md:8, 22). pr-proof was HTML-only through a `~/.agents/skills` symlink. pr-guard wired (settings.json:135). | allstack | +| Reflect: brief, scorecard, drift report | gap | No retro or learn skill in allstack skills or METHODS (skills.rs:5-25). allstack-recall covers recall only. | allstack | +| File hand-offs between stages | part | Docs and Prime memory, no typed manifest. allstack hardcodes `docs/plans/allstack-{run.id}.md` (application/mod.rs:184). | allstack | +| Entry skill `harness` routing stages | gap | No harness skill in either repository or installed directory. | both | +| gstack: office-hours, plan-ceo-review, autoplan | part / part / gap | Installed gstack symlinks only. decebal has no plan-ceo-review or autoplan directory. | decebal | +| gstack: plan-eng-review, review, ship | have | allstack-plan:12-33, allstack-review, allstack-ship, plus gstack symlinks. | allstack | +| gstack: investigate | part | `workflows/methods/diagnose.md` was not in METHODS (skills.rs had seven entries). decebal has systematic-debugging. | allstack | +| gstack: qa and browse | part | allstack-qa named no browser driver (SKILL.md:8, 16, 28). Driver choice is open. | allstack | +| gstack: cso | part | decebal security-review. allstack had no security method. | both | +| gstack: careful, guard, freeze | have | Hook-bound at settings.json:115 (infra-guard). | decebal | +| gstack: retro and learn | gap | Same as the Reflect row. | allstack | +| gstack: captain | gap | No match in the gstack install or in allstack. The mapping to cos is unverified. | none | +| Glossary `docs/concepts/agentic-harness.md` | gap | Grep for concepts/agentic-harness, Elephant, and Goldfish returned no hits in allstack or decebal. | both | +| Confidence scale rock/sand/water | have | allstack-shape SKILL.md:25. | allstack | +| semantic-isa for agent specs | part | `~/.claude/skills/semantic-isa` (115 lines, design and write only). Not vendored in decebal. | decebal | +| Human approval gate before beads | gap | accept_plan set Building with no approval input (application/mod.rs:54-61). claude-prd had no sign-off step. claude-beads said human actions never block (SKILL.md:29-52). | both | +| Fresh-context review, Claude Code | part | A spawned process gives freshness (hosts.rs:51-73). No hook checks subagent identity. claude-guard source grep returned no match. | both | +| Fresh-context review, Codex | part | `codex exec --sandbox read-only` (hosts.rs:33-48). `~/.codex/hooks.json` has no agent matcher. | both | +| Guard surface, Claude | have | settings.json:115, 125, 135, 145, 189. | decebal | +| Guard surface, Codex | have on trunk, part in source | `~/.codex/hooks.json` `^Bash$` runs codex-guard pr-guard. The pr-guard source for codex-guard exists only on `feat/codex-parity`. | decebal | +| claude-guard source has pr-guard | have on origin/main, part locally | origin/main commit 0514154 adds `pr_guard.rs`. The local checkout on `feat/prompt-id` lacks it (main.rs:39-45). | decebal | +| Template configs lack pr-guard | gap | `configs/settings.json` and the `hooks/README.md` install block do not list pr-guard. | decebal | +| prompt-number matcher | gap | settings.json:141 uses `Write\|Edit`. prompt_number.rs:92 also handles MultiEdit. | both | +| Prod-guard tokens | gap | `~/.claude/prod-guard-tokens.txt` still holds the template value (`prod:my-company-prod`), so the prod layer matches nothing. | allstack (doctor) | +| Fail-open policy table | have, partial | infra/mod.rs:144-160 has Allow, Ask, and Deny. prompt-number denies (prompt_number.rs:90-99). The proposal's claim that every guard fails open is wrong. | decebal | +| Sandbox kit wires guards | gap | `docker-sandbox-team-kit/base/claude/settings.json` has no claude-guard entry. | both | +| Idempotent hook installer | gap | No hook writer in allstack. decebal installs by hand (hooks/README.md:31-38). | decebal | +| Process ownership | have | allstack process.rs:51-68, 106-114, 116-161. | allstack | +| Build lock | have | allstack build_lock.rs:10-53 and runtime.rs:356-359. | allstack | +| Gate runner | part | gates.rs is a parser only. runtime.rs:353-392 is the runner. | allstack | +| cn task syntax in claude-beads | drift | Repository SKILL.md uses `cn create` (lines 225 and others) and `cn sync --flush-only` (line 432). `cn create --help` returned "unrecognized subcommand 'create'". The installed copy uses `cn task create`. | decebal | +| claude-beads installed copy | drift | `~/.claude/skills/claude-beads/SKILL.md` sha 3b524961, repository sha fe5a014b. claude-prd is a symlink whose sha 686f48d4 matches. | decebal | +| Dangling claude-cn-* symlinks | unverified | Symlinks exist under `~/.claude/skills`. Target existence was not checked. | decebal | +| Installer for allstack skills, both hosts | have | skills.rs:47-63 emits `.agents/skills` and `.claude/skills`. bootstrap.rs:280-300 checks and applies with conflict reporting. README.md:23-24. | allstack | +| Per-repo install versus global | gap | allstack install is per repository. The global `~/.claude` path is separate. | both | +| Chronis adopt, start, current | part | presentation.rs:68, 82, 244. `current` reads the run store, not cn. | allstack | +| Team retro from cn history | water | No event query in `cn --help`. `cn list --all --format json` is valid (`cn list --help`). | decebal | +| cos: judge, --check, --scope, verdict | part | Installed binary help lists `judge`, `--check`, `--scope`, and `scorecard`. Source has none (grep returned no match). | neither | +| cos: COS_AGENT_SLOTS | part | String present in the installed binary only. No slot logic in source. | neither | +| cos: spend cap and deadline | have | agents/harness.rs:28 `--max-budget-usd`. agents/mod.rs:31 CEILING 2h. | neither | +| cos agents: three versus one brief | conflict | main ships three agents. `feat/cos-dashboard-from-upstream` was not read. | neither | +| Installed skill and agent drift | gap | `~/.claude/agents/strategist.md` and `tactician.md` hardcode a local chief-of-staff path. | decebal | +| Browser driver for qa in sbx | water | sbx is not installed. The cos sandbox is Seatbelt, not sbx. | neither | +| EGM install, plugin or vendor | gap | `claude plugin marketplace add google/elephant-goldfish-model` (harness.txt:277). EGM was not fetched. | both | + +## 2. Build plan + +Each entry names its confidence tier and what would move it up. Tiers are rock (bet it), sand (shape it first), and water (spike it first). + +### Installer path: one command per repository for both hosts + +- Extend `init --skills-only` to ship the harness skills. Both hosts already come from one METHODS list. Add `harness`, `spec`, `reflect`, and `investigate`. Confidence: rock for the mechanism, sand for the skill content. Moves up after one real feature run through the harness. +- Add `allstack doctor` checks as read-only probes. The probes would run each guard subcommand from settings.json and hooks.json with stdin closed and fail on usage text. They would fail when prod-guard-tokens.txt equals the template, and check `cn ` tokens in SKILL.md against `cn --help`. Confidence: rock, because every check is a pure read. +- User-level hooks and global skills: a Rust binary in decebal, named `skillsync`, with `check`, `install --dry-run`, and `doctor`. It would merge guard entries into settings.json and hooks.json idempotently by command string, with a backup, and compare sha values between source and `~/.claude/skills` and `~/.agents/skills`. Confidence: sand. The Codex hooks.state trust-hash behavior is unverified. Moves up when a test round-trips both files. + +### Spec layer + +- Skills `allstack-spec` (this repository) and the EGM skills in decebal. Confidence: sand. Moves up when EGM's reviewer configuration and its five review stages are read. Upstream was not fetched. +- `allstack spec review`, a subcommand that starts a read-only host process with only the draft path and repository root. Confidence: rock for the spawn, which reuses the host argv in `hosts.rs`. Water for proving the reviewer has no author context. No verified signal on either host shows the author context. Spike: whether Claude Code exposes agent identity to hooks, and whether Codex does. +- An approval record bound to the design digest, checked by `spec check`. Confidence: sand. The gap itself is rock. The digest binding is a design choice, recorded in the approval semantics above. +- Enforcement in claude-beads: it must call `allstack spec check` before it cuts beads, and a claude-guard PreToolUse deny when the approval is missing. Confidence: sand. Not in this repository. +- Glossary `docs/concepts/agentic-harness.md`, indexed in the same change. Confidence: rock. Moves up with one peer review session. + +### Ledger layer + +- Fix claude-beads: port the installed copy's `cn task create` form into the repository copy, and add a lint that checks each `cn ` against `cn --help`. Confidence: rock. +- Bridge allstack to epics: read ready beads rather than one task per run (chronis.rs:119-155). Confidence: sand. Allstack's lifecycle is run-level today. +- Team retro: spike only. Confidence: water. Moves up with a measured listing of events across the three local `.chronis` stores through cn. The path flag is absent, and cn is bound to the working directory. + +### Guards layer + +- Add pr-guard to `configs/settings.json` and the `hooks/README.md` install block. Change the prompt-number matcher to `Write|Edit|MultiEdit`. Add a deny, ask, and allow matrix. Confidence: rock. +- Merge or rebase origin/main into the decebal checkout so `pr_guard.rs` is present locally. Confidence: rock, verified by the merge-base check. The merge-base check had no printed output in the first run, and the exit code was taken from a second run. +- Wire claude-guard into the sandbox kit `base/`. Confidence: sand. Sbx was not run and egress was not tested. Moves up with `sbx kit validate` and one sandbox run. +- A kernel that admits or refuses semantic-isa operations. Confidence: water. Spike one agent first. + +### cos layer + +- Land judge, --check, --scope, and verdict in source, matching the installed binary's help text. Confidence: sand. The judge behavior was never run, and source and binary may differ. +- Merge or close `feat/cos-dashboard-from-upstream`. Confidence: rock. Owner decision. + +### Distribution + +- Skill-sync check covering sha, symlink versus copy, and dangling links. Confidence: rock. +- Vendor or plugin decision for EGM and the club skills. Confidence: sand. A local directory marketplace works without network access through `extraKnownMarketplaces` in settings.json. + +### Ordered first bet + +The smallest set of rocks that makes the install verifiable end to end. The harness router and the EGM spec gate remain sand and stay outside this set. + +1. Merge origin/main into the decebal checkout and confirm `pr_guard.rs` is present locally. +2. Add pr-guard to `configs/settings.json` and `hooks/README.md`, and fix the prompt-number matcher. +3. Fix the claude-beads `cn` syntax in the repository copy and add the `cn --help` lint. +4. Add `skillsync check`, or the allstack doctor equivalent: sha comparison, dangling links, and the template token check. +5. Land the glossary, indexed. + +Acceptance for this set: `skillsync check` and `allstack doctor` both exit 0 on the machine, `claude-guard pr-guard` exits 0 with stdin closed, and the claude-beads lint passes. + +## 3. What this repository now implements + +- Skills `allstack-harness`, `allstack-spec`, `allstack-reflect`, and `allstack-investigate`, registered in METHODS. `allstack init --skills-only` installs them into `.agents/skills` and `.claude/skills`. Skill count went from seven to eleven. +- `allstack spec review [--host codex|claude]`. It starts a read-only host process whose prompt contains only the draft path and repository root. It writes a receipt under `.allstack/spec/reviews/` and prints the receipt path, digest, verdict, and findings. A host failure exits nonzero and writes no receipt. +- `allstack spec approve --by [--without-review]`. It records the digest, approver, and receipt path as an event in the AllSource store. Without `--without-review` it refuses when no receipt exists for the current digest. +- `allstack spec check `. It exits 0 only when the latest approval matches the current digest and its receipt, if any, still exists. Otherwise it exits nonzero with `unapproved`, `changed since approval`, or `unreviewed`. +- Tests in `tests/spec.rs` use a fake `codex` executable on `PATH`. They cover receipt creation, the reviewer prompt contents, approval binding, edit invalidation, refusal without review, receipt removal, and host failure. + +## 4. Not checked + +- The claude-web-club repository is absent from this machine. Proposal items about it were judged from the proposal text only. +- EGM and gstack were not fetched. The gstack install was checked by grep only, and no SKILL.md body was read. +- Not read: claude-prd and claude-beads bodies (checked by sha only), security-review, systematic-debugging, the installed semantic-isa body, and codex-prd. +- The fake-host tests do not exercise a real Codex or Claude Code process. The reviewer's read-only sandbox and its absence of author context were not observed on a real host. +- Claude Code subagent identity in PreToolUse hooks: no documentation was read. +- Codex `~/.codex/hooks.json` contents were read by a verifier, not in this session. Hook output schema and trust-hash behavior were not verified. +- cos source was checked by grep only. The installed judge and scorecard behavior was never run. COS_AGENT_SLOTS was not verified as live. A machine-wide grep for COS_AGENT_SLOTS timed out at 120 seconds in the verifier run and did not complete. +- Sbx is not installed. The sandbox kit was read as files only. +- Prod-guard-tokens.txt state came from a verifier read, not a re-read. +- The merge-base check on commit 0514154 printed no output in the first run. Its exit status was taken from a second run. +- `cargo deny check` was not run in this session. No dependency changed, so the lock file is unchanged. +- The `skillsync` binary, the claude-beads enforcement, the claude-guard deny rule for missing approvals, and the glossary's Plan-stage enforcement are outside this repository and were not built here. diff --git a/skills/allstack-harness/SKILL.md b/skills/allstack-harness/SKILL.md new file mode 100644 index 0000000..f5f19da --- /dev/null +++ b/skills/allstack-harness/SKILL.md @@ -0,0 +1,42 @@ +--- +name: allstack-harness +description: Name the current lifecycle stage of a change (Think, Spec, Plan, Build, Review, Ship, Reflect) from read-only repository state and load the next specialist skill. +--- + +# Route the lifecycle stage + +Route only. This skill names the stage, records skipped stages, and loads the next skill by name. It does not design, review, implement, commit, or publish. Read repository policy before acting. + +## Inspect state read-only + +Collect these facts before naming a stage. Run them one per call. Every command reads repository state except `allstack spec check`, which takes the run coordinator lock. + +- Branch and liveness: `git branch --show-current`, then `gh pr list --head --state all --json number,state,title`. A `MERGED` branch is dead and starts a new branch. +- Active managed run: `allstack current`. A returned run names its stage; use `allstack status ` for detail. +- Tracker: `cn ready --toon` for unblocked beads. Use `cn list --all --toon` only when the answer depends on completed work. +- Design documents: list `docs/plans/*-design.md` for the change. For each one that matters, run `allstack spec check `. Exit 0 means the current digest is approved. Exit 1 gives the reason: `unapproved`, `changed since approval`, or `unreviewed`. +- Shape and plan documents: check the indexed `docs/plans/` entries named by the request. + +Absence of state is a fact to record, not a reason to guess. A tool that cannot run leaves its stage unverified. + +## Name the stage + +| Stage | Named when | Next skill | +| --- | --- | --- | +| Think | No demand, scope, or alternatives are written for the request | `allstack-shape` | +| Spec | A design is needed and no approved design matches the current digest | `allstack-spec` | +| Plan | An approved design exists and no beads or accepted plan cover it | `allstack-plan`, then `claude-prd` and `claude-beads` | +| Build | Beads are ready on a live branch and implementation is not yet verified | `allstack-ship` (managed delivery through `start`, `plan`, `build`, `verify`) | +| Review | Implementation exists on the branch and has no review receipt | `allstack-review`, then `allstack-qa` | +| Ship | Verification passed and no open PR exists for the branch | `allstack-ship` | +| Reflect | The run shipped or the PR merged | `allstack-reflect` | + +The Plan stage must not cut beads for a design whose `allstack spec check` does not exit 0. `claude-beads` is expected to run that check first; this repository does not enforce it. If the check fails, report the reason instead of working around it. + +## Bug fixes skip Spec + +A defect with a reproduction is a bug fix. Record `Think` and `Spec` as skipped with the reason, then route to `allstack-investigate` for diagnosis and to the `bugfix` catalog workflow for durable delivery. A defect that changes intended behavior is a feature change and returns to Spec. + +## Report + +Return one short block: the branch and liveness fact, the named stage, each skipped stage with its reason, the single next skill, and any state that could not be read. Load the next skill only after this block is written. Do not load a later stage in the same turn. diff --git a/skills/allstack-investigate/SKILL.md b/skills/allstack-investigate/SKILL.md new file mode 100644 index 0000000..6d56066 --- /dev/null +++ b/skills/allstack-investigate/SKILL.md @@ -0,0 +1,33 @@ +--- +name: allstack-investigate +description: Diagnose a reported defect or regression from repository evidence, name the responsible layer, and produce a repair plan with regression tests, without editing code. +--- + +# Investigate the defect + +Diagnose the supplied defect from evidence. Read applicable repository instructions, the request, the relevant code, and any retained failure output. Open recalled sources before treating them as requirements. + +## Establish the facts + +Read `workflows/methods/diagnose.md` first and follow its steps. Identify observed behavior, expected behavior, and the reproduction inputs. Name the exact failed condition. A model's explanation is not reproduction evidence. If required evidence is absent, name it and leave the diagnosis unproven. + +Trace the value or state from input through each layer to the output. Find the producer that first creates the wrong value. For data-driven behavior, read the published data contract before proposing an engine change, and distinguish a missing contract from a runtime that violates an existing one. + +Confirm which condition actually failed. A guard with several conjuncts needs the runtime value of each conjunct, not the code path alone. + +## Produce the repair plan + +The final response contains: + +- The complete affected user flow, from trigger to observed symptom. +- Concrete source locations (`path:line`) for the producer and each consumer. +- The evidence supporting the cause, with the command or retained output that showed it. +- The smallest responsible repair, and why a larger one is not needed. +- Regression tests that fail for the observed defect and pass after the repair. +- The final checks to run before shipping. + +Label each check as executed or proposed. Do not claim a check passed when it did not run. + +## Boundaries + +Do not edit files, create branches, commit, push, publish, send messages, or spawn agents. The durable path is the `bugfix` catalog workflow (`allstack start --workflow bugfix`), which indexes this plan and runs verification. Write the plan as prose that an incoming host can act on without this conversation. diff --git a/skills/allstack-reflect/SKILL.md b/skills/allstack-reflect/SKILL.md new file mode 100644 index 0000000..d67be4b --- /dev/null +++ b/skills/allstack-reflect/SKILL.md @@ -0,0 +1,43 @@ +--- +name: allstack-reflect +description: Write a retrospective from tracker history, run evidence, and recalled decisions, and flag approved designs whose code has moved since approval. +--- + +# Reflect on delivery + +Read evidence, then write one report. Do not edit product code, close tasks, merge PRs, or change approvals. A reflection that cannot read a source states that gap instead of inferring the result. + +## Gather evidence + +Run each read one per call: + +- Tracker history: `cn list --all --toon`. The output can be large; save it to the scratchpad and search it rather than reading it whole. +- Managed runs: `allstack current` for an active run, and `allstack evidence ` for gate receipts of a shipped or completed run. +- Prior context: `allstack recall `. Open each cited source before relying on it. + +## Drift check + +For each design under `docs/plans/*-design.md`: + +1. Run `allstack spec check `. Record the gate reason. +2. Find the design's last commit with `git log -1 --format=%H -- `. +3. List commits since that point that touch the paths the design names, with `git log --oneline ..HEAD -- ...`. +4. Flag a design whose named code moved after its approval. Give the commits and the paths. A design with no approval is reported as unapproved, not as drift. + +## Write the report + +Write `docs/reports/YYYY-MM-DD-.md` and add it to `docs/README.md` in the same change. Include: + +- Facts: runs, PRs, and task counts with their sources. +- What worked and what failed, each with its evidence. +- Drift flags, each with its commits. +- Gaps: any source that was unreadable or not checked. +- Decisions to keep, as a list the next session can act on. + +Use plain engineering prose. Give numbers, paths, and commands rather than adjectives. Do not assign blame to a person. + +Retain the accepted decisions with `allstack remember --kind decision --source --summary ""`. Retain review results with `--kind review`. Memory is context only; it does not authorize a change. + +## Report + +Return the report path, the drift flags, and the list of unreadable sources. diff --git a/skills/allstack-spec/SKILL.md b/skills/allstack-spec/SKILL.md new file mode 100644 index 0000000..de98cf1 --- /dev/null +++ b/skills/allstack-spec/SKILL.md @@ -0,0 +1,47 @@ +--- +name: allstack-spec +description: Draft a design document, obtain a fresh-context host review, resolve findings, and record a human approval bound to the design's sha256 digest. +--- + +# Specify the design + +The drafting session is the author. A review by `allstack spec review` runs in a separate host process that sees only the draft path and the repository root. Approval belongs to a human. Do not approve on your own judgment. + +## Draft + +Write `docs/plans/YYYY-MM-DD--design.md` in this repository's docs layout. Do not create a `docs/designs/` directory. Add the document to `docs/README.md` in the same change. + +Cover the complete job a person completes when the change works, its trigger, inputs, outputs, and failure behavior. State non-goals. Name the observable acceptance checks. Mark each open risk with a confidence tier: rock (bet it), sand (shape it first), or water (spike it first). Do not write time estimates. + +When the design is an agent or tool-using system, frame it with semantic-isa: classify each environment-altering operation into a typed instruction, state the governance property the kernel enforces, name the typed bindings, and trace taint from untrusted inputs to every sink. Verification clears taint only through a named check. + +## Obtain a fresh-context review + +Run the review from the repository root: + +```sh +allstack -C spec review docs/plans/-design.md --host codex +``` + +Use `--host claude` for Claude Code. The command prints JSON with `receipt`, `digest`, `verdict`, and `findings`. Read the findings from the receipt path. A nonzero exit means the host failed; report it and do not treat the design as reviewed. + +## Resolve and loop + +Resolve each finding by changing the draft, or by stating in the draft why the finding does not apply, with the evidence. Any edit changes the digest, so run `spec review` again on the new bytes. Repeat until the verdict is `clean`. A `findings` or `unknown` verdict is not clean. + +## Ask the human + +When the verdict is clean, show the human the design path, its digest, the review receipt path, and any open findings you rejected with reasons. Ask for an explicit yes. Only on that yes run: + +```sh +allstack -C spec approve docs/plans/-design.md --by "" +allstack -C spec check docs/plans/-design.md +``` + +`check` must exit 0 before the Plan stage starts. Pass `--without-review` only when the human asks for that override in those words. Record the override in the report. + +Any later edit invalidates the approval. `spec check` then reports `changed since approval`; return to review. + +## Report + +Return the design path, its digest, the review verdict and receipt path, the approval result or the explicit reason none was recorded, and the rejected findings with their evidence. diff --git a/src/application/mod.rs b/src/application/mod.rs index 52baeb7..ae12593 100644 --- a/src/application/mod.rs +++ b/src/application/mod.rs @@ -1,4 +1,5 @@ pub mod ports; +pub mod spec; use crate::domain::{Host, Policy, Run, Stage}; use anyhow::{Context, Result, ensure}; use ports::{Platform, Start}; diff --git a/src/application/spec.rs b/src/application/spec.rs new file mode 100644 index 0000000..3d4265c --- /dev/null +++ b/src/application/spec.rs @@ -0,0 +1,26 @@ +//! Pure rules for design review and approval. I/O lives in the infrastructure layer. + +/// The reviewer receives the draft path and repository root only, never the author's conversation. +pub fn review_prompt(path: &str, root: &str) -> String { + format!( + "You are an independent design reviewer with no prior conversation. Review the design document at `{path}` in the repository at `{root}`. Read that document and the repository code it names; do not read anything it does not point to. Do not edit files or run commands that change state. Report each defect as: severity, location, consequence, and evidence. End with exactly one line: `VERDICT: clean` when no defect remains, otherwise `VERDICT: findings`." + ) +} + +/// Returns `unknown` unless the reviewer wrote an explicit verdict line. +pub fn verdict(text: &str) -> &'static str { + let mut verdict = "unknown"; + for line in text.lines().map(str::trim) { + match line { + "VERDICT: clean" => verdict = "clean", + "VERDICT: findings" => verdict = "findings", + _ => {} + } + } + verdict +} + +pub fn approver_valid(name: &str) -> bool { + let name = name.trim(); + !name.is_empty() && name.len() <= 128 && !name.chars().any(char::is_control) +} diff --git a/src/domain.rs b/src/domain.rs index ce1b26b..f0c76ca 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -310,3 +310,55 @@ pub fn validate_transition(previous: Option<&Run>, event: &Event) -> Result<(), _ => Err("missing, duplicated, or invalid event sequence".into()), } } + +/// A recorded human decision to accept one exact design digest. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpecApproval { + pub schema: u8, + pub path: String, + pub digest: String, + pub approver: String, + pub review: Option, + pub without_review: bool, + pub sequence: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpecGate { + Approved, + Unapproved, + Changed, + Unreviewed, +} + +impl SpecGate { + pub fn reason(self) -> &'static str { + match self { + Self::Approved => "approved", + Self::Unapproved => "unapproved", + Self::Changed => "changed since approval", + Self::Unreviewed => "unreviewed", + } + } +} + +/// `review_ok` is false when the approval cites a review receipt that is missing or bound to another digest. +pub fn spec_gate(latest: Option<&SpecApproval>, digest: &str, review_ok: bool) -> SpecGate { + match latest { + None => SpecGate::Unapproved, + Some(approval) if approval.digest != digest => SpecGate::Changed, + Some(_) if !review_ok => SpecGate::Unreviewed, + Some(_) => SpecGate::Approved, + } +} + +/// Design paths are repository-relative Markdown files with no parent or hidden components. +pub fn spec_path_valid(path: &str) -> bool { + !path.is_empty() + && path.ends_with(".md") + && !path.starts_with('/') + && path + .split('/') + .all(|part| !part.is_empty() && part != ".." && !part.starts_with('.')) +} diff --git a/src/infrastructure/hosts.rs b/src/infrastructure/hosts.rs index 33bc530..c756198 100644 --- a/src/infrastructure/hosts.rs +++ b/src/infrastructure/hosts.rs @@ -11,7 +11,6 @@ pub fn invoke( prompt: &str, planning: bool, ) -> Result { - let claude_build_tools = "Read,Glob,Grep,Edit,Write,Bash(git diff *),Bash(git status *),Bash(git add -- *),Bash(rtk git diff *),Bash(rtk git status *),Bash(rtk git add -- *),Bash(shasum -a 256 *)"; let prompt = format!( "Managed Allstack run {}. Host {}, epoch {}, revision {}. Chronis task {} already claimed and read back by coordinator for this host. Active checkpoint {}. This detached worktree is intentional; original repository branch is {}. Do not change task ownership or run state yourself. Recover source context from the indexed plan; no prior conversation is assumed.\n\n{}", run.id, @@ -30,7 +29,30 @@ pub fn invoke( } else { prompt }; - let argv: Vec = match run.host { + let outcome = process::run(Spec { + argv: argv(run.host, planning), + cwd: (&run.workspace).into(), + seconds: policy.command_seconds.min(290), + environment: BTreeMap::new(), + input: Some(prompt), + directory: state.join("process"), + })?; + ensure!( + outcome.success(), + "host failed, timed out, or lost owner; inspect private process evidence and reconcile: {}", + outcome.stderr + ); + final_text(run.host, &outcome.stdout) +} + +/// Argv for a host run with read-only tools and no edit or shell authority. +pub fn read_only_argv(host: Host) -> Vec { + argv(host, true) +} + +fn argv(host: Host, planning: bool) -> Vec { + let claude_build_tools = "Read,Glob,Grep,Edit,Write,Bash(git diff *),Bash(git status *),Bash(git add -- *),Bash(rtk git diff *),Bash(rtk git status *),Bash(rtk git add -- *),Bash(shasum -a 256 *)"; + match host { Host::Codex => vec![ "codex", "exec", @@ -71,23 +93,14 @@ pub fn invoke( .into_iter() .map(String::from) .collect(), - }; - let outcome = process::run(Spec { - argv, - cwd: (&run.workspace).into(), - seconds: policy.command_seconds.min(290), - environment: BTreeMap::new(), - input: Some(prompt), - directory: state.join("process"), - })?; - ensure!( - outcome.success(), - "host failed, timed out, or lost owner; inspect private process evidence and reconcile: {}", - outcome.stderr - ); - match run.host { + } +} + +/// Extracts the final assistant text from a successful host run's stdout. +pub fn final_text(host: Host, stdout: &str) -> Result { + match host { Host::ClaudeCode => { - let value: Value = serde_json::from_str(outcome.stdout.trim()) + let value: Value = serde_json::from_str(stdout.trim()) .context("invalid Claude result; operation remains unresolved")?; ensure!( value["is_error"] == false, @@ -107,8 +120,7 @@ pub fn invoke( .to_string()) } Host::Codex => { - let events: Vec = outcome - .stdout + let events: Vec = stdout .lines() .map(serde_json::from_str) .collect::>()?; diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index 740b8b2..eb41225 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -13,6 +13,7 @@ pub mod memory; pub mod process; pub mod runtime; pub mod skills; +pub mod spec; pub mod store; pub mod workspace; diff --git a/src/infrastructure/skills.rs b/src/infrastructure/skills.rs index 4f56ff2..b2945a2 100644 --- a/src/infrastructure/skills.rs +++ b/src/infrastructure/skills.rs @@ -22,6 +22,19 @@ const METHODS: &[(&str, &str)] = &[ "handoff", include_str!("../../skills/allstack-handoff/SKILL.md"), ), + ( + "harness", + include_str!("../../skills/allstack-harness/SKILL.md"), + ), + ("spec", include_str!("../../skills/allstack-spec/SKILL.md")), + ( + "reflect", + include_str!("../../skills/allstack-reflect/SKILL.md"), + ), + ( + "investigate", + include_str!("../../skills/allstack-investigate/SKILL.md"), + ), ]; pub fn method(name: &str) -> Result<&'static str> { diff --git a/src/infrastructure/spec.rs b/src/infrastructure/spec.rs new file mode 100644 index 0000000..2ed6432 --- /dev/null +++ b/src/infrastructure/spec.rs @@ -0,0 +1,200 @@ +//! Design review receipts and digest-bound approvals for the harness spec gate. +use super::{atomic_write, bootstrap_fs::Root, digest, hosts, private_dir, process, store::Store}; +use crate::{ + application::spec as rules, + domain::{Host, SpecApproval, SpecGate, spec_gate, spec_path_valid}, +}; +use anyhow::{Context, Result, ensure}; +use serde_json::{Value, json}; +use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +const APPROVAL: &str = "allstack.spec.v1.approval"; + +fn entity(path: &str) -> String { + format!("allstack-spec-{}", digest(path.as_bytes())) +} + +fn reviews(repo: &Path) -> PathBuf { + repo.join(".allstack/spec/reviews") +} + +fn draft(repo: &Path, path: &str) -> Result { + ensure!( + spec_path_valid(path), + "design path must be a relative Markdown path without parent or hidden components" + ); + let bytes = Root::new(repo)? + .read(path)? + .with_context(|| format!("design missing: {path}"))?; + Ok(digest(&bytes)) +} + +fn receipt_valid(receipt: &Path, path: &str, design: &str) -> bool { + fs::read(receipt) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .is_some_and(|value| { + value["path"].as_str() == Some(path) + && value["digest"].as_str() == Some(design) + && value["verdict"].as_str() == Some("clean") + }) +} + +fn latest_receipt(repo: &Path, path: &str, design: &str) -> Result> { + let dir = reviews(repo); + if !dir.is_dir() { + return Ok(None); + } + let prefix = format!("{design}-"); + let mut found = fs::read_dir(&dir)? + .map(|entry| entry.map(|e| e.path())) + .collect::>>()?; + found.retain(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with(&prefix)) + }); + found.sort(); + Ok(found + .into_iter() + .rev() + .find(|p| receipt_valid(p, path, design))) +} + +pub async fn review(repo: &Path, path: &str, host: Host) -> Result { + let design = draft(repo, path)?; + let outcome = process::run(process::Spec { + argv: hosts::read_only_argv(host), + cwd: repo.into(), + seconds: 240, + environment: BTreeMap::new(), + input: Some(rules::review_prompt(path, &repo.to_string_lossy())), + directory: repo.join(".allstack/process"), + })?; + ensure!( + outcome.success(), + "review host failed, timed out, or lost owner; inspect private process evidence: {}", + outcome.stderr + ); + let findings = hosts::final_text(host, &outcome.stdout)?; + let verdict = rules::verdict(&findings); + let dir = reviews(repo); + private_dir(&dir)?; + // Zero-padded nanoseconds make lexical filename order match creation order. + let stamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let receipt = dir.join(format!( + "{design}-{stamp:020}-{}.json", + uuid::Uuid::new_v4() + )); + let record = json!({ + "schema": 1, + "path": path, + "digest": design, + "host": host.name(), + "verdict": verdict, + "findings": findings, + }); + atomic_write(&receipt, &serde_json::to_vec_pretty(&record)?)?; + Ok(json!({ + "receipt": receipt, + "path": path, + "digest": design, + "host": host.name(), + "verdict": verdict, + "findings": findings, + })) +} + +pub async fn approve( + repo: &Path, + path: &str, + approver: &str, + without_review: bool, +) -> Result { + ensure!( + rules::approver_valid(approver), + "approver must be 1..128 printable characters" + ); + let design = draft(repo, path)?; + let review = if without_review { + None + } else { + let receipt = latest_receipt(repo, path, &design)?.with_context(|| { + "no clean review receipt for the current digest; run allstack spec review until clean, or pass --without-review" + })?; + Some(receipt.to_string_lossy().into_owned()) + }; + let store = Store::open(&repo.join(".allstack")).await?; + let result = async { + let sequence = store.facts(&entity(path), APPROVAL).await?.len() as u64 + 1; + let approval = SpecApproval { + schema: 1, + path: path.into(), + digest: design.clone(), + approver: approver.trim().into(), + review: review.clone(), + without_review, + sequence, + }; + store + .record_fact(&entity(path), APPROVAL, serde_json::to_value(&approval)?) + .await?; + Ok::<_, anyhow::Error>(approval) + } + .await; + let shutdown = store.shutdown().await; + let approval = result?; + shutdown?; + Ok(json!({ + "approved": true, + "path": approval.path, + "digest": approval.digest, + "approver": approval.approver, + "review": approval.review, + "without_review": approval.without_review, + "sequence": approval.sequence, + })) +} + +pub async fn check(repo: &Path, path: &str) -> Result { + let design = draft(repo, path)?; + let latest = if repo.join(".allstack").exists() { + let store = Store::open(&repo.join(".allstack")).await?; + let facts = store.facts(&entity(path), APPROVAL).await; + let shutdown = store.shutdown().await; + let facts = facts?; + shutdown?; + facts + .into_iter() + .map(serde_json::from_value::) + .collect::, _>>()? + .into_iter() + .filter(|a| a.path == path) + .max_by_key(|a| a.sequence) + } else { + None + }; + let review_ok = match latest.as_ref().and_then(|a| a.review.as_deref()) { + None => true, + Some(receipt) => receipt_valid(Path::new(receipt), path, &design), + }; + let gate: SpecGate = spec_gate(latest.as_ref(), &design, review_ok); + ensure!( + gate == SpecGate::Approved, + "spec {path} is {}", + gate.reason() + ); + let approval = latest.context("approved gate without approval")?; + Ok(json!({ + "approved": true, + "path": path, + "digest": design, + "approver": approval.approver, + "sequence": approval.sequence, + })) +} diff --git a/src/infrastructure/store.rs b/src/infrastructure/store.rs index afa0b89..e322209 100644 --- a/src/infrastructure/store.rs +++ b/src/infrastructure/store.rs @@ -165,6 +165,47 @@ impl Store { Ok(()) } + /// Appends a non-run fact under its own entity and reads it back before returning. + pub async fn record_fact( + &self, + entity: &str, + event_type: &str, + payload: serde_json::Value, + ) -> Result<()> { + self.core + .ingest(IngestEvent { + entity_id: entity, + event_type, + payload: payload.clone(), + metadata: None, + tenant_id: None, + }) + .await + .context("fact append uncertain; inspect before retry")?; + ensure!( + self.facts(entity, event_type).await?.contains(&payload), + "fact append acknowledgement mismatch" + ); + Ok(()) + } + + pub async fn facts(&self, entity: &str, event_type: &str) -> Result> { + let page = self + .core + .query_page(Query::new().entity_id(entity).limit(1001)) + .await?; + ensure!( + !page.has_more && page.total_count == page.events.len() && page.events.len() <= 1000, + "incomplete or oversized fact history" + ); + Ok(page + .events + .into_iter() + .filter(|raw| raw.entity_id == entity && raw.event_type == event_type) + .map(|raw| raw.payload) + .collect()) + } + pub async fn shutdown(&self) -> Result<()> { self.core.shutdown().await?; Ok(()) diff --git a/src/main.rs b/src/main.rs index aba69c0..eb70450 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,11 +56,15 @@ async fn main() { .map(|s| s.0.join("process")) .unwrap_or_else(|| repo.join(".allstack/process")), })?; - anyhow::ensure!( - output.success(), - "operation failed or exceeded its deadline: {}", - output.stderr - ); + anyhow::ensure!(!output.timed_out, "operation exceeded its 290s deadline"); + if !output.success() { + let reported = serde_json::from_str::(&output.stderr) + .ok() + .and_then(|v| v.get("error")?.as_str().map(str::to_owned)); + anyhow::bail!( + reported.unwrap_or(format!("operation failed: {}", output.stderr)) + ); + } Ok(serde_json::from_str(&output.stdout)?) })() } diff --git a/src/presentation.rs b/src/presentation.rs index 1af723c..bd0a46b 100644 --- a/src/presentation.rs +++ b/src/presentation.rs @@ -151,10 +151,54 @@ pub enum Action { Recall { query: String, }, + /// Review a design document in a fresh host process and record a digest-bound approval. + Spec { + #[command(subcommand)] + action: SpecAction, + }, #[command(name = "__supervise", hide = true)] Supervise, } +#[derive(Subcommand)] +pub enum SpecAction { + /// Run a read-only host review that sees only the design path and repository root. + Review { + path: String, + #[arg(long, value_parser = ["codex", "claude"], default_value = "codex")] + host: String, + }, + /// Approve the current digest of a design after a matching review receipt exists. + Approve { + path: String, + #[arg(long)] + by: String, + #[arg(long)] + without_review: bool, + }, + /// Exit nonzero unless the latest approval matches the design's current digest. + Check { path: String }, +} + +async fn spec_command(repo: &std::path::Path, action: &SpecAction) -> Result { + use crate::infrastructure::spec; + match action { + SpecAction::Review { path, host } => { + let host = match host.as_str() { + "codex" => Host::Codex, + _ => Host::ClaudeCode, + }; + spec::review(repo, path, host).await + } + SpecAction::Approve { + path, + by, + without_review, + } => spec::approve(repo, path, by, *without_review).await, + SpecAction::Check { path } => spec::check(repo, path).await, + } +} + pub async fn execute(cli: Cli) -> Result { if let Action::Skills { skill } = &cli.command { return skills::inspect(skill.as_deref()); @@ -196,6 +240,7 @@ pub async fn execute(cli: Cli) -> Result { Action::Recall { query } => { return crate::infrastructure::knowledge::recall(&repo, query).await; } + Action::Spec { action } => return spec_command(&repo, action).await, Action::Doctor { policy } => return bootstrap::doctor(&repo, policy), Action::Catalog { workflow } => return catalog::inspect(&repo, workflow.as_deref()), _ => {} @@ -216,7 +261,8 @@ async fn dispatch(p: &Runtime, action: Action) -> Result { | Action::Catalog { .. } | Action::Skills { .. } | Action::Remember { .. } - | Action::Recall { .. } => { + | Action::Recall { .. } + | Action::Spec { .. } => { unreachable!("setup commands dispatch before opening run storage") } Action::Start { diff --git a/templates/native-skills.md b/templates/native-skills.md index 11ed4f7..409d217 100644 --- a/templates/native-skills.md +++ b/templates/native-skills.md @@ -11,6 +11,10 @@ Invoke skills directly in your coding host. Codex accepts `$allstack-shape` and | `allstack-ship` | Verify and publish an authorized change through managed delivery. | | `allstack-recall` | Recover relevant decisions and inspect their sources. | | `allstack-handoff` | Explicitly continue an active delivery in another coding host. | +| `allstack-harness` | Name the current lifecycle stage and load the next skill. | +| `allstack-spec` | Draft a design, obtain a fresh-context review, and record a digest-bound approval. | +| `allstack-investigate` | Diagnose a reported defect from evidence and produce a repair plan. | +| `allstack-reflect` | Write a retrospective and flag approved designs whose code moved. | Install just this interface with `allstack init --skills-only --check`, then `allstack init --skills-only`. No Rust project, Chronis executable, task, delivery run, or Prime store is needed for installation or independent shaping, planning, review, and QA. Existing managed installation also includes these skills. Conflicting files are preserved and reported before writes. Review initialized files before committing them. diff --git a/tests/native_skills.rs b/tests/native_skills.rs index 5d9a602..e42d7c3 100644 --- a/tests/native_skills.rs +++ b/tests/native_skills.rs @@ -43,7 +43,19 @@ fn installs_native_hosts_without_project_profile_or_stores_and_preserves_conflic assert!(!dir.path().join(".agents").exists()); bootstrap::install_skills(dir.path(), false).unwrap(); for host in [".agents", ".claude"] { - for id in ["shape", "plan", "review", "qa", "ship", "recall", "handoff"] { + for id in [ + "shape", + "plan", + "review", + "qa", + "ship", + "recall", + "handoff", + "harness", + "spec", + "reflect", + "investigate", + ] { let path = dir .path() .join(format!("{host}/skills/allstack-{id}/SKILL.md")); @@ -151,7 +163,7 @@ async fn empty_recall_and_discovery_leave_repository_untouched() { .as_array() .unwrap() .len(), - 7 + 11 ); let dir = repository(); assert_eq!( @@ -159,7 +171,7 @@ async fn empty_recall_and_discovery_leave_repository_untouched() { .as_array() .unwrap() .len(), - 7 + 11 ); assert!(skills::inspect(Some("invented")).is_err()); assert_eq!( diff --git a/tests/spec.rs b/tests/spec.rs new file mode 100644 index 0000000..ae5c0ca --- /dev/null +++ b/tests/spec.rs @@ -0,0 +1,204 @@ +use serde_json::Value; +use std::{fs, os::unix::fs::PermissionsExt, path::PathBuf, process::Command}; + +const DESIGN: &str = "docs/plans/2026-11-07-harness-design.md"; +const PASS: &str = "#!/bin/sh\ncat > CAPTURE\nprintf '%s\\n' '{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"- none\\nVERDICT: clean\"}}' '{\"type\":\"turn.completed\"}'\n"; +const FAIL: &str = "#!/bin/sh\ncat >/dev/null\nexit 3\n"; +const NO_VERDICT: &str = "#!/bin/sh\ncat > CAPTURE\nprintf '%s\\n' '{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"- gap\"}}' '{\"type\":\"turn.completed\"}'\n"; +/// Reports `VERDICT: findings` while the `CAPTURE.findings` marker file exists, `clean` otherwise. +const MIXED: &str = r#"#!/bin/sh +cat > CAPTURE +if [ -f CAPTURE.findings ]; then verdict=findings; else verdict=clean; fi +printf '%s\n' "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"- gap\\nVERDICT: $verdict\"}}" '{"type":"turn.completed"}' +"#; + +struct Fixture { + tmp: tempfile::TempDir, + repo: PathBuf, + path: String, +} + +impl Fixture { + /// The fake `codex` executable replaces the real host on PATH; it captures the review prompt. + fn new(host_script: &str) -> Self { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repository"); + fs::create_dir(&repo).unwrap(); + assert!( + Command::new("git") + .args(["init", "--quiet"]) + .current_dir(&repo) + .status() + .unwrap() + .success() + ); + fs::create_dir_all(repo.join("docs/plans")).unwrap(); + fs::write(repo.join(DESIGN), "# Design\n\nFirst draft.\n").unwrap(); + let bin = tmp.path().join("bin"); + fs::create_dir(&bin).unwrap(); + let capture = tmp.path().join("prompt.txt"); + let script = host_script.replace("CAPTURE", &capture.display().to_string()); + let codex = bin.join("codex"); + fs::write(&codex, script).unwrap(); + fs::set_permissions(&codex, fs::Permissions::from_mode(0o755)).unwrap(); + let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap()); + Self { tmp, repo, path } + } + + fn captured_prompt(&self) -> String { + fs::read_to_string(self.tmp.path().join("prompt.txt")).unwrap() + } + + fn run(&self, args: &[&str]) -> (bool, String, String) { + let timeout = if cfg!(target_os = "macos") { + "gtimeout" + } else { + "timeout" + }; + let output = Command::new(timeout) + .args(["-k", "5", "90", env!("CARGO_BIN_EXE_allstack"), "-C"]) + .arg(&self.repo) + .args(args) + .env("PATH", &self.path) + .output() + .unwrap(); + ( + output.status.success(), + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + ) + } + + fn json(&self, args: &[&str]) -> Value { + let (ok, out, err) = self.run(args); + assert!(ok, "{err}"); + serde_json::from_str(&out).unwrap() + } +} + +fn receipt_of(review: &Value) -> PathBuf { + let receipt = PathBuf::from(review["receipt"].as_str().unwrap()); + assert!(receipt.is_file(), "{}", receipt.display()); + receipt +} + +#[test] +fn review_runs_fresh_host_and_approval_binds_the_digest() { + let f = Fixture::new(PASS); + let review = f.json(&["spec", "review", DESIGN, "--host", "codex"]); + assert_eq!(review["verdict"], "clean"); + receipt_of(&review); + let prompt = f.captured_prompt(); + assert!(prompt.contains(DESIGN), "{prompt}"); + assert!(prompt.contains("no prior conversation"), "{prompt}"); + assert!(prompt.len() < 2000, "reviewer prompt carries extra context"); + + let approved = f.json(&["spec", "approve", DESIGN, "--by", "reviewer"]); + assert_eq!(approved["without_review"], false); + assert_eq!(f.json(&["spec", "check", DESIGN])["approved"], true); + + fs::write(f.repo.join(DESIGN), "# Design\n\nSecond draft.\n").unwrap(); + let (ok, _, err) = f.run(&["spec", "check", DESIGN]); + assert!(!ok && err.contains("changed since approval"), "{err}"); +} + +#[test] +fn approval_without_review_is_refused_unless_overridden() { + let f = Fixture::new(PASS); + let (ok, _, err) = f.run(&["spec", "approve", DESIGN, "--by", "reviewer"]); + assert!(!ok && err.contains("no clean review receipt"), "{err}"); + let (ok, _, err) = f.run(&["spec", "check", DESIGN]); + assert!(!ok && err.contains("unapproved"), "{err}"); + + let approved = f.json(&[ + "spec", + "approve", + DESIGN, + "--by", + "reviewer", + "--without-review", + ]); + assert_eq!(approved["without_review"], true); + assert_eq!(f.json(&["spec", "check", DESIGN])["approved"], true); +} + +#[test] +fn approval_is_unreviewed_when_its_receipt_is_removed() { + let f = Fixture::new(PASS); + let review = f.json(&["spec", "review", DESIGN]); + f.json(&["spec", "approve", DESIGN, "--by", "reviewer"]); + fs::remove_file(receipt_of(&review)).unwrap(); + let (ok, _, err) = f.run(&["spec", "check", DESIGN]); + assert!(!ok && err.contains("unreviewed"), "{err}"); +} + +#[test] +fn failed_host_fails_the_review_and_writes_no_receipt() { + let f = Fixture::new(FAIL); + let (ok, _, err) = f.run(&["spec", "review", DESIGN]); + assert!(!ok && err.contains("review host failed"), "{err}"); + assert!(!f.repo.join(".allstack/spec/reviews").exists()); +} + +#[test] +fn approval_cites_newest_clean_receipt_and_ignores_findings() { + let f = Fixture::new(MIXED); + f.json(&["spec", "review", DESIGN]); + let newest_clean = f.json(&["spec", "review", DESIGN]); + fs::write(f.tmp.path().join("prompt.txt.findings"), "").unwrap(); + let findings = f.json(&["spec", "review", DESIGN]); + assert_eq!(findings["verdict"], "findings"); + let approved = f.json(&["spec", "approve", DESIGN, "--by", "reviewer"]); + assert_eq!(approved["review"], newest_clean["receipt"]); + assert_eq!(f.json(&["spec", "check", DESIGN])["approved"], true); +} + +#[test] +fn findings_or_unknown_verdict_refuses_approval() { + let f = Fixture::new(MIXED); + fs::write(f.tmp.path().join("prompt.txt.findings"), "").unwrap(); + f.json(&["spec", "review", DESIGN]); + let (ok, _, err) = f.run(&["spec", "approve", DESIGN, "--by", "reviewer"]); + assert!(!ok && err.contains("no clean review receipt"), "{err}"); + + let f = Fixture::new(NO_VERDICT); + let review = f.json(&["spec", "review", DESIGN]); + assert_eq!(review["verdict"], "unknown"); + let (ok, _, err) = f.run(&["spec", "approve", DESIGN, "--by", "reviewer"]); + assert!(!ok && err.contains("no clean review receipt"), "{err}"); +} + +#[test] +fn receipt_edited_to_findings_reports_unreviewed() { + let f = Fixture::new(PASS); + let review = f.json(&["spec", "review", DESIGN]); + f.json(&["spec", "approve", DESIGN, "--by", "reviewer"]); + let receipt = receipt_of(&review); + let edited = fs::read_to_string(&receipt) + .unwrap() + .replace("\"clean\"", "\"findings\""); + fs::write(&receipt, edited).unwrap(); + let (ok, _, err) = f.run(&["spec", "check", DESIGN]); + assert!(!ok && err.contains("unreviewed"), "{err}"); +} + +#[test] +fn design_path_approver_and_missing_file_are_refused() { + let f = Fixture::new(PASS); + for path in [ + "/abs/design.md", + "../design.md", + ".hidden/design.md", + "docs/plans/design.txt", + ] { + let (ok, _, err) = f.run(&["spec", "check", path]); + assert!( + !ok && err.contains("relative Markdown path"), + "{path}: {err}" + ); + } + let (ok, _, err) = f.run(&["spec", "approve", DESIGN, "--by", " "]); + assert!(!ok && err.contains("approver must be"), "{err}"); + let (ok, _, err) = f.run(&["spec", "check", "docs/plans/missing-design.md"]); + assert!(!ok && err.contains("design missing"), "{err}"); +}