@sabaiway/agent-workflow-kit 2.1.0 → 3.0.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 (99) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +6 -5
  3. package/SKILL.md +12 -8
  4. package/bin/install.mjs +1 -1
  5. package/bridges/antigravity-cli-bridge/SKILL.md +8 -1
  6. package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +150 -0
  7. package/bridges/antigravity-cli-bridge/bin/agy-review-model-screen.test.mjs +76 -0
  8. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +80 -15
  9. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +95 -4
  10. package/bridges/antigravity-cli-bridge/capability.json +3 -2
  11. package/bridges/codex-cli-bridge/SKILL.md +8 -1
  12. package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +143 -0
  13. package/bridges/codex-cli-bridge/bin/codex-review.sh +71 -10
  14. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +93 -10
  15. package/bridges/codex-cli-bridge/capability.json +3 -2
  16. package/capability.json +1 -1
  17. package/migrations/3.0.0-hardened-core-loop.md +44 -0
  18. package/migrations/README.md +1 -1
  19. package/package.json +2 -2
  20. package/references/hooks/gate-approve.mjs +1 -1
  21. package/references/modes/bootstrap.md +3 -3
  22. package/references/modes/commit-guard.md +19 -0
  23. package/references/modes/core-evidence.md +15 -0
  24. package/references/modes/coverage-check.md +12 -0
  25. package/references/modes/doc-parity.md +7 -6
  26. package/references/modes/gates.md +7 -6
  27. package/references/modes/grounding.md +1 -2
  28. package/references/modes/hook.md +1 -1
  29. package/references/modes/review-state.md +3 -3
  30. package/references/modes/upgrade.md +6 -6
  31. package/references/modes/velocity.md +2 -2
  32. package/references/scripts/archive-decisions.mjs +1 -1
  33. package/references/scripts/install-git-hooks-repo-exec.test.mjs +82 -0
  34. package/references/scripts/install-git-hooks.mjs +90 -18
  35. package/references/scripts/install-git-hooks.test.mjs +102 -0
  36. package/references/scripts/migrate-gates-branches.test.mjs +157 -0
  37. package/references/scripts/migrate-gates.mjs +395 -0
  38. package/references/scripts/migrate-gates.test.mjs +284 -0
  39. package/references/templates/agent_rules.md +2 -2
  40. package/tools/ack-write.mjs +1 -1
  41. package/tools/atomic-write.mjs +2 -2
  42. package/tools/autonomy-config.mjs +1 -1
  43. package/tools/autonomy-doctor.mjs +1 -1
  44. package/tools/autonomy-write.mjs +1 -1
  45. package/tools/bridge-settings-read.mjs +1 -1
  46. package/tools/bridge-settings.mjs +1 -1
  47. package/tools/changed-surface.mjs +8 -69
  48. package/tools/cheap-agents.mjs +2 -2
  49. package/tools/commands.mjs +17 -10
  50. package/tools/commit-guard.mjs +167 -0
  51. package/tools/core-evidence.mjs +914 -0
  52. package/tools/coverage-check.mjs +260 -0
  53. package/tools/delegation.mjs +1 -1
  54. package/tools/detect-backends.mjs +3 -3
  55. package/tools/doc-parity.mjs +11 -27
  56. package/tools/engine-source.mjs +1 -1
  57. package/tools/family-members.mjs +1 -1
  58. package/tools/family-registry.mjs +1 -1
  59. package/tools/fs-safe.mjs +1 -1
  60. package/tools/gate-hook.mjs +1 -1
  61. package/tools/{seed-gates.mjs → gates-init.mjs} +58 -52
  62. package/tools/grounding.mjs +6 -83
  63. package/tools/hide-footprint.mjs +1 -1
  64. package/tools/inject-methodology.mjs +1 -1
  65. package/tools/known-footprint.mjs +1 -1
  66. package/tools/labels.mjs +1 -1
  67. package/tools/lcov.mjs +6 -10
  68. package/tools/lens-region.mjs +1 -1
  69. package/tools/manifest/validate.mjs +19 -0
  70. package/tools/migrate-adr-store.mjs +2 -2
  71. package/tools/orchestration-config.mjs +1 -1
  72. package/tools/orchestration-write.mjs +1 -1
  73. package/tools/presentation.mjs +1 -1
  74. package/tools/procedures.mjs +2 -2
  75. package/tools/recipes.mjs +60 -9
  76. package/tools/recommendations.mjs +63 -7
  77. package/tools/release-scan.mjs +1 -1
  78. package/tools/renderers.mjs +1 -1
  79. package/tools/review-state.mjs +288 -340
  80. package/tools/run-gates.mjs +216 -92
  81. package/tools/sandbox-masks.mjs +1 -1
  82. package/tools/semver-lite.mjs +1 -1
  83. package/tools/set-autonomy.mjs +1 -1
  84. package/tools/set-recipe.mjs +1 -1
  85. package/tools/setup-backends.mjs +1 -1
  86. package/tools/surface.mjs +1 -1
  87. package/tools/uninstall.mjs +1 -1
  88. package/tools/velocity-profile.mjs +2 -2
  89. package/tools/view-model.mjs +1 -1
  90. package/references/modes/fold-completeness.md +0 -30
  91. package/references/modes/review-ledger.md +0 -34
  92. package/references/templates/verification-profile.json +0 -10
  93. package/tools/fold-completeness-run.mjs +0 -1120
  94. package/tools/fold-completeness.mjs +0 -672
  95. package/tools/review-ledger-core.mjs +0 -428
  96. package/tools/review-ledger-write.mjs +0 -647
  97. package/tools/review-ledger.mjs +0 -630
  98. package/tools/sarif.mjs +0 -52
  99. package/tools/verification-profile.mjs +0 -219
package/tools/recipes.mjs CHANGED
@@ -12,14 +12,14 @@
12
12
  // Invariants (the backends/status posture): pure (no fs/network/CLI in planRecipe/recommendRecipe),
13
13
  // read-only, NEVER runs a subscription CLI. The kit only surfaces/selects/plans a recipe — the
14
14
  // orchestrator (the main agent) executes it via the bridge skills and always makes the single commit;
15
- // a backend is advisory or delegated, never autonomous. Dependency-free, Node >= 18.
15
+ // a backend is advisory or delegated, never autonomous. Dependency-free, Node >= 22.
16
16
 
17
- import { existsSync } from 'node:fs';
17
+ import { existsSync, readFileSync } from 'node:fs';
18
18
  import { dirname, join, resolve } from 'node:path';
19
19
  import { pathToFileURL } from 'node:url';
20
20
  // The host-level bridge-settings snapshot (fact-only, best-effort). READ-ONLY core only — never the
21
21
  // writer — so this read-only advisor never pulls in the atomic-write core.
22
- import { settingsSnapshot } from './bridge-settings-read.mjs';
22
+ import { settingsSnapshot, DEFAULT_BUNDLE_ROOT } from './bridge-settings-read.mjs';
23
23
  import {
24
24
  detectBackends,
25
25
  wrapperCmdFor,
@@ -298,7 +298,50 @@ export const resolveActivityRecipe = ({ config = {}, readiness = [], activity, s
298
298
  // DISPLAY_ALIASES — the ONE alias table the recommendation clause already uses; ordering is the
299
299
  // deterministic BACKEND_PRIORITY (codex before agy), independent of detection emission order.
300
300
  // Always exactly one line: no part may carry a newline (pinned by tests).
301
- export const composeStatusLine = (detection, recommendation, settings = null, autonomy = null) => {
301
+ // composeConfiguredPosture(ctx) the CONFIGURED dispatch posture (strip-the-kit D5), rendered
302
+ // from the bundled manifests' VALIDATED posture pins overlaid with the ACTIVE bridge-settings —
303
+ // today only the codex tier knob (bridge-settings stays model/effort-free by design, so this
304
+ // surface can never carry a settings-file model claim). Returns null when NO bundled bridge
305
+ // declares a posture block (a pre-D5 bundle keeps every surface byte-identical). Best-effort:
306
+ // a corrupt bundle degrades to null — the posture segment is a footer fact, never a gate.
307
+ export const composeConfiguredPosture = (ctx = {}) => {
308
+ try {
309
+ const bundleRoot = ctx.bundleRoot ?? DEFAULT_BUNDLE_ROOT;
310
+ const read = ctx.readFile ?? readFileSync;
311
+ const parts = [];
312
+ for (const name of BACKEND_PRIORITY) {
313
+ let manifest;
314
+ try {
315
+ manifest = JSON.parse(String(read(join(bundleRoot, name, 'capability.json'), 'utf8')));
316
+ } catch {
317
+ return null; // an unreadable/corrupt manifest is CORRUPTION — a partial posture would lie
318
+ }
319
+ if (!Object.hasOwn(manifest, 'posture')) continue; // a pre-D5 bridge — legitimate absence
320
+ const posture = manifest.posture;
321
+ // The FULL closed shape (the validator/receipt-predicate twin): a present-but-invalid
322
+ // block nulls the WHOLE render — fail closed to silence, never a partial/mangled line.
323
+ const invalid =
324
+ posture === null || typeof posture !== 'object' || Array.isArray(posture) ||
325
+ typeof posture.model !== 'string' || posture.model.length === 0 ||
326
+ (Object.hasOwn(posture, 'effort') && (typeof posture.effort !== 'string' || posture.effort.length === 0)) ||
327
+ (Object.hasOwn(posture, 'tier') && posture.tier !== null && (typeof posture.tier !== 'string' || posture.tier.length === 0)) ||
328
+ Object.keys(posture).some((k) => !['model', 'effort', 'tier'].includes(k));
329
+ if (invalid) return null;
330
+ const seg = [`model=${posture.model}`];
331
+ if (Object.hasOwn(posture, 'effort')) seg.push(`effort=${posture.effort}`);
332
+ if (Object.hasOwn(posture, 'tier')) {
333
+ const knob = (ctx.settings?.active ?? []).find((s) => s.key === 'CODEX_SERVICE_TIER' && s.bridge === name);
334
+ seg.push(knob ? `tier=${knob.value} (bridge-settings)` : `tier=${posture.tier ?? 'standard'}`);
335
+ }
336
+ parts.push(`${DISPLAY_ALIASES[name] ?? name} ${seg.join(' ')}`);
337
+ }
338
+ return parts.length ? parts.join(' · ') : null;
339
+ } catch {
340
+ return null;
341
+ }
342
+ };
343
+
344
+ export const composeStatusLine = (detection, recommendation, settings = null, autonomy = null, posture = null) => {
302
345
  const backends = [...detection]
303
346
  .sort((a, b) => priorityIndex(a.name) - priorityIndex(b.name))
304
347
  .map((b) => `${DISPLAY_ALIASES[b.name] ?? b.name} ${b.readiness === READY ? '✓' : '✗'} ${b.readiness}`)
@@ -315,7 +358,10 @@ export const composeStatusLine = (detection, recommendation, settings = null, au
315
358
  // suffix precedent). Fact-only: effective per-activity levels + the render-sync state; an absent
316
359
  // policy says "computed defaults" honestly; a malformed policy surfaces LOUDLY, never omitted.
317
360
  const autonomySegment = autonomy == null ? '' : ` · autonomy: ${oneLine(formatAutonomySegment(autonomy))}`;
318
- return base + suffix + autonomySegment;
361
+ // The D5 posture segment: rendered ONLY when the caller supplies the composed pins-derived
362
+ // posture (composeConfiguredPosture) — an omitted/null param keeps the line byte-identical.
363
+ const postureSegment = posture == null ? '' : ` · posture: ${oneLine(posture)}`;
364
+ return base + suffix + autonomySegment + postureSegment;
319
365
  };
320
366
 
321
367
  // The one-segment autonomy renderer behind composeStatusLine's 4th param.
@@ -425,7 +471,7 @@ export const composeActiveRecipeLine = ({ config, source } = {}, detection, auto
425
471
 
426
472
  // The structured report behind `--json` — the recipes, the recommendation, a plan per recipe, and
427
473
  // (additive) the pasteable one-line backend status composed from the same detection.
428
- export const buildReport = (detection, settings = null, autonomy = null) => {
474
+ export const buildReport = (detection, settings = null, autonomy = null, posture = null) => {
429
475
  const recommendation = recommendRecipe(detection);
430
476
  return {
431
477
  recipes: RECIPES.map(({ id, title, role, minBackends, degradesTo, summary }) => ({
@@ -440,7 +486,7 @@ export const buildReport = (detection, settings = null, autonomy = null) => {
440
486
  plans: RECIPES.map((r) => planRecipe(r.id, detection)),
441
487
  // The SAME autonomy facts the --status-line surface renders — the --json envelope must never
442
488
  // expose a stale machine-composed status line (codex R1, Segment B).
443
- statusLine: composeStatusLine(detection, recommendation, settings, autonomy),
489
+ statusLine: composeStatusLine(detection, recommendation, settings, autonomy, posture),
444
490
  };
445
491
  };
446
492
 
@@ -512,8 +558,13 @@ exclusive. Detection only — never writes, never commits, never runs a subscrip
512
558
  console.error(`[agent-workflow-kit] ${err.message}`);
513
559
  return err.exitCode ?? 1;
514
560
  }
515
- } else if (argv.includes('--status-line')) console.log(composeStatusLine(detection, recommendRecipe(detection), settingsSnapshot(), await composeAutonomyFacts(process.cwd())));
516
- else if (argv.includes('--json')) console.log(JSON.stringify(buildReport(detection, settingsSnapshot(), await composeAutonomyFacts(process.cwd())), null, 2));
561
+ } else if (argv.includes('--status-line')) {
562
+ const snapshot = settingsSnapshot();
563
+ console.log(composeStatusLine(detection, recommendRecipe(detection), snapshot, await composeAutonomyFacts(process.cwd()), composeConfiguredPosture({ settings: snapshot })));
564
+ } else if (argv.includes('--json')) {
565
+ const snapshot = settingsSnapshot();
566
+ console.log(JSON.stringify(buildReport(detection, snapshot, await composeAutonomyFacts(process.cwd()), composeConfiguredPosture({ settings: snapshot })), null, 2));
567
+ }
517
568
  else console.log(formatRecipes(detection));
518
569
  return 0;
519
570
  };
@@ -28,9 +28,9 @@
28
28
  // Read-only: never writes, never commits, never runs a subscription CLI. The reused probes are all
29
29
  // exported read-only surfaces of their owning tools (velocity/autonomy/doctor/backends/recipes/
30
30
  // registry/sandbox-masks); the sandbox-masks and settings probes may run read-only git queries.
31
- // Dependency-free, Node >= 18. No side effects on import (the isDirectRun idiom).
31
+ // Dependency-free, Node >= 22. No side effects on import (the isDirectRun idiom).
32
32
 
33
- import { readFileSync, readdirSync, lstatSync } from 'node:fs';
33
+ import { readFileSync, readdirSync, lstatSync, existsSync } from 'node:fs';
34
34
  import { createHash } from 'node:crypto';
35
35
  import { homedir } from 'node:os';
36
36
  import { dirname, join, resolve } from 'node:path';
@@ -54,6 +54,8 @@ import { ACTIVITIES, resolveActivityRecipe } from './recipes.mjs';
54
54
  import { surveyFamily, surveyGateHook } from './family-registry.mjs';
55
55
  import { probeSandboxMasks, needsMasksApply } from './sandbox-masks.mjs';
56
56
  import { shellQuoteArg } from './review-state.mjs';
57
+ import { isFinalCapableDeclaration } from './run-gates.mjs';
58
+ import { resolveGitHooksPath } from './commit-guard.mjs';
57
59
  import { loadConfig } from './orchestration-config.mjs';
58
60
  import { DEFAULT_BUNDLE_ROOT, SETTINGS_FILENAME, settingsPath, parseSettings, duplicateKeys } from './bridge-settings-read.mjs';
59
61
  import { assertContainedRealPath } from './fs-safe.mjs';
@@ -97,6 +99,7 @@ export const SEVERITIES = Object.freeze({
97
99
  'review-recipe': SEVERITY_ATTENTION,
98
100
  'gates-declaration': SEVERITY_OPTIONAL,
99
101
  'gate-hook': SEVERITY_OPTIONAL,
102
+ 'commit-guard': SEVERITY_OPTIONAL,
100
103
  'read-lane': SEVERITY_OPTIONAL,
101
104
  'read-lane.stale': SEVERITY_ATTENTION,
102
105
  'read-lane.missing': SEVERITY_ATTENTION,
@@ -152,6 +155,7 @@ export const WHATS = Object.freeze({
152
155
  'review-recipe': '{degraded}',
153
156
  'gates-declaration': 'no declared gate matrix (docs/ai/gates.json absent or empty) — gates prompt one by one; the apply PREVIEWS its --apply line, writes nothing',
154
157
  'gate-hook': '{n} declared gate(s) prompt per run — the gate-approval hook is not wired',
158
+ 'commit-guard': 'the gate matrix is final-run-capable but no commit-guard arms the pre-commit hook — a commit needs no green receipt yet',
155
159
  'read-lane': 'the gate hook is wired but the read-only compound lane is off — pipes/chains of seeded reads still prompt one by one',
156
160
  'read-lane.stale': 'the read-lane is ON but the placed gate hook is stale — an old hook never reads lanes.json, so the lane is silently dark; reseed it',
157
161
  'read-lane.missing': 'the gate hook is wired but its placed file is missing — every Bash call errors and the read-lane is dark; re-place it',
@@ -205,6 +209,7 @@ export const BENEFITS = Object.freeze({
205
209
  'review-recipe': 'review coverage — the review recipe you configured actually runs instead of silently degrading',
206
210
  'gates-declaration': 'velocity — your project’s gates run as ONE declared batch with a PASS/FAIL table',
207
211
  'gate-hook': 'velocity — your own declared gate commands auto-approve byte-exactly (opt-in PreToolUse hook)',
212
+ 'commit-guard': 'integrity — commits require the ONE green --final receipt at the exact staged fingerprint (consented pre-commit arm)',
208
213
  'read-lane': 'velocity — pipes/chains of your seeded read-only commands auto-approve instead of prompting (opt-in, conservatively classified)',
209
214
  'family-freshness': 'currency — placed family members carry the latest shipped fixes and features',
210
215
  'sandbox-masks': 'zero clutter — git status shows only your changes (the review domain already ignores the masks by construction)',
@@ -326,13 +331,13 @@ const probeGates = ({ root, deps, add, skip }) => {
326
331
  if (sg.error) throw new Error(sg.error);
327
332
  if (sg.declarationPresent && sg.declaredGates === null) throw new Error(sg.declarationError ?? 'gates.json present but unreadable');
328
333
  // An ABSENT file and the seeded-EMPTY list are equally undeclared (codex R2, Segment B); the
329
- // apply is the consent-gated seeder PREVIEW (it proposes entries from the project's own
334
+ // apply is the consent-gated gates-init PREVIEW (it proposes entries from the project's own
330
335
  // scripts and writes only on an explicit yes) — never the runner.
331
336
  if (!sg.declarationPresent || sg.declaredGates === 0) {
332
- // The seeder writes ONLY with --apply and consent is per-entry (--only) the apply field
333
- // stays a PURE executable command (run-exactly-as-rendered feeds it to the shell); the
334
- // two-step preview semantics live in WHAT, never as prose appended to the command.
335
- add('gates-declaration', fillTemplate(WHATS['gates-declaration'], {}), `node ${q(toolPath('seed-gates.mjs'))} --cwd ${q(root)}`);
337
+ // gates-init writes ONLY with --apply the apply field stays a PURE executable command
338
+ // (run-exactly-as-rendered feeds it to the shell); the two-step preview semantics live in
339
+ // WHAT, never as prose appended to the command.
340
+ add('gates-declaration', fillTemplate(WHATS['gates-declaration'], {}), `node ${q(toolPath('gates-init.mjs'))} --cwd ${q(root)}`);
336
341
  return;
337
342
  }
338
343
  if (sg.declaredGates > 0 && !sg.wired) {
@@ -343,6 +348,56 @@ const probeGates = ({ root, deps, add, skip }) => {
343
348
  }
344
349
  };
345
350
 
351
+ // The D10 consumer surface: once the declaration is FINAL-capable (the canonical core checks
352
+ // present, the checker LAST — the run-gates helper is the one home of that rule), offer the
353
+ // consented commit-guard install — for a MANAGED guardless pre-commit hook AND for an absent one
354
+ // alike; an UNMANAGED hook is a loud skip (manual merge, never an overwrite offer); a
355
+ // non-final-capable declaration gets NO offer (an armed guard over a matrix that cannot mint a
356
+ // receipt would block every commit).
357
+ const INSTALLER_MARKER_RE = /:install-git-hooks\.mjs$/m;
358
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
359
+ // Convergence needs the EXACT canonical armed line the installer writes — full-line anchored on
360
+ // the kit's OWN resolved guard path. A commented-out line or a lookalike path (fake-commit-guard)
361
+ // must never read as armed (that would suppress the offer and leave commits unguarded).
362
+ const canonicalGuardLineRe = () => new RegExp(`^node "${escapeRe(toolPath('commit-guard.mjs'))}" --check$`, 'm');
363
+ const probeCommitGuard = ({ root, deps, add, skip }) => {
364
+ try {
365
+ const read = deps.readFile ?? readFileSync;
366
+ const gatesRaw = (() => {
367
+ try {
368
+ return String(read(join(root, 'docs', 'ai', 'gates.json'), 'utf8'));
369
+ } catch {
370
+ return null;
371
+ }
372
+ })();
373
+ if (gatesRaw === null) return; // undeclared — the gates-declaration item owns that story
374
+ const gates = JSON.parse(gatesRaw).gates;
375
+ if (!isFinalCapableDeclaration(gates, root)) return; // no receipt to guard yet — no offer
376
+ const installer = join(root, 'scripts', 'install-git-hooks.mjs');
377
+ if (!(deps.exists ?? existsSync)(installer)) {
378
+ skip('commit-guard', new Error('the hook installer (scripts/install-git-hooks.mjs) is not deployed — run upgrade first'));
379
+ return;
380
+ }
381
+ const hooksPath = (deps.gitHooksPath ?? resolveGitHooksPath)(root);
382
+ if (hooksPath === null) return; // not a git tree — nothing to hook
383
+ const hook = (() => {
384
+ try {
385
+ return String(read(join(hooksPath, 'pre-commit'), 'utf8'));
386
+ } catch {
387
+ return null;
388
+ }
389
+ })();
390
+ if (hook !== null && canonicalGuardLineRe().test(hook)) return; // armed — converged (managed or not)
391
+ if (hook !== null && !INSTALLER_MARKER_RE.test(hook)) {
392
+ skip('commit-guard', new Error('an UNMANAGED pre-commit hook exists — merge the guard line by hand (the installer refuses to overwrite it)'));
393
+ return;
394
+ }
395
+ add('commit-guard', fillTemplate(WHATS['commit-guard'], {}), `node ${q(installer)} --commit-guard ${q(toolPath('commit-guard.mjs'))}`);
396
+ } catch (err) {
397
+ skip('commit-guard', err);
398
+ }
399
+ };
400
+
346
401
  // The read-lane offer (AD-055 Part II, Help-through-Recommendations): once the gate hook is PLACED
347
402
  // and WIRED, offer to enable the opt-in read-only compound lane. Mutually exclusive with the
348
403
  // gate-hook item BY CONSTRUCTION — that item keys on `!wired`, this one on `wired` — so an unplaced
@@ -690,6 +745,7 @@ const PROBES = Object.freeze([
690
745
  probeSandboxProvision,
691
746
  probeReviewRecipe,
692
747
  probeGates,
748
+ probeCommitGuard,
693
749
  probeReadLane,
694
750
  probeFamilyFreshness,
695
751
  probeMasksItem,
@@ -8,7 +8,7 @@
8
8
  // tripping the (narrow) attribution rules. Only the TEST is self-excluded (it holds attribution
9
9
  // fixtures); the scanner source contains no attribution construction, so it is scanned normally.
10
10
  //
11
- // Pure `scanText` for unit fixtures; `scanPaths`/CLI for the release tree. Dependency-free, Node >= 18.
11
+ // Pure `scanText` for unit fixtures; `scanPaths`/CLI for the release tree. Dependency-free, Node >= 22.
12
12
 
13
13
  import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
14
14
  import { join, extname, basename, resolve } from 'node:path';
@@ -5,7 +5,7 @@
5
5
  // the JSON mode is handled by the caller (it prints the envelope, not a render). The renderers do
6
6
  // LAYOUT + GLYPHS only — phrasing + headline counts already live in the ViewModel.
7
7
  //
8
- // Pure, no side effects, Node >= 18.
8
+ // Pure, no side effects, Node >= 22.
9
9
 
10
10
  import { BLOCK_TITLES, SETTINGS_LABELS, glyphsFor, NO_DEPLOYMENT } from './presentation.mjs';
11
11