pi-aia-asf 0.13.5 → 0.14.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/CHANGELOG.md CHANGED
@@ -7,6 +7,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.14.1] - 2026-11-04
11
+
12
+ ### Added
13
+
14
+ - (describe changes for 0.14.1)
15
+
16
+ ## [0.14.0] - 2026-11-02
17
+
18
+ ### Added
19
+
20
+ - **Zero-context reviewer pass** (Phase 4 and Phase 7, large work): six read-only reviewer
21
+ personas in `skills/aia-asf/agents/` (`design-critic`, `codebase-fitness`, `zoom-out`,
22
+ `comprehension`, `implementation-readiness`, `structural`) plus a `synthesizer`, each
23
+ invoked separately, each answering in a fixed finding format (`Blocker | Major | Nit`,
24
+ scope, rationale, recommendation), critique-first and cite-or-abstain. Reviewers are
25
+ given the artifact and the acceptance criteria only — never the conversation that
26
+ produced it.
27
+ - `lib/review.mjs` — the review guards as code rather than as prose: citation verification
28
+ (a report whose cited file or line range does not resolve is recorded `not run`, **never**
29
+ "zero findings"), the per-reviewer status table, the finding schema with dedup and
30
+ severity ordering, and A/B duplication permitted only at the blocker boundary. The
31
+ citation guard exists because a read-only subagent was observed reporting a confident
32
+ reading of a file that was never asked for, and exiting 0.
33
+ - `lib/parallel-estimate.mjs` — the serial/parallel decision computed from file ownership,
34
+ interface ownership, dependency cycles, serial cost and a bounded schedule. Parallel
35
+ requires no conflicts, at least 3 fully independent packages and a speed-up of 1.3×; a
36
+ job under 30 serial minutes is refused outright, and a refusal is a recorded decision
37
+ rather than an omission. Also carries the P47 switch (`readParallelSwitch`: default OFF,
38
+ `ASF_PARALLEL=1` for one run that does not persist) and the join contract: one lane, the
39
+ full suite after the rebase, no merge conflict resolved by a model, no thread resolving
40
+ its own ticket.
41
+ - `lib/gates/drift.mjs` — task-scoped drift: the claims this task's own artifacts make
42
+ about files and symbols are resolved against the code. `no-claims` and `no-artifacts`
43
+ are reported as examined-nothing, never as clean, and only a stale claim blocks.
44
+ - `scripts/audit-docs.mjs` — ASF audits its own normative content: relative links, anchors,
45
+ index/directory agreement, routing ("Read when" or an index row) and frontmatter.
46
+ - `lib/skill-budget.mjs` + `test/test-skill-budget.mjs` — an always-read byte ceiling with a
47
+ rule-preservation check: procedure, tables and examples may move out of the always-read
48
+ files; a rule statement may not, and a split that drops one fails the suite.
49
+ - `references/06m-antipatterns.md` (the complete forbidden list, with a short always-read
50
+ floor left in `SKILL.md`) and `references/06b-appendix.md` (the Rule 9 browser procedure
51
+ and Rule 15 enforcement-class detail).
52
+ - `references/05-plan.md` — a work-package table (files owned, interfaces needed and
53
+ provided, depends on, estimated minutes) and a required parallel verdict with its reason,
54
+ recorded even when the answer is serial.
55
+ - Rebuilt `test/test-antipatterns.mjs`, `test/test-parallel-switch.mjs` and
56
+ `test/test-operator-surface.mjs` — these had been in the working tree, were never
57
+ committed, and are gone; they are rewritten against the modules they guard. Added
58
+ `test/test-review-guards.mjs`, `test/test-docs-integrity.mjs`,
59
+ `test/test-skill-budget.mjs`, and task-scoped drift coverage in
60
+ `test/test-improvements.mjs`. Every new guard ships with a control that must fail.
61
+
62
+ ### Changed
63
+
64
+ - The always-read set is 74,777 bytes across `SKILL.md` and the three mandatory references,
65
+ down from 88,515 (−15.5%), with the per-file ceilings and their reasons recorded in
66
+ `lib/skill-budget.mjs` rather than as a bare number. `SKILL.md` itself went 36,931 →
67
+ 25,689 bytes; the reduction is duplicated procedure and the forbidden list, not rules.
68
+ - Phase 5 presents `PLAN.md` as an artifact (path plus decision summary) instead of pasting
69
+ the plan into chat; Phase 4 now states a reviewer budget (6 reviewers, 2 re-audits, 3
70
+ concurrent, stop and report at 6× the serial token estimate) so thoroughness is bounded
71
+ without being thin.
72
+
73
+ ### Fixed
74
+
75
+ - `references/06k-performance.md` linked to a research document that is not in the package —
76
+ a dangling link that no test could see until the doc audit was added.
77
+
10
78
  ## [0.13.5] - 2026-10-18
11
79
 
12
80
  ### Added
package/index.ts CHANGED
@@ -827,8 +827,18 @@ export default function register(pi: ExtensionAPI): void {
827
827
  // as final and notification-only, while `agent_before_settle` is the final
828
828
  // actionable boundary. And nothing here awaits a resumed run: the loop guard
829
829
  // documents a production hang caused by exactly that.
830
- pi.on("agent_before_settle", () => {
830
+ pi.on("agent_before_settle", async () => {
831
831
  try {
832
+ // The completion gate belongs to an ASF run. Without this check it judged
833
+ // every session that happened to load ASF — a plain question in a scratch
834
+ // directory was held to the same evidence bar as a delivered feature.
835
+ // While pi refused a contentless continuation the gate was inert and
836
+ // nobody noticed; once the continuation actually resumed the run, the
837
+ // gate began interrupting work that never asked for ASF. An ASF run is
838
+ // active when /asf has set a phase for this project and it has not ended.
839
+ const state = await loadState(projectName());
840
+ if (!state.current || state.current.phase === "none") return undefined;
841
+
832
842
  const decision = decideSettlement(ledger, {
833
843
  scale: currentScale(),
834
844
  webTouched,
@@ -845,7 +855,31 @@ export default function register(pi: ExtensionAPI): void {
845
855
  continuationsRequested++;
846
856
  // One bounded continuation naming exactly what is missing. No UI prompt:
847
857
  // this sends the agent back to work, it does not ask anyone anything.
848
- return { continue: true };
858
+ //
859
+ // The continuation MUST carry content. pi commits `entries` and only
860
+ // then re-checks whether the context can run another provider request;
861
+ // at settle time the context ends with the assistant's own message, so
862
+ // a bare `{ continue: true }` is refused with
863
+ // "agent_before_settle requested continuation without runnable model
864
+ // context" and the agent is never told what was missing. A
865
+ // `custom_message` entry projects to a user-role message, which is what
866
+ // makes the next request runnable — and it is the only thing that can
867
+ // deliver the missing list to the model.
868
+ const list = decision.missing.map((item: string) => `- ${item}`).join("\n");
869
+ return {
870
+ entries: [
871
+ {
872
+ type: "custom_message",
873
+ customType: "asf-completion-gate",
874
+ content:
875
+ `ASF completion gate: this run cannot settle yet. Missing:\n${list}\n\n` +
876
+ `Close these now, then let the run settle. Bounded: continuation ${continuationsRequested} of ${MAX_CONTINUATIONS}.`,
877
+ display: true,
878
+ details: { missing: decision.missing, continuation: continuationsRequested },
879
+ },
880
+ ],
881
+ continue: true,
882
+ };
849
883
  }
850
884
  return undefined;
851
885
  } catch {
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Task-scoped drift (W2.1).
3
+ *
4
+ * Repo-wide documentation drift was considered and rejected: it presumes the
5
+ * documentation exists and is maintained, and products legitimately evolve, so a
6
+ * repo-wide check produces permanent noise that gets switched off — which is worse
7
+ * than never having it. What is checked instead is narrower and always available:
8
+ * the claims THIS task wrote about itself (its PLAN.md, its captured specs, its
9
+ * design document) against the code as it stands now.
10
+ *
11
+ * That is the same claim/evidence split as everywhere else in ASF: an artifact that
12
+ * names `lib/gates/ledger.mjs` or `deriveSuiteRun()` is making a checkable assertion.
13
+ * If the file is gone or the symbol no longer exists, the artifact is stale and the
14
+ * delivery report is describing something that is not there.
15
+ *
16
+ * A scan that extracts no claims is reported as such. "Nothing found" and "nothing
17
+ * looked at" are different results, and conflating them is how a guard goes green
18
+ * while doing nothing.
19
+ */
20
+
21
+ const SOURCE_EXT = /\.(ts|mts|tsx|js|mjs|cjs|jsx|py|go|rs|java|kt|swift|c|cc|cpp|h|hpp|rb|php|sh|sql|json|yaml|yml)$/i;
22
+ const BACKTICK = /`([^`\n]{2,140})`/g;
23
+
24
+ /**
25
+ * Pull the checkable assertions out of an artifact.
26
+ * Kinds:
27
+ * file — a backticked path (contains "/" or carries a source/document extension)
28
+ * symbol — a backticked identifier, optionally call-shaped `foo()` or `Foo#bar`
29
+ */
30
+ export function extractClaims(text) {
31
+ const claims = [];
32
+ const seen = new Set();
33
+ let m;
34
+ let line = 1;
35
+ const lines = text.split("\n");
36
+ for (let i = 0; i < lines.length; i++) {
37
+ BACKTICK.lastIndex = 0;
38
+ let mm;
39
+ while ((mm = BACKTICK.exec(lines[i]))) {
40
+ const raw = mm[1].trim();
41
+ if (!raw || /\s/.test(raw) && !/^\w+#\w+$/.test(raw)) continue;
42
+ const cleaned = raw.replace(/^\.\//, "").replace(/:[0-9]+(-[0-9]+)?$/, "");
43
+ const isPath = cleaned.includes("/") || /\.[a-z0-9]{1,6}$/i.test(cleaned);
44
+ const isSymbol = /^[A-Za-z_$][\w$]*(\([^)]*\))?$/.test(cleaned) || /^\w+#\w+$/.test(cleaned);
45
+ if (!isPath && !isSymbol) continue;
46
+ const kind = isPath && !cleaned.endsWith("()") ? "file" : "symbol";
47
+ const value = cleaned.replace(/\(\)$/, "");
48
+ const key = `${kind}:${value}`;
49
+ if (seen.has(key)) continue;
50
+ seen.add(key);
51
+ claims.push({ kind, value, line: i + 1, raw });
52
+ }
53
+ }
54
+ return claims;
55
+ }
56
+
57
+ function walk(dir, fs, out = [], depth = 0) {
58
+ if (depth > 8) return out;
59
+ let entries = [];
60
+ try {
61
+ entries = fs.readdirSync(dir, { withFileTypes: true });
62
+ } catch {
63
+ return out;
64
+ }
65
+ for (const e of entries) {
66
+ if (e.name === ".git" || e.name === "node_modules" || e.name === "dist" || e.name === "coverage") continue;
67
+ const p = `${dir}/${e.name}`;
68
+ if (e.isDirectory()) walk(p, fs, out, depth + 1);
69
+ else if (SOURCE_EXT.test(e.name)) out.push(p);
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /**
75
+ * @param {object} input
76
+ * @param {Array<{path:string, text:string}>} input.artifacts the task's own artifacts
77
+ * @param {string} input.repoRoot
78
+ * @param {object} input.fs injectable for tests
79
+ * @param {string[]} [input.ignore] claim values to skip (documented exceptions)
80
+ */
81
+ export function checkDrift({ artifacts = [], repoRoot, fs, ignore = [] }) {
82
+ const ignored = new Set(ignore);
83
+ const sources = walk(repoRoot, fs);
84
+ const sourceSet = new Set(sources.map((p) => p.replace(`${repoRoot}/`, "")));
85
+ const symbolCache = new Map();
86
+ const hasSymbol = (name) => {
87
+ if (symbolCache.has(name)) return symbolCache.get(name);
88
+ const re = new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
89
+ for (const f of sources) {
90
+ let body = "";
91
+ try {
92
+ body = fs.readFileSync(f, "utf8");
93
+ } catch {
94
+ continue;
95
+ }
96
+ if (re.test(body)) {
97
+ symbolCache.set(name, true);
98
+ return true;
99
+ }
100
+ }
101
+ symbolCache.set(name, false);
102
+ return false;
103
+ };
104
+
105
+ const claims = [];
106
+ const unresolved = [];
107
+ const unread = [];
108
+ for (const artifact of artifacts) {
109
+ // An artifact may be handed over with its text, or named by path — and an artifact
110
+ // that could not be read is examined-nothing, never a clean result.
111
+ let text = artifact.text;
112
+ if (text === undefined && artifact.path) {
113
+ try {
114
+ text = fs.readFileSync(`${repoRoot}/${artifact.path}`.replace("//", "/"), "utf8");
115
+ } catch {
116
+ text = undefined;
117
+ }
118
+ }
119
+ if (text === undefined) {
120
+ unread.push(artifact.path || "(unnamed artifact)");
121
+ continue;
122
+ }
123
+ for (const c of extractClaims(text)) {
124
+ if (ignored.has(c.value)) continue;
125
+ claims.push({ ...c, artifact: artifact.path });
126
+ const ok = c.kind === "file" ? sourceSet.has(c.value) : hasSymbol(c.value);
127
+ if (!ok) unresolved.push({ ...c, artifact: artifact.path });
128
+ }
129
+ }
130
+
131
+ const checked = claims.length;
132
+ return {
133
+ checked,
134
+ unresolved,
135
+ // A scan that looked at nothing is not a clean scan. Reported separately so a
136
+ // caller cannot mistake "0 unresolved" for "the artifact still matches the code".
137
+ unread,
138
+ status: checked === 0 ? (unread.length ? "no-artifacts" : "no-claims") : unresolved.length ? "drift" : "clean",
139
+ // Only a stale claim blocks the document-accuracy tick. "no-claims" and
140
+ // "no-artifacts" are examined-nothing: they never block, and they never tick.
141
+ blocking: unresolved.length > 0,
142
+ sourcesScanned: sources.length,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Render the finding the way the delivery report wants it: name the artifact and the
148
+ * stale claim, so the operator can act without re-reading the document.
149
+ */
150
+ export function renderDrift(result) {
151
+ if (result.status === "no-claims") {
152
+ return "drift: no checkable claims were found in the task artifacts — this is not a pass, the artifact names no files or symbols";
153
+ }
154
+ if (!result.unresolved.length) return `drift: clean — ${result.checked} claim(s) in the task artifacts all resolve against the source`;
155
+ return [
156
+ `drift: ${result.unresolved.length} of ${result.checked} claim(s) in the task artifacts no longer resolve:`,
157
+ ...result.unresolved.map((u) => ` ${u.artifact}:${u.line} — ${u.kind} ${u.value}`),
158
+ ].join("\n");
159
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Parallel-execution estimator (W3.1).
3
+ *
4
+ * ASF's rule is that parallel implementation is a decision, not an enthusiasm. The
5
+ * decision needs numbers, so this computes them: the serial cost, the critical path
6
+ * under a concurrency cap, the speed-up that cap actually yields, and the conflicts
7
+ * that forbid parallelism in the first place.
8
+ *
9
+ * Two kinds of conflict are checked, because file disjointness alone is not
10
+ * independence. Agents working on disjoint files still break each other when they
11
+ * both touch a shared interface — the silent semantic drift a merge queue cannot see.
12
+ * So a shared interface that neither package declares as provided counts as a
13
+ * dependency, and the answer is serial.
14
+ *
15
+ * Nothing here spawns anything. It answers "is this worth parallelising" and returns
16
+ * the reasons either way, so a refusal is recorded rather than silently omitted.
17
+ */
18
+
19
+ const MIN_SPEEDUP = 1.3;
20
+ const MIN_INDEPENDENT = 3;
21
+
22
+ /**
23
+ * @param {object} input
24
+ * @param {Array<{id:string, files:string[], interfacesNeeded?:string[], interfacesProvided?:string[], dependsOn?:string[], minutes:number}>} input.packages
25
+ * @param {number} [input.maxConcurrent]
26
+ */
27
+ export function estimateParallelism({ packages = [], maxConcurrent = 3 }) {
28
+ const reasons = [];
29
+ const errors = [];
30
+
31
+ if (!Array.isArray(packages) || packages.length === 0) {
32
+ return { verdict: "serial", speedup: 1, reasons: ["no work packages declared"], conflicts: [], errors: ["packages is empty"] };
33
+ }
34
+
35
+ const byId = new Map();
36
+ for (const p of packages) {
37
+ if (!p.id) errors.push("a package has no id");
38
+ if (byId.has(p.id)) errors.push(`duplicate package id "${p.id}"`);
39
+ if (!Number.isFinite(p.minutes) || p.minutes <= 0) errors.push(`package "${p.id}" has no positive minutes`);
40
+ if (!Array.isArray(p.files) || p.files.length === 0) errors.push(`package "${p.id}" declares no files — independence cannot be verified`);
41
+ byId.set(p.id, p);
42
+ }
43
+ for (const p of packages) {
44
+ for (const d of p.dependsOn || []) {
45
+ if (!byId.has(d)) errors.push(`package "${p.id}" depends on unknown package "${d}"`);
46
+ }
47
+ }
48
+ if (errors.length) return { verdict: "serial", speedup: 1, reasons: ["work packages are not well formed"], conflicts: [], errors };
49
+
50
+ // A short job is not worth three context windows. The floor is deliberately plain:
51
+ // parallelism is bought with tokens, and a 15-minute task finished in 5 costs the same
52
+ // in money and more in risk.
53
+ const MIN_SERIAL_MINUTES = 30;
54
+ if (packages.reduce((s, p) => s + p.minutes, 0) < MIN_SERIAL_MINUTES) {
55
+ return {
56
+ verdict: "serial",
57
+ serial: packages.reduce((s, p) => s + p.minutes, 0),
58
+ criticalPath: 0,
59
+ criticalPathIds: [],
60
+ parallel: false,
61
+ speedup: 1,
62
+ maxConcurrent: Math.max(1, Math.floor(maxConcurrent)),
63
+ independentCount: 0,
64
+ conflicts: [],
65
+ reasons: [`serial cost is under ${MIN_SERIAL_MINUTES} minutes — the threads would cost more than they save`],
66
+ errors: [],
67
+ };
68
+ }
69
+
70
+ // --- conflicts -------------------------------------------------------------
71
+ const conflicts = [];
72
+ for (let i = 0; i < packages.length; i++) {
73
+ for (let j = i + 1; j < packages.length; j++) {
74
+ const a = packages[i], b = packages[j];
75
+ const sharedFiles = (a.files || []).filter((f) => (b.files || []).includes(f));
76
+ if (sharedFiles.length) {
77
+ conflicts.push({ kind: "file", packages: [a.id, b.id], items: sharedFiles });
78
+ }
79
+ // A shared interface is only safe when exactly one side owns it.
80
+ const aProv = new Set(a.interfacesProvided || []);
81
+ const bProv = new Set(b.interfacesProvided || []);
82
+ const aNeed = new Set(a.interfacesNeeded || []);
83
+ const bNeed = new Set(b.interfacesNeeded || []);
84
+ const bothTouch = new Set([...aNeed, ...aProv].filter((x) => bNeed.has(x) || bProv.has(x)));
85
+ const unowned = [...bothTouch].filter((x) => !(aProv.has(x) && bNeed.has(x)) && !(bProv.has(x) && aNeed.has(x)));
86
+ if (unowned.length) {
87
+ conflicts.push({ kind: "interface", packages: [a.id, b.id], items: unowned });
88
+ }
89
+ }
90
+ }
91
+
92
+ // --- cycle check -----------------------------------------------------------
93
+ const state = new Map();
94
+ const hasCycle = (id, stack) => {
95
+ if (state.get(id) === "done") return false;
96
+ if (stack.has(id)) return true;
97
+ stack.add(id);
98
+ state.set(id, "visiting");
99
+ for (const d of byId.get(id).dependsOn || []) {
100
+ if (hasCycle(d, stack)) return true;
101
+ }
102
+ stack.delete(id);
103
+ state.set(id, "done");
104
+ return false;
105
+ };
106
+ for (const p of packages) {
107
+ state.clear();
108
+ if (hasCycle(p.id, new Set())) {
109
+ return { verdict: "serial", speedup: 1, reasons: ["dependency cycle among work packages"], conflicts, errors: ["cycle"] };
110
+ }
111
+ }
112
+
113
+ // --- serial and critical path ---------------------------------------------
114
+ const serial = packages.reduce((s, p) => s + p.minutes, 0);
115
+ const finish = new Map();
116
+ const earliestFinish = (id) => {
117
+ if (finish.has(id)) return finish.get(id);
118
+ const p = byId.get(id);
119
+ const start = Math.max(0, ...(p.dependsOn || []).map(earliestFinish));
120
+ const f = start + p.minutes;
121
+ finish.set(id, f);
122
+ return f;
123
+ };
124
+ const criticalPath = Math.max(...packages.map((p) => earliestFinish(p.id)));
125
+ const criticalPathIds = packages
126
+ .filter((p) => earliestFinish(p.id) === criticalPath)
127
+ .map((p) => p.id);
128
+
129
+ // --- bounded list schedule (what the cap really yields) --------------------
130
+ const cap = Math.max(1, Math.floor(maxConcurrent));
131
+ const remaining = packages.map((p) => ({ ...p, done: false }));
132
+ const doneAt = new Map();
133
+ let clock = 0;
134
+ let guard = 0;
135
+ while (remaining.some((p) => !p.done) && guard++ < 1000) {
136
+ const ready = remaining
137
+ .filter((p) => !p.done && (p.dependsOn || []).every((d) => doneAt.has(d)))
138
+ .sort((a, b) => b.minutes - a.minutes);
139
+ if (!ready.length) break;
140
+ const batch = ready.slice(0, cap);
141
+ const start = Math.max(0, ...(batch.flatMap((p) => (p.dependsOn || []).map((d) => doneAt.get(d) || 0))));
142
+ const end = start + Math.max(...batch.map((p) => p.minutes));
143
+ for (const p of batch) {
144
+ p.done = true;
145
+ doneAt.set(p.id, end);
146
+ }
147
+ clock = Math.max(clock, end);
148
+ }
149
+ const parallel = clock;
150
+ const speedup = parallel > 0 ? serial / parallel : 1;
151
+
152
+ // --- verdict ---------------------------------------------------------------
153
+ const independentCount = packages.filter(
154
+ (p) => !(p.dependsOn || []).length && !conflicts.some((c) => c.packages.includes(p.id)),
155
+ ).length;
156
+
157
+ if (conflicts.length) {
158
+ const kinds = [...new Set(conflicts.map((c) => c.kind))].join(", ");
159
+ reasons.push(`conflicts of kind ${kinds} — packages are not independent (06d: if two tasks might touch the same file, they are dependent)`);
160
+ }
161
+ if (independentCount < MIN_INDEPENDENT) {
162
+ reasons.push(`only ${independentCount} fully independent package(s); ${MIN_INDEPENDENT} are needed to pay for the fan-out`);
163
+ }
164
+ if (speedup < MIN_SPEEDUP) {
165
+ reasons.push(`critical path ${criticalPath}m vs serial ${serial}m — speed-up ${speedup.toFixed(2)}x is below the ${MIN_SPEEDUP}x floor, so parallelism would cost more than it saves`);
166
+ }
167
+
168
+ const verdict = conflicts.length === 0 && independentCount >= MIN_INDEPENDENT && speedup >= MIN_SPEEDUP ? "parallel" : "serial";
169
+ if (verdict === "parallel") {
170
+ reasons.push(`${independentCount} independent packages, speed-up ${speedup.toFixed(2)}x at concurrency ${cap}; join through the serialised queue and run the full suite after rebase`);
171
+ }
172
+
173
+ return {
174
+ verdict,
175
+ serial,
176
+ criticalPath,
177
+ criticalPathIds,
178
+ parallel,
179
+ speedup: Number(speedup.toFixed(2)),
180
+ maxConcurrent: cap,
181
+ independentCount,
182
+ conflicts,
183
+ reasons,
184
+ errors: [],
185
+ };
186
+ }
187
+
188
+ /**
189
+ * The join contract (W3.2). Kept as data so a test can assert the queue is
190
+ * single-lane and the suite is not skippable, rather than trusting prose.
191
+ */
192
+ export const JOIN_STEPS = Object.freeze([
193
+ "fetch every branch and the current main",
194
+ "rebase the branch onto the updated main",
195
+ "read each diff against its package's DoD — the orchestrator, never a thread",
196
+ "run the FULL suite in the rebased state (unit + integration + E2E)",
197
+ "fast-forward merge only if every check passed",
198
+ "resolve each ticket with its own summary — only after the suite is green",
199
+ ]);
200
+
201
+ export const JOIN_FORBIDDEN = Object.freeze([
202
+ "an LLM resolving a merge conflict — re-read the two intents by hand, or stop and ask",
203
+ "merging without the full suite in the rebased state",
204
+ "two joins in flight at once",
205
+ "a thread resolving its own ticket or writing its own summary",
206
+ "a thread editing outside its declared file partition",
207
+ ]);
208
+
209
+ /**
210
+ * The parallelism switch (P47). Default OFF, and staying off is the safe reading:
211
+ * parallelism buys elapsed time and costs tokens, and a wrong "yes" costs both. It is
212
+ * a config value, not a dependency — ASF must keep working with no orchestration
213
+ * plugin installed, so nothing here imports one.
214
+ */
215
+ export const PARALLEL_DEFAULT = false;
216
+ export const MAX_CONCURRENT_CAP = 4; // this box has 4 cores — more threads than cores is just a slower queue
217
+
218
+ export function readParallelSwitch({ config = {}, env = {} } = {}) {
219
+ const configured = config?.parallel?.enabled === true;
220
+ const oneRun = /^(1|true|yes)$/i.test(String(env.ASF_PARALLEL ?? ""));
221
+ const enabled = configured || oneRun;
222
+ const requested = Number(config?.parallel?.maxConcurrent ?? 3);
223
+ const maxConcurrent = Math.max(1, Math.min(MAX_CONCURRENT_CAP, Number.isFinite(requested) ? requested : 3));
224
+ return {
225
+ enabled,
226
+ maxConcurrent,
227
+ reason: enabled
228
+ ? oneRun && !configured
229
+ ? "enabled for this run only by ASF_PARALLEL — the override does not persist"
230
+ : `enabled by config parallel.enabled, capped at ${maxConcurrent} concurrent`
231
+ : "default OFF — single-agent is the default; enable with parallel.enabled, or ASF_PARALLEL=1 for one run",
232
+ };
233
+ }
234
+
235
+ export function planJoin({ branches = [] }) {
236
+ return {
237
+ order: branches.map((b, i) => ({ branch: b, lane: 0, step: i + 1, steps: JOIN_STEPS })),
238
+ singleLane: true,
239
+ forbidden: JOIN_FORBIDDEN,
240
+ };
241
+ }
package/lib/review.mjs ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Review guards (W1.2, W1.3, W1.6, W3.3).
3
+ *
4
+ * These are the rules that decide whether a review actually happened, so they are
5
+ * code and not prose: a rule about trustworthiness that lives only in markdown is a
6
+ * rule an agent can satisfy by nodding (06b Rule 20's argument, applied to review).
7
+ *
8
+ * The failure this exists for is observed, not hypothetical: a read-only subagent
9
+ * asked to report from a file that did not exist returned a confident reading of a
10
+ * *different* file and exited 0. Exit status and confidence are both cheap. A citation
11
+ * that resolves is not.
12
+ */
13
+
14
+ export const REVIEW_DEFAULTS = Object.freeze({
15
+ maxReviewers: 6,
16
+ abForBlockers: true,
17
+ maxReaudits: 2,
18
+ maxConcurrent: 3,
19
+ tokenCeilingMultiple: 6,
20
+ });
21
+
22
+ export const LENSES = Object.freeze([
23
+ "asf-design-critic",
24
+ "asf-codebase-fitness",
25
+ "asf-zoom-out",
26
+ "asf-comprehension",
27
+ "asf-implementation-readiness",
28
+ "asf-structural",
29
+ ]);
30
+
31
+ export const SEVERITIES = Object.freeze(["Blocker", "Major", "Nit"]);
32
+
33
+ const FINDING = /^###\s+(.+?)\s+—\s+(Blocker|Major|Nit)\s*$/im;
34
+ const SCOPE = /\*\*Scope:\*\*\s*`?([^`\n]+?)`?\s*$/m;
35
+
36
+ /**
37
+ * Resolve every `path` or `path:lines` a reviewer cited.
38
+ * A citation that does not exist, or a line range outside the file, means the
39
+ * reviewer cannot have read it: the report is rejected and recorded `not run`.
40
+ */
41
+ export function verifyCitations({ findings = [], repoRoot, fs, minCoverage = 1 }) {
42
+ const accepted = [];
43
+ const rejected = [];
44
+ const existsCache = new Map();
45
+ const readCache = new Map();
46
+
47
+ const fileOf = (scope) => {
48
+ const m = /^\s*(?:`?)([^\s:`]+)((?::\d+(?:-\d+)?)?)`?/.exec(scope || "");
49
+ if (!m) return null;
50
+ return { file: m[1], from: m[2] ? Number(m[2].slice(1).split("-")[0]) : null, to: m[2] && m[2].includes("-") ? Number(m[2].split("-")[1]) : null };
51
+ };
52
+
53
+ for (const f of findings) {
54
+ const cite = fileOf(f.scope);
55
+ if (!cite || !cite.file) {
56
+ rejected.push({ ...f, reason: "no resolvable citation in the scope" });
57
+ continue;
58
+ }
59
+ const abs = `${repoRoot}/${cite.file.replace(/^\.\//, "")}`;
60
+ let ok = existsCache.get(abs);
61
+ if (ok === undefined) {
62
+ ok = fs.existsSync(abs);
63
+ existsCache.set(abs, ok);
64
+ }
65
+ if (!ok) {
66
+ rejected.push({ ...f, reason: `cited file does not exist: ${cite.file}` });
67
+ continue;
68
+ }
69
+ if (cite.from) {
70
+ let lines = readCache.get(abs);
71
+ if (!lines) {
72
+ lines = fs.readFileSync(abs, "utf8").split("\n").length;
73
+ readCache.set(abs, lines);
74
+ }
75
+ if (cite.from > lines || (cite.to && cite.to > lines)) {
76
+ rejected.push({ ...f, reason: `cited range ${cite.file}:${cite.from}-${cite.to ?? cite.from} is outside the file (${lines} lines)` });
77
+ continue;
78
+ }
79
+ }
80
+ accepted.push(f);
81
+ }
82
+
83
+ const coverage = findings.length ? accepted.length / findings.length : 0;
84
+ return {
85
+ accepted,
86
+ rejected,
87
+ coverage: Number(coverage.toFixed(2)),
88
+ // A report where nothing resolves is not a clean review — it is an absent one.
89
+ status: findings.length === 0 ? "no-findings-submitted" : accepted.length === 0 ? "not run" : coverage < minCoverage ? "partial" : "completed",
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Per-reviewer status. A lens that timed out, crashed, returned nothing, or had every
95
+ * citation rejected is `not run` — never "zero findings".
96
+ */
97
+ export function reviewerStatus({ results = [] }) {
98
+ const rows = [];
99
+ for (const r of results) {
100
+ let status;
101
+ if (r.error || r.timedOut) status = "not run";
102
+ else if (!r.output || !String(r.output).trim()) status = "not run";
103
+ else if (r.citationCheck && r.citationCheck.status === "not run") status = "not run";
104
+ else status = "completed";
105
+ rows.push({ lens: r.lens, status, findings: status === "completed" ? (r.citationCheck?.accepted.length ?? 0) : 0 });
106
+ }
107
+ return {
108
+ rows,
109
+ completed: rows.filter((r) => r.status === "completed").length,
110
+ notRun: rows.filter((r) => r.status === "not run").map((r) => r.lens),
111
+ render() {
112
+ return ["| Reviewer | Status |", "| --- | --- |", ...rows.map((r) => `| \`${r.lens}\` | ${r.status} |`)].join("\n");
113
+ },
114
+ };
115
+ }
116
+
117
+ /** Findings must carry severity, scope and a recommendation — and merge across lenses. */
118
+ export function normalizeFindings(list = []) {
119
+ const out = [];
120
+ const seen = new Set();
121
+ const invalid = [];
122
+ for (const f of list) {
123
+ if (!SEVERITIES.includes(f.severity) || !f.scope || !f.recommendation) {
124
+ invalid.push(f);
125
+ continue;
126
+ }
127
+ const key = `${f.severity}|${String(f.scope).replace(/\s+/g, " ")}|${String(f.finding).toLowerCase().replace(/\W+/g, " ").trim()}`;
128
+ if (seen.has(key)) continue;
129
+ seen.add(key);
130
+ out.push(f);
131
+ }
132
+ const rank = { Blocker: 0, Major: 1, Nit: 2 };
133
+ out.sort((a, b) => rank[a.severity] - rank[b.severity]);
134
+ return { findings: out, rejected: invalid };
135
+ }
136
+
137
+ /**
138
+ * A/B duplication lifts recall, and recall is the expensive property — it is bought at
139
+ * the blocker boundary, nowhere else.
140
+ */
141
+ export function abAllowed({ phase, severities = [], config = REVIEW_DEFAULTS }) {
142
+ if (!config.abForBlockers) return { allowed: false, reason: "review.abForBlockers is off" };
143
+ if (phase !== "blocker-rereview") return { allowed: false, reason: `A/B is only for a blocker re-review, not "${phase}"` };
144
+ if (!severities.includes("Blocker")) return { allowed: false, reason: "no blocker was reported, so there is nothing to re-review" };
145
+ return { allowed: true, reason: "blocker boundary — two instances of the lens to lift recall" };
146
+ }
147
+
148
+ export { FINDING, SCOPE };