@codyswann/lisa 2.349.1 → 2.351.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 (192) hide show
  1. package/dist/cli/doctor-learnings-ledger.d.ts +23 -0
  2. package/dist/cli/doctor-learnings-ledger.d.ts.map +1 -0
  3. package/dist/cli/doctor-learnings-ledger.js +67 -0
  4. package/dist/cli/doctor-learnings-ledger.js.map +1 -0
  5. package/dist/cli/doctor.d.ts.map +1 -1
  6. package/dist/cli/doctor.js +6 -0
  7. package/dist/cli/doctor.js.map +1 -1
  8. package/dist/core/learnings-file-safety.d.ts +12 -1
  9. package/dist/core/learnings-file-safety.d.ts.map +1 -1
  10. package/dist/core/learnings-file-safety.js +20 -1
  11. package/dist/core/learnings-file-safety.js.map +1 -1
  12. package/dist/core/learnings-location.d.ts +40 -0
  13. package/dist/core/learnings-location.d.ts.map +1 -0
  14. package/dist/core/learnings-location.js +89 -0
  15. package/dist/core/learnings-location.js.map +1 -0
  16. package/dist/core/learnings-stray-ledger.d.ts +24 -0
  17. package/dist/core/learnings-stray-ledger.d.ts.map +1 -0
  18. package/dist/core/learnings-stray-ledger.js +155 -0
  19. package/dist/core/learnings-stray-ledger.js.map +1 -0
  20. package/dist/core/project-config.d.ts +1 -13
  21. package/dist/core/project-config.d.ts.map +1 -1
  22. package/dist/core/project-config.js +5 -53
  23. package/dist/core/project-config.js.map +1 -1
  24. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  25. package/dist/core/upstream-evidence-manifest.js +46 -18
  26. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  27. package/package.json +1 -1
  28. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  30. package/plugins/lisa/.codex-plugin/skills/lisa-github-read-issue/SKILL.md +1 -0
  31. package/plugins/lisa/.codex-plugin/skills/lisa-github-validate-issue/SKILL.md +22 -0
  32. package/plugins/lisa/.codex-plugin/skills/lisa-github-write-issue/SKILL.md +15 -0
  33. package/plugins/lisa/.codex-plugin/skills/lisa-implement/SKILL.md +6 -0
  34. package/plugins/lisa/.codex-plugin/skills/lisa-jira-validate-ticket/SKILL.md +22 -0
  35. package/plugins/lisa/.codex-plugin/skills/lisa-jira-write-ticket/SKILL.md +15 -0
  36. package/plugins/lisa/.codex-plugin/skills/lisa-linear-access/SKILL.md +25 -3
  37. package/plugins/lisa/.codex-plugin/skills/lisa-linear-validate-issue/SKILL.md +22 -0
  38. package/plugins/lisa/.codex-plugin/skills/lisa-linear-write-issue/SKILL.md +15 -0
  39. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/SKILL.md +20 -2
  40. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +13 -9
  41. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/note-format.mjs +179 -0
  42. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/resolve-secret.mjs +32 -6
  43. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/tools-from-notes.mjs +6 -2
  44. package/plugins/lisa/.codex-plugin/skills/lisa-setup-atlassian/SKILL.md +17 -0
  45. package/plugins/lisa/.codex-plugin/skills/lisa-setup-linear/SKILL.md +17 -0
  46. package/plugins/lisa/.codex-plugin/skills/lisa-setup-notion/SKILL.md +17 -0
  47. package/plugins/lisa/rules/eager/derived-branch-plan.md +45 -0
  48. package/plugins/lisa/rules/reference/config-resolution.md +16 -0
  49. package/plugins/lisa/rules/reference/derived-branch-plan.md +112 -0
  50. package/plugins/lisa/skills/lisa-github-read-issue/SKILL.md +1 -0
  51. package/plugins/lisa/skills/lisa-github-validate-issue/SKILL.md +22 -0
  52. package/plugins/lisa/skills/lisa-github-write-issue/SKILL.md +15 -0
  53. package/plugins/lisa/skills/lisa-implement/SKILL.md +6 -0
  54. package/plugins/lisa/skills/lisa-jira-validate-ticket/SKILL.md +22 -0
  55. package/plugins/lisa/skills/lisa-jira-write-ticket/SKILL.md +15 -0
  56. package/plugins/lisa/skills/lisa-linear-access/SKILL.md +25 -3
  57. package/plugins/lisa/skills/lisa-linear-validate-issue/SKILL.md +22 -0
  58. package/plugins/lisa/skills/lisa-linear-write-issue/SKILL.md +15 -0
  59. package/plugins/lisa/skills/lisa-secrets-access/SKILL.md +20 -2
  60. package/plugins/lisa/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +13 -9
  61. package/plugins/lisa/skills/lisa-secrets-access/scripts/note-format.mjs +179 -0
  62. package/plugins/lisa/skills/lisa-secrets-access/scripts/resolve-secret.mjs +32 -6
  63. package/plugins/lisa/skills/lisa-secrets-access/scripts/tools-from-notes.mjs +6 -2
  64. package/plugins/lisa/skills/lisa-setup-atlassian/SKILL.md +17 -0
  65. package/plugins/lisa/skills/lisa-setup-linear/SKILL.md +17 -0
  66. package/plugins/lisa/skills/lisa-setup-notion/SKILL.md +17 -0
  67. package/plugins/lisa-agy/plugin.json +1 -1
  68. package/plugins/lisa-agy/skills/lisa-github-read-issue/SKILL.md +1 -0
  69. package/plugins/lisa-agy/skills/lisa-github-validate-issue/SKILL.md +22 -0
  70. package/plugins/lisa-agy/skills/lisa-github-write-issue/SKILL.md +15 -0
  71. package/plugins/lisa-agy/skills/lisa-implement/SKILL.md +6 -0
  72. package/plugins/lisa-agy/skills/lisa-jira-validate-ticket/SKILL.md +22 -0
  73. package/plugins/lisa-agy/skills/lisa-jira-write-ticket/SKILL.md +15 -0
  74. package/plugins/lisa-agy/skills/lisa-linear-access/SKILL.md +25 -3
  75. package/plugins/lisa-agy/skills/lisa-linear-validate-issue/SKILL.md +22 -0
  76. package/plugins/lisa-agy/skills/lisa-linear-write-issue/SKILL.md +15 -0
  77. package/plugins/lisa-agy/skills/lisa-secrets-access/SKILL.md +20 -2
  78. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +13 -9
  79. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/note-format.mjs +179 -0
  80. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/resolve-secret.mjs +32 -6
  81. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/tools-from-notes.mjs +6 -2
  82. package/plugins/lisa-agy/skills/lisa-setup-atlassian/SKILL.md +17 -0
  83. package/plugins/lisa-agy/skills/lisa-setup-linear/SKILL.md +17 -0
  84. package/plugins/lisa-agy/skills/lisa-setup-notion/SKILL.md +17 -0
  85. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  87. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  88. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-copilot/rules/eager/derived-branch-plan.md +45 -0
  92. package/plugins/lisa-copilot/rules/reference/config-resolution.md +16 -0
  93. package/plugins/lisa-copilot/rules/reference/derived-branch-plan.md +112 -0
  94. package/plugins/lisa-copilot/skills/lisa-github-read-issue/SKILL.md +1 -0
  95. package/plugins/lisa-copilot/skills/lisa-github-validate-issue/SKILL.md +22 -0
  96. package/plugins/lisa-copilot/skills/lisa-github-write-issue/SKILL.md +15 -0
  97. package/plugins/lisa-copilot/skills/lisa-implement/SKILL.md +6 -0
  98. package/plugins/lisa-copilot/skills/lisa-jira-validate-ticket/SKILL.md +22 -0
  99. package/plugins/lisa-copilot/skills/lisa-jira-write-ticket/SKILL.md +15 -0
  100. package/plugins/lisa-copilot/skills/lisa-linear-access/SKILL.md +25 -3
  101. package/plugins/lisa-copilot/skills/lisa-linear-validate-issue/SKILL.md +22 -0
  102. package/plugins/lisa-copilot/skills/lisa-linear-write-issue/SKILL.md +15 -0
  103. package/plugins/lisa-copilot/skills/lisa-secrets-access/SKILL.md +20 -2
  104. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +13 -9
  105. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/note-format.mjs +179 -0
  106. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/resolve-secret.mjs +32 -6
  107. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/tools-from-notes.mjs +6 -2
  108. package/plugins/lisa-copilot/skills/lisa-setup-atlassian/SKILL.md +17 -0
  109. package/plugins/lisa-copilot/skills/lisa-setup-linear/SKILL.md +17 -0
  110. package/plugins/lisa-copilot/skills/lisa-setup-notion/SKILL.md +17 -0
  111. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  112. package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +16 -0
  113. package/plugins/lisa-cursor/rules/derived-branch-plan-reference.mdc +117 -0
  114. package/plugins/lisa-cursor/rules/derived-branch-plan.mdc +50 -0
  115. package/plugins/lisa-cursor/skills/lisa-github-read-issue/SKILL.md +1 -0
  116. package/plugins/lisa-cursor/skills/lisa-github-validate-issue/SKILL.md +22 -0
  117. package/plugins/lisa-cursor/skills/lisa-github-write-issue/SKILL.md +15 -0
  118. package/plugins/lisa-cursor/skills/lisa-implement/SKILL.md +6 -0
  119. package/plugins/lisa-cursor/skills/lisa-jira-validate-ticket/SKILL.md +22 -0
  120. package/plugins/lisa-cursor/skills/lisa-jira-write-ticket/SKILL.md +15 -0
  121. package/plugins/lisa-cursor/skills/lisa-linear-access/SKILL.md +25 -3
  122. package/plugins/lisa-cursor/skills/lisa-linear-validate-issue/SKILL.md +22 -0
  123. package/plugins/lisa-cursor/skills/lisa-linear-write-issue/SKILL.md +15 -0
  124. package/plugins/lisa-cursor/skills/lisa-secrets-access/SKILL.md +20 -2
  125. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +13 -9
  126. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/note-format.mjs +179 -0
  127. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/resolve-secret.mjs +32 -6
  128. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/tools-from-notes.mjs +6 -2
  129. package/plugins/lisa-cursor/skills/lisa-setup-atlassian/SKILL.md +17 -0
  130. package/plugins/lisa-cursor/skills/lisa-setup-linear/SKILL.md +17 -0
  131. package/plugins/lisa-cursor/skills/lisa-setup-notion/SKILL.md +17 -0
  132. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  133. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  134. package/plugins/lisa-expo-agy/plugin.json +1 -1
  135. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  136. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  137. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  138. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  139. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  140. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  141. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  142. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  143. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  144. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  145. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  146. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  147. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  148. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  149. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  150. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  151. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  152. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  153. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  154. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  155. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  156. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  157. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  158. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  159. package/plugins/lisa-rails-agy/plugin.json +1 -1
  160. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  161. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  162. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  163. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  164. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  165. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  166. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  167. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  168. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  169. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  170. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  171. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  172. package/plugins/src/base/rules/eager/derived-branch-plan.md +45 -0
  173. package/plugins/src/base/rules/reference/config-resolution.md +16 -0
  174. package/plugins/src/base/rules/reference/derived-branch-plan.md +112 -0
  175. package/plugins/src/base/skills/lisa-github-read-issue/SKILL.md +1 -0
  176. package/plugins/src/base/skills/lisa-github-validate-issue/SKILL.md +22 -0
  177. package/plugins/src/base/skills/lisa-github-write-issue/SKILL.md +15 -0
  178. package/plugins/src/base/skills/lisa-implement/SKILL.md +6 -0
  179. package/plugins/src/base/skills/lisa-jira-validate-ticket/SKILL.md +22 -0
  180. package/plugins/src/base/skills/lisa-jira-write-ticket/SKILL.md +15 -0
  181. package/plugins/src/base/skills/lisa-linear-access/SKILL.md +25 -3
  182. package/plugins/src/base/skills/lisa-linear-validate-issue/SKILL.md +22 -0
  183. package/plugins/src/base/skills/lisa-linear-write-issue/SKILL.md +15 -0
  184. package/plugins/src/base/skills/lisa-secrets-access/SKILL.md +20 -2
  185. package/plugins/src/base/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +13 -9
  186. package/plugins/src/base/skills/lisa-secrets-access/scripts/note-format.mjs +179 -0
  187. package/plugins/src/base/skills/lisa-secrets-access/scripts/resolve-secret.mjs +32 -6
  188. package/plugins/src/base/skills/lisa-secrets-access/scripts/tools-from-notes.mjs +6 -2
  189. package/plugins/src/base/skills/lisa-setup-atlassian/SKILL.md +17 -0
  190. package/plugins/src/base/skills/lisa-setup-linear/SKILL.md +17 -0
  191. package/plugins/src/base/skills/lisa-setup-notion/SKILL.md +17 -0
  192. package/scripts/github-status-check.sh +22 -7
@@ -112,6 +112,21 @@ Linear descriptions are markdown (NOT Jira wiki markup — no `h2.` headings, us
112
112
  Human confirmation replaces the automated annotation with a bare key or
113
113
  `Confirmed: <env>`. Skip only for doc/config/type-only items.]
114
114
 
115
+ ## Branch Plan
116
+ [GENERATED, never hand-authored. Render only when the item has a Target
117
+ Backend Environment; omit entirely when `runtime_behavior_change = false`
118
+ (doc-only / config-only / type-only) or for a Project/container — absence is
119
+ correct there. Derive per the `derived-branch-plan` rule: resolve the
120
+ environment, map it forward through `.lisa.config.json` `deploy.branches`,
121
+ and prove the branch exists on the remote. Do not accept caller-supplied
122
+ branches; recompute them. Exactly three lines:
123
+ Branch from: <branch>
124
+ PR into: <branch>
125
+ Derived from: Target Backend Environment <env> via .lisa.config.json deploy.branches
126
+ Both fields name the same branch by construction. A missing, ambiguous, or
127
+ non-unique mapping, or a branch absent from the remote, STOPS the write —
128
+ never default to `main` or the remote default to keep the write alive.]
129
+
115
130
  ## Sign-in Required
116
131
  [Include this section ONLY if the work touches authenticated surfaces.
117
132
  Specify: the account/role to sign in as, where to get the credentials
@@ -116,9 +116,11 @@ Every provider has a description field — Bitwarden `note`, 1Password notes, AW
116
116
 
117
117
  These are two different rules and should not be conflated:
118
118
 
119
- - **Rule A — the note must exist and be well-formed.** Universal: every secret, every provider, every surface. Enforced **statically** by `verify` and by `doctor`. Nothing to do with agents.
119
+ - **Rule A — the note must exist and be well-formed.** Universal: every secret, every provider, every surface. Enforced **statically** by `verify` and by `doctor`, and it **blocks** — a malformed note is an error, not a warning. Nothing to do with agents.
120
120
  - **Rule B — an agent must read the note before first use.** Runtime, and **only in lanes where a consumer has latitude**. An agent could do anything with a write-scoped token, and the note is what bounds it. A reviewed workflow step resolving one credential by exact name has no latitude, so gating it there is ceremony that can only fail-closed and never inform.
121
121
 
122
+ Rule A blocks because a warning enforced nothing. Both checks previously tested only whether a note was *present*, and reported a warning either way, so a vault of empty notes reported clean — which is precisely why the notes stayed empty. A check that cannot fail is a check nobody acts on.
123
+
122
124
  Format — first line prose, then `key: value` lines:
123
125
 
124
126
  ```text
@@ -129,6 +131,22 @@ ci: yes - injected by <mechanism>
129
131
  docs: <path>
130
132
  ```
131
133
 
134
+ ### What "well-formed" means, exactly
135
+
136
+ `scripts/note-format.mjs` is the one validator; `verify` and `doctor` both call it, so they cannot reach different verdicts about the same note. It **blocks** on:
137
+
138
+ | Defect | Why it blocks |
139
+ | --- | --- |
140
+ | `missing-note` | No note at all, empty, or only whitespace. |
141
+ | `no-prose` | Every line is a `key: value` line, so nothing states what the credential *is*. |
142
+ | `empty-field` | A field with nothing after the colon — it promises a fact the reader cannot find. |
143
+ | `empty-tool-line` | A `tool:` line naming no tool: it asks for an install and supplies nothing. |
144
+ | `bad-tool-name` | A `tool:` entry that is not a bare name, so the reader drops it silently. |
145
+
146
+ It **warns**, without blocking, on `stray-separator` — a trailing or doubled comma in a tool list. The reader handles it; failing a vault over punctuation would only teach operators that the check is noise.
147
+
148
+ Deliberately **not** checked: which fields a note carries, and how informative the prose is. The documented format mandates neither, and enforcing beyond the prose is the same defect as prose beyond the enforcement, pointing the other way. A line is treated as prose unless the text before its colon is a single bare lowercase token — so "Attio CRM: system of record" is a sentence, not a field.
149
+
132
150
  ### `tool:` — the CLI a secret implies
133
151
 
134
152
  One key is read by more than a human. A credential and the CLI that consumes it belong together — `SONARQUBE_CLI_TOKEN` is only useful with `sonar` — so a `tool:` (or `tools: a, b`) line declares that pairing where it cannot drift from the secret:
@@ -229,7 +247,7 @@ Cache **in-process only**. Never write a resolved value to disk except through t
229
247
 
230
248
  - Every name in `require` resolves.
231
249
  - Every key matches `^[A-Z][A-Z0-9_]*$`.
232
- - No secret has an empty note.
250
+ - Every secret's note exists and is well-formed, per the table above. This is an **error**, so a vault that was passing on warnings will newly fail until its notes are written.
233
251
  - Every name in `rotating` has a resolvable bootstrap, so its replacement could be persisted.
234
252
  - **No secret is readable from two stores.** A value present in both the provider and a local cache is not a duplicate — it is **two live credentials**, one of which is untracked. This is the check most worth having: it catches drift before a deletion turns the forgotten copy into an orphan nobody can revoke.
235
253
 
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { createHash } from "node:crypto";
21
21
 
22
+ import { validateNote } from "./note-format.mjs";
22
23
  import { fetchAll, fetchRotatable } from "./providers.mjs";
23
24
  import { readMaterialized } from "./resolve-secret.mjs";
24
25
  import { readConfig } from "./surfaces.mjs";
@@ -118,20 +119,23 @@ export function checkNaming(provider, report) {
118
119
  }
119
120
 
120
121
  /**
121
- * Assert every secret carries a usage note.
122
+ * Assert every secret carries a usage note in the documented format.
123
+ *
124
+ * This blocks rather than warns, and the promotion is the point. The contract
125
+ * has always said the note "must exist and be well-formed", enforced statically
126
+ * by `doctor` — but a warning enforces nothing, so a vault of empty notes
127
+ * reported clean and stayed empty. A check that never fails is a check nobody
128
+ * acts on.
129
+ *
130
+ * The grammar lives in `note-format.mjs` so this and `resolve-secret.mjs verify`
131
+ * cannot disagree about what a well-formed note is.
122
132
  * @param {Map<string, object>} provider Provider view.
123
133
  * @param {Function} report Finding collector.
124
134
  */
125
135
  export function checkNotes(provider, report) {
126
136
  for (const [name, entry] of provider) {
127
- if (!entry.note?.trim()) {
128
- report(
129
- "warn",
130
- name,
131
- "has no usage note. An agent cannot learn this credential's scope " +
132
- "without one, and inferring it from the name is exactly the guess " +
133
- "that writes to the wrong system"
134
- );
137
+ for (const defect of validateNote(entry?.note)) {
138
+ report(defect.level, name, defect.message);
135
139
  }
136
140
  }
137
141
  }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Validate a credential's usage note against the format the contract documents.
3
+ *
4
+ * The contract states Rule A — "the note must exist and be well-formed",
5
+ * enforced statically by `verify` and by `doctor`. Only half of that was ever
6
+ * true. Both consumers tested the note for emptiness and nothing tested its
7
+ * shape, so a note that promised a scope and delivered a bare colon, or a
8
+ * `tool:` line naming a URL instead of a CLI, passed every check Lisa had. The
9
+ * prose described an enforcement that did not exist, which is worse than no
10
+ * rule: readers stopped checking because they believed something else was.
11
+ *
12
+ * This module is that missing half, and it lives in one place on purpose. The
13
+ * same argument the values file makes for keeping its writer and its parser
14
+ * together applies here: `tools-from-notes.mjs` decides what a `tool:` line
15
+ * *is*, and if this validator held a second copy of that pattern, a note could
16
+ * pass doctor and then be silently ignored at install time with nothing to
17
+ * reveal the disagreement. So the matcher is defined here once and imported
18
+ * there.
19
+ *
20
+ * Messages are written for the person who has to fix the vault, who is often
21
+ * not an engineer. Each one names the secret's defect and the edit that clears
22
+ * it, never only the rule that was broken.
23
+ * @module note-format
24
+ */
25
+
26
+ /**
27
+ * A `key: value` metadata line.
28
+ *
29
+ * The key must be a single bare lowercase token. That is what separates
30
+ * `scope: read-write` from prose that happens to contain a colon, such as
31
+ * "Attio CRM: system of record for sales" — a sentence has spaces before its
32
+ * colon, and a capitalised word opening a sentence is prose, not a field. The
33
+ * discriminator is deliberately generous: mistaking a field for prose only
34
+ * loosens a check, while mistaking prose for a field would fail a good note.
35
+ */
36
+ const FIELD_LINE = /^[ \t]*([a-z][a-z0-9_-]*)[ \t]*:[ \t]*(.*)$/;
37
+
38
+ /** Matches `tool: name` or `tools: a, b`, case-insensitively, on one line. */
39
+ const TOOL_LINE_SINGLE = /^[ \t]*tools?[ \t]*:[ \t]*(.*)$/i;
40
+
41
+ /**
42
+ * The same matcher, global and multiline, for scanning a whole note.
43
+ *
44
+ * Derived from the single-line pattern rather than written out again, so the
45
+ * two can never disagree about what a tool line is.
46
+ */
47
+ export const TOOL_LINE = new RegExp(TOOL_LINE_SINGLE.source, "gim");
48
+
49
+ /**
50
+ * A bare CLI name: what a `tool:` entry is permitted to be.
51
+ *
52
+ * Names are matched against Lisa's catalogue and never executed, so this is not
53
+ * the security boundary — the catalogue is. It is the check that tells an
54
+ * operator their entry will be silently dropped, which is the failure mode
55
+ * worth catching: a note asking for `sonar (for CI)` installs nothing, and the
56
+ * session fails much later for a reason that looks unrelated.
57
+ */
58
+ const TOOL_NAME = /^[a-z0-9][a-z0-9._+-]*$/;
59
+
60
+ /**
61
+ * Whether a line is structured metadata rather than a sentence.
62
+ * @param {string} line One line of a note.
63
+ * @returns {boolean} True when the line is a field or a tool line.
64
+ */
65
+ function isMetadataLine(line) {
66
+ return FIELD_LINE.test(line) || TOOL_LINE_SINGLE.test(line);
67
+ }
68
+
69
+ /**
70
+ * Check the entries on one `tool:` / `tools:` line.
71
+ * @param {string} value The text after the colon.
72
+ * @param {object[]} defects Collector to append to.
73
+ */
74
+ function checkToolLine(value, defects) {
75
+ if (!value.trim()) {
76
+ defects.push({
77
+ level: "error",
78
+ code: "empty-tool-line",
79
+ message:
80
+ "has a `tool:` line that names no tool. Either name the command-line " +
81
+ "tool this credential is for (for example `tool: sonar`) or delete " +
82
+ "the line — as written it asks for a tool and supplies none, so " +
83
+ "nothing gets installed",
84
+ });
85
+ return;
86
+ }
87
+
88
+ for (const raw of value.split(",")) {
89
+ const entry = raw.trim();
90
+ if (!entry) {
91
+ // A trailing or doubled comma is sloppy punctuation, not a broken note:
92
+ // the reader drops the empty entry and installs the rest correctly.
93
+ // Blocking a vault over a comma would teach operators the check is noise.
94
+ defects.push({
95
+ level: "warn",
96
+ code: "stray-separator",
97
+ message:
98
+ "has a `tool:` line with a stray comma. Nothing breaks, but tidy it " +
99
+ "so the list reads as the tools it actually names",
100
+ });
101
+ continue;
102
+ }
103
+ if (!TOOL_NAME.test(entry.toLowerCase())) {
104
+ defects.push({
105
+ level: "error",
106
+ code: "bad-tool-name",
107
+ message:
108
+ `has a \`tool:\` entry that is not a tool name: "${entry}". Each ` +
109
+ "entry must be one plain name such as `sonar` or `gh`, separated by " +
110
+ "commas — no descriptions, links, versions, or punctuation. As " +
111
+ "written this entry is ignored, so the tool never gets installed",
112
+ });
113
+ }
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Report every way a usage note departs from the documented format.
119
+ *
120
+ * Returns defects rather than throwing so a caller can list a whole vault's
121
+ * problems in one pass. An operator fixing a vault wants every edit at once,
122
+ * not one error per run.
123
+ * @param {unknown} note The note text stored beside the secret.
124
+ * @returns {{level: string, code: string, message: string}[]} Defects found.
125
+ */
126
+ export function validateNote(note) {
127
+ if (typeof note !== "string" || !note.trim()) {
128
+ // One cause, one instruction. Also accusing an absent note of lacking a
129
+ // first line would leave the operator guessing which fix comes first.
130
+ return [
131
+ {
132
+ level: "error",
133
+ code: "missing-note",
134
+ message:
135
+ "has no usage note. Write one in the secret's own description field " +
136
+ "in the vault: a first line saying what this credential is for and " +
137
+ "what it can reach, then `key: value` lines such as `scope:`, " +
138
+ "`owner:`, and `tool:`. Guessing a credential's purpose from its " +
139
+ "name is exactly the guess that writes to the wrong system",
140
+ },
141
+ ];
142
+ }
143
+
144
+ const defects = [];
145
+ const lines = note.split(/\r?\n/);
146
+ const populated = lines.filter(line => line.trim());
147
+
148
+ if (!populated.some(line => !isMetadataLine(line))) {
149
+ defects.push({
150
+ level: "error",
151
+ code: "no-prose",
152
+ message:
153
+ "has no plain-language first line. Add a sentence at the top saying " +
154
+ "what this credential is for and what it can reach — the `key: value` " +
155
+ "lines below it list details, but they never say what the thing is",
156
+ });
157
+ }
158
+
159
+ for (const line of populated) {
160
+ const tool = TOOL_LINE_SINGLE.exec(line);
161
+ if (tool) {
162
+ checkToolLine(tool[1], defects);
163
+ continue;
164
+ }
165
+ const field = FIELD_LINE.exec(line);
166
+ if (field && !field[2].trim()) {
167
+ defects.push({
168
+ level: "error",
169
+ code: "empty-field",
170
+ message:
171
+ `has an empty \`${field[1]}:\` line. Either fill in the value or ` +
172
+ "delete the line — a field with nothing after the colon promises a " +
173
+ "fact the reader then cannot find anywhere",
174
+ });
175
+ }
176
+ }
177
+
178
+ return defects;
179
+ }
@@ -30,6 +30,7 @@
30
30
  import { existsSync, readFileSync } from "node:fs";
31
31
 
32
32
  import { parseEnv } from "./envfile.mjs";
33
+ import { validateNote } from "./note-format.mjs";
33
34
  import { ENV_KEY, fetchAll } from "./providers.mjs";
34
35
  import { materializedPaths, readConfig } from "./surfaces.mjs";
35
36
 
@@ -109,6 +110,19 @@ function sourceOf(name, provider, file) {
109
110
  return " ";
110
111
  }
111
112
 
113
+ /**
114
+ * Say what is wrong with a note, not merely that something is.
115
+ *
116
+ * "NO NOTE" against a note that exists but is malformed sends the operator
117
+ * looking for a missing field they already wrote.
118
+ * @param {object} [fault] The blocking defect, if any.
119
+ * @returns {string} A status label for the verify table.
120
+ */
121
+ function noteStatus(fault) {
122
+ if (!fault) return "noted";
123
+ return fault.code === "missing-note" ? "NO NOTE" : `NOTE ${fault.code}`;
124
+ }
125
+
112
126
  /**
113
127
  * Verify every declared secret, mirroring the ladder's own order.
114
128
  *
@@ -119,12 +133,20 @@ function sourceOf(name, provider, file) {
119
133
  * This command is read-only. Proving that a rotating credential can actually be
120
134
  * written back requires a write, so that check lives in the rotation path
121
135
  * rather than here.
136
+ *
137
+ * The two views are parameters so a test can supply them. Reaching for the
138
+ * provider CLI is the one thing in here that cannot run in a test, and a check
139
+ * nobody can test is how the note rule stayed unenforced for so long.
122
140
  * @param {object} cfg Resolved configuration.
141
+ * @param {Map<string, object>} [provider] Provider view.
142
+ * @param {Map<string, string>} [file] Materialized view.
123
143
  * @returns {number} Count of secrets that failed.
124
144
  */
125
- function verify(cfg) {
126
- const provider = fetchAll(cfg);
127
- const file = readMaterialized(cfg);
145
+ export function verify(
146
+ cfg,
147
+ provider = fetchAll(cfg),
148
+ file = readMaterialized(cfg)
149
+ ) {
128
150
  const names = cfg.require ?? [
129
151
  ...new Set([...provider.keys(), ...file.keys()]),
130
152
  ];
@@ -139,13 +161,17 @@ function verify(cfg) {
139
161
  provider.get(name)?.value
140
162
  );
141
163
  const named = ENV_KEY.test(name);
142
- const noted = Boolean(provider.get(name)?.note);
143
- if (!resolves || !named || !noted) bad += 1;
164
+ // Presence is not well-formedness. Testing `Boolean(note)` passed a note
165
+ // that was a single stray character, which is exactly the note an agent
166
+ // learns nothing from. Same validator doctor uses, so the two agree.
167
+ const defects = validateNote(provider.get(name)?.note);
168
+ const noteFault = defects.find(d => d.level === "error");
169
+ if (!resolves || !named || noteFault) bad += 1;
144
170
  console.log(
145
171
  ` ${name.padEnd(30)} ${resolves ? "resolves" : "MISSING "} ` +
146
172
  `${sourceOf(name, provider, file)} ` +
147
173
  `${named ? "name ok" : "NAME NOT UPPER_SNAKE"} ` +
148
- `${noted ? "noted" : "NO NOTE"}` +
174
+ `${noteStatus(noteFault)}` +
149
175
  `${rotating.has(name) ? " rotating" : ""}`
150
176
  );
151
177
  }
@@ -21,11 +21,15 @@
21
21
  * its note. Selecting from a known set means the worst a hostile note can do is
22
22
  * ask for a CLI Lisa already ships a pinned, checksummed entry for — it can
23
23
  * never introduce a URL, a version, or a command.
24
+ * The matcher itself lives in `note-format.mjs` alongside the validator that
25
+ * checks these lines, for the same reason the values file keeps its writer and
26
+ * its parser in one module: two copies of the pattern would let a note pass
27
+ * `doctor` and then be silently ignored here, with nothing to reveal the
28
+ * disagreement.
24
29
  * @module tools-from-notes
25
30
  */
26
31
 
27
- /** Matches `tool: name` or `tools: a, b`, case-insensitively, one per line. */
28
- const TOOL_LINE = /^[ \t]*tools?[ \t]*:[ \t]*(.+)$/gim;
32
+ import { TOOL_LINE } from "./note-format.mjs";
29
33
 
30
34
  /**
31
35
  * Collect the tool names a set of notes asks for.
@@ -77,6 +77,23 @@ read_token() {
77
77
  local slug=$(echo "$email" | tr '[:upper:]@.' '[:lower:]__')
78
78
  local varname="ATLASSIAN_API_TOKEN_${slug}"
79
79
  [ -n "${!varname}" ] && { echo "${!varname}"; return; }
80
+ # Preferred path: the single secrets chokepoint. It owns the one-store rule
81
+ # and the surface ladder, so anything it can answer must not be read out of an
82
+ # OS keychain here — a second reader is how the same credential ends up living
83
+ # in two places and drifting. Keep this identical to `atlassian-access`.
84
+ local resolver
85
+ for resolver in .claude/skills/lisa-secrets-access/scripts/resolve-secret.mjs \
86
+ .agents/skills/lisa-secrets-access/scripts/resolve-secret.mjs; do
87
+ if [ -f "$resolver" ]; then
88
+ local via_lisa
89
+ via_lisa=$(node "$resolver" get ATLASSIAN_API_TOKEN 2>/dev/null) \
90
+ && [ -n "$via_lisa" ] && { echo "$via_lisa"; return; }
91
+ break
92
+ fi
93
+ done
94
+ # Legacy fallback: the OS keychain written by the guided flow below, for
95
+ # projects that have not adopted a credentials provider. Reached only when the
96
+ # chokepoint is absent or has no entry.
80
97
  case "$(uname -s)" in
81
98
  Darwin) security find-generic-password -s lisa-atlassian -a "$email" -w 2>/dev/null ;;
82
99
  Linux) command -v secret-tool >/dev/null && secret-tool lookup service lisa-atlassian account "$email" 2>/dev/null ;;
@@ -89,6 +89,23 @@ read_linear_key() { # $1=workspace slug
89
89
  local slug; slug=$(echo "$ws" | tr '[:upper:]-' '[:lower:]_')
90
90
  local varname="LINEAR_API_KEY_${slug}"
91
91
  [ -n "${!varname}" ] && { echo "${!varname}"; return; }
92
+ # Preferred path: the single secrets chokepoint. It owns the one-store rule
93
+ # and the surface ladder, so anything it can answer must not be read out of an
94
+ # OS keychain here — a second reader is how the same credential ends up living
95
+ # in two places and drifting. Keep this identical to `linear-access`.
96
+ local resolver
97
+ for resolver in .claude/skills/lisa-secrets-access/scripts/resolve-secret.mjs \
98
+ .agents/skills/lisa-secrets-access/scripts/resolve-secret.mjs; do
99
+ if [ -f "$resolver" ]; then
100
+ local via_lisa
101
+ via_lisa=$(node "$resolver" get LINEAR_API_KEY 2>/dev/null) \
102
+ && [ -n "$via_lisa" ] && { echo "$via_lisa"; return; }
103
+ break
104
+ fi
105
+ done
106
+ # Legacy fallback: the OS keychain written by the guided flow below, for
107
+ # projects that have not adopted a credentials provider. Reached only when the
108
+ # chokepoint is absent or has no entry.
92
109
  case "$(uname -s)" in
93
110
  Darwin) security find-generic-password -s lisa-linear -a "$ws" -w 2>/dev/null ;;
94
111
  Linux) command -v secret-tool >/dev/null && secret-tool lookup service lisa-linear account "$ws" 2>/dev/null ;;
@@ -164,6 +164,23 @@ read_notion_token() {
164
164
  local slug=$(echo "$workspace" | tr '[:upper:]-' '[:lower:]_')
165
165
  local varname="NOTION_API_TOKEN_${slug}"
166
166
  [ -n "${!varname}" ] && { echo "${!varname}"; return; }
167
+ # Preferred path: the single secrets chokepoint. It owns the one-store rule
168
+ # and the surface ladder, so anything it can answer must not be read out of an
169
+ # OS keychain here — a second reader is how the same credential ends up living
170
+ # in two places and drifting. Keep this identical to `notion-access`.
171
+ local resolver
172
+ for resolver in .claude/skills/lisa-secrets-access/scripts/resolve-secret.mjs \
173
+ .agents/skills/lisa-secrets-access/scripts/resolve-secret.mjs; do
174
+ if [ -f "$resolver" ]; then
175
+ local via_lisa
176
+ via_lisa=$(node "$resolver" get NOTION_API_TOKEN 2>/dev/null) \
177
+ && [ -n "$via_lisa" ] && { echo "$via_lisa"; return; }
178
+ break
179
+ fi
180
+ done
181
+ # Legacy fallback: the OS keychain written by the guided flow below, for
182
+ # projects that have not adopted a credentials provider. Reached only when the
183
+ # chokepoint is absent or has no entry.
167
184
  case "$(uname -s)" in
168
185
  Darwin) security find-generic-password -s lisa-notion -a "$workspace" -w 2>/dev/null ;;
169
186
  Linux) command -v secret-tool >/dev/null && secret-tool lookup service lisa-notion account "$workspace" 2>/dev/null ;;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.349.1",
3
+ "version": "2.351.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.349.1",
3
+ "version": "2.351.0",
4
4
  "description": "AWS CDK-specific Lisa plugin.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.349.1",
3
+ "version": "2.351.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.349.1",
3
+ "version": "2.351.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.349.1",
3
+ "version": "2.351.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.349.1",
3
+ "version": "2.351.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -0,0 +1,45 @@
1
+ # Derived Branch Plan (load-bearing)
2
+
3
+ **Every applicable leaf work item shows the branch it will be built from and the branch its PR lands in, as a generated `Branch Plan` section — and that section is derived data, not a second authority.** The authority is `## Target Backend Environment` resolved forward through `.lisa.config.json` `deploy.branches`. A work item that displays branches nobody can trace back to that mapping, or a flow that reads the displayed branches instead of recomputing them, is a contract violation.
4
+
5
+ **One vendor-neutral contract, cited by** `lisa-jira-write-ticket`, `lisa-github-write-issue`, `lisa-linear-write-issue`, `lisa-jira-validate-ticket`, `lisa-github-validate-issue`, `lisa-linear-validate-issue`, and `lisa-implement` (the `leaf-only-lifecycle` / `repo-scope-split` precedent: one shared slug, never divergent per-vendor prose).
6
+
7
+ ## Why derived, and not two branch fields
8
+
9
+ The operator need is real: reading a ticket should not require resolving a config file to learn where the work starts and where it lands. But Lisa already owns that fact. Adding independent literal branch fields would create **a second source of truth that can drift** — and the failure mode of that drift is not a cosmetic mismatch, it is code silently shipped to the wrong environment. So the branches are rendered for humans and recomputed for machines. The rendered branches are **never read as input** by any flow, at any phase — a surface that needs the base branch derives it again from the environment.
10
+
11
+ ## The rendered section
12
+
13
+ Exactly three lines, identical across vendors (`h2. Branch Plan` in JIRA wiki markup, `## Branch Plan` in GitHub/Linear markdown):
14
+
15
+ ```
16
+ Branch from: <branch>
17
+ PR into: <branch>
18
+ Derived from: Target Backend Environment <env> via .lisa.config.json deploy.branches
19
+ ```
20
+
21
+ The third line is the **derivation provenance line**. It is what marks the section machine-authored: a `Branch Plan` without it is treated as hand-authored, and hand-authored branches are never obeyed.
22
+
23
+ `Branch from` and `PR into` name the **same branch by construction** — implementation checks the feature branch out of `origin/<base>` and targets the PR back into `<base>`. A plan naming two different branches is malformed and fails validation. (A fix that genuinely lands on one branch and must reach another travels as a linked follow-up work item with its own branch plan, never as a divergence inside one plan.)
24
+
25
+ ## Derivation
26
+
27
+ 1. Resolve the environment under the existing `Target Backend Environment` grammar (`config-resolution`, `pre-flight-autofill`) — human-confirmed wins, then validated `Inferred:`, then `Assumption:`.
28
+ 2. Map that exact configured key forward through `deploy.branches`. The key must map uniquely and the mapped branch must exist on the remote.
29
+ 3. Render both fields to that branch plus the provenance line.
30
+
31
+ A missing or **ambiguous** mapping, a key that is **not unique**, or a branch absent from the remote **stops** the flow — exactly as `lisa-implement` already stops. **Never default independently to `main`, to the remote default branch, or to any other branch** to keep a write from failing. The remote default is a legitimate input only through the `Assumption:` fallback the environment grammar already defines, and it must be recorded as such.
32
+
33
+ ## Exemption
34
+
35
+ Work that declares no runtime behavior change — `runtime_behavior_change = false`, i.e. doc-only, config-only, and type-only items — carries **no `Target Backend Environment`**, therefore has nothing to derive from and **requires no branch plan**. Its **absence is correct**, not a missing section, and no gate may demand one. Containers (an Epic, or a Story/Spike still holding child work) are exempt for the same reason: they are not built directly.
36
+
37
+ The exemption is one-way. Exempt work that *carries* a branch plan fails validation — branches asserted for work that declared it has no runtime target are hand-authored by definition, which is the second-authority failure this rule exists to prevent.
38
+
39
+ ## Conflict, legacy, and drift
40
+
41
+ - **Conflict** — a present plan that disagrees with the recomputed plan **fails**; the flow never silently picks a side. The remediation is always to correct the environment, never to edit the branches.
42
+ - **Legacy** — an item written before this rule has no plan. At claim time the implementing agent derives it, **writes the assumption onto the item as a comment**, and proceeds. Never a silent guess.
43
+ - **Drift** — config changed after the plan was rendered. Current config wins; the stale plan is re-rendered and never followed.
44
+
45
+ Full derivation table, gate S19 semantics, the claim-time arms, and the vendor rendering details: [reference/derived-branch-plan.md](../reference/derived-branch-plan.md).
@@ -847,6 +847,22 @@ read_notion_token() {
847
847
  local slug=$(echo "$workspace" | tr '[:upper:]-' '[:lower:]_')
848
848
  local varname="NOTION_API_TOKEN_${slug}"
849
849
  [ -n "${!varname}" ] && { echo "${!varname}"; return; }
850
+ # Preferred path: the single secrets chokepoint, which owns the one-store rule
851
+ # and the surface ladder. An agent following this reference must try it before
852
+ # any keychain — a second reader is how one credential ends up in two places.
853
+ local resolver
854
+ for resolver in .claude/skills/lisa-secrets-access/scripts/resolve-secret.mjs \
855
+ .agents/skills/lisa-secrets-access/scripts/resolve-secret.mjs; do
856
+ if [ -f "$resolver" ]; then
857
+ local via_lisa
858
+ via_lisa=$(node "$resolver" get NOTION_API_TOKEN 2>/dev/null) \
859
+ && [ -n "$via_lisa" ] && { echo "$via_lisa"; return; }
860
+ break
861
+ fi
862
+ done
863
+ # Legacy fallback: the OS keychain written by the guided setup flow, for
864
+ # projects with no credentials provider. Reached only when the chokepoint is
865
+ # absent or has no entry.
850
866
  case "$(uname -s)" in
851
867
  Darwin) security find-generic-password -s lisa-notion -a "$workspace" -w 2>/dev/null ;;
852
868
  Linux) command -v secret-tool >/dev/null && \