@antoneeo/agentic-sdlc-skill 1.40.0 → 1.42.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.
@@ -125,6 +125,21 @@ def kb_unquote(value):
125
125
  return v
126
126
 
127
127
 
128
+ def frontmatter_date(meta):
129
+ """A note's or a sidecar's `date:` as `YYYY-MM-DD`, or None (F-073).
130
+ Quotes stripped, a datetime suffix tolerated, an impossible date refused --
131
+ the one parser behind orient's recency probe and any chronology reader."""
132
+ m = re.match(r"(\d{4}-\d{2}-\d{2})", kb_unquote((meta or {}).get("date")))
133
+ if not m:
134
+ return None
135
+ try:
136
+ import datetime as _dt
137
+ _dt.date.fromisoformat(m.group(1))
138
+ except ValueError:
139
+ return None
140
+ return m.group(1)
141
+
142
+
128
143
  def kb_parse_scope(text):
129
144
  """'-' | 'from X' | 'until X' | 'from X until Y' | 'if <cond>' ->
130
145
  ('always',) | ('window', from, until) | ('if', cond). Raises on junk."""
@@ -1945,14 +1960,13 @@ def kb_cmd_orient(argv):
1945
1960
  # it -- the file was already fully read, so the cap bought
1946
1961
  # only failure. load_frontmatter is the authoritative
1947
1962
  # reader (fence-bounded, so a column-0 date: in the body
1948
- # still cannot misdate the note); kb_unquote covers quoted
1949
- # dates; the prefix match tolerates a datetime suffix.
1963
+ # still cannot misdate the note); frontmatter_date covers
1964
+ # quoted dates and tolerates a datetime suffix.
1950
1965
  meta = sdlc_core.load_frontmatter(
1951
1966
  sdlc_core.read_text(p).splitlines()) or {}
1952
- m = re.match(r"(\d{4}-\d{2}-\d{2})",
1953
- kb_unquote(meta.get("date")))
1954
- if m:
1955
- stamp = _dt.date.fromisoformat(m.group(1))
1967
+ day = frontmatter_date(meta)
1968
+ if day:
1969
+ stamp = _dt.date.fromisoformat(day)
1956
1970
  except Exception:
1957
1971
  pass
1958
1972
  if stamp is None:
@@ -89,6 +89,14 @@ ARCHITECT_PASS_EPOCH = "2026-07-28"
89
89
  # row. Same grandfathering discipline as the pass above -- never nag work that
90
90
  # predates the rule.
91
91
  DESIGN_REVIEW_EPOCH = "2026-07-28"
92
+ # Chronology (F-073): an L3 ANALYSIS started, or an L3 HANDOFF updated, on/after
93
+ # this date is read for undated Diary text and a multi-step `next:`. Advisories,
94
+ # like the two gates above: a fixed epoch cannot protect a late upgrader, so a
95
+ # warning here would redden --strict CI on work written before the upgrade.
96
+ CHRONOLOGY_EPOCH = "2026-10-18"
97
+ NEXT_MAX_CHARS = 240 # this repository's longest `next:` was 214 at the epoch
98
+ ID_SCAN_MAX_REFS = 300 # A3 is skipped above it (one advisory says so, T9)
99
+ ID_SCAN_REFS_PER_CALL = 50 # keeps one git command line far below 32k chars
92
100
  def review_log_rel():
93
101
  return f"{docs_dir()}/audit/reviews/REVIEW_LOG.md"
94
102
  # Component Map 'Where' refs: a dotted token counts as a path only with one of
@@ -1101,6 +1109,298 @@ def section_body(text, aliases):
1101
1109
  return None
1102
1110
 
1103
1111
 
1112
+ def section_lines(text, aliases):
1113
+ """(heading_line, body) for the first matching `## ` section, or None: the
1114
+ body NOT stripped, so body line n is file line heading_line + n, and an
1115
+ indented first line keeps its indentation. The readers below use it; a
1116
+ stripped body would shift every line number and move ` - DATE` to column 0."""
1117
+ lines = (text or "").splitlines()
1118
+ for i, line in enumerate(lines):
1119
+ if any(line.startswith(a) for a in aliases):
1120
+ body = []
1121
+ for nxt in lines[i + 1:]:
1122
+ if nxt.startswith("## "):
1123
+ break
1124
+ body.append(nxt)
1125
+ return i + 1, "\n".join(body)
1126
+ return None
1127
+
1128
+
1129
+ # ---------------------------------------------------------------- chronology
1130
+ # One grammar for a dated entry (F-073), read by the validator and by any
1131
+ # consumer that lists events (F-072's chronology): both call these readers, so
1132
+ # they cannot disagree on what an event is. The readers return openers only --
1133
+ # date, label, line -- and format nothing for display.
1134
+
1135
+ DIARY_SECTION = next(s for s in ANALYSIS_SECTIONS if s[0] == "## Diary")
1136
+ _ISO = r"\d{4}-\d{2}-\d{2}"
1137
+ # At column 0: optional list marker, optional `###`/`####`, optional bold, then
1138
+ # the ISO date and a word boundary. The date FIRST is the rule; markup is
1139
+ # tolerated so the dated forms already written stay readable with no migration.
1140
+ ENTRY_OPENER = re.compile(r"^(?:([-*]) |(#{3,4}) )?(\*\*)?(" + _ISO + r")\b(.*)$")
1141
+ _LABEL_SEP = re.compile(r"^(?:\*\*)?\s*(?:[—–:-])?\s*")
1142
+ _REQUEST_HEADING = re.compile(r"^### (R\d+) [—–-] (" + _ISO + r") [—–-] (.+)$")
1143
+ _ADR_BODY_DATE = re.compile(r"^\**Date:\**\s*(" + _ISO + r")\b", re.M)
1144
+ _ADR_NAME_DATE = re.compile(r"^ADR_(" + _ISO + r")_", re.I)
1145
+ UNIT_ID_RE = re.compile(r"\b([A-Z]-\d{3,})\b")
1146
+
1147
+
1148
+ def _entry_label(bold, rest):
1149
+ rest = _LABEL_SEP.sub("", rest, count=1)
1150
+ if bold and "**" in rest:
1151
+ rest = rest.split("**", 1)[0]
1152
+ else:
1153
+ rest = re.split(r"(?<=[.!?])\s", rest, maxsplit=1)[0]
1154
+ return rest.strip(" .:—–-*")[:120]
1155
+
1156
+
1157
+ def _opener_at(lines, i):
1158
+ """The match when lines[i] opens an entry. It must also START A BLOCK -- the
1159
+ line above is blank, absent or a heading, or the line is itself a list item
1160
+ or a heading -- so a hard-wrapped prose line that happens to begin with a
1161
+ date is never an event. (A separator after the date is NOT required: real
1162
+ openers read `DATE (qualifier)` and `DATE word`.)"""
1163
+ m = ENTRY_OPENER.match(lines[i])
1164
+ if not m:
1165
+ return None
1166
+ prev = lines[i - 1] if i else ""
1167
+ if not prev.strip() or prev.startswith("#") or m.group(1) or m.group(2):
1168
+ return m
1169
+ return None
1170
+
1171
+
1172
+ def entry_openers(body):
1173
+ """[(date, label, line)] -- one per dated entry opener; line is 1-based
1174
+ within `body`. An undated paragraph belongs to the entry above it. Comments
1175
+ are blanked first, exactly as `undated_diary_lines` does, so an entry
1176
+ written under the template's comment is an event to both."""
1177
+ lines = _blank_comments(body).splitlines()
1178
+ out = []
1179
+ for i in range(len(lines)):
1180
+ m = _opener_at(lines, i)
1181
+ if m:
1182
+ out.append((m.group(4), _entry_label(m.group(3), m.group(5)), i + 1))
1183
+ return out
1184
+
1185
+
1186
+ def _blank_comments(text):
1187
+ # comments become blank lines, so line numbers still point at the file's lines
1188
+ return re.sub(r"<!--.*?-->", lambda m: "\n" * m.group(0).count("\n"), text or "",
1189
+ flags=re.S)
1190
+
1191
+
1192
+ def undated_diary_lines(body):
1193
+ """[(line, text)] a Diary body cannot date: text before the first opener,
1194
+ and any `### ` heading that is not an opener. `####` and deeper headings are
1195
+ sub-structure of an entry and stay legal. Comments are ignored."""
1196
+ lines = _blank_comments(body).splitlines()
1197
+ out, seen_opener = [], False
1198
+ for i, line in enumerate(lines):
1199
+ if _opener_at(lines, i):
1200
+ seen_opener = True
1201
+ elif line.startswith("### "):
1202
+ out.append((i + 1, line.strip()))
1203
+ elif not seen_opener and line.strip():
1204
+ out.append((i + 1, line.strip()))
1205
+ return out
1206
+
1207
+
1208
+ def request_entries(text):
1209
+ """(entries, unparsed) for a request record: entries are
1210
+ [(rid, date, label, line)]; a `### ` heading that does not match
1211
+ `### R<n> — YYYY-MM-DD — <label>` is returned in unparsed as (line, text),
1212
+ never dropped."""
1213
+ entries, unparsed = [], []
1214
+ for i, line in enumerate((text or "").splitlines()):
1215
+ if not line.startswith("### "):
1216
+ continue
1217
+ m = _REQUEST_HEADING.match(line.rstrip())
1218
+ if m:
1219
+ entries.append((m.group(1), m.group(2), m.group(3).strip(), i + 1))
1220
+ else:
1221
+ unparsed.append((i + 1, line.rstrip()))
1222
+ return entries, unparsed
1223
+
1224
+
1225
+ def adr_date(name, text):
1226
+ """An ADR's date: `**Date:** YYYY-MM-DD` in the body, else the
1227
+ `ADR_YYYY-MM-DD_*` file name, else None."""
1228
+ m = _ADR_BODY_DATE.search(text or "") or _ADR_NAME_DATE.match(name or "")
1229
+ return m.group(1) if m else None
1230
+
1231
+
1232
+ def _meta_date(meta, key):
1233
+ return parse_iso(str(meta.get(key) or "").strip().strip("'\"")[:10])
1234
+
1235
+
1236
+ def chronology_advisories(root):
1237
+ """A1 (undated Diary text) and A2 (`next:` is not one step), as advisory
1238
+ strings. Gated on `level: L3` and on a date the file carries -- never an
1239
+ mtime -- so L1/L2 work and anything written before the epoch see nothing."""
1240
+ epoch = parse_iso(CHRONOLOGY_EPOCH)
1241
+ out = []
1242
+ for p, meta, text in list_analyses(root):
1243
+ started = _meta_date(meta, "start_date")
1244
+ if str(meta.get("level", "")).upper() != "L3" or not started or started < epoch:
1245
+ continue
1246
+ section = section_lines(text, DIARY_SECTION)
1247
+ if section is None:
1248
+ continue
1249
+ head, body = section
1250
+ rel = "solutions/" + p.name
1251
+ lines = undated_diary_lines(body)
1252
+ preface = [(n, t) for n, t in lines if not t.startswith("### ")]
1253
+ if preface:
1254
+ out.append(f"{rel}: line {head + preface[0][0]}: Diary text '{preface[0][1][:60]}' "
1255
+ "comes before the first dated entry -- open entries with "
1256
+ "**YYYY-MM-DD — label.**, and write the current state as the latest "
1257
+ "dated entry")
1258
+ for n, t in lines:
1259
+ if t.startswith("### "):
1260
+ out.append(f"{rel}: line {head + n}: Diary heading '{t[:60]}' is not a dated "
1261
+ "entry -- open entries with **YYYY-MM-DD — label.**")
1262
+ for p, meta in list_workstreams(root):
1263
+ updated = _meta_date(meta, "updated")
1264
+ if str(meta.get("level", "")).upper() != "L3" or not updated or updated < epoch:
1265
+ continue
1266
+ nxt = str(meta.get("next") or "").strip()
1267
+ if len(nxt) > NEXT_MAX_CHARS:
1268
+ out.append(f"audit/{p.name}: next: is {len(nxt)} chars (> {NEXT_MAX_CHARS}): one "
1269
+ "next step here, the state goes to the Diary as a dated entry")
1270
+ return out
1271
+
1272
+
1273
+ def _open_units(root):
1274
+ """{id: {analysis name}} for the tree's open L3 units: an L3 ANALYSIS that is
1275
+ PLANNED or IN_PROGRESS, or a request record (L3 only, r26) whose `analysis:`
1276
+ is absent from the tree or names such an open ANALYSIS. A closed unit's
1277
+ record, kept forever, does not count."""
1278
+ units, present, open_names = {}, set(), set()
1279
+ for p, meta, _ in list_analyses(root):
1280
+ present.add(p.name)
1281
+ if (str(meta.get("level", "")).upper() == "L3"
1282
+ and meta.get("status") in ("PLANNED", "IN_PROGRESS")):
1283
+ m = UNIT_ID_RE.search(str(meta.get("id") or ""))
1284
+ if m:
1285
+ open_names.add(p.name)
1286
+ units.setdefault(m.group(1), set()).add(p.name)
1287
+ req = ai_path(root, "audit", "requests")
1288
+ if req.is_dir():
1289
+ for p in sorted(req.glob("REQUEST_*.md")):
1290
+ meta = load_frontmatter(read_text(p).splitlines())
1291
+ name = str(meta.get("analysis") or "").strip().strip("'\"`")
1292
+ m = UNIT_ID_RE.search(str(meta.get("unit") or meta.get("id") or ""))
1293
+ if m and name and (name not in present or name in open_names):
1294
+ units.setdefault(m.group(1), set()).add(name)
1295
+ return units
1296
+
1297
+
1298
+ def _git_out(root, *args, timeout=30):
1299
+ r = subprocess.run(["git", *args], cwd=str(root), capture_output=True, text=True,
1300
+ encoding="utf-8", errors="replace", timeout=timeout)
1301
+ return r.stdout if r.returncode == 0 else None
1302
+
1303
+
1304
+ def ids_on_other_refs(root, max_refs=ID_SCAN_MAX_REFS):
1305
+ """[(id, mine, theirs, [refs])] -- an open unit's id that another ref (heads,
1306
+ remotes, tags) uses for a DIFFERENT analysis. Identity is the ANALYSIS file
1307
+ name, declared by the ANALYSIS or announced by a request record's
1308
+ `analysis:`. One `git grep` over local refs, fixed arguments only (T8);
1309
+ as fresh as the last fetch. [] with no open L3 unit or no git; None when
1310
+ skipped -- above `max_refs` refs, or git failed or timed out -- so the
1311
+ caller can say the check did not run instead of reporting it clean."""
1312
+ mine = _open_units(root)
1313
+ if not mine or not git_available(root):
1314
+ return []
1315
+ try:
1316
+ head = (_git_out(root, "rev-parse", "HEAD") or "").strip()
1317
+ listing = _git_out(root, "for-each-ref",
1318
+ "--format=%(objectname) %(objecttype) %(*objecttype) %(refname)",
1319
+ "refs/heads", "refs/remotes", "refs/tags")
1320
+ if listing is None:
1321
+ return None
1322
+ refs = []
1323
+ for line in listing.split("\n"):
1324
+ parts = line.split(" ")
1325
+ if len(parts) == 3: # %(*objecttype) empty: not a tag
1326
+ parts.insert(2, "")
1327
+ if len(parts) != 4:
1328
+ continue
1329
+ sha, kind, peeled, ref = parts
1330
+ # only refs that resolve to a commit can be grepped (a tag on a blob
1331
+ # would fail the whole batch); the tree's own commit is not "another
1332
+ # ref"; symbolic HEADs repeat a branch
1333
+ if "commit" not in (kind, peeled) or ref.endswith("/HEAD") or sha == head:
1334
+ continue
1335
+ refs.append(ref)
1336
+ if not refs:
1337
+ return []
1338
+ if len(refs) > max_refs:
1339
+ return None
1340
+ # Pathspecs are relative to cwd=root, so a docs root inside a monorepo
1341
+ # subdirectory is found. -z keeps a path with ':' or non-ASCII intact
1342
+ # (quotePath off); -n plus the fence lines confine hits to frontmatter.
1343
+ docs = docs_dir()
1344
+ chunks = []
1345
+ for i in range(0, len(refs), ID_SCAN_REFS_PER_CALL): # command-line length
1346
+ r = subprocess.run(
1347
+ ["git", "-c", "core.quotePath=false", "grep", "-z", "-n", "-I", "-E",
1348
+ r"^(id|unit|analysis):|^---\s*$", *refs[i:i + ID_SCAN_REFS_PER_CALL],
1349
+ "--", f"{docs}/solutions/ANALYSIS_*.md", f"{docs}/audit/requests/REQUEST_*.md"],
1350
+ cwd=str(root), capture_output=True, text=True, encoding="utf-8",
1351
+ errors="replace", timeout=60)
1352
+ if r.returncode not in (0, 1): # 1 = no match
1353
+ return None
1354
+ chunks.append(r.stdout)
1355
+ except Exception:
1356
+ return None
1357
+ per = {}
1358
+ for line in "".join(chunks).split("\n"): # not splitlines: \x1c.. are content
1359
+ parts = line.split("\0")
1360
+ if len(parts) != 3 or ":" not in parts[0] or not parts[1].isdigit():
1361
+ continue
1362
+ ref, path = parts[0].split(":", 1) # a ref name cannot hold ':'
1363
+ content = parts[2].rstrip("\r")
1364
+ st = per.setdefault((ref, path), {"fences": 0, "kv": {}})
1365
+ if int(parts[1]) == 1 and content.strip() == "---":
1366
+ st["fences"] = 1 # frontmatter opens on line 1
1367
+ elif st["fences"] == 1 and content.strip() == "---":
1368
+ st["fences"] = 2 # ...and closes here
1369
+ elif st["fences"] == 1:
1370
+ key, _, val = content.partition(":")
1371
+ st["kv"].setdefault(key, val.strip())
1372
+ found = {}
1373
+ for (ref, path), st in per.items():
1374
+ kv = st["kv"]
1375
+ fname = path.replace("\\", "/").rsplit("/", 1)[-1]
1376
+ name = fname if fname.startswith("ANALYSIS_") else kv.get("analysis", "").strip("'\"`")
1377
+ m = UNIT_ID_RE.search(kv.get("id") or kv.get("unit") or "")
1378
+ if not (m and name) or m.group(1) not in mine or name in mine[m.group(1)]:
1379
+ continue
1380
+ short = re.sub(r"^refs/(heads|remotes|tags)/", "", ref)
1381
+ found.setdefault((m.group(1), name), [])
1382
+ if short not in found[(m.group(1), name)]:
1383
+ found[(m.group(1), name)].append(short)
1384
+ return sorted((fid, ", ".join(sorted(mine[fid])), theirs, sorted(refs_))
1385
+ for (fid, theirs), refs_ in found.items())
1386
+
1387
+
1388
+ def id_advisories(root):
1389
+ """A3 as advisory strings, for every entry point's `check`."""
1390
+ hits = ids_on_other_refs(root)
1391
+ if hits is None:
1392
+ return [f"id check skipped (more than {ID_SCAN_MAX_REFS} refs, or git grep failed "
1393
+ "or timed out) -- choose a new unit's id after fetching, as the first id "
1394
+ "free on every ref"]
1395
+ out = []
1396
+ for fid, mine, theirs, refs in hits:
1397
+ more = f" (+{len(refs) - 1} more)" if len(refs) > 1 else ""
1398
+ out.append(f"id {fid} ({mine}) is also used by {theirs} on {refs[0]}{more}: choose "
1399
+ "the first id free on every ref -- if this is the same unit under its "
1400
+ "old name, ignore it")
1401
+ return out
1402
+
1403
+
1104
1404
  def design_review_due(meta):
1105
1405
  """True when an L3 ANALYSIS owes a design-review row (review.md moment 1):
1106
1406
  implementation has started or finished, and it began on/after the gate
@@ -1556,6 +1856,7 @@ def cmd_validate(root, strict=False, hybrid=False):
1556
1856
  except ValueError:
1557
1857
  warnings.append("audit/handoff.md: date not parseable")
1558
1858
 
1859
+ advisories.extend(chronology_advisories(root)) # A1, A2 (F-073)
1559
1860
  for a in advisories:
1560
1861
  print(f"[note] {a}")
1561
1862
  for w in warnings:
@@ -1925,6 +2226,10 @@ def cmd_check(root, strict=False, hybrid=False, extra_validate=None, extra_secti
1925
2226
  rc_v = cmd_validate(root, strict=strict, hybrid=hybrid)
1926
2227
  if extra_validate is not None:
1927
2228
  rc_v = max(rc_v, extra_validate())
2229
+ # A3 (F-073): check only -- it reads git refs, and validate stays file-local.
2230
+ # Advisory: printed, never part of any rc.
2231
+ for a in id_advisories(root):
2232
+ print(f"[note] {a}")
1928
2233
  print("\n===== stale =====")
1929
2234
  rc_s = cmd_stale(root, hybrid=hybrid)
1930
2235
  rcs = [("validate", rc_v), ("stale", rc_s)]
@@ -216,7 +216,7 @@ Only for features spanning multiple ANALYSIS documents or multiple milestones: o
216
216
 
217
217
  The frontmatter is the source of truth for the feature state (the `features_history.md` index is generated from it).
218
218
 
219
- `domain:` and `checks:` are optional for ordinary analyses and only earn their place in a project where more than one lens is installed; a course ANALYSIS always declares `domain: course`. `checks:` names **portable checks** imported from another domain — e.g. `domain: knowledge` with `checks: [marketing.funnel]`. An imported check can only ADD findings, never relax what the owning domain requires, so importing one is always safe; naming a check this installation does not carry produces a visible warning, never a silent pass. `id:` is unique **within a domain**, and its prefix says which: `F-` code, `K-` knowledge, `M-` marketing, `C-` course. Projects that predate the prefixes keep their `F-` ids — uniqueness was already scoped to the one domain they have.
219
+ `domain:` and `checks:` are optional for ordinary analyses and only earn their place in a project where more than one lens is installed; a course ANALYSIS always declares `domain: course`. `checks:` names **portable checks** imported from another domain — e.g. `domain: knowledge` with `checks: [marketing.funnel]`. An imported check can only ADD findings, never relax what the owning domain requires, so importing one is always safe; naming a check this installation does not carry produces a visible warning, never a silent pass. `id:` is unique **within a domain**, and its prefix says which: `F-` code, `K-` knowledge, `M-` marketing, `C-` course. Projects that predate the prefixes keep their `F-` ids — uniqueness was already scoped to the one domain they have. Choose a new unit's id **after fetching**, as the first id free on every branch and tag the checkout can see: `check` names an open L3 unit whose id another ref uses for a different analysis.
220
220
 
221
221
  ```markdown
222
222
  ---
@@ -367,8 +367,13 @@ checks: [marketing.funnel] # optional — extra portable checks to run on t
367
367
  explicit alternative verification and reason. -->
368
368
 
369
369
  ## Diary / Current State
370
- <!-- updated at every milestone: where I am, last problem, next step.
371
- It is the handoff source for this feature. -->
370
+ <!-- One dated entry per event, newest last. Open every entry with its date, once:
371
+ **YYYY-MM-DD — label.** body
372
+ The latest entry IS the current state (where I am, last problem, next step),
373
+ dated the day it was written. An undated paragraph is read as part of the
374
+ dated entry above it, so a new event never starts undated; `####`
375
+ sub-headings inside an entry are fine. It is the handoff source for this
376
+ feature. -->
372
377
  ```
373
378
 
374
379
  Allowed frontmatter states: `PLANNED` | `IN_PROGRESS` | `COMPLETED` | `CANCELLED`. `COMPLETED` requires `end_date`. (The validator also accepts the deprecated Italian keys `stato`/`livello`/`data_inizio`/`data_fine` in existing projects.)
@@ -382,6 +387,7 @@ Allowed frontmatter states: `PLANNED` | `IN_PROGRESS` | `COMPLETED` | `CANCELLED
382
387
  ## Time-box
383
388
  ## What was tried
384
389
  ## Answer / Outcome
390
+ <!-- open with **YYYY-MM-DD — label.**, dated the day the answer was reached -->
385
391
  ## Consequences
386
392
  <!-- max 1 page. Spike code is NOT mergeable: for production reclassify L2/L3. -->
387
393
  ```
@@ -732,6 +738,12 @@ and why two concurrent writers no longer collide on it. `details:` holds the *ot
732
738
  pointers (the ANALYSIS, a review log entry); the generator prepends this file's own
733
739
  name, so nothing points at itself by hand.
734
740
 
741
+ `next:` is **the next concrete step, in one sentence** (at most 240 characters;
742
+ `validate` notes a longer one on an L3 file updated under the rule). What happened,
743
+ and the state of the work, go to the Diary as a dated entry: the registry is what
744
+ `orient` shows at every session start, cut at a fixed size, so a paragraph here
745
+ costs the next agent the rows below it.
746
+
735
747
  ## ai_docs/audit/project_notes.md — the registry's project-wide notes (source)
736
748
 
737
749
  Plain lines, no frontmatter, appended verbatim to the generated registry under
@@ -739,6 +751,10 @@ Plain lines, no frontmatter, appended verbatim to the generated registry under
739
751
  everyone rather than for one workstream. It exists so that generating the registry
740
752
  cannot destroy notes that belong to no workstream.
741
753
 
754
+ A note that records an **event** (a release, a migration, an incident) opens with its
755
+ date — `**YYYY-MM-DD — label.**` — so it is read as one; a **standing** note (an
756
+ environment quirk) stays undated, and is not an event.
757
+
742
758
  ## ai_docs/strategic/architecture.md and existing_features.md
743
759
 
744
760
  Canonical docs: they open with the header (`description:`/`status:`) so they enter the `INDEX.md` manifest cleanly.