task-pipeline-skill 1.96.0 → 1.98.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/HOW-IT-WORKS.md +17 -13
  3. package/README.md +23 -17
  4. package/SKILL-CARD.md +2 -2
  5. package/cursor/rules/task-pipeline.mdc +33 -21
  6. package/evals/task-pipeline.evals.json +3 -3
  7. package/package.json +1 -1
  8. package/plugins/task-pipeline/.claude-plugin/plugin.json +2 -2
  9. package/plugins/task-pipeline/commands/task-pipeline.md +14 -219
  10. package/plugins/task-pipeline/skills/evidence-docs/SKILL.md +8 -1
  11. package/plugins/task-pipeline/skills/evidence-docs/references/GENERATED.md +1 -1
  12. package/plugins/task-pipeline/skills/evidence-docs/references/learned.md +24 -1
  13. package/plugins/task-pipeline/skills/evidence-docs/references/retrospective.md +21 -576
  14. package/plugins/task-pipeline/skills/project-audit/SKILL.md +8 -1
  15. package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +162 -230
  16. package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +1 -1
  17. package/plugins/task-pipeline/skills/task-pipeline/pipeline.example.json +9 -9
  18. package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +10 -3
  19. package/plugins/task-pipeline/skills/task-pipeline/references/acceptance.md +2 -2
  20. package/plugins/task-pipeline/skills/task-pipeline/references/brainstorm.md +3 -3
  21. package/plugins/task-pipeline/skills/task-pipeline/references/browser.md +48 -11
  22. package/plugins/task-pipeline/skills/task-pipeline/references/build.md +7 -7
  23. package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +49 -44
  24. package/plugins/task-pipeline/skills/task-pipeline/references/companion-skills.md +32 -24
  25. package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -1
  26. package/plugins/task-pipeline/skills/task-pipeline/references/finish-line.md +13 -8
  27. package/plugins/task-pipeline/skills/task-pipeline/references/iteration.md +20 -23
  28. package/plugins/task-pipeline/skills/task-pipeline/references/knowledge-sources.md +5 -3
  29. package/plugins/task-pipeline/skills/task-pipeline/references/learned.md +24 -1
  30. package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +17 -7
  31. package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +47 -1
  32. package/plugins/task-pipeline/skills/task-pipeline/references/spec.md +5 -4
  33. package/plugins/task-pipeline/skills/task-pipeline/references/stage-00-intake.md +39 -25
  34. package/plugins/task-pipeline/skills/task-pipeline/references/stage-05-dev.md +3 -3
  35. package/plugins/task-pipeline/skills/task-pipeline/references/stage-08-post-deploy.md +3 -2
  36. package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +34 -22
  37. package/plugins/task-pipeline/skills/task-pipeline/references/tdd.md +3 -2
  38. package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +6 -4
  39. package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +77 -21
  40. package/plugins/task-pipeline/skills/task-pipeline/scripts/retro_budget.py +305 -0
  41. package/plugins/task-pipeline/skills/task-pipeline/scripts/short_path.py +70 -0
  42. package/plugins/task-pipeline/skills/task-pipeline/scripts/waves.py +22 -10
  43. package/plugins/task-pipeline/skills/task-pipeline/templates/browser-claims.json +5 -5
  44. package/plugins/task-pipeline/skills/task-pipeline/templates/canvas.md +1 -1
  45. package/plugins/task-pipeline/skills/task-pipeline/templates/retro-archive.md +9 -0
  46. package/plugins/task-pipeline/skills/task-pipeline/templates/retro.md +10 -1
@@ -35,6 +35,7 @@ conditional on the code, never merely sequenced after it):
35
35
  4 nothing runnable: an unclassified, running or parked dependency remains
36
36
  5 external execution lease contention
37
37
  6 nothing runnable: all remaining dependency leaves are recorded watched waits
38
+ 7 `certify` stopped at its ceiling: the node is parked with the evidence
38
39
 
39
40
  **The mutation verbs never leave a half-written queue.** `add` and `park` write to a
40
41
  temp file in the same directory and `os.replace` it into place, so a crash mid-write
@@ -654,10 +655,25 @@ ALL_TIERS = TIERS + (VISUAL_TIER,)
654
655
  SURFACE_CLASSES = ("flagship", "product", "internal", "ad")
655
656
  RISK_CLASSES = ("R1", "R2", "R3")
656
657
  # Passing certification rounds a node owes before `close` accepts it, by risk class
657
- # (`references/certification.md` → *Rounds owed by risk class*). R3's must include one
658
- # round recorded `--kind adversarial`.
659
- ROUNDS_OWED = {"R1": 1, "R2": 2, "R3": 3}
658
+ # (`references/certification.md` → *Rounds owed by risk class*). A clean round ends the
659
+ # node unless it is R3 or a flagship surface: those owe a second passing round, and it is
660
+ # the adversarial one, a different question rather than the same reading again. R2 owed
661
+ # two blind rounds until 1.98.0, and a clean round did not cancel the next one: the loop
662
+ # the 2026-10-20 cost audit measured.
663
+ ROUNDS_OWED = {"R1": 1, "R2": 1, "R3": 2}
660
664
  ROUND_KINDS = ("blind", "adversarial")
665
+ # An R1 node certifies with ONE blind tier (any of the three; `unit` is the default
666
+ # reading). Every other node owes all three, and a flagship or product surface the fourth.
667
+ ONE_TIER_RISK = ("R1",)
668
+
669
+
670
+ def owed_rounds(node):
671
+ """(rounds owed, adversarial owed) for a node, or (0, False) when it owes no count."""
672
+ owed = ROUNDS_OWED.get(node.get("risk"), 0)
673
+ adversarial = node.get("risk") == "R3" or node.get("surface_class") == "flagship"
674
+ if node.get("surface_class") == "flagship":
675
+ owed = max(owed, 2)
676
+ return owed, adversarial
661
677
 
662
678
 
663
679
  def passing_rounds(cert):
@@ -675,13 +691,13 @@ def passing_rounds(cert):
675
691
 
676
692
  def owed_shortfall(node):
677
693
  """None when the node's class is satisfied, else a sentence saying what it still owes."""
678
- owed = ROUNDS_OWED.get(node.get("risk"))
694
+ owed, adversarial = owed_rounds(node)
679
695
  if not owed:
680
696
  return None
681
697
  passed = passing_rounds(node.get("certification"))
682
698
  if len(passed) < owed:
683
699
  return "%d of %d owed passing rounds" % (len(passed), owed)
684
- if node.get("risk") == "R3" and "adversarial" not in passed:
700
+ if adversarial and "adversarial" not in passed:
685
701
  cert = node.get("certification") or {}
686
702
  kinds = list(cert.get("kinds") or [])
687
703
  failed_adv = any(k == "adversarial" and not all(v == "pass" for v in (h or {}).values())
@@ -1661,10 +1677,14 @@ def cmd_certify(graph, args):
1661
1677
  the number the ceiling below reads. The node stays `pending` on a failure; the
1662
1678
  round count is the trail.
1663
1679
 
1664
- **The ceiling measures rather than stops** — `references/loop-guard.md`. At the
1665
- ceiling `certify` still runs and still tells the truth about the tiers; what it
1666
- adds is the name of the tier that keeps failing, because a run spinning on one
1667
- level needs the operator to see WHICH level, not to be halted.
1680
+ **The ceiling stops** (`references/loop-guard.md`, since 1.98.0). A round that fails
1681
+ with the node's failing rounds at the ceiling parks the node with the evidence as its
1682
+ reason (exit 7): the run records a blocker row and moves on. It used to measure
1683
+ only, and a measured loop is still a loop.
1684
+
1685
+ **No re-run on unchanged input.** A round of the same kind on the same tree as the
1686
+ round before it is refused before anything is written: a tier's answer about a tree
1687
+ it already read is the answer it gave.
1668
1688
  """
1669
1689
  guard(graph, args.graph)
1670
1690
 
@@ -1721,10 +1741,14 @@ def cmd_certify(graph, args):
1721
1741
  die("the tier reports are malformed — nothing was written:\n " + "\n ".join(bad))
1722
1742
 
1723
1743
  missing = [x for x in TIERS if x not in reports]
1724
- if missing:
1744
+ if node.get("risk") in ONE_TIER_RISK:
1745
+ if len(missing) == len(TIERS):
1746
+ die("certification of an %s node needs one blind tier report (unit, seam or "
1747
+ "product); none was given" % node.get("risk"))
1748
+ elif missing:
1725
1749
  die("certification is missing the %s report(s) — all three are required, because "
1726
- "the level nobody read is the level the defect survives at"
1727
- % ", ".join("`%s`" % m for m in missing))
1750
+ "the level nobody read is the level the defect survives at (an R1 node "
1751
+ "certifies with one)" % ", ".join("`%s`" % m for m in missing))
1728
1752
  sclass = node.get("surface_class")
1729
1753
  if sclass in VISUAL_REQUIRED and VISUAL_TIER not in reports:
1730
1754
  die("certification is missing the `%s` report — %s is a %s surface, and on one the "
@@ -1743,6 +1767,18 @@ def cmd_certify(graph, args):
1743
1767
  head = ""
1744
1768
 
1745
1769
  prior = node.get("certification") or {}
1770
+ try:
1771
+ _t = subprocess.run(["git", "rev-parse", "HEAD^{tree}"], capture_output=True, text=True)
1772
+ tree = _t.stdout.strip() if _t.returncode == 0 else ""
1773
+ except OSError:
1774
+ tree = ""
1775
+ prior_kinds = list(prior.get("kinds") or [])
1776
+ if (tree and prior.get("tree") == tree
1777
+ and (prior_kinds[-1] if prior_kinds else "blind") == args.kind):
1778
+ die("%s: round %d already read tree %s as a %s round: re-running a tier on "
1779
+ "unchanged input repeats its answer. Change the code, run the other kind of round, "
1780
+ "or record a blocker (references/loop-guard.md). Nothing was written."
1781
+ % (nid, int(prior.get("round") or 0), tree[:12], args.kind))
1746
1782
  round_no = int(prior.get("round") or 0) + 1
1747
1783
  tiers_now = {x: reports[x]["verdict"] for x in tiers}
1748
1784
  history = list(prior.get("history") or []) + [tiers_now]
@@ -1754,6 +1790,8 @@ def cmd_certify(graph, args):
1754
1790
  "history": history,
1755
1791
  "kinds": kinds,
1756
1792
  }
1793
+ if tree:
1794
+ node["certification"]["tree"] = tree
1757
1795
 
1758
1796
  failed = [x for x in tiers if tiers_now[x] == "fail"]
1759
1797
 
@@ -1761,6 +1799,20 @@ def cmd_certify(graph, args):
1761
1799
  # operator needs named; counting it here is what makes the loop visible.
1762
1800
  churning = [x for x in tiers
1763
1801
  if len(history) >= 2 and all(h.get(x) == "fail" for h in history)]
1802
+ failed_rounds = sum(1 for h in history if any(v == "fail" for v in h.values()))
1803
+ stop = bool(failed) and failed_rounds >= args.ceiling
1804
+ if stop:
1805
+ first = next((f for x in failed for f in reports[x]["findings"]
1806
+ if f.get("severity") == "breaks"), {})
1807
+ reason = ("certification ceiling: %d failing round(s), ceiling %d; failing tier(s) %s; "
1808
+ "%s; first break %s: %s; check: %s. Blocker row owed on the board"
1809
+ % (failed_rounds, args.ceiling, ", ".join(failed),
1810
+ ("stuck on " + ", ".join(churning)) if churning else "churn across levels",
1811
+ first.get("where", "?"), first.get("what", "?"), first.get("check", "?")))
1812
+ reason = " ".join(reason.split())
1813
+ node["status"] = "parked"
1814
+ node["parked_reason"] = reason
1815
+ revise(graph, "park", nid, reason)
1764
1816
 
1765
1817
  save(args.graph, graph)
1766
1818
 
@@ -1774,9 +1826,10 @@ def cmd_certify(graph, args):
1774
1826
  print(" [%s] %s — %s" % (tier, f["where"], f["what"]), file=sys.stderr)
1775
1827
  print(" fix: %s" % f.get("fix", "(not stated)"), file=sys.stderr)
1776
1828
  print(" check: %s" % f["check"], file=sys.stderr)
1777
- if round_no >= args.ceiling:
1778
- print("\n%s has been certified %d time(s), at or over the ceiling of %d."
1779
- % (nid, round_no, args.ceiling), file=sys.stderr)
1829
+ if stop:
1830
+ print("\n%s has failed %d round(s), at the ceiling of %d. STOPPED: the node is "
1831
+ "parked with this evidence as its reason; record a blocker row and move on."
1832
+ % (nid, failed_rounds, args.ceiling), file=sys.stderr)
1780
1833
  if churning:
1781
1834
  print("The same tier has failed every round: %s. That is not a fix "
1782
1835
  "away — the level itself is being misread, or the node is the "
@@ -1785,6 +1838,8 @@ def cmd_certify(graph, args):
1785
1838
  else:
1786
1839
  print("No single tier is failing every round, so this is churn across "
1787
1840
  "levels rather than one stuck level.", file=sys.stderr)
1841
+ if stop:
1842
+ return 7
1788
1843
  print("\nThe node stays open. Round %d is recorded on it." % round_no,
1789
1844
  file=sys.stderr)
1790
1845
  return 1
@@ -1843,7 +1898,7 @@ def cmd_certify(graph, args):
1843
1898
  fh.write("\n")
1844
1899
  os.replace(tmp, out)
1845
1900
  print("%s: certified at %s in round %d" % (nid, ", ".join(tiers), round_no))
1846
- owed = ROUNDS_OWED.get(node.get("risk"))
1901
+ owed, _adv = owed_rounds(node)
1847
1902
  short = owed_shortfall(node)
1848
1903
  if short:
1849
1904
  print("round %d: %s — not closable yet. The next round reads the node's whole diff at "
@@ -1964,7 +2019,7 @@ def cmd_close(graph, args):
1964
2019
  if short and not exc:
1965
2020
  die("%s is %s: %s — not closable yet. Run the next round with `graph.py certify`, "
1966
2021
  "or record an authorized exception with `waive`. Nothing was written."
1967
- % (nid, node["risk"], short))
2022
+ % (nid, node.get("risk") or "a %s surface" % node.get("surface_class"), short))
1968
2023
  if cert:
1969
2024
  tiers = cert.get("tiers") or {}
1970
2025
  failing = sorted(k for k, val in tiers.items() if val != "pass")
@@ -2073,8 +2128,8 @@ VERBS = {
2073
2128
  "wait": (cmd_wait, "record a watched external wait, without executing its locator"),
2074
2129
  "resume": (cmd_wait, "resolve the exact wait identity and return to pending"),
2075
2130
  "wave": (cmd_wave, "move a node to another wave, carrying the reason"),
2076
- "certify": (cmd_certify, "require three independent tier reports (four on a "
2077
- "flagship or product surface), then emit the verdict "
2131
+ "certify": (cmd_certify, "require three independent tier reports (one on an R1 node, "
2132
+ "four on a flagship or product surface), then emit the verdict "
2078
2133
  "`close` consumes"),
2079
2134
  "close": (cmd_close, "consume a verdict, close one node and re-plan"),
2080
2135
  }
@@ -2128,8 +2183,9 @@ def main(argv=None):
2128
2183
  help="the round's kind; R3 owes one `adversarial` round "
2129
2184
  "(references/certification.md → *Rounds owed by risk class*)")
2130
2185
  p_cert.add_argument("--ceiling", type=int, default=3,
2131
- help="rounds after which the output names the churning tier; it "
2132
- "measures rather than stops (references/loop-guard.md)")
2186
+ help="failing rounds after which certify STOPS: the node is parked "
2187
+ "with the evidence and the run records a blocker row and moves "
2188
+ "on, exit 7 (references/loop-guard.md)")
2133
2189
 
2134
2190
  for verb in ("claim", "release", "recover", "complete"):
2135
2191
  made[verb].add_argument("--authority", required=True,
@@ -0,0 +1,305 @@
1
+ #!/usr/bin/env python3
2
+ """retro_budget.py: the byte budget of the retro's floor, measured and enforced.
3
+
4
+ The floor is what stage 0 reads in full: the *Standing instructions* section and the
5
+ *Run stamps* section of `<artifacts>/retro.md` (`references/retrospective.md` ->
6
+ *The floor has a byte budget*). A cap of ten rows never bounded their SIZE: measured
7
+ on 2026-10-20 across ~50 projects the floor ran from ~3k to ~76k tokens, one of them
8
+ 304 KB. This script is the bound.
9
+
10
+ retro_budget.py check [--retro PATH] [--budget N] [--row N] [--stamp N]
11
+ prints one line: the floor's bytes, bytes/4 tokens, and the largest row of
12
+ each kind. Exit 0 within budget; 1 over budget (every offender listed);
13
+ 2 could not measure (no file, or a floor section is missing).
14
+ retro_budget.py migrate [--retro PATH] [--archive PATH] [--write]
15
+ moves narrative out of the floor VERBATIM into the archive, each block
16
+ carrying the sha256 of the text it holds, and leaves a pointer in its place:
17
+ a stamp row over the stamp limit keeps Date, Topic and Commit; a standing row
18
+ over the row limit loses its `Because` cell. Without --write it prints the
19
+ plan and writes nothing. Exit 0 when the floor is then within budget, 1 when a
20
+ row is still over (an instruction too long to shorten by moving its story:
21
+ rewrite it by hand), 2 could not measure.
22
+ retro_budget.py verify [--retro PATH]
23
+ every `sha256:` pointer in the floor resolves to an archive block whose text
24
+ hashes to it. Exit 0, or 1 naming each pointer that does not.
25
+
26
+ Standard library only, like every script this skill ships.
27
+ """
28
+ import argparse
29
+ import datetime
30
+ import hashlib
31
+ import os
32
+ import re
33
+ import sys
34
+
35
+ BUDGET = 12000 # bytes of floor (standing instructions + run stamps)
36
+ ROW = 600 # chars per standing-instruction row
37
+ STAMP = 300 # chars per run-stamp row
38
+ CAP = 10 # rows per table, the existing cap
39
+
40
+ STANDING = "## Standing instructions"
41
+ STAMPS = "## Run stamps"
42
+ POINTER = re.compile(r"(retro/[0-9]{4}-Q[1-4]\.md) sha256:([0-9a-f]{12,64})")
43
+
44
+
45
+ class Unmeasurable(Exception):
46
+ pass
47
+
48
+
49
+ def sections(text):
50
+ """{name: (start, end)} line spans of the two floor sections, end exclusive."""
51
+ lines = text.split("\n")
52
+ spans = {}
53
+ for i, line in enumerate(lines):
54
+ for name in (STANDING, STAMPS):
55
+ if line.startswith(name) and name not in spans:
56
+ j = i + 1
57
+ while j < len(lines) and not (lines[j].startswith("## ")
58
+ or lines[j].startswith("# ")):
59
+ j += 1
60
+ spans[name] = (i, j)
61
+ missing = [n for n in (STANDING, STAMPS) if n not in spans]
62
+ if missing:
63
+ raise Unmeasurable("no `%s` section, so the floor cannot be measured"
64
+ % "` or `".join(missing))
65
+ return lines, spans
66
+
67
+
68
+ def table_rows(lines, span):
69
+ """Indices of the data rows of every table inside a span (header and rule skipped)."""
70
+ out = []
71
+ start, end = span
72
+ i = start
73
+ while i < end:
74
+ if lines[i].startswith("|") and i + 1 < end and re.match(r"^\|\s*:?-{3}", lines[i + 1]):
75
+ i += 2
76
+ while i < end and lines[i].startswith("|"):
77
+ out.append(i)
78
+ i += 1
79
+ continue
80
+ i += 1
81
+ return out
82
+
83
+
84
+ def cells(row):
85
+ parts = row.strip().split("|")
86
+ return [c.strip() for c in parts[1:-1]]
87
+
88
+
89
+ def header_cols(lines, i):
90
+ """Column count of the table holding row i, read from its header (a `|` inside a
91
+ cell's text must not widen the row that replaces it)."""
92
+ k = i
93
+ while k > 0 and lines[k - 1].startswith("|"):
94
+ k -= 1
95
+ return len(cells(lines[k]))
96
+
97
+
98
+ def measure(text, budget=BUDGET, row=ROW, stamp=STAMP):
99
+ lines, spans = sections(text)
100
+ floor = "\n".join(lines[spans[STANDING][0]:spans[STANDING][1]]) + "\n" + \
101
+ "\n".join(lines[spans[STAMPS][0]:spans[STAMPS][1]])
102
+ nbytes = len(floor.encode("utf-8"))
103
+ std = table_rows(lines, spans[STANDING])
104
+ stm = table_rows(lines, spans[STAMPS])
105
+ over = []
106
+ if nbytes > budget:
107
+ over.append("floor is %d bytes, over the budget of %d" % (nbytes, budget))
108
+ for i in std:
109
+ if len(lines[i]) > row:
110
+ over.append("standing row %s is %d chars, over %d"
111
+ % ((cells(lines[i]) or ["?"])[0], len(lines[i]), row))
112
+ for i in stm:
113
+ if len(lines[i]) > stamp:
114
+ c = cells(lines[i])
115
+ over.append("stamp %s %s is %d chars, over %d"
116
+ % (c[0] if c else "?", c[2] if len(c) > 2 else "", len(lines[i]), stamp))
117
+ if len(std) > CAP:
118
+ over.append("%d standing rows, over the cap of %d" % (len(std), CAP))
119
+ if len(stm) > CAP:
120
+ over.append("%d run stamps, over the cap of %d" % (len(stm), CAP))
121
+ return {
122
+ "bytes": nbytes, "tokens": nbytes // 4, "budget": budget,
123
+ "standing": len(std), "standing_max": max([len(lines[i]) for i in std] or [0]),
124
+ "stamps": len(stm), "stamps_max": max([len(lines[i]) for i in stm] or [0]),
125
+ "over": over, "lines": lines, "spans": spans, "std": std, "stm": stm,
126
+ }
127
+
128
+
129
+ def summary(m, row=ROW, stamp=STAMP):
130
+ return ("retro floor: %d bytes · ~%d tokens (bytes/4) · budget %d · standing %d/%d rows, "
131
+ "largest %d/%d · stamps %d/%d, largest %d/%d"
132
+ % (m["bytes"], m["tokens"], m["budget"], m["standing"], CAP, m["standing_max"], row,
133
+ m["stamps"], CAP, m["stamps_max"], stamp))
134
+
135
+
136
+ def read(path):
137
+ try:
138
+ with open(path, encoding="utf-8") as fh:
139
+ return fh.read()
140
+ except OSError as exc:
141
+ raise Unmeasurable("cannot read %s (%s)" % (path, exc.strerror or exc))
142
+
143
+
144
+ def default_archive(retro):
145
+ today = datetime.date.today()
146
+ q = (today.month - 1) // 3 + 1
147
+ return os.path.join(os.path.dirname(os.path.abspath(retro)), "retro",
148
+ "%d-Q%d.md" % (today.year, q))
149
+
150
+
151
+ def digest(text):
152
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
153
+
154
+
155
+ def block(kind, label, text):
156
+ h = digest(text)
157
+ return h, ("\n### retro_budget migrate · %s · %s · %s\n\n<!-- sha256:%s -->\n%s\n"
158
+ % (datetime.date.today().isoformat(), kind, label, h, text))
159
+
160
+
161
+ def cmd_check(a):
162
+ m = measure(read(a.retro), a.budget, a.row, a.stamp)
163
+ print(summary(m, a.row, a.stamp))
164
+ if m["over"]:
165
+ for o in m["over"]:
166
+ print(" over: " + o)
167
+ print(" remedy: retro_budget.py migrate --write moves the narrative to the archive "
168
+ "verbatim; an instruction still over the row limit is rewritten by hand")
169
+ return 1
170
+ return 0
171
+
172
+
173
+ def cmd_migrate(a):
174
+ text = read(a.retro)
175
+ m = measure(text, a.budget, a.row, a.stamp)
176
+ lines = m["lines"]
177
+ archive = a.archive or default_archive(a.retro)
178
+ rel = os.path.relpath(os.path.abspath(archive),
179
+ os.path.dirname(os.path.abspath(a.retro))).replace(os.sep, "/")
180
+ moved = []
181
+ for i in m["stm"]:
182
+ if len(lines[i]) <= a.stamp:
183
+ continue
184
+ c = cells(lines[i])
185
+ if len(c) < 3:
186
+ continue
187
+ h, b = block("stamp", "%s %s" % (c[0], c[2]), lines[i])
188
+ ptr = "moved: %s sha256:%s" % (rel, h[:12])
189
+ # the table keeps its column count: the pointer fills Verdict, `·` the rest
190
+ tail = ["·"] * max(0, header_cols(lines, i) - 4)
191
+
192
+ def row_of(topic):
193
+ return "| " + " | ".join([c[0], topic, c[2], ptr] + tail) + " |"
194
+
195
+ topic = c[1]
196
+ new = row_of(topic)
197
+ if len(new) > a.stamp:
198
+ keep = max(8, len(topic) - (len(new) - a.stamp) - 1)
199
+ new = row_of(topic[:keep].rstrip() + "…")
200
+ moved.append((i, new, b, "stamp %s %s" % (c[0], c[2])))
201
+ for i in m["std"]:
202
+ if len(lines[i]) <= a.row:
203
+ continue
204
+ c = cells(lines[i])
205
+ if len(c) < 5:
206
+ continue
207
+ h, b = block("standing", c[0], c[4])
208
+ c[4] = "story: %s sha256:%s" % (rel, h[:12])
209
+ moved.append((i, "| " + " | ".join(c) + " |", b, "standing %s Because" % c[0]))
210
+ if not moved:
211
+ print(summary(m, a.row, a.stamp))
212
+ print("migrate: nothing to move")
213
+ return 1 if m["over"] else 0
214
+ for i, new, _, label in moved:
215
+ print("migrate: %s -> %s (%d -> %d chars)" % (label, rel, len(lines[i]), len(new)))
216
+ if not a.write:
217
+ print("migrate: dry run, nothing written (pass --write)")
218
+ return 0
219
+ os.makedirs(os.path.dirname(os.path.abspath(archive)), exist_ok=True)
220
+ if not os.path.exists(archive):
221
+ with open(archive, "w", encoding="utf-8") as fh:
222
+ fh.write("# Retro archive · %s\n\n**Append-only. Queried, never read in full.** "
223
+ "Blocks moved here by `retro_budget.py migrate` carry the sha256 of the "
224
+ "text they hold; the floor keeps a pointer to it.\n"
225
+ % os.path.basename(archive)[:-3])
226
+ with open(archive, "a", encoding="utf-8") as fh:
227
+ for _, _, b, _ in moved:
228
+ fh.write(b)
229
+ for i, new, _, _ in moved:
230
+ lines[i] = new
231
+ tmp = a.retro + ".tmp"
232
+ with open(tmp, "w", encoding="utf-8") as fh:
233
+ fh.write("\n".join(lines))
234
+ os.replace(tmp, a.retro)
235
+ after = measure(read(a.retro), a.budget, a.row, a.stamp)
236
+ print(summary(after, a.row, a.stamp))
237
+ bad = verify(a.retro)
238
+ if bad:
239
+ for b in bad:
240
+ print(" unproved: " + b)
241
+ return 1
242
+ for o in after["over"]:
243
+ print(" still over: " + o)
244
+ return 1 if after["over"] else 0
245
+
246
+
247
+ def verify(retro):
248
+ text = read(retro)
249
+ lines, spans = sections(text)
250
+ floor = "\n".join(lines[spans[STANDING][0]:spans[STANDING][1]] +
251
+ lines[spans[STAMPS][0]:spans[STAMPS][1]])
252
+ base = os.path.dirname(os.path.abspath(retro))
253
+ bad, cache = [], {}
254
+ for rel, short in POINTER.findall(floor):
255
+ path = os.path.join(base, rel)
256
+ if path not in cache:
257
+ try:
258
+ cache[path] = read(path)
259
+ except Unmeasurable:
260
+ cache[path] = None
261
+ arch = cache[path]
262
+ if arch is None:
263
+ bad.append("sha256:%s points at %s, which does not exist" % (short, rel))
264
+ continue
265
+ ok = False
266
+ for m in re.finditer(r"<!-- sha256:([0-9a-f]{64}) -->\n(.*?)\n(?=\n#{2,3} |\Z)", arch, re.S):
267
+ if m.group(1).startswith(short) and digest(m.group(2)) == m.group(1):
268
+ ok = True
269
+ break
270
+ if not ok:
271
+ bad.append("sha256:%s has no block in %s whose text hashes to it" % (short, rel))
272
+ return bad
273
+
274
+
275
+ def cmd_verify(a):
276
+ bad = verify(a.retro)
277
+ for b in bad:
278
+ print("unproved: " + b)
279
+ if not bad:
280
+ print("verify: every archive pointer in the floor resolves and its hash matches")
281
+ return 1 if bad else 0
282
+
283
+
284
+ def main(argv=None):
285
+ p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
286
+ sub = p.add_subparsers(dest="cmd", required=True)
287
+ for name in ("check", "migrate", "verify"):
288
+ s = sub.add_parser(name)
289
+ s.add_argument("--retro", default="docs/evidence/retro.md")
290
+ s.add_argument("--budget", type=int, default=BUDGET)
291
+ s.add_argument("--row", type=int, default=ROW)
292
+ s.add_argument("--stamp", type=int, default=STAMP)
293
+ if name == "migrate":
294
+ s.add_argument("--archive")
295
+ s.add_argument("--write", action="store_true")
296
+ a = p.parse_args(argv)
297
+ try:
298
+ return {"check": cmd_check, "migrate": cmd_migrate, "verify": cmd_verify}[a.cmd](a)
299
+ except Unmeasurable as exc:
300
+ print("retro_budget: could not measure: %s" % exc, file=sys.stderr)
301
+ return 2
302
+
303
+
304
+ if __name__ == "__main__":
305
+ sys.exit(main())
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env python3
2
+ """short_path.py: stage 0's proportional decision, computed rather than proposed.
3
+
4
+ `references/stage-00-intake.md` -> Phase 1d. Until 1.98.0 the short path was only
5
+ *proposed*, and silence took the full flow; under a standing "decide and act"
6
+ instruction silence is the normal answer, so a small fix met eleven stages every time.
7
+ This script takes the five triage answers and DECIDES, printing the one line the run
8
+ shows the operator.
9
+
10
+ short_path.py --files PATH [PATH ...] --contract yes|no --user-facing yes|no
11
+ --deploy yes|no --risk R1|R2|R3 [--unknowns N]
12
+
13
+ Exit 0: the short path (brief in the run ledger, build with tests, one verification,
14
+ docs and handoff). Exit 3: the full flow, with every answer that ruled the short path
15
+ out. Exit 2: usage. Standard library only.
16
+ """
17
+ import argparse
18
+ import sys
19
+
20
+ MAX_FILES = 5
21
+ SMALL_RISK = "R1"
22
+
23
+
24
+ def decide(files, contract, user_facing, deploy, risk, unknowns=0):
25
+ """(short?, line) for the five answers plus the gap check's unknowns."""
26
+ n = len(set(files))
27
+ why = []
28
+ if unknowns:
29
+ why.append("%d material unknown(s) left by the gap check" % unknowns)
30
+ if n > MAX_FILES:
31
+ why.append("%d files, over %d" % (n, MAX_FILES))
32
+ if n == 0:
33
+ why.append("no file named, so the change is not bounded")
34
+ if contract:
35
+ why.append("a public contract is touched")
36
+ if user_facing:
37
+ why.append("a user-observable change")
38
+ if deploy:
39
+ why.append("a deploy target")
40
+ if risk != SMALL_RISK:
41
+ why.append("risk %s" % risk)
42
+ if why:
43
+ return False, "full flow: " + "; ".join(why)
44
+ return True, ("short path: %s · %d file%s · no contract · no user-facing change · "
45
+ "no deploy" % (risk, n, "" if n == 1 else "s"))
46
+
47
+
48
+ def yes_no(v):
49
+ v = v.strip().lower()
50
+ if v not in ("yes", "no"):
51
+ raise argparse.ArgumentTypeError("answer yes or no, not %r" % v)
52
+ return v == "yes"
53
+
54
+
55
+ def main(argv=None):
56
+ p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
57
+ p.add_argument("--files", nargs="*", default=[], help="the paths the change touches")
58
+ p.add_argument("--contract", type=yes_no, required=True)
59
+ p.add_argument("--user-facing", type=yes_no, required=True)
60
+ p.add_argument("--deploy", type=yes_no, required=True)
61
+ p.add_argument("--risk", choices=("R1", "R2", "R3"), required=True)
62
+ p.add_argument("--unknowns", type=int, default=0)
63
+ a = p.parse_args(argv)
64
+ short, line = decide(a.files, a.contract, a.user_facing, a.deploy, a.risk, a.unknowns)
65
+ print(line)
66
+ return 0 if short else 3
67
+
68
+
69
+ if __name__ == "__main__":
70
+ sys.exit(main())