@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,45 @@
1
+ /**
2
+ * YAG-383: tracks the `/go` run ids a driver session has launched, so /cost's
3
+ * server-authoritative spend fetch can widen its query to include them.
4
+ *
5
+ * The backend writes a run-attributed dispatch's `llm_usage.session_id` as
6
+ * the RUN id, not the driver session id (see attributionHeaders in
7
+ * config.ts: a `/go` child's completions carry `x-yagni-run-id`, and the
8
+ * proxy stores THAT in `session_id` — the run id wins, by design, so a
9
+ * per-run query works on its own). A plain `GET /api/yagni-code/spend?
10
+ * sessionId=<driver>` therefore never sees a single `/go` run's spend: it is
11
+ * real spend, billed under this session's work, but invisible to that query.
12
+ * `/cost` (index.ts's `fetchSpend`) closes that gap by also asking for every
13
+ * run id this process has launched, via `runIds=`.
14
+ *
15
+ * Module-level state is the right shape here: one CLI process IS one driver
16
+ * session, so "run ids this process has launched" and "run ids this session
17
+ * has launched" are the same list. Capped at the most recent
18
+ * `MAX_TRACKED_RUN_IDS` so an extremely long-lived session's /cost call never
19
+ * grows an unbounded query string (the backend independently caps `runIds` at
20
+ * the same number — see MAX_SPEND_RUN_IDS in routes/yagniCode.ts — capping
21
+ * symmetrically here keeps a stable, useful most-recent set instead of
22
+ * trailing off into a 400 from the server).
23
+ */
24
+ /**
25
+ * Record a `/go` run id once it is known (the goCommand.ts seam right after
26
+ * `runSession.start()` resolves). Ignores anything that is not UUID-shaped
27
+ * and de-dupes a repeat call for the same run (a resumed/re-adopted run can
28
+ * report the same id twice) — both defensive, since a malformed value here
29
+ * must never corrupt /cost's query string.
30
+ */
31
+ export declare function recordSessionRun(runId: string): void;
32
+ /** The run ids recorded so far this process, oldest first, capped. */
33
+ export declare function sessionRunIds(): string[];
34
+ /**
35
+ * How many run ids this session has ever dropped off the front of the
36
+ * `MAX_TRACKED_RUN_IDS` cap. Zero for the overwhelming majority of sessions
37
+ * (20 `/go` runs in one process is a lot); /cost's server rendering
38
+ * (costHud.ts's `formatServerCostLines`) surfaces this as an "Excludes N
39
+ * earlier /go runs." note when it is nonzero, so a long session's spend total
40
+ * is never silently missing runs with no explanation.
41
+ */
42
+ export declare function droppedSessionRuns(): number;
43
+ /** Test-only reset: module-level state otherwise leaks across test files/cases. */
44
+ export declare function _resetSessionRunsForTest(): void;
45
+ //# sourceMappingURL=sessionRuns.d.ts.map
@@ -0,0 +1,77 @@
1
+ /**
2
+ * YAG-383: tracks the `/go` run ids a driver session has launched, so /cost's
3
+ * server-authoritative spend fetch can widen its query to include them.
4
+ *
5
+ * The backend writes a run-attributed dispatch's `llm_usage.session_id` as
6
+ * the RUN id, not the driver session id (see attributionHeaders in
7
+ * config.ts: a `/go` child's completions carry `x-yagni-run-id`, and the
8
+ * proxy stores THAT in `session_id` — the run id wins, by design, so a
9
+ * per-run query works on its own). A plain `GET /api/yagni-code/spend?
10
+ * sessionId=<driver>` therefore never sees a single `/go` run's spend: it is
11
+ * real spend, billed under this session's work, but invisible to that query.
12
+ * `/cost` (index.ts's `fetchSpend`) closes that gap by also asking for every
13
+ * run id this process has launched, via `runIds=`.
14
+ *
15
+ * Module-level state is the right shape here: one CLI process IS one driver
16
+ * session, so "run ids this process has launched" and "run ids this session
17
+ * has launched" are the same list. Capped at the most recent
18
+ * `MAX_TRACKED_RUN_IDS` so an extremely long-lived session's /cost call never
19
+ * grows an unbounded query string (the backend independently caps `runIds` at
20
+ * the same number — see MAX_SPEND_RUN_IDS in routes/yagniCode.ts — capping
21
+ * symmetrically here keeps a stable, useful most-recent set instead of
22
+ * trailing off into a 400 from the server).
23
+ */
24
+ const MAX_TRACKED_RUN_IDS = 20;
25
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
26
+ let runIds = [];
27
+ /**
28
+ * Count of run ids ever pushed off the front of `runIds` by the
29
+ * `MAX_TRACKED_RUN_IDS` cap below. Carry-over from the /cost re-review: a
30
+ * long-lived session that launches more than 20 `/go` runs silently lost the
31
+ * oldest ones from /cost's `runIds=` widening, with no indication that
32
+ * anything was left out — an honesty gap identical in spirit to
33
+ * `counterfactualIncomplete` / `unbilledSellMillicents` elsewhere in this
34
+ * surface. Tracked here (not derived) because the dropped ids themselves are
35
+ * gone — only the count survives.
36
+ */
37
+ let dropped = 0;
38
+ /**
39
+ * Record a `/go` run id once it is known (the goCommand.ts seam right after
40
+ * `runSession.start()` resolves). Ignores anything that is not UUID-shaped
41
+ * and de-dupes a repeat call for the same run (a resumed/re-adopted run can
42
+ * report the same id twice) — both defensive, since a malformed value here
43
+ * must never corrupt /cost's query string.
44
+ */
45
+ export function recordSessionRun(runId) {
46
+ if (!UUID_RE.test(runId))
47
+ return;
48
+ if (runIds.includes(runId))
49
+ return;
50
+ runIds.push(runId);
51
+ if (runIds.length > MAX_TRACKED_RUN_IDS) {
52
+ const overflow = runIds.length - MAX_TRACKED_RUN_IDS;
53
+ dropped += overflow;
54
+ runIds = runIds.slice(overflow);
55
+ }
56
+ }
57
+ /** The run ids recorded so far this process, oldest first, capped. */
58
+ export function sessionRunIds() {
59
+ return [...runIds];
60
+ }
61
+ /**
62
+ * How many run ids this session has ever dropped off the front of the
63
+ * `MAX_TRACKED_RUN_IDS` cap. Zero for the overwhelming majority of sessions
64
+ * (20 `/go` runs in one process is a lot); /cost's server rendering
65
+ * (costHud.ts's `formatServerCostLines`) surfaces this as an "Excludes N
66
+ * earlier /go runs." note when it is nonzero, so a long session's spend total
67
+ * is never silently missing runs with no explanation.
68
+ */
69
+ export function droppedSessionRuns() {
70
+ return dropped;
71
+ }
72
+ /** Test-only reset: module-level state otherwise leaks across test files/cases. */
73
+ export function _resetSessionRunsForTest() {
74
+ runIds = [];
75
+ dropped = 0;
76
+ }
77
+ //# sourceMappingURL=sessionRuns.js.map
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Durable write-spool for the judgment-capture tools (spec §4, R4 write half).
3
+ *
4
+ * `record_decision` and `record_engineering_context` are metered writes that
5
+ * must never be blindly retried (invariant §0.8) — but losing a captured
6
+ * decision because the laptop lost Wi-Fi is worse. The backend now dedups on
7
+ * an `idempotencyKey` (§2), so the safe shape is:
8
+ *
9
+ * - every logical write carries a generated idempotencyKey,
10
+ * - a transport failure or 5xx appends `{endpoint, body, idempotencyKey, ts}`
11
+ * to `~/.yagni-code/spool/pending.ndjson` (dir 0700, file 0600,
12
+ * torn-line-tolerant — the same self-healing NDJSON pattern as
13
+ * pipeline/checkpoint.ts),
14
+ * - the spool is flushed at session_start and after any successful write,
15
+ * replaying with the SAME idempotencyKey (the server replays the stored
16
+ * response, so a duplicate flush is harmless),
17
+ * - entries older than 14 days are dropped with an honest notice (stale
18
+ * judgment silently landing weeks later would be worse than losing it).
19
+ *
20
+ * Everything here is FAIL-SOFT: a spool problem must never break a tool call
21
+ * or session start. Eval mode never reaches this module (the capture tools are
22
+ * not registered there).
23
+ */
24
+ /** Spooled entries older than this are dropped at flush (with a notice). */
25
+ export declare const MAX_SPOOL_AGE_MS: number;
26
+ /** One durable pending write: replayed verbatim with the same idempotencyKey. */
27
+ export interface SpoolEntry {
28
+ /** Repo-relative endpoint path, e.g. `/api/yagni-code/decisions`. */
29
+ endpoint: string;
30
+ /** The JSON body as originally posted (already carrying the idempotencyKey). */
31
+ body: Record<string, unknown>;
32
+ idempotencyKey: string;
33
+ /** Epoch ms when the write first failed (drives the 14-day expiry). */
34
+ ts: number;
35
+ }
36
+ export declare function _setSpoolHomeForTest(dir: string | null): void;
37
+ /** The on-disk pending file (exported so tests can inspect it directly). */
38
+ export declare function spoolFile(): string;
39
+ /** Append one pending write. NEVER throws (fail-soft: memory loss beats a crash). */
40
+ export declare function appendToSpool(entry: SpoolEntry): void;
41
+ /** Load pending entries, dropping torn/garbage lines. Never throws. */
42
+ export declare function loadSpool(): SpoolEntry[];
43
+ /** The transport/auth surface a flush or a spooling write posts through. */
44
+ export interface SpoolClientOpts {
45
+ baseUrl: string;
46
+ getToken: () => string | undefined;
47
+ fetchImpl?: typeof fetch;
48
+ now?: () => number;
49
+ }
50
+ /** What one flush did — `notices` are honest, user-showable one-liners. */
51
+ export interface FlushOutcome {
52
+ replayed: number;
53
+ droppedExpired: number;
54
+ droppedRejected: number;
55
+ remaining: number;
56
+ notices: string[];
57
+ }
58
+ /**
59
+ * Replay every pending write with its ORIGINAL idempotencyKey (server-side
60
+ * dedup makes a double replay a no-op). Serialized: concurrent flushes (a
61
+ * session_start racing a post-write flush) coalesce onto one pass.
62
+ *
63
+ * Per entry: expired (>14d) → dropped with a notice; 2xx → replayed; a
64
+ * permanent 4xx (not auth/rate) → dropped with a notice (the backend will
65
+ * never accept it); transport error, 5xx, 401/403/408/429 → kept, and the
66
+ * rest of the flush stops (the backend is unreachable or the token is bad —
67
+ * hammering the remaining entries would not help). Never throws.
68
+ */
69
+ export declare function flushSpool(opts: SpoolClientOpts): Promise<FlushOutcome>;
70
+ /** The outcome of one spool-backed judgment write. */
71
+ export type JudgmentWriteOutcome = {
72
+ kind: "ok";
73
+ json: unknown;
74
+ }
75
+ /** Transport failure or 5xx: the write is durably spooled for replay. */
76
+ | {
77
+ kind: "spooled";
78
+ reason: string;
79
+ }
80
+ /** A non-retryable client error: the caller should throw this message. */
81
+ | {
82
+ kind: "rejected";
83
+ message: string;
84
+ };
85
+ /**
86
+ * POST one judgment write with its idempotencyKey; on transport failure or a
87
+ * 5xx, durably spool it for replay instead of losing it. A successful write
88
+ * also kicks a background flush (fail-soft, fire-and-forget) so earlier
89
+ * stranded entries drain as soon as the backend is healthy again.
90
+ */
91
+ export declare function sendOrSpool(opts: SpoolClientOpts, label: string, endpoint: string, body: Record<string, unknown>, idempotencyKey: string, signal?: AbortSignal): Promise<JudgmentWriteOutcome>;
92
+ //# sourceMappingURL=spool.d.ts.map
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Durable write-spool for the judgment-capture tools (spec §4, R4 write half).
3
+ *
4
+ * `record_decision` and `record_engineering_context` are metered writes that
5
+ * must never be blindly retried (invariant §0.8) — but losing a captured
6
+ * decision because the laptop lost Wi-Fi is worse. The backend now dedups on
7
+ * an `idempotencyKey` (§2), so the safe shape is:
8
+ *
9
+ * - every logical write carries a generated idempotencyKey,
10
+ * - a transport failure or 5xx appends `{endpoint, body, idempotencyKey, ts}`
11
+ * to `~/.yagni-code/spool/pending.ndjson` (dir 0700, file 0600,
12
+ * torn-line-tolerant — the same self-healing NDJSON pattern as
13
+ * pipeline/checkpoint.ts),
14
+ * - the spool is flushed at session_start and after any successful write,
15
+ * replaying with the SAME idempotencyKey (the server replays the stored
16
+ * response, so a duplicate flush is harmless),
17
+ * - entries older than 14 days are dropped with an honest notice (stale
18
+ * judgment silently landing weeks later would be worse than losing it).
19
+ *
20
+ * Everything here is FAIL-SOFT: a spool problem must never break a tool call
21
+ * or session start. Eval mode never reaches this module (the capture tools are
22
+ * not registered there).
23
+ */
24
+ import { appendFileSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { friendlyFetchError, METERED_POST_FETCH_POLICY, resilientFetch } from "./resilientFetch.js";
27
+ import { codeStateHome } from "./stateHome.js";
28
+ /** Spooled entries older than this are dropped at flush (with a notice). */
29
+ export const MAX_SPOOL_AGE_MS = 14 * 24 * 60 * 60 * 1000;
30
+ /**
31
+ * Test seam mirroring checkpoint.ts `_setCheckpointHomeForTest`: when set, the
32
+ * spool roots here instead of `~/.yagni-code`. Pass `null` to restore.
33
+ */
34
+ let homeOverride = null;
35
+ export function _setSpoolHomeForTest(dir) {
36
+ homeOverride = dir;
37
+ }
38
+ function spoolHome() {
39
+ return codeStateHome(homeOverride);
40
+ }
41
+ function spoolDir() {
42
+ return join(spoolHome(), "spool");
43
+ }
44
+ /** The on-disk pending file (exported so tests can inspect it directly). */
45
+ export function spoolFile() {
46
+ return join(spoolDir(), "pending.ndjson");
47
+ }
48
+ /** Append one pending write. NEVER throws (fail-soft: memory loss beats a crash). */
49
+ export function appendToSpool(entry) {
50
+ try {
51
+ mkdirSync(spoolDir(), { recursive: true, mode: 0o700 });
52
+ const file = spoolFile();
53
+ // Self-heal a torn previous write (crash mid-append): if the file ends
54
+ // mid-line, prefix a newline so OUR record lands on its own line instead of
55
+ // gluing onto the fragment and losing both (mirrors checkpoint.ts).
56
+ let prefix = "";
57
+ try {
58
+ const existing = readFileSync(file);
59
+ if (existing.length > 0 && existing[existing.length - 1] !== 0x0a)
60
+ prefix = "\n";
61
+ }
62
+ catch {
63
+ /* no file yet — nothing to heal */
64
+ }
65
+ appendFileSync(file, `${prefix}${JSON.stringify(entry)}\n`, { mode: 0o600 });
66
+ }
67
+ catch {
68
+ /* fail-soft: a spool write must never break the tool call */
69
+ }
70
+ }
71
+ function isSpoolEntry(value) {
72
+ const e = value;
73
+ return (!!e &&
74
+ typeof e === "object" &&
75
+ typeof e.endpoint === "string" &&
76
+ e.endpoint.startsWith("/") &&
77
+ !!e.body &&
78
+ typeof e.body === "object" &&
79
+ typeof e.idempotencyKey === "string" &&
80
+ typeof e.ts === "number");
81
+ }
82
+ /** Load pending entries, dropping torn/garbage lines. Never throws. */
83
+ export function loadSpool() {
84
+ try {
85
+ const raw = readFileSync(spoolFile(), "utf8");
86
+ const out = [];
87
+ for (const line of raw.split("\n")) {
88
+ if (!line.trim())
89
+ continue;
90
+ try {
91
+ const obj = JSON.parse(line);
92
+ if (isSpoolEntry(obj))
93
+ out.push(obj);
94
+ }
95
+ catch {
96
+ /* unparseable (torn final write) — drop this line, keep the rest */
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+ catch {
102
+ return []; // missing/unreadable → nothing pending
103
+ }
104
+ }
105
+ /** Atomically rewrite the pending file with the surviving entries (0600). */
106
+ function rewriteSpool(entries) {
107
+ try {
108
+ const file = spoolFile();
109
+ if (entries.length === 0) {
110
+ rmSync(file, { force: true });
111
+ return;
112
+ }
113
+ mkdirSync(spoolDir(), { recursive: true, mode: 0o700 });
114
+ const tmp = join(spoolDir(), `.pending-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}.tmp`);
115
+ writeFileSync(tmp, entries.map((e) => JSON.stringify(e)).join("\n") + "\n", { mode: 0o600 });
116
+ renameSync(tmp, file);
117
+ }
118
+ catch {
119
+ /* fail-soft: worst case the next flush replays an already-deduped key */
120
+ }
121
+ }
122
+ const EMPTY_FLUSH = {
123
+ replayed: 0,
124
+ droppedExpired: 0,
125
+ droppedRejected: 0,
126
+ remaining: 0,
127
+ notices: [],
128
+ };
129
+ function plural(n) {
130
+ return n === 1 ? "" : "s";
131
+ }
132
+ let flushInFlight = null;
133
+ /**
134
+ * Replay every pending write with its ORIGINAL idempotencyKey (server-side
135
+ * dedup makes a double replay a no-op). Serialized: concurrent flushes (a
136
+ * session_start racing a post-write flush) coalesce onto one pass.
137
+ *
138
+ * Per entry: expired (>14d) → dropped with a notice; 2xx → replayed; a
139
+ * permanent 4xx (not auth/rate) → dropped with a notice (the backend will
140
+ * never accept it); transport error, 5xx, 401/403/408/429 → kept, and the
141
+ * rest of the flush stops (the backend is unreachable or the token is bad —
142
+ * hammering the remaining entries would not help). Never throws.
143
+ */
144
+ export function flushSpool(opts) {
145
+ if (!flushInFlight) {
146
+ flushInFlight = doFlush(opts)
147
+ .catch(() => EMPTY_FLUSH)
148
+ .finally(() => {
149
+ flushInFlight = null;
150
+ });
151
+ }
152
+ return flushInFlight;
153
+ }
154
+ /** Statuses that mean "try again later", not "the backend rejected this body". */
155
+ const KEEP_STATUSES = new Set([401, 403, 408, 429]);
156
+ async function doFlush(opts) {
157
+ const entries = loadSpool();
158
+ if (entries.length === 0)
159
+ return EMPTY_FLUSH;
160
+ const nowMs = (opts.now ?? Date.now)();
161
+ // Keys we have RESOLVED this pass (replayed OK, or permanently dropped). We do
162
+ // NOT rewrite from this stale snapshot — instead we re-read the current spool
163
+ // just before writing and drop only these resolved keys, so a write appended
164
+ // mid-flush (a tool that failed while we were replaying) is never clobbered.
165
+ const resolved = new Set();
166
+ let replayed = 0;
167
+ let droppedExpired = 0;
168
+ let droppedRejected = 0;
169
+ let stopped = false;
170
+ for (const entry of entries) {
171
+ if (nowMs - entry.ts > MAX_SPOOL_AGE_MS) {
172
+ droppedExpired += 1;
173
+ resolved.add(entry.idempotencyKey);
174
+ continue;
175
+ }
176
+ if (stopped)
177
+ continue;
178
+ try {
179
+ const res = await resilientFetch(`${opts.baseUrl}${entry.endpoint}`, {
180
+ method: "POST",
181
+ headers: {
182
+ "content-type": "application/json",
183
+ authorization: `Bearer ${opts.getToken() ?? ""}`,
184
+ },
185
+ // Replay verbatim; re-assert the key in case an old entry predates it.
186
+ body: JSON.stringify({ ...entry.body, idempotencyKey: entry.idempotencyKey }),
187
+ }, { fetchImpl: opts.fetchImpl, policy: METERED_POST_FETCH_POLICY });
188
+ if (res.ok) {
189
+ replayed += 1;
190
+ resolved.add(entry.idempotencyKey);
191
+ }
192
+ else if (res.status >= 500 || KEEP_STATUSES.has(res.status)) {
193
+ stopped = true;
194
+ }
195
+ else {
196
+ droppedRejected += 1;
197
+ resolved.add(entry.idempotencyKey);
198
+ }
199
+ }
200
+ catch {
201
+ stopped = true;
202
+ }
203
+ }
204
+ // Re-read + filter + rewrite are back-to-back synchronous calls with no await
205
+ // between them, so no append can interleave in that window (single-threaded).
206
+ // Everything not resolved this pass — kept entries AND any mid-flush append —
207
+ // survives. A re-appended duplicate of a replayed key is harmless (the server
208
+ // dedups on idempotencyKey; it just replays next pass).
209
+ const keep = loadSpool().filter((e) => !resolved.has(e.idempotencyKey));
210
+ rewriteSpool(keep);
211
+ const notices = [];
212
+ if (replayed > 0) {
213
+ notices.push(`Replayed ${replayed} locally spooled YAGNI record${plural(replayed)}.`);
214
+ }
215
+ if (droppedExpired > 0) {
216
+ notices.push(`Dropped ${droppedExpired} unsent YAGNI record${plural(droppedExpired)} older than 14 days from the local spool.`);
217
+ }
218
+ if (droppedRejected > 0) {
219
+ notices.push(`Discarded ${droppedRejected} spooled YAGNI record${plural(droppedRejected)} the backend rejected.`);
220
+ }
221
+ return { replayed, droppedExpired, droppedRejected, remaining: keep.length, notices };
222
+ // (keep.length reflects the post-rewrite spool, including any mid-flush append.)
223
+ }
224
+ /**
225
+ * POST one judgment write with its idempotencyKey; on transport failure or a
226
+ * 5xx, durably spool it for replay instead of losing it. A successful write
227
+ * also kicks a background flush (fail-soft, fire-and-forget) so earlier
228
+ * stranded entries drain as soon as the backend is healthy again.
229
+ */
230
+ export async function sendOrSpool(opts, label, endpoint, body, idempotencyKey, signal) {
231
+ const posted = { ...body, idempotencyKey };
232
+ let res;
233
+ try {
234
+ res = await resilientFetch(`${opts.baseUrl}${endpoint}`, {
235
+ method: "POST",
236
+ headers: {
237
+ "content-type": "application/json",
238
+ authorization: `Bearer ${opts.getToken() ?? ""}`,
239
+ },
240
+ body: JSON.stringify(posted),
241
+ }, { fetchImpl: opts.fetchImpl, signal, policy: METERED_POST_FETCH_POLICY });
242
+ }
243
+ catch (err) {
244
+ const reason = err instanceof Error ? err.message : String(err);
245
+ appendToSpool({ endpoint, body: posted, idempotencyKey, ts: (opts.now ?? Date.now)() });
246
+ return { kind: "spooled", reason };
247
+ }
248
+ if (res.ok) {
249
+ // Success proves the backend is reachable: drain anything stranded earlier.
250
+ void flushSpool(opts).catch(() => { });
251
+ let json = null;
252
+ try {
253
+ json = await res.json();
254
+ }
255
+ catch {
256
+ /* a 2xx with a non-JSON body still counts as recorded */
257
+ }
258
+ return { kind: "ok", json };
259
+ }
260
+ if (res.status >= 500) {
261
+ appendToSpool({ endpoint, body: posted, idempotencyKey, ts: (opts.now ?? Date.now)() });
262
+ return { kind: "spooled", reason: `the YAGNI backend returned HTTP ${res.status}` };
263
+ }
264
+ return { kind: "rejected", message: await friendlyFetchError(label, res) };
265
+ }
266
+ //# sourceMappingURL=spool.js.map
@@ -0,0 +1,2 @@
1
+ export declare function codeStateHome(override: string | null, env?: NodeJS.ProcessEnv, userHome?: string): string;
2
+ //# sourceMappingURL=stateHome.d.ts.map
@@ -0,0 +1,6 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ export function codeStateHome(override, env = process.env, userHome = homedir()) {
4
+ return override ?? env.YAGNI_CODE_HOME ?? join(userHome, ".yagni-code");
5
+ }
6
+ //# sourceMappingURL=stateHome.js.map
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The general `subagent` tool — delegate a self-contained task to an agent
3
+ * with its own fresh context window, optionally in parallel.
4
+ *
5
+ * Reuses the /go pipeline's battle-tested child runner (`runStage`: NDJSON
6
+ * stream folding, output caps, abort escalation) through its `personaBody`
7
+ * seam, so a subagent is exactly a grounded pi child whose system prompt is
8
+ * the agent definition's body instead of a /go persona.
9
+ *
10
+ * Agent definitions are the Claude Code format — markdown with
11
+ * name/description/tools/model frontmatter — discovered from the repo's
12
+ * `.claude/agents/` and `.pi/agents/`, the user's `~/.claude/agents/`, and
13
+ * any Claude Code plugin `agents/` dirs the launcher passed via
14
+ * `YAGNI_CLAUDE_AGENT_DIRS` (project beats user beats plugin on a name
15
+ * collision). A built-in `general` agent is always available, so the tool
16
+ * works in a repo with no definitions at all.
17
+ * Claude model names map onto YAGNI tiers (opus→advanced, sonnet→standard,
18
+ * haiku→efficient); unknown models run on standard.
19
+ */
20
+ import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
21
+ import { Type } from "typebox";
22
+ import { runStage } from "./pipeline/runner.js";
23
+ import type { ModelTier, PipelineStage } from "./pipeline/types.js";
24
+ export declare const SUBAGENT_TOOL_NAME = "subagent";
25
+ export declare const GENERAL_AGENT_NAME = "general";
26
+ export declare const MAX_PARALLEL_SUBAGENTS = 4;
27
+ /**
28
+ * The default tool surface a subagent gets when its definition declares none:
29
+ * the full working set plus grounded answers, mirroring what a Claude Code
30
+ * subagent would inherit.
31
+ */
32
+ export declare const DEFAULT_SUBAGENT_TOOLS: string[];
33
+ export type SubagentSource = "project-claude" | "project-pi" | "user-claude" | "plugin" | "builtin";
34
+ /**
35
+ * Claude Code plugin `agents/` dirs, delimiter-joined by the yagni launcher
36
+ * (see yagni-code-cli claudeCompat.ts — trust gating happens there).
37
+ */
38
+ export declare const CLAUDE_AGENT_DIRS_ENV = "YAGNI_CLAUDE_AGENT_DIRS";
39
+ export interface SubagentDef {
40
+ name: string;
41
+ description: string;
42
+ tools?: string[];
43
+ model: ModelTier;
44
+ /** The agent's system prompt (the markdown body). */
45
+ body: string;
46
+ source: SubagentSource;
47
+ }
48
+ /** Claude Code model names → YAGNI tiers; YAGNI tiers pass through. */
49
+ export declare function mapModelTier(model: string | undefined): ModelTier;
50
+ /**
51
+ * Parse one Claude Code-format agent file. Returns null when the file has no
52
+ * description — the one field the tool cannot do without (the model picks
53
+ * agents by it).
54
+ */
55
+ export declare function parseAgentMarkdown(content: string, fallbackName: string, source: SubagentSource): SubagentDef | null;
56
+ export interface DiscoverDeps {
57
+ cwd: string;
58
+ homeDir?: string;
59
+ /** Env seam for the plugin agent dirs (defaults to process.env). */
60
+ env?: NodeJS.ProcessEnv;
61
+ }
62
+ /**
63
+ * All available agents: built-in general, then Claude Code plugin `agents/`
64
+ * dirs (launcher-provided), then user `~/.claude/agents`, then the repo's
65
+ * `.pi/agents` and `.claude/agents` — later wins on a name collision, so a
66
+ * project definition beats a user one beats a plugin one beats the built-in.
67
+ */
68
+ export declare function discoverSubagents(deps: DiscoverDeps): SubagentDef[];
69
+ export declare function formatAgentList(agents: SubagentDef[]): string;
70
+ /**
71
+ * A subagent invocation is a synthetic pipeline stage: the task rides in as
72
+ * `{ticket}` verbatim, and the agent's body replaces the /go persona through
73
+ * the runner's personaBody seam.
74
+ */
75
+ export declare function buildSubagentStage(def: SubagentDef, task: string): {
76
+ stage: PipelineStage;
77
+ ctx: {
78
+ ticket: string;
79
+ grounded: boolean;
80
+ };
81
+ };
82
+ type SubagentParams = {
83
+ task?: string;
84
+ agent?: string;
85
+ tasks?: Array<{
86
+ task: string;
87
+ agent?: string;
88
+ }>;
89
+ };
90
+ export interface MakeSubagentToolDeps {
91
+ runStageImpl?: typeof runStage;
92
+ discover?: (deps: DiscoverDeps) => SubagentDef[];
93
+ homeDir?: string;
94
+ }
95
+ export declare function makeSubagentTool(deps?: MakeSubagentToolDeps): {
96
+ name: string;
97
+ label: string;
98
+ description: string;
99
+ promptSnippet: string;
100
+ parameters: Type.TObject<{
101
+ task: Type.TOptional<Type.TString>;
102
+ agent: Type.TOptional<Type.TString>;
103
+ tasks: Type.TOptional<Type.TArray<Type.TObject<{
104
+ task: Type.TString;
105
+ agent: Type.TOptional<Type.TString>;
106
+ }>>>;
107
+ }>;
108
+ execute(_toolCallId: string, params: SubagentParams, signal?: AbortSignal, onUpdate?: (update: {
109
+ content: Array<{
110
+ type: "text";
111
+ text: string;
112
+ }>;
113
+ details: unknown;
114
+ }) => void, ctx?: ExtensionContext): Promise<{
115
+ content: {
116
+ type: "text";
117
+ text: string;
118
+ }[];
119
+ details: {};
120
+ isError: boolean;
121
+ } | {
122
+ isError?: boolean | undefined;
123
+ content: {
124
+ type: "text";
125
+ text: string;
126
+ }[];
127
+ details: {
128
+ tasks: {
129
+ agent: string;
130
+ task: string;
131
+ exitCode: number;
132
+ usage: import("./pipeline/types.js").StageUsage;
133
+ toolCalls: number;
134
+ }[];
135
+ };
136
+ }>;
137
+ };
138
+ export interface RegisterSubagentsDeps {
139
+ discover?: (deps: DiscoverDeps) => SubagentDef[];
140
+ homeDir?: string;
141
+ }
142
+ /** Wire the subagent tool and the /agents listing command. */
143
+ export declare function registerSubagents(pi: ExtensionAPI, deps?: RegisterSubagentsDeps): void;
144
+ export {};
145
+ //# sourceMappingURL=subagents.d.ts.map