@codyswann/lisa 3.4.0 → 3.5.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 (206) hide show
  1. package/README.md +22 -0
  2. package/all/copy-contents/gitignore +7 -0
  3. package/all/copy-overwrite/scripts/lisa-command-envelope.mjs +44 -4
  4. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +31 -0
  5. package/all/copy-overwrite/scripts/lisa-schema-validate.mjs +125 -47
  6. package/all/copy-overwrite/scripts/lisa-work-item.mjs +24 -2
  7. package/cdk/merge/.oxlintrc.json +1 -1
  8. package/dist/cli/apply.d.ts.map +1 -1
  9. package/dist/cli/apply.js +48 -13
  10. package/dist/cli/apply.js.map +1 -1
  11. package/dist/cli/doctor-apply-freshness.d.ts +29 -0
  12. package/dist/cli/doctor-apply-freshness.d.ts.map +1 -0
  13. package/dist/cli/doctor-apply-freshness.js +194 -0
  14. package/dist/cli/doctor-apply-freshness.js.map +1 -0
  15. package/dist/cli/doctor-learnings-merge-driver.d.ts +23 -0
  16. package/dist/cli/doctor-learnings-merge-driver.d.ts.map +1 -0
  17. package/dist/cli/doctor-learnings-merge-driver.js +73 -0
  18. package/dist/cli/doctor-learnings-merge-driver.js.map +1 -0
  19. package/dist/cli/doctor-readiness-workflows.js +2 -2
  20. package/dist/cli/doctor-readiness-workflows.js.map +1 -1
  21. package/dist/cli/doctor.d.ts.map +1 -1
  22. package/dist/cli/doctor.js +13 -0
  23. package/dist/cli/doctor.js.map +1 -1
  24. package/dist/cli/ui-ci-quality-jobs-parse.js +2 -2
  25. package/dist/cli/ui-ci-quality-jobs-parse.js.map +1 -1
  26. package/dist/cli/ui-deploy-pipeline-model.d.ts +4 -0
  27. package/dist/cli/ui-deploy-pipeline-model.d.ts.map +1 -1
  28. package/dist/cli/ui-deploy-pipeline-model.js +2 -2
  29. package/dist/cli/ui-deploy-pipeline-model.js.map +1 -1
  30. package/dist/codex/agent-transformer.js +2 -2
  31. package/dist/codex/agent-transformer.js.map +1 -1
  32. package/dist/codex/command-skill-transformer.js +3 -3
  33. package/dist/codex/command-skill-transformer.js.map +1 -1
  34. package/dist/codex/scripts/block-no-verify.sh +53 -4
  35. package/dist/core/apply-receipt.d.ts +68 -0
  36. package/dist/core/apply-receipt.d.ts.map +1 -0
  37. package/dist/core/apply-receipt.js +107 -0
  38. package/dist/core/apply-receipt.js.map +1 -0
  39. package/dist/core/learnings-merge-driver-install.d.ts +34 -0
  40. package/dist/core/learnings-merge-driver-install.d.ts.map +1 -1
  41. package/dist/core/learnings-merge-driver-install.js +36 -0
  42. package/dist/core/learnings-merge-driver-install.js.map +1 -1
  43. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  44. package/dist/core/upstream-evidence-manifest.js +62 -39
  45. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  46. package/dist/migrations/ensure-lisa-postinstall.d.ts +33 -0
  47. package/dist/migrations/ensure-lisa-postinstall.d.ts.map +1 -1
  48. package/dist/migrations/ensure-lisa-postinstall.js +47 -5
  49. package/dist/migrations/ensure-lisa-postinstall.js.map +1 -1
  50. package/dist/migrations/ensure-oxlint-base-configs.d.ts +42 -0
  51. package/dist/migrations/ensure-oxlint-base-configs.d.ts.map +1 -0
  52. package/dist/migrations/ensure-oxlint-base-configs.js +212 -0
  53. package/dist/migrations/ensure-oxlint-base-configs.js.map +1 -0
  54. package/dist/migrations/index.d.ts +1 -0
  55. package/dist/migrations/index.d.ts.map +1 -1
  56. package/dist/migrations/index.js +3 -0
  57. package/dist/migrations/index.js.map +1 -1
  58. package/dist/opencode/command-transformer.js +3 -3
  59. package/dist/opencode/command-transformer.js.map +1 -1
  60. package/dist/utils/yaml.d.ts +54 -0
  61. package/dist/utils/yaml.d.ts.map +1 -0
  62. package/dist/utils/yaml.js +124 -0
  63. package/dist/utils/yaml.js.map +1 -0
  64. package/expo/copy-overwrite/scripts/bdd/baseline.mjs +104 -19
  65. package/expo/copy-overwrite/scripts/bdd/contract.mjs +20 -1
  66. package/expo/copy-overwrite/scripts/bdd/discover.mjs +53 -10
  67. package/expo/copy-overwrite/scripts/bdd/parse.mjs +25 -3
  68. package/expo/copy-overwrite/scripts/bdd/render.mjs +2 -1
  69. package/expo/copy-overwrite/scripts/bdd/report.mjs +26 -12
  70. package/expo/copy-overwrite/scripts/bdd/validate.mjs +2 -2
  71. package/expo/copy-overwrite/scripts/bdd/waivers.mjs +19 -4
  72. package/expo/copy-overwrite/scripts/bdd-matrix.mjs +2 -2
  73. package/expo/copy-overwrite/scripts/check-bdd-coverage.mjs +58 -16
  74. package/expo/create-only/.github/required-checks.json +10 -4
  75. package/expo/merge/.oxlintrc.json +1 -1
  76. package/harper-fabric/merge/.oxlintrc.json +1 -1
  77. package/nestjs/create-only/.github/required-checks.json +57 -0
  78. package/nestjs/merge/.oxlintrc.json +1 -1
  79. package/package.json +1 -1
  80. package/phaser/merge/.oxlintrc.json +1 -1
  81. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  83. package/plugins/lisa/.codex-plugin/skills/lisa-atlassian-access/SKILL.md +7 -0
  84. package/plugins/lisa/.codex-plugin/skills/lisa-implement/SKILL.md +1 -1
  85. package/plugins/lisa/.codex-plugin/skills/lisa-jam-access/SKILL.md +37 -2
  86. package/plugins/lisa/.codex-plugin/skills/lisa-notion-access/SKILL.md +7 -0
  87. package/plugins/lisa/.codex-plugin/skills/lisa-posthog-access/SKILL.md +40 -3
  88. package/plugins/lisa/.codex-plugin/skills/lisa-sentry-access/SKILL.md +44 -3
  89. package/plugins/lisa/.codex-plugin/skills/lisa-sonarcloud-access/SKILL.md +56 -0
  90. package/plugins/lisa/.codex-plugin/skills/lisa-track/SKILL.md +1 -1
  91. package/plugins/lisa/hooks/block-no-verify.agy.sh +53 -4
  92. package/plugins/lisa/hooks/block-no-verify.sh +31 -0
  93. package/plugins/lisa/rules/eager/credential-substrate-precedence.md +3 -1
  94. package/plugins/lisa/rules/eager/tracked-work.md +1 -1
  95. package/plugins/lisa/rules/reference/credential-substrate-precedence.md +20 -0
  96. package/plugins/lisa/rules/reference/tracked-work.md +4 -2
  97. package/plugins/lisa/skills/lisa-atlassian-access/SKILL.md +7 -0
  98. package/plugins/lisa/skills/lisa-implement/SKILL.md +1 -1
  99. package/plugins/lisa/skills/lisa-jam-access/SKILL.md +37 -2
  100. package/plugins/lisa/skills/lisa-notion-access/SKILL.md +7 -0
  101. package/plugins/lisa/skills/lisa-posthog-access/SKILL.md +40 -3
  102. package/plugins/lisa/skills/lisa-sentry-access/SKILL.md +44 -3
  103. package/plugins/lisa/skills/lisa-sonarcloud-access/SKILL.md +56 -0
  104. package/plugins/lisa/skills/lisa-track/SKILL.md +1 -1
  105. package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +53 -4
  106. package/plugins/lisa-agy/plugin.json +1 -1
  107. package/plugins/lisa-agy/skills/lisa-atlassian-access/SKILL.md +7 -0
  108. package/plugins/lisa-agy/skills/lisa-implement/SKILL.md +1 -1
  109. package/plugins/lisa-agy/skills/lisa-jam-access/SKILL.md +37 -2
  110. package/plugins/lisa-agy/skills/lisa-notion-access/SKILL.md +7 -0
  111. package/plugins/lisa-agy/skills/lisa-posthog-access/SKILL.md +40 -3
  112. package/plugins/lisa-agy/skills/lisa-sentry-access/SKILL.md +44 -3
  113. package/plugins/lisa-agy/skills/lisa-sonarcloud-access/SKILL.md +56 -0
  114. package/plugins/lisa-agy/skills/lisa-track/SKILL.md +1 -1
  115. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  116. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  117. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  118. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  119. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  120. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  121. package/plugins/lisa-copilot/hooks/block-no-verify.sh +31 -0
  122. package/plugins/lisa-copilot/rules/eager/credential-substrate-precedence.md +3 -1
  123. package/plugins/lisa-copilot/rules/eager/tracked-work.md +1 -1
  124. package/plugins/lisa-copilot/rules/reference/credential-substrate-precedence.md +20 -0
  125. package/plugins/lisa-copilot/rules/reference/tracked-work.md +4 -2
  126. package/plugins/lisa-copilot/skills/lisa-atlassian-access/SKILL.md +7 -0
  127. package/plugins/lisa-copilot/skills/lisa-implement/SKILL.md +1 -1
  128. package/plugins/lisa-copilot/skills/lisa-jam-access/SKILL.md +37 -2
  129. package/plugins/lisa-copilot/skills/lisa-notion-access/SKILL.md +7 -0
  130. package/plugins/lisa-copilot/skills/lisa-posthog-access/SKILL.md +40 -3
  131. package/plugins/lisa-copilot/skills/lisa-sentry-access/SKILL.md +44 -3
  132. package/plugins/lisa-copilot/skills/lisa-sonarcloud-access/SKILL.md +56 -0
  133. package/plugins/lisa-copilot/skills/lisa-track/SKILL.md +1 -1
  134. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  135. package/plugins/lisa-cursor/hooks/block-no-verify.sh +31 -0
  136. package/plugins/lisa-cursor/rules/credential-substrate-precedence-reference.mdc +20 -0
  137. package/plugins/lisa-cursor/rules/credential-substrate-precedence.mdc +3 -1
  138. package/plugins/lisa-cursor/rules/tracked-work-reference.mdc +4 -2
  139. package/plugins/lisa-cursor/rules/tracked-work.mdc +1 -1
  140. package/plugins/lisa-cursor/skills/lisa-atlassian-access/SKILL.md +7 -0
  141. package/plugins/lisa-cursor/skills/lisa-implement/SKILL.md +1 -1
  142. package/plugins/lisa-cursor/skills/lisa-jam-access/SKILL.md +37 -2
  143. package/plugins/lisa-cursor/skills/lisa-notion-access/SKILL.md +7 -0
  144. package/plugins/lisa-cursor/skills/lisa-posthog-access/SKILL.md +40 -3
  145. package/plugins/lisa-cursor/skills/lisa-sentry-access/SKILL.md +44 -3
  146. package/plugins/lisa-cursor/skills/lisa-sonarcloud-access/SKILL.md +56 -0
  147. package/plugins/lisa-cursor/skills/lisa-track/SKILL.md +1 -1
  148. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  149. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  150. package/plugins/lisa-expo-agy/plugin.json +1 -1
  151. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  152. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  153. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  154. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  155. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  156. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  157. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  158. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  159. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  160. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  161. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  162. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  163. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  164. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  165. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  166. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  167. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  168. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  169. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  170. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  171. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  172. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  173. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  174. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  175. package/plugins/lisa-rails-agy/plugin.json +1 -1
  176. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  177. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  178. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  179. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  180. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  181. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  182. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  183. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  184. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  185. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  186. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  187. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  188. package/plugins/src/base/hooks/block-no-verify.agy.sh +53 -4
  189. package/plugins/src/base/hooks/block-no-verify.sh +31 -0
  190. package/plugins/src/base/rules/eager/credential-substrate-precedence.md +3 -1
  191. package/plugins/src/base/rules/eager/tracked-work.md +1 -1
  192. package/plugins/src/base/rules/reference/credential-substrate-precedence.md +20 -0
  193. package/plugins/src/base/rules/reference/tracked-work.md +4 -2
  194. package/plugins/src/base/skills/lisa-atlassian-access/SKILL.md +7 -0
  195. package/plugins/src/base/skills/lisa-implement/SKILL.md +1 -1
  196. package/plugins/src/base/skills/lisa-jam-access/SKILL.md +37 -2
  197. package/plugins/src/base/skills/lisa-notion-access/SKILL.md +7 -0
  198. package/plugins/src/base/skills/lisa-posthog-access/SKILL.md +40 -3
  199. package/plugins/src/base/skills/lisa-sentry-access/SKILL.md +44 -3
  200. package/plugins/src/base/skills/lisa-sonarcloud-access/SKILL.md +56 -0
  201. package/plugins/src/base/skills/lisa-track/SKILL.md +1 -1
  202. package/scripts/install-claude-plugins.sh +5 -1
  203. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +298 -26
  204. package/typescript/create-only/.github/required-checks.json +11 -6
  205. package/typescript/create-only/.github/workflows/required-checks-drift.yml +84 -0
  206. package/typescript/merge/.oxlintrc.json +1 -1
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import {
20
20
  REPORT_SCHEMA_VERSION,
21
+ byCodeUnit,
21
22
  runnersByPlatform,
22
23
  trackerUrl,
23
24
  } from "./contract.mjs";
@@ -73,19 +74,24 @@ function buildTestInventory(discovery, contract) {
73
74
  * required here" and "everything required here is covered" are different
74
75
  * claims, and printing the second for the first is how a platform with no
75
76
  * obligations comes to look fully covered.
77
+ *
78
+ * `percentage` is ROUNDED FOR DISPLAY and `exact` is not. Only `exact` is ever
79
+ * compared against a floor: 2000 of 2001 obligations is 99.95002%, which
80
+ * rounds to 100.0, and a genuinely-below-floor platform reporting `ok: true`
81
+ * because of a display convention is the same class of false headline this
82
+ * whole report is built to prevent.
76
83
  * @param {readonly object[]} subset - Obligations to count.
77
84
  * @param {ReadonlySet<string>} coveredKeys - Keys with an aligned mapping.
78
- * @returns {object} Covered/total/percentage.
85
+ * @returns {object} Covered/total/percentage/exact.
79
86
  */
80
87
  function summarize(subset, coveredKeys) {
81
88
  const covered = subset.filter(item => coveredKeys.has(item.key)).length;
89
+ const exact = subset.length === 0 ? null : (covered / subset.length) * 100;
82
90
  return {
83
91
  covered,
84
92
  total: subset.length,
85
- percentage:
86
- subset.length === 0
87
- ? null
88
- : Number(((covered / subset.length) * 100).toFixed(1)),
93
+ percentage: exact === null ? null : Number(exact.toFixed(1)),
94
+ exact,
89
95
  };
90
96
  }
91
97
 
@@ -212,7 +218,7 @@ export function buildExecution(mappings, runs) {
212
218
  notRunTests: outcomes
213
219
  .filter(item => item.result === null)
214
220
  .map(item => `${item.runner} ${item.file} :: ${item.evidence}`)
215
- .sort(),
221
+ .sort(byCodeUnit),
216
222
  };
217
223
  }
218
224
 
@@ -268,18 +274,23 @@ function classifyFloor(declared) {
268
274
  */
269
275
  export function evaluateFloor(contract, byPlatform, platforms) {
270
276
  const floors = contract.coverageFloor ?? {};
271
- const entries = [...platforms].sort().map(platform => {
277
+ const entries = [...platforms].sort(byCodeUnit).map(platform => {
272
278
  const declared = classifyFloor(floors[platform]);
273
279
  // A platform with no obligations reports `null`, not 100. It cannot clear
274
280
  // a positive floor by having nothing to measure.
275
281
  const actual = byPlatform[platform]?.percentage ?? null;
282
+ // The UNROUNDED value decides. `actual` above is the display figure and is
283
+ // kept only so the report and the operator message agree with the rest of
284
+ // the traceability block.
285
+ const exact = byPlatform[platform]?.exact ?? null;
276
286
  return [
277
287
  platform,
278
288
  {
279
289
  floor: declared.value,
280
290
  state: declared.state,
281
291
  actual,
282
- ok: isFloorMet(declared, actual),
292
+ exact,
293
+ ok: isFloorMet(declared, exact),
283
294
  },
284
295
  ];
285
296
  });
@@ -302,7 +313,7 @@ export function evaluateFloor(contract, byPlatform, platforms) {
302
313
  * disable enforcement. An unset floor has nothing to clear here; the enforced
303
314
  * mode reports its absence separately.
304
315
  * @param {{state: string, value: number|null}} declared - The classified floor.
305
- * @param {number|null} actual - Measured percentage, or null when there are no obligations.
316
+ * @param {number|null} actual - Measured UNROUNDED percentage, or null when there are no obligations.
306
317
  * @returns {boolean} Whether the floor is satisfied.
307
318
  */
308
319
  function isFloorMet(declared, actual) {
@@ -338,7 +349,10 @@ export function buildTrackerIndex(scenarios, trackers) {
338
349
  scenario => scenario.trackers.length === 0
339
350
  ).length,
340
351
  tags: [...tags.values()]
341
- .map(entry => ({ ...entry, scenarios: [...entry.scenarios].sort() }))
352
+ .map(entry => ({
353
+ ...entry,
354
+ scenarios: [...entry.scenarios].sort(byCodeUnit),
355
+ }))
342
356
  .sort((a, b) => a.tag.localeCompare(b.tag)),
343
357
  };
344
358
  }
@@ -366,7 +380,7 @@ export function buildReport({
366
380
  const obligations = declared.filter(item => !waivedKeys.has(item.key));
367
381
  const coveredKeys = coverageKeys(scenarios, contract, unresolved);
368
382
  const byPlatform = Object.fromEntries(
369
- [...platforms].sort().map(platform => [
383
+ [...platforms].sort(byCodeUnit).map(platform => [
370
384
  platform,
371
385
  summarize(
372
386
  obligations.filter(item => item.platform === platform),
@@ -460,7 +474,7 @@ function countScenarios(scenarios) {
460
474
  function byRunnerSummary(contract, obligations, coveredKeys) {
461
475
  return Object.fromEntries(
462
476
  Object.keys(contract.runnerPlatforms ?? {})
463
- .sort()
477
+ .sort(byCodeUnit)
464
478
  .map(runner => [
465
479
  runner,
466
480
  summarize(
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import * as fs from "node:fs";
11
11
 
12
- import { runnersByPlatform } from "./contract.mjs";
12
+ import { byCodeUnit, runnersByPlatform } from "./contract.mjs";
13
13
  import { resolveInsideRepo } from "./parse.mjs";
14
14
 
15
15
  /**
@@ -43,7 +43,7 @@ export function validateScenarios(scenarios, platforms) {
43
43
  defects.push(
44
44
  defect(
45
45
  "scenario-platform",
46
- `${at} declares no platform; expected one of ${[...platforms].sort().join(", ") || "(none configured)"}`
46
+ `${at} declares no platform; expected one of ${[...platforms].sort(byCodeUnit).join(", ") || "(none configured)"}`
47
47
  )
48
48
  );
49
49
  }
@@ -22,10 +22,16 @@ const REQUIRED_FIELDS = [
22
22
  ];
23
23
 
24
24
  /** ISO calendar date, the only accepted date shape. */
25
- const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
25
+ export const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
26
26
 
27
27
  /**
28
28
  * Validate every waiver in the coverage map.
29
+ *
30
+ * The evaluation date is validated FIRST and reported as a defect of its own.
31
+ * Expiry is a lexical `<` comparison against an ISO date, and every comparison
32
+ * with a non-date string is false — so an unusable `BDD_TODAY` did not merely
33
+ * skip the expiry check, it silently passed EVERY expired waiver in the repo.
34
+ * A date the gate cannot read is a finding, never a free pass.
29
35
  * @param {object} input - Scenarios, contract, and the evaluation date.
30
36
  * @returns {object[]} Defects found.
31
37
  */
@@ -34,7 +40,15 @@ export function validateWaivers({ scenarios, contract, today }) {
34
40
  const platformRunners = runnersByPlatform(contract.runnerPlatforms);
35
41
  const mapped = mappedKeys(contract);
36
42
  const seen = new Set();
37
- const defects = [];
43
+ const usableToday = typeof today === "string" && ISO_DATE.test(today);
44
+ const defects = usableToday
45
+ ? []
46
+ : [
47
+ defect(
48
+ "waiver-metadata",
49
+ `the evaluation date ${JSON.stringify(today ?? null)} is not an ISO date (YYYY-MM-DD), so no waiver expiry could be evaluated. Set BDD_TODAY to a calendar date or leave it unset.`
50
+ ),
51
+ ];
38
52
  for (const [index, waiver] of (contract.platformWaivers ?? []).entries()) {
39
53
  const at = `coverage-map.platformWaivers[${index}] ${waiver.scenario ?? "(no scenario)"}`;
40
54
  const scenario = byId.get(waiver.scenario);
@@ -43,7 +57,7 @@ export function validateWaivers({ scenarios, contract, today }) {
43
57
  defects.push(blocking);
44
58
  continue;
45
59
  }
46
- defects.push(...metadataDefects(waiver, at, today));
60
+ defects.push(...metadataDefects(waiver, at, usableToday ? today : null));
47
61
  defects.push(...runnerDefects(waiver, platformRunners, at));
48
62
  defects.push(...platformDefects(waiver, scenario, { mapped, seen }, at));
49
63
  }
@@ -96,7 +110,7 @@ function blockingError(waiver, scenario, at) {
96
110
  * waiver — the time-box is what stops an IOU from becoming permanent.
97
111
  * @param {object} waiver - Raw waiver entry.
98
112
  * @param {string} at - Location label.
99
- * @param {string} today - ISO date the run is evaluated against.
113
+ * @param {string|null} today - ISO date the run is evaluated against, or null when unusable.
100
114
  * @returns {object[]} Defects found.
101
115
  */
102
116
  function metadataDefects(waiver, at, today) {
@@ -125,6 +139,7 @@ function metadataDefects(waiver, at, today) {
125
139
  );
126
140
  }
127
141
  if (
142
+ today !== null &&
128
143
  waiver.expiresAt &&
129
144
  ISO_DATE.test(waiver.expiresAt) &&
130
145
  waiver.expiresAt < today
@@ -20,7 +20,7 @@ import * as fs from "node:fs";
20
20
  import * as path from "node:path";
21
21
  import { fileURLToPath, pathToFileURL } from "node:url";
22
22
 
23
- import { declaredPlatforms, trackerUrl } from "./bdd/contract.mjs";
23
+ import { byCodeUnit, declaredPlatforms, trackerUrl } from "./bdd/contract.mjs";
24
24
  import { loadScenarios } from "./bdd/parse.mjs";
25
25
  import { indexResults } from "./bdd/report.mjs";
26
26
  import { loadExecutionResults } from "./check-bdd-coverage.mjs";
@@ -128,7 +128,7 @@ export function renderMatrix(root, resultFiles) {
128
128
  const results = indexResults(execution.runs);
129
129
  const byScenario = mappingsByScenario(contract);
130
130
  const waived = waivedKeys(contract);
131
- const runners = Object.keys(contract.runnerPlatforms ?? {}).sort();
131
+ const runners = Object.keys(contract.runnerPlatforms ?? {}).sort(byCodeUnit);
132
132
  const byFeature = new Map();
133
133
  for (const scenario of scenarios) {
134
134
  byFeature.set(scenario.feature, [
@@ -77,7 +77,7 @@ import {
77
77
  validateScenarios,
78
78
  validateTrackerTags,
79
79
  } from "./bdd/validate.mjs";
80
- import { validateWaivers } from "./bdd/waivers.mjs";
80
+ import { ISO_DATE, validateWaivers } from "./bdd/waivers.mjs";
81
81
 
82
82
  const PACKAGE_ROOT = path.resolve(
83
83
  path.dirname(fileURLToPath(import.meta.url)),
@@ -178,6 +178,17 @@ export function validateAdoption(contract, mode, today) {
178
178
  */
179
179
  function bootstrapDefects(adoption, today) {
180
180
  const defects = [];
181
+ if (!ISO_DATE.test(String(today))) {
182
+ // The expiry is a lexical comparison against an ISO date, and every
183
+ // comparison with a non-date is false — so an unreadable evaluation date
184
+ // would make the time-box unreachable and bootstrap permanent.
185
+ defects.push(
186
+ defect(
187
+ "bootstrap-metadata",
188
+ `the evaluation date ${JSON.stringify(today ?? null)} is not an ISO date (YYYY-MM-DD), so the bootstrap expiry could not be evaluated`
189
+ )
190
+ );
191
+ }
181
192
  if (!adoption.owner) {
182
193
  defects.push(
183
194
  defect(
@@ -193,14 +204,14 @@ function bootstrapDefects(adoption, today) {
193
204
  "bootstrap requires adoption.expiresAt (an ISO date); a bootstrap with no time-box never ends"
194
205
  )
195
206
  );
196
- } else if (!/^\d{4}-\d{2}-\d{2}$/.test(adoption.expiresAt)) {
207
+ } else if (!ISO_DATE.test(adoption.expiresAt)) {
197
208
  defects.push(
198
209
  defect(
199
210
  "bootstrap-metadata",
200
211
  "adoption.expiresAt must be an ISO date (YYYY-MM-DD)"
201
212
  )
202
213
  );
203
- } else if (adoption.expiresAt < today) {
214
+ } else if (ISO_DATE.test(String(today)) && adoption.expiresAt < today) {
204
215
  defects.push(
205
216
  defect(
206
217
  "bootstrap-expired",
@@ -278,7 +289,7 @@ function validateAll({
278
289
  ...defects,
279
290
  defect(
280
291
  "baseline",
281
- `base revision ${options.baseSha} is not readable, so the non-regression checks could not run. A gate that cannot compare against a base does not get to report that nothing regressed.`
292
+ `base revision ${options.baseSha} is not readable${baseline.error ? ` (${baseline.error})` : ""}, so the non-regression checks could not run. A gate that cannot compare against a base does not get to report that nothing regressed.`
282
293
  ),
283
294
  ];
284
295
  }
@@ -343,16 +354,16 @@ function floorIntegrityDefects(report) {
343
354
 
344
355
  /**
345
356
  * Defects that only exist in enforced mode, where absence must fail.
346
- * @param {object} input - Contract, scenarios, report, and platforms.
357
+ *
358
+ * The platform vocabulary is deliberately NOT a parameter: every platform this
359
+ * function cares about already reaches it through `report.floor`, which was
360
+ * built from that same vocabulary. It used to be passed and discarded behind a
361
+ * `void`, which reads as "unused for now" and hides whether an intended check
362
+ * was ever written.
363
+ * @param {object} input - Contract, scenarios, report, and discovery.
347
364
  * @returns {object[]} Defects found.
348
365
  */
349
- function enforcedDefects({
350
- contract,
351
- scenarios,
352
- report,
353
- platforms,
354
- discovery,
355
- }) {
366
+ function enforcedDefects({ contract, scenarios, report, discovery }) {
356
367
  const defects = [];
357
368
  if (scenarios.length === 0) {
358
369
  defects.push(
@@ -392,15 +403,28 @@ function enforcedDefects({
392
403
  defects.push(
393
404
  defect(
394
405
  "floor-regression",
395
- `${platform} traceability coverage ${value.actual}% is below its committed floor of ${value.floor}%`
406
+ `${platform} traceability coverage ${measured(value)}% is below its committed floor of ${value.floor}%`
396
407
  )
397
408
  );
398
409
  }
399
410
  }
400
- void platforms;
401
411
  return defects;
402
412
  }
403
413
 
414
+ /**
415
+ * The measured percentage, printed at enough precision to explain the verdict.
416
+ *
417
+ * The floor is decided on the unrounded value, so a platform sitting at
418
+ * 99.95% must not be told it failed at "100%" — a message that contradicts
419
+ * its own finding is how an operator concludes the gate is broken.
420
+ * @param {{actual: number|null, exact: number|null}} value - A floor entry.
421
+ * @returns {string} The percentage to print.
422
+ */
423
+ function measured(value) {
424
+ if (value.exact === null) return String(value.actual);
425
+ return String(Number(value.exact.toFixed(4)));
426
+ }
427
+
404
428
  /**
405
429
  * Evaluate the gate.
406
430
  * @param {string} root - Repo root.
@@ -452,7 +476,7 @@ export function run(root, options) {
452
476
  discovery,
453
477
  }),
454
478
  ...(mode === "enforced"
455
- ? enforcedDefects({ contract, scenarios, report, platforms, discovery })
479
+ ? enforcedDefects({ contract, scenarios, report, discovery })
456
480
  : []),
457
481
  ];
458
482
  return result({ mode, defects, report, contract });
@@ -689,6 +713,14 @@ async function main() {
689
713
  * When the shared module is absent the same object is emitted unvalidated
690
714
  * rather than nothing: a gate that produces no result is indistinguishable
691
715
  * from one that passed.
716
+ *
717
+ * When the shared module is present but REFUSES the envelope — a schema this
718
+ * gate's fields no longer satisfy, or a half-copied `scripts/` directory whose
719
+ * schema document never arrived — that refusal is caught rather than thrown.
720
+ * An uncaught rejection here produced no envelope at all and a Node-supplied
721
+ * exit code, which is the one outcome this function exists to rule out. The
722
+ * fields are emitted with `status: "invalid"`, so the run is machine-readable,
723
+ * operator-readable, and NONZERO.
692
724
  * @param {object} input - The gate run, CLI options, and files written.
693
725
  * @returns {Promise<object>} The envelope.
694
726
  */
@@ -708,7 +740,17 @@ async function sealEnvelope({ gateRun, options, filesWritten }) {
708
740
  );
709
741
  return { schemaVersion: "lisa-command-envelope-v1", ...fields };
710
742
  }
711
- return shared.buildEnvelope(fields);
743
+ try {
744
+ return shared.buildEnvelope(fields);
745
+ } catch (error) {
746
+ console.error(`[bdd-coverage] ${error.message}`);
747
+ return {
748
+ schemaVersion: "lisa-command-envelope-v1",
749
+ ...fields,
750
+ status: "invalid",
751
+ reason: `the result could not be sealed into a valid command envelope: ${error.message}`,
752
+ };
753
+ }
712
754
  }
713
755
 
714
756
  /**
@@ -1,10 +1,15 @@
1
1
  {
2
2
  "_readme": [
3
3
  "See typescript/create-only/.github/required-checks.json in Lisa for the full rationale. In short: GitHub counts a SKIPPED required status check as SATISFIED, so a `skip_jobs` token that silences a required context makes the merge gate decorative.",
4
- "This expo seed declares the three tokens the shipped ci.yml skips. Every one of them is currently NOT ruleset-required, so nothing here is a false green but the declaration exists so that the day one of them BECOMES required, the guard says so instead of the gate quietly going hollow.",
5
- "Transcribe `required_contexts` byte for byte from your ruleset and keep it current with `--remote`.",
6
- "The nightly-gate context is listed because Lisa ships expo/github-rulesets/nightly-e2e-health.json requiring it on dev. No skip_jobs token silences it today, so nothing is a false green it is here so that the day one does, the guard says so instead of staying quiet."
4
+ "This seed declares the three tokens the shipped ci.yml skips, so that the day one of them is required the guard can say so instead of the gate quietly going hollow. The `ruleset_required: false` on each is a DEFAULT, not a measurement it is only meaningful once you have transcribed `required_contexts` from your own ruleset, which is what stamping `baseline_fetched_at` asserts.",
5
+ "⚠️ `required_contexts` SHIPS EMPTY AND UNSTAMPED ON PURPOSE. Lisa cannot know what YOUR ruleset requires, and an earlier version of this seed shipped a guess that was measured WRONG (#2476): it claimed `🔗 Work-Item Traceability` was required when no ruleset required it, and omitted six contexts that were. Until `ruleset.baseline_fetched_at` carries the date you transcribed the real list, the guard reports NOT CHECKED rather than answering from fiction.",
6
+ "To arm it: gh api repos/OWNER/NAME/rulesets --jq '.[] | \"\\\\(.id) \\\\(.name)\"' to find the ids, then gh api repos/OWNER/NAME/rulesets/ID --jq '.rules[] | select(.type==\"required_status_checks\") | .parameters.required_status_checks[].context' for the contexts. Paste them into `required_contexts` BYTE FOR BYTE, fill in `ruleset.repo` / `ruleset.ids`, and set `baseline_fetched_at` to today.",
7
+ "A transcription expires after 90 days, because a ruleset can be edited with no signal in this repository. The shipped `.github/workflows/required-checks-drift.yml` runs `--remote` weekly to catch that; `--remote` reads the ruleset live and so answers even when the cache is untrusted.",
8
+ "Lisa's quality.yml runs the offline arm on every pull request. This seed ships `\"enforcement\": \"warn\"`, which downgrades findings AND the NOT-CHECKED refusal to reports so a fresh install does not go red on arrival. Delete the key once you have transcribed the list — then it blocks.",
9
+ "`skip_jobs` is matched as an exact comma-delimited token and GitHub Actions expression syntax has no string-replace, so write the list with no spaces: `skip_jobs: 'a,b'`, never `skip_jobs: 'a, b'`. A spaced token matches nothing and the job runs — reported as `whitespace_in_skip_token`.",
10
+ "`_example_required_contexts` below is a STARTING POINT FOR TYPING, never read by the guard. Verify every line against your own ruleset before promoting any of it."
7
11
  ],
12
+ "enforcement": "warn",
8
13
  "ruleset": {
9
14
  "repo": "",
10
15
  "ids": [],
@@ -14,7 +19,8 @@
14
19
  ".github/workflows/ci.yml"
15
20
  ],
16
21
  "exemption_ticket_pattern": "^[A-Z][A-Z0-9]+-\\d+$",
17
- "required_contexts": [
22
+ "required_contexts": [],
23
+ "_example_required_contexts": [
18
24
  "🔍 Quality Checks / 🧹 Lint",
19
25
  "🔍 Quality Checks / 🔍 Type Check",
20
26
  "🔍 Quality Checks / 🏗️ Build",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "./node_modules/oxlint/configuration_schema.json",
3
- "extends": ["./node_modules/@codyswann/lisa/oxlint/expo.json"],
3
+ "extends": ["./.lisa/lisa-oxlint/expo.json"],
4
4
  "ignorePatterns": [
5
5
  "build/**",
6
6
  "dist/**",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "./node_modules/oxlint/configuration_schema.json",
3
- "extends": ["./node_modules/@codyswann/lisa/oxlint/harper-fabric.json"],
3
+ "extends": ["./.lisa/lisa-oxlint/harper-fabric.json"],
4
4
  "ignorePatterns": [
5
5
  "build/**",
6
6
  "dist/**",
@@ -0,0 +1,57 @@
1
+ {
2
+ "_readme": [
3
+ "See typescript/create-only/.github/required-checks.json in Lisa for the full rationale. In short: GitHub counts a SKIPPED required status check as SATISFIED, so a `skip_jobs` token that silences a required context makes the merge gate decorative.",
4
+ "This seed declares the three tokens the shipped ci.yml skips, so that the day one of them is required the guard can say so instead of the gate quietly going hollow. The `ruleset_required: false` on each is a DEFAULT, not a measurement — it is only meaningful once you have transcribed `required_contexts` from your own ruleset, which is what stamping `baseline_fetched_at` asserts.",
5
+ "⚠️ `required_contexts` SHIPS EMPTY AND UNSTAMPED ON PURPOSE. Lisa cannot know what YOUR ruleset requires, and an earlier version of this seed shipped a guess that was measured WRONG (#2476): it claimed `🔗 Work-Item Traceability` was required when no ruleset required it, and omitted six contexts that were. Until `ruleset.baseline_fetched_at` carries the date you transcribed the real list, the guard reports NOT CHECKED rather than answering from fiction.",
6
+ "To arm it: gh api repos/OWNER/NAME/rulesets --jq '.[] | \"\\\\(.id) \\\\(.name)\"' to find the ids, then gh api repos/OWNER/NAME/rulesets/ID --jq '.rules[] | select(.type==\"required_status_checks\") | .parameters.required_status_checks[].context' for the contexts. Paste them into `required_contexts` BYTE FOR BYTE, fill in `ruleset.repo` / `ruleset.ids`, and set `baseline_fetched_at` to today.",
7
+ "A transcription expires after 90 days, because a ruleset can be edited with no signal in this repository. The shipped `.github/workflows/required-checks-drift.yml` runs `--remote` weekly to catch that; `--remote` reads the ruleset live and so answers even when the cache is untrusted.",
8
+ "Lisa's quality.yml runs the offline arm on every pull request. This seed ships `\"enforcement\": \"warn\"`, which downgrades findings AND the NOT-CHECKED refusal to reports so a fresh install does not go red on arrival. Delete the key once you have transcribed the list — then it blocks.",
9
+ "`skip_jobs` is matched as an exact comma-delimited token and GitHub Actions expression syntax has no string-replace, so write the list with no spaces: `skip_jobs: 'a,b'`, never `skip_jobs: 'a, b'`. A spaced token matches nothing and the job runs — reported as `whitespace_in_skip_token`.",
10
+ "`_example_required_contexts` below is a STARTING POINT FOR TYPING, never read by the guard. Verify every line against your own ruleset before promoting any of it."
11
+ ],
12
+ "enforcement": "warn",
13
+ "ruleset": {
14
+ "repo": "",
15
+ "ids": [],
16
+ "baseline_fetched_at": ""
17
+ },
18
+ "workflows": [
19
+ ".github/workflows/ci.yml"
20
+ ],
21
+ "exemption_ticket_pattern": "^[A-Z][A-Z0-9]+-\\d+$",
22
+ "required_contexts": [],
23
+ "_example_required_contexts": [
24
+ "🔍 Quality Checks / 🧹 Lint",
25
+ "🔍 Quality Checks / 🔍 Type Check",
26
+ "🔍 Quality Checks / 🏗️ Build",
27
+ "🔍 Quality Checks / 📐 Check Formatting",
28
+ "🔍 Quality Checks / 🔒 Security Scan",
29
+ "🔍 Quality Checks / 🧪 Run Unit Tests",
30
+ "🔍 Quality Checks / 🧪 Run Integration Tests",
31
+ "🔍 Quality Checks / 🔗 Work-Item Traceability"
32
+ ],
33
+ "skip_job_declarations": {
34
+ "test:e2e": {
35
+ "suppressed_contexts": [
36
+ "🔍 Quality Checks / 🧪 Run E2E Tests"
37
+ ],
38
+ "ruleset_required": false,
39
+ "reason": "A NestJS API's end-to-end suite needs a database and a booted app, which the shipped ci.yml does not provision. Lisa's quality callee guards each job on script existence, so un-skipping it without that scaffolding would produce a run that succeeds having tested nothing — a worse false green than the skip. Not a required context, so skipping removes a check rather than faking one."
40
+ },
41
+ "playwright_e2e": {
42
+ "suppressed_contexts": [
43
+ "🔍 Quality Checks / 🎭 Playwright Shard Setup",
44
+ "🔍 Quality Checks / 🎭 Playwright E2E Tests"
45
+ ],
46
+ "ruleset_required": false,
47
+ "reason": "Note this ONE token silences TWO jobs — the mapping is not a mechanical transform, which is why this file is a reviewed snapshot rather than something derived. Neither is a required context: a headless API has no browser surface for Playwright to drive."
48
+ },
49
+ "zap_baseline": {
50
+ "suppressed_contexts": [
51
+ "🔍 Quality Checks / 🕷️ OWASP ZAP Baseline"
52
+ ],
53
+ "ruleset_required": false,
54
+ "reason": "The shipped ci.yml runs ZAP as its own `zap` job against a deployed target after quality passes, not inside the quality callee. Not a required context."
55
+ }
56
+ }
57
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "./node_modules/oxlint/configuration_schema.json",
3
- "extends": ["./node_modules/@codyswann/lisa/oxlint/nestjs.json"],
3
+ "extends": ["./.lisa/lisa-oxlint/nestjs.json"],
4
4
  "ignorePatterns": [
5
5
  "build/**",
6
6
  "dist/**",
package/package.json CHANGED
@@ -120,7 +120,7 @@
120
120
  }
121
121
  },
122
122
  "name": "@codyswann/lisa",
123
- "version": "3.4.0",
123
+ "version": "3.5.0",
124
124
  "description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
125
125
  "main": "dist/index.js",
126
126
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "./node_modules/oxlint/configuration_schema.json",
3
- "extends": ["./node_modules/@codyswann/lisa/oxlint/phaser.json"],
3
+ "extends": ["./.lisa/lisa-oxlint/phaser.json"],
4
4
  "ignorePatterns": [
5
5
  "build/**",
6
6
  "dist/**",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "Universal governance: agents, skills, commands, hooks, and rules for all projects.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -69,6 +69,13 @@ read_atlassian_token() {
69
69
  # Legacy fallback: the OS keychain written by the guided /lisa:setup:atlassian
70
70
  # flow, for projects that have not adopted a credentials provider. Reached only
71
71
  # when the chokepoint is absent or has no entry.
72
+ #
73
+ # This rung is REMOVED ON 2026-11-01 — a dated migration ramp, not a standing
74
+ # exemption (see credential-substrate-precedence, "Legacy OS-keychain fallback
75
+ # — removal date"). A keychain entry is machine-local ambient state no headless
76
+ # surface can reach, so a project resting on it has no working tier 1 in cron,
77
+ # CI, or a cloud session. Re-run /lisa:setup:atlassian before that date to
78
+ # store ATLASSIAN_API_TOKEN through the chokepoint instead.
72
79
  case "$(uname -s)" in
73
80
  Darwin) security find-generic-password -s lisa-atlassian -a "$email" -w 2>/dev/null ;;
74
81
  Linux) command -v secret-tool >/dev/null && secret-tool lookup service lisa-atlassian account "$email" 2>/dev/null ;;
@@ -64,7 +64,7 @@ The input-resolver invokes `lisa-track $ARGUMENTS` and owns its complete resolve
64
64
  - **Bind before durable work:** only after the verified claim, run:
65
65
 
66
66
  ```bash
67
- node scripts/lisa-work-item.mjs bind <canonical-ref>
67
+ node scripts/lisa-work-item.mjs link <canonical-ref>
68
68
  ```
69
69
 
70
70
  Require a successful readback of that worktree-local binding. On detached HEAD, `branch: null` is the expected pending binding; after branch creation the mandatory `attach-branch` step below must replace it before any commit. Tracker or binding failure stops the flow; never continue untracked.
@@ -39,20 +39,55 @@ headlessly — which is why it leads. The CLI tier uses:
39
39
  ```bash
40
40
  curl -fsSL https://native.jam.dev/install | bash
41
41
  export PATH="$HOME/.local/bin:$PATH"
42
- printf '%s' "$JAM_PAT" | jam auth login --token
42
+
43
+ # Resolve the PAT through the chokepoint before giving up on the environment.
44
+ # `$JAM_PAT` is the documented fallback, not the only rung: without this, a
45
+ # project that keeps its credentials in Bitwarden, Doppler, or AWS has no tier 1
46
+ # path at all and silently resolves through the interactive MCP — the exact
47
+ # divergence `credential-substrate-precedence` exists to remove.
48
+ read_jam_pat() {
49
+ [ -n "${JAM_PAT:-}" ] && { echo "$JAM_PAT"; return; }
50
+ local resolver
51
+ for resolver in .claude/skills/lisa-secrets-access/scripts/resolve-secret.mjs \
52
+ .agents/skills/lisa-secrets-access/scripts/resolve-secret.mjs; do
53
+ if [ -f "$resolver" ]; then
54
+ local via_lisa
55
+ via_lisa=$(node "$resolver" get JAM_PAT 2>/dev/null) \
56
+ && [ -n "$via_lisa" ] && { echo "$via_lisa"; return; }
57
+ break
58
+ fi
59
+ done
60
+ return 1
61
+ }
62
+
63
+ # Piped, never written to disk and never passed as an argv token: a PAT on a
64
+ # command line is readable from the process table by every user on the host.
65
+ read_jam_pat | jam auth login --token
43
66
  jam skills install
44
67
  ```
45
68
 
46
69
  If neither tier works, fail with:
47
70
 
48
71
  ```text
49
- Error: no Jam access substrate available. Authenticate the Jam MCP or set JAM_PAT.
72
+ Error: no Jam access substrate available. Authenticate the Jam MCP, set JAM_PAT, or store JAM_PAT in this project's secrets provider.
50
73
  ```
51
74
 
75
+ ## Mutation boundary
76
+
77
+ Every operation in the Invocation Contract is **read-only** — fetching a trace,
78
+ a recording, or a bug report. So the `credential-substrate-precedence` guarded
79
+ fallback for mutating operations (write, read back, assert the tenant from the
80
+ response, roll back on mismatch) is not engaged here, and a failed tier is
81
+ simply skipped. A future operation that mutates Jam state — commenting on or
82
+ deleting a Jam — is a write and MUST reconcile by read-back before any retry.
83
+
52
84
  ## Invariants
53
85
 
54
86
  - Tier order is `credential-substrate-precedence`: `JAM_PAT` CLI first, Jam MCP as
55
87
  a preserved first-class fallback. Do not retry a failed tier blindly.
88
+ - The PAT is resolved through `lisa-secrets-access`, with the bare `JAM_PAT`
89
+ environment variable as the documented fallback. Never read a second
90
+ credential store directly.
56
91
  - Never commit a Jam PAT into `.mcp.json` or any generated setup artifact.
57
92
  - Headless Jam access requires `native.jam.dev` for the installer and
58
93
  `api.jam.dev` for CLI/API calls in any custom remote network allowlist.
@@ -70,6 +70,13 @@ read_notion_token() {
70
70
  # Legacy fallback: the OS keychain written by the guided /lisa:setup:notion
71
71
  # flow, for projects that have not adopted a credentials provider. Reached only
72
72
  # when the chokepoint is absent or has no entry.
73
+ #
74
+ # This rung is REMOVED ON 2026-11-01 — a dated migration ramp, not a standing
75
+ # exemption (see credential-substrate-precedence, "Legacy OS-keychain fallback
76
+ # — removal date"). A keychain entry is machine-local ambient state no headless
77
+ # surface can reach, so a project resting on it has no working tier 1 in cron,
78
+ # CI, or a cloud session. Re-run /lisa:setup:notion before that date to store
79
+ # NOTION_API_TOKEN through the chokepoint instead.
73
80
  case "$(uname -s)" in
74
81
  Darwin) security find-generic-password -s lisa-notion -a "$workspace" -w 2>/dev/null ;;
75
82
  Linux) command -v secret-tool >/dev/null && \