@yagni-app/code 0.1.0 → 0.2.0

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 (155) hide show
  1. package/README.md +65 -11
  2. package/dist/claudeCompat.d.ts +109 -0
  3. package/dist/claudeCompat.js +260 -0
  4. package/dist/claudePlugins.d.ts +109 -0
  5. package/dist/claudePlugins.js +336 -0
  6. package/dist/cli.d.ts +1 -5
  7. package/dist/cli.js +79 -20
  8. package/dist/config.d.ts +1 -1
  9. package/dist/config.js +4 -2
  10. package/dist/crashReport.d.ts +135 -0
  11. package/dist/crashReport.js +291 -0
  12. package/dist/credentials.js +2 -1
  13. package/dist/distribution.d.ts +15 -0
  14. package/dist/distribution.js +45 -0
  15. package/dist/doctor.d.ts +27 -0
  16. package/dist/doctor.js +81 -3
  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 +194 -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 +87 -0
  25. package/dist/extension/boostCommand.d.ts +144 -0
  26. package/dist/extension/boostCommand.js +263 -0
  27. package/dist/extension/branding.d.ts +79 -0
  28. package/dist/extension/branding.js +156 -0
  29. package/dist/extension/chipEditor.d.ts +117 -0
  30. package/dist/extension/chipEditor.js +373 -0
  31. package/dist/extension/claudeRules.d.ts +54 -0
  32. package/dist/extension/claudeRules.js +180 -0
  33. package/dist/extension/config.d.ts +173 -0
  34. package/dist/extension/config.js +194 -0
  35. package/dist/extension/costHud.d.ts +186 -0
  36. package/dist/extension/costHud.js +293 -0
  37. package/dist/extension/crashReport.d.ts +89 -0
  38. package/dist/extension/crashReport.js +241 -0
  39. package/dist/extension/decisionCapture.d.ts +52 -0
  40. package/dist/extension/decisionCapture.js +66 -0
  41. package/dist/extension/decisions.d.ts +83 -0
  42. package/dist/extension/decisions.js +200 -0
  43. package/dist/extension/diagnostics.d.ts +41 -0
  44. package/dist/extension/diagnostics.js +110 -0
  45. package/dist/extension/index.d.ts +155 -0
  46. package/dist/extension/index.js +597 -0
  47. package/dist/extension/initDone.d.ts +28 -0
  48. package/dist/extension/initDone.js +66 -0
  49. package/dist/extension/initPass.d.ts +170 -0
  50. package/dist/extension/initPass.js +394 -0
  51. package/dist/extension/mcpTools.d.ts +57 -0
  52. package/dist/extension/mcpTools.js +132 -0
  53. package/dist/extension/nextWorkTool.d.ts +51 -0
  54. package/dist/extension/nextWorkTool.js +80 -0
  55. package/dist/extension/permission.d.ts +91 -0
  56. package/dist/extension/permission.js +236 -0
  57. package/dist/extension/pipeline/activity.d.ts +37 -0
  58. package/dist/extension/pipeline/activity.js +151 -0
  59. package/dist/extension/pipeline/activityFeed.d.ts +59 -0
  60. package/dist/extension/pipeline/activityFeed.js +175 -0
  61. package/dist/extension/pipeline/budget.d.ts +48 -0
  62. package/dist/extension/pipeline/budget.js +68 -0
  63. package/dist/extension/pipeline/checkpoint.d.ts +31 -0
  64. package/dist/extension/pipeline/checkpoint.js +176 -0
  65. package/dist/extension/pipeline/eval.d.ts +205 -0
  66. package/dist/extension/pipeline/eval.js +226 -0
  67. package/dist/extension/pipeline/events.d.ts +56 -0
  68. package/dist/extension/pipeline/events.js +147 -0
  69. package/dist/extension/pipeline/findings.d.ts +42 -0
  70. package/dist/extension/pipeline/findings.js +144 -0
  71. package/dist/extension/pipeline/finish.d.ts +128 -0
  72. package/dist/extension/pipeline/finish.js +307 -0
  73. package/dist/extension/pipeline/goCommand.d.ts +146 -0
  74. package/dist/extension/pipeline/goCommand.js +1085 -0
  75. package/dist/extension/pipeline/goCompareCommand.d.ts +58 -0
  76. package/dist/extension/pipeline/goCompareCommand.js +203 -0
  77. package/dist/extension/pipeline/goFlags.d.ts +40 -0
  78. package/dist/extension/pipeline/goFlags.js +46 -0
  79. package/dist/extension/pipeline/goStatusCommands.d.ts +59 -0
  80. package/dist/extension/pipeline/goStatusCommands.js +188 -0
  81. package/dist/extension/pipeline/invocation.d.ts +45 -0
  82. package/dist/extension/pipeline/invocation.js +64 -0
  83. package/dist/extension/pipeline/orchestrator.d.ts +131 -0
  84. package/dist/extension/pipeline/orchestrator.js +645 -0
  85. package/dist/extension/pipeline/personas.d.ts +44 -0
  86. package/dist/extension/pipeline/personas.js +248 -0
  87. package/dist/extension/pipeline/resilience.d.ts +85 -0
  88. package/dist/extension/pipeline/resilience.js +166 -0
  89. package/dist/extension/pipeline/resume.d.ts +18 -0
  90. package/dist/extension/pipeline/resume.js +106 -0
  91. package/dist/extension/pipeline/runCostTable.d.ts +37 -0
  92. package/dist/extension/pipeline/runCostTable.js +165 -0
  93. package/dist/extension/pipeline/runRegistry.d.ts +112 -0
  94. package/dist/extension/pipeline/runRegistry.js +202 -0
  95. package/dist/extension/pipeline/runSession.d.ts +152 -0
  96. package/dist/extension/pipeline/runSession.js +167 -0
  97. package/dist/extension/pipeline/runState.d.ts +177 -0
  98. package/dist/extension/pipeline/runState.js +275 -0
  99. package/dist/extension/pipeline/runner.d.ts +98 -0
  100. package/dist/extension/pipeline/runner.js +310 -0
  101. package/dist/extension/pipeline/scrubSecrets.d.ts +15 -0
  102. package/dist/extension/pipeline/scrubSecrets.js +32 -0
  103. package/dist/extension/pipeline/stages.d.ts +53 -0
  104. package/dist/extension/pipeline/stages.js +115 -0
  105. package/dist/extension/pipeline/ticketResolution.d.ts +34 -0
  106. package/dist/extension/pipeline/ticketResolution.js +75 -0
  107. package/dist/extension/pipeline/types.d.ts +439 -0
  108. package/dist/extension/pipeline/types.js +48 -0
  109. package/dist/extension/pipeline/verify.d.ts +277 -0
  110. package/dist/extension/pipeline/verify.js +763 -0
  111. package/dist/extension/pipeline/workspace.d.ts +32 -0
  112. package/dist/extension/pipeline/workspace.js +73 -0
  113. package/dist/extension/pipeline/worktree.d.ts +107 -0
  114. package/dist/extension/pipeline/worktree.js +202 -0
  115. package/dist/extension/provider.d.ts +17 -0
  116. package/dist/extension/provider.js +33 -0
  117. package/dist/extension/recall.d.ts +93 -0
  118. package/dist/extension/recall.js +190 -0
  119. package/dist/extension/recordContextTool.d.ts +38 -0
  120. package/dist/extension/recordContextTool.js +85 -0
  121. package/dist/extension/recordDecisionTool.d.ts +52 -0
  122. package/dist/extension/recordDecisionTool.js +102 -0
  123. package/dist/extension/repoDocs.d.ts +81 -0
  124. package/dist/extension/repoDocs.js +260 -0
  125. package/dist/extension/rerouteNotice.d.ts +42 -0
  126. package/dist/extension/rerouteNotice.js +67 -0
  127. package/dist/extension/resilientFetch.d.ts +60 -0
  128. package/dist/extension/resilientFetch.js +133 -0
  129. package/dist/extension/reviewTool.d.ts +34 -0
  130. package/dist/extension/reviewTool.js +81 -0
  131. package/dist/extension/sessionRuns.d.ts +45 -0
  132. package/dist/extension/sessionRuns.js +77 -0
  133. package/dist/extension/spool.d.ts +92 -0
  134. package/dist/extension/spool.js +266 -0
  135. package/dist/extension/stateHome.d.ts +2 -0
  136. package/dist/extension/stateHome.js +6 -0
  137. package/dist/extension/subagents.d.ts +145 -0
  138. package/dist/extension/subagents.js +326 -0
  139. package/dist/extension/surface.d.ts +10 -0
  140. package/dist/extension/surface.js +12 -0
  141. package/dist/extension/todos.d.ts +110 -0
  142. package/dist/extension/todos.js +217 -0
  143. package/dist/extension/tokenProvider.d.ts +93 -0
  144. package/dist/extension/tokenProvider.js +234 -0
  145. package/dist/launch.d.ts +25 -3
  146. package/dist/launch.js +27 -9
  147. package/dist/login.d.ts +7 -0
  148. package/dist/login.js +3 -1
  149. package/dist/paths.d.ts +13 -4
  150. package/dist/paths.js +17 -5
  151. package/dist/profiles.d.ts +1 -1
  152. package/dist/profiles.js +5 -2
  153. package/dist/upgrade.d.ts +97 -0
  154. package/dist/upgrade.js +284 -0
  155. package/package.json +11 -15
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Crash reporting for the YAGNI Code launcher (and, via deep import, the
3
+ * desktop driver): a sanitized, fire-and-forget POST to the customer's own
4
+ * YAGNI backend at /api/yagni-code/crash. Deliberately no third-party
5
+ * telemetry SDK — crash reports are the ONLY thing this CLI phones home, they
6
+ * contain no code and no prompts, and `YAGNI_DISABLE_CRASH_REPORTS=1` turns
7
+ * them off entirely (same switch family as YAGNI_DISABLE_UPDATE_CHECK).
8
+ *
9
+ * Everything here is fail-soft: reporting never throws, never retries, never
10
+ * blocks longer than the short timeout, and never changes how the process
11
+ * would otherwise crash.
12
+ *
13
+ * The sanitizer core (scrub patterns + env-value redaction + path collapse +
14
+ * caps) is duplicated in `pi-extension-yagni/src/crashReport.ts` (the
15
+ * extension is bundled standalone and cannot import across packages) and
16
+ * re-run server-side in `backend/src/yagniCode/crashReports.ts`. Keep the
17
+ * three in sync. Spec:
18
+ * docs/superpowers/specs/2026-08-08-crash-reporting-design.md
19
+ */
20
+ import { credentialsFromProfile, readActiveProfile } from "./profiles.js";
21
+ export const CRASH_REPORT_DISABLE_ENV = "YAGNI_DISABLE_CRASH_REPORTS";
22
+ export const CRASH_REPORT_TIMEOUT_MS = 1_500;
23
+ /** Hard ceiling the process-exit path waits for a report before exiting. */
24
+ export const CRASH_REPORT_EXIT_RACE_MS = 1_600;
25
+ export const MAX_CRASH_MESSAGE = 512;
26
+ export const MAX_CRASH_ERROR_CLASS = 128;
27
+ export const MAX_CRASH_STACK = 8_000;
28
+ export const MAX_CRASH_STACK_FRAMES = 40;
29
+ /** If the serialized payload would exceed this, the stack is dropped. */
30
+ export const MAX_CRASH_PAYLOAD_BYTES = 16_384;
31
+ /** Same truthiness rule as updateChecksDisabled: set and not "" / "0". */
32
+ export function crashReportsDisabled(env = process.env) {
33
+ const value = env[CRASH_REPORT_DISABLE_ENV];
34
+ return value !== undefined && value !== "" && value !== "0";
35
+ }
36
+ // Mirrors scrubSecrets (backend yagniCode/scrubSecrets.ts and
37
+ // pi-extension-yagni pipeline/scrubSecrets.ts) — keep in sync.
38
+ const SECRET_PATTERNS = [
39
+ [/\b([a-z][a-z0-9+.\-]*:\/\/[^\s:@/]+):[^\s:@/]+@/gi, "$1:[REDACTED]@"],
40
+ [/\b(sk-[A-Za-z0-9]{16,}|sk_(?:live|test)_[A-Za-z0-9]{16,}|rk_(?:live|test)_[A-Za-z0-9]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_\-]{20,})\b/g, "[REDACTED]"],
41
+ [/\b([A-Za-z0-9_]*(?:secret|password|passwd|api[_-]?key|token|private[_-]?key|access[_-]?key)[A-Za-z0-9_]*)\b(\s*[:=]\s*)("[^"]+"|'[^']+'|`[^`]+`|[^\s"']+)/gi, "$1$2[REDACTED]"],
42
+ [/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, "[REDACTED]"],
43
+ ];
44
+ const HOME_DIR_RE = /(?:\/(?:Users|home)\/|[A-Za-z]:\\Users\\)[^\s/\\]+/g;
45
+ /**
46
+ * Absolute or ~-rooted path tokens (colon excluded so `file.ts:10:5` keeps its
47
+ * location). The lookbehind stops a mid-path slash from matching, so an
48
+ * already-relative path (`src/pipeline/run.ts` after the repo-root strip)
49
+ * survives intact instead of being re-collapsed at its inner slashes. Spaces
50
+ * are deliberately ALLOWED inside the token: real directories contain spaces
51
+ * ("~/Client Confidential Project/src/x.ts"), and a space-stopping class
52
+ * would leak every segment after the first space verbatim. The cost is that
53
+ * prose following a path can be folded into the kept basename — over-redacts
54
+ * rather than under-redacts.
55
+ */
56
+ const PATH_TOKEN_RE = /(?<![\w.~/\\])(?:~\/|\/|[A-Za-z]:\\)[^\n():'",]+/g;
57
+ /** Values too short to redact safely ("1", "true", "utf-8" would shred text). */
58
+ const MIN_ENV_VALUE_LENGTH = 8;
59
+ function isPathLikeValue(value) {
60
+ // Filesystem-path values (HOME, PWD, PATH, TMPDIR…) are handled — and more
61
+ // readably — by the path rules; replacing them here would break ~-collapse.
62
+ return value.startsWith("/") || value.startsWith("~") || /^[A-Za-z]:\\/.test(value);
63
+ }
64
+ function collapsePathToken(token) {
65
+ const nm = token.lastIndexOf("node_modules/");
66
+ if (nm >= 0)
67
+ return token.slice(nm);
68
+ const cut = Math.max(token.lastIndexOf("/"), token.lastIndexOf("\\"));
69
+ const base = cut >= 0 ? token.slice(cut + 1) : token;
70
+ return base.length > 0 ? base : token;
71
+ }
72
+ /**
73
+ * Sanitize one piece of crash text. Order matters:
74
+ * 1. env-value redaction (non-path values, length >= 8) → `[ENV:NAME]`
75
+ * 2. secret patterns (keys, connection strings, base64 blobs)
76
+ * 3. home-dir collapse to `~`
77
+ * 4. repo-root strip (repo files stay readable as relative paths)
78
+ * 5. every remaining absolute/`~` path → basename (node_modules kept from
79
+ * `node_modules/` on, so dependency frames stay diagnosable)
80
+ * Over-redacts rather than under-redacts; pure; never throws.
81
+ */
82
+ export function sanitizeCrashText(text, opts = {}) {
83
+ let out = text;
84
+ const env = opts.env ?? process.env;
85
+ const entries = Object.entries(env)
86
+ .filter((entry) => typeof entry[1] === "string" &&
87
+ entry[1].length >= MIN_ENV_VALUE_LENGTH &&
88
+ !isPathLikeValue(entry[1]))
89
+ // Longest first so a value containing another value cannot leave a tail.
90
+ .sort((a, b) => b[1].length - a[1].length);
91
+ for (const [name, value] of entries) {
92
+ if (out.includes(value))
93
+ out = out.split(value).join(`[ENV:${name}]`);
94
+ }
95
+ for (const [re, repl] of SECRET_PATTERNS)
96
+ out = out.replace(re, repl);
97
+ out = out.replace(HOME_DIR_RE, "~");
98
+ if (opts.repoRoot) {
99
+ const root = opts.repoRoot.replace(HOME_DIR_RE, "~").replace(/\/+$/, "");
100
+ if (root.length > 1)
101
+ out = out.split(`${root}/`).join("");
102
+ }
103
+ out = out.replace(PATH_TOKEN_RE, collapsePathToken);
104
+ return out;
105
+ }
106
+ /**
107
+ * Reduce an arbitrary thrown value to sanitized error facts. Reads ONLY
108
+ * `name`, `message`, and `stack` — custom fields (response bodies, request
109
+ * payloads…) are never touched.
110
+ */
111
+ export function sanitizeCrashError(err, opts = {}) {
112
+ let errorClass = "Error";
113
+ let message;
114
+ let stack;
115
+ if (err instanceof Error) {
116
+ errorClass = err.name || "Error";
117
+ message = err.message;
118
+ stack = typeof err.stack === "string" ? err.stack : undefined;
119
+ }
120
+ else {
121
+ try {
122
+ message = String(err);
123
+ }
124
+ catch {
125
+ message = "unstringifiable thrown value";
126
+ }
127
+ }
128
+ const cappedStack = stack !== undefined
129
+ ? sanitizeCrashText(stack, opts)
130
+ .split("\n")
131
+ .slice(0, MAX_CRASH_STACK_FRAMES)
132
+ .join("\n")
133
+ .slice(0, MAX_CRASH_STACK)
134
+ : undefined;
135
+ return {
136
+ errorClass: sanitizeCrashText(errorClass, opts).slice(0, MAX_CRASH_ERROR_CLASS),
137
+ message: sanitizeCrashText(message, opts).slice(0, MAX_CRASH_MESSAGE),
138
+ ...(cappedStack !== undefined ? { stack: cappedStack } : {}),
139
+ };
140
+ }
141
+ /**
142
+ * Sanitize + POST one crash report from pre-extracted fields. Resolves on
143
+ * every outcome — timeout, network error, non-2xx, disabled — and never
144
+ * throws; there is nobody upstream who could act on a failure, and a
145
+ * reporting bug must never re-crash the process.
146
+ */
147
+ export async function sendCrashReport(input) {
148
+ try {
149
+ const env = input.env ?? process.env;
150
+ if (crashReportsDisabled(env))
151
+ return;
152
+ const opts = { env, repoRoot: input.repoRoot };
153
+ const stack = input.stack !== undefined
154
+ ? sanitizeCrashText(input.stack, opts)
155
+ .split("\n")
156
+ .slice(0, MAX_CRASH_STACK_FRAMES)
157
+ .join("\n")
158
+ .slice(0, MAX_CRASH_STACK)
159
+ : undefined;
160
+ const payload = {
161
+ client: input.client,
162
+ clientVersion: input.clientVersion,
163
+ platform: `${process.platform} ${process.arch}`,
164
+ errorClass: sanitizeCrashText(input.errorClass, opts).slice(0, MAX_CRASH_ERROR_CLASS),
165
+ message: sanitizeCrashText(input.message, opts).slice(0, MAX_CRASH_MESSAGE),
166
+ ...(stack !== undefined ? { stack } : {}),
167
+ ...(input.context !== undefined ? { context: input.context } : {}),
168
+ timestamp: new Date().toISOString(),
169
+ };
170
+ if (JSON.stringify(payload).length > MAX_CRASH_PAYLOAD_BYTES) {
171
+ delete payload.stack;
172
+ }
173
+ const fetchImpl = input.fetchImpl ?? fetch;
174
+ const controller = new AbortController();
175
+ const timer = setTimeout(() => controller.abort(), input.timeoutMs ?? CRASH_REPORT_TIMEOUT_MS);
176
+ timer.unref?.();
177
+ try {
178
+ await fetchImpl(`${input.baseUrl.replace(/\/$/, "")}/api/yagni-code/crash`, {
179
+ method: "POST",
180
+ headers: {
181
+ "content-type": "application/json",
182
+ authorization: `Bearer ${input.token}`,
183
+ },
184
+ body: JSON.stringify(payload),
185
+ signal: controller.signal,
186
+ });
187
+ }
188
+ finally {
189
+ clearTimeout(timer);
190
+ }
191
+ }
192
+ catch {
193
+ // fail-soft by contract
194
+ }
195
+ }
196
+ /**
197
+ * Fire one crash report from a live thrown value. Same never-throws contract
198
+ * as {@link sendCrashReport}, which does the sending.
199
+ */
200
+ export async function reportCrash(input) {
201
+ try {
202
+ const env = input.env ?? process.env;
203
+ const sanitized = sanitizeCrashError(input.error, { env, repoRoot: input.repoRoot });
204
+ await sendCrashReport({
205
+ client: input.client,
206
+ clientVersion: input.clientVersion,
207
+ errorClass: sanitized.errorClass,
208
+ message: sanitized.message,
209
+ stack: sanitized.stack,
210
+ context: input.context,
211
+ baseUrl: input.baseUrl,
212
+ token: input.token,
213
+ env,
214
+ repoRoot: input.repoRoot,
215
+ fetchImpl: input.fetchImpl,
216
+ timeoutMs: input.timeoutMs,
217
+ });
218
+ }
219
+ catch {
220
+ // fail-soft by contract
221
+ }
222
+ }
223
+ async function resolveProfileTarget() {
224
+ try {
225
+ const profile = await readActiveProfile();
226
+ const creds = credentialsFromProfile(profile);
227
+ if (!creds?.token)
228
+ return null;
229
+ return { baseUrl: creds.baseUrl, token: creds.token };
230
+ }
231
+ catch {
232
+ return null;
233
+ }
234
+ }
235
+ /**
236
+ * The handler behind uncaughtException/unhandledRejection: report (bounded by
237
+ * `raceMs`), then preserve today's crash behavior — print the error to stderr
238
+ * and exit 1. Exposed for tests; `installProcessCrashHandlers` registers it.
239
+ */
240
+ export function makeProcessCrashHandler(context, opts) {
241
+ const exit = opts.exit ?? ((code) => process.exit(code));
242
+ const writeStderr = opts.writeStderr ?? ((text) => process.stderr.write(text));
243
+ return (err) => {
244
+ void (async () => {
245
+ try {
246
+ const resolveTarget = opts.resolveTarget ?? resolveProfileTarget;
247
+ const target = await resolveTarget();
248
+ if (target) {
249
+ // The race timer is deliberately ref'd: it guarantees the exit path
250
+ // even if the fetch never settles.
251
+ await Promise.race([
252
+ reportCrash({
253
+ client: opts.client,
254
+ clientVersion: opts.clientVersion,
255
+ error: err,
256
+ context,
257
+ baseUrl: target.baseUrl,
258
+ token: target.token,
259
+ env: opts.env,
260
+ repoRoot: opts.repoRoot,
261
+ fetchImpl: opts.fetchImpl,
262
+ }),
263
+ new Promise((resolve) => setTimeout(resolve, opts.raceMs ?? CRASH_REPORT_EXIT_RACE_MS)),
264
+ ]);
265
+ }
266
+ }
267
+ catch {
268
+ // reporting must never re-crash the crash path
269
+ }
270
+ try {
271
+ const text = err instanceof Error ? (err.stack ?? err.message) : String(err);
272
+ writeStderr(`${text}\n`);
273
+ }
274
+ catch {
275
+ // even stderr can fail (EPIPE); still exit
276
+ }
277
+ exit(1);
278
+ })();
279
+ };
280
+ }
281
+ /**
282
+ * Install process-level crash reporting. Call once, before main() runs, from
283
+ * a process YAGNI owns (the launcher, the desktop driver) — never from inside
284
+ * pi's process, where adding these listeners would change crash semantics.
285
+ */
286
+ export function installProcessCrashHandlers(opts) {
287
+ const proc = opts.proc ?? process;
288
+ proc.on("uncaughtException", makeProcessCrashHandler("uncaught-exception", opts));
289
+ proc.on("unhandledRejection", makeProcessCrashHandler("unhandled-rejection", opts));
290
+ }
291
+ //# sourceMappingURL=crashReport.js.map
@@ -5,6 +5,7 @@
5
5
  import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
6
6
  import { homedir } from "node:os";
7
7
  import { dirname, join } from "node:path";
8
+ import { DISTRIBUTION } from "./distribution.js";
8
9
  /**
9
10
  * Test seam: when set, all path resolution roots here instead of
10
11
  * `~/.yagni-code`, so tests can exercise profile/credential storage against a
@@ -16,7 +17,7 @@ export function _setYagniCodeHomeForTest(dir) {
16
17
  homeOverride = dir;
17
18
  }
18
19
  export function credentialsDir() {
19
- return homeOverride ?? join(homedir(), ".yagni-code");
20
+ return homeOverride ?? join(homedir(), DISTRIBUTION.stateDirName);
20
21
  }
21
22
  export function credentialsPath() {
22
23
  return join(credentialsDir(), "credentials.json");
@@ -0,0 +1,15 @@
1
+ export type DistributionChannel = "production" | "staging";
2
+ export interface CodeDistribution {
3
+ channel: DistributionChannel;
4
+ packageName: string;
5
+ commandName: string;
6
+ displayName: string;
7
+ defaultBaseUrl: string;
8
+ defaultProfile: string;
9
+ stateDirName: string;
10
+ updateTag: "latest" | "staging";
11
+ }
12
+ export declare function distributionForPackageName(packageName: string): CodeDistribution;
13
+ export declare function resolveDistribution(env?: NodeJS.ProcessEnv, packageName?: string): CodeDistribution;
14
+ export declare const DISTRIBUTION: CodeDistribution;
15
+ //# sourceMappingURL=distribution.d.ts.map
@@ -0,0 +1,45 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ const PRODUCTION = {
5
+ channel: "production",
6
+ packageName: "@yagni-app/code",
7
+ commandName: "yagni",
8
+ displayName: "YAGNI Code",
9
+ defaultBaseUrl: "https://yagni.app",
10
+ defaultProfile: "prod",
11
+ stateDirName: ".yagni-code",
12
+ updateTag: "latest",
13
+ };
14
+ const STAGING = {
15
+ channel: "staging",
16
+ packageName: "@yagni-app/code-staging",
17
+ commandName: "yagni-staging",
18
+ displayName: "YAGNI Code Staging",
19
+ defaultBaseUrl: "https://yagni-staging.app",
20
+ defaultProfile: "staging",
21
+ stateDirName: ".yagni-code-staging",
22
+ updateTag: "staging",
23
+ };
24
+ export function distributionForPackageName(packageName) {
25
+ return packageName === STAGING.packageName ? { ...STAGING } : { ...PRODUCTION };
26
+ }
27
+ function installedPackageName() {
28
+ try {
29
+ const path = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
30
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
31
+ return parsed.name ?? PRODUCTION.packageName;
32
+ }
33
+ catch {
34
+ return PRODUCTION.packageName;
35
+ }
36
+ }
37
+ export function resolveDistribution(env = process.env, packageName = installedPackageName()) {
38
+ if (env.YAGNI_CODE_DISTRIBUTION === "staging")
39
+ return { ...STAGING };
40
+ if (env.YAGNI_CODE_DISTRIBUTION === "production")
41
+ return { ...PRODUCTION };
42
+ return distributionForPackageName(packageName);
43
+ }
44
+ export const DISTRIBUTION = resolveDistribution();
45
+ //# sourceMappingURL=distribution.js.map
package/dist/doctor.d.ts CHANGED
@@ -55,7 +55,18 @@ export declare function checkProfileToken(profile: Pick<Profile, "name" | "token
55
55
  export declare function checkTokenExpiry(status: TokenExpiryStatus): CheckResult;
56
56
  export declare function checkBackend(probe: BackendProbe): CheckResult;
57
57
  export declare function checkStateDir(probe: StateDirProbe): CheckResult;
58
+ export declare function checkCliUpdate(probe: {
59
+ current: string;
60
+ latest: string | null;
61
+ }): CheckResult;
58
62
  export declare function checkGh(onPath: boolean): CheckResult;
63
+ /** What the Windows bash probe found (pi needs a bash — Git Bash — on win32). */
64
+ export interface BashProbe {
65
+ found: boolean;
66
+ /** The resolved bash path, when found. */
67
+ where?: string;
68
+ }
69
+ export declare function checkBash(probe: BashProbe): CheckResult;
59
70
  export interface DoctorReport {
60
71
  checks: CheckResult[];
61
72
  exitCode: number;
@@ -71,10 +82,26 @@ export interface DoctorDeps {
71
82
  probeBackend?: (baseUrl: string, token: string) => Promise<BackendProbe>;
72
83
  probeStateDir?: () => StateDirProbe;
73
84
  ghOnPath?: () => boolean;
85
+ /** Platform seam for the win32-only bash check (defaults to process.platform). */
86
+ platform?: NodeJS.Platform;
87
+ /** Windows bash probe; only ever called when the platform is win32. */
88
+ probeBash?: () => BashProbe;
89
+ currentVersion?: string;
90
+ probeLatestVersion?: () => Promise<string | null>;
74
91
  log?: (msg: string) => void;
75
92
  }
76
93
  /** Whether a `gh` executable is resolvable on PATH (no subprocess spawn). */
77
94
  export declare function ghOnPathDefault(env?: NodeJS.ProcessEnv): boolean;
95
+ /**
96
+ * Locate the bash pi will actually use on Windows. The order and locations
97
+ * MIRROR pi 0.83's own shell resolution (dist/utils/shell.js) exactly:
98
+ * `%ProgramFiles%\Git\bin\bash.exe`, then `%ProgramFiles(x86)%\Git\bin\bash.exe`,
99
+ * then `bash.exe` on PATH (`where bash.exe`). Deliberately NOTHING wider — a
100
+ * per-user Git install in `%LOCALAPPDATA%` that is not on PATH is invisible
101
+ * to pi, and a doctor that reported it green would bless a machine where the
102
+ * first bash tool call throws. Pure function of env, like the gh probe.
103
+ */
104
+ export declare function bashOnWindowsDefault(env?: NodeJS.ProcessEnv): BashProbe;
78
105
  /**
79
106
  * Gather every check result against the (injectable) probes. Pure ordering; each
80
107
  * individual check is a pure function of its probe.
package/dist/doctor.js CHANGED
@@ -15,6 +15,7 @@
15
15
  import { existsSync, readFileSync, statSync } from "node:fs";
16
16
  import { delimiter, join } from "node:path";
17
17
  import { credentialsDir } from "./credentials.js";
18
+ import { currentCliVersion, fetchLatestVersion, isNewerVersion } from "./upgrade.js";
18
19
  import { classifyTokenExpiry } from "./launch.js";
19
20
  import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir } from "./paths.js";
20
21
  import { readActiveProfile } from "./profiles.js";
@@ -41,12 +42,12 @@ export function checkExtension(probe) {
41
42
  return {
42
43
  name: "extension",
43
44
  status: "fail",
44
- detail: "pi-extension-yagni not built (dist/index.js missing)",
45
- hint: "reinstall it: npm i -g @yagni-app/code (monorepo: pnpm --filter pi-extension-yagni build)",
45
+ detail: "YAGNI extension not found (dist/extension/index.js missing)",
46
+ hint: "reinstall it: npm i -g @yagni-app/code (monorepo: pnpm --filter @yagni-app/code build)",
46
47
  required: true,
47
48
  };
48
49
  }
49
- return { name: "extension", status: "ok", detail: "pi-extension-yagni built", required: true };
50
+ return { name: "extension", status: "ok", detail: "YAGNI extension loaded", required: true };
50
51
  }
51
52
  export function checkProfileToken(profile) {
52
53
  if (!profile.token) {
@@ -160,6 +161,26 @@ export function checkStateDir(probe) {
160
161
  }
161
162
  return { name: "state dir", status: "ok", detail: `${probe.path} is 0700`, required: false };
162
163
  }
164
+ export function checkCliUpdate(probe) {
165
+ if (probe.latest === null) {
166
+ return {
167
+ name: "cli version",
168
+ status: "ok",
169
+ detail: `v${probe.current} (update check unavailable)`,
170
+ required: false,
171
+ };
172
+ }
173
+ if (isNewerVersion(probe.current, probe.latest)) {
174
+ return {
175
+ name: "cli version",
176
+ status: "warn",
177
+ detail: `v${probe.current} — v${probe.latest} is available`,
178
+ hint: "run `yagni upgrade`",
179
+ required: false,
180
+ };
181
+ }
182
+ return { name: "cli version", status: "ok", detail: `v${probe.current} (latest)`, required: false };
183
+ }
163
184
  export function checkGh(onPath) {
164
185
  if (onPath) {
165
186
  return {
@@ -177,6 +198,24 @@ export function checkGh(onPath) {
177
198
  required: false,
178
199
  };
179
200
  }
201
+ export function checkBash(probe) {
202
+ if (!probe.found) {
203
+ return {
204
+ name: "bash",
205
+ status: "fail",
206
+ detail: "no bash found (pi runs its shell commands through bash)",
207
+ hint: "Install Git for Windows — pi needs its bash: https://gitforwindows.org "
208
+ + "(a per-user install must also put bash.exe on PATH)",
209
+ required: true,
210
+ };
211
+ }
212
+ return {
213
+ name: "bash",
214
+ status: "ok",
215
+ detail: probe.where ? `found (${probe.where})` : "found",
216
+ required: true,
217
+ };
218
+ }
180
219
  function toOctal(mode) {
181
220
  return `0${(mode & 0o777).toString(8).padStart(3, "0")}`;
182
221
  }
@@ -275,6 +314,32 @@ export function ghOnPathDefault(env = process.env) {
275
314
  }
276
315
  return false;
277
316
  }
317
+ /**
318
+ * Locate the bash pi will actually use on Windows. The order and locations
319
+ * MIRROR pi 0.83's own shell resolution (dist/utils/shell.js) exactly:
320
+ * `%ProgramFiles%\Git\bin\bash.exe`, then `%ProgramFiles(x86)%\Git\bin\bash.exe`,
321
+ * then `bash.exe` on PATH (`where bash.exe`). Deliberately NOTHING wider — a
322
+ * per-user Git install in `%LOCALAPPDATA%` that is not on PATH is invisible
323
+ * to pi, and a doctor that reported it green would bless a machine where the
324
+ * first bash tool call throws. Pure function of env, like the gh probe.
325
+ */
326
+ export function bashOnWindowsDefault(env = process.env) {
327
+ for (const root of [env.ProgramFiles, env["ProgramFiles(x86)"]]) {
328
+ if (!root)
329
+ continue;
330
+ const candidate = join(root, "Git", "bin", "bash.exe");
331
+ if (existsSync(candidate))
332
+ return { found: true, where: candidate };
333
+ }
334
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
335
+ if (!dir)
336
+ continue;
337
+ const candidate = join(dir, "bash.exe");
338
+ if (existsSync(candidate))
339
+ return { found: true, where: candidate };
340
+ }
341
+ return { found: false };
342
+ }
278
343
  /**
279
344
  * Gather every check result against the (injectable) probes. Pure ordering; each
280
345
  * individual check is a pure function of its probe.
@@ -287,9 +352,22 @@ export async function gatherChecks(deps = {}) {
287
352
  const probeBackend = deps.probeBackend ?? defaultProbeBackend;
288
353
  const probeStateDir = deps.probeStateDir ?? defaultProbeStateDir;
289
354
  const ghOnPath = deps.ghOnPath ?? (() => ghOnPathDefault());
355
+ const platform = deps.platform ?? process.platform;
356
+ const probeBash = deps.probeBash ?? (() => bashOnWindowsDefault());
357
+ const probeLatestVersion = deps.probeLatestVersion ?? (() => fetchLatestVersion());
290
358
  const checks = [];
291
359
  checks.push(checkPiEngine(probePiEngine()));
292
360
  checks.push(checkExtension(probeExtension()));
361
+ // win32 only, and skipped means NOT SHOWN: on macOS/Linux there is nothing
362
+ // to say. pi shells out through bash, so a Windows machine without Git Bash
363
+ // cannot launch at all — a required red, like a missing engine.
364
+ if (platform === "win32") {
365
+ checks.push(checkBash(probeBash()));
366
+ }
367
+ checks.push(checkCliUpdate({
368
+ current: deps.currentVersion ?? currentCliVersion(),
369
+ latest: await probeLatestVersion(),
370
+ }));
293
371
  const profile = await readProfile();
294
372
  checks.push(checkProfileToken(profile));
295
373
  if (profile.token) {
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The PURE half of the ask_advisor escalation (YAG-380).
3
+ *
4
+ * Balanced sessions drive on `advanced` and escalate hard judgment calls to
5
+ * `peak`. This module owns every decision about whether a consult may happen and
6
+ * what it cost; `askAdvisorTool.ts` owns the spawn. Same split as `permission.ts`
7
+ * (`decideGate` pure, wiring separate), for the same reason: the rules are what
8
+ * need exhaustive tests, and they must not require a child process to exercise.
9
+ *
10
+ * Two bounds, both necessarily POST-HOC. pi exposes no pre-emptive turn or token
11
+ * ceiling (see `pipeline/budget.ts`), so a consult's cost is only knowable once
12
+ * the child has exited:
13
+ *
14
+ * - `maxConsults` is the real product bound. It makes escalation predictable
15
+ * and priceable, which is the point of the cap.
16
+ * - `maxSpend` is a RUNAWAY CATCH, not a budget — deliberately generous, in
17
+ * the same spirit as `DEFAULT_RUN_BUDGET`. A thorough consult on a large
18
+ * repo runs $0.40-$1.00, so a full 5-consult session lands near $2-$5 and
19
+ * never approaches this. It exists only so an advisor stuck in a loop has
20
+ * something to stop it.
21
+ *
22
+ * State is held in a closure via {@link makeAdvisorState}; no module-level
23
+ * mutable state, so two sessions in one process cannot bleed into each other.
24
+ */
25
+ /** The session tier that may escalate. Balanced only, by design. */
26
+ export declare const ADVISOR_TIER = "balanced";
27
+ /** The tier a consult itself runs on. */
28
+ export declare const ADVISOR_MODEL_TIER = "peak";
29
+ /** Bounds on escalation within a single session. */
30
+ export interface AdvisorLimits {
31
+ /** Hard cap on consults per session. */
32
+ maxConsults: number;
33
+ /** Runaway-catch ceiling on aggregate advisor spend, in the same unit as StageUsage.cost. */
34
+ maxSpend: number;
35
+ }
36
+ export declare const DEFAULT_ADVISOR_LIMITS: AdvisorLimits;
37
+ /** Mutable per-session advisor accounting. */
38
+ export interface AdvisorState {
39
+ consults: number;
40
+ spend: number;
41
+ }
42
+ /** A live session's advisor accounting, held in a closure. */
43
+ export interface AdvisorStateHandle {
44
+ read(): AdvisorState;
45
+ /** Record a completed consult. Returns the state after recording. */
46
+ record(cost: number): AdvisorState;
47
+ }
48
+ export declare function makeAdvisorState(): AdvisorStateHandle;
49
+ /** The outcome of the pre-consult gate. */
50
+ export interface ConsultDecision {
51
+ allow: boolean;
52
+ /** Plain-text explanation when disallowed, surfaced to the model verbatim. */
53
+ reason?: string;
54
+ }
55
+ export interface ConsultGateInput {
56
+ /** The LIVE session model id (pi's picker can change this mid-session). */
57
+ model: string | undefined;
58
+ state: AdvisorState;
59
+ limits: AdvisorLimits;
60
+ }
61
+ /**
62
+ * Decide whether one consult may proceed. PURE.
63
+ *
64
+ * The tier check reads the model at CALL time rather than at registration time:
65
+ * tools register once at activation but pi's picker can switch the session model
66
+ * afterwards, so a registration-time check would both leave the tool live after
67
+ * switching away from Balanced and hide it forever from a session that switched
68
+ * to Balanced.
69
+ */
70
+ export declare function decideConsult(input: ConsultGateInput): ConsultDecision;
71
+ /**
72
+ * The line appended to a consult's advice so spend is visible at the moment it
73
+ * happens. `/cost` cannot see child processes at all (it accumulates off the
74
+ * parent's `turn_end`), so without this an advisor consult is invisible — see
75
+ * YAG-383 for the server-authoritative fix.
76
+ */
77
+ export declare function formatConsultCost(input: {
78
+ cost: number;
79
+ consults: number;
80
+ limits: AdvisorLimits;
81
+ }): string;
82
+ /** The advisor subtotal line `/cost` appends when a session has escalated. */
83
+ export declare function formatAdvisorSubtotal(state: AdvisorState, limits: AdvisorLimits): string;
84
+ //# sourceMappingURL=advisor.d.ts.map