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.
- package/CHANGELOG.md +90 -0
- package/HOW-IT-WORKS.md +17 -13
- package/README.md +23 -17
- package/SKILL-CARD.md +2 -2
- package/cursor/rules/task-pipeline.mdc +33 -21
- package/evals/task-pipeline.evals.json +3 -3
- package/package.json +1 -1
- package/plugins/task-pipeline/.claude-plugin/plugin.json +2 -2
- package/plugins/task-pipeline/commands/task-pipeline.md +14 -219
- package/plugins/task-pipeline/skills/evidence-docs/SKILL.md +8 -1
- package/plugins/task-pipeline/skills/evidence-docs/references/GENERATED.md +1 -1
- package/plugins/task-pipeline/skills/evidence-docs/references/learned.md +24 -1
- package/plugins/task-pipeline/skills/evidence-docs/references/retrospective.md +21 -576
- package/plugins/task-pipeline/skills/project-audit/SKILL.md +8 -1
- package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +162 -230
- package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.example.json +9 -9
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +10 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/acceptance.md +2 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/brainstorm.md +3 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/browser.md +48 -11
- package/plugins/task-pipeline/skills/task-pipeline/references/build.md +7 -7
- package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +49 -44
- package/plugins/task-pipeline/skills/task-pipeline/references/companion-skills.md +32 -24
- package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/finish-line.md +13 -8
- package/plugins/task-pipeline/skills/task-pipeline/references/iteration.md +20 -23
- package/plugins/task-pipeline/skills/task-pipeline/references/knowledge-sources.md +5 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/learned.md +24 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +17 -7
- package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +47 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/spec.md +5 -4
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-00-intake.md +39 -25
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-05-dev.md +3 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-08-post-deploy.md +3 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +34 -22
- package/plugins/task-pipeline/skills/task-pipeline/references/tdd.md +3 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +6 -4
- package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +77 -21
- package/plugins/task-pipeline/skills/task-pipeline/scripts/retro_budget.py +305 -0
- package/plugins/task-pipeline/skills/task-pipeline/scripts/short_path.py +70 -0
- package/plugins/task-pipeline/skills/task-pipeline/scripts/waves.py +22 -10
- package/plugins/task-pipeline/skills/task-pipeline/templates/browser-claims.json +5 -5
- package/plugins/task-pipeline/skills/task-pipeline/templates/canvas.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/templates/retro-archive.md +9 -0
- 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*).
|
|
658
|
-
# round
|
|
659
|
-
|
|
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 =
|
|
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
|
|
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
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
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
|
|
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
|
|
1778
|
-
print("\n%s has
|
|
1779
|
-
|
|
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 =
|
|
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
|
|
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 (
|
|
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
|
|
2132
|
-
"
|
|
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())
|