@codyswann/lisa 3.16.0 → 3.17.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 (119) hide show
  1. package/all/copy-overwrite/scripts/lisa-gates.mjs +1165 -0
  2. package/all/copy-overwrite/scripts/lisa-reconcile-policy.mjs +1188 -0
  3. package/all/copy-overwrite/scripts/lisa-run-gates.mjs +597 -0
  4. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  5. package/dist/core/lisa-owned-hash-ledger.js +24 -0
  6. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  7. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  8. package/dist/core/upstream-evidence-manifest.js +72 -8
  9. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  10. package/package.json +6 -2
  11. package/plugins/lisa/.claude-plugin/plugin.json +10 -1
  12. package/plugins/lisa/.codex-plugin/hooks.json +9 -0
  13. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  14. package/plugins/lisa/.codex-plugin/skills/lisa-doctor/SKILL.md +108 -2
  15. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
  16. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
  17. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
  18. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
  19. package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
  20. package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
  21. package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
  22. package/plugins/lisa/hooks/secrets-preflight.sh +72 -0
  23. package/plugins/lisa/skills/lisa-doctor/SKILL.md +108 -2
  24. package/plugins/lisa/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
  25. package/plugins/lisa/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
  26. package/plugins/lisa/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
  27. package/plugins/lisa/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
  28. package/plugins/lisa/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
  29. package/plugins/lisa/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
  30. package/plugins/lisa/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
  31. package/plugins/lisa-agy/plugin.json +1 -1
  32. package/plugins/lisa-agy/skills/lisa-doctor/SKILL.md +108 -2
  33. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
  34. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
  35. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
  36. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
  37. package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
  38. package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
  39. package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
  40. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  43. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-copilot/.claude-plugin/plugin.json +10 -1
  46. package/plugins/lisa-copilot/hooks/secrets-preflight.sh +72 -0
  47. package/plugins/lisa-copilot/skills/lisa-doctor/SKILL.md +108 -2
  48. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
  49. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
  50. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
  51. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
  52. package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
  53. package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
  54. package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
  55. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-cursor/hooks/hooks.json +3 -0
  57. package/plugins/lisa-cursor/hooks/secrets-preflight.sh +72 -0
  58. package/plugins/lisa-cursor/skills/lisa-doctor/SKILL.md +108 -2
  59. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
  60. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
  61. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
  62. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
  63. package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
  64. package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
  65. package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
  66. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-expo-agy/plugin.json +1 -1
  69. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  73. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  74. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  78. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  79. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  83. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  84. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  88. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  89. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  93. package/plugins/lisa-rails-agy/plugin.json +1 -1
  94. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  98. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  99. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  100. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  103. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  104. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  105. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  106. package/plugins/src/base/.claude-plugin/plugin.json +9 -0
  107. package/plugins/src/base/hooks/secrets-preflight.sh +72 -0
  108. package/plugins/src/base/skills/lisa-doctor/SKILL.md +108 -2
  109. package/plugins/src/base/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
  110. package/plugins/src/base/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
  111. package/plugins/src/base/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
  112. package/plugins/src/base/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
  113. package/plugins/src/base/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
  114. package/plugins/src/base/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
  115. package/plugins/src/base/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
  116. package/scripts/generate-lisa-owned-hash-ledger.mjs +10 -1
  117. package/scripts/lib/per-agent-hook-filter.mjs +18 -0
  118. package/typescript/copy-contents/.husky/pre-commit +130 -21
  119. package/typescript/copy-contents/.husky/pre-push +202 -22
package/package.json CHANGED
@@ -37,17 +37,21 @@
37
37
  "check:upstream-evidence-manifest": "node scripts/generate-upstream-evidence-manifest.mjs --check",
38
38
  "build:lisa-owned-hash-ledger": "node scripts/generate-lisa-owned-hash-ledger.mjs",
39
39
  "check:lisa-owned-hash-ledger": "node scripts/generate-lisa-owned-hash-ledger.mjs --check",
40
+ "check:artifacts": "status=0; bun run check:upstream-evidence-manifest || status=1; bun run check:lisa-owned-hash-ledger || status=1; exit $status",
40
41
  "refresh:upstream-public-commits": "node scripts/generate-upstream-evidence-manifest.mjs --refresh-public-commits",
41
42
  "check:rules-pairing": "bash scripts/check-rules-pairing.sh",
42
43
  "check:workflow-inputs": "node scripts/detect-stale-workflow-inputs.mjs --project . --project typescript/create-only --project rails/create-only --json",
43
44
  "check:conflict-markers": "node scripts/check-conflict-markers.mjs",
44
45
  "check:duplicate-versions": "node scripts/check-duplicate-versions.mjs",
45
46
  "check:required-check-promotions": "node scripts/check-required-check-promotions.mjs",
47
+ "check:work-item": "node scripts/lisa-work-item.mjs validate-pr",
48
+ "check:thresholds": "node plugins/src/base/hooks/threshold-ratchet.mjs --base HEAD^",
46
49
  "verify:health-contract": "bun run build:dist && node scripts/verify-health-contract-built.mjs",
47
50
  "verify:health-deterministic": "bun run build:dist && node scripts/verify-health-deterministic-built.mjs",
48
51
  "verify:learner-frontmatter-built": "bun run build:dist && node scripts/verify-learner-frontmatter-built.mjs",
49
52
  "verify:health-agentic": "bun run build:dist && node scripts/verify-health-agentic-built.mjs",
50
- "verify:health-consumer": "bun run build:dist && node scripts/verify-health-consumer-built.mjs"
53
+ "verify:health-consumer": "bun run build:dist && node scripts/verify-health-consumer-built.mjs",
54
+ "lint:staged": "lint-staged"
51
55
  },
52
56
  "engines": {
53
57
  "npm": "please-use-bun",
@@ -125,7 +129,7 @@
125
129
  }
126
130
  },
127
131
  "name": "@codyswann/lisa",
128
- "version": "3.16.0",
132
+ "version": "3.17.0",
129
133
  "description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
130
134
  "main": "dist/index.js",
131
135
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "3.16.0",
3
+ "version": "3.17.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -193,6 +193,15 @@
193
193
  }
194
194
  ]
195
195
  },
196
+ {
197
+ "matcher": "",
198
+ "hooks": [
199
+ {
200
+ "type": "command",
201
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/secrets-preflight.sh"
202
+ }
203
+ ]
204
+ },
196
205
  {
197
206
  "matcher": "",
198
207
  "hooks": [
@@ -114,6 +114,15 @@
114
114
  "command": "${PLUGIN_ROOT}/hooks/setup-jira-cli.sh"
115
115
  }
116
116
  ]
117
+ },
118
+ {
119
+ "matcher": "",
120
+ "hooks": [
121
+ {
122
+ "type": "command",
123
+ "command": "${PLUGIN_ROOT}/hooks/secrets-preflight.sh"
124
+ }
125
+ ]
117
126
  }
118
127
  ],
119
128
  "SubagentStart": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "3.16.0",
3
+ "version": "3.17.0",
4
4
  "description": "Universal governance: agents, skills, commands, hooks, and rules for all projects.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -422,14 +422,93 @@ The verdict ladder is:
422
422
  - `READY_WITH_WARNINGS` — no `FAIL`, but one or more `WARN`.
423
423
  - `NOT_READY` — one or more `FAIL`.
424
424
 
425
+ ## Gate configuration
426
+
427
+ ```sh
428
+ node scripts/lisa-gates.mjs validate # shape + unknown gate ids
429
+ node scripts/lisa-gates.mjs list --moment=pull-request
430
+ node scripts/lisa-gates.mjs contexts # branch-protection contexts
431
+ ```
432
+
433
+ A gate is a **property** — *credential leakage* — not a tool. `gitleaks` is one way to prove
434
+ that property, and which way is the project's choice: each gate names a task in the project's own
435
+ runner, so swapping the tool changes one line of project config and nothing in Lisa.
436
+
437
+ `validate` refuses an unknown gate id rather than ignoring it. A misspelled `credential-leakge`
438
+ would otherwise read as an enabled guarantee and run nothing at all — the same silent-hole shape
439
+ as a skipped required check. Gates a project invents carry an `x-` prefix, which Lisa runs without
440
+ pretending to understand.
441
+
442
+ `contexts` is the value that replaces a hand-transcribed branch-protection list. It is scoped to
443
+ one environment: a gate required before a production deploy is **not** thereby a merge blocker on a
444
+ pull request, and collapsing the two would promote every deploy-time gate into branch protection.
445
+
446
+ When reconciling a ruleset against it, pass `--previous=` with any label retired in the last
447
+ release. Downstream repositories call the shared workflow unpinned, so a renamed job reaches every
448
+ repository before any of them has reconciled — and a required context that never reports leaves
449
+ pull requests waiting indefinitely. The fastest way out of that is deleting the requirement, which
450
+ is how a rename ends up removing a guarantee. Emitting both labels for one release avoids it.
451
+
452
+ ### Reconciling against the live ruleset
453
+
454
+ `contexts` says what the repository *should* require. Reconciliation asks whether GitHub agrees:
455
+
456
+ ```sh
457
+ node scripts/lisa-reconcile-policy.mjs --dry-run # read-only: what would change
458
+ node scripts/lisa-reconcile-policy.mjs --on-drift=report # report, write nothing
459
+ node scripts/lisa-reconcile-policy.mjs --on-drift=block # exit 1 on any drift, for CI
460
+ node scripts/lisa-reconcile-policy.mjs # honors policy.on_drift
461
+ node scripts/lisa-reconcile-policy.mjs --previous="🧽 Lint" # keep a renamed context required
462
+ node scripts/lisa-reconcile-policy.mjs --prune # also remove EXTRA contexts
463
+ ```
464
+
465
+ It reads the live ruleset and repository settings through `gh`, compares them against the derived
466
+ contexts and the `policy` block, and reports three sets: **MISSING** (declared, not live), **EXTRA**
467
+ (live, not declared), **MATCHED**. `--dry-run` never writes, whatever `policy.on_drift` says.
468
+
469
+ Three behaviors need an operator to understand them before reading a report.
470
+
471
+ **1. `UNPROVEN` is not a pass.** If `gh` is missing, unauthenticated, or the API errors — a private
472
+ repository on a plan without rulesets answers `403` — the verdict is `UNPROVEN` and the exit code is
473
+ `2`. It is neither of its neighbours, and the distinction is the same one the secrets preflight
474
+ draws with `unreachable`: reported as clean it is a vacuous green, clean precisely because nothing
475
+ was learned; reported as drift it sends someone to fix a ruleset that may be perfectly correct. The
476
+ drift sets come back `null` rather than empty, because empty is what a clean repository looks like.
477
+ `on_drift` does not soften this — not even `report` — because `on_drift` decides what to do about a
478
+ drift that was *measured*, and here nothing was. Map it to doctor's `WARN`/`FAIL` on the same rule
479
+ as any other unavailable check surface: never `PASS`.
480
+
481
+ **2. An `EXTRA` context is reported, never removed.** Lisa does not own the whole required list.
482
+ `SonarCloud Code Analysis`, `GitGuardian Security Checks`, and `CodeRabbit` are posted by external
483
+ apps that no gates block declares, so `contextsFor` cannot derive them and every one of them is
484
+ EXTRA by construction. Under `repair` the script therefore ADDS what is missing and leaves what is
485
+ extra alone, naming each one. Removing them requires `--prune`, and the right way to clear the list
486
+ is one at a time: each EXTRA context is either an app to keep, or a gate that belongs in
487
+ `.lisa.config.json` — decide which before pruning anything.
488
+
489
+ **3. Keep both names during a rename.** `--previous=<old label>` requires the old and the new
490
+ context simultaneously for one release. Without it the reconciliation reports the still-live old
491
+ context as EXTRA (and `--prune` would delete it) while in-flight pull requests wait on a context
492
+ that will never report again.
493
+
494
+ Repair writes exactly two things: required contexts on a ruleset, and repository settings. Policy
495
+ carried by the *shape* of a rule — linear history, signed commits, force-push and deletion
496
+ protection, conversation resolution — is compared here and repaired by
497
+ `scripts/lisa-github-rulesets.sh`, which owns rule construction; the reconciler reports those and
498
+ names that script rather than reshaping rules it did not build. When more than one ruleset requires
499
+ status checks it refuses to guess which owns the derived contexts and asks for `--ruleset=<name>`,
500
+ because writing to the wrong one enforces a context under a different ref-name condition.
501
+
425
502
  ## Secrets configuration
426
503
 
427
504
  Run the secrets health checks through the skill that owns the contract, rather than reimplementing
428
505
  any part of it here:
429
506
 
430
507
  ```sh
431
- node .claude/skills/lisa-secrets-access/scripts/validate-config.mjs # shape
432
- node .claude/skills/lisa-secrets-access/scripts/doctor-secrets.mjs # health
508
+ node .claude/skills/lisa-secrets-access/scripts/validate-config.mjs # shape
509
+ node .claude/skills/lisa-secrets-access/scripts/preflight-secrets.mjs # credential readiness
510
+ node .claude/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs # tooling readiness
511
+ node .claude/skills/lisa-secrets-access/scripts/doctor-secrets.mjs # health
433
512
  ```
434
513
 
435
514
  Run the validator first. It checks only the *shape* of the `secrets`, `remoteEnv`, and
@@ -438,6 +517,33 @@ would otherwise surface somewhere unhelpful: a container failing mid-setup, a sc
438
517
  never fires, a dispatch naming a surface nobody provisioned. The health check then asks whether the
439
518
  credentials actually resolve.
440
519
 
520
+ The preflight in the middle is the same check the SessionStart hook runs, and it is here for the
521
+ half the hook deliberately cannot do. The hook injects its verdict into an agent's context and
522
+ never blocks — killing a session over a credential it may not need would tax every session for a
523
+ minority need, and a control people route around enforces nothing. Doctor is where the same
524
+ verdict is allowed to be a non-zero exit.
525
+
526
+ It reports three outcomes, and the third carries the weight. `ok` and `missing` are self-evident.
527
+ **`unreachable`** means the provider itself could not be asked — no CLI, no bootstrap token, an API
528
+ that errored — and it is neither of its neighbours. Reported as `ok` it would be a vacuous green,
529
+ clean precisely because nothing was learned. Reported as `missing` it would blame the vault for a
530
+ fault in this machine's access and send someone to grant a credential that was never absent. Both
531
+ fail; they differ in what they tell the reader to fix.
532
+
533
+ The tooling preflight asks the same question about CLIs, and it is a *caller* of
534
+ `planToolchain` rather than a second implementation — that distinction is the point.
535
+ `verify-remote-env.mjs` previously ran its own toolchain loop which never consulted
536
+ `minVersion`, so a container verified clean against a `node` older than the manifest
537
+ demanded while the plan-side check rejected exactly that. One function now answers the
538
+ question everywhere.
539
+
540
+ It needs only two verdicts. A local binary cannot fail to be asked the way a vault can,
541
+ and the analogous trap — a tool present at an unparseable version — already fails closed
542
+ upstream, because an unknown version loses every `minVersion` comparison. What it does add
543
+ is a split credentials have no equivalent for: a missing tool with a pinned, checksummed
544
+ `install` entry is something Lisa can place itself, so it reports an action and exits zero,
545
+ while a tool nothing can provision blocks and exits non-zero.
546
+
441
547
  It reports without ever printing a value, and compares two copies of the same credential by digest.
442
548
  Map its findings into the doctor's own verdicts: `error` → `FAIL`, `warn` → `WARN`.
443
549
 
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Prove, before an agent starts work, that the credentials it needs resolve.
3
+ *
4
+ * The contract has always claimed this. `secrets.require` is documented as an
5
+ * assertion — "a listed name that does not resolve is a startup error, not a
6
+ * late surprise" — and that sentence is the literal text of an error message in
7
+ * `doctor-secrets.mjs`. But nothing ran it at startup. The only caller of
8
+ * either presence check was a bash line inside `lisa-doctor`'s SKILL.md, which
9
+ * an agent had to read and choose to execute. The guarantee was prose; the
10
+ * plumbing to deliver it did not exist.
11
+ *
12
+ * This module is the missing caller, and it answers the question the gate
13
+ * actually asks. `AGENTS.md` requires intake to establish that the factory has
14
+ * the tooling "*and provable access to that tooling*" before accepting work. A
15
+ * credential that resolves nowhere is precisely a failure of provable access,
16
+ * and discovering it forty minutes into a build — after a ticket is claimed and
17
+ * a branch is cut — converts a clean refusal into abandoned half-work.
18
+ *
19
+ * **Three verdicts, and the third is the point.** `ok` and `missing` are
20
+ * obvious. `unreachable` is what happens when the provider itself cannot be
21
+ * asked: no CLI installed, no bootstrap token, an API that errored. That state
22
+ * must never collapse into either neighbour. Folded into `ok` it is a vacuous
23
+ * green — the check reports clean precisely when it learned nothing, which is
24
+ * the failure mode that lets a suppressed error read as a measured zero. Folded
25
+ * into `missing` it blames the vault for a fault in the caller's access, and
26
+ * sends whoever reads it to grant a credential that was never absent.
27
+ *
28
+ * Both non-ok verdicts fail. They are distinguished in the *message*, not the
29
+ * exit code, because the remediation differs completely: `missing` means extend
30
+ * the grant in the vault, `unreachable` means fix this machine's access to it.
31
+ * @module preflight-secrets
32
+ */
33
+
34
+ import { execFileSync } from "node:child_process";
35
+ import { realpathSync } from "node:fs";
36
+ import { fileURLToPath } from "node:url";
37
+
38
+ import { fetchAll } from "./providers.mjs";
39
+ import { readConfig } from "./surfaces.mjs";
40
+ import { readMaterialized } from "./resolve-secret.mjs";
41
+ import {
42
+ routingFloorReasons,
43
+ SUBSTITUTE_SUBSTRATES,
44
+ } from "./routing-floor.mjs";
45
+
46
+ /** Verdicts this check can reach, worst last. */
47
+ export const VERDICTS = ["ok", "missing", "unreachable"];
48
+
49
+ /**
50
+ * The names that must resolve on this surface, and why each is required.
51
+ *
52
+ * The floor is *unioned* with `require` rather than checked against it. A
53
+ * project should not have to restate `GH_TOKEN` to be protected by a rule its
54
+ * own `tracker: "github"` already implies, and a required set that depends on
55
+ * someone having typed it is exactly the hand-maintained list this replaces.
56
+ *
57
+ * `require` keeps its second job untouched. It still narrows resolution — a
58
+ * name outside it cannot be fetched — and the floor deliberately does not feed
59
+ * that. Unioning the floor into `cfg.require` would switch narrowing *on* for
60
+ * every project that never opted into it, and every credential they resolve
61
+ * today that is not in the floor would start throwing.
62
+ * @param {object} cfg Resolved configuration.
63
+ * @returns {Array<{name: string, reasons: string[]}>} Required names, sorted.
64
+ */
65
+ export function requiredNames(cfg) {
66
+ const reasons = routingFloorReasons(cfg.routing ?? {});
67
+ const declared = cfg.require ?? [];
68
+ const names = [...new Set([...(cfg.requiredFloor ?? []), ...declared])];
69
+ return names
70
+ .sort((left, right) => left.localeCompare(right))
71
+ .map(name => ({ name, reasons: whyRequired(name, reasons, declared) }));
72
+ }
73
+
74
+ /**
75
+ * Why one name is required, never empty.
76
+ *
77
+ * `report` renders this as "NAME — required because <reasons>", so an empty
78
+ * list prints a sentence that stops mid-clause and tells the operator nothing
79
+ * about which line of config to change. A name can reach the required set
80
+ * through the floor without a routing reason — anything that hands `preflight`
81
+ * a `requiredFloor` it did not derive from `routing` does exactly that — so the
82
+ * renderer needs a cause it can always print.
83
+ * @param {string} name The credential name.
84
+ * @param {Record<string, string[]>} reasons Routing-derived causes.
85
+ * @param {string[]} declared Names listed in `secrets.require`.
86
+ * @returns {string[]} At least one reason.
87
+ */
88
+ function whyRequired(name, reasons, declared) {
89
+ const routingReasons = Object.hasOwn(reasons, name) ? reasons[name] : [];
90
+ const causes = [
91
+ ...(routingReasons ?? []),
92
+ ...(declared.includes(name) ? ["declared in secrets.require"] : []),
93
+ ];
94
+ return causes.length
95
+ ? causes
96
+ : ["it is in this project's resolved secrets floor"];
97
+ }
98
+
99
+ /**
100
+ * Check every required credential against the resolution ladder.
101
+ *
102
+ * Values are never returned, printed, or written anywhere by this function. It
103
+ * reports names and whether each resolved, which is all a readiness check needs
104
+ * and the only shape safe to surface into an agent's context.
105
+ * @param {object} [cfg] Resolved configuration.
106
+ * @param {Function} [fetch] Provider view factory, injected for tests.
107
+ * @param {Function} [materialized] Materialized view factory, for tests.
108
+ * @param {Record<string, string|undefined>} [env] Environment to inspect.
109
+ * @param {Function} [probe] Substrate probe runner, injected for tests.
110
+ * @returns {{verdict: string, required: Array<{name: string, reasons: string[]}>, missing: Array<{name: string, reasons: string[]}>, reason: string|null}}
111
+ */
112
+ export function preflight(
113
+ cfg = readConfig(),
114
+ fetch = fetchAll,
115
+ materialized = readMaterialized,
116
+ env = process.env,
117
+ probe = runProbe
118
+ ) {
119
+ const required = requiredNames(cfg);
120
+ if (!required.length) {
121
+ return { verdict: "ok", required, missing: [], reason: null };
122
+ }
123
+
124
+ const file = safely(() => materialized(cfg)) ?? new Map();
125
+
126
+ // Env and the materialized file are consulted before the provider, and a
127
+ // required name satisfied by either needs no provider call at all. A session
128
+ // whose credentials are already exported is the common case on CI, and paying
129
+ // a network round-trip to confirm what is already in the environment would
130
+ // make the cheapest surface the slowest.
131
+ const unresolvedLocally = required.filter(
132
+ ({ name }) =>
133
+ !present(env[name]) && !file.get(name) && !substrateSatisfies(name, probe)
134
+ );
135
+ if (!unresolvedLocally.length) {
136
+ return { verdict: "ok", required, missing: [], reason: null };
137
+ }
138
+
139
+ let provider;
140
+ try {
141
+ provider = fetch(cfg);
142
+ } catch (err) {
143
+ return {
144
+ verdict: "unreachable",
145
+ required,
146
+ missing: unresolvedLocally,
147
+ reason: err.message,
148
+ };
149
+ }
150
+
151
+ const missing = unresolvedLocally.filter(
152
+ ({ name }) => !present(provider.get(name)?.value)
153
+ );
154
+ return {
155
+ verdict: missing.length ? "missing" : "ok",
156
+ required,
157
+ missing,
158
+ reason: null,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Render a verdict for whoever has to act on it.
164
+ *
165
+ * Written for a non-technical operator standing at the gate, per `AGENTS.md`:
166
+ * everything crossing a gate outward must be readable by the person being asked
167
+ * to decide. So each line names the credential, why it is required, and which
168
+ * of the two remedies applies.
169
+ * @param {object} result A {@link preflight} result.
170
+ * @param {object} cfg Resolved configuration.
171
+ * @returns {string} Operator-readable report, empty when nothing needs saying.
172
+ */
173
+ export function report(result, cfg) {
174
+ if (result.verdict === "ok") return "";
175
+
176
+ const lines = [];
177
+ if (result.verdict === "unreachable") {
178
+ lines.push(
179
+ `Secrets preflight could NOT be completed on surface "${cfg.surface}".`,
180
+ `The "${cfg.provider}" provider could not be reached, so nothing is`,
181
+ `known about any credential — this is not a report that they are fine.`,
182
+ ``,
183
+ ` reason: ${result.reason}`,
184
+ ``,
185
+ `Fix this machine's access to the provider (bootstrap token, CLI`,
186
+ `install, network), then start a new session. Do not treat the`,
187
+ `credentials below as verified:`
188
+ );
189
+ } else {
190
+ lines.push(
191
+ `Secrets preflight FAILED on surface "${cfg.surface}".`,
192
+ `These credentials are required and resolve nowhere — not in the`,
193
+ `environment, not materialized, not in the "${cfg.provider}" grant.`,
194
+ `Extend the grant in the vault (or correct the routing that requires`,
195
+ `them), then start a new session:`
196
+ );
197
+ }
198
+ lines.push(``);
199
+ for (const { name, reasons } of result.missing) {
200
+ lines.push(` ${name} — required because ${reasons.join("; ")}`);
201
+ }
202
+ lines.push(
203
+ ``,
204
+ `Work needing one of these cannot be completed. Route the item to`,
205
+ `blocked with this reason rather than claiming it and stopping partway.`
206
+ );
207
+ return lines.join("\n");
208
+ }
209
+
210
+ /**
211
+ * Whether an alternative substrate already provides this capability.
212
+ * @param {string} name Required credential name.
213
+ * @param {Function} probe Probe runner, injected for tests.
214
+ * @returns {boolean} True when the substrate answered successfully.
215
+ */
216
+ function substrateSatisfies(name, probe) {
217
+ const substrate = SUBSTITUTE_SUBSTRATES[name];
218
+ return substrate ? probe(substrate) : false;
219
+ }
220
+
221
+ /**
222
+ * How long a substrate probe may take before it counts as no answer.
223
+ *
224
+ * `gh auth status` contacts GitHub, and this runs on the session-start path.
225
+ * Unbounded, a hung connection holds the session open for as long as the child
226
+ * lives — the `catch` cannot help, because it is only reached once the call
227
+ * returns. Five seconds is far beyond a healthy round trip and far below the
228
+ * point where a person concludes the agent is broken.
229
+ */
230
+ const PROBE_TIMEOUT_MS = 5000;
231
+
232
+ /**
233
+ * Run a substrate probe, treating any failure as "did not satisfy".
234
+ *
235
+ * Output is discarded rather than captured. The probe's job is to answer a
236
+ * yes/no question with its exit status, and a command that can print a
237
+ * credential is one whose stdout should never enter this process.
238
+ *
239
+ * A timeout throws, which the catch maps to false — the fail-closed answer. The
240
+ * credential is then checked against the environment, the materialized file and
241
+ * the provider as if no substrate existed, so a slow network costs a stricter
242
+ * check rather than a wrong verdict.
243
+ *
244
+ * Exported so the bound itself can be proved against a real slow command. A
245
+ * timeout that only exists as an option object is a claim; one that has been
246
+ * watched cut a hanging child off is a control.
247
+ * @param {{command: string, args: string[]}} substrate Probe definition.
248
+ * @returns {boolean} True when the command exited zero.
249
+ */
250
+ export function runProbe(substrate) {
251
+ try {
252
+ execFileSync(substrate.command, substrate.args, {
253
+ stdio: "ignore",
254
+ timeout: PROBE_TIMEOUT_MS,
255
+ });
256
+ return true;
257
+ } catch {
258
+ return false;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Whether a resolved value counts as present.
264
+ * @param {unknown} value Candidate value.
265
+ * @returns {boolean} True when it is a non-blank string.
266
+ */
267
+ function present(value) {
268
+ return typeof value === "string" && value.trim().length > 0;
269
+ }
270
+
271
+ /**
272
+ * Run a thunk, treating any throw as "no view available".
273
+ *
274
+ * The materialized file is optional on every surface and absent on most, so a
275
+ * failure to read it is not a preflight failure — the provider is still
276
+ * authoritative. Only the provider's own unavailability is a verdict.
277
+ * @param {Function} thunk Work to attempt.
278
+ * @returns {*} The result, or undefined when it threw.
279
+ */
280
+ function safely(thunk) {
281
+ try {
282
+ return thunk();
283
+ } catch {
284
+ return undefined;
285
+ }
286
+ }
287
+
288
+ /**
289
+ * CLI entry point. Prints the report and exits non-zero on any failure.
290
+ */
291
+ function main() {
292
+ const cfg = readConfig();
293
+ const result = preflight(cfg);
294
+ const text = report(result, cfg);
295
+ if (text) console.error(text);
296
+ if (result.verdict !== "ok") process.exit(1);
297
+ }
298
+
299
+ /**
300
+ * Whether this module is the entry point node was asked to run.
301
+ *
302
+ * Both sides are realpath'd rather than compared as text: `import.meta.url` is
303
+ * the resolved path while `process.argv[1]` is whatever the caller typed, so a
304
+ * symlinked path — every git worktree, and every `/tmp` path on macOS — makes a
305
+ * raw comparison false. The module then loads, runs nothing, and exits 0, which
306
+ * is a readiness check reporting clean because it never ran. Same rule and same
307
+ * reasoning as `scripts/lib/invoked-as-script.mjs`, written out here because a
308
+ * plugin payload has no `./lib/` to import from.
309
+ * @param {string} moduleUrl This module's own `import.meta.url`.
310
+ * @param {string} [argv1] Entry path; defaults to `process.argv[1]`.
311
+ * @returns {boolean} Whether the CLI body should run.
312
+ */
313
+ function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
314
+ if (!argv1) return false;
315
+ try {
316
+ return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
317
+ } catch {
318
+ return false;
319
+ }
320
+ }
321
+
322
+ if (invokedAsScript(import.meta.url)) {
323
+ main();
324
+ }