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.
- package/CHANGELOG.md +87 -0
- package/CONTRIBUTING.md +2 -2
- package/README.md +2 -1
- package/SKILL-CARD.md +1 -1
- package/bin/task-pipeline.js +70 -9
- package/evals/cases/evidence-docs.json +188 -0
- package/evals/cases/project-audit.json +188 -0
- package/evals/cases/task-pipeline.json +191 -0
- package/package.json +5 -4
- package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
- package/plugins/task-pipeline/skills/evidence-docs/SKILL.md +16 -11
- package/plugins/task-pipeline/skills/evidence-docs/references/GENERATED.md +8 -0
- package/plugins/task-pipeline/skills/evidence-docs/references/documentation.md +472 -0
- package/plugins/task-pipeline/skills/evidence-docs/references/gates.md +645 -0
- package/plugins/task-pipeline/skills/evidence-docs/references/hooks.md +279 -0
- package/plugins/task-pipeline/skills/evidence-docs/references/learned.md +292 -0
- package/plugins/task-pipeline/skills/evidence-docs/references/retrospective.md +551 -0
- package/plugins/task-pipeline/skills/evidence-docs/references/setup.md +149 -0
- package/plugins/task-pipeline/skills/evidence-docs/templates/decisions.md +50 -0
- package/plugins/task-pipeline/skills/evidence-docs/templates/docgate.sh +537 -0
- package/plugins/task-pipeline/skills/project-audit/SKILL.md +66 -25
- package/plugins/task-pipeline/skills/project-audit/scripts/audit.py +11 -0
- package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +71 -54
- package/plugins/task-pipeline/skills/task-pipeline/execution-attempt.schema.json +68 -0
- package/plugins/task-pipeline/skills/task-pipeline/execution-packet.example.json +42 -0
- package/plugins/task-pipeline/skills/task-pipeline/execution-packet.schema.json +217 -0
- package/plugins/task-pipeline/skills/task-pipeline/execution-result.example.json +49 -0
- package/plugins/task-pipeline/skills/task-pipeline/execution-result.schema.json +261 -0
- package/plugins/task-pipeline/skills/task-pipeline/graph.example.json +10 -1
- package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +172 -2
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +50 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/acceptance.md +7 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/artifacts.md +23 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/audit.md +6 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/backlog.md +8 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/browser.md +8 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/build.md +32 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/companion-skills.md +14 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/decomposition.md +83 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +53 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/documentation.md +3 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +27 -8
- package/plugins/task-pipeline/skills/task-pipeline/references/hooks.md +10 -5
- package/plugins/task-pipeline/skills/task-pipeline/references/model-tiering.md +19 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/planning.md +203 -26
- package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +26 -8
- package/plugins/task-pipeline/skills/task-pipeline/references/work-graph.md +7 -1
- package/plugins/task-pipeline/skills/task-pipeline/scripts/context_packets.py +686 -0
- package/plugins/task-pipeline/skills/task-pipeline/scripts/execution_authority.py +271 -0
- package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +415 -18
- package/plugins/task-pipeline/skills/task-pipeline/scripts/packet.py +400 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/README.md +2 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/browser-claims.json +54 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/finding-evidence.json +42 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/hooks.example.json +2 -2
- 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")
|
|
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
|
-
|
|
777
|
-
|
|
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
|
|
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")
|
|
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")
|
|
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
|
|
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
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
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):
|