mirror of
https://github.com/Druthulu/BFM-decomp
synced 2026-10-03 16:16:50 -04:00
chore: install Project Architect 3.0
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user