@caddis/cli 0.4.12 → 0.4.14
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.
- package/bundles/antigravity-plugin/guard_agy.py +62 -13
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +146 -0
- package/bundles/antigravity-plugin/scripts/caddis-run.ps1 +11 -0
- package/bundles/antigravity-plugin/scripts/caddis-run.sh +9 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +24 -3
- package/bundles/antigravity-plugin/scripts/caddis_governor.py +542 -0
- package/bundles/antigravity-plugin/scripts/caddis_host_guard.py +689 -0
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +7 -3
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +1632 -0
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +159 -14
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +59 -7
- package/bundles/antigravity-plugin/scripts/install-host-guard.ps1 +393 -0
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +30 -4
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +48 -1
- package/bundles/antigravity-plugin/skills/kb/SKILL.md +4 -0
- package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +89 -16
- package/bundles/antigravity-plugin/warm_start_agy.py +38 -4
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -104,6 +104,9 @@ class DocFindings:
|
|
|
104
104
|
stale_prone: list[tuple[int, str]] = field(default_factory=list)
|
|
105
105
|
historical: list[tuple[int, str]] = field(default_factory=list)
|
|
106
106
|
missing_paths: list[str] = field(default_factory=list)
|
|
107
|
+
# Bare filenames that match NO file in this repo. Advisory: in one real handover every such
|
|
108
|
+
# name was a file in another repository, which no local check could ever satisfy.
|
|
109
|
+
unresolved_names: list[str] = field(default_factory=list)
|
|
107
110
|
# A THIRD refusing tier, kept separate from stale_prone so the message can say something
|
|
108
111
|
# different. A stale hash is an accident the successor will notice; a stored answer key
|
|
109
112
|
# defeats the exercise SILENTLY — it certifies a reader who understood nothing.
|
|
@@ -139,6 +142,18 @@ _INLINE_ANSWER = re.compile(r"\bQ\s*\d{1,2}\b.*?\b(?:A|Ans|Answer)\s*[:.\)]", re
|
|
|
139
142
|
|
|
140
143
|
_DOC_PATH = re.compile(r"`([^`\s<>{}*?]+?\.(?:md|py|ts|tsx|json|ya?ml|sh|ps1|html|toml|sql))`")
|
|
141
144
|
|
|
145
|
+
# A count QUOTED FROM an earlier document ("a previous handover said '1,066 tests'") is a citation,
|
|
146
|
+
# not a claim about now — found 2026-09-10, when the sentence warning the reader not to quote
|
|
147
|
+
# counts had to lose its example to pass. Quotation marks alone are NOT enough: `status: "312
|
|
148
|
+
# tests passed"` is a current claim in quotes (review catch). So three things must hold: the count
|
|
149
|
+
# sits inside a quoted span, a reporting verb comes BEFORE that span, and the line makes no
|
|
150
|
+
# now-claim. A single quote only opens a span at a word boundary, so "it's" cannot start one.
|
|
151
|
+
_QUOTED = re.compile(r"(?<!\w)'[^'\n]*'(?!\w)|\"[^\"\n]*\"|“[^”\n]*”|‘[^’\n]*’")
|
|
152
|
+
_REPORTED = re.compile(r"\b(?:said|says|claimed|claims|wrote|reported|quoted|stated)\b", re.I)
|
|
153
|
+
_NOW_CLAIM = re.compile(
|
|
154
|
+
r"\b(?:now|still|currently|current|today|at present|as of|remains?|unchanged|holds|stands"
|
|
155
|
+
r"|nothing has changed)\b", re.I)
|
|
156
|
+
|
|
142
157
|
|
|
143
158
|
def check_document(text: str, repo_root: Path | None = None) -> DocFindings:
|
|
144
159
|
"""Round 0: the parent validates its OWN handover before anyone reads it.
|
|
@@ -185,17 +200,42 @@ def check_document(text: str, repo_root: Path | None = None) -> DocFindings:
|
|
|
185
200
|
out.historical.append((n, f"commit hash `{m.group(0)}` — reads as a historical "
|
|
186
201
|
"citation, which is durable. Check that it is not "
|
|
187
202
|
"standing in for current state"))
|
|
203
|
+
cited = [] if _NOW_CLAIM.search(line) else [
|
|
204
|
+
(q.start(), q.end()) for q in _QUOTED.finditer(line)
|
|
205
|
+
if _REPORTED.search(line[:q.start()])]
|
|
188
206
|
for m in _COUNT.finditer(line):
|
|
207
|
+
if any(a <= m.start() and m.end() <= b for a, b in cited):
|
|
208
|
+
out.historical.append((n, f"`{m.group(0)}` quoted from an earlier document — "
|
|
209
|
+
"reads as a citation. Check it is not standing in for "
|
|
210
|
+
"current state"))
|
|
211
|
+
continue
|
|
189
212
|
out.stale_prone.append((n, f"`{m.group(0)}` stated as a fact — write the command "
|
|
190
213
|
"instead; this was wrong within the hour, twice"))
|
|
191
214
|
if repo_root is not None:
|
|
215
|
+
tracked: list[str] | None = None
|
|
192
216
|
for raw in sorted(set(_DOC_PATH.findall(text))):
|
|
193
217
|
cand = next((tok for tok in raw.split() if "/" in tok or tok.endswith(".md")), raw)
|
|
194
218
|
cand = cand.strip("(),;:'\"")
|
|
195
219
|
if cand.startswith(("http://", "https://", "~", "$")) or ".." in cand:
|
|
196
220
|
continue
|
|
197
|
-
if
|
|
221
|
+
if (repo_root / cand).exists():
|
|
222
|
+
continue
|
|
223
|
+
if "/" in cand or "\\" in cand:
|
|
198
224
|
out.missing_paths.append(cand)
|
|
225
|
+
continue
|
|
226
|
+
# A BARE filename. Block only when it names a file this repo has — that is the catch
|
|
227
|
+
# worth keeping (`deploy.ps1` named the canonical copy when the task was about another
|
|
228
|
+
# file). A name found nowhere is far more likely a file in another repository.
|
|
229
|
+
if tracked is None:
|
|
230
|
+
tracked = _run(["git", "ls-files"], repo_root).splitlines()
|
|
231
|
+
hits = [t for t in tracked if t.rsplit("/", 1)[-1] == cand]
|
|
232
|
+
if not hits:
|
|
233
|
+
out.unresolved_names.append(cand)
|
|
234
|
+
elif len(hits) == 1:
|
|
235
|
+
out.missing_paths.append(f"{cand} — did you mean `{hits[0]}`? Name the full path")
|
|
236
|
+
else:
|
|
237
|
+
out.missing_paths.append(f"{cand} — matches {len(hits)} files ("
|
|
238
|
+
+ ", ".join(hits[:3]) + "). Name the full path")
|
|
199
239
|
return out
|
|
200
240
|
|
|
201
241
|
|
|
@@ -256,6 +296,29 @@ class Preflight:
|
|
|
256
296
|
return not self.refusals
|
|
257
297
|
|
|
258
298
|
|
|
299
|
+
def in_flight_branches(repo_root: Path) -> list[str]:
|
|
300
|
+
"""Local branches holding commits the successor cannot see: never pushed, or ahead of upstream.
|
|
301
|
+
|
|
302
|
+
Found 2026-09-10: the parent had an unmerged branch correcting a plan the successor was told to
|
|
303
|
+
edit next. The successor caught it by luck — branching off main would have reverted the fix.
|
|
304
|
+
Empty when the repo has no remote, where every branch is local by design and naming them all
|
|
305
|
+
would be noise. Open PRs are NOT detected: that needs `gh` and the right account.
|
|
306
|
+
"""
|
|
307
|
+
if not _run(["git", "remote"], repo_root):
|
|
308
|
+
return []
|
|
309
|
+
out = _run(["git", "for-each-ref",
|
|
310
|
+
"--format=%(refname:short)|%(upstream:short)|%(upstream:track)", "refs/heads"],
|
|
311
|
+
repo_root)
|
|
312
|
+
found: list[str] = []
|
|
313
|
+
for line in out.splitlines():
|
|
314
|
+
name, up, track = (line.split("|") + ["", ""])[:3]
|
|
315
|
+
if not up:
|
|
316
|
+
found.append(f"{name} (never pushed)")
|
|
317
|
+
elif "ahead" in track:
|
|
318
|
+
found.append(f"{name} ({track.strip('[]')} of {up})")
|
|
319
|
+
return found
|
|
320
|
+
|
|
321
|
+
|
|
259
322
|
def preflight(repo_root: Path, context_pct: float | None = None) -> Preflight:
|
|
260
323
|
"""Conditions under which a spawn must not happen at all.
|
|
261
324
|
|
|
@@ -273,6 +336,12 @@ def preflight(repo_root: Path, context_pct: float | None = None) -> Preflight:
|
|
|
273
336
|
p.refusals.append(
|
|
274
337
|
f"{p.dirty} uncommitted file(s). The successor pulls, so uncommitted work is invisible "
|
|
275
338
|
"to it — it will redo the work or build on a state that does not exist. Commit first.")
|
|
339
|
+
flight = in_flight_branches(repo_root)
|
|
340
|
+
if flight:
|
|
341
|
+
p.notes.append(
|
|
342
|
+
"in-flight work the successor cannot see: " + ", ".join(flight) + ". Push each one, "
|
|
343
|
+
"name it in the relay, and say whether the successor must wait for it. Open PRs are "
|
|
344
|
+
"not detected here — list them yourself.")
|
|
276
345
|
|
|
277
346
|
d = repo_root / ART / "parking-lot"
|
|
278
347
|
if d.is_dir():
|
|
@@ -352,9 +421,22 @@ def fingerprint(repo_root: Path, with_tests: bool = False) -> dict:
|
|
|
352
421
|
"tests": "not measured",
|
|
353
422
|
}
|
|
354
423
|
if with_tests:
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
424
|
+
# The inventory's runner, not a second copy of it. The copy that lived here hard-coded
|
|
425
|
+
# pytest and ran it through `_run`, which returns "" on a non-zero exit — so a Pester repo
|
|
426
|
+
# AND a red pytest suite both read "suite reported no summary" (found 2026-09-10).
|
|
427
|
+
# caddis_inventory had already fixed both: `[handover] test_cmd`, and a runner that keeps
|
|
428
|
+
# a failing suite's summary. The two files sit side by side in source and in the bundle.
|
|
429
|
+
# Loaded by FILE, not by name: a `caddis_inventory` already imported from another path
|
|
430
|
+
# would otherwise be silently reused, and sys.path would be changed for the whole process.
|
|
431
|
+
import importlib.util
|
|
432
|
+
spec = importlib.util.spec_from_file_location(
|
|
433
|
+
"_caddis_inventory_for_spawn", Path(__file__).resolve().parent / "caddis_inventory.py")
|
|
434
|
+
_inv = importlib.util.module_from_spec(spec)
|
|
435
|
+
# Registered BEFORE exec: `@dataclass` looks its module up in sys.modules while the class
|
|
436
|
+
# body runs, and fails with "'NoneType' object has no attribute '__dict__'" otherwise.
|
|
437
|
+
sys.modules[spec.name] = _inv
|
|
438
|
+
spec.loader.exec_module(_inv)
|
|
439
|
+
fp["tests"], _shown = _inv._run_tests(_inv._test_command(repo_root), repo_root)
|
|
358
440
|
return fp
|
|
359
441
|
|
|
360
442
|
|
|
@@ -398,6 +480,26 @@ def verify_answerable(repo_root: Path, answer_in: str, needle: str) -> tuple[boo
|
|
|
398
480
|
PARENT_RELAY = "parent-relay.md"
|
|
399
481
|
PARENT_STATE = "parent-session-state.md"
|
|
400
482
|
|
|
483
|
+
# One task per line, `- [ ]` open and `- [x]` done. Counted so the successor's read-back can be
|
|
484
|
+
# checked against the file instead of taken on trust.
|
|
485
|
+
_TASK_LINE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+\[([ xX])\]", re.M)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def count_open_tasks(repo_root: Path) -> int | None:
|
|
489
|
+
"""Open checkbox items in the parent's task list, or None when there is nothing to count.
|
|
490
|
+
|
|
491
|
+
None covers "no file" and "a file with no checkbox lines". Either way there is nothing to
|
|
492
|
+
compare a reported count with, and refusing would demand a format the parent never used.
|
|
493
|
+
"""
|
|
494
|
+
try:
|
|
495
|
+
text = (repo_root / ART / PARENT_STATE).read_text(encoding="utf-8", errors="ignore")
|
|
496
|
+
except OSError:
|
|
497
|
+
return None
|
|
498
|
+
marks = _TASK_LINE.findall(text)
|
|
499
|
+
if not marks:
|
|
500
|
+
return None
|
|
501
|
+
return sum(1 for m in marks if m == " ")
|
|
502
|
+
|
|
401
503
|
|
|
402
504
|
def _mtime(path: Path) -> float | None:
|
|
403
505
|
try:
|
|
@@ -428,18 +530,24 @@ def capture_order(repo_root: Path, spawn_id: str) -> tuple[list[str], list[str]]
|
|
|
428
530
|
return refusals, notes
|
|
429
531
|
|
|
430
532
|
if t_state is None:
|
|
431
|
-
notes.append(f"no {ART}/{PARENT_STATE}.
|
|
432
|
-
"
|
|
433
|
-
"handover rather than leaving the
|
|
533
|
+
notes.append(f"no {ART}/{PARENT_STATE}. Task state is per-session — the successor's "
|
|
534
|
+
"tracker starts empty, so this file is the only way the list reaches it. If "
|
|
535
|
+
"the list is genuinely empty, SAY so in the handover rather than leaving the "
|
|
536
|
+
"reader to infer it.")
|
|
434
537
|
elif t_state > t_relay:
|
|
435
538
|
refusals.append(f"{PARENT_STATE} is NEWER than {PARENT_RELAY}. The relay cites the task "
|
|
436
539
|
"list, so a list written afterwards means the relay describes a state "
|
|
437
|
-
"that never existed. Rewrite the relay, do not re-touch the list."
|
|
540
|
+
"that never existed. Rewrite the relay, do not re-touch the list. If you "
|
|
541
|
+
"corrected the list on purpose, re-read the relay against it and re-save "
|
|
542
|
+
"the relay — a save that only moves its timestamp is the sanctioned fix.")
|
|
438
543
|
|
|
439
544
|
if t_relay > t_prompt:
|
|
440
545
|
refusals.append(f"{PARENT_RELAY} is NEWER than the prompt. The questions must target what "
|
|
441
546
|
"the relay actually says — derived from a relay that has since changed, "
|
|
442
|
-
"they test your memory of it, which is the thing under suspicion."
|
|
547
|
+
"they test your memory of it, which is the thing under suspicion. If you "
|
|
548
|
+
"corrected the relay on purpose, re-check the questions against it and "
|
|
549
|
+
"re-save the prompt — a save that only moves its timestamp is the "
|
|
550
|
+
"sanctioned fix.")
|
|
443
551
|
|
|
444
552
|
# Anything the relay quotes that moved after it was written.
|
|
445
553
|
for sub_dir in ("kb", "parking-lot"):
|
|
@@ -530,6 +638,10 @@ def handshake_open(repo_root: Path, spawn_id: str, transport: str = "auto",
|
|
|
530
638
|
# exist yet. The parent fills it in from the `from-name` of the child's first message,
|
|
531
639
|
# which is the only moment either side reliably knows that name.
|
|
532
640
|
"peer": peer,
|
|
641
|
+
# Tells the caddis guard to refuse tree-moving git commands until `close` stamps
|
|
642
|
+
# `closed_at`. An explicit flag, so handshake files written before the lock existed —
|
|
643
|
+
# which never got a `closed_at` — do not suddenly lock a tree.
|
|
644
|
+
"tree_lock": True,
|
|
533
645
|
"opened_at": int(time.time()),
|
|
534
646
|
"history": [],
|
|
535
647
|
}
|
|
@@ -550,7 +662,8 @@ MAX_REJECTS = 2
|
|
|
550
662
|
|
|
551
663
|
|
|
552
664
|
def handshake_record(repo_root: Path, spawn_id: str, event: str,
|
|
553
|
-
peer: str = "", verdict: str = ""
|
|
665
|
+
peer: str = "", verdict: str = "",
|
|
666
|
+
revived: int | None = None) -> tuple[dict | None, str]:
|
|
554
667
|
"""Advance the handshake. One step forward, with exactly one named exception.
|
|
555
668
|
|
|
556
669
|
**REJECT sends it back to `awaiting-answers`, and that is the whole point of this function
|
|
@@ -612,10 +725,28 @@ def handshake_record(repo_root: Path, spawn_id: str, event: str,
|
|
|
612
725
|
+ ("skip a step" if there > here + 1 else "move backwards")
|
|
613
726
|
+ " — refusing rather than rewriting, because a skipped step here is a "
|
|
614
727
|
"gate that never ran.")
|
|
728
|
+
# The task list crosses the session boundary only as a file. Task state is per-session, so the
|
|
729
|
+
# successor's tracker starts empty — measured 2026-09-10: a parent board of 59 tasks, and the
|
|
730
|
+
# child's `TaskList` said "No tasks found". So the read-back must say how many items the child
|
|
731
|
+
# rebuilt from the file. Required on `readback` (spawn-session's parent-first flow). Optional
|
|
732
|
+
# on child-first `answers`, because spawn-hub shares that flow and hands over a role, not a list.
|
|
733
|
+
if event == "readback" and revived is None:
|
|
734
|
+
return state, (f"a read-back must say how many open tasks the successor rebuilt from "
|
|
735
|
+
f"{ART}/{PARENT_STATE} into its own tracker. Ask for the number, then record "
|
|
736
|
+
"it with `--revived <n>` (0 is a valid answer when the list is empty).")
|
|
737
|
+
if revived is not None:
|
|
738
|
+
expected = count_open_tasks(repo_root)
|
|
739
|
+
if expected is not None and revived != expected:
|
|
740
|
+
return state, (f"the successor reports {revived} revived task(s), but "
|
|
741
|
+
f"{ART}/{PARENT_STATE} has {expected} open. A successor that revives "
|
|
742
|
+
"fewer has dropped some silently. Ask it to re-read the file and "
|
|
743
|
+
"report again.")
|
|
744
|
+
state["revived"] = revived
|
|
615
745
|
if peer:
|
|
616
746
|
state["peer"] = peer
|
|
617
747
|
state["history"].append({"event": event, "at": int(time.time()),
|
|
618
748
|
**({"verdict": verdict} if event == "verdict" and verdict else {}),
|
|
749
|
+
**({"revived": revived} if revived is not None else {}),
|
|
619
750
|
**({"peer": peer} if peer else {})})
|
|
620
751
|
state["state"] = target
|
|
621
752
|
_handshake_path(repo_root, spawn_id).write_text(
|
|
@@ -677,7 +808,13 @@ def handshake_close(repo_root: Path, spawn_id: str) -> tuple[bool, str]:
|
|
|
677
808
|
"ALIVE until the child confirms it has the verdict — closing on an "
|
|
678
809
|
"unanswered handshake is the same as never running one. (It may stop "
|
|
679
810
|
"WRITING to the repo now; that is a different rule and both hold.)")
|
|
680
|
-
|
|
811
|
+
# Stamp it. The guard's shared-tree lock holds while a handshake is open and lifts on this.
|
|
812
|
+
if not state.get("closed_at"):
|
|
813
|
+
state["closed_at"] = int(time.time())
|
|
814
|
+
_handshake_path(repo_root, spawn_id).write_text(
|
|
815
|
+
json.dumps(state, indent=2) + chr(10), encoding="utf-8")
|
|
816
|
+
return True, ("acknowledged — closed. Commit and push the handshake file, send the child one "
|
|
817
|
+
"last message saying you are stopping, then EXIT (see \"The parent's ending\")")
|
|
681
818
|
|
|
682
819
|
|
|
683
820
|
def main(argv: list[str] | None = None) -> int:
|
|
@@ -714,6 +851,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
714
851
|
h.add_argument("--peer", default="",
|
|
715
852
|
help="the child's name as ListAgents shows it — take it from the `from-name` "
|
|
716
853
|
"of its first message; that is the only moment it is reliably known")
|
|
854
|
+
h.add_argument("--revived", type=int, default=None,
|
|
855
|
+
help="with `--event readback` (required) or `--event answers` (optional): how "
|
|
856
|
+
"many open tasks the successor rebuilt from parent-session-state.md. "
|
|
857
|
+
"Checked against the file's `- [ ]` lines")
|
|
717
858
|
q = sub.add_parser("verify-question")
|
|
718
859
|
q.add_argument("--answer-in", required=True)
|
|
719
860
|
q.add_argument("--needle", required=True)
|
|
@@ -746,6 +887,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
746
887
|
f = check_document(doc.read_text(encoding="utf-8", errors="ignore"), root)
|
|
747
888
|
for n, msg in f.historical:
|
|
748
889
|
print(f" note line {n}: {msg}")
|
|
890
|
+
for name in f.unresolved_names:
|
|
891
|
+
print(f" note `{name}` matches no file in this repo — fine if it lives elsewhere; "
|
|
892
|
+
"give the full path if it is here")
|
|
749
893
|
if f.ok():
|
|
750
894
|
print(f"[spawn] {doc.name} passes round 0 — nothing stale-prone, every path exists")
|
|
751
895
|
return EXIT_OK
|
|
@@ -785,8 +929,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
785
929
|
f"({st['transport_reason']}), state `{st['state']}`")
|
|
786
930
|
if st["state"] == HANDSHAKE_STATES[0]:
|
|
787
931
|
print(f" PARENT-FIRST. You have the address, so message \"{st['peer']}\" now: "
|
|
788
|
-
"tell it what to read, then ask it to reply with WHICH FILES it opened "
|
|
789
|
-
"
|
|
932
|
+
"tell it what to read, then ask it to reply with WHICH FILES it opened, and "
|
|
933
|
+
f"how many open tasks it rebuilt from {ART}/{PARENT_STATE}, before it "
|
|
934
|
+
"answers anything. Record that with `--event readback --revived <n>`.")
|
|
790
935
|
elif st["transport"] == "message" and not st["peer"]:
|
|
791
936
|
print(" No --peer given, so this is the child-first flow: print the prompt and "
|
|
792
937
|
"wait. If you already started the child, re-open with `--peer <its "
|
|
@@ -814,7 +959,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
814
959
|
if not a.event:
|
|
815
960
|
sys.stderr.write("[spawn] --event is required for `record`" + chr(10))
|
|
816
961
|
return EXIT_REFUSED
|
|
817
|
-
st, err = handshake_record(root, a.id, a.event, a.peer, a.verdict)
|
|
962
|
+
st, err = handshake_record(root, a.id, a.event, a.peer, a.verdict, a.revived)
|
|
818
963
|
if err:
|
|
819
964
|
sys.stderr.write("[spawn] " + err + chr(10))
|
|
820
965
|
return EXIT_REFUSED
|
|
@@ -192,7 +192,7 @@ def extract_docmap_entries(docmap_text: str) -> set[str]:
|
|
|
192
192
|
# the other; the instruction file carries the mapping. Legacy synonyms included so old docs stay quiet.
|
|
193
193
|
TRUST_STATUS_VALUES: frozenset[str] = frozenset({
|
|
194
194
|
"draft", "stable", "deprecated", # OKF v0.2
|
|
195
|
-
"current", "done", "superseded", "ready",
|
|
195
|
+
"current", "done", "superseded", "ready", "proposed", # caddis
|
|
196
196
|
"shipped", "implemented", # caddis legacy synonyms for `done`
|
|
197
197
|
"open", "doing", "dropped", # caddis parking-lot (`done` shared above)
|
|
198
198
|
})
|
|
@@ -675,6 +675,43 @@ def insert_table_rows(text: str, heading_contains: str, rows: list[str]) -> str:
|
|
|
675
675
|
return out + "\n" if text.endswith("\n") else out # preserve trailing newline (git-clean)
|
|
676
676
|
|
|
677
677
|
|
|
678
|
+
# A link to a SIBLING note — `[x](x.md)` or `[x](./x.md)` — is what marks a table as the KB index.
|
|
679
|
+
# A link that climbs out (`../../docs/guide.md`) marks a table of other docs, which must not get
|
|
680
|
+
# KB rows appended to it.
|
|
681
|
+
_SIBLING_NOTE_LINK = re.compile(r"\]\((?:\./)?[^/()\s]+\.md\)")
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def insert_into_note_table(text: str, rows: list[str]) -> str:
|
|
685
|
+
"""Append ``rows`` to the first table that already links sibling KB notes, whatever its heading.
|
|
686
|
+
|
|
687
|
+
The fallback for a DOC-MAP written by hand before the scaffold existed. Found 2026-09-10 in
|
|
688
|
+
a consumer repo: a working 15-row table with no ``## Knowledge base`` heading, invisible to
|
|
689
|
+
``insert_table_rows``, so every new note had to be indexed by hand. Returns ``text`` unchanged
|
|
690
|
+
when no such table exists. Pure.
|
|
691
|
+
"""
|
|
692
|
+
if not rows:
|
|
693
|
+
return text
|
|
694
|
+
lines = text.splitlines()
|
|
695
|
+
i = 0
|
|
696
|
+
while i < len(lines):
|
|
697
|
+
if not lines[i].strip().startswith("|"):
|
|
698
|
+
i += 1
|
|
699
|
+
continue
|
|
700
|
+
start = i
|
|
701
|
+
while i < len(lines) and lines[i].strip().startswith("|"):
|
|
702
|
+
i += 1
|
|
703
|
+
end = i - 1
|
|
704
|
+
if end - start < 1 or set(lines[start + 1].strip()) - set("|-: "):
|
|
705
|
+
continue # no |---| separator: not a table
|
|
706
|
+
first_cells = [r.split("|")[1] for r in lines[start + 2:end + 1] if r.count("|") > 1]
|
|
707
|
+
if any(_SIBLING_NOTE_LINK.search(c) for c in first_cells):
|
|
708
|
+
head = lines[start:start + 2]
|
|
709
|
+
body = [b for b in lines[start + 2:end + 1] if not _is_placeholder_row(b)]
|
|
710
|
+
out = "\n".join(lines[:start] + head + body + rows + lines[end + 1:])
|
|
711
|
+
return out + "\n" if text.endswith("\n") else out
|
|
712
|
+
return text
|
|
713
|
+
|
|
714
|
+
|
|
678
715
|
def build_docmap(root: Path, project_name: str = "project") -> str:
|
|
679
716
|
"""Full DOC-MAP text for a fresh repo: scaffold + discovered reference docs + existing KB notes.
|
|
680
717
|
|
|
@@ -779,7 +816,10 @@ def reindex(
|
|
|
779
816
|
# Index orphan KB notes (additive). Report success ONLY if the rows actually landed — insert is a
|
|
780
817
|
# no-op when the "Knowledge base" heading/table is absent, and we must not claim work we didn't do.
|
|
781
818
|
if orphans:
|
|
782
|
-
|
|
819
|
+
new_rows = kb_note_rows(root, entries)
|
|
820
|
+
after = insert_table_rows(updated, "Knowledge base", new_rows)
|
|
821
|
+
if after == updated:
|
|
822
|
+
after = insert_into_note_table(updated, new_rows) # a hand-made map, no heading
|
|
783
823
|
if after != updated:
|
|
784
824
|
updated = after
|
|
785
825
|
summary.append(f"indexed {len(orphans)} KB note(s): " + ", ".join(orphans))
|
|
@@ -886,6 +926,20 @@ def run(root: Path, check: bool) -> int:
|
|
|
886
926
|
return 0
|
|
887
927
|
|
|
888
928
|
|
|
929
|
+
def run_reindex(root: Path, prune: bool = False) -> int:
|
|
930
|
+
"""Run the reindex, print its summary, and return the exit code.
|
|
931
|
+
|
|
932
|
+
Non-zero when any part of the work was NOT done — a note it could not index, or a DOC-MAP it
|
|
933
|
+
could not write. Until 2026-09-10 this always exited 0, so a partial success looked exactly
|
|
934
|
+
like a full one and a repo could sit with unindexed notes indefinitely.
|
|
935
|
+
"""
|
|
936
|
+
root = Path(root)
|
|
937
|
+
_changed, summary = reindex(root, root.name, prune=prune)
|
|
938
|
+
for line in summary:
|
|
939
|
+
print(f"[kb] {line}")
|
|
940
|
+
return 1 if any(s.startswith(("could not", "WARNING")) for s in summary) else 0
|
|
941
|
+
|
|
942
|
+
|
|
889
943
|
def main() -> None:
|
|
890
944
|
if hasattr(sys.stdout, "reconfigure"):
|
|
891
945
|
sys.stdout.reconfigure(encoding="utf-8") # Windows cp1252 can't encode some doc names
|
|
@@ -909,12 +963,10 @@ def main() -> None:
|
|
|
909
963
|
)
|
|
910
964
|
args = parser.parse_args()
|
|
911
965
|
root = _repo_root()
|
|
912
|
-
# Reindex/prune are maintenance actions (the `/kb` command drives them), not a gate —
|
|
966
|
+
# Reindex/prune are maintenance actions (the `/kb` command drives them), not a gate — but they
|
|
967
|
+
# still exit non-zero when part of the work could not be done.
|
|
913
968
|
if args.reindex or args.prune:
|
|
914
|
-
|
|
915
|
-
for line in summary:
|
|
916
|
-
print(f"[kb] {line}")
|
|
917
|
-
sys.exit(0)
|
|
969
|
+
sys.exit(run_reindex(root, prune=args.prune))
|
|
918
970
|
# Resolve the repo root from git (cwd fallback) — the pre-push gate runs from the repo root,
|
|
919
971
|
# so this is correct wherever the checker is copied. Tests call run() with an explicit root.
|
|
920
972
|
sys.exit(run(root, args.check))
|