okstra 0.149.0 → 0.151.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 (57) hide show
  1. package/docs/architecture.md +1 -1
  2. package/docs/cli.md +2 -2
  3. package/docs/project-structure-overview.md +1 -1
  4. package/package.json +3 -2
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/agents/workers/report-writer-worker.md +8 -0
  7. package/runtime/agents/workers/translator-worker.md +67 -0
  8. package/runtime/bin/okstra-render-final-report.py +0 -11
  9. package/runtime/bin/okstra-report-translate.py +191 -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/plan-body-verification.md +1 -1
  13. package/runtime/prompts/lead/report-writer.md +16 -15
  14. package/runtime/prompts/lead/team-contract.md +2 -2
  15. package/runtime/prompts/wizard/prompts.ko.json +17 -1
  16. package/runtime/python/okstra_ctl/analysis_inputs.py +24 -9
  17. package/runtime/python/okstra_ctl/analysis_packet.py +23 -1
  18. package/runtime/python/okstra_ctl/clarification_items.py +241 -44
  19. package/runtime/python/okstra_ctl/codex_dispatch.py +2 -1
  20. package/runtime/python/okstra_ctl/convergence.py +15 -1
  21. package/runtime/python/okstra_ctl/dispatch_core.py +2 -2
  22. package/runtime/python/okstra_ctl/dispatch_state.py +12 -1
  23. package/runtime/python/okstra_ctl/final_report_paths.py +22 -1
  24. package/runtime/python/okstra_ctl/i18n.py +12 -7
  25. package/runtime/python/okstra_ctl/render_final_report.py +18 -17
  26. package/runtime/python/okstra_ctl/report_finalize.py +18 -0
  27. package/runtime/python/okstra_ctl/report_html/filters.py +15 -77
  28. package/runtime/python/okstra_ctl/report_html/render.py +44 -2
  29. package/runtime/python/okstra_ctl/report_translation.py +469 -0
  30. package/runtime/python/okstra_ctl/report_views.py +23 -9
  31. package/runtime/python/okstra_ctl/run.py +1 -1
  32. package/runtime/python/okstra_ctl/user_response.py +11 -6
  33. package/runtime/python/okstra_ctl/wizard.py +100 -25
  34. package/runtime/python/okstra_ctl/worker_liveness.py +130 -36
  35. package/runtime/templates/reports/html/base.template.html +12 -12
  36. package/runtime/templates/reports/html/i18n/en.json +395 -0
  37. package/runtime/templates/reports/html/i18n/ko.json +395 -0
  38. package/runtime/templates/reports/html/macros/forms.html +16 -16
  39. package/runtime/templates/reports/html/macros/visualizations.html +2 -2
  40. package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +17 -17
  41. package/runtime/templates/reports/html/tasks/error-analysis.template.html +12 -12
  42. package/runtime/templates/reports/html/tasks/feature-analysis.template.html +16 -16
  43. package/runtime/templates/reports/html/tasks/final-verification.template.html +12 -12
  44. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +37 -37
  45. package/runtime/templates/reports/html/tasks/implementation.template.html +18 -18
  46. package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +7 -7
  47. package/runtime/templates/reports/html/tasks/project-analysis.template.html +29 -29
  48. package/runtime/templates/reports/html/tasks/release-handoff.template.html +13 -13
  49. package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +14 -14
  50. package/runtime/templates/reports/report.js +8 -5
  51. package/runtime/validators/validate-report-views.py +1 -1
  52. package/runtime/validators/validate-run.py +59 -31
  53. package/src/cli-registry.mjs +11 -0
  54. package/src/commands/inspect/worker-liveness.mjs +9 -7
  55. package/src/commands/report/translate.mjs +31 -0
  56. package/src/lib/helper-scripts.mjs +1 -0
  57. 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
@@ -720,7 +720,7 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
720
720
  | `okstra convergence apply-critic-gaps --work-state <path> --results <path>` | Apply one verified coverage-critic batch after the main queue reaches a terminal state |
721
721
  | `okstra convergence finalize --work-state <path> --output <path>` | Materialize the terminal schema v1.3 convergence state |
722
722
  | `okstra convergence validate --state <path> --kind <working\|final>` | Validate replayable working state or a terminal final state |
723
- | `okstra convergence example --kind <groups\|round-results\|critic-results>` | Print one deterministic valid input example as JSON |
723
+ | `okstra convergence example --kind <groups\|round-results\|critic-results>` | Print one deterministic valid input example as JSON. `groups` feeds `seed --groups` and `round-results` feeds `apply-round --results`; `critic-results` is the critic worker's own result document and is **not** the `apply-critic-gaps --results` input — that input is the coverage batch the lead assembles from those candidates plus each analyser's vote (`dispatches[]` / `gaps[]` / `modelExecutionValue`) |
724
724
  | `okstra plan-items extract --data <data.json> --output <items.json>` | Deterministically extract the complete implementation-planning `P-*` queue from report-writer data.json |
725
725
  | `okstra plan-items validate --data <data.json> --items <items.json>` | Require the persisted `P-*` queue to match a fresh deterministic extraction exactly |
726
726
  | `okstra config <get\|set\|unset\|show> [key] [value] [--scope project\|global\|all]` | Manage persistent settings such as `pr-template-path` with atomic JSON writes |
@@ -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.149.0",
3
+ "version": "0.151.0",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -38,6 +38,7 @@
38
38
  "test:js": "node --test tests-js/*.test.mjs",
39
39
  "test:py": "python3 -m pytest tests/",
40
40
  "test:workflow": "bash validators/validate-workflow.sh",
41
- "check": "npm run build && npm run test:js && npm run test:py && npm run test:workflow"
41
+ "test:e2e": "bash tests-e2e/run-all.sh",
42
+ "check": "npm run build && npm run test:js && npm run test:py && npm run test:workflow && npm run test:e2e"
42
43
  }
43
44
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.149.0",
3
- "builtAt": "2026-08-04T18:04:11.228Z",
2
+ "package": "0.151.0",
3
+ "builtAt": "2026-08-05T06:06:04.770Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -19,6 +19,14 @@ tools: ["Bash", "Read", "Write", "Edit", "Glob", "Grep", "TodoWrite", "WebFetch"
19
19
  - The `**Report Language:**` header in your dispatch prompt is already
20
20
  resolved to `en` or `ko` by the lead. Copy it verbatim into
21
21
  `data.json.meta.reportLanguage`. Never write `auto` here.
22
+ - **That header is not the language you write in — you always write English.**
23
+ It names the language the human HTML renders in, and Phase 7 reads it to
24
+ decide whether to dispatch a translator. The data.json is the SSOT every
25
+ later phase, validator and agent reads, so authoring it in anything but
26
+ English splits the record. **Enforced:** `okstra report-finalize` runs
27
+ `report-translate check-source` as its first Phase 7 step and fails the run
28
+ when the data.json's authored prose is not English. You can run that check
29
+ yourself before returning.
22
30
 
23
31
  ## Authority
24
32
 
@@ -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)
@@ -0,0 +1,191 @@
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 ( # noqa: E402
48
+ HANGUL_PROSE_LIMIT,
49
+ extract,
50
+ hangul_share,
51
+ overlay,
52
+ )
53
+ from okstra_ctl.final_report_paths import ( # noqa: E402
54
+ translation_sidecar_path,
55
+ translation_source_path,
56
+ )
57
+
58
+
59
+ def _load(path: Path) -> dict:
60
+ try:
61
+ payload = json.loads(path.read_text(encoding="utf-8"))
62
+ except OSError as exc:
63
+ raise SystemExit(f"error: cannot read {path}: {exc}") from exc
64
+ except json.JSONDecodeError as exc:
65
+ raise SystemExit(f"error: {path} is not valid JSON: {exc}") from exc
66
+ if not isinstance(payload, dict):
67
+ raise SystemExit(f"error: {path} must hold a JSON object")
68
+ return payload
69
+
70
+
71
+ def cmd_extract(args: argparse.Namespace) -> int:
72
+ data_path = Path(args.data).resolve()
73
+ data = _load(data_path)
74
+ strings = extract(data)
75
+ out_path = translation_source_path(data_path)
76
+ lang = str((data.get("meta") or {}).get("reportLanguage") or "")
77
+ out_path.write_text(
78
+ json.dumps(
79
+ {
80
+ "lang": lang,
81
+ "sourceData": data_path.name,
82
+ "strings": strings,
83
+ },
84
+ ensure_ascii=False,
85
+ indent=2,
86
+ )
87
+ + "\n",
88
+ encoding="utf-8",
89
+ )
90
+ print(
91
+ json.dumps(
92
+ {
93
+ "ok": True,
94
+ "sourcePath": str(out_path),
95
+ "sidecarPath": str(translation_sidecar_path(data_path, lang)) if lang else "",
96
+ "lang": lang,
97
+ "stringCount": len(strings),
98
+ "charCount": sum(len(v) for v in strings.values()),
99
+ },
100
+ ensure_ascii=False,
101
+ )
102
+ )
103
+ return 0
104
+
105
+
106
+ def cmd_check(args: argparse.Namespace) -> int:
107
+ sidecar_file = Path(args.sidecar).resolve()
108
+ sidecar = _load(sidecar_file)
109
+ strings = sidecar.get("strings")
110
+ if not isinstance(strings, dict):
111
+ raise SystemExit(f"error: {sidecar_file} has no 'strings' object")
112
+ source_name = str(sidecar.get("sourceData") or "")
113
+ if not source_name:
114
+ raise SystemExit(f"error: {sidecar_file} does not name its sourceData")
115
+ data_path = sidecar_file.parent / source_name
116
+ if not data_path.is_file():
117
+ raise SystemExit(f"error: sourceData not found beside the sidecar: {data_path}")
118
+
119
+ _, report = overlay(_load(data_path), strings)
120
+ offered = report.applied + len(report.untranslated)
121
+ payload = {
122
+ "ok": not report.unresolved,
123
+ "applied": report.applied,
124
+ "offered": offered,
125
+ "untranslated": list(report.untranslated),
126
+ "unresolved": list(report.unresolved),
127
+ }
128
+ print(json.dumps(payload, ensure_ascii=False))
129
+ if report.unresolved:
130
+ # A pointer that resolves nowhere means the sidecar was written against
131
+ # a different report. Rendering it would silently drop those strings.
132
+ sys.stderr.write(
133
+ f"error: {len(report.unresolved)} pointer(s) do not resolve in "
134
+ f"{data_path.name}\n"
135
+ )
136
+ return 1
137
+ return 0
138
+
139
+
140
+ def cmd_check_source(args: argparse.Namespace) -> int:
141
+ data_path = Path(args.data).resolve()
142
+ share, length = hangul_share(_load(data_path))
143
+ payload = {
144
+ "ok": share < HANGUL_PROSE_LIMIT,
145
+ "hangulShare": round(share, 4),
146
+ "limit": HANGUL_PROSE_LIMIT,
147
+ "proseChars": length,
148
+ }
149
+ print(json.dumps(payload, ensure_ascii=False))
150
+ if not payload["ok"]:
151
+ sys.stderr.write(
152
+ f"error: {data_path.name} was authored in Korean "
153
+ f"({share:.0%} of its prose, limit {HANGUL_PROSE_LIMIT:.0%}). "
154
+ "The data.json is the English SSOT every later phase reads; the "
155
+ "report language selects the human HTML's language and is served "
156
+ "by the Phase 7 translator, not by authoring the SSOT in it.\n"
157
+ )
158
+ return 1
159
+ return 0
160
+
161
+
162
+ def _parser() -> argparse.ArgumentParser:
163
+ parser = argparse.ArgumentParser(
164
+ prog="okstra-report-translate.py",
165
+ description="Build and verify the final-report translation sidecar.",
166
+ )
167
+ sub = parser.add_subparsers(dest="op", required=True)
168
+
169
+ extract_cmd = sub.add_parser("extract", help="write the translator's work list")
170
+ extract_cmd.add_argument("data", help="path to final-report-<type>-<seq>.data.json")
171
+ extract_cmd.set_defaults(func=cmd_extract)
172
+
173
+ check_cmd = sub.add_parser("check", help="verify a filled sidecar against its report")
174
+ check_cmd.add_argument("sidecar", help="path to final-report-<type>-<seq>.i18n.<lang>.json")
175
+ check_cmd.set_defaults(func=cmd_check)
176
+
177
+ source_cmd = sub.add_parser(
178
+ "check-source", help="verify the data.json itself was authored in English"
179
+ )
180
+ source_cmd.add_argument("data", help="path to final-report-<type>-<seq>.data.json")
181
+ source_cmd.set_defaults(func=cmd_check_source)
182
+ return parser
183
+
184
+
185
+ def main(argv: list[str] | None = None) -> int:
186
+ args = _parser().parse_args(argv)
187
+ return int(args.func(args))
188
+
189
+
190
+ if __name__ == "__main__":
191
+ 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).
@@ -175,7 +175,7 @@ Plan-body verification stays **lightweight** even under this posture — the `ve
175
175
 
176
176
  1. Lead runs `okstra plan-items extract --data <data.json> --output <state>/plan-items-....json`, places the persisted `items[]` verbatim in every verifier prompt with the compact `subject` and lossless `payload`, then runs `okstra plan-items validate --data <data.json> --items <state>/plan-items-....json`. Dispatch only after that exact-match validation succeeds.
177
177
  2. For each analyser worker in the roster (`claude`, `codex`, and `antigravity` if opted in), lead constructs a reverify prompt using the template in §"Plan-body reverify prompt" below.
178
- 3. Dispatch uses the same wrapper infrastructure as finding convergence. The `--role-slug` is `<role>-plan-verify-r<N>`. Result file path: `runs/<task-type>/worker-results/<role-slug>-plan-verify-r<N>-implementation-planning-<seq>.md`.
178
+ 3. Dispatch uses the same wrapper infrastructure as finding convergence, so the `--role-slug` is the same canonical `<role>-worker` that convergence uses — not a round-specific slug. Result file path: `runs/<task-type>/worker-results/<role>-worker-plan-verify-r<N>-implementation-planning-<seq>.md` (e.g. `codex-worker-plan-verify-r1-implementation-planning-003.md`). The `-worker-` token is load-bearing twice over: §"Plan-body reverify prompt" requires the same anchor headers as convergence, whose `**Audit sidecar path:**` is derived by `okstra_ctl.worker_artifact_paths.audit_sidecar_rel()` inserting `-audit-` after that token — a slug without it makes the header underivable and the helper raises. Record each `planItems[].verdicts[].worker` as the same `<role>-worker` string, because provenance compares it to this filename's prefix. **Enforced:** `tests/contract/test_reverify_dispatch_anchors.py` derives the sidecar from the documented name and re-extracts the prefix the provenance resolver uses.
179
179
  **Verdict provenance (BLOCKING).** Every verdict recorded in `planItems[].verdicts[]` MUST trace back to a dispatch that actually returned a result file at the path above. The whole gate — classification, self-fix eligibility, promotion, `gateBlockedBy` — is computed from these votes, so an unbacked vote lets the round be skipped while the gate still reads `passed`. **Enforced:** `validators/validate-run.py` `_validate_plan_body_verdict_provenance` fails any `verdicts[].worker` with no matching `<worker>-plan-verify-r<N>-<task-type>-<seq>.md` result file. Recording a `verification-error` for a dispatch that produced no result is the correct way to represent a failed worker — inventing an `AGREE` is a contract violation.
180
180
 
181
181
  4. After all dispatches return, lead aggregates verdicts per `P-*` item across workers and classifies each:
@@ -176,7 +176,7 @@ Table Generation Rules:
176
176
 
177
177
  Place this section immediately after the execution status table.
178
178
 
179
- Example (English mode shown the renderer substitutes Korean when `meta.reportLanguage = "ko"`):
179
+ Example (the Markdown renders in English whatever `meta.reportLanguage` says):
180
180
 
181
181
  ```markdown
182
182
  ### Token Usage Summary
@@ -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`.
@@ -231,7 +233,7 @@ The rows below mirror `PLANNING_REQUIRED_SECTIONS` in `validators/validate-run.p
231
233
 
232
234
  `Implementation Design Preparation` is NOT in the substring list: `templates/reports/final-report.template.md` §5.5.10 renders that heading from `implementationPlanning.designPreparation`, so it is enforced by the schema + renderer rather than by a heading scan.
233
235
 
234
- The Korean translation in parentheses is optional but the English keyword is mandatory. The body of each section is written in the Report Language per the writing rules below. For non-`implementation-planning` runs, omit this entire block — these headings are NOT validator-checked for other task-types.
236
+ The English keyword is mandatory and the body of each section is written in English, like everything else you author see "Writing Guidelines" below. For non-`implementation-planning` runs, omit this entire block — these headings are NOT validator-checked for other task-types.
235
237
 
236
238
  The final-report template `templates/reports/final-report.template.md` Section 5.5 already encodes this contract — copy that block verbatim and fill in.
237
239
 
@@ -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:**
@@ -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]: