@azure-id/orc 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README-id.md +206 -125
  3. package/README.md +274 -74
  4. package/bin/cli.js +1595 -2
  5. package/bin/mockrun-catalog.js +272 -267
  6. package/bin/verify-contracts.js +4527 -4509
  7. package/bin/verify-package.js +6 -0
  8. package/bin/webui/api.js +26 -0
  9. package/bin/webui/app.html +10 -0
  10. package/bin/webui/css/panels/knowledge.css +30 -0
  11. package/bin/webui/css/panels/rules.css +145 -0
  12. package/bin/webui/fixtures/index.js +27 -0
  13. package/bin/webui/fixtures/rules.js +1002 -0
  14. package/bin/webui/fixtures/wiki-one.js +198 -0
  15. package/bin/webui/i18n/en/knowledge.json +127 -117
  16. package/bin/webui/i18n/en/nav.json +1 -0
  17. package/bin/webui/i18n/en/rules.json +55 -0
  18. package/bin/webui/i18n/id/knowledge.json +127 -117
  19. package/bin/webui/i18n/id/nav.json +1 -0
  20. package/bin/webui/i18n/id/rules.json +55 -0
  21. package/bin/webui/js/01-i18n.js +1 -0
  22. package/bin/webui/js/panels/knowledge.js +117 -1
  23. package/bin/webui/js/panels/rules.js +351 -0
  24. package/mock-run/INDEX.md +107 -106
  25. package/mock-run/orc-rules.md +254 -0
  26. package/package.json +1 -1
  27. package/templates/commands/orc-rules.md +66 -0
  28. package/templates/commands/orc-wiki.md +23 -1
  29. package/templates/skills/_shared/README.md +61 -54
  30. package/templates/skills/_shared/phases/README.md +1 -0
  31. package/templates/skills/_shared/phases/preflight.md +20 -0
  32. package/templates/skills/_shared/phases/rules.md +154 -0
  33. package/templates/skills/_shared/return-validation.md +28 -0
  34. package/templates/skills/_shared/rules/CREDITS.md +141 -0
  35. package/templates/skills/_shared/rules/INDEX.md +107 -0
  36. package/templates/skills/_shared/rules/code.md +229 -0
  37. package/templates/skills/_shared/rules/delivery.md +114 -0
  38. package/templates/skills/_shared/rules/ui.md +139 -0
  39. package/templates/skills/_shared/rules/writing.md +223 -0
  40. package/templates/skills/context-combiner/SKILL.md +9 -1
  41. package/templates/skills/orc/SKILL.md +7 -0
  42. package/templates/skills/orc-aftermath/SKILL.md +7 -0
  43. package/templates/skills/orc-analyze/SKILL.md +7 -0
  44. package/templates/skills/orc-analyze-mini/SKILL.md +9 -1
  45. package/templates/skills/orc-boundary/SKILL.md +7 -0
  46. package/templates/skills/orc-brainstorm/SKILL.md +7 -0
  47. package/templates/skills/orc-budget/SKILL.md +7 -0
  48. package/templates/skills/orc-challenge/SKILL.md +7 -0
  49. package/templates/skills/orc-claude/SKILL.md +7 -0
  50. package/templates/skills/orc-diy/SKILL.md +7 -0
  51. package/templates/skills/orc-explain/SKILL.md +7 -0
  52. package/templates/skills/orc-export/SKILL.md +7 -0
  53. package/templates/skills/orc-fast/SKILL.md +7 -0
  54. package/templates/skills/orc-grill/SKILL.md +7 -0
  55. package/templates/skills/orc-handoff/SKILL.md +9 -1
  56. package/templates/skills/orc-learn/SKILL.md +7 -0
  57. package/templates/skills/orc-mini/SKILL.md +7 -0
  58. package/templates/skills/orc-pact/SKILL.md +7 -0
  59. package/templates/skills/orc-poly/SKILL.md +7 -0
  60. package/templates/skills/orc-pr-driver/SKILL.md +9 -1
  61. package/templates/skills/orc-pr-setup/SKILL.md +9 -1
  62. package/templates/skills/orc-quick/SKILL.md +7 -0
  63. package/templates/skills/orc-retro/SKILL.md +7 -0
  64. package/templates/skills/orc-route/SKILL.md +7 -0
  65. package/templates/skills/orc-test/SKILL.md +7 -0
  66. package/templates/skills/orc-verify/SKILL.md +7 -0
  67. package/templates/skills/orc-wiki/SKILL.md +15 -0
  68. package/templates/skills/orc-wiki/references/partial-refresh.md +68 -0
  69. package/templates/skills/orc-wiki/references/phases/phase-0.md +15 -1
package/bin/cli.js CHANGED
@@ -3595,6 +3595,25 @@ const LANE_CALLS = {
3595
3595
  never: "never apply an import without the user's yes",
3596
3596
  lanes: ["orc-export", "orc-pact"],
3597
3597
  },
3598
+ // v1.7.0 — the anti-slop card. One assembler, because a card built in
3599
+ // twenty-eight spines is twenty-eight ideas of the precedence order, and
3600
+ // because this is the only place its per-spawn token weight can be measured.
3601
+ "rules-slice": {
3602
+ cmd: "orc rules slice --lane <lane> [--pack ui] [--json]",
3603
+ what: "THE anti-slop card for a slice: your project rules first, then ORC's own packs, in precedence order",
3604
+ exits: { 0: "answered", 2: "no --lane, a lane that carries no rules (orc-doc), or an unknown pack" },
3605
+ states: null,
3606
+ cost: "free",
3607
+ when: "once per dispatch, for every slice that writes words or code — and with `--pack ui` only when the task's declared files are front-end",
3608
+ on_absent: "an empty user ledger is an ANSWER: the ORC packs still ride, and the line still prints `yours none`",
3609
+ canonical: "_shared/phases/rules.md",
3610
+ never: "never assemble this card in a skill, and never let a lane trim its own copy to save tokens",
3611
+ // Spelled out rather than derived from RULE_LANE_PACKS: that table is
3612
+ // declared far below this catalogue, and reading it here is a temporal dead
3613
+ // zone that crashes the CLI on every command. `test/cli/rules.test.js`
3614
+ // asserts the two agree.
3615
+ lanes: ["context-combiner", "orc", "orc-aftermath", "orc-analyze", "orc-analyze-mini", "orc-boundary", "orc-brainstorm", "orc-budget", "orc-challenge", "orc-claude", "orc-diy", "orc-explain", "orc-export", "orc-fast", "orc-grill", "orc-handoff", "orc-learn", "orc-mini", "orc-pact", "orc-poly", "orc-pr-driver", "orc-pr-setup", "orc-quick", "orc-retro", "orc-route", "orc-test", "orc-verify", "orc-wiki"],
3616
+ },
3598
3617
  };
3599
3618
 
3600
3619
  // `orc lane calls <lane>` / `--all` — the catalogue, rendered.
@@ -3757,6 +3776,15 @@ const PHASE_FILES = {
3757
3776
  layers: ["core"],
3758
3777
  why_single_layer: "it is a standing card injected VERBATIM into a slice — a layered card would be a different card",
3759
3778
  },
3779
+ // v1.7.0 — the anti-slop card. Single-layer for the SAME reason as
3780
+ // house-rules: what varies between lanes is DATA (which packs, whether a task
3781
+ // is front-end), not prose, and a layered standing card is a different card.
3782
+ rules: {
3783
+ file: "_shared/phases/rules.md",
3784
+ layers: ["core"],
3785
+ why_single_layer:
3786
+ "a standing card assembled by ONE command (`orc rules slice`) and injected verbatim — the per-lane variation is which packs it carries, which is data the CLI already resolves",
3787
+ },
3760
3788
  "stop-resume": {
3761
3789
  file: "_shared/phases/stop-resume.md",
3762
3790
  layers: ["core"],
@@ -3965,6 +3993,17 @@ const LANE_PHASES = {
3965
3993
  "wiki-consult": ["orc", "orc-mini", "orc-fast"],
3966
3994
  "security-checklist": ["orc", "orc-diy"],
3967
3995
  "house-rules": ["orc", "orc-mini", "orc-fast", "orc-quick", "orc-doc"],
3996
+ // v1.7.0 — every lane that writes words or code, and `orc-doc` is absent BY
3997
+ // DESIGN: it has its own ledger (`orc doc rules`) and its own frozen-per-
3998
+ // document mechanic. This list is the same set as RULE_LANE_PACKS, which
3999
+ // `test/cli/rules.test.js` holds together.
4000
+ rules: [
4001
+ "context-combiner", "orc", "orc-aftermath", "orc-analyze", "orc-analyze-mini",
4002
+ "orc-boundary", "orc-brainstorm", "orc-budget", "orc-challenge", "orc-claude",
4003
+ "orc-diy", "orc-explain", "orc-export", "orc-fast", "orc-grill", "orc-handoff",
4004
+ "orc-learn", "orc-mini", "orc-pact", "orc-poly", "orc-pr-driver", "orc-pr-setup",
4005
+ "orc-quick", "orc-retro", "orc-route", "orc-test", "orc-verify", "orc-wiki",
4006
+ ],
3968
4007
  // v1.0.0 W13 — the build phases. `orc-diy` reads the `composed` layer, which
3969
4008
  // `orc diy compile` stitches; `/orc` reads `full`. Two readers is what moved
3970
4009
  // these out of orc/references/phases/ (design-02 §2, the >=2-lane rule).
@@ -3992,6 +4031,9 @@ const PHASE_ORDER = [
3992
4031
  "analyst-gates",
3993
4032
  "planning",
3994
4033
  "house-rules",
4034
+ // Immediately after the house card, because the card it assembles sits
4035
+ // directly under it in a slice and the order in a slice IS the contract.
4036
+ "rules",
3995
4037
  "wave-grouping",
3996
4038
  "scoring",
3997
4039
  "execution",
@@ -4225,7 +4267,16 @@ function lanePhaseRows(lane) {
4225
4267
  ? "always"
4226
4268
  : "on-phase",
4227
4269
  optional_when: null,
4228
- calls: id === "preflight" ? laneCallIds.filter((c) => PREFLIGHT_CALL_IDS.has(c)) : [],
4270
+ // v1.7.0 — `rules` names its own call for the same reason `preflight`
4271
+ // does: the phase IS that command, and a phase row reading "none
4272
+ // catalogued" beside a phase whose whole procedure is one CLI call reads
4273
+ // as "this phase makes no calls", which is the opposite of true.
4274
+ calls:
4275
+ id === "preflight"
4276
+ ? laneCallIds.filter((c) => PREFLIGHT_CALL_IDS.has(c))
4277
+ : id === "rules"
4278
+ ? laneCallIds.filter((c) => c === "rules-slice")
4279
+ : [],
4229
4280
  });
4230
4281
  }
4231
4282
  return rows;
@@ -8807,6 +8858,417 @@ function wikiDocRows(claudeDir) {
8807
8858
  return { rows: detail.per_doc, detail, fresh: f, edges, state: s.state, meta: s.meta, paths, code: 0 };
8808
8859
  }
8809
8860
 
8861
+ // ── orc wiki resolve | add | refs (v1.7.0 W6) — one doc at a time ──────────
8862
+ //
8863
+ // Targeted REFRESH has existed since v0.33.0 and is not rebuilt here. What was
8864
+ // missing is the other half: ADDING one topic to a wiki that already exists.
8865
+ // Until now a new coverage area only appeared as a by-product of the
8866
+ // coverage-gap sweep during a delta refresh, so "add the remittance feature to
8867
+ // the wiki" had no path that did not re-plan every area in the repo.
8868
+ //
8869
+ // Three free commands, none of which scans or spawns:
8870
+ //
8871
+ // resolve does a doc already cover this topic? → refresh it, or add one
8872
+ // add RESERVE the doc row so a scan-task has a target to write into
8873
+ // refs the DERIVED-REFERENCE sweep: after ONE doc changes, which of the
8874
+ // surfaces that are derived from the doc set are now stale
8875
+ //
8876
+ // `orc wiki sync` stays the only writer of wiki-meta.json and INDEX.md. `add`
8877
+ // writes a stub, `refs` calls sync. Neither becomes a second manifest writer.
8878
+
8879
+ // `positionals()` strips a flag's value only for the flags it knows about, and
8880
+ // these three are new. A local reader keeps the wiki half from having to teach
8881
+ // the global one about every flag any command ever adds.
8882
+ function wikiOpt(name) {
8883
+ for (let i = 0; i < args.length; i++)
8884
+ if (args[i] === name && args[i + 1] !== undefined && !String(args[i + 1]).startsWith("--"))
8885
+ return String(args[i + 1]);
8886
+ return undefined;
8887
+ }
8888
+
8889
+ const WIKI_STOPWORDS = new Set([
8890
+ "the", "a", "an", "and", "or", "for", "to", "of", "in", "on", "at", "is", "it",
8891
+ "this", "that", "with", "our", "my", "we", "i", "add", "need", "want", "please",
8892
+ "feature", "features", "page", "doc", "docs", "wiki", "about", "new", "some",
8893
+ "how", "what", "where", "why", "can", "you", "me", "us", "thing", "things",
8894
+ ]);
8895
+
8896
+ const wikiTerms = (topic) =>
8897
+ [...new Set(String(topic || "").toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length >= 3 && !WIKI_STOPWORDS.has(w)))];
8898
+
8899
+ // A doc is scored on the fields a HUMAN would look at, weighted by how
8900
+ // deliberate each one is. `keywords` exist precisely so a request can be
8901
+ // matched to a page, so they weigh most; a glob in `covers` is the loosest
8902
+ // signal and weighs least.
8903
+ function wikiScoreDoc(row, terms) {
8904
+ const hay = {
8905
+ keywords: (row.keywords || []).join(" ").toLowerCase(),
8906
+ title: String(row.title || "").toLowerCase(),
8907
+ area: String(row.area || "").toLowerCase(),
8908
+ file: String(row.file || "").toLowerCase(),
8909
+ covers: (row.covers || []).join(" ").toLowerCase(),
8910
+ };
8911
+ const W = { keywords: 4, title: 3, area: 3, file: 2, covers: 1 };
8912
+ let score = 0;
8913
+ const why = [];
8914
+ for (const t of terms)
8915
+ for (const [field, text] of Object.entries(hay))
8916
+ if (text.includes(t)) {
8917
+ score += W[field];
8918
+ why.push(`${t} in ${field}`);
8919
+ break; // one field per term — a term is evidence once, not five times
8920
+ }
8921
+ return { score, why };
8922
+ }
8923
+
8924
+ // Cheap, deterministic scope proposal for a NEW doc: paths whose own name
8925
+ // carries a topic term. It is a STARTING POINT for the user to correct, never
8926
+ // an answer — which is why it is capped and labelled as a proposal.
8927
+ function wikiSuggestPaths(root, terms) {
8928
+ if (!terms.length) return [];
8929
+ const raw = gitIn(root, ["ls-files"]);
8930
+ const all = raw ? String(raw).split("\n").map((x) => x.trim()).filter(Boolean) : [];
8931
+ if (!all.length) return [];
8932
+ const dirs = new Map();
8933
+ const files = [];
8934
+ for (const f of all) {
8935
+ const low = f.toLowerCase();
8936
+ if (!terms.some((t) => low.includes(t))) continue;
8937
+ files.push(f);
8938
+ const d = f.split("/").slice(0, -1).join("/");
8939
+ if (d) dirs.set(d, (dirs.get(d) || 0) + 1);
8940
+ }
8941
+ // Prefer a directory that holds several matches over the files themselves:
8942
+ // a coverage area is a place, not a list.
8943
+ const top = [...dirs.entries()].filter(([, n]) => n >= 2).sort((a, b) => b[1] - a[1]).slice(0, 4);
8944
+ const out = top.map(([d, n]) => ({ glob: d + "/**", matches: n }));
8945
+ for (const f of files.slice(0, 6))
8946
+ if (!out.some((o) => f.startsWith(o.glob.replace(/\*\*$/, "")))) out.push({ glob: f, matches: 1 });
8947
+ return out.slice(0, 8);
8948
+ }
8949
+
8950
+ function wikiResolveCmd(claudeDir) {
8951
+ const asJson = wantsJson();
8952
+ const pos = positionals();
8953
+ const topic = (wikiOpt("--topic") || pos.slice(2).join(" ")).trim();
8954
+ if (!topic) {
8955
+ const hint = 'orc wiki resolve needs a topic: orc wiki resolve "remittance feature"';
8956
+ if (asJson) emitJson({ ok: false, reason: "no-topic", hint }, 2);
8957
+ console.error("❌ " + hint);
8958
+ process.exit(2);
8959
+ }
8960
+ // The doc HEADERS, not the freshness rows: `keywords` and `area` are the two
8961
+ // fields a topic actually matches on, and neither survives into per_doc.
8962
+ const paths = wikiPaths(claudeDir);
8963
+ const st = wikiState(claudeDir);
8964
+ if (st.state === "none" || st.state === "corrupt") {
8965
+ const hint =
8966
+ st.state === "none"
8967
+ ? "no wiki yet — run `/orc-wiki` to build one. `resolve` compares a topic against docs that already exist."
8968
+ : "wiki-meta.json is unreadable — run `orc wiki sync` (free) to rebuild it from the docs.";
8969
+ if (asJson) emitJson({ ok: false, reason: st.state === "none" ? "no-wiki" : "corrupt", state: st.state, topic, hint }, 3);
8970
+ console.log(hint);
8971
+ process.exit(3);
8972
+ }
8973
+ const { docs } = readWikiDocs(paths.wikiDir);
8974
+
8975
+ const terms = wikiTerms(topic);
8976
+ const scored = docs
8977
+ .map((d) => {
8978
+ const row = {
8979
+ file: d.rel,
8980
+ area: d.header.area || null,
8981
+ title: (d.text.match(/^#\s+(.+)$/m) || [])[1] || null,
8982
+ keywords: Array.isArray(d.header.keywords) ? d.header.keywords : [],
8983
+ covers: Array.isArray(d.header.covers) ? d.header.covers : [],
8984
+ };
8985
+ return { ...row, ...wikiScoreDoc(row, terms) };
8986
+ })
8987
+ .filter((r) => r.score > 0)
8988
+ .sort((a, b) => b.score - a.score);
8989
+
8990
+ const top = scored[0] || null;
8991
+ const second = scored[1] || null;
8992
+ // A clear winner needs to beat the runner-up, not merely lead it. Two docs a
8993
+ // point apart is the case where guessing costs a scan of the wrong area.
8994
+ let verdict = "new";
8995
+ if (top && (!second || top.score >= second.score * 1.5 + 1)) verdict = "match";
8996
+ else if (top) verdict = "ambiguous";
8997
+
8998
+ const slug = topic.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
8999
+ const payload = {
9000
+ ok: true,
9001
+ topic,
9002
+ terms,
9003
+ verdict,
9004
+ match: verdict === "match" ? top : null,
9005
+ candidates: scored.slice(0, 5),
9006
+ suggested_slug: slug,
9007
+ suggested_covers: verdict === "new" ? wikiSuggestPaths(paths.root, terms) : [],
9008
+ next:
9009
+ verdict === "match"
9010
+ ? `/orc-wiki update ${top.file}`
9011
+ : verdict === "ambiguous"
9012
+ ? "ask which doc this belongs to — two or more cover it about equally"
9013
+ : `/orc-wiki add "${topic}"`,
9014
+ note: "the suggested covers are a STARTING POINT derived from path names, never an answer — the user corrects them before anything is scanned.",
9015
+ };
9016
+ const code = verdict === "match" ? 0 : verdict === "new" ? 1 : 2;
9017
+ if (asJson) emitJson(payload, code);
9018
+
9019
+ console.log(`\norc wiki resolve — "${topic}"\n`);
9020
+ if (!terms.length) console.log(ui.color.gray(" no searchable terms in that topic"));
9021
+ if (verdict === "match")
9022
+ console.log(` ${ui.color.green("MATCH")} ${top.file}\n ${ui.color.gray(top.why.slice(0, 4).join(" · "))}`);
9023
+ else if (verdict === "ambiguous") {
9024
+ console.log(` ${ui.color.yellow("AMBIGUOUS")} — ${plural(scored.length, "doc")} cover this about equally\n`);
9025
+ for (const c of scored.slice(0, 5)) console.log(` ${String(c.score).padStart(3)} ${c.file} ${ui.color.gray(c.why.slice(0, 3).join(" · "))}`);
9026
+ } else {
9027
+ console.log(` ${ui.color.cyan("NEW")} nothing on disk covers this.`);
9028
+ if (payload.suggested_covers.length) {
9029
+ console.log("\n " + ui.color.gray("a starting point, from path names only — correct it before anything is scanned:"));
9030
+ for (const s of payload.suggested_covers) console.log(` ${s.glob} ${ui.color.gray(s.matches + " file(s)")}`);
9031
+ }
9032
+ }
9033
+ console.log("\n next: " + payload.next + "\n");
9034
+ process.exit(code);
9035
+ }
9036
+
9037
+ const WIKI_DOC_TYPES = ["feature", "reference", "architecture"];
9038
+
9039
+ function wikiAddCmd(claudeDir) {
9040
+ const asJson = wantsJson();
9041
+ const pos = positionals();
9042
+ const slugRaw = String(pos[2] || "").trim();
9043
+ const paths = wikiPaths(claudeDir);
9044
+ const fail = (reason, hint, code = 2) => {
9045
+ if (asJson) emitJson({ ok: false, reason, hint }, code);
9046
+ console.error("❌ " + hint);
9047
+ process.exit(code);
9048
+ };
9049
+ if (!slugRaw) return fail("no-slug", 'orc wiki add needs a slug: orc wiki add remittance --title "Remittance" --covers "src/remittance/**"');
9050
+ const slug = slugRaw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
9051
+ if (!slug) return fail("bad-slug", `"${slugRaw}" has no usable characters for a slug.`);
9052
+
9053
+ const type = String(wikiOpt("--type") || "feature").toLowerCase();
9054
+ if (!WIKI_DOC_TYPES.includes(type)) return fail("bad-type", `--type must be one of: ${WIKI_DOC_TYPES.join(", ")}`);
9055
+ const title = String(wikiOpt("--title") || slug.replace(/-/g, " ")).trim();
9056
+
9057
+ // `--covers` may be repeated, or comma-separated. A reservation with no
9058
+ // coverage is a doc nothing can ever mark stale, so it is refused.
9059
+ const covers = [];
9060
+ for (let i = 0; i < args.length; i++)
9061
+ if (args[i] === "--covers" && args[i + 1] && !String(args[i + 1]).startsWith("--"))
9062
+ for (const g of String(args[i + 1]).split(",")) if (g.trim()) covers.push(g.trim());
9063
+ if (!covers.length)
9064
+ return fail("no-covers", 'orc wiki add needs --covers "<glob>" (repeatable). A doc with no coverage can never be marked stale, so it is not a doc.');
9065
+
9066
+ if (!fs.existsSync(paths.wikiDir))
9067
+ return fail("no-wiki", "no wiki/ directory — `orc wiki add` reserves a row in a wiki that exists. Run `/orc-wiki` to build one first.", 3);
9068
+
9069
+ const rel = `wiki/orc-${type}-${slug}${type === "feature" ? "-overview" : ""}.md`;
9070
+ const abs = path.join(paths.root, ...rel.split("/"));
9071
+ if (fs.existsSync(abs))
9072
+ return fail("exists", `${rel} already exists — refresh it instead: /orc-wiki update ${rel}`, 1);
9073
+
9074
+ // A STUB, not a document. It carries `status: reserved` so every consumer can
9075
+ // tell it apart from a scanned doc, and its first prose line becomes the
9076
+ // INDEX.md description — so a sync that lands between the reservation and the
9077
+ // scan says RESERVED rather than showing an empty row that reads as a real
9078
+ // page. A doc that lies is worse than a doc that is missing.
9079
+ const body = [
9080
+ "---",
9081
+ "wiki_schema: 2",
9082
+ `doc_type: ${type}`,
9083
+ `area: ${slug}`,
9084
+ `covers: [${covers.join(", ")}]`,
9085
+ "keywords: []",
9086
+ "status: reserved",
9087
+ "---",
9088
+ "",
9089
+ `# ${title}`,
9090
+ "",
9091
+ "RESERVED by `orc wiki add` — no scan has run yet, so nothing below is evidence.",
9092
+ "",
9093
+ "The scan-task writes this body and replaces `status: reserved` with a real",
9094
+ "`scanned_at` / `scanned_commit` / `covered_files` set. Until then this row is",
9095
+ "a placeholder, and `orc wiki refs` reports it as outstanding.",
9096
+ "",
9097
+ ].join("\n");
9098
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
9099
+ fs.writeFileSync(abs, body);
9100
+
9101
+ const payload = {
9102
+ ok: true,
9103
+ reserved: rel,
9104
+ path: abs,
9105
+ doc_type: type,
9106
+ area: slug,
9107
+ title,
9108
+ covers,
9109
+ next: "scan the area, write the body, then `orc wiki refs` (free) to re-derive every reference",
9110
+ note: "this is a RESERVATION, not a doc. It is registered only when `orc wiki sync` next runs, and it reports as outstanding until a scan replaces the body.",
9111
+ };
9112
+ if (asJson) emitJson(payload, 0);
9113
+ console.log(`\n✓ reserved ${rel}`);
9114
+ console.log(ui.color.gray(` covers: ${covers.join(", ")}`));
9115
+ console.log(ui.color.gray(" no scan has run — the body is a placeholder until one does"));
9116
+ console.log(ui.color.gray(" next: " + payload.next) + "\n");
9117
+ process.exit(0);
9118
+ }
9119
+
9120
+ // The DERIVED-REFERENCE sweep. After ONE doc is written, these surfaces are
9121
+ // derived from the doc SET and are now behind it. Before this command they were
9122
+ // repaired by memory, one at a time, and the ones nobody remembered stayed
9123
+ // wrong — which is the failure a single-doc update makes most likely, because
9124
+ // the run is small enough that the sweep feels unnecessary.
9125
+ function wikiRefsCmd(claudeDir) {
9126
+ const asJson = wantsJson();
9127
+ const checkOnly = flag("--check") === true;
9128
+ const paths = wikiPaths(claudeDir);
9129
+ const st = wikiState(claudeDir);
9130
+ if (st.state === "none") {
9131
+ const hint = "no wiki — nothing is derived from it yet. Run `/orc-wiki` to build one.";
9132
+ if (asJson) emitJson({ ok: false, reason: "no-wiki", state: st.state, hint, items: [] }, 2);
9133
+ console.log(hint);
9134
+ process.exit(2);
9135
+ }
9136
+
9137
+ const items = [];
9138
+ const add = (id, state, what, fix, cost) => items.push({ id, state, what, fix, cost });
9139
+
9140
+ // 1. Registration — the only surface this command REPAIRS, because sync is
9141
+ // free, derived and already the single writer.
9142
+ let registration = st.state;
9143
+ if (st.state === "registered") add("registration", "clean", "wiki-meta.json and INDEX.md match the docs on disk", null, "free");
9144
+ else if (checkOnly)
9145
+ add("registration", "drifted", `registration is ${st.state.toUpperCase()}`, "orc wiki sync", "free");
9146
+ else {
9147
+ try {
9148
+ const r = buildRegistration(claudeDir);
9149
+ fs.writeFileSync(paths.meta, JSON.stringify(r.meta, null, 2) + "\n");
9150
+ fs.writeFileSync(path.join(paths.wikiDir, "INDEX.md"), r.index);
9151
+ registration = "registered";
9152
+ add("registration", "repaired", `registration was ${st.state.toUpperCase()} — re-derived from the doc headers`, null, "free");
9153
+ } catch (e) {
9154
+ add("registration", "drifted", `registration is ${st.state.toUpperCase()} and could not be rebuilt: ${e.message}`, "orc wiki sync", "free");
9155
+ }
9156
+ }
9157
+
9158
+ const { docs } = readWikiDocs(paths.wikiDir);
9159
+ const meta = readMetaAt(paths.meta).meta || {};
9160
+ const registered = Array.isArray(meta.docs) ? meta.docs : [];
9161
+
9162
+ // 2. Reserved rows — a reservation that never got its scan is the one way
9163
+ // this new flow can leave the wiki worse than it found it.
9164
+ const reserved = docs.filter((d) => String(d.header.status || "") === "reserved").map((d) => d.rel);
9165
+ if (reserved.length)
9166
+ add("reserved", "outstanding", `${plural(reserved.length, "reserved row")} with no scan behind it: ${reserved.join(", ")}`,
9167
+ `/orc-wiki update ${reserved[0]}`, "money");
9168
+ else add("reserved", "clean", "no reserved rows waiting for a scan", null, "free");
9169
+
9170
+ // 3. Orientation — DERIVED, read first by every consumer, and free to
9171
+ // regenerate. Stale when any doc was scanned after it was.
9172
+ const newest = docs.reduce((a, d) => {
9173
+ const t = parseScannedAt(d.header.scanned_at);
9174
+ return t && (!a || t > a) ? t : a;
9175
+ }, null);
9176
+ const orient = docs.find((d) => /orc-orientation\.md$/.test(d.rel));
9177
+ if (!orient)
9178
+ add("orientation", "missing", "wiki/orc-orientation.md does not exist — it is the page every consumer reads first",
9179
+ "/orc-wiki refresh wiki/orc-orientation.md", "free");
9180
+ else {
9181
+ const ot = parseScannedAt(orient.header.scanned_at);
9182
+ if (newest && ot && newest > ot)
9183
+ add("orientation", "behind", "wiki/orc-orientation.md is older than the newest doc, so its reading order predates it",
9184
+ "/orc-wiki refresh wiki/orc-orientation.md", "free");
9185
+ else add("orientation", "clean", "wiki/orc-orientation.md is no older than the newest doc", null, "free");
9186
+ }
9187
+
9188
+ // 4. Architecture overview — OPTIONAL output. A small wiki may legitimately
9189
+ // have none, so its absence is reported as `absent`, never as drift.
9190
+ const archRel = "wiki/orc-architecture-overview.md";
9191
+ const archAbs = path.join(paths.root, ...archRel.split("/"));
9192
+ if (!fs.existsSync(archAbs)) add("architecture", "absent", "no architecture overview — optional, and a small wiki may not need one", null, "free");
9193
+ else {
9194
+ const arch = fs.readFileSync(archAbs, "utf8");
9195
+ const missing = registered
9196
+ .filter((d) => !/orc-orientation\.md$/.test(d.file) && d.file !== archRel)
9197
+ .filter((d) => !arch.includes(d.file) && !(d.area && arch.toLowerCase().includes(String(d.area).toLowerCase())))
9198
+ .map((d) => d.file);
9199
+ if (missing.length)
9200
+ add("architecture", "behind", `${archRel} names neither the file nor the area of: ${missing.join(", ")}`,
9201
+ "/orc-wiki refresh " + archRel, "free");
9202
+ else add("architecture", "clean", `${archRel} names every registered area`, null, "free");
9203
+ }
9204
+
9205
+ // 5. The CLAUDE.md pointer block — it carries a doc COUNT, so a doc added or
9206
+ // retired makes it say something measurably untrue.
9207
+ const cmAbs = path.join(paths.root, "CLAUDE.md");
9208
+ if (!fs.existsSync(cmAbs)) add("claude-md", "missing", "no CLAUDE.md — future sessions are never told the wiki exists", "/orc-wiki (the pointer-injection step)", "free");
9209
+ else {
9210
+ const cm = fs.readFileSync(cmAbs, "utf8");
9211
+ const m = /<!--\s*ORC-WIKI:START[\s\S]*?<!--\s*ORC-WIKI:END\s*-->/.exec(cm);
9212
+ if (!m) add("claude-md", "missing", "CLAUDE.md has no ORC-WIKI pointer block", "/orc-wiki (the pointer-injection step)", "free");
9213
+ else {
9214
+ const said = /·\s*(\d+)\s*docs?/.exec(m[0]);
9215
+ const n = said ? Number(said[1]) : null;
9216
+ if (n !== null && n !== registered.length)
9217
+ add("claude-md", "behind", `the pointer block says ${plural(n, "doc")}; ${plural(registered.length, "doc")} are registered`,
9218
+ "/orc-wiki (the pointer-injection step)", "free");
9219
+ else add("claude-md", "clean", "the pointer block's doc count matches the registry", null, "free");
9220
+ }
9221
+ }
9222
+
9223
+ // 6. Dead crosslink tags — a tag whose anchor file is gone. NEVER deleted
9224
+ // here: retiring a tag is a per-tag offer in the lane, and a refresh has
9225
+ // never been allowed to bulk-delete that folder.
9226
+ const tags = (readCrosslinkProvided(paths).list || []).filter((t) => {
9227
+ const f = String(t.anchor || "").split(":")[0].trim();
9228
+ return f && !fs.existsSync(path.join(paths.root, ...f.split("/")));
9229
+ });
9230
+ if (tags.length)
9231
+ add("crosslink", "dead", `${plural(tags.length, "crosslink tag")} anchored to a file that no longer exists`,
9232
+ "/orc-wiki (the dead-tag sweep — offered per tag, never automatic)", "free");
9233
+ else add("crosslink", "clean", "every crosslink tag's anchor still exists", null, "free");
9234
+
9235
+ const open = items.filter((i) => i.state !== "clean" && i.state !== "absent" && i.state !== "repaired");
9236
+ const repaired = items.filter((i) => i.state === "repaired");
9237
+ const code = open.length ? 1 : repaired.length ? 1 : 0;
9238
+
9239
+ if (asJson)
9240
+ emitJson(
9241
+ {
9242
+ ok: true,
9243
+ mode: checkOnly ? "check" : "repair",
9244
+ registration,
9245
+ items,
9246
+ open: open.map((i) => i.id),
9247
+ repaired: repaired.map((i) => i.id),
9248
+ clean: open.length === 0,
9249
+ note: "only registration is repaired here, because `orc wiki sync` is free and already the single writer of wiki-meta.json and INDEX.md. Everything else is REPORTED with its command — a sweep that silently regenerated prose would be spending money nobody asked it to spend.",
9250
+ },
9251
+ code
9252
+ );
9253
+
9254
+ console.log(`\norc wiki refs — ${checkOnly ? "check" : "repair"} ${ui.color.gray(plural(registered.length, "registered doc"))}\n`);
9255
+ for (const i of items) {
9256
+ const c = i.state === "clean" || i.state === "repaired" ? ui.color.green : i.state === "absent" ? ui.color.gray : ui.color.yellow;
9257
+ console.log(` ${c(i.state.padEnd(11))} ${ui.color.cyan(i.id.padEnd(13))} ${i.what}`);
9258
+ if (i.fix) console.log(` ${"".padEnd(11)} ${ui.color.gray("→ " + i.fix + " (" + i.cost + ")")}`);
9259
+ }
9260
+ console.log(
9261
+ "\n " +
9262
+ ui.color.gray(
9263
+ open.length
9264
+ ? `${plural(open.length, "surface")} still to do — every free one first.`
9265
+ : "every derived reference is current."
9266
+ ) +
9267
+ "\n"
9268
+ );
9269
+ process.exit(code);
9270
+ }
9271
+
8810
9272
  function wikiDocsCmd(claudeDir) {
8811
9273
  const asJson = wantsJson();
8812
9274
  const d = wikiDocRows(claudeDir);
@@ -9016,6 +9478,18 @@ function wiki() {
9016
9478
  case "show":
9017
9479
  wikiShowCmd(claudeDir, pos[2]);
9018
9480
  break;
9481
+ // v1.7.0 — one doc at a time. `resolve` and `refs` are READS; `add` writes a
9482
+ // RESERVATION stub and nothing else. `orc wiki sync` stays the only writer
9483
+ // of wiki-meta.json and INDEX.md.
9484
+ case "resolve":
9485
+ wikiResolveCmd(claudeDir);
9486
+ break;
9487
+ case "add":
9488
+ wikiAddCmd(claudeDir);
9489
+ break;
9490
+ case "refs":
9491
+ wikiRefsCmd(claudeDir);
9492
+ break;
9019
9493
  case "coverage":
9020
9494
  wikiCoverage(claudeDir);
9021
9495
  break;
@@ -9039,7 +9513,14 @@ function wiki() {
9039
9513
  " orc wiki show <doc> [--body] ONE doc: header, coverage, tags, usage, its free repairs\n" +
9040
9514
  " (exit 0 / 2 unreadable / 3 unknown doc)\n" +
9041
9515
  " orc wiki coverage [--json] % of tracked files covered by >=1 doc, and the uncovered\n" +
9042
- " set by directory. A REPORT, never a gate (exit 0 full / 1 gaps)"
9516
+ " set by directory. A REPORT, never a gate (exit 0 full / 1 gaps)\n" +
9517
+ " orc wiki resolve <topic> does a doc already cover this? (exit 0 match / 1 new /\n" +
9518
+ " 2 ambiguous / 3 no wiki). Free, no scan\n" +
9519
+ " orc wiki add <slug> --title <t> --covers <glob> RESERVE a doc row for a new topic.\n" +
9520
+ " Writes a stub, never scans (exit 0 / 1 exists / 2 bad args)\n" +
9521
+ " orc wiki refs [--check] the DERIVED-REFERENCE sweep after ONE doc changes:\n" +
9522
+ " registration, reserved rows, orientation, architecture,\n" +
9523
+ " the CLAUDE.md pointer, dead tags (exit 0 clean / 1 work)"
9043
9524
  );
9044
9525
  process.exit(1);
9045
9526
  }
@@ -42391,6 +42872,1111 @@ function slSaveAndCompile(claudeDir, layout, info, skipWrite) {
42391
42872
  }
42392
42873
 
42393
42874
 
42875
+ // ── orc rules (v1.7.0) — the anti-slop rule surface ─────────────────────────
42876
+ //
42877
+ // A SECOND rule surface, beside the code house rules, and the two never mix.
42878
+ // It has two halves, written by different people, living in different places:
42879
+ //
42880
+ // ORC RULES templates/skills/_shared/rules/*.md → .claude/skills/_shared/rules/
42881
+ // SHIPPED and READ-ONLY. Changes with `orc update`, never with a
42882
+ // command. `orc rules set --pack …` is refused BY NAME, because a
42883
+ // command that silently does nothing is worse than one that says
42884
+ // what replaced it.
42885
+ //
42886
+ // USER RULES .claude/orc/rules.md
42887
+ // The project's own. Plain text, three priority headings, as much
42888
+ // text under each as the user wants — the `orc doc rules` design,
42889
+ // reused without change, because that argument was already had: a
42890
+ // standing instruction is prose, not a form, and nobody's real P0
42891
+ // fits on one line.
42892
+ //
42893
+ // PRECEDENCE, printed everywhere rather than documented once:
42894
+ //
42895
+ // 1. project CODE house rules (_shared/phases/house-rules.md + CLAUDE.md P0)
42896
+ // scope: CODE and agent BEHAVIOUR only. It says nothing about prose, so it
42897
+ // never overrules a writing rule — it does not speak about prose at all.
42898
+ // 2. USER rules — they beat the ORC rules OUTRIGHT on any conflict.
42899
+ // 3. ORC rules.
42900
+ //
42901
+ // An override is NEVER silent. The CLI counts the ones a user NAMED by id, and
42902
+ // says that is what it counted; a conflict nobody named is found by the agent at
42903
+ // dispatch time and comes back as `rules_conflicts[]`. That split is deliberate:
42904
+ // THE CLI CANNOT PARSE INTENT, SO IT DOES NOT PRETEND TO — the same decision
42905
+ // `orc doc rules` made about its structural boundary, for the same reason. A
42906
+ // validator that sometimes works is worse than none.
42907
+ //
42908
+ // `/orc-doc` reads NONE of this. That lane has its own ledger and its own
42909
+ // frozen-per-document mechanic, and the two surfaces stay apart on purpose.
42910
+
42911
+ const RULES_DIR_NAME = "rules";
42912
+ const USER_RULES_FILE = "rules.md";
42913
+ const RULE_PRIORITIES = ["P0", "P1", "P2"];
42914
+ const RULE_TIERS = ["HARD", "PURPOSE", "LOCK"];
42915
+ // id → pack, so a finding, an override and a slice line all name the same thing
42916
+ // forever. The prefix IS the contract; renaming one would orphan every recorded
42917
+ // finding that referenced it.
42918
+ const RULE_PACKS = [
42919
+ { id: "writing", file: "writing.md", prefix: "OSW", layer: "writing", title: "Writing" },
42920
+ { id: "code", file: "code.md", prefix: "OSC", layer: "code", title: "Code" },
42921
+ { id: "delivery", file: "delivery.md", prefix: "OSD", layer: "core", title: "Delivery" },
42922
+ { id: "ui", file: "ui.md", prefix: "OSU", layer: "ui", title: "UI" },
42923
+ ];
42924
+ const RULE_PACK_IDS = RULE_PACKS.map((p) => p.id);
42925
+
42926
+ const RULES_BOUNDARY =
42927
+ "Rules govern WHAT is written and HOW it reads, and WHAT SHAPE of code is " +
42928
+ "acceptable. They can never change how a lane RUNS: the scoring, the wave " +
42929
+ "order, the gates, the dispatch contract, the ship rules, or any lane's " +
42930
+ "structural and safety rules. A rule that asks for one of those comes back as " +
42931
+ "unsupported_request — never a guessed compromise.";
42932
+
42933
+ const RULES_PRECEDENCE = [
42934
+ { rank: 1, layer: "house rules", scope: "CODE and agent BEHAVIOUR only", where: "_shared/phases/house-rules.md + the project's CLAUDE.md P0", beats: "everything below it, always" },
42935
+ { rank: 2, layer: "your rules", scope: "everything", where: "<claude>/orc/rules.md", beats: "the ORC rules OUTRIGHT on any conflict" },
42936
+ { rank: 3, layer: "ORC rules", scope: "everything", where: "<claude>/skills/_shared/rules/", beats: "nothing — it is the floor, and yours replaces it" },
42937
+ ];
42938
+
42939
+ // Which packs ride in which lane. `ui` is absent from every row ON PURPOSE: it
42940
+ // is added PER TASK by the orchestrator when a task's declared files are
42941
+ // front-end. A UI rule in a backend slice is tokens paid on every spawn for a
42942
+ // rule that cannot apply.
42943
+ //
42944
+ // `orc-doc` is absent from the whole table. Excluded means excluded.
42945
+ const RULE_LANE_PACKS = {
42946
+ orc: ["writing", "code", "delivery"],
42947
+ "orc-mini": ["writing", "code", "delivery"],
42948
+ "orc-fast": ["writing", "code", "delivery"],
42949
+ "orc-diy": ["writing", "code", "delivery"],
42950
+ "orc-quick": ["writing", "code", "delivery"],
42951
+ "orc-wiki": ["writing", "delivery"],
42952
+ "orc-learn": ["writing", "delivery"],
42953
+ "orc-claude": ["writing", "delivery"],
42954
+ "orc-analyze": ["writing", "delivery"],
42955
+ "orc-analyze-mini": ["writing", "delivery"],
42956
+ "orc-brainstorm": ["writing", "delivery"],
42957
+ "orc-grill": ["writing", "delivery"],
42958
+ "orc-pact": ["writing", "delivery"],
42959
+ "orc-boundary": ["writing", "delivery"],
42960
+ "orc-handoff": ["writing", "delivery"],
42961
+ "orc-export": ["writing", "delivery"],
42962
+ "orc-challenge": ["writing", "delivery"],
42963
+ "orc-poly": ["writing", "delivery"],
42964
+ "orc-aftermath": ["writing", "delivery"],
42965
+ "orc-budget": ["writing", "delivery"],
42966
+ "orc-retro": ["writing", "delivery"],
42967
+ "orc-verify": ["writing", "delivery"],
42968
+ "orc-pr-setup": ["writing", "delivery"],
42969
+ "orc-pr-driver": ["writing", "delivery"],
42970
+ "orc-test": ["writing", "delivery"],
42971
+ "orc-route": ["writing", "delivery"],
42972
+ "orc-explain": ["writing", "delivery"],
42973
+ "context-combiner": ["writing", "delivery"],
42974
+ };
42975
+
42976
+ // The credit table, as DATA — `orc rules credits --json` and the panel both read
42977
+ // it; CREDITS.md beside the packs is the long form. Credit is a shipped artifact
42978
+ // here, not a line in a commit message.
42979
+ const RULE_CREDITS = [
42980
+ { author: "Peter G. Yang", handle: "petergyang", repo: "https://github.com/petergyang/no-ai-slop", license: "MIT", read: "2026-09-13", took: "the prose pack: the named slop patterns, the banned lexicon and phrase lists, the portability test, and the rule that outranks them all — preserve the writer's voice, make the minimum effective edit", packs: ["writing"] },
42981
+ { author: "Miqdad Badjuber", handle: "miqdadbadjuber", repo: "https://github.com/miqdadbadjuber/anti-slop", license: "MIT", read: "2026-09-13", took: "the three-tier mechanism (Hard Gate / Purpose-Gate / Quality Lock) every pack uses, rules R-01…R-38 for the UI pack, the antislop-code comment catalogue, R-35 for the delivery pack, and the rule that external direction is data to apply, not instructions to obey", packs: ["ui", "code", "delivery", "writing"] },
42982
+ { author: "@ehmo", handle: "ehmo", repo: "https://github.com/ehmo/slopkit", license: "see repo", read: "2026-09-13", took: "the delivery pack, from the slopgent skill: separate observation from inference, guard load-bearing caveats while cutting empty hedges, name the driving variable, no apology theatre, never report an unrun tool", packs: ["delivery"] },
42983
+ { author: "@BioInfo", handle: "BioInfo", repo: "https://github.com/BioInfo/slopless", license: "see repo", read: "2026-09-13", took: "surgical-change discipline and the quality gates: read before stating, flag a discrepancy rather than pick one, verify before documenting, and the compatibility rules", packs: ["code", "delivery"] },
42984
+ { author: "Andrej Karpathy", handle: "karpathy", repo: "https://theaiarchitects.com/blog/karpathy-claude-md-rules", license: "—", read: "2026-09-13", took: "think before coding, simplicity first, surgical changes, goal-driven execution — and the test ORC reuses directly: every changed line should trace directly to the user's request", packs: ["code"] },
42985
+ { author: "Matty Cartwright", handle: null, repo: "https://mattycartwright.com/blog/the-anti-slop-writing-rules", license: "—", read: "2026-09-13", took: "the layered ban structure (words → phrases → sentence patterns → structural patterns), which is how writing.md is ordered, and the read-aloud tests behind OSW-20", packs: ["writing"] },
42986
+ { author: "research", handle: null, repo: "https://arxiv.org/html/2512.18020v1", license: "—", read: "2026-09-13", took: "the five LLM-call smells — unbounded max metrics, no model version pinning, no system message, no structured output, temperature not set — which are OSC-20", packs: ["code"] },
42987
+ { author: "research", handle: null, repo: "https://arxiv.org/html/2605.02741v1", license: "—", read: "2026-09-13", took: "the Reasoning-Complexity Paradox (OSC-15) and the Modular Mirage (OSC-16), plus the God-class finding (OSC-17)", packs: ["code"] },
42988
+ { author: "research", handle: null, repo: "https://arxiv.org/pdf/2510.03029", license: "—", read: "2026-09-13", took: "over-commenting, defensive checks for impossible conditions, and redundant reimplementation — behind OSC-01, OSC-11 and OSC-18", packs: ["code"] },
42989
+ ];
42990
+
42991
+ // The packs are PACKAGE content, so they are read the way `_shared/phases/` is:
42992
+ // the INSTALLED copy first, because that is the one the agent will actually
42993
+ // open, and the package's own tree as the floor so a global install with no
42994
+ // project still answers.
42995
+ function rulesRoot(claudeDir) {
42996
+ const installed = path.join(claudeDir, "skills", "_shared", RULES_DIR_NAME);
42997
+ if (fs.existsSync(path.join(installed, "INDEX.md"))) return { dir: installed, source: "installed" };
42998
+ return { dir: path.join(SRC_SKILLS, "_shared", RULES_DIR_NAME), source: "package" };
42999
+ }
43000
+
43001
+ // `### OSW-01 · HARD · Lead with the point`, then everything until the next
43002
+ // heading. The middle dot is the separator because no rule title contains one.
43003
+ const RULE_HEAD_RE = /^###[ \t]+(OS[WCDU]-\d{2})[ \t]*[·|][ \t]*(HARD|PURPOSE|LOCK)[ \t]*[·|][ \t]*(.+?)[ \t]*$/;
43004
+
43005
+ function rulesParsePack(text, pack) {
43006
+ const rows = [];
43007
+ let cur = null;
43008
+ const flush = () => {
43009
+ if (!cur) return;
43010
+ cur.body = cur.body.join("\n").replace(/^(?:[ \t]*\n)+/, "").replace(/\s+$/, "");
43011
+ rows.push(cur);
43012
+ cur = null;
43013
+ };
43014
+ for (const raw of String(text || "").replace(/\r\n/g, "\n").split("\n")) {
43015
+ const m = RULE_HEAD_RE.exec(raw);
43016
+ if (m) {
43017
+ flush();
43018
+ cur = { id: m[1], tier: m[2], title: m[3], pack: pack.id, prefix: pack.prefix, body: [] };
43019
+ continue;
43020
+ }
43021
+ // A `## ` heading or a horizontal rule ends the rule list — that is the
43022
+ // pack's trailing "Not a ban" section, which is prose ABOUT the rules, not
43023
+ // a rule. Without the `---` case the separator bled into the last rule's
43024
+ // body and rode into every slice.
43025
+ if (/^##[ \t]/.test(raw) || /^-{3,}[ \t]*$/.test(raw)) flush();
43026
+ else if (cur) cur.body.push(raw);
43027
+ }
43028
+ flush();
43029
+ return rows;
43030
+ }
43031
+
43032
+ function rulesReadPacks(claudeDir) {
43033
+ const root = rulesRoot(claudeDir);
43034
+ const packs = [];
43035
+ let all = [];
43036
+ for (const p of RULE_PACKS) {
43037
+ const file = path.join(root.dir, p.file);
43038
+ let text = null;
43039
+ try {
43040
+ text = fs.readFileSync(file, "utf8");
43041
+ } catch (_) {}
43042
+ const rules = text === null ? [] : rulesParsePack(text, p);
43043
+ packs.push({
43044
+ ...p,
43045
+ file,
43046
+ readable: text !== null,
43047
+ count: rules.length,
43048
+ tiers: RULE_TIERS.reduce((a, t) => ((a[t] = rules.filter((r) => r.tier === t).length), a), {}),
43049
+ rules,
43050
+ });
43051
+ all = all.concat(rules);
43052
+ }
43053
+ return {
43054
+ dir: root.dir,
43055
+ source: root.source,
43056
+ readable: packs.every((p) => p.readable),
43057
+ packs,
43058
+ rules: all,
43059
+ count: all.length,
43060
+ credits_file: path.join(root.dir, "CREDITS.md"),
43061
+ };
43062
+ }
43063
+
43064
+ // ── the user ledger — the `orc doc rules` file format, reused verbatim ──────
43065
+
43066
+ const userRulesPath = (claudeDir) => path.join(claudeDir, "orc", USER_RULES_FILE);
43067
+ const userRulesEmpty = () => ({ P0: "", P1: "", P2: "" });
43068
+
43069
+ // With or without `#`s, with or without a colon: a config a human types by hand
43070
+ // must not fail on a plausible spelling of the only structure it has.
43071
+ const USER_RULES_HEAD_RE = /^[ \t]*#{0,6}[ \t]*(P[012])[ \t]*[:.)-]?[ \t]*$/;
43072
+
43073
+ const USER_RULES_PREAMBLE = [
43074
+ "# ORC · project rules",
43075
+ "#",
43076
+ "# This project's own standing instructions — about the words ORC writes and",
43077
+ "# the shape of the code it writes. They are read BEFORE ORC's own anti-slop",
43078
+ "# rules, and THEY WIN wherever the two disagree.",
43079
+ "#",
43080
+ "# Put each one under the heading you want it read at — P0 first, then P1,",
43081
+ "# then P2 — in as many lines as you like. There is no one-line rule and no",
43082
+ "# rule count: the whole block is handed to every agent VERBATIM.",
43083
+ "#",
43084
+ "# To switch an ORC rule off, name its id in your own rule:",
43085
+ "# OSW-13: em dashes are our house voice. Use them freely.",
43086
+ "#",
43087
+ "# Anything above the first `## P0` heading is a note to yourself and is never",
43088
+ "# dispatched. Edit this file directly, or in `orc ui` ▸ Rules.",
43089
+ ].join("\n");
43090
+
43091
+ function userRulesParse(text) {
43092
+ const acc = { P0: [], P1: [], P2: [] };
43093
+ const pre = [];
43094
+ let cur = null;
43095
+ for (const raw of String(text == null ? "" : text).replace(/\r\n/g, "\n").split("\n")) {
43096
+ const m = USER_RULES_HEAD_RE.exec(raw);
43097
+ if (m) {
43098
+ cur = m[1];
43099
+ continue;
43100
+ }
43101
+ if (cur) acc[cur].push(raw);
43102
+ else pre.push(raw);
43103
+ }
43104
+ // VERBATIM inside a block: only the blank lines the headings themselves
43105
+ // introduce are trimmed. A user's indentation, bullets and blank lines are
43106
+ // theirs — the `context.md` rule, applied to a config file.
43107
+ const body = (rows) => rows.join("\n").replace(/^(?:[ \t]*\n)+/, "").replace(/\s+$/, "");
43108
+ return {
43109
+ preamble: pre.join("\n").replace(/\s+$/, ""),
43110
+ blocks: { P0: body(acc.P0), P1: body(acc.P1), P2: body(acc.P2) },
43111
+ };
43112
+ }
43113
+
43114
+ function userRulesRender(preamble, blocks) {
43115
+ const L = [String(preamble || "").replace(/\s+$/, "") || USER_RULES_PREAMBLE, ""];
43116
+ for (const pr of RULE_PRIORITIES) {
43117
+ L.push("## " + pr, "");
43118
+ const body = String((blocks || {})[pr] || "").replace(/\s+$/, "");
43119
+ if (body) L.push(body, "");
43120
+ }
43121
+ return L.join("\n");
43122
+ }
43123
+
43124
+ function userRulesRead(claudeDir) {
43125
+ let text = null;
43126
+ try {
43127
+ text = fs.readFileSync(userRulesPath(claudeDir), "utf8");
43128
+ } catch (_) {}
43129
+ const parsed = userRulesParse(text === null ? USER_RULES_PREAMBLE : text);
43130
+ const preamble = parsed.preamble || USER_RULES_PREAMBLE;
43131
+ return {
43132
+ path: userRulesPath(claudeDir),
43133
+ exists: text !== null,
43134
+ preamble,
43135
+ blocks: parsed.blocks,
43136
+ text: userRulesRender(preamble, parsed.blocks),
43137
+ empty: RULE_PRIORITIES.every((k) => !parsed.blocks[k]),
43138
+ };
43139
+ }
43140
+
43141
+ // ONE WRITER, and it is this function, reached only from `orc rules`.
43142
+ function userRulesWrite(claudeDir, preamble, blocks) {
43143
+ const p = userRulesPath(claudeDir);
43144
+ fs.mkdirSync(path.dirname(p), { recursive: true });
43145
+ const text = userRulesRender(preamble, blocks);
43146
+ fs.writeFileSync(p, text);
43147
+ return { path: p, text };
43148
+ }
43149
+
43150
+ const userRulesCount = (block) => String(block || "").split("\n").filter((l) => l.trim()).length;
43151
+ const userRulesCounts = (blocks) =>
43152
+ RULE_PRIORITIES.reduce((a, k) => ((a[k] = userRulesCount((blocks || {})[k])), a), {});
43153
+
43154
+ // OVERRIDES, counted the only honest way: an ORC rule id the user NAMED. The
43155
+ // CLI cannot read intent, so it never guesses that a sentence contradicts a
43156
+ // rule — it reports what was named, says so in the same breath, and leaves the
43157
+ // rest to the agent, which returns `rules_conflicts[]` at dispatch time.
43158
+ const RULE_ID_RE = /\bOS[WCDU]-\d{2}\b/g;
43159
+
43160
+ function rulesOverrides(blocks, packs) {
43161
+ const byId = new Map(packs.rules.map((r) => [r.id, r]));
43162
+ const out = [];
43163
+ const seen = new Set();
43164
+ for (const pr of RULE_PRIORITIES)
43165
+ for (const line of String((blocks || {})[pr] || "").split("\n"))
43166
+ for (const id of line.match(RULE_ID_RE) || []) {
43167
+ if (seen.has(id)) continue;
43168
+ seen.add(id);
43169
+ const r = byId.get(id);
43170
+ out.push({ id, priority: pr, line: line.trim(), known: !!r, tier: r ? r.tier : null, title: r ? r.title : null });
43171
+ }
43172
+ return out;
43173
+ }
43174
+
43175
+ // The ONE line the preflight prints. A rule set is NEVER silent, and an empty
43176
+ // user ledger still gets its half of the line.
43177
+ function rulesLine(packs, blocks, overrides) {
43178
+ const c = userRulesCounts(blocks);
43179
+ const total = RULE_PRIORITIES.reduce((a, k) => a + c[k], 0);
43180
+ const packBit = `ORC ${packs.count} (` + packs.packs.map((p) => `${p.prefix[2]} ${p.count}`).join(" · ") + ")";
43181
+ const mine = total
43182
+ ? `yours ${plural(total, "line")} (${RULE_PRIORITIES.filter((k) => c[k]).map((k) => `${k} ${c[k]}`).join(" · ")})`
43183
+ : "yours none";
43184
+ const ov = overrides && overrides.length ? ` · ${plural(overrides.length, "override")}` : "";
43185
+ return `${packBit} · ${mine}${ov}`;
43186
+ }
43187
+
43188
+ // ── the slice — the ONLY assembler ─────────────────────────────────────────
43189
+ //
43190
+ // No skill builds this text. One assembler means no two lanes can drift, and it
43191
+ // means the token cost is measurable in exactly one place.
43192
+ //
43193
+ // THE TOKEN RULE: 65 rules would ride on EVERY spawn, so HARD rules carry their
43194
+ // body and PURPOSE/LOCK rules carry one line each plus the file to open when one
43195
+ // applies. That is the read-ladder discipline this repo already runs on, applied
43196
+ // to a standing card.
43197
+ function rulesSlice(claudeDir, lane, extraPacks) {
43198
+ const packs = rulesReadPacks(claudeDir);
43199
+ const led = userRulesRead(claudeDir);
43200
+ const overrides = rulesOverrides(led.blocks, packs);
43201
+ const overridden = new Set(overrides.filter((o) => o.known).map((o) => o.id));
43202
+ const want = new Set([...(RULE_LANE_PACKS[lane] || []), ...(extraPacks || [])]);
43203
+ const chosen = packs.packs.filter((p) => want.has(p.id));
43204
+
43205
+ const L = [];
43206
+ if (!led.empty) {
43207
+ L.push("YOUR PROJECT'S RULES — read these FIRST. They WIN over the ORC rules below.", "");
43208
+ for (const pr of RULE_PRIORITIES) {
43209
+ const body = String(led.blocks[pr] || "").replace(/\s+$/, "");
43210
+ if (body) L.push(pr, body, "");
43211
+ }
43212
+ }
43213
+ if (chosen.length) {
43214
+ L.push(
43215
+ `ORC RULES — the anti-slop baseline. Shipped with ORC, read-only. ${packs.count} rules, ${plural(chosen.length, "pack")} in this slice.`,
43216
+ "Source: petergyang/no-ai-slop · miqdadbadjuber/anti-slop · ehmo/slopkit · BioInfo/slopless · Karpathy · Matty Cartwright. Full credits: `orc rules credits`.",
43217
+ "HARD = absolute · PURPOSE = allowed with a written one-line reason · LOCK = consistency, reported never blocking.",
43218
+ ""
43219
+ );
43220
+ for (const p of chosen) {
43221
+ L.push(`[${p.title}]`);
43222
+ for (const r of p.rules) {
43223
+ if (overridden.has(r.id)) continue; // the user replaced it
43224
+ if (r.tier === "HARD") L.push(`${r.id} HARD · ${r.title}`, r.body, "");
43225
+ else L.push(`${r.id} ${r.tier} · ${r.title}`);
43226
+ }
43227
+ const light = p.rules.filter((r) => r.tier !== "HARD" && !overridden.has(r.id)).length;
43228
+ if (light) L.push("", `${plural(light, "rule")} above are one-liners — open ${p.file} when one of them applies.`, "");
43229
+ }
43230
+ }
43231
+ if (overrides.length) {
43232
+ L.push(
43233
+ "OVERRIDDEN — your rules replaced these, so they are NOT in force here:",
43234
+ ...overrides.map((o) => ` ${o.id}${o.known ? " · " + o.title : " (not a known ORC rule id)"} — ${o.line}`),
43235
+ ""
43236
+ );
43237
+ }
43238
+ L.push(
43239
+ RULES_BOUNDARY,
43240
+ "",
43241
+ "Return `rules_applied[]` (the ids you acted on), `rules_conflicts[]` (two rules that disagree — a gap, never a silent choice) and `rules_overridden[]` (an ORC id a project rule replaced)."
43242
+ );
43243
+ return {
43244
+ lane,
43245
+ packs: chosen.map((p) => p.id),
43246
+ text: L.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\s+$/, ""),
43247
+ overrides,
43248
+ line: rulesLine(packs, led.blocks, overrides),
43249
+ };
43250
+ }
43251
+
43252
+ // ── the command ────────────────────────────────────────────────────────────
43253
+
43254
+ const RULES_VALUE_FLAGS = ["--priority", "--text", "--set-file", "--lane", "--pack", "--dir"];
43255
+
43256
+ function rulesOpt(name) {
43257
+ for (let i = 0; i < args.length; i++)
43258
+ if (args[i] === name && args[i + 1] !== undefined && !String(args[i + 1]).startsWith("--"))
43259
+ return String(args[i + 1]);
43260
+ return undefined;
43261
+ }
43262
+
43263
+ function rulesPositionals() {
43264
+ const out = [];
43265
+ for (let i = 0; i < args.length; i++) {
43266
+ const a = args[i];
43267
+ if (a === "--global") continue;
43268
+ if (RULES_VALUE_FLAGS.includes(a)) {
43269
+ i++;
43270
+ continue;
43271
+ }
43272
+ if (a.startsWith("-")) continue;
43273
+ out.push(a);
43274
+ }
43275
+ return out;
43276
+ }
43277
+
43278
+ function rulesCmd() {
43279
+ const claudeDir = resolveClaudeDir();
43280
+ const asJson = wantsJson();
43281
+ const pos = rulesPositionals(); // ["rules", <sub?>, <arg?>]
43282
+ const sub = String(pos[1] || "");
43283
+ const packs = rulesReadPacks(claudeDir);
43284
+ const led = userRulesRead(claudeDir);
43285
+
43286
+ const fail = (reason, hint, code = 2) => {
43287
+ if (asJson) emitJson({ ok: false, reason, hint, boundary: RULES_BOUNDARY }, code);
43288
+ console.error("❌ " + hint);
43289
+ process.exit(code);
43290
+ };
43291
+
43292
+ // ── the writes ───────────────────────────────────────────────────────────
43293
+ // ORC's own packs are REFUSED BY NAME. A command that looks like it should
43294
+ // work and quietly does nothing is worse than one that says what replaced it.
43295
+ if (rulesOpt("--pack") && (sub === "set" || sub === "add" || sub === "clear" || sub === "set-all"))
43296
+ return fail(
43297
+ "read-only",
43298
+ "ORC rules are read-only — they change with `orc update`, never with a command. " +
43299
+ 'Write your own instead: orc rules add --priority P0 --text "…" (yours win on any conflict).'
43300
+ );
43301
+
43302
+ const save = (msg, blocks, extra) => {
43303
+ const w = userRulesWrite(claudeDir, led.preamble, blocks);
43304
+ const fresh = userRulesParse(w.text).blocks;
43305
+ const ov = rulesOverrides(fresh, packs);
43306
+ if (asJson)
43307
+ emitJson(
43308
+ {
43309
+ ok: true,
43310
+ ...extra,
43311
+ file: w.path,
43312
+ blocks: fresh,
43313
+ text: w.text,
43314
+ counts: userRulesCounts(fresh),
43315
+ line: rulesLine(packs, fresh, ov),
43316
+ priorities: RULE_PRIORITIES,
43317
+ overrides: ov,
43318
+ boundary: RULES_BOUNDARY,
43319
+ },
43320
+ 0
43321
+ );
43322
+ console.log("✓ " + msg);
43323
+ console.log(ui.color.gray(" " + rulesLine(packs, fresh, ov)));
43324
+ console.log(ui.color.gray(" " + w.path));
43325
+ process.exit(0);
43326
+ };
43327
+
43328
+ const priorityOf = () => {
43329
+ const pr = String(rulesOpt("--priority") || "").toUpperCase();
43330
+ return RULE_PRIORITIES.includes(pr) ? pr : null;
43331
+ };
43332
+ const wholeFile = (text, msg, extra) => {
43333
+ const parsed = userRulesParse(text);
43334
+ // A whole-file write brings its own preamble, so the notes a user typed
43335
+ // above `## P0` survive a round trip through the panel.
43336
+ led.preamble = parsed.preamble || led.preamble;
43337
+ return save(msg, parsed.blocks, extra);
43338
+ };
43339
+
43340
+ if (sub === "set" || sub === "add") {
43341
+ const pr = priorityOf();
43342
+ if (!pr) return fail("bad-priority", `--priority must be one of: ${RULE_PRIORITIES.join(", ")}`);
43343
+ const text = rulesOpt("--text");
43344
+ if (!text || !String(text).trim())
43345
+ return fail("no-text", `orc rules ${sub} needs --text "<your rule, in your own words>". As many lines as you like.`);
43346
+ const body = String(text).replace(/\r\n/g, "\n").replace(/\s+$/, "");
43347
+ const blocks = { ...led.blocks };
43348
+ blocks[pr] = sub === "add" && blocks[pr] ? blocks[pr] + "\n" + body : body;
43349
+ return save(`${pr} ${sub === "add" ? "extended" : "set"} — ${plural(userRulesCount(blocks[pr]), "line")}`, blocks, { priority: pr });
43350
+ }
43351
+ if (sub === "clear") {
43352
+ const pr = priorityOf();
43353
+ if (!pr) return fail("bad-priority", `--priority must be one of: ${RULE_PRIORITIES.join(", ")}`);
43354
+ const blocks = { ...led.blocks };
43355
+ blocks[pr] = "";
43356
+ return save(`${pr} cleared`, blocks, { priority: pr, cleared: true });
43357
+ }
43358
+ // The PANEL's write, and the one a script pipes in: the whole file at once.
43359
+ if (sub === "set-all") {
43360
+ const text = rulesOpt("--text");
43361
+ if (text === undefined) return fail("no-text", 'orc rules set-all needs --text "<the whole file>".');
43362
+ return wholeFile(text, "your rules replaced");
43363
+ }
43364
+ const setFile = rulesOpt("--set-file");
43365
+ if (setFile) {
43366
+ const abs = path.isAbsolute(setFile) ? setFile : path.resolve(process.cwd(), setFile);
43367
+ if (!fs.existsSync(abs)) return fail("no-such-file", `no such file: ${setFile}`);
43368
+ return wholeFile(fs.readFileSync(abs, "utf8"), `your rules replaced from ${setFile}`, { from: setFile });
43369
+ }
43370
+ if (flag("--reset") === true) {
43371
+ led.preamble = USER_RULES_PREAMBLE;
43372
+ return save("your rules reset to the empty template", userRulesEmpty(), { reset: true });
43373
+ }
43374
+
43375
+ // ── the reads ────────────────────────────────────────────────────────────
43376
+ // The lint is a read with its own exit-code contract (0 clean / 1 findings /
43377
+ // 2 nothing to lint), so it branches before anything else computes.
43378
+ if (sub === "lint") return rulesLintCmd(claudeDir);
43379
+
43380
+ const overrides = rulesOverrides(led.blocks, packs);
43381
+ const line = rulesLine(packs, led.blocks, overrides);
43382
+
43383
+ if (sub === "credits") {
43384
+ let body = null;
43385
+ try {
43386
+ body = fs.readFileSync(packs.credits_file, "utf8");
43387
+ } catch (_) {}
43388
+ if (asJson)
43389
+ emitJson({ ok: body !== null, file: packs.credits_file, sources: RULE_CREDITS, text: body }, body === null ? 2 : 0);
43390
+ if (body === null) return fail("unreadable", `CREDITS.md is missing from ${packs.dir}. Run \`orc update\`.`);
43391
+ console.log("\n" + body.replace(/^<!--[\s\S]*?-->\n*/, ""));
43392
+ process.exit(0);
43393
+ }
43394
+
43395
+ if (sub === "packs") {
43396
+ if (asJson)
43397
+ emitJson(
43398
+ {
43399
+ ok: packs.readable,
43400
+ dir: packs.dir,
43401
+ source: packs.source,
43402
+ count: packs.count,
43403
+ read_only: true,
43404
+ packs: packs.packs.map(({ rules, ...p }) => p),
43405
+ tiers: RULE_TIERS,
43406
+ line,
43407
+ },
43408
+ packs.readable ? 0 : 2
43409
+ );
43410
+ console.log(`\norc rules — ${packs.count} ORC rules in ${plural(packs.packs.length, "pack")} ${ui.color.gray("(read-only)")}\n`);
43411
+ for (const p of packs.packs)
43412
+ console.log(
43413
+ ` ${ui.color.cyan(p.prefix.padEnd(5))} ${p.title.padEnd(10)} ${String(p.count).padStart(3)} rules ` +
43414
+ ui.color.gray(`HARD ${p.tiers.HARD} · PURPOSE ${p.tiers.PURPOSE} · LOCK ${p.tiers.LOCK} layer=${p.layer}`)
43415
+ );
43416
+ console.log("\n" + ui.color.gray(" " + packs.dir) + "\n");
43417
+ process.exit(packs.readable ? 0 : 2);
43418
+ }
43419
+
43420
+ if (sub === "show") {
43421
+ const want = String(pos[2] || "").toLowerCase();
43422
+ const p = packs.packs.find((x) => x.id === want || x.prefix.toLowerCase() === want);
43423
+ if (!p) return fail("unknown-pack", `unknown pack: ${pos[2] || "(none)"} — one of: ${RULE_PACK_IDS.join(", ")}`);
43424
+ if (asJson) emitJson({ ok: true, ...p, read_only: true, boundary: RULES_BOUNDARY }, 0);
43425
+ console.log(`\n${p.title} (${p.prefix}) — ${p.count} rules ${ui.color.gray("read-only")}\n`);
43426
+ for (const r of p.rules) console.log(` ${ui.color.cyan(r.id)} ${r.tier.padEnd(7)} ${r.title}`);
43427
+ console.log("\n" + ui.color.gray(" " + p.file) + "\n");
43428
+ process.exit(0);
43429
+ }
43430
+
43431
+ if (sub === "user") {
43432
+ if (asJson)
43433
+ emitJson(
43434
+ {
43435
+ ok: true,
43436
+ file: led.path,
43437
+ exists: led.exists,
43438
+ empty: led.empty,
43439
+ preamble: led.preamble,
43440
+ blocks: led.blocks,
43441
+ text: led.text,
43442
+ template: userRulesRender(USER_RULES_PREAMBLE, userRulesEmpty()),
43443
+ counts: userRulesCounts(led.blocks),
43444
+ priorities: RULE_PRIORITIES,
43445
+ overrides,
43446
+ line,
43447
+ boundary: RULES_BOUNDARY,
43448
+ },
43449
+ led.empty ? 1 : 0
43450
+ );
43451
+ console.log("\n" + (led.empty ? "No project rules yet." : led.text) + "\n");
43452
+ console.log(ui.color.gray(" " + led.path));
43453
+ console.log(ui.color.gray(" " + line) + "\n");
43454
+ process.exit(led.empty ? 1 : 0);
43455
+ }
43456
+
43457
+ if (sub === "slice") {
43458
+ const lane = rulesOpt("--lane");
43459
+ if (!lane) return fail("no-lane", "orc rules slice needs --lane <lane>.");
43460
+ if (!RULE_LANE_PACKS[lane])
43461
+ return fail(
43462
+ "no-rules-lane",
43463
+ lane === "orc-doc"
43464
+ ? "`orc-doc` carries no ORC rules by design — that lane has its own ledger: `orc doc rules`."
43465
+ : `lane \`${lane}\` carries no ORC rules. Lanes that do: ${Object.keys(RULE_LANE_PACKS).sort().join(", ")}`
43466
+ );
43467
+ const extra = String(rulesOpt("--pack") || "").split(",").map((s) => s.trim()).filter(Boolean);
43468
+ for (const e of extra)
43469
+ if (!RULE_PACK_IDS.includes(e)) return fail("unknown-pack", `unknown pack: ${e} — one of: ${RULE_PACK_IDS.join(", ")}`);
43470
+ const s = rulesSlice(claudeDir, lane, extra);
43471
+ if (asJson) emitJson({ ok: true, ...s, boundary: RULES_BOUNDARY }, 0);
43472
+ console.log("\n" + s.text + "\n");
43473
+ process.exit(0);
43474
+ }
43475
+
43476
+ if (sub && sub !== "list")
43477
+ return fail(
43478
+ "unknown-subcommand",
43479
+ `Unknown: orc rules ${sub}\n` +
43480
+ "Usage: orc rules [--json] both halves + the precedence ladder\n" +
43481
+ " orc rules packs [--json] the pack table (read-only)\n" +
43482
+ " orc rules show <pack> [--json] ONE pack, verbatim\n" +
43483
+ " orc rules user [--json] YOUR ledger (exit 1 = none yet, still an answer)\n" +
43484
+ " orc rules credits [--json] every source, author and licence\n" +
43485
+ " orc rules slice --lane <lane> [--pack ui] THE dispatch text — the only assembler\n" +
43486
+ " orc rules lint <path…|--staged|--diff> the FREE check (0 clean / 1 findings / 2 none)\n" +
43487
+ ' orc rules set --priority P0 --text "…" replace ONE block\n' +
43488
+ ' orc rules add --priority P0 --text "…" append to a block\n' +
43489
+ " orc rules clear --priority P0 empty ONE block\n" +
43490
+ ' orc rules set-all --text "…" replace the WHOLE file (what `orc ui` writes)\n' +
43491
+ " orc rules --set-file <path> replace the whole file from a file\n" +
43492
+ " orc rules --reset back to the bare template"
43493
+ );
43494
+
43495
+ // `orc rules` — both halves, the ladder, the counts.
43496
+ if (asJson)
43497
+ emitJson(
43498
+ {
43499
+ ok: packs.readable,
43500
+ line,
43501
+ orc: {
43502
+ dir: packs.dir,
43503
+ source: packs.source,
43504
+ read_only: true,
43505
+ count: packs.count,
43506
+ packs: packs.packs.map(({ rules, ...p }) => p),
43507
+ rules: packs.rules,
43508
+ credits_file: packs.credits_file,
43509
+ credits: RULE_CREDITS,
43510
+ },
43511
+ user: {
43512
+ file: led.path,
43513
+ exists: led.exists,
43514
+ empty: led.empty,
43515
+ blocks: led.blocks,
43516
+ text: led.text,
43517
+ template: userRulesRender(USER_RULES_PREAMBLE, userRulesEmpty()),
43518
+ counts: userRulesCounts(led.blocks),
43519
+ },
43520
+ priorities: RULE_PRIORITIES,
43521
+ tiers: RULE_TIERS,
43522
+ precedence: RULES_PRECEDENCE,
43523
+ overrides,
43524
+ overrides_note:
43525
+ "counted from ORC rule ids you NAMED in your own rules. A conflict you did not name is found by the agent at dispatch and returned as rules_conflicts[] — the CLI cannot parse intent, so it does not pretend to.",
43526
+ lanes: RULE_LANE_PACKS,
43527
+ excluded: { "orc-doc": "has its own ledger: `orc doc rules`" },
43528
+ boundary: RULES_BOUNDARY,
43529
+ },
43530
+ led.empty ? 1 : 0
43531
+ );
43532
+
43533
+ console.log(`\norc rules — ${line}\n`);
43534
+ console.log(ui.color.bold(" Precedence") + ui.color.gray(" the order never changes"));
43535
+ for (const r of RULES_PRECEDENCE)
43536
+ console.log(
43537
+ ` ${r.rank}. ${ui.color.cyan(r.layer.padEnd(12))} ${ui.color.gray(r.scope)}\n` +
43538
+ ` ${ui.color.gray(r.where)}\n` +
43539
+ ` ${ui.color.gray("beats " + r.beats)}`
43540
+ );
43541
+ console.log("");
43542
+ console.log(ui.color.bold(" ORC rules") + ui.color.gray(" read-only — `orc update` changes them, nothing else"));
43543
+ for (const p of packs.packs)
43544
+ console.log(
43545
+ ` ${ui.color.cyan(p.prefix.padEnd(5))} ${p.title.padEnd(10)} ${String(p.count).padStart(3)} ` +
43546
+ ui.color.gray(`HARD ${p.tiers.HARD} · PURPOSE ${p.tiers.PURPOSE} · LOCK ${p.tiers.LOCK}`)
43547
+ );
43548
+ console.log(ui.color.gray(` ${packs.dir}`));
43549
+ console.log("");
43550
+ console.log(ui.color.bold(" Your rules") + ui.color.gray(" they win on any conflict"));
43551
+ if (led.empty) {
43552
+ console.log(ui.color.gray(" none yet"));
43553
+ console.log(ui.color.gray(' add one: orc rules add --priority P0 --text "…"'));
43554
+ } else {
43555
+ const c = userRulesCounts(led.blocks);
43556
+ for (const pr of RULE_PRIORITIES) if (c[pr]) console.log(` ${ui.color.cyan(pr)} ${plural(c[pr], "line")}`);
43557
+ }
43558
+ console.log(ui.color.gray(` ${led.path}`));
43559
+ if (overrides.length) {
43560
+ console.log("");
43561
+ console.log(ui.color.bold(" Overridden") + ui.color.gray(" ORC rules your rules replaced"));
43562
+ for (const o of overrides)
43563
+ console.log(` ${ui.color.yellow(o.id)} ${o.known ? o.title : ui.color.gray("not a known ORC rule id")}`);
43564
+ console.log(ui.color.gray(" counted from ids you named. A conflict you did not name is found by the agent at dispatch."));
43565
+ }
43566
+ console.log("");
43567
+ console.log(ui.color.gray(" " + RULES_BOUNDARY));
43568
+ console.log("");
43569
+ process.exit(led.empty ? 1 : 0);
43570
+ }
43571
+
43572
+ // ── orc rules lint (v1.7.0 W4) — the free half ─────────────────────────────
43573
+ //
43574
+ // Deterministic, zero tokens, and DELIBERATELY SMALL. It checks only what a
43575
+ // string match can PROVE, and it says — in every mode, including `--json` —
43576
+ // how many rules it did not check. That line is not politeness; it is the
43577
+ // difference between a lint and a claim. A lint that implied it had graded all
43578
+ // 65 rules would let a clean exit stand in for a review that never happened,
43579
+ // which is the `orc doc lint` lesson and the reason that command prints its own
43580
+ // coverage the same way.
43581
+ //
43582
+ // The judgement rules — "is this abstraction speculative", "does this comment
43583
+ // explain why", "is this sentence portable" — need a reader, not a matcher, and
43584
+ // nothing here pretends otherwise.
43585
+ //
43586
+ // A user rule that NAMES a checkable id suppresses that check, and the
43587
+ // suppression is printed. Precedence is not a doc: it runs.
43588
+
43589
+ // Which rules this matcher can actually prove. The id is the contract, so a
43590
+ // pack edit that renames one breaks the build here rather than silently
43591
+ // dropping a check.
43592
+ const RULE_LINT_IDS = [
43593
+ "OSW-10", "OSW-11", "OSW-13", "OSW-18",
43594
+ "OSC-02", "OSC-03", "OSC-04", "OSC-05", "OSC-06", "OSC-07", "OSC-21",
43595
+ "OSU-03", "OSU-06",
43596
+ ];
43597
+
43598
+ const LINT_LEXICON = [
43599
+ "delve", "delves", "delving", "leverage", "leverages", "leveraging", "utilize", "utilizes",
43600
+ "utilise", "utilises", "facilitate", "facilitates", "empower", "empowers", "streamline",
43601
+ "streamlines", "robust", "cutting-edge", "paradigm shift", "game changer", "game-changer",
43602
+ "tapestry", "realm", "beacon", "multifaceted", "meticulous", "meticulously", "intricate",
43603
+ "paramount", "transformative", "elevate", "elevates", "embark", "supercharge", "supercharged",
43604
+ "harness", "harnesses", "ever-evolving", "seamless", "seamlessly", "unleash", "unleashes",
43605
+ "pivotal",
43606
+ ];
43607
+
43608
+ const LINT_PHRASES = [
43609
+ "it's worth noting", "it is worth noting", "it's important to note", "it is important to note",
43610
+ "at the end of the day", "when it comes to", "at its core", "in today's world", "in the age of",
43611
+ "in the world of", "the reality is", "the truth is", "in terms of", "with regard to",
43612
+ "going forward", "let's dive in", "in this article",
43613
+ ];
43614
+
43615
+ const LINT_BUZZWORDS = [
43616
+ "ai powered", "ai-powered", "next generation", "next-generation", "revolutionary",
43617
+ "seamless", "cutting edge", "cutting-edge", "intelligent", "ultimate", "powerful",
43618
+ "effortless", "supercharged",
43619
+ ];
43620
+
43621
+ // The whole comment IS the label. Matched against the comment's text with the
43622
+ // marker and trailing punctuation stripped, so `// Main logic` is a finding and
43623
+ // `// Main logic runs only on the retry path` is not.
43624
+ const LINT_EMPTY_LABELS = [
43625
+ "main logic", "core logic", "business logic", "helper function", "helper functions",
43626
+ "helpers", "utils", "utilities", "entry point", "error handling", "imports", "constants",
43627
+ "variables", "initialization", "initialisation", "setup", "cleanup", "configuration",
43628
+ "note this is important", "important please read", "the main function", "main function",
43629
+ ];
43630
+
43631
+ // A TODO that names a feeling instead of a task.
43632
+ const LINT_VAGUE_TODO = [
43633
+ "", "improve this", "improve", "improve later", "optimize", "optimise", "optimize this",
43634
+ "future improvements", "future improvement", "additional optimization can be added here",
43635
+ "add more validation", "add more", "more validation", "more tests", "clean this up",
43636
+ "cleanup", "clean up", "refactor", "refactor this", "fix this", "better", "do better",
43637
+ "handle this", "revisit", "revisit this", "later", "tbd",
43638
+ ];
43639
+
43640
+ const EMOJI_RE = /\p{Extended_Pictographic}/u;
43641
+
43642
+ const LINT_PROSE_EXT = new Set([".md", ".markdown", ".txt", ".mdx", ".rst"]);
43643
+ const LINT_UI_EXT = new Set([".css", ".scss", ".sass", ".less", ".html", ".htm", ".vue", ".svelte", ".jsx", ".tsx"]);
43644
+ const LINT_CODE_EXT = new Set([
43645
+ ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py", ".rb", ".go", ".rs", ".java", ".kt",
43646
+ ".swift", ".c", ".h", ".cc", ".cpp", ".hpp", ".cs", ".php", ".sh", ".bash", ".zsh", ".sql",
43647
+ ".css", ".scss", ".less", ".vue", ".svelte", ".yml", ".yaml",
43648
+ ]);
43649
+ const LINT_SKIP_DIR = new Set([".git", "node_modules", "dist", "build", "vendor", ".next", "coverage", ".claude"]);
43650
+
43651
+ // Strip a line's comment marker and return the comment's own text, or null when
43652
+ // the line is not a whole-line comment. Only whole-line comments are linted: a
43653
+ // trailing comment after real code is where the genuinely useful one-liners
43654
+ // live, and flagging those would make the lint noise.
43655
+ function lintCommentText(line) {
43656
+ const t = line.trim();
43657
+ let m = /^(?:\/\/+|#+|--|;;?|\*|\/\*+|<!--)\s?(.*?)(?:\*\/|-->)?\s*$/.exec(t);
43658
+ if (!m) return null;
43659
+ if (!/^(?:\/\/|#|--|;|\*|\/\*|<!--)/.test(t)) return null;
43660
+ return m[1];
43661
+ }
43662
+
43663
+ const lintNormalise = (s) => String(s || "").toLowerCase().replace(/[.:!?,;*_`]+$/g, "").replace(/\s+/g, " ").trim();
43664
+
43665
+ function lintScanFile(abs, rel, opts) {
43666
+ const ext = path.extname(rel).toLowerCase();
43667
+ const base = path.basename(rel);
43668
+ const out = [];
43669
+ const on = (id) => opts.active.has(id);
43670
+ const add = (id, line, evidence, fix) => {
43671
+ const r = opts.byId.get(id);
43672
+ out.push({
43673
+ file: rel,
43674
+ line,
43675
+ rule: id,
43676
+ tier: r ? r.tier : null,
43677
+ pack: r ? r.pack : null,
43678
+ title: r ? r.title : null,
43679
+ evidence,
43680
+ fix,
43681
+ });
43682
+ };
43683
+
43684
+ // OSC-21 is about the file's NAME, so it fires before a byte is read.
43685
+ if (on("OSC-21")) {
43686
+ if (/^(summary|implementation[_-]?notes|changes|notes|readme[_-]new)\.(md|txt)$/i.test(base) ||
43687
+ /[._-](final|v2|new|copy|old|bak|orig)\.[a-z0-9]+$/i.test(base) ||
43688
+ /\.(bak|old|orig)$/i.test(base))
43689
+ add("OSC-21", 1, base, "nobody asked for this file. Report it in the return instead of leaving it behind.");
43690
+ }
43691
+
43692
+ let text;
43693
+ try {
43694
+ text = fs.readFileSync(abs, "utf8");
43695
+ } catch (_) {
43696
+ return out;
43697
+ }
43698
+ if (text.includes("")) return out; // binary
43699
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
43700
+
43701
+ // THE CARVE-OUT, and it is not a convenience. A rule that bans a word has to
43702
+ // PRINT that word to define it, so the packs themselves fail their own lint on
43703
+ // every line — and a lint whose loudest findings are its own documentation is
43704
+ // a lint people learn to ignore. anti-slop's R-02 needed the same carve-out
43705
+ // for the same reason.
43706
+ //
43707
+ // Two markers, both explicit, both counted in the summary so an exemption is
43708
+ // never silent:
43709
+ // <!-- orc-rules:… --> in the head → this IS a rules pack. Skipped whole.
43710
+ // orc-rules-ignore-file in the head → the user's own opt-out. Skipped whole.
43711
+ // orc-rules-ignore on a line → that line only.
43712
+ const head = text.slice(0, 600);
43713
+ if (/<!--\s*orc-rules:/.test(head)) {
43714
+ opts.exempt.packs.push(rel);
43715
+ return out;
43716
+ }
43717
+ if (/orc-rules-ignore-file/.test(head)) {
43718
+ opts.exempt.files.push(rel);
43719
+ return out;
43720
+ }
43721
+
43722
+ const isProse = LINT_PROSE_EXT.has(ext);
43723
+ const isCode = LINT_CODE_EXT.has(ext);
43724
+ const isUi = LINT_UI_EXT.has(ext);
43725
+
43726
+ // Prose rules run on prose files AND on the comments inside code, because a
43727
+ // banned word is a banned word wherever ORC wrote it.
43728
+ for (let i = 0; i < lines.length; i++) {
43729
+ const raw = lines[i];
43730
+ if (raw.includes("orc-rules-ignore")) continue;
43731
+ const n = i + 1;
43732
+ const comment = isCode ? lintCommentText(raw) : null;
43733
+ const prose = isProse ? raw : comment;
43734
+
43735
+ if (prose !== null && prose !== undefined) {
43736
+ const low = prose.toLowerCase();
43737
+ if (on("OSW-10"))
43738
+ for (const w of LINT_LEXICON)
43739
+ if (new RegExp(`\\b${w.replace(/[.*+?^${}()|[\]\\]/g, "\\// ── the `--json` crash envelope (v0.49.2) ───────────────────────────────────")}\\b`, "i").test(low)) {
43740
+ add("OSW-10", n, w, "use the plain word");
43741
+ break; // one finding per line — a list of six is a list nobody reads
43742
+ }
43743
+ if (on("OSW-11"))
43744
+ for (const p of LINT_PHRASES)
43745
+ if (low.includes(p)) {
43746
+ add("OSW-11", n, p, "delete the clause and keep the point");
43747
+ break;
43748
+ }
43749
+ }
43750
+
43751
+ if (isProse && on("OSW-18") && /^#{1,6}\s/.test(raw) && EMOJI_RE.test(raw))
43752
+ add("OSW-18", n, raw.trim().slice(0, 60), "format follows the content; it does not decorate it");
43753
+
43754
+ if (isUi && on("OSU-06")) {
43755
+ const low = raw.toLowerCase();
43756
+ for (const b of LINT_BUZZWORDS)
43757
+ if (low.includes(b)) {
43758
+ add("OSU-06", n, b, "say what it does; show evidence, not adjectives");
43759
+ break;
43760
+ }
43761
+ }
43762
+
43763
+ if (isUi && on("OSU-03") && /outline\s*:\s*(none|0)\b/i.test(raw)) {
43764
+ // HEURISTIC, and the finding says so: a replacement indicator within five
43765
+ // lines either way counts as one. The lint cannot prove a focus ring is
43766
+ // visible; it can prove nobody wrote one nearby.
43767
+ const near = lines.slice(Math.max(0, i - 5), i + 6).join("\n");
43768
+ if (!/:focus-visible|box-shadow|outline-offset|outline\s*:\s*(?!none|0)\S/i.test(near))
43769
+ add("OSU-03", n, raw.trim().slice(0, 60), "replace it with a visible focus indicator, or drop the reset (heuristic: none found within five lines)");
43770
+ }
43771
+
43772
+ if (comment !== null && comment !== undefined) {
43773
+ const c = comment.trim();
43774
+ const norm = lintNormalise(c);
43775
+
43776
+ if (on("OSC-02")) {
43777
+ if (/([=\-*_~#])\1{5,}/.test(c))
43778
+ add("OSC-02", n, c.slice(0, 40), "one plain line, or nothing");
43779
+ else if (c.length > 2 && c.length <= 40 && /^[A-Z0-9 ()\-]+$/.test(c) && /[A-Z]{2,}/.test(c))
43780
+ add("OSC-02", n, c, "sentence case, and only if the label carries a fact");
43781
+ }
43782
+ if (on("OSC-03") && /^(step\s*\d+\b|first|next|then|finally|lastly)\s*[:.\-…]*\s*$/i.test(c.replace(/\.\.\.$/, "")))
43783
+ add("OSC-03", n, c, "the control flow is already visible — delete it");
43784
+ if (on("OSC-03") && /^step\s*\d+\s*[:.\-]/i.test(c))
43785
+ add("OSC-03", n, c.slice(0, 40), "the control flow is already visible — delete it");
43786
+ if (on("OSC-04") && LINT_EMPTY_LABELS.includes(norm))
43787
+ add("OSC-04", n, c, "the label names a category, not a fact — delete it");
43788
+ if (on("OSC-05")) {
43789
+ const t = /^(TODO|FIXME|XXX)\b\s*:?\s*(.*)$/i.exec(c);
43790
+ if (t && LINT_VAGUE_TODO.includes(lintNormalise(t[2])))
43791
+ add("OSC-05", n, c, "name the task and enough context to act on it, or delete it");
43792
+ }
43793
+ if (on("OSC-06") && EMOJI_RE.test(c))
43794
+ add("OSC-06", n, c.slice(0, 40), "plain English, or nothing");
43795
+ if (on("OSC-07") && /^end(\s+(of\s+)?[\w$.]+)?\s*[.]?$/i.test(c))
43796
+ add("OSC-07", n, c, "the closing brace already ends the block");
43797
+ }
43798
+ if (isCode && on("OSC-07") && /[})\]]\s*(?:\/\/|#|--)\s*end\b/i.test(raw))
43799
+ add("OSC-07", n, raw.trim().slice(0, 40), "the closing brace already ends the block");
43800
+ }
43801
+
43802
+ // OSW-13 is a DOSE rule, so it is measured per file, never per occurrence.
43803
+ if (isProse && on("OSW-13")) {
43804
+ const dashes = (text.match(/—/g) || []).length;
43805
+ const words = (text.match(/\S+/g) || []).length;
43806
+ if (dashes) {
43807
+ const short = words < 150;
43808
+ const per1000 = words ? (dashes / words) * 1000 : dashes;
43809
+ if (short)
43810
+ add("OSW-13", 1, `${plural(dashes, "em dash", "em dashes")} in ${plural(words, "word")}`, "short output carries none");
43811
+ else if (per1000 > 2)
43812
+ add("OSW-13", 1, `${dashes} em dashes, ${per1000.toFixed(1)} per 1000 words (cap 2)`, "keep the ones that beat a comma, a period or parentheses; cut the rest");
43813
+ }
43814
+ }
43815
+ return out;
43816
+ }
43817
+
43818
+ function lintWalk(abs, rel, acc, depth) {
43819
+ let st;
43820
+ try {
43821
+ st = fs.statSync(abs);
43822
+ } catch (_) {
43823
+ return;
43824
+ }
43825
+ if (st.isFile()) {
43826
+ acc.push({ abs, rel });
43827
+ return;
43828
+ }
43829
+ if (!st.isDirectory() || depth > 12) return;
43830
+ for (const e of fs.readdirSync(abs, { withFileTypes: true })) {
43831
+ if (e.isDirectory() && LINT_SKIP_DIR.has(e.name)) continue;
43832
+ if (e.name.startsWith(".") && e.isDirectory()) continue;
43833
+ lintWalk(path.join(abs, e.name), rel ? rel + "/" + e.name : e.name, acc, depth + 1);
43834
+ }
43835
+ }
43836
+
43837
+ function rulesLintCmd(claudeDir) {
43838
+ const asJson = wantsJson();
43839
+ const pos = rulesPositionals(); // ["rules","lint", …paths]
43840
+ const packs = rulesReadPacks(claudeDir);
43841
+ const led = userRulesRead(claudeDir);
43842
+ const overrides = rulesOverrides(led.blocks, packs);
43843
+ const byId = new Map(packs.rules.map((r) => [r.id, r]));
43844
+
43845
+ const fail = (reason, hint, code = 2) => {
43846
+ if (asJson) emitJson({ ok: false, reason, hint }, code);
43847
+ console.error("❌ " + hint);
43848
+ process.exit(code);
43849
+ };
43850
+
43851
+ // `--pack` narrows the CHECKS, never the files.
43852
+ const wantPacks = String(rulesOpt("--pack") || "")
43853
+ .split(",")
43854
+ .map((s) => s.trim().toLowerCase())
43855
+ .filter(Boolean)
43856
+ .map((s) => ({ w: "writing", c: "code", d: "delivery", u: "ui" }[s] || s));
43857
+ for (const p of wantPacks)
43858
+ if (!RULE_PACK_IDS.includes(p)) return fail("unknown-pack", `unknown pack: ${p} — one of: ${RULE_PACK_IDS.join(", ")}`);
43859
+
43860
+ // A user rule that NAMES a checkable id turns that check off, and the run says
43861
+ // so. Precedence that only exists in a document is not precedence.
43862
+ const suppressed = overrides.filter((o) => RULE_LINT_IDS.includes(o.id)).map((o) => o.id);
43863
+ const active = new Set(
43864
+ RULE_LINT_IDS.filter((id) => {
43865
+ if (suppressed.includes(id)) return false;
43866
+ if (!wantPacks.length) return true;
43867
+ const r = byId.get(id);
43868
+ return r && wantPacks.includes(r.pack);
43869
+ })
43870
+ );
43871
+
43872
+ // Targets: explicit paths, or a git selection.
43873
+ const root = process.cwd();
43874
+ const files = [];
43875
+ const gitList = (args) => {
43876
+ const r = require("child_process").spawnSync("git", args, { cwd: root, encoding: "utf8" });
43877
+ return r.status === 0 ? String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean) : null;
43878
+ };
43879
+ let source = "paths";
43880
+ if (flag("--staged") === true) {
43881
+ source = "--staged";
43882
+ const l = gitList(["diff", "--cached", "--name-only"]);
43883
+ if (l === null) return fail("no-git", "not a git repository, so there is nothing staged to lint.");
43884
+ for (const rel of l) files.push({ abs: path.resolve(root, rel), rel });
43885
+ } else if (flag("--diff") === true) {
43886
+ source = "--diff";
43887
+ const l = gitList(["diff", "--name-only", "HEAD"]);
43888
+ if (l === null) return fail("no-git", "not a git repository, so there is no diff to lint.");
43889
+ for (const rel of l) files.push({ abs: path.resolve(root, rel), rel });
43890
+ } else {
43891
+ const paths = pos.slice(2);
43892
+ if (!paths.length)
43893
+ return fail("no-target", "orc rules lint needs a path, or --staged, or --diff.");
43894
+ for (const p of paths) lintWalk(path.resolve(root, p), p.replace(/\\/g, "/"), files, 0);
43895
+ }
43896
+
43897
+ const seen = new Set();
43898
+ const targets = files.filter((f) => {
43899
+ if (seen.has(f.abs) || !fs.existsSync(f.abs)) return false;
43900
+ seen.add(f.abs);
43901
+ const ext = path.extname(f.rel).toLowerCase();
43902
+ return LINT_PROSE_EXT.has(ext) || LINT_CODE_EXT.has(ext) || LINT_UI_EXT.has(ext);
43903
+ });
43904
+
43905
+ if (!targets.length)
43906
+ return fail("nothing-to-lint", `nothing lintable in ${source === "paths" ? pos.slice(2).join(", ") : source}. Prose, source, and stylesheet files only.`);
43907
+
43908
+ const opts = { active, byId, exempt: { packs: [], files: [] } };
43909
+ let findings = [];
43910
+ for (const t of targets) findings = findings.concat(lintScanFile(t.abs, t.rel, opts));
43911
+ findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.rule.localeCompare(b.rule));
43912
+
43913
+ const notChecked = packs.count - active.size;
43914
+ // THE MANDATORY LINE. It prints in every mode, clean or not, and it is the
43915
+ // only honest way to hand back a zero.
43916
+ const coverage = `checked ${plural(active.size, "rule")} of ${packs.count} — not checked here: ${plural(notChecked, "rule")}. They need a reader, not a matcher.`;
43917
+
43918
+ if (asJson)
43919
+ emitJson(
43920
+ {
43921
+ ok: true,
43922
+ source,
43923
+ files: targets.length,
43924
+ findings,
43925
+ count: findings.length,
43926
+ checked: [...active],
43927
+ checked_count: active.size,
43928
+ not_checked_count: notChecked,
43929
+ total_rules: packs.count,
43930
+ coverage,
43931
+ suppressed,
43932
+ suppressed_note: suppressed.length
43933
+ ? "your own rules name these ids, so the check is off here. Precedence runs; it is not only written down."
43934
+ : null,
43935
+ exempt: opts.exempt,
43936
+ exempt_note:
43937
+ "a rules pack has to print a banned word to define it, so the packs are skipped whole. `orc-rules-ignore-file` in a head, or `orc-rules-ignore` on a line, is your own opt-out. Both are counted here rather than being silent.",
43938
+ },
43939
+ findings.length ? 1 : 0
43940
+ );
43941
+
43942
+ console.log(`\norc rules lint — ${plural(targets.length, "file")} (${source})\n`);
43943
+ if (!findings.length) console.log(" " + ui.color.green("clean") + ui.color.gray(" — on the rules a matcher can prove"));
43944
+ let last = "";
43945
+ for (const f of findings) {
43946
+ if (f.file !== last) {
43947
+ console.log("\n " + ui.color.cyan(f.file));
43948
+ last = f.file;
43949
+ }
43950
+ console.log(
43951
+ ` ${String(f.line).padStart(5)} ${ui.color.yellow(f.rule)} ${String(f.tier || "").padEnd(7)} ${f.title || ""}`
43952
+ );
43953
+ console.log(` ${ui.color.gray(f.evidence)}`);
43954
+ console.log(` ${ui.color.gray("→ " + f.fix)}`);
43955
+ }
43956
+ if (suppressed.length) {
43957
+ console.log("\n " + ui.color.bold("off here") + ui.color.gray(" your own rules name these ids"));
43958
+ console.log(" " + suppressed.join(" · "));
43959
+ }
43960
+ // An exemption is never silent: a file that was skipped and never said so is
43961
+ // indistinguishable from a file that passed.
43962
+ const ex = opts.exempt.packs.length + opts.exempt.files.length;
43963
+ if (ex)
43964
+ console.log(
43965
+ "\n " +
43966
+ ui.color.gray(
43967
+ `skipped ${plural(ex, "file")} — ` +
43968
+ [
43969
+ opts.exempt.packs.length ? `${opts.exempt.packs.length} rules pack(s), which must print a banned word to define it` : null,
43970
+ opts.exempt.files.length ? `${opts.exempt.files.length} marked orc-rules-ignore-file` : null,
43971
+ ]
43972
+ .filter(Boolean)
43973
+ .join(" · ")
43974
+ )
43975
+ );
43976
+ console.log("\n " + ui.color.gray(coverage) + "\n");
43977
+ process.exit(findings.length ? 1 : 0);
43978
+ }
43979
+
42394
43980
  // ── the `--json` crash envelope (v0.49.2) ───────────────────────────────────
42395
43981
  // A read asked for JSON must answer in JSON or not at all. Before this, an
42396
43982
  // unexpected throw inside any `--json` route printed a Node stack to stderr and
@@ -42491,6 +44077,13 @@ function jsonCrash(err) {
42491
44077
  case "doc":
42492
44078
  doc();
42493
44079
  break;
44080
+ // v1.7.0 — the anti-slop rule surface. Two halves: the SHIPPED read-only
44081
+ // packs and the project's own ledger, which WINS on any conflict. Every
44082
+ // subcommand is a READ except the ledger writers, which are rules.md's only
44083
+ // writer. `orc-doc` is excluded by design — it has `orc doc rules`.
44084
+ case "rules":
44085
+ rulesCmd();
44086
+ break;
42494
44087
  // v0.50.0 — dispatch ORC's workers to non-Claude agents. Every subcommand
42495
44088
  // is a READ with an exit-code contract except `add`, `remove` and the
42496
44089
  // route writers, which are extra.json's only writers.