@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.
- package/CHANGELOG.md +60 -0
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/README_VN.md +134 -0
- package/SECURITY.md +69 -0
- package/bin/cli.mjs +382 -0
- package/package.json +52 -0
- package/skills/flow/SKILL.md +234 -0
- package/skills/flow/_templates/00-idea.md +16 -0
- package/skills/flow/_templates/00-inspect.md +42 -0
- package/skills/flow/_templates/01-research.md +59 -0
- package/skills/flow/_templates/02-scope.md +71 -0
- package/skills/flow/_templates/03-prd.md +57 -0
- package/skills/flow/_templates/04-adr.md +21 -0
- package/skills/flow/_templates/05-contract.md +59 -0
- package/skills/flow/_templates/card.md +26 -0
- package/skills/flow/_templates/constitution.md +22 -0
- package/skills/flow/eval/fixtures/f01a/flow/01-research.md +77 -0
- package/skills/flow/eval/fixtures/f01b/flow/01-research.md +75 -0
- package/skills/flow/eval/fixtures/f02a/flow/02-scope.md +56 -0
- package/skills/flow/eval/fixtures/f02b/flow/02-scope.md +65 -0
- package/skills/flow/eval/fixtures/fcda/.flow/.gitkeep +0 -0
- package/skills/flow/eval/fixtures/fcda/cards/C-001.md +46 -0
- package/skills/flow/eval/fixtures/fcdb/.flow/.gitkeep +0 -0
- package/skills/flow/eval/fixtures/fcdb/cards/C-001.md +35 -0
- package/skills/flow/eval/manifest.tsv +7 -0
- package/skills/flow/harness/README.md +117 -0
- package/skills/flow/harness/_db.py +228 -0
- package/skills/flow/harness/_domain.py +161 -0
- package/skills/flow/harness/_presence.py +109 -0
- package/skills/flow/harness/flow_harness.py +1103 -0
- package/skills/flow/harness/repo_map.py +93 -0
- package/skills/flow/harness/schema/001-init.sql +129 -0
- package/skills/flow/harness/schema/002-story-verify.sql +9 -0
- package/skills/flow/harness/schema/003-tool-registry.sql +15 -0
- package/skills/flow/harness/schema/004-intervention.sql +15 -0
- package/skills/flow/harness/schema/005-tool-extensions.sql +20 -0
- package/skills/flow/harness/schema/009-accessed-count.sql +11 -0
- package/skills/flow/harness/schema/010-usage-event.sql +42 -0
- package/skills/flow/harness/schema/011-usage-gate-reason.sql +8 -0
- package/skills/flow/harness/schema/012-usage-ephemeral.sql +10 -0
- package/skills/flow/law/CLAUDE.md +140 -0
- package/skills/flow/law/DESIGN.md +141 -0
- package/skills/flow/law/RETRO.md +13 -0
- package/skills/flow/playbooks/README.md +28 -0
- package/skills/flow/playbooks/cloudflare-ai-gateway-qwen.md +346 -0
- package/skills/flow/playbooks/docker-deploy-stale-cache.md +32 -0
- package/skills/flow/playbooks/rag-lite-postgres-cloudflare.md +192 -0
- package/skills/flow/playbooks/windows-gitbash-jsonl-telemetry.md +57 -0
- package/skills/flow/references/adversarial-review.md +197 -0
- package/skills/flow/references/agent-detection.md +88 -0
- package/skills/flow/references/agent-stage-mapping.md +114 -0
- package/skills/flow/references/antigravity-integration.md +92 -0
- package/skills/flow/references/auto-run.md +81 -0
- package/skills/flow/references/claudekit-skills.md +128 -0
- package/skills/flow/references/codex-integration.md +104 -0
- package/skills/flow/references/command-dispatch.md +54 -0
- package/skills/flow/references/debt-and-halts.md +41 -0
- package/skills/flow/references/design-review-checklist.md +42 -0
- package/skills/flow/references/gate-eval.md +186 -0
- package/skills/flow/references/gate-rules.md +171 -0
- package/skills/flow/references/ground-truth-gates.md +49 -0
- package/skills/flow/references/loop-harness-2026-principles.md +52 -0
- package/skills/flow/references/mode-work.md +37 -0
- package/skills/flow/references/project-types.md +42 -0
- package/skills/flow/references/stage-state-machine.md +47 -0
- package/skills/flow/references/ui-patterns-tcr.md +49 -0
- package/skills/flow/runner/flow.cmd +18 -0
- package/skills/flow/runner/flow.sh +3297 -0
- package/skills-manifest.json +70 -0
- package/src/constants.mjs +65 -0
- package/src/detect.mjs +36 -0
- package/src/help.mjs +42 -0
- package/src/installer.mjs +231 -0
- package/src/prompt.mjs +60 -0
- 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,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
|