@yagni-app/code 0.3.2 → 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.
Files changed (58) hide show
  1. package/dist/cli.js +13 -0
  2. package/dist/extension/footer.d.ts +1 -1
  3. package/dist/extension/hooks.d.ts +111 -0
  4. package/dist/extension/hooks.js +666 -0
  5. package/dist/extension/index.d.ts +13 -6
  6. package/dist/extension/index.js +57 -7
  7. package/dist/extension/{approvedPrefixes.js → permission/approvedPrefixes.js} +1 -1
  8. package/dist/extension/permission/dbReadPolicy.d.ts +90 -0
  9. package/dist/extension/permission/dbReadPolicy.js +227 -0
  10. package/dist/extension/{execPolicy.js → permission/execPolicy.js} +41 -13
  11. package/dist/extension/{permission.d.ts → permission/gate.d.ts} +9 -2
  12. package/dist/extension/{permission.js → permission/gate.js} +103 -4
  13. package/dist/extension/{guardian.d.ts → permission/guardian.d.ts} +2 -2
  14. package/dist/extension/{guardian.js → permission/guardian.js} +1 -1
  15. package/dist/extension/permission/index.d.ts +14 -0
  16. package/dist/extension/permission/index.js +14 -0
  17. package/dist/extension/permission/packageManagerPolicy.d.ts +55 -0
  18. package/dist/extension/permission/packageManagerPolicy.js +170 -0
  19. package/dist/extension/pipeline/activityFeed.js +19 -5
  20. package/dist/extension/pipeline/checker.d.ts +99 -0
  21. package/dist/extension/pipeline/checker.js +238 -0
  22. package/dist/extension/pipeline/fanout.d.ts +116 -0
  23. package/dist/extension/pipeline/fanout.js +248 -0
  24. package/dist/extension/pipeline/fanoutBeats.d.ts +31 -0
  25. package/dist/extension/pipeline/fanoutBeats.js +86 -0
  26. package/dist/extension/pipeline/goCommand.d.ts +14 -0
  27. package/dist/extension/pipeline/goCommand.js +38 -1
  28. package/dist/extension/pipeline/headlessGo.d.ts +163 -0
  29. package/dist/extension/pipeline/headlessGo.js +333 -0
  30. package/dist/extension/pipeline/invocation.d.ts +7 -1
  31. package/dist/extension/pipeline/invocation.js +7 -1
  32. package/dist/extension/pipeline/mission.d.ts +55 -0
  33. package/dist/extension/pipeline/mission.js +70 -0
  34. package/dist/extension/pipeline/orchestrator.d.ts +48 -3
  35. package/dist/extension/pipeline/orchestrator.js +450 -9
  36. package/dist/extension/pipeline/personas.d.ts +16 -1
  37. package/dist/extension/pipeline/personas.js +117 -6
  38. package/dist/extension/pipeline/runSession.d.ts +45 -1
  39. package/dist/extension/pipeline/runState.d.ts +57 -12
  40. package/dist/extension/pipeline/runState.js +60 -18
  41. package/dist/extension/pipeline/runner.js +10 -1
  42. package/dist/extension/pipeline/stages.d.ts +84 -7
  43. package/dist/extension/pipeline/stages.js +166 -0
  44. package/dist/extension/pipeline/tierCap.d.ts +32 -0
  45. package/dist/extension/pipeline/tierCap.js +57 -0
  46. package/dist/extension/pipeline/types.d.ts +130 -1
  47. package/dist/extension/pipeline/types.js +17 -0
  48. package/dist/extension/pipeline/verify.d.ts +86 -3
  49. package/dist/extension/pipeline/verify.js +175 -6
  50. package/dist/extension/turnLog.d.ts +38 -0
  51. package/dist/extension/turnLog.js +93 -0
  52. package/dist/goHeadless.d.ts +75 -0
  53. package/dist/goHeadless.js +132 -0
  54. package/dist/paths.d.ts +9 -0
  55. package/dist/paths.js +12 -0
  56. package/package.json +2 -2
  57. /package/dist/extension/{approvedPrefixes.d.ts → permission/approvedPrefixes.d.ts} +0 -0
  58. /package/dist/extension/{execPolicy.d.ts → permission/execPolicy.d.ts} +0 -0
@@ -0,0 +1,238 @@
1
+ /**
2
+ * PURE checker-side helpers for the implement diamond (spec decisions 5-7).
3
+ *
4
+ * The checker itself is deterministic and lives in `verify.ts` (the per-workstream
5
+ * scoped typecheck plus the one full `makeRunVerify` on the merged tree). What is
6
+ * left is the reading of its verdict, and that is all pure text math:
7
+ *
8
+ * - {@link checkerFindings} unions the scoped + full findings into one deduped list.
9
+ * - {@link composeSynthesizerInput} turns the fan handoff plus the checker's
10
+ * verdict into the synthesizer's `{previous}`.
11
+ * - {@link attributeFindings} routes each finding to the workstream whose claims
12
+ * cover its file; anything unattributable is the synthesizer's (or, on the
13
+ * single-writer path, the one builder's).
14
+ * - {@link parseOpenFindings} reads the synthesizer's own "## Open findings"
15
+ * section, because decision 7 lets the summary report defects the deterministic
16
+ * checker cannot see.
17
+ * - {@link composeResidue} names what is still open when the fix cap is reached,
18
+ * so `reviewInput` hands the review loop the truth rather than a clean-looking
19
+ * summary.
20
+ *
21
+ * No I/O, no model calls: the orchestrator owns the children, this module owns the
22
+ * reading, exactly the split `findings.ts` and `fanout.ts` already use.
23
+ */
24
+ import { claimCovers } from "./fanout.js";
25
+ /** Cap on how many open findings are ever carried into a prompt or the residue. */
26
+ const MAX_CARRIED_FINDINGS = 25;
27
+ const key = (f) => `${f.file ?? ""}:${f.line ?? ""}:${f.message}`;
28
+ /** Union findings in first-seen order, deduped by file + line + message. */
29
+ export function mergeFindings(...groups) {
30
+ const seen = new Set();
31
+ const out = [];
32
+ for (const group of groups) {
33
+ for (const f of group) {
34
+ const k = key(f);
35
+ if (seen.has(k))
36
+ continue;
37
+ seen.add(k);
38
+ out.push(f);
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+ /** Every defect this checker pass found: the scoped typechecks plus the full verify. */
44
+ export function checkerFindings(report) {
45
+ return mergeFindings(report.scoped.flatMap((s) => s.findings), report.verify?.findings ?? []);
46
+ }
47
+ /** One finding as a line a builder can act on (mirrors the review loop's handoff shape). */
48
+ export function renderFindings(findings) {
49
+ return findings
50
+ .slice(0, MAX_CARRIED_FINDINGS)
51
+ .map((f) => {
52
+ const loc = f.file ? `${f.file}${f.line != null ? `:${f.line}` : ""}` : "";
53
+ return `${f.severity} | ${loc} | ${f.message}`;
54
+ })
55
+ .join("\n");
56
+ }
57
+ /** One workstream's scoped-typecheck line, stating honestly whether it ran. */
58
+ function scopedLine(s) {
59
+ if (!s.ran)
60
+ return `- ${s.name}: not checked (${s.reason ?? "no scoped build check"})`;
61
+ if (s.findings.length === 0)
62
+ return `- ${s.name}: clean (${s.command ?? "check"})`;
63
+ return `- ${s.name}: ${s.findings.length} finding${s.findings.length === 1 ? "" : "s"} (${s.command ?? "check"})`;
64
+ }
65
+ /** The full verify's one-line verdict, including the fail-open case. */
66
+ function verifyLine(verify) {
67
+ if (!verify)
68
+ return "The full verify did not run on this pass.";
69
+ if (!verify.ran)
70
+ return `The full verify did not produce a verdict: ${verify.reason ?? "it could not run"}.`;
71
+ const label = verify.command ? ` (${verify.command})` : "";
72
+ if (verify.ok)
73
+ return `The full verify passed${label}.`;
74
+ return `The full verify failed${label}: ${verify.findings.length} finding${verify.findings.length === 1 ? "" : "s"}.`;
75
+ }
76
+ /**
77
+ * The synthesizer's `{previous}`: the fan's own handoff (which already names each
78
+ * workstream, its claims, and any out-of-claim edits) plus the checker's verdict,
79
+ * with a broken workstream attributed BY NAME so the reconciler knows where to look.
80
+ */
81
+ export function composeSynthesizerInput(handoff, report) {
82
+ const parts = [handoff];
83
+ if (report.scoped.length > 0) {
84
+ parts.push(["## Checker: scoped typecheck per workstream", ...report.scoped.map(scopedLine)].join("\n"));
85
+ }
86
+ parts.push(`## Checker: full verify on the merged tree\n${verifyLine(report.verify)}`);
87
+ const findings = checkerFindings(report);
88
+ if (findings.length > 0) {
89
+ parts.push(`## Checker findings\n${renderFindings(findings)}`);
90
+ }
91
+ return parts.join("\n\n");
92
+ }
93
+ /** The workstream whose claims cover this finding's file, if exactly one does. */
94
+ function ownerOf(finding, workstreams) {
95
+ const file = finding.file?.trim();
96
+ if (!file)
97
+ return undefined;
98
+ return workstreams.find((w) => w.files.some((claim) => claimCovers(claim, file)));
99
+ }
100
+ /**
101
+ * Route findings to the builders that can fix them: a finding whose file sits under
102
+ * a workstream's claims goes to that workstream (at its original tier), and anything
103
+ * else - no file, a file outside every claim, a seam between two workstreams - is
104
+ * unattributable and belongs to the synthesizer. With no workstreams (the
105
+ * single-writer path) everything is unassigned, which is exactly right: there is one
106
+ * builder and it owns the whole tree.
107
+ */
108
+ export function attributeFindings(findings, workstreams) {
109
+ const assigned = [];
110
+ const unassigned = [];
111
+ for (const finding of findings) {
112
+ const owner = ownerOf(finding, workstreams);
113
+ if (!owner) {
114
+ unassigned.push(finding);
115
+ continue;
116
+ }
117
+ const existing = assigned.find((a) => a.workstream.name === owner.name);
118
+ if (existing)
119
+ existing.findings.push(finding);
120
+ else
121
+ assigned.push({ workstream: owner, findings: [finding] });
122
+ }
123
+ return { assigned, unassigned };
124
+ }
125
+ /** A heading line, at any depth: `## Open findings`. */
126
+ const HEADING = /^#{1,6}\s+(.*)$/;
127
+ /** `path/to/file.ts` or `path/to/file.ts:42` — a path token, not prose. */
128
+ const PATH_SHAPE = /^[\w./@-]+\.[A-Za-z]{1,5}(?::\d+)?$/;
129
+ /**
130
+ * The first path-shaped token in a synthesizer's finding line (backticked first,
131
+ * since that is how the persona writes paths), with any `:line` suffix dropped so
132
+ * it compares against claim prefixes. Undefined when the line names no file, which
133
+ * makes the finding unattributable and therefore the synthesizer's own.
134
+ */
135
+ function pathIn(line) {
136
+ const quoted = [...line.matchAll(/`([^`]+)`/g)].map((m) => m[1].trim());
137
+ for (const token of [...quoted, ...line.split(/[\s,;()[\]]+/)]) {
138
+ const clean = token.replace(/[.,;:]+$/, "");
139
+ if (clean.includes("/") && PATH_SHAPE.test(clean))
140
+ return clean.replace(/:\d+$/, "");
141
+ }
142
+ return undefined;
143
+ }
144
+ /**
145
+ * The synthesizer's own "## Open findings" section (its persona output contract),
146
+ * as findings the fix loop can act on. "none" is a real answer and returns nothing;
147
+ * an absent section returns nothing too, because a summary that never claimed a
148
+ * defect is not evidence of one. Deliberately forgiving about the prose: what the
149
+ * loop needs is the line and, where the synthesizer named one, the file.
150
+ */
151
+ export function parseOpenFindings(summary) {
152
+ const lines = summary.split("\n");
153
+ const start = lines.findIndex((l) => {
154
+ const m = l.trim().match(HEADING);
155
+ return m ? /^open findings\b/i.test(m[1].trim()) : false;
156
+ });
157
+ if (start < 0)
158
+ return [];
159
+ const body = [];
160
+ for (const raw of lines.slice(start + 1)) {
161
+ if (HEADING.test(raw.trim()))
162
+ break;
163
+ const line = raw.trim().replace(/^[-*]\s+/, "").trim();
164
+ if (line)
165
+ body.push(line);
166
+ }
167
+ if (body.length === 0)
168
+ return [];
169
+ if (body.length === 1 && /^none\b/i.test(body[0].replace(/[.*_`]/g, "")))
170
+ return [];
171
+ return body.slice(0, MAX_CARRIED_FINDINGS).map((line) => {
172
+ const file = pathIn(line);
173
+ const finding = {
174
+ severity: "critical",
175
+ lens: "does_it_hold",
176
+ message: `synthesizer: ${line}`,
177
+ };
178
+ if (file)
179
+ finding.file = file;
180
+ return finding;
181
+ });
182
+ }
183
+ /**
184
+ * The same "## Open findings" section {@link parseOpenFindings} reads, REMOVED
185
+ * from a summary (heading and body, up to the next heading of any depth).
186
+ *
187
+ * A fix turn that only re-engaged builders never re-runs the synthesizer, so its
188
+ * summary stays the handoff verbatim. Once those defects are answered and the
189
+ * re-check is clean, leaving the section in place would hand the review loop a
190
+ * document that names open defects in one paragraph and calls the final check
191
+ * clean in the next. Everything else the synthesizer wrote is untouched: this
192
+ * drops only the section the fix loop has since resolved.
193
+ */
194
+ export function stripOpenFindings(summary) {
195
+ const lines = summary.split("\n");
196
+ const start = lines.findIndex((l) => {
197
+ const m = l.trim().match(HEADING);
198
+ return m ? /^open findings\b/i.test(m[1].trim()) : false;
199
+ });
200
+ if (start < 0)
201
+ return summary;
202
+ let end = lines.length;
203
+ for (let i = start + 1; i < lines.length; i += 1) {
204
+ if (HEADING.test(lines[i].trim())) {
205
+ end = i;
206
+ break;
207
+ }
208
+ }
209
+ return [...lines.slice(0, start), ...lines.slice(end)].join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
210
+ }
211
+ /**
212
+ * What `reviewInput` says when the fix loop ran and then came back clean: the
213
+ * reviewers should know the candidate was repaired inside the implement stage, not
214
+ * that it was right first time. Absent when no fix turn ran, which keeps a
215
+ * clean-on-the-first-check run byte-identical to today's handoff.
216
+ */
217
+ export function composeFixNote(turns) {
218
+ return [
219
+ "## Verification",
220
+ `${turns} fix pass${turns === 1 ? "" : "es"} ran inside the implement stage. The final check came back clean.`,
221
+ ].join("\n");
222
+ }
223
+ /**
224
+ * What `reviewInput` says when the fix cap is reached with defects still open
225
+ * (spec decision 7): named, not hidden, so the review loop picks them up knowing
226
+ * the implement stage already spent its turns on them.
227
+ */
228
+ export function composeResidue(findings, turns) {
229
+ const header = turns === 0
230
+ ? "## Open findings from verification"
231
+ : `## Open findings after ${turns} fix pass${turns === 1 ? "" : "es"}`;
232
+ return [
233
+ header,
234
+ "Verification still reports these. They were not resolved inside the implement stage:",
235
+ renderFindings(findings),
236
+ ].join("\n");
237
+ }
238
+ //# sourceMappingURL=checker.js.map
@@ -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