@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.
- package/all/copy-contents/.gitattributes +4 -0
- package/dist/core/learnings-merge-driver.d.ts.map +1 -1
- package/dist/core/learnings-merge-driver.js +15 -0
- package/dist/core/learnings-merge-driver.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +30 -9
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/sync/lifecycle-defaults.d.ts +1 -0
- package/dist/sync/lifecycle-defaults.d.ts.map +1 -1
- package/dist/sync/lifecycle-defaults.js +1 -0
- package/dist/sync/lifecycle-defaults.js.map +1 -1
- package/dist/sync/registry.d.ts.map +1 -1
- package/dist/sync/registry.js +5 -2
- package/dist/sync/registry.js.map +1 -1
- package/package.json +2 -1
- package/plugins/lisa/.claude-plugin/plugin.json +10 -1
- package/plugins/lisa/.codex-plugin/hooks.json +9 -0
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-github-sync/SKILL.md +8 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-jira-sync/SKILL.md +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-linear-sync/SKILL.md +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-repair-intake/SKILL.md +31 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-tracker-sync/SKILL.md +1 -1
- package/plugins/lisa/hooks/failure-signature-index.mjs +482 -0
- package/plugins/lisa/hooks/failure-signature-index.sh +26 -0
- package/plugins/lisa/rules/eager/leaf-only-lifecycle.md +11 -0
- package/plugins/lisa/rules/reference/config-resolution.md +7 -1
- package/plugins/lisa/rules/reference/leaf-only-lifecycle.md +21 -0
- package/plugins/lisa/scripts/rollup-blocker-classification.mjs +592 -0
- package/plugins/lisa/skills/lisa-github-sync/SKILL.md +8 -2
- package/plugins/lisa/skills/lisa-jira-sync/SKILL.md +1 -1
- package/plugins/lisa/skills/lisa-linear-sync/SKILL.md +1 -1
- package/plugins/lisa/skills/lisa-repair-intake/SKILL.md +31 -1
- package/plugins/lisa/skills/lisa-tracker-sync/SKILL.md +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/scripts/rollup-blocker-classification.mjs +592 -0
- package/plugins/lisa-agy/skills/lisa-github-sync/SKILL.md +8 -2
- package/plugins/lisa-agy/skills/lisa-jira-sync/SKILL.md +1 -1
- package/plugins/lisa-agy/skills/lisa-linear-sync/SKILL.md +1 -1
- package/plugins/lisa-agy/skills/lisa-repair-intake/SKILL.md +31 -1
- package/plugins/lisa-agy/skills/lisa-tracker-sync/SKILL.md +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +10 -1
- package/plugins/lisa-copilot/hooks/failure-signature-index.mjs +482 -0
- package/plugins/lisa-copilot/hooks/failure-signature-index.sh +26 -0
- package/plugins/lisa-copilot/rules/eager/leaf-only-lifecycle.md +11 -0
- package/plugins/lisa-copilot/rules/reference/config-resolution.md +7 -1
- package/plugins/lisa-copilot/rules/reference/leaf-only-lifecycle.md +21 -0
- package/plugins/lisa-copilot/scripts/rollup-blocker-classification.mjs +592 -0
- package/plugins/lisa-copilot/skills/lisa-github-sync/SKILL.md +8 -2
- package/plugins/lisa-copilot/skills/lisa-jira-sync/SKILL.md +1 -1
- package/plugins/lisa-copilot/skills/lisa-linear-sync/SKILL.md +1 -1
- package/plugins/lisa-copilot/skills/lisa-repair-intake/SKILL.md +31 -1
- package/plugins/lisa-copilot/skills/lisa-tracker-sync/SKILL.md +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/failure-signature-index.mjs +482 -0
- package/plugins/lisa-cursor/hooks/failure-signature-index.sh +26 -0
- package/plugins/lisa-cursor/hooks/hooks.json +4 -0
- package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +7 -1
- package/plugins/lisa-cursor/rules/leaf-only-lifecycle-reference.mdc +21 -0
- package/plugins/lisa-cursor/rules/leaf-only-lifecycle.mdc +11 -0
- package/plugins/lisa-cursor/scripts/rollup-blocker-classification.mjs +592 -0
- package/plugins/lisa-cursor/skills/lisa-github-sync/SKILL.md +8 -2
- package/plugins/lisa-cursor/skills/lisa-jira-sync/SKILL.md +1 -1
- package/plugins/lisa-cursor/skills/lisa-linear-sync/SKILL.md +1 -1
- package/plugins/lisa-cursor/skills/lisa-repair-intake/SKILL.md +31 -1
- package/plugins/lisa-cursor/skills/lisa-tracker-sync/SKILL.md +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/.claude-plugin/plugin.json +10 -1
- package/plugins/src/base/hooks/failure-signature-index.mjs +482 -0
- package/plugins/src/base/hooks/failure-signature-index.sh +26 -0
- package/plugins/src/base/rules/eager/leaf-only-lifecycle.md +11 -0
- package/plugins/src/base/rules/reference/config-resolution.md +7 -1
- package/plugins/src/base/rules/reference/leaf-only-lifecycle.md +21 -0
- package/plugins/src/base/scripts/rollup-blocker-classification.mjs +592 -0
- package/plugins/src/base/skills/lisa-github-sync/SKILL.md +8 -2
- package/plugins/src/base/skills/lisa-jira-sync/SKILL.md +1 -1
- package/plugins/src/base/skills/lisa-linear-sync/SKILL.md +1 -1
- package/plugins/src/base/skills/lisa-repair-intake/SKILL.md +31 -1
- package/plugins/src/base/skills/lisa-tracker-sync/SKILL.md +1 -1
|
@@ -108,7 +108,13 @@ If the `subIssues` field is unavailable (older GHES), fall back to body parentag
|
|
|
108
108
|
| else any child has **started** (`status:in-progress`, or shipped to an env while a sibling has not) | `claimed` | `status:in-progress` |
|
|
109
109
|
| else (children exist, none started) | — | unchanged — parent keeps its non-ready container label |
|
|
110
110
|
|
|
111
|
-
- **Blocked dominates** — a single blocked child surfaces `status:blocked` on the parent even while siblings progress, so a human sees the parent needs attention.
|
|
111
|
+
- **Blocked dominates — and the rollup must say which child and which kind** — a single blocked child surfaces `status:blocked` on the parent even while siblings progress, so a human sees the parent needs attention. `status:blocked` alone is a single bit and cannot tell a child waiting on an external event from one whose acceptance criteria are unbuildable; the second never clears on its own. Run the shared classifier over the resolved child graph before writing the label or the comment:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
node "${CLAUDE_PLUGIN_ROOT}/scripts/rollup-blocker-classification.mjs" --input=<graph.json>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Its report names, per class, the blocking leaf, the path to it (`#1495 -> #1515 -> #1547`) and **who must act** — that text goes in the rollup comment verbatim. It exits non-zero when it classified nothing (unreadable graph, no children, no readable child): treat that as a rollup that could not be derived and fall through to the **Safe default** below, never to "no blocked children". Never infer a class from prose and never apply the `spec_defect` marker from a flow — see `leaf-only-lifecycle` → **Classifying a hold**.
|
|
112
118
|
- **Least-advanced env wins** — the parent reaches an env only when every required child has reached at least that env; it never sits ahead of its laggard child. Native closure (`gh issue close --reason completed`) fires only when the resolved env is the production `status:done`, never at `status:on-dev`/`status:on-stg`.
|
|
113
119
|
- **"Required" children only** — a child labelled won't-do / optional does not hold the parent open; only leaves that must ship count toward the env-rollup check.
|
|
114
120
|
- **Recursive** — a parent reaches an env only when its children have all reached at least that env; an Epic reaches it only when its Stories have themselves rolled up to it. Evaluate bottom-up.
|
|
@@ -116,7 +122,7 @@ If the `subIssues` field is unavailable (older GHES), fall back to body parentag
|
|
|
116
122
|
|
|
117
123
|
**Single-environment collapse (this repo).** `.lisa.config.json` `deploy.branches` declares only `production: main`, so the env-keyed `done` resolves to the single label `status:done` — there is no `status:on-dev` / `status:on-stg` and **no dev → staging → prod promotion chain**. Resolve the env rungs via the env-keyed `done` logic in `config-resolution`, but in the single-environment case the only rung is production and it collapses to the one `status:done` value; the rollup never attempts to resolve a dev or staging `done`. Projects that DO have multiple environments keep the env-keyed map and roll the parent up to whichever `done` (including intermediate `status:on-dev`/`status:on-stg`) its leaves have collectively reached.
|
|
118
124
|
|
|
119
|
-
**Apply the derived label** (only when it differs from the parent's current `status:*`): remove the parent's existing `status:*` label and add the derived one, keeping exactly one `status:*` label so the build-queue invariant holds. Post an idempotent `[claude-sync] rollup` comment naming the derived state and the child tally (e.g. `3/4 leaves terminal, 1 blocked → status:blocked`);
|
|
125
|
+
**Apply the derived label** (only when it differs from the parent's current `status:*`): remove the parent's existing `status:*` label and add the derived one, keeping exactly one `status:*` label so the build-queue invariant holds. Post an idempotent `[claude-sync] rollup` comment naming the derived state and the child tally (e.g. `3/4 leaves terminal, 1 blocked → status:blocked`); when the derived state is `status:blocked` the comment also carries the classifier's per-class section, so the blocking child and its actor are one read rather than a descent. Skip the comment if an identical one is already the most recent rollup comment — the classifier's fingerprint is the dedupe key, and an unchanged verdict is not re-posted.
|
|
120
126
|
|
|
121
127
|
```bash
|
|
122
128
|
gh issue edit <parent-number> --repo <org>/<repo> \
|
|
@@ -92,7 +92,7 @@ When invoked with `--rollup`, this skill **derives a parent/container ticket's s
|
|
|
92
92
|
| else any child has **started** (`In Progress` / `Code Review`, or shipped to an env while a sibling has not) | `claimed` | `In Progress` |
|
|
93
93
|
| else (children exist, none started) | — | unchanged — parent keeps its non-ready container status |
|
|
94
94
|
|
|
95
|
-
- **Blocked dominates** — a single blocked child surfaces `Blocked` on the parent even while siblings progress.
|
|
95
|
+
- **Blocked dominates** — a single blocked child surfaces `Blocked` on the parent even while siblings 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**.
|
|
96
96
|
- **Least-advanced env wins** — the parent reaches an env only when every required child has reached at least that env; it never sits ahead of its laggard child. Apply native terminal resolution (the `leaf-only-lifecycle` Terminal native closure) only when the resolved env is the production `Done`, never at `On Dev`/`On Stg`.
|
|
97
97
|
- **"Required" children only** — won't-do / optional children do not hold the parent open.
|
|
98
98
|
- **Recursive** — an Epic reaches an env only when its Stories have themselves rolled up to at least that env; a Story reaches it only when its Sub-tasks have. Evaluate bottom-up.
|
|
@@ -125,7 +125,7 @@ When the caller passes `--rollup`, this skill **derives a parent/container's wor
|
|
|
125
125
|
| else any child has **started** (`In Progress` / `In Review`, or shipped to an env while a sibling has not) | `claimed` | `In Progress` |
|
|
126
126
|
| else (children exist, none started) | — | unchanged — parent keeps its non-ready container state |
|
|
127
127
|
|
|
128
|
-
- **Blocked dominates** — one blocked child surfaces `Blocked` on the parent even while siblings progress.
|
|
128
|
+
- **Blocked dominates** — one blocked child surfaces `Blocked` on the parent even while siblings 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**.
|
|
129
129
|
- **Least-advanced env wins** — the parent reaches an env only when every required child has reached at least that env; it never sits ahead of its laggard child. Native completion (moving the workflow `state` to Done) fires only when the resolved env is the production `Done`, never at `On Dev`/`On Stg`.
|
|
130
130
|
- **"Required" children only** — won't-do / optional (e.g. `Canceled`) children do not hold the parent open.
|
|
131
131
|
- **Recursive** — a Project reaches an env only when its Issues have themselves rolled up to at least that env. Evaluate bottom-up.
|
|
@@ -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.
|
|
@@ -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
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
"command": "${CURSOR_PLUGIN_ROOT}/hooks/shell-write-nudge.sh",
|
|
12
12
|
"matcher": "Bash"
|
|
13
13
|
},
|
|
14
|
+
{
|
|
15
|
+
"command": "${CURSOR_PLUGIN_ROOT}/hooks/failure-signature-index.sh",
|
|
16
|
+
"matcher": "Bash"
|
|
17
|
+
},
|
|
14
18
|
{
|
|
15
19
|
"command": "${CURSOR_PLUGIN_ROOT}/hooks/threshold-ratchet.sh",
|
|
16
20
|
"matcher": "Edit|Write|NotebookEdit|Bash"
|
|
@@ -467,11 +467,12 @@ Every lifecycle skill operates on a fixed set of **roles** (`ready`, `claimed`,
|
|
|
467
467
|
|
|
468
468
|
#### Build markers (additive labels, not lifecycle roles)
|
|
469
469
|
|
|
470
|
-
A **marker** is an additive label applied *alongside* a lifecycle role, not a state the item transitions *to*. Markers carry no rollup or transition semantics — they annotate an item that is already in some role. The build lifecycle defines
|
|
470
|
+
A **marker** is an additive label applied *alongside* a lifecycle role, not a state the item transitions *to*. Markers carry no rollup or transition semantics — they annotate an item that is already in some role, and the rollup reads them to say *which kind* of hold a `blocked` item is (`rollup-blocker-classification`). The build lifecycle defines two markers:
|
|
471
471
|
|
|
472
472
|
| Marker | What it means | JIRA default | GitHub/Linear default |
|
|
473
473
|
|---|---|---|---|
|
|
474
474
|
| `human_needed` | Applied with `blocked` when — **after the agent has drafted every authorable missing section via the `pre-flight-autofill` procedure** — the block still requires a human to confirm the drafted assumptions or supply something no agent can invent: real missing credentials, access/permissions, or an irreducible product/scoping decision. | `Human Needed` (label) | `human-needed` (label) |
|
|
475
|
+
| `spec_defect` | Applied with `blocked` when the thing holding the item is the item's **own specification** — an acceptance criterion that cannot be satisfied as written, a field naming an environment or surface the project does not have, a validation journey demanding something the project cannot produce. **Human-applied only.** No agent sets it, because judging a criterion unbuildable is a product call, and the agent that wrote a bad criterion is the least likely to know it did. | `Spec Defect` (label) | `blocked:spec-defect` (label) |
|
|
475
476
|
|
|
476
477
|
Markers are labels on **every** vendor, Linear included — that is the one place the Linear build lane still touches `linear.labels`.
|
|
477
478
|
|
|
@@ -480,9 +481,14 @@ Resolution keys:
|
|
|
480
481
|
- JIRA: `jira.labels.human_needed` (default `Human Needed`). Applied as a JIRA **label** — not a workflow status — because an item holds exactly one status but any number of labels. The `blocked` status still drives the lifecycle; `human_needed` is the additive marker on top of it.
|
|
481
482
|
- GitHub: `github.labels.build.human_needed` (default `human-needed`). Added next to the `blocked` label.
|
|
482
483
|
- Linear: `linear.labels.build.human_needed` (default `human-needed`). Applied as a Linear **label**, for the same reason as JIRA — an Issue holds exactly one workflow state but any number of labels, so an additive marker cannot be a state. The `blocked` **state** still drives the lifecycle; `human_needed` is the marker on top of it. This is the only build-lane key left in `linear.labels`.
|
|
484
|
+
- `spec_defect` resolves the same way on each vendor — `jira.labels.spec_defect` (default `Spec Defect`), `github.labels.build.spec_defect` and `linear.labels.build.spec_defect` (default `blocked:spec-defect`).
|
|
483
485
|
|
|
484
486
|
**When to apply it.** Apply `human_needed` only when a human must act before the item can move — the pre-flight gate failures that bounce a ticket back to its reporter are exactly this case, but **only after** the agent has run the `pre-flight-autofill` draft-then-block procedure (drafting the authorable gaps — acceptance criteria, validation journey, repository, relationship search, etc. — into the ticket as labeled assumptions). What then remains for the human is to **confirm those assumptions** or supply a genuinely human-only input (real missing credentials, an irreducible product/scoping decision). The marker means "a human must confirm or decide," not "a human must author from scratch."
|
|
485
487
|
|
|
488
|
+
**Why `spec_defect` is separate from `human_needed`.** Both name a hold a person must clear, but they are different asks and the rollup must not merge them. `human_needed` waits on an input from *outside* the item — a credential, an access grant, a product decision — and the item's own text is fine. `spec_defect` waits on the item's text being *rewritten*, and no external event will ever supply that. An item carrying `spec_defect` will sit forever until somebody edits it; that is exactly the class that accumulated silently when the rollup rendered both as plain `blocked` (issue #3045: 32 identical hold comments over six weeks on one Epic). Where both markers are present, `spec_defect` wins — it is the more specific record, and the only one carrying a judgment nothing else can supply.
|
|
489
|
+
|
|
490
|
+
**Nothing infers `spec_defect`.** It is never derived from prose, a title, or a failed gate. A `blocked` item carrying neither marker and no open `is blocked by` link classifies as **`unknown`**, and the rollup says so and asks a person to decide — an honest `unknown` beats a confident wrong answer, and auto-reclassifying would turn a human product call into a guess.
|
|
491
|
+
|
|
486
492
|
**When NOT to apply it.** Do **not** apply `human_needed` to a block that an automated cycle can clear on its own: a block whose `is blocked by` dependency is another tracked ticket that will build and close (for example a `repair-intake`-filed build-ready fix ticket for an unmergeable PR or a failed deploy), or any block waiting only on a retry. Those self-heal; flagging them for a human is noise. If such an item already carries a stale `human_needed` marker, clear it when the block becomes auto-recoverable.
|
|
487
493
|
|
|
488
494
|
The marker is **best-effort and additive**: it never replaces the `blocked` role and never gates a transition. A project that does not define the label name inherits the defaults above; a project that does not want the marker at all can leave the label absent in its tracker, in which case the add is a no-op the agent records and moves on.
|