@yagni-app/code-staging 0.0.0 → 0.1.0-staging.997.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/LICENSE.md +55 -0
  2. package/README.md +166 -0
  3. package/dist/branding.d.ts +25 -0
  4. package/dist/branding.js +27 -0
  5. package/dist/claudeCompat.d.ts +78 -0
  6. package/dist/claudeCompat.js +198 -0
  7. package/dist/cli.d.ts +55 -0
  8. package/dist/cli.js +326 -0
  9. package/dist/config.d.ts +19 -0
  10. package/dist/config.js +28 -0
  11. package/dist/credentials.d.ts +41 -0
  12. package/dist/credentials.js +75 -0
  13. package/dist/distribution.d.ts +15 -0
  14. package/dist/distribution.js +45 -0
  15. package/dist/doctor.d.ts +94 -0
  16. package/dist/doctor.js +343 -0
  17. package/dist/extension/advisor.d.ts +84 -0
  18. package/dist/extension/advisor.js +101 -0
  19. package/dist/extension/askAdvisorTool.d.ts +83 -0
  20. package/dist/extension/askAdvisorTool.js +188 -0
  21. package/dist/extension/askYagniTool.d.ts +39 -0
  22. package/dist/extension/askYagniTool.js +63 -0
  23. package/dist/extension/bless.d.ts +45 -0
  24. package/dist/extension/bless.js +74 -0
  25. package/dist/extension/branding.d.ts +48 -0
  26. package/dist/extension/branding.js +119 -0
  27. package/dist/extension/chipEditor.d.ts +117 -0
  28. package/dist/extension/chipEditor.js +369 -0
  29. package/dist/extension/config.d.ts +112 -0
  30. package/dist/extension/config.js +108 -0
  31. package/dist/extension/costHud.d.ts +73 -0
  32. package/dist/extension/costHud.js +123 -0
  33. package/dist/extension/decisionCapture.d.ts +52 -0
  34. package/dist/extension/decisionCapture.js +66 -0
  35. package/dist/extension/decisions.d.ts +83 -0
  36. package/dist/extension/decisions.js +200 -0
  37. package/dist/extension/diagnostics.d.ts +41 -0
  38. package/dist/extension/diagnostics.js +110 -0
  39. package/dist/extension/index.d.ts +116 -0
  40. package/dist/extension/index.js +388 -0
  41. package/dist/extension/initDone.d.ts +28 -0
  42. package/dist/extension/initDone.js +66 -0
  43. package/dist/extension/initPass.d.ts +152 -0
  44. package/dist/extension/initPass.js +394 -0
  45. package/dist/extension/nextWorkTool.d.ts +51 -0
  46. package/dist/extension/nextWorkTool.js +80 -0
  47. package/dist/extension/permission.d.ts +91 -0
  48. package/dist/extension/permission.js +236 -0
  49. package/dist/extension/pipeline/activity.d.ts +37 -0
  50. package/dist/extension/pipeline/activity.js +151 -0
  51. package/dist/extension/pipeline/activityFeed.d.ts +59 -0
  52. package/dist/extension/pipeline/activityFeed.js +175 -0
  53. package/dist/extension/pipeline/budget.d.ts +48 -0
  54. package/dist/extension/pipeline/budget.js +68 -0
  55. package/dist/extension/pipeline/checkpoint.d.ts +31 -0
  56. package/dist/extension/pipeline/checkpoint.js +176 -0
  57. package/dist/extension/pipeline/eval.d.ts +168 -0
  58. package/dist/extension/pipeline/eval.js +182 -0
  59. package/dist/extension/pipeline/events.d.ts +56 -0
  60. package/dist/extension/pipeline/events.js +147 -0
  61. package/dist/extension/pipeline/findings.d.ts +42 -0
  62. package/dist/extension/pipeline/findings.js +144 -0
  63. package/dist/extension/pipeline/finish.d.ts +128 -0
  64. package/dist/extension/pipeline/finish.js +307 -0
  65. package/dist/extension/pipeline/goCommand.d.ts +128 -0
  66. package/dist/extension/pipeline/goCommand.js +972 -0
  67. package/dist/extension/pipeline/goCompareCommand.d.ts +48 -0
  68. package/dist/extension/pipeline/goCompareCommand.js +184 -0
  69. package/dist/extension/pipeline/goFlags.d.ts +40 -0
  70. package/dist/extension/pipeline/goFlags.js +46 -0
  71. package/dist/extension/pipeline/goStatusCommands.d.ts +59 -0
  72. package/dist/extension/pipeline/goStatusCommands.js +188 -0
  73. package/dist/extension/pipeline/invocation.d.ts +45 -0
  74. package/dist/extension/pipeline/invocation.js +64 -0
  75. package/dist/extension/pipeline/orchestrator.d.ts +131 -0
  76. package/dist/extension/pipeline/orchestrator.js +636 -0
  77. package/dist/extension/pipeline/personas.d.ts +44 -0
  78. package/dist/extension/pipeline/personas.js +248 -0
  79. package/dist/extension/pipeline/resilience.d.ts +85 -0
  80. package/dist/extension/pipeline/resilience.js +166 -0
  81. package/dist/extension/pipeline/resume.d.ts +18 -0
  82. package/dist/extension/pipeline/resume.js +106 -0
  83. package/dist/extension/pipeline/runRegistry.d.ts +112 -0
  84. package/dist/extension/pipeline/runRegistry.js +202 -0
  85. package/dist/extension/pipeline/runSession.d.ts +152 -0
  86. package/dist/extension/pipeline/runSession.js +167 -0
  87. package/dist/extension/pipeline/runState.d.ts +158 -0
  88. package/dist/extension/pipeline/runState.js +264 -0
  89. package/dist/extension/pipeline/runner.d.ts +79 -0
  90. package/dist/extension/pipeline/runner.js +298 -0
  91. package/dist/extension/pipeline/scrubSecrets.d.ts +15 -0
  92. package/dist/extension/pipeline/scrubSecrets.js +32 -0
  93. package/dist/extension/pipeline/stages.d.ts +51 -0
  94. package/dist/extension/pipeline/stages.js +113 -0
  95. package/dist/extension/pipeline/ticketResolution.d.ts +34 -0
  96. package/dist/extension/pipeline/ticketResolution.js +75 -0
  97. package/dist/extension/pipeline/types.d.ts +436 -0
  98. package/dist/extension/pipeline/types.js +48 -0
  99. package/dist/extension/pipeline/verify.d.ts +277 -0
  100. package/dist/extension/pipeline/verify.js +758 -0
  101. package/dist/extension/pipeline/workspace.d.ts +32 -0
  102. package/dist/extension/pipeline/workspace.js +73 -0
  103. package/dist/extension/pipeline/worktree.d.ts +107 -0
  104. package/dist/extension/pipeline/worktree.js +200 -0
  105. package/dist/extension/provider.d.ts +11 -0
  106. package/dist/extension/provider.js +26 -0
  107. package/dist/extension/recall.d.ts +93 -0
  108. package/dist/extension/recall.js +187 -0
  109. package/dist/extension/recordContextTool.d.ts +38 -0
  110. package/dist/extension/recordContextTool.js +85 -0
  111. package/dist/extension/recordDecisionTool.d.ts +52 -0
  112. package/dist/extension/recordDecisionTool.js +102 -0
  113. package/dist/extension/repoDocs.d.ts +81 -0
  114. package/dist/extension/repoDocs.js +260 -0
  115. package/dist/extension/resilientFetch.d.ts +60 -0
  116. package/dist/extension/resilientFetch.js +133 -0
  117. package/dist/extension/reviewTool.d.ts +34 -0
  118. package/dist/extension/reviewTool.js +81 -0
  119. package/dist/extension/spool.d.ts +92 -0
  120. package/dist/extension/spool.js +266 -0
  121. package/dist/extension/stateHome.d.ts +2 -0
  122. package/dist/extension/stateHome.js +6 -0
  123. package/dist/extension/subagents.d.ts +135 -0
  124. package/dist/extension/subagents.js +281 -0
  125. package/dist/extension/surface.d.ts +10 -0
  126. package/dist/extension/surface.js +12 -0
  127. package/dist/extension/todos.d.ts +110 -0
  128. package/dist/extension/todos.js +217 -0
  129. package/dist/extension/tokenProvider.d.ts +93 -0
  130. package/dist/extension/tokenProvider.js +234 -0
  131. package/dist/launch.d.ts +76 -0
  132. package/dist/launch.js +111 -0
  133. package/dist/login.d.ts +45 -0
  134. package/dist/login.js +142 -0
  135. package/dist/logout.d.ts +14 -0
  136. package/dist/logout.js +34 -0
  137. package/dist/paths.d.ts +31 -0
  138. package/dist/paths.js +87 -0
  139. package/dist/piPackage.d.ts +33 -0
  140. package/dist/piPackage.js +71 -0
  141. package/dist/profiles.d.ts +80 -0
  142. package/dist/profiles.js +222 -0
  143. package/dist/refresh.d.ts +70 -0
  144. package/dist/refresh.js +117 -0
  145. package/dist/upgrade.d.ts +97 -0
  146. package/dist/upgrade.js +284 -0
  147. package/package.json +39 -3
@@ -0,0 +1,394 @@
1
+ /**
2
+ * The CLI init pass (Onramp Door B, spec §5B/§7).
3
+ *
4
+ * On a FRESH workspace, the first `yagni` run in a repo should not drop the
5
+ * user into a bare prompt. Instead it runs a one-time init pass:
6
+ * 1. detect a fresh workspace (an empty/thin grounding corpus, via the existing
7
+ * `GET /api/yagni-code/context` brief),
8
+ * 2. read the repo (README, AGENTS.md/CLAUDE.md, package.json scripts, ADRs),
9
+ * 3. draft the ENGINEERING HALF of the company brief + a proposed Engineering
10
+ * Team, and record the salient ADRs/conventions as decisions so the corpus
11
+ * is non-empty for the very first `/go`,
12
+ * 4. present the draft for approve/edit in-terminal (mirrored to the app via the
13
+ * recorded decisions), NEVER auto-committing the Team, and
14
+ * 5. offer the ONE default next action (`suggest_next_work`) with escape hatches.
15
+ *
16
+ * Honesty rails (non-negotiable, spec §9): when a repo has no README/AGENTS/ADRs
17
+ * the engineering half stays THIN and SAYS SO — nothing is fabricated, and no
18
+ * decision is seeded from thin air. The proposed Team is only ever a DRAFT; it is
19
+ * never created/committed automatically.
20
+ *
21
+ * No new backend transport: the only writes are through the existing token-scoped
22
+ * `record_decision` endpoint (reused via {@link recordDecision}). Every seam is
23
+ * injectable so the whole pass is unit-testable without a network or a filesystem.
24
+ */
25
+ import { readFile as fsReadFile, readdir as fsReaddir } from "node:fs/promises";
26
+ import path from "node:path";
27
+ import { fetchContextBrief as defaultFetchContextBrief, } from "./config.js";
28
+ import { defaultNextAction } from "./nextWorkTool.js";
29
+ import { recordDecision as defaultRecordDecision, } from "./recordDecisionTool.js";
30
+ // ---------------------------------------------------------------------------
31
+ // Fresh-workspace detection
32
+ // ---------------------------------------------------------------------------
33
+ /**
34
+ * Below this many non-whitespace characters (and with no sources), a returned
35
+ * context brief is treated as thin/empty — i.e. the corpus has not been seeded
36
+ * yet, so this is a fresh workspace that should run the init pass.
37
+ */
38
+ export const FRESH_BRIEF_MIN_CHARS = 40;
39
+ /**
40
+ * Is this a fresh workspace (an empty/thin grounding corpus)? A `null` brief, or
41
+ * a brief with only a stub of text and no sources, means nothing has grounded the
42
+ * workspace yet. A brief with real text OR any sources means it is already
43
+ * populated and the init pass must be skipped.
44
+ */
45
+ export function isFreshWorkspace(brief) {
46
+ if (!brief)
47
+ return true;
48
+ const text = (brief.brief ?? "").trim();
49
+ const hasSources = Array.isArray(brief.sources) && brief.sources.length > 0;
50
+ return text.length < FRESH_BRIEF_MIN_CHARS && !hasSources;
51
+ }
52
+ /** The default fs seam: node:fs/promises, resolving to empty on any error. */
53
+ const nodeIntakeFs = {
54
+ async readFile(p) {
55
+ try {
56
+ return await fsReadFile(p, "utf8");
57
+ }
58
+ catch {
59
+ return "";
60
+ }
61
+ },
62
+ async readdir(p) {
63
+ try {
64
+ return await fsReaddir(p);
65
+ }
66
+ catch {
67
+ return [];
68
+ }
69
+ },
70
+ };
71
+ /** Candidate filenames, tried in order; the first that reads non-empty wins. */
72
+ const README_CANDIDATES = ["README.md", "README", "readme.md", "README.markdown"];
73
+ const AGENTS_CANDIDATES = ["AGENTS.md", "CLAUDE.md"];
74
+ const ADR_DIRS = ["docs/adr", "docs/adrs", "docs/decisions"];
75
+ /** Cap the amount of prose we lift so a large README never bloats the brief. */
76
+ const MAX_DOC_CHARS = 4000;
77
+ const MAX_SUMMARY_CHARS = 280;
78
+ const MAX_ADRS = 25;
79
+ async function firstNonEmpty(fs, cwd, names) {
80
+ for (const name of names) {
81
+ const body = (await fs.readFile(path.join(cwd, name))).trim();
82
+ if (body)
83
+ return body.slice(0, MAX_DOC_CHARS);
84
+ }
85
+ return undefined;
86
+ }
87
+ /** Parse the `scripts` map out of a package.json body; {} on any problem. */
88
+ function parseScripts(raw) {
89
+ if (!raw.trim())
90
+ return {};
91
+ try {
92
+ const parsed = JSON.parse(raw);
93
+ const scripts = parsed.scripts;
94
+ if (!scripts || typeof scripts !== "object")
95
+ return {};
96
+ const out = {};
97
+ for (const [k, v] of Object.entries(scripts)) {
98
+ if (typeof v === "string")
99
+ out[k] = v;
100
+ }
101
+ return out;
102
+ }
103
+ catch {
104
+ return {};
105
+ }
106
+ }
107
+ /** The first `# heading` (or the filename) as a title; first paragraph as summary. */
108
+ function extractAdr(relPath, content) {
109
+ const heading = content.match(/^#{1,3}\s+(.+)$/m);
110
+ const title = heading?.[1]?.trim() || path.basename(relPath).replace(/\.[^.]+$/, "");
111
+ // First non-heading, non-empty paragraph.
112
+ const paragraphs = content
113
+ .split(/\n\s*\n/)
114
+ .map((p) => p.trim())
115
+ .filter((p) => p && !p.startsWith("#"));
116
+ const summary = paragraphs[0]?.replace(/\s+/g, " ").slice(0, MAX_SUMMARY_CHARS);
117
+ return { path: relPath, title, summary: summary || undefined };
118
+ }
119
+ async function readAdrs(fs, cwd) {
120
+ const adrs = [];
121
+ for (const dir of ADR_DIRS) {
122
+ const entries = await fs.readdir(path.join(cwd, dir));
123
+ for (const entry of entries.filter((e) => /\.m(d|arkdown)$/i.test(e)).sort()) {
124
+ if (adrs.length >= MAX_ADRS)
125
+ return adrs;
126
+ const rel = `${dir}/${entry}`;
127
+ const content = await fs.readFile(path.join(cwd, rel));
128
+ if (content.trim())
129
+ adrs.push(extractAdr(rel, content));
130
+ }
131
+ }
132
+ return adrs;
133
+ }
134
+ /**
135
+ * Read the repo at `cwd` for the signals that seed the engineering half of the
136
+ * brief. Every read is best-effort; a missing file simply drops that signal.
137
+ */
138
+ export async function readRepoIntake(cwd, fs = nodeIntakeFs) {
139
+ const [readme, agents, pkgRaw, adrs] = await Promise.all([
140
+ firstNonEmpty(fs, cwd, README_CANDIDATES),
141
+ firstNonEmpty(fs, cwd, AGENTS_CANDIDATES),
142
+ fs.readFile(path.join(cwd, "package.json")),
143
+ readAdrs(fs, cwd),
144
+ ]);
145
+ const packageScripts = parseScripts(pkgRaw);
146
+ return {
147
+ readme,
148
+ agents,
149
+ packageScripts,
150
+ adrs,
151
+ testCommand: packageScripts.test || undefined,
152
+ buildCommand: packageScripts.build || undefined,
153
+ };
154
+ }
155
+ const THIN_BRIEF = "I couldn't find a README, AGENTS.md/CLAUDE.md, or ADRs in this repo, so I don't " +
156
+ "know much about how you build yet. Tell me in a line what this repo is and how " +
157
+ "you build it, and I'll fill in the engineering brief.";
158
+ const TEAM_MISSION = "Owns how we build: conventions, reviews, test/build, and risk areas.";
159
+ /** The README's first real paragraph (heading-stripped), capped. */
160
+ function firstParagraph(readme) {
161
+ if (!readme)
162
+ return undefined;
163
+ const para = readme
164
+ .split(/\n\s*\n/)
165
+ .map((p) => p.trim())
166
+ .find((p) => p && !p.startsWith("#") && !/^[!\[]/.test(p));
167
+ return para?.replace(/\s+/g, " ").slice(0, MAX_SUMMARY_CHARS);
168
+ }
169
+ /**
170
+ * Draft the engineering half of the brief from repo intake. PURE. Honest-when-thin:
171
+ * with no README/AGENTS/ADRs the brief says so and NO decision is seeded.
172
+ */
173
+ export function draftEngineering(intake) {
174
+ const hasSubstance = Boolean(intake.readme || intake.agents || intake.adrs.length);
175
+ const thin = !hasSubstance;
176
+ const team = {
177
+ name: "Engineering",
178
+ mission: TEAM_MISSION,
179
+ responsibilities: [],
180
+ playbookRules: [],
181
+ testCommand: intake.testCommand,
182
+ buildCommand: intake.buildCommand,
183
+ riskAreas: intake.adrs
184
+ .filter((a) => /risk|security|migration|billing|auth/i.test(a.title))
185
+ .map((a) => a.title),
186
+ };
187
+ if (intake.testCommand)
188
+ team.playbookRules.push(`Run \`${intake.testCommand}\` before handing off a change.`);
189
+ if (intake.buildCommand)
190
+ team.playbookRules.push(`Run \`${intake.buildCommand}\` to verify the change builds.`);
191
+ team.playbookRules.push("Ask @yagni before guessing on anything org- or codebase-specific; record product-intent decisions as you make them.");
192
+ if (thin) {
193
+ team.responsibilities.push("Define how this team builds software (not documented yet).");
194
+ return { brief: THIN_BRIEF, thin: true, decisions: [], team };
195
+ }
196
+ if (intake.agents)
197
+ team.responsibilities.push("Uphold the conventions documented in AGENTS.md/CLAUDE.md.");
198
+ if (intake.testCommand || intake.buildCommand)
199
+ team.responsibilities.push("Keep the build and tests green.");
200
+ if (intake.adrs.length)
201
+ team.responsibilities.push("Steward the architecture decisions in docs/adr.");
202
+ if (team.responsibilities.length === 0)
203
+ team.responsibilities.push("Own how this team builds software.");
204
+ const lines = ["How this team builds software (drafted from repo intake — a draft, editable):"];
205
+ const whatItIs = firstParagraph(intake.readme);
206
+ if (whatItIs)
207
+ lines.push(`- What this is: ${whatItIs}`);
208
+ if (intake.agents)
209
+ lines.push("- Conventions: documented in AGENTS.md/CLAUDE.md.");
210
+ if (intake.testCommand)
211
+ lines.push(`- Tests: \`${intake.testCommand}\``);
212
+ if (intake.buildCommand)
213
+ lines.push(`- Build: \`${intake.buildCommand}\``);
214
+ if (intake.adrs.length)
215
+ lines.push(`- Decisions on record: ${intake.adrs.map((a) => a.title).join("; ")}`);
216
+ const brief = lines.join("\n");
217
+ // Seed the corpus so the very first /go has real ground to cite. The brief
218
+ // itself is banked as one grounding entry, then each ADR/convention as a
219
+ // decision. All are DRAFTS (editable), never treated as immutable fact.
220
+ const decisions = [
221
+ {
222
+ question: "How does this team build software?",
223
+ decision: brief,
224
+ rationale: "Drafted from repo intake (README/AGENTS/ADRs) during the first YAGNI Code run; a draft, editable.",
225
+ },
226
+ ...intake.adrs.map((adr) => ({
227
+ question: `What does "${adr.title}" decide?`,
228
+ decision: adr.summary || adr.title,
229
+ rationale: `Recorded from ${adr.path} during repo intake.`,
230
+ })),
231
+ ];
232
+ return { brief, thin: false, decisions, team };
233
+ }
234
+ /**
235
+ * Compact one-screen PREVIEW of the draft for the approve/edit dialog.
236
+ *
237
+ * Approval-gated (spec §5B/§9, F2/F13): NOTHING is written to the corpus until
238
+ * the human approves or corrects the draft, so this is a preview of INTENT — it
239
+ * states what will be recorded ON APPROVAL, and never claims a corpus was
240
+ * already seeded. When thin there is nothing to record (honest — nothing to
241
+ * fabricate). The ACTUAL recorded count is reported after the write runs (see
242
+ * `runInitPass`).
243
+ */
244
+ export function summarizeDraft(draft) {
245
+ const parts = [draft.brief, "", `Proposed Team: ${draft.team.name} — ${draft.team.mission}`];
246
+ if (draft.team.responsibilities.length) {
247
+ parts.push("Responsibilities:", ...draft.team.responsibilities.map((r) => ` - ${r}`));
248
+ }
249
+ if (draft.thin) {
250
+ parts.push("", "(Engineering context is thin — nothing to record yet; I never fabricate one.)");
251
+ }
252
+ else if (draft.decisions.length > 0) {
253
+ parts.push("", `On approval I'll record ${draft.decisions.length} decision(s) into your grounding corpus.`);
254
+ }
255
+ parts.push("", "This Team is a draft — it is never created automatically. You can edit it first.");
256
+ return parts.join("\n");
257
+ }
258
+ /**
259
+ * Run the init pass. Guards on fresh-workspace detection (defensive — the caller
260
+ * also guards), reads the repo, drafts the engineering half, presents it for
261
+ * approve/edit, and ONLY THEN seeds the corpus (approval-gated: an approve banks
262
+ * the drafted decisions, an edit banks the correction, a decline writes nothing),
263
+ * before offering the one default next action. Fully fail-soft: a UI or network
264
+ * hiccup never throws (it must never break session start).
265
+ */
266
+ export async function runInitPass(pi, ctx, deps) {
267
+ const recordDecisionFn = deps.recordDecision ?? defaultRecordDecision;
268
+ const readIntake = deps.readRepoIntake ?? ((cwd) => readRepoIntake(cwd));
269
+ const decisionOpts = {
270
+ baseUrl: deps.baseUrl,
271
+ getToken: deps.getToken,
272
+ fetchImpl: deps.fetchImpl,
273
+ };
274
+ // 1. Fresh-workspace guard (skip when already grounded).
275
+ const brief = deps.brief !== undefined
276
+ ? deps.brief
277
+ : await (deps.fetchContextBrief ?? defaultFetchContextBrief)({
278
+ baseUrl: deps.baseUrl,
279
+ getToken: deps.getToken,
280
+ fetchImpl: deps.fetchImpl,
281
+ }).catch(() => null);
282
+ if (!isFreshWorkspace(brief))
283
+ return { ran: false, reason: "not_fresh" };
284
+ const ui = (ctx.hasUI ? ctx.ui : undefined);
285
+ // 1b. Approval-gated writes (spec §5B/§9 — captured-judgment beat): the init
286
+ // pass records decisions into the corpus, but a decision only earns its place
287
+ // when a human can see and approve/edit the draft. In non-interactive / print
288
+ // (`-p`) / no-UI mode there is no one to approve, so we NEVER write — we skip
289
+ // the pass entirely (returning ran:false so the caller does NOT mark the
290
+ // workspace init-done, leaving the seed for the first interactive run).
291
+ if (!ui)
292
+ return { ran: false, reason: "non_interactive" };
293
+ const notify = (message, type) => {
294
+ try {
295
+ ui?.notify?.(message, type);
296
+ }
297
+ catch {
298
+ /* a notice must never disrupt the pass */
299
+ }
300
+ };
301
+ notify("YAGNI Code — first run in this repo. Reading it to seed your engineering brief…", "info");
302
+ // 2. Read the repo + draft the engineering half.
303
+ let intake;
304
+ try {
305
+ intake = await readIntake(ctx.cwd);
306
+ }
307
+ catch {
308
+ intake = { packageScripts: {}, adrs: [] };
309
+ }
310
+ const draft = draftEngineering(intake);
311
+ // 3. Present the draft for approve/edit BEFORE any durable write (spec §5B/§9,
312
+ // F2/F13 — approval-gated writes). A decision earns its place in the corpus
313
+ // only once the human has APPROVED the draft or CORRECTED it; nothing is
314
+ // written before the dialog. The Team itself is never auto-committed. In a
315
+ // UI without a `confirm` capability there is no way to obtain approval, so
316
+ // (consistent with the no-UI guard above) we write nothing.
317
+ let decisionsRecorded = 0;
318
+ let chosenAction;
319
+ try {
320
+ let approved = false;
321
+ let correctedBrief;
322
+ if (ui && typeof ui.confirm === "function") {
323
+ approved = await ui.confirm("Your draft Engineering Team", summarizeDraft(draft));
324
+ if (!approved && typeof ui.editor === "function") {
325
+ const edited = await ui.editor("Edit the engineering brief", draft.brief);
326
+ if (edited && edited.trim() && edited.trim() !== draft.brief.trim()) {
327
+ correctedBrief = edited.trim();
328
+ }
329
+ }
330
+ }
331
+ // 4. Approval-gated corpus writes. APPROVE → bank the drafted decisions.
332
+ // REJECT-then-correct → bank ONLY the correction (the rejected auto-draft
333
+ // is never written). REJECT without an edit (or no approval UI) → write
334
+ // NOTHING. Best-effort throughout: a failed write is swallowed and the
335
+ // recorded count stays honest (F13).
336
+ if (approved) {
337
+ for (const decision of draft.decisions) {
338
+ try {
339
+ const result = await recordDecisionFn(decisionOpts, decision, ctx.signal);
340
+ if (!result.spooled)
341
+ decisionsRecorded += 1;
342
+ }
343
+ catch {
344
+ /* fail-soft: recording is best-effort */
345
+ }
346
+ }
347
+ }
348
+ else if (correctedBrief) {
349
+ // The edit is a captured-judgment beat: bank the correction so the next
350
+ // run inherits the steer.
351
+ try {
352
+ const result = await recordDecisionFn(decisionOpts, {
353
+ question: "How does this team build software?",
354
+ decision: correctedBrief,
355
+ rationale: "The user edited the drafted engineering brief during the init pass.",
356
+ }, ctx.signal);
357
+ if (!result.spooled)
358
+ decisionsRecorded += 1;
359
+ }
360
+ catch {
361
+ /* fail-soft */
362
+ }
363
+ }
364
+ // Report the ACTUAL recorded count once the write has run (never the
365
+ // intended count) so the receipt is honest even on a partial/failed write.
366
+ if (decisionsRecorded > 0) {
367
+ notify(`Recorded ${decisionsRecorded} decision(s) into your grounding corpus.`, "info");
368
+ }
369
+ // 5. Offer the ONE default next action (not a four-option chooser).
370
+ const next = defaultNextAction();
371
+ if (ui && typeof ui.select === "function") {
372
+ const labels = [next.primary.label, ...next.escapeHatches.map((h) => h.label)];
373
+ const picked = await ui.select("What next?", labels);
374
+ const chosen = [next.primary, ...next.escapeHatches].find((o) => o.label === picked) ?? next.primary;
375
+ chosenAction = chosen.id;
376
+ ui.setEditorText?.(chosen.prompt);
377
+ }
378
+ else {
379
+ notify(`Next: ${next.primary.prompt}`, "info");
380
+ }
381
+ }
382
+ catch {
383
+ /* the presentation is best-effort; the corpus is already seeded */
384
+ }
385
+ return {
386
+ ran: true,
387
+ thin: draft.thin,
388
+ decisionsRecorded,
389
+ teamDrafted: true,
390
+ teamCommitted: false,
391
+ chosenAction,
392
+ };
393
+ }
394
+ //# sourceMappingURL=initPass.js.map
@@ -0,0 +1,51 @@
1
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import type { Citation } from "./askYagniTool.js";
4
+ /** Options for {@link makeSuggestNextWorkTool}. */
5
+ export interface MakeNextWorkToolOptions {
6
+ baseUrl: string;
7
+ getToken: () => string | undefined;
8
+ fetchImpl?: typeof fetch;
9
+ }
10
+ /** A single offered next action: its id, the label shown, and the editor prompt it prefills. */
11
+ export interface NextActionOption {
12
+ id: "suggest_next_work" | "bootstrap" | "review";
13
+ /** Short label shown in the selector. */
14
+ label: string;
15
+ /** The message prefilled into the editor when this action is chosen. */
16
+ prompt: string;
17
+ }
18
+ /**
19
+ * The one default next action plus its escape hatches. `primary` is the single
20
+ * most-likely thing to do (ask @yagni what to work on next); `escapeHatches` are
21
+ * the two alternatives (bootstrap a first ticket, or review the working diff).
22
+ * This is deliberately a ONE-default-plus-hatches shape, NOT a flat four-option
23
+ * chooser — defaults over choices (spec §7.3).
24
+ */
25
+ export interface DefaultNextAction {
26
+ primary: NextActionOption;
27
+ escapeHatches: NextActionOption[];
28
+ }
29
+ /**
30
+ * Build the default next action offered after the init pass. The recommended
31
+ * default is `suggest_next_work` ("ask @yagni what to work on next"); the escape
32
+ * hatches are "bootstrap a first ticket and /go" and "review this diff". Pure so
33
+ * the presenter can render it without a network.
34
+ */
35
+ export declare function defaultNextAction(): DefaultNextAction;
36
+ declare const parameters: Type.TObject<{
37
+ context: Type.TOptional<Type.TString>;
38
+ }>;
39
+ /**
40
+ * Build the `suggest_next_work` tool.
41
+ *
42
+ * Asks the YAGNI app for the highest-value next thing to work on, RANKED BY
43
+ * BUSINESS PRIORITY across open work-items and desk next-moves (not by ticket
44
+ * order), with the business case for the top pick. Returns a ranked
45
+ * recommendation plus citations.
46
+ */
47
+ export declare function makeSuggestNextWorkTool(opts: MakeNextWorkToolOptions): ToolDefinition<typeof parameters, {
48
+ citations: Citation[];
49
+ }>;
50
+ export {};
51
+ //# sourceMappingURL=nextWorkTool.d.ts.map
@@ -0,0 +1,80 @@
1
+ import { Type } from "typebox";
2
+ import { friendlyFetchError, METERED_POST_FETCH_POLICY, resilientFetch } from "./resilientFetch.js";
3
+ /**
4
+ * Build the default next action offered after the init pass. The recommended
5
+ * default is `suggest_next_work` ("ask @yagni what to work on next"); the escape
6
+ * hatches are "bootstrap a first ticket and /go" and "review this diff". Pure so
7
+ * the presenter can render it without a network.
8
+ */
9
+ export function defaultNextAction() {
10
+ return {
11
+ primary: {
12
+ id: "suggest_next_work",
13
+ label: "Ask @yagni what to work on next (recommended)",
14
+ prompt: "What should I work on next?",
15
+ },
16
+ escapeHatches: [
17
+ {
18
+ id: "bootstrap",
19
+ label: "Bootstrap: pick a first ticket and /go",
20
+ prompt: "Pick a good first ticket to bootstrap on, then run /go on it.",
21
+ },
22
+ {
23
+ id: "review",
24
+ label: "Review this diff",
25
+ prompt: "Review the current working-tree diff for business fit and correctness.",
26
+ },
27
+ ],
28
+ };
29
+ }
30
+ const parameters = Type.Object({
31
+ context: Type.Optional(Type.String()),
32
+ });
33
+ /**
34
+ * Build the `suggest_next_work` tool.
35
+ *
36
+ * Asks the YAGNI app for the highest-value next thing to work on, RANKED BY
37
+ * BUSINESS PRIORITY across open work-items and desk next-moves (not by ticket
38
+ * order), with the business case for the top pick. Returns a ranked
39
+ * recommendation plus citations.
40
+ */
41
+ export function makeSuggestNextWorkTool(opts) {
42
+ return {
43
+ name: "suggest_next_work",
44
+ label: "Suggest next work",
45
+ description: "Ask YAGNI what to work on next, ranked by BUSINESS priority across open work-items " +
46
+ "and desk next-moves (not just ticket order), with the business case for the top " +
47
+ "pick and a concrete first step. Optionally pass a `context` to focus the ranking " +
48
+ "(e.g. 'revenue this week'). Use when deciding what to pick up.",
49
+ promptSnippet: "suggest_next_work: ask YAGNI for the highest-value next task, ranked by business priority.",
50
+ promptGuidelines: [
51
+ "Call suggest_next_work when the user asks what to do next or when you've finished and need the next task.",
52
+ "Pass a `context` if the user has a focus (a goal, a deadline, an area) so the ranking reflects it.",
53
+ "Present the recommendation with its business case; let the user confirm before starting.",
54
+ ],
55
+ parameters,
56
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
57
+ onUpdate?.({
58
+ content: [{ type: "text", text: "Ranking the highest-value next work…" }],
59
+ details: { citations: [] },
60
+ });
61
+ const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/next-action`, {
62
+ method: "POST",
63
+ headers: {
64
+ "content-type": "application/json",
65
+ authorization: `Bearer ${opts.getToken() ?? ""}`,
66
+ },
67
+ body: JSON.stringify({ context: params.context, cwd: ctx?.cwd }),
68
+ }, { fetchImpl: opts.fetchImpl, signal, policy: METERED_POST_FETCH_POLICY });
69
+ if (!res.ok) {
70
+ throw new Error(await friendlyFetchError("suggest_next_work", res));
71
+ }
72
+ const data = (await res.json());
73
+ return {
74
+ content: [{ type: "text", text: data.suggestion }],
75
+ details: { citations: data.citations ?? [] },
76
+ };
77
+ },
78
+ };
79
+ }
80
+ //# sourceMappingURL=nextWorkTool.js.map
@@ -0,0 +1,91 @@
1
+ /**
2
+ * P3 - interactive permission tiers + plan mode, wired to pi's tool-call gate.
3
+ *
4
+ * YAGNI Code registers no tool_call handler today, so the interactive session has
5
+ * no plan/approval surface. P3 adds one on pi's documented `tool_call` block seam
6
+ * plus a `/mode` command:
7
+ * - auto (default): never blocks. Byte-identical to today, so this is additive.
8
+ * - plan : blocks write/edit/bash so the agent can explore + propose without
9
+ * touching the tree.
10
+ * - review : surfaces a three-way ctx.ui.select before a write/edit/bash; a
11
+ * session bless (policy.isBlessed) auto-approves an action the user
12
+ * already OK'd for this tool + directory, so only novel actions
13
+ * prompt. Picking "Yes, and don't ask again for <tool> in <dir>"
14
+ * adds a session-scoped bless rule AND drafts a decision capture.
15
+ *
16
+ * `decideGate` is PURE; the live wiring holds the mode in a small closure (no
17
+ * module-global state). The default auto mode remains fail-open, but stricter
18
+ * modes fail closed for side-effect tools if the gate itself errors. Bless rules
19
+ * are session-scoped, path-prefix-bound, never persisted, and never consulted in
20
+ * plan mode (plan blocks outright before isBlessed is reached).
21
+ *
22
+ * Plan mode is a full mode, not just a blocker: while active, every agent turn
23
+ * carries a hidden [PLAN MODE ACTIVE] context message so the model plans
24
+ * instead of flailing against held writes, and a footer chip shows the mode.
25
+ * When the mode leaves plan, stale plan-context messages are filtered out of
26
+ * the context so the model doesn't keep believing it is restricted.
27
+ */
28
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
29
+ import { type BlessStore } from "./bless.js";
30
+ export type PermissionMode = "auto" | "plan" | "review";
31
+ /** Which tools each tier acts on, plus the optional grounding-bless predicate. */
32
+ export interface PermissionPolicy {
33
+ /** Tools blocked outright in plan mode (write/exec). */
34
+ planBlockTools: string[];
35
+ /** Tools that prompt for confirmation in review mode. */
36
+ reviewConfirmTools: string[];
37
+ /**
38
+ * Optional: a recorded decision already blesses this action, so it auto-runs in
39
+ * review mode instead of prompting. The hook for tying the gate to captured
40
+ * judgment; default undefined (nothing pre-blessed).
41
+ */
42
+ isBlessed?: (toolName: string, params: Record<string, unknown>) => boolean;
43
+ }
44
+ export declare const DEFAULT_PERMISSION_POLICY: PermissionPolicy;
45
+ /** A pure gate verdict: block outright, ask to confirm, or allow. */
46
+ export interface GateDecision {
47
+ block: boolean;
48
+ reason?: string;
49
+ /** review mode only: the caller should ctx.ui.confirm before allowing. */
50
+ confirm?: boolean;
51
+ }
52
+ /**
53
+ * Pure permission decision for one tool call under a mode + policy. auto always
54
+ * allows; plan blocks the write/exec set; review marks writes for confirmation
55
+ * unless a recorded decision blesses them.
56
+ */
57
+ export declare function decideGate(toolName: string, params: Record<string, unknown>, mode: PermissionMode, policy: PermissionPolicy): GateDecision;
58
+ /** What was blessed with "don't ask again", handed to the capture hook. */
59
+ export interface BlessRememberInfo {
60
+ tool: string;
61
+ /** A human-readable directory label (cwd-relative when inside the tree). */
62
+ dir: string;
63
+ }
64
+ export interface RegisterPermissionDeps {
65
+ policy?: PermissionPolicy;
66
+ /** Initial mode (default auto). */
67
+ mode?: PermissionMode;
68
+ /** Session bless store factory (needs the cwd from the first tool_call). */
69
+ makeBlessStore?: (cwd: string) => BlessStore;
70
+ /**
71
+ * Called (fire-and-forget) when the user picks "Yes, and don't ask again".
72
+ * The judgment-capture hook: draft a decision so YAGNI remembers. Fail-soft;
73
+ * never blocks the approved tool call.
74
+ */
75
+ onBlessRemember?: (ctx: ExtensionContext, info: BlessRememberInfo) => void | Promise<void>;
76
+ }
77
+ /** The customType tag on injected plan-mode context (filterable later). */
78
+ export declare const PLAN_CONTEXT_TYPE = "yagni-plan-context";
79
+ export declare const PLAN_CONTEXT_MESSAGE = "[PLAN MODE ACTIVE]\nYou are in plan mode: explore and design, change nothing.\n- write, edit, and bash are held by the permission gate; do not attempt them.\n- Read, search, and ask_yagni freely to ground the plan in how this company works.\n- Produce a concrete numbered plan of the steps you would take, with the files involved.\n- End by asking the user to review the plan; they run /mode auto (or /mode review) to execute it.\n- Once executing, track the plan's steps with todo_write.";
80
+ /**
81
+ * Drop previously injected plan-mode context once plan mode is off, so the
82
+ * model stops believing writes are held. Pure; returns the SAME array when
83
+ * nothing needs filtering so callers can cheaply detect a no-op.
84
+ */
85
+ export declare function filterStalePlanContext<T>(messages: T[]): T[];
86
+ /**
87
+ * Wire the tool_call gate + the /mode command onto a shared mode holder. Default
88
+ * auto, so absent any /mode this is a no-op over today's behavior.
89
+ */
90
+ export declare function registerPermissionGate(pi: ExtensionAPI, deps?: RegisterPermissionDeps): void;
91
+ //# sourceMappingURL=permission.d.ts.map