@yagni-app/code-staging 0.3.0-staging.1096.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,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
@@ -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) {
@@ -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
@@ -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.1098.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": "41fc21aacfab499d38635d4dad5bace90249b438"
43
43
  }