@yagni-app/code-staging 0.0.0 → 0.1.0-staging.997.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.
Files changed (147) hide show
  1. package/LICENSE.md +55 -0
  2. package/README.md +166 -0
  3. package/dist/branding.d.ts +25 -0
  4. package/dist/branding.js +27 -0
  5. package/dist/claudeCompat.d.ts +78 -0
  6. package/dist/claudeCompat.js +198 -0
  7. package/dist/cli.d.ts +55 -0
  8. package/dist/cli.js +326 -0
  9. package/dist/config.d.ts +19 -0
  10. package/dist/config.js +28 -0
  11. package/dist/credentials.d.ts +41 -0
  12. package/dist/credentials.js +75 -0
  13. package/dist/distribution.d.ts +15 -0
  14. package/dist/distribution.js +45 -0
  15. package/dist/doctor.d.ts +94 -0
  16. package/dist/doctor.js +343 -0
  17. package/dist/extension/advisor.d.ts +84 -0
  18. package/dist/extension/advisor.js +101 -0
  19. package/dist/extension/askAdvisorTool.d.ts +83 -0
  20. package/dist/extension/askAdvisorTool.js +188 -0
  21. package/dist/extension/askYagniTool.d.ts +39 -0
  22. package/dist/extension/askYagniTool.js +63 -0
  23. package/dist/extension/bless.d.ts +45 -0
  24. package/dist/extension/bless.js +74 -0
  25. package/dist/extension/branding.d.ts +48 -0
  26. package/dist/extension/branding.js +119 -0
  27. package/dist/extension/chipEditor.d.ts +117 -0
  28. package/dist/extension/chipEditor.js +369 -0
  29. package/dist/extension/config.d.ts +112 -0
  30. package/dist/extension/config.js +108 -0
  31. package/dist/extension/costHud.d.ts +73 -0
  32. package/dist/extension/costHud.js +123 -0
  33. package/dist/extension/decisionCapture.d.ts +52 -0
  34. package/dist/extension/decisionCapture.js +66 -0
  35. package/dist/extension/decisions.d.ts +83 -0
  36. package/dist/extension/decisions.js +200 -0
  37. package/dist/extension/diagnostics.d.ts +41 -0
  38. package/dist/extension/diagnostics.js +110 -0
  39. package/dist/extension/index.d.ts +116 -0
  40. package/dist/extension/index.js +388 -0
  41. package/dist/extension/initDone.d.ts +28 -0
  42. package/dist/extension/initDone.js +66 -0
  43. package/dist/extension/initPass.d.ts +152 -0
  44. package/dist/extension/initPass.js +394 -0
  45. package/dist/extension/nextWorkTool.d.ts +51 -0
  46. package/dist/extension/nextWorkTool.js +80 -0
  47. package/dist/extension/permission.d.ts +91 -0
  48. package/dist/extension/permission.js +236 -0
  49. package/dist/extension/pipeline/activity.d.ts +37 -0
  50. package/dist/extension/pipeline/activity.js +151 -0
  51. package/dist/extension/pipeline/activityFeed.d.ts +59 -0
  52. package/dist/extension/pipeline/activityFeed.js +175 -0
  53. package/dist/extension/pipeline/budget.d.ts +48 -0
  54. package/dist/extension/pipeline/budget.js +68 -0
  55. package/dist/extension/pipeline/checkpoint.d.ts +31 -0
  56. package/dist/extension/pipeline/checkpoint.js +176 -0
  57. package/dist/extension/pipeline/eval.d.ts +168 -0
  58. package/dist/extension/pipeline/eval.js +182 -0
  59. package/dist/extension/pipeline/events.d.ts +56 -0
  60. package/dist/extension/pipeline/events.js +147 -0
  61. package/dist/extension/pipeline/findings.d.ts +42 -0
  62. package/dist/extension/pipeline/findings.js +144 -0
  63. package/dist/extension/pipeline/finish.d.ts +128 -0
  64. package/dist/extension/pipeline/finish.js +307 -0
  65. package/dist/extension/pipeline/goCommand.d.ts +128 -0
  66. package/dist/extension/pipeline/goCommand.js +972 -0
  67. package/dist/extension/pipeline/goCompareCommand.d.ts +48 -0
  68. package/dist/extension/pipeline/goCompareCommand.js +184 -0
  69. package/dist/extension/pipeline/goFlags.d.ts +40 -0
  70. package/dist/extension/pipeline/goFlags.js +46 -0
  71. package/dist/extension/pipeline/goStatusCommands.d.ts +59 -0
  72. package/dist/extension/pipeline/goStatusCommands.js +188 -0
  73. package/dist/extension/pipeline/invocation.d.ts +45 -0
  74. package/dist/extension/pipeline/invocation.js +64 -0
  75. package/dist/extension/pipeline/orchestrator.d.ts +131 -0
  76. package/dist/extension/pipeline/orchestrator.js +636 -0
  77. package/dist/extension/pipeline/personas.d.ts +44 -0
  78. package/dist/extension/pipeline/personas.js +248 -0
  79. package/dist/extension/pipeline/resilience.d.ts +85 -0
  80. package/dist/extension/pipeline/resilience.js +166 -0
  81. package/dist/extension/pipeline/resume.d.ts +18 -0
  82. package/dist/extension/pipeline/resume.js +106 -0
  83. package/dist/extension/pipeline/runRegistry.d.ts +112 -0
  84. package/dist/extension/pipeline/runRegistry.js +202 -0
  85. package/dist/extension/pipeline/runSession.d.ts +152 -0
  86. package/dist/extension/pipeline/runSession.js +167 -0
  87. package/dist/extension/pipeline/runState.d.ts +158 -0
  88. package/dist/extension/pipeline/runState.js +264 -0
  89. package/dist/extension/pipeline/runner.d.ts +79 -0
  90. package/dist/extension/pipeline/runner.js +298 -0
  91. package/dist/extension/pipeline/scrubSecrets.d.ts +15 -0
  92. package/dist/extension/pipeline/scrubSecrets.js +32 -0
  93. package/dist/extension/pipeline/stages.d.ts +51 -0
  94. package/dist/extension/pipeline/stages.js +113 -0
  95. package/dist/extension/pipeline/ticketResolution.d.ts +34 -0
  96. package/dist/extension/pipeline/ticketResolution.js +75 -0
  97. package/dist/extension/pipeline/types.d.ts +436 -0
  98. package/dist/extension/pipeline/types.js +48 -0
  99. package/dist/extension/pipeline/verify.d.ts +277 -0
  100. package/dist/extension/pipeline/verify.js +758 -0
  101. package/dist/extension/pipeline/workspace.d.ts +32 -0
  102. package/dist/extension/pipeline/workspace.js +73 -0
  103. package/dist/extension/pipeline/worktree.d.ts +107 -0
  104. package/dist/extension/pipeline/worktree.js +200 -0
  105. package/dist/extension/provider.d.ts +11 -0
  106. package/dist/extension/provider.js +26 -0
  107. package/dist/extension/recall.d.ts +93 -0
  108. package/dist/extension/recall.js +187 -0
  109. package/dist/extension/recordContextTool.d.ts +38 -0
  110. package/dist/extension/recordContextTool.js +85 -0
  111. package/dist/extension/recordDecisionTool.d.ts +52 -0
  112. package/dist/extension/recordDecisionTool.js +102 -0
  113. package/dist/extension/repoDocs.d.ts +81 -0
  114. package/dist/extension/repoDocs.js +260 -0
  115. package/dist/extension/resilientFetch.d.ts +60 -0
  116. package/dist/extension/resilientFetch.js +133 -0
  117. package/dist/extension/reviewTool.d.ts +34 -0
  118. package/dist/extension/reviewTool.js +81 -0
  119. package/dist/extension/spool.d.ts +92 -0
  120. package/dist/extension/spool.js +266 -0
  121. package/dist/extension/stateHome.d.ts +2 -0
  122. package/dist/extension/stateHome.js +6 -0
  123. package/dist/extension/subagents.d.ts +135 -0
  124. package/dist/extension/subagents.js +281 -0
  125. package/dist/extension/surface.d.ts +10 -0
  126. package/dist/extension/surface.js +12 -0
  127. package/dist/extension/todos.d.ts +110 -0
  128. package/dist/extension/todos.js +217 -0
  129. package/dist/extension/tokenProvider.d.ts +93 -0
  130. package/dist/extension/tokenProvider.js +234 -0
  131. package/dist/launch.d.ts +76 -0
  132. package/dist/launch.js +111 -0
  133. package/dist/login.d.ts +45 -0
  134. package/dist/login.js +142 -0
  135. package/dist/logout.d.ts +14 -0
  136. package/dist/logout.js +34 -0
  137. package/dist/paths.d.ts +31 -0
  138. package/dist/paths.js +87 -0
  139. package/dist/piPackage.d.ts +33 -0
  140. package/dist/piPackage.js +71 -0
  141. package/dist/profiles.d.ts +80 -0
  142. package/dist/profiles.js +222 -0
  143. package/dist/refresh.d.ts +70 -0
  144. package/dist/refresh.js +117 -0
  145. package/dist/upgrade.d.ts +97 -0
  146. package/dist/upgrade.js +284 -0
  147. package/package.json +39 -3
@@ -0,0 +1,388 @@
1
+ import { Text } from "@earendil-works/pi-tui";
2
+ import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
3
+ import { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
4
+ import { makeAskYagniTool } from "./askYagniTool.js";
5
+ import { makeReviewBusinessMatchTool } from "./reviewTool.js";
6
+ import { makeRecordEngineeringContextTool } from "./recordContextTool.js";
7
+ import { makeRecordDecisionTool } from "./recordDecisionTool.js";
8
+ import { makeSuggestNextWorkTool } from "./nextWorkTool.js";
9
+ import { BRAND_NAME, brandSystemPrompt, buildMastheadString } from "./branding.js";
10
+ import { registerCostCommand } from "./costHud.js";
11
+ import { isFreshWorkspace, runInitPass as defaultRunInitPass } from "./initPass.js";
12
+ import { isInitDone as defaultIsInitDone, markInitDone as defaultMarkInitDone } from "./initDone.js";
13
+ import { registerGoCommand } from "./pipeline/goCommand.js";
14
+ import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
15
+ import { registerPermissionGate } from "./permission.js";
16
+ import { registerSubagents } from "./subagents.js";
17
+ import { registerTodos } from "./todos.js";
18
+ import { registerDecisionCommands } from "./decisions.js";
19
+ import { makeDecisionCapture } from "./decisionCapture.js";
20
+ import { registerAmbientRecall } from "./recall.js";
21
+ import { resilientFetch } from "./resilientFetch.js";
22
+ import { flushSpool as defaultFlushSpool } from "./spool.js";
23
+ import { makeAuthedFetch, makeTokenProvider } from "./tokenProvider.js";
24
+ import { fetchCatalog as defaultFetchCatalog, fetchContextBrief as defaultFetchContextBrief, getToken, getTokenExpiresAt as defaultGetTokenExpiresAt, getWorkspaceId as defaultGetWorkspaceId, resolveBaseUrl, tokenExpiryNotice, } from "./config.js";
25
+ import { buildYagniProvider } from "./provider.js";
26
+ import { registerChipEditor } from "./chipEditor.js";
27
+ function isEvalMode(env = process.env) {
28
+ return env.YAGNI_CODE_EVAL_MODE === "1";
29
+ }
30
+ export async function registerYagni(pi, deps = {}) {
31
+ const baseUrl = deps.baseUrl ?? resolveBaseUrl();
32
+ const now = deps.now ?? (() => Date.now());
33
+ const fetchCatalog = deps.fetchCatalog ?? defaultFetchCatalog;
34
+ const evalMode = isEvalMode(deps.env ?? process.env);
35
+ // The refreshing TokenProvider replaces the old boot-time token snapshot:
36
+ // tools read the CURRENT token per call, a proactive unref'd timer rotates
37
+ // it near expiry, and `authedFetch` gives every tool's 401 path a single
38
+ // serialized refresh + one retry at THIS one seam (no per-tool copies).
39
+ const tokenProvider = deps.tokenProvider ??
40
+ makeTokenProvider({
41
+ baseUrl,
42
+ env: deps.env,
43
+ initialToken: deps.getToken ? deps.getToken() : getToken(deps.env),
44
+ initialExpiresAt: deps.getTokenExpiresAt
45
+ ? deps.getTokenExpiresAt()
46
+ : defaultGetTokenExpiresAt(deps.env),
47
+ fetchImpl: deps.fetchImpl,
48
+ now: deps.now,
49
+ });
50
+ const getTokenFn = () => tokenProvider.getToken();
51
+ const authedFetch = makeAuthedFetch(tokenProvider, deps.fetchImpl);
52
+ const flushSpoolFn = deps.flushSpool ?? defaultFlushSpool;
53
+ const catalog = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
54
+ pi.registerProvider("yagni", buildYagniProvider(catalog, baseUrl));
55
+ const toolOpts = { baseUrl, getToken: getTokenFn, fetchImpl: authedFetch };
56
+ pi.registerTool(makeAskYagniTool(toolOpts));
57
+ // The peak-tier escalation for Balanced sessions (YAG-380). Registered
58
+ // UNCONDITIONALLY and gated at execute time on the live session model: pi's
59
+ // picker can switch the model after activation, so a registration-time tier
60
+ // check would both leave the tool live after switching away from Balanced and
61
+ // hide it forever from a session that switched TO Balanced. One state handle
62
+ // per session, shared with /advise so they draw on the same cap.
63
+ const advisorState = makeAdvisorState();
64
+ const askAdvisorTool = makeAskAdvisorTool({ state: advisorState });
65
+ pi.registerTool(askAdvisorTool);
66
+ // /advise runs the SAME tool, sharing the state handle, so a manual consult
67
+ // draws on the same cap rather than opening a side channel around it.
68
+ registerAdviseCommand(pi, askAdvisorTool);
69
+ // The differentiated business-grounded tools (loop bricks): review a change
70
+ // for business fit, rank the next work by business priority, and record the
71
+ // engineering rationale back onto the work-item.
72
+ pi.registerTool(makeReviewBusinessMatchTool(toolOpts));
73
+ pi.registerTool(makeSuggestNextWorkTool(toolOpts));
74
+ if (!evalMode) {
75
+ pi.registerTool(makeRecordEngineeringContextTool(toolOpts));
76
+ // The capture half of the judgment loop: bank a product-intent decision so
77
+ // ask_yagni answers the same question next time instead of interrupting a human.
78
+ pi.registerTool(makeRecordDecisionTool(toolOpts));
79
+ }
80
+ // The visible checklist for multi-step work: the todo_write tool, its
81
+ // above-editor widget, and /todos. Branch-replayed, so forks and resumes
82
+ // show the list as it stood at that point.
83
+ registerTodos(pi);
84
+ // Image paste with [Image #N] chips: replaces the editor on
85
+ // session_start (TUI mode) so pasting a screenshot drops a chip instead of a
86
+ // temp path, and registers the input transform that turns chips into image
87
+ // attachments for the model. No-op outside TUI mode.
88
+ registerChipEditor(pi);
89
+ // The general subagent tool: delegate self-contained tasks (optionally in
90
+ // parallel) to fresh-context agents defined in .claude/agents / .pi/agents,
91
+ // riding the /go pipeline's child runner. /agents lists what's available.
92
+ registerSubagents(pi);
93
+ // The grounded multi-agent pipeline entry point: /go <ticket> runs
94
+ // map → plan → implement → review → fix, each child grounded by inheritance.
95
+ registerGoCommand(pi);
96
+ // M6 eval (report-only): /go-compare runs a ticket grounded vs blind and reports
97
+ // the business-fit delta. Never wired to routing.
98
+ registerGoCompareCommand(pi);
99
+ // W4 judgment loop: the decisions surface (/decide + /decisions) and the
100
+ // bless-with-remember capture are the same product-intent write as the record
101
+ // tools, so both are gated together (skipped in eval mode).
102
+ const decisionClientOpts = { baseUrl, getToken: getTokenFn, fetchImpl: authedFetch };
103
+ const decisionCapture = evalMode ? undefined : makeDecisionCapture(decisionClientOpts);
104
+ if (!evalMode)
105
+ registerDecisionCommands(pi, decisionClientOpts);
106
+ // P3 + W4: interactive permission tiers + plan mode via /mode, now with a
107
+ // session bless store (three-way review-mode select) and a capture hook that
108
+ // drafts a decision on "don't ask again". Default auto, so still additive.
109
+ registerPermissionGate(pi, {
110
+ onBlessRemember: decisionCapture
111
+ ? (ctx, info) => decisionCapture.captureFromBless(ctx, info)
112
+ : undefined,
113
+ });
114
+ // P2: /cost reports session usage (accumulated off turn_end) + credit headroom.
115
+ // The headroom fetch is fail-soft: until the backend /credits endpoint exists it
116
+ // resolves null and /cost still shows session usage.
117
+ registerCostCommand(pi, {
118
+ // Advisor consults run in a child process, so they never reach /cost's
119
+ // turn_end accumulator. Thread the subtotal in explicitly (YAG-383 owns the
120
+ // real fix, which also covers /go's still-invisible stage spend).
121
+ advisorSubtotal: () => formatAdvisorSubtotal(advisorState.read(), DEFAULT_ADVISOR_LIMITS),
122
+ fetchHeadroom: async (signal) => {
123
+ try {
124
+ const res = await resilientFetch(`${baseUrl}/api/yagni-code/credits`, { method: "GET", headers: { authorization: `Bearer ${getTokenFn() ?? ""}` } }, { fetchImpl: authedFetch, signal, policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: 10_000, jitterRatio: 0 } });
125
+ if (!res.ok)
126
+ return null;
127
+ const data = (await res.json());
128
+ if (typeof data.remaining !== "number")
129
+ return null;
130
+ return { remaining: data.remaining, unit: typeof data.unit === "string" ? data.unit : "credits" };
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ },
136
+ });
137
+ // /exit — alias for pi's built-in /quit. Delegates to ctx.shutdown() which
138
+ // performs graceful TUI teardown (restores terminal state, emits
139
+ // session_shutdown for other extensions, disposes the runtime host, prints
140
+ // the resume hint) before calling process.exit(0). When idle (!isStreaming),
141
+ // pi's shutdownHandler calls this.shutdown() immediately; when streaming, it
142
+ // defers to agent_end — both correct.
143
+ pi.registerCommand("exit", {
144
+ description: "Exit YAGNI Code (alias for /quit).",
145
+ handler: async (_args, ctx) => {
146
+ ctx.shutdown();
147
+ },
148
+ });
149
+ // Boot grounded: fetch the company brief once (best-effort — a failure here
150
+ // must never block the agent), then inject it into the system prompt so the
151
+ // very first turn already knows how this company and codebase work.
152
+ const fetchBrief = deps.fetchContextBrief ?? defaultFetchContextBrief;
153
+ let contextBrief;
154
+ let briefResult = null;
155
+ if (!evalMode) {
156
+ try {
157
+ briefResult = await fetchBrief({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
158
+ contextBrief = briefResult?.brief?.trim() ? briefResult.brief : undefined;
159
+ }
160
+ catch {
161
+ briefResult = null;
162
+ contextBrief = undefined;
163
+ }
164
+ }
165
+ // M1 ambient judgment recall: after a `read`, append the recorded judgment for
166
+ // that path to the tool result (result modification is supported on pi's
167
+ // `tool_result` event — verified against 0.80.2). No-op when the corpus is thin
168
+ // (<3 active decisions from /context.counts) or in eval mode.
169
+ registerAmbientRecall(pi, {
170
+ baseUrl,
171
+ getToken: getTokenFn,
172
+ fetchImpl: authedFetch,
173
+ decisionsCount: briefResult?.counts?.decisions ?? 0,
174
+ evalMode,
175
+ });
176
+ // Onramp Door B (spec §5B/§7): on a FRESH workspace (empty/thin grounding
177
+ // corpus), the first run reads the repo, drafts the engineering-half brief + a
178
+ // proposed Engineering Team, and seeds decisions — instead of a bare prompt. We
179
+ // reuse the brief we just fetched (no double round-trip). Skipped in eval mode.
180
+ const runInitPassFn = deps.runInitPass ?? defaultRunInitPass;
181
+ const getWorkspaceIdFn = deps.getWorkspaceId ?? (() => defaultGetWorkspaceId(deps.env));
182
+ const isInitDoneFn = deps.isInitDone ?? defaultIsInitDone;
183
+ const markInitDoneFn = deps.markInitDone ?? defaultMarkInitDone;
184
+ const workspaceId = getWorkspaceIdFn();
185
+ // F2a — one-time marker (idempotency): the init pass writes only to
186
+ // `yagni_code_decisions`, which the context endpoint never reads back (it reads
187
+ // Vision + Goals), so a Vision/Goals-less workspace looks "fresh" forever and
188
+ // would re-seed duplicate decisions on every launch. A per-workspace marker
189
+ // short-circuits the pass after its first real run — regardless of brief
190
+ // emptiness. (Degrades to freshness-only when the workspace id is unknown.)
191
+ const alreadyInit = !evalMode && !!workspaceId && isInitDoneFn(workspaceId);
192
+ // F2b — fail CLOSED: a FAILED context fetch (network / non-2xx / throw) surfaces
193
+ // as a `null` brief here (fetchContextBrief and the catch above both map failure
194
+ // to null; a successful-but-empty response is a non-null object). A failed fetch
195
+ // must NOT be treated as fresh, or a transient outage would spuriously re-seed.
196
+ // Only a genuinely-empty SUCCESSFUL response (non-null, thin) counts as fresh.
197
+ const freshWorkspace = !evalMode && !alreadyInit && briefResult !== null && isFreshWorkspace(briefResult);
198
+ // Own the identity + inject live company context on every turn.
199
+ pi.on("before_agent_start", (event) => ({
200
+ systemPrompt: brandSystemPrompt(event.systemPrompt, { contextBrief }),
201
+ }));
202
+ // Two finalized-message guards share this handler (their conditions are
203
+ // mutually exclusive: YAG-460 takes error-stopped messages, YAG-466 takes
204
+ // stop/length ones without an errorMessage).
205
+ //
206
+ // YAG-460: a context-too-large rejection from the backend proxy gets a
207
+ // readable message in place of raw JSON (see the branch below for why it
208
+ // must keep the `request_too_large` marker and must not compact).
209
+ //
210
+ // YAG-466 belt-and-braces: a turn that ends "successfully" with zero content
211
+ // renders as literal silence in the TUI — the Working indicator vanishes and
212
+ // nothing appears, which reads as a frozen session. The backend now surfaces
213
+ // its own stalls as errors, so any empty completion that still gets here is
214
+ // unexplained; rewrite it into a visible error instead of letting it vanish.
215
+ // Only `stop`/`length` qualify: `aborted` is the user's own Esc (often with an
216
+ // empty message and no errorMessage — not a failure), and `error` already
217
+ // renders. The message text deliberately matches neither pi's overflow nor
218
+ // retryable-error patterns: a deterministic empty response should not burn
219
+ // auto-retries or trigger compaction — the user decides what to do next.
220
+ pi.on("message_end", (event, ctx) => {
221
+ const msg = event.message;
222
+ if (msg.role !== "assistant")
223
+ return;
224
+ // YAG-460: the backend proxy answers an oversized conversation with an
225
+ // OpenAI-format 413 whose type is `request_too_large`. pi's own overflow
226
+ // detection matches that marker and runs full recovery — compact, then
227
+ // auto-retry the failed turn — so this branch must NOT call ctx.compact()
228
+ // (it would race the built-in recovery and lose the retry). Its only job
229
+ // is UX: replace the raw `413: {"error":{...}}` JSON with a readable
230
+ // message and tell the user what is happening. The rewritten text KEEPS
231
+ // the `request_too_large` marker verbatim: pi's _checkCompaction reads the
232
+ // post-replacement message, and dropping the marker would defeat the very
233
+ // recovery this error exists to trigger.
234
+ if (msg.stopReason === "error" && msg.errorMessage) {
235
+ const isContextTooLarge = /request_too_large|context_too_large/i.test(msg.errorMessage);
236
+ if (!isContextTooLarge)
237
+ return;
238
+ const explanation = "The conversation context is too large for the model (request_too_large). "
239
+ + "YAGNI Code is compacting the session and retrying automatically — "
240
+ + "if it does not recover, run /compact or start a new session.";
241
+ if (ctx.hasUI) {
242
+ try {
243
+ ctx.ui.notify(explanation, "warning");
244
+ }
245
+ catch {
246
+ // Surfacing the problem must never break the session itself.
247
+ }
248
+ }
249
+ return { message: { ...msg, errorMessage: explanation } };
250
+ }
251
+ if (msg.stopReason !== "stop" && msg.stopReason !== "length")
252
+ return;
253
+ if (msg.errorMessage)
254
+ return;
255
+ const hasVisibleContent = msg.content.some((c) => (c.type === "text" && c.text.trim().length > 0) ||
256
+ (c.type === "thinking" && c.thinking.trim().length > 0) ||
257
+ c.type === "toolCall");
258
+ if (hasVisibleContent)
259
+ return;
260
+ const explanation = `The model returned an empty response (stop reason: ${msg.stopReason}). ` +
261
+ "This usually means the provider stalled — please try again.";
262
+ if (ctx.hasUI) {
263
+ try {
264
+ ctx.ui.notify(explanation, "warning");
265
+ }
266
+ catch {
267
+ // Surfacing the problem must never break the session itself.
268
+ }
269
+ }
270
+ return { message: { ...msg, stopReason: "error", errorMessage: explanation } };
271
+ });
272
+ // Chrome: replace pi's built-in startup header (which shows "pi v<version>"
273
+ // and a "Pi can explain its own features…" line) with a YAGNI Code masthead,
274
+ // set the terminal title, and add a footer brand mark. TUI only.
275
+ pi.on("session_start", async (event, ctx) => {
276
+ ctx.ui?.setTitle(BRAND_NAME);
277
+ if (ctx.mode === "tui") {
278
+ ctx.ui?.setStatus?.("brand", BRAND_NAME);
279
+ // setHeader replaces the built-in header in place (verified against pi
280
+ // 0.80.2 setExtensionHeader); the factory returns a simple Text component.
281
+ ctx.ui?.setHeader?.((_tui, theme) => new Text(buildMastheadString(theme)));
282
+ }
283
+ // Best-effort, once at session start: if the token is at or near expiry, say
284
+ // so via a single notice so a long session does not silently start 401-ing
285
+ // mid-flight (both completions and grounding tools). The provider's expiry
286
+ // is preferred over the env snapshot because a proactive/401 refresh may
287
+ // already have rotated the token by the time the session starts.
288
+ if (ctx.hasUI) {
289
+ try {
290
+ const notice = tokenExpiryNotice(tokenProvider.getExpiresAt(), now());
291
+ if (notice)
292
+ ctx.ui.notify(notice.message, notice.type);
293
+ }
294
+ catch {
295
+ // A notice must never disrupt session start.
296
+ }
297
+ }
298
+ // R4 write half: drain any judgment writes spooled by a previous session
299
+ // (transport/5xx failures of record_decision / record_engineering_context).
300
+ // Fire-and-forget and fail-soft — a flush must never delay or break session
301
+ // start; honest notices (replays, expiry drops) surface when it finishes.
302
+ if (!evalMode) {
303
+ void flushSpoolFn({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch })
304
+ .then((outcome) => {
305
+ if (!ctx.hasUI)
306
+ return;
307
+ for (const noticeText of outcome.notices)
308
+ ctx.ui.notify(noticeText, "info");
309
+ })
310
+ .catch(() => { });
311
+ }
312
+ // Run the init pass only on a genuine first-run (a fresh startup, not a
313
+ // reload/resume/fork) of a fresh workspace. Fully fail-soft: it must never
314
+ // break session start. The pass itself also re-guards on freshness.
315
+ if (event.reason === "startup" && freshWorkspace) {
316
+ try {
317
+ const outcome = await runInitPassFn(pi, ctx, {
318
+ baseUrl,
319
+ getToken: getTokenFn,
320
+ fetchImpl: authedFetch,
321
+ brief: briefResult,
322
+ });
323
+ // F2a: mark the workspace init-done on any real run (ran === true — thin
324
+ // or not), so it never re-seeds. A skip (not_fresh / non_interactive) is
325
+ // ran:false and does NOT mark, leaving the seed for a later real run.
326
+ if (outcome.ran && workspaceId)
327
+ markInitDoneFn(workspaceId);
328
+ }
329
+ catch {
330
+ // The init pass is best-effort; a failure never blocks the session.
331
+ }
332
+ }
333
+ });
334
+ }
335
+ export default async function (pi) {
336
+ await registerYagni(pi);
337
+ }
338
+ export { makeAskYagniTool } from "./askYagniTool.js";
339
+ export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
340
+ export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
341
+ export { makeReviewBusinessMatchTool } from "./reviewTool.js";
342
+ export { makeRecordEngineeringContextTool } from "./recordContextTool.js";
343
+ export { makeRecordDecisionTool } from "./recordDecisionTool.js";
344
+ export { makeSuggestNextWorkTool, defaultNextAction } from "./nextWorkTool.js";
345
+ export { recordDecision } from "./recordDecisionTool.js";
346
+ // Onramp Door B: the CLI init pass (fresh-workspace detection + repo intake +
347
+ // engineering-half drafting + decision seeding + one default next action).
348
+ export { runInitPass, isFreshWorkspace, readRepoIntake, draftEngineering, summarizeDraft, FRESH_BRIEF_MIN_CHARS, } from "./initPass.js";
349
+ // Onramp Door B (F2a): the one-time init-pass idempotency marker.
350
+ export { isInitDone, markInitDone, initDoneMarkerFile, _setInitDoneHomeForTest } from "./initDone.js";
351
+ export { brandSystemPrompt, YAGNI_IDENTITY, BRAND_NAME } from "./branding.js";
352
+ export { fetchCatalog, getToken, getWorkspaceId, resolveBaseUrl } from "./config.js";
353
+ export { buildYagniProvider } from "./provider.js";
354
+ export { registerGoCommand } from "./pipeline/goCommand.js";
355
+ export { runPipeline } from "./pipeline/orchestrator.js";
356
+ // R1: the in-loop resilience HOF a dev can compose with (or replace at) the
357
+ // `runStage` seam, plus its policy type and the default policy.
358
+ export { withResilience, classifyTransient, composeAbortSignal } from "./pipeline/resilience.js";
359
+ export { DEFAULT_RESILIENCE_POLICY } from "./pipeline/types.js";
360
+ // P4: the deterministic verify gate seam (override the command via .yagni-code/verify).
361
+ export { buildVerifyEnv, detectVerifyCommand, makeRunVerify, parseVerifyFailures } from "./pipeline/verify.js";
362
+ // M6: the report-only grounded-vs-blind eval engine.
363
+ export { blindStages, reportOnlyStages, makeGroundedVsBlindEval, formatComparisonReport } from "./pipeline/eval.js";
364
+ export { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
365
+ // The general subagent tool: Claude Code-format agent discovery + fan-out.
366
+ export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkdown, buildSubagentStage, formatAgentList, mapModelTier, SUBAGENT_TOOL_NAME, GENERAL_AGENT_NAME, MAX_PARALLEL_SUBAGENTS, DEFAULT_SUBAGENT_TOOLS, } from "./subagents.js";
367
+ // The session todo checklist: todo_write tool, widget renderer, /todos.
368
+ export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, todoSummary, TODO_TOOL_NAME, MAX_TODOS, } from "./todos.js";
369
+ // P3 + W4: the permission gate seam (decideGate is pure; policy injectable) plus
370
+ // the session bless-with-remember capture hook.
371
+ export { decideGate, registerPermissionGate, filterStalePlanContext, DEFAULT_PERMISSION_POLICY, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
372
+ // W4 judgment loop: the session bless store (tool + path-prefix, session-only).
373
+ export { makeBlessStore, blessPath } from "./bless.js";
374
+ // W4 judgment loop: the decisions surface (/decide + /decisions) + shared bank.
375
+ export { bankDecision, fetchDecisions, supersedeDecision, parseDecideArgs, formatDecisionsList, shortId, registerDecisionCommands, DEFAULT_LIST_LIMIT, } from "./decisions.js";
376
+ // W4 judgment loop: the debounced, TUI-confirmed decision capture at the gate.
377
+ export { makeDecisionCapture, CAPTURE_DEBOUNCE_MS } from "./decisionCapture.js";
378
+ // M1: ambient judgment recall appended to `read` results (pure cores + fetch).
379
+ export { registerAmbientRecall, fetchRecall, formatRecallBlock, toRepoRelativePath, normalizeRecall, RECALL_TIMEOUT_MS, RECALL_MIN_DECISIONS, } from "./recall.js";
380
+ // P2: the session cost accumulator + /cost command.
381
+ export { makeCostAccumulator, formatCostLine, registerCostCommand } from "./costHud.js";
382
+ // W4 trust plumbing: the refreshing TokenProvider (kills the boot snapshot) and
383
+ // the ONE 401 refresh-then-retry-once fetch seam every tool shares.
384
+ export { makeTokenProvider, makeAuthedFetch, persistRotationToProfile, PROACTIVE_REFRESH_WINDOW_MS, REFRESH_RETRY_MS, } from "./tokenProvider.js";
385
+ // W4 trust plumbing: the durable write-spool for the judgment-capture tools
386
+ // (idempotencyKey-replayed; server-side dedup makes flush safe).
387
+ export { appendToSpool, loadSpool, flushSpool, sendOrSpool, spoolFile, MAX_SPOOL_AGE_MS, _setSpoolHomeForTest, } from "./spool.js";
388
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The CLI init-pass idempotency marker (Onramp Door B, spec §5B/§7).
3
+ *
4
+ * The init pass writes only to `yagni_code_decisions`, but freshness is computed
5
+ * from `GET /api/yagni-code/context` — which reads only Vision + active Goals.
6
+ * A workspace with neither (the common case for a fresh CLI-only user) therefore
7
+ * looks "fresh" on EVERY launch, so without a marker the init pass would re-run
8
+ * and accumulate duplicate decisions each time.
9
+ *
10
+ * The fix is a one-time, per-workspace marker file, dropped after the first real
11
+ * run. It lives in the same `~/.yagni-code` home as the run journal
12
+ * ({@link checkpoint.ts}), is keyed on a truncated SHA-256 of the workspace id so
13
+ * the filename is path-safe and reveals no identity, and holds only a timestamp
14
+ * (never a token or any PII). Every operation is fail-soft: a missing marker
15
+ * simply means "not yet run", and a write failure never breaks session start.
16
+ */
17
+ export declare function _setInitDoneHomeForTest(dir: string | null): void;
18
+ /**
19
+ * The marker path for a workspace. The id is hashed (SHA-256, first 16 hex chars)
20
+ * so an arbitrary/opaque workspace id is path-safe and the raw id never lands in
21
+ * a filename.
22
+ */
23
+ export declare function initDoneMarkerFile(workspaceId: string): string;
24
+ /** Has the init pass already run once for this workspace? Fail-soft → false. */
25
+ export declare function isInitDone(workspaceId: string): boolean;
26
+ /** Record that the init pass has run once for this workspace. Fail-soft (never throws). */
27
+ export declare function markInitDone(workspaceId: string): void;
28
+ //# sourceMappingURL=initDone.d.ts.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The CLI init-pass idempotency marker (Onramp Door B, spec §5B/§7).
3
+ *
4
+ * The init pass writes only to `yagni_code_decisions`, but freshness is computed
5
+ * from `GET /api/yagni-code/context` — which reads only Vision + active Goals.
6
+ * A workspace with neither (the common case for a fresh CLI-only user) therefore
7
+ * looks "fresh" on EVERY launch, so without a marker the init pass would re-run
8
+ * and accumulate duplicate decisions each time.
9
+ *
10
+ * The fix is a one-time, per-workspace marker file, dropped after the first real
11
+ * run. It lives in the same `~/.yagni-code` home as the run journal
12
+ * ({@link checkpoint.ts}), is keyed on a truncated SHA-256 of the workspace id so
13
+ * the filename is path-safe and reveals no identity, and holds only a timestamp
14
+ * (never a token or any PII). Every operation is fail-soft: a missing marker
15
+ * simply means "not yet run", and a write failure never breaks session start.
16
+ */
17
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
18
+ import { createHash } from "node:crypto";
19
+ import { join } from "node:path";
20
+ import { codeStateHome } from "./stateHome.js";
21
+ /**
22
+ * Test seam mirroring {@link checkpoint._setCheckpointHomeForTest}: when set, the
23
+ * marker roots here instead of `~/.yagni-code`, so tests hit a tmpdir. Pass
24
+ * `null` to restore.
25
+ */
26
+ let homeOverride = null;
27
+ export function _setInitDoneHomeForTest(dir) {
28
+ homeOverride = dir;
29
+ }
30
+ function initDoneHome() {
31
+ return codeStateHome(homeOverride);
32
+ }
33
+ /**
34
+ * The marker path for a workspace. The id is hashed (SHA-256, first 16 hex chars)
35
+ * so an arbitrary/opaque workspace id is path-safe and the raw id never lands in
36
+ * a filename.
37
+ */
38
+ export function initDoneMarkerFile(workspaceId) {
39
+ const hash = createHash("sha256").update(workspaceId).digest("hex").slice(0, 16);
40
+ return join(initDoneHome(), `init-done-${hash}`);
41
+ }
42
+ /** Has the init pass already run once for this workspace? Fail-soft → false. */
43
+ export function isInitDone(workspaceId) {
44
+ if (!workspaceId)
45
+ return false;
46
+ try {
47
+ return existsSync(initDoneMarkerFile(workspaceId));
48
+ }
49
+ catch {
50
+ return false; // unreadable → treat as not-yet-run (the caller still guards on freshness)
51
+ }
52
+ }
53
+ /** Record that the init pass has run once for this workspace. Fail-soft (never throws). */
54
+ export function markInitDone(workspaceId) {
55
+ if (!workspaceId)
56
+ return;
57
+ try {
58
+ mkdirSync(initDoneHome(), { recursive: true, mode: 0o700 });
59
+ // Content is a bare timestamp — no token, no PII (the id is hashed in the name).
60
+ writeFileSync(initDoneMarkerFile(workspaceId), `${new Date().toISOString()}\n`, { mode: 0o600 });
61
+ }
62
+ catch {
63
+ /* fail-soft: a marker write must never break session start */
64
+ }
65
+ }
66
+ //# sourceMappingURL=initDone.js.map
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The CLI init pass (Onramp Door B, spec §5B/§7).
3
+ *
4
+ * On a FRESH workspace, the first `yagni` run in a repo should not drop the
5
+ * user into a bare prompt. Instead it runs a one-time init pass:
6
+ * 1. detect a fresh workspace (an empty/thin grounding corpus, via the existing
7
+ * `GET /api/yagni-code/context` brief),
8
+ * 2. read the repo (README, AGENTS.md/CLAUDE.md, package.json scripts, ADRs),
9
+ * 3. draft the ENGINEERING HALF of the company brief + a proposed Engineering
10
+ * Team, and record the salient ADRs/conventions as decisions so the corpus
11
+ * is non-empty for the very first `/go`,
12
+ * 4. present the draft for approve/edit in-terminal (mirrored to the app via the
13
+ * recorded decisions), NEVER auto-committing the Team, and
14
+ * 5. offer the ONE default next action (`suggest_next_work`) with escape hatches.
15
+ *
16
+ * Honesty rails (non-negotiable, spec §9): when a repo has no README/AGENTS/ADRs
17
+ * the engineering half stays THIN and SAYS SO — nothing is fabricated, and no
18
+ * decision is seeded from thin air. The proposed Team is only ever a DRAFT; it is
19
+ * never created/committed automatically.
20
+ *
21
+ * No new backend transport: the only writes are through the existing token-scoped
22
+ * `record_decision` endpoint (reused via {@link recordDecision}). Every seam is
23
+ * injectable so the whole pass is unit-testable without a network or a filesystem.
24
+ */
25
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
26
+ import { fetchContextBrief as defaultFetchContextBrief, type ContextBrief } from "./config.js";
27
+ import { type NextActionOption } from "./nextWorkTool.js";
28
+ import { recordDecision as defaultRecordDecision, type RecordDecisionParams } from "./recordDecisionTool.js";
29
+ /**
30
+ * Below this many non-whitespace characters (and with no sources), a returned
31
+ * context brief is treated as thin/empty — i.e. the corpus has not been seeded
32
+ * yet, so this is a fresh workspace that should run the init pass.
33
+ */
34
+ export declare const FRESH_BRIEF_MIN_CHARS = 40;
35
+ /**
36
+ * Is this a fresh workspace (an empty/thin grounding corpus)? A `null` brief, or
37
+ * a brief with only a stub of text and no sources, means nothing has grounded the
38
+ * workspace yet. A brief with real text OR any sources means it is already
39
+ * populated and the init pass must be skipped.
40
+ */
41
+ export declare function isFreshWorkspace(brief: ContextBrief | null | undefined): boolean;
42
+ /** A single architecture-decision record found in the repo. */
43
+ export interface RepoAdr {
44
+ /** Repo-relative path, e.g. `docs/adr/0001-use-fly.md`. */
45
+ path: string;
46
+ /** The record's title (its first heading, or the filename when headingless). */
47
+ title: string;
48
+ /** A short first-paragraph summary, when present. */
49
+ summary?: string;
50
+ }
51
+ /** What the repo intake could find. All fields are best-effort / optional. */
52
+ export interface RepoIntake {
53
+ /** README body (capped), when present. */
54
+ readme?: string;
55
+ /** AGENTS.md or CLAUDE.md body (capped), when present. */
56
+ agents?: string;
57
+ /** package.json `scripts` map (empty when absent/unparseable). */
58
+ packageScripts: Record<string, string>;
59
+ /** Architecture decision records found under docs/adr (etc.). */
60
+ adrs: RepoAdr[];
61
+ /** The literal test command (package.json `scripts.test`), when present. */
62
+ testCommand?: string;
63
+ /** The literal build command (package.json `scripts.build`), when present. */
64
+ buildCommand?: string;
65
+ }
66
+ /** Injectable filesystem seam so repo intake is unit-testable without disk. */
67
+ export interface RepoIntakeFs {
68
+ readFile(p: string): Promise<string>;
69
+ readdir(p: string): Promise<string[]>;
70
+ }
71
+ /**
72
+ * Read the repo at `cwd` for the signals that seed the engineering half of the
73
+ * brief. Every read is best-effort; a missing file simply drops that signal.
74
+ */
75
+ export declare function readRepoIntake(cwd: string, fs?: RepoIntakeFs): Promise<RepoIntake>;
76
+ /** A proposed Engineering Team — a DRAFT that is never auto-committed. */
77
+ export interface DraftTeam {
78
+ name: string;
79
+ mission: string;
80
+ responsibilities: string[];
81
+ playbookRules: string[];
82
+ testCommand?: string;
83
+ buildCommand?: string;
84
+ riskAreas: string[];
85
+ }
86
+ /** The drafted engineering half: the brief, seeded decisions, and a Team draft. */
87
+ export interface EngineeringDraft {
88
+ /** The engineering-half brief text (honest-when-thin). */
89
+ brief: string;
90
+ /** True when the repo had no README/AGENTS/ADRs — the brief says so, nothing is fabricated. */
91
+ thin: boolean;
92
+ /** Decisions to seed into the corpus. EMPTY when thin (no fabrication). */
93
+ decisions: RecordDecisionParams[];
94
+ /** The proposed Engineering Team (a draft; never auto-committed). */
95
+ team: DraftTeam;
96
+ }
97
+ /**
98
+ * Draft the engineering half of the brief from repo intake. PURE. Honest-when-thin:
99
+ * with no README/AGENTS/ADRs the brief says so and NO decision is seeded.
100
+ */
101
+ export declare function draftEngineering(intake: RepoIntake): EngineeringDraft;
102
+ /** Injectable dependencies for {@link runInitPass}. */
103
+ export interface RunInitPassDeps {
104
+ baseUrl: string;
105
+ getToken: () => string | undefined;
106
+ fetchImpl?: typeof fetch;
107
+ /**
108
+ * A pre-fetched context brief (the caller usually already fetched it to boot
109
+ * grounded). When `undefined`, the pass fetches it itself. Pass `null`
110
+ * explicitly to signal "fetched, and it was empty".
111
+ */
112
+ brief?: ContextBrief | null;
113
+ fetchContextBrief?: typeof defaultFetchContextBrief;
114
+ readRepoIntake?: (cwd: string) => Promise<RepoIntake>;
115
+ recordDecision?: typeof defaultRecordDecision;
116
+ }
117
+ /** The outcome of an init-pass attempt. */
118
+ export type InitPassOutcome = {
119
+ ran: false;
120
+ reason: "not_fresh" | "non_interactive";
121
+ } | {
122
+ ran: true;
123
+ thin: boolean;
124
+ decisionsRecorded: number;
125
+ /** The Team was drafted for the user. */
126
+ teamDrafted: true;
127
+ /** The Team is NEVER created/committed automatically. Always false. */
128
+ teamCommitted: false;
129
+ /** Which next action the user picked (undefined in headless mode). */
130
+ chosenAction?: NextActionOption["id"];
131
+ };
132
+ /**
133
+ * Compact one-screen PREVIEW of the draft for the approve/edit dialog.
134
+ *
135
+ * Approval-gated (spec §5B/§9, F2/F13): NOTHING is written to the corpus until
136
+ * the human approves or corrects the draft, so this is a preview of INTENT — it
137
+ * states what will be recorded ON APPROVAL, and never claims a corpus was
138
+ * already seeded. When thin there is nothing to record (honest — nothing to
139
+ * fabricate). The ACTUAL recorded count is reported after the write runs (see
140
+ * `runInitPass`).
141
+ */
142
+ export declare function summarizeDraft(draft: EngineeringDraft): string;
143
+ /**
144
+ * Run the init pass. Guards on fresh-workspace detection (defensive — the caller
145
+ * also guards), reads the repo, drafts the engineering half, presents it for
146
+ * approve/edit, and ONLY THEN seeds the corpus (approval-gated: an approve banks
147
+ * the drafted decisions, an edit banks the correction, a decline writes nothing),
148
+ * before offering the one default next action. Fully fail-soft: a UI or network
149
+ * hiccup never throws (it must never break session start).
150
+ */
151
+ export declare function runInitPass(pi: ExtensionAPI, ctx: ExtensionContext, deps: RunInitPassDeps): Promise<InitPassOutcome>;
152
+ //# sourceMappingURL=initPass.d.ts.map