@jaguilar87/gaia 5.0.8 → 5.0.9

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 (89) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +11 -0
  4. package/bin/README.md +6 -1
  5. package/bin/cli/approvals.py +341 -238
  6. package/bin/cli/brief.py +13 -0
  7. package/bin/cli/doctor.py +1 -1
  8. package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
  9. package/dist/gaia-ops/hooks/adapters/claude_code.py +19 -85
  10. package/dist/gaia-ops/hooks/modules/context/context_injector.py +23 -7
  11. package/dist/gaia-ops/hooks/modules/events/event_writer.py +63 -96
  12. package/dist/gaia-ops/hooks/modules/security/__init__.py +0 -2
  13. package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +238 -69
  14. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +506 -1103
  15. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +24 -1
  16. package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +150 -90
  17. package/dist/gaia-ops/hooks/modules/session/session_manifest.py +257 -28
  18. package/dist/gaia-ops/hooks/post_compact.py +1 -0
  19. package/dist/gaia-ops/hooks/pre_compact.py +1 -0
  20. package/dist/gaia-ops/hooks/user_prompt_submit.py +20 -0
  21. package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +27 -7
  22. package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +11 -6
  23. package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -2
  24. package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +69 -28
  25. package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +16 -3
  26. package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +10 -5
  27. package/dist/gaia-ops/skills/pending-approvals/SKILL.md +16 -11
  28. package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +20 -6
  29. package/dist/gaia-ops/skills/subagent-request-approval/reference.md +23 -15
  30. package/dist/gaia-ops/tools/migration/README.md +10 -12
  31. package/dist/gaia-ops/tools/scan/orchestrator.py +194 -10
  32. package/dist/gaia-ops/tools/scan/tests/test_integration.py +1 -2
  33. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  34. package/dist/gaia-security/hooks/adapters/claude_code.py +19 -85
  35. package/dist/gaia-security/hooks/modules/context/context_injector.py +23 -7
  36. package/dist/gaia-security/hooks/modules/events/event_writer.py +63 -96
  37. package/dist/gaia-security/hooks/modules/security/__init__.py +0 -2
  38. package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +238 -69
  39. package/dist/gaia-security/hooks/modules/security/approval_grants.py +506 -1103
  40. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +24 -1
  41. package/dist/gaia-security/hooks/modules/session/pending_scanner.py +150 -90
  42. package/dist/gaia-security/hooks/modules/session/session_manifest.py +257 -28
  43. package/dist/gaia-security/hooks/user_prompt_submit.py +20 -0
  44. package/gaia/approvals/store.py +87 -9
  45. package/gaia/store/schema.sql +38 -1
  46. package/gaia/store/writer.py +400 -0
  47. package/hooks/adapters/claude_code.py +19 -85
  48. package/hooks/elicitation_result.py +20 -75
  49. package/hooks/modules/context/context_injector.py +23 -7
  50. package/hooks/modules/events/event_writer.py +63 -96
  51. package/hooks/modules/security/__init__.py +0 -2
  52. package/hooks/modules/security/approval_cleanup.py +238 -69
  53. package/hooks/modules/security/approval_grants.py +506 -1103
  54. package/hooks/modules/security/mutative_verbs.py +24 -1
  55. package/hooks/modules/session/pending_scanner.py +150 -90
  56. package/hooks/modules/session/session_manifest.py +257 -28
  57. package/hooks/post_compact.py +1 -0
  58. package/hooks/pre_compact.py +1 -0
  59. package/hooks/user_prompt_submit.py +20 -0
  60. package/package.json +1 -1
  61. package/pyproject.toml +1 -1
  62. package/scripts/bootstrap_database.sh +66 -17
  63. package/scripts/migrations/README.md +26 -14
  64. package/scripts/migrations/schema.checksum +2 -2
  65. package/scripts/migrations/v18_to_v19.sql +36 -0
  66. package/scripts/migrations/v19_to_v20.sql +20 -0
  67. package/skills/agent-approval-protocol/SKILL.md +27 -7
  68. package/skills/agent-approval-protocol/reference.md +11 -6
  69. package/skills/gaia-patterns/reference.md +2 -2
  70. package/skills/orchestrator-present-approval/SKILL.md +69 -28
  71. package/skills/orchestrator-present-approval/reference.md +16 -3
  72. package/skills/orchestrator-present-approval/template.md +10 -5
  73. package/skills/pending-approvals/SKILL.md +16 -11
  74. package/skills/subagent-request-approval/SKILL.md +20 -6
  75. package/skills/subagent-request-approval/reference.md +23 -15
  76. package/tools/migration/README.md +10 -12
  77. package/tools/scan/orchestrator.py +194 -10
  78. package/tools/scan/tests/test_integration.py +1 -2
  79. package/bin/cli/plans.py +0 -517
  80. package/dist/gaia-ops/tools/context/deep_merge.py +0 -159
  81. package/dist/gaia-ops/tools/migration/migrate_04_harness_events.py +0 -132
  82. package/dist/gaia-ops/tools/migration/migrate_04_harness_events.sh +0 -23
  83. package/dist/gaia-ops/tools/scan/merge.py +0 -213
  84. package/dist/gaia-ops/tools/scan/tests/test_merge.py +0 -269
  85. package/tools/context/deep_merge.py +0 -159
  86. package/tools/migration/migrate_04_harness_events.py +0 -132
  87. package/tools/migration/migrate_04_harness_events.sh +0 -23
  88. package/tools/scan/merge.py +0 -213
  89. package/tools/scan/tests/test_merge.py +0 -269
@@ -194,6 +194,26 @@ if __name__ == "__main__":
194
194
  else:
195
195
  logger.info("Could not extract user prompt from stdin, skipping routing")
196
196
 
197
+ # Per-turn VERIFIED pending approvals. Lets the orchestrator present
198
+ # a pending approval for consent directly from injected context,
199
+ # WITHOUT dispatching a subagent to derive/verify it (that dispatch's
200
+ # SubagentStop caused a pending-revocation bug). Emits "" when there
201
+ # are no verified pendings, so a turn with nothing pending injects
202
+ # nothing -- this is what keeps the per-turn injection quiet, unlike
203
+ # the one-shot SessionStart summary it deliberately does not re-emit.
204
+ try:
205
+ from modules.session.session_manifest import (
206
+ build_per_turn_pending_approvals_block,
207
+ )
208
+ pending_block = build_per_turn_pending_approvals_block()
209
+ if pending_block:
210
+ context_parts.append(pending_block)
211
+ except Exception as _pa_exc:
212
+ logger.debug(
213
+ "per-turn pending approvals injection failed (non-fatal): %s",
214
+ _pa_exc,
215
+ )
216
+
197
217
  additional_context = "\n\n".join(context_parts)
198
218
  logger.info("Context injected: %s mode (%d chars)", mode, len(additional_context))
199
219
 
@@ -14,6 +14,20 @@ through the hook layer, to the orchestrator when a T3 command is blocked: the
14
14
  the status and event vocabularies, and how to confirm a grant is active. The
15
15
  tables below are the canonical schema -- relay them verbatim, do not author them.
16
16
 
17
+ The orchestrator presents this contract to the user from a **trusted source**,
18
+ never by dispatching a subagent to verify or derive it (it has no shell). The
19
+ primary source is the per-turn `[PENDING-APPROVALS-VERIFIED]` block injected at
20
+ `UserPromptSubmit` (`build_verified_pending_approvals` in
21
+ `hooks/modules/session/session_manifest.py`), which carries every pending that
22
+ has survived >= 1 turn, each already DB-read and fingerprint-verified
23
+ (`verified: true`). For a pending emitted in the current turn -- not yet in the
24
+ block -- the fallback is the subagent's relayed `approval_request`. The
25
+ **integrity boundary is grant activation**, not presentation:
26
+ `verify_fingerprint` (`gaia/approvals/chain.py`) runs when the user selects the
27
+ Approve label, so a tampered payload fails to form a grant regardless of how it
28
+ was presented. See `Skill('orchestrator-present-approval')` for the presentation
29
+ discipline.
30
+
17
31
  For the universal response envelope (`plan_status` states, `evidence_report`),
18
32
  see `agent-protocol`. For the deep mechanics -- fingerprint canonicalization,
19
33
  the hash chain, grant activation, reading a granted approval from Python -- see
@@ -27,10 +41,12 @@ For a **singular** T3 approval (the hook-block path),
27
41
  verbatim. For a **plan-first `COMMAND_SET`** the id is instead **content-derived**
28
42
  by `store.derive_command_set_id()`: `P-<first 32 hex of
29
43
  sha256(canonical(command strings))>`. The two share the `P-` prefix and 32-hex
30
- length but differ in origin -- the command_set id is deterministic so the
31
- orchestrator reproduces it from the command_set (via `gaia approvals derive-id`)
32
- with no DB search; the singular id is random because the subagent relays it
33
- directly. The `P-` prefix is mandatory in both cases: without it the PostToolUse
44
+ length but differ in origin -- the command_set id is deterministic (minted at
45
+ SubagentStop intake), and once the pending has survived a turn the orchestrator
46
+ reads that id directly from the injected `[PENDING-APPROVALS-VERIFIED]` block
47
+ (no derive-dispatch, no DB search); the singular id is random and the subagent
48
+ relays it directly for the same-turn case. The `P-` prefix is mandatory in both
49
+ cases: without it the PostToolUse
34
50
  hook cannot do targeted grant activation. The first 8 hex chars after `P-` are
35
51
  the nonce prefix shown in option labels: `[P-b1bdfbb0]`.
36
52
 
@@ -106,9 +122,13 @@ whose command never ran, or that ran through the redirect-sanitized path.
106
122
  - `SHOWN` precedes `APPROVED`; the activation path writes them together.
107
123
  - `approval_events` is append-only -- the `bu_approval_events_immutable` and
108
124
  `bd_approval_events_immutable` triggers `RAISE(ABORT)` on UPDATE/DELETE.
109
- - The orchestrator MUST re-verify a relayed payload via
110
- `chain.verify_fingerprint(approval_id, payload_json, con)` before presenting;
111
- a mismatch raises `ChainTamperError` and the approval aborts.
125
+ - The payload's integrity is enforced at grant **activation**, not at
126
+ presentation: `chain.verify_fingerprint(approval_id, payload_json, con)` runs
127
+ when the user selects the Approve label, and a mismatch raises
128
+ `ChainTamperError` so the grant never forms. The orchestrator presents from a
129
+ trusted source (the injected `[PENDING-APPROVALS-VERIFIED]` block, already
130
+ fingerprint-verified by the hook; or a same-turn relayed `approval_request`)
131
+ and never dispatches a subagent to verify or derive the approval.
112
132
 
113
133
  For the grant activation walk-through, fingerprint internals, reading a granted
114
134
  approval from Python, and the retry-blocked-again diagnosis, see `reference.md`.
@@ -12,12 +12,17 @@ canonical string. `store.insert_requested()` stores both the canonical JSON
12
12
  (`payload_json`) and the hex fingerprint on the `approvals` row and on the
13
13
  `REQUESTED` event.
14
14
 
15
- The orchestrator MUST re-verify via
16
- `chain.verify_fingerprint(approval_id, payload_json, con)` before presenting.
17
- That function re-parses and re-canonicalizes the relayed `payload_json`,
18
- recomputes the fingerprint, and compares it against the fingerprint stored on
19
- the `REQUESTED` event. A mismatch raises `ChainTamperError` and the approval
20
- aborts -- this is a security boundary, not a recoverable UX issue.
15
+ The fingerprint is verified at grant **activation**, not at presentation.
16
+ `chain.verify_fingerprint(approval_id, payload_json, con)` re-parses and
17
+ re-canonicalizes the payload, recomputes the fingerprint, and compares it
18
+ against the fingerprint stored on the `REQUESTED` event; a mismatch raises
19
+ `ChainTamperError` and the grant never forms -- a security boundary, not a
20
+ recoverable UX issue. The per-turn `[PENDING-APPROVALS-VERIFIED]` builder
21
+ (`build_verified_pending_approvals`) applies the same check when assembling the
22
+ injected block, so only fingerprint-clean pendings reach the orchestrator marked
23
+ `verified: true`. The orchestrator therefore presents from that already-verified
24
+ block (or a same-turn relayed `approval_request`) and never dispatches to verify
25
+ the payload itself.
21
26
 
22
27
  ## Hash chain
23
28
 
@@ -109,7 +109,7 @@ The package ships a single `gaia` binary (`bin/gaia.js`) that dispatches to Pyth
109
109
  | `gaia memory` | `bin/cli/memory.py` | Episodic memory: FTS5 search, show episode, health checks |
110
110
  | `gaia metrics` | `bin/cli/metrics.py` | Usage analytics: tier classification, agent invocations, anomaly counters |
111
111
  | `gaia paths` | `bin/cli/paths.py` | Inspect canonical Gaia storage paths (DB, plugin root, workspace) |
112
- | `gaia plans` | `bin/cli/plans.py` | List and display briefs/plans with status info |
112
+ | `gaia plan` | `bin/cli/plan.py` | Manage plans (one per brief, DB-canonical): save, show, list, status |
113
113
  | `gaia workspace` | `bin/cli/workspace.py` | Workspace identity and consolidate operations |
114
114
  | `gaia scan` | `bin/cli/scan.py` | In-process project scan: detect stack, sync results to ~/.gaia/gaia.db (DB-canonical; no project-context.json written) |
115
115
  | `gaia status` | `bin/cli/status.py` | Quick installation snapshot: version, mode, DB path, registered workspace, last scan |
@@ -289,7 +289,7 @@ After `npm install -g @jaguilar87/gaia` (or via the local symlink) the dispatche
289
289
  | `gaia history` | Session history viewer | Debugging past sessions |
290
290
  | `gaia memory` | Episodic memory inspect/search | Recall past episodes, memory health |
291
291
  | `gaia approvals` | List/accept/reject pending T3 approvals | Approval workflow |
292
- | `gaia brief` / `gaia plans` | Brief and plan management against the DB substrate | Planning, brief lifecycle |
292
+ | `gaia brief` / `gaia plan` | Brief and plan management against the DB substrate | Planning, brief lifecycle |
293
293
  | `gaia context` | Display and refresh project context | Audit context state |
294
294
  | `gaia paths` | Print resolved storage paths | Path debugging |
295
295
  | `gaia workspace` | Workspace identity and consolidate operations | Multi-workspace setups |
@@ -15,11 +15,13 @@ names the specific action. No exceptions. No brevity shortcuts.
15
15
  ```
16
16
 
17
17
  `orchestrator-present-approval` is the discipline the orchestrator follows when
18
- a subagent emits `APPROVAL_REQUEST` with an `approval_id`: relay the
19
- `sealed_payload` into AskUserQuestion -- fingerprint check, mandatory fields in
20
- the question, mandatory nonce in the option label. For the subagent side that
21
- produced the payload see `subagent-request-approval`; for the data contract
22
- itself see `agent-approval-protocol`.
18
+ an approval needs the user's consent: relay the sealed fields into
19
+ AskUserQuestion -- mandatory fields in the question, mandatory nonce in the
20
+ option label. The orchestrator has no shell, so it never dispatches a subagent
21
+ to derive or verify an approval; it presents from a trusted source it already
22
+ holds. For the subagent side that produced the payload see
23
+ `subagent-request-approval`; for the data contract itself see
24
+ `agent-approval-protocol`.
23
25
 
24
26
  ## Mental Model
25
27
 
@@ -27,25 +29,53 @@ The orchestrator sits between the subagent and the user. The user cannot make
27
29
  an informed decision on data they have not seen -- a summary, a reference to
28
30
  "the plan above", or an offer to show details on request all push the decision
29
31
  without the data needed to decide. The job is **verbatim relay, not
30
- re-authoring**: rewriting any of the 7 sealed fields breaks the fingerprint and
31
- `verify_fingerprint` (`gaia/approvals/chain.py`) raises `ChainTamperError`.
32
-
33
- ## Step 0 -- Verify the approval against the DB (mandatory before SHOWN)
34
-
35
- A subagent's reported `approval_id` is an unverified claim, not a fact. The agent runs in its own context and can relay an id that is stale, from another session, or simply wrong -- and a stale id presented as a fresh block walks the user into consenting to nothing real (or to a grant that no longer exists). The DB is the source of truth; the agent's report is a pointer into it that you must resolve, never the authority itself.
36
-
37
- So before AskUserQuestion, two checks against the DB, in order:
38
-
39
- 1. **The approval exists, is fresh, and is from the current session.** Query `gaia approvals pending --session "$CLAUDE_SESSION_ID"` (or `--json` for parsing). The reported `approval_id` MUST appear in that result. If it appears only under `--all-sessions` but not the current session, it is leakage from another session (a test session such as `e2e-sim`, a prior run) -- **do not present**. If it does not appear at all, it does not exist or was already consumed/rejected -- **do not present**. Freshness is the `created_at` of the pending row plus its presence as still-`pending`; an id the agent reports that is not currently pending in *this* session is not a fresh block, whatever the agent says.
40
- 2. **The payload is untampered.** Call `verify_fingerprint(approval_id, payload_json, con) -> bool` from `gaia/approvals/chain.py`. It raises `ChainTamperError` if the payload was modified between subagent emission and your relay (security boundary, do not present), and `ValueError` if no REQUESTED event exists for this `approval_id`. Either case: **do not present**, report the failure, stop.
41
-
42
- **For a `command_set` (plan-first batch) the agent does not know the id at all -- so you DERIVE it deterministically, you do not search the DB.** The hook mints the `approval_id` at SubagentStop (`_intake_command_set_pending` -- see Rule 3) from the **content** of the command_set, not from a uuid4. The id is `P-<first 32 hex of sha256(canonical(command list))>` (`derive_command_set_id` in `gaia/approvals/store.py`). Because it is content-derived, you reproduce the EXACT minted id from the `command_set` you already hold in the contract -- with **no `gaia approvals pending --session` search**. Run:
43
-
44
- ```
45
- gaia approvals derive-id --commands-json '<the command_set from the contract>'
46
- ```
47
-
48
- It applies the SAME mutative filter the intake used and prints the `P-...` id (the same function `derive_command_set_id` the hook minted with). This closes the cross-session miss: the SubagentStop output never reaches the parent (Claude Code issue #5812), so a random id could not be recovered across sessions -- a content-derived one needs no recovery at all. Having derived the id, run Step 0's existence/fingerprint checks against it exactly as for a singular id (the row is now findable by that exact id). The shape: the DB mints content-derived, the orchestrator re-derives, the agent never owns the id and no search is needed.
32
+ re-authoring**: rewriting any of the sealed fields would change the consent
33
+ surface from what was recorded. Integrity of the payload is enforced at grant
34
+ **activation** (`verify_fingerprint` in `gaia/approvals/chain.py`, called when
35
+ the user selects the Approve label), not at presentation -- so presentation
36
+ itself never needs a verify-dispatch.
37
+
38
+ ## Step 0 -- Present from a trusted source; never dispatch to verify or derive
39
+
40
+ The orchestrator has no shell. It MUST NOT dispatch a subagent solely to derive
41
+ or verify an approval before presenting -- that dispatch is both unnecessary
42
+ (the integrity check runs at activation, below) and harmful (its SubagentStop
43
+ can sweep the very pending being verified). Instead, present from one of two
44
+ **trusted** sources:
45
+
46
+ 1. **Primary -- the injected `[PENDING-APPROVALS-VERIFIED]` block.** A per-turn
47
+ hook (`hooks/modules/session/session_manifest.py`) injects, on every
48
+ `UserPromptSubmit`, every pending that has survived >= 1 turn. Each row in
49
+ that block has already been DB-read and fingerprint-verified by the hook
50
+ (`build_verified_pending_approvals` -- only rows whose payload re-canonicalizes
51
+ to the fingerprint stored on their `REQUESTED` event appear, each marked
52
+ `verified: true`). **Present directly from this block** -- the fields, the
53
+ full `approval_id`, and (for batches) the whole `command_set` with its minted
54
+ id are all there. No DB query, no `derive-id`, no dispatch.
55
+ 2. **Fallback -- same-turn relay.** A pending a subagent emits during the
56
+ CURRENT turn will not be in this turn's block yet: the block is built at
57
+ `UserPromptSubmit`, before the subagent ran. For that case present from the
58
+ subagent's relayed `approval_request`. This is justified because the pending
59
+ was freshly minted in THIS session by a trusted dispatch, AND integrity is
60
+ enforced at grant **activation** (`verify_fingerprint` fires when the user
61
+ selects the Approve label), not at presentation. The old pre-presentation
62
+ verify was redundant belt-and-suspenders; it is removed.
63
+
64
+ Once the pending survives a turn it appears in the injected block, so the relay
65
+ is only ever needed for the same-turn case.
66
+
67
+ **For a `command_set` (plan-first batch) you do not derive the id -- you read it
68
+ from the block.** The hook mints the `approval_id` at SubagentStop
69
+ (`_intake_command_set_pending` -- see Rule 3) from the **content** of the
70
+ command_set (`derive_command_set_id` in `gaia/approvals/store.py`,
71
+ `P-<first 32 hex of sha256(canonical(command list))>`). Once that pending has
72
+ survived a turn, the `[PENDING-APPROVALS-VERIFIED]` block carries it with its
73
+ minted `approval_id` and all N commands already attached -- so you read the id
74
+ and the commands straight from the block. **No `gaia approvals derive-id`
75
+ dispatch is needed.** For a command_set emitted in the CURRENT turn (not yet in
76
+ the block), present from the subagent's relayed `approval_request`, which carries
77
+ the same `command_set`; the content-derived id reaches you when the pending
78
+ appears in the next turn's block.
49
79
 
50
80
  ## Mandatory presentation -- 5 labeled fields + nonce-suffixed label
51
81
 
@@ -72,7 +102,13 @@ whose `id` starts with `P-{prefix}`. Without the suffix no grant is created.
72
102
  See `template.md` for the canonical layout and `reference.md` -> "GOOD vs BAD
73
103
  Examples" for full presentations.
74
104
 
75
- Fields above are extracted from the DB-stored canonical payload (`payload_json` on the REQUESTED row), not from the subagent's relayed `approval_request` — that's why `rollback_hint` is the field name here while the subagent contract uses `rollback`.
105
+ Fields above are extracted from your trusted source. From the injected
106
+ `[PENDING-APPROVALS-VERIFIED]` block (the primary path) they appear under the
107
+ canonical names shown here (`operation`, `exact_content`, `scope`, `risk_level`,
108
+ `rationale`, `rollback_hint`). From a same-turn relayed `approval_request` (the
109
+ fallback) the rollback field arrives under the key `rollback` -- map it to
110
+ ROLLBACK the same way. Either way you copy values verbatim; you do not re-author
111
+ them.
76
112
 
77
113
  ## Rules
78
114
 
@@ -90,7 +126,12 @@ Fields above are extracted from the DB-stored canonical payload (`payload_json`
90
126
  `APPROVAL_REQUEST` carrying a `command_set` of >= 2 `{command, rationale}`
91
127
  items and **no** `approval_id`, the SubagentStop processor
92
128
  (`handoff_persister._intake_command_set_pending`) mints ONE pending
93
- `COMMAND_SET` with one `approval_id`. You present that single approval: list
129
+ `COMMAND_SET` with one content-derived `approval_id`. Once that pending has
130
+ survived a turn it appears in the injected `[PENDING-APPROVALS-VERIFIED]`
131
+ block with its minted `approval_id` and all N commands -- **read the id and
132
+ commands from the block; do not dispatch `gaia approvals derive-id`.** (A
133
+ command_set emitted in the current turn is presented from the subagent's
134
+ relayed `approval_request`.) You present that single approval: list
94
135
  **all N commands** in the question body, but use **one** Approve label with
95
136
  **one** `[P-{nonce8}]` suffix -- one consent covers the whole batch. On
96
137
  approval, `activate_db_pending_by_prefix` Step 3b creates a single
@@ -126,5 +167,5 @@ wording, see `reference.md` -> "GOOD vs BAD Examples", "Option Label Patterns",
126
167
  | "Similar command, slightly different path -- I'll reuse / wrap it" | Grants match the statement signature byte-for-byte. Any wrapper, redirect, flag, or path drift is a different signature and a fresh re-block. |
127
168
  | "The same command emitted a new approval_id" | Grants are single-use and consumed on the first retry. A second run is a new APPROVAL_REQUEST -- approve again. |
128
169
  | "I'll set batch_scope to approve many at once" | `batch_scope` is ignored -- but a real batch path exists: a plan-first `command_set` (>= 2 items, no `approval_id`) is intaken into ONE pending `COMMAND_SET`. Present that single approval (N commands shown, one `[P-...]` nonce, one consent), not N separate approvals. |
129
- | "I can paraphrase a field before relaying" | The fingerprint covers all 7 sealed fields; any modification raises `ChainTamperError` in Step 0 and the presentation is refused. |
130
- | **"The agent reported an `approval_id`, so it's a real fresh block"** -- trusting a nonce relayed by the subagent | The agent's reported id is an unverified pointer, not a fact. It can be stale or belong to another session -- subagents have presented a STALE nonce from a test session (`e2e-sim`) as if it were a fresh block. Resolve every reported id against `gaia approvals pending --session "$CLAUDE_SESSION_ID"` (Step 0): it must be currently pending in *this* session. Visible only under `--all-sessions`, or absent entirely, means do not present. For `command_set` the hook mints the id and the agent never has one -- you **derive** it deterministically from the command_set via `gaia approvals derive-id` (content-derived, no DB search), then run the same existence/fingerprint checks. |
170
+ | "I can paraphrase a field before relaying" | The fingerprint covers all sealed fields and is checked at grant **activation** (`verify_fingerprint`, when the user selects the Approve label); a paraphrase there raises `ChainTamperError` and the grant never forms. Relay verbatim so activation succeeds. |
171
+ | **"I'll dispatch a subagent to verify or derive the approval before presenting"** | The orchestrator has no shell and must NEVER dispatch to verify or derive an approval. The pending arrives **already verified** in the injected `[PENDING-APPROVALS-VERIFIED]` block (DB-read + fingerprint-checked by the per-turn hook, `verified: true`) -- present from it. For a same-turn pending not yet in the block, present from the subagent's relayed `approval_request`. A verify/derive dispatch is unnecessary (integrity is enforced at activation) and harmful (its SubagentStop can sweep the very pending). For `command_set`, read the minted `approval_id` and all commands from the block -- do not run `gaia approvals derive-id`. |
@@ -151,6 +151,16 @@ commands** in the question body, with **one** Approve label carrying **one**
151
151
  `[P-{nonce8}]` suffix. The user gives one consent; each command then runs on its
152
152
  own retry within the 60-minute window. You do NOT issue N separate approvals.
153
153
 
154
+ **Reading the batch id and commands -- from the block, not by dispatch.** Once
155
+ the minted `COMMAND_SET` pending has survived a turn, it appears in the injected
156
+ `[PENDING-APPROVALS-VERIFIED]` block with its content-derived `approval_id` and
157
+ all N commands attached (`build_verified_pending_approvals` in
158
+ `hooks/modules/session/session_manifest.py`). Read the id and the commands
159
+ straight from that block -- the orchestrator has no shell and must NOT dispatch
160
+ `gaia approvals derive-id` or any verify command. For a command_set emitted in
161
+ the CURRENT turn (not yet in the block), present from the subagent's relayed
162
+ `approval_request`, which carries the same `command_set`.
163
+
154
164
  ## Grant Activation Mechanics
155
165
 
156
166
  When the hook blocks a T3 Bash command in subagent context,
@@ -161,9 +171,12 @@ generates a `P-{uuid4_hex}` `approval_id`, fingerprints the payload, inserts an
161
171
  message ends with `approval_id: P-{...}` (`build_t3_blocked_denial_message` in
162
172
  `hooks/modules/security/approval_messages.py`).
163
173
 
164
- The subagent relays that `approval_id` in its `approval_request`. The
165
- orchestrator presents via AskUserQuestion with the `[P-xxxxxxxx]` label. When
166
- the user selects the Approve label, the **ElicitationResult hook**
174
+ The orchestrator presents via AskUserQuestion with the `[P-xxxxxxxx]` label,
175
+ reading the `approval_id` and fields from the injected
176
+ `[PENDING-APPROVALS-VERIFIED]` block (primary) or, for a same-turn pending not
177
+ yet in the block, from the subagent's relayed `approval_request` (fallback). It
178
+ does not dispatch to verify or derive. When the user selects the Approve label,
179
+ the **ElicitationResult hook**
167
180
  (`hooks/elicitation_result.py`) fires and calls
168
181
  `activate_db_pending_by_prefix()`, which:
169
182
 
@@ -1,8 +1,12 @@
1
1
  # AskUserQuestion Template
2
2
 
3
3
  Use this layout verbatim when presenting an approval to the user. Replace
4
- `{...}` placeholders with values extracted from the subagent's `sealed_payload`
5
- and `approval_request`. Do not paraphrase, summarize, or omit any field.
4
+ `{...}` placeholders with values read from your trusted source -- the injected
5
+ `[PENDING-APPROVALS-VERIFIED]` block (primary; already DB-read and
6
+ fingerprint-verified by the per-turn hook) or, for a same-turn pending not yet
7
+ in the block, the subagent's relayed `approval_request` (fallback). Never
8
+ dispatch a subagent to derive or verify the approval. Do not paraphrase,
9
+ summarize, or omit any field.
6
10
 
7
11
  ## Standard Approval (single command)
8
12
 
@@ -23,8 +27,9 @@ AskUserQuestion(
23
27
  )
24
28
  ```
25
29
 
26
- Where `approval_id_prefix8` is the first 8 characters of the `approval_id`
27
- field from the subagent's `approval_request` (after the `P-` prefix).
30
+ Where `approval_id_prefix8` is the first 8 characters (after the `P-` prefix) of
31
+ the `approval_id` read from the `[PENDING-APPROVALS-VERIFIED]` block, or from the
32
+ subagent's `approval_request` for a same-turn pending.
28
33
 
29
34
  ## Batch template (COMMAND_SET)
30
35
 
@@ -47,4 +52,4 @@ ignored -- the signal is the presence of `command_set` in the contract.
47
52
  | SCOPE | `sealed_payload.scope` |
48
53
  | RIESGO | `sealed_payload.risk_level` + `sealed_payload.rationale` |
49
54
  | ROLLBACK | `sealed_payload.rollback_hint` (null -> "NOT REVERSIBLE") |
50
- | Option nonce suffix | `approval_request.approval_id` first 8 chars after `P-` |
55
+ | Option nonce suffix | `approval_id` first 8 chars after `P-` (from the `[PENDING-APPROVALS-VERIFIED]` block, or `approval_request.approval_id` for a same-turn pending) |
@@ -37,7 +37,7 @@ report "rejected" when nothing actually changed.
37
37
  | `gaia approvals list` | DB grants + filesystem pendings | `cmd_list` (mixed) |
38
38
  | `gaia approvals reject NONCE` | filesystem only | `reject_pending` in `hooks/modules/security/approval_grants.py` |
39
39
  | `gaia approvals reject-all` | filesystem only | loops `reject_pending` |
40
- | `gaia approvals clean` | filesystem only | `cleanup_expired_grants` |
40
+ | `gaia approvals clean` | DB (cross-session stale pendings) + filesystem | `cmd_clean` in `bin/cli/approvals.py`: calls `store.list_pending(all_sessions=True)`, transitions every pending older than `DEFAULT_PENDING_TTL_MINUTES` (24 h) to `revoked` via `store.revoke()`, then calls `cleanup_expired_grants` for filesystem files |
41
41
 
42
42
  The practical consequence: `revoke` is the DB-aware single-id verb; `reject` and
43
43
  `reject-all` only touch the legacy filesystem queue. If you need to mark a DB
@@ -105,15 +105,19 @@ Offer bulk cleanup when the user says "limpia todos los pendings", "borra los
105
105
  pendientes", or when SessionStart surfaces 5+ orphaned pendings the user has
106
106
  not engaged with.
107
107
 
108
- - `gaia approvals reject-all` -- bulk reject across the **filesystem** queue.
109
- Returns "0 rejected" when the queue is empty.
110
- - `gaia approvals clean` -- removes expired/stale **filesystem** files.
108
+ - `gaia approvals reject-all` -- bulk soft-reject across the **filesystem** queue.
109
+ Returns "0 rejected" when the queue is empty. Does not touch DB rows.
110
+ - `gaia approvals clean` -- the first-class cross-session bulk drain for stale
111
+ DB pendings: `cmd_clean` calls `store.list_pending(all_sessions=True)` and
112
+ transitions every pending older than 24 h (`DEFAULT_PENDING_TTL_MINUTES`) to
113
+ `revoked` via `store.revoke()`, then runs `cleanup_expired_grants` to clean
114
+ expired filesystem grant files. Runs without a T3 prompt (consent-reducing,
115
+ listed in `CONSENT_REDUCING_SUBCOMMAND_EXCEPTIONS`). Use this when
116
+ `gaia approvals pending --all-sessions` shows a backlog of stale rows.
111
117
 
112
- There is no first-class bulk-revoke for the DB queue. If `gaia approvals
113
- pending --all-sessions` shows rows that need clearing, either revoke each by id
114
- or call `store.revoke()` in a short Python loop. Do not report "bulk cleanup
115
- done" after `reject-all` if the DB queue still has pending rows -- check
116
- `gaia approvals pending --all-sessions` to confirm.
118
+ Do not report "bulk cleanup done" after `reject-all` alone -- it only clears
119
+ the filesystem queue. Run `gaia approvals clean` to drain the DB backlog, then
120
+ confirm with `gaia approvals pending --all-sessions`.
117
121
 
118
122
  Do not offer `reject-all` when there are active same-session pendings the user
119
123
  may still want to approve.
@@ -123,8 +127,9 @@ may still want to approve.
123
127
  - Approving without showing the exact COMANDO -- the user consents on the
124
128
  verbatim string, not a summary. The full presentation discipline lives in
125
129
  `orchestrator-present-approval`; this skill does not restate it.
126
- - Treating `gaia approvals reject-all` as a DB cleanup -- it operates on the
127
- filesystem queue only. DB rows survive the call.
130
+ - Treating `gaia approvals reject-all` as a full cleanup -- it operates on the
131
+ filesystem queue only; DB rows survive the call. Use `gaia approvals clean`
132
+ to drain the DB backlog.
128
133
  - Reporting "rechazado" without verifying the store -- `revoke` returns
129
134
  `not_found` for filesystem-only pendings; the inverse happens for `reject` on
130
135
  DB rows. Pick the verb by store, or be ready to fall back.
@@ -44,9 +44,20 @@ Add an `approval_request` to your `agent_contract_handoff`, copying the hook's f
44
44
 
45
45
  The `approval_request` schema is canonical in `agent-approval-protocol` — relay the sealed_payload fields verbatim (the hook built them) and add `verification` (your own success criteria) + `approval_id` (the literal token from the denial). See `agent-approval-protocol/SKILL.md` for the full field list and types.
46
46
 
47
- The `approval_id` is the `P-{...}` token the orchestrator uses to find the
48
- `REQUESTED` row in the DB and validate the fingerprint. Fields written only in
49
- prose are invisible to the presentation -- the user would approve blind.
47
+ The `approval_id` is the `P-{...}` token tying this request to its `REQUESTED`
48
+ row in the DB. Fields written only in prose are invisible to the presentation --
49
+ the user would approve blind.
50
+
51
+ **What your relay is for: same-turn immediacy.** Your `approval_request` is the
52
+ orchestrator's source only for the CURRENT turn. The orchestrator's primary
53
+ source is the per-turn `[PENDING-APPROVALS-VERIFIED]` block injected at
54
+ `UserPromptSubmit`, which carries every pending that has survived >= 1 turn,
55
+ already DB-read and fingerprint-verified. But that block was built before you
56
+ ran this turn, so a pending you mint now is not in it yet -- the orchestrator
57
+ presents it from your relay until the next turn's block picks it up. You emit the
58
+ same fields either way; nothing on your side changes. The orchestrator never
59
+ dispatches a subagent to verify or derive your request -- integrity is enforced
60
+ at grant activation, not at presentation.
50
61
 
51
62
  ## Non-negotiable rules
52
63
 
@@ -105,9 +116,12 @@ your side. What changed underneath: the minted `approval_id` is now
105
116
  (`derive_command_set_id` -> `P-<first 32 hex of sha256(canonical commands)>`),
106
117
  not a random uuid4. You do not compute or emit it (you cannot hash reliably, and
107
118
  you have nothing to attempt yet); the value is purely internal. The reason it
108
- matters: the orchestrator reproduces that exact id from the `command_set` you
109
- emitted (via `gaia approvals derive-id`), with no DB search and no cross-session
110
- miss. Your contract stays the same -- `command_set` of `{command, rationale}`
119
+ matters: the content-derived id is reproducible without a uuid4 that could be
120
+ lost across sessions. Once the minted pending has survived a turn, the
121
+ orchestrator reads it -- with all N commands -- straight from the injected
122
+ `[PENDING-APPROVALS-VERIFIED]` block (no DB search, no derive-dispatch); for the
123
+ turn you mint it in, the orchestrator presents from the `command_set` in your
124
+ relay. Your contract stays the same -- `command_set` of `{command, rationale}`
111
125
  items, no `approval_id`.
112
126
 
113
127
  On the user's approval, that one pending activates into a single `COMMAND_SET`
@@ -16,8 +16,12 @@ payload from the intercepted command and calls
16
16
  3. writes the `REQUESTED` event to the DB.
17
17
 
18
18
  The block message you receive (`[T3_BLOCKED] ...`) ends with `approval_id: P-{...}`.
19
- You relay that token plus the operation details; the orchestrator re-derives the
20
- fingerprint from the DB row.
19
+ You relay that token plus the operation details. For the current turn the
20
+ orchestrator presents from your relay; once the pending survives a turn it
21
+ appears in the per-turn `[PENDING-APPROVALS-VERIFIED]` block, already
22
+ fingerprint-verified by the hook. Payload integrity is enforced at grant
23
+ activation (`verify_fingerprint`), so the orchestrator never dispatches to
24
+ verify or derive your request.
21
25
 
22
26
  Source: `bash_validator._build_sealed_payload()`, the subagent block path in
23
27
  `bash_validator._validate_single_command()`; `gaia/approvals/store.py`
@@ -99,12 +103,14 @@ singular hook-block path (which mints `P-{uuid4hex}`), the intake derives the id
99
103
  from the command_set content via `gaia.approvals.store.derive_command_set_id()`:
100
104
  `P-<first 32 hex of sha256(canonical(post-filter command strings))>`. It then
101
105
  passes that id to `insert_requested(..., approval_id=...)` as the pending row id.
102
- The point is reproducibility without a DB lookup: the orchestrator holds the
103
- same `command_set` (you emitted it in the contract) and reproduces the EXACT id
104
- with `gaia approvals derive-id`, which applies the same mutative filter and the
105
- same canonicalization (`chain.canonical_payload`). This closes the cross-session
106
- miss -- a uuid4 minted at SubagentStop could not be recovered by the parent
107
- (Claude Code #5812), but a content-derived id needs no recovery. The id is
106
+ The point is reproducibility without a fragile uuid4: a uuid4 minted at
107
+ SubagentStop could not be recovered by the parent (Claude Code #5812), but a
108
+ content-derived id needs no recovery -- the same canonicalization
109
+ (`chain.canonical_payload`) and mutative filter always yield the same id. Once
110
+ the minted pending survives a turn, the orchestrator reads that id (and all N
111
+ commands) straight from the injected `[PENDING-APPROVALS-VERIFIED]` block -- no
112
+ DB lookup and no `gaia approvals derive-id` dispatch; for the mint turn it
113
+ presents from the `command_set` in your relay. The id is
108
114
  **order-sensitive** (the consume side matches positionally) and **content-only**
109
115
  (rationale/session/agent are not folded in, so both sides agree from the command
110
116
  list alone). Idempotency follows the existing fingerprint dedup: two identical
@@ -154,15 +160,17 @@ single-use within the 60-minute window.
154
160
  Always `plan_status: "APPROVAL_REQUEST"`. The presence of `approval_id` tells the
155
161
  orchestrator which path:
156
162
 
157
- - **With `approval_id`** -- the hook blocked a single command; orchestrator
158
- validates the fingerprint and activates the single-use semantic grant on user
159
- approval.
163
+ - **With `approval_id`** -- the hook blocked a single command; the orchestrator
164
+ presents from your relay (current turn) or the injected
165
+ `[PENDING-APPROVALS-VERIFIED]` block (later turns), and the single-use semantic
166
+ grant activates on user approval (fingerprint checked at activation).
160
167
  - **Without `approval_id`, with a `command_set` of >= 2 items** -- plan-first
161
168
  batch. The SubagentStop intake processor mints ONE pending `COMMAND_SET` with a
162
- **content-derived** id (`derive_command_set_id`), and the orchestrator
163
- reproduces that exact id from the command_set via `gaia approvals derive-id`
164
- (no DB search) before presenting the single approval (N commands, one nonce).
165
- See "Batch / COMMAND_SET -- wired" above.
169
+ **content-derived** id (`derive_command_set_id`). The orchestrator reads that
170
+ id and the N commands from the injected `[PENDING-APPROVALS-VERIFIED]` block
171
+ (no derive-dispatch), or, for the mint turn, from the `command_set` in your
172
+ relay, then presents the single approval (N commands, one nonce). See
173
+ "Batch / COMMAND_SET -- wired" above.
166
174
  - **Without `approval_id` and without a multi-item `command_set`** -- plan-first
167
175
  single (you are presenting one T3 plan before attempting); the orchestrator
168
176
  gates on user consent before any execution.
@@ -19,7 +19,12 @@ desde el filesystem hacia `~/.gaia/gaia.db`.
19
19
  | 01 | Episodes | `.claude/project-context/episodic-memory/episodes.jsonl` | `episodes` (+`episodes_fts`) |
20
20
  | 02 | Memory | `~/.claude/projects/-home-jorge-ws-me/memory/*.md` | `memory` (+`memory_fts`) |
21
21
  | 03 | Context contracts | `.claude/project-context/project-context.json` | `context_contracts` |
22
- | 04 | Harness events | `.claude/events/events.jsonl` | `harness_events` |
22
+ | 04 | Harness events | ~~`.claude/events/events.jsonl`~~ (ELIMINADO) | `harness_events` |
23
+
24
+ > **Dominio 04 completado y eliminado.** `events.jsonl` y su archivo `.lock` fueron
25
+ > retirados. El hook `event_writer` escribe directamente a `harness_events` en la DB.
26
+ > El script `migrate_04_harness_events.py` y su wrapper `.sh` fueron borrados una vez
27
+ > completada la absorción. Los datos vivos se leen desde `harness_events` en `~/.gaia/gaia.db`.
23
28
 
24
29
  Cada dominio tiene 2 archivos:
25
30
 
@@ -37,8 +42,8 @@ bootstrap.sh # crea/inicializa ~/.gaia/gaia.db con s
37
42
  ./migrate_01_episodes.sh # ~50-80 MB de SQL, batch 80
38
43
  ./migrate_02_memory.sh # 28 .md (MEMORY.md excluido)
39
44
  ./migrate_03_context_contracts.sh # 12 secciones
40
- ./migrate_04_harness_events.sh # ~5-10 MB de SQL, batch 200
41
- ./validate.sh # 5 aserciones read-only
45
+ # migrate_04_harness_events.sh ELIMINADO dominio 04 completado; eventos en DB-canonical
46
+ ./validate.sh # aserciones read-only (V4 eliminada junto con 04)
42
47
  ```
43
48
 
44
49
  Cada script imprime `[migrate_NN] OK` al terminar.
@@ -50,14 +55,7 @@ Cada script imprime `[migrate_NN] OK` al terminar.
50
55
  | 01 episodes | `INSERT OR IGNORE` (PK = `episode_id`) | sí |
51
56
  | 02 memory | `INSERT OR IGNORE` (PK = `(project, name)`) | sí |
52
57
  | 03 context_contracts | `INSERT OR IGNORE` (PK = `(project, section_name)`) | sí |
53
- | 04 harness_events | `INSERT` simple (sin PK natural) | **no duplica filas** |
54
-
55
- Para re-ejecutar 04 limpiamente:
56
-
57
- ```
58
- sqlite3 ~/.gaia/gaia.db "DELETE FROM harness_events WHERE project='me';"
59
- ./migrate_04_harness_events.sh
60
- ```
58
+ | 04 harness_events | N/A tool eliminado; escritura vía `event_writer` DB-direct | N/A |
61
59
 
62
60
  ## Validación
63
61
 
@@ -68,7 +66,7 @@ sqlite3 ~/.gaia/gaia.db "DELETE FROM harness_events WHERE project='me';"
68
66
  | V1 | `COUNT(*) FROM episodes` == líneas no vacías de `episodes.jsonl` |
69
67
  | V2 | `COUNT(*) FROM memory` == archivos `.md` (excluyendo `MEMORY.md`) |
70
68
  | V3 | `COUNT(*) FROM context_contracts` == 12 |
71
- | V4 | `COUNT(*) FROM harness_events` == líneas no vacías de `events.jsonl` |
69
+ | ~~V4~~ | ~~`COUNT(*) FROM harness_events` == líneas no vacías de `events.jsonl`~~ — eliminado junto con el dominio 04 |
72
70
  | V5 | `COUNT(*) FROM episodes_fts` == `COUNT(*) FROM episodes` (FTS sync) |
73
71
 
74
72
  Exit code: 0 si todas pasan, 1 si alguna falla.