@yagni-app/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.
Files changed (48) hide show
  1. package/dist/cli.js +12 -0
  2. package/dist/connectClaudeCode.d.ts +77 -0
  3. package/dist/connectClaudeCode.js +228 -0
  4. package/dist/connectCodex.d.ts +75 -0
  5. package/dist/connectCodex.js +201 -0
  6. package/dist/extension/approvedPrefixes.d.ts +11 -0
  7. package/dist/extension/approvedPrefixes.js +30 -0
  8. package/dist/extension/askAdvisorTool.d.ts +18 -3
  9. package/dist/extension/askAdvisorTool.js +121 -15
  10. package/dist/extension/askYagniTool.d.ts +23 -0
  11. package/dist/extension/askYagniTool.js +42 -2
  12. package/dist/extension/branding.d.ts +11 -1
  13. package/dist/extension/branding.js +47 -7
  14. package/dist/extension/config.d.ts +12 -0
  15. package/dist/extension/config.js +2 -1
  16. package/dist/extension/execPolicy.d.ts +17 -1
  17. package/dist/extension/execPolicy.js +164 -33
  18. package/dist/extension/flywheel.d.ts +44 -0
  19. package/dist/extension/flywheel.js +53 -0
  20. package/dist/extension/footer.d.ts +8 -1
  21. package/dist/extension/footer.js +33 -19
  22. package/dist/extension/guardian.d.ts +14 -4
  23. package/dist/extension/guardian.js +35 -11
  24. package/dist/extension/index.d.ts +20 -3
  25. package/dist/extension/index.js +92 -13
  26. package/dist/extension/mineBeat.d.ts +95 -0
  27. package/dist/extension/mineBeat.js +193 -0
  28. package/dist/extension/permission.d.ts +1 -0
  29. package/dist/extension/permission.js +23 -18
  30. package/dist/extension/pipeline/goCommand.js +6 -4
  31. package/dist/extension/pipeline/personas.js +1 -1
  32. package/dist/extension/pipeline/resilience.d.ts +2 -1
  33. package/dist/extension/pipeline/resilience.js +21 -2
  34. package/dist/extension/pipeline/runRegistry.d.ts +9 -1
  35. package/dist/extension/pipeline/runRegistry.js +22 -1
  36. package/dist/extension/recordDecisionTool.d.ts +8 -0
  37. package/dist/extension/recordDecisionTool.js +24 -0
  38. package/dist/extension/subagents.d.ts +7 -1
  39. package/dist/extension/subagents.js +60 -5
  40. package/dist/extension/todos.d.ts +28 -1
  41. package/dist/extension/todos.js +76 -1
  42. package/dist/extension/ultra.d.ts +27 -0
  43. package/dist/extension/ultra.js +76 -0
  44. package/dist/login.d.ts +4 -2
  45. package/dist/login.js +19 -4
  46. package/dist/token.d.ts +25 -0
  47. package/dist/token.js +45 -0
  48. package/package.json +3 -2
@@ -77,7 +77,7 @@ import { registerGoStatusCommands } from "./goStatusCommands.js";
77
77
  import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
78
78
  import { composeAbortSignal } from "./resilience.js";
79
79
  import { planResume } from "./resume.js";
80
- import { activeRunCount, beginRun, classifyRunLiveness, findActiveRunByTicket, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, MAX_CONCURRENT_RUNS, settleRun, trackRunAbort, trackRunPromise, worktreesDir, } from "./runRegistry.js";
80
+ import { activeRunCount, beginRun, classifyRunLiveness, findActiveRunByTicket, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, resolveMaxConcurrentRuns, settleRun, trackRunAbort, trackRunPromise, worktreesDir, } from "./runRegistry.js";
81
81
  import { makeRunSession as defaultMakeRunSession } from "./runSession.js";
82
82
  import { recordSessionRun } from "../sessionRuns.js";
83
83
  import { resolveTicketBrief as defaultResolveTicketBrief } from "./ticketResolution.js";
@@ -460,13 +460,15 @@ export function registerGoCommand(pi, deps = {}) {
460
460
  return;
461
461
  }
462
462
  // In-flight guards: the same ticket never runs twice at once in this
463
- // process, and at most MAX_CONCURRENT_RUNS runs are in flight.
463
+ // process, and at most resolveMaxConcurrentRuns() runs are in flight
464
+ // (default 3; fleet operators raise it via YAGNI_MAX_CONCURRENT_RUNS).
464
465
  if (findActiveRunByTicket(ticket)) {
465
466
  notify(`/go ${ticket} is already running - see /go-status.`, "warning");
466
467
  return;
467
468
  }
468
- if (activeRunCount() >= MAX_CONCURRENT_RUNS) {
469
- notify(`${MAX_CONCURRENT_RUNS} /go runs are already in flight; wait for one to finish (see /go-status).`, "warning");
469
+ const maxConcurrentRuns = resolveMaxConcurrentRuns();
470
+ if (activeRunCount() >= maxConcurrentRuns) {
471
+ notify(`${maxConcurrentRuns} /go runs are already in flight; wait for one to finish (see /go-status) or raise YAGNI_MAX_CONCURRENT_RUNS.`, "warning");
470
472
  return;
471
473
  }
472
474
  // --- Run tree resolution: worktree by default; --here = legacy in-place.
@@ -57,7 +57,7 @@ Budget discipline: you have a hard output budget, and a plan that gets cut off m
57
57
  Keep it concrete; the worker executes it verbatim.`;
58
58
  const WORKER_BODY = `You are a worker with full capabilities, operating in an isolated context to implement a plan. Work autonomously and use the tools as needed.
59
59
 
60
- You are grounded. Call ask_yagni before guessing about anything organization- or codebase-specific. Critically: for ANY product-intent call you are forced to make that the plan did not settle — a behavior choice, a tradeoff, an interpretation of intent — call record_decision so the company's decision corpus captures it and the next agent inherits the call instead of re-litigating it.
60
+ You are grounded. Call ask_yagni before guessing about anything organization- or codebase-specific. Treat a confirmed answer as settled; when an answer is an unverified assumption or an inference and your change leans on it, say so in your Notes so the reviewer knows what to check. Critically: for ANY product-intent call you are forced to make that the plan did not settle — a behavior choice, a tradeoff, an interpretation of intent — call record_decision so the company's decision corpus captures it and the next agent inherits the call instead of re-litigating it. When ask_yagni reports no recorded position, follow its instruction and record the assumption you proceed on.
61
61
 
62
62
  You MUST make the change. If the plan is missing, partial, or appears cut off, do not stop at exploring: implement the ticket directly from the ticket text and the code, calling record_decision for any intent you infer. Ending your turn with no write/edit is a failure.
63
63
 
@@ -4,7 +4,8 @@
4
4
  * `withResilience(runStage, policy)` is a composable higher-order function that
5
5
  * wraps the per-stage child spawn (`runner.ts#runStage`) with the one axis the
6
6
  * roadmap calls the whole competitive gap: a per-stage IDLE timeout (no NDJSON
7
- * event for N ms) and a total WALL-CLOCK timeout, both firing the runner's
7
+ * event for N ms, deferred while a tool is in flight see the stall note at
8
+ * the timer wiring) and a total WALL-CLOCK timeout, both firing the runner's
8
9
  * existing SIGTERM -> SIGKILL abort; bounded exponential backoff with jitter; and
9
10
  * retry of CLASSIFIED-TRANSIENT outcomes only. One structured telemetry record is
10
11
  * emitted per attempt.
@@ -4,7 +4,8 @@
4
4
  * `withResilience(runStage, policy)` is a composable higher-order function that
5
5
  * wraps the per-stage child spawn (`runner.ts#runStage`) with the one axis the
6
6
  * roadmap calls the whole competitive gap: a per-stage IDLE timeout (no NDJSON
7
- * event for N ms) and a total WALL-CLOCK timeout, both firing the runner's
7
+ * event for N ms, deferred while a tool is in flight see the stall note at
8
+ * the timer wiring) and a total WALL-CLOCK timeout, both firing the runner's
8
9
  * existing SIGTERM -> SIGKILL abort; bounded exponential backoff with jitter; and
9
10
  * retry of CLASSIFIED-TRANSIENT outcomes only. One structured telemetry record is
10
11
  * emitted per attempt.
@@ -100,19 +101,37 @@ export function withResilience(base, policy, opts = {}) {
100
101
  if (!timeoutController.signal.aborted)
101
102
  timeoutController.abort();
102
103
  };
104
+ // Tools the child has started but not finished. The idle window measures
105
+ // STALL, not silence: a long quiet tool (a 6-minute test suite, a slow
106
+ // build) emits no NDJSON between its start and end events, and that is
107
+ // progress, not a hang. While a tool is in flight the idle expiry defers
108
+ // and re-arms instead of aborting; the wall-clock timer stays the
109
+ // backstop for a tool that is genuinely hung.
110
+ let inFlightTools = 0;
103
111
  let idleTimer;
104
112
  const armIdle = () => {
105
113
  if (idleTimer)
106
114
  clearTimeout(idleTimer);
107
- idleTimer = setTimeout(fireTimeout, policy.idleTimeoutMs);
115
+ idleTimer = setTimeout(fireIdle, policy.idleTimeoutMs);
108
116
  idleTimer.unref?.();
109
117
  };
118
+ const fireIdle = () => {
119
+ if (inFlightTools > 0) {
120
+ armIdle();
121
+ return;
122
+ }
123
+ fireTimeout();
124
+ };
110
125
  const wallTimer = setTimeout(fireTimeout, policy.wallTimeoutMs);
111
126
  wallTimer.unref?.();
112
127
  armIdle();
113
128
  const originalOnEvent = deps.onEvent;
114
129
  const onEvent = (ev) => {
115
130
  sawAnyEvent = true;
131
+ if (ev.type === "tool_execution_start")
132
+ inFlightTools += 1;
133
+ else if (ev.type === "tool_execution_end")
134
+ inFlightTools = Math.max(0, inFlightTools - 1);
116
135
  armIdle(); // reset the idle window on every live event
117
136
  originalOnEvent?.(ev);
118
137
  };
@@ -22,8 +22,16 @@
22
22
  * candidate).
23
23
  */
24
24
  import type { CheckpointRecord, StopReason } from "./types.js";
25
- /** Bound on simultaneously in-flight /go runs in one process (spec §3b). */
25
+ /** Default bound on simultaneously in-flight /go runs in one process (spec §3b). */
26
26
  export declare const MAX_CONCURRENT_RUNS = 3;
27
+ /** Hard ceiling for the env override — a typo must not launch hundreds of runs. */
28
+ export declare const MAX_CONCURRENT_RUNS_CEILING = 32;
29
+ /**
30
+ * Resolve the in-flight /go cap from the environment. `YAGNI_MAX_CONCURRENT_RUNS`
31
+ * raises (or lowers) the default for fleet-scale operators; non-numeric or < 1
32
+ * falls back to the default, and anything above the ceiling clamps to it.
33
+ */
34
+ export declare function resolveMaxConcurrentRuns(env?: Record<string, string | undefined>): number;
27
35
  /**
28
36
  * A non-terminal row whose journal has been quiet this long is treated as
29
37
  * INTERRUPTED (its process died) rather than still running elsewhere. Sits
@@ -24,8 +24,22 @@
24
24
  import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
25
25
  import { join } from "node:path";
26
26
  import { codeStateHome } from "../stateHome.js";
27
- /** Bound on simultaneously in-flight /go runs in one process (spec §3b). */
27
+ /** Default bound on simultaneously in-flight /go runs in one process (spec §3b). */
28
28
  export const MAX_CONCURRENT_RUNS = 3;
29
+ /** Hard ceiling for the env override — a typo must not launch hundreds of runs. */
30
+ export const MAX_CONCURRENT_RUNS_CEILING = 32;
31
+ /**
32
+ * Resolve the in-flight /go cap from the environment. `YAGNI_MAX_CONCURRENT_RUNS`
33
+ * raises (or lowers) the default for fleet-scale operators; non-numeric or < 1
34
+ * falls back to the default, and anything above the ceiling clamps to it.
35
+ */
36
+ export function resolveMaxConcurrentRuns(env = process.env) {
37
+ const raw = env.YAGNI_MAX_CONCURRENT_RUNS?.trim();
38
+ const parsed = raw ? Number.parseInt(raw, 10) : NaN;
39
+ if (!Number.isFinite(parsed) || parsed < 1)
40
+ return MAX_CONCURRENT_RUNS;
41
+ return Math.min(parsed, MAX_CONCURRENT_RUNS_CEILING);
42
+ }
29
43
  /**
30
44
  * A non-terminal row whose journal has been quiet this long is treated as
31
45
  * INTERRUPTED (its process died) rather than still running elsewhere. Sits
@@ -106,6 +120,13 @@ const active = new Map();
106
120
  export function _resetRunRegistryForTest() {
107
121
  active.clear();
108
122
  }
123
+ // NOTE on growth: the mirror is append-only and grows without bound on a
124
+ // long-lived install. In-place compaction was reviewed and REMOVED (PR #1698):
125
+ // a fold+rewrite without cross-process exclusion can permanently erase another
126
+ // process's terminal settle (nothing ever re-appends a final row), which would
127
+ // resurrect a finished run as "interrupted" and invite duplicate worktree
128
+ // adoption. Compaction needs an inter-process lock + unique temp files —
129
+ // tracked separately; until then, growth is the safe failure mode.
109
130
  /** Fail-soft append of one full row to the mirror (self-heals a torn previous write). */
110
131
  function appendRow(row) {
111
132
  try {
@@ -1,5 +1,6 @@
1
1
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
+ import { type FlywheelState } from "./flywheel.js";
3
4
  /** Options for {@link makeRecordDecisionTool}. */
4
5
  export interface MakeRecordDecisionToolOptions {
5
6
  baseUrl: string;
@@ -7,6 +8,13 @@ export interface MakeRecordDecisionToolOptions {
7
8
  fetchImpl?: typeof fetch;
8
9
  /** Idempotency-key source (default: crypto.randomUUID); injected in tests. */
9
10
  makeIdempotencyKey?: () => string;
11
+ /**
12
+ * Shared flywheel session state (Run 7). A record_decision that follows a
13
+ * surfaced no-position suggestion sends `dedupe: true` — a mid-run agent
14
+ * has no human to adjudicate a near-duplicate. A human `/decide` never
15
+ * rides this state.
16
+ */
17
+ flywheel?: FlywheelState;
10
18
  }
11
19
  /** The durable fields of a recorded product-intent decision. */
12
20
  export interface RecordDecisionParams {
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { Type } from "typebox";
3
+ import { consumeFlywheelAttribution } from "./flywheel.js";
3
4
  import { sendOrSpool } from "./spool.js";
4
5
  /**
5
6
  * POST a single decision to the token-scoped grounding endpoint and return its
@@ -67,12 +68,20 @@ export function makeRecordDecisionTool(opts) {
67
68
  // never bank the same decision twice. Transport failures and 5xx are
68
69
  // spooled durably instead of lost (R4 write half).
69
70
  const idempotencyKey = (opts.makeIdempotencyKey ?? randomUUID)();
71
+ // Run 7 flywheel attribution: a record answering the QUESTION a
72
+ // surfaced no-position suggestion asked about asks the backend to
73
+ // dedupe against active decisions first (decisive, not advisory — no
74
+ // human is present). An unrelated record never inherits the flag.
75
+ const flywheelAttributed = opts.flywheel
76
+ ? consumeFlywheelAttribution(opts.flywheel, params.question)
77
+ : false;
70
78
  const outcome = await sendOrSpool(opts, "record_decision", "/api/yagni-code/decisions", {
71
79
  question: params.question,
72
80
  decision: params.decision,
73
81
  rationale: params.rationale,
74
82
  repo: params.repo,
75
83
  workItemId: params.workItemId,
84
+ ...(flywheelAttributed ? { dedupe: true } : {}),
76
85
  }, idempotencyKey, signal);
77
86
  if (outcome.kind === "rejected") {
78
87
  throw new Error(outcome.message);
@@ -92,6 +101,21 @@ export function makeRecordDecisionTool(opts) {
92
101
  };
93
102
  }
94
103
  const data = outcome.json;
104
+ if (data?.deduped) {
105
+ // The backend matched an existing active decision and inserted
106
+ // nothing; surface it so the agent leans on the recorded judgment.
107
+ const existing = data.existing;
108
+ const summary = existing?.decision ? ` ${existing.decision}` : "";
109
+ return {
110
+ content: [
111
+ {
112
+ type: "text",
113
+ text: `An equivalent decision is already recorded; nothing new was banked.${summary}`,
114
+ },
115
+ ],
116
+ details: { id: existing?.id ?? null, spooled: false },
117
+ };
118
+ }
95
119
  return {
96
120
  content: [{ type: "text", text: "Recorded the decision in YAGNI." }],
97
121
  details: { id: data?.id ?? null, spooled: false },
@@ -20,11 +20,13 @@
20
20
  import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
21
21
  import { Type } from "typebox";
22
22
  import { runStage } from "./pipeline/runner.js";
23
- import type { ModelTier, PipelineStage } from "./pipeline/types.js";
23
+ import { type ModelTier, type PipelineStage } from "./pipeline/types.js";
24
24
  import { renderSubagentCall, renderSubagentResult } from "./subagentRender.js";
25
25
  export declare const SUBAGENT_TOOL_NAME = "subagent";
26
26
  export declare const GENERAL_AGENT_NAME = "general";
27
27
  export declare const MAX_PARALLEL_SUBAGENTS = 4;
28
+ /** Wider fan-out ceiling while the session is in ultra mode (/ultra). */
29
+ export declare const MAX_PARALLEL_SUBAGENTS_ULTRA = 8;
28
30
  /**
29
31
  * The default tool surface a subagent gets when its definition declares none:
30
32
  * the full working set plus grounded answers, mirroring what a Claude Code
@@ -92,6 +94,8 @@ export interface MakeSubagentToolDeps {
92
94
  runStageImpl?: typeof runStage;
93
95
  discover?: (deps: DiscoverDeps) => SubagentDef[];
94
96
  homeDir?: string;
97
+ /** Live ultra-mode probe (/ultra): widens the per-call fan-out ceiling. */
98
+ isUltra?: () => boolean;
95
99
  }
96
100
  export declare function makeSubagentTool(deps?: MakeSubagentToolDeps): {
97
101
  name: string;
@@ -135,6 +139,8 @@ export declare function makeSubagentTool(deps?: MakeSubagentToolDeps): {
135
139
  export interface RegisterSubagentsDeps {
136
140
  discover?: (deps: DiscoverDeps) => SubagentDef[];
137
141
  homeDir?: string;
142
+ /** Live ultra-mode probe (/ultra): widens the per-call fan-out ceiling. */
143
+ isUltra?: () => boolean;
138
144
  }
139
145
  /** Wire the subagent tool and the /agents listing command. */
140
146
  export declare function registerSubagents(pi: ExtensionAPI, deps?: RegisterSubagentsDeps): void;
@@ -23,7 +23,9 @@ import { delimiter, join } from "node:path";
23
23
  import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
24
24
  import { Type } from "typebox";
25
25
  import { sanitizeCallerSegment } from "./config.js";
26
+ import { withResilience } from "./pipeline/resilience.js";
26
27
  import { runStage } from "./pipeline/runner.js";
28
+ import { DEFAULT_RESILIENCE_POLICY } from "./pipeline/types.js";
27
29
  import { applyChildEvent, finalizeTask, formatWorkingMessage, newTaskProgress, progressSummaryText, renderSubagentCall, renderSubagentResult, } from "./subagentRender.js";
28
30
  /**
29
31
  * YAG-471 attribution: the `x-yagni-caller` prefix for a subagent invocation.
@@ -34,6 +36,8 @@ const SUBAGENT_CALLER_PREFIX = "subagent:";
34
36
  export const SUBAGENT_TOOL_NAME = "subagent";
35
37
  export const GENERAL_AGENT_NAME = "general";
36
38
  export const MAX_PARALLEL_SUBAGENTS = 4;
39
+ /** Wider fan-out ceiling while the session is in ultra mode (/ultra). */
40
+ export const MAX_PARALLEL_SUBAGENTS_ULTRA = 8;
37
41
  /**
38
42
  * The default tool surface a subagent gets when its definition declares none:
39
43
  * the full working set plus grounded answers, mirroring what a Claude Code
@@ -123,6 +127,50 @@ const IMPLEMENTER_AGENT = {
123
127
  body: IMPLEMENTER_BODY,
124
128
  source: "builtin",
125
129
  };
130
+ const VERIFICATION_BODY = `You are an adversarial verifier. Another agent
131
+ produced work — a change, a plan, or a claim — and your job is to try to
132
+ BREAK it, not to summarize it. Default to skepticism: hunt for the concrete
133
+ failure scenario (the inputs, state, or sequence that makes it wrong). Bash
134
+ is read-only here (\`git diff\`, \`git log\`, \`git show\`); do NOT modify files
135
+ or run builds.
136
+
137
+ If your task names a lens (correctness, edge cases, codebase fit, security,
138
+ …), judge ONLY through that lens and leave the rest to your sibling
139
+ verifiers.
140
+
141
+ You are grounded in how THIS company works: call ask_yagni before inferring
142
+ a convention, an ownership rule, or anything organization-specific.
143
+
144
+ Your final message is your verdict back to the driving agent, which has NOT
145
+ seen what you read. Format:
146
+ ## Verdict
147
+ BROKEN or HOLDS, with one sentence why.
148
+ ## Findings
149
+ Each real problem: file:line, the concrete failure scenario, severity. No
150
+ style nits.
151
+ ## Not verified
152
+ What you could not check, and why.
153
+
154
+ A HOLDS after real digging is valuable; a rubber stamp is not. If you found
155
+ nothing, say exactly what you tried to break and how.`;
156
+ /** The diamond's reduce layer: refute-first review of completed work on the
157
+ * advanced tier (judgment is the whole job, so the premium is worth paying —
158
+ * the server clamps subagent children at advanced anyway). It has no
159
+ * edit/write tools; bash is included for `git diff`-style inspection and is
160
+ * restricted to read-only USE by the persona, the same prompt-level stance as
161
+ * /go's reviewer — not a technical guarantee. */
162
+ const VERIFICATION_AGENT = {
163
+ name: "verification",
164
+ description: "Adversarial verification of completed work: tries to refute a change, " +
165
+ "plan, or claim and reports concrete failure scenarios. Does not edit " +
166
+ "files. Fan out 2-3 with different lenses (correctness, edge cases, " +
167
+ "codebase fit) for anything significant and treat agreement as " +
168
+ "confirmation.",
169
+ model: "advanced",
170
+ tools: ["read", "grep", "find", "ls", "bash", "ask_yagni"],
171
+ body: VERIFICATION_BODY,
172
+ source: "builtin",
173
+ };
126
174
  // Concrete tiers a subagent can actually run on. `balanced` is deliberately NOT
127
175
  // a member here even though it is a member of `ModelTier`: a subagent needs
128
176
  // ONE model for its whole run, and balanced is a session-level routing policy,
@@ -210,7 +258,7 @@ function loadAgentsFromDir(dir, source) {
210
258
  export function discoverSubagents(deps) {
211
259
  const home = deps.homeDir ?? homedir();
212
260
  const layers = [
213
- [GENERAL_AGENT, SEARCHER_AGENT, IMPLEMENTER_AGENT],
261
+ [GENERAL_AGENT, SEARCHER_AGENT, IMPLEMENTER_AGENT, VERIFICATION_AGENT],
214
262
  ...pluginAgentDirs(deps.env ?? process.env).map((dir) => loadAgentsFromDir(dir, "plugin")),
215
263
  loadAgentsFromDir(join(home, ".claude", "agents"), "user-claude"),
216
264
  loadAgentsFromDir(join(deps.cwd, ".pi", "agents"), "project-pi"),
@@ -261,11 +309,17 @@ const parameters = Type.Object({
261
309
  task: Type.String(),
262
310
  agent: Type.Optional(Type.String()),
263
311
  }), {
264
- description: `Run several independent tasks in parallel (max ${MAX_PARALLEL_SUBAGENTS}). Use INSTEAD of task.`,
312
+ description: `Run several independent tasks in parallel (max ${MAX_PARALLEL_SUBAGENTS}; ${MAX_PARALLEL_SUBAGENTS_ULTRA} in ultra mode). Use INSTEAD of task.`,
265
313
  })),
266
314
  });
267
315
  export function makeSubagentTool(deps = {}) {
268
- const run = deps.runStageImpl ?? runStage;
316
+ // The default runner rides the /go pipeline's resilience wrapper, so a chat
317
+ // subagent gets the same idle + wall-clock ceilings and transient-only retry
318
+ // as a /go stage child (previously a hung subagent hung the tool call until
319
+ // the user pressed Esc). The synthetic stage id is "implement", so the
320
+ // wrapper's write-gate already refuses to re-run a child that may have
321
+ // landed a partial edit.
322
+ const run = deps.runStageImpl ?? withResilience(runStage, DEFAULT_RESILIENCE_POLICY);
269
323
  const discover = deps.discover ?? discoverSubagents;
270
324
  return {
271
325
  name: SUBAGENT_TOOL_NAME,
@@ -293,8 +347,9 @@ export function makeSubagentTool(deps = {}) {
293
347
  if (requested.length === 0) {
294
348
  return fail("Error: pass `task` (or a `tasks` array) describing what to do.");
295
349
  }
296
- if (requested.length > MAX_PARALLEL_SUBAGENTS) {
297
- return fail(`Error: at most ${MAX_PARALLEL_SUBAGENTS} parallel tasks per call.`);
350
+ const maxParallel = deps.isUltra?.() ? MAX_PARALLEL_SUBAGENTS_ULTRA : MAX_PARALLEL_SUBAGENTS;
351
+ if (requested.length > maxParallel) {
352
+ return fail(`Error: at most ${maxParallel} parallel tasks per call.`);
298
353
  }
299
354
  const cwd = ctx?.cwd ?? process.cwd();
300
355
  const agents = discover({ cwd, homeDir: deps.homeDir });
@@ -17,6 +17,16 @@ import { Type } from "typebox";
17
17
  export declare const TODO_TOOL_NAME = "todo_write";
18
18
  export declare const MAX_TODOS = 50;
19
19
  export declare const MAX_TODO_TEXT = 300;
20
+ /**
21
+ * Staleness-reminder throttle (both counters must trip): a reminder is
22
+ * eligible only after this many assistant turns since the last todo_write AND
23
+ * this many since the last reminder. The two-counter shape (staleness gate +
24
+ * anti-spam gate) mirrors what Claude Code ships for its own todo tool; the
25
+ * driver model routinely stops updating the board mid-grind (the frozen
26
+ * "Todos 0/8" report), and a bare description-level instruction does not
27
+ * survive a long run.
28
+ */
29
+ export declare const TODO_REMINDER_TURNS = 10;
20
30
  /**
21
31
  * The desktop's structured state record rides its own widget key, like the
22
32
  * `/go` run state: one JSON line the app parses and renders itself, never
@@ -61,6 +71,23 @@ export interface TodoTheme {
61
71
  export declare function renderTodoWidget(todos: TodoItem[], theme: TodoTheme): string[];
62
72
  /** The desktop state record: exactly one JSON line under TODO_STATE_KEY. */
63
73
  export declare function todoStateLine(todos: TodoItem[]): string;
74
+ /**
75
+ * PURE: is a staleness reminder due? Only when the board has open work (an
76
+ * empty or fully-completed list never nags) and BOTH throttle counters have
77
+ * reached {@link TODO_REMINDER_TURNS}.
78
+ */
79
+ export declare function shouldRemindTodos(input: {
80
+ todos: TodoItem[];
81
+ turnsSinceWrite: number;
82
+ turnsSinceReminder: number;
83
+ }): boolean;
84
+ /**
85
+ * PURE: the hedged reminder block appended to a tool result when the board has
86
+ * gone stale. Carries the CURRENT list so the model can reconcile without a
87
+ * read, and explicitly licenses ignoring it, so an accurate board costs one
88
+ * glance rather than a spurious todo_write.
89
+ */
90
+ export declare function formatTodoReminder(todos: TodoItem[]): string;
64
91
  /** Replay the branch: the last todo_write result is the canonical list. */
65
92
  export declare function reconstructTodos(entries: unknown[]): TodoItem[];
66
93
  type TodoParams = {
@@ -104,7 +131,7 @@ export declare function makeTodoTool(get: () => TodoItem[], set: (todos: TodoIte
104
131
  isError?: undefined;
105
132
  }>;
106
133
  };
107
- /** Wire the tool, the branch-replay events, and the /todos command. */
134
+ /** Wire the tool, the branch-replay events, the staleness reminder, and /todos. */
108
135
  export declare function registerTodos(pi: ExtensionAPI): void;
109
136
  export {};
110
137
  //# sourceMappingURL=todos.d.ts.map
@@ -17,6 +17,16 @@ import { isDesktopSurface } from "./surface.js";
17
17
  export const TODO_TOOL_NAME = "todo_write";
18
18
  export const MAX_TODOS = 50;
19
19
  export const MAX_TODO_TEXT = 300;
20
+ /**
21
+ * Staleness-reminder throttle (both counters must trip): a reminder is
22
+ * eligible only after this many assistant turns since the last todo_write AND
23
+ * this many since the last reminder. The two-counter shape (staleness gate +
24
+ * anti-spam gate) mirrors what Claude Code ships for its own todo tool; the
25
+ * driver model routinely stops updating the board mid-grind (the frozen
26
+ * "Todos 0/8" report), and a bare description-level instruction does not
27
+ * survive a long run.
28
+ */
29
+ export const TODO_REMINDER_TURNS = 10;
20
30
  const WIDGET_KEY = "yagni-todos";
21
31
  /**
22
32
  * The desktop's structured state record rides its own widget key, like the
@@ -101,6 +111,33 @@ export function renderTodoWidget(todos, theme) {
101
111
  export function todoStateLine(todos) {
102
112
  return JSON.stringify({ v: 1, todos });
103
113
  }
114
+ /**
115
+ * PURE: is a staleness reminder due? Only when the board has open work (an
116
+ * empty or fully-completed list never nags) and BOTH throttle counters have
117
+ * reached {@link TODO_REMINDER_TURNS}.
118
+ */
119
+ export function shouldRemindTodos(input) {
120
+ const { todos, turnsSinceWrite, turnsSinceReminder } = input;
121
+ if (todos.length === 0)
122
+ return false;
123
+ const { done, total } = todoSummary(todos);
124
+ if (done === total)
125
+ return false;
126
+ return turnsSinceWrite >= TODO_REMINDER_TURNS && turnsSinceReminder >= TODO_REMINDER_TURNS;
127
+ }
128
+ /**
129
+ * PURE: the hedged reminder block appended to a tool result when the board has
130
+ * gone stale. Carries the CURRENT list so the model can reconcile without a
131
+ * read, and explicitly licenses ignoring it, so an accurate board costs one
132
+ * glance rather than a spurious todo_write.
133
+ */
134
+ export function formatTodoReminder(todos) {
135
+ return ("⟦YAGNI todos⟧ The todo_write checklist has not been updated for a while. " +
136
+ "If the work has moved on, bring it current now: mark finished steps completed, " +
137
+ "set the step you are on to in_progress, and add newly discovered steps. " +
138
+ "If the list is already accurate, ignore this.\n" +
139
+ formatTodoList(todos));
140
+ }
104
141
  /** Replay the branch: the last todo_write result is the canonical list. */
105
142
  export function reconstructTodos(entries) {
106
143
  let todos = [];
@@ -186,9 +223,14 @@ export function makeTodoTool(get, set) {
186
223
  },
187
224
  };
188
225
  }
189
- /** Wire the tool, the branch-replay events, and the /todos command. */
226
+ /** Wire the tool, the branch-replay events, the staleness reminder, and /todos. */
190
227
  export function registerTodos(pi) {
191
228
  let todos = [];
229
+ // Staleness-reminder counters (see TODO_REMINDER_TURNS). Session-local like
230
+ // the list cache itself; branch replay resets them so a resume/fork never
231
+ // opens with an instantly-due reminder.
232
+ let turnsSinceWrite = 0;
233
+ let turnsSinceReminder = 0;
192
234
  const reconstruct = (ctx) => {
193
235
  try {
194
236
  todos = reconstructTodos(ctx.sessionManager.getBranch());
@@ -196,12 +238,45 @@ export function registerTodos(pi) {
196
238
  catch {
197
239
  todos = [];
198
240
  }
241
+ turnsSinceWrite = 0;
242
+ turnsSinceReminder = 0;
199
243
  paintWidget(ctx, todos);
200
244
  };
201
245
  pi.on("session_start", async (_event, ctx) => reconstruct(ctx));
202
246
  pi.on("session_tree", async (_event, ctx) => reconstruct(ctx));
247
+ // Turn counting: one tick per finalized assistant message, the same "turn"
248
+ // the model experiences between opportunities to call todo_write.
249
+ pi.on("message_end", async (event) => {
250
+ if (event.message?.role === "assistant") {
251
+ turnsSinceWrite += 1;
252
+ turnsSinceReminder += 1;
253
+ }
254
+ });
255
+ // The reminder rides an existing tool result (the same result-modification
256
+ // seam ambient recall uses), so it reaches the model mid-run without
257
+ // spending a turn. Never appended to todo_write's own result, and fail-soft:
258
+ // a reminder must never break a tool call.
259
+ pi.on("tool_result", async (event) => {
260
+ try {
261
+ if (event.toolName === TODO_TOOL_NAME)
262
+ return;
263
+ if (!shouldRemindTodos({ todos, turnsSinceWrite, turnsSinceReminder }))
264
+ return;
265
+ turnsSinceReminder = 0;
266
+ return {
267
+ content: [
268
+ ...event.content,
269
+ { type: "text", text: `\n\n${formatTodoReminder(todos)}` },
270
+ ],
271
+ };
272
+ }
273
+ catch {
274
+ return;
275
+ }
276
+ });
203
277
  pi.registerTool(makeTodoTool(() => todos, (next) => {
204
278
  todos = next;
279
+ turnsSinceWrite = 0;
205
280
  }));
206
281
  pi.registerCommand("todos", {
207
282
  description: "Show the agent's current task list for this session.",
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Ultra mode (/ultra) — an explicit, session-scoped dial for aggressive
3
+ * multi-agent orchestration ("the diamond": fan out → reduce → synthesize).
4
+ *
5
+ * Off by default so the trial-default behavior is unchanged; toggling on swaps
6
+ * the driver's delegation paragraph for the diamond directive (branding.ts's
7
+ * YAGNI_IDENTITY_ULTRA) and widens the subagent tool's per-call fan-out
8
+ * ceiling (subagents.ts). The two halves take effect at different moments:
9
+ * the fan-out ceiling is probed live on every subagent call, but the identity
10
+ * is read in index.ts's before_agent_start handler, which pi fires only when
11
+ * a NEW user prompt is submitted — a toggle mid-run leaves the running task on
12
+ * its existing instructions until the next message (the handler notifies when
13
+ * that is the case). Ultra is a prompt + ceiling change only: it never touches
14
+ * the permission mode, the model tier, or the /go pipeline.
15
+ */
16
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
+ export interface UltraHolder {
18
+ get(): boolean;
19
+ set(on: boolean): void;
20
+ }
21
+ export declare function createUltraHolder(initial?: boolean): UltraHolder;
22
+ /**
23
+ * Wire the /ultra command onto a shared holder. No argument toggles; `on` /
24
+ * `off` set explicitly; `status` reports without changing anything.
25
+ */
26
+ export declare function registerUltraCommand(pi: ExtensionAPI, holder: UltraHolder): void;
27
+ //# sourceMappingURL=ultra.d.ts.map
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Ultra mode (/ultra) — an explicit, session-scoped dial for aggressive
3
+ * multi-agent orchestration ("the diamond": fan out → reduce → synthesize).
4
+ *
5
+ * Off by default so the trial-default behavior is unchanged; toggling on swaps
6
+ * the driver's delegation paragraph for the diamond directive (branding.ts's
7
+ * YAGNI_IDENTITY_ULTRA) and widens the subagent tool's per-call fan-out
8
+ * ceiling (subagents.ts). The two halves take effect at different moments:
9
+ * the fan-out ceiling is probed live on every subagent call, but the identity
10
+ * is read in index.ts's before_agent_start handler, which pi fires only when
11
+ * a NEW user prompt is submitted — a toggle mid-run leaves the running task on
12
+ * its existing instructions until the next message (the handler notifies when
13
+ * that is the case). Ultra is a prompt + ceiling change only: it never touches
14
+ * the permission mode, the model tier, or the /go pipeline.
15
+ */
16
+ export function createUltraHolder(initial = false) {
17
+ let on = initial;
18
+ return {
19
+ get: () => on,
20
+ set: (v) => {
21
+ on = v;
22
+ },
23
+ };
24
+ }
25
+ /** Status chip shown while ultra is on (same footer surface as the /mode chip). */
26
+ const ULTRA_STATUS = "◆ ultra";
27
+ const ULTRA_ON_COPY = "Ultra mode ON: meaningful work fans out to parallel subagents, adversarial " +
28
+ "verification agents try to break the result, then the agent synthesizes. " +
29
+ "Expect more subagent spend per task.";
30
+ const ULTRA_OFF_COPY = "Ultra mode OFF: back to delegate-when-useful.";
31
+ /**
32
+ * Appended when the toggle lands mid-run: the identity swap only applies when
33
+ * the next prompt is submitted (see the module docblock), so without this note
34
+ * the chip flips while the running task visibly keeps its old behavior — which
35
+ * reads as ultra mode being broken.
36
+ */
37
+ const MID_RUN_NOTE = " The task currently running keeps its existing instructions; the change takes full effect on your next message.";
38
+ /**
39
+ * Wire the /ultra command onto a shared holder. No argument toggles; `on` /
40
+ * `off` set explicitly; `status` reports without changing anything.
41
+ */
42
+ export function registerUltraCommand(pi, holder) {
43
+ pi.registerCommand("ultra", {
44
+ description: "Toggle ultra mode: aggressive fan-out/verify/synthesize orchestration. /ultra on | off | status.",
45
+ handler: async (args, ctx) => {
46
+ const notify = (m, t) => {
47
+ if (ctx.hasUI)
48
+ ctx.ui.notify(m, t);
49
+ };
50
+ const arg = args.trim().toLowerCase();
51
+ if (arg && arg !== "on" && arg !== "off" && arg !== "status") {
52
+ notify(`Unknown argument "${arg}". Use /ultra, /ultra on, /ultra off, or /ultra status.`, "warning");
53
+ return;
54
+ }
55
+ if (arg === "status") {
56
+ notify(holder.get() ? ULTRA_ON_COPY : ULTRA_OFF_COPY, "info");
57
+ return;
58
+ }
59
+ const next = arg === "on" ? true : arg === "off" ? false : !holder.get();
60
+ holder.set(next);
61
+ try {
62
+ if (ctx.hasUI)
63
+ ctx.ui.setStatus?.("yagni-ultra", next ? ULTRA_STATUS : undefined);
64
+ }
65
+ catch {
66
+ // The chip is chrome; never let it break /ultra.
67
+ }
68
+ // Guarded probe: test fakes (and any minimal harness ctx) may not carry
69
+ // isIdle, and its absence must read as idle, never as busy.
70
+ const midRun = typeof ctx.isIdle === "function" && !ctx.isIdle();
71
+ const copy = next ? ULTRA_ON_COPY : ULTRA_OFF_COPY;
72
+ notify(midRun ? copy + MID_RUN_NOTE : copy, "info");
73
+ },
74
+ });
75
+ }
76
+ //# sourceMappingURL=ultra.js.map