@yagni-app/code-staging 0.3.0-staging.1096.1 → 0.3.0-staging.1099.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.
@@ -1,11 +1,20 @@
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
  import { type RepoDocSnippet } from "./repoDocs.js";
4
5
  /** A single source citation returned by the YAGNI `ask` endpoint. */
5
6
  export interface Citation {
6
7
  title: string;
7
8
  url: string;
8
9
  }
10
+ /**
11
+ * How hard the answer may be leaned on (mirror of `@yagni/shared`'s
12
+ * AskStanding — mirrored locally by this extension's no-workspace-imports
13
+ * convention, see costHud.ts).
14
+ */
15
+ export type AskStanding = "confirmed" | "asserted" | "inferred" | "no_position";
16
+ /** One line the TUI shows above the answer, per standing. */
17
+ export declare const STANDING_LINES: Record<AskStanding, string>;
9
18
  /** Options for {@link makeAskYagniTool}. */
10
19
  export interface MakeAskYagniToolOptions {
11
20
  baseUrl: string;
@@ -19,6 +28,19 @@ export interface MakeAskYagniToolOptions {
19
28
  * checkout's own era, since the docs are read from the tree being edited.
20
29
  */
21
30
  collectDocs?: (cwd: string, query: string) => RepoDocSnippet[];
31
+ /**
32
+ * Shared flywheel session state (Run 7): caps how many no-position
33
+ * recordSuggestions reach the model per session and attributes the
34
+ * follow-up record_decision for dedupe. Absent → suggestions always
35
+ * surface, nothing is attributed (tests, older wiring).
36
+ */
37
+ flywheel?: FlywheelState;
38
+ /**
39
+ * The session repo (`owner/name`), for the backend's soft-scoped decision
40
+ * read (workspace-level decisions plus this repo's own — never another
41
+ * repo's conventions). Absent keeps the workspace-wide read.
42
+ */
43
+ getRepo?: () => string | undefined;
22
44
  }
23
45
  declare const parameters: Type.TObject<{
24
46
  question: Type.TString;
@@ -34,6 +56,7 @@ declare const parameters: Type.TObject<{
34
56
  */
35
57
  export declare function makeAskYagniTool(opts: MakeAskYagniToolOptions): ToolDefinition<typeof parameters, {
36
58
  citations: Citation[];
59
+ standing?: AskStanding;
37
60
  }>;
38
61
  export {};
39
62
  //# sourceMappingURL=askYagniTool.d.ts.map
@@ -1,8 +1,21 @@
1
1
  import { Container, Spacer, Text } from "@earendil-works/pi-tui";
2
2
  import { Type } from "typebox";
3
+ import { canSurfaceSuggestion, noteSuggestionSurfaced } from "./flywheel.js";
3
4
  import { collectRepoDocs } from "./repoDocs.js";
4
5
  import { friendlyFetchError, METERED_POST_FETCH_POLICY, resilientFetch } from "./resilientFetch.js";
5
6
  import { markdownOrPlain } from "./subagentRender.js";
7
+ /** One line the TUI shows above the answer, per standing. */
8
+ export const STANDING_LINES = {
9
+ confirmed: "Grounded in a confirmed decision",
10
+ asserted: "Grounded in a recorded assumption, not yet verified",
11
+ inferred: "Inferred from workspace context, not a recorded decision",
12
+ no_position: "No recorded position in this workspace",
13
+ };
14
+ function standingLine(value) {
15
+ return typeof value === "string" && value in STANDING_LINES
16
+ ? STANDING_LINES[value]
17
+ : undefined;
18
+ }
6
19
  const parameters = Type.Object({
7
20
  question: Type.String(),
8
21
  context: Type.Optional(Type.String()),
@@ -40,6 +53,7 @@ export function makeAskYagniTool(opts) {
40
53
  "Call ask_yagni BEFORE guessing about anything organization- or codebase-specific (conventions, policies, architecture, ownership, product decisions).",
41
54
  "Pass the user's actual question; add relevant local context (file paths, snippets) in the optional `context` field.",
42
55
  "When you use an answer, quote or reference its citations so the user can verify the source.",
56
+ "Answers carry a standing: treat a confirmed decision as settled; when you lean on an unverified assumption or an inference, say so where the work is reviewed; when there is no recorded position, follow the answer's instruction to record the assumption you proceed on.",
43
57
  ],
44
58
  parameters,
45
59
  renderCall(args, theme) {
@@ -55,8 +69,11 @@ export function makeAskYagniTool(opts) {
55
69
  const citations = result.details?.citations ?? [];
56
70
  if (isPartial)
57
71
  return new Text(t.fg("muted", answer || "Asking YAGNI…"), 0, 0);
72
+ const standing = standingLine(result.details?.standing);
58
73
  if (expanded) {
59
74
  const container = new Container();
75
+ if (standing)
76
+ container.addChild(new Text(t.fg("muted", standing), 0, 0));
60
77
  container.addChild(markdownOrPlain(answer || "(no answer)", t));
61
78
  if (citations.length > 0) {
62
79
  container.addChild(new Spacer(1));
@@ -69,6 +86,8 @@ export function makeAskYagniTool(opts) {
69
86
  const lines = answer.trim().split("\n");
70
87
  const out = lines.slice(0, ANSWER_PREVIEW_LINES).map((l) => t.fg("toolOutput", l));
71
88
  const meta = [];
89
+ if (standing)
90
+ meta.push(standing);
72
91
  if (citations.length > 0)
73
92
  meta.push(citationCount(citations.length));
74
93
  if (lines.length > ANSWER_PREVIEW_LINES || citations.length > 0)
@@ -95,6 +114,7 @@ export function makeAskYagniTool(opts) {
95
114
  question: params.question,
96
115
  context: params.context,
97
116
  cwd: ctx?.cwd,
117
+ ...(opts.getRepo?.() ? { repo: opts.getRepo() } : {}),
98
118
  ...(repoDocs.length > 0 ? { repoDocs } : {}),
99
119
  }),
100
120
  }, { fetchImpl: opts.fetchImpl, signal, policy: METERED_POST_FETCH_POLICY });
@@ -102,9 +122,29 @@ export function makeAskYagniTool(opts) {
102
122
  throw new Error(await friendlyFetchError("ask_yagni", res));
103
123
  }
104
124
  const data = (await res.json());
125
+ // Run 7 flywheel: a no-position answer carries the record-the-assumption
126
+ // instruction. It reaches the model VERBATIM inside the tool result, but
127
+ // only up to the per-session cap — past it a busy run stops being told
128
+ // to bank more asserted rows.
129
+ let text = data.answer;
130
+ const suggestion = data.recordSuggestion?.instruction;
131
+ if (suggestion && data.standing === "no_position") {
132
+ const state = opts.flywheel;
133
+ if (!state || canSurfaceSuggestion(state)) {
134
+ // Attribution correlates to the QUESTION the suggestion asked
135
+ // about, so only the record_decision that answers it inherits the
136
+ // dedupe flag.
137
+ if (state)
138
+ noteSuggestionSurfaced(state, data.recordSuggestion?.question ?? params.question);
139
+ text = `${text}\n\n${suggestion}`;
140
+ }
141
+ }
105
142
  return {
106
- content: [{ type: "text", text: data.answer }],
107
- details: { citations: data.citations ?? [] },
143
+ content: [{ type: "text", text }],
144
+ details: {
145
+ citations: data.citations ?? [],
146
+ ...(data.standing ? { standing: data.standing } : {}),
147
+ },
108
148
  };
109
149
  },
110
150
  };
@@ -113,6 +113,12 @@ export interface ContextBrief {
113
113
  decisions: number;
114
114
  corrections: number;
115
115
  };
116
+ /**
117
+ * Active decisions scoped to the repo named on the request (additive, Run
118
+ * 7; absent without `?repo=`, on older backends, and on a count failure).
119
+ * The opt-in mining beat fires ONLY on an explicit 0.
120
+ */
121
+ repoDecisionCount?: number;
116
122
  }
117
123
  /**
118
124
  * Attribution headers for the model proxy (YAG-471). On the SERVER side these
@@ -192,6 +198,12 @@ export interface FetchContextBriefOptions {
192
198
  fetchImpl?: typeof fetch;
193
199
  /** Env seam for the telemetry headers; defaults to process.env. */
194
200
  env?: NodeJS.ProcessEnv;
201
+ /**
202
+ * The session repo (`owner/name`), when resolvable. Rides as `?repo=` so
203
+ * the response can carry `repoDecisionCount` for the mining beat — no
204
+ * second round-trip at boot.
205
+ */
206
+ repo?: string;
195
207
  }
196
208
  /**
197
209
  * Fetch the workspace company brief at startup.
@@ -208,7 +208,8 @@ export function sessionTelemetryHeaders(env = process.env) {
208
208
  */
209
209
  export async function fetchContextBrief(opts) {
210
210
  try {
211
- const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/context`, {
211
+ const query = opts.repo ? `?repo=${encodeURIComponent(opts.repo)}` : "";
212
+ const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/context${query}`, {
212
213
  method: "GET",
213
214
  headers: {
214
215
  authorization: `Bearer ${opts.getToken() ?? ""}`,
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Session state for the miss-to-record flywheel (org plane, Run 7).
3
+ *
4
+ * When `ask_yagni` answers `no_position`, the server attaches a
5
+ * `recordSuggestion` telling the agent to bank the assumption it proceeds on.
6
+ * The extension owns two client-side guards the server cannot:
7
+ *
8
+ * - **The per-run cap.** At most {@link FLYWHEEL_RUN_CAP} suggestions are
9
+ * surfaced per session; past it the suggestion is suppressed so a busy
10
+ * session cannot flood the ledger with asserted rows.
11
+ * - **Dedupe attribution.** A `record_decision` call that answers the
12
+ * QUESTION a surfaced suggestion asked about is a mid-run agent with no
13
+ * human to adjudicate a near-duplicate, so it sends `dedupe: true` (the
14
+ * backend then returns the existing row instead of inserting). The
15
+ * attribution is correlated to the suggested question — an unrelated
16
+ * record_decision (a different judgment the agent banks mid-run, or a
17
+ * human `/decide`) never inherits the flag.
18
+ */
19
+ /** Most flywheel suggestions surfaced per session. */
20
+ export declare const FLYWHEEL_RUN_CAP = 3;
21
+ export interface FlywheelState {
22
+ /** Suggestions surfaced so far this session. */
23
+ suggestionsShown: number;
24
+ /**
25
+ * The question of the most recent surfaced suggestion, until a matching
26
+ * record_decision consumes it. Only a record answering THIS question is
27
+ * flywheel-attributed (sends `dedupe: true`).
28
+ */
29
+ pendingQuestion: string | null;
30
+ }
31
+ export declare function makeFlywheelState(): FlywheelState;
32
+ /** May another suggestion be surfaced? */
33
+ export declare function canSurfaceSuggestion(state: FlywheelState): boolean;
34
+ /** Record that a suggestion (asking about `question`) reached the model. */
35
+ export declare function noteSuggestionSurfaced(state: FlywheelState, question: string): void;
36
+ /**
37
+ * Consume the flywheel attribution for a record_decision call. Returns true
38
+ * (and clears the pending question) only when the recorded question matches
39
+ * the surfaced suggestion's; a mismatch leaves the attribution pending — the
40
+ * agent may record other judgments before circling back, and a fail-safe
41
+ * mismatch simply means a normal insert (no dedupe), never a swallowed write.
42
+ */
43
+ export declare function consumeFlywheelAttribution(state: FlywheelState, recordedQuestion: string): boolean;
44
+ //# sourceMappingURL=flywheel.d.ts.map
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Session state for the miss-to-record flywheel (org plane, Run 7).
3
+ *
4
+ * When `ask_yagni` answers `no_position`, the server attaches a
5
+ * `recordSuggestion` telling the agent to bank the assumption it proceeds on.
6
+ * The extension owns two client-side guards the server cannot:
7
+ *
8
+ * - **The per-run cap.** At most {@link FLYWHEEL_RUN_CAP} suggestions are
9
+ * surfaced per session; past it the suggestion is suppressed so a busy
10
+ * session cannot flood the ledger with asserted rows.
11
+ * - **Dedupe attribution.** A `record_decision` call that answers the
12
+ * QUESTION a surfaced suggestion asked about is a mid-run agent with no
13
+ * human to adjudicate a near-duplicate, so it sends `dedupe: true` (the
14
+ * backend then returns the existing row instead of inserting). The
15
+ * attribution is correlated to the suggested question — an unrelated
16
+ * record_decision (a different judgment the agent banks mid-run, or a
17
+ * human `/decide`) never inherits the flag.
18
+ */
19
+ /** Most flywheel suggestions surfaced per session. */
20
+ export const FLYWHEEL_RUN_CAP = 3;
21
+ export function makeFlywheelState() {
22
+ return { suggestionsShown: 0, pendingQuestion: null };
23
+ }
24
+ /** May another suggestion be surfaced? */
25
+ export function canSurfaceSuggestion(state) {
26
+ return state.suggestionsShown < FLYWHEEL_RUN_CAP;
27
+ }
28
+ /** Loose textual identity: case- and whitespace-insensitive, terminal punctuation ignored. */
29
+ function normalizeQuestion(question) {
30
+ return question.toLowerCase().replace(/\s+/g, " ").replace(/[.?!\s]+$/g, "").trim();
31
+ }
32
+ /** Record that a suggestion (asking about `question`) reached the model. */
33
+ export function noteSuggestionSurfaced(state, question) {
34
+ state.suggestionsShown += 1;
35
+ state.pendingQuestion = question;
36
+ }
37
+ /**
38
+ * Consume the flywheel attribution for a record_decision call. Returns true
39
+ * (and clears the pending question) only when the recorded question matches
40
+ * the surfaced suggestion's; a mismatch leaves the attribution pending — the
41
+ * agent may record other judgments before circling back, and a fail-safe
42
+ * mismatch simply means a normal insert (no dedupe), never a swallowed write.
43
+ */
44
+ export function consumeFlywheelAttribution(state, recordedQuestion) {
45
+ if (state.pendingQuestion === null)
46
+ return false;
47
+ if (normalizeQuestion(state.pendingQuestion) !== normalizeQuestion(recordedQuestion)) {
48
+ return false;
49
+ }
50
+ state.pendingQuestion = null;
51
+ return true;
52
+ }
53
+ //# sourceMappingURL=flywheel.js.map
@@ -41,6 +41,9 @@
41
41
  import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
42
42
  import type { ModeHolder, PermissionMode } from "./permission.js";
43
43
  export declare const BRANCH_MAX_WIDTH = 60;
44
+ export declare function cyclePermissionMode(current: PermissionMode): PermissionMode;
45
+ export declare function isShiftTab(data: string): boolean;
46
+ export declare const GIT_MUTATING_PATTERN: RegExp;
44
47
  /**
45
48
  * Resolve the status bar's left pad from the launcher's `YAGNI_PAD_X` env so it
46
49
  * aligns with the editor input and the chat/output area on one shared column.
@@ -102,7 +105,11 @@ export declare function renderFooterLines(input: {
102
105
  * and returns the component `setFooter` expects. Called from the
103
106
  * `session_start` handler in index.ts.
104
107
  */
105
- export declare function createYagniFooterFactory(ctx: ExtensionContext, modeHolder?: ModeHolder): (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
108
+ export interface FooterInvalidateHandle {
109
+ invalidateGit(): void;
110
+ requestRender(): void;
111
+ }
112
+ export declare function createYagniFooterFactory(ctx: ExtensionContext, modeHolder?: ModeHolder, invalidateHandle?: FooterInvalidateHandle): (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
106
113
  render(width: number): string[];
107
114
  invalidate(): void;
108
115
  dispose(): void;
@@ -41,11 +41,20 @@
41
41
  import { spawnSync } from "node:child_process";
42
42
  import { statSync } from "node:fs";
43
43
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
44
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
44
+ import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
45
45
  export const BRANCH_MAX_WIDTH = 60;
46
46
  const WORKTREE_MAX_WIDTH = 30;
47
47
  /** Section separator: single space + middle dot + single space. */
48
48
  const SEP = " · ";
49
+ const MODE_CYCLE = ["auto", "review", "plan"];
50
+ export function cyclePermissionMode(current) {
51
+ const idx = MODE_CYCLE.indexOf(current);
52
+ return MODE_CYCLE[(idx + 1) % MODE_CYCLE.length];
53
+ }
54
+ export function isShiftTab(data) {
55
+ return matchesKey(data, "shift+tab");
56
+ }
57
+ export const GIT_MUTATING_PATTERN = /\bgit\s+(?:checkout|switch|branch|worktree|reset|restore|rebase|merge|cherry-pick|bisect)\b/;
49
58
  /** Default horizontal pad when the launcher didn't forward one (matches outputPad=1). */
50
59
  const DEFAULT_PAD_X = 1;
51
60
  /**
@@ -107,10 +116,10 @@ export function collectUsage(sessionManager) {
107
116
  return totals;
108
117
  }
109
118
  /** End-cut ellipsis truncation (ANSI-aware) so the branch prefix stays readable. */
110
- function truncateEnd(text, maxWidth) {
119
+ function truncateEnd(text, maxWidth, ellipsis = "…") {
111
120
  if (visibleWidth(text) <= maxWidth)
112
121
  return text;
113
- return truncateToWidth(text, maxWidth, "…");
122
+ return truncateToWidth(text, maxWidth, ellipsis);
114
123
  }
115
124
  function runGit(args, cwd) {
116
125
  try {
@@ -193,15 +202,23 @@ export function detectGitInfo(cwd, home) {
193
202
  const worktree = resolveWorktreeLabel(root, branch);
194
203
  return { folder: basename(gitMainRepoRoot(root)), inRepo: true, branch, worktree };
195
204
  }
196
- /** Context color: dim below 70, warning 70-90, error above 90. */
205
+ const MODE_DISPLAY = {
206
+ auto: { text: "⏵⏵ auto mode", color: "accent" },
207
+ review: { text: "✓ review mode", color: "warning" },
208
+ plan: { text: "⏸ plan mode", color: "success" },
209
+ };
210
+ function modeDisplay(mode) {
211
+ return MODE_DISPLAY[mode];
212
+ }
213
+ /** Context color: success below 70, warning 70-90, error above 90. */
197
214
  function contextColor(percent) {
198
215
  if (percent === null)
199
- return "dim";
216
+ return "success";
200
217
  if (percent > 90)
201
218
  return "error";
202
219
  if (percent > 70)
203
220
  return "warning";
204
- return "dim";
221
+ return "success";
205
222
  }
206
223
  /** Pure line-builder, exported for tests. All data injected; colors via theme. */
207
224
  export function renderFooterLines(input, theme, width, padX = 0) {
@@ -218,7 +235,7 @@ export function renderFooterLines(input, theme, width, padX = 0) {
218
235
  if (input.git.worktree)
219
236
  line1Parts.push(theme.fg("warning", `[${input.git.worktree}]`));
220
237
  if (input.git.branch)
221
- line1Parts.push(theme.fg("border", truncateEnd(input.git.branch, BRANCH_MAX_WIDTH)));
238
+ line1Parts.push(theme.fg("border", truncateEnd(input.git.branch, BRANCH_MAX_WIDTH, dim("…"))));
222
239
  }
223
240
  const line1 = pad + truncateToWidth(line1Parts.join(sep), contentWidth, dim("…"));
224
241
  // Line 2: [mode ·] model · ↑in ↓out $cost · ctx%
@@ -232,8 +249,10 @@ export function renderFooterLines(input, theme, width, padX = 0) {
232
249
  const stats = statParts.join(" ");
233
250
  const percentText = input.contextPercent === null ? "?" : `${Math.round(input.contextPercent)}%`;
234
251
  const line2Parts = [];
235
- if (input.mode)
236
- line2Parts.push(dim(`${input.mode} mode`));
252
+ if (input.mode) {
253
+ const modeLabel = modeDisplay(input.mode);
254
+ line2Parts.push(theme.fg(modeLabel.color, modeLabel.text) + dim(" (shift+tab to change)"));
255
+ }
237
256
  line2Parts.push(dim(input.model));
238
257
  if (stats)
239
258
  line2Parts.push(dim(stats));
@@ -247,17 +266,8 @@ export function renderFooterLines(input, theme, width, padX = 0) {
247
266
  }
248
267
  return lines;
249
268
  }
250
- /**
251
- * Create a footer factory that captures the session `ctx` (for session data)
252
- * and returns the component `setFooter` expects. Called from the
253
- * `session_start` handler in index.ts.
254
- */
255
- export function createYagniFooterFactory(ctx, modeHolder) {
269
+ export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle) {
256
270
  return (_tui, theme, footerData) => {
257
- // Recompute git/worktree info only when the branch actually changes. Optional-
258
- // chained: onBranchChange is typed on ReadonlyFooterDataProvider, but the runtime
259
- // provider is whatever pi version is installed — guard so a mismatch can't break
260
- // footer construction (worst case, git info just doesn't auto-invalidate).
261
271
  let gitCache;
262
272
  const unsubscribeBranch = footerData.onBranchChange?.(() => {
263
273
  gitCache = undefined;
@@ -268,6 +278,10 @@ export function createYagniFooterFactory(ctx, modeHolder) {
268
278
  }
269
279
  return gitCache;
270
280
  };
281
+ if (invalidateHandle) {
282
+ invalidateHandle.invalidateGit = () => { gitCache = undefined; };
283
+ invalidateHandle.requestRender = () => { _tui?.requestRender?.(); };
284
+ }
271
285
  return {
272
286
  render(width) {
273
287
  const statuses = [...footerData.getExtensionStatuses().entries()]
@@ -5,6 +5,7 @@ import { fetchMcpServers as defaultFetchMcpServers } from "./mcpTools.js";
5
5
  import { type FlushOutcome, type SpoolClientOpts } from "./spool.js";
6
6
  import { type TokenProvider } from "./tokenProvider.js";
7
7
  import { type CatalogResult, type ContextBrief } from "./config.js";
8
+ import { maybeOfferMiningBeat as defaultMaybeOfferMiningBeat, type MineBeatMarkers } from "./mineBeat.js";
8
9
  /**
9
10
  * YAGNI Code extension entry point.
10
11
  *
@@ -36,7 +37,21 @@ export interface RegisterYagniDeps {
36
37
  baseUrl: string;
37
38
  getToken: () => string | undefined;
38
39
  fetchImpl?: typeof fetch;
40
+ repo?: string;
39
41
  }) => Promise<ContextBrief | null>;
42
+ /**
43
+ * The session repo (`owner/name`), resolved from the origin remote before
44
+ * the boot brief fetch so `/context` can carry `repoDecisionCount` for the
45
+ * mining beat. Injectable (no git in tests); undefined outside a repo.
46
+ */
47
+ resolveRepoFullName?: (cwd: string) => string | undefined;
48
+ /**
49
+ * The opt-in seeding beat itself (Run 7). Injectable so tests assert it
50
+ * fires only on a startup with an explicitly-zero repo ledger.
51
+ */
52
+ offerMiningBeat?: typeof defaultMaybeOfferMiningBeat;
53
+ /** The beat's once-per-repo marker store. Injectable (no disk in tests). */
54
+ mineBeatMarkers?: MineBeatMarkers;
40
55
  /**
41
56
  * Workspace MCP server list seam (YAG-446). The default hits
42
57
  * `/api/yagni-code/mcp/servers` and is fail-soft: older backends and
@@ -20,7 +20,7 @@ import { registerCostCommand } from "./costHud.js";
20
20
  import { isDebug } from "./diagnostics.js";
21
21
  import { droppedSessionRuns, sessionRunIds } from "./sessionRuns.js";
22
22
  import { codeStateHome } from "./stateHome.js";
23
- import { createYagniFooterFactory, formatCwd } from "./footer.js";
23
+ import { createYagniFooterFactory, cyclePermissionMode, formatCwd, GIT_MUTATING_PATTERN, isShiftTab } from "./footer.js";
24
24
  import { RerouteNotifier } from "./rerouteNotice.js";
25
25
  import { isFreshWorkspace, registerTeamSetupCommand, runInitPass as defaultRunInitPass } from "./initPass.js";
26
26
  import { isInitDone as defaultIsInitDone, markInitDone as defaultMarkInitDone } from "./initDone.js";
@@ -41,6 +41,8 @@ import { makeAuthedFetch, makeTokenProvider } from "./tokenProvider.js";
41
41
  import { attributionHeaders, fetchCatalog as defaultFetchCatalog, fetchContextBrief as defaultFetchContextBrief, getToken, getTokenExpiresAt as defaultGetTokenExpiresAt, getWorkspaceId as defaultGetWorkspaceId, isDriverCaller, resolveBaseUrl, tokenExpiryNotice, } from "./config.js";
42
42
  import { buildYagniProvider } from "./provider.js";
43
43
  import { registerChipEditor } from "./chipEditor.js";
44
+ import { defaultMineBeatGit, fileMineBeatMarkers, maybeOfferMiningBeat as defaultMaybeOfferMiningBeat, } from "./mineBeat.js";
45
+ import { makeFlywheelState } from "./flywheel.js";
44
46
  function isEvalMode(env = process.env) {
45
47
  return env.YAGNI_CODE_EVAL_MODE === "1";
46
48
  }
@@ -144,7 +146,11 @@ export async function registerYagni(pi, deps = {}) {
144
146
  // child, a subagent, or an advisor consult).
145
147
  pi.registerProvider("yagni", buildYagniProvider(catalog, baseUrl, attributionHeaders(deps.env)));
146
148
  const toolOpts = { baseUrl, getToken: getTokenFn, fetchImpl: authedFetch };
147
- pi.registerTool(makeAskYagniTool(toolOpts));
149
+ // One flywheel state per session, shared by ask_yagni (caps how many
150
+ // no-position record suggestions reach the model) and record_decision
151
+ // (flywheel-attributed records send dedupe: true). Run 7.
152
+ const flywheelState = makeFlywheelState();
153
+ pi.registerTool(makeAskYagniTool({ ...toolOpts, flywheel: flywheelState, getRepo: () => sessionRepo }));
148
154
  // Ticket write-back (spec 2026-08-09): explicit user-intent writes to the
149
155
  // workspace tracker, attributed to the developer via per-user credentials.
150
156
  if (!evalMode) {
@@ -172,7 +178,7 @@ export async function registerYagni(pi, deps = {}) {
172
178
  pi.registerTool(makeRecordEngineeringContextTool(toolOpts));
173
179
  // The capture half of the judgment loop: bank a product-intent decision so
174
180
  // ask_yagni answers the same question next time instead of interrupting a human.
175
- pi.registerTool(makeRecordDecisionTool(toolOpts));
181
+ pi.registerTool(makeRecordDecisionTool({ ...toolOpts, flywheel: flywheelState }));
176
182
  }
177
183
  // The visible checklist for multi-step work: the todo_write tool, its
178
184
  // above-editor widget, and /todos. Branch-replayed, so forks and resumes
@@ -244,6 +250,7 @@ export async function registerYagni(pi, deps = {}) {
244
250
  // Mutating MCP tools join write/edit/bash in the gate policy: plan mode
245
251
  // holds them, review mode confirms them.
246
252
  const modeHolder = createModeHolder();
253
+ const footerInvalidateHandle = { invalidateGit: () => { }, requestRender: () => { } };
247
254
  const guardianState = makeGuardianState();
248
255
  // Disabled by the local env override OR the workspace kill switch
249
256
  // (yagni_code.guardian, read from the catalog response at launch). The env
@@ -460,11 +467,27 @@ export async function registerYagni(pi, deps = {}) {
460
467
  // must never block the agent), then inject it into the system prompt so the
461
468
  // very first turn already knows how this company and codebase work.
462
469
  const fetchBrief = deps.fetchContextBrief ?? defaultFetchContextBrief;
470
+ // Resolved BEFORE the brief fetch so /context can answer repoDecisionCount
471
+ // in the same round-trip (the mining beat's signal). Best-effort: undefined
472
+ // outside a git repo or without an origin remote.
473
+ const resolveRepoFn = deps.resolveRepoFullName ?? ((cwd) => defaultMineBeatGit.repoFullName(cwd));
474
+ let sessionRepo;
475
+ try {
476
+ sessionRepo = evalMode ? undefined : resolveRepoFn(process.cwd());
477
+ }
478
+ catch {
479
+ sessionRepo = undefined;
480
+ }
463
481
  let contextBrief;
464
482
  let briefResult = null;
465
483
  if (!evalMode) {
466
484
  try {
467
- briefResult = await fetchBrief({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
485
+ briefResult = await fetchBrief({
486
+ baseUrl,
487
+ getToken: getTokenFn,
488
+ fetchImpl: authedFetch,
489
+ repo: sessionRepo,
490
+ });
468
491
  contextBrief = briefResult?.brief?.trim() ? briefResult.brief : undefined;
469
492
  }
470
493
  catch {
@@ -695,7 +718,15 @@ export async function registerYagni(pi, deps = {}) {
695
718
  // context % on line 2, and extension statuses (brand, todos, mode) on
696
719
  // line 3. The factory captures ctx so the footer can read session data
697
720
  // (token stats, context usage) that isn't on the footerData provider.
698
- ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx, modeHolder)(tui, theme, footerData));
721
+ ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx, modeHolder, footerInvalidateHandle)(tui, theme, footerData));
722
+ ctx.ui?.onTerminalInput?.((data) => {
723
+ if (isShiftTab(data)) {
724
+ modeHolder.set(cyclePermissionMode(modeHolder.get()));
725
+ footerInvalidateHandle.requestRender();
726
+ return { consume: true };
727
+ }
728
+ return undefined;
729
+ });
699
730
  // Label the collapsed chain-of-thought line so users know it is reasoning
700
731
  // and how to reveal the full trace. Harmless when reasoning is expanded
701
732
  // (the label only shows on hidden thinking blocks). Fails closed to pi's
@@ -769,6 +800,38 @@ export async function registerYagni(pi, deps = {}) {
769
800
  // The init pass is best-effort; a failure never blocks the session.
770
801
  }
771
802
  }
803
+ // Run 7: the opt-in repo seeding beat. Fires only on a genuine startup
804
+ // when /context reported an explicitly-empty repo-scoped ledger, once per
805
+ // repo (marker store). Fully fail-soft — a beat failure never blocks the
806
+ // session.
807
+ if (event.reason === "startup" && !evalMode) {
808
+ try {
809
+ const offerBeat = deps.offerMiningBeat ?? defaultMaybeOfferMiningBeat;
810
+ const markers = deps.mineBeatMarkers ?? fileMineBeatMarkers(codeStateHome(null, env));
811
+ await offerBeat(ctx, {
812
+ repoFullName: sessionRepo,
813
+ repoDecisionCount: briefResult?.repoDecisionCount,
814
+ cwd: process.cwd(),
815
+ baseUrl,
816
+ getToken: getTokenFn,
817
+ fetchImpl: authedFetch,
818
+ markers,
819
+ });
820
+ }
821
+ catch {
822
+ // Seeding is best-effort; a failure never blocks the session.
823
+ }
824
+ }
825
+ });
826
+ pi.on("tool_result", (event) => {
827
+ if (event.toolName !== "bash")
828
+ return;
829
+ const command = typeof event.input?.command === "string"
830
+ ? event.input.command
831
+ : "";
832
+ if (GIT_MUTATING_PATTERN.test(command)) {
833
+ footerInvalidateHandle.invalidateGit();
834
+ }
772
835
  });
773
836
  }
774
837
  export default async function (pi) {
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The opt-in repo seeding beat (org plane, Run 7).
3
+ *
4
+ * On the first interactive session in a repo whose repo-scoped decision
5
+ * ledger is empty (`/context?repo=` reports `repoDecisionCount: 0`), YAGNI
6
+ * Code offers — once — to read the repo's own decision record and seed the
7
+ * ledger: "Read this repo's ADRs and docs and seed the decision ledger?".
8
+ * One keystroke, then the backend extracts, dedupes, and banks at most 12
9
+ * `repo_mined` asserted rows, and the beat prints the banked count plus the
10
+ * Library review deep link.
11
+ *
12
+ * Never automatic, never a nag: a decline writes a per-repo marker so the
13
+ * beat does not recur; an accepted pass marks the repo only after the server
14
+ * confirms (a transport failure leaves the offer available next session).
15
+ * The corpus is collected at git HEAD (same echo-chamber guard as
16
+ * repoDocs.ts) — ADR directories, agent-instruction root docs, top-level
17
+ * docs/*.md, and recent merge subjects — bounded client-side to the same
18
+ * limits the backend enforces.
19
+ */
20
+ /**
21
+ * Client-side bounds on the mining corpus — mirror of the backend's
22
+ * MINE_INPUT_LIMITS so nothing is silently truncated server-side.
23
+ */
24
+ export declare const MINE_BEAT_LIMITS: {
25
+ readonly maxDocs: 40;
26
+ readonly maxPathChars: 300;
27
+ readonly maxExcerptChars: 8000;
28
+ readonly maxCommitSubjects: 200;
29
+ readonly maxSubjectChars: 200;
30
+ };
31
+ /** One document offered to the mining pass. */
32
+ export interface MineBeatDoc {
33
+ path: string;
34
+ excerpt: string;
35
+ }
36
+ /** Injectable git seam (default: execFileSync; every call fail-soft). */
37
+ export interface MineBeatGit {
38
+ /** `owner/name` from the origin remote, or undefined outside a repo. */
39
+ repoFullName: (cwd: string) => string | undefined;
40
+ /** Tracked paths at HEAD, or null when cwd is not a usable git tree. */
41
+ listAtHead: (cwd: string) => string[] | null;
42
+ /** One file's HEAD content, or null when unreadable. */
43
+ readAtHead: (cwd: string, rel: string) => string | null;
44
+ /** Recent merge-commit subjects (fallback: plain subjects on squash repos). */
45
+ mergeSubjects: (cwd: string, max: number) => string[];
46
+ }
47
+ /** Parse `owner/name` from an origin remote URL (ssh or https). */
48
+ export declare function parseRepoFullName(remote: string | null | undefined): string | undefined;
49
+ export declare const defaultMineBeatGit: MineBeatGit;
50
+ /** Is this HEAD-tracked path part of the repo's decision record? */
51
+ export declare function isMineCandidate(rel: string): boolean;
52
+ /** Collect the HEAD-era mining corpus, bounded to MINE_BEAT_LIMITS. */
53
+ export declare function collectMineCorpus(cwd: string, gitImpl?: MineBeatGit): {
54
+ docs: MineBeatDoc[];
55
+ commitSubjects: string[];
56
+ };
57
+ /** Injectable marker-store seam (default: a JSON file per repo on disk). */
58
+ export interface MineBeatMarkers {
59
+ has: (repoFullName: string) => boolean;
60
+ write: (repoFullName: string, status: "declined" | "seeded") => void;
61
+ }
62
+ /** Filesystem path of one repo's beat marker under the state home. */
63
+ export declare function mineMarkerPath(stateHome: string, repoFullName: string): string;
64
+ export declare function fileMineBeatMarkers(stateHome: string): MineBeatMarkers;
65
+ /** The subset of the session-start ctx the beat needs. */
66
+ export interface MineBeatUi {
67
+ hasUI: boolean;
68
+ ui: {
69
+ confirm?: (title: string, message: string) => Promise<boolean>;
70
+ notify: (message: string, level: "info" | "error") => void;
71
+ };
72
+ }
73
+ export interface OfferMiningBeatOptions {
74
+ repoFullName: string | undefined;
75
+ /** From `/context?repo=`; the beat fires ONLY on an explicit 0. */
76
+ repoDecisionCount: number | undefined;
77
+ cwd: string;
78
+ baseUrl: string;
79
+ getToken: () => string | undefined;
80
+ fetchImpl?: typeof fetch;
81
+ markers: MineBeatMarkers;
82
+ gitImpl?: MineBeatGit;
83
+ collect?: typeof collectMineCorpus;
84
+ }
85
+ export interface MiningBeatOutcome {
86
+ offered: boolean;
87
+ accepted?: boolean;
88
+ banked?: number;
89
+ }
90
+ /**
91
+ * Offer (once per repo) and, on yes, run the seeding pass. Fail-soft
92
+ * throughout: any error surfaces as a notify at most, never a broken session.
93
+ */
94
+ export declare function maybeOfferMiningBeat(ctx: MineBeatUi, opts: OfferMiningBeatOptions): Promise<MiningBeatOutcome>;
95
+ //# sourceMappingURL=mineBeat.d.ts.map
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The opt-in repo seeding beat (org plane, Run 7).
3
+ *
4
+ * On the first interactive session in a repo whose repo-scoped decision
5
+ * ledger is empty (`/context?repo=` reports `repoDecisionCount: 0`), YAGNI
6
+ * Code offers — once — to read the repo's own decision record and seed the
7
+ * ledger: "Read this repo's ADRs and docs and seed the decision ledger?".
8
+ * One keystroke, then the backend extracts, dedupes, and banks at most 12
9
+ * `repo_mined` asserted rows, and the beat prints the banked count plus the
10
+ * Library review deep link.
11
+ *
12
+ * Never automatic, never a nag: a decline writes a per-repo marker so the
13
+ * beat does not recur; an accepted pass marks the repo only after the server
14
+ * confirms (a transport failure leaves the offer available next session).
15
+ * The corpus is collected at git HEAD (same echo-chamber guard as
16
+ * repoDocs.ts) — ADR directories, agent-instruction root docs, top-level
17
+ * docs/*.md, and recent merge subjects — bounded client-side to the same
18
+ * limits the backend enforces.
19
+ */
20
+ import { execFileSync } from "node:child_process";
21
+ import * as fs from "node:fs";
22
+ import { dirname, join } from "node:path";
23
+ import { METERED_POST_FETCH_POLICY, resilientFetch } from "./resilientFetch.js";
24
+ /**
25
+ * Client-side bounds on the mining corpus — mirror of the backend's
26
+ * MINE_INPUT_LIMITS so nothing is silently truncated server-side.
27
+ */
28
+ export const MINE_BEAT_LIMITS = {
29
+ maxDocs: 40,
30
+ maxPathChars: 300,
31
+ maxExcerptChars: 8_000,
32
+ maxCommitSubjects: 200,
33
+ maxSubjectChars: 200,
34
+ };
35
+ const GIT_OPTS = {
36
+ encoding: "utf8",
37
+ maxBuffer: 16 * 1024 * 1024,
38
+ stdio: ["ignore", "pipe", "ignore"],
39
+ };
40
+ function git(cwd, args) {
41
+ try {
42
+ return execFileSync("git", args, { cwd, ...GIT_OPTS });
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ /** Parse `owner/name` from an origin remote URL (ssh or https). */
49
+ export function parseRepoFullName(remote) {
50
+ if (!remote)
51
+ return undefined;
52
+ const m = remote.trim().match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
53
+ return m ? m[1] : undefined;
54
+ }
55
+ export const defaultMineBeatGit = {
56
+ repoFullName: (cwd) => parseRepoFullName(git(cwd, ["config", "--get", "remote.origin.url"])),
57
+ listAtHead: (cwd) => {
58
+ const out = git(cwd, ["ls-tree", "-r", "--name-only", "HEAD"]);
59
+ return out === null ? null : out.split("\n").filter(Boolean);
60
+ },
61
+ readAtHead: (cwd, rel) => git(cwd, ["show", `HEAD:${rel}`]),
62
+ mergeSubjects: (cwd, max) => {
63
+ const merges = git(cwd, ["log", "--merges", `-n`, String(max), "--pretty=%s"]);
64
+ const chosen = merges && merges.trim().length > 0
65
+ ? merges
66
+ : // Squash-merge repos have no merge commits; plain subjects still
67
+ // carry the shipped-work trail. No further heuristics (spec flag).
68
+ git(cwd, ["log", `-n`, String(max), "--pretty=%s"]);
69
+ return (chosen ?? "").split("\n").map((s) => s.trim()).filter(Boolean).slice(0, max);
70
+ },
71
+ };
72
+ /** Is this HEAD-tracked path part of the repo's decision record? */
73
+ export function isMineCandidate(rel) {
74
+ if (/^(?:docs\/)?adrs?\/[^/]+\.md$/i.test(rel))
75
+ return true;
76
+ if (/^(?:AGENTS|CLAUDE|CONTRIBUTING|CONTEXT|ARCHITECTURE)\.md$/i.test(rel))
77
+ return true;
78
+ if (/^docs\/[^/]+\.md$/i.test(rel))
79
+ return true;
80
+ return false;
81
+ }
82
+ /** Collect the HEAD-era mining corpus, bounded to MINE_BEAT_LIMITS. */
83
+ export function collectMineCorpus(cwd, gitImpl = defaultMineBeatGit) {
84
+ const tracked = gitImpl.listAtHead(cwd);
85
+ if (tracked === null)
86
+ return { docs: [], commitSubjects: [] };
87
+ const docs = [];
88
+ for (const rel of tracked) {
89
+ if (docs.length >= MINE_BEAT_LIMITS.maxDocs)
90
+ break;
91
+ if (rel.length > MINE_BEAT_LIMITS.maxPathChars)
92
+ continue;
93
+ if (!isMineCandidate(rel))
94
+ continue;
95
+ const body = gitImpl.readAtHead(cwd, rel);
96
+ if (body === null || body.trim().length === 0)
97
+ continue;
98
+ docs.push({ path: rel, excerpt: body.trim().slice(0, MINE_BEAT_LIMITS.maxExcerptChars) });
99
+ }
100
+ const commitSubjects = gitImpl
101
+ .mergeSubjects(cwd, MINE_BEAT_LIMITS.maxCommitSubjects)
102
+ .map((s) => s.slice(0, MINE_BEAT_LIMITS.maxSubjectChars));
103
+ return { docs, commitSubjects };
104
+ }
105
+ /** Filesystem path of one repo's beat marker under the state home. */
106
+ export function mineMarkerPath(stateHome, repoFullName) {
107
+ return join(stateHome, "mine-beat", `${repoFullName.replace(/[/\\]/g, "__")}.json`);
108
+ }
109
+ export function fileMineBeatMarkers(stateHome) {
110
+ return {
111
+ has: (repo) => {
112
+ try {
113
+ return fs.existsSync(mineMarkerPath(stateHome, repo));
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ },
119
+ write: (repo, status) => {
120
+ try {
121
+ const p = mineMarkerPath(stateHome, repo);
122
+ fs.mkdirSync(dirname(p), { recursive: true });
123
+ fs.writeFileSync(p, JSON.stringify({ status, at: new Date().toISOString() }), "utf8");
124
+ }
125
+ catch {
126
+ // A marker failure must never break the session; worst case the beat
127
+ // asks once more next launch.
128
+ }
129
+ },
130
+ };
131
+ }
132
+ /**
133
+ * Offer (once per repo) and, on yes, run the seeding pass. Fail-soft
134
+ * throughout: any error surfaces as a notify at most, never a broken session.
135
+ */
136
+ export async function maybeOfferMiningBeat(ctx, opts) {
137
+ const { repoFullName } = opts;
138
+ // Only an EXPLICIT zero fires the beat: an absent count means an older
139
+ // backend or a count failure, and a nag on either would be wrong.
140
+ if (!repoFullName || opts.repoDecisionCount !== 0)
141
+ return { offered: false };
142
+ if (opts.markers.has(repoFullName))
143
+ return { offered: false };
144
+ if (!ctx.hasUI || typeof ctx.ui.confirm !== "function")
145
+ return { offered: false };
146
+ const collect = opts.collect ?? collectMineCorpus;
147
+ const corpus = collect(opts.cwd, opts.gitImpl ?? defaultMineBeatGit);
148
+ // Nothing to mine yet: skip WITHOUT a marker, so a later checkout that
149
+ // gains docs still gets its one offer.
150
+ if (corpus.docs.length === 0 && corpus.commitSubjects.length === 0) {
151
+ return { offered: false };
152
+ }
153
+ const accepted = await ctx.ui.confirm("Seed the decision ledger?", `Read this repo's ADRs and docs and bank up to 12 candidate decisions for ${repoFullName}? You review every one in the Library before it counts as confirmed.`);
154
+ if (!accepted) {
155
+ opts.markers.write(repoFullName, "declined");
156
+ return { offered: true, accepted: false };
157
+ }
158
+ try {
159
+ const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/decisions/mine`, {
160
+ method: "POST",
161
+ headers: {
162
+ authorization: `Bearer ${opts.getToken() ?? ""}`,
163
+ "content-type": "application/json",
164
+ },
165
+ body: JSON.stringify({
166
+ repo: repoFullName,
167
+ docs: corpus.docs,
168
+ commitSubjects: corpus.commitSubjects,
169
+ // Stable per repo: the write-spool replay contract makes a retry of
170
+ // the same pass idempotent server-side.
171
+ idempotencyKey: `mine:${repoFullName}`,
172
+ }),
173
+ }, { fetchImpl: opts.fetchImpl, policy: METERED_POST_FETCH_POLICY });
174
+ if (!res.ok) {
175
+ // No marker: the offer stays available next session.
176
+ ctx.ui.notify("Seeding the decision ledger failed; YAGNI Code will offer again next session.", "error");
177
+ return { offered: true, accepted: true };
178
+ }
179
+ const body = (await res.json());
180
+ opts.markers.write(repoFullName, "seeded");
181
+ if (body.alreadyMined)
182
+ return { offered: true, accepted: true, banked: 0 };
183
+ const banked = typeof body.banked === "number" ? body.banked : 0;
184
+ const noun = banked === 1 ? "decision" : "decisions";
185
+ ctx.ui.notify(`Banked ${banked} ${noun} read from this repo's docs. Review them: ${opts.baseUrl}/knowledge/decisions`, "info");
186
+ return { offered: true, accepted: true, banked };
187
+ }
188
+ catch {
189
+ ctx.ui.notify("Seeding the decision ledger failed; YAGNI Code will offer again next session.", "error");
190
+ return { offered: true, accepted: true };
191
+ }
192
+ }
193
+ //# sourceMappingURL=mineBeat.js.map
@@ -41,6 +41,7 @@ export type PermissionMode = "auto" | "plan" | "review";
41
41
  export interface ModeHolder {
42
42
  get(): PermissionMode;
43
43
  set(m: PermissionMode): void;
44
+ onSet(fn: (m: PermissionMode) => void): void;
44
45
  }
45
46
  export declare function createModeHolder(initial?: PermissionMode): ModeHolder;
46
47
  /** Which tools each tier acts on, plus the optional grounding-bless predicate. */
@@ -33,9 +33,15 @@ import { isDebug } from "./diagnostics.js";
33
33
  import { buildDiagnosticEvent, checkCircuitBreaker, DEFAULT_GUARDIAN_LIMITS, } from "./guardian.js";
34
34
  export function createModeHolder(initial = "auto") {
35
35
  let current = initial;
36
+ const listeners = new Set();
36
37
  return {
37
38
  get: () => current,
38
- set: (m) => { current = m; },
39
+ set: (m) => {
40
+ current = m;
41
+ for (const fn of listeners)
42
+ fn(m);
43
+ },
44
+ onSet: (fn) => { listeners.add(fn); },
39
45
  };
40
46
  }
41
47
  export const DEFAULT_PERMISSION_POLICY = {
@@ -199,11 +205,6 @@ export function filterStaleModeContext(messages, currentMode) {
199
205
  }
200
206
  /** Legacy alias — the original plan-mode filter name. */
201
207
  export const filterStalePlanContext = filterStaleModeContext;
202
- const MODE_STATUS = {
203
- auto: undefined,
204
- plan: "⏸ plan",
205
- review: "✓ review",
206
- };
207
208
  const MODE_COPY = {
208
209
  auto: "auto: coding changes apply directly; external tracker changes ask first (default).",
209
210
  plan: "plan: write, edit, and bash are held so the agent can explore and propose only.",
@@ -257,6 +258,11 @@ export function registerPermissionGate(pi, deps = {}) {
257
258
  const basePolicy = deps.policy ?? DEFAULT_PERMISSION_POLICY;
258
259
  let mode = deps.mode ?? "auto";
259
260
  const makeStore = deps.makeBlessStore ?? defaultMakeBlessStore;
261
+ deps.modeHolder?.onSet((m) => {
262
+ if (m !== mode)
263
+ approvedCommands.clear();
264
+ mode = m;
265
+ });
260
266
  // The session bless store is created lazily on the first tool_call (it needs
261
267
  // the cwd). Its isBlessed backs the review-mode auto-approve, UNLESS the caller
262
268
  // injected its own isBlessed (e.g. a test policy) — that always wins.
@@ -453,7 +459,7 @@ export function registerPermissionGate(pi, deps = {}) {
453
459
  }
454
460
  // Show the reviewing chip.
455
461
  if (ctx?.hasUI)
456
- ctx.ui.setStatus?.("yagni-guardian", "🛡 reviewing");
462
+ ctx.ui.setStatus?.("yagni-guardian", "Guardian Reviewing");
457
463
  const startMs = Date.now();
458
464
  let reviewResult;
459
465
  try {
@@ -746,13 +752,6 @@ export function registerPermissionGate(pi, deps = {}) {
746
752
  });
747
753
  const paintMode = (ctx) => {
748
754
  deps.modeHolder?.set(mode);
749
- try {
750
- if (ctx.hasUI)
751
- ctx.ui.setStatus?.("yagni-mode", MODE_STATUS[mode]);
752
- }
753
- catch {
754
- // The chip is chrome; never let it break /mode.
755
- }
756
755
  };
757
756
  pi.registerCommand("mode", {
758
757
  description: "Set the permission tier: /mode auto | plan | review. No argument shows the current mode.",
@@ -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
 
@@ -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 },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "0.3.0-staging.1096.1",
3
+ "version": "0.3.0-staging.1099.1",
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)",
@@ -39,5 +39,5 @@
39
39
  "smol-toml": "^1.8.0",
40
40
  "typebox": "^1.3.11"
41
41
  },
42
- "yagniSourceSha": "04ba3799e7c6310f337b80067ba44ee257d69df3"
42
+ "yagniSourceSha": "c94b255f76f60efa95f4c2c016b1c533bbeae49f"
43
43
  }