@manhquy/flow-skill 0.1.0 → 0.2.1

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.
@@ -20,6 +20,7 @@ import sys
20
20
  import _db
21
21
  import _domain as D
22
22
  import _presence as P
23
+ import graph_executor as G
23
24
 
24
25
 
25
26
  def _flow_lineage_db(argv):
@@ -58,6 +59,23 @@ def _flow_lineage_db(argv):
58
59
  def _maybe_forward_to_rust(argv):
59
60
  if os.environ.get("FLOW_HARNESS_BACKEND", "python").lower() != "rust":
60
61
  return None
62
+ # graph verbs are flow-only (schema band 014+); the external harness-cli has no such
63
+ # surface, and on a FRESH db the lineage sniff below would not refuse yet — so the
64
+ # never-forward set must be explicit, not inferred from DB state. The verb is the
65
+ # first NON-OPTION token: `--db <path> graph run` must not slip past an argv[0] check.
66
+ verb, i = None, 0
67
+ while i < len(argv):
68
+ tok = argv[i]
69
+ if tok == "--db":
70
+ i += 2
71
+ continue
72
+ if tok.startswith("-"):
73
+ i += 1
74
+ continue
75
+ verb = tok
76
+ break
77
+ if verb == "graph":
78
+ return None
61
79
  conflict = _flow_lineage_db(argv)
62
80
  if conflict:
63
81
  sys.stderr.write(
@@ -656,6 +674,18 @@ def _events_path(a):
656
674
  return os.path.join(os.path.dirname(a._db_path), "events.jsonl")
657
675
 
658
676
 
677
+ def _canon_sink(p):
678
+ """Spelling-independent name for a sink path, so 'same file' means 'same key'.
679
+
680
+ The same directory reaches us under several spellings: a macOS temp root arrives as
681
+ both /var/... and /private/var/..., and Windows hands back either an 8.3 short name
682
+ (C:/Users/RUNNER~1/...) or the long one depending on who resolved it. Keying rows on
683
+ the raw spelling silently splits one sink into two. realpath collapses the symlink and
684
+ short-name cases; normcase folds Windows separators and case.
685
+ """
686
+ return os.path.normcase(os.path.realpath(p))
687
+
688
+
659
689
  def _global_log_path():
660
690
  return os.path.join(os.path.expanduser("~"), ".claude", "flow", "usage.jsonl")
661
691
 
@@ -671,21 +701,33 @@ def _coerce_event(o):
671
701
 
672
702
 
673
703
  def cmd_rollup(con, a):
674
- srcs = [_events_path(a)]
704
+ # (path, key) pairs: `path` is the file read; `key` is the rollup_cursor /
705
+ # UNIQUE(src,line_no) namespace. They differ only for --src ingests, where a
706
+ # lifecycle-unique key (--src-key-path + --src-key-tag) prevents a RECYCLED worktree
707
+ # path from silently swallowing lines 1..N of the next lifecycle's sink.
708
+ if getattr(a, "src", None):
709
+ kp = getattr(a, "src_key_path", None) or a.src
710
+ tag = getattr(a, "src_key_tag", None)
711
+ key = _canon_sink(kp) + ("#" + tag if tag else "")
712
+ pairs = [(a.src, key)]
713
+ else:
714
+ p = _events_path(a)
715
+ pairs = [(p, p)]
675
716
  if getattr(a, "global_", False):
676
- srcs.append(_global_log_path())
717
+ g = _global_log_path()
718
+ pairs.append((g, g))
677
719
  rolled = skipped = 0
678
720
  cols = ["src", "line_no"] + list(USAGE_COLS)
679
721
  ph = ",".join("?" for _ in cols)
680
- for src in srcs:
681
- if not os.path.isfile(src):
722
+ for src_path, src in pairs:
723
+ if not os.path.isfile(src_path):
682
724
  continue
683
725
  cur = con.execute("SELECT last_line FROM rollup_cursor WHERE src=?", (src,)).fetchone()
684
726
  last = cur[0] if cur else 0
685
727
  # errors="replace": a single invalid byte anywhere in a shared, multi-writer log must
686
728
  # not abort the WHOLE rollup - it degrades to a per-line json.loads failure below
687
729
  # instead (one bad row skipped, not every row on the file).
688
- with open(src, "r", encoding="utf-8", errors="replace") as fh:
730
+ with open(src_path, "r", encoding="utf-8", errors="replace") as fh:
689
731
  text = fh.read()
690
732
  lines = text.split("\n")
691
733
  lines = lines[:-1] # drop trailing empty (after final \n) or a partial unterminated last line
@@ -730,15 +772,33 @@ def cmd_rollup(con, a):
730
772
  return 0
731
773
 
732
774
 
775
+ def cmd_graph(con, a):
776
+ return {
777
+ "run": G.cmd_graph_run, "record": G.cmd_graph_record, "next": G.cmd_graph_next,
778
+ "resume": G.cmd_graph_resume, "status": G.cmd_graph_status,
779
+ "abandon": G.cmd_graph_abandon, "gc": G.cmd_graph_gc, "lint": G.cmd_graph_lint,
780
+ "cards": G.cmd_graph_cards, "root": G.cmd_graph_root,
781
+ "session": G.cmd_graph_session, "finish": G.cmd_graph_finish,
782
+ }[a.graph_cmd](con, a)
783
+
784
+
733
785
  def cmd_usage(con, a):
734
786
  src = _global_log_path() if getattr(a, "global_", False) else _events_path(a)
735
- w = "WHERE src=?"
787
+ # `src=?` matches direct-sink rows; the LIKE arm matches lifecycle-keyed ingests
788
+ # (`rollup --src-key-path <this-sink> --src-key-tag <branch>#<created_at>` — worktree
789
+ # telemetry merged at
790
+ # workspace-remove time). Without it those rows would be write-only: invisible to
791
+ # usage and unreachable by prune.
792
+ w = "WHERE (src=? OR src LIKE ? || '#%')"
736
793
  # Default-exclude throwaway/test runs so the view reflects real builds. COALESCE handles old
737
794
  # rows (ephemeral NULL -> 0); the project-name fallback excludes legacy `tmp.*` runs that
738
795
  # predate the field (no log rewrite). `--include-ephemeral` shows everything.
739
796
  if not getattr(a, "include_ephemeral", False):
740
797
  w += " AND NOT (COALESCE(ephemeral,0)=1 OR project LIKE 'tmp.%')"
741
- pr = (src,)
798
+ # Direct-sink rows are keyed by the path as this process spells it, so `src=?` keeps
799
+ # matching them verbatim; only the lifecycle arm compares canonically, because those
800
+ # keys are written by whoever called rollup and their spelling is not ours to choose.
801
+ pr = (src, _canon_sink(src))
742
802
  total = con.execute(f"SELECT COUNT(*) FROM usage_event {w}", pr).fetchone()[0]
743
803
  if not total:
744
804
  if not getattr(a, "summary", False): # --summary stays silent on no data (recall appends nothing)
@@ -1003,8 +1063,12 @@ def cmd_prune(con, a):
1003
1063
  os.replace(tmp, src) # atomic on the same filesystem
1004
1064
  # line numbers changed -> the mirror + cursor for this sink are stale; reset so the next
1005
1065
  # rollup re-ingests the kept lines as line_no 1..K (UNIQUE(src,line_no) stays consistent).
1006
- con.execute("DELETE FROM usage_event WHERE src=?", (src,))
1007
- con.execute("DELETE FROM rollup_cursor WHERE src=?", (src,))
1066
+ # Include lifecycle-keyed ingests for this sink (`<sink>#<branch>#<created_at>`):
1067
+ # their line numbers are unrelated to the rewritten file, but the reset contract is
1068
+ # "mirror rows for this sink are rebuilt from the kept lines", and orphaned keyed
1069
+ # rows would be unreachable forever after.
1070
+ con.execute("DELETE FROM usage_event WHERE src=? OR src LIKE ? || '#%'", (src, _canon_sink(src)))
1071
+ con.execute("DELETE FROM rollup_cursor WHERE src=? OR src LIKE ? || '#%'", (src, _canon_sink(src)))
1008
1072
  con.commit()
1009
1073
  print(json.dumps({"sink": src, "kept": len(kept), "dropped": len(lines) - len(kept)}))
1010
1074
  if getattr(a, "global_", False):
@@ -1113,6 +1177,20 @@ def build_parser():
1113
1177
 
1114
1178
  pr = sub.add_parser("rollup", help="ingest JSONL usage sinks into usage_event (idempotent)")
1115
1179
  pr.add_argument("--global", dest="global_", action="store_true", help="also roll up the device-global log")
1180
+ pr.add_argument("--src", help="ingest this JSONL sink instead of the project sink")
1181
+ # Path and tag are SEPARATE flags on purpose. A single pre-joined
1182
+ # "<path>#<branch>#<created_at>" string is not a path, so the Git-Bash/MSYS argument
1183
+ # translation that rewrites POSIX paths into native Windows ones leaves it alone — the
1184
+ # key would then be spelled differently from the sink this python process resolves for
1185
+ # itself, and the rows became write-only: invisible to usage, unreachable by prune.
1186
+ pr.add_argument("--src-key-path", dest="src_key_path",
1187
+ help="DESTINATION project's events.jsonl path; canonicalized, then used as "
1188
+ "the cursor/dedupe key prefix so usage/prune group these rows with "
1189
+ "that project (default: the --src path itself)")
1190
+ pr.add_argument("--src-key-tag", dest="src_key_tag",
1191
+ help="lifecycle discriminator appended as '#<tag>', e.g. "
1192
+ "'<branch>#<created_at>' — makes the key unique per worktree "
1193
+ "lifecycle so a RECYCLED path cannot swallow the next lifecycle's lines")
1116
1194
  pu = sub.add_parser("usage", help="print usage analytics from usage_event")
1117
1195
  pu.add_argument("--global", dest="global_", action="store_true", help="read the device-global log instead of this project")
1118
1196
  pu.add_argument("--json", action="store_true")
@@ -1124,6 +1202,79 @@ def build_parser():
1124
1202
  ppr = sub.add_parser("prune", help="cap each JSONL sink to its last N lines (crash-safe; resets the mirror for that sink)")
1125
1203
  ppr.add_argument("--keep", type=int, help="lines to keep (default 5000)")
1126
1204
  ppr.add_argument("--global", dest="global_", action="store_true", help="also prune the device-global log")
1205
+
1206
+ pg = sub.add_parser("graph", help="graph executor: record/advance journal (flow-owned band 014+)")
1207
+ pgs = pg.add_subparsers(dest="graph_cmd", required=True)
1208
+ g1 = pgs.add_parser("run", help="create an execution; prints its id")
1209
+ g1.add_argument("--project"); g1.add_argument("--kind", required=True, choices=["auto_run", "planning", "card"])
1210
+ g1.add_argument("--topology-version", dest="topology_version", type=int, default=1)
1211
+ g1.add_argument("--topology-hash", dest="topology_hash", default="")
1212
+ g1.add_argument("--story", help="story id tracking handle (TEXT, e.g. C-001)")
1213
+ g2 = pgs.add_parser("record", help="record a step boundary: writes + checkpoint in ONE transaction; prints checkpoint id")
1214
+ g2.add_argument("--execution", required=True); g2.add_argument("--ns", default="")
1215
+ g2.add_argument("--node", required=True)
1216
+ g2.add_argument("--manifest", help="JSON evidence object (parsed + duplicate-key rejected); "
1217
+ "prefer the typed flags below so no caller concatenates JSON")
1218
+ g2.add_argument("--gate-exit", dest="gate_exit", type=int, help="gate verdict exit code")
1219
+ g2.add_argument("--gate-cmd", dest="gate_cmd", help="gate command that produced the verdict")
1220
+ g2.add_argument("--card-status", dest="card_status", help="card status token at record time")
1221
+ g2.add_argument("--worktree", help="worktree path (dispatch evidence)")
1222
+ g2.add_argument("--vendor", help="agent vendor (dispatch evidence)")
1223
+ g2.add_argument("--writes", help="JSON array of {task,channel,value} step writes")
1224
+ g2.add_argument("--parent", help="parent checkpoint id (fork chain, e.g. two-strikes attempt 2)")
1225
+ g2.add_argument("--source", default="loop", choices=["input", "loop", "update", "fork"])
1226
+ g2.add_argument("--interrupt", action="store_true", help="open an operator interrupt and pause (exit 3)")
1227
+ g2.add_argument("--prompt", help="interrupt prompt shown to the operator")
1228
+ g2.add_argument("--security-class", dest="security_class", action="store_true")
1229
+ g2.add_argument("--merge", action="store_true",
1230
+ help="record a merge boundary; the executor COMPUTES the ancestry proof "
1231
+ "(caller claims are ignored) and records card-abandon when unmerged")
1232
+ g2.add_argument("--branch", help="branch to prove merged (with --merge)")
1233
+ g2.add_argument("--base", help="merge base (default: origin/HEAD, else current branch)")
1234
+ g3 = pgs.add_parser("next", help="next node per topology+journal (exit 3 = complete/paused)")
1235
+ g3.add_argument("--execution", required=True); g3.add_argument("--ns", default="")
1236
+ g3.add_argument("--topology", help="explicit topology JSON path (tests/fixtures; default: "
1237
+ "the shipped pin-verified topology + hash policy)")
1238
+ g3.add_argument("--force-retopology", dest="force_retopology", action="store_true",
1239
+ help="after a skill upgrade changed the topology: fork the chain onto "
1240
+ "the current topology instead of refusing")
1241
+ g4 = pgs.add_parser("resume", help="resolve an open interrupt (guarded) and resume")
1242
+ g4.add_argument("--execution", required=True)
1243
+ g4.add_argument("--ns", default="", help="select the interrupt by namespace")
1244
+ g4.add_argument("--interrupt-id", dest="interrupt_id",
1245
+ help="select the exact interrupt (required when several are open)")
1246
+ g4.add_argument("--answer", help='JSON object with a non-empty string "reason"')
1247
+ g4.add_argument("--actor", help="resolving actor id (stored as audit evidence)")
1248
+ g4.add_argument("--target", help="closed-set target the DEBT line must name (C-NNN or NN-stage)")
1249
+ g5 = pgs.add_parser("status", help="execution + latest checkpoint per ns + open interrupts (JSON)")
1250
+ g5.add_argument("--execution", required=True)
1251
+ g6 = pgs.add_parser("abandon", help="terminal-state an execution (kill-at-gate, stale cleanup)")
1252
+ g6.add_argument("--execution", required=True); g6.add_argument("--outcome", help="e.g. killed")
1253
+ gf = pgs.add_parser("finish", help="mark an execution done (lane closed) so the next "
1254
+ "feature gets a fresh session and gc can reclaim")
1255
+ gf.add_argument("--execution", required=True); gf.add_argument("--outcome")
1256
+ gf.add_argument("--force", action="store_true", help="close despite open interrupts")
1257
+ g7 = pgs.add_parser("gc", help="purge terminal executions for ONE project (cascade), then "
1258
+ "--stale-days marks aged checkpoint-less running as abandoned "
1259
+ "(deleted on the NEXT gc, so doctor can surface them first)")
1260
+ g7.add_argument("--stale-days", dest="stale_days", type=int)
1261
+ g7.add_argument("--project", help="project scope (default: basename of FLOW_PROJECT_ROOT)")
1262
+ g9 = pgs.add_parser("cards", help="compile the card DAG: deps-met set + overlap-serialized "
1263
+ "ready set + blocked reasons (JSON); rejects dep cycles")
1264
+ g9.add_argument("--active-files", dest="active_files",
1265
+ help="space/comma list of allowed-file tokens already owned by live worktrees")
1266
+ pgs.add_parser("root", help="print the directory owning this project's durable state "
1267
+ "(worktree-translated; the one main-tree scope)")
1268
+ gse = pgs.add_parser("session", help="print the project's running auto_run execution, "
1269
+ "minting one atomically if absent (no pin file)")
1270
+ gse.add_argument("--project"); gse.add_argument("--no-create", dest="no_create",
1271
+ action="store_true",
1272
+ help="exit 3 instead of minting")
1273
+ gse.add_argument("--kind", choices=["auto_run", "planning"], default="auto_run",
1274
+ help="session lane: shipping (auto_run) or planning")
1275
+ g8 = pgs.add_parser("lint", help="validate a topology (default: the shipped one incl. pin)")
1276
+ g8.add_argument("--topology", help="explicit topology path (fixtures; skips the pin unless --pin)")
1277
+ g8.add_argument("--pin", help="explicit pin file to verify against")
1127
1278
  return p
1128
1279
 
1129
1280
 
@@ -1147,6 +1298,10 @@ def main(argv):
1147
1298
  "see harness/README.md for the full contract.\n"
1148
1299
  )
1149
1300
  raise
1301
+ if a.cmd == "graph" and getattr(a, "graph_cmd", "") == "lint":
1302
+ # lint touches no table: skip connect() so CI/linting never mints a DB
1303
+ # (or runs migrations) in the working tree.
1304
+ return G.cmd_graph_lint(None, a) or 0
1150
1305
  con = _db.connect(db_path=a.db)
1151
1306
  a._db_path = a.db or _db.default_db_path()
1152
1307
  dispatch = {
@@ -1155,6 +1310,7 @@ def main(argv):
1155
1310
  "intervention": cmd_intervention, "query": cmd_query,
1156
1311
  "audit": cmd_audit, "propose": cmd_propose,
1157
1312
  "rollup": cmd_rollup, "usage": cmd_usage, "prune": cmd_prune,
1313
+ "graph": cmd_graph,
1158
1314
  }
1159
1315
  try:
1160
1316
  return dispatch[a.cmd](con, a) or 0