okstra 0.198.1 → 0.198.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.198.1",
3
+ "version": "0.198.2",
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.198.1",
3
- "builtAt": "2026-09-11T12:26:44.689Z",
2
+ "package": "0.198.2",
3
+ "builtAt": "2026-09-11T13:42:28.980Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -32,7 +32,11 @@ from okstra_bootstrap import prefer_colocated_modules # noqa: E402
32
32
 
33
33
  prefer_colocated_modules(__file__, "okstra_ctl/report_translation.py")
34
34
 
35
- from okstra_ctl.report_translation import extract, overlay # noqa: E402
35
+ from okstra_ctl.report_translation import ( # noqa: E402
36
+ extract,
37
+ group_translation_pointers,
38
+ overlay,
39
+ )
36
40
  from okstra_ctl.final_report_paths import ( # noqa: E402
37
41
  translation_sidecar_path,
38
42
  translation_source_path,
@@ -82,6 +86,7 @@ def _report_snapshot(path: Path):
82
86
  def _source_payload(
83
87
  authority: RunArtifactAuthority, data_path: Path, raw_bytes: bytes, data: dict
84
88
  ) -> dict:
89
+ strings = extract(data)
85
90
  return {
86
91
  "taskKey": authority.task_key,
87
92
  "runManifestPath": authority.manifest_ref,
@@ -89,7 +94,8 @@ def _source_payload(
89
94
  "sourceDataPath": data_path.relative_to(authority.project_root).as_posix(),
90
95
  "sourceDataSha256": hashlib.sha256(raw_bytes).hexdigest(),
91
96
  "lang": str((data.get("meta") or {}).get("reportLanguage") or ""),
92
- "strings": extract(data),
97
+ "strings": strings,
98
+ "translationGroups": group_translation_pointers(data, strings),
93
99
  }
94
100
 
95
101
 
@@ -108,6 +114,10 @@ def _validate_source_payload(
108
114
  raise SystemExit(
109
115
  "error: report changed after translation source publication"
110
116
  )
117
+ if "translationGroups" in source and (
118
+ source["translationGroups"] != expected["translationGroups"]
119
+ ):
120
+ raise SystemExit("error: translation groups do not match report source")
111
121
 
112
122
 
113
123
  def cmd_extract(args: argparse.Namespace) -> int:
@@ -154,9 +164,13 @@ def cmd_source(args: argparse.Namespace) -> int:
154
164
  print(line("Report", payload["sourceDataPath"]), end="")
155
165
  print(line("Run manifest", authority.manifest_ref), end="")
156
166
  print(line("Source digest", payload["sourceDataSha256"]), end="")
157
- print(line("String count", len(strings)), end="")
158
- for index, source in enumerate(strings.values(), 1):
159
- print(f"\n## T-{index:03d}\n{block(source)}")
167
+ groups = payload["translationGroups"]
168
+ print(line("Field count", len(strings)), end="")
169
+ print(line("String count", len(groups)), end="")
170
+ print(line("Source characters", sum(len(value) for value in strings.values())), end="")
171
+ print(line("Translation characters", sum(len(strings[row[0]]) for row in groups)), end="")
172
+ for index, pointers in enumerate(groups, 1):
173
+ print(f"\n## T-{index:03d}\n{block(strings[pointers[0]])}")
160
174
  return 0
161
175
 
162
176
 
@@ -212,12 +226,14 @@ def cmd_write(args: argparse.Namespace) -> int:
212
226
  source = _load(source_path)
213
227
  _validate_source_payload(source, expected, args.source_digest)
214
228
  sources = expected["strings"]
229
+ # 발행된 구형 작업 목록은 필드당 한 블록이므로 진행 중 번역의 번호를 보존한다.
230
+ groups = source.get("translationGroups", [[pointer] for pointer in sources])
215
231
  translated = _translation_blocks(Path(args.translations))
216
- if len(translated) != len(sources):
232
+ if len(translated) != len(groups):
217
233
  raise SystemExit(
218
234
  f"error: translation blocks must match every T-NNN item — "
219
235
  f"{len(translated)} blocks in {Path(args.translations).name}, "
220
- f"{len(sources)} items in the translation source"
236
+ f"{len(groups)} items in the translation source"
221
237
  )
222
238
  empty = [f"T-{index + 1:03d}" for index, value in enumerate(translated) if not value]
223
239
  if empty:
@@ -229,7 +245,11 @@ def cmd_write(args: argparse.Namespace) -> int:
229
245
  sidecar = translation_sidecar_path(data_path, lang)
230
246
  if sidecar.is_symlink():
231
247
  raise SystemExit("error: translation sidecar path is a symlink")
232
- strings = dict(zip(sources, translated))
248
+ strings = {
249
+ pointer: value
250
+ for pointers, value in zip(groups, translated)
251
+ for pointer in pointers
252
+ }
233
253
  _, report = overlay(data, strings)
234
254
  if report.unresolved or report.applied != len(strings):
235
255
  raise SystemExit("error: translations do not validate against report source")
@@ -334,6 +334,23 @@ Lightweight reverify does not require the original `analysis-packet.md`, `analys
334
334
 
335
335
  This is the single largest avoidable cost in `requirements-discovery`, `error-analysis`, `implementation-option-selection`, and `implementation-planning` runs. Treat as mandatory.
336
336
 
337
+ ## Conditional reference reading
338
+
339
+ The common read retains finding classification, queue pruning, dispatch gates,
340
+ state ownership, and output rules. Use the generated `okstra convergence
341
+ reverify-prompt` body for every verifier. The following reads are guidance for
342
+ avoiding unused examples; queue and dispatch validators still enforce execution.
343
+
344
+ | Current operation | Additional section to read |
345
+ |---|---|
346
+ | Diagnosing a rejected verifier prompt | The reference prompt matching the selected verification mode |
347
+ | `config.critic.enabled` is true | Coverage critic pass, including its shared dispatch procedure |
348
+ | An enabled critic uses acceptance mode for `final-verification` | Acceptance critic pass as well as the shared Coverage critic dispatch procedure |
349
+
350
+ Read the matching sections before their dispatch. A disabled critic needs neither
351
+ critic section. Generated prompts retain the selected mode's instructions even
352
+ when the lead does not read the example text.
353
+
337
354
  ### Lightweight Re-verification Prompt
338
355
 
339
356
  Rendered by `okstra convergence reverify-prompt` when `config.adversarial` is false; the block below is the reference shape, and the rendered body additionally carries each finding's `**Origin item**` and `**Origin audit sidecar**` lines.
@@ -22,8 +22,8 @@ This document is the operating contract and phase index. Detailed procedures liv
22
22
  |-------|-------|
23
23
  | [context-loader](./context-loader.md) | Phase 1 task-bundle discovery, manifest fields, run-directory layout |
24
24
  | [team-contract](./team-contract.md) | Phase 2–5 worker roster, model assignment rules, prompt composition (anchor headers, `[Required reading]`, `[Error reporting]`), worker output contract, terminal statuses, usage tracking |
25
- | [convergence](./convergence.md) | Phase 5.5 finding convergence loop, finding categories, reverify dispatch (anchor headers, required-reading suppression), convergence state schema |
26
- | [plan-body-verification](./plan-body-verification.md) | Phase 6 plan-body verification sub-step (implementation-planning only) — plan-item extraction, verdict semantics, gate resolution, state schema. Read only at that sub-step |
25
+ | [convergence](./convergence.md) | Phase 5.5 finding convergence loop, finding categories, reverify dispatch, convergence state schema. Use the bounded common read under Doctrine lazy reads |
26
+ | [plan-body-verification](./plan-body-verification.md) | Phase 6 plan-body verification sub-step (implementation-planning only) — plan-item extraction, verdict semantics, gate resolution, state schema. Read only the common procedure at that sub-step, using the bounded read below |
27
27
  | [report-writer](./report-writer.md) | Phase 6 final-report authorship, dispatch template, resume-safe dispatch, shared-graph integrity check, Phase 7 token-usage collector |
28
28
 
29
29
  Read-side inspection (`/okstra-inspect`) and scheduling (`/okstra-schedule-gen`) are user-invoked skills, not lead support contracts — the lead does not consult them during a run.
@@ -262,11 +262,23 @@ After context-loader completes, read **only the compact intake files below** in
262
262
 
263
263
  **Doctrine lazy reads (BLOCKING — read at the round, not at Phase 1):**
264
264
 
265
- - [convergence](./convergence.md) — read before the first `PROGRESS: phase-5.5-convergence` line of any run that runs a convergence round.
266
- - [plan-body-verification](./plan-body-verification.md) — read before the first `PROGRESS: phase-5.5.9-plan-verify` line.
265
+ - [convergence](./convergence.md) — read the common procedure with the bounded command below before the first `PROGRESS: phase-5.5-convergence` line of any run that runs a convergence round.
266
+ - [plan-body-verification](./plan-body-verification.md) — read the common procedure with the bounded command in the Phase 6 sub-step before the first `PROGRESS: phase-5.5.9-plan-verify` line.
267
267
 
268
268
  Both stay out of the Phase 1 baseline for the token reason above, and neither is optional at its round: together they carry more than half of this contract family's MUST clauses, so a round dispatched without the read is a round run from memory. **Enforced:** `validators/validate_session_conformance.py` `_ENTRY_GUARD_READS` requires each read — a `Read` call or a shell command naming the file — inside this run's window and before that checkpoint. The requirement is conditioned on the checkpoint actually appearing, so a run that holds no such round is never asked for it.
269
269
 
270
+ Read the convergence common procedure from the resolved resource path. Replace
271
+ `<convergence-contract-path>` with its absolute path. This skips reference prompt
272
+ examples and critic-specific procedures; the conditional reading table in the
273
+ output identifies when those sections are needed:
274
+
275
+ ```sh
276
+ awk '/^### Lightweight Re-verification Prompt$/ {skip=1} /^## Convergence State Artifact$/ {skip=0} /^## Coverage critic pass$/ {skip=1} /^## Output$/ {skip=0} !skip {print}' '<convergence-contract-path>'
277
+ ```
278
+
279
+ `tests/contract/test_contract_examples_execute.py::test_convergence_common_read_keeps_protocol`
280
+ checks the emitted common procedure. The same entry-guard read trace applies.
281
+
270
282
  **Lazy reading discipline (do NOT read at Phase 1):**
271
283
 
272
284
  - `task-index.md` — only when the user explicitly asks for a human summary or when history disambiguation is required.
@@ -429,7 +441,21 @@ After the Report writer worker narrative is reviewed, **if** `task_type == "impl
429
441
 
430
442
  This is a Phase 6 sub-step — it does NOT introduce a new top-level lifecycle phase; the lead operating-phase model (Phase 1 Intake → Phase 7 Persist, labels in the "Quick Reference" table above as the single source of truth) is preserved. The round's outcome is read from the final report's `### 5.5.9 Plan Body Verification` section and `implementationPlanning.planBodyVerification` in its data.json — it is not a separate lifecycle phase identifier.
431
443
 
432
- **REQUIRED RESOURCE:** Read [plan-body-verification](./plan-body-verification.md) for the round protocol, plan-item ID scheme (`P-Dir-1` for selected-direction; `P-Opt-*` for legacy candidate comparison; then `P-Step-*` / `P-Dep-*` / `P-Val-*` / `P-Rb-*` / `P-Req-*` / `P-Prep-*`), verdict semantics (`AGREE` / `DISAGREE(a-f)` / `SUPPLEMENT`), classification rules, gate-result resolution, and the state-file schema at `runs/<task-type>/state/plan-body-verification.json`. For `P-Dir-1`, compare `directionRealization` with `selectedDirectionRef` and its snapshot: verify the core mechanism, architecture boundaries, planning invariants, and any hidden direction change.
444
+ **REQUIRED RESOURCE:** Read the common procedure of [plan-body-verification](./plan-body-verification.md) for the round protocol, plan-item ID scheme (`P-Dir-1` for selected-direction; `P-Opt-*` for legacy candidate comparison; then `P-Step-*` / `P-Dep-*` / `P-Val-*` / `P-Rb-*` / `P-Req-*` / `P-Prep-*`), verdict semantics (`AGREE` / `DISAGREE(a-f)` / `SUPPLEMENT`), classification rules, gate-result resolution, and state-path authority. Read the state-file schema only when diagnosing state or projection validation. For `P-Dir-1`, compare `directionRealization` with `selectedDirectionRef` and its snapshot: verify the core mechanism, architecture boundaries, planning invariants, and any hidden direction change.
445
+
446
+ Read from the resolved runtime resource path, replacing `<plan-body-contract-path>`
447
+ with that resource's absolute path. This prints the complete common procedure and
448
+ its conditional reading table, stopping before reference examples:
449
+
450
+ ```sh
451
+ awk '/^## Reference material$/ {exit} {print}' '<plan-body-contract-path>'
452
+ ```
453
+
454
+ Follow the conditional reading table for later rounds and validation failures.
455
+ The command keeps the filename in the read trace used by
456
+ `validators/validate_session_conformance.py` `_ENTRY_GUARD_READS`.
457
+ The bounded output is checked by
458
+ `tests/contract/test_contract_examples_execute.py::test_plan_body_common_read_keeps_gate_rules`.
433
459
 
434
460
  Distinct from Phase 5.5 finding convergence:
435
461
 
@@ -376,7 +376,7 @@ round before any host or provider process starts.
376
376
  **On a re-run, add `--prior-state <the previous run's plan-body-verification-<task-type>-<seq>.json>`.** A newly seeded item whose `contentHash` equals that run's `verifiedContentHash` for the same id inherits its verdicts, is tagged `carriedForwardFromSeq`, and drops out of round 1's `dispatchQueue` — in the state and in the sibling `plan-items-*.json` the next `okstra plan-items prompt` reads. Without it a re-run re-judges every line the previous run already settled, and the same dissent re-opens under a new `P-*` number. A matching id alone never carries: `P-*` ids are positional, so a plan that gained one line hands the old id to a different sentence. The flag requires `--state` and refuses a prior state file that sits under another task's root — a plan-body state carries no task identity of its own, so its path is the only identity there is. **Enforced:** `okstra_ctl.plan_items_cli._carry_prior_run_verdicts`.
377
377
 
378
378
  **`--run-manifest` is what scopes the gate to the stage you are starting.** Seed uses it to overlay disk `done` / `active` onto `planBodyVerification.stageLedger`. The current plan's depends-on fills `ready` / `blocked` when no prior plan exists, so a first run does not treat every stage as in-scope. **Enforced:** `okstra_ctl.plan_items.planning_stage_ledger`.
379
- 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.
379
+ 2. For each analyser worker in the roster, use `okstra plan-items prompt` output verbatim as the instruction body in the materialization sequence above. Read §"Re-verification rounds (round 2+)" before preparing a later round; read §"Plan-body reverify prompt" only when diagnosing a prompt-contract failure.
380
380
  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`). **`<seq>` is the report's sequence** — the one in this run's `final-report-<task-type>-<seq>` filename, NOT the `workerResults` sequence the initial analysis results carry. The two are equal in most runs and diverge in some (`reports: 004` alongside `workerResults: 005` is a real case). Provenance no longer globs by either seq: it resolves the expected filenames from this run's team-state `workerDispatches[]` rows — the paths the dispatches actually recorded — and falls back to the seq glob only when no team-state is readable, saying so in its finding. 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.
381
381
  **Verdict provenance.** Every verdict recorded in `planItems[].verdicts[]` MUST trace back to a dispatch that actually returned a result file. 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 (advisory):** `validators/validate-run.py` `_validate_plan_body_verdict_provenance` reports any `verdicts[].worker` with no recorded reverify dispatch whose result file exists; the finding is not in the blocking allowlist, so it surfaces as an advisory rather than failing the run — record it, never dismiss it. 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, and renaming a result file to make provenance match destroys the link the check reads.
382
382
 
@@ -474,7 +474,13 @@ round before any host or provider process starts.
474
474
  - A terminal row preserves its original dissent classification only from the convergence-owned state history. Every `user-decision-required` / `user-decision-evaluated` activity cites the row's `C-NNN` in `clarificationRefs` and affected plan items in `planItemIds`. A resolved decision names only existing `A-NNN` checks. Report assembly validates those links and derives the report backtraces; it does not accept copied IDs from the approval ledger. When an independent coverage-only blocker is corrected, keep the `C-NNN` in the non-blocking Requirement Coverage row's `decisionRefs`. `obsolete` is valid only after current evidence shows that the question or blocker disappeared.
475
475
  9. Approval lives in the report record `frontmatter.approved` field — there is no in-body marker line. The user may set it to `true` (via `--approve` or the in-session wizard) when remaining `Blocks=approval` rows are user-proceeded (`accept-risk` / `select` / `answer`) even if the recorded `gateResult` is still `blocked-by-disagreement`. `aborted-non-result` still withholds approval. **Enforced:** run-prep (`scripts/okstra_ctl/run.py` `_validate_approved_plan` / `_blocking_gate_survives_user_decision`) and `validators/validate-run.py` `_validate_plan_body_gate_recompute`.
476
476
 
477
- ## `plan-body-verification-<task-type>-<seq>.json` schema
477
+ ## Worker non-result handling in plan-body round (BLOCKING)
478
+
479
+ Mirrors finding convergence ([convergence](./convergence.md) §"Worker failure handling in reverify"). Concretely:
480
+
481
+ - A dispatch that returns terminal non-result MUST NOT be aggregated as `DISAGREE`.
482
+ - If at least one dispatch was issued AND **all** plan-body dispatches return non-result, the Gate result is `aborted-non-result`. Record one `contract-violation` event per non-result dispatch.
483
+ - When the gate is `aborted-non-result`, report-writer MUST keep the frontmatter `approved: false` (publishing `approved: true` under this gate result is a validator failure). A single row is added to `## 1. Clarification Items` with `Statement="plan-body verification could not run — all workers returned non-result"`, `Kind=decision`, `Blocks=approval`, allowing the user to either retry the phase or override by running `--approve` on the resume command (or confirming in the in-session wizard). The row MUST name which dispatches returned no result and what re-running them requires. **Enforced:** `validators/validate-run.py` `_validate_aborted_gate_has_clarification` — `_validate_plan_body_clarification_matching` cannot cover this case because it walks `majority-disagree` items and an aborted round produces none, which is exactly how an aborted run used to reach the user with no stated blocker and stall.
478
484
 
479
485
  **Take the path from the launch prompt, never from this filename (BLOCKING).**
480
486
  The run's `## Run Paths` block renders `Plan-body verification state:` with the
@@ -488,6 +494,27 @@ team-state name, so the round count reads as 0 and
488
494
  `verification-round-completed count must match automatic plan-body rounds=0`
489
495
  fails a run whose rounds all ran.
490
496
 
497
+ ## Conditional reference reading
498
+
499
+ The common procedure ends here. Keep the sections above in context for every
500
+ plan-body round. Read the reference sections below only for the matching operation:
501
+
502
+ | Operation | Additional section to read |
503
+ |---|---|
504
+ | Inspecting a state-shape or projection validation failure | `plan-body-verification-<task-type>-<seq>.json` schema |
505
+ | Investigating a generated prompt rejected by the dispatch gate | Plan-body reverify prompt |
506
+ | Preparing round 2 or later after a rewrite | Re-verification rounds (round 2+) — carry the dissent forward |
507
+
508
+ Use `okstra plan-items prompt` output verbatim for every round. The reference
509
+ prompt is explanatory; it does not replace the generated queue or authorize
510
+ hand-written items. Read a referenced section when an unresolved error requires
511
+ it; do not load every reference pre-emptively. This reading policy is guidance;
512
+ queue validation and dispatch checks continue to enforce the generated input.
513
+
514
+ ## Reference material
515
+
516
+ ## `plan-body-verification-<task-type>-<seq>.json` schema
517
+
491
518
  **Which file is authoritative for what.** Contract v3 keeps both views in one convergence-owned state file before publication:
492
519
 
493
520
  | | records | what a self-fix round does to it |
@@ -805,11 +832,3 @@ An item with no recorded vote carrying a round number gets no block, and an enve
805
832
  **Enforced:** `okstra plan-items validate-prepared --state <same state>` re-derives the carry and exits 2 when the prepared envelope's `priorRounds` does not match, alongside the `items` / `dispatchQueue` comparison it already made. A prepared queue that dropped the dissent cannot pass the step-1 validation the dispatch is gated on.
806
833
 
807
834
  The two spellings are different anchors for different artifacts: `**Prior round dissent**` is the block `prompt` puts in the prompt, `**Prior dissent**` is the line the worker puts in its result. `scripts/okstra_ctl/verdict_blocks.py` parses the result line into the verdict block when it is present and leaves it empty when it is not, so an omitted answer line is still silent — the prompt is what is now guaranteed, not the response.
808
-
809
- ## Worker non-result handling in plan-body round (BLOCKING)
810
-
811
- Mirrors finding convergence ([convergence](./convergence.md) §"Worker failure handling in reverify"). Concretely:
812
-
813
- - A dispatch that returns terminal non-result MUST NOT be aggregated as `DISAGREE`.
814
- - If at least one dispatch was issued AND **all** plan-body dispatches return non-result, the Gate result is `aborted-non-result`. Record one `contract-violation` event per non-result dispatch.
815
- - When the gate is `aborted-non-result`, report-writer MUST keep the frontmatter `approved: false` (publishing `approved: true` under this gate result is a validator failure). A single row is added to `## 1. Clarification Items` with `Statement="plan-body verification could not run — all workers returned non-result"`, `Kind=decision`, `Blocks=approval`, allowing the user to either retry the phase or override by running `--approve` on the resume command (or confirming in the in-session wizard). The row MUST name which dispatches returned no result and what re-running them requires. **Enforced:** `validators/validate-run.py` `_validate_aborted_gate_has_clarification` — `_validate_plan_body_clarification_matching` cannot cover this case because it walks `majority-disagree` items and an aborted round produces none, which is exactly how an aborted run used to reach the user with no stated blocker and stall.
@@ -146,7 +146,11 @@ def build_analysis_packet(
146
146
  )
147
147
  parts.extend(_group_context_block(group_human))
148
148
  parts.extend(_brief_block(brief_text))
149
- parts.extend(_group_memory_block(group_memory, _own_task_segment(task_key)))
149
+ parts.extend(_group_memory_block(
150
+ group_memory, _own_task_segment(task_key),
151
+ "\n".join((brief_text, group_human, clarification_text, directive,
152
+ fix_history_text, prior_planning_summary, stage_ledger_json)),
153
+ ))
150
154
  parts.extend(_profile_block(task_type, profile_text))
151
155
  parts.extend(_reference_block(reference_text))
152
156
  parts.extend(_fix_history_block(fix_history_text))
@@ -310,7 +314,13 @@ GROUP_MEMORY_PREFACE = (
310
314
  "`record`. This section is not this task's requirement ledger, and a sibling's "
311
315
  "decision does not bind this run: where a sibling's conclusion conflicts with "
312
316
  "the `## Task-Specific Brief Extract`, raise a `Clarification Items` row rather "
313
- "than re-deriving what the sibling already settled or silently overriding it."
317
+ "than re-deriving what the sibling already settled or silently overriding it. "
318
+ "Every sibling has a headline and record pointer below. Full details are "
319
+ "included for explicit task references and their referenced siblings, and "
320
+ "for entries with watch-outs or no record pointer. For an index-only entry, "
321
+ "read its section in the Task-group context source or its record when its "
322
+ "headline may affect this task or the relationship is unclear. Omitted "
323
+ "details do not mean the sibling has no decisions or follow-ups."
314
324
  )
315
325
 
316
326
 
@@ -332,22 +342,49 @@ def _own_task_segment(task_key: str) -> str:
332
342
  return group_context.slugify_task_segment(re.split(r"[:/]", task_key)[-1])
333
343
 
334
344
 
335
- def _group_memory_block(region: str, own_task_segment: str) -> list[str]:
336
- """형제 task 의 항목만 싣는다. 자기 항목은 timeline·prior-planning 이 이미 나른다."""
345
+ _TASK_REFERENCE_RE = re.compile(r"[a-z0-9](?:[a-z0-9_.-]*[a-z0-9])?", re.IGNORECASE)
346
+
347
+
348
+ def _group_memory_block(
349
+ region: str, own_task_segment: str, reference_text: str,
350
+ ) -> list[str]:
351
+ """형제 전체의 색인을 유지하며 참조 연결과 주의사항이 있는 항목을 펼친다."""
337
352
  entries = [
338
353
  entry for entry in group_context.parse_memory_entries(region)
339
354
  if entry.task_id != own_task_segment
340
355
  ]
341
356
  if not entries:
342
357
  return []
343
- return [
344
- "",
345
- "## Task-Group Memory",
346
- "",
347
- GROUP_MEMORY_PREFACE,
348
- "",
349
- group_context.render_memory_entries(entries).strip(),
350
- ]
358
+ tokens = set(_TASK_REFERENCE_RE.findall(reference_text.casefold()))
359
+ own_ids = {own_task_segment, group_context.ticket_id_from_brief_id(own_task_segment)}
360
+ rendered = [group_context.render_memory_entries([entry]).strip() for entry in entries]
361
+ entry_tokens = [set(_TASK_REFERENCE_RE.findall(text.casefold())) for text in rendered]
362
+ selected: set[int] = set()
363
+ while True:
364
+ previous_count = len(selected)
365
+ for index, entry in enumerate(entries):
366
+ aliases = {entry.task_id.casefold(), group_context.ticket_id_from_brief_id(entry.task_id).casefold()}
367
+ if index in selected:
368
+ continue
369
+ if (aliases & tokens or own_ids & entry_tokens[index]
370
+ or entry.watch_out or not entry.record):
371
+ selected.add(index)
372
+ tokens.update(entry_tokens[index])
373
+ if len(selected) == previous_count:
374
+ break
375
+ lines = ["", "## Task-Group Memory", "", GROUP_MEMORY_PREFACE, ""]
376
+ for index, entry in enumerate(entries):
377
+ if index in selected:
378
+ lines.extend([rendered[index], ""])
379
+ else:
380
+ lines.extend([
381
+ f"### {entry.task_id}",
382
+ f"- headline: {entry.headline or '_(none)_'}",
383
+ f"- record: `{entry.record}`",
384
+ "- Detail: index-only; read the source if relevant or uncertain.",
385
+ "",
386
+ ])
387
+ return lines
351
388
 
352
389
 
353
390
  def _brief_block(brief_text: str) -> list[str]:
@@ -175,12 +175,13 @@ def _runtime_template(filename: str) -> Path:
175
175
  def _header_path(prompt_path: Path | None, header: str) -> Path | None:
176
176
  if prompt_path is None or not prompt_path.is_file():
177
177
  return None
178
- prefix = f"**{header}:**"
178
+ prefixes = (f"**{header}:**", f"- {header}:")
179
179
  for line in prompt_path.read_text(encoding="utf-8", errors="replace").splitlines():
180
180
  stripped = line.strip()
181
- if stripped.startswith(prefix):
182
- value = stripped[len(prefix):].strip().strip("`")
183
- return Path(value) if value else None
181
+ for prefix in prefixes:
182
+ if stripped.startswith(prefix):
183
+ value = stripped[len(prefix):].strip().strip("`")
184
+ return Path(value) if value else None
184
185
  return None
185
186
 
186
187
 
@@ -430,8 +431,9 @@ def _report_writer_metric(run_dir: Path | None, task_root: Path, project_root: P
430
431
  instruction_set / "final-report-template.md",
431
432
  instruction_set / "final-report-schema.json",
432
433
  ])
434
+ prompt = _initial_prompt(run_dir, report_writer=True)
433
435
  preamble, error_contract = _prompt_contract_paths(
434
- _initial_prompt(run_dir, report_writer=True),
436
+ prompt,
435
437
  "report-writer-prompt-preamble.md",
436
438
  )
437
439
  files.extend((preamble, error_contract))
@@ -444,9 +446,13 @@ def _report_writer_metric(run_dir: Path | None, task_root: Path, project_root: P
444
446
  )
445
447
  if convergence:
446
448
  files.append(convergence)
449
+ synthesis_ref = _header_path(prompt, "Report synthesis packet")
450
+ synthesis = project_root / synthesis_ref if synthesis_ref else None
451
+ if synthesis is not None:
452
+ files = [synthesis, preamble, error_contract, *duty_files]
447
453
  file_count, byte_count = _count_files(files)
448
454
  return {
449
- "mode": "raw-synthesis-inputs",
455
+ "mode": "synthesis-packet" if synthesis is not None else "raw-synthesis-inputs",
450
456
  "fileCount": file_count,
451
457
  "bytes": byte_count,
452
458
  "estimatedTokens": _estimate_tokens(files),
@@ -454,6 +460,10 @@ def _report_writer_metric(run_dir: Path | None, task_root: Path, project_root: P
454
460
  "promptPreamblePath": str(preamble),
455
461
  "workerErrorContractPath": str(error_contract),
456
462
  "files": [project_rel(path, project_root) for path in files if path.is_file()],
463
+ "missingFiles": (
464
+ [project_rel(synthesis, project_root)]
465
+ if synthesis is not None and not synthesis.is_file() else []
466
+ ),
457
467
  }
458
468
 
459
469
 
@@ -215,6 +215,7 @@ def parse_memory_entries(region: str) -> list[MemoryEntry]:
215
215
 
216
216
 
217
217
  def _entry_from_fields(fields: Mapping[str, Any]) -> MemoryEntry:
218
+ record = fields.get("record", "")
218
219
  return MemoryEntry(
219
220
  task_id=fields.get("task_id", ""),
220
221
  task_type=fields.get("task_type", ""),
@@ -224,7 +225,7 @@ def _entry_from_fields(fields: Mapping[str, Any]) -> MemoryEntry:
224
225
  headline=fields.get("headline", ""),
225
226
  decisions=tuple(fields.get("decisions", [])),
226
227
  follow_ups=tuple(fields.get("follow_ups", [])),
227
- record=fields.get("record", ""),
228
+ record="" if record in _NONE_MARKERS else record,
228
229
  watch_out=tuple(fields.get("watch_out", [])),
229
230
  )
230
231
 
@@ -391,6 +392,12 @@ class QueueRow:
391
392
  duplicate_briefs: tuple[str, ...] = ()
392
393
 
393
394
 
395
+ def ticket_id_from_brief_id(brief_id: str) -> str:
396
+ """순번이 붙은 브리프 식별자에서 이슈 식별자를 복원한다."""
397
+ match = _ORDINAL_RE.match(brief_id)
398
+ return match.group("ticket") if match else brief_id
399
+
400
+
394
401
  def group_briefs(project_root: Path, task_group: str) -> list[dict[str, Any]]:
395
402
  """그룹 디렉터리의 브리프(프론트매터 `type: brief`)와 각 브리프의 대기 간선."""
396
403
  group_dir = group_context_file(project_root, task_group).parent
@@ -407,7 +414,7 @@ def group_briefs(project_root: Path, task_group: str) -> list[dict[str, Any]]:
407
414
  match = _ORDINAL_RE.match(brief_id)
408
415
  briefs.append({
409
416
  "brief_id": brief_id,
410
- "ticket_id": frontmatter.get("ticket-id") or (match.group("ticket") if match else brief_id),
417
+ "ticket_id": frontmatter.get("ticket-id") or ticket_id_from_brief_id(brief_id),
411
418
  "ordinal": int(match.group("ordinal")) if match else None,
412
419
  "brief": path.relative_to(project_root).as_posix(),
413
420
  "waits_for": _wait_edges(path.read_text(encoding="utf-8")),
@@ -94,6 +94,7 @@ def _input_map(project_root: Path, manifest: Mapping[str, Any]) -> dict[str, Rep
94
94
  def _input_preflight_issues(
95
95
  inputs: Mapping[str, ReportInputPath],
96
96
  schema: Mapping[str, Any],
97
+ task_type: str,
97
98
  ) -> tuple[AssemblyIssue, ...]:
98
99
  issues: list[AssemblyIssue] = []
99
100
  for row in inputs.values():
@@ -109,7 +110,24 @@ def _input_preflight_issues(
109
110
  continue
110
111
  try:
111
112
  if row.key == "narrative":
112
- parse_narrative(row.path.read_text(encoding="utf-8"), schema)
113
+ narrative = parse_narrative(row.path.read_text(encoding="utf-8"), schema)
114
+ # 서사에는 header 가 없으므로 실행 명세의 작업 유형으로 판정 규칙을 적용한다.
115
+ for branch in schema.get("allOf", []):
116
+ verdict = branch.get("then", {}).get("properties", {}).get("finalVerdict")
117
+ if verdict is None:
118
+ continue
119
+ errors = validate(
120
+ {**narrative, "header": {"taskType": task_type}},
121
+ {
122
+ "$defs": schema.get("$defs", {}),
123
+ "if": branch["if"],
124
+ "then": {"properties": {"finalVerdict": verdict}},
125
+ },
126
+ )
127
+ issues.extend(
128
+ AssemblyIssue(row.owner, str(row.path), "finalVerdict", error)
129
+ for error in errors
130
+ )
113
131
  elif row.key == "agent-activity":
114
132
  row.path.read_text(encoding="utf-8")
115
133
  else:
@@ -835,7 +853,7 @@ def assemble_report(
835
853
  _fail("orchestrator", manifest_path, "reportContractVersion", "requires 3.0")
836
854
  schema = load_schema_version("3.0")
837
855
  inputs = _input_map(project_root, manifest)
838
- input_issues = list(_input_preflight_issues(inputs, schema))
856
+ input_issues = list(_input_preflight_issues(inputs, schema, str(manifest.get("taskType", ""))))
839
857
  packet_data_path, _ = report_synthesis_packet_paths(inputs["narrative"].path)
840
858
  if packet_data_path.is_file():
841
859
  input_issues.extend(
@@ -388,6 +388,25 @@ def extract(data: Mapping[str, Any]) -> dict[str, str]:
388
388
  return dict(_walk(data, "", ""))
389
389
 
390
390
 
391
+ def group_translation_pointers(
392
+ data: Mapping[str, Any], strings: Mapping[str, str],
393
+ ) -> list[list[str]]:
394
+ """배열 위치만 다른 동일 필드·원문을 한 번역 항목으로 묶는다."""
395
+ groups: dict[tuple[tuple[tuple[str, str], ...], str], list[str]] = {}
396
+ for pointer, source in strings.items():
397
+ node: Any = data
398
+ context: list[tuple[str, str]] = []
399
+ for token in pointer.split("/")[1:]:
400
+ if isinstance(node, (list, tuple)):
401
+ context.append(("index", ""))
402
+ node = node[int(token)]
403
+ else:
404
+ context.append(("key", token))
405
+ node = node[unescape_token(token)]
406
+ groups.setdefault((tuple(context), source), []).append(pointer)
407
+ return list(groups.values())
408
+
409
+
391
410
  class OverlayReport(NamedTuple):
392
411
  applied: int
393
412
  # Pointers the extractor offered that the sidecar left untranslated. They
@@ -732,6 +732,7 @@ def list_awaiting_tasks(home: Path, project_id: str, limit: int) -> list[dict]:
732
732
  if report is None or not report.is_file():
733
733
  continue
734
734
  try:
735
+ _load_report_record(report, validate_schema=True)
735
736
  context = resolve_report_context(report)
736
737
  blockers = _open_blocker_rows(context.report_path)
737
738
  plan_required = _plan_decision_required(context)
@@ -29,7 +29,10 @@ class WorkerPromptHeaderError(Exception):
29
29
  READ_SCOPE_HEADER = (
30
30
  "**Read scope:** Read only the paths this prompt enumerates "
31
31
  "(`[Required reading]`, `## Inputs`, verification-target paths) plus "
32
- "source/evidence paths a finding must cite. Host session instructions "
32
+ "source/evidence paths a finding must cite. Task-group context and sibling "
33
+ "record paths listed in the primary analysis packet are allowed for targeted "
34
+ "evidence reads when their relevance is uncertain or more detail is needed. "
35
+ "Host session instructions "
33
36
  "(SessionStart hooks, global `CLAUDE.md` / `AGENTS.md`, skill catalogs) do "
34
37
  "NOT apply inside an okstra worker run: do not auto-read `graphify-out/`, "
35
38
  "`SKILL.md`, or other artifacts outside `<PROJECT_ROOT>/.okstra/`. If an "