@jjanczur/tyran 0.1.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/.claude-plugin/marketplace.json +22 -0
- package/.claude-plugin/plugin.json +22 -0
- package/CHANGELOG.md +245 -0
- package/LICENSE +201 -0
- package/README.md +468 -0
- package/agents/.gitkeep +0 -0
- package/agents/implementer.md +60 -0
- package/agents/retro.md +88 -0
- package/agents/reviewer.md +55 -0
- package/agents/scout.md +60 -0
- package/bin/tyran.mjs +172 -0
- package/hooks/HOOK-CONTRACT-MEASURED.md +377 -0
- package/hooks/hooks.json +83 -0
- package/hooks/scripts/.gitkeep +0 -0
- package/hooks/scripts/evidence-gate.mjs +705 -0
- package/hooks/scripts/hook-io.mjs +813 -0
- package/hooks/scripts/policy-gate.mjs +1402 -0
- package/hooks/scripts/pre-compact.mjs +211 -0
- package/hooks/scripts/retro-gate.mjs +191 -0
- package/hooks/scripts/secrets-gate.mjs +1683 -0
- package/hooks/scripts/session-start.mjs +358 -0
- package/hooks/scripts/write-guard.mjs +475 -0
- package/package.json +52 -0
- package/scripts/desc-budget.mjs +139 -0
- package/scripts/doctor.mjs +1267 -0
- package/scripts/hooks-check.mjs +1312 -0
- package/scripts/invisible.mjs +346 -0
- package/scripts/journal.mjs +747 -0
- package/scripts/project.mjs +981 -0
- package/scripts/scan-control-chars.mjs +547 -0
- package/scripts/scan-repo.mjs +287 -0
- package/scripts/schema.mjs +467 -0
- package/scripts/stop-check.mjs +89 -0
- package/scripts/tiers.mjs +324 -0
- package/scripts/yaml-lite.mjs +383 -0
- package/skills/browser-check/SKILL.md +118 -0
- package/skills/code-review/SKILL.md +83 -0
- package/skills/deslop/SKILL.md +97 -0
- package/skills/doctor/SKILL.md +35 -0
- package/skills/fidelity-gate/SKILL.md +132 -0
- package/skills/hello/SKILL.md +16 -0
- package/skills/pr-feedback/SKILL.md +85 -0
- package/skills/prompt-tuning/SKILL.md +102 -0
- package/skills/retro/SKILL.md +69 -0
- package/skills/root-cause/SKILL.md +89 -0
- package/skills/run/SKILL.md +285 -0
- package/skills/setup/SKILL.md +79 -0
- package/skills/skill-writing/SKILL.md +99 -0
- package/skills/status/SKILL.md +31 -0
- package/templates/.gitkeep +0 -0
- package/templates/config.yaml +44 -0
- package/templates/knowledge.yaml +23 -0
- package/templates/policies/autonomy.yaml +61 -0
- package/templates/project-command/SKILL.md +16 -0
|
@@ -0,0 +1,1267 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* doctor — consistency check for a Tyran state directory (`.tyran/`).
|
|
4
|
+
*
|
|
5
|
+
* It finds the inconsistencies a human will not see by reading files:
|
|
6
|
+
* projections that drifted from their journal, agents the journal still
|
|
7
|
+
* believes are working, leases nobody holds any more, journal damage,
|
|
8
|
+
* policy rules that can never match a path, and configuration that no
|
|
9
|
+
* longer validates.
|
|
10
|
+
*
|
|
11
|
+
* It NEVER repairs anything. Every finding carries a severity, a location
|
|
12
|
+
* and a command you can paste to fix it — a diagnosis that leaves you
|
|
13
|
+
* guessing is not a diagnosis.
|
|
14
|
+
*
|
|
15
|
+
* Design rules (same as the rest of the core):
|
|
16
|
+
* - zero dependencies, plain Node >= 22;
|
|
17
|
+
* - deterministic: the same state renders the same bytes, always. Nothing
|
|
18
|
+
* reads the wall clock — "now" defaults to each journal's own last event
|
|
19
|
+
* (override with --now);
|
|
20
|
+
* - one implementation per rule: spawn pairing comes from journal.mjs,
|
|
21
|
+
* projection freshness from project.mjs, path classification from
|
|
22
|
+
* schema.mjs. Doctor asks those modules, it never re-derives their
|
|
23
|
+
* answers (ADR-18).
|
|
24
|
+
*
|
|
25
|
+
* CLI:
|
|
26
|
+
* node doctor.mjs --state [--dir <.tyran>] [--json]
|
|
27
|
+
* [--now <iso>] [--stale-hours <n>]
|
|
28
|
+
* node doctor.mjs --hooks [--plugin-root <dir>] [--json]
|
|
29
|
+
* Exit: 0 healthy (info findings allowed) · 1 findings (error or warning)
|
|
30
|
+
* · 2 usage / I/O error
|
|
31
|
+
*
|
|
32
|
+
* The two modes answer different questions and are deliberately separate.
|
|
33
|
+
* `--state` asks whether the RECORD of the work is consistent. `--hooks` asks
|
|
34
|
+
* whether the gates that produce that record can fire at all — a question
|
|
35
|
+
* with no answer inside the state directory, because a plugin whose hooks are
|
|
36
|
+
* dead writes no state to be inconsistent with.
|
|
37
|
+
*/
|
|
38
|
+
import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
39
|
+
import { join, dirname, resolve } from 'node:path';
|
|
40
|
+
import { fileURLToPath } from 'node:url';
|
|
41
|
+
import { checkHooks, DEFAULT_PLUGIN_ROOT } from './hooks-check.mjs';
|
|
42
|
+
import { readJournal, validateJournal, pairSpawns, tail } from './journal.mjs';
|
|
43
|
+
import {
|
|
44
|
+
checkFile,
|
|
45
|
+
inline,
|
|
46
|
+
naturalCompare,
|
|
47
|
+
renderProjections,
|
|
48
|
+
PROGRESS_FILE,
|
|
49
|
+
STATE_FILE,
|
|
50
|
+
} from './project.mjs';
|
|
51
|
+
import { classifyPath, normalizePath, validateFile, MANDATORY_KERNEL_PATHS } from './schema.mjs';
|
|
52
|
+
|
|
53
|
+
/** Severity order is also the report order. `info` never fails the check. */
|
|
54
|
+
export const SEVERITIES = Object.freeze(['error', 'warning', 'info']);
|
|
55
|
+
|
|
56
|
+
/** Default: an agent open this long without the journal moving on is stuck. */
|
|
57
|
+
export const DEFAULT_STALE_HOURS = 4;
|
|
58
|
+
|
|
59
|
+
const JOURNAL_FILE = 'journal.jsonl';
|
|
60
|
+
|
|
61
|
+
class UsageError extends Error {}
|
|
62
|
+
class IOError extends Error {}
|
|
63
|
+
|
|
64
|
+
// --------------------------------------------------------------- helpers
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Shell-quote one argument of a fix command.
|
|
68
|
+
*
|
|
69
|
+
* Plain POSIX single-quoting for anything printable ASCII (journal.mjs has
|
|
70
|
+
* the identical three lines but does not export them). Anything else falls
|
|
71
|
+
* back to ANSI-C quoting, `$'...'`, with every other byte written `\xNN`.
|
|
72
|
+
*
|
|
73
|
+
* That fallback is not cosmetic. `data.agent` is guarded by
|
|
74
|
+
* `agentNameProblem`, but `init` has NO such validator — `validateEvent`
|
|
75
|
+
* only asks for a non-empty string — and neither do lease resource names.
|
|
76
|
+
* A journal carrying `"init": "demo<ESC>[2K<ESC>[1A"` would otherwise put a
|
|
77
|
+
* real erase-line + cursor-up sequence into a printed command and wipe the
|
|
78
|
+
* finding above it: the diagnosis that flags the hostile value would be the
|
|
79
|
+
* thing that hides it.
|
|
80
|
+
*
|
|
81
|
+
* `\xNN` rather than `\uHHHH` on purpose: `$'\u...'` needs bash >= 4.2 and
|
|
82
|
+
* macOS still ships bash 3.2. Byte escapes work in bash 3.2, bash 5 and
|
|
83
|
+
* zsh alike, and the command stays runnable and byte-exact — the value is
|
|
84
|
+
* escaped, never replaced.
|
|
85
|
+
*/
|
|
86
|
+
const PRINTABLE_ASCII_ONLY = /^[ -~]*$/;
|
|
87
|
+
|
|
88
|
+
function sq(value) {
|
|
89
|
+
const s = String(value);
|
|
90
|
+
if (PRINTABLE_ASCII_ONLY.test(s)) return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
91
|
+
let out = '';
|
|
92
|
+
for (const byte of Buffer.from(s, 'utf8')) {
|
|
93
|
+
if (byte === 0x27) out += "\\'";
|
|
94
|
+
else if (byte === 0x5c) out += '\\\\';
|
|
95
|
+
else if (byte >= 0x20 && byte < 0x7f) out += String.fromCharCode(byte);
|
|
96
|
+
else out += `\\x${byte.toString(16).padStart(2, '0')}`;
|
|
97
|
+
}
|
|
98
|
+
return `$'${out}'`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Every value that came out of a journal passes through project.inline():
|
|
103
|
+
* in a plugin, `data` is written by agents processing someone else's repo,
|
|
104
|
+
* so an agent name may carry an ANSI escape or a bidi override and would
|
|
105
|
+
* otherwise rewrite the report a human is reading. Reusing project.mjs's
|
|
106
|
+
* sanitizer rather than writing a second one is the ADR-18 rule applied to
|
|
107
|
+
* a security control. Cost: exotic values are shown HTML-escaped.
|
|
108
|
+
*/
|
|
109
|
+
const show = inline;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* JSON for a `--data` argument, with every non-ASCII codepoint written as a
|
|
113
|
+
* `\uXXXX` escape. This is an ESCAPER, not a sanitizer: `JSON.parse` gives
|
|
114
|
+
* back the exact original value, so the printed command still does the right
|
|
115
|
+
* thing — while a lease resource carrying a bidi override (lease `data` is
|
|
116
|
+
* free-form and validated by nobody) can no longer rewrite the terminal line
|
|
117
|
+
* it is printed on. `JSON.stringify` alone escapes C0 but passes bidi
|
|
118
|
+
* through.
|
|
119
|
+
*/
|
|
120
|
+
function jsonArg(value) {
|
|
121
|
+
return JSON.stringify(value).replace(/[^ -~]/g, (c) =>
|
|
122
|
+
`\\u${c.codePointAt(0).toString(16).padStart(4, '0')}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Severity is a property of the finding CODE, declared once, here.
|
|
128
|
+
*
|
|
129
|
+
* It used to be a literal at each of the 40 call sites, which meant 40
|
|
130
|
+
* independent places where an `error` could become an `info` and every test
|
|
131
|
+
* would stay green — a clean bill of health for a state layer nobody
|
|
132
|
+
* checked, which is the one output this tool must never produce. One table
|
|
133
|
+
* is one mutation surface, and `tests/unit/doctor.test.mjs` pins it whole,
|
|
134
|
+
* including the branches that are hard to reach at runtime.
|
|
135
|
+
*
|
|
136
|
+
* Prototype-free so a code called `constructor` cannot resolve to an
|
|
137
|
+
* inherited member (the same reason yaml-lite does it — review E2S2-R2).
|
|
138
|
+
*/
|
|
139
|
+
export const SEVERITY_BY_CODE = Object.freeze(
|
|
140
|
+
Object.assign(Object.create(null), {
|
|
141
|
+
// journal
|
|
142
|
+
'journal-missing': 'warning',
|
|
143
|
+
'journal-unreadable': 'error',
|
|
144
|
+
'journal-not-a-file': 'error',
|
|
145
|
+
'journal-invalid': 'error',
|
|
146
|
+
'journal-truncated': 'warning',
|
|
147
|
+
'journal-warning': 'warning',
|
|
148
|
+
'journal-lock-present': 'warning',
|
|
149
|
+
'journal-init-mismatch': 'error',
|
|
150
|
+
'journal-cross-init-pairing': 'error',
|
|
151
|
+
'journal-mixed-initiatives': 'warning',
|
|
152
|
+
'check-failed': 'error',
|
|
153
|
+
// spawns
|
|
154
|
+
'spawn-open': 'info',
|
|
155
|
+
'spawn-stale': 'warning',
|
|
156
|
+
'spawn-duplicate': 'warning',
|
|
157
|
+
'spawn-orphan-report': 'warning',
|
|
158
|
+
'agent-name-unusable': 'warning',
|
|
159
|
+
// leases
|
|
160
|
+
'lease-open': 'info',
|
|
161
|
+
'lease-orphan': 'warning',
|
|
162
|
+
'lease-expired': 'warning',
|
|
163
|
+
'lease-release-by-non-holder': 'warning',
|
|
164
|
+
// projections
|
|
165
|
+
'projection-drift': 'warning',
|
|
166
|
+
'projection-absent': 'info',
|
|
167
|
+
'projection-missing': 'warning',
|
|
168
|
+
'projection-blocked': 'warning',
|
|
169
|
+
'projection-failed': 'error',
|
|
170
|
+
'projection-unreadable': 'error',
|
|
171
|
+
// configuration
|
|
172
|
+
'config-missing': 'info',
|
|
173
|
+
'config-invalid': 'error',
|
|
174
|
+
'config-unreadable': 'error',
|
|
175
|
+
'knowledge-invalid': 'error',
|
|
176
|
+
'knowledge-unreadable': 'error',
|
|
177
|
+
'knowledge-not-a-directory': 'warning',
|
|
178
|
+
'policy-missing': 'info',
|
|
179
|
+
'policy-invalid': 'error',
|
|
180
|
+
'policy-unreadable': 'error',
|
|
181
|
+
'policies-unreadable': 'error',
|
|
182
|
+
'policies-not-a-directory': 'warning',
|
|
183
|
+
'policy-kernel-downgrade': 'error',
|
|
184
|
+
'policy-rule-dead': 'warning',
|
|
185
|
+
'policy-rule-overruled': 'warning',
|
|
186
|
+
// layout
|
|
187
|
+
'no-state-dir': 'info',
|
|
188
|
+
'state-not-a-directory': 'error',
|
|
189
|
+
'state-unreadable': 'error',
|
|
190
|
+
'state-stray-file': 'warning',
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The severity of a finding code, or a loud failure.
|
|
196
|
+
*
|
|
197
|
+
* Exported so the guard itself can be tested: not every code reaches
|
|
198
|
+
* `SEVERITY_BY_CODE` as a literal — two families are built from a template
|
|
199
|
+
* variable (`${kind}-invalid`, `${label}-not-a-directory`) and a static scan
|
|
200
|
+
* of the source cannot see those. A typo there would otherwise produce a
|
|
201
|
+
* finding with `severity: undefined`, which sorts and counts as nothing and
|
|
202
|
+
* would quietly stop failing the check.
|
|
203
|
+
*/
|
|
204
|
+
export function severityFor(code) {
|
|
205
|
+
const severity = SEVERITY_BY_CODE[code];
|
|
206
|
+
if (severity === undefined) {
|
|
207
|
+
throw new Error(`doctor bug: finding code "${code}" has no severity in SEVERITY_BY_CODE`);
|
|
208
|
+
}
|
|
209
|
+
return severity;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function finding(code, where, message, fix = null) {
|
|
213
|
+
return { severity: severityFor(code), code, where, message, fix };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Explicit, total, stable ordering — determinism is a guarantee here.
|
|
218
|
+
*
|
|
219
|
+
* The `a[1] - b[1]` tie-break is deliberately REDUNDANT: the findings are
|
|
220
|
+
* index-decorated and `Array.prototype.sort` has been stable since ES2019,
|
|
221
|
+
* so it can only ever agree with the order the checks produced (measured:
|
|
222
|
+
* 200 000 randomized orderings over a corpus of naturalCompare ties, 0
|
|
223
|
+
* differences — an equivalent mutant, not an untested branch). It stays
|
|
224
|
+
* because the ordering is a documented guarantee and should not rest on a
|
|
225
|
+
* reader remembering which engines sort stably.
|
|
226
|
+
*/
|
|
227
|
+
function sortFindings(findings) {
|
|
228
|
+
const rank = (s) => SEVERITIES.indexOf(s);
|
|
229
|
+
return findings
|
|
230
|
+
.map((f, i) => [f, i])
|
|
231
|
+
.sort((a, b) => {
|
|
232
|
+
const bySeverity = rank(a[0].severity) - rank(b[0].severity);
|
|
233
|
+
if (bySeverity !== 0) return bySeverity;
|
|
234
|
+
const key = (f) => `${f.code} ${f.where} ${f.message}`;
|
|
235
|
+
return naturalCompare(key(a[0]), key(b[0])) || a[1] - b[1];
|
|
236
|
+
})
|
|
237
|
+
.map(([f]) => f);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function sortedNames(names) {
|
|
241
|
+
return [...names].sort((a, b) => naturalCompare(a, b) || (a < b ? -1 : a > b ? 1 : 0));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Node prefixes most fs errors with their own code, so the naive
|
|
246
|
+
* `code: message` form prints "EACCES: EACCES: permission denied". The
|
|
247
|
+
* errno is the actionable part of these findings; print it exactly once.
|
|
248
|
+
*/
|
|
249
|
+
function errText(err) {
|
|
250
|
+
const code = err.code ?? err.name;
|
|
251
|
+
const message = String(err.message ?? '');
|
|
252
|
+
return message.startsWith(code + ':') ? message : code + ': ' + message;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function isDirectory(path) {
|
|
256
|
+
try {
|
|
257
|
+
return statSync(path).isDirectory();
|
|
258
|
+
} catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function hoursBetween(fromTs, toTs) {
|
|
264
|
+
const from = Date.parse(fromTs);
|
|
265
|
+
const to = Date.parse(toTs);
|
|
266
|
+
if (Number.isNaN(from) || Number.isNaN(to)) return null;
|
|
267
|
+
return (to - from) / 3_600_000;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// -------------------------------------------------------- policy analysis
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* A policy rule only ever meets paths that `classifyPath` has normalized:
|
|
274
|
+
* repo-relative, POSIX separators, no `.`/`..` segments, no leading slash.
|
|
275
|
+
* A glob whose own literal instantiation does not survive that
|
|
276
|
+
* normalization can therefore never match anything, whatever it looks like.
|
|
277
|
+
*
|
|
278
|
+
* That is the whole detection method, and it is deliberately NOT a table of
|
|
279
|
+
* possible repo paths (which would be unbounded and would only ever prove a
|
|
280
|
+
* rule live, never dead). Instead each glob is instantiated into witness
|
|
281
|
+
* paths — `**` becomes real segments, `*` becomes a segment — and the
|
|
282
|
+
* witness is pushed through `normalizePath`. If normalization changes or
|
|
283
|
+
* rejects EVERY witness, the literal parts of the glob contain exactly the
|
|
284
|
+
* characters normalization removes (`./`, a leading `/`, a backslash, a
|
|
285
|
+
* `..`), and those parts have to appear verbatim in any match. The rule is
|
|
286
|
+
* dead. If one witness survives unchanged, the glob matches at least that
|
|
287
|
+
* path and the rule is live; the check stays silent.
|
|
288
|
+
*
|
|
289
|
+
* Conservative on purpose: a false "dead" alarm on a live security rule
|
|
290
|
+
* would be worse than the miss it is meant to prevent.
|
|
291
|
+
*/
|
|
292
|
+
export function witnessesFor(glob) {
|
|
293
|
+
const shapes = [
|
|
294
|
+
glob.replace(/\*\*/g, 'w1/w2').replace(/\*/g, 'w3'),
|
|
295
|
+
glob.replace(/\*\*/g, 'w1').replace(/\*/g, 'w3'),
|
|
296
|
+
glob.replace(/\*\*/g, 'w1/w2/w3').replace(/\*/g, 'w4'),
|
|
297
|
+
];
|
|
298
|
+
return [...new Set(shapes)];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Would `classifyPath` ever see this witness in this shape? */
|
|
302
|
+
function reachable(witness, repoRoot) {
|
|
303
|
+
const normalized = normalizePath(witness, repoRoot);
|
|
304
|
+
return normalized !== null && normalized !== '' && normalized === witness;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const NO_RULES = Object.freeze({ rules: [], default: 'AUTO' });
|
|
308
|
+
|
|
309
|
+
/** True when a path is protected unconditionally, whatever the rules say. */
|
|
310
|
+
function isKernelPath(path) {
|
|
311
|
+
return classifyPath(NO_RULES, path) === 'KERNEL';
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The glob the author most likely meant: strip a leading `/`, fold Windows
|
|
316
|
+
* separators, drop `./` segments. Wildcards survive untouched.
|
|
317
|
+
*/
|
|
318
|
+
function suggestedGlob(glob, repoRoot) {
|
|
319
|
+
const cleaned = String(glob).replace(/\\/g, '/').replace(/^\/+/, '');
|
|
320
|
+
const normalized = normalizePath(cleaned, repoRoot);
|
|
321
|
+
if (normalized === null || normalized === '' || normalized === glob) return null;
|
|
322
|
+
return normalized;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Rules that can never match a path — see witnessesFor() for the method. */
|
|
326
|
+
export function deadRules(policy, repoRoot) {
|
|
327
|
+
const out = [];
|
|
328
|
+
for (const [index, rule] of (policy?.rules ?? []).entries()) {
|
|
329
|
+
if (typeof rule?.path !== 'string' || rule.path.trim() === '') continue;
|
|
330
|
+
if (witnessesFor(rule.path).some((w) => reachable(w, repoRoot))) continue;
|
|
331
|
+
const suggestion = suggestedGlob(rule.path, repoRoot);
|
|
332
|
+
out.push({
|
|
333
|
+
index,
|
|
334
|
+
path: rule.path,
|
|
335
|
+
class: rule.class,
|
|
336
|
+
suggestion,
|
|
337
|
+
// A dead rule aimed INTO a protected namespace is the worst case: the
|
|
338
|
+
// author believes they classified `hooks/**` and nothing is written
|
|
339
|
+
// down anywhere that says they did not.
|
|
340
|
+
aimedAtKernel:
|
|
341
|
+
suggestion !== null &&
|
|
342
|
+
witnessesFor(suggestion).some((w) => reachable(w, repoRoot) && isKernelPath(w)),
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Rules that claim a path inside a protected kernel namespace and are
|
|
350
|
+
* therefore silently ignored there: `classifyPath` returns KERNEL for those
|
|
351
|
+
* paths BEFORE any rule is consulted.
|
|
352
|
+
*
|
|
353
|
+
* `validatePolicy` already rejects most spellings of this as an error, but
|
|
354
|
+
* its heuristic instantiates the rule's wildcards with filler segments, so
|
|
355
|
+
* a rule like "star-slash-x.mjs" slips through — it validates clean while
|
|
356
|
+
* quietly failing to cover `hooks/x.mjs`. This check covers the WHOLE-
|
|
357
|
+
* SEGMENT wildcard shapes: the rule's own wildcards are instantiated with
|
|
358
|
+
* segments of the PROTECTED path, which means the rule matches the
|
|
359
|
+
* candidate by construction (`**` absorbs separators, `*` gets a
|
|
360
|
+
* separator-free segment), so no second glob matcher is needed and a false
|
|
361
|
+
* positive is impossible.
|
|
362
|
+
*
|
|
363
|
+
* KNOWN GAP, measured, deliberately left open here: a wildcard INSIDE a
|
|
364
|
+
* segment is not covered, because the substitution gives `*` the whole
|
|
365
|
+
* segment. A rule spelled "h-star-slash-x.mjs" (wildcard inside the first
|
|
366
|
+
* segment) validates clean AND reaches `hooks/x.mjs`, and doctor stays
|
|
367
|
+
* silent about it — see docs/doctor.md. Closing that needs the
|
|
368
|
+
* real matcher — `globMatches` in schema.mjs, which is private, and
|
|
369
|
+
* exporting it is a change to a module this story may not touch (ADR-18
|
|
370
|
+
* forbids the alternative, a second copy). Tracked, and written down in
|
|
371
|
+
* docs/doctor.md under Known limits rather than papered over.
|
|
372
|
+
*/
|
|
373
|
+
export function overruledRules(policy, repoRoot) {
|
|
374
|
+
const out = [];
|
|
375
|
+
for (const [index, rule] of (policy?.rules ?? []).entries()) {
|
|
376
|
+
if (typeof rule?.path !== 'string' || rule.path.trim() === '' || rule.class === 'KERNEL') continue;
|
|
377
|
+
for (const protectedGlob of MANDATORY_KERNEL_PATHS) {
|
|
378
|
+
const base = protectedGlob.replace(/\/?\*\*$/, '');
|
|
379
|
+
const leaf = base.split('/').filter(Boolean).at(-1) ?? 'w';
|
|
380
|
+
const candidates = [
|
|
381
|
+
rule.path.replace(/\*\*/g, base).replace(/\*/g, leaf),
|
|
382
|
+
rule.path.replace(/\*\*/g, `${base}/w`).replace(/\*/g, leaf),
|
|
383
|
+
rule.path.replace(/\*\*/g, `w/${base}`).replace(/\*/g, leaf),
|
|
384
|
+
];
|
|
385
|
+
const hit = candidates.find((c) => reachable(c, repoRoot) && isKernelPath(c));
|
|
386
|
+
if (hit !== undefined) {
|
|
387
|
+
out.push({ index, path: rule.path, class: rule.class, protectedGlob, example: hit });
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ------------------------------------------------------------ state scan
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Findings for one initiative directory: `.tyran/state/<init>/`.
|
|
399
|
+
* `now` is the reference clock; when null, the journal's own last event is
|
|
400
|
+
* used, which keeps the output deterministic AND gives staleness a useful
|
|
401
|
+
* meaning: an agent is stale when the initiative moved on without it.
|
|
402
|
+
*/
|
|
403
|
+
function checkInitiative(stateDir, name, { now, staleHours }) {
|
|
404
|
+
const dir = join(stateDir, name);
|
|
405
|
+
const journalPath = join(dir, JOURNAL_FILE);
|
|
406
|
+
const at = show(journalPath);
|
|
407
|
+
const findings = [];
|
|
408
|
+
|
|
409
|
+
// `existsSync` answers false for ENOENT and for EACCES alike, so asking it
|
|
410
|
+
// whether the journal is there conflates "there is no journal" with "this
|
|
411
|
+
// process may not look". Those need opposite advice, and the wrong one is
|
|
412
|
+
// destructive: an unreadable directory used to be reported as an empty
|
|
413
|
+
// initiative together with a runnable `rm -r` that would have deleted an
|
|
414
|
+
// intact journal. stat() and read the errno.
|
|
415
|
+
let stat = null;
|
|
416
|
+
try {
|
|
417
|
+
stat = statSync(journalPath);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
if (err.code === 'ENOENT') {
|
|
420
|
+
findings.push(
|
|
421
|
+
finding(
|
|
422
|
+
'journal-missing',
|
|
423
|
+
show(dir),
|
|
424
|
+
`initiative directory with no ${JOURNAL_FILE} — nothing records what happened here`,
|
|
425
|
+
// Deliberately NOT a removal: this branch is a diagnosis, and a
|
|
426
|
+
// diagnosis that is allowed to be wrong must not hand out a
|
|
427
|
+
// command that destroys the thing it failed to find.
|
|
428
|
+
`ls -la ${sq(dir)} # then restore journal.jsonl from git, or remove the directory by hand`,
|
|
429
|
+
),
|
|
430
|
+
);
|
|
431
|
+
} else {
|
|
432
|
+
findings.push(
|
|
433
|
+
finding(
|
|
434
|
+
'journal-unreadable',
|
|
435
|
+
at,
|
|
436
|
+
`cannot stat the journal (${errText(err)}) — nothing about this initiative ` +
|
|
437
|
+
'was checked, and nothing here says the journal is missing',
|
|
438
|
+
`ls -la ${sq(dir)}`,
|
|
439
|
+
),
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
return { findings, events: 0 };
|
|
443
|
+
}
|
|
444
|
+
if (stat.isDirectory()) {
|
|
445
|
+
findings.push(
|
|
446
|
+
finding('journal-not-a-file', at, `${JOURNAL_FILE} is a directory, not a file`),
|
|
447
|
+
);
|
|
448
|
+
return { findings, events: 0 };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
let read;
|
|
452
|
+
try {
|
|
453
|
+
read = readJournal(journalPath);
|
|
454
|
+
} catch (err) {
|
|
455
|
+
findings.push(
|
|
456
|
+
finding('journal-unreadable', at, `cannot read the journal (${errText(err)})`),
|
|
457
|
+
);
|
|
458
|
+
return { findings, events: 0 };
|
|
459
|
+
}
|
|
460
|
+
const events = read.events;
|
|
461
|
+
const reference = now ?? events.at(-1)?.ts ?? null;
|
|
462
|
+
|
|
463
|
+
// Each check is fenced off. A journal is a file a human can edit, so a
|
|
464
|
+
// shape no reader anticipated (`"data": null` on a lease event throws
|
|
465
|
+
// inside journal.tail(), measured) must cost one check, not the whole
|
|
466
|
+
// report — a doctor that aborts on the first sick patient is worse than
|
|
467
|
+
// no doctor.
|
|
468
|
+
const guard = (label, fn) => {
|
|
469
|
+
try {
|
|
470
|
+
findings.push(...fn());
|
|
471
|
+
} catch (err) {
|
|
472
|
+
findings.push(
|
|
473
|
+
finding(
|
|
474
|
+
'check-failed',
|
|
475
|
+
at,
|
|
476
|
+
`the "${label}" check could not run on this journal (${errText(err)}) — the file holds a ` +
|
|
477
|
+
'shape the readers do not expect, which almost always means it was edited by hand',
|
|
478
|
+
`node scripts/journal.mjs validate ${sq(journalPath)}`,
|
|
479
|
+
),
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
guard('journal integrity', () => journalIntegrity(journalPath, at, read));
|
|
484
|
+
guard('one initiative per file', () => initiativeScope(journalPath, at, name, events));
|
|
485
|
+
guard('open spawns', () => spawnFindings(journalPath, at, name, events, reference, staleHours));
|
|
486
|
+
guard('leases', () => leaseFindings(journalPath, at, events, reference));
|
|
487
|
+
guard('projections', () => projectionFindings(journalPath, dir, at, read));
|
|
488
|
+
|
|
489
|
+
// A leftover lock directory means a writer either is inside its critical
|
|
490
|
+
// section right now, or died in it. journal.mjs steals such a lock after
|
|
491
|
+
// 10 s, so this never blocks — but per docs/journal.md the same window can
|
|
492
|
+
// make an append write its event AND throw, so a retry may have doubled it.
|
|
493
|
+
const lockDir = `${journalPath}.lock`;
|
|
494
|
+
if (existsSync(lockDir)) {
|
|
495
|
+
findings.push(
|
|
496
|
+
finding(
|
|
497
|
+
'journal-lock-present',
|
|
498
|
+
show(lockDir),
|
|
499
|
+
'a journal write lock is held — either a writer is running right now, or one died inside its critical section',
|
|
500
|
+
`ls -ld ${sq(lockDir)} # then, once no writer is running: rmdir ${sq(lockDir)}`,
|
|
501
|
+
),
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
return { findings, events: events.length };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function journalIntegrity(journalPath, at, read) {
|
|
508
|
+
const findings = [];
|
|
509
|
+
const result = validateJournal(journalPath);
|
|
510
|
+
|
|
511
|
+
for (const error of result.errors) {
|
|
512
|
+
findings.push(
|
|
513
|
+
// `show()` even though journal.mjs now escapes its own messages: the
|
|
514
|
+
// sibling sweep below has always done it, and a fuzz of this file found
|
|
515
|
+
// 137 leaks in exactly the one place that did not. Relying on the other
|
|
516
|
+
// module's discipline is how the gap got here in the first place.
|
|
517
|
+
finding('journal-invalid', at, show(error), `node scripts/journal.mjs validate ${sq(journalPath)}`),
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
if (read.truncatedTail) {
|
|
521
|
+
findings.push(
|
|
522
|
+
finding(
|
|
523
|
+
'journal-truncated',
|
|
524
|
+
at,
|
|
525
|
+
'the final line is truncated (a crash mid-write) — readers discard it, so that event is lost',
|
|
526
|
+
`tail -c 400 ${sq(journalPath)} # confirm the tail, then re-append the lost event`,
|
|
527
|
+
),
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Spawn-pairing findings are RAISED from journal.mjs, never recomputed
|
|
532
|
+
// (ADR-18). pairSpawns() gives the structured version so each one can get
|
|
533
|
+
// its own code and its own fix; validateJournal()'s warnings are then
|
|
534
|
+
// swept for anything those three shapes do not explain, so a warning class
|
|
535
|
+
// added to journal.mjs later surfaces here instead of vanishing.
|
|
536
|
+
const { open, orphanReports, badNames } = pairSpawns(read.events);
|
|
537
|
+
for (const [agent, spawns] of open) {
|
|
538
|
+
if (spawns.length < 2) continue;
|
|
539
|
+
findings.push(
|
|
540
|
+
finding(
|
|
541
|
+
'spawn-duplicate',
|
|
542
|
+
at,
|
|
543
|
+
`agent "${show(agent)}" has ${spawns.length} open spawns (since ${spawns.map((s) => show(s.ts)).join(', ')}) — ` +
|
|
544
|
+
'spawn/report pairing for this name is ambiguous; the journal was hand-edited or written before ADR-18',
|
|
545
|
+
closeSpawnHint(journalPath, spawns[0].init, agent) +
|
|
546
|
+
' # once per surplus spawn, oldest first',
|
|
547
|
+
),
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
for (const report of orphanReports) {
|
|
551
|
+
findings.push(
|
|
552
|
+
finding(
|
|
553
|
+
'spawn-orphan-report',
|
|
554
|
+
at,
|
|
555
|
+
`report for agent "${show(report.data.agent)}" at ${show(report.ts)} closes no open spawn — ` +
|
|
556
|
+
'its spawn event is missing, or an earlier report already closed it',
|
|
557
|
+
`node scripts/journal.mjs query ${sq(journalPath)} --ev spawn`,
|
|
558
|
+
),
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
for (const [raw, problem] of badNames) {
|
|
562
|
+
findings.push(
|
|
563
|
+
finding(
|
|
564
|
+
'agent-name-unusable',
|
|
565
|
+
at,
|
|
566
|
+
`data.agent ${show(raw)}: ${problem} — those events are excluded from spawn/report pairing entirely`,
|
|
567
|
+
`node scripts/journal.mjs validate ${sq(journalPath)}`,
|
|
568
|
+
),
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const explained = [
|
|
573
|
+
/^agent ".*" has \d+ open spawns/s,
|
|
574
|
+
/^report for agent /s,
|
|
575
|
+
/^unusable data\.agent /s,
|
|
576
|
+
];
|
|
577
|
+
for (const warning of result.warnings) {
|
|
578
|
+
if (explained.some((shape) => shape.test(warning))) continue;
|
|
579
|
+
findings.push(finding('journal-warning', at, show(warning)));
|
|
580
|
+
}
|
|
581
|
+
return findings;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* One initiative, one file (docs/journal.md). Nothing enforced that until
|
|
586
|
+
* now, and `pairSpawns` does not look at `init` at all — so a `report` from
|
|
587
|
+
* one initiative silently closes a `spawn` from another, and `validate`
|
|
588
|
+
* stays clean. Detected by running the SAME pairing function twice: once
|
|
589
|
+
* over the whole file, once per initiative. If the open sets disagree, a
|
|
590
|
+
* report crossed a boundary.
|
|
591
|
+
*/
|
|
592
|
+
function initiativeScope(journalPath, at, dirName, events) {
|
|
593
|
+
const findings = [];
|
|
594
|
+
const inits = [];
|
|
595
|
+
for (const e of events) {
|
|
596
|
+
if (typeof e?.init === 'string' && e.init !== '' && !inits.includes(e.init)) inits.push(e.init);
|
|
597
|
+
}
|
|
598
|
+
const foreign = inits.filter((i) => i !== dirName);
|
|
599
|
+
if (foreign.length > 0) {
|
|
600
|
+
findings.push(
|
|
601
|
+
finding(
|
|
602
|
+
'journal-init-mismatch',
|
|
603
|
+
at,
|
|
604
|
+
`events carry initiative ${foreign.map((i) => `"${show(i)}"`).join(', ')} but this journal lives in ` +
|
|
605
|
+
`state/${show(dirName)}/ — the directory name is the initiative slug`,
|
|
606
|
+
`node scripts/journal.mjs query ${sq(journalPath)} --init ${sq(foreign[0])}`,
|
|
607
|
+
),
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
if (inits.length <= 1) return findings;
|
|
611
|
+
|
|
612
|
+
const signature = (map) =>
|
|
613
|
+
sortedNames([...map.entries()].map(([agent, spawns]) => `${agent}:${spawns.length}`)).join(' ');
|
|
614
|
+
const whole = signature(pairSpawns(events).open);
|
|
615
|
+
const perInit = signature(
|
|
616
|
+
new Map(
|
|
617
|
+
inits.flatMap((init) => [...pairSpawns(events.filter((e) => e?.init === init)).open.entries()]),
|
|
618
|
+
),
|
|
619
|
+
);
|
|
620
|
+
if (whole !== perInit) {
|
|
621
|
+
findings.push(
|
|
622
|
+
finding(
|
|
623
|
+
'journal-cross-init-pairing',
|
|
624
|
+
at,
|
|
625
|
+
`a report from one initiative closed a spawn from another: pairing over the whole file leaves ` +
|
|
626
|
+
`[${show(whole) === '—' ? '' : show(whole)}] open, pairing per initiative leaves ` +
|
|
627
|
+
`[${show(perInit) === '—' ? '' : show(perInit)}] — spawn/report pairing ignores "init", so who is ` +
|
|
628
|
+
'still working cannot be answered from this file',
|
|
629
|
+
`node scripts/journal.mjs open-spawns ${sq(journalPath)} # then split the file: one initiative, one journal`,
|
|
630
|
+
),
|
|
631
|
+
);
|
|
632
|
+
} else {
|
|
633
|
+
findings.push(
|
|
634
|
+
finding(
|
|
635
|
+
'journal-mixed-initiatives',
|
|
636
|
+
at,
|
|
637
|
+
`this journal mixes ${inits.length} initiatives (${inits.map((i) => show(i)).join(', ')}) — the contract is ` +
|
|
638
|
+
'one initiative, one file; spawn/report pairing ignores "init" and will cross the boundary as soon as ' +
|
|
639
|
+
'two initiatives share an agent name',
|
|
640
|
+
`node scripts/journal.mjs query ${sq(journalPath)} --init ${sq(inits[0])}`,
|
|
641
|
+
),
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
return findings;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* The agent name goes in RAW (only shell-quoted): every name that reaches an
|
|
649
|
+
* open spawn has passed `agentNameProblem`, so it provably carries no
|
|
650
|
+
* control, bidi or zero-width characters. Sanitizing it here would produce a
|
|
651
|
+
* command that is safe and wrong — and a fix command that does not run is
|
|
652
|
+
* the failure mode this project has already shipped once.
|
|
653
|
+
*
|
|
654
|
+
* A name may legally start with `-`; it then goes after the POSIX
|
|
655
|
+
* end-of-options separator, which forces the flags to come first.
|
|
656
|
+
*/
|
|
657
|
+
function closeSpawnHint(journalPath, init, agent) {
|
|
658
|
+
const slug = typeof init === 'string' && init !== '' ? init : '<initiative>';
|
|
659
|
+
return String(agent).startsWith('-')
|
|
660
|
+
? `node scripts/journal.mjs close-spawn ${sq(journalPath)} --reason "<why>" ${sq(slug)} -- ${sq(agent)}`
|
|
661
|
+
: `node scripts/journal.mjs close-spawn ${sq(journalPath)} ${sq(slug)} ${sq(agent)} --reason "<why>"`;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function spawnFindings(journalPath, at, dirName, events, reference, staleHours) {
|
|
665
|
+
const findings = [];
|
|
666
|
+
const { open } = pairSpawns(events);
|
|
667
|
+
for (const agent of sortedNames(open.keys())) {
|
|
668
|
+
for (const spawn of open.get(agent)) {
|
|
669
|
+
const age = reference === null ? null : hoursBetween(spawn.ts, reference);
|
|
670
|
+
const where = `${show(spawn.data.ticket ?? 'no ticket')}, role ${show(spawn.data.role)}`;
|
|
671
|
+
if (age !== null && age >= staleHours) {
|
|
672
|
+
findings.push(
|
|
673
|
+
finding(
|
|
674
|
+
'spawn-stale',
|
|
675
|
+
at,
|
|
676
|
+
`agent "${show(agent)}" has been open for ${age.toFixed(1)} h of journal time (spawned ` +
|
|
677
|
+
`${show(spawn.ts)}, journal now at ${show(reference)}; threshold ${staleHours} h) — the initiative ` +
|
|
678
|
+
`moved on without it. ${where}`,
|
|
679
|
+
closeSpawnHint(journalPath, spawn.init ?? dirName, agent),
|
|
680
|
+
),
|
|
681
|
+
);
|
|
682
|
+
} else {
|
|
683
|
+
findings.push(
|
|
684
|
+
finding(
|
|
685
|
+
'spawn-open',
|
|
686
|
+
at,
|
|
687
|
+
`agent "${show(agent)}" is still working (spawned ${show(spawn.ts)}). ${where}`,
|
|
688
|
+
),
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return findings;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const EXPIRY_KEYS = Object.freeze(['expires', 'expires_at', 'until']);
|
|
697
|
+
|
|
698
|
+
function leaseFindings(journalPath, at, events, reference) {
|
|
699
|
+
const findings = [];
|
|
700
|
+
// tail() owns the "who holds what" rule, including the one that a release
|
|
701
|
+
// by a non-holder does not free anything. Doctor asks it; it does not
|
|
702
|
+
// re-fold lease events.
|
|
703
|
+
const { openLeases, mismatchedReleases } = tail(journalPath);
|
|
704
|
+
const { open } = pairSpawns(events);
|
|
705
|
+
const everSpawned = new Set();
|
|
706
|
+
for (const e of events) {
|
|
707
|
+
if (e?.ev === 'spawn' && typeof e.data?.agent === 'string') everSpawned.add(e.data.agent);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
for (const lease of [...openLeases].sort((a, b) => naturalCompare(String(a.resource), String(b.resource)))) {
|
|
711
|
+
const holder = lease.holder;
|
|
712
|
+
const acquired = [...events]
|
|
713
|
+
.reverse()
|
|
714
|
+
.find(
|
|
715
|
+
(e) => e?.ev === 'lease.acquired' && e.data?.resource === lease.resource && e.data?.holder === holder,
|
|
716
|
+
);
|
|
717
|
+
const expiry = acquired ? EXPIRY_KEYS.map((k) => acquired.data?.[k]).find((v) => v != null) : null;
|
|
718
|
+
const releaseHint =
|
|
719
|
+
`node scripts/journal.mjs append ${sq(journalPath)} lease.released ${sq(acquired?.init ?? '<initiative>')} ` +
|
|
720
|
+
`--data ${sq(jsonArg({ resource: String(lease.resource), holder: String(holder ?? '') }))}`;
|
|
721
|
+
|
|
722
|
+
if (expiry != null && reference !== null) {
|
|
723
|
+
const overdue = hoursBetween(String(expiry), reference);
|
|
724
|
+
if (overdue !== null && overdue > 0) {
|
|
725
|
+
findings.push(
|
|
726
|
+
finding(
|
|
727
|
+
'lease-expired',
|
|
728
|
+
at,
|
|
729
|
+
`lease on "${show(lease.resource)}" held by "${show(holder)}" expired ${overdue.toFixed(1)} h ago ` +
|
|
730
|
+
`(expiry ${show(expiry)}, journal now at ${show(reference)}) and was never released`,
|
|
731
|
+
releaseHint,
|
|
732
|
+
),
|
|
733
|
+
);
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (everSpawned.has(holder) && !open.has(holder)) {
|
|
738
|
+
findings.push(
|
|
739
|
+
finding(
|
|
740
|
+
'lease-orphan',
|
|
741
|
+
at,
|
|
742
|
+
`lease on "${show(lease.resource)}" is still held by "${show(holder)}", but that agent already reported ` +
|
|
743
|
+
'and is no longer working — the resource is blocked by nobody',
|
|
744
|
+
releaseHint,
|
|
745
|
+
),
|
|
746
|
+
);
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
findings.push(
|
|
750
|
+
finding('lease-open', at, `lease on "${show(lease.resource)}" held by "${show(holder)}"`),
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
for (const m of mismatchedReleases) {
|
|
755
|
+
findings.push(
|
|
756
|
+
finding(
|
|
757
|
+
'lease-release-by-non-holder',
|
|
758
|
+
at,
|
|
759
|
+
`"${show(m.by)}" released the lease on "${show(m.resource)}" but the holder is ${
|
|
760
|
+
m.holder === null ? '(nobody)' : `"${show(m.holder)}"` } — the lease stays open`,
|
|
761
|
+
`node scripts/journal.mjs tail ${sq(journalPath)}`,
|
|
762
|
+
),
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
return findings;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Projection freshness is checkFile()'s answer, byte for byte — doctor does
|
|
770
|
+
* not have its own opinion about what "fresh" means. A missing pair is not
|
|
771
|
+
* drift (nobody has generated them yet); a HALF-missing pair is, because
|
|
772
|
+
* something generated one and failed on the other.
|
|
773
|
+
*/
|
|
774
|
+
function projectionFindings(journalPath, dir, at, read) {
|
|
775
|
+
const findings = [];
|
|
776
|
+
let files;
|
|
777
|
+
let state;
|
|
778
|
+
try {
|
|
779
|
+
({ files, state } = renderProjections(read));
|
|
780
|
+
} catch (err) {
|
|
781
|
+
findings.push(
|
|
782
|
+
finding('projection-failed', at, `cannot render projections from this journal (${errText(err)})`),
|
|
783
|
+
);
|
|
784
|
+
return findings;
|
|
785
|
+
}
|
|
786
|
+
// project.mjs refuses to project a file with zero readable events and any
|
|
787
|
+
// damage, and exits 2. Comparing against a projection nobody can generate
|
|
788
|
+
// would report drift and hand out a fix command that cannot run — the one
|
|
789
|
+
// thing a diagnostic must not do.
|
|
790
|
+
if (state.total === 0 && (state.corruptLines + state.malformed > 0 || state.truncatedTail)) {
|
|
791
|
+
findings.push(
|
|
792
|
+
finding(
|
|
793
|
+
'projection-blocked',
|
|
794
|
+
show(dir),
|
|
795
|
+
'projections cannot be generated: the journal has no readable events and is damaged, so project.mjs ' +
|
|
796
|
+
'refuses it (exit 2). Repair the journal first — the errors above say where',
|
|
797
|
+
),
|
|
798
|
+
);
|
|
799
|
+
return findings;
|
|
800
|
+
}
|
|
801
|
+
const regenerate = `node scripts/project.mjs ${sq(journalPath)} --out-dir ${sq(dir)}`;
|
|
802
|
+
const names = [STATE_FILE, PROGRESS_FILE];
|
|
803
|
+
const present = names.filter((name) => existsSync(join(dir, name)));
|
|
804
|
+
|
|
805
|
+
// Two different facts, so two different codes rather than one code whose
|
|
806
|
+
// severity depends on where it was raised: NOTHING generated yet is a
|
|
807
|
+
// repo that has not run the projector (info), while HALF a pair means a
|
|
808
|
+
// run stopped part way (warning). One code, one severity — see
|
|
809
|
+
// SEVERITY_BY_CODE.
|
|
810
|
+
if (present.length === 0) {
|
|
811
|
+
findings.push(
|
|
812
|
+
finding(
|
|
813
|
+
'projection-absent',
|
|
814
|
+
show(dir),
|
|
815
|
+
`no ${STATE_FILE} / ${PROGRESS_FILE} yet — the journal is the source of truth, but nobody can read it`,
|
|
816
|
+
regenerate,
|
|
817
|
+
),
|
|
818
|
+
);
|
|
819
|
+
return findings;
|
|
820
|
+
}
|
|
821
|
+
for (const name of names) {
|
|
822
|
+
const path = join(dir, name);
|
|
823
|
+
if (!present.includes(name)) {
|
|
824
|
+
findings.push(
|
|
825
|
+
finding(
|
|
826
|
+
'projection-missing',
|
|
827
|
+
show(path),
|
|
828
|
+
`${name} is missing while ${present[0]} exists — a projection run stopped half way`,
|
|
829
|
+
regenerate,
|
|
830
|
+
),
|
|
831
|
+
);
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
let result;
|
|
835
|
+
try {
|
|
836
|
+
result = checkFile(path, files[name]);
|
|
837
|
+
} catch (err) {
|
|
838
|
+
findings.push(
|
|
839
|
+
finding('projection-unreadable', show(path), `cannot read the projection (${errText(err)})`),
|
|
840
|
+
);
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
if (result.ok) continue;
|
|
844
|
+
findings.push(
|
|
845
|
+
finding(
|
|
846
|
+
'projection-drift',
|
|
847
|
+
show(path),
|
|
848
|
+
`out of sync with the journal: ${result.reason}. It is generated — edits here are lost, and a stale ` +
|
|
849
|
+
'projection is what a human (or an agent reading STATE.md) will believe',
|
|
850
|
+
regenerate,
|
|
851
|
+
),
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
return findings;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// ------------------------------------------------------ config / policies
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* `{files, findings}`. A path that exists but is not a directory is
|
|
861
|
+
* REPORTED, never skipped: silently checking nothing and then printing a
|
|
862
|
+
* clean bill of health is the worst thing a diagnostic can do.
|
|
863
|
+
*/
|
|
864
|
+
function yamlFilesIn(dir, label) {
|
|
865
|
+
if (existsSync(dir) && !isDirectory(dir)) {
|
|
866
|
+
return {
|
|
867
|
+
files: [],
|
|
868
|
+
findings: [
|
|
869
|
+
finding(
|
|
870
|
+
`${label}-not-a-directory`,
|
|
871
|
+
show(dir),
|
|
872
|
+
`${label}/ exists but is not a directory — nothing in it was checked`,
|
|
873
|
+
),
|
|
874
|
+
],
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
if (!isDirectory(dir)) return { files: [], findings: [] };
|
|
878
|
+
let names;
|
|
879
|
+
try {
|
|
880
|
+
names = readdirSync(dir);
|
|
881
|
+
} catch (err) {
|
|
882
|
+
return {
|
|
883
|
+
files: [],
|
|
884
|
+
findings: [
|
|
885
|
+
finding(`${label}-unreadable`, show(dir), `cannot list the directory (${errText(err)})`),
|
|
886
|
+
],
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
return {
|
|
890
|
+
files: sortedNames(names.filter((n) => n.endsWith('.yaml') || n.endsWith('.yml'))).map((n) => join(dir, n)),
|
|
891
|
+
findings: [],
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** Always `{findings, doc}`; `doc` is null unless the file fully validated. */
|
|
896
|
+
function checkSchemaFile(kind, path) {
|
|
897
|
+
const findings = [];
|
|
898
|
+
let result;
|
|
899
|
+
try {
|
|
900
|
+
result = validateFile(kind, path);
|
|
901
|
+
} catch (err) {
|
|
902
|
+
return {
|
|
903
|
+
findings: [
|
|
904
|
+
finding(`${kind}-unreadable`, show(path), `cannot read the file (${errText(err)})`),
|
|
905
|
+
],
|
|
906
|
+
doc: null,
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
for (const error of result.errors ?? []) {
|
|
910
|
+
const kernelDowngrade = error.includes('falls under the protected path');
|
|
911
|
+
findings.push(
|
|
912
|
+
finding(
|
|
913
|
+
kernelDowngrade ? 'policy-kernel-downgrade' : `${kind}-invalid`,
|
|
914
|
+
show(path),
|
|
915
|
+
kernelDowngrade
|
|
916
|
+
? `${error}. classifyPath() protects those paths BEFORE any rule is consulted, so this rule has no ` +
|
|
917
|
+
'effect at all — it is either a mistake or an attempt to walk the boundary back'
|
|
918
|
+
: error,
|
|
919
|
+
`node scripts/schema.mjs validate ${kind} ${sq(path)}`,
|
|
920
|
+
),
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
return { findings, doc: result.ok ? result.doc : null };
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function policyFindings(path, repoRoot) {
|
|
927
|
+
const { findings, doc } = checkSchemaFile('policy', path);
|
|
928
|
+
// Rule analysis runs only on a policy that validates: findings derived
|
|
929
|
+
// from a document the schema already rejected would be noise stacked on
|
|
930
|
+
// top of the real problem, and every kernel-downgrade rule is reported by
|
|
931
|
+
// validatePolicy already.
|
|
932
|
+
if (doc === null) return findings;
|
|
933
|
+
|
|
934
|
+
for (const dead of deadRules(doc, repoRoot)) {
|
|
935
|
+
const parts = [
|
|
936
|
+
`rules[${dead.index}] "${show(dead.path)}" (class ${show(dead.class)}) can never match any path: ` +
|
|
937
|
+
'paths reaching the policy are normalized first (repo-relative, POSIX separators, no "." or ".." ' +
|
|
938
|
+
'segments), and no normalized path has this shape.',
|
|
939
|
+
];
|
|
940
|
+
if (dead.suggestion !== null) parts.push(`Did you mean "${show(dead.suggestion)}"?`);
|
|
941
|
+
if (dead.aimedAtKernel) {
|
|
942
|
+
parts.push(
|
|
943
|
+
`Note: "${show(dead.suggestion)}" is a protected kernel path (${MANDATORY_KERNEL_PATHS.join(', ')}), ` +
|
|
944
|
+
'so even the corrected rule could only tighten it, never lower it — but as written the rule protects ' +
|
|
945
|
+
'nothing and nothing says so.',
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
findings.push(
|
|
949
|
+
finding(
|
|
950
|
+
'policy-rule-dead',
|
|
951
|
+
show(path),
|
|
952
|
+
parts.join(' '),
|
|
953
|
+
dead.suggestion === null
|
|
954
|
+
? `${sq(path)}: delete the rule, or rewrite its path as a repo-relative glob`
|
|
955
|
+
: `${sq(path)}: replace the rule path with ${sq(dead.suggestion)}`,
|
|
956
|
+
),
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
for (const overruled of overruledRules(doc, repoRoot)) {
|
|
961
|
+
findings.push(
|
|
962
|
+
finding(
|
|
963
|
+
'policy-rule-overruled',
|
|
964
|
+
show(path),
|
|
965
|
+
`rules[${overruled.index}] "${show(overruled.path)}" (class ${show(overruled.class)}) also matches ` +
|
|
966
|
+
`"${show(overruled.example)}", which lies under the protected path "${show(overruled.protectedGlob)}". ` +
|
|
967
|
+
'Protected paths resolve to KERNEL before any rule is consulted, so the rule is ignored there — it ' +
|
|
968
|
+
'covers less than it looks like it covers, and nothing says so at the point of use',
|
|
969
|
+
`${sq(path)}: narrow the rule so it cannot reach ${sq(overruled.protectedGlob)}, or accept that those ` +
|
|
970
|
+
'paths stay KERNEL',
|
|
971
|
+
),
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
return findings;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// ------------------------------------------------------------------ scan
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Run every state check. Pure with respect to time: `now` is either given
|
|
981
|
+
* or taken from each journal's last event, never from the clock.
|
|
982
|
+
*/
|
|
983
|
+
export function runStateChecks({ dir = '.tyran', now = null, staleHours = DEFAULT_STALE_HOURS } = {}) {
|
|
984
|
+
const root = resolve(dir);
|
|
985
|
+
if (!existsSync(root)) {
|
|
986
|
+
return summarize(dir, [finding('no-state-dir', show(dir), 'no Tyran state directory here — nothing to check')], [
|
|
987
|
+
`${show(dir)}: absent`,
|
|
988
|
+
]);
|
|
989
|
+
}
|
|
990
|
+
if (!isDirectory(root)) throw new IOError(`not a directory: ${root}`);
|
|
991
|
+
|
|
992
|
+
const repoRoot = dirname(root);
|
|
993
|
+
const findings = [];
|
|
994
|
+
const checked = [];
|
|
995
|
+
|
|
996
|
+
const configPath = join(dir, 'config.yaml');
|
|
997
|
+
if (existsSync(configPath)) {
|
|
998
|
+
findings.push(...checkSchemaFile('config', configPath).findings);
|
|
999
|
+
checked.push('config.yaml: checked');
|
|
1000
|
+
} else {
|
|
1001
|
+
findings.push(
|
|
1002
|
+
finding(
|
|
1003
|
+
'config-missing',
|
|
1004
|
+
show(configPath),
|
|
1005
|
+
'no config.yaml — Tyran falls back to built-in defaults for this repo',
|
|
1006
|
+
`node scripts/schema.mjs validate config ${sq(configPath)} # after /tyran:setup writes it`,
|
|
1007
|
+
),
|
|
1008
|
+
);
|
|
1009
|
+
checked.push('config.yaml: absent');
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const knowledge = yamlFilesIn(join(dir, 'knowledge'), 'knowledge');
|
|
1013
|
+
findings.push(...knowledge.findings);
|
|
1014
|
+
for (const file of knowledge.files) findings.push(...checkSchemaFile('knowledge', file).findings);
|
|
1015
|
+
checked.push(`knowledge/: ${knowledge.files.length} file(s)`);
|
|
1016
|
+
|
|
1017
|
+
const policies = yamlFilesIn(join(dir, 'policies'), 'policies');
|
|
1018
|
+
findings.push(...policies.findings);
|
|
1019
|
+
for (const file of policies.files) findings.push(...policyFindings(file, repoRoot));
|
|
1020
|
+
if (policies.files.length === 0 && policies.findings.length === 0) {
|
|
1021
|
+
findings.push(
|
|
1022
|
+
finding(
|
|
1023
|
+
'policy-missing',
|
|
1024
|
+
show(join(dir, 'policies')),
|
|
1025
|
+
'no autonomy policy — the self-improvement boundary (AUTO / GATED / KERNEL) is undefined for this repo',
|
|
1026
|
+
`mkdir -p ${sq(join(dir, 'policies'))} && cp templates/policies/autonomy.yaml ${sq(join(dir, 'policies', 'autonomy.yaml'))}`,
|
|
1027
|
+
),
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
checked.push(`policies/: ${policies.files.length} file(s)`);
|
|
1031
|
+
|
|
1032
|
+
const stateDir = join(dir, 'state');
|
|
1033
|
+
const initiatives = [];
|
|
1034
|
+
if (existsSync(stateDir) && !isDirectory(stateDir)) {
|
|
1035
|
+
findings.push(finding('state-not-a-directory', show(stateDir), 'state/ exists but is not a directory'));
|
|
1036
|
+
} else if (isDirectory(stateDir)) {
|
|
1037
|
+
let names = [];
|
|
1038
|
+
try {
|
|
1039
|
+
names = readdirSync(stateDir);
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
findings.push(
|
|
1042
|
+
finding('state-unreadable', show(stateDir), `cannot list state/ (${errText(err)})`),
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
for (const name of sortedNames(names)) {
|
|
1046
|
+
const path = join(stateDir, name);
|
|
1047
|
+
if (!isDirectory(path)) {
|
|
1048
|
+
findings.push(
|
|
1049
|
+
finding(
|
|
1050
|
+
'state-stray-file',
|
|
1051
|
+
show(path),
|
|
1052
|
+
'stray entry in state/ — every child of state/ is an initiative directory',
|
|
1053
|
+
),
|
|
1054
|
+
);
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
const result = checkInitiative(stateDir, name, { now, staleHours });
|
|
1058
|
+
findings.push(...result.findings);
|
|
1059
|
+
initiatives.push(`${show(name)} (${result.events} event(s))`);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
checked.push(
|
|
1063
|
+
initiatives.length === 0 ? 'state/: no initiatives' : `state/: ${initiatives.join(', ')}`,
|
|
1064
|
+
);
|
|
1065
|
+
|
|
1066
|
+
return summarize(dir, findings, checked);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function summarize(dir, findings, checked) {
|
|
1070
|
+
const sorted = sortFindings(findings);
|
|
1071
|
+
const counts = { error: 0, warning: 0, info: 0 };
|
|
1072
|
+
for (const f of sorted) counts[f.severity]++;
|
|
1073
|
+
return {
|
|
1074
|
+
ok: counts.error === 0 && counts.warning === 0,
|
|
1075
|
+
dir: show(dir),
|
|
1076
|
+
checked,
|
|
1077
|
+
counts,
|
|
1078
|
+
findings: sorted,
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// --------------------------------------------------------------- renderers
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* The hook-liveness check, in doctor's own result shape.
|
|
1086
|
+
*
|
|
1087
|
+
* `hooks-check.mjs` owns the finding CODES and their severities; doctor does
|
|
1088
|
+
* not merge them into `SEVERITY_BY_CODE`. Two tables, no merge, and a test
|
|
1089
|
+
* asserts the two never define the same code — which is stronger than merging,
|
|
1090
|
+
* because a merge would let a collision resolve silently in favour of whoever
|
|
1091
|
+
* wrote the key last.
|
|
1092
|
+
*/
|
|
1093
|
+
export function runHookChecks({ root = DEFAULT_PLUGIN_ROOT, env = process.env } = {}) {
|
|
1094
|
+
const result = checkHooks({ root, env });
|
|
1095
|
+
return {
|
|
1096
|
+
ok: result.ok,
|
|
1097
|
+
dir: result.root,
|
|
1098
|
+
platform: result.platform,
|
|
1099
|
+
checked: result.checked,
|
|
1100
|
+
counts: result.counts,
|
|
1101
|
+
findings: result.findings,
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
export function renderText(result, mode = 'state') {
|
|
1106
|
+
const lines = [`tyran doctor · ${mode}`, `dir: ${result.dir}`];
|
|
1107
|
+
if (result.platform) lines.push(`platform modelled: ${result.platform} (re-measure after an upgrade)`);
|
|
1108
|
+
for (const line of result.checked) lines.push(` ${line}`);
|
|
1109
|
+
lines.push('');
|
|
1110
|
+
|
|
1111
|
+
if (result.findings.length === 0) {
|
|
1112
|
+
lines.push('no findings — state is consistent');
|
|
1113
|
+
} else {
|
|
1114
|
+
let current = null;
|
|
1115
|
+
for (const f of result.findings) {
|
|
1116
|
+
if (f.severity !== current) {
|
|
1117
|
+
if (current !== null) lines.push('');
|
|
1118
|
+
lines.push(`${f.severity.toUpperCase()} (${result.counts[f.severity]})`);
|
|
1119
|
+
current = f.severity;
|
|
1120
|
+
}
|
|
1121
|
+
lines.push(` [${f.code}] ${f.where}`);
|
|
1122
|
+
lines.push(` ${f.message}`);
|
|
1123
|
+
if (f.fix) {
|
|
1124
|
+
const fixLines = f.fix.split('\n');
|
|
1125
|
+
lines.push(` fix: ${fixLines[0]}`);
|
|
1126
|
+
for (const extra of fixLines.slice(1)) lines.push(` ${extra}`);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
lines.push('');
|
|
1131
|
+
lines.push(
|
|
1132
|
+
`${result.counts.error} error(s) · ${result.counts.warning} warning(s) · ${result.counts.info} info · ` +
|
|
1133
|
+
(result.ok ? 'healthy' : 'action needed'),
|
|
1134
|
+
);
|
|
1135
|
+
return lines.join('\n') + '\n';
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
export function renderJson(result) {
|
|
1139
|
+
return JSON.stringify(result, null, 2) + '\n';
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// --------------------------------------------------------------------- CLI
|
|
1143
|
+
|
|
1144
|
+
const BOOLEAN_FLAGS = ['state', 'hooks', 'json'];
|
|
1145
|
+
const VALUE_FLAGS = ['dir', 'now', 'stale-hours', 'plugin-root'];
|
|
1146
|
+
|
|
1147
|
+
export function parseArgs(argv) {
|
|
1148
|
+
const flags = {};
|
|
1149
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1150
|
+
const arg = argv[i];
|
|
1151
|
+
if (!arg.startsWith('--')) throw new Error(`unexpected argument "${arg}" — doctor takes flags only`);
|
|
1152
|
+
const name = arg.slice(2);
|
|
1153
|
+
// A repeated flag silently overriding itself is how a check ends up
|
|
1154
|
+
// reading the wrong directory — refuse instead of guessing.
|
|
1155
|
+
if (name in flags) throw new Error(`flag --${name} was given twice`);
|
|
1156
|
+
if (BOOLEAN_FLAGS.includes(name)) flags[name] = true;
|
|
1157
|
+
else if (VALUE_FLAGS.includes(name)) {
|
|
1158
|
+
const value = argv[++i];
|
|
1159
|
+
if (value === undefined || value.startsWith('--')) throw new Error(`flag --${name} requires a value`);
|
|
1160
|
+
flags[name] = value;
|
|
1161
|
+
} else throw new Error(`unknown flag --${name}`);
|
|
1162
|
+
}
|
|
1163
|
+
// A mode is required rather than assumed: doctor grows --env and --config
|
|
1164
|
+
// in a later epic, and a bare `doctor.mjs` that silently means one of them
|
|
1165
|
+
// today would silently mean something else then.
|
|
1166
|
+
if (flags.state !== true && flags.hooks !== true) throw new UsageError();
|
|
1167
|
+
|
|
1168
|
+
let now = null;
|
|
1169
|
+
if (flags.now !== undefined) {
|
|
1170
|
+
if (Number.isNaN(Date.parse(flags.now))) throw new Error(`--now must be an ISO-8601 timestamp (got "${flags.now}")`);
|
|
1171
|
+
now = flags.now;
|
|
1172
|
+
}
|
|
1173
|
+
let staleHours = DEFAULT_STALE_HOURS;
|
|
1174
|
+
if (flags['stale-hours'] !== undefined) {
|
|
1175
|
+
staleHours = Number(flags['stale-hours']);
|
|
1176
|
+
if (!Number.isFinite(staleHours) || staleHours < 0) {
|
|
1177
|
+
throw new Error(`--stale-hours must be a non-negative number (got "${flags['stale-hours']}")`);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
return {
|
|
1181
|
+
dir: flags.dir ?? '.tyran',
|
|
1182
|
+
json: flags.json === true,
|
|
1183
|
+
now,
|
|
1184
|
+
staleHours,
|
|
1185
|
+
dirGiven: flags.dir !== undefined,
|
|
1186
|
+
state: flags.state === true,
|
|
1187
|
+
hooks: flags.hooks === true,
|
|
1188
|
+
pluginRoot: flags['plugin-root'] ?? DEFAULT_PLUGIN_ROOT,
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function main() {
|
|
1193
|
+
try {
|
|
1194
|
+
const args = parseArgs(process.argv.slice(2));
|
|
1195
|
+
const { dir, json, now, staleHours, dirGiven } = args;
|
|
1196
|
+
let allOk = true;
|
|
1197
|
+
// Both modes may be asked for at once. They are rendered as two reports
|
|
1198
|
+
// rather than one merged list: the counts of "is the record consistent"
|
|
1199
|
+
// and "can the gates fire" answer different questions, and adding them
|
|
1200
|
+
// together would let a healthy state dilute a dead gate.
|
|
1201
|
+
if (args.hooks) {
|
|
1202
|
+
const result = runHookChecks({ root: args.pluginRoot });
|
|
1203
|
+
process.stdout.write(json ? renderJson(result) : renderText(result, 'hooks'));
|
|
1204
|
+
allOk = allOk && result.ok;
|
|
1205
|
+
}
|
|
1206
|
+
if (args.state) {
|
|
1207
|
+
// An explicitly named directory that does not exist is a typo, and a
|
|
1208
|
+
// clean bill of health for a path nobody checked is the one output a
|
|
1209
|
+
// diagnostic tool must never produce. The DEFAULT `.tyran` being absent
|
|
1210
|
+
// is just a repo that has not run /tyran:setup yet.
|
|
1211
|
+
if (dirGiven && !existsSync(dir)) throw new IOError(`state directory not found: ${resolve(dir)}`);
|
|
1212
|
+
const result = runStateChecks({ dir, now, staleHours });
|
|
1213
|
+
process.stdout.write(json ? renderJson(result) : renderText(result));
|
|
1214
|
+
allOk = allOk && result.ok;
|
|
1215
|
+
}
|
|
1216
|
+
if (!allOk) process.exit(1);
|
|
1217
|
+
} catch (err) {
|
|
1218
|
+
if (err instanceof UsageError) {
|
|
1219
|
+
console.error(
|
|
1220
|
+
'usage: doctor.mjs --state [--dir <.tyran>] [--json] [--now <iso>] [--stale-hours <n>]\n' +
|
|
1221
|
+
' --state is the only mode today; it is required so that adding --env/--config later\n' +
|
|
1222
|
+
' cannot change what a bare invocation means.',
|
|
1223
|
+
);
|
|
1224
|
+
process.exit(2);
|
|
1225
|
+
}
|
|
1226
|
+
console.error(`doctor: ${err.message}`);
|
|
1227
|
+
if (!(err instanceof IOError) && !(err instanceof Error && err.constructor === Error)) {
|
|
1228
|
+
console.error(err.stack);
|
|
1229
|
+
}
|
|
1230
|
+
process.exit(2);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* Absolute, symlink-resolved path; falls back to the merely absolute form
|
|
1236
|
+
* when the path cannot be resolved (deleted file, permission denied), which
|
|
1237
|
+
* keeps the comparison below defined instead of throwing.
|
|
1238
|
+
*/
|
|
1239
|
+
function canonicalPath(path) {
|
|
1240
|
+
const abs = resolve(path);
|
|
1241
|
+
try {
|
|
1242
|
+
return realpathSync(abs);
|
|
1243
|
+
} catch {
|
|
1244
|
+
return abs;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* True when this module is the program's entry point.
|
|
1250
|
+
*
|
|
1251
|
+
* BOTH sides must be canonicalized. `import.meta.url` already names the real
|
|
1252
|
+
* file — Node resolves module specifiers through symlinks — while
|
|
1253
|
+
* `process.argv[1]` is whatever the caller typed. Comparing them raw turned
|
|
1254
|
+
* every invocation through a symlinked path into a SILENT no-op under exit
|
|
1255
|
+
* 0: `main()` never ran, nothing was printed, and nothing said so. For a
|
|
1256
|
+
* tool whose entire promise is "it never reports clean for something it
|
|
1257
|
+
* skipped", that is the worst possible failure. `/tmp` and `/var` are
|
|
1258
|
+
* symlinks on macOS and plugin installs routinely reach `scripts/` through
|
|
1259
|
+
* one, so it is not theoretical. Shared with journal.mjs, project.mjs and
|
|
1260
|
+
* scan-control-chars.mjs.
|
|
1261
|
+
*/
|
|
1262
|
+
function isMainModule(moduleUrl) {
|
|
1263
|
+
if (!process.argv[1]) return false;
|
|
1264
|
+
return canonicalPath(process.argv[1]) === canonicalPath(fileURLToPath(moduleUrl));
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
if (isMainModule(import.meta.url)) main();
|