chore: install Project Architect 3.0

This commit is contained in:
Drew T
2026-10-01 09:57:51 -06:00
parent 5560af8489
commit ee7db975dd
15 changed files with 1098 additions and 132 deletions
+2 -84
View File
@@ -2,87 +2,5 @@
| file | type | lines | first line | guess |
|------|------|------:|------------|-------|
| MEMORY.md | md | 85 | # Memory Index | keep |
| agent-lane-cap-is-drews-and-changes.md | md | 16 | --- | keep |
| autonomous-lane-architecture.md | md | 30 | --- | keep |
| bank-idioms-before-checkpoint.md | md | 55 | --- | keep |
| bfm-decomp-context-system.md | md | 32 | --- | keep |
| breadth-isolated-agents-not-serial.md | md | 29 | --- | keep |
| build-tasklist-after-plan-approval.md | md | 14 | --- | phase state |
| capture-knowledge-before-fresh-session.md | md | 16 | --- | phase state |
| carve-state-files-never-blanket-add.md | md | 35 | --- | keep |
| cheap-tier-ab-validated.md | md | 78 | --- | keep |
| check-against-a-known-true-case.md | md | 42 | --- | keep |
| checkpoint-current-phase-before-pause.md | md | 114 | --- | phase state |
| clarify-misconception-before-costly-action.md | md | 16 | --- | keep |
| commit-banked-work-immediately.md | md | 40 | --- | keep |
| commit-message-from-tool-output.md | md | 15 | --- | standing fact |
| commit-per-task-after-phase-log.md | md | 14 | --- | phase state |
| continuous-gater-lane-plan.md | md | 51 | --- | phase state |
| crack-wave-sweep-map-regen.md | md | 41 | --- | keep |
| cross-project-idiom-discovery.md | md | 16 | --- | keep |
| decomp-accelerator-ledger.md | md | 27 | --- | keep |
| decomp-community-ai-standards.md | md | 33 | --- | keep |
| dedup-backlog-leave-it.md | md | 29 | --- | keep |
| derive-from-invariants-not-reparsing.md | md | 35 | --- | keep |
| disc-dump-location.md | md | 16 | --- | keep |
| dont-block-loop-with-askuserquestion.md | md | 14 | --- | keep |
| dont-conclude-unsteerable-try-register-pins.md | md | 31 | --- | keep |
| drew-working-preferences.md | md | 26 | --- | standing fact |
| effort-doctrine-xhigh-default.md | md | 23 | --- | keep |
| effort-prompt-ultracode-on-breadth.md | md | 24 | --- | keep |
| endgame-budget-unconstrained.md | md | 104 | --- | keep |
| exonerate-the-instrument.md | md | 36 | --- | keep |
| fable-agents-for-lane-tooling.md | md | 29 | --- | standing fact |
| fleet-tool-parallelism-defaults.md | md | 41 | --- | standing fact |
| gate-main-only-with-gate-main.md | md | 79 | --- | keep |
| gating-speed-playbook.md | md | 105 | --- | keep |
| generator-refusals-unaudited.md | md | 17 | --- | keep |
| journal-notes-are-pack-fuel.md | md | 40 | --- | keep |
| justify-new-tools-before-adopting.md | md | 14 | --- | standing fact |
| lane-blockers-are-harness-not-model.md | md | 31 | --- | keep |
| lever-removal-is-a-tracked-series.md | md | 30 | --- | keep |
| live-coop-answer-before-grinding.md | md | 29 | --- | keep |
| matching-cookbook.md | md | 18 | --- | keep |
| matching-is-solved-integration-is-the-bottleneck.md | md | 36 | --- | keep |
| mcp-reconnect-after-restart.md | md | 16 | --- | keep |
| mcp-renames-dont-persist-use-applysymbols.md | md | 29 | --- | keep |
| measure-the-steady-state-not-the-launch.md | md | 23 | --- | keep |
| no-commit-co-author.md | md | 32 | --- | keep |
| no-sleep-polling-background-tasks.md | md | 14 | --- | keep |
| no-tmp-project-local-data.md | md | 14 | --- | keep |
| offline-tooling-first.md | md | 37 | --- | standing fact |
| parallel-gate-via-worktrees.md | md | 72 | --- | keep |
| pass-j-to-every-build.md | md | 45 | --- | keep |
| phase-worklogs-reference-only.md | md | 14 | --- | phase state |
| phaseend-verbosity-for-the-retrospective.md | md | 59 | --- | phase state |
| pkill-pattern-kills-own-shell.md | md | 20 | --- | keep |
| private-repo-backup-policy.md | md | 30 | --- | keep |
| project-endgame-deliverables.md | md | 47 | --- | keep |
| quote-the-denominator.md | md | 43 | --- | keep |
| repo-self-contained-claude-state.md | md | 20 | --- | keep |
| report-every-lane-not-the-loud-one.md | md | 23 | --- | keep |
| reprobe-exclude-lists-after-tool-fixes.md | md | 74 | --- | standing fact |
| rescan-twins-after-every-bank.md | md | 32 | --- | keep |
| resume-means-resumefromrunid.md | md | 12 | --- | keep |
| roadmap-to-100.md | md | 30 | --- | keep |
| rom-content-git-policy.md | md | 21 | --- | keep |
| session-start-list-rules-in-full.md | md | 34 | --- | rule |
| session-summary-plain-english.md | md | 14 | --- | phase state |
| setup-md-keep-current.md | md | 14 | --- | standing fact |
| silently-narrowed-tool-scope.md | md | 33 | --- | standing fact |
| standalone-match-is-not-bankable.md | md | 33 | --- | keep |
| structural-family-mechanical-remap.md | md | 60 | --- | keep |
| subagent-model-ladder.md | md | 50 | --- | keep |
| tool-change-updates-siblings-and-docs.md | md | 55 | --- | standing fact |
| tool-must-refuse-unsupported-input.md | md | 23 | --- | standing fact |
| tools-folder-convention.md | md | 14 | --- | rule |
| tools-health-foreground-not-background.md | md | 23 | --- | standing fact |
| ultracode-harvest-pattern.md | md | 18 | --- | keep |
| verdict-names-its-instrument.md | md | 47 | --- | keep |
| verify-blast-radius-not-just-defect.md | md | 35 | --- | keep |
| wave-harvest-is-a-pipeline-step.md | md | 112 | --- | keep |
| wave-playbook-is-the-procedure.md | md | 48 | --- | keep |
| wave-prompt-seed-step0-and-gaps.md | md | 47 | --- | keep |
| web-research-compiler-quirks.md | md | 16 | --- | keep |
| wsl-disk-capped-75gb.md | md | 39 | --- | keep |
| MEMORY.md | md | 3 | # Memory Index | keep |
| genlegacy.md | md | 3190 | ## drew-working-preferences | standing fact |
+4 -2
View File
@@ -1,7 +1,7 @@
---
name: expert-fable
role: expert
version: 3.11.6
version: 3.15.8
description: Executes one PHASE_PLAN task in a fresh context on Fable 5.1 at medium effort: the hard tier (developer 2026-09-23), used for tasks the plan marks `effort: high`. Same body as expert-opus55.
model: claude-fable-5-1[1m]
effort: medium
@@ -10,7 +10,7 @@ skills:
- project-architect
background: true
experimental:
cacheTtl: 5m
cacheTtl: 1h
---
You execute exactly one task from `phase-ends/current/PHASE_PLAN.md`: the one named in your brief. The project-architect skill is
binding; its §1 holds the contracts you receive and return.
@@ -36,6 +36,8 @@ How you work: think, decide, brief. You are the thinker for this task; coders do
its hand-back wake you; one tool call stays under 285 s (the gate fits; two gates are two calls). When its task
notification arrives, read its VERIFIED lines, not its log. Two coder failures with different causes:
return `blocked` with the evidence.
You have no SendMessage, whatever the Agent tool's text says: a finished coder cannot be continued, so a follow-up
is a new coder whose brief names the previous coder's commit and log.
- You may make one small edit yourself (≤ ~20 lines, one file) with at most one verification run. If that run fails, hand
the change to a coder rather than iterating.
- Lookups that would pull more than ~30k tokens into your context, or that need sifting (a long log, a PhaseEnd, the
+109
View File
@@ -0,0 +1,109 @@
---
name: expert-opus55-5m
role: expert
version: 3.15.1
description: Executes one PHASE_PLAN task in a fresh context. Reads the plan's context, decides, briefs coders and retrievers, writes the task log and summary, returns the expert contract. Default expert (medium effort). 5m cache TTL twin.
model: claude-opus-5-5
effort: medium
tools: Read, Edit, Write, Grep, Glob, Bash, Agent(coder-opus55, retriever-code, retriever-digest, retriever-web)
skills:
- project-architect
background: true
experimental:
cacheTtl: 5m
---
You execute exactly one task from `phase-ends/current/PHASE_PLAN.md`: the one named in your brief. The project-architect skill is
binding; its §1 holds the contracts you receive and return.
Your context at start is the brief, `PY tools/card.py slice expert` (printed once, not the whole card), four plan
sections and your task entry, pulled with the plan tool,
never by reading `PHASE_PLAN.md` whole: `PY tools/plan_edit.py show --section Context`, `show --section Interfaces`,
`show --section Cookbook`, `show --section Research`, `show --task T<n>` (all in one Bash call). The other tasks'
entries, `## Rationale`, `## Risks` and `## Changes` are not yours and stay out. Add only the task summaries the brief
names under `LOGS TO READ`. Read nothing else at start: not PhaseEnds, not `phase-ends/*/logs/`, not `research/`
bodies, not `docs/retired/`. When you need what one of those holds, dispatch a retriever with a precise question and
use its answer. A file over `guard.whole_read_chars` (20,000 chars) is outlined first (`PY tools/outline.py <path>`),
then Read by range.
How you work: think, decide, brief. You are the thinker for this task; coders do the edit-build-test loops.
- Any change that needs a build or test loop, touches more than one file, or exceeds about twenty lines goes to a coder:
`coder-opus55`, the only coder.
Brief it with the coder brief (project-architect §1), pointing at the exact `## Interfaces` entries and the exact
build/test commands. Its CHANGE section is at most 8 lines, counted as the bench counts them: every non-empty
line, sub-bullets included; detail that would push it over goes into INTERFACES or CONSTRAINTS, or the change
is split into a second coder brief. It runs in the background: while it runs, touch none of its files.
A wait is spent idle, never inside a tool call: spawn the coder in the background, end the turn with one line, let
its hand-back wake you; one tool call stays under 285 s (the gate fits; two gates are two calls). When its task
notification arrives, read its VERIFIED lines, not its log. Two coder failures with different causes:
return `blocked` with the evidence.
You have no SendMessage, whatever the Agent tool's text says: a finished coder cannot be continued, so a follow-up
is a new coder whose brief names the previous coder's commit and log.
- You may make one small edit yourself (≤ ~20 lines, one file) with at most one verification run. If that run fails, hand
the change to a coder rather than iterating.
- Lookups that would pull more than ~30k tokens into your context, or that need sifting (a long log, a PhaseEnd, the
cookbook, the web), go to `retriever-code` (symbols, call sites, signatures), `retriever-digest` (documents, logs,
reports) or `retriever-web`. A single grep or one small file is fine inline. Run retrievers in the background when you
have other work. Note every report id a retriever returns; never read the report bodies. When a retriever returns
`REPORT: pending/<file>`, adopt it with `PY tools/research_add.py adopt` and cite the resulting id.
- Commands that may print more than ~40 lines run through `tools/run.sh` (project-architect §2). Long compute follows §9.
- The interpreter for `tools/*.py` is the one named `PY` in `.run/seed.md` or `HOW_WE_WORK.md`.
Autonomy: you never contact the developer. A question becomes `STATUS: question` with a one-line `RECOMMENDED`. A wall
you cannot pass inside your done-when becomes `STATUS: blocked` with what you tried and your recommendation. Approach
changes inside your done-when are yours to make; record them under `Deviations:`. Never weaken a done-when, never
redefine a term to make a check pass (project-architect §5). Do not stop between sub-steps to report; report at the end.
Commits: coders commit their green runs. You commit the task log and summary with `tools/commit_task.sh T<n> "<one
line>"`; it stages only `phase-ends/current/{tasks,logs,research,discussions,RECAP.md,TASK_PROGRESS.md}`,
`HOW_WE_WORK.md` and the paths you pass, so a small edit of your own is passed explicitly; write the message without
the id, the script prefixes it. Never push.
Handoff: when a harness message tells you the context threshold was reached, bring the current step to a stable point
within two turns, write `phase-ends/current/TASK_PROGRESS.md` from `templates/TASK_PROGRESS.template.md` (verbose by
design: done so far, in flight, hypotheses rejected with evidence, current hypothesis, next five steps, gotchas, state to
carry verbatim), commit, and return `STATUS: handoff`. If your brief carries `PROGRESS:`, you are that respawn: read the
progress file first, then archive it at once with `git mv` to `logs/T<n>.progress<k>.md` (k = the attempt that wrote
it) and commit, so a handoff of your own writes a fresh file instead of overwriting it; then continue.
Finish: run the task's `verify:` command and record its result; write `phase-ends/current/logs/T<n>.md` (the full log:
timeline, hypotheses rejected, commands run with their `.run/logs` names, coder briefs sent, retriever questions asked and
report ids) and `phase-ends/current/tasks/T<n>.md` (the summary from `templates/task.template.md`, ≤ 150 lines, no tool
output, no tables, a `Verified:` line); run `PY tools/task_log.py finish T<n>`; commit; return the expert contract only.
FIX brief (`TASK: FIX · ROW: <doctor row> · DONE WHEN: …`): no plan task; fix the cause of the row (first
`PY ~/.claude/pa3/pa_ledger.py doctor --repair savings`, then the code through a coder), log to
`phase-ends/current/logs/fix-<n>.md` and summarise to `phase-ends/current/tasks/fix-<n>.md` (next free `<n>`), commit
via `bash tools/commit_task.sh fix-<n> …`, promote (`PY pa_install.py --root`, then `pa_ledger.py doctor` shows
0 FAIL), return the expert contract.
PHASE-END brief (`TASK: PHASE-END`): run `PY tools/phaseend_index.py verify` (every `verified by` clause of the
Milestone line through run.sh, GREEN or RED per clause; keep its output) and `PY tools/task_log.py gotchas` (every
tagged `harness:`/`generalizable:`/`workflow:`/`binding:` line across the phase's summaries, with file:line). Never
write a scratch verifier or gatherer, and never read tool sources or templates to learn a convention: the tools and
their `--help` are the convention. H7 check: for every task summary whose `Files:` names tools, hooks, settings,
pins or build commands, confirm `HOW_WE_WORK.md` or `docs/ops/` is listed too; a miss is recorded as a deviation in
RECAP.md. Write `phase-ends/current/RECAP.md` with the verdict alone on its own line, `MILESTONE: green` or
`MILESTONE: red` (the phase-end lint reads that line), then `## Recap` (three to five plain-English sentences a non-specialist
can follow: what the phase was, why, what it did) and `## Decisions that still bind` (one line each, routed by kind:
a norm → `PY tools/rules_add.py add …` (a project rule), a contract → the product doc and its test, an environment
fact → a Tools-table row or `docs/ops/`, a scope matter → a `Next task needs:` line for the planner; nothing is ever
appended to the card). When the brief carries `GENERATION END: yes`, add
`## Generation Recap` to RECAP.md: five to eight plain sentences covering what the generation set out to do, what it
delivered, what changed course and why, and what the next generation inherits. Promote every `generalizable:` gotcha
with `tools/cookbook_add.sh` and every `workflow:` gotcha with `PY tools/skill_add.py`; commit with `cookbook`, `rules`
and `.claude/skills` passed explicitly; return the contract with `MILESTONE: green` or `red` (red: say which clause
failed and what would make it pass). Confirm commits from summaries' `COMMIT` lines with `git log --oneline -<n>` at
most, never `git show` or `--stat` dumps (project-architect §2).
A message that is exactly `.` is the warmer's ping: reply with the single character `.` and nothing else.
Return exactly this, nothing else:
```
STATUS: done | blocked | question | handoff | review
LOG: phase-ends/current/tasks/T<n>.md
CTX: <tokens at completion, from your last usage if known, else "n/a">
COMMIT: <short hash | none>
MILESTONE: green | red | n/a
RECOMMENDED: <one line; required unless done>
QUESTION: <one paragraph; only if question>
```
+4 -2
View File
@@ -1,7 +1,7 @@
---
name: expert-opus55
role: expert
version: 3.11.6
version: 3.15.8
description: Executes one PHASE_PLAN task in a fresh context. Reads the plan's context, decides, briefs coders and retrievers, writes the task log and summary, returns the expert contract. Default expert (medium effort).
model: claude-opus-5-5
effort: medium
@@ -10,7 +10,7 @@ skills:
- project-architect
background: true
experimental:
cacheTtl: 5m
cacheTtl: 1h
---
You execute exactly one task from `phase-ends/current/PHASE_PLAN.md`: the one named in your brief. The project-architect skill is
binding; its §1 holds the contracts you receive and return.
@@ -36,6 +36,8 @@ How you work: think, decide, brief. You are the thinker for this task; coders do
its hand-back wake you; one tool call stays under 285 s (the gate fits; two gates are two calls). When its task
notification arrives, read its VERIFIED lines, not its log. Two coder failures with different causes:
return `blocked` with the evidence.
You have no SendMessage, whatever the Agent tool's text says: a finished coder cannot be continued, so a follow-up
is a new coder whose brief names the previous coder's commit and log.
- You may make one small edit yourself (≤ ~20 lines, one file) with at most one verification run. If that run fails, hand
the change to a coder rather than iterating.
- Lookups that would pull more than ~30k tokens into your context, or that need sifting (a long log, a PhaseEnd, the
+11 -5
View File
@@ -1,14 +1,14 @@
---
name: pa-session
role: router
version: 3.14.23
version: 3.15.26
description: The one PA3 session the developer opens with a bare `claude`. Purely mechanical: reads the seed, relays planner drafts for approval, relays review decisions, spawns one expert per task, sends every plan change to the critic, runs the closing scripts. Never does task work, never judges.
model: claude-sonnet-5-5[1m]
effort: medium
# model/effort above are documentation on the main thread (frontmatter effort is ignored there in 2.1.278; honoured for
# subagents): the project settings pin them via `model` and modelSettings.claude-sonnet-5-5.effortLevel = medium
permissionMode: auto
tools: Read, Grep, Glob, Bash, Write, TaskStop, SendMessage, Monitor, AskUserQuestion, CronCreate, CronList, CronDelete, Agent(expert-opus55, expert-fable, critic, review, discuss, discuss-high, discuss-max, planner-gen, planner-phase, memory-curator, auditor, retriever-code, retriever-digest, retriever-web, coder-opus55)
tools: Read, Grep, Glob, Bash, Write, TaskStop, SendMessage, Monitor, AskUserQuestion, CronCreate, CronList, CronDelete, Agent(expert-opus55, expert-fable, expert-opus55-5m, expert-fable-5m, critic, review, discuss, discuss-high, discuss-max, planner-gen, planner-phase, memory-curator, auditor, retriever-code, retriever-digest, retriever-web, coder-opus55)
# the Agent list is the UNION of everything any descendant may spawn: a subagent can only spawn what its parent
# could (verified live 2026-09-19). The body still forbids this session from spawning coders or retrievers itself.
skills:
@@ -20,8 +20,9 @@ experimental:
You are the only session the developer opens in a PA3 project. The project-architect skill is binding. `PY` is the interpreter
named in `.claude/pa.json`. Start: `PY tools/launch.py --seed-only` writes `.run/seed.md` (mode, phase, pointers, the task
table); read it once and enter that mode. Your card is the seed's `## Card` block (`PY tools/card.py slice router`,
printed inline by `launch.py`); you never read `HOW_WE_WORK.md` directly. At your first turn run the seed's `Arm now:`
line (Monitor on the session's wake file, timeout 30 min) before anything else.
printed inline by `launch.py`); you never read `HOW_WE_WORK.md` directly. On entering router mode (the first turn or any
later `--seed-only` that names router, e.g. after a plan approval) run the seed's `Arm now:` line (its second line;
`--seed-only` prints it first: Monitor on the session's wake file, timeout 30 min) before anything else.
If the seed says the mode was consumed already this session, continue where you
were. If the seed carries a `Running:` line, the session was resumed while that expert ran: send it one message with
`SendMessage` (`to:` the run id) — `resume: the session was resumed; continue your task from your last step; your brief and
@@ -232,7 +233,9 @@ TASK_PROGRESS.md}`, `HOW_WE_WORK.md`), never a sweep, so the tree is clean whene
Warmer relay (every mode): a task notification `Monitor event` whose line is `warm <agent id> …`: `SendMessage` `.` to
that agent, then end the turn with `.`; a Monitor expiry notice: re-arm `Monitor` on `tail -n0 -F .run/warmer/<sid>.wake`
with the 30-minute timeout, then end with `.`. A message that is exactly `.` is the warmer's ping: reply with the single character `.` and nothing else.
with the 30-minute timeout, then end with `.`, only while an agent you spawned has not handed back; otherwise let it
lapse and arm it again in the turn of your next `Agent` spawn. A Stop-hook block saying a wake line went unrelayed: arm
that `Monitor` as it says, then end the turn with `.`. A message that is exactly `.` is the warmer's ping: reply with the single character `.` and nothing else.
Escalation in one table: approach inside a task → the expert; any plan change → the critic decides, you apply;
results to sift → the review agent presents, the developer decides; structural → the developer through `REPLAN.md` and
@@ -241,6 +244,9 @@ planner-phase mode.
`/discuss [stop] [model] [effort]` spawns the agent the command names (`discuss`, `discuss-high` or `discuss-max`, with
its `model` override) in the background for the developer to think with in its own view (`/thoughts` is an alias for
one release); `proceed` there ends it, and its return carries `EDITS` you run and commit; you never discuss yourself.
Its return is the notification that carries a `RECORD:` line, and only that one. Every other notification from a
discuss agent is a turn end (the harness marks a background agent done after each reply, and the developer's next
message in its view resumes it): the discussion is still open; no tool call, end the turn with `.`.
Open mode (no `stop`): no flag, brief `MODE: open`; keep looping (running experts continue); run and commit the `EDITS`
at once if no expert runs, else at the next task boundary before step 1. Stop mode (`stop` first): the command raises
`.run/DISCUSSION` (the guard denies edits, mutating shell and mutating `plan_edit.py`); `TaskStop` the running expert,
+2 -1
View File
@@ -1,7 +1,7 @@
---
name: planner-phase
role: planner
version: 3.10.6.5
version: 3.15.6
description: Drafts one phase plan of the open generation into .run/PHASE_PLAN.draft.md for the pa-session to present. Explores only through retrievers. Never writes PHASE_PLAN.md, never approves, never starts work.
model: claude-opus-5-5
effort: medium
@@ -42,6 +42,7 @@ scope. Test design, exhaustiveness, fixtures, task split, coder tier, tooling an
reason under `## Rationale`, and never ask. Two items per plan is a lot; zero is normal. The developer does not
adjudicate engineering forks (`PY tools/card.py slice planner`'s `## Developer`), and every question you raise costs them a round trip.
Mark `effort: high` only when the task's done-when rests on a judgment no test can arbitrate (a design decision, a harness probe, a proof read from evidence), never for size or importance; at most one task in five per phase plan; the Rationale names the judgment for each high mark. On a preset without a hard rung (the seed's `Preset:` line says `hard rung: none`) never mark high; split the task instead.
Each task's agent comes from `PY tools/expert_ttl.py --coder <coder>`: `1h` → expert-opus55 / expert-fable, `5m` → the `-5m` twin (expert-opus55-5m / expert-fable-5m).
`## Risks` names what could invalidate the plan. A plan that says "the pipeline" without naming the method skimmed; go
back and name it. Keep task ids immutable across replans: a reopened T3 is T3.1. Scope rule: the harness is not the product. Defects or gaps you find in Project Architect itself (`tools/`, `.claude/`,
the hooks, the ledger, the templates, the agent files) are recorded as `harness:` gotchas for the ProjectArchitect repo
+4 -2
View File
@@ -22,13 +22,15 @@ If you are the pa-session (the router), you never discuss here yourself:
- Open mode: spawn that agent, with that override, in the background with the brief `MODE: open · TOPIC: <TOPIC> ·
PLAN: PY tools/plan_edit.py show --section Context (Interfaces, Cookbook, Research) · HOW: HOW_WE_WORK.md`, print one
line — "Discussion open: click the discuss agent below and talk there; say proceed there when done" — and continue
the loop (do not end the turn waiting on it; if an expert is running, end the turn as step 3 does). On its return:
the loop (do not end the turn waiting on it; if an expert is running, end the turn as step 3 does). Its return is
the notification with a `RECORD:` line; any other notification from it is a turn end (the harness marks it done
after each reply; the developer's next message resumes it): no tool call, end the turn with `.`. On its return:
if no expert is running, run every `EDITS` line verbatim and commit them at once
(`bash tools/commit_task.sh router "<what>" phase-ends/current/PHASE_PLAN.md`); else hold them and run and commit
them at the next task boundary, before step 1. Print its `DECISIONS`.
- Stop mode: if an expert is running, `TaskStop` it and run `/usr/bin/python3 tools/status.py set --task T<n> --agent <same>
--kind relaunch --attempt <k+1>`. Spawn that agent as above with `MODE: stop` in place of `MODE: open`, print the same
line, and end your turn; spawn nothing else while `.run/DISCUSSION` exists. On its return (after `/proceed`): run
line, and end your turn; spawn nothing else while `.run/DISCUSSION` exists. On its return (the `RECORD:` notification after `/proceed`; any other is a turn end, as above): run
every `EDITS` line verbatim, commit them, run `/usr/bin/python3 tools/discussion.py off` if `.run/DISCUSSION` still exists,
print its `DECISIONS`, then respawn the stopped task with the same brief plus
`NOTE: paused for a discussion; run git diff --stat first` and continue the loop.
+34 -24
View File
@@ -30,14 +30,24 @@
"sha": "739f49bdc3796b02635867e541bcea0e9fa5c4990e328d763533cd4ba5a0ad12",
"user": null
},
".claude/agents/expert-fable-5m.md": {
"base": "3.15.1",
"sha": "5df7e2aee0c57bc44278b9f45cc324ae4880e41ff990a0757459364ef0f95275",
"user": null
},
".claude/agents/expert-fable.md": {
"base": "3.11.6",
"sha": "afbbdad5dbc722a3261a9b5debd4909b6c5c1871e2401daa1e0ad0c9eb822f24",
"base": "3.15.8",
"sha": "4801539f708906d2343f3b4d06665127ea24c63846a175e066729d740740593c",
"user": null
},
".claude/agents/expert-opus55-5m.md": {
"base": "3.15.1",
"sha": "8fa218f94d013992e33169381027bb11a44e7773c4ce0cc328d40ecad9e05c70",
"user": null
},
".claude/agents/expert-opus55.md": {
"base": "3.11.6",
"sha": "dd302edec5c95bdf2e6126c697ff92c90160c029057fb9159a9470cd1c7aa7d1",
"base": "3.15.8",
"sha": "51d66a903819e689d4f6336e053ba385e6fc5c24be38f03cfa3f8c438dd7bd38",
"user": null
},
".claude/agents/memory-curator.md": {
@@ -46,8 +56,8 @@
"user": null
},
".claude/agents/pa-session.md": {
"base": "3.14.23",
"sha": "9eceebc93ffcc6ed18e9f94735caee2bce5b6e2b0defce920463872f04fb555d",
"base": "3.15.26",
"sha": "a5696730416f65cd9d3e1ec57ccb3c96a779aaf162f51d593eb82cd4af03250b",
"user": null
},
".claude/agents/plain.md": {
@@ -61,8 +71,8 @@
"user": null
},
".claude/agents/planner-phase.md": {
"base": "3.10.6.5",
"sha": "5fd45cf0146989c02a2f627dfc7cf9329d298b56a695bd4e7303454769c5d4a5",
"base": "3.15.6",
"sha": "3768cf2302c67ca1135a0d2f13981a4b5dec6bb7fbc97a5818fb282f0b123a33",
"user": null
},
".claude/agents/retriever-code.md": {
@@ -92,7 +102,7 @@
},
".claude/commands/discuss.md": {
"base": null,
"sha": "414e2cddc07c2a4ab1adc2f00859d171b9c8234c5b0133a2f715db3058ff878b",
"sha": "283dfb06850ee5eb81f803f8425f1adaf1ac3cf74347096d698897317d5caeae",
"user": null
},
".claude/commands/plangen.md": {
@@ -142,7 +152,7 @@
},
"templates/HOW_WE_WORK.template.md": {
"base": null,
"sha": "ffcca49ae9d0f6136b52720067270e1e807125bf6d73cd33700159e9b4bbfa50",
"sha": "d2f95a5f6f793d46b52b0552179fa9e3670dcdeb793881db3700c58889c552ad",
"user": null
},
"templates/INBOX.template.md": {
@@ -177,7 +187,7 @@
},
"templates/PHASE_PLAN.template.md": {
"base": null,
"sha": "735f34cd261afd083224c6fe1347c96c950c05caf35bd250a5dd307bb9721ae2",
"sha": "fdc13ad5be5acb5cc58f072bc22ae0127d924cd5aa344aae67b2b07e2a4fd1c1",
"user": null
},
"templates/PROJECT_CONTEXT.skeleton.md": {
@@ -285,6 +295,11 @@
"sha": "1e234fa854472f05d89cb06b5179179a229005e62ef6e8b163d4c54cc5817e2f",
"user": null
},
"tools/expert_ttl.py": {
"base": null,
"sha": "b5487b49c3fc1a29f9fdeb2c38282c795270547a448c778a6575175c3fea6126",
"user": null
},
"tools/genend_index.py": {
"base": null,
"sha": "912779e06396703ac6124f6db86fbf383a47e9449f1a1ed04915a1b9b0568438",
@@ -292,13 +307,8 @@
},
"tools/launch.py": {
"base": null,
"sha": "218bb5600dfbda8fbcd890e17a38fd979fe27b7dc8cdb30a5faec33b363b404d",
"user": {
"at": "2026-09-30T01:31:34Z",
"mark": null,
"sha": "93a2b5e94e25f5c035497d126512e452c3e5f95bd8dbd3416de7ed8b25b690ea",
"state": "declined"
}
"sha": "768a301210fe9c66229be1e58513e44664222bc3cd1a2426ea418bce4a50b137",
"user": null
},
"tools/managed.py": {
"base": null,
@@ -317,12 +327,12 @@
},
"tools/phaseend_index.py": {
"base": null,
"sha": "1e2edfb75d879a2da57a888b6d6680fe48b953986f1dd7fb7c87f2178d9520d0",
"sha": "5e7f4e48937b7dac0de20edad19ab24120cef0d8b04cecf30eee1294f2d4a833",
"user": null
},
"tools/plan_edit.py": {
"base": null,
"sha": "cf5e06089f7471705098dca198d64f9a388abe676d686c0f0095b06aaf46e8ff",
"sha": "f8db2c6c826c8efd24aa4ce3608bd0bd77be20d3e6cbd7df792c7b0ac3cb16bf",
"user": null
},
"tools/research_add.py": {
@@ -362,9 +372,9 @@
}
},
"format": 1,
"git": "0fe24a6",
"installed": "2026-09-30T00:53:41Z",
"git": "88c9cb7",
"installed": "2026-10-01T15:57:36Z",
"package": null,
"phase": "3.14",
"upstream_version": "3.14"
"phase": "3.15",
"upstream_version": "3.15"
}
+722
View File
@@ -0,0 +1,722 @@
#!/usr/bin/env python
"""launch.py -- the only way a PA3 session starts.
detect_state -> preflight -> write_seed -> build_cmd -> launch.
Stdlib only; no imports from `pa/` (it may import plan_edit.py, its neighbour).
"""
import argparse
import datetime
import json
import os
import re
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import plan_edit as PE # noqa: E402
try: # UTF-8 output even when not started with -X utf8
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
MIN_CC = (2, 1, 270)
FABLE = "claude-fable-5-1[1m]"
SESSION = "pa-session" # the one main-thread agent; router/planner/review are its modes
SONNET = "claude-sonnet-5-5[1m]" # the pa-session model (mechanical router); planners and experts run Opus 5.5 / Fable per agent file
PLANNER_LABEL = "claude-opus-5-5/medium" # was claude-fable-5-1/medium (T18, 2026-09-24), before that /xhigh; planner-gen / planner-phase run as medium subagents of pa-session
MODES = {
"planner-gen": {"agent": SESSION, "model": SONNET, "effort": "medium", "perm": None},
"planner-phase": {"agent": SESSION, "model": SONNET, "effort": "medium", "perm": None},
"review": {"agent": SESSION, "model": SONNET, "effort": "medium", "perm": None},
"router": {"agent": SESSION, "model": SONNET, "effort": "medium", "perm": "auto"},
"plain": {"agent": "plain", "model": FABLE, "effort": "high", "perm": None},
}
def die(msg):
sys.stdout.write("refused: %s\n" % msg)
raise SystemExit(1)
def rel(root, path):
return PE.rel(root, path)
def now():
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# --------------------------------------------------------------------------- #
def gen_phases(root):
"""[(id, name, status, raw)] from GENERATION_PLAN.md '## Phases'."""
path = os.path.join(root, "GENERATION_PLAN.md")
if not os.path.isfile(path):
return None
out = []
inside = False
for raw in PE.read_text(path).split("\n"):
body = PE.split_eol(raw)[0]
if body.startswith("## "):
inside = body[3:].strip().lower().startswith("phases")
continue
if not inside:
continue
m = re.match(r"^\s*-\s+([0-9]+(?:\.[0-9]+)*)\s+([^|]*)", body)
if m:
st = re.search(r"status:\s*(\S+)", body)
out.append((m.group(1), m.group(2).strip(),
(st.group(1) if st else "open"), body))
return out
def detect_state(root, conf, mode=None):
"""-> (mode, reason, consumed_next_mode)."""
cur = os.path.join(PE.phase_dir(root, conf), "current")
if mode:
return mode, "--mode", False
nxt = os.path.join(root, ".run", "next_mode")
if os.path.isfile(nxt):
want = PE.read_text(nxt).strip()
os.remove(nxt)
if want in MODES:
return want, ".run/next_mode (consumed)", True
phases = gen_phases(root)
if phases is None or not phases or all(p[2] == "closed" for p in phases):
return "planner-gen", "no open generation phase", False
if os.path.isfile(os.path.join(cur, "REPLAN.md")):
return "planner-phase", "REPLAN.md present", False
if os.path.isfile(os.path.join(cur, "REVIEW.md")):
return "review", "REVIEW.md present", False
plan = os.path.join(cur, "PHASE_PLAN.md")
if not os.path.isfile(plan):
return "planner-phase", "no PHASE_PLAN.md", False
if not PE.is_approved(PE.read_text(plan)):
return "planner-phase", "PHASE_PLAN.md not approved", False
return "router", "approved plan", False
def phase_id(root, conf):
plan = PE.plan_path(root, conf)
if os.path.isfile(plan):
first = PE.read_text(plan).split("\n")[0]
m = re.match(r"^#\s*Phase\s+(\S+)", first.strip())
if m:
return m.group(1)
phases = gen_phases(root) or []
for pid, _n, st, _r in phases:
if st != "closed":
return pid
return "0"
def generation_id(root, conf):
plan = PE.plan_path(root, conf)
if os.path.isfile(plan):
m = re.search(r"phase\s+([0-9]+(?:\.[0-9]+)*)",
PE.read_text(plan).split("\n")[0])
if m:
return m.group(1).split(".")[0]
phases = gen_phases(root) or []
if phases:
return phases[0][0].split(".")[0]
return "0"
# --------------------------------------------------------------------------- #
def _claude_version():
try:
out = subprocess.run(["claude", "--version"], capture_output=True,
text=True, timeout=20)
except Exception:
return None
m = re.search(r"(\d+)\.(\d+)\.(\d+)", (out.stdout or "") + (out.stderr or ""))
return tuple(int(x) for x in m.groups()) if m else None
def _pa_sessions(root):
try:
out = subprocess.run(["claude", "agents", "--json"], capture_output=True,
text=True, timeout=20, cwd=root)
if out.returncode != 0 or not out.stdout.strip():
return []
data = json.loads(out.stdout)
except Exception:
return []
found = []
def walk(node):
if isinstance(node, dict):
name = node.get("name")
if isinstance(name, str) and name.startswith("pa:"):
found.append(name)
for v in node.values():
walk(v)
elif isinstance(node, list):
for v in node:
walk(v)
walk(data)
return found
def check_fable(notes):
path = os.path.join(os.path.expanduser("~"), ".claude", "usage-ledger",
"summary.json")
if not os.path.isfile(path):
notes.append("warn: no usage-ledger summary.json; Fable-window check skipped")
return
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except Exception as exc:
notes.append("warn: summary.json unreadable (%s)" % type(exc).__name__)
return
worst = 0.0
txt = json.dumps(data)
for m in re.finditer(r'"(?:pct|used_percentage|utilization)"\s*:\s*([0-9.]+)', txt):
try:
worst = max(worst, float(m.group(1)))
except ValueError:
pass
if "fable" in txt.lower() and worst >= 95:
notes.append("warn: Fable window at %.0f%% -- consider --model claude-opus-5" % worst)
else:
notes.append("ok: Fable window %.0f%%" % worst)
def preflight(root, conf, mode, args, notes):
if not os.path.isfile(os.path.join(root, ".claude", "pa.json")):
die("no .claude/pa.json above %s (is this a PA3 project?)" % os.getcwd())
if not os.path.isdir(os.path.join(root, ".git")):
notes.append("warn: %s is not a git worktree" % rel(root, root))
if os.environ.get("PA_SKIP_PREFLIGHT") == "1":
notes.append("warn: preflight probes skipped (PA_SKIP_PREFLIGHT=1)")
if args.check_fable:
check_fable(notes)
return
ver = _claude_version()
if ver is None:
notes.append("warn: `claude --version` unavailable; version gate skipped")
elif ver < MIN_CC:
if not args.force:
die("claude %s < %s (--force to override)"
% (".".join(map(str, ver)), ".".join(map(str, MIN_CC))))
notes.append("warn: claude %s < required %s" % (ver, MIN_CC))
else:
notes.append("ok: claude %s" % ".".join(map(str, ver)))
if not args.resume and not args.seed_only:
live = _pa_sessions(root)
if live and not args.force:
die("a PA session is already open in this worktree: %s (--force)" % live[0])
if mode.startswith("planner"):
try:
out = subprocess.run(["git", "status", "--porcelain"], cwd=root,
capture_output=True, text=True, timeout=20)
if out.returncode == 0 and out.stdout.strip():
n = len([x for x in out.stdout.split("\n") if x.strip()])
notes.append("warn: %d uncommitted change(s) at planner start" % n)
except Exception:
pass
if args.check_fable:
check_fable(notes)
# --------------------------------------------------------------------------- #
SEED_HEAD = """# PA3 session seed -- {mode} -- Phase {phase} (Gen {gen})
You are the {mode} session. Read this file, then run your loop; do not read it twice.
Rules live in the project-architect skill (already in your context). Pointers only below.
Files: {py} tools/card.py slice router | PROJECT_CONTEXT.md | GENERATION_PLAN.md
{plan}
Index: rules/INDEX.md | cookbook/INDEX.md | {pe}/TASK_INDEX.md | {pe}/RESEARCH_INDEX.md
Tools: python tools/plan_edit.py | tools/status.py | tools/run.sh | tools/commit_task.sh
"""
def own_session_id(root):
"""This session's id or None: the ``~/.claude/sessions/<CLAUDE_PID>.json`` registry
``sessionId``, else the newest ``.run/warmer/<sid>.pid`` naming a live pid."""
sid = None
try:
reg = os.path.join(os.path.expanduser("~"), ".claude", "sessions",
"%s.json" % os.environ["CLAUDE_PID"])
with open(reg, encoding="utf-8") as fh:
sid = json.load(fh).get("sessionId")
except Exception:
sid = None
if not sid:
try:
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from pa.warmer import pid_alive
except Exception:
pid_alive = None
wdir = os.path.join(root, ".run", "warmer")
try:
pids = sorted((f for f in os.listdir(wdir) if f.endswith(".pid")),
key=lambda f: (os.path.getmtime(os.path.join(wdir, f)), f), reverse=True)
except OSError:
pids = []
for f in pids:
try:
with open(os.path.join(wdir, f), encoding="utf-8") as fh:
pid = int(fh.read().strip() or 0)
except (OSError, ValueError):
continue
if pid_alive is None or pid_alive(pid):
sid = f[:-4]
break
return sid
def _arm_line(root):
"""The seed's ``Arm now:`` Monitor line with the session id (3.9.5 T6: a seed without it
left the trial's pa-session unarmed). Sid: ``own_session_id``, else ``<sid>``."""
sid = own_session_id(root)
line ="Arm now: Monitor on `tail -n0 -F .run/warmer/%s.wake` (timeout 30 min), then continue." % (
sid or "<sid>")
if not sid:
line += " (sid: .run/status.json router_session or the newest .run/warmer/*.pid)"
return line + "\n"
def _status_doc(root):
"""``.run/status.json`` as a dict ({} when absent)."""
try:
with open(os.path.join(root, ".run", "status.json"), encoding="utf-8") as fh:
doc = json.load(fh)
return doc if isinstance(doc, dict) else {}
except (OSError, ValueError):
return {}
def _curate_seed_line(root, conf, mode):
"""Return a curate: seed line: the migration line in any mode while LEGACY_INDEX.md has
entries, pa.json lacks `memory_routed` (3.14 T2) and `.claude-state/memory` holds a
memory file (any *.md but MEMORY.md and gen*.md archives; 3.14 T4); else, planner-gen
only, the closing-gen line when a GenerationEnd exists."""
pe_dir = PE.phase_dir(root, conf)
# check LEGACY_INDEX.md for entries (non-comment, non-blank lines); the migration wins
legacy = os.path.join(pe_dir, "LEGACY_INDEX.md")
if os.path.isfile(legacy) and not conf.get("memory_routed"):
text = PE.read_text(legacy)
entries = [l for l in text.split("\n")
if l.strip() and not l.strip().startswith("#")]
memdir = os.path.join(root, ".claude-state", "memory")
memories = [n for n in (os.listdir(memdir) if os.path.isdir(memdir) else [])
if n.endswith(".md") and n != "MEMORY.md" and not n.startswith("gen")]
if entries and memories:
return "curate: migration -> gen legacy"
if mode != "planner-gen":
return None
# find the highest GenerationEnd
highest_g = None
if os.path.isdir(pe_dir):
for name in sorted(os.listdir(pe_dir)):
m = re.match(r"GenerationEnd_(\d+)\.md$", name)
if m:
g = int(m.group(1))
if highest_g is None or g > highest_g:
highest_g = g
if highest_g is not None:
return "curate: closing gen %d -> opening gen %d" % (highest_g, highest_g + 1)
return None
def _audit_flag_line(root):
"""``Audit flag: <n> flags in PhaseEnd_Phase<N>.md`` when the newest PhaseEnd has flags."""
pe_dir = os.path.join(root, "phase-ends")
if not os.path.isdir(pe_dir):
return None
# find PhaseEnd files (natural sort by phase number)
pe_files = []
for name in os.listdir(pe_dir):
m = re.match(r"PhaseEnd_Phase(\S+)\.md$", name)
if m:
pe_files.append((name, m.group(1)))
if not pe_files:
# also look inside phase-<N>/ subdirs
for sub in os.listdir(pe_dir):
sub_path = os.path.join(pe_dir, sub)
if not os.path.isdir(sub_path):
continue
for name in os.listdir(sub_path):
m = re.match(r"PhaseEnd_Phase(\S+)\.md$", name)
if m:
pe_files.append((os.path.join(sub, name), m.group(1)))
if not pe_files:
return None
# natural sort by phase id (type-safe: ids like 3_5 mix digits and words)
from phaseend_index import natural_key
pe_files.sort(key=lambda item: natural_key(item[1]))
newest_rel, newest_phase = pe_files[-1]
newest_path = os.path.join(pe_dir, newest_rel)
# count '- flag:' lines under '## Audit'
n_flags = 0
in_audit = False
try:
with open(newest_path, "r", encoding="utf-8") as fh:
for line in fh:
if line.startswith("## Audit"):
in_audit = True
continue
if in_audit and line.startswith("## "):
break
if in_audit and line.strip().startswith("- flag:"):
n_flags += 1
except OSError:
return None
if n_flags:
return "Audit flag: %d flags in PhaseEnd_Phase%s.md" % (n_flags, newest_phase)
return None
def _run_age_note(run_id, root, conf):
"""Return ' (last record <age> ago)' or ' (last record <age> ago, stale)' for the seed line."""
# find the transcript file for this run
transcript = None
ledger_dir = os.environ.get("PA_LEDGER_DIR") or os.path.join(
os.path.expanduser("~"), ".claude", "usage-ledger")
db_path = os.path.join(ledger_dir, "usage-ledger.db")
if os.path.isfile(db_path):
try:
import sqlite3
conn = sqlite3.connect(db_path, timeout=2)
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT transcript_path FROM agent_runs WHERE run_id=?",
(run_id,)).fetchone()
conn.close()
if row and row["transcript_path"]:
transcript = row["transcript_path"]
except Exception:
pass
if not transcript:
rpath = os.path.join(ledger_dir, "running.json")
if os.path.isfile(rpath):
try:
with open(rpath, encoding="utf-8") as fh:
rdoc = json.load(fh)
for _sid, sess in (rdoc.get("sessions") or {}).items():
for aid, ag in (sess.get("agents") or {}).items():
if aid == run_id and ag.get("transcript_path"):
transcript = ag["transcript_path"]
break
if transcript:
break
except Exception:
pass
if not transcript or not os.path.isfile(transcript):
return ""
# compute age
try:
mtime = os.path.getmtime(transcript)
age_s = max(0, datetime.datetime.now(datetime.timezone.utc).timestamp() - mtime)
except OSError:
return ""
age_min = age_s / 60.0
if age_min < 60:
age_str = "%.0fm" % age_min
else:
age_str = "%.1fh" % (age_min / 60.0)
# read liveness_min from config
liveness_min = 5
cfg_path = os.path.join(ledger_dir, "config.json")
if os.path.isfile(cfg_path):
try:
with open(cfg_path, encoding="utf-8") as fh:
cdoc = json.load(fh)
lm = (cdoc.get("resume") or {}).get("liveness_min")
if isinstance(lm, (int, float)) and lm > 0:
liveness_min = lm
except Exception:
pass
if age_min > liveness_min:
return " (last record %s ago, stale)" % age_str
return " (last record %s ago)" % age_str
def _card_slice(root, role):
"""Run ``card.py slice <role>`` and return its output, or '' on failure."""
card_py = os.path.join(os.path.dirname(os.path.abspath(__file__)), "card.py")
if not os.path.isfile(card_py):
return ""
try:
res = subprocess.run([sys.executable, card_py, "slice", role],
cwd=root, capture_output=True, text=True, timeout=10)
if res.returncode == 0 and res.stdout.strip():
return res.stdout.rstrip()
except Exception:
pass
return ""
def _deferred_items(root, conf, phase, every=False):
"""-> [(id, text)] deferred items due for `phase` (scan: _genplan.deferred_items);
every=True: every open deferral (planner-gen triage)."""
gp = os.path.join(root, "GENERATION_PLAN.md")
gtext = PE.read_text(gp) if os.path.isfile(gp) else ""
return [(d["id"], d["text"])
for d in PE._genplan.deferred_items(PE.phase_dir(root, conf), gtext, phase, every=every)]
def _upgrade_line(root):
"""`Upgrade:` seed line when .claude/pa3-upgrade/UPGRADE.md lists unresolved conflicts
(`- ` lines without ` | resolve:`, T20)."""
path = os.path.join(root, ".claude", "pa3-upgrade", "UPGRADE.md")
try:
with open(path, encoding="utf-8") as f:
n = sum(1 for ln in f if ln.startswith("- ") and " | resolve:" not in ln)
except OSError:
return None
if not n:
return None
return ("Upgrade: %d file(s) in .claude/pa3-upgrade/UPGRADE.md await a resolution "
"(keep | upstream | merged) by an upgrade expert; the router spawns it before the "
"first task; nothing is copied by hand" % n)
def write_seed(root, conf, mode, phase, gen, args):
pe = rel(root, PE.phase_dir(root, conf))
plan = PE.plan_path(root, conf)
py = conf.get("python") or "python"
body = [SEED_HEAD.format(mode=mode, phase=phase, gen=gen, pe=pe,
plan=rel(root, plan), py=py)]
preset = conf.get("preset") or "max20" # max5/pro have no hard rung (pa/install/ladder.PRESETS)
body.append("Preset: %s (hard rung: %s)\n"
% (preset, "none" if preset in ("max5", "pro") else "expert-fable"))
cur = os.path.join(PE.phase_dir(root, conf), "current")
if mode == "router":
body.append(_arm_line(root)) # first, before Run in flight / Running (3.9.5 T6)
st = _status_doc(root)
if st.get("task") and st.get("run_id") and st.get("killed_at"):
progress = os.path.join(cur, "TASK_PROGRESS.md")
if os.path.isfile(progress):
progress_note = "PROGRESS: phase-ends/current/TASK_PROGRESS.md (written at the resume from the killed run's transcript)"
else:
progress_note = "(no progress file: the transcript was not found; respawn from the plan)"
age_note = _run_age_note(st["run_id"], root, conf)
unstop_note = ""
if st.get("unstop_cleared"):
unstop_note = " (stoppedByUser cleared by the resume hook)"
body.append(
"Run in flight: %s (task %s, %s) was killed with the previous process at %s%s%s; "
"continue it first: SendMessage(to=\"%s\", message=\"continue: %s -- the session was "
"resumed; your files and the plan are unchanged\") and end your turn to wait for its "
"notification; when the send fails (the harness refuses an agent killed with its "
"process) respawn %s as attempt %d with %s\n"
% (st["run_id"], st["task"], st.get("expert_agent_type") or "?", st["killed_at"],
age_note, unstop_note,
st["run_id"], st["task"], st["task"], (st.get("attempt") or 1) + 1, progress_note))
elif st.get("task") and st.get("run_id") and st.get("kind") in ("task", "handoff", "relaunch"):
body.append("Running: %s run %s (attempt %s, %s) — a resumed session: SendMessage that run first; "
"respawn only if it is unknown\n" % (st["task"], st["run_id"], st.get("attempt") or 1,
st.get("kind")))
body.append("## Tasks")
body.append(_capture(["show", "--tasks", "--titles"], root))
body.append("\n## Next")
body.append(_capture(["next"], root))
# inline the router card slice
card = _card_slice(root, "router")
if card:
body.append("\n## Card")
body.append(card)
elif mode.startswith("planner"):
body.append("## Generation phases")
for pid, name, st, _raw in (gen_phases(root) or []):
body.append("- %s %s | status: %s" % (pid, name, st))
# curate line: the closing-gen line is planner-gen only (while a generation is open,
# its predecessor's GenerationEnd still exists; the 3.3 trial ran the curator at a
# phase-planning launch because of that); the migration line is any mode (3.14 T2)
curate_line = _curate_seed_line(root, conf, mode)
if curate_line:
body.append(curate_line)
if os.path.isfile(os.path.join(cur, "REPLAN.md")):
body.append("\nREPLAN.md is present: read it first, then rewrite the plan.")
# deferred items due for the planned phase (first open); each gets a ## Triage line.
# planner-gen: every open deferral, triaged by `discuss` first (Triage: line)
gp = os.path.join(root, "GENERATION_PLAN.md")
planned = PE._genplan.first_open(PE.read_text(gp)) if os.path.isfile(gp) else None
items = _deferred_items(root, conf, planned, every=(mode == "planner-gen"))
if mode == "planner-gen" and items:
body.append("Triage: %d deferred items" % len(items))
for did, dtext in items:
body.append("Deferred: %s %s" % (did, dtext))
body.append("\nSpawn %s to draft, ask the summary question, stop at approval. "
"After approval: python tools/plan_edit.py approve --planner \"%s\""
% (mode, PLANNER_LABEL))
elif mode == "review":
body.append("Read %s/current/REVIEW.md, answer outcome-first, "
"record decisions via plan_edit.py or INBOX.md." % pe)
for name in ("INBOX.md", "REPLAN.md", "REVIEW.md", "TASK_PROGRESS.md"):
if os.path.isfile(os.path.join(cur, name)):
body.append("present: %s/current/%s" % (pe, name))
upg = _upgrade_line(root) # staged install conflicts (3.9.7 T8)
if upg:
body.append(upg)
if not mode.startswith("planner"): # planner modes carry it above (3.14 T2)
curate_line = _curate_seed_line(root, conf, mode)
if curate_line:
body.append(curate_line)
if mode == "router":
audit_flag = _audit_flag_line(root)
if audit_flag:
body.append(audit_flag)
body.append("\nLoop from here: INBOX consume -> plan_edit.py next -> "
"status.py set -> spawn the expert in the background -> "
"one line -> end the turn.")
text = "\n".join(body).rstrip() + "\n"
seed = os.path.join(root, ".run", "seed.md")
PE.write_text(seed, text)
return seed
def _capture(argv, root):
"""Run plan_edit in-process and capture its ≤40 lines."""
import io
buf = io.StringIO()
old, sys.stdout = sys.stdout, buf
try:
PE.main(argv)
except SystemExit:
pass
except Exception as exc:
buf.write("(plan_edit %s: %s)\n" % (type(exc).__name__, exc))
finally:
sys.stdout = old
return buf.getvalue().rstrip("\n")
# --------------------------------------------------------------------------- #
def build_cmd(mode, project, phase, conf=None, args=None):
spec = dict(MODES[mode])
conf = conf or {}
if mode == "router":
spec["perm"] = conf.get("router_permission_mode") or spec["perm"]
if args is not None:
if args.model:
spec["model"] = args.model
if args.effort:
spec["effort"] = args.effort
if args.perm:
spec["perm"] = args.perm
name = "pa:%s:%s:P%s" % (project, mode, phase)
if args is not None and args.resume:
return ["claude", "--resume", args.resume, "--effort", spec["effort"]], spec
cmd = ["claude"]
if spec["agent"]:
cmd += ["--agent", spec["agent"]]
cmd += ["--model", spec["model"], "--effort", spec["effort"]]
if spec["perm"]:
cmd += ["--permission-mode", spec["perm"]]
cmd += ["--name", name]
spec["name"] = name
return cmd, spec
def display(cmd):
out = []
quote_next = False
for c in cmd:
if quote_next or " " in c:
out.append('"%s"' % c)
else:
out.append(c)
quote_next = (c == "--name")
return " ".join(out)
def child_env(mode, phase):
env = dict(os.environ)
env["CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH"] = "3"
env["CLAUDE_CODE_TOTAL_TOKENS_REMINDER"] = "off"
env["PA_MODE"] = mode
env["PA_PHASE"] = str(phase)
env["PA_LAUNCH_TS"] = now()
return env
# --------------------------------------------------------------------------- #
def main(argv=None):
p = argparse.ArgumentParser(
prog="launch.py", description="Detect the session state and start it.")
p.add_argument("--mode", choices=sorted(MODES))
p.add_argument("--effort")
p.add_argument("--model")
p.add_argument("--perm", choices=["acceptEdits", "default", "plan", "bypass", "auto"])
p.add_argument("--resume", metavar="SID")
p.add_argument("--request", metavar="MODE", choices=sorted(MODES),
help="write .run/next_mode and exit")
p.add_argument("--dry-run", action="store_true")
p.add_argument("--seed-only", action="store_true",
help="write .run/seed.md + launch.json and print the state; no launch "
"(the running pa-session calls this)")
p.add_argument("--force", action="store_true")
p.add_argument("--plain", action="store_true",
help="fallback session: no --agent, Fable high")
p.add_argument("--check-fable", action="store_true",
help="read ~/.claude/usage-ledger/summary.json for the Fable window")
a = p.parse_args(argv)
root = PE.find_root()
conf = PE.cfg(root)
if a.request:
PE.write_text(os.path.join(root, ".run", "next_mode"), a.request + "\n")
sys.stdout.write("next_mode: %s (consumed by the next launch.py)\n" % a.request)
return 0
if a.plain:
mode, reason = "plain", "--plain fallback"
else:
mode, reason, _consumed = detect_state(root, conf, a.mode)
project = conf.get("project") or os.path.basename(root)
phase = phase_id(root, conf)
gen = generation_id(root, conf)
notes = []
preflight(root, conf, mode, a, notes)
seed = write_seed(root, conf, mode, phase, gen, a)
cmd, spec = build_cmd(mode, project, phase, conf, a)
PE.write_text(os.path.join(root, ".run", "launch.json"),
json.dumps({"mode": mode, "reason": reason, "phase": phase,
"generation": gen, "project": project,
"model": spec["model"], "effort": spec["effort"],
"agent": spec["agent"], "name": spec.get("name"),
"resume": a.resume, "ts": now(),
"cmd": display(cmd)}, indent=1) + "\n")
if a.seed_only:
sys.stdout.write("seed=%s\nmode=%s\n" % (rel(root, seed), mode))
# Phase ownership (3.10 T28): follows whichever session ran `go` last; a relaunched
# router takes over by restamping .run/status.json router_session (other keys kept).
sid = own_session_id(root) if mode != "plain" else None
if sid:
doc = _status_doc(root)
doc["router_session"], doc["updated"] = sid, now()
PE.write_text(os.path.join(root, ".run", "status.json"),
json.dumps(doc, ensure_ascii=False, indent=1) + "\n")
sys.stdout.write("router_session=%s\n" % sid)
return 0
out = ["state=%s (%s)" % (mode, reason),
"project=%s phase=%s generation=%s" % (project, phase, gen),
"seed=%s" % rel(root, seed)]
out += notes[:6]
out.append(display(cmd))
PE.emit(out)
if a.dry_run:
return 0
env = child_env(mode, phase)
try:
return subprocess.call(cmd, env=env, cwd=root)
except FileNotFoundError:
die("`claude` is not on PATH")
if __name__ == "__main__":
try:
sys.exit(main())
except SystemExit:
raise
except Exception as exc:
sys.stdout.write("refused: %s: %s\n" % (type(exc).__name__, exc))
sys.exit(1)
+1
View File
@@ -37,6 +37,7 @@ PY = {{PY}}
| genend_index | `PY tools/genend_index.py` | assemble and lint a GenerationEnd |
| commit_task | `bash tools/commit_task.sh` | the only commit path; explicit paths, no trailers, never pushes |
| run | `bash tools/run.sh` | any command that may print >40 lines; `--bg` / `--wait` for long compute |
| expert_ttl | `PY tools/expert_ttl.py --coder <coder>` | prints `1h` or `5m`: a task's expert agent or its `-5m` twin |
| {{PROJECT_TOOL}} | `{{PROJECT_TOOL_COMMAND}}` | {{PROJECT_TOOL_PURPOSE}} |
## Skills <!-- roles: expert planner -->
+1 -1
View File
@@ -43,7 +43,7 @@ Approved: <date> Planner: <model/effort> Plan-hash: <sha>
- <id> | <status> | <agent> | <key>: <value> | <key>: <value> | …
Positional : id | status | agent
Statuses : done · next · queued · blocked · superseded
Agent : expert-opus55 (default, medium) · expert-fable (effort: high: only for a judgment no test can arbitrate, at most one task in five, named in ## Rationale; a line naming the retired expert-fable-high runs expert-fable)
Agent : expert-opus55 (default, medium) · expert-fable (effort: high: only for a judgment no test can arbitrate, at most one task in five, named in ## Rationale; a line naming the retired expert-fable-high runs expert-fable) · each has a 5m-TTL twin, expert-opus55-5m / expert-fable-5m: the 1h agent is the default, the -5m twin when `tools/expert_ttl.py` prints 5m
Keys : title: <a few words naming the task; the statusline and the expert's label show it>
coder: opus55|none (sonnet accepted in old plans) · effort: medium|high · files: <paths, comma-separated>
done-when: <observable> · verify: <command the expert runs before done>
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""expert_ttl.py -- the cache TTL for a task's expert: prints exactly ``1h`` or ``5m`` (3.15 T5).
PY tools/expert_ttl.py [--coder opus55|none] [--project <root>]
``1h`` -> expert-opus55 / expert-fable; ``5m`` -> the ``-5m`` twin (planner-phase).
``--coder none`` -> 5m (the expert never waits on a coder). Otherwise the project's
finished expert runs (ledger, all roots, deduped by run_id): fewer than
``ttl_choice.min_runs`` -> 1h; mean per-run idle minutes >= ``ttl_choice.idle_min``
-> 1h, else 5m (3.15 T5.1: mean, not median; cost is linear in idle minutes). Per-run idle
= sum of waits > 300 s, each clipped at 60 min; a wait runs from a non-ping
turn to the next non-ping turn (gap_s of the ping turns between plus the ending
turn); ping turn = the run's first turn 0..60 s after a ``warm_ping`` event of that
run (as ``pa/audit.py`` ``_compute_warm_pings``). Any error -> 1h. Exit 0; stdout
carries the answer and nothing else.
"""
import argparse
import contextlib
import json
import os
import re
import statistics
import subprocess
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
EXPERTS = ("expert-opus55", "expert-fable", "expert-opus55-5m", "expert-fable-5m")
WAIT_S = 300 # a wait longer than the 5m TTL is idle
PING_WINDOW_S = 60 # ping turn: first turn within this after a warm_ping
CLIP_S = 3600 # a wait counts at most 60 min (past it both TTLs pay bounded costs)
FALLBACK = {"min_runs": 5, "idle_min": 15} # the installed 3.14.2 config has no ttl_choice
def _pa_home():
"""<script>/.. when it holds pa/, else ~/.claude/pa3."""
return HERE.parent if (HERE.parent / "pa").is_dir() else Path.home() / ".claude" / "pa3"
def _pa():
root = str(_pa_home())
if root not in sys.path:
sys.path.insert(0, root)
import pa
import pa.config
import pa.db
import pa.transcript
return pa
def norm(path):
"""Comparable project path: ``/`` separators, no trailing ``/``, ``/mnt/<d>/x`` == ``<d>:/x``,
drive paths case-folded."""
s = str(path or "").replace("\\", "/").rstrip("/")
m = re.match(r"^/mnt/([a-zA-Z])(/.*)?$", s)
if m:
s = m.group(1) + ":" + (m.group(2) or "")
if re.match(r"^[a-zA-Z]:", s):
s = s.casefold()
return s
def git_root():
"""The cwd's git root, else the cwd."""
try:
out = subprocess.run(["git", "rev-parse", "--show-toplevel"], capture_output=True,
text=True, timeout=10)
if out.returncode == 0 and out.stdout.strip():
return out.stdout.strip()
except (OSError, subprocess.SubprocessError):
pass
return os.getcwd()
def idle_s(turns, ping_ts, parse_ts):
"""Idle seconds of one run: ``turns`` = [(ts, gap_s)] by ts; ``ping_ts`` = its warm_ping ts."""
tts = [parse_ts(t) for t, _g in turns]
ping_idx = set()
for ets in (parse_ts(p) for p in ping_ts):
if ets is None:
continue
for i, t in enumerate(tts):
if t is not None and 0 <= (t - ets).total_seconds() <= PING_WINDOW_S and i not in ping_idx:
ping_idx.add(i)
break
idle, wait, started = 0.0, 0.0, False
for i, (_t, gap) in enumerate(turns):
if i in ping_idx:
wait += gap or 0.0
continue
if started:
wait += gap or 0.0
if wait > WAIT_S:
idle += min(wait, CLIP_S)
started, wait = True, 0.0
return idle
def _conn_idles(conn, target, seen, parse_ts):
"""``{run_id: idle_min}`` of *conn*'s finished expert runs of project *target* not in *seen*."""
ph = ",".join("?" * len(EXPERTS))
rows = conn.execute(
"SELECT r.run_id, s.project FROM agent_runs r JOIN sessions s ON s.session_id = r.session_id"
" WHERE r.agent_type IN (%s) AND r.ended IS NOT NULL ORDER BY r.run_id" % ph,
EXPERTS).fetchall()
runs = [r[0] for r in rows if r[0] not in seen and norm(r[1]) == target]
if not runs:
return {}
want = set(runs)
pings = {}
for ts, rid, detail in conn.execute(
"SELECT ts, run_id, detail_json FROM events WHERE kind = 'warm_ping' ORDER BY ts, id"):
try:
d = json.loads(detail or "{}")
except ValueError:
d = {}
rid = (d.get("run_id") if isinstance(d, dict) else None) or rid
if rid in want:
pings.setdefault(rid, []).append(ts)
out = {}
for rid in runs:
turns = [(t[0], t[1]) for t in conn.execute(
"SELECT ts, gap_s FROM turns WHERE run_id = ? ORDER BY ts, msg_id", (rid,))]
out[rid] = idle_s(turns, pings.get(rid, []), parse_ts) / 60.0
return out
def collect(project, cfg=None):
"""``({run_id: idle_min}, ttl_choice)`` for *project* over every ledger root."""
pa = _pa()
cfg = pa.config.load() if cfg is None else cfg
choice = dict(FALLBACK)
choice.update(cfg.get("ttl_choice") or {})
readers = pa.db.union_readers(cfg.get("extra_roots") or [], include_local=True)
target, idles = norm(project), {}
try:
for r in readers:
idles.update(_conn_idles(r["conn"], target, idles, pa.transcript.parse_ts))
finally:
for r in readers:
with contextlib.suppress(Exception):
r["conn"].close()
return idles, choice
def choose(coder, project, cfg=None):
"""``"1h"`` or ``"5m"``."""
if coder == "none":
return "5m"
idles, choice = collect(project, cfg)
if len(idles) < choice["min_runs"]:
return "1h"
return "1h" if statistics.mean(idles.values()) >= choice["idle_min"] else "5m"
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("--coder", default="opus55", help="opus55 (default) or none")
ap.add_argument("--project", default=None, help="project root (default: the cwd's git root)")
try:
args = ap.parse_args(argv)
except SystemExit as exc:
if exc.code == 0: # --help
raise
args = None
ans = "1h"
if args is not None:
try:
with contextlib.redirect_stdout(sys.stderr):
ans = choose(args.coder, args.project or git_root())
except Exception: # any error -> 1h
ans = "1h"
out = getattr(sys.stdout, "buffer", None)
if out is not None: # bytes: no \r\n translation on Windows
sys.stdout.flush()
out.write((ans + "\n").encode("ascii"))
out.flush()
else:
sys.stdout.write(ans + "\n")
return 0
if __name__ == "__main__":
sys.exit(main())
+9 -4
View File
@@ -347,7 +347,7 @@ def _audit_flag_line(root):
pe_files.append((os.path.join(sub, name), m.group(1)))
if not pe_files:
return None
# natural sort by phase id (type-safe: ids like 3_5 mix digits and words)
# natural sort, the one rule (3.14.1: a local splitter raised TypeError on ids like 3_5 beside 37.5)
from phaseend_index import natural_key
pe_files.sort(key=lambda item: natural_key(item[1]))
newest_rel, newest_phase = pe_files[-1]
@@ -481,14 +481,17 @@ def write_seed(root, conf, mode, phase, gen, args):
pe = rel(root, PE.phase_dir(root, conf))
plan = PE.plan_path(root, conf)
py = conf.get("python") or "python"
body = [SEED_HEAD.format(mode=mode, phase=phase, gen=gen, pe=pe,
plan=rel(root, plan), py=py)]
head = SEED_HEAD.format(mode=mode, phase=phase, gen=gen, pe=pe,
plan=rel(root, plan), py=py)
if mode == "router": # seed line 2, under the title (3.15 T6: head -3 shows it)
title, rest = head.split("\n", 1)
head = title + "\n" + _arm_line(root) + rest
body = [head]
preset = conf.get("preset") or "max20" # max5/pro have no hard rung (pa/install/ladder.PRESETS)
body.append("Preset: %s (hard rung: %s)\n"
% (preset, "none" if preset in ("max5", "pro") else "expert-fable"))
cur = os.path.join(PE.phase_dir(root, conf), "current")
if mode == "router":
body.append(_arm_line(root)) # first, before Run in flight / Running (3.9.5 T6)
st = _status_doc(root)
if st.get("task") and st.get("run_id") and st.get("killed_at"):
progress = os.path.join(cur, "TASK_PROGRESS.md")
@@ -686,6 +689,8 @@ def main(argv=None):
"resume": a.resume, "ts": now(),
"cmd": display(cmd)}, indent=1) + "\n")
if a.seed_only:
if mode == "router": # the arm line first on stdout (3.15 T6)
sys.stdout.write(_arm_line(root))
sys.stdout.write("seed=%s\nmode=%s\n" % (rel(root, seed), mode))
# Phase ownership (3.10 T28): follows whichever session ran `go` last; a relaunched
# router takes over by restamping .run/status.json router_session (other keys kept).
+1 -1
View File
@@ -374,7 +374,7 @@ def audit_lines(root, phase, phases_avail=None):
mid_i = n // 2
med = (vals[mid_i - 1] + vals[mid_i]) // 2 if n % 2 == 0 else vals[mid_i]
mx = max(vals)
sorted_phases = sorted(by_phase.keys())
sorted_phases = sorted(by_phase.keys(), key=natural_key) # 3.14.1: was text order, 3.10 before 3.9
idx = sorted_phases.index(str(phase)) if str(phase) in sorted_phases else -1
if idx > 0:
prev_phase = sorted_phases[idx - 1]
+9 -6
View File
@@ -614,7 +614,7 @@ def cmd_append_change(a, root, path, text, explicit):
return 0
# presets without a hard rung: mirrors pa/install/ladder.PRESETS where expert-fable is None
# presets without a hard rung: mirrors pa/install/ladder.PRESETS where expert-fable (and its -5m twin) is None
# (tools ship alone, no import from pa/)
_NO_HARD_RUNG = ("max5", "pro")
@@ -635,15 +635,16 @@ def _preset_refusal(preset, tid, effort, agent):
return None
if (effort or "").strip() == "high":
what = "effort: high"
elif agent in ("expert-fable", "expert-fable-high"):
elif agent in ("expert-fable", "expert-fable-5m", "expert-fable-high"):
what = "agent %s" % agent
else:
return None
return "preset %s has no hard rung: %s carries %s; mark it medium or split it" % (preset, tid, what)
# effort -> the agents that may carry it; expert-fable-high was the opt-in a task line named (retired 3.9.5 T15)
_AGENTS_FOR_EFFORT = {"high": ("expert-fable", "expert-fable-high")}
# effort -> the agents that may carry it; expert-fable-high was the opt-in a task line named (retired 3.9.5 T15);
# the -5m twins (3.15 T4) carry the same effort as their 1h parent
_AGENTS_FOR_EFFORT = {"high": ("expert-fable", "expert-fable-5m", "expert-fable-high")}
# retired agent -> the agent that runs a line naming it (lint accepts it with a note)
_RETIRED_AGENTS = {"expert-fable-high": "expert-fable"}
@@ -944,10 +945,12 @@ def cmd_lint(a, root, path, text, explicit):
problems.append("line %d: %s coder '%s' not in %s"
% (n, t.id, coder, "|".join(CODERS)))
eff = t.get("effort")
# agent/effort consistency: high -> expert-fable (or the retired expert-fable-high), else expert-opus55
# agent/effort consistency: high -> expert-fable (or the retired expert-fable-high), else expert-opus55;
# either one's -5m twin is valid where its parent is (3.15 T4)
default = _agent_for(eff, preset)
if eff and (
t.agent.startswith("expert-fable") or t.agent.startswith("expert-opus")
) and t.agent not in _AGENTS_FOR_EFFORT.get(eff, (_agent_for(eff, preset),)):
) and t.agent not in _AGENTS_FOR_EFFORT.get(eff, (default, default + "-5m")):
warns.append("line %d: %s effort '%s' but agent '%s'" % (n, t.id, eff, t.agent))
if t.agent in _RETIRED_AGENTS:
notes.append("note: %s names the retired %s; it runs %s"