@jaguilar87/gaia 5.1.0 → 5.1.1

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 (92) 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/agents/gaia-orchestrator.md +4 -10
  5. package/bin/README.md +1 -1
  6. package/bin/cli/approvals.py +0 -144
  7. package/bin/cli/cleanup.py +1 -30
  8. package/bin/cli/context.py +379 -6
  9. package/bin/cli/doctor.py +1 -1
  10. package/bin/cli/history.py +18 -6
  11. package/bin/cli/install.py +4 -2
  12. package/bin/cli/memory.py +86 -10
  13. package/bin/cli/metrics.py +1216 -275
  14. package/bin/cli/query.py +89 -0
  15. package/bin/cli/scan.py +68 -2
  16. package/bin/cli/uninstall.py +9 -5
  17. package/bin/pre-publish-validate.js +7 -4
  18. package/gaia/project.py +49 -20
  19. package/gaia/store/reader.py +175 -11
  20. package/gaia/store/schema.sql +180 -1
  21. package/gaia/store/writer.py +1146 -165
  22. package/hooks/README.md +2 -2
  23. package/hooks/adapters/claude_code.py +6 -12
  24. package/hooks/elicitation_result.py +4 -9
  25. package/hooks/modules/agents/contract_validator.py +2 -1
  26. package/hooks/modules/agents/handoff_persister.py +5 -220
  27. package/hooks/modules/agents/task_info_builder.py +9 -2
  28. package/hooks/modules/agents/transcript_reader.py +41 -21
  29. package/hooks/modules/audit/logger.py +8 -3
  30. package/hooks/modules/context/context_injector.py +10 -3
  31. package/hooks/modules/core/__init__.py +5 -2
  32. package/hooks/modules/core/logging_setup.py +60 -0
  33. package/hooks/modules/core/paths.py +0 -12
  34. package/hooks/modules/security/approval_grants.py +24 -92
  35. package/hooks/modules/security/gaia_db_write_guard.py +7 -2
  36. package/hooks/modules/security/mutative_verbs.py +194 -11
  37. package/hooks/modules/session/pending_scanner.py +24 -222
  38. package/hooks/modules/session/session_manifest.py +13 -288
  39. package/hooks/post_compact.py +4 -9
  40. package/hooks/post_tool_use.py +4 -9
  41. package/hooks/pre_compact.py +5 -10
  42. package/hooks/pre_tool_use.py +4 -11
  43. package/hooks/session_end_hook.py +4 -9
  44. package/hooks/session_start.py +18 -21
  45. package/hooks/stop_hook.py +5 -10
  46. package/hooks/subagent_start.py +5 -10
  47. package/hooks/subagent_stop.py +9 -23
  48. package/hooks/task_completed.py +5 -10
  49. package/hooks/user_prompt_submit.py +4 -9
  50. package/package.json +1 -1
  51. package/pyproject.toml +1 -1
  52. package/scripts/migrations/schema.checksum +2 -2
  53. package/scripts/migrations/v21_to_v22.sql +80 -0
  54. package/scripts/migrations/v22_to_v23.sql +20 -0
  55. package/scripts/migrations/v23_to_v24.sql +30 -0
  56. package/scripts/migrations/v24_to_v25.sql +77 -0
  57. package/scripts/migrations/v25_to_v26.sql +116 -0
  58. package/skills/README.md +8 -0
  59. package/skills/agent-approval-protocol/SKILL.md +27 -27
  60. package/skills/agent-approval-protocol/reference.md +20 -12
  61. package/skills/agent-protocol/SKILL.md +1 -1
  62. package/skills/agent-protocol/examples.md +17 -9
  63. package/skills/agent-response/SKILL.md +1 -1
  64. package/skills/diagram-builder/GLOSSARY.md +77 -0
  65. package/skills/diagram-builder/SKILL.md +156 -0
  66. package/skills/diagram-builder/assets/README.md +45 -0
  67. package/skills/diagram-builder/assets/data/data.generated.js +77 -0
  68. package/skills/diagram-builder/assets/data/document.yaml +12 -0
  69. package/skills/diagram-builder/assets/data/pages/overview.yaml +47 -0
  70. package/skills/diagram-builder/assets/engine/build-data.mjs +51 -0
  71. package/skills/diagram-builder/assets/engine/engine.js +863 -0
  72. package/skills/diagram-builder/assets/index.html +638 -0
  73. package/skills/diagram-builder/assets/package.json +15 -0
  74. package/skills/diagram-builder/assets/tools/verify.mjs +128 -0
  75. package/skills/diagram-builder/reference.md +444 -0
  76. package/skills/execution/SKILL.md +3 -3
  77. package/skills/gaia-patterns/reference.md +2 -2
  78. package/skills/memory/SKILL.md +49 -0
  79. package/skills/orchestrator-present-approval/SKILL.md +90 -79
  80. package/skills/orchestrator-present-approval/reference.md +59 -42
  81. package/skills/orchestrator-present-approval/template.md +16 -14
  82. package/skills/pending-approvals/SKILL.md +80 -29
  83. package/skills/pending-approvals/reference.md +48 -60
  84. package/skills/security-tiers/SKILL.md +1 -1
  85. package/skills/subagent-request-approval/SKILL.md +65 -68
  86. package/skills/subagent-request-approval/reference.md +63 -63
  87. package/skills/visual-verify/SKILL.md +107 -0
  88. package/skills/visual-verify/scripts/screenshot.js +210 -0
  89. package/tools/scan/classify.py +584 -15
  90. package/tools/scan/migrate_workspace.py +9 -4
  91. package/tools/scan/store_populator.py +231 -2
  92. package/tools/scan/tests/conftest.py +31 -0
@@ -46,6 +46,7 @@ _KNOWN_TABLES = {
46
46
  "libraries",
47
47
  "services",
48
48
  "features",
49
+ "project_facets",
49
50
  "tf_modules",
50
51
  "tf_live",
51
52
  "releases",
@@ -59,7 +60,7 @@ _KNOWN_TABLES = {
59
60
 
60
61
 
61
62
  # ---------------------------------------------------------------------------
62
- # Semantic-grant lifetime (Brief 71, Change 3a)
63
+ # Semantic-grant lifetime (approvals redesign, M1)
63
64
  # ---------------------------------------------------------------------------
64
65
  #
65
66
  # APPROVAL_GRANT_TTL_MINUTES is the default lifetime of an ACTIVE semantic grant
@@ -71,21 +72,23 @@ _KNOWN_TABLES = {
71
72
  # 24h), which is how long an UNANSWERED approval waits for the user. The two must
72
73
  # not be conflated: a 24h pending window lets a human come back the next day,
73
74
  # while the grant window is the short, post-approval execution horizon. Collapsing
74
- # them would either shrink the approval wait to 1h (a regression) or stretch the
75
+ # them would either shrink the approval wait to 5m (a regression) or stretch the
75
76
  # grant lifetime to 24h (a security weakening). See the regression guards in
76
77
  # tests/hooks/test_pending_scanner_cleanup.py::TestTTLConstants.
77
78
  #
78
- # The value moved 5 -> 60 (Change 3a) so that a human-in-the-loop approval which
79
- # crosses a session boundary (block under the subagent session, approve under the
80
- # orchestrator, retry under the subagent) does not silently expire before it can
81
- # be consumed. 60 aligns with the 1-hour staleness horizon.
79
+ # The value is 5 minutes (approvals redesign, M1). The grant is consumed AT THE
80
+ # MATCH (bash_validator flips the row PENDING->CONSUMED when it authorizes the
81
+ # command in PreToolUse, before execution), so this short window only needs to
82
+ # cover the block -> approve -> retry round trip; a grant that is never presented
83
+ # to a matching retry simply expires. Replay protection comes from consume-at-
84
+ # match plus this short TTL, not from a long-lived grant.
82
85
  #
83
86
  # It lives HERE, in gaia.store.writer, because writer is the dependency leaf of
84
87
  # the approval planes: gaia.approvals.store already imports from this module
85
88
  # (_connect) and the hooks approval_grants module already imports
86
89
  # insert_semantic_grant from here, while writer imports neither -- so any consumer
87
90
  # can read this constant without a circular import.
88
- APPROVAL_GRANT_TTL_MINUTES = 60
91
+ APPROVAL_GRANT_TTL_MINUTES = 5
89
92
 
90
93
 
91
94
  # ---------------------------------------------------------------------------
@@ -171,20 +174,31 @@ def _applied(extra: dict | None = None) -> dict:
171
174
  # ---------------------------------------------------------------------------
172
175
 
173
176
  def _resolve_identity(workspace: str, workspace_path: Path | None = None) -> str:
174
- """Resolve workspace identity.
177
+ """Resolve workspace identity -- REMOTE-derived, read directly (M2-T7).
175
178
 
176
- Rule (post-fix):
179
+ Rule:
177
180
  * If ``workspace_path`` is provided AND ``workspace_path / .git`` exists
178
181
  (the workspace root is itself a git project), resolve identity from
179
- the git remote of that directory via ``gaia.project.current``.
182
+ the git remote of that directory, read DIRECTLY via
183
+ ``gaia.project._git_remote_origin`` + ``_normalize_remote``.
180
184
  * Otherwise (organizational workspace -- no .git at the root), the
181
185
  identity IS the workspace name. We do NOT leak the remote of a child
182
186
  project up to the workspace row.
183
187
 
184
- This prevents the historical contamination where a workspace like ``me``
185
- received the identity of its first scanned child project.
188
+ This deliberately does NOT go through ``gaia.project.current()``. As of
189
+ M2-T7 (AC-9) ``current()`` is PATH-based (it answers "which workspace am I
190
+ in" by disk location, not by remote). The ``workspaces.identity`` column,
191
+ however, must remain the normalized git remote (``host/owner/repo``) so two
192
+ clones of the same remote collapse to the same identity row (the B0 design
193
+ in ``tools/scan/store_populator.py``). Reading the remote directly here
194
+ decouples the identity column from ``current()``'s path-first behavior and
195
+ preserves the remote-derived semantic.
186
196
 
187
- Falls back to the workspace string itself when path resolution fails.
197
+ This also prevents the historical contamination where a workspace like
198
+ ``me`` received the identity of its first scanned child project.
199
+
200
+ Falls back to the workspace string itself when path resolution fails or no
201
+ remote is configured.
188
202
 
189
203
  Args:
190
204
  workspace: Workspace name used as the fallback / organizational identity.
@@ -200,10 +214,12 @@ def _resolve_identity(workspace: str, workspace_path: Path | None = None) -> str
200
214
  try:
201
215
  if not (workspace_path / ".git").is_dir():
202
216
  return workspace.lower()
203
- from gaia.project import current as _project_current
204
- ident = _project_current(cwd=workspace_path)
205
- if ident and ident != "global":
206
- return ident
217
+ from gaia.project import _git_remote_origin, _normalize_remote
218
+ remote = _git_remote_origin(workspace_path)
219
+ if remote:
220
+ ident = _normalize_remote(remote)
221
+ if ident:
222
+ return ident
207
223
  except Exception:
208
224
  pass
209
225
  return workspace.lower()
@@ -348,11 +364,185 @@ def mark_workspace_demoted(
348
364
  con.close()
349
365
 
350
366
 
367
+ # ---------------------------------------------------------------------------
368
+ # Column ownership map (coalesce-or-omit + agent-owned protection)
369
+ # ---------------------------------------------------------------------------
370
+ #
371
+ # Ported from tools/scan/orchestrator.py's SCANNER_OWNED_TOP_LEVEL /
372
+ # AGENT_ENRICHED_SECTIONS split (the retired project-context.json ownership
373
+ # model) down to the DB write path (workspace-identity brief, M1-T2).
374
+ #
375
+ # Semantics:
376
+ # * Coalesce-or-omit: a column is only written when its key is PRESENT in
377
+ # the caller's `fields` mapping (even when the value is explicitly None,
378
+ # e.g. ``missing_since=None`` to reactivate a project). A key ABSENT from
379
+ # `fields` is left OUT of the INSERT/UPDATE entirely -- the column keeps
380
+ # its current value instead of being forced to NULL just because a given
381
+ # scan run's payload did not mention it. This is the fix for the
382
+ # "columns go NULL when a rescan omits them" clobber.
383
+ # * Agent-owned protection: a column named in the table's `_AGENT_OWNED`
384
+ # set is stripped from `fields` before the coalesce-or-omit step
385
+ # whenever the caller passes ``strip_agent_owned=True`` -- the scan path
386
+ # (bulk_upsert's projects/apps branches, populate_project) always does.
387
+ # A direct caller that does NOT set ``strip_agent_owned`` (tests, or any
388
+ # future agent-driven write) keeps full write access -- the flag gates
389
+ # the SCAN PATH specifically, not the column in the abstract.
390
+ #
391
+ # M3/T9: `description` is agent-owned (schema v23, scripts/migrations/
392
+ # v22_to_v23.sql). The scan path (strip_agent_owned=True) can never write it,
393
+ # regardless of what a caller's `fields` dict happens to contain -- it is
394
+ # stripped by _present_fields before the coalesce-or-omit step, same
395
+ # mechanism already proven for apps.description/status in M1.
396
+ _PROJECTS_AGENT_OWNED: frozenset = frozenset({"description"})
397
+ # NOTE: `role` is NOT agent-owned here (M1-T3): it is auto-detected by
398
+ # tools/scan/role_detector.py and refreshed on every scan, so it belongs to
399
+ # the scanner. See schema.sql's `role` column comment for the same note.
400
+ _APPS_AGENT_OWNED: frozenset = frozenset({"description", "status"})
401
+
402
+
403
+ def _present_fields(
404
+ fields: Mapping[str, Any],
405
+ recognized: Sequence[str],
406
+ *,
407
+ strip: frozenset = frozenset(),
408
+ ) -> dict:
409
+ """Return the subset of `recognized` keys actually supplied in `fields`.
410
+
411
+ Powers coalesce-or-omit: building the INSERT/UPDATE column list from this
412
+ dict's keys means an omitted scanner-owned column is never forced to NULL,
413
+ and (when `strip` names the table's agent-owned columns) the scan path can
414
+ never write agent-owned data regardless of what its payload happens to
415
+ include.
416
+ """
417
+ return {k: fields[k] for k in recognized if k in fields and k not in strip}
418
+
419
+
420
+ def _find_collision_free_name(
421
+ con: sqlite3.Connection,
422
+ workspace: str,
423
+ name: str,
424
+ project_identity: str | None,
425
+ ) -> str:
426
+ """Return a `projects.name` guaranteed not to collide with a DIFFERENT
427
+ physical repo already occupying ``(workspace, name)``.
428
+
429
+ Two distinct repos (distinct ``project_identity``) can legitimately share
430
+ a basename under the same workspace (e.g. two "foo" repos nested under
431
+ different containers). Without this guard, upserting the second one would
432
+ silently overwrite the first via the ``(workspace, name)`` UNIQUE
433
+ constraint -- the collision-key defect (workspace-identity brief, AC-2).
434
+
435
+ Read-only (issues no writes) so it is safe to call from a dry-run preview
436
+ as well as from inside upsert_project's write transaction. When the
437
+ existing occupant shares the SAME identity (or the slot is free, or the
438
+ slot's identity is unset/legacy), the name is returned unchanged --
439
+ disambiguation only fires for a CONFIRMED different physical repo.
440
+
441
+ Args:
442
+ con: Open connection (used read-only here).
443
+ workspace: Workspace name.
444
+ name: Candidate project name.
445
+ project_identity: The NEW row's stable identity, or None/empty (in
446
+ which case no collision can be detected and `name` is returned
447
+ unchanged).
448
+
449
+ Returns:
450
+ `name` unchanged, or `name` suffixed with `-2`, `-3`, ... until a free
451
+ (or same-identity) slot is found.
452
+ """
453
+ if not project_identity:
454
+ return name
455
+
456
+ def _occupied_by_other(candidate: str) -> bool:
457
+ row = con.execute(
458
+ "SELECT project_identity FROM projects WHERE workspace = ? AND name = ?",
459
+ (workspace, candidate),
460
+ ).fetchone()
461
+ existing_identity = row["project_identity"] if row else None
462
+ return bool(existing_identity) and existing_identity != project_identity
463
+
464
+ if not _occupied_by_other(name):
465
+ return name
466
+
467
+ suffix = 2
468
+ while True:
469
+ candidate = f"{name}-{suffix}"
470
+ if not _occupied_by_other(candidate):
471
+ return candidate
472
+ suffix += 1
473
+
474
+
475
+ def preview_project_name(
476
+ workspace: str,
477
+ name: str,
478
+ project_identity: str | None,
479
+ *,
480
+ db_path: Path | None = None,
481
+ extra_claimed: Mapping[str, str] | None = None,
482
+ ) -> str:
483
+ """Read-only preview of the name :func:`upsert_project` would actually use.
484
+
485
+ Lets a dry-run report the REAL, collision-free name without writing
486
+ anything. ``extra_claimed`` lets a caller iterating a batch of repos in
487
+ one pass (e.g. ``tools/scan/classify.py::scan``) fold in names already
488
+ "claimed" earlier in the SAME batch -- names that a real ``apply=True``
489
+ run would already have committed to the DB by the time a later repo in
490
+ the batch is processed (commits are sequential), but that a dry-run,
491
+ which writes nothing, cannot see via the DB alone.
492
+
493
+ Args:
494
+ workspace: Workspace name.
495
+ name: Candidate project name.
496
+ project_identity: The repo's stable identity, or None/empty.
497
+ db_path: Optional explicit DB path (used by tests).
498
+ extra_claimed: Optional ``{name: project_identity}`` map of names
499
+ already claimed earlier in the same in-progress batch.
500
+
501
+ Returns:
502
+ The name that would be used, disambiguated if needed.
503
+ """
504
+ if not project_identity:
505
+ return name
506
+ if extra_claimed and name in extra_claimed:
507
+ if extra_claimed[name] == project_identity:
508
+ return name
509
+ # In-memory collision against an earlier repo in this same batch --
510
+ # resolve purely in-memory first (no DB round trip needed to know
511
+ # this slot is taken), then fall through to the DB-aware resolver
512
+ # starting from the first candidate suffix.
513
+ suffix = 2
514
+ while True:
515
+ candidate = f"{name}-{suffix}"
516
+ claimed_identity = extra_claimed.get(candidate)
517
+ if claimed_identity is None:
518
+ break
519
+ if claimed_identity == project_identity:
520
+ return candidate
521
+ suffix += 1
522
+ name = candidate
523
+
524
+ # Dry-run touches-nothing guarantee: never let a PREVIEW materialize the
525
+ # DB. `_connect()` runs schema.sql when the file is absent, which would
526
+ # create the data dir during a --dry-run scan (regression caught by
527
+ # tests/cli/test_scan.py::test_dry_run_does_not_touch_db). A DB that does
528
+ # not yet exist has zero rows to collide with, so the in-memory
529
+ # `extra_claimed` resolution above is already the complete answer.
530
+ resolved = db_path if db_path is not None else _db_path()
531
+ if not resolved.exists():
532
+ return name
533
+
534
+ con = _connect(resolved)
535
+ try:
536
+ return _find_collision_free_name(con, workspace, name, project_identity)
537
+ finally:
538
+ con.close()
539
+
540
+
351
541
  # ---------------------------------------------------------------------------
352
542
  # Public API: upsert_project
353
543
  # ---------------------------------------------------------------------------
354
544
 
355
- _PROJECT_FIELDS = ("role", "remote_url", "platform", "primary_language", "group_name", "path", "status", "missing_since", "project_identity")
545
+ _PROJECT_FIELDS = ("role", "remote_url", "platform", "primary_language", "group_name", "path", "status", "missing_since", "project_identity", "description")
356
546
 
357
547
 
358
548
  def _projects_has_identity_column(con: sqlite3.Connection) -> bool:
@@ -376,6 +566,7 @@ def upsert_project(
376
566
  *,
377
567
  db_path: Path | None = None,
378
568
  workspace_path: Path | None = None,
569
+ strip_agent_owned: bool = False,
379
570
  ) -> dict:
380
571
  """Upsert a projects row, enforcing per-agent write permission.
381
572
 
@@ -385,26 +576,39 @@ def upsert_project(
385
576
  fields: Dict of column->value pairs. Recognized keys:
386
577
  ``role``, ``remote_url``, ``platform``, ``primary_language``,
387
578
  ``group_name``, ``path``, ``status``, ``missing_since``,
388
- ``project_identity``. When ``project_identity`` is non-null and the
389
- live schema carries the column (v18+), the UPSERT collapses on that
390
- stable identity: the SAME physical repo scanned from different
391
- workspaces/roots updates the existing row IN PLACE (preserving its
392
- original (workspace, name) PK) instead of inserting a duplicate.
393
- ``status`` defaults to 'active' when not provided. ``missing_since``
394
- defaults to NULL. On re-upsert of a live project (status='active')
395
- the scanner should pass status='active' and missing_since=None to
396
- reactivate a previously-missing project; default values handle this
397
- when the caller omits both fields.
579
+ ``project_identity``. A key ABSENT from `fields` is coalesce-or-
580
+ omit: the column keeps its current value instead of being forced
581
+ to NULL (see the ownership map above `_PROJECTS_AGENT_OWNED`). A
582
+ key present with value None (e.g. ``missing_since=None``) is an
583
+ explicit write -- this is how the scanner reactivates a
584
+ previously-missing project (pass status='active' and
585
+ missing_since=None together). When ``project_identity`` is
586
+ non-null and the live schema carries the column (v18+), the
587
+ UPSERT collapses on that stable identity: the SAME physical repo
588
+ scanned from different workspaces/roots updates the existing row
589
+ IN PLACE (preserving its original (workspace, name) PK) instead
590
+ of inserting a duplicate. ``status`` defaults to 'active' when
591
+ not provided (or explicitly None).
398
592
  agent: Agent name. Must have allow_write=1 for table 'projects' in
399
593
  agent_permissions.
400
- topic_key: Optional dimension key.
594
+ topic_key: Optional dimension key. Coalesced: an explicit value
595
+ overwrites; omitting it (None) preserves the existing value
596
+ instead of nulling it on every rescan.
401
597
  db_path: Optional explicit DB path (used by tests).
402
598
  workspace_path: Directory whose git remote supplies the workspaces.identity
403
599
  value. Pass ``project_path`` from the scanner for correct
404
600
  multi-workspace ingestion.
601
+ strip_agent_owned: When True (the scan path -- bulk_upsert's
602
+ projects branch, populate_project), any key in
603
+ ``_PROJECTS_AGENT_OWNED`` is dropped from `fields` before the
604
+ coalesce-or-omit step, regardless of what the caller supplied.
605
+ Direct callers (tests, future agent-driven writes) leave this
606
+ False and keep full write access.
405
607
 
406
608
  Returns:
407
- {"status": "applied"} on success.
609
+ {"status": "applied", "name": <final name used, disambiguated if a
610
+ genuine repo-name collision was detected -- see
611
+ :func:`_find_collision_free_name`>} on success.
408
612
  {"status": "rejected", "reason": "not_authorized"} if the agent lacks
409
613
  write permission for the 'projects' table.
410
614
  """
@@ -416,13 +620,19 @@ def upsert_project(
416
620
  con.execute("BEGIN")
417
621
  try:
418
622
  _ensure_workspace_row(con, workspace, workspace_path)
419
- data = {k: fields.get(k) for k in _PROJECT_FIELDS}
420
- # Default status to 'active' when not explicitly provided.
421
- # This ensures newly-inserted rows and re-upserted live projects
422
- # always carry an explicit status value.
423
- status_val = data["status"] if data["status"] is not None else "active"
623
+
624
+ present = _present_fields(
625
+ fields, _PROJECT_FIELDS,
626
+ strip=_PROJECTS_AGENT_OWNED if strip_agent_owned else frozenset(),
627
+ )
628
+ # Default status to 'active' when not explicitly provided (or
629
+ # explicitly None). Newly-inserted rows and re-upserted live
630
+ # projects always carry an explicit status value -- unchanged
631
+ # historical default.
632
+ if present.get("status") is None:
633
+ present["status"] = "active"
424
634
  now = _now_iso()
425
- project_identity = data["project_identity"]
635
+ project_identity = present.get("project_identity")
426
636
 
427
637
  # Identity-collapse path (M1-T2): when a stable project_identity is
428
638
  # supplied AND the live schema carries the column, the SAME physical
@@ -432,102 +642,74 @@ def upsert_project(
432
642
  # one) and UPDATE it IN PLACE, preserving its original (workspace,
433
643
  # name) PK -- the first-seen vantage wins, later scans only refresh
434
644
  # the row's scanner-owned columns. This is what makes the
435
- # "same repo from two roots -> 0 duplicates" query hold.
645
+ # "same repo from two roots -> 0 duplicates" query hold. Only the
646
+ # columns actually PRESENT in `fields` are updated (coalesce-or-
647
+ # omit); scanner-owned columns this call didn't mention keep their
648
+ # current value instead of being nulled.
436
649
  if has_identity_col and project_identity:
437
650
  existing = con.execute(
438
651
  "SELECT workspace, name FROM projects WHERE project_identity = ?",
439
652
  (project_identity,),
440
653
  ).fetchone()
441
654
  if existing is not None:
655
+ set_parts = [f"{c} = ?" for c in present.keys()]
656
+ set_parts += ["scanner_ts = ?", "topic_key = COALESCE(?, topic_key)"]
657
+ params = list(present.values()) + [now, topic_key]
442
658
  con.execute(
443
- """
444
- UPDATE projects SET
445
- role = ?,
446
- remote_url = ?,
447
- platform = ?,
448
- primary_language = ?,
449
- scanner_ts = ?,
450
- topic_key = ?,
451
- group_name = ?,
452
- path = ?,
453
- status = ?,
454
- missing_since = ?,
455
- project_identity = ?
456
- WHERE workspace = ? AND name = ?
457
- """,
458
- (
459
- data["role"], data["remote_url"], data["platform"],
460
- data["primary_language"], now, topic_key,
461
- data["group_name"], data["path"],
462
- status_val, data["missing_since"], project_identity,
463
- existing["workspace"], existing["name"],
464
- ),
659
+ f"UPDATE projects SET {', '.join(set_parts)} "
660
+ f"WHERE workspace = ? AND name = ?",
661
+ (*params, existing["workspace"], existing["name"]),
465
662
  )
466
663
  con.commit()
467
- return _applied()
664
+ return _applied({"name": existing["name"]})
665
+
666
+ # No identity match -- this is a NEW row (or a legacy DB with no
667
+ # identity column). Resolve a collision-free name so a DIFFERENT
668
+ # physical repo sharing this basename never silently overwrites
669
+ # an existing, unrelated row (AC-2).
670
+ final_name = name
671
+ if has_identity_col and project_identity:
672
+ final_name = _find_collision_free_name(con, workspace, name, project_identity)
468
673
 
469
674
  if has_identity_col:
675
+ insert_cols = ["workspace", "name"] + list(present.keys()) + ["scanner_ts", "topic_key"]
676
+ insert_vals = [workspace, final_name] + list(present.values()) + [now, topic_key]
677
+ update_clause_parts = [f"{c} = excluded.{c}" for c in present.keys()]
678
+ update_clause_parts += [
679
+ "scanner_ts = excluded.scanner_ts",
680
+ "topic_key = COALESCE(excluded.topic_key, topic_key)",
681
+ ]
470
682
  con.execute(
471
- """
472
- INSERT INTO projects (workspace, name, role, remote_url, platform,
473
- primary_language, scanner_ts, topic_key,
474
- group_name, path, status, missing_since,
475
- project_identity)
476
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
477
- ON CONFLICT(workspace, name) DO UPDATE SET
478
- role = excluded.role,
479
- remote_url = excluded.remote_url,
480
- platform = excluded.platform,
481
- primary_language = excluded.primary_language,
482
- scanner_ts = excluded.scanner_ts,
483
- topic_key = excluded.topic_key,
484
- group_name = excluded.group_name,
485
- path = excluded.path,
486
- status = excluded.status,
487
- missing_since = excluded.missing_since,
488
- project_identity = excluded.project_identity
489
- """,
490
- (
491
- workspace, name,
492
- data["role"], data["remote_url"], data["platform"],
493
- data["primary_language"], now, topic_key,
494
- data["group_name"], data["path"],
495
- status_val, data["missing_since"], project_identity,
496
- ),
683
+ f"INSERT INTO projects ({', '.join(insert_cols)}) "
684
+ f"VALUES ({', '.join(['?'] * len(insert_cols))}) "
685
+ f"ON CONFLICT(workspace, name) DO UPDATE SET {', '.join(update_clause_parts)}",
686
+ insert_vals,
497
687
  )
498
688
  else:
499
689
  # Backward-compat: un-migrated DB without project_identity.
690
+ # No collision-free naming is possible without an identity
691
+ # signal -- degrades to the historical (workspace, name) key.
692
+ # Drop `project_identity` from `present`: the legacy schema
693
+ # does not carry that column at all.
694
+ legacy_present = {k: v for k, v in present.items() if k != "project_identity"}
695
+ insert_cols = ["workspace", "name"] + list(legacy_present.keys()) + ["scanner_ts", "topic_key"]
696
+ insert_vals = [workspace, final_name] + list(legacy_present.values()) + [now, topic_key]
697
+ update_clause_parts = [f"{c} = excluded.{c}" for c in legacy_present.keys()]
698
+ update_clause_parts += [
699
+ "scanner_ts = excluded.scanner_ts",
700
+ "topic_key = COALESCE(excluded.topic_key, topic_key)",
701
+ ]
500
702
  con.execute(
501
- """
502
- INSERT INTO projects (workspace, name, role, remote_url, platform,
503
- primary_language, scanner_ts, topic_key,
504
- group_name, path, status, missing_since)
505
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
506
- ON CONFLICT(workspace, name) DO UPDATE SET
507
- role = excluded.role,
508
- remote_url = excluded.remote_url,
509
- platform = excluded.platform,
510
- primary_language = excluded.primary_language,
511
- scanner_ts = excluded.scanner_ts,
512
- topic_key = excluded.topic_key,
513
- group_name = excluded.group_name,
514
- path = excluded.path,
515
- status = excluded.status,
516
- missing_since = excluded.missing_since
517
- """,
518
- (
519
- workspace, name,
520
- data["role"], data["remote_url"], data["platform"],
521
- data["primary_language"], now, topic_key,
522
- data["group_name"], data["path"],
523
- status_val, data["missing_since"],
524
- ),
703
+ f"INSERT INTO projects ({', '.join(insert_cols)}) "
704
+ f"VALUES ({', '.join(['?'] * len(insert_cols))}) "
705
+ f"ON CONFLICT(workspace, name) DO UPDATE SET {', '.join(update_clause_parts)}",
706
+ insert_vals,
525
707
  )
526
708
  con.commit()
527
709
  except Exception:
528
710
  con.rollback()
529
711
  raise
530
- return _applied()
712
+ return _applied({"name": final_name})
531
713
  finally:
532
714
  con.close()
533
715
 
@@ -548,6 +730,7 @@ def upsert_app(
548
730
  topic_key: str | None = None,
549
731
  *,
550
732
  db_path: Path | None = None,
733
+ strip_agent_owned: bool = False,
551
734
  ) -> dict:
552
735
  """Upsert an apps row, enforcing per-agent write permission.
553
736
 
@@ -557,9 +740,19 @@ def upsert_app(
557
740
  ``projects`` table).
558
741
  name: App name.
559
742
  fields: Dict with optional keys ``kind``, ``description``, ``status``.
743
+ A key ABSENT from `fields` is coalesce-or-omit: the column keeps
744
+ its current value instead of being forced to NULL. A key present
745
+ (even with value None) is an explicit write.
560
746
  agent: Agent name. Requires allow_write=1 for table 'apps'.
561
- topic_key: Optional dimension key.
747
+ topic_key: Optional dimension key. Coalesced: an explicit value
748
+ overwrites; omitting it (None) preserves the existing value.
562
749
  db_path: Optional explicit DB path (used by tests).
750
+ strip_agent_owned: When True (the scan path -- bulk_upsert's apps
751
+ branch), ``description`` and ``status`` (``_APPS_AGENT_OWNED``)
752
+ are dropped from `fields` before the coalesce-or-omit step,
753
+ regardless of what the caller supplied. Direct callers (tests,
754
+ future agent-driven writes) leave this False and keep full
755
+ write access.
563
756
 
564
757
  Returns:
565
758
  {"status": "applied"} on success.
@@ -582,24 +775,23 @@ def upsert_app(
582
775
  "INSERT INTO projects (workspace, name, scanner_ts) VALUES (?, ?, ?)",
583
776
  (workspace, project, _now_iso()),
584
777
  )
585
- data = {k: fields.get(k) for k in _APP_FIELDS}
778
+ present = _present_fields(
779
+ fields, _APP_FIELDS,
780
+ strip=_APPS_AGENT_OWNED if strip_agent_owned else frozenset(),
781
+ )
782
+ now = _now_iso()
783
+ insert_cols = ["workspace", "project", "name"] + list(present.keys()) + ["topic_key", "scanner_ts"]
784
+ insert_vals = [workspace, project, name] + list(present.values()) + [topic_key, now]
785
+ update_clause_parts = [f"{c} = excluded.{c}" for c in present.keys()]
786
+ update_clause_parts += [
787
+ "topic_key = COALESCE(excluded.topic_key, topic_key)",
788
+ "scanner_ts = excluded.scanner_ts",
789
+ ]
586
790
  con.execute(
587
- """
588
- INSERT INTO apps (workspace, project, name, kind, description, status,
589
- topic_key, scanner_ts)
590
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
591
- ON CONFLICT(workspace, project, name) DO UPDATE SET
592
- kind = excluded.kind,
593
- description = excluded.description,
594
- status = excluded.status,
595
- topic_key = excluded.topic_key,
596
- scanner_ts = excluded.scanner_ts
597
- """,
598
- (
599
- workspace, project, name,
600
- data["kind"], data["description"], data["status"],
601
- topic_key, _now_iso(),
602
- ),
791
+ f"INSERT INTO apps ({', '.join(insert_cols)}) "
792
+ f"VALUES ({', '.join(['?'] * len(insert_cols))}) "
793
+ f"ON CONFLICT(workspace, project, name) DO UPDATE SET {', '.join(update_clause_parts)}",
794
+ insert_vals,
603
795
  )
604
796
  con.commit()
605
797
  except Exception:
@@ -653,6 +845,7 @@ def delete_missing_in(
653
845
  "libraries": ("project", "name"),
654
846
  "services": ("project", "name"),
655
847
  "features": ("project", "name"),
848
+ "project_facets": ("project", "scope", "key"),
656
849
  "tf_modules": ("project", "name"),
657
850
  "tf_live": ("project", "name"),
658
851
  "releases": ("project", "name"),
@@ -799,6 +992,10 @@ def bulk_upsert(
799
992
  applied = 0
800
993
  rejected = 0
801
994
  if table == "projects":
995
+ # bulk_upsert is exclusively the scan path's batch writer (see the
996
+ # module docstring: "populators NEVER touch agent-owned columns") --
997
+ # strip_agent_owned=True enforces that structurally, regardless of
998
+ # what a row dict happens to include.
802
999
  for r in rows_list:
803
1000
  res = upsert_project(
804
1001
  workspace,
@@ -807,6 +1004,7 @@ def bulk_upsert(
807
1004
  agent,
808
1005
  topic_key=r.get("topic_key"),
809
1006
  db_path=db_path,
1007
+ strip_agent_owned=True,
810
1008
  )
811
1009
  if res.get("status") == "applied":
812
1010
  applied += 1
@@ -824,6 +1022,7 @@ def bulk_upsert(
824
1022
  agent,
825
1023
  topic_key=r.get("topic_key"),
826
1024
  db_path=db_path,
1025
+ strip_agent_owned=True,
827
1026
  )
828
1027
  if res.get("status") == "applied":
829
1028
  applied += 1
@@ -840,6 +1039,7 @@ def bulk_upsert(
840
1039
  "libraries": ("project", "name"),
841
1040
  "services": ("project", "name"),
842
1041
  "features": ("project", "name"),
1042
+ "project_facets": ("project", "scope", "key"),
843
1043
  "tf_modules": ("project", "name"),
844
1044
  "tf_live": ("project", "name"),
845
1045
  "releases": ("project", "name"),
@@ -1134,6 +1334,74 @@ def _validate_curated_slug(name: str, type: str) -> None:
1134
1334
  )
1135
1335
 
1136
1336
 
1337
+ def resolve_project_ref(
1338
+ workspace: str,
1339
+ project_name: str,
1340
+ *,
1341
+ db_path: Path | None = None,
1342
+ ) -> str:
1343
+ """Resolve a ``projects.name`` within ``workspace`` to its stable
1344
+ ``project_identity`` anchor -- the value ``upsert_memory(project_ref=...)``
1345
+ expects (N3 forward-only anchoring).
1346
+
1347
+ Looks up the exact ``(workspace, project_name)`` row -- the same lookup
1348
+ documented as the manual convention in ``skills/memory/SKILL.md`` before
1349
+ this function existed (``SELECT project_identity FROM projects WHERE
1350
+ workspace=? AND name=?``). Never guesses: raises ``ValueError`` with an
1351
+ actionable message when the project does not exist, when more than one
1352
+ row matches (structurally guarded against by the ``(workspace, name)``
1353
+ primary key, but checked defensively), or when the matching row has not
1354
+ yet been assigned a ``project_identity`` (e.g. a legacy pre-v18 row, or a
1355
+ project scanned before the identity column was populated) -- anchoring to
1356
+ an absent identity would be a guess, not a resolution.
1357
+
1358
+ Args:
1359
+ workspace: Workspace name (matches ``projects.workspace``).
1360
+ project_name: Project basename (matches ``projects.name``).
1361
+ db_path: Optional explicit DB path (used by tests).
1362
+
1363
+ Returns:
1364
+ The resolved ``project_identity`` string.
1365
+
1366
+ Raises:
1367
+ ValueError: project not found, ambiguous, or has no project_identity.
1368
+ """
1369
+ con = _connect(db_path)
1370
+ try:
1371
+ rows = con.execute(
1372
+ "SELECT project_identity FROM projects WHERE workspace = ? AND name = ?",
1373
+ (workspace, project_name),
1374
+ ).fetchall()
1375
+ finally:
1376
+ con.close()
1377
+
1378
+ if not rows:
1379
+ raise ValueError(
1380
+ f"project {project_name!r} not found in workspace {workspace!r}; "
1381
+ f"cannot anchor memory to it. Check the name with "
1382
+ f"`gaia context query \"SELECT name FROM projects WHERE "
1383
+ f"workspace='{workspace}'\"`."
1384
+ )
1385
+ if len(rows) > 1:
1386
+ # Structurally unreachable today ((workspace, name) is the projects
1387
+ # PK), kept as a defensive guard against a future schema change that
1388
+ # relaxes that constraint -- "never guess" applies here too.
1389
+ raise ValueError(
1390
+ f"project {project_name!r} is ambiguous in workspace {workspace!r} "
1391
+ f"({len(rows)} matching rows); cannot anchor memory to a single "
1392
+ f"identity without guessing."
1393
+ )
1394
+ identity = rows[0]["project_identity"]
1395
+ if not identity:
1396
+ raise ValueError(
1397
+ f"project {project_name!r} in workspace {workspace!r} has no "
1398
+ f"project_identity yet (legacy row, or not yet scanned); "
1399
+ f"cannot anchor memory to it without guessing. Run `gaia scan` "
1400
+ f"first."
1401
+ )
1402
+ return identity
1403
+
1404
+
1137
1405
  def upsert_memory(
1138
1406
  workspace: str,
1139
1407
  name: str,
@@ -1142,10 +1410,46 @@ def upsert_memory(
1142
1410
  body: str,
1143
1411
  description: str | None = None,
1144
1412
  origin_session_id: str | None = None,
1413
+ project_ref: str | None = None,
1145
1414
  db_path: Path | None = None,
1146
1415
  workspace_path: Path | None = None,
1147
1416
  ) -> dict:
1148
1417
  """Upsert a curated-memory row in the ``memory`` table.
1418
+
1419
+ Archive-on-upsert (scan-v2 SV3): when this overwrites an existing row, the
1420
+ ``memory_au``... no -- the ``trg_memory_history`` AFTER UPDATE trigger fires
1421
+ on the ON CONFLICT DO UPDATE below and archives the PREVIOUS ``body`` (and
1422
+ workspace/type/description/status/deleted_at) into ``memory_history`` before
1423
+ the new value lands. The prior version is never lost; no explicit archival
1424
+ code is needed here because the guarantee is enforced at the SQL layer for
1425
+ every write path, not just this one.
1426
+
1427
+ Resurrection: re-adding a slug that was soft-deleted clears ``deleted_at``
1428
+ (the row returns to the live set). The clearing is captured by the same
1429
+ history trigger.
1430
+
1431
+ ``project_ref`` -- forward-only remote-stable project anchor (N3, scan-v2
1432
+ SV3 follow-up). The v25/v26 columns/migration exist, but the automatic
1433
+ backfill in ``scripts/migrations/v25_to_v26.sql`` (guarded on "workspace
1434
+ hosts exactly one active project") is a one-time, already-applied
1435
+ historical statement that populated 0 rows in practice -- the
1436
+ memory-row-to-project mapping is ambiguous whenever a workspace hosts more
1437
+ than one project, and NEVER guessed. There is no live code that re-runs or
1438
+ depends on that guard; going forward, ``project_ref`` is anchored
1439
+ explicitly, at write time, by whoever calls this function knowing which
1440
+ project a ``project``-type row is about (see ``gaia memory add --project``
1441
+ in ``bin/cli/memory.py``, which resolves a project name to its
1442
+ ``projects.project_identity`` via :func:`resolve_project_ref` before
1443
+ calling here).
1444
+
1445
+ Coalesce-or-omit (same discipline as ``topic_key`` elsewhere in this
1446
+ module): ``project_ref=None`` (the default) never touches an existing
1447
+ anchor -- an update that does not mention the project leaves a
1448
+ previously-set anchor intact instead of clobbering it back to NULL. Pass
1449
+ an explicit identity string to set or overwrite it. There is no "clear"
1450
+ sentinel; once anchored, forward-only re-anchoring is the only write path
1451
+ (matches the existing ``topic_key`` COALESCE convention -- no precedent in
1452
+ this module for an explicit-NULL clear on a coalesced column).
1149
1453
  """
1150
1454
  _assert_dispatch_can_write_memory()
1151
1455
 
@@ -1178,17 +1482,19 @@ def upsert_memory(
1178
1482
  con.execute(
1179
1483
  """
1180
1484
  INSERT INTO memory (workspace, name, type, description, body,
1181
- origin_session_id, updated_at)
1182
- VALUES (?, ?, ?, ?, ?, ?, ?)
1485
+ project_ref, origin_session_id, updated_at)
1486
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1183
1487
  ON CONFLICT(workspace, name) DO UPDATE SET
1184
1488
  type = excluded.type,
1185
1489
  description = excluded.description,
1186
1490
  body = excluded.body,
1491
+ project_ref = COALESCE(excluded.project_ref, project_ref),
1187
1492
  origin_session_id = excluded.origin_session_id,
1188
- updated_at = excluded.updated_at
1493
+ updated_at = excluded.updated_at,
1494
+ deleted_at = NULL
1189
1495
  """,
1190
1496
  (workspace, name, type, description, body,
1191
- origin_session_id, now),
1497
+ project_ref, origin_session_id, now),
1192
1498
  )
1193
1499
  con.commit()
1194
1500
  return {
@@ -1215,15 +1521,49 @@ def delete_memory(
1215
1521
  workspace: str,
1216
1522
  name: str,
1217
1523
  *,
1524
+ hard: bool = False,
1218
1525
  db_path: Path | None = None,
1219
1526
  ) -> bool:
1220
- """Hard-delete a curated memory row."""
1527
+ """Soft-delete (tombstone) a curated memory row -- scan-v2 SV3.
1528
+
1529
+ By default this is a SOFT delete: the row's ``deleted_at`` column is stamped
1530
+ with the current UTC timestamp instead of the row being physically removed.
1531
+ The row and its ``body`` survive (recoverable, and re-addable via
1532
+ :func:`upsert_memory`, which clears the tombstone). The ``trg_memory_history``
1533
+ trigger records the tombstone transition (before_deleted_at NULL -> after
1534
+ non-NULL). All read paths filter ``deleted_at IS NULL`` so a tombstoned row
1535
+ is invisible to normal queries.
1536
+
1537
+ A tombstone is idempotent: calling delete_memory on an already-tombstoned
1538
+ row is a no-op (the row is not re-stamped and no new history row is written).
1539
+
1540
+ ``hard=True`` performs the real physical DELETE. This is the ONLY path that
1541
+ destroys the row and its body, and it exists exclusively for explicit human
1542
+ curation ("never hard-delete curated memory except by explicit human
1543
+ curation" -- decision_scan_v2_memory_loss_vectors). The CLI surfaces it via
1544
+ ``gaia memory delete --hard`` behind the existing confirmation prompt.
1545
+
1546
+ Returns True when a row was affected (tombstoned or hard-deleted), False
1547
+ when no live row matched (already tombstoned, or absent).
1548
+ """
1221
1549
  _assert_dispatch_can_write_memory()
1222
1550
  con = _connect(db_path)
1223
1551
  try:
1552
+ if hard:
1553
+ cur = con.execute(
1554
+ "DELETE FROM memory WHERE workspace = ? AND name = ?",
1555
+ (workspace, name),
1556
+ )
1557
+ con.commit()
1558
+ return cur.rowcount > 0
1559
+ # Soft delete: stamp deleted_at only on a currently-live row. The
1560
+ # `deleted_at IS NULL` guard makes a repeated tombstone a no-op (no
1561
+ # spurious history row, no timestamp churn).
1562
+ now = _now_iso()
1224
1563
  cur = con.execute(
1225
- "DELETE FROM memory WHERE workspace = ? AND name = ?",
1226
- (workspace, name),
1564
+ "UPDATE memory SET deleted_at = ?, updated_at = ? "
1565
+ "WHERE workspace = ? AND name = ? AND deleted_at IS NULL",
1566
+ (now, now, workspace, name),
1227
1567
  )
1228
1568
  con.commit()
1229
1569
  return cur.rowcount > 0
@@ -1708,6 +2048,7 @@ def search_memory_curated(
1708
2048
  JOIN memory m ON m.rowid = memory_fts.rowid
1709
2049
  WHERE memory_fts MATCH ?
1710
2050
  AND m.workspace = ?
2051
+ AND m.deleted_at IS NULL
1711
2052
  ORDER BY rank
1712
2053
  LIMIT ?
1713
2054
  """,
@@ -1735,17 +2076,26 @@ def get_memory(
1735
2076
  workspace: str,
1736
2077
  name: str,
1737
2078
  *,
2079
+ include_deleted: bool = False,
1738
2080
  db_path: Path | None = None,
1739
2081
  ) -> dict | None:
1740
- """Return a curated memory row as a dict, or ``None`` when missing."""
2082
+ """Return a curated memory row as a dict, or ``None`` when missing.
2083
+
2084
+ Tombstoned rows (``deleted_at`` non-NULL, scan-v2 SV3) are excluded by
2085
+ default so a soft-deleted memory reads as absent. Pass
2086
+ ``include_deleted=True`` to reach a tombstoned row (e.g. for an explicit
2087
+ hard-delete or a recovery inspection).
2088
+ """
1741
2089
  con = _connect(db_path)
1742
2090
  try:
1743
- row = con.execute(
1744
- "SELECT workspace, name, type, description, body, "
1745
- " origin_session_id, updated_at "
1746
- "FROM memory WHERE workspace = ? AND name = ?",
1747
- (workspace, name),
1748
- ).fetchone()
2091
+ sql = (
2092
+ "SELECT workspace, name, type, description, body, project_ref, "
2093
+ " origin_session_id, updated_at, deleted_at "
2094
+ "FROM memory WHERE workspace = ? AND name = ?"
2095
+ )
2096
+ if not include_deleted:
2097
+ sql += " AND deleted_at IS NULL"
2098
+ row = con.execute(sql, (workspace, name)).fetchone()
1749
2099
  if row is None:
1750
2100
  return None
1751
2101
  return {k: row[k] for k in row.keys()}
@@ -1757,23 +2107,28 @@ def list_memory(
1757
2107
  workspace: str,
1758
2108
  *,
1759
2109
  type: str | None = None,
2110
+ include_deleted: bool = False,
1760
2111
  db_path: Path | None = None,
1761
2112
  ) -> list[dict]:
1762
- """List curated memory rows, optionally filtered by ``type``."""
2113
+ """List curated memory rows, optionally filtered by ``type``.
2114
+
2115
+ Tombstoned rows (``deleted_at`` non-NULL, scan-v2 SV3) are excluded by
2116
+ default; pass ``include_deleted=True`` to include them.
2117
+ """
1763
2118
  con = _connect(db_path)
1764
2119
  try:
1765
- if type is None:
1766
- rows = con.execute(
1767
- "SELECT name, type, description, updated_at "
1768
- "FROM memory WHERE workspace = ? ORDER BY name",
1769
- (workspace,),
1770
- ).fetchall()
1771
- else:
1772
- rows = con.execute(
1773
- "SELECT name, type, description, updated_at "
1774
- "FROM memory WHERE workspace = ? AND type = ? ORDER BY name",
1775
- (workspace, type),
1776
- ).fetchall()
2120
+ where = ["workspace = ?"]
2121
+ params: list = [workspace]
2122
+ if type is not None:
2123
+ where.append("type = ?")
2124
+ params.append(type)
2125
+ if not include_deleted:
2126
+ where.append("deleted_at IS NULL")
2127
+ sql = (
2128
+ "SELECT name, type, description, updated_at "
2129
+ "FROM memory WHERE " + " AND ".join(where) + " ORDER BY name"
2130
+ )
2131
+ rows = con.execute(sql, params).fetchall()
1777
2132
  return [dict(r) for r in rows]
1778
2133
  finally:
1779
2134
  con.close()
@@ -2620,15 +2975,629 @@ def reorder_tasks(
2620
2975
  # Public API: wipe_workspace
2621
2976
  # ---------------------------------------------------------------------------
2622
2977
 
2623
- def wipe_workspace(workspace: str, *, db_path: Path | None = None) -> None:
2978
+ def _reinsert_row(con: sqlite3.Connection, table: str, row: sqlite3.Row) -> None:
2979
+ """Re-INSERT a captured ``sqlite3.Row`` back into ``table`` verbatim.
2980
+
2981
+ Column list is derived from the row's own keys, so the helper survives
2982
+ schema evolution without hard-coding column names. Used by
2983
+ :func:`wipe_workspace` to restore memory / memory_links / the workspaces row
2984
+ after a CASCADE wipe.
2985
+ """
2986
+ cols = list(row.keys())
2987
+ placeholders = ", ".join("?" for _ in cols)
2988
+ col_list = ", ".join(cols)
2989
+ con.execute(
2990
+ f"INSERT INTO {table} ({col_list}) VALUES ({placeholders})",
2991
+ tuple(row[c] for c in cols),
2992
+ )
2993
+
2994
+
2995
+ def wipe_workspace(
2996
+ workspace: str,
2997
+ *,
2998
+ preserve_memory: bool = True,
2999
+ db_path: Path | None = None,
3000
+ ) -> None:
2624
3001
  """Delete the workspaces row for `workspace`. FK CASCADE removes all
2625
3002
  child rows (projects, apps, integrations, etc.) automatically.
3003
+
3004
+ Memory preservation (scan-v2 SV3, Vector 4)
3005
+ -------------------------------------------
3006
+ ``memory`` and ``memory_links`` are FK'd to ``workspaces`` with ON DELETE
3007
+ CASCADE, so a naive workspace delete DESTROYS all curated memory for the
3008
+ workspace. That is the loss vector `migrate_workspace.py` triggered on every
3009
+ re-scan. This function now DECOUPLES memory from the CASCADE at the app
3010
+ layer -- the safer of the two options (the alternative, changing the FK to
3011
+ ON DELETE SET NULL / RESTRICT, would require a full ``memory`` table rebuild
3012
+ per the v21->v22 precedent).
3013
+
3014
+ With ``preserve_memory=True`` (the DEFAULT): inside a single transaction the
3015
+ memory rows, memory_links rows, and the workspaces row itself are captured
3016
+ BEFORE the delete; the CASCADE then fires as normal; and the workspaces row
3017
+ (with its identity / created_at / status preserved) plus every memory /
3018
+ memory_links row is re-inserted. Net effect: projects and all scannable
3019
+ children are cleared (what a re-scan wants), while curated memory survives
3020
+ untouched. The memory_ai / memory_links insert triggers keep the FTS mirror
3021
+ consistent.
3022
+
3023
+ ``preserve_memory=False`` performs the original full CASCADE (memory
3024
+ destroyed). This exists ONLY for explicit human curation -- e.g.
3025
+ ``gaia context wipe --purge-memory`` behind its confirmation prompt --
3026
+ honouring "never hard-delete curated memory except by explicit human
3027
+ curation".
3028
+ """
3029
+ con = _connect(db_path)
3030
+ try:
3031
+ con.execute("BEGIN")
3032
+ try:
3033
+ if preserve_memory:
3034
+ ws_row = con.execute(
3035
+ "SELECT * FROM workspaces WHERE name = ?", (workspace,)
3036
+ ).fetchone()
3037
+ mem_rows = con.execute(
3038
+ "SELECT * FROM memory WHERE workspace = ?", (workspace,)
3039
+ ).fetchall()
3040
+ link_rows = con.execute(
3041
+ "SELECT * FROM memory_links WHERE workspace = ?", (workspace,)
3042
+ ).fetchall()
3043
+
3044
+ con.execute("DELETE FROM workspaces WHERE name = ?", (workspace,))
3045
+
3046
+ # Restore the workspaces row (only when it existed) so the FK
3047
+ # target for the re-inserted memory is present again, then the
3048
+ # memory + links. If the workspace had no row, there was nothing
3049
+ # to preserve and the delete was a no-op.
3050
+ if ws_row is not None:
3051
+ _reinsert_row(con, "workspaces", ws_row)
3052
+ for r in mem_rows:
3053
+ _reinsert_row(con, "memory", r)
3054
+ for r in link_rows:
3055
+ _reinsert_row(con, "memory_links", r)
3056
+ else:
3057
+ con.execute("DELETE FROM workspaces WHERE name = ?", (workspace,))
3058
+ con.commit()
3059
+ except Exception:
3060
+ con.rollback()
3061
+ raise
3062
+ finally:
3063
+ con.close()
3064
+
3065
+
3066
+ # ---------------------------------------------------------------------------
3067
+ # Surgical reconciliation helpers (workspace-identity brief, M4/T10)
3068
+ # ---------------------------------------------------------------------------
3069
+ #
3070
+ # `gaia scan` reconciles the `projects` table workspace-by-workspace: it
3071
+ # upserts the repos it finds under the SCANNED workspace and prunes
3072
+ # (mark_missing_in / delete_missing_in) only WITHIN that same workspace. Two
3073
+ # consequences make a plain rescan unable to repair historical drift:
3074
+ #
3075
+ # 1. A stale row with project_identity=NULL living under a DIFFERENT
3076
+ # workspace than the one being scanned is never collapsed (the identity-
3077
+ # collapse path in upsert_project only fires for a non-null identity) and
3078
+ # never pruned (prune is scoped to the scanned workspace) -- so a rescan
3079
+ # RESURRECTS the repo as a fresh duplicate row and leaves the stale row
3080
+ # ORPHANED under its old workspace.
3081
+ # 2. project_context_contracts is not touched by scan at all, so a contract
3082
+ # written under the wrong workspace can only be corrected by moving it.
3083
+ #
3084
+ # wipe_workspace is too blunt for a LIVE workspace: it CASCADE-deletes the
3085
+ # workspaces row and EVERY child (projects, memory, briefs, episodes, PCC).
3086
+ # The helper below is the surgical, workspace-preserving primitive that
3087
+ # repairs drift without destroying collateral:
3088
+ #
3089
+ # relocate_contracts -- re-key project_context_contracts rows between
3090
+ # workspaces (the only correction path for mis-keyed PCC).
3091
+ #
3092
+ # NOTE: a `delete_projects` sibling (targeted deletion of `projects` rows)
3093
+ # existed here as a one-time reconciliation tool (workspace-identity brief
3094
+ # M4/T10) and was removed in scan-v2 SV4 -- agents must never hold the power
3095
+ # to hard-delete project rows; `mark_missing_in` (soft-delete, scanner-owned)
3096
+ # and the resolve-move adjudication (re-key + tombstone, see
3097
+ # `resolve_move_candidate` below) are the only sanctioned paths that touch a
3098
+ # project row's lifecycle.
3099
+ # ---------------------------------------------------------------------------
3100
+
3101
+
3102
+ def relocate_contracts(
3103
+ from_workspace: str,
3104
+ to_workspace: str,
3105
+ contracts: Sequence[str],
3106
+ *,
3107
+ on_conflict: str = "error",
3108
+ dry_run: bool = False,
3109
+ db_path: Path | None = None,
3110
+ ) -> dict:
3111
+ """Re-key ``project_context_contracts`` rows from one workspace to another.
3112
+
3113
+ `gaia scan` never touches project_context_contracts, so a contract written
3114
+ under the wrong workspace (e.g. AOS project context mis-keyed to the 'me'
3115
+ workspace) can only be corrected by moving the row. This re-keys the named
3116
+ contracts by UPDATEing the ``workspace`` PK column IN PLACE -- payload,
3117
+ metadata and updated_at are preserved, and the ``trg_pcc_history`` AFTER
3118
+ UPDATE trigger records the move in project_context_contracts_history.
3119
+
3120
+ ``to_workspace`` must satisfy the FK to workspaces(name); it is created via
3121
+ :func:`_ensure_workspace_row` when absent. The PK is
3122
+ (workspace, contract_name), so if (to_workspace, contract) ALREADY exists
3123
+ ``on_conflict`` decides:
3124
+
3125
+ 'error' -- raise ValueError, move nothing (default; never clobber)
3126
+ 'skip' -- leave both rows; report the contract under 'skipped'
3127
+ 'overwrite' -- delete the target row first, then move the source row
3128
+
3129
+ Idempotent: a contract already absent from ``from_workspace`` is reported
3130
+ under 'missing' and is a no-op, so re-running after a partial apply is safe.
3131
+
3132
+ Args:
3133
+ from_workspace: Source workspace (current, wrong key).
3134
+ to_workspace: Destination workspace (correct key).
3135
+ contracts: Contract names to move (project_context_contracts.contract_name).
3136
+ on_conflict: 'error' | 'skip' | 'overwrite' (see above).
3137
+ dry_run: When True, mutate nothing; report the classification only.
3138
+ db_path: Optional explicit DB path (used by tests).
3139
+
3140
+ Returns:
3141
+ {"status": "applied"|"preview", "from": ..., "to": ...,
3142
+ "moved": [...], "skipped": [...], "missing": [...], "overwritten": [...]}.
3143
+
3144
+ Raises:
3145
+ ValueError: on invalid on_conflict, empty contracts, from==to, or an
3146
+ unresolved PK conflict when on_conflict='error'.
3147
+ """
3148
+ if on_conflict not in ("error", "skip", "overwrite"):
3149
+ raise ValueError(
3150
+ f"relocate_contracts: invalid on_conflict {on_conflict!r}; "
3151
+ f"must be 'error', 'skip', or 'overwrite'"
3152
+ )
3153
+ contract_list = list(contracts)
3154
+ if not contract_list:
3155
+ raise ValueError("relocate_contracts: at least one contract is required")
3156
+ if from_workspace == to_workspace:
3157
+ raise ValueError(
3158
+ "relocate_contracts: from_workspace and to_workspace are identical"
3159
+ )
3160
+
3161
+ moved: list[str] = []
3162
+ skipped: list[str] = []
3163
+ missing: list[str] = []
3164
+ overwritten: list[str] = []
3165
+
3166
+ con = _connect(db_path)
3167
+ try:
3168
+ con.execute("BEGIN")
3169
+ try:
3170
+ if not dry_run:
3171
+ _ensure_workspace_row(con, to_workspace)
3172
+
3173
+ for name in contract_list:
3174
+ src = con.execute(
3175
+ "SELECT 1 FROM project_context_contracts "
3176
+ "WHERE workspace = ? AND contract_name = ?",
3177
+ (from_workspace, name),
3178
+ ).fetchone()
3179
+ if src is None:
3180
+ missing.append(name)
3181
+ continue
3182
+
3183
+ dst = con.execute(
3184
+ "SELECT 1 FROM project_context_contracts "
3185
+ "WHERE workspace = ? AND contract_name = ?",
3186
+ (to_workspace, name),
3187
+ ).fetchone()
3188
+ if dst is not None:
3189
+ if on_conflict == "error":
3190
+ raise ValueError(
3191
+ f"relocate_contracts: target already has contract "
3192
+ f"{name!r} under workspace {to_workspace!r} "
3193
+ f"(on_conflict='error')"
3194
+ )
3195
+ if on_conflict == "skip":
3196
+ skipped.append(name)
3197
+ continue
3198
+ # overwrite
3199
+ if not dry_run:
3200
+ con.execute(
3201
+ "DELETE FROM project_context_contracts "
3202
+ "WHERE workspace = ? AND contract_name = ?",
3203
+ (to_workspace, name),
3204
+ )
3205
+ overwritten.append(name)
3206
+
3207
+ if not dry_run:
3208
+ con.execute(
3209
+ "UPDATE project_context_contracts SET workspace = ? "
3210
+ "WHERE workspace = ? AND contract_name = ?",
3211
+ (to_workspace, from_workspace, name),
3212
+ )
3213
+ moved.append(name)
3214
+
3215
+ con.commit()
3216
+ except Exception:
3217
+ con.rollback()
3218
+ raise
3219
+ finally:
3220
+ con.close()
3221
+
3222
+ return {
3223
+ "status": "preview" if dry_run else "applied",
3224
+ "from": from_workspace,
3225
+ "to": to_workspace,
3226
+ "moved": moved,
3227
+ "skipped": skipped,
3228
+ "missing": missing,
3229
+ "overwritten": overwritten,
3230
+ }
3231
+
3232
+
3233
+ def relocate_memory(
3234
+ from_workspace: str,
3235
+ to_workspace: str,
3236
+ names: Sequence[str],
3237
+ *,
3238
+ on_conflict: str = "error",
3239
+ dry_run: bool = False,
3240
+ db_path: Path | None = None,
3241
+ ) -> dict:
3242
+ """Re-key curated ``memory`` rows (and their intra-set ``memory_links``)
3243
+ between workspaces -- the mirror of :func:`relocate_contracts` for the
3244
+ curated memory table.
3245
+
3246
+ `gaia scan` never touches ``memory``, so a memory row written under the
3247
+ wrong workspace (e.g. the 'project_gaia_roadmap' / 'user_blog_articles'
3248
+ notes mis-keyed to the 'rnd' workspace but belonging to 'me') can only be
3249
+ corrected by moving the row. This re-keys the named rows by UPDATEing the
3250
+ ``workspace`` PK column IN PLACE: every other column (type, description,
3251
+ body, origin_session_id, updated_at, class, status) is preserved untouched,
3252
+ and the ``memory_au`` AFTER UPDATE trigger keeps the ``memory_fts`` mirror
3253
+ in sync (workspace is an FTS column, so the mirror row is rewritten).
3254
+
3255
+ memory_links follow the notes: a link under ``from_workspace`` whose BOTH
3256
+ endpoints are in the moved set is re-keyed to ``to_workspace`` (the edge
3257
+ travels with the pair). A link with only ONE endpoint in the moved set
3258
+ cannot stay consistent under the single-workspace link model -- scan-v2 SV3
3259
+ DELETES that now-dangling edge (its endpoint left the workspace) and reports
3260
+ it under 'partial_links'. The link is derived graph metadata, not curated
3261
+ memory: both endpoint rows survive untouched; only the broken edge is
3262
+ removed, so nothing is lost silently and no dangling reference is left
3263
+ behind.
3264
+
3265
+ Provenance (scan-v2 SV3): the workspace re-key UPDATE fires the
3266
+ ``trg_memory_history`` trigger, which records before_workspace ->
3267
+ after_workspace for each moved row -- the origin of every move is preserved
3268
+ in ``memory_history`` automatically, no explicit trace-write needed.
3269
+
3270
+ ``to_workspace`` must satisfy the FK to workspaces(name); it is created via
3271
+ :func:`_ensure_workspace_row` when absent. PK is (workspace, name); on a
3272
+ (to_workspace, name) collision ``on_conflict`` decides:
3273
+
3274
+ 'error' -- raise ValueError, move nothing (default; never clobber)
3275
+ 'skip' -- leave both rows; report the name under 'skipped'
3276
+ 'overwrite' -- delete the target row first, then move the source row
3277
+
3278
+ Idempotent: a name already absent from ``from_workspace`` is reported under
3279
+ 'missing' and is a no-op, so re-running after a partial apply is safe.
3280
+
3281
+ Subject to the curated-memory write guard
3282
+ (:func:`_assert_dispatch_can_write_memory`): like every other memory
3283
+ mutator, this refuses writes from a NON-curator subagent dispatch. Run it
3284
+ from a human shell or the orchestrator/operator context.
3285
+
3286
+ Returns:
3287
+ {"status": "applied"|"preview", "from": ..., "to": ...,
3288
+ "moved": [...], "skipped": [...], "missing": [...],
3289
+ "overwritten": [...],
3290
+ "links_moved": [{"src","dst","kind"}...],
3291
+ "partial_links": [{"src","dst","kind"}...]}.
3292
+
3293
+ Raises:
3294
+ ValueError: invalid on_conflict, empty names, from==to, or an
3295
+ unresolved PK conflict when on_conflict='error'.
3296
+ MemoryWriteForbidden: when GAIA_DISPATCH_AGENT names a non-curator.
3297
+ """
3298
+ _assert_dispatch_can_write_memory()
3299
+
3300
+ if on_conflict not in ("error", "skip", "overwrite"):
3301
+ raise ValueError(
3302
+ f"relocate_memory: invalid on_conflict {on_conflict!r}; "
3303
+ f"must be 'error', 'skip', or 'overwrite'"
3304
+ )
3305
+ name_list = list(names)
3306
+ if not name_list:
3307
+ raise ValueError("relocate_memory: at least one name is required")
3308
+ if from_workspace == to_workspace:
3309
+ raise ValueError(
3310
+ "relocate_memory: from_workspace and to_workspace are identical"
3311
+ )
3312
+
3313
+ moved: list[str] = []
3314
+ skipped: list[str] = []
3315
+ missing: list[str] = []
3316
+ overwritten: list[str] = []
3317
+ links_moved: list[dict] = []
3318
+ partial_links: list[dict] = []
3319
+
3320
+ con = _connect(db_path)
3321
+ try:
3322
+ con.execute("BEGIN")
3323
+ try:
3324
+ if not dry_run:
3325
+ _ensure_workspace_row(con, to_workspace)
3326
+
3327
+ for name in name_list:
3328
+ src = con.execute(
3329
+ "SELECT 1 FROM memory WHERE workspace = ? AND name = ?",
3330
+ (from_workspace, name),
3331
+ ).fetchone()
3332
+ if src is None:
3333
+ missing.append(name)
3334
+ continue
3335
+
3336
+ dst = con.execute(
3337
+ "SELECT 1 FROM memory WHERE workspace = ? AND name = ?",
3338
+ (to_workspace, name),
3339
+ ).fetchone()
3340
+ if dst is not None:
3341
+ if on_conflict == "error":
3342
+ raise ValueError(
3343
+ f"relocate_memory: target already has memory "
3344
+ f"{name!r} under workspace {to_workspace!r} "
3345
+ f"(on_conflict='error')"
3346
+ )
3347
+ if on_conflict == "skip":
3348
+ skipped.append(name)
3349
+ continue
3350
+ # overwrite: drop the target row first (memory_ad keeps FTS
3351
+ # in sync); its own links under `to` are left as-is.
3352
+ if not dry_run:
3353
+ con.execute(
3354
+ "DELETE FROM memory WHERE workspace = ? AND name = ?",
3355
+ (to_workspace, name),
3356
+ )
3357
+ overwritten.append(name)
3358
+
3359
+ if not dry_run:
3360
+ con.execute(
3361
+ "UPDATE memory SET workspace = ? "
3362
+ "WHERE workspace = ? AND name = ?",
3363
+ (to_workspace, from_workspace, name),
3364
+ )
3365
+ moved.append(name)
3366
+
3367
+ # Re-key links that live entirely within the moved set.
3368
+ moved_set = set(moved)
3369
+ if moved_set:
3370
+ link_rows = con.execute(
3371
+ "SELECT src_name, dst_name, kind FROM memory_links "
3372
+ "WHERE workspace = ?",
3373
+ (from_workspace,),
3374
+ ).fetchall()
3375
+ for lr in link_rows:
3376
+ src_in = lr["src_name"] in moved_set
3377
+ dst_in = lr["dst_name"] in moved_set
3378
+ if not (src_in or dst_in):
3379
+ continue
3380
+ entry = {
3381
+ "src": lr["src_name"],
3382
+ "dst": lr["dst_name"],
3383
+ "kind": lr["kind"],
3384
+ }
3385
+ if src_in and dst_in:
3386
+ if not dry_run:
3387
+ con.execute(
3388
+ "UPDATE memory_links SET workspace = ? "
3389
+ "WHERE workspace = ? AND src_name = ? "
3390
+ "AND dst_name = ? AND kind = ?",
3391
+ (to_workspace, from_workspace,
3392
+ lr["src_name"], lr["dst_name"], lr["kind"]),
3393
+ )
3394
+ links_moved.append(entry)
3395
+ else:
3396
+ # Only one endpoint moved. Under the single-workspace
3397
+ # link model this edge is now referentially dangling:
3398
+ # one of its endpoints no longer exists under
3399
+ # ``from_workspace`` and cannot be re-homed to
3400
+ # ``to_workspace`` (the other endpoint stayed). Leaving
3401
+ # it in place is silent corruption -- scan-v2 SV3 removes
3402
+ # the dangling edge and reports it under 'partial_links'
3403
+ # so nothing is lost silently. A link is derived graph
3404
+ # metadata, not curated memory: both endpoint rows (the
3405
+ # data) survive untouched; only the broken edge is
3406
+ # dropped. Never touches memory rows.
3407
+ if not dry_run:
3408
+ con.execute(
3409
+ "DELETE FROM memory_links "
3410
+ "WHERE workspace = ? AND src_name = ? "
3411
+ "AND dst_name = ? AND kind = ?",
3412
+ (from_workspace, lr["src_name"],
3413
+ lr["dst_name"], lr["kind"]),
3414
+ )
3415
+ partial_links.append(entry)
3416
+
3417
+ con.commit()
3418
+ except Exception:
3419
+ con.rollback()
3420
+ raise
3421
+ finally:
3422
+ con.close()
3423
+
3424
+ return {
3425
+ "status": "preview" if dry_run else "applied",
3426
+ "from": from_workspace,
3427
+ "to": to_workspace,
3428
+ "moved": moved,
3429
+ "skipped": skipped,
3430
+ "missing": missing,
3431
+ "overwritten": overwritten,
3432
+ "links_moved": links_moved,
3433
+ "partial_links": partial_links,
3434
+ }
3435
+
3436
+
3437
+ # ---------------------------------------------------------------------------
3438
+ # scan-v2 SV4: move-candidate adjudication (superseded_by write / re-key).
3439
+ #
3440
+ # `gaia scan` (SV2) only DETECTS and REPORTS a move -- it pairs a project that
3441
+ # vanished from one workspace 1:1 (by normalized remote) with a project that
3442
+ # appeared in another, and emits a `move_candidate`. It never mutates the
3443
+ # lineage. A human then adjudicates each candidate; `resolve_move_candidate`
3444
+ # below is the write path that EXECUTES an adjudicated 'movido' decision.
3445
+ #
3446
+ # Post-scan, a detected move leaves TWO rows in `projects`:
3447
+ # * the OLD row (the `from` side): now status='missing' (soft-deleted by the
3448
+ # reconcile pass), still carrying the pre-move project_identity (its
3449
+ # git-common-dir at the old location) and any agent-owned `description`.
3450
+ # * the NEW row (the `to` side): freshly upserted, status='active', carrying
3451
+ # a DIFFERENT project_identity (the git-common-dir changed when the repo
3452
+ # physically moved). This is the successor.
3453
+ #
3454
+ # The 'movido' adjudication links the two WITHOUT ever hard-deleting either:
3455
+ # * When the successor row ALREADY exists (the realistic post-scan state, and
3456
+ # the only state a move_candidate is ever emitted from): the old row is
3457
+ # tombstoned (status='missing') and its `superseded_by` column is set to the
3458
+ # successor's project_identity -- the forward link that records "this row's
3459
+ # project moved to the row bearing identity X". Both rows survive; the
3460
+ # successor stays the active canonical at the new (workspace, name). A
3461
+ # merge/re-key of the old row ONTO the successor slot is impossible without
3462
+ # destroying the successor row (a hard delete), which the no-hard-delete
3463
+ # principle forbids -- so the link, not a key rewrite, is the mechanism.
3464
+ # * When the successor row does NOT exist (defensive path, e.g. adjudicating
3465
+ # from a cross-DB or dry-run report where the new location was never
3466
+ # scanned into its own row): the OLD row is RE-KEYED in place -- its
3467
+ # (workspace, name) is updated to the successor location and status flipped
3468
+ # back to 'active'. The row (identity, description, remote) travels intact;
3469
+ # the re-key preserves the data.
3470
+ #
3471
+ # Agent-authored collateral (curated `memory`, `project_context_contracts`) is
3472
+ # NEVER auto-moved here -- it is only PROPOSED. The human relocates it
3473
+ # deliberately via `gaia context move-memory` / `move-contracts` once the move
3474
+ # is confirmed. This function touches only the `projects` lineage.
3475
+ #
3476
+ # 'duplicado' / 'worktree' decisions are a structural no-op: both rows are
3477
+ # legitimately independent and are left exactly as they are (see the CLI
3478
+ # `--decision` handling; this writer is only invoked for 'movido').
3479
+ # ---------------------------------------------------------------------------
3480
+
3481
+ def resolve_move_candidate(
3482
+ from_workspace: str,
3483
+ from_name: str,
3484
+ to_workspace: str,
3485
+ to_name: str,
3486
+ *,
3487
+ dry_run: bool = False,
3488
+ db_path: Path | None = None,
3489
+ ) -> dict:
3490
+ """Execute a 'movido' adjudication of a scan-v2 move_candidate.
3491
+
3492
+ Links the OLD (``from``) project row to its successor (``to``) without ever
3493
+ hard-deleting a row. Two branches (see the module comment above):
3494
+
3495
+ * successor row EXISTS -> tombstone the old row (status='missing') and
3496
+ write ``superseded_by`` = successor.project_identity on it. Both rows
3497
+ survive; the successor stays the active canonical. action='superseded'.
3498
+ * successor row ABSENT -> re-key the old row in place: update its
3499
+ (workspace, name) to the successor location, flip status back to
3500
+ 'active', clear missing_since. The row's data travels intact.
3501
+ action='rekeyed'.
3502
+
3503
+ Curated memory / PCC are NOT moved here -- they are proposed for a separate
3504
+ `move-memory` / `move-contracts` step. This function only touches the
3505
+ `projects` lineage.
3506
+
3507
+ Args:
3508
+ from_workspace: Old row workspace (move_candidate ``from.workspace``).
3509
+ from_name: Old row name (move_candidate ``from.project``).
3510
+ to_workspace: Successor workspace (move_candidate ``to.workspace``).
3511
+ to_name: Successor name (move_candidate ``to.project``).
3512
+ dry_run: When True, mutate nothing; report the branch + successor
3513
+ identity that WOULD be written.
3514
+ db_path: Optional explicit DB path (used by tests).
3515
+
3516
+ Returns:
3517
+ {"status": "applied"|"preview", "action": "superseded"|"rekeyed",
3518
+ "from": {"workspace","name"}, "to": {"workspace","name"},
3519
+ "superseded_by": <successor project_identity or None>,
3520
+ "proposed_relocations": {"memory": <n>, "contracts": <n>}}.
3521
+
3522
+ Raises:
3523
+ ValueError: when the old row does not exist, or from == to.
2626
3524
  """
3525
+ if not from_workspace or not from_name:
3526
+ raise ValueError("resolve_move_candidate: from_workspace and from_name are required")
3527
+ if not to_workspace or not to_name:
3528
+ raise ValueError("resolve_move_candidate: to_workspace and to_name are required")
3529
+ if (from_workspace, from_name) == (to_workspace, to_name):
3530
+ raise ValueError(
3531
+ "resolve_move_candidate: from and to are identical -- nothing to resolve"
3532
+ )
3533
+
2627
3534
  con = _connect(db_path)
2628
3535
  try:
2629
3536
  con.execute("BEGIN")
2630
3537
  try:
2631
- con.execute("DELETE FROM workspaces WHERE name = ?", (workspace,))
3538
+ old = con.execute(
3539
+ "SELECT workspace, name, project_identity, status "
3540
+ "FROM projects WHERE workspace = ? AND name = ?",
3541
+ (from_workspace, from_name),
3542
+ ).fetchone()
3543
+ if old is None:
3544
+ raise ValueError(
3545
+ f"resolve_move_candidate: old row "
3546
+ f"({from_workspace!r}, {from_name!r}) not found"
3547
+ )
3548
+
3549
+ successor = con.execute(
3550
+ "SELECT workspace, name, project_identity, status "
3551
+ "FROM projects WHERE workspace = ? AND name = ?",
3552
+ (to_workspace, to_name),
3553
+ ).fetchone()
3554
+
3555
+ # Count agent-authored collateral still keyed to the OLD workspace,
3556
+ # so the caller can PROPOSE (never auto-execute) its relocation.
3557
+ proposed_memory = con.execute(
3558
+ "SELECT COUNT(*) FROM memory WHERE workspace = ? "
3559
+ "AND deleted_at IS NULL",
3560
+ (from_workspace,),
3561
+ ).fetchone()[0]
3562
+ proposed_contracts = con.execute(
3563
+ "SELECT COUNT(*) FROM project_context_contracts WHERE workspace = ?",
3564
+ (from_workspace,),
3565
+ ).fetchone()[0]
3566
+
3567
+ now = _now_iso()
3568
+
3569
+ if successor is not None:
3570
+ # Realistic post-scan state: two rows. Tombstone the old row and
3571
+ # link it forward to the successor identity. Never hard-delete.
3572
+ action = "superseded"
3573
+ superseded_by = successor["project_identity"]
3574
+ if not dry_run:
3575
+ con.execute(
3576
+ "UPDATE projects SET status = 'missing', "
3577
+ "missing_since = COALESCE(missing_since, ?), "
3578
+ "superseded_by = ? "
3579
+ "WHERE workspace = ? AND name = ?",
3580
+ (now, superseded_by, from_workspace, from_name),
3581
+ )
3582
+ # Ensure the successor is the active canonical row.
3583
+ con.execute(
3584
+ "UPDATE projects SET status = 'active', missing_since = NULL "
3585
+ "WHERE workspace = ? AND name = ?",
3586
+ (to_workspace, to_name),
3587
+ )
3588
+ else:
3589
+ # Successor slot is free: re-key the old row in place. The row's
3590
+ # identity + description + remote travel with it (data preserved).
3591
+ action = "rekeyed"
3592
+ superseded_by = old["project_identity"]
3593
+ if not dry_run:
3594
+ con.execute(
3595
+ "UPDATE projects SET workspace = ?, name = ?, "
3596
+ "status = 'active', missing_since = NULL "
3597
+ "WHERE workspace = ? AND name = ?",
3598
+ (to_workspace, to_name, from_workspace, from_name),
3599
+ )
3600
+
2632
3601
  con.commit()
2633
3602
  except Exception:
2634
3603
  con.rollback()
@@ -2636,6 +3605,18 @@ def wipe_workspace(workspace: str, *, db_path: Path | None = None) -> None:
2636
3605
  finally:
2637
3606
  con.close()
2638
3607
 
3608
+ return {
3609
+ "status": "preview" if dry_run else "applied",
3610
+ "action": action,
3611
+ "from": {"workspace": from_workspace, "name": from_name},
3612
+ "to": {"workspace": to_workspace, "name": to_name},
3613
+ "superseded_by": superseded_by,
3614
+ "proposed_relocations": {
3615
+ "memory": proposed_memory,
3616
+ "contracts": proposed_contracts,
3617
+ },
3618
+ }
3619
+
2639
3620
 
2640
3621
  # ---------------------------------------------------------------------------
2641
3622
  # Public API: approval_grants (DB-backed command_set grant store, M3)
@@ -3009,10 +3990,10 @@ def insert_semantic_grant(
3009
3990
  Retained for audit only -- check_db_semantic_grant() matches
3010
3991
  cross-session (Brief 71), so this is NOT used to scope lookup.
3011
3992
  ttl_minutes: Grant lifetime in minutes. Defaults to
3012
- APPROVAL_GRANT_TTL_MINUTES (60), aligning the grant lifetime with the
3013
- 1-hour staleness horizon so a human-in-the-loop approval crossing a
3014
- session boundary does not silently expire before the subagent can
3015
- consume it. This is the GRANT window, distinct from the 24h pending
3993
+ APPROVAL_GRANT_TTL_MINUTES (5). The grant is consumed AT THE MATCH,
3994
+ so this short window only needs to cover the block -> approve ->
3995
+ retry round trip; a grant never presented to a matching retry simply
3996
+ expires. This is the GRANT window, distinct from the 24h pending
3016
3997
  window (DEFAULT_PENDING_TTL_MINUTES).
3017
3998
  db_path: Optional explicit DB path (used by tests).
3018
3999
 
@@ -3517,9 +4498,9 @@ def consume_db_file_path_grant(
3517
4498
  # ---------------------------------------------------------------------------
3518
4499
  #
3519
4500
  # Foundation scaffolding for the grant-lifecycle FS-to-DB migration (v20).
3520
- # These helpers are called by the upcoming confirm_grant and
3521
- # consume_session_grants migration; callers are NOT wired yet -- only the DB
3522
- # write path is provided here.
4501
+ # confirm_db_grant() backs the confirm_grant flow. (The former
4502
+ # consume_session_grants SubagentStop sweep was removed in the approvals
4503
+ # redesign M1 -- grants are consumed at the match and expire on their short TTL.)
3523
4504
  #
3524
4505
  # confirm_db_grant() -- sets confirmed=1 on a PENDING grant row;
3525
4506
  # used when the user explicitly confirms a