@yagni-app/code-staging 0.3.2-staging.1112.1 → 0.3.2-staging.1115.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/cli.js +13 -0
- package/dist/extension/hooks.d.ts +111 -0
- package/dist/extension/hooks.js +666 -0
- package/dist/extension/index.d.ts +7 -0
- package/dist/extension/index.js +22 -0
- package/dist/extension/permission.d.ts +7 -0
- package/dist/extension/permission.js +100 -1
- 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 +7 -1
- package/dist/extension/pipeline/invocation.js +7 -1
- 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 +117 -6
- 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/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/package.json +2 -2
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
import type { FanoutMode } from "./types.js";
|
|
28
|
+
/** The tiers a workstream may run on: load-bearing work standard, mechanical work efficient. */
|
|
29
|
+
export type WorkstreamTier = "standard" | "efficient";
|
|
30
|
+
/**
|
|
31
|
+
* The `go.fanout` knob's environment surface (spec decision 8), named the way
|
|
32
|
+
* `YAGNI_GO_TIER_CAP` is (see tierCap.ts): a run-wide /go setting an eval or
|
|
33
|
+
* benchmark lane pins from the outside. `always` forces the diamond attempt;
|
|
34
|
+
* unset (everywhere else) leaves the partitioner's own conservative verdict as
|
|
35
|
+
* the only thing that decides.
|
|
36
|
+
*/
|
|
37
|
+
export declare const FANOUT_MODE_ENV = "YAGNI_GO_FANOUT";
|
|
38
|
+
/**
|
|
39
|
+
* Parse a raw env value into a mode. Unset, empty, or unrecognized all yield
|
|
40
|
+
* undefined so a caller can warn about a typo; {@link resolveFanoutMode} is what
|
|
41
|
+
* turns that into the safe `auto` default. A typo must never pin a lane.
|
|
42
|
+
*/
|
|
43
|
+
export declare function parseFanoutMode(raw: string | undefined): FanoutMode | undefined;
|
|
44
|
+
/** The run's fan-out mode from an environment. Defaults to `auto`. */
|
|
45
|
+
export declare function resolveFanoutMode(env: NodeJS.ProcessEnv): FanoutMode;
|
|
46
|
+
/** The only legal fan widths (execution concurrency is capped separately by the session). */
|
|
47
|
+
export declare const PARTITION_WIDTHS: readonly [2, 4, 8];
|
|
48
|
+
export type PartitionWidth = (typeof PARTITION_WIDTHS)[number];
|
|
49
|
+
/** One workstream: a named, tasked builder with a disjoint set of prefix claims. */
|
|
50
|
+
export interface PartitionWorkstream {
|
|
51
|
+
name: string;
|
|
52
|
+
task: string;
|
|
53
|
+
/** Normalized, deduped path prefixes; a directory claim covers its subtree. */
|
|
54
|
+
files: string[];
|
|
55
|
+
tier: WorkstreamTier;
|
|
56
|
+
}
|
|
57
|
+
/** The orchestrator's verdict: fan into workstreams, or run today's single writer. */
|
|
58
|
+
export interface PartitionDecision {
|
|
59
|
+
mode: "fan" | "single";
|
|
60
|
+
/** Fan only; always equals `workstreams.length`. */
|
|
61
|
+
width?: PartitionWidth;
|
|
62
|
+
/** Always present: why this width, or why single-writer. */
|
|
63
|
+
reason: string;
|
|
64
|
+
/** Fan only. */
|
|
65
|
+
workstreams?: PartitionWorkstream[];
|
|
66
|
+
}
|
|
67
|
+
/** One pair of colliding claims across two workstreams. */
|
|
68
|
+
export interface ClaimCollision {
|
|
69
|
+
path: string;
|
|
70
|
+
otherPath: string;
|
|
71
|
+
workstreams: [string, string];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Parse outcome. `ok: false` splits into the re-askable soft rejection and the
|
|
75
|
+
* hard overlap error (see the module comment).
|
|
76
|
+
*/
|
|
77
|
+
export type PartitionParse = {
|
|
78
|
+
ok: true;
|
|
79
|
+
decision: PartitionDecision;
|
|
80
|
+
} | {
|
|
81
|
+
ok: false;
|
|
82
|
+
hard: false;
|
|
83
|
+
reason: string;
|
|
84
|
+
} | {
|
|
85
|
+
ok: false;
|
|
86
|
+
hard: true;
|
|
87
|
+
reason: string;
|
|
88
|
+
collisions: ClaimCollision[];
|
|
89
|
+
};
|
|
90
|
+
/** Extract the inner text of a fenced ```partition block, if present. */
|
|
91
|
+
export declare function extractPartitionBlock(raw: string): string | null;
|
|
92
|
+
/**
|
|
93
|
+
* Normalize a claim or changed path for prefix comparison: trim, convert
|
|
94
|
+
* backslashes, collapse doubled slashes, drop interior `.` segments, a leading
|
|
95
|
+
* `./`, and trailing slashes. Purely lexical — no filesystem access. Without
|
|
96
|
+
* the collapse, `packages//backend` and `packages/./backend` pass disjointness
|
|
97
|
+
* against `packages/backend` while claiming the same directory — the exact
|
|
98
|
+
* two-writers-one-file outcome the partition-time hard error exists to prevent.
|
|
99
|
+
*/
|
|
100
|
+
export declare function normalizeClaimPath(path: string): string;
|
|
101
|
+
/** True when `claim` is the path itself or a directory containing it. */
|
|
102
|
+
export declare function claimCovers(claim: string, path: string): boolean;
|
|
103
|
+
/**
|
|
104
|
+
* Parse the orchestrator's raw output into a {@link PartitionDecision}. Never
|
|
105
|
+
* throws: an unusable block is a soft rejection (re-ask, then single-writer) and
|
|
106
|
+
* overlapping claims are the hard rejection the caller turns into a stage error.
|
|
107
|
+
*/
|
|
108
|
+
export declare function parsePartition(raw: string): PartitionParse;
|
|
109
|
+
/**
|
|
110
|
+
* Post-fan claim audit: the changed paths not covered by any workstream's claims,
|
|
111
|
+
* normalized, deduped, in first-seen order. Claims are prefixes, so a file created
|
|
112
|
+
* under a claimed directory is in-claim; anything else is a named violation the
|
|
113
|
+
* synthesizer reconciles.
|
|
114
|
+
*/
|
|
115
|
+
export declare function auditClaims(changedPaths: string[], workstreams: PartitionWorkstream[]): string[];
|
|
116
|
+
//# sourceMappingURL=fanout.d.ts.map
|
|
@@ -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) {
|