@codyswann/lisa 4.11.2 → 4.12.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 (123) hide show
  1. package/all/copy-contents/.gitattributes +4 -0
  2. package/dist/core/learnings-merge-driver.d.ts.map +1 -1
  3. package/dist/core/learnings-merge-driver.js +15 -0
  4. package/dist/core/learnings-merge-driver.js.map +1 -1
  5. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  6. package/dist/core/upstream-evidence-manifest.js +30 -9
  7. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  8. package/dist/sync/lifecycle-defaults.d.ts +1 -0
  9. package/dist/sync/lifecycle-defaults.d.ts.map +1 -1
  10. package/dist/sync/lifecycle-defaults.js +1 -0
  11. package/dist/sync/lifecycle-defaults.js.map +1 -1
  12. package/dist/sync/registry.d.ts.map +1 -1
  13. package/dist/sync/registry.js +5 -2
  14. package/dist/sync/registry.js.map +1 -1
  15. package/package.json +2 -1
  16. package/plugins/lisa/.claude-plugin/plugin.json +10 -1
  17. package/plugins/lisa/.codex-plugin/hooks.json +9 -0
  18. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  19. package/plugins/lisa/.codex-plugin/skills/lisa-github-sync/SKILL.md +8 -2
  20. package/plugins/lisa/.codex-plugin/skills/lisa-jira-sync/SKILL.md +1 -1
  21. package/plugins/lisa/.codex-plugin/skills/lisa-linear-sync/SKILL.md +1 -1
  22. package/plugins/lisa/.codex-plugin/skills/lisa-repair-intake/SKILL.md +31 -1
  23. package/plugins/lisa/.codex-plugin/skills/lisa-tracker-sync/SKILL.md +1 -1
  24. package/plugins/lisa/hooks/failure-signature-index.mjs +482 -0
  25. package/plugins/lisa/hooks/failure-signature-index.sh +26 -0
  26. package/plugins/lisa/rules/eager/leaf-only-lifecycle.md +11 -0
  27. package/plugins/lisa/rules/reference/config-resolution.md +7 -1
  28. package/plugins/lisa/rules/reference/leaf-only-lifecycle.md +21 -0
  29. package/plugins/lisa/scripts/rollup-blocker-classification.mjs +592 -0
  30. package/plugins/lisa/skills/lisa-github-sync/SKILL.md +8 -2
  31. package/plugins/lisa/skills/lisa-jira-sync/SKILL.md +1 -1
  32. package/plugins/lisa/skills/lisa-linear-sync/SKILL.md +1 -1
  33. package/plugins/lisa/skills/lisa-repair-intake/SKILL.md +31 -1
  34. package/plugins/lisa/skills/lisa-tracker-sync/SKILL.md +1 -1
  35. package/plugins/lisa-agy/plugin.json +1 -1
  36. package/plugins/lisa-agy/scripts/rollup-blocker-classification.mjs +592 -0
  37. package/plugins/lisa-agy/skills/lisa-github-sync/SKILL.md +8 -2
  38. package/plugins/lisa-agy/skills/lisa-jira-sync/SKILL.md +1 -1
  39. package/plugins/lisa-agy/skills/lisa-linear-sync/SKILL.md +1 -1
  40. package/plugins/lisa-agy/skills/lisa-repair-intake/SKILL.md +31 -1
  41. package/plugins/lisa-agy/skills/lisa-tracker-sync/SKILL.md +1 -1
  42. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  45. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-copilot/.claude-plugin/plugin.json +10 -1
  48. package/plugins/lisa-copilot/hooks/failure-signature-index.mjs +482 -0
  49. package/plugins/lisa-copilot/hooks/failure-signature-index.sh +26 -0
  50. package/plugins/lisa-copilot/rules/eager/leaf-only-lifecycle.md +11 -0
  51. package/plugins/lisa-copilot/rules/reference/config-resolution.md +7 -1
  52. package/plugins/lisa-copilot/rules/reference/leaf-only-lifecycle.md +21 -0
  53. package/plugins/lisa-copilot/scripts/rollup-blocker-classification.mjs +592 -0
  54. package/plugins/lisa-copilot/skills/lisa-github-sync/SKILL.md +8 -2
  55. package/plugins/lisa-copilot/skills/lisa-jira-sync/SKILL.md +1 -1
  56. package/plugins/lisa-copilot/skills/lisa-linear-sync/SKILL.md +1 -1
  57. package/plugins/lisa-copilot/skills/lisa-repair-intake/SKILL.md +31 -1
  58. package/plugins/lisa-copilot/skills/lisa-tracker-sync/SKILL.md +1 -1
  59. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-cursor/hooks/failure-signature-index.mjs +482 -0
  61. package/plugins/lisa-cursor/hooks/failure-signature-index.sh +26 -0
  62. package/plugins/lisa-cursor/hooks/hooks.json +4 -0
  63. package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +7 -1
  64. package/plugins/lisa-cursor/rules/leaf-only-lifecycle-reference.mdc +21 -0
  65. package/plugins/lisa-cursor/rules/leaf-only-lifecycle.mdc +11 -0
  66. package/plugins/lisa-cursor/scripts/rollup-blocker-classification.mjs +592 -0
  67. package/plugins/lisa-cursor/skills/lisa-github-sync/SKILL.md +8 -2
  68. package/plugins/lisa-cursor/skills/lisa-jira-sync/SKILL.md +1 -1
  69. package/plugins/lisa-cursor/skills/lisa-linear-sync/SKILL.md +1 -1
  70. package/plugins/lisa-cursor/skills/lisa-repair-intake/SKILL.md +31 -1
  71. package/plugins/lisa-cursor/skills/lisa-tracker-sync/SKILL.md +1 -1
  72. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  74. package/plugins/lisa-expo-agy/plugin.json +1 -1
  75. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  79. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  80. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  84. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  85. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  89. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  90. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  94. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  95. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  99. package/plugins/lisa-rails-agy/plugin.json +1 -1
  100. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  103. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  104. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  105. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  106. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  107. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  108. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  109. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  110. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  111. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  112. package/plugins/src/base/.claude-plugin/plugin.json +10 -1
  113. package/plugins/src/base/hooks/failure-signature-index.mjs +482 -0
  114. package/plugins/src/base/hooks/failure-signature-index.sh +26 -0
  115. package/plugins/src/base/rules/eager/leaf-only-lifecycle.md +11 -0
  116. package/plugins/src/base/rules/reference/config-resolution.md +7 -1
  117. package/plugins/src/base/rules/reference/leaf-only-lifecycle.md +21 -0
  118. package/plugins/src/base/scripts/rollup-blocker-classification.mjs +592 -0
  119. package/plugins/src/base/skills/lisa-github-sync/SKILL.md +8 -2
  120. package/plugins/src/base/skills/lisa-jira-sync/SKILL.md +1 -1
  121. package/plugins/src/base/skills/lisa-linear-sync/SKILL.md +1 -1
  122. package/plugins/src/base/skills/lisa-repair-intake/SKILL.md +31 -1
  123. package/plugins/src/base/skills/lisa-tracker-sync/SKILL.md +1 -1
@@ -687,11 +687,41 @@ left in a status it should not carry, including a stale build-ready `ready`).
687
687
  them (e.g. all `On Stg` → `On Stg`); else any child started → `claimed`; else unchanged.
688
688
  Optional / won't-do / not-planned children are terminal-but-dropped and do not hold the parent
689
689
  open.
690
+ 2a. **When the derived state is `blocked`, classify the holds before writing anything.** A bare
691
+ `blocked` on a container is a single bit, and it cannot tell a hold that waits on an external
692
+ event from one that waits on a person rewriting an acceptance criterion — the second never
693
+ clears on its own, so rendering them alike lets it accumulate silently (`leaf-only-lifecycle` →
694
+ **Classifying a hold**; #3045). Feed the resolved child graph to the shared classifier:
695
+
696
+ ```bash
697
+ ROLLUP_DIR="$(mktemp -d)"
698
+ # children[]: { ref, state, labels[], blockedBy[{ref, open}], children[] } — the graph
699
+ # step 1 already resolved, nested as deep as it was read. The script probes nothing.
700
+ jq -n --slurpfile g "$ROLLUP_DIR/graph.json" \
701
+ '{container: $g[0].container, children: $g[0].children, readError: $g[0].readError}' \
702
+ > "$ROLLUP_DIR/input.json"
703
+
704
+ node "${CLAUDE_PLUGIN_ROOT}/scripts/rollup-blocker-classification.mjs" \
705
+ --input="$ROLLUP_DIR/input.json" --since="<last recorded fingerprint>"
706
+ ```
707
+
708
+ It **exits non-zero and classifies nothing** when the tracker could not be read, the container
709
+ has no children, or no child was readable. That is a repair failure to report, never an
710
+ all-clear: do not fall through to "no blocked children" on a non-zero exit. On success its
711
+ report names, per class, the blocking leaf, the path to it, and **who must act** — those lines
712
+ are what goes in the rollup note, verbatim. Never set the `spec_defect` marker yourself and
713
+ never infer a class from prose; a hold with nothing recorded classifies `unknown`, and the
714
+ report already asks a person to decide.
715
+
690
716
  3. **If the derived state differs from the parent's current state, apply it** via the vendor's
691
717
  lifecycle write (JIRA transition, GitHub/Linear label swap keeping exactly one `status:*`),
692
718
  removing any conflicting stale build lifecycle role — **including a stale `ready`** the parent
693
719
  should never carry. Post an idempotent `[lisa-repair-intake]` rollup note naming the derived
694
- state and the child tally (honor the backoff window + fingerprint).
720
+ state and the child tally (honor the backoff window + fingerprint). **When the derived state is
721
+ `blocked`, the note carries the classifier's per-class report** — the blocking leaf, its path,
722
+ and its actor — so an operator never descends the tree by hand to find out which item and which
723
+ kind. Use the classifier's `change.summary` as the dedupe test: an unchanged verdict has nothing
724
+ new to say and the note is **not** re-posted.
695
725
  4. **Perform native closure only at the true terminal `done`.** When — and only when — the derived
696
726
  env is the production/terminal value, finalize through the provider-native mechanism (GitHub
697
727
  `gh issue close --reason completed`, Linear move to Done state, JIRA resolved/closed verified at
@@ -38,7 +38,7 @@ The state machine (first match wins, evaluated over the **required** leaves only
38
38
  | else any leaf has **started** (claimed or in review, or shipped while a sibling has not) | active / in-progress | `claimed` (or `review` where supported) |
39
39
  | else (leaves exist, none started) | unchanged | — |
40
40
 
41
- - **Blocked dominates** — one blocked leaf surfaces blocked on the parent even while others progress.
41
+ - **Blocked dominates** — one blocked leaf surfaces blocked on the parent even while others progress. It never says *which* child or *which kind* of hold; run `scripts/rollup-blocker-classification.mjs` over the resolved child graph and carry its per-class report — blocking leaf, path, and who must act — into the rollup note. A non-zero exit means it classified nothing; that is a failure to report, never an all-clear. See `leaf-only-lifecycle` → **Classifying a hold**.
42
42
  - **Least-advanced env wins** — a parent reaches an env only once all required leaves have reached at least that env (all `On Stg` → `On Stg`; mixed dev/staging → the dev value). Native terminal closure fires only at the production `done`, never at an intermediate env.
43
43
  - **The parent never carries `ready`** — `ready` is a human "claim this leaf" signal; rollup only moves a parent between non-ready container states.
44
44
  - **Rollup is recursive** — an Epic rolls up from its Stories, each of which rolls up from its own leaves. Evaluate bottom-up.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.11.2",
3
+ "version": "4.12.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": "4.11.2",
3
+ "version": "4.12.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": "4.11.2",
3
+ "version": "4.12.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": "4.11.2",
3
+ "version": "4.12.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": "4.11.2",
3
+ "version": "4.12.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": "4.11.2",
3
+ "version": "4.12.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -27,6 +27,15 @@
27
27
  }
28
28
  ]
29
29
  },
30
+ {
31
+ "matcher": "Bash",
32
+ "hooks": [
33
+ {
34
+ "type": "command",
35
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/failure-signature-index.sh"
36
+ }
37
+ ]
38
+ },
30
39
  {
31
40
  "matcher": "Edit|Write|NotebookEdit|Bash",
32
41
  "hooks": [
@@ -0,0 +1,482 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Make a hazard reachable from its EFFECT, not only from its cause.
4
+ *
5
+ * ## The defect this exists against
6
+ *
7
+ * A hazard recorded in a comment beside the code that causes it is invisible to
8
+ * the person who meets its effect. They search where it *surfaced*; the note
9
+ * lives where it *originates*, in a different file owned by a different
10
+ * concern. Knowing to open that file requires already knowing the cause, which
11
+ * is the one thing they do not have. Measured on one hazard in this repository:
12
+ * three independent discoveries, two of them already correctly documented, and
13
+ * the third still cost a wrong hypothesis carried for hours plus instrumentation
14
+ * written from scratch to measure what was already written down
15
+ * (CodySwannGT/lisa#3054, CodySwannGT/lisa#3060, CodySwannGT/lisa#3061).
16
+ *
17
+ * A fourth sighting is the same shape one turn further on: the hazard was not
18
+ * merely documented but GUARDED, and the guard was equally unreachable, so the
19
+ * proposed remedy was a second control for a condition one control already
20
+ * covered. Two checks for one condition, drifting apart, each making the other
21
+ * look redundant — worse than the wasted diagnosis. Hence `guard` on an entry.
22
+ *
23
+ * ## Why an index and not more prose
24
+ *
25
+ * The natural fix — "search harder", or "record cross-cutting hazards
26
+ * centrally" — is a retrieval strategy that requires already knowing the
27
+ * answer, and this repository's own measurement is that prose rules land at
28
+ * roughly zero adoption while executable controls land at 100%.
29
+ *
30
+ * So this stores no knowledge. Every entry is a **routing table row**: the text
31
+ * a person actually sees, and a pointer to the record that already explains it.
32
+ * The records are not moved, copied, or rewritten — they stay next to the cause,
33
+ * where they are correct and useful to the reader who is already in that file.
34
+ *
35
+ * ## How it gets read
36
+ *
37
+ * By the person standing in the symptom, without them going to look:
38
+ *
39
+ * 1. `--hook` runs as a PostToolUse hook on every Bash result. When a
40
+ * command's output matches a signature, the notice arrives attached to the
41
+ * failure itself. Nobody has to know this file exists.
42
+ * 2. `--match` accepts any transcript on stdin, so a gate runner or a person
43
+ * can ask the same question of any output.
44
+ *
45
+ * That is deliberately the whole reader set. An index with a browsing surface
46
+ * is an index nobody reads.
47
+ *
48
+ * ## Why it cannot rot into a decoration
49
+ *
50
+ * `--check` refuses three ways, and each one is a way this could otherwise
51
+ * report all-clear while indexing nothing:
52
+ *
53
+ * - an entry whose cited record no longer contains its anchor — the pointer
54
+ * rotted, which is exactly what happened to the reference that named
55
+ * `src/cli/doctor-learnings-merge-driver.ts` before it was renamed;
56
+ * - an entry whose `signature` does not match its own recorded `sample` — a
57
+ * regex typo silently disables an entry, the same way a malformed ERE
58
+ * silently disabled a project safety rule in `parity-safety-net.sh`;
59
+ * - an index that resolves ZERO entries — an empty routing table is a broken
60
+ * one, never a clean one.
61
+ *
62
+ * ## What deliberately does NOT belong here
63
+ *
64
+ * A hazard with no machine-recognisable symptom. `grep` answering three ways so
65
+ * a `printf | grep -q` guard permits a catastrophic delete
66
+ * (CodySwannGT/lisa#3054, CodySwannGT/lisa#3188) has no signature at all — its
67
+ * symptom is silence. There is nothing for this to match, and an entry
68
+ * pretending otherwise would be a row that can never fire. Such hazards belong
69
+ * on the learnings ladder as executable controls, not here.
70
+ *
71
+ * The ledger at `.lisa/PROJECT_LEARNINGS.md` is also not this. It is capped at
72
+ * twenty entries and rotates by design; a routing table that evicts rows is a
73
+ * routing table that stops routing.
74
+ * @module failure-signature-index
75
+ */
76
+ import { execFileSync } from "node:child_process";
77
+ import * as fs from "node:fs";
78
+ import * as path from "node:path";
79
+ import { fileURLToPath } from "node:url";
80
+
81
+ /** Repository-root filename holding the index. */
82
+ export const INDEX_FILENAME = "failure-signatures.json";
83
+
84
+ /**
85
+ * Longest transcript tail examined, in bytes.
86
+ *
87
+ * Bounded because this parses arbitrary command output inside a hook on every
88
+ * Bash call. The tail rather than the head: a build that fails after printing
89
+ * ten megabytes of progress puts the sentence that matters last.
90
+ */
91
+ const MAX_SCAN_BYTES = 512 * 1024;
92
+
93
+ /** Most entries reported for one output, so a match is a notice and not a wall. */
94
+ const MAX_REPORTED = 2;
95
+
96
+ /** Fields every entry must carry, with a non-empty string in each. */
97
+ const REQUIRED_TEXT_FIELDS = Object.freeze([
98
+ "id",
99
+ "symptom",
100
+ "sample",
101
+ "signature",
102
+ "cause",
103
+ ]);
104
+
105
+ /**
106
+ * One row of the routing table.
107
+ * @typedef {object} SignatureEntry
108
+ * @property {string} id Stable slug naming the hazard.
109
+ * @property {string} symptom One line: what the person actually sees.
110
+ * @property {string} sample Verbatim excerpt of real output, which `signature`
111
+ * must match — the proof that the row can ever fire.
112
+ * @property {string} signature `RegExp` source matched against command output.
113
+ * @property {string} cause One line: what is really happening.
114
+ * @property {readonly {file: string, anchor: string}[]} records Where the
115
+ * explanation already lives. Never moved here; only pointed at.
116
+ * @property {{file: string, anchor: string, name: string}} [guard] The control
117
+ * that already covers this, when one exists, so nobody builds a second.
118
+ */
119
+
120
+ /**
121
+ * The repository root, falling back to the working directory outside git.
122
+ * @param {string} [cwd] Directory to resolve from.
123
+ * @returns {string} An absolute path.
124
+ */
125
+ export function repoRoot(cwd = process.cwd()) {
126
+ try {
127
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
128
+ cwd,
129
+ encoding: "utf8",
130
+ stdio: ["ignore", "pipe", "ignore"],
131
+ }).trim();
132
+ } catch {
133
+ return cwd;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Read and parse the index.
139
+ * @param {string} root Repository root.
140
+ * @returns {{entries: readonly SignatureEntry[], error: string|null}} The rows,
141
+ * or the reason there are none.
142
+ */
143
+ export function loadIndex(root) {
144
+ const file = path.join(root, INDEX_FILENAME);
145
+ if (!fs.existsSync(file)) {
146
+ return { entries: [], error: `${INDEX_FILENAME} does not exist` };
147
+ }
148
+ try {
149
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
150
+ const entries = Array.isArray(parsed) ? parsed : parsed?.entries;
151
+ if (!Array.isArray(entries)) {
152
+ return { entries: [], error: `${INDEX_FILENAME} has no entries array` };
153
+ }
154
+ return { entries, error: null };
155
+ } catch (cause) {
156
+ return {
157
+ entries: [],
158
+ error: `${INDEX_FILENAME} is not valid JSON: ${cause}`,
159
+ };
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Compile an entry's signature.
165
+ * @param {SignatureEntry} entry The row.
166
+ * @returns {RegExp|null} The pattern, or null when it does not compile.
167
+ */
168
+ function compile(entry) {
169
+ try {
170
+ return new RegExp(entry.signature);
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * The 1-based line an anchor sits on.
178
+ * @param {string} root Repository root.
179
+ * @param {{file: string, anchor: string}} record The pointer.
180
+ * @returns {number|null} The line, or null when the anchor is not present.
181
+ */
182
+ export function anchorLine(root, record) {
183
+ const file = path.join(root, record.file);
184
+ if (!fs.existsSync(file)) return null;
185
+ const lines = fs.readFileSync(file, "utf8").split("\n");
186
+ const found = lines.findIndex(line => line.includes(record.anchor));
187
+ return found === -1 ? null : found + 1;
188
+ }
189
+
190
+ /**
191
+ * Every complaint one entry earns.
192
+ * @param {SignatureEntry} entry The row.
193
+ * @param {string} root Repository root.
194
+ * @returns {string[]} Human-readable problems, empty when the row resolves.
195
+ */
196
+ function entryProblems(entry, root) {
197
+ const label = entry?.id ?? "<entry with no id>";
198
+ const missing = REQUIRED_TEXT_FIELDS.filter(
199
+ field => typeof entry?.[field] !== "string" || entry[field].length === 0
200
+ ).map(field => `${label}: "${field}" is missing or empty`);
201
+ const pattern = compile(entry ?? {});
202
+ const patternProblems =
203
+ pattern === null
204
+ ? [`${label}: "signature" is not a valid regular expression`]
205
+ : pattern.test(String(entry.sample ?? ""))
206
+ ? []
207
+ : [
208
+ `${label}: "signature" does not match its own "sample", so this row can never fire`,
209
+ ];
210
+ const records = Array.isArray(entry?.records) ? entry.records : [];
211
+ const recordProblems =
212
+ records.length === 0
213
+ ? [`${label}: has no "records", so it points at no existing explanation`]
214
+ : records
215
+ .filter(record => anchorLine(root, record) === null)
216
+ .map(
217
+ record =>
218
+ `${label}: record ${record.file} no longer contains "${record.anchor}" — the pointer rotted`
219
+ );
220
+ const guardProblems =
221
+ entry?.guard && anchorLine(root, entry.guard) === null
222
+ ? [
223
+ `${label}: guard ${entry.guard.file} no longer contains "${entry.guard.anchor}" — the pointer rotted`,
224
+ ]
225
+ : [];
226
+ return [...missing, ...patternProblems, ...recordProblems, ...guardProblems];
227
+ }
228
+
229
+ /**
230
+ * Check every row, and refuse an index that routes nothing.
231
+ * @param {readonly SignatureEntry[]} entries The rows.
232
+ * @param {string} root Repository root.
233
+ * @returns {{problems: string[], resolved: number}} What is wrong, and how many
234
+ * rows survived.
235
+ */
236
+ export function validateIndex(entries, root) {
237
+ const duplicated = entries
238
+ .map(entry => entry?.id)
239
+ .filter((id, at, all) => all.indexOf(id) !== at)
240
+ .map(id => `${id}: duplicate id`);
241
+ const perEntry = entries.flatMap(entry => entryProblems(entry, root));
242
+ const resolved = entries.filter(
243
+ entry => entryProblems(entry, root).length === 0
244
+ ).length;
245
+ const empty =
246
+ resolved === 0
247
+ ? [
248
+ `${INDEX_FILENAME} resolves ZERO entries — an index that indexes nothing is broken, not clean`,
249
+ ]
250
+ : [];
251
+ return {
252
+ problems: [...duplicated, ...perEntry, ...empty],
253
+ resolved,
254
+ };
255
+ }
256
+
257
+ /**
258
+ * The rows whose signature appears in some output.
259
+ * @param {string} text Command output.
260
+ * @param {readonly SignatureEntry[]} entries The rows.
261
+ * @returns {SignatureEntry[]} Matches, in index order, capped.
262
+ */
263
+ export function matchEntries(text, entries) {
264
+ const scanned =
265
+ text.length > MAX_SCAN_BYTES ? text.slice(-MAX_SCAN_BYTES) : text;
266
+ return entries
267
+ .filter(entry => {
268
+ const pattern = compile(entry);
269
+ return pattern !== null && pattern.test(scanned);
270
+ })
271
+ .slice(0, MAX_REPORTED);
272
+ }
273
+
274
+ /**
275
+ * The notice a matched row prints.
276
+ *
277
+ * Worded to stop a rediscovery rather than to start one: it names the cause
278
+ * outright, cites the record with a line number so it is one click away, and
279
+ * says explicitly that the diagnosis is already done.
280
+ * @param {SignatureEntry} entry The matched row.
281
+ * @param {string} root Repository root.
282
+ * @returns {string} An operator-readable block.
283
+ */
284
+ export function formatNotice(entry, root) {
285
+ const records = entry.records.map(record => {
286
+ const line = anchorLine(root, record);
287
+ return ` - ${record.file}${line === null ? "" : `:${line}`}`;
288
+ });
289
+ const guard =
290
+ entry.guard === undefined
291
+ ? []
292
+ : [
293
+ ` ALREADY GUARDED by ${entry.guard.name} (${entry.guard.file}) — do NOT build a second control for this.`,
294
+ ];
295
+ return [
296
+ ` ${entry.id}`,
297
+ ` Symptom: ${entry.symptom}`,
298
+ ` Cause: ${entry.cause}`,
299
+ ` This is already written down, next to the cause:`,
300
+ ...records,
301
+ ...guard,
302
+ ` Read those before diagnosing this yourself.`,
303
+ ].join("\n");
304
+ }
305
+
306
+ /**
307
+ * The whole report for one output.
308
+ * @param {readonly SignatureEntry[]} matched Rows that fired.
309
+ * @param {string} root Repository root.
310
+ * @returns {string} The report, empty when nothing fired.
311
+ */
312
+ export function formatReport(matched, root) {
313
+ if (matched.length === 0) return "";
314
+ return [
315
+ "Lisa failure-signature index — this output matches a KNOWN hazard whose",
316
+ "explanation already exists in this repository:",
317
+ "",
318
+ ...matched.map(entry => formatNotice(entry, root)),
319
+ ].join("\n");
320
+ }
321
+
322
+ /**
323
+ * Everything a Bash PostToolUse payload said, as one string.
324
+ *
325
+ * Shallow and string-only on purpose: the payload shape differs between agents
326
+ * and between tools, and a matcher that walks arbitrary nesting would start
327
+ * matching on file contents a command merely read.
328
+ * @param {object} payload The parsed hook stdin.
329
+ * @returns {string} Output text, empty when there is none.
330
+ */
331
+ export function outputText(payload) {
332
+ const response = payload?.tool_response;
333
+ if (typeof response === "string") return response;
334
+ return ["stdout", "stderr", "error", "output"]
335
+ .map(field => response?.[field])
336
+ .filter(value => typeof value === "string")
337
+ .join("\n");
338
+ }
339
+
340
+ /**
341
+ * Whether a command is about the index itself.
342
+ *
343
+ * A `cat failure-signatures.json` contains every sample by construction, so
344
+ * without this the index fires on anyone reading it. One noisy false positive
345
+ * teaches people to ignore the notice, which is the whole failure mode.
346
+ * @param {object} payload The parsed hook stdin.
347
+ * @returns {boolean} Whether to stay silent.
348
+ */
349
+ export function isSelfReferential(payload) {
350
+ const command = payload?.tool_input?.command;
351
+ return typeof command === "string" && command.includes("failure-signature");
352
+ }
353
+
354
+ /**
355
+ * Read all of stdin.
356
+ * @returns {Promise<string>} What was piped in.
357
+ */
358
+ async function readStdin() {
359
+ const chunks = [];
360
+ for await (const chunk of process.stdin) chunks.push(chunk);
361
+ return Buffer.concat(chunks).toString("utf8");
362
+ }
363
+
364
+ /**
365
+ * The `--check` mode: prove the index still routes.
366
+ * @param {string} root Repository root.
367
+ * @returns {number} An exit code.
368
+ */
369
+ export function runCheck(root) {
370
+ const { entries, error } = loadIndex(root);
371
+ if (error !== null) {
372
+ process.stderr.write(`failure-signature index: ${error}\n`);
373
+ return 1;
374
+ }
375
+ const { problems, resolved } = validateIndex(entries, root);
376
+ if (problems.length > 0) {
377
+ process.stderr.write(
378
+ `failure-signature index: ${problems.length} problem(s)\n` +
379
+ problems.map(problem => ` - ${problem}\n`).join("")
380
+ );
381
+ return 1;
382
+ }
383
+ process.stdout.write(
384
+ `failure-signature index: ${resolved} entr${resolved === 1 ? "y resolves" : "ies resolve"} to a live record\n`
385
+ );
386
+ return 0;
387
+ }
388
+
389
+ /**
390
+ * The `--hook` mode: teach at the moment of failure.
391
+ *
392
+ * Advisory by construction — always exit 0. The hazard has already happened;
393
+ * blocking the agent here would punish it for meeting a symptom.
394
+ * @param {string} raw The hook's stdin.
395
+ * @param {string} root Repository root.
396
+ * @returns {number} An exit code.
397
+ */
398
+ export function runHook(raw, root) {
399
+ const payload = (() => {
400
+ try {
401
+ return JSON.parse(raw);
402
+ } catch {
403
+ return null;
404
+ }
405
+ })();
406
+ if (payload === null || isSelfReferential(payload)) return 0;
407
+ const { entries } = loadIndex(root);
408
+ const report = formatReport(matchEntries(outputText(payload), entries), root);
409
+ if (report.length === 0) return 0;
410
+ process.stderr.write(`${report}\n`);
411
+ process.stdout.write(
412
+ `${JSON.stringify({
413
+ hookSpecificOutput: {
414
+ hookEventName: "PostToolUse",
415
+ additionalContext: report,
416
+ },
417
+ })}\n`
418
+ );
419
+ return 0;
420
+ }
421
+
422
+ /**
423
+ * The `--match` mode: ask the same question of any transcript.
424
+ * @param {string} text The transcript.
425
+ * @param {string} root Repository root.
426
+ * @returns {number} An exit code.
427
+ */
428
+ export function runMatch(text, root) {
429
+ const { entries } = loadIndex(root);
430
+ const report = formatReport(matchEntries(text, entries), root);
431
+ if (report.length === 0) return 0;
432
+ process.stdout.write(`${report}\n`);
433
+ return 0;
434
+ }
435
+
436
+ /**
437
+ * Dispatch on the requested mode.
438
+ * @param {readonly string[]} argv Command-line arguments.
439
+ * @param {string} raw Piped stdin, when the mode needs it.
440
+ * @param {string} root Repository root.
441
+ * @returns {number} An exit code.
442
+ */
443
+ export function main(argv, raw, root) {
444
+ if (argv.includes("--hook")) return runHook(raw, root);
445
+ if (argv.includes("--match")) return runMatch(raw, root);
446
+ return runCheck(root);
447
+ }
448
+
449
+ /**
450
+ * Whether this module is the process entry point.
451
+ *
452
+ * Both sides are realpath'd. Comparing `fileURLToPath(import.meta.url)` against
453
+ * `path.resolve(process.argv[1])` disagrees whenever the checkout is reached
454
+ * through a symlink — a git worktree, or any `/tmp` path on macOS, `/tmp` being
455
+ * a symlink to `/private/tmp` — because `import.meta.url` is the REAL path
456
+ * while `argv[1]` is whatever the caller typed. For a CHECK that is a
457
+ * fail-OPEN: no output, exit 0, the npm script "succeeds", and the gate
458
+ * silently stops having an opinion.
459
+ *
460
+ * The one implementation lives at `scripts/lib/invoked-as-script.mjs`. This
461
+ * file cannot import it: it is materialized into every plugin payload, where
462
+ * there is no `./lib/` to import from — the same accommodation
463
+ * `threshold-ratchet.mjs` makes, for the same reason.
464
+ * @param {string} moduleUrl The caller's own `import.meta.url`.
465
+ * @param {string | undefined} [argv1] Entry path; defaults to `process.argv[1]`.
466
+ * @returns {boolean} Whether the caller should run its CLI body.
467
+ */
468
+ export function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
469
+ if (!argv1) return false;
470
+ try {
471
+ return fs.realpathSync(argv1) === fs.realpathSync(fileURLToPath(moduleUrl));
472
+ } catch {
473
+ return false;
474
+ }
475
+ }
476
+
477
+ if (invokedAsScript(import.meta.url)) {
478
+ const argv = process.argv.slice(2);
479
+ const needsStdin = argv.includes("--hook") || argv.includes("--match");
480
+ const raw = needsStdin ? await readStdin() : "";
481
+ process.exitCode = main(argv, raw, repoRoot());
482
+ }
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bash
2
+ # PostToolUse hook for Bash: the failure-signature index.
3
+ #
4
+ # A hazard recorded beside its cause is unreachable from its effect — the
5
+ # searcher does not yet know which concern owns the cause, so "search harder"
6
+ # is a retrieval strategy that requires already knowing the answer
7
+ # (CodySwannGT/lisa#3061). This hook closes that gap from the only direction
8
+ # that works: it reads the output of the command that just ran and, when that
9
+ # output matches a known hazard, names the record that already explains it.
10
+ #
11
+ # Advisory by construction — ALWAYS exit 0. The hazard has already happened;
12
+ # blocking an agent here would punish it for meeting a symptom. Every
13
+ # infrastructure gap (no node, no git repo, unreadable stdin, no index) is also
14
+ # exit 0: a broken hook must never wedge a session, and `check:failure-signatures`
15
+ # is what guarantees the index still routes.
16
+ set -uo pipefail
17
+
18
+ input="$(cat)"
19
+
20
+ command -v node >/dev/null 2>&1 || exit 0
21
+ git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
22
+
23
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24
+
25
+ printf '%s' "$input" | node "$script_dir/failure-signature-index.mjs" --hook || true
26
+ exit 0
@@ -26,6 +26,17 @@ Evaluate over the env ladder `in-progress < dev < staging < production` (the ord
26
26
  3. Else any leaf has **started** (claimed/in review, or shipped while a sibling has not) → parent is **in-progress** (`claimed`).
27
27
  4. Else (leaves exist but none started) → parent unchanged.
28
28
 
29
+ **Blocked dominates — but it must say which leaf and which kind.** A parent that rolls up to `blocked` names the blocking leaf (with the path to it) and the **class** of the hold, derived by `rollup-blocker-classification` from recorded signals only:
30
+
31
+ | Class | Recorded signal on the leaf | Who must act |
32
+ |---|---|---|
33
+ | `spec-defect` | the `spec_defect` marker (human-applied only) | a person rewrites the acceptance criteria — nothing external will ever clear it |
34
+ | `human-input` | the `human_needed` marker | a person supplies the input (access, credential, product decision) |
35
+ | `hard-blocker` | an open `is blocked by` link | nobody — it clears when that work closes |
36
+ | `unknown` | none of the above | a person says which kind it is, and records it |
37
+
38
+ Never infer a class from prose, and never auto-apply `spec_defect`: judging a criterion unbuildable is a human call, and `unknown` is the honest answer. A rollup that examined nothing — unreadable tracker, no children, no readable child — **fails**; it never reports not-blocked.
39
+
29
40
  **Blocked dominates.** A parent reaches an env only once all required leaves have reached at least that env. Intermediate-env rollup (`On Dev`/`On Stg`) happens, but native closure fires only at production `done`. Optional/won't-do children do not hold a parent open. Rollup is recursive — bottom-up. The parent never carries `ready`; a container found in `ready` is reconciled by rolling it up from its children.
30
41
 
31
42
  ## Terminal native closure