@antoneeo/agentic-sdlc-skill 1.20.2 → 1.21.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.
@@ -215,7 +215,11 @@ DEFAULT_DOMAIN = "code"
215
215
  PORTABLE_CHECKS = {}
216
216
  # Which check namespaces this distribution actually carries. Set by the entry point;
217
217
  # a `checks:` entry outside it WARNS visibly rather than passing silently.
218
- _ENTRY_POINT = {"domain": DEFAULT_DOMAIN, "provides": ()}
218
+ _ENTRY_POINT = {"domain": DEFAULT_DOMAIN, "provides": (), "script": "sdlc_check.py"}
219
+ # "script" is what generated headers tell the reader to RUN. Declared, never
220
+ # derived from sys.argv: the generated bytes must not depend on how the command
221
+ # was invoked, or the alignment check would fail on the invocation instead of on
222
+ # the content.
219
223
 
220
224
 
221
225
  # --- distribution profile ----------------------------------------------------
@@ -292,10 +296,17 @@ def portable_check(name):
292
296
  return register
293
297
 
294
298
 
295
- def set_entry_point(domain, provides=()):
299
+ def set_entry_point(domain, provides=(), script=None):
296
300
  """Declare which domain this distribution is and which check namespaces it ships."""
297
301
  _ENTRY_POINT["domain"] = domain
298
302
  _ENTRY_POINT["provides"] = tuple(provides)
303
+ if script:
304
+ _ENTRY_POINT["script"] = script
305
+
306
+
307
+ def entry_script():
308
+ """The command name a generated header tells the reader to run."""
309
+ return _ENTRY_POINT["script"]
299
310
 
300
311
 
301
312
  def project_default_domain(root):
@@ -727,6 +738,142 @@ def build_manifest(root):
727
738
  return "\n".join(lines).rstrip() + "\n"
728
739
 
729
740
 
741
+ # ------------------------------------------------- workstream registry (F-028)
742
+ # audit/handoff.md is GENERATED from one source file per open workstream, so two
743
+ # writers working two workstreams touch two different files. Row-per-workstream
744
+ # alone was not enough -- F-019 had that and the file still conflicted twice,
745
+ # because a file-global `Date:` header defeats row-level ownership. The header is
746
+ # now DERIVED, so no writer touches it.
747
+
748
+ REGISTRY_COLUMNS = ("Workstream", "Level", "Branch", "Status", "Since",
749
+ "Next step", "Details")
750
+ REGISTRY_KEYS = ("workstream", "level", "branch", "status", "since", "next")
751
+ REGISTRY_CAP = 20 # a signal, never a truncation (see cmd_index)
752
+ PROJECT_NOTES = "project_notes.md" # NOT handoff_notes.md: the HANDOFF_*.md
753
+ # glob is case-insensitive on Windows, and that name would be collected as a
754
+ # source. The trap is real; the name is the fix.
755
+
756
+
757
+ def registry_header():
758
+ return (f"<!-- GENERATED by {entry_script()} index - do not edit by hand. "
759
+ f"Source of truth: the HANDOFF_*.md files in {docs_dir()}/audit/. -->")
760
+
761
+
762
+ def list_workstreams(root):
763
+ """[(path, meta)] for audit/HANDOFF_*.md carrying registry frontmatter.
764
+
765
+ Opt-in by the presence of `workstream:` -- a project whose handoff is still
766
+ hand-written has no sources, so nothing generates and nothing errors (the
767
+ F-019 migration lesson). Sorted by workstream id: the alignment check is a
768
+ byte comparison, and glob order differs across filesystems."""
769
+ aud = ai_path(root, "audit")
770
+ out = []
771
+ if not aud.is_dir():
772
+ return out
773
+ for p in sorted(aud.glob("HANDOFF_*.md")):
774
+ meta = load_frontmatter(read_text(p).splitlines())
775
+ if str(meta.get("workstream") or "").strip():
776
+ out.append((p, meta))
777
+ out.sort(key=lambda pm: (str(pm[1].get("workstream")).strip().lower(), pm[0].name))
778
+ return out
779
+
780
+
781
+ def _registry_cell(value):
782
+ text = str(value if value is not None else "").strip()
783
+ return text.replace("|", "\\|") or "-"
784
+
785
+
786
+ def build_registry(root):
787
+ """The generated registry, or "" when there is no source to build it from.
788
+
789
+ Deterministic by construction: sorted rows, and a `Date:` taken from the
790
+ newest `updated:` VALUE written inside the sources -- never a filesystem
791
+ timestamp. Git does not preserve mtimes, so an mtime-derived header would
792
+ regenerate differently in every fresh clone and the alignment check would
793
+ fire on a tree nobody touched."""
794
+ rows = list_workstreams(root)
795
+ if not rows:
796
+ return ""
797
+ stamp = max(str(m.get("updated") or m.get("since") or "").strip() or "0000-00-00"
798
+ for _p, m in rows)
799
+ lines = ["# Handoff — workstream registry",
800
+ f"Date: {stamp} (UTC)",
801
+ "",
802
+ registry_header(),
803
+ "",
804
+ "| " + " | ".join(REGISTRY_COLUMNS) + " |",
805
+ "|" + "---|" * len(REGISTRY_COLUMNS)]
806
+ for p, meta in rows:
807
+ extra = str(meta.get("details") or "").strip()
808
+ details = f"{p.name} · {extra}" if extra else p.name
809
+ cells = [_registry_cell(meta.get(k)) for k in REGISTRY_KEYS]
810
+ lines.append("| " + " | ".join(cells) + f" | {_registry_cell(details)} |")
811
+ notes = ai_path(root, "audit", PROJECT_NOTES)
812
+ if notes.is_file():
813
+ body = read_text(notes).strip()
814
+ if body:
815
+ lines += ["", "## Project-wide notes", "", body]
816
+ return "\n".join(lines) + "\n"
817
+
818
+
819
+ def parse_registry_rows(text):
820
+ """Workstream ids in a registry table, hand-written or generated."""
821
+ ids = []
822
+ for line in text.splitlines():
823
+ s = line.strip()
824
+ if not s.startswith("|") or set(s) <= {"|", "-", " ", ":"}:
825
+ continue
826
+ first = s.strip("|").split("|")[0].strip()
827
+ if first and first.lower() != "workstream":
828
+ ids.append(first)
829
+ return ids
830
+
831
+
832
+ def registry_conversion_blockers(root):
833
+ """What stops `index` from writing over a hand-written handoff.md.
834
+
835
+ The mixed state is the trap this exists for: converting one row at a time
836
+ leaves a project at one source file and five hand-written rows, and
837
+ regenerating from the one source DELETES the other five -- silently, in the
838
+ file whose whole purpose is not losing them. So conversion is per project.
839
+ Empty list = writing is safe."""
840
+ hand = ai_path(root, "audit", "handoff.md")
841
+ if not hand.is_file():
842
+ return []
843
+ text = read_text(hand)
844
+ if "GENERATED by sdlc_check.py index" in text:
845
+ return [] # already ours
846
+ blockers = []
847
+ known = {str(m.get("workstream")).strip() for _p, m in list_workstreams(root)}
848
+ orphans = [r for r in parse_registry_rows(text) if r not in known]
849
+ if orphans:
850
+ blockers.append("rows no HANDOFF_*.md accounts for: " + ", ".join(orphans))
851
+ # Everything else in the file must have a home too, or it is lost on write:
852
+ # a pre-1.17 narrative handoff carries no table at all, so orphan rows alone
853
+ # would not notice it.
854
+ notes_ok = ai_path(root, "audit", PROJECT_NOTES).is_file()
855
+ leftovers, in_notes = [], False
856
+ for line in text.splitlines():
857
+ s = line.strip()
858
+ if not s or s.startswith("|") or s.startswith("<!--"):
859
+ continue
860
+ if s.startswith("# ") or re.match(r"^(?:Date|Data):", s):
861
+ continue
862
+ if re.match(r"^##\s+Project-wide notes\s*$", s):
863
+ in_notes = True
864
+ continue
865
+ if s.startswith("## "):
866
+ in_notes = False
867
+ if in_notes and notes_ok:
868
+ continue
869
+ leftovers.append(s)
870
+ if leftovers:
871
+ blockers.append("content outside the table with nowhere to go (%d line(s), first: %r) "
872
+ "-- project-wide notes belong in audit/%s"
873
+ % (len(leftovers), leftovers[0][:60], PROJECT_NOTES))
874
+ return blockers
875
+
876
+
730
877
  def list_guides(root):
731
878
  """[(rel_to_ai_docs, path, meta, text)] for ai_docs/reference/GUIDE_*.md."""
732
879
  ref = ai_path(root, "reference")
@@ -821,6 +968,30 @@ def cmd_index(root):
821
968
  "a comprehension map (`source_kind: code`) -- see `guides.md`.\n",
822
969
  encoding="utf-8")
823
970
  print(f"[ok] guide router regenerated (empty stub): {gidx}")
971
+ return max(rc_registry(root), 0)
972
+
973
+
974
+ def rc_registry(root):
975
+ """Write the generated workstream registry, or refuse and say why (F-028)."""
976
+ ws = list_workstreams(root)
977
+ if not ws:
978
+ return 0 # no sources: a hand-written handoff is untouched
979
+ blockers = registry_conversion_blockers(root)
980
+ hand = ai_path(root, "audit", "handoff.md")
981
+ if blockers:
982
+ print(f"[ERROR] {docs_dir()}/audit/handoff.md NOT regenerated -- it still holds "
983
+ "state no source accounts for:")
984
+ for b in blockers:
985
+ print(f" - {b}")
986
+ print(" Convert the whole registry at once (templates.md): converting "
987
+ "one row at a time is the state that loses the others.")
988
+ return 1
989
+ hand.parent.mkdir(parents=True, exist_ok=True)
990
+ hand.write_text(build_registry(root), encoding="utf-8")
991
+ print(f"[ok] workstream registry regenerated: {hand}")
992
+ if len(ws) > REGISTRY_CAP:
993
+ print(f"[warn] {len(ws)} open workstreams: the registry is meant to stay under "
994
+ f"{REGISTRY_CAP}. Nothing was truncated -- closing one is the fix.")
824
995
  return 0
825
996
 
826
997
 
@@ -1257,8 +1428,33 @@ def cmd_validate(root, strict=False, hybrid=False):
1257
1428
  elif norm_text(read_text(gidx)) != norm_text(build_guide_index(root)):
1258
1429
  errors.append(f"{docs_dir()}/reference/INDEX.md not aligned with the guides: run 'sdlc_check.py index'")
1259
1430
 
1260
- # Handoff: header and freshness
1431
+ # Handoff: alignment with its sources (F-028), then header and freshness
1261
1432
  hand = ai / "audit" / "handoff.md"
1433
+ workstreams = list_workstreams(root)
1434
+ if workstreams:
1435
+ if not hand.is_file():
1436
+ errors.append(f"{docs_dir()}/audit/handoff.md missing while HANDOFF_*.md sources "
1437
+ "exist: the registry is the only place a cold agent sees the open "
1438
+ "workstreams -- run 'sdlc_check.py index'")
1439
+ elif norm_text(read_text(hand)) != norm_text(build_registry(root)):
1440
+ errors.append(f"{docs_dir()}/audit/handoff.md not aligned with its HANDOFF_*.md "
1441
+ "sources: run 'sdlc_check.py index'. A merge resolved by hand is "
1442
+ "exactly what this catches")
1443
+ if len(workstreams) > REGISTRY_CAP:
1444
+ warnings.append(f"{len(workstreams)} open workstreams: the registry is meant to "
1445
+ f"stay under {REGISTRY_CAP}")
1446
+ # Two files claiming one workstream is the collision this design does NOT
1447
+ # fix (two people opening the same work under different file names). It
1448
+ # would otherwise show up as two identical-looking rows and nothing else.
1449
+ seen = {}
1450
+ for p, meta in workstreams:
1451
+ wid = str(meta.get("workstream")).strip()
1452
+ if wid in seen:
1453
+ warnings.append(f"{docs_dir()}/audit/{p.name} and {seen[wid]} both claim "
1454
+ f"workstream '{wid}': the registry shows two rows for one "
1455
+ "workstream — decide which file owns it")
1456
+ else:
1457
+ seen[wid] = p.name
1262
1458
  if hand.is_file():
1263
1459
  m = re.search(r"(?:Date|Data):\s*(\d{4}-\d{2}-\d{2})", read_text(hand))
1264
1460
  if not m:
@@ -86,7 +86,7 @@ Must-reads for this project, in order. The full manifest of canonical docs is
86
86
  1. `reference/INDEX.md` — the guide router: which guide already governs the work you are about to do (generated).
87
87
  2. `vision/project_vision.md` — why the project exists (check its Status first).
88
88
  3. `strategic/architecture.md` — how it is built.
89
- 4. `audit/handoff.md` — where work stopped last session (if present).
89
+ 4. `audit/handoff.md` — where work stopped last session (generated from the `HANDOFF_*.md` beside it; never edited by hand).
90
90
 
91
91
  Directory purposes: `vision/` (project direction), `strategic/` (architecture and
92
92
  feature catalog), `reference/` (operative guides), `solutions/` (per-feature
@@ -367,36 +367,50 @@ States: PENDING (to analyze) | ANALYZED (analyzed, with reference) | SKIPPED (wi
367
367
  | vendor/ | SKIPPED | - | vendored code |
368
368
  ```
369
369
 
370
- ## ai_docs/audit/handoff.md — the workstream registry
371
-
372
- One row per OPEN workstream, ≤ 20 lines. **Parallel-safe by construction**: closing
373
- one milestone removes one row and never touches another's resume point — the defect
374
- this replaces was a single narrative slot where the last session to close overwrote
375
- everyone else's handoff. It is an **inventory for lookup** (like the generated
376
- manifest), not a work board: no assignment, no due dates, no execution ordering.
377
-
378
- Updated at every L3 closure (row removed) AND at session end with work still
379
- IN_PROGRESS (row refreshed) see Write Triggers.
380
-
381
- **Coming from a pre-1.17 project** (narrative handoff with `## Active features` /
382
- `## Next step` / `## Session notes`): nothing is broken and nothing is urgent — the
383
- validator only checks the `Date:` header and its age, and the orientation hook reads
384
- the file verbatim. Read it as a one-row registry, and convert it the next time the
385
- write trigger fires: each `## Active features` bullet becomes a row, `## Next step`
386
- becomes that row's next step, `## Session notes` becomes `## Project-wide notes`.
387
- Migrating a repository that is not being worked on buys nothing.
370
+ ## ai_docs/audit/handoff.md — the workstream registry (GENERATED)
371
+
372
+ One row per OPEN workstream. **Never written by hand**: `sdlc_check.py index` builds
373
+ it from the `HANDOFF_[feature].md` files, and `validate` errors when the two disagree.
374
+ It is an **inventory for lookup** (like the generated manifest), not a work board: no
375
+ assignment, no due dates, no execution ordering, no holder.
376
+
377
+ **Why generated, and not just one row per workstream.** Row-per-workstream alone was
378
+ tried and was not enough: two workstreams opened from one base still conflicted twice
379
+ in this file, because a file-global `Date:` header defeats row-level ownership no
380
+ matter how few rows each writer touches. So the truth moved into the per-workstream
381
+ file, and the header is derived (the newest `updated:` in the sources — a value, never
382
+ a filesystem timestamp, which git does not preserve). Two writers on two workstreams
383
+ now touch two different files. The generated view can still conflict at merge; that
384
+ conflict is resolved **mechanically** by re-running `index`, never by hand, and
385
+ `validate` refuses CLEAN until the file matches its sources.
386
+
387
+ Project-wide notes have their own source, `ai_docs/audit/project_notes.md`, appended
388
+ verbatim under `## Project-wide notes`. (Not `handoff_notes.md`: the `HANDOFF_*.md`
389
+ glob is case-insensitive on Windows and would collect it as a workstream.)
390
+
391
+ **Converting an existing project** — lazily, at the first write, and **all at once**.
392
+ Converting one row at a time is the state that loses the others: the next `index`
393
+ would regenerate from the one source and drop the rest. `index` refuses to write while
394
+ anything in the file is unaccounted for, and names it. A pre-1.17 narrative handoff
395
+ (`## Active features` / `## Next step` / `## Session notes`) is the same conversion:
396
+ each bullet becomes a `HANDOFF_[feature].md`, `## Session notes` becomes
397
+ `project_notes.md`. A project with no sources yet is not touched and reports nothing —
398
+ migrating a repository nobody is working on buys nothing.
388
399
 
389
400
  ```markdown
390
401
  # Handoff — workstream registry
391
402
  Date: 2026-06-11 (UTC)
392
403
 
404
+ <!-- GENERATED by sdlc_check.py index - do not edit by hand. Source of truth: the HANDOFF_*.md files in ai_docs/audit/. -->
405
+
393
406
  | Workstream | Level | Branch | Status | Since | Next step | Details |
394
407
  |---|---|---|---|---|---|---|
395
408
  | F-001 SSO login | L3 | feature/sso-login | PROGRESS | 2026-06-10 | wire callback tests | HANDOFF_login_sso.md · ANALYSIS_login_sso.md |
396
- | F-002 Audit refresh | L3 | feature/audit | PAUSED | 2026-06-02 | resume at Phase 4 | ANALYSIS_audit_refresh.md (no volatile state) |
409
+ | F-002 Audit refresh | L3 | feature/audit | PAUSED | 2026-06-02 | resume at Phase 4 | HANDOFF_audit_refresh.md · ANALYSIS_audit_refresh.md |
397
410
 
398
411
  ## Project-wide notes
399
- <!-- one or two lines: release pending, environment quirks that affect everyone -->
412
+
413
+ <!-- from audit/project_notes.md: release pending, environment quirks that affect everyone -->
400
414
  ```
401
415
 
402
416
  ## ai_docs/audit/reviews/REVIEW_LOG.md
@@ -431,21 +445,47 @@ records the realization actually used — fresh subagent, one-shot client run, o
431
445
  honest; writing nothing, or implying independence you did not have, is the failure
432
446
  this column exists to prevent. `findings_real` is how many raised findings survived
433
447
  triage: over time it is the only evidence of whether the gate earns its cost.
434
-
435
- ## ai_docs/audit/HANDOFF_[feature].md volatile resume logistics (ephemeral)
436
-
437
- **Resume logistics ONLY; the ANALYSIS Diary keeps the durable narrative (DRY).**
438
- The boundary: Diary = what happened and why (decisions, state of the work survives
439
- forever); this file = how to pick the work back up (branch/worktree, uncommitted
440
- state, environment notes, the next concrete command — worthless once resumed).
441
- Created only when a session pauses the feature WITH volatile state to record;
442
- **DELETED at the feature's closure**, in the same step that flips the ANALYSIS to
443
- COMPLETEDanything in it worth keeping was in the wrong file.
448
+ Concurrent reviews: `init` writes a `.gitattributes` stanza giving this file
449
+ `merge=union`a **built-in** driver (no per-clone `git config`, unlike
450
+ `merge=ours`, which silently does nothing until every clone configures it).
451
+ Rows are date-stamped and their order carries no meaning, so a union merge keeps
452
+ both sides instead of asking a human to choose. It is defence in depth: without
453
+ git, or without the stanza, the outcome is today's one conflict you resolve by
454
+ hand, never a lost row.
455
+
456
+
457
+ ## ai_docs/audit/HANDOFF_[feature].md one open workstream, its own file
458
+
459
+ **The authored home of that workstream's registry row**, and the only one: the
460
+ registry is generated from these files. **One exists for every OPEN workstream**, with
461
+ or without volatile state — a workstream whose file is missing has no row, and a
462
+ workstream with no row is invisible to the next cold agent. **DELETED at the feature's
463
+ closure**, in the same step that flips the ANALYSIS to COMPLETED: deleting it *is*
464
+ removing the row.
465
+
466
+ **The DRY boundary, restated because the file is no longer rare.** What used to keep
467
+ narrative out of it was that it barely existed; now it always does. So: the ANALYSIS
468
+ Diary keeps **what happened and why** (decisions, state of the work — survives
469
+ forever), and this file keeps **the row plus the resume logistics** — how to pick the work back up (branch,
470
+ worktree, uncommitted state, the next concrete command — worthless once resumed).
471
+ Prose that would still be worth reading after closure is in the wrong file, because
472
+ this one is deleted.
473
+
474
+ The frontmatter IS the row. `workstream:` is what marks the file as a source: without
475
+ it the file is still a perfectly good volatile note, and nothing generates.
444
476
 
445
477
  ```markdown
478
+ ---
479
+ workstream: F-001 SSO login
480
+ level: L3
481
+ branch: feature/sso-login (worktree ../wt-sso)
482
+ status: PROGRESS
483
+ since: 2026-06-10
484
+ next: wire the callback tests
485
+ details: ANALYSIS_login_sso.md
486
+ updated: 2026-06-11
487
+ ---
446
488
  # HANDOFF: [feature] (ephemeral — deleted at closure)
447
- Updated: 2026-06-11 (UTC)
448
- Branch: feature/sso-login (worktree ../wt-sso)
449
489
 
450
490
  ## Resume state
451
491
  <!-- uncommitted files, half-run migrations, env vars, running services -->
@@ -457,6 +497,19 @@ Branch: feature/sso-login (worktree ../wt-sso)
457
497
  <!-- traps discovered this session that bite on resume (locks, CRLF, flaky test) -->
458
498
  ```
459
499
 
500
+ `updated:` is the date this file last changed, and the newest one across all sources
501
+ becomes the registry's `Date:` header — which is why no writer ever edits that header
502
+ and why two concurrent writers no longer collide on it. `details:` holds the *other*
503
+ pointers (the ANALYSIS, a review log entry); the generator prepends this file's own
504
+ name, so nothing points at itself by hand.
505
+
506
+ ## ai_docs/audit/project_notes.md — the registry's project-wide notes (source)
507
+
508
+ Plain lines, no frontmatter, appended verbatim to the generated registry under
509
+ `## Project-wide notes`. Release pending, environment quirks, anything true for
510
+ everyone rather than for one workstream. It exists so that generating the registry
511
+ cannot destroy notes that belong to no workstream.
512
+
460
513
  ## ai_docs/strategic/architecture.md and existing_features.md
461
514
 
462
515
  Canonical docs: they open with the header (`description:`/`status:`) so they enter the `INDEX.md` manifest cleanly.