pi-aia-asf 0.13.4 → 0.14.0
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 +92 -0
- package/lib/gates/drift.mjs +159 -0
- package/lib/parallel-estimate.mjs +241 -0
- package/lib/review.mjs +148 -0
- package/lib/skill-budget.mjs +133 -0
- package/package.json +3 -2
- package/skills/aia-asf/SKILL.md +110 -184
- package/skills/aia-asf/agents/codebase-fitness.md +60 -0
- package/skills/aia-asf/agents/comprehension.md +60 -0
- package/skills/aia-asf/agents/design-critic.md +60 -0
- package/skills/aia-asf/agents/implementation-readiness.md +60 -0
- package/skills/aia-asf/agents/structural.md +60 -0
- package/skills/aia-asf/agents/synthesizer.md +72 -0
- package/skills/aia-asf/agents/zoom-out.md +60 -0
- package/skills/aia-asf/references/04-adversarial.md +70 -0
- package/skills/aia-asf/references/05-plan.md +20 -0
- package/skills/aia-asf/references/06b-appendix.md +96 -0
- package/skills/aia-asf/references/06b-testing-qa.md +21 -103
- package/skills/aia-asf/references/06d-delegation.md +69 -0
- package/skills/aia-asf/references/06i-completeness.md +32 -0
- package/skills/aia-asf/references/06k-performance.md +35 -1
- package/skills/aia-asf/references/06m-antipatterns.md +74 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,98 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.14.0] - 2026-11-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Zero-context reviewer pass** (Phase 4 and Phase 7, large work): six read-only reviewer
|
|
15
|
+
personas in `skills/aia-asf/agents/` (`design-critic`, `codebase-fitness`, `zoom-out`,
|
|
16
|
+
`comprehension`, `implementation-readiness`, `structural`) plus a `synthesizer`, each
|
|
17
|
+
invoked separately, each answering in a fixed finding format (`Blocker | Major | Nit`,
|
|
18
|
+
scope, rationale, recommendation), critique-first and cite-or-abstain. Reviewers are
|
|
19
|
+
given the artifact and the acceptance criteria only — never the conversation that
|
|
20
|
+
produced it.
|
|
21
|
+
- `lib/review.mjs` — the review guards as code rather than as prose: citation verification
|
|
22
|
+
(a report whose cited file or line range does not resolve is recorded `not run`, **never**
|
|
23
|
+
"zero findings"), the per-reviewer status table, the finding schema with dedup and
|
|
24
|
+
severity ordering, and A/B duplication permitted only at the blocker boundary. The
|
|
25
|
+
citation guard exists because a read-only subagent was observed reporting a confident
|
|
26
|
+
reading of a file that was never asked for, and exiting 0.
|
|
27
|
+
- `lib/parallel-estimate.mjs` — the serial/parallel decision computed from file ownership,
|
|
28
|
+
interface ownership, dependency cycles, serial cost and a bounded schedule. Parallel
|
|
29
|
+
requires no conflicts, at least 3 fully independent packages and a speed-up of 1.3×; a
|
|
30
|
+
job under 30 serial minutes is refused outright, and a refusal is a recorded decision
|
|
31
|
+
rather than an omission. Also carries the P47 switch (`readParallelSwitch`: default OFF,
|
|
32
|
+
`ASF_PARALLEL=1` for one run that does not persist) and the join contract: one lane, the
|
|
33
|
+
full suite after the rebase, no merge conflict resolved by a model, no thread resolving
|
|
34
|
+
its own ticket.
|
|
35
|
+
- `lib/gates/drift.mjs` — task-scoped drift: the claims this task's own artifacts make
|
|
36
|
+
about files and symbols are resolved against the code. `no-claims` and `no-artifacts`
|
|
37
|
+
are reported as examined-nothing, never as clean, and only a stale claim blocks.
|
|
38
|
+
- `scripts/audit-docs.mjs` — ASF audits its own normative content: relative links, anchors,
|
|
39
|
+
index/directory agreement, routing ("Read when" or an index row) and frontmatter.
|
|
40
|
+
- `lib/skill-budget.mjs` + `test/test-skill-budget.mjs` — an always-read byte ceiling with a
|
|
41
|
+
rule-preservation check: procedure, tables and examples may move out of the always-read
|
|
42
|
+
files; a rule statement may not, and a split that drops one fails the suite.
|
|
43
|
+
- `references/06m-antipatterns.md` (the complete forbidden list, with a short always-read
|
|
44
|
+
floor left in `SKILL.md`) and `references/06b-appendix.md` (the Rule 9 browser procedure
|
|
45
|
+
and Rule 15 enforcement-class detail).
|
|
46
|
+
- `references/05-plan.md` — a work-package table (files owned, interfaces needed and
|
|
47
|
+
provided, depends on, estimated minutes) and a required parallel verdict with its reason,
|
|
48
|
+
recorded even when the answer is serial.
|
|
49
|
+
- Rebuilt `test/test-antipatterns.mjs`, `test/test-parallel-switch.mjs` and
|
|
50
|
+
`test/test-operator-surface.mjs` — these had been in the working tree, were never
|
|
51
|
+
committed, and are gone; they are rewritten against the modules they guard. Added
|
|
52
|
+
`test/test-review-guards.mjs`, `test/test-docs-integrity.mjs`,
|
|
53
|
+
`test/test-skill-budget.mjs`, and task-scoped drift coverage in
|
|
54
|
+
`test/test-improvements.mjs`. Every new guard ships with a control that must fail.
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
58
|
+
- The always-read set is 74,777 bytes across `SKILL.md` and the three mandatory references,
|
|
59
|
+
down from 88,515 (−15.5%), with the per-file ceilings and their reasons recorded in
|
|
60
|
+
`lib/skill-budget.mjs` rather than as a bare number. `SKILL.md` itself went 36,931 →
|
|
61
|
+
25,689 bytes; the reduction is duplicated procedure and the forbidden list, not rules.
|
|
62
|
+
- Phase 5 presents `PLAN.md` as an artifact (path plus decision summary) instead of pasting
|
|
63
|
+
the plan into chat; Phase 4 now states a reviewer budget (6 reviewers, 2 re-audits, 3
|
|
64
|
+
concurrent, stop and report at 6× the serial token estimate) so thoroughness is bounded
|
|
65
|
+
without being thin.
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- `references/06k-performance.md` linked to a research document that is not in the package —
|
|
70
|
+
a dangling link that no test could see until the doc audit was added.
|
|
71
|
+
|
|
72
|
+
## [0.13.5] - 2026-10-18
|
|
73
|
+
|
|
74
|
+
### Added
|
|
75
|
+
|
|
76
|
+
- **`test/test-packaging.mjs` — the artifact gate.** Checks the **packed** file set
|
|
77
|
+
rather than the working tree: `index.ts`, the `skills/aia-asf` skill, its normative
|
|
78
|
+
reference files, the `lib/` modules and the declared `obligations.d/` all really ship;
|
|
79
|
+
every entry `files` allowlists is present in the tarball; `pi.extensions` and
|
|
80
|
+
`pi.skills` point at entries that ship; and no relative import inside the artifact
|
|
81
|
+
points outside it. Nothing could previously see a broken `files` allowlist —
|
|
82
|
+
typecheck reads source files, the suite imports from disk, and `npm publish` only
|
|
83
|
+
reports that the upload was accepted. An ASF release whose skill did not ship is an
|
|
84
|
+
empty deliverable that installs without error.
|
|
85
|
+
- **`scripts/verify-published.mjs` — post-publish verification of the published bytes.**
|
|
86
|
+
Installs the just-published version **from the registry** into a throwaway prefix
|
|
87
|
+
(with the peer deps the pi host provides), imports the installed extension, requires
|
|
88
|
+
its default export to be the activate function, and requires the skill and
|
|
89
|
+
`obligations.d/` to be present in the installed copy. Reachable as
|
|
90
|
+
`npm run verify:published`; `BMX_SKIP_PUBLISHED_VERIFY=1` skips it in the release.
|
|
91
|
+
- **`scripts/release.sh` now gates instead of announcing.** It used to print
|
|
92
|
+
"✅ Released" on the strength of `npm publish`'s exit code alone. It now polls the
|
|
93
|
+
registry until the version is listed **and its tarball is fetchable**, then runs the
|
|
94
|
+
published-artifact verification, and exits non-zero if either fails.
|
|
95
|
+
|
|
96
|
+
### Notes
|
|
97
|
+
|
|
98
|
+
- The import resolver in the artifact gate follows the ESM declaration convention, so a
|
|
99
|
+
`.d.mts` file naming its sibling as `./x.d.mjs` is not reported as a missing module
|
|
100
|
+
(`lib/gates/` ships `.mts` + `.d.mts` pairs).
|
|
101
|
+
|
|
10
102
|
## [0.13.4] - 2026-10-16
|
|
11
103
|
|
|
12
104
|
### Fixed
|
|
@@ -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 };
|