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.
- package/CHANGELOG.md +466 -0
- package/README.md +2 -1
- package/dist/bin/religion.js +87 -7
- package/dist/lib/args.js +15 -5
- package/dist/lib/changelog.js +29 -0
- package/dist/lib/dashboard.js +102 -22
- package/dist/lib/doctor.js +134 -26
- package/dist/lib/install.js +43 -6
- package/dist/lib/paths.js +18 -0
- package/dist/lib/settings.js +156 -0
- package/dist/lib/state.js +261 -8
- package/dist/lib/status.js +38 -3
- package/dist/lib/uninstall.js +216 -0
- package/package.json +2 -2
- package/template/.agents/skills/auto/SKILL.md +9 -3
- package/template/.agents/skills/check/SKILL.md +69 -2
- package/template/.agents/skills/ci/SKILL.md +28 -0
- package/template/.agents/skills/complete/SKILL.md +76 -5
- package/template/.agents/skills/distill/SKILL.md +42 -19
- package/template/.agents/skills/feature/SKILL.md +19 -4
- package/template/.agents/skills/feature/reference/spec-template.md +1 -0
- package/template/.agents/skills/fix/SKILL.md +7 -0
- package/template/.agents/skills/implement/SKILL.md +37 -9
- package/template/.agents/skills/implement/reference/worktrees.md +175 -0
- package/template/.agents/skills/scout/SKILL.md +2 -2
- package/template/.agents/skills/spike/SKILL.md +8 -0
- package/template/.agents/skills/status/SKILL.md +17 -4
- package/template/.agents/skills/tests/SKILL.md +45 -3
- package/template/.claude/hooks/warn-on-drift.mjs +74 -0
- package/template/.claude/hooks/write-handoff.mjs +54 -2
- package/template/.claude/skills/auto/SKILL.md +9 -3
- package/template/.claude/skills/check/SKILL.md +69 -2
- package/template/.claude/skills/ci/SKILL.md +28 -0
- package/template/.claude/skills/complete/SKILL.md +76 -5
- package/template/.claude/skills/distill/SKILL.md +42 -19
- package/template/.claude/skills/feature/SKILL.md +19 -4
- package/template/.claude/skills/feature/reference/spec-template.md +1 -0
- package/template/.claude/skills/fix/SKILL.md +7 -0
- package/template/.claude/skills/implement/SKILL.md +37 -9
- package/template/.claude/skills/implement/reference/worktrees.md +175 -0
- package/template/.claude/skills/scout/SKILL.md +2 -2
- package/template/.claude/skills/spike/SKILL.md +8 -0
- package/template/.claude/skills/status/SKILL.md +17 -4
- package/template/.claude/skills/tests/SKILL.md +45 -3
- package/template/AGENTS.md +7 -2
- package/template/CLAUDE.md +7 -2
- package/template/religion/.state/settings-template.json +9 -0
- 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:
|
|
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:
|
|
21
|
-
"-y": { commands:
|
|
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
|
+
}
|
package/dist/lib/dashboard.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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(#
|
|
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" : "
|
|
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
|
|
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" : "
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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.")) +
|
package/dist/lib/doctor.js
CHANGED
|
@@ -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
|
|
30
|
-
export const ALLOWED =
|
|
31
|
-
|
|
32
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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] };
|
package/dist/lib/install.js
CHANGED
|
@@ -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"));
|