okstra 0.148.1 → 0.150.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/docs/architecture.md +1 -1
  2. package/docs/cli.md +1 -1
  3. package/docs/project-structure-overview.md +1 -1
  4. package/package.json +1 -1
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/agents/workers/translator-worker.md +67 -0
  7. package/runtime/bin/okstra-render-final-report.py +0 -11
  8. package/runtime/bin/okstra-render-report-views.py +20 -0
  9. package/runtime/bin/okstra-report-translate.py +158 -0
  10. package/runtime/prompts/lead/adapters/claude-code.md +1 -1
  11. package/runtime/prompts/lead/okstra-lead-contract.md +1 -0
  12. package/runtime/prompts/lead/report-writer.md +14 -13
  13. package/runtime/prompts/lead/team-contract.md +2 -2
  14. package/runtime/prompts/profiles/project-analysis.md +18 -0
  15. package/runtime/prompts/profiles/release-handoff.md +3 -0
  16. package/runtime/prompts/profiles/requirements-discovery.md +7 -0
  17. package/runtime/prompts/wizard/prompts.ko.json +17 -1
  18. package/runtime/python/okstra_ctl/analysis_inputs.py +24 -9
  19. package/runtime/python/okstra_ctl/analysis_packet.py +23 -1
  20. package/runtime/python/okstra_ctl/clarification_items.py +241 -44
  21. package/runtime/python/okstra_ctl/codex_dispatch.py +2 -1
  22. package/runtime/python/okstra_ctl/dispatch_core.py +2 -2
  23. package/runtime/python/okstra_ctl/dispatch_state.py +12 -1
  24. package/runtime/python/okstra_ctl/final_report_paths.py +22 -1
  25. package/runtime/python/okstra_ctl/i18n.py +12 -7
  26. package/runtime/python/okstra_ctl/render_final_report.py +18 -17
  27. package/runtime/python/okstra_ctl/report_html/common.py +77 -47
  28. package/runtime/python/okstra_ctl/report_html/filters.py +63 -30
  29. package/runtime/python/okstra_ctl/report_html/models.py +15 -0
  30. package/runtime/python/okstra_ctl/report_html/render.py +86 -5
  31. package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +14 -4
  32. package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +3 -3
  33. package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +5 -3
  34. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +2 -7
  35. package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +5 -9
  36. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +2 -5
  37. package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +1 -2
  38. package/runtime/python/okstra_ctl/report_html/view_models/project_analysis.py +90 -4
  39. package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +1 -8
  40. package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +6 -4
  41. package/runtime/python/okstra_ctl/report_html/visualizations.py +146 -11
  42. package/runtime/python/okstra_ctl/report_translation.py +440 -0
  43. package/runtime/python/okstra_ctl/report_view_artifacts.py +5 -0
  44. package/runtime/python/okstra_ctl/report_views.py +23 -9
  45. package/runtime/python/okstra_ctl/run.py +1 -1
  46. package/runtime/python/okstra_ctl/time_report.py +2 -2
  47. package/runtime/python/okstra_ctl/usage_report.py +2 -2
  48. package/runtime/python/okstra_ctl/user_response.py +11 -6
  49. package/runtime/python/okstra_ctl/wizard.py +100 -25
  50. package/runtime/python/okstra_ctl/worker_liveness.py +130 -36
  51. package/runtime/schemas/final-report-v2.0.schema.json +229 -1
  52. package/runtime/templates/reports/final-report.template.md +55 -0
  53. package/runtime/templates/reports/html/assets/base.css +59 -9
  54. package/runtime/templates/reports/html/base.template.html +23 -44
  55. package/runtime/templates/reports/html/i18n/en.json +395 -0
  56. package/runtime/templates/reports/html/i18n/ko.json +395 -0
  57. package/runtime/templates/reports/html/macros/forms.html +20 -20
  58. package/runtime/templates/reports/html/macros/layout.html +18 -5
  59. package/runtime/templates/reports/html/macros/visualizations.html +7 -5
  60. package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +30 -15
  61. package/runtime/templates/reports/html/tasks/error-analysis.template.html +22 -15
  62. package/runtime/templates/reports/html/tasks/feature-analysis.template.html +35 -15
  63. package/runtime/templates/reports/html/tasks/final-verification.template.html +21 -14
  64. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +78 -19
  65. package/runtime/templates/reports/html/tasks/implementation.template.html +34 -16
  66. package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +11 -11
  67. package/runtime/templates/reports/html/tasks/project-analysis.template.html +65 -25
  68. package/runtime/templates/reports/html/tasks/release-handoff.template.html +28 -14
  69. package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +35 -15
  70. package/runtime/templates/reports/report.js +8 -5
  71. package/runtime/validators/validate-report-views.py +1 -1
  72. package/runtime/validators/validate-run.py +28 -31
  73. package/runtime/validators/validate_analysis_report.py +36 -0
  74. package/src/cli-registry.mjs +11 -0
  75. package/src/commands/inspect/worker-liveness.mjs +9 -7
  76. package/src/commands/report/translate.mjs +31 -0
  77. package/src/lib/helper-scripts.mjs +1 -0
  78. package/runtime/templates/reports/i18n/ko.json +0 -273
@@ -304,7 +304,7 @@ The standard `okstra` workflow applies the following team contract consistently
304
304
  - Model defaults are provider and functional-role policy. Fallbacks include Claude lead/analyser=`opus`, Codex lead/analyser=`gpt-5.6-sol`, Claude report writer=`sonnet`, Antigravity=`gemini-3.1-pro`, Grok analyser=`grok-build-0.1`, and Kimi analyser=`kimi-k2.7-code`.
305
305
  - Before the final judgment, each required role in the current run's worker roster must have either a result or an explicit terminal status (`completed`, `timeout`, `error`, `not-run`).
306
306
  - Every attempted worker (`completed`, `timeout`, `error`) must have an assigned worker prompt history file under the current run's `prompts/` directory.
307
- - Worker timing begins at the atomic transition to `in-progress`, which records `workers[].startedAt` in `team-state.json`; prompt creation time is not a dispatch proxy. `okstra worker-state transition` and both dispatch adapters share `dispatch_state.transition_worker_status`, while `okstra worker-liveness --team-state ... --worker ...` reads that timestamp as the launch-grace authority.
307
+ - Worker timing begins at the atomic transition to `in-progress`, which records `workers[].startedAt` in `team-state.json`; prompt creation time is not a dispatch proxy. `okstra worker-state transition` and both dispatch adapters share `dispatch_state.transition_worker_status`, while `okstra worker-liveness --team-state ... --worker ...` reads that timestamp as the launch-grace authority for both probe kinds — the in-process audit sidecar is reused on re-dispatch, so only `startedAt` separates the previous attempt's last heartbeat from this dispatch's silence.
308
308
  - An unnamed generic parallel worker is not accepted as a substitute for a required role.
309
309
 
310
310
  ### Cross-task worker prompt policy and final-verification boundaries
package/docs/cli.md CHANGED
@@ -729,7 +729,7 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
729
729
  | `okstra rollup [--task-group <group>] [--project-root <dir>] [--cwd <dir>]` | Read-only backend for the okstra-rollup skill. For every catalog task, or one task group, it emits JSON with per-task run counts, raw duration in ms, error counts, latest report paths, group totals, and status/category/phase distributions. Omitting `--task-group` targets the whole project catalog. The caller skill formats raw ms as HH:MM:SS and synthesizes report prose. Use the `okstra inspect` family for a single-task drill-down |
730
730
  | `okstra usage-report [--days <positive-int>] [--project-root <dir>] [--cwd <dir>] [--json]` | Read-only backend for the okstra-usage skill. Defaults to the whole current project's last 30 days and emits task-type run coverage, raw/billable tokens, known USD cost, CPU-sum milliseconds, wall-clock milliseconds, unavailable reason counts, and unmatched pricing models |
731
731
  | `okstra worker-state transition --team-state <path> --worker <id> --status <in-progress\|completed\|timeout\|error\|not-run> [--reason <text>] [--model <execution-value>]` | Atomically update one persisted worker row. `in-progress` records the authoritative `startedAt` and clears `endedAt`; terminal states record `endedAt`; `timeout`, `error`, and `not-run` require a reason. Dispatch adapters use this same transition path, so CLI-backed and in-process orchestration share the status timestamp contract |
732
- | `okstra worker-liveness [--audit <path>]… [--team-state <path> --worker <id>]… [--max-idle <seconds>] [--launch-grace <seconds>] [--json]` | Judge whether pending workers are still alive so the lead's poll ends a stalled wait early instead of paying the full deadline. Both selectors repeat and may be mixed in one call. `--audit` takes an in-process worker audit sidecar and reports `stalled` when its `- PROGRESS:` heartbeat is past the idle budget. Each `--team-state` must have a paired `--worker`; that selector resolves the worker's prompt and starts launch grace from its persisted `startedAt`, then reports `did-not-launch` when neither the wrapper `.log` nor `.status.json` appears. Healthy probes report `live`. It only judges—it never kills or re-dispatches. Exit 1 on an unhealthy verdict, so a poll loop can branch without parsing JSON. The heartbeat line shape and budget come from the `okstra_ctl.worker_heartbeat` SSOT shared with the Phase 7 audit (`validators/validate_session_conformance.py`) |
732
+ | `okstra worker-liveness [--team-state <path> --worker <id>]… [--max-idle <seconds>] [--launch-grace <seconds>] [--json]` | Judge whether pending workers are still alive so the lead's poll ends a stalled wait early instead of paying the full deadline. The selector repeats; each `--team-state` must have a paired `--worker`. The worker row's `livenessMode` picks the probe: `audit-heartbeat` reads its `auditSidecarPath` and reports `stalled` when the `- PROGRESS:` heartbeat is past the idle budget; `wrapper-status` reads its `promptPath` and reports `did-not-launch` when neither the wrapper `.log` nor `.status.json` appears. Both graces start at the persisted `startedAt`, never at an artifact mtime — the audit sidecar is reused on re-dispatch, so a heartbeat older than this dispatch counts as no signal yet rather than a stall. Healthy probes report `live`. It only judges—it never kills or re-dispatches. Exit 1 on an unhealthy verdict, so a poll loop can branch without parsing JSON. The heartbeat line shape and budget come from the `okstra_ctl.worker_heartbeat` SSOT shared with the Phase 7 audit (`validators/validate_session_conformance.py`) |
733
733
  | `okstra log-report [--project-root <dir>] [--cwd <dir>] [--top <N>] [--json]` | Read-only inventory of wrapper transcript `.log` files and their sibling prompt `.md` files. Each ranked entry preserves `path` / `sizeBytes` for compatibility and also reports `transcriptPath`, `transcriptBytes`, `promptPath`, `promptBytes`, and `transcriptToPromptRatio`; totals distinguish prompt bytes from transcript bytes and count paired files. Ranking remains transcript-size descending |
734
734
  | `okstra recap <assemble\|record\|note> <task-root\|task-key> …` | Backend for the okstra-inspect `recap` facet. `assemble` is read-only and prints a JSON summary of phase transitions across a task's runs. `record --kind <summary\|qa> --mode <artifact\|code> --answer <text> [--question <text>] [--citation <path:line> …]` appends one line to `<task-root>/recap/recap-log.jsonl` and never mutates other artifacts. `note --kind <verification-evidence\|decision-draft\|analysis-note> --slug <topic> --purpose <text> --scope-note <text> (--body <markdown>\|--body-file <path>)` writes an agent-authored note to `<task-root>/notes/` and prints its path plus the `--clarification-response` argument for feeding it into a later run |
735
735
  | `okstra user-response <list\|show\|write> …` | Backend for the `/okstra-user-response` skill: answer a task's open clarification questions in-session and write the response sidecar. `list --home <dir> --project <id> [--limit <n>]` finds reports with open questions; `show --report <md>` reads one report's questions; `write --report <md> --answers <json> [--approval <json>] [--task-key <key>]` writes the sidecar. Each answer carries a `disposition` of `answer` or `reframe`; a `reframe` is carried into the next run as a re-scoped brief. JSON output; exit 0 ok / 1 error |
@@ -267,7 +267,7 @@ Important modules:
267
267
  | `index.py`, `jsonl.py`, `reconcile.py`, `listing.py`, `batch.py`, `backfill.py` | `~/.okstra` run index and history operations |
268
268
  | `run_index_row.py` | single reference point for creating / slimming / hydrating a `~/.okstra` run-index row — runId SSOT, preserves projectId raw |
269
269
  | `error_report.py`, `error_log_core.py`, `error_zip.py` | backend for the okstra-inspect errors/error-zip facets — `error_log_core` is the read-only core that globs/parses/aggregates `errors-*.jsonl`, `error_report` renders the errors facet, and `error_zip` collects cross-project run directories, allowlist-anonymizes, aggregates clusters, and produces a zip |
270
- | `worker_heartbeat.py`, `worker_liveness.py` | `worker_heartbeat` is the single definition of the `- PROGRESS:` heartbeat line shape and its 5-minute (+60s grace) cadence budget, shared by the Phase 7 audit (`validators/validate_session_conformance.py`) and the live probe; `worker_liveness` backs `okstra worker-liveness`, reporting a pending worker as `stalled` (heartbeat past the budget) or `did-not-launch` (no wrapper `.log`/`.status.json` past the launch grace) |
270
+ | `worker_heartbeat.py`, `worker_liveness.py` | `worker_heartbeat` is the single definition of the `- PROGRESS:` heartbeat line shape and its 5-minute (+60s grace) cadence budget, shared by the Phase 7 audit (`validators/validate_session_conformance.py`) and the live probe; `worker_liveness` backs `okstra worker-liveness`, resolving each pending worker from its team-state row (`livenessMode` picks the artifact, `startedAt` anchors the grace) and reporting `stalled` (heartbeat past the budget, or none yet for this dispatch past the grace) or `did-not-launch` (no wrapper `.log`/`.status.json` past the launch grace) |
271
271
  | `log_report.py`, `time_report.py` | read-side backend for the okstra-inspect logs/time facets (`okstra log-report` pairs each wrapper transcript `.log` with its sibling prompt `.md` and reports both byte counts without changing legacy transcript-size fields; `okstra time-report` is per-task time aggregation) |
272
272
  | `rollup.py` | read-side backend for the okstra-rollup skill — fans the catalog out per task-group (or the whole project) and deterministically aggregates each task's run count, elapsed time (raw ms), error count, and latest report path, plus group-level totals/status, category, and phase distribution. Reuses the `time_report`/`error_log_core` functions and delegates report-body synthesis to the skill |
273
273
  | `usage_report.py` | Read-only okstra-usage backend — scans the whole current project's recent run timelines, defaults to 30 days, and returns task-type coverage, raw/billable tokens, known USD cost, CPU-sum and wall-clock milliseconds, unavailable reason counts, and unmatched pricing models |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.148.1",
3
+ "version": "0.150.0",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.148.1",
3
- "builtAt": "2026-08-04T05:55:30.824Z",
2
+ "package": "0.150.0",
3
+ "builtAt": "2026-08-05T03:48:23.472Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -0,0 +1,67 @@
1
+ ---
2
+ name: translator-worker
3
+ description: |
4
+ Use this agent when okstra is in Phase 7 and the run's report language is not `en`. This agent translates the final-report's reader-facing strings into a sidecar the HTML renderer overlays. It is NOT an analysis worker — it produces no findings and never edits the report itself.
5
+
6
+ <example>
7
+ Context: okstra finished Phase 6 with `meta.reportLanguage: "ko"` and is entering Phase 7.
8
+ user: "okstra this task bundle"
9
+ assistant: "Phase 7 — dispatching translator-worker to write the ko translation sidecar."
10
+ <commentary>The okstra skill dispatches this agent before `okstra report-finalize` so `render-views` has a sidecar to overlay.</commentary>
11
+ </example>
12
+ color: cyan
13
+ model: inherit
14
+ tools: ["Bash", "Read", "Write", "Glob", "Grep"]
15
+ ---
16
+
17
+ **Write one file**: the translation sidecar at the assigned `Result Path`. That is the `Translator worker`'s sole responsibility. You are NOT an analysis worker — you produce no findings, you vote in nothing, and you never edit the final-report data.json, its Markdown sibling, or its HTML.
18
+
19
+ The data.json is the English SSOT that every later phase and validator reads. Your sidecar is presentation: the HTML renderer overlays it onto an in-memory copy so only the human report speaks the reader's language.
20
+
21
+ ## Procedure
22
+
23
+ 1. Read your dispatch prompt's `**Report Language:**` header. It is already resolved to a concrete language — never `auto`.
24
+ 2. Build your work list:
25
+
26
+ ```bash
27
+ okstra report-translate extract <data.json path>
28
+ ```
29
+
30
+ It writes `<stem>.translation-source.json`, a map of JSON Pointer to the English text at it. Those pointers are the whole job — you never invent one, and every value the renderer reads as machinery (ids, paths, commands, enum tokens, CSS-class values, verbatim quotes) has already been withheld from you.
31
+ 3. Write the sidecar at `Result Path`, same shape, with each value translated:
32
+
33
+ ```json
34
+ {
35
+ "lang": "<language>",
36
+ "sourceData": "final-report-<task-type>-<seq>.data.json",
37
+ "strings": { "/humanSummary/headline": "…" }
38
+ }
39
+ ```
40
+
41
+ 4. Verify before you return:
42
+
43
+ ```bash
44
+ okstra report-translate check <Result Path>
45
+ ```
46
+
47
+ A non-zero exit means a pointer resolves nowhere — you altered or invented one. Fix it and re-run. Do not return on a failing check.
48
+
49
+ ## How to translate
50
+
51
+ You are a translator who reads code. Judge every term on whether the translation or the original carries the meaning faster to a working developer in the target language, and pick that one. The goal is a reader who understands the report sooner, not a document with no English left in it.
52
+
53
+ - **Never touch**: code identifiers, file paths, CLI commands and flags, model names, commit SHAs, URLs, and anything already inside backticks. Reproduce them character for character.
54
+ - **Keep the English word** when that is what developers in the target language actually say. Forcing a native coinage onto `commit`, `worktree`, `merge`, `lint`, `diff`, `stage`, `rollback` or `PR` makes the sentence *slower* to read, not more local.
55
+ - **Translate the explanation.** Connective prose — why something matters, what a reader should do, what a finding means — is where the translation earns its place. Carry the meaning, not the word order.
56
+ - **Do not translate literally.** A word-for-word rendering that is technically correct and unreadable has failed. Say what the sentence means the way a developer would say it.
57
+ - **Gloss on first use, once.** When a technical term does need translating, write it as `<translation>(<English>)` the first time it appears in the document, then use the translation alone. Never gloss the same term twice.
58
+ - **One claim per sentence.** Where the English stacks four clauses behind em-dashes, split it. The reader gains nothing from the original's punctuation.
59
+ - **Match the register.** A verdict line is terse; a rationale paragraph is explanatory. Do not inflate a three-word cell into a sentence, or compress a paragraph into a fragment.
60
+ - **Leave it out when you cannot do it justice.** An omitted pointer renders in English, which is a correct fallback. A confident mistranslation is not.
61
+
62
+ ## What you never do
63
+
64
+ - Never edit the data.json, the Markdown sibling, or the HTML. Your only output is the sidecar.
65
+ - Never add, remove, or re-order pointers relative to the extract output.
66
+ - Never translate a value the extract step did not offer you. Their absence is deliberate — the renderer reads them as machinery, and a translated one breaks the page silently.
67
+ - Never return the sidecar contents inline. The file on disk is the artifact.
@@ -26,7 +26,6 @@ _HERE = Path(__file__).resolve().parent
26
26
  # scripts; for in-repo invocation we add ``scripts/`` explicitly.
27
27
  sys.path.insert(0, str(_HERE))
28
28
 
29
- from okstra_ctl.i18n import SUPPORTED_LANGS # noqa: E402
30
29
  from okstra_ctl.final_report_paths import final_report_markdown_path # noqa: E402
31
30
  from okstra_ctl.render_final_report import ( # noqa: E402
32
31
  FinalReportRenderError,
@@ -62,15 +61,6 @@ def main(argv: list[str]) -> int:
62
61
  "repo-local report template."
63
62
  ),
64
63
  )
65
- parser.add_argument(
66
- "--report-language",
67
- choices=list(SUPPORTED_LANGS),
68
- default=None,
69
- help=(
70
- "Override the language passed into the renderer. When omitted, "
71
- "the renderer reads data.json.meta.reportLanguage (fallback 'en')."
72
- ),
73
- )
74
64
  args = parser.parse_args(argv)
75
65
 
76
66
  output = args.output or final_report_markdown_path(args.data)
@@ -80,7 +70,6 @@ def main(argv: list[str]) -> int:
80
70
  args.data,
81
71
  output,
82
72
  template_path=args.template,
83
- report_language=args.report_language,
84
73
  )
85
74
  except FinalReportRenderError as exc:
86
75
  print(f"error: {exc}", file=sys.stderr)
@@ -138,9 +138,29 @@ def _v2_run_meta(data: dict, markdown_path: Path, args: argparse.Namespace):
138
138
  task_type,
139
139
  seq,
140
140
  args.source_report or markdown_path.name,
141
+ _elapsed_ms(markdown_path, task_type, seq),
141
142
  )
142
143
 
143
144
 
145
+ def _elapsed_ms(markdown_path, task_type: str, seq: str) -> int | None:
146
+ """Wall-clock milliseconds for this run, or None when it cannot be measured.
147
+
148
+ The measurement lives in the run's team-state, not in the report data, so
149
+ the CLI resolves it here rather than teaching the renderer about run
150
+ layout. A missing or timestamp-less state yields None — the header then
151
+ omits the field instead of printing a zero.
152
+ """
153
+ from okstra_ctl.report_view_artifacts import team_state_path_for_report
154
+ from okstra_ctl.time_report import wall_clock_ms
155
+
156
+ state_path = team_state_path_for_report(markdown_path, task_type, seq)
157
+ try:
158
+ state = json.loads(state_path.read_text(encoding="utf-8"))
159
+ except (OSError, ValueError):
160
+ return None
161
+ return wall_clock_ms(state) or None if isinstance(state, dict) else None
162
+
163
+
144
164
  def main(argv: list[str] | None = None) -> int:
145
165
  parser = argparse.ArgumentParser(
146
166
  description="Render the self-contained HTML view of an okstra final-report."
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env python3
2
+ """CLI entrypoint for the Phase 7 translation sidecar.
3
+
4
+ Usage:
5
+ okstra-report-translate.py extract <path-to-final-report.data.json>
6
+ okstra-report-translate.py check <path-to-sidecar.i18n.<lang>.json>
7
+
8
+ ``extract`` writes the translator's work list — every pointer the report holds
9
+ a translatable string at, paired with the English text. The translator fills
10
+ in the values rather than authoring pointers, so a sidecar cannot cite a path
11
+ the document does not have.
12
+
13
+ ``check`` is the translator's own gate before it returns: it resolves every
14
+ pointer in the sidecar against the report and reports what is still English.
15
+ Without it a bad sidecar surfaces at render time, after the worker is gone.
16
+
17
+ This script is the canonical single-reference-point. The Node CLI
18
+ (``bin/okstra report-translate``) is a thin wrapper that spawns it.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import os
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ REPO_ROOT = Path(__file__).resolve().parents[1]
29
+ SCRIPTS_DIR = REPO_ROOT / "scripts"
30
+ HOME_LIB = (
31
+ Path(os.environ.get("OKSTRA_HOME", str(Path.home() / ".okstra")))
32
+ / "lib"
33
+ / "python"
34
+ )
35
+
36
+ # Prefer dev sources when present, fall back to install — a stale install
37
+ # without this module would otherwise shadow the in-repo copy.
38
+ if (SCRIPTS_DIR / "okstra_ctl" / "report_translation.py").is_file():
39
+ if str(SCRIPTS_DIR) in sys.path:
40
+ sys.path.remove(str(SCRIPTS_DIR))
41
+ sys.path.insert(0, str(SCRIPTS_DIR))
42
+ if HOME_LIB.is_dir() and str(HOME_LIB) not in sys.path:
43
+ sys.path.append(str(HOME_LIB))
44
+ elif HOME_LIB.is_dir() and str(HOME_LIB) not in sys.path:
45
+ sys.path.insert(0, str(HOME_LIB))
46
+
47
+ from okstra_ctl.report_translation import extract, overlay # noqa: E402
48
+ from okstra_ctl.final_report_paths import ( # noqa: E402
49
+ translation_sidecar_path,
50
+ translation_source_path,
51
+ )
52
+
53
+
54
+ def _load(path: Path) -> dict:
55
+ try:
56
+ payload = json.loads(path.read_text(encoding="utf-8"))
57
+ except OSError as exc:
58
+ raise SystemExit(f"error: cannot read {path}: {exc}") from exc
59
+ except json.JSONDecodeError as exc:
60
+ raise SystemExit(f"error: {path} is not valid JSON: {exc}") from exc
61
+ if not isinstance(payload, dict):
62
+ raise SystemExit(f"error: {path} must hold a JSON object")
63
+ return payload
64
+
65
+
66
+ def cmd_extract(args: argparse.Namespace) -> int:
67
+ data_path = Path(args.data).resolve()
68
+ data = _load(data_path)
69
+ strings = extract(data)
70
+ out_path = translation_source_path(data_path)
71
+ lang = str((data.get("meta") or {}).get("reportLanguage") or "")
72
+ out_path.write_text(
73
+ json.dumps(
74
+ {
75
+ "lang": lang,
76
+ "sourceData": data_path.name,
77
+ "strings": strings,
78
+ },
79
+ ensure_ascii=False,
80
+ indent=2,
81
+ )
82
+ + "\n",
83
+ encoding="utf-8",
84
+ )
85
+ print(
86
+ json.dumps(
87
+ {
88
+ "ok": True,
89
+ "sourcePath": str(out_path),
90
+ "sidecarPath": str(translation_sidecar_path(data_path, lang)) if lang else "",
91
+ "lang": lang,
92
+ "stringCount": len(strings),
93
+ "charCount": sum(len(v) for v in strings.values()),
94
+ },
95
+ ensure_ascii=False,
96
+ )
97
+ )
98
+ return 0
99
+
100
+
101
+ def cmd_check(args: argparse.Namespace) -> int:
102
+ sidecar_file = Path(args.sidecar).resolve()
103
+ sidecar = _load(sidecar_file)
104
+ strings = sidecar.get("strings")
105
+ if not isinstance(strings, dict):
106
+ raise SystemExit(f"error: {sidecar_file} has no 'strings' object")
107
+ source_name = str(sidecar.get("sourceData") or "")
108
+ if not source_name:
109
+ raise SystemExit(f"error: {sidecar_file} does not name its sourceData")
110
+ data_path = sidecar_file.parent / source_name
111
+ if not data_path.is_file():
112
+ raise SystemExit(f"error: sourceData not found beside the sidecar: {data_path}")
113
+
114
+ _, report = overlay(_load(data_path), strings)
115
+ offered = report.applied + len(report.untranslated)
116
+ payload = {
117
+ "ok": not report.unresolved,
118
+ "applied": report.applied,
119
+ "offered": offered,
120
+ "untranslated": list(report.untranslated),
121
+ "unresolved": list(report.unresolved),
122
+ }
123
+ print(json.dumps(payload, ensure_ascii=False))
124
+ if report.unresolved:
125
+ # A pointer that resolves nowhere means the sidecar was written against
126
+ # a different report. Rendering it would silently drop those strings.
127
+ sys.stderr.write(
128
+ f"error: {len(report.unresolved)} pointer(s) do not resolve in "
129
+ f"{data_path.name}\n"
130
+ )
131
+ return 1
132
+ return 0
133
+
134
+
135
+ def _parser() -> argparse.ArgumentParser:
136
+ parser = argparse.ArgumentParser(
137
+ prog="okstra-report-translate.py",
138
+ description="Build and verify the final-report translation sidecar.",
139
+ )
140
+ sub = parser.add_subparsers(dest="op", required=True)
141
+
142
+ extract_cmd = sub.add_parser("extract", help="write the translator's work list")
143
+ extract_cmd.add_argument("data", help="path to final-report-<type>-<seq>.data.json")
144
+ extract_cmd.set_defaults(func=cmd_extract)
145
+
146
+ check_cmd = sub.add_parser("check", help="verify a filled sidecar against its report")
147
+ check_cmd.add_argument("sidecar", help="path to final-report-<type>-<seq>.i18n.<lang>.json")
148
+ check_cmd.set_defaults(func=cmd_check)
149
+ return parser
150
+
151
+
152
+ def main(argv: list[str] | None = None) -> int:
153
+ args = _parser().parse_args(argv)
154
+ return int(args.func(args))
155
+
156
+
157
+ if __name__ == "__main__":
158
+ raise SystemExit(main())
@@ -64,7 +64,7 @@ This adapter maps the neutral Okstra lead operations to Claude Code host primiti
64
64
  - Follow the core Result Path + terminal-status completion contract. The Claude adapter's wake mechanism is one `Bash(run_in_background: true)` poll covering every pending Result Path, not foreground sleep or an idle-notification dependency. A spawn acknowledgement is never completion.
65
65
  - The background poll uses a per-worker deadline of twice the expected duration: 20 minutes for `requirements-discovery`, 30 for `error-analysis`, 40 for `implementation-planning`, 40 for `implementation`, and 20 for `final-verification`. On timeout, record terminal status and apply the core's single shared retry budget.
66
66
  - Each in-process worker heartbeat audit sidecar must update at least every five minutes while its result is pending. A missing or stale heartbeat consumes the same one-retry budget; after the second silent hang, record `timeout`. The result file remains the authoritative completion signal.
67
- - **The background poll checks liveness, not only Result Paths.** Result Paths change once, at the very end, so polling them alone pays the full deadline for a worker that died at minute three. Each poll iteration MUST also run, in the same background shell, one `okstra worker-liveness` call covering every pending worker — `--audit <audit-sidecar-path>` for each in-process worker, and a paired `--team-state <path> --worker <id>` for each CLI-wrapper worker. The dispatch record's `livenessMode` selects the selector; never infer it from provider or filename. The wrapper selector resolves its prompt path and authoritative dispatch `startedAt` from team-state. It exits non-zero when a worker is `stalled` (heartbeat older than the cadence budget) or `did-not-launch`; either verdict ends the wait for that worker immediately and spends the core's one-retry budget, rather than waiting out the deadline. The command reports only — it never kills or re-dispatches. It shares its heartbeat budget with the Phase 7 audit (`okstra_ctl.worker_heartbeat`), so a worker the live probe passes cannot fail the post-hoc one for cadence.
67
+ - **The background poll checks liveness, not only Result Paths.** Result Paths change once, at the very end, so polling them alone pays the full deadline for a worker that died at minute three. Each poll iteration MUST also run, in the same background shell, one `okstra worker-liveness` call covering every pending worker — one paired `--team-state <path> --worker <id>` per worker, in-process and CLI-wrapper alike. The probe reads that worker row's `livenessMode` to pick the artifact and its `startedAt` as the grace anchor; never pass an artifact path yourself and never infer the transport from provider or filename. It exits non-zero when a worker is `stalled` (heartbeat older than the cadence budget) or `did-not-launch`; either verdict ends the wait for that worker immediately and spends the core's one-retry budget, rather than waiting out the deadline. The command reports only — it never kills or re-dispatches. It shares its heartbeat budget with the Phase 7 audit (`okstra_ctl.worker_heartbeat`), so a worker the live probe passes cannot fail the post-hoc one for cadence.
68
68
  - The Claude Code harness blocks long foreground sleeps and shorter-sleep circumvention loops. Keep the result poll in a single background shell and let wrapper agents use their documented `BashOutput` loop.
69
69
  - On approved cleanup, reconcile the current live session roster before sending shutdown requests. Never target the lead session.
70
70
  - Collect usage before teardown. Resume through the recorded Claude session id and keep all run artifacts authoritative.
@@ -370,6 +370,7 @@ The detailed persistence checklist and the BLOCKING token-usage collector invoca
370
370
 
371
371
  Order of operations:
372
372
 
373
+ 0. **Translation (conditional).** Read `meta.reportLanguage` from the final-report data.json. When it is `en`, skip this step entirely — there is nothing to translate and no worker to pay for. When it is anything else, dispatch `Translator worker` with `**Report Language:**` set to that value and `**Result Path:**` set to `runs/<task-type>/reports/final-report-<task-type>-<seq>.i18n.<lang>.json`. The worker builds its own work list with `okstra report-translate extract` and gates itself with `okstra report-translate check`; Lead verifies the sidecar exists before continuing. The data.json stays English — the sidecar is presentation, overlaid by `render-views` in step 2, and a missing one degrades to an English HTML rather than failing the run.
373
374
  1. Run the token-usage collector with `--substitute-data`. The final-report data.json MUST already exist; the collector populates its token / cost cells and re-renders the markdown sibling.
374
375
  2. Verify both the final-report data.json and rendered markdown exist at the expected paths. When `Report writer worker` is in the roster, that worker authored the data.json + invoked the renderer in Phase 6 — Lead's job here is to **verify** schema validation passes and structure matches the template output.
375
376
  3. Update team-state artifact (preserve usage fields written by the script).
@@ -195,8 +195,10 @@ Token Summary Generation Rules:
195
195
  - **You populate the data.json in Phase 6, BEFORE Phase 7 runs the collector.** Set `tokenUsage.lead.totalTokens` / `.billableTokens` / `.costUsd`, the `worker` and `grand` rows, `tokenUsage.cli.costUsd`, and each `executionStatus[].{totalTokens,billableTokens,costUsd,durationMs,cliTotalTokens,cliCostUsd}` to JSON `null`. The renderer emits `--` for nulls; `okstra-token-usage.py --substitute-data` populates them in Phase 7 and re-renders the markdown. Never set these cells to `0`, `"not-collected"`, `"--"`, `"N/A"`, or any other sentinel: nulls are the only valid placeholder, and the substitution step depends on them being null when it runs.
196
196
  - Set `meta.reportLanguage` to the resolved `en` or `ko` value passed in
197
197
  **Report Language**. `auto` is forbidden in this field — the lead has
198
- already resolved it. The renderer reads this field as SSOT when no
199
- CLI `--report-language` flag is given.
198
+ already resolved it. The field records **which language the human HTML
199
+ renders in**, not the language you author in: you always author English
200
+ (see "Writing Guidelines" below). Phase 7 reads it to decide whether to
201
+ dispatch the translator.
200
202
  - All values come from `usageSummary` (populated by `scripts/okstra-token-usage.py` at the start of Phase 7). Do not estimate or invent.
201
203
  - **Lead** row: `usageSummary.leadTotalTokens` / `usageSummary.leadBillableEquivalentTokens` / `usageSummary.estimatedCostUsd.lead`.
202
204
  - **Worker subtotal** row: `usageSummary.workerTotalTokens` / `usageSummary.workerBillableEquivalentTokens` / `usageSummary.estimatedCostUsd.claudeWorkers`.
@@ -330,17 +332,16 @@ Every field MUST anchor its claim with at least one evidence reference — a `pa
330
332
  - Write in Markdown. **Prefer tables over prose bullet lists** for any section that enumerates multiple items with the same shape (evidence rows, risks, options, dependencies, rollback steps, follow-ups, open questions). Bullets are reserved for short, single-line standalone statements (e.g., "- No additional information requested."). When the template provides a table form, do NOT degrade it back to bullets in the rendered report. **Exception — `## Background and Rationale` (`rationale`) is deliberately prose**: it is connected narrative explaining the *why*, not a same-shape enumeration, so write full sentences there rather than forcing it into a table.
331
333
  - **Do not restate the same conclusion verbatim across sections.** The Verdict Card and Reader Summary are *digests*: give the outcome in one or two sentences and point to `## 7. Final Verdict` / `## 5.8.8 Routing Recommendation` for the full reasoning, rather than copying their multi-clause conclusion word-for-word. Only the `Verdict Token` and `Direction` cells must byte-match §7 (per the Verdict Card contract above). `Next Step` must point to the same routing target as §7, but its actionable command and prose need not be byte-identical. The prose conclusion must not be a duplicate.
332
334
  - **Keep each sentence to one main idea.** A single sentence that stacks four or five clauses with em-dashes and nested parentheticals (300+ characters) is hard to read, and the renderer can only line-break at sentence ends — so break such reasoning into separate sentences. Facts, evidence, and IDs still live in the tables; prose carries only the connective *why*.
333
- - Write the final report body in the language passed in **Report Language**
334
- above (`en` or `ko`). The template's fixed labels (section asides,
335
- empty-states, token summary, column headers, release-handoff labels)
336
- are i18n-rendered by `okstra-render-final-report.py` from
337
- `templates/reports/i18n/<lang>.json`; do not translate those focus
338
- on the prose you author (Section 6 categories, Section 2 evidence
339
- narratives, Section 5 risks, Section 3 recommendations, etc.).
340
- Code identifiers, file paths, model names, status tokens, and the
341
- validator-checked English substrings (`Option Candidates`,
342
- `Verdict Token`, `accepted`/`conditional-accept`/`blocked`, etc.)
343
- stay in English regardless of Report Language.
335
+ - **Write the report body in English, whatever the Report Language is.**
336
+ The data.json is the SSOT every later phase, validator and agent reads,
337
+ and its AI-handoff Markdown sibling has the same audience, so both stay
338
+ in one language. Only the human HTML follows the reader: when
339
+ **Report Language** is not `en`, Phase 7 dispatches the translator
340
+ worker, which writes a sidecar the HTML renderer overlays. You never
341
+ author that sidecar and never write a second language into the data.json.
342
+ The HTML's own fixed strings — headings, column headers, empty states,
343
+ enum labels come from `templates/reports/html/i18n/<lang>.json` and
344
+ are not yours to write either.
344
345
  - If only one worker is usable, perform a reduced-confidence synthesis
345
346
  - If evidence is insufficient, explicitly state "I don't know"
346
347
  - If expected values are present in `reference-expectations.md`, list matches, gaps, and missing evidence separately
@@ -125,7 +125,7 @@ Terminal statuses that can be recorded for a worker:
125
125
 
126
126
  Between wakes, `okstra worker-liveness` is the **only** sanctioned way to ask whether a pending worker is still alive. Do NOT hand-roll a polling script, an `ls` / `stat` loop, or any ad-hoc file-existence check: a lead that writes its own probe owns that probe's bugs, and those bugs surface as *worker* failures — a shell quoting slip silently turns the probe into a no-op that reports health it never measured.
127
127
 
128
- Each probe matches exactly one dispatch backend. The dispatch record's `livenessMode` is authoritative: `audit-heartbeat` registers the in-process audit sidecar, while `wrapper-status` registers the CLI wrapper status sidecar. Do not infer either transport from a worker filename or provider name.
128
+ Each probe matches exactly one dispatch backend. The worker row's `livenessMode` is authoritative and the probe reads it for you: `audit-heartbeat` registers the in-process audit sidecar, while `wrapper-status` registers the CLI wrapper status sidecar. Do not infer either transport from a worker filename or provider name, and do not pass the artifact path yourself.
129
129
 
130
130
  | Worker | Backend | Flag | What it reads |
131
131
  |---|---|---|---|
@@ -148,7 +148,7 @@ After each worker subagent returns (regardless of role), Lead MUST verify the ca
148
148
  - The result file is absent at the resolved absolute path even though the worker returned without a `*_RESULT_MISSING` sentinel — for example, claude-worker returned its final assistant message but never persisted the artifact, or the wrapper exited 0 and the codex/antigravity sub-agent forwarded raw stdout despite the contract.
149
149
  - The result file exists but cannot be parsed (frontmatter unreadable, sections 1–5 entirely missing). A truncated file in the middle of section 5 is NOT covered here — it goes to the validator's regular `error` path, not the retry path.
150
150
  - `okstra worker-liveness --team-state <path> --worker <id>` reports a **CLI-wrapper** worker (`codex` / `antigravity`) `did-not-launch` — neither `<prompt-path>.log` nor `<prompt-path>.status.json` exists after the persisted `startedAt` plus the launch grace (default 60s). The wrapper writes its status sidecar before invoking the CLI and hard-fails loudly with a distinct exit code on every argument check before that, so the absence of BOTH artifacts means the dispatch itself never reached the script. Without this trigger the only evidence was a lead noticing two missing files by eye, and the run paid the full polling cap for a worker that never started.
151
- - `okstra worker-liveness --audit` reports an **in-process** worker `stalled` — its registered audit sidecar's newest `- PROGRESS:` heartbeat is older than the cadence budget, or the sidecar carries no heartbeat at all. This is the in-process equivalent of the CLI wrappers' idle watchdog: the wrapper reaps a silent CLI itself, but nothing reaped a silent in-process worker until its deadline.
151
+ - `okstra worker-liveness --team-state <path> --worker <id>` reports an **in-process** worker `stalled` — its registered audit sidecar's newest `- PROGRESS:` heartbeat is older than the cadence budget, or the sidecar carries no heartbeat at all. This is the in-process equivalent of the CLI wrappers' idle watchdog: the wrapper reaps a silent CLI itself, but nothing reaped a silent in-process worker until its deadline. The same selector serves both worker kinds — the sidecar is reused when a worker is re-dispatched, so the probe needs the row's `startedAt` to tell the previous attempt's last heartbeat apart from this dispatch's silence.
152
152
  - The result file exists but its audit sidecar does not, at `runs/<task-type>/worker-results/<worker>-audit-<task-type>-<seq>.md`. Workers write both in the same step, so a result without a sidecar means the Reading Confirmation block — the only evidence the worker read its inputs — was never produced. `validate-run.py` fails the run on this at Phase 7 either way (`validate_worker_results_audit`); checking it here spends the existing one-retry budget while the role can still be re-dispatched, instead of surfacing hours later when the worker session is gone.
153
153
 
154
154
  **One-retry policy:**
@@ -18,6 +18,24 @@
18
18
  - scan scope and excluded areas
19
19
  - components, dependency directions, entry points, repositories, and external integrations
20
20
  - shallow feature index and unresolved navigation questions
21
+ - Fill these when the repository states them; leave a block out rather than guessing at it:
22
+ - `projectAnalysis.techStack` — the languages the project is written in with their
23
+ versions, the frameworks it runs on, and the package manager, build, test, lint,
24
+ typecheck, format, CI and container tools. Record where each version was read via
25
+ `versionSource`: a manifest range (`^1.17.0`) and a lockfile pin are different facts,
26
+ and a mismatch between a pinned server image and a ranged client is a real
27
+ compatibility risk that collapses if both are written the same way. A tool the
28
+ project does not have is not a row — its absence belongs in `qualityCoverage`.
29
+ - `projectAnalysis.internalInterfaces` — the seams components call across, with the
30
+ signature a caller writes against. `kind` is what the seam is, not where its file
31
+ sits: `port` for an abstraction a domain declares, `adapter` for an implementation
32
+ that satisfies one. `ownerComponentId` and every `consumers` entry must name a
33
+ component this report declares.
34
+ - `projectAnalysis.workflows` — one run of real work through the system, start to
35
+ finish: what triggers it and which component does what, in order. This is the only
36
+ block that says how the parts are used rather than how they are arranged, so a
37
+ reader can follow a request without reconstructing it from the dependency graph.
38
+ Each step's `componentId` must name a declared component.
21
39
  - Cross-verification mode:
22
40
  - Phase 5.5 convergence runs in adversarial mode (`convergence.adversarial=true`).
23
41
  - Non-goals:
@@ -1,5 +1,8 @@
1
1
  # Release Handoff Profile
2
2
 
3
+ - Record the handoff shape in `releaseHandoff.handoffScope`: `mode` always, plus `stages`
4
+ and `collectorBranch` in stage-group mode. The report is the only place a reader learns
5
+ which stages shipped — the collector branch name does not say.
3
6
  - Purpose: take an `accepted` final-verification verdict for an already-committed implementation branch and turn it into a delivered push and/or pull request, with explicit user selection at every mutating step. Two modes: **whole-task** (default — the verified task branch becomes one PR) and **stage-group** (a user-selected subset of verified stages is merged into a collector branch and becomes one PR).
4
7
  - **Execution model: single-lead, no worker dispatch.** This phase is a thin orchestrator over `git` / `gh`; it does NOT dispatch teammates, does NOT dispatch analysis or drafter sub-agents, and does NOT run convergence. The host-native Okstra lead performs every step inline (drafting PR text, asking the user, running git / gh, writing the final report) — see "Lead-only contract" below.
5
8
  - Worker roster: none — this profile intentionally has no `- Required workers:` block; the run is executed entirely by the Okstra lead.
@@ -25,6 +25,13 @@
25
25
  - classify the work as bugfix, feature, improvement, refactor, or ops
26
26
  - determine whether `error-analysis` or `implementation-planning` is the next safe step. Direct `implementation` handoff is never a valid routing target — implementation requires an approved `implementation-planning` report
27
27
  - capture the reporter's **rejection criteria** — the delivered outcome that would make this work wrong or unacceptable — as a routing input. Consume it from the brief's `Desired Outcome` / `Out of Scope` / `Source Material` when present; when it is absent AND it would change the classification (e.g. bugfix vs feature) or the next-phase choice, raise it as one `decision` clarification row with `Evidence checked: none — reporter intent`. Never infer it — this is a reporter-intent signal, the mirror of improvement-discovery's `Anti-goals`
28
+ - record the rejection criteria in `requirementsDiscovery.rejectionCriteria` with the
29
+ `source` that produced it. When it was absent and would not have changed the
30
+ classification or the routing, say so with `source: absent-not-material` — an empty
31
+ field cannot be told apart from a phase that never looked
32
+ - record the terminology this phase settled in `requirementsDiscovery.domainAlignment`:
33
+ whether the glossary and decision records were read, and for every fuzzy or overloaded
34
+ term, the single canonical form and what decided it
28
35
  - identify missing materials that block reliable routing
29
36
  - define task continuity expectations for long-running work under the same task key
30
37
  - capture approval or confirmation points before the next phase starts
@@ -456,7 +456,23 @@
456
456
  }
457
457
  },
458
458
  "workers_override": {
459
- "label": "참여시킬 분석 워커를 선택해주세요 (최소 1개). report-writer 는 항상 포함됩니다.",
459
+ "label": "이번 run 에 추가할 분석 워커를 선택해주세요 (최소 1개, 여러 가능).\n기본 워커와 report-writer 는 항상 참여하므로 목록에 없습니다 — 기본 워커에서 일부를 빼려면 '직접 선택'을 고르세요.",
460
+ "echo_template": "workers: {value}",
461
+ "labels": {
462
+ "default_roster": "기본 워커만 ({workers}) — 옵션 워커 추가 없음",
463
+ "add_optional": "{worker} 추가 (옵션)"
464
+ },
465
+ "options": {
466
+ "__free_input__": "직접 선택 (기본 워커까지 포함한 전체 목록에서 고르기)"
467
+ },
468
+ "errors": {
469
+ "min_one_required": "워커를 최소 1개 선택해주세요",
470
+ "custom_must_be_alone": "'직접 선택'은 다른 항목과 함께 고를 수 없습니다 — 단독으로 선택해주세요",
471
+ "unknown_option": "목록에 없는 항목입니다: {values}"
472
+ }
473
+ },
474
+ "workers_custom": {
475
+ "label": "참여시킬 분석 워커를 직접 선택해주세요 (최소 1개). report-writer 는 항상 포함됩니다.",
460
476
  "echo_template": "workers: {value}",
461
477
  "options": {
462
478
  "_OPTIONAL_SUFFIX": " (옵션)"
@@ -5,7 +5,7 @@ import json
5
5
  import re
6
6
  import subprocess
7
7
  from dataclasses import dataclass
8
- from datetime import datetime
8
+ from datetime import datetime, timezone
9
9
  from pathlib import Path
10
10
  from typing import Mapping, Sequence
11
11
 
@@ -73,7 +73,6 @@ _REPORT_NAME_RE = re.compile(r"^final-report-.+-(?P<run_seq>[^-]+)\.md$")
73
73
  _FEATURE_ID_RE = re.compile(r"PF-\d{3}")
74
74
  _FULL_COMMIT_RE = re.compile(r"[0-9a-f]{40}")
75
75
  _RUN_SEQ_RE = re.compile(r"[0-9]{3}")
76
- _CREATED_AT_RE = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z")
77
76
 
78
77
 
79
78
  def parse_evidence_paths(
@@ -149,15 +148,31 @@ def _validated_commit(value: str, field: str) -> str:
149
148
 
150
149
 
151
150
  def _validated_created_at(value: str) -> str:
152
- if _CREATED_AT_RE.fullmatch(value) is None:
153
- raise AnalysisInputError(
154
- "data.json header.createdAt must use YYYY-MM-DDTHH:MM:SSZ"
155
- )
151
+ """The candidate's creation instant in canonical UTC ``...Z`` form.
152
+
153
+ What this field must give the caller is one *comparable* value — candidates
154
+ are ordered by it. It is not a spelling contract: the report schema requires
155
+ only a non-empty string, nothing tells a report-writer to emit UTC, and
156
+ `validate-run` accepts a local offset. Demanding `Z` here made this loader
157
+ the only reader that rejected a report every other reader had accepted, and
158
+ the run lost its carry-in candidates without saying why. So normalise.
159
+
160
+ An instant with no offset is still refused: guessing its zone would order
161
+ it wrongly against the others.
162
+ """
156
163
  try:
157
- datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
164
+ instant = datetime.fromisoformat(value.replace("Z", "+00:00"))
158
165
  except ValueError as exc:
159
- raise AnalysisInputError("data.json header.createdAt is invalid") from exc
160
- return value
166
+ raise AnalysisInputError(
167
+ "data.json header.createdAt must be an ISO-8601 instant "
168
+ "(e.g. 2026-08-05T04:25:00Z or 2026-08-05T04:25:00+09:00)"
169
+ ) from exc
170
+ if instant.tzinfo is None:
171
+ raise AnalysisInputError(
172
+ "data.json header.createdAt carries no UTC offset, so it cannot be "
173
+ "ordered against the other candidates"
174
+ )
175
+ return instant.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
161
176
 
162
177
 
163
178
  def _report_path_metadata(project_root: Path, report_path: Path) -> tuple[Path, str, str]:
@@ -299,17 +299,39 @@ def _top_level_bullet_heading(line: str) -> str:
299
299
  return label
300
300
 
301
301
 
302
+ # Report headings carry a section number and the renderer's scroll anchor
303
+ # (`## 1. Clarification Items <a id="1-clarification-items"></a>`), while the
304
+ # section names above are written bare. Keying a heading by both spellings is
305
+ # what lets a lookup for `Clarification Items` find it — without the alias the
306
+ # Carry-In Extract rendered `No matching source sections were available` for
307
+ # every carry-in ever staged. No section name starts with a digit, so the
308
+ # stripped alias cannot collide with a name meant to be matched literally.
309
+ _HEADING_NUMBER_RE = re.compile(r"^\d+(?:\.\d+)*\.?\s+")
310
+ _HEADING_ANCHOR_RE = re.compile(r'\s*<a id="[^"]*"></a>\s*$')
311
+
312
+
313
+ def _heading_alias(heading: str) -> str:
314
+ return _HEADING_NUMBER_RE.sub("", _HEADING_ANCHOR_RE.sub("", heading)).strip()
315
+
316
+
302
317
  def _section_map(text: str) -> dict[str, str]:
303
318
  result: dict[str, list[str]] = {}
319
+ aliases: dict[str, str] = {}
304
320
  current = ""
305
321
  for line in _strip_frontmatter(text).splitlines():
306
322
  if line.startswith("## "):
307
323
  current = line[3:].strip()
308
324
  result.setdefault(current, [])
325
+ alias = _heading_alias(current)
326
+ if alias and alias != current:
327
+ aliases.setdefault(alias, current)
309
328
  continue
310
329
  if current:
311
330
  result[current].append(line)
312
- return {key: "\n".join(lines).strip() for key, lines in result.items()}
331
+ sections = {key: "\n".join(lines).strip() for key, lines in result.items()}
332
+ for alias, heading in aliases.items():
333
+ sections.setdefault(alias, sections[heading])
334
+ return sections
313
335
 
314
336
 
315
337
  def _strip_frontmatter(text: str) -> str: