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.
- package/docs/architecture/storage-model.md +10 -0
- package/docs/cli.md +4 -3
- package/docs/project-structure-overview.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-report-translate.py +28 -8
- package/runtime/prompts/lead/convergence.md +17 -0
- package/runtime/prompts/lead/okstra-lead-contract.md +32 -6
- package/runtime/prompts/lead/plan-body-verification.md +41 -15
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +1 -1
- package/runtime/python/okstra_ctl/analysis_packet.py +95 -12
- package/runtime/python/okstra_ctl/context_cost.py +16 -6
- package/runtime/python/okstra_ctl/direct_work.py +109 -0
- package/runtime/python/okstra_ctl/group_context.py +23 -3
- package/runtime/python/okstra_ctl/material.py +29 -0
- package/runtime/python/okstra_ctl/model_io/lines.py +1 -0
- package/runtime/python/okstra_ctl/model_io/renderers.py +6 -0
- package/runtime/python/okstra_ctl/plan_items.py +39 -5
- package/runtime/python/okstra_ctl/plan_items_cli.py +61 -6
- package/runtime/python/okstra_ctl/recap.py +6 -0
- package/runtime/python/okstra_ctl/render.py +8 -4
- package/runtime/python/okstra_ctl/report_assembly.py +20 -2
- package/runtime/python/okstra_ctl/report_narrative.py +4 -4
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +49 -40
- package/runtime/python/okstra_ctl/report_translation.py +19 -0
- package/runtime/python/okstra_ctl/run.py +5 -0
- package/runtime/python/okstra_ctl/set_work_status.py +90 -40
- package/runtime/python/okstra_ctl/task_list_cli.py +2 -0
- package/runtime/python/okstra_ctl/user_response.py +1 -0
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +5 -1
- package/runtime/python/okstra_project/state.py +4 -0
- package/runtime/schemas/final-report-v3.0.schema.json +10 -0
- package/runtime/skills/okstra-inspect/SKILL.md +6 -1
- package/runtime/skills/okstra-inspect/facets/recap.md +6 -1
- package/runtime/skills/okstra-inspect/facets/status.md +12 -0
- package/runtime/templates/reports/html/assets/base.css +4 -0
- package/runtime/templates/reports/html/assets/base.js +30 -0
- package/runtime/validators/validate-run.py +47 -6
- 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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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=
|
|
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"-
|
|
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 (
|
|
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
|
-
|