@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.
@@ -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(
@@ -160,6 +178,21 @@ def cmd_intake(con, a):
160
178
  return 0
161
179
 
162
180
 
181
+ _PROOF_SOURCES = ("card_markdown_gate", "manual", "verify_command")
182
+ _SECRET_RE = re.compile(
183
+ r"(?i)(token|secret|passwd|password|credential|api[_-]?key|bearer|authorization|-----begin)"
184
+ )
185
+
186
+
187
+ def _mask_evidence(text):
188
+ """Redact secret-shaped free text before durable store (trust-align RT)."""
189
+ if text is None:
190
+ return None
191
+ if _SECRET_RE.search(text):
192
+ return "***redacted***"
193
+ return text
194
+
195
+
163
196
  def cmd_story(con, a):
164
197
  if a.story_cmd == "add":
165
198
  if a.lane not in D.LANES:
@@ -169,11 +202,58 @@ def cmd_story(con, a):
169
202
  print(f"PASS: story {a.id} added (lane={a.lane})")
170
203
  return 0
171
204
  if a.story_cmd == "update":
205
+ # Trust boundary (US-101 spirit, flow-native): bare implemented is forbidden.
206
+ if a.status == "implemented":
207
+ print(
208
+ "FAIL: story update --status implemented is rejected.\n"
209
+ " Use: story complete --id <id> --proof-source card_markdown_gate|manual|verify_command\n"
210
+ " (honest proof provenance; never forge last_verified_result without story verify)"
211
+ )
212
+ return 1
172
213
  n = _db.update(con, "story", "id", a.id, status=a.status,
173
214
  unit_proof=a.unit, integration_proof=a.integration,
174
- e2e_proof=a.e2e, platform_proof=a.platform, evidence=a.evidence)
215
+ e2e_proof=a.e2e, platform_proof=a.platform,
216
+ evidence=_mask_evidence(a.evidence))
175
217
  print(f"{'PASS' if n else 'FAIL'}: story {a.id} {'updated' if n else 'not found'}")
176
218
  return 0 if n else 1
219
+ if a.story_cmd == "complete":
220
+ s = _db.one(con, "SELECT * FROM story WHERE id=?", (a.id,))
221
+ if not s:
222
+ print(f"FAIL: story {a.id} not found"); return 1
223
+ src = (a.proof_source or "").strip()
224
+ if src not in _PROOF_SOURCES:
225
+ print(
226
+ "FAIL: --proof-source required: card_markdown_gate|manual|verify_command\n"
227
+ " card_markdown_gate = /flow check markdown gate (does NOT set last_verified=pass)\n"
228
+ " verify_command = requires prior story verify pass"
229
+ )
230
+ return 1
231
+ if src == "verify_command":
232
+ if s.get("last_verified_result") != "pass":
233
+ print(
234
+ f"FAIL: proof-source verify_command requires last_verified_result=pass "
235
+ f"(got {s.get('last_verified_result')!r}). Run: story verify --id {a.id}"
236
+ )
237
+ return 1
238
+ # Honest provenance: card/manual never forge last_verified_result=pass
239
+ ev = _mask_evidence(a.evidence) or s.get("evidence") or ""
240
+ note = (s.get("notes") or "").strip()
241
+ prov = f"proof_source={src}"
242
+ if prov not in note:
243
+ note = (note + "\n" + prov).strip() if note else prov
244
+ if src == "card_markdown_gate" and "last_verified_result=pass" not in (note or ""):
245
+ # explicit honesty marker for auditors
246
+ note = note + "\nverify_stamp=not_shell"
247
+ fields = dict(status="implemented", notes=note)
248
+ if ev:
249
+ fields["evidence"] = ev
250
+ # only keep last_verified_* when real verify already passed
251
+ n = _db.update(con, "story", "id", a.id, **fields)
252
+ print(
253
+ f"PASS: story {a.id} complete (status=implemented, proof_source={src})"
254
+ + ("" if src == "verify_command" else " [no shell-verify stamp]")
255
+ )
256
+ return 0 if n else 1
177
257
  if a.story_cmd in ("verify", "verify-all"):
178
258
  if a.story_cmd == "verify":
179
259
  stories = [_db.one(con, "SELECT * FROM story WHERE id=?", (a.id,))]
@@ -594,6 +674,18 @@ def _events_path(a):
594
674
  return os.path.join(os.path.dirname(a._db_path), "events.jsonl")
595
675
 
596
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
+
597
689
  def _global_log_path():
598
690
  return os.path.join(os.path.expanduser("~"), ".claude", "flow", "usage.jsonl")
599
691
 
@@ -609,21 +701,33 @@ def _coerce_event(o):
609
701
 
610
702
 
611
703
  def cmd_rollup(con, a):
612
- 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)]
613
716
  if getattr(a, "global_", False):
614
- srcs.append(_global_log_path())
717
+ g = _global_log_path()
718
+ pairs.append((g, g))
615
719
  rolled = skipped = 0
616
720
  cols = ["src", "line_no"] + list(USAGE_COLS)
617
721
  ph = ",".join("?" for _ in cols)
618
- for src in srcs:
619
- if not os.path.isfile(src):
722
+ for src_path, src in pairs:
723
+ if not os.path.isfile(src_path):
620
724
  continue
621
725
  cur = con.execute("SELECT last_line FROM rollup_cursor WHERE src=?", (src,)).fetchone()
622
726
  last = cur[0] if cur else 0
623
727
  # errors="replace": a single invalid byte anywhere in a shared, multi-writer log must
624
728
  # not abort the WHOLE rollup - it degrades to a per-line json.loads failure below
625
729
  # instead (one bad row skipped, not every row on the file).
626
- with open(src, "r", encoding="utf-8", errors="replace") as fh:
730
+ with open(src_path, "r", encoding="utf-8", errors="replace") as fh:
627
731
  text = fh.read()
628
732
  lines = text.split("\n")
629
733
  lines = lines[:-1] # drop trailing empty (after final \n) or a partial unterminated last line
@@ -668,15 +772,33 @@ def cmd_rollup(con, a):
668
772
  return 0
669
773
 
670
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
+
671
785
  def cmd_usage(con, a):
672
786
  src = _global_log_path() if getattr(a, "global_", False) else _events_path(a)
673
- 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 ? || '#%')"
674
793
  # Default-exclude throwaway/test runs so the view reflects real builds. COALESCE handles old
675
794
  # rows (ephemeral NULL -> 0); the project-name fallback excludes legacy `tmp.*` runs that
676
795
  # predate the field (no log rewrite). `--include-ephemeral` shows everything.
677
796
  if not getattr(a, "include_ephemeral", False):
678
797
  w += " AND NOT (COALESCE(ephemeral,0)=1 OR project LIKE 'tmp.%')"
679
- 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))
680
802
  total = con.execute(f"SELECT COUNT(*) FROM usage_event {w}", pr).fetchone()[0]
681
803
  if not total:
682
804
  if not getattr(a, "summary", False): # --summary stays silent on no data (recall appends nothing)
@@ -941,8 +1063,12 @@ def cmd_prune(con, a):
941
1063
  os.replace(tmp, src) # atomic on the same filesystem
942
1064
  # line numbers changed -> the mirror + cursor for this sink are stale; reset so the next
943
1065
  # rollup re-ingests the kept lines as line_no 1..K (UNIQUE(src,line_no) stays consistent).
944
- con.execute("DELETE FROM usage_event WHERE src=?", (src,))
945
- 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)))
946
1072
  con.commit()
947
1073
  print(json.dumps({"sink": src, "kept": len(kept), "dropped": len(lines) - len(kept)}))
948
1074
  if getattr(a, "global_", False):
@@ -983,6 +1109,13 @@ def build_parser():
983
1109
  a2.add_argument("--evidence")
984
1110
  a3 = pss.add_parser("verify"); a3.add_argument("--id", required=True)
985
1111
  pss.add_parser("verify-all")
1112
+ # flow-native complete (trust-align D7): not protocol-v1 positional parity
1113
+ ac = pss.add_parser("complete", help="mark implemented with honest proof_source (not bare update)")
1114
+ ac.add_argument("--id", required=True)
1115
+ ac.add_argument("--proof-source", dest="proof_source",
1116
+ choices=("card_markdown_gate", "manual", "verify_command"),
1117
+ help="card_markdown_gate never forges last_verified=pass")
1118
+ ac.add_argument("--evidence")
986
1119
 
987
1120
  pt = sub.add_parser("trace", help="record an agent task execution trace (auto-scored)")
988
1121
  pt.add_argument("--summary", required=True); pt.add_argument("--intake", type=int); pt.add_argument("--story", "--card")
@@ -1044,6 +1177,20 @@ def build_parser():
1044
1177
 
1045
1178
  pr = sub.add_parser("rollup", help="ingest JSONL usage sinks into usage_event (idempotent)")
1046
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")
1047
1194
  pu = sub.add_parser("usage", help="print usage analytics from usage_event")
1048
1195
  pu.add_argument("--global", dest="global_", action="store_true", help="read the device-global log instead of this project")
1049
1196
  pu.add_argument("--json", action="store_true")
@@ -1055,6 +1202,79 @@ def build_parser():
1055
1202
  ppr = sub.add_parser("prune", help="cap each JSONL sink to its last N lines (crash-safe; resets the mirror for that sink)")
1056
1203
  ppr.add_argument("--keep", type=int, help="lines to keep (default 5000)")
1057
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")
1058
1278
  return p
1059
1279
 
1060
1280
 
@@ -1078,6 +1298,10 @@ def main(argv):
1078
1298
  "see harness/README.md for the full contract.\n"
1079
1299
  )
1080
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
1081
1305
  con = _db.connect(db_path=a.db)
1082
1306
  a._db_path = a.db or _db.default_db_path()
1083
1307
  dispatch = {
@@ -1086,6 +1310,7 @@ def main(argv):
1086
1310
  "intervention": cmd_intervention, "query": cmd_query,
1087
1311
  "audit": cmd_audit, "propose": cmd_propose,
1088
1312
  "rollup": cmd_rollup, "usage": cmd_usage, "prune": cmd_prune,
1313
+ "graph": cmd_graph,
1089
1314
  }
1090
1315
  try:
1091
1316
  return dispatch[a.cmd](con, a) or 0