jeo-code 0.1.0 → 0.4.4

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.
Files changed (177) hide show
  1. package/README.ja.md +160 -0
  2. package/README.ko.md +160 -0
  3. package/README.md +115 -297
  4. package/README.zh.md +160 -0
  5. package/package.json +11 -6
  6. package/scripts/install.sh +28 -28
  7. package/scripts/uninstall.sh +17 -15
  8. package/src/AGENTS.md +50 -0
  9. package/src/agent/AGENTS.md +49 -0
  10. package/src/agent/bash-fixups.ts +103 -0
  11. package/src/agent/compaction.ts +410 -19
  12. package/src/agent/config-schema.ts +119 -5
  13. package/src/agent/context-files.ts +314 -17
  14. package/src/agent/dev/AGENTS.md +36 -0
  15. package/src/agent/dev/advanced-analyzer.ts +12 -0
  16. package/src/agent/dev/evolution-bridge.ts +82 -0
  17. package/src/agent/dev/evolution-logger.ts +41 -0
  18. package/src/agent/dev/self-analysis.ts +64 -0
  19. package/src/agent/dev/self-improve.ts +24 -0
  20. package/src/agent/dev/spec-automation.ts +49 -0
  21. package/src/agent/engine.ts +804 -54
  22. package/src/agent/hooks.ts +273 -0
  23. package/src/agent/loop.ts +21 -1
  24. package/src/agent/memory.ts +201 -0
  25. package/src/agent/model-recency.ts +32 -0
  26. package/src/agent/output-minimizer.ts +108 -0
  27. package/src/agent/output-util.ts +64 -0
  28. package/src/agent/plan.ts +187 -0
  29. package/src/agent/seed.ts +52 -0
  30. package/src/agent/session.ts +235 -21
  31. package/src/agent/state.ts +286 -39
  32. package/src/agent/step-budget.ts +232 -0
  33. package/src/agent/subagents.ts +223 -26
  34. package/src/agent/task-tool.ts +272 -0
  35. package/src/agent/todo-tool.ts +87 -0
  36. package/src/agent/tokenizer.ts +117 -0
  37. package/src/agent/tool-registry.ts +54 -0
  38. package/src/agent/tools.ts +562 -103
  39. package/src/agent/web-search.ts +538 -0
  40. package/src/ai/AGENTS.md +44 -0
  41. package/src/ai/index.ts +1 -0
  42. package/src/ai/model-catalog-compat.ts +3 -1
  43. package/src/ai/model-catalog.ts +74 -9
  44. package/src/ai/model-discovery.ts +215 -17
  45. package/src/ai/model-manager.ts +346 -32
  46. package/src/ai/model-picker.ts +1 -1
  47. package/src/ai/model-registry.ts +4 -2
  48. package/src/ai/pricing.ts +84 -0
  49. package/src/ai/provider-registry.ts +23 -0
  50. package/src/ai/provider-status.ts +60 -16
  51. package/src/ai/providers/AGENTS.md +42 -0
  52. package/src/ai/providers/anthropic.ts +250 -31
  53. package/src/ai/providers/antigravity.ts +219 -0
  54. package/src/ai/providers/errors.ts +15 -1
  55. package/src/ai/providers/gemini.ts +196 -13
  56. package/src/ai/providers/ollama.ts +37 -7
  57. package/src/ai/providers/openai-responses.ts +173 -0
  58. package/src/ai/providers/openai.ts +64 -12
  59. package/src/ai/sse.ts +4 -1
  60. package/src/ai/types.ts +18 -1
  61. package/src/auth/AGENTS.md +41 -0
  62. package/src/auth/callback-server.ts +6 -1
  63. package/src/auth/flows/AGENTS.md +32 -0
  64. package/src/auth/flows/antigravity.ts +151 -0
  65. package/src/auth/flows/google-project.ts +190 -0
  66. package/src/auth/flows/google.ts +39 -18
  67. package/src/auth/flows/index.ts +15 -5
  68. package/src/auth/flows/openai.ts +2 -2
  69. package/src/auth/oauth.ts +8 -0
  70. package/src/auth/refresh.ts +44 -27
  71. package/src/auth/storage.ts +149 -26
  72. package/src/auth/types.ts +1 -1
  73. package/src/autopilot.ts +362 -0
  74. package/src/bun-imports.d.ts +4 -0
  75. package/src/cli/AGENTS.md +39 -0
  76. package/src/cli/runner.ts +148 -14
  77. package/src/cli.ts +13 -4
  78. package/src/commands/AGENTS.md +40 -0
  79. package/src/commands/approve.ts +62 -3
  80. package/src/commands/auth.ts +167 -25
  81. package/src/commands/chat.ts +37 -8
  82. package/src/commands/deep-interview.ts +633 -175
  83. package/src/commands/doctor.ts +84 -37
  84. package/src/commands/evolve-core.ts +18 -0
  85. package/src/commands/evolve.ts +2 -1
  86. package/src/commands/export.ts +176 -0
  87. package/src/commands/gjc.ts +52 -0
  88. package/src/commands/launch.ts +3549 -240
  89. package/src/commands/mcp.ts +3 -3
  90. package/src/commands/ooo-seed.ts +19 -0
  91. package/src/commands/ralplan.ts +253 -35
  92. package/src/commands/resume.ts +1 -1
  93. package/src/commands/session.ts +183 -0
  94. package/src/commands/setup-helpers.ts +10 -3
  95. package/src/commands/setup.ts +57 -16
  96. package/src/commands/skills.ts +78 -18
  97. package/src/commands/state.ts +198 -0
  98. package/src/commands/status.ts +84 -0
  99. package/src/commands/team.ts +340 -212
  100. package/src/commands/ultragoal.ts +122 -61
  101. package/src/commands/update.ts +244 -0
  102. package/src/ledger.ts +270 -0
  103. package/src/mcp/AGENTS.md +38 -0
  104. package/src/mcp/server.ts +115 -14
  105. package/src/mcp/tools.ts +42 -22
  106. package/src/md-modules.d.ts +4 -0
  107. package/src/prompts/AGENTS.md +41 -0
  108. package/src/prompts/agents/AGENTS.md +35 -0
  109. package/src/prompts/agents/architect.md +35 -0
  110. package/src/prompts/agents/critic.md +37 -0
  111. package/src/prompts/agents/executor.md +36 -0
  112. package/src/prompts/agents/planner.md +37 -0
  113. package/src/prompts/skills/AGENTS.md +36 -0
  114. package/src/prompts/skills/deep-dive/AGENTS.md +31 -0
  115. package/src/prompts/skills/deep-dive/SKILL.md +13 -0
  116. package/src/prompts/skills/deep-interview/AGENTS.md +31 -0
  117. package/src/prompts/skills/deep-interview/SKILL.md +12 -0
  118. package/src/prompts/skills/gjc/AGENTS.md +31 -0
  119. package/src/prompts/skills/gjc/SKILL.md +15 -0
  120. package/src/prompts/skills/ralplan/AGENTS.md +31 -0
  121. package/src/prompts/skills/ralplan/SKILL.md +11 -0
  122. package/src/prompts/skills/team/AGENTS.md +31 -0
  123. package/src/prompts/skills/team/SKILL.md +11 -0
  124. package/src/prompts/skills/ultragoal/AGENTS.md +31 -0
  125. package/src/prompts/skills/ultragoal/SKILL.md +11 -0
  126. package/src/skills/AGENTS.md +38 -0
  127. package/src/skills/catalog.ts +565 -31
  128. package/src/tui/AGENTS.md +43 -0
  129. package/src/tui/app.ts +1181 -92
  130. package/src/tui/components/AGENTS.md +42 -0
  131. package/src/tui/components/ascii-art.ts +257 -15
  132. package/src/tui/components/autocomplete.ts +98 -16
  133. package/src/tui/components/autopilot-status.ts +65 -0
  134. package/src/tui/components/category-index.ts +49 -0
  135. package/src/tui/components/code-view.ts +54 -11
  136. package/src/tui/components/color.ts +171 -2
  137. package/src/tui/components/config-panel.ts +82 -15
  138. package/src/tui/components/duration.ts +38 -0
  139. package/src/tui/components/evolution.ts +3 -3
  140. package/src/tui/components/footer.ts +91 -42
  141. package/src/tui/components/forge.ts +426 -31
  142. package/src/tui/components/hints.ts +54 -0
  143. package/src/tui/components/hud.ts +73 -0
  144. package/src/tui/components/index.ts +4 -0
  145. package/src/tui/components/input-box.ts +150 -0
  146. package/src/tui/components/layout.ts +11 -3
  147. package/src/tui/components/live-model-picker.ts +108 -0
  148. package/src/tui/components/markdown-table.ts +140 -0
  149. package/src/tui/components/markdown-text.ts +97 -0
  150. package/src/tui/components/meter.ts +4 -1
  151. package/src/tui/components/model-picker.ts +3 -2
  152. package/src/tui/components/provider-picker.ts +3 -2
  153. package/src/tui/components/section.ts +70 -0
  154. package/src/tui/components/select-list.ts +40 -10
  155. package/src/tui/components/skill-picker.ts +25 -0
  156. package/src/tui/components/slash.ts +244 -21
  157. package/src/tui/components/status.ts +272 -11
  158. package/src/tui/components/step-timeline.ts +218 -0
  159. package/src/tui/components/stream.ts +26 -9
  160. package/src/tui/components/themes.ts +212 -6
  161. package/src/tui/components/todo-card.ts +47 -0
  162. package/src/tui/components/tool-list.ts +58 -12
  163. package/src/tui/components/transcript.ts +120 -0
  164. package/src/tui/components/update-box.ts +31 -0
  165. package/src/tui/components/welcome.ts +162 -0
  166. package/src/tui/components/width.ts +163 -0
  167. package/src/tui/monitoring/AGENTS.md +31 -0
  168. package/src/tui/monitoring/hud-view.ts +55 -0
  169. package/src/tui/renderer.ts +112 -3
  170. package/src/tui/terminal.ts +40 -33
  171. package/src/util/AGENTS.md +39 -0
  172. package/src/util/clipboard-image.ts +118 -0
  173. package/src/util/env.ts +12 -0
  174. package/src/util/provider-error.ts +78 -0
  175. package/src/util/retry.ts +91 -6
  176. package/src/util/update-check.ts +64 -0
  177. package/src/commands/models.ts +0 -104
@@ -0,0 +1,232 @@
1
+ /**
2
+ * StepBudget — gjc-style flexible step budgeting ("retry flow") for the agent loop.
3
+ *
4
+ * Replaces the bare `step <= maxSteps` counter with a budget that EXTENDS itself
5
+ * while the turn is demonstrably making progress (recent tool calls succeeding on
6
+ * distinct targets) and fails fast into consolidation when it is stalled. Mirrors
7
+ * gjc's provider retry budgets: a base budget, a bounded number of extensions, an
8
+ * absolute hard cap, and explicit fail-fast classes (a stalled window — too many
9
+ * failures or no distinct targets — never earns an extension).
10
+ *
11
+ * The existing engine guards (MAX_REPEAT identical calls, MAX_FAILURES consecutive
12
+ * failures, parse-bounce salvage) remain the early fail-fast layer; this module
13
+ * only governs what happens when the step counter reaches the current limit.
14
+ */
15
+
16
+ export interface StepBudgetConfig {
17
+ /** Initial step budget (the caller's `maxSteps`). */
18
+ baseSteps: number;
19
+ /** Steps granted per extension. */
20
+ extensionSteps: number;
21
+ /** How many times the budget may extend (0 = legacy fixed counter). */
22
+ maxExtensions: number;
23
+ /** Absolute ceiling no extension may cross. */
24
+ hardCap: number;
25
+ /** Recent tool-call window scored for progress. */
26
+ windowSize: number;
27
+ /** Required ok-ratio in the window to earn an extension. */
28
+ minProgressRatio: number;
29
+ /** Required distinct call signatures in the window (anti-spin). */
30
+ minDistinct: number;
31
+ }
32
+
33
+ export interface ExtensionDecision {
34
+ extend: boolean;
35
+ /** Human-readable reason — surfaced as an onNotice/onBudget line. */
36
+ reason: string;
37
+ /** The (possibly new) current limit after the decision. */
38
+ limit: number;
39
+ }
40
+
41
+ type EnvLike = Record<string, string | undefined>;
42
+
43
+ function envNum(env: EnvLike, key: string, dflt: number, min: number, max: number): number {
44
+ // `key` is the legacy JEO_* name; the JEO_* spelling is preferred when both are set.
45
+ const raw = env[key.replace(/^JEO_/, "JEO_")] ?? env[key];
46
+ if (raw === undefined || raw === "") return dflt;
47
+ const n = Number(raw);
48
+ if (!Number.isFinite(n)) return dflt;
49
+ return Math.max(min, Math.min(max, Math.trunc(n)));
50
+ }
51
+
52
+ /**
53
+ * Resolve the effective budget config: defaults ← env (`JEO_STEP_*`) ← caller overrides.
54
+ * Defaults keep the flow ON for the main agent (2 extensions, half-budget each, 3× cap);
55
+ * bounded delegation (task/team subagents) passes `{ maxExtensions: 0 }` so a parent's
56
+ * step contract stays exact.
57
+ */
58
+ export function resolveStepBudgetConfig(
59
+ baseSteps: number,
60
+ env: EnvLike = process.env,
61
+ overrides?: Partial<StepBudgetConfig>,
62
+ ): StepBudgetConfig {
63
+ const base = Math.max(1, Math.trunc(baseSteps));
64
+ const cfg: StepBudgetConfig = {
65
+ baseSteps: base,
66
+ extensionSteps: envNum(env, "JEO_STEP_EXTENSION_SIZE", Math.max(4, Math.ceil(base / 2)), 1, 100),
67
+ maxExtensions: envNum(env, "JEO_STEP_EXTENSIONS", 2, 0, 8),
68
+ hardCap: envNum(env, "JEO_STEP_HARD_CAP", base * 3, base, base * 10),
69
+ windowSize: envNum(env, "JEO_STEP_WINDOW", 8, 2, 32),
70
+ minProgressRatio: 0.5,
71
+ minDistinct: 2,
72
+ };
73
+ const merged = { ...cfg, ...overrides };
74
+ // Sanity: the cap can never undercut the base budget.
75
+ merged.baseSteps = Math.max(1, Math.trunc(merged.baseSteps));
76
+ merged.hardCap = Math.max(merged.baseSteps, Math.trunc(merged.hardCap));
77
+ merged.maxExtensions = Math.max(0, Math.trunc(merged.maxExtensions));
78
+ merged.extensionSteps = Math.max(1, Math.trunc(merged.extensionSteps));
79
+ merged.windowSize = Math.max(2, Math.trunc(merged.windowSize));
80
+ return merged;
81
+ }
82
+
83
+ /** True when `key` carries a non-empty value in `env`. */
84
+ function envSet(env: EnvLike, key: string): boolean {
85
+ const raw = env[key.replace(/^JEO_/, "JEO_")] ?? env[key];
86
+ return raw !== undefined && raw !== "";
87
+ }
88
+
89
+ /**
90
+ * Dynamic (process-driven) budget — the default when the caller passes no explicit
91
+ * `--max-steps`: there is no SMALL hardcoded step ceiling. The budget starts at a
92
+ * rolling base (`JEO_STEP_BASE`, default 24) and keeps extending itself for as long
93
+ * as the recent tool window shows real progress; only a stalled window declines the
94
+ * extension, at which point the loop dynamically CONSOLIDATES a final wrap-up
95
+ * instead of dying at a fixed count.
96
+ *
97
+ * Termination is still GUARANTEED: extensions are unlimited in count, but the
98
+ * absolute ceiling defaults to `DYNAMIC_HARD_CAP` (600 steps) instead of Infinity.
99
+ * An unbounded ceiling turned every hole in the progress heuristic into a literal
100
+ * infinite loop (e.g. a model cycling successful reads forever); a large finite cap
101
+ * keeps long autonomous runs alive while converting a pathological spin into a
102
+ * consolidation wrap-up. Setting `JEO_STEP_EXTENSIONS` / `JEO_STEP_HARD_CAP`
103
+ * restores a fully bounded budget; caller overrides win over both.
104
+ */
105
+ export const DYNAMIC_HARD_CAP = 600;
106
+
107
+ export function dynamicStepBudgetConfig(
108
+ env: EnvLike = process.env,
109
+ overrides?: Partial<StepBudgetConfig>,
110
+ ): StepBudgetConfig {
111
+ const base = envNum(env, "JEO_STEP_BASE", 24, 1, 10_000);
112
+ const dynamic: Partial<StepBudgetConfig> = {};
113
+ if (!envSet(env, "JEO_STEP_EXTENSIONS")) dynamic.maxExtensions = Number.POSITIVE_INFINITY;
114
+ if (!envSet(env, "JEO_STEP_HARD_CAP")) dynamic.hardCap = Math.max(base, DYNAMIC_HARD_CAP);
115
+ return resolveStepBudgetConfig(base, env, { ...dynamic, ...overrides });
116
+ }
117
+
118
+ /** The step limit a dynamic turn starts from — seeds the `step N/M` display before
119
+ * the engine's onBudget extensions grow the denominator. */
120
+ export function initialDynamicStepLimit(env: EnvLike = process.env): number {
121
+ return dynamicStepBudgetConfig(env).baseSteps;
122
+ }
123
+
124
+ /**
125
+ * Fixed-size hash of a tool-call signature (two interleaved FNV-1a streams).
126
+ * Signature strings embed the full JSON arguments — a `write` call carries the
127
+ * whole file body — so per-turn bookkeeping (the novelty `seen` set, the scoring
128
+ * window, the engine's repeat/cycle guards) stores this digest instead. Bounds a
129
+ * long turn's signature memory at O(steps × ~14 bytes) without changing any
130
+ * equality semantics the guards rely on.
131
+ */
132
+ export function hashSignature(s: string): string {
133
+ let h1 = 0x811c9dc5 | 0;
134
+ let h2 = 0x811c9dc5 | 0;
135
+ for (let i = 0; i < s.length; i++) {
136
+ const c = s.charCodeAt(i);
137
+ h1 = Math.imul(h1 ^ c, 0x01000193);
138
+ h2 = Math.imul(h2 ^ (c + i), 0x01000193);
139
+ }
140
+ return (h1 >>> 0).toString(36) + "." + (h2 >>> 0).toString(36);
141
+ }
142
+
143
+ export class StepBudget {
144
+ private readonly cfg: StepBudgetConfig;
145
+ private readonly window: { signature: string; success: boolean }[] = [];
146
+ private extensions = 0;
147
+ private currentLimit: number;
148
+ /** Every signature executed this turn — basis of the novelty rule. */
149
+ private readonly seen = new Set<string>();
150
+ /** Never-seen-before signatures recorded since the last granted extension.
151
+ * An extension requires ≥ 1: a window that merely CYCLES through previously
152
+ * executed calls (read A, read B, read A, …) is a spin, not progress, even
153
+ * when every call succeeds and the distinct-count check passes. */
154
+ private novelSinceExtension = 0;
155
+
156
+ constructor(cfg: StepBudgetConfig) {
157
+ this.cfg = cfg;
158
+ this.currentLimit = Math.min(cfg.baseSteps, cfg.hardCap);
159
+ }
160
+
161
+ /** The current step limit (base + granted extensions). */
162
+ limit(): number {
163
+ return this.currentLimit;
164
+ }
165
+
166
+ /** Extensions granted so far. */
167
+ extensionsUsed(): number {
168
+ return this.extensions;
169
+ }
170
+
171
+ /** Record an executed tool call (ring-buffered to the scoring window).
172
+ * Stored as a fixed-size digest — see `hashSignature` (memory bound). */
173
+ record(signature: string, success: boolean): void {
174
+ const sig = hashSignature(signature);
175
+ if (!this.seen.has(sig)) {
176
+ this.seen.add(sig);
177
+ this.novelSinceExtension++;
178
+ }
179
+ this.window.push({ signature: sig, success });
180
+ if (this.window.length > this.cfg.windowSize) this.window.shift();
181
+ }
182
+
183
+ /** Progress over the recent window: ok count, total, distinct signatures. */
184
+ progress(): { ok: number; total: number; distinct: number } {
185
+ const ok = this.window.filter(r => r.success).length;
186
+ const distinct = new Set(this.window.map(r => r.signature)).size;
187
+ return { ok, total: this.window.length, distinct };
188
+ }
189
+
190
+ /**
191
+ * Called when the step counter reaches the current limit. Grants a bounded
192
+ * extension when the recent window shows real progress; otherwise declines
193
+ * with an explicit fail-fast reason (the loop then consolidates).
194
+ */
195
+ tryExtend(): ExtensionDecision {
196
+ const decline = (why: string): ExtensionDecision => ({
197
+ extend: false,
198
+ reason: why,
199
+ limit: this.currentLimit,
200
+ });
201
+ if (this.cfg.maxExtensions <= 0) return decline("step extensions disabled");
202
+ if (this.extensions >= this.cfg.maxExtensions) {
203
+ return decline(`extension budget exhausted (${this.extensions}/${this.cfg.maxExtensions})`);
204
+ }
205
+ if (this.currentLimit >= this.cfg.hardCap) {
206
+ return decline(`hard step cap ${this.cfg.hardCap} reached`);
207
+ }
208
+ const p = this.progress();
209
+ if (p.total < 2) return decline(`not enough recent tool activity (${p.total} call(s))`);
210
+ const ratio = p.ok / p.total;
211
+ if (ratio < this.cfg.minProgressRatio || p.distinct < this.cfg.minDistinct) {
212
+ return decline(
213
+ `no recent progress (${p.ok}/${p.total} ok, ${p.distinct} distinct target(s))`,
214
+ );
215
+ }
216
+ if (this.novelSinceExtension < 1) {
217
+ return decline(
218
+ `no novel tool calls since the last extension (cycling through ${p.distinct} repeated target(s))`,
219
+ );
220
+ }
221
+ this.extensions++;
222
+ this.novelSinceExtension = 0;
223
+ this.currentLimit = Math.min(this.currentLimit + this.cfg.extensionSteps, this.cfg.hardCap);
224
+ return {
225
+ extend: true,
226
+ reason:
227
+ `progress detected (${p.ok}/${p.total} recent tools ok, ${p.distinct} targets) — ` +
228
+ `step budget extended to ${this.currentLimit} (extension ${this.extensions}/${this.cfg.maxExtensions})`,
229
+ limit: this.currentLimit,
230
+ };
231
+ }
232
+ }
@@ -2,15 +2,19 @@
2
2
  * Subagent role registry (gjc role-agent parity: executor / planner / architect /
3
3
  * critic). A "subagent" is the executor tool-loop driven with a role-specific
4
4
  * system prompt, model, step budget, and toolset. The registry is pure data so
5
- * it can be listed in the TUI (`/agents`) and consumed by `joc team` without
5
+ * it can be listed in the TUI (`/agents`) and consumed by `jeo team` without
6
6
  * importing any provider or I/O code.
7
7
  *
8
8
  * Read-only roles (planner/architect/critic) get a mutation-free toolset so a
9
9
  * review/plan lane physically cannot edit the repo, mirroring gjc's read-only
10
10
  * role agents.
11
11
  */
12
- import { DEFAULT_TOOLS, executorSystemPrompt, type ToolHandler } from "./engine";
12
+ import { DEFAULT_TOOLS, TOOL_PROTOCOL, READONLY_TOOL_PROTOCOL, WORKING_DISCIPLINE, type ToolHandler } from "./engine";
13
13
  import type { Config } from "./state";
14
+ import architectPrompt from "../prompts/agents/architect.md" with { type: "text" };
15
+ import criticPrompt from "../prompts/agents/critic.md" with { type: "text" };
16
+ import executorPrompt from "../prompts/agents/executor.md" with { type: "text" };
17
+ import plannerPrompt from "../prompts/agents/planner.md" with { type: "text" };
14
18
 
15
19
  export interface SubagentRole {
16
20
  /** Stable id used in config + `/agents <id>`. */
@@ -23,6 +27,16 @@ export interface SubagentRole {
23
27
  readOnly: boolean;
24
28
  /** Default tool-loop step budget for this role. */
25
29
  defaultMaxSteps: number;
30
+ /** Role-specific prompt template. */
31
+ prompt: string;
32
+ /** Required markers that must appear in done.reason. */
33
+ requiredDoneMarkers?: string[];
34
+ /** When set on a MUTATING role, bash is wrapped so EVERY shell segment must
35
+ * start with one of these prefixes (after stripping leading env-assignments /
36
+ * `sudo`); anything else is rejected. The registry definition IS the runtime
37
+ * constraint — no config plumbing. Undefined = unconstrained bash. Read-only
38
+ * roles drop bash entirely regardless. (plan/gjc-inheritance.md cycle 10, B5) */
39
+ bashAllowedPrefixes?: string[];
26
40
  }
27
41
 
28
42
  /** The four bundled subagent roles. `executor` is the only mutating role. */
@@ -33,6 +47,8 @@ export const SUBAGENT_ROLES: readonly SubagentRole[] = [
33
47
  description: "Bounded implementation, refactors, fixes, and verification-ready edits.",
34
48
  readOnly: false,
35
49
  defaultMaxSteps: 15,
50
+ prompt: executorPrompt,
51
+ requiredDoneMarkers: ["Summary:", "Changed Files:", "Verification:"],
36
52
  },
37
53
  {
38
54
  id: "planner",
@@ -40,6 +56,17 @@ export const SUBAGENT_ROLES: readonly SubagentRole[] = [
40
56
  description: "Read-only sequencing, acceptance criteria, risks, and handoff shape.",
41
57
  readOnly: true,
42
58
  defaultMaxSteps: 10,
59
+ prompt: plannerPrompt,
60
+ requiredDoneMarkers: [
61
+ "Summary:",
62
+ "In Scope:",
63
+ "Out of Scope:",
64
+ "File-level Changes:",
65
+ "Sequencing:",
66
+ "Acceptance Criteria:",
67
+ "Verification:",
68
+ "Risks:",
69
+ ],
43
70
  },
44
71
  {
45
72
  id: "architect",
@@ -47,6 +74,14 @@ export const SUBAGENT_ROLES: readonly SubagentRole[] = [
47
74
  description: "Read-only architecture and code review with severity-rated findings.",
48
75
  readOnly: true,
49
76
  defaultMaxSteps: 10,
77
+ prompt: architectPrompt,
78
+ requiredDoneMarkers: [
79
+ "Summary:",
80
+ "Findings:",
81
+ "Recommendations:",
82
+ "Architectural Status:",
83
+ "Code Review Recommendation:",
84
+ ],
50
85
  },
51
86
  {
52
87
  id: "critic",
@@ -54,20 +89,64 @@ export const SUBAGENT_ROLES: readonly SubagentRole[] = [
54
89
  description: "Read-only plan critic; approves only actionable, verifiable plans.",
55
90
  readOnly: true,
56
91
  defaultMaxSteps: 8,
92
+ prompt: criticPrompt,
93
+ requiredDoneMarkers: ["Justification:"],
57
94
  },
58
95
  ];
59
96
 
60
97
  const DEFAULT_ROLE_ID = "executor";
61
98
 
99
+ /** Generic prompt template for CONFIG-DECLARED custom roles (no per-role .md
100
+ * bundled). Uses the same {{…}} variables as the bundled templates, so the
101
+ * whole role pipeline stays template-driven rather than hardcoded. */
102
+ const CUSTOM_ROLE_PROMPT = `You are the {{ROLE_TITLE}} subagent: {{ROLE_DESCRIPTION}}
103
+
104
+ {{TOOL_PROTOCOL}}
105
+
106
+ Work strictly within your assignment. When finished, reply
107
+ {"tool":"done","arguments":{"reason":"Summary: <what you found/did>"}}.`;
108
+
109
+ /**
110
+ * SYSTEM-driven role registry: config.subagents entries that DECLARE a role
111
+ * identity (a \`prompt\`, \`title\`, or \`description\`) under an id that is not
112
+ * bundled become first-class roles — no code change needed to add one. Bare
113
+ * model/steps pins on unknown ids are NOT roles (typo safety). Safety default:
114
+ * a custom role is READ-ONLY unless it explicitly sets \`readOnly: false\`.
115
+ */
116
+ export function rolesFromConfig(config: Pick<Config, "subagents">): SubagentRole[] {
117
+ const custom: SubagentRole[] = [];
118
+ for (const [rawId, entry] of Object.entries(config.subagents ?? {})) {
119
+ const id = normalizeRoleId(rawId);
120
+ if (!id || SUBAGENT_ROLES.some(r => r.id === id)) continue;
121
+ if (!entry || (entry.prompt === undefined && entry.title === undefined && entry.description === undefined)) continue;
122
+ const title = entry.title ?? id.charAt(0).toUpperCase() + id.slice(1);
123
+ custom.push({
124
+ id,
125
+ title,
126
+ description: entry.description ?? `Custom role "${id}" (declared in config.subagents).`,
127
+ readOnly: entry.readOnly ?? true,
128
+ defaultMaxSteps: typeof entry.maxSteps === "number" && entry.maxSteps > 0 ? entry.maxSteps : 12,
129
+ prompt: (entry.prompt ?? CUSTOM_ROLE_PROMPT).replaceAll("{{ROLE_DESCRIPTION}}", entry.description ?? title),
130
+ });
131
+ }
132
+ return custom;
133
+ }
134
+
135
+ /** Bundled roles + config-declared custom roles (the full live registry). */
136
+ export function allSubagentRoles(config?: Pick<Config, "subagents">): SubagentRole[] {
137
+ return config ? [...SUBAGENT_ROLES, ...rolesFromConfig(config)] : [...SUBAGENT_ROLES];
138
+ }
139
+
62
140
  /** Normalize loosely-typed role input (case-insensitive, trimmed). */
63
141
  export function normalizeRoleId(input: string | undefined | null): string {
64
142
  return (input ?? "").trim().toLowerCase();
65
143
  }
66
144
 
67
- /** Look up a role by id (case-insensitive). Returns undefined when unknown. */
68
- export function getSubagentRole(id: string | undefined | null): SubagentRole | undefined {
145
+ /** Look up a role by id (case-insensitive) across the bundled registry and —
146
+ * when a config is supplied config-declared custom roles. */
147
+ export function getSubagentRole(id: string | undefined | null, config?: Pick<Config, "subagents">): SubagentRole | undefined {
69
148
  const want = normalizeRoleId(id);
70
- return SUBAGENT_ROLES.find(r => r.id === want);
149
+ return allSubagentRoles(config).find(r => r.id === want);
71
150
  }
72
151
 
73
152
  /** The default role (`executor`) used when none is specified. */
@@ -81,41 +160,112 @@ export type SubagentConfig = NonNullable<Config["subagents"]>;
81
160
  /** Per-role model override → falls back to the global default model. */
82
161
  export function resolveSubagentModel(roleId: string, config: Pick<Config, "defaultModel" | "subagents">): string {
83
162
  const entry = config.subagents?.[normalizeRoleId(roleId)];
84
- return entry?.model ?? config.defaultModel;
163
+ return entry?.model || config.defaultModel;
85
164
  }
86
165
 
87
166
  /** Per-role step budget → config override, else the role default, else 15. */
88
167
  export function resolveSubagentMaxSteps(roleId: string, config: Pick<Config, "subagents">): number {
89
168
  const entry = config.subagents?.[normalizeRoleId(roleId)];
90
169
  if (typeof entry?.maxSteps === "number" && entry.maxSteps > 0) return entry.maxSteps;
91
- return getSubagentRole(roleId)?.defaultMaxSteps ?? 15;
170
+ return getSubagentRole(roleId, config)?.defaultMaxSteps ?? 15;
171
+ }
172
+
173
+ function renderRolePrompt(template: string, role: SubagentRole): string {
174
+ return template
175
+ .replaceAll("{{TOOL_PROTOCOL}}", `${TOOL_PROTOCOL}\n\n${WORKING_DISCIPLINE}`)
176
+ .replaceAll("{{READONLY_TOOL_PROTOCOL}}", READONLY_TOOL_PROTOCOL)
177
+ .replaceAll("{{ROLE_TITLE}}", role.title)
178
+ .trim();
179
+ }
180
+
181
+ export function validateSubagentDoneReason(role: SubagentRole, reason: string | undefined): { ok: boolean; missing?: string[] } {
182
+ const trimmed = (reason ?? "").trim();
183
+ if (!trimmed) return { ok: false, missing: ["done.reason"] };
184
+ if (role.id === "critic") {
185
+ const verdicts = ["[OKAY]", "[ITERATE]", "[REJECT]"];
186
+ const hasVerdict = verdicts.some(marker => trimmed.startsWith(marker));
187
+ const missing = [
188
+ ...(hasVerdict ? [] : ["[OKAY]|[ITERATE]|[REJECT]"]),
189
+ ...((role.requiredDoneMarkers ?? []).filter(marker => !trimmed.includes(marker))),
190
+ ];
191
+ return { ok: missing.length === 0, missing };
192
+ }
193
+ const missing = (role.requiredDoneMarkers ?? []).filter(marker => !trimmed.includes(marker));
194
+ return { ok: missing.length === 0, missing };
92
195
  }
93
196
 
94
- /** Build a role-specific executor system prompt; read-only roles get a no-mutation directive. */
197
+ /** Build a role-specific system prompt from its dedicated template. */
95
198
  export function subagentSystemPrompt(role: SubagentRole): string {
96
- const base = executorSystemPrompt(`${role.title} subagent — ${role.description}`);
97
- if (!role.readOnly) return base;
98
- return (
99
- base +
100
- `\n\nYou are a READ-ONLY ${role.title}. Do not create or modify files; ` +
101
- `use read / find / search (and read-only bash) only, then report your findings via done.`
102
- );
199
+ return renderRolePrompt(role.prompt, role);
200
+ }
201
+
202
+ /**
203
+ * True when EVERY shell segment of `command` starts with one of `prefixes`
204
+ * (after stripping leading env-assignments and a leading `sudo`). Splitting on
205
+ * `; && || |` means a chained `… && rm -rf` cannot smuggle an un-vetted command
206
+ * past a single allowed head. Command substitution (`$(…)`, backticks) can't be
207
+ * statically vetted, so it is rejected outright. Empty allowlist = unconstrained.
208
+ */
209
+ export function bashCommandAllowed(command: string, prefixes: string[]): boolean {
210
+ if (prefixes.length === 0) return true;
211
+ if (/[`]|\$\(/.test(command)) return false; // un-vettable command substitution
212
+ const matches = (seg: string, p: string) =>
213
+ seg === p || seg.startsWith(p + " ") || seg.startsWith(p + "\t");
214
+ for (const raw of command.split(/(?:&&|\|\||[;|])/)) {
215
+ let seg = raw.trim();
216
+ if (seg === "") continue;
217
+ seg = seg
218
+ .replace(/^(?:\w+=(?:"[^"]*"|'[^']*'|\S+)\s+)+/, "") // FOO=bar BAZ=qux cmd
219
+ .replace(/^sudo\s+/, "")
220
+ .trim();
221
+ if (seg === "" || !prefixes.some(p => matches(seg, p))) return false;
222
+ }
223
+ return true;
103
224
  }
104
225
 
105
- /** Toolset for a role: read-only roles drop the mutating tools (write/edit). */
226
+ /**
227
+ * Toolset for a role:
228
+ * - Read-only roles drop ALL mutating tools (write/edit AND bash) so a
229
+ * review/plan lane physically cannot change the repo.
230
+ * - A mutating role with `bashAllowedPrefixes` gets bash replaced by a
231
+ * prefix-checking wrapper (registry definition = runtime constraint).
232
+ * - Otherwise the full default toolset (unconstrained bash) is returned.
233
+ */
106
234
  export function subagentToolset(role: SubagentRole): Record<string, ToolHandler> {
107
- if (!role.readOnly) return DEFAULT_TOOLS;
108
- const ro: Record<string, ToolHandler> = {};
109
- for (const [name, handler] of Object.entries(DEFAULT_TOOLS)) {
110
- if (name === "write" || name === "edit") continue;
111
- ro[name] = handler;
235
+ if (role.readOnly) {
236
+ const MUTATING = new Set(["write", "edit", "bash"]);
237
+ const ro: Record<string, ToolHandler> = {};
238
+ for (const [name, handler] of Object.entries(DEFAULT_TOOLS)) {
239
+ if (MUTATING.has(name)) continue;
240
+ ro[name] = handler;
241
+ }
242
+ return ro;
112
243
  }
113
- return ro;
244
+ const prefixes = role.bashAllowedPrefixes;
245
+ if (prefixes && prefixes.length > 0) {
246
+ const inner = DEFAULT_TOOLS.bash;
247
+ const guarded: Record<string, ToolHandler> = { ...DEFAULT_TOOLS };
248
+ guarded.bash = async (a, cwd) => {
249
+ const command = String(a.command ?? a.cmd ?? "");
250
+ if (!bashCommandAllowed(command, prefixes)) {
251
+ return {
252
+ success: false,
253
+ output: "",
254
+ error:
255
+ `bash rejected for role '${role.id}': every command segment must start with one of [${prefixes.join(", ")}]. ` +
256
+ `Received: ${command.trim().slice(0, 120)}`,
257
+ };
258
+ }
259
+ return inner(a, cwd);
260
+ };
261
+ return guarded;
262
+ }
263
+ return DEFAULT_TOOLS;
114
264
  }
115
265
 
116
- /** All role ids (for `/agents` autocomplete + validation). */
117
- export function subagentRoleIds(): string[] {
118
- return SUBAGENT_ROLES.map(r => r.id);
266
+ /** All role ids — bundled + config-declared (for autocomplete + validation). */
267
+ export function subagentRoleIds(config?: Pick<Config, "subagents">): string[] {
268
+ return allSubagentRoles(config).map(r => r.id);
119
269
  }
120
270
 
121
271
  /** Parse a `/agents <role> maxSteps <n>` value → positive int, else undefined. */
@@ -129,10 +279,21 @@ export function parseMaxSteps(input: string | undefined): number | undefined {
129
279
  * maxSteps). Pure — does not mutate `config`. Unknown roles are rejected by the
130
280
  * caller via `getSubagentRole`; this helper trusts the id it is given.
131
281
  */
282
+ export type ThinkLevelValue = "minimal" | "low" | "medium" | "high" | "xhigh";
283
+
284
+ /** Per-role reasoning level → explicit role override, else undefined (= INHERIT
285
+ * the global thinkingLevel at call time — gjc's "(inherit)" semantics). */
286
+ export function resolveSubagentThinking(
287
+ roleId: string,
288
+ config: Pick<Config, "subagents">,
289
+ ): ThinkLevelValue | undefined {
290
+ return config.subagents?.[normalizeRoleId(roleId)]?.thinking;
291
+ }
292
+
132
293
  export function withSubagentSetting(
133
294
  config: Pick<Config, "subagents">,
134
295
  roleId: string,
135
- patch: { model?: string; maxSteps?: number },
296
+ patch: { model?: string; maxSteps?: number; thinking?: ThinkLevelValue },
136
297
  ): SubagentConfig {
137
298
  const id = normalizeRoleId(roleId);
138
299
  const subs: SubagentConfig = { ...(config.subagents ?? {}) };
@@ -147,3 +308,39 @@ export function clearSubagentSetting(config: Pick<Config, "subagents">, roleId:
147
308
  delete subs[id];
148
309
  return subs;
149
310
  }
311
+
312
+ /** One pickable apply-target: the global default or a subagent role. */
313
+ export interface ApplyTargetChoice {
314
+ /** "default" or a subagent role id. */
315
+ value: string;
316
+ label: string;
317
+ /** Right-aligned hint: the target's CURRENT model (so the picker doubles as a viewer). */
318
+ hint: string;
319
+ }
320
+
321
+ /**
322
+ * Choices for the "apply picked model to…" picker shown after an interactive
323
+ * model selection (gjc parity: picking a model also lets you pick WHO uses it).
324
+ * The hint shows each target's current model, so the same picker doubles as a
325
+ * read-and-change panel for existing role assignments. Pure — testable.
326
+ */
327
+ export function applyTargetChoices(
328
+ config: Pick<Config, "defaultModel" | "subagents" | "thinkingLevel">,
329
+ ): ApplyTargetChoice[] {
330
+ const roleThink = (id: string): string => {
331
+ const t = resolveSubagentThinking(id, config);
332
+ return t ? ` (${t})` : " (inherit)";
333
+ };
334
+ return [
335
+ {
336
+ value: "default",
337
+ label: "default — every session",
338
+ hint: `${config.defaultModel} (${config.thinkingLevel ?? "medium"})`,
339
+ },
340
+ ...allSubagentRoles(config).map(role => ({
341
+ value: role.id,
342
+ label: `subagent ${role.id} — ${role.title}`,
343
+ hint: resolveSubagentModel(role.id, config) + (config.subagents?.[role.id]?.model ? "" : " (default)") + roleThink(role.id),
344
+ })),
345
+ ];
346
+ }