@jaguilar87/gaia 5.0.7 → 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 (99) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +13 -0
  4. package/bin/README.md +6 -1
  5. package/bin/cli/approvals.py +486 -474
  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 +92 -86
  10. package/dist/gaia-ops/hooks/modules/agents/handoff_persister.py +13 -2
  11. package/dist/gaia-ops/hooks/modules/context/context_injector.py +23 -7
  12. package/dist/gaia-ops/hooks/modules/events/event_writer.py +63 -96
  13. package/dist/gaia-ops/hooks/modules/security/__init__.py +0 -2
  14. package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +238 -69
  15. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +506 -1103
  16. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +24 -1
  17. package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +150 -90
  18. package/dist/gaia-ops/hooks/modules/session/session_manifest.py +257 -28
  19. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +19 -0
  20. package/dist/gaia-ops/hooks/post_compact.py +1 -0
  21. package/dist/gaia-ops/hooks/pre_compact.py +1 -0
  22. package/dist/gaia-ops/hooks/user_prompt_submit.py +20 -0
  23. package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +50 -14
  24. package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +16 -9
  25. package/dist/gaia-ops/skills/agent-protocol/examples.md +12 -1
  26. package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -2
  27. package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +69 -22
  28. package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +16 -3
  29. package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +20 -14
  30. package/dist/gaia-ops/skills/pending-approvals/SKILL.md +16 -11
  31. package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +28 -3
  32. package/dist/gaia-ops/skills/subagent-request-approval/reference.md +34 -8
  33. package/dist/gaia-ops/tools/migration/README.md +10 -12
  34. package/dist/gaia-ops/tools/scan/orchestrator.py +194 -10
  35. package/dist/gaia-ops/tools/scan/tests/test_integration.py +1 -2
  36. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  37. package/dist/gaia-security/hooks/adapters/claude_code.py +92 -86
  38. package/dist/gaia-security/hooks/modules/agents/handoff_persister.py +13 -2
  39. package/dist/gaia-security/hooks/modules/context/context_injector.py +23 -7
  40. package/dist/gaia-security/hooks/modules/events/event_writer.py +63 -96
  41. package/dist/gaia-security/hooks/modules/security/__init__.py +0 -2
  42. package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +238 -69
  43. package/dist/gaia-security/hooks/modules/security/approval_grants.py +506 -1103
  44. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +24 -1
  45. package/dist/gaia-security/hooks/modules/session/pending_scanner.py +150 -90
  46. package/dist/gaia-security/hooks/modules/session/session_manifest.py +257 -28
  47. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +19 -0
  48. package/dist/gaia-security/hooks/user_prompt_submit.py +20 -0
  49. package/gaia/approvals/__init__.py +2 -1
  50. package/gaia/approvals/store.py +165 -15
  51. package/gaia/store/schema.sql +38 -1
  52. package/gaia/store/writer.py +400 -0
  53. package/hooks/adapters/claude_code.py +92 -86
  54. package/hooks/elicitation_result.py +20 -75
  55. package/hooks/modules/agents/handoff_persister.py +13 -2
  56. package/hooks/modules/context/context_injector.py +23 -7
  57. package/hooks/modules/events/event_writer.py +63 -96
  58. package/hooks/modules/security/__init__.py +0 -2
  59. package/hooks/modules/security/approval_cleanup.py +238 -69
  60. package/hooks/modules/security/approval_grants.py +506 -1103
  61. package/hooks/modules/security/mutative_verbs.py +24 -1
  62. package/hooks/modules/session/pending_scanner.py +150 -90
  63. package/hooks/modules/session/session_manifest.py +257 -28
  64. package/hooks/modules/tools/bash_validator.py +19 -0
  65. package/hooks/post_compact.py +1 -0
  66. package/hooks/pre_compact.py +1 -0
  67. package/hooks/user_prompt_submit.py +20 -0
  68. package/package.json +1 -1
  69. package/pyproject.toml +1 -1
  70. package/scripts/bootstrap_database.sh +66 -17
  71. package/scripts/migrations/README.md +26 -14
  72. package/scripts/migrations/schema.checksum +2 -2
  73. package/scripts/migrations/v18_to_v19.sql +36 -0
  74. package/scripts/migrations/v19_to_v20.sql +20 -0
  75. package/skills/agent-approval-protocol/SKILL.md +50 -14
  76. package/skills/agent-approval-protocol/reference.md +16 -9
  77. package/skills/agent-protocol/examples.md +12 -1
  78. package/skills/gaia-patterns/reference.md +2 -2
  79. package/skills/orchestrator-present-approval/SKILL.md +69 -22
  80. package/skills/orchestrator-present-approval/reference.md +16 -3
  81. package/skills/orchestrator-present-approval/template.md +20 -14
  82. package/skills/pending-approvals/SKILL.md +16 -11
  83. package/skills/subagent-request-approval/SKILL.md +28 -3
  84. package/skills/subagent-request-approval/reference.md +34 -8
  85. package/tools/migration/README.md +10 -12
  86. package/tools/scan/orchestrator.py +194 -10
  87. package/tools/scan/tests/test_integration.py +1 -2
  88. package/bin/cli/plans.py +0 -517
  89. package/dist/gaia-ops/tools/context/deep_merge.py +0 -159
  90. package/dist/gaia-ops/tools/migration/migrate_04_harness_events.py +0 -132
  91. package/dist/gaia-ops/tools/migration/migrate_04_harness_events.sh +0 -23
  92. package/dist/gaia-ops/tools/scan/merge.py +0 -213
  93. package/dist/gaia-ops/tools/scan/tests/test_merge.py +0 -269
  94. package/gaia/approvals/revert.py +0 -282
  95. package/tools/context/deep_merge.py +0 -159
  96. package/tools/migration/migrate_04_harness_events.py +0 -132
  97. package/tools/migration/migrate_04_harness_events.sh +0 -23
  98. package/tools/scan/merge.py +0 -213
  99. package/tools/scan/tests/test_merge.py +0 -269
@@ -90,6 +90,11 @@ class BashValidationResult:
90
90
  # plain error string (exit 2). Used for structured block responses that
91
91
  # should correct the agent rather than terminate execution.
92
92
  block_response: Optional[Dict[str, Any]] = None
93
+ # When a T3 command is allowed because it matched (and consumed) an active
94
+ # grant, this carries the approval_id of that grant. The adapter stashes it
95
+ # in HookState so PostToolUse can append an EXECUTED/FAILED event to the
96
+ # approval_events chain for this approval. None for non-T3 / no-grant paths.
97
+ consumed_approval_id: Optional[str] = None
93
98
 
94
99
  def __post_init__(self):
95
100
  if self.suggestions is None:
@@ -667,6 +672,7 @@ class BashValidator:
667
672
  allowed=True,
668
673
  tier=SecurityTier.T3_BLOCKED,
669
674
  reason="Command-set grant matched",
675
+ consumed_approval_id=cs_approval_id,
670
676
  )
671
677
 
672
678
  # DB-primary + filesystem-fallback grant check.
@@ -720,6 +726,7 @@ class BashValidator:
720
726
  allowed=True,
721
727
  tier=SecurityTier.T3_BLOCKED,
722
728
  reason="Grant confirmed",
729
+ consumed_approval_id=db_approval_id,
723
730
  )
724
731
  else:
725
732
  # Filesystem grant exists, not yet confirmed -- GAIA approved,
@@ -733,6 +740,7 @@ class BashValidator:
733
740
  allowed=True,
734
741
  tier=SecurityTier.T3_BLOCKED,
735
742
  reason="Grant active, pending confirmation",
743
+ consumed_approval_id=db_approval_id,
736
744
  )
737
745
  else:
738
746
  # Converge on the single T3 decision point. When there is an
@@ -808,6 +816,7 @@ class BashValidator:
808
816
  allowed=True,
809
817
  tier=SecurityTier.T3_BLOCKED,
810
818
  reason="Command-set grant matched",
819
+ consumed_approval_id=cs_approval_id,
811
820
  )
812
821
 
813
822
  grant = check_approval_grant(command, session_id=session_id)
@@ -859,6 +868,7 @@ class BashValidator:
859
868
  allowed=True,
860
869
  tier=SecurityTier.T3_BLOCKED,
861
870
  reason="Grant confirmed",
871
+ consumed_approval_id=db_approval_id,
862
872
  )
863
873
  else:
864
874
  logger.info(
@@ -870,6 +880,7 @@ class BashValidator:
870
880
  allowed=True,
871
881
  tier=SecurityTier.T3_BLOCKED,
872
882
  reason="Grant active, pending confirmation",
883
+ consumed_approval_id=db_approval_id,
873
884
  )
874
885
 
875
886
  # No grant matched -- converge on the single T3 decision
@@ -939,10 +950,18 @@ class BashValidator:
939
950
  key=lambda t: tier_order.index(t.value),
940
951
  )
941
952
 
953
+ # Propagate the consumed approval_id from whichever component matched a
954
+ # grant, so PostToolUse can append EXECUTED/FAILED for that approval.
955
+ consumed_approval_id = next(
956
+ (r.consumed_approval_id for r in component_results if r.consumed_approval_id),
957
+ None,
958
+ )
959
+
942
960
  return BashValidationResult(
943
961
  allowed=True,
944
962
  tier=highest_tier,
945
963
  reason=f"All {len(components)} components validated",
964
+ consumed_approval_id=consumed_approval_id,
946
965
  )
947
966
 
948
967
  def _phase4_check_composition(
@@ -35,6 +35,7 @@ def _handle_post_compact(event) -> None:
35
35
 
36
36
  response = {
37
37
  "hookSpecificOutput": {
38
+ "hookEventName": "PostCompact",
38
39
  "additionalContext": context,
39
40
  }
40
41
  }
@@ -52,6 +52,7 @@ def _handle_pre_compact(event) -> None:
52
52
 
53
53
  response = {
54
54
  "hookSpecificOutput": {
55
+ "hookEventName": "PreCompact",
55
56
  "additionalContext": context,
56
57
  }
57
58
  }
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaguilar87/gaia",
3
- "version": "5.0.7",
3
+ "version": "5.0.9",
4
4
  "description": "Multi-agent orchestration system for Claude Code - DevOps automation toolkit",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gaia"
3
- version = "5.0.7"
3
+ version = "5.0.9"
4
4
  description = "Multi-agent orchestration system for Claude Code - DevOps automation toolkit"
5
5
  requires-python = ">=3.11"
6
6
  license = {text = "MIT"}
@@ -211,9 +211,13 @@ fi
211
211
  # EXPECTED_SCHEMA_VERSION en doctor.py en el mismo commit.
212
212
  # - Para una DB en el FLOOR, esa migración corre directo (la DB está en el
213
213
  # estado source de la migración). No se necesitan variantes _fresh: un
214
- # fresh install ya está en EXPECTED tras schema.sql, así que el loop no
215
- # entra (CURRENT == EXPECTED). El guard-probe por-versión del modelo viejo
216
- # desaparece junto con la cadena histórica.
214
+ # fresh install sella el ledger en el FLOOR (Section 3b) y, cuando hay
215
+ # migraciones forward (EXPECTED > FLOOR), ESTE loop se replaya en cada
216
+ # fresh install desde FLOOR+1 hasta EXPECTED contra una DB cuyos objetos
217
+ # schema.sql ya creó -- por eso las migraciones DEBEN ser idempotentes
218
+ # (CREATE ... IF NOT EXISTS; ADD COLUMN neutralizado por el guard runner).
219
+ # El guard-probe por-versión del modelo viejo desaparece junto con la
220
+ # cadena histórica.
217
221
  #
218
222
  # Cada migración corre en su propia transacción BEGIN/COMMIT. Si falla, abort
219
223
  # -- el ledger NO avanza y el próximo bootstrap retry ve la misma pendiente.
@@ -245,13 +249,58 @@ echo "[bootstrap] schema_version: current=${CURRENT_VERSION}, expected=${EXPECTE
245
249
 
246
250
  MIG_DIR="${SCRIPT_DIR}/migrations"
247
251
 
252
+ # --- Idempotent ADD COLUMN guard (runner-level) ------------------------------
253
+ #
254
+ # Forward migrations are applied on EVERY fresh install: schema.sql produces the
255
+ # EXPECTED shape, the ledger is stamped at the FLOOR, and Section 3c walks
256
+ # FLOOR+1..EXPECTED (this is what test_fresh_install_stamps_floor and
257
+ # test_bootstrap_idempotent_at_floor require). Because schema.sql already
258
+ # carries each migration's target DDL (see migrations/README.md section 1:
259
+ # "add the DDL to schema.sql AND create the migration"), a migration is always
260
+ # replayed against a DB that already has its objects.
261
+ #
262
+ # CREATE ... IF NOT EXISTS makes CREATE statements idempotent under that replay
263
+ # (v18_to_v19 relies on it). But SQLite has NO `ADD COLUMN IF NOT EXISTS`, so a
264
+ # bare `ALTER TABLE t ADD COLUMN c` aborts with "duplicate column name" when the
265
+ # column already exists from schema.sql. This guard restores idempotency for
266
+ # ADD COLUMN at the RUNNER level (not by putting invalid SQL in the .sql file):
267
+ # for each `ALTER TABLE <t> ADD COLUMN <c> ...` line, if column <c> already
268
+ # exists on table <t> (PRAGMA table_info), the line is neutralised (commented
269
+ # out) before the migration runs. Every other statement passes through verbatim.
270
+ #
271
+ # Pure bash + sqlite3, no python3 -- consistent with this script's principles.
272
+ _filter_add_column_idempotent() {
273
+ # $1 = path to the migration .sql file. Emits the (possibly filtered) SQL on
274
+ # stdout. Lines that are `ALTER TABLE t ADD COLUMN c` for an existing column
275
+ # are replaced by a comment; all other lines are passed through unchanged.
276
+ local mig_file="$1"
277
+ local line lower table col exists
278
+ while IFS= read -r line || [ -n "$line" ]; do
279
+ # Normalise whitespace for matching only (emit the ORIGINAL line).
280
+ lower="$(printf '%s' "$line" | tr '[:upper:]' '[:lower:]')"
281
+ if [[ "$lower" =~ alter[[:space:]]+table[[:space:]]+([a-z0-9_]+)[[:space:]]+add[[:space:]]+column[[:space:]]+([a-z0-9_]+) ]]; then
282
+ table="${BASH_REMATCH[1]}"
283
+ col="${BASH_REMATCH[2]}"
284
+ exists="$(sqlite3 "$GAIA_DB" "SELECT COUNT(*) FROM pragma_table_info('${table}') WHERE name='${col}';")"
285
+ if [ "$exists" -gt 0 ]; then
286
+ printf -- '-- [bootstrap] skipped (column %s.%s already present): %s\n' "$table" "$col" "$line"
287
+ continue
288
+ fi
289
+ fi
290
+ printf '%s\n' "$line"
291
+ done < "$mig_file"
292
+ }
293
+
248
294
  if [ "$CURRENT_VERSION" -lt "$EXPECTED_VERSION" ]; then
249
- # Forward-only loop. Reaches here only when a FUTURE migration has been
250
- # added (EXPECTED_SCHEMA_VERSION > FLOOR) and the live DB is behind it.
251
- # On a fresh install the DB is already at EXPECTED (schema.sql produced the
252
- # FLOOR == EXPECTED shape when no forward migrations exist), so this branch
253
- # is skipped entirely. Any DB below the FLOOR was already rejected in
254
- # Section 3b, so CURRENT_VERSION here is always >= FLOOR.
295
+ # Forward-only loop. Runs whenever the live DB is behind EXPECTED, which
296
+ # INCLUDES a fresh install: Section 3b stamps the ledger at the FLOOR, and
297
+ # when forward migrations exist (EXPECTED > FLOOR) a fresh DB sits at the
298
+ # FLOOR while EXPECTED is higher, so it enters here and replays FLOOR+1..
299
+ # EXPECTED. That replay runs against a DB whose objects schema.sql already
300
+ # created, which is exactly why these migrations MUST be idempotent (CREATE
301
+ # ... IF NOT EXISTS; ADD COLUMN neutralised by the runner guard above).
302
+ # Any DB below the FLOOR was already rejected in Section 3b, so
303
+ # CURRENT_VERSION here is always >= FLOOR.
255
304
  for N in $(seq $((CURRENT_VERSION + 1)) "$EXPECTED_VERSION"); do
256
305
  PREV=$((N - 1))
257
306
  MIG_FILE="${MIG_DIR}/v${PREV}_to_v${N}.sql"
@@ -263,15 +312,15 @@ if [ "$CURRENT_VERSION" -lt "$EXPECTED_VERSION" ]; then
263
312
  exit 1
264
313
  fi
265
314
 
266
- # Forward-only: a DB at the FLOOR (or any version below N) is in the
267
- # source state of this migration, so we apply it directly inside an
268
- # explicit transaction. No per-version guard probe and no _fresh
269
- # variant are needed -- the historical "schema.sql already created the
270
- # target table" case only existed because the baseline was v1 and the
271
- # whole chain was walked on every fresh install. Under the FLOOR model
272
- # a fresh install is already at EXPECTED, so it never enters this loop.
315
+ # Apply the migration inside an explicit transaction. The SQL is passed
316
+ # through _filter_add_column_idempotent first so that `ADD COLUMN`
317
+ # statements for columns schema.sql already created are skipped (SQLite
318
+ # lacks `ADD COLUMN IF NOT EXISTS`). CREATE ... IF NOT EXISTS statements
319
+ # are already idempotent and pass through unchanged. This is what lets a
320
+ # fresh install (where schema.sql produced the EXPECTED shape) replay the
321
+ # FLOOR+1..EXPECTED migrations without aborting on duplicate columns.
273
322
  echo "[bootstrap] migration v${PREV}->v${N}: applying ${MIG_FILE}"
274
- MIG_SQL="$(cat "$MIG_FILE")"
323
+ MIG_SQL="$(_filter_add_column_idempotent "$MIG_FILE")"
275
324
  if ! sqlite3 "$GAIA_DB" <<EOF
276
325
  BEGIN;
277
326
  ${MIG_SQL}
@@ -17,25 +17,31 @@ The floor is **v18**. It is declared in three places that must agree:
17
17
 
18
18
  | Location | What it holds |
19
19
  |----------|---------------|
20
- | `gaia/store/schema.sql` | Produces the v18 shape directly (fresh installs land here). |
21
- | `scripts/bootstrap_database.sh` Section 3b (`SCHEMA_FLOOR=18`) | Seeds/stamps the ledger at the floor; rejects DBs below it. |
22
- | `bin/cli/doctor.py` (`EXPECTED_SCHEMA_VERSION`) | The version the CLI expects; equals the floor until a forward migration is added. |
20
+ | `gaia/store/schema.sql` | Produces the **latest** (EXPECTED) shape directly -- fresh installs land here, not at the floor. |
21
+ | `scripts/bootstrap_database.sh` Section 3b (`SCHEMA_FLOOR=18`) | Stamps the fresh ledger at the floor; rejects DBs below it. |
22
+ | `bin/cli/doctor.py` (`EXPECTED_SCHEMA_VERSION`) | The version the CLI expects; equals the floor when no forward migration exists, and the highest migration target once they do. |
23
23
 
24
24
  How bootstrap treats each case:
25
25
 
26
26
  * **Fresh install** (no `schema_version` rows): `schema.sql` already produced
27
- the floor shape, so bootstrap stamps `(version=18, ...)` directly. It does
28
- **not** seed v1 and walk the chain.
29
- * **DB at or above the floor** (the common case, e.g. `~/.gaia/gaia.db`): no
30
- migration needed. Section 3c only runs if a forward migration exists.
27
+ the EXPECTED shape, and Section 3b stamps the ledger at the **floor** (not
28
+ EXPECTED). Section 3c then replays every forward migration from `floor+1` to
29
+ EXPECTED against that already-current DB. It does **not** seed v1 and walk the
30
+ historical chain. Because the migrations run against objects `schema.sql`
31
+ already created, they **must be idempotent** (see section 1).
32
+ * **DB at or above the floor** (the common case, e.g. `~/.gaia/gaia.db`):
33
+ Section 3c applies any forward migrations the DB is still behind on, up to
34
+ EXPECTED.
31
35
  * **DB below the floor** (`1 <= version < 18`): **no longer supported** for
32
36
  in-place upgrade. Bootstrap aborts with a clear message asking you to
33
37
  recreate the DB (back up, delete `~/.gaia/gaia.db`, re-run `gaia install`).
34
38
 
35
39
  There are no `_fresh` / `_merge` variants under the floor model. Those existed
36
40
  only because the old baseline was v1 and the whole chain was walked on every
37
- fresh install. With the floor, a fresh install is already at the expected
38
- version after `schema.sql`, so the migration loop is skipped entirely.
41
+ fresh install. Under the floor model the forward-migration loop is still
42
+ replayed on every fresh install (from `floor+1` to EXPECTED) -- so the single
43
+ forward migration file per bump must be idempotent rather than split into
44
+ `_fresh` / `_merge` variants.
39
45
 
40
46
  ---
41
47
 
@@ -48,14 +54,19 @@ version) to `N`:
48
54
  1. Add the new DDL to `gaia/store/schema.sql` so fresh installs land in the
49
55
  target shape.
50
56
  2. Create exactly one `scripts/migrations/v{N-1}_to_v{N}.sql` containing the
51
- full DDL delta applied to a DB at version `N-1`.
57
+ full DDL delta applied to a DB at version `N-1`. It **must be idempotent**
58
+ (`CREATE ... IF NOT EXISTS`; for `ADD COLUMN`, rely on the runner's
59
+ existence guard -- SQLite has no `ADD COLUMN IF NOT EXISTS`), because it is
60
+ replayed on fresh installs against a DB that already has those objects.
52
61
  3. Bump `EXPECTED_SCHEMA_VERSION` to `N` in `bin/cli/doctor.py` **in the same
53
62
  commit**.
54
63
 
55
64
  `bootstrap_database.sh` Section 3c then applies `v{N-1}_to_v{N}.sql` inside a
56
65
  single `BEGIN/COMMIT` transaction for any DB behind `N`, and stamps the ledger
57
- only on success. A fresh install is already at `N` after `schema.sql`, so it
58
- never enters the loop -- no `_fresh` variant is required.
66
+ only on success. A fresh install stamps the ledger at the floor (Section 3b)
67
+ and then replays `floor+1 .. N` here too -- since `schema.sql` already produced
68
+ the `N` shape, the migration runs against objects that already exist, which is
69
+ exactly why it must be idempotent (no `_fresh` variant is used).
59
70
 
60
71
  `tests/cli/test_schema_version_lockstep.py` enforces that
61
72
  `EXPECTED_SCHEMA_VERSION` equals the floor when no forward migrations exist,
@@ -93,8 +104,9 @@ as the ledger grows.
93
104
  | `vN_to_vN+1.sql` | Applied to an existing DB at version N. Contains the full DDL delta, applied inside a `BEGIN/COMMIT` transaction by bootstrap Section 3c. |
94
105
 
95
106
  The historical `_fresh` and `_merge` variants are no longer used: under the
96
- floor model a fresh install is already at the expected version after
97
- `schema.sql`, so it never runs a migration script.
107
+ floor model a single idempotent `vN_to_vN+1.sql` covers both an in-place
108
+ upgrade and the fresh-install replay (Section 3c walks `floor+1 .. EXPECTED`
109
+ on every fresh install), so one idempotent file replaces the old split.
98
110
 
99
111
  ---
100
112
 
@@ -4,5 +4,5 @@
4
4
  # corresponds to (EXPECTED_SCHEMA_VERSION in bin/cli/doctor.py).
5
5
  # Do NOT edit by hand: bump EXPECTED_SCHEMA_VERSION + add a migration,
6
6
  # then re-run the guard to refresh this file.
7
- version=18
8
- sha256=6f728de0625d5011b86eaf536c21785d19cfa08592ecaf086ab46f9b0d0ebda0
7
+ version=20
8
+ sha256=027c8a61e8217b40cbdb07d0b83e7fb18f1ec671d3069dc0a0ce6bb4cc0e8ee9
@@ -0,0 +1,36 @@
1
+ -- Migration v18 -> v19: audit-immutability gap closure (Task B).
2
+ --
3
+ -- Adds BEFORE UPDATE trigger bu_approvals_status_has_event on the approvals
4
+ -- table to enforce that every approvals.status transition is accompanied by a
5
+ -- preceding event row in the append-only approval_events chain.
6
+ --
7
+ -- The trigger fires when status changes to 'approved', 'rejected', or 'revoked'.
8
+ -- It checks that an event row with the matching event_type exists for the
9
+ -- approval_id within the same transaction. If no matching event is found it
10
+ -- raises ABORT, rolling back the UPDATE.
11
+ --
12
+ -- This closes the gap where a direct UPDATE approvals SET status = 'approved'
13
+ -- could flip the status column without leaving an auditable event, violating
14
+ -- the "auditable + immutable" invariant of the approval_events chain.
15
+ --
16
+ -- Bootstrap note: a fresh install (schema.sql) already includes this trigger
17
+ -- via CREATE TRIGGER IF NOT EXISTS; this migration only adds it to existing DBs
18
+ -- that were initialized before v19.
19
+
20
+ CREATE TRIGGER IF NOT EXISTS bu_approvals_status_has_event
21
+ BEFORE UPDATE OF status ON approvals
22
+ WHEN NEW.status != OLD.status AND NEW.status IN ('approved', 'rejected', 'revoked')
23
+ BEGIN
24
+ SELECT CASE
25
+ WHEN (
26
+ SELECT COUNT(*) FROM approval_events
27
+ WHERE approval_id = NEW.id
28
+ AND event_type = CASE NEW.status
29
+ WHEN 'approved' THEN 'APPROVED'
30
+ WHEN 'rejected' THEN 'REJECTED'
31
+ WHEN 'revoked' THEN 'REVOKED'
32
+ END
33
+ ) = 0
34
+ THEN RAISE(ABORT, 'approvals: status change requires a preceding event in approval_events')
35
+ END;
36
+ END;
@@ -0,0 +1,20 @@
1
+ -- Migration v19 -> v20: add multi_use and confirmed columns to approval_grants.
2
+ --
3
+ -- These two columns support the upcoming FS-grant-plane migration:
4
+ -- multi_use INTEGER NOT NULL DEFAULT 0 -- 1 = multi-use grant, 0 = single-use (BOOLEAN)
5
+ -- confirmed INTEGER NOT NULL DEFAULT 0 -- 1 = grant confirmed by user, 0 = pending (BOOLEAN)
6
+ --
7
+ -- Both columns use the established boolean-as-INTEGER convention (DEFAULT 0)
8
+ -- already in use across this schema (e.g. allow_write, can_read, can_write).
9
+ --
10
+ -- SQLite ALTER TABLE ADD COLUMN is safe and additive: existing rows receive the
11
+ -- DEFAULT value and the table is NOT rebuilt. Zero data loss is guaranteed by
12
+ -- the SQLite specification (https://www.sqlite.org/lang_altertable.html).
13
+ --
14
+ -- Bootstrap note: migrations run once via the version chain. A fresh install
15
+ -- seeds from schema.sql (which already includes both columns) and then skips
16
+ -- this migration file by version-gating; this file runs only against existing
17
+ -- DBs initialized before v20 (which do not yet have these columns).
18
+
19
+ ALTER TABLE approval_grants ADD COLUMN multi_use INTEGER NOT NULL DEFAULT 0;
20
+ ALTER TABLE approval_grants ADD COLUMN confirmed INTEGER NOT NULL DEFAULT 0;
@@ -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
@@ -21,10 +35,20 @@ the hash chain, grant activation, reading a granted approval from Python -- see
21
35
 
22
36
  ## approval_id format
23
37
 
38
+ For a **singular** T3 approval (the hook-block path),
24
39
  `store._generate_approval_id()` returns `P-{uuid4().hex}` (e.g.
25
- `P-b1bdfbb0b9474bf5b3f86b1f6a213f7a`). The `P-` prefix is mandatory: without it
26
- the PostToolUse hook cannot do targeted grant activation. The first 8 hex chars
27
- after `P-` are the nonce prefix shown in option labels: `[P-b1bdfbb0]`.
40
+ `P-b1bdfbb0b9474bf5b3f86b1f6a213f7a`) -- a random, unique id the subagent relays
41
+ verbatim. For a **plan-first `COMMAND_SET`** the id is instead **content-derived**
42
+ by `store.derive_command_set_id()`: `P-<first 32 hex of
43
+ sha256(canonical(command strings))>`. The two share the `P-` prefix and 32-hex
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
50
+ hook cannot do targeted grant activation. The first 8 hex chars after `P-` are
51
+ the nonce prefix shown in option labels: `[P-b1bdfbb0]`.
28
52
 
29
53
  ## APPROVAL_REQUEST contract shape
30
54
 
@@ -55,8 +79,11 @@ becomes `rollback` in the contract; `commands` (`[exact_content]`) and
55
79
  }
56
80
  ```
57
81
 
58
- There is no `batch_scope` field: the `verb_family` grant was removed, so each
59
- blocked command gets its own single-use grant. See
82
+ There is no `batch_scope` field: the `verb_family` grant was removed. For a
83
+ single blocked command, each gets its own single-use `SCOPE_SEMANTIC_SIGNATURE`
84
+ grant. For a batch of >= 2 T3 commands known up-front, emit a `command_set`
85
+ list and **no** `approval_id` -- the SubagentStop intake mints a single
86
+ `COMMAND_SET` grant (one consent covers all). See
60
87
  `Skill('orchestrator-present-approval')` for the orchestrator side.
61
88
 
62
89
  ## Status vocabularies -- distinct columns, opposite casing, never collapse
@@ -69,8 +96,8 @@ blocked command gets its own single-use grant. See
69
96
  ## Event chain
70
97
 
71
98
  The `approval_events.event_type` CHECK admits nine values: `REQUESTED` `SHOWN`
72
- `APPROVED` `REJECTED` `EXECUTED` `FAILED` `NOOP` `REVOKED` `REVERTED`. Only these
73
- are written by production code today:
99
+ `APPROVED` `REJECTED` `EXECUTED` `FAILED` `NOOP` `REVOKED` `REVERTED`. These are
100
+ written by production code today:
74
101
 
75
102
  | Event | Who writes it | When |
76
103
  |-------|--------------|------|
@@ -78,11 +105,16 @@ are written by production code today:
78
105
  | `SHOWN` | ElicitationResult hook via `activate_db_pending_by_prefix()` | User selects an Approve `[P-xxx]` label |
79
106
  | `APPROVED` | ElicitationResult hook (same call as `SHOWN`) | Immediately after `SHOWN` |
80
107
  | `REJECTED` / `REVOKED` | `gaia approvals` CLI via `store.reject()` / `store.revoke()` | User rejects or admin cancels |
108
+ | `EXECUTED` / `FAILED` | PostToolUse adapter (`_record_t3_outcome_event`) via `store.record_event()` | An approved T3 command runs under a consumed grant -- `EXECUTED` on clean exit, `FAILED` otherwise |
81
109
 
82
- `EXECUTED` `FAILED` `NOOP` `REVERTED` are valid in the CHECK and are *read* by
83
- `store.get_executed_payload()` and `revert.py`, but no production hook *writes*
84
- them today -- treat them as a designed extension point, not a live invariant. Do
85
- not assume an `EXECUTED` event exists after a command runs.
110
+ The PostToolUse path closes the audit cycle: PreToolUse stashes the consumed
111
+ grant's `approval_id` in `HookState`, and PostToolUse appends `EXECUTED` or
112
+ `FAILED` for that approval, continuing the hash chain through `record_event()`.
113
+ `store.get_executed_payload()` and `gaia approvals replay` read the `EXECUTED`
114
+ payload to re-present the commands that ran. `NOOP` and `REVERTED` remain valid
115
+ in the CHECK but are **inert** -- no production code writes them (the revert
116
+ feature was removed). Do not assume an `EXECUTED` event exists for an approval
117
+ whose command never ran, or that ran through the redirect-sanitized path.
86
118
 
87
119
  ## Key invariants
88
120
 
@@ -90,9 +122,13 @@ not assume an `EXECUTED` event exists after a command runs.
90
122
  - `SHOWN` precedes `APPROVED`; the activation path writes them together.
91
123
  - `approval_events` is append-only -- the `bu_approval_events_immutable` and
92
124
  `bd_approval_events_immutable` triggers `RAISE(ABORT)` on UPDATE/DELETE.
93
- - The orchestrator MUST re-verify a relayed payload via
94
- `chain.verify_fingerprint(approval_id, payload_json, con)` before presenting;
95
- 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.
96
132
 
97
133
  For the grant activation walk-through, fingerprint internals, reading a granted
98
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
 
@@ -27,9 +32,11 @@ Each event links to the previous via `prev_hash` -> `this_hash`
27
32
  Because `approval_events` is append-only (UPDATE/DELETE blocked by the
28
33
  `bu_approval_events_immutable` and `bd_approval_events_immutable` triggers),
29
34
  `this_hash` is computed in the application layer before INSERT, inside
30
- `chain.insert_event()` -- not by a DB trigger. `REVERTED` events, when written,
31
- carry the original `event_id` in `metadata_json` per the revert design (D14);
32
- see `gaia/approvals/revert.py`.
35
+ `chain.insert_event()` -- not by a DB trigger. `EXECUTED` / `FAILED` events,
36
+ appended by the PostToolUse adapter through `store.record_event()` after an
37
+ approved T3 command runs, extend the same chain. `REVERTED` remains a valid
38
+ CHECK value but is **inert** -- the revert feature was removed, so no code
39
+ writes it.
33
40
 
34
41
  ## Grant activation walk-through
35
42
 
@@ -330,4 +330,15 @@ The agent discovered a project fact a section it owns did not yet hold, and writ
330
330
 
331
331
  ## Notes on multi-command APPROVAL_REQUEST sweeps
332
332
 
333
- There is no batch/multi-use grant in the current code: the legacy `verb_family` grant was removed (`hooks/modules/security/approval_grants.py`) and its `COMMAND_SET` replacement has no production activation path yet. Do **not** emit a `batch_scope` field -- it is ignored. When one intent expands into many T3 commands, each blocked command produces its own single-use approval; emit one `APPROVAL_REQUEST` per blocked command (shape identical to example 4 above) and let the user approve each.
333
+ **Just-in-time (unknown batch):** when T3 commands appear one at a time as the
334
+ agent works, each blocked command produces its own `APPROVAL_REQUEST` with an
335
+ `approval_id` (shape identical to example 4 above). Do not emit `batch_scope`
336
+ -- it is ignored.
337
+
338
+ **Plan-first (known batch):** when the agent knows >= 2 T3 commands up-front,
339
+ emit ONE `APPROVAL_REQUEST` carrying a `command_set` list of `{command,
340
+ rationale}` items and **no** `approval_id`. The SubagentStop intake
341
+ (`handoff_persister._intake_command_set_pending`) mints a single `COMMAND_SET`
342
+ approval; the orchestrator presents it as one consent covering all N commands.
343
+ Each command then runs on its own retry, byte-for-byte matched and consumed
344
+ individually.
@@ -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 |