@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.
- package/dist/extension/index.d.ts +2 -2
- package/dist/extension/index.js +19 -14
- package/dist/extension/initPass.d.ts +65 -47
- package/dist/extension/initPass.js +145 -145
- package/package.json +2 -2
|
@@ -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,
|
|
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";
|
package/dist/extension/index.js
CHANGED
|
@@ -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
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
|
|
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
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
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
|
|
550
|
-
//
|
|
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
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
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
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
23
|
+
* injectable so both flows are unit-testable without a network or a filesystem.
|
|
24
24
|
*/
|
|
25
|
-
import { readFile as fsReadFile
|
|
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
|
|
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
|
|
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
|
|
159
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
218
|
-
//
|
|
219
|
-
//
|
|
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
|
|
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("",
|
|
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
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
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 = (
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
302
|
-
//
|
|
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: {}
|
|
315
|
+
intake = { packageScripts: {} };
|
|
309
316
|
}
|
|
310
317
|
const draft = draftEngineering(intake);
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
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 (
|
|
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
|
-
//
|
|
332
|
-
// REJECT-then-correct → bank ONLY the correction (the
|
|
333
|
-
// is never written). REJECT without an edit (or no
|
|
334
|
-
// NOTHING. Best-effort throughout: a failed write
|
|
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
|
|
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(
|
|
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(
|
|
372
|
+
else if (!draft.thin) {
|
|
373
|
+
notify("Nothing recorded.", "info");
|
|
380
374
|
}
|
|
381
375
|
}
|
|
382
376
|
catch {
|
|
383
|
-
/* the presentation is best-effort
|
|
377
|
+
/* the presentation is best-effort */
|
|
384
378
|
}
|
|
385
|
-
return {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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.
|
|
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": "
|
|
41
|
+
"yagniSourceSha": "db184c4a3d15bc4b5e140a5337f34be7dadbb943"
|
|
42
42
|
}
|