@codyswann/lisa 4.37.2 → 4.38.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 (127) hide show
  1. package/all/copy-contents/.gitattributes +9 -0
  2. package/all/copy-contents/gitignore +6 -0
  3. package/dist/core/learnings-merge-driver.d.ts +8 -0
  4. package/dist/core/learnings-merge-driver.d.ts.map +1 -1
  5. package/dist/core/learnings-merge-driver.js +17 -0
  6. package/dist/core/learnings-merge-driver.js.map +1 -1
  7. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  8. package/dist/core/lisa-owned-hash-ledger.js +4 -0
  9. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  10. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  11. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  12. package/dist/core/upstream-evidence-manifest.js +64 -11
  13. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  14. package/package.json +6 -4
  15. package/plugins/lisa/.claude-plugin/plugin.json +28 -1
  16. package/plugins/lisa/.codex-plugin/hooks.json +27 -0
  17. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  18. package/plugins/lisa/.codex-plugin/skills/lisa-github-write-issue/SKILL.md +11 -0
  19. package/plugins/lisa/.codex-plugin/skills/lisa-jira-write-ticket/SKILL.md +11 -0
  20. package/plugins/lisa/.codex-plugin/skills/lisa-linear-write-issue/SKILL.md +11 -0
  21. package/plugins/lisa/.codex-plugin/skills/lisa-repair-intake/SKILL.md +30 -2
  22. package/plugins/lisa/hooks/inject-resolved-config.mjs +26 -7
  23. package/plugins/lisa/hooks/withdrawn-rulings.mjs +525 -0
  24. package/plugins/lisa/hooks/withdrawn-rulings.sh +24 -0
  25. package/plugins/lisa/rules/eager/config-resolution.md +3 -1
  26. package/plugins/lisa/rules/eager/withdrawn-rulings.md +69 -0
  27. package/plugins/lisa/rules/reference/config-resolution.md +1 -1
  28. package/plugins/lisa/rules/reference/withdrawn-rulings.md +114 -0
  29. package/plugins/lisa/scripts/intake-blocker-reprobe.mjs +120 -3
  30. package/plugins/lisa/skills/lisa-github-write-issue/SKILL.md +11 -0
  31. package/plugins/lisa/skills/lisa-jira-write-ticket/SKILL.md +11 -0
  32. package/plugins/lisa/skills/lisa-linear-write-issue/SKILL.md +11 -0
  33. package/plugins/lisa/skills/lisa-repair-intake/SKILL.md +30 -2
  34. package/plugins/lisa-agy/plugin.json +1 -1
  35. package/plugins/lisa-agy/scripts/intake-blocker-reprobe.mjs +120 -3
  36. package/plugins/lisa-agy/skills/lisa-github-write-issue/SKILL.md +11 -0
  37. package/plugins/lisa-agy/skills/lisa-jira-write-ticket/SKILL.md +11 -0
  38. package/plugins/lisa-agy/skills/lisa-linear-write-issue/SKILL.md +11 -0
  39. package/plugins/lisa-agy/skills/lisa-repair-intake/SKILL.md +30 -2
  40. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  43. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-copilot/.claude-plugin/plugin.json +19 -1
  46. package/plugins/lisa-copilot/hooks/inject-resolved-config.mjs +26 -7
  47. package/plugins/lisa-copilot/hooks/withdrawn-rulings.mjs +525 -0
  48. package/plugins/lisa-copilot/hooks/withdrawn-rulings.sh +24 -0
  49. package/plugins/lisa-copilot/rules/eager/config-resolution.md +3 -1
  50. package/plugins/lisa-copilot/rules/eager/withdrawn-rulings.md +69 -0
  51. package/plugins/lisa-copilot/rules/reference/config-resolution.md +1 -1
  52. package/plugins/lisa-copilot/rules/reference/withdrawn-rulings.md +114 -0
  53. package/plugins/lisa-copilot/scripts/intake-blocker-reprobe.mjs +120 -3
  54. package/plugins/lisa-copilot/skills/lisa-github-write-issue/SKILL.md +11 -0
  55. package/plugins/lisa-copilot/skills/lisa-jira-write-ticket/SKILL.md +11 -0
  56. package/plugins/lisa-copilot/skills/lisa-linear-write-issue/SKILL.md +11 -0
  57. package/plugins/lisa-copilot/skills/lisa-repair-intake/SKILL.md +30 -2
  58. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-cursor/hooks/hooks.json +10 -0
  60. package/plugins/lisa-cursor/hooks/inject-resolved-config.mjs +26 -7
  61. package/plugins/lisa-cursor/hooks/withdrawn-rulings.mjs +525 -0
  62. package/plugins/lisa-cursor/hooks/withdrawn-rulings.sh +24 -0
  63. package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +1 -1
  64. package/plugins/lisa-cursor/rules/config-resolution.mdc +3 -1
  65. package/plugins/lisa-cursor/rules/withdrawn-rulings-reference.mdc +119 -0
  66. package/plugins/lisa-cursor/rules/withdrawn-rulings.mdc +74 -0
  67. package/plugins/lisa-cursor/scripts/intake-blocker-reprobe.mjs +120 -3
  68. package/plugins/lisa-cursor/skills/lisa-github-write-issue/SKILL.md +11 -0
  69. package/plugins/lisa-cursor/skills/lisa-jira-write-ticket/SKILL.md +11 -0
  70. package/plugins/lisa-cursor/skills/lisa-linear-write-issue/SKILL.md +11 -0
  71. package/plugins/lisa-cursor/skills/lisa-repair-intake/SKILL.md +30 -2
  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 +27 -0
  113. package/plugins/src/base/hooks/inject-resolved-config.mjs +26 -7
  114. package/plugins/src/base/hooks/withdrawn-rulings.mjs +525 -0
  115. package/plugins/src/base/hooks/withdrawn-rulings.sh +24 -0
  116. package/plugins/src/base/rules/eager/config-resolution.md +3 -1
  117. package/plugins/src/base/rules/eager/withdrawn-rulings.md +69 -0
  118. package/plugins/src/base/rules/reference/config-resolution.md +1 -1
  119. package/plugins/src/base/rules/reference/withdrawn-rulings.md +114 -0
  120. package/plugins/src/base/scripts/intake-blocker-reprobe.mjs +120 -3
  121. package/plugins/src/base/skills/lisa-github-write-issue/SKILL.md +11 -0
  122. package/plugins/src/base/skills/lisa-jira-write-ticket/SKILL.md +11 -0
  123. package/plugins/src/base/skills/lisa-linear-write-issue/SKILL.md +11 -0
  124. package/plugins/src/base/skills/lisa-repair-intake/SKILL.md +30 -2
  125. package/scripts/lib/hook-registration-audit.mjs +612 -0
  126. package/scripts/measure-tmpdir-growth.mjs +925 -0
  127. package/typescript/copy-contents/.husky/pre-push +98 -26
@@ -0,0 +1,114 @@
1
+ # Withdrawn Rulings — reference
2
+
3
+ Long-form body for the eager rule `withdrawn-rulings`. Origin: CodySwannGT/lisa#3752.
4
+
5
+ ## The general property
6
+
7
+ A session is a snapshot. Three independent instances of it were measured on one day:
8
+
9
+ 1. **Plugin version.** A session executes the plugin cache directory resolved *when the session
10
+ started*. A session roughly eleven hours old was running one version while the default branch and
11
+ the marketplace clone were two releases ahead — with the newer version already sitting in that
12
+ cache. Not a distribution failure: updating the cache does not help a running session, only a
13
+ restart re-resolves. That session reported an already-fixed defect as live, persuasively, because
14
+ it really had observed the behaviour. It was reporting the truth about a stale binary.
15
+ 2. **Rules injection.** The rule injector reads `rules/eager/*.md` and concatenates them into
16
+ context at SessionStart and SubagentStart. No metadata, no expiry, no filter — a rule withdrawn
17
+ upstream stays in the context of every session that started before the withdrawal, for that
18
+ session's whole life.
19
+ 3. **Shipped versus in effect.** A fix merged to the default branch is not in effect for any session
20
+ already running.
21
+
22
+ **A fix has two facts behind it: that it exists, and that it is in effect.** Testing either one and
23
+ reporting "fixed" is the error. Two agents made opposite halves of that mistake in a single evening,
24
+ neither carelessly, each holding a real measurement.
25
+
26
+ ## The two instances that produced this rule
27
+
28
+ **A correction that was itself wrong, published.** A claim was refuted with specific file-and-line
29
+ evidence. The refutation was propagated to two agents and into a public work-item comment before a
30
+ third agent, reading the source directly, established that the original claim was right and the
31
+ refutation had been produced by grepping a stale checkout. The wrong correction stood for roughly
32
+ forty minutes. Anyone who read that item in the interval holds the wrong version and has no signal
33
+ it changed — the retraction is a later comment, which a reader who has already formed a conclusion
34
+ will not necessarily see.
35
+
36
+ **A workaround circulated, then retracted.** A technique was recommended to three agents, then
37
+ disproved by measurement and withdrawn. The three had no mechanism to learn it was withdrawn.
38
+
39
+ And the technique had **zero observed successes but still read as having worked**: the agent
40
+ performed it, the very next operation was refused, it worked around the failure by other means, and
41
+ later the condition the technique was supposed to create arrived on its own for unrelated reasons.
42
+ Nothing about that sequence looks like a failure from outside.
43
+
44
+ ## Why durable memory is the worst case
45
+
46
+ A superseded finding survives longest exactly where it was best recorded. A claim made in passing
47
+ decays with the conversation; a claim written to a memory file persists and is re-read
48
+ authoritatively by future sessions. **The more responsibly a finding was captured, the more durable
49
+ its error becomes** — and the retraction, being conversational, decays while the claim does not.
50
+
51
+ ## What makes this hard
52
+
53
+ - **A session cannot detect that its own context is stale.** Context completeness is not observable
54
+ from inside. Any fix that asks an agent to assess its own context is the trap, not the remedy.
55
+ - **A retraction is usually not written to any canonical surface.** It is a reply, produced under
56
+ exactly the conditions least likely to bear a chore.
57
+ - **The audience is not enumerable.** Whoever received the original may have relayed it onward, and
58
+ a session that has gone quiet is unreachable by any message.
59
+
60
+ The third point is why the mechanism is a **re-check by the reader**, not a broadcast by the
61
+ publisher. The publisher records once; every live session re-checks on its own.
62
+
63
+ ## The ledger's two tiers
64
+
65
+ | tier | path | who it reaches |
66
+ |---|---|---|
67
+ | repository | `.lisa/WITHDRAWN.jsonl` (committed, union-merged) | every future reader of this project, forever |
68
+ | machine | `${LISA_STATE_HOME:-~/.lisa}/withdrawn-rulings.jsonl` | sibling sessions in **other worktrees on this machine, now** |
69
+
70
+ A withdrawal is written to both by one command; reads are the union, de-duplicated by id. The
71
+ machine tier exists because a committed file cannot reach a sibling agent until it is merged and
72
+ pulled, and the audience that needs the retraction soonest is the one working beside you.
73
+
74
+ JSONL rather than prose: each line is independent, so concurrent appends from many agents do not
75
+ collide and `merge=union` reconstructs the union exactly. A prose ledger would need a bespoke merge
76
+ driver to survive the same traffic.
77
+
78
+ ## How a running session is reached
79
+
80
+ `--session-start` stamps a per-session mark recording the ids the session was born already knowing.
81
+ `--hook` runs after every tool result and announces exactly the entries that appeared **since**,
82
+ once each.
83
+
84
+ The common case is the cheap case: when neither ledger's `size:mtime` has moved, `--hook` returns
85
+ after two `stat` calls without parsing anything. It fires on every tool call in every session and
86
+ every subagent, so anything expensive multiplies.
87
+
88
+ A **missing mark is deliberately silent**. A session with no mark is one whose starting knowledge is
89
+ unknown; announcing every historical withdrawal to it would degrade the mechanism into distrusting
90
+ everything, which is the failure the control case in #3752 exists to prevent.
91
+
92
+ ## Surface parity
93
+
94
+ | surface | session stamp | mid-session announcement | `--list` at the escalation boundary |
95
+ |---|---|---|---|
96
+ | Claude Code | yes — SessionStart and SubagentStart | yes — PostToolUse | yes |
97
+ | Codex | yes, interactive only | yes, interactive only | yes |
98
+ | Cursor | yes — both start events | yes | yes |
99
+ | Copilot | main sessions only — it has no subagent-start event, so a Copilot subagent is stamp-less and therefore silent | yes — `postToolUse` | yes |
100
+ | Antigravity | no — its plugin ships blocking `PreToolUse` guards only, and headless hooks do not fire | no | yes |
101
+ | OpenCode | no — it has no plugin hook runtime, only rules and skills | no | yes |
102
+
103
+ Where a surface cannot carry the automatic half, the eager rule's `--list` instruction is the
104
+ carried half: it is the same pointer shape #3592 shipped, which survives precisely because it does
105
+ not travel inside the thing it points at. `codex exec` runs no plugin hooks at all, so on that
106
+ surface the ledger is a read the agent performs, not one performed for it. Antigravity and OpenCode
107
+ are the same shape, permanently rather than per-invocation: neither carries a session-lifecycle hook
108
+ today, which is the identical accommodation `failure-signature-index` makes on those two surfaces.
109
+
110
+ ## What deliberately does not belong here
111
+
112
+ A disagreement that has not been settled. The ledger is for a claim its own author has **withdrawn**
113
+ — not for a claim someone doubts. A tombstone for a live dispute converts the ledger into an
114
+ argument, and an argument is not something a session can act on in one line.
@@ -38,10 +38,20 @@ function normalizeLabels(labels) {
38
38
  }
39
39
 
40
40
  /**
41
- * @param {{ labels?: unknown, body?: unknown, humanNeededLabel?: unknown }} input
42
- * @returns {boolean}
41
+ * Whether an item is held for a person, on either surface.
42
+ *
43
+ * Exported deliberately, and it is the ONLY answer to that question anywhere in
44
+ * the intake machinery. Every selection and promotion path calls this rather
45
+ * than inspecting labels itself, because the label surface alone is a partial
46
+ * answer: the vendor writers stamp the body marker on a deliberate hold, so an
47
+ * item held exactly as the filing contract instructs carries no label at all.
48
+ * A path that read labels alone judged such an item unheld and promoted it —
49
+ * which is #3805, and is why this is a shared function rather than a shared
50
+ * convention.
51
+ * @param {{ labels?: unknown, body?: unknown, humanNeededLabel?: unknown }} input - Candidate surfaces
52
+ * @returns {boolean} True when a person is holding this item
43
53
  */
44
- function isHumanGated(input) {
54
+ export function isHumanGated(input) {
45
55
  const configured = String(
46
56
  input.humanNeededLabel ?? DEFAULT_HUMAN_NEEDED_LABEL
47
57
  )
@@ -282,6 +292,113 @@ export function planHumanGateReconciliation(input = {}) {
282
292
  };
283
293
  }
284
294
 
295
+ /**
296
+ * Marker on the note the normalization sweep leaves, so a later cycle
297
+ * recognises its own work instead of commenting again.
298
+ *
299
+ * Deliberately NOT the hold marker and deliberately not a superstring of it:
300
+ * `HUMAN_GATE_MARKER` closes with `]`, so neither this nor
301
+ * `HUMAN_GATE_NOTE_MARKER` matches it. A note that read as a hold declaration
302
+ * would make every reported item look held to the next body-reading sweep,
303
+ * including items the report merely names.
304
+ */
305
+ export const NORMALIZATION_HOLD_NOTE_MARKER =
306
+ "<!-- [lisa-human-gate-normalization-held] -->";
307
+
308
+ /**
309
+ * Decide whether an item carrying NO configured lifecycle label may be
310
+ * normalized into a ready lane.
311
+ *
312
+ * This is the promotion path, and it was keyed on labels alone. The configured
313
+ * `human_needed` label is one of the lifecycle labels whose *absence* the sweep
314
+ * enumerates on, so an item held only by the body marker was a member of the
315
+ * swept population by definition and got the build-ready label applied to it.
316
+ * The `[lisa-human-gate]` exclusion existed, in a different and read-only sweep
317
+ * further down the same contract: the one sweep that writes was the one that
318
+ * could not see the marker.
319
+ *
320
+ * Order is load-bearing for the same reason it is in `classifyPreWorkCandidate`:
321
+ * the hold is checked before lane membership and before any classification, so
322
+ * nothing downstream — however confident — can promote an item a person parked.
323
+ *
324
+ * On a match this plans NO write of its own. `planHumanGateReconciliation`
325
+ * turns a match into durable state, so a false positive there latches and every
326
+ * later cycle agrees with the first. The gate test is a plain substring match
327
+ * whose imprecision is tracked separately, so this path refuses and reports
328
+ * read-only instead — the same treatment the ungated-filing sweep already gives
329
+ * a hold it finds.
330
+ * @param {{
331
+ * labels?: unknown
332
+ * body?: unknown
333
+ * humanNeededLabel?: unknown
334
+ * lifecycleLabels?: unknown
335
+ * readyLabel?: unknown
336
+ * }} input - Candidate surfaces plus the configured lifecycle vocabulary
337
+ * @returns {{ normalize: boolean, reason: string, humanGated: boolean, actions: { addReadyLabel: string | null, reportHold: boolean } }} Normalization plan
338
+ */
339
+ export function planLabelNormalization(input = {}) {
340
+ const idle = Object.freeze({ addReadyLabel: null, reportHold: false });
341
+ if (isHumanGated(input)) {
342
+ return {
343
+ normalize: false,
344
+ reason: "human-gate",
345
+ humanGated: true,
346
+ actions: Object.freeze({ addReadyLabel: null, reportHold: true }),
347
+ };
348
+ }
349
+
350
+ const labels = normalizeLabels(input.labels);
351
+ if (normalizeLabels(input.lifecycleLabels).some(l => labels.includes(l))) {
352
+ return {
353
+ normalize: false,
354
+ reason: "already-in-lifecycle",
355
+ humanGated: false,
356
+ actions: idle,
357
+ };
358
+ }
359
+
360
+ const ready = trimmedString(input.readyLabel);
361
+ if (ready.length === 0) {
362
+ return {
363
+ normalize: false,
364
+ reason: "no-ready-label",
365
+ humanGated: false,
366
+ actions: idle,
367
+ };
368
+ }
369
+
370
+ return {
371
+ normalize: true,
372
+ reason: "normalize",
373
+ humanGated: false,
374
+ actions: Object.freeze({ addReadyLabel: ready, reportHold: false }),
375
+ };
376
+ }
377
+
378
+ /**
379
+ * Render the notice for an item the normalization sweep left alone.
380
+ *
381
+ * Distinct from `formatHumanGateNote` because the two say opposite things
382
+ * about what happened: that one reports a lane that WAS changed, this one
383
+ * reports a promotion that was declined and nothing else. Reusing it would tell
384
+ * an operator their item had been moved when it had not.
385
+ * @returns {string} Comment body, carrying the marker that keeps a re-run quiet
386
+ */
387
+ export function formatNormalizationHoldNote() {
388
+ return [
389
+ "**Left alone: a person is holding this**",
390
+ "",
391
+ "- What was found: the description says a person is holding this one, so " +
392
+ "it was not put into the queue that agents work from.",
393
+ "- What changed: nothing at all. It was left exactly as it is, and " +
394
+ "nothing will pick it up on its own.",
395
+ "- To resume it: remove the hold note from the description, then put it " +
396
+ "into the build queue.",
397
+ "",
398
+ NORMALIZATION_HOLD_NOTE_MARKER,
399
+ ].join("\n");
400
+ }
401
+
285
402
  /**
286
403
  * Render the hold notice for a reader who does not know this subsystem.
287
404
  *
@@ -276,6 +276,17 @@ Held for a human product call: <reason>.
276
276
  <!-- [lisa-human-gate] reason=<short-slug> -->
277
277
  ```
278
278
 
279
+ **Stamp both surfaces, not just the marker.** Also apply the configured `human_needed` marker
280
+ label (`github.labels.build.human_needed`, default `human-needed`) to the issue, via `gh issue edit`/`gh issue create --label`. The marker in the body and the label say the same thing,
281
+ and a filing that carries only one of them is a **half-armed gate**: sweeps that read the body
282
+ honour it, and sweeps that read labels do not. That is not hypothetical — the repair sweep that
283
+ normalizes items carrying no lifecycle label keys on the *absence* of `human_needed`, so a
284
+ marker-only hold was its population by construction and got promoted into the build queue (#3805).
285
+ The marker remains the authoritative declaration; the label is what makes the hold visible to a
286
+ person scanning a board and to any path that has not yet been routed through the shared reader.
287
+ If the label does not exist in the tracker, create it, or record that it could not be applied and
288
+ proceed — the marker still holds. Never file the label *instead of* the marker.
289
+
279
290
  If a leaf arrives with `build_ready` omitted or `false` **and** no `human_gate`, do not create it: report the incomplete handoff and name both ways to resolve it (`build_ready: true`, or a `human_gate` reason). Containers are exempt — their state rolls up from children, so they need neither.
280
291
 
281
292
  ## Phase 5.5 — Validate (Pre-write Gate)
@@ -261,6 +261,17 @@ Held for a human product call: <reason>.
261
261
  <!-- [lisa-human-gate] reason=<short-slug> -->
262
262
  ```
263
263
 
264
+ **Stamp both surfaces, not just the marker.** Also apply the configured `human_needed` marker
265
+ label (`jira.labels.human_needed`, default `Human Needed`) to the ticket, via the ticket's `labels` field. The marker in the body and the label say the same thing,
266
+ and a filing that carries only one of them is a **half-armed gate**: sweeps that read the body
267
+ honour it, and sweeps that read labels do not. That is not hypothetical — the repair sweep that
268
+ normalizes items carrying no lifecycle label keys on the *absence* of `human_needed`, so a
269
+ marker-only hold was its population by construction and got promoted into the build queue (#3805).
270
+ The marker remains the authoritative declaration; the label is what makes the hold visible to a
271
+ person scanning a board and to any path that has not yet been routed through the shared reader.
272
+ If the label does not exist in the tracker, create it, or record that it could not be applied and
273
+ proceed — the marker still holds. Never file the label *instead of* the marker.
274
+
264
275
  If a leaf arrives with `build_ready` omitted or `false` **and** no `human_gate`, do not create it: report the incomplete handoff and name both ways to resolve it (`build_ready: true`, or a `human_gate` reason). Containers are exempt — their status rolls up from children, so they need neither.
265
276
 
266
277
  ## Phase 5.5 — Validate (Pre-write Gate)
@@ -280,6 +280,17 @@ Held for a human product call: <reason>.
280
280
  <!-- [lisa-human-gate] reason=<short-slug> -->
281
281
  ```
282
282
 
283
+ **Stamp both surfaces, not just the marker.** Also apply the configured `human_needed` marker
284
+ label (`linear.labels.build.human_needed`, default `human-needed`) to the Issue, via the Issue's labels (create it via `create_issue_label` if absent). The marker in the body and the label say the same thing,
285
+ and a filing that carries only one of them is a **half-armed gate**: sweeps that read the body
286
+ honour it, and sweeps that read labels do not. That is not hypothetical — the repair sweep that
287
+ normalizes items carrying no lifecycle label keys on the *absence* of `human_needed`, so a
288
+ marker-only hold was its population by construction and got promoted into the build queue (#3805).
289
+ The marker remains the authoritative declaration; the label is what makes the hold visible to a
290
+ person scanning a board and to any path that has not yet been routed through the shared reader.
291
+ If the label does not exist in the tracker, create it, or record that it could not be applied and
292
+ proceed — the marker still holds. Never file the label *instead of* the marker.
293
+
283
294
  If a leaf arrives with `build_ready` omitted or `false` **and** no `human_gate`, do not create it: report the incomplete handoff and name both ways to resolve it (`build_ready: true`, or a `human_gate` reason). Containers are exempt — their state rolls up from children, so they need neither.
284
295
 
285
296
  ## Phase 5.5 — Validate (Pre-write Gate)
@@ -951,6 +951,23 @@ with labels like `build-ready`, or with no Lisa status label at all, that are in
951
951
  selected by `intake_mode`. If `intake_mode=prd`, only PRD-classified issues are normalized. If
952
952
  `intake_mode=build`, every non-PRD issue is normalized as a build ticket. If `intake_mode=both`,
953
953
  classify PRDs first and normalize all remaining issues as build tickets.
954
+ 2a. **Ask whether a person is holding it, before anything else.** Call
955
+ `planLabelNormalization({ labels, body, humanNeededLabel, lifecycleLabels, readyLabel })` from
956
+ `scripts/intake-blocker-reprobe.mjs` for every candidate and apply exactly the verdict it
957
+ returns. Do **not** re-implement the test here and do **not** decide held-ness from labels
958
+ alone: the vendor writers stamp the `[lisa-human-gate]` marker into the body of a deliberate
959
+ hold, and `human_needed` is one of the lifecycle labels this sweep keys on the **absence** of —
960
+ so an item held exactly as the filing contract instructs is a member of this swept population by
961
+ construction, and a label-only read promoted it into the build queue (#3805). The
962
+ `[lisa-human-gate]` exclusion already existed in the read-only "Ungated non-ready filing" sweep
963
+ below; the one sweep that writes was the one that could not see it.
964
+
965
+ On `normalize: false` with reason `human-gate`, **stop for that item**: apply no label, do not
966
+ classify it, and report it read-only per step 5. Writing a label here — even the human-needed
967
+ marker — would turn a match into durable state on a second path, and the gate test is a plain
968
+ substring match whose precision is a separate open defect (#3815). Refusing and reporting is
969
+ the safe failure direction without the latch. Count these under `held_for_person`, never under
970
+ `normalized_ready`.
954
971
  3. Classify the issue:
955
972
  - **PRD** if it has PRD labels/markers (`prd`, `type:PRD`, `kind:prd`), PRD structure
956
973
  (`## Problem`, `## Goals`, `## Validation Journey`, generated-work/backlink sections), or
@@ -964,7 +981,8 @@ with labels like `build-ready`, or with no Lisa status label at all, that are in
964
981
  - **Ambiguous PRD/build** if strong PRD and build classifications both match. In `intake_mode=prd`
965
982
  normalize as PRD; in `intake_mode=build` normalize as build; in `intake_mode=both`, prefer PRD
966
983
  only when the body has PRD structure, otherwise build.
967
- 4. Apply exactly one configured ready label for the classified lifecycle:
984
+ 4. Apply exactly one configured ready label for the classified lifecycle — and only when step
985
+ 2a returned `normalize: true`; its `actions.addReadyLabel` is the label to apply, verbatim:
968
986
  - PRD → add the configured PRD `ready` label (default `prd-ready`).
969
987
  - Build ticket → add the configured build `ready` label (default `status:ready`).
970
988
  Keep any unofficial labels for auditability unless the project has explicitly configured one as a
@@ -972,7 +990,11 @@ with labels like `build-ready`, or with no Lisa status label at all, that are in
972
990
  normalization makes the next normal `lisa-intake` run pick it up.
973
991
  5. Post one idempotent `[lisa-repair-intake]` note naming the classification and the configured label
974
992
  applied. Include the normalization result in the loop-prevention fingerprint so repeated repair
975
- cycles do not spam comments.
993
+ cycles do not spam comments. For an item step 2a held, post `formatNormalizationHoldNote()`
994
+ instead — it says that nothing was changed and how a person resumes the item, which is the
995
+ opposite of what the reconciliation note says and must not be substituted for it. A promotion
996
+ declined silently is invisible, which is the quieter version of the failure this step exists to
997
+ prevent, so name every held item in the cycle summary via `summarizeHumanGateHolds([...])`.
976
998
 
977
999
  ### Ungated non-ready filing → surface as an incomplete handoff (read-only)
978
1000
 
@@ -1223,6 +1245,8 @@ It MAY:
1223
1245
  Epic/Story container** from its hierarchy/body-parentage children — so rollup and the GitHub UI can
1224
1246
  rely on the native graph. This repairs structure only; it does not ship, transition, or verify the
1225
1247
  parent.
1248
+ - Leave a normalization candidate untouched and report it when it is held for a person — the
1249
+ human gate is consulted before classification and its refusal is absolute.
1226
1250
  - Normalize a GitHub issue with no configured lifecycle label by adding the configured PRD or build
1227
1251
  `ready` label after classifying the issue. This is a visibility repair, not a claim; the item
1228
1252
  remains open and unclaimed for normal intake.
@@ -1305,6 +1329,10 @@ Report outcomes in these buckets:
1305
1329
  from hierarchy/body-parentage) whose missing native sub-issue links were attached.
1306
1330
  - `normalized_ready` — GitHub issues missing official lifecycle labels that were classified and
1307
1331
  given the configured PRD/build `ready` label so normal intake can claim them.
1332
+ - `held_for_person` — normalization candidates left exactly as they were because a person is
1333
+ holding them, per the human gate in step 2a of that repair. Nothing was written to them. This
1334
+ bucket is never merged into `normalized_ready` or `errors`: "a person parked this and I honoured
1335
+ it" is a repair outcome, not a failure and not a promotion.
1308
1336
  - `still_blocked` — examined and intentionally left `blocked`, with the active reason.
1309
1337
  - `reversal_suppressed` — a repair this skill previously made was reversed by a human, so the
1310
1338
  classification was **not** re-applied. Name the **reversed transition** (role, direction, and
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.37.2",
3
+ "version": "4.38.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -38,10 +38,20 @@ function normalizeLabels(labels) {
38
38
  }
39
39
 
40
40
  /**
41
- * @param {{ labels?: unknown, body?: unknown, humanNeededLabel?: unknown }} input
42
- * @returns {boolean}
41
+ * Whether an item is held for a person, on either surface.
42
+ *
43
+ * Exported deliberately, and it is the ONLY answer to that question anywhere in
44
+ * the intake machinery. Every selection and promotion path calls this rather
45
+ * than inspecting labels itself, because the label surface alone is a partial
46
+ * answer: the vendor writers stamp the body marker on a deliberate hold, so an
47
+ * item held exactly as the filing contract instructs carries no label at all.
48
+ * A path that read labels alone judged such an item unheld and promoted it —
49
+ * which is #3805, and is why this is a shared function rather than a shared
50
+ * convention.
51
+ * @param {{ labels?: unknown, body?: unknown, humanNeededLabel?: unknown }} input - Candidate surfaces
52
+ * @returns {boolean} True when a person is holding this item
43
53
  */
44
- function isHumanGated(input) {
54
+ export function isHumanGated(input) {
45
55
  const configured = String(
46
56
  input.humanNeededLabel ?? DEFAULT_HUMAN_NEEDED_LABEL
47
57
  )
@@ -282,6 +292,113 @@ export function planHumanGateReconciliation(input = {}) {
282
292
  };
283
293
  }
284
294
 
295
+ /**
296
+ * Marker on the note the normalization sweep leaves, so a later cycle
297
+ * recognises its own work instead of commenting again.
298
+ *
299
+ * Deliberately NOT the hold marker and deliberately not a superstring of it:
300
+ * `HUMAN_GATE_MARKER` closes with `]`, so neither this nor
301
+ * `HUMAN_GATE_NOTE_MARKER` matches it. A note that read as a hold declaration
302
+ * would make every reported item look held to the next body-reading sweep,
303
+ * including items the report merely names.
304
+ */
305
+ export const NORMALIZATION_HOLD_NOTE_MARKER =
306
+ "<!-- [lisa-human-gate-normalization-held] -->";
307
+
308
+ /**
309
+ * Decide whether an item carrying NO configured lifecycle label may be
310
+ * normalized into a ready lane.
311
+ *
312
+ * This is the promotion path, and it was keyed on labels alone. The configured
313
+ * `human_needed` label is one of the lifecycle labels whose *absence* the sweep
314
+ * enumerates on, so an item held only by the body marker was a member of the
315
+ * swept population by definition and got the build-ready label applied to it.
316
+ * The `[lisa-human-gate]` exclusion existed, in a different and read-only sweep
317
+ * further down the same contract: the one sweep that writes was the one that
318
+ * could not see the marker.
319
+ *
320
+ * Order is load-bearing for the same reason it is in `classifyPreWorkCandidate`:
321
+ * the hold is checked before lane membership and before any classification, so
322
+ * nothing downstream — however confident — can promote an item a person parked.
323
+ *
324
+ * On a match this plans NO write of its own. `planHumanGateReconciliation`
325
+ * turns a match into durable state, so a false positive there latches and every
326
+ * later cycle agrees with the first. The gate test is a plain substring match
327
+ * whose imprecision is tracked separately, so this path refuses and reports
328
+ * read-only instead — the same treatment the ungated-filing sweep already gives
329
+ * a hold it finds.
330
+ * @param {{
331
+ * labels?: unknown
332
+ * body?: unknown
333
+ * humanNeededLabel?: unknown
334
+ * lifecycleLabels?: unknown
335
+ * readyLabel?: unknown
336
+ * }} input - Candidate surfaces plus the configured lifecycle vocabulary
337
+ * @returns {{ normalize: boolean, reason: string, humanGated: boolean, actions: { addReadyLabel: string | null, reportHold: boolean } }} Normalization plan
338
+ */
339
+ export function planLabelNormalization(input = {}) {
340
+ const idle = Object.freeze({ addReadyLabel: null, reportHold: false });
341
+ if (isHumanGated(input)) {
342
+ return {
343
+ normalize: false,
344
+ reason: "human-gate",
345
+ humanGated: true,
346
+ actions: Object.freeze({ addReadyLabel: null, reportHold: true }),
347
+ };
348
+ }
349
+
350
+ const labels = normalizeLabels(input.labels);
351
+ if (normalizeLabels(input.lifecycleLabels).some(l => labels.includes(l))) {
352
+ return {
353
+ normalize: false,
354
+ reason: "already-in-lifecycle",
355
+ humanGated: false,
356
+ actions: idle,
357
+ };
358
+ }
359
+
360
+ const ready = trimmedString(input.readyLabel);
361
+ if (ready.length === 0) {
362
+ return {
363
+ normalize: false,
364
+ reason: "no-ready-label",
365
+ humanGated: false,
366
+ actions: idle,
367
+ };
368
+ }
369
+
370
+ return {
371
+ normalize: true,
372
+ reason: "normalize",
373
+ humanGated: false,
374
+ actions: Object.freeze({ addReadyLabel: ready, reportHold: false }),
375
+ };
376
+ }
377
+
378
+ /**
379
+ * Render the notice for an item the normalization sweep left alone.
380
+ *
381
+ * Distinct from `formatHumanGateNote` because the two say opposite things
382
+ * about what happened: that one reports a lane that WAS changed, this one
383
+ * reports a promotion that was declined and nothing else. Reusing it would tell
384
+ * an operator their item had been moved when it had not.
385
+ * @returns {string} Comment body, carrying the marker that keeps a re-run quiet
386
+ */
387
+ export function formatNormalizationHoldNote() {
388
+ return [
389
+ "**Left alone: a person is holding this**",
390
+ "",
391
+ "- What was found: the description says a person is holding this one, so " +
392
+ "it was not put into the queue that agents work from.",
393
+ "- What changed: nothing at all. It was left exactly as it is, and " +
394
+ "nothing will pick it up on its own.",
395
+ "- To resume it: remove the hold note from the description, then put it " +
396
+ "into the build queue.",
397
+ "",
398
+ NORMALIZATION_HOLD_NOTE_MARKER,
399
+ ].join("\n");
400
+ }
401
+
285
402
  /**
286
403
  * Render the hold notice for a reader who does not know this subsystem.
287
404
  *
@@ -276,6 +276,17 @@ Held for a human product call: <reason>.
276
276
  <!-- [lisa-human-gate] reason=<short-slug> -->
277
277
  ```
278
278
 
279
+ **Stamp both surfaces, not just the marker.** Also apply the configured `human_needed` marker
280
+ label (`github.labels.build.human_needed`, default `human-needed`) to the issue, via `gh issue edit`/`gh issue create --label`. The marker in the body and the label say the same thing,
281
+ and a filing that carries only one of them is a **half-armed gate**: sweeps that read the body
282
+ honour it, and sweeps that read labels do not. That is not hypothetical — the repair sweep that
283
+ normalizes items carrying no lifecycle label keys on the *absence* of `human_needed`, so a
284
+ marker-only hold was its population by construction and got promoted into the build queue (#3805).
285
+ The marker remains the authoritative declaration; the label is what makes the hold visible to a
286
+ person scanning a board and to any path that has not yet been routed through the shared reader.
287
+ If the label does not exist in the tracker, create it, or record that it could not be applied and
288
+ proceed — the marker still holds. Never file the label *instead of* the marker.
289
+
279
290
  If a leaf arrives with `build_ready` omitted or `false` **and** no `human_gate`, do not create it: report the incomplete handoff and name both ways to resolve it (`build_ready: true`, or a `human_gate` reason). Containers are exempt — their state rolls up from children, so they need neither.
280
291
 
281
292
  ## Phase 5.5 — Validate (Pre-write Gate)
@@ -261,6 +261,17 @@ Held for a human product call: <reason>.
261
261
  <!-- [lisa-human-gate] reason=<short-slug> -->
262
262
  ```
263
263
 
264
+ **Stamp both surfaces, not just the marker.** Also apply the configured `human_needed` marker
265
+ label (`jira.labels.human_needed`, default `Human Needed`) to the ticket, via the ticket's `labels` field. The marker in the body and the label say the same thing,
266
+ and a filing that carries only one of them is a **half-armed gate**: sweeps that read the body
267
+ honour it, and sweeps that read labels do not. That is not hypothetical — the repair sweep that
268
+ normalizes items carrying no lifecycle label keys on the *absence* of `human_needed`, so a
269
+ marker-only hold was its population by construction and got promoted into the build queue (#3805).
270
+ The marker remains the authoritative declaration; the label is what makes the hold visible to a
271
+ person scanning a board and to any path that has not yet been routed through the shared reader.
272
+ If the label does not exist in the tracker, create it, or record that it could not be applied and
273
+ proceed — the marker still holds. Never file the label *instead of* the marker.
274
+
264
275
  If a leaf arrives with `build_ready` omitted or `false` **and** no `human_gate`, do not create it: report the incomplete handoff and name both ways to resolve it (`build_ready: true`, or a `human_gate` reason). Containers are exempt — their status rolls up from children, so they need neither.
265
276
 
266
277
  ## Phase 5.5 — Validate (Pre-write Gate)
@@ -280,6 +280,17 @@ Held for a human product call: <reason>.
280
280
  <!-- [lisa-human-gate] reason=<short-slug> -->
281
281
  ```
282
282
 
283
+ **Stamp both surfaces, not just the marker.** Also apply the configured `human_needed` marker
284
+ label (`linear.labels.build.human_needed`, default `human-needed`) to the Issue, via the Issue's labels (create it via `create_issue_label` if absent). The marker in the body and the label say the same thing,
285
+ and a filing that carries only one of them is a **half-armed gate**: sweeps that read the body
286
+ honour it, and sweeps that read labels do not. That is not hypothetical — the repair sweep that
287
+ normalizes items carrying no lifecycle label keys on the *absence* of `human_needed`, so a
288
+ marker-only hold was its population by construction and got promoted into the build queue (#3805).
289
+ The marker remains the authoritative declaration; the label is what makes the hold visible to a
290
+ person scanning a board and to any path that has not yet been routed through the shared reader.
291
+ If the label does not exist in the tracker, create it, or record that it could not be applied and
292
+ proceed — the marker still holds. Never file the label *instead of* the marker.
293
+
283
294
  If a leaf arrives with `build_ready` omitted or `false` **and** no `human_gate`, do not create it: report the incomplete handoff and name both ways to resolve it (`build_ready: true`, or a `human_gate` reason). Containers are exempt — their state rolls up from children, so they need neither.
284
295
 
285
296
  ## Phase 5.5 — Validate (Pre-write Gate)
@@ -951,6 +951,23 @@ with labels like `build-ready`, or with no Lisa status label at all, that are in
951
951
  selected by `intake_mode`. If `intake_mode=prd`, only PRD-classified issues are normalized. If
952
952
  `intake_mode=build`, every non-PRD issue is normalized as a build ticket. If `intake_mode=both`,
953
953
  classify PRDs first and normalize all remaining issues as build tickets.
954
+ 2a. **Ask whether a person is holding it, before anything else.** Call
955
+ `planLabelNormalization({ labels, body, humanNeededLabel, lifecycleLabels, readyLabel })` from
956
+ `scripts/intake-blocker-reprobe.mjs` for every candidate and apply exactly the verdict it
957
+ returns. Do **not** re-implement the test here and do **not** decide held-ness from labels
958
+ alone: the vendor writers stamp the `[lisa-human-gate]` marker into the body of a deliberate
959
+ hold, and `human_needed` is one of the lifecycle labels this sweep keys on the **absence** of —
960
+ so an item held exactly as the filing contract instructs is a member of this swept population by
961
+ construction, and a label-only read promoted it into the build queue (#3805). The
962
+ `[lisa-human-gate]` exclusion already existed in the read-only "Ungated non-ready filing" sweep
963
+ below; the one sweep that writes was the one that could not see it.
964
+
965
+ On `normalize: false` with reason `human-gate`, **stop for that item**: apply no label, do not
966
+ classify it, and report it read-only per step 5. Writing a label here — even the human-needed
967
+ marker — would turn a match into durable state on a second path, and the gate test is a plain
968
+ substring match whose precision is a separate open defect (#3815). Refusing and reporting is
969
+ the safe failure direction without the latch. Count these under `held_for_person`, never under
970
+ `normalized_ready`.
954
971
  3. Classify the issue:
955
972
  - **PRD** if it has PRD labels/markers (`prd`, `type:PRD`, `kind:prd`), PRD structure
956
973
  (`## Problem`, `## Goals`, `## Validation Journey`, generated-work/backlink sections), or
@@ -964,7 +981,8 @@ with labels like `build-ready`, or with no Lisa status label at all, that are in
964
981
  - **Ambiguous PRD/build** if strong PRD and build classifications both match. In `intake_mode=prd`
965
982
  normalize as PRD; in `intake_mode=build` normalize as build; in `intake_mode=both`, prefer PRD
966
983
  only when the body has PRD structure, otherwise build.
967
- 4. Apply exactly one configured ready label for the classified lifecycle:
984
+ 4. Apply exactly one configured ready label for the classified lifecycle — and only when step
985
+ 2a returned `normalize: true`; its `actions.addReadyLabel` is the label to apply, verbatim:
968
986
  - PRD → add the configured PRD `ready` label (default `prd-ready`).
969
987
  - Build ticket → add the configured build `ready` label (default `status:ready`).
970
988
  Keep any unofficial labels for auditability unless the project has explicitly configured one as a
@@ -972,7 +990,11 @@ with labels like `build-ready`, or with no Lisa status label at all, that are in
972
990
  normalization makes the next normal `lisa-intake` run pick it up.
973
991
  5. Post one idempotent `[lisa-repair-intake]` note naming the classification and the configured label
974
992
  applied. Include the normalization result in the loop-prevention fingerprint so repeated repair
975
- cycles do not spam comments.
993
+ cycles do not spam comments. For an item step 2a held, post `formatNormalizationHoldNote()`
994
+ instead — it says that nothing was changed and how a person resumes the item, which is the
995
+ opposite of what the reconciliation note says and must not be substituted for it. A promotion
996
+ declined silently is invisible, which is the quieter version of the failure this step exists to
997
+ prevent, so name every held item in the cycle summary via `summarizeHumanGateHolds([...])`.
976
998
 
977
999
  ### Ungated non-ready filing → surface as an incomplete handoff (read-only)
978
1000
 
@@ -1223,6 +1245,8 @@ It MAY:
1223
1245
  Epic/Story container** from its hierarchy/body-parentage children — so rollup and the GitHub UI can
1224
1246
  rely on the native graph. This repairs structure only; it does not ship, transition, or verify the
1225
1247
  parent.
1248
+ - Leave a normalization candidate untouched and report it when it is held for a person — the
1249
+ human gate is consulted before classification and its refusal is absolute.
1226
1250
  - Normalize a GitHub issue with no configured lifecycle label by adding the configured PRD or build
1227
1251
  `ready` label after classifying the issue. This is a visibility repair, not a claim; the item
1228
1252
  remains open and unclaimed for normal intake.
@@ -1305,6 +1329,10 @@ Report outcomes in these buckets:
1305
1329
  from hierarchy/body-parentage) whose missing native sub-issue links were attached.
1306
1330
  - `normalized_ready` — GitHub issues missing official lifecycle labels that were classified and
1307
1331
  given the configured PRD/build `ready` label so normal intake can claim them.
1332
+ - `held_for_person` — normalization candidates left exactly as they were because a person is
1333
+ holding them, per the human gate in step 2a of that repair. Nothing was written to them. This
1334
+ bucket is never merged into `normalized_ready` or `errors`: "a person parked this and I honoured
1335
+ it" is a repair outcome, not a failure and not a promotion.
1308
1336
  - `still_blocked` — examined and intentionally left `blocked`, with the active reason.
1309
1337
  - `reversal_suppressed` — a repair this skill previously made was reversed by a human, so the
1310
1338
  classification was **not** re-applied. Name the **reversed transition** (role, direction, and