@attalabs/vinaya 0.25.0 → 0.26.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 (59) hide show
  1. package/README.md +4 -4
  2. package/aeg-root/contracts/architect-planner.md +5 -5
  3. package/aeg-root/contracts/developer-reviewer.md +7 -7
  4. package/aeg-root/contracts/planner-developer.md +142 -0
  5. package/aeg-root/contracts/reviewer-archivist.md +2 -2
  6. package/aeg-root/contracts/tranche-archivist-planner.md +12 -11
  7. package/aeg-root/enforcement.md +15 -5
  8. package/aeg-root/process.md +53 -75
  9. package/aeg-root/roles/archivist.md +3 -3
  10. package/aeg-root/roles/developer.md +10 -10
  11. package/aeg-root/roles/planner.md +88 -23
  12. package/aeg-root/roles/principal.md +11 -11
  13. package/aeg-root/roles/reviewer.md +10 -10
  14. package/aeg-root/roles/security.md +7 -7
  15. package/aeg-root/roles/tranche-archivist.md +2 -2
  16. package/aeg-root/skills/aeg/SKILL.md +5 -5
  17. package/aeg-root/skills/aeg-roles/SKILL.md +7 -7
  18. package/aeg-root/state-machine.md +35 -34
  19. package/aeg-root/task-model.md +3 -3
  20. package/aeg-root/templates/brief-template.md +2 -2
  21. package/aeg-root/templates/issue-rationale-template.md +3 -3
  22. package/aeg-root/tranche-model.md +21 -21
  23. package/dist/checks/bin/check-body-bare-digits.js +116 -19
  24. package/dist/checks/bin/check-branch-topology.js +116 -19
  25. package/dist/checks/bin/check-brief-shape.js +121 -1101
  26. package/dist/checks/bin/check-changeset-coverage.js +877 -26
  27. package/dist/checks/bin/check-closes-n.js +116 -19
  28. package/dist/checks/bin/check-coherence.js +116 -19
  29. package/dist/checks/bin/check-dead-branch-push.js +116 -19
  30. package/dist/checks/bin/check-dispatch-readiness.js +119 -22
  31. package/dist/checks/bin/check-doc-coverage-push.js +877 -26
  32. package/dist/checks/bin/check-doc-coverage.js +879 -28
  33. package/dist/checks/bin/check-doctrine-no-procedures.js +116 -19
  34. package/dist/checks/bin/check-doctrine-portability.js +877 -26
  35. package/dist/checks/bin/check-evidence-fresh.js +116 -19
  36. package/dist/checks/bin/check-exec-bits.js +877 -26
  37. package/dist/checks/bin/check-first-push-dispatch.js +116 -19
  38. package/dist/checks/bin/check-issue-assignment.js +116 -19
  39. package/dist/checks/bin/check-main-branch-refusal.js +117 -20
  40. package/dist/checks/bin/check-no-disk-state.js +116 -19
  41. package/dist/checks/bin/check-pr-premise-reassert.js +5401 -0
  42. package/dist/checks/bin/check-pr-report-density.js +116 -19
  43. package/dist/checks/bin/check-quoted-command.js +875 -24
  44. package/dist/checks/bin/check-reader-resolvable-prose.js +875 -24
  45. package/dist/checks/bin/check-registry-gates.js +147 -21
  46. package/dist/checks/bin/check-retired-vocabulary.js +875 -24
  47. package/dist/checks/bin/check-review-gate.js +116 -19
  48. package/dist/checks/bin/check-single-plan-pr.js +116 -19
  49. package/dist/checks/bin/check-surface-scope.js +5722 -0
  50. package/dist/checks/bin/check-test-plan.js +116 -19
  51. package/dist/checks/bin/check-token-collection-wired.js +116 -19
  52. package/dist/checks/bin/check-token-report.js +128 -26
  53. package/dist/checks/bin/check-workspace-escape.js +875 -24
  54. package/dist/index.js +1002 -214
  55. package/package.json +1 -1
  56. package/aeg-root/contracts/brief-developer.md +0 -141
  57. package/aeg-root/contracts/planner-brief.md +0 -143
  58. package/aeg-root/roles/brief-author.md +0 -116
  59. package/aeg-root/skills/brief-authoring/SKILL.md +0 -509
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { readFileSync as readFileSync35 } from "node:fs";
5
- import { dirname as dirname10, join as join34 } from "node:path";
5
+ import { dirname as dirname12, join as join34 } from "node:path";
6
6
  import { fileURLToPath as fileURLToPath2 } from "node:url";
7
7
 
8
8
  // src/commands/archive.ts
@@ -374,7 +374,7 @@ var LABELS = [
374
374
  id: "vinaya/needs:brief-correction",
375
375
  category: "needs",
376
376
  form: "literal",
377
- carries: "Waiting on the Brief Author — the brief contradicts the surface it describes."
377
+ carries: "Waiting on the Planner — the brief contradicts the surface it describes."
378
378
  },
379
379
  {
380
380
  key: "waiver-docs",
@@ -2460,6 +2460,11 @@ function readTierFromPrBody(prBody) {
2460
2460
  const t = Number(m[4]);
2461
2461
  return t === 0 || t === 1 || t === 3 ? t : null;
2462
2462
  }
2463
+ function applyTierFloor(declared, derivedFloor) {
2464
+ if (declared === null)
2465
+ return derivedFloor;
2466
+ return declared >= derivedFloor ? declared : derivedFloor;
2467
+ }
2463
2468
  var OVERRIDE_BODY_TOKEN = `[${label("override-docs")}]`;
2464
2469
  // ../../packages/aeg-core/src/objectives.ts
2465
2470
  import { createHash as createHash2 } from "node:crypto";
@@ -2523,7 +2528,7 @@ function objectivesOf(body) {
2523
2528
  continue;
2524
2529
  }
2525
2530
  if (hasBacktickedPath(text) && wordCount(stripObjectiveBackticks(text)) < MIN_WORDS_OUTSIDE_BACKTICKS) {
2526
- errors.push(`O${n} is little more than a file path — an objective states an observable outcome, never a bare path (the Brief Author maps it to files).`);
2531
+ errors.push(`O${n} is little more than a file path — an objective states an observable outcome, never a bare path (the Planner maps it to files).`);
2527
2532
  continue;
2528
2533
  }
2529
2534
  objectives.push({ id: `O${n}`, text });
@@ -2691,18 +2696,14 @@ function checkTestPlanExclusivity(prBody) {
2691
2696
  function checkPrincipalPlaceholder(prBody) {
2692
2697
  const region = testPlanRegion(prBody);
2693
2698
  const lineRe = /^-\s*\[[ xX]\]\s*\*{2}\[principal\]\*{2}(.*)$/gim;
2699
+ const errors = [];
2694
2700
  for (const m of region.matchAll(lineRe)) {
2695
2701
  const content = m[1] ?? "";
2696
2702
  if (/^\s*None\b/i.test(content)) {
2697
- return {
2698
- status: "fail",
2699
- errors: [
2700
- 'brief-validation Test Plan shape: a `**[principal]**` checkbox item is a "None" placeholder — if there is no principal-runnable surface, omit the item entirely; an untickable placeholder box blocks the merge gate forever.'
2701
- ]
2702
- };
2703
+ errors.push(`brief-validation Test Plan shape: a \`**[principal]**\` checkbox item is a "None" placeholder ("${content.trim()}") — if there is no principal-runnable surface, omit the item entirely; an untickable placeholder box blocks the merge gate forever.`);
2703
2704
  }
2704
2705
  }
2705
- return { status: "pass", errors: [] };
2706
+ return errors.length > 0 ? { status: "fail", errors } : { status: "pass", errors: [] };
2706
2707
  }
2707
2708
  function checkPremiseCoverage(prBody, surfaceFiles) {
2708
2709
  if (surfaceFiles.length === 0)
@@ -2828,6 +2829,18 @@ function extractFencedBlocks(text) {
2828
2829
  }
2829
2830
  return blocks;
2830
2831
  }
2832
+ var AEG_BRIEF_V1_MARKER = "<!-- aeg:brief:v1 -->";
2833
+ function contentAfterTwoLines(body) {
2834
+ const first = body.indexOf(`
2835
+ `);
2836
+ if (first === -1)
2837
+ return "";
2838
+ const second = body.indexOf(`
2839
+ `, first + 1);
2840
+ if (second === -1)
2841
+ return "";
2842
+ return body.slice(second + 1);
2843
+ }
2831
2844
  // ../../packages/aeg-core/src/doctrine-portability.ts
2832
2845
  var EXEMPT_LITERALS = new Set([
2833
2846
  "path/inside/the/shipped/diff.ts",
@@ -2909,7 +2922,9 @@ function partWordCount(text) {
2909
2922
  }
2910
2923
  function looksLikeFilePath(entry2) {
2911
2924
  const stripped = entry2.replace(/\/\*\*?$/, "");
2912
- return /\.[A-Za-z0-9]{1,6}$/.test(stripped);
2925
+ const lastSegment = stripped.split("/").pop() ?? stripped;
2926
+ const extMatch = /\.[A-Za-z0-9]{1,6}$/.exec(lastSegment);
2927
+ return extMatch !== null && extMatch.index > 0;
2913
2928
  }
2914
2929
  function splitGlobList(raw) {
2915
2930
  return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
@@ -2940,6 +2955,23 @@ function parseIssueSurface(body) {
2940
2955
  return { ok: false, errors };
2941
2956
  return { ok: true, value: { in: inGlobs, out: outGlobs } };
2942
2957
  }
2958
+ function globCoversPath(glob, path) {
2959
+ const g = glob.replace(/\/\*\*?$/, "").replace(/\/+$/, "");
2960
+ const p = path.replace(/\/+$/, "");
2961
+ return g === p || g.startsWith(`${p}/`) || p.startsWith(`${g}/`);
2962
+ }
2963
+ function checkSurfaceGlobsResolve(body, resolvesToFile) {
2964
+ const surface = parseIssueSurface(body);
2965
+ if (!surface.ok)
2966
+ return { status: "pass", errors: [] };
2967
+ const errors = [];
2968
+ for (const glob of surface.value.in) {
2969
+ if (!resolvesToFile(glob)) {
2970
+ errors.push(`issue-validation Surface: \`${glob}\` in \`## Surface\`'s \`in:\` list matches no tracked file — a Surface that cannot resolve cannot render a brief's file list.`);
2971
+ }
2972
+ }
2973
+ return { status: errors.length > 0 ? "fail" : "pass", errors };
2974
+ }
2943
2975
  var ISSUE_PART_LINE_RE = /^Part\s+(\d+)\s*\(([^)]*)\)\s*[-—–]\s*(.*)$/i;
2944
2976
  function parseIssueParts(body) {
2945
2977
  const section = topLevelSectionText(body, "Parts");
@@ -2975,6 +3007,22 @@ function parseIssueParts(body) {
2975
3007
  return { ok: false, errors };
2976
3008
  return { ok: true, value: parts };
2977
3009
  }
3010
+ function checkPartsCiteDefinedObjectives(body) {
3011
+ const parts = parseIssueParts(body);
3012
+ const objectives = objectivesOf(body);
3013
+ if (!parts.ok || !objectives.ok)
3014
+ return { status: "pass", errors: [] };
3015
+ const definedIds = new Set(objectives.objectives.map((o) => Number.parseInt(o.id.slice(1), 10)));
3016
+ const errors = [];
3017
+ for (const part of parts.value) {
3018
+ for (const objectiveId of part.objectiveIds) {
3019
+ if (!definedIds.has(objectiveId)) {
3020
+ errors.push(`issue-validation Parts: Part ${part.n} cites O${objectiveId}, which the Issue's own \`## Objectives\` section does not define.`);
3021
+ }
3022
+ }
3023
+ }
3024
+ return { status: errors.length > 0 ? "fail" : "pass", errors };
3025
+ }
2978
3026
  var ISSUE_PRINCIPAL_LINE_RE = /^-\s*\[[ xX]\]\s*\*{2}\[principal\]\*{2}(.*)$/gim;
2979
3027
  var UNIT_TESTS_ONLY_SENTINEL_RE = /(?:\*\*)?Test plan(?:\*\*)?\s*:\s*(?:\*\*)?\s*unit-tests-only/i;
2980
3028
  function parseIssueTestPlan(body) {
@@ -3055,15 +3103,23 @@ function namesPath(text, path) {
3055
3103
  return false;
3056
3104
  }
3057
3105
  var BLAST_RADIUS_ACK_RE = /(?:\*\*)?blast-radius-ack(?:\*\*)?\s*[:—–-]/i;
3058
- function checkBlastRadiusScope(body, _labels, sharedPackages, projectPaths) {
3106
+ function checkBlastRadiusScope(body, _labels, sharedPackages, projectPaths, issueNumber = null) {
3059
3107
  if (sharedPackages.length === 0)
3060
3108
  return { status: "pass", errors: [] };
3061
- const text = [
3062
- rationaleFieldText(PATH_TEXT(body), "Boundary"),
3063
- rationaleFieldText(PATH_TEXT(body), "Project\\(s\\)|Project(?:s)?\\s*\\+|blast radius")
3064
- ].join(`
3109
+ const decideFromSurface = issueNumber !== null && issueNumber >= BRIEF_SECTIONS_SINCE_ISSUE;
3110
+ let named;
3111
+ if (decideFromSurface) {
3112
+ const surface = parseIssueSurface(body);
3113
+ const surfaceIn = surface.ok ? surface.value.in : [];
3114
+ named = sharedPackages.filter((d) => surfaceIn.some((glob) => globCoversPath(glob, d)));
3115
+ } else {
3116
+ const text = [
3117
+ rationaleFieldText(PATH_TEXT(body), "Boundary"),
3118
+ rationaleFieldText(PATH_TEXT(body), "Project\\(s\\)|Project(?:s)?\\s*\\+|blast radius")
3119
+ ].join(`
3065
3120
  `);
3066
- const named = sharedPackages.filter((d) => namesPath(text, d));
3121
+ named = sharedPackages.filter((d) => namesPath(text, d));
3122
+ }
3067
3123
  if (named.length === 0)
3068
3124
  return { status: "pass", errors: [] };
3069
3125
  const registered = new Set(projectPaths.map((p) => p.name.trim()));
@@ -3103,6 +3159,7 @@ function rationaleFieldText(text, labelPattern) {
3103
3159
  return m ? m[0] : "";
3104
3160
  }
3105
3161
  var DOC_PATH_RE = /(?:(?:aeg-root|apps|packages|specs|docs|tools|\.claude|\.github)\/[\w./@-]*\.(?:md|mdx)|\.claude\/(?:skills|rules)\/[\w./-]+|\b(?:docs-index|decisions|projects|state-machine|enforcement|process|README|CLAUDE)\.md\b|\b[\w-]+-(?:spec|decisions|backlog)\.md\b)/i;
3162
+ var DOC_PATH_RE_GLOBAL = new RegExp(DOC_PATH_RE.source, "gi");
3106
3163
  var NO_DOC_SURFACE_RE = /(?:\*\*)?\s*no-doc-surface/i;
3107
3164
  function checkRationaleNamesDocs(body) {
3108
3165
  const text = PATH_TEXT(body);
@@ -3119,6 +3176,26 @@ function checkRationaleNamesDocs(body) {
3119
3176
  ]
3120
3177
  };
3121
3178
  }
3179
+ function checkDocsWithinSurface(body, issueNumber) {
3180
+ if (issueNumber !== null && issueNumber < BRIEF_SECTIONS_SINCE_ISSUE)
3181
+ return { status: "pass", errors: [] };
3182
+ const surface = parseIssueSurface(body);
3183
+ if (!surface.ok)
3184
+ return { status: "pass", errors: [] };
3185
+ const text = PATH_TEXT(body);
3186
+ const scope = rationaleFieldText(text, "Docs to keep coherent|§7");
3187
+ if (NO_DOC_SURFACE_RE.test(scope))
3188
+ return { status: "pass", errors: [] };
3189
+ const pointers = [...new Set([...scope.matchAll(DOC_PATH_RE_GLOBAL)].map((m) => m[0]))];
3190
+ const errors = [];
3191
+ for (const pointer of pointers) {
3192
+ const excludingGlob = surface.value.out.find((glob) => globCoversPath(glob, pointer));
3193
+ if (excludingGlob) {
3194
+ errors.push(`issue-validation Docs to keep coherent: \`${pointer}\` falls inside \`## Surface\`'s \`out:\` glob \`${excludingGlob}\` — this task's own declared surface explicitly excludes the doc it claims to keep coherent.`);
3195
+ }
3196
+ }
3197
+ return { status: errors.length > 0 ? "fail" : "pass", errors };
3198
+ }
3122
3199
 
3123
3200
  // ../../packages/aeg-core/src/coherence-checks.ts
3124
3201
  var R1_GRANDFATHERED_ISSUES = new Set([279, 280, 281, 282]);
@@ -3478,8 +3555,31 @@ function parseRationaleFields(body) {
3478
3555
  if (text)
3479
3556
  out[key] = text;
3480
3557
  }
3558
+ return { ...out, declaredTier: readTierFromPrBody(body) };
3559
+ }
3560
+ function extractBoundaryFilePaths(boundaryText) {
3561
+ const out = [];
3562
+ const seen = new Set;
3563
+ for (const match of boundaryText.matchAll(/`([^`\n]+)`/g)) {
3564
+ const token = match[1].trim();
3565
+ if (token.length === 0 || token.includes(" ") || token.includes("*"))
3566
+ continue;
3567
+ if (!/\.[A-Za-z0-9]{1,6}$/.test(token))
3568
+ continue;
3569
+ if (seen.has(token))
3570
+ continue;
3571
+ seen.add(token);
3572
+ out.push(token);
3573
+ }
3481
3574
  return out;
3482
3575
  }
3576
+ function admittedSurfaceFiles(surfaceFiles, surface) {
3577
+ return surfaceFiles.filter((f) => {
3578
+ const admitted = surface.in.some((g) => globCoversPath(g, f.path));
3579
+ const excluded = surface.out.some((g) => globCoversPath(g, f.path));
3580
+ return admitted && !excluded;
3581
+ });
3582
+ }
3483
3583
  function bulletList(items) {
3484
3584
  return items.map((i) => `- ${i}`).join(`
3485
3585
  `);
@@ -3491,7 +3591,8 @@ function renderHeader(facts, template) {
3491
3591
  const introMatch = template.match(/^You are the AEG Developer\.[^\n]*$/m);
3492
3592
  const intro = introMatch ? introMatch[0].replace(/\s*\[[^\]]*\]/g, "").replace(/\bBoth mandatory\.$/, "Mandatory.").trim() : "You are the AEG Developer. Read `aeg-root/roles/developer.md` first. Mandatory.";
3493
3593
  const reason = facts.rationale.suggestedAgentClass ?? "";
3494
- const tier = deriveTierFromDiff(facts.surfaceFiles.map((f) => f.path));
3594
+ const derivedFloor = deriveTierFromDiff(facts.surfaceFiles.map((f) => f.path));
3595
+ const tier = applyTierFloor(facts.rationale.declaredTier ?? null, derivedFloor);
3495
3596
  const lines = [
3496
3597
  "**For:** [model] (coding-agent CLI on a dev machine, dispatched locally, unattended)",
3497
3598
  `**Reason:** ${reason}`,
@@ -3575,7 +3676,7 @@ function renderSection5(facts) {
3575
3676
  "**Step 0 (mandatory, verbatim):**",
3576
3677
  "",
3577
3678
  "```",
3578
- `git worktree add .worktrees/task/${facts.trancheSlug}/${facts.taskId} -b task/${facts.trancheSlug}/${facts.taskId} origin/main && cd .worktrees/task/${facts.trancheSlug}/${facts.taskId} && bun install --frozen-lockfile --silent`,
3679
+ `git worktree add .worktrees/task/${facts.trancheSlug}/${facts.taskId} -b task/${facts.trancheSlug}/${facts.taskId} --no-track origin/main && cd .worktrees/task/${facts.trancheSlug}/${facts.taskId} && git config push.autoSetupRemote true && bun install --frozen-lockfile --silent`,
3579
3680
  "```",
3580
3681
  "",
3581
3682
  "1. Clean status; parent `origin/main`; branch suffix literal-matches the task id.",
@@ -3746,22 +3847,27 @@ function renderBrief(facts, template) {
3746
3847
  if (!facts.rationale[key])
3747
3848
  missing.push(RATIONALE_FIELD_NAMES[key]);
3748
3849
  }
3850
+ const filteredSurfaceFiles = admittedSurfaceFiles(facts.surfaceFiles, facts.surface);
3851
+ if (facts.surface.in.length > 0 && facts.surfaceFiles.length > 0 && filteredSurfaceFiles.length === 0) {
3852
+ missing.push("Surface map (every file the Boundary named is excluded by the Issue's own `## Surface` `in:`/`out:` globs — Boundary and Surface disagree; fix the Issue rather than render an empty surface map)");
3853
+ }
3749
3854
  if (missing.length > 0)
3750
3855
  return { ok: false, missing };
3751
- const section7Pointers = facts.docOwnersContent ? deriveSection7Pointers(facts.surfaceFiles.map((f) => f.path), facts.docOwnersContent) : [];
3856
+ const scopedFacts = { ...facts, surfaceFiles: filteredSurfaceFiles };
3857
+ const section7Pointers = facts.docOwnersContent ? deriveSection7Pointers(filteredSurfaceFiles.map((f) => f.path), facts.docOwnersContent) : [];
3752
3858
  const brief = [
3753
- renderHeader(facts, template),
3859
+ renderHeader(scopedFacts, template),
3754
3860
  "",
3755
3861
  ...facts.objectives.length > 0 ? [renderObjectives(facts.objectives), ""] : [],
3756
3862
  renderSection2(facts),
3757
3863
  "",
3758
3864
  renderSection3(facts),
3759
3865
  "",
3760
- renderSection4(facts),
3866
+ renderSection4(scopedFacts),
3761
3867
  "",
3762
3868
  renderSection5(facts),
3763
3869
  "",
3764
- renderSection6(facts),
3870
+ renderSection6(scopedFacts),
3765
3871
  "",
3766
3872
  renderSection7(section7Pointers),
3767
3873
  "",
@@ -4112,6 +4218,25 @@ function assessRound(state, observations) {
4112
4218
  return assessGate(state, observations);
4113
4219
  return assessVerdicts(state, observations);
4114
4220
  }
4221
+ // ../../packages/aeg-core/src/dev-review-loop/render-summary.ts
4222
+ function confidenceCell(confidence) {
4223
+ if (confidence === null)
4224
+ return "—";
4225
+ if (confidence === "absent")
4226
+ return "absent";
4227
+ return `${confidence.value}%`;
4228
+ }
4229
+ function row(record) {
4230
+ const counts = SEVERITY_COLUMNS.map((key) => String(record.countsBySeverity[key] ?? 0));
4231
+ return `| ${record.round} | ${counts.join(" | ")} | ${confidenceCell(record.confidence)} | ${record.outcome} |`;
4232
+ }
4233
+ function renderSummary(journal) {
4234
+ const header = `| round | ${SEVERITY_COLUMNS.join(" | ")} | confidence | outcome |`;
4235
+ const divider = `| --- | ${SEVERITY_COLUMNS.map(() => "---").join(" | ")} | --- | --- |`;
4236
+ const rows = journal.rounds.map(row);
4237
+ return [header, divider, ...rows].join(`
4238
+ `);
4239
+ }
4115
4240
  // ../../packages/aeg-core/src/log/envelope.ts
4116
4241
  import { createHash as createHash3 } from "node:crypto";
4117
4242
 
@@ -4119,7 +4244,6 @@ import { createHash as createHash3 } from "node:crypto";
4119
4244
  import { z } from "zod";
4120
4245
  var ROLE_VALUES = [
4121
4246
  "planner",
4122
- "brief-author",
4123
4247
  "developer",
4124
4248
  "code-reviewer",
4125
4249
  "security",
@@ -4193,18 +4317,20 @@ var dispatchShared = {
4193
4317
  round: z.number().int().optional(),
4194
4318
  effect_id: z.string()
4195
4319
  };
4320
+ var dispatchUsageField = z.object({ input: z.number().nonnegative(), output: z.number().nonnegative() }).strict().nullable();
4196
4321
  var DispatchEventSchema = z.discriminatedUnion("event", [
4197
4322
  z.object({ ...dispatchShared, event: z.literal("dispatched"), prompt_hash: z.string() }).strict(),
4198
4323
  z.object({
4199
4324
  ...dispatchShared,
4200
4325
  event: z.literal("outcome_received"),
4201
4326
  outcome: DispatchOutcomeSchema,
4202
- usage: z.object({ input: z.number().nonnegative(), output: z.number().nonnegative() }).strict().nullable()
4327
+ usage: dispatchUsageField
4203
4328
  }).strict(),
4204
4329
  z.object({
4205
4330
  ...dispatchShared,
4206
4331
  event: z.literal("dispatch_failed"),
4207
- reason: z.enum(["timeout", "crash", "refused", "unattributed_write"])
4332
+ reason: z.enum(["timeout", "crash", "refused", "unattributed_write"]),
4333
+ usage: dispatchUsageField
4208
4334
  }).strict()
4209
4335
  ]);
4210
4336
  var loopShared = {
@@ -4479,6 +4605,7 @@ function resolveDoctrineRootInfo(pkg = packageRoot(import.meta.url), cwd = proce
4479
4605
  return hasDoctrineEntry(bundleRoot) ? { root: bundleRoot, source: "bundle" } : null;
4480
4606
  }
4481
4607
  var ROLE_ALIASES = { "code-reviewer": "reviewer" };
4608
+ var RETIRED_ROLES = { "brief-author": "planner" };
4482
4609
  function hasDoctrineEntry(root) {
4483
4610
  return existsSync2(join2(root, ...ENTRY_SEGMENTS));
4484
4611
  }
@@ -4502,6 +4629,11 @@ function doctrineCommand(args) {
4502
4629
  if (roleFlagIndex !== -1) {
4503
4630
  const requested = args[roleFlagIndex + 1];
4504
4631
  const roleName = requested !== undefined ? Object.hasOwn(ROLE_ALIASES, requested) ? ROLE_ALIASES[requested] : requested : undefined;
4632
+ if (roleName !== undefined && Object.hasOwn(RETIRED_ROLES, roleName)) {
4633
+ process.stderr.write(`vinaya doctrine --role: '${roleName}' has been retired — use --role ${RETIRED_ROLES[roleName]}.
4634
+ `);
4635
+ process.exit(1);
4636
+ }
4505
4637
  const validRoleNames = listRoleNames(root);
4506
4638
  if (roleName === undefined || roleName.startsWith("--") || !validRoleNames.includes(roleName)) {
4507
4639
  process.stderr.write(`vinaya doctrine --role: '${requested ?? ""}' is not a known role. ` + `Valid role names: ${validRoleNames.join(", ")}
@@ -4534,11 +4666,12 @@ var AGENTS_SKILLS_GROUP = "Agent skills (.agents/skills/)";
4534
4666
  function formatRoleTitle(roleName) {
4535
4667
  return roleName.split("-").filter((segment) => segment.length > 0).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
4536
4668
  }
4669
+ var RETIRED_ROLE_NAMES = new Set(["brief-author"]);
4537
4670
  function discoverRoleNames(doctrineRoot) {
4538
4671
  const rolesDir = join3(doctrineRoot, "roles");
4539
4672
  if (!existsSync3(rolesDir))
4540
4673
  return [];
4541
- return readdirSync2(rolesDir).filter((name) => name.endsWith(".md")).map((name) => name.slice(0, -".md".length)).filter((roleName) => {
4674
+ return readdirSync2(rolesDir).filter((name) => name.endsWith(".md")).map((name) => name.slice(0, -".md".length)).filter((roleName) => !RETIRED_ROLE_NAMES.has(roleName)).filter((roleName) => {
4542
4675
  const { data } = matter3(readFileSync3(join3(rolesDir, `${roleName}.md`), "utf8"));
4543
4676
  return data.actor !== "human";
4544
4677
  }).sort();
@@ -4567,6 +4700,14 @@ function buildAgentsSkillsOps(doctrineRoot, selfHost = null) {
4567
4700
  group: AGENTS_SKILLS_GROUP
4568
4701
  }));
4569
4702
  }
4703
+ var AGENT_SKILL_PATH_PATTERN = /^\.agents\/skills\/vinaya-([^/]+)\/SKILL\.md$/;
4704
+ function staleAgentSkillPaths(doctrineRoot, manifestFiles) {
4705
+ const liveRoles = new Set(discoverRoleNames(doctrineRoot));
4706
+ return manifestFiles.filter((path) => {
4707
+ const match = AGENT_SKILL_PATH_PATTERN.exec(path);
4708
+ return match !== null && !liveRoles.has(match[1]);
4709
+ });
4710
+ }
4570
4711
 
4571
4712
  // src/lib/claude-command-emitter.ts
4572
4713
  var CLAUDE_COMMAND_GROUP = "Claude Code command (.claude/commands/)";
@@ -5415,9 +5556,21 @@ fi
5415
5556
  node ${selfHost.bin} ${args} || exit 1`;
5416
5557
  }
5417
5558
  function preCommitBody(selfHost) {
5418
- return `# Vinaya commit-time gate. Runs the deterministic checks over your staged
5559
+ const doctrineGate = `# Vinaya commit-time gate. Runs the deterministic checks over your staged
5419
5560
  # diff before the commit lands.
5420
5561
  ${hookRun(selfHost, "check --all --diff-only --local --skip-full")}`;
5562
+ if (!selfHost)
5563
+ return doctrineGate;
5564
+ return `# Ring 0: format + lint your staged files, applying every safe fix, then
5565
+ # type-check only the packages your staged diff actually touches (O9) —
5566
+ # scoped so this hook costs seconds, not the whole repo's worth of work.
5567
+ VINAYA_STAGED_FILES="$(git diff --cached --name-only --diff-filter=ACMR)"
5568
+ bunx biome check --write --staged . || exit 1
5569
+ if [ -n "$VINAYA_STAGED_FILES" ]; then
5570
+ echo "$VINAYA_STAGED_FILES" | xargs git add --
5571
+ fi
5572
+ bunx turbo typecheck --affected || exit 1
5573
+ ${doctrineGate}`;
5421
5574
  }
5422
5575
  function prePushBody(selfHost) {
5423
5576
  const base = `# Vinaya pre-push gate. Runs branch/dispatch checks before the push leaves.
@@ -5489,7 +5642,7 @@ anything substantive:
5489
5642
 
5490
5643
  prints the absolute path to that role's doctrine — \`architect\`,
5491
5644
  \`developer\`, \`reviewer\`, \`planner\`, \`security\`, \`archivist\`,
5492
- \`tranche-archivist\`, \`brief-author\`, or \`principal\`. If your agent tool
5645
+ \`tranche-archivist\`, or \`principal\`. If your agent tool
5493
5646
  supports slash-style commands, the same doctrine is likely exposed as
5494
5647
  \`/vinaya <role>\` — check your tool's command list before falling back to
5495
5648
  the raw CLI form.
@@ -5630,7 +5783,7 @@ var FIXED_LABEL_METADATA = {
5630
5783
  "needs-principal-input": { color: "b60205", description: "Blocked on a Principal decision" },
5631
5784
  "needs-brief-correction": {
5632
5785
  color: "fbca04",
5633
- description: "Blocked on the Brief Author — brief contradicts the surface"
5786
+ description: "Blocked on the Planner — brief contradicts the surface"
5634
5787
  },
5635
5788
  "waiver-docs": { color: "0e8a16", description: "Doc-coverage gate excused for this PR (principal-applied)" },
5636
5789
  "waiver-review": { color: "0e8a16", description: "Review gate excused for this PR (principal-applied)" },
@@ -8511,6 +8664,21 @@ function expandGlob(glob) {
8511
8664
  function sha256OfFile(path) {
8512
8665
  return createHash5("sha256").update(readFileSync9(path)).digest("hex");
8513
8666
  }
8667
+ function resolveBoundaryPaths(tokens, allTrackedFiles) {
8668
+ const trackedSet = new Set(allTrackedFiles);
8669
+ const resolved = new Set;
8670
+ for (const token of tokens) {
8671
+ if (trackedSet.has(token)) {
8672
+ resolved.add(token);
8673
+ continue;
8674
+ }
8675
+ const suffix = `/${token}`;
8676
+ const suffixMatches = allTrackedFiles.filter((f) => f.endsWith(suffix));
8677
+ if (suffixMatches.length === 1)
8678
+ resolved.add(suffixMatches[0]);
8679
+ }
8680
+ return [...resolved];
8681
+ }
8514
8682
  function listDirs(dir) {
8515
8683
  try {
8516
8684
  return readdirSync5(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
@@ -8618,17 +8786,17 @@ async function assembleAndRenderBrief(trancheSlug, taskId, surfaceGlobsOverride)
8618
8786
  const gate = checkDispatchReadiness(gateInput);
8619
8787
  const surfaceResult = parseIssueSurface(issueBody);
8620
8788
  const surface = surfaceResult.ok ? surfaceResult.value : { in: [], out: [] };
8789
+ const rationale = parseRationaleFields(issueBody);
8621
8790
  const globs = surfaceGlobsOverride ?? surface.in;
8622
- const surfaceFiles = [];
8623
8791
  for (const glob of globs) {
8624
- const matches = expandGlob(glob);
8625
- if (matches.length === 0) {
8792
+ if (expandGlob(glob).length === 0) {
8626
8793
  return { ok: false, missing: [`--surfaces glob "${glob}" matched no tracked file.`] };
8627
8794
  }
8628
- for (const path of matches) {
8629
- surfaceFiles.push({ path, sha256: sha256OfFile(path), packageName: packageNameForPath(path) });
8630
- }
8631
8795
  }
8796
+ const allTrackedFiles = git(["ls-files"]).split(`
8797
+ `).map((s) => s.trim()).filter(Boolean);
8798
+ const boundaryTokens = extractBoundaryFilePaths(rationale.boundary ?? "");
8799
+ const surfaceFiles = resolveBoundaryPaths(boundaryTokens, allTrackedFiles).sort().map((path) => ({ path, sha256: sha256OfFile(path), packageName: packageNameForPath(path) }));
8632
8800
  const workspaces = workspaceGlobs();
8633
8801
  const consumersOf = buildConsumersOf(workspaces, listDirs, readManifest);
8634
8802
  const partsResult = parseIssueParts(issueBody);
@@ -8645,7 +8813,7 @@ async function assembleAndRenderBrief(trancheSlug, taskId, surfaceGlobsOverride)
8645
8813
  projects: task.projects,
8646
8814
  dependsOn: task.dependsOn,
8647
8815
  conflictsWith: task.conflictsWith,
8648
- rationale: parseRationaleFields(issueBody),
8816
+ rationale,
8649
8817
  objectives: (() => {
8650
8818
  const parsed = objectivesOf(issueBody);
8651
8819
  return parsed.ok ? parsed.objectives : [];
@@ -8662,7 +8830,7 @@ async function assembleAndRenderBrief(trancheSlug, taskId, surfaceGlobsOverride)
8662
8830
  };
8663
8831
  const template = readFileSync9(TEMPLATE_PATH, "utf8");
8664
8832
  const result = renderBrief(facts, template);
8665
- return result.ok ? { ok: true, brief: result.brief } : { ok: false, missing: result.missing };
8833
+ return result.ok ? { ok: true, brief: result.brief, issue: task.issue } : { ok: false, missing: result.missing };
8666
8834
  }
8667
8835
 
8668
8836
  // src/commands/brief.ts
@@ -8674,16 +8842,19 @@ async function briefRenderCommand(args) {
8674
8842
  const trancheSlug = args[0];
8675
8843
  const taskId = args[1];
8676
8844
  if (!trancheSlug || !taskId || trancheSlug.startsWith("--")) {
8677
- console.error("Usage: vinaya brief render <tranche> <n> --surfaces <glob1,glob2,...> [--out <path>]");
8845
+ console.error("Usage: vinaya brief render <tranche> <n> [--surfaces <glob1,glob2,...>] [--out <path>]");
8678
8846
  process.exit(2);
8679
8847
  }
8680
8848
  const surfacesIdx = args.indexOf("--surfaces");
8681
8849
  const surfacesArg = surfacesIdx !== -1 ? args[surfacesIdx + 1] : undefined;
8682
- if (!surfacesArg)
8683
- refuse("--surfaces <glob1,glob2,...> is required.");
8684
- const globs = surfacesArg.split(",").map((s) => s.trim()).filter(Boolean);
8685
- if (globs.length === 0)
8686
- refuse("--surfaces resolved to zero globs.");
8850
+ let globs;
8851
+ if (surfacesIdx !== -1) {
8852
+ if (!surfacesArg)
8853
+ refuse("--surfaces <glob1,glob2,...> was passed with no value.");
8854
+ globs = surfacesArg.split(",").map((s) => s.trim()).filter(Boolean);
8855
+ if (globs.length === 0)
8856
+ refuse("--surfaces resolved to zero globs.");
8857
+ }
8687
8858
  const outIdx = args.indexOf("--out");
8688
8859
  const outPath = outIdx !== -1 ? args[outIdx + 1] : undefined;
8689
8860
  const result = await assembleAndRenderBrief(trancheSlug, taskId, globs);
@@ -8790,6 +8961,18 @@ var REGISTRY = [
8790
8961
  },
8791
8962
  0
8792
8963
  ],
8964
+ [
8965
+ {
8966
+ name: "pr-premise-reassert",
8967
+ run: bin("check-pr-premise-reassert"),
8968
+ scope: "diff",
8969
+ timeoutMs: 15000,
8970
+ env: {
8971
+ PR_BODY: { optional: true }
8972
+ }
8973
+ },
8974
+ 0
8975
+ ],
8793
8976
  [
8794
8977
  {
8795
8978
  name: "dispatch-readiness",
@@ -8838,6 +9021,22 @@ var REGISTRY = [
8838
9021
  },
8839
9022
  0
8840
9023
  ],
9024
+ [
9025
+ {
9026
+ name: "surface-scope",
9027
+ run: bin("check-surface-scope"),
9028
+ scope: "diff",
9029
+ timeoutMs: 15000,
9030
+ env: {
9031
+ AEG_REPO: { optional: true },
9032
+ BASE_SHA: { optional: true },
9033
+ BRANCH: { optional: true },
9034
+ GITHUB_TOKEN: { optional: true },
9035
+ GH_TOKEN: { optional: true }
9036
+ }
9037
+ },
9038
+ 0
9039
+ ],
8841
9040
  [
8842
9041
  {
8843
9042
  name: "test-plan",
@@ -8879,6 +9078,7 @@ var REGISTRY = [
8879
9078
  requiresOpenPr: true,
8880
9079
  env: {
8881
9080
  PR_BODY: { optional: true },
9081
+ BRANCH: { optional: true },
8882
9082
  CLAUDE_PROJECT_DIR: { optional: true },
8883
9083
  CLAUDE_CODE_SESSION_ID: { optional: true }
8884
9084
  }
@@ -9751,8 +9951,8 @@ function renderPlanTable(result) {
9751
9951
  const rows = result.resolved.map((entry2) => [entry2.name, entry2.state, entry2.source, envCellFor(entry2.spec)]);
9752
9952
  const failureRows = result.failures.map((f) => [f.key, "FAILED", "—", f.reason]);
9753
9953
  const table = [header, ...rows, ...failureRows];
9754
- const widths = header.map((_, col) => Math.max(...table.map((row) => row[col]?.length ?? 0)));
9755
- return table.map((row) => row.map((cell, col) => (cell ?? "").padEnd(widths[col] ?? 0)).join(" ")).join(`
9954
+ const widths = header.map((_, col) => Math.max(...table.map((row2) => row2[col]?.length ?? 0)));
9955
+ return table.map((row2) => row2.map((cell, col) => (cell ?? "").padEnd(widths[col] ?? 0)).join(" ")).join(`
9756
9956
  `);
9757
9957
  }
9758
9958
  function renderRolesTable(rolePlan) {
@@ -9762,8 +9962,8 @@ function renderRolesTable(rolePlan) {
9762
9962
  const rows = rolePlan.resolved.map((r) => [r.name, r.state, r.source, r.renderId, r.inertToGating ? "inert" : "core"]);
9763
9963
  const failureRows = rolePlan.failures.map((f) => [f.key, "FAILED", "—", "—", f.reason]);
9764
9964
  const table = [header, ...rows, ...failureRows];
9765
- const widths = header.map((_, col) => Math.max(...table.map((row) => row[col]?.length ?? 0)));
9766
- return table.map((row) => row.map((cell, col) => (cell ?? "").padEnd(widths[col] ?? 0)).join(" ")).join(`
9965
+ const widths = header.map((_, col) => Math.max(...table.map((row2) => row2[col]?.length ?? 0)));
9966
+ return table.map((row2) => row2.map((cell, col) => (cell ?? "").padEnd(widths[col] ?? 0)).join(" ")).join(`
9767
9967
  `);
9768
9968
  }
9769
9969
  function duplicateIdFailures(resolved) {
@@ -10031,6 +10231,12 @@ None — fixture content only.
10031
10231
  ## Test Plan
10032
10232
  Test Plan: unit-tests-only
10033
10233
 
10234
+ ## Token report
10235
+
10236
+ | Phase | Role | Agent/Model | Tokens in | Tokens out | Cost | Date |
10237
+ | --- | --- | --- | --- | --- | --- | --- |
10238
+ | demo: develop | Developer | — | — | — | — | — |
10239
+
10034
10240
  ## Stop Conditions
10035
10241
  Autonomy: Do not stop to ask clarifying questions — this is a scripted demo; proceed and clean up automatically.
10036
10242
 
@@ -10152,8 +10358,10 @@ async function demoBreakCommand(args) {
10152
10358
 
10153
10359
  // src/lib/dispatch.ts
10154
10360
  import { randomUUID as randomUUID2, createHash as createHash6 } from "node:crypto";
10155
- import { accessSync, constants as fsConstants3, readFileSync as readFileSync14 } from "node:fs";
10361
+ import { accessSync, constants as fsConstants3, mkdirSync as mkdirSync4, readFileSync as readFileSync14, writeFileSync as writeFileSync5 } from "node:fs";
10362
+ import { chmodSync as chmodSync2, createWriteStream } from "node:fs";
10156
10363
  import { execFileSync as execFileSync13, spawn as spawn2 } from "node:child_process";
10364
+ import { homedir as homedir3 } from "node:os";
10157
10365
 
10158
10366
  // src/lib/log-sink.ts
10159
10367
  import { execFileSync as execFileSync12 } from "node:child_process";
@@ -10354,21 +10562,112 @@ function log(e) {
10354
10562
  }
10355
10563
 
10356
10564
  // src/lib/dispatch.ts
10357
- import { join as join14 } from "node:path";
10565
+ import { dirname as dirname7, join as join14 } from "node:path";
10358
10566
  var AGENT_VENDOR_NAMES = ["claude", "codex", "gemini"];
10359
10567
  function isAgentVendor2(value) {
10360
10568
  return AGENT_VENDOR_NAMES.includes(value);
10361
10569
  }
10362
- var DEFAULT_TIMEOUT_MS = 3600000;
10570
+ var AGENT_CLASS_VALUES = ["high", "mid", "fast"];
10571
+ function isAgentClass(value) {
10572
+ return AGENT_CLASS_VALUES.includes(value);
10573
+ }
10574
+ var DEFAULT_TIMEOUT_MS = 14400000;
10363
10575
  var SIGKILL_GRACE_MS = 5000;
10364
- function parseClaudeUsage(stdout) {
10576
+ var HEARTBEAT_INTERVAL_MS = 60000;
10577
+ function timeoutWarningLeadMs(timeoutMs) {
10578
+ return Math.min(300000, Math.floor(timeoutMs / 2));
10579
+ }
10580
+ var MAX_TEE_BYTES = 8 * 1024 * 1024;
10581
+ function openOutputTee(effectId) {
10582
+ const inert = { write: () => {}, end: () => {}, path: null };
10583
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(effectId))
10584
+ return inert;
10365
10585
  try {
10366
- const obj = JSON.parse(stdout);
10367
- const u = obj.usage;
10368
- if (u && typeof u.input_tokens === "number" && typeof u.output_tokens === "number") {
10369
- return { input: u.input_tokens, output: u.output_tokens };
10370
- }
10371
- } catch {}
10586
+ const dir = join14(GLOBAL_VINAYA_HOME, "dispatch-output");
10587
+ mkdirSync4(dir, { recursive: true, mode: 448 });
10588
+ chmodSync2(dir, 448);
10589
+ const path = join14(dir, `${effectId}.log`);
10590
+ const stream = createWriteStream(path, { flags: "a", mode: 384 });
10591
+ let broken = false;
10592
+ let written = 0;
10593
+ let carry = "";
10594
+ stream.on("error", () => {
10595
+ broken = true;
10596
+ });
10597
+ const home = homedir3();
10598
+ return {
10599
+ write: (chunk) => {
10600
+ if (broken || written >= MAX_TEE_BYTES)
10601
+ return;
10602
+ try {
10603
+ const text = carry + chunk.toString("utf8");
10604
+ const cut = Math.max(0, text.length - 256);
10605
+ const emit = redact(text.slice(0, cut), home);
10606
+ carry = text.slice(cut);
10607
+ if (emit.length === 0)
10608
+ return;
10609
+ written += Buffer.byteLength(emit);
10610
+ stream.write(emit);
10611
+ } catch {
10612
+ broken = true;
10613
+ }
10614
+ },
10615
+ end: () => {
10616
+ try {
10617
+ if (!broken && carry.length > 0)
10618
+ stream.write(redact(carry, home));
10619
+ stream.end();
10620
+ } catch {
10621
+ broken = true;
10622
+ }
10623
+ },
10624
+ path
10625
+ };
10626
+ } catch {
10627
+ return inert;
10628
+ }
10629
+ }
10630
+ function parseClaudeModel(stdout) {
10631
+ const lines = stdout.split(`
10632
+ `).filter((l) => l.trim().length > 0);
10633
+ for (let i = lines.length - 1;i >= 0; i--) {
10634
+ try {
10635
+ const obj = JSON.parse(lines[i]);
10636
+ const keys = obj.modelUsage ? Object.keys(obj.modelUsage) : [];
10637
+ if (keys.length > 0)
10638
+ return keys.join(",");
10639
+ } catch {}
10640
+ }
10641
+ return null;
10642
+ }
10643
+ function parseCodexModel(_stdout) {
10644
+ return null;
10645
+ }
10646
+ function parseGeminiModel(stdout) {
10647
+ const lines = stdout.split(`
10648
+ `).filter((l) => l.trim().length > 0);
10649
+ for (let i = lines.length - 1;i >= 0; i--) {
10650
+ try {
10651
+ const obj = JSON.parse(lines[i]);
10652
+ const keys = obj.stats?.models ? Object.keys(obj.stats.models) : [];
10653
+ if (keys.length > 0)
10654
+ return keys.join(",");
10655
+ } catch {}
10656
+ }
10657
+ return null;
10658
+ }
10659
+ function parseClaudeUsage(stdout) {
10660
+ const lines = stdout.split(`
10661
+ `).filter((l) => l.trim().length > 0);
10662
+ for (let i = lines.length - 1;i >= 0; i--) {
10663
+ try {
10664
+ const obj = JSON.parse(lines[i]);
10665
+ const u = obj.usage;
10666
+ if (u && typeof u.input_tokens === "number" && typeof u.output_tokens === "number") {
10667
+ return { input: u.input_tokens, output: u.output_tokens };
10668
+ }
10669
+ } catch {}
10670
+ }
10372
10671
  return null;
10373
10672
  }
10374
10673
  function parseCodexUsage(stdout) {
@@ -10390,16 +10689,31 @@ function parseCodexUsage(stdout) {
10390
10689
  }
10391
10690
  return null;
10392
10691
  }
10393
- function parseGeminiUsage(_stdout) {
10692
+ function parseGeminiUsage(stdout) {
10693
+ const lines = stdout.split(`
10694
+ `).filter((l) => l.trim().length > 0);
10695
+ for (let i = lines.length - 1;i >= 0; i--) {
10696
+ try {
10697
+ const obj = JSON.parse(lines[i]);
10698
+ const st = obj.stats;
10699
+ if (st && typeof st.input_tokens === "number" && typeof st.output_tokens === "number") {
10700
+ return { input: st.input_tokens, output: st.output_tokens };
10701
+ }
10702
+ } catch {}
10703
+ }
10394
10704
  return null;
10395
10705
  }
10396
10706
  function parseClaudeResumeId(stdout) {
10397
- try {
10398
- const obj = JSON.parse(stdout);
10399
- return typeof obj.session_id === "string" ? obj.session_id : null;
10400
- } catch {
10401
- return null;
10707
+ const lines = stdout.split(`
10708
+ `).filter((l) => l.trim().length > 0);
10709
+ for (let i = lines.length - 1;i >= 0; i--) {
10710
+ try {
10711
+ const obj = JSON.parse(lines[i]);
10712
+ if (typeof obj.session_id === "string")
10713
+ return obj.session_id;
10714
+ } catch {}
10402
10715
  }
10716
+ return null;
10403
10717
  }
10404
10718
  function parseCodexResumeId(stdout) {
10405
10719
  for (const raw of stdout.split(`
@@ -10423,29 +10737,132 @@ function parseGeminiResumeId(stdout) {
10423
10737
  return null;
10424
10738
  }
10425
10739
  }
10740
+ var SAFE_REPO_SEGMENT = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
10741
+ function isSafeRepoSegment2(segment) {
10742
+ return SAFE_REPO_SEGMENT.test(segment) && !segment.includes("..");
10743
+ }
10744
+ function resumeRecordPathFor(role, agent, repo, task, pr) {
10745
+ const repoSegment = repo && isSafeRepoSegment2(repo.owner) && isSafeRepoSegment2(repo.repo) ? `${repo.owner}-${repo.repo}` : "unresolved";
10746
+ const scope = task !== undefined ? `issue${task}` : pr !== undefined ? `pr${pr}` : "unscoped";
10747
+ return join14(GLOBAL_VINAYA_HOME, "dispatch-resume", repoSegment, `${role}-${agent}-${scope}.json`);
10748
+ }
10749
+ function recordResumeState(record) {
10750
+ try {
10751
+ const path = resumeRecordPathFor(record.role, record.agent, record.repo, record.task ?? undefined, record.pr ?? undefined);
10752
+ mkdirSync4(dirname7(path), { recursive: true, mode: 448 });
10753
+ chmodSync2(dirname7(path), 448);
10754
+ writeFileSync5(path, JSON.stringify(record, null, 2), { mode: 384 });
10755
+ return path;
10756
+ } catch {
10757
+ return null;
10758
+ }
10759
+ }
10760
+ function renderClaudeEvent(obj) {
10761
+ const type = obj.type;
10762
+ if (type === "assistant" || type === "user") {
10763
+ const msg = obj.message;
10764
+ const content = Array.isArray(msg?.content) ? msg?.content : [];
10765
+ const out = [];
10766
+ for (const block of content) {
10767
+ if (block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0) {
10768
+ out.push(block.text.trim());
10769
+ } else if (block.type === "tool_use" && typeof block.name === "string") {
10770
+ const input = block.input;
10771
+ const subject = typeof input?.file_path === "string" && input.file_path || typeof input?.command === "string" && input.command || typeof input?.pattern === "string" && input.pattern || typeof input?.path === "string" && input.path || "";
10772
+ const trimmed = subject.length > 120 ? `${subject.slice(0, 117)}...` : subject;
10773
+ out.push(trimmed ? `⚙ ${block.name}: ${trimmed}` : `⚙ ${block.name}`);
10774
+ }
10775
+ }
10776
+ return out.length > 0 ? out.join(`
10777
+ `) : null;
10778
+ }
10779
+ if (type === "system" && obj.subtype === "init")
10780
+ return "⏵ session started";
10781
+ if (typeof obj.stop_reason === "string")
10782
+ return `⏹ ${obj.stop_reason}`;
10783
+ return null;
10784
+ }
10785
+ function renderCodexEvent(obj) {
10786
+ const type = typeof obj.type === "string" ? obj.type : "";
10787
+ if (type.endsWith(".completed") || type.endsWith(".started"))
10788
+ return `⏵ ${type}`;
10789
+ const item = obj.item;
10790
+ return typeof item?.text === "string" && item.text.trim().length > 0 ? item.text.trim() : null;
10791
+ }
10792
+ function renderGeminiEvent(obj) {
10793
+ if (obj.type === "init")
10794
+ return "⏵ session started";
10795
+ if (obj.type === "message" && obj.role === "assistant") {
10796
+ const text = typeof obj.content === "string" ? obj.content.trim() : "";
10797
+ return text.length > 0 ? text : null;
10798
+ }
10799
+ if (obj.type === "result") {
10800
+ const status = typeof obj.status === "string" ? obj.status : "finished";
10801
+ return `⏹ ${status}`;
10802
+ }
10803
+ return null;
10804
+ }
10426
10805
  var VENDOR_TABLE = {
10427
10806
  claude: {
10428
10807
  binary: "claude",
10429
- args: ["-p", "--output-format", "json"],
10430
- resumeArgs: (id) => ["-p", "-r", id, "--output-format", "json"],
10808
+ args: (model) => ["-p", "--verbose", "--output-format", "stream-json", ...model ? ["--model", model] : []],
10809
+ resumeArgs: (id, model) => [
10810
+ "-p",
10811
+ "-r",
10812
+ id,
10813
+ "--verbose",
10814
+ "--output-format",
10815
+ "stream-json",
10816
+ ...model ? ["--model", model] : []
10817
+ ],
10431
10818
  parseUsage: parseClaudeUsage,
10432
- parseResumeId: parseClaudeResumeId
10819
+ parseModel: parseClaudeModel,
10820
+ parseResumeId: parseClaudeResumeId,
10821
+ renderEvent: renderClaudeEvent,
10822
+ classModels: { high: "opus", mid: "sonnet", fast: "haiku" }
10433
10823
  },
10434
10824
  codex: {
10435
10825
  binary: "codex",
10436
- args: ["exec", "--json", "-"],
10437
- resumeArgs: (id) => ["exec", "resume", id, "--json", "-"],
10826
+ args: (model) => ["exec", ...model ? ["--model", model] : [], "--json", "-"],
10827
+ resumeArgs: (id, model) => ["exec", "resume", id, ...model ? ["--model", model] : [], "--json", "-"],
10438
10828
  parseUsage: parseCodexUsage,
10439
- parseResumeId: parseCodexResumeId
10829
+ parseModel: parseCodexModel,
10830
+ parseResumeId: parseCodexResumeId,
10831
+ renderEvent: renderCodexEvent,
10832
+ classModels: {}
10440
10833
  },
10441
10834
  gemini: {
10442
10835
  binary: "gemini",
10443
- args: ["-p", "", "--output-format", "json", "--skip-trust"],
10444
- resumeArgs: (id) => ["-p", "", "--resume", id, "--output-format", "json", "--skip-trust"],
10836
+ args: (model) => ["-p", "", ...model ? ["--model", model] : [], "--output-format", "stream-json", "--skip-trust"],
10837
+ resumeArgs: (id, model) => [
10838
+ "-p",
10839
+ "",
10840
+ "--resume",
10841
+ id,
10842
+ ...model ? ["--model", model] : [],
10843
+ "--output-format",
10844
+ "stream-json",
10845
+ "--skip-trust"
10846
+ ],
10445
10847
  parseUsage: parseGeminiUsage,
10446
- parseResumeId: parseGeminiResumeId
10848
+ parseModel: parseGeminiModel,
10849
+ parseResumeId: parseGeminiResumeId,
10850
+ renderEvent: renderGeminiEvent,
10851
+ classModels: {}
10447
10852
  }
10448
10853
  };
10854
+ var CLAUDE_MODEL_ALIASES = new Set(["fable", "opus", "sonnet", "haiku"]);
10855
+ function identifyVendorFromModelShape(model) {
10856
+ const lower = model.toLowerCase();
10857
+ if (CLAUDE_MODEL_ALIASES.has(lower) || lower.startsWith("claude-"))
10858
+ return "claude";
10859
+ if (lower.startsWith("gemini-") || lower.startsWith("gemma-"))
10860
+ return "gemini";
10861
+ return null;
10862
+ }
10863
+ function resolveClassModel(agent, agentClass) {
10864
+ return VENDOR_TABLE[agent].classModels[agentClass] ?? null;
10865
+ }
10449
10866
  function resolveExecutable(binary) {
10450
10867
  try {
10451
10868
  const path = execFileSync13("which", [binary], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
@@ -10513,6 +10930,30 @@ async function dispatchRole(role, agent, prompt2, opts) {
10513
10930
  const vendor = VENDOR_TABLE[agent];
10514
10931
  const start = Date.now();
10515
10932
  const roundField = opts.round !== undefined ? { round: opts.round } : {};
10933
+ const resolvedModel = opts.model !== undefined ? `requested:${opts.model}` : "default";
10934
+ if (opts.model !== undefined) {
10935
+ const foreignVendor = identifyVendorFromModelShape(opts.model);
10936
+ if (foreignVendor !== null && foreignVendor !== agent) {
10937
+ const durationMs = Date.now() - start;
10938
+ const priorSize = sizeOfSafe(outboxPath);
10939
+ log2({
10940
+ kind: "dispatch",
10941
+ event: "dispatch_failed",
10942
+ payload: {},
10943
+ target_role: role,
10944
+ model: resolvedModel,
10945
+ ...roundField,
10946
+ effect_id: effectId,
10947
+ reason: "refused",
10948
+ usage: null,
10949
+ duration_ms: durationMs
10950
+ });
10951
+ process.stderr.write(`[vinaya dispatch ${effectId}] ${role} via ${agent}: refused — model '${opts.model}' is a ${foreignVendor} model; ` + `${agent} does not accept it. ${agent} accepts its own model names (never a ${foreignVendor} alias or a ` + `'${foreignVendor}-'/'gemma-' full name).
10952
+ `);
10953
+ await waitForDispatchLine(outboxPath, priorSize, runId, effectId, "dispatch_failed");
10954
+ return { exitCode: null, durationMs, usage: null, resumeId: null, timedOut: false, failureReason: "refused" };
10955
+ }
10956
+ }
10516
10957
  const binaryPath = resolveExecutable(vendor.binary);
10517
10958
  if (binaryPath === null) {
10518
10959
  const durationMs = Date.now() - start;
@@ -10522,10 +10963,11 @@ async function dispatchRole(role, agent, prompt2, opts) {
10522
10963
  event: "dispatch_failed",
10523
10964
  payload: {},
10524
10965
  target_role: role,
10525
- model: agent,
10966
+ model: resolvedModel,
10526
10967
  ...roundField,
10527
10968
  effect_id: effectId,
10528
10969
  reason: "refused",
10970
+ usage: null,
10529
10971
  duration_ms: durationMs
10530
10972
  });
10531
10973
  await waitForDispatchLine(outboxPath, priorSize, runId, effectId, "dispatch_failed");
@@ -10538,7 +10980,7 @@ async function dispatchRole(role, agent, prompt2, opts) {
10538
10980
  event: "dispatched",
10539
10981
  payload: {},
10540
10982
  target_role: role,
10541
- model: agent,
10983
+ model: resolvedModel,
10542
10984
  ...roundField,
10543
10985
  effect_id: effectId,
10544
10986
  prompt_hash: promptHashOf(prompt2)
@@ -10547,7 +10989,7 @@ async function dispatchRole(role, agent, prompt2, opts) {
10547
10989
  }
10548
10990
  const timeoutMs = loadConfig()?.dispatch?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
10549
10991
  return new Promise((resolve3) => {
10550
- const child = spawn2(binaryPath, opts.resumeId ? vendor.resumeArgs(opts.resumeId) : vendor.args, {
10992
+ const child = spawn2(binaryPath, opts.resumeId ? vendor.resumeArgs(opts.resumeId, opts.model) : vendor.args(opts.model), {
10551
10993
  stdio: ["pipe", "pipe", "pipe"],
10552
10994
  env: {
10553
10995
  ...process.env,
@@ -10562,15 +11004,52 @@ async function dispatchRole(role, agent, prompt2, opts) {
10562
11004
  let killTimer;
10563
11005
  let stdoutBuf = "";
10564
11006
  const MAX_STDOUT_BYTES = 1e6;
11007
+ const outputTee = openOutputTee(effectId);
11008
+ if (outputTee.path !== null) {
11009
+ process.stderr.write(`[vinaya dispatch ${effectId}] ${role} via ${agent}: output teed to ${outputTee.path}
11010
+ `);
11011
+ }
11012
+ let renderCarry = "";
10565
11013
  child.stdout.on("data", (chunk) => {
11014
+ outputTee.write(chunk);
10566
11015
  if (stdoutBuf.length < MAX_STDOUT_BYTES)
10567
11016
  stdoutBuf += chunk.toString("utf8");
11017
+ renderCarry += chunk.toString("utf8");
11018
+ const lines = renderCarry.split(`
11019
+ `);
11020
+ renderCarry = lines.pop() ?? "";
11021
+ for (const line of lines) {
11022
+ if (line.trim().length === 0)
11023
+ continue;
11024
+ try {
11025
+ const rendered = vendor.renderEvent(JSON.parse(line));
11026
+ if (rendered)
11027
+ process.stderr.write(`${rendered}
11028
+ `);
11029
+ } catch {}
11030
+ }
10568
11031
  });
10569
- child.stderr.on("data", () => {});
11032
+ child.stderr.on("data", (chunk) => {
11033
+ outputTee.write(chunk);
11034
+ });
11035
+ const heartbeatTimer = setInterval(() => {
11036
+ const elapsedS = Math.round((Date.now() - start) / 1000);
11037
+ process.stderr.write(`[vinaya dispatch ${effectId}] ${role} via ${agent}: still running — ${elapsedS}s elapsed (ceiling ${Math.round(timeoutMs / 1000)}s)
11038
+ `);
11039
+ }, HEARTBEAT_INTERVAL_MS);
11040
+ const warnLeadMs = timeoutWarningLeadMs(timeoutMs);
11041
+ const warnTimer = setTimeout(() => {
11042
+ process.stderr.write(`[vinaya dispatch ${effectId}] ${role} via ${agent}: approaching timeout — SIGTERM in ~${Math.round(warnLeadMs / 1000)}s unless it finishes first
11043
+ `);
11044
+ }, Math.max(timeoutMs - warnLeadMs, 0));
10570
11045
  const timeoutTimer = setTimeout(() => {
10571
11046
  timedOut = true;
11047
+ process.stderr.write(`[vinaya dispatch ${effectId}] ${role} via ${agent}: ceiling reached — sending SIGTERM
11048
+ `);
10572
11049
  child.kill("SIGTERM");
10573
11050
  killTimer = setTimeout(() => {
11051
+ process.stderr.write(`[vinaya dispatch ${effectId}] ${role} via ${agent}: still alive after SIGTERM — sending SIGKILL
11052
+ `);
10574
11053
  child.kill("SIGKILL");
10575
11054
  }, SIGKILL_GRACE_MS);
10576
11055
  }, timeoutMs);
@@ -10581,6 +11060,9 @@ async function dispatchRole(role, agent, prompt2, opts) {
10581
11060
  clearTimeout(timeoutTimer);
10582
11061
  if (killTimer)
10583
11062
  clearTimeout(killTimer);
11063
+ clearInterval(heartbeatTimer);
11064
+ clearTimeout(warnTimer);
11065
+ outputTee.end();
10584
11066
  await waitForDispatchLine(outboxPath, priorSize, runId, effectId, event);
10585
11067
  resolve3(handle);
10586
11068
  }
@@ -10592,16 +11074,18 @@ async function dispatchRole(role, agent, prompt2, opts) {
10592
11074
  event: "dispatch_failed",
10593
11075
  payload: {},
10594
11076
  target_role: role,
10595
- model: agent,
11077
+ model: resolvedModel,
10596
11078
  ...roundField,
10597
11079
  effect_id: effectId,
10598
11080
  reason: "crash",
11081
+ usage: null,
10599
11082
  duration_ms: durationMs
10600
11083
  });
10601
11084
  finish({ exitCode: null, durationMs, usage: null, resumeId: null, timedOut: false, failureReason: "crash" }, "dispatch_failed", priorSize);
10602
11085
  });
10603
11086
  child.on("exit", (code) => {
10604
11087
  const durationMs = Date.now() - start;
11088
+ const usage = vendor.parseUsage(stdoutBuf);
10605
11089
  if (timedOut) {
10606
11090
  const priorSize2 = sizeOfSafe(outboxPath);
10607
11091
  log2({
@@ -10609,13 +11093,14 @@ async function dispatchRole(role, agent, prompt2, opts) {
10609
11093
  event: "dispatch_failed",
10610
11094
  payload: {},
10611
11095
  target_role: role,
10612
- model: agent,
11096
+ model: resolvedModel,
10613
11097
  ...roundField,
10614
11098
  effect_id: effectId,
10615
11099
  reason: "timeout",
11100
+ usage,
10616
11101
  duration_ms: durationMs
10617
11102
  });
10618
- finish({ exitCode: code, durationMs, usage: null, resumeId: null, timedOut: true, failureReason: "timeout" }, "dispatch_failed", priorSize2);
11103
+ finish({ exitCode: code, durationMs, usage, resumeId: null, timedOut: true, failureReason: "timeout" }, "dispatch_failed", priorSize2);
10619
11104
  return;
10620
11105
  }
10621
11106
  if (code !== 0) {
@@ -10625,24 +11110,42 @@ async function dispatchRole(role, agent, prompt2, opts) {
10625
11110
  event: "dispatch_failed",
10626
11111
  payload: {},
10627
11112
  target_role: role,
10628
- model: agent,
11113
+ model: resolvedModel,
10629
11114
  ...roundField,
10630
11115
  effect_id: effectId,
10631
11116
  reason: "crash",
11117
+ usage,
10632
11118
  duration_ms: durationMs
10633
11119
  });
10634
- finish({ exitCode: code, durationMs, usage: null, resumeId: null, timedOut: false, failureReason: "crash" }, "dispatch_failed", priorSize2);
11120
+ finish({ exitCode: code, durationMs, usage, resumeId: null, timedOut: false, failureReason: "crash" }, "dispatch_failed", priorSize2);
10635
11121
  return;
10636
11122
  }
10637
- const usage = vendor.parseUsage(stdoutBuf);
10638
11123
  const resumeId = vendor.parseResumeId(stdoutBuf);
11124
+ if (resumeId !== null) {
11125
+ const resumeRecordPath = recordResumeState({
11126
+ resumeId,
11127
+ role,
11128
+ agent,
11129
+ repo,
11130
+ task: opts.task ?? null,
11131
+ pr: opts.pr ?? null,
11132
+ round: opts.round ?? null,
11133
+ effectId,
11134
+ capturedAt: new Date().toISOString()
11135
+ });
11136
+ if (resumeRecordPath !== null) {
11137
+ process.stderr.write(`[vinaya dispatch ${effectId}] ${role} via ${agent}: resumable — session recorded at ${resumeRecordPath}
11138
+ `);
11139
+ }
11140
+ }
11141
+ const reportedModel = vendor.parseModel(stdoutBuf);
10639
11142
  const priorSize = sizeOfSafe(outboxPath);
10640
11143
  log2({
10641
11144
  kind: "dispatch",
10642
11145
  event: "outcome_received",
10643
11146
  payload: {},
10644
11147
  target_role: role,
10645
- model: agent,
11148
+ model: reportedModel ?? resolvedModel,
10646
11149
  ...roundField,
10647
11150
  effect_id: effectId,
10648
11151
  outcome: { type: "plan", issues: [] },
@@ -10659,19 +11162,19 @@ async function dispatchRole(role, agent, prompt2, opts) {
10659
11162
  // src/lib/dev-review-loop.ts
10660
11163
  import { randomUUID as randomUUID3 } from "node:crypto";
10661
11164
  import { execFileSync as execFileSync17 } from "node:child_process";
10662
- import { mkdirSync as mkdirSync4, mkdtempSync as mkdtempSync3, readFileSync as readFileSync17, rmSync as rmSync6, unlinkSync as unlinkSync2, writeFileSync as writeFileSync8 } from "node:fs";
11165
+ import { mkdirSync as mkdirSync5, mkdtempSync as mkdtempSync3, readFileSync as readFileSync17, rmSync as rmSync6, unlinkSync as unlinkSync2, writeFileSync as writeFileSync9 } from "node:fs";
10663
11166
  import { tmpdir as tmpdir4 } from "node:os";
10664
- import { join as join18 } from "node:path";
11167
+ import { dirname as dirname8, join as join18 } from "node:path";
10665
11168
 
10666
11169
  // src/commands/review-post.ts
10667
11170
  import { execFileSync as execFileSync15 } from "node:child_process";
10668
- import { readFileSync as readFileSync16, rmSync as rmSync4, writeFileSync as writeFileSync6 } from "node:fs";
11171
+ import { readFileSync as readFileSync16, rmSync as rmSync4, writeFileSync as writeFileSync7 } from "node:fs";
10669
11172
  import { tmpdir as tmpdir2 } from "node:os";
10670
11173
  import { join as join16 } from "node:path";
10671
11174
 
10672
11175
  // src/lib/forge-write.ts
10673
11176
  import { execFileSync as execFileSync14 } from "node:child_process";
10674
- import { existsSync as existsSync12, mkdtempSync, readdirSync as readdirSync6, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "node:fs";
11177
+ import { existsSync as existsSync12, mkdtempSync, readdirSync as readdirSync6, rmSync as rmSync3, writeFileSync as writeFileSync6 } from "node:fs";
10675
11178
  import { readFileSync as readFileSync15 } from "node:fs";
10676
11179
  import { tmpdir } from "node:os";
10677
11180
  import { join as join15 } from "node:path";
@@ -10709,7 +11212,7 @@ function resolveShippableArgs(args, bodyResult) {
10709
11212
  }
10710
11213
  const dir = mkdtempSync(join15(tmpdir(), "vinaya-forge-body-"));
10711
11214
  const tempPath = join15(dir, "body.md");
10712
- writeFileSync5(tempPath, bodyResult.body, "utf8");
11215
+ writeFileSync6(tempPath, bodyResult.body, "utf8");
10713
11216
  const finalArgs = [...args];
10714
11217
  const { argIndex, inlineForm } = bodyResult.source;
10715
11218
  finalArgs[argIndex] = inlineForm ? `--body-file=${tempPath}` : tempPath;
@@ -10995,13 +11498,22 @@ var CHECK_ISSUE_CONTENT = "issue-content";
10995
11498
  var ISSUE_CONTENT_RECOVERY = {
10996
11499
  blastRadius: "Add a second registered `Project(s)` this task also touches, or a `blast-radius-ack: <why one lens is enough>` line, then re-run `{cmd}`.",
10997
11500
  noBriefContent: "Move the brief-shaped section named above out of the Issue body and into the brief — the Issue carries the Planner's durable rationale, not the brief's just-in-time surface — then re-run `{cmd}`.",
10998
- rationaleNamesDocs: 'Name a concrete doc/skill path (e.g. `aeg-root/…`, `.claude/skills/…/SKILL.md`) in "Docs to keep coherent" or "Traps", or write the `no-doc-surface` sentinel if the surface genuinely has none, then re-run `{cmd}`.'
11501
+ rationaleNamesDocs: 'Name a concrete doc/skill path (e.g. `aeg-root/…`, `.claude/skills/…/SKILL.md`) in "Docs to keep coherent" or "Traps", or write the `no-doc-surface` sentinel if the surface genuinely has none, then re-run `{cmd}`.',
11502
+ surfaceGlobsResolve: "Fix the named `## Surface` `in:` glob so it matches at least one real tracked file (a typo, or a directory that does not exist yet), then re-run `{cmd}`.",
11503
+ partsCiteObjectives: "Fix the named Part to cite an objective id the `## Objectives` section actually defines, or add the missing objective, then re-run `{cmd}`.",
11504
+ docsWithinSurface: 'Move the named doc pointer to a path `## Surface`\'s `in:` globs actually cover (never widen the surface just to fit the pointer — that renders an unusable brief), or drop it from "Docs to keep coherent" if this task does not really keep it coherent, then re-run `{cmd}`.'
10999
11505
  };
11000
11506
  function validateIssueContent(input) {
11001
11507
  const findings = [
11002
- [checkBlastRadiusScope(input.body, input.labels, input.sharedPackages, input.projectPaths).errors, "blastRadius"],
11508
+ [
11509
+ checkBlastRadiusScope(input.body, input.labels, input.sharedPackages, input.projectPaths, input.issueNumber).errors,
11510
+ "blastRadius"
11511
+ ],
11003
11512
  [checkNoBriefContent(input.body).errors, "noBriefContent"],
11004
- [checkRationaleNamesDocs(input.body).errors, "rationaleNamesDocs"]
11513
+ [checkRationaleNamesDocs(input.body).errors, "rationaleNamesDocs"],
11514
+ [checkSurfaceGlobsResolve(input.body, input.resolvesToFile).errors, "surfaceGlobsResolve"],
11515
+ [checkPartsCiteDefinedObjectives(input.body).errors, "partsCiteObjectives"],
11516
+ [checkDocsWithinSurface(input.body, input.issueNumber).errors, "docsWithinSurface"]
11005
11517
  ];
11006
11518
  const errors = [];
11007
11519
  for (const [messages, kind] of findings) {
@@ -11073,7 +11585,9 @@ function validateTaskIssue(body, title, labels, retryCommand, issueNumber) {
11073
11585
  labels,
11074
11586
  sharedPackages: readSharedPackages(),
11075
11587
  projectPaths: readProjectPaths(),
11076
- retryCommand
11588
+ retryCommand,
11589
+ issueNumber,
11590
+ resolvesToFile: (glob) => expandGlob(glob).length > 0
11077
11591
  });
11078
11592
  if (contentErrors.length > 0)
11079
11593
  refuse2(contentErrors);
@@ -11120,7 +11634,7 @@ ${body}
11120
11634
  `;
11121
11635
  const dir = mkdtempSync(join15(tmpdir(), "vinaya-marked-comment-"));
11122
11636
  const tmp = join15(dir, "comment.md");
11123
- writeFileSync5(tmp, commentBody, "utf8");
11637
+ writeFileSync6(tmp, commentBody, "utf8");
11124
11638
  try {
11125
11639
  const out = execFileSync14("gh", [kind, "comment", ref, "--body-file", tmp], {
11126
11640
  encoding: "utf8",
@@ -11769,7 +12283,7 @@ function printOnlyResult(json, role, headSha, body) {
11769
12283
  }
11770
12284
  function postComment(pr, body) {
11771
12285
  const tmp = join16(tmpdir2(), `vinaya-review-post-${process.pid}-${Date.now()}.md`);
11772
- writeFileSync6(tmp, body);
12286
+ writeFileSync7(tmp, body);
11773
12287
  try {
11774
12288
  return gh(["pr", "comment", pr, "--body-file", tmp]).trim();
11775
12289
  } catch (err) {
@@ -12114,10 +12628,9 @@ Self-verification: clean — re-parsed VERDICT is bound to head ${headSha}.
12114
12628
  // src/lib/dispatch-task.ts
12115
12629
  import { execFileSync as execFileSync16 } from "node:child_process";
12116
12630
  import { createHash as createHash7 } from "node:crypto";
12117
- import { mkdtempSync as mkdtempSync2, rmSync as rmSync5, writeFileSync as writeFileSync7 } from "node:fs";
12631
+ import { mkdtempSync as mkdtempSync2, rmSync as rmSync5, writeFileSync as writeFileSync8 } from "node:fs";
12118
12632
  import { tmpdir as tmpdir3 } from "node:os";
12119
12633
  import { join as join17 } from "node:path";
12120
- var AEG_BRIEF_V1_MARKER = "<!-- aeg:brief:v1 -->";
12121
12634
  var DISPATCH_AGENTS = ["claude", "codex", "gemini"];
12122
12635
 
12123
12636
  class DispatchTaskError extends Error {
@@ -12125,17 +12638,6 @@ class DispatchTaskError extends Error {
12125
12638
  function sh3(cmd, args) {
12126
12639
  return execFileSync16(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
12127
12640
  }
12128
- function contentAfterTwoLines(body) {
12129
- const first = body.indexOf(`
12130
- `);
12131
- if (first === -1)
12132
- return "";
12133
- const second = body.indexOf(`
12134
- `, first + 1);
12135
- if (second === -1)
12136
- return "";
12137
- return body.slice(second + 1);
12138
- }
12139
12641
  function briefHash(brief) {
12140
12642
  return createHash7("sha256").update(`${brief}
12141
12643
  `).digest("hex");
@@ -12153,6 +12655,32 @@ function fetchIssueComments(n) {
12153
12655
  throw new DispatchTaskError(`could not parse \`gh issue view ${n} --json comments\` output.`);
12154
12656
  }
12155
12657
  }
12658
+ function fetchIssueBody(n) {
12659
+ try {
12660
+ return JSON.parse(sh3("gh", ["issue", "view", String(n), "--json", "body"])).body;
12661
+ } catch (err) {
12662
+ throw new DispatchTaskError(`could not fetch Issue #${n}'s body (\`gh issue view\`) to resolve its suggested agent-class: ${err instanceof Error ? err.message : String(err)}`);
12663
+ }
12664
+ }
12665
+ function extractAgentClass(rawRationaleField) {
12666
+ const m = /agent-class\**\s*[—–-]\s*(\w+)/i.exec(rawRationaleField);
12667
+ const word = m?.[1]?.toLowerCase();
12668
+ return word !== undefined && isAgentClass(word) ? word : null;
12669
+ }
12670
+ function resolveModelFromRationale(agent, rawRationaleField, explicitModel) {
12671
+ if (explicitModel !== undefined)
12672
+ return explicitModel;
12673
+ const agentClass = rawRationaleField !== undefined ? extractAgentClass(rawRationaleField) : null;
12674
+ if (agentClass === null)
12675
+ return;
12676
+ return resolveClassModel(agent, agentClass) ?? undefined;
12677
+ }
12678
+ function resolveModelForDispatch(agent, issue, explicitModel) {
12679
+ if (explicitModel !== undefined)
12680
+ return explicitModel;
12681
+ const raw = parseRationaleFields(fetchIssueBody(issue)).suggestedAgentClass;
12682
+ return resolveModelFromRationale(agent, raw, undefined);
12683
+ }
12156
12684
  function findExistingV1Comment(n) {
12157
12685
  return fetchIssueComments(n).find((c) => c.body.split(`
12158
12686
  `)[0] === AEG_BRIEF_V1_MARKER) ?? null;
@@ -12178,7 +12706,7 @@ Once \`dispatchRole\` ships, the same \`--agent\` flag on \`task dispatch\` will
12178
12706
  async function withPromptFile(prompt2, fn) {
12179
12707
  const dir = mkdtempSync2(join17(tmpdir3(), "vinaya-dispatch-prompt-"));
12180
12708
  const promptFile = join17(dir, "prompt.md");
12181
- writeFileSync7(promptFile, prompt2, "utf8");
12709
+ writeFileSync8(promptFile, prompt2, "utf8");
12182
12710
  try {
12183
12711
  return await fn(promptFile);
12184
12712
  } finally {
@@ -12195,10 +12723,11 @@ var defaultDeps2 = {
12195
12723
  findExistingV1Comment,
12196
12724
  postMarkedComment,
12197
12725
  resolveDispatchRole,
12198
- resolveDispatchAuthorization
12726
+ resolveDispatchAuthorization,
12727
+ resolveModelForDispatch
12199
12728
  };
12200
12729
  async function dispatchTask(input, deps = defaultDeps2) {
12201
- const { tranche, n, agent } = input;
12730
+ const { tranche, n, agent, model } = input;
12202
12731
  {
12203
12732
  const { authorized, login } = deps.resolveDispatchAuthorization();
12204
12733
  if (!authorized) {
@@ -12211,18 +12740,30 @@ async function dispatchTask(input, deps = defaultDeps2) {
12211
12740
  ${result.missing.map((m) => ` - ${m}`).join(`
12212
12741
  `)}`);
12213
12742
  }
12214
- const existing = deps.findExistingV1Comment(n);
12743
+ const issue = result.issue;
12744
+ const existing = deps.findExistingV1Comment(issue);
12215
12745
  if (existing) {
12216
12746
  throw new DispatchTaskError(`Task ${n} in tranche \`${tranche}\` is already dispatched — see ${existing.url}`);
12217
12747
  }
12748
+ let dispatchRole2 = null;
12749
+ let resolvedModel;
12750
+ if (agent) {
12751
+ dispatchRole2 = await deps.resolveDispatchRole();
12752
+ if (dispatchRole2) {
12753
+ resolvedModel = deps.resolveModelForDispatch(agent, issue, model);
12754
+ }
12755
+ }
12218
12756
  const hash = briefHash(result.brief);
12219
12757
  const commentBody = `Brief hash: ${hash}
12220
12758
  ${result.brief}`;
12221
- const url = deps.postMarkedComment("issue", String(n), AEG_BRIEF_V1_MARKER, commentBody);
12759
+ const url = deps.postMarkedComment("issue", String(issue), AEG_BRIEF_V1_MARKER, commentBody);
12222
12760
  if (agent) {
12223
- const dispatchRole2 = await deps.resolveDispatchRole();
12224
12761
  if (dispatchRole2) {
12225
- await withPromptFile(result.brief, (promptFile) => dispatchRole2("developer", agent, result.brief, { task: n, promptFile }));
12762
+ await withPromptFile(result.brief, (promptFile) => dispatchRole2("developer", agent, result.brief, {
12763
+ task: issue,
12764
+ promptFile,
12765
+ model: resolvedModel
12766
+ }));
12226
12767
  } else {
12227
12768
  printManualDispatchInstruction(tranche, n, agent);
12228
12769
  }
@@ -12385,14 +12926,123 @@ function renderReviewerPrompt(facts) {
12385
12926
  function outboxRoot() {
12386
12927
  return join18(GLOBAL_VINAYA_HOME, "outbox");
12387
12928
  }
12929
+ function heldVerdictPath(root, task, round, role) {
12930
+ return join18(root, "dev-review-loop", String(task), `round-${round}-${role}.md`);
12931
+ }
12388
12932
  function writeHeldVerdict(root, task, round, role, renderedComment) {
12389
12933
  const dir = join18(root, "dev-review-loop", String(task));
12390
- mkdirSync4(dir, { recursive: true });
12391
- writeFileSync8(join18(dir, `round-${round}-${role}.md`), renderedComment, "utf8");
12934
+ mkdirSync5(dir, { recursive: true });
12935
+ writeFileSync9(heldVerdictPath(root, task, round, role), renderedComment, "utf8");
12392
12936
  }
12393
12937
  function reviewerWorkDir(root, task, round, role) {
12394
12938
  return join18(root, "dev-review-loop", String(task), `round-${round}-${role}-work`);
12395
12939
  }
12940
+ function forgeEffectPath(root, task, key) {
12941
+ return join18(root, "dev-review-loop", String(task), `effect-${key}.json`);
12942
+ }
12943
+ function readForgeEffect(path) {
12944
+ const raw = readIfExists(path);
12945
+ if (!raw)
12946
+ return null;
12947
+ try {
12948
+ return JSON.parse(raw);
12949
+ } catch {
12950
+ return null;
12951
+ }
12952
+ }
12953
+ function writeForgeEffect(path, record) {
12954
+ mkdirSync5(dirname8(path), { recursive: true });
12955
+ writeFileSync9(path, JSON.stringify(record), "utf8");
12956
+ }
12957
+ function postForgeEffectOnce(root, task, key, poster) {
12958
+ const path = forgeEffectPath(root, task, key);
12959
+ const existing = readForgeEffect(path);
12960
+ if (existing?.status === "posted" && existing.url)
12961
+ return existing.url;
12962
+ const effectId = existing?.effectId ?? randomUUID3();
12963
+ writeForgeEffect(path, { effectId, status: "started" });
12964
+ const url = poster();
12965
+ writeForgeEffect(path, { effectId, status: "posted", url });
12966
+ return url;
12967
+ }
12968
+ function postPrComment(pr, body) {
12969
+ const dir = mkdtempSync3(join18(tmpdir4(), "vinaya-dev-review-loop-comment-"));
12970
+ const tmp = join18(dir, "comment.md");
12971
+ writeFileSync9(tmp, body, "utf8");
12972
+ try {
12973
+ return execFileSync17("gh", ["pr", "comment", String(pr), "--body-file", tmp], {
12974
+ encoding: "utf8",
12975
+ stdio: ["ignore", "pipe", "pipe"]
12976
+ }).trim();
12977
+ } finally {
12978
+ rmSync6(dir, { recursive: true, force: true });
12979
+ }
12980
+ }
12981
+ function fetchAllPrCommentBodies(pr) {
12982
+ const out = sh4("gh", ["pr", "view", String(pr), "--json", "comments"]);
12983
+ return principalBodies(markerComments(out), principalAllowlist());
12984
+ }
12985
+ function publishRound(root, input) {
12986
+ const { task, round, prNumber, expectedHead } = input;
12987
+ const reviewerBody = readIfExists(heldVerdictPath(root, task, round, "reviewer"));
12988
+ const securityBody = readIfExists(heldVerdictPath(root, task, round, "security"));
12989
+ if (!reviewerBody || !securityBody) {
12990
+ throw new Error(`publishRound: missing held verdict file(s) for task ${task} round ${round} — writeHeldVerdict should have written both before assessRound ever returned 'publish'.`);
12991
+ }
12992
+ postForgeEffectOnce(root, task, `${round}-reviewer-verdict`, () => postPrComment(prNumber, reviewerBody));
12993
+ const postedReviewer = extractCodeReviewVerdict(fetchAllPrCommentBodies(prNumber));
12994
+ if (postedReviewer.danglingNote || postedReviewer.headSha !== expectedHead) {
12995
+ throw new Error(`publishRound: posted reviewer verdict does not re-parse clean through extractCodeReviewVerdict bound to ${expectedHead}: ${postedReviewer.danglingNote ?? `headSha read back as ${String(postedReviewer.headSha)}`}`);
12996
+ }
12997
+ postForgeEffectOnce(root, task, `${round}-security-verdict`, () => postPrComment(prNumber, securityBody));
12998
+ const postedSecurity = extractSecurityReviewVerdict(fetchAllPrCommentBodies(prNumber));
12999
+ if (postedSecurity.danglingNote || postedSecurity.headSha !== expectedHead) {
13000
+ throw new Error(`publishRound: posted security verdict does not re-parse clean through extractSecurityReviewVerdict bound to ${expectedHead}: ${postedSecurity.danglingNote ?? `headSha read back as ${String(postedSecurity.headSha)}`}`);
13001
+ }
13002
+ const summary = renderSummary(input.journal);
13003
+ const summaryAsCodeReview = extractCodeReviewVerdict([summary]);
13004
+ const summaryAsSecurity = extractSecurityReviewVerdict([summary]);
13005
+ if (summaryAsCodeReview.danglingNote === null || summaryAsSecurity.danglingNote === null) {
13006
+ throw new Error("publishRound: the rendered summary re-parses as a real verdict through the gate's own extractors — refusing to post it (a summary mistaken for a verdict decides a merge).");
13007
+ }
13008
+ postForgeEffectOnce(root, task, `${round}-summary`, () => postPrComment(prNumber, summary));
13009
+ }
13010
+ function pauseMarker(reason) {
13011
+ return `<!-- aeg:loop:paused:${reason} -->`;
13012
+ }
13013
+ function renderPauseComment(prNumber, reason) {
13014
+ return [
13015
+ `The dev-review-loop paused: ${reason}.`,
13016
+ "",
13017
+ "A Principal ruling is needed before this can continue. Once one is posted on this PR, resume with:",
13018
+ "",
13019
+ "```",
13020
+ `vinaya dev-review-loop --resume ${prNumber}`,
13021
+ "```"
13022
+ ].join(`
13023
+ `);
13024
+ }
13025
+ function postPauseComment(root, task, round, head, prNumber, reason) {
13026
+ postForgeEffectOnce(root, task, `pause-${round}-${head}`, () => postMarkedComment("pr", String(prNumber), pauseMarker(reason), renderPauseComment(prNumber, reason)));
13027
+ }
13028
+ function pauseStatePath(root, task) {
13029
+ return join18(root, "dev-review-loop", String(task), "pause-state.json");
13030
+ }
13031
+ function writePauseState(root, state) {
13032
+ const path = pauseStatePath(root, state.task);
13033
+ mkdirSync5(dirname8(path), { recursive: true });
13034
+ writeFileSync9(path, JSON.stringify(state), "utf8");
13035
+ }
13036
+ function readPauseState(root, task) {
13037
+ const raw = readIfExists(pauseStatePath(root, task));
13038
+ if (!raw)
13039
+ return null;
13040
+ try {
13041
+ return JSON.parse(raw);
13042
+ } catch {
13043
+ return null;
13044
+ }
13045
+ }
12396
13046
  var CONFIDENCE_PROMPT_LINE = "Before ending this turn, write your confidence in this round's changes to a file named `.vinaya-confidence` at the root of your worktree, containing exactly one line: `CONFIDENCE: <0-100> — <one-sentence reason>` (a whole number from 0 to 100, an em dash, then your reason in one sentence). This is read by the review loop before it decides the next step — do not skip it.";
12397
13047
  var CONFIDENCE_LINE = /^CONFIDENCE:\s*(\d{1,3})\s*(?:—|-)\s*(.+)$/m;
12398
13048
  function parseConfidenceReply(replyText) {
@@ -12430,7 +13080,7 @@ function readIfExists(path) {
12430
13080
  function withPromptFile2(prompt2, fn) {
12431
13081
  const dir = mkdtempSync3(join18(tmpdir4(), "vinaya-dev-review-loop-prompt-"));
12432
13082
  const promptFile = join18(dir, "prompt.md");
12433
- writeFileSync8(promptFile, prompt2, "utf8");
13083
+ writeFileSync9(promptFile, prompt2, "utf8");
12434
13084
  try {
12435
13085
  return fn(promptFile);
12436
13086
  } finally {
@@ -12675,18 +13325,66 @@ function renderReviewerDispatchPrompt(role, facts, workDir) {
12675
13325
 
12676
13326
  ${instructions}`;
12677
13327
  }
13328
+ function taskFromPrBody(body) {
13329
+ const m = /Closes #(\d+)/i.exec(body);
13330
+ return m ? Number(m[1]) : null;
13331
+ }
13332
+ function fetchPrBody2(pr) {
13333
+ const out = sh4("gh", ["pr", "view", String(pr), "--json", "body"]);
13334
+ return JSON.parse(out).body;
13335
+ }
13336
+ function routeCompletionEvents(events, decisionType) {
13337
+ if (decisionType !== "publish")
13338
+ return { toLogNow: [...events], toDeferUntilPublish: [] };
13339
+ return {
13340
+ toLogNow: events.filter((e) => e.event !== "journal_finalized"),
13341
+ toDeferUntilPublish: events.filter((e) => e.event === "journal_finalized")
13342
+ };
13343
+ }
12678
13344
  async function devReviewLoop(input, deps = {}) {
12679
13345
  const d = { ...defaultDeps3(), ...deps };
13346
+ const root = d.outboxRoot();
13347
+ let task;
13348
+ let branch;
13349
+ let prNumber = -1;
13350
+ let resumeFrom = null;
13351
+ if ("resumePr" in input) {
13352
+ const resumePr = input.resumePr;
13353
+ const closesTask = taskFromPrBody(fetchPrBody2(resumePr));
13354
+ if (closesTask === null) {
13355
+ throw new Error(`devReviewLoop --resume: PR #${resumePr}'s body carries no \`Closes #N\` reference — cannot derive its task.`);
13356
+ }
13357
+ const held = readPauseState(root, closesTask);
13358
+ if (!held) {
13359
+ throw new Error(`devReviewLoop --resume: no held pause state found for task ${closesTask} (PR #${resumePr}) — nothing to resume.`);
13360
+ }
13361
+ if (held.prNumber !== resumePr) {
13362
+ throw new Error(`devReviewLoop --resume: task ${closesTask}'s held pause state names PR #${held.prNumber}, not PR #${resumePr}.`);
13363
+ }
13364
+ const rulings = d.fetchRulings(resumePr);
13365
+ if (rulings.length === 0) {
13366
+ throw new Error(`devReviewLoop --resume: PR #${resumePr} carries no Principal ruling comment yet — nothing to resume from.`);
13367
+ }
13368
+ const currentHead = d.resolveHead(held.branch);
13369
+ if (currentHead !== held.head) {
13370
+ throw new Error(`devReviewLoop --resume: PR #${resumePr}'s head has moved since it paused (paused at ${held.head}, now ${currentHead}) — restart from round ${held.round} against the new head; resume never silently replays from round 1.`);
13371
+ }
13372
+ task = held.task;
13373
+ branch = held.branch;
13374
+ prNumber = held.prNumber;
13375
+ resumeFrom = held;
13376
+ } else {
13377
+ task = input.task;
13378
+ branch = d.developerBranchFor(task);
13379
+ }
12680
13380
  const { log: log2, runId } = createLogSink();
12681
13381
  if (!process.env.VINAYA_RUN_ID)
12682
13382
  process.env.VINAYA_RUN_ID = runId;
12683
- process.env.VINAYA_TASK = String(input.task);
13383
+ process.env.VINAYA_TASK = String(task);
12684
13384
  const repo = await resolveRepo().catch(() => null);
12685
- const branch = d.developerBranchFor(input.task);
12686
- const root = d.outboxRoot();
12687
13385
  const repoRoot2 = d.repoRoot();
12688
13386
  const confidenceFilePath = join18(repoRoot2, ".worktrees", branch, CONFIDENCE_FILE_NAME);
12689
- const loopOutboxPath = outboxPathFor({ outboxRoot: () => root }, repo, input.task);
13387
+ const loopOutboxPath = outboxPathFor({ outboxRoot: () => root }, repo, task);
12690
13388
  async function logEvents(events) {
12691
13389
  for (const e of events) {
12692
13390
  const priorSize = sizeOfSafe2(loopOutboxPath);
@@ -12696,20 +13394,20 @@ async function devReviewLoop(input, deps = {}) {
12696
13394
  }
12697
13395
  const config = {
12698
13396
  loopId: randomUUID3(),
12699
- task: input.task,
13397
+ task,
12700
13398
  reviewers: ["code-reviewer", "security"],
12701
13399
  models: { developer: input.agent, "code-reviewer": input.agent, security: input.agent }
12702
13400
  };
12703
13401
  let state = initialLoopState(config);
12704
- let round = 1;
13402
+ let round = resumeFrom ? resumeFrom.round : 1;
12705
13403
  let devResumeId = null;
12706
13404
  let devDispatchSucceededBefore = false;
12707
- let prNumber = -1;
12708
13405
  let lastReviewContext = null;
13406
+ let resumedDispatch = resumeFrom !== null;
12709
13407
  async function dispatchDeveloper(prompt2, roundNum) {
12710
13408
  const isResume = devResumeId !== null;
12711
13409
  const handle = await withPromptFile2(prompt2, (promptFile) => d.dispatchRole("developer", input.agent, prompt2, {
12712
- task: input.task,
13410
+ task,
12713
13411
  round: roundNum,
12714
13412
  resumeId: devResumeId ?? undefined,
12715
13413
  promptFile
@@ -12723,14 +13421,14 @@ async function devReviewLoop(input, deps = {}) {
12723
13421
  return handle;
12724
13422
  }
12725
13423
  async function dispatchReviewer(role, roundNum, facts) {
12726
- const workDir = reviewerWorkDir(root, input.task, roundNum, role);
12727
- mkdirSync4(workDir, { recursive: true });
13424
+ const workDir = reviewerWorkDir(root, task, roundNum, role);
13425
+ mkdirSync5(workDir, { recursive: true });
12728
13426
  const dispatchRoleName = role === "reviewer" ? "code-reviewer" : "security";
12729
13427
  const prompt2 = renderReviewerDispatchPrompt(role, facts, workDir);
12730
- const handle = await withPromptFile2(prompt2, (promptFile) => d.dispatchRole(dispatchRoleName, input.agent, prompt2, { task: input.task, round: roundNum, promptFile }));
13428
+ const handle = await withPromptFile2(prompt2, (promptFile) => d.dispatchRole(dispatchRoleName, input.agent, prompt2, { task, round: roundNum, promptFile }));
12731
13429
  await assertDispatchOrEscalate(handle, input.agent, false, false);
12732
- const parsed = buildVerdictFromReport(role, workDir, facts.head, input.agent, input.task, handle);
12733
- writeHeldVerdict(root, input.task, roundNum, role, parsed.rendered);
13430
+ const parsed = buildVerdictFromReport(role, workDir, facts.head, input.agent, task, handle);
13431
+ writeHeldVerdict(root, task, roundNum, role, parsed.rendered);
12734
13432
  return parsed;
12735
13433
  }
12736
13434
  function computeStats(head, roundStartMs2) {
@@ -12754,18 +13452,27 @@ async function devReviewLoop(input, deps = {}) {
12754
13452
  } catch {}
12755
13453
  return content ? parseConfidenceReply(content) : "absent";
12756
13454
  }
12757
- const roundStart0 = d.now();
12758
- const brief = d.fetchFrozenBrief(input.task);
12759
- await dispatchDeveloper(brief, round);
12760
- prNumber = await pollUntil(() => d.findOpenPrForBranch(branch), d.prPollMaxAttempts, d.prPollIntervalMs, d.sleep, `devReviewLoop: no open PR appeared for branch \`${branch}\` within the poll budget.`).then((pr) => pr.number);
13455
+ let roundStartMs = d.now();
13456
+ if (resumeFrom) {
13457
+ const rulings = d.fetchRulings(prNumber);
13458
+ lastReviewContext = rulings.map((r, i) => `${i + 1}. ${r}`).join(`
13459
+ `);
13460
+ } else {
13461
+ const brief = d.fetchFrozenBrief(task);
13462
+ await dispatchDeveloper(brief, round);
13463
+ prNumber = await pollUntil(() => d.findOpenPrForBranch(branch), d.prPollMaxAttempts, d.prPollIntervalMs, d.sleep, `devReviewLoop: no open PR appeared for branch \`${branch}\` within the poll budget.`).then((pr) => pr.number);
13464
+ }
12761
13465
  let decision = { type: "dispatch_developer" };
12762
- let roundStartMs = roundStart0;
12763
- let firstPass = true;
13466
+ let firstPass = !resumeFrom;
13467
+ let pendingCompletionEvents = [];
12764
13468
  while (true) {
12765
13469
  if (decision.type === "dispatch_developer") {
12766
13470
  if (!firstPass) {
12767
13471
  const prompt2 = [
12768
- lastReviewContext ? `Round ${round} review findings:
13472
+ resumedDispatch ? `Principal ruling on this pause:
13473
+
13474
+ ${lastReviewContext}
13475
+ ` : lastReviewContext ? `Round ${round} review findings:
12769
13476
 
12770
13477
  ${lastReviewContext}
12771
13478
  ` : "CI was red on the last head — fix and push.",
@@ -12776,6 +13483,7 @@ ${lastReviewContext}
12776
13483
  `);
12777
13484
  roundStartMs = d.now();
12778
13485
  await dispatchDeveloper(prompt2, round);
13486
+ resumedDispatch = false;
12779
13487
  }
12780
13488
  firstPass = false;
12781
13489
  const gate = await waitForGreenGate(roundStartMs);
@@ -12785,7 +13493,7 @@ ${lastReviewContext}
12785
13493
  state = result.state;
12786
13494
  decision = result.decision;
12787
13495
  await logEvents(result.events);
12788
- d.flushOutbox(input.task);
13496
+ d.flushOutbox(task);
12789
13497
  } else if (decision.type === "ask_confidence") {
12790
13498
  const reaskPrompt = `Your last reply did not include a valid confidence line.
12791
13499
 
@@ -12799,11 +13507,11 @@ ${CONFIDENCE_PROMPT_LINE}`;
12799
13507
  state = result.state;
12800
13508
  decision = result.decision;
12801
13509
  await logEvents(result.events);
12802
- d.flushOutbox(input.task);
13510
+ d.flushOutbox(task);
12803
13511
  } else if (decision.type === "dispatch_reviewers") {
12804
13512
  const head = d.resolveHead(branch);
12805
13513
  const ciConclusion = d.fetchCiConclusion(head);
12806
- const objectives = d.fetchIssueObjectives(input.task);
13514
+ const objectives = d.fetchIssueObjectives(task);
12807
13515
  const rulings = d.fetchRulings(prNumber);
12808
13516
  const facts = { objectives, rulings, head, ciConclusion };
12809
13517
  const [reviewer, security] = await Promise.all([
@@ -12819,14 +13527,39 @@ ${security.rendered}`;
12819
13527
  const result = assessRound(state, obs);
12820
13528
  state = result.state;
12821
13529
  decision = result.decision;
12822
- await logEvents(result.events);
12823
- d.flushOutbox(input.task);
13530
+ const routed = routeCompletionEvents(result.events, decision.type);
13531
+ pendingCompletionEvents = routed.toDeferUntilPublish;
13532
+ await logEvents(routed.toLogNow);
13533
+ d.flushOutbox(task);
12824
13534
  if (decision.type === "dispatch_developer")
12825
13535
  round += 1;
12826
13536
  }
12827
- if (decision.type === "publish" || decision.type === "pause") {
12828
- d.flushOutbox(input.task);
12829
- return { finalDecision: decision, prNumber };
13537
+ if (decision.type === "publish") {
13538
+ publishRound(root, {
13539
+ task,
13540
+ round,
13541
+ prNumber,
13542
+ expectedHead: d.resolveHead(branch),
13543
+ journal: { rounds: state.rounds }
13544
+ });
13545
+ await logEvents(pendingCompletionEvents);
13546
+ d.flushOutbox(task);
13547
+ return { finalDecision: decision, prNumber, task };
13548
+ }
13549
+ if (decision.type === "pause") {
13550
+ const pauseHead = d.resolveHead(branch);
13551
+ writePauseState(root, {
13552
+ task,
13553
+ round,
13554
+ head: pauseHead,
13555
+ branch,
13556
+ prNumber,
13557
+ reason: decision.reason,
13558
+ pausedAt: new Date().toISOString()
13559
+ });
13560
+ postPauseComment(root, task, round, pauseHead, prNumber, decision.reason);
13561
+ d.flushOutbox(task);
13562
+ return { finalDecision: decision, prNumber, task };
12830
13563
  }
12831
13564
  }
12832
13565
  }
@@ -12834,27 +13567,24 @@ ${security.rendered}`;
12834
13567
  // src/commands/dev-review-loop.ts
12835
13568
  function parseArgs2(args) {
12836
13569
  let task;
13570
+ let resumePr;
12837
13571
  let agent;
12838
13572
  let json = false;
12839
13573
  for (let i = 0;i < args.length; i++) {
12840
13574
  const a = args[i];
12841
13575
  if (a === "--task")
12842
13576
  task = Number(args[++i]);
13577
+ else if (a === "--resume")
13578
+ resumePr = Number(args[++i]);
12843
13579
  else if (a === "--agent")
12844
13580
  agent = args[++i];
12845
13581
  else if (a === "--json")
12846
13582
  json = true;
12847
13583
  }
12848
- return { task, agent, json };
13584
+ return { task, resumePr, agent, json };
12849
13585
  }
12850
13586
  async function devReviewLoopCommand(args) {
12851
13587
  const parsed = parseArgs2(args);
12852
- if (parsed.task === undefined || !Number.isInteger(parsed.task) || parsed.task <= 0) {
12853
- process.stderr.write(`vinaya dev-review-loop: --task <n> is required (a positive integer Issue number)
12854
- `);
12855
- process.exit(1);
12856
- }
12857
- const task = parsed.task;
12858
13588
  const agentRaw = parsed.agent ?? loadConfig()?.dispatch?.agent;
12859
13589
  if (!agentRaw) {
12860
13590
  process.stderr.write(`vinaya dev-review-loop: --agent <claude|codex|gemini> is required (or set dispatch.agent in vinaya.config.json)
@@ -12867,16 +13597,34 @@ async function devReviewLoopCommand(args) {
12867
13597
  process.exit(1);
12868
13598
  }
12869
13599
  const agent = agentRaw;
12870
- const result = await devReviewLoop({ task, agent });
13600
+ let input;
13601
+ if (parsed.resumePr !== undefined) {
13602
+ if (!Number.isInteger(parsed.resumePr) || parsed.resumePr <= 0) {
13603
+ process.stderr.write(`vinaya dev-review-loop: --resume <pr> requires a positive integer PR number
13604
+ `);
13605
+ process.exit(1);
13606
+ }
13607
+ input = { resumePr: parsed.resumePr, agent };
13608
+ } else {
13609
+ if (parsed.task === undefined || !Number.isInteger(parsed.task) || parsed.task <= 0) {
13610
+ process.stderr.write(`vinaya dev-review-loop: --task <n> is required (a positive integer Issue number)
13611
+ `);
13612
+ process.exit(1);
13613
+ }
13614
+ input = { task: parsed.task, agent };
13615
+ }
13616
+ const result = await devReviewLoop(input);
12871
13617
  if (parsed.json) {
12872
- printJson({ finalDecision: result.finalDecision, prNumber: result.prNumber });
13618
+ printJson({ finalDecision: result.finalDecision, prNumber: result.prNumber, task: result.task });
12873
13619
  } else if (result.finalDecision.type === "publish") {
12874
- process.stdout.write(`vinaya dev-review-loop: task ${task}, PR #${result.prNumber} — publish
13620
+ process.stdout.write(`vinaya dev-review-loop: task ${result.task}, PR #${result.prNumber} — publish
12875
13621
  `);
12876
13622
  } else if (result.finalDecision.type === "pause") {
12877
- process.stdout.write(`vinaya dev-review-loop: task ${task}, PR #${result.prNumber} — paused (${result.finalDecision.reason})
13623
+ process.stdout.write(`vinaya dev-review-loop: task ${result.task}, PR #${result.prNumber} — paused (${result.finalDecision.reason})
12878
13624
  `);
12879
13625
  }
13626
+ if (result.finalDecision.type === "pause")
13627
+ process.exit(1);
12880
13628
  }
12881
13629
 
12882
13630
  // src/commands/dispatch.ts
@@ -12884,8 +13632,8 @@ import { readFileSync as readFileSync19 } from "node:fs";
12884
13632
 
12885
13633
  // src/commands/log.ts
12886
13634
  import { execFileSync as execFileSync18 } from "node:child_process";
12887
- import { lstatSync, readFileSync as readFileSync18, statSync as statSync2, writeFileSync as writeFileSync9, rmSync as rmSync7 } from "node:fs";
12888
- import { homedir as homedir3, tmpdir as tmpdir5 } from "node:os";
13635
+ import { lstatSync, readFileSync as readFileSync18, statSync as statSync2, writeFileSync as writeFileSync10, rmSync as rmSync7 } from "node:fs";
13636
+ import { homedir as homedir4, tmpdir as tmpdir5 } from "node:os";
12889
13637
  import { join as join19 } from "node:path";
12890
13638
  var FORGE_COMMENT_MAX_CHARS = 65536;
12891
13639
  function isEnoent2(err) {
@@ -12956,7 +13704,7 @@ function refuse22(error) {
12956
13704
  process.exit(2);
12957
13705
  }
12958
13706
  var SAFE_PATH_SEGMENT2 = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
12959
- function isSafeRepoSegment2(segment) {
13707
+ function isSafeRepoSegment3(segment) {
12960
13708
  return SAFE_PATH_SEGMENT2.test(segment) && !segment.includes("..");
12961
13709
  }
12962
13710
  function outboxPathFor2(deps, target) {
@@ -12989,7 +13737,7 @@ function parseOutboxLine(raw, index) {
12989
13737
  if (!result.success) {
12990
13738
  throw new CorruptOutboxLineError(index, result.error.issues[0]?.message ?? "schema violation");
12991
13739
  }
12992
- const redacted = redact(result.data, homedir3());
13740
+ const redacted = redact(result.data, homedir4());
12993
13741
  return { postLine: JSON.stringify(redacted), runId: redacted.meta.run_id, seq: redacted.meta.seq };
12994
13742
  }
12995
13743
  function renderChunk(runId, seqFrom, seqTo, postLines) {
@@ -13062,7 +13810,7 @@ function issueFromPr(prNumber) {
13062
13810
  }
13063
13811
  function postChunk(op, targetId, body, index) {
13064
13812
  const tmp = join19(tmpdir5(), `vinaya-log-flush-${process.pid}-${Date.now()}-${index}.md`);
13065
- writeFileSync9(tmp, body, { flag: "wx" });
13813
+ writeFileSync10(tmp, body, { flag: "wx" });
13066
13814
  try {
13067
13815
  const out = op === "pr.comment" ? gh2(["pr", "comment", targetId, "--body-file", tmp]) : gh2(["issue", "comment", targetId, "--body-file", tmp]);
13068
13816
  const match = /#issuecomment-(\d+)/.exec(out);
@@ -13104,7 +13852,7 @@ async function logFlushCommand(args) {
13104
13852
  const target = parsed.pr !== undefined ? { pr: parsed.pr } : { issue: issueNumber };
13105
13853
  const forgeTargetId = parsed.pr !== undefined ? String(parsed.pr) : String(issueNumber);
13106
13854
  const resolved = await resolveRepo();
13107
- const repo = resolved && isSafeRepoSegment2(resolved.owner) && isSafeRepoSegment2(resolved.repo) ? resolved : null;
13855
+ const repo = resolved && isSafeRepoSegment3(resolved.owner) && isSafeRepoSegment3(resolved.repo) ? resolved : null;
13108
13856
  const outboxRoot2 = () => join19(GLOBAL_VINAYA_HOME, "outbox");
13109
13857
  const path = outboxPathFor2({ outboxRoot: outboxRoot2, repo }, { issue: issueNumber });
13110
13858
  let lstat;
@@ -13185,7 +13933,7 @@ async function logFlushCommand(args) {
13185
13933
  `).join("");
13186
13934
  const liveNow = readFileSync18(path);
13187
13935
  const tail = liveNow.subarray(Math.min(startOffset, liveNow.byteLength));
13188
- writeFileSync9(path, Buffer.concat([Buffer.from(unposted, "utf8"), tail]));
13936
+ writeFileSync10(path, Buffer.concat([Buffer.from(unposted, "utf8"), tail]));
13189
13937
  if (!finalLanded) {
13190
13938
  emitCheckError({
13191
13939
  schema: CHECK_SCHEMA_VERSION,
@@ -13210,6 +13958,7 @@ async function logFlushCommand(args) {
13210
13958
  function parseArgs4(args) {
13211
13959
  const role = args[0];
13212
13960
  let agent;
13961
+ let model;
13213
13962
  let promptFile;
13214
13963
  let task;
13215
13964
  let pr;
@@ -13220,6 +13969,8 @@ function parseArgs4(args) {
13220
13969
  const a = args[i];
13221
13970
  if (a === "--agent")
13222
13971
  agent = args[++i];
13972
+ else if (a === "--model")
13973
+ model = args[++i];
13223
13974
  else if (a === "--prompt-file")
13224
13975
  promptFile = args[++i];
13225
13976
  else if (a === "--task")
@@ -13233,7 +13984,7 @@ function parseArgs4(args) {
13233
13984
  else if (a === "--json")
13234
13985
  json = true;
13235
13986
  }
13236
- return { role, agent, promptFile, task, pr, round, resume, json };
13987
+ return { role, agent, model, promptFile, task, pr, round, resume, json };
13237
13988
  }
13238
13989
  async function dispatchCommand(args) {
13239
13990
  const parsed = parseArgs4(args);
@@ -13280,6 +14031,7 @@ async function dispatchCommand(args) {
13280
14031
  pr: parsed.pr,
13281
14032
  round: parsed.round,
13282
14033
  resumeId: parsed.resume,
14034
+ model: parsed.model,
13283
14035
  promptFile
13284
14036
  });
13285
14037
  if (parsed.json) {
@@ -13452,8 +14204,8 @@ function envDeclarationWarning(name) {
13452
14204
  }
13453
14205
 
13454
14206
  // src/lib/registry-write.ts
13455
- import { existsSync as existsSync14, mkdirSync as mkdirSync5, readFileSync as readFileSync22, writeFileSync as writeFileSync10 } from "node:fs";
13456
- import { dirname as dirname7, join as join21 } from "node:path";
14207
+ import { existsSync as existsSync14, mkdirSync as mkdirSync6, readFileSync as readFileSync22, writeFileSync as writeFileSync11 } from "node:fs";
14208
+ import { dirname as dirname9, join as join21 } from "node:path";
13457
14209
  var PROJECTS_REGISTRY_PATH = ".vinaya/projects.md";
13458
14210
  var CONFIG_FILE_PATH = "vinaya.config.json";
13459
14211
  function rowLine(name, path, specsPath) {
@@ -13531,11 +14283,11 @@ function planRegistryRow(repoRoot2, name, path, specsPath) {
13531
14283
  function applyRegistryRow(repoRoot2, plan, name, path, specsPath) {
13532
14284
  const abs2 = join21(repoRoot2, PROJECTS_REGISTRY_PATH);
13533
14285
  if (plan.action === "create-host") {
13534
- mkdirSync5(dirname7(abs2), { recursive: true });
13535
- writeFileSync10(abs2, freshProjectsRegistry(name, path, specsPath), "utf-8");
14286
+ mkdirSync6(dirname9(abs2), { recursive: true });
14287
+ writeFileSync11(abs2, freshProjectsRegistry(name, path, specsPath), "utf-8");
13536
14288
  } else if (plan.action === "append-row") {
13537
14289
  const existing = readFileSync22(abs2, "utf-8");
13538
- writeFileSync10(abs2, appendRegistryRow(existing, name, path, specsPath), "utf-8");
14290
+ writeFileSync11(abs2, appendRegistryRow(existing, name, path, specsPath), "utf-8");
13539
14291
  }
13540
14292
  }
13541
14293
  function renderRegistryRowDiffLine(plan) {
@@ -13567,7 +14319,7 @@ function applyConfigProjectEntry(repoRoot2, plan) {
13567
14319
  const abs2 = join21(repoRoot2, CONFIG_FILE_PATH);
13568
14320
  const seed = JSON.parse(readFileSync22(abs2, "utf-8"));
13569
14321
  const projects = Array.isArray(seed.projects) ? seed.projects : [];
13570
- writeFileSync10(abs2, `${JSON.stringify({ ...seed, projects: [...projects, plan.entry] }, null, 2)}
14322
+ writeFileSync11(abs2, `${JSON.stringify({ ...seed, projects: [...projects, plan.entry] }, null, 2)}
13571
14323
  `, "utf-8");
13572
14324
  }
13573
14325
  function renderConfigProjectEntryDiffLine(plan) {
@@ -14188,7 +14940,7 @@ async function ejectCommand(args) {
14188
14940
  }
14189
14941
 
14190
14942
  // src/commands/init.ts
14191
- import { existsSync as existsSync17, readFileSync as readFileSync25, writeFileSync as writeFileSync11 } from "node:fs";
14943
+ import { existsSync as existsSync17, readFileSync as readFileSync25, writeFileSync as writeFileSync12 } from "node:fs";
14192
14944
  import { join as join24 } from "node:path";
14193
14945
  function realDeps6() {
14194
14946
  return {
@@ -14241,7 +14993,7 @@ function readManifest3(repoRoot2) {
14241
14993
  function writeManifest(repoRoot2, manifest) {
14242
14994
  const configAbs = join24(repoRoot2, CONFIG_PATH);
14243
14995
  const seed = JSON.parse(readFileSync25(configAbs, "utf-8"));
14244
- writeFileSync11(configAbs, `${JSON.stringify({ ...seed, managed: manifest }, null, 2)}
14996
+ writeFileSync12(configAbs, `${JSON.stringify({ ...seed, managed: manifest }, null, 2)}
14245
14997
  `, "utf-8");
14246
14998
  }
14247
14999
  async function runInit(args, deps) {
@@ -14457,7 +15209,7 @@ function issueEditCommand(args) {
14457
15209
 
14458
15210
  // src/commands/issue-objectives.ts
14459
15211
  import { execFileSync as execFileSync20 } from "node:child_process";
14460
- import { mkdtempSync as mkdtempSync4, rmSync as rmSync8, writeFileSync as writeFileSync12 } from "node:fs";
15212
+ import { mkdtempSync as mkdtempSync4, rmSync as rmSync8, writeFileSync as writeFileSync13 } from "node:fs";
14461
15213
  import { tmpdir as tmpdir6 } from "node:os";
14462
15214
  import { join as join25 } from "node:path";
14463
15215
  var RETRY = 'vinaya issue objectives edit <n> --add "<sentence>" | --drop O<k> | --replace O<k> "<sentence>" --reason "<text>"';
@@ -14592,7 +15344,7 @@ function issueObjectivesEditCommand(args) {
14592
15344
  const newBody = spliceObjectivesSection(body, renderObjectives(updated));
14593
15345
  const dir = mkdtempSync4(join25(tmpdir6(), "vinaya-objectives-edit-"));
14594
15346
  const tmp = join25(dir, "body.md");
14595
- writeFileSync12(tmp, newBody, "utf8");
15347
+ writeFileSync13(tmp, newBody, "utf8");
14596
15348
  try {
14597
15349
  const ghArgs = ["--body-file", tmp];
14598
15350
  writeValidatedIssueEdit({
@@ -15007,7 +15759,7 @@ async function milestoneCloseCommand(args) {
15007
15759
  }
15008
15760
 
15009
15761
  // src/commands/new-check.ts
15010
- import { chmodSync as chmodSync2, existsSync as existsSync18, mkdirSync as mkdirSync6, readFileSync as readFileSync26, writeFileSync as writeFileSync13 } from "node:fs";
15762
+ import { chmodSync as chmodSync3, existsSync as existsSync18, mkdirSync as mkdirSync7, readFileSync as readFileSync26, writeFileSync as writeFileSync14 } from "node:fs";
15011
15763
  import { join as join26 } from "node:path";
15012
15764
  var TEMPLATE_PATH2 = join26(packageRoot(import.meta.url), "templates", "custom-check.template.ts");
15013
15765
  var CHECKS_DIR = join26("scripts", "vinaya-checks");
@@ -15029,7 +15781,7 @@ function newCheckCommand(args) {
15029
15781
  }
15030
15782
  const scriptsDir = join26(process.cwd(), CHECKS_DIR);
15031
15783
  if (!existsSync18(scriptsDir))
15032
- mkdirSync6(scriptsDir, { recursive: true });
15784
+ mkdirSync7(scriptsDir, { recursive: true });
15033
15785
  const fileStem = name.slice(name.indexOf("/") + 1);
15034
15786
  const targetPath = join26(scriptsDir, `${fileStem}.ts`);
15035
15787
  if (existsSync18(targetPath)) {
@@ -15038,8 +15790,8 @@ function newCheckCommand(args) {
15038
15790
  }
15039
15791
  const template = readFileSync26(TEMPLATE_PATH2, "utf-8");
15040
15792
  const contents = template.split("{{CHECK_NAME}}").join(name);
15041
- writeFileSync13(targetPath, contents, "utf-8");
15042
- chmodSync2(targetPath, 493);
15793
+ writeFileSync14(targetPath, contents, "utf-8");
15794
+ chmodSync3(targetPath, 493);
15043
15795
  const relPath = join26(CHECKS_DIR, `${fileStem}.ts`);
15044
15796
  const registration = JSON.stringify({ checks: { [name]: { run: `./${relPath}`, scope: "diff" } } }, null, 2);
15045
15797
  process.stdout.write(`Created ${relPath}
@@ -15050,7 +15802,7 @@ ${registration}
15050
15802
  }
15051
15803
 
15052
15804
  // src/commands/new-noop-check.ts
15053
- import { chmodSync as chmodSync3, existsSync as existsSync19, mkdirSync as mkdirSync7, readFileSync as readFileSync27, writeFileSync as writeFileSync14 } from "node:fs";
15805
+ import { chmodSync as chmodSync4, existsSync as existsSync19, mkdirSync as mkdirSync8, readFileSync as readFileSync27, writeFileSync as writeFileSync15 } from "node:fs";
15054
15806
  import { join as join27 } from "node:path";
15055
15807
  var TEMPLATE_PATH3 = join27(packageRoot(import.meta.url), "templates", "noop-check.template.ts");
15056
15808
  var CHECKS_DIR2 = join27("vinaya", "checks");
@@ -15069,7 +15821,7 @@ function newNoopCheckCommand(args) {
15069
15821
  }
15070
15822
  const checksDir = join27(process.cwd(), CHECKS_DIR2);
15071
15823
  if (!existsSync19(checksDir))
15072
- mkdirSync7(checksDir, { recursive: true });
15824
+ mkdirSync8(checksDir, { recursive: true });
15073
15825
  const targetPath = join27(checksDir, `${name}.ts`);
15074
15826
  if (existsSync19(targetPath)) {
15075
15827
  console.error(`Error: ${targetPath} already exists.`);
@@ -15077,8 +15829,8 @@ function newNoopCheckCommand(args) {
15077
15829
  }
15078
15830
  const template = readFileSync27(TEMPLATE_PATH3, "utf-8");
15079
15831
  const contents = template.split("{{CHECK_NAME}}").join(name);
15080
- writeFileSync14(targetPath, contents, "utf-8");
15081
- chmodSync3(targetPath, 493);
15832
+ writeFileSync15(targetPath, contents, "utf-8");
15833
+ chmodSync4(targetPath, 493);
15082
15834
  const relPath = join27(CHECKS_DIR2, `${name}.ts`);
15083
15835
  const entry2 = {
15084
15836
  run: `./${relPath}`,
@@ -15097,7 +15849,7 @@ ${registration}
15097
15849
  }
15098
15850
 
15099
15851
  // src/commands/new-role.ts
15100
- import { existsSync as existsSync20, mkdirSync as mkdirSync8, readFileSync as readFileSync28, writeFileSync as writeFileSync15 } from "node:fs";
15852
+ import { existsSync as existsSync20, mkdirSync as mkdirSync9, readFileSync as readFileSync28, writeFileSync as writeFileSync16 } from "node:fs";
15101
15853
  import { join as join28 } from "node:path";
15102
15854
  var TEMPLATE_PATH4 = join28(packageRoot(import.meta.url), "templates", "role.template.md");
15103
15855
  var ROLES_DIR = join28("vinaya", "roles");
@@ -15115,7 +15867,7 @@ A bare, un-namespaced key resolves as an OVERRIDE of a core role — a complete
15115
15867
  }
15116
15868
  const rolesDir = join28(process.cwd(), ROLES_DIR);
15117
15869
  if (!existsSync20(rolesDir))
15118
- mkdirSync8(rolesDir, { recursive: true });
15870
+ mkdirSync9(rolesDir, { recursive: true });
15119
15871
  const roleId = name.slice(name.indexOf("/") + 1);
15120
15872
  const targetPath = join28(rolesDir, `${roleId}.md`);
15121
15873
  if (existsSync20(targetPath)) {
@@ -15124,7 +15876,7 @@ A bare, un-namespaced key resolves as an OVERRIDE of a core role — a complete
15124
15876
  }
15125
15877
  const template = readFileSync28(TEMPLATE_PATH4, "utf-8");
15126
15878
  const contents = template.split("{{ROLE_ID}}").join(roleId);
15127
- writeFileSync15(targetPath, contents, "utf-8");
15879
+ writeFileSync16(targetPath, contents, "utf-8");
15128
15880
  const relPath = join28(ROLES_DIR, `${roleId}.md`);
15129
15881
  const registration = JSON.stringify({ roles: { [name]: { contract: `./${relPath}` } } }, null, 2);
15130
15882
  process.stdout.write(`Created ${relPath}
@@ -15144,8 +15896,8 @@ function summariseNumstat(numstat) {
15144
15896
  let added = 0;
15145
15897
  let deleted = 0;
15146
15898
  let binary = 0;
15147
- for (const row of rows) {
15148
- const [a, d] = row.split("\t");
15899
+ for (const row2 of rows) {
15900
+ const [a, d] = row2.split("\t");
15149
15901
  if (a === "-" || d === "-") {
15150
15902
  binary++;
15151
15903
  continue;
@@ -15709,7 +16461,7 @@ function prRuleCommand(args) {
15709
16461
 
15710
16462
  // src/commands/pr-report.ts
15711
16463
  import { execFileSync as execFileSync24, spawnSync as spawnSync2 } from "node:child_process";
15712
- import { existsSync as existsSync21, mkdtempSync as mkdtempSync5, readFileSync as readFileSync30, rmSync as rmSync9, writeFileSync as writeFileSync16 } from "node:fs";
16464
+ import { existsSync as existsSync21, mkdtempSync as mkdtempSync5, readFileSync as readFileSync30, rmSync as rmSync9, writeFileSync as writeFileSync17 } from "node:fs";
15713
16465
  import { tmpdir as tmpdir7 } from "node:os";
15714
16466
  import { join as join29 } from "node:path";
15715
16467
  var EVIDENCE_START = "<!-- AEG:EVIDENCE:START -->";
@@ -16112,7 +16864,7 @@ var USAGE5 = "Usage: vinaya pr report [--write <body-file> | --push <pr>] [--pha
16112
16864
  function ghEditBody(pr, body) {
16113
16865
  const dir = mkdtempSync5(join29(tmpdir7(), "vinaya-pr-report-push-"));
16114
16866
  const tmp = join29(dir, "body.md");
16115
- writeFileSync16(tmp, body);
16867
+ writeFileSync17(tmp, body);
16116
16868
  try {
16117
16869
  gh3(["pr", "edit", pr, "--body-file", tmp]);
16118
16870
  } finally {
@@ -16208,7 +16960,7 @@ ${USAGE5}`);
16208
16960
  } catch (err) {
16209
16961
  const dir = mkdtempSync5(join29(tmpdir7(), "vinaya-pr-report-push-restore-failed-"));
16210
16962
  const savePath = join29(dir, "pre-edit-body.md");
16211
- writeFileSync16(savePath, preEditBody);
16963
+ writeFileSync17(savePath, preEditBody);
16212
16964
  console.error(`vinaya pr report: self-verification FAILED on PR ${pushPr} AND the restore of its pre-edit body also failed: ${err instanceof Error ? err.message : String(err)}. PR ${pushPr}'s body may now be corrupted — the pre-edit body was saved to ${savePath}; restore it by hand with \`gh pr edit ${pushPr} --body-file ${savePath}\`.`);
16213
16965
  process.exit(1);
16214
16966
  }
@@ -16241,7 +16993,7 @@ The AEG:EVIDENCE block was still pushed to PR ${pushPr}.`);
16241
16993
  transcriptPath,
16242
16994
  modelOverride
16243
16995
  });
16244
- writeFileSync16(writePath, composeWrittenBody(existing, result.blockInner, tokens));
16996
+ writeFileSync17(writePath, composeWrittenBody(existing, result.blockInner, tokens));
16245
16997
  if (tokens.collected) {
16246
16998
  process.stdout.write(`Wrote AEG:EVIDENCE and AEG:TOKENS blocks to ${writePath}
16247
16999
  `);
@@ -16440,8 +17192,8 @@ import { existsSync as existsSync23, readFileSync as readFileSync32 } from "node
16440
17192
  import { join as join31 } from "node:path";
16441
17193
 
16442
17194
  // src/lib/doc-owners-write.ts
16443
- import { existsSync as existsSync22, mkdirSync as mkdirSync9, readFileSync as readFileSync31, writeFileSync as writeFileSync17 } from "node:fs";
16444
- import { dirname as dirname8, join as join30 } from "node:path";
17195
+ import { existsSync as existsSync22, mkdirSync as mkdirSync10, readFileSync as readFileSync31, writeFileSync as writeFileSync18 } from "node:fs";
17196
+ import { dirname as dirname10, join as join30 } from "node:path";
16445
17197
  function bindingLine(glob, pointer) {
16446
17198
  return `${glob} ${pointer}`;
16447
17199
  }
@@ -16469,11 +17221,11 @@ function planDocOwnersBinding(repoRoot3, glob, pointer) {
16469
17221
  function applyDocOwnersBinding(repoRoot3, plan, glob, pointer) {
16470
17222
  const abs2 = join30(repoRoot3, DOC_OWNERS_PATH);
16471
17223
  if (plan.action === "create-host") {
16472
- mkdirSync9(dirname8(abs2), { recursive: true });
16473
- writeFileSync17(abs2, freshDocOwners(glob, pointer), "utf-8");
17224
+ mkdirSync10(dirname10(abs2), { recursive: true });
17225
+ writeFileSync18(abs2, freshDocOwners(glob, pointer), "utf-8");
16474
17226
  } else if (plan.action === "append-row") {
16475
17227
  const existing = readFileSync31(abs2, "utf-8");
16476
- writeFileSync17(abs2, appendDocOwnersBinding(existing, glob, pointer), "utf-8");
17228
+ writeFileSync18(abs2, appendDocOwnersBinding(existing, glob, pointer), "utf-8");
16477
17229
  }
16478
17230
  }
16479
17231
  function renderDocOwnersBindingDiffLine(plan) {
@@ -17033,7 +17785,7 @@ async function reviewStatusCommand(args) {
17033
17785
  import { execFile as execFile5, spawn as spawn3 } from "node:child_process";
17034
17786
  import { existsSync as existsSync24, readFileSync as readFileSync33, renameSync as renameSync2 } from "node:fs";
17035
17787
  import net from "node:net";
17036
- import { dirname as dirname9, join as join32 } from "node:path";
17788
+ import { dirname as dirname11, join as join32 } from "node:path";
17037
17789
  import { promisify as promisify5 } from "node:util";
17038
17790
 
17039
17791
  // src/lib/studio-bundle.ts
@@ -17098,7 +17850,7 @@ function resolveStudioTarget(cwd, moduleUrl = import.meta.url) {
17098
17850
  }
17099
17851
  if (existsSync24(join32(dir, ".git")))
17100
17852
  break;
17101
- const parent = dirname9(dir);
17853
+ const parent = dirname11(dir);
17102
17854
  if (parent === dir)
17103
17855
  break;
17104
17856
  dir = parent;
@@ -17189,7 +17941,7 @@ async function taskDispatchCommand(args) {
17189
17941
  const trancheSlug = args[0];
17190
17942
  const taskIdArg = args[1];
17191
17943
  if (!trancheSlug || !taskIdArg || trancheSlug.startsWith("--")) {
17192
- console.error(`Usage: vinaya task dispatch <tranche> <n> [--agent ${DISPATCH_AGENTS.join(" | ")}]`);
17944
+ console.error(`Usage: vinaya task dispatch <tranche> <n> [--agent ${DISPATCH_AGENTS.join(" | ")}] [--model <name>]`);
17193
17945
  process.exit(2);
17194
17946
  }
17195
17947
  const n = Number.parseInt(taskIdArg, 10);
@@ -17208,7 +17960,17 @@ async function taskDispatchCommand(args) {
17208
17960
  }
17209
17961
  agent = value;
17210
17962
  }
17211
- const result = await dispatchTask({ tranche: trancheSlug, n, agent });
17963
+ const modelIdx = rest.indexOf("--model");
17964
+ let model;
17965
+ if (modelIdx !== -1) {
17966
+ const value = rest[modelIdx + 1];
17967
+ if (!value) {
17968
+ console.error("--model requires a value");
17969
+ process.exit(2);
17970
+ }
17971
+ model = value;
17972
+ }
17973
+ const result = await dispatchTask({ tranche: trancheSlug, n, agent, model });
17212
17974
  process.stdout.write(`${result.brief}
17213
17975
  `);
17214
17976
  if (result.commentUrl)
@@ -17218,7 +17980,7 @@ Posted: ${result.commentUrl}
17218
17980
  }
17219
17981
 
17220
17982
  // src/commands/upgrade.ts
17221
- import { existsSync as existsSync25, readFileSync as readFileSync34, rmSync as rmSync10, writeFileSync as writeFileSync18 } from "node:fs";
17983
+ import { existsSync as existsSync25, readFileSync as readFileSync34, rmSync as rmSync10, writeFileSync as writeFileSync19 } from "node:fs";
17222
17984
  import { join as join33 } from "node:path";
17223
17985
  function realDeps9() {
17224
17986
  return {
@@ -17261,7 +18023,7 @@ function writeManifestVersion(repoRoot3, manifest) {
17261
18023
  const configAbs = join33(repoRoot3, CONFIG_PATH);
17262
18024
  const seed = JSON.parse(readFileSync34(configAbs, "utf-8"));
17263
18025
  const updated = { ...manifest, version: MANAGED_MANIFEST_VERSION };
17264
- writeFileSync18(configAbs, `${JSON.stringify({ ...seed, managed: updated }, null, 2)}
18026
+ writeFileSync19(configAbs, `${JSON.stringify({ ...seed, managed: updated }, null, 2)}
17265
18027
  `, "utf-8");
17266
18028
  }
17267
18029
  function stripFor(repoRoot3, path, marker, comment) {
@@ -17328,9 +18090,13 @@ function extractWorkflowTrigger(content) {
17328
18090
  const match = content.match(/^on:\r?\n {2}([A-Za-z0-9_]+):/m);
17329
18091
  return match?.[1] ?? null;
17330
18092
  }
17331
- function planUpgrade(ops, repoRoot3, manifest, routing) {
18093
+ function planUpgrade(ops, repoRoot3, manifest, routing, staleSkillPaths = []) {
17332
18094
  const entries = [];
17333
- let hasChanges = routing.arm || routing.migratesManifest || routing.strips.some((s) => s.present);
18095
+ const staleFiles = staleSkillPaths.map((path) => ({
18096
+ path,
18097
+ present: existsSync25(join33(repoRoot3, path))
18098
+ }));
18099
+ let hasChanges = routing.arm || routing.migratesManifest || routing.strips.some((s) => s.present) || staleFiles.some((f) => f.present);
17334
18100
  const ownedFiles = new Set(manifest.files);
17335
18101
  const blockKey = (path, marker) => `${path}::${marker}`;
17336
18102
  const ownedBlocks = new Set(manifest.blocks.map((b) => blockKey(b.path, b.marker)));
@@ -17394,7 +18160,13 @@ function planUpgrade(ops, repoRoot3, manifest, routing) {
17394
18160
  }
17395
18161
  }
17396
18162
  const versionMigration = manifest.version === MANAGED_MANIFEST_VERSION ? null : { from: manifest.version, to: MANAGED_MANIFEST_VERSION };
17397
- return { entries, routing, versionMigration, hasChanges: hasChanges || versionMigration !== null };
18163
+ return { entries, routing, staleFiles, versionMigration, hasChanges: hasChanges || versionMigration !== null };
18164
+ }
18165
+ function withoutStaleFiles(manifest, plan) {
18166
+ if (plan.staleFiles.length === 0)
18167
+ return manifest;
18168
+ const stale = new Set(plan.staleFiles.map((f) => f.path));
18169
+ return { ...manifest, files: manifest.files.filter((f) => !stale.has(f)) };
17398
18170
  }
17399
18171
  function withClaudeStopHookRecorded(manifest, plan) {
17400
18172
  let files = manifest.files;
@@ -17419,6 +18191,13 @@ function renderUpgradeDiff(plan) {
17419
18191
  lines.push(` ~ migrate manifest version ${plan.versionMigration.from} → ${plan.versionMigration.to}`);
17420
18192
  lines.push("");
17421
18193
  }
18194
+ if (plan.staleFiles.length > 0) {
18195
+ lines.push("── Retired role skills ─────────────────────────────");
18196
+ for (const f of plan.staleFiles) {
18197
+ lines.push(f.present ? ` - remove ${f.path} (role retired — its doctrine now refuses)` : ` · gone ${f.path} (already removed)`);
18198
+ }
18199
+ lines.push("");
18200
+ }
17422
18201
  const r = plan.routing;
17423
18202
  if (r.blockedReason || r.arm || r.migratesManifest || r.strips.length > 0) {
17424
18203
  lines.push("── Hook location ─────────────────────────────");
@@ -17503,7 +18282,7 @@ function regenerateBlock(repoRoot3, op) {
17503
18282
  const content = readFileSync34(abs2, "utf-8");
17504
18283
  const stripped = stripBlockFromContent(content, op.marker, op.comment);
17505
18284
  if (stripped !== null) {
17506
- writeFileSync18(abs2, stripped.endsWith(`
18285
+ writeFileSync19(abs2, stripped.endsWith(`
17507
18286
  `) ? stripped : `${stripped}
17508
18287
  `, "utf-8");
17509
18288
  }
@@ -17524,6 +18303,13 @@ function applyUpgrade(plan, repoRoot3) {
17524
18303
  regenerateBlock(repoRoot3, e.op);
17525
18304
  }
17526
18305
  }
18306
+ for (const f of plan.staleFiles) {
18307
+ if (!f.present)
18308
+ continue;
18309
+ const abs2 = containedAbs(repoRoot3, f.path);
18310
+ if (abs2 !== null)
18311
+ rmSync10(abs2, { force: true });
18312
+ }
17527
18313
  for (const s of plan.routing.strips) {
17528
18314
  if (!s.present)
17529
18315
  continue;
@@ -17536,7 +18322,7 @@ function applyUpgrade(plan, repoRoot3) {
17536
18322
  if (blockStripLeavesEmpty(stripped))
17537
18323
  rmSync10(abs2, { force: true });
17538
18324
  else
17539
- writeFileSync18(abs2, stripped.endsWith(`
18325
+ writeFileSync19(abs2, stripped.endsWith(`
17540
18326
  `) ? stripped : `${stripped}
17541
18327
  `, "utf-8");
17542
18328
  }
@@ -17575,7 +18361,9 @@ async function runUpgrade(args, deps) {
17575
18361
  agents: resolveAgentVendors(planManifest)
17576
18362
  };
17577
18363
  const ops = buildInitOps(ctx);
17578
- const plan = planUpgrade(ops, repo.repoRoot, planManifest, routing);
18364
+ const doctrineRootForStaleSkills = ctx.agents.has("skills") ? resolveDoctrineRoot() : null;
18365
+ const staleSkillPaths = doctrineRootForStaleSkills ? staleAgentSkillPaths(doctrineRootForStaleSkills, planManifest.files) : [];
18366
+ const plan = planUpgrade(ops, repo.repoRoot, planManifest, routing, staleSkillPaths);
17579
18367
  if (!plan.hasChanges) {
17580
18368
  if (routing.blockedReason) {
17581
18369
  process.stdout.write(routing.target === TRACKED_HOOK_DIR ? `Note: core.hooksPath NOT armed — ${routing.blockedReason}.
@@ -17608,7 +18396,7 @@ async function runUpgrade(args, deps) {
17608
18396
  applyUpgrade(plan, repo.repoRoot);
17609
18397
  if (routing.arm)
17610
18398
  await deps.setHooksPath(repo.repoRoot, TRACKED_HOOK_DIR);
17611
- writeManifestVersion(repo.repoRoot, withClaudeStopHookRecorded(planManifest, plan));
18399
+ writeManifestVersion(repo.repoRoot, withClaudeStopHookRecorded(withoutStaleFiles(planManifest, plan), plan));
17612
18400
  process.stdout.write(`
17613
18401
  Vinaya upgraded.
17614
18402
  `);
@@ -17734,7 +18522,7 @@ Reason: ${reason}`];
17734
18522
  // src/lib/output.ts
17735
18523
  var NAME_COLUMN_WIDTH = 28;
17736
18524
  var PLANNED_MARKER = "[planned — not yet implemented] ";
17737
- function row(indent2, name, description) {
18525
+ function row2(indent2, name, description) {
17738
18526
  const padded = name.length >= NAME_COLUMN_WIDTH ? `${name} ` : name.padEnd(NAME_COLUMN_WIDTH);
17739
18527
  return `${indent2}${padded}${description}`;
17740
18528
  }
@@ -17742,9 +18530,9 @@ function printHelp() {
17742
18530
  const lines = ["vinaya — Vinaya CLI", "", "USAGE", " vinaya <command> [options]", "", "COMMANDS"];
17743
18531
  for (const command of COMMANDS) {
17744
18532
  const marker = command.status === "planned" ? PLANNED_MARKER : "";
17745
- lines.push(row(" ", command.name, `${marker}${command.description}`));
18533
+ lines.push(row2(" ", command.name, `${marker}${command.description}`));
17746
18534
  for (const flag of command.flags ?? []) {
17747
- lines.push(row(" ", flag.flag, `${marker}${flag.description}`));
18535
+ lines.push(row2(" ", flag.flag, `${marker}${flag.description}`));
17748
18536
  }
17749
18537
  }
17750
18538
  lines.push("", "Run 'vinaya version' to check what's installed.");
@@ -17754,7 +18542,7 @@ function printHelp() {
17754
18542
  }
17755
18543
 
17756
18544
  // src/index.ts
17757
- var PACKAGE_ROOT = join34(dirname10(fileURLToPath2(import.meta.url)), "..");
18545
+ var PACKAGE_ROOT = join34(dirname12(fileURLToPath2(import.meta.url)), "..");
17758
18546
  function readVersion2() {
17759
18547
  const pkg = JSON.parse(readFileSync35(join34(PACKAGE_ROOT, "package.json"), "utf-8"));
17760
18548
  return pkg.version;