@chrono-meta/fh-gate 3.0.0 → 3.1.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 (111) hide show
  1. package/.claude/regression/probes_live.yaml +137 -0
  2. package/.claude/rules/.residency-patterns.defaults +7 -0
  3. package/.claude/rules/fh_4axis_gate.md +50 -1
  4. package/.claude-plugin/marketplace.json +8 -2
  5. package/AGENTS.md +27 -0
  6. package/CATALOG.md +17 -0
  7. package/CLAUDE.md +12 -2
  8. package/README.ja.md +51 -7
  9. package/README.ko.md +48 -7
  10. package/README.md +37 -5
  11. package/README.zh.md +45 -8
  12. package/docs/STANDARDS_ALIGNMENT.md +61 -0
  13. package/docs/USER_GUIDE.md +3 -0
  14. package/docs/USE_CASES.md +50 -0
  15. package/docs/model_tier_expectations.md +60 -0
  16. package/knowledge/shared/harness-core/fh_three_layer_canon.md +30 -10
  17. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +8 -0
  18. package/knowledge/shared/harness-core/harness_incubator_doctrine.md +9 -0
  19. package/knowledge/shared/harness-core/iso_ai_standards_crosswalk.md +139 -0
  20. package/knowledge/shared/harness-core/measurement-integrity-checklist.md +20 -0
  21. package/knowledge/shared/learnings/subagent_invocations_log.yaml +451 -4
  22. package/package.json +40 -2
  23. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  24. package/plugins/fh-commons/skills/preprep/README.md +4 -1
  25. package/plugins/fh-commons/skills/preprep/SKILL.md +99 -3
  26. package/plugins/fh-commons/skills/preprep/diagram_from_json.py +154 -0
  27. package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_negative.pptx +0 -0
  28. package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_positive.pptx +0 -0
  29. package/plugins/fh-commons/skills/preprep/fixtures/mk_slide_fixtures.py +179 -0
  30. package/plugins/fh-commons/skills/preprep/interslide_deps.py +98 -9
  31. package/plugins/fh-commons/skills/preprep/lane_adjacent_dup.py +4 -1
  32. package/plugins/fh-commons/skills/preprep/lane_diagram.py +95 -0
  33. package/plugins/fh-commons/skills/preprep/lane_geometry.py +181 -0
  34. package/plugins/fh-commons/skills/preprep/lane_promise.py +4 -1
  35. package/plugins/fh-commons/skills/preprep/lane_slide_refs.py +134 -0
  36. package/plugins/fh-commons/skills/preprep/lane_slide_relations.py +300 -0
  37. package/plugins/fh-commons/skills/preprep/preprep.py +129 -8
  38. package/plugins/fh-commons/skills/preprep/preprep_wire.py +210 -0
  39. package/plugins/fh-commons/skills/preprep/presentation_checklist.md +4 -0
  40. package/plugins/fh-commons/skills/preprep/surfaces.example.yaml +18 -0
  41. package/plugins/fh-commons/skills/preprep/test_preprep_lanes_rp.py +184 -0
  42. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  43. package/plugins/fh-meta/CHANGELOG.md +76 -1
  44. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +87 -2
  45. package/plugins/fh-meta/skills/frontier-digest/SKILL.md +2 -2
  46. package/plugins/fh-meta/skills/frontier-digest/SKILL_detail.md +25 -5
  47. package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL.md +11 -0
  48. package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL_detail.md +3 -0
  49. package/plugins/fh-qp/.claude-plugin/plugin.json +22 -0
  50. package/plugins/fh-qp/README.md +71 -0
  51. package/plugins/fh-qp/fixtures/evidence_known_clean.txt +3 -0
  52. package/plugins/fh-qp/fixtures/evidence_known_dirty.txt +6 -0
  53. package/plugins/fh-qp/fixtures/reach_known_partial.tsv +4 -0
  54. package/plugins/fh-qp/fixtures/reach_known_reached.tsv +4 -0
  55. package/plugins/fh-qp/fixtures/reach_known_wall.tsv +3 -0
  56. package/plugins/fh-qp/fixtures/verdicts_known_bad_branch.tsv +2 -0
  57. package/plugins/fh-qp/fixtures/verdicts_known_bad_vacuous_machine.tsv +2 -0
  58. package/plugins/fh-qp/fixtures/verdicts_known_good.tsv +4 -0
  59. package/plugins/fh-qp/fixtures/verdicts_verify_only.tsv +3 -0
  60. package/plugins/fh-qp/qp_profile.example.yaml +29 -0
  61. package/plugins/fh-qp/scripts/qp_tools.sh +216 -0
  62. package/plugins/fh-qp/skills/qp/SKILL.md +65 -0
  63. package/plugins/fh-qp/skills/qp-plan/SKILL.md +42 -0
  64. package/plugins/fh-qp/skills/qp-regress/SKILL.md +45 -0
  65. package/plugins/fh-qp/skills/qp-run/SKILL.md +49 -0
  66. package/scripts/chamber_run.sh +14 -5
  67. package/scripts/com.forge-harness.live-eval.plist +84 -0
  68. package/scripts/compaction_probe.sh +9 -35
  69. package/scripts/directional_diff_gate.sh +14 -2
  70. package/scripts/frontier_digest_autopilot.sh +4 -1
  71. package/scripts/map_postprocess.py +90 -0
  72. package/scripts/outbound_query_guard.sh +131 -0
  73. package/scripts/outbound_query_hook.sh +373 -0
  74. package/scripts/package_coverage_check.sh +55 -16
  75. package/scripts/pipe_verdict_guard.sh +41 -1
  76. package/scripts/probe_live_eval.sh +240 -0
  77. package/scripts/probe_live_eval_lib.py +579 -0
  78. package/scripts/proposal_hook.sh +120 -17
  79. package/scripts/push_zone_check.sh +78 -0
  80. package/scripts/residency_closure_scan.py +252 -0
  81. package/scripts/selfcheck.sh +41 -1
  82. package/scripts/session_close_check.sh +100 -0
  83. package/scripts/sim_isolated_run.sh +98 -2
  84. package/scripts/test_action_yml_lanes.sh +160 -0
  85. package/scripts/test_fh_qp_lanes.sh +105 -0
  86. package/scripts/test_gate_two_verdicts_lanes.sh +139 -0
  87. package/scripts/test_map_postprocess_lanes.sh +143 -0
  88. package/scripts/test_marker_affected_lanes.sh +93 -0
  89. package/scripts/test_marker_crossfamily_lanes.sh +90 -6
  90. package/scripts/test_marker_oracle_lanes.sh +136 -0
  91. package/scripts/test_outbound_query_hook_lanes.sh +433 -0
  92. package/scripts/test_outbound_query_lanes.sh +87 -0
  93. package/scripts/test_pipe_verdict_guard_lanes.sh +26 -0
  94. package/scripts/test_preprep_diagram_lanes.sh +87 -0
  95. package/scripts/test_preprep_drift_anchor.sh +3 -3
  96. package/scripts/test_preprep_slide_refs_lanes.sh +169 -0
  97. package/scripts/test_probe_live_eval_lanes.sh +437 -0
  98. package/scripts/test_proposal_hook_lanes.sh +22 -1
  99. package/scripts/test_push_zone_lanes.sh +304 -0
  100. package/scripts/test_residency_closure_lanes.sh +70 -0
  101. package/scripts/test_sim_isolated_run_lanes.sh +119 -0
  102. package/scripts/test_utterance_intake_lanes.sh +414 -0
  103. package/scripts/test_worktree_reclaim_lanes.sh +70 -0
  104. package/scripts/transcript_utterances.py +222 -0
  105. package/scripts/utterance_intake.sh +424 -0
  106. package/scripts/validate_yaml.sh +27 -0
  107. package/scripts/worktree_reclaim.sh +95 -0
  108. package/templates/.git-hooks/pre-commit +353 -1
  109. package/templates/.git-hooks/pre-push +91 -0
  110. package/templates/RED_TEAM_REPORT.md +49 -0
  111. package/templates/settings.PreToolUse.snippet.json +65 -1
@@ -0,0 +1,65 @@
1
+ ---
2
+ name: qp
3
+ description: QP (Quality Platform) router — takes a web URL or a desktop app name and runs the plan → run → regress loop (qp-plan, qp-run, qp-regress) end to end, or routes to one stage. Classifies the target (public vs profile-required), probes which adapter the session has (Playwright MCP for web, computer-use MCP for desktop), and writes every artifact with a typed engine/adapter marker. Triggers on "test this website for me", "run a QA pass on this app", "check the app end to end", "이 사이트 QA 돌려줘", "이 앱 테스트해줘", "회귀 돌려줘".
4
+ user-invocable: true
5
+ allowed-tools: ["Read", "Bash", "Write", "Glob", "Grep"]
6
+ model: sonnet
7
+ origin: chamber run fh-qa-generic (2026-09-05) — generic edition of a field QA harness's P·A·R loop; zero domain constants
8
+ ---
9
+
10
+ # qp — Quality Platform router
11
+
12
+ One entry point for people who want to **dynamically test a web or desktop app with a QA lens from inside Claude Code**.
13
+ Three stages, each its own skill, each leaving files:
14
+
15
+ | stage | skill | you get |
16
+ |---|---|---|
17
+ | **P**lan | `qp-plan` | `qp/plan/inventory.md` (surfaces the app exposes) + `qp/plan/tcs.tsv` (test cases) |
18
+ | **A**utomation (run) | `qp-run` | `qp/run/<ts>/verdicts.tsv` + masked evidence (DOM snapshots or screenshots) |
19
+ | **R**egression | `qp-regress` | `qp/regress/<ts>/surface_reach.txt` + per-TC delta vs the previous run |
20
+
21
+ Glossary (one line each, because a first-time reader asked): **TC** = test case · **inventory** = the list of screens/routes/menus the app exposes · **MTM** = each finding is labeled *as planned* / *differs from the plan document* / *code differs* · **precondition** = what must already be true before a step runs (logged in, page loaded), checked not assumed — a failure before it is met is `BLOCKED`, not `FAIL` · **surface_reach** = how many TCs actually left the entry screen · **engine** = which machinery drove the run (see §Engine).
22
+
23
+ ## Step 0 — classify the target (mechanical, never by eye)
24
+
25
+ ```
26
+ bash plugins/fh-qp/scripts/qp_tools.sh target-class <https://… | app:NAME> [--profile <file>]
27
+ ```
28
+ | result | rc | what you do |
29
+ |---|---|---|
30
+ | `PUBLIC` | 0 | proceed |
31
+ | `PROFILE_OK` | 0 | proceed with the profile |
32
+ | `PROFILE_REQUIRED` | 4 | **stop.** Say: *"this target is not public; QP needs a profile file — copy `plugins/fh-qp/qp_profile.example.yaml` to a gitignored path, fill the host/app, and re-run with `--profile`."* Do not improvise a run against a private host. |
33
+ | `UNKNOWN` | 10 | stop, report the target form QP accepts |
34
+
35
+ ## Step 1 — probe the adapter (mechanical; unknown ≠ present)
36
+
37
+ List the MCP tool names this session actually has (from your tool list — do not guess), then:
38
+ ```
39
+ bash plugins/fh-qp/scripts/qp_tools.sh adapter-probe --need web|desktop --tools "<comma-separated tool names>"
40
+ ```
41
+ `ADAPTER=… evidence=dom|pixel` → proceed and **write that line into every artifact header**.
42
+ `HARNESS_ERROR` (rc 10) → **stop.** Write `qp/HARNESS_ERROR.txt` with the reason and tell the user which MCP to connect (Playwright MCP for web · computer-use MCP for desktop). This is not a pass and not a skip.
43
+
44
+ ## Engine — typed capability vs MCP fallback (form C)
45
+
46
+ - If a QA harness has **registered** a typed capability whose id ends in `:par` (check `.claude/capabilities/**/*.cap` and `bash scripts/cluster_capability_scan.sh` when in an FH hub), call its `entry` and merge constraints **strictest-wins** (`capability_composition_contract.md`). Engine marker: `engine=capability:<id>`.
47
+ - Otherwise drive the target yourself through the adapter from Step 1. Engine marker: `engine=mcp-fallback adapter=<name> evidence=dom|pixel`.
48
+ - **As of 2026-09-05 no such capability is registered anywhere** — the fallback is the only path that exists. If the user explicitly asks for the capability engine (`--engine capability`) and none is registered → `HARNESS_ERROR reason=capability-not-registered`, never a silent fallback.
49
+ - Desktop (`evidence=pixel`) is the **weaker edition**: no DOM, so `verify` closes only by screenshot text you can read, and surface_reach hashes screenshots, not DOM. Say so in the report header.
50
+
51
+ ## Step 2 — run the stages
52
+
53
+ `qp <target>` = plan → run → regress in order, stopping at the first `HARNESS_ERROR`. `qp plan|run|regress <target>` = one stage. Each stage skill carries its own Done When.
54
+
55
+ ## Done When
56
+ 1. Target classified with a `target-class` line recorded in `qp/plan/inventory.md` header · **[mandatory-pass]** — rc is the check.
57
+ 2. Adapter probed and `ADAPTER=` or `HARNESS_ERROR` written to a file · **[mandatory-pass]** — file exists and matches the probe output.
58
+ 3. Every artifact header carries `engine=` and `evidence=` · **[measured]** — `grep -L 'engine=' qp/**/*.md qp/**/*.tsv` is empty.
59
+ 4. Stages chained in P→A→R order with no stage skipped silently · **[judged — pairing: the lane `scripts/test_fh_qp_lanes.sh` and the per-stage Done When files]**.
60
+
61
+ ## Independently executable
62
+ Needs only `bash` + the session's MCP adapter. No other FH skill is required. Inside an FH hub the capability scan is optional.
63
+
64
+ ## Not this skill
65
+ Mobile (deferred). Verifying FH itself (use FH's own gates). Fixing the app.
@@ -0,0 +1,42 @@
1
+ ---
2
+ name: qp-plan
3
+ description: Prepare stage of QP — builds a surface inventory of a web page or desktop app (routes, menus, forms, dialogs), checks it is MECE (no overlap, nothing missing that is visible), and designs test cases as a TSV with preconditions and expected values whose SOURCE is named. Works without a spec document (then expected values come from the inventory, not a plan). Triggers on "what should I test on this page", "make a test plan for this app", "list the screens of this app", "테스트 케이스 뽑아줘", "이 화면 인벤토리 만들어줘".
4
+ user-invocable: true
5
+ allowed-tools: ["Read", "Bash", "Write", "Glob", "Grep"]
6
+ model: sonnet
7
+ origin: chamber run fh-qa-generic (2026-09-05)
8
+ ---
9
+
10
+ # qp-plan — Prepare (inventory → MECE → TCs)
11
+
12
+ Input: a target that passed `qp_tools.sh target-class` (PUBLIC or PROFILE_OK) and an adapter line from `adapter-probe`. Optional: a plan/spec document path (`--plan <file>`). Optional profile.
13
+
14
+ ## 1. Surface inventory — observe, do not guess
15
+ Open the entry route with the adapter (web: navigate + accessibility snapshot; desktop: screenshot). Record every **navigable surface** you can see: links/routes, menu items, buttons that open a dialog, forms, tabs. One row each:
16
+
17
+ ```
18
+ | id | kind (route·menu·dialog·form·tab) | label as shown | how reached (from which surface, which action) | observed? (yes/no) |
19
+ ```
20
+ Rules: ⓐ **do not invent routes** — a surface is listed only if you saw its trigger; ⓑ parameterised routes (`/item/:id`) are listed as `ROUTE_PARAMETERIZED`, not filled with a made-up id; ⓒ if the entry route did not load → stop with `HARNESS_ERROR reason=entry-unreachable`. Write `qp/plan/inventory.md` with the header lines `target-class: …` (the tool's literal output), `ADAPTER=… evidence=dom|pixel` (the probe's literal output), `engine=mcp-fallback` (or `engine=capability:<id>` only if the `qp` router found a registered capability — never a browser name; a floor-tier sim wrote `engine=chromium` when this line did not say so, 2026-09-05), `plan_doc: <path|NONE>`.
21
+
22
+ ## 2. MECE check
23
+ Walk the inventory once more against the snapshot: any visible navigable element not in the list → add it (**missing**); any two rows reaching the same surface → merge (**overlap**). Write the count of each fix under `## MECE` in inventory.md. Zero fixes is a legitimate value; an unstated count is not.
24
+
25
+ ## 3. Test-case design — `qp/plan/tcs.tsv`
26
+ Tab-separated, header row:
27
+ ```
28
+ tc_id surface_id precondition step_verb step_target expected expected_source priority
29
+ ```
30
+ - `step_verb` ∈ `navigate click input verify` (one verb per row; a multi-step TC is several rows sharing `tc_id`).
31
+ - `expected_source` ∈ `PLAN_DOC INVENTORY CODE HUMAN` — **where the expected value came from**. With no plan document every row is `INVENTORY` (or `HUMAN` if the user told you). This field is what lets `qp-run` label a finding *differs from plan* vs *code differs*; a TC without it cannot be run.
32
+ - `precondition` is what must be true before step 1 (e.g. `at:/`, `logged-in`). Write `none` explicitly when there is none.
33
+ - **At least one TC must carry a state-changing verb (`click` or `input`)** — a verify-only plan never exercises the runner (`qp_tools.sh run-verbs` enforces this on the run output).
34
+ - Minimum: inventory ≥ 5 surfaces, TCs ≥ 3 for a first pass; say when the target is too small to reach that (a one-page site can be).
35
+
36
+ ## Done When
37
+ 1. `qp/plan/inventory.md` exists with the four header lines and ≥1 observed surface · **[mandatory-pass]**.
38
+ 2. `qp/plan/tcs.tsv` parses: every row has 8 fields, `step_verb` and `expected_source` in their enums, ≥1 click/input row · **[measured]** — `awk -F'\t' 'NF!=8' tcs.tsv` prints nothing; `run-verbs` on the later verdicts is OK.
39
+ 3. No surface in the inventory was invented (each has `observed? yes` or a `ROUTE_PARAMETERIZED` mark) · **[judged — pairing: a second session re-opens the entry route and diffs the inventory; the lane's residency/zero-constant grep on the plan files]**.
40
+
41
+ ## Independently executable
42
+ Yes — bash + adapter. Does not need `qp-run`.
@@ -0,0 +1,45 @@
1
+ ---
2
+ name: qp-regress
3
+ description: Regression stage of QP — re-executes a previous run's test cases unchanged, computes surface_reach (how many TCs left the entry screen, as a ratio over ALL TCs), and reports the per-TC delta against the earlier run so a "no change" claim is per case, not an aggregate. Triggers on "did anything regress since last run", "re-run last week's QA pass", "compare this run with the previous one", "회귀 확인해줘", "지난 런이랑 비교해줘".
4
+ user-invocable: true
5
+ allowed-tools: ["Read", "Bash", "Write", "Glob", "Grep"]
6
+ model: sonnet
7
+ origin: chamber run fh-qa-generic (2026-09-05)
8
+ ---
9
+
10
+ # qp-regress — Regression (re-run unchanged → surface_reach → delta)
11
+
12
+ Input: a baseline `qp/run/<ts>/` and the same `qp/plan/tcs.tsv`. Output: `qp/regress/<ts>/`.
13
+
14
+ ## 1. Re-run unchanged
15
+ Execute the baseline's TCs with `qp-run` **without editing them**. Editing a TC to make it pass is a new run, not a regression — say so if the user asks for it.
16
+
17
+ ## 2. surface_reach — did the batch leave the entry screen?
18
+ ```
19
+ bash plugins/fh-qp/scripts/qp_tools.sh surface-reach qp/run/<new-ts>/evidence.tsv
20
+ ```
21
+ | result | meaning |
22
+ |---|---|
23
+ | `REACHED n/n` | every TC observed a screen other than the modal (entry) one |
24
+ | `PARTIAL k/n` | some did — report **which** did not, by tc_id |
25
+ | `NOT_REACHED 0/n` | the whole batch sat on one screen (a login wall, a dead env). Every FAIL in that run is **unmeasured**, not a detection — write that sentence in the report |
26
+ | `UNMEASURED` (rc 10) | no evidence file / empty — not a zero |
27
+ Denominator is **all TCs**, including ones that never executed; write `surface_reach.txt` with the raw line.
28
+
29
+ ## 3. Per-TC delta — `delta.tsv`
30
+ ```
31
+ tc_id baseline_status new_status changed note
32
+ ```
33
+ One row per TC. `changed=yes` on any status change **or** on a same-status verdict whose `closure` moved from MACHINE to JUDGMENT (the check got weaker). A run where every row is `changed=no` may say "no regression"; a run reported only as "12 PASS both times" may not — the aggregate hides a swap.
34
+
35
+ ## 4. Report — `report.md`
36
+ First 8 lines: target · engine/adapter/evidence · `#mtm:` state · surface_reach line · counts PASS/FAIL/BLOCKED/AMBIGUOUS new vs baseline · number of `changed=yes` · the sentence for NOT_REACHED if it applies · what was **not** measured (unexecuted TCs, dropped screenshots).
37
+
38
+ ## Done When
39
+ 1. `surface_reach.txt` holds a `REACHED|PARTIAL|NOT_REACHED|UNMEASURED` line produced by the tool · **[mandatory-pass]**.
40
+ 2. `delta.tsv` has one row per TC in the baseline · **[measured]** — row count equals distinct tc_ids.
41
+ 3. A NOT_REACHED run's report contains the "FAILs are unmeasured" sentence · **[measured]** — grep.
42
+ 4. The delta's `changed` column reflects real behavior change, not selector drift · **[judged — pairing: for each `changed=yes`, the before/after DOM snapshots are attached and a second session confirms the element moved, not the locator]**.
43
+
44
+ ## Independently executable
45
+ Yes — needs a baseline run dir in the documented shape and bash.
@@ -0,0 +1,49 @@
1
+ ---
2
+ name: qp-run
3
+ description: Automation stage of QP — executes the test cases from qp-plan against the live app through the session's adapter (Playwright MCP for web, computer-use MCP for desktop), records one verdict per step with a closed status, an MTM branch, whether the step was closed by machine evidence or left to judgment, and masks emails/tokens/passwords out of the evidence before saving. Triggers on "run these test cases", "execute the QA plan against the site", "drive the app through the TCs", "TC 실행해줘", "플랜대로 돌려줘".
4
+ user-invocable: true
5
+ allowed-tools: ["Read", "Bash", "Write", "Glob", "Grep"]
6
+ model: sonnet
7
+ origin: chamber run fh-qa-generic (2026-09-05)
8
+ ---
9
+
10
+ # qp-run — Automation (execute → verdict → mask)
11
+
12
+ Input: `qp/plan/tcs.tsv` + the adapter/engine lines from `qp/plan/inventory.md`. Output dir: `qp/run/<YYYYMMDD-HHMM>/`.
13
+
14
+ ## 0. MTM state — decide once, write it first
15
+ `#mtm:` is the first line of `verdicts.tsv`:
16
+ | value | when |
17
+ |---|---|
18
+ | `ACTIVE` | a plan document was given **and** you opened it (path recorded) |
19
+ | `UNAVAILABLE` | no plan document — expected values come from the inventory; the branch *differs from plan* is **unreachable** and the vocabulary collapses to AS_PLANNED / CODE_DIFFERS |
20
+ | `FAILED` | a plan document was given but could not be read — say so; do not fall back silently |
21
+ | `DISABLED` | the user turned it off (reason recorded) |
22
+ `UNAVAILABLE` and `FAILED` are different events; never merge them.
23
+
24
+ ## 1. Precondition first — attribution, not blocking
25
+ Before step 1 of each TC, establish its `precondition`. Navigation you can do (`at:/about`) you do; state you cannot create (`logged-in`, `has-an-order`) you mark `UNMET_UNEXECUTABLE`. A step-1 failure under an unmet precondition is **`BLOCKED`**, not `FAIL` — you never reached the screen, so it is not a claim about the app. Only step 1 is re-attributed; later steps failed after arrival.
26
+
27
+ ## 2. Execute each step, observe before and after
28
+ web: accessibility snapshot before → action → snapshot after. desktop: screenshot before/after. Save each observation as `<tc_id>_step<N>_{before,after}.{md,png}` and append its stable id from `bash plugins/fh-qp/scripts/qp_tools.sh screen-id <file>` (a content hash — Playwright MCP ref tokens change every navigation, so a raw file hash is NOT stable; measured 2026-09-05) to `evidence.tsv` (`tc_id<TAB>step<TAB>screen_hash`) — this file is what `qp-regress` reads.
29
+
30
+ ## 3. Verdict row per step — `verdicts.tsv` (7 tab-separated fields)
31
+ ```
32
+ tc_id status branch closure verb assertion expected_source
33
+ ```
34
+ - `status` ∈ `PASS FAIL BLOCKED AMBIGUOUS` (AMBIGUOUS = could not map the step to one element: 0 candidates or a tie).
35
+ - `branch` ∈ `AS_PLANNED DIFFERS_FROM_PLAN CODE_DIFFERS NONE`. `DIFFERS_FROM_PLAN` only under `#mtm: ACTIVE` and only with `expected_source=PLAN_DOC`. `CODE_DIFFERS` needs `expected_source` `CODE` or `PLAN_DOC`. `NONE` only for BLOCKED/AMBIGUOUS.
36
+ - `closure` = `MACHINE` **only if** `assertion` names the check that closed it (`text-visible:About`, `url==…`, `element-count==3`). An empty assertion with `MACHINE` is invalid — that is the muscle-not-skeleton hole this field exists to close. Everything you decided by reading a screenshot is `JUDGMENT`.
37
+ - Validate before you finish: `bash plugins/fh-qp/scripts/qp_tools.sh mtm-check verdicts.tsv` must print `OK`; `run-verbs verdicts.tsv` must print `OK` (≥1 click/input actually executed).
38
+
39
+ ## 4. Mask, then save — original 0
40
+ Every text artifact goes through `qp_tools.sh mask <in> <out>` and only `<out>` is kept; delete `<in>`. `RESIDUE` (rc 5) → do not keep the file; report which artifact. Typed secret values (from the profile's `*_env`) are read from the environment at type-time and never written. Screenshots that show a filled password field are kept only if the field is masked in the image (crop or overlay); otherwise drop the screenshot and say so.
41
+
42
+ ## Done When
43
+ 1. `verdicts.tsv` passes `mtm-check` and `run-verbs` · **[mandatory-pass]** — rc 0 both.
44
+ 2. `evidence.tsv` has ≥1 row per executed TC · **[measured]**.
45
+ 3. Masking: `grep -cE '@|Bearer|eyJ' <saved artifacts>` is 0 outside `__REDACTED_*__` markers · **[measured]** — the lane reproduces it on a known-dirty fixture.
46
+ 4. Every BLOCKED verdict names its unmet precondition; every JUDGMENT closure says what was read · **[judged — pairing: `qp-regress` re-runs the same TCs and flags verdicts that flip without a DOM change; a second session reviews BLOCKED rows against the before-snapshots]**.
47
+
48
+ ## Independently executable
49
+ Yes, given a `tcs.tsv` in the documented shape (hand-written is fine) and an adapter.
@@ -168,12 +168,17 @@ fi
168
168
  _stamp "step-4-done"; echo " ✓ step 4: $NPERS blind persona sections present (isolation-gate satisfied)"
169
169
  _witness_record "$WS/SIM_NOTES.md"
170
170
 
171
- # STEP 5 — Emission Gate. Require a VERDICT: EMIT | PARTIAL-EMIT | KILL.
171
+ # STEP 5 — Emission Gate. Require a VERDICT: EMIT | PARTIAL-EMIT | KILL | CURATED | NOT-APPLICABLE.
172
+ # 🟥 2026-09-04: the doctrine re-routed two outcomes on 2026-08-17 (§3-SCREEN-2026-08-17 — net-new
173
+ # shortfall → CURATED, judgment-shaped candidate → NOT-APPLICABLE) and this regex kept parsing the
174
+ # OLD vocabulary for 18 days; run #16 had to file «KILL + CURATED 재료 동봉» to get past it, and the
175
+ # pre-registered taxonomy known-pair (5 old runs → CURATE) could not be replayed at all. A runner that
176
+ # rejects the doctrine's own words is [[feedback_rule_misdescribes_its_own_machine]] in the code half.
172
177
  if [ ! -f "$WS/EMISSION_VERDICT.md" ]; then
173
178
  cat > "$WS/EMISSION_VERDICT.md" <<EOF
174
179
  # Emission Gate Verdict — $SLUG (chamber run)
175
180
 
176
- VERDICT: <EMIT | PARTIAL-EMIT | KILL>
181
+ VERDICT: <EMIT | PARTIAL-EMIT | KILL | CURATED | NOT-APPLICABLE>
177
182
 
178
183
  ## Judged: does the simulation hold? (+ mechanical anchor: overlap grep / gate verdicts / reproduced flows)
179
184
 
@@ -183,11 +188,11 @@ EOF
183
188
  echo " ⛔ step 5 BLOCKED: decide WITH the operator (HITL), record VERDICT in $WS/EMISSION_VERDICT.md, re-run."; exit 1
184
189
  fi
185
190
  # PARTIAL-EMIT listed FIRST in every alternation so it is never mis-extracted as its EMIT substring.
186
- VERDICT=$(grep -ioE '^VERDICT:[[:space:]]*(PARTIAL-EMIT|EMIT|KILL)' "$WS/EMISSION_VERDICT.md" 2>/dev/null | head -1 | grep -ioE 'PARTIAL-EMIT|EMIT|KILL' | head -1 | tr 'a-z' 'A-Z')
191
+ VERDICT=$(grep -ioE '^VERDICT:[[:space:]]*(PARTIAL-EMIT|EMIT|KILL|CURATED|NOT-APPLICABLE)' "$WS/EMISSION_VERDICT.md" 2>/dev/null | head -1 | grep -ioE 'PARTIAL-EMIT|EMIT|KILL|CURATED|NOT-APPLICABLE' | head -1 | tr 'a-z' 'A-Z')
187
192
  # a bare "## Verdict:" prose line (run #3 style) also counts if it names KILL/EMIT
188
- [ -z "$VERDICT" ] && VERDICT=$(grep -ioE 'VERDICT[: *]+\**(PARTIAL-EMIT|EMIT|KILL)' "$WS/EMISSION_VERDICT.md" 2>/dev/null | grep -ioE 'PARTIAL-EMIT|EMIT|KILL' | head -1 | tr 'a-z' 'A-Z')
193
+ [ -z "$VERDICT" ] && VERDICT=$(grep -ioE 'VERDICT[: *]+\**(PARTIAL-EMIT|EMIT|KILL|CURATED|NOT-APPLICABLE)' "$WS/EMISSION_VERDICT.md" 2>/dev/null | grep -ioE 'PARTIAL-EMIT|EMIT|KILL|CURATED|NOT-APPLICABLE' | head -1 | tr 'a-z' 'A-Z')
189
194
  if [ -z "$VERDICT" ]; then
190
- echo " ⛔ step 5 BLOCKED: no VERDICT (EMIT|PARTIAL-EMIT|KILL) found in $WS/EMISSION_VERDICT.md, re-run."; exit 1
195
+ echo " ⛔ step 5 BLOCKED: no VERDICT (EMIT|PARTIAL-EMIT|KILL|CURATED|NOT-APPLICABLE) found in $WS/EMISSION_VERDICT.md, re-run."; exit 1
191
196
  fi
192
197
  _stamp "step-5-done"; echo " ✓ step 5: Emission Gate verdict = $VERDICT"
193
198
  _witness_record "$WS/EMISSION_VERDICT.md"
@@ -269,6 +274,10 @@ case "$VERDICT" in
269
274
  echo " existing asset / the skeleton (no new asset). Workspace stays as evidence." ;;
270
275
  KILL) echo "TERMINUS (KILL): first-class success — a cheap run prevented a speculative/reinvention build."
271
276
  echo " No emit. Workspace stays as the evidence record; seen-filter will skip re-listing it." ;;
277
+ CURATED) echo "TERMINUS (CURATED): net-new shortfall is not a kill (§3-SCREEN-2026-08-17) — hand the maker the"
278
+ echo " prior-art list, the closest existing asset, and the delta it does not cover." ;;
279
+ NOT-APPLICABLE) echo "TERMINUS (NOT-APPLICABLE): judgment-shaped candidate — not this incubator's output form."
280
+ echo " Route to doctrine (a rule/lens), not to a build. Workspace stays as evidence." ;;
272
281
  esac
273
282
  echo "chamber run '$SLUG' COMPLETE (STATUS: step-7-done, verdict $VERDICT)."
274
283
  # EMIT/PARTIAL-EMIT 인데 순서 증인이 없으면 **비영 종료**한다. KILL 은 영향 없다 —
@@ -0,0 +1,84 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <!-- Replace every /path/to/... below with your own absolute paths before installing.
6
+ Follows the same template shape as scripts/com.forge-harness.daily-report.plist —
7
+ see that file's own comments for the general launchd-on-macOS caveats (sleep/wake
8
+ does not guarantee the exact minute, StandardOutPath dirs must pre-exist). -->
9
+ <key>Label</key>
10
+ <string>com.forge-harness.live-eval</string>
11
+
12
+ <key>ProgramArguments</key>
13
+ <array>
14
+ <string>/bin/bash</string>
15
+ <string>/path/to/forge-harness/scripts/probe_live_eval.sh</string>
16
+ <!-- 🟥 COST + API-KEY DECISION IS THE OPERATOR'S — this template ships with NO default
17
+ the subset / ids limiter flags limiter, i.e. installing it as-is runs the FULL selected set (12
18
+ probes x 2 calls = 24 live `claude -p` invocations) every night. Read
19
+ scripts/probe_live_eval.sh's own header (§COST) before installing, and add a
20
+ a subset or ids argument argument here if a nightly full-set run is not what you
21
+ want. This plist is a template, not a recommendation of scope. -->
22
+ <!-- 🟥 reps=3 (2026-09-06). 이 인자가 없으면 reps=1 이고, 그때의 pass_rate 로는 문턱을
23
+ 정할 수 없다 — 실측: 유효 런 3 개 재채점에서 12 프로브 중 5 개가 flaky, 같은 코퍼스
24
+ 15 분 간격 두 런에서 4 개가 뒤집혔고 관측 pass_rate 는 0.50/0.67/0.67 이었다(단일 rep
25
+ 노이즈 폭 > 문턱까지의 거리). 판정은 과반, 분산은 리포트 `reps(pass/ran)` 칸에 남는다.
26
+ ⚠️ 비용이 3 배다: 24 → **72** 회 `claude -p`. 줄이려면 the subset / ids limiter flags 를 같이 줘라. -->
27
+ <string>--reps</string>
28
+ <string>3</string>
29
+ </array>
30
+
31
+ <!-- 매일 02:30 (야간, 다른 cron 과 안 겹치는 시간대 — frontier-digest=09:00, daily-report=07:30
32
+ 과 분리). 잠자는 중이면 launchd 가 깨어난 뒤 한 번 돌린다(정각 보장 아님). -->
33
+ <key>StartCalendarInterval</key>
34
+ <dict>
35
+ <key>Hour</key><integer>2</integer>
36
+ <key>Minute</key><integer>30</integer>
37
+ </dict>
38
+
39
+ <key>RunAtLoad</key>
40
+ <false/>
41
+
42
+ <key>StandardOutPath</key>
43
+ <string>/path/to/forge-harness/tracks/_meta/logs/live_eval_out.log</string>
44
+ <key>StandardErrorPath</key>
45
+ <string>/path/to/forge-harness/tracks/_meta/logs/live_eval_err.log</string>
46
+
47
+ <key>EnvironmentVariables</key>
48
+ <dict>
49
+ <key>PATH</key>
50
+ <!-- must include wherever `claude` and `python3` resolve for this user.
51
+ /opt/homebrew/bin included so Homebrew's `gtimeout` (Apple Silicon prefix; Intel's
52
+ /usr/local/bin is already below) resolves — stock macOS ships no `timeout(1)` at
53
+ all, and scripts/probe_live_eval.sh's runner (sim_isolated_run.sh) falls back to a
54
+ bash-native watchdog if NEITHER resolves, so this is an optimization, not a hard
55
+ requirement: the run still enforces its timeout without this entry, just via the
56
+ slower fallback path. See sim_isolated_run.sh's own §timeout(1) RESOLUTION header
57
+ (measured 2026-09-05: the first unpatched run here died 12/12 FAILED-TO-RUN because
58
+ this PATH had neither `timeout` nor `gtimeout` AND the fallback did not exist yet). -->
59
+ <string>/path/to/home/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
60
+ <key>HOME</key>
61
+ <string>/path/to/home</string>
62
+ </dict>
63
+ </dict>
64
+ </plist>
65
+
66
+ <!--
67
+ INSTALL (manual — no launchd_wiring_check.sh render- helper exists for this plist yet; unlike
68
+ com.forge-harness.daily-report.plist, this one is not wired into that renderer, so every
69
+ /path/to/... above must be hand-edited before `launchctl load`):
70
+
71
+ 1. cp scripts/com.forge-harness.live-eval.plist ~/Library/LaunchAgents/com.forge-harness.live-eval.plist
72
+ 2. Edit every /path/to/... above to this checkout's absolute path and your $HOME.
73
+ 3. mkdir -p tracks/_meta/logs (StandardOutPath/StandardErrorPath dirs must pre-exist)
74
+ 4. launchctl load ~/Library/LaunchAgents/com.forge-harness.live-eval.plist
75
+ 5. Verify: launchctl list | grep com.forge-harness.live-eval
76
+
77
+ UNINSTALL:
78
+ launchctl unload ~/Library/LaunchAgents/com.forge-harness.live-eval.plist
79
+ rm ~/Library/LaunchAgents/com.forge-harness.live-eval.plist
80
+
81
+ The nightly run appends its record to tracks/_meta/live_eval_<date>.md (created fresh each run —
82
+ not append-only; a stale prior day's file is left alone, per date, exactly like
83
+ tracks/_meta/daily_report_<date>.md and tracks/_meta/frontier_digest_<date>.md).
84
+ -->
@@ -70,41 +70,15 @@ do_seal() {
70
70
 
71
71
  echo "## 운영자 발화 (이 세션)"
72
72
  if [ -f "$transcript" ]; then
73
- python3 - "$transcript" <<'PY' 2>/dev/null || echo "- (전사본 파싱 실패 — 원본: $transcript)"
74
- import json,sys
75
- n=0
76
- skipped_tool=skipped_meta=skipped_other=0
77
- for line in open(sys.argv[1], errors='replace'):
78
- try: d=json.loads(line)
79
- except Exception: continue
80
- if d.get('type')!='user': continue
81
- m=d.get('message') or {}
82
- c=m.get('content')
83
- # ⚠️ 초판은 "실발화=str · 툴결과=list" 로 갈랐다. **틀렸다** — 이미지/파일을 첨부한 실발화는
84
- # list 다(high 리뷰 실측: 전사본 25개에서 그런 발화 18건이 구조적으로 안 보였다). 더 나쁜 건
85
- # self-test 픽스처가 같은 가정을 인코딩해서 **초록이 그 결함을 보증**했다는 것이다.
86
- # 이제 list 는 text 블록을 꺼내 쓰고, tool_result 만 제외한다.
87
- if isinstance(c,str):
88
- t=c
89
- elif isinstance(c,list):
90
- if any(isinstance(b,dict) and b.get('type')=='tool_result' for b in c):
91
- skipped_tool+=1; continue
92
- parts=[b.get('text','') for b in c if isinstance(b,dict) and b.get('type')=='text']
93
- if not parts:
94
- skipped_other+=1; continue # 이미지-only 등 — 셈에서 지우지 않고 센다
95
- t=' '.join(parts)
96
- else:
97
- skipped_other+=1; continue
98
- t=' '.join(t.split())
99
- if not t: skipped_other+=1; continue
100
- if t.startswith('<') or t.startswith('/'): # 슬래시 커맨드·메타 봉투 제외
101
- skipped_meta+=1; continue
102
- n+=1
103
- print(f"{n}. {t[:200]}")
104
- # **제외분을 반드시 인쇄한다.** 합계만 찍으면 그 원장이 완전한 것처럼 읽힌다 — `not found ≠ 0`.
105
- print(f"\n합계: {n}건" if n else "- (발화 0건)")
106
- print(f"제외: tool_result {skipped_tool} · 메타/커맨드 {skipped_meta} · 텍스트없음 {skipped_other}")
107
- PY
73
+ # 🟥 추출 로직은 `scripts/transcript_utterances.py` **단일 소스**다 (2026-09-05).
74
+ # 인라인 heredoc 이었을 때 두 번째 소비처(`utterance_intake.sh` — 발화 착지 검사)를
75
+ # 붙이려면 사본이 생겼고, 전처리 두 벌은 «한쪽만 통과하는 입력이 다른 쪽에서 무음
76
+ # 드롭» 이다(`[[feedback_divergent_leniency_duplicate_normalizers]]`).
77
+ # 출력은 **바이트 동일**해야 한다 — `scripts/test_utterance_intake_lanes.sh` L10 이
78
+ # 골든으로 고정하고, L12/L12b 가 되돌림으로 «정말 이 파일을 통해 도는가» 를 잰다.
79
+ # 추출기가 없거나 죽으면 비영 종료 → 아래 폴백 문구가 그대로 뜬다(무음 아님).
80
+ python3 "$REPO_ROOT/scripts/transcript_utterances.py" "$transcript" --format seal 2>/dev/null \
81
+ || echo "- (전사본 파싱 실패 — 원본: $transcript)"
108
82
  else
109
83
  echo "- 🟥 전사본 경로 없음: $transcript"
110
84
  fi
@@ -106,6 +106,15 @@ b64_decode() {
106
106
  TMP=$(mktemp -d) || { echo "HARNESS-ERROR: mktemp failed"; exit 10; }
107
107
  trap 'rm -rf "$TMP"' EXIT
108
108
 
109
+ # ── has_nul: binary detection ──────────────────────────────────────────────────
110
+ # ONE implementation, called by check_path AND by --self-test. Until 2026-09-05 this was defined
111
+ # twice, byte-identical, once inside each caller — so the self-test exercised ITS OWN copy and a
112
+ # broken production copy stayed green (3-arm mutation: production-only break → rc=0). The file
113
+ # already pinned the one-implementation rule for compute_lost and loss_token; this was the one
114
+ # helper that escaped it. NOT `grep -q $'\x00'`: bash cannot hold a NUL in a string, so that
115
+ # collapses to the EMPTY pattern and every text file is declared binary. Strip-and-compare.
116
+ has_nul() { LC_ALL=C tr -d '\000' < "$1" | cmp -s - "$1" || return 0; return 1; }
117
+
109
118
  # ── compute_lost: the verdict, as multiset arithmetic ───────────────────────────
110
119
  # $TMP/lost = R − L (occurrences the remote has that the upload does not)
111
120
  # ONE implementation, called by check_path AND by --self-test — two copies of the same
@@ -234,7 +243,6 @@ check_path() { # $1=repo $2=base_sha $3=path
234
243
  # NOT `grep -q $'\x00'`: bash cannot hold a NUL in a string, so `$'\x00'` collapses to
235
244
  # the EMPTY pattern, grep matches every line, and every text file is declared binary —
236
245
  # a total over-block that looks like a strict check. Strip-and-compare instead.
237
- has_nul() { LC_ALL=C tr -d '\000' < "$1" | cmp -s - "$1" || return 0; return 1; }
238
246
  if has_nul "$prev" || has_nul "$path"; then
239
247
  echo " 🟥 $path — UNDECIDABLE: binary content (NUL bytes); a line-wise direction"
240
248
  echo " verdict over binary is not a measurement."
@@ -360,13 +368,17 @@ self_test() {
360
368
  # (h) NUL detection. The first draft used `grep -q $'"'"'\x00'"'"'`, which bash collapses to the
361
369
  # EMPTY pattern — every text file matched and every push would have been declared
362
370
  # binary/UNDECIDABLE. Both directions are pinned so that regression cannot return.
363
- has_nul() { LC_ALL=C tr -d '\000' < "$1" | cmp -s - "$1" || return 0; return 1; }
364
371
  printf 'plain text\n' > "$TMP/tn"
365
372
  has_nul "$TMP/tn" && r=BINARY || r=TEXT
366
373
  t "plain text is not misread as binary" TEXT "$r"
367
374
  printf 'a\000b\n' > "$TMP/tn"
368
375
  has_nul "$TMP/tn" && r=BINARY || r=TEXT
369
376
  t "a file with NUL bytes is detected as binary" BINARY "$r"
377
+ # (h2) The pair above measures PRODUCTION only if there is exactly ONE definition — a second,
378
+ # self-test-local copy made this suite green while the production copy was broken
379
+ # (2026-09-05, 3-arm mutation). Pin the count so the shadow copy cannot return.
380
+ t "has_nul is defined exactly once (the self-test measures the production copy)" 1 \
381
+ "$(grep -c '^has_nul() {' "$0" | tr -d ' ')"
370
382
 
371
383
  # (i) 404 must be the HTTP status, not any stderr containing "not found".
372
384
  # `gh: command not found` used to be accepted as "new file at base" → fail-open.
@@ -210,7 +210,10 @@ PROMPT="$(cat "$PROMPT_FILE")"
210
210
  rm -f "$PROMPT_FILE"
211
211
 
212
212
  _log "Stage 2 starting (timeout ${ATTEMPT_TIMEOUT_SECS}s)"
213
- "$CLAUDE_BIN" -p --permission-mode bypassPermissions "$PROMPT" >> "$LOG_FILE" 2>&1 &
213
+ # 프롬프트는 `-p` 바로 뒤(positional-first) Stage 1 러너와 같은 계약(2026-09-05, variadic 플래그 뒤
214
+ # positional 은 먹힌다). FD_MODEL 은 Stage 1 과 같은 변수로 핀한다: /model 로 저장한 기본 모델이
215
+ # `claude -p` 무인 런에도 적용되므로, 무인 잡의 모델은 plist 가 명시한다(운영자 결정 2026-09-05).
216
+ "$CLAUDE_BIN" -p "$PROMPT" --permission-mode bypassPermissions ${FD_MODEL:+--model "$FD_MODEL"} >> "$LOG_FILE" 2>&1 &
214
217
  CLAUDE_PID=$!
215
218
  DEADLINE=$((SECONDS + ATTEMPT_TIMEOUT_SECS))
216
219
  while kill -0 "$CLAUDE_PID" 2>/dev/null && [ "$SECONDS" -lt "$DEADLINE" ]; do
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+ """docs/map 의 archify 산출 HTML 에 붙이는 재생성-후 후처리 — 두 가지만 한다.
3
+
4
+ (1) reader-width floor 뷰어의 `MIN_READER_WIDTH = 960` 상수를 뷰포트 비례 하한으로 바꾼다.
5
+ archify 뷰어는 «스크롤 없이 다 담기» 를 목표로 폭을 줄이는데, 세로 예산이 모자라면
6
+ 1728px 화면에서도 960px 로 바닥을 친다(실측 2026-09-06: 1728×950 → 960). 지도는
7
+ «한 화면에 담기» 보다 «넓게 읽기» 가 목적이라 하한을 화면 비례로 올린다.
8
+ 실측 효과(1728×950, fh_process): 960 → 1382. 가로 오버플로 없음(1280×800 → 1216).
9
+
10
+ (2) SVG 재생성 HTML 안의 유일한 <svg> + 뷰어 <style> 을 심은 정적 벡터를 다시 만든다.
11
+ 기존 커밋본 두 장(fh_trust.dataflow.svg · fh_assets.architecture.svg)에 대해
12
+ 바이트 동일 재현을 known-pair 로 확인하고 만든 추출기다.
13
+
14
+ fail-closed: 기대한 리터럴이 없으면(렌더러 버전 드리프트) 종료코드 3 으로 멈춘다 —
15
+ 조용히 «패치할 게 없었다» 로 넘어가면 다음 발행이 옛 동작으로 나간다.
16
+
17
+ usage: map_postprocess.py <file.html> [...] 실제 적용
18
+ map_postprocess.py --check <file.html> [...] 적용 여부만 보고(쓰기 없음)
19
+ exit: 0 적용/이미적용 · 3 리터럴 부재(드리프트) · 4 인자/파일 오류
20
+ """
21
+ import re
22
+ import sys
23
+
24
+ OLD = 'var MIN_READER_WIDTH = 960;'
25
+ NEW = ('var MIN_READER_WIDTH = Math.min(1440, '
26
+ 'Math.max(960, Math.round(window.innerWidth * 0.80)));')
27
+
28
+
29
+ def build_svg(html: str) -> str:
30
+ styles = re.findall(r'<style[^>]*>(.*?)</style>', html, re.S)
31
+ if len(styles) != 1:
32
+ raise ValueError(f'expected exactly 1 <style> block, got {len(styles)}')
33
+ m = re.search(r'<svg\b.*?</svg>', html, re.S)
34
+ if not m:
35
+ raise ValueError('no <svg> block found')
36
+ svg = m.group(0).replace('<svg ', '<svg xmlns="http://www.w3.org/2000/svg" ', 1)
37
+ tag_end = svg.index('>') + 1
38
+ return ('<?xml version="1.0" encoding="UTF-8"?>\n'
39
+ + svg[:tag_end] + '<style><![CDATA[\n' + styles[0] + '\n]]></style>'
40
+ + svg[tag_end:])
41
+
42
+
43
+ def process(path: str, check_only: bool) -> int:
44
+ try:
45
+ html = open(path, encoding='utf-8').read()
46
+ except OSError as exc:
47
+ print(f'ERROR {path}: {exc}', file=sys.stderr)
48
+ return 4
49
+ has_old, has_new = OLD in html, NEW in html
50
+ if not has_old and not has_new:
51
+ print(f'DRIFT {path}: neither the original nor the patched reader-width '
52
+ f'literal is present — archify version drift, patch NOT applied',
53
+ file=sys.stderr)
54
+ return 3
55
+ if check_only:
56
+ print(f'{"PATCHED" if has_new else "UNPATCHED"} {path}')
57
+ return 0
58
+ patched = html.replace(OLD, NEW) if has_old else html
59
+ svg_path = re.sub(r'\.html$', '.svg', path)
60
+ # 쓰기 전에 둘 다 만들어 둔다 — 중간에 실패하면 «반쯤 적용된 트리»가 남고,
61
+ # 특히 open(...,'w') 은 예외가 나기 전에 이미 대상을 0바이트로 잘라 놓는다(L6 가 잡았다).
62
+ try:
63
+ svg = build_svg(patched)
64
+ except ValueError as exc:
65
+ print(f'ERROR {svg_path}: {exc} — nothing written', file=sys.stderr)
66
+ return 3
67
+ if has_old:
68
+ open(path, 'w', encoding='utf-8').write(patched)
69
+ print(f'PATCH {path}: reader-width floor -> viewport-proportional')
70
+ else:
71
+ print(f'SKIP {path}: already patched (idempotent)')
72
+ open(svg_path, 'w', encoding='utf-8').write(svg)
73
+ print(f'SVG {svg_path}')
74
+ return 0
75
+
76
+
77
+ def main(argv):
78
+ check_only = '--check' in argv
79
+ files = [a for a in argv if not a.startswith('--')]
80
+ if not files:
81
+ print(__doc__, file=sys.stderr)
82
+ return 4
83
+ worst = 0
84
+ for path in files:
85
+ worst = max(worst, process(path, check_only))
86
+ return worst
87
+
88
+
89
+ if __name__ == '__main__':
90
+ sys.exit(main(sys.argv[1:]))