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
@@ -52,6 +52,7 @@ import os
52
52
  import re
53
53
  import sys
54
54
  import tempfile
55
+ import time
55
56
 
56
57
  # Who may OWN a node — which is a different axis from who ships as a subagent.
57
58
  #
@@ -76,11 +77,18 @@ ROLES = {
76
77
  "verifier", "decomposer", "ux", "ui", "researcher", "market-analyst", "bug-analyst",
77
78
  }
78
79
 
80
+ # TERMINAL answers "is this node's own lifecycle over" — a parked node is over.
81
+ # It does NOT answer "did this node produce what its consumers need": that
82
+ # predicate is satisfaction, and only `done` satisfies (FIX-PF-04.01). A park
83
+ # with reason "producer unavailable" used to make its CONSUMER runnable, which
84
+ # ran work without its required input; frontier/close/certify now demand `done`
85
+ # on a blocker, and a valid alternative producer is an explicit, versioned edge
86
+ # change (`add`/`invalidate` with a revision), never an implicit unblock.
79
87
  TERMINAL = {"done", "parked"}
80
88
  NO_GRAPH = {"producer", "doctrine"}
81
89
  # One place, and the schema enumerates the same three. Two homes for this set is
82
90
  # what let `close` write a verb the format forbade.
83
- REVISION_VERBS = {"add", "park", "close"}
91
+ REVISION_VERBS = {"add", "park", "close", "invalidate", "waive"}
84
92
  # Parking a node PROMOTES its dependents: `parked` is terminal, so anything
85
93
  # blocked on it becomes runnable even though the payload it waited on never
86
94
  # arrived. That is deliberate — `can_continue_around` in the verdict is the
@@ -379,6 +387,23 @@ def unblocks(nodes):
379
387
  return {n.get("id"): len(reach(n.get("id"))) for n in nodes}
380
388
 
381
389
 
390
+ def descendants(nodes, root):
391
+ """Every node transitively DOWNSTREAM of `root` — the nodes whose work
392
+ depends on it through `blocked_by`. Used by `invalidate` to find the proofs
393
+ a change to `root` makes stale (FIX-PF-02.02)."""
394
+ dependents = {}
395
+ for n in nodes:
396
+ for b in n.get("blocked_by") or []:
397
+ dependents.setdefault(b, set()).add(n.get("id"))
398
+ seen, stack = set(), [root]
399
+ while stack:
400
+ for d in dependents.get(stack.pop(), ()):
401
+ if d not in seen:
402
+ seen.add(d)
403
+ stack.append(d)
404
+ return seen
405
+
406
+
382
407
  def collisions(ready):
383
408
  """Pairs of simultaneously-runnable nodes that mutate the same thing — B-093.
384
409
 
@@ -411,7 +436,7 @@ def frontier(graph):
411
436
  if n.get("status") in TERMINAL or n.get("status") == "running":
412
437
  continue
413
438
  blockers = n.get("blocked_by") or []
414
- if all(by_id.get(b, {}).get("status") in TERMINAL for b in blockers):
439
+ if all(by_id.get(b, {}).get("status") == "done" for b in blockers):
415
440
  ready.append(n)
416
441
  rank = unblocks(nodes)
417
442
  order = {n.get("id"): i for i, n in enumerate(nodes)}
@@ -452,6 +477,23 @@ def verdict_violations(v):
452
477
  if out:
453
478
  return out
454
479
 
480
+ # `tested` is the proof identity (FIX-PF-02.01): what the reviewer actually
481
+ # ran the check against. Optional in shape (the 7-key contract is unchanged),
482
+ # but when present every field is typed, and `close` REQUIRES it inside a
483
+ # checkout so a stale proof cannot be stamped onto a moved tree.
484
+ if "tested" in v:
485
+ tv = v["tested"]
486
+ if not isinstance(tv, dict):
487
+ out.append("verdict `tested` must be an object binding the proof to a tree")
488
+ else:
489
+ for k in ("head", "tree", "base", "packet", "graph_revision"):
490
+ if k in tv and not isinstance(tv[k], str):
491
+ out.append(f"verdict `tested.{k}` must be a string")
492
+ if "checks" in tv and not isinstance(tv["checks"], list):
493
+ out.append("verdict `tested.checks` must be a list")
494
+ if out:
495
+ return out
496
+
455
497
  if not isinstance(v["node"], str) or not v["node"].startswith(NODE_ID):
456
498
  out.append(f"verdict `node` is {v['node']!r}, which is not a node id")
457
499
 
@@ -687,6 +729,22 @@ def cmd_next(graph, args):
687
729
  if nodes and all(n.get("status") in TERMINAL for n in nodes):
688
730
  return 3
689
731
  ready = frontier(graph)
732
+ # A consumer whose producer is PARKED is not runnable and never will be by
733
+ # itself (FIX-PF-04.01) — say so BEFORE the empty-frontier exit, or the one
734
+ # moment the operator most needs the reason is the one moment it is silent.
735
+ _by_id = {n.get("id"): n for n in nodes}
736
+ for n in nodes:
737
+ if n.get("status") in TERMINAL or n.get("status") == "running":
738
+ continue
739
+ parked_blockers = [b for b in n.get("blocked_by") or []
740
+ if _by_id.get(b, {}).get("status") == "parked"]
741
+ if parked_blockers:
742
+ reasons = "; ".join(
743
+ f"{b}: {_by_id[b].get('parked_reason', '?')}" for b in parked_blockers)
744
+ print(f"held: {n.get('id')} waits on parked {', '.join(parked_blockers)} "
745
+ f"({reasons}) — a park does not produce the artifact; if an "
746
+ f"alternative producer exists, change the edge explicitly",
747
+ file=sys.stderr)
690
748
  if not ready:
691
749
  return 4
692
750
  # The frontier and nothing else. This is the line that enters a context on
@@ -711,6 +769,215 @@ def cmd_next(graph, args):
711
769
  return 0
712
770
 
713
771
 
772
+ def _load_authority(args):
773
+ """External mode's coordinator, fail-closed. Importing beside this script so
774
+ a checkout runs without install."""
775
+ import importlib.util
776
+ here = os.path.dirname(os.path.abspath(__file__))
777
+ spec = importlib.util.spec_from_file_location(
778
+ "execution_authority", os.path.join(here, "execution_authority.py"))
779
+ ea = importlib.util.module_from_spec(spec)
780
+ spec.loader.exec_module(ea)
781
+ return ea
782
+
783
+
784
+ def cmd_claim(graph, args):
785
+ """External-executor mode: turn one ADVISORY frontier row into a durable,
786
+ arbitrated CLAIM. `next` says what could run; this says who may. Exactly one
787
+ of two racing runs wins; an authority that cannot answer BLOCKS dispatch
788
+ (fail-closed) rather than letting both proceed.
789
+
790
+ Exit codes: 0 won (grant on stdout), 5 lost the race (holder on stderr),
791
+ 4 the node is not runnable, 1 the authority was unavailable — and 1 means
792
+ NO work starts, which is the whole point of fail-closed."""
793
+ if violations(graph):
794
+ die("graph does not validate — run `validate` first", 1)
795
+ node_id = args.node
796
+ ready = {n["id"]: n for n in frontier(graph)}
797
+ if node_id not in ready:
798
+ die(f"{node_id} is not in the frontier — claim only a runnable node", 4)
799
+ revision = int(ready[node_id].get("revision", 0) or 0)
800
+ ea = _load_authority(args)
801
+ try:
802
+ auth = ea.authority_for(args.authority)
803
+ except ea.AuthorityUnavailable as e:
804
+ die(f"authority unavailable — dispatch BLOCKED, no work started: {e}", 1)
805
+ try:
806
+ grant = auth.claim(node_id, args.owner, revision, now=time.time(),
807
+ ttl_seconds=args.ttl)
808
+ except ea.AuthorityUnavailable as e:
809
+ die(f"arbitration failed — dispatch BLOCKED, no work started: {e}", 1)
810
+ finally:
811
+ auth.close()
812
+ if grant is None:
813
+ holder = None
814
+ try:
815
+ a2 = ea.authority_for(args.authority)
816
+ holder = a2.holder(node_id)
817
+ a2.close()
818
+ except ea.AuthorityUnavailable:
819
+ pass
820
+ who = holder.get("owner") if holder else "another run"
821
+ print(f"lost: {node_id} is already claimed by {who}", file=sys.stderr)
822
+ return 5
823
+ print(json.dumps(grant, ensure_ascii=False))
824
+ return 0
825
+
826
+
827
+ def cmd_recover(graph, args):
828
+ """External mode: reclaim an EXPIRED node for a new owner, minting a higher
829
+ fence. A still-live claim is not recoverable (that is stealing a working
830
+ node); this exits 5 while the lease holds."""
831
+ ea = _load_authority(args)
832
+ try:
833
+ auth = ea.authority_for(args.authority)
834
+ except ea.AuthorityUnavailable as e:
835
+ die(f"authority unavailable: {e}", 1)
836
+ revision = 0
837
+ ready = {n["id"]: n for n in (graph.get("nodes") or [])}
838
+ if args.node in ready:
839
+ revision = int(ready[args.node].get("revision", 0) or 0)
840
+ try:
841
+ import time as _t
842
+ grant = auth.recover(args.node, args.owner, revision, now=_t.time(), ttl_seconds=args.ttl)
843
+ finally:
844
+ auth.close()
845
+ if grant is None:
846
+ print(f"not recovered: {args.node} still holds a live claim", file=sys.stderr)
847
+ return 5
848
+ print(json.dumps(grant, ensure_ascii=False))
849
+ return 0
850
+
851
+
852
+ def cmd_complete(graph, args):
853
+ """External mode: record completion from the CURRENT fence-holder only. A
854
+ late old worker (stale fence) is refused (exit 5), never overwriting the run
855
+ that took the node over; the current holder completing twice is idempotent."""
856
+ ea = _load_authority(args)
857
+ try:
858
+ auth = ea.authority_for(args.authority)
859
+ except ea.AuthorityUnavailable as e:
860
+ die(f"authority unavailable: {e}", 1)
861
+ import time as _t
862
+ try:
863
+ grant = auth.complete(args.node, args.owner, args.fence, now=_t.time())
864
+ finally:
865
+ auth.close()
866
+ if grant is None:
867
+ print(f"not completed: {args.node} is not held by {args.owner} at fence "
868
+ f"{args.fence} — a late or superseded worker cannot complete it", file=sys.stderr)
869
+ return 5
870
+ print(json.dumps(grant, ensure_ascii=False))
871
+ return 0
872
+
873
+
874
+ def cmd_release(graph, args):
875
+ """Give back a hold this run actually owns (matching fence). A mismatch is a
876
+ no-op, not an error someone can use to steal a live node."""
877
+ ea = _load_authority(args)
878
+ try:
879
+ auth = ea.authority_for(args.authority)
880
+ except ea.AuthorityUnavailable as e:
881
+ die(f"authority unavailable: {e}", 1)
882
+ try:
883
+ ok = auth.release(args.node, args.owner, args.fence)
884
+ finally:
885
+ auth.close()
886
+ if not ok:
887
+ print(f"not released: {args.node} is not held by {args.owner} at fence {args.fence}",
888
+ file=sys.stderr)
889
+ return 5
890
+ print(f"released {args.node}")
891
+ return 0
892
+
893
+
894
+ def cmd_waive(graph, args):
895
+ """An AUTHORIZED EXCEPTION is its own disposition — never a fake PASS
896
+ (FIX-PF-03.02). Where an operator decides a node ships without (or despite)
897
+ certification, that decision is recorded as an exception carrying its
898
+ REASON and the IDENTITY that authorized it. The node is never marked
899
+ certified; a failed certification stays visible beside the exception, and
900
+ `close` stamps the exception into the evidence so a reader six weeks later
901
+ sees a decision, not a green."""
902
+ guard(graph, args.graph)
903
+ nid = args.node
904
+ by_id = {n.get("id"): n for n in graph.get("nodes") or []}
905
+ if nid not in by_id:
906
+ die("no node %s in this graph — nothing was waived" % nid)
907
+ reason = (args.reason or "").strip()
908
+ who = (args.by or "").strip()
909
+ if not reason:
910
+ die("waive needs --reason: an exception with no reason is a green with extra steps")
911
+ if not who:
912
+ die("waive needs --by: an exception nobody signed is nobody's decision")
913
+ node = by_id[nid]
914
+ import subprocess as _sp
915
+ try:
916
+ r = _sp.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True)
917
+ at = r.stdout.strip() if r.returncode == 0 else "unavailable"
918
+ except OSError:
919
+ at = "unavailable"
920
+ node["exception"] = {"reason": reason, "by": who, "at": at}
921
+ revise(graph, "waive", nid, "authorized exception by %s: %s" % (who, reason),
922
+ precondition=at)
923
+ bad = violations(graph)
924
+ if bad:
925
+ die("waiving %s would break the graph — nothing was written:\n %s"
926
+ % (nid, "\n ".join(bad)))
927
+ save(args.graph, graph)
928
+ print("waived %s — authorized exception by %s (never a certification)" % (nid, who))
929
+ return 0
930
+
931
+
932
+ def cmd_invalidate(graph, args):
933
+ """A REQ / interface / brief change supersedes the node it touched and
934
+ INVALIDATES the proofs downstream of it (FIX-PF-02.02).
935
+
936
+ A proof is a claim about a tree; when an upstream contract moves, every
937
+ descendant that was certified against the old contract is certified against
938
+ a tree that no longer exists. So `invalidate` records a superseding revision
939
+ on the changed node and, for the node itself and each `done` DESCENDANT,
940
+ resets it to `pending`, clears its evidence / proof / certification, and
941
+ records why. Nodes NOT downstream of the change keep their proof untouched —
942
+ an invalidation that reached the whole graph would be a reason nobody runs
943
+ it.
944
+ """
945
+ guard(graph, args.graph)
946
+ nid = args.node
947
+ by_id = {n.get("id"): n for n in graph.get("nodes") or []}
948
+ if nid not in by_id:
949
+ die("no node %s in this graph — nothing was invalidated" % nid)
950
+ why = (args.why or "").strip()
951
+ if not why:
952
+ die("invalidate needs --why: a superseding revision with no reason is "
953
+ "indistinguishable from a node quietly reset")
954
+
955
+ affected = {nid} | descendants(graph.get("nodes") or [], nid)
956
+ reset = []
957
+ for aid in sorted(affected):
958
+ node = by_id[aid]
959
+ if node.get("status") == "parked":
960
+ continue # a parked node stays parked; its reason still stands
961
+ had_proof = node.get("status") == "done" or node.get("proof") or node.get("certification")
962
+ node["status"] = "pending"
963
+ node["evidence"] = None
964
+ node["proof"] = None
965
+ node["certification"] = None
966
+ if had_proof:
967
+ reset.append(aid)
968
+ revise(graph, "invalidate", nid, why,
969
+ precondition="supersedes proofs downstream of " + nid)
970
+
971
+ bad = violations(graph)
972
+ if bad:
973
+ die("invalidating %s would break the graph — nothing was written:\n %s"
974
+ % (nid, "\n ".join(bad)))
975
+ save(args.graph, graph)
976
+ print("invalidated %s and %d downstream node(s); reset %d certified proof(s): %s"
977
+ % (nid, len(affected) - 1, len(reset), ", ".join(reset) or "none"))
978
+ return 0
979
+
980
+
714
981
  def cmd_goal(graph, args):
715
982
  goal = (graph.get("goal") or "").strip()
716
983
  if not goal:
@@ -765,16 +1032,23 @@ class held:
765
1032
  return False
766
1033
 
767
1034
 
768
- def revise(graph, verb, node, why):
1035
+ def revise(graph, verb, node, why, precondition=None):
769
1036
  """Append the revision. Both verbs call it; neither may skip it.
770
1037
 
771
1038
  `park` demanded a reason from the start and `add` demanded nothing, so half the
772
1039
  graph's revision surface was silent — and a graph that changed for reasons nobody
773
1040
  recorded can always explain its own completion by appealing to a plan that existed
774
1041
  only at the end.
1042
+
1043
+ `precondition` (FIX-PF-02.01) records the tree the mutation was made against — the
1044
+ proven HEAD for a `close` — so a revision carries a VERSION, not only a verb and a
1045
+ reason. A brief/REQ/interface change that moves this tree is then visibly a
1046
+ different precondition, which is what invalidates a proof taken before it.
775
1047
  """
776
- graph.setdefault("revisions", []).append(
777
- {"verb": verb, "node": node, "why": why})
1048
+ entry = {"verb": verb, "node": node, "why": why}
1049
+ if precondition:
1050
+ entry["precondition"] = precondition
1051
+ graph.setdefault("revisions", []).append(entry)
778
1052
 
779
1053
 
780
1054
  def guard(graph, path):
@@ -1065,7 +1339,7 @@ def cmd_producer(graph, args):
1065
1339
  def cmd_doctrine(graph, args):
1066
1340
  """Which doctrine this run actually read — B-061.
1067
1341
 
1068
- The bundle is 38 reference files. A run reads some subset and nothing recorded which,
1342
+ The bundle is 39 reference files. A run reads some subset and nothing recorded which,
1069
1343
  so **a skipped file and a read one were indistinguishable** — the class every guard in
1070
1344
  this repository exists to catch, left standing over the doctrine itself.
1071
1345
 
@@ -1162,7 +1436,7 @@ def cmd_certify(graph, args):
1162
1436
  die("%s is already %s — certifying it again would overwrite the record of the "
1163
1437
  "close that already happened" % (nid, node.get("status")))
1164
1438
  open_blockers = [b for b in node.get("blocked_by") or []
1165
- if by_id.get(b, {}).get("status") not in TERMINAL]
1439
+ if by_id.get(b, {}).get("status") != "done"]
1166
1440
  if open_blockers:
1167
1441
  die("%s waits on %s, which %s not closed — certifying work that could not have "
1168
1442
  "run certifies nothing" % (nid, ", ".join(open_blockers),
@@ -1181,6 +1455,14 @@ def cmd_certify(graph, args):
1181
1455
  if v:
1182
1456
  bad += ["%s: %s" % (os.path.basename(path), line) for line in v]
1183
1457
  continue
1458
+ # Reviewer EXPOSURE, stated honestly (FIX-PF-03.02): a report may say
1459
+ # what the reviewer actually saw. A syntax lint is not a blind review,
1460
+ # and recording one as a tier is the fake this refuses by name.
1461
+ exposure = str(t.get("exposure", "")).strip().lower()
1462
+ if exposure and re.search(r"\b(lint|syntax[- ]only|grep[- ]only|regex[- ]only)\b", exposure):
1463
+ bad.append("%s: exposure is %r — a syntax lint is not a blind review, and a "
1464
+ "tier cannot be certified on one" % (os.path.basename(path), exposure))
1465
+ continue
1184
1466
  if t["node"] != nid:
1185
1467
  bad.append("%s: reports on %s while this certification is for %s — a report "
1186
1468
  "about another node is not evidence about this one"
@@ -1277,6 +1559,22 @@ def cmd_certify(graph, args):
1277
1559
  "why": "certified at all three tiers in round %d" % round_no},
1278
1560
  "evidence": ["%s: %s" % (x, e) for x in TIERS for e in reports[x]["evidence"]],
1279
1561
  }
1562
+ # Proof identity (FIX-PF-02.01): the certification tested THIS tree, so it
1563
+ # records the commit it tested into the verdict it hands `close`. Without it
1564
+ # `close` — which now demands proof inside a checkout — would refuse the
1565
+ # verdict certify just assembled. Read from git here, never from a report.
1566
+ import subprocess as _sp
1567
+ try:
1568
+ _h = _sp.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True)
1569
+ _head = _h.stdout.strip() if _h.returncode == 0 else ""
1570
+ _tr = _sp.run(["git", "rev-parse", "HEAD^{tree}"], capture_output=True, text=True)
1571
+ _tree = _tr.stdout.strip() if _tr.returncode == 0 else ""
1572
+ except OSError:
1573
+ _head, _tree = "", ""
1574
+ if _head:
1575
+ verdict["tested"] = {"head": _head}
1576
+ if _tree:
1577
+ verdict["tested"]["tree"] = _tree
1280
1578
  # Checked against the same gate `close` will apply, HERE, so a certification
1281
1579
  # cannot hand the run a verdict its own consumer refuses.
1282
1580
  broken = verdict_violations(verdict)
@@ -1337,21 +1635,88 @@ def cmd_close(graph, args):
1337
1635
  die("%s is already %s — a second close would overwrite the record of the first"
1338
1636
  % (nid, node.get("status")))
1339
1637
  open_blockers = [b for b in node.get("blocked_by") or []
1340
- if by_id.get(b, {}).get("status") not in TERMINAL]
1638
+ if by_id.get(b, {}).get("status") != "done"]
1341
1639
  if open_blockers:
1342
1640
  die("%s waits on %s, which %s not closed — a verdict about work that could not have "
1343
1641
  "run is a verdict about nothing" % (nid, ", ".join(open_blockers),
1344
1642
  "is" if len(open_blockers) == 1 else "are"))
1345
1643
 
1346
- # The stamp. Read here, never accepted from the verdict.
1644
+ # Proof identity (FIX-PF-02.01). The stamp is not "the current HEAD, whatever
1645
+ # it is" — that stamps a verdict earned at v1 onto a tree already at v2. The
1646
+ # verdict must DECLARE the commit and tree it tested, and close COMPARES that
1647
+ # to HEAD: an old proof against moved code is refused, never re-stamped.
1347
1648
  import subprocess
1348
- try:
1349
- r = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True)
1350
- head = r.stdout.strip() if r.returncode == 0 else ""
1351
- except OSError:
1352
- head = ""
1353
- stamp = ("observed at " + head) if head else \
1354
- "observed at unavailable — not inside a git checkout, so no commit identifies the tree"
1649
+
1650
+ def _git(*a):
1651
+ try:
1652
+ r = subprocess.run(["git", *a], capture_output=True, text=True)
1653
+ return r.stdout.strip() if r.returncode == 0 else ""
1654
+ except OSError:
1655
+ return ""
1656
+
1657
+ head = _git("rev-parse", "HEAD")
1658
+ tree = _git("rev-parse", "HEAD^{tree}")
1659
+ tested = v.get("tested") or {}
1660
+ if head:
1661
+ # Inside a checkout: provenance must be PROVEN, not assumed.
1662
+ if not tested.get("head"):
1663
+ die("the verdict declares no `tested.head` — inside a checkout close must "
1664
+ "confirm the reviewer saw THIS tree; re-run the verifier and record the "
1665
+ "commit it tested. Nothing was written.")
1666
+ if tested["head"] != head:
1667
+ die("the verdict tested %s but HEAD is %s — the code moved under the proof. "
1668
+ "A verdict written before the tree changed is evidence about a different "
1669
+ "tree; re-verify at HEAD and issue a fresh verdict. Nothing was written."
1670
+ % (tested["head"][:12], head[:12]))
1671
+ if tested.get("tree") and tree and tested["tree"] != tree:
1672
+ die("the verdict tested tree %s but HEAD's tree is %s — the working tree moved "
1673
+ "under the proof even though the commit matches. Nothing was written."
1674
+ % (tested["tree"][:12], tree[:12]))
1675
+ stamp = "proven at " + head + ((" (tree " + tree + ")") if tree else "")
1676
+ node["proof"] = {"head": head, "tree": tree or "",
1677
+ "graph_revision": str(len(graph.get("revisions") or []))}
1678
+ for k in ("base", "packet", "attempt"):
1679
+ if k in tested and isinstance(tested[k], str):
1680
+ node["proof"][k] = tested[k]
1681
+ if isinstance(tested.get("checks"), list):
1682
+ node["proof"]["checks"] = list(tested["checks"])
1683
+ else:
1684
+ stamp = "observed at unavailable — not inside a git checkout, so no commit " \
1685
+ "identifies the tree"
1686
+ node["proof"] = {"head": "unavailable"}
1687
+
1688
+ # Completion gate (FIX-PF-03.01): a certification that RAN and FAILED cannot
1689
+ # be stepped around by a direct close with a hand-shaped verdict — the
1690
+ # enforcement lives here, in the store mutation, not only in a UI or a
1691
+ # preflight someone can skip. Where certify never ran, the verdict is the
1692
+ # verifier's judgement and close proceeds as before.
1693
+ cert = node.get("certification")
1694
+ exc = node.get("exception")
1695
+ if cert:
1696
+ tiers = cert.get("tiers") or {}
1697
+ failing = sorted(k for k, val in tiers.items() if val != "pass")
1698
+ if failing and not exc:
1699
+ die("%s has a certification (round %s) with failing tier(s): %s — a direct "
1700
+ "close cannot step around a failed certification; fix the finding and "
1701
+ "re-certify, or record an AUTHORIZED EXCEPTION with `waive` (its own "
1702
+ "disposition, never a pass). Nothing was written."
1703
+ % (nid, cert.get("round"), ", ".join(failing)))
1704
+ if failing and exc:
1705
+ # The exception authorizes the close; the failure stays VISIBLE and
1706
+ # the node is never marked certified.
1707
+ stamp += "; closed under authorized exception by %s: %s (tier(s) %s still failing)" % (
1708
+ exc.get("by", "?"), exc.get("reason", "?"), ", ".join(failing))
1709
+ # Same-candidate check regardless of the exception: a certification of
1710
+ # ANOTHER commit is stale either way.
1711
+ cert_at = cert.get("at") or ""
1712
+ if head and cert_at and not str(cert_at).startswith("unavailable") and cert_at != head:
1713
+ die("%s was certified at %s but HEAD is %s — the certification is for a "
1714
+ "different candidate; re-certify at the current one. Nothing was written."
1715
+ % (nid, str(cert_at)[:12], head[:12]))
1716
+ if exc and not (cert and sorted(k for k, val in (cert.get("tiers") or {}).items()
1717
+ if val != "pass")):
1718
+ stamp += "; closed under authorized exception by %s: %s" % (
1719
+ exc.get("by", "?"), exc.get("reason", "?"))
1355
1720
 
1356
1721
  node["status"] = "done"
1357
1722
  node["evidence"] = list(v["evidence"]) + [stamp]
@@ -1390,7 +1755,8 @@ def cmd_close(graph, args):
1390
1755
  revise(graph, "park", pid, why)
1391
1756
  parked.append(pid)
1392
1757
 
1393
- revise(graph, "close", nid, why or "closed with no re-plan")
1758
+ revise(graph, "close", nid, why or "closed with no re-plan",
1759
+ precondition=(head or "unavailable"))
1394
1760
 
1395
1761
  bad = violations(graph)
1396
1762
  if bad:
@@ -1419,6 +1785,12 @@ VERBS = {
1419
1785
  "validate": (cmd_validate, "every invariant a schema cannot state"),
1420
1786
  "next": (cmd_next, "the frontier, ordered by what it unblocks"),
1421
1787
  "goal": (cmd_goal, "the release goal this graph serves"),
1788
+ "claim": (cmd_claim, "external mode: arbitrate one runnable node to a single owner (fail-closed)"),
1789
+ "invalidate": (cmd_invalidate, "a REQ/interface/brief change supersedes a node and invalidates proofs downstream"),
1790
+ "waive": (cmd_waive, "record an AUTHORIZED EXCEPTION (reason + identity) — its own disposition, never a fake PASS"),
1791
+ "recover": (cmd_recover, "external mode: reclaim an EXPIRED node for a new owner (fenced)"),
1792
+ "complete": (cmd_complete, "external mode: record completion from the current fence-holder (late worker refused)"),
1793
+ "release": (cmd_release, "external mode: give back a hold this run owns"),
1422
1794
  "doctrine": (cmd_doctrine, "which of the bundle's reference files this run opened"),
1423
1795
  "producer": (cmd_producer, "what produced this proof: actor, model, runtime, skill, "
1424
1796
  "config digest, commit, trace"),
@@ -1479,6 +1851,31 @@ def main(argv=None):
1479
1851
  help="rounds after which the output names the churning tier; it "
1480
1852
  "measures rather than stops (references/loop-guard.md)")
1481
1853
 
1854
+ for verb in ("claim", "release", "recover", "complete"):
1855
+ made[verb].add_argument("--authority", required=True,
1856
+ help="path to the local sqlite execution authority "
1857
+ "(external mode; a Fabric adapter replaces this seam)")
1858
+ made[verb].add_argument("--owner", required=True,
1859
+ help="the session/attempt identity making the claim — NOT a role")
1860
+ made[verb].add_argument("--node", required=True, help="the node to act on")
1861
+ for verb in ("claim", "recover"):
1862
+ made[verb].add_argument("--ttl", type=int, default=1800,
1863
+ help="lease seconds; the OS lock is NOT held this long — the "
1864
+ "lease is, and a crashed holder frees the node by expiry")
1865
+ for verb in ("release", "complete"):
1866
+ made[verb].add_argument("--fence", type=int, required=True,
1867
+ help="the fence token from the grant; a stale fence is refused")
1868
+
1869
+ made["invalidate"].add_argument("--node", required=True,
1870
+ help="the node whose REQ/interface/brief changed")
1871
+ made["invalidate"].add_argument("--why", required=True,
1872
+ help="why the contract changed — enters the revision log")
1873
+ made["waive"].add_argument("--node", required=True, help="the node the exception covers")
1874
+ made["waive"].add_argument("--reason", required=True,
1875
+ help="why this node ships without/despite certification")
1876
+ made["waive"].add_argument("--by", required=True,
1877
+ help="the identity that authorized the exception")
1878
+
1482
1879
  p_park = made["park"]
1483
1880
  p_park.add_argument("node")
1484
1881
  # `required=True` makes the MISSING flag a usage error (exit 2). The empty and
@@ -1490,7 +1887,7 @@ def main(argv=None):
1490
1887
  verbs = {k: v[0] for k, v in VERBS.items()}
1491
1888
  if args.verb in NO_GRAPH:
1492
1889
  return verbs[args.verb](None, args)
1493
- if args.verb in ("add", "park", "close", "certify"):
1890
+ if args.verb in ("add", "park", "close", "certify", "invalidate", "waive"):
1494
1891
  # The READ happens inside the lock too. Loading first and locking second is the
1495
1892
  # same lost update with an extra step: the stale copy is already in memory.
1496
1893
  with held(args.graph):