@yagni-app/code-staging 0.1.0-staging.997.1 → 0.2.0-staging.1025.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 (64) hide show
  1. package/README.md +58 -9
  2. package/dist/claudeCompat.d.ts +36 -5
  3. package/dist/claudeCompat.js +85 -23
  4. package/dist/claudePlugins.d.ts +109 -0
  5. package/dist/claudePlugins.js +336 -0
  6. package/dist/cli.js +14 -4
  7. package/dist/crashReport.d.ts +135 -0
  8. package/dist/crashReport.js +291 -0
  9. package/dist/doctor.d.ts +21 -0
  10. package/dist/doctor.js +52 -0
  11. package/dist/extension/askAdvisorTool.js +7 -1
  12. package/dist/extension/bless.js +16 -3
  13. package/dist/extension/boostCommand.d.ts +144 -0
  14. package/dist/extension/boostCommand.js +263 -0
  15. package/dist/extension/branding.d.ts +31 -0
  16. package/dist/extension/branding.js +37 -0
  17. package/dist/extension/chipEditor.js +7 -3
  18. package/dist/extension/claudeRules.d.ts +54 -0
  19. package/dist/extension/claudeRules.js +180 -0
  20. package/dist/extension/config.d.ts +61 -0
  21. package/dist/extension/config.js +86 -0
  22. package/dist/extension/costHud.d.ts +128 -15
  23. package/dist/extension/costHud.js +189 -19
  24. package/dist/extension/crashReport.d.ts +89 -0
  25. package/dist/extension/crashReport.js +241 -0
  26. package/dist/extension/index.d.ts +43 -4
  27. package/dist/extension/index.js +241 -32
  28. package/dist/extension/initPass.d.ts +65 -47
  29. package/dist/extension/initPass.js +145 -145
  30. package/dist/extension/mcpTools.d.ts +57 -0
  31. package/dist/extension/mcpTools.js +132 -0
  32. package/dist/extension/pipeline/eval.d.ts +42 -5
  33. package/dist/extension/pipeline/eval.js +44 -0
  34. package/dist/extension/pipeline/goCommand.d.ts +18 -0
  35. package/dist/extension/pipeline/goCommand.js +139 -26
  36. package/dist/extension/pipeline/goCompareCommand.d.ts +18 -8
  37. package/dist/extension/pipeline/goCompareCommand.js +42 -23
  38. package/dist/extension/pipeline/orchestrator.js +9 -0
  39. package/dist/extension/pipeline/runCostTable.d.ts +37 -0
  40. package/dist/extension/pipeline/runCostTable.js +165 -0
  41. package/dist/extension/pipeline/runState.d.ts +19 -0
  42. package/dist/extension/pipeline/runState.js +11 -0
  43. package/dist/extension/pipeline/runner.d.ts +19 -0
  44. package/dist/extension/pipeline/runner.js +13 -1
  45. package/dist/extension/pipeline/scrubSecrets.js +2 -2
  46. package/dist/extension/pipeline/stages.d.ts +3 -1
  47. package/dist/extension/pipeline/stages.js +3 -1
  48. package/dist/extension/pipeline/types.d.ts +7 -4
  49. package/dist/extension/pipeline/verify.js +6 -1
  50. package/dist/extension/pipeline/worktree.js +3 -1
  51. package/dist/extension/provider.d.ts +7 -1
  52. package/dist/extension/provider.js +8 -1
  53. package/dist/extension/recall.js +5 -2
  54. package/dist/extension/rerouteNotice.d.ts +42 -0
  55. package/dist/extension/rerouteNotice.js +67 -0
  56. package/dist/extension/sessionRuns.d.ts +45 -0
  57. package/dist/extension/sessionRuns.js +77 -0
  58. package/dist/extension/subagents.d.ts +17 -7
  59. package/dist/extension/subagents.js +52 -7
  60. package/dist/launch.d.ts +17 -3
  61. package/dist/launch.js +22 -9
  62. package/dist/login.d.ts +7 -0
  63. package/dist/login.js +3 -1
  64. package/package.json +2 -2
@@ -1,28 +1,28 @@
1
1
  /**
2
- * The CLI init pass (Onramp Door B, spec §5B/§7).
2
+ * The CLI first-run experience (Onramp Door B, spec §5B/§7, as amended by
3
+ * ADR-0033: first run is a welcome, not a Team ceremony).
3
4
  *
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.
5
+ * On a FRESH workspace (an empty/thin grounding corpus, via the existing
6
+ * `GET /api/yagni-code/context` brief), the first interactive `yagni` run shows
7
+ * a short WELCOME: how to work with YAGNI in one notice, then one offer (ask
8
+ * @yagni what to work on next) with free text as a first-class choice.
9
+ * Escape/cancel land in an empty editor ready to type into, never a forced
10
+ * prompt. The welcome records NOTHING and reads nothing from the repo.
15
11
  *
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.
12
+ * The Team-drafting flow (repo intake -> engineering-half draft -> approve/edit
13
+ * -> bank the brief as ONE decision) lives behind the explicit `/setup-team`
14
+ * command ({@link registerTeamSetupCommand}) so onboarding can invoke it
15
+ * deliberately. Honesty rails are unchanged (spec §9, F2/F13): approval-gated
16
+ * writes, honest-when-thin (nothing fabricated), the Team is only ever a DRAFT
17
+ * and never created automatically, and the repo intake reads only universal
18
+ * signals (README, AGENTS.md/CLAUDE.md, package.json scripts) so it behaves
19
+ * the same on any customer repo.
20
20
  *
21
21
  * No new backend transport: the only writes are through the existing token-scoped
22
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.
23
+ * injectable so both flows are unit-testable without a network or a filesystem.
24
24
  */
25
- import { readFile as fsReadFile, readdir as fsReaddir } from "node:fs/promises";
25
+ import { readFile as fsReadFile } from "node:fs/promises";
26
26
  import path from "node:path";
27
27
  import { fetchContextBrief as defaultFetchContextBrief, } from "./config.js";
28
28
  import { defaultNextAction } from "./nextWorkTool.js";
@@ -59,23 +59,13 @@ const nodeIntakeFs = {
59
59
  return "";
60
60
  }
61
61
  },
62
- async readdir(p) {
63
- try {
64
- return await fsReaddir(p);
65
- }
66
- catch {
67
- return [];
68
- }
69
- },
70
62
  };
71
63
  /** Candidate filenames, tried in order; the first that reads non-empty wins. */
72
64
  const README_CANDIDATES = ["README.md", "README", "readme.md", "README.markdown"];
73
65
  const AGENTS_CANDIDATES = ["AGENTS.md", "CLAUDE.md"];
74
- const ADR_DIRS = ["docs/adr", "docs/adrs", "docs/decisions"];
75
66
  /** Cap the amount of prose we lift so a large README never bloats the brief. */
76
67
  const MAX_DOC_CHARS = 4000;
77
68
  const MAX_SUMMARY_CHARS = 280;
78
- const MAX_ADRS = 25;
79
69
  async function firstNonEmpty(fs, cwd, names) {
80
70
  for (const name of names) {
81
71
  const body = (await fs.readFile(path.join(cwd, name))).trim();
@@ -104,59 +94,38 @@ function parseScripts(raw) {
104
94
  return {};
105
95
  }
106
96
  }
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
97
  /**
135
98
  * Read the repo at `cwd` for the signals that seed the engineering half of the
136
99
  * brief. Every read is best-effort; a missing file simply drops that signal.
137
100
  */
138
101
  export async function readRepoIntake(cwd, fs = nodeIntakeFs) {
139
- const [readme, agents, pkgRaw, adrs] = await Promise.all([
102
+ const [readme, agents, pkgRaw] = await Promise.all([
140
103
  firstNonEmpty(fs, cwd, README_CANDIDATES),
141
104
  firstNonEmpty(fs, cwd, AGENTS_CANDIDATES),
142
105
  fs.readFile(path.join(cwd, "package.json")),
143
- readAdrs(fs, cwd),
144
106
  ]);
145
107
  const packageScripts = parseScripts(pkgRaw);
146
108
  return {
147
109
  readme,
148
110
  agents,
149
111
  packageScripts,
150
- adrs,
151
112
  testCommand: packageScripts.test || undefined,
152
113
  buildCommand: packageScripts.build || undefined,
153
114
  };
154
115
  }
155
- const THIN_BRIEF = "I couldn't find a README, AGENTS.md/CLAUDE.md, or ADRs in this repo, so I don't " +
116
+ const THIN_BRIEF = "I couldn't find a README or AGENTS.md/CLAUDE.md in this repo, so I don't " +
156
117
  "know much about how you build yet. Tell me in a line what this repo is and how " +
157
118
  "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. */
119
+ const TEAM_MISSION = "Owns how we build: conventions, reviews, and test/build.";
120
+ /** Cap `text` at `max` chars, cutting back to the last word boundary with an ellipsis. */
121
+ function truncateAtWord(text, max) {
122
+ if (text.length <= max)
123
+ return text;
124
+ const cut = text.slice(0, max);
125
+ const lastSpace = cut.lastIndexOf(" ");
126
+ return `${(lastSpace > 0 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
127
+ }
128
+ /** The README's first real paragraph (heading-stripped), capped at a word boundary. */
160
129
  function firstParagraph(readme) {
161
130
  if (!readme)
162
131
  return undefined;
@@ -164,14 +133,16 @@ function firstParagraph(readme) {
164
133
  .split(/\n\s*\n/)
165
134
  .map((p) => p.trim())
166
135
  .find((p) => p && !p.startsWith("#") && !/^[!\[]/.test(p));
167
- return para?.replace(/\s+/g, " ").slice(0, MAX_SUMMARY_CHARS);
136
+ if (!para)
137
+ return undefined;
138
+ return truncateAtWord(para.replace(/\s+/g, " "), MAX_SUMMARY_CHARS);
168
139
  }
169
140
  /**
170
141
  * 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.
142
+ * with no README/AGENTS the brief says so and NO decision is seeded.
172
143
  */
173
144
  export function draftEngineering(intake) {
174
- const hasSubstance = Boolean(intake.readme || intake.agents || intake.adrs.length);
145
+ const hasSubstance = Boolean(intake.readme || intake.agents);
175
146
  const thin = !hasSubstance;
176
147
  const team = {
177
148
  name: "Engineering",
@@ -180,9 +151,6 @@ export function draftEngineering(intake) {
180
151
  playbookRules: [],
181
152
  testCommand: intake.testCommand,
182
153
  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
154
  };
187
155
  if (intake.testCommand)
188
156
  team.playbookRules.push(`Run \`${intake.testCommand}\` before handing off a change.`);
@@ -197,8 +165,6 @@ export function draftEngineering(intake) {
197
165
  team.responsibilities.push("Uphold the conventions documented in AGENTS.md/CLAUDE.md.");
198
166
  if (intake.testCommand || intake.buildCommand)
199
167
  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
168
  if (team.responsibilities.length === 0)
203
169
  team.responsibilities.push("Own how this team builds software.");
204
170
  const lines = ["How this team builds software (drafted from repo intake — a draft, editable):"];
@@ -211,23 +177,17 @@ export function draftEngineering(intake) {
211
177
  lines.push(`- Tests: \`${intake.testCommand}\``);
212
178
  if (intake.buildCommand)
213
179
  lines.push(`- Build: \`${intake.buildCommand}\``);
214
- if (intake.adrs.length)
215
- lines.push(`- Decisions on record: ${intake.adrs.map((a) => a.title).join("; ")}`);
216
180
  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.
181
+ // Seed the corpus with ONE entry so the very first /go has real ground to
182
+ // cite: the drafted brief itself. Repo docs stay in the repo where the agent
183
+ // can read them whole; regex-lifted fragments are never banked as decisions.
184
+ // The brief is a DRAFT (editable), never treated as immutable fact.
220
185
  const decisions = [
221
186
  {
222
187
  question: "How does this team build software?",
223
188
  decision: brief,
224
- rationale: "Drafted from repo intake (README/AGENTS/ADRs) during the first YAGNI Code run; a draft, editable.",
189
+ rationale: "Drafted from repo intake (README/AGENTS) during the first YAGNI Code run; a draft, editable.",
225
190
  },
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
191
  ];
232
192
  return { brief, thin: false, decisions, team };
233
193
  }
@@ -250,27 +210,37 @@ export function summarizeDraft(draft) {
250
210
  parts.push("", "(Engineering context is thin — nothing to record yet; I never fabricate one.)");
251
211
  }
252
212
  else if (draft.decisions.length > 0) {
253
- parts.push("", `On approval I'll record ${draft.decisions.length} decision(s) into your grounding corpus.`);
213
+ parts.push("", "On approval I'll save this brief into your grounding corpus. Nothing else is recorded.");
254
214
  }
255
215
  parts.push("", "This Team is a draft — it is never created automatically. You can edit it first.");
256
216
  return parts.join("\n");
257
217
  }
218
+ /** A permissive notify wrapper: a notice must never disrupt a flow. */
219
+ function safeNotify(ui) {
220
+ return (message, type) => {
221
+ try {
222
+ ui?.notify?.(message, type);
223
+ }
224
+ catch {
225
+ /* swallowed */
226
+ }
227
+ };
228
+ }
229
+ /** The one-notice welcome shown on the very first interactive run in a repo. */
230
+ const WELCOME_NOTICE = "First run in this repo. Just type to start working. Ask me about your company " +
231
+ "context (tickets, docs, decisions), or run /go <ticket> for a ticket-to-PR run.";
232
+ /** The free-text choice: an untouched editor, ready for whatever they want to build. */
233
+ const FREE_TEXT_LABEL = "Just start typing";
258
234
  /**
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).
235
+ * Run the first-run welcome (ADR-0033). Guards on fresh-workspace detection
236
+ * (defensive — the caller also guards), shows a short how-to-work-with-YAGNI
237
+ * notice, and offers ONE default next action (ask @yagni what to work on next)
238
+ * with free text as a first-class choice. Picking "Just start typing",
239
+ * pressing escape, or cancelling all leave the editor untouched so the user
240
+ * can type whatever they want to start on. Records NOTHING, reads nothing from
241
+ * the repo, and is fully fail-soft (it must never break session start).
265
242
  */
266
243
  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
244
  // 1. Fresh-workspace guard (skip when already grounded).
275
245
  const brief = deps.brief !== undefined
276
246
  ? deps.brief
@@ -281,45 +251,78 @@ export async function runInitPass(pi, ctx, deps) {
281
251
  }).catch(() => null);
282
252
  if (!isFreshWorkspace(brief))
283
253
  return { ran: false, reason: "not_fresh" };
254
+ // In non-interactive / print (`-p`) / no-UI mode there is no one to welcome;
255
+ // skip WITHOUT marking init-done so the first interactive run still gets it.
284
256
  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
257
  if (!ui)
292
258
  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 */
259
+ const notify = safeNotify(ui);
260
+ notify(WELCOME_NOTICE, "info");
261
+ // 2. Offer the ONE default next action (spec §7.3: defaults over choices),
262
+ // with free text as a first-class door. Everything here is best-effort:
263
+ // whatever happens, the user ends at a prompt they can just type into.
264
+ let chosenAction = "free_text";
265
+ try {
266
+ const next = defaultNextAction();
267
+ const review = next.escapeHatches.find((h) => h.id === "review");
268
+ if (typeof ui.select === "function") {
269
+ const options = [next.primary, ...(review ? [review] : [])];
270
+ const labels = [next.primary.label, FREE_TEXT_LABEL, ...(review ? [review.label] : [])];
271
+ const picked = await ui.select("What next?", labels);
272
+ const chosen = options.find((o) => o.label === picked);
273
+ // "Just start typing", escape, and cancel all leave the editor alone —
274
+ // a first run must never force a prompt on the user.
275
+ if (chosen) {
276
+ chosenAction = chosen.id;
277
+ ui.setEditorText?.(chosen.prompt);
278
+ }
299
279
  }
280
+ }
281
+ catch {
282
+ /* the offer is best-effort; free text always works */
283
+ }
284
+ return { ran: true, chosenAction };
285
+ }
286
+ /**
287
+ * Run the `/setup-team` flow: read the repo's universal signals, draft the
288
+ * engineering half + a proposed Team, present it for approve/edit, and ONLY
289
+ * THEN bank the brief as one decision (approval-gated: an approve banks the
290
+ * draft, an edit banks the correction, a decline writes nothing). Fully
291
+ * fail-soft; the Team itself is never created automatically.
292
+ */
293
+ export async function runTeamSetup(pi, ctx, deps) {
294
+ const recordDecisionFn = deps.recordDecision ?? defaultRecordDecision;
295
+ const readIntake = deps.readRepoIntake ?? ((cwd) => readRepoIntake(cwd));
296
+ const decisionOpts = {
297
+ baseUrl: deps.baseUrl,
298
+ getToken: deps.getToken,
299
+ fetchImpl: deps.fetchImpl,
300
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.
301
+ // Approval-gated writes (spec §5B/§9 — captured-judgment beat): a decision
302
+ // only earns its place when a human can see and approve/edit the draft. With
303
+ // no UI there is no one to approve, so we NEVER write.
304
+ const ui = (ctx.hasUI ? ctx.ui : undefined);
305
+ if (!ui)
306
+ return { ran: false, reason: "non_interactive" };
307
+ const notify = safeNotify(ui);
308
+ notify("Reading the repo to draft your Engineering Team…", "info");
309
+ // 1. Read the repo + draft the engineering half.
303
310
  let intake;
304
311
  try {
305
- intake = await readIntake(ctx.cwd);
312
+ intake = await readIntake(ctx.cwd ?? process.cwd());
306
313
  }
307
314
  catch {
308
- intake = { packageScripts: {}, adrs: [] };
315
+ intake = { packageScripts: {} };
309
316
  }
310
317
  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.
318
+ // 2. Present the draft for approve/edit BEFORE any durable write (F2/F13).
319
+ // In a UI without a `confirm` capability there is no way to obtain
320
+ // approval, so (consistent with the no-UI guard above) we write nothing.
317
321
  let decisionsRecorded = 0;
318
- let chosenAction;
319
322
  try {
320
323
  let approved = false;
321
324
  let correctedBrief;
322
- if (ui && typeof ui.confirm === "function") {
325
+ if (typeof ui.confirm === "function") {
323
326
  approved = await ui.confirm("Your draft Engineering Team", summarizeDraft(draft));
324
327
  if (!approved && typeof ui.editor === "function") {
325
328
  const edited = await ui.editor("Edit the engineering brief", draft.brief);
@@ -328,11 +331,11 @@ export async function runInitPass(pi, ctx, deps) {
328
331
  }
329
332
  }
330
333
  }
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).
334
+ // 3. Approval-gated corpus writes. APPROVE → bank the drafted brief
335
+ // decision. REJECT-then-correct → bank ONLY the correction (the
336
+ // rejected auto-draft is never written). REJECT without an edit (or no
337
+ // approval UI) → write NOTHING. Best-effort throughout: a failed write
338
+ // is swallowed and the recorded count stays honest (F13).
336
339
  if (approved) {
337
340
  for (const decision of draft.decisions) {
338
341
  try {
@@ -352,7 +355,7 @@ export async function runInitPass(pi, ctx, deps) {
352
355
  const result = await recordDecisionFn(decisionOpts, {
353
356
  question: "How does this team build software?",
354
357
  decision: correctedBrief,
355
- rationale: "The user edited the drafted engineering brief during the init pass.",
358
+ rationale: "The user edited the drafted engineering brief during team setup.",
356
359
  }, ctx.signal);
357
360
  if (!result.spooled)
358
361
  decisionsRecorded += 1;
@@ -364,31 +367,28 @@ export async function runInitPass(pi, ctx, deps) {
364
367
  // Report the ACTUAL recorded count once the write has run (never the
365
368
  // intended count) so the receipt is honest even on a partial/failed write.
366
369
  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);
370
+ notify("Saved your engineering brief into the grounding corpus.", "info");
377
371
  }
378
- else {
379
- notify(`Next: ${next.primary.prompt}`, "info");
372
+ else if (!draft.thin) {
373
+ notify("Nothing recorded.", "info");
380
374
  }
381
375
  }
382
376
  catch {
383
- /* the presentation is best-effort; the corpus is already seeded */
377
+ /* the presentation is best-effort */
384
378
  }
385
- return {
386
- ran: true,
387
- thin: draft.thin,
388
- decisionsRecorded,
389
- teamDrafted: true,
390
- teamCommitted: false,
391
- chosenAction,
392
- };
379
+ return { ran: true, thin: draft.thin, decisionsRecorded, teamCommitted: false };
380
+ }
381
+ /**
382
+ * Register `/setup-team`: the explicit, approval-gated flow that drafts the
383
+ * Engineering Team + engineering brief from the repo. Deliberately NOT part of
384
+ * first-run (ADR-0033) — onboarding invokes it when the workspace is ready.
385
+ */
386
+ export function registerTeamSetupCommand(pi, opts) {
387
+ pi.registerCommand("setup-team", {
388
+ description: "Draft an Engineering Team + engineering brief from this repo; nothing is saved without your approval.",
389
+ handler: async (_args, ctx) => {
390
+ await runTeamSetup(pi, ctx, opts);
391
+ },
392
+ });
393
393
  }
394
394
  //# sourceMappingURL=initPass.js.map
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Workspace MCP servers in YAGNI Code (YAG-446).
3
+ *
4
+ * The workspace's registered MCP servers (Connections → MCP Servers) are
5
+ * the config source: at session start we fetch the list from the backend
6
+ * and register one pi tool per enabled server tool, named with the same
7
+ * `mcp_<slug>__<tool>` wire shape the backend executor parses. Calls
8
+ * execute THROUGH the backend (`POST /api/yagni-code/mcp/call`), so
9
+ * credentials never reach this machine and the backend's egress guard,
10
+ * autonomy gate, per-user credential policy, and audit trail all apply.
11
+ *
12
+ * Everything here is fail-soft against an older backend or an
13
+ * un-rescoped token: a 403/404/network failure on the list fetch means
14
+ * no MCP tools and no startup error.
15
+ */
16
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
+ export interface McpToolInfo {
18
+ name: string;
19
+ description: string | null;
20
+ inputSchema: unknown;
21
+ riskClass: string;
22
+ }
23
+ export interface McpServerInfo {
24
+ slug: string;
25
+ name: string;
26
+ tools: McpToolInfo[];
27
+ viewerCredential: {
28
+ source: "user" | "workspace";
29
+ };
30
+ }
31
+ export interface McpClientOpts {
32
+ baseUrl: string;
33
+ getToken: () => string | undefined;
34
+ fetchImpl?: typeof fetch;
35
+ }
36
+ export declare function mcpWireToolName(slug: string, tool: string): string;
37
+ /**
38
+ * Fetch the workspace's MCP servers. Fail-soft by design: any non-OK
39
+ * response (older backend without the endpoints, token minted before the
40
+ * `yagni_code:mcp` scope existed) or transport error returns `[]`.
41
+ */
42
+ export declare function fetchMcpServers(opts: McpClientOpts): Promise<McpServerInfo[]>;
43
+ /**
44
+ * Register every reachable MCP tool. Returns the wire names of MUTATING
45
+ * tools so the caller can extend the permission-gate policy — plan mode
46
+ * holds them, review mode confirms them, exactly like write/edit/bash.
47
+ */
48
+ export declare function registerMcpTools(pi: ExtensionAPI, servers: McpServerInfo[], opts: McpClientOpts): {
49
+ mutatingToolNames: string[];
50
+ };
51
+ /** Render the /mcp listing (pure, for tests). */
52
+ export declare function renderMcpListing(servers: McpServerInfo[], baseUrl: string): string;
53
+ /** Wire the `/mcp` command: list servers, tools, and credential status. */
54
+ export declare function registerMcpCommand(pi: ExtensionAPI, servers: McpServerInfo[], opts: {
55
+ baseUrl: string;
56
+ }): void;
57
+ //# sourceMappingURL=mcpTools.d.ts.map
@@ -0,0 +1,132 @@
1
+ import { Type } from "typebox";
2
+ import { friendlyFetchError, METERED_POST_FETCH_POLICY, resilientFetch } from "./resilientFetch.js";
3
+ export function mcpWireToolName(slug, tool) {
4
+ return `mcp_${slug}__${tool}`;
5
+ }
6
+ /**
7
+ * Fetch the workspace's MCP servers. Fail-soft by design: any non-OK
8
+ * response (older backend without the endpoints, token minted before the
9
+ * `yagni_code:mcp` scope existed) or transport error returns `[]`.
10
+ */
11
+ export async function fetchMcpServers(opts) {
12
+ try {
13
+ const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/mcp/servers`, {
14
+ method: "GET",
15
+ headers: { authorization: `Bearer ${opts.getToken() ?? ""}` },
16
+ }, { fetchImpl: opts.fetchImpl });
17
+ if (!res.ok)
18
+ return [];
19
+ const data = (await res.json());
20
+ if (!Array.isArray(data.servers))
21
+ return [];
22
+ return data.servers;
23
+ }
24
+ catch {
25
+ return [];
26
+ }
27
+ }
28
+ function schemaFor(tool) {
29
+ const schema = tool.inputSchema;
30
+ if (schema !== null &&
31
+ typeof schema === "object" &&
32
+ !Array.isArray(schema) &&
33
+ Object.keys(schema).length > 0) {
34
+ // MCP inputSchemas are plain JSON Schema objects, which is exactly what
35
+ // TypeBox schemas are at runtime — pass the server's schema through so
36
+ // the model sees real parameter shapes.
37
+ return schema;
38
+ }
39
+ return Type.Object({}, { additionalProperties: true });
40
+ }
41
+ function makeMcpTool(server, tool, opts) {
42
+ const wireName = mcpWireToolName(server.slug, tool.name);
43
+ return {
44
+ name: wireName,
45
+ label: `${server.name}: ${tool.name}`,
46
+ description: `${tool.description ?? `MCP tool ${tool.name}`} ` +
47
+ `(MCP server "${server.name}", risk: ${tool.riskClass}; runs through YAGNI)`,
48
+ parameters: schemaFor(tool),
49
+ async execute(_toolCallId, params, signal) {
50
+ const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/mcp/call`, {
51
+ method: "POST",
52
+ headers: {
53
+ "content-type": "application/json",
54
+ authorization: `Bearer ${opts.getToken() ?? ""}`,
55
+ },
56
+ body: JSON.stringify({
57
+ slug: server.slug,
58
+ tool: tool.name,
59
+ args: params ?? {},
60
+ }),
61
+ }, { fetchImpl: opts.fetchImpl, signal, policy: METERED_POST_FETCH_POLICY });
62
+ if (!res.ok) {
63
+ throw new Error(await friendlyFetchError(wireName, res));
64
+ }
65
+ const result = (await res.json());
66
+ if (!result.success) {
67
+ const suffix = result.errorCode === "user_credential_required"
68
+ ? ` Bind your token at ${opts.baseUrl}/services/mcp.`
69
+ : "";
70
+ throw new Error(`${result.error ?? "MCP tool call failed"}${suffix}`);
71
+ }
72
+ const text = typeof result.result === "string"
73
+ ? result.result
74
+ : JSON.stringify(result.result ?? null, null, 2);
75
+ return {
76
+ content: [{ type: "text", text }],
77
+ details: { riskClass: tool.riskClass },
78
+ };
79
+ },
80
+ };
81
+ }
82
+ /**
83
+ * Register every reachable MCP tool. Returns the wire names of MUTATING
84
+ * tools so the caller can extend the permission-gate policy — plan mode
85
+ * holds them, review mode confirms them, exactly like write/edit/bash.
86
+ */
87
+ export function registerMcpTools(pi, servers, opts) {
88
+ const mutatingToolNames = [];
89
+ for (const server of servers) {
90
+ for (const tool of server.tools) {
91
+ pi.registerTool(makeMcpTool(server, tool, opts));
92
+ if (tool.riskClass !== "read_only") {
93
+ mutatingToolNames.push(mcpWireToolName(server.slug, tool.name));
94
+ }
95
+ }
96
+ }
97
+ return { mutatingToolNames };
98
+ }
99
+ /** Render the /mcp listing (pure, for tests). */
100
+ export function renderMcpListing(servers, baseUrl) {
101
+ if (servers.length === 0) {
102
+ return [
103
+ "No MCP servers connected for this workspace.",
104
+ `An admin can register one under Connections: ${baseUrl}/services/mcp`,
105
+ ].join("\n");
106
+ }
107
+ const lines = ["Workspace MCP servers:"];
108
+ for (const server of servers) {
109
+ const mutating = server.tools.filter((t) => t.riskClass !== "read_only").length;
110
+ const credential = server.viewerCredential.source === "user"
111
+ ? "connected as you"
112
+ : "workspace credential";
113
+ lines.push(` ${server.name} (${server.slug}) — ${server.tools.length} tool${server.tools.length === 1 ? "" : "s"}` +
114
+ `${mutating > 0 ? ` (${mutating} mutating)` : ""} · ${credential}`);
115
+ for (const tool of server.tools) {
116
+ lines.push(` ${mcpWireToolName(server.slug, tool.name)} [${tool.riskClass}]`);
117
+ }
118
+ }
119
+ lines.push(`Bind a personal token (writes carry your authority): ${baseUrl}/services/mcp`);
120
+ return lines.join("\n");
121
+ }
122
+ /** Wire the `/mcp` command: list servers, tools, and credential status. */
123
+ export function registerMcpCommand(pi, servers, opts) {
124
+ pi.registerCommand("mcp", {
125
+ description: "List this workspace's MCP servers, their tools, and your credential status.",
126
+ handler: async (_args, ctx) => {
127
+ if (ctx.hasUI)
128
+ ctx.ui.notify(renderMcpListing(servers, opts.baseUrl), "info");
129
+ },
130
+ });
131
+ }
132
+ //# sourceMappingURL=mcpTools.js.map