create-religion 1.2.0 → 1.4.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 (48) hide show
  1. package/CHANGELOG.md +466 -0
  2. package/README.md +2 -1
  3. package/dist/bin/religion.js +87 -7
  4. package/dist/lib/args.js +15 -5
  5. package/dist/lib/changelog.js +29 -0
  6. package/dist/lib/dashboard.js +102 -22
  7. package/dist/lib/doctor.js +134 -26
  8. package/dist/lib/install.js +43 -6
  9. package/dist/lib/paths.js +18 -0
  10. package/dist/lib/settings.js +156 -0
  11. package/dist/lib/state.js +261 -8
  12. package/dist/lib/status.js +38 -3
  13. package/dist/lib/uninstall.js +216 -0
  14. package/package.json +2 -2
  15. package/template/.agents/skills/auto/SKILL.md +9 -3
  16. package/template/.agents/skills/check/SKILL.md +69 -2
  17. package/template/.agents/skills/ci/SKILL.md +28 -0
  18. package/template/.agents/skills/complete/SKILL.md +76 -5
  19. package/template/.agents/skills/distill/SKILL.md +42 -19
  20. package/template/.agents/skills/feature/SKILL.md +19 -4
  21. package/template/.agents/skills/feature/reference/spec-template.md +1 -0
  22. package/template/.agents/skills/fix/SKILL.md +7 -0
  23. package/template/.agents/skills/implement/SKILL.md +37 -9
  24. package/template/.agents/skills/implement/reference/worktrees.md +175 -0
  25. package/template/.agents/skills/scout/SKILL.md +2 -2
  26. package/template/.agents/skills/spike/SKILL.md +8 -0
  27. package/template/.agents/skills/status/SKILL.md +17 -4
  28. package/template/.agents/skills/tests/SKILL.md +45 -3
  29. package/template/.claude/hooks/warn-on-drift.mjs +74 -0
  30. package/template/.claude/hooks/write-handoff.mjs +54 -2
  31. package/template/.claude/skills/auto/SKILL.md +9 -3
  32. package/template/.claude/skills/check/SKILL.md +69 -2
  33. package/template/.claude/skills/ci/SKILL.md +28 -0
  34. package/template/.claude/skills/complete/SKILL.md +76 -5
  35. package/template/.claude/skills/distill/SKILL.md +42 -19
  36. package/template/.claude/skills/feature/SKILL.md +19 -4
  37. package/template/.claude/skills/feature/reference/spec-template.md +1 -0
  38. package/template/.claude/skills/fix/SKILL.md +7 -0
  39. package/template/.claude/skills/implement/SKILL.md +37 -9
  40. package/template/.claude/skills/implement/reference/worktrees.md +175 -0
  41. package/template/.claude/skills/scout/SKILL.md +2 -2
  42. package/template/.claude/skills/spike/SKILL.md +8 -0
  43. package/template/.claude/skills/status/SKILL.md +17 -4
  44. package/template/.claude/skills/tests/SKILL.md +45 -3
  45. package/template/AGENTS.md +7 -2
  46. package/template/CLAUDE.md +7 -2
  47. package/template/religion/.state/settings-template.json +9 -0
  48. package/template/religion/learning/journal.md +4 -3
package/dist/lib/args.js CHANGED
@@ -7,18 +7,19 @@
7
7
  * the wrong place.
8
8
  */
9
9
  import { ADAPTERS } from "./install.js";
10
- const COMMANDS = ["install", "update", "status", "doctor", "dashboard", "help"];
10
+ const COMMANDS = ["install", "update", "uninstall", "status", "doctor", "dashboard", "help"];
11
11
  const WRITES = new Set(["install", "update"]);
12
+ const CHANGES = new Set(["install", "update", "uninstall"]);
12
13
  const REPORTS = new Set(["status", "doctor"]);
13
14
  const EVERY = new Set(COMMANDS);
14
15
  /** Every option, the commands it applies to, and what it does. Parsing dispatches on this alone. */
15
16
  const FLAGS = {
16
17
  "--help": { commands: EVERY, apply: (options) => void (options.command = "help") },
17
18
  "-h": { commands: EVERY, apply: (options) => void (options.command = "help") },
18
- "--dry-run": { commands: WRITES, apply: (options) => void (options.dryRun = true) },
19
+ "--dry-run": { commands: CHANGES, apply: (options) => void (options.dryRun = true) },
19
20
  "--force": { commands: WRITES, apply: (options) => void (options.force = true) },
20
- "--yes": { commands: WRITES, apply: (options) => void (options.yes = true) },
21
- "-y": { commands: WRITES, apply: (options) => void (options.yes = true) },
21
+ "--yes": { commands: CHANGES, apply: (options) => void (options.yes = true) },
22
+ "-y": { commands: CHANGES, apply: (options) => void (options.yes = true) },
22
23
  "--json": { commands: REPORTS, apply: (options) => void (options.json = true) },
23
24
  ...Object.fromEntries(Object.keys(ADAPTERS).map((adapter) => [
24
25
  `--${adapter}`,
@@ -34,6 +35,15 @@ const FLAGS = {
34
35
  };
35
36
  /** The options each command accepts, read from the same table parsing uses. */
36
37
  export const GRAMMAR = grammar();
38
+ /** Every exit code the tool uses, and what each means. Public: a meaning never changes within 1.x. */
39
+ export const EXIT_CODES = {
40
+ 0: "the command did what it was asked",
41
+ 1: "it ran and reports failure: no project found, a failing doctor check,\n" +
42
+ "conflicts, declined merges or linked files left by install or update,\n" +
43
+ "a file uninstall meant to change but left, a project installed by a\n" +
44
+ "newer version, or an unexpected error",
45
+ 2: "usage error: nothing was written"
46
+ };
37
47
  function grammar() {
38
48
  const table = {};
39
49
  for (const command of COMMANDS)
@@ -94,7 +104,7 @@ function unknownCommand(word) {
94
104
  ? `Unknown command '${word}'. Did you mean '${closest.command}'?`
95
105
  : `Unknown command '${word}', and no directory of that name exists.`;
96
106
  }
97
- function editDistance(a, b) {
107
+ export function editDistance(a, b) {
98
108
  let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
99
109
  for (let i = 1; i <= a.length; i += 1) {
100
110
  const current = [i];
@@ -0,0 +1,29 @@
1
+ /**
2
+ * What changed between two versions, read from the changelog the package ships.
3
+ *
4
+ * The format is what Changesets writes: `## 1.2.0` per release, `### Minor Changes` groups,
5
+ * and `- 6097eb0: text` entries that may run across several lines.
6
+ */
7
+ import { isNewer } from "./install.js";
8
+ /** Releases after `from`, up to and including `to`, newest first, each change as its first sentence. */
9
+ export function changesSince(changelog, from, to) {
10
+ if (!changelog || typeof from !== "string")
11
+ return [];
12
+ return changelog.split(/^(?=## )/m).flatMap((section) => {
13
+ const version = /^## (\S+)/.exec(section)?.[1];
14
+ // A prerelease compares equal to its release, so its notes would be shown twice or never.
15
+ if (!version || version.includes("-") || !isNewer(version, from) || isNewer(version, to))
16
+ return [];
17
+ const changes = section
18
+ .split(/^- /m)
19
+ .slice(1)
20
+ .map((entry) => firstSentence(entry.split(/\n\s*\n|\n(?=#)|\n\s+- /)[0].replace(/\s+/g, " ").replace(/^[0-9a-f]{7,40}: /, "")))
21
+ .filter(Boolean);
22
+ return [{ version, changes }];
23
+ });
24
+ }
25
+ // A full stop ends the sentence only before a capital or a code span, so "e.g. `--yes`" does not.
26
+ function firstSentence(text) {
27
+ const end = text.search(/(?<!\b(?:e\.g|i\.e|etc|vs))\.(\s+[A-Z`]|$)/);
28
+ return (end < 0 ? text : text.slice(0, end + 1)).trim();
29
+ }
@@ -7,10 +7,11 @@
7
7
  import http from "node:http";
8
8
  import path from "node:path";
9
9
  import { runDoctor } from "./doctor.js";
10
- import { readManifest } from "./install.js";
10
+ import { editedSinceInstall, readManifest } from "./install.js";
11
11
  import { readIfPresent, statePath } from "./paths.js";
12
12
  import { parseInbox, parseSpec, readHistory, readProjectState } from "./state.js";
13
- import { computeStatus } from "./status.js";
13
+ import { describeSettings } from "./settings.js";
14
+ import { computeStatus, DISTILL_AFTER } from "./status.js";
14
15
  /** `version` is the running tool's, shown beside the version that installed the project. */
15
16
  export async function startDashboard(root, version = null) {
16
17
  let port = 0;
@@ -66,6 +67,7 @@ function send(response, status, type, body) {
66
67
  }
67
68
  async function readState(root, version) {
68
69
  const state = await readProjectState(root);
70
+ const changes = await editedSinceInstall(root);
69
71
  return {
70
72
  project: path.basename(root),
71
73
  status: computeStatus(state),
@@ -74,10 +76,12 @@ async function readState(root, version) {
74
76
  activity: await readActivity(root),
75
77
  work: parseSpec(await readIfPresent(statePath(root, "context", "current-work.md"))),
76
78
  history: await readHistory(root),
79
+ learning: { ...state.learning, distillAfter: DISTILL_AFTER },
77
80
  health: {
78
- checks: await runDoctor(root),
81
+ checks: await runDoctor(root, changes),
79
82
  config: state.config,
80
- install: await readInstall(root),
83
+ settings: describeSettings(state.config),
84
+ install: await readInstall(root, changes),
81
85
  tool: version,
82
86
  inbox: parseInbox(await readIfPresent(statePath(root, "context", "inbox.md"))),
83
87
  questions: state.openQuestions
@@ -85,7 +89,7 @@ async function readState(root, version) {
85
89
  };
86
90
  }
87
91
  /** The manifest is the project's file and may be hand-edited, so only well-formed parts are shown. */
88
- async function readInstall(root) {
92
+ async function readInstall(root, changes) {
89
93
  const manifest = await readManifest(root);
90
94
  if (!manifest || typeof manifest !== "object")
91
95
  return null;
@@ -93,7 +97,9 @@ async function readInstall(root) {
93
97
  return {
94
98
  version: typeof version === "string" ? version : null,
95
99
  adapters: Array.isArray(adapters) ? adapters.filter((a) => typeof a === "string") : [],
96
- managed: managed && typeof managed === "object" ? Object.keys(managed).length : 0
100
+ managed: managed && typeof managed === "object" ? Object.keys(managed).length : 0,
101
+ edited: changes?.edited ?? [],
102
+ missing: changes?.missing ?? []
97
103
  };
98
104
  }
99
105
  async function readActivity(root) {
@@ -416,13 +422,16 @@ code { font-family: var(--font-mono); font-size: var(--text-xs); background: var
416
422
  .view { display: none; scroll-margin-top: 100vh; }
417
423
  .view:target, main:not(:has(.view:target)) #overview { display: block; }
418
424
  .app:not(:has(.view:target)) .to-overview, .app:has(#overview:target) .to-overview, .app:has(#work:target) .to-work,
419
- .app:has(#findings:target) .to-findings, .app:has(#history:target) .to-history, .app:has(#health:target) .to-health { background: var(--surface-2); color: var(--text); }
425
+ .app:has(#findings:target) .to-findings, .app:has(#history:target) .to-history, .app:has(#deferred:target) .to-deferred,
426
+ .app:has(#learning:target) .to-learning,
427
+ .app:has(#health:target) .to-health { background: var(--surface-2); color: var(--text); }
420
428
  .tile.bad .big { color: var(--block); }
421
429
  .checks span { align-items: flex-start; }
422
430
  .checks span::before { flex-shrink: 0; margin-top: calc((var(--leading-base) * 1em - var(--dot-size)) / 2); }
423
431
  .checks span.bad::before { background: var(--block); }
424
432
  .checklist .bad { width: var(--dot-size); height: var(--dot-size); border-radius: 50%; background: var(--block); }
425
433
  .callout.bad { color: var(--block); }
434
+ .rows td .bad { color: var(--block); }
426
435
  .callout.bad::before { background: var(--block); }
427
436
  .live.off { color: var(--block); }
428
437
  .live.off::before { background: var(--block); }
@@ -430,6 +439,7 @@ code { font-family: var(--font-mono); font-size: var(--text-xs); background: var
430
439
  .empty { margin: 0; color: var(--text-3); font-size: var(--text-sm); }
431
440
  button.chip { font-family: inherit; cursor: pointer; }
432
441
  [data-pick] { cursor: pointer; }
442
+ .section-label [data-pick] { color: var(--accent); } .section-label [data-pick].sel { text-decoration: underline; }
433
443
  .nowrap { white-space: nowrap; }
434
444
  :focus-visible { outline: var(--mark-ring) solid var(--accent); outline-offset: var(--space-1); }
435
445
  @media (max-width: 900px) {
@@ -446,6 +456,8 @@ button.chip { font-family: inherit; cursor: pointer; }
446
456
  <a class="to-work" href="#work">Work <span class="count" id="count-work"></span></a>
447
457
  <a class="to-findings" href="#findings">Findings <span class="count" id="count-findings"></span></a>
448
458
  <a class="to-history" href="#history">History <span class="count" id="count-history"></span></a>
459
+ <a class="to-deferred" href="#deferred">Deferred <span class="count" id="count-deferred"></span></a>
460
+ <a class="to-learning" href="#learning">Learning <span class="count" id="count-learning"></span></a>
449
461
  <a class="to-health" href="#health">Health <span class="count" id="count-health"></span></a>
450
462
  </div>
451
463
  <div class="rail-foot">
@@ -491,6 +503,18 @@ button.chip { font-family: inherit; cursor: pointer; }
491
503
  <aside class="panel sticky" id="hs-detail"></aside>
492
504
  </div>
493
505
  </section>
506
+ <section class="view" id="deferred">
507
+ <div class="head"><h1>Deferred</h1><div class="meta"><span>history/</span></div></div>
508
+ <section class="panel"><header><h2>What each item set aside</h2><a href="#history">Open the chosen item in History</a></header><div class="body" id="df-body"></div></section>
509
+ </section>
510
+ <section class="view" id="learning">
511
+ <div class="head"><h1>Learning</h1><div class="meta"><span>learning/lessons.md</span><span>learning/journal.md</span></div></div>
512
+ <div class="tiles" id="ln-tiles"></div>
513
+ <div class="grid">
514
+ <section class="panel"><header><h2>Lessons</h2><span class="count">loaded every session</span></header><div class="body" id="ln-lessons"></div></section>
515
+ <section class="panel"><header><h2>Journal</h2><span class="count" id="ln-count"></span></header><div class="body" id="ln-journal"></div></section>
516
+ </div>
517
+ </section>
494
518
  <section class="view" id="health">
495
519
  <div class="head"><h1>Activity and health</h1><div class="meta"><span>religion/.state/</span><span>religion/config.json</span></div></div>
496
520
  <div id="hl-notice"></div>
@@ -608,6 +632,8 @@ button.chip { font-family: inherit; cursor: pointer; }
608
632
  renderWork();
609
633
  renderFindings();
610
634
  renderHistory();
635
+ renderDeferred();
636
+ renderLearning();
611
637
  renderHealth();
612
638
  }
613
639
 
@@ -619,6 +645,9 @@ button.chip { font-family: inherit; cursor: pointer; }
619
645
  $("count-findings").textContent = pending ? String(pending) : "";
620
646
  $("count-findings").className = data.status.findings.blocking.length ? "count warn" : "count";
621
647
  $("count-history").textContent = data.history.length ? String(data.history.length) : "";
648
+ const deferred = sum(data.history, (a) => a.deferred.length);
649
+ $("count-deferred").textContent = deferred ? String(deferred) : "";
650
+ $("count-learning").textContent = data.learning.undecided ? String(data.learning.undecided) : "";
622
651
  $("count-health").textContent = passing + "/" + data.health.checks.length;
623
652
  $("count-health").className = passing < data.health.checks.length ? "count warn" : "count";
624
653
  }
@@ -631,7 +660,7 @@ button.chip { font-family: inherit; cursor: pointer; }
631
660
  const closed = sum(data.history, (a) => a.findings.filter((f) => f.status === "closed").length);
632
661
  $("ov-meta").innerHTML = "<span>" + esc(setting("git", "mode", "trunk")) + " mode</span><span>review " + esc(setting("workflow", "stepReview", "every")) + "</span>";
633
662
  $("ov-tiles").innerHTML =
634
- tile("Plan", s.plan.done + "<small> / " + s.plan.total + " items</small>", s.plan.nextItem ? "Next: " + esc(short(s.plan.nextItem)) : s.plan.total ? "Complete" : "No items yet", bar([[s.plan.done, "--ok"]], s.plan.total)) +
663
+ tile("Plan", s.plan.done + "<small> / " + s.plan.total + " items</small>", s.plan.nextItem ? "Next: " + esc(short(s.plan.nextItem)) : !s.plan.total ? "No items yet" : s.plan.done === s.plan.total ? "Complete" : "Every remaining item waits", bar([[s.plan.done, "--ok"]], s.plan.total)) +
635
664
  (s.work.active
636
665
  ? tile("Active item", s.work.stepsDone + "<small> / " + s.work.stepsTotal + " steps</small>", esc(s.work.title || "Untitled"), bar([[s.work.stepsDone, "--progress"]], s.work.stepsTotal))
637
666
  : tile("Active item", "None", "Nothing in progress")) +
@@ -703,7 +732,7 @@ button.chip { font-family: inherit; cursor: pointer; }
703
732
  (step.doneWhen ? "<p class='when'><b>Done when</b>" + md(step.doneWhen) + "</p>" : "") + "</div>"
704
733
  ).join("")
705
734
  : "<div class='body'>" + empty("The spec has no build steps yet.") + "</div>") +
706
- "</section></div><div class='col'>" +
735
+ "</section>" + evidencePanel(work.evidence) + "</div><div class='col'>" +
707
736
  "<section class='panel'><header><h2>Scope</h2></header><div class='body'><div class='label section-label'>In</div>" + list(work.inScope) + "<div class='label section-label'>Out</div>" + list(work.outOfScope) + "</div></section>" +
708
737
  "<section class='panel'><header><h2>Files and areas</h2><span class='count'>" + work.files.length + "</span></header><div class='body'>" + list(work.files) + "</div></section>" +
709
738
  "<section class='panel'><header><h2>How it is built</h2></header><div class='body'><dl class='kv'>" +
@@ -715,6 +744,18 @@ button.chip { font-family: inherit; cursor: pointer; }
715
744
  "</dl></div></section></div></div>";
716
745
  }
717
746
 
747
+ // What check recorded, so the proof is on the page rather than only in a past conversation.
748
+ function evidencePanel(evidence) {
749
+ const verdict = { pass: "ok", fail: "bad", "could not check": "bad" };
750
+ return "<section class='panel'><header><h2>Evidence</h2><span class='count'>" + (evidence && evidence.stamp ? esc(evidence.stamp) : "") + "</span></header><div class='body'>" +
751
+ (evidence && evidence.entries.length
752
+ ? "<ul class='checklist'>" + evidence.entries.map((e) =>
753
+ "<li><span class='" + (verdict[e.verdict] || "bad") + "'></span><span>" + md(e.outcome) + "</span><span class='detail'>" + md(e.detail) + "</span><span class='blocks'>" + esc(e.verdict) + "</span></li>"
754
+ ).join("") + "</ul>"
755
+ : empty("Not checked yet. check records here what it observed for each outcome.")) +
756
+ "</div></section>";
757
+ }
758
+
718
759
  const STATUS_ORDER = ["open", "fixed", "unverified", "accepted", "closed", "invalid"];
719
760
  const idNumber = (f) => Number(String(f.id).slice(2)) || 0;
720
761
  const pill = (status) => "<span class='status " + (["open", "fixed", "closed"].indexOf(status) >= 0 ? status : "unverified") + "'>" + esc(status) + "</span>";
@@ -811,7 +852,7 @@ button.chip { font-family: inherit; cursor: pointer; }
811
852
  shipped.forEach((a) => (kinds[a.kind] = (kinds[a.kind] || 0) + 1));
812
853
  const s = data.status;
813
854
  $("hs-tiles").innerHTML =
814
- tile("Plan", s.plan.done + "<small> / " + s.plan.total + " items</small>", s.plan.nextItem ? "Next: " + esc(short(s.plan.nextItem)) : s.plan.total ? "Complete" : "No items yet", bar([[s.plan.done, "--ok"]], s.plan.total)) +
855
+ tile("Plan", s.plan.done + "<small> / " + s.plan.total + " items</small>", s.plan.nextItem ? "Next: " + esc(short(s.plan.nextItem)) : !s.plan.total ? "No items yet" : s.plan.done === s.plan.total ? "Complete" : "Every remaining item waits", bar([[s.plan.done, "--ok"]], s.plan.total)) +
815
856
  tile("Shipped", String(shipped.length), shipped.length ? Object.keys(kinds).map((k) => kinds[k] + " " + esc(k)).join(", ") : "Nothing archived yet") +
816
857
  tile("Commits", String(commits), shipped.length ? Math.round(commits / shipped.length) + " per item on average" : "None recorded") +
817
858
  tile("Repairs", String(sum(shipped, (a) => a.repairs)), "Steps added after an audit") +
@@ -861,13 +902,43 @@ button.chip { font-family: inherit; cursor: pointer; }
861
902
  return a[0] - b[0] < 0 || (a[0] === b[0] && (a[1] - b[1] < 0 || (a[1] === b[1] && a[2] < b[2])));
862
903
  }
863
904
 
864
- function flatten(value, prefix, rows) {
865
- if (value && typeof value === "object" && !Array.isArray(value)) {
866
- Object.keys(value).forEach((k) => flatten(value[k], prefix ? prefix + "." + k : k, rows));
867
- } else {
868
- rows.push([prefix, typeof value === "string" ? value : JSON.stringify(value)]);
869
- }
870
- return rows;
905
+ // A record, not a queue: archives are never edited, so nothing here says whether a note was later done.
906
+ function renderDeferred() {
907
+ const left = data.history.filter((a) => a.deferred.length);
908
+ const total = sum(left, (a) => a.deferred.length);
909
+ $("df-body").innerHTML = left.length
910
+ ? "<p class='prose dim'>" + total + " notes from " + left.length + " items, newest first: what each chose to leave, as it stood when the item closed. " +
911
+ "A note may since have been done; the archive is not changed when it is. Choose an item to open it in History.</p>" +
912
+ left.map((a) =>
913
+ "<div class='divider'></div><div class='label section-label'><span tabindex='0' data-pick='archive' data-value='" + esc(a.kind + "/" + a.file) + "'" +
914
+ (ui.archive === a.kind + "/" + a.file ? " class='sel'" : "") + ">" +
915
+ esc((a.number ? a.number + ". " : "") + (a.title || a.file)) + "</span> <span class='faint'>" + esc(a.type || a.kind) + "</span></div>" +
916
+ "<ul class='list'>" + a.deferred.map((d) => "<li>" + md(d) + "</li>").join("") + "</ul>"
917
+ ).join("")
918
+ : empty("Nothing deferred yet. complete archives what each item set aside, and it gathers here.");
919
+ }
920
+
921
+ function renderLearning() {
922
+ const l = data.learning;
923
+ const due = l.undecided >= l.distillAfter;
924
+ $("ln-tiles").innerHTML =
925
+ tile("Lessons", String(l.lessons.length), l.lessons.length ? "Loaded into every session" : "None promoted yet") +
926
+ tile("Journal", String(l.journal.length), l.journal.length ? "One entry per finished item" : "No entries yet") +
927
+ tile("Waiting for distill", String(l.undecided), due ? "Enough to be worth a distill pass" : "Too few to group yet", "", due ? "bad" : "");
928
+ $("ln-lessons").innerHTML = l.lessons.length
929
+ ? l.lessons.map((lesson) => "<p class='prose'>" + md(lesson) + "</p>").join("")
930
+ : empty("Nothing learned yet. distill promotes an observation here once it has recurred or been confirmed.");
931
+ $("ln-count").textContent = l.journal.length ? l.journal.length + " entries, newest first" : "";
932
+ const field = (label, value) => (value ? "<dt>" + label + "</dt><dd>" + md(value) + "</dd>" : "");
933
+ $("ln-journal").innerHTML = l.journal.length
934
+ ? l.journal.slice().reverse().map((e) =>
935
+ "<div class='label section-label'>" + esc(e.heading) + " <span class='tag" + (e.decided ? "" : " progress") + "'>" + (e.decided ? "decided" : "waiting") + "</span></div>" +
936
+ (e.surprised || e.wentWrong || e.deferred
937
+ ? "<dl class='kv'>" + field("Surprised", e.surprised) + field("Went wrong", e.wentWrong) + field("Deferred", e.deferred) +
938
+ (e.archive ? "<dt>Archive</dt><dd class='mono dim'>" + esc(e.archive) + "</dd>" : "") + "</dl>"
939
+ : e.summary ? "<p class='prose'>" + md(e.summary) + "</p>" : "")
940
+ ).join("<div class='divider'></div>")
941
+ : empty("No entries yet. complete appends one for every item it finishes.");
871
942
  }
872
943
 
873
944
  function renderHealth() {
@@ -893,14 +964,23 @@ button.chip { font-family: inherit; cursor: pointer; }
893
964
  "<li><span class='" + (c.ok ? "ok" : "bad") + "'></span><span>" + esc(c.name) + "</span><span class='detail'>" + md(c.detail) + "</span><span class='blocks'>" +
894
965
  (c.blocks === "nothing" ? "advisory" : "blocks " + esc(c.blocks)) + "</span></li>"
895
966
  ).join("");
896
- const settings = flatten(config(), "", []);
897
- $("hl-config").innerHTML = settings.length
898
- ? "<table class='rows'><tr><th>Setting</th><th>Value</th></tr>" + settings.map((r) => "<tr><td class='file'>" + esc(r[0]) + "</td><td class='mono'>" + esc(r[1]) + "</td></tr>").join("") + "</table>"
899
- : "<div class='body'>" + empty("No configuration. The built-in defaults apply.") + "</div>";
967
+ $("hl-config").innerHTML = "<table class='rows'><tr><th>Setting</th><th>Here</th><th>Default</th><th>Accepts</th></tr>" +
968
+ h.settings.map((r) =>
969
+ "<tr><td><span class='file'>" + esc(r.key) + "</span>" + (r.summary ? "<div class='dim'>" + esc(r.summary) + "</div>" : "") +
970
+ (r.problem ? "<div class='bad'>" + esc(r.problem) + "</div>" : "") + "</td>" +
971
+ "<td class='mono'>" + (r.value === null ? "<span class='faint'>default</span>" : esc(r.value)) + "</td>" +
972
+ "<td class='mono'>" + (r.default ? md(r.default) : "") + "</td><td>" + (r.accepts ? md(r.accepts) : "") + "</td></tr>"
973
+ ).join("") + "</table>";
900
974
  $("hl-install").innerHTML =
901
975
  (install
902
976
  ? "<dl class='kv'>" + field("Installed by", esc(install.version || "unknown"), "mono") + field("This tool", esc(h.tool || "unknown"), "mono") +
903
- field("Adapters", esc(install.adapters.map((x) => ADAPTER_NAMES[x] || x).join(", ") || "none")) + field("Managed", install.managed + " files") + "</dl>"
977
+ field("Adapters", esc(install.adapters.map((x) => ADAPTER_NAMES[x] || x).join(", ") || "none")) + field("Managed", install.managed + " files") + "</dl>" +
978
+ "<div class='divider'></div><div class='label section-label'>Changed since install</div>" +
979
+ (install.edited.length || install.missing.length
980
+ ? "<ul class='list'>" + install.edited.map((f) => "<li class='mono'>" + esc(f) + "</li>").join("") +
981
+ install.missing.map((f) => "<li class='mono'>" + esc(f) + " <span class='faint'>missing</span></li>").join("") + "</ul>" +
982
+ "<p class='prose dim'>update keeps edited files and reports them as changed locally; it puts missing ones back.</p>"
983
+ : empty("Nothing. Every skill and hook matches what was installed."))
904
984
  : empty("No install record. Installing or updating writes religion/.state/manifest.json.")) +
905
985
  "<div class='divider'></div><div class='label section-label'>Inbox</div>" +
906
986
  (h.inbox.length ? h.inbox.map((n) => "<p class='prose'>" + (n.date ? "<span class='faint mono'>" + esc(n.date) + "</span> " : "") + md(n.text) + "</p>").join("") : empty("Empty.")) +
@@ -6,7 +6,9 @@
6
6
  */
7
7
  import fs from "node:fs/promises";
8
8
  import path from "node:path";
9
- import { exists, readIfPresent, STATE_DIR, statePath } from "./paths.js";
9
+ import { exists, readIfPresent, SKILL_TREES, STATE_DIR, statePath } from "./paths.js";
10
+ import { editedSinceInstall, isPlainFile } from "./install.js";
11
+ import { SETTINGS, settingProblems } from "./settings.js";
10
12
  import { readProjectState } from "./state.js";
11
13
  const REQUIRED = [
12
14
  "config.json",
@@ -26,17 +28,10 @@ const REQUIRED = [
26
28
  "history/spikes",
27
29
  "reference"
28
30
  ];
29
- /** The values each enumerated setting accepts. Other settings are not checked. */
30
- export const ALLOWED = {
31
- "workflow.stepReview": ["every", "item"],
32
- "git.mode": ["trunk", "branch-per-item", "pull-request"],
33
- "git.checkpoints": ["none", "every-step", "squash"],
34
- "verification.logicTests": ["when-configured", "required"],
35
- "verification.uiEvidence": ["when-available", "required"],
36
- "qualityGates.audit": ["manual", "when-sensitive", "always"],
37
- "qualityGates.check": ["manual", "when-behavioral", "always"]
38
- };
39
- export async function runDoctor(root) {
31
+ /** The values each enumerated setting accepts, as the surface record lists them. */
32
+ export const ALLOWED = Object.fromEntries(Object.entries(SETTINGS).flatMap(([key, setting]) => (setting.kind === "enum" ? [[key, setting.values]] : [])));
33
+ /** `changes` lets a caller that already compared the managed files share the result. */
34
+ export async function runDoctor(root, changes) {
40
35
  const results = [];
41
36
  const state = await readProjectState(root);
42
37
  const missing = [];
@@ -54,7 +49,7 @@ export async function runDoctor(root) {
54
49
  results.push({ name: "configuration", ok: false, detail: "config.json does not parse", blocks: "work" });
55
50
  }
56
51
  else {
57
- const bad = invalidSettings(state.config);
52
+ const bad = settingProblems(state.config);
58
53
  results.push({
59
54
  name: "configuration",
60
55
  ok: bad.length === 0,
@@ -62,7 +57,7 @@ export async function runDoctor(root) {
62
57
  blocks: "work"
63
58
  });
64
59
  }
65
- const trees = [".claude/skills", ".agents/skills"];
60
+ const trees = SKILL_TREES;
66
61
  const present = {};
67
62
  for (const tree of trees) {
68
63
  const dir = path.join(root, tree);
@@ -132,21 +127,134 @@ export async function runDoctor(root) {
132
127
  detail: (await exists(path.join(root, ".git"))) ? "repository present" : "not a git repository",
133
128
  blocks: "completion"
134
129
  });
130
+ results.push(managedFilesCheck(changes === undefined ? await editedSinceInstall(root) : changes));
131
+ results.push(await readHooks(root));
135
132
  return results;
136
133
  }
137
- function invalidSettings(config) {
138
- const problems = [];
139
- if (config === null || typeof config !== "object")
140
- return ["not an object"];
141
- for (const [key, allowed] of Object.entries(ALLOWED)) {
142
- const value = key.split(".").reduce((node, part) => (node && typeof node === "object" ? node[part] : undefined), config);
143
- if (value === undefined)
144
- continue;
145
- if (!allowed.includes(String(value))) {
146
- problems.push(`${key} is "${String(value)}", expected one of ${allowed.join(", ")}`);
147
- }
134
+ const SETTINGS_FILE = ".claude/settings.json";
135
+ const LOCAL_SETTINGS_FILE = ".claude/settings.local.json";
136
+ /**
137
+ * The hooks this version wires, held equal to the shipped settings template by verification.
138
+ * Not the project's own copy of that template, which is seeded once and goes stale.
139
+ */
140
+ export const HOOKS = [
141
+ { event: "PreToolUse", command: "node .claude/hooks/guard-rendered-output.mjs" },
142
+ { event: "Stop", command: "node .claude/hooks/write-handoff.mjs" },
143
+ { event: "PostToolUse", command: "node .claude/hooks/scan-untrusted-input.mjs" },
144
+ { event: "PreToolUse", command: "node .claude/hooks/warn-on-drift.mjs" }
145
+ ];
146
+ async function readHooks(root) {
147
+ const files = new Set();
148
+ for (const { command } of HOOKS) {
149
+ const file = hookFile(command);
150
+ // Existence only: a hook file is never read here, so one reached through a link still counts.
151
+ if (file && (await exists(path.join(root, ...file.split("/")))))
152
+ files.add(file);
153
+ }
154
+ return hooksCheck({
155
+ installed: await exists(path.join(root, ".claude", "skills")),
156
+ wanted: HOOKS,
157
+ settings: await readSettings(root, SETTINGS_FILE),
158
+ local: await readSettings(root, LOCAL_SETTINGS_FILE),
159
+ files
160
+ });
161
+ }
162
+ /** A settings file's text, read only when it is a plain file reached without a link. */
163
+ async function readSettings(root, relative) {
164
+ if (await isPlainFile(root, relative))
165
+ return readIfPresent(path.join(root, ...relative.split("/")));
166
+ const parts = relative.split("/");
167
+ for (let i = 1; i <= parts.length; i += 1) {
168
+ const stat = await fs.lstat(path.join(root, ...parts.slice(0, i))).catch(() => null);
169
+ if (!stat)
170
+ return null;
171
+ if (stat.isSymbolicLink())
172
+ return "link";
173
+ }
174
+ return "not-file";
175
+ }
176
+ /** Every `(event, command)` a settings file registers, or null when it does not parse. */
177
+ export function parseHooks(source) {
178
+ if (source === null)
179
+ return null;
180
+ let parsed;
181
+ try {
182
+ parsed = JSON.parse(source);
183
+ }
184
+ catch {
185
+ return null;
148
186
  }
149
- return problems;
187
+ const hooks = parsed && typeof parsed === "object" ? parsed.hooks : undefined;
188
+ if (!hooks || typeof hooks !== "object")
189
+ return [];
190
+ return Object.entries(hooks).flatMap(([event, groups]) => (Array.isArray(groups) ? groups : []).flatMap((group) => {
191
+ const inner = group && typeof group === "object" ? group.hooks : undefined;
192
+ return (Array.isArray(inner) ? inner : []).flatMap((hook) => {
193
+ const command = hook && typeof hook === "object" ? hook.command : undefined;
194
+ return typeof command === "string" ? [{ event, command }] : [];
195
+ });
196
+ }));
197
+ }
198
+ // `node .claude/hooks/x.mjs`, or the `"$CLAUDE_PROJECT_DIR"/...` form Claude Code's own docs use.
199
+ const HOOK_COMMAND = /\bnode\s+"?(?:\$\{?CLAUDE_PROJECT_DIR\}?"?\/)?(\.claude\/hooks\/[\w.-]+\.mjs)"?(?:\s|$)/;
200
+ export function hookFile(command) {
201
+ return HOOK_COMMAND.exec(command)?.[1] ?? null;
202
+ }
203
+ function disablesHooks(source) {
204
+ try {
205
+ return source !== null && JSON.parse(source)?.disableAllHooks === true;
206
+ }
207
+ catch {
208
+ return false;
209
+ }
210
+ }
211
+ /**
212
+ * Whether Claude Code's settings register every hook the wiring template names, under the same
213
+ * event, with each hook's file present. Matchers are not compared: a user may tune them.
214
+ */
215
+ export function hooksCheck(input) {
216
+ const result = (ok, detail) => ({ name: "hooks", ok, detail, blocks: "nothing" });
217
+ if (!input.installed)
218
+ return result(true, "Claude Code is not installed here");
219
+ if (input.settings === "link")
220
+ return result(false, `${SETTINGS_FILE} is reached through a link, so it was not read`);
221
+ if (input.settings === "not-file")
222
+ return result(false, `${SETTINGS_FILE} is not a regular file`);
223
+ const local = typeof input.local === "string" && input.local !== "link" && input.local !== "not-file" ? input.local : null;
224
+ if (input.settings === null && local === null)
225
+ return result(false, `${SETTINGS_FILE} is missing; a fresh install writes it`);
226
+ const shared = input.settings === null ? [] : parseHooks(input.settings);
227
+ if (shared === null)
228
+ return result(false, `${SETTINGS_FILE} does not parse`);
229
+ if (disablesHooks(input.settings) || disablesHooks(local))
230
+ return result(false, "disableAllHooks is on, so no hook runs");
231
+ const registered = [...shared, ...(parseHooks(local) ?? [])];
232
+ const unwired = input.wanted.filter((w) => !registered.some((r) => r.event === w.event && hookFile(r.command) === hookFile(w.command)));
233
+ const fileless = input.wanted.filter((w) => {
234
+ const file = hookFile(w.command);
235
+ return file !== null && !input.files.has(file);
236
+ });
237
+ if (unwired.length === 0 && fileless.length === 0)
238
+ return result(true, `all ${input.wanted.length} wired`);
239
+ const name = (hook) => `${hookFile(hook.command)?.split("/").pop() ?? hook.command} (${hook.event})`;
240
+ const parts = [
241
+ unwired.length ? `not registered in ${SETTINGS_FILE} or ${LOCAL_SETTINGS_FILE}: ${unwired.map(name).join(", ")}; copy them from a fresh install's settings` : "",
242
+ fileless.length ? `missing hook file: ${fileless.map(name).join(", ")}; run update` : ""
243
+ ].filter(Boolean);
244
+ return result(false, parts.join("; "));
245
+ }
246
+ const LISTED = 5;
247
+ /** Advisory: an edited managed file is its owner's choice, and `update` keeps it rather than overwriting it. */
248
+ export function managedFilesCheck(changes) {
249
+ const result = (detail) => ({ name: "managed files", ok: true, detail, blocks: "nothing" });
250
+ if (!changes)
251
+ return result("no install record to compare with");
252
+ const { edited, missing, total } = changes;
253
+ if (edited.length === 0 && missing.length === 0)
254
+ return result(`all ${total} unchanged since install`);
255
+ const changed = [...edited, ...missing.map((file) => `${file} (missing)`)];
256
+ const shown = changed.slice(0, LISTED).join(", ") + (changed.length > LISTED ? `, and ${changed.length - LISTED} more` : "");
257
+ return result(`${edited.length} edited and ${missing.length} missing of ${total} since install: ${shown}`);
150
258
  }
151
259
  export function doctorReport(checks) {
152
260
  return { schemaVersion: 1, healthy: checks.every((check) => check.ok), checks: [...checks] };
@@ -17,7 +17,7 @@ export const ADAPTERS = {
17
17
  copilot: { label: "GitHub Copilot", trees: [".agents/skills"], entry: ["AGENTS.md"] },
18
18
  opencode: { label: "OpenCode", trees: [".agents/skills"], entry: ["AGENTS.md"] }
19
19
  };
20
- const MANIFEST_PATH = `${STATE_DIR}/.state/manifest.json`;
20
+ export const MANIFEST_PATH = `${STATE_DIR}/.state/manifest.json`;
21
21
  const MANIFEST = path.join(...MANIFEST_PATH.split("/"));
22
22
  /** Where originals are copied before install or update changes them, relative to the project. */
23
23
  export const BACKUPS = `${STATE_DIR}/.state/backups`;
@@ -121,7 +121,7 @@ async function planDropped(templateRoot, target, previous) {
121
121
  * asked anything. A case-insensitive disk would otherwise let `RELIGION/` or `.CLAUDE/`
122
122
  * reach files the comparison was meant to exclude.
123
123
  */
124
- function retiringTree(relative) {
124
+ export function retiringTree(relative) {
125
125
  if (relative.includes("\\") || path.posix.isAbsolute(relative))
126
126
  return null;
127
127
  if (path.posix.normalize(relative) !== relative || relative.split("/").includes(".."))
@@ -130,7 +130,7 @@ function retiringTree(relative) {
130
130
  return [...trees].find((tree) => relative.startsWith(`${tree}/`)) ?? null;
131
131
  }
132
132
  /** Every component a real directory and the last a regular file, with no symbolic link anywhere. */
133
- async function isPlainFile(target, relative) {
133
+ export async function isPlainFile(target, relative) {
134
134
  const parts = relative.split("/");
135
135
  let current = target;
136
136
  for (const [index, part] of parts.entries()) {
@@ -247,13 +247,13 @@ async function rebuildOrConflict(target, relative, recorded) {
247
247
  const genuine = typeof recorded === "string" && /^[0-9a-f]{16}$/.test(recorded);
248
248
  return genuine && (await isPlainFile(target, relative)) ? "rebuild" : "conflict";
249
249
  }
250
- async function backUp(target, relative) {
250
+ export async function backUp(target, relative) {
251
251
  const backup = path.join(target, ...`${BACKUPS}/${relative}`.split("/"));
252
252
  await fs.mkdir(path.dirname(backup), { recursive: true });
253
253
  await fs.copyFile(path.join(target, relative), backup);
254
254
  }
255
255
  /** Whether any existing component of a project path is a symbolic link. Missing ones are not. */
256
- async function throughLink(target, relative) {
256
+ export async function throughLink(target, relative) {
257
257
  let current = target;
258
258
  for (const part of relative.split("/")) {
259
259
  current = path.join(current, part);
@@ -369,7 +369,7 @@ export function manifestRefusal(manifest, packageVersion) {
369
369
  }
370
370
  return null;
371
371
  }
372
- function isNewer(candidate, than) {
372
+ export function isNewer(candidate, than) {
373
373
  const a = parseVersion(candidate);
374
374
  const b = parseVersion(than);
375
375
  if (!a || !b)
@@ -386,6 +386,43 @@ function parseVersion(version) {
386
386
  const match = /^(\d+)\.(\d+)\.(\d+)(?:-.*)?$/.exec(version.trim());
387
387
  return match ? match.slice(1, 4).map(Number) : null;
388
388
  }
389
+ /**
390
+ * Managed files whose contents no longer match what was installed, and those that are gone.
391
+ *
392
+ * Only plain files under a skill or hook tree are compared, the same line `update` draws for
393
+ * what it may retire: the manifest is a file in someone's repository, so a recorded path must
394
+ * not be able to make this read anything else. Entry files are left out because `update`
395
+ * merges them, so an edit there is expected rather than a change it will report.
396
+ */
397
+ export async function editedSinceInstall(target) {
398
+ const managed = (await readManifest(target))?.managed;
399
+ if (!managed || typeof managed !== "object")
400
+ return null;
401
+ const edited = [];
402
+ const missing = [];
403
+ let total = 0;
404
+ for (const [relative, recorded] of Object.entries(managed)) {
405
+ if (typeof recorded !== "string" || !retiringTree(relative))
406
+ continue;
407
+ total += 1;
408
+ const file = path.join(target, ...relative.split("/"));
409
+ // Anything there that is not the plain, readable file installed counts as edited: a link,
410
+ // a directory or an unreadable file is what `update` reports too, and this check must
411
+ // never be the thing that makes doctor fail.
412
+ if (!(await fs.lstat(file).catch(() => null))) {
413
+ missing.push(relative);
414
+ }
415
+ else if (!(await isPlainFile(target, relative))) {
416
+ edited.push(relative);
417
+ }
418
+ else {
419
+ const contents = await fs.readFile(file).catch(() => null);
420
+ if (contents === null || hash(contents) !== recorded)
421
+ edited.push(relative);
422
+ }
423
+ }
424
+ return { edited: edited.sort(), missing: missing.sort(), total };
425
+ }
389
426
  export async function readManifest(target) {
390
427
  try {
391
428
  return JSON.parse(await fs.readFile(path.join(target, MANIFEST), "utf8"));