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
12 changes: 6 additions & 6 deletions instructions/defaults/planning.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ At `planning/read_context`, load context in this order:
- Split the plan into small ordered tasks, each independently understandable and small enough for one TDD loop.
- In a follow-up planning pass, existing completed task artifacts are history. Create new revision task IDs for new work; do not reuse a completed task ID.
4. `write_task_files`
- Call `planner_task_upsert` once per behavioral task with semantic fields only: task id, title, objective, scope, acceptance criteria, `requirements` (the exact `REQ-n` ids from `spec.json` this task discharges **or enables** — a setup/infrastructure task cites the requirement it is a prerequisite for, e.g. a "cargo init" task cites the `REQ-n` whose code it makes buildable; **only `REQ-n` ids belong here, never `CON-n` constraints or `ASM-n` assumptions** — the coverage gate at `consistency_check` names every requirement no task covers and every task that traces to nothing), and optional Local Contract Context fields. The wrapper creates `task.json`, `task.md`, and empty TDD lifecycle artifacts; do not write task JSON manually. Upsert is keyed by task id and **replaces the whole record** — there is no delete tool, so retire a redundant task by folding its scope into another, and when editing an existing task resupply every field (an omitted `requirements` is wiped back to orphan).
- Call `planner_task_upsert` once per behavioral task with semantic fields only: task id, title, objective, scope, acceptance criteria, `requirements` (the exact `REQ-n` ids from `spec.json` this task actually **discharges** — implements and proves; **only `REQ-n` ids belong here, never `CON-n` constraints or `ASM-n` assumptions**, and never a `REQ-n` the task merely enables), `dependsOn` (the taskIds this task builds on — foundations first), and optional Local Contract Context fields. A setup/infrastructure task that implements no requirement leaves `requirements` empty and is not orphan work: the task that discharges a requirement lists it under `dependsOn`. The coverage gate at `consistency_check` names every requirement no task discharges, and every task neither discharging a requirement nor depended on by one. The wrapper creates `task.json`, `task.md`, and empty TDD lifecycle artifacts; do not write task JSON manually. Upsert is keyed by task id and **replaces the whole record** — there is no delete tool, so retire a redundant task by folding its scope into another, and when editing an existing task resupply every field (an omitted `requirements` or `dependsOn` is wiped).
- Each `task.md` must state scope, acceptance criteria, expected files or symbols, dependency context, checks, and the relevant AGENTS.md chain when known.
- Before finishing this step, discharge the "Generated Artifacts" rule below: by default the plan carries a task that puts the toolchain's generated output under `.gitignore`.
- In a follow-up planning pass, call `planner_task_upsert` only for new or still-pending revision tasks. Completed task IDs are immutable audit history.
5. `verify_plan` — verify tasks are ordered, bounded, testable, and free of hidden broad work. Record decisions and remaining risks.
6. `consistency_check` — run the requirement-coverage gate with `planner_gate_check` (gate: `plan_coverage`), plus `planner_elenchus_check` for any interacting-constraint web. See "Consistency Check" below.
Expand All @@ -47,12 +48,11 @@ At `planning/read_context`, load context in this order:

## Generated Artifacts (keep them out of git)

Before writing task files, account for what this project's toolchain will generate that must NOT be tracked — build outputs, dependency/vendor directories, caches, coverage and test output, editor/tool scratch. Two failure modes to preempt, because either one makes `planner_git_inspect` and later commits swell with generated bulk instead of source:
Every toolchain emits output that must NOT be tracked — build outputs, dependency/vendor directories, caches, coverage and test output, editor/tool scratch. Untracked, it swells `planner_git_inspect` and every later commit with generated bulk instead of source, and buries the real diff.

- a fresh project whose `.gitignore` is missing or empty, so the first build/dependency step fills the worktree with untracked bulk;
- an existing project adopting a new tool or technology that emits an artifact its current `.gitignore` does not yet cover.
**Default action:** the plan carries a task that writes the correct `.gitignore` entries for what this project will generate, folded into the setup/scaffolding task that first produces the artifact when there is one, or a standalone `.gitignore` task otherwise. Do this whether the project is fresh (its `.gitignore` is missing or empty) or existing (it adopts a new tool that emits an artifact the current `.gitignore` does not yet cover). You already know the conventional artifact names for each ecosystem — apply them; there is no need to spell them out here.

If the relevant ignore rules are absent, add updating `.gitignore` as an explicit task (or fold it into the setup/scaffolding task that first produces the artifact), so git inspection and commits stay to source only. You already know the conventional artifact names for each ecosystemapply them; there is no need to spell them out here. If the repository already ignores everything the plan will generate, note that and move on.
**The only escape:** the repository already ignores everything the plan will generate. If so, state that in one line and move ondo not add a redundant task. Never skip this silently: either the plan has the ignore task, or you have written why it is unnecessary.

## Restrictions

Expand Down Expand Up @@ -89,7 +89,7 @@ If doubt remains, revise `plan.md` or task artifacts before entering execution.

## Consistency Check (elenchus)

At `consistency_check`, run the requirement-coverage gate: call `planner_gate_check` with `gate: "plan_coverage"`. It reads `spec.json` and every task's `requirements` list, compiles them into VRF deterministically, and runs the engine — you write NO program. The verdict is total: every in-scope requirement must be covered by at least one task (a dropped requirement is **named**), and every task must trace to at least one requirement (orphan work is **named**). Iterate until **CONSISTENT**: fix a gap in place with `planner_task_upsert` (add the missing `requirements` ids or a missing task), or de-scope a requirement through a recorded user decision, then re-run the gate. `planner_finish_step` refuses to advance while the latest plan_coverage run is not CONSISTENT or is stale (spec.json or a task's requirements changed after the pass).
At `consistency_check`, run the requirement-coverage gate: call `planner_gate_check` with `gate: "plan_coverage"`. It reads `spec.json` and every task's `requirements` and `dependsOn`, compiles them into VRF deterministically, and runs the engine — you write NO program. The verdict is total: every in-scope requirement must be discharged by at least one task (a dropped requirement is **named**), and every task must be justified — it either discharges a requirement or a discharging task (transitively) depends on it — else it is orphan work and is **named**. A dependency cycle is rejected too (dependsOn must form a DAG). Iterate until **CONSISTENT**: fix a gap in place with `planner_task_upsert` (add the missing `requirements` id, add a `dependsOn` so an infra task is justified by the task that needs it, or add a missing task), or de-scope a requirement through a recorded user decision, then re-run the gate. `planner_finish_step` refuses to advance while the latest plan_coverage run is not CONSISTENT or is stale (spec.json or a task's requirements/dependsOn changed after the pass).

On top of the mandatory coverage gate, when the plan itself has a web of interacting constraints — exclusive owners, ordering/dependency chains, mutually exclusive states — model it with a free-form `planner_elenchus_check` (`IMPORT "templates/plan-consistency.vrf"`, see the template header and the `pi-planner-elenchus` skill; the engine is a three-valued SAT checker over formal logic, no arithmetic — model quantities as named symbolic states, never numbers). A CONFLICT there is also a hard gate. Record every conclusion in `decisions.md`.

Expand Down
11 changes: 9 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,13 @@ const TASK_UPSERT_TOOL_PARAMETERS = {
type: "array",
items: { type: "string" },
description:
"Spec traceability: the REQ-n ids from spec.json this task discharges OR enables (a setup/infrastructure task cites the requirement it is a prerequisite for). Required in practice for plans with a spec — the plan_coverage gate names every requirement no task covers and every task that traces to nothing. Upsert REPLACES the whole task record: when editing an existing task, resupply this list or it is wiped back to orphan work.",
"Spec traceability: the REQ-n ids from spec.json this task actually DISCHARGES (implements and proves) — never a REQ it only enables. A setup/infrastructure task that implements no requirement leaves this empty and instead earns its place through `dependsOn`. The plan_coverage gate names every requirement no task discharges. Upsert REPLACES the whole task record: resupply this list when editing or it is wiped.",
},
dependsOn: {
type: "array",
items: { type: "string" },
description:
"Build-order dependencies: taskIds this task builds on (foundations first) — NOT REQ-n ids. This is how a non-discharging setup task avoids being orphan work: a task that discharges a requirement depends on it. The plan_coverage gate justifies a task if it discharges a requirement OR a discharging task (transitively) depends on it, and rejects a dependency cycle. Upsert REPLACES the whole record: resupply this list when editing.",
},
contractChain: {
type: "array",
Expand Down Expand Up @@ -3172,7 +3178,8 @@ function registerPlannerTools(
},
requirement: {
type: ["string", "null"],
description: "Optional REQ-n this behavior exercises.",
description:
'A SINGLE REQ-n id (a string, e.g. "REQ-1") this behavior exercises, or null — never an array. Every in-scope REQ-n this task owns must be cited by at least one behavior or the tdd_coverage gate BLOCKS; set it on the behavior that verifies that requirement.',
},
test: {
type: ["object", "null"],
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Runtime domain for planner stages, model-facing status, tool wrappers, timers, r
- `elenchus-tools.ts` → `planner_elenchus_check` (free-form, model-authored programs) + the shared `last-check.json` record (`ElenchusLastCheckRecord`, with `gate`/`sourceHash` for gate runs and a `repeat` counter that `writeElenchusLastCheck` increments on identical gate re-runs — the gate-thrash friction signal).
- `spec-tools.ts` → `planner_spec_submit`: validates and persists `spec.json`/renders `spec.md` via `storage/spec-store.ts`; snapshots the previous version to `spec.prev.json` (change-request audit trail).
- `gate-tools.ts` → `planner_gate_check` (`spec_consistency` | `plan_coverage` | `tdd_coverage`): loads durable artifacts, runs the matching deterministic compiler from `src/vrf/`, executes the engine, writes `coverage.md` sections + `last-check.json` (verdict + sha256 of the compiled source), and translates every machine gap into a concrete action or a ready-to-ask user question. Takes NO program — gate VRF is never hand-written (REQ-12).
- `behavior-tools.ts` → `planner_behavior_upsert`: the per-task behavior board (`storage/behavior-store.ts`), the `planned → red → green` test-first toggle ladder.
- `behavior-tools.ts` → `planner_behavior_upsert`: the per-task behavior board (`storage/behavior-store.ts`), the `planned → red → green` test-first toggle ladder. The write is a MERGE, so `computeBehaviorBoardNudges` guards the two silent traps: an identical resubmit is named a no-op (else the model loops on the same board), and any in-scope owned REQ no behavior cites is named on the write itself (the same gap `tdd_coverage` would later block on). Both are nudges in the success text, never blocks — the board is already saved.
- Hard gates live in `workflow-tools.ts` (`validateSpecGatePassed`, `validatePlanCoverageGatePassed`, `validateTddCoverageGatePassed`): a gate step only advances on a CONSISTENT run whose `sourceHash` still matches the artifact on disk; legacy plans/tasks without the artifact degrade gracefully (REQ-11).

**Reasoning fuel** — a tone-only nudge toward the engine where a real interacting-condition web is on the table, computed entirely from the planner's own artifacts and records (never the engine's report). See the `fuel-never-blocks` Stable Contract above.
Expand Down
128 changes: 128 additions & 0 deletions src/runtime/behavior-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import { SCHEMA_VERSION } from "../constants";
import type {
TaskBehavior,
TaskBehaviorsRecord,
} from "../storage/behavior-store";
import { computeBehaviorBoardNudges } from "./behavior-tools";

function behavior(
overrides: Partial<TaskBehavior> & { id: string },
): TaskBehavior {
return {
statement: `does ${overrides.id}`,
kind: "happy",
requirement: null,
test: null,
branches: [],
status: "planned",
...overrides,
};
}

function board(behaviors: TaskBehavior[]): TaskBehaviorsRecord {
return { schemaVersion: SCHEMA_VERSION, taskId: "setup-project", behaviors };
}

describe("computeBehaviorBoardNudges", () => {
it("flags a byte-identical resubmit as a no-op", () => {
const record = board([behavior({ id: "BHV-1" })]);
const previous = board([behavior({ id: "BHV-1" })]);
const lines = computeBehaviorBoardNudges({
previous,
record,
ownedRequirements: [],
coverableRequirements: null,
});
expect(lines.join("\n")).toContain("No change");
});

it("stays silent when the board actually changed", () => {
const previous = board([behavior({ id: "BHV-1", status: "planned" })]);
const record = board([
behavior({
id: "BHV-1",
status: "red",
test: { file: "t.rs", name: "t" },
}),
]);
const lines = computeBehaviorBoardNudges({
previous,
record,
ownedRequirements: [],
coverableRequirements: null,
});
expect(lines.join("\n")).not.toContain("No change");
});

it("names an owned in-scope requirement no behavior cites", () => {
const record = board([behavior({ id: "BHV-1" })]);
const lines = computeBehaviorBoardNudges({
previous: null,
record,
ownedRequirements: ["REQ-1"],
coverableRequirements: new Set(["REQ-1"]),
}).join("\n");
expect(lines).toContain("Owned requirements no behavior cites yet: REQ-1");
expect(lines).toContain('"requirement": "REQ-1"');
});

it("stays silent once the owned requirement is cited", () => {
const record = board([behavior({ id: "BHV-1", requirement: "REQ-1" })]);
const lines = computeBehaviorBoardNudges({
previous: null,
record,
ownedRequirements: ["REQ-1"],
coverableRequirements: new Set(["REQ-1"]),
});
expect(lines.join("\n")).not.toContain("Owned requirements");
});

it("ignores an owned requirement the spec no longer counts as coverable", () => {
const record = board([behavior({ id: "BHV-1" })]);
const lines = computeBehaviorBoardNudges({
previous: null,
record,
ownedRequirements: ["REQ-9"],
// REQ-9 is owned by the task but out of scope / deferred in the spec.
coverableRequirements: new Set(["REQ-1"]),
});
expect(lines.join("\n")).not.toContain("Owned requirements");
});

it("never nudges about requirements for a legacy plan without a spec", () => {
const record = board([behavior({ id: "BHV-1" })]);
const lines = computeBehaviorBoardNudges({
previous: null,
record,
ownedRequirements: ["REQ-1"],
coverableRequirements: null,
});
expect(lines.join("\n")).not.toContain("Owned requirements");
});

it("surfaces BOTH traps on the identical-resubmit loop the session hit", () => {
// setup-project owns REQ-1, no behavior cites it, and the model resubmits
// the same two-green board — exactly the 13-call loop from the live run.
const twoGreen = [
behavior({
id: "BHV-1",
status: "green",
test: { file: "tests/setup.rs", name: "test_cargo_toml_exists" },
}),
behavior({
id: "BHV-2",
status: "green",
test: { file: "tests/setup.rs", name: "test_source_files_exist" },
}),
];
const lines = computeBehaviorBoardNudges({
previous: board(twoGreen.map((b) => ({ ...b }))),
record: board(twoGreen.map((b) => ({ ...b }))),
ownedRequirements: ["REQ-1"],
coverableRequirements: new Set(["REQ-1"]),
}).join("\n");
expect(lines).toContain("No change");
expect(lines).toContain("Owned requirements no behavior cites yet: REQ-1");
});
});
Loading