@yagni-app/code 1.0.1 → 1.0.3

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.
@@ -29,6 +29,7 @@ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-age
29
29
  import { type Component } from "@earendil-works/pi-tui";
30
30
  import { Type } from "typebox";
31
31
  import { type AdvisorLimits, type AdvisorStateHandle } from "./advisor.js";
32
+ import type { ChildUsageHandle } from "./childUsage.js";
32
33
  import type { WorkingLineHandle } from "./workingLine.js";
33
34
  import { runStage as defaultRunStage } from "./pipeline/runner.js";
34
35
  import { type PipelineStage } from "./pipeline/types.js";
@@ -56,6 +57,12 @@ export interface MakeAskAdvisorToolOptions {
56
57
  * absent, the tool falls back to ui.setWorkingMessage directly.
57
58
  */
58
59
  workingLine?: WorkingLineHandle;
60
+ /**
61
+ * Session child-usage accumulator (childUsage.ts), so the footer's session
62
+ * totals include advisor consults — the spend the consult-cost line already
63
+ * prints. Absent = not wired (tests/harnesses): no recording.
64
+ */
65
+ childUsage?: ChildUsageHandle;
59
66
  }
60
67
  /**
61
68
  * Assemble the consult brief. The advisor's persona already tells it not to take
@@ -129,7 +129,7 @@ export function makeAskAdvisorTool(opts) {
129
129
  renderShell: "self",
130
130
  renderCall: renderAdvisorCall,
131
131
  renderResult: renderSubagentResult,
132
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
132
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
133
133
  // Read the LIVE session model: pi's picker can change it after this tool
134
134
  // was registered, in both directions.
135
135
  const decision = decideConsult({
@@ -194,6 +194,10 @@ export function makeAskAdvisorTool(opts) {
194
194
  const cost = result.usage?.cost ?? 0;
195
195
  const state = opts.state.record(cost);
196
196
  finalizeTask(progress, result, Date.now());
197
+ // Footer session totals include the consult. Keyed by toolCallId — one
198
+ // consult per call, unique even for parallel consults minted in the same
199
+ // millisecond — so a re-fired finalize for the same call is a no-op.
200
+ opts.childUsage?.record("advisor", toolCallId, result.usage);
197
201
  if (result.exitCode !== 0 && !result.finalOutput.trim()) {
198
202
  // Fail honestly rather than returning an empty recommendation. The
199
203
  // consult still counts: it spawned, and it may well have spent.
@@ -12,7 +12,7 @@ export interface Citation {
12
12
  * AskStanding — mirrored locally by this extension's no-workspace-imports
13
13
  * convention, see costHud.ts).
14
14
  */
15
- export type AskStanding = "confirmed" | "asserted" | "inferred" | "no_position";
15
+ export type AskStanding = "confirmed" | "asserted" | "inferred" | "no_position" | "contested";
16
16
  /** One line the TUI shows above the answer, per standing. */
17
17
  export declare const STANDING_LINES: Record<AskStanding, string>;
18
18
  /** Options for {@link makeAskYagniTool}. */
@@ -10,6 +10,7 @@ export const STANDING_LINES = {
10
10
  asserted: "Grounded in a recorded assumption, not yet verified",
11
11
  inferred: "Inferred from workspace context, not a recorded decision",
12
12
  no_position: "No recorded position in this workspace",
13
+ contested: "Two recorded decisions conflict — escalate to a human",
13
14
  };
14
15
  function standingLine(value) {
15
16
  return typeof value === "string" && value in STANDING_LINES
@@ -141,11 +142,29 @@ export function makeAskYagniTool(opts) {
141
142
  text = `${text}\n\n${suggestion}`;
142
143
  }
143
144
  }
145
+ // P0 (Run 8 review): a contested answer must reach the MODEL, not just
146
+ // the TUI. The backend composes an ordinary answer and only overrides
147
+ // standing client-side, so without this the coding agent can act on one
148
+ // conflicting side without ever seeing the do-not-proceed instruction.
149
+ // The refusal + both positions are appended VERBATIM to tool content.
150
+ if (data.standing === "contested") {
151
+ const lines = [];
152
+ if (data.contestedInstruction)
153
+ lines.push(data.contestedInstruction);
154
+ for (const p of data.contestedPositions ?? []) {
155
+ lines.push(`- (${p.id}) ${p.question}: ${p.decision}`);
156
+ }
157
+ if (lines.length > 0) {
158
+ text = `${data.answer}\n\n${lines.join("\n")}`;
159
+ }
160
+ }
144
161
  return {
145
162
  content: [{ type: "text", text }],
146
163
  details: {
147
164
  citations: data.citations ?? [],
148
165
  ...(data.standing ? { standing: data.standing } : {}),
166
+ ...(data.contestedInstruction ? { contestedInstruction: data.contestedInstruction } : {}),
167
+ ...(data.contestedPositions ? { contestedPositions: data.contestedPositions } : {}),
149
168
  },
150
169
  };
151
170
  },
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Session-scoped child-process usage accounting (YAG-383's honest-local
3
+ * counterpart for the FOOTER).
4
+ *
5
+ * `collectUsage` (footer.ts) reads pi's session entries, which only ever carry
6
+ * the DRIVER's own turns: subagents, advisor consults, Guardian reviews, and
7
+ * every /go stage run in a child process whose usage never lands in those
8
+ * entries. The per-child surfaces (subagent receipts, /go's run-cost note,
9
+ * advisor's consult-cost line) already fold each child's usage from its NDJSON
10
+ * stream — this module is the session-level SUM of those already-held figures,
11
+ * so the footer's totals can include what the receipts already printed.
12
+ *
13
+ * Same shape as `makeAdvisorState` (advisor.ts): a closure-held handle, `read()`
14
+ * snapshots, and an additive `record()`. Tokens ride along with cost so the
15
+ * footer's `↑in ↓out` figures stay honest too. `record()` is idempotent per
16
+ * (source, key): a re-fired completion for the same child must never
17
+ * double-count its spend, so each seam keys by the child's identity (one key
18
+ * per subagent task / consult / review / run attempt).
19
+ */
20
+ /** Usage fields the footer's line-2 stats render. */
21
+ export interface ChildUsageDelta {
22
+ input: number;
23
+ output: number;
24
+ cacheRead: number;
25
+ cacheWrite: number;
26
+ cost: number;
27
+ }
28
+ export interface ChildUsageHandle {
29
+ read(): ChildUsageDelta;
30
+ /**
31
+ * Add one child's usage, deduped by `(source, key)`. A repeated record for
32
+ * the same key is a no-op (re-fire safety), not an overwrite: child spend is
33
+ * cumulative and never revised downward. `delta` may be absent (a degraded
34
+ * or stubbed runner that reported no usage): the key is still consumed and
35
+ * nothing is added — never a throw.
36
+ */
37
+ record(source: string, key: string, delta: Partial<ChildUsageDelta> | undefined): ChildUsageDelta;
38
+ }
39
+ export declare function makeChildUsageState(): ChildUsageHandle;
40
+ //# sourceMappingURL=childUsage.d.ts.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Session-scoped child-process usage accounting (YAG-383's honest-local
3
+ * counterpart for the FOOTER).
4
+ *
5
+ * `collectUsage` (footer.ts) reads pi's session entries, which only ever carry
6
+ * the DRIVER's own turns: subagents, advisor consults, Guardian reviews, and
7
+ * every /go stage run in a child process whose usage never lands in those
8
+ * entries. The per-child surfaces (subagent receipts, /go's run-cost note,
9
+ * advisor's consult-cost line) already fold each child's usage from its NDJSON
10
+ * stream — this module is the session-level SUM of those already-held figures,
11
+ * so the footer's totals can include what the receipts already printed.
12
+ *
13
+ * Same shape as `makeAdvisorState` (advisor.ts): a closure-held handle, `read()`
14
+ * snapshots, and an additive `record()`. Tokens ride along with cost so the
15
+ * footer's `↑in ↓out` figures stay honest too. `record()` is idempotent per
16
+ * (source, key): a re-fired completion for the same child must never
17
+ * double-count its spend, so each seam keys by the child's identity (one key
18
+ * per subagent task / consult / review / run attempt).
19
+ */
20
+ const num = (n) => {
21
+ const v = n ?? 0;
22
+ return Number.isFinite(v) && v > 0 ? v : 0;
23
+ };
24
+ export function makeChildUsageState() {
25
+ const seen = new Set();
26
+ const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
27
+ return {
28
+ read: () => ({ ...totals }),
29
+ record(source, key, delta) {
30
+ const dedupeKey = `${source}\u0000${key}`;
31
+ if (seen.has(dedupeKey))
32
+ return { ...totals };
33
+ seen.add(dedupeKey);
34
+ totals.input += num(delta?.input);
35
+ totals.output += num(delta?.output);
36
+ totals.cacheRead += num(delta?.cacheRead);
37
+ totals.cacheWrite += num(delta?.cacheWrite);
38
+ totals.cost += num(delta?.cost);
39
+ return { ...totals };
40
+ },
41
+ };
42
+ }
43
+ //# sourceMappingURL=childUsage.js.map
@@ -35,6 +35,10 @@ export declare const DIFF_PREVIEW_LINES = 12;
35
35
  export declare const ERROR_TAIL_LINES = 5;
36
36
  /** Output tail lines shown under a still-running shell command. */
37
37
  export declare const PARTIAL_TAIL_LINES = 3;
38
+ /** Left/right margin for self-rendered rows, matching pi's `outputPad` (1) so
39
+ * tool rows align with prose. Self-render rows bypass pi's pad (renderShell
40
+ * "self" → unpadded container), so we reapply it here. */
41
+ export declare const OUTPUT_PAD = 1;
38
42
  declare const BUILTIN_NAMES: readonly ["read", "bash", "edit", "write", "grep", "find", "ls"];
39
43
  type BuiltinName = (typeof BUILTIN_NAMES)[number];
40
44
  export type RowStatus = "running" | "ok" | "error";
@@ -41,6 +41,10 @@ export const PARTIAL_TAIL_LINES = 3;
41
41
  const EXPANDED_MAX_LINES = 1000;
42
42
  /** Title arg preview width. */
43
43
  const ARG_PREVIEW_MAX = 96;
44
+ /** Left/right margin for self-rendered rows, matching pi's `outputPad` (1) so
45
+ * tool rows align with prose. Self-render rows bypass pi's pad (renderShell
46
+ * "self" → unpadded container), so we reapply it here. */
47
+ export const OUTPUT_PAD = 1;
44
48
  const BUILTIN_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls"];
45
49
  const TITLE_BY_NAME = {
46
50
  read: "Read",
@@ -257,7 +261,7 @@ function empty() {
257
261
  return new Container();
258
262
  }
259
263
  function textComponent(lines) {
260
- return lines.length === 0 ? empty() : new Text(lines.join("\n"), 0, 0);
264
+ return lines.length === 0 ? empty() : new Text(lines.join("\n"), OUTPUT_PAD, 0);
261
265
  }
262
266
  function defaultLoadSettings(cwd) {
263
267
  return SettingsManager.create(cwd, getAgentDir());
@@ -332,13 +336,13 @@ export function registerCondensedTools(pi, deps = {}) {
332
336
  const status = slice.isError ? "error" : slice.isPartial ? "running" : "ok";
333
337
  const argsRecord = args;
334
338
  if (slice.expanded || !isQuiet(row)) {
335
- return new Text(formatRowTitle(name, argsRecord, status, theme, slice.cwd), 0, 0);
339
+ return new Text(formatRowTitle(name, argsRecord, status, theme, slice.cwd), OUTPUT_PAD, 0);
336
340
  }
337
341
  if (!row.final) {
338
- return new Text(formatRowTitle(name, argsRecord, "running", theme, slice.cwd), 0, 0);
342
+ return new Text(formatRowTitle(name, argsRecord, "running", theme, slice.cwd), OUTPUT_PAD, 0);
339
343
  }
340
344
  const summary = tracker.summaryFor(row.id);
341
- return summary ? new Text(theme.fg("muted", summary), 0, 0) : empty();
345
+ return summary ? new Text(theme.fg("muted", summary), OUTPUT_PAD, 0) : empty();
342
346
  },
343
347
  renderResult(result, options, theme, context) {
344
348
  const slice = context;
@@ -39,6 +39,7 @@
39
39
  * — useful reference for what data to replicate and how to format it.
40
40
  */
41
41
  import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
42
+ import type { ChildUsageHandle } from "./childUsage.js";
42
43
  import type { ModeHolder, PermissionMode } from "./permission/gate.js";
43
44
  export declare const BRANCH_MAX_WIDTH = 60;
44
45
  export declare function cyclePermissionMode(current: PermissionMode): PermissionMode;
@@ -99,6 +100,17 @@ export declare function renderFooterLines(input: {
99
100
  /** Formatted `+N -M` / `±`, or null when clean — appended to the branch as `[ … ]`. */
100
101
  diff?: string | null;
101
102
  usage: UsageTotals;
103
+ /**
104
+ * Session child-process spend (subagents, advisor consults, Guardian
105
+ * reviews, /go runs) — childUsage.ts's session accumulator. Added into
106
+ * the line-2 stats so the footer's totals include the spend the per-child
107
+ * receipts already print; absent/zero changes nothing.
108
+ */
109
+ childUsage?: {
110
+ input: number;
111
+ output: number;
112
+ cost: number;
113
+ };
102
114
  contextPercent: number | null;
103
115
  statuses: string[];
104
116
  }, theme: Pick<Theme, "fg">, width: number, padX?: number): string[];
@@ -111,7 +123,7 @@ export interface FooterInvalidateHandle {
111
123
  invalidateGit(): void;
112
124
  requestRender(): void;
113
125
  }
114
- export declare function createYagniFooterFactory(ctx: ExtensionContext, modeHolder?: ModeHolder, invalidateHandle?: FooterInvalidateHandle): (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
126
+ export declare function createYagniFooterFactory(ctx: ExtensionContext, modeHolder?: ModeHolder, invalidateHandle?: FooterInvalidateHandle, childUsage?: ChildUsageHandle): (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
115
127
  render(width: number): string[];
116
128
  invalidate(): void;
117
129
  dispose(): void;
@@ -245,13 +245,19 @@ export function renderFooterLines(input, theme, width, padX = 0) {
245
245
  }
246
246
  const line1 = pad + truncateToWidth(line1Parts.join(sep), contentWidth, dim("…"));
247
247
  // Line 2: [mode ·] model · ↑in ↓out $cost · ctx%
248
+ // Child-process spend (subagents, advisor, /go) joins the driver totals so
249
+ // the footer matches the per-child receipts and /cost's session scope.
250
+ const child = input.childUsage;
251
+ const totalIn = input.usage.input + (child?.input ?? 0);
252
+ const totalOut = input.usage.output + (child?.output ?? 0);
253
+ const totalCost = input.usage.cost + (child?.cost ?? 0);
248
254
  const statParts = [];
249
- if (input.usage.input)
250
- statParts.push(`↑${formatTokens(input.usage.input)}`);
251
- if (input.usage.output)
252
- statParts.push(`↓${formatTokens(input.usage.output)}`);
253
- if (input.usage.cost)
254
- statParts.push(`$${input.usage.cost.toFixed(3)}`);
255
+ if (totalIn)
256
+ statParts.push(`↑${formatTokens(totalIn)}`);
257
+ if (totalOut)
258
+ statParts.push(`↓${formatTokens(totalOut)}`);
259
+ if (totalCost)
260
+ statParts.push(`$${totalCost.toFixed(3)}`);
255
261
  const stats = statParts.join(" ");
256
262
  const percentText = input.contextPercent === null ? "?" : `${Math.round(input.contextPercent)}%`;
257
263
  const line2Parts = [];
@@ -272,7 +278,7 @@ export function renderFooterLines(input, theme, width, padX = 0) {
272
278
  }
273
279
  return lines;
274
280
  }
275
- export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle) {
281
+ export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle, childUsage) {
276
282
  return (_tui, theme, footerData) => {
277
283
  let gitCache;
278
284
  let diffStatCache;
@@ -305,6 +311,7 @@ export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle) {
305
311
  mode: modeHolder?.get() ?? null,
306
312
  diff: formatDiffStat(diffStatCache?.get() ?? null),
307
313
  usage: collectUsage(ctx.sessionManager),
314
+ childUsage: childUsage?.read(),
308
315
  contextPercent: ctx.getContextUsage()?.percent ?? null,
309
316
  statuses,
310
317
  }, theme, width, resolveFooterPadX());
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
3
  import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
4
+ import { makeChildUsageState } from "./childUsage.js";
4
5
  import { appendGrant, loadGrants, resolveRepoKey, storagePrefix } from "./permission/approvedPrefixes.js";
5
6
  import { redactCommand } from "./redact.js";
6
7
  import { formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, resolveGuardianLimits, reviewCommand, deriveGuardianTimeoutMs } from "./permission/guardian.js";
@@ -40,12 +41,13 @@ import { registerDecisionCommands } from "./decisions.js";
40
41
  import { makeDecisionCapture } from "./decisionCapture.js";
41
42
  import { registerAmbientRecall } from "./recall.js";
42
43
  import { resilientFetch } from "./resilientFetch.js";
43
- import { installUncaughtExceptionMonitor, makeCrashReporter } from "./crashReport.js";
44
+ import { installUncaughtExceptionMonitor, makeCrashReporter, runningUnderTest } from "./crashReport.js";
44
45
  import { flushSpool as defaultFlushSpool } from "./spool.js";
45
46
  import { makeAuthedFetch, makeTokenProvider } from "./tokenProvider.js";
46
47
  import { attributionHeaders, fetchCatalog as defaultFetchCatalog, fetchContextBrief as defaultFetchContextBrief, getToken, getTokenExpiresAt as defaultGetTokenExpiresAt, getWorkspaceId as defaultGetWorkspaceId, isDriverCaller, resolveBaseUrl, tokenExpiryNotice, } from "./config.js";
47
48
  import { buildYagniProvider } from "./provider.js";
48
49
  import { registerChipEditor } from "./chipEditor.js";
50
+ import { registerSlashCommandFilter } from "./slashCommandFilter.js";
49
51
  import { defaultMineBeatGit, fileMineBeatMarkers, maybeOfferMiningBeat as defaultMaybeOfferMiningBeat, } from "./mineBeat.js";
50
52
  import { makeFlywheelState } from "./flywheel.js";
51
53
  import { registerSilentTurnReminder } from "./silentTurnReminder.js";
@@ -186,7 +188,11 @@ export async function registerYagni(pi, deps = {}) {
186
188
  // hide it forever from a session that switched TO Advanced. One state handle
187
189
  // per session, shared with /advise so they draw on the same cap.
188
190
  const advisorState = makeAdvisorState();
189
- const askAdvisorTool = makeAskAdvisorTool({ state: advisorState, workingLine });
191
+ // Session child-usage accumulator: every child process's spend (subagent
192
+ // tasks, advisor consults, Guardian reviews, /go runs) lands here so the
193
+ // footer's session totals include what the per-child receipts print.
194
+ const childUsage = makeChildUsageState();
195
+ const askAdvisorTool = makeAskAdvisorTool({ state: advisorState, workingLine, childUsage });
190
196
  pi.registerTool(askAdvisorTool);
191
197
  // /advise runs the SAME tool, sharing the state handle, so a manual consult
192
198
  // draws on the same cap rather than opening a side channel around it.
@@ -219,6 +225,11 @@ export async function registerYagni(pi, deps = {}) {
219
225
  // temp path, and registers the input transform that turns chips into image
220
226
  // attachments for the model. No-op outside TUI mode.
221
227
  registerChipEditor(pi);
228
+ // Hide pi's built-in slash commands that contradict YAGNI's single-provider,
229
+ // single-identity model (e.g. /share gist upload, /login <provider>) from
230
+ // slash-discovery. TUI autocomplete only; the desktop palette already
231
+ // excludes built-ins, and this is a no-op in RPC/print modes.
232
+ registerSlashCommandFilter(pi);
222
233
  // The general subagent tool: delegate self-contained tasks (optionally in
223
234
  // parallel) to fresh-context agents defined in .claude/agents / .pi/agents,
224
235
  // riding the /go pipeline's child runner. /agents lists what's available.
@@ -226,7 +237,7 @@ export async function registerYagni(pi, deps = {}) {
226
237
  // driver's delegation identity for the diamond directive (see the
227
238
  // before_agent_start handler below) and widens the tool's fan-out ceiling.
228
239
  const ultraHolder = createUltraHolder();
229
- registerSubagents(pi, { isUltra: () => ultraHolder.get(), workingLine });
240
+ registerSubagents(pi, { isUltra: () => ultraHolder.get(), workingLine, childUsage });
230
241
  registerUltraCommand(pi, ultraHolder);
231
242
  // Shared fetch timeout for the small, interactive display-path reads below
232
243
  // (/cost's spend + headroom, and Task 8's per-run spend for /go's summary):
@@ -236,6 +247,7 @@ export async function registerYagni(pi, deps = {}) {
236
247
  // The grounded multi-agent pipeline entry point: /go <ticket> runs
237
248
  // map → plan → implement → review → fix, each child grounded by inheritance.
238
249
  registerGoCommand(pi, {
250
+ childUsage,
239
251
  // /ultra is one dial for the whole session: the same holder the subagent
240
252
  // tool reads widens the implement diamond's parallel ceiling (4 -> 8) for
241
253
  // the fan and its fix turns. Read per run, so a toggle lands on the next /go.
@@ -326,7 +338,10 @@ export async function registerYagni(pi, deps = {}) {
326
338
  const { event: _ignored, ...fields } = payload;
327
339
  logEvent({
328
340
  source: "guardian",
329
- level: event === "guardian_event_post_failed" ? "error" : "info",
341
+ // `malformed` is a real failure (this is the "unclear verdict" bug the
342
+ // whole capture exists to diagnose) — an error, not routine info. The
343
+ // failed-telemetry event is already an error.
344
+ level: event === "guardian_event_post_failed" || payload.outcome === "malformed" ? "error" : "info",
330
345
  event,
331
346
  sessionId: env.YAGNI_SESSION_ID ?? undefined,
332
347
  fields,
@@ -350,8 +365,45 @@ export async function registerYagni(pi, deps = {}) {
350
365
  guardianLimits,
351
366
  guardianTier,
352
367
  guardianDisabled,
368
+ childUsage,
353
369
  guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
354
- onGuardianReview: (ev) => guardianLogSink(ev),
370
+ onGuardianReview: (ev) => {
371
+ guardianLogSink(ev);
372
+ // A malformed verdict is a real failure on our side that must reach
373
+ // Sentry for EVERY workspace (independent of the opt-in storage tier).
374
+ // Fire-and-forget to the dedicated telemetry endpoint; fail-soft, and
375
+ // suppressed under test/eval so a CI run never phones prod.
376
+ if (ev.outcome === "malformed" && !evalMode && !runningUnderTest(env)) {
377
+ void (async () => {
378
+ try {
379
+ const body = {
380
+ outcome: ev.outcome,
381
+ ...(ev.rawOutput ? { rawOutput: ev.rawOutput } : {}),
382
+ ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
383
+ ...(ev.tier ? { tier: ev.tier } : {}),
384
+ };
385
+ const res = await resilientFetch(`${baseUrl}/api/yagni-code/guardian-error`, {
386
+ method: "POST",
387
+ headers: {
388
+ "content-type": "application/json",
389
+ authorization: `Bearer ${getTokenFn() ?? ""}`,
390
+ ...attributionHeaders(deps.env),
391
+ },
392
+ body: JSON.stringify(body),
393
+ }, {
394
+ fetchImpl: authedFetch,
395
+ policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: GUARDIAN_EVENT_TIMEOUT_MS, jitterRatio: 0 },
396
+ });
397
+ if (!res.ok) {
398
+ guardianLogSink({ event: "guardian_error_post_failed", status: res.status });
399
+ }
400
+ }
401
+ catch {
402
+ guardianLogSink({ event: "guardian_error_post_failed", kind: "network" });
403
+ }
404
+ })();
405
+ }
406
+ },
355
407
  grants: sessionGrants,
356
408
  resolveRepoKey,
357
409
  persistGrant: (grant) => {
@@ -855,7 +907,7 @@ export async function registerYagni(pi, deps = {}) {
855
907
  // context % on line 2, and extension statuses (brand, todos, mode) on
856
908
  // line 3. The factory captures ctx so the footer can read session data
857
909
  // (token stats, context usage) that isn't on the footerData provider.
858
- ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx, modeHolder, footerInvalidateHandle)(tui, theme, footerData));
910
+ ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx, modeHolder, footerInvalidateHandle, childUsage)(tui, theme, footerData));
859
911
  ctx.ui?.onTerminalInput?.((data) => {
860
912
  if (isShiftTab(data)) {
861
913
  modeHolder.set(cyclePermissionMode(modeHolder.get()));
@@ -151,6 +151,12 @@ export interface RegisterPermissionDeps {
151
151
  guardianState?: import("./guardian.js").GuardianStateHandle;
152
152
  /** Guardian limits (timeouts, circuit breaker). Defaults to DEFAULT_GUARDIAN_LIMITS. */
153
153
  guardianLimits?: import("./guardian.js").GuardianLimits;
154
+ /**
155
+ * Session child-usage accumulator (childUsage.ts): each completed Guardian
156
+ * review's spend is recorded so the footer's session totals include it.
157
+ * Absent = not wired: no recording, nothing else changes.
158
+ */
159
+ childUsage?: import("../childUsage.js").ChildUsageHandle;
154
160
  /** Override the Guardian model tier (default: efficient). */
155
161
  guardianTier?: string;
156
162
  /** Injectable Guardian review function (tests pass a stub). */
@@ -366,6 +366,10 @@ export function registerPermissionGate(pi, deps = {}) {
366
366
  const ERROR_ASK_CAP = 3;
367
367
  let errorFallbackAsks = 0;
368
368
  let breakerEscalationOffered = false;
369
+ // Uniquifies each Guardian review's childUsage key: parallel gated tool
370
+ // calls can start their reviews in the same millisecond, and a timestamp
371
+ // alone would silently drop the second review's spend as a "duplicate".
372
+ let reviewSpendSeq = 0;
369
373
  const emitGateEvent = (event) => {
370
374
  if (!deps.onGuardianEvent)
371
375
  return;
@@ -561,13 +565,18 @@ export function registerPermissionGate(pi, deps = {}) {
561
565
  ctx.ui.setStatus?.("yagni-guardian", undefined);
562
566
  }
563
567
  const durationMs = Date.now() - startMs;
564
- const emitDiag = (outcome, rationale) => {
568
+ // Footer session totals include the review's spend. Each review
569
+ // records exactly once, right here, so the key only has to be unique
570
+ // per review — the seq keeps same-millisecond parallel reviews apart.
571
+ deps.childUsage?.record("guardian", `${startMs}#${reviewSpendSeq++}`, { cost: reviewResult.cost });
572
+ const emitDiag = (outcome, rationale, rawOutput) => {
565
573
  if (!deps.onGuardianReview)
566
574
  return;
567
575
  void Promise.resolve(deps.onGuardianReview(buildDiagnosticEvent(outcome, {
568
576
  durationMs,
569
577
  tier: guardianTier,
570
578
  ...(rationale ? { rationale } : {}),
579
+ ...(rawOutput ? { rawOutput } : {}),
571
580
  debug: isDebug(),
572
581
  }))).catch(() => { });
573
582
  };
@@ -734,7 +743,7 @@ export function registerPermissionGate(pi, deps = {}) {
734
743
  }
735
744
  // Guardian failed (timeout/malformed/network/empty/aborted).
736
745
  const error = reviewResult.error ?? "network";
737
- emitDiag(error);
746
+ emitDiag(error, undefined, reviewResult.rawOutput);
738
747
  if (error === "aborted" || ctx?.signal?.aborted) {
739
748
  // The user aborted mid-consult — silent block: no dialog, no
740
749
  // "Guardian unavailable" warning on a turn they deliberately
@@ -106,7 +106,20 @@ export interface ReviewResult {
106
106
  verdict: GuardianVerdict | null;
107
107
  error?: GuardianError;
108
108
  cost: number;
109
+ /**
110
+ * Scrubbed + capped copy of the model output when the verdict failed to
111
+ * parse (`error: "malformed"`). Present so the sink can capture the exact
112
+ * failure shape. Never contains the raw command unredacted: `scrubSecrets`
113
+ * removes secret-shaped values before this is stored.
114
+ */
115
+ rawOutput?: string;
109
116
  }
117
+ /**
118
+ * Cap on the malformed-output capture. The model's unparseable verdict is
119
+ * usually a single short JSON-ish blob; this bounds a pathological output so
120
+ * it cannot balloon a log line or a Sentry payload.
121
+ */
122
+ export declare const GUARDIAN_RAW_OUTPUT_CAP = 2048;
110
123
  export interface ReviewCommandDeps {
111
124
  runStage?: typeof defaultRunStage;
112
125
  cwd: string;
@@ -143,6 +156,12 @@ export interface GuardianDiagnosticEvent {
143
156
  commandHash?: string;
144
157
  /** Debug-only: the Guardian's rationale. */
145
158
  rationale?: string;
159
+ /**
160
+ * Scrubbed + capped copy of the unparseable model output, present only for
161
+ * `outcome: "malformed"`. Always-on (NOT debug-gated): it is already
162
+ * `scrubSecrets`-redacted and size-capped at the source.
163
+ */
164
+ rawOutput?: string;
146
165
  }
147
166
  /**
148
167
  * Create a sanitized diagnostic event. Never includes the raw command text
@@ -153,6 +172,7 @@ export declare function buildDiagnosticEvent(outcome: GuardianOutcome | Guardian
153
172
  tier?: string;
154
173
  rationale?: string;
155
174
  commandHash?: string;
175
+ rawOutput?: string;
156
176
  debug?: boolean;
157
177
  }): GuardianDiagnosticEvent;
158
178
  //# sourceMappingURL=guardian.d.ts.map
@@ -28,6 +28,7 @@
28
28
  * alternating deny/ask.
29
29
  */
30
30
  import { runStage as defaultRunStage } from "../pipeline/runner.js";
31
+ import { scrubSecrets } from "../pipeline/scrubSecrets.js";
31
32
  export const DEFAULT_GUARDIAN_LIMITS = {
32
33
  maxReviews: 120,
33
34
  maxConsecutiveDenials: 3,
@@ -161,6 +162,12 @@ export function formatGuardianSubtotal(state, limits) {
161
162
  const plural = state.reviews === 1 ? "review" : "reviews";
162
163
  return `Guardian: ${state.reviews} ${plural}.`;
163
164
  }
165
+ /**
166
+ * Cap on the malformed-output capture. The model's unparseable verdict is
167
+ * usually a single short JSON-ish blob; this bounds a pathological output so
168
+ * it cannot balloon a log line or a Sentry payload.
169
+ */
170
+ export const GUARDIAN_RAW_OUTPUT_CAP = 2048;
164
171
  /**
165
172
  * The synthetic stage a Guardian consult runs as. Borrows the `plan` StageId
166
173
  * (same pattern as the advisor) so it doesn't ripple into feed/reducers. The
@@ -231,7 +238,14 @@ export async function reviewCommand(command, deps) {
231
238
  }
232
239
  const verdict = parseVerdict(output);
233
240
  if (!verdict) {
234
- return { verdict: null, error: "malformed", cost };
241
+ // Scrubbed + capped so the local sink and Sentry can see the exact
242
+ // failure shape without carrying a raw command or a secret it echoed.
243
+ return {
244
+ verdict: null,
245
+ error: "malformed",
246
+ cost,
247
+ rawOutput: scrubSecrets(output).slice(0, GUARDIAN_RAW_OUTPUT_CAP),
248
+ };
235
249
  }
236
250
  return { verdict, cost };
237
251
  }
@@ -261,6 +275,7 @@ export function buildDiagnosticEvent(outcome, opts) {
261
275
  outcome,
262
276
  ...(opts.durationMs !== undefined ? { durationMs: opts.durationMs } : {}),
263
277
  ...(opts.tier !== undefined ? { tier: opts.tier } : {}),
278
+ ...(opts.rawOutput !== undefined ? { rawOutput: opts.rawOutput } : {}),
264
279
  };
265
280
  if (opts.debug) {
266
281
  if (opts.rationale)
@@ -110,6 +110,14 @@ export interface RegisterGoDeps {
110
110
  * cares injects its own fake.
111
111
  */
112
112
  fetchRunSpend?: (runId: string, signal?: AbortSignal) => Promise<SpendResponse | null>;
113
+ /**
114
+ * Session child-usage accumulator (childUsage.ts). Each attempt's newly-run
115
+ * stage usage is recorded (keyed by runId + attempt start, because a resumed
116
+ * attempt's stages are new spend, never a replay) so the footer's session
117
+ * totals include /go spend — the same figure `runCostNote` already prints.
118
+ * Absent = not wired.
119
+ */
120
+ childUsage?: import("../childUsage.js").ChildUsageHandle;
113
121
  /** Injectable fs existence check (worktree adoption on resume). */
114
122
  exists?: (path: string) => boolean;
115
123
  /** Clock seam for registry rows + staleness. */
@@ -969,6 +969,14 @@ export function registerGoCommand(pi, deps = {}) {
969
969
  if (result.stopReason === "clean" && runSignal.aborted) {
970
970
  result = { ...result, stopReason: "aborted" };
971
971
  }
972
+ // Footer session totals include this run's spend. Keyed per ATTEMPT
973
+ // (runId + this invocation's start), not per runId: `result.stages`
974
+ // only ever holds the stages THIS attempt actually ran (a resume's
975
+ // replayed spend lives in the checkpoint's priorUsage, never here),
976
+ // so an abort → same-session resume must record both attempts — a
977
+ // runId-only key would silently drop the resume's new spend. Partial
978
+ // spend on an aborted attempt is still real spend worth counting.
979
+ deps.childUsage?.record("go", `${runId}@${startedAt}`, aggregateRunUsage(result.stages, result.rounds));
972
980
  // FINISH stage (spec §3c): ONLY on a clean stop. Commit the run's work
973
981
  // (worktree always; --here only over a clean pre-run baseline) with the
974
982
  // provenance trailer, and push + PR when --pr asked for it. Iron
@@ -0,0 +1,30 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ /**
3
+ * Slash-command filtering for the CLI.
4
+ *
5
+ * pi ships its own built-in slash commands (see `BUILTIN_SLASH_COMMANDS` in
6
+ * pi's `core/slash-commands.js`) and bakes them directly into its interactive
7
+ * autocomplete provider (`createBaseAutocompleteProvider`). Most are
8
+ * pi-generic and harmless (`/new`, `/compact`, `/settings`), but a subset
9
+ * contradict YAGNI's single-provider, single-identity model — or leak pi
10
+ * features YAGNI deliberately does not surface — and only confuse a YAGNI Code
11
+ * user. We hide those from slash-discovery.
12
+ *
13
+ * Scope note: this filters DISCOVERY only. pi dispatches built-in commands in
14
+ * its own private `onSubmit` chain (not from the autocomplete list), so a
15
+ * hidden command can still be typed by hand — the same bar pi itself draws for
16
+ * its easter-egg commands, which are also absent from autocomplete. Removing
17
+ * them from the list is the whole intent: a YAGNI user should not be offered
18
+ * `/share` (ships a transcript to a GitHub gist) or `/login <provider>` (pi
19
+ * provider auth) when YAGNI's model is a single `yagni` provider.
20
+ *
21
+ * The desktop palette is unaffected: pi's RPC `get_commands` (which the desktop
22
+ * app reads) already returns extension commands + prompt templates + skills and
23
+ * never includes built-ins. This wrapper is inert outside interactive TUI mode,
24
+ * where `addAutocompleteProvider` is handed a no-op factory.
25
+ */
26
+ /** pi built-in slash commands hidden from YAGNI Code autocomplete. */
27
+ export declare const HIDDEN_BUILTIN_SLASH_COMMANDS: readonly string[];
28
+ /** Register the slash-command filter on `session_start` (TUI autocomplete only). */
29
+ export declare function registerSlashCommandFilter(pi: ExtensionAPI): void;
30
+ //# sourceMappingURL=slashCommandFilter.d.ts.map
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Slash-command filtering for the CLI.
3
+ *
4
+ * pi ships its own built-in slash commands (see `BUILTIN_SLASH_COMMANDS` in
5
+ * pi's `core/slash-commands.js`) and bakes them directly into its interactive
6
+ * autocomplete provider (`createBaseAutocompleteProvider`). Most are
7
+ * pi-generic and harmless (`/new`, `/compact`, `/settings`), but a subset
8
+ * contradict YAGNI's single-provider, single-identity model — or leak pi
9
+ * features YAGNI deliberately does not surface — and only confuse a YAGNI Code
10
+ * user. We hide those from slash-discovery.
11
+ *
12
+ * Scope note: this filters DISCOVERY only. pi dispatches built-in commands in
13
+ * its own private `onSubmit` chain (not from the autocomplete list), so a
14
+ * hidden command can still be typed by hand — the same bar pi itself draws for
15
+ * its easter-egg commands, which are also absent from autocomplete. Removing
16
+ * them from the list is the whole intent: a YAGNI user should not be offered
17
+ * `/share` (ships a transcript to a GitHub gist) or `/login <provider>` (pi
18
+ * provider auth) when YAGNI's model is a single `yagni` provider.
19
+ *
20
+ * The desktop palette is unaffected: pi's RPC `get_commands` (which the desktop
21
+ * app reads) already returns extension commands + prompt templates + skills and
22
+ * never includes built-ins. This wrapper is inert outside interactive TUI mode,
23
+ * where `addAutocompleteProvider` is handed a no-op factory.
24
+ */
25
+ /** pi built-in slash commands hidden from YAGNI Code autocomplete. */
26
+ export const HIDDEN_BUILTIN_SLASH_COMMANDS = [
27
+ "share", // ships a session transcript to a secret GitHub gist (off-brand + privacy footgun)
28
+ "login", // pi provider auth; YAGNI auth is `yagni login`, one provider
29
+ "logout", // pi provider auth; YAGNI auth is `yagni logout`
30
+ "scoped-models", // pi multi-model Ctrl+P cycling; YAGNI pins a single tier
31
+ "fork", // pi session-fork feature YAGNI never surfaces
32
+ "clone", // pi session-clone feature YAGNI never surfaces
33
+ "tree", // pi session-tree feature YAGNI never surfaces
34
+ "trust", // pi project-trust prompt; collides with YAGNI guardrails
35
+ "changelog", // pi's own changelog, not YAGNI's
36
+ ];
37
+ const HIDDEN = new Set(HIDDEN_BUILTIN_SLASH_COMMANDS);
38
+ /**
39
+ * True when the text before the cursor is a slash-COMMAND name being typed —
40
+ * a leading `/` plus a name with no interior `/` or space. This mirrors pi's
41
+ * own slash-command detection (its `applyCompletion` treats a prefix with an
42
+ * interior `/` as a file path, e.g. the absolute path `/usr/local/...`), so we
43
+ * never strip a legitimate file-named completion.
44
+ */
45
+ function isSlashCommandName(textBeforeCursor) {
46
+ return textBeforeCursor.startsWith("/")
47
+ && !textBeforeCursor.slice(1).includes("/")
48
+ && !textBeforeCursor.slice(1).includes(" ");
49
+ }
50
+ function filterHiddenSlashSuggestions(suggestions) {
51
+ const items = suggestions.items.filter((item) => !HIDDEN.has(item.value));
52
+ if (items.length === 0)
53
+ return null;
54
+ return { ...suggestions, items };
55
+ }
56
+ /** Wrap pi's built-in autocomplete provider so hidden built-ins never complete. */
57
+ function makeYagniAutocompleteProvider(current) {
58
+ return {
59
+ triggerCharacters: current.triggerCharacters,
60
+ async getSuggestions(lines, cursorLine, cursorCol, options) {
61
+ const textBeforeCursor = (lines[cursorLine] ?? "").slice(0, cursorCol);
62
+ if (!isSlashCommandName(textBeforeCursor)) {
63
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
64
+ }
65
+ const suggestions = await current.getSuggestions(lines, cursorLine, cursorCol, options);
66
+ return suggestions ? filterHiddenSlashSuggestions(suggestions) : null;
67
+ },
68
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
69
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
70
+ },
71
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
72
+ return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
73
+ },
74
+ };
75
+ }
76
+ /** Register the slash-command filter on `session_start` (TUI autocomplete only). */
77
+ export function registerSlashCommandFilter(pi) {
78
+ pi.on("session_start", (_event, ctx) => {
79
+ if (ctx.mode !== "tui" || !ctx.hasUI)
80
+ return;
81
+ try {
82
+ ctx.ui.addAutocompleteProvider(makeYagniAutocompleteProvider);
83
+ }
84
+ catch {
85
+ // Filtering is cosmetic; a failure must never break session start.
86
+ }
87
+ });
88
+ }
89
+ //# sourceMappingURL=slashCommandFilter.js.map
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
21
21
  import { Type } from "typebox";
22
+ import type { ChildUsageHandle } from "./childUsage.js";
22
23
  import type { WorkingLineHandle } from "./workingLine.js";
23
24
  import { runStage } from "./pipeline/runner.js";
24
25
  import { type ModelTier, type PipelineStage } from "./pipeline/types.js";
@@ -103,6 +104,13 @@ export interface MakeSubagentToolDeps {
103
104
  * the tool falls back to setting ui.setWorkingMessage directly.
104
105
  */
105
106
  workingLine?: WorkingLineHandle;
107
+ /**
108
+ * Session child-usage accumulator (childUsage.ts). Each completed task's
109
+ * folded usage is recorded so the footer's session totals include the
110
+ * child spend the per-task receipt already prints. Absent = not wired
111
+ * (headless/eval harnesses): no recording, nothing else changes.
112
+ */
113
+ childUsage?: ChildUsageHandle;
106
114
  }
107
115
  export declare function makeSubagentTool(deps?: MakeSubagentToolDeps): {
108
116
  name: string;
@@ -120,7 +128,7 @@ export declare function makeSubagentTool(deps?: MakeSubagentToolDeps): {
120
128
  renderShell: "self";
121
129
  renderCall: typeof renderSubagentCall;
122
130
  renderResult: typeof renderSubagentResult;
123
- execute(_toolCallId: string, params: SubagentParams, signal?: AbortSignal, onUpdate?: (update: {
131
+ execute(toolCallId: string, params: SubagentParams, signal?: AbortSignal, onUpdate?: (update: {
124
132
  content: Array<{
125
133
  type: "text";
126
134
  text: string;
@@ -151,6 +159,8 @@ export interface RegisterSubagentsDeps {
151
159
  isUltra?: () => boolean;
152
160
  /** Session working-line manager; see MakeSubagentToolDeps.workingLine. */
153
161
  workingLine?: WorkingLineHandle;
162
+ /** Session child-usage accumulator; see MakeSubagentToolDeps.childUsage. */
163
+ childUsage?: ChildUsageHandle;
154
164
  }
155
165
  /** Wire the subagent tool and the /agents listing command. */
156
166
  export declare function registerSubagents(pi: ExtensionAPI, deps?: RegisterSubagentsDeps): void;
@@ -33,6 +33,17 @@ import { applyChildEvent, finalizeTask, formatWorkingMessage, newTaskProgress, p
33
33
  * within the model proxy's 64-char caller-label limit.
34
34
  */
35
35
  const SUBAGENT_CALLER_PREFIX = "subagent:";
36
+ /**
37
+ * Dedupe key for one task's childUsage record. Neither `startedAt` (parallel
38
+ * tasks on the same agent are minted in the same `resolved.map` pass and can
39
+ * share a millisecond) nor the per-invocation index (every invocation starts
40
+ * at 0) is unique alone; the pair is, because one tool call's task set is
41
+ * minted exactly once. A re-fired finalize for the same record lands on the
42
+ * same key, so the accumulator's (source, key) dedupe makes it a no-op.
43
+ */
44
+ function progressKey(toolCallId, p, index) {
45
+ return `${toolCallId}/${index}/${p.agent}@${p.startedAt}`;
46
+ }
36
47
  export const SUBAGENT_TOOL_NAME = "subagent";
37
48
  export const GENERAL_AGENT_NAME = "general";
38
49
  export const MAX_PARALLEL_SUBAGENTS = 4;
@@ -354,7 +365,7 @@ export function makeSubagentTool(deps = {}) {
354
365
  renderShell: "self",
355
366
  renderCall: renderSubagentCall,
356
367
  renderResult: renderSubagentResult,
357
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
368
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
358
369
  const fail = (text) => ({
359
370
  content: [{ type: "text", text }],
360
371
  details: {},
@@ -428,6 +439,10 @@ export function makeSubagentTool(deps = {}) {
428
439
  },
429
440
  });
430
441
  finalizeTask(progress, result, Date.now());
442
+ // Record the child's spend into the session accumulator so the
443
+ // footer totals include it (dedupe key: this progress record's
444
+ // identity — per invocation, so parallel tasks never collide).
445
+ deps.childUsage?.record("subagent", progressKey(toolCallId, progress, index), result.usage);
431
446
  emit();
432
447
  return { agent: def.name, task, result };
433
448
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -41,5 +41,5 @@
41
41
  "turndown": "^7.2.4",
42
42
  "typebox": "^1.3.15"
43
43
  },
44
- "yagniSourceSha": "ba1d574af2d35bb9464a8b47d4e7d4a3caa3ebac"
44
+ "yagniSourceSha": "e8e3f653ae3366f156535d53740b775e21fc6fb4"
45
45
  }