create-egregore 0.19.7 → 0.19.11

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