@yagni-app/code-staging 0.1.0-staging.1019.1 → 0.1.0-staging.1020.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.
@@ -110,8 +110,8 @@ export { makeSuggestNextWorkTool, defaultNextAction } from "./nextWorkTool.js";
110
110
  export type { MakeNextWorkToolOptions, DefaultNextAction, NextActionOption, } from "./nextWorkTool.js";
111
111
  export { recordDecision } from "./recordDecisionTool.js";
112
112
  export type { RecordDecisionParams } from "./recordDecisionTool.js";
113
- export { runInitPass, isFreshWorkspace, readRepoIntake, draftEngineering, summarizeDraft, FRESH_BRIEF_MIN_CHARS, } from "./initPass.js";
114
- export type { RunInitPassDeps, InitPassOutcome, RepoIntake, RepoIntakeFs, RepoAdr, EngineeringDraft, DraftTeam, } from "./initPass.js";
113
+ export { runInitPass, runTeamSetup, registerTeamSetupCommand, isFreshWorkspace, readRepoIntake, draftEngineering, summarizeDraft, FRESH_BRIEF_MIN_CHARS, } from "./initPass.js";
114
+ export type { RunInitPassDeps, InitPassOutcome, RunTeamSetupDeps, TeamSetupOutcome, RepoIntake, RepoIntakeFs, EngineeringDraft, DraftTeam, } from "./initPass.js";
115
115
  export { isInitDone, markInitDone, initDoneMarkerFile, _setInitDoneHomeForTest } from "./initDone.js";
116
116
  export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, BRAND_NAME } from "./branding.js";
117
117
  export { attributionHeaders, isDriverCaller, fetchCatalog, getToken, getWorkspaceId, resolveBaseUrl, sanitizeCallerSegment, } from "./config.js";
@@ -16,7 +16,7 @@ import { isDebug } from "./diagnostics.js";
16
16
  import { droppedSessionRuns, sessionRunIds } from "./sessionRuns.js";
17
17
  import { codeStateHome } from "./stateHome.js";
18
18
  import { RerouteNotifier } from "./rerouteNotice.js";
19
- import { isFreshWorkspace, runInitPass as defaultRunInitPass } from "./initPass.js";
19
+ import { isFreshWorkspace, registerTeamSetupCommand, runInitPass as defaultRunInitPass } from "./initPass.js";
20
20
  import { isInitDone as defaultIsInitDone, markInitDone as defaultMarkInitDone } from "./initDone.js";
21
21
  import { fetchMcpServers as defaultFetchMcpServers, registerMcpCommand, registerMcpTools, } from "./mcpTools.js";
22
22
  import { registerGoCommand } from "./pipeline/goCommand.js";
@@ -359,21 +359,26 @@ export async function registerYagni(pi, deps = {}) {
359
359
  decisionsCount: briefResult?.counts?.decisions ?? 0,
360
360
  evalMode,
361
361
  });
362
- // Onramp Door B (spec §5B/§7): on a FRESH workspace (empty/thin grounding
363
- // corpus), the first run reads the repo, drafts the engineering-half brief + a
364
- // proposed Engineering Team, and seeds decisions — instead of a bare prompt. We
365
- // reuse the brief we just fetched (no double round-trip). Skipped in eval mode.
362
+ // ADR-0033: the Team-drafting flow is an explicit command, never a first-run
363
+ // ceremony. /setup-team drafts the Engineering Team + engineering brief from
364
+ // the repo and banks the brief as one decision on approval.
365
+ registerTeamSetupCommand(pi, { baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
366
+ // Onramp Door B (spec §5B/§7, as amended by ADR-0033): on a FRESH workspace
367
+ // (empty/thin grounding corpus), the first run shows a short welcome + one
368
+ // next-work offer, with free text first-class — instead of a bare prompt. It
369
+ // records nothing; Team drafting lives behind /setup-team. We reuse the brief
370
+ // we just fetched (no double round-trip). Skipped in eval mode.
366
371
  const runInitPassFn = deps.runInitPass ?? defaultRunInitPass;
367
372
  const getWorkspaceIdFn = deps.getWorkspaceId ?? (() => defaultGetWorkspaceId(deps.env));
368
373
  const isInitDoneFn = deps.isInitDone ?? defaultIsInitDone;
369
374
  const markInitDoneFn = deps.markInitDone ?? defaultMarkInitDone;
370
375
  const workspaceId = getWorkspaceIdFn();
371
- // F2a — one-time marker (idempotency): the init pass writes only to
372
- // `yagni_code_decisions`, which the context endpoint never reads back (it reads
373
- // Vision + Goals), so a Vision/Goals-less workspace looks "fresh" forever and
374
- // would re-seed duplicate decisions on every launch. A per-workspace marker
375
- // short-circuits the pass after its first real run — regardless of brief
376
- // emptiness. (Degrades to freshness-only when the workspace id is unknown.)
376
+ // F2a — one-time marker (idempotency): the context endpoint never reflects
377
+ // the welcome (it reads Vision + Goals), so a Vision/Goals-less workspace
378
+ // looks "fresh" forever and would re-welcome on every launch. A per-workspace
379
+ // marker short-circuits the pass after its first real run — regardless of
380
+ // brief emptiness. (Degrades to freshness-only when the workspace id is
381
+ // unknown.)
377
382
  const alreadyInit = !evalMode && !!workspaceId && isInitDoneFn(workspaceId);
378
383
  // F2b — fail CLOSED: a FAILED context fetch (network / non-2xx / throw) surfaces
379
384
  // as a `null` brief here (fetchContextBrief and the catch above both map failure
@@ -546,9 +551,9 @@ export { makeRecordEngineeringContextTool } from "./recordContextTool.js";
546
551
  export { makeRecordDecisionTool } from "./recordDecisionTool.js";
547
552
  export { makeSuggestNextWorkTool, defaultNextAction } from "./nextWorkTool.js";
548
553
  export { recordDecision } from "./recordDecisionTool.js";
549
- // Onramp Door B: the CLI init pass (fresh-workspace detection + repo intake +
550
- // engineering-half drafting + decision seeding + one default next action).
551
- export { runInitPass, isFreshWorkspace, readRepoIntake, draftEngineering, summarizeDraft, FRESH_BRIEF_MIN_CHARS, } from "./initPass.js";
554
+ // Onramp Door B (as amended by ADR-0033): the first-run welcome (write-free,
555
+ // free text first-class) + the explicit /setup-team drafting flow.
556
+ export { runInitPass, runTeamSetup, registerTeamSetupCommand, isFreshWorkspace, readRepoIntake, draftEngineering, summarizeDraft, FRESH_BRIEF_MIN_CHARS, } from "./initPass.js";
552
557
  // Onramp Door B (F2a): the one-time init-pass idempotency marker.
553
558
  export { isInitDone, markInitDone, initDoneMarkerFile, _setInitDoneHomeForTest } from "./initDone.js";
554
559
  export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, BRAND_NAME } from "./branding.js";
@@ -1,26 +1,26 @@
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
25
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
26
26
  import { fetchContextBrief as defaultFetchContextBrief, type ContextBrief } from "./config.js";
@@ -39,16 +39,12 @@ export declare const FRESH_BRIEF_MIN_CHARS = 40;
39
39
  * populated and the init pass must be skipped.
40
40
  */
41
41
  export declare function isFreshWorkspace(brief: ContextBrief | null | undefined): boolean;
42
- /** A single architecture-decision record found in the repo. */
43
- export interface RepoAdr {
44
- /** Repo-relative path, e.g. `docs/adr/0001-use-fly.md`. */
45
- path: string;
46
- /** The record's title (its first heading, or the filename when headingless). */
47
- title: string;
48
- /** A short first-paragraph summary, when present. */
49
- summary?: string;
50
- }
51
- /** What the repo intake could find. All fields are best-effort / optional. */
42
+ /**
43
+ * What the repo intake could find. All fields are best-effort / optional, and
44
+ * every signal is a UNIVERSAL repo convention (README, agents file, package
45
+ * scripts) — never a house-specific layout like docs/adr, so the intake
46
+ * behaves the same on any customer repo.
47
+ */
52
48
  export interface RepoIntake {
53
49
  /** README body (capped), when present. */
54
50
  readme?: string;
@@ -56,8 +52,6 @@ export interface RepoIntake {
56
52
  agents?: string;
57
53
  /** package.json `scripts` map (empty when absent/unparseable). */
58
54
  packageScripts: Record<string, string>;
59
- /** Architecture decision records found under docs/adr (etc.). */
60
- adrs: RepoAdr[];
61
55
  /** The literal test command (package.json `scripts.test`), when present. */
62
56
  testCommand?: string;
63
57
  /** The literal build command (package.json `scripts.build`), when present. */
@@ -66,7 +60,6 @@ export interface RepoIntake {
66
60
  /** Injectable filesystem seam so repo intake is unit-testable without disk. */
67
61
  export interface RepoIntakeFs {
68
62
  readFile(p: string): Promise<string>;
69
- readdir(p: string): Promise<string[]>;
70
63
  }
71
64
  /**
72
65
  * Read the repo at `cwd` for the signals that seed the engineering half of the
@@ -81,13 +74,12 @@ export interface DraftTeam {
81
74
  playbookRules: string[];
82
75
  testCommand?: string;
83
76
  buildCommand?: string;
84
- riskAreas: string[];
85
77
  }
86
78
  /** The drafted engineering half: the brief, seeded decisions, and a Team draft. */
87
79
  export interface EngineeringDraft {
88
80
  /** The engineering-half brief text (honest-when-thin). */
89
81
  brief: string;
90
- /** True when the repo had no README/AGENTS/ADRs — the brief says so, nothing is fabricated. */
82
+ /** True when the repo had no README/AGENTS — the brief says so, nothing is fabricated. */
91
83
  thin: boolean;
92
84
  /** Decisions to seed into the corpus. EMPTY when thin (no fabrication). */
93
85
  decisions: RecordDecisionParams[];
@@ -96,10 +88,10 @@ export interface EngineeringDraft {
96
88
  }
97
89
  /**
98
90
  * Draft the engineering half of the brief from repo intake. PURE. Honest-when-thin:
99
- * with no README/AGENTS/ADRs the brief says so and NO decision is seeded.
91
+ * with no README/AGENTS the brief says so and NO decision is seeded.
100
92
  */
101
93
  export declare function draftEngineering(intake: RepoIntake): EngineeringDraft;
102
- /** Injectable dependencies for {@link runInitPass}. */
94
+ /** Injectable dependencies for {@link runInitPass} (the first-run welcome). */
103
95
  export interface RunInitPassDeps {
104
96
  baseUrl: string;
105
97
  getToken: () => string | undefined;
@@ -111,23 +103,34 @@ export interface RunInitPassDeps {
111
103
  */
112
104
  brief?: ContextBrief | null;
113
105
  fetchContextBrief?: typeof defaultFetchContextBrief;
114
- readRepoIntake?: (cwd: string) => Promise<RepoIntake>;
115
- recordDecision?: typeof defaultRecordDecision;
116
106
  }
117
- /** The outcome of an init-pass attempt. */
107
+ /** The outcome of a first-run welcome attempt. The welcome never writes. */
118
108
  export type InitPassOutcome = {
119
109
  ran: false;
120
110
  reason: "not_fresh" | "non_interactive";
111
+ } | {
112
+ ran: true;
113
+ /** What the user picked; `free_text` covers the typing option AND escape/cancel. */
114
+ chosenAction: NextActionOption["id"] | "free_text";
115
+ };
116
+ /** Injectable dependencies for {@link runTeamSetup} (the `/setup-team` flow). */
117
+ export interface RunTeamSetupDeps {
118
+ baseUrl: string;
119
+ getToken: () => string | undefined;
120
+ fetchImpl?: typeof fetch;
121
+ readRepoIntake?: (cwd: string) => Promise<RepoIntake>;
122
+ recordDecision?: typeof defaultRecordDecision;
123
+ }
124
+ /** The outcome of a `/setup-team` run. */
125
+ export type TeamSetupOutcome = {
126
+ ran: false;
127
+ reason: "non_interactive";
121
128
  } | {
122
129
  ran: true;
123
130
  thin: boolean;
124
131
  decisionsRecorded: number;
125
- /** The Team was drafted for the user. */
126
- teamDrafted: true;
127
132
  /** The Team is NEVER created/committed automatically. Always false. */
128
133
  teamCommitted: false;
129
- /** Which next action the user picked (undefined in headless mode). */
130
- chosenAction?: NextActionOption["id"];
131
134
  };
132
135
  /**
133
136
  * Compact one-screen PREVIEW of the draft for the approve/edit dialog.
@@ -141,12 +144,27 @@ export type InitPassOutcome = {
141
144
  */
142
145
  export declare function summarizeDraft(draft: EngineeringDraft): string;
143
146
  /**
144
- * Run the init pass. Guards on fresh-workspace detection (defensive — the caller
145
- * also guards), reads the repo, drafts the engineering half, presents it for
146
- * approve/edit, and ONLY THEN seeds the corpus (approval-gated: an approve banks
147
- * the drafted decisions, an edit banks the correction, a decline writes nothing),
148
- * before offering the one default next action. Fully fail-soft: a UI or network
149
- * hiccup never throws (it must never break session start).
147
+ * Run the first-run welcome (ADR-0033). Guards on fresh-workspace detection
148
+ * (defensive — the caller also guards), shows a short how-to-work-with-YAGNI
149
+ * notice, and offers ONE default next action (ask @yagni what to work on next)
150
+ * with free text as a first-class choice. Picking "Just start typing",
151
+ * pressing escape, or cancelling all leave the editor untouched so the user
152
+ * can type whatever they want to start on. Records NOTHING, reads nothing from
153
+ * the repo, and is fully fail-soft (it must never break session start).
150
154
  */
151
155
  export declare function runInitPass(pi: ExtensionAPI, ctx: ExtensionContext, deps: RunInitPassDeps): Promise<InitPassOutcome>;
156
+ /**
157
+ * Run the `/setup-team` flow: read the repo's universal signals, draft the
158
+ * engineering half + a proposed Team, present it for approve/edit, and ONLY
159
+ * THEN bank the brief as one decision (approval-gated: an approve banks the
160
+ * draft, an edit banks the correction, a decline writes nothing). Fully
161
+ * fail-soft; the Team itself is never created automatically.
162
+ */
163
+ export declare function runTeamSetup(pi: ExtensionAPI, ctx: ExtensionContext, deps: RunTeamSetupDeps): Promise<TeamSetupOutcome>;
164
+ /**
165
+ * Register `/setup-team`: the explicit, approval-gated flow that drafts the
166
+ * Engineering Team + engineering brief from the repo. Deliberately NOT part of
167
+ * first-run (ADR-0033) — onboarding invokes it when the workspace is ready.
168
+ */
169
+ export declare function registerTeamSetupCommand(pi: ExtensionAPI, opts: RunTeamSetupDeps): void;
152
170
  //# sourceMappingURL=initPass.d.ts.map
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "0.1.0-staging.1019.1",
3
+ "version": "0.1.0-staging.1020.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -38,5 +38,5 @@
38
38
  "@earendil-works/pi-tui": "0.83.0",
39
39
  "typebox": "^1.1.38"
40
40
  },
41
- "yagniSourceSha": "03dc13fce991758459702c7178a5f4cfa5d110b7"
41
+ "yagniSourceSha": "db184c4a3d15bc4b5e140a5337f34be7dadbb943"
42
42
  }