@yagni-app/code 0.3.0 → 0.3.2

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.
Files changed (56) hide show
  1. package/dist/cli.js +12 -0
  2. package/dist/connectClaudeCode.d.ts +77 -0
  3. package/dist/connectClaudeCode.js +228 -0
  4. package/dist/connectCodex.d.ts +75 -0
  5. package/dist/connectCodex.js +201 -0
  6. package/dist/crashReport.d.ts +12 -0
  7. package/dist/crashReport.js +28 -1
  8. package/dist/extension/approvedPrefixes.d.ts +11 -0
  9. package/dist/extension/approvedPrefixes.js +30 -0
  10. package/dist/extension/askAdvisorTool.d.ts +18 -3
  11. package/dist/extension/askAdvisorTool.js +121 -15
  12. package/dist/extension/askYagniTool.d.ts +23 -0
  13. package/dist/extension/askYagniTool.js +42 -2
  14. package/dist/extension/branding.d.ts +11 -1
  15. package/dist/extension/branding.js +47 -7
  16. package/dist/extension/config.d.ts +12 -0
  17. package/dist/extension/config.js +2 -1
  18. package/dist/extension/crashReport.d.ts +18 -0
  19. package/dist/extension/crashReport.js +35 -2
  20. package/dist/extension/execPolicy.d.ts +17 -1
  21. package/dist/extension/execPolicy.js +227 -33
  22. package/dist/extension/flywheel.d.ts +44 -0
  23. package/dist/extension/flywheel.js +53 -0
  24. package/dist/extension/footer.d.ts +8 -1
  25. package/dist/extension/footer.js +33 -19
  26. package/dist/extension/guardian.d.ts +14 -4
  27. package/dist/extension/guardian.js +35 -11
  28. package/dist/extension/index.d.ts +20 -3
  29. package/dist/extension/index.js +92 -13
  30. package/dist/extension/mineBeat.d.ts +95 -0
  31. package/dist/extension/mineBeat.js +193 -0
  32. package/dist/extension/permission.d.ts +2 -1
  33. package/dist/extension/permission.js +75 -22
  34. package/dist/extension/pipeline/goCommand.js +6 -4
  35. package/dist/extension/pipeline/invocation.d.ts +24 -2
  36. package/dist/extension/pipeline/invocation.js +30 -2
  37. package/dist/extension/pipeline/personas.js +2 -2
  38. package/dist/extension/pipeline/resilience.d.ts +2 -1
  39. package/dist/extension/pipeline/resilience.js +21 -2
  40. package/dist/extension/pipeline/runRegistry.d.ts +9 -1
  41. package/dist/extension/pipeline/runRegistry.js +22 -1
  42. package/dist/extension/recordDecisionTool.d.ts +8 -0
  43. package/dist/extension/recordDecisionTool.js +24 -0
  44. package/dist/extension/subagents.d.ts +7 -1
  45. package/dist/extension/subagents.js +73 -5
  46. package/dist/extension/todos.d.ts +28 -1
  47. package/dist/extension/todos.js +76 -1
  48. package/dist/extension/ultra.d.ts +27 -0
  49. package/dist/extension/ultra.js +76 -0
  50. package/dist/login.d.ts +4 -2
  51. package/dist/login.js +19 -4
  52. package/dist/promptEnrichment.d.ts +1 -1
  53. package/dist/promptEnrichment.js +1 -1
  54. package/dist/token.d.ts +25 -0
  55. package/dist/token.js +45 -0
  56. package/package.json +3 -2
@@ -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. */
@@ -183,7 +184,7 @@ export interface RegisterPermissionDeps {
183
184
  export declare const MODE_CONTEXT_TYPE = "yagni-mode-context";
184
185
  /** Legacy alias — the original plan-mode tag, kept for backward compat. */
185
186
  export declare const PLAN_CONTEXT_TYPE = "yagni-mode-context";
186
- export declare const PLAN_CONTEXT_MESSAGE = "[PLAN MODE ACTIVE]\nYou are in plan mode: explore and design, change nothing.\n- write, edit, and bash are held by the permission gate; do not attempt them.\n- Read, search, and ask_yagni freely to ground the plan in how this company works.\n- Produce a concrete numbered plan of the steps you would take, with the files involved.\n- End by asking the user to review the plan; they run /mode auto (or /mode review) to execute it.\n- Once executing, track the plan's steps with todo_write.";
187
+ export declare const PLAN_CONTEXT_MESSAGE = "[PLAN MODE ACTIVE]\nYou are in plan mode: explore and design, change nothing.\n- Read-only bash commands (ls, grep, git status, gh pr view, etc.) run freely to help you explore.\n- Ambiguous bash commands are reviewed by the Guardian; if non-mutating they run, if potentially mutating you will be asked.\n- write, edit, file_ticket, and update_ticket_status are held by the permission gate; do not attempt them.\n- Read, search, and ask_yagni freely to ground the plan in how this company works.\n- Produce a concrete numbered plan of the steps you would take, with the files involved.\n- End by asking the user to review the plan; they run /mode auto (or /mode review) to execute it.\n- Once executing, track the plan's steps with todo_write.";
187
188
  /** Build the mode-awareness context message for the current permission mode. */
188
189
  export declare function buildModeContextMessage(mode: PermissionMode): string;
189
190
  /**
@@ -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 = {
@@ -50,6 +56,42 @@ export const DEFAULT_PERMISSION_POLICY = {
50
56
  */
51
57
  export function decideGate(toolName, params, mode, policy) {
52
58
  if (mode === "plan") {
59
+ // Non-bash tools in planBlockTools are held outright — they are
60
+ // inherently mutating (write, edit, file_ticket, update_ticket_status).
61
+ // Bash is the exploration tool: run it through the exec policy so
62
+ // read-only commands (git status, ls, grep, gh pr view) work, and
63
+ // prompt-band commands are routed to the Guardian. The gate handler
64
+ // ensures Guardian-unavailable/capped/disabled states fail closed.
65
+ if (toolName === "bash") {
66
+ const command = typeof params.command === "string" ? params.command.trim() : "";
67
+ if (command) {
68
+ try {
69
+ const execPolicy = policy.execPolicy ?? DEFAULT_EXEC_POLICY;
70
+ const classification = classifyCommand(command, execPolicy);
71
+ if (classification.decision === "allow")
72
+ return { block: false };
73
+ if (classification.decision === "forbidden") {
74
+ return {
75
+ block: true,
76
+ reason: `${classification.justification}. Do not attempt the same outcome via a workaround or indirect execution — use a materially safer alternative, or ask the user.`,
77
+ };
78
+ }
79
+ // prompt — Guardian reviews. The gate handler runs the Guardian
80
+ // and handles allow/ask/deny. Grants and cache are skipped in
81
+ // plan mode (they can cover writes). Guardian unavailable/capped/
82
+ // disabled → block (fail closed).
83
+ return { block: false, classify: "prompt", classifyJustification: classification.justification };
84
+ }
85
+ catch {
86
+ // classifyCommand threw — fail closed in plan mode.
87
+ return {
88
+ block: true,
89
+ reason: `plan mode: could not classify this bash command and it is held. Switch to /mode auto to apply changes.`,
90
+ };
91
+ }
92
+ }
93
+ return { block: false };
94
+ }
53
95
  if (policy.planBlockTools.includes(toolName)) {
54
96
  return {
55
97
  block: true,
@@ -116,7 +158,9 @@ const AUTO_MARKER = "[AUTO MODE]";
116
158
  const REVIEW_MARKER = "[REVIEW MODE]";
117
159
  export const PLAN_CONTEXT_MESSAGE = `${PLAN_MARKER}
118
160
  You are in plan mode: explore and design, change nothing.
119
- - write, edit, and bash are held by the permission gate; do not attempt them.
161
+ - Read-only bash commands (ls, grep, git status, gh pr view, etc.) run freely to help you explore.
162
+ - Ambiguous bash commands are reviewed by the Guardian; if non-mutating they run, if potentially mutating you will be asked.
163
+ - write, edit, file_ticket, and update_ticket_status are held by the permission gate; do not attempt them.
120
164
  - Read, search, and ask_yagni freely to ground the plan in how this company works.
121
165
  - Produce a concrete numbered plan of the steps you would take, with the files involved.
122
166
  - End by asking the user to review the plan; they run /mode auto (or /mode review) to execute it.
@@ -199,11 +243,6 @@ export function filterStaleModeContext(messages, currentMode) {
199
243
  }
200
244
  /** Legacy alias — the original plan-mode filter name. */
201
245
  export const filterStalePlanContext = filterStaleModeContext;
202
- const MODE_STATUS = {
203
- auto: undefined,
204
- plan: "⏸ plan",
205
- review: "✓ review",
206
- };
207
246
  const MODE_COPY = {
208
247
  auto: "auto: coding changes apply directly; external tracker changes ask first (default).",
209
248
  plan: "plan: write, edit, and bash are held so the agent can explore and propose only.",
@@ -257,6 +296,11 @@ export function registerPermissionGate(pi, deps = {}) {
257
296
  const basePolicy = deps.policy ?? DEFAULT_PERMISSION_POLICY;
258
297
  let mode = deps.mode ?? "auto";
259
298
  const makeStore = deps.makeBlessStore ?? defaultMakeBlessStore;
299
+ deps.modeHolder?.onSet((m) => {
300
+ if (m !== mode)
301
+ approvedCommands.clear();
302
+ mode = m;
303
+ });
260
304
  // The session bless store is created lazily on the first tool_call (it needs
261
305
  // the cwd). Its isBlessed backs the review-mode auto-approve, UNLESS the caller
262
306
  // injected its own isBlessed (e.g. a test policy) — that always wins.
@@ -279,6 +323,11 @@ export function registerPermissionGate(pi, deps = {}) {
279
323
  const guardianTier = deps.guardianTier;
280
324
  // --- YAG-510 gate state ---
281
325
  // Grants: in-memory list seeded from deps, appended on "don't ask again".
326
+ // Deliberately NOT live-reloaded from disk: auto mode can write files, so a
327
+ // mid-session re-read of rules.json would let the agent (or a prompt
328
+ // injection) author its own grants and self-authorize within the same
329
+ // session. New grants from concurrent sessions apply at next launch — the
330
+ // startup load is the trust boundary (PR #1698 review).
282
331
  const grants = [...(deps.grants ?? [])];
283
332
  // Keyed by cwd: a session can change working directory (cd, /go worktrees),
284
333
  // and a repoKey memoized from the first cwd would let repo-A grants match
@@ -403,21 +452,24 @@ export function registerPermissionGate(pi, deps = {}) {
403
452
  }
404
453
  }
405
454
  // 2. Session exact-command approval cache (ticket 4.5).
406
- if (command && approvedCommands.has(cacheKey(cwd, command))) {
455
+ // Skipped in plan mode: a cached approval can cover a write command,
456
+ // and plan mode's contract is no mutations without Guardian review.
457
+ if (modeAtEntry !== "plan" && command && approvedCommands.has(cacheKey(cwd, command))) {
407
458
  emitGateEvent({ ...eventBase, outcome: "cached_allow", consulted: false });
408
459
  return {};
409
460
  }
410
461
  const guardianAvailable = Boolean(guardianState && !guardianDisabled && guardianReview);
411
462
  const limits = guardianLimits ?? DEFAULT_GUARDIAN_LIMITS;
412
463
  if (guardianAvailable && guardianState.read().reviews >= limits.maxReviews) {
413
- // Session consult cap. Review mode falls through to its ordinary
414
- // confirm (no LLM cost); auto blocks.
415
- if (modeAtEntry === "auto") {
464
+ // Sliding-window consult cap (capacity recovers as old reviews age
465
+ // out a long-lived session is never bricked). Review mode falls
466
+ // through to its ordinary confirm (no LLM cost); auto and plan block.
467
+ if (modeAtEntry === "auto" || modeAtEntry === "plan") {
416
468
  if (ctx?.hasUI)
417
- ctx.ui.notify(`Guardian review cap reached (${limits.maxReviews} this session).`, "warning");
418
- return { block: true, reason: `Guardian review cap reached (${limits.maxReviews} this session). Switch to /mode review to approve manually.` };
469
+ ctx.ui.notify(`Guardian review cap reached (${limits.maxReviews} in the last hour).`, "warning");
470
+ return { block: true, reason: `Guardian review cap reached (${limits.maxReviews} in the last hour). Capacity recovers as older reviews age out; switch to /mode review to approve manually, or retry this step later.` };
419
471
  }
420
- // fall through to decision.confirm below
472
+ // review mode: fall through to decision.confirm below
421
473
  }
422
474
  else if (guardianAvailable) {
423
475
  // Circuit breaker (pre-consult). With a UI, escalate to ONE ask per
@@ -447,7 +499,7 @@ export function registerPermissionGate(pi, deps = {}) {
447
499
  }
448
500
  // Show the reviewing chip.
449
501
  if (ctx?.hasUI)
450
- ctx.ui.setStatus?.("yagni-guardian", "🛡 reviewing");
502
+ ctx.ui.setStatus?.("yagni-guardian", "Guardian Reviewing");
451
503
  const startMs = Date.now();
452
504
  let reviewResult;
453
505
  try {
@@ -663,6 +715,14 @@ export function registerPermissionGate(pi, deps = {}) {
663
715
  // approval cache were already consulted above.
664
716
  return {};
665
717
  }
718
+ else if (modeAtEntry === "plan") {
719
+ // Guardian disabled or not wired in plan mode: fail closed. Without
720
+ // the Guardian to verify the command is non-mutating, the plan-mode
721
+ // contract (no changes) cannot be upheld. The user can switch to
722
+ // /mode auto or /mode review to proceed.
723
+ emitGateEvent({ ...eventBase, outcome: "breaker_blocked", consulted: false });
724
+ return { block: true, reason: "Guardian unavailable in plan mode. Switch to /mode auto to run commands, or /mode review to approve manually." };
725
+ }
666
726
  // review mode with Guardian disabled/capped: fall through to confirm.
667
727
  }
668
728
  if (decision.confirm) {
@@ -740,13 +800,6 @@ export function registerPermissionGate(pi, deps = {}) {
740
800
  });
741
801
  const paintMode = (ctx) => {
742
802
  deps.modeHolder?.set(mode);
743
- try {
744
- if (ctx.hasUI)
745
- ctx.ui.setStatus?.("yagni-mode", MODE_STATUS[mode]);
746
- }
747
- catch {
748
- // The chip is chrome; never let it break /mode.
749
- }
750
803
  };
751
804
  pi.registerCommand("mode", {
752
805
  description: "Set the permission tier: /mode auto | plan | review. No argument shows the current mode.",
@@ -77,7 +77,7 @@ import { registerGoStatusCommands } from "./goStatusCommands.js";
77
77
  import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
78
78
  import { composeAbortSignal } from "./resilience.js";
79
79
  import { planResume } from "./resume.js";
80
- import { activeRunCount, beginRun, classifyRunLiveness, findActiveRunByTicket, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, MAX_CONCURRENT_RUNS, settleRun, trackRunAbort, trackRunPromise, worktreesDir, } from "./runRegistry.js";
80
+ import { activeRunCount, beginRun, classifyRunLiveness, findActiveRunByTicket, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, resolveMaxConcurrentRuns, settleRun, trackRunAbort, trackRunPromise, worktreesDir, } from "./runRegistry.js";
81
81
  import { makeRunSession as defaultMakeRunSession } from "./runSession.js";
82
82
  import { recordSessionRun } from "../sessionRuns.js";
83
83
  import { resolveTicketBrief as defaultResolveTicketBrief } from "./ticketResolution.js";
@@ -460,13 +460,15 @@ export function registerGoCommand(pi, deps = {}) {
460
460
  return;
461
461
  }
462
462
  // In-flight guards: the same ticket never runs twice at once in this
463
- // process, and at most MAX_CONCURRENT_RUNS runs are in flight.
463
+ // process, and at most resolveMaxConcurrentRuns() runs are in flight
464
+ // (default 3; fleet operators raise it via YAGNI_MAX_CONCURRENT_RUNS).
464
465
  if (findActiveRunByTicket(ticket)) {
465
466
  notify(`/go ${ticket} is already running - see /go-status.`, "warning");
466
467
  return;
467
468
  }
468
- if (activeRunCount() >= MAX_CONCURRENT_RUNS) {
469
- notify(`${MAX_CONCURRENT_RUNS} /go runs are already in flight; wait for one to finish (see /go-status).`, "warning");
469
+ const maxConcurrentRuns = resolveMaxConcurrentRuns();
470
+ if (activeRunCount() >= maxConcurrentRuns) {
471
+ notify(`${maxConcurrentRuns} /go runs are already in flight; wait for one to finish (see /go-status) or raise YAGNI_MAX_CONCURRENT_RUNS.`, "warning");
470
472
  return;
471
473
  }
472
474
  // --- Run tree resolution: worktree by default; --here = legacy in-place.
@@ -3,9 +3,17 @@
3
3
  * `buildLaunch` (no spawn, no fs).
4
4
  *
5
5
  * `buildStageInvocation` produces the per-stage pi PASSTHROUGH argv exactly as
6
- * the subagent example assembles it (runSingleAgent L294-330):
6
+ * the subagent example assembles it (runSingleAgent L294-330), with an added
7
+ * honesty preamble delivered as the FIRST `--append-system-prompt` so the
8
+ * anti-fabrication rule appears before the persona body in the child's
9
+ * assembled system prompt:
7
10
  * --mode json -p --no-session --model <tier> --tools <csv>
8
- * --append-system-prompt <file> 'Task: <rendered>'
11
+ * --append-system-prompt <honesty> --append-system-prompt <file> 'Task: <rendered>'
12
+ *
13
+ * Pi concatenates multiple `--append-system-prompt` values with `\n\n` in
14
+ * array order, so the child sees: [pi base] + [honesty] + [persona] +
15
+ * [project_context]. The preamble is placed first so open-weight models
16
+ * encounter the anti-fabrication rule before their role identity.
9
17
  *
10
18
  * `groundedChildArgv` prepends the grounding pi-args the yagni-code launcher
11
19
  * would otherwise add (`-e <self> --provider yagni`) because v1 spawns pi
@@ -17,6 +25,20 @@
17
25
  * chain's `step.task.replace(/{previous}/g, …)`.
18
26
  */
19
27
  import type { PipelineStage, ReviewLens } from "./types.js";
28
+ /**
29
+ * Anti-fabrication preamble injected as the first `--append-system-prompt` on
30
+ * every child pi process (both /go stages and subagent tool calls). The
31
+ * driver-only `ENGINEERING_PRACTICE_SECTION` never reaches children (it's a
32
+ * launcher flag, and children build their own argv); this preamble ensures
33
+ * the honesty rule is present in every child regardless.
34
+ *
35
+ * Content constraints (same as the enrichment section — the branding scrub's
36
+ * `scrubOutsideProjectContext` replaces whole-word \bpi\b and the
37
+ * `PI_DOCS_BLOCK_RE` regex consumes consecutive `- ` bullet lines that follow
38
+ * a docs header): must not contain the standalone word "pi", must not open
39
+ * with a `- ` bullet line, no emojis.
40
+ */
41
+ export declare const CHILD_HONESTY_PREAMBLE: string;
20
42
  /** Fill {ticket}/{previous}; an absent var renders as empty string. */
21
43
  export declare function renderTemplate(tmpl: string, vars: {
22
44
  ticket?: string;
@@ -3,9 +3,17 @@
3
3
  * `buildLaunch` (no spawn, no fs).
4
4
  *
5
5
  * `buildStageInvocation` produces the per-stage pi PASSTHROUGH argv exactly as
6
- * the subagent example assembles it (runSingleAgent L294-330):
6
+ * the subagent example assembles it (runSingleAgent L294-330), with an added
7
+ * honesty preamble delivered as the FIRST `--append-system-prompt` so the
8
+ * anti-fabrication rule appears before the persona body in the child's
9
+ * assembled system prompt:
7
10
  * --mode json -p --no-session --model <tier> --tools <csv>
8
- * --append-system-prompt <file> 'Task: <rendered>'
11
+ * --append-system-prompt <honesty> --append-system-prompt <file> 'Task: <rendered>'
12
+ *
13
+ * Pi concatenates multiple `--append-system-prompt` values with `\n\n` in
14
+ * array order, so the child sees: [pi base] + [honesty] + [persona] +
15
+ * [project_context]. The preamble is placed first so open-weight models
16
+ * encounter the anti-fabrication rule before their role identity.
9
17
  *
10
18
  * `groundedChildArgv` prepends the grounding pi-args the yagni-code launcher
11
19
  * would otherwise add (`-e <self> --provider yagni`) because v1 spawns pi
@@ -16,6 +24,24 @@
16
24
  * `renderTemplate` does the {ticket}/{previous} substitution, mirroring the
17
25
  * chain's `step.task.replace(/{previous}/g, …)`.
18
26
  */
27
+ /**
28
+ * Anti-fabrication preamble injected as the first `--append-system-prompt` on
29
+ * every child pi process (both /go stages and subagent tool calls). The
30
+ * driver-only `ENGINEERING_PRACTICE_SECTION` never reaches children (it's a
31
+ * launcher flag, and children build their own argv); this preamble ensures
32
+ * the honesty rule is present in every child regardless.
33
+ *
34
+ * Content constraints (same as the enrichment section — the branding scrub's
35
+ * `scrubOutsideProjectContext` replaces whole-word \bpi\b and the
36
+ * `PI_DOCS_BLOCK_RE` regex consumes consecutive `- ` bullet lines that follow
37
+ * a docs header): must not contain the standalone word "pi", must not open
38
+ * with a `- ` bullet line, no emojis.
39
+ */
40
+ export const CHILD_HONESTY_PREAMBLE = "Never fabricate file paths, file contents, function signatures, enum values, or code. " +
41
+ "If you cannot find something, say so explicitly — an honest \"not found\" is more valuable " +
42
+ "than a plausible-sounding invention. Every file path you cite must be a path you actually " +
43
+ "read with a tool. If you are uncertain whether something exists, say you are uncertain " +
44
+ "rather than presenting a guess as a finding.";
19
45
  /** Fill {ticket}/{previous}; an absent var renders as empty string. */
20
46
  export function renderTemplate(tmpl, vars) {
21
47
  return tmpl
@@ -41,6 +67,8 @@ export function buildStageInvocation(stage, ctx) {
41
67
  "--tools",
42
68
  stage.tools.join(","),
43
69
  "--append-system-prompt",
70
+ CHILD_HONESTY_PREAMBLE,
71
+ "--append-system-prompt",
44
72
  ctx.promptFilePath,
45
73
  `Task: ${task}`,
46
74
  ];
@@ -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
 
@@ -107,7 +107,7 @@ Risk taxonomy:
107
107
  Outcome rules:
108
108
  - "deny" is reserved for actions you are VERY CONFIDENT are very dangerous: irreversible destruction, exfiltration of sensitive data, credential probing, production-impacting security weakening. If you are not sure it is that bad, do not deny.
109
109
  - "allow" when you are confident the action is safe: read-only, routine dev-loop, or clearly scoped and reversible.
110
- - "ask" for everything in between — the action might be fine but a human should decide. This is the correct outcome whenever your confidence is middling; prefer ask over deny for anything short of the very-dangerous bar, and prefer allow over ask for routine work (do not hedge routine commands into ask).
110
+ - "ask" for actions where a specific, concrete risk makes you unsure — the action has a plausible path to harm that you cannot rule out by reading the command. Do not ask just because a command "might" do something; ask when you can name the specific thing you are worried about. If you cannot articulate a concrete risk, allow. Prefer allow over ask: routine dev-loop operations, local file writes within a project, and standard git operations (add, commit, fetch) are safe the agent is already operating in the user's repository with their consent.
111
111
 
112
112
  Rationale rules:
113
113
  - For "ask", the rationale MUST be a specific question addressed to the user, naming the concrete effect that made you unsure — e.g. "This pushes 3 commits to the shared main branch — do you want to publish them now?". You may be told why the static policy routed the command to you; your rationale must ADD information beyond that policy text, not restate it.
@@ -4,7 +4,8 @@
4
4
  * `withResilience(runStage, policy)` is a composable higher-order function that
5
5
  * wraps the per-stage child spawn (`runner.ts#runStage`) with the one axis the
6
6
  * roadmap calls the whole competitive gap: a per-stage IDLE timeout (no NDJSON
7
- * event for N ms) and a total WALL-CLOCK timeout, both firing the runner's
7
+ * event for N ms, deferred while a tool is in flight see the stall note at
8
+ * the timer wiring) and a total WALL-CLOCK timeout, both firing the runner's
8
9
  * existing SIGTERM -> SIGKILL abort; bounded exponential backoff with jitter; and
9
10
  * retry of CLASSIFIED-TRANSIENT outcomes only. One structured telemetry record is
10
11
  * emitted per attempt.
@@ -4,7 +4,8 @@
4
4
  * `withResilience(runStage, policy)` is a composable higher-order function that
5
5
  * wraps the per-stage child spawn (`runner.ts#runStage`) with the one axis the
6
6
  * roadmap calls the whole competitive gap: a per-stage IDLE timeout (no NDJSON
7
- * event for N ms) and a total WALL-CLOCK timeout, both firing the runner's
7
+ * event for N ms, deferred while a tool is in flight see the stall note at
8
+ * the timer wiring) and a total WALL-CLOCK timeout, both firing the runner's
8
9
  * existing SIGTERM -> SIGKILL abort; bounded exponential backoff with jitter; and
9
10
  * retry of CLASSIFIED-TRANSIENT outcomes only. One structured telemetry record is
10
11
  * emitted per attempt.
@@ -100,19 +101,37 @@ export function withResilience(base, policy, opts = {}) {
100
101
  if (!timeoutController.signal.aborted)
101
102
  timeoutController.abort();
102
103
  };
104
+ // Tools the child has started but not finished. The idle window measures
105
+ // STALL, not silence: a long quiet tool (a 6-minute test suite, a slow
106
+ // build) emits no NDJSON between its start and end events, and that is
107
+ // progress, not a hang. While a tool is in flight the idle expiry defers
108
+ // and re-arms instead of aborting; the wall-clock timer stays the
109
+ // backstop for a tool that is genuinely hung.
110
+ let inFlightTools = 0;
103
111
  let idleTimer;
104
112
  const armIdle = () => {
105
113
  if (idleTimer)
106
114
  clearTimeout(idleTimer);
107
- idleTimer = setTimeout(fireTimeout, policy.idleTimeoutMs);
115
+ idleTimer = setTimeout(fireIdle, policy.idleTimeoutMs);
108
116
  idleTimer.unref?.();
109
117
  };
118
+ const fireIdle = () => {
119
+ if (inFlightTools > 0) {
120
+ armIdle();
121
+ return;
122
+ }
123
+ fireTimeout();
124
+ };
110
125
  const wallTimer = setTimeout(fireTimeout, policy.wallTimeoutMs);
111
126
  wallTimer.unref?.();
112
127
  armIdle();
113
128
  const originalOnEvent = deps.onEvent;
114
129
  const onEvent = (ev) => {
115
130
  sawAnyEvent = true;
131
+ if (ev.type === "tool_execution_start")
132
+ inFlightTools += 1;
133
+ else if (ev.type === "tool_execution_end")
134
+ inFlightTools = Math.max(0, inFlightTools - 1);
116
135
  armIdle(); // reset the idle window on every live event
117
136
  originalOnEvent?.(ev);
118
137
  };
@@ -22,8 +22,16 @@
22
22
  * candidate).
23
23
  */
24
24
  import type { CheckpointRecord, StopReason } from "./types.js";
25
- /** Bound on simultaneously in-flight /go runs in one process (spec §3b). */
25
+ /** Default bound on simultaneously in-flight /go runs in one process (spec §3b). */
26
26
  export declare const MAX_CONCURRENT_RUNS = 3;
27
+ /** Hard ceiling for the env override — a typo must not launch hundreds of runs. */
28
+ export declare const MAX_CONCURRENT_RUNS_CEILING = 32;
29
+ /**
30
+ * Resolve the in-flight /go cap from the environment. `YAGNI_MAX_CONCURRENT_RUNS`
31
+ * raises (or lowers) the default for fleet-scale operators; non-numeric or < 1
32
+ * falls back to the default, and anything above the ceiling clamps to it.
33
+ */
34
+ export declare function resolveMaxConcurrentRuns(env?: Record<string, string | undefined>): number;
27
35
  /**
28
36
  * A non-terminal row whose journal has been quiet this long is treated as
29
37
  * INTERRUPTED (its process died) rather than still running elsewhere. Sits