@mmerterden/multi-agent-pipeline 20.15.0 → 20.16.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/docs/facts.json +5 -4
  3. package/install/catalog-history.json +1 -1
  4. package/install/claude.mjs +5 -1
  5. package/install/codex.mjs +4 -3
  6. package/manifest.json +36 -30
  7. package/package.json +1 -1
  8. package/pipeline/commands/multi-agent/review/SKILL.md +42 -1
  9. package/pipeline/multi-agent-refs/features/review-decision.md +2 -1
  10. package/pipeline/multi-agent-refs/features/valven.md +83 -0
  11. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -0
  12. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -0
  13. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +2 -4
  14. package/pipeline/multi-agent-refs/phases/phase-3-review.md +5 -5
  15. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +1 -0
  16. package/pipeline/schemas/agent-state.schema.json +16 -0
  17. package/pipeline/schemas/prefs.schema.json +21 -0
  18. package/pipeline/scripts/gen-skills-index.mjs +2 -1
  19. package/pipeline/scripts/phase-tracker.sh +8 -0
  20. package/pipeline/scripts/pre-push-check.sh +3 -1
  21. package/pipeline/scripts/review-decision-gate.mjs +1 -1
  22. package/pipeline/scripts/uninstall.mjs +5 -1
  23. package/pipeline/scripts/valven-gate.mjs +788 -0
  24. package/pipeline/scripts/valven-port.mjs +169 -0
  25. package/pipeline/scripts/valven-split.mjs +341 -0
  26. package/pipeline/scripts/verify-citations.mjs +14 -1
  27. package/pipeline/skills/.skill-manifest.json +7 -3
  28. package/pipeline/skills/.skills-index.json +104 -71
  29. package/pipeline/skills/shared/README.md +4 -3
  30. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +2 -0
  31. package/pipeline/skills/shared/core/valven-rules/SKILL.md +62 -0
  32. package/pipeline/skills/shared/core/valven-rules/references/rules.yml +140 -0
  33. package/pipeline/skills/skills-index.md +74 -71
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @file valven-port.mjs - carry a branch's net change onto a fresh branch cut
5
+ * from the current tip of its base (valven-rules VLV-AGE-01, VLV-BEHIND-01).
6
+ *
7
+ * Usage:
8
+ * valven-port.mjs --base <ref> [--repo <dir>] [--head <ref>] [--branch <name>]
9
+ * [--to <new-branch>] [--subject <commit subject>] [--apply] [--out <file>]
10
+ *
11
+ * Without --apply it reports what would move: the merge-base, how far the base
12
+ * has moved since, the changed paths, and the paths the base also touched (the
13
+ * ones a 3-way apply may have to merge). Nothing changes.
14
+ *
15
+ * With --apply it creates `--to` (default `<branch>-v2`, or the next free
16
+ * `-vN`) at the tip of --base, applies the diff merge-base..head with
17
+ * `git apply --3way --index`, commits it as one commit and returns to the
18
+ * branch it started on. Old commit history does not move: the new branch holds
19
+ * the change, not the path that led to it. On a conflict nothing is left
20
+ * behind: the new branch is deleted, the conflicted paths are reported, and
21
+ * the exit code is 1. Nothing is pushed.
22
+ *
23
+ * Exit codes:
24
+ * 0 - plan printed, or applied cleanly
25
+ * 1 - applied with conflicts; rolled back, `conflicts[]` names the paths
26
+ * 2 - setup error: bad arguments, dirty worktree on --apply, git failure, empty change
27
+ *
28
+ * @module pipeline/scripts/valven-port
29
+ */
30
+
31
+ import { execFileSync, spawnSync } from "node:child_process";
32
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
33
+ import { join } from "node:path";
34
+ import { tmpdir } from "node:os";
35
+ import { runMain } from "../lib/fatal.mjs";
36
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
37
+
38
+ function git(repo, args) {
39
+ return execFileSync("git", ["-C", repo, ...args], {
40
+ encoding: "utf-8",
41
+ maxBuffer: 256 * 1024 * 1024,
42
+ stdio: ["ignore", "pipe", "pipe"],
43
+ });
44
+ }
45
+
46
+ /** `feature/X-1-login` -> `feature/X-1-login-v2`; `...-v2` -> `...-v3`. */
47
+ export function nextName(branch, exists) {
48
+ const m = /^(.*)-v(\d+)$/.exec(branch);
49
+ const stem = m ? m[1] : branch;
50
+ let n = m ? Number(m[2]) + 1 : 2;
51
+ while (exists(`${stem}-v${n}`)) n++;
52
+ return `${stem}-v${n}`;
53
+ }
54
+
55
+ function parseArgs(argv) {
56
+ const args = {};
57
+ for (let i = 0; i < argv.length; i++) {
58
+ const a = argv[i];
59
+ if (a === "--apply") {
60
+ args.apply = true;
61
+ continue;
62
+ }
63
+ if (!a.startsWith("--")) throw new Error(`unexpected argument ${a}`);
64
+ const val = argv[i + 1];
65
+ if (val === undefined || val.startsWith("--")) throw new Error(`${a} needs a value`);
66
+ args[a.slice(2)] = val;
67
+ i++;
68
+ }
69
+ return args;
70
+ }
71
+
72
+ function fail(msg) {
73
+ process.stderr.write(`valven-port: ${msg}\n`);
74
+ process.exitCode = 2;
75
+ }
76
+
77
+ const lines = (out) => out.split("\n").filter(Boolean);
78
+
79
+ async function main() {
80
+ let args;
81
+ try {
82
+ args = parseArgs(process.argv.slice(2));
83
+ } catch (err) {
84
+ return fail(err.message);
85
+ }
86
+ if (!args.base) return fail("--base is required");
87
+ const repo = args.repo ?? process.cwd();
88
+ const head = args.head ?? "HEAD";
89
+
90
+ let plan;
91
+ try {
92
+ const mergeBase = git(repo, ["merge-base", args.base, head]).trim();
93
+ const headSha = git(repo, ["rev-parse", head]).trim();
94
+ const baseSha = git(repo, ["rev-parse", args.base]).trim();
95
+ const branch = args.branch ?? git(repo, ["rev-parse", "--abbrev-ref", head]).trim();
96
+ const changed = lines(git(repo, ["diff", "--name-only", "-M", mergeBase, headSha]));
97
+ if (!changed.length) return fail(`no change between ${args.base} and ${head}`);
98
+ const baseTouched = new Set(lines(git(repo, ["diff", "--name-only", mergeBase, baseSha])));
99
+ const exists = (name) => git(repo, ["branch", "--list", name]).trim() !== "";
100
+ const to = args.to ?? nextName(branch, exists);
101
+ const subject =
102
+ args.subject ??
103
+ lines(
104
+ git(repo, ["log", "--no-merges", "--reverse", "--format=%s", `${mergeBase}..${headSha}`]),
105
+ )[0] ??
106
+ branch;
107
+ plan = {
108
+ branch,
109
+ to,
110
+ base: args.base,
111
+ baseSha,
112
+ mergeBase,
113
+ head: headSha,
114
+ behind: Number(git(repo, ["rev-list", "--count", `${headSha}..${baseSha}`]).trim()),
115
+ commitsDropped: Number(
116
+ git(repo, ["rev-list", "--count", "--no-merges", `${mergeBase}..${headSha}`]).trim(),
117
+ ),
118
+ subject,
119
+ changedPaths: changed,
120
+ sharedWithBase: changed.filter((p) => baseTouched.has(p)),
121
+ applied: false,
122
+ };
123
+ } catch (err) {
124
+ return fail(`git failed: ${String(err.stderr || err.message).trim()}`);
125
+ }
126
+
127
+ if (args.apply) {
128
+ if (git(repo, ["status", "--porcelain"]).trim()) return fail("worktree is not clean");
129
+ if (git(repo, ["branch", "--list", plan.to]).trim())
130
+ return fail(`branch ${plan.to} already exists`);
131
+ const original = git(repo, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
132
+ const back = original === "HEAD" ? plan.head : original;
133
+ const tmp = mkdtempSync(join(tmpdir(), "valven-port-"));
134
+ const patch = join(tmp, "change.patch");
135
+ try {
136
+ writeFileSync(patch, git(repo, ["diff", "--binary", "-M", plan.mergeBase, plan.head]));
137
+ git(repo, ["checkout", "-q", "-b", plan.to, plan.baseSha]);
138
+ const r = spawnSync("git", ["-C", repo, "apply", "--3way", "--index", patch], {
139
+ encoding: "utf-8",
140
+ });
141
+ if (r.status !== 0) {
142
+ const unmerged = lines(git(repo, ["diff", "--name-only", "--diff-filter=U"]));
143
+ plan.conflicts = unmerged.length ? unmerged : lines(r.stderr).slice(0, 20);
144
+ git(repo, ["reset", "-q", "--hard"]);
145
+ git(repo, ["checkout", "-q", back]);
146
+ git(repo, ["branch", "-q", "-D", plan.to]);
147
+ process.exitCode = 1;
148
+ } else {
149
+ git(repo, ["commit", "-q", "-m", plan.subject]);
150
+ plan.applied = true;
151
+ plan.portedSha = git(repo, ["rev-parse", "HEAD"]).trim();
152
+ plan.differsFromHead = lines(
153
+ git(repo, ["diff", "--name-only", plan.head, "HEAD", "--", ...plan.changedPaths]),
154
+ );
155
+ git(repo, ["checkout", "-q", back]);
156
+ }
157
+ } catch (err) {
158
+ return fail(`git failed while applying: ${String(err.stderr || err.message).trim()}`);
159
+ } finally {
160
+ rmSync(tmp, { recursive: true, force: true });
161
+ }
162
+ }
163
+
164
+ const json = `${JSON.stringify(plan, null, 2)}\n`;
165
+ if (args.out) writeFileSync(args.out, json);
166
+ process.stdout.write(json);
167
+ }
168
+
169
+ if (invokedDirectly(import.meta.url)) runMain("valven-port", main, { code: 2 });
@@ -0,0 +1,341 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @file valven-split.mjs - split one branch into stacked pull requests that
5
+ * each fit the valven-rules size budget (VLV-SIZE-01, VLV-SIZE-02, VLV-SCOPE-01).
6
+ *
7
+ * Usage:
8
+ * valven-split.mjs --base <ref> [--repo <dir>] [--head <ref>] [--branch <name>]
9
+ * [--budget <lines>] [--max-files <n>] [--rules <rules.yml>]
10
+ * [--prefs <prefs.json>] [--apply] [--out <file>]
11
+ *
12
+ * Without --apply it prints the plan and changes nothing. With --apply it
13
+ * creates `<branch>-p1` .. `<branch>-pN`: p1 from the merge-base, each next one
14
+ * from the previous, one commit per slice, then checks that the top of the
15
+ * stack has exactly the tree of the original branch and returns to it. Nothing
16
+ * is pushed.
17
+ *
18
+ * Slicing:
19
+ * 1. Pure renames and moves form the first slice.
20
+ * 2. Every test file travels with the source file it pairs with by name.
21
+ * 3. Groups are ordered by layer: data (models, services, repositories), then
22
+ * domain (view models, use cases, state), then UI, then everything else.
23
+ * 4. Groups are packed greedily up to the budget. A group larger than the
24
+ * budget becomes its own slice and is marked `oversized`; it is never cut.
25
+ * 5. Generated output, lockfiles and snapshots (review-file-exclusions.json)
26
+ * count toward no budget and ride with the first content slice, since the
27
+ * code in every later slice may compile against them.
28
+ *
29
+ * Exit codes:
30
+ * 0 - plan printed, or applied and the stack's top matches the branch
31
+ * 1 - applied, but the top of the stack differs from the branch (left in place for inspection)
32
+ * 2 - setup error: bad arguments, dirty worktree on --apply, git failure, a target branch already exists
33
+ *
34
+ * @module pipeline/scripts/valven-split
35
+ */
36
+
37
+ import { execFileSync } from "node:child_process";
38
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
39
+ import { join } from "node:path";
40
+ import { loadPatterns, partition } from "./review-file-filter.mjs";
41
+ import {
42
+ applyOverrides,
43
+ isPureRename,
44
+ isTestPath,
45
+ parseGateRules,
46
+ parseNumstatZ,
47
+ rulesCandidates,
48
+ } from "./valven-gate.mjs";
49
+ import { runMain } from "../lib/fatal.mjs";
50
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
51
+ import { prefsPath } from "../lib/user-facing.mjs";
52
+
53
+ const DEFAULT_PATTERNS = join(import.meta.dirname, "..", "schemas", "review-file-exclusions.json");
54
+
55
+ const LAYERS = [
56
+ {
57
+ name: "data",
58
+ re: /(model|entity|dto|service|repository|repo\b|network|api|endpoint|mapper|schema|migration|request|response|client|datasource|dao)/i,
59
+ },
60
+ {
61
+ name: "domain",
62
+ re: /(viewmodel|view_model|usecase|use_case|interactor|presenter|store|reducer|state|coordinator|router|formstate)/i,
63
+ },
64
+ {
65
+ name: "ui",
66
+ re: /(view|screen|scene|component|composable|fragment|activity|controller|cell|sheet|\/ui\/)/i,
67
+ },
68
+ ];
69
+
70
+ /**
71
+ * Match order differs from rank order: `ViewModel` contains both `model` and
72
+ * `view`, so the domain pattern is tried first.
73
+ */
74
+ const MATCH_ORDER = [1, 0, 2];
75
+
76
+ /** Layer rank of a path: 0 data, 1 domain, 2 ui, 3 other. File name first, then the path. */
77
+ export function layerOf(path) {
78
+ const name = path.split("/").pop() ?? path;
79
+ for (const subject of [name, path]) {
80
+ for (const i of MATCH_ORDER) {
81
+ if (LAYERS[i].re.test(subject)) return i;
82
+ }
83
+ }
84
+ return LAYERS.length;
85
+ }
86
+
87
+ export const LAYER_NAMES = [...LAYERS.map((l) => l.name), "other"];
88
+
89
+ /** File stem without the test suffix: `LoginViewModelTests.swift` -> `LoginViewModel`. */
90
+ export function stemOf(path) {
91
+ const file = path.split("/").pop() ?? path;
92
+ return file
93
+ .replace(/\.[^.]+$/, "")
94
+ .replace(/(\.test|\.spec|_test|Tests|Test|Spec)$/, "")
95
+ .toLowerCase();
96
+ }
97
+
98
+ const churn = (f) => f.added + f.deleted;
99
+
100
+ /**
101
+ * Build the slice plan. Pure.
102
+ *
103
+ * @param {{path:string, oldPath:string|null, added:number, deleted:number}[]} files
104
+ * @param {{budget:number, maxFiles:number, patterns:object[]}} opts
105
+ */
106
+ export function planSlices(files, { budget, maxFiles, patterns }) {
107
+ const { reviewed } = partition(
108
+ files.map((f) => f.path),
109
+ patterns,
110
+ );
111
+ const keep = new Set(reviewed);
112
+ const human = files.filter((f) => keep.has(f.path));
113
+ const riders = files.filter((f) => !keep.has(f.path));
114
+
115
+ const renames = human.filter(isPureRename);
116
+ const content = human.filter((f) => !isPureRename(f));
117
+
118
+ const tests = content.filter((f) => isTestPath(f.path));
119
+ const sources = content.filter((f) => !isTestPath(f.path));
120
+ const groups = sources.map((f) => ({ files: [f], layer: layerOf(f.path), key: f.path }));
121
+ const byStem = new Map();
122
+ for (const g of groups) {
123
+ const s = stemOf(g.key);
124
+ if (!byStem.has(s)) byStem.set(s, g);
125
+ }
126
+ for (const t of tests) {
127
+ const g = byStem.get(stemOf(t.path));
128
+ if (g) g.files.push(t);
129
+ else groups.push({ files: [t], layer: layerOf(t.path), key: t.path });
130
+ }
131
+ groups.sort((a, b) => a.layer - b.layer || a.key.localeCompare(b.key));
132
+
133
+ const slices = [];
134
+ if (renames.length) {
135
+ slices.push({ kind: "renames", files: renames, lines: 0, layers: [], oversized: false });
136
+ }
137
+ let cur = null;
138
+ const close = () => {
139
+ if (cur && cur.files.length) slices.push(cur);
140
+ cur = null;
141
+ };
142
+ for (const g of groups) {
143
+ const lines = g.files.reduce((n, f) => n + churn(f), 0);
144
+ const layer = LAYER_NAMES[g.layer];
145
+ if (lines > budget || g.files.length > maxFiles) {
146
+ close();
147
+ slices.push({ kind: "content", files: g.files, lines, layers: [layer], oversized: true });
148
+ continue;
149
+ }
150
+ if (cur && (cur.lines + lines > budget || cur.files.length + g.files.length > maxFiles))
151
+ close();
152
+ if (!cur) cur = { kind: "content", files: [], lines: 0, layers: [], oversized: false };
153
+ cur.files.push(...g.files);
154
+ cur.lines += lines;
155
+ if (!cur.layers.includes(layer)) cur.layers.push(layer);
156
+ }
157
+ close();
158
+
159
+ if (riders.length) {
160
+ const target = slices.find((s) => s.kind === "content") ?? slices[0];
161
+ if (target) target.riders = riders;
162
+ else
163
+ slices.push({ kind: "generated", files: [], riders, lines: 0, layers: [], oversized: false });
164
+ }
165
+ return slices.map((s, i) => ({ index: i + 1, riders: [], ...s }));
166
+ }
167
+
168
+ /** Subject for slice k of n, keeping a trailing `[KEY-1]` at the end. */
169
+ export function sliceSubject(base, k, n) {
170
+ const m = /^(.*?)(\s*\[[A-Z][A-Z0-9]+-\d+\])\s*$/.exec(base);
171
+ return m ? `${m[1]} (${k}/${n})${m[2]}` : `${base} (${k}/${n})`;
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // I/O
176
+ // ---------------------------------------------------------------------------
177
+
178
+ function git(repo, args) {
179
+ return execFileSync("git", ["-C", repo, ...args], {
180
+ encoding: "utf-8",
181
+ maxBuffer: 64 * 1024 * 1024,
182
+ stdio: ["ignore", "pipe", "pipe"],
183
+ });
184
+ }
185
+
186
+ function parseArgs(argv) {
187
+ const args = {};
188
+ for (let i = 0; i < argv.length; i++) {
189
+ const a = argv[i];
190
+ if (a === "--apply") {
191
+ args.apply = true;
192
+ continue;
193
+ }
194
+ if (!a.startsWith("--")) throw new Error(`unexpected argument ${a}`);
195
+ const val = argv[i + 1];
196
+ if (val === undefined || val.startsWith("--")) throw new Error(`${a} needs a value`);
197
+ args[a.slice(2)] = val;
198
+ i++;
199
+ }
200
+ return args;
201
+ }
202
+
203
+ function fail(msg) {
204
+ process.stderr.write(`valven-split: ${msg}\n`);
205
+ process.exitCode = 2;
206
+ }
207
+
208
+ function readThresholds(args) {
209
+ let overrides;
210
+ try {
211
+ overrides =
212
+ JSON.parse(readFileSync(args.prefs ?? prefsPath(), "utf-8"))?.global?.valven?.thresholds ??
213
+ {};
214
+ } catch {
215
+ overrides = {};
216
+ }
217
+ const rulesPath = args.rules ?? rulesCandidates().find((p) => existsSync(p));
218
+ const rules =
219
+ rulesPath && existsSync(rulesPath)
220
+ ? applyOverrides(parseGateRules(readFileSync(rulesPath, "utf-8")).rules, overrides)
221
+ : [];
222
+ const size = rules.find((r) => r.id === "VLV-SIZE-01");
223
+ const files = rules.find((r) => r.id === "VLV-SIZE-02");
224
+ return {
225
+ budget: Number(args.budget ?? size?.important_over ?? 250),
226
+ maxFiles: Number(args["max-files"] ?? files?.important_over ?? 25),
227
+ };
228
+ }
229
+
230
+ function applySlice(repo, head, slice) {
231
+ const touch = [...slice.files, ...slice.riders];
232
+ for (const f of touch) {
233
+ if (f.oldPath && f.oldPath !== f.path)
234
+ git(repo, ["rm", "-q", "--cached", "--ignore-unmatch", "--", f.oldPath]);
235
+ if (f.deletedFile) git(repo, ["rm", "-q", "--cached", "--ignore-unmatch", "--", f.path]);
236
+ else git(repo, ["checkout", head, "--", f.path]);
237
+ }
238
+ git(repo, ["checkout", "--", "."]);
239
+ git(repo, ["clean", "-fdq"]);
240
+ }
241
+
242
+ async function main() {
243
+ let args;
244
+ try {
245
+ args = parseArgs(process.argv.slice(2));
246
+ } catch (err) {
247
+ return fail(err.message);
248
+ }
249
+ if (!args.base) return fail("--base is required");
250
+ const repo = args.repo ?? process.cwd();
251
+ const head = args.head ?? "HEAD";
252
+ const { budget, maxFiles } = readThresholds(args);
253
+ if (!(budget > 0) || !(maxFiles > 0))
254
+ return fail("budget and max-files must be positive numbers");
255
+ const { patterns, error } = loadPatterns(args.patterns ?? DEFAULT_PATTERNS);
256
+ if (error) process.stderr.write(`valven-split: ${error}; counting every file\n`);
257
+
258
+ let mergeBase;
259
+ let headSha;
260
+ let branch;
261
+ let files;
262
+ let firstSubject;
263
+ try {
264
+ mergeBase = git(repo, ["merge-base", args.base, head]).trim();
265
+ headSha = git(repo, ["rev-parse", head]).trim();
266
+ branch = args.branch ?? git(repo, ["rev-parse", "--abbrev-ref", head]).trim();
267
+ files = parseNumstatZ(git(repo, ["diff", "--numstat", "-z", "-M", mergeBase, headSha]));
268
+ const deleted = new Set(
269
+ git(repo, ["diff", "--name-only", "-z", "--diff-filter=D", mergeBase, headSha])
270
+ .split("\0")
271
+ .filter(Boolean),
272
+ );
273
+ for (const f of files) f.deletedFile = deleted.has(f.path);
274
+ firstSubject =
275
+ git(repo, ["log", "--no-merges", "--reverse", "--format=%s", `${mergeBase}..${headSha}`])
276
+ .split("\n")
277
+ .find(Boolean) ?? branch;
278
+ } catch (err) {
279
+ return fail(`git failed: ${String(err.stderr || err.message).trim()}`);
280
+ }
281
+
282
+ const slices = planSlices(files, { budget, maxFiles, patterns });
283
+ const n = slices.length;
284
+ const plan = {
285
+ branch,
286
+ base: args.base,
287
+ mergeBase,
288
+ head: headSha,
289
+ budget,
290
+ maxFiles,
291
+ sliceCount: n,
292
+ slices: slices.map((s) => ({
293
+ index: s.index,
294
+ branch: `${branch}-p${s.index}`,
295
+ parent: s.index === 1 ? args.base : `${branch}-p${s.index - 1}`,
296
+ kind: s.kind,
297
+ lines: s.lines,
298
+ layers: s.layers,
299
+ oversized: s.oversized,
300
+ subject: sliceSubject(firstSubject, s.index, n),
301
+ files: s.files.map((f) => f.path),
302
+ riders: s.riders.map((f) => f.path),
303
+ })),
304
+ applied: false,
305
+ };
306
+
307
+ if (args.apply && n > 1) {
308
+ try {
309
+ if (git(repo, ["status", "--porcelain"]).trim()) return fail("worktree is not clean");
310
+ for (const s of plan.slices) {
311
+ if (git(repo, ["branch", "--list", s.branch]).trim())
312
+ return fail(`branch ${s.branch} already exists`);
313
+ }
314
+ const original = git(repo, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
315
+ let parent = mergeBase;
316
+ for (const [i, s] of slices.entries()) {
317
+ const name = plan.slices[i].branch;
318
+ git(repo, ["checkout", "-q", "-b", name, parent]);
319
+ applySlice(repo, headSha, s);
320
+ git(repo, ["commit", "-q", "--allow-empty", "-m", plan.slices[i].subject]);
321
+ parent = name;
322
+ }
323
+ const drift = git(repo, ["diff", "--name-only", parent, headSha]).trim();
324
+ git(repo, ["checkout", "-q", original === "HEAD" ? headSha : original]);
325
+ plan.applied = true;
326
+ plan.topMatchesBranch = drift === "";
327
+ if (drift) {
328
+ plan.drift = drift.split("\n");
329
+ process.exitCode = 1;
330
+ }
331
+ } catch (err) {
332
+ return fail(`git failed while applying: ${String(err.stderr || err.message).trim()}`);
333
+ }
334
+ }
335
+
336
+ const json = `${JSON.stringify(plan, null, 2)}\n`;
337
+ if (args.out) writeFileSync(args.out, json);
338
+ process.stdout.write(json);
339
+ }
340
+
341
+ if (invokedDirectly(import.meta.url)) runMain("valven-split", main, { code: 2 });
@@ -199,7 +199,7 @@ export function extract(raw, { nested = true, quotes = true } = {}) {
199
199
  for (const [i, item] of (Array.isArray(doc[key]) ? doc[key] : []).entries()) {
200
200
  if (!item || typeof item !== "object") continue;
201
201
  const f = nested && item.finding && typeof item.finding === "object" ? item.finding : item;
202
- push(f.file, Number(f.line), `${key}[${i}]`, f.quote);
202
+ push(f.file, citedLine(f), `${key}[${i}]`, f.quote);
203
203
  }
204
204
  }
205
205
  const repoEvidence = doc?.evidence?.repoEvidence || doc?.analysisSpec?.evidence?.repoEvidence;
@@ -256,6 +256,19 @@ export function extract(raw, { nested = true, quotes = true } = {}) {
256
256
  return out;
257
257
  }
258
258
 
259
+ /**
260
+ * The line a finding cites. A valven-rules finding judges the pull request as a
261
+ * whole and carries `line: 0`; only its file is resolved. Every other source
262
+ * keeps the strict check, so line 0 is not a way around it.
263
+ *
264
+ * @param {{line?: unknown, criteriaSource?: unknown}} f
265
+ * @returns {number}
266
+ */
267
+ export function citedLine(f) {
268
+ if (f.criteriaSource === "valven-rules" && Number(f.line) === 0) return Number.NaN;
269
+ return Number(f.line);
270
+ }
271
+
259
272
  /**
260
273
  * Resolve one citation at a commit, or in the checkout when `rev` is null.
261
274
  *
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
- "generatedAt": "2026-10-02T08:26:22Z",
4
- "skillCount": 220,
3
+ "generatedAt": "2026-10-02T10:19:25Z",
4
+ "skillCount": 221,
5
5
  "entries": [
6
6
  {
7
7
  "path": "shared/core/apple-archive-compliance/SKILL.md",
@@ -161,7 +161,7 @@
161
161
  },
162
162
  {
163
163
  "path": "shared/core/multi-agent-review/SKILL.md",
164
- "sha256": "1cbe44b2ec63e0e158ded35825876e7dc9c45732fc9b067f0655e68823188efb"
164
+ "sha256": "cd500e32efdf6c8f36da5bb2e43d2558d096e33cd3274aead5118dd2c14f118a"
165
165
  },
166
166
  {
167
167
  "path": "shared/core/multi-agent-route-off/SKILL.md",
@@ -267,6 +267,10 @@
267
267
  "path": "shared/core/multi-agent/SKILL.md",
268
268
  "sha256": "3dd44ccc00e41c09e59ad223900a391f80158bd3f69e45d67c684285bc42418d"
269
269
  },
270
+ {
271
+ "path": "shared/core/valven-rules/SKILL.md",
272
+ "sha256": "274299b75e36991941b8402ae025967ce553878100c1a3878a955d27f5b7db9e"
273
+ },
270
274
  {
271
275
  "path": "shared/external/accessibility-compliance-accessibility-audit/SKILL.md",
272
276
  "sha256": "0508b6e832b43d54c6ed5f57d42c292925b81e4df1c132ba1786860385ea2838"