@yagni-app/code-staging 0.3.0-staging.1093.1 → 0.3.0-staging.1098.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,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() ?? ""}`,
@@ -35,7 +35,18 @@
35
35
  * bundler), and external dependencies aren't resolvable from the bundled path.
36
36
  */
37
37
  export type TokenEntry = string | {
38
- op: "pipe" | "and" | "or" | "semi" | "redirect" | "substitution";
38
+ op: "pipe" | "and" | "or" | "semi" | "substitution";
39
+ } | {
40
+ op: "redirect";
41
+ direction: "out";
42
+ fd: "stdout" | "stderr";
43
+ target: string;
44
+ append: boolean;
45
+ } | {
46
+ op: "redirect";
47
+ direction: "in";
48
+ } | {
49
+ op: "background";
39
50
  };
40
51
  /**
41
52
  * Parse a shell command string into tokens and control operators.
@@ -48,6 +59,11 @@ export type TokenEntry = string | {
48
59
  * - `#` comments (start-of-word to end-of-line, outside quotes)
49
60
  * - Shell constructs we flag as unanalyzable: $(), backticks (INCLUDING
50
61
  * inside double quotes — bash executes those), >, <, background &
62
+ * - Redirect metadata: stdout/stderr redirects carry fd + target so that
63
+ * safe redirects (2>/dev/null, 2>&1) can be distinguished from unsafe ones
64
+ * (> file.txt). Stdin redirects (<, <<) carry no metadata — they always
65
+ * floor. Background & emits a distinct `background` op (not `semi`) so
66
+ * hasUnhandledConstructs can always catch it.
51
67
  *
52
68
  * Does NOT handle: variable expansion, glob patterns, heredocs beyond the
53
69
  * redirect flag, nested subshells beyond depth tracking. Commands using
@@ -45,6 +45,11 @@
45
45
  * - `#` comments (start-of-word to end-of-line, outside quotes)
46
46
  * - Shell constructs we flag as unanalyzable: $(), backticks (INCLUDING
47
47
  * inside double quotes — bash executes those), >, <, background &
48
+ * - Redirect metadata: stdout/stderr redirects carry fd + target so that
49
+ * safe redirects (2>/dev/null, 2>&1) can be distinguished from unsafe ones
50
+ * (> file.txt). Stdin redirects (<, <<) carry no metadata — they always
51
+ * floor. Background & emits a distinct `background` op (not `semi`) so
52
+ * hasUnhandledConstructs can always catch it.
48
53
  *
49
54
  * Does NOT handle: variable expansion, glob patterns, heredocs beyond the
50
55
  * redirect flag, nested subshells beyond depth tracking. Commands using
@@ -149,11 +154,13 @@ export function shellParse(command) {
149
154
  i += 2;
150
155
  }
151
156
  else {
152
- // Single & — background operator. A construct (floor: prompt), but
153
- // the command before it must still be rule-matched: `rm -rf / &`
154
- // has to stay forbidden, so emit a separator rather than gluing.
157
+ // Single & — background operator. Emits a distinct `background` op
158
+ // (not `semi`) so hasUnhandledConstructs can always catch it and
159
+ // floor the command. The command before it must still be rule-
160
+ // matched: `rm -rf / &` has to stay forbidden, so emit a separator
161
+ // rather than gluing.
155
162
  pushCurrent();
156
- tokens.push({ op: "semi" });
163
+ tokens.push({ op: "background" });
157
164
  hasConstruct = true;
158
165
  i++;
159
166
  }
@@ -163,18 +170,124 @@ export function shellParse(command) {
163
170
  tokens.push({ op: "semi" });
164
171
  i++;
165
172
  continue;
166
- case ">":
167
- case "<":
173
+ case ">": {
168
174
  pushCurrent();
169
- tokens.push({ op: "redirect" });
175
+ // Check for a preceding fd digit: `2>` → stderr, `1>` → stdout.
176
+ // The digit was emitted as a string token — pop it and use as fd.
177
+ let fd = "stdout";
178
+ if (tokens.length > 0 && typeof tokens[tokens.length - 1] === "string") {
179
+ const last = tokens[tokens.length - 1];
180
+ if (last === "2") {
181
+ fd = "stderr";
182
+ tokens.pop();
183
+ }
184
+ else if (last === "1") {
185
+ fd = "stdout";
186
+ tokens.pop();
187
+ }
188
+ }
189
+ let append = false;
190
+ i++;
191
+ if (command[i] === ">") {
192
+ append = true;
193
+ i++;
194
+ }
195
+ while (command[i] === " " || command[i] === "\t")
196
+ i++;
197
+ // Read the target — handles quoted targets (mirrors the main loop's
198
+ // quote logic), fd merges (&N), and bare words.
199
+ let target = "";
200
+ if (command[i] === "&") {
201
+ // fd merge: &1, &2, etc.
202
+ i++;
203
+ let digits = "";
204
+ while (command[i] >= "0" && command[i] <= "9") {
205
+ digits += command[i];
206
+ i++;
207
+ }
208
+ target = "&" + digits;
209
+ }
210
+ else if (command[i] === "'") {
211
+ i++;
212
+ while (i < command.length && command[i] !== "'") {
213
+ target += command[i];
214
+ i++;
215
+ }
216
+ if (i < command.length)
217
+ i++;
218
+ }
219
+ else if (command[i] === '"') {
220
+ i++;
221
+ while (i < command.length && command[i] !== '"') {
222
+ if (command[i] === "\\" && i + 1 < command.length) {
223
+ target += command[i + 1];
224
+ i += 2;
225
+ }
226
+ else {
227
+ target += command[i];
228
+ i++;
229
+ }
230
+ }
231
+ if (i < command.length)
232
+ i++;
233
+ }
234
+ else {
235
+ while (i < command.length &&
236
+ command[i] !== " " && command[i] !== "\t" &&
237
+ command[i] !== "\n" && command[i] !== "\r" &&
238
+ command[i] !== "|" && command[i] !== "&" &&
239
+ command[i] !== ";" && command[i] !== ">" &&
240
+ command[i] !== "<") {
241
+ target += command[i];
242
+ i++;
243
+ }
244
+ }
245
+ tokens.push({ op: "redirect", direction: "out", fd, target, append });
170
246
  hasConstruct = true;
171
- // Skip the operator char(s) and any following space
247
+ continue;
248
+ }
249
+ case "<": {
250
+ pushCurrent();
251
+ // Stdin redirect — always floors (changes program behavior).
172
252
  i++;
173
- if (command[i] === ch)
174
- i++; // >> or <<
253
+ if (command[i] === "<")
254
+ i++; // << heredoc — skip delimiter
175
255
  while (command[i] === " " || command[i] === "\t")
176
256
  i++;
257
+ // Consume the target (filename or heredoc delimiter) so it doesn't
258
+ // appear as a segment token — mirrors the > case's target reading.
259
+ if (command[i] === "'") {
260
+ i++;
261
+ while (i < command.length && command[i] !== "'")
262
+ i++;
263
+ if (i < command.length)
264
+ i++;
265
+ }
266
+ else if (command[i] === '"') {
267
+ i++;
268
+ while (i < command.length && command[i] !== '"') {
269
+ if (command[i] === "\\" && i + 1 < command.length)
270
+ i += 2;
271
+ else
272
+ i++;
273
+ }
274
+ if (i < command.length)
275
+ i++;
276
+ }
277
+ else {
278
+ while (i < command.length &&
279
+ command[i] !== " " && command[i] !== "\t" &&
280
+ command[i] !== "\n" && command[i] !== "\r" &&
281
+ command[i] !== "|" && command[i] !== "&" &&
282
+ command[i] !== ";" && command[i] !== ">" &&
283
+ command[i] !== "<") {
284
+ i++;
285
+ }
286
+ }
287
+ tokens.push({ op: "redirect", direction: "in" });
288
+ hasConstruct = true;
177
289
  continue;
290
+ }
178
291
  case "$":
179
292
  if (command[i + 1] === "(") {
180
293
  pushCurrent();
@@ -213,9 +326,10 @@ export function shellParse(command) {
213
326
  }
214
327
  pushCurrent();
215
328
  // If we detected constructs but didn't emit them as operator tokens
216
- // (e.g. background & or double-quoted substitution), surface that via a
217
- // trailing substitution token so hasUnhandledConstructs sees it.
218
- if (hasConstruct && !tokens.some((t) => typeof t === "object" && (t.op === "redirect" || t.op === "substitution"))) {
329
+ // (e.g. double-quoted substitution), surface that via a trailing
330
+ // substitution token so hasUnhandledConstructs sees it. Background & now
331
+ // emits its own distinct op, so it no longer relies on this fallback.
332
+ if (hasConstruct && !tokens.some((t) => typeof t === "object" && (t.op === "redirect" || t.op === "substitution" || t.op === "background"))) {
219
333
  tokens.push({ op: "substitution" });
220
334
  }
221
335
  return tokens;
@@ -294,47 +408,58 @@ const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
294
408
  export function tokenize(command) {
295
409
  return shellParse(command).filter((t) => typeof t === "string");
296
410
  }
297
- /** Operator tokens we can safely split on (compound command segments). */
298
- const SPLIT_OPS = new Set(["pipe", "and", "or", "semi"]);
411
+ /** Ops that split compound commands into segments (pipe, &&, ||, ;, background &). */
412
+ const SPLIT_OPS = new Set(["pipe", "and", "or", "semi", "background"]);
413
+ /** Ops that are safe splittable separators — they do NOT trigger the construct floor.
414
+ * Background & is in SPLIT_OPS (splits segments) but NOT here (always floors). */
415
+ const SAFE_SPLIT_OPS = new Set(["pipe", "and", "or", "semi"]);
416
+ /** A redirect to /dev/null (discard) or &N (fd merge) is safe — no file created. */
417
+ function isSafeRedirect(t) {
418
+ if (typeof t === "string")
419
+ return false;
420
+ if (t.op !== "redirect")
421
+ return false;
422
+ if (t.direction === "in")
423
+ return false; // stdin redirect — always floor
424
+ if (t.target === "/dev/null")
425
+ return true; // discard stderr/stdout — safe
426
+ if (t.target.startsWith("&") && t.target.length > 1)
427
+ return true; // fd merge (2>&1, 1>&2) — safe; bare "&" (>& with no digit) is NOT safe
428
+ return false; // > file.txt, >> file.txt — unsafe
429
+ }
299
430
  /**
300
431
  * Detect whether the command uses shell constructs we can't statically
301
- * classify (command substitution, redirects, background &) — anything that
302
- * is NOT a splittable operator. These impose a floor of `prompt`: a command
303
- * carrying them is never auto-allowed, but forbidden matches still win.
432
+ * classify (command substitution, unsafe redirects, background &) — anything
433
+ * that is NOT a safe splittable operator or a safe redirect. These impose a
434
+ * floor of `prompt`: a command carrying them is never auto-allowed, but
435
+ * forbidden matches still win.
304
436
  */
305
437
  function hasUnhandledConstructs(command) {
306
- return shellParse(command).some((t) => typeof t === "object" && "op" in t && !SPLIT_OPS.has(t.op));
438
+ return shellParse(command).some((t) => typeof t === "object" && !SAFE_SPLIT_OPS.has(t.op) && !isSafeRedirect(t));
307
439
  }
308
440
  /**
309
441
  * Split a command into token-array segments at control operators (|, &&, ||,
310
- * ;, newline). Redirect targets (the token after > or <) are dropped from the
311
- * segment — they are filenames, not arguments to rule-match. Token arrays are
312
- * carried through (never re-joined into strings) so quoting survives.
442
+ * ;, newline, background &). Redirect targets are consumed inside the
443
+ * tokenizer's > / < cases (stored on the redirect op), so no skipNext logic
444
+ * is needed. Token arrays are carried through (never re-joined into strings)
445
+ * so quoting survives.
313
446
  */
314
447
  function splitSegmentsTokens(command) {
315
448
  const parsed = shellParse(command);
316
449
  const segments = [];
317
450
  let current = [];
318
- let skipNext = false;
319
451
  for (const t of parsed) {
320
452
  if (typeof t === "object") {
321
453
  if (SPLIT_OPS.has(t.op)) {
322
454
  if (current.length > 0)
323
455
  segments.push(current);
324
456
  current = [];
325
- skipNext = false;
326
- }
327
- else if (t.op === "redirect") {
328
- skipNext = true;
329
457
  }
330
- // substitution ops are construct markers; the inner text is handled
331
- // by dangerScan via extractSubstitutions.
458
+ // redirect and substitution ops are construct markers; their targets
459
+ // are consumed inside the tokenizer, and the inner text of $(...) is
460
+ // handled by dangerScan via extractSubstitutions.
332
461
  }
333
462
  else {
334
- if (skipNext) {
335
- skipNext = false;
336
- continue;
337
- }
338
463
  current.push(t);
339
464
  }
340
465
  }
@@ -510,6 +635,9 @@ function classifySegmentTokens(rawTokens, policy, opts) {
510
635
  const tailResult = classifySegmentTokens(tail, policy, { ...opts, depth: opts.depth + 1 });
511
636
  if (tailResult.decision === "forbidden")
512
637
  return tailResult;
638
+ if (tailResult.decision === "allow" && !neverAllow) {
639
+ return { decision: "allow", justification: "xargs forwards to a read-only command" };
640
+ }
513
641
  }
514
642
  if (opts.forbiddenOnly)
515
643
  return { decision: "allow", justification: "no forbidden match" };
@@ -727,6 +855,7 @@ export const DEFAULT_EXEC_POLICY = {
727
855
  { pattern: ["jq"], decision: "allow", justification: "filter JSON to stdout" },
728
856
  { pattern: ["stat"], decision: "allow", justification: "show file metadata" },
729
857
  { pattern: ["file"], decision: "allow", justification: "identify file type" },
858
+ { pattern: ["strings"], decision: "allow", justification: "extract printable strings from binary files (read-only)" },
730
859
  { pattern: ["basename"], decision: "allow", justification: "strip directory from path" },
731
860
  { pattern: ["dirname"], decision: "allow", justification: "extract directory from path" },
732
861
  { pattern: ["realpath"], decision: "allow", justification: "resolve a path" },
@@ -760,7 +889,9 @@ export const DEFAULT_EXEC_POLICY = {
760
889
  { pattern: ["node", "--version"], decision: "allow", justification: "check node version" },
761
890
  { pattern: ["node", "-v"], decision: "allow", justification: "check node version" },
762
891
  { pattern: ["npm", "ls"], decision: "allow", justification: "list installed packages" },
892
+ { pattern: ["npm", "list"], decision: "allow", justification: "list installed packages" },
763
893
  { pattern: ["pnpm", "ls"], decision: "allow", justification: "list installed packages" },
894
+ { pattern: ["pnpm", "list"], decision: "allow", justification: "list installed packages" },
764
895
  { pattern: ["pnpm", "--version"], decision: "allow", justification: "check pnpm version" },
765
896
  { pattern: ["tsc", "--version"], decision: "allow", justification: "check typescript version" },
766
897
  // --- prompt: potentially destructive but context-dependent ---
@@ -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
@@ -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
@@ -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
@@ -460,11 +466,27 @@ export async function registerYagni(pi, deps = {}) {
460
466
  // must never block the agent), then inject it into the system prompt so the
461
467
  // very first turn already knows how this company and codebase work.
462
468
  const fetchBrief = deps.fetchContextBrief ?? defaultFetchContextBrief;
469
+ // Resolved BEFORE the brief fetch so /context can answer repoDecisionCount
470
+ // in the same round-trip (the mining beat's signal). Best-effort: undefined
471
+ // outside a git repo or without an origin remote.
472
+ const resolveRepoFn = deps.resolveRepoFullName ?? ((cwd) => defaultMineBeatGit.repoFullName(cwd));
473
+ let sessionRepo;
474
+ try {
475
+ sessionRepo = evalMode ? undefined : resolveRepoFn(process.cwd());
476
+ }
477
+ catch {
478
+ sessionRepo = undefined;
479
+ }
463
480
  let contextBrief;
464
481
  let briefResult = null;
465
482
  if (!evalMode) {
466
483
  try {
467
- briefResult = await fetchBrief({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
484
+ briefResult = await fetchBrief({
485
+ baseUrl,
486
+ getToken: getTokenFn,
487
+ fetchImpl: authedFetch,
488
+ repo: sessionRepo,
489
+ });
468
490
  contextBrief = briefResult?.brief?.trim() ? briefResult.brief : undefined;
469
491
  }
470
492
  catch {
@@ -769,6 +791,28 @@ export async function registerYagni(pi, deps = {}) {
769
791
  // The init pass is best-effort; a failure never blocks the session.
770
792
  }
771
793
  }
794
+ // Run 7: the opt-in repo seeding beat. Fires only on a genuine startup
795
+ // when /context reported an explicitly-empty repo-scoped ledger, once per
796
+ // repo (marker store). Fully fail-soft — a beat failure never blocks the
797
+ // session.
798
+ if (event.reason === "startup" && !evalMode) {
799
+ try {
800
+ const offerBeat = deps.offerMiningBeat ?? defaultMaybeOfferMiningBeat;
801
+ const markers = deps.mineBeatMarkers ?? fileMineBeatMarkers(codeStateHome(null, env));
802
+ await offerBeat(ctx, {
803
+ repoFullName: sessionRepo,
804
+ repoDecisionCount: briefResult?.repoDecisionCount,
805
+ cwd: process.cwd(),
806
+ baseUrl,
807
+ getToken: getTokenFn,
808
+ fetchImpl: authedFetch,
809
+ markers,
810
+ });
811
+ }
812
+ catch {
813
+ // Seeding is best-effort; a failure never blocks the session.
814
+ }
815
+ }
772
816
  });
773
817
  }
774
818
  export default async function (pi) {