@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.
Files changed (54) hide show
  1. package/.claude-plugin/marketplace.json +22 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/CHANGELOG.md +245 -0
  4. package/LICENSE +201 -0
  5. package/README.md +468 -0
  6. package/agents/.gitkeep +0 -0
  7. package/agents/implementer.md +60 -0
  8. package/agents/retro.md +88 -0
  9. package/agents/reviewer.md +55 -0
  10. package/agents/scout.md +60 -0
  11. package/bin/tyran.mjs +172 -0
  12. package/hooks/HOOK-CONTRACT-MEASURED.md +377 -0
  13. package/hooks/hooks.json +83 -0
  14. package/hooks/scripts/.gitkeep +0 -0
  15. package/hooks/scripts/evidence-gate.mjs +705 -0
  16. package/hooks/scripts/hook-io.mjs +813 -0
  17. package/hooks/scripts/policy-gate.mjs +1402 -0
  18. package/hooks/scripts/pre-compact.mjs +211 -0
  19. package/hooks/scripts/retro-gate.mjs +191 -0
  20. package/hooks/scripts/secrets-gate.mjs +1683 -0
  21. package/hooks/scripts/session-start.mjs +358 -0
  22. package/hooks/scripts/write-guard.mjs +475 -0
  23. package/package.json +52 -0
  24. package/scripts/desc-budget.mjs +139 -0
  25. package/scripts/doctor.mjs +1267 -0
  26. package/scripts/hooks-check.mjs +1312 -0
  27. package/scripts/invisible.mjs +346 -0
  28. package/scripts/journal.mjs +747 -0
  29. package/scripts/project.mjs +981 -0
  30. package/scripts/scan-control-chars.mjs +547 -0
  31. package/scripts/scan-repo.mjs +287 -0
  32. package/scripts/schema.mjs +467 -0
  33. package/scripts/stop-check.mjs +89 -0
  34. package/scripts/tiers.mjs +324 -0
  35. package/scripts/yaml-lite.mjs +383 -0
  36. package/skills/browser-check/SKILL.md +118 -0
  37. package/skills/code-review/SKILL.md +83 -0
  38. package/skills/deslop/SKILL.md +97 -0
  39. package/skills/doctor/SKILL.md +35 -0
  40. package/skills/fidelity-gate/SKILL.md +132 -0
  41. package/skills/hello/SKILL.md +16 -0
  42. package/skills/pr-feedback/SKILL.md +85 -0
  43. package/skills/prompt-tuning/SKILL.md +102 -0
  44. package/skills/retro/SKILL.md +69 -0
  45. package/skills/root-cause/SKILL.md +89 -0
  46. package/skills/run/SKILL.md +285 -0
  47. package/skills/setup/SKILL.md +79 -0
  48. package/skills/skill-writing/SKILL.md +99 -0
  49. package/skills/status/SKILL.md +31 -0
  50. package/templates/.gitkeep +0 -0
  51. package/templates/config.yaml +44 -0
  52. package/templates/knowledge.yaml +23 -0
  53. package/templates/policies/autonomy.yaml +61 -0
  54. package/templates/project-command/SKILL.md +16 -0
@@ -0,0 +1,467 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * schema — validators for Tyran's repo data layer (`.tyran/`).
4
+ *
5
+ * Three file families, one rule each (see docs/configuration.md):
6
+ * config.yaml — how Tyran behaves in THIS repo
7
+ * knowledge/*.yaml — facts Tyran learned about THIS repo
8
+ * policies/*.yaml — what Tyran may do without asking
9
+ *
10
+ * Every inferred config field carries provenance so a human can audit where
11
+ * a value came from and how sure the scanner was.
12
+ *
13
+ * CLI: node schema.mjs validate <kind> <file...> kind: config|knowledge|policy
14
+ * Exit: 0 valid · 1 findings · 2 usage/IO error
15
+ */
16
+ import { readFileSync, existsSync, realpathSync } from 'node:fs';
17
+ import { resolve } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { parse, YamlLiteError } from './yaml-lite.mjs';
20
+ import { escapeInvisible } from './invisible.mjs';
21
+
22
+ export const PROFILES = Object.freeze(['eco', 'balanced', 'full']);
23
+ export const AUTONOMY_CLASSES = Object.freeze(['P1', 'P2', 'P3']);
24
+ /**
25
+ * Capability tiers, cheapest first. Four rather than three because "expensive"
26
+ * is not one thing: `deep` buys harder reasoning for root-cause work, `top`
27
+ * is reserved for the calls where being wrong is expensive AND hard to notice
28
+ * (security review, arbitration, acceptance). Collapsing them into one tier
29
+ * means every escalation pays the top price, which is how a cost mode stops
30
+ * being used at all.
31
+ */
32
+ export const TIER_KEYS = Object.freeze(['cheap', 'work', 'deep', 'top']);
33
+ export const ARTIFACT_CLASSES = Object.freeze(['AUTO', 'GATED', 'KERNEL']);
34
+ export const KNOWLEDGE_KINDS = Object.freeze(['fact', 'convention', 'gotcha', 'command', 'decision']);
35
+
36
+ const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
37
+ const isNonEmptyString = (v) => typeof v === 'string' && v.trim() !== '';
38
+
39
+ /**
40
+ * A provenance-carrying value: either a bare scalar (author-written) or
41
+ * `{value, source, confidence, needs_confirmation}` (scanner-inferred).
42
+ */
43
+ function checkProvenanced(node, path, errors, { valueCheck, valueHint }) {
44
+ if (isPlainObject(node) && 'value' in node) {
45
+ if (!valueCheck(node.value)) errors.push(`${path}.value: ${valueHint}`);
46
+ if (!isNonEmptyString(node.source)) {
47
+ errors.push(`${path}.source: required for inferred values (file or command that proved it)`);
48
+ }
49
+ if (typeof node.confidence !== 'number' || node.confidence < 0 || node.confidence > 1) {
50
+ errors.push(`${path}.confidence: must be a number in [0, 1]`);
51
+ }
52
+ if ('needs_confirmation' in node && typeof node.needs_confirmation !== 'boolean') {
53
+ errors.push(`${path}.needs_confirmation: must be a boolean`);
54
+ }
55
+ for (const key of Object.keys(node)) {
56
+ if (!['value', 'source', 'confidence', 'needs_confirmation'].includes(key)) {
57
+ errors.push(`${path}.${key}: unknown key in a provenanced value`);
58
+ }
59
+ }
60
+ return node.value;
61
+ }
62
+ if (!valueCheck(node)) errors.push(`${path}: ${valueHint}`);
63
+ return node;
64
+ }
65
+
66
+ /** Validate `.tyran/config.yaml`. */
67
+ export function validateConfig(doc) {
68
+ const errors = [];
69
+ if (!isPlainObject(doc)) return ['config must be a mapping'];
70
+
71
+ const known = ['profile', 'autonomy', 'tiers', 'validation', 'shared_zones', 'budget'];
72
+ for (const key of Object.keys(doc)) {
73
+ if (!known.includes(key)) errors.push(`${key}: unknown top-level key`);
74
+ }
75
+
76
+ if (!('profile' in doc)) errors.push('profile: required (eco | balanced | full)');
77
+ else {
78
+ checkProvenanced(doc.profile, 'profile', errors, {
79
+ valueCheck: (v) => PROFILES.includes(v),
80
+ valueHint: `must be one of ${PROFILES.join(' | ')}`,
81
+ });
82
+ }
83
+
84
+ if (!('autonomy' in doc)) errors.push('autonomy: required (P1 | P2 | P3)');
85
+ else {
86
+ checkProvenanced(doc.autonomy, 'autonomy', errors, {
87
+ valueCheck: (v) => AUTONOMY_CLASSES.includes(v),
88
+ valueHint: `must be one of ${AUTONOMY_CLASSES.join(' | ')}`,
89
+ });
90
+ }
91
+
92
+ if (!('tiers' in doc)) errors.push(`tiers: required (${TIER_KEYS.join(', ')})`);
93
+ else if (!isPlainObject(doc.tiers)) errors.push('tiers: must be a mapping');
94
+ else {
95
+ for (const key of TIER_KEYS) {
96
+ if (!(key in doc.tiers)) errors.push(`tiers.${key}: required`);
97
+ else if (!isNonEmptyString(doc.tiers[key])) errors.push(`tiers.${key}: must be a model alias string`);
98
+ }
99
+ for (const key of Object.keys(doc.tiers)) {
100
+ if (!TIER_KEYS.includes(key)) errors.push(`tiers.${key}: unknown tier (allowed: ${TIER_KEYS.join(', ')})`);
101
+ }
102
+ }
103
+
104
+ if ('validation' in doc) {
105
+ const list = checkProvenanced(doc.validation, 'validation', errors, {
106
+ valueCheck: (v) => Array.isArray(v),
107
+ valueHint: 'must be a list of shell commands',
108
+ });
109
+ if (Array.isArray(list)) {
110
+ list.forEach((cmd, i) => {
111
+ if (!isNonEmptyString(cmd)) errors.push(`validation[${i}]: must be a non-empty command string`);
112
+ });
113
+ }
114
+ }
115
+
116
+ if ('shared_zones' in doc) {
117
+ if (!Array.isArray(doc.shared_zones)) errors.push('shared_zones: must be a list of path globs');
118
+ else {
119
+ doc.shared_zones.forEach((z, i) => {
120
+ if (!isNonEmptyString(z)) errors.push(`shared_zones[${i}]: must be a non-empty path glob`);
121
+ });
122
+ }
123
+ }
124
+
125
+ if ('budget' in doc) {
126
+ if (!isPlainObject(doc.budget)) errors.push('budget: must be a mapping');
127
+ else {
128
+ for (const [k, v] of Object.entries(doc.budget)) {
129
+ if (typeof v !== 'number' || v <= 0) errors.push(`budget.${k}: must be a positive number`);
130
+ }
131
+ }
132
+ }
133
+
134
+ return errors;
135
+ }
136
+
137
+ /**
138
+ * Validate a knowledge file: `entries:` list of learned facts.
139
+ * Schema adapted from the best pattern found in the wild (metaswarm):
140
+ * provenance + confidence + usage counters, so entries that stop earning
141
+ * their keep can be degraded or retired by a later retrospective.
142
+ */
143
+ export function validateKnowledge(doc) {
144
+ const errors = [];
145
+ if (!isPlainObject(doc)) return ['knowledge file must be a mapping with an "entries" list'];
146
+ if (!Array.isArray(doc.entries)) return ['entries: required, must be a list'];
147
+
148
+ const seen = new Set();
149
+ doc.entries.forEach((entry, i) => {
150
+ const at = `entries[${i}]`;
151
+ if (!isPlainObject(entry)) {
152
+ errors.push(`${at}: must be a mapping`);
153
+ return;
154
+ }
155
+ if (!isNonEmptyString(entry.id)) errors.push(`${at}.id: required, non-empty string`);
156
+ else if (seen.has(entry.id)) errors.push(`${at}.id: duplicate id "${entry.id}"`);
157
+ else seen.add(entry.id);
158
+
159
+ if (!KNOWLEDGE_KINDS.includes(entry.kind)) {
160
+ errors.push(`${at}.kind: must be one of ${KNOWLEDGE_KINDS.join(' | ')}`);
161
+ }
162
+ if (!isNonEmptyString(entry.text)) errors.push(`${at}.text: required, non-empty string`);
163
+ if (typeof entry.confidence !== 'number' || entry.confidence < 0 || entry.confidence > 1) {
164
+ errors.push(`${at}.confidence: must be a number in [0, 1]`);
165
+ }
166
+ if (!Array.isArray(entry.provenance) || entry.provenance.length === 0) {
167
+ errors.push(`${at}.provenance: required, at least one {source, reference} entry`);
168
+ } else {
169
+ entry.provenance.forEach((p, j) => {
170
+ if (!isPlainObject(p) || !isNonEmptyString(p.source)) {
171
+ errors.push(`${at}.provenance[${j}].source: required (where this was learned)`);
172
+ } else if (!isNonEmptyString(p.reference)) {
173
+ errors.push(`${at}.provenance[${j}].reference: required (which run/file/commit proved it)`);
174
+ }
175
+ });
176
+ }
177
+ for (const counter of ['used', 'helpful', 'outdated_reports']) {
178
+ if (counter in entry && (!Number.isInteger(entry[counter]) || entry[counter] < 0)) {
179
+ errors.push(`${at}.${counter}: must be a non-negative integer`);
180
+ }
181
+ }
182
+ if ('applies_to' in entry && !Array.isArray(entry.applies_to)) {
183
+ errors.push(`${at}.applies_to: must be a list of path globs`);
184
+ }
185
+ if ('supersedes' in entry && !isNonEmptyString(entry.supersedes)) {
186
+ errors.push(`${at}.supersedes: must be an entry id`);
187
+ }
188
+ });
189
+ return errors;
190
+ }
191
+
192
+ /**
193
+ * Paths that MUST be classified KERNEL. A policy that downgrades any of
194
+ * these would let the self-improvement loop disable its own enforcement,
195
+ * so the validator rejects it — the boundary cannot be edited away, only
196
+ * tightened (review E2S2-R6).
197
+ */
198
+ export const MANDATORY_KERNEL_PATHS = Object.freeze(['hooks/**', '.tyran/policies/**']);
199
+
200
+ /**
201
+ * Validate `policies/autonomy.yaml`: which paths the retro agent may write
202
+ * autonomously (AUTO), which need approval (GATED), which are untouchable
203
+ * (KERNEL). Enforced later by a PreToolUse hook — this validator makes sure
204
+ * the file itself can never be ambiguous:
205
+ * - the mandatory KERNEL paths are present and classified KERNEL;
206
+ * - a `default` class exists, so an unmatched path has a defined answer;
207
+ * - precedence is explicit (most specific rule wins — longest path glob).
208
+ */
209
+ export function validatePolicy(doc) {
210
+ const errors = [];
211
+ if (!isPlainObject(doc)) return ['policy must be a mapping'];
212
+ if (!Array.isArray(doc.rules)) return ['rules: required, must be a list'];
213
+
214
+ if (!('default' in doc)) {
215
+ errors.push("default: required — the class applied to paths no rule matches (use 'GATED' when unsure)");
216
+ } else if (!ARTIFACT_CLASSES.includes(doc.default)) {
217
+ errors.push(`default: must be one of ${ARTIFACT_CLASSES.join(' | ')}`);
218
+ }
219
+ for (const key of Object.keys(doc)) {
220
+ if (!['rules', 'default'].includes(key)) errors.push(`${key}: unknown top-level key`);
221
+ }
222
+
223
+ const seenPaths = new Set();
224
+ const classByPath = new Map();
225
+ doc.rules.forEach((rule, i) => {
226
+ const at = `rules[${i}]`;
227
+ if (!isPlainObject(rule)) {
228
+ errors.push(`${at}: must be a mapping`);
229
+ return;
230
+ }
231
+ if (!isNonEmptyString(rule.path)) errors.push(`${at}.path: required path glob`);
232
+ else if (seenPaths.has(rule.path)) errors.push(`${at}.path: duplicate rule for "${rule.path}"`);
233
+ else {
234
+ seenPaths.add(rule.path);
235
+ classByPath.set(rule.path, rule.class);
236
+ }
237
+
238
+ if (!ARTIFACT_CLASSES.includes(rule.class)) {
239
+ errors.push(`${at}.class: must be one of ${ARTIFACT_CLASSES.join(' | ')}`);
240
+ }
241
+ if (!isNonEmptyString(rule.reason)) {
242
+ errors.push(`${at}.reason: required — every boundary must say why it exists`);
243
+ }
244
+ });
245
+
246
+ for (const required of MANDATORY_KERNEL_PATHS) {
247
+ if (!classByPath.has(required)) {
248
+ errors.push(`rules: a rule for "${required}" is required and must be class KERNEL`);
249
+ } else if (classByPath.get(required) !== 'KERNEL') {
250
+ errors.push(
251
+ `rules: "${required}" is classified ${classByPath.get(required)} — it must be KERNEL ` +
252
+ '(a system that can disable its own gates has none)',
253
+ );
254
+ }
255
+ }
256
+
257
+ // Presence of the literal rule is not enough: a MORE SPECIFIC non-KERNEL
258
+ // rule would out-rank it under classifyPath's precedence and hand the
259
+ // enforcement mechanism to AUTO with a green CI (review E2S2-R9).
260
+ // Validate the EFFECT, not the spelling.
261
+ for (const rule of doc.rules) {
262
+ if (!isPlainObject(rule) || !isNonEmptyString(rule.path) || rule.class === 'KERNEL') continue;
263
+ for (const required of MANDATORY_KERNEL_PATHS) {
264
+ // Intersection, not string containment: a rule matching ANY concrete
265
+ // path under the protected glob is a downgrade attempt, however it is
266
+ // spelled (review E2S2-R10).
267
+ // Intersect in both directions: concretize the rule INTO the protected
268
+ // namespace (`**/x.mjs` → `hooks/x.mjs`) and probe the protected glob
269
+ // against the rule. Either hit means the rule can claim a kernel file.
270
+ const base = required.replace(/\/?\*\*$/, '');
271
+ const intoNamespace = [rule.path.replace(/\*\*/g, base), rule.path.replace(/\*\*/g, `${base}/a`)];
272
+ if (
273
+ globMatches(required, concretize(rule.path)) ||
274
+ intoNamespace.some((candidate) => globMatches(required, candidate.replace(/\*/g, 'x'))) ||
275
+ probesFor(required).some((probe) => globMatches(rule.path, probe))
276
+ ) {
277
+ errors.push(
278
+ `rules: "${rule.path}" (class ${rule.class}) falls under the protected path "${required}" — ` +
279
+ 'kernel paths may only be tightened, never downgraded by a more specific rule',
280
+ );
281
+ }
282
+ }
283
+ }
284
+
285
+ // Belt and braces: probe the resolver itself, so any future change to
286
+ // precedence that reopens this hole fails here.
287
+ if (errors.length === 0) {
288
+ for (const probe of MANDATORY_KERNEL_PATHS.flatMap(probesFor)) {
289
+ if (classifyPath(doc, probe) !== 'KERNEL') {
290
+ errors.push(`rules: "${probe}" resolves to ${classifyPath(doc, probe)}, but protected paths must resolve to KERNEL`);
291
+ }
292
+ }
293
+ }
294
+ return errors;
295
+ }
296
+
297
+ /** Concrete sample paths under a protected glob, at several depths. */
298
+ function probesFor(protectedGlob) {
299
+ const base = protectedGlob.replace(/\/?\*\*$/, '');
300
+ return [`${base}/probe.mjs`, `${base}/a/probe.mjs`, `${base}/a/b/probe.yaml`, `${base}/autonomy.yaml`];
301
+ }
302
+
303
+ /** Replace wildcards with concrete segments so a glob can be tested for containment. */
304
+ function concretize(glob) {
305
+ return glob.replace(/\*\*/g, '__any__/__any__').replace(/(?<!_)\*(?!_)/g, '__any__').replace(/__any__/g, 'x');
306
+ }
307
+
308
+ /**
309
+ * Resolve which class applies to a path. Precedence: the most specific
310
+ * matching rule wins, measured by glob length; ties go to the stricter
311
+ * class. Unmatched paths fall back to `default`. Exported so the future
312
+ * PreToolUse hook and `doctor` share exactly one implementation.
313
+ */
314
+ export function classifyPath(policy, filePath) {
315
+ const strictness = { AUTO: 0, GATED: 1, KERNEL: 2 };
316
+ const normalized = normalizePath(filePath);
317
+ if (normalized === null) return 'KERNEL'; // outside the repo → never autonomous
318
+ // Protected paths win unconditionally, BEFORE any rule is consulted: no
319
+ // glob spelling (`**/policy-gate.mjs`, casing, nesting) can outrank them
320
+ // (review E2S2-R10). validatePolicy still reports such rules as findings.
321
+ for (const protectedGlob of MANDATORY_KERNEL_PATHS) {
322
+ if (globMatches(protectedGlob, normalized)) return 'KERNEL';
323
+ }
324
+ let best = null;
325
+ for (const rule of policy.rules ?? []) {
326
+ if (!isNonEmptyString(rule.path) || !globMatches(rule.path, normalized)) continue;
327
+ if (
328
+ best === null ||
329
+ rule.path.length > best.path.length ||
330
+ (rule.path.length === best.path.length && strictness[rule.class] > strictness[best.class])
331
+ ) {
332
+ best = rule;
333
+ }
334
+ }
335
+ return best ? best.class : (policy.default ?? 'GATED');
336
+ }
337
+
338
+ /**
339
+ * Normalize to a repo-relative POSIX path before matching. A hook receives
340
+ * whatever form the tool used (`./hooks/x`, an absolute path, Windows
341
+ * separators); without this, the same file could resolve to two different
342
+ * classes and the gate would fail open (review E2S2-R9b).
343
+ * Returns null when the path escapes the repo root.
344
+ */
345
+ export function normalizePath(filePath, repoRoot = process.env.CLAUDE_PROJECT_DIR ?? process.cwd()) {
346
+ let p = String(filePath).replace(/\\/g, '/');
347
+ const root = String(repoRoot).replace(/\\/g, '/').replace(/\/+$/, '');
348
+ if (p.startsWith(root + '/')) p = p.slice(root.length + 1);
349
+ else if (p.startsWith('/') || /^[A-Za-z]:\//.test(p)) return null; // absolute, outside the repo
350
+ const segments = [];
351
+ for (const segment of p.split('/')) {
352
+ if (segment === '' || segment === '.') continue;
353
+ if (segment === '..') {
354
+ if (segments.length === 0) return null;
355
+ segments.pop();
356
+ continue;
357
+ }
358
+ segments.push(segment);
359
+ }
360
+ return segments.join('/');
361
+ }
362
+
363
+ /**
364
+ * Minimal glob: `**` spans separators, `*` does not.
365
+ *
366
+ * Exported so a caller can ask WHICH glob matched, which `classifyPath` cannot
367
+ * answer: it applies `MANDATORY_KERNEL_PATHS` unconditionally and returns a
368
+ * class, so probing it with a one-rule policy reports the first protected glob
369
+ * for every protected path. The policy gate needs the real answer to name the
370
+ * right authority in a refusal, and the only alternative was a fourth spelling
371
+ * of glob matching in a repository that already counted three spellings of one
372
+ * rule (ADR-21).
373
+ */
374
+ export function globMatches(glob, filePath) {
375
+ const pattern = glob
376
+ .split('**')
377
+ .map((part) => part.split('*').map(escapeRegExp).join('[^/]*'))
378
+ .join('.*');
379
+ // Case-insensitive on purpose: on case-insensitive filesystems (macOS,
380
+ // Windows) `HOOKS/x` and `hooks/x` are the same file, and a security
381
+ // classifier must fail closed rather than let casing pick a weaker class.
382
+ return new RegExp(`^${pattern}$`, 'i').test(filePath);
383
+ }
384
+
385
+ function escapeRegExp(s) {
386
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
387
+ }
388
+
389
+ const VALIDATORS = { config: validateConfig, knowledge: validateKnowledge, policy: validatePolicy };
390
+
391
+ /** Parse + validate a file. Returns {ok, errors}. */
392
+ export function validateFile(kind, file) {
393
+ const validator = VALIDATORS[kind];
394
+ if (!validator) return { ok: false, errors: [`unknown kind "${kind}" (config | knowledge | policy)`] };
395
+ if (!existsSync(file)) return { ok: false, errors: [`file not found: ${file}`] };
396
+ let doc;
397
+ try {
398
+ doc = parse(readFileSync(file, 'utf8'));
399
+ } catch (err) {
400
+ // Any parse failure is a finding, never a crash — pathological input
401
+ // (e.g. deep nesting → RangeError) must not escape as a stack trace
402
+ // (review E2S2-R8).
403
+ if (err instanceof YamlLiteError) return { ok: false, errors: [`YAML: ${err.message}`] };
404
+ return { ok: false, errors: [`YAML: unparseable (${err.name}: ${err.message})`] };
405
+ }
406
+ const errors = validator(doc);
407
+ return { ok: errors.length === 0, errors, doc };
408
+ }
409
+
410
+ // ---------------------------------------------------------------- CLI
411
+
412
+ function main() {
413
+ const [cmd, kind, ...files] = process.argv.slice(2);
414
+ if (cmd !== 'validate' || !kind || files.length === 0) {
415
+ console.error('usage: schema.mjs validate <config|knowledge|policy> <file...>');
416
+ process.exit(2);
417
+ }
418
+ let failed = false;
419
+ for (const file of files) {
420
+ const { ok, errors } = validateFile(kind, file);
421
+ if (ok) {
422
+ console.log(`ok ${escapeInvisible(file)}`);
423
+ } else {
424
+ failed = true;
425
+ console.log(`FAIL ${escapeInvisible(file)}`);
426
+ // Validation messages quote values read out of a YAML file, and a
427
+ // .tyran/ tree can come from a template someone else wrote. Same channel
428
+ // as project.warnings() and the journal CLI, closed the same way.
429
+ for (const e of errors) console.log(` - ${escapeInvisible(String(e))}`);
430
+ }
431
+ }
432
+ process.exit(failed ? 1 : 0);
433
+ }
434
+
435
+ /**
436
+ * Absolute, symlink-resolved path; falls back to the merely absolute form when
437
+ * realpath cannot follow argv[1] (the script directory was renamed after
438
+ * launch, a parent component is unreadable, or a launcher rewrote argv[1] to a
439
+ * logical name). Without the fallback the guard would throw out of module
440
+ * scope and kill the tool at startup — a different bug, not a fix.
441
+ */
442
+ function canonicalPath(path) {
443
+ const abs = resolve(path);
444
+ try {
445
+ return realpathSync(abs);
446
+ } catch {
447
+ return abs;
448
+ }
449
+ }
450
+
451
+ /**
452
+ * True when this module is the program's entry point.
453
+ *
454
+ * BOTH sides must be canonicalized. `import.meta.url` already names the real
455
+ * file — Node resolves module specifiers through symlinks — while
456
+ * `process.argv[1]` is whatever the caller typed. Comparing them raw turned
457
+ * every invocation through a symlinked path into a SILENT no-op under exit 0:
458
+ * `main()` never ran, no file was validated, and CI read that as "valid".
459
+ * `/tmp` and `/var` are symlinks on macOS and plugin installs reach `scripts/`
460
+ * through one, so this is the ordinary case rather than an exotic one.
461
+ */
462
+ function isMainModule(moduleUrl) {
463
+ if (!process.argv[1]) return false;
464
+ return canonicalPath(process.argv[1]) === canonicalPath(fileURLToPath(moduleUrl));
465
+ }
466
+
467
+ if (isMainModule(import.meta.url)) main();
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * stop-check — the operator's brake on a running initiative.
4
+ *
5
+ * Create `.tyran/STOP` (any content; the first line is read back as the
6
+ * reason) and the conductor halts before its next spawn or merge. Delete it
7
+ * and work resumes. The value is that it needs no session: an operator who
8
+ * sees an overnight run going wrong at 3am can stop it from a phone with a
9
+ * one-line commit, and does not have to kill a process and lose the state
10
+ * that would explain what happened.
11
+ *
12
+ * The idea is borrowed from pro-workflow's file kill-switch; the code and the
13
+ * semantics here are our own.
14
+ *
15
+ * WHAT THIS IS NOT: a lock. `.tyran/STOP` is classified KERNEL in the shipped
16
+ * policy, so the write guard refuses an agent that tries to delete it through
17
+ * a writing tool — but the policy gate's own documented limits apply, and a
18
+ * shell command that removes the directory without naming the file is one of
19
+ * them. It stops a loop that is misbehaving. It does not stop one that is
20
+ * trying to escape.
21
+ *
22
+ * CLI:
23
+ * node stop-check.mjs [--dir <repo>] # exit 0 clear · 1 stopped · 2 error
24
+ */
25
+ import { readFileSync, existsSync, statSync, realpathSync } from 'node:fs';
26
+ import { join, resolve } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { escapeInvisible } from './invisible.mjs';
29
+
30
+ export const STOP_PATH = join('.tyran', 'STOP');
31
+
32
+ /** How much of the file is read back. A reason is a sentence, not a payload. */
33
+ const REASON_BYTES = 2000;
34
+
35
+ /**
36
+ * Returns `{stopped, reason, path}`.
37
+ *
38
+ * An unreadable STOP file counts as STOPPED, not as absent. Every other
39
+ * reading in this codebase fails open because a broken gate must not block
40
+ * ordinary work; this one fails CLOSED, because the file's only purpose is
41
+ * to say "do not continue" and the operator who created it is not around to
42
+ * be asked. A brake that releases itself when it is damaged is not a brake.
43
+ */
44
+ export function checkStop(repoDir) {
45
+ const path = join(repoDir, STOP_PATH);
46
+ if (!existsSync(path)) return { stopped: false, reason: null, path };
47
+ try {
48
+ if (statSync(path).isDirectory()) {
49
+ return { stopped: true, reason: '(STOP exists but is a directory)', path };
50
+ }
51
+ const first = readFileSync(path, 'utf8').slice(0, REASON_BYTES).split('\n')[0].trim();
52
+ return { stopped: true, reason: first.length > 0 ? first : '(no reason given)', path };
53
+ } catch (error) {
54
+ return { stopped: true, reason: `(STOP exists but could not be read: ${error.code ?? 'unknown'})`, path };
55
+ }
56
+ }
57
+
58
+ function main() {
59
+ const args = process.argv.slice(2);
60
+ const di = args.indexOf('--dir');
61
+ const dir = resolve(di === -1 ? process.cwd() : (args[di + 1] ?? process.cwd()));
62
+
63
+ const { stopped, reason, path } = checkStop(dir);
64
+ if (!stopped) {
65
+ console.log(`stop-check: clear (no ${STOP_PATH})`);
66
+ process.exit(0);
67
+ }
68
+ // The reason is operator-authored text on its way into a terminal that a
69
+ // half-awake reader is scanning; escape it like any other untrusted string.
70
+ console.error(`stop-check: STOPPED by ${escapeInvisible(path)}`);
71
+ console.error(` reason: ${escapeInvisible(reason)}`);
72
+ process.exit(1);
73
+ }
74
+
75
+ function canonicalPath(path) {
76
+ const abs = resolve(path);
77
+ try {
78
+ return realpathSync(abs);
79
+ } catch {
80
+ return abs;
81
+ }
82
+ }
83
+
84
+ function isMainModule(moduleUrl) {
85
+ if (!process.argv[1]) return false;
86
+ return canonicalPath(process.argv[1]) === canonicalPath(fileURLToPath(moduleUrl));
87
+ }
88
+
89
+ if (isMainModule(import.meta.url)) main();