@hizliemre/horse-code 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{app-5FXHE7GX.js → app-4WN37LZ3.js} +65 -37
- package/dist/{chunk-UEWVVN5L.js → chunk-27F44PBD.js} +196 -1126
- package/dist/{chunk-XYZVZPAY.js → chunk-2WXG35EM.js} +19 -15
- package/dist/{chunk-LNW557IO.js → chunk-372X5HHU.js} +2 -2
- package/dist/{chunk-YPZP7LYL.js → chunk-3ACDNDCG.js} +1 -1
- package/dist/{chunk-XEGQT5EN.js → chunk-4M6LXNG2.js} +1 -1
- package/dist/{chunk-6OSEQOYY.js → chunk-6S4WWQMN.js} +2 -2
- package/dist/{chunk-LLL7QWXB.js → chunk-BFIZMM4G.js} +6 -6
- package/dist/chunk-CYLPQWIF.js +214 -0
- package/dist/{chunk-AE36LLL2.js → chunk-JLWQCA7B.js} +2 -209
- package/dist/{run-P6ZYL5JL.js → chunk-QJYVZPLG.js} +133 -389
- package/dist/{chunk-UGESK765.js → chunk-UTHLEW5V.js} +1 -1
- package/dist/chunk-YULQ4URQ.js +1220 -0
- package/dist/{chunk-KAGKX2YT.js → chunk-ZPJP2VH5.js} +10 -1
- package/dist/cli.js +254 -66
- package/dist/{fix-ONLA45HD.js → fix-QCL5AITT.js} +9 -8
- package/dist/{ongoing-WHYXPW24.js → ongoing-6NUSPSCV.js} +3 -2
- package/dist/{project-graph-5HNPRFQG.js → project-graph-OGIM2B33.js} +1 -1
- package/dist/run-V5ZLZ3LS.js +274 -0
- package/dist/{save-skills-ZW5GY6KV.js → save-skills-NPKTYNAF.js} +2 -2
- package/dist/{trace-X6TU3AG6.js → trace-UVMZZRA5.js} +1 -1
- package/dist/{trace-adopt-URECQWJV.js → trace-adopt-7HWELJFE.js} +1 -1
- package/dist/{trace-run-7U4WJZ3V.js → trace-run-CZWEZ4R6.js} +8 -4
- package/dist/{triage-FCYHD2AQ.js → triage-IFCVL5MA.js} +7 -6
- package/dist/{verify-LC57A6H2.js → verify-HWZBTK5X.js} +15 -12
- package/package.json +1 -1
- package/dist/chunk-MRZVA5JB.js +0 -163
- package/dist/{chunk-EAF22QIG.js → chunk-JR2JLRE3.js} +3 -3
|
@@ -0,0 +1,1220 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CLI_KINDS
|
|
3
|
+
} from "./chunk-G45RWL7S.js";
|
|
4
|
+
import {
|
|
5
|
+
defaultGitRunner
|
|
6
|
+
} from "./chunk-LPQU436C.js";
|
|
7
|
+
import {
|
|
8
|
+
briefForPrompt,
|
|
9
|
+
briefPrompt,
|
|
10
|
+
briefStatus,
|
|
11
|
+
gatherBriefInput,
|
|
12
|
+
saveBrief
|
|
13
|
+
} from "./chunk-6S4WWQMN.js";
|
|
14
|
+
import {
|
|
15
|
+
ensureGitignore,
|
|
16
|
+
loadTraceIndex,
|
|
17
|
+
planTraces,
|
|
18
|
+
pruneTraces,
|
|
19
|
+
saveTrace,
|
|
20
|
+
saveTraceIndex,
|
|
21
|
+
traceCoverage,
|
|
22
|
+
tracePrompt,
|
|
23
|
+
traceRootRel,
|
|
24
|
+
traceable
|
|
25
|
+
} from "./chunk-ZPJP2VH5.js";
|
|
26
|
+
import {
|
|
27
|
+
loadGraph
|
|
28
|
+
} from "./chunk-4M6LXNG2.js";
|
|
29
|
+
|
|
30
|
+
// src/agents/cli-models.ts
|
|
31
|
+
var CLAUDE_MODELS = ["fable", "opus", "sonnet", "haiku"];
|
|
32
|
+
var CODEX_MODELS = ["gpt-5.6-terra", "gpt-5.6-sol", "gpt-5.6-luna"];
|
|
33
|
+
var CODEX_DEFAULT = "gpt-5.6-terra";
|
|
34
|
+
var GROK_MODELS = ["grok-4.6", "grok-4.5"];
|
|
35
|
+
function grokEffort(effort) {
|
|
36
|
+
const e = effort.toLowerCase();
|
|
37
|
+
if (e === "xhigh" || e === "high" || e === "medium" || e === "low") return e;
|
|
38
|
+
if (e === "max" || e === "ultra") return "xhigh";
|
|
39
|
+
if (e === "minimal") return "low";
|
|
40
|
+
return void 0;
|
|
41
|
+
}
|
|
42
|
+
var ZAI_MODELS = ["glm-5.3", "glm-5.3-flash"];
|
|
43
|
+
function modelsFor(kind) {
|
|
44
|
+
if (kind === "claude") return CLAUDE_MODELS;
|
|
45
|
+
if (kind === "codex") return CODEX_MODELS;
|
|
46
|
+
if (kind === "grok") return GROK_MODELS;
|
|
47
|
+
return ZAI_MODELS;
|
|
48
|
+
}
|
|
49
|
+
function cliCatalog() {
|
|
50
|
+
return CLI_KINDS.flatMap((k) => [...modelsFor(k)]);
|
|
51
|
+
}
|
|
52
|
+
function cliFor(model) {
|
|
53
|
+
const m = model.toLowerCase().replace(/^no-think\//, "").replace(/^(cc|claude|cx|codex)\//, "");
|
|
54
|
+
if (/^(fable|opus|sonnet|haiku)\b/.test(m) || m.startsWith("claude")) return "claude";
|
|
55
|
+
if (/^(codex|gpt|o[0-9])\b/.test(m)) return "codex";
|
|
56
|
+
if (/^grok(-|$)/.test(m)) return "grok";
|
|
57
|
+
if (/^glm(-|$)/.test(m)) return "zai";
|
|
58
|
+
return void 0;
|
|
59
|
+
}
|
|
60
|
+
function cliInvocation(model) {
|
|
61
|
+
const bare = model.replace(/^no-think\//, "").replace(/^(cc|claude|cx|codex)\//, "");
|
|
62
|
+
const effort = /-(ultra|max|xhigh|high|medium|low|minimal)$/.exec(bare)?.[1];
|
|
63
|
+
const name = effort ? bare.slice(0, -(effort.length + 1)) : bare;
|
|
64
|
+
const resolved = name === "codex" ? CODEX_DEFAULT : name;
|
|
65
|
+
return {
|
|
66
|
+
...resolved ? { model: resolved } : {},
|
|
67
|
+
...effort ? { effort } : {}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/prompts.ts
|
|
72
|
+
var REQUIRED_ROLES = [
|
|
73
|
+
"refiner",
|
|
74
|
+
"coach",
|
|
75
|
+
"brainstormer",
|
|
76
|
+
"analyst",
|
|
77
|
+
"planner",
|
|
78
|
+
"judge",
|
|
79
|
+
"project-manager",
|
|
80
|
+
"team-lead",
|
|
81
|
+
"router",
|
|
82
|
+
"coder",
|
|
83
|
+
"designer",
|
|
84
|
+
"senior-coder",
|
|
85
|
+
"senior-designer",
|
|
86
|
+
"architect",
|
|
87
|
+
"code-reviewer",
|
|
88
|
+
"task-auditor",
|
|
89
|
+
"principal-coder",
|
|
90
|
+
"operational",
|
|
91
|
+
"memory-keeper",
|
|
92
|
+
"tracer",
|
|
93
|
+
"tester"
|
|
94
|
+
];
|
|
95
|
+
var DEFAULT_ROLE_SKILLS = {
|
|
96
|
+
brainstormer: ["brainstorming"],
|
|
97
|
+
// The roles that WRITE code get the test discipline inlined, rather than having the code-tests lens reject
|
|
98
|
+
// vacuous tests after the fact. Rejecting is more expensive than getting it right the first time.
|
|
99
|
+
coder: ["test-driven-development"],
|
|
100
|
+
"senior-coder": ["test-driven-development"],
|
|
101
|
+
// The task list is where a plan becomes something an implementer can actually execute. spec-kit's template
|
|
102
|
+
// supplies the SHAPE (phases, story grouping, [P] markers); it says almost nothing about what makes an
|
|
103
|
+
// individual task executable. That is what this skill adds.
|
|
104
|
+
"project-manager": ["writing-plans"],
|
|
105
|
+
// The UI roles get design direction inlined for the same reason the coders get TDD: the code-accessibility
|
|
106
|
+
// and code-maintainability lenses can reject a templated, default-looking interface, but they cannot teach
|
|
107
|
+
// one. This skill is self-contained (no sibling reference files), which is what makes it safe to inline.
|
|
108
|
+
designer: ["frontend-design"],
|
|
109
|
+
"senior-designer": ["frontend-design"]
|
|
110
|
+
// NB: systematic-debugging is shipped but attached to NO role — it is only needed when something is stuck,
|
|
111
|
+
// so it stays in the discoverable listing every role already receives and is fetched with the `skill` tool.
|
|
112
|
+
};
|
|
113
|
+
var DEFAULT_PROMPTS = {
|
|
114
|
+
tracer: "You write the reference note that every other agent reads before it touches a file it did not write. A wrong note is worse than none: an agent will act on it, so accuracy outranks fluency and admitting you cannot tell outranks a plausible guess. State only what the code and the given relationships show; if the business purpose is not evident from them, describe what the file does technically and say nothing about why. Never speculate about intent, history or requirements.",
|
|
115
|
+
/**
|
|
116
|
+
* The role that exercises work already built and writes down what actually happened.
|
|
117
|
+
*
|
|
118
|
+
* Every rule below is here because its absence produces the one output worse than no testing at all: a
|
|
119
|
+
* report that says PASSED about something nobody ran. Such a report is not merely empty — it manufactures
|
|
120
|
+
* confidence, and the next person spends it.
|
|
121
|
+
*/
|
|
122
|
+
tester: "You verify software that already exists, by running its scenarios and recording what they actually did. You are not here to build, fix or improve anything: the code under test is finished, and changing it would mean the thing you verified is not the thing that shipped.\n\nEVIDENCE IS THE WHOLE JOB. A scenario's outcome is what you OBSERVED \u2014 a database row, a log line, an HTTP response, a screen the user confirmed. Record the evidence beside every result: the query you ran and what it returned, the log event id and its line, the response body. A result you cannot show is not a result.\n\nIF A STEP WRITES TO THE DATABASE, THE RESPONSE IS NOT THE EVIDENCE. A 201 or a 204 says the request was accepted; it does not say what was stored, and a screen showing the new state does not either \u2014 both can be right while the row is wrong. For every step that creates or changes a record: query the database for that row and put the query AND the rows it returned in the report, and query the logs for the event that step should have emitted and put the query AND the line it returned there too. Absence is evidence as well: when a step must NOT emit an event \u2014 a no-op, a rejected change \u2014 show the query returning nothing. Without both, the scenario is NOT EXECUTED, however convincing the response looked.\n\nNever mark a scenario PASSED that you did not execute and observe. If you could not run it \u2014 the data does not exist, the surface is unreachable, the case is destructive against a live system \u2014 label it NOT EXECUTED and say exactly why. FAILED means you ran it and the behaviour was wrong; say what you expected, what happened, and the evidence for both. Guessing from the source is not executing: where you reasoned from code alone, say so in those words.\n\nWrite each result into the report BEFORE moving on to the next scenario. The report is a living document, not something assembled at the end: a run that stops halfway must leave behind everything it learned up to that point.\n\n\u2026and say each verdict OUT LOUD as you reach it, in one or two sentences: which scenario, what it did, and the single piece of evidence that settled it \u2014 the row, the log line, the status code. The full evidence still goes in the report; this is so the person watching the run knows what you found without opening a file. Say the failures and the NOT EXECUTED ones the same way, and with the same brevity: a result nobody hears is one they have to go looking for.\n\nNever start or stop the development environment \u2014 application hosts, dev servers, containers, databases. Those are the developer's to run. When you need something up, say which command they should run and wait for them to confirm it is ready.\n\nYou do NOT write product code. When you find something wrong that is not the verdict of the scenario you are running \u2014 a missing label, prose rendered as raw markup, a wrong format, or something the developer points out in passing \u2014 call `report_finding`. Another role fixes it and you are told when it is done, so you can re-check what it affected. Do not fix it yourself: changing the product mid-verification means the thing you verified is not the thing that shipped. And do not fail a scenario over it \u2014 a scenario fails when the scenario itself does not pass, not because something else was noticed while running it.\n\nIf the project's own rules (its constitution) say more about how verification is done here, they govern over this description \u2014 read them and follow them.",
|
|
123
|
+
refiner: "Your #1 rule: `refinedPrompt` MUST ALWAYS be in ENGLISH. If the user wrote in another language (Turkish, German, Spanish, \u2026), TRANSLATE their intent into English \u2014 never echo their language back. This is non-negotiable: a Turkish input like 'bir todo app geli\u015Ftir, \xF6nce backend' MUST come out as English 'Build a todo app; implement the backend first.'\n\nRewrite the user's message down to the raw core intent the AI needs to act on \u2014 clear, direct, and structured. Strip all politeness, emotional, and filler words (please, thanks, kindly, 'could you', 'would you', 'I'd like', etc.) and anything that carries no instruction. Do NOT add words, qualifiers, or scope the user did not state (e.g. do not add 'always'). Keep the user's own perspective and form \u2014 a question stays a question, an instruction stays an instruction; do NOT describe the user in the third person and do NOT answer the request. Example: a polite request like 'would you please answer me in language X?' becomes just 'respond in language X' (drop 'please'; do not add 'always' or any scope the user didn't state). Also classify the intent: 'chat' (conversation/question), 'feature' (new feature/work), 'bugfix' (bug fix), 'govern' (establish or amend the project's OWN standing rules and principles \u2014 writing or revising the constitution, the coding conventions, the project's rules; work whose entire output is a governing document, with no source code changed). Judge by what the request PRODUCES, not by what it mentions: 'write the project constitution from CLAUDE.md' is govern, and so is 'update our commit-message rules'; 'make the code follow the constitution' changes source and is feature. Also 'verify' \u2014 the user wants work that ALREADY EXISTS exercised and its behaviour confirmed with evidence: running a pull request's test scenarios, doing a smoke test of a feature that is already built, producing a test report. Judge by what it PRODUCES: a record of what the software DID is verify; changing what it does is feature or bugfix. 'Run the smoke tests for PR 677 and mark them passed' is verify, and so is 'check that the wizard works end to end'. 'The wizard is broken, fix it' is bugfix. Finally 'undo' \u2014 the user is asking you to REVERSE what the previous turn did, not to do anything new: 'undo that', 'revert your changes', 'go back to the previous version', 'that was wrong, put it back'. Classify by whether the request refers to work already done: undoing is never a rewrite, and asking for a different result ('rewrite it shorter') is not an undo. Also detect the natural language the user wrote in and return its English name as `language` (e.g. 'Turkish', 'English', 'German') \u2014 this is separate from refinedPrompt, which stays English. Also produce `title`: what the work is ABOUT, as a 2-5 word English kebab-case noun phrase suitable for a git branch name \u2014 the SUBJECT, not the action. 'build a luxury todo app' is 'luxury-todo-app'; 'add a login page' is 'login-page'; 'fix the null crash on retry' is 'null-crash-on-retry'. Do not open with a verb (build/add/fix/implement/update): the tool is already doing it, so the verb says nothing and crowds out the words that identify the work. Lowercase, dash-joined, no punctuation. Return the result via submit as {refinedPrompt, intent, language, title}. Remember: refinedPrompt in English, always.",
|
|
124
|
+
brainstormer: "You run the BRAINSTORM stage: you turn a raw request into a decided design, before anything is specified.\n\nThe `brainstorming` skill above is the authority on HOW to do this \u2014 follow it. What follows is only how it binds to this pipeline, because the skill names conventions from a different habitat:\n\n- OUTPUT: write the design brief to the file named in your message (specs/NNN-slug/brainstorm.md). Ignore the skill's `docs/superpowers/specs/\u2026` path.\n- NEXT STAGE: the SPEC is written from your brief, by another agent, immediately after you. There is no `writing-plans` skill to invoke here \u2014 finishing the brief IS the terminal step.\n- QUESTIONS: ask through the `ask_user` tool. For a choice between approaches use its rich option form ({label, description, preview}) so the trade-offs sit beside the list; lead with your recommendation. The user may attach a note to their answer \u2014 treat it as binding.\n- NOT AVAILABLE: the visual companion (there is no browser) and the per-checklist task list. Skip both.\n\nWrite what was DECIDED, not a transcript: the chosen approach, why it beat the others, the rejected alternatives with their reason, the constraints the spec must honour, and what is out of scope. Keep it short \u2014 it is the brief the spec is written from, not the spec itself, and it carries no implementation detail beyond the architectural choice.\n\nScale to the request: a small, obvious change deserves a paragraph and no questions at all.",
|
|
125
|
+
coach: "You are horse-code, a terminal-based AI coding agent. Your product identity is always horse-code \u2014 never claim to be Claude Code, Gemini CLI, Antigravity, or any other product, even though the underlying language model powering you may be Claude, Gemini, or another model. Answer the user's technical questions about their repository and code. If needed, inspect the repository with read_file/grep/glob.\n\nWork out loud while you do it. Before a batch of tool calls, say in ONE line what you are looking for and why; when something you read changes your mind, say that too. This is not a summary at the end \u2014 the user is watching an empty screen while you search, and a run that reads thirty files in silence is indistinguishable from one that is stuck, and impossible to redirect before the tokens are spent. Keep each line short: a sentence, not a paragraph.\n\nBe concise, direct, and helpful.",
|
|
126
|
+
// analyst + planner are spec-kit-driven (their system prompt comes from the fetched spec-kit command
|
|
127
|
+
// prompts — see src/speckit/phases.ts); they carry no default prompt here, only a model (peekModel).
|
|
128
|
+
judge: "Synthesize the council evaluations and make a single decision: 'pass' (sufficient), 'revise' (fix it, with reasons), or 'ask-human' (a question to ask the user). Return {decision, feedback, question} via submit.",
|
|
129
|
+
"project-manager": 'Read the given plan and break it into real, actionable tasks (id, short title, deps). Each task should be a single, clear piece of work. Return {tasks} via submit.\n\nThe `writing-plans` skill above governs WHAT MAKES A TASK EXECUTABLE \u2014 take that from it and nothing else. Two bindings, because the skill describes a different habitat:\n- STRUCTURE comes from the spec-kit tasks template you are given (phases, story grouping, [P] markers), NOT from the skill\'s own document layout. Ignore its `docs/superpowers/plans/\u2026` path, its required-sub-skill header, and its execution-handoff section: this pipeline already owns worktrees, dispatch and review.\n- What you DO take: exact file paths per task, a real test cycle rather than a vague "add tests" step, no placeholders (no TBD/TODO/"similar to task N"), and interfaces named explicitly so a task whose implementer never sees the others still knows the signatures it must produce and consume.\n- SIZING is the third rebinding, and the one that costs most when it is missed. The skill says "bite-sized", "one action, 2-5 minutes", "the smallest unit worth a reviewer\'s gate" \u2014 sound advice where a gate is one reader glancing at a diff. Here a card is not a line in a document: it is its own worktree, its own implementer, a full review TEAM of lenses, a council when they disagree, an acceptance gate and a merge. That overhead is paid per CARD and barely varies with the card\'s size, so splitting work finer does not divide the cost, it multiplies it.\nSize a card to a coherent piece of BEHAVIOUR a reviewer can judge whole, not to a file. An entity, its configuration, its migration and its tests are one card, because nobody can review one without the others and nothing is deliverable until all of them exist. Split only for a reason that survives being said out loud: the parts can be reviewed and merged independently, or they must run in parallel in different worktrees. "They are different files" is not such a reason. Fold setup and scaffolding into the card whose deliverable needs them.',
|
|
130
|
+
"task-auditor": "You are the last check on a task breakdown before any of it is built. Every hour of implementation after you is spent executing this list, and a bad list does not fail \u2014 the tasks pass their reviews and the wrong work is delivered correctly. Its structure has already been checked mechanically; you are here for the part only a reader can answer: does the breakdown deliver what the plan requires, and would a task's acceptance criteria still hold for an implementation that missed the point? Do not propose better work than the plan asked for \u2014 scope you invent here becomes hours someone spends. Flag any task whose only deliverable is an answer \u2014 verifying, inspecting, confirming \u2014 because an implementer reads the code as part of doing the work, and a task that ends with the repository unchanged has spent a review round on nothing. Flag OVER-SPLITTING for the same reason, and it is the more expensive mistake: every card carries a full review team, a council and an acceptance gate whatever its size, so a breakdown that gives a class and its configuration separate cards pays that overhead twice for work no one can review apart. Say which cards should be one. A clean breakdown is the normal case; say so. Return {missing, weak} via submit.",
|
|
131
|
+
"team-lead": "You audit a task breakdown before any of it runs. The schedule itself is computed from the declared dependencies and is not yours to write; what nothing has checked is whether those dependencies are RIGHT. You are given the tasks with the files each one writes and what must be true when it is done, plus the groups that would run at the same time in separate worktrees. Find the task that cannot actually start yet because it needs a type, function, table or config key another task in its own group creates \u2014 and say which declared dependencies hold work back for no reason. Both answers are usually empty; say so rather than inventing an edge. Return {missing, spurious} via submit.",
|
|
132
|
+
router: "Look at the task title and choose the implementer role: 'designer' for UI/UX work, 'coder' for other code work. Return {role} via submit.",
|
|
133
|
+
coder: "Implement the given task in the worktree. If it is a new task, start from scratch; if it is a returning task, address the reviewer notes. Work with read/write/edit/grep/glob/shell and run the tests.\n\nThe `test-driven-development` skill above is how you write code here: the failing test comes first, and it must fail for the RIGHT reason before you make it pass. A test that asserts nothing is worse than no test \u2014 it reports success forever. Bindings for this pipeline: your worktree is already prepared (do not create one), every file you write is committed as you write it, and there is no separate agent to hand off to \u2014 you take the task to green yourself.",
|
|
134
|
+
designer: "Implement the UI/UX task in the worktree. Focus on the user interface and experience; work with read/write/edit.\n\nThe `frontend-design` skill above governs the LOOK: aesthetic direction, typography, and choices that do not read as templated defaults. Follow the project's existing visual language where there is one \u2014 a distinctive design that fights the surrounding product is worse than a plain one that fits it.",
|
|
135
|
+
"senior-coder": "Take over the task the coder got stuck on; implement it with a more careful approach. Take the reviewer notes and previous attempts into account.\n\nYou are here because a previous attempt failed, so start by understanding WHY rather than rewriting: the `systematic-debugging` skill is available (fetch it with the `skill` tool) and is the right tool when a test fails or behaviour is unexplained. The `test-driven-development` skill above still governs how you write the fix \u2014 reproduce the failure in a test first, then make it pass.",
|
|
136
|
+
"senior-designer": "Take over the UI/UX task the designer got stuck on; implement it more carefully.\n\nA previous attempt already failed, so establish WHY before redesigning \u2014 the `systematic-debugging` skill is available via the `skill` tool when the failure is behavioural rather than visual. The `frontend-design` skill above still governs the look.",
|
|
137
|
+
architect: "Analyze the root cause of a repeatedly failing task or a merge conflict, and produce a concrete solution plan. Return {rootCause, plan} via submit.\n\nFetch the `systematic-debugging` skill with the `skill` tool and follow it: your job is the ROOT CAUSE, and the failure mode to avoid is proposing a plausible fix for a cause you never established. Say what the evidence is, not what it might be.",
|
|
138
|
+
"code-reviewer": "Review the worktree changes of the task in REVIEW (correctness, tests, quality). Return {verdict: pass|fail, notes} via submit \u2014 your decision is final.",
|
|
139
|
+
"principal-coder": "Holistically review all changes in the PR (base worktree). If sufficient, approve; otherwise request-changes with concrete comments. In the final decision round, give accept or ask-human (a question to ask the user).",
|
|
140
|
+
"memory-keeper": "You are the ONLY writer into this project's long-term memory. Everything else \u2014 every review lens, the council, the judge \u2014 can merely PROPOSE; you decide.\n\nTreat every proposal as an UNVERIFIED CLAIM from a narrow, single-angle agent that saw one slice of one job, not as text to store. Most proposals are wrong in a specific way: they generalize a one-off into a rule, they restate the finding the agent was reviewing, or they record general programming advice any model already knows. Discard all of those. When a claim does survive, REWRITE it in your own words \u2014 never store an agent's sentence verbatim. Merge proposals that say the same thing into one memory.\n\nA memory qualifies ONLY if it is (a) durable \u2014 still true next month, (b) project-specific, and (c) actionable \u2014 it would change what an agent does. Write conventions, constraints, gotchas and root causes. A `lesson` must state what went wrong AND what to do instead. Set `audience` only when the memory is genuinely useful to specific roles and useless to the rest; leave it out otherwise.\n\nNEVER write transient run detail (task ids, attempt counts, what happened today), never restate the request, never duplicate a memory that already exists, and never include credentials, tokens, keys, or anything resembling a secret. Each memory is one self-contained sentence that makes sense with no other context.\n\nReturn at most 5 memories via submit as {memories}. Returning NONE is the most common correct answer \u2014 prefer an empty list over a weak memory, because a bad memory is injected into every future run.",
|
|
141
|
+
operational: "You handle version control for the project. Given a git diff of work just completed, write a single Conventional Commits message: `type(scope): subject`. Types: feat, fix, docs, refactor, test, chore, style, perf, build, ci. Choose the scope from the touched area (e.g. spec, plan, tasks, or a module name) or omit it. The subject is imperative, lowercase, \u226472 chars, no trailing period. Add a short body only if the change genuinely needs explanation. Commit messages are always in English. Return {message} via submit."
|
|
142
|
+
};
|
|
143
|
+
var SPEC_TEAM = [
|
|
144
|
+
{ name: "spec-completeness", perspective: "coverage of the REQUESTED scope: capabilities the user asked for that are missing, or behavior left unspecified", models: [] },
|
|
145
|
+
{ name: "spec-clarity", perspective: "ambiguity: requirements that can be read two ways, vague wording, unresolved NEEDS CLARIFICATION markers", models: [] },
|
|
146
|
+
{ name: "spec-consistency", perspective: "internal contradictions between requirements, acceptance scenarios, and success criteria", models: [] },
|
|
147
|
+
{ name: "spec-scope", perspective: "scope discipline: requirements the user never asked for, gold-plating, scope creep beyond the request", models: [] },
|
|
148
|
+
{ name: "spec-abstraction-leak", perspective: "implementation detail that has leaked into the spec (languages, frameworks, APIs, storage mechanics, code structure) \u2014 a spec must stay technology-agnostic", models: [] },
|
|
149
|
+
{ name: "spec-verifiability", perspective: "are success criteria measurable and technology-agnostic, and can each acceptance scenario be tested without knowing the implementation", models: [] },
|
|
150
|
+
{ name: "spec-user-value", perspective: "do the user stories deliver the value the user actually asked for, and is the priority ordering sensible", models: [] },
|
|
151
|
+
{ name: "spec-domain-model", perspective: "key entities, their attributes and relationships \u2014 coherent and complete at the domain level, with no implementation detail", models: [] },
|
|
152
|
+
{ name: "spec-privacy", perspective: "requirement-level data handling: what data is stored, who may see it, what must never leak or be retained", models: [] }
|
|
153
|
+
];
|
|
154
|
+
var PLAN_TEAM = [
|
|
155
|
+
{ name: "plan-spec-conformance", perspective: "traceability to the approved spec: every requirement covered by the plan, and nothing planned that the spec never asked for", models: [] },
|
|
156
|
+
{ name: "plan-architecture", perspective: "layering, module boundaries, dependency direction, overall structural coherence", models: [] },
|
|
157
|
+
{ name: "plan-data-model", perspective: "schema and entity design, relationships, migrations, integrity constraints", models: [] },
|
|
158
|
+
{ name: "plan-api-contracts", perspective: "interface and contract design, naming, backward compatibility, ergonomics", models: [] },
|
|
159
|
+
{ name: "plan-security", perspective: "threat model, authentication/authorization design, input validation, secret handling, injection surfaces", models: [] },
|
|
160
|
+
{ name: "plan-concurrency", perspective: "race conditions, atomicity, ordering, multi-writer/multi-tab safety, shared-state design", models: [] },
|
|
161
|
+
{ name: "plan-resilience", perspective: "failure modes, error propagation, recovery, retries, partial-failure behavior", models: [] },
|
|
162
|
+
{ name: "plan-performance", perspective: "algorithmic complexity, hot paths, resource bounds, scalability of the chosen design", models: [] },
|
|
163
|
+
{ name: "plan-test-strategy", perspective: "how the design will be proven: seams, dependency injection, contract/integration test layers, what each test actually establishes", models: [] },
|
|
164
|
+
{ name: "plan-simplicity", perspective: "YAGNI: over-engineering, unnecessary abstraction, complexity the requested scope does not justify", models: [] },
|
|
165
|
+
{ name: "plan-dependencies", perspective: "third-party choices, supply-chain risk, versioning, licensing", models: [] },
|
|
166
|
+
{ name: "plan-observability", perspective: "logging, metrics, tracing, debuggability, actionable failure signals", models: [] },
|
|
167
|
+
{ name: "plan-structure", perspective: "project structure: directory/file layout, build setup, adherence to existing repo conventions", models: [] },
|
|
168
|
+
{ name: "plan-feasibility", perspective: "can this be built and maintained as described, in reasonable increments, with the effort the request warrants", models: [] }
|
|
169
|
+
];
|
|
170
|
+
var CODE_TEAM = [
|
|
171
|
+
{ name: "code-plan-conformance", perspective: "does the code implement what the task required \u2014 nothing missing, and no extra scope beyond the task", models: [] },
|
|
172
|
+
{ name: "code-correctness", perspective: "logical correctness, edge cases, off-by-one and boundary conditions, invariants", models: [] },
|
|
173
|
+
{ name: "code-security", perspective: "injection, secret leakage, missing authorization checks, unsafe APIs, unvalidated input", models: [] },
|
|
174
|
+
{ name: "code-error-handling", perspective: "swallowed errors, propagation, cleanup on failure, partial-failure behavior", models: [] },
|
|
175
|
+
{ name: "code-concurrency", perspective: "race conditions, deadlocks, atomicity, shared mutable state", models: [] },
|
|
176
|
+
{ name: "code-tests", perspective: "is the new behavior covered, and do the tests actually assert something meaningful (no vacuous tests)", models: [] },
|
|
177
|
+
{ name: "code-data-integrity", perspective: "persistence correctness, transactions, validation at boundaries, migration safety", models: [] },
|
|
178
|
+
{ name: "code-performance", perspective: "hot paths, unnecessary allocation/work, N+1 patterns, obvious inefficiency", models: [] },
|
|
179
|
+
{ name: "code-maintainability", perspective: "naming, structure, complexity, readability, future tech-debt", models: [] },
|
|
180
|
+
{ name: "code-simplicity", perspective: "dead code, duplication, unnecessary abstraction, complexity the task does not justify", models: [] },
|
|
181
|
+
{ name: "code-api-surface", perspective: "public interface shape, backward compatibility, accidental API exposure", models: [] },
|
|
182
|
+
{ name: "code-accessibility", perspective: "accessibility of UI code: keyboard operation, ARIA/semantics, contrast, i18n readiness", models: [] },
|
|
183
|
+
{ name: "code-observability", perspective: "logging/metrics where a failure would otherwise be undiagnosable", models: [] },
|
|
184
|
+
{ name: "code-dependencies", perspective: "newly introduced dependencies: justified, correctly versioned, no supply-chain or licensing problem", models: [] },
|
|
185
|
+
{ name: "code-conventions", perspective: "consistency with the surrounding codebase's idioms, patterns, and style", models: [] }
|
|
186
|
+
];
|
|
187
|
+
var DEFAULT_COUNCIL = [
|
|
188
|
+
{ name: "correctness-judge", perspective: "Is the work under review correct, coherent and internally consistent? Weigh the team's correctness/logic/data findings.", models: [] },
|
|
189
|
+
{ name: "risk-judge", perspective: "What is the real blast radius of shipping this as-is? Weigh security, failure modes, concurrency, and data-integrity findings against likelihood and severity.", models: [] },
|
|
190
|
+
{ name: "completeness-judge", perspective: "Is what was asked for fully and unambiguously covered? Weigh the team's completeness, gap, and contract findings.", models: [] },
|
|
191
|
+
{ name: "user-value-judge", perspective: "Does this deliver the user's actual intent well? Weigh usability, accessibility, and whether the scope serves the request without gold-plating.", models: [] },
|
|
192
|
+
{ name: "feasibility-judge", perspective: "Can this be built and maintained as described? Weigh architecture, simplicity, dependencies, and maintainability findings against effort.", models: [] }
|
|
193
|
+
];
|
|
194
|
+
function placedSkills() {
|
|
195
|
+
return [...new Set(Object.values(DEFAULT_ROLE_SKILLS).flat())];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// src/providers/anthropic.ts
|
|
199
|
+
function isAnthropicModel(model) {
|
|
200
|
+
return /(^|\/)(claude|fable|mythos)/i.test(model) || /claude/i.test(model);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/tui/role-models.ts
|
|
204
|
+
var WEAK_RE = /\b(flash|mini|nano|haiku|lite|small|turbo|fast|\d{1,2}b)\b/i;
|
|
205
|
+
var FLAGSHIP_ROLES = ["judge", "principal-coder"];
|
|
206
|
+
var COUNCIL_ROLES = DEFAULT_COUNCIL.map((c) => c.name);
|
|
207
|
+
var SPEC_LENS_ROLES = SPEC_TEAM.map((c) => c.name);
|
|
208
|
+
var PLAN_LENS_ROLES = PLAN_TEAM.map((c) => c.name);
|
|
209
|
+
var CODE_LENS_ROLES = CODE_TEAM.map((c) => c.name);
|
|
210
|
+
var STRONG_ROLES = [
|
|
211
|
+
"brainstormer",
|
|
212
|
+
"analyst",
|
|
213
|
+
"planner",
|
|
214
|
+
"architect",
|
|
215
|
+
"senior-coder",
|
|
216
|
+
"senior-designer",
|
|
217
|
+
...COUNCIL_ROLES,
|
|
218
|
+
...PLAN_LENS_ROLES,
|
|
219
|
+
...CODE_LENS_ROLES
|
|
220
|
+
];
|
|
221
|
+
var MID_ROLES = ["coach", "coder", "designer", "code-reviewer", "operational", "memory-keeper", "task-auditor", ...SPEC_LENS_ROLES];
|
|
222
|
+
var FAST_ROLES = ["refiner", "router", "project-manager", "team-lead"];
|
|
223
|
+
var CAPABLE_ROLES = /* @__PURE__ */ new Set([...FLAGSHIP_ROLES, ...STRONG_ROLES, ...MID_ROLES]);
|
|
224
|
+
var ROLE_PROFILES = {
|
|
225
|
+
tracer: "Writes the per-file reference note every other agent reads before changing unfamiliar code \u2014 high volume, but its output is a COMMITTED FILE, not a turn in a conversation: a shallow note is believed by every agent that opens that file, forever, and nothing later corrects it. Give it the MOST capable non-[flagship] model in the catalogue, not merely one that qualifies as [strong]. Volume is not a reason to go cheaper here.",
|
|
226
|
+
refiner: "Classifies intent and rewrites the prompt every turn \u2014 highest call volume, trivial task \u2192 a fast, cheap model.",
|
|
227
|
+
router: "Picks coder-vs-designer for a task \u2014 tiny and frequent \u2192 fast, cheap.",
|
|
228
|
+
"project-manager": "Turns a task list into board items \u2014 light and structured \u2192 fast, cheap.",
|
|
229
|
+
"task-auditor": "The only check on the task breakdown before hours of implementation are spent executing it \u2014 reads the plan against the task list and finds what was dropped. Low volume, and everything downstream depends on it \u2192 a capable model, never the cheapest.",
|
|
230
|
+
"team-lead": "Coordinates implementation waves \u2014 light orchestration \u2192 fast, cheap.",
|
|
231
|
+
coach: "Your main interactive assistant, used constantly all session (highest interaction volume) \u2192 a capable but EFFICIENT model, never the costly flagship.",
|
|
232
|
+
brainstormer: "Turns a raw request into a decided design before the spec: explores the repo, weighs 2-3 approaches, gets the user to choose. Low volume, sets the direction for everything downstream \u2192 a strong reasoning model.",
|
|
233
|
+
analyst: "Authors the spec and constitution \u2192 a strong reasoning model (Opus-tier).",
|
|
234
|
+
planner: "Designs the implementation plan \u2192 a strong reasoning model (Opus-tier).",
|
|
235
|
+
architect: "Diagnoses stuck tasks and produces recovery plans \u2014 serious design work \u2192 a strong model.",
|
|
236
|
+
judge: "Critiques specs/plans and makes the final review call \u2014 low volume, high stakes \u2192 the most capable flagship model.",
|
|
237
|
+
coder: "Writes the bulk of the implementation \u2014 very high work volume \u2192 a good high-throughput coding model (Sonnet-tier), NOT the flagship (wasteful at this volume).",
|
|
238
|
+
"senior-coder": "Reviews and revises above the coder \u2014 must be MORE capable than the coder (Opus-tier).",
|
|
239
|
+
"principal-coder": "Final code decision-maker \u2014 low volume, high stakes \u2192 the flagship is appropriate.",
|
|
240
|
+
designer: "Builds UI \u2014 high volume \u2192 a capable coding/design model, not the flagship.",
|
|
241
|
+
"senior-designer": "Senior UI reviewer \u2014 more capable than the designer.",
|
|
242
|
+
"code-reviewer": "Reviews diffs \u2014 moderate volume \u2192 a solid capable model.",
|
|
243
|
+
"memory-keeper": "Decides what a finished job taught the project and writes it to durable memory \u2014 low volume, but a bad memory poisons every later run \u2192 a capable, efficient model, never the cheapest.",
|
|
244
|
+
operational: "Handles version control: writes conventional commit messages and (later) drives merges/conflicts \u2014 high volume \u2192 a capable, efficient model."
|
|
245
|
+
};
|
|
246
|
+
for (const [stage, lenses, heft] of [
|
|
247
|
+
["spec", SPEC_TEAM, "a capable, efficient model (a spec is a short business-level doc)"],
|
|
248
|
+
["plan", PLAN_TEAM, "a strong model (technical design judgment)"],
|
|
249
|
+
["code", CODE_TEAM, "a strong model (reads real implementations)"]
|
|
250
|
+
]) {
|
|
251
|
+
for (const l of lenses) ROLE_PROFILES[l.name] = `${stage.toUpperCase()}-review lens \u2014 ${l.perspective}. Low volume, quality-critical \u2192 ${heft}.`;
|
|
252
|
+
}
|
|
253
|
+
for (const c of DEFAULT_COUNCIL) {
|
|
254
|
+
ROLE_PROFILES[c.name] = `Review COUNCIL decider \u2014 ${c.perspective} Casts the binding pass/revise vote on contested work \u2192 a strong model.`;
|
|
255
|
+
}
|
|
256
|
+
var ROLE_ADVICE = ROLE_PROFILES;
|
|
257
|
+
function filterModelsForRole(role, all, exclude = []) {
|
|
258
|
+
const advice = ROLE_ADVICE[role];
|
|
259
|
+
const excluded = new Set(exclude);
|
|
260
|
+
const avail = all.filter((m) => !excluded.has(m));
|
|
261
|
+
if (CAPABLE_ROLES.has(role)) {
|
|
262
|
+
const strong = avail.filter((m) => !WEAK_RE.test(m));
|
|
263
|
+
if (strong.length === 0) return { models: avail.length ? avail : all, note: advice ? `${advice} (No strong models detected \u2014 showing all.)` : void 0 };
|
|
264
|
+
return { models: strong, note: `${advice ?? ""} Showing ${strong.length} of ${avail.length} models (fast/weak models hidden for this role).`.trim() };
|
|
265
|
+
}
|
|
266
|
+
if (FAST_ROLES.includes(role)) {
|
|
267
|
+
const fast = avail.filter((m) => WEAK_RE.test(m));
|
|
268
|
+
if (fast.length === 0) return { models: avail.length ? avail : all, note: advice };
|
|
269
|
+
return { models: fast, note: `${advice ?? ""} Showing ${fast.length} of ${avail.length} fast/cheap models.`.trim() };
|
|
270
|
+
}
|
|
271
|
+
return { models: avail.length ? avail : all };
|
|
272
|
+
}
|
|
273
|
+
function effortFor(role, model) {
|
|
274
|
+
if (!isAnthropicModel(model)) return void 0;
|
|
275
|
+
if (FLAGSHIP_ROLES.includes(role)) return "max";
|
|
276
|
+
if (STRONG_ROLES.includes(role)) return "xhigh";
|
|
277
|
+
if (FAST_ROLES.includes(role)) return "low";
|
|
278
|
+
if (MID_ROLES.includes(role)) return "high";
|
|
279
|
+
return void 0;
|
|
280
|
+
}
|
|
281
|
+
var effortBump = (s) => /-(ultra|max|xhigh)/.test(s) ? 4 : /-high/.test(s) ? 3 : /-medium/.test(s) ? 2 : /-low/.test(s) ? 1 : 0;
|
|
282
|
+
var versionBump = (s, family) => {
|
|
283
|
+
if (family) {
|
|
284
|
+
const m = s.match(new RegExp(`${family}[-_. ]?(\\d+)(?:[-.](\\d+))?`));
|
|
285
|
+
if (m) {
|
|
286
|
+
const major = Number(m[1]);
|
|
287
|
+
const minor = m[2] === void 0 ? 0 : Number(m[2]);
|
|
288
|
+
if (major < 100) return major + (minor < 10 ? minor / 10 : minor / 100);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const g = s.match(/(\d)[-.](\d)\b/);
|
|
292
|
+
return g ? Number(g[1]) + Number(g[2]) / 10 : 0;
|
|
293
|
+
};
|
|
294
|
+
var KNOWN_FAMILY_RE = /(fable|mythos|opus|sonnet|haiku|claude|codex|gpt-|\bo\d\b|gemini|deepseek|llama|qwen|kimi|glm|mistral|grok|nova|command-r|phi-\d)/i;
|
|
295
|
+
var NON_TEXT_RE = /\b(image|imagen|vision|video|veo|tts|audio|speech|voice|embed|embedding|rerank|ocr|computer-use|realtime|moderation)\b/i;
|
|
296
|
+
function isKnownModel(model) {
|
|
297
|
+
return KNOWN_FAMILY_RE.test(model) && !NON_TEXT_RE.test(model);
|
|
298
|
+
}
|
|
299
|
+
var UNRANKED_SCORE = 50;
|
|
300
|
+
function capabilityScore(model) {
|
|
301
|
+
const s = model.toLowerCase();
|
|
302
|
+
if (WEAK_RE.test(s)) return 20 + effortBump(s);
|
|
303
|
+
if (/fable|mythos/.test(s)) return 100;
|
|
304
|
+
if (/opus/.test(s)) return 88 + versionBump(s, "opus");
|
|
305
|
+
if (/codex|gpt-5|\bo3\b/.test(s)) return 82 + effortBump(s) + versionBump(s, "gpt") / 100;
|
|
306
|
+
if (/sonnet/.test(s)) return 78 + versionBump(s, "sonnet");
|
|
307
|
+
if (/grok/.test(s)) return 78 + versionBump(s, "grok");
|
|
308
|
+
if (/glm/.test(s)) return 78 + versionBump(s, "glm");
|
|
309
|
+
if (/gemini/.test(s) && /pro/.test(s)) return 76 + versionBump(s, "gemini") + effortBump(s);
|
|
310
|
+
if (/gpt-4/.test(s)) return 65;
|
|
311
|
+
if (/deepseek/.test(s)) return 55;
|
|
312
|
+
return UNRANKED_SCORE;
|
|
313
|
+
}
|
|
314
|
+
function mostCapable(models) {
|
|
315
|
+
return [...models].sort((a, b) => capabilityScore(b) - capabilityScore(a))[0] ?? "";
|
|
316
|
+
}
|
|
317
|
+
function modelBand(model) {
|
|
318
|
+
if (WEAK_RE.test(model)) return "fast";
|
|
319
|
+
const s = capabilityScore(model);
|
|
320
|
+
if (s >= 95) return "flagship";
|
|
321
|
+
if (s >= 84) return "strong";
|
|
322
|
+
if (s <= UNRANKED_SCORE) return "fast";
|
|
323
|
+
return "mid";
|
|
324
|
+
}
|
|
325
|
+
function baseModel(model) {
|
|
326
|
+
const segs = model.toLowerCase().split("/");
|
|
327
|
+
let s = segs[segs.length - 1];
|
|
328
|
+
s = s.replace(/-(ultra|max|xhigh|high|medium|low|free|thinking|preview)\b/g, "");
|
|
329
|
+
s = s.replace(/-\d{6,8}\b/g, "");
|
|
330
|
+
return s.replace(/-+$/, "");
|
|
331
|
+
}
|
|
332
|
+
function modelFamily(model) {
|
|
333
|
+
return baseModel(model).replace(/[-.]v?\d+(?:[-.]\d+)*(?=[-.]|$)/g, "").replace(/[-.]{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
|
|
334
|
+
}
|
|
335
|
+
function latestFirst(models) {
|
|
336
|
+
const best = /* @__PURE__ */ new Map();
|
|
337
|
+
for (const m of models) {
|
|
338
|
+
const key = modelFamily(m);
|
|
339
|
+
const cur = best.get(key);
|
|
340
|
+
if (!cur || capabilityScore(m) > capabilityScore(cur)) best.set(key, m);
|
|
341
|
+
}
|
|
342
|
+
const isLatest = (m) => best.get(modelFamily(m)) === m;
|
|
343
|
+
return [...models.filter(isLatest), ...models.filter((m) => !isLatest(m))];
|
|
344
|
+
}
|
|
345
|
+
function versionlessId(model) {
|
|
346
|
+
const cut = model.lastIndexOf("/");
|
|
347
|
+
const prefix = cut >= 0 ? model.slice(0, cut + 1) : "";
|
|
348
|
+
const name = model.slice(cut + 1).toLowerCase().replace(/-\d{6,8}\b/g, "").replace(/[-.]v?\d+(?:[-.]\d+)*(?=[-.]|$)/g, "").replace(/[-.]{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
|
|
349
|
+
return prefix + name;
|
|
350
|
+
}
|
|
351
|
+
var DURABLE_ROLES = ["tracer"];
|
|
352
|
+
function strongestPrimary(chain, pool) {
|
|
353
|
+
const head = chain[0];
|
|
354
|
+
if (!head) return chain;
|
|
355
|
+
let best = head;
|
|
356
|
+
for (const m of pool) {
|
|
357
|
+
if (modelBand(m) === "flagship" || !isKnownModel(m)) continue;
|
|
358
|
+
if (capabilityScore(m) > capabilityScore(best)) best = m;
|
|
359
|
+
}
|
|
360
|
+
if (best === head) return chain;
|
|
361
|
+
const at = chain.indexOf(best);
|
|
362
|
+
if (at > 0) {
|
|
363
|
+
const next = [...chain];
|
|
364
|
+
next[at] = head;
|
|
365
|
+
next[0] = best;
|
|
366
|
+
return next;
|
|
367
|
+
}
|
|
368
|
+
return [best, ...chain.slice(1)];
|
|
369
|
+
}
|
|
370
|
+
function newestPrimary(chain, pool) {
|
|
371
|
+
const head = chain[0];
|
|
372
|
+
if (!head) return chain;
|
|
373
|
+
const key = versionlessId(head);
|
|
374
|
+
let best = head;
|
|
375
|
+
for (const m of pool) {
|
|
376
|
+
if (versionlessId(m) !== key) continue;
|
|
377
|
+
if (capabilityScore(m) > capabilityScore(best)) best = m;
|
|
378
|
+
}
|
|
379
|
+
if (best === head) return chain;
|
|
380
|
+
const at = chain.indexOf(best);
|
|
381
|
+
if (at > 0) {
|
|
382
|
+
const next = [...chain];
|
|
383
|
+
next[at] = head;
|
|
384
|
+
next[0] = best;
|
|
385
|
+
return next;
|
|
386
|
+
}
|
|
387
|
+
return [best, ...chain.slice(1)];
|
|
388
|
+
}
|
|
389
|
+
function dedupBest(models) {
|
|
390
|
+
const best = /* @__PURE__ */ new Map();
|
|
391
|
+
for (const m of models) {
|
|
392
|
+
const key = baseModel(m);
|
|
393
|
+
const cur = best.get(key);
|
|
394
|
+
if (!cur || capabilityScore(m) > capabilityScore(cur)) best.set(key, m);
|
|
395
|
+
}
|
|
396
|
+
return [...best.values()].sort((a, b) => capabilityScore(b) - capabilityScore(a));
|
|
397
|
+
}
|
|
398
|
+
function sourceOf(model) {
|
|
399
|
+
const s = model.toLowerCase().replace(/^no-think\//, "");
|
|
400
|
+
return cliFor(s) ?? s.split("/")[0];
|
|
401
|
+
}
|
|
402
|
+
function interleaveBySource(pool) {
|
|
403
|
+
const bySource = /* @__PURE__ */ new Map();
|
|
404
|
+
for (const m of pool) {
|
|
405
|
+
const s = sourceOf(m);
|
|
406
|
+
const q = bySource.get(s);
|
|
407
|
+
if (q) q.push(m);
|
|
408
|
+
else bySource.set(s, [m]);
|
|
409
|
+
}
|
|
410
|
+
const queues = [...bySource.values()];
|
|
411
|
+
const out = [];
|
|
412
|
+
for (let more = true; more; ) {
|
|
413
|
+
more = false;
|
|
414
|
+
for (const q of queues) {
|
|
415
|
+
const m = q.shift();
|
|
416
|
+
if (m !== void 0) {
|
|
417
|
+
out.push(m);
|
|
418
|
+
more = true;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
var BAND_ORDER = { fast: 0, mid: 1, strong: 2, flagship: 3 };
|
|
425
|
+
function bandDistance(primary, candidate) {
|
|
426
|
+
const p = BAND_ORDER[modelBand(primary)];
|
|
427
|
+
const c = BAND_ORDER[modelBand(candidate)];
|
|
428
|
+
return Math.abs(c - p) * 2 + (c < p ? 1 : 0);
|
|
429
|
+
}
|
|
430
|
+
function pickFallbacks(primary, pool, n) {
|
|
431
|
+
const chosen = [];
|
|
432
|
+
const usedModels = /* @__PURE__ */ new Set([baseModel(primary)]);
|
|
433
|
+
const usedSources = /* @__PURE__ */ new Set([sourceOf(primary)]);
|
|
434
|
+
const byHeft = pool.map((m, i) => ({ m, i })).sort((a, b) => bandDistance(primary, a.m) - bandDistance(primary, b.m) || a.i - b.i).map((x) => x.m);
|
|
435
|
+
for (const m of byHeft) {
|
|
436
|
+
if (chosen.length >= n) break;
|
|
437
|
+
if (usedModels.has(baseModel(m)) || usedSources.has(sourceOf(m))) continue;
|
|
438
|
+
chosen.push(m);
|
|
439
|
+
usedModels.add(baseModel(m));
|
|
440
|
+
usedSources.add(sourceOf(m));
|
|
441
|
+
}
|
|
442
|
+
for (const m of byHeft) {
|
|
443
|
+
if (chosen.length >= n) break;
|
|
444
|
+
if (usedModels.has(baseModel(m))) continue;
|
|
445
|
+
chosen.push(m);
|
|
446
|
+
usedModels.add(baseModel(m));
|
|
447
|
+
}
|
|
448
|
+
return chosen;
|
|
449
|
+
}
|
|
450
|
+
var FALLBACK_COUNT = 2;
|
|
451
|
+
function adjustRoleModels(roles, models, unfit) {
|
|
452
|
+
if (models.length === 0) return [];
|
|
453
|
+
const recognised = models.filter(isKnownModel);
|
|
454
|
+
const pick = recognised.length ? recognised : models;
|
|
455
|
+
const capable = dedupBest(pick.filter((m) => !WEAK_RE.test(m)));
|
|
456
|
+
const fast = dedupBest(pick.filter((m) => WEAK_RE.test(m)));
|
|
457
|
+
const capablePool = capable.length ? capable : fast;
|
|
458
|
+
const fastPool = fast.length ? fast : capable;
|
|
459
|
+
const primaryPool = latestFirst(capablePool);
|
|
460
|
+
const primaryFast = latestFirst(fastPool);
|
|
461
|
+
const nonFlagship = primaryPool.filter((m) => modelBand(m) !== "flagship");
|
|
462
|
+
const strongPool = primaryPool.filter((m) => modelBand(m) === "strong");
|
|
463
|
+
const midPool = primaryPool.filter((m) => modelBand(m) === "mid");
|
|
464
|
+
const wanted = new Set(roles);
|
|
465
|
+
const forRole = (role, pool) => {
|
|
466
|
+
if (!unfit) return pool;
|
|
467
|
+
const fit = pool.filter((m) => !unfit(role, m));
|
|
468
|
+
return fit.length ? fit : pool;
|
|
469
|
+
};
|
|
470
|
+
const known = /* @__PURE__ */ new Set([...FLAGSHIP_ROLES, ...STRONG_ROLES, ...MID_ROLES, ...FAST_ROLES]);
|
|
471
|
+
const primary = /* @__PURE__ */ new Map();
|
|
472
|
+
const flagSrc = primaryPool;
|
|
473
|
+
FLAGSHIP_ROLES.filter((r) => wanted.has(r)).forEach((r, i) => {
|
|
474
|
+
const src = forRole(r, flagSrc);
|
|
475
|
+
primary.set(r, src[i % src.length]);
|
|
476
|
+
});
|
|
477
|
+
const strongSrc = interleaveBySource(strongPool.length ? strongPool : nonFlagship.length ? nonFlagship : primaryPool);
|
|
478
|
+
STRONG_ROLES.filter((r) => wanted.has(r)).concat(roles.filter((r) => !known.has(r))).forEach((r, i) => {
|
|
479
|
+
const src = forRole(r, strongSrc);
|
|
480
|
+
primary.set(r, src[i % src.length]);
|
|
481
|
+
});
|
|
482
|
+
const midSrc = interleaveBySource(midPool.length ? midPool : nonFlagship.length ? nonFlagship : primaryPool);
|
|
483
|
+
MID_ROLES.filter((r) => wanted.has(r)).forEach((r, i) => {
|
|
484
|
+
const src = forRole(r, midSrc);
|
|
485
|
+
primary.set(r, src[i % src.length]);
|
|
486
|
+
});
|
|
487
|
+
FAST_ROLES.filter((r) => wanted.has(r)).forEach((r, i) => {
|
|
488
|
+
const src = forRole(r, primaryFast);
|
|
489
|
+
primary.set(r, src[i % src.length]);
|
|
490
|
+
});
|
|
491
|
+
return roles.map((role) => {
|
|
492
|
+
const head = primary.get(role) ?? primaryPool[0];
|
|
493
|
+
const capForFb = MID_ROLES.includes(role) ? capablePool.filter((m) => modelBand(m) !== "flagship") : capablePool;
|
|
494
|
+
const pool = FAST_ROLES.includes(role) ? [...fastPool, ...capForFb] : [...capForFb, ...fastPool];
|
|
495
|
+
return { role, models: newestPrimary([head, ...pickFallbacks(head, forRole(role, pool), FALLBACK_COUNT)], models) };
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// src/skills/apply.ts
|
|
500
|
+
import { readFile } from "fs/promises";
|
|
501
|
+
import { readdirSync } from "fs";
|
|
502
|
+
import { resolve, sep } from "path";
|
|
503
|
+
import { z } from "zod";
|
|
504
|
+
function applySkills(basePrompt, mandatory, registry) {
|
|
505
|
+
const parts = [basePrompt];
|
|
506
|
+
if (mandatory.length) {
|
|
507
|
+
const sections = mandatory.map((name) => {
|
|
508
|
+
const skill = registry.get(name);
|
|
509
|
+
if (!skill) throw new Error(`applySkills: undefined skill: ${name}`);
|
|
510
|
+
const where = skill.dir ? `
|
|
511
|
+
_Skill base directory: ${skill.dir}_
|
|
512
|
+
` : "";
|
|
513
|
+
return `## ${skill.name}${where}
|
|
514
|
+
${skill.content}`;
|
|
515
|
+
});
|
|
516
|
+
parts.push(`# Mandatory Skills
|
|
517
|
+
${sections.join("\n\n")}`);
|
|
518
|
+
}
|
|
519
|
+
const mandatorySet = new Set(mandatory);
|
|
520
|
+
const available = registry.list().filter((s) => !mandatorySet.has(s.name));
|
|
521
|
+
if (available.length) {
|
|
522
|
+
const lines = available.map((s) => `- ${s.name}: ${s.description}`);
|
|
523
|
+
parts.push(`# Discoverable Skills (call the skill tool to fetch its content)
|
|
524
|
+
${lines.join("\n")}`);
|
|
525
|
+
}
|
|
526
|
+
return parts.join("\n\n");
|
|
527
|
+
}
|
|
528
|
+
var skillParams = z.object({
|
|
529
|
+
name: z.string().describe("The skill's name, exactly as it is listed."),
|
|
530
|
+
/**
|
|
531
|
+
* A supporting document inside the skill's own directory, e.g. "reference/critique.md".
|
|
532
|
+
*
|
|
533
|
+
* Described, because an undescribed optional string gets filled in. Measured: four consecutive calls sent
|
|
534
|
+
* `file: ""` and every one of them failed — the skill was there, its content was one branch away, and an
|
|
535
|
+
* empty string took the other branch.
|
|
536
|
+
*/
|
|
537
|
+
file: z.string().optional().describe('Optional. A supporting document inside the skill, e.g. "reference/critique.md". Omit it to read the skill itself \u2014 do not pass an empty string.')
|
|
538
|
+
});
|
|
539
|
+
var DOCS_SHOWN = 12;
|
|
540
|
+
function docsIn(dir) {
|
|
541
|
+
try {
|
|
542
|
+
return readdirSync(dir, { withFileTypes: true }).filter((e) => e.name !== "SKILL.md" && !e.name.startsWith(".")).map((e) => e.isDirectory() ? `${e.name}/` : e.name).sort().slice(0, DOCS_SHOWN);
|
|
543
|
+
} catch {
|
|
544
|
+
return [];
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
var MAX_SKILL_DOC_CHARS = 3e4;
|
|
548
|
+
var MAX_SKILLS_LISTED = 12;
|
|
549
|
+
function noSuchSkill(name, available) {
|
|
550
|
+
const shape = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
551
|
+
const same = available.filter((s) => shape(s) === shape(name));
|
|
552
|
+
if (same.length === 1) {
|
|
553
|
+
return `skill not found: ${name} \u2014 did you mean \`${same[0]}\`? Call it with that exact name.`;
|
|
554
|
+
}
|
|
555
|
+
if (!available.length) return `skill not found: ${name}. This project has no skills installed, so carry on without one.`;
|
|
556
|
+
const near = available.filter((s) => shape(s).includes(shape(name)) || shape(name).includes(shape(s)));
|
|
557
|
+
if (near.length && near.length <= MAX_SKILLS_LISTED) {
|
|
558
|
+
return `skill not found: ${name}. Closest by name: ${near.map((s) => `\`${s}\``).join(", ")}. Call one of those exactly if it is what you meant, or carry on without a skill.`;
|
|
559
|
+
}
|
|
560
|
+
const shown = available.slice(0, MAX_SKILLS_LISTED);
|
|
561
|
+
const rest = available.length - shown.length;
|
|
562
|
+
return `skill not found: ${name}. Available: ${shown.join(", ")}${rest > 0 ? `, and ${rest} more \u2014 the full list is in your system prompt` : ""}. Use one of these exactly, or carry on without a skill \u2014 do not guess another name.`;
|
|
563
|
+
}
|
|
564
|
+
function buildSkillTool(registry) {
|
|
565
|
+
return {
|
|
566
|
+
name: "skill",
|
|
567
|
+
description: 'Fetch a skill\'s content by name. Some skills are dispatchers whose SKILL.md points at supporting documents (e.g. "see reference/critique.md"); pass `file` with that relative path to read one. Fetch a document only when the skill actually sends you to it.',
|
|
568
|
+
permissionLevel: "safe",
|
|
569
|
+
parameters: skillParams,
|
|
570
|
+
run: async (rawArgs) => {
|
|
571
|
+
const parsed = skillParams.safeParse(rawArgs);
|
|
572
|
+
if (!parsed.success) {
|
|
573
|
+
return { content: `skill: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`, isError: true };
|
|
574
|
+
}
|
|
575
|
+
const { name, file } = parsed.data;
|
|
576
|
+
const skill = registry.get(name);
|
|
577
|
+
if (!skill) return { content: noSuchSkill(name, registry.list().map((s) => s.name)), isError: true };
|
|
578
|
+
if (file === void 0 || !file.trim()) {
|
|
579
|
+
const where = skill.dir ? `_Skill base directory: ${skill.dir}_
|
|
580
|
+
|
|
581
|
+
` : "";
|
|
582
|
+
return { content: `${where}${skill.content}`, isError: false };
|
|
583
|
+
}
|
|
584
|
+
if (!skill.dir) return { content: `skill ${name}: has no supporting documents`, isError: true };
|
|
585
|
+
const target = resolve(skill.dir, file);
|
|
586
|
+
const root = resolve(skill.dir);
|
|
587
|
+
if (target !== root && !target.startsWith(root + sep)) {
|
|
588
|
+
return { content: `skill ${name}: ${file} is outside the skill directory`, isError: true };
|
|
589
|
+
}
|
|
590
|
+
let raw;
|
|
591
|
+
try {
|
|
592
|
+
raw = await readFile(target, "utf8");
|
|
593
|
+
} catch {
|
|
594
|
+
const has = docsIn(skill.dir);
|
|
595
|
+
return {
|
|
596
|
+
content: `skill ${name}: no such document: ${file}` + (has.length ? `. It has: ${has.join(", ")}` : `. It has no supporting documents.`),
|
|
597
|
+
isError: true
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
if (raw.length <= MAX_SKILL_DOC_CHARS) return { content: raw, isError: false };
|
|
601
|
+
return {
|
|
602
|
+
content: `${raw.slice(0, MAX_SKILL_DOC_CHARS)}
|
|
603
|
+
|
|
604
|
+
[skill ${name}/${file}: truncated at ${MAX_SKILL_DOC_CHARS} of ${raw.length} chars]`,
|
|
605
|
+
isError: false
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// src/agent/roles.ts
|
|
612
|
+
function isTransientFailure(reason) {
|
|
613
|
+
const r = reason.toLowerCase();
|
|
614
|
+
if (/\b(429|rate.?limit|quota|exhaust|insufficient|billing|credit)\b/.test(r)) return false;
|
|
615
|
+
return /overload|529|50[0234]|timeout|timed out|deadline|econnreset|epipe|socket hang up|stream ended|temporar|unavailable|try again/.test(r);
|
|
616
|
+
}
|
|
617
|
+
function isSourceCapacity(reason) {
|
|
618
|
+
return /capacity is (?:temporarily unavailable|busy)/i.test(reason);
|
|
619
|
+
}
|
|
620
|
+
function sourcePrefix(model) {
|
|
621
|
+
const s = model.replace(/^no-think\//, "");
|
|
622
|
+
const cli = cliFor(s);
|
|
623
|
+
if (cli) return cli;
|
|
624
|
+
const i = s.indexOf("/");
|
|
625
|
+
return i > 0 ? s.slice(0, i) : void 0;
|
|
626
|
+
}
|
|
627
|
+
function weightedCycle(sources, weights) {
|
|
628
|
+
const queues = sources.map((s) => Array(Math.max(1, weights[s] ?? 1)).fill(s));
|
|
629
|
+
const out = [];
|
|
630
|
+
for (let more = true; more; ) {
|
|
631
|
+
more = false;
|
|
632
|
+
for (const q of queues) {
|
|
633
|
+
const m = q.shift();
|
|
634
|
+
if (m !== void 0) {
|
|
635
|
+
out.push(m);
|
|
636
|
+
more = true;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return out;
|
|
641
|
+
}
|
|
642
|
+
function canonicalSource(name) {
|
|
643
|
+
const s = name.toLowerCase().replace(/^no-think\//, "");
|
|
644
|
+
if (s === "cc" || s === "claude") return "claude";
|
|
645
|
+
if (s === "cx") return "codex";
|
|
646
|
+
return sourcePrefix(s) ?? s;
|
|
647
|
+
}
|
|
648
|
+
function providerOutage(reason) {
|
|
649
|
+
return /no active credentials for provider:?\s*([\w.-]+)/i.exec(reason)?.[1] ?? /provider\s+'?([\w.-]+)'?\s+is not configured/i.exec(reason)?.[1] ?? /all\s+([\w.-]+)\s+accounts have exhausted their quota/i.exec(reason)?.[1] ?? /shared egress ip quota exhausted\s*\(([\w.-]+)\)/i.exec(reason)?.[1] ?? /^\s*(claude|codex|grok|zai)\s+CLI:\s*rejected\b/i.exec(reason)?.[1]?.toLowerCase();
|
|
650
|
+
}
|
|
651
|
+
function quotaResetAt(reason) {
|
|
652
|
+
const iso = /\(resets\s+([0-9T:.\-]+Z)\)/i.exec(reason)?.[1];
|
|
653
|
+
const t = iso ? Date.parse(iso) : NaN;
|
|
654
|
+
return Number.isFinite(t) ? t : void 0;
|
|
655
|
+
}
|
|
656
|
+
var RoleRegistry = class _RoleRegistry {
|
|
657
|
+
// durable behavioral rules → appended to EVERY role's prompt
|
|
658
|
+
constructor(roles, defaultPrompts = {}, skillRegistry) {
|
|
659
|
+
this.roles = roles;
|
|
660
|
+
this.defaultPrompts = defaultPrompts;
|
|
661
|
+
this.skillRegistry = skillRegistry;
|
|
662
|
+
}
|
|
663
|
+
roles;
|
|
664
|
+
defaultPrompts;
|
|
665
|
+
skillRegistry;
|
|
666
|
+
modelOverride;
|
|
667
|
+
roleOverrides = /* @__PURE__ */ new Map();
|
|
668
|
+
// per-role model CHAIN override (highest priority)
|
|
669
|
+
effortOverrides = /* @__PURE__ */ new Map();
|
|
670
|
+
// Models that failed retryably (429/5xx/quota) → skipped in every chain until released. Kept WITH the
|
|
671
|
+
// reason and the time so a coordinator can report them and later re-probe whether the limit has reset.
|
|
672
|
+
quarantine = /* @__PURE__ */ new Map();
|
|
673
|
+
notify;
|
|
674
|
+
// fallback UI note sink (wired once the controller exists)
|
|
675
|
+
onQuarantine;
|
|
676
|
+
/** What each model has actually managed to do in each ROLE — see setFitness. */
|
|
677
|
+
fitness;
|
|
678
|
+
// Models that answered in prose instead of calling the submit tool. Not a transport error, so nothing ever
|
|
679
|
+
// benched them: the chain quietly slid to the fallback on EVERY call, forever, in every role that held them.
|
|
680
|
+
strikes = /* @__PURE__ */ new Map();
|
|
681
|
+
rulesProvider;
|
|
682
|
+
/** Every configured role name — used to validate a role reference produced by a model (memory audiences). */
|
|
683
|
+
names() {
|
|
684
|
+
return [.../* @__PURE__ */ new Set([...Object.keys(this.roles), ...Object.keys(this.defaultPrompts)])];
|
|
685
|
+
}
|
|
686
|
+
/** Wire the fallback-note sink (called after the controller exists). */
|
|
687
|
+
setNotify(fn) {
|
|
688
|
+
this.notify = fn;
|
|
689
|
+
}
|
|
690
|
+
/** Wire the durable-rules source (memory). Rules are appended to every role's system prompt (always honored). */
|
|
691
|
+
setRules(fn) {
|
|
692
|
+
this.rulesProvider = fn;
|
|
693
|
+
}
|
|
694
|
+
/** The rule block to append to a role's prompt — empty when there are no rules. Public so prompt-supplying
|
|
695
|
+
* callers (spec-kit phases build their own prompt) can append it too. */
|
|
696
|
+
ruleSuffix() {
|
|
697
|
+
const rules = this.rulesProvider?.() ?? [];
|
|
698
|
+
return rules.length ? `
|
|
699
|
+
|
|
700
|
+
User rules (ALWAYS honor these):
|
|
701
|
+
${rules.map((r) => `- ${r}`).join("\n")}` : "";
|
|
702
|
+
}
|
|
703
|
+
/** Live-swap the model used by every role (session-only; clears on undefined/empty). */
|
|
704
|
+
setModelOverride(model) {
|
|
705
|
+
this.modelOverride = model && model.length > 0 ? model : void 0;
|
|
706
|
+
}
|
|
707
|
+
/** Live-swap the model CHAIN of ONE role (session-only; wins over the global override). Clears on empty. */
|
|
708
|
+
setRoleModel(roleName, models) {
|
|
709
|
+
const chain = (typeof models === "string" ? [models] : models ?? []).filter((m) => m.length > 0);
|
|
710
|
+
if (chain.length) this.roleOverrides.set(roleName, chain);
|
|
711
|
+
else this.roleOverrides.delete(roleName);
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* How hard this role should work, set alongside its chain.
|
|
715
|
+
*
|
|
716
|
+
* An override on the live registry rather than a config re-read, for the same reason `setRoleModel` is one:
|
|
717
|
+
* `/roles adjust` has to take effect in the session that ran it, not only in the next one.
|
|
718
|
+
*
|
|
719
|
+
* `undefined` REMOVES it — a role reassigned from a Claude model to one whose effort cannot be set must
|
|
720
|
+
* stop carrying a level, or the config keeps a number that no longer applies to anything.
|
|
721
|
+
*/
|
|
722
|
+
setRoleEffort(roleName, effort) {
|
|
723
|
+
if (effort) this.effortOverrides.set(roleName, effort);
|
|
724
|
+
else this.effortOverrides.delete(roleName);
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Wire the record of what each model has actually managed to do in each role.
|
|
728
|
+
*
|
|
729
|
+
* Without it a chain is only a list of names from a catalogue. With it, a model that has twice answered
|
|
730
|
+
* this role in prose instead of doing its work stops being offered to this role — while staying available
|
|
731
|
+
* to every other role, where it may be perfectly good.
|
|
732
|
+
*/
|
|
733
|
+
setFitness(f) {
|
|
734
|
+
this.fitness = f;
|
|
735
|
+
}
|
|
736
|
+
/** Wire the quarantine hook: whatever benches a model, every role still holding it must be re-assigned. */
|
|
737
|
+
setOnQuarantine(fn) {
|
|
738
|
+
this.onQuarantine = fn;
|
|
739
|
+
}
|
|
740
|
+
/** Mark a model spent — every chain skips it from now on, until it is released. */
|
|
741
|
+
markExhausted(model, reason = "unavailable", now = Date.now(), until) {
|
|
742
|
+
if (!model || this.isQuarantined(model)) return;
|
|
743
|
+
const ends = until ?? (isTransientFailure(reason) ? now + _RoleRegistry.TRANSIENT_BENCH_MS : void 0);
|
|
744
|
+
this.quarantine.set(model, { at: now, reason, ...ends !== void 0 && { until: ends } });
|
|
745
|
+
this.onQuarantine?.(model, reason, ends);
|
|
746
|
+
}
|
|
747
|
+
/** Every model any role's chain names — the pool this registry can actually reach for. */
|
|
748
|
+
knownModels() {
|
|
749
|
+
return [...new Set(Object.values(this.roles).flatMap((r) => r.models ?? []))];
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Benches every model of one provider, for a failure that is about the provider itself.
|
|
753
|
+
*
|
|
754
|
+
* Returns what it took out, so the caller can say so once instead of six times. Falls back to benching the
|
|
755
|
+
* single model when the pool names none of that provider — an unknown provider is still a real failure.
|
|
756
|
+
*/
|
|
757
|
+
markProviderExhausted(provider, model, reason, now = Date.now()) {
|
|
758
|
+
const want = canonicalSource(provider);
|
|
759
|
+
const hit = this.knownModels().filter((m) => sourcePrefix(m) === want);
|
|
760
|
+
const until = quotaResetAt(reason);
|
|
761
|
+
for (const m of hit) this.markExhausted(m, reason, now, until);
|
|
762
|
+
if (!hit.length) {
|
|
763
|
+
this.markExhausted(model, reason, now);
|
|
764
|
+
return [model];
|
|
765
|
+
}
|
|
766
|
+
return hit;
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* How long a BEHAVIOURAL bench lasts before the model is tried again.
|
|
770
|
+
*
|
|
771
|
+
* A model that is out of quota is out until the quota returns, and nothing here can shorten that. A model
|
|
772
|
+
* that answered in prose is a different case entirely: the transport was fine, and the next prompt may not
|
|
773
|
+
* be the one it stumbled on. Benching it for the rest of a multi-hour run costs every role that held it —
|
|
774
|
+
* measured live, one such bench re-assigned SIXTEEN roles away from the best model available.
|
|
775
|
+
*/
|
|
776
|
+
static STRUCTURAL_BENCH_MS = 10 * 6e4;
|
|
777
|
+
/**
|
|
778
|
+
* How long a TRANSPORT bench lasts — the busy server, not the spent subscription.
|
|
779
|
+
*
|
|
780
|
+
* The argument above, one door over. A model that answered in prose gets ten minutes because the transport
|
|
781
|
+
* was fine; a model whose transport said "Overloaded" for one second is the same case in its purest form,
|
|
782
|
+
* and it was the one getting benched for the whole session.
|
|
783
|
+
*
|
|
784
|
+
* Measured live, in the middle of a feature run: `cc/claude-opus-5` served five calls in the preceding two
|
|
785
|
+
* minutes (23.8s, 2.9s, 3.1s, 25.3s, 38.7s, all ok), then one 529 in 1.7 seconds — and 18 roles were moved
|
|
786
|
+
* off the best model in the fleet for the rest of the session. A 529 is the textbook transient condition;
|
|
787
|
+
* the API's own guidance for it is to retry with backoff.
|
|
788
|
+
*
|
|
789
|
+
* Two minutes: long enough that a genuinely struggling gateway is not hammered, short enough that a
|
|
790
|
+
* one-second blip costs a couple of turns rather than an afternoon.
|
|
791
|
+
*/
|
|
792
|
+
static TRANSIENT_BENCH_MS = 2 * 6e4;
|
|
793
|
+
/**
|
|
794
|
+
* How many structured failures a model gets before it is benched. One miss can be a genuinely hard prompt;
|
|
795
|
+
* a pattern is the model. Low, because every strike costs a full wasted pass in every role that holds it.
|
|
796
|
+
*/
|
|
797
|
+
static STRUCTURAL_STRIKES = 2;
|
|
798
|
+
/**
|
|
799
|
+
* Records that a model finished a turn WITHOUT producing the structured result it was asked for (prose
|
|
800
|
+
* instead of a tool call). This is not "unavailable" — the transport was fine — so it never reached the
|
|
801
|
+
* retryable path that benches a model, and the chain slid to the fallback on every single call instead.
|
|
802
|
+
* Returns the strike count; at the threshold the model is quarantined like any other spent one.
|
|
803
|
+
*/
|
|
804
|
+
markStructuralFailure(model, reason = "no valid structured result", role) {
|
|
805
|
+
if (!model) return 0;
|
|
806
|
+
const key = role ? `${model}::${role}` : model;
|
|
807
|
+
const n = (this.strikes.get(key) ?? 0) + 1;
|
|
808
|
+
this.strikes.set(key, n);
|
|
809
|
+
if (n < _RoleRegistry.STRUCTURAL_STRIKES) return n;
|
|
810
|
+
if (role) {
|
|
811
|
+
this.fitness?.record?.(role, model, reason);
|
|
812
|
+
const rolesFailed = [...this.strikes.entries()].filter(([k, v]) => k.startsWith(`${model}::`) && v >= _RoleRegistry.STRUCTURAL_STRIKES).length;
|
|
813
|
+
if (rolesFailed >= _RoleRegistry.STRUCTURAL_ROLES_BEFORE_BENCH) {
|
|
814
|
+
this.markExhausted(
|
|
815
|
+
model,
|
|
816
|
+
`${reason} (in ${rolesFailed} roles)`,
|
|
817
|
+
Date.now(),
|
|
818
|
+
Date.now() + _RoleRegistry.STRUCTURAL_BENCH_MS
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
return n;
|
|
822
|
+
}
|
|
823
|
+
this.markExhausted(model, reason, Date.now(), Date.now() + _RoleRegistry.STRUCTURAL_BENCH_MS);
|
|
824
|
+
return n;
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* How many DISTINCT roles must reject a model this way before it is benched outright.
|
|
828
|
+
*
|
|
829
|
+
* Two, because one role can have a prompt that a good model reads badly — and the fitness record already
|
|
830
|
+
* takes it out of that role. A second, unrelated role failing the same way is the first evidence that the
|
|
831
|
+
* model, not the prompt, is the problem.
|
|
832
|
+
*/
|
|
833
|
+
static STRUCTURAL_ROLES_BEFORE_BENCH = 2;
|
|
834
|
+
/** Models currently quarantined, with why and when — surfaced to the user and re-probed before an adjust. */
|
|
835
|
+
quarantined() {
|
|
836
|
+
return [...this.quarantine].map(([model, q]) => ({ model, ...q }));
|
|
837
|
+
}
|
|
838
|
+
isQuarantined(model, now = Date.now()) {
|
|
839
|
+
const q = this.quarantine.get(model);
|
|
840
|
+
if (!q) return false;
|
|
841
|
+
if (q.until !== void 0 && now >= q.until) {
|
|
842
|
+
this.quarantine.delete(model);
|
|
843
|
+
this.strikes.clear();
|
|
844
|
+
return false;
|
|
845
|
+
}
|
|
846
|
+
return true;
|
|
847
|
+
}
|
|
848
|
+
/** Put a model back in play (its quota reset, or the user forced it). */
|
|
849
|
+
release(model) {
|
|
850
|
+
this.strikes.delete(model);
|
|
851
|
+
return this.quarantine.delete(model);
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Roles whose CURRENT chain still contains `model`. When a model is quarantined these are the roles that
|
|
855
|
+
* would otherwise keep resolving to a dead chain, so they are exactly the ones to re-assign.
|
|
856
|
+
*/
|
|
857
|
+
rolesUsing(model) {
|
|
858
|
+
return this.names().filter((r) => this.rawChain(r).includes(model));
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* The role's chain BEFORE quarantine filtering — what was actually assigned to it.
|
|
862
|
+
*
|
|
863
|
+
* The order is the documented one and nothing precedes it: per-role override, then the session model, then
|
|
864
|
+
* the config. It used to bail on an empty CONFIG chain before either override was consulted, which made a
|
|
865
|
+
* role the config had never heard of impossible to assign — the one case where assigning is the whole
|
|
866
|
+
* point. Measured with `tester`, added in a version the user's config predated: `/roles adjust` set the
|
|
867
|
+
* override, the override was skipped, and the role stayed broken for the rest of the session while the
|
|
868
|
+
* error message recommended running `/roles adjust`.
|
|
869
|
+
*/
|
|
870
|
+
rawChain(roleName) {
|
|
871
|
+
const perRole = this.roleOverrides.get(roleName);
|
|
872
|
+
if (perRole && perRole.length) return perRole;
|
|
873
|
+
if (this.modelOverride && roleName !== "refiner") return [this.modelOverride];
|
|
874
|
+
return this.roles[roleName]?.models ?? [];
|
|
875
|
+
}
|
|
876
|
+
/** True when every model assigned to this role is quarantined — the chain has collapsed and needs replacing. */
|
|
877
|
+
chainCollapsed(roleName) {
|
|
878
|
+
const raw = this.rawChain(roleName);
|
|
879
|
+
return raw.length > 0 && raw.every((m) => this.isQuarantined(m));
|
|
880
|
+
}
|
|
881
|
+
/** The full model chain for a role by priority: per-role override → global override (non-refiner) → config. */
|
|
882
|
+
chain(roleName) {
|
|
883
|
+
const base = this.rawChain(roleName);
|
|
884
|
+
if (!base.length) return [];
|
|
885
|
+
const live = base.filter((m) => !this.isQuarantined(m));
|
|
886
|
+
const usable = live.length ? live : base;
|
|
887
|
+
const fit = this.fitness ? usable.filter((m) => !this.fitness.unfit(roleName, m)) : usable;
|
|
888
|
+
return fit.length ? fit : usable;
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* The role's chain ROTATED by `slot`. Parallel workers share one role — five implementers in a wave are all
|
|
892
|
+
* `coder` — so every one of them resolved to the same chain head and hammered a single subscription until it
|
|
893
|
+
* rate-limited. Rotating gives each worker a different lead model while keeping its FULL fallback set, so
|
|
894
|
+
* spreading the load costs no resilience.
|
|
895
|
+
*/
|
|
896
|
+
chainFor(roleName, slot = 0) {
|
|
897
|
+
const c = this.chain(roleName);
|
|
898
|
+
if (c.length < 2) return c;
|
|
899
|
+
const order = [];
|
|
900
|
+
for (const m of c) {
|
|
901
|
+
const s = sourceOf(m);
|
|
902
|
+
if (!order.includes(s)) order.push(s);
|
|
903
|
+
}
|
|
904
|
+
const cycle = weightedCycle(order, this.sourceWeights?.() ?? {});
|
|
905
|
+
if (cycle.length) {
|
|
906
|
+
const want = cycle[(slot % cycle.length + cycle.length) % cycle.length];
|
|
907
|
+
const i = c.findIndex((m) => sourceOf(m) === want);
|
|
908
|
+
if (i > 0) return [c[i], ...c.filter((_, j) => j !== i)];
|
|
909
|
+
if (i === 0) return c;
|
|
910
|
+
}
|
|
911
|
+
const k = (slot % c.length + c.length) % c.length;
|
|
912
|
+
return k === 0 ? c : [...c.slice(k), ...c.slice(0, k)];
|
|
913
|
+
}
|
|
914
|
+
/** How many accounts each source has connected — set at the composition root; equal weights without it. */
|
|
915
|
+
sourceWeights;
|
|
916
|
+
/** Wire the account weights (called once the pool exists). */
|
|
917
|
+
setSourceWeights(fn) {
|
|
918
|
+
this.sourceWeights = fn;
|
|
919
|
+
}
|
|
920
|
+
/** The model a role would use next (chain head), for UI display only. */
|
|
921
|
+
peekModel(roleName) {
|
|
922
|
+
return this.chain(roleName)[0] ?? "";
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* The chain (primary + fallbacks) and session-fallback hooks for a role, WITHOUT its system prompt —
|
|
926
|
+
* for callers that supply their own prompt (e.g. spec-kit phases). resolve() layers the prompt on top.
|
|
927
|
+
*/
|
|
928
|
+
fallbackOpts(roleName) {
|
|
929
|
+
const chain = this.chain(roleName);
|
|
930
|
+
const notify = this.notify;
|
|
931
|
+
const effort = this.effortOverrides.get(roleName) ?? this.roles[roleName]?.effort;
|
|
932
|
+
return {
|
|
933
|
+
// Travels with the chain, not with the prompt: the seven callers that take only the chain are exactly
|
|
934
|
+
// the ones whose work is heaviest (the tester, the analyst, the spec-kit phases).
|
|
935
|
+
...effort ? { effort } : {},
|
|
936
|
+
/**
|
|
937
|
+
* The name belongs to the CHAIN, not to the prompt.
|
|
938
|
+
*
|
|
939
|
+
* It was put on `resolve()` alone, on the assumption that every caller spreads a resolved role. Seven
|
|
940
|
+
* do not: they take the chain from here and supply their own prompt (the spec-kit phases, the tester,
|
|
941
|
+
* the analyst, the question normalizer). Measured on the first run after the change — 19 tool calls,
|
|
942
|
+
* one of them attributed. Everything the attribution was for happens in those seven.
|
|
943
|
+
*/
|
|
944
|
+
role: roleName,
|
|
945
|
+
model: chain[0] ?? "",
|
|
946
|
+
fallbacks: chain.slice(1),
|
|
947
|
+
onExhausted: (m, reason) => {
|
|
948
|
+
const why = reason ?? "unavailable";
|
|
949
|
+
const source = providerOutage(why) ?? (isSourceCapacity(why) ? sourcePrefix(m) : void 0);
|
|
950
|
+
if (source) this.markProviderExhausted(source, m, why);
|
|
951
|
+
else this.markExhausted(m, why);
|
|
952
|
+
},
|
|
953
|
+
onStructuralFailure: (m, reason) => this.markStructuralFailure(m, reason, roleName),
|
|
954
|
+
onFallback: notify ? (from, to, reason) => notify(`\u2935 \`${from}\` \u2192 \`${to}\` \u2014 ${reason}`) : void 0
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
/** The skills already attached to a role — what task-level routing must not inline a second time. */
|
|
958
|
+
skillsFor(roleName) {
|
|
959
|
+
return this.roles[roleName]?.skills ?? [];
|
|
960
|
+
}
|
|
961
|
+
resolve(roleName) {
|
|
962
|
+
const role = this.roles[roleName];
|
|
963
|
+
if (!role) throw new Error(`undefined role: ${roleName}`);
|
|
964
|
+
if (!this.rawChain(roleName).length) {
|
|
965
|
+
throw new Error(
|
|
966
|
+
`role '${roleName}' has no model defined \u2014 set one with \`/roles setmodel\`, run \`/roles adjust\`, or choose a session model with \`/model\`.`
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
let systemPrompt = role.systemPrompt ?? this.defaultPrompts[roleName];
|
|
970
|
+
if (systemPrompt === void 0) throw new Error(`role '${roleName}' has no systemPrompt`);
|
|
971
|
+
if (this.skillRegistry) {
|
|
972
|
+
try {
|
|
973
|
+
systemPrompt = applySkills(systemPrompt, role.skills ?? [], this.skillRegistry);
|
|
974
|
+
} catch (e) {
|
|
975
|
+
throw new Error(`role '${roleName}' skill error: ${e instanceof Error ? e.message : String(e)}`);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
return { ...this.fallbackOpts(roleName), systemPrompt: systemPrompt + this.ruleSuffix() };
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
// src/engine/trace-run.ts
|
|
983
|
+
async function traceableFiles(cwd, opts) {
|
|
984
|
+
const r = await defaultGitRunner(["ls-files", "--cached", "--others", "--exclude-standard"], cwd);
|
|
985
|
+
return traceable(r.stdout.split("\n").filter(Boolean), opts);
|
|
986
|
+
}
|
|
987
|
+
async function coverageFor(cwd) {
|
|
988
|
+
return traceCoverage(cwd, await traceableFiles(cwd), await loadTraceIndex(cwd));
|
|
989
|
+
}
|
|
990
|
+
var TRACE_CONCURRENCY = 6;
|
|
991
|
+
var INDEX_CHECKPOINT = 25;
|
|
992
|
+
function describePlan(plan, models) {
|
|
993
|
+
if (!plan.jobs.length) {
|
|
994
|
+
return plan.upToDate ? `All ${plan.upToDate} traces are current \u2014 nothing to write, nothing to spend.` : "No files to trace.";
|
|
995
|
+
}
|
|
996
|
+
const kIn = Math.round(plan.estimatedInputTokens / 1e3);
|
|
997
|
+
const kOut = Math.round(plan.estimatedOutputTokens / 1e3);
|
|
998
|
+
const skipped = plan.skipped.length ? `
|
|
999
|
+
|
|
1000
|
+
${plan.skipped.length} file(s) skipped as too large to trace economically (e.g. ${plan.skipped.slice(0, 3).map((s) => `\`${s.file}\``).join(", ")}${plan.skipped.length > 3 ? ", \u2026" : ""}).` : "";
|
|
1001
|
+
const cached = plan.upToDate ? `
|
|
1002
|
+
${plan.upToDate} file(s) already have a current trace and will be left alone.` : "";
|
|
1003
|
+
const [head, ...rest] = models;
|
|
1004
|
+
const chain = `\`${head}\`${rest.length ? `, falling back to ${rest.map((m) => `\`${m}\``).join(" then ")}` : ""}`;
|
|
1005
|
+
return `**Tracing ${plan.jobs.length} file(s)** with ${chain}.
|
|
1006
|
+
|
|
1007
|
+
This is the part of understanding your project that costs tokens \u2014 the graph was free, this is not. Each file is read once and described in ~150 words.
|
|
1008
|
+
|
|
1009
|
+
Rough cost: **~${kIn}k input + ~${kOut}k output tokens**.${cached}${skipped}`;
|
|
1010
|
+
}
|
|
1011
|
+
var ChatFailure = class extends Error {
|
|
1012
|
+
constructor(message, retryable) {
|
|
1013
|
+
super(message);
|
|
1014
|
+
this.retryable = retryable;
|
|
1015
|
+
}
|
|
1016
|
+
retryable;
|
|
1017
|
+
};
|
|
1018
|
+
var BLIND_BENCH_MS = 60 * 60 * 1e3;
|
|
1019
|
+
var SpentSources = class {
|
|
1020
|
+
until = /* @__PURE__ */ new Map();
|
|
1021
|
+
/** Files a refusal against its subscription. Returns the source, when the message named one. */
|
|
1022
|
+
record(reason, now = Date.now()) {
|
|
1023
|
+
const source = providerOutage(reason);
|
|
1024
|
+
if (!source) return void 0;
|
|
1025
|
+
this.until.set(source, quotaResetAt(reason) ?? now + BLIND_BENCH_MS);
|
|
1026
|
+
return source;
|
|
1027
|
+
}
|
|
1028
|
+
/** Whether a source is still standing down. A lapsed bench is forgotten as it is read. */
|
|
1029
|
+
spent(source, now = Date.now()) {
|
|
1030
|
+
const t = this.until.get(source);
|
|
1031
|
+
if (t === void 0) return false;
|
|
1032
|
+
if (now >= t) {
|
|
1033
|
+
this.until.delete(source);
|
|
1034
|
+
return false;
|
|
1035
|
+
}
|
|
1036
|
+
return true;
|
|
1037
|
+
}
|
|
1038
|
+
/** The links of a chain still worth trying. A model no CLI serves is nobody's subscription, so it stays. */
|
|
1039
|
+
live(chain, now = Date.now()) {
|
|
1040
|
+
return chain.filter((m) => {
|
|
1041
|
+
const s = cliFor(m);
|
|
1042
|
+
return !s || !this.spent(s, now);
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
async function traceOne(provider, chain, job, signal, spent, brief) {
|
|
1047
|
+
if (!spent.live(chain).length) {
|
|
1048
|
+
throw new Error(`every subscription in the chain is out of quota (${chain.join(", ")})`);
|
|
1049
|
+
}
|
|
1050
|
+
let last;
|
|
1051
|
+
for (const model of chain) {
|
|
1052
|
+
const source = cliFor(model);
|
|
1053
|
+
if (source && spent.spent(source)) continue;
|
|
1054
|
+
try {
|
|
1055
|
+
return { body: await askOne(provider, model, job, signal, brief), model };
|
|
1056
|
+
} catch (e) {
|
|
1057
|
+
if (!(e instanceof ChatFailure)) throw e;
|
|
1058
|
+
if (!e.retryable) throw e;
|
|
1059
|
+
last = e;
|
|
1060
|
+
spent.record(e.message);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
throw last ?? new Error("no model could write this trace");
|
|
1064
|
+
}
|
|
1065
|
+
async function askOne(provider, model, job, signal, brief) {
|
|
1066
|
+
const req = {
|
|
1067
|
+
model,
|
|
1068
|
+
messages: [
|
|
1069
|
+
{ role: "system", content: "You write terse, factual reference notes about source files. You never speculate." },
|
|
1070
|
+
{ role: "user", content: tracePrompt(job, brief) }
|
|
1071
|
+
],
|
|
1072
|
+
tools: []
|
|
1073
|
+
};
|
|
1074
|
+
let out = "";
|
|
1075
|
+
for await (const ev of provider.chat(req, signal)) {
|
|
1076
|
+
if (ev.type === "text-delta") out += ev.text;
|
|
1077
|
+
else if (ev.type === "error") throw new ChatFailure(ev.message, ev.retryable === true);
|
|
1078
|
+
}
|
|
1079
|
+
const body = out.replace(/<\/?think>/gi, "").trim();
|
|
1080
|
+
if (!body) throw new ChatFailure("empty response", true);
|
|
1081
|
+
return body;
|
|
1082
|
+
}
|
|
1083
|
+
async function runTraces(opts) {
|
|
1084
|
+
const { cwd, plan } = opts;
|
|
1085
|
+
const signal = opts.signal ?? new AbortController().signal;
|
|
1086
|
+
const index = await loadTraceIndex(cwd);
|
|
1087
|
+
const brief = briefForPrompt(cwd);
|
|
1088
|
+
const failed = [];
|
|
1089
|
+
const spent = new SpentSources();
|
|
1090
|
+
let written = 0;
|
|
1091
|
+
let done = 0;
|
|
1092
|
+
const queue = [...plan.jobs];
|
|
1093
|
+
const worker = async () => {
|
|
1094
|
+
for (; ; ) {
|
|
1095
|
+
const job = queue.shift();
|
|
1096
|
+
if (!job || signal.aborted) return;
|
|
1097
|
+
let wroteTo;
|
|
1098
|
+
let words;
|
|
1099
|
+
let error;
|
|
1100
|
+
try {
|
|
1101
|
+
const { body, model } = await traceOne(opts.provider, opts.models, job, signal, spent, brief);
|
|
1102
|
+
const rec = await saveTrace(cwd, job, body, model);
|
|
1103
|
+
index.traces[job.file] = rec;
|
|
1104
|
+
wroteTo = `${traceRootRel()}/${job.file}.md`;
|
|
1105
|
+
words = body.split(/\s+/).filter(Boolean).length;
|
|
1106
|
+
written++;
|
|
1107
|
+
if (written % INDEX_CHECKPOINT === 0) await saveTraceIndex(cwd, index);
|
|
1108
|
+
} catch (e) {
|
|
1109
|
+
if (signal.aborted) return;
|
|
1110
|
+
error = e instanceof Error ? e.message : String(e);
|
|
1111
|
+
failed.push({ file: job.file, error });
|
|
1112
|
+
}
|
|
1113
|
+
opts.onProgress?.({
|
|
1114
|
+
done: ++done,
|
|
1115
|
+
total: plan.jobs.length,
|
|
1116
|
+
file: job.file,
|
|
1117
|
+
...wroteTo !== void 0 && { wroteTo },
|
|
1118
|
+
...words !== void 0 && { words },
|
|
1119
|
+
...error !== void 0 && { error }
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
};
|
|
1123
|
+
await Promise.all(Array.from({ length: Math.min(TRACE_CONCURRENCY, queue.length) }, worker));
|
|
1124
|
+
const pruned = opts.liveFiles ? await pruneTraces(cwd, opts.liveFiles, index) : [];
|
|
1125
|
+
await saveTraceIndex(cwd, index);
|
|
1126
|
+
const wroteGitignore = written > 0 && await ensureGitignore(cwd);
|
|
1127
|
+
return { written, failed, pruned, upToDate: plan.upToDate, cancelled: signal.aborted, wroteGitignore };
|
|
1128
|
+
}
|
|
1129
|
+
async function planFor(cwd, files) {
|
|
1130
|
+
return planTraces(cwd, files, await loadGraph(cwd), await loadTraceIndex(cwd));
|
|
1131
|
+
}
|
|
1132
|
+
async function buildBrief(opts) {
|
|
1133
|
+
if (!opts.force) {
|
|
1134
|
+
const st = await briefStatus(opts.cwd, opts.files);
|
|
1135
|
+
if (st.built && !st.stale) {
|
|
1136
|
+
return { ok: true, skipped: true, message: `Project brief is current (${st.sources.length} document(s)) \u2014 not rewritten.` };
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
const input = await gatherBriefInput(opts.cwd, opts.files);
|
|
1140
|
+
if (!input) {
|
|
1141
|
+
return { ok: false, message: "No documentation found (README, docs/, specs/) \u2014 traces will describe the code without product context." };
|
|
1142
|
+
}
|
|
1143
|
+
const signal = opts.signal ?? new AbortController().signal;
|
|
1144
|
+
const spent = new SpentSources();
|
|
1145
|
+
let body = "";
|
|
1146
|
+
let wroteWith = "";
|
|
1147
|
+
let last = "";
|
|
1148
|
+
for (const model of spent.live(opts.models)) {
|
|
1149
|
+
const req = {
|
|
1150
|
+
model,
|
|
1151
|
+
messages: [
|
|
1152
|
+
{ role: "system", content: "You write factual project briefings from documentation. You never invent facts the documents do not state." },
|
|
1153
|
+
{ role: "user", content: briefPrompt(input) }
|
|
1154
|
+
],
|
|
1155
|
+
tools: []
|
|
1156
|
+
};
|
|
1157
|
+
let out = "";
|
|
1158
|
+
try {
|
|
1159
|
+
for await (const ev of opts.provider.chat(req, signal)) {
|
|
1160
|
+
if (ev.type === "text-delta") out += ev.text;
|
|
1161
|
+
else if (ev.type === "error") throw new ChatFailure(ev.message, ev.retryable === true);
|
|
1162
|
+
}
|
|
1163
|
+
} catch (e) {
|
|
1164
|
+
if (e instanceof ChatFailure && !e.retryable) return { ok: false, message: `Project brief failed (${e.message}) \u2014 tracing can still run without it.` };
|
|
1165
|
+
last = e instanceof Error ? e.message : String(e);
|
|
1166
|
+
if (e instanceof ChatFailure) spent.record(e.message);
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
const text = out.replace(/<\/?think>/gi, "").trim();
|
|
1170
|
+
if (text) {
|
|
1171
|
+
body = text;
|
|
1172
|
+
wroteWith = model;
|
|
1173
|
+
break;
|
|
1174
|
+
}
|
|
1175
|
+
last = "the brief came back empty";
|
|
1176
|
+
}
|
|
1177
|
+
if (!body) return { ok: false, message: `Project brief failed (${last || "no model answered"}) \u2014 tracing can still run without it.` };
|
|
1178
|
+
await saveBrief(opts.cwd, body, { hash: input.hash, sources: input.sources.map((s) => s.file), writtenAt: Date.now(), model: wroteWith });
|
|
1179
|
+
return { ok: true, message: `**Project brief** written from ${input.sources.length} document(s): ${input.sources.slice(0, 6).map((s) => `\`${s.file}\``).join(", ")}` };
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
export {
|
|
1183
|
+
grokEffort,
|
|
1184
|
+
ZAI_MODELS,
|
|
1185
|
+
modelsFor,
|
|
1186
|
+
cliCatalog,
|
|
1187
|
+
cliFor,
|
|
1188
|
+
cliInvocation,
|
|
1189
|
+
REQUIRED_ROLES,
|
|
1190
|
+
DEFAULT_ROLE_SKILLS,
|
|
1191
|
+
DEFAULT_PROMPTS,
|
|
1192
|
+
SPEC_TEAM,
|
|
1193
|
+
PLAN_TEAM,
|
|
1194
|
+
CODE_TEAM,
|
|
1195
|
+
DEFAULT_COUNCIL,
|
|
1196
|
+
placedSkills,
|
|
1197
|
+
ROLE_PROFILES,
|
|
1198
|
+
filterModelsForRole,
|
|
1199
|
+
effortFor,
|
|
1200
|
+
isKnownModel,
|
|
1201
|
+
capabilityScore,
|
|
1202
|
+
mostCapable,
|
|
1203
|
+
modelBand,
|
|
1204
|
+
DURABLE_ROLES,
|
|
1205
|
+
strongestPrimary,
|
|
1206
|
+
newestPrimary,
|
|
1207
|
+
sourceOf,
|
|
1208
|
+
adjustRoleModels,
|
|
1209
|
+
applySkills,
|
|
1210
|
+
buildSkillTool,
|
|
1211
|
+
RoleRegistry,
|
|
1212
|
+
traceableFiles,
|
|
1213
|
+
coverageFor,
|
|
1214
|
+
TRACE_CONCURRENCY,
|
|
1215
|
+
describePlan,
|
|
1216
|
+
SpentSources,
|
|
1217
|
+
runTraces,
|
|
1218
|
+
planFor,
|
|
1219
|
+
buildBrief
|
|
1220
|
+
};
|