@manhquy/flow-skill 0.1.0 → 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.
- package/README.md +11 -8
- package/README_VN.md +7 -4
- package/package.json +1 -1
- package/skills/flow/SKILL.md +1 -1
- package/skills/flow/harness/GAP-MATRIX-0.1.17.md +4 -3
- package/skills/flow/harness/README.md +29 -2
- package/skills/flow/harness/_db.py +175 -6
- package/skills/flow/harness/flow_harness.py +165 -9
- package/skills/flow/harness/graph_executor.py +1205 -0
- package/skills/flow/harness/graph_ids.py +35 -0
- package/skills/flow/harness/graph_predicates.py +48 -0
- package/skills/flow/harness/pins/flow-topology.sha256 +1 -0
- package/skills/flow/harness/schema/014-graph-executor.sql +67 -0
- package/skills/flow/references/auto-run.md +29 -2
- package/skills/flow/references/command-dispatch.md +1 -0
- package/skills/flow/references/concierge.md +1 -0
- package/skills/flow/references/flow-topology.json +146 -0
- package/skills/flow/runner/flow.sh +216 -5
- package/skills-manifest.json +8 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
681
|
-
if not os.path.isfile(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1007
|
-
|
|
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
|