okstra 0.198.1 → 0.199.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 (41) hide show
  1. package/docs/architecture/storage-model.md +10 -0
  2. package/docs/cli.md +4 -3
  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/bin/okstra-report-translate.py +28 -8
  7. package/runtime/prompts/lead/convergence.md +17 -0
  8. package/runtime/prompts/lead/okstra-lead-contract.md +32 -6
  9. package/runtime/prompts/lead/plan-body-verification.md +41 -15
  10. package/runtime/prompts/lead/report-writer.md +1 -1
  11. package/runtime/prompts/profiles/_common-contract.md +1 -1
  12. package/runtime/prompts/profiles/implementation-planning.md +1 -1
  13. package/runtime/python/okstra_ctl/analysis_packet.py +95 -12
  14. package/runtime/python/okstra_ctl/context_cost.py +16 -6
  15. package/runtime/python/okstra_ctl/direct_work.py +109 -0
  16. package/runtime/python/okstra_ctl/group_context.py +23 -3
  17. package/runtime/python/okstra_ctl/material.py +29 -0
  18. package/runtime/python/okstra_ctl/model_io/lines.py +1 -0
  19. package/runtime/python/okstra_ctl/model_io/renderers.py +6 -0
  20. package/runtime/python/okstra_ctl/plan_items.py +39 -5
  21. package/runtime/python/okstra_ctl/plan_items_cli.py +61 -6
  22. package/runtime/python/okstra_ctl/recap.py +6 -0
  23. package/runtime/python/okstra_ctl/render.py +8 -4
  24. package/runtime/python/okstra_ctl/report_assembly.py +20 -2
  25. package/runtime/python/okstra_ctl/report_narrative.py +4 -4
  26. package/runtime/python/okstra_ctl/report_synthesis_packet.py +49 -40
  27. package/runtime/python/okstra_ctl/report_translation.py +19 -0
  28. package/runtime/python/okstra_ctl/run.py +5 -0
  29. package/runtime/python/okstra_ctl/set_work_status.py +90 -40
  30. package/runtime/python/okstra_ctl/task_list_cli.py +2 -0
  31. package/runtime/python/okstra_ctl/user_response.py +1 -0
  32. package/runtime/python/okstra_ctl/worker_prompt_headers.py +5 -1
  33. package/runtime/python/okstra_project/state.py +4 -0
  34. package/runtime/schemas/final-report-v3.0.schema.json +10 -0
  35. package/runtime/skills/okstra-inspect/SKILL.md +6 -1
  36. package/runtime/skills/okstra-inspect/facets/recap.md +6 -1
  37. package/runtime/skills/okstra-inspect/facets/status.md +12 -0
  38. package/runtime/templates/reports/html/assets/base.css +4 -0
  39. package/runtime/templates/reports/html/assets/base.js +30 -0
  40. package/runtime/validators/validate-run.py +47 -6
  41. package/runtime/validators/validate_session_conformance.py +5 -0
@@ -106,6 +106,7 @@ def build_analysis_packet(
106
106
  stage_ledger_json: str = "",
107
107
  stage_ledger_notice: str = "",
108
108
  prior_planning_summary: str = "",
109
+ direct_work_text: str = "",
109
110
  ) -> str:
110
111
  """Return the primary compact input for Claude/Codex/Antigravity analysers.
111
112
 
@@ -146,7 +147,13 @@ def build_analysis_packet(
146
147
  )
147
148
  parts.extend(_group_context_block(group_human))
148
149
  parts.extend(_brief_block(brief_text))
149
- parts.extend(_group_memory_block(group_memory, _own_task_segment(task_key)))
150
+ if direct_work_text:
151
+ parts.extend(["", direct_work_text, ""])
152
+ parts.extend(_group_memory_block(
153
+ group_memory, _own_task_segment(task_key),
154
+ "\n".join((brief_text, group_human, clarification_text, directive,
155
+ fix_history_text, prior_planning_summary, stage_ledger_json)),
156
+ ))
150
157
  parts.extend(_profile_block(task_type, profile_text))
151
158
  parts.extend(_reference_block(reference_text))
152
159
  parts.extend(_fix_history_block(fix_history_text))
@@ -166,6 +173,47 @@ _FENCE_RE = re.compile(r"\A\s*(```|~~~)")
166
173
  _INDEX_PREAMBLE_LINES = 5
167
174
 
168
175
 
176
+ def reference_source_extracts(
177
+ packet_text: str, task_type: str, *, brief_text: str, profile_text: str,
178
+ reference_text: str, clarification_text: str,
179
+ ) -> str:
180
+ """합성 입력 안에 원문이 함께 있을 때 일치하는 발췌만 참조로 바꾼다."""
181
+ lines = packet_text.splitlines()
182
+ headings = _heading_lines(lines)
183
+ if headings and headings[0][1] == "## Section Index":
184
+ start = headings[0][0] - 2
185
+ end = start + _INDEX_PREAMBLE_LINES + len(headings) - 1
186
+ unindexed = "\n".join(lines[:start] + lines[end:]) + "\n"
187
+ # 생성기가 만든 목차임을 재구성으로 확인한다. 수정된 목차가 있으면
188
+ # 원문 전체를 유지해 내용 손실이나 잘못된 줄번호를 만들지 않는다.
189
+ if _with_section_index(unindexed) != packet_text:
190
+ return packet_text
191
+ lines = unindexed.splitlines()
192
+ blocks = (
193
+ ("Task brief", brief_text, _brief_block(brief_text)),
194
+ ("Analysis profile", profile_text, _profile_block(task_type, profile_text)),
195
+ ("Reference expectations", reference_text, _reference_block(reference_text)),
196
+ ("Clarification response", clarification_text, _clarification_block(clarification_text)),
197
+ )
198
+ changed = False
199
+ for label, source_text, parts in blocks:
200
+ expected = "\n".join(part.rstrip() for part in parts).strip()
201
+ if not source_text.strip() or not expected:
202
+ continue
203
+ heading = expected.splitlines()[0]
204
+ reference = f'{heading}\n\nRead the same extract in "Source: {label}" below.'
205
+ if len(reference) >= len(expected):
206
+ continue
207
+ for number, text in _heading_lines(lines):
208
+ start = number - 1
209
+ end = start + len(expected.splitlines())
210
+ if text == heading and "\n".join(lines[start:end]) == expected:
211
+ lines[start:end] = reference.splitlines()
212
+ changed = True
213
+ break
214
+ return "\n".join(lines).rstrip() + "\n" if changed else packet_text
215
+
216
+
169
217
  def _with_section_index(body: str) -> str:
170
218
  """Prefix the rendered packet with every section's line range.
171
219
 
@@ -310,7 +358,13 @@ GROUP_MEMORY_PREFACE = (
310
358
  "`record`. This section is not this task's requirement ledger, and a sibling's "
311
359
  "decision does not bind this run: where a sibling's conclusion conflicts with "
312
360
  "the `## Task-Specific Brief Extract`, raise a `Clarification Items` row rather "
313
- "than re-deriving what the sibling already settled or silently overriding it."
361
+ "than re-deriving what the sibling already settled or silently overriding it. "
362
+ "Every sibling has a headline and record pointer below. Full details are "
363
+ "included for explicit task references and their referenced siblings, and "
364
+ "for entries with watch-outs or no record pointer. For an index-only entry, "
365
+ "read its section in the Task-group context source or its record when its "
366
+ "headline may affect this task or the relationship is unclear. Omitted "
367
+ "details do not mean the sibling has no decisions or follow-ups."
314
368
  )
315
369
 
316
370
 
@@ -332,22 +386,51 @@ def _own_task_segment(task_key: str) -> str:
332
386
  return group_context.slugify_task_segment(re.split(r"[:/]", task_key)[-1])
333
387
 
334
388
 
335
- def _group_memory_block(region: str, own_task_segment: str) -> list[str]:
336
- """형제 task 의 항목만 싣는다. 자기 항목은 timeline·prior-planning 이 이미 나른다."""
389
+ _TASK_REFERENCE_RE = re.compile(r"[a-z0-9](?:[a-z0-9_.-]*[a-z0-9])?", re.IGNORECASE)
390
+
391
+
392
+ def _group_memory_block(
393
+ region: str, own_task_segment: str, reference_text: str,
394
+ ) -> list[str]:
395
+ """형제 전체의 색인을 유지하며 참조 연결과 주의사항이 있는 항목을 펼친다."""
337
396
  entries = [
338
397
  entry for entry in group_context.parse_memory_entries(region)
339
398
  if entry.task_id != own_task_segment
340
399
  ]
341
400
  if not entries:
342
401
  return []
343
- return [
344
- "",
345
- "## Task-Group Memory",
346
- "",
347
- GROUP_MEMORY_PREFACE,
348
- "",
349
- group_context.render_memory_entries(entries).strip(),
350
- ]
402
+ tokens = set(_TASK_REFERENCE_RE.findall(reference_text.casefold()))
403
+ own_ids = {own_task_segment, group_context.ticket_id_from_brief_id(own_task_segment)}
404
+ rendered = [group_context.render_memory_entries([entry]).strip() for entry in entries]
405
+ entry_tokens = [set(_TASK_REFERENCE_RE.findall(text.casefold())) for text in rendered]
406
+ selected: set[int] = set()
407
+ while True:
408
+ previous_count = len(selected)
409
+ for index, entry in enumerate(entries):
410
+ aliases = {entry.task_id.casefold(), group_context.ticket_id_from_brief_id(entry.task_id).casefold()}
411
+ if index in selected:
412
+ continue
413
+ if (aliases & tokens or own_ids & entry_tokens[index]
414
+ or entry.watch_out or not entry.record):
415
+ selected.add(index)
416
+ tokens.update(entry_tokens[index])
417
+ if len(selected) == previous_count:
418
+ break
419
+ lines = ["", "## Task-Group Memory", "", GROUP_MEMORY_PREFACE, ""]
420
+ for index, entry in enumerate(entries):
421
+ if index in selected:
422
+ lines.extend([rendered[index], ""])
423
+ else:
424
+ lines.extend([
425
+ f"### {entry.task_id}",
426
+ f"- headline: {entry.headline or '_(none)_'}",
427
+ f"- record: `{entry.record}`",
428
+ "- Detail: index-only; read the source if relevant or uncertain.",
429
+ "",
430
+ ])
431
+ if entry.source == "direct":
432
+ lines.extend(["- Source: direct work; no cross-verification performed for this record.", ""])
433
+ return lines
351
434
 
352
435
 
353
436
  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
 
@@ -0,0 +1,109 @@
1
+ """실행 없이 등록한 작업의 결과를 기존 작업·그룹 조회 경로에 연결한다."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ from datetime import date
7
+ from pathlib import Path
8
+
9
+ from okstra_project import project_json_path, tasks_root
10
+ from okstra_project.dirs import TASK_CATALOG_RELATIVE
11
+
12
+ from . import group_context
13
+ from .brief_frontmatter import read_brief_frontmatter
14
+ from .ids import slugify_task_segment
15
+ from .json_boundary import load_owned_object, write_owned_object_atomic
16
+ from .paths import task_dir, task_manifest_file
17
+ from .render import render_task_catalog_discovery
18
+ from .run_context import dir_flock
19
+
20
+
21
+ def find_brief_tasks(project_root: Path, token: str, task_group: str) -> list[dict]:
22
+ project = load_owned_object(project_json_path(project_root), artifact="project config")
23
+ project_id = project.get("projectId", "")
24
+ if not project_id:
25
+ raise ValueError("project.json has no projectId")
26
+ matches = []
27
+ for folder in sorted(group_context.briefs_root(project_root).glob("*")):
28
+ if not folder.is_dir() or (task_group and folder.name != slugify_task_segment(task_group)):
29
+ continue
30
+ for brief in group_context.group_briefs(project_root, folder.name):
31
+ identities = (brief["brief_id"], brief["ticket_id"], folder.name)
32
+ if token.casefold() not in {str(value).casefold() for value in identities}:
33
+ continue
34
+ path = project_root / brief["brief"]
35
+ frontmatter = read_brief_frontmatter(path)
36
+ group = frontmatter.get("task-group") or folder.name
37
+ if slugify_task_segment(group) != folder.name:
38
+ raise ValueError(f"Brief task-group does not match its directory: {brief['brief']}")
39
+ task_id = slugify_task_segment(brief["brief_id"])
40
+ root = task_dir(project_root, slugify_task_segment(group), task_id)
41
+ matches.append({
42
+ "_briefVerified": True,
43
+ "schemaVersion": "1.0", "projectId": project_id,
44
+ "projectRoot": str(project_root), "taskGroup": group, "taskId": task_id,
45
+ "taskKey": f"{project_id}:{group}:{task_id}",
46
+ "taskGroupPathSegment": slugify_task_segment(group),
47
+ "taskIdPathSegment": task_id, "taskBriefPath": brief["brief"],
48
+ "taskRootPath": root.relative_to(project_root).as_posix(),
49
+ "taskManifestPath": task_manifest_file(root).relative_to(project_root).as_posix(),
50
+ })
51
+ return matches
52
+
53
+
54
+ def record_direct_work(project_root: Path, manifest_path: Path, manifest: dict) -> None:
55
+ """상태와 본문이 같은 재시도는 기존 결과를 재사용한다. 정본 포인터는 호출자가 쓴다."""
56
+ summary = str(manifest.get("workStatusNote") or "").strip()
57
+ if not summary:
58
+ raise ValueError("Direct completion requires --note or --note-file with the work and verification summary")
59
+ previous_path = manifest.get("latestWorkRecordPath", "")
60
+ if previous_path:
61
+ (project_root / previous_path).resolve().relative_to(manifest_path.parent.resolve())
62
+ previous = (
63
+ load_owned_object(project_root / previous_path, artifact="direct work record")
64
+ if previous_path else {}
65
+ )
66
+ if previous.get("summary") == summary and previous.get("statusUpdatedAt") == manifest["workStatusUpdatedAt"]:
67
+ return
68
+ record = {
69
+ "schemaVersion": "1.0", "taskKey": manifest["taskKey"], "source": "direct",
70
+ "summary": summary, "crossVerification": "not-performed",
71
+ "statusUpdatedAt": manifest["workStatusUpdatedAt"],
72
+ "previousRecordPath": previous_path,
73
+ }
74
+ identity = {key: value for key, value in record.items() if key != "statusUpdatedAt"}
75
+ digest = hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest()
76
+ path = manifest_path.parent / "work-records" / f"{digest}.json"
77
+ if path.exists():
78
+ saved = load_owned_object(path, artifact="direct work record")
79
+ manifest["workStatusUpdatedAt"] = saved["statusUpdatedAt"]
80
+ manifest["updatedAt"] = saved["statusUpdatedAt"]
81
+ else:
82
+ write_owned_object_atomic(path, record, artifact="direct work record")
83
+ manifest["latestWorkRecordPath"] = path.relative_to(project_root).as_posix()
84
+
85
+
86
+ def publish_direct_work(project_root: Path, manifest: dict) -> Path | None:
87
+ """상태 저장 뒤 재실행해도 같은 그룹 항목을 갱신한다."""
88
+ catalog = project_root / TASK_CATALOG_RELATIVE
89
+ with dir_flock(catalog.parent, ".task-catalog.lock"):
90
+ render_task_catalog_discovery(str(catalog), {
91
+ "PROJECT_ROOT": str(project_root), "OKSTRA_TASKS_ROOT": str(tasks_root(project_root)),
92
+ "PROJECT_ID": manifest["projectId"], "TASK_KEY": manifest["taskKey"],
93
+ "RUN_TIMESTAMP_ISO": manifest["workStatusUpdatedAt"],
94
+ })
95
+ if not manifest.get("latestWorkRecordPath") or manifest["workStatus"] != "done":
96
+ return group_context.refresh_group_queue(project_root, manifest["taskGroup"])
97
+ record = load_owned_object(
98
+ project_root / manifest["latestWorkRecordPath"], artifact="direct work record"
99
+ )
100
+ entry = group_context.MemoryEntry(
101
+ task_id=manifest["taskId"], task_type="", seq="", next_phase="",
102
+ date=record["statusUpdatedAt"][:10], headline=" ".join(record["summary"].split())[:group_context.HEADLINE_MAX],
103
+ decisions=(), follow_ups=(), record=manifest["latestWorkRecordPath"],
104
+ source="direct",
105
+ )
106
+ target, _ = group_context.record_task_memory(
107
+ project_root, manifest["taskGroup"], entry, today=date.today()
108
+ )
109
+ return target
@@ -102,6 +102,7 @@ class MemoryEntry:
102
102
  follow_ups: tuple[str, ...]
103
103
  record: str
104
104
  watch_out: tuple[str, ...] = ()
105
+ source: str = "run"
105
106
 
106
107
 
107
108
  def memory_entry_from_record(
@@ -198,6 +199,8 @@ def parse_memory_entries(region: str) -> list[MemoryEntry]:
198
199
  task_type=match.group("type"), seq=match.group("seq"),
199
200
  date=match.group("date"), next_phase=match.group("next").strip(),
200
201
  )
202
+ elif line.startswith("- direct: "):
203
+ current.update(source="direct", date=line[len("- direct: "):].strip())
201
204
  elif line.startswith("- headline: "):
202
205
  current["headline"] = line[len("- headline: "):].strip()
203
206
  elif line == "- decisions:":
@@ -215,6 +218,7 @@ def parse_memory_entries(region: str) -> list[MemoryEntry]:
215
218
 
216
219
 
217
220
  def _entry_from_fields(fields: Mapping[str, Any]) -> MemoryEntry:
221
+ record = fields.get("record", "")
218
222
  return MemoryEntry(
219
223
  task_id=fields.get("task_id", ""),
220
224
  task_type=fields.get("task_type", ""),
@@ -224,8 +228,9 @@ def _entry_from_fields(fields: Mapping[str, Any]) -> MemoryEntry:
224
228
  headline=fields.get("headline", ""),
225
229
  decisions=tuple(fields.get("decisions", [])),
226
230
  follow_ups=tuple(fields.get("follow_ups", [])),
227
- record=fields.get("record", ""),
231
+ record="" if record in _NONE_MARKERS else record,
228
232
  watch_out=tuple(fields.get("watch_out", [])),
233
+ source=fields.get("source", "run"),
229
234
  )
230
235
 
231
236
 
@@ -237,9 +242,12 @@ def render_memory_entries(entries: list[MemoryEntry]) -> str:
237
242
  for entry in entries:
238
243
  lines = [
239
244
  f"### {entry.task_id}",
240
- f"- latest: {entry.task_type} #{entry.seq} · {entry.date} · next: {entry.next_phase or '_(none)_'}",
245
+ (f"- direct: {entry.date}" if entry.source == "direct" else
246
+ f"- latest: {entry.task_type} #{entry.seq} · {entry.date} · next: {entry.next_phase or '_(none)_'}"),
241
247
  f"- headline: {entry.headline or '_(none)_'}",
242
248
  ]
249
+ if entry.source == "direct":
250
+ lines.append("- Verification: direct work; no cross-verification performed for this record.")
243
251
  if entry.decisions:
244
252
  lines.append("- decisions:")
245
253
  lines.extend(f" - {item}" for item in entry.decisions)
@@ -391,6 +399,12 @@ class QueueRow:
391
399
  duplicate_briefs: tuple[str, ...] = ()
392
400
 
393
401
 
402
+ def ticket_id_from_brief_id(brief_id: str) -> str:
403
+ """순번이 붙은 브리프 식별자에서 이슈 식별자를 복원한다."""
404
+ match = _ORDINAL_RE.match(brief_id)
405
+ return match.group("ticket") if match else brief_id
406
+
407
+
394
408
  def group_briefs(project_root: Path, task_group: str) -> list[dict[str, Any]]:
395
409
  """그룹 디렉터리의 브리프(프론트매터 `type: brief`)와 각 브리프의 대기 간선."""
396
410
  group_dir = group_context_file(project_root, task_group).parent
@@ -407,7 +421,7 @@ def group_briefs(project_root: Path, task_group: str) -> list[dict[str, Any]]:
407
421
  match = _ORDINAL_RE.match(brief_id)
408
422
  briefs.append({
409
423
  "brief_id": brief_id,
410
- "ticket_id": frontmatter.get("ticket-id") or (match.group("ticket") if match else brief_id),
424
+ "ticket_id": frontmatter.get("ticket-id") or ticket_id_from_brief_id(brief_id),
411
425
  "ordinal": int(match.group("ordinal")) if match else None,
412
426
  "brief": path.relative_to(project_root).as_posix(),
413
427
  "waits_for": _wait_edges(path.read_text(encoding="utf-8")),
@@ -461,6 +475,12 @@ def catalog_progress(project_root: Path, task_group: str) -> dict[str, tuple[str
461
475
  if str(entry.get("workStatus") or "") == WORK_STATUS_DONE:
462
476
  status = QUEUE_DONE
463
477
  progress = f"{progress} · marked done" if progress else "marked done"
478
+ elif entry.get("workStatus") == "todo":
479
+ status = QUEUE_NOT_STARTED
480
+ elif entry.get("workStatus") in ("in-progress", "blocked"):
481
+ status = QUEUE_IN_PROGRESS
482
+ if entry.get("latestWorkRecordPath"):
483
+ progress = f"{progress} · direct work recorded" if progress else "direct work recorded"
464
484
  out[task_id] = (status, progress)
465
485
  return out
466
486
 
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
  import json
11
11
  from pathlib import Path
12
12
  from typing import Optional
13
+ from okstra_project import list_project_tasks
13
14
 
14
15
  from .json_boundary import JsonBoundaryError, load_owned_object
15
16
 
@@ -64,3 +65,31 @@ def related_tasks_bullets(items: list[str]) -> str:
64
65
 
65
66
  def related_tasks_inline(items: list[str]) -> str:
66
67
  return ", ".join(items) if items else "None"
68
+
69
+
70
+ def direct_work_context(project_root: Path, task_key: str, related: list[str]) -> str:
71
+ """자기 작업과 다른 그룹의 명시적 관련 작업 결과를 준비 입력에 싣는다."""
72
+ own_group = task_key.split(":")[1].casefold()
73
+ wanted = {value.casefold() for value in related}
74
+ blocks = []
75
+ for task in list_project_tasks(project_root):
76
+ key = task["taskKey"]
77
+ is_own = key.casefold() == task_key.casefold()
78
+ is_related = key.casefold() in wanted or str(task.get("taskId", "")).casefold() in wanted
79
+ if not is_own and (not is_related or str(task.get("taskGroup", "")).casefold() == own_group):
80
+ continue
81
+ record_path = task.get("latestWorkRecordPath")
82
+ if not record_path:
83
+ continue
84
+ record_file = project_root / record_path
85
+ record_file.resolve().relative_to((project_root / ".okstra").resolve())
86
+ record = load_owned_object(record_file, artifact="direct work record")
87
+ blocks.extend([
88
+ f"### {key}", f"- Current work status: {task.get('workStatus', '')}",
89
+ "- Source: direct work; no cross-verification performed for this record.",
90
+ f"- Summary: {' '.join(record['summary'].split())[:400]}",
91
+ f"- Record: `{record_path}`", "",
92
+ ])
93
+ if not blocks:
94
+ return ""
95
+ return "\n".join(["## Direct Work Context", "", *blocks])
@@ -114,6 +114,7 @@ def _status_overview_text(rows: list[dict[str, object]]) -> str:
114
114
  _line("Latest report", row.get("latestReportRecordPath")),
115
115
  _line("Latest resume command", row.get("latestResumeCommandPath")),
116
116
  _line("Work status", row.get("workStatus")),
117
+ _line("Direct work record", row.get("latestWorkRecordPath")),
117
118
  ))
118
119
  return "# Okstra Status Input\n\n" + "".join(blocks)
119
120
 
@@ -106,6 +106,7 @@ def _overview_rows(
106
106
  "latestReportRecordPath": manifest.get("latestReportRecordPath") or latest_run.get("reportRecordPath"),
107
107
  "latestResumeCommandPath": latest_run.get("resumeCommandPath") or manifest.get("latestResumeCommandPath"),
108
108
  "workStatus": manifest.get("workStatus"),
109
+ "latestWorkRecordPath": manifest.get("latestWorkRecordPath"),
109
110
  "lastRun": latest_run.get("runTimestamp"),
110
111
  }
111
112
  if task_type and row["taskType"] != task_type:
@@ -310,6 +311,7 @@ def render_status_input(
310
311
  + _line("Work status", manifest.get("workStatus"))
311
312
  + _line("Work status updated at", manifest.get("workStatusUpdatedAt"))
312
313
  + _line("Work status note", manifest.get("workStatusNote"))
314
+ + _line("Direct work record", manifest.get("latestWorkRecordPath"))
313
315
  + _line("Latest report", manifest.get("latestReportRecordPath"))
314
316
  + _line("Latest resume command", manifest.get("latestResumeCommandPath"))
315
317
  + _line("History timeline", manifest.get("historyTimelinePath"))
@@ -363,6 +365,8 @@ def render_recap_input(project_root: Path, task_ref: str) -> str:
363
365
  "# Okstra Recap Input\n\n",
364
366
  _line("Task key", recap.get("taskKey")),
365
367
  _line("Run count", recap.get("runCount")),
368
+ _line("Work status", recap.get("workStatus")),
369
+ _line("Direct work record", recap.get("latestWorkRecordPath")),
366
370
  ]
367
371
  transitions = recap.get("transitions")
368
372
  for transition in transitions if isinstance(transitions, list) else []:
@@ -426,6 +430,8 @@ def render_group_recap_input(project_root: Path, task_group: str) -> str:
426
430
  _line("Phase state", task.get("currentPhaseState")),
427
431
  _line("Latest run status", task.get("latestRunStatus")),
428
432
  _line("Work status", task.get("workStatus")),
433
+ _line("Direct work record", task.get("latestWorkRecordPath")),
434
+ _line("Memory source", memory.get("source")),
429
435
  _line("Run count", task.get("runCount")),
430
436
  _line("Next phase", pointer.get("phase")),
431
437
  _line("Next phase status", pointer.get("status")),
@@ -930,11 +930,36 @@ class NextDispatch:
930
930
  }
931
931
 
932
932
 
933
+ def self_fix_rounds(verification: Mapping[str, Any]) -> frozenset[int]:
934
+ """마지막 검증 라운드 번호와 자동 수정 횟수를 구분한다."""
935
+ rounds = frozenset(
936
+ group["round"] for group in (verification.get("selfFixGroups") or [])
937
+ if isinstance(group, Mapping)
938
+ and type(group.get("round")) is int and group["round"] > 0
939
+ )
940
+ applied = verification.get("selfFixRoundsApplied")
941
+ if not rounds and type(applied) is int and applied > 0:
942
+ return frozenset({applied})
943
+ return rounds
944
+
945
+
946
+ def lead_decision_basis(item: Mapping[str, Any]) -> str:
947
+ """본문·범위·판정이 바뀌면 이전 리드 결정으로 새 쟁점을 해소할 수 없다."""
948
+ basis = {key: item.get(key) for key in (
949
+ "id", "subject", "block", "stageScope", "contentHash",
950
+ "verifiedContentHash", "verdicts",
951
+ )}
952
+ return hashlib.sha256(
953
+ json.dumps(basis, sort_keys=True, ensure_ascii=False).encode("utf-8")
954
+ ).hexdigest()
955
+
956
+
933
957
  def next_dispatch(
934
958
  items: Sequence[Mapping[str, Any]],
935
959
  payloads: Mapping[str, Mapping[str, Any]] | None = None,
936
960
  *,
937
961
  critic_rostered: bool = True,
962
+ decision_items: Sequence[Mapping[str, Any]] = (),
938
963
  ) -> NextDispatch:
939
964
  """환경 전용 UNVERIFIABLE 은 전체 라운드를 만들지 않는다. 일괄 오류만 그 워커.
940
965
 
@@ -945,6 +970,19 @@ def next_dispatch(
945
970
  `critic-tie` 다: critic 이 있는 run 을 사용자 결정으로 보내는 쪽이 더
946
971
  나쁜 오답이다.
947
972
  """
973
+ for authority in ("lead", "user"):
974
+ pending = tuple(
975
+ str(row["id"]) for row in decision_items
976
+ if row.get("decisionAuthority") == authority
977
+ and not row.get("leadDecisionApplied")
978
+ )
979
+ if pending:
980
+ return NextDispatch(
981
+ kind=f"{authority}-decision", workers=(), item_ids=pending,
982
+ reason="automatic self-fix finished; resolve remaining decisions without another worker batch",
983
+ )
984
+ if decision_items:
985
+ return NextDispatch(kind="none", workers=(), item_ids=(), reason="remaining decisions resolved")
948
986
  assigned = tuple(
949
987
  str(item["id"]) for item in items
950
988
  if isinstance(item.get("id"), str) and item["id"]
@@ -981,10 +1019,7 @@ def next_dispatch(
981
1019
  "approval decision per item"
982
1020
  ),
983
1021
  )
984
- return NextDispatch(
985
- kind="none", workers=(), item_ids=(),
986
- reason="no worker batch",
987
- )
1022
+ return NextDispatch(kind="none", workers=(), item_ids=(), reason="no worker batch")
988
1023
 
989
1024
 
990
1025
  def correction_prompt_text(queue_markdown: str) -> str:
@@ -1000,4 +1035,3 @@ def critic_tie_prompt_text(queue_markdown: str) -> str:
1000
1035
  def reverify_prompt_text(queue_markdown: str) -> str:
1001
1036
  """라운드 2+ 프롬프트. 직전 반대 의견을 읽으라는 지시가 큐보다 앞이다."""
1002
1037
  return f"{REVERIFY_PREAMBLE.rstrip()}\n\n{queue_markdown.lstrip()}"
1003
-