task-pipeline-skill 1.94.1 → 1.95.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 +40 -0
- package/CONTRIBUTING.md +32 -0
- package/HOW-IT-WORKS.md +4 -3
- package/README.md +18 -7
- package/SKILL-CARD.md +2 -2
- package/package.json +1 -1
- package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
- package/plugins/task-pipeline/agents/verifier.md +1 -1
- package/plugins/task-pipeline/commands/task-pipeline.md +3 -2
- package/plugins/task-pipeline/hooks/release-gate.sh +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +11 -9
- package/plugins/task-pipeline/skills/task-pipeline/references/artifacts.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/brainstorm.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/browser.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/companion-skills.md +3 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +3 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +19 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +12 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/spec.md +7 -5
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-00-intake.md +158 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-01-docs-study.md +10 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-02-brainstorm.md +52 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-03-spec.md +66 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-04-plan.md +11 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-05-dev.md +55 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-06-tests.md +11 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-07-lint-deploy.md +15 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-08-post-deploy.md +29 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-09-docs-wiki.md +45 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-10-acceptance.md +91 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +43 -482
- package/plugins/task-pipeline/skills/task-pipeline/references/verification.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/scripts/stage_checkpoint.py +156 -33
- package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +2 -2
|
@@ -10,9 +10,9 @@ run goes on exactly as before, and the ledger says memory was unavailable.
|
|
|
10
10
|
This script never talks to Observatory: the agent calls the tool. The script does the two
|
|
11
11
|
deterministic halves around that call, so nothing about the checkpoint is left to recall:
|
|
12
12
|
|
|
13
|
-
stage_checkpoint.py emit [--ledger PATH] [--project project:<slug>]
|
|
13
|
+
stage_checkpoint.py emit [--ledger PATH] [--project-root PATH] [--project project:<slug>]
|
|
14
14
|
[--constraint TEXT ...] [--credential PROJECT/ENV/NAME ...]
|
|
15
|
-
prints the tool's arguments as JSON, from the ledger
|
|
15
|
+
prints the tool's arguments as JSON, from the ledger and selected project profile
|
|
16
16
|
stage_checkpoint.py record --answer FILE|- [--ledger PATH]
|
|
17
17
|
reads the tool's answer and keeps `workflowId` and `leaseId` for the next boundary
|
|
18
18
|
stage_checkpoint.py record --unavailable "<reason>" [--for-run] [--ledger PATH]
|
|
@@ -24,13 +24,17 @@ deterministic halves around that call, so nothing about the checkpoint is left t
|
|
|
24
24
|
|
|
25
25
|
What it keeps, and where:
|
|
26
26
|
|
|
27
|
-
- The
|
|
27
|
+
- The ledger supplies the observed verdicts; the selected project `pipeline.json` supplies
|
|
28
|
+
stage order and names (bundled default only when project config is absent). The run's topic is the goal, the
|
|
28
29
|
ledger's verdicts are `done`, the next stage is `open`, the operator's constraints come in
|
|
29
30
|
as `--constraint`, keys are passed by NAME only (`--credential`), and the git checkout is
|
|
30
31
|
an artifact (branch and a 12-character head). Observatory redacts what it stores as well.
|
|
31
|
-
- `idempotencyKey` is derived from the run's topic and the exact `stage:` line
|
|
32
|
+
- `idempotencyKey` is derived from the run's topic and the exact `stage:` line (also the
|
|
33
|
+
profile digest for a configured profile), so a retry
|
|
32
34
|
of the same boundary replays the first answer, and a stage that runs again (a new line, a
|
|
33
35
|
new time) is a new checkpoint.
|
|
36
|
+
- The first emit saves a run-scoped profile binding before returning arguments. A changed
|
|
37
|
+
profile/run or unreadable saved state refuses instead of switching an existing workflow.
|
|
34
38
|
- `workflowId` and `leaseId` live in `.task-pipeline/memory.json`, mode 0600, beside the
|
|
35
39
|
ledger in the git-ignored run directory, with the constraints and key names given so far:
|
|
36
40
|
they are stated once and carried to every later boundary. The lease token is a write right: it is never
|
|
@@ -60,6 +64,7 @@ import json
|
|
|
60
64
|
import os
|
|
61
65
|
import pathlib
|
|
62
66
|
import re
|
|
67
|
+
import stat
|
|
63
68
|
import subprocess
|
|
64
69
|
import sys
|
|
65
70
|
from datetime import datetime, timezone
|
|
@@ -80,15 +85,70 @@ MEMORY_EVENT = re.compile(r"^event:\s*memory\s+—\s")
|
|
|
80
85
|
MEMORY_UNAVAILABLE = re.compile(r"^event:\s*memory\s+—\s+unavailable\b")
|
|
81
86
|
|
|
82
87
|
|
|
83
|
-
def
|
|
84
|
-
"""
|
|
85
|
-
|
|
86
|
-
path = pathlib.Path(__file__).resolve().parents[1] / "pipeline.example.json"
|
|
88
|
+
def _profile(root: pathlib.Path) -> tuple[list[dict], dict]:
|
|
89
|
+
"""Admit the selected profile for the numeric checkpoint ledger, not execute gates."""
|
|
90
|
+
path = root / "pipeline.json"
|
|
87
91
|
try:
|
|
92
|
+
path.lstat()
|
|
93
|
+
configured = True
|
|
94
|
+
except FileNotFoundError:
|
|
95
|
+
configured = False
|
|
96
|
+
except OSError:
|
|
97
|
+
raise SystemExit("stage_checkpoint: selected profile cannot be read")
|
|
98
|
+
if not configured:
|
|
99
|
+
path = pathlib.Path(__file__).resolve().parents[1] / "pipeline.example.json"
|
|
100
|
+
try:
|
|
101
|
+
if not stat.S_ISREG(path.stat().st_mode):
|
|
102
|
+
raise SystemExit("stage_checkpoint: selected profile is not a regular file")
|
|
88
103
|
doc = json.loads(path.read_text(encoding="utf-8"))
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
104
|
+
except (OSError, ValueError, UnicodeError):
|
|
105
|
+
raise SystemExit("stage_checkpoint: selected profile cannot be read as JSON")
|
|
106
|
+
if not isinstance(doc, dict) or not isinstance(doc.get("stages"), list) or not doc["stages"]:
|
|
107
|
+
raise SystemExit("stage_checkpoint: selected profile needs a nonempty stages array")
|
|
108
|
+
mandatory = doc.get("must_understand", [])
|
|
109
|
+
if not isinstance(mandatory, list) or mandatory:
|
|
110
|
+
raise SystemExit("stage_checkpoint: unsupported mandatory profile capabilities")
|
|
111
|
+
stages, ids, states = [], set(), set()
|
|
112
|
+
for ordinal, stage in enumerate(doc["stages"]):
|
|
113
|
+
if not isinstance(stage, dict):
|
|
114
|
+
raise SystemExit("stage_checkpoint: malformed selected stage")
|
|
115
|
+
sid, state = stage.get("id", ordinal), stage.get("state")
|
|
116
|
+
name = stage.get("name", state)
|
|
117
|
+
if isinstance(name, str) and not name.strip():
|
|
118
|
+
name = state # optional human label may be empty; stable state still identifies it
|
|
119
|
+
skills, gate = stage.get("skills"), stage.get("gate")
|
|
120
|
+
if (type(sid) is not int or sid < 0 or sid in ids or
|
|
121
|
+
not isinstance(state, str) or not state.strip() or state in states or
|
|
122
|
+
not isinstance(name, str) or not name.strip() or "\n" in name or "\r" in name or
|
|
123
|
+
not isinstance(skills, list) or not skills or
|
|
124
|
+
any(not isinstance(v, str) or not v.strip() for v in skills) or
|
|
125
|
+
not isinstance(gate, dict) or gate.get("type") not in ("auto", "manual", "judgment") or
|
|
126
|
+
not isinstance(gate.get("check"), str) or not gate["check"].strip() or
|
|
127
|
+
(gate["type"] == "judgment" and
|
|
128
|
+
(not isinstance(gate.get("judge"), str) or not gate["judge"].strip()))):
|
|
129
|
+
raise SystemExit("stage_checkpoint: malformed or ambiguous selected stage")
|
|
130
|
+
ids.add(sid)
|
|
131
|
+
states.add(state)
|
|
132
|
+
stages.append({"id": sid, "name": name, "gate": gate["type"]})
|
|
133
|
+
semantic = {k: doc[k] for k in ("stages", "kernel", "must_understand") if k in doc}
|
|
134
|
+
identity = {"root": str(root), "source": "pipeline.json" if configured else "bundled-default",
|
|
135
|
+
"digest": hashlib.sha256(json.dumps(semantic, sort_keys=True,
|
|
136
|
+
ensure_ascii=False).encode()).hexdigest()}
|
|
137
|
+
return stages, identity
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _root(ledger: pathlib.Path, given: str | None) -> pathlib.Path:
|
|
141
|
+
if given:
|
|
142
|
+
root = pathlib.Path(given).resolve()
|
|
143
|
+
if not root.is_dir() or not ledger.is_relative_to(root):
|
|
144
|
+
raise SystemExit("stage_checkpoint: --project-root must contain the ledger")
|
|
145
|
+
return root
|
|
146
|
+
if ledger.parent.name == ".task-pipeline":
|
|
147
|
+
return ledger.parent.parent
|
|
148
|
+
git_root = _git(ledger.parent, "rev-parse", "--show-toplevel")
|
|
149
|
+
if not git_root:
|
|
150
|
+
raise SystemExit("stage_checkpoint: relocated ledger requires --project-root")
|
|
151
|
+
return pathlib.Path(git_root).resolve()
|
|
92
152
|
|
|
93
153
|
|
|
94
154
|
def _now() -> str:
|
|
@@ -145,6 +205,8 @@ def _read_ledger(path: pathlib.Path) -> tuple[str, list[tuple]]:
|
|
|
145
205
|
topic, stages = (TOPIC.match(rl).group(1) if rl else None), []
|
|
146
206
|
for line in lines:
|
|
147
207
|
s = STAGE.match(line)
|
|
208
|
+
if line.startswith("stage:") and not s:
|
|
209
|
+
raise SystemExit("stage_checkpoint: malformed stage ledger line")
|
|
148
210
|
if s:
|
|
149
211
|
stages.append((int(s.group(1)), s.group(2), s.group(3), s.group(4), s.group(5), line))
|
|
150
212
|
if not topic:
|
|
@@ -176,14 +238,48 @@ def marker_for_this_run(ledger: pathlib.Path, state: dict) -> dict | None:
|
|
|
176
238
|
return gone
|
|
177
239
|
|
|
178
240
|
|
|
241
|
+
def _canonical_ledger(ledger: pathlib.Path) -> pathlib.Path:
|
|
242
|
+
# A file alias can put a prior record beside the link while emit read its target.
|
|
243
|
+
# Refuse it rather than choosing one of two potentially live workflow receipts.
|
|
244
|
+
try:
|
|
245
|
+
mode = ledger.lstat().st_mode
|
|
246
|
+
except FileNotFoundError:
|
|
247
|
+
return ledger.resolve()
|
|
248
|
+
except OSError:
|
|
249
|
+
raise SystemExit("stage_checkpoint: ledger cannot be read")
|
|
250
|
+
if stat.S_ISLNK(mode):
|
|
251
|
+
raise SystemExit("stage_checkpoint: ledger file alias is unsupported; reconcile and use the real ledger path")
|
|
252
|
+
if not stat.S_ISREG(mode):
|
|
253
|
+
raise SystemExit("stage_checkpoint: ledger is not a regular file")
|
|
254
|
+
return ledger.resolve()
|
|
255
|
+
|
|
256
|
+
|
|
179
257
|
def _state_path(ledger: pathlib.Path) -> pathlib.Path:
|
|
180
|
-
|
|
258
|
+
path = _canonical_ledger(ledger).parent / STATE_NAME
|
|
259
|
+
try:
|
|
260
|
+
mode = path.lstat().st_mode
|
|
261
|
+
except FileNotFoundError:
|
|
262
|
+
return path
|
|
263
|
+
except OSError:
|
|
264
|
+
raise SystemExit("stage_checkpoint: memory state cannot be read")
|
|
265
|
+
if stat.S_ISLNK(mode):
|
|
266
|
+
raise SystemExit("stage_checkpoint: memory state link is unsafe; reconcile it before continuing")
|
|
267
|
+
if not stat.S_ISREG(mode):
|
|
268
|
+
raise SystemExit("stage_checkpoint: memory state is not a regular file")
|
|
269
|
+
return path
|
|
181
270
|
|
|
182
271
|
|
|
183
|
-
def _load_state(ledger: pathlib.Path) -> dict:
|
|
272
|
+
def _load_state(ledger: pathlib.Path, strict: bool = False) -> dict:
|
|
184
273
|
try:
|
|
185
|
-
|
|
186
|
-
|
|
274
|
+
state = json.loads(_state_path(ledger).read_text(encoding="utf-8"))
|
|
275
|
+
if not isinstance(state, dict):
|
|
276
|
+
raise ValueError("state is not an object")
|
|
277
|
+
return state
|
|
278
|
+
except FileNotFoundError:
|
|
279
|
+
return {}
|
|
280
|
+
except (OSError, ValueError, UnicodeError):
|
|
281
|
+
if strict:
|
|
282
|
+
raise SystemExit("stage_checkpoint: memory state cannot be read")
|
|
187
283
|
return {}
|
|
188
284
|
|
|
189
285
|
|
|
@@ -224,14 +320,28 @@ def _project(root: pathlib.Path, given: str | None) -> str:
|
|
|
224
320
|
|
|
225
321
|
|
|
226
322
|
def emit(ledger: pathlib.Path, project: str | None, constraints: list[str],
|
|
227
|
-
credentials: list[str]) -> dict:
|
|
323
|
+
credentials: list[str], project_root: str | None = None) -> dict:
|
|
324
|
+
ledger = _canonical_ledger(ledger)
|
|
228
325
|
topic, stages = _read_ledger(ledger)
|
|
229
|
-
|
|
326
|
+
root = _root(ledger, project_root)
|
|
327
|
+
profile, identity = _profile(root)
|
|
328
|
+
names = {s["id"]: s["name"] for s in profile}
|
|
230
329
|
if not stages:
|
|
231
330
|
raise SystemExit("stage_checkpoint: no `stage:` line yet — a checkpoint follows a gate")
|
|
232
331
|
sid, name, gate, verdict, at, line = stages[-1]
|
|
233
|
-
|
|
234
|
-
|
|
332
|
+
state = _load_state(ledger, strict=True)
|
|
333
|
+
run = run_key(ledger.read_text(encoding="utf-8").splitlines())
|
|
334
|
+
binding = {**identity, "run": run}
|
|
335
|
+
previous = state.get("profile")
|
|
336
|
+
if previous is None and state.get("workflowId") and identity["source"] != "bundled-default":
|
|
337
|
+
raise SystemExit("stage_checkpoint: legacy workflow has no selected-profile binding; reconcile before emitting")
|
|
338
|
+
if previous is not None and previous != binding:
|
|
339
|
+
raise SystemExit("stage_checkpoint: selected profile or run changed; reconcile the saved workflow before emitting")
|
|
340
|
+
for observed in stages:
|
|
341
|
+
selected = next((s for s in profile if s["id"] == observed[0]), None)
|
|
342
|
+
if selected is None or (identity["source"] != "bundled-default" and
|
|
343
|
+
(selected["name"] != observed[1] or selected["gate"] != observed[2])):
|
|
344
|
+
raise SystemExit("stage_checkpoint: ledger stage does not match the selected profile")
|
|
235
345
|
# CONSTRAINTS AND KEYS OUTLIVE THE BOUNDARY THEY WERE GIVEN AT. The operator states them
|
|
236
346
|
# once; a later boundary that dropped them would hand a successor an empty list — the
|
|
237
347
|
# one thing a successor must never act without (found by the live receipt, 2026-10-05).
|
|
@@ -241,12 +351,18 @@ def emit(ledger: pathlib.Path, project: str | None, constraints: list[str],
|
|
|
241
351
|
done = [{"step_id": f"stage-{s[0]}", "result": f"{s[1]}: gate {s[2]}, verdict {s[3]} at {s[4]}",
|
|
242
352
|
"evidence": [f"ledger: {ledger.name}", *reviews.get(s[0], [])]}
|
|
243
353
|
for s in stages if s[3] in ("pass", "skip")]
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
354
|
+
position = next(i for i, s in enumerate(profile) if s["id"] == sid)
|
|
355
|
+
terminal = position == len(profile) - 1
|
|
356
|
+
closed = terminal and verdict == "pass"
|
|
357
|
+
if closed:
|
|
358
|
+
open_steps = []
|
|
359
|
+
elif verdict in ("pass", "skip") and not terminal:
|
|
360
|
+
nxt = profile[position + 1]["id"]
|
|
361
|
+
open_steps = [{"step_id": f"stage-{nxt}", "next_action": f"enter stage {nxt} {names[nxt]}"}]
|
|
362
|
+
else:
|
|
363
|
+
action = (f"confirm stage {sid} {name}: final stage was skipped" if verdict == "skip"
|
|
364
|
+
else f"repair stage {sid} {name}: its gate did not pass")
|
|
365
|
+
open_steps = [{"step_id": f"stage-{sid}", "next_action": action}]
|
|
250
366
|
creds = []
|
|
251
367
|
for c in credentials:
|
|
252
368
|
parts = c.split("/")
|
|
@@ -258,21 +374,26 @@ def emit(ledger: pathlib.Path, project: str | None, constraints: list[str],
|
|
|
258
374
|
if head:
|
|
259
375
|
artifacts.append({"kind": "git", "path": str(root), "branch": branch or "", "head": head[:12]})
|
|
260
376
|
body = {"goal": topic,
|
|
261
|
-
"plan": [{"step_id": f"stage-{i}", "title": names[i]} for i in
|
|
377
|
+
"plan": [{"step_id": f"stage-{i}", "title": names[i]} for i in names],
|
|
262
378
|
"done": done, "open": open_steps, "constraints": constraints,
|
|
263
379
|
"artifacts": artifacts, "notes": f"task-pipeline stage boundary; run ledger {ledger}"}
|
|
264
380
|
if creds:
|
|
265
381
|
body["credentials"] = creds
|
|
382
|
+
key = f"{topic}\n{line}"
|
|
383
|
+
if identity["source"] != "bundled-default":
|
|
384
|
+
key += "\n" + identity["digest"]
|
|
266
385
|
args = {"owner": OWNER,
|
|
267
|
-
"idempotencyKey": "tp-" + hashlib.sha256(
|
|
386
|
+
"idempotencyKey": "tp-" + hashlib.sha256(key.encode()).hexdigest()[:32],
|
|
268
387
|
"stepId": f"stage-{sid}", "status": STATUS.get(verdict, "in_progress"), "body": body,
|
|
269
|
-
"close":
|
|
388
|
+
"close": closed}
|
|
270
389
|
if state.get("workflowId") and state.get("leaseId"):
|
|
271
390
|
args["workflowId"], args["leaseId"] = state["workflowId"], state["leaseId"]
|
|
272
391
|
else:
|
|
273
392
|
args["projectId"] = _project(root, project)
|
|
274
|
-
if
|
|
275
|
-
|
|
393
|
+
if (previous != binding or constraints != state.get("constraints", []) or
|
|
394
|
+
credentials != state.get("credentials", [])):
|
|
395
|
+
_save_state(ledger, {**state, "constraints": constraints, "credentials": credentials,
|
|
396
|
+
"profile": binding})
|
|
276
397
|
return args
|
|
277
398
|
|
|
278
399
|
|
|
@@ -344,7 +465,7 @@ def record(ledger: pathlib.Path, answer_text: str | None, unavailable: str | Non
|
|
|
344
465
|
lease = answer.get("leaseId") or state.get("leaseId")
|
|
345
466
|
if not wid:
|
|
346
467
|
raise SystemExit("stage_checkpoint: the answer names no workflowId")
|
|
347
|
-
_save_state(ledger, {**{k: v for k, v in state.items() if k in ("constraints", "credentials")},
|
|
468
|
+
_save_state(ledger, {**{k: v for k, v in state.items() if k in ("constraints", "credentials", "profile")},
|
|
348
469
|
"workflowId": wid, **({"leaseId": lease} if lease else {})})
|
|
349
470
|
rev = (answer.get("checkpoint") or {}).get("revision") or answer.get("revision") or "?"
|
|
350
471
|
step = answer.get("stepId") or "?"
|
|
@@ -406,6 +527,7 @@ def main(argv: list[str]) -> int:
|
|
|
406
527
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
407
528
|
e = sub.add_parser("emit", help="print the checkpoint tool's arguments")
|
|
408
529
|
e.add_argument("--ledger", default=DEFAULT_LEDGER)
|
|
530
|
+
e.add_argument("--project-root", help="explicit project root for a relocated ledger")
|
|
409
531
|
e.add_argument("--project", help="project:<slug>; default the checkout's folder name")
|
|
410
532
|
e.add_argument("--constraint", action="append", default=[], help="repeatable")
|
|
411
533
|
e.add_argument("--credential", action="append", default=[], help="PROJECT/ENV/NAME, repeatable")
|
|
@@ -430,9 +552,10 @@ def main(argv: list[str]) -> int:
|
|
|
430
552
|
ac.add_argument("--handoff-id", required=True)
|
|
431
553
|
ac.add_argument("--session")
|
|
432
554
|
a = p.parse_args(argv)
|
|
433
|
-
ledger = pathlib.Path(a.ledger)
|
|
555
|
+
ledger = _canonical_ledger(pathlib.Path(a.ledger))
|
|
556
|
+
_state_path(ledger) # every command refuses unsafe aliases before producing tool arguments
|
|
434
557
|
if a.cmd == "emit":
|
|
435
|
-
print(json.dumps(emit(ledger, a.project, a.constraint, a.credential), ensure_ascii=False))
|
|
558
|
+
print(json.dumps(emit(ledger, a.project, a.constraint, a.credential, a.project_root), ensure_ascii=False))
|
|
436
559
|
return 0
|
|
437
560
|
if a.cmd == "handoff":
|
|
438
561
|
print(json.dumps(handoff(ledger, a.to_provider, a.to_model, a.reason, a.offer_ttl),
|
|
@@ -20,7 +20,7 @@ Run: `<topic>` · started `<YYYY-MM-DD>` · module map: `<path or "none">`
|
|
|
20
20
|
|
|
21
21
|
## `read:` — which doctrine this run actually opened
|
|
22
22
|
|
|
23
|
-
The bundle is
|
|
23
|
+
The bundle is 52 reference files and nothing recorded which of them a run read, so **a
|
|
24
24
|
skipped file and a read one were indistinguishable** — the class every guard in this
|
|
25
25
|
pipeline exists to catch, left standing over the doctrine itself.
|
|
26
26
|
|
|
@@ -48,7 +48,7 @@ same claim one level down.
|
|
|
48
48
|
|---|---|---|
|
|
49
49
|
| `unmeasured — no run ledger` | there is no ledger | nothing to read from |
|
|
50
50
|
| `unmeasured — the ledger carries no read: lines` | the hook is absent, **or** the run opened no doctrine | two opposite facts, and the ledger cannot separate them, so neither is claimed |
|
|
51
|
-
| `N of
|
|
51
|
+
| `N of 52 reference files read — unattested`, then each unread one | the hook is installed and fired | the count alone says there is a gap, not where — and `unattested` says the ledger cannot name who wrote the lines |
|
|
52
52
|
|
|
53
53
|
**It is a disclosure: no floor, no direction, never a target.** A run that needs four files
|
|
54
54
|
and reads four is not worse than one that reads thirty — and the moment the number becomes
|