@manhquy/flow-skill 0.1.0-rc.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/LICENSE +21 -0
  3. package/README.md +152 -0
  4. package/README_VN.md +134 -0
  5. package/SECURITY.md +69 -0
  6. package/bin/cli.mjs +382 -0
  7. package/package.json +52 -0
  8. package/skills/flow/SKILL.md +234 -0
  9. package/skills/flow/_templates/00-idea.md +16 -0
  10. package/skills/flow/_templates/00-inspect.md +42 -0
  11. package/skills/flow/_templates/01-research.md +59 -0
  12. package/skills/flow/_templates/02-scope.md +71 -0
  13. package/skills/flow/_templates/03-prd.md +57 -0
  14. package/skills/flow/_templates/04-adr.md +21 -0
  15. package/skills/flow/_templates/05-contract.md +59 -0
  16. package/skills/flow/_templates/card.md +26 -0
  17. package/skills/flow/_templates/constitution.md +22 -0
  18. package/skills/flow/eval/fixtures/f01a/flow/01-research.md +77 -0
  19. package/skills/flow/eval/fixtures/f01b/flow/01-research.md +75 -0
  20. package/skills/flow/eval/fixtures/f02a/flow/02-scope.md +56 -0
  21. package/skills/flow/eval/fixtures/f02b/flow/02-scope.md +65 -0
  22. package/skills/flow/eval/fixtures/fcda/.flow/.gitkeep +0 -0
  23. package/skills/flow/eval/fixtures/fcda/cards/C-001.md +46 -0
  24. package/skills/flow/eval/fixtures/fcdb/.flow/.gitkeep +0 -0
  25. package/skills/flow/eval/fixtures/fcdb/cards/C-001.md +35 -0
  26. package/skills/flow/eval/manifest.tsv +7 -0
  27. package/skills/flow/harness/README.md +117 -0
  28. package/skills/flow/harness/_db.py +228 -0
  29. package/skills/flow/harness/_domain.py +161 -0
  30. package/skills/flow/harness/_presence.py +109 -0
  31. package/skills/flow/harness/flow_harness.py +1103 -0
  32. package/skills/flow/harness/repo_map.py +93 -0
  33. package/skills/flow/harness/schema/001-init.sql +129 -0
  34. package/skills/flow/harness/schema/002-story-verify.sql +9 -0
  35. package/skills/flow/harness/schema/003-tool-registry.sql +15 -0
  36. package/skills/flow/harness/schema/004-intervention.sql +15 -0
  37. package/skills/flow/harness/schema/005-tool-extensions.sql +20 -0
  38. package/skills/flow/harness/schema/009-accessed-count.sql +11 -0
  39. package/skills/flow/harness/schema/010-usage-event.sql +42 -0
  40. package/skills/flow/harness/schema/011-usage-gate-reason.sql +8 -0
  41. package/skills/flow/harness/schema/012-usage-ephemeral.sql +10 -0
  42. package/skills/flow/law/CLAUDE.md +140 -0
  43. package/skills/flow/law/DESIGN.md +141 -0
  44. package/skills/flow/law/RETRO.md +13 -0
  45. package/skills/flow/playbooks/README.md +28 -0
  46. package/skills/flow/playbooks/cloudflare-ai-gateway-qwen.md +346 -0
  47. package/skills/flow/playbooks/docker-deploy-stale-cache.md +32 -0
  48. package/skills/flow/playbooks/rag-lite-postgres-cloudflare.md +192 -0
  49. package/skills/flow/playbooks/windows-gitbash-jsonl-telemetry.md +57 -0
  50. package/skills/flow/references/adversarial-review.md +197 -0
  51. package/skills/flow/references/agent-detection.md +88 -0
  52. package/skills/flow/references/agent-stage-mapping.md +114 -0
  53. package/skills/flow/references/antigravity-integration.md +92 -0
  54. package/skills/flow/references/auto-run.md +81 -0
  55. package/skills/flow/references/claudekit-skills.md +128 -0
  56. package/skills/flow/references/codex-integration.md +104 -0
  57. package/skills/flow/references/command-dispatch.md +54 -0
  58. package/skills/flow/references/debt-and-halts.md +41 -0
  59. package/skills/flow/references/design-review-checklist.md +42 -0
  60. package/skills/flow/references/gate-eval.md +186 -0
  61. package/skills/flow/references/gate-rules.md +171 -0
  62. package/skills/flow/references/ground-truth-gates.md +49 -0
  63. package/skills/flow/references/loop-harness-2026-principles.md +52 -0
  64. package/skills/flow/references/mode-work.md +37 -0
  65. package/skills/flow/references/project-types.md +42 -0
  66. package/skills/flow/references/stage-state-machine.md +47 -0
  67. package/skills/flow/references/ui-patterns-tcr.md +49 -0
  68. package/skills/flow/runner/flow.cmd +18 -0
  69. package/skills/flow/runner/flow.sh +3297 -0
  70. package/skills-manifest.json +70 -0
  71. package/src/constants.mjs +65 -0
  72. package/src/detect.mjs +36 -0
  73. package/src/help.mjs +42 -0
  74. package/src/installer.mjs +231 -0
  75. package/src/prompt.mjs +60 -0
  76. package/src/tty.mjs +11 -0
@@ -0,0 +1,1103 @@
1
+ #!/usr/bin/env python3
2
+ """flow-harness - durable layer for the /flow skill (intake, story, trace, decision, backlog).
3
+
4
+ Default backend: this Python + stdlib sqlite3 implementation (no install needed).
5
+ Power-path backend: set FLOW_HARNESS_BACKEND=rust to forward argv to the compiled
6
+ repository-harness CLI (path via FLOW_HARNESS_CLI, default scripts/bin/harness-cli).
7
+
8
+ Run: python flow_harness.py <command> [args] (Git Bash on Windows / any POSIX)
9
+ DB: <FLOW_PROJECT_ROOT>/.flow/harness.db (override with --db)
10
+ """
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import re
16
+ import sqlite3
17
+ import subprocess
18
+ import sys
19
+
20
+ import _db
21
+ import _domain as D
22
+ import _presence as P
23
+
24
+
25
+ def _flow_lineage_db(argv):
26
+ """Path of a flow-lineage DB an external rust binary must not touch, else None.
27
+
28
+ flow's Python port re-homed its accessed-count/usage migrations to versions 009-012,
29
+ leaving 006-008 free. An upstream harness-cli only knows the shared 001-005 base; pointed
30
+ at such a DB it sees MAX(version) >= 9, applies nothing, and would silently diverge if it
31
+ later defines its own 006-008 (skipped because the version is already exceeded). The rust
32
+ seam is frozen for flow-lineage DBs — detect them by the usage mirror or a re-homed version."""
33
+ db_path = None
34
+ for i, tok in enumerate(argv):
35
+ if tok == "--db" and i + 1 < len(argv):
36
+ db_path = argv[i + 1]
37
+ elif tok.startswith("--db="):
38
+ db_path = tok.split("=", 1)[1]
39
+ if not db_path:
40
+ db_path = _db.default_db_path()
41
+ if not db_path or not os.path.exists(db_path):
42
+ return None # fresh/absent DB: nothing to protect yet
43
+ try:
44
+ con = sqlite3.connect(db_path)
45
+ try:
46
+ has_usage = con.execute(
47
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='usage_event'"
48
+ ).fetchone() is not None
49
+ row = con.execute("SELECT MAX(version) FROM schema_version").fetchone()
50
+ maxv = (row[0] or 0) if row else 0
51
+ finally:
52
+ con.close()
53
+ except sqlite3.Error:
54
+ return None # best-effort: unreadable -> let the CLI decide, never break the python path
55
+ return db_path if (has_usage or maxv >= 9) else None
56
+
57
+
58
+ def _maybe_forward_to_rust(argv):
59
+ if os.environ.get("FLOW_HARNESS_BACKEND", "python").lower() != "rust":
60
+ return None
61
+ conflict = _flow_lineage_db(argv)
62
+ if conflict:
63
+ sys.stderr.write(
64
+ "flow-harness: refusing to forward to the rust backend.\n"
65
+ f" The DB at {conflict} uses flow's Python schema lineage (usage-log + re-homed\n"
66
+ " migrations 009-012). An external harness-cli only knows the shared 001-005 base and\n"
67
+ " would silently diverge on this DB. The rust seam is frozen for flow-lineage DBs.\n"
68
+ " Use the Python backend (unset FLOW_HARNESS_BACKEND) for this project.\n"
69
+ )
70
+ return 2
71
+ cli = os.environ.get("FLOW_HARNESS_CLI")
72
+ if not cli:
73
+ root = os.environ.get("FLOW_PROJECT_ROOT") or os.getcwd()
74
+ cand = os.path.join(root, "scripts", "bin", "harness-cli")
75
+ cli = cand + (".exe" if os.name == "nt" else "")
76
+ if not os.path.exists(cli):
77
+ sys.stderr.write(
78
+ f"flow-harness: FLOW_HARNESS_BACKEND=rust but binary not found ({cli}).\n"
79
+ "Build it: cd repository-harness && cargo build --release -p harness-cli, "
80
+ "then set FLOW_HARNESS_CLI to the binary. See harness/README.md.\n"
81
+ )
82
+ return 2
83
+ return subprocess.call([cli] + argv)
84
+
85
+
86
+ def _jlist(s):
87
+ if s is None:
88
+ return None
89
+ if isinstance(s, (list, tuple)):
90
+ items = list(s)
91
+ else:
92
+ items = [x.strip() for x in str(s).split(",") if x.strip()]
93
+ return json.dumps(items)
94
+
95
+
96
+ # --- usage signal: accessed_count (read-only reuse counter; NEVER deletes a row) ---
97
+ # Low access count is not evidence of low value: a rare one-time security lesson is recalled
98
+ # rarely BECAUSE it is rare. So security-class rows sort first and nothing is ever pruned by count.
99
+ # Mirrors flow.sh's canonical security-class Tier-C pattern, extended with JWT-class terms a
100
+ # title can carry. Over-classifying is safe here (security rows merely sort first); a buried
101
+ # security lesson is the real harm, so err broad.
102
+ _SECURITY_RE = re.compile(
103
+ r"auth|authoriz|authorize|admin|tenan|payment|billing|password|token|secret|credential|"
104
+ r"permission|role|rbac|login|pii|migrat|validation|jwt|oauth|session|crypto|encrypt", re.I)
105
+
106
+
107
+ def _is_security_text(*parts):
108
+ return bool(_SECURITY_RE.search(" ".join(str(p) for p in parts if p)))
109
+
110
+
111
+ def _order_by_reuse(rows, sec_key):
112
+ # security-class rows FIRST (never deprioritized by a low count), then most-reused-first,
113
+ # then a stable id tiebreak. Pure ordering — never drops or deletes a row.
114
+ return sorted(rows, key=lambda r: (0 if sec_key(r) else 1,
115
+ -(r.get("accessed_count") or 0),
116
+ str(r.get("id"))))
117
+
118
+
119
+ def _touch_accessed(con, table, ids):
120
+ # increment the read counter for surfaced rows. `table` is a code literal, never user input.
121
+ # Best-effort: a read-only / locked DB must NOT fail the query — surfacing the rows matters
122
+ # more than the counter, so swallow a write error and return the results regardless.
123
+ ids = [i for i in ids if i is not None]
124
+ if not ids:
125
+ return
126
+ ph = ", ".join("?" for _ in ids)
127
+ try:
128
+ con.execute(f"UPDATE {table} SET accessed_count = accessed_count + 1 WHERE id IN ({ph})", ids)
129
+ con.commit()
130
+ except sqlite3.Error:
131
+ pass
132
+
133
+
134
+ # ---------------- commands ----------------
135
+
136
+ def cmd_init(con, a):
137
+ # use the graceful version helper (survives the case where schema_version is absent)
138
+ print("flow-harness: initialized at schema version", _db._current_version(con))
139
+ print("db:", a._db_path)
140
+
141
+
142
+ def cmd_intake(con, a):
143
+ flags = D.normalize_flags(a.flags)
144
+ recommended, reason = D.classify_lane(flags, removing_validation=a.removing_validation,
145
+ code_impact_high=a.code_impact_high)
146
+ ok, msg = D.lane_downgrade_allowed(recommended, a.lane, a.narrow_scope)
147
+ if not ok:
148
+ print("BLOCKED:", msg)
149
+ return 1
150
+ lane = a.lane or recommended
151
+ iid = _db.insert(con, "intake", input_type=a.type, summary=a.summary, risk_lane=lane,
152
+ risk_flags=_jlist(flags) if flags else None,
153
+ affected_docs=_jlist(a.docs), story_id=a.story, notes=a.notes)
154
+ print(f"PASS: intake #{iid} -> lane={lane}")
155
+ print(f" reason: {reason}" + ("" if lane == recommended else f" (operator set lane={lane})"))
156
+ if flags:
157
+ print(" flags:", ", ".join(flags))
158
+ if lane == "high_risk":
159
+ print(" high-risk: create a story folder, record a durable decision on behavior/auth/data/contract change.")
160
+ return 0
161
+
162
+
163
+ def cmd_story(con, a):
164
+ if a.story_cmd == "add":
165
+ if a.lane not in D.LANES:
166
+ print("FAIL: --lane must be tiny|normal|high_risk"); return 1
167
+ _db.insert(con, "story", id=a.id, title=a.title, risk_lane=a.lane,
168
+ contract_doc=a.contract, verify_command=a.verify)
169
+ print(f"PASS: story {a.id} added (lane={a.lane})")
170
+ return 0
171
+ if a.story_cmd == "update":
172
+ n = _db.update(con, "story", "id", a.id, status=a.status,
173
+ unit_proof=a.unit, integration_proof=a.integration,
174
+ e2e_proof=a.e2e, platform_proof=a.platform, evidence=a.evidence)
175
+ print(f"{'PASS' if n else 'FAIL'}: story {a.id} {'updated' if n else 'not found'}")
176
+ return 0 if n else 1
177
+ if a.story_cmd in ("verify", "verify-all"):
178
+ if a.story_cmd == "verify":
179
+ stories = [_db.one(con, "SELECT * FROM story WHERE id=?", (a.id,))]
180
+ if stories == [None]:
181
+ print(f"FAIL: story {a.id} not found"); return 1
182
+ else:
183
+ stories = _db.rows(con, "SELECT * FROM story WHERE verify_command IS NOT NULL AND verify_command<>''")
184
+ rc = 0
185
+ for s in stories:
186
+ cmd = s.get("verify_command")
187
+ if not cmd:
188
+ print(f" {s['id']}: no verify_command (skip)"); continue
189
+ # shell=True is intentional: verify_command is operator-authored (like a Makefile
190
+ # target). Do NOT pass externally-attacker-controlled strings here. For a shared
191
+ # multi-author harness, gate verify-all behind an allowlist or use shell=False.
192
+ res = subprocess.run(cmd, shell=True, capture_output=True, text=True)
193
+ result = "pass" if res.returncode == 0 else "fail"
194
+ _db.update(con, "story", "id", s["id"],
195
+ last_verified_at=_now(con), last_verified_result=result)
196
+ print(f" {s['id']}: {result} (exit {res.returncode})")
197
+ if result == "fail":
198
+ rc = 1
199
+ return rc
200
+
201
+
202
+ def cmd_trace(con, a):
203
+ rec = dict(task_summary=a.summary, intake_id=a.intake, story_id=a.story, agent=a.agent,
204
+ actions_taken=_jlist(a.actions), files_read=_jlist(a.files_read),
205
+ files_changed=_jlist(a.files_changed), decisions_made=_jlist(a.decisions),
206
+ errors=_jlist(a.errors), outcome=a.outcome, duration_seconds=a.duration,
207
+ token_estimate=a.tokens, harness_friction=a.friction, notes=a.notes)
208
+ tid = _db.insert(con, "trace", **{k: v for k, v in rec.items() if v is not None})
209
+ achieved, missing = D.score_trace(rec)
210
+ lane = None
211
+ if a.story:
212
+ s = _db.one(con, "SELECT * FROM story WHERE id=?", (a.story,))
213
+ lane = s.get("risk_lane") if s else None
214
+ lane = lane or a.lane or "normal"
215
+ ok, required = D.tier_verdict(lane, achieved)
216
+ print(f"PASS: trace #{tid} recorded. tier {achieved}/3, required {required} for lane '{lane}'"
217
+ + ("" if ok else " <-- BELOW required"))
218
+ if missing:
219
+ print(" to reach next tier, add:", ", ".join(missing))
220
+ # pre-close gate (advisory)
221
+ if a.story:
222
+ s = _db.one(con, "SELECT * FROM story WHERE id=?", (a.story,))
223
+ if s and s.get("last_verified_result") != "pass":
224
+ print(f" pre-close gate: story {a.story} has not passed verification "
225
+ f"(last={s.get('last_verified_result')}). Run 'story verify {a.story}'.")
226
+ return 0 # advisory: tier verdict never hard-fails the agent
227
+
228
+
229
+ def cmd_decision(con, a):
230
+ if a.decision_cmd == "add":
231
+ _db.insert(con, "decision", id=a.id, title=a.title, doc_path=a.doc,
232
+ status=a.status, verify_command=a.verify, predicted_impact=a.predicted)
233
+ print(f"PASS: decision {a.id} added. (durable record = this row + markdown ADR at {a.doc or '<none>'})")
234
+ if not a.doc:
235
+ print(" note: high-risk decisions need a docs/decisions/NNNN-*.md file too - a trace field is not durable.")
236
+ return 0
237
+ if a.decision_cmd == "verify":
238
+ d = _db.one(con, "SELECT * FROM decision WHERE id=?", (a.id,))
239
+ if not d:
240
+ print(f"FAIL: decision {a.id} not found"); return 1
241
+ if not d.get("verify_command"):
242
+ print(f" {a.id}: no verify_command"); return 0
243
+ # shell=True: operator-authored command only (see note in cmd_story verify).
244
+ res = subprocess.run(d["verify_command"], shell=True, capture_output=True, text=True)
245
+ result = "pass" if res.returncode == 0 else "fail"
246
+ _db.update(con, "decision", "id", a.id, last_verified_at=_now(con), last_verified_result=result)
247
+ print(f" {a.id}: {result} (exit {res.returncode})")
248
+ return 0 if result == "pass" else 1
249
+ if a.decision_cmd == "outcome":
250
+ # close the predicted-vs-actual loop: record what actually happened vs predicted_impact.
251
+ n = _db.update(con, "decision", "id", a.id, actual_outcome=a.actual, status=a.status)
252
+ print(f"PASS: decision {a.id} actual_outcome recorded" if n else f"FAIL: decision {a.id} not found")
253
+ return 0 if n else 1
254
+
255
+
256
+ def cmd_backlog(con, a):
257
+ if a.backlog_cmd == "add":
258
+ bid = _db.insert(con, "backlog", title=a.title, current_pain=a.pain,
259
+ discovered_while=a.discovered_while, suggested_improvement=a.suggested,
260
+ predicted_impact=a.predicted, risk=a.risk)
261
+ print(f"PASS: backlog #{bid} opened (growth-rule). Close it later with --outcome.")
262
+ return 0
263
+ if a.backlog_cmd == "close":
264
+ n = _db.update(con, "backlog", "id", a.id, actual_outcome=a.outcome,
265
+ status=a.status, implemented_at=_now(con) if a.status == "implemented" else None)
266
+ print(f"{'PASS' if n else 'FAIL'}: backlog #{a.id} {'closed' if n else 'not found'} ({a.status})")
267
+ return 0 if n else 1
268
+
269
+
270
+ def _scan_tool_row(con, root, row):
271
+ """Probe one tool's presence and persist status + checked_at. Returns (status, detail)."""
272
+ status, detail = P.scan_tool_status(root, row.get("kind") or "cli",
273
+ row.get("command"), row.get("scan_target"))
274
+ con.execute("UPDATE tool SET status=?, checked_at=datetime('now') WHERE name=?",
275
+ (status, row["name"]))
276
+ con.commit()
277
+ return status, detail
278
+
279
+
280
+ def cmd_tool(con, a):
281
+ root = os.environ.get("FLOW_PROJECT_ROOT") or os.getcwd()
282
+ if a.tool_cmd == "register":
283
+ kind = (getattr(a, "kind", None) or "cli").lower()
284
+ if kind not in D.TOOL_KINDS:
285
+ print(f"FAIL: unknown tool kind '{kind}' (use one of: {', '.join(D.TOOL_KINDS)})")
286
+ return 1
287
+ # Validate the responsibility against the fixed vocabulary (like --kind). Free text would
288
+ # fragment the "what is equipped for purpose X" lookup — a typo'd responsibility silently
289
+ # never matches `query tools --responsibility "<canonical>"`, defeating the registry.
290
+ if a.responsibility not in D.TOOL_RESPONSIBILITIES:
291
+ print(f"FAIL: unknown responsibility '{a.responsibility}'. Use one of: "
292
+ f"{', '.join(D.TOOL_RESPONSIBILITIES)}")
293
+ return 1
294
+ cap = D.normalize_capability(getattr(a, "capability", None)) or None
295
+ scan = getattr(a, "scan_target", None)
296
+ # Registry = declared intent + last-scanned reality: registration always succeeds (for a
297
+ # valid kind/responsibility) and records the probed status. A tool may legitimately be
298
+ # registered before it is installed (status=missing until `tool check` / a later register
299
+ # sees it). Presence is a query concern (`query tools --status present`), never a gate.
300
+ status, _detail = P.scan_tool_status(root, kind, a.command, scan)
301
+ # Upsert by PK name: DELETE + INSERT + checked_at in one explicit transaction so a crash
302
+ # mid-upsert can never leave the row missing (atomic replace, not delete-then-maybe-insert).
303
+ try:
304
+ con.execute("BEGIN")
305
+ con.execute("DELETE FROM tool WHERE name=?", (a.name,))
306
+ cols = dict(name=a.name, command=a.command, description=a.description,
307
+ responsibility=a.responsibility, args=a.args, kind=kind,
308
+ capability=cap, scan_target=scan, status=status)
309
+ keys = [k for k, v in cols.items() if v is not None]
310
+ con.execute(f"INSERT INTO tool ({', '.join(keys)}) VALUES ({', '.join('?' for _ in keys)})",
311
+ [cols[k] for k in keys])
312
+ con.execute("UPDATE tool SET checked_at=datetime('now') WHERE name=?", (a.name,))
313
+ con.execute("COMMIT")
314
+ except Exception:
315
+ con.execute("ROLLBACK")
316
+ raise
317
+ hint = " (not found yet; shows missing until installed)" if status == "missing" else ""
318
+ print(f"PASS: tool '{a.name}' registered "
319
+ f"(kind={kind}, capability={cap or '-'}, status={status}){hint}")
320
+ return 0
321
+ if a.tool_cmd == "check":
322
+ where, params = ("WHERE name=?", (a.name,)) if getattr(a, "name", None) else ("", ())
323
+ data = _db.rows(con, f"SELECT name,kind,command,scan_target FROM tool {where} ORDER BY name", params)
324
+ if not data:
325
+ if getattr(a, "name", None):
326
+ print(f"FAIL: no tool named '{a.name}'"); return 1
327
+ print("(no tools registered)"); return 0
328
+ results = [(r["name"], *_scan_tool_row(con, root, r)) for r in data]
329
+ if getattr(a, "json", False):
330
+ print(json.dumps([{"name": n, "status": s, "detail": d} for n, s, d in results], indent=2))
331
+ else:
332
+ for n, s, d in results:
333
+ print(f" {n:<20} {s:<8} {d}")
334
+ return 0
335
+ if a.tool_cmd == "remove":
336
+ cur = con.execute("DELETE FROM tool WHERE name=?", (a.name,)); con.commit()
337
+ if cur.rowcount:
338
+ print(f"PASS: tool '{a.name}' removed"); return 0
339
+ print(f"FAIL: no tool named '{a.name}'"); return 1
340
+ print(f"FAIL: unknown tool subcommand '{a.tool_cmd}'"); return 1
341
+
342
+
343
+ def cmd_intervention(con, a):
344
+ iid = _db.insert(con, "intervention", trace_id=a.trace, story_id=a.story,
345
+ type=a.type, description=a.description, source=a.source, impact=a.impact)
346
+ print(f"PASS: intervention #{iid} ({a.type} by {a.source})")
347
+ return 0
348
+
349
+
350
+ def cmd_query(con, a):
351
+ if a.query_cmd == "matrix":
352
+ data = _db.rows(con, "SELECT id,title,risk_lane,status,unit_proof,integration_proof,"
353
+ "e2e_proof,platform_proof,last_verified_result FROM story ORDER BY id")
354
+ if a.json:
355
+ print(json.dumps(data, indent=2)); return 0
356
+ if not data:
357
+ print("(no stories yet)"); return 0
358
+ b = (lambda v: ("1" if a.numeric else "yes") if v else ("0" if a.numeric else "no"))
359
+ print(f"{'STORY':<10} {'LANE':<9} {'STATUS':<12} U I E P VERIFY TITLE")
360
+ for s in data:
361
+ print(f"{s['id']:<10} {s['risk_lane']:<9} {s['status']:<12} "
362
+ f"{b(s['unit_proof'])[:1]} {b(s['integration_proof'])[:1]} "
363
+ f"{b(s['e2e_proof'])[:1]} {b(s['platform_proof'])[:1]} "
364
+ f"{(s['last_verified_result'] or '-'):<6} {s['title']}")
365
+ return 0
366
+ if a.query_cmd == "backlog":
367
+ where = ""
368
+ if a.open:
369
+ where = "WHERE status IN ('proposed','accepted')"
370
+ elif a.closed:
371
+ where = "WHERE status IN ('implemented','rejected')"
372
+ data = _db.rows(con, f"SELECT * FROM backlog {where}")
373
+ data = _order_by_reuse(data, lambda r: _is_security_text(r.get("title"), r.get("current_pain")))
374
+ _touch_accessed(con, "backlog", [r["id"] for r in data])
375
+ if a.json:
376
+ print(json.dumps(data, indent=2)); return 0
377
+ for x in data:
378
+ print(f" #{x['id']} [{x['status']}] {x['title']} -- pain: {x.get('current_pain') or '-'} "
379
+ f"| predicted: {x.get('predicted_impact') or '-'} | actual: {x.get('actual_outcome') or '-'}")
380
+ if not data:
381
+ print("(no backlog items)")
382
+ return 0
383
+ if a.query_cmd == "friction":
384
+ data = _db.rows(con, "SELECT id,created_at,task_summary,harness_friction,accessed_count FROM trace "
385
+ "WHERE harness_friction IS NOT NULL AND harness_friction<>''")
386
+ data = _order_by_reuse(data, lambda r: _is_security_text(r.get("task_summary"), r.get("harness_friction")))
387
+ _touch_accessed(con, "trace", [r["id"] for r in data])
388
+ if a.json:
389
+ print(json.dumps(data, indent=2)); return 0
390
+ for x in data:
391
+ print(f" trace #{x['id']}: {x['harness_friction']} (task: {x['task_summary']})")
392
+ if not data:
393
+ print("(no friction recorded)")
394
+ return 0
395
+ if a.query_cmd == "tools":
396
+ clauses, params = [], []
397
+ if a.responsibility:
398
+ clauses.append("responsibility = ?"); params.append(a.responsibility)
399
+ if getattr(a, "capability", None):
400
+ clauses.append("capability = ?"); params.append(D.normalize_capability(a.capability))
401
+ if getattr(a, "status", None):
402
+ clauses.append("status = ?"); params.append(a.status)
403
+ where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
404
+ data = _db.rows(con, f"SELECT * FROM tool {where} ORDER BY name", tuple(params))
405
+ if a.json:
406
+ print(json.dumps(data, indent=2)); return 0
407
+ for x in data:
408
+ print(f" {x['name']:<20} {(x.get('kind') or 'cli'):<7} {(x.get('status') or 'unknown'):<8} "
409
+ f"{(x.get('capability') or '-'):<22} {x['responsibility']}")
410
+ if not data:
411
+ print("(no tools registered)")
412
+ return 0
413
+ if a.query_cmd == "decisions":
414
+ data = _db.rows(con, "SELECT id,title,status,predicted_impact,actual_outcome,accessed_count FROM decision")
415
+ data = _order_by_reuse(data, lambda r: _is_security_text(r.get("title")))
416
+ _touch_accessed(con, "decision", [r["id"] for r in data])
417
+ if a.json:
418
+ print(json.dumps(data, indent=2)); return 0
419
+ for x in data:
420
+ print(f" {x['id']} [{x['status']}] {x['title']} "
421
+ f"| predicted: {x.get('predicted_impact') or '-'} | actual: {x.get('actual_outcome') or '-'}")
422
+ if not data:
423
+ print("(no decisions)")
424
+ return 0
425
+
426
+
427
+ # ---- self-improvement: audit (entropy/drift) + propose (deterministic GRC loop) ----
428
+ # Ported from repository-harness (infrastructure.rs audit/propose, domain.rs entropy_score).
429
+ # Deterministic on purpose: mine REPEATED friction/interventions by count (>=2) — never an
430
+ # LLM guess (grounded > intrinsic). Proposals are advisory; --commit only adds backlog rows.
431
+
432
+ _AUDIT_QUERIES = {
433
+ "orphaned_stories": ("orphaned planned/in-progress stories",
434
+ "SELECT story.id, story.title FROM story LEFT JOIN trace ON trace.story_id=story.id "
435
+ "WHERE story.status IN ('planned','in_progress') AND trace.id IS NULL ORDER BY story.id"),
436
+ "unverified_stories": ("unverified story commands",
437
+ "SELECT id, title FROM story WHERE verify_command IS NOT NULL AND TRIM(verify_command)<>'' "
438
+ "AND last_verified_result IS NULL ORDER BY id"),
439
+ "unverified_decisions": ("unverified decision commands",
440
+ "SELECT id, title FROM decision WHERE verify_command IS NOT NULL AND TRIM(verify_command)<>'' "
441
+ "AND last_verified_result IS NULL ORDER BY id"),
442
+ "backlog_without_outcomes": ("implemented backlog items without outcomes",
443
+ "SELECT CAST(id AS TEXT), title FROM backlog WHERE predicted_impact IS NOT NULL "
444
+ "AND actual_outcome IS NULL AND status='implemented' ORDER BY id"),
445
+ "stale_stories": ("stale unfinished stories (>30d since last trace)",
446
+ "SELECT story.id, story.title FROM story JOIN trace ON trace.story_id=story.id "
447
+ "WHERE story.status<>'implemented' GROUP BY story.id, story.title "
448
+ "HAVING julianday('now')-julianday(MAX(trace.created_at))>30 ORDER BY story.id"),
449
+ }
450
+ _ENTROPY_WEIGHTS = {"orphaned_stories": 10, "unverified_stories": 5, "unverified_decisions": 5,
451
+ "backlog_without_outcomes": 2, "stale_stories": 3}
452
+
453
+
454
+ def _audit(con):
455
+ findings = {k: _db.rows(con, sql) for k, (_label, sql) in _AUDIT_QUERIES.items()}
456
+ score = min(100, sum(len(findings[k]) * _ENTROPY_WEIGHTS[k] for k in _AUDIT_QUERIES))
457
+ return findings, score
458
+
459
+
460
+ def _normalize_token(s):
461
+ return re.sub(r"[^a-z0-9]+", " ", str(s).lower()).strip()
462
+
463
+
464
+ def _repeated_values(values, threshold=2):
465
+ """Group by normalized key, keep a representative + count; return those seen >= threshold."""
466
+ grouped, order = {}, []
467
+ for v in values:
468
+ k = _normalize_token(v)
469
+ if not k:
470
+ continue
471
+ if k in grouped:
472
+ grouped[k][1] += 1
473
+ else:
474
+ grouped[k] = [v, 1]
475
+ order.append(k)
476
+ return [(grouped[k][0], grouped[k][1]) for k in order if grouped[k][1] >= threshold]
477
+
478
+
479
+ def _short_title(s, n=8):
480
+ words = " ".join(str(s).split()[:n])
481
+ return (words[:69] + "...") if len(words) > 72 else words
482
+
483
+
484
+ def _confidence_for_count(c):
485
+ return "high" if c >= 3 else "medium"
486
+
487
+
488
+ def _repeated_friction(con):
489
+ rows = _db.rows(con, "SELECT harness_friction AS f FROM trace WHERE harness_friction IS NOT NULL "
490
+ "AND TRIM(harness_friction)<>'' AND LOWER(TRIM(harness_friction))<>'none'")
491
+ return _repeated_values([r["f"] for r in rows])
492
+
493
+
494
+ def _repeated_interventions(con):
495
+ rows = _db.rows(con, "SELECT type || ': ' || description AS k FROM intervention WHERE TRIM(description)<>''")
496
+ return _repeated_values([r["k"] for r in rows])
497
+
498
+
499
+ def _build_proposals(con):
500
+ findings, _score = _audit(con)
501
+ props = []
502
+ for text, count in _repeated_friction(con):
503
+ props.append(dict(title=f"Reduce repeated friction: {_short_title(text)}",
504
+ component="Failure attribution",
505
+ evidence=f"{count} traces recorded similar friction: {text}",
506
+ predicted_impact="Fewer repeated friction entries for similar tasks.",
507
+ risk="normal",
508
+ suggested="Update the relevant docs, templates, or guidance for this friction pattern.",
509
+ validation="Review the next five related traces and compare friction frequency.",
510
+ confidence=_confidence_for_count(count)))
511
+ for key, count in _repeated_interventions(con):
512
+ props.append(dict(title=f"Address repeated intervention: {_short_title(key)}",
513
+ component="Intervention recording",
514
+ evidence=f"{count} interventions share the pattern: {key}",
515
+ predicted_impact="Fewer repeated human/review interventions for the same issue.",
516
+ risk="normal",
517
+ suggested="Clarify the operating rule or gate that would have caught this earlier.",
518
+ validation="Future interventions of this type should decrease after the rule change.",
519
+ confidence=_confidence_for_count(count)))
520
+ for stage, f, t, fc in _usage_gate_fail_stages(con):
521
+ props.append(dict(title=f"Stage {stage} fails its gate often",
522
+ component=stage,
523
+ evidence=f"gate fail-rate {f}/{t} over {fc} cycles (mechanical usage log)",
524
+ predicted_impact="Fewer gate retries at this stage once its artifact/template is tightened.",
525
+ risk="normal",
526
+ suggested="Tighten this stage's artifact/template, or split the stage so its gate is honestly satisfiable.",
527
+ validation="Watch this stage's gate fail-rate in 'flow usage' over the next cycles.",
528
+ confidence="medium"))
529
+ for key, (label, _sql) in _AUDIT_QUERIES.items():
530
+ n = len(findings[key])
531
+ if n > 0:
532
+ props.append(dict(title=f"Clean up {label}", component="Entropy auditing",
533
+ evidence=f"Audit found {n} {label}.",
534
+ predicted_impact="Lower entropy score and stronger completion evidence.",
535
+ risk="tiny",
536
+ suggested="Resolve the listed audit findings or record why they are intentionally retained.",
537
+ validation="Run 'flow harness audit' and confirm the category count decreases.",
538
+ confidence="low"))
539
+ return props
540
+
541
+
542
+ def cmd_audit(con, a):
543
+ findings, score = _audit(con)
544
+ print(f"flow-harness audit: entropy score {score}/100 (0 = clean, higher = more drift)")
545
+ any_found = False
546
+ for key, (label, _sql) in _AUDIT_QUERIES.items():
547
+ rows = findings[key]
548
+ if rows:
549
+ any_found = True
550
+ print(f" {len(rows)} {label}:")
551
+ for r in rows[:10]:
552
+ vals = list(r.values())
553
+ print(f" - {vals[0]}: {vals[1] if len(vals) > 1 else ''}")
554
+ if not any_found:
555
+ print(" no drift findings - clean.")
556
+ return 0
557
+
558
+
559
+ def cmd_propose(con, a):
560
+ props = _build_proposals(con)
561
+ if not props:
562
+ print("flow-harness propose: no repeated friction/interventions or audit drift yet - nothing to propose.")
563
+ return 0
564
+ mode = "committing to backlog" if a.commit else "dry-run; pass --commit to add to backlog"
565
+ print(f"flow-harness propose: {len(props)} improvement proposal(s) from accumulated signal ({mode})")
566
+ for p in props:
567
+ print(f" [{p['confidence']}] {p['title']}")
568
+ print(f" evidence: {p['evidence']}")
569
+ print(f" suggest: {p['suggested']}")
570
+ if a.commit:
571
+ bid = _db.insert(con, "backlog", title=p["title"], discovered_while="flow harness propose",
572
+ current_pain=p["evidence"], suggested_improvement=p["suggested"],
573
+ risk=p["risk"], predicted_impact=p["predicted_impact"],
574
+ notes=f"component: {p['component']}; confidence: {p['confidence']}; validation: {p['validation']}")
575
+ print(f" -> backlog #{bid}")
576
+ return 0
577
+
578
+
579
+ def _now(con):
580
+ return con.execute("SELECT datetime('now')").fetchone()[0]
581
+
582
+
583
+ # ---------------- usage-log rollup + stats (schema 006) ----------------
584
+ # JSONL sinks are the source of truth; usage_event is a derived, queryable mirror.
585
+ # Idempotency rests on UNIQUE(src,line_no); rollup_cursor skips already-seen lines.
586
+
587
+ USAGE_COLS = ("epoch_s", "session_id", "cycle_id", "project", "command", "args",
588
+ "exit_code", "gate_pass", "duration_s", "stage_from", "stage_to", "card",
589
+ "project_type", "mode", "flow_version", "tier", "host", "read_only",
590
+ "gate_fail_reason", "ephemeral")
591
+
592
+
593
+ def _events_path(a):
594
+ return os.path.join(os.path.dirname(a._db_path), "events.jsonl")
595
+
596
+
597
+ def _global_log_path():
598
+ return os.path.join(os.path.expanduser("~"), ".claude", "flow", "usage.jsonl")
599
+
600
+
601
+ def _coerce_event(o):
602
+ row = {c: o.get(c) for c in USAGE_COLS}
603
+ for k in ("gate_pass", "read_only", "ephemeral"):
604
+ v = row.get(k)
605
+ if isinstance(v, bool):
606
+ row[k] = 1 if v else 0
607
+ # JSON null -> None (non-gate command); ints pass through
608
+ return row
609
+
610
+
611
+ def cmd_rollup(con, a):
612
+ srcs = [_events_path(a)]
613
+ if getattr(a, "global_", False):
614
+ srcs.append(_global_log_path())
615
+ rolled = skipped = 0
616
+ cols = ["src", "line_no"] + list(USAGE_COLS)
617
+ ph = ",".join("?" for _ in cols)
618
+ for src in srcs:
619
+ if not os.path.isfile(src):
620
+ continue
621
+ cur = con.execute("SELECT last_line FROM rollup_cursor WHERE src=?", (src,)).fetchone()
622
+ last = cur[0] if cur else 0
623
+ # errors="replace": a single invalid byte anywhere in a shared, multi-writer log must
624
+ # not abort the WHOLE rollup - it degrades to a per-line json.loads failure below
625
+ # instead (one bad row skipped, not every row on the file).
626
+ with open(src, "r", encoding="utf-8", errors="replace") as fh:
627
+ text = fh.read()
628
+ lines = text.split("\n")
629
+ lines = lines[:-1] # drop trailing empty (after final \n) or a partial unterminated last line
630
+ n = 0
631
+ total = len(lines)
632
+ cursor_advance = last
633
+ for line in lines:
634
+ n += 1
635
+ if n <= last:
636
+ continue
637
+ s = line.strip()
638
+ if not s:
639
+ cursor_advance = n
640
+ continue
641
+ try:
642
+ o = json.loads(s)
643
+ except ValueError:
644
+ skipped += 1
645
+ # A torn line can only be the CURRENT final line of the file (an EXIT-trap
646
+ # append still in flight) - any earlier malformed line is real corruption, not
647
+ # a race, so it is skipped and the cursor still advances past it. The final
648
+ # line's cursor is held so a completing append is retried on the next rollup
649
+ # rather than being permanently skipped once `n` reaches it.
650
+ if n == total:
651
+ break
652
+ cursor_advance = n
653
+ continue
654
+ row = _coerce_event(o)
655
+ vals = [src, n] + [row[c] for c in USAGE_COLS]
656
+ c2 = con.execute(f"INSERT OR IGNORE INTO usage_event ({','.join(cols)}) VALUES ({ph})", vals)
657
+ if c2.rowcount and c2.rowcount > 0:
658
+ rolled += 1
659
+ cursor_advance = n
660
+ # advance the cursor monotonically only (never reset backward, e.g. when a sink is
661
+ # truncated/empty on re-read) — correctness rests on UNIQUE(src,line_no) regardless.
662
+ if cursor_advance > last:
663
+ con.execute("INSERT INTO rollup_cursor(src,last_line,updated_at) VALUES(?,?,datetime('now')) "
664
+ "ON CONFLICT(src) DO UPDATE SET last_line=excluded.last_line, updated_at=excluded.updated_at",
665
+ (src, cursor_advance))
666
+ con.commit()
667
+ print(json.dumps({"rolled": rolled, "skipped": skipped}))
668
+ return 0
669
+
670
+
671
+ def cmd_usage(con, a):
672
+ src = _global_log_path() if getattr(a, "global_", False) else _events_path(a)
673
+ w = "WHERE src=?"
674
+ # Default-exclude throwaway/test runs so the view reflects real builds. COALESCE handles old
675
+ # rows (ephemeral NULL -> 0); the project-name fallback excludes legacy `tmp.*` runs that
676
+ # predate the field (no log rewrite). `--include-ephemeral` shows everything.
677
+ if not getattr(a, "include_ephemeral", False):
678
+ w += " AND NOT (COALESCE(ephemeral,0)=1 OR project LIKE 'tmp.%')"
679
+ pr = (src,)
680
+ total = con.execute(f"SELECT COUNT(*) FROM usage_event {w}", pr).fetchone()[0]
681
+ if not total:
682
+ if not getattr(a, "summary", False): # --summary stays silent on no data (recall appends nothing)
683
+ print(f"usage: no events yet for {src}")
684
+ print(" (run some flow commands first, then 'flow usage')")
685
+ return 0
686
+ g = con.execute(f"SELECT COUNT(*), SUM(CASE WHEN gate_pass=0 THEN 1 ELSE 0 END) FROM usage_event "
687
+ f"{w} AND command IN ('next','check') AND gate_pass IS NOT NULL", pr).fetchone()
688
+ gate_total, gate_fail = (g[0] or 0), (g[1] or 0)
689
+ fail_rate = (100.0 * gate_fail / gate_total) if gate_total else 0.0
690
+ cyc = con.execute(f"SELECT cycle_id, MAX(epoch_s)-MIN(epoch_s) FROM usage_event "
691
+ f"{w} AND cycle_id<>'' GROUP BY cycle_id", pr).fetchall()
692
+ cycles_started = len(cyc)
693
+ build_cycles, diag_cycles = _count_build_cycles(con, w, pr)
694
+ times = sorted(r[1] for r in cyc if r[1] is not None)
695
+ reached = con.execute(f"SELECT COUNT(DISTINCT cycle_id) FROM usage_event "
696
+ f"{w} AND cycle_id<>'' AND (card<>'' OR command='card')", pr).fetchone()[0]
697
+ dwell = con.execute(f"SELECT stage_to, COUNT(*), AVG(duration_s) FROM usage_event "
698
+ f"{w} AND command='next' AND stage_to<>'' GROUP BY stage_to ORDER BY stage_to", pr).fetchall()
699
+ # Wall-clock time spent IN each stage, reconstructed from stage transitions: a `next` event's
700
+ # epoch is the moment you LEFT stage_from / ENTERED stage_to. dwell(S) = (exit S) - (enter S),
701
+ # per cycle, averaged across cycles. Unlike duration_s (the runner's own exec time ~1-2s) this
702
+ # is real lead-time and answers "where do builds stall". Abandoned stages (no exit) are skipped.
703
+ #
704
+ # Legacy rows (pre-fix compact global lines) lack stage_from. Best-effort: infer it from the
705
+ # preceding row's stage_to, partitioned strictly by (project, cycle_id) ordered by epoch_s.
706
+ # Where a real stage_from exists it always wins; inference fires only when the field is absent.
707
+ # Inferred dwell is approximate (re-entries / idle gaps acknowledged); exact rows are unaffected.
708
+ trans = con.execute(f"SELECT cycle_id, project, stage_from, stage_to, epoch_s FROM usage_event "
709
+ f"{w} AND command='next' AND cycle_id<>'' AND epoch_s IS NOT NULL "
710
+ f"ORDER BY project, cycle_id, epoch_s", pr).fetchall()
711
+ _prev_stage_to: dict = {} # key=(project, cycle_id) -> stage_to of last processed row
712
+ _enter, _exit = {}, {} # key=(project, cycle_id) to prevent cross-project bleed on --global
713
+ _dwell_inference_fired = False # True when any row lacked a real stage_from (legacy pre-v0.12)
714
+ for cyc_id, proj_name, sf, st, es in trans:
715
+ pkey = (proj_name or "", cyc_id)
716
+ # Infer stage_from from prior row's stage_to when the field is absent (legacy compact rows).
717
+ sf_effective = sf if sf else _prev_stage_to.get(pkey)
718
+ if not sf and sf_effective:
719
+ _dwell_inference_fired = True
720
+ _prev_stage_to[pkey] = st if st else _prev_stage_to.get(pkey)
721
+ if st:
722
+ _enter.setdefault(pkey, {}).setdefault(st, es) # first entry into st
723
+ if sf_effective:
724
+ _exit.setdefault(pkey, {})[sf_effective] = es # last exit from sf_effective
725
+ _wall = {}
726
+ for pkey, stages in _enter.items():
727
+ for stg, ein in stages.items():
728
+ eout = _exit.get(pkey, {}).get(stg)
729
+ if eout is not None and eout >= ein:
730
+ _wall.setdefault(stg, []).append(eout - ein)
731
+ stage_wall = sorted((stg, len(v), sum(v) / len(v)) for stg, v in _wall.items())
732
+ def _dur(s):
733
+ s = int(s)
734
+ if s >= 86400: return f"{s/86400:.1f}d"
735
+ if s >= 3600: return f"{s/3600:.1f}h"
736
+ if s >= 60: return f"{s/60:.1f}m"
737
+ return f"{s}s"
738
+ cmds = con.execute(f"SELECT command, COUNT(*) FROM usage_event {w} GROUP BY command ORDER BY COUNT(*) DESC", pr).fetchall()
739
+ med = times[len(times) // 2] if times else 0
740
+ if getattr(a, "summary", False): # one compact line for `recall` to append
741
+ tf = con.execute(f"SELECT stage_to, SUM(CASE WHEN gate_pass=0 THEN 1 ELSE 0 END) AS f, COUNT(*) AS t "
742
+ f"FROM usage_event {w} AND command IN ('next','check') AND stage_to<>'' AND gate_pass IS NOT NULL "
743
+ f"GROUP BY stage_to HAVING f>0 ORDER BY f DESC LIMIT 1", pr).fetchone()
744
+ tfs = f"{tf[0]}({tf[1]}/{tf[2]})" if tf else "none"
745
+ print(f"USAGE (mechanical log): cycles={cycles_started} build-intent={build_cycles} diagnostic-only={diag_cycles} reached-cards={reached} "
746
+ f"| cycle-time s min/med/max={times[0] if times else 0}/{med}/{times[-1] if times else 0} "
747
+ f"| gate fail-rate={fail_rate:.0f}% | top-fail-stage={tfs}")
748
+ return 0
749
+ # per-card dwell (wall-clock): time from an operator-marked 'card start' to the card's
750
+ # successful 'card done'. Both are command='card'; the verb lives in args ('start C-NNN' /
751
+ # 'done C-NNN'). Pair the earliest start with the latest SUCCESSFUL done per (project, cycle,
752
+ # card). Cards finished by hand-edit + '/flow check' (no 'card done' event) have no pair — the
753
+ # metric covers verb-tracked cards only. A failed/reverted 'card done' (exit_code != 0) is not
754
+ # a completion, so it never closes a dwell. Works under --global too as of v0.20 Phase 1: the
755
+ # compact global JSONL line now carries card/args for command=card rows (previously omitted,
756
+ # which made --global dwell permanently blind) - rows written before v0.20 still lack these
757
+ # fields and simply yield no pair, which is why the "no pairs yet" branch below is worded as
758
+ # a capability statement, not a claim that --global can never show dwell.
759
+ _crows = con.execute(
760
+ f"SELECT project, cycle_id, card, args, exit_code, epoch_s FROM usage_event "
761
+ f"{w} AND command='card' AND card<>'' AND epoch_s IS NOT NULL "
762
+ f"AND (args LIKE 'start%' OR args LIKE 'done%') ORDER BY epoch_s", pr).fetchall()
763
+ _cstart, _cdone = {}, {}
764
+ for _proj, _cyc, _card, _cargs, _cec, _ces in _crows:
765
+ _ckey = (_proj or "", _cyc or "", _card)
766
+ _act = (_cargs or "").strip().lower()
767
+ if _act.startswith("start"):
768
+ _cstart.setdefault(_ckey, _ces) # earliest start (rows are epoch-ordered)
769
+ elif _act.startswith("done") and (_cec == 0):
770
+ _cdone[_ckey] = _ces # latest successful done
771
+ card_dwell = sorted(
772
+ (k[2], _cdone[k] - t0) for k, t0 in _cstart.items()
773
+ if k in _cdone and _cdone[k] >= t0)
774
+ # optional --builds-only: restrict cycle timing metrics to build cycles only
775
+ builds_only = getattr(a, "builds_only", False)
776
+ if builds_only and build_cycles < cycles_started:
777
+ # Identify build cycle_ids from the already-fetched _count_build_cycles data.
778
+ # Re-query to get per-cycle timing for build cycles only.
779
+ cycle_rows = con.execute(
780
+ f"SELECT cycle_id, read_only, command, MAX(epoch_s)-MIN(epoch_s) AS dur "
781
+ f"FROM usage_event {w} AND cycle_id<>'' GROUP BY cycle_id",
782
+ pr
783
+ ).fetchall()
784
+ # For each cycle, check if any event in it is non-read_only.
785
+ # We use the already-computed cycle event classification via _count_build_cycles logic
786
+ # but here per-cycle (need per-cycle decision).
787
+ # Efficient: aggregate read_only flag per cycle from the JSONL-derived table.
788
+ build_times = []
789
+ for row in cycle_rows:
790
+ cid, ro, cmd, dur = row
791
+ # Check if this cycle has any build event: query its events
792
+ # (small: cycles typically have few events)
793
+ cycle_evs = con.execute(
794
+ f"SELECT read_only, command FROM usage_event {w} AND cycle_id=?",
795
+ list(pr) + [cid]
796
+ ).fetchall()
797
+ if any(not _is_readonly_event(ro_e, cmd_e) for ro_e, cmd_e in cycle_evs):
798
+ if dur is not None:
799
+ build_times.append(dur)
800
+ times_display = sorted(build_times)
801
+ med_display = times_display[len(times_display) // 2] if times_display else 0
802
+ display_count = build_cycles
803
+ else:
804
+ times_display, med_display, display_count = times, med, cycles_started
805
+ if getattr(a, "json", False):
806
+ print(json.dumps({
807
+ "src": src, "events_total": total,
808
+ "gate_fail_rate_pct": round(fail_rate, 1), "gate_fail": gate_fail, "gate_total": gate_total,
809
+ "cycles_started": cycles_started,
810
+ "cycles_build_intent": build_cycles,
811
+ "cycles_diagnostic_only": diag_cycles,
812
+ "cycles_reached_cards": reached,
813
+ "cycle_time_s": {"min": times_display[0] if times_display else 0, "median": med_display, "max": times_display[-1] if times_display else 0},
814
+ "stage_exec_time": [{"stage": s, "n": c, "avg_s": round(v or 0, 1)} for s, c, v in dwell],
815
+ "stage_dwell": [{"stage": s, "n": c, "avg_s": round(v, 1)} for s, c, v in stage_wall],
816
+ "card_dwell": [{"card": c, "s": round(s, 1)} for c, s in card_dwell],
817
+ "commands": [{"command": c, "n": n} for c, n in cmds],
818
+ }))
819
+ return 0
820
+ print(f"flow usage - {src}")
821
+ print(f" events total: {total}")
822
+ print(f" gate fail-rate: {fail_rate:.0f}% ({gate_fail}/{gate_total} next|check failed)")
823
+ print(f" cycles started: {cycles_started} (build-intent: {build_cycles} · diagnostic-only: {diag_cycles})")
824
+ print(f" cycles reached cards: {reached} (abandonment proxy: {cycles_started - reached} not yet at cards)")
825
+ if times_display:
826
+ _ct_label = "build cycles" if builds_only and build_cycles < cycles_started else "cycles"
827
+ print(f" cycle-time (s) [{display_count} {_ct_label}]: min={times_display[0]} median={med_display} max={times_display[-1]}")
828
+ print(" per-stage command exec time (avg duration_s of 'next' - runner overhead, NOT lead-time):")
829
+ for s, c, v in dwell:
830
+ print(f" {s:<12} n={c} avg={(v or 0):.0f}s")
831
+ _dwell_hdr = (" per-stage dwell (wall-clock; ~approx for pre-v0.12 global rows):"
832
+ if _dwell_inference_fired else
833
+ " per-stage dwell (wall-clock: avg real time spent IN the stage, from transitions):")
834
+ print(_dwell_hdr)
835
+ if stage_wall:
836
+ for s, c, v in stage_wall:
837
+ print(f" {s:<12} n={c} avg={_dur(v)}")
838
+ else:
839
+ print(" (no completed stage transitions yet)")
840
+ print(" per-card dwell (wall-clock: 'card start' -> successful 'card done'):")
841
+ if card_dwell:
842
+ for c, s in card_dwell:
843
+ print(f" {c:<12} {_dur(s)}")
844
+ elif getattr(a, "global_", False):
845
+ print(" (per-card dwell requires rows written by flow >= 0.20 - older rows in this log predate the card/args fields)")
846
+ else:
847
+ print(" (no card start->done pairs yet - mark cards with '/flow card start|done')")
848
+ print(" commands:")
849
+ for c, n in cmds:
850
+ print(f" {c:<12} {n}")
851
+ return 0
852
+
853
+
854
+ # C-012: READ-TIME build-intent classification (FR2 logging is UNCHANGED; this is rollup-only).
855
+ # A cycle is a build cycle iff it has >=1 non-read_only event.
856
+ # Primary: use the already-logged `read_only` field.
857
+ # Fallback (legacy rows lacking the field): classify by command name via the same allowlist
858
+ # that _log_is_readonly in flow.sh uses: status|recall|ready|usage|tokens|coherence|
859
+ # consistency|contract|constitution|doctor|design|help are read-only; everything else is not.
860
+ _READONLY_CMDS = frozenset({
861
+ "status", "recall", "ready", "usage", "tokens", "coherence",
862
+ "consistency", "contract", "constitution", "doctor", "design", "help",
863
+ "-h", "--help", "",
864
+ })
865
+
866
+
867
+ def _is_readonly_event(ro, cmd):
868
+ """Return True iff this usage event is read-only (no build mutation).
869
+ ro: the read_only column value (1/0/True/False/None); cmd: the command string.
870
+ Primary: use `ro` when present. Fallback for legacy rows (ro=None): classify by command name.
871
+ """
872
+ if ro is not None:
873
+ # Already logged: 1/True = read_only, 0/False = not read_only
874
+ return bool(ro)
875
+ # Legacy row: COALESCE by command name
876
+ return (cmd or "").strip().lower() in _READONLY_CMDS
877
+
878
+
879
+ def _count_build_cycles(con, where_clause, params):
880
+ """Count build cycles (>=1 non-read_only event) and diagnostic-only cycles.
881
+ Retroactive: works on legacy rows lacking read_only via command-name COALESCE.
882
+ Returns (build_count, diagnostic_count).
883
+ """
884
+ rows = con.execute(
885
+ f"SELECT cycle_id, read_only, command FROM usage_event "
886
+ f"{where_clause} AND cycle_id<>''",
887
+ params
888
+ ).fetchall()
889
+ # Group events by cycle_id; a cycle is build-intent if any event is non-read_only.
890
+ cycle_has_build: dict = {} # cycle_id -> bool
891
+ for cid, ro, cmd in rows:
892
+ if cid not in cycle_has_build:
893
+ cycle_has_build[cid] = False
894
+ if not _is_readonly_event(ro, cmd):
895
+ cycle_has_build[cid] = True
896
+ build_count = sum(1 for v in cycle_has_build.values() if v)
897
+ diag_count = sum(1 for v in cycle_has_build.values() if not v)
898
+ return build_count, diag_count
899
+
900
+
901
+ def _usage_gate_fail_stages(con):
902
+ """Stages whose gate fails often (>=50%) across >=2 distinct cycles — the usage->propose signal.
903
+ Heuristic, surfaced for the operator to commit (never auto-applied)."""
904
+ try:
905
+ rows = con.execute(
906
+ "SELECT stage_to, SUM(CASE WHEN gate_pass=0 THEN 1 ELSE 0 END) AS f, COUNT(*) AS t, "
907
+ " COUNT(DISTINCT CASE WHEN gate_pass=0 THEN cycle_id END) AS fc "
908
+ "FROM usage_event WHERE command IN ('next','check') AND stage_to<>'' AND gate_pass IS NOT NULL "
909
+ "GROUP BY stage_to").fetchall()
910
+ except sqlite3.Error:
911
+ return []
912
+ out = []
913
+ for stage, f, t, fc in rows:
914
+ if t and (f / t) >= 0.5 and (fc or 0) >= 2:
915
+ out.append((stage, f, t, fc))
916
+ return out
917
+
918
+
919
+ def cmd_prune(con, a):
920
+ """Cap each JSONL sink to its last N lines, crash-safe (temp + os.replace). Pruning renumbers
921
+ lines, so the usage_event mirror + rollup_cursor for that sink are reset to rebuild cleanly."""
922
+ keep = getattr(a, "keep", None) or 5000
923
+ srcs = [_events_path(a)]
924
+ if getattr(a, "global_", False):
925
+ srcs.append(_global_log_path())
926
+ for src in srcs:
927
+ if not os.path.isfile(src):
928
+ print(json.dumps({"sink": src, "kept": 0, "dropped": 0, "note": "absent"}))
929
+ continue
930
+ # errors="replace": same shared, multi-writer file `cmd_rollup` was hardened against
931
+ # (v0.20 Phase 1) - a single invalid byte here must not crash prune either.
932
+ with open(src, "r", encoding="utf-8", errors="replace") as fh:
933
+ lines = fh.readlines()
934
+ if len(lines) <= keep:
935
+ print(json.dumps({"sink": src, "kept": len(lines), "dropped": 0}))
936
+ continue
937
+ kept = lines[-keep:]
938
+ tmp = src + ".tmp"
939
+ with open(tmp, "w", encoding="utf-8") as fh:
940
+ fh.writelines(kept)
941
+ os.replace(tmp, src) # atomic on the same filesystem
942
+ # line numbers changed -> the mirror + cursor for this sink are stale; reset so the next
943
+ # 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,))
946
+ con.commit()
947
+ print(json.dumps({"sink": src, "kept": len(kept), "dropped": len(lines) - len(kept)}))
948
+ if getattr(a, "global_", False):
949
+ # the global sink is shared: only THIS project's db cursor/mirror was reset. Any other
950
+ # project that ran `rollup --global` keeps a stale cursor + phantom rows for it until reset.
951
+ sys.stderr.write("note: pruned the device-global log; other projects that rolled it up should "
952
+ "reset it too (run a rollup after deleting their rollup_cursor row for that path).\n")
953
+ return 0
954
+
955
+
956
+ # ---------------- arg parsing ----------------
957
+
958
+ def build_parser():
959
+ p = argparse.ArgumentParser(prog="flow_harness.py", description="flow durable harness layer")
960
+ p.add_argument("--db", help="sqlite db path (default <FLOW_PROJECT_ROOT>/.flow/harness.db)")
961
+ sub = p.add_subparsers(dest="cmd", required=True)
962
+
963
+ sub.add_parser("init", help="create/upgrade the harness db")
964
+
965
+ pi = sub.add_parser("intake", help="classify incoming work + choose risk lane")
966
+ pi.add_argument("--type", required=True, choices=D.INPUT_TYPES)
967
+ pi.add_argument("--summary", required=True)
968
+ pi.add_argument("--flags", help="comma list: " + ",".join(D.RISK_FLAGS))
969
+ pi.add_argument("--lane", choices=D.LANES, help="override recommended lane")
970
+ pi.add_argument("--narrow-scope", dest="narrow_scope", action="store_true",
971
+ help="operator acceptance required to downgrade a hard-gate high_risk lane")
972
+ pi.add_argument("--removing-validation", dest="removing_validation", action="store_true")
973
+ pi.add_argument("--code-impact-high", dest="code_impact_high", action="store_true")
974
+ pi.add_argument("--docs"); pi.add_argument("--story"); pi.add_argument("--notes")
975
+
976
+ ps = sub.add_parser("story", help="story packets + proof status")
977
+ pss = ps.add_subparsers(dest="story_cmd", required=True)
978
+ a1 = pss.add_parser("add"); a1.add_argument("--id", required=True); a1.add_argument("--title", required=True)
979
+ a1.add_argument("--lane", required=True, choices=D.LANES); a1.add_argument("--contract"); a1.add_argument("--verify")
980
+ a2 = pss.add_parser("update"); a2.add_argument("--id", required=True); a2.add_argument("--status", choices=D.STORY_STATUSES)
981
+ for f in ("unit", "integration", "e2e", "platform"):
982
+ a2.add_argument(f"--{f}", type=int, choices=(0, 1))
983
+ a2.add_argument("--evidence")
984
+ a3 = pss.add_parser("verify"); a3.add_argument("--id", required=True)
985
+ pss.add_parser("verify-all")
986
+
987
+ pt = sub.add_parser("trace", help="record an agent task execution trace (auto-scored)")
988
+ pt.add_argument("--summary", required=True); pt.add_argument("--intake", type=int); pt.add_argument("--story", "--card")
989
+ # Accept the natural underscore variants + --card as a --story alias: agents in the wild
990
+ # (real CMC/C2-App-001 logs) typed --actions_taken/--files_changed/--files_read/--card and lost
991
+ # the trace to argparse exit-2. These aliases make those calls succeed instead of silently dropping.
992
+ pt.add_argument("--agent"); pt.add_argument("--actions", "--actions_taken")
993
+ pt.add_argument("--files-read", "--files_read", dest="files_read")
994
+ pt.add_argument("--files-changed", "--files_changed", dest="files_changed"); pt.add_argument("--decisions"); pt.add_argument("--errors")
995
+ pt.add_argument("--outcome", choices=D.TRACE_OUTCOMES); pt.add_argument("--duration", type=int)
996
+ pt.add_argument("--tokens", type=int); pt.add_argument("--friction"); pt.add_argument("--notes")
997
+ pt.add_argument("--lane", choices=D.LANES, help="lane hint when no --story")
998
+
999
+ pd = sub.add_parser("decision", help="durable decision records")
1000
+ pds = pd.add_subparsers(dest="decision_cmd", required=True)
1001
+ d1 = pds.add_parser("add"); d1.add_argument("--id", required=True); d1.add_argument("--title", required=True)
1002
+ d1.add_argument("--doc"); d1.add_argument("--status", choices=D.DECISION_STATUSES); d1.add_argument("--verify")
1003
+ d1.add_argument("--predicted")
1004
+ d2 = pds.add_parser("verify"); d2.add_argument("--id", required=True)
1005
+ d3 = pds.add_parser("outcome"); d3.add_argument("--id", required=True)
1006
+ d3.add_argument("--actual", required=True); d3.add_argument("--status", choices=D.DECISION_STATUSES)
1007
+
1008
+ pb = sub.add_parser("backlog", help="growth-rule improvement loop")
1009
+ pbs = pb.add_subparsers(dest="backlog_cmd", required=True)
1010
+ b1 = pbs.add_parser("add"); b1.add_argument("--title", required=True); b1.add_argument("--pain")
1011
+ b1.add_argument("--discovered-while", dest="discovered_while"); b1.add_argument("--suggested")
1012
+ b1.add_argument("--predicted"); b1.add_argument("--risk", choices=D.LANES)
1013
+ b2 = pbs.add_parser("close"); b2.add_argument("--id", type=int, required=True); b2.add_argument("--outcome", required=True)
1014
+ b2.add_argument("--status", choices=D.BACKLOG_STATUSES, default="implemented")
1015
+
1016
+ ptl = sub.add_parser("tool", help="kind-aware inbound tool/capability registry"); ptl_s = ptl.add_subparsers(dest="tool_cmd", required=True)
1017
+ tr = ptl_s.add_parser("register"); tr.add_argument("--name", required=True); tr.add_argument("--command", required=True)
1018
+ tr.add_argument("--description", required=True); tr.add_argument("--responsibility", required=True); tr.add_argument("--args")
1019
+ tr.add_argument("--kind", choices=D.TOOL_KINDS, default="cli", help="cli|binary|mcp|skill|http")
1020
+ tr.add_argument("--capability", help="workflow purpose, kebab-cased (e.g. edge-case-expansion)")
1021
+ tr.add_argument("--scan-target", dest="scan_target", help="path/URL probed for presence (mcp|skill|http)")
1022
+ tc = ptl_s.add_parser("check", help="probe registered tools' presence; persist status + checked_at")
1023
+ tc.add_argument("--name"); tc.add_argument("--json", action="store_true")
1024
+ trm = ptl_s.add_parser("remove"); trm.add_argument("--name", required=True)
1025
+
1026
+ pv = sub.add_parser("intervention", help="record a human/reviewer/ci/agent override")
1027
+ # --note is an additive alias for --description (both set a.description; eases the verb-grammar friction)
1028
+ pv.add_argument("--type", required=True, choices=D.INTERVENTION_TYPES); pv.add_argument("--description", "--note", required=True)
1029
+ pv.add_argument("--source", required=True, choices=D.INTERVENTION_SOURCES); pv.add_argument("--trace", type=int)
1030
+ pv.add_argument("--story"); pv.add_argument("--impact")
1031
+
1032
+ pq = sub.add_parser("query", help="read durable state")
1033
+ pqs = pq.add_subparsers(dest="query_cmd", required=True)
1034
+ q1 = pqs.add_parser("matrix"); q1.add_argument("--json", action="store_true"); q1.add_argument("--numeric", action="store_true")
1035
+ q2 = pqs.add_parser("backlog"); q2.add_argument("--open", action="store_true"); q2.add_argument("--closed", action="store_true"); q2.add_argument("--json", action="store_true")
1036
+ q3 = pqs.add_parser("friction"); q3.add_argument("--json", action="store_true")
1037
+ q4 = pqs.add_parser("tools"); q4.add_argument("--responsibility"); q4.add_argument("--json", action="store_true"); q4.add_argument("--summary", action="store_true")
1038
+ q4.add_argument("--capability"); q4.add_argument("--status", choices=D.TOOL_STATUSES)
1039
+ q5 = pqs.add_parser("decisions"); q5.add_argument("--json", action="store_true")
1040
+
1041
+ sub.add_parser("audit", help="entropy/drift audit: orphaned/unverified/stale records + a 0-100 score")
1042
+ pp = sub.add_parser("propose", help="deterministic improvement proposals from repeated friction/interventions + audit drift")
1043
+ pp.add_argument("--commit", action="store_true", help="write the proposals into the backlog (else dry-run)")
1044
+
1045
+ pr = sub.add_parser("rollup", help="ingest JSONL usage sinks into usage_event (idempotent)")
1046
+ pr.add_argument("--global", dest="global_", action="store_true", help="also roll up the device-global log")
1047
+ pu = sub.add_parser("usage", help="print usage analytics from usage_event")
1048
+ pu.add_argument("--global", dest="global_", action="store_true", help="read the device-global log instead of this project")
1049
+ pu.add_argument("--json", action="store_true")
1050
+ pu.add_argument("--include-ephemeral", dest="include_ephemeral", action="store_true",
1051
+ help="include throwaway/test runs (temp-dir or tmp.* projects), excluded by default")
1052
+ pu.add_argument("--summary", action="store_true", help="one compact line (for `recall` to append); silent on no data")
1053
+ pu.add_argument("--builds-only", dest="builds_only", action="store_true",
1054
+ help="filter cycle timing metrics to build-intent cycles only (excludes diagnostic-only)")
1055
+ ppr = sub.add_parser("prune", help="cap each JSONL sink to its last N lines (crash-safe; resets the mirror for that sink)")
1056
+ ppr.add_argument("--keep", type=int, help="lines to keep (default 5000)")
1057
+ ppr.add_argument("--global", dest="global_", action="store_true", help="also prune the device-global log")
1058
+ return p
1059
+
1060
+
1061
+ def main(argv):
1062
+ forwarded = _maybe_forward_to_rust(argv)
1063
+ if forwarded is not None:
1064
+ return forwarded
1065
+ try:
1066
+ a = build_parser().parse_args(argv)
1067
+ except SystemExit as e:
1068
+ # argparse exits 2 on a bad/missing flag, having printed a terse usage line. In real use
1069
+ # (CMC/C2-App-001 logs) that silently dropped durable decisions/traces. Add a guiding hint
1070
+ # of the common forms so the failure is actionable, then preserve argparse's exit code.
1071
+ if e.code not in (None, 0):
1072
+ sys.stderr.write(
1073
+ "flow-harness: command not accepted. Common forms (flags accept - or _; --card = --story):\n"
1074
+ " intake --type <new_spec|bug|chore|...> --summary \"...\" [--flags auth,data_model]\n"
1075
+ " story add --id C-NNN --title \"...\" --lane <normal|high_risk|...>\n"
1076
+ " trace --summary \"...\" [--story C-NNN] [--agent X] [--actions \"...\"] [--files-changed \"...\"] [--outcome completed]\n"
1077
+ " decision add --id <slug> --title \"...\" [--doc flow/04-adr.md]\n"
1078
+ "see harness/README.md for the full contract.\n"
1079
+ )
1080
+ raise
1081
+ con = _db.connect(db_path=a.db)
1082
+ a._db_path = a.db or _db.default_db_path()
1083
+ dispatch = {
1084
+ "init": cmd_init, "intake": cmd_intake, "story": cmd_story, "trace": cmd_trace,
1085
+ "decision": cmd_decision, "backlog": cmd_backlog, "tool": cmd_tool,
1086
+ "intervention": cmd_intervention, "query": cmd_query,
1087
+ "audit": cmd_audit, "propose": cmd_propose,
1088
+ "rollup": cmd_rollup, "usage": cmd_usage, "prune": cmd_prune,
1089
+ }
1090
+ try:
1091
+ return dispatch[a.cmd](con, a) or 0
1092
+ except sqlite3.IntegrityError as e:
1093
+ sys.stderr.write(f"flow-harness: {e} (already exists?)\n")
1094
+ return 1
1095
+ except sqlite3.Error as e:
1096
+ sys.stderr.write(f"flow-harness: db error: {e}\n")
1097
+ return 1
1098
+ finally:
1099
+ con.close()
1100
+
1101
+
1102
+ if __name__ == "__main__":
1103
+ sys.exit(main(sys.argv[1:]))