@yagni-app/code 0.3.1 → 0.3.3
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/cli.js +13 -0
- package/dist/crashReport.d.ts +12 -0
- package/dist/crashReport.js +28 -1
- package/dist/extension/crashReport.d.ts +18 -0
- package/dist/extension/crashReport.js +35 -2
- package/dist/extension/footer.d.ts +1 -1
- package/dist/extension/hooks.d.ts +111 -0
- package/dist/extension/hooks.js +666 -0
- package/dist/extension/index.d.ts +13 -6
- package/dist/extension/index.js +57 -7
- package/dist/extension/{approvedPrefixes.js → permission/approvedPrefixes.js} +1 -1
- package/dist/extension/permission/dbReadPolicy.d.ts +90 -0
- package/dist/extension/permission/dbReadPolicy.js +227 -0
- package/dist/extension/{execPolicy.js → permission/execPolicy.js} +99 -8
- package/dist/extension/{permission.d.ts → permission/gate.d.ts} +10 -3
- package/dist/extension/{permission.js → permission/gate.js} +156 -9
- package/dist/extension/{guardian.d.ts → permission/guardian.d.ts} +2 -2
- package/dist/extension/{guardian.js → permission/guardian.js} +1 -1
- package/dist/extension/permission/index.d.ts +14 -0
- package/dist/extension/permission/index.js +14 -0
- package/dist/extension/permission/packageManagerPolicy.d.ts +55 -0
- package/dist/extension/permission/packageManagerPolicy.js +170 -0
- package/dist/extension/pipeline/activityFeed.js +19 -5
- package/dist/extension/pipeline/checker.d.ts +99 -0
- package/dist/extension/pipeline/checker.js +238 -0
- package/dist/extension/pipeline/fanout.d.ts +116 -0
- package/dist/extension/pipeline/fanout.js +248 -0
- package/dist/extension/pipeline/fanoutBeats.d.ts +31 -0
- package/dist/extension/pipeline/fanoutBeats.js +86 -0
- package/dist/extension/pipeline/goCommand.d.ts +14 -0
- package/dist/extension/pipeline/goCommand.js +38 -1
- package/dist/extension/pipeline/headlessGo.d.ts +163 -0
- package/dist/extension/pipeline/headlessGo.js +333 -0
- package/dist/extension/pipeline/invocation.d.ts +31 -3
- package/dist/extension/pipeline/invocation.js +37 -3
- package/dist/extension/pipeline/mission.d.ts +55 -0
- package/dist/extension/pipeline/mission.js +70 -0
- package/dist/extension/pipeline/orchestrator.d.ts +48 -3
- package/dist/extension/pipeline/orchestrator.js +450 -9
- package/dist/extension/pipeline/personas.d.ts +16 -1
- package/dist/extension/pipeline/personas.js +118 -7
- package/dist/extension/pipeline/runSession.d.ts +45 -1
- package/dist/extension/pipeline/runState.d.ts +57 -12
- package/dist/extension/pipeline/runState.js +60 -18
- package/dist/extension/pipeline/runner.js +10 -1
- package/dist/extension/pipeline/stages.d.ts +84 -7
- package/dist/extension/pipeline/stages.js +166 -0
- package/dist/extension/pipeline/tierCap.d.ts +32 -0
- package/dist/extension/pipeline/tierCap.js +57 -0
- package/dist/extension/pipeline/types.d.ts +130 -1
- package/dist/extension/pipeline/types.js +17 -0
- package/dist/extension/pipeline/verify.d.ts +86 -3
- package/dist/extension/pipeline/verify.js +175 -6
- package/dist/extension/subagents.js +13 -0
- package/dist/extension/turnLog.d.ts +38 -0
- package/dist/extension/turnLog.js +93 -0
- package/dist/goHeadless.d.ts +75 -0
- package/dist/goHeadless.js +132 -0
- package/dist/paths.d.ts +9 -0
- package/dist/paths.js +12 -0
- package/dist/promptEnrichment.d.ts +1 -1
- package/dist/promptEnrichment.js +1 -1
- package/package.json +2 -2
- /package/dist/extension/{approvedPrefixes.d.ts → permission/approvedPrefixes.d.ts} +0 -0
- /package/dist/extension/{execPolicy.d.ts → permission/execPolicy.d.ts} +0 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PURE partition contract for the implement diamond — the analogue of
|
|
3
|
+
* `findings.ts` for the fan-out orchestrator's output.
|
|
4
|
+
*
|
|
5
|
+
* `parsePartition` reads the orchestrator child's fenced ```partition JSON block
|
|
6
|
+
* and validates it against the contract (spec "Partition contract"): mode legal,
|
|
7
|
+
* width in {2,4,8} with a matching workstream count, per-workstream tier legal
|
|
8
|
+
* (absent defaults to `standard`), every workstream named + tasked with at least
|
|
9
|
+
* one file claim, and claims pairwise DISJOINT across workstreams.
|
|
10
|
+
*
|
|
11
|
+
* Two failure kinds, deliberately distinct because the pipeline treats them
|
|
12
|
+
* differently:
|
|
13
|
+
* - SOFT (`hard: false`): malformed block, illegal field, missing reason. The
|
|
14
|
+
* caller gets one cheap-tier format re-ask and then degrades to single-writer.
|
|
15
|
+
* A partition is never guessed.
|
|
16
|
+
* - HARD (`hard: true`): overlapping claims. Two writers on one file is the one
|
|
17
|
+
* thing the design refuses to discover at merge time, so it fails the stage at
|
|
18
|
+
* partition time with the colliding paths and workstreams named. The caller
|
|
19
|
+
* (orchestrator) raises it as a `PipelineStageError`; keeping the throw out of
|
|
20
|
+
* here keeps this module pure and free of an import cycle.
|
|
21
|
+
*
|
|
22
|
+
* `auditClaims` is the post-fan check: claims are path PREFIXES (a directory
|
|
23
|
+
* claims its whole subtree), so a file a builder legitimately created inside its
|
|
24
|
+
* claimed directory is in-claim, and anything else is named as a violation for
|
|
25
|
+
* the synthesizer to reconcile.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* The `go.fanout` knob's environment surface (spec decision 8), named the way
|
|
29
|
+
* `YAGNI_GO_TIER_CAP` is (see tierCap.ts): a run-wide /go setting an eval or
|
|
30
|
+
* benchmark lane pins from the outside. `always` forces the diamond attempt;
|
|
31
|
+
* unset (everywhere else) leaves the partitioner's own conservative verdict as
|
|
32
|
+
* the only thing that decides.
|
|
33
|
+
*/
|
|
34
|
+
export const FANOUT_MODE_ENV = "YAGNI_GO_FANOUT";
|
|
35
|
+
/**
|
|
36
|
+
* Parse a raw env value into a mode. Unset, empty, or unrecognized all yield
|
|
37
|
+
* undefined so a caller can warn about a typo; {@link resolveFanoutMode} is what
|
|
38
|
+
* turns that into the safe `auto` default. A typo must never pin a lane.
|
|
39
|
+
*/
|
|
40
|
+
export function parseFanoutMode(raw) {
|
|
41
|
+
const normalized = raw?.trim().toLowerCase();
|
|
42
|
+
if (normalized === "auto" || normalized === "always")
|
|
43
|
+
return normalized;
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
/** The run's fan-out mode from an environment. Defaults to `auto`. */
|
|
47
|
+
export function resolveFanoutMode(env) {
|
|
48
|
+
return parseFanoutMode(env[FANOUT_MODE_ENV]) ?? "auto";
|
|
49
|
+
}
|
|
50
|
+
/** The only legal fan widths (execution concurrency is capped separately by the session). */
|
|
51
|
+
export const PARTITION_WIDTHS = [2, 4, 8];
|
|
52
|
+
const TIERS = ["standard", "efficient"];
|
|
53
|
+
function soft(reason) {
|
|
54
|
+
return { ok: false, hard: false, reason };
|
|
55
|
+
}
|
|
56
|
+
/** Extract the inner text of a fenced ```partition block, if present. */
|
|
57
|
+
export function extractPartitionBlock(raw) {
|
|
58
|
+
const m = raw.match(/```partition[^\n]*\n([\s\S]*?)```/i);
|
|
59
|
+
return m ? m[1] : null;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Normalize a claim or changed path for prefix comparison: trim, convert
|
|
63
|
+
* backslashes, collapse doubled slashes, drop interior `.` segments, a leading
|
|
64
|
+
* `./`, and trailing slashes. Purely lexical — no filesystem access. Without
|
|
65
|
+
* the collapse, `packages//backend` and `packages/./backend` pass disjointness
|
|
66
|
+
* against `packages/backend` while claiming the same directory — the exact
|
|
67
|
+
* two-writers-one-file outcome the partition-time hard error exists to prevent.
|
|
68
|
+
*/
|
|
69
|
+
export function normalizeClaimPath(path) {
|
|
70
|
+
return path
|
|
71
|
+
.trim()
|
|
72
|
+
.replace(/\\/g, "/")
|
|
73
|
+
.split("/")
|
|
74
|
+
.filter((segment) => segment.length > 0 && segment !== ".")
|
|
75
|
+
.join("/");
|
|
76
|
+
}
|
|
77
|
+
/** True when `claim` is the path itself or a directory containing it. */
|
|
78
|
+
export function claimCovers(claim, path) {
|
|
79
|
+
const c = normalizeClaimPath(claim);
|
|
80
|
+
const p = normalizeClaimPath(path);
|
|
81
|
+
if (!c || !p)
|
|
82
|
+
return false;
|
|
83
|
+
return p === c || p.startsWith(`${c}/`);
|
|
84
|
+
}
|
|
85
|
+
/** Reject claims that escape the repo, are absolute, or claim everything. */
|
|
86
|
+
function claimIsLegal(claim) {
|
|
87
|
+
if (!claim || claim === "." || claim.startsWith("/") || claim.startsWith("\\"))
|
|
88
|
+
return false;
|
|
89
|
+
return !normalizeClaimPath(claim).split("/").includes("..");
|
|
90
|
+
}
|
|
91
|
+
function readWorkstream(value, index) {
|
|
92
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
93
|
+
return `workstream ${index + 1} is not an object`;
|
|
94
|
+
}
|
|
95
|
+
const obj = value;
|
|
96
|
+
const name = typeof obj.name === "string" ? obj.name.trim() : "";
|
|
97
|
+
if (!name)
|
|
98
|
+
return `workstream ${index + 1} has no name`;
|
|
99
|
+
const task = typeof obj.task === "string" ? obj.task.trim() : "";
|
|
100
|
+
if (!task)
|
|
101
|
+
return `workstream "${name}" has no task`;
|
|
102
|
+
if (!Array.isArray(obj.files))
|
|
103
|
+
return `workstream "${name}" has no file claims`;
|
|
104
|
+
const files = [];
|
|
105
|
+
for (const entry of obj.files) {
|
|
106
|
+
if (typeof entry !== "string")
|
|
107
|
+
return `workstream "${name}" has a non-string file claim`;
|
|
108
|
+
// Legality reads the RAW entry: normalization collapses the leading `/`
|
|
109
|
+
// (and `\`) markers legality has to see.
|
|
110
|
+
const claim = normalizeClaimPath(entry);
|
|
111
|
+
if (!claim || !claimIsLegal(entry.trim())) {
|
|
112
|
+
return `workstream "${name}" has an illegal file claim: ${entry}`;
|
|
113
|
+
}
|
|
114
|
+
if (!files.includes(claim))
|
|
115
|
+
files.push(claim);
|
|
116
|
+
}
|
|
117
|
+
if (files.length === 0)
|
|
118
|
+
return `workstream "${name}" has no file claims`;
|
|
119
|
+
let tier = "standard";
|
|
120
|
+
if (obj.tier !== undefined) {
|
|
121
|
+
if (typeof obj.tier !== "string" || !TIERS.includes(obj.tier)) {
|
|
122
|
+
return `workstream "${name}" has an illegal tier: ${String(obj.tier)}`;
|
|
123
|
+
}
|
|
124
|
+
tier = obj.tier;
|
|
125
|
+
}
|
|
126
|
+
return { name, task, files, tier };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Find every pair of claims that collide across two workstreams. Prefix nesting
|
|
130
|
+
* counts: a workstream claiming `packages/backend/src` collides with a sibling
|
|
131
|
+
* claiming `packages/backend/src/routes/x.ts`.
|
|
132
|
+
*/
|
|
133
|
+
function findCollisions(workstreams) {
|
|
134
|
+
const collisions = [];
|
|
135
|
+
for (let i = 0; i < workstreams.length; i += 1) {
|
|
136
|
+
for (let j = i + 1; j < workstreams.length; j += 1) {
|
|
137
|
+
for (const a of workstreams[i].files) {
|
|
138
|
+
for (const b of workstreams[j].files) {
|
|
139
|
+
if (claimCovers(a, b) || claimCovers(b, a)) {
|
|
140
|
+
collisions.push({
|
|
141
|
+
path: a,
|
|
142
|
+
otherPath: b,
|
|
143
|
+
workstreams: [workstreams[i].name, workstreams[j].name],
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return collisions;
|
|
151
|
+
}
|
|
152
|
+
function collisionReason(collisions) {
|
|
153
|
+
const parts = collisions.map((c) => c.path === c.otherPath
|
|
154
|
+
? `${c.path} claimed by both ${c.workstreams[0]} and ${c.workstreams[1]}`
|
|
155
|
+
: `${c.path} (${c.workstreams[0]}) contains ${c.otherPath} (${c.workstreams[1]})`);
|
|
156
|
+
return `Overlapping file claims: ${parts.join("; ")}`;
|
|
157
|
+
}
|
|
158
|
+
/** Pull the JSON payload out of a partition fence, a json fence, or bare output. */
|
|
159
|
+
function extractPayload(raw) {
|
|
160
|
+
const partition = extractPartitionBlock(raw);
|
|
161
|
+
if (partition !== null)
|
|
162
|
+
return partition.trim();
|
|
163
|
+
const json = raw.match(/```json[^\n]*\n([\s\S]*?)```/i);
|
|
164
|
+
if (json)
|
|
165
|
+
return json[1].trim();
|
|
166
|
+
const trimmed = raw.trim();
|
|
167
|
+
return trimmed.startsWith("{") ? trimmed : null;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Parse the orchestrator's raw output into a {@link PartitionDecision}. Never
|
|
171
|
+
* throws: an unusable block is a soft rejection (re-ask, then single-writer) and
|
|
172
|
+
* overlapping claims are the hard rejection the caller turns into a stage error.
|
|
173
|
+
*/
|
|
174
|
+
export function parsePartition(raw) {
|
|
175
|
+
if (!raw || !raw.trim())
|
|
176
|
+
return soft("empty partition output");
|
|
177
|
+
const payload = extractPayload(raw);
|
|
178
|
+
if (payload === null)
|
|
179
|
+
return soft("no ```partition block in the output");
|
|
180
|
+
let parsed;
|
|
181
|
+
try {
|
|
182
|
+
parsed = JSON.parse(payload);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return soft("the partition block is not valid JSON");
|
|
186
|
+
}
|
|
187
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
188
|
+
return soft("the partition block is not a JSON object");
|
|
189
|
+
}
|
|
190
|
+
const obj = parsed;
|
|
191
|
+
const mode = obj.mode;
|
|
192
|
+
if (mode !== "fan" && mode !== "single") {
|
|
193
|
+
return soft(`illegal partition mode: ${String(mode)}`);
|
|
194
|
+
}
|
|
195
|
+
const reason = typeof obj.reason === "string" ? obj.reason.trim() : "";
|
|
196
|
+
if (!reason)
|
|
197
|
+
return soft("the partition block has no reason");
|
|
198
|
+
if (mode === "single")
|
|
199
|
+
return { ok: true, decision: { mode: "single", reason } };
|
|
200
|
+
const width = obj.width;
|
|
201
|
+
if (typeof width !== "number" || !PARTITION_WIDTHS.includes(width)) {
|
|
202
|
+
return soft(`illegal fan width: ${String(width)}`);
|
|
203
|
+
}
|
|
204
|
+
if (!Array.isArray(obj.workstreams))
|
|
205
|
+
return soft("a fan partition has no workstreams");
|
|
206
|
+
const workstreams = [];
|
|
207
|
+
for (const [index, entry] of obj.workstreams.entries()) {
|
|
208
|
+
const read = readWorkstream(entry, index);
|
|
209
|
+
if (typeof read === "string")
|
|
210
|
+
return soft(read);
|
|
211
|
+
if (workstreams.some((w) => w.name === read.name)) {
|
|
212
|
+
return soft(`duplicate workstream name: ${read.name}`);
|
|
213
|
+
}
|
|
214
|
+
workstreams.push(read);
|
|
215
|
+
}
|
|
216
|
+
if (workstreams.length !== width) {
|
|
217
|
+
return soft(`fan width ${width} does not match ${workstreams.length} workstreams`);
|
|
218
|
+
}
|
|
219
|
+
const collisions = findCollisions(workstreams);
|
|
220
|
+
if (collisions.length > 0) {
|
|
221
|
+
return { ok: false, hard: true, reason: collisionReason(collisions), collisions };
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
ok: true,
|
|
225
|
+
decision: { mode: "fan", width: width, reason, workstreams },
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Post-fan claim audit: the changed paths not covered by any workstream's claims,
|
|
230
|
+
* normalized, deduped, in first-seen order. Claims are prefixes, so a file created
|
|
231
|
+
* under a claimed directory is in-claim; anything else is a named violation the
|
|
232
|
+
* synthesizer reconciles.
|
|
233
|
+
*/
|
|
234
|
+
export function auditClaims(changedPaths, workstreams) {
|
|
235
|
+
const claims = workstreams.flatMap((w) => w.files);
|
|
236
|
+
const violations = [];
|
|
237
|
+
for (const raw of changedPaths) {
|
|
238
|
+
const path = normalizeClaimPath(raw);
|
|
239
|
+
if (!path)
|
|
240
|
+
continue;
|
|
241
|
+
if (claims.some((claim) => claimCovers(claim, path)))
|
|
242
|
+
continue;
|
|
243
|
+
if (!violations.includes(path))
|
|
244
|
+
violations.push(path);
|
|
245
|
+
}
|
|
246
|
+
return violations;
|
|
247
|
+
}
|
|
248
|
+
//# sourceMappingURL=fanout.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The implement diamond's RECORDING thread: pipeline progress in, run-session
|
|
3
|
+
* stage beats out (spec "Recording and the run surface").
|
|
4
|
+
*
|
|
5
|
+
* The pipeline already says everything the run surface needs — the partition
|
|
6
|
+
* verdict, each builder starting and finishing, each bounded fix turn — on the
|
|
7
|
+
* `onProgress` channel. This folds that stream into the additive
|
|
8
|
+
* `fanout` / `children` / `fixTurns` payloads on `POST /runs/:id/stages`, so the
|
|
9
|
+
* web run surface can render per-workstream rows without the pipeline growing a
|
|
10
|
+
* second reporting path.
|
|
11
|
+
*
|
|
12
|
+
* PURE and I/O-free, like `findings.ts` and `fanout.ts`: `apply` returns the beat
|
|
13
|
+
* to send (or null when the signal is not the diamond's), and the caller decides
|
|
14
|
+
* whether to POST it. Two properties are load-bearing:
|
|
15
|
+
*
|
|
16
|
+
* 1. **Recorded states only.** A child row exists only once its `stage_start`
|
|
17
|
+
* arrives, and it is `failed` only when the pipeline said the child died.
|
|
18
|
+
* There is no invented "pending", and no tick nobody earned.
|
|
19
|
+
* 2. **Every beat is a copy.** The roster keeps mutating as children finish, so
|
|
20
|
+
* a beat already handed to the (async, fail-soft) session must never change
|
|
21
|
+
* underneath it.
|
|
22
|
+
*/
|
|
23
|
+
import type { StageArgs } from "./runSession.js";
|
|
24
|
+
import type { PipelineProgress } from "./types.js";
|
|
25
|
+
/** Folds the diamond's progress signals into the stage beats that record them. */
|
|
26
|
+
export interface FanoutBeats {
|
|
27
|
+
/** The beat this signal produces, or null when it is not the diamond's. */
|
|
28
|
+
apply(p: PipelineProgress): StageArgs | null;
|
|
29
|
+
}
|
|
30
|
+
export declare function makeFanoutBeats(): FanoutBeats;
|
|
31
|
+
//# sourceMappingURL=fanoutBeats.d.ts.map
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The implement diamond's RECORDING thread: pipeline progress in, run-session
|
|
3
|
+
* stage beats out (spec "Recording and the run surface").
|
|
4
|
+
*
|
|
5
|
+
* The pipeline already says everything the run surface needs — the partition
|
|
6
|
+
* verdict, each builder starting and finishing, each bounded fix turn — on the
|
|
7
|
+
* `onProgress` channel. This folds that stream into the additive
|
|
8
|
+
* `fanout` / `children` / `fixTurns` payloads on `POST /runs/:id/stages`, so the
|
|
9
|
+
* web run surface can render per-workstream rows without the pipeline growing a
|
|
10
|
+
* second reporting path.
|
|
11
|
+
*
|
|
12
|
+
* PURE and I/O-free, like `findings.ts` and `fanout.ts`: `apply` returns the beat
|
|
13
|
+
* to send (or null when the signal is not the diamond's), and the caller decides
|
|
14
|
+
* whether to POST it. Two properties are load-bearing:
|
|
15
|
+
*
|
|
16
|
+
* 1. **Recorded states only.** A child row exists only once its `stage_start`
|
|
17
|
+
* arrives, and it is `failed` only when the pipeline said the child died.
|
|
18
|
+
* There is no invented "pending", and no tick nobody earned.
|
|
19
|
+
* 2. **Every beat is a copy.** The roster keeps mutating as children finish, so
|
|
20
|
+
* a beat already handed to the (async, fail-soft) session must never change
|
|
21
|
+
* underneath it.
|
|
22
|
+
*/
|
|
23
|
+
/** The stage every fan-out beat belongs to: the diamond lives INSIDE implement. */
|
|
24
|
+
const IMPLEMENT = "implement";
|
|
25
|
+
export function makeFanoutBeats() {
|
|
26
|
+
// Name -> tier + claim count from the partition verdict, so a child row can
|
|
27
|
+
// carry them the moment that child starts.
|
|
28
|
+
const shape = new Map();
|
|
29
|
+
const children = [];
|
|
30
|
+
const fixTurns = [];
|
|
31
|
+
const roster = () => children.map((c) => ({ ...c }));
|
|
32
|
+
const upsert = (name, state) => {
|
|
33
|
+
const existing = children.find((c) => c.name === name);
|
|
34
|
+
if (existing) {
|
|
35
|
+
existing.state = state;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
const meta = shape.get(name) ?? {};
|
|
39
|
+
children.push({
|
|
40
|
+
name,
|
|
41
|
+
...(meta.tier ? { tier: meta.tier } : {}),
|
|
42
|
+
...(meta.files !== undefined ? { files: meta.files } : {}),
|
|
43
|
+
state,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return { stage: IMPLEMENT, phase: "start", children: roster() };
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
apply(p) {
|
|
50
|
+
switch (p.kind) {
|
|
51
|
+
case "fanout": {
|
|
52
|
+
for (const w of p.workstreams ?? [])
|
|
53
|
+
shape.set(w.name, { tier: w.tier, files: w.files });
|
|
54
|
+
return {
|
|
55
|
+
stage: IMPLEMENT,
|
|
56
|
+
phase: "start",
|
|
57
|
+
fanout: {
|
|
58
|
+
mode: p.mode,
|
|
59
|
+
...(p.width ? { width: p.width } : {}),
|
|
60
|
+
reason: p.reason,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
case "stage_start":
|
|
65
|
+
if (p.stageId !== IMPLEMENT || !p.workstream)
|
|
66
|
+
return null;
|
|
67
|
+
return upsert(p.workstream, "running");
|
|
68
|
+
case "stage_done":
|
|
69
|
+
if (p.stageId !== IMPLEMENT || !p.workstream)
|
|
70
|
+
return null;
|
|
71
|
+
return upsert(p.workstream, p.degraded === "failed" ? "failed" : "done");
|
|
72
|
+
case "fix_turn": {
|
|
73
|
+
fixTurns.push({ turn: p.turn, findings: p.findings, reengaged: [...p.reengaged] });
|
|
74
|
+
return {
|
|
75
|
+
stage: IMPLEMENT,
|
|
76
|
+
phase: "start",
|
|
77
|
+
fixTurns: fixTurns.map((t) => ({ ...t, reengaged: [...t.reengaged] })),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
default:
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=fanoutBeats.js.map
|
|
@@ -114,6 +114,20 @@ export interface RegisterGoDeps {
|
|
|
114
114
|
exists?: (path: string) => boolean;
|
|
115
115
|
/** Clock seam for registry rows + staleness. */
|
|
116
116
|
now?: () => number;
|
|
117
|
+
/**
|
|
118
|
+
* Environment the run-wide /go settings are read from (today: `go.fanout` via
|
|
119
|
+
* YAGNI_GO_FANOUT). Defaults to `process.env`; injected so a test can pin a
|
|
120
|
+
* benchmark lane without mutating the process.
|
|
121
|
+
*/
|
|
122
|
+
env?: NodeJS.ProcessEnv;
|
|
123
|
+
/**
|
|
124
|
+
* Is /ultra on right now? The same holder the subagent tool reads (wired in
|
|
125
|
+
* index.ts), consulted per call because /ultra can flip mid-session. It is the
|
|
126
|
+
* implement diamond's parallel ceiling: ultra runs the fan (and its fix turns)
|
|
127
|
+
* up to 8 wide, everything else stays at 4. Absent (an unwired embedder or a
|
|
128
|
+
* test) simply means "not ultra", which is today's behavior.
|
|
129
|
+
*/
|
|
130
|
+
isUltra?: () => boolean;
|
|
117
131
|
baseUrl?: string;
|
|
118
132
|
getToken?: () => string | undefined;
|
|
119
133
|
/**
|
|
@@ -63,6 +63,8 @@ import { existsSync } from "node:fs";
|
|
|
63
63
|
import { join } from "node:path";
|
|
64
64
|
import { eventToLine } from "./activity.js";
|
|
65
65
|
import { ActivityFeed, SPINNER_FRAMES } from "./activityFeed.js";
|
|
66
|
+
import { resolveFanoutMode } from "./fanout.js";
|
|
67
|
+
import { makeFanoutBeats } from "./fanoutBeats.js";
|
|
66
68
|
import { RunState } from "./runState.js";
|
|
67
69
|
import { aggregateRunUsage } from "./budget.js";
|
|
68
70
|
import { formatRunCostTable } from "./runCostTable.js";
|
|
@@ -78,7 +80,7 @@ import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
|
|
|
78
80
|
import { composeAbortSignal } from "./resilience.js";
|
|
79
81
|
import { planResume } from "./resume.js";
|
|
80
82
|
import { activeRunCount, beginRun, classifyRunLiveness, findActiveRunByTicket, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, resolveMaxConcurrentRuns, settleRun, trackRunAbort, trackRunPromise, worktreesDir, } from "./runRegistry.js";
|
|
81
|
-
import { makeRunSession as defaultMakeRunSession } from "./runSession.js";
|
|
83
|
+
import { makeRunSession as defaultMakeRunSession, } from "./runSession.js";
|
|
82
84
|
import { recordSessionRun } from "../sessionRuns.js";
|
|
83
85
|
import { resolveTicketBrief as defaultResolveTicketBrief } from "./ticketResolution.js";
|
|
84
86
|
import { snapshotWorkspace as defaultSnapshotWorkspace } from "./workspace.js";
|
|
@@ -422,6 +424,13 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
422
424
|
const loadJournal = deps.loadJournal ?? ((sessionKey) => makeFileCheckpointStore(sessionKey).load());
|
|
423
425
|
const exists = deps.exists ?? existsSync;
|
|
424
426
|
const now = deps.now ?? Date.now;
|
|
427
|
+
// `go.fanout` (spec decision 8): `always` pins the implement diamond for
|
|
428
|
+
// benchmark / eval lanes; unset leaves the partitioner's conservative verdict
|
|
429
|
+
// as the only thing that decides.
|
|
430
|
+
const fanoutMode = resolveFanoutMode(deps.env ?? process.env);
|
|
431
|
+
// The dial is held, never its value: /ultra can be toggled between runs, so
|
|
432
|
+
// the pipeline calls it when it needs the ceiling rather than reading it here.
|
|
433
|
+
const isUltra = deps.isUltra;
|
|
425
434
|
// Task 8: no default — see the RegisterGoDeps doc comment for why (only
|
|
426
435
|
// index.ts's real wiring makes sense here; absent, `resolveCostText` falls
|
|
427
436
|
// back to the local client estimate).
|
|
@@ -559,6 +568,10 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
559
568
|
const feed = ctx.hasUI && !desktop ? new ActivityFeed(ticket) : undefined;
|
|
560
569
|
const startedAt = Date.now();
|
|
561
570
|
const run = ctx.hasUI && desktop ? new RunState({ runId: runShortId, ticket, startedAt }) : undefined;
|
|
571
|
+
// The implement diamond's recording thread. Per RUN (never module-scoped):
|
|
572
|
+
// it holds this run's child roster and fix turns. Independent of `feed` /
|
|
573
|
+
// `run`, because the Work page's run surface is fed on headless runs too.
|
|
574
|
+
const fanoutBeats = makeFanoutBeats();
|
|
562
575
|
const stateKey = `${STATE_KEY_PREFIX}${runShortId}`;
|
|
563
576
|
let paintTimer;
|
|
564
577
|
let animationTimer;
|
|
@@ -639,6 +652,21 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
639
652
|
baseUrl: deps.baseUrl ?? resolveBaseUrl(),
|
|
640
653
|
getToken: deps.getToken ?? defaultGetToken,
|
|
641
654
|
});
|
|
655
|
+
// The diamond's own recording channel is STRICTLY ORDERED, and it is the
|
|
656
|
+
// only one that has to be. Every roster beat carries the WHOLE roster and
|
|
657
|
+
// the run surface derives it last-one-wins (FanoutSection's `readFanout`),
|
|
658
|
+
// so two beats in flight at once can land in either order and a "running"
|
|
659
|
+
// that overtakes its own "done" paints a finished builder as running for
|
|
660
|
+
// the rest of the run. Chaining them through one promise makes the order
|
|
661
|
+
// the surface reads the order the pipeline produced. Still fire-and-forget
|
|
662
|
+
// (nothing on the run's critical path waits on it) and still fail-soft: a
|
|
663
|
+
// rejected beat never breaks the chain. The other stage beats stay
|
|
664
|
+
// unserialized on purpose — each names its own boundary, so their arrival
|
|
665
|
+
// order carries no state a later beat can undo.
|
|
666
|
+
let fanBeatChain = Promise.resolve();
|
|
667
|
+
const recordFanBeat = (b) => {
|
|
668
|
+
fanBeatChain = fanBeatChain.then(() => session.stage(b)).catch(() => { });
|
|
669
|
+
};
|
|
642
670
|
const repoCtx = await resolveRepoContext(runCwd, ctx.signal);
|
|
643
671
|
// Durable resilience journal, keyed by the RUN tree (the worktree path for
|
|
644
672
|
// a default run; ctx.cwd for --here). Detect an interrupted prior /go for
|
|
@@ -880,10 +908,19 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
880
908
|
let result = await runPipeline(ticket, {
|
|
881
909
|
cwd: runCwd,
|
|
882
910
|
signal: runSignal,
|
|
911
|
+
fanout: fanoutMode,
|
|
912
|
+
...(isUltra ? { isUltra } : {}),
|
|
883
913
|
...(ticketBrief ? { ticketBrief } : {}),
|
|
884
914
|
onProgress: (p) => {
|
|
885
915
|
feed?.applyProgress(p);
|
|
886
916
|
run?.applyProgress(p, Date.now());
|
|
917
|
+
// The implement diamond's own beats (the partition verdict, each
|
|
918
|
+
// child's state, each fix turn) ride the SAME fail-soft stage
|
|
919
|
+
// channel as every other boundary, additively: a signal that is not
|
|
920
|
+
// the diamond's produces no beat at all.
|
|
921
|
+
const fanBeat = fanoutBeats.apply(p);
|
|
922
|
+
if (fanBeat)
|
|
923
|
+
recordFanBeat(fanBeat);
|
|
887
924
|
// The ribbon replaces the status chip on the desktop, so the chip is
|
|
888
925
|
// only set for the terminal's status-line fallback.
|
|
889
926
|
if (ctx.hasUI && !desktop) {
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The headless front door to the /go pipeline (spec: sandbox harness parity).
|
|
3
|
+
*
|
|
4
|
+
* `/go` is an interactive pi slash command and pi's print mode ignores slash
|
|
5
|
+
* commands, so until now the ONLY way to drive the pipeline from a script was
|
|
6
|
+
* to import `runPipeline` directly (`scripts/eval-go-harness.ts` did exactly
|
|
7
|
+
* that). That gap is why the cloud mission sandbox ran a single flat model
|
|
8
|
+
* session instead of the product's staged pipeline. This module closes it:
|
|
9
|
+
*
|
|
10
|
+
* yagni go --headless --ticket-file <p> [--plan-file <p>] [--memo-file <p>]
|
|
11
|
+
* [--run-id <id>] [--cwd <p>] [--json]
|
|
12
|
+
*
|
|
13
|
+
* It is the SAME `runPipeline` path the interactive command drives (no forked
|
|
14
|
+
* driver) with the interactive-only machinery left out: no worktree, no run
|
|
15
|
+
* registry, no run session mint (the caller already owns the run row and passes
|
|
16
|
+
* its id with `--run-id`), no checkpoint journal, no terminal UI.
|
|
17
|
+
*
|
|
18
|
+
* MISSION MODE (`--plan-file`, optionally `--memo-file`): the factory already
|
|
19
|
+
* produced the plan and a human approved it at the mission's plan gate, so the
|
|
20
|
+
* pipeline skips its own map/plan stages and enters at implement on that plan,
|
|
21
|
+
* with the scoping memo as the repo context the map brief would have given (see
|
|
22
|
+
* mission.ts for the pure rules). Delivery is NOT ours in mission mode: the
|
|
23
|
+
* FINISH stage is a `/go`-session step and is never driven here, so a mission
|
|
24
|
+
* run ends at the reviewed candidate and never commits, pushes, opens a PR, or
|
|
25
|
+
* reports one. The mission's own prHandoff owns all of that.
|
|
26
|
+
*
|
|
27
|
+
* Output contract:
|
|
28
|
+
* - `--json`: one NDJSON line per event on stdout. Child events are the pi
|
|
29
|
+
* `--mode json` objects VERBATIM plus an additive `yagni` attribution key
|
|
30
|
+
* (stage / lens / round), so the event vocabulary is unchanged; pipeline-level
|
|
31
|
+
* lines carry their own `pipeline_*` types.
|
|
32
|
+
* - `{type:"pipeline_fanout", stage, phase, fanout?, children?, fixTurns?}`:
|
|
33
|
+
* the implement diamond's recording beats. Interactive `/go` POSTs these to
|
|
34
|
+
* its run session; headlessly there IS no session, so the SAME
|
|
35
|
+
* `makeFanoutBeats` thread rides the stream instead and the mission
|
|
36
|
+
* collector folds them back onto the Run. Without this a cloud mission would
|
|
37
|
+
* record no partition decision, no per-workstream row, and no fix turn.
|
|
38
|
+
* - Always, as the last line: the result object
|
|
39
|
+
* `{type:"pipeline_result", ok, stopReason, rounds, findings, stages:[{stage,
|
|
40
|
+
* tier, usage}], …}` — or `{type:"pipeline_error", …}` when a build stage
|
|
41
|
+
* threw.
|
|
42
|
+
* - Exit codes: 0 ONLY on a verified candidate (a `clean` stop), 1 for any
|
|
43
|
+
* other outcome or a thrown pipeline error, 2 for a usage/input problem.
|
|
44
|
+
*
|
|
45
|
+
* `YAGNI_GO_TIER_CAP` (eval + template-smoke lanes) is resolved here for the
|
|
46
|
+
* result object and forwarded on the child env; the clamp itself is applied
|
|
47
|
+
* centrally in `runStage` (see tierCap.ts). `YAGNI_GO_FANOUT` is read the same
|
|
48
|
+
* way and pins the implement diamond for a benchmark lane (see fanout.ts).
|
|
49
|
+
*/
|
|
50
|
+
import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
|
|
51
|
+
import type { RunBudget } from "./budget.js";
|
|
52
|
+
import type { JsonEvent, ModelTier, PipelineStage, StageId, StageTag, StageUsage, StopReason } from "./types.js";
|
|
53
|
+
/** One-line usage copy, shared by every argument error. */
|
|
54
|
+
export declare const HEADLESS_GO_USAGE = "Usage: yagni go --headless --ticket-file <path> [--plan-file <path>] [--memo-file <path>] [--run-id <id>] [--cwd <path>] [--json]";
|
|
55
|
+
/**
|
|
56
|
+
* Exit codes. `verified` is deliberately narrow: only a `clean` stop means the
|
|
57
|
+
* run produced a reviewed candidate, so a round_cap / no_changes / failed run
|
|
58
|
+
* can never read as success to a script, a smoke gate, or CI.
|
|
59
|
+
*/
|
|
60
|
+
export declare const HEADLESS_GO_EXIT: {
|
|
61
|
+
readonly verified: 0;
|
|
62
|
+
readonly unverified: 1;
|
|
63
|
+
readonly usage: 2;
|
|
64
|
+
};
|
|
65
|
+
/** The parsed flag surface (pure; no fs, no env). */
|
|
66
|
+
export interface HeadlessGoArgs {
|
|
67
|
+
headless: boolean;
|
|
68
|
+
json: boolean;
|
|
69
|
+
ticketFile?: string;
|
|
70
|
+
planFile?: string;
|
|
71
|
+
memoFile?: string;
|
|
72
|
+
runId?: string;
|
|
73
|
+
cwd?: string;
|
|
74
|
+
/** Any `--flag`-shaped token we do not know: refused, never folded in. */
|
|
75
|
+
unknownFlags: string[];
|
|
76
|
+
/** Bare tokens: the ticket is a FILE here, so a stray positional is refused. */
|
|
77
|
+
positionals: string[];
|
|
78
|
+
/**
|
|
79
|
+
* Value flags whose value was missing, empty, or another flag: refused, so
|
|
80
|
+
* `--ticket-file --json` fails naming the real problem instead of trying to
|
|
81
|
+
* read a file called `--json` with JSON mode silently off.
|
|
82
|
+
*/
|
|
83
|
+
missingValues: string[];
|
|
84
|
+
}
|
|
85
|
+
/** One row of the result's per-stage usage table. */
|
|
86
|
+
export interface HeadlessStageRow {
|
|
87
|
+
stage: StageId;
|
|
88
|
+
/** The tier the stage actually ran on (capped when `YAGNI_GO_TIER_CAP` is set). */
|
|
89
|
+
tier?: ModelTier;
|
|
90
|
+
/** Present on review rows: which review round produced them. */
|
|
91
|
+
round?: number;
|
|
92
|
+
usage: StageUsage;
|
|
93
|
+
}
|
|
94
|
+
/** The final result object written to stdout and returned to the caller. */
|
|
95
|
+
export interface HeadlessGoResult {
|
|
96
|
+
/** True ONLY for a verified candidate (a `clean` stop) — mirrors exit 0. */
|
|
97
|
+
ok: boolean;
|
|
98
|
+
stopReason: StopReason;
|
|
99
|
+
rounds: number;
|
|
100
|
+
findings: number;
|
|
101
|
+
blocking: number;
|
|
102
|
+
stages: HeadlessStageRow[];
|
|
103
|
+
runId?: string;
|
|
104
|
+
/** The resolved `YAGNI_GO_TIER_CAP` ceiling; absent when the run is uncapped. */
|
|
105
|
+
tierCap?: ModelTier;
|
|
106
|
+
/**
|
|
107
|
+
* True when a plan or memo was injected: the pipeline entered at implement on
|
|
108
|
+
* the approved plan and FINISH did not run, so `commitSha` / `prUrl` are never
|
|
109
|
+
* reported (the mission's prHandoff delivers the candidate). Absent otherwise.
|
|
110
|
+
*/
|
|
111
|
+
missionMode?: true;
|
|
112
|
+
commitSha?: string;
|
|
113
|
+
prUrl?: string;
|
|
114
|
+
/** Honest note when the deterministic verify gate gave no verdict. */
|
|
115
|
+
verifyNote?: string;
|
|
116
|
+
verifyCommand?: string;
|
|
117
|
+
}
|
|
118
|
+
export interface HeadlessGoOutcome {
|
|
119
|
+
exitCode: number;
|
|
120
|
+
result?: HeadlessGoResult;
|
|
121
|
+
/** Set when the run ended on a thrown error or an argument problem. */
|
|
122
|
+
error?: string;
|
|
123
|
+
}
|
|
124
|
+
/** Injectable seams: no fs, no stdout, no pipeline in a unit test. */
|
|
125
|
+
export interface HeadlessGoDeps {
|
|
126
|
+
/** Repo to run in. `--cwd` wins; defaults to `process.cwd()`. */
|
|
127
|
+
cwd?: string;
|
|
128
|
+
/** Environment the tier cap is read from. Defaults to `process.env`. */
|
|
129
|
+
env?: NodeJS.ProcessEnv;
|
|
130
|
+
/** Environment for spawned stage children. Defaults to `env`. */
|
|
131
|
+
childEnv?: NodeJS.ProcessEnv;
|
|
132
|
+
runPipeline?: typeof defaultRunPipeline;
|
|
133
|
+
readFile?: (path: string) => string;
|
|
134
|
+
/** stdout sink; one call per NDJSON line, newline added by the caller. */
|
|
135
|
+
write?: (line: string) => void;
|
|
136
|
+
/** stderr sink for human-readable problems. */
|
|
137
|
+
writeErr?: (line: string) => void;
|
|
138
|
+
signal?: AbortSignal;
|
|
139
|
+
budget?: RunBudget;
|
|
140
|
+
/** Stage-list override (the blind eval lane's grounding-stripped copy). */
|
|
141
|
+
stages?: PipelineStage[];
|
|
142
|
+
/** False runs the blind eval lane; defaults to grounded like every real run. */
|
|
143
|
+
grounded?: boolean;
|
|
144
|
+
/** Additive tap on every child event (the eval lane's retrieval telemetry). */
|
|
145
|
+
onEvent?: (ev: JsonEvent, tag: StageTag) => void;
|
|
146
|
+
/** Additive tap on the pipeline's own log events. */
|
|
147
|
+
logger?: (event: string, data?: unknown) => void;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Parse the argv remainder after `go`. Accepts both `--flag value` and
|
|
151
|
+
* `--flag=value`; unknown flags and stray positionals are collected rather than
|
|
152
|
+
* silently absorbed, so a typo fails loudly instead of running the wrong thing.
|
|
153
|
+
*/
|
|
154
|
+
export declare function parseHeadlessGoArgs(argv: string[]): HeadlessGoArgs;
|
|
155
|
+
/** The argument problem, or undefined when the invocation is usable. */
|
|
156
|
+
export declare function validateHeadlessGoArgs(args: HeadlessGoArgs): string | undefined;
|
|
157
|
+
/**
|
|
158
|
+
* Run the pipeline headlessly. Never throws: every failure resolves to an exit
|
|
159
|
+
* code and an honest final line, so the sandbox harness and the smoke gate can
|
|
160
|
+
* read one contract.
|
|
161
|
+
*/
|
|
162
|
+
export declare function runHeadlessGo(argv: string[], deps?: HeadlessGoDeps): Promise<HeadlessGoOutcome>;
|
|
163
|
+
//# sourceMappingURL=headlessGo.d.ts.map
|