codec-cortex 0.3.2__py3-none-any.whl

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 (81) hide show
  1. codec_cortex-0.3.2.dist-info/METADATA +183 -0
  2. codec_cortex-0.3.2.dist-info/RECORD +81 -0
  3. codec_cortex-0.3.2.dist-info/WHEEL +5 -0
  4. codec_cortex-0.3.2.dist-info/entry_points.txt +2 -0
  5. codec_cortex-0.3.2.dist-info/licenses/LICENSE +21 -0
  6. codec_cortex-0.3.2.dist-info/scm_file_list.json +124 -0
  7. codec_cortex-0.3.2.dist-info/scm_version.json +8 -0
  8. codec_cortex-0.3.2.dist-info/top_level.txt +1 -0
  9. cortex/__init__.py +22 -0
  10. cortex/__main__.py +7 -0
  11. cortex/_version.py +24 -0
  12. cortex/cli/__init__.py +5 -0
  13. cortex/cli/commands/__init__.py +150 -0
  14. cortex/cli/commands/add.py +89 -0
  15. cortex/cli/commands/compile.py +31 -0
  16. cortex/cli/commands/delete.py +44 -0
  17. cortex/cli/commands/diagram.py +130 -0
  18. cortex/cli/commands/diff.py +135 -0
  19. cortex/cli/commands/doctor.py +74 -0
  20. cortex/cli/commands/format.py +29 -0
  21. cortex/cli/commands/get.py +45 -0
  22. cortex/cli/commands/glossary.py +98 -0
  23. cortex/cli/commands/list.py +44 -0
  24. cortex/cli/commands/micro.py +79 -0
  25. cortex/cli/commands/move.py +45 -0
  26. cortex/cli/commands/new.py +80 -0
  27. cortex/cli/commands/recover.py +90 -0
  28. cortex/cli/commands/render.py +93 -0
  29. cortex/cli/commands/update.py +81 -0
  30. cortex/cli/commands/v2_canonicalize.py +162 -0
  31. cortex/cli/commands/v2_compare.py +74 -0
  32. cortex/cli/commands/v2_convert.py +183 -0
  33. cortex/cli/commands/v2_explain_loss.py +97 -0
  34. cortex/cli/commands/v2_inspect.py +113 -0
  35. cortex/cli/commands/v2_roundtrip.py +104 -0
  36. cortex/cli/commands/v2_roundtrip_bidir.py +128 -0
  37. cortex/cli/commands/v2_verify_view.py +65 -0
  38. cortex/cli/commands/verify.py +119 -0
  39. cortex/cli/main.py +636 -0
  40. cortex/core/__init__.py +85 -0
  41. cortex/core/ast.py +313 -0
  42. cortex/core/compare.py +180 -0
  43. cortex/core/document_kind.py +525 -0
  44. cortex/core/errors.py +399 -0
  45. cortex/core/lexer.py +267 -0
  46. cortex/core/parser.py +671 -0
  47. cortex/core/validator.py +268 -0
  48. cortex/core/writer.py +249 -0
  49. cortex/crud/__init__.py +17 -0
  50. cortex/crud/mutations.py +342 -0
  51. cortex/crud/selectors.py +95 -0
  52. cortex/crud/transactions.py +213 -0
  53. cortex/glossary/__init__.py +21 -0
  54. cortex/glossary/contracts.py +37 -0
  55. cortex/glossary/minimal.py +94 -0
  56. cortex/glossary/model.py +77 -0
  57. cortex/glossary/resolver.py +96 -0
  58. cortex/hcortex/__init__.py +35 -0
  59. cortex/hcortex/edit_parser.py +489 -0
  60. cortex/hcortex/edit_renderer.py +158 -0
  61. cortex/hcortex/markdown_model.py +81 -0
  62. cortex/hcortex/profiles.py +166 -0
  63. cortex/hcortex/read_renderer.py +342 -0
  64. cortex/hcortex/recovery.py +782 -0
  65. cortex/py.typed +0 -0
  66. cortex/templates/__init__.py +8 -0
  67. cortex/templates/brain.py +118 -0
  68. cortex/templates/minimal_glossary.py +42 -0
  69. cortex/templates/package.py +91 -0
  70. cortex/templates/skill.py +91 -0
  71. cortex/v2/__init__.py +30 -0
  72. cortex/v2/diagnostics.py +57 -0
  73. cortex/v2/encoder.py +1106 -0
  74. cortex/v2/equivalence.py +323 -0
  75. cortex/v2/hcortex_parser.py +615 -0
  76. cortex/v2/hcortex_renderer.py +450 -0
  77. cortex/v2/ir.py +223 -0
  78. cortex/v2/parser.py +655 -0
  79. cortex/v2/view.py +425 -0
  80. cortex/v2/view_renderer.py +474 -0
  81. cortex/v2/writer.py +301 -0
@@ -0,0 +1,782 @@
1
+ """Recovery / migration mode for legacy ``.cortex`` artefacts.
2
+
3
+ Closes audit gap H-06 / B-006:
4
+
5
+ - Tolerates preambles (SPDX headers, Markdown front-matter) before ``$0``.
6
+ - Accepts legacy glossary column ``Expansion`` as an alias for ``Type``.
7
+ - Accepts legacy type name ``contenido`` as an alias for ``cuerpo``.
8
+ - Accepts glossaries that omit the ``Layer`` column.
9
+ - When ``$0`` is missing entirely, reconstructs a minimal ``$0`` from
10
+ the sigils observed in the file and marks every reconstructed sigil
11
+ with an ``RSK`` diagnostic so the user can review before trusting.
12
+
13
+ The recovery flow is:
14
+
15
+ raw text
16
+
17
+
18
+ strip_preamble() → text without leading SPDX/markdown
19
+
20
+
21
+ parse_cortex() → AST (with reconstructed $0 if missing)
22
+
23
+
24
+ diagnose_ambiguities() → list of RSK/AUD diagnostics
25
+
26
+
27
+ write_cortex() → canonical, conformant .cortex output
28
+
29
+ Usage from the CLI:
30
+
31
+ cortex recover legacy.cortex --out legacy.fixed.cortex
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import re
37
+ from dataclasses import dataclass, field
38
+ from typing import List, Optional, Tuple
39
+
40
+ from ..core.ast import (
41
+ CortexDocument,
42
+ Glossary,
43
+ MicroDef,
44
+ SigilDef,
45
+ TypeDef,
46
+ )
47
+ from ..core.errors import (
48
+ CANONICAL_MICRO,
49
+ CANONICAL_TYPES,
50
+ CortexError,
51
+ E030_RECOVERY_INCOMPLETE,
52
+ )
53
+ from ..core.parser import parse_cortex
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Preamble stripping
58
+ # ---------------------------------------------------------------------------
59
+
60
+ # SPDX header lines: <!-- SPDX-FileCopyrightText: ... -->
61
+ _SPDX_RE = re.compile(r"^\s*<!--\s*SPDX-", re.IGNORECASE)
62
+ # Markdown front-matter: ---\n...\n---
63
+ _FRONTMATTER_DELIM = re.compile(r"^---\s*$")
64
+ # HTML comment opening: <!-- ...
65
+ _HTML_COMMENT_OPEN = re.compile(r"^\s*<!--")
66
+ _HTML_COMMENT_CLOSE = re.compile(r"-->\s*$")
67
+ # Markdown headings or horizontal rule before $0
68
+ _MD_HEADING_RE = re.compile(r"^#+\s")
69
+ _MD_HR_RE = re.compile(r"^(-{3,}|\*{3,}|_{3,})\s*$")
70
+
71
+
72
+ def strip_preamble(text: str) -> Tuple[str, List[str]]:
73
+ """Strip leading non-``.cortex`` content from ``text``.
74
+
75
+ Returns ``(clean_text, preamble_lines)`` where ``preamble_lines`` is
76
+ the list of lines that were removed (for traceability — the caller
77
+ may keep them as a comment in the rebuilt file).
78
+
79
+ Recognised preamble forms:
80
+ - SPDX comments (``<!-- SPDX-... -->``)
81
+ - Markdown front-matter (``---`` ... ``---``)
82
+ - HTML comments before the first section
83
+ - Markdown headings / horizontal rules
84
+ - Plain prose paragraphs before ``$0``
85
+ """
86
+
87
+ lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
88
+ preamble: List[str] = []
89
+ i = 0
90
+ n = len(lines)
91
+ # State for HTML comment / front-matter
92
+ in_html_comment = False
93
+ in_frontmatter = False
94
+
95
+ while i < n:
96
+ line = lines[i]
97
+ stripped = line.strip()
98
+
99
+ # Front-matter handling
100
+ if i == 0 and _FRONTMATTER_DELIM.match(stripped):
101
+ in_frontmatter = True
102
+ preamble.append(line)
103
+ i += 1
104
+ continue
105
+ if in_frontmatter:
106
+ preamble.append(line)
107
+ if _FRONTMATTER_DELIM.match(stripped):
108
+ in_frontmatter = False
109
+ i += 1
110
+ continue
111
+
112
+ # HTML comment block
113
+ if _HTML_COMMENT_OPEN.match(line) and not _HTML_COMMENT_CLOSE.search(line):
114
+ in_html_comment = True
115
+ preamble.append(line)
116
+ i += 1
117
+ continue
118
+ if in_html_comment:
119
+ preamble.append(line)
120
+ if _HTML_COMMENT_CLOSE.search(line):
121
+ in_html_comment = False
122
+ i += 1
123
+ continue
124
+
125
+ # SPDX single-line comment
126
+ if _SPDX_RE.match(line):
127
+ preamble.append(line)
128
+ i += 1
129
+ continue
130
+
131
+ # Empty line inside preamble
132
+ if not stripped:
133
+ preamble.append(line)
134
+ i += 1
135
+ continue
136
+
137
+ # Markdown heading or horizontal rule
138
+ if _MD_HEADING_RE.match(line) or _MD_HR_RE.match(line):
139
+ preamble.append(line)
140
+ i += 1
141
+ continue
142
+
143
+ # If we hit a section header ($N or $0), stop
144
+ if _is_section_header(stripped):
145
+ break
146
+
147
+ # If we hit an entry start (SIGIL:name{), stop
148
+ if _looks_like_entry_start(stripped):
149
+ break
150
+
151
+ # Otherwise: treat as prose preamble
152
+ preamble.append(line)
153
+ i += 1
154
+
155
+ clean = "\n".join(lines[i:])
156
+ return clean, preamble
157
+
158
+
159
+ def _is_section_header(stripped: str) -> bool:
160
+ if stripped.startswith("$"):
161
+ head = stripped[1:].split(":", 1)[0].split("·", 1)[0].strip()
162
+ return head.isdigit()
163
+ if stripped.startswith("#"):
164
+ inner = stripped.lstrip("#").strip().lstrip("-").strip()
165
+ if inner.startswith("$"):
166
+ head = inner[1:].split(":", 1)[0].split("·", 1)[0].strip()
167
+ return head.isdigit()
168
+ return False
169
+
170
+
171
+ def _looks_like_entry_start(stripped: str) -> bool:
172
+ return bool(re.match(r"^([A-Z][A-Z0-9_]*|!):[A-Za-z_][A-Za-z0-9_]*\s*\{", stripped))
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # Legacy glossary compatibility
177
+ # ---------------------------------------------------------------------------
178
+
179
+ # Legacy column-name aliases (audit gap B-006)
180
+ LEGACY_COLUMN_ALIASES = {
181
+ "expansion": "type", # legacy "Expansion" column → "Type"
182
+ "kind": "type",
183
+ "value": "type",
184
+ "cognitive_layer": "layer",
185
+ "cortex_layer": "layer",
186
+ "description": "description",
187
+ "desc": "description",
188
+ "comment": "description",
189
+ }
190
+
191
+ # Legacy type name aliases (audit gap B-006)
192
+ LEGACY_TYPE_ALIASES = {
193
+ "contenido": "cuerpo",
194
+ "body": "cuerpo",
195
+ "text": "cuerpo",
196
+ "code": "bloque",
197
+ "raw": "bloque",
198
+ "block": "bloque",
199
+ "positional": "attrs-pos",
200
+ "pos": "attrs-pos",
201
+ "relation": "relación",
202
+ "rel": "relación",
203
+ "relacion": "relación",
204
+ }
205
+
206
+
207
+ def normalise_legacy_type_name(name: str) -> str:
208
+ """Normalise a legacy type name to the canonical form."""
209
+
210
+ return LEGACY_TYPE_ALIASES.get(name.lower(), name)
211
+
212
+
213
+ # Legacy declaration regex that accepts EITHER the new 6-column form
214
+ # (Sigil | Name | Type | Risk | Layer | Description) or the legacy
215
+ # 5-column form (Sigil | Name | Expansion | Risk | Description).
216
+ _LEGACY_GLOSSARY_RE = re.compile(
217
+ r"""^\s*\#?\s*
218
+ (?P<sigil>[A-Z][A-Z0-9_]*|!)
219
+ \s*\|\s*
220
+ (?P<name>[A-Za-z_][A-Za-z0-9_]*)
221
+ \s*\|\s*
222
+ (?P<type>[A-Za-z\-]+)
223
+ \s*\|\s*
224
+ (?P<risk>[A-Z])
225
+ (?:\s*\|\s*(?P<layer>[A-Za-z/]+))?
226
+ (?:\s*\|\s*(?P<desc>.+?))?
227
+ \s*$
228
+ """,
229
+ re.VERBOSE,
230
+ )
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # Recovery result
235
+ # ---------------------------------------------------------------------------
236
+
237
+ @dataclass
238
+ class RecoveryResult:
239
+ """Outcome of :func:`recover_cortex`."""
240
+
241
+ doc: CortexDocument
242
+ preamble: List[str] = field(default_factory=list)
243
+ diagnostics: List[dict] = field(default_factory=list)
244
+ reconstructed_glossary: bool = False
245
+ source_text: str = ""
246
+
247
+ def to_dict(self) -> dict:
248
+ return {
249
+ "preamble_lines": len(self.preamble),
250
+ "preamble_preview": "\n".join(self.preamble[:10]),
251
+ "diagnostics": self.diagnostics,
252
+ "reconstructed_glossary": self.reconstructed_glossary,
253
+ "sections": [s.id for s in self.doc.sections],
254
+ "sigils": list(self.doc.glossary.sigils.keys()),
255
+ }
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # Main recovery entry point
260
+ # ---------------------------------------------------------------------------
261
+
262
+ def recover_cortex(
263
+ text: str,
264
+ path: str = "<recovery>",
265
+ strict: bool = False,
266
+ embed_aud_rsk: bool = False,
267
+ ) -> RecoveryResult:
268
+ """Recover a (possibly legacy) ``.cortex`` text into a conforming AST.
269
+
270
+ Steps:
271
+ 1. Strip preamble (SPDX, front-matter, HTML comments, prose).
272
+ 2. Try to parse the remaining text.
273
+ 3. If parsing fails because ``$0`` is missing, reconstruct a
274
+ minimal ``$0`` from the observed sigils.
275
+ 4. Normalise legacy column names and type aliases.
276
+ 5. Emit ``RSK`` diagnostics for every reconstructed sigil.
277
+ 6. If ``embed_aud_rsk=True``, insert ``AUD`` and ``RSK`` entries
278
+ into the recovered ``.cortex`` so the artefact itself carries
279
+ the recovery trace (re-audit M-RA-03).
280
+ 7. Return a :class:`RecoveryResult` with the rebuilt doc.
281
+ """
282
+
283
+ diagnostics: List[dict] = []
284
+ clean_text, preamble = strip_preamble(text)
285
+ if preamble:
286
+ diagnostics.append({
287
+ "code": "I001_PREAMBLE_STRIPPED",
288
+ "message": (
289
+ f"stripped {len(preamble)} preamble line(s) before $0; "
290
+ "preamble is preserved in the result but not interpreted"
291
+ ),
292
+ "severity": "info",
293
+ })
294
+
295
+ # Try normal parse first
296
+ doc: Optional[CortexDocument] = None
297
+ try:
298
+ doc = parse_cortex(clean_text, path=path)
299
+ # v1.1.3 P0-1: detect "empty glossary with observable entries" as
300
+ # equivalent to missing-glossary. The parser accepts files that
301
+ # start directly with entries by creating an implicit empty $0,
302
+ # so we must check post-parse whether $0 is actually populated.
303
+ observed_sigils = {e.sigil for _, e in doc.iter_entries()}
304
+ if not doc.glossary.sigils and observed_sigils:
305
+ diagnostics.append({
306
+ "code": E030_RECOVERY_INCOMPLETE,
307
+ "message": (
308
+ "file has $0 section but glossary is empty while "
309
+ f"{len(observed_sigils)} sigil(s) are used in entries; "
310
+ "attempting reconstruction — review before trusting as memory"
311
+ ),
312
+ "severity": "warning",
313
+ })
314
+ doc = _reconstruct_glossary(clean_text, path)
315
+ except CortexError as e:
316
+ # If it's a missing-glossary error, try to reconstruct $0
317
+ if e.code in ("E001_MISSING_GLOSSARY", "E002_GLOSSARY_NOT_FIRST"):
318
+ diagnostics.append({
319
+ "code": E030_RECOVERY_INCOMPLETE,
320
+ "message": (
321
+ "file is missing $0 glossary; attempting reconstruction "
322
+ "from observed sigils — review before trusting as memory"
323
+ ),
324
+ "severity": "warning",
325
+ })
326
+ doc = _reconstruct_glossary(clean_text, path)
327
+ else:
328
+ raise
329
+
330
+ # Normalise legacy type names in glossary
331
+ for sigil, sd in list(doc.glossary.sigils.items()):
332
+ new_type = normalise_legacy_type_name(sd.type)
333
+ if new_type != sd.type:
334
+ diagnostics.append({
335
+ "code": "I002_LEGACY_TYPE_ALIAS",
336
+ "message": (
337
+ f"sigil {sigil}: legacy type {sd.type!r} normalised to "
338
+ f"canonical {new_type!r}"
339
+ ),
340
+ "sigil": sigil,
341
+ "severity": "info",
342
+ })
343
+ sd.type = new_type
344
+
345
+ # Emit RSK for every reconstructed sigil
346
+ if doc.meta.get("reconstructed_glossary"):
347
+ for sigil in doc.meta.get("reconstructed_sigils", []):
348
+ diagnostics.append({
349
+ "code": "W010_RECONSTRUCTED_SIGIL",
350
+ "message": (
351
+ f"sigil {sigil!r} was reconstructed from usage; "
352
+ "verify its name/type/risk/layer before trusting"
353
+ ),
354
+ "sigil": sigil,
355
+ "severity": "warning",
356
+ })
357
+
358
+ # v1.1.6 P1-4: move operational entries out of $0 even if $0 already
359
+ # existed. The SKILL says $0 is structural metadata only; if a legacy
360
+ # file has operational entries mixed into $0, recovery must separate
361
+ # them into a proper operational section.
362
+ # v1.1.8 Fix 1: find a truly FREE section by scanning $1, $2, ... $99,
363
+ # $100, ... until we find one that doesn't exist. Never contaminate
364
+ # an existing section.
365
+ GLOSSARY_ENTRY_SIGILS = frozenset({"GSIG", "GTYP", "GMIC", "GCON"})
366
+ sec0 = doc.get_section("$0")
367
+ moved_live_entries: list = [] # v1.1.8 Fix 2: track for RSK embedding
368
+ if sec0 is not None:
369
+ ops_in_zero = [
370
+ e for e in sec0.entries
371
+ if e.sigil not in GLOSSARY_ENTRY_SIGILS
372
+ ]
373
+ if ops_in_zero:
374
+ # Move operational entries out of $0
375
+ sec0.entries = [
376
+ e for e in sec0.entries
377
+ if e.sigil in GLOSSARY_ENTRY_SIGILS
378
+ ]
379
+ # v1.1.8 Fix 1: find the first truly free section.
380
+ # Scan $1, $2, ..., $99, $100, ... until we find one that doesn't exist.
381
+ recovery_section_id = None
382
+ n = 1
383
+ while True:
384
+ candidate = f"${n}"
385
+ if doc.get_section(candidate) is None:
386
+ recovery_section_id = candidate
387
+ break
388
+ n += 1
389
+ recovery_sec = doc.get_or_create_section(
390
+ recovery_section_id, title="RECOVERED CONTENT"
391
+ )
392
+ for e in ops_in_zero:
393
+ e.section = recovery_section_id
394
+ recovery_sec.entries.append(e)
395
+ diagnostics.append({
396
+ "code": "I004_OPS_MOVED_FROM_ZERO",
397
+ "message": (
398
+ f"moved {len(ops_in_zero)} operational entr(y/ies) from $0 "
399
+ f"to {recovery_section_id}: RECOVERED CONTENT "
400
+ "($0 is structural metadata only)"
401
+ ),
402
+ "severity": "info",
403
+ })
404
+ # v1.1.7 P1-5: add RSK when FCS/OBJ/WRK/STP/NXT are moved from $0
405
+ moved_live = [
406
+ e for e in ops_in_zero
407
+ if e.sigil in ("FCS", "OBJ", "WRK", "STP", "NXT")
408
+ ]
409
+ if moved_live:
410
+ moved_live_entries = moved_live # save for embed
411
+ diagnostics.append({
412
+ "code": "W011_RECOVERED_LIVE_STATE",
413
+ "message": (
414
+ f"{len(moved_live)} live working-state entr(y/ies) "
415
+ f"({', '.join(e.sigil + ':' + e.name for e in moved_live)}) "
416
+ "recovered from $0 — verify operational validity before "
417
+ "trusting as active memory"
418
+ ),
419
+ "severity": "warning",
420
+ })
421
+
422
+ # v1.1.9: repair an existing but incomplete $0 by auto-declaring
423
+ # observed sigils and canonical types before validation/rendering.
424
+ _repair_incomplete_glossary(doc, diagnostics)
425
+
426
+ # Re-audit M-RA-03: optionally embed AUD/RSK entries in the artefact
427
+ # v1.1.8 Fix 2/3: pass recovery context so AUD describes the real event
428
+ # and RSK is embedded for W011_RECOVERED_LIVE_STATE.
429
+ if embed_aud_rsk and diagnostics:
430
+ recovery_context = {
431
+ "reconstructed_glossary": doc.meta.get("reconstructed_glossary", False),
432
+ "repaired_incomplete_glossary": doc.meta.get("repaired_incomplete_glossary", False),
433
+ "auto_declared_sigils": list(doc.meta.get("auto_declared_sigils", [])),
434
+ "auto_declared_types": list(doc.meta.get("auto_declared_types", [])),
435
+ "moved_live_entries": moved_live_entries,
436
+ }
437
+ _embed_recovery_trace(doc, diagnostics, preamble, recovery_context)
438
+
439
+ result = RecoveryResult(
440
+ doc=doc,
441
+ preamble=preamble,
442
+ diagnostics=diagnostics,
443
+ reconstructed_glossary=bool(doc.meta.get("reconstructed_glossary")),
444
+ source_text=text,
445
+ )
446
+ return result
447
+
448
+
449
+ def _canonical_sigil_lookup() -> dict:
450
+ """Build canonical sigil lookup with brain > skill > package priority."""
451
+ from ..glossary.minimal import brain_sigils, skill_sigils, package_sigils
452
+ canonical_lookup = {}
453
+ for source in (package_sigils(), skill_sigils(), brain_sigils()):
454
+ for sd in source:
455
+ canonical_lookup[sd.sigil] = sd
456
+ return canonical_lookup
457
+
458
+
459
+ def _ensure_canonical_types_and_micro(doc: CortexDocument, diagnostics: List[dict]) -> list:
460
+ """Ensure canonical types and micro-tokens exist in the glossary."""
461
+ from ..core.errors import CANONICAL_TYPES, CANONICAL_MICRO
462
+ from ..core.ast import TypeDef, MicroDef
463
+ added_types = []
464
+ for t in CANONICAL_TYPES:
465
+ if t not in doc.glossary.types:
466
+ doc.glossary.add_type(TypeDef(name=t, description="canonical type"))
467
+ added_types.append(t)
468
+ for tok, val in CANONICAL_MICRO.items():
469
+ if tok not in doc.glossary.micro:
470
+ doc.glossary.add_micro(MicroDef(token=tok, value=val))
471
+ return added_types
472
+
473
+
474
+ def _repair_incomplete_glossary(doc: CortexDocument, diagnostics: List[dict]) -> list:
475
+ """Auto-declare observed sigils missing from an existing $0.
476
+
477
+ v1.1.9: Missing local declarations are a recoverable $0 incompleteness
478
+ case, not a runtime permission to keep E003_UNKNOWN_SIGIL.
479
+ """
480
+
481
+ added_types = _ensure_canonical_types_and_micro(doc, diagnostics)
482
+ canonical_lookup = _canonical_sigil_lookup()
483
+
484
+ observed_sigils: list = []
485
+ seen: set = set()
486
+ for _sec, entry in doc.iter_entries():
487
+ if entry.sigil in {"GSIG", "GTYP", "GMIC", "GCON"}:
488
+ continue
489
+ if entry.sigil not in seen:
490
+ seen.add(entry.sigil)
491
+ observed_sigils.append(entry.sigil)
492
+
493
+ repaired: list = []
494
+ noncanonical: list = list(doc.meta.get("reconstructed_sigils", []))
495
+ for sig in observed_sigils:
496
+ if sig in doc.glossary.sigils:
497
+ continue
498
+ if sig in canonical_lookup:
499
+ doc.glossary.add_sigil(canonical_lookup[sig])
500
+ else:
501
+ from ..core.ast import SigilDef
502
+ doc.glossary.add_sigil(SigilDef(
503
+ sigil=sig,
504
+ name=sig.lower(),
505
+ type="attrs",
506
+ risk="M",
507
+ layer="Semantic",
508
+ description="auto-declared while repairing incomplete $0 (was: unknown)",
509
+ ))
510
+ if sig not in noncanonical:
511
+ noncanonical.append(sig)
512
+ repaired.append(sig)
513
+ diagnostics.append({
514
+ "code": "W012_INCOMPLETE_GLOSSARY_REPAIRED",
515
+ "message": (
516
+ f"sigil {sig!r} was used by entries but missing from $0; "
517
+ "auto-declared during recovery — verify local contract before trusting"
518
+ ),
519
+ "sigil": sig,
520
+ "severity": "warning",
521
+ })
522
+
523
+ if repaired or added_types:
524
+ doc.meta["repaired_incomplete_glossary"] = True
525
+ doc.meta["auto_declared_sigils"] = repaired
526
+ doc.meta["auto_declared_types"] = added_types
527
+ doc.meta["reconstructed_sigils"] = noncanonical
528
+
529
+ return repaired
530
+
531
+
532
+ def _first_free_recovery_section_id(doc: CortexDocument) -> str:
533
+ """Return a section id guaranteed not to exist in ``doc``.
534
+
535
+ v1.1.8 Fix 1 / v1.1.9 Fix 3: Scan until the first free positive integer
536
+ id; no artificial ceiling.
537
+ """
538
+
539
+ n = 1
540
+ while True:
541
+ candidate = f"${n}"
542
+ if doc.get_section(candidate) is None:
543
+ return candidate
544
+ n += 1
545
+
546
+
547
+ def _embed_recovery_trace(
548
+ doc: CortexDocument,
549
+ diagnostics: List[dict],
550
+ preamble: List[str],
551
+ recovery_context: Optional[dict] = None,
552
+ ) -> None:
553
+ """Insert AUD and RSK entries into ``doc`` carrying the recovery trace.
554
+
555
+ v1.1.8 Fix 2: embed RSK for W011_RECOVERED_LIVE_STATE (moved live state).
556
+ v1.1.8 Fix 3: AUD describes the real event, not always glossary_reconstruction.
557
+ v1.1.9: embed RSK/RSK for incomplete_glossary_repair.
558
+ """
559
+
560
+ from ..core.parser import build_entry_from_value
561
+ from ..core.ast import SigilDef
562
+
563
+ if recovery_context is None:
564
+ recovery_context = {}
565
+
566
+ # v1.1.2/v1.1.9: ensure AUD and RSK are declared in $0 before adding entries
567
+ canonical_lookup = _canonical_sigil_lookup()
568
+ for required in ("AUD", "RSK"):
569
+ if required not in doc.glossary.sigils:
570
+ sd = canonical_lookup.get(required) or SigilDef(
571
+ sigil=required,
572
+ name=required.lower(),
573
+ type="attrs",
574
+ risk="M",
575
+ layer="Prefrontal",
576
+ description=f"{required} entry (auto-declared by recovery)",
577
+ )
578
+ doc.glossary.add_sigil(sd)
579
+ diagnostics.append({
580
+ "code": "I003_SIGIL_AUTO_DECLARED",
581
+ "message": (
582
+ f"sigil {required!r} auto-declared in $0 to support "
583
+ f"embedded recovery trace"
584
+ ),
585
+ "sigil": required,
586
+ "severity": "info",
587
+ })
588
+
589
+ # v1.1.8 Fix 3: AUD describes the real event(s)
590
+ reconstructed = recovery_context.get("reconstructed_glossary", False)
591
+ repaired_incomplete = recovery_context.get("repaired_incomplete_glossary", False)
592
+ auto_declared = list(recovery_context.get("auto_declared_sigils", []))
593
+ auto_declared_types = list(recovery_context.get("auto_declared_types", []))
594
+ moved_live = recovery_context.get("moved_live_entries", [])
595
+
596
+ events = []
597
+ if reconstructed:
598
+ events.append("glossary_reconstruction")
599
+ if repaired_incomplete:
600
+ events.append("incomplete_glossary_repair")
601
+ if moved_live:
602
+ events.append("live_state_recovered_from_zero")
603
+ if not events:
604
+ events.append("recovery_processing")
605
+ event_str = "+".join(events)
606
+
607
+ result_parts = []
608
+ if reconstructed:
609
+ result_parts.append("reconstructed $0 from observed sigils")
610
+ if repaired_incomplete:
611
+ sigs = ", ".join(auto_declared) if auto_declared else "no missing sigils"
612
+ types = ", ".join(auto_declared_types) if auto_declared_types else "no missing types"
613
+ result_parts.append(f"repaired incomplete $0 by auto-declaring sigils: {sigs}; types: {types}")
614
+ if moved_live:
615
+ sigs = ", ".join(f"{e.sigil}:{e.name}" for e in moved_live)
616
+ result_parts.append(f"moved {len(moved_live)} live-state entr(y/ies) from $0 ({sigs})")
617
+ if not result_parts:
618
+ result_parts.append("recovery trace embedded")
619
+ result_str = "; ".join(result_parts)
620
+
621
+ # AUD entry
622
+ aud_sec = doc.get_or_create_section("$8", title="RECOVERY AUDIT")
623
+ aud_sec.entries.append(build_entry_from_value(
624
+ "$8", "AUD", "recovery", "attrs",
625
+ {
626
+ "event": event_str,
627
+ "evidence": f"{len(diagnostics)} diagnostic(s) emitted",
628
+ "result": result_str,
629
+ "date": "1970-01-01",
630
+ },
631
+ ))
632
+
633
+ # v1.1.4 P1-6: general RSK when $0 was reconstructed
634
+ if reconstructed:
635
+ rsk_sec = doc.get_or_create_section("$5", title="RECOVERY RISKS")
636
+ rsk_sec.entries.append(build_entry_from_value(
637
+ "$5", "RSK", "reconstructed_glossary", "attrs",
638
+ {
639
+ "risk": "$0 glossary was missing and reconstructed from observed sigils; canonical metadata may not match original intent",
640
+ "impact": "medium",
641
+ "mitigation": "review $0 declarations and verify each sigil's name/type/risk/layer before trusting as memory",
642
+ "status": "current",
643
+ "survive": "work",
644
+ },
645
+ ))
646
+ for sig in doc.meta.get("reconstructed_sigils", []):
647
+ rsk_sec.entries.append(build_entry_from_value(
648
+ "$5", "RSK", f"reconstructed_{sig.lower()}", "attrs",
649
+ {
650
+ "risk": f"sigil {sig!r} was reconstructed from usage without canonical metadata",
651
+ "impact": "medium",
652
+ "mitigation": "verify name/type/risk/layer before trusting as memory",
653
+ "status": "current",
654
+ "survive": "work",
655
+ },
656
+ ))
657
+
658
+ # v1.1.9: embed RSK when an existing but incomplete $0 was repaired
659
+ if repaired_incomplete:
660
+ rsk_sec = doc.get_or_create_section("$5", title="RECOVERY RISKS")
661
+ rsk_sec.entries.append(build_entry_from_value(
662
+ "$5", "RSK", "incomplete_glossary_repaired", "attrs",
663
+ {
664
+ "risk": "existing $0 glossary was incomplete and was auto-repaired from observed entry sigils",
665
+ "impact": "medium",
666
+ "mitigation": "review auto-declared sigils and confirm their name/type/risk/layer before trusting as memory",
667
+ "status": "current",
668
+ "survive": "work",
669
+ },
670
+ ))
671
+ for sig in auto_declared:
672
+ rsk_sec.entries.append(build_entry_from_value(
673
+ "$5", "RSK", f"auto_declared_{sig.lower()}", "attrs",
674
+ {
675
+ "risk": f"sigil {sig!r} was used by entries but missing from $0; recovery auto-declared it",
676
+ "impact": "medium",
677
+ "mitigation": f"verify {sig!r} local contract before trusting recovered content",
678
+ "status": "current",
679
+ "survive": "work",
680
+ },
681
+ ))
682
+
683
+ # v1.1.8 Fix 2: embed RSK for W011_RECOVERED_LIVE_STATE
684
+ if moved_live:
685
+ rsk_sec = doc.get_or_create_section("$5", title="RECOVERY RISKS")
686
+ for e in moved_live:
687
+ rsk_sec.entries.append(build_entry_from_value(
688
+ "$5", "RSK", f"recovered_live_{e.sigil.lower()}_{e.name.lower()}", "attrs",
689
+ {
690
+ "risk": f"{e.sigil}:{e.name} was recovered from $0 (structural metadata section) — operational validity unverified",
691
+ "impact": "high",
692
+ "mitigation": f"verify {e.sigil}:{e.name} represents valid operational state before trusting as active memory",
693
+ "status": "current",
694
+ "survive": "work",
695
+ },
696
+ ))
697
+
698
+
699
+ def _reconstruct_glossary(text: str, path: str) -> CortexDocument:
700
+ """Build a minimal ``$0`` from the sigils used in ``text``.
701
+
702
+ The reconstruction:
703
+ - Scans for entry starts (``SIGIL:name{...``) in the text.
704
+ - For each unique sigil, registers a SigilDef with reasonable
705
+ defaults: type=attrs, risk=M, layer=Semantic.
706
+ - If the sigil is canonical, uses the canonical metadata.
707
+ Re-audit M-RA-04: prefer ``brain_sigils()`` definitions over
708
+ ``package_sigils()`` for shared sigils (e.g. IDN) so that
709
+ ``IDN:agent`` doesn't inherit "Package identity" description.
710
+ - Builds a synthetic ``$0`` section with comment-form declarations.
711
+ - Re-parses the resulting text with the parser.
712
+ """
713
+
714
+ # Find all entry starts
715
+ sigils_seen: List[str] = []
716
+ seen_set = set()
717
+ for m in re.finditer(r"(?:^|\n)\s*([A-Z][A-Z0-9_]*|!):[A-Za-z_][A-Za-z0-9_]*\s*\{", text):
718
+ sig = m.group(1)
719
+ if sig not in seen_set:
720
+ seen_set.add(sig)
721
+ sigils_seen.append(sig)
722
+
723
+ # Build minimal glossary
724
+ # Re-audit M-RA-04: build canonical lookup with explicit priority
725
+ # (brain > skill > package) so shared sigils like IDN get the most
726
+ # generic (brain-flavoured) description rather than the package one.
727
+ from ..glossary.minimal import brain_sigils, skill_sigils, package_sigils
728
+ canonical_lookup = {}
729
+ # Insert in reverse priority so higher-priority overrides lower
730
+ for source in (package_sigils(), skill_sigils(), brain_sigils()):
731
+ for sd in source:
732
+ canonical_lookup[sd.sigil] = sd
733
+
734
+ g = Glossary()
735
+ for t in CANONICAL_TYPES:
736
+ g.add_type(TypeDef(name=t, description="canonical type"))
737
+ for tok, val in CANONICAL_MICRO.items():
738
+ g.add_micro(MicroDef(token=tok, value=val))
739
+ reconstructed_sigils: List[str] = []
740
+ for sig in sigils_seen:
741
+ if sig in canonical_lookup:
742
+ g.add_sigil(canonical_lookup[sig])
743
+ else:
744
+ g.add_sigil(SigilDef(
745
+ sigil=sig,
746
+ name=sig.lower(),
747
+ type="attrs",
748
+ risk="M",
749
+ layer="Semantic",
750
+ description="reconstructed from usage (was: unknown)",
751
+ ))
752
+ reconstructed_sigils.append(sig)
753
+
754
+ # Build a synthetic document: $0 (synthetic) + $1: RECOVERED CONTENT
755
+ # + the original text body.
756
+ # v1.1.5 P0-3: the original text must NOT stay inside $0. We insert
757
+ # a "$1: RECOVERED CONTENT" section header before the original
758
+ # entries so they land in $1 (operational) instead of $0 (structural).
759
+ # This ensures:
760
+ # - verify --strict passes (no E033_ZERO_SECTION_MEMORY_ENTRY)
761
+ # - HCORTEX audit shows the recovered entries (it omits $0 by design)
762
+ # - The recovery procedure is faithful to SKILL.md §4.1.3
763
+ from ..core.writer import serialize_glossary
764
+ glossary_text = serialize_glossary(g)
765
+ # Check if the original text already starts with a section header.
766
+ # If it does, we don't need to add $1 (the entries are already in a
767
+ # proper section). If it doesn't (entry-first), we add $1.
768
+ stripped_text = text.lstrip()
769
+ if stripped_text.startswith("$") or stripped_text.startswith("#"):
770
+ # Already has a section header; use as-is
771
+ synthetic = f"$0: RECOVERED GLOSSARY\n\n{glossary_text}\n\n{stripped_text}"
772
+ else:
773
+ # Entry-first: wrap original entries in $1: RECOVERED CONTENT
774
+ synthetic = (
775
+ f"$0: RECOVERED GLOSSARY\n\n{glossary_text}\n\n"
776
+ f"$1: RECOVERED CONTENT\n\n{stripped_text}"
777
+ )
778
+
779
+ doc = parse_cortex(synthetic, path=path)
780
+ doc.meta["reconstructed_glossary"] = True
781
+ doc.meta["reconstructed_sigils"] = reconstructed_sigils
782
+ return doc