create-egregore 0.19.33 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/assets/egregore-launcher.js +118 -13
  2. package/bin/cli.js +19 -3
  3. package/lib/connect-runtime.js +4 -2
  4. package/lib/local.js +15 -1
  5. package/lib/prime-runtime.js +208 -0
  6. package/lib/setup.js +45 -3
  7. package/package.json +4 -3
  8. package/runtime/codex/.codex/skills/ingest-notion/SKILL.md +29 -0
  9. package/runtime/codex/.codex/skills/notion-connect/SKILL.md +29 -0
  10. package/runtime/codex/.codex/skills/teams-connect/SKILL.md +29 -0
  11. package/runtime/codex/.codex/spec-manifest.json +3 -3
  12. package/runtime/codex/AGENTS.md +11 -4
  13. package/runtime/codex/bin/activity-data.sh +4 -8
  14. package/runtime/codex/bin/attendant.sh +13 -0
  15. package/runtime/codex/bin/autosave.sh +27 -1
  16. package/runtime/codex/bin/codex-render-spec.mjs +6 -0
  17. package/runtime/codex/bin/codex-session-start.sh +3 -1
  18. package/runtime/codex/bin/graph-batch.sh +25 -6
  19. package/runtime/codex/bin/graph-op.sh +5 -4
  20. package/runtime/codex/bin/graph.sh +31 -5
  21. package/runtime/codex/bin/ingest.py +81 -5
  22. package/runtime/codex/bin/lib/context.sh +26 -1
  23. package/runtime/codex/bin/lib/greeting.sh +4 -1
  24. package/runtime/codex/bin/lib/worktree-links.sh +25 -0
  25. package/runtime/codex/manifest.json +7 -1
  26. package/runtime/pi/.codex/skills/ingest-notion/SKILL.md +29 -0
  27. package/runtime/pi/.codex/skills/notion-connect/SKILL.md +29 -0
  28. package/runtime/pi/.codex/skills/teams-connect/SKILL.md +29 -0
  29. package/runtime/pi/.codex/spec-manifest.json +3 -3
  30. package/runtime/pi/.pi/APPEND_SYSTEM.md +3 -2
  31. package/runtime/pi/.pi/extensions/egregore.ts +17 -6
  32. package/runtime/pi/.pi/spec-manifest.json +2 -2
  33. package/runtime/pi/AGENTS.md +11 -4
  34. package/runtime/pi/bin/activity-data.sh +4 -8
  35. package/runtime/pi/bin/attendant.sh +13 -0
  36. package/runtime/pi/bin/autosave.sh +27 -1
  37. package/runtime/pi/bin/codex-render-spec.mjs +6 -0
  38. package/runtime/pi/bin/codex-session-start.sh +3 -1
  39. package/runtime/pi/bin/graph-batch.sh +25 -6
  40. package/runtime/pi/bin/graph-op.sh +5 -4
  41. package/runtime/pi/bin/graph.sh +31 -5
  42. package/runtime/pi/bin/ingest.py +81 -5
  43. package/runtime/pi/bin/lib/context.sh +26 -1
  44. package/runtime/pi/bin/lib/greeting.sh +4 -1
  45. package/runtime/pi/bin/lib/worktree-links.sh +25 -0
  46. package/runtime/pi/bin/pi-branch-policy.mjs +138 -1
  47. package/runtime/pi/bin/pi-product-workflows.mjs +35 -7
  48. package/runtime/pi/manifest.json +7 -1
  49. package/runtime/prime/.claude/context/notification-consent.md +66 -0
  50. package/runtime/prime/.codex/config.toml +17 -0
  51. package/runtime/prime/.codex/hooks/branch-guard.js +461 -0
  52. package/runtime/prime/.codex/hooks/search-hint.js +67 -0
  53. package/runtime/prime/.codex/hooks.json +30 -0
  54. package/runtime/prime/.codex/skills/activity/SKILL.md +82 -0
  55. package/runtime/prime/.codex/skills/announce/SKILL.md +67 -0
  56. package/runtime/prime/.codex/skills/archive/SKILL.md +45 -0
  57. package/runtime/prime/.codex/skills/branch/SKILL.md +29 -0
  58. package/runtime/prime/.codex/skills/checkup/SKILL.md +45 -0
  59. package/runtime/prime/.codex/skills/commit/SKILL.md +29 -0
  60. package/runtime/prime/.codex/skills/contribute/SKILL.md +29 -0
  61. package/runtime/prime/.codex/skills/create-skill/SKILL.md +29 -0
  62. package/runtime/prime/.codex/skills/dark-mode/SKILL.md +29 -0
  63. package/runtime/prime/.codex/skills/dashboard/SKILL.md +59 -0
  64. package/runtime/prime/.codex/skills/deep-reflect/SKILL.md +109 -0
  65. package/runtime/prime/.codex/skills/delete-user/SKILL.md +29 -0
  66. package/runtime/prime/.codex/skills/emissary/SKILL.md +45 -0
  67. package/runtime/prime/.codex/skills/handoff/SKILL.md +166 -0
  68. package/runtime/prime/.codex/skills/harvest/QUESTION_PALETTE.md +76 -0
  69. package/runtime/prime/.codex/skills/harvest/SKILL.md +131 -0
  70. package/runtime/prime/.codex/skills/ingest/SKILL.md +29 -0
  71. package/runtime/prime/.codex/skills/ingest-notion/SKILL.md +29 -0
  72. package/runtime/prime/.codex/skills/ingest-user-interview/SKILL.md +45 -0
  73. package/runtime/prime/.codex/skills/invite/SKILL.md +44 -0
  74. package/runtime/prime/.codex/skills/issue/SKILL.md +45 -0
  75. package/runtime/prime/.codex/skills/me/SKILL.md +29 -0
  76. package/runtime/prime/.codex/skills/meeting/SKILL.md +45 -0
  77. package/runtime/prime/.codex/skills/note/SKILL.md +29 -0
  78. package/runtime/prime/.codex/skills/notion-connect/SKILL.md +29 -0
  79. package/runtime/prime/.codex/skills/onboarding/SKILL.md +45 -0
  80. package/runtime/prime/.codex/skills/pr/SKILL.md +29 -0
  81. package/runtime/prime/.codex/skills/pull/SKILL.md +29 -0
  82. package/runtime/prime/.codex/skills/push/SKILL.md +29 -0
  83. package/runtime/prime/.codex/skills/quest/SKILL.md +80 -0
  84. package/runtime/prime/.codex/skills/reflect/SKILL.md +45 -0
  85. package/runtime/prime/.codex/skills/review-pr/SKILL.md +45 -0
  86. package/runtime/prime/.codex/skills/save/SKILL.md +125 -0
  87. package/runtime/prime/.codex/skills/slack-connect/SKILL.md +29 -0
  88. package/runtime/prime/.codex/skills/summon/SKILL.md +45 -0
  89. package/runtime/prime/.codex/skills/sync-repos/SKILL.md +29 -0
  90. package/runtime/prime/.codex/skills/teams-connect/SKILL.md +29 -0
  91. package/runtime/prime/.codex/skills/telegram-connect/SKILL.md +29 -0
  92. package/runtime/prime/.codex/skills/telemetry/SKILL.md +29 -0
  93. package/runtime/prime/.codex/skills/test/SKILL.md +45 -0
  94. package/runtime/prime/.codex/skills/the-spiral/SKILL.md +84 -0
  95. package/runtime/prime/.codex/skills/tui-design/SKILL.md +45 -0
  96. package/runtime/prime/.codex/skills/tutorial/SKILL.md +45 -0
  97. package/runtime/prime/.codex/skills/update/SKILL.md +29 -0
  98. package/runtime/prime/.codex/skills/view/SKILL.md +156 -0
  99. package/runtime/prime/.codex/skills/view/agents/openai.yaml +4 -0
  100. package/runtime/prime/.codex/skills/view/assets/compose-scaffold.html +221 -0
  101. package/runtime/prime/.codex/skills/visual-explain/SKILL.md +45 -0
  102. package/runtime/prime/.codex/skills/wrap/SKILL.md +58 -0
  103. package/runtime/prime/.codex/spec-manifest.json +132 -0
  104. package/runtime/prime/.prime/agent/APPEND_SYSTEM.md +393 -0
  105. package/runtime/prime/.prime/agent/extensions/egregore.ts +460 -0
  106. package/runtime/prime/.prime/agent/settings.json +4 -0
  107. package/runtime/prime/.prime/agent/skills/egregore/SKILL.md +34 -0
  108. package/runtime/prime/.prime/agent/skills/egregore/pyproject.toml +13 -0
  109. package/runtime/prime/.prime/agent/skills/egregore/src/egregore/__init__.py +156 -0
  110. package/runtime/prime/.prime/agent/spec-manifest.json +12 -0
  111. package/runtime/prime/AGENTS.md +489 -0
  112. package/runtime/prime/bin/activity-data.sh +238 -0
  113. package/runtime/prime/bin/agent.sh +775 -0
  114. package/runtime/prime/bin/attendant.sh +707 -0
  115. package/runtime/prime/bin/autosave.sh +112 -0
  116. package/runtime/prime/bin/capture-reconcile.sh +69 -0
  117. package/runtime/prime/bin/capture-run.sh +341 -0
  118. package/runtime/prime/bin/codex-install-skills.sh +78 -0
  119. package/runtime/prime/bin/codex-render-spec.mjs +440 -0
  120. package/runtime/prime/bin/codex-session-start.sh +265 -0
  121. package/runtime/prime/bin/codex-skill-render.mjs +347 -0
  122. package/runtime/prime/bin/codex-sync-skills.sh +232 -0
  123. package/runtime/prime/bin/connect-refresh.sh +33 -0
  124. package/runtime/prime/bin/dashboard-data.sh +178 -0
  125. package/runtime/prime/bin/graph-batch.sh +79 -0
  126. package/runtime/prime/bin/graph-op.sh +1102 -0
  127. package/runtime/prime/bin/graph-wal.sh +229 -0
  128. package/runtime/prime/bin/graph.sh +223 -0
  129. package/runtime/prime/bin/handoff-pr-backfill.sh +86 -0
  130. package/runtime/prime/bin/handoff-run.sh +671 -0
  131. package/runtime/prime/bin/handoff-save-egregore.sh +212 -0
  132. package/runtime/prime/bin/index-handoff.sh +322 -0
  133. package/runtime/prime/bin/ingest-graph.sh +80 -0
  134. package/runtime/prime/bin/ingest-surface.html +495 -0
  135. package/runtime/prime/bin/ingest.py +756 -0
  136. package/runtime/prime/bin/ingest.sh +279 -0
  137. package/runtime/prime/bin/ingest_graph.py +856 -0
  138. package/runtime/prime/bin/ingest_surface.py +544 -0
  139. package/runtime/prime/bin/lib/artifact-id.sh +46 -0
  140. package/runtime/prime/bin/lib/config.sh +116 -0
  141. package/runtime/prime/bin/lib/context.sh +621 -0
  142. package/runtime/prime/bin/lib/dashboard-artifact.sh +242 -0
  143. package/runtime/prime/bin/lib/ensure-develop.sh +183 -0
  144. package/runtime/prime/bin/lib/git-sync.sh +406 -0
  145. package/runtime/prime/bin/lib/greeting.sh +519 -0
  146. package/runtime/prime/bin/lib/hash.sh +13 -0
  147. package/runtime/prime/bin/lib/identity.sh +213 -0
  148. package/runtime/prime/bin/lib/metrics.sh +374 -0
  149. package/runtime/prime/bin/lib/noncode.sh +31 -0
  150. package/runtime/prime/bin/lib/permissions.sh +106 -0
  151. package/runtime/prime/bin/lib/time.sh +56 -0
  152. package/runtime/prime/bin/lib/worktree-links.sh +79 -0
  153. package/runtime/prime/bin/notify.sh +636 -0
  154. package/runtime/prime/bin/person.py +630 -0
  155. package/runtime/prime/bin/person.sh +448 -0
  156. package/runtime/prime/bin/pi-branch-policy.mjs +312 -0
  157. package/runtime/prime/bin/pi-product-workflows.mjs +394 -0
  158. package/runtime/prime/bin/prime-render-spec.mjs +123 -0
  159. package/runtime/prime/bin/prime-session-start.sh +24 -0
  160. package/runtime/prime/bin/publish-artifact.sh +271 -0
  161. package/runtime/prime/bin/publish-references.sh +144 -0
  162. package/runtime/prime/bin/repo-state.sh +96 -0
  163. package/runtime/prime/bin/search.sh +240 -0
  164. package/runtime/prime/bin/session-autosave.sh +209 -0
  165. package/runtime/prime/bin/session-log.sh +199 -0
  166. package/runtime/prime/bin/settings.sh +304 -0
  167. package/runtime/prime/bin/telemetry.sh +391 -0
  168. package/runtime/prime/bin/worktree-create.sh +128 -0
  169. package/runtime/prime/bin/worktree-remove.sh +50 -0
  170. package/runtime/prime/bin/worktree.sh +198 -0
  171. package/runtime/prime/manifest.json +178 -0
@@ -0,0 +1,856 @@
1
+ #!/usr/bin/env python3
2
+ """Validate and plan replayable Egregore ingestion graph projections."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import re
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ ROOT = Path(__file__).resolve().parent.parent
15
+ CORPUS_SCHEMA = "egregore-ingest-corpus/v1"
16
+ CONNECTOR_SCHEMA = "egregore-connector-lifecycle/v1"
17
+ KNOWLEDGE_SCHEMA = "egregore-knowledge-projection/v1"
18
+ PLAN_SCHEMA = "egregore-ingest-graph-plan/v2"
19
+ SOURCE_KINDS = {"meeting", "interview", "google", "notion", "corpus", "document"}
20
+ SOURCE_RELATIONS = {"INVOLVES", "CONDUCTED_BY"}
21
+ ARTIFACT_RELATIONS = {"RELATES_TO", "SUPERSEDES"}
22
+ ARTIFACT_LABELS = {
23
+ "decision": "Decision",
24
+ "finding": "Finding",
25
+ "pattern": "Pattern",
26
+ "claim": "Claim",
27
+ "research": "Research",
28
+ }
29
+ PERSON_MATCH = """(
30
+ (
31
+ $personKind = 'external'
32
+ AND p.kind = 'external'
33
+ AND $identityKey <> ''
34
+ AND coalesce(p.ingestRef, '') = $identityKey
35
+ )
36
+ OR (
37
+ $personKind = 'member'
38
+ AND NOT coalesce(p.kind, '') IN ['external', 'identity_alias']
39
+ AND coalesce(p.status, 'active') = 'active'
40
+ AND p.ingestRef IS NULL
41
+ AND (
42
+ coalesce(p.personId, '') = $personRef
43
+ OR coalesce(toString(p.githubId), '') = $personRef
44
+ OR toLower(coalesce(p.name, '')) = toLower($personRef)
45
+ OR toLower(coalesce(p.github, '')) = toLower($personRef)
46
+ OR toLower(coalesce(p.handle, '')) = toLower($personRef)
47
+ OR toLower($personRef) IN [alias IN coalesce(p.previousNames, []) | toLower(alias)]
48
+ OR toLower($personRef) IN [alias IN coalesce(p.githubAliases, []) | toLower(alias)]
49
+ OR toLower($personRef) IN [email IN coalesce(p.emails, []) | toLower(email)]
50
+ )
51
+ )
52
+ )"""
53
+
54
+
55
+ class ContractError(ValueError):
56
+ """The manifest does not satisfy the projection contract."""
57
+
58
+
59
+ def required_string(value: Any, field: str) -> str:
60
+ if not isinstance(value, str) or not value.strip():
61
+ raise ContractError(f"{field} is required")
62
+ return value.strip()
63
+
64
+
65
+ def string_list(value: Any, field: str) -> list[str]:
66
+ if value is None:
67
+ return []
68
+ if not isinstance(value, list) or not all(
69
+ isinstance(item, str) and item.strip() for item in value
70
+ ):
71
+ raise ContractError(f"{field} must be an array of non-empty strings")
72
+ return [item.strip() for item in value]
73
+
74
+
75
+ def graph_properties(value: Any, field: str) -> dict[str, Any]:
76
+ if value is None:
77
+ return {}
78
+ if not isinstance(value, dict):
79
+ raise ContractError(f"{field} must be an object")
80
+ result: dict[str, Any] = {}
81
+ for key, item in value.items():
82
+ if not isinstance(key, str) or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", key):
83
+ raise ContractError(f"{field} contains an unsafe property name")
84
+ if isinstance(item, (str, int, float, bool)) or item is None:
85
+ result[key] = item
86
+ elif isinstance(item, list) and all(
87
+ isinstance(entry, (str, int, float, bool)) or entry is None
88
+ for entry in item
89
+ ):
90
+ result[key] = item
91
+ else:
92
+ raise ContractError(f"{field}.{key} is not a graph-safe scalar or array")
93
+ return result
94
+
95
+
96
+ def manifest_path(path: Path) -> str:
97
+ resolved = path.resolve()
98
+ memory = (ROOT / "memory").resolve()
99
+ try:
100
+ return resolved.relative_to(memory).as_posix()
101
+ except ValueError:
102
+ return str(resolved)
103
+
104
+
105
+ def query(statement: str, parameters: dict[str, Any]) -> dict[str, Any]:
106
+ return {"statement": statement, "parameters": parameters}
107
+
108
+
109
+ def corpus_manifest(value: dict[str, Any]) -> bool:
110
+ return isinstance(value.get("source"), dict) and isinstance(
111
+ value.get("documents"), list
112
+ )
113
+
114
+
115
+ def validate_corpus(value: dict[str, Any]) -> None:
116
+ schema = value.get("schema")
117
+ if schema not in (None, CORPUS_SCHEMA):
118
+ raise ContractError(f"unsupported corpus schema: {schema}")
119
+ source = value.get("source")
120
+ if not isinstance(source, dict):
121
+ raise ContractError("source must be an object")
122
+ required_string(source.get("id"), "source.id")
123
+ required_string(source.get("updated_at"), "source.updated_at")
124
+ graph_properties(source.get("boundaries", {}), "source.boundaries")
125
+ documents = value.get("documents")
126
+ if not isinstance(documents, list):
127
+ raise ContractError("documents must be an array")
128
+ for index, document in enumerate(documents):
129
+ if not isinstance(document, dict):
130
+ raise ContractError(f"documents[{index}] must be an object")
131
+ required_string(document.get("id"), f"documents[{index}].id")
132
+ required_string(
133
+ document.get("source_path"), f"documents[{index}].source_path"
134
+ )
135
+ required_string(
136
+ document.get("content_hash"), f"documents[{index}].content_hash"
137
+ )
138
+ chunks = document.get("chunks", [])
139
+ if not isinstance(chunks, list):
140
+ raise ContractError(f"documents[{index}].chunks must be an array")
141
+ for chunk_index, chunk in enumerate(chunks):
142
+ if not isinstance(chunk, dict):
143
+ raise ContractError(
144
+ f"documents[{index}].chunks[{chunk_index}] must be an object"
145
+ )
146
+ required_string(
147
+ chunk.get("id"),
148
+ f"documents[{index}].chunks[{chunk_index}].id",
149
+ )
150
+ required_string(
151
+ chunk.get("sha256"),
152
+ f"documents[{index}].chunks[{chunk_index}].sha256",
153
+ )
154
+ if not isinstance(chunk.get("index"), int) or chunk["index"] < 0:
155
+ raise ContractError(
156
+ f"documents[{index}].chunks[{chunk_index}].index must be a non-negative integer"
157
+ )
158
+ tombstones = value.get("tombstones", [])
159
+ if not isinstance(tombstones, list):
160
+ raise ContractError("tombstones must be an array")
161
+ for index, tombstone in enumerate(tombstones):
162
+ if not isinstance(tombstone, dict):
163
+ raise ContractError(f"tombstones[{index}] must be an object")
164
+ required_string(tombstone.get("id"), f"tombstones[{index}].id")
165
+
166
+
167
+ def corpus_plan(value: dict[str, Any], path: Path) -> dict[str, Any]:
168
+ validate_corpus(value)
169
+ source = value["source"]
170
+ source_id = source["id"]
171
+ projection_id = f"ingest-source:{source_id}"
172
+ producer = str(value.get("producer_session") or "")
173
+ file_path = manifest_path(path)
174
+ queries: list[dict[str, Any]] = []
175
+ queries.append(
176
+ query(
177
+ """MERGE (s:IngestSource:KnowledgeSource {id: $id})
178
+ SET s.name = $name,
179
+ s.kind = $kind,
180
+ s.sourceKind = $kind,
181
+ s.revision = $updated,
182
+ s.boundariesJson = $boundariesJson,
183
+ s.rootHint = $rootHint,
184
+ s.filePath = $filePath,
185
+ s.updatedAt = datetime($updated),
186
+ s.ingestManifestId = $manifestId,
187
+ s.quarantineStatus = 'unverified'
188
+ WITH s
189
+ OPTIONAL MATCH (producer:Session {id: $producerSid})
190
+ FOREACH (_ IN CASE WHEN producer IS NULL OR $producerSid = '' THEN [] ELSE [1] END |
191
+ MERGE (producer)-[r:INGESTED]->(s)
192
+ SET r.provenance = $manifestId,
193
+ r.method = 'ingest-corpus-v1',
194
+ r.recordedAt = datetime()
195
+ )
196
+ RETURN s.id""",
197
+ {
198
+ "id": source_id,
199
+ "name": source.get("name") or source_id,
200
+ "kind": source.get("kind") or "files",
201
+ "updated": source["updated_at"],
202
+ "boundariesJson": json.dumps(
203
+ source.get("boundaries", {}), sort_keys=True
204
+ ),
205
+ "rootHint": source.get("root_hint") or "",
206
+ "filePath": file_path,
207
+ "manifestId": projection_id,
208
+ "producerSid": producer,
209
+ },
210
+ )
211
+ )
212
+ for document in value.get("documents", []):
213
+ chunk_ids = [chunk["id"] for chunk in document.get("chunks", [])]
214
+ common = {
215
+ "id": document["id"],
216
+ "title": document.get("title") or document["source_path"],
217
+ "sourcePath": document["source_path"],
218
+ "contentHash": document["content_hash"],
219
+ "normalizedContentHash": document.get("normalized_content_hash") or "",
220
+ "revision": document.get("revision") or document["content_hash"],
221
+ "normalizedPath": document.get("file_path") or "",
222
+ "manifestPath": file_path,
223
+ "status": document.get("status") or "unverified",
224
+ "boundariesJson": json.dumps(
225
+ document.get("boundaries", {}), sort_keys=True
226
+ ),
227
+ "mime": document.get("mime") or "",
228
+ "bytes": int(document.get("bytes") or 0),
229
+ "extractionJson": json.dumps(
230
+ document.get("extraction") or {}, sort_keys=True
231
+ ),
232
+ "ingestedAt": document.get("ingested_at") or "",
233
+ "chunkIds": chunk_ids,
234
+ "sourceId": source_id,
235
+ "manifestId": projection_id,
236
+ }
237
+ queries.append(
238
+ query(
239
+ """MERGE (d:IngestDocument:Document:KnowledgeSource {id: $id})
240
+ SET d.title = $title,
241
+ d.sourcePath = $sourcePath,
242
+ d.contentHash = $contentHash,
243
+ d.normalizedContentHash = $normalizedContentHash,
244
+ d.revision = $revision,
245
+ d.filePath = $manifestPath,
246
+ d.normalizedPath = $normalizedPath,
247
+ d.status = $status,
248
+ d.boundariesJson = $boundariesJson,
249
+ d.mime = $mime,
250
+ d.bytes = $bytes,
251
+ d.extractionJson = $extractionJson,
252
+ d.chunkIds = $chunkIds,
253
+ d.ingestedAt = CASE WHEN $ingestedAt = '' THEN d.ingestedAt ELSE datetime($ingestedAt) END,
254
+ d.ingestManifestId = $manifestId
255
+ WITH d
256
+ MATCH (s:IngestSource {id: $sourceId})
257
+ MERGE (d)-[r:FROM_SOURCE]->(s)
258
+ SET r.provenance = $manifestId,
259
+ r.method = 'ingest-corpus-v1',
260
+ r.recordedAt = datetime()
261
+ WITH d
262
+ OPTIONAL MATCH (old:IngestChunk)-[:FROM_DOCUMENT]->(d)
263
+ WHERE NOT (old.id IN $chunkIds)
264
+ SET old.status = 'superseded',
265
+ old.supersededAt = datetime()
266
+ RETURN d.id""",
267
+ common,
268
+ )
269
+ )
270
+ if document.get("chunks"):
271
+ queries.append(
272
+ query(
273
+ """MATCH (d:IngestDocument {id: $id})
274
+ UNWIND $chunks AS chunk
275
+ MERGE (c:IngestChunk:EvidenceChunk {id: chunk.id})
276
+ SET c.ordinal = chunk.index,
277
+ c.contentHash = chunk.sha256,
278
+ c.documentId = $id,
279
+ c.documentRevision = $revision,
280
+ c.pointerPath = $normalizedPath,
281
+ c.textPointer = $normalizedPath + '#ingest-chunk:' + chunk.id,
282
+ c.status = 'current',
283
+ c.ingestManifestId = $manifestId
284
+ MERGE (c)-[r:FROM_DOCUMENT]->(d)
285
+ SET r.provenance = $manifestId,
286
+ r.method = 'ingest-corpus-v1',
287
+ r.recordedAt = datetime()
288
+ RETURN count(c)""",
289
+ {
290
+ **common,
291
+ "chunks": document["chunks"],
292
+ },
293
+ )
294
+ )
295
+ for tombstone in value.get("tombstones", []):
296
+ queries.append(
297
+ query(
298
+ """MATCH (d:IngestDocument {id: $id})
299
+ SET d.status = 'deleted',
300
+ d.deletedAt = CASE WHEN $deleted = '' THEN datetime() ELSE datetime($deleted) END,
301
+ d.ingestManifestId = $manifestId
302
+ WITH d
303
+ OPTIONAL MATCH (c:IngestChunk)-[:FROM_DOCUMENT]->(d)
304
+ SET c.status = 'deleted'
305
+ RETURN d.id""",
306
+ {
307
+ "id": required_string(tombstone.get("id"), "tombstone.id"),
308
+ "deleted": tombstone.get("deleted_at") or "",
309
+ "manifestId": projection_id,
310
+ },
311
+ )
312
+ )
313
+ return {
314
+ "schema": PLAN_SCHEMA,
315
+ "schema_family": "corpus-intake",
316
+ "manifest_id": projection_id,
317
+ "source": {"kind": "corpus", "id": source_id},
318
+ "query_count": len(queries),
319
+ "queries": queries,
320
+ }
321
+
322
+
323
+ def validate_person(person: Any, field: str) -> dict[str, Any]:
324
+ if not isinstance(person, dict):
325
+ raise ContractError(f"{field} must be an object")
326
+ ref = required_string(person.get("ref"), f"{field}.ref")
327
+ kind = str(person.get("kind") or "member")
328
+ if kind not in {"member", "external"}:
329
+ raise ContractError(f"{field}.kind must be member or external")
330
+ relation = str(person.get("relation") or "INVOLVES")
331
+ if relation not in SOURCE_RELATIONS:
332
+ raise ContractError(f"{field}.relation is not supported")
333
+ name = str(person.get("name") or "").strip()
334
+ identity_key = str(person.get("identity_key") or "").strip()
335
+ if kind == "external" and (not name or not identity_key):
336
+ raise ContractError(
337
+ f"{field} external people require name and identity_key"
338
+ )
339
+ return {
340
+ "ref": ref,
341
+ "kind": kind,
342
+ "relation": relation,
343
+ "name": name,
344
+ "identity_key": identity_key,
345
+ "properties": graph_properties(person.get("properties", {}), f"{field}.properties"),
346
+ }
347
+
348
+
349
+ def validate_knowledge(value: dict[str, Any]) -> dict[str, Any]:
350
+ legacy = value.get("schema_version") == 1
351
+ if value.get("schema") != KNOWLEDGE_SCHEMA and not legacy:
352
+ raise ContractError(f"schema must be {KNOWLEDGE_SCHEMA}")
353
+ required_string(value.get("manifest_id"), "manifest_id")
354
+ required_string(value.get("producer_session"), "producer_session")
355
+ pipeline = value.get("pipeline")
356
+ if not isinstance(pipeline, dict):
357
+ raise ContractError("pipeline must be an object")
358
+ required_string(pipeline.get("name"), "pipeline.name")
359
+ if not isinstance(pipeline.get("version"), int):
360
+ raise ContractError("pipeline.version must be an integer")
361
+ source = value.get("source")
362
+ if not isinstance(source, dict):
363
+ raise ContractError("source must be an object")
364
+ kind = required_string(source.get("kind"), "source.kind")
365
+ if kind not in SOURCE_KINDS:
366
+ raise ContractError(f"source.kind must be one of {sorted(SOURCE_KINDS)}")
367
+ required_string(source.get("id"), "source.id")
368
+ required_string(source.get("title"), "source.title")
369
+ required_string(source.get("file_path"), "source.file_path")
370
+ date = str(source.get("date") or "")
371
+ if date and not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date):
372
+ raise ContractError("source.date must be YYYY-MM-DD")
373
+ if kind in {"meeting", "interview"} and not date:
374
+ raise ContractError(f"source.date is required for {kind}")
375
+ people = [
376
+ validate_person(person, f"source.people[{index}]")
377
+ for index, person in enumerate(source.get("people", []))
378
+ ]
379
+ refs = [person["ref"] for person in people]
380
+ if len(refs) != len(set(refs)):
381
+ raise ContractError("source.people refs must be unique")
382
+ source["people"] = people
383
+ graph_properties(source.get("properties", {}), "source.properties")
384
+ string_list(source.get("quest_ids", []), "source.quest_ids")
385
+ artifacts = value.get("artifacts")
386
+ if not isinstance(artifacts, list):
387
+ raise ContractError("artifacts must be an array")
388
+ for index, artifact in enumerate(artifacts):
389
+ field = f"artifacts[{index}]"
390
+ if not isinstance(artifact, dict):
391
+ raise ContractError(f"{field} must be an object")
392
+ required_string(artifact.get("id"), f"{field}.id")
393
+ required_string(artifact.get("title"), f"{field}.title")
394
+ artifact_type = required_string(artifact.get("type"), f"{field}.type").lower()
395
+ if not re.fullmatch(r"[a-z][a-z0-9-]{0,49}", artifact_type):
396
+ raise ContractError(f"{field}.type is invalid")
397
+ required_string(artifact.get("file_path"), f"{field}.file_path")
398
+ required_string(artifact.get("summary"), f"{field}.summary")
399
+ string_list(artifact.get("topics", []), f"{field}.topics")
400
+ string_list(artifact.get("quest_ids", []), f"{field}.quest_ids")
401
+ string_list(artifact.get("mentions", []), f"{field}.mentions")
402
+ chunks = string_list(
403
+ artifact.get("source_chunks", []), f"{field}.source_chunks"
404
+ )
405
+ if kind in {"corpus", "document"} and not chunks:
406
+ raise ContractError(
407
+ f"{field}.source_chunks is required for corpus/document promotion"
408
+ )
409
+ confidence = artifact.get("confidence", 0)
410
+ if not isinstance(confidence, (int, float)) or not 0 <= confidence <= 1:
411
+ raise ContractError(f"{field}.confidence must be between 0 and 1")
412
+ graph_properties(artifact.get("properties", {}), f"{field}.properties")
413
+ author = str(artifact.get("author") or "").strip()
414
+ if author and author not in refs:
415
+ raise ContractError(f"{field}.author must reference source.people")
416
+ unknown_mentions = set(artifact.get("mentions", [])) - set(refs)
417
+ if unknown_mentions:
418
+ raise ContractError(
419
+ f"{field}.mentions contains unknown people: {sorted(unknown_mentions)}"
420
+ )
421
+ relations = value.get("relations", [])
422
+ if not isinstance(relations, list):
423
+ raise ContractError("relations must be an array")
424
+ for index, relation in enumerate(relations):
425
+ field = f"relations[{index}]"
426
+ if not isinstance(relation, dict):
427
+ raise ContractError(f"{field} must be an object")
428
+ required_string(relation.get("from"), f"{field}.from")
429
+ required_string(relation.get("to"), f"{field}.to")
430
+ if relation.get("type") not in ARTIFACT_RELATIONS:
431
+ raise ContractError(f"{field}.type is not supported")
432
+ return value
433
+
434
+
435
+ def source_shape(kind: str) -> tuple[str, str]:
436
+ if kind == "meeting":
437
+ return "Meeting:KnowledgeSource", "FROM_MEETING"
438
+ if kind == "interview":
439
+ return "Interview:KnowledgeSource", "FROM_INTERVIEW"
440
+ if kind == "google":
441
+ return "Artifact:KnowledgeSource", "DERIVED_FROM"
442
+ if kind == "notion":
443
+ return "Artifact:KnowledgeSource", "DERIVED_FROM"
444
+ if kind == "corpus":
445
+ return "IngestSource:KnowledgeSource", "DERIVED_FROM"
446
+ return "IngestDocument:Document:KnowledgeSource", "DERIVED_FROM"
447
+
448
+
449
+ def person_parameters(
450
+ ref: str, people: dict[str, dict[str, Any]]
451
+ ) -> dict[str, str]:
452
+ record = people.get(ref, {})
453
+ return {
454
+ "personRef": ref,
455
+ "personKind": str(record.get("kind") or "member"),
456
+ "identityKey": str(record.get("identity_key") or ""),
457
+ }
458
+
459
+
460
+ def knowledge_plan(value: dict[str, Any], path: Path) -> dict[str, Any]:
461
+ value = validate_knowledge(value)
462
+ manifest_id = value["manifest_id"]
463
+ producer = value["producer_session"]
464
+ pipeline = value["pipeline"]
465
+ source = value["source"]
466
+ kind = source["kind"]
467
+ source_labels, source_edge = source_shape(kind)
468
+ method = f"{pipeline['name']}-v{pipeline['version']}"
469
+ people = {person["ref"]: person for person in source.get("people", [])}
470
+ source_params = {
471
+ "id": source["id"],
472
+ "title": source["title"],
473
+ "kind": kind,
474
+ "date": source.get("date") or "",
475
+ "filePath": source["file_path"],
476
+ "externalRef": source.get("external_ref") or "",
477
+ "contentHash": source.get("content_hash") or "",
478
+ "properties": graph_properties(
479
+ source.get("properties", {}), "source.properties"
480
+ ),
481
+ "manifestId": manifest_id,
482
+ "producerSid": producer,
483
+ "method": method,
484
+ }
485
+ queries: list[dict[str, Any]] = [
486
+ query(
487
+ f"""MERGE (src:{source_labels} {{id: $id}})
488
+ SET src.title = $title,
489
+ src.name = coalesce(src.name, $title),
490
+ src.sourceKind = $kind,
491
+ src.date = CASE WHEN $date = '' THEN src.date ELSE date($date) END,
492
+ src.filePath = $filePath,
493
+ src.externalRef = $externalRef,
494
+ src.contentHash = $contentHash,
495
+ src.ingestManifestId = $manifestId,
496
+ src.verificationStatus = 'reviewed',
497
+ src += $properties
498
+ WITH src
499
+ OPTIONAL MATCH (producer:Session {{id: $producerSid}})
500
+ FOREACH (_ IN CASE WHEN producer IS NULL THEN [] ELSE [1] END |
501
+ MERGE (producer)-[r:INGESTED]->(src)
502
+ SET r.provenance = $manifestId,
503
+ r.method = $method,
504
+ r.recordedAt = datetime()
505
+ )
506
+ RETURN src.id""",
507
+ source_params,
508
+ )
509
+ ]
510
+ for quest_id in source.get("quest_ids", []):
511
+ queries.append(
512
+ query(
513
+ f"""MATCH (src:{source_labels} {{id: $sourceId}})
514
+ MATCH (q:Quest {{id: $questId}})
515
+ MERGE (src)-[r:PART_OF]->(q)
516
+ SET r.provenance = $manifestId,
517
+ r.method = $method,
518
+ r.recordedAt = datetime()
519
+ RETURN q.id""",
520
+ {
521
+ "sourceId": source["id"],
522
+ "questId": quest_id,
523
+ "manifestId": manifest_id,
524
+ "method": method,
525
+ },
526
+ )
527
+ )
528
+ for person in source.get("people", []):
529
+ if person["kind"] == "external":
530
+ queries.append(
531
+ query(
532
+ """MERGE (p:Person:KnowledgeEntity {ingestRef: $identityKey})
533
+ SET p.name = $name,
534
+ p.kind = 'external',
535
+ p.identityStatus = 'source-scoped',
536
+ p.ingestManifestId = $manifestId,
537
+ p += $properties
538
+ RETURN p.ingestRef""",
539
+ {
540
+ "identityKey": person["identity_key"],
541
+ "name": person["name"],
542
+ "manifestId": manifest_id,
543
+ "properties": person["properties"],
544
+ },
545
+ )
546
+ )
547
+ params = {
548
+ **person_parameters(person["ref"], people),
549
+ "sourceId": source["id"],
550
+ "manifestId": manifest_id,
551
+ "method": method,
552
+ }
553
+ queries.append(
554
+ query(
555
+ f"""MATCH (src:{source_labels} {{id: $sourceId}})
556
+ MATCH (p:Person)
557
+ WHERE {PERSON_MATCH}
558
+ MERGE (src)-[r:{person['relation']}]->(p)
559
+ SET r.provenance = $manifestId,
560
+ r.method = $method,
561
+ r.recordedAt = datetime()
562
+ RETURN p.name""",
563
+ params,
564
+ )
565
+ )
566
+ for artifact in value["artifacts"]:
567
+ artifact_type = artifact["type"].lower()
568
+ subtype = ARTIFACT_LABELS.get(artifact_type)
569
+ labels = f"Artifact:{subtype}" if subtype else "Artifact"
570
+ artifact_params = {
571
+ "id": artifact["id"],
572
+ "title": artifact["title"],
573
+ "type": artifact_type,
574
+ "filePath": artifact["file_path"],
575
+ "topics": artifact.get("topics", []),
576
+ "summary": artifact["summary"],
577
+ "confidence": artifact.get("confidence", 0),
578
+ "verificationStatus": artifact.get("verification_status")
579
+ or "reviewed",
580
+ "properties": graph_properties(
581
+ artifact.get("properties", {}),
582
+ f"artifact {artifact['id']} properties",
583
+ ),
584
+ "manifestId": manifest_id,
585
+ "producerSid": producer,
586
+ "method": method,
587
+ }
588
+ queries.append(
589
+ query(
590
+ f"""MERGE (a:{labels} {{id: $id}})
591
+ SET a.title = $title,
592
+ a.type = $type,
593
+ a.filePath = $filePath,
594
+ a.topics = $topics,
595
+ a.summary = $summary,
596
+ a.confidence = $confidence,
597
+ a.verificationStatus = $verificationStatus,
598
+ a.ingestManifestId = $manifestId,
599
+ a.updatedAt = datetime(),
600
+ a += $properties
601
+ WITH a
602
+ OPTIONAL MATCH (producer:Session {{id: $producerSid}})
603
+ FOREACH (_ IN CASE WHEN producer IS NULL THEN [] ELSE [1] END |
604
+ MERGE (producer)-[r:PRODUCED]->(a)
605
+ SET r.provenance = $manifestId,
606
+ r.method = $method,
607
+ r.recordedAt = datetime()
608
+ )
609
+ RETURN a.id""",
610
+ artifact_params,
611
+ )
612
+ )
613
+ queries.append(
614
+ query(
615
+ f"""MATCH (a:Artifact {{id: $artifactId}})
616
+ MATCH (src:{source_labels} {{id: $sourceId}})
617
+ MERGE (a)-[r:{source_edge}]->(src)
618
+ SET r.provenance = $manifestId,
619
+ r.method = $method,
620
+ r.recordedAt = datetime()
621
+ RETURN a.id""",
622
+ {
623
+ "artifactId": artifact["id"],
624
+ "sourceId": source["id"],
625
+ "manifestId": manifest_id,
626
+ "method": method,
627
+ },
628
+ )
629
+ )
630
+ if artifact.get("author"):
631
+ queries.append(
632
+ query(
633
+ f"""MATCH (a:Artifact {{id: $artifactId}})
634
+ MATCH (p:Person)
635
+ WHERE {PERSON_MATCH}
636
+ MERGE (a)-[r:CONTRIBUTED_BY]->(p)
637
+ SET r.provenance = $manifestId,
638
+ r.method = $method,
639
+ r.recordedAt = datetime()
640
+ RETURN p.name""",
641
+ {
642
+ "artifactId": artifact["id"],
643
+ **person_parameters(artifact["author"], people),
644
+ "manifestId": manifest_id,
645
+ "method": method,
646
+ },
647
+ )
648
+ )
649
+ for mentioned in artifact.get("mentions", []):
650
+ queries.append(
651
+ query(
652
+ f"""MATCH (a:Artifact {{id: $artifactId}})
653
+ MATCH (p:Person)
654
+ WHERE {PERSON_MATCH}
655
+ MERGE (a)-[r:MENTIONS]->(p)
656
+ SET r.provenance = $manifestId,
657
+ r.method = $method,
658
+ r.recordedAt = datetime()
659
+ RETURN p.name""",
660
+ {
661
+ "artifactId": artifact["id"],
662
+ **person_parameters(mentioned, people),
663
+ "manifestId": manifest_id,
664
+ "method": method,
665
+ },
666
+ )
667
+ )
668
+ if artifact.get("source_chunks"):
669
+ queries.append(
670
+ query(
671
+ """MATCH (a:Artifact {id: $artifactId})
672
+ UNWIND $chunkIds AS chunkId
673
+ MATCH (c:IngestChunk {id: chunkId})
674
+ MERGE (a)-[r:DERIVED_FROM_CHUNK]->(c)
675
+ SET r.provenance = $manifestId,
676
+ r.method = $method,
677
+ r.recordedAt = datetime()
678
+ RETURN count(c)""",
679
+ {
680
+ "artifactId": artifact["id"],
681
+ "chunkIds": artifact["source_chunks"],
682
+ "manifestId": manifest_id,
683
+ "method": method,
684
+ },
685
+ )
686
+ )
687
+ for quest_id in artifact.get("quest_ids", []):
688
+ queries.append(
689
+ query(
690
+ """MATCH (a:Artifact {id: $artifactId})
691
+ MATCH (q:Quest {id: $questId})
692
+ MERGE (a)-[r:PART_OF]->(q)
693
+ SET r.provenance = $manifestId,
694
+ r.method = $method,
695
+ r.recordedAt = datetime()
696
+ RETURN q.id""",
697
+ {
698
+ "artifactId": artifact["id"],
699
+ "questId": quest_id,
700
+ "manifestId": manifest_id,
701
+ "method": method,
702
+ },
703
+ )
704
+ )
705
+ for relation in value.get("relations", []):
706
+ queries.append(
707
+ query(
708
+ f"""MATCH (a:Artifact {{id: $fromId}})
709
+ MATCH (b:Artifact {{id: $toId}})
710
+ MERGE (a)-[r:{relation['type']}]->(b)
711
+ SET r.provenance = $manifestId,
712
+ r.method = $method,
713
+ r.recordedAt = datetime()
714
+ RETURN a.id""",
715
+ {
716
+ "fromId": relation["from"],
717
+ "toId": relation["to"],
718
+ "manifestId": manifest_id,
719
+ "method": method,
720
+ },
721
+ )
722
+ )
723
+ return {
724
+ "schema": PLAN_SCHEMA,
725
+ "schema_family": "reviewed-knowledge",
726
+ "manifest_id": manifest_id,
727
+ "manifest_path": manifest_path(path),
728
+ "source": {"kind": kind, "id": source["id"]},
729
+ "query_count": len(queries),
730
+ "queries": queries,
731
+ }
732
+
733
+
734
+ def connector_manifest(value: dict[str, Any]) -> bool:
735
+ return value.get("schema") == CONNECTOR_SCHEMA
736
+
737
+
738
+ def connector_plan(value: dict[str, Any], path: Path) -> dict[str, Any]:
739
+ """Project connector lifecycle: which providers this org has connected,
740
+ which accounts are authorized on each, and which ingest sources arrived
741
+ through which account. Connected mode only — the plan is executed by the
742
+ same runner as corpus plans and is idempotent (MERGE throughout)."""
743
+ provider = required_string(value.get("provider"), "provider")
744
+ if provider not in ("google", "notion"):
745
+ raise ContractError(f"unsupported connector provider: {provider}")
746
+ accounts = value.get("accounts")
747
+ if not isinstance(accounts, list) or not accounts:
748
+ raise ContractError("accounts must be a non-empty list")
749
+ recorded = required_string(value.get("recorded_at"), "recorded_at")
750
+ source_id = str(value.get("source_id") or "")
751
+ queries: list[dict[str, Any]] = []
752
+ queries.append(
753
+ query(
754
+ """MERGE (c:Connector {id: $provider})
755
+ SET c.provider = $provider,
756
+ c.updatedAt = datetime($recorded)
757
+ RETURN c.id""",
758
+ {"provider": provider, "recorded": recorded},
759
+ )
760
+ )
761
+ for entry in accounts:
762
+ if not isinstance(entry, dict):
763
+ raise ContractError("each account must be an object")
764
+ email = required_string(entry.get("email"), "accounts[].email")
765
+ status = str(entry.get("status") or "authorized")
766
+ if status not in ("authorized", "revoked"):
767
+ raise ContractError(f"unsupported account status: {status}")
768
+ account_id = f"{provider}:{email.lower()}"
769
+ queries.append(
770
+ query(
771
+ """MERGE (a:SourceAccount {id: $id})
772
+ SET a.email = $email,
773
+ a.provider = $provider,
774
+ a.status = $status,
775
+ a.updatedAt = datetime($recorded)
776
+ FOREACH (_ IN CASE WHEN $status = 'authorized' AND a.authorizedAt IS NULL THEN [1] ELSE [] END |
777
+ SET a.authorizedAt = datetime($recorded)
778
+ )
779
+ WITH a
780
+ MATCH (c:Connector {id: $provider})
781
+ MERGE (a)-[:ON]->(c)
782
+ WITH a
783
+ OPTIONAL MATCH (s:IngestSource {id: $sourceId})
784
+ FOREACH (_ IN CASE WHEN s IS NULL OR $sourceId = '' THEN [] ELSE [1] END |
785
+ MERGE (s)-[v:VIA_ACCOUNT]->(a)
786
+ SET v.recordedAt = datetime($recorded)
787
+ )
788
+ RETURN a.id""",
789
+ {
790
+ "id": account_id,
791
+ "email": email,
792
+ "provider": provider,
793
+ "status": status,
794
+ "recorded": recorded,
795
+ "sourceId": source_id,
796
+ },
797
+ )
798
+ )
799
+ return {
800
+ "schema": PLAN_SCHEMA,
801
+ "schema_family": "connector-lifecycle",
802
+ "manifest_id": f"connector:{provider}",
803
+ "source": {"kind": "connector", "id": provider},
804
+ "query_count": len(queries),
805
+ "queries": queries,
806
+ }
807
+
808
+
809
+ def build_plan(value: dict[str, Any], path: Path) -> dict[str, Any]:
810
+ if connector_manifest(value):
811
+ return connector_plan(value, path)
812
+ if corpus_manifest(value):
813
+ return corpus_plan(value, path)
814
+ return knowledge_plan(value, path)
815
+
816
+
817
+ def main() -> int:
818
+ parser = argparse.ArgumentParser(description=__doc__)
819
+ parser.add_argument("mode", choices=("validate", "plan"))
820
+ parser.add_argument("manifest")
821
+ args = parser.parse_args()
822
+ path = Path(args.manifest)
823
+ try:
824
+ value = json.loads(path.read_text(encoding="utf-8"))
825
+ if not isinstance(value, dict):
826
+ raise ContractError("manifest must be a JSON object")
827
+ plan = build_plan(value, path)
828
+ except (OSError, json.JSONDecodeError, ContractError, TypeError) as exc:
829
+ print(
830
+ json.dumps(
831
+ {
832
+ "valid": False,
833
+ "path": str(path),
834
+ "error": str(exc),
835
+ }
836
+ )
837
+ )
838
+ return 2
839
+ if args.mode == "validate":
840
+ print(
841
+ json.dumps(
842
+ {
843
+ "valid": True,
844
+ "path": str(path),
845
+ "manifest_id": plan["manifest_id"],
846
+ "schema_family": plan["schema_family"],
847
+ }
848
+ )
849
+ )
850
+ else:
851
+ print(json.dumps(plan, indent=2, sort_keys=True))
852
+ return 0
853
+
854
+
855
+ if __name__ == "__main__":
856
+ raise SystemExit(main())