@yagni-app/code 0.2.1 → 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/cli.d.ts +30 -0
- package/dist/cli.js +147 -3
- package/dist/connectClaudeCode.d.ts +77 -0
- package/dist/connectClaudeCode.js +228 -0
- package/dist/connectCodex.d.ts +75 -0
- package/dist/connectCodex.js +201 -0
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +1 -1
- package/dist/extension/advisor.d.ts +4 -4
- package/dist/extension/advisor.js +6 -7
- package/dist/extension/approvedPrefixes.d.ts +103 -0
- package/dist/extension/approvedPrefixes.js +282 -0
- package/dist/extension/askAdvisorTool.d.ts +20 -5
- package/dist/extension/askAdvisorTool.js +126 -20
- package/dist/extension/askYagniTool.d.ts +23 -0
- package/dist/extension/askYagniTool.js +91 -2
- package/dist/extension/branding.d.ts +34 -3
- package/dist/extension/branding.js +114 -13
- package/dist/extension/chipEditor.d.ts +30 -9
- package/dist/extension/chipEditor.js +173 -59
- package/dist/extension/claudeRules.d.ts +0 -2
- package/dist/extension/claudeRules.js +0 -8
- package/dist/extension/cmux/dispatcher.d.ts +25 -0
- package/dist/extension/cmux/dispatcher.js +266 -0
- package/dist/extension/cmux/hooks.d.ts +12 -0
- package/dist/extension/cmux/hooks.js +192 -0
- package/dist/extension/cmux/index.d.ts +3 -0
- package/dist/extension/cmux/index.js +155 -0
- package/dist/extension/cmux/naming.d.ts +5 -0
- package/dist/extension/cmux/naming.js +23 -0
- package/dist/extension/cmux/state.d.ts +33 -0
- package/dist/extension/cmux/state.js +142 -0
- package/dist/extension/config.d.ts +44 -1
- package/dist/extension/config.js +37 -4
- package/dist/extension/costHud.d.ts +16 -22
- package/dist/extension/costHud.js +8 -47
- package/dist/extension/crashReport.js +1 -3
- package/dist/extension/execPolicy.d.ts +135 -0
- package/dist/extension/execPolicy.js +936 -0
- package/dist/extension/flywheel.d.ts +44 -0
- package/dist/extension/flywheel.js +53 -0
- package/dist/extension/footer.d.ts +118 -0
- package/dist/extension/footer.js +308 -0
- package/dist/extension/guardian.d.ts +139 -0
- package/dist/extension/guardian.js +237 -0
- package/dist/extension/index.d.ts +35 -7
- package/dist/extension/index.js +338 -33
- package/dist/extension/mineBeat.d.ts +95 -0
- package/dist/extension/mineBeat.js +193 -0
- package/dist/extension/permission.d.ts +124 -10
- package/dist/extension/permission.js +603 -52
- package/dist/extension/pipeline/childRegistry.d.ts +41 -0
- package/dist/extension/pipeline/childRegistry.js +118 -0
- package/dist/extension/pipeline/finish.js +5 -1
- package/dist/extension/pipeline/goCommand.d.ts +1 -1
- package/dist/extension/pipeline/goCommand.js +40 -9
- package/dist/extension/pipeline/goStatusCommands.d.ts +10 -0
- package/dist/extension/pipeline/goStatusCommands.js +61 -1
- package/dist/extension/pipeline/personas.js +26 -1
- package/dist/extension/pipeline/resilience.d.ts +2 -1
- package/dist/extension/pipeline/resilience.js +21 -2
- package/dist/extension/pipeline/runRegistry.d.ts +23 -1
- package/dist/extension/pipeline/runRegistry.js +57 -1
- package/dist/extension/pipeline/runner.js +4 -0
- package/dist/extension/pipeline/verify.d.ts +4 -0
- package/dist/extension/pipeline/verify.js +48 -26
- package/dist/extension/recordDecisionTool.d.ts +8 -0
- package/dist/extension/recordDecisionTool.js +24 -0
- package/dist/extension/redact.d.ts +20 -0
- package/dist/extension/redact.js +64 -0
- package/dist/extension/rerouteNotice.d.ts +3 -4
- package/dist/extension/rerouteNotice.js +20 -11
- package/dist/extension/subagentRender.d.ts +129 -0
- package/dist/extension/subagentRender.js +441 -0
- package/dist/extension/subagents.d.ts +11 -8
- package/dist/extension/subagents.js +162 -37
- package/dist/extension/ticketTools.d.ts +37 -0
- package/dist/extension/ticketTools.js +117 -0
- package/dist/extension/todos.d.ts +28 -1
- package/dist/extension/todos.js +76 -1
- package/dist/extension/tokenProvider.js +46 -5
- package/dist/extension/ultra.d.ts +27 -0
- package/dist/extension/ultra.js +76 -0
- package/dist/launch.d.ts +7 -0
- package/dist/launch.js +24 -12
- package/dist/login.d.ts +4 -2
- package/dist/login.js +19 -4
- package/dist/padding.d.ts +22 -0
- package/dist/padding.js +25 -0
- package/dist/promptEnrichment.d.ts +40 -0
- package/dist/promptEnrichment.js +85 -0
- package/dist/signalForward.d.ts +60 -0
- package/dist/signalForward.js +130 -0
- package/dist/token.d.ts +25 -0
- package/dist/token.js +45 -0
- package/package.json +6 -5
- package/dist/extension/boostCommand.d.ts +0 -144
- package/dist/extension/boostCommand.js +0 -263
|
@@ -23,7 +23,10 @@ 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";
|
|
29
|
+
import { applyChildEvent, finalizeTask, formatWorkingMessage, newTaskProgress, progressSummaryText, renderSubagentCall, renderSubagentResult, } from "./subagentRender.js";
|
|
27
30
|
/**
|
|
28
31
|
* YAG-471 attribution: the `x-yagni-caller` prefix for a subagent invocation.
|
|
29
32
|
* The sanitized agent name is capped so the WHOLE label (prefix + name) stays
|
|
@@ -33,6 +36,8 @@ const SUBAGENT_CALLER_PREFIX = "subagent:";
|
|
|
33
36
|
export const SUBAGENT_TOOL_NAME = "subagent";
|
|
34
37
|
export const GENERAL_AGENT_NAME = "general";
|
|
35
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;
|
|
36
41
|
/**
|
|
37
42
|
* The default tool surface a subagent gets when its definition declares none:
|
|
38
43
|
* the full working set plus grounded answers, mirroring what a Claude Code
|
|
@@ -75,6 +80,97 @@ const GENERAL_AGENT = {
|
|
|
75
80
|
body: GENERAL_BODY,
|
|
76
81
|
source: "builtin",
|
|
77
82
|
};
|
|
83
|
+
const SEARCHER_BODY = `You are a repo scout. Your job is wide, mechanical reconnaissance:
|
|
84
|
+
find files, map structure, trace usages, and summarize what is there. You do
|
|
85
|
+
not write code and you do not run commands; you read and report.
|
|
86
|
+
|
|
87
|
+
You are grounded in how THIS company works: call ask_yagni before inferring a
|
|
88
|
+
convention, an ownership rule, or anything organization-specific.
|
|
89
|
+
|
|
90
|
+
Your final message is your report back to the driving agent, which has NOT
|
|
91
|
+
seen what you read. Make it compressed and complete: exact file paths, the
|
|
92
|
+
key excerpts, and a one-paragraph map of how the pieces relate. Say what you
|
|
93
|
+
did NOT find as plainly as what you found.`;
|
|
94
|
+
/** Wide search and repo mapping on the cheapest tier: read-only by
|
|
95
|
+
* construction, so a wrong answer costs a re-ask, never a bad edit. */
|
|
96
|
+
const SEARCHER_AGENT = {
|
|
97
|
+
name: "searcher",
|
|
98
|
+
description: "Fast repo reconnaissance: wide searches, structure mapping, usage tracing, " +
|
|
99
|
+
"summarizing files. Read-only. Use for any broad look-around you would " +
|
|
100
|
+
"otherwise do with a chain of grep/read calls.",
|
|
101
|
+
model: "efficient",
|
|
102
|
+
tools: ["read", "grep", "find", "ls", "ask_yagni"],
|
|
103
|
+
body: SEARCHER_BODY,
|
|
104
|
+
source: "builtin",
|
|
105
|
+
};
|
|
106
|
+
const IMPLEMENTER_BODY = `You are a mechanical implementer. You execute a
|
|
107
|
+
well-specified change: apply an edit across files, fix a failing test, rename
|
|
108
|
+
carefully, wire a defined seam. The judgment calls were made before you were
|
|
109
|
+
spawned; if the task turns out to require one, STOP and report the fork in
|
|
110
|
+
your final message instead of guessing.
|
|
111
|
+
|
|
112
|
+
You are grounded in how THIS company works: call ask_yagni before inferring a
|
|
113
|
+
convention, an ownership rule, or anything organization-specific.
|
|
114
|
+
|
|
115
|
+
Your final message is your report back to the driving agent, which has NOT
|
|
116
|
+
seen what you did. List every file you touched, what changed in each, the
|
|
117
|
+
commands you ran with their outcomes, and anything you deliberately left
|
|
118
|
+
undone.`;
|
|
119
|
+
/** Mechanical multi-file execution on the mid tier: the task arrives fully
|
|
120
|
+
* specified, so the premium tiers' judgment is not being paid for. */
|
|
121
|
+
const IMPLEMENTER_AGENT = {
|
|
122
|
+
name: "implementer",
|
|
123
|
+
description: "Mechanical execution of a fully-specified change: multi-file edits, " +
|
|
124
|
+
"test-fix grinds, careful renames. Spawn it with the decision already " +
|
|
125
|
+
"made; it stops and reports rather than improvising.",
|
|
126
|
+
model: "standard",
|
|
127
|
+
body: IMPLEMENTER_BODY,
|
|
128
|
+
source: "builtin",
|
|
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
|
+
};
|
|
78
174
|
// Concrete tiers a subagent can actually run on. `balanced` is deliberately NOT
|
|
79
175
|
// a member here even though it is a member of `ModelTier`: a subagent needs
|
|
80
176
|
// ONE model for its whole run, and balanced is a session-level routing policy,
|
|
@@ -162,7 +258,7 @@ function loadAgentsFromDir(dir, source) {
|
|
|
162
258
|
export function discoverSubagents(deps) {
|
|
163
259
|
const home = deps.homeDir ?? homedir();
|
|
164
260
|
const layers = [
|
|
165
|
-
[GENERAL_AGENT],
|
|
261
|
+
[GENERAL_AGENT, SEARCHER_AGENT, IMPLEMENTER_AGENT, VERIFICATION_AGENT],
|
|
166
262
|
...pluginAgentDirs(deps.env ?? process.env).map((dir) => loadAgentsFromDir(dir, "plugin")),
|
|
167
263
|
loadAgentsFromDir(join(home, ".claude", "agents"), "user-claude"),
|
|
168
264
|
loadAgentsFromDir(join(deps.cwd, ".pi", "agents"), "project-pi"),
|
|
@@ -213,11 +309,17 @@ const parameters = Type.Object({
|
|
|
213
309
|
task: Type.String(),
|
|
214
310
|
agent: Type.Optional(Type.String()),
|
|
215
311
|
}), {
|
|
216
|
-
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.`,
|
|
217
313
|
})),
|
|
218
314
|
});
|
|
219
315
|
export function makeSubagentTool(deps = {}) {
|
|
220
|
-
|
|
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);
|
|
221
323
|
const discover = deps.discover ?? discoverSubagents;
|
|
222
324
|
return {
|
|
223
325
|
name: SUBAGENT_TOOL_NAME,
|
|
@@ -229,6 +331,8 @@ export function makeSubagentTool(deps = {}) {
|
|
|
229
331
|
"(list them with /agents); omit `agent` for the general-purpose one.",
|
|
230
332
|
promptSnippet: "subagent: delegate a self-contained task (or parallel tasks) to a fresh-context agent; returns its report.",
|
|
231
333
|
parameters,
|
|
334
|
+
renderCall: renderSubagentCall,
|
|
335
|
+
renderResult: renderSubagentResult,
|
|
232
336
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
233
337
|
const fail = (text) => ({
|
|
234
338
|
content: [{ type: "text", text }],
|
|
@@ -243,8 +347,9 @@ export function makeSubagentTool(deps = {}) {
|
|
|
243
347
|
if (requested.length === 0) {
|
|
244
348
|
return fail("Error: pass `task` (or a `tasks` array) describing what to do.");
|
|
245
349
|
}
|
|
246
|
-
|
|
247
|
-
|
|
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.`);
|
|
248
353
|
}
|
|
249
354
|
const cwd = ctx?.cwd ?? process.cwd();
|
|
250
355
|
const agents = discover({ cwd, homeDir: deps.homeDir });
|
|
@@ -257,31 +362,56 @@ export function makeSubagentTool(deps = {}) {
|
|
|
257
362
|
}
|
|
258
363
|
resolved.push({ def, task: req.task });
|
|
259
364
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
365
|
+
// Live progress: one folded record per task rides every partial update's
|
|
366
|
+
// `details` (rendered by renderSubagentResult), the plain-text summary
|
|
367
|
+
// rides `content` (pi's fallback renderer and headless consumers), and
|
|
368
|
+
// the harness "Working…" line mirrors the aggregate while children run.
|
|
369
|
+
const progresses = resolved.map(({ def, task }) => newTaskProgress(def.name, task, Date.now()));
|
|
370
|
+
const ui = ctx?.hasUI ? ctx.ui : undefined;
|
|
371
|
+
let lastWorking;
|
|
372
|
+
const emit = () => {
|
|
373
|
+
const now = Date.now();
|
|
374
|
+
onUpdate?.({
|
|
375
|
+
content: [{ type: "text", text: progressSummaryText(progresses, now) }],
|
|
376
|
+
details: {
|
|
377
|
+
tasks: progresses.map((p) => ({ ...p, actions: [...p.actions], usage: { ...p.usage } })),
|
|
267
378
|
},
|
|
268
|
-
],
|
|
269
|
-
details: {},
|
|
270
|
-
});
|
|
271
|
-
const outcomes = await Promise.all(resolved.map(async ({ def, task }) => {
|
|
272
|
-
const { stage, ctx: stageCtx } = buildSubagentStage(def, task);
|
|
273
|
-
const result = await run(stage, stageCtx, {
|
|
274
|
-
cwd,
|
|
275
|
-
signal,
|
|
276
|
-
personaBody: () => def.body,
|
|
277
|
-
// YAG-471: attribute this child's completions to the specific
|
|
278
|
-
// subagent, not the generic /go stage label the runner would
|
|
279
|
-
// otherwise derive from stage.id ("implement", reused as the
|
|
280
|
-
// synthetic subagent stage — see buildSubagentStage).
|
|
281
|
-
callerLabel: `${SUBAGENT_CALLER_PREFIX}${sanitizeCallerSegment(def.name, 64 - SUBAGENT_CALLER_PREFIX.length)}`,
|
|
282
379
|
});
|
|
283
|
-
|
|
284
|
-
|
|
380
|
+
const working = formatWorkingMessage(progresses, now);
|
|
381
|
+
if (ui && working !== lastWorking) {
|
|
382
|
+
lastWorking = working;
|
|
383
|
+
ui.setWorkingMessage?.(working);
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
emit();
|
|
387
|
+
let outcomes;
|
|
388
|
+
try {
|
|
389
|
+
outcomes = await Promise.all(resolved.map(async ({ def, task }, index) => {
|
|
390
|
+
const progress = progresses[index];
|
|
391
|
+
const { stage, ctx: stageCtx } = buildSubagentStage(def, task);
|
|
392
|
+
const result = await run(stage, stageCtx, {
|
|
393
|
+
cwd,
|
|
394
|
+
signal,
|
|
395
|
+
personaBody: () => def.body,
|
|
396
|
+
// YAG-471: attribute this child's completions to the specific
|
|
397
|
+
// subagent, not the generic /go stage label the runner would
|
|
398
|
+
// otherwise derive from stage.id ("implement", reused as the
|
|
399
|
+
// synthetic subagent stage — see buildSubagentStage).
|
|
400
|
+
callerLabel: `${SUBAGENT_CALLER_PREFIX}${sanitizeCallerSegment(def.name, 64 - SUBAGENT_CALLER_PREFIX.length)}`,
|
|
401
|
+
onEvent: (ev) => {
|
|
402
|
+
if (applyChildEvent(progress, ev))
|
|
403
|
+
emit();
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
finalizeTask(progress, result, Date.now());
|
|
407
|
+
emit();
|
|
408
|
+
return { agent: def.name, task, result };
|
|
409
|
+
}));
|
|
410
|
+
}
|
|
411
|
+
finally {
|
|
412
|
+
// Restore the default "Working…" text whether we resolved or threw.
|
|
413
|
+
ui?.setWorkingMessage?.();
|
|
414
|
+
}
|
|
285
415
|
const allFailed = outcomes.every((o) => o.result.exitCode !== 0);
|
|
286
416
|
const sections = outcomes.map((o) => {
|
|
287
417
|
const output = o.result.finalOutput.trim();
|
|
@@ -295,15 +425,10 @@ export function makeSubagentTool(deps = {}) {
|
|
|
295
425
|
});
|
|
296
426
|
return {
|
|
297
427
|
content: [{ type: "text", text: sections.join("\n\n") }],
|
|
298
|
-
details:
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
exitCode: o.result.exitCode,
|
|
303
|
-
usage: o.result.usage,
|
|
304
|
-
toolCalls: o.result.toolCalls,
|
|
305
|
-
})),
|
|
306
|
-
},
|
|
428
|
+
// The folded progress records ARE the final details: a superset of the
|
|
429
|
+
// old {agent, task, exitCode, usage, toolCalls} shape, plus the action
|
|
430
|
+
// log and report that renderSubagentResult paints.
|
|
431
|
+
details: { tasks: progresses },
|
|
307
432
|
...(allFailed ? { isError: true } : {}),
|
|
308
433
|
};
|
|
309
434
|
},
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
/**
|
|
4
|
+
* Ticket write-back tools (spec 2026-08-09): file a ticket, move a ticket.
|
|
5
|
+
*
|
|
6
|
+
* Both are EXPLICIT user-intent actions — the guidelines pin the agent to
|
|
7
|
+
* calling them only when the developer asked. Writes execute server-side as
|
|
8
|
+
* the developer's own tracker account (per-user write identity); when no
|
|
9
|
+
* personal connection exists the backend answers 412 `connect_required` and
|
|
10
|
+
* the tool renders the one-time connect prompt instead of failing opaquely.
|
|
11
|
+
*/
|
|
12
|
+
export interface MakeTicketToolOptions {
|
|
13
|
+
baseUrl: string;
|
|
14
|
+
getToken: () => string | undefined;
|
|
15
|
+
fetchImpl?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
declare const fileTicketParams: Type.TObject<{
|
|
18
|
+
title: Type.TString;
|
|
19
|
+
description: Type.TOptional<Type.TString>;
|
|
20
|
+
tracker: Type.TOptional<Type.TUnion<[Type.TLiteral<"jira">, Type.TLiteral<"linear">]>>;
|
|
21
|
+
target_key: Type.TOptional<Type.TString>;
|
|
22
|
+
}>;
|
|
23
|
+
export declare function makeFileTicketTool(opts: MakeTicketToolOptions): ToolDefinition<typeof fileTicketParams, {
|
|
24
|
+
identifier?: string;
|
|
25
|
+
url?: string | null;
|
|
26
|
+
}>;
|
|
27
|
+
declare const updateTicketStatusParams: Type.TObject<{
|
|
28
|
+
ref: Type.TString;
|
|
29
|
+
status: Type.TString;
|
|
30
|
+
tracker: Type.TOptional<Type.TUnion<[Type.TLiteral<"jira">, Type.TLiteral<"linear">]>>;
|
|
31
|
+
}>;
|
|
32
|
+
export declare function makeUpdateTicketStatusTool(opts: MakeTicketToolOptions): ToolDefinition<typeof updateTicketStatusParams, {
|
|
33
|
+
identifier?: string;
|
|
34
|
+
state?: string;
|
|
35
|
+
}>;
|
|
36
|
+
export {};
|
|
37
|
+
//# sourceMappingURL=ticketTools.d.ts.map
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { friendlyFetchError, METERED_POST_FETCH_POLICY, resilientFetch, } from "./resilientFetch.js";
|
|
3
|
+
/** Render backend problem responses (412/404/422) as agent-relayable text. */
|
|
4
|
+
async function renderProblem(toolName, res) {
|
|
5
|
+
let body = null;
|
|
6
|
+
try {
|
|
7
|
+
body = (await res.clone().json());
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
body = null;
|
|
11
|
+
}
|
|
12
|
+
if (res.status === 412 && body?.error === "connect_required") {
|
|
13
|
+
return [
|
|
14
|
+
`A personal ${body.service ?? "tracker"} connection is needed to write as you.`,
|
|
15
|
+
`Connect here (one time): ${body.connect_url ?? "(ask your admin for the connect link)"}`,
|
|
16
|
+
"Then ask me again and I'll retry.",
|
|
17
|
+
].join("\n");
|
|
18
|
+
}
|
|
19
|
+
if ((res.status === 404 || res.status === 422) && body?.error) {
|
|
20
|
+
const options = body.options?.length
|
|
21
|
+
? `\nAvailable options: ${body.options.join(", ")}`
|
|
22
|
+
: "";
|
|
23
|
+
return `${body.message ?? body.error}${options}`;
|
|
24
|
+
}
|
|
25
|
+
throw new Error(await friendlyFetchError(toolName, res));
|
|
26
|
+
}
|
|
27
|
+
const fileTicketParams = Type.Object({
|
|
28
|
+
title: Type.String(),
|
|
29
|
+
description: Type.Optional(Type.String()),
|
|
30
|
+
tracker: Type.Optional(Type.Union([Type.Literal("jira"), Type.Literal("linear")])),
|
|
31
|
+
target_key: Type.Optional(Type.String()),
|
|
32
|
+
});
|
|
33
|
+
export function makeFileTicketTool(opts) {
|
|
34
|
+
return {
|
|
35
|
+
name: "file_ticket",
|
|
36
|
+
label: "File Ticket",
|
|
37
|
+
description: "File a ticket in the workspace's tracker (Jira or Linear), attributed to the developer's " +
|
|
38
|
+
"own account. ONLY call when the user explicitly asks to file/create a ticket — never " +
|
|
39
|
+
"speculatively, never as a side effect of other work. Pass target_key (Jira project key or " +
|
|
40
|
+
"Linear team key) when the workspace has more than one.",
|
|
41
|
+
promptSnippet: "file_ticket: file a ticket in the workspace tracker as the developer (explicit request only).",
|
|
42
|
+
promptGuidelines: [
|
|
43
|
+
"Call file_ticket ONLY when the user explicitly asks to file, create, or capture a ticket.",
|
|
44
|
+
"If the tool reports a connect prompt or asks for a target/tracker, relay it verbatim and wait for the user.",
|
|
45
|
+
],
|
|
46
|
+
parameters: fileTicketParams,
|
|
47
|
+
async execute(toolCallId, params, signal) {
|
|
48
|
+
const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/tickets`, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: {
|
|
51
|
+
"content-type": "application/json",
|
|
52
|
+
authorization: `Bearer ${opts.getToken() ?? ""}`,
|
|
53
|
+
},
|
|
54
|
+
body: JSON.stringify({ ...params, idempotencyKey: toolCallId }),
|
|
55
|
+
}, { fetchImpl: opts.fetchImpl, signal, policy: METERED_POST_FETCH_POLICY });
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
const text = await renderProblem("file_ticket", res);
|
|
58
|
+
return { content: [{ type: "text", text }], details: {} };
|
|
59
|
+
}
|
|
60
|
+
const data = (await res.json());
|
|
61
|
+
return {
|
|
62
|
+
content: [
|
|
63
|
+
{
|
|
64
|
+
type: "text",
|
|
65
|
+
text: `Filed ${data.identifier}${data.url ? `: ${data.url}` : ""}`,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
details: { identifier: data.identifier, url: data.url },
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const updateTicketStatusParams = Type.Object({
|
|
74
|
+
ref: Type.String(),
|
|
75
|
+
status: Type.String(),
|
|
76
|
+
tracker: Type.Optional(Type.Union([Type.Literal("jira"), Type.Literal("linear")])),
|
|
77
|
+
});
|
|
78
|
+
export function makeUpdateTicketStatusTool(opts) {
|
|
79
|
+
return {
|
|
80
|
+
name: "update_ticket_status",
|
|
81
|
+
label: "Update Ticket Status",
|
|
82
|
+
description: "Move a ticket to a new status by name (e.g. mark YAG-123 In Progress), attributed to the " +
|
|
83
|
+
"developer's own tracker account. ONLY call on the user's explicit request — never as an " +
|
|
84
|
+
"automatic side effect of starting or finishing work.",
|
|
85
|
+
promptSnippet: "update_ticket_status: move a tracker ticket to a named status as the developer (explicit request only).",
|
|
86
|
+
promptGuidelines: [
|
|
87
|
+
"Call update_ticket_status ONLY when the user explicitly asks to move/mark a ticket's status.",
|
|
88
|
+
"If the requested status is not available, relay the offered options and let the user pick.",
|
|
89
|
+
],
|
|
90
|
+
parameters: updateTicketStatusParams,
|
|
91
|
+
async execute(toolCallId, params, signal) {
|
|
92
|
+
const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/tickets/transition`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: {
|
|
95
|
+
"content-type": "application/json",
|
|
96
|
+
authorization: `Bearer ${opts.getToken() ?? ""}`,
|
|
97
|
+
},
|
|
98
|
+
body: JSON.stringify({ ...params, idempotencyKey: toolCallId }),
|
|
99
|
+
}, { fetchImpl: opts.fetchImpl, signal, policy: METERED_POST_FETCH_POLICY });
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
const text = await renderProblem("update_ticket_status", res);
|
|
102
|
+
return { content: [{ type: "text", text }], details: {} };
|
|
103
|
+
}
|
|
104
|
+
const data = (await res.json());
|
|
105
|
+
return {
|
|
106
|
+
content: [
|
|
107
|
+
{
|
|
108
|
+
type: "text",
|
|
109
|
+
text: `${data.identifier} → ${data.state ?? params.status}`,
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
details: { identifier: data.identifier, state: data.state },
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=ticketTools.js.map
|
|
@@ -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
|
|
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
|
package/dist/extension/todos.js
CHANGED
|
@@ -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
|
|
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.",
|
|
@@ -130,7 +130,7 @@ export function makeTokenProvider(deps) {
|
|
|
130
130
|
});
|
|
131
131
|
}, delay);
|
|
132
132
|
}
|
|
133
|
-
function applyRotation(rotation) {
|
|
133
|
+
function applyRotation(rotation, skipPersist = false) {
|
|
134
134
|
token = rotation.token;
|
|
135
135
|
if (rotation.expiresAt)
|
|
136
136
|
expiresAt = rotation.expiresAt;
|
|
@@ -139,13 +139,45 @@ export function makeTokenProvider(deps) {
|
|
|
139
139
|
env.YAGNI_TOKEN = rotation.token;
|
|
140
140
|
if (rotation.expiresAt)
|
|
141
141
|
env.YAGNI_TOKEN_EXPIRES_AT = rotation.expiresAt;
|
|
142
|
+
if (!skipPersist) {
|
|
143
|
+
try {
|
|
144
|
+
persistProfile(rotation);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
/* fail-soft */
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
armProactiveTimer();
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Read the token from the launcher's profile file on disk. Used as a fallback
|
|
154
|
+
* when the server-side refresh fails (the old token is also invalid for
|
|
155
|
+
* /auth/refresh): an external `yagni login` writes a fresh token to the same
|
|
156
|
+
* file, so re-reading it can recover a session that the server refresh cannot.
|
|
157
|
+
* Returns null when the file is missing, unreadable, or carries the same token
|
|
158
|
+
* already in memory.
|
|
159
|
+
*/
|
|
160
|
+
function readTokenFromDisk() {
|
|
161
|
+
const profilePath = env.YAGNI_PROFILE_PATH?.trim();
|
|
162
|
+
if (!profilePath)
|
|
163
|
+
return null;
|
|
142
164
|
try {
|
|
143
|
-
|
|
165
|
+
const parsed = JSON.parse(readFileSync(profilePath, "utf8"));
|
|
166
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
167
|
+
return null;
|
|
168
|
+
const obj = parsed;
|
|
169
|
+
const diskToken = typeof obj.token === "string" ? obj.token : undefined;
|
|
170
|
+
if (!diskToken || diskToken === token)
|
|
171
|
+
return null;
|
|
172
|
+
return {
|
|
173
|
+
token: diskToken,
|
|
174
|
+
expiresAt: typeof obj.expiresAt === "string" ? obj.expiresAt : undefined,
|
|
175
|
+
workspaceId: typeof obj.workspaceId === "string" ? obj.workspaceId : undefined,
|
|
176
|
+
};
|
|
144
177
|
}
|
|
145
178
|
catch {
|
|
146
|
-
|
|
179
|
+
return null;
|
|
147
180
|
}
|
|
148
|
-
armProactiveTimer();
|
|
149
181
|
}
|
|
150
182
|
async function doRefresh() {
|
|
151
183
|
const current = token;
|
|
@@ -161,8 +193,17 @@ export function makeTokenProvider(deps) {
|
|
|
161
193
|
body: "{}",
|
|
162
194
|
signal: AbortSignal.timeout(REFRESH_REQUEST_TIMEOUT_MS),
|
|
163
195
|
});
|
|
164
|
-
if (!res.ok)
|
|
196
|
+
if (!res.ok) {
|
|
197
|
+
// Server refresh failed (the old token is also invalid for /auth/refresh).
|
|
198
|
+
// Fall back to the profile file on disk: an external `yagni login` may
|
|
199
|
+
// have written a fresh token there that this running session hasn't seen.
|
|
200
|
+
const disk = readTokenFromDisk();
|
|
201
|
+
if (disk) {
|
|
202
|
+
applyRotation(disk, true);
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
165
205
|
return false;
|
|
206
|
+
}
|
|
166
207
|
const data = (await res.json());
|
|
167
208
|
if (!data || typeof data.token !== "string" || data.token.length === 0)
|
|
168
209
|
return false;
|