task-pipeline-skill 1.85.2 → 1.86.3

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 (57) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/CONTRIBUTING.md +2 -2
  3. package/README.md +2 -1
  4. package/SKILL-CARD.md +1 -1
  5. package/bin/task-pipeline.js +70 -9
  6. package/evals/cases/evidence-docs.json +188 -0
  7. package/evals/cases/project-audit.json +188 -0
  8. package/evals/cases/task-pipeline.json +191 -0
  9. package/package.json +5 -4
  10. package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
  11. package/plugins/task-pipeline/skills/evidence-docs/SKILL.md +16 -11
  12. package/plugins/task-pipeline/skills/evidence-docs/references/GENERATED.md +8 -0
  13. package/plugins/task-pipeline/skills/evidence-docs/references/documentation.md +472 -0
  14. package/plugins/task-pipeline/skills/evidence-docs/references/gates.md +645 -0
  15. package/plugins/task-pipeline/skills/evidence-docs/references/hooks.md +279 -0
  16. package/plugins/task-pipeline/skills/evidence-docs/references/learned.md +292 -0
  17. package/plugins/task-pipeline/skills/evidence-docs/references/retrospective.md +551 -0
  18. package/plugins/task-pipeline/skills/evidence-docs/references/setup.md +149 -0
  19. package/plugins/task-pipeline/skills/evidence-docs/templates/decisions.md +50 -0
  20. package/plugins/task-pipeline/skills/evidence-docs/templates/docgate.sh +537 -0
  21. package/plugins/task-pipeline/skills/project-audit/SKILL.md +66 -25
  22. package/plugins/task-pipeline/skills/project-audit/scripts/audit.py +11 -0
  23. package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +71 -54
  24. package/plugins/task-pipeline/skills/task-pipeline/execution-attempt.schema.json +68 -0
  25. package/plugins/task-pipeline/skills/task-pipeline/execution-packet.example.json +42 -0
  26. package/plugins/task-pipeline/skills/task-pipeline/execution-packet.schema.json +217 -0
  27. package/plugins/task-pipeline/skills/task-pipeline/execution-result.example.json +49 -0
  28. package/plugins/task-pipeline/skills/task-pipeline/execution-result.schema.json +261 -0
  29. package/plugins/task-pipeline/skills/task-pipeline/graph.example.json +10 -1
  30. package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +172 -2
  31. package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +50 -1
  32. package/plugins/task-pipeline/skills/task-pipeline/references/acceptance.md +7 -0
  33. package/plugins/task-pipeline/skills/task-pipeline/references/artifacts.md +23 -0
  34. package/plugins/task-pipeline/skills/task-pipeline/references/audit.md +6 -0
  35. package/plugins/task-pipeline/skills/task-pipeline/references/backlog.md +8 -1
  36. package/plugins/task-pipeline/skills/task-pipeline/references/browser.md +8 -0
  37. package/plugins/task-pipeline/skills/task-pipeline/references/build.md +32 -0
  38. package/plugins/task-pipeline/skills/task-pipeline/references/companion-skills.md +14 -3
  39. package/plugins/task-pipeline/skills/task-pipeline/references/decomposition.md +83 -2
  40. package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +53 -0
  41. package/plugins/task-pipeline/skills/task-pipeline/references/documentation.md +3 -0
  42. package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +27 -8
  43. package/plugins/task-pipeline/skills/task-pipeline/references/hooks.md +10 -5
  44. package/plugins/task-pipeline/skills/task-pipeline/references/model-tiering.md +19 -0
  45. package/plugins/task-pipeline/skills/task-pipeline/references/planning.md +203 -26
  46. package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
  47. package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +26 -8
  48. package/plugins/task-pipeline/skills/task-pipeline/references/work-graph.md +7 -1
  49. package/plugins/task-pipeline/skills/task-pipeline/scripts/context_packets.py +686 -0
  50. package/plugins/task-pipeline/skills/task-pipeline/scripts/execution_authority.py +271 -0
  51. package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +415 -18
  52. package/plugins/task-pipeline/skills/task-pipeline/scripts/packet.py +400 -0
  53. package/plugins/task-pipeline/skills/task-pipeline/templates/README.md +2 -0
  54. package/plugins/task-pipeline/skills/task-pipeline/templates/browser-claims.json +54 -0
  55. package/plugins/task-pipeline/skills/task-pipeline/templates/finding-evidence.json +42 -0
  56. package/plugins/task-pipeline/skills/task-pipeline/templates/hooks.example.json +2 -2
  57. package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +2 -2
@@ -0,0 +1,686 @@
1
+ #!/usr/bin/env python3
2
+ """Finding → parent mapper — the first stage of the packet compiler (CTX-02.01).
3
+
4
+ An audit report is a list of findings; a plan is a list of parent tasks. This
5
+ stage turns one into the other WITHOUT losing anything on the way, and its
6
+ contract is three sentences:
7
+
8
+ * **The id is derived, never invented.** A finding `RT-01` maps to parent
9
+ `FIX-RT-01` on every compile, whatever order the report arrives in — a
10
+ positional id renumbers when a row is inserted, and a renumbered id orphans
11
+ every receipt that cited the old one. Capability requests (work the user
12
+ asked for that no finding demanded) carry their own explicit ids.
13
+ * **Evidence, limits and priority are separate fields from status.** A parent
14
+ is born `parent_planned` with `revision` 1; nothing a finding carries can
15
+ smuggle a status or a revision in — approval, fact and outcome do not
16
+ promote each other (parent decision D03/D04).
17
+ * **No finding is dropped, silently or otherwise.** A row the mapper cannot
18
+ map is a named problem that blocks the WHOLE compile — a partial plan that
19
+ quietly lost two findings reads exactly like a complete one.
20
+
21
+ python3 scripts/context_packets.py compile <report.json> # parents JSON on stdout
22
+ python3 scripts/context_packets.py verify <report.json> <parents.json>
23
+
24
+ `compile` is deterministic: same corpus → byte-identical output (sorted keys,
25
+ sorted parents, stable separators), so a repeated compile can be compared with
26
+ `diff` and a receipt can pin the plan by digest. `verify` recompiles and
27
+ refuses a parents file that dropped, duplicated or mutated a mapping.
28
+
29
+ The SECOND stage (CTX-02.02) compiles one parent + one outcome slice into a
30
+ dispatchable leaf packet, and its contract is the COLD READER's: the packet
31
+ alone, with no author history, must answer eight questions — goal, inputs,
32
+ decisions, scope, outputs, acceptance, guards, resume. A broad parent with an
33
+ unresolved decision dispatches NO leaf; a missing version or output contract
34
+ fails readiness; acceptance carries at least one positive and one negative
35
+ case; a budget cuts appendix material only and RECORDS the cut, never the
36
+ acceptance; and a slice is selected by explicit id — never by mtime, because
37
+ "newest file" is an authority nobody granted. Neither a design flow nor a
38
+ .design/TASKS.md becomes a parallel plan authority: leaves come from the plan
39
+ through this compiler or they are not leaves.
40
+
41
+ python3 scripts/context_packets.py compile-leaf <parent.json> <slice.json>
42
+ python3 scripts/context_packets.py readiness <leaf.json>
43
+
44
+ The THIRD stage (CTX-02.03) moves a leaf between machines as a
45
+ content-addressed bundle: relative locators and digests only — an absolute
46
+ path is a fact about the author's machine, a credential file is a leak, and
47
+ both are refused at export. Import verifies every blob against the manifest
48
+ BEFORE writing anything (all or nothing), then materializes the same bytes
49
+ under whatever root the recipient has: two imports on two roots are
50
+ byte-identical, and a missing or corrupt blob rejects the whole bundle by
51
+ name.
52
+
53
+ python3 scripts/context_packets.py export-bundle <leaf.json> <src_root> <out_dir>
54
+ python3 scripts/context_packets.py import-bundle <bundle_dir> <dest_root>
55
+
56
+ The FOURTH stage (CTX-02.04) is the PRE-DISPATCH check — the last gate before
57
+ a leaf is claimed and worked. It re-verifies every input's digest against the
58
+ bytes on disk NOW (source drift blocks — the plan was made against other
59
+ bytes), confirms each prerequisite output has been materialized (a missing
60
+ upstream blocks), enforces the configured PRIMARY context budget (an oversized
61
+ mandatory context blocks; the budget cuts appendix, never primary, and a
62
+ breach is NEVER a silent truncation), and checks the declared capability and
63
+ resource ownership. Any failure blocks the claim and names itself; nothing is
64
+ trimmed to fit.
65
+
66
+ python3 scripts/context_packets.py predispatch <leaf.json> <root> [--capabilities cap,cap] [--produced id,id]
67
+
68
+ Python stdlib only, like every validator here.
69
+ """
70
+ import hashlib
71
+ import json
72
+ import os
73
+ import re
74
+ import sys
75
+
76
+ SCHEMA_VERSION = "audit-plan/1"
77
+ PARENT_PREFIX = "FIX-"
78
+ BORN_STATUS = "parent_planned"
79
+ SMUGGLED = ("status", "revision", "dispatch_state")
80
+ COPIED = ("title", "module", "priority", "priority_rank", "priority_reason",
81
+ "evidence", "limits")
82
+ REQUIRED = ("id", "title", "module", "priority")
83
+
84
+
85
+ def _row_problems(row, where, need_explicit_id):
86
+ out = []
87
+ if not isinstance(row, dict):
88
+ return [f"{where}: not an object"]
89
+ for field in REQUIRED:
90
+ if not row.get(field):
91
+ out.append(f"{where}: missing {field} — an unmappable row is a "
92
+ "problem, never a silent drop")
93
+ for field in SMUGGLED:
94
+ if field in row:
95
+ out.append(f"{where}: carries `{field}` — status axes are separate "
96
+ "fields the mapper assigns, a report cannot smuggle one")
97
+ if need_explicit_id and isinstance(row.get("id"), str) \
98
+ and row["id"].startswith(PARENT_PREFIX):
99
+ out.append(f"{where}: capability id {row['id']!r} collides with the "
100
+ f"derived `{PARENT_PREFIX}*` namespace findings own")
101
+ return out
102
+
103
+
104
+ def _parent(row, parent_id, finding_id):
105
+ p = {"schema_version": SCHEMA_VERSION, "id": parent_id,
106
+ "finding_id": finding_id, "revision": 1, "status": BORN_STATUS}
107
+ for field in COPIED:
108
+ if field in row:
109
+ p[field] = row[field]
110
+ return p
111
+
112
+
113
+ def compile_parents(report):
114
+ """Map a report to parents. Returns (result, problems) — a non-empty
115
+ problems list means NO parents were produced: all or nothing."""
116
+ problems = []
117
+ if not isinstance(report, dict):
118
+ return None, ["report: not an object"]
119
+ findings = report.get("findings", [])
120
+ capabilities = report.get("capabilities", [])
121
+ for name, rows in (("findings", findings), ("capabilities", capabilities)):
122
+ if not isinstance(rows, list):
123
+ problems.append(f"report.{name}: not a list")
124
+ if problems:
125
+ return None, problems
126
+
127
+ parents, seen, sources = [], {}, {}
128
+ for i, row in enumerate(findings):
129
+ where = f"findings[{i}]"
130
+ rp = _row_problems(row, where, need_explicit_id=False)
131
+ if rp:
132
+ problems.extend(rp)
133
+ continue
134
+ pid = PARENT_PREFIX + str(row["id"])
135
+ if pid in seen:
136
+ problems.append(f"{where}: finding id {row['id']!r} already mapped "
137
+ f"from {seen[pid]} — a duplicate id is two claims "
138
+ "to one receipt")
139
+ continue
140
+ seen[pid] = where
141
+ sources[pid] = str(row["id"])
142
+ parents.append(_parent(row, pid, str(row["id"])))
143
+ for i, row in enumerate(capabilities):
144
+ where = f"capabilities[{i}]"
145
+ rp = _row_problems(row, where, need_explicit_id=True)
146
+ if rp:
147
+ problems.extend(rp)
148
+ continue
149
+ pid = str(row["id"])
150
+ if pid in seen:
151
+ problems.append(f"{where}: id {pid!r} already mapped from {seen[pid]}")
152
+ continue
153
+ seen[pid] = where
154
+ sources[pid] = None
155
+ parents.append(_parent(row, pid, None))
156
+ if problems:
157
+ return None, problems
158
+
159
+ parents.sort(key=lambda p: p["id"])
160
+ return {"schema_version": "development-plan/3",
161
+ "parents": parents,
162
+ "trace": {p["id"]: sources[p["id"]] for p in parents}}, []
163
+
164
+
165
+ def canon(obj):
166
+ return json.dumps(obj, ensure_ascii=False, sort_keys=True,
167
+ separators=(",", ":")) + "\n"
168
+
169
+
170
+ def verify_parents(report, plan):
171
+ """Recompile and compare: every original parent preserved, none invented,
172
+ none mutated. Returns a list of problems, empty when faithful."""
173
+ fresh, problems = compile_parents(report)
174
+ if problems:
175
+ return [f"the report itself does not compile: {p}" for p in problems]
176
+ if not isinstance(plan, dict) or not isinstance(plan.get("parents"), list):
177
+ return ["parents file: no parents list"]
178
+ got = {p.get("id"): p for p in plan["parents"] if isinstance(p, dict)}
179
+ want = {p["id"]: p for p in fresh["parents"]}
180
+ out = []
181
+ if len(got) != len(plan["parents"]):
182
+ out.append("parents file: duplicate or malformed parent ids")
183
+ for pid in sorted(set(want) - set(got)):
184
+ out.append(f"{pid}: dropped — its finding is still in the report")
185
+ for pid in sorted(set(got) - set(want)):
186
+ out.append(f"{pid}: present in the plan but derived from no finding or "
187
+ "declared capability")
188
+ for pid in sorted(set(want) & set(got)):
189
+ if canon(want[pid]) != canon(got[pid]):
190
+ out.append(f"{pid}: mutated relative to a faithful compile")
191
+ return out
192
+
193
+
194
+ LEAF_SCHEMA = "execution-packet/1"
195
+ COLD_READER_QUESTIONS = ("goal", "inputs", "decisions", "scope", "outputs",
196
+ "acceptance", "guards", "resume")
197
+ SHA_RE_STR = r"^[0-9a-f]{64}$"
198
+
199
+
200
+ def _bound(ref):
201
+ import re as _re
202
+ return (isinstance(ref, dict) and ref.get("address")
203
+ and _re.match(SHA_RE_STR, str(ref.get("sha256", ""))))
204
+
205
+
206
+ def compile_leaf(parent, slice_spec):
207
+ """One parent + one outcome slice → one dispatchable leaf packet, or
208
+ problems. All or nothing: an unresolved parent dispatches no leaf."""
209
+ problems = []
210
+ if not isinstance(parent, dict) or not isinstance(slice_spec, dict):
211
+ return None, ["parent and slice must be objects"]
212
+ decisions = slice_spec.get("decision_refs", parent.get("decision_refs", []))
213
+ for i, ref in enumerate(decisions):
214
+ if not _bound(ref):
215
+ problems.append(
216
+ f"decision_refs[{i}]: unresolved — a decision without address+"
217
+ "digest is a rumour, and a broad unresolved parent dispatches "
218
+ "no leaf")
219
+ acceptance = slice_spec.get("acceptance", [])
220
+ kinds = {a.get("kind") for a in acceptance if isinstance(a, dict)}
221
+ if "positive" not in kinds or "negative" not in kinds:
222
+ problems.append("acceptance: needs at least one positive and one "
223
+ "negative case — a slice provable only by success is "
224
+ "not testable")
225
+ if not slice_spec.get("expected_result"):
226
+ problems.append("expected_result: missing — every slice has a concrete "
227
+ "expected result")
228
+ if not slice_spec.get("outputs"):
229
+ problems.append("outputs: missing — a leaf without an output contract "
230
+ "fails readiness")
231
+ for field in ("id", "module", "intent"):
232
+ if not slice_spec.get(field):
233
+ problems.append(f"{field}: missing")
234
+ for i, ref in enumerate(slice_spec.get("inputs", [])):
235
+ if not _bound(ref):
236
+ problems.append(f"inputs[{i}]: missing address or digest — an "
237
+ "unverifiable input does not dispatch")
238
+ if problems:
239
+ return None, problems
240
+
241
+ primary = list(slice_spec.get("primary", []))
242
+ appendix = list(slice_spec.get("appendix", []))
243
+ budget = slice_spec.get("budgets", {}).get("context_bytes")
244
+ dropped = []
245
+ if isinstance(budget, int):
246
+ def size(items):
247
+ return sum(len(canon(x)) for x in items)
248
+ while appendix and size(primary) + size(appendix) > budget:
249
+ dropped.append(appendix.pop())
250
+ if size(primary) > budget:
251
+ return None, ["budgets.context_bytes: smaller than the primary "
252
+ "material — a budget cuts appendix, never decisions "
253
+ "or acceptance; raise it or split the slice"]
254
+
255
+ leaf = {
256
+ "schema_version": LEAF_SCHEMA,
257
+ "id": slice_spec["id"],
258
+ "parent_id": parent.get("id"),
259
+ "module": slice_spec["module"],
260
+ "intent": slice_spec["intent"],
261
+ "inputs": slice_spec.get("inputs", []),
262
+ "decision_refs": decisions,
263
+ "source_scope": slice_spec.get("source_scope", {"edit_targets": []}),
264
+ "budgets": slice_spec.get("budgets", {"context_bytes": 1}),
265
+ "acceptance": [a["text"] for a in acceptance],
266
+ "expected_result": slice_spec["expected_result"],
267
+ "outputs": slice_spec["outputs"],
268
+ "guards": slice_spec.get("guards", parent.get("non_goals", [])),
269
+ "resume": slice_spec.get(
270
+ "resume", "re-read this packet, verify input digests, continue at "
271
+ "the first unmet acceptance case"),
272
+ "acceptance_map": {a["text"]: a.get("parent_acceptance")
273
+ for a in acceptance},
274
+ "primary": primary,
275
+ "appendix": appendix,
276
+ }
277
+ if dropped:
278
+ leaf["appendix_dropped"] = dropped # the cut is recorded, never silent
279
+ return leaf, []
280
+
281
+
282
+ DISPATCH_SCHEMA = "dispatch-packet/1"
283
+ _SECRETISH = re.compile(
284
+ r"(?i)^(?:.*[_-])?(token|secret|password|passwd|api[_-]?key|apikey|credential|"
285
+ r"authorization|cookie|private[_-]?key)s?$")
286
+
287
+
288
+ def compile_dispatch_packet(leaf, context):
289
+ """The IMMUTABLE packet a build runs from (FIX-PF-05.01): one leaf plus the
290
+ project context — REQ refs, global constraints, interfaces, artifact
291
+ digests, the base revision, scope, budget, the run profile and the skill
292
+ lock — every ref bound (address+digest) or the packet is refused, and NO
293
+ ephemeral secret rides inside: a packet outlives the session that built it,
294
+ so a value that must expire is referenced by the NAME of its store, never
295
+ carried by value.
296
+ """
297
+ problems = []
298
+ if not isinstance(leaf, dict) or not isinstance(context, dict):
299
+ return None, ["leaf and context must be objects"]
300
+
301
+ required = ("requirements", "constraints", "interfaces", "artifacts",
302
+ "base", "scope", "budget", "profile", "skill_lock")
303
+ for key in required:
304
+ if key not in context:
305
+ problems.append(f"{key}: missing — a fresh packet carries every "
306
+ "required constraint, and an absent one is a refusal, "
307
+ "not a default")
308
+ for key in ("requirements", "constraints", "interfaces", "artifacts"):
309
+ for i, ref in enumerate(context.get(key) or []):
310
+ if not _bound(ref):
311
+ problems.append(f"{key}[{i}]: unresolved — a required ref without "
312
+ "address+digest does not compile")
313
+ base = context.get("base") or {}
314
+ if "base" in context and not base.get("head"):
315
+ problems.append("base.head: missing — a packet with no base revision is a "
316
+ "packet about no tree")
317
+ if "skill_lock" in context and not _bound(context.get("skill_lock") or {}):
318
+ problems.append("skill_lock: unresolved — the skill versions the build ran "
319
+ "under are part of the proof")
320
+
321
+ packet = {
322
+ "schema_version": DISPATCH_SCHEMA,
323
+ "leaf_id": leaf.get("id"),
324
+ "requirements": context.get("requirements") or [],
325
+ "constraints": context.get("constraints") or [],
326
+ "interfaces": context.get("interfaces") or [],
327
+ "artifacts": context.get("artifacts") or [],
328
+ "base": base,
329
+ "scope": context.get("scope") or {},
330
+ "budget": context.get("budget") or {},
331
+ "profile": context.get("profile") or {},
332
+ "skill_lock": context.get("skill_lock") or {},
333
+ }
334
+
335
+ def scan(node, path):
336
+ if isinstance(node, dict):
337
+ for k, v in node.items():
338
+ if _SECRETISH.match(str(k)):
339
+ problems.append(
340
+ f"{path}.{k}: a dispatch packet carries no ephemeral "
341
+ "secret — it outlives the session; reference the store "
342
+ "by NAME, never the value")
343
+ scan(v, f"{path}.{k}")
344
+ elif isinstance(node, list):
345
+ for i, v in enumerate(node):
346
+ scan(v, f"{path}[{i}]")
347
+ scan(packet, "packet")
348
+
349
+ if problems:
350
+ return None, problems
351
+ return packet, []
352
+
353
+
354
+ def leaf_readiness(leaf):
355
+ """The cold reader's eight questions, answered from the packet ALONE."""
356
+ problems = []
357
+ if not isinstance(leaf, dict):
358
+ return ["leaf: not an object"]
359
+ if leaf.get("schema_version") != LEAF_SCHEMA:
360
+ problems.append("schema_version: missing or unknown — an unversioned "
361
+ "packet fails readiness")
362
+ answers = {
363
+ "goal": leaf.get("intent"),
364
+ "inputs": leaf.get("inputs"),
365
+ "decisions": leaf.get("decision_refs"),
366
+ "scope": (leaf.get("source_scope") or {}).get("edit_targets"),
367
+ "outputs": leaf.get("outputs"),
368
+ "acceptance": leaf.get("acceptance"),
369
+ "guards": leaf.get("guards"),
370
+ "resume": leaf.get("resume"),
371
+ }
372
+ for q in COLD_READER_QUESTIONS:
373
+ if not answers.get(q):
374
+ problems.append(f"cold reader cannot answer {q!r} from the packet "
375
+ "alone — readiness fails")
376
+ return problems
377
+
378
+
379
+ def select_slice(slices, active_id):
380
+ """A slice is chosen by explicit id. No id, no fallback — 'the newest
381
+ file' is an authority nobody granted (never mtime)."""
382
+ if not active_id:
383
+ raise ValueError("no active slice id given — selection by mtime or "
384
+ "recency is refused; name the slice")
385
+ matches = [s for s in slices if isinstance(s, dict) and s.get("id") == active_id]
386
+ if not matches:
387
+ raise ValueError(f"slice {active_id!r} is not in the set")
388
+ if len(matches) > 1:
389
+ raise ValueError(f"slice {active_id!r} appears {len(matches)} times")
390
+ return matches[0]
391
+
392
+
393
+ BUNDLE_SCHEMA = "context-bundle/1"
394
+ CREDENTIAL_NAMES = (".env", "id_rsa", "id_ed25519", "credentials", ".netrc",
395
+ "secrets", ".pem", ".key")
396
+
397
+
398
+ def _locator_problems(addr):
399
+ a = str(addr)
400
+ if os.path.isabs(a) or (len(a) > 1 and a[1] == ":"):
401
+ return [f"{a}: absolute locator — a bundle carries relative locators "
402
+ "only; an absolute path is a fact about the author's machine"]
403
+ if ".." in a.replace("\\", "/").split("/"):
404
+ return [f"{a}: escaping locator — `..` walks out of any root"]
405
+ base = os.path.basename(a).lower()
406
+ for cred in CREDENTIAL_NAMES:
407
+ if cred in base:
408
+ return [f"{a}: looks like a credential ({cred}) — a bundle carries "
409
+ "no credentials, ever"]
410
+ return []
411
+
412
+
413
+ def export_bundle(leaf, src_root, out_dir):
414
+ """Leaf → content-addressed bundle. All or nothing: any problem exports
415
+ no bytes."""
416
+ problems = []
417
+ if not isinstance(leaf, dict):
418
+ return None, ["leaf: not an object"]
419
+ locators = {}
420
+ blobs = {}
421
+ for ref in leaf.get("inputs", []):
422
+ addr = ref.get("address", "")
423
+ lp = _locator_problems(addr)
424
+ if lp:
425
+ problems.extend(lp)
426
+ continue
427
+ src = os.path.join(src_root, addr)
428
+ try:
429
+ with open(src, "rb") as fh:
430
+ data = fh.read()
431
+ except OSError:
432
+ problems.append(f"{addr}: unreadable under the source root — an "
433
+ "input the author cannot read cannot travel")
434
+ continue
435
+ digest = hashlib.sha256(data).hexdigest()
436
+ if digest != ref.get("sha256"):
437
+ problems.append(f"{addr}: bytes do not match the declared digest — "
438
+ "the source moved under the plan; recompile first")
439
+ continue
440
+ locators[addr] = digest
441
+ blobs[digest] = data
442
+ if problems:
443
+ return None, problems
444
+
445
+ os.makedirs(os.path.join(out_dir, "blobs"), exist_ok=True)
446
+ for digest, data in sorted(blobs.items()):
447
+ with open(os.path.join(out_dir, "blobs", digest), "wb") as fh:
448
+ fh.write(data)
449
+ manifest = {"schema_version": BUNDLE_SCHEMA, "leaf": leaf,
450
+ "locators": locators}
451
+ with open(os.path.join(out_dir, "manifest.json"), "w", encoding="utf-8") as fh:
452
+ fh.write(canon(manifest))
453
+ return manifest, []
454
+
455
+
456
+ def import_bundle(bundle_dir, dest_root):
457
+ """Bundle → files under the RECIPIENT's root. Every blob is verified
458
+ BEFORE anything is written — a corrupt bundle writes nothing."""
459
+ problems = []
460
+ try:
461
+ with open(os.path.join(bundle_dir, "manifest.json"), encoding="utf-8") as fh:
462
+ manifest = json.load(fh)
463
+ except (OSError, ValueError) as e:
464
+ return None, [f"manifest.json: unreadable — {e}"]
465
+ if manifest.get("schema_version") != BUNDLE_SCHEMA:
466
+ return None, ["manifest: missing or unknown schema_version — an "
467
+ "unversioned bundle does not import"]
468
+ verified = {}
469
+ for addr, digest in sorted((manifest.get("locators") or {}).items()):
470
+ problems.extend(_locator_problems(addr))
471
+ blob = os.path.join(bundle_dir, "blobs", str(digest))
472
+ try:
473
+ with open(blob, "rb") as fh:
474
+ data = fh.read()
475
+ except OSError:
476
+ problems.append(f"{addr}: blob {digest} is MISSING — dispatch blocks")
477
+ continue
478
+ actual = hashlib.sha256(data).hexdigest()
479
+ if actual != digest:
480
+ problems.append(f"{addr}: blob is CORRUPT (manifest says {digest}, "
481
+ f"bytes hash to {actual}) — dispatch blocks")
482
+ continue
483
+ verified[addr] = data
484
+ if problems:
485
+ return None, problems
486
+
487
+ for addr, data in sorted(verified.items()):
488
+ dest = os.path.join(dest_root, addr)
489
+ os.makedirs(os.path.dirname(dest) or dest_root, exist_ok=True)
490
+ with open(dest, "wb") as fh:
491
+ fh.write(data)
492
+ return manifest, []
493
+
494
+
495
+ def predispatch(leaf, root, capabilities=None, produced=None):
496
+ """The last gate before a claim. Returns problems, empty when the leaf is
497
+ safe to dispatch. Nothing here truncates — every breach BLOCKS."""
498
+ problems = []
499
+ if not isinstance(leaf, dict):
500
+ return ["leaf: not an object"]
501
+ have_caps = set(capabilities or [])
502
+ have_produced = set(produced or [])
503
+
504
+ # 1. Every input's digest against the bytes on disk NOW.
505
+ for i, ref in enumerate(leaf.get("inputs", [])):
506
+ addr, want = ref.get("address"), ref.get("sha256")
507
+ if not addr or not want:
508
+ problems.append(f"inputs[{i}]: missing address or digest — "
509
+ "unverifiable, blocks dispatch")
510
+ continue
511
+ try:
512
+ with open(os.path.join(root, addr), "rb") as fh:
513
+ actual = hashlib.sha256(fh.read()).hexdigest()
514
+ except OSError:
515
+ problems.append(f"{addr}: not present under the root — the plan's "
516
+ "input is gone, blocks dispatch")
517
+ continue
518
+ if actual != want:
519
+ problems.append(f"{addr}: source DRIFT — on disk {actual[:12]}…, the "
520
+ f"plan was made against {want[:12]}…; recompile, do "
521
+ "not dispatch stale context")
522
+
523
+ # 2. Prerequisite outputs materialized.
524
+ for dep in leaf.get("depends_on", []):
525
+ if not isinstance(dep, dict):
526
+ continue
527
+ if dep.get("kind") == "data" and dep.get("task_id") not in have_produced:
528
+ problems.append(f"dependency {dep.get('task_id')}: its output is not "
529
+ "materialized — a data prerequisite blocks dispatch")
530
+
531
+ # 3. The PRIMARY context budget — never a silent truncation.
532
+ budget = (leaf.get("budgets") or {}).get("context_bytes")
533
+ primary = leaf.get("primary", [])
534
+ if isinstance(budget, int):
535
+ size = sum(len(canon(x)) for x in primary)
536
+ if size > budget:
537
+ problems.append(f"primary context is {size} bytes over the "
538
+ f"{budget}-byte budget — the budget cuts appendix, "
539
+ "never primary; split the leaf, do not truncate")
540
+
541
+ # 4. Declared capability and resource ownership.
542
+ for cap in leaf.get("required_capabilities", []):
543
+ if cap not in have_caps:
544
+ problems.append(f"capability {cap!r} is not available on this host — "
545
+ "blocks dispatch")
546
+ scope = leaf.get("source_scope") or {}
547
+ claim = scope.get("claim")
548
+ if scope.get("edit_targets") and not claim:
549
+ problems.append("edit targets are declared but no coordination claim is "
550
+ "named — take the claim before dispatch where the project "
551
+ "has agent-sync on")
552
+ return problems
553
+
554
+
555
+ def _load(path):
556
+ with open(path, encoding="utf-8") as fh:
557
+ return json.load(fh)
558
+
559
+
560
+ def main(argv):
561
+ if len(argv) >= 2 and argv[0] == "compile":
562
+ try:
563
+ report = _load(argv[1])
564
+ except (OSError, ValueError) as e:
565
+ print(f"REJECTED: unreadable report — {e}", file=sys.stderr)
566
+ return 1
567
+ result, problems = compile_parents(report)
568
+ if problems:
569
+ for p in problems:
570
+ print(f"REJECTED: {p}", file=sys.stderr)
571
+ return 1
572
+ sys.stdout.write(canon(result))
573
+ return 0
574
+ if len(argv) == 3 and argv[0] == "compile-leaf":
575
+ try:
576
+ parent, slice_spec = _load(argv[1]), _load(argv[2])
577
+ except (OSError, ValueError) as e:
578
+ print(f"REJECTED: {e}", file=sys.stderr)
579
+ return 1
580
+ leaf, problems = compile_leaf(parent, slice_spec)
581
+ if problems:
582
+ for pr in problems:
583
+ print(f"REJECTED: {pr}", file=sys.stderr)
584
+ return 1
585
+ sys.stdout.write(canon(leaf))
586
+ return 0
587
+ if len(argv) == 3 and argv[0] == "compile-dispatch":
588
+ try:
589
+ leaf, context = _load(argv[1]), _load(argv[2])
590
+ except (OSError, ValueError) as e:
591
+ print(f"REJECTED: {e}", file=sys.stderr)
592
+ return 1
593
+ packet, problems = compile_dispatch_packet(leaf, context)
594
+ if problems:
595
+ for pr in problems:
596
+ print(f"REJECTED: {pr}", file=sys.stderr)
597
+ return 1
598
+ sys.stdout.write(canon(packet))
599
+ return 0
600
+ if len(argv) == 2 and argv[0] == "readiness":
601
+ try:
602
+ leaf = _load(argv[1])
603
+ except (OSError, ValueError) as e:
604
+ print(f"REJECTED: {e}", file=sys.stderr)
605
+ return 1
606
+ problems = leaf_readiness(leaf)
607
+ for pr in problems:
608
+ print(f"REJECTED: {pr}", file=sys.stderr)
609
+ if problems:
610
+ return 1
611
+ print("READY — the cold reader's eight questions are answered")
612
+ return 0
613
+ if len(argv) >= 3 and argv[0] == "predispatch":
614
+ try:
615
+ leaf = _load(argv[1])
616
+ except (OSError, ValueError) as e:
617
+ print(f"REJECTED: {e}", file=sys.stderr)
618
+ return 1
619
+ root = argv[2]
620
+ caps, produced = [], []
621
+ rest = argv[3:]
622
+ i = 0
623
+ while i < len(rest):
624
+ if rest[i] == "--capabilities" and i + 1 < len(rest):
625
+ caps = rest[i + 1].split(",")
626
+ i += 2
627
+ elif rest[i] == "--produced" and i + 1 < len(rest):
628
+ produced = rest[i + 1].split(",")
629
+ i += 2
630
+ else:
631
+ i += 1
632
+ problems = predispatch(leaf, root, caps, produced)
633
+ for pr in problems:
634
+ print(f"BLOCKED: {pr}", file=sys.stderr)
635
+ if problems:
636
+ return 1
637
+ print("CLEAR — inputs fresh, prerequisites materialized, budget met, "
638
+ "capabilities and claim present")
639
+ return 0
640
+ if len(argv) == 4 and argv[0] == "export-bundle":
641
+ try:
642
+ leaf = _load(argv[1])
643
+ except (OSError, ValueError) as e:
644
+ print(f"REJECTED: {e}", file=sys.stderr)
645
+ return 1
646
+ _m, problems = export_bundle(leaf, argv[2], argv[3])
647
+ if problems:
648
+ for pr in problems:
649
+ print(f"REJECTED: {pr}", file=sys.stderr)
650
+ return 1
651
+ print(f"exported {len(_m['locators'])} blob(s) to {argv[3]}")
652
+ return 0
653
+ if len(argv) == 3 and argv[0] == "import-bundle":
654
+ _m, problems = import_bundle(argv[1], argv[2])
655
+ if problems:
656
+ for pr in problems:
657
+ print(f"REJECTED: {pr}", file=sys.stderr)
658
+ return 1
659
+ print(f"imported {len(_m['locators'])} file(s) under {argv[2]}")
660
+ return 0
661
+ if len(argv) == 3 and argv[0] == "verify":
662
+ try:
663
+ report, plan = _load(argv[1]), _load(argv[2])
664
+ except (OSError, ValueError) as e:
665
+ print(f"REJECTED: {e}", file=sys.stderr)
666
+ return 1
667
+ problems = verify_parents(report, plan)
668
+ for p in problems:
669
+ print(f"REJECTED: {p}", file=sys.stderr)
670
+ if problems:
671
+ return 1
672
+ print(f"OK — {len(plan['parents'])} parents, every one traced")
673
+ return 0
674
+ print(__doc__.strip().splitlines()[0], file=sys.stderr)
675
+ print("usage: context_packets.py compile <report.json> | "
676
+ "verify <report.json> <parents.json> | "
677
+ "compile-leaf <parent.json> <slice.json> | readiness <leaf.json> | "
678
+ "export-bundle <leaf.json> <src_root> <out_dir> | "
679
+ "import-bundle <bundle_dir> <dest_root> | "
680
+ "predispatch <leaf.json> <root> [--capabilities …] [--produced …]",
681
+ file=sys.stderr)
682
+ return 2
683
+
684
+
685
+ if __name__ == "__main__":
686
+ sys.exit(main(sys.argv[1:]))