@attalabs/vinaya 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,63 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync as readFileSync10 } from "node:fs";
5
- import { dirname as dirname7, join as join12 } from "node:path";
6
- import { fileURLToPath as fileURLToPath4 } from "node:url";
4
+ import { readFileSync as readFileSync11 } from "node:fs";
5
+ import { dirname as dirname5, join as join14 } from "node:path";
6
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
7
7
 
8
- // src/commands/check.ts
8
+ // src/commands/archive.ts
9
9
  import { execFileSync as execFileSync2 } from "node:child_process";
10
-
11
- // src/checks/contract.ts
12
- var CHECK_SCHEMA_VERSION = 1;
13
- function emitCheckError(error) {
14
- process.stderr.write(`${JSON.stringify(error)}
15
- `);
16
- }
17
-
18
- // src/checks/registry.ts
19
- import { dirname, join } from "node:path";
20
- import { fileURLToPath } from "node:url";
21
- var CLI_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
22
- var BIN_DIR = join(CLI_ROOT, "src", "checks", "bin");
23
- function coreCheckRegistry() {
24
- return [
25
- {
26
- name: "brief-shape",
27
- run: join(BIN_DIR, "check-brief-shape.ts"),
28
- scope: "diff",
29
- timeoutMs: 15000
30
- },
31
- {
32
- name: "doc-coverage",
33
- run: join(BIN_DIR, "check-doc-coverage.ts"),
34
- scope: "diff",
35
- timeoutMs: 15000
36
- },
37
- {
38
- name: "coherence",
39
- run: join(BIN_DIR, "check-coherence.ts"),
40
- scope: "full",
41
- timeoutMs: 30000
42
- },
43
- {
44
- name: "dispatch-readiness",
45
- run: join(BIN_DIR, "check-dispatch-readiness.ts"),
46
- scope: "full",
47
- timeoutMs: 30000
48
- },
49
- {
50
- name: "reader-resolvable-prose",
51
- run: join(BIN_DIR, "check-reader-resolvable-prose.ts"),
52
- scope: "full",
53
- timeoutMs: 30000
54
- }
55
- ];
56
- }
57
-
58
- // src/checks/runner.ts
59
- import { spawn } from "node:child_process";
60
- import { cpus } from "node:os";
61
10
  // ../../../packages/aeg-core/src/anchored-region.ts
62
11
  function maskCode(body) {
63
12
  const fill = (line) => " ".repeat(line.length);
@@ -151,6 +100,8 @@ function anchoredRegion(body, field) {
151
100
  return null;
152
101
  return body.slice(afterStart, afterStart + end.index);
153
102
  }
103
+ // ../../../packages/aeg-forge-state/src/parse-rationale-deps.ts
104
+ var SECTION_HEADER = /\*\*Dependency rationale\*\*/i;
154
105
  // ../../../packages/aeg-forge-state/src/gh.ts
155
106
  import { execFile, execFileSync } from "node:child_process";
156
107
  import { promisify } from "node:util";
@@ -370,17 +321,12 @@ var DERIVED_STATUSES = [
370
321
  "incoherent"
371
322
  ];
372
323
  var DERIVABLE_STATUSES = DERIVED_STATUSES.filter((s) => s !== "backlog");
373
- // ../../../packages/aeg-core/src/file-classify.ts
374
- var FROZEN_ARCHIVES = new Set([
375
- "docs/decisions-legacy.md",
376
- "apps/herald-ai/docs/herald-decisions-legacy.md",
377
- "apps/vada-ai/docs/vada-decisions-legacy.md"
378
- ]);
379
324
  // ../../../packages/aeg-core/src/waiver-label.ts
380
325
  var WAIVER_LABEL = label("waiver-docs");
381
326
  var WAIVER_LABEL_REVIEW = label("waiver-review");
382
327
 
383
328
  // ../../../packages/aeg-core/src/doc-owners.ts
329
+ var DOC_OWNERS_PATH = ".vinaya/doc-owners";
384
330
  function globToRegex(pat) {
385
331
  let re = "^";
386
332
  let i = 0;
@@ -620,7 +566,7 @@ function checkForgeTitle(title) {
620
566
  }
621
567
  // ../../../packages/aeg-core/src/issue-validation.ts
622
568
  function hasRationaleField(body, labelPattern) {
623
- const re = new RegExp(`(?:\\*\\*|^#{1,4}\\s+)\\s*${labelPattern}`, "im");
569
+ const re = new RegExp(`(?:\\*\\*|^#{1,4}\\s+)\\s*(?:${labelPattern})`, "im");
624
570
  return re.test(body);
625
571
  }
626
572
  var RATIONALE_FIELDS = [
@@ -633,8 +579,18 @@ var RATIONALE_FIELDS = [
633
579
  { name: "Stop-and-escalate", pattern: "Stop-and-escalate" },
634
580
  { name: "Docs to keep coherent", pattern: "Docs to keep coherent|§7" }
635
581
  ];
582
+ var DEPENDENCY_RATIONALE_FIELD_NAME = "Dependency rationale";
636
583
  function checkIssueRationale(body) {
637
- const errors = RATIONALE_FIELDS.filter((f) => !hasRationaleField(body, f.pattern)).map((f) => `issue-validation ${f.name}: rationale field not found in the Issue body — every task Issue carries the full Planner's rationale (aeg-root/contracts/planner-brief.md).`);
584
+ const errors = [];
585
+ for (const f of RATIONALE_FIELDS) {
586
+ if (!hasRationaleField(body, f.pattern)) {
587
+ errors.push(`issue-validation ${f.name}: rationale field not found in the Issue body — every task Issue carries the full Planner's rationale (aeg-root/contracts/planner-brief.md).`);
588
+ continue;
589
+ }
590
+ if (f.name === DEPENDENCY_RATIONALE_FIELD_NAME && !SECTION_HEADER.test(body)) {
591
+ errors.push(`issue-validation ${f.name}: rationale field found, but not in the form amend-deps requires — ` + "write `**Dependency rationale** — …`, not `**Dependency rationale:** …`. " + "`amendRationaleDeps` (the only sanctioned way to edit Depends-on/Conflicts-with) locates this " + "section by the exact anchor `**Dependency rationale**`; a colon inside the bold breaks that match " + "and the Issue becomes unamendable.");
592
+ }
593
+ }
638
594
  return { status: errors.length > 0 ? "fail" : "pass", errors };
639
595
  }
640
596
  function isTaskIssueLabelSet(labels) {
@@ -643,116 +599,873 @@ function isTaskIssueLabelSet(labels) {
643
599
 
644
600
  // ../../../packages/aeg-core/src/coherence-checks.ts
645
601
  var R1_GRANDFATHERED_ISSUES = new Set([279, 280, 281, 282]);
602
+ // ../../../packages/aeg-core/src/verdict-extraction.ts
603
+ function extractVerdict(comments, valuePattern, missingLabel) {
604
+ const clearHits = comments.filter((c) => valuePattern.test(c));
605
+ if (clearHits.length > 0) {
606
+ const latest = clearHits[clearHits.length - 1];
607
+ const m = latest.match(valuePattern);
608
+ return { value: m[1].toUpperCase().replace(/[_-]/g, " "), danglingNote: null };
609
+ }
610
+ return {
611
+ value: `no ${missingLabel} pass was run before merge — DANGLING, see below`,
612
+ danglingNote: `no ${missingLabel} verdict comment found on this PR`
613
+ };
614
+ }
615
+ function extractCodeReviewVerdict(comments) {
616
+ return extractVerdict(comments, /^[ \t]*(?:\*{1,3}|_{1,3})?VERDICT:\s*(APPROVE|REQUEST[ _-]?CHANGES|LGTM)(?![A-Za-z0-9])/im, "code-reviewer");
617
+ }
618
+ function extractSecurityReviewVerdict(comments) {
619
+ return extractVerdict(comments, /^[ \t]*(?:\*{1,3}|_{1,3})?VERDICT:\s*(PASS|FAIL)(?![A-Za-z0-9])/im, "security-review");
620
+ }
621
+
622
+ // ../../../packages/aeg-core/src/archive-task.ts
623
+ var TASK_BRANCH_PATTERN = /^task\/([^/]+)\/([^/]+)$/;
624
+ function taskRefFromBranch(branch) {
625
+ const m = branch.match(TASK_BRANCH_PATTERN);
626
+ if (!m)
627
+ return null;
628
+ return { tranche: m[1], taskId: m[2] };
629
+ }
630
+ function isEligibleForProvenance(ref, issueLabels) {
631
+ if (ref !== null)
632
+ return true;
633
+ return hasLabel("tranche", issueLabels);
634
+ }
635
+ var PROVENANCE_HEADING = "### AEG provenance";
636
+ function hasProvenance(comments) {
637
+ return comments.some((c) => c.includes(PROVENANCE_HEADING));
638
+ }
639
+ function extractField(body, labelPattern, anchor) {
640
+ const anchored = anchor !== undefined ? anchoredRegion(body, anchor) : null;
641
+ const region = anchored ?? headerRegion(body);
642
+ const re = new RegExp(`^(?:\\*\\*)?\\s*${labelPattern}\\s*(?:\\*\\*)?\\s*:\\s*(?:\\*\\*)?\\s*([^\\n·]+)`, "im");
643
+ const m = region.match(re);
644
+ if (!m)
645
+ return null;
646
+ const value = m[1].trim();
647
+ return value.length > 0 ? value : null;
648
+ }
649
+ function closesRefs(text) {
650
+ return [...new Set([...text.matchAll(/Closes #(\d+)/gi)].map((m) => Number(m[1])))];
651
+ }
652
+ function extractIssue(body) {
653
+ const stripped = stripCode(body);
654
+ const bodyNums = closesRefs(stripped);
655
+ const anchored = anchoredRegion(stripped, "CLOSES");
656
+ if (anchored !== null) {
657
+ const anchorNums = closesRefs(anchored);
658
+ if (anchorNums.length === 0)
659
+ return { issue: null, extraIssues: bodyNums, outsideHeader: false };
660
+ const issue2 = anchorNums[0];
661
+ return { issue: issue2, extraIssues: bodyNums.filter((n) => n !== issue2), outsideHeader: false };
662
+ }
663
+ const headerNums = closesRefs(headerRegion(stripped));
664
+ if (bodyNums.length === 0)
665
+ return { issue: null, extraIssues: [], outsideHeader: false };
666
+ const issue = headerNums.length > 0 ? headerNums[0] : bodyNums[0];
667
+ return { issue, extraIssues: bodyNums.filter((n) => n !== issue), outsideHeader: headerNums.length === 0 };
668
+ }
669
+ function buildProvenanceBlock(facts) {
670
+ const ref = taskRefFromBranch(facts.headRefName);
671
+ const dangling = [];
672
+ const { issue, extraIssues, outsideHeader } = extractIssue(facts.body);
673
+ if (issue === null) {
674
+ dangling.push("no `Closes #N` found in PR body — Issue field is DANGLING, no Issue will be closed");
675
+ }
676
+ if (outsideHeader) {
677
+ dangling.push(`\`Closes #${issue}\` was found outside the PR body's header block — the canonical form places it before the first \`##\` heading`);
678
+ }
679
+ if (extraIssues.length > 0) {
680
+ dangling.push(`PR body references additional Issues (${extraIssues.map((n) => `#${n}`).join(", ")}) beyond the first — closing only #${issue}, the rest are flagged, not closed`);
681
+ }
682
+ const tier = readTierFromPrBody(facts.body);
683
+ if (tier === null)
684
+ dangling.push("no `Tier:` field found in PR body — Tier field is DANGLING");
685
+ const project = extractField(facts.body, "Project(?:\\(s\\))?", "PROJECT");
686
+ if (!project)
687
+ dangling.push("no `Project:` field found in PR body — Project(s) field is DANGLING");
688
+ const forField = extractField(facts.body, "For");
689
+ if (!forField)
690
+ dangling.push("no `For:` field found in PR body — Model/agent field is DANGLING");
691
+ const codeReview = extractCodeReviewVerdict(facts.comments);
692
+ if (codeReview.danglingNote)
693
+ dangling.push(codeReview.danglingNote);
694
+ const security = extractSecurityReviewVerdict(facts.comments);
695
+ if (security.danglingNote)
696
+ dangling.push(security.danglingNote);
697
+ const ticket = extractField(facts.body, "Ticket") ?? "none";
698
+ const taskLabel = ref ? `task ${ref.taskId} (tranche ${ref.tranche})` : `task (branch ${facts.headRefName})`;
699
+ const lines = [
700
+ `${PROVENANCE_HEADING} — ${taskLabel}`,
701
+ `- Issue: ${issue !== null ? `#${issue} (closed by merge)` : "DANGLING — no Closes #N in PR body"}`,
702
+ `- Tier: ${tier !== null ? tier : "DANGLING — no Tier field in PR body"}`,
703
+ "- Brief: in this PR body (the frozen intent)",
704
+ `- Project(s): ${project ?? "DANGLING — no Project field in PR body"}`,
705
+ `- Model/agent: ${forField ?? "DANGLING — no For field in PR body"}`,
706
+ `- Code review: ${codeReview.value}`,
707
+ `- Security: ${security.value}`,
708
+ `- Ticket: ${ticket}`,
709
+ `- Merged: ${facts.mergeSha} at ${facts.mergedAt}`
710
+ ];
711
+ if (dangling.length > 0) {
712
+ lines.push("", `DANGLING: ${dangling.join("; ")}`);
713
+ }
714
+ return { block: lines.join(`
715
+ `), issue, dangling };
716
+ }
646
717
  // ../../../packages/aeg-core/src/diagram-model.ts
647
718
  import matter from "gray-matter";
648
719
  // ../../../packages/aeg-core/src/reader-resolvable-prose.ts
649
720
  var SWEPT_CLASSES = new Set(["ships", "reader-facing"]);
650
- // src/checks/runner.ts
651
- function defaultParallelism() {
652
- return Math.max(1, cpus().length);
721
+ // ../../../packages/aeg-core/src/direct-main-push.ts
722
+ function checkDirectMainPush(fact) {
723
+ const [first] = fact.associatedMergedPrNumbers;
724
+ if (first !== undefined)
725
+ return { verdict: "legitimate", mergedPrNumber: first };
726
+ return { verdict: "direct-push", sha: fact.sha };
727
+ }
728
+ // ../../../packages/aeg-core/src/dead-branch-push-audit.ts
729
+ function findDeadBranchPushes(facts) {
730
+ return facts.filter((f) => new Date(f.latestCommitAt).getTime() > new Date(f.resolvedAt).getTime()).map(({ branch, prNumber, prState, resolvedAt, latestCommitAt }) => ({
731
+ branch,
732
+ prNumber,
733
+ prState,
734
+ resolvedAt,
735
+ latestCommitAt
736
+ }));
653
737
  }
654
- function isCheckError(value) {
655
- if (typeof value !== "object" || value === null)
656
- return false;
657
- const v = value;
658
- return v.schema === CHECK_SCHEMA_VERSION && typeof v.check === "string" && (v.severity === "error" || v.severity === "warning") && typeof v.message === "string" && typeof v.agent_recovery_prompt === "string";
738
+ // src/lib/detect.ts
739
+ import { execFile as execFile4 } from "node:child_process";
740
+ import { existsSync } from "node:fs";
741
+ import { join } from "node:path";
742
+ import { promisify as promisify4 } from "node:util";
743
+ var execFileAsync4 = promisify4(execFile4);
744
+ async function detectGitRepo() {
745
+ try {
746
+ const { stdout: root } = await execFileAsync4("git", ["rev-parse", "--show-toplevel"]);
747
+ const repoRoot = root.trim();
748
+ let owner = "";
749
+ let repo = "";
750
+ try {
751
+ const { stdout: url } = await execFileAsync4("git", ["remote", "get-url", "origin"]);
752
+ const m = url.trim().match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
753
+ if (m) {
754
+ owner = m[1] ?? "";
755
+ repo = m[2] ?? "";
756
+ }
757
+ } catch {}
758
+ return { repoRoot, owner, repo };
759
+ } catch {
760
+ return null;
761
+ }
659
762
  }
660
- function shouldSkip(spec, opts) {
661
- if (spec.scope !== "diff")
662
- return false;
663
- if (!opts.diffOnly)
664
- return false;
665
- if (opts.changedFiles === null)
666
- return false;
667
- if (!spec.include || spec.include.length === 0)
763
+ async function checkGhAuth() {
764
+ try {
765
+ await execFileAsync4("gh", ["auth", "status"]);
766
+ return true;
767
+ } catch {
668
768
  return false;
669
- const regexes = spec.include.map(globToRegex);
670
- return !opts.changedFiles.some((f) => regexes.some((re) => re.test(f)));
769
+ }
671
770
  }
672
- var KILL_GRACE_MS = 2000;
673
- async function runOne(spec, timeoutMs) {
674
- const start = performance.now();
675
- const proc = spawn(spec.run, spec.args ?? [], {
676
- stdio: ["ignore", "pipe", "pipe"]
677
- });
678
- let timedOut = false;
679
- let killTimer;
680
- const timer = setTimeout(() => {
681
- timedOut = true;
682
- proc.kill("SIGTERM");
683
- killTimer = setTimeout(() => proc.kill("SIGKILL"), KILL_GRACE_MS);
684
- }, timeoutMs);
685
- let stderrText = "";
686
- proc.stderr.setEncoding("utf-8");
687
- proc.stderr.on("data", (chunk) => {
688
- stderrText += chunk;
689
- });
690
- proc.stdout.resume();
691
- let spawnError;
692
- const exitCode = await new Promise((resolve) => {
693
- proc.on("close", (code) => resolve(code));
694
- proc.on("error", (err) => {
695
- spawnError = err;
696
- resolve(null);
697
- });
698
- });
699
- clearTimeout(timer);
700
- clearTimeout(killTimer);
701
- const durationMs = performance.now() - start;
702
- if (timedOut) {
703
- return { name: spec.name, status: "timeout", exitCode: null, errors: [], durationMs };
771
+ async function ghAuthStatus() {
772
+ try {
773
+ const { stderr, stdout } = await execFileAsync4("gh", ["auth", "status"]);
774
+ return { authenticated: true, detail: (stderr || stdout).trim() };
775
+ } catch (err) {
776
+ const stderr = err.stderr;
777
+ return { authenticated: false, detail: stderr?.trim() || "gh is not authenticated" };
704
778
  }
705
- if (spawnError) {
706
- const code = spawnError.code;
707
- return {
708
- name: spec.name,
709
- status: "error",
710
- exitCode: null,
711
- errors: [
712
- {
713
- schema: 1,
714
- check: spec.name,
715
- severity: "error",
716
- message: code === "ENOENT" ? `Could not run check "${spec.name}": executable \`${spec.run}\` was not found on PATH.` : `Could not run check "${spec.name}": ${spawnError.message}`,
717
- agent_recovery_prompt: code === "ENOENT" ? `Install \`${spec.run}\` or correct the \`run\` field for check "${spec.name}" in vinaya.config.json, then re-run.` : `Inspect the \`run\` and \`args\` fields for check "${spec.name}" in vinaya.config.json, then re-run.`
718
- }
719
- ],
720
- durationMs
721
- };
779
+ }
780
+ async function branchProtectionConfigured(owner, repo) {
781
+ if (!owner || !repo)
782
+ return null;
783
+ try {
784
+ await execFileAsync4("gh", ["api", `repos/${owner}/${repo}/branches/main/protection`]);
785
+ return true;
786
+ } catch (err) {
787
+ const stderr = err.stderr ?? "";
788
+ if (/\b404\b/.test(stderr))
789
+ return false;
790
+ return null;
722
791
  }
723
- const lines = stderrText.split(`
724
- `).map((l) => l.trim()).filter(Boolean);
725
- const errors = [];
726
- let malformed = false;
727
- for (const line of lines) {
728
- try {
729
- const parsed = JSON.parse(line);
730
- if (isCheckError(parsed)) {
731
- errors.push(parsed);
732
- } else {
733
- malformed = true;
792
+ }
793
+ function resolveHookDir(repoRoot) {
794
+ return existsSync(join(repoRoot, ".husky")) ? ".husky" : ".git/hooks";
795
+ }
796
+ function hookDirFromManifest(manifest, fallback) {
797
+ const block = manifest.blocks.find((b) => b.path.startsWith(".husky/") || b.path.startsWith(".git/hooks/"));
798
+ if (block?.path.startsWith(".husky/"))
799
+ return ".husky";
800
+ if (block?.path.startsWith(".git/hooks/"))
801
+ return ".git/hooks";
802
+ return fallback;
803
+ }
804
+ async function customHooksPath(repoRoot) {
805
+ try {
806
+ const { stdout } = await execFileAsync4("git", ["-C", repoRoot, "config", "--get", "core.hooksPath"]);
807
+ const v = stdout.trim();
808
+ if (!v)
809
+ return null;
810
+ if (v === ".husky" || v === ".husky/_")
811
+ return null;
812
+ return v;
813
+ } catch {
814
+ return null;
815
+ }
816
+ }
817
+ function ghLabelGateway(repoRoot) {
818
+ return {
819
+ async exists(name) {
820
+ try {
821
+ const { stdout } = await execFileAsync4("gh", ["label", "list", "--json", "name"], { cwd: repoRoot });
822
+ const names = JSON.parse(stdout);
823
+ return names.some((l) => l.name === name);
824
+ } catch {
825
+ return false;
734
826
  }
735
- } catch {
736
- malformed = true;
827
+ },
828
+ async create(name, color, description) {
829
+ await execFileAsync4("gh", ["label", "create", name, "--color", color, "--description", description], {
830
+ cwd: repoRoot
831
+ });
737
832
  }
738
- }
739
- let status;
740
- if (malformed) {
741
- status = "error";
742
- } else if (exitCode === 0) {
743
- status = "pass";
744
- } else if (exitCode === 1) {
745
- status = "fail";
746
- } else {
747
- status = "error";
748
- }
749
- return { name: spec.name, status, exitCode, errors, durationMs };
833
+ };
750
834
  }
751
- async function runChecks(specs, opts) {
752
- const results = new Array(specs.length);
753
- const toRun = [];
754
- for (let i = 0;i < specs.length; i++) {
755
- const spec = specs[i];
835
+
836
+ // src/commands/archive.ts
837
+ function realDeps() {
838
+ return { detectRepo: detectGitRepo };
839
+ }
840
+ function sh(args, input) {
841
+ return execFileSync2(args[0], args.slice(1), { encoding: "utf8", input }).trim();
842
+ }
843
+ function shJson(args) {
844
+ return JSON.parse(sh(args));
845
+ }
846
+ function parseMergeSha(args) {
847
+ for (const a of args) {
848
+ const m = a.match(/^--merge-sha=(.+)$/);
849
+ if (m)
850
+ return m[1];
851
+ }
852
+ return null;
853
+ }
854
+ async function runArchive(args, deps) {
855
+ const repo = await deps.detectRepo();
856
+ if (!repo) {
857
+ console.error("Error: not a git repository. Run `vinaya archive` from inside your repo.");
858
+ return 1;
859
+ }
860
+ if (!repo.owner || !repo.repo) {
861
+ console.error("Error: could not resolve a GitHub owner/repo from the `origin` remote.");
862
+ return 1;
863
+ }
864
+ const repoFlag = `${repo.owner}/${repo.repo}`;
865
+ const mergeSha = parseMergeSha(args) ?? sh(["git", "rev-parse", "HEAD"]);
866
+ const associated = shJson(["gh", "api", `repos/${repoFlag}/commits/${mergeSha}/pulls`]);
867
+ if (associated.length === 0) {
868
+ console.log(`[vinaya archive] no associated PR for merge ${mergeSha} — skip.`);
869
+ return 0;
870
+ }
871
+ const prNumber = associated[0].number;
872
+ const pr = shJson([
873
+ "gh",
874
+ "pr",
875
+ "view",
876
+ String(prNumber),
877
+ "-R",
878
+ repoFlag,
879
+ "--json",
880
+ "number,headRefName,body,mergedAt,comments"
881
+ ]);
882
+ const ref = taskRefFromBranch(pr.headRefName);
883
+ let issueLabels = [];
884
+ if (ref === null) {
885
+ const { issue: candidateIssue } = extractIssue(pr.body);
886
+ if (candidateIssue !== null) {
887
+ issueLabels = shJson([
888
+ "gh",
889
+ "issue",
890
+ "view",
891
+ String(candidateIssue),
892
+ "-R",
893
+ repoFlag,
894
+ "--json",
895
+ "labels"
896
+ ]).labels.map((l) => l.name);
897
+ }
898
+ }
899
+ if (!isEligibleForProvenance(ref, issueLabels)) {
900
+ console.log(`[vinaya archive] non-task branch (${pr.headRefName}) and closing Issue carries no vinaya/tranche:* label — skip.`);
901
+ return 0;
902
+ }
903
+ const comments = pr.comments.map((c) => c.body);
904
+ if (hasProvenance(comments)) {
905
+ console.log(`[vinaya archive] provenance already present on PR #${pr.number} — skip (idempotent).`);
906
+ return 0;
907
+ }
908
+ const facts = {
909
+ number: pr.number,
910
+ headRefName: pr.headRefName,
911
+ body: pr.body,
912
+ mergedAt: pr.mergedAt,
913
+ mergeSha,
914
+ comments
915
+ };
916
+ const { block, issue, dangling } = buildProvenanceBlock(facts);
917
+ console.log(`[vinaya archive] posting provenance block to PR #${pr.number}...`);
918
+ sh(["gh", "pr", "comment", String(pr.number), "-R", repoFlag, "--body-file", "-"], block);
919
+ console.log(`[vinaya archive] provenance block posted to PR #${pr.number}.`);
920
+ if (dangling.length > 0) {
921
+ console.log(`[vinaya archive] DANGLING (${dangling.length}): ${dangling.join("; ")}`);
922
+ }
923
+ if (issue !== null) {
924
+ console.log(`[vinaya archive] closing Issue #${issue}...`);
925
+ sh(["gh", "issue", "close", String(issue), "-R", repoFlag]);
926
+ const state = shJson([
927
+ "gh",
928
+ "issue",
929
+ "view",
930
+ String(issue),
931
+ "-R",
932
+ repoFlag,
933
+ "--json",
934
+ "state"
935
+ ]).state;
936
+ if (state !== "CLOSED") {
937
+ console.error(`[vinaya archive] FAILED — Issue #${issue} did not confirm CLOSED (state: ${state}).`);
938
+ return 1;
939
+ }
940
+ console.log(`[vinaya archive] Issue #${issue} confirmed CLOSED.`);
941
+ } else {
942
+ console.log("[vinaya archive] no Issue to close — Closes #N absent from PR body.");
943
+ }
944
+ console.log("[vinaya archive] PASS.");
945
+ return 0;
946
+ }
947
+ async function archiveCommand(args) {
948
+ process.exit(await runArchive(args, realDeps()));
949
+ }
950
+
951
+ // src/commands/audit.ts
952
+ import { execFileSync as execFileSync3 } from "node:child_process";
953
+
954
+ // src/lib/envelope.ts
955
+ var ENVELOPE_SCHEMA_VERSION = 1;
956
+ function toEnvelope(data) {
957
+ return { schema: ENVELOPE_SCHEMA_VERSION, data };
958
+ }
959
+ function printJson(data) {
960
+ process.stdout.write(`${JSON.stringify(toEnvelope(data), null, 2)}
961
+ `);
962
+ }
963
+
964
+ // src/commands/audit.ts
965
+ function realDeps2() {
966
+ return { detectRepo: detectGitRepo };
967
+ }
968
+ var COMMAND_TIMEOUT_MS = 20000;
969
+ var DEAD_BRANCH_LABEL = label("dead-branch-push");
970
+ var DEAD_BRANCH_MARKER = `<!-- ${DEAD_BRANCH_LABEL} -->`;
971
+ var DEAD_BRANCH_LABEL_DESCRIPTION = "daily-drift: commits landed on this branch after its PR already resolved";
972
+ var DIRECT_PUSH_LABEL = label("direct-main-push");
973
+ var DIRECT_PUSH_MARKER = `<!-- ${DIRECT_PUSH_LABEL} -->`;
974
+ var DIRECT_PUSH_LABEL_DESCRIPTION = "A commit landed on main with no associated merged PR (ring-2 detection)";
975
+ function shSoft(args, input) {
976
+ try {
977
+ return execFileSync3(args[0], args.slice(1), {
978
+ encoding: "utf8",
979
+ input,
980
+ timeout: COMMAND_TIMEOUT_MS
981
+ }).trim();
982
+ } catch (err) {
983
+ const stderr = err.stderr;
984
+ const message = stderr ? stderr.toString().trim() : err.message;
985
+ console.log(`[vinaya audit] non-fatal: command failed — ${message}`);
986
+ return "";
987
+ }
988
+ }
989
+ function shSoftJson(args) {
990
+ const out = shSoft(args);
991
+ if (!out)
992
+ return null;
993
+ try {
994
+ return JSON.parse(out);
995
+ } catch {
996
+ return null;
997
+ }
998
+ }
999
+ function sh2(args, input) {
1000
+ return execFileSync3(args[0], args.slice(1), { encoding: "utf8", input }).trim();
1001
+ }
1002
+ function listTaskBranches() {
1003
+ const out = shSoft(["git", "ls-remote", "--heads", "origin", "task/*"]);
1004
+ if (!out)
1005
+ return [];
1006
+ return out.split(`
1007
+ `).map((line) => line.trim()).filter(Boolean).map((line) => {
1008
+ const [sha, ref] = line.split("\t");
1009
+ return { name: ref.replace(/^refs\/heads\//, ""), sha };
1010
+ });
1011
+ }
1012
+ function mostRecentPr(branch, repoFlag) {
1013
+ const entries = shSoftJson([
1014
+ "gh",
1015
+ "pr",
1016
+ "list",
1017
+ "--head",
1018
+ branch,
1019
+ "-R",
1020
+ repoFlag,
1021
+ "--state",
1022
+ "all",
1023
+ "--json",
1024
+ "number,state,mergedAt,closedAt",
1025
+ "--limit",
1026
+ "1"
1027
+ ]);
1028
+ return entries?.[0] ?? null;
1029
+ }
1030
+ function commitDate(sha, repoFlag) {
1031
+ const json = shSoftJson([
1032
+ "gh",
1033
+ "api",
1034
+ `repos/${repoFlag}/commits/${sha}`
1035
+ ]);
1036
+ return json?.commit?.committer?.date ?? null;
1037
+ }
1038
+ function gatherDeadBranchFacts(repoFlag) {
1039
+ const facts = [];
1040
+ for (const { name, sha } of listTaskBranches()) {
1041
+ const pr = mostRecentPr(name, repoFlag);
1042
+ if (!pr)
1043
+ continue;
1044
+ if (pr.state !== "MERGED" && pr.state !== "CLOSED")
1045
+ continue;
1046
+ const resolvedAt = pr.state === "MERGED" ? pr.mergedAt : pr.closedAt;
1047
+ if (!resolvedAt)
1048
+ continue;
1049
+ const latestCommitAt = commitDate(sha, repoFlag);
1050
+ if (!latestCommitAt)
1051
+ continue;
1052
+ facts.push({ branch: name, prNumber: pr.number, prState: pr.state, resolvedAt, latestCommitAt });
1053
+ }
1054
+ return facts;
1055
+ }
1056
+ function ensureLabelExists(repoFlag, name, description) {
1057
+ const existing = shSoftJson(["gh", "label", "list", "-R", repoFlag, "--json", "name", "--limit", "200"]) ?? [];
1058
+ if (existing.some((l) => l.name === name))
1059
+ return;
1060
+ shSoft(["gh", "label", "create", name, "-R", repoFlag, "--color", "B60205", "--description", description]);
1061
+ }
1062
+ function commentIdFromUrl(url) {
1063
+ const match = url.match(/issuecomment-(\d+)/);
1064
+ return match ? match[1] : null;
1065
+ }
1066
+ function flagDeadBranchPush(finding, repoFlag) {
1067
+ shSoft(["gh", "pr", "edit", String(finding.prNumber), "-R", repoFlag, "--add-label", DEAD_BRANCH_LABEL]);
1068
+ const body = [
1069
+ DEAD_BRANCH_MARKER,
1070
+ "**Dead-branch push detected by `vinaya audit`.**",
1071
+ "",
1072
+ `Branch \`${finding.branch}\` has a ${finding.prState} PR (#${finding.prNumber}, resolved ${finding.resolvedAt}), ` + `but its remote tip commit is dated ${finding.latestCommitAt} — after that resolution. ` + "This is a notification, not a merge gate — nothing was reverted or mutated."
1073
+ ].join(`
1074
+ `);
1075
+ const prJson = shSoftJson([
1076
+ "gh",
1077
+ "pr",
1078
+ "view",
1079
+ String(finding.prNumber),
1080
+ "-R",
1081
+ repoFlag,
1082
+ "--json",
1083
+ "comments"
1084
+ ]);
1085
+ const existing = prJson?.comments.find((c) => c.body.includes(DEAD_BRANCH_MARKER));
1086
+ const existingId = existing ? commentIdFromUrl(existing.url) : null;
1087
+ if (existingId) {
1088
+ shSoft(["gh", "api", `repos/${repoFlag}/issues/comments/${existingId}`, "-X", "PATCH", "-F", "body=@-"], body);
1089
+ } else {
1090
+ shSoft(["gh", "pr", "comment", String(finding.prNumber), "-R", repoFlag, "--body-file", "-"], body);
1091
+ }
1092
+ }
1093
+ function runDeadBranchAudit(repoFlag) {
1094
+ try {
1095
+ const facts = gatherDeadBranchFacts(repoFlag);
1096
+ const findings = findDeadBranchPushes(facts);
1097
+ if (findings.length > 0) {
1098
+ ensureLabelExists(repoFlag, DEAD_BRANCH_LABEL, DEAD_BRANCH_LABEL_DESCRIPTION);
1099
+ for (const finding of findings)
1100
+ flagDeadBranchPush(finding, repoFlag);
1101
+ }
1102
+ return { scanned: facts.length, findings };
1103
+ } catch (err) {
1104
+ console.log(`[vinaya audit] dead-branch scan error (never-red, reporting only): ${err.message}`);
1105
+ return { scanned: 0, findings: [] };
1106
+ }
1107
+ }
1108
+ function fetchAssociatedMergedPrs(sha, repoFlag) {
1109
+ const out = sh2(["gh", "api", `repos/${repoFlag}/commits/${sha}/pulls`]);
1110
+ const prs = JSON.parse(out);
1111
+ return prs.filter((pr) => pr.merged_at !== null).map((pr) => pr.number);
1112
+ }
1113
+ function incidentAlreadyOpen(sha, repoFlag) {
1114
+ const out = sh2([
1115
+ "gh",
1116
+ "issue",
1117
+ "list",
1118
+ "-R",
1119
+ repoFlag,
1120
+ "--label",
1121
+ DIRECT_PUSH_LABEL,
1122
+ "--state",
1123
+ "open",
1124
+ "--search",
1125
+ sha,
1126
+ "--json",
1127
+ "number"
1128
+ ]);
1129
+ const issues = JSON.parse(out || "[]");
1130
+ return issues.length > 0;
1131
+ }
1132
+ function openDirectPushIncident(sha, repoFlag) {
1133
+ ensureLabelExists(repoFlag, DIRECT_PUSH_LABEL, DIRECT_PUSH_LABEL_DESCRIPTION);
1134
+ if (incidentAlreadyOpen(sha, repoFlag)) {
1135
+ console.error(`[vinaya audit] incident already open for ${sha} — not duplicating.`);
1136
+ return;
1137
+ }
1138
+ const body = [
1139
+ DIRECT_PUSH_MARKER,
1140
+ "**Direct push to `main` detected — no associated merged PR.**",
1141
+ "",
1142
+ `Commit \`${sha}\` landed on \`main\` but the commits→pulls association API reports no merged pull request that introduced it.`,
1143
+ "",
1144
+ "This is a detection-only finding — nothing was reverted or mutated."
1145
+ ].join(`
1146
+ `);
1147
+ sh2([
1148
+ "gh",
1149
+ "issue",
1150
+ "create",
1151
+ "-R",
1152
+ repoFlag,
1153
+ "--title",
1154
+ `Direct push to main detected: ${sha.slice(0, 12)}`,
1155
+ "--body",
1156
+ body,
1157
+ "--label",
1158
+ DIRECT_PUSH_LABEL
1159
+ ]);
1160
+ }
1161
+ function runDirectMainPushCheck(sha, repoFlag) {
1162
+ const associatedMergedPrNumbers = fetchAssociatedMergedPrs(sha, repoFlag);
1163
+ const result = checkDirectMainPush({ sha, associatedMergedPrNumbers });
1164
+ if (result.verdict === "direct-push") {
1165
+ openDirectPushIncident(sha, repoFlag);
1166
+ return { verdict: "direct-push" };
1167
+ }
1168
+ return { verdict: "legitimate", mergedPrNumber: result.mergedPrNumber };
1169
+ }
1170
+ function parseSha(args) {
1171
+ for (const a of args) {
1172
+ const m = a.match(/^--sha=(.+)$/);
1173
+ if (m)
1174
+ return m[1];
1175
+ }
1176
+ return null;
1177
+ }
1178
+ function parseOnly(args) {
1179
+ for (const a of args) {
1180
+ const m = a.match(/^--only=(dead-branches|direct-push)$/);
1181
+ if (m)
1182
+ return m[1];
1183
+ }
1184
+ return null;
1185
+ }
1186
+ async function runAudit(args, deps) {
1187
+ const jsonOutput = args.includes("--json");
1188
+ const only = parseOnly(args);
1189
+ const repo = await deps.detectRepo();
1190
+ if (!repo) {
1191
+ console.error("Error: not a git repository. Run `vinaya audit` from inside your repo.");
1192
+ return 1;
1193
+ }
1194
+ if (!repo.owner || !repo.repo) {
1195
+ console.error("Error: could not resolve a GitHub owner/repo from the `origin` remote.");
1196
+ return 1;
1197
+ }
1198
+ const repoFlag = `${repo.owner}/${repo.repo}`;
1199
+ const sha = parseSha(args) ?? sh2(["git", "rev-parse", "HEAD"]);
1200
+ const deadBranch = only === "direct-push" ? { scanned: 0, findings: [] } : runDeadBranchAudit(repoFlag);
1201
+ const directPush = only === "dead-branches" ? null : runDirectMainPushCheck(sha, repoFlag);
1202
+ const failed = directPush?.verdict === "direct-push";
1203
+ if (jsonOutput) {
1204
+ printJson({
1205
+ deadBranchAudit: only === "direct-push" ? null : { scanned: deadBranch.scanned, findings: deadBranch.findings },
1206
+ directMainPush: directPush ? { sha, ...directPush } : null
1207
+ });
1208
+ } else {
1209
+ process.stdout.write(`vinaya audit
1210
+
1211
+ `);
1212
+ if (only !== "direct-push") {
1213
+ process.stdout.write(`· [dead-branch-push] task branches scanned: ${deadBranch.scanned}, flagged: ${deadBranch.findings.length}
1214
+ `);
1215
+ for (const f of deadBranch.findings) {
1216
+ process.stdout.write(` ⚠ ${f.branch} — PR #${f.prNumber} (${f.prState}) resolved ${f.resolvedAt}, tip commit ${f.latestCommitAt}
1217
+ `);
1218
+ }
1219
+ }
1220
+ if (directPush) {
1221
+ if (directPush.verdict === "legitimate") {
1222
+ process.stdout.write(`✓ [direct-main-push] ${sha} is legitimate — introduced by merged PR #${directPush.mergedPrNumber}.
1223
+ `);
1224
+ } else {
1225
+ process.stdout.write(`✗ [direct-main-push] VIOLATION: ${sha} has no associated merged PR.
1226
+ `);
1227
+ }
1228
+ }
1229
+ }
1230
+ return failed ? 1 : 0;
1231
+ }
1232
+ async function auditCommand(args) {
1233
+ process.exit(await runAudit(args, realDeps2()));
1234
+ }
1235
+
1236
+ // src/commands/check.ts
1237
+ import { execFileSync as execFileSync4 } from "node:child_process";
1238
+
1239
+ // src/checks/contract.ts
1240
+ var CHECK_SCHEMA_VERSION = 1;
1241
+ function emitCheckError(error) {
1242
+ process.stderr.write(`${JSON.stringify(error)}
1243
+ `);
1244
+ }
1245
+
1246
+ // src/checks/registry.ts
1247
+ import { join as join3 } from "node:path";
1248
+
1249
+ // src/lib/package-root.ts
1250
+ import { existsSync as existsSync2 } from "node:fs";
1251
+ import { dirname, join as join2 } from "node:path";
1252
+ import { fileURLToPath } from "node:url";
1253
+ function packageRoot(moduleUrl) {
1254
+ let dir = dirname(fileURLToPath(moduleUrl));
1255
+ while (!existsSync2(join2(dir, "package.json"))) {
1256
+ const parent = dirname(dir);
1257
+ if (parent === dir)
1258
+ break;
1259
+ dir = parent;
1260
+ }
1261
+ return dir;
1262
+ }
1263
+
1264
+ // src/checks/registry.ts
1265
+ var BIN_DIR = join3(packageRoot(import.meta.url), "src", "checks", "bin");
1266
+ function coreCheckRegistry() {
1267
+ return [
1268
+ {
1269
+ name: "brief-shape",
1270
+ run: join3(BIN_DIR, "check-brief-shape.ts"),
1271
+ scope: "diff",
1272
+ timeoutMs: 15000
1273
+ },
1274
+ {
1275
+ name: "doc-coverage",
1276
+ run: join3(BIN_DIR, "check-doc-coverage.ts"),
1277
+ scope: "diff",
1278
+ timeoutMs: 15000
1279
+ },
1280
+ {
1281
+ name: "coherence",
1282
+ run: join3(BIN_DIR, "check-coherence.ts"),
1283
+ scope: "full",
1284
+ timeoutMs: 30000
1285
+ },
1286
+ {
1287
+ name: "dispatch-readiness",
1288
+ run: join3(BIN_DIR, "check-dispatch-readiness.ts"),
1289
+ scope: "full",
1290
+ timeoutMs: 30000
1291
+ },
1292
+ {
1293
+ name: "closes-n",
1294
+ run: join3(BIN_DIR, "check-closes-n.ts"),
1295
+ scope: "diff",
1296
+ timeoutMs: 15000
1297
+ },
1298
+ {
1299
+ name: "single-plan-pr",
1300
+ run: join3(BIN_DIR, "check-single-plan-pr.ts"),
1301
+ scope: "diff",
1302
+ timeoutMs: 15000
1303
+ },
1304
+ {
1305
+ name: "test-plan",
1306
+ run: join3(BIN_DIR, "check-test-plan.ts"),
1307
+ scope: "diff",
1308
+ timeoutMs: 15000
1309
+ },
1310
+ {
1311
+ name: "no-disk-state",
1312
+ run: join3(BIN_DIR, "check-no-disk-state.ts"),
1313
+ scope: "diff",
1314
+ timeoutMs: 15000
1315
+ },
1316
+ {
1317
+ name: "registry-gates",
1318
+ run: join3(BIN_DIR, "check-registry-gates.ts"),
1319
+ scope: "full",
1320
+ timeoutMs: 30000
1321
+ },
1322
+ {
1323
+ name: "review-gate",
1324
+ run: join3(BIN_DIR, "check-review-gate.ts"),
1325
+ scope: "full",
1326
+ timeoutMs: 30000
1327
+ },
1328
+ {
1329
+ name: "branch-topology",
1330
+ run: join3(BIN_DIR, "check-branch-topology.ts"),
1331
+ scope: "full",
1332
+ timeoutMs: 30000
1333
+ },
1334
+ {
1335
+ name: "dead-branch-push",
1336
+ run: join3(BIN_DIR, "check-dead-branch-push.ts"),
1337
+ scope: "full",
1338
+ timeoutMs: 30000
1339
+ },
1340
+ {
1341
+ name: "first-push-dispatch",
1342
+ run: join3(BIN_DIR, "check-first-push-dispatch.ts"),
1343
+ scope: "full",
1344
+ timeoutMs: 30000
1345
+ },
1346
+ {
1347
+ name: "doc-coverage-push",
1348
+ run: join3(BIN_DIR, "check-doc-coverage-push.ts"),
1349
+ scope: "diff",
1350
+ timeoutMs: 15000
1351
+ },
1352
+ {
1353
+ name: "issue-assignment",
1354
+ run: join3(BIN_DIR, "check-issue-assignment.ts"),
1355
+ scope: "full",
1356
+ timeoutMs: 30000
1357
+ }
1358
+ ];
1359
+ }
1360
+
1361
+ // src/checks/runner.ts
1362
+ import { spawn } from "node:child_process";
1363
+ import { cpus } from "node:os";
1364
+ function defaultParallelism() {
1365
+ return Math.max(1, cpus().length);
1366
+ }
1367
+ function isCheckError(value) {
1368
+ if (typeof value !== "object" || value === null)
1369
+ return false;
1370
+ const v = value;
1371
+ return v.schema === CHECK_SCHEMA_VERSION && typeof v.check === "string" && (v.severity === "error" || v.severity === "warning") && typeof v.message === "string" && typeof v.agent_recovery_prompt === "string";
1372
+ }
1373
+ function shouldSkip(spec, opts) {
1374
+ if (spec.scope !== "diff")
1375
+ return false;
1376
+ if (!opts.diffOnly)
1377
+ return false;
1378
+ if (opts.changedFiles === null)
1379
+ return false;
1380
+ if (!spec.include || spec.include.length === 0)
1381
+ return false;
1382
+ const regexes = spec.include.map(globToRegex);
1383
+ return !opts.changedFiles.some((f) => regexes.some((re) => re.test(f)));
1384
+ }
1385
+ var KILL_GRACE_MS = 2000;
1386
+ async function runOne(spec, timeoutMs) {
1387
+ const start = performance.now();
1388
+ const proc = spawn(spec.run, spec.args ?? [], {
1389
+ stdio: ["ignore", "pipe", "pipe"]
1390
+ });
1391
+ let timedOut = false;
1392
+ let killTimer;
1393
+ const timer = setTimeout(() => {
1394
+ timedOut = true;
1395
+ proc.kill("SIGTERM");
1396
+ killTimer = setTimeout(() => proc.kill("SIGKILL"), KILL_GRACE_MS);
1397
+ }, timeoutMs);
1398
+ let stderrText = "";
1399
+ proc.stderr.setEncoding("utf-8");
1400
+ proc.stderr.on("data", (chunk) => {
1401
+ stderrText += chunk;
1402
+ });
1403
+ proc.stdout.resume();
1404
+ let spawnError;
1405
+ const exitCode = await new Promise((resolve) => {
1406
+ proc.on("close", (code) => resolve(code));
1407
+ proc.on("error", (err) => {
1408
+ spawnError = err;
1409
+ resolve(null);
1410
+ });
1411
+ });
1412
+ clearTimeout(timer);
1413
+ clearTimeout(killTimer);
1414
+ const durationMs = performance.now() - start;
1415
+ if (timedOut) {
1416
+ return { name: spec.name, status: "timeout", exitCode: null, errors: [], durationMs };
1417
+ }
1418
+ if (spawnError) {
1419
+ const code = spawnError.code;
1420
+ return {
1421
+ name: spec.name,
1422
+ status: "error",
1423
+ exitCode: null,
1424
+ errors: [
1425
+ {
1426
+ schema: 1,
1427
+ check: spec.name,
1428
+ severity: "error",
1429
+ message: code === "ENOENT" ? `Could not run check "${spec.name}": executable \`${spec.run}\` was not found on PATH.` : `Could not run check "${spec.name}": ${spawnError.message}`,
1430
+ agent_recovery_prompt: code === "ENOENT" ? `Install \`${spec.run}\` or correct the \`run\` field for check "${spec.name}" in vinaya.config.json, then re-run.` : `Inspect the \`run\` and \`args\` fields for check "${spec.name}" in vinaya.config.json, then re-run.`
1431
+ }
1432
+ ],
1433
+ durationMs
1434
+ };
1435
+ }
1436
+ const lines = stderrText.split(`
1437
+ `).map((l) => l.trim()).filter(Boolean);
1438
+ const errors = [];
1439
+ let malformed = false;
1440
+ for (const line of lines) {
1441
+ try {
1442
+ const parsed = JSON.parse(line);
1443
+ if (isCheckError(parsed)) {
1444
+ errors.push(parsed);
1445
+ } else {
1446
+ malformed = true;
1447
+ }
1448
+ } catch {
1449
+ malformed = true;
1450
+ }
1451
+ }
1452
+ let status;
1453
+ if (malformed) {
1454
+ status = "error";
1455
+ } else if (exitCode === 0) {
1456
+ status = "pass";
1457
+ } else if (exitCode === 1) {
1458
+ status = "fail";
1459
+ } else {
1460
+ status = "error";
1461
+ }
1462
+ return { name: spec.name, status, exitCode, errors, durationMs };
1463
+ }
1464
+ async function runChecks(specs, opts) {
1465
+ const results = new Array(specs.length);
1466
+ const toRun = [];
1467
+ for (let i = 0;i < specs.length; i++) {
1468
+ const spec = specs[i];
756
1469
  if (shouldSkip(spec, opts)) {
757
1470
  results[i] = { name: spec.name, status: "skipped", exitCode: null, errors: [], durationMs: 0 };
758
1471
  } else {
@@ -774,9 +1487,9 @@ async function runChecks(specs, opts) {
774
1487
  }
775
1488
 
776
1489
  // src/lib/config.ts
777
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1490
+ import { existsSync as existsSync3, mkdirSync, readFileSync, writeFileSync } from "node:fs";
778
1491
  import { homedir } from "node:os";
779
- import { dirname as dirname2, join as join2 } from "node:path";
1492
+ import { dirname as dirname2, join as join4 } from "node:path";
780
1493
  import { z } from "zod";
781
1494
  var CheckEntrySchema = z.object({
782
1495
  run: z.string(),
@@ -842,14 +1555,14 @@ var VinayaConfigSchema = z.object({
842
1555
  briefSchema: BriefSchemaSchema.optional(),
843
1556
  managed: ManagedManifestSchema.optional()
844
1557
  });
845
- var GLOBAL_VINAYA_HOME = join2(homedir(), ".vinaya");
846
- var GLOBAL_CONFIG_PATH = join2(GLOBAL_VINAYA_HOME, "config.json");
1558
+ var GLOBAL_VINAYA_HOME = join4(homedir(), ".vinaya");
1559
+ var GLOBAL_CONFIG_PATH = join4(GLOBAL_VINAYA_HOME, "config.json");
847
1560
  var LOCAL_CONFIG_FILENAME = "vinaya.config.json";
848
1561
  function findLocalConfig() {
849
1562
  let dir = process.cwd();
850
1563
  while (true) {
851
- const candidate = join2(dir, LOCAL_CONFIG_FILENAME);
852
- if (existsSync(candidate))
1564
+ const candidate = join4(dir, LOCAL_CONFIG_FILENAME);
1565
+ if (existsSync3(candidate))
853
1566
  return candidate;
854
1567
  const parent = dirname2(dir);
855
1568
  if (parent === dir)
@@ -861,7 +1574,7 @@ function configPath() {
861
1574
  const local = findLocalConfig();
862
1575
  if (local)
863
1576
  return local;
864
- if (existsSync(GLOBAL_CONFIG_PATH))
1577
+ if (existsSync3(GLOBAL_CONFIG_PATH))
865
1578
  return GLOBAL_CONFIG_PATH;
866
1579
  return null;
867
1580
  }
@@ -883,20 +1596,10 @@ function loadConfigChecked() {
883
1596
  return { ok: true, config: parsed.data };
884
1597
  }
885
1598
 
886
- // src/lib/envelope.ts
887
- var ENVELOPE_SCHEMA_VERSION = 1;
888
- function toEnvelope(data) {
889
- return { schema: ENVELOPE_SCHEMA_VERSION, data };
890
- }
891
- function printJson(data) {
892
- process.stdout.write(`${JSON.stringify(toEnvelope(data), null, 2)}
893
- `);
894
- }
895
-
896
1599
  // src/commands/check.ts
897
1600
  function git(args) {
898
1601
  try {
899
- return execFileSync2("git", args, { encoding: "utf8" }).trim();
1602
+ return execFileSync4("git", args, { encoding: "utf8" }).trim();
900
1603
  } catch {
901
1604
  return "";
902
1605
  }
@@ -983,16 +1686,217 @@ async function checkCommand(args) {
983
1686
  process.exit(failed ? 1 : 0);
984
1687
  }
985
1688
 
1689
+ // src/commands/demo.ts
1690
+ import { execFileSync as execFileSync5, spawnSync } from "node:child_process";
1691
+ import { existsSync as existsSync4, readFileSync as readFileSync2, rmSync, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
1692
+ import { isAbsolute, join as join5 } from "node:path";
1693
+ var DEMO_BRANCH_PREFIX = "vinaya/demo-break-";
1694
+ var FIXTURE_PATH = ".vinaya-demo-brief.md";
1695
+ var STATE_FILENAME = "vinaya-demo-state.json";
1696
+ function gitCapture(repoRoot, args) {
1697
+ return execFileSync5("git", args, { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
1698
+ }
1699
+ function branchExists(repoRoot, name) {
1700
+ try {
1701
+ execFileSync5("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${name}`], {
1702
+ cwd: repoRoot,
1703
+ stdio: ["ignore", "pipe", "pipe"]
1704
+ });
1705
+ return true;
1706
+ } catch {
1707
+ return false;
1708
+ }
1709
+ }
1710
+ function createDemoBranch(repoRoot) {
1711
+ for (let attempt = 0;attempt < 5; attempt++) {
1712
+ const candidate = `${DEMO_BRANCH_PREFIX}${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1713
+ if (!branchExists(repoRoot, candidate)) {
1714
+ gitCapture(repoRoot, ["checkout", "-b", candidate]);
1715
+ return candidate;
1716
+ }
1717
+ }
1718
+ throw new Error("Could not generate a collision-free demo branch name after 5 attempts.");
1719
+ }
1720
+ function gitDirAbs(repoRoot) {
1721
+ const raw = gitCapture(repoRoot, ["rev-parse", "--git-dir"]);
1722
+ return isAbsolute(raw) ? raw : join5(repoRoot, raw);
1723
+ }
1724
+ function recoverFromCrash(repoRoot, statePath) {
1725
+ const currentBranch = gitCapture(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
1726
+ let state = null;
1727
+ if (existsSync4(statePath)) {
1728
+ try {
1729
+ state = JSON.parse(readFileSync2(statePath, "utf-8"));
1730
+ } catch {
1731
+ state = null;
1732
+ }
1733
+ }
1734
+ const onStrayBranch = currentBranch.startsWith(DEMO_BRANCH_PREFIX);
1735
+ if (!state) {
1736
+ if (onStrayBranch) {
1737
+ throw new Error(`On a leftover demo branch \`${currentBranch}\` with no recoverable state file. ` + "Checkout the branch you want manually, then re-run `vinaya demo break`.");
1738
+ }
1739
+ return;
1740
+ }
1741
+ process.stdout.write(`→ Found a leftover demo branch from a previous run — cleaning it up first.
1742
+ `);
1743
+ if (onStrayBranch) {
1744
+ gitCapture(repoRoot, ["reset", "--hard"]);
1745
+ gitCapture(repoRoot, ["clean", "-fd"]);
1746
+ if (!branchExists(repoRoot, state.originalBranch)) {
1747
+ throw new Error(`Recorded original branch \`${state.originalBranch}\` no longer exists. ` + "Checkout the branch you want manually, then re-run `vinaya demo break`.");
1748
+ }
1749
+ gitCapture(repoRoot, ["checkout", state.originalBranch]);
1750
+ }
1751
+ if (branchExists(repoRoot, state.demoBranch)) {
1752
+ gitCapture(repoRoot, ["branch", "-D", state.demoBranch]);
1753
+ }
1754
+ rmSync(statePath, { force: true });
1755
+ }
1756
+ function attemptCommit(repoRoot, demoBranch, prBody, message) {
1757
+ const result = spawnSync("git", ["commit", "-m", message], {
1758
+ cwd: repoRoot,
1759
+ encoding: "utf8",
1760
+ stdio: ["ignore", "pipe", "pipe"],
1761
+ env: { ...process.env, PR_BODY: prBody, BRANCH: demoBranch }
1762
+ });
1763
+ return { status: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
1764
+ }
1765
+ function briefFixture(opts) {
1766
+ const tierLine = opts.includeTier ? `Tier: 1
1767
+ ` : "";
1768
+ return `For: vinaya demo
1769
+ Project: vinaya-demo-fixture
1770
+ ${tierLine}
1771
+ ## Summary
1772
+ Fixture brief staged only by \`vinaya demo break\` — discarded with the demo branch.
1773
+
1774
+ ## Technical surface map
1775
+ None — fixture content only; no real files change.
1776
+
1777
+ ## Documentation-update list
1778
+ None — fixture content only.
1779
+
1780
+ ## Test Plan
1781
+ Test Plan: unit-tests-only
1782
+
1783
+ ## Stop Conditions
1784
+ Autonomy: Do not stop to ask clarifying questions — this is a scripted demo; proceed and clean up automatically.
1785
+
1786
+ Illustrative Step 0 (never actually run by this demo):
1787
+ \`\`\`
1788
+ git worktree add .worktrees/demo-fixture -b demo/fixture origin/main
1789
+ \`\`\`
1790
+
1791
+ Closes #0 (fixture value — no real Issue; discarded with the demo branch)
1792
+ `;
1793
+ }
1794
+ function cleanup(repoRoot, originalBranch, demoBranch, statePath) {
1795
+ gitCapture(repoRoot, ["reset", "--hard"]);
1796
+ gitCapture(repoRoot, ["clean", "-fd"]);
1797
+ gitCapture(repoRoot, ["checkout", originalBranch]);
1798
+ gitCapture(repoRoot, ["branch", "-D", demoBranch]);
1799
+ if (existsSync4(statePath))
1800
+ rmSync(statePath, { force: true });
1801
+ }
1802
+ async function runDemoBreak(repoRoot, args) {
1803
+ const keep = args.includes("--keep");
1804
+ const hookDir = resolveHookDir(repoRoot);
1805
+ const hookPath = join5(repoRoot, hookDir, "pre-commit");
1806
+ if (!existsSync4(hookPath)) {
1807
+ console.error("Vinaya hooks are not installed in this repo. Run `vinaya init` first.");
1808
+ return 1;
1809
+ }
1810
+ const statePath = join5(gitDirAbs(repoRoot), STATE_FILENAME);
1811
+ recoverFromCrash(repoRoot, statePath);
1812
+ const dirty = gitCapture(repoRoot, ["status", "--porcelain"]);
1813
+ if (dirty.length > 0) {
1814
+ console.error("Working tree has uncommitted changes. Commit or stash them before running `vinaya demo break` — " + "the demo never touches a dirty tree.");
1815
+ return 1;
1816
+ }
1817
+ const originalBranch = gitCapture(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
1818
+ if (originalBranch === "HEAD") {
1819
+ console.error("Cannot run `vinaya demo break` from a detached HEAD — checkout a branch first.");
1820
+ return 1;
1821
+ }
1822
+ const demoBranch = createDemoBranch(repoRoot);
1823
+ writeFileSync2(statePath, JSON.stringify({ originalBranch, demoBranch }), "utf-8");
1824
+ process.stdout.write(`
1825
+ → Created isolated branch \`${demoBranch}\` off \`${originalBranch}\`.
1826
+
1827
+ `);
1828
+ const fixturePath = join5(repoRoot, FIXTURE_PATH);
1829
+ writeFileSync2(fixturePath, briefFixture({ includeTier: false }), "utf-8");
1830
+ gitCapture(repoRoot, ["add", FIXTURE_PATH]);
1831
+ process.stdout.write(`Staged \`${FIXTURE_PATH}\` — a brief missing its required \`Tier:\` field.
1832
+ Attempting to commit…
1833
+
1834
+ `);
1835
+ const badResult = attemptCommit(repoRoot, demoBranch, briefFixture({ includeTier: false }), "Chore(demo): stage a malformed brief");
1836
+ if (badResult.status === 0) {
1837
+ console.error("Expected the installed hook to refuse this commit, but it passed — the demo cannot show a real refusal here.");
1838
+ cleanup(repoRoot, originalBranch, demoBranch, statePath);
1839
+ return 1;
1840
+ }
1841
+ process.stdout.write(`✗ Commit refused — the real installed hook output:
1842
+
1843
+ `);
1844
+ process.stdout.write(badResult.stdout);
1845
+ process.stdout.write(badResult.stderr);
1846
+ process.stdout.write(`
1847
+ → Fixing the brief: adding the missing \`Tier:\` field…
1848
+
1849
+ `);
1850
+ writeFileSync2(fixturePath, briefFixture({ includeTier: true }), "utf-8");
1851
+ gitCapture(repoRoot, ["add", FIXTURE_PATH]);
1852
+ const goodResult = attemptCommit(repoRoot, demoBranch, briefFixture({ includeTier: true }), "Chore(demo): fix the brief and commit");
1853
+ if (goodResult.status !== 0) {
1854
+ console.error(`The fixed brief still failed the hook — this demo cannot proceed:
1855
+ `);
1856
+ console.error(goodResult.stdout);
1857
+ console.error(goodResult.stderr);
1858
+ cleanup(repoRoot, originalBranch, demoBranch, statePath);
1859
+ return 1;
1860
+ }
1861
+ process.stdout.write(`✓ Commit passed — the fix worked.
1862
+ `);
1863
+ if (keep) {
1864
+ if (existsSync4(statePath))
1865
+ unlinkSync(statePath);
1866
+ process.stdout.write(`
1867
+ --keep: leaving \`${demoBranch}\` checked out for you to inspect. Clean up with:
1868
+
1869
+ `);
1870
+ process.stdout.write(` git checkout ${originalBranch} && git branch -D ${demoBranch}
1871
+ `);
1872
+ return 0;
1873
+ }
1874
+ cleanup(repoRoot, originalBranch, demoBranch, statePath);
1875
+ process.stdout.write(`
1876
+ ✓ Cleaned up — back on \`${originalBranch}\`, \`${demoBranch}\` deleted.
1877
+ `);
1878
+ return 0;
1879
+ }
1880
+ async function demoBreakCommand(args) {
1881
+ const repo = await detectGitRepo();
1882
+ if (!repo) {
1883
+ console.error("Error: not a git repository. Run `vinaya demo break` from inside your repo.");
1884
+ process.exit(1);
1885
+ }
1886
+ const code = await runDemoBreak(repo.repoRoot, args);
1887
+ process.exit(code);
1888
+ }
1889
+
986
1890
  // src/commands/doctor.ts
987
- import { existsSync as existsSync4, readFileSync as readFileSync3, statSync } from "node:fs";
988
- import { dirname as dirname4, join as join5 } from "node:path";
989
- import { fileURLToPath as fileURLToPath2 } from "node:url";
1891
+ import { existsSync as existsSync6, readFileSync as readFileSync4, statSync } from "node:fs";
1892
+ import { join as join7 } from "node:path";
990
1893
 
991
1894
  // src/lib/artifacts.ts
992
1895
  var CONFIG_PATH = "vinaya.config.json";
993
1896
  var DOCTRINE_POINTER_PATH = "VINAYA.md";
994
1897
  var CHECKS_WORKFLOW_PATH = ".github/workflows/vinaya-checks.yml";
995
1898
  var REVIEW_WORKFLOW_PATH = ".github/workflows/vinaya-review.yml";
1899
+ var ARCHIVIST_WORKFLOW_PATH = ".github/workflows/vinaya-archivist.yml";
996
1900
  var MANAGED_NOTE = "Managed by Vinaya — created by `vinaya init`. `vinaya upgrade` regenerates it; `vinaya eject` removes it.";
997
1901
  function starterConfig() {
998
1902
  return {
@@ -1039,7 +1943,7 @@ jobs:
1039
1943
  with:
1040
1944
  node-version: 20
1041
1945
  - name: Run checks
1042
- run: npx --yes vinaya check --all --diff-only
1946
+ run: npx --yes @attalabs/vinaya check --all --diff-only
1043
1947
  `;
1044
1948
  }
1045
1949
  function reviewWorkflow() {
@@ -1058,44 +1962,123 @@ on:
1058
1962
  issue_comment:
1059
1963
  types: [created]
1060
1964
 
1061
- jobs:
1062
- vinaya-review:
1063
- name: vinaya review gate
1064
- if: >
1065
- github.event_name == 'pull_request' ||
1066
- (github.event.issue.pull_request != null && contains(github.event.comment.body, 'VERDICT'))
1965
+ jobs:
1966
+ vinaya-review:
1967
+ name: vinaya review gate
1968
+ if: >
1969
+ github.event_name == 'pull_request' ||
1970
+ (github.event.issue.pull_request != null && contains(github.event.comment.body, 'VERDICT'))
1971
+ runs-on: ubuntu-latest
1972
+ permissions:
1973
+ contents: read
1974
+ pull-requests: read
1975
+ issues: read
1976
+ steps:
1977
+ # issue_comment payloads carry no PR head SHA/branch — resolve them
1978
+ # before checkout, and check out that exact commit (the event's default
1979
+ # ref is the repo's default branch, not the PR head).
1980
+ - name: Resolve PR head
1981
+ id: pr
1982
+ env:
1983
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
1984
+ run: |
1985
+ if [ "\${{ github.event_name }}" = "pull_request" ]; then
1986
+ NUMBER="\${{ github.event.pull_request.number }}"
1987
+ else
1988
+ NUMBER="\${{ github.event.issue.number }}"
1989
+ fi
1990
+ SHA=$(gh pr view "$NUMBER" --repo "\${{ github.repository }}" --json headRefOid -q .headRefOid)
1991
+ echo "sha=$SHA" >> "$GITHUB_OUTPUT"
1992
+ - uses: actions/checkout@v4
1993
+ with:
1994
+ ref: \${{ steps.pr.outputs.sha }}
1995
+ fetch-depth: 0
1996
+ - uses: actions/setup-node@v4
1997
+ with:
1998
+ node-version: 20
1999
+ - name: Review gate
2000
+ env:
2001
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
2002
+ run: npx --yes @attalabs/vinaya check --all
2003
+ `;
2004
+ }
2005
+ function archivistWorkflow() {
2006
+ return `# ${MANAGED_NOTE}
2007
+ #
2008
+ # The ring-2 post-merge/scheduled mechanisms: per-task Archivist provenance
2009
+ # + close-out (post-merge), dead-branch-push drift (daily-drift, a
2010
+ # notification channel — never fails red), and direct-main-push detection
2011
+ # (direct-main-push-detection, a real pass/fail).
2012
+ name: Vinaya Archivist
2013
+
2014
+ on:
2015
+ push:
2016
+ branches: [main]
2017
+ schedule:
2018
+ - cron: "0 2 * * *" # daily at 02:00 UTC
2019
+ workflow_dispatch:
2020
+
2021
+ jobs:
2022
+ post-merge:
2023
+ name: Post-Merge Archivist
2024
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
2025
+ runs-on: ubuntu-latest
2026
+ permissions:
2027
+ contents: read
2028
+ issues: write
2029
+ pull-requests: write
2030
+ steps:
2031
+ - uses: actions/checkout@v4
2032
+ with:
2033
+ fetch-depth: 0
2034
+ - uses: actions/setup-node@v4
2035
+ with:
2036
+ node-version: 20
2037
+ - name: Run vinaya archive
2038
+ env:
2039
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
2040
+ run: npx --yes @attalabs/vinaya archive --merge-sha=\${{ github.sha }}
2041
+
2042
+ daily-drift:
2043
+ name: Daily Drift Check (dead-branch pushes)
2044
+ if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
1067
2045
  runs-on: ubuntu-latest
1068
2046
  permissions:
1069
2047
  contents: read
1070
- pull-requests: read
1071
- issues: read
2048
+ issues: write
2049
+ pull-requests: write
1072
2050
  steps:
1073
- # issue_comment payloads carry no PR head SHA/branch — resolve them
1074
- # before checkout, and check out that exact commit (the event's default
1075
- # ref is the repo's default branch, not the PR head).
1076
- - name: Resolve PR head
1077
- id: pr
2051
+ - uses: actions/checkout@v4
2052
+ with:
2053
+ fetch-depth: 0
2054
+ - uses: actions/setup-node@v4
2055
+ with:
2056
+ node-version: 20
2057
+ - name: Run vinaya audit --only=dead-branches
2058
+ continue-on-error: true # never-red — this job is a notification channel, not a gate
1078
2059
  env:
1079
2060
  GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
1080
- run: |
1081
- if [ "\${{ github.event_name }}" = "pull_request" ]; then
1082
- NUMBER="\${{ github.event.pull_request.number }}"
1083
- else
1084
- NUMBER="\${{ github.event.issue.number }}"
1085
- fi
1086
- SHA=$(gh pr view "$NUMBER" --repo "\${{ github.repository }}" --json headRefOid -q .headRefOid)
1087
- echo "sha=$SHA" >> "$GITHUB_OUTPUT"
2061
+ run: npx --yes @attalabs/vinaya audit --only=dead-branches
2062
+
2063
+ direct-main-push-detection:
2064
+ name: Direct-Main-Push Detection
2065
+ if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch'
2066
+ runs-on: ubuntu-latest
2067
+ permissions:
2068
+ contents: read
2069
+ issues: write
2070
+ pull-requests: read
2071
+ steps:
1088
2072
  - uses: actions/checkout@v4
1089
2073
  with:
1090
- ref: \${{ steps.pr.outputs.sha }}
1091
2074
  fetch-depth: 0
1092
2075
  - uses: actions/setup-node@v4
1093
2076
  with:
1094
2077
  node-version: 20
1095
- - name: Review gate
2078
+ - name: Run vinaya audit --only=direct-push
1096
2079
  env:
1097
2080
  GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
1098
- run: npx --yes vinaya check --all
2081
+ run: npx --yes @attalabs/vinaya audit --only=direct-push --sha=\${{ github.sha }}
1099
2082
  `;
1100
2083
  }
1101
2084
  var HOOK_PREAMBLE = `#!/usr/bin/env sh
@@ -1103,20 +2086,22 @@ var HOOK_PREAMBLE = `#!/usr/bin/env sh
1103
2086
  function preCommitBody() {
1104
2087
  return `# Vinaya commit-time gate. Runs the deterministic checks over your staged
1105
2088
  # diff before the commit lands.
1106
- npx --no-install vinaya check --all --diff-only || exit 1`;
2089
+ npx --no-install @attalabs/vinaya check --all --diff-only || exit 1`;
1107
2090
  }
1108
2091
  function prePushBody() {
1109
2092
  return `# Vinaya pre-push gate. Runs branch/dispatch checks before the push leaves.
1110
- npx --no-install vinaya check --all || exit 1`;
2093
+ npx --no-install @attalabs/vinaya check --all || exit 1`;
1111
2094
  }
1112
2095
  function doctrinePointer() {
1113
2096
  return `<!-- ${MANAGED_NOTE} -->
1114
2097
  # Vinaya doctrine — read this first
1115
2098
 
1116
2099
  This repo is governed by Vinaya. The full, canonical doctrine (roles,
1117
- contracts, the state machine, the ring gates) ships inside the installed
1118
- \`vinaya\` npm package as versioned reference content and is updated cleanly by
1119
- \`vinaya upgrade\` there is no in-repo copy to drift.
2100
+ contracts, the state machine, the ring gates) currently lives in the
2101
+ \`attalabs\` monorepo's \`aeg-root/\` (public GitHub source) bundling it into
2102
+ the installed \`vinaya\` npm package, so it ships and updates cleanly via
2103
+ \`vinaya upgrade\` with no in-repo copy to drift, is planned but not yet
2104
+ shipped.
1120
2105
 
1121
2106
  An agent working in this repo follows the governed flow by reading two things:
1122
2107
 
@@ -1128,8 +2113,59 @@ An agent working in this repo follows the governed flow by reading two things:
1128
2113
  Live task status is derived from the forge (Issues, labels, comments) via
1129
2114
  \`vinaya check\` — it is never written into a file here.
1130
2115
 
1131
- To view the doctrine text: \`vinaya doctor\` reports what is installed; the
1132
- package's own reference content is the source of truth.
2116
+ To view the doctrine text today: read \`aeg-root/\` in the \`attalabs\`
2117
+ monorepo; \`vinaya doctor\` reports what is installed in this repo. Once
2118
+ in-package bundling ships, the package's own reference content becomes the
2119
+ source of truth.
2120
+ `;
2121
+ }
2122
+ function starterDocOwners() {
2123
+ return `# ${MANAGED_NOTE}
2124
+ #
2125
+ # ${DOC_OWNERS_PATH} — code → doc bindings for the coherence seam.
2126
+ #
2127
+ # Format: one binding per line, CODEOWNERS-shaped.
2128
+ # <code-glob> <doc-pointer>
2129
+ # Whitespace-separated. Lines starting with \`#\` and blank lines are ignored.
2130
+ #
2131
+ # Glob syntax (kept deliberately simple — no character classes):
2132
+ # \`**\` matches any sequence (including \`/\`)
2133
+ # \`*\` matches any sequence not containing \`/\`
2134
+ # every other character is literal — so dynamic-route segments like
2135
+ # \`[username]\` match literally, no escaping required.
2136
+ #
2137
+ # Pointer forms:
2138
+ # in-repo path e.g. \`docs/my-feature.md\`
2139
+ # in-repo path with anchor e.g. \`docs/my-feature.md#section-2\`
2140
+ # URL e.g. \`https://example.com/docs/x\`
2141
+ #
2142
+ # Coverage rule (enforced by C5, the doc-owners check bundled into \`vinaya check\`):
2143
+ # When a code file in the PR matches a glob:
2144
+ # - in-repo pointer → that path must appear in the PR diff, else FAIL
2145
+ # - URL pointer → require a \`Doc-ack: <pointer> — <note>\` PR-body line
2146
+ # - dangling pointer → in-repo pointer that does not exist on disk: FAIL
2147
+ # Escape:
2148
+ # A PR-wide \`vinaya/waiver:docs\` label whose labeling timeline event's
2149
+ # actor is a configured principal suppresses every fired binding for that
2150
+ # PR — a forge-authenticated human act, never a parseable string. There is
2151
+ # no PR-body waiver field; label presence alone is never sufficient.
2152
+ #
2153
+ # Dormancy:
2154
+ # This file absent, OR no glob matches any changed code file → silent no-op.
2155
+ # The gate has no opinion until you teach it one.
2156
+ #
2157
+ # ── No-doc allow-list ───────────────────────────────────────────────────────
2158
+ # Surfaces that legitimately need no bound doc are listed below. Format:
2159
+ # # no-doc: <glob> — <reason>
2160
+ # These lines exempt the matching directory from the completeness scoreboard
2161
+ # (\`vinaya check\`'s advisory output). They do NOT affect C5 enforcement — if a
2162
+ # binding exists for a surface, C5 still fires on changed files regardless of
2163
+ # any no-doc line. Use no-doc for scaffold-only, config-only, or stub-only
2164
+ # surfaces where adding a doc-owners binding would be meaningless busywork.
2165
+ # To un-exempt a surface, remove its no-doc line and add a real binding.
2166
+ #
2167
+ # This starter ships empty — no bindings, no no-doc entries. Add your own
2168
+ # below this line as your repo grows real code → doc coverage needs.
1133
2169
  `;
1134
2170
  }
1135
2171
  function labelOps() {
@@ -1162,6 +2198,7 @@ function buildInitOps(ctx) {
1162
2198
  const hookMode = 493;
1163
2199
  ops.push({ kind: "create-file", path: CHECKS_WORKFLOW_PATH, content: checksWorkflow(), group: "CI workflows" });
1164
2200
  ops.push({ kind: "create-file", path: REVIEW_WORKFLOW_PATH, content: reviewWorkflow(), group: "CI workflows" });
2201
+ ops.push({ kind: "create-file", path: ARCHIVIST_WORKFLOW_PATH, content: archivistWorkflow(), group: "CI workflows" });
1165
2202
  ops.push({
1166
2203
  kind: "managed-block",
1167
2204
  path: `${ctx.hookDir}/pre-commit`,
@@ -1195,6 +2232,12 @@ function buildInitOps(ctx) {
1195
2232
  content: doctrinePointer(),
1196
2233
  group: "Doctrine pointer"
1197
2234
  });
2235
+ ops.push({
2236
+ kind: "create-file",
2237
+ path: DOC_OWNERS_PATH,
2238
+ content: starterDocOwners(),
2239
+ group: "Doc-ownership manifest"
2240
+ });
1198
2241
  ops.push(...labelOps());
1199
2242
  ops.push({ kind: "print", message: BRANCH_PROTECTION_NOTE, group: "Branch protection (printed, never applied)" });
1200
2243
  return ops;
@@ -1212,107 +2255,9 @@ function buildInitProductOps(name) {
1212
2255
  ];
1213
2256
  }
1214
2257
 
1215
- // src/lib/detect.ts
1216
- import { execFile as execFile4 } from "node:child_process";
1217
- import { existsSync as existsSync2 } from "node:fs";
1218
- import { join as join3 } from "node:path";
1219
- import { promisify as promisify4 } from "node:util";
1220
- var execFileAsync4 = promisify4(execFile4);
1221
- async function detectGitRepo() {
1222
- try {
1223
- const { stdout: root } = await execFileAsync4("git", ["rev-parse", "--show-toplevel"]);
1224
- const repoRoot = root.trim();
1225
- let owner = "";
1226
- let repo = "";
1227
- try {
1228
- const { stdout: url } = await execFileAsync4("git", ["remote", "get-url", "origin"]);
1229
- const m = url.trim().match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
1230
- if (m) {
1231
- owner = m[1] ?? "";
1232
- repo = m[2] ?? "";
1233
- }
1234
- } catch {}
1235
- return { repoRoot, owner, repo };
1236
- } catch {
1237
- return null;
1238
- }
1239
- }
1240
- async function checkGhAuth() {
1241
- try {
1242
- await execFileAsync4("gh", ["auth", "status"]);
1243
- return true;
1244
- } catch {
1245
- return false;
1246
- }
1247
- }
1248
- async function ghAuthStatus() {
1249
- try {
1250
- const { stderr, stdout } = await execFileAsync4("gh", ["auth", "status"]);
1251
- return { authenticated: true, detail: (stderr || stdout).trim() };
1252
- } catch (err) {
1253
- const stderr = err.stderr;
1254
- return { authenticated: false, detail: stderr?.trim() || "gh is not authenticated" };
1255
- }
1256
- }
1257
- async function branchProtectionConfigured(owner, repo) {
1258
- if (!owner || !repo)
1259
- return null;
1260
- try {
1261
- await execFileAsync4("gh", ["api", `repos/${owner}/${repo}/branches/main/protection`]);
1262
- return true;
1263
- } catch (err) {
1264
- const stderr = err.stderr ?? "";
1265
- if (/\b404\b/.test(stderr))
1266
- return false;
1267
- return null;
1268
- }
1269
- }
1270
- function resolveHookDir(repoRoot) {
1271
- return existsSync2(join3(repoRoot, ".husky")) ? ".husky" : ".git/hooks";
1272
- }
1273
- function hookDirFromManifest(manifest, fallback) {
1274
- const block = manifest.blocks.find((b) => b.path.startsWith(".husky/") || b.path.startsWith(".git/hooks/"));
1275
- if (block?.path.startsWith(".husky/"))
1276
- return ".husky";
1277
- if (block?.path.startsWith(".git/hooks/"))
1278
- return ".git/hooks";
1279
- return fallback;
1280
- }
1281
- async function customHooksPath(repoRoot) {
1282
- try {
1283
- const { stdout } = await execFileAsync4("git", ["-C", repoRoot, "config", "--get", "core.hooksPath"]);
1284
- const v = stdout.trim();
1285
- if (!v)
1286
- return null;
1287
- if (v === ".husky" || v === ".husky/_")
1288
- return null;
1289
- return v;
1290
- } catch {
1291
- return null;
1292
- }
1293
- }
1294
- function ghLabelGateway(repoRoot) {
1295
- return {
1296
- async exists(name) {
1297
- try {
1298
- const { stdout } = await execFileAsync4("gh", ["label", "list", "--json", "name"], { cwd: repoRoot });
1299
- const names = JSON.parse(stdout);
1300
- return names.some((l) => l.name === name);
1301
- } catch {
1302
- return false;
1303
- }
1304
- },
1305
- async create(name, color, description) {
1306
- await execFileAsync4("gh", ["label", "create", name, "--color", color, "--description", description], {
1307
- cwd: repoRoot
1308
- });
1309
- }
1310
- };
1311
- }
1312
-
1313
2258
  // src/lib/ops.ts
1314
- import { chmodSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
1315
- import { dirname as dirname3, join as join4, resolve, sep } from "node:path";
2259
+ import { chmodSync, existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
2260
+ import { dirname as dirname3, join as join6, resolve, sep } from "node:path";
1316
2261
  var MARKER_NS = "vinaya:managed";
1317
2262
  function markerLines(marker, comment) {
1318
2263
  const [open, close] = comment === "hash" ? ["#", ""] : ["<!--", " -->"];
@@ -1328,7 +2273,7 @@ ${op.body}
1328
2273
  ${end}`;
1329
2274
  }
1330
2275
  function abs(repoRoot, relPath) {
1331
- return join4(repoRoot, relPath);
2276
+ return join6(repoRoot, relPath);
1332
2277
  }
1333
2278
  function containedAbs(repoRoot, relPath) {
1334
2279
  const root = resolve(repoRoot);
@@ -1339,9 +2284,9 @@ function containedAbs(repoRoot, relPath) {
1339
2284
  }
1340
2285
  function fileContains(repoRoot, relPath, needle) {
1341
2286
  const p = abs(repoRoot, relPath);
1342
- if (!existsSync3(p))
2287
+ if (!existsSync5(p))
1343
2288
  return false;
1344
- return readFileSync2(p, "utf-8").includes(needle);
2289
+ return readFileSync3(p, "utf-8").includes(needle);
1345
2290
  }
1346
2291
  function planInstall(ops, repoRoot, ownedFiles = new Set) {
1347
2292
  const entries = [];
@@ -1349,7 +2294,7 @@ function planInstall(ops, repoRoot, ownedFiles = new Set) {
1349
2294
  for (const op of ops) {
1350
2295
  switch (op.kind) {
1351
2296
  case "create-file": {
1352
- const exists = existsSync3(abs(repoRoot, op.path));
2297
+ const exists = existsSync5(abs(repoRoot, op.path));
1353
2298
  let action;
1354
2299
  if (!exists)
1355
2300
  action = "create";
@@ -1367,7 +2312,7 @@ function planInstall(ops, repoRoot, ownedFiles = new Set) {
1367
2312
  let action;
1368
2313
  if (fileContains(repoRoot, op.path, begin))
1369
2314
  action = "skip-present";
1370
- else if (existsSync3(abs(repoRoot, op.path)))
2315
+ else if (existsSync5(abs(repoRoot, op.path)))
1371
2316
  action = "append";
1372
2317
  else
1373
2318
  action = "create-host";
@@ -1445,19 +2390,19 @@ function renderInstallDiff(plan) {
1445
2390
  }
1446
2391
  function writeFileWithDirs(target, content, mode) {
1447
2392
  mkdirSync2(dirname3(target), { recursive: true });
1448
- writeFileSync2(target, content, "utf-8");
2393
+ writeFileSync3(target, content, "utf-8");
1449
2394
  if (mode !== undefined)
1450
2395
  chmodSync(target, mode);
1451
2396
  }
1452
2397
  function appendBlock(repoRoot, op) {
1453
2398
  const target = abs(repoRoot, op.path);
1454
- const existing = readFileSync2(target, "utf-8");
2399
+ const existing = readFileSync3(target, "utf-8");
1455
2400
  const sep2 = existing.endsWith(`
1456
2401
  `) ? `
1457
2402
  ` : `
1458
2403
 
1459
2404
  `;
1460
- writeFileSync2(target, `${existing}${sep2}${renderBlock(op)}
2405
+ writeFileSync3(target, `${existing}${sep2}${renderBlock(op)}
1461
2406
  `, "utf-8");
1462
2407
  if (op.mode !== undefined)
1463
2408
  chmodSync(target, op.mode);
@@ -1504,9 +2449,14 @@ async function applyInstall(plan, repoRoot, labels, onFilesRecorded) {
1504
2449
  for (const e of plan.entries) {
1505
2450
  if (e.kind !== "create-label")
1506
2451
  continue;
1507
- if (!await labels.exists(e.op.name)) {
1508
- await labels.create(e.op.name, e.op.color, e.op.description);
1509
- createdLabels.push(e.op.name);
2452
+ try {
2453
+ if (!await labels.exists(e.op.name)) {
2454
+ await labels.create(e.op.name, e.op.color, e.op.description);
2455
+ createdLabels.push(e.op.name);
2456
+ }
2457
+ } catch (err) {
2458
+ const reason = err.stderr?.trim() || err.message;
2459
+ console.warn(`Warning: could not create label '${e.op.name}': ${reason}`);
1510
2460
  }
1511
2461
  }
1512
2462
  return { ...base, labels: dedupe(createdLabels) };
@@ -1557,7 +2507,7 @@ function planEject(manifest, repoRoot) {
1557
2507
  escapes.push(b.path);
1558
2508
  continue;
1559
2509
  }
1560
- if (!existsSync3(p)) {
2510
+ if (!existsSync5(p)) {
1561
2511
  actions.push({
1562
2512
  kind: "strip-block",
1563
2513
  path: b.path,
@@ -1568,7 +2518,7 @@ function planEject(manifest, repoRoot) {
1568
2518
  });
1569
2519
  continue;
1570
2520
  }
1571
- const content = readFileSync2(p, "utf-8");
2521
+ const content = readFileSync3(p, "utf-8");
1572
2522
  const stripped = stripBlockFromContent(content, b.marker, b.comment);
1573
2523
  const removesHost = stripped !== null && blockStripLeavesEmpty(stripped);
1574
2524
  actions.push({
@@ -1586,7 +2536,7 @@ function planEject(manifest, repoRoot) {
1586
2536
  escapes.push(f);
1587
2537
  continue;
1588
2538
  }
1589
- actions.push({ kind: "delete-file", path: f, present: existsSync3(p) });
2539
+ actions.push({ kind: "delete-file", path: f, present: existsSync5(p) });
1590
2540
  }
1591
2541
  for (const name of manifest.labels) {
1592
2542
  actions.push({ kind: "report-label", name });
@@ -1620,20 +2570,20 @@ function applyEject(plan, repoRoot) {
1620
2570
  const p = containedAbs(repoRoot, a.path);
1621
2571
  if (p === null)
1622
2572
  continue;
1623
- const content = readFileSync2(p, "utf-8");
2573
+ const content = readFileSync3(p, "utf-8");
1624
2574
  const stripped = stripBlockFromContent(content, a.marker, a.comment);
1625
2575
  if (stripped === null)
1626
2576
  continue;
1627
2577
  if (blockStripLeavesEmpty(stripped))
1628
- rmSync(p, { force: true });
2578
+ rmSync2(p, { force: true });
1629
2579
  else
1630
- writeFileSync2(p, stripped.endsWith(`
2580
+ writeFileSync3(p, stripped.endsWith(`
1631
2581
  `) ? stripped : `${stripped}
1632
2582
  `, "utf-8");
1633
2583
  } else if (a.kind === "delete-file") {
1634
2584
  const p = containedAbs(repoRoot, a.path);
1635
2585
  if (a.present && p !== null)
1636
- rmSync(p, { force: true });
2586
+ rmSync2(p, { force: true });
1637
2587
  }
1638
2588
  }
1639
2589
  return {
@@ -1642,21 +2592,11 @@ function applyEject(plan, repoRoot) {
1642
2592
  }
1643
2593
 
1644
2594
  // src/commands/doctor.ts
1645
- function packageRoot() {
1646
- let dir = dirname4(fileURLToPath2(import.meta.url));
1647
- while (!existsSync4(join5(dir, "package.json"))) {
1648
- const parent = dirname4(dir);
1649
- if (parent === dir)
1650
- break;
1651
- dir = parent;
1652
- }
1653
- return dir;
1654
- }
1655
2595
  function readVersion() {
1656
- const pkg = JSON.parse(readFileSync3(join5(packageRoot(), "package.json"), "utf-8"));
2596
+ const pkg = JSON.parse(readFileSync4(join7(packageRoot(import.meta.url), "package.json"), "utf-8"));
1657
2597
  return pkg.version;
1658
2598
  }
1659
- function realDeps() {
2599
+ function realDeps3() {
1660
2600
  return {
1661
2601
  detectRepo: detectGitRepo,
1662
2602
  ghAuthStatus,
@@ -1672,12 +2612,12 @@ var info = (check, message) => ({ check, severity: "info", message });
1672
2612
  var warn = (check, message) => ({ check, severity: "warn", message });
1673
2613
  var error = (check, message) => ({ check, severity: "error", message });
1674
2614
  function readConfig(repoRoot) {
1675
- const p = join5(repoRoot, CONFIG_PATH);
1676
- if (!existsSync4(p))
2615
+ const p = join7(repoRoot, CONFIG_PATH);
2616
+ if (!existsSync6(p))
1677
2617
  return { kind: "missing" };
1678
2618
  let raw;
1679
2619
  try {
1680
- raw = JSON.parse(readFileSync3(p, "utf-8"));
2620
+ raw = JSON.parse(readFileSync4(p, "utf-8"));
1681
2621
  } catch (err) {
1682
2622
  return { kind: "invalid", error: `invalid JSON: ${err.message}` };
1683
2623
  }
@@ -1706,14 +2646,14 @@ function diagnoseInstall(repoRoot, ctx, manifest) {
1706
2646
  for (const op of buildInitOps(ctx)) {
1707
2647
  if (op.kind === "create-file") {
1708
2648
  const check = labelForPath(op.path);
1709
- const abs2 = join5(repoRoot, op.path);
1710
- const exists = existsSync4(abs2);
2649
+ const abs2 = join7(repoRoot, op.path);
2650
+ const exists = existsSync6(abs2);
1711
2651
  const owned = ownedFiles.has(op.path);
1712
2652
  if (!exists) {
1713
2653
  findings.push(owned ? error(check, `${op.path} is recorded as vinaya-managed but missing on disk — run \`vinaya upgrade\`.`) : error(check, `${op.path} is not installed — run \`vinaya init\`.`));
1714
2654
  continue;
1715
2655
  }
1716
- const content = readFileSync3(abs2, "utf-8");
2656
+ const content = readFileSync4(abs2, "utf-8");
1717
2657
  if (!owned) {
1718
2658
  findings.push(content === op.content ? warn(check, `${op.path} has vinaya's own content but isn't recorded in the manifest.`) : info(check, `${op.path} exists but is foreign content — not vinaya-managed, left untouched.`));
1719
2659
  continue;
@@ -1730,13 +2670,13 @@ function diagnoseInstall(repoRoot, ctx, manifest) {
1730
2670
  }
1731
2671
  } else if (op.kind === "managed-block") {
1732
2672
  const check = "hooks";
1733
- const abs2 = join5(repoRoot, op.path);
2673
+ const abs2 = join7(repoRoot, op.path);
1734
2674
  const owned = ownedBlocks.has(blockKey(op.path, op.marker));
1735
- if (!existsSync4(abs2)) {
2675
+ if (!existsSync6(abs2)) {
1736
2676
  findings.push(owned ? error(check, `${op.path} is missing — likely a fresh clone (raw git hooks aren't tracked by git). Run \`vinaya upgrade\` to restore it.`) : info(check, `${op.path} is not installed.`));
1737
2677
  continue;
1738
2678
  }
1739
- const content = readFileSync3(abs2, "utf-8");
2679
+ const content = readFileSync4(abs2, "utf-8");
1740
2680
  const { begin, end } = markerLines(op.marker, op.comment);
1741
2681
  const hasMarkers = content.includes(begin) && content.includes(end);
1742
2682
  if (!hasMarkers) {
@@ -1761,8 +2701,8 @@ function diagnoseInstall(repoRoot, ctx, manifest) {
1761
2701
  function diagnoseCustomChecks(repoRoot, config) {
1762
2702
  const findings = [];
1763
2703
  for (const [name, entry2] of Object.entries(config.checks ?? {})) {
1764
- const scriptAbs = join5(repoRoot, entry2.run);
1765
- findings.push(existsSync4(scriptAbs) ? ok("checks", `custom check '${name}' → ${entry2.run}`) : error("checks", `custom check '${name}' points at a missing script: ${entry2.run}`));
2704
+ const scriptAbs = join7(repoRoot, entry2.run);
2705
+ findings.push(existsSync6(scriptAbs) ? ok("checks", `custom check '${name}' → ${entry2.run}`) : error("checks", `custom check '${name}' points at a missing script: ${entry2.run}`));
1766
2706
  }
1767
2707
  return findings;
1768
2708
  }
@@ -1846,12 +2786,12 @@ async function runDoctor(args, deps) {
1846
2786
  return healthy ? 0 : 1;
1847
2787
  }
1848
2788
  async function doctorCommand(args) {
1849
- process.exit(await runDoctor(args, realDeps()));
2789
+ process.exit(await runDoctor(args, realDeps3()));
1850
2790
  }
1851
2791
 
1852
2792
  // src/commands/eject.ts
1853
- import { existsSync as existsSync5, readFileSync as readFileSync4 } from "node:fs";
1854
- import { join as join6 } from "node:path";
2793
+ import { existsSync as existsSync7, readFileSync as readFileSync5 } from "node:fs";
2794
+ import { join as join8 } from "node:path";
1855
2795
 
1856
2796
  // src/lib/prompt.ts
1857
2797
  var stdinBuffer = "";
@@ -1918,7 +2858,7 @@ function closeStdin() {
1918
2858
  }
1919
2859
 
1920
2860
  // src/commands/eject.ts
1921
- function realDeps2() {
2861
+ function realDeps4() {
1922
2862
  return {
1923
2863
  detectRepo: detectGitRepo,
1924
2864
  confirm: async (q) => {
@@ -1932,12 +2872,12 @@ function parse(args) {
1932
2872
  return { dryRun: args.includes("--dry-run"), yes: args.includes("--yes") };
1933
2873
  }
1934
2874
  function readManifest(repoRoot) {
1935
- const p = join6(repoRoot, CONFIG_PATH);
1936
- if (!existsSync5(p))
2875
+ const p = join8(repoRoot, CONFIG_PATH);
2876
+ if (!existsSync7(p))
1937
2877
  return { kind: "none" };
1938
2878
  let raw;
1939
2879
  try {
1940
- raw = JSON.parse(readFileSync4(p, "utf-8"));
2880
+ raw = JSON.parse(readFileSync5(p, "utf-8"));
1941
2881
  } catch (err) {
1942
2882
  return { kind: "orphan", reason: `vinaya.config.json is not valid JSON: ${err.message}` };
1943
2883
  }
@@ -2015,14 +2955,14 @@ Vinaya ejected.
2015
2955
  return 0;
2016
2956
  }
2017
2957
  async function ejectCommand(args) {
2018
- const code = await runEject(args, realDeps2());
2958
+ const code = await runEject(args, realDeps4());
2019
2959
  process.exit(code);
2020
2960
  }
2021
2961
 
2022
2962
  // src/commands/init.ts
2023
- import { existsSync as existsSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "node:fs";
2024
- import { join as join7 } from "node:path";
2025
- function realDeps3() {
2963
+ import { existsSync as existsSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
2964
+ import { join as join9 } from "node:path";
2965
+ function realDeps5() {
2026
2966
  return {
2027
2967
  detectRepo: detectGitRepo,
2028
2968
  checkGhAuth,
@@ -2041,19 +2981,19 @@ function flags(args) {
2041
2981
  }
2042
2982
  var PRODUCT_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
2043
2983
  function readManifest2(repoRoot) {
2044
- const p = join7(repoRoot, CONFIG_PATH);
2045
- if (!existsSync6(p))
2984
+ const p = join9(repoRoot, CONFIG_PATH);
2985
+ if (!existsSync8(p))
2046
2986
  return null;
2047
2987
  try {
2048
- return VinayaConfigSchema.parse(JSON.parse(readFileSync5(p, "utf-8"))).managed ?? null;
2988
+ return VinayaConfigSchema.parse(JSON.parse(readFileSync6(p, "utf-8"))).managed ?? null;
2049
2989
  } catch {
2050
2990
  return null;
2051
2991
  }
2052
2992
  }
2053
2993
  function writeManifest(repoRoot, manifest) {
2054
- const configAbs = join7(repoRoot, CONFIG_PATH);
2055
- const seed = JSON.parse(readFileSync5(configAbs, "utf-8"));
2056
- writeFileSync3(configAbs, `${JSON.stringify({ ...seed, managed: manifest }, null, 2)}
2994
+ const configAbs = join9(repoRoot, CONFIG_PATH);
2995
+ const seed = JSON.parse(readFileSync6(configAbs, "utf-8"));
2996
+ writeFileSync4(configAbs, `${JSON.stringify({ ...seed, managed: manifest }, null, 2)}
2057
2997
  `, "utf-8");
2058
2998
  }
2059
2999
  async function runInit(args, deps) {
@@ -2181,20 +3121,20 @@ function dedupeBlocks2(bs) {
2181
3121
  });
2182
3122
  }
2183
3123
  async function initCommand(args) {
2184
- process.exit(await runInit(args, realDeps3()));
3124
+ process.exit(await runInit(args, realDeps5()));
2185
3125
  }
2186
3126
  async function initProductCommand(args) {
2187
- process.exit(await runInitProduct(args, realDeps3()));
3127
+ process.exit(await runInitProduct(args, realDeps5()));
2188
3128
  }
2189
3129
 
2190
3130
  // src/commands/issue.ts
2191
- import { execFileSync as execFileSync3 } from "node:child_process";
3131
+ import { execFileSync as execFileSync6 } from "node:child_process";
2192
3132
 
2193
3133
  // src/lib/forge-write.ts
2194
- import { mkdtempSync, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "node:fs";
2195
- import { readFileSync as readFileSync6 } from "node:fs";
3134
+ import { mkdtempSync, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "node:fs";
3135
+ import { readFileSync as readFileSync7 } from "node:fs";
2196
3136
  import { tmpdir } from "node:os";
2197
- import { join as join8 } from "node:path";
3137
+ import { join as join10 } from "node:path";
2198
3138
  class ForgeArgError extends Error {
2199
3139
  }
2200
3140
  function locateBody(args) {
@@ -2204,13 +3144,13 @@ function locateBody(args) {
2204
3144
  const p = args[i + 1];
2205
3145
  if (!p)
2206
3146
  throw new ForgeArgError("`--body-file` was given with no path.");
2207
- return { body: readFileSync6(p, "utf8"), source: { kind: "file", argIndex: i + 1, inlineForm: false } };
3147
+ return { body: readFileSync7(p, "utf8"), source: { kind: "file", argIndex: i + 1, inlineForm: false } };
2208
3148
  }
2209
3149
  if (a.startsWith("--body-file=")) {
2210
3150
  const p = a.slice("--body-file=".length);
2211
3151
  if (!p)
2212
3152
  throw new ForgeArgError("`--body-file=` was given with no path.");
2213
- return { body: readFileSync6(p, "utf8"), source: { kind: "file", argIndex: i, inlineForm: true } };
3153
+ return { body: readFileSync7(p, "utf8"), source: { kind: "file", argIndex: i, inlineForm: true } };
2214
3154
  }
2215
3155
  if (a === "--body" || a === "-b") {
2216
3156
  const v = args[i + 1];
@@ -2227,13 +3167,13 @@ function resolveShippableArgs(args, bodyResult) {
2227
3167
  if (bodyResult?.source.kind !== "file") {
2228
3168
  return { finalArgs: args, cleanup: () => {} };
2229
3169
  }
2230
- const dir = mkdtempSync(join8(tmpdir(), "vinaya-forge-body-"));
2231
- const tempPath = join8(dir, "body.md");
2232
- writeFileSync4(tempPath, bodyResult.body, "utf8");
3170
+ const dir = mkdtempSync(join10(tmpdir(), "vinaya-forge-body-"));
3171
+ const tempPath = join10(dir, "body.md");
3172
+ writeFileSync5(tempPath, bodyResult.body, "utf8");
2233
3173
  const finalArgs = [...args];
2234
3174
  const { argIndex, inlineForm } = bodyResult.source;
2235
3175
  finalArgs[argIndex] = inlineForm ? `--body-file=${tempPath}` : tempPath;
2236
- return { finalArgs, cleanup: () => rmSync2(dir, { recursive: true, force: true }) };
3176
+ return { finalArgs, cleanup: () => rmSync3(dir, { recursive: true, force: true }) };
2237
3177
  }
2238
3178
  function extractTitle(args) {
2239
3179
  for (let i = 0;i < args.length; i++) {
@@ -2401,7 +3341,7 @@ function locateBodyOrRefuse(ghArgs, retryCommand) {
2401
3341
  function fetchForgeLabels(issueRef) {
2402
3342
  let out;
2403
3343
  try {
2404
- out = execFileSync3("gh", ["issue", "view", issueRef, "--json", "labels"], {
3344
+ out = execFileSync6("gh", ["issue", "view", issueRef, "--json", "labels"], {
2405
3345
  encoding: "utf8",
2406
3346
  stdio: ["ignore", "pipe", "pipe"]
2407
3347
  });
@@ -2427,9 +3367,9 @@ function reportPass(json, command) {
2427
3367
  }
2428
3368
  }
2429
3369
  function runGhWrite(ghCmd, ghArgs, bodyResult, json) {
2430
- const { finalArgs, cleanup } = resolveShippableArgs(ghArgs, bodyResult);
3370
+ const { finalArgs, cleanup: cleanup2 } = resolveShippableArgs(ghArgs, bodyResult);
2431
3371
  try {
2432
- const out = execFileSync3("gh", [...ghCmd, ...finalArgs], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
3372
+ const out = execFileSync6("gh", [...ghCmd, ...finalArgs], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
2433
3373
  const url = out.trim();
2434
3374
  if (json)
2435
3375
  printJson({ validated: true, written: true, url });
@@ -2437,7 +3377,7 @@ function runGhWrite(ghCmd, ghArgs, bodyResult, json) {
2437
3377
  process.stdout.write(`${url}
2438
3378
  `);
2439
3379
  } finally {
2440
- cleanup();
3380
+ cleanup2();
2441
3381
  }
2442
3382
  }
2443
3383
  function validateTaskIssue(body, title, retryCommand) {
@@ -2500,21 +3440,10 @@ function issueEditCommand(args) {
2500
3440
  }
2501
3441
 
2502
3442
  // src/commands/new-check.ts
2503
- import { chmodSync as chmodSync2, existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "node:fs";
2504
- import { dirname as dirname5, join as join9 } from "node:path";
2505
- import { fileURLToPath as fileURLToPath3 } from "node:url";
2506
- function packageRoot2() {
2507
- let dir = dirname5(fileURLToPath3(import.meta.url));
2508
- while (!existsSync7(join9(dir, "package.json"))) {
2509
- const parent = dirname5(dir);
2510
- if (parent === dir)
2511
- break;
2512
- dir = parent;
2513
- }
2514
- return dir;
2515
- }
2516
- var TEMPLATE_PATH = join9(packageRoot2(), "templates", "custom-check.template.ts");
2517
- var CHECKS_DIR = join9("scripts", "vinaya-checks");
3443
+ import { chmodSync as chmodSync2, existsSync as existsSync9, mkdirSync as mkdirSync3, readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "node:fs";
3444
+ import { join as join11 } from "node:path";
3445
+ var TEMPLATE_PATH = join11(packageRoot(import.meta.url), "templates", "custom-check.template.ts");
3446
+ var CHECKS_DIR = join11("scripts", "vinaya-checks");
2518
3447
  var VALID_NAME = /^[a-z0-9][a-z0-9-]*$/;
2519
3448
  function newCheckCommand(args) {
2520
3449
  const name = args[0];
@@ -2522,19 +3451,19 @@ function newCheckCommand(args) {
2522
3451
  console.error("Usage: vinaya new check <name> (name: lowercase letters, digits, hyphens)");
2523
3452
  process.exit(2);
2524
3453
  }
2525
- const scriptsDir = join9(process.cwd(), CHECKS_DIR);
2526
- if (!existsSync7(scriptsDir))
3454
+ const scriptsDir = join11(process.cwd(), CHECKS_DIR);
3455
+ if (!existsSync9(scriptsDir))
2527
3456
  mkdirSync3(scriptsDir, { recursive: true });
2528
- const targetPath = join9(scriptsDir, `${name}.ts`);
2529
- if (existsSync7(targetPath)) {
3457
+ const targetPath = join11(scriptsDir, `${name}.ts`);
3458
+ if (existsSync9(targetPath)) {
2530
3459
  console.error(`Error: ${targetPath} already exists.`);
2531
3460
  process.exit(1);
2532
3461
  }
2533
- const template = readFileSync7(TEMPLATE_PATH, "utf-8");
3462
+ const template = readFileSync8(TEMPLATE_PATH, "utf-8");
2534
3463
  const contents = template.split("{{CHECK_NAME}}").join(name);
2535
- writeFileSync5(targetPath, contents, "utf-8");
3464
+ writeFileSync6(targetPath, contents, "utf-8");
2536
3465
  chmodSync2(targetPath, 493);
2537
- const relPath = join9(CHECKS_DIR, `${name}.ts`);
3466
+ const relPath = join11(CHECKS_DIR, `${name}.ts`);
2538
3467
  const registration = JSON.stringify({ checks: { [name]: { run: `./${relPath}`, scope: "diff" } } }, null, 2);
2539
3468
  process.stdout.write(`Created ${relPath}
2540
3469
 
@@ -2544,12 +3473,12 @@ ${registration}
2544
3473
  }
2545
3474
 
2546
3475
  // src/commands/pr.ts
2547
- import { execFileSync as execFileSync4 } from "node:child_process";
3476
+ import { execFileSync as execFileSync7 } from "node:child_process";
2548
3477
  var RETRY_CREATE2 = "vinaya pr create --validate-only …";
2549
3478
  var RETRY_EDIT2 = "vinaya pr edit <n> --validate-only …";
2550
3479
  function git2(args) {
2551
3480
  try {
2552
- return execFileSync4("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
3481
+ return execFileSync7("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
2553
3482
  } catch {
2554
3483
  return "";
2555
3484
  }
@@ -2581,9 +3510,9 @@ function reportPass2(json, command) {
2581
3510
  }
2582
3511
  }
2583
3512
  function runGhWrite2(ghCmd, ghArgs, bodyResult, json) {
2584
- const { finalArgs, cleanup } = resolveShippableArgs(ghArgs, bodyResult);
3513
+ const { finalArgs, cleanup: cleanup2 } = resolveShippableArgs(ghArgs, bodyResult);
2585
3514
  try {
2586
- const out = execFileSync4("gh", [...ghCmd, ...finalArgs], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
3515
+ const out = execFileSync7("gh", [...ghCmd, ...finalArgs], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
2587
3516
  const url = out.trim();
2588
3517
  if (json)
2589
3518
  printJson({ validated: true, written: true, url });
@@ -2591,13 +3520,13 @@ function runGhWrite2(ghCmd, ghArgs, bodyResult, json) {
2591
3520
  process.stdout.write(`${url}
2592
3521
  `);
2593
3522
  } finally {
2594
- cleanup();
3523
+ cleanup2();
2595
3524
  }
2596
3525
  }
2597
3526
  function fetchPrForgeContext(prRef) {
2598
3527
  let viewOut;
2599
3528
  try {
2600
- viewOut = execFileSync4("gh", ["pr", "view", prRef, "--json", "headRefName,files"], {
3529
+ viewOut = execFileSync7("gh", ["pr", "view", prRef, "--json", "headRefName,files"], {
2601
3530
  encoding: "utf8",
2602
3531
  stdio: ["ignore", "pipe", "pipe"]
2603
3532
  });
@@ -2694,22 +3623,22 @@ function prEditCommand(args) {
2694
3623
 
2695
3624
  // src/commands/studio.ts
2696
3625
  import { spawn as spawn2 } from "node:child_process";
2697
- import { existsSync as existsSync8, readFileSync as readFileSync8 } from "node:fs";
2698
- import { dirname as dirname6, join as join10 } from "node:path";
3626
+ import { existsSync as existsSync10, readFileSync as readFileSync9 } from "node:fs";
3627
+ import { dirname as dirname4, join as join12 } from "node:path";
2699
3628
  function resolveStudioTarget(cwd) {
2700
3629
  let dir = cwd;
2701
3630
  for (;; ) {
2702
- const webDir = join10(dir, "apps", "vinaya", "web");
2703
- const pkgPath = join10(webDir, "package.json");
2704
- if (existsSync8(pkgPath)) {
3631
+ const webDir = join12(dir, "apps", "vinaya", "web");
3632
+ const pkgPath = join12(webDir, "package.json");
3633
+ if (existsSync10(pkgPath)) {
2705
3634
  try {
2706
- const pkg = JSON.parse(readFileSync8(pkgPath, "utf-8"));
3635
+ const pkg = JSON.parse(readFileSync9(pkgPath, "utf-8"));
2707
3636
  if (pkg.name === "@atta/vinaya-web") {
2708
3637
  return { kind: "workspace", webDir };
2709
3638
  }
2710
3639
  } catch {}
2711
3640
  }
2712
- const parent = dirname6(dir);
3641
+ const parent = dirname4(dir);
2713
3642
  if (parent === dir)
2714
3643
  break;
2715
3644
  dir = parent;
@@ -2736,9 +3665,9 @@ async function runStudio(cwd, args) {
2736
3665
  }
2737
3666
 
2738
3667
  // src/commands/upgrade.ts
2739
- import { existsSync as existsSync9, readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "node:fs";
2740
- import { join as join11 } from "node:path";
2741
- function realDeps4() {
3668
+ import { existsSync as existsSync11, readFileSync as readFileSync10, writeFileSync as writeFileSync7 } from "node:fs";
3669
+ import { join as join13 } from "node:path";
3670
+ function realDeps6() {
2742
3671
  return {
2743
3672
  detectRepo: detectGitRepo,
2744
3673
  hookDirFor: resolveHookDir,
@@ -2753,12 +3682,12 @@ function flags2(args) {
2753
3682
  return { dryRun: args.includes("--dry-run"), yes: args.includes("--yes") };
2754
3683
  }
2755
3684
  function readManifest3(repoRoot) {
2756
- const p = join11(repoRoot, CONFIG_PATH);
2757
- if (!existsSync9(p))
3685
+ const p = join13(repoRoot, CONFIG_PATH);
3686
+ if (!existsSync11(p))
2758
3687
  return { kind: "missing" };
2759
3688
  let raw;
2760
3689
  try {
2761
- raw = JSON.parse(readFileSync9(p, "utf-8"));
3690
+ raw = JSON.parse(readFileSync10(p, "utf-8"));
2762
3691
  } catch (err) {
2763
3692
  return { kind: "invalid", error: `invalid JSON: ${err.message}` };
2764
3693
  }
@@ -2774,10 +3703,10 @@ function readManifest3(repoRoot) {
2774
3703
  return { kind: "ok", manifest: parsed.data.managed };
2775
3704
  }
2776
3705
  function writeManifestVersion(repoRoot, manifest) {
2777
- const configAbs = join11(repoRoot, CONFIG_PATH);
2778
- const seed = JSON.parse(readFileSync9(configAbs, "utf-8"));
3706
+ const configAbs = join13(repoRoot, CONFIG_PATH);
3707
+ const seed = JSON.parse(readFileSync10(configAbs, "utf-8"));
2779
3708
  const updated = { ...manifest, version: MANAGED_MANIFEST_VERSION };
2780
- writeFileSync6(configAbs, `${JSON.stringify({ ...seed, managed: updated }, null, 2)}
3709
+ writeFileSync7(configAbs, `${JSON.stringify({ ...seed, managed: updated }, null, 2)}
2781
3710
  `, "utf-8");
2782
3711
  }
2783
3712
  function planUpgrade(ops, repoRoot, manifest) {
@@ -2788,8 +3717,8 @@ function planUpgrade(ops, repoRoot, manifest) {
2788
3717
  const ownedBlocks = new Set(manifest.blocks.map((b) => blockKey(b.path, b.marker)));
2789
3718
  for (const op of ops) {
2790
3719
  if (op.kind === "create-file") {
2791
- const abs2 = join11(repoRoot, op.path);
2792
- const exists = existsSync9(abs2);
3720
+ const abs2 = join13(repoRoot, op.path);
3721
+ const exists = existsSync11(abs2);
2793
3722
  const owned = ownedFiles.has(op.path);
2794
3723
  let action;
2795
3724
  if (op.path === CONFIG_PATH) {
@@ -2799,7 +3728,7 @@ function planUpgrade(ops, repoRoot, manifest) {
2799
3728
  } else if (!exists) {
2800
3729
  action = "recreate";
2801
3730
  hasChanges = true;
2802
- } else if (readFileSync9(abs2, "utf-8") !== op.content) {
3731
+ } else if (readFileSync10(abs2, "utf-8") !== op.content) {
2803
3732
  action = "regenerate";
2804
3733
  hasChanges = true;
2805
3734
  } else {
@@ -2807,16 +3736,16 @@ function planUpgrade(ops, repoRoot, manifest) {
2807
3736
  }
2808
3737
  entries.push({ kind: "create-file", op, action });
2809
3738
  } else if (op.kind === "managed-block") {
2810
- const abs2 = join11(repoRoot, op.path);
3739
+ const abs2 = join13(repoRoot, op.path);
2811
3740
  const owned = ownedBlocks.has(blockKey(op.path, op.marker));
2812
3741
  let action;
2813
3742
  if (!owned) {
2814
3743
  action = "not-installed";
2815
- } else if (!existsSync9(abs2)) {
3744
+ } else if (!existsSync11(abs2)) {
2816
3745
  action = "recreate-host";
2817
3746
  hasChanges = true;
2818
3747
  } else {
2819
- const content = readFileSync9(abs2, "utf-8");
3748
+ const content = readFileSync10(abs2, "utf-8");
2820
3749
  const { begin, end } = markerLines(op.marker, op.comment);
2821
3750
  if (!(content.includes(begin) && content.includes(end))) {
2822
3751
  action = "recreate-append";
@@ -2892,11 +3821,11 @@ function renderUpgradeDiff(plan) {
2892
3821
  `);
2893
3822
  }
2894
3823
  function regenerateBlock(repoRoot, op) {
2895
- const abs2 = join11(repoRoot, op.path);
2896
- const content = readFileSync9(abs2, "utf-8");
3824
+ const abs2 = join13(repoRoot, op.path);
3825
+ const content = readFileSync10(abs2, "utf-8");
2897
3826
  const stripped = stripBlockFromContent(content, op.marker, op.comment);
2898
3827
  if (stripped !== null) {
2899
- writeFileSync6(abs2, stripped.endsWith(`
3828
+ writeFileSync7(abs2, stripped.endsWith(`
2900
3829
  `) ? stripped : `${stripped}
2901
3830
  `, "utf-8");
2902
3831
  }
@@ -2906,7 +3835,7 @@ function applyUpgrade(plan, repoRoot) {
2906
3835
  for (const e of plan.entries) {
2907
3836
  if (e.kind === "create-file") {
2908
3837
  if (e.action === "regenerate" || e.action === "recreate") {
2909
- writeFileWithDirs(join11(repoRoot, e.op.path), e.op.content, e.op.mode);
3838
+ writeFileWithDirs(join13(repoRoot, e.op.path), e.op.content, e.op.mode);
2910
3839
  }
2911
3840
  } else {
2912
3841
  if (e.action === "recreate-host")
@@ -2976,7 +3905,120 @@ Vinaya upgraded.
2976
3905
  return 0;
2977
3906
  }
2978
3907
  async function upgradeCommand(args) {
2979
- process.exit(await runUpgrade(args, realDeps4()));
3908
+ process.exit(await runUpgrade(args, realDeps6()));
3909
+ }
3910
+
3911
+ // src/commands/waiver.ts
3912
+ import { execFileSync as execFileSync8 } from "node:child_process";
3913
+ function labelFor(kind) {
3914
+ return kind === "docs" ? WAIVER_LABEL : WAIVER_LABEL_REVIEW;
3915
+ }
3916
+ function gh(args) {
3917
+ return execFileSync8("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: process.env }).trim();
3918
+ }
3919
+ function detectPrNumber() {
3920
+ try {
3921
+ const out = gh(["pr", "view", "--json", "number", "-q", ".number"]);
3922
+ return /^\d+$/.test(out) ? out : null;
3923
+ } catch {
3924
+ return null;
3925
+ }
3926
+ }
3927
+ var VALUE_FLAGS = ["--reason"];
3928
+ var BOOLEAN_FLAGS = ["--print-only"];
3929
+ function positionalArgs(args) {
3930
+ const result = [];
3931
+ for (let i = 0;i < args.length; i++) {
3932
+ const a = args[i];
3933
+ if (VALUE_FLAGS.some((f) => a.startsWith(`${f}=`)))
3934
+ continue;
3935
+ if (VALUE_FLAGS.includes(a)) {
3936
+ i += 1;
3937
+ continue;
3938
+ }
3939
+ if (BOOLEAN_FLAGS.includes(a))
3940
+ continue;
3941
+ result.push(a);
3942
+ }
3943
+ return result;
3944
+ }
3945
+ async function resolveKind(args) {
3946
+ const positional = positionalArgs(args).find((a) => a === "docs" || a === "review");
3947
+ if (positional === "docs" || positional === "review")
3948
+ return positional;
3949
+ const answer = (await prompt("Which waiver — `docs` or `review`? ")).trim().toLowerCase();
3950
+ return answer === "docs" || answer === "review" ? answer : null;
3951
+ }
3952
+ async function resolvePrNumber(args) {
3953
+ const positional = positionalArgs(args).find((a) => /^\d+$/.test(a));
3954
+ if (positional)
3955
+ return positional;
3956
+ const detected = detectPrNumber();
3957
+ if (detected)
3958
+ return detected;
3959
+ const answer = (await prompt("PR number (could not auto-detect one for the current branch): ")).trim();
3960
+ return /^\d+$/.test(answer) ? answer : null;
3961
+ }
3962
+ function flagValue(args, flag) {
3963
+ const eq = args.find((a) => a.startsWith(`${flag}=`));
3964
+ if (eq)
3965
+ return eq.slice(flag.length + 1);
3966
+ const idx = args.indexOf(flag);
3967
+ if (idx !== -1 && args[idx + 1] !== undefined)
3968
+ return args[idx + 1];
3969
+ return null;
3970
+ }
3971
+ async function resolveReason(args) {
3972
+ const flagged = flagValue(args, "--reason");
3973
+ if (flagged && flagged.trim().length > 0)
3974
+ return flagged.trim();
3975
+ const answer = await prompt("Reason for this waiver (one line — posted as a PR comment): ");
3976
+ return answer.trim();
3977
+ }
3978
+ async function waiverCommand(args) {
3979
+ const printOnly = args.includes("--print-only");
3980
+ const kind = await resolveKind(args);
3981
+ if (!kind) {
3982
+ console.error("Usage: vinaya waiver <docs|review> [<pr-number>] [--reason <text>] [--print-only]");
3983
+ closeStdin();
3984
+ process.exit(2);
3985
+ return;
3986
+ }
3987
+ const prNumber = await resolvePrNumber(args);
3988
+ if (!prNumber) {
3989
+ console.error("A PR number is required — none was given and none could be auto-detected.");
3990
+ closeStdin();
3991
+ process.exit(2);
3992
+ return;
3993
+ }
3994
+ const reason = await resolveReason(args);
3995
+ closeStdin();
3996
+ if (!reason) {
3997
+ console.error("A reason is required — the waiver label alone carries no rationale for the reviewer.");
3998
+ process.exit(2);
3999
+ return;
4000
+ }
4001
+ const label2 = labelFor(kind);
4002
+ const editArgs = ["pr", "edit", prNumber, "--add-label", label2];
4003
+ const commentArgs = ["pr", "comment", prNumber, "--body", `Waiver applied: \`${label2}\`
4004
+ Reason: ${reason}`];
4005
+ if (printOnly) {
4006
+ process.stdout.write(`--print-only: nothing below was executed. Run these yourself, as a human, to apply the waiver:
4007
+
4008
+ `);
4009
+ process.stdout.write(` gh ${editArgs.join(" ")}
4010
+ `);
4011
+ process.stdout.write(` gh ${commentArgs.join(" ")}
4012
+ `);
4013
+ return;
4014
+ }
4015
+ process.stdout.write(`Applying \`${label2}\` to PR #${prNumber} under your authenticated \`gh\` identity — vinaya never fabricates an actor.
4016
+ `);
4017
+ execFileSync8("gh", editArgs, { stdio: "inherit", env: process.env });
4018
+ execFileSync8("gh", commentArgs, { stdio: "inherit", env: process.env });
4019
+ process.stdout.write(`
4020
+ ✓ Label applied and reason posted as a PR comment.
4021
+ `);
2980
4022
  }
2981
4023
 
2982
4024
  // ../sources/src/commands.ts
@@ -3101,6 +4143,30 @@ var COMMANDS = [
3101
4143
  ],
3102
4144
  status: "shipped"
3103
4145
  },
4146
+ {
4147
+ name: "archive",
4148
+ description: "Run the post-merge Archivist directly: provenance + Issue close-out for a merged task PR",
4149
+ flags: [{ flag: "--merge-sha", description: "The merge commit to resolve (defaults to the current HEAD)" }],
4150
+ details: [
4151
+ "Resolves the merge commit's associated PR via `gh`, and — if it's a task PR without a provenance comment yet — posts the provenance block and closes the linked Issue. Idempotent: re-running against an already-archived PR is a no-op.",
4152
+ "The same logic the generated `vinaya-archivist.yml` workflow's `post-merge` job runs on every push to `main` — callable directly for a one-off run or local verification."
4153
+ ],
4154
+ status: "shipped"
4155
+ },
4156
+ {
4157
+ name: "audit",
4158
+ description: "Run the ring-2 dead-branch-push and direct-main-push detection checks directly",
4159
+ flags: [
4160
+ { flag: "--only", description: "Scope to one check: 'dead-branches' or 'direct-push'" },
4161
+ { flag: "--sha", description: "The commit to check for direct-main-push (defaults to the current HEAD)" },
4162
+ { flag: "--json", description: "Enveloped JSON output" }
4163
+ ],
4164
+ details: [
4165
+ "Dead-branch-push is never-red — a notification channel that flags (label + PR comment) any `task/*` branch whose tip commit lands after its own PR already resolved. Direct-main-push is a real pass/fail — it opens an incident Issue and exits 1 if a commit on `main` has no associated merged PR.",
4166
+ "The same logic the generated `vinaya-archivist.yml` workflow's `daily-drift` and `direct-main-push-detection` jobs run on schedule / on every push to `main` — callable directly for a one-off run or local verification."
4167
+ ],
4168
+ status: "shipped"
4169
+ },
3104
4170
  {
3105
4171
  name: "eject",
3106
4172
  description: "Remove every Vinaya-installed artifact, restoring the repo to stock",
@@ -3113,12 +4179,25 @@ var COMMANDS = [
3113
4179
  {
3114
4180
  name: "demo break",
3115
4181
  description: "Run a guided refusal-then-fix demo on an isolated, discardable branch",
3116
- status: "planned"
4182
+ flags: [{ flag: "--keep", description: "Skip cleanup and leave the demo branch checked out to inspect" }],
4183
+ details: [
4184
+ "Creates a collision-safe `vinaya/demo-break-<id>` branch off the current one, stages a deliberately incomplete draft brief, and attempts a real `git commit` — the repo's actually-installed pre-commit hook refuses it with its real output, not a scripted string. Applies the minimal fix, commits again, then switches back and deletes the demo branch.",
4185
+ "Safe to run twice: refuses on a dirty working tree, refuses from a detached HEAD, and recovers automatically from a prior crashed run before starting a fresh one — never leaves the original branch touched or a stray demo branch behind."
4186
+ ],
4187
+ status: "shipped"
3117
4188
  },
3118
4189
  {
3119
4190
  name: "waiver",
3120
- description: "Apply the principal-verified 'vinaya/waiver:docs' label after prompting for a reason",
3121
- status: "planned"
4191
+ description: "Apply the actor-verified 'vinaya/waiver:docs' or 'vinaya/waiver:review' label after prompting for a reason",
4192
+ flags: [
4193
+ { flag: "--reason", description: "The waiver rationale, posted as a PR comment (prompted for if omitted)" },
4194
+ { flag: "--print-only", description: "Print the exact `gh` commands instead of running them" }
4195
+ ],
4196
+ details: [
4197
+ "Applies the label via `gh pr edit --add-label`, under the invoking human's own authenticated `gh` identity — this command never fabricates an actor. A waiver is never an agent-emittable string: not a PR body field, not a commit trailer, not a comment — the label plus its own labeling-timeline actor is the only mechanism ring 1 honors.",
4198
+ "`--print-only` prints the exact `gh pr edit`/`gh pr comment` commands and runs nothing — for a human who wants to run them itself, or a CI/non-interactive context where an agent session should never be the one applying its own waiver."
4199
+ ],
4200
+ status: "shipped"
3122
4201
  },
3123
4202
  {
3124
4203
  name: "studio",
@@ -3155,9 +4234,9 @@ function printHelp() {
3155
4234
  }
3156
4235
 
3157
4236
  // src/index.ts
3158
- var PACKAGE_ROOT = join12(dirname7(fileURLToPath4(import.meta.url)), "..");
4237
+ var PACKAGE_ROOT = join14(dirname5(fileURLToPath2(import.meta.url)), "..");
3159
4238
  function readVersion2() {
3160
- const pkg = JSON.parse(readFileSync10(join12(PACKAGE_ROOT, "package.json"), "utf-8"));
4239
+ const pkg = JSON.parse(readFileSync11(join14(PACKAGE_ROOT, "package.json"), "utf-8"));
3161
4240
  return pkg.version;
3162
4241
  }
3163
4242
  var [, , command, ...args] = process.argv;
@@ -3207,6 +4286,14 @@ try {
3207
4286
  await checkCommand(args);
3208
4287
  break;
3209
4288
  }
4289
+ case "archive": {
4290
+ await archiveCommand(args);
4291
+ break;
4292
+ }
4293
+ case "audit": {
4294
+ await auditCommand(args);
4295
+ break;
4296
+ }
3210
4297
  case "new": {
3211
4298
  const [subcommand, ...rest] = args;
3212
4299
  if (subcommand === "check") {
@@ -3241,6 +4328,20 @@ try {
3241
4328
  }
3242
4329
  break;
3243
4330
  }
4331
+ case "demo": {
4332
+ const [subcommand, ...rest] = args;
4333
+ if (subcommand === "break") {
4334
+ await demoBreakCommand(rest);
4335
+ } else {
4336
+ console.error(`Unknown 'demo' subcommand: ${subcommand ?? "(none)"} (expected 'break')`);
4337
+ process.exit(2);
4338
+ }
4339
+ break;
4340
+ }
4341
+ case "waiver": {
4342
+ await waiverCommand(args);
4343
+ break;
4344
+ }
3244
4345
  default:
3245
4346
  console.error(`Unknown command: ${command}`);
3246
4347
  printHelp();