@yagni-app/code 0.1.0 → 0.2.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 (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 +34 -0
  126. package/dist/extension/rerouteNotice.js +79 -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,241 @@
1
+ /**
2
+ * Crash reporting for the pi extension: a sanitized, fire-and-forget POST to
3
+ * the YAGNI backend's /api/yagni-code/crash. No third-party telemetry SDK —
4
+ * crash reports are the only thing that phones home, they contain no code and
5
+ * no prompts, and `YAGNI_DISABLE_CRASH_REPORTS=1` turns them off.
6
+ *
7
+ * Two report paths use this module:
8
+ * - `uncaughtExceptionMonitor` (installed by index.ts): the MONITOR hook is
9
+ * chosen deliberately — unlike an `uncaughtException` listener it never
10
+ * changes crash semantics in pi's process (which YAGNI does not own); it
11
+ * only observes the crash that is already happening. This is also the
12
+ * "tool error that indicates a crash, not a user error" discriminator:
13
+ * a user-visible tool failure is caught and rendered by pi, while
14
+ * anything reaching the monitor was about to kill the session.
15
+ * - the /go pipeline's terminal catch (goCommand.ts), where a thrown error
16
+ * — not a verdict-shaped failure — ends the run.
17
+ *
18
+ * Everything is fail-soft: never throws, never retries, single short-timeout
19
+ * attempt, and reporting can never affect the user's session.
20
+ *
21
+ * The sanitizer (env-value redaction + path collapse + caps around the shared
22
+ * scrubSecrets patterns) is kept in sync with
23
+ * `yagni-code-cli/src/crashReport.ts`; the backend re-runs its own copy in
24
+ * `backend/src/yagniCode/crashReports.ts`. Spec:
25
+ * docs/superpowers/specs/2026-08-08-crash-reporting-design.md
26
+ */
27
+ import { spawn } from "node:child_process";
28
+ import { scrubSecrets } from "./pipeline/scrubSecrets.js";
29
+ import { isDesktopSurface } from "./surface.js";
30
+ const defaultSpawn = (command, args, options) => spawn(command, args, options);
31
+ export const CRASH_REPORT_DISABLE_ENV = "YAGNI_DISABLE_CRASH_REPORTS";
32
+ export const CRASH_REPORT_TIMEOUT_MS = 1_500;
33
+ export const MAX_CRASH_MESSAGE = 512;
34
+ export const MAX_CRASH_ERROR_CLASS = 128;
35
+ export const MAX_CRASH_STACK = 8_000;
36
+ export const MAX_CRASH_STACK_FRAMES = 40;
37
+ export const MAX_CRASH_PAYLOAD_BYTES = 16_384;
38
+ /** Same truthiness rule as the CLI's YAGNI_DISABLE_* family. */
39
+ export function crashReportsDisabled(env = process.env) {
40
+ const value = env[CRASH_REPORT_DISABLE_ENV];
41
+ return value !== undefined && value !== "" && value !== "0";
42
+ }
43
+ const HOME_DIR_RE = /(?:\/(?:Users|home)\/|[A-Za-z]:\\Users\\)[^\s/\\]+/g;
44
+ // Spaces deliberately allowed inside the token (real directories contain
45
+ // them); prose after a path may fold into the kept basename — over-redacts
46
+ // rather than under-redacts. Keep in sync with the CLI copy.
47
+ const PATH_TOKEN_RE = /(?<![\w.~/\\])(?:~\/|\/|[A-Za-z]:\\)[^\n():'",]+/g;
48
+ const MIN_ENV_VALUE_LENGTH = 8;
49
+ function isPathLikeValue(value) {
50
+ return value.startsWith("/") || value.startsWith("~") || /^[A-Za-z]:\\/.test(value);
51
+ }
52
+ function collapsePathToken(token) {
53
+ const nm = token.lastIndexOf("node_modules/");
54
+ if (nm >= 0)
55
+ return token.slice(nm);
56
+ const cut = Math.max(token.lastIndexOf("/"), token.lastIndexOf("\\"));
57
+ const base = cut >= 0 ? token.slice(cut + 1) : token;
58
+ return base.length > 0 ? base : token;
59
+ }
60
+ /** Env values → [ENV:NAME], scrubSecrets patterns, ~-collapse, repo-relative, basename. */
61
+ export function sanitizeCrashText(text, opts = {}) {
62
+ let out = text;
63
+ const env = opts.env ?? process.env;
64
+ const entries = Object.entries(env)
65
+ .filter((entry) => typeof entry[1] === "string" &&
66
+ entry[1].length >= MIN_ENV_VALUE_LENGTH &&
67
+ !isPathLikeValue(entry[1]))
68
+ .sort((a, b) => b[1].length - a[1].length);
69
+ for (const [name, value] of entries) {
70
+ if (out.includes(value))
71
+ out = out.split(value).join(`[ENV:${name}]`);
72
+ }
73
+ out = scrubSecrets(out);
74
+ out = out.replace(HOME_DIR_RE, "~");
75
+ if (opts.repoRoot) {
76
+ const root = opts.repoRoot.replace(HOME_DIR_RE, "~").replace(/\/+$/, "");
77
+ if (root.length > 1)
78
+ out = out.split(`${root}/`).join("");
79
+ }
80
+ out = out.replace(PATH_TOKEN_RE, collapsePathToken);
81
+ return out;
82
+ }
83
+ /** Reads ONLY name/message/stack; custom error fields are never touched. */
84
+ export function sanitizeCrashError(err, opts = {}) {
85
+ let errorClass = "Error";
86
+ let message;
87
+ let stack;
88
+ if (err instanceof Error) {
89
+ errorClass = err.name || "Error";
90
+ message = err.message;
91
+ stack = typeof err.stack === "string" ? err.stack : undefined;
92
+ }
93
+ else {
94
+ try {
95
+ message = String(err);
96
+ }
97
+ catch {
98
+ message = "unstringifiable thrown value";
99
+ }
100
+ }
101
+ const cappedStack = stack !== undefined
102
+ ? sanitizeCrashText(stack, opts)
103
+ .split("\n")
104
+ .slice(0, MAX_CRASH_STACK_FRAMES)
105
+ .join("\n")
106
+ .slice(0, MAX_CRASH_STACK)
107
+ : undefined;
108
+ return {
109
+ errorClass: sanitizeCrashText(errorClass, opts).slice(0, MAX_CRASH_ERROR_CLASS),
110
+ message: sanitizeCrashText(message, opts).slice(0, MAX_CRASH_MESSAGE),
111
+ ...(cappedStack !== undefined ? { stack: cappedStack } : {}),
112
+ };
113
+ }
114
+ /**
115
+ * Build the fail-soft reporter. The extension runs inside pi's process, so
116
+ * the client label follows the surface: `desktop` under the desktop shell
117
+ * (YAGNI_SURFACE=desktop), `cli` in the terminal. Version comes from
118
+ * `YAGNI_CODE_VERSION` (forwarded by the launcher; the bundled extension has
119
+ * no package.json on disk). Resolves on every outcome; never throws.
120
+ */
121
+ export function makeCrashReporter(opts) {
122
+ return async (error, context, repoRoot) => {
123
+ try {
124
+ const env = opts.env ?? process.env;
125
+ if (crashReportsDisabled(env))
126
+ return;
127
+ const token = opts.getToken();
128
+ if (!token)
129
+ return;
130
+ const sanitized = sanitizeCrashError(error, { env, repoRoot });
131
+ const payload = {
132
+ client: isDesktopSurface() ? "desktop" : "cli",
133
+ clientVersion: env.YAGNI_CODE_VERSION?.trim() || "unknown",
134
+ platform: `${process.platform} ${process.arch}`,
135
+ ...sanitized,
136
+ ...(context !== undefined ? { context } : {}),
137
+ timestamp: new Date().toISOString(),
138
+ };
139
+ if (JSON.stringify(payload).length > MAX_CRASH_PAYLOAD_BYTES) {
140
+ delete payload.stack;
141
+ }
142
+ const fetchImpl = opts.fetchImpl ?? fetch;
143
+ const controller = new AbortController();
144
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? CRASH_REPORT_TIMEOUT_MS);
145
+ timer.unref?.();
146
+ try {
147
+ await fetchImpl(`${opts.baseUrl.replace(/\/$/, "")}/api/yagni-code/crash`, {
148
+ method: "POST",
149
+ headers: {
150
+ "content-type": "application/json",
151
+ authorization: `Bearer ${token}`,
152
+ },
153
+ body: JSON.stringify(payload),
154
+ signal: controller.signal,
155
+ });
156
+ }
157
+ finally {
158
+ clearTimeout(timer);
159
+ }
160
+ }
161
+ catch {
162
+ // fail-soft by contract
163
+ }
164
+ };
165
+ }
166
+ /**
167
+ * One-shot sender source for the detached delivery child. A fatal uncaught
168
+ * exception kills pi's process before any in-process fetch could connect, so
169
+ * the monitor path hands the (already sanitized) payload to a detached
170
+ * `node -e` child that survives the parent's death, POSTs once with a hard
171
+ * timeout, and exits. No retries, nothing written to disk. The payload, URL
172
+ * and token travel via the child's env — the same channel the launcher
173
+ * already uses to hand pi its YAGNI_TOKEN.
174
+ */
175
+ const DETACHED_SENDER_SRC = [
176
+ "const done = () => process.exit(0);",
177
+ "setTimeout(done, 5000).unref?.();",
178
+ "fetch(process.env.YAGNI_CRASH_URL, {",
179
+ " method: 'POST',",
180
+ " headers: {",
181
+ " 'content-type': 'application/json',",
182
+ " authorization: 'Bearer ' + process.env.YAGNI_CRASH_TOKEN,",
183
+ " },",
184
+ " body: process.env.YAGNI_CRASH_PAYLOAD,",
185
+ " signal: AbortSignal.timeout(4000),",
186
+ "}).catch(() => {}).finally(done);",
187
+ ].join("\n");
188
+ /**
189
+ * Deliver a crash report from a process that is about to die: build the
190
+ * sanitized payload in-process (cheap, synchronous), then spawn a detached
191
+ * one-shot sender that outlives the crash. Never throws.
192
+ */
193
+ export function reportFatalCrash(error, opts, context) {
194
+ try {
195
+ const env = opts.env ?? process.env;
196
+ if (crashReportsDisabled(env))
197
+ return;
198
+ const token = opts.getToken();
199
+ if (!token)
200
+ return;
201
+ const sanitized = sanitizeCrashError(error, { env });
202
+ const payload = {
203
+ client: isDesktopSurface() ? "desktop" : "cli",
204
+ clientVersion: env.YAGNI_CODE_VERSION?.trim() || "unknown",
205
+ platform: `${process.platform} ${process.arch}`,
206
+ ...sanitized,
207
+ ...(context !== undefined ? { context } : {}),
208
+ timestamp: new Date().toISOString(),
209
+ };
210
+ if (JSON.stringify(payload).length > MAX_CRASH_PAYLOAD_BYTES) {
211
+ delete payload.stack;
212
+ }
213
+ const spawnImpl = opts.spawnImpl ?? defaultSpawn;
214
+ const child = spawnImpl(process.execPath, ["-e", DETACHED_SENDER_SRC], {
215
+ detached: true,
216
+ stdio: "ignore",
217
+ env: {
218
+ // Minimal env: the sender needs nothing from the parent beyond these.
219
+ YAGNI_CRASH_URL: `${opts.baseUrl.replace(/\/$/, "")}/api/yagni-code/crash`,
220
+ YAGNI_CRASH_TOKEN: token,
221
+ YAGNI_CRASH_PAYLOAD: JSON.stringify(payload),
222
+ },
223
+ });
224
+ child.unref();
225
+ }
226
+ catch {
227
+ // a crash reporter must never add its own crash
228
+ }
229
+ }
230
+ /**
231
+ * Observe (never alter) a fatal crash in pi's process. Uses
232
+ * `uncaughtExceptionMonitor`, which fires before the process dies without
233
+ * installing a handler that would change node's default crash behavior —
234
+ * YAGNI does not own pi's process, so crash semantics must stay untouched.
235
+ */
236
+ export function installUncaughtExceptionMonitor(opts, proc = process) {
237
+ proc.on("uncaughtExceptionMonitor", (err) => {
238
+ reportFatalCrash(err, opts, "uncaught-exception");
239
+ });
240
+ }
241
+ //# sourceMappingURL=crashReport.js.map
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Decision capture at the human gate (spec §4, M3 — conservative).
3
+ *
4
+ * When the user picks "Yes, and don't ask again for <tool> in <dir>" at the
5
+ * review gate, that is a piece of durable judgment worth banking so YAGNI
6
+ * answers the same question next time instead of interrupting a human. Capture
7
+ * is deliberately conservative:
8
+ *
9
+ * - HEADLESS never auto-banks (no consent surface; review-mode bless is itself
10
+ * fail-closed headless, but this guards regardless).
11
+ * - TUI ALWAYS confirms via `ctx.ui.confirm` before banking.
12
+ * - DEBOUNCED to at most one capture PROMPT per 10 minutes, so a burst of
13
+ * blesses never nags.
14
+ *
15
+ * The bank goes through {@link bankDecision} (source `cli_correction`,
16
+ * idempotency-keyed, spool-durable). Everything is fail-soft: a capture problem
17
+ * never affects the tool call that triggered it.
18
+ */
19
+ import { type DecisionClientOpts } from "./decisions.js";
20
+ import type { JudgmentWriteOutcome } from "./spool.js";
21
+ /** At most one capture prompt per this window (spec: 10 minutes). */
22
+ export declare const CAPTURE_DEBOUNCE_MS: number;
23
+ /** The minimal context the capture needs (a subset of pi's ExtensionContext). */
24
+ export interface CaptureCtx {
25
+ hasUI: boolean;
26
+ signal?: AbortSignal;
27
+ ui: {
28
+ confirm(title: string, message: string): Promise<boolean>;
29
+ notify(message: string, type?: "info" | "warning" | "error"): void;
30
+ };
31
+ }
32
+ /** What was blessed: the tool and a human-readable directory label. */
33
+ export interface BlessCaptureInfo {
34
+ tool: string;
35
+ dir: string;
36
+ }
37
+ export interface DecisionCapture {
38
+ /** On a bless-with-remember: debounced, TUI-confirmed decision capture. */
39
+ captureFromBless(ctx: CaptureCtx, info: BlessCaptureInfo): Promise<void>;
40
+ }
41
+ /** Injectable seams for {@link makeDecisionCapture}. */
42
+ export interface DecisionCaptureDeps extends DecisionClientOpts {
43
+ now?: () => number;
44
+ /** Bank implementation (default: {@link bankDecision}); injected in tests. */
45
+ bank?: (opts: DecisionClientOpts, params: {
46
+ question: string;
47
+ decision: string;
48
+ }, signal?: AbortSignal) => Promise<JudgmentWriteOutcome>;
49
+ }
50
+ /** Build a session-scoped decision capture (holds the debounce timestamp). */
51
+ export declare function makeDecisionCapture(deps: DecisionCaptureDeps): DecisionCapture;
52
+ //# sourceMappingURL=decisionCapture.d.ts.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Decision capture at the human gate (spec §4, M3 — conservative).
3
+ *
4
+ * When the user picks "Yes, and don't ask again for <tool> in <dir>" at the
5
+ * review gate, that is a piece of durable judgment worth banking so YAGNI
6
+ * answers the same question next time instead of interrupting a human. Capture
7
+ * is deliberately conservative:
8
+ *
9
+ * - HEADLESS never auto-banks (no consent surface; review-mode bless is itself
10
+ * fail-closed headless, but this guards regardless).
11
+ * - TUI ALWAYS confirms via `ctx.ui.confirm` before banking.
12
+ * - DEBOUNCED to at most one capture PROMPT per 10 minutes, so a burst of
13
+ * blesses never nags.
14
+ *
15
+ * The bank goes through {@link bankDecision} (source `cli_correction`,
16
+ * idempotency-keyed, spool-durable). Everything is fail-soft: a capture problem
17
+ * never affects the tool call that triggered it.
18
+ */
19
+ import { bankDecision } from "./decisions.js";
20
+ /** At most one capture prompt per this window (spec: 10 minutes). */
21
+ export const CAPTURE_DEBOUNCE_MS = 10 * 60 * 1000;
22
+ /** Build a session-scoped decision capture (holds the debounce timestamp). */
23
+ export function makeDecisionCapture(deps) {
24
+ const now = deps.now ?? (() => Date.now());
25
+ const bank = deps.bank ?? bankDecision;
26
+ let lastPromptAt = Number.NEGATIVE_INFINITY;
27
+ return {
28
+ async captureFromBless(ctx, info) {
29
+ // Headless never auto-banks: there is no consent surface.
30
+ if (!ctx.hasUI)
31
+ return;
32
+ // Debounce: hold if we prompted within the window (whatever the answer).
33
+ const t = now();
34
+ if (t - lastPromptAt < CAPTURE_DEBOUNCE_MS)
35
+ return;
36
+ lastPromptAt = t;
37
+ const question = `Can the agent run ${info.tool} within ${info.dir} without asking each time?`;
38
+ const decision = `Yes, ${info.tool} within ${info.dir} is approved for this repository.`;
39
+ let ok = false;
40
+ try {
41
+ ok = await ctx.ui.confirm("Record this as a YAGNI decision?", `You approved ${info.tool} in ${info.dir}. Record it so YAGNI answers the same question next time instead of asking a human?`);
42
+ }
43
+ catch {
44
+ return; // a failed prompt never banks
45
+ }
46
+ if (!ok)
47
+ return;
48
+ try {
49
+ const outcome = await bank(deps, { question, decision }, ctx.signal);
50
+ if (outcome.kind === "ok") {
51
+ ctx.ui.notify("Recorded the decision in YAGNI.", "info");
52
+ }
53
+ else if (outcome.kind === "spooled") {
54
+ ctx.ui.notify("Saved the decision locally; it will sync automatically.", "info");
55
+ }
56
+ else {
57
+ ctx.ui.notify(outcome.message, "error");
58
+ }
59
+ }
60
+ catch {
61
+ /* fail-soft: a capture failure never affects the tool call */
62
+ }
63
+ },
64
+ };
65
+ }
66
+ //# sourceMappingURL=decisionCapture.js.map
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The client-side decisions surface (spec §4, W4): the `/decide` and
3
+ * `/decisions` commands plus the shared write helper the bless-capture flow
4
+ * reuses.
5
+ *
6
+ * - `bankDecision` POSTs a `source: 'cli_correction'` decision with a generated
7
+ * idempotencyKey through the durable write-spool (part A), so a transport
8
+ * failure or 5xx spools it for replay instead of losing it, and a replay can
9
+ * never double-bank (server dedups on the key).
10
+ * - `fetchDecisions` / `supersedeDecision` reuse the shared authed-fetch seam,
11
+ * so a GET/PATCH inherits the one 401-refresh-then-retry-once behaviour.
12
+ * - `/decide <question> => <decision>` records ONE decision directly (the user
13
+ * typed it — explicit intent, no extra confirm or debounce).
14
+ * - `/decisions` lists recent decisions (id-prefixed, compact); `/decisions
15
+ * supersede <id>` flips one to superseded.
16
+ *
17
+ * Formatters and the arg parser are pure; the network calls are injectable via
18
+ * the shared `SpoolClientOpts` so tests never touch disk or the network.
19
+ */
20
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
+ import { type JudgmentWriteOutcome, type SpoolClientOpts } from "./spool.js";
22
+ /** Transport + idempotency seams for the decisions surface. */
23
+ export interface DecisionClientOpts extends SpoolClientOpts {
24
+ /** Idempotency-key source (default: crypto.randomUUID); injected in tests. */
25
+ makeIdempotencyKey?: () => string;
26
+ }
27
+ /** The durable fields banked by `/decide` or a bless-with-remember capture. */
28
+ export interface BankDecisionParams {
29
+ question: string;
30
+ decision: string;
31
+ rationale?: string;
32
+ repo?: string;
33
+ }
34
+ /**
35
+ * Bank a `cli_correction` decision durably. Every logical write carries a
36
+ * generated idempotencyKey; the backend dedups on it, so a spool replay or a
37
+ * 401 retry can never bank the same decision twice. Transport failures and 5xx
38
+ * are spooled for replay instead of lost (R4 write half). Never throws.
39
+ */
40
+ export declare function bankDecision(opts: DecisionClientOpts, params: BankDecisionParams, signal?: AbortSignal): Promise<JudgmentWriteOutcome>;
41
+ /** One row from GET /decisions (mirrors the backend DecisionListItem). */
42
+ export interface DecisionListItem {
43
+ id: string;
44
+ question: string;
45
+ decision: string;
46
+ rationale: string | null;
47
+ repoFullName: string | null;
48
+ source: string;
49
+ status: string;
50
+ createdAt: string;
51
+ citedCount: number;
52
+ }
53
+ /** Options for {@link fetchDecisions}. */
54
+ export interface FetchDecisionsOpts {
55
+ limit?: number;
56
+ repo?: string;
57
+ }
58
+ export declare const DEFAULT_LIST_LIMIT = 20;
59
+ /** GET recent decisions (any read scope). Throws a friendly error on non-2xx. */
60
+ export declare function fetchDecisions(opts: DecisionClientOpts, list?: FetchDecisionsOpts, signal?: AbortSignal): Promise<DecisionListItem[]>;
61
+ /**
62
+ * PATCH one decision to `superseded` (scope yagni_code:write). Idempotent flip,
63
+ * so a single attempt is enough — no client retry (invariant §0.8). Throws a
64
+ * friendly error on non-2xx.
65
+ */
66
+ export declare function supersedeDecision(opts: DecisionClientOpts, id: string, supersededBy?: string, signal?: AbortSignal): Promise<void>;
67
+ /** The parsed `/decide` argument: `<question> => <decision>`. */
68
+ export interface DecideArgs {
69
+ question: string;
70
+ decision: string;
71
+ }
72
+ /** Pure: parse `<question> => <decision>`; null when either half is missing. */
73
+ export declare function parseDecideArgs(raw: string): DecideArgs | null;
74
+ /** Pure: a short id prefix for compact display. */
75
+ export declare function shortId(id: string): string;
76
+ /** Pure: render the compact, id-prefixed decisions list (no em-dashes). */
77
+ export declare function formatDecisionsList(items: DecisionListItem[]): string;
78
+ /**
79
+ * Wire the `/decide` and `/decisions` commands. Both are fail-soft: any
80
+ * transport error surfaces as an honest notice, never a thrown command.
81
+ */
82
+ export declare function registerDecisionCommands(pi: ExtensionAPI, opts: DecisionClientOpts): void;
83
+ //# sourceMappingURL=decisions.d.ts.map
@@ -0,0 +1,200 @@
1
+ /**
2
+ * The client-side decisions surface (spec §4, W4): the `/decide` and
3
+ * `/decisions` commands plus the shared write helper the bless-capture flow
4
+ * reuses.
5
+ *
6
+ * - `bankDecision` POSTs a `source: 'cli_correction'` decision with a generated
7
+ * idempotencyKey through the durable write-spool (part A), so a transport
8
+ * failure or 5xx spools it for replay instead of losing it, and a replay can
9
+ * never double-bank (server dedups on the key).
10
+ * - `fetchDecisions` / `supersedeDecision` reuse the shared authed-fetch seam,
11
+ * so a GET/PATCH inherits the one 401-refresh-then-retry-once behaviour.
12
+ * - `/decide <question> => <decision>` records ONE decision directly (the user
13
+ * typed it — explicit intent, no extra confirm or debounce).
14
+ * - `/decisions` lists recent decisions (id-prefixed, compact); `/decisions
15
+ * supersede <id>` flips one to superseded.
16
+ *
17
+ * Formatters and the arg parser are pure; the network calls are injectable via
18
+ * the shared `SpoolClientOpts` so tests never touch disk or the network.
19
+ */
20
+ import { randomUUID } from "node:crypto";
21
+ import { friendlyFetchError, METERED_POST_FETCH_POLICY, resilientFetch } from "./resilientFetch.js";
22
+ import { sendOrSpool } from "./spool.js";
23
+ /**
24
+ * Bank a `cli_correction` decision durably. Every logical write carries a
25
+ * generated idempotencyKey; the backend dedups on it, so a spool replay or a
26
+ * 401 retry can never bank the same decision twice. Transport failures and 5xx
27
+ * are spooled for replay instead of lost (R4 write half). Never throws.
28
+ */
29
+ export function bankDecision(opts, params, signal) {
30
+ const idempotencyKey = (opts.makeIdempotencyKey ?? randomUUID)();
31
+ return sendOrSpool(opts, "record_decision", "/api/yagni-code/decisions", {
32
+ question: params.question,
33
+ decision: params.decision,
34
+ rationale: params.rationale,
35
+ repo: params.repo,
36
+ source: "cli_correction",
37
+ }, idempotencyKey, signal);
38
+ }
39
+ export const DEFAULT_LIST_LIMIT = 20;
40
+ /** GET recent decisions (any read scope). Throws a friendly error on non-2xx. */
41
+ export async function fetchDecisions(opts, list = {}, signal) {
42
+ const limit = list.limit ?? DEFAULT_LIST_LIMIT;
43
+ const query = new URLSearchParams({ limit: String(limit) });
44
+ if (list.repo)
45
+ query.set("repo", list.repo);
46
+ const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/decisions?${query.toString()}`, { method: "GET", headers: { authorization: `Bearer ${opts.getToken() ?? ""}` } }, { fetchImpl: opts.fetchImpl, signal });
47
+ if (!res.ok)
48
+ throw new Error(await friendlyFetchError("list decisions", res));
49
+ const data = (await res.json());
50
+ return Array.isArray(data.decisions) ? data.decisions : [];
51
+ }
52
+ /**
53
+ * PATCH one decision to `superseded` (scope yagni_code:write). Idempotent flip,
54
+ * so a single attempt is enough — no client retry (invariant §0.8). Throws a
55
+ * friendly error on non-2xx.
56
+ */
57
+ export async function supersedeDecision(opts, id, supersededBy, signal) {
58
+ const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/decisions/${encodeURIComponent(id)}`, {
59
+ method: "PATCH",
60
+ headers: {
61
+ "content-type": "application/json",
62
+ authorization: `Bearer ${opts.getToken() ?? ""}`,
63
+ },
64
+ body: JSON.stringify({ status: "superseded", ...(supersededBy ? { supersededBy } : {}) }),
65
+ }, { fetchImpl: opts.fetchImpl, signal, policy: METERED_POST_FETCH_POLICY });
66
+ if (!res.ok)
67
+ throw new Error(await friendlyFetchError("supersede decision", res));
68
+ }
69
+ /** Pure: parse `<question> => <decision>`; null when either half is missing. */
70
+ export function parseDecideArgs(raw) {
71
+ const idx = raw.indexOf("=>");
72
+ if (idx < 0)
73
+ return null;
74
+ const question = raw.slice(0, idx).trim();
75
+ const decision = raw.slice(idx + 2).trim();
76
+ if (!question || !decision)
77
+ return null;
78
+ return { question, decision };
79
+ }
80
+ /** Pure: a short id prefix for compact display. */
81
+ export function shortId(id) {
82
+ return id.slice(0, 8);
83
+ }
84
+ /** Pure: render the compact, id-prefixed decisions list (no em-dashes). */
85
+ export function formatDecisionsList(items) {
86
+ if (items.length === 0) {
87
+ return "No decisions recorded yet. Use /decide <question> => <decision> to bank one.";
88
+ }
89
+ const header = `Recorded decisions (${items.length}):`;
90
+ const lines = items.map((d) => {
91
+ const flag = d.status === "active" ? "" : ` [${d.status}]`;
92
+ const cited = d.citedCount > 0 ? ` (cited ${d.citedCount})` : "";
93
+ return `${shortId(d.id)}${flag} ${d.question} => ${d.decision}${cited}`;
94
+ });
95
+ return [header, ...lines].join("\n");
96
+ }
97
+ /**
98
+ * Resolve a displayed short-id prefix (or a full id) to the full decision id by
99
+ * matching against the current list. Returns the full id, the raw arg when no
100
+ * match is found (let the backend answer an honest 404), or "ambiguous" when
101
+ * more than one decision shares the prefix. Fail-soft: a list error falls back
102
+ * to the raw arg.
103
+ */
104
+ async function resolveDecisionId(opts, arg, signal) {
105
+ let items = [];
106
+ try {
107
+ items = await fetchDecisions(opts, {}, signal);
108
+ }
109
+ catch {
110
+ return arg; // cannot resolve → let the PATCH itself surface the outcome
111
+ }
112
+ const matches = items.filter((d) => d.id === arg || d.id.startsWith(arg));
113
+ if (matches.length === 1)
114
+ return matches[0].id;
115
+ if (matches.length > 1)
116
+ return "ambiguous";
117
+ return arg;
118
+ }
119
+ /** A friendly one-liner for a bank outcome, or null when nothing to say. */
120
+ function bankOutcomeNotice(outcome) {
121
+ if (outcome.kind === "ok")
122
+ return { message: "Recorded the decision in YAGNI.", type: "info" };
123
+ if (outcome.kind === "spooled") {
124
+ return {
125
+ message: `Could not reach YAGNI (${outcome.reason}). Saved the decision locally; it will sync automatically.`,
126
+ type: "warning",
127
+ };
128
+ }
129
+ return { message: outcome.message, type: "error" };
130
+ }
131
+ /**
132
+ * Wire the `/decide` and `/decisions` commands. Both are fail-soft: any
133
+ * transport error surfaces as an honest notice, never a thrown command.
134
+ */
135
+ export function registerDecisionCommands(pi, opts) {
136
+ pi.registerCommand("decide", {
137
+ description: "Record a product-intent decision directly: /decide <question> => <decision>. YAGNI answers the same question next time.",
138
+ handler: async (args, ctx) => {
139
+ const notify = (m, t) => {
140
+ if (ctx.hasUI)
141
+ ctx.ui.notify(m, t);
142
+ };
143
+ const parsed = parseDecideArgs(args);
144
+ if (!parsed) {
145
+ notify("Usage: /decide <question> => <decision>", "warning");
146
+ return;
147
+ }
148
+ try {
149
+ const outcome = await bankDecision(opts, { question: parsed.question, decision: parsed.decision }, ctx.signal);
150
+ const notice = bankOutcomeNotice(outcome);
151
+ notify(notice.message, notice.type);
152
+ }
153
+ catch (err) {
154
+ notify(`Could not record the decision: ${err instanceof Error ? err.message : String(err)}`, "error");
155
+ }
156
+ },
157
+ });
158
+ pi.registerCommand("decisions", {
159
+ description: "List recent recorded decisions. /decisions supersede <id> marks one superseded.",
160
+ handler: async (args, ctx) => {
161
+ const notify = (m, t) => {
162
+ if (ctx.hasUI)
163
+ ctx.ui.notify(m, t);
164
+ };
165
+ const trimmed = args.trim();
166
+ if (trimmed.toLowerCase().startsWith("supersede")) {
167
+ const arg = trimmed.slice("supersede".length).trim();
168
+ if (!arg) {
169
+ notify("Usage: /decisions supersede <id>", "warning");
170
+ return;
171
+ }
172
+ try {
173
+ // The list shows short id prefixes, so resolve the argument against
174
+ // the current list to the full UUID the backend PATCH requires. A
175
+ // failed lookup falls through with the raw arg (the backend then
176
+ // answers an honest 404).
177
+ const id = await resolveDecisionId(opts, arg, ctx.signal);
178
+ if (id === "ambiguous") {
179
+ notify(`Ambiguous decision id "${arg}"; use more characters.`, "warning");
180
+ return;
181
+ }
182
+ await supersedeDecision(opts, id, undefined, ctx.signal);
183
+ notify(`Superseded decision ${shortId(id)}.`, "info");
184
+ }
185
+ catch (err) {
186
+ notify(`Could not supersede decision: ${err instanceof Error ? err.message : String(err)}`, "error");
187
+ }
188
+ return;
189
+ }
190
+ try {
191
+ const items = await fetchDecisions(opts, {}, ctx.signal);
192
+ await pi.sendUserMessage(formatDecisionsList(items));
193
+ }
194
+ catch (err) {
195
+ notify(`Could not list decisions: ${err instanceof Error ? err.message : String(err)}`, "error");
196
+ }
197
+ },
198
+ });
199
+ }
200
+ //# sourceMappingURL=decisions.js.map