Skip to content

Commit 0f19bd3

Browse files
committed
Merge: v1.3.16 — ledger parses the plan template's checkbox bullets (the parser had never met Sage's own output; E9/E10 fixtures structurally blind); manifest resume display same fix; template-conformance suite pins every artifact parser against the actual templates + the ledger↔spec-gate round-trip
2 parents f7cc487 + d2eb033 commit 0f19bd3

10 files changed

Lines changed: 301 additions & 13 deletions

File tree

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sage",
3-
"version": "1.3.15",
3+
"version": "1.3.16",
44
"description": "AI skills framework: UNDERSTAND → ENVISION → DELIVER. Mechanical process enforcement, 9 workflows, 12 core skills (+ installable packs), 5 agent personas.",
55
"author": {
66
"name": "xoai",

CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,41 @@
22

33
All notable changes to Sage will be documented in this file.
44

5+
## [1.3.16] — the ledger reads the plans Sage writes, and templates become the tested contract
6+
7+
### The ledger reads the plans Sage actually writes
8+
9+
Field report (2026-08-04, the first real `--subagents` cycle to get past
10+
the flag parser): `ledger.py init` refused a 24-task plan — "no `## Task
11+
N — title` headings found". The plan template Sage itself generates
12+
(`core/templates/plan/standard.plan-template.md`) writes checkbox bullets
13+
(`- [ ] **Task N:** title`); the ledger's parser was written against the
14+
E9 fixture's heading convention and had never met the product's own
15+
output. A parser tested only on test-authored data.
16+
17+
- `ledger.py` parses both forms now — heading or checkbox bullet, checked
18+
or not (done ≠ independently reviewed), `[DOC]` markers stripped,
19+
duplicate ids first-wins. The error message names both accepted forms.
20+
Regression-pinned with the template's own shape AND verified against
21+
the reporting cycle's real 24-task plan.
22+
- `manifest.py plan_tasks` had the same assumption with a worse failure
23+
mode: on bullet plans the `/continue` resume display printed section
24+
headings ("Tasks", "Rollback") as if they were the plan's tasks.
25+
Bullet-aware now, same fallback chain.
26+
- The workflow doc names both forms where it describes ledger generation.
27+
- **The class is now pinned, not just this instance**: a new
28+
template-conformance suite (`test_template_conformance.py`, in
29+
fastcheck) runs every parser of a Sage artifact against the TEMPLATE
30+
that produces it — ledger and resume task parsing, the judge's
31+
current-task and spec-boundary reads, scope-derive's declaration-line
32+
regex, and the cross-language ledger↔spec-gate round-trip (scaffold →
33+
blocked while pending → allowed when done+approved), all against
34+
`standard.plan-template.md` and both spec templates verbatim. A parser
35+
that stops reading Sage's own output now fails in fastcheck, not in a
36+
field report. The audit that produced it found no further mismatches:
37+
manifest frontmatter keys, `## Boundaries`, and `- **Files:**` lines
38+
all conform.
39+
540
## [1.3.15] — the flag parser survives its own flags, and the dead tests rise
641

742
### `/build --subagents` no longer crashes the flag parser

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.3.15
1+
1.3.16

core/workflows/sub-workflows/subagent-execution.workflow.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ python3 sage/runtime/tools/ledger.py init \
6363
.sage/work/<initiative>/plan.md
6464
```
6565

66-
This generates one ledger entry per `## Task N` in the approved plan and sets
66+
This generates one ledger entry per plan task — the template's
67+
`- [ ] **Task N:**` bullets or `## Task N` headings — and sets
6768
`execution_mode: subagent`, which arms the completion guard.
6869

6970
**Why a script and not an instruction to you.** E9 measured this. Given a ledger,

develop/evals/coverage.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,13 @@ surfaces:
159159
sage-task-reviewer / sage-branch-reviewer iff the user's config carries
160160
a model (modelless = unbound, [V-E]); E9/E10 still measure the
161161
claude-code inherit path — the binding branch is deterministic-only,
162-
and the cost comparison stays P5-T3, unclaimed.
162+
and the cost comparison stays P5-T3, unclaimed. 2026-08-04 later the
163+
same day: ledger.py now parses the plan template's checkbox-bullet
164+
tasks as well as the E9-fixture heading form — the first real
165+
--subagents cycle could not scaffold a ledger from a plan Sage itself
166+
generated (both scenario fixtures use headings, so E9/E10 could never
167+
see it; BulletPlanTest pins the template's shape, and the fix was
168+
verified against the reporting cycle's real 24-task plan).
163169
164170
subworkflow-quality-gates:
165171
path: core/workflows/sub-workflows/quality-gates.workflow.md

develop/fastcheck.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ CHECKS=(
4747
"ledger tests :: python3 develop/validators/tools/test_ledger.py"
4848
"agent-binding tests :: python3 develop/validators/tools/test_agent_binding.py"
4949
"flag parser tests :: python3 develop/validators/tools/test_sage_flags.py"
50+
"template conformance :: python3 develop/validators/tools/test_template_conformance.py"
5051
"scope-judge runtime tests :: python3 develop/validators/scope-judge/test_scope_judge.py"
5152
"review controller tests :: python3 develop/validators/review/test_review_controller.py"
5253
"review ledger tests :: python3 develop/validators/review/test_review_ledger.py"

develop/validators/tools/test_ledger.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,68 @@ def test_a_plan_with_no_tasks_fails_loudly(self):
8080
(self.d / "plan.md").write_text("# Plan\n\nSome prose, no task headings.\n")
8181
r = run("init", self.m(), self.p())
8282
self.assertEqual(r.returncode, 1)
83-
self.assertIn("no `## Task N", r.stderr)
83+
self.assertIn("no plan tasks found", r.stderr)
84+
85+
86+
87+
PLAN_BULLETS = """# Plan
88+
89+
## Tasks
90+
91+
- [ ] **Task 1:** Migration 0005: schema + RLS + down-guard
92+
- **Files:** internal/platform/db/migrations/0005.sql
93+
- **Action:** schema only
94+
- [x] **Task 2:** Public IDs (whk_/del_/sbr_) [DOC]
95+
- **Depends on:** Task 1
96+
- [ ] **Task 3:** Signer + signature vectors
97+
98+
## Rollback
99+
100+
Not a task, just a section heading.
101+
"""
102+
103+
104+
class BulletPlanTest(unittest.TestCase):
105+
"""Field report 2026-08-04: the first real --subagents run could not
106+
scaffold a ledger, because ledger.py parsed only `## Task N` headings —
107+
the E9 FIXTURE's format — while the plan template Sage itself generates
108+
(core/templates/plan/standard.plan-template.md) writes checkbox bullets.
109+
A parser tested only against test-authored data had never met the
110+
product's own output."""
111+
112+
def setUp(self):
113+
self.d = pathlib.Path(tempfile.mkdtemp())
114+
(self.d / "plan.md").write_text(PLAN_BULLETS)
115+
(self.d / "manifest.md").write_text(
116+
'---\ncycle_id: "t"\ngate_state: plan-approved\n---\n\n# Cycle\n')
117+
118+
def test_template_bullets_scaffold_a_ledger(self):
119+
r = run("init", str(self.d / "manifest.md"), str(self.d / "plan.md"))
120+
self.assertEqual(r.returncode, 0, r.stderr)
121+
fm = (self.d / "manifest.md").read_text()
122+
self.assertEqual(fm.count("- id:"), 3)
123+
self.assertIn("title: Signer + signature vectors", fm)
124+
125+
def test_checked_bullets_and_doc_markers(self):
126+
"""A checked task still belongs in the ledger (done is not
127+
independently reviewed), and [DOC] is presentation, not title."""
128+
import importlib.util as _iu
129+
spec = _iu.spec_from_file_location("ledger", LEDGER)
130+
led = _iu.module_from_spec(spec)
131+
spec.loader.exec_module(led)
132+
tasks = led.parse_plan_tasks(PLAN_BULLETS)
133+
self.assertEqual([n for n, _ in tasks], [1, 2, 3])
134+
self.assertEqual(tasks[1][1], "Public IDs (whk_/del_/sbr_)")
135+
136+
def test_mixed_forms_dedupe_by_id_first_wins(self):
137+
import importlib.util as _iu
138+
spec = _iu.spec_from_file_location("ledger", LEDGER)
139+
led = _iu.module_from_spec(spec)
140+
spec.loader.exec_module(led)
141+
mixed = "## Task 1 — heading form\n\n- [ ] **Task 1:** bullet form\n- [ ] **Task 2:** only bullet\n"
142+
tasks = led.parse_plan_tasks(mixed)
143+
self.assertEqual(tasks, [(1, "heading form"), (2, "only bullet")])
144+
84145

85146
if __name__ == "__main__":
86147
unittest.main()
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Every parser of a Sage artifact, run against the TEMPLATE that produces it.
2+
3+
THE CLASS THIS KILLS. The ledger schism (2026-08-04): ledger.py parsed only
4+
`## Task N` headings — the E9 fixture's convention — while the plan template
5+
Sage generates writes `- [ ] **Task N:**` bullets. Green suite, broken
6+
product: the parser had been tested exclusively against fixture-authored
7+
data and had never once met Sage's own output. The first real --subagents
8+
cycle was the first meeting, and it failed.
9+
10+
So: the templates are the contract, and this suite is the handshake. Every
11+
consumer of plan.md / spec.md / the ledger block parses the ACTUAL template
12+
file (or an artifact scaffolded from it) and must find what the template
13+
promises. A parser change that breaks template conformance fails HERE, in
14+
fastcheck, before any field report. If you add a template or a parser, add
15+
its handshake.
16+
17+
Python 3.8+, stdlib only.
18+
"""
19+
import json
20+
import pathlib
21+
import re
22+
import shutil
23+
import subprocess
24+
import sys
25+
import tempfile
26+
import unittest
27+
28+
REPO = pathlib.Path(__file__).resolve().parents[3]
29+
sys.path.insert(0, str(REPO / "runtime" / "tools"))
30+
31+
import ledger as LED # noqa: E402
32+
import manifest as MAN # noqa: E402
33+
import scope_judge as SJ # noqa: E402
34+
35+
PLAN_TEMPLATE = REPO / "core" / "templates" / "plan" / "standard.plan-template.md"
36+
SPEC_TEMPLATES = (REPO / "core" / "templates" / "spec" / "full.spec-template.md",
37+
REPO / "core" / "templates" / "spec" / "minimal.spec-template.md")
38+
SPEC_GATE = (REPO / "runtime" / "platforms" / "claude-code" / "hooks"
39+
/ "sage-spec-gate.sh")
40+
41+
PLAN_TEXT = PLAN_TEMPLATE.read_text(encoding="utf-8")
42+
43+
44+
class PlanTemplateHandshake(unittest.TestCase):
45+
"""plan.md consumers × the plan template, verbatim."""
46+
47+
def test_ledger_parses_the_template_tasks(self):
48+
tasks = LED.parse_plan_tasks(PLAN_TEXT)
49+
self.assertGreaterEqual(len(tasks), 2,
50+
"the template ships a code task AND a DOC task")
51+
self.assertEqual(tasks[0][0], 1)
52+
self.assertNotIn("[DOC]", tasks[1][1],
53+
"[DOC] is presentation, not title")
54+
55+
def test_manifest_plan_tasks_sees_tasks_not_section_headings(self):
56+
d = pathlib.Path(tempfile.mkdtemp())
57+
self.addCleanup(shutil.rmtree, d, ignore_errors=True)
58+
(d / "plan.md").write_text(PLAN_TEXT)
59+
tasks = MAN.plan_tasks(d)
60+
self.assertTrue(tasks and all(t.startswith("Task ") for t in tasks),
61+
"bullet plans must yield tasks, not section headings "
62+
"(the /continue display printed 'Gate Log' as a task "
63+
"before 2026-08-04): %r" % tasks[:3])
64+
65+
def test_scope_judge_current_task_reads_the_template(self):
66+
d = pathlib.Path(tempfile.mkdtemp())
67+
self.addCleanup(shutil.rmtree, d, ignore_errors=True)
68+
(d / "plan.md").write_text(PLAN_TEXT)
69+
tid, title = SJ.current_task(d)
70+
self.assertEqual(tid, 1)
71+
block = SJ.current_task_block(d)
72+
self.assertIn("**Files:**", block,
73+
"the packet must carry the task's declaration lines")
74+
75+
def test_scope_derive_files_regex_matches_the_template_lines(self):
76+
files_lines = [l for l in PLAN_TEXT.splitlines()
77+
if MAN._FILES_LINE_RE.match(l)]
78+
self.assertGreaterEqual(
79+
len(files_lines), 2,
80+
"the template's `- **Files:**` and `- **Output:**` lines are what "
81+
"scope derive reads; if this regex stops matching them, every "
82+
"derived scope is empty")
83+
84+
85+
class SpecTemplateHandshake(unittest.TestCase):
86+
"""spec.md consumers × both spec templates."""
87+
88+
def test_judge_boundary_section_found_in_both_templates(self):
89+
for tpl in SPEC_TEMPLATES:
90+
d = pathlib.Path(tempfile.mkdtemp())
91+
self.addCleanup(shutil.rmtree, d, ignore_errors=True)
92+
(d / "spec.md").write_text(tpl.read_text(encoding="utf-8"))
93+
self.assertTrue(SJ.spec_boundary(d),
94+
"%s: the judge packet's boundary section came "
95+
"back empty — heading drifted?" % tpl.name)
96+
97+
98+
class LedgerRoundTrip(unittest.TestCase):
99+
"""The cross-language seam: ledger.py WRITES the tasks block, the
100+
spec-gate hook READS it (R101). Scaffolded from a template-form plan,
101+
driven through the real gate, both directions — the live probe from the
102+
2026-08-04 review, pinned."""
103+
104+
def setUp(self):
105+
self.root = pathlib.Path(tempfile.mkdtemp())
106+
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
107+
cyc = self.root / ".sage" / "work" / "001-x"
108+
cyc.mkdir(parents=True)
109+
(self.root / ".sage" / "config.yaml").write_text(
110+
"hard_enforcement: true\n")
111+
(cyc / "plan.md").write_text(
112+
"# Plan\n\n## Tasks\n\n"
113+
"- [ ] **Task 1:** one\n - **Files:** src/a.py\n"
114+
"- [ ] **Task 2:** two [DOC]\n - **Output:** docs/x.md\n")
115+
(cyc / "manifest.md").write_text(
116+
'---\ncycle_id: "001-x"\ngate_state: building\n'
117+
"status: in-progress\n---\n# x\n")
118+
self.cyc = cyc
119+
r = subprocess.run(
120+
[sys.executable, str(REPO / "runtime" / "tools" / "ledger.py"),
121+
"init", str(cyc / "manifest.md"), str(cyc / "plan.md")],
122+
capture_output=True, text=True)
123+
assert r.returncode == 0, r.stderr
124+
125+
def gate(self):
126+
payload = json.dumps({
127+
"tool_name": "Edit",
128+
"tool_input": {"file_path": str(self.cyc / "manifest.md"),
129+
"old_string": "gate_state: building",
130+
"new_string": "gate_state: gates-passed"},
131+
"cwd": str(self.root)})
132+
return subprocess.run(
133+
["bash", str(SPEC_GATE)], input=payload, capture_output=True,
134+
text=True, env={"PATH": "/usr/bin:/bin",
135+
"CLAUDE_PROJECT_DIR": str(self.root)}).returncode
136+
137+
def mark(self, status, review):
138+
m = self.cyc / "manifest.md"
139+
t = m.read_text()
140+
t = t.replace("status: pending", "status: %s" % status)
141+
t = t.replace("review: pending", "review: %s" % review)
142+
m.write_text(t)
143+
144+
def test_gate_blocks_then_allows_the_scaffolded_ledger(self):
145+
self.assertEqual(self.gate(), 2,
146+
"pending ledger must block gates-passed (R101)")
147+
self.mark("done", "approved")
148+
self.assertEqual(self.gate(), 0,
149+
"done+approved (with the model: field present) "
150+
"must pass — the gate parser tolerates the schema")
151+
152+
153+
if __name__ == "__main__":
154+
unittest.main(verbosity=2)

runtime/tools/ledger.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,39 @@
3838
import re
3939
import sys
4040

41-
TASK_RE = re.compile(r"^##\s*Task\s+(\d+)\s*[—:-]\s*(.+?)\s*$", re.M)
41+
# The plan template's canonical task form is a CHECKBOX BULLET —
42+
# `- [ ] **Task N:** title` (core/templates/plan/standard.plan-template.md)
43+
# — while `## Task N — title` headings are the convention the E9/E10
44+
# fixtures were authored in. The first shipped parser read only headings:
45+
# written against the fixture, never against the template, so the first
46+
# real `--subagents` run (field report 2026-08-04) could not scaffold a
47+
# ledger from a plan Sage itself had generated. Both forms parse now.
48+
TASK_RE = re.compile(
49+
r"^##\s*Task\s+(\d+)\s*[—:-]\s*(.+?)\s*$"
50+
r"|^\s*-\s*\[[ xX]\]\s*\*\*Task\s+(\d+)\s*:?\*\*:?\s*(.+?)\s*$",
51+
re.M)
4252

4353

4454
def parse_plan_tasks(plan_text):
45-
"""Every `## Task N — title` in the approved plan, in order.
55+
"""Every plan task, in document order — heading or checkbox-bullet form.
4656
4757
The plan is the contract the ledger must mirror. If the plan has three tasks
4858
and the ledger has two, the cycle is claiming a review of work it never
4959
listed — which is the failure the ledger exists to make impossible.
60+
61+
A checked bullet still belongs in the ledger (done ≠ independently
62+
reviewed); `[DOC]` markers are presentation, stripped from titles; a
63+
duplicate id keeps its first occurrence.
5064
"""
51-
return [(int(n), title.strip()) for n, title in TASK_RE.findall(plan_text)]
65+
tasks, seen = [], set()
66+
for m in TASK_RE.finditer(plan_text):
67+
n = int(m.group(1) or m.group(3))
68+
title = re.sub(r"\s*\[DOC\]\s*$", "", (m.group(2) or m.group(4)).strip())
69+
if n in seen:
70+
continue
71+
seen.add(n)
72+
tasks.append((n, title))
73+
return tasks
5274

5375

5476
def split_frontmatter(text):
@@ -94,9 +116,11 @@ def init(manifest_path, plan_path):
94116

95117
tasks = parse_plan_tasks(plan.read_text(encoding="utf-8"))
96118
if not tasks:
97-
print("✗ no `## Task N — title` headings found in %s.\n"
98-
" The ledger is generated from the plan's tasks; a plan with no "
99-
"parseable tasks cannot produce one." % plan, file=sys.stderr)
119+
print("✗ no plan tasks found in %s.\n"
120+
" Accepted forms: `- [ ] **Task N:** title` (the plan template's\n"
121+
" bullets) or `## Task N — title` headings. The ledger is\n"
122+
" generated from the plan's tasks; a plan with no parseable "
123+
"tasks cannot produce one." % plan, file=sys.stderr)
100124
return 1
101125

102126
text = manifest.read_text(encoding="utf-8")

runtime/tools/manifest.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -403,13 +403,19 @@ def _read(path: pathlib.Path) -> str:
403403

404404

405405
def plan_tasks(cycle_dir: pathlib.Path):
406-
"""The plan's task headings, verbatim. Falls back to every ## heading when the
407-
plan does not use the `## Task N` convention."""
406+
"""The plan's tasks: `## Task N` headings, else the plan template's
407+
canonical `- [ ] **Task N:**` checkbox bullets (which real plans use —
408+
the resume display was printing section headings for them, field report
409+
2026-08-04), else every ## heading as the last resort."""
408410
plan = cycle_dir / "plan.md"
409411
if not plan.is_file():
410412
return []
411413
text = _read(plan)
412414
tasks = re.findall(r"^##\s+(Task\b.*?)\s*$", text, re.M)
415+
if not tasks:
416+
tasks = ["Task %s: %s" % (n, t) for n, t in re.findall(
417+
r"^\s*-\s*\[[ xX]\]\s*\*\*Task\s+(\d+)\s*:?\*\*:?\s*(.+?)\s*$",
418+
text, re.M)]
413419
return tasks or re.findall(r"^##\s+(.*?)\s*$", text, re.M)
414420

415421

0 commit comments

Comments
 (0)