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
9 changes: 9 additions & 0 deletions instructions/defaults/planning.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ At `planning/read_context`, load context in this order:
- If a task reveals additional required work, add or revise a task artifact during planning instead of silently expanding implementation scope.
- For a change request after a completed pass, use new revision task IDs such as `fix-storage-root-revision` instead of reopening completed task IDs.

## 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:

- 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.

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 ecosystem — apply 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.

## Restrictions

- Do not edit production files or write tests yet.
Expand Down
9 changes: 8 additions & 1 deletion src/guard/tool-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const PLANNER_WRAPPER_TOOLS = [
"planner_refactor_review",
"planner_doubt_review",
"planner_elenchus_check",
"planner_reason",
"planner_skill_create",
"planner_skill_update",
"planner_contract_scan",
Expand Down Expand Up @@ -141,6 +142,7 @@ const STEP_ALLOWED_TOOLS = {
"planner_contract_read",
"planner_contract_upsert",
"planner_elenchus_check",
"planner_reason",
"planner_git_commit",
"planner_exec",
],
Expand Down Expand Up @@ -187,6 +189,7 @@ const STEP_ALLOWED_TOOLS = {
consistency_check: [
"planner_gate_check",
"planner_elenchus_check",
"planner_reason",
"planner_task_upsert",
"planner_contract_route",
"planner_contract_read",
Expand All @@ -205,6 +208,7 @@ const STEP_ALLOWED_TOOLS = {
"planner_skill_create",
"planner_skill_update",
"planner_elenchus_check",
"planner_reason",
"planner_debug_strategy",
"planner_debug_probe",
"planner_debug_result",
Expand Down Expand Up @@ -264,6 +268,7 @@ const STEP_ALLOWED_TOOLS = {
"planner_contract_check",
"planner_contract_upsert",
"planner_elenchus_check",
"planner_reason",
"planner_report_stuck",
"planner_skill_create",
"planner_skill_update",
Expand Down Expand Up @@ -329,6 +334,7 @@ const STEP_ALLOWED_TOOLS = {
"planner_git_inspect",
"planner_doubt_review",
"planner_elenchus_check",
"planner_reason",
"planner_skill_create",
"planner_skill_update",
"planner_contract_route",
Expand Down Expand Up @@ -379,6 +385,7 @@ const STEP_ALLOWED_TOOLS = {
"planner_recovery_resume",
"planner_git_inspect",
"planner_elenchus_check",
"planner_reason",
],
},
} as const satisfies Record<
Expand Down Expand Up @@ -406,7 +413,7 @@ export function getAllowedPlannerWrapperTools(
// diagnosis tool (in-process engine, writes only planner artifacts) —
// the one broken-state step where it must stay reachable.
...(state.stage === "recovery" && state.step === "repair_or_resume"
? (["planner_elenchus_check"] as const)
? (["planner_elenchus_check", "planner_reason"] as const)
: []),
]);
}
Expand Down
5 changes: 3 additions & 2 deletions src/index.tool-visibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,9 @@ describe("filterPlannerTools", () => {
}
});

it("ALL_PLANNER_TOOL_NAMES has exactly 52 tools", () => {
expect(ALL_PLANNER_TOOL_NAMES).toHaveLength(52);
it("ALL_PLANNER_TOOL_NAMES has exactly 53 tools", () => {
expect(ALL_PLANNER_TOOL_NAMES).toHaveLength(53);
expect(ALL_PLANNER_TOOL_NAMES).toContain("planner_reason");
expect(ALL_PLANNER_TOOL_NAMES).toContain("planner_artifact_read");
expect(ALL_PLANNER_TOOL_NAMES).toContain("planner_report_stuck");
expect(ALL_PLANNER_TOOL_NAMES).toContain("planner_refactor_review");
Expand Down
138 changes: 115 additions & 23 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,10 @@ import {
PLANNER_QUESTION_TOOL_NAMES,
type PlannerQuestionToolName,
} from "./runtime/question-tools";
import {
executePlannerReasonTool,
PLANNER_REASON_TOOL_NAME,
} from "./runtime/reason-tools";
import {
executePlannerRecoveryReportTool,
executePlannerRecoveryTool,
Expand Down Expand Up @@ -209,6 +213,7 @@ import {
executePlannerSpecTool,
PLANNER_SPEC_TOOL_NAME,
} from "./runtime/spec-tools";
import { formatTransitionReasoningFuelTail } from "./runtime/status";
import {
buildPlannerStuckCompactInstructions,
executePlannerStuckTool,
Expand Down Expand Up @@ -2961,6 +2966,72 @@ function registerPlannerTools(
},
});

pi.registerTool({
name: PLANNER_REASON_TOOL_NAME,
label: "Planner Reason",
description:
"Grow and re-check the plan's living logical world with the bundled elenchus engine. Statements accumulate across stages: each assert adds facts, premises, or rules to a domain and re-checks the whole world against everything asserted before. Observations may name a source file to anchor to, so when that file later changes the knowledge demotes to a belief instead of raising a false contradiction. The engine's raw output is returned verbatim (CONSISTENT / WARNING / UNDERDETERMINED / CONFLICT and why); the tool never parses it. A reasoning-fuel line reports how much of the interacting-condition web on the table you have run through the engine.",
promptSnippet:
'At the reasoning steps, default to building the world with planner_reason instead of trusting a prose chain: mode=assert with a domain and statements (each a .vrf line; add anchor="path" for a file observation), mode=retract with ids to remove a statement, mode=recheck to re-run as-is. A CONFLICT hard-blocks planner_finish_step until a re-run improves it — apply the drop/flip the output names or retract the wrong statement, never delete a valid premise. When the decision genuinely has no interacting-constraint web, the fuel line stays silent and you may skip it. Available at the discovery scan, planning/consistency_check, execution/write_tdd_plan, execution/contract_check, finalize/doubt_review, and recovery/repair_or_resume.',
parameters: {
type: "object",
properties: {
mode: {
type: "string",
enum: ["assert", "retract", "recheck"],
description:
'"assert" adds statements to a world domain and re-checks; "retract" removes statements by id and re-checks; "recheck" re-runs the world as-is.',
},
domain: {
type: "string",
description:
'World domain for asserted statements: "discovery", "plan", "scratch", or "task_<slug>". Required when mode=assert.',
},
statements: {
type: "array",
items: {
type: "object",
properties: {
vrf: {
type: "string",
description:
"One elenchus statement (FACT/NOT/PREMISE/RULE/BELIEVES/SET/CLOSE/VAR), possibly multi-line. Instruments (PROVE/HENCE/TRY) are not stored.",
},
anchor: {
type: "string",
description:
"Optional project-root-relative file this observation is read from. The tool hashes it now; only FACT/NOT may carry an anchor.",
},
},
required: ["vrf"],
additionalProperties: false,
},
description:
"The statements to assert. Required and non-empty when mode=assert.",
},
ids: {
type: "array",
items: { type: "string" },
description:
'Statement ids to remove, e.g. ["w3","w7"]. Required and non-empty when mode=retract.',
},
},
required: ["mode"],
additionalProperties: false,
} as never,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { fs, projectPaths } = await resolveRuntimeContext(ctx.cwd);
await recordPlannerToolActivityForProject({ fs, projectPaths });
const result = await executePlannerReasonTool({
fs,
git: gitRunner,
projectPaths,
params,
});
return plannerToolResponse(result);
},
});

pi.registerTool({
name: PLANNER_SPEC_TOOL_NAME,
label: "Planner Spec Submit",
Expand Down Expand Up @@ -3310,11 +3381,27 @@ function registerPlannerTools(
transitionStatus: result.result.status,
});

// Ride the reasoning-fuel nudge on the transition tail so the model
// sees it in the drive loop (planner_finish_step), not only on a rare
// planner_status call. Computed for the step this transition lands on;
// self-silences when no interacting-condition web is warranted there.
// Presentational only — fuel enters no decision, and this seam calls
// the surfacing layer (status), never a fuel module, so the tone-only
// invariant holds.
const fuelLine = await formatTransitionReasoningFuelTail({
fs,
status: result.result.status,
planPaths: result.planPaths,
state: result.result.state,
});

return {
content: [
{
type: "text",
text: [result.text, compact?.text].filter(Boolean).join("\n\n"),
text: [result.text, compact?.text, fuelLine]
.filter(Boolean)
.join("\n\n"),
},
],
details: compact ? { ...result, compact } : result,
Expand Down Expand Up @@ -3460,15 +3547,13 @@ function registerPlannerTools(
}

const PLANNER_COMPACT_WIDGET_KEY = "planner-compact";
// Refresh cadence for the indicator's slow-moving parts (the asymptotic bar,
// percent, and ETA). The SDK's setWidget has NO diffing — every call disposes
// the old component and rebuilds it (interactive-mode.js `setExtensionWidget`),
// so each push is a full teardown+repaint. A 1 Hz push of a live seconds counter
// therefore flickers the banner every second. We keep the indicator nearly
// static instead: no per-second elapsed in the rendered line, a coarse refresh,
// and a content dedup (see `setCompactWidget`) that skips a push when nothing
// visible changed — so in steady state the widget repaints only when the bar or
// ETA actually moves, not on a fixed clock.
// Tick cadence for the safety-deadline check only. The SDK's setWidget has NO
// diffing — every call disposes the old component and rebuilds it
// (interactive-mode.js `setExtensionWidget`), so each push is a full
// teardown+repaint that reflows the editor. So the rendered line is fully STATIC
// for the run (no elapsed, no moving percent/bar): the content dedup in
// `setCompactWidget` pushes it once and skips every later tick, and this interval
// only re-checks the deadline below. Nothing animates, so nothing flickers.
const PLANNER_COMPACT_REFRESH_MS = 5000;
// Safety deadline so a *failed* compaction can never leave the indicator up
// forever. Pi's built-in /compact swallows a failure and never emits
Expand Down Expand Up @@ -3667,26 +3752,24 @@ function registerPlannerCompactEvents(
)
: PLANNER_COMPACT_MAX_MS;

// The rendered line is static (no elapsed/percent) — the same string every
// tick — so the widget setter's dedup pushes it once and never repaints it
// mid-run. The interval therefore exists only to enforce the safety
// deadline; it does not animate anything.
const text = formatCompactIndicator({ sizeLabel, reasonLabel, estimate });
const renderStatus = () => {
try {
const elapsedMs = Date.now() - startedAt;
// Self-terminating deadline: the tick that draws the elapsed seconds
// also enforces the cap, so a failed compaction with no completion
// event and no follow-up turn cannot hang the indicator (the 124m
// "still Compacting…" case). A separate setTimeout could be lost across
// suspend/resume; this cannot — if the bar is ticking, the check runs.
if (elapsedMs >= clearAfterMs) {
// Self-terminating deadline: the same tick that keeps the banner up also
// enforces the cap, so a failed compaction with no completion event and
// no follow-up turn cannot hang the indicator (the 124m "still
// Compacting…" case). A separate setTimeout could be lost across
// suspend/resume; this cannot — if the interval is live, the check runs.
if (Date.now() - startedAt >= clearAfterMs) {
// A run that never completed is not a representative timing sample.
pendingCompact = null;
stopCompactIndicator(ctx);
return;
}
const text = formatCompactIndicator({
sizeLabel,
reasonLabel,
elapsedMs,
estimate,
});
setCompactWidget(ctx, [ctx.ui.theme.fg("accent", text)], text);
} catch {
// Never let a redraw throw out of the interval.
Expand Down Expand Up @@ -3774,9 +3857,18 @@ function registerPlannerCompactEvents(
// the chat, and no message can stream while summarization blocks the loop. So
// the moment one arrives with the indicator still up, the run has ended — hide
// it at once rather than waiting for the next agent_start or the safety cap.
// We clear on BOTH message_start and every streaming message_update: a
// streaming assistant reply is delivered as message_update tokens (the signal
// the dashboard itself tracks), and message_start alone does not fire reliably
// for it — so keying only on message_start left the banner sitting over the
// model's answer as it streamed. clearStaleCompactIndicator is idempotent (a
// no-op once nothing is up), so a per-token call is cheap.
pi.on("message_start", async (_event, ctx) => {
clearStaleCompactIndicator(ctx);
});
pi.on("message_update", async (_event, ctx) => {
clearStaleCompactIndicator(ctx);
});

// Proactive monitor: at every turn boundary, project the context budget from
// the model's real output budget and compact *before* the window fills, so a
Expand Down
10 changes: 9 additions & 1 deletion src/runtime/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Runtime domain for planner stages, model-facing status, tool wrappers, timers, r
- Runtime gates must never allow wrappers outside the current stage/step policy.
- Artifacts are the durable truth after compact; chat memory is advisory only.
- `planner_contract_upsert` may write AGENTS.md only. Other context files are read-only imports.
- Reasoning fuel is tone-only: its level changes only the directive the model reads, never an allow/block decision. The only hard floors stay on named terminal defects (a CONFLICT verdict, an un-CONSISTENT gate). Enforced by `fuel-never-blocks.invariant.test.ts`.

### Read First
- `status.ts`
Expand Down Expand Up @@ -69,12 +70,19 @@ Runtime domain for planner stages, model-facing status, tool wrappers, timers, r

**SDD gates (spec-driven development)** — the deterministic verifier loop: the model authors structured artifacts, compilers in `vrf/` turn them into VRF, the elenchus engine judges, and `workflow-tools.ts` hard-gates on the verdict.
- `elenchus-engine.ts` → thin lazy loader for the `elenchus-wasm` engine (`runElenchusCheck` with a sandboxed IMPORT resolver); degrades to a typed failure if the wasm is missing.
- `elenchus-tools.ts` → `planner_elenchus_check` (free-form, model-authored programs) + the shared `last-check.json` record (`ElenchusLastCheckRecord`, extended with `gate`/`sourceHash` for gate runs).
- `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.
- 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.
- `reasoning-fuel.ts` → pure math: `computeReasoningFuel({warrantedWeb, coverage, stale, friction})` (null when nothing warrants the engine), plus the warranted-web collectors (branches/spec-constraints/shared-surfaces) and the engagement/friction readers over `ElenchusLastCheckRecord`. No I/O, no store, no engine.
- `reason-context.ts` → `loadStepReasoningFuel`: assembles the current step's fuel by loading only the planner's own artifacts (spec constraints at consistency_check, task branches at the execution reasoning steps, shared task surfaces at doubt_review). The one bridge both the reason tool and `status.ts` call.
- `reason-directive.ts` → `renderReasoningDirective`: the tone ladder (silent when null, quiet ≥70, top-deficit in 30–69, directing below 30 or on any friction). Templated, never generative.
- `reason-tools.ts` → `planner_reason` (assert/retract/recheck over the living `vrf/world-store.ts`): returns the verdict + the engine's raw output verbatim + the fuel directive, and writes a model-authored `last-check.json` so fuel credits the engagement. Gated exactly like `planner_elenchus_check`.
- Surfacing: the fuel directive is rendered in `status.ts` (the `## Reasoning Fuel` section) AND on the tail of an applied workflow transition via `status.ts` `formatTransitionReasoningFuelTail`, which the tool dispatcher (`index.ts`) appends to `planner_finish_step`'s result — so the model meets the nudge in the drive loop it actually reads, not only on a rare `planner_status`. The dispatcher imports this from the surfacing layer (`status.ts`), never a fuel module, so the `fuel-never-blocks` invariant holds; `workflow-tools.ts` only passes `planPaths` through as data.

**TDD** — the structured pre/post-implementation evidence form.
- `tdd-evidence.ts` → field/section name constants for the pre-implementation proof contract, post-implementation counterexample review, and merge-scope audit; no logic.
- `tdd-form.ts` → `TDD_SECTIONS` (canonical order) + `mergeTddMarkdown`/`renderTddSection`, used by `artifact-tools.ts` to assemble/merge the TDD artifact.
Expand Down
Loading