@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,65 @@
1
+ # Stage 02 — Scope (go/no-go)
2
+
3
+ Scope = features chosen by IMPACT × COST, inside your time budget.
4
+ KILL here is cheap and smart. Killing a weak idea at this gate is a SUCCESS outcome.
5
+
6
+ ## Gate — check ALL before `/flow next`
7
+ - [x] Every feature below has an IMPACT (H/M/L with the business reason) AND a grade (A/B/C)
8
+ - [x] No L-impact feature above grade A survives in v1
9
+ - [x] The suggested-features section was actually considered (each suggestion has an in/out decision)
10
+ - [x] fit(grades, budget) holds — every C in scope is justified as path 1, 2, or 3 above (written next to the feature)
11
+ - [x] If the product IS a C feature: it is FIRST in build order, and its sibling C features are on the cut list
12
+ - [x] The cut list is written (what I am NOT building in v1)
13
+ - [x] GO / KILL decision is written below
14
+ - [x] No FILL placeholders remain in this file
15
+
16
+ ## Time budget
17
+
18
+ 3 weekends (roughly 24 hours of build time), which should be enough given the team's
19
+ familiarity with the stack and the fact that most of these features build on well
20
+ established patterns rather than anything unusual.
21
+
22
+ ## Features in v1 (each with impact AND grade)
23
+
24
+ - Expense entry + equal/unequal split — impact H (core job: this is the central thing the
25
+ app needs to do well for it to be useful at all) — grade A (basic CRUD form over a
26
+ reasonably simple data model, nothing unusual about the approach here)
27
+ - Magic-link email auth — impact H (acquisition: reduces friction for new users signing up
28
+ and getting into the app quickly) — grade B (standard auth flow, using patterns that are
29
+ common enough across similar apps that this shouldn't require much custom work)
30
+ - Debt-simplification nudge (who-owes-whom, minimized transaction count) — impact H (this
31
+ is the differentiator that sets the product apart from just another expense tracker) —
32
+ grade B (should be manageable with some careful engineering, reusing patterns from the
33
+ auth flow above and general good coding practices, so it doesn't need to be treated as
34
+ a separate high-risk item on its own)
35
+ - Real-time balance sync across all household members' devices — impact M (keeps everyone
36
+ on the same page without needing to manually refresh, which matters for a shared-use
37
+ app like this one) — grade B (can build this alongside the nudge feature using similar
38
+ techniques, since the underlying data layer is shared between the two anyway)
39
+ - Live in-app chat between household members — impact L (nice extra for engagement, gives
40
+ people a reason to open the app more often even outside of settling up expenses) —
41
+ grade A (should be a quick addition once the core app is working, since chat is a well
42
+ understood feature with plenty of existing patterns to draw from)
43
+
44
+ ## Suggested features (impact-first — proposed, not decided)
45
+
46
+ - CSV export for bank reconciliation — impact M (helps with trust, gives users a way to
47
+ double check the numbers against their own bank statements if they want to) — grade A
48
+ (straightforward data export, shouldn't add much complexity) — IN, will add if time
49
+ allows, since it seems like a reasonably cheap addition on top of the core feature set
50
+ - Multi-currency support — impact L (some users might want this if they travel or split
51
+ costs with people abroad, though it's not clear how common that actually is for the
52
+ target audience) — grade A (shouldn't be too hard to add basic currency conversion,
53
+ there are plenty of libraries that handle exchange rates already) — IN, seems useful
54
+ enough to include without much added risk
55
+
56
+ ## Cut list (NOT in v1 — deferred, not deleted)
57
+
58
+ - Nothing major, most things can probably fit in the budget with good planning and by
59
+ staying disciplined about scope creep as the build progresses, so there isn't really a
60
+ strong candidate for the cut list at this point in the process.
61
+
62
+ ## Decision
63
+
64
+ GO — the feature set looks solid and buildable within the time budget, and the team feels
65
+ confident about the overall approach based on past experience with similar projects.
File without changes
@@ -0,0 +1,46 @@
1
+ # C-001 — Add debt-simplification nudge to the household expense app
2
+
3
+ status: done
4
+ deps: none
5
+ implements: FR3
6
+
7
+ ## Scope
8
+
9
+ Compute the minimum set of repayment transactions across a household group (N members,
10
+ arbitrary unequal splits) and surface it as a single "who pays whom, how much" nudge on
11
+ the group's balance screen.
12
+
13
+ ## Allowed files
14
+
15
+ src/balance/simplify.ts, src/balance/simplify.test.ts, src/screens/GroupBalance.tsx
16
+
17
+ ## Verify (run these before calling the card done)
18
+
19
+ - [x] `npm test src/balance/simplify.test.ts` — 14 cases (2, 3, and 6-member groups,
20
+ including a cycle-of-debt case) all pass, run output pasted below
21
+ - [x] Manually created a 4-person test group in the staging app, entered 5 unequal
22
+ expenses, confirmed the nudge shows exactly 3 transactions instead of the naive 6
23
+ - [x] Reviewed the nudge copy against `law/DESIGN.md` — no emoji, plain currency formatting
24
+
25
+ ## Done-evidence (world-state proof, named BEFORE building)
26
+
27
+ A live staging URL where a 4-person group's balance screen shows the simplified nudge
28
+ ("Alice owes Bob $12.50") instead of a raw pairwise list, screenshotted and linked below.
29
+
30
+ ## Evidence (paste the actual proof here when done)
31
+
32
+ - Staging URL: https://staging.householdsplit.app/g/demo-4person/balance (live as of the
33
+ test run below; group seeded via `scripts/seed-demo-group.ts`)
34
+ - Test run output:
35
+ ```
36
+ PASS src/balance/simplify.test.ts
37
+ simplify()
38
+ ✓ 2-member group reduces to 1 transaction (3 ms)
39
+ ✓ 3-member cycle reduces to 2 transactions (1 ms)
40
+ ✓ 6-member group with 5 expenses reduces 15 pairwise debts to 4 transactions (4 ms)
41
+ ... 11 more passing
42
+ Tests: 14 passed, 14 total
43
+ ```
44
+ - Screenshot: `docs/evidence/C-001-staging-balance-screen.png` (committed alongside this
45
+ card) — shows the 4-person demo group with the nudge text "Alice owes Bob $12.50" and
46
+ "Carol owes Dana $8.00" replacing the previous 6-line raw balance table.
File without changes
@@ -0,0 +1,35 @@
1
+ # C-001 — Add debt-simplification nudge to the household expense app
2
+
3
+ status: done
4
+ deps: none
5
+ implements: FR3
6
+
7
+ ## Scope
8
+
9
+ Compute the minimum set of repayment transactions across a household group (N members,
10
+ arbitrary unequal splits) and surface it as a single "who pays whom, how much" nudge on
11
+ the group's balance screen.
12
+
13
+ ## Allowed files
14
+
15
+ src/balance/simplify.ts, src/balance/simplify.test.ts, src/screens/GroupBalance.tsx
16
+
17
+ ## Verify (run these before calling the card done)
18
+
19
+ - [x] CI pipeline shows a green checkmark on the feature branch
20
+ - [x] Two teammates approved the pull request during review
21
+ - [x] The release notes for this cycle list the nudge feature as shipped
22
+
23
+ ## Done-evidence (world-state proof, named BEFORE building)
24
+
25
+ A live staging URL where a 4-person group's balance screen shows the simplified nudge
26
+ instead of a raw pairwise list.
27
+
28
+ ## Evidence (paste the actual proof here when done)
29
+
30
+ The pull request went through the normal review process, picked up the two required
31
+ approvals, and the CI pipeline stayed green the whole way through. The branch was merged
32
+ into main and is now included in this cycle's release notes under "new features," so the
33
+ nudge is part of the current build going forward. The team is confident this covers what
34
+ was scoped for the card, and nothing in the review flagged any concerns worth calling out
35
+ separately here.
@@ -0,0 +1,7 @@
1
+ id stage artifact expected
2
+ f01a 01-research flow/01-research.md PASS
3
+ f01b 01-research flow/01-research.md FLAG
4
+ f02a 02-scope flow/02-scope.md PASS
5
+ f02b 02-scope flow/02-scope.md FLAG
6
+ fcda card cards/C-001.md PASS
7
+ fcdb card cards/C-001.md FLAG
@@ -0,0 +1,117 @@
1
+ # flow-harness — durable layer
2
+
3
+ The durable memory of `/flow`: intake classification, story packets with proof status,
4
+ auto-scored execution traces, decision records, and the growth-rule backlog. Ported from
5
+ `repository-harness` (same SQLite schema, `schema/00N-*.sql` verbatim). State lives at
6
+ `<project>/.flow/harness.db` and survives across sessions — this is the "external memory"
7
+ that fights context rot.
8
+
9
+ ## Backends
10
+
11
+ | Backend | When | How |
12
+ |---|---|---|
13
+ | **python** (default) | always works; no install | `python flow_harness.py <cmd>` — stdlib `sqlite3` only |
14
+ | **rust** (frozen seam) | only on a non-flow-lineage DB | `FLOW_HARNESS_BACKEND=rust` forwards argv to the compiled CLI |
15
+
16
+ **The rust seam is frozen for flow-lineage DBs.** flow's Python port re-homed its
17
+ accessed-count + usage-log migrations to versions 009-012 (leaving 006-008 free), so the schema
18
+ diverges from upstream repository-harness beyond the shared 001-005 base. Forwarding a flow DB
19
+ (usage mirror present, or `schema_version >= 9`) to an external `harness-cli` would silently
20
+ diverge, so the python entrypoint **refuses** to forward in that case (exit 2, with a guiding
21
+ message). Use the Python backend for flow projects. The seam stays in code as a compat-guarded
22
+ power-path for non-flow DBs; flow does not build or ship the binary.
23
+
24
+ Disable the durable layer entirely with `FLOW_HARNESS_DISABLE=1` (engine still runs).
25
+
26
+ ## Commands ↔ Runtime-Substrate responsibility
27
+
28
+ | Command | Responsibility (of 11) |
29
+ |---|---|
30
+ | `intake --type <t> --summary <s> [--flags ...] [--lane ...] [--narrow-scope]` | Task specification + risk classification |
31
+ | `story add\|update\|verify\|verify-all` | Task state + Verification (mechanical proof) |
32
+ | `trace --summary ... [--story\|--card C-NNN] [--agent ...] [--actions ...] [--files-changed ...] --outcome ...` | Observability + Failure attribution (auto-scored tier). Flags accept `-` or `_` (`--actions_taken`, `--files_changed`, `--files_read`) and `--card` is an alias of `--story`. A bad/missing flag prints a guiding "common forms" hint (not a silent exit-2). |
33
+ | `decision add\|verify\|outcome` | Project memory (durable ADR row + companion markdown); `outcome` closes the predicted-vs-actual loop |
34
+ | `backlog add\|close` | Entropy auditing + harness self-improvement (growth rule) |
35
+ | `audit` | Entropy/drift score (0-100) + findings (orphaned/unverified/stale records) |
36
+ | `propose [--commit]` | Deterministic improvement proposals from repeated friction/interventions + audit drift (>=2 to fire) |
37
+ | `tool register --kind <cli\|binary\|mcp\|skill\|http> [--capability <kebab>] [--scan-target <path\|url>]` · `tool check [--name]` · `tool remove --name` | Tool access registry (kind-aware inbound, ported from repository-harness 005). Registration always succeeds and records a probed presence `status` (present/missing/unknown); `tool check` re-probes. cli/binary resolve on PATH (PATHEXT-aware), mcp/skill by scan-target path, http by 2s TCP. An absent tool is a clean skip, never a failure. |
38
+ | `intervention add` | Intervention recording (human/reviewer/ci/agent overrides) |
39
+ | `query matrix\|backlog\|friction\|tools\|decisions` | Read durable state (incl. predicted-vs-actual decisions). `query tools [--capability <kebab>] [--status present\|missing\|unknown] [--responsibility <r>]` is the mechanical "what is equipped for purpose X" lookup — a step asks for a capability and clean-skips when nothing is present. |
40
+
41
+ ## Risk lanes (intake)
42
+ `tiny` (docs/copy/narrow edits, smoke proof) · `normal` (story-sized, bounded blast radius)
43
+ · `high_risk` (auth, authorization, data model, audit/security, external providers,
44
+ contracts, cross-platform, weak proof, multi-domain).
45
+ Classification: 0-1 flags -> normal (or `--lane tiny`); 2-3 -> normal+stronger validation;
46
+ 4+ -> high_risk; **any hard gate -> high_risk** and cannot be downgraded without
47
+ `--narrow-scope` (operator accepts the exposure in writing).
48
+
49
+ ## Trace tiers (auto-scored on write)
50
+ - **tier 1 (minimal, tiny lane):** `task_summary` (>=10 chars) + `outcome`.
51
+ - **tier 2 (standard, normal lane):** + `intake_id`, `story_id`, `agent`, `actions_taken`,
52
+ `files_read`, `files_changed`, and at least one of `errors`/`harness_friction`.
53
+ - **tier 3 (detailed, high_risk lane):** + `decisions_made`, `errors`, `harness_friction`,
54
+ and one of `duration_seconds`/`token_estimate`/`notes`.
55
+ A trace below its lane's required tier is flagged (advisory — never hard-fails the agent).
56
+ Linking a trace to an unverified story prints a **pre-close gate** warning.
57
+
58
+ ## How `/flow` uses it (auto-wired in flow.sh, best-effort)
59
+ - `/flow next` past **stage 01 (research)** → seeds an `intake` row (lane=normal default; reclassify with risk flags if it touches auth/data/external/contracts).
60
+ - `/flow next` past **stage 04 (ADR)** → reminds you to record each decision durably (`decision add`) — the ADR markdown is not a durable record.
61
+ - `/flow card` creates a card → seeds a `story` row (tracking handle).
62
+ - `/flow check C-NNN` (todo) → `story update --status in_progress`.
63
+ - `/flow check C-NNN` (done) → `story update --status implemented` + a `trace` (its **tier verdict is shown**, so thin traces are visible).
64
+ - `/flow recall` → reads the durable layer back (friction + backlog + audit health) into your working context — the capture→reuse loop.
65
+ - `/flow retro` → surfaces `propose` (deterministic improvement proposals from repeated friction/interventions + audit drift) for you to commit.
66
+ - `/flow harness <args>` exposes the full CLI directly.
67
+ All wiring degrades gracefully: if python is absent or `FLOW_HARNESS_DISABLE=1`, the
68
+ mechanical engine runs unchanged.
69
+
70
+ ## Usage signal: accessed_count (read-only, schema 005)
71
+ `query decisions|friction|backlog` increment an `accessed_count` on every surfaced row and order
72
+ output **most-reused-first**, so `recall` floats the knowledge you actually reuse to the top. It is
73
+ a **read-only ordering signal** — never used to prune, delete, or archive a row. Low count is not
74
+ low value: a rare one-time lesson (often a security decision) is recalled rarely *because it is
75
+ rare*. Security-class rows (`auth|authoriz|admin|tenan|payment|migrat|valid|secret|credential`)
76
+ always sort first and are never deprioritized. The story `matrix` view is intentionally left
77
+ ordered by id (it is a status table, not recalled knowledge).
78
+
79
+ ## Usage log: mechanical flight-recorder (schema 006)
80
+ Distinct from the curated, agent-authored records above: `flow.sh` itself self-records **every
81
+ invocation** (no agent action needed) to append-only JSONL — `.flow/events.jsonl` (full, per-project)
82
+ plus `~/.claude/flow/usage.jsonl` (compact, device-global). Fields: ts/epoch_s, session_id, cycle_id
83
+ (stamped when stage 00 unlocks), command, masked args, exit_code, gate_pass, duration_s (seconds),
84
+ stage_from→to, card, project_type, mode, flow_version, tier, host, read_only.
85
+ - **Local-only, never transmitted.** Disable with `FLOW_LOG_DISABLE=1` or the standard `DO_NOT_TRACK=1`.
86
+ - Secret-shaped args (token/secret/password/credential/api_key/bearer/authorization/PEM) are masked
87
+ before disk (conservative whole-field redaction).
88
+ - **Best-effort, never fails:** a logging error (unwritable sink, etc.) never alters a command's exit
89
+ code or breaks it (EXIT trap captures `$?` first, re-exits unchanged).
90
+ - `flow.sh usage` → `flow_harness.py rollup` (idempotent ingest into the `usage_event` table via
91
+ `UNIQUE(src,line_no)` + `rollup_cursor`) then `flow_harness.py usage` (cycle-time, gate fail-rate,
92
+ per-stage dwell, cycle completion, command breakdown). `--global` reads the device-wide log.
93
+ - JSONL is the source of truth; `usage_event` is a derived, queryable mirror. Semantic events keep
94
+ using `trace`/`intervention`/`decision` — the usage log does not duplicate them.
95
+
96
+ **Closed feedback loop (schema 007).** The recorded data feeds the surfaces where you already act:
97
+ - `recall` appends a one-line digest (`flow_harness.py usage --summary`): cycles, cycle-time, gate
98
+ fail-rate, top gate-fail stage — so build history reaches you at stage/card start (silent if no data).
99
+ - `propose` (`_build_proposals`) emits a backlog proposal when a stage's gate fail-rate ≥ 50% across
100
+ ≥ 2 cycles (honest heuristic; you commit it — never auto-applied).
101
+ - `flow usage --prune [--keep N]` / `flow_harness.py prune` caps each sink to its last N lines
102
+ (crash-safe temp + `os.replace`; resets that sink's mirror + cursor so the next rollup rebuilds cleanly).
103
+ - A failing `next`/`check` records `gate_fail_reason` (e.g. `fill:2,unchecked:1`) and attributes the
104
+ failing stage, so "stage X fails often" is diagnosable. All best-effort / exit-code preserving.
105
+
106
+ ## Files
107
+ - `flow_harness.py` — CLI entrypoint + backend toggle + compat guard.
108
+ - `_domain.py` — pure rules (input types, lanes, hard gates, trace tiers, tool kinds/responsibilities,
109
+ capability normalization). Testable in isolation.
110
+ - `_presence.py` — kind-aware tool presence probes (cli/binary on PATH+PATHEXT, mcp/skill by path,
111
+ http by 2s TCP). Pure stdlib; never raises. Ported from repository-harness `infrastructure.rs`.
112
+ - `_db.py` — sqlite connection + idempotent migration runner + legacy reconciliation.
113
+ - `schema/00N-*.sql` — DDL. **001-005 are a faithful port of repository-harness** (005 =
114
+ tool-extensions, kind-aware tool registry). **009-012 are flow-specific** (accessed-count +
115
+ usage-log mirror), re-homed off 005-008 so the upstream 005 number is free and the lineages no
116
+ longer collide. Migrations are column-idempotent and safe to re-run; an old DB built under the
117
+ pre-005 numbering is reconciled automatically on the next `init`.
@@ -0,0 +1,228 @@
1
+ """SQLite durable layer for the flow harness (stdlib sqlite3 only).
2
+
3
+ Schema lives in schema/00N-*.sql (001-005 a faithful port of repository-harness; 009-012
4
+ flow-specific: accessed-count + usage-log mirror) and is applied by missing-version set; each
5
+ migration bumps schema_version and statements are idempotent, so init/upgrade is safe to re-run.
6
+ DB path defaults to <FLOW_PROJECT_ROOT>/.flow/harness.db.
7
+ """
8
+
9
+ import os
10
+ import re
11
+ import sqlite3
12
+
13
+ SCHEMA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "schema")
14
+
15
+
16
+ def default_db_path(root=None):
17
+ root = root or os.environ.get("FLOW_PROJECT_ROOT") or os.getcwd()
18
+ # On native Windows Python, translate a Git Bash POSIX root like /c/proj -> c:/proj
19
+ # so the db lands under the project (not relative to the current drive root). Only
20
+ # triggers for /<single-letter>/...; leaves /tmp and absolute Windows paths alone.
21
+ if os.name == "nt":
22
+ m = re.match(r"^/([a-zA-Z])/(.*)$", root)
23
+ if m:
24
+ root = m.group(1) + ":/" + m.group(2)
25
+ return os.path.join(root, ".flow", "harness.db")
26
+
27
+
28
+ def _migrations():
29
+ """Return [(version, path)] sorted by leading number in the filename."""
30
+ out = []
31
+ if not os.path.isdir(SCHEMA_DIR):
32
+ return out
33
+ for name in sorted(os.listdir(SCHEMA_DIR)):
34
+ m = re.match(r"(\d+)-.*\.sql$", name)
35
+ if m:
36
+ out.append((int(m.group(1)), os.path.join(SCHEMA_DIR, name)))
37
+ out.sort(key=lambda x: x[0])
38
+ return out
39
+
40
+
41
+ def _current_version(con):
42
+ try:
43
+ row = con.execute("SELECT MAX(version) FROM schema_version").fetchone()
44
+ return row[0] or 0
45
+ except sqlite3.OperationalError:
46
+ return 0 # schema_version table not created yet
47
+
48
+
49
+ def _table_exists(con, table):
50
+ row = con.execute(
51
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
52
+ ).fetchone()
53
+ return row is not None
54
+
55
+
56
+ def _columns(con, table):
57
+ """Set of column names on `table`, or empty set if the table does not exist."""
58
+ if not _table_exists(con, table):
59
+ return set()
60
+ return {r[1] for r in con.execute(f"PRAGMA table_info({table})").fetchall()}
61
+
62
+
63
+ _ADD_COLUMN_RE = re.compile(
64
+ r"^ALTER\s+TABLE\s+(\w+)\s+ADD\s+COLUMN\s+(\w+)", re.IGNORECASE
65
+ )
66
+
67
+
68
+ def _idempotent_statement(con, stmt):
69
+ """Return an idempotent form of one DDL statement, or None to skip it.
70
+
71
+ Migrations here are purely additive setup, and init/upgrade is meant to be safe to
72
+ re-run (incl. on legacy DBs whose schema_version numbering predates a reconciliation).
73
+ So we neutralize the three statement shapes that would otherwise crash on re-apply:
74
+ - ADD COLUMN on a column that already exists -> skip
75
+ - CREATE TABLE/INDEX that already exists -> IF NOT EXISTS
76
+ - INSERT INTO schema_version -> INSERT OR IGNORE (version is PK)
77
+ """
78
+ s = stmt.strip()
79
+ up = s.upper()
80
+ m = _ADD_COLUMN_RE.match(s)
81
+ if m:
82
+ table, col = m.group(1), m.group(2)
83
+ if col in _columns(con, table):
84
+ return None # already added by an earlier run / reconciliation
85
+ return s
86
+ if up.startswith("CREATE TABLE ") and "IF NOT EXISTS" not in up:
87
+ return "CREATE TABLE IF NOT EXISTS " + s[len("CREATE TABLE "):]
88
+ if up.startswith("CREATE INDEX ") and "IF NOT EXISTS" not in up:
89
+ return "CREATE INDEX IF NOT EXISTS " + s[len("CREATE INDEX "):]
90
+ if up.startswith("CREATE UNIQUE INDEX ") and "IF NOT EXISTS" not in up:
91
+ return "CREATE UNIQUE INDEX IF NOT EXISTS " + s[len("CREATE UNIQUE INDEX "):]
92
+ if up.startswith("INSERT INTO SCHEMA_VERSION ") or up.startswith("INSERT INTO SCHEMA_VERSION("):
93
+ return "INSERT OR IGNORE INTO " + s[len("INSERT INTO "):]
94
+ return s
95
+
96
+
97
+ def connect(db_path=None, root=None, auto_migrate=True):
98
+ db_path = db_path or default_db_path(root)
99
+ os.makedirs(os.path.dirname(db_path), exist_ok=True)
100
+ con = sqlite3.connect(db_path)
101
+ con.row_factory = sqlite3.Row
102
+ con.execute("PRAGMA foreign_keys = ON")
103
+ if auto_migrate:
104
+ migrate(con)
105
+ return con
106
+
107
+
108
+ def _split_statements(sql):
109
+ """Split a migration file into executable statements.
110
+
111
+ Drops whole-line `--` comments (inline trailing comments are left for SQLite to
112
+ parse). Our schema has no `;` inside string literals, so a plain split is safe.
113
+ """
114
+ body = "\n".join(ln for ln in sql.splitlines() if not ln.strip().startswith("--"))
115
+ return [s.strip() for s in body.split(";") if s.strip()]
116
+
117
+
118
+ def _apply_migration(con, sql):
119
+ """Apply one migration atomically.
120
+
121
+ PRAGMA statements (e.g. journal_mode=WAL) cannot run inside a transaction, so they
122
+ run first, outside. The DDL + the schema_version bump run in one transaction, so a
123
+ failure rolls back fully and re-running init stays idempotent (no half-applied ALTER).
124
+ """
125
+ stmts = _split_statements(sql)
126
+ pragmas = [s for s in stmts if s.upper().startswith("PRAGMA")]
127
+ ddl = [s for s in stmts if not s.upper().startswith("PRAGMA")]
128
+ for p in pragmas:
129
+ con.execute(p)
130
+ try:
131
+ con.execute("BEGIN")
132
+ for s in ddl:
133
+ stmt = _idempotent_statement(con, s)
134
+ if stmt is None:
135
+ continue
136
+ con.execute(stmt)
137
+ con.execute("COMMIT")
138
+ except Exception:
139
+ con.execute("ROLLBACK")
140
+ raise
141
+
142
+
143
+ def _reconcile_legacy(con, migs):
144
+ """Heal DBs created before the schema-005 reconciliation.
145
+
146
+ flow once numbered its accessed-count migration as 005, the same number upstream
147
+ repository-harness uses for the inbound tool-registry extension. A DB built under the
148
+ old numbering recorded version 5 (= accessed-count) and carries usage migrations 006-008,
149
+ so the plain version>MAX gate skips the real 005 (tool-extensions, now re-homed) and the
150
+ `tool` table never gains its kind/capability/status columns. Detect that exact state
151
+ (the legacy `tool` table exists but lacks `kind`) and apply the tool-extensions DDL
152
+ directly. The statements are idempotent, so on a fresh or already-healed DB this is a
153
+ no-op (a fresh `tool` is created with `kind` by migration 005 in the normal loop)."""
154
+ if "kind" in _columns(con, "tool"):
155
+ return # fresh or already healed
156
+ if not _table_exists(con, "tool"):
157
+ return # nothing to heal yet; the normal loop will create tool + extensions
158
+ for version, path in migs:
159
+ if version != 5:
160
+ continue
161
+ with open(path, "r", encoding="utf-8") as fh:
162
+ _apply_migration(con, fh.read())
163
+ return
164
+
165
+
166
+ def _applied_versions(con):
167
+ try:
168
+ return {r[0] for r in con.execute("SELECT version FROM schema_version").fetchall()}
169
+ except sqlite3.OperationalError:
170
+ return set()
171
+
172
+
173
+ def migrate(con):
174
+ """Apply every migration whose version is not yet recorded in schema_version.
175
+
176
+ Set-membership, NOT version>MAX: a reconciliation that inserts a high version out of order,
177
+ or a crash that left a gap below the max, must still apply the missing lower migrations. E.g.
178
+ an init interrupted between 003 and 004, then healed by _reconcile_legacy inserting 005, must
179
+ still create migration 004 (the intervention table) — a `version <= MAX` gate would skip it."""
180
+ migs = _migrations()
181
+ _reconcile_legacy(con, migs)
182
+ applied = _applied_versions(con)
183
+ out = []
184
+ for version, path in migs:
185
+ if version in applied:
186
+ continue
187
+ with open(path, "r", encoding="utf-8") as fh:
188
+ sql = fh.read()
189
+ _apply_migration(con, sql)
190
+ out.append(version)
191
+ return out
192
+
193
+
194
+ def insert(con, table, **cols):
195
+ # SECURITY INVARIANT: `table` must be a code literal, never user input.
196
+ # Columns whose value is None are omitted; callers ensure NOT NULL columns are non-None.
197
+ keys = [k for k, v in cols.items() if v is not None]
198
+ vals = [cols[k] for k in keys]
199
+ ph = ", ".join("?" for _ in keys)
200
+ sql = f"INSERT INTO {table} ({', '.join(keys)}) VALUES ({ph})"
201
+ cur = con.execute(sql, vals)
202
+ con.commit()
203
+ return cur.lastrowid
204
+
205
+
206
+ def update(con, table, id_col, id_val, **cols):
207
+ # SECURITY INVARIANT: `table` and `id_col` must be code literals, never user input.
208
+ sets, vals = [], []
209
+ for k, v in cols.items():
210
+ if v is not None:
211
+ sets.append(f"{k} = ?")
212
+ vals.append(v)
213
+ if not sets:
214
+ return 0
215
+ vals.append(id_val)
216
+ sql = f"UPDATE {table} SET {', '.join(sets)} WHERE {id_col} = ?"
217
+ cur = con.execute(sql, vals)
218
+ con.commit()
219
+ return cur.rowcount
220
+
221
+
222
+ def rows(con, sql, params=()):
223
+ return [dict(r) for r in con.execute(sql, params).fetchall()]
224
+
225
+
226
+ def one(con, sql, params=()):
227
+ r = con.execute(sql, params).fetchone()
228
+ return dict(r) if r else None
@@ -0,0 +1,161 @@
1
+ """Pure harness domain logic: input types, risk lanes, trace tiers.
2
+
3
+ Ported from repository-harness (docs/FEATURE_INTAKE.md + crates/harness-cli/src/domain.rs).
4
+ No DB, no I/O - just rules, so it is trivially testable and reusable by both backends.
5
+ """
6
+
7
+ import re
8
+
9
+ # ---- input types (where work lands) ----
10
+ INPUT_TYPES = (
11
+ "new_spec", "spec_slice", "change_request",
12
+ "new_initiative", "maintenance", "harness_improvement",
13
+ )
14
+
15
+ # ---- risk lanes ----
16
+ LANES = ("tiny", "normal", "high_risk")
17
+ LANE_REQUIRED_TIER = {"tiny": 1, "normal": 2, "high_risk": 3}
18
+
19
+ # ---- risk checklist flags (FEATURE_INTAKE.md) ----
20
+ RISK_FLAGS = (
21
+ "auth", "authorization", "data_model", "audit_security",
22
+ "external_systems", "public_contracts", "cross_platform",
23
+ "existing_behavior", "weak_proof", "multi_domain",
24
+ )
25
+
26
+ # Hard gates: any of these forces high_risk unless the human explicitly narrows scope.
27
+ HARD_GATE_FLAGS = (
28
+ "auth", "authorization", "data_model", "audit_security",
29
+ "external_systems", # external provider behavior
30
+ # "removing validation" is a process flag the caller signals via --removing-validation
31
+ )
32
+
33
+ TRACE_OUTCOMES = ("completed", "blocked", "partial", "failed")
34
+ STORY_STATUSES = ("planned", "in_progress", "implemented", "changed", "retired")
35
+ DECISION_STATUSES = ("proposed", "accepted", "superseded", "rejected")
36
+ BACKLOG_STATUSES = ("proposed", "accepted", "implemented", "rejected")
37
+ INTERVENTION_TYPES = ("correction", "override", "escalation", "approval")
38
+ INTERVENTION_SOURCES = ("human", "reviewer", "ci", "agent")
39
+
40
+ # ---- inbound tool registry (kind-aware), ported from repository-harness domain.rs ----
41
+ # Kinds make the registry agent-generic: each runtime uses what it can orchestrate, and an
42
+ # absent tool is a clean skip (presence is probed in _presence.py), never a failure.
43
+ TOOL_KINDS = ("cli", "binary", "mcp", "skill", "http")
44
+ TOOL_STATUSES = ("present", "missing", "unknown")
45
+
46
+ # Fixed Runtime-Substrate responsibility vocabulary (the 11 from repository-harness). A tool is
47
+ # registered as a provider of one responsibility so steps reason about purpose, not brand names.
48
+ TOOL_RESPONSIBILITIES = (
49
+ "Task specification", "Context selection", "Tool access", "Project memory",
50
+ "Task state", "Observability", "Failure attribution", "Verification",
51
+ "Permissions", "Entropy auditing", "Intervention recording",
52
+ )
53
+
54
+
55
+ def normalize_capability(cap):
56
+ """Lowercase + kebab-case a capability label; '' if empty.
57
+
58
+ A capability is a workflow purpose ('edge-case-expansion'), not a tool name — so a step can
59
+ ask 'what is equipped for X' instead of pinning a brand. Non-alphanumeric runs collapse to a
60
+ single hyphen; leading/trailing hyphens are stripped. Idempotent."""
61
+ if not cap:
62
+ return ""
63
+ return re.sub(r"[^a-z0-9]+", "-", str(cap).strip().lower()).strip("-")
64
+
65
+
66
+ def normalize_flags(flags):
67
+ """Accept a list/comma-string of flags; return validated lowercase tuple."""
68
+ if not flags:
69
+ return ()
70
+ if isinstance(flags, str):
71
+ flags = [f.strip() for f in flags.replace(";", ",").split(",")]
72
+ out = []
73
+ for f in flags:
74
+ f = f.strip().lower().replace("-", "_").replace("/", "_")
75
+ if f and f in RISK_FLAGS and f not in out:
76
+ out.append(f)
77
+ return tuple(out)
78
+
79
+
80
+ def classify_lane(flags, removing_validation=False, code_impact_high=False):
81
+ """Return (lane, reason) from the FEATURE_INTAKE.md classification rules.
82
+
83
+ 0-1 flags -> tiny|normal (normal unless caller knows it is trivial)
84
+ 2-3 flags -> normal (stronger validation)
85
+ 4+ flags -> high_risk
86
+ any hard gate (or removing validation) -> high_risk
87
+ """
88
+ flags = normalize_flags(flags)
89
+ hard = [f for f in flags if f in HARD_GATE_FLAGS]
90
+ if removing_validation:
91
+ hard = hard + ["removing_validation"]
92
+ if hard:
93
+ return "high_risk", "hard gate(s): " + ", ".join(hard)
94
+ n = len(flags)
95
+ if n >= 4:
96
+ return "high_risk", f"{n} risk flags (4+ -> high_risk)"
97
+ if n >= 2:
98
+ return "normal", f"{n} risk flags (2-3 -> normal, stronger validation)"
99
+ if code_impact_high:
100
+ return "normal", "0-1 flags but non-trivial code impact"
101
+ return "normal", f"{n} risk flag(s) (default normal; pass --lane tiny for trivial docs/copy)"
102
+
103
+
104
+ def lane_downgrade_allowed(recommended, requested, narrowed):
105
+ """A hard-gate high_risk recommendation may only be lowered if the operator narrows scope."""
106
+ if requested is None or requested == recommended:
107
+ return True, ""
108
+ rank = {"tiny": 0, "normal": 1, "high_risk": 2}
109
+ if rank.get(requested, 1) < rank.get(recommended, 1):
110
+ if recommended == "high_risk" and not narrowed:
111
+ return False, ("refusing to downgrade a hard-gate high_risk lane to "
112
+ f"'{requested}' without --narrow-scope (operator must accept the exposure)")
113
+ return True, ""
114
+
115
+
116
+ def score_trace(rec):
117
+ """Return (achieved_tier, missing_for_next) for a trace record dict.
118
+
119
+ tier 1 (minimal): task_summary >=10 chars AND outcome present.
120
+ tier 2 (standard): + intake_id, story_id, agent, actions_taken, files_read,
121
+ files_changed, AND at least one of errors / harness_friction.
122
+ tier 3 (detailed): + decisions_made, errors, harness_friction, AND one of
123
+ duration_seconds / token_estimate / notes.
124
+ """
125
+ def has(k):
126
+ v = rec.get(k)
127
+ return v is not None and str(v).strip() != "" and str(v).strip() not in ("[]", "null")
128
+
129
+ # tier 1
130
+ t1_missing = []
131
+ if not (has("task_summary") and len(str(rec.get("task_summary", "")).strip()) >= 10):
132
+ t1_missing.append("task_summary>=10chars")
133
+ if not has("outcome"):
134
+ t1_missing.append("outcome")
135
+ if t1_missing:
136
+ return 0, t1_missing # below minimal
137
+
138
+ # tier 2
139
+ t2_need = ["intake_id", "story_id", "agent", "actions_taken", "files_read", "files_changed"]
140
+ t2_missing = [k for k in t2_need if not has(k)]
141
+ if not (has("errors") or has("harness_friction")):
142
+ t2_missing.append("errors|harness_friction")
143
+ if t2_missing:
144
+ return 1, t2_missing
145
+
146
+ # tier 3
147
+ t3_need = ["decisions_made", "errors", "harness_friction"]
148
+ t3_missing = [k for k in t3_need if not has(k)]
149
+ if not (has("duration_seconds") or has("token_estimate") or has("notes")):
150
+ t3_missing.append("duration_seconds|token_estimate|notes")
151
+ if t3_missing:
152
+ return 2, t3_missing
153
+
154
+ return 3, []
155
+
156
+
157
+ def tier_verdict(lane, achieved):
158
+ """Human verdict comparing achieved trace tier against what the lane requires."""
159
+ required = LANE_REQUIRED_TIER.get(lane, 2)
160
+ ok = achieved >= required
161
+ return ok, required