@manhquy/flow-skill 0.1.0-rc.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1205 @@
1
+ """Graph executor: record/advance API over the 014 schema (stdlib only).
2
+
3
+ There is NO resident process. Each call is a short-lived invocation that records
4
+ evidence or computes next-steps from topology + journal; the LLM agent and flow.sh
5
+ own all sequencing and all LLM work. Exit contract (fail-closed, consumed by
6
+ flow.sh `harness_capture_checked`):
7
+ 0 = value on stdout / success
8
+ 3 = definitive no-next (complete) or paused on an open interrupt
9
+ 4 = harness unavailable / contradictory flags (FLOW_HARNESS_DISABLE set)
10
+ 1/2 = failure
11
+ Security-class interrupt resolution enforces the out-of-band DEBT-commit artifact
12
+ (closed-set target, captured DEBT line, committed provenance via HEAD blob + blame)
13
+ with resolved_by + session id stored as AUDIT evidence. Documented residual limit:
14
+ git authorship is forgeable by an agent with git-config control.
15
+ """
16
+
17
+ import hashlib
18
+ import json
19
+ import os
20
+ import re
21
+ import sqlite3
22
+ import subprocess
23
+ import sys
24
+ import time
25
+
26
+ import _db
27
+ import graph_ids
28
+ import graph_predicates as PRED
29
+
30
+ SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
31
+ TRUSTED_TOPOLOGY = os.path.join(SKILL_DIR, "references", "flow-topology.json")
32
+ TOPOLOGY_PIN = os.path.join(os.path.dirname(os.path.abspath(__file__)),
33
+ "pins", "flow-topology.sha256")
34
+
35
+ # Mirrors flow.sh cmd_skip's security-class regex (flow.sh:1242) - keep in sync.
36
+ SECURITY_RE = re.compile(
37
+ r"auth|authoriz|authorize|admin|tenan|payment|billing|password|token|secret|"
38
+ r"credential|permission|role|rbac|login|pii|data loss|migration|validation",
39
+ re.IGNORECASE,
40
+ )
41
+ # Closed-set interrupt targets: a card id or a stage name. Validated BEFORE any
42
+ # file/git lookup so no free-form text ever reaches a match pattern.
43
+ TARGET_RE = re.compile(r"^C-[0-9]{1,4}$|^[0-9]{2}-[a-z-]+$")
44
+
45
+ NODE_TYPES = {"gate_check", "git_op", "record_evidence", "interrupt"}
46
+
47
+
48
+ def _root():
49
+ return os.environ.get("FLOW_PROJECT_ROOT") or os.getcwd()
50
+
51
+
52
+ def _project_key():
53
+ """Project identity for rows in the SHARED db. Must be main-root scoped: stamping
54
+ basename(_root()) would record whichever worktree happened to mint the execution
55
+ (`proj-card-C-001`), and gc filtering by the caller's basename would then never
56
+ see it - terminal rows unpurgeable, stale sweep blind."""
57
+ root = _root()
58
+ return os.path.basename(_db._linked_worktree_main_root(root) or root)
59
+
60
+
61
+ def _session_id():
62
+ # Mirrors flow.sh _session_env_id cascade (flow.sh:353-368).
63
+ for var in ("FLOW_SESSION_ID", "CLAUDE_CODE_SESSION_ID", "CODEX_SESSION_ID",
64
+ "CODEX_THREAD_ID", "AGY_SESSION_ID", "ANTIGRAVITY_SESSION_ID"):
65
+ v = os.environ.get(var)
66
+ if v:
67
+ return v.replace("\r", "").replace("\n", "").replace("|", "")
68
+ return "ppid:%s:%s" % (os.uname().nodename if hasattr(os, "uname") else "host",
69
+ os.getppid())
70
+
71
+
72
+ def _git_env():
73
+ """git honours GIT_DIR/GIT_WORK_TREE from the environment, and every git HOOK
74
+ exports them (also `git rebase -x`, `bisect run`, `submodule foreach`). Inheriting
75
+ them silently points our worktree translation and merge proofs at a foreign repo."""
76
+ env = dict(os.environ)
77
+ for k in ("GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE",
78
+ "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"):
79
+ env.pop(k, None)
80
+ return env
81
+
82
+
83
+ def _git(root, *args):
84
+ try:
85
+ return subprocess.run(["git", "-C", root, *args], env=_git_env(),
86
+ capture_output=True, text=True, timeout=15)
87
+ except Exception:
88
+ return None
89
+
90
+
91
+ def _git_out(root, *args):
92
+ r = _git(root, *args)
93
+ return r.stdout if (r is not None and r.returncode == 0) else None
94
+
95
+
96
+ def _linked_main_root(root):
97
+ """Main-worktree path when `root` is inside a linked worktree, else None.
98
+
99
+ Delegates to the ONE resolver (`_db`), which handles submodules,
100
+ --separate-git-dir (core.worktree) and monorepo sub-projects - a second local
101
+ heuristic is exactly what drifted before."""
102
+ return _db._linked_worktree_main_root(root)
103
+
104
+
105
+ # ---------------- topology ----------------
106
+
107
+ def load_topology(path):
108
+ """Phase-2 fixture loader: plain JSON file. Phase 3 replaces this call site with
109
+ the install-dir-only, pin-verified loader + full lint."""
110
+ with open(path, encoding="utf-8") as fh:
111
+ topo = json.load(fh)
112
+ if not isinstance(topo.get("nodes"), dict) or not isinstance(topo.get("edges"), list):
113
+ raise ValueError("topology: nodes/edges missing")
114
+ for name, spec in topo["nodes"].items():
115
+ if spec.get("type") not in NODE_TYPES:
116
+ raise ValueError(f"topology: node {name} has type outside {sorted(NODE_TYPES)}")
117
+ return topo
118
+
119
+
120
+ def _successors(topo, node, latest_manifest):
121
+ out = []
122
+ for e in topo["edges"]:
123
+ if e.get("from") != node:
124
+ continue
125
+ pred = e.get("when", "always")
126
+ if _predicate(pred, latest_manifest):
127
+ out.append(e["to"])
128
+ return out
129
+
130
+
131
+ def _predicate(name, manifest):
132
+ # Registry lives in graph_predicates (exactly {always, review_green, review_red};
133
+ # red-team round 7: debt-skips are a traversal semantic, not predicates).
134
+ return PRED.evaluate(name, manifest)
135
+
136
+
137
+ def canonical_hash(topo):
138
+ # Whitespace-insensitive: hash the canonical JSON form, so cosmetic edits
139
+ # don't invalidate the pin or strand paused executions.
140
+ blob = json.dumps(topo, sort_keys=True, separators=(",", ":")).encode("utf-8")
141
+ return hashlib.sha256(blob).hexdigest()
142
+
143
+
144
+ def load_topology_trusted():
145
+ """Shipped-topology loader: skill install dir ONLY, pin-verified.
146
+
147
+ A project-local flow-topology.json is IGNORED with a warning (topology is
148
+ executable-adjacent data - a cloned repo must not be able to swap it), and a
149
+ pin mismatch REFUSES (exit path: ValueError -> clean rc 1), never just records.
150
+ Returns (topology, canonical_hash)."""
151
+ local = os.path.join(_root(), "flow-topology.json")
152
+ if os.path.exists(local):
153
+ sys.stderr.write("flow-graph: ignoring project-local flow-topology.json "
154
+ "(topology loads only from the skill install dir, pin-verified)\n")
155
+ topo = load_topology(TRUSTED_TOPOLOGY)
156
+ try:
157
+ with open(TOPOLOGY_PIN, encoding="utf-8") as fh:
158
+ pin = fh.read().split()[0]
159
+ except (OSError, IndexError):
160
+ raise ValueError(f"topology pin missing/unreadable at {TOPOLOGY_PIN}")
161
+ h = canonical_hash(topo)
162
+ if h != pin:
163
+ raise ValueError("topology pin mismatch - refusing to run. If the edit is "
164
+ "intentional, regenerate the pin (see harness/README.md)")
165
+ return topo, h
166
+
167
+
168
+ def _skipped_stages(root):
169
+ p = os.path.join(root, "flow", ".skipped")
170
+ try:
171
+ with open(p, encoding="utf-8") as fh:
172
+ return {ln.strip() for ln in fh if ln.strip()}
173
+ except OSError:
174
+ return set()
175
+
176
+
177
+ # A debt-skipped gate is ACCEPTED-WITH-DEBT: cmd_skip only advances after the DEBT +
178
+ # security-class + 05 guards, and its own contract line is "planning_complete now
179
+ # tolerates it" (flow.sh:1258). So for traversal purposes a skipped gate evaluates
180
+ # predicates as a PASS - its green out-edges are taken, red ones are not.
181
+ _SKIPPED_GATE_MANIFEST = {"gate": {"exit": 0, "skipped": True}}
182
+
183
+
184
+ def _substitute_skips(topo, nodes, skipped, _seen=None):
185
+ """Transitively replace gate_check nodes whose mapped `stage` is debt-skipped
186
+ with THEIR successors (round-7 traversal semantic - no bypass edges in data).
187
+ Topology never writes flow/.skipped; only cmd_skip does. `skipped` is the
188
+ stage-name set (callers pass _skipped_stages(root); lint passes subsets)."""
189
+ if not skipped:
190
+ return nodes
191
+ seen = _seen or set()
192
+ out = []
193
+ for n in nodes:
194
+ spec = topo["nodes"].get(n, {})
195
+ stage = spec.get("stage")
196
+ if spec.get("type") == "gate_check" and stage and stage in skipped:
197
+ if n in seen:
198
+ continue # cycle revisit: never re-emit a skipped gate (matches _skip_walk)
199
+ seen.add(n)
200
+ out.extend(_substitute_skips(
201
+ topo, _successors(topo, n, _SKIPPED_GATE_MANIFEST), skipped, seen))
202
+ else:
203
+ out.append(n)
204
+ return out
205
+
206
+
207
+ # ---------------- journal ----------------
208
+
209
+ def _latest_checkpoint(con, execution_id, ns):
210
+ return _db.one(con,
211
+ "SELECT * FROM graph_checkpoint WHERE execution_id=? AND ns=? "
212
+ "ORDER BY checkpoint_id DESC LIMIT 1", (execution_id, ns))
213
+
214
+
215
+ def _execution(con, execution_id):
216
+ return _db.one(con, "SELECT * FROM graph_execution WHERE id=?", (execution_id,))
217
+
218
+
219
+ def _open_interrupt(con, execution_id, ns=None):
220
+ if ns is None:
221
+ return _db.one(con, "SELECT * FROM graph_interrupt WHERE execution_id=? "
222
+ "AND status='open' ORDER BY created_at LIMIT 1", (execution_id,))
223
+ return _db.one(con, "SELECT * FROM graph_interrupt WHERE execution_id=? AND ns=? "
224
+ "AND status='open' ORDER BY created_at LIMIT 1", (execution_id, ns))
225
+
226
+
227
+ def _visits(con, execution_id, ns, node):
228
+ return con.execute("SELECT COUNT(*) FROM graph_checkpoint WHERE execution_id=? "
229
+ "AND ns=? AND node=?", (execution_id, ns, node)).fetchone()[0]
230
+
231
+
232
+ def plan_next(con, topo, execution_id, ns, root):
233
+ """Next node names for (execution, ns): entry roots before the first checkpoint,
234
+ else predicate-gated successors of the latest node, with skip-substitution."""
235
+ latest = _latest_checkpoint(con, execution_id, ns)
236
+ # Skips are written by cmd_skip at the main root; a card worktree must not see a
237
+ # different skip set than the journal was recorded under.
238
+ skipped = _skipped_stages(_db._linked_worktree_main_root(root) or root)
239
+ if latest is None:
240
+ if ns != "":
241
+ # Non-root namespaces (card:C-NNN) have no implicit entry: Phase 4 starts
242
+ # them by recording their dispatch node explicitly. Empty = nothing to do.
243
+ return []
244
+ entry = topo.get("entry") or []
245
+ return _substitute_skips(topo, list(entry[:1]), skipped)
246
+ manifest = json.loads(latest["manifest"])
247
+ nxt = _substitute_skips(topo, _successors(topo, latest["node"], manifest), skipped)
248
+ # A declared max_visits is a BOUND, not decoration: once a repair node has been
249
+ # visited that many times the two-strikes contract says escalate, so stop advising it
250
+ # (auto-run.md: strike 2 -> operator/cross-vendor, never an unbounded loop).
251
+ out = []
252
+ for n in nxt:
253
+ cap = topo["nodes"].get(n, {}).get("max_visits")
254
+ if cap and _visits(con, execution_id, ns, n) >= int(cap):
255
+ sys.stderr.write(f"flow-graph: {n} hit max_visits={cap} - escalate, not retry\n")
256
+ continue
257
+ out.append(n)
258
+ return out
259
+
260
+
261
+ # ---------------- verbs ----------------
262
+
263
+ def _guard_flags():
264
+ if os.environ.get("FLOW_HARNESS_DISABLE"):
265
+ sys.stderr.write(
266
+ "flow-graph: FLOW_HARNESS_DISABLE is set but a graph verb was invoked - "
267
+ "contradictory instruction (the executor IS the durable layer). Unset "
268
+ "FLOW_HARNESS_DISABLE to use graph commands.\n")
269
+ return 4
270
+ return None
271
+
272
+
273
+ def cmd_graph_run(con, a):
274
+ rc = _guard_flags()
275
+ if rc:
276
+ return rc
277
+ if a.story and _db.one(con, "SELECT 1 FROM story WHERE id=?", (a.story,)) is None:
278
+ sys.stderr.write(f"flow-graph: no story {a.story} - create it first "
279
+ "(story add --id ...) or omit --story\n")
280
+ return 1
281
+ eid = graph_ids.new_id()
282
+ with _db.transaction(con):
283
+ con.execute(
284
+ "INSERT INTO graph_execution (id,project,kind,topology_version,topology_hash,story_id) "
285
+ "VALUES (?,?,?,?,?,?)",
286
+ (eid, a.project or _project_key(), a.kind,
287
+ a.topology_version, a.topology_hash, a.story))
288
+ print(eid)
289
+ return 0
290
+
291
+
292
+ TERMINAL = ("done", "failed", "abandoned")
293
+
294
+
295
+ def _no_dupes(pairs):
296
+ seen = {}
297
+ for k, v in pairs:
298
+ if k in seen:
299
+ raise ValueError(f"duplicate key {k!r} in manifest")
300
+ seen[k] = v
301
+ return seen
302
+
303
+
304
+ def _build_manifest(a):
305
+ """Assemble the evidence manifest HERE, from typed flags - never from a JSON string
306
+ a shell concatenated. Card `status:` tokens and git ref names are attacker-adjacent
307
+ free text: spliced into a bash JSON literal, `status: x","gate":{"exit":0},"z":"`
308
+ silently overrides a RED gate with a green one (json keeps the last duplicate key).
309
+ A caller-supplied --manifest is still accepted for tests, but is parsed and
310
+ duplicate-key-rejected before it can reach the journal."""
311
+ m = {}
312
+ if a.manifest:
313
+ m = json.loads(a.manifest, object_pairs_hook=_no_dupes)
314
+ if not isinstance(m, dict):
315
+ raise ValueError("manifest must be a JSON object")
316
+ gate = {}
317
+ if getattr(a, "gate_exit", None) is not None:
318
+ gate["exit"] = int(a.gate_exit)
319
+ if getattr(a, "gate_cmd", None):
320
+ gate["cmd"] = a.gate_cmd
321
+ if gate:
322
+ m["gate"] = gate
323
+ for flag, key in (("card_status", "status"), ("branch", "branch"),
324
+ ("worktree", "worktree"), ("vendor", "vendor")):
325
+ v = getattr(a, flag, None)
326
+ if v:
327
+ m[key] = v
328
+ return json.dumps(m)
329
+
330
+
331
+ def cmd_graph_session(con, a):
332
+ """Print the project's current RUNNING auto_run execution, minting one atomically if
333
+ there is none (unless --no-create). SQLite's BEGIN IMMEDIATE is the arbitration point.
334
+
335
+ This replaces a bash pin file + mkdir claim: that design needed corpse recovery,
336
+ TOCTOU-free reclaim and a liveness check, and got all three wrong (a claim could be
337
+ stolen mid-mint, and an ABANDONED row still passed an existence-only check, silently
338
+ blackholing every later record). Here the query itself filters on status='running',
339
+ so a retired execution can never be adopted."""
340
+ rc = _guard_flags()
341
+ if rc:
342
+ return rc
343
+ project = a.project or _project_key()
344
+ kind = getattr(a, "kind", None) or "auto_run"
345
+ # running OR paused: a paused execution is waiting on an operator interrupt - still
346
+ # the live session (its resolution must land on the SAME execution). Only terminal
347
+ # states (done/failed/abandoned) are unadoptable. Planning and shipping keep separate
348
+ # sessions (different namespaces, different lifetimes).
349
+ q = ("SELECT id FROM graph_execution WHERE project=? AND kind=? "
350
+ "AND status IN ('running','paused') ORDER BY id DESC LIMIT 1")
351
+ row = _db.one(con, q, (project, kind))
352
+ if row:
353
+ print(row["id"])
354
+ return 0
355
+ if getattr(a, "no_create", False):
356
+ return 3
357
+ eid = graph_ids.new_id()
358
+ with _db.transaction(con):
359
+ row = con.execute(q, (project, kind)).fetchone() # re-check under the write lock
360
+ if row:
361
+ eid = row[0]
362
+ else:
363
+ con.execute(
364
+ "INSERT INTO graph_execution (id,project,kind,topology_version,topology_hash) "
365
+ "VALUES (?,?,?,?,?)", (eid, project, kind, 0, ""))
366
+ print(eid)
367
+ return 0
368
+
369
+
370
+ def cmd_graph_record(con, a):
371
+ rc = _guard_flags()
372
+ if rc:
373
+ return rc
374
+ ex = _execution(con, a.execution)
375
+ if ex is None:
376
+ sys.stderr.write(f"flow-graph: no execution {a.execution}\n")
377
+ return 1
378
+ if ex["status"] in TERMINAL:
379
+ # Terminal is terminal: a killed-at-gate execution must never be resurrected
380
+ # by a later record (the kill IS the operator decision).
381
+ sys.stderr.write(f"flow-graph: execution is terminal ({ex['status']}) - refusing record\n")
382
+ return 1
383
+ ns = a.ns or ""
384
+ try:
385
+ a.manifest = _build_manifest(a)
386
+ except (ValueError, TypeError) as e:
387
+ sys.stderr.write(f"flow-graph: bad manifest: {e}\n")
388
+ return 1
389
+ # Boundary, not heartbeat: re-running the SAME verb with the SAME evidence is a
390
+ # no-op. Without this, a second `flow.sh check` on a done card appends another
391
+ # card-review after card-verify-live and `plan_next` (which reads the LATEST
392
+ # checkpoint) rewinds the walk - advising re-verification of shipped work. A
393
+ # changed manifest (red -> green, repair cycles) still records, as it must.
394
+ if not getattr(a, "interrupt", False):
395
+ prev = _latest_checkpoint(con, a.execution, ns)
396
+ if prev and prev["node"] == a.node and prev["manifest"] == a.manifest:
397
+ print(prev["checkpoint_id"])
398
+ return 0
399
+ if os.environ.get("FLOW_GRAPH_TOPOLOGY_FIXTURE") != "1":
400
+ # Typo'd or contract-drifted node names would journal cleanly and then dead-end
401
+ # at `next` as rc 3 "complete". Validate against the shipped topology (fixtures
402
+ # opt out - they define their own node sets).
403
+ try:
404
+ topo_v, _ = load_topology_trusted()
405
+ if a.node not in topo_v["nodes"]:
406
+ sys.stderr.write(f"flow-graph: node {a.node!r} is not in the shipped topology\n")
407
+ return 1
408
+ except (OSError, ValueError):
409
+ pass # pin/loader problems surface on `next`/`lint`, not on a record
410
+ if getattr(a, "merge", False):
411
+ # The caller may NOT assert a merge: _ws_remove has no merge knowledge
412
+ # (flow.sh:2077+ only removes the tree), so the executor computes the proof.
413
+ root = _root()
414
+ branch = a.branch or ""
415
+ if not _safe_ref(branch):
416
+ sys.stderr.write("flow-graph: --merge needs --branch <ref>\n")
417
+ return 2
418
+ # Bases, in proof precedence: the LOCAL integration branch first (auto-run merges
419
+ # locally at step 5 and tears the tree down at step 8 with no push in between, so
420
+ # origin/HEAD is stale there and would journal shipped work as abandoned), then
421
+ # origin/HEAD as secondary confirmation. Never `rev-parse HEAD` of the current
422
+ # tree: inside a card worktree that IS the card branch (self-ancestor).
423
+ bases = []
424
+ if a.base:
425
+ bases = [a.base]
426
+ else:
427
+ main_root = _linked_main_root(root) or root
428
+ local = (_git_out(main_root, "rev-parse", "--abbrev-ref", "HEAD") or "").strip()
429
+ remote = (_git_out(root, "rev-parse", "--abbrev-ref", "origin/HEAD") or "").strip()
430
+ bases = [b for b in (local, remote) if b]
431
+ bases = [b for b in bases if _safe_ref(b) and b != branch]
432
+ if not bases:
433
+ sys.stderr.write("flow-graph: no usable merge base (self-ancestor or unresolvable) "
434
+ "- pass --base explicitly\n")
435
+ return 2
436
+ sha = (_git_out(root, "rev-parse", "--verify", "--quiet", branch, "--") or "").strip()
437
+ if not sha:
438
+ sys.stderr.write(f"flow-graph: branch {branch} not found\n")
439
+ return 1
440
+ merged, proved_by = False, None
441
+ for b in bases:
442
+ r = _git(root, "merge-base", "--is-ancestor", sha, b)
443
+ if r is not None and r.returncode == 0:
444
+ merged, proved_by = True, b
445
+ break
446
+ a.manifest = json.dumps({"branch": branch, "git_sha": sha,
447
+ "base": proved_by or bases[0], "bases_tried": bases,
448
+ "merged": merged})
449
+ if not merged:
450
+ # Unmerged teardown is abandonment, never a merge record.
451
+ a.node = "card-abandon"
452
+ latest = _latest_checkpoint(con, a.execution, ns)
453
+ versions = json.loads(latest["versions"]) if latest else {}
454
+ versions[a.node] = versions.get(a.node, 0) + 1
455
+ seen = json.loads(latest["seen"]) if latest else {}
456
+ seen[a.node] = dict(versions)
457
+ ck = graph_ids.new_id()
458
+ # Ordering is lexicographic over these ids, and a backwards clock step (NTP, resume
459
+ # from suspend, a second host on a shared checkout) would otherwise mint an id BELOW
460
+ # the journal head - the write lands but `_latest_checkpoint` never sees it.
461
+ if latest and ck <= latest["checkpoint_id"]:
462
+ ck = graph_ids.after(latest["checkpoint_id"])
463
+ meta = {"source": a.source, "step": (json.loads(latest["meta"])["step"] + 1) if latest else 0,
464
+ "ts": int(time.time())}
465
+ writes = json.loads(a.writes) if a.writes else []
466
+ if os.environ.get("FLOW_GRAPH_FAILPOINT") == "before-commit":
467
+ # test-only crash injection: proves the write below is atomic (rolls back whole)
468
+ try:
469
+ with _db.transaction(con):
470
+ con.execute("INSERT INTO graph_checkpoint "
471
+ "(execution_id,ns,checkpoint_id,parent_checkpoint_id,node,manifest,versions,seen,meta) "
472
+ "VALUES (?,?,?,?,?,?,?,?,?)",
473
+ (a.execution, ns, ck, a.parent, a.node, a.manifest or "{}",
474
+ json.dumps(versions), json.dumps(seen), json.dumps(meta)))
475
+ raise RuntimeError("failpoint")
476
+ except RuntimeError:
477
+ sys.stderr.write("flow-graph: FLOW_GRAPH_FAILPOINT=before-commit fired "
478
+ "(transaction rolled back)\n")
479
+ return 1
480
+ with _db.transaction(con):
481
+ for i, w in enumerate(writes):
482
+ con.execute("INSERT INTO graph_step_write "
483
+ "(execution_id,ns,checkpoint_id,task_id,idx,channel,value) "
484
+ "VALUES (?,?,?,?,?,?,?)",
485
+ (a.execution, ns, ck, w.get("task", "t0"), i,
486
+ w.get("channel", ""), json.dumps(w.get("value"))))
487
+ con.execute("INSERT INTO graph_checkpoint "
488
+ "(execution_id,ns,checkpoint_id,parent_checkpoint_id,node,manifest,versions,seen,meta) "
489
+ "VALUES (?,?,?,?,?,?,?,?,?)",
490
+ (a.execution, ns, ck, a.parent, a.node, a.manifest or "{}",
491
+ json.dumps(versions), json.dumps(seen), json.dumps(meta)))
492
+ if a.interrupt:
493
+ con.execute("INSERT INTO graph_interrupt "
494
+ "(execution_id,ns,interrupt_id,node,prompt,security_class) "
495
+ "VALUES (?,?,?,?,?,?)",
496
+ (a.execution, ns, graph_ids.new_id(), a.node,
497
+ a.prompt or "operator decision required", 1 if a.security_class else 0))
498
+ con.execute("UPDATE graph_execution SET status='paused', "
499
+ "updated_at=datetime('now') WHERE id=?", (a.execution,))
500
+ else:
501
+ con.execute("UPDATE graph_execution SET updated_at=datetime('now') WHERE id=?",
502
+ (a.execution,))
503
+ if a.interrupt:
504
+ # exit 3 = paused/no-value by contract: no stdout (the id is in `status`)
505
+ return 3
506
+ print(ck)
507
+ return 0
508
+
509
+
510
+ def cmd_graph_next(con, a):
511
+ rc = _guard_flags()
512
+ if rc:
513
+ return rc
514
+ ex = _execution(con, a.execution)
515
+ if ex is None:
516
+ sys.stderr.write(f"flow-graph: no execution {a.execution}\n")
517
+ return 1
518
+ if _open_interrupt(con, a.execution, a.ns or ""):
519
+ sys.stderr.write("flow-graph: this namespace has an open interrupt - "
520
+ "resolve it via `graph resume --interrupt-id ...`\n")
521
+ return 3
522
+ if ex["status"] == "paused" and _open_interrupt(con, a.execution):
523
+ sys.stderr.write("flow-graph: execution paused on an open interrupt - "
524
+ "resolve via `graph resume --answer ... --actor ...`\n")
525
+ return 3
526
+ if ex["status"] in TERMINAL:
527
+ sys.stderr.write(f"flow-graph: execution is terminal ({ex['status']})\n")
528
+ return 3
529
+ try:
530
+ if a.topology:
531
+ # Fixture-only surface: an ungated explicit path would bypass the pin, the
532
+ # lint, AND the hash policy in one flag (review H2) - a cloned repo could
533
+ # walk arbitrary topology. Tests opt in via env; production never does.
534
+ if os.environ.get("FLOW_GRAPH_TOPOLOGY_FIXTURE") != "1":
535
+ sys.stderr.write(
536
+ "flow-graph: --topology is a test-fixture surface; set "
537
+ "FLOW_GRAPH_TOPOLOGY_FIXTURE=1 to use it. Production walks the "
538
+ "shipped pin-verified topology only.\n")
539
+ return 2
540
+ sys.stderr.write(f"flow-graph: WARNING fixture topology in use: {a.topology} "
541
+ "(pin/lint/hash policy bypassed - journal hash unchanged)\n")
542
+ topo = load_topology(a.topology)
543
+ else:
544
+ topo, h = load_topology_trusted()
545
+ exh = ex["topology_hash"]
546
+ if not exh:
547
+ # Dark-phase executions were created with an empty hash: back-fill on
548
+ # the first verified walk so the upgrade guard applies from then on.
549
+ _db.update_where(con, "graph_execution", {"id": a.execution},
550
+ topology_hash=h,
551
+ topology_version=topo.get("topology_version", 1))
552
+ elif exh != h:
553
+ if getattr(a, "force_retopology", False):
554
+ # Fork onto the new topology: a fork checkpoint at the current node
555
+ # keeps the old chain walkable, then the execution re-pins its hash.
556
+ latest = _latest_checkpoint(con, a.execution, a.ns or "")
557
+ with _db.transaction(con):
558
+ if latest:
559
+ meta = json.loads(latest["meta"])
560
+ con.execute(
561
+ "INSERT INTO graph_checkpoint (execution_id,ns,checkpoint_id,"
562
+ "parent_checkpoint_id,node,manifest,versions,seen,meta) "
563
+ "VALUES (?,?,?,?,?,?,?,?,?)",
564
+ (a.execution, a.ns or "", graph_ids.new_id(),
565
+ latest["checkpoint_id"], latest["node"], latest["manifest"],
566
+ latest["versions"], latest["seen"],
567
+ json.dumps({"source": "fork", "step": meta["step"] + 1,
568
+ "ts": int(time.time())})))
569
+ con.execute("UPDATE graph_execution SET topology_hash=?, "
570
+ "topology_version=?, updated_at=datetime('now') WHERE id=?",
571
+ (h, topo.get("topology_version", 1), a.execution))
572
+ else:
573
+ sys.stderr.write(
574
+ "flow-graph: the shipped topology changed since this execution "
575
+ "started (hash mismatch - e.g. a skill upgrade). Refusing to walk "
576
+ "a chain recorded under different semantics. Re-run with "
577
+ "--force-retopology to fork onto the current topology.\n")
578
+ return 1
579
+ nxt = plan_next(con, topo, a.execution, a.ns or "", _root())
580
+ except (OSError, ValueError) as e:
581
+ # Bad/missing topology, pin mismatch, or unregistered predicate: a clean
582
+ # failure, never a traceback (rc 1; 3 stays reserved for complete/paused).
583
+ sys.stderr.write(f"flow-graph: topology error: {e}\n")
584
+ return 1
585
+ if not nxt:
586
+ return 3
587
+ print(nxt[0])
588
+ return 0
589
+
590
+
591
+ def _debt_provenance(root, target):
592
+ """(ok, why, debt_line, author_mail) — out-of-band artifact checks (a)+(b).
593
+
594
+ (a) closed-set target, DEBT-row-restricted fixed-string match, FULL line captured
595
+ (the capture is $debt_line's ONLY provenance; empty capture refuses).
596
+ (b) committed provenance against HEAD's blob of the SAME file: refuse on ANY
597
+ uncommitted DEBT.md change, locate the captured line in
598
+ `git show HEAD:<prefix>DEBT.md`, then blame that line at HEAD for author-mail.
599
+ """
600
+ if not TARGET_RE.match(target or ""):
601
+ return False, "target outside closed set", None, None
602
+ debt_path = os.path.join(root, "DEBT.md")
603
+ try:
604
+ with open(debt_path, encoding="utf-8") as fh:
605
+ rows = [ln.rstrip("\n") for ln in fh]
606
+ except OSError:
607
+ return False, "no DEBT.md", None, None
608
+ debt_line = next((ln for ln in rows
609
+ if ln.startswith("- [ ] DEBT:") and target in ln), "")
610
+ if not debt_line:
611
+ return False, "no open DEBT line naming the target", None, None
612
+ r = _git(root, "diff", "--quiet", "HEAD", "--", "DEBT.md")
613
+ if r is None or r.returncode != 0:
614
+ return False, "DEBT.md has uncommitted changes (or git unavailable)", None, None
615
+ pfx = (_git_out(root, "rev-parse", "--show-prefix") or "").strip()
616
+ blob = _git_out(root, "show", f"HEAD:{pfx}DEBT.md")
617
+ if blob is None:
618
+ return False, "DEBT.md not committed", None, None
619
+ n = next((i + 1 for i, ln in enumerate(blob.splitlines()) if ln == debt_line), 0)
620
+ if not n:
621
+ return False, "captured DEBT line absent from HEAD blob", None, None
622
+ porc = _git_out(root, "blame", "-w", "--line-porcelain",
623
+ "-L", f"{n},{n}", "HEAD", "--", "DEBT.md") or ""
624
+ mail = next((ln[len("author-mail "):].strip("<> ")
625
+ for ln in porc.splitlines() if ln.startswith("author-mail ")), None)
626
+ return True, "", debt_line, mail
627
+
628
+
629
+ def cmd_graph_resume(con, a):
630
+ rc = _guard_flags()
631
+ if rc:
632
+ return rc
633
+ ex = _execution(con, a.execution)
634
+ if ex is None:
635
+ sys.stderr.write(f"flow-graph: no execution {a.execution}\n")
636
+ return 1
637
+ if ex["status"] in TERMINAL:
638
+ sys.stderr.write(f"flow-graph: execution is terminal ({ex['status']}) - refusing resume\n")
639
+ return 1
640
+ # Which interrupt is being answered must be EXPLICIT when more than one is open.
641
+ # Resolving "whichever came first" let an operator answer a benign interrupt and
642
+ # un-pause an execution whose security-class interrupt was still open - the guard
643
+ # chain (closed-set target, committed DEBT provenance) was simply never reached.
644
+ opens = _db.rows(con, "SELECT * FROM graph_interrupt WHERE execution_id=? AND status='open' "
645
+ "ORDER BY created_at, ns, interrupt_id", (a.execution,))
646
+ if getattr(a, "interrupt_id", None):
647
+ opens = [r for r in opens if r["interrupt_id"] == a.interrupt_id]
648
+ elif getattr(a, "ns", None) is not None and a.ns != "":
649
+ opens = [r for r in opens if r["ns"] == a.ns]
650
+ if len(opens) > 1 and (a.answer or a.actor):
651
+ sys.stderr.write(
652
+ "flow-graph: %d interrupts are open - name the one you are answering with "
653
+ "--interrupt-id (or --ns):\n" % len(opens))
654
+ for r in opens:
655
+ sys.stderr.write(" %s ns=%s node=%s security_class=%s\n"
656
+ % (r["interrupt_id"], r["ns"] or "''", r["node"],
657
+ r["security_class"]))
658
+ return 2
659
+ intr = opens[0] if opens else None
660
+ if intr is None:
661
+ if a.answer or a.actor:
662
+ sys.stderr.write("flow-graph: no open interrupt matches this execution/selector\n")
663
+ return 1
664
+ # No interrupt + no answer: reconciliation report. For each ns, verify the
665
+ # latest manifest's durable claims against git so evidence-backed work is
666
+ # never re-dispatched even if a recording call was missed.
667
+ root = _root()
668
+ report = []
669
+ for row in _db.rows(con,
670
+ "SELECT ns, MAX(checkpoint_id) AS ck FROM graph_checkpoint "
671
+ "WHERE execution_id=? GROUP BY ns", (a.execution,)):
672
+ full = _db.one(con, "SELECT node, manifest FROM graph_checkpoint "
673
+ "WHERE execution_id=? AND ns=? AND checkpoint_id=?",
674
+ (a.execution, row["ns"], row["ck"]))
675
+ manifest = json.loads(full["manifest"])
676
+ report.append({"ns": row["ns"], "node": full["node"],
677
+ "evidence": verify_evidence(root, manifest)})
678
+ print(json.dumps({"execution": a.execution, "reconciliation": report}))
679
+ return 0
680
+ if not a.answer or not a.actor:
681
+ sys.stderr.write("flow-graph: resume needs --answer <json> and --actor <id>\n")
682
+ return 2
683
+ try:
684
+ ans = json.loads(a.answer)
685
+ reason = ans["reason"]
686
+ assert isinstance(reason, str) and reason.strip()
687
+ except Exception:
688
+ sys.stderr.write("flow-graph: --answer must be a JSON object with a non-empty "
689
+ "string \"reason\"\n")
690
+ return 1
691
+ root = _root()
692
+ audit_session = _session_id()
693
+ author_distinct = None
694
+ if a.target and not TARGET_RE.match(a.target):
695
+ sys.stderr.write("flow-graph: BLOCKED - target outside the closed set "
696
+ "(C-NNN or NN-stage)\n")
697
+ return 1
698
+ if intr["security_class"]:
699
+ if SECURITY_RE.search(reason):
700
+ sys.stderr.write("flow-graph: BLOCKED - that reason looks security-class; "
701
+ "security resolutions are operator-only (mirror of cmd_skip)\n")
702
+ return 1
703
+ target = a.target or ""
704
+ ok, why, debt_line, mail = _debt_provenance(root, target)
705
+ if not ok:
706
+ sys.stderr.write(f"flow-graph: BLOCKED - {why}. A committed open DEBT line "
707
+ "naming the target is required (out-of-band artifact).\n")
708
+ return 1
709
+ cfg = (_git_out(root, "config", "user.email") or "").strip()
710
+ # Audit evidence, NOT authorization: same-identity environments degrade
711
+ # (documented) to committed-provenance + audit fields.
712
+ author_distinct = bool(mail and cfg and mail != cfg)
713
+ with _db.transaction(con):
714
+ con.execute("UPDATE graph_interrupt SET status='resolved', resume_value=?, "
715
+ "resolved_by=?, resolved_at=datetime('now') "
716
+ "WHERE execution_id=? AND ns=? AND interrupt_id=?",
717
+ (json.dumps({"answer": ans, "actor": a.actor,
718
+ "session": audit_session,
719
+ "author_distinct": author_distinct}),
720
+ a.actor, intr["execution_id"], intr["ns"], intr["interrupt_id"]))
721
+ # Un-pause only when NOTHING is still open: resolving one interrupt must not
722
+ # release an execution whose security-class interrupt is still waiting.
723
+ left = con.execute("SELECT COUNT(*) FROM graph_interrupt WHERE execution_id=? "
724
+ "AND status='open'", (a.execution,)).fetchone()[0]
725
+ if not left:
726
+ con.execute("UPDATE graph_execution SET status='running', "
727
+ "updated_at=datetime('now') WHERE id=?", (a.execution,))
728
+ if left:
729
+ sys.stderr.write(f"flow-graph: {left} interrupt(s) still open - execution stays paused\n")
730
+ print(intr["interrupt_id"])
731
+ return 0
732
+
733
+
734
+ def _safe_ref(v):
735
+ # Manifest values reach git argv: refuse anything option-shaped so a crafted
736
+ # manifest cannot smuggle git flags (evidence spoofing).
737
+ return isinstance(v, str) and v and not v.startswith("-")
738
+
739
+
740
+ def verify_evidence(root, manifest):
741
+ """Git reconciliation: check a manifest's durable claims against reality.
742
+ Consumed by `graph resume` (no-args reconciliation report) so evidence-backed
743
+ work is never re-dispatched even if a recording call was missed (the
744
+ between-invocations window)."""
745
+ out = {}
746
+ branch = manifest.get("branch")
747
+ if _safe_ref(branch):
748
+ out["branch_exists"] = _git_out(root, "rev-parse", "--verify",
749
+ "--quiet", branch, "--") is not None
750
+ sha, base = manifest.get("git_sha"), manifest.get("base")
751
+ if _safe_ref(sha) and _safe_ref(base):
752
+ r = _git(root, "merge-base", "--is-ancestor", sha, base)
753
+ out["merged"] = bool(r is not None and r.returncode == 0)
754
+ return out
755
+
756
+
757
+ def cmd_graph_status(con, a):
758
+ rc = _guard_flags()
759
+ if rc:
760
+ return rc
761
+ ex = _execution(con, a.execution)
762
+ if ex is None:
763
+ sys.stderr.write(f"flow-graph: no execution {a.execution}\n")
764
+ return 1
765
+ latest = _db.rows(con,
766
+ "SELECT ns, MAX(checkpoint_id) AS checkpoint_id, node FROM graph_checkpoint "
767
+ "WHERE execution_id=? GROUP BY ns", (a.execution,))
768
+ opens = _db.rows(con, "SELECT ns, interrupt_id, node, security_class FROM graph_interrupt "
769
+ "WHERE execution_id=? AND status='open'", (a.execution,))
770
+ print(json.dumps({"execution": dict(ex), "latest": latest, "open_interrupts": opens}))
771
+ return 0
772
+
773
+
774
+ def cmd_graph_abandon(con, a):
775
+ rc = _guard_flags()
776
+ if rc:
777
+ return rc
778
+ with _db.transaction(con):
779
+ cur = con.execute("UPDATE graph_execution SET status='abandoned', outcome=?, "
780
+ "updated_at=datetime('now') WHERE id=?",
781
+ (a.outcome, a.execution))
782
+ if not cur.rowcount:
783
+ raise sqlite3.IntegrityError(f"no execution {a.execution}")
784
+ con.execute("UPDATE graph_interrupt SET status='abandoned' "
785
+ "WHERE execution_id=? AND status='open'", (a.execution,))
786
+ return 0
787
+
788
+
789
+ # ---------------- card DAG (Phase 4) ----------------
790
+
791
+ _CARD_RE = re.compile(r"C-[0-9]+", re.IGNORECASE)
792
+
793
+
794
+ def _card_id(tok):
795
+ return "C-%03d" % int(tok.split("-")[1]) # C-1 == C-001
796
+
797
+
798
+ def _read_cards(cards_dir):
799
+ """Parse the REAL card format (no YAML frontmatter): a `deps:` body line whose
800
+ free text is scraped for C-NNN ids (mirrors flow.sh:1310-1319) and a
801
+ `## Allowed files` markdown section (mirrors _card_allowed_files, flow.sh:1289)
802
+ tokenized like _ws_tokens (flow.sh:1948) so overlap can never diverge."""
803
+ cards = {}
804
+ if not os.path.isdir(cards_dir):
805
+ return cards
806
+ for name in sorted(os.listdir(cards_dir)):
807
+ m = re.match(r"^(C-[0-9]+)\.md$", name)
808
+ if not m:
809
+ continue
810
+ cid = _card_id(m.group(1))
811
+ status, deps, files, in_allowed, fill = "", [], set(), False, False
812
+ with open(os.path.join(cards_dir, name), encoding="utf-8", errors="replace") as fh:
813
+ for line in fh:
814
+ s = line.rstrip("\n")
815
+ if s.startswith("## Allowed files"):
816
+ in_allowed = True
817
+ continue
818
+ if in_allowed and s.startswith("## "):
819
+ in_allowed = False
820
+ if in_allowed and s.strip():
821
+ if "[FILL" in s:
822
+ fill = True
823
+ continue
824
+ # Mirror _ws_tokens EXACTLY (flow.sh:2035): strip one leading bullet
825
+ # (incl. tabs), split on whitespace, then DELETE backticks/commas
826
+ # (deleting, not splitting, is what joins `a.ts`,`b.ts` the same way).
827
+ for tok in re.sub(r"^\s*-\s*", "", s).split():
828
+ # \r too: bash `tr -s ' \t'` leaves it, so a CRLF checkout would
829
+ # otherwise compare `src/a.ts\r` against `src/a.ts` and miss overlap.
830
+ tok = tok.replace("`", "").replace(",", "").replace("\r", "")
831
+ if tok:
832
+ files.add(tok)
833
+ elif s.startswith("status:") and not status:
834
+ status = s.split(":", 1)[1].strip().split()[0] if s.split(":", 1)[1].strip() else ""
835
+ elif s.startswith("deps:") and not deps:
836
+ deps = [_card_id(t) for t in _CARD_RE.findall(s)]
837
+ # A self-dep is kept (not silently dropped): cmd_ready blocks such a card, so the
838
+ # compiler must too - dropping it would advertise an undispatchable card as ready.
839
+ cards[cid] = {"id": cid, "status": status, "deps": sorted(set(deps)),
840
+ "files": sorted(files), "fill": fill}
841
+ return cards
842
+
843
+
844
+ def _dep_cycles(cards):
845
+ color, out = {}, []
846
+ path = []
847
+
848
+ def dfs(n):
849
+ color[n] = 1
850
+ path.append(n)
851
+ for d in cards.get(n, {}).get("deps", []):
852
+ if d not in cards:
853
+ continue
854
+ if color.get(d) == 1:
855
+ out.append(path[path.index(d):] + [d])
856
+ elif color.get(d, 0) == 0:
857
+ dfs(d)
858
+ path.pop()
859
+ color[n] = 2
860
+
861
+ for n in cards:
862
+ if color.get(n, 0) == 0:
863
+ dfs(n)
864
+ return out
865
+
866
+
867
+ def compile_cards(root, active_files=()):
868
+ """Card DAG + ready-set. deps-met mirrors cmd_ready (status of each dep == done);
869
+ overlap serialization is NEW deliberate behavior (legacy `ready` only advises,
870
+ flow.sh:1302): cards sharing an allowed-file token, or overlapping a currently
871
+ active worktree's tokens, are held back so two agents never edit one file."""
872
+ cards = _read_cards(os.path.join(root, "cards"))
873
+ cycles = _dep_cycles(cards)
874
+ # Only `todo` is buildable - cmd_ready's exact semantics (an invalid/blank status is
875
+ # a gate violation, not a dispatchable card).
876
+ todo = [c for c in cards.values() if c["status"] == "todo"]
877
+ in_cycle = {n for cyc in cycles for n in cyc}
878
+ deps_met, blocked = [], {}
879
+ for c in sorted(todo, key=lambda x: x["id"]):
880
+ missing = [d for d in c["deps"] if cards.get(d, {}).get("status") != "done"]
881
+ if c["id"] in in_cycle:
882
+ blocked[c["id"]] = {"reason": "dep cycle",
883
+ "cycle": next("->".join(x) for x in cycles if c["id"] in x)}
884
+ elif missing:
885
+ blocked[c["id"]] = {"reason": "deps", "missing": missing}
886
+ elif c["fill"]:
887
+ blocked[c["id"]] = {"reason": "allowed-files still has [FILL"}
888
+ else:
889
+ deps_met.append(c["id"])
890
+ ready, taken = [], set(active_files)
891
+ for cid in deps_met:
892
+ f = set(cards[cid]["files"])
893
+ if f & taken:
894
+ blocked[cid] = {"reason": "allowed-files overlap",
895
+ "with": sorted(f & taken)}
896
+ continue
897
+ taken |= f
898
+ ready.append(cid)
899
+ return {"cards": {k: v for k, v in sorted(cards.items())},
900
+ "deps_met": deps_met, "ready": ready, "blocked": blocked,
901
+ "cycles": ["->".join(c) for c in cycles]}
902
+
903
+
904
+ def cmd_graph_cards(con, a):
905
+ rc = _guard_flags()
906
+ if rc:
907
+ return rc
908
+ active = []
909
+ if a.active_files:
910
+ active = [t for t in a.active_files.replace(",", " ").split() if t]
911
+ out = compile_cards(_root(), active)
912
+ # A dep cycle blocks its OWN members, never the board: cmd_ready keeps advising every
913
+ # other buildable card, and auto-run routes all dispatch through this verb - one
914
+ # authoring typo must not halt a healthy run. rc 1 stays for an unreadable cards/.
915
+ for c in out["cycles"]:
916
+ sys.stderr.write(f"flow-graph: card dependency cycle: {c}\n")
917
+ print(json.dumps(out))
918
+ return 0
919
+
920
+
921
+ def cmd_graph_root(con, a):
922
+ """Print the directory that owns this project's durable state. Single source of
923
+ truth for main-tree scoping: flow.sh asks THIS instead of re-deriving the worktree
924
+ translation with a weaker heuristic (a second implementation drifted on monorepo
925
+ sub-projects and --separate-git-dir repos, re-splitting the journal)."""
926
+ print(os.path.dirname(_db.default_db_path()))
927
+ return 0
928
+
929
+
930
+ # ---------------- lint ----------------
931
+
932
+ # Autonomous cmd position allows ONLY read-only surfaces: flow.sh's scan-only `gate`
933
+ # verb and side-effect-free git queries. `check` mutates story/trace on pass and is
934
+ # Must-ask - a shape row for it would read as conditional permission (round-9 F1).
935
+ _CMD_GIT_VERBS = {"worktree", "merge-base", "rev-parse", "status", "log"}
936
+
937
+
938
+ def _lint_cmd(name, cmd):
939
+ errs = []
940
+ if not isinstance(cmd, list) or not cmd or not all(isinstance(x, str) for x in cmd):
941
+ return [f"node {name}: cmd must be a non-empty argv array of strings"]
942
+ for tok in cmd:
943
+ if "{" in tok and tok != "{card}":
944
+ errs.append(f"node {name}: unknown placeholder {tok!r} (only {{card}})")
945
+ prog = cmd[0]
946
+ if prog == "flow.sh":
947
+ verb = cmd[1] if len(cmd) > 1 else ""
948
+ if verb != "gate":
949
+ errs.append(f"node {name}: flow.sh verb {verb!r} banned in autonomous cmd "
950
+ "position (only the read-only `gate` verb)")
951
+ else:
952
+ args = cmd[2:]
953
+ ok = (len(args) == 1 and args[0] in PRED.STAGES) or args == ["--card", "{card}"]
954
+ if not ok:
955
+ errs.append(f"node {name}: gate arg shape must be a STAGES member "
956
+ f"or ['--card','{{card}}'] (got {args})")
957
+ elif prog == "git":
958
+ if len(cmd) < 2 or cmd[1] not in _CMD_GIT_VERBS:
959
+ errs.append(f"node {name}: git verb outside {sorted(_CMD_GIT_VERBS)}")
960
+ else:
961
+ errs.append(f"node {name}: argv[0] {prog!r} outside {{flow.sh, git}}")
962
+ return errs
963
+
964
+
965
+ def _reachable(topo, roots):
966
+ seen, stack = set(), [r for r in roots if r in topo["nodes"]]
967
+ while stack:
968
+ n = stack.pop()
969
+ if n in seen:
970
+ continue
971
+ seen.add(n)
972
+ stack.extend(e["to"] for e in topo["edges"] if e["from"] == n and e["to"] in topo["nodes"])
973
+ return seen
974
+
975
+
976
+ def _skip_walk(topo, root, skipped):
977
+ """Reachability under plan_next's skip-substitution: skipped gates pass through
978
+ (their edges evaluated against the accepted-with-debt PASS manifest); all other
979
+ edges are treated as traversable (their predicates depend on runtime gates)."""
980
+ smap = PRED.stage_map(topo)
981
+ seen, landed, stack = set(), set(), [root]
982
+ while stack:
983
+ n = stack.pop()
984
+ if n in seen or n not in topo["nodes"]:
985
+ continue
986
+ seen.add(n)
987
+ if smap.get(n) in skipped:
988
+ for e in topo["edges"]:
989
+ if e["from"] == n and _predicate(e.get("when", "always"),
990
+ _SKIPPED_GATE_MANIFEST):
991
+ stack.append(e["to"])
992
+ continue
993
+ landed.add(n)
994
+ stack.extend(e["to"] for e in topo["edges"] if e["from"] == n)
995
+ return landed
996
+
997
+
998
+ def _lint_cycles(topo):
999
+ """Planning (stage-carrying) subgraphs must be acyclic; every cycle anywhere
1000
+ must pass through a node carrying max_visits (the bounded-repair contract)."""
1001
+ errs = []
1002
+ nodes = topo["nodes"]
1003
+ adj = {}
1004
+ for e in topo["edges"]:
1005
+ adj.setdefault(e["from"], []).append(e["to"])
1006
+ color, stack_path = {}, []
1007
+ cycles = []
1008
+
1009
+ def dfs(n):
1010
+ color[n] = 1
1011
+ stack_path.append(n)
1012
+ for m in adj.get(n, []):
1013
+ if m not in nodes:
1014
+ continue
1015
+ if color.get(m) == 1:
1016
+ cycles.append(stack_path[stack_path.index(m):] + [m])
1017
+ elif color.get(m, 0) == 0:
1018
+ dfs(m)
1019
+ stack_path.pop()
1020
+ color[n] = 2
1021
+
1022
+ for n in nodes:
1023
+ if color.get(n, 0) == 0:
1024
+ dfs(n)
1025
+ smap = PRED.stage_map(topo)
1026
+ for cyc in cycles:
1027
+ if any(smap.get(n) for n in cyc):
1028
+ errs.append(f"planning subgraph cycle: {'->'.join(cyc)}")
1029
+ if not any(nodes[n].get("max_visits") for n in set(cyc)):
1030
+ errs.append(f"unbounded cycle (no max_visits node): {'->'.join(cyc)}")
1031
+ return errs
1032
+
1033
+
1034
+ def _lint(topo, pin_path=None):
1035
+ errs = []
1036
+ nodes, edges = topo["nodes"], topo["edges"]
1037
+ entry = topo.get("entry") or []
1038
+ if not entry:
1039
+ errs.append("entry: missing or empty (unreachability is defined against entry roots)")
1040
+ for e in edges:
1041
+ for k in ("from", "to"):
1042
+ if e.get(k) not in nodes:
1043
+ errs.append(f"edge {e.get('from')}->{e.get('to')}: unknown node ref {e.get(k)!r}")
1044
+ w = e.get("when", "always")
1045
+ if w not in PRED.REGISTRY:
1046
+ errs.append(f"edge {e.get('from')}->{e.get('to')}: unregistered predicate {w!r}")
1047
+ for name, spec in nodes.items():
1048
+ if spec.get("cmd") is not None:
1049
+ errs += _lint_cmd(name, spec["cmd"])
1050
+ # Green-stranded gate (review H3): a gate_check with out-edges but none
1051
+ # satisfiable on a PASSING gate would dead-end at runtime with rc 3 -
1052
+ # indistinguishable from real completion. Terminals (no out-edges) are exempt.
1053
+ if spec.get("type") == "gate_check":
1054
+ outs = [e for e in edges if e.get("from") == name]
1055
+ if outs and not any(_predicate(e.get("when", "always"), _SKIPPED_GATE_MANIFEST)
1056
+ for e in outs if e.get("when", "always") in PRED.REGISTRY):
1057
+ errs.append(f"node {name}: green-stranded gate (no out-edge satisfiable "
1058
+ "on a passing gate - runtime would report complete)")
1059
+ reach_all = set()
1060
+ for r in entry:
1061
+ if r not in nodes:
1062
+ errs.append(f"entry root {r!r} is not a node")
1063
+ continue
1064
+ sub = _reachable(topo, [r])
1065
+ reach_all |= sub
1066
+ # Planning-subgraph discriminator: it contains the UNSKIPPABLE terminal stage
1067
+ # (05-contract). "Any node has a stage" would be self-defeating: a rogue stage
1068
+ # on card-review would flip the whole card subgraph to planning and legalize
1069
+ # itself (round-9 F1) - exactly the debt-skippable review gate to prevent.
1070
+ planningy = PRED.STAGES[-1] in {nodes[n].get("stage") for n in sub}
1071
+ for n in sub:
1072
+ if nodes[n].get("type") != "gate_check":
1073
+ continue
1074
+ st = nodes[n].get("stage")
1075
+ if planningy and st not in PRED.STAGES:
1076
+ errs.append(f"node {n}: planning gate_check needs a `stage` in STAGES (got {st!r})")
1077
+ if not planningy and st:
1078
+ errs.append(f"node {n}: gate_check declares stage {st!r} in a subgraph "
1079
+ f"without the {PRED.STAGES[-1]} terminal - card subgraphs must "
1080
+ "not declare `stage` (it would become debt-skippable), and a "
1081
+ f"planning subgraph must include {PRED.STAGES[-1]}")
1082
+ # skip-reachability, scoped per subgraph (round-8 F1 + review H3): planning
1083
+ # roots must reach the 05-CONTRACT node itself - not just any out-edge-less
1084
+ # node - under EVERY subset of the skippable stages (2^5 = 32).
1085
+ terminals = {n for n in sub if not any(e["from"] == n for e in edges)}
1086
+ if planningy:
1087
+ term_nodes = {n for n in sub if nodes[n].get("stage") == PRED.STAGES[-1]}
1088
+ skippable = [s for s in PRED.SKIPPABLE_STAGES
1089
+ if s in set(PRED.stage_map(topo).values())]
1090
+ for mask in range(1 << len(skippable)):
1091
+ subset = {skippable[i] for i in range(len(skippable)) if mask >> i & 1}
1092
+ if not (_skip_walk(topo, r, subset) & term_nodes):
1093
+ errs.append(f"root {r}: the {PRED.STAGES[-1]} terminal is not "
1094
+ f"reachable when skipped={sorted(subset)}")
1095
+ break
1096
+ elif terminals and not (_skip_walk(topo, r, set()) & terminals):
1097
+ errs.append(f"root {r}: no terminal reachable")
1098
+ for n in nodes:
1099
+ if entry and n not in reach_all:
1100
+ errs.append(f"node {n}: unreachable from entry roots")
1101
+ errs += _lint_cycles(topo)
1102
+ if pin_path:
1103
+ try:
1104
+ with open(pin_path, encoding="utf-8") as fh:
1105
+ pin = fh.read().split()[0]
1106
+ except (OSError, IndexError):
1107
+ pin = None
1108
+ if pin != canonical_hash(topo):
1109
+ errs.append(f"pin mismatch vs {pin_path} (regenerate per harness/README.md "
1110
+ "if the topology edit is intentional)")
1111
+ return errs
1112
+
1113
+
1114
+ def cmd_graph_lint(con, a):
1115
+ rc = _guard_flags()
1116
+ if rc:
1117
+ return rc
1118
+ path = a.topology or TRUSTED_TOPOLOGY
1119
+ pin = a.pin or (TOPOLOGY_PIN if not a.topology else None)
1120
+ try:
1121
+ topo = load_topology(path)
1122
+ except (OSError, ValueError) as e:
1123
+ sys.stderr.write(f"flow-graph: lint: {e}\n")
1124
+ return 1
1125
+ try:
1126
+ errs = _lint(topo, pin_path=pin)
1127
+ except (AttributeError, TypeError, KeyError, RecursionError) as e:
1128
+ # Malformed shapes (node spec as string, edge as scalar, absurd depth) must
1129
+ # produce a lint line, never a traceback - lint is an operator-facing tool.
1130
+ sys.stderr.write(f"flow-graph: lint: malformed topology: {type(e).__name__}: {e}\n")
1131
+ return 1
1132
+ if errs:
1133
+ for e in errs:
1134
+ sys.stderr.write(f"flow-graph: lint: {e}\n")
1135
+ return 1
1136
+ print(json.dumps({"ok": True, "nodes": len(topo["nodes"]),
1137
+ "edges": len(topo["edges"]),
1138
+ "topology_hash": canonical_hash(topo)}))
1139
+ return 0
1140
+
1141
+
1142
+ def cmd_graph_finish(con, a):
1143
+ """Close a lane: mark the execution done so `session` mints a fresh one for the next
1144
+ feature and `gc` can eventually reclaim it. Without a terminal transition nothing
1145
+ ever leaves `running`, so the journal grows forever and a completed planning lane
1146
+ answers "complete" for the feature that follows it."""
1147
+ rc = _guard_flags()
1148
+ if rc:
1149
+ return rc
1150
+ ex = _execution(con, a.execution)
1151
+ if ex is None:
1152
+ sys.stderr.write(f"flow-graph: no execution {a.execution}\n")
1153
+ return 1
1154
+ if ex["status"] in TERMINAL:
1155
+ return 0 # idempotent
1156
+ opens = con.execute("SELECT COUNT(*) FROM graph_interrupt WHERE execution_id=? "
1157
+ "AND status='open'", (a.execution,)).fetchone()[0]
1158
+ if opens and not getattr(a, "force", False):
1159
+ sys.stderr.write(f"flow-graph: {opens} interrupt(s) still open - resolve them or "
1160
+ "pass --force\n")
1161
+ return 1
1162
+ with _db.transaction(con):
1163
+ con.execute("UPDATE graph_execution SET status='done', outcome=?, "
1164
+ "updated_at=datetime('now') WHERE id=?",
1165
+ (a.outcome or "complete", a.execution))
1166
+ return 0
1167
+
1168
+
1169
+ def cmd_graph_gc(con, a):
1170
+ rc = _guard_flags()
1171
+ if rc:
1172
+ return rc
1173
+ project = a.project or _project_key()
1174
+ # Purge FIRST, then sweep: rows marked stale in this invocation survive until the
1175
+ # NEXT gc, so doctor/status get a window to surface them (never mark-and-delete in
1176
+ # one call). Purge is scoped to one project - the DB is deliberately shared across
1177
+ # worktrees, and a sibling card's gc must not erase this project's history.
1178
+ with _db.transaction(con):
1179
+ cur = con.execute("DELETE FROM graph_execution "
1180
+ "WHERE status IN ('done','failed','abandoned') AND project=?",
1181
+ (project,))
1182
+ deleted = cur.rowcount
1183
+ marked = 0
1184
+ if a.stale_days is not None:
1185
+ cutoff = int(time.time()) - a.stale_days * 86400
1186
+ try:
1187
+ stale = _db.rows(con,
1188
+ "SELECT e.id FROM graph_execution e "
1189
+ "WHERE e.status IN ('running','paused') AND e.project=? "
1190
+ "AND e.created_at <= datetime('now', ?) "
1191
+ "AND NOT EXISTS ("
1192
+ " SELECT 1 FROM graph_checkpoint c WHERE c.execution_id=e.id "
1193
+ " AND json_extract(c.meta,'$.ts') >= ?)",
1194
+ (project, f"-{a.stale_days} days", cutoff))
1195
+ except sqlite3.OperationalError:
1196
+ sys.stderr.write("flow-graph: this SQLite lacks JSON1 (json_extract) - "
1197
+ "stale sweep unavailable\n")
1198
+ print(json.dumps({"deleted": deleted, "stale_marked": 0}))
1199
+ return 1
1200
+ for row in stale:
1201
+ _db.update_where(con, "graph_execution", {"id": row["id"]},
1202
+ status="abandoned", outcome="stale")
1203
+ marked += 1
1204
+ print(json.dumps({"deleted": deleted, "stale_marked": marked}))
1205
+ return 0