okstra 0.197.0 → 0.197.1
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 +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/python/okstra_ctl/conformance.py +26 -5
- package/runtime/python/okstra_ctl/group_context.py +96 -3
- package/runtime/python/okstra_ctl/run.py +23 -0
- package/runtime/python/okstra_ctl/set_work_status.py +30 -1
- package/runtime/skills/okstra-brief-gen/SKILL.md +24 -7
- package/runtime/skills/okstra-inspect/facets/recap.md +17 -1
- package/runtime/templates/reports/group-context.template.md +1 -1
- package/runtime/validators/validate-run.py +33 -2
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -41,11 +41,32 @@ _CONFORMANCE_TESTS_RE = re.compile(
|
|
|
41
41
|
)
|
|
42
42
|
|
|
43
43
|
|
|
44
|
-
def normalize_conformance_script(
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
def normalize_conformance_script(
|
|
45
|
+
script: str,
|
|
46
|
+
task_root: Path | None = None,
|
|
47
|
+
) -> str:
|
|
48
|
+
"""script 표기를 task-root 상대형 한 가지로 맞춘다.
|
|
49
|
+
|
|
50
|
+
같은 스크립트를 선언(승인 계획)과 매니페스트 entry 가 서로 다른 접두사로
|
|
51
|
+
적는다 — `<task_root>/qa/...`, `./qa/...`, 그리고 계획이 절대경로
|
|
52
|
+
`/…/<task_root>/qa/scripts/stage-2.ts` 를 쓰고 실행자는 `qa/scripts/stage-2.ts`
|
|
53
|
+
를 쓴 실측(2026-09-10 dev-10628-3 stage 2). 대조 전에 양쪽을 이 함수로
|
|
54
|
+
통과시켜 표기 차이만으로 stage 가 막히지 않게 한다.
|
|
55
|
+
|
|
56
|
+
`task_root` 를 주면 그 아래를 가리키는 절대경로도 상대형으로 접는다. 주지
|
|
57
|
+
않으면 절대경로는 그대로 둔다 — 계획 단계 파싱처럼 task_root 를 모르는
|
|
58
|
+
호출부가 있다.
|
|
59
|
+
"""
|
|
47
60
|
prefix = "<task_root>/"
|
|
48
|
-
|
|
61
|
+
value = script[len(prefix):] if script.startswith(prefix) else script
|
|
62
|
+
while value.startswith("./"):
|
|
63
|
+
value = value[2:]
|
|
64
|
+
if task_root is not None and value.startswith("/"):
|
|
65
|
+
try:
|
|
66
|
+
return Path(value).resolve().relative_to(task_root.resolve()).as_posix()
|
|
67
|
+
except (ValueError, OSError):
|
|
68
|
+
return value
|
|
69
|
+
return value
|
|
49
70
|
|
|
50
71
|
|
|
51
72
|
# 계획 서사의 정본 줄은 `Conformance tests: stage-<N> — <나머지>` 이고 data.json
|
|
@@ -87,7 +108,7 @@ def missing_declared_scripts(entries: object, task_root: Path) -> list[str]:
|
|
|
87
108
|
script = entry.get("script")
|
|
88
109
|
if not isinstance(script, str) or not script.strip():
|
|
89
110
|
continue
|
|
90
|
-
relative = normalize_conformance_script(script)
|
|
111
|
+
relative = normalize_conformance_script(script, task_root)
|
|
91
112
|
candidate = (root / relative).resolve()
|
|
92
113
|
stage_number = str(entry.get("stageKey") or "").rsplit("-stage-", 1)[-1]
|
|
93
114
|
try:
|
|
@@ -70,7 +70,8 @@ _NONE_MARKERS = {"_(none)_", ""}
|
|
|
70
70
|
MEMORY_BEGIN = "<!-- okstra:task-memory:begin -->"
|
|
71
71
|
MEMORY_END = "<!-- okstra:task-memory:end -->"
|
|
72
72
|
MEMORY_NOTE = (
|
|
73
|
-
"<!-- okstra
|
|
73
|
+
"<!-- okstra redraws this region after every report-finalize, at "
|
|
74
|
+
"`okstra set-work-status`, and before each run copies this file. "
|
|
74
75
|
"Edit the sections above it, not this one. -->"
|
|
75
76
|
)
|
|
76
77
|
MEMORY_HEADING = "## Task Memory"
|
|
@@ -323,6 +324,32 @@ def record_task_memory(
|
|
|
323
324
|
return target, queue
|
|
324
325
|
|
|
325
326
|
|
|
327
|
+
def refresh_group_queue(project_root: Path, task_group: str) -> Path | None:
|
|
328
|
+
"""그룹 문서의 시작 순서만 지금 값으로 다시 그린다. 문서가 없으면 아무것도 안 한다.
|
|
329
|
+
|
|
330
|
+
기록된 항목과 사람 절은 바이트 그대로 두고 큐만 다시 계산한다. 이 투영을
|
|
331
|
+
쓰는 곳은 원래 report-finalize 의 `record-group-memory` 하나뿐이라, 리포트
|
|
332
|
+
없이 끝난 run 이나 사람이 손으로 바꾼 상태는 다음 finalize 까지 문서에
|
|
333
|
+
닿지 않았다(2026-09-10 실측, cache 그룹 dev-10635: `workStatus` 를 done 으로
|
|
334
|
+
적었는데 큐는 `[in progress] — requirements-discovery (blocked)` 그대로).
|
|
335
|
+
같은 그룹의 finalize 와 겹칠 수 있으므로 같은 flock 아래에서 읽고-쓴다.
|
|
336
|
+
"""
|
|
337
|
+
target = group_context_file(project_root, task_group)
|
|
338
|
+
if not target.is_file():
|
|
339
|
+
return None
|
|
340
|
+
with dir_flock(target.parent, LOCK_FILENAME):
|
|
341
|
+
text = target.read_text(encoding="utf-8")
|
|
342
|
+
before, region, after = split_memory_region(text)
|
|
343
|
+
if not region:
|
|
344
|
+
return None
|
|
345
|
+
entries = parse_memory_entries(region)
|
|
346
|
+
queue = group_queue(project_root, task_group, entries)
|
|
347
|
+
updated = before + render_memory_region(entries, queue) + after
|
|
348
|
+
if updated != text:
|
|
349
|
+
target.write_text(updated, encoding="utf-8")
|
|
350
|
+
return target
|
|
351
|
+
|
|
352
|
+
|
|
326
353
|
# --- 시작 순서 ----------------------------------------------------------------
|
|
327
354
|
#
|
|
328
355
|
# 그룹의 task 는 순서가 있을 수 있다. 순서는 brief-gen 이 매긴 브리프 순번
|
|
@@ -332,6 +359,11 @@ def record_task_memory(
|
|
|
332
359
|
# 15번 가드 티켓이 1번을 `blocks` 한다고 적었지만 사용자는 1번을 먼저 끝냈다.
|
|
333
360
|
# 큐는 영역 머리에 실리고, 이 task 가 끝나는 closeout 은 큐의 다음 task 를 이름한다.
|
|
334
361
|
|
|
362
|
+
# `okstra set-work-status` 가 task-manifest 에 적는 값 중 큐 상태를 덮는 것.
|
|
363
|
+
# 나머지(`todo` / `in-progress` / `blocked`)는 큐의 세 상태로 옮길 때 파생값보다
|
|
364
|
+
# 나은 정보가 없어 덮지 않는다.
|
|
365
|
+
WORK_STATUS_DONE = "done"
|
|
366
|
+
|
|
335
367
|
QUEUE_DONE = "done"
|
|
336
368
|
QUEUE_IN_PROGRESS = "in progress"
|
|
337
369
|
QUEUE_NOT_STARTED = "not started"
|
|
@@ -354,6 +386,9 @@ class QueueRow:
|
|
|
354
386
|
status: str # QUEUE_*
|
|
355
387
|
progress: str # "<task-type> #<seq>" for a recorded task, else ""
|
|
356
388
|
waits_for: tuple[str, ...] = () # ticket ids the graph says come first and are not done
|
|
389
|
+
# 같은 `brief-id` 를 가진 다른 브리프 파일들. 하나의 task 를 두 파일이
|
|
390
|
+
# 주장하는 상태이므로 큐는 정본 한 줄만 싣고 나머지를 여기 이름한다.
|
|
391
|
+
duplicate_briefs: tuple[str, ...] = ()
|
|
357
392
|
|
|
358
393
|
|
|
359
394
|
def group_briefs(project_root: Path, task_group: str) -> list[dict[str, Any]]:
|
|
@@ -417,6 +452,15 @@ def catalog_progress(project_root: Path, task_group: str) -> dict[str, tuple[str
|
|
|
417
452
|
state = str(entry.get("currentPhaseState") or entry.get("latestRunStatus") or "")
|
|
418
453
|
progress = f"{phase} ({state})" if phase and state else phase
|
|
419
454
|
status = QUEUE_DONE if pointer.get("status") == STATUS_TERMINAL else QUEUE_IN_PROGRESS
|
|
455
|
+
# 사용자가 `okstra set-work-status <task> done` 으로 끝났다고 선언한 task
|
|
456
|
+
# 는 끝난 것이다. 파생 포인터만 보면 리포트 없이 끝난 run 이 영원히
|
|
457
|
+
# `[in progress]` 로 남는다 — 실측(2026-09-10, fontsninja-v3-site cache
|
|
458
|
+
# 그룹 dev-10635): 사람이 AWS 콘솔에서 작업을 마치고 `done` 을 적었는데,
|
|
459
|
+
# 그 run 이 리포트 조립 실패로 끝나 포인터가 `blocked` 에 멈춰 있었다.
|
|
460
|
+
# 파생 표기는 지우지 않는다 — 왜 done 인지가 그 차이에 남는다.
|
|
461
|
+
if str(entry.get("workStatus") or "") == WORK_STATUS_DONE:
|
|
462
|
+
status = QUEUE_DONE
|
|
463
|
+
progress = f"{progress} · marked done" if progress else "marked done"
|
|
420
464
|
out[task_id] = (status, progress)
|
|
421
465
|
return out
|
|
422
466
|
|
|
@@ -439,6 +483,7 @@ def group_queue(
|
|
|
439
483
|
for brief in briefs:
|
|
440
484
|
for ticket, before in brief["waits_for"].items():
|
|
441
485
|
waits.setdefault(ticket, set()).update((before & by_ticket.keys()) - {ticket})
|
|
486
|
+
briefs, duplicates = _fold_duplicate_briefs(briefs)
|
|
442
487
|
ordered = sorted(briefs, key=lambda b: (b["ordinal"] is None, b["ordinal"] or 0, b["brief_id"]))
|
|
443
488
|
by_task = {entry.task_id: entry for entry in entries}
|
|
444
489
|
recorded = catalog_progress(project_root, task_group)
|
|
@@ -464,10 +509,50 @@ def group_queue(
|
|
|
464
509
|
rows.append(QueueRow(
|
|
465
510
|
slugify_task_segment(brief["brief_id"]), brief["brief_id"], brief["ticket_id"],
|
|
466
511
|
brief["brief"], status, progress, open_waits,
|
|
512
|
+
duplicates.get(brief["brief_id"], ()),
|
|
467
513
|
))
|
|
468
514
|
return rows
|
|
469
515
|
|
|
470
516
|
|
|
517
|
+
def _fold_duplicate_briefs(
|
|
518
|
+
briefs: list[dict[str, Any]],
|
|
519
|
+
) -> tuple[list[dict[str, Any]], dict[str, tuple[str, ...]]]:
|
|
520
|
+
"""같은 `brief-id` 를 주장하는 브리프들을 정본 하나로 접는다.
|
|
521
|
+
|
|
522
|
+
`brief-id` 는 task 디렉터리 이름(`task_id`)의 출처라, 두 파일이 같은 id 를
|
|
523
|
+
달면 하나의 task 를 둘이 주장하는 상태다. 접지 않으면 큐가 같은 task 를 두
|
|
524
|
+
줄로 싣고 번호가 브리프 수보다 커진다 — `next_in_group` 과 closeout 도 같은
|
|
525
|
+
task 를 두 번 가리킨다(2026-09-10 실측, fontsninja-v3-site `cache` 그룹:
|
|
526
|
+
개정 전 브리프를 `.superseded-<날짜>.md` 로 같은 디렉터리에 남겨 15개 그룹의
|
|
527
|
+
큐가 16번까지 갔다).
|
|
528
|
+
|
|
529
|
+
정본은 파일 이름이 곧 `brief-id` 인 파일이다 — brief-gen 이 쓰는 이름이고,
|
|
530
|
+
같은 id 를 단 다른 이름은 사본이다. 그런 파일이 없거나 여럿이면 경로 순서로
|
|
531
|
+
첫 번째다. 거절하지 않는 이유는 이 큐를 report-finalize 와 위저드가 읽기
|
|
532
|
+
때문이다: 브리프 디렉터리 정리가 안 됐다고 run 발행을 막을 일은 아니다.
|
|
533
|
+
대신 남은 사본을 `duplicate_briefs` 로 실어 큐가 그 사실을 말한다.
|
|
534
|
+
"""
|
|
535
|
+
by_id: dict[str, list[dict[str, Any]]] = {}
|
|
536
|
+
for brief in briefs:
|
|
537
|
+
by_id.setdefault(brief["brief_id"], []).append(brief)
|
|
538
|
+
kept: list[dict[str, Any]] = []
|
|
539
|
+
duplicates: dict[str, tuple[str, ...]] = {}
|
|
540
|
+
for brief_id, group in by_id.items():
|
|
541
|
+
if len(group) == 1:
|
|
542
|
+
kept.append(group[0])
|
|
543
|
+
continue
|
|
544
|
+
ordered_group = sorted(group, key=lambda b: b["brief"])
|
|
545
|
+
canonical = next(
|
|
546
|
+
(b for b in ordered_group if Path(b["brief"]).stem == brief_id),
|
|
547
|
+
ordered_group[0],
|
|
548
|
+
)
|
|
549
|
+
kept.append(canonical)
|
|
550
|
+
duplicates[brief_id] = tuple(
|
|
551
|
+
b["brief"] for b in ordered_group if b is not canonical
|
|
552
|
+
)
|
|
553
|
+
return kept, duplicates
|
|
554
|
+
|
|
555
|
+
|
|
471
556
|
def next_in_group(queue: list[QueueRow]) -> QueueRow | None:
|
|
472
557
|
"""시작 순서에서 아직 시작하지 않은 첫 task."""
|
|
473
558
|
return next((row for row in queue if row.status == QUEUE_NOT_STARTED), None)
|
|
@@ -482,6 +567,13 @@ def render_queue(queue: list[QueueRow]) -> str:
|
|
|
482
567
|
if row.waits_for:
|
|
483
568
|
tail += f" (waits for {', '.join(row.waits_for)})"
|
|
484
569
|
lines.append(f"{number}. [{row.status}] {row.task_id}{tail}")
|
|
570
|
+
# 사본을 이름한다. 이 줄이 없으면 큐가 조용히 한 파일을 무시하고, 그
|
|
571
|
+
# 파일을 고친 사람은 자기 편집이 어디로 갔는지 알 길이 없다.
|
|
572
|
+
for extra in row.duplicate_briefs:
|
|
573
|
+
lines.append(
|
|
574
|
+
f" - ignored duplicate of this brief-id: `{extra}` — one task, "
|
|
575
|
+
"two brief files. Move the copy out of the briefs directory."
|
|
576
|
+
)
|
|
485
577
|
return "\n".join(lines) + "\n"
|
|
486
578
|
|
|
487
579
|
|
|
@@ -657,8 +749,9 @@ def _init_command(args: argparse.Namespace) -> int:
|
|
|
657
749
|
"line remains; delete the file if the group needs no context."
|
|
658
750
|
)
|
|
659
751
|
print(
|
|
660
|
-
f"The trailing `{MEMORY_HEADING}` region is
|
|
661
|
-
"report-finalize
|
|
752
|
+
f"The trailing `{MEMORY_HEADING}` region is okstra's — redrawn after every "
|
|
753
|
+
"report-finalize, at `okstra set-work-status`, and before each run copies "
|
|
754
|
+
"this file; leave it alone."
|
|
662
755
|
)
|
|
663
756
|
return 0
|
|
664
757
|
|
|
@@ -3631,6 +3631,23 @@ def _stage_or_clear(path: Path, body: str) -> None:
|
|
|
3631
3631
|
path.unlink(missing_ok=True)
|
|
3632
3632
|
|
|
3633
3633
|
|
|
3634
|
+
def _refresh_group_context_queue(project_root: Path, task_group: str) -> None:
|
|
3635
|
+
"""그룹 문서의 시작 순서를 지금 값으로 다시 그린다. 실패해도 run 은 계속한다.
|
|
3636
|
+
|
|
3637
|
+
파생 표면이라 이것 때문에 run 준비를 멈출 이유가 없다. 대신 삼키지 않고
|
|
3638
|
+
stderr 로 알린다 — 워커가 낡은 형제 상태를 읽었다는 사실을 나중에 설명할
|
|
3639
|
+
근거가 남아야 한다.
|
|
3640
|
+
"""
|
|
3641
|
+
try:
|
|
3642
|
+
group_context.refresh_group_queue(project_root, task_group)
|
|
3643
|
+
except (OSError, ValueError) as exc:
|
|
3644
|
+
print(
|
|
3645
|
+
f"prepare: group-context start order for {task_group!r} could not be "
|
|
3646
|
+
f"redrawn before it was copied into the instruction set: {exc}",
|
|
3647
|
+
file=sys.stderr,
|
|
3648
|
+
)
|
|
3649
|
+
|
|
3650
|
+
|
|
3634
3651
|
def _write_instruction_set_sources(
|
|
3635
3652
|
inp: PrepareInputs,
|
|
3636
3653
|
ctx: dict,
|
|
@@ -3694,6 +3711,12 @@ def _write_instruction_set_sources(
|
|
|
3694
3711
|
(instruction_set / "analysis-material.md").write_text(review_material, encoding="utf-8")
|
|
3695
3712
|
shutil.copyfile(inp.brief_path, instruction_set / "task-brief.md")
|
|
3696
3713
|
# 그룹 맥락은 preflight 를 통과한 파일만 여기 온다(`_validate_group_context_preflight`).
|
|
3714
|
+
#
|
|
3715
|
+
# 복사 직전에 시작 순서를 다시 그린다. 이 파일은 그대로 워커의 analysis
|
|
3716
|
+
# packet 에 실리므로, 형제 task 의 상태가 낡은 채로 복사되면 워커는 이미 끝난
|
|
3717
|
+
# task 를 진행 중으로 읽는다. 투영을 쓰는 곳이 report-finalize 뿐이던 동안
|
|
3718
|
+
# 리포트 없이 끝난 run 의 상태는 다음 finalize 까지 문서에 닿지 않았다.
|
|
3719
|
+
_refresh_group_context_queue(Path(inp.project_root), inp.task_group)
|
|
3697
3720
|
group_context_source = group_context.group_context_file(
|
|
3698
3721
|
Path(inp.project_root), inp.task_group
|
|
3699
3722
|
)
|
|
@@ -14,6 +14,7 @@ import sys
|
|
|
14
14
|
from datetime import datetime, timezone
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
|
+
from okstra_ctl import group_context
|
|
17
18
|
from okstra_ctl.ids import slugify_task_segment
|
|
18
19
|
from okstra_ctl.fixed_text import line, scalar
|
|
19
20
|
from okstra_ctl.paths import task_dir, task_manifest_file
|
|
@@ -38,6 +39,7 @@ def _emit(payload: dict, *, text: bool = False) -> None:
|
|
|
38
39
|
("taskKey", "Task key"), ("previousWorkStatus", "Previous work status"),
|
|
39
40
|
("workStatus", "Work status"), ("workStatusUpdatedAt", "Updated at"),
|
|
40
41
|
("workStatusNote", "Note"), ("taskManifestPath", "Task manifest"),
|
|
42
|
+
("groupContextPath", "Group context"),
|
|
41
43
|
):
|
|
42
44
|
if key in payload:
|
|
43
45
|
lines.append(line(label, payload.get(key)).rstrip("\n"))
|
|
@@ -152,6 +154,29 @@ def _updated_payload(manifest_path: Path, manifest: dict, entry: dict, args) ->
|
|
|
152
154
|
}
|
|
153
155
|
|
|
154
156
|
|
|
157
|
+
def _refresh_group_queue(project_root: Path, entry: dict) -> str:
|
|
158
|
+
"""상태를 바꾼 task 가 속한 그룹 문서의 시작 순서를 다시 그린다.
|
|
159
|
+
|
|
160
|
+
그 투영을 쓰는 곳이 report-finalize 하나뿐이라, 여기서 갱신하지 않으면
|
|
161
|
+
사람이 적은 상태가 다음 finalize 까지 그룹 문서에 닿지 않는다. 그룹 문서가
|
|
162
|
+
없거나 갱신에 실패해도 상태 기록 자체는 성공이다 — 매니페스트는 이미
|
|
163
|
+
쓰였고, 이것은 파생 표면이다. 실패는 삼키지 않고 stderr 로 알린다.
|
|
164
|
+
"""
|
|
165
|
+
task_group = str(entry.get("taskGroup") or "")
|
|
166
|
+
if not task_group:
|
|
167
|
+
return ""
|
|
168
|
+
try:
|
|
169
|
+
target = group_context.refresh_group_queue(project_root, task_group)
|
|
170
|
+
except (OSError, ValueError) as exc:
|
|
171
|
+
print(
|
|
172
|
+
f"set-work-status: work status recorded, but the group-context start "
|
|
173
|
+
f"order for {task_group!r} could not be redrawn: {exc}",
|
|
174
|
+
file=sys.stderr,
|
|
175
|
+
)
|
|
176
|
+
return ""
|
|
177
|
+
return str(target) if target is not None else ""
|
|
178
|
+
|
|
179
|
+
|
|
155
180
|
def main(argv: list[str] | None = None) -> int:
|
|
156
181
|
args = _parse_args(argv)
|
|
157
182
|
project_root, entry, resolution_exit = _resolve_entry(args)
|
|
@@ -184,7 +209,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
184
209
|
)
|
|
185
210
|
return 1
|
|
186
211
|
|
|
187
|
-
|
|
212
|
+
payload = _updated_payload(manifest_path, manifest, entry, args)
|
|
213
|
+
refreshed = _refresh_group_queue(project_root, entry)
|
|
214
|
+
if refreshed:
|
|
215
|
+
payload["groupContextPath"] = refreshed
|
|
216
|
+
_emit(payload, text=args.text)
|
|
188
217
|
return 0
|
|
189
218
|
|
|
190
219
|
|
|
@@ -1024,13 +1024,30 @@ per-page ratio instead of origin load). If the file is absent, ask once via
|
|
|
1024
1024
|
- `Skip — this group needs no shared context`.
|
|
1025
1025
|
|
|
1026
1026
|
Never fill the skeleton yourself from the tickets: a ticket summary decides
|
|
1027
|
-
nothing, and the sections ask for what the tickets do not say.
|
|
1028
|
-
|
|
1029
|
-
file
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1027
|
+
nothing, and the sections ask for what the tickets do not say.
|
|
1028
|
+
|
|
1029
|
+
If the file exists, what you do with it depends on what you are writing:
|
|
1030
|
+
|
|
1031
|
+
- **A new brief** leaves it untouched. Mention its path in the hand-off block.
|
|
1032
|
+
- **A rewrite or correction of an existing brief** reconciles it in the same
|
|
1033
|
+
response, whenever the change supersedes something the group document
|
|
1034
|
+
states — a Definition of Better figure, a success signal, a Group-Wide
|
|
1035
|
+
Constraint, a Ticket Relation, or what it says is executable from the
|
|
1036
|
+
project root. okstra copies this file into every run's
|
|
1037
|
+
`instruction-set/task-group-context.md` and carries it in the analysis
|
|
1038
|
+
packet **ahead of the brief extract**, so a sentence you corrected in the
|
|
1039
|
+
brief is still outranked by the group document's stale copy of it. Qualify a
|
|
1040
|
+
diverged figure with the date and method of the measurement that contradicts
|
|
1041
|
+
it rather than deleting it, and name the brief the correction came from.
|
|
1042
|
+
|
|
1043
|
+
Either way, edit only the authored sections. The file's trailing `## Task Memory`
|
|
1044
|
+
region (between `<!-- okstra:task-memory:begin -->` and `end`) is okstra's: it
|
|
1045
|
+
holds the group's start order and each task's latest conclusion, and okstra
|
|
1046
|
+
redraws it after every run's `report-finalize`, at `okstra set-work-status`, and
|
|
1047
|
+
before each run copies the file into its instruction set. Anything written there
|
|
1048
|
+
by hand is overwritten without warning. A group whose runs finished before
|
|
1049
|
+
anyone created the file already has one holding that region alone — `init` then
|
|
1050
|
+
inserts the human sections above it.
|
|
1034
1051
|
|
|
1035
1052
|
Then stop. Do not invoke `okstra-run` directly — the user chooses when to
|
|
1036
1053
|
proceed, and they may want to edit the brief externally first. In the
|
|
@@ -6,7 +6,7 @@ Loaded lazily by the dispatch table in `SKILL.md` (core). Shared rules — Step
|
|
|
6
6
|
|
|
7
7
|
Trigger phrases: "okstra recap", "recap", "work summary", "summarize this task", "before/after summary", "explain this work", "task question".
|
|
8
8
|
|
|
9
|
-
On top of the `.okstra` artifacts accumulated for a single task-id — or for every task of one task-group — (a) produce a before/after summary and (b) answer free-form questions about that work. By default it reads only the `.okstra/` subtree (artifact mode). It expands to code mode only when the user explicitly asks to look at the code changes too. This sub-command performs
|
|
9
|
+
On top of the `.okstra` artifacts accumulated for a single task-id — or for every task of one task-group — (a) produce a before/after summary and (b) answer free-form questions about that work. By default it reads only the `.okstra/` subtree (artifact mode). It expands to code mode only when the user explicitly asks to look at the code changes too. This sub-command performs the `recap-log.jsonl` append, the `notes/` note authoring (recap.5), and the group-context reconciliation that note triggers (recap.6); it never mutates `task-manifest.json` / catalog / timeline, and it touches `group-context.md` only in the authored sections above the `<!-- okstra:task-memory:begin -->` marker.
|
|
10
10
|
|
|
11
11
|
### recap.1 — Resolve target
|
|
12
12
|
|
|
@@ -144,3 +144,19 @@ Write the body to the scratchpad as markdown first, then pass it with `--body-fi
|
|
|
144
144
|
4. **`notes/` is inert to okstra** — no run reads it automatically. After writing, relay the `clarificationResponseArg` the CLI printed (e.g. `--clarification-response <notePath>`) to the user verbatim, telling them it only takes effect when the next run is executed with that argument.
|
|
145
145
|
|
|
146
146
|
**Guardrail:** `.okstra/` is gitignored — treat `notes/` as local scratch and never `git add` it. Creating a new note is easy to undo (delete the file), so always prefer it over editing a generated or user-owned file.
|
|
147
|
+
|
|
148
|
+
### recap.6 — Reconcile the task-group context (required whenever recap.5 writes a note)
|
|
149
|
+
|
|
150
|
+
A group's shared context does not stay true while its tasks run. Figures get re-measured, a success signal turns out to be satisfiable without a fix, a constraint names the wrong place. `group-context.md` states in its own header that okstra copies it into each run's `instruction-set/task-group-context.md` and carries it in the analysis packet's `## Task-Group Context` section, **ahead of the brief extract** — so a stale sentence there outranks a corrected brief for every task in the group.
|
|
151
|
+
|
|
152
|
+
**After writing a note, read `<PROJECT_ROOT>/.okstra/briefs/<task-group>/group-context.md` if it exists.** If anything the note establishes touches its Definition of Better figures, success signals, Group-Wide Constraints, Ticket Relations, or its statement of what is executable from the project root, update those sections **in the same response that wrote the note**. A note written without that check is an incomplete deliverable, the same way a `.project-docs/` document without its index row is.
|
|
153
|
+
|
|
154
|
+
Three rules for the edit:
|
|
155
|
+
|
|
156
|
+
1. **Only above the marker.** Edit the authored sections above `<!-- okstra:task-memory:begin -->`. The region below is okstra's projection — start order, per-task status, recorded runs — and it is redrawn from the task manifests at every `report-finalize`, at `okstra set-work-status`, and before each run copies the file. Anything you write there is overwritten without warning.
|
|
157
|
+
2. **Qualify a diverged figure; do not delete it.** An audit number is the baseline for the window it was measured in, not a current reading. Where a direct measurement contradicts it, say so with the measurement's date and method, and keep both. Deleting the old figure destroys the reason the group exists.
|
|
158
|
+
3. **Say which task and note the correction came from.** The next reader needs to get from the sentence back to its evidence.
|
|
159
|
+
|
|
160
|
+
If the group has no `group-context.md`, do not create one here — `okstra group-context init` (via okstra-brief-gen) owns that skeleton, and creating an empty one pre-empts the sections a human meant to author.
|
|
161
|
+
|
|
162
|
+
The same exposure applies to any other edit that supersedes group-wide facts, a brief rewrite most of all. Treat this check as belonging to the fact, not to this sub-command.
|
|
@@ -31,7 +31,7 @@ generator: okstra-brief-gen
|
|
|
31
31
|
<Which tickets are causes, which are observation means, which depend on which. Write `_(none)_` when the briefs' Related Task Graph already says it all.>
|
|
32
32
|
|
|
33
33
|
<!-- okstra:task-memory:begin -->
|
|
34
|
-
<!-- okstra
|
|
34
|
+
<!-- okstra redraws this region after every report-finalize, at `okstra set-work-status`, and before each run copies this file. Edit the sections above it, not this one. -->
|
|
35
35
|
## Task Memory
|
|
36
36
|
|
|
37
37
|
_(no runs recorded yet)_
|
|
@@ -2002,10 +2002,32 @@ def _approved_plan_conformance_manifest(
|
|
|
2002
2002
|
}
|
|
2003
2003
|
|
|
2004
2004
|
|
|
2005
|
+
def _conformance_script_matches(actual: str, declared: str) -> bool:
|
|
2006
|
+
"""두 script 표기가 같은 파일을 가리키는지 판정한다.
|
|
2007
|
+
|
|
2008
|
+
1순위는 task-root 상대형끼리의 일치다. task_root 아래로 접히지 않는
|
|
2009
|
+
절대경로(예: 워크트리 경로로 적힌 선언)가 남으면, 남은 쪽이 다른 쪽을
|
|
2010
|
+
경로 경계에서 후행 일치하는지까지 본다 — `/…/wt/qa/scripts/s.ts` 와
|
|
2011
|
+
`qa/scripts/s.ts` 는 같은 파일이다. 후행 일치는 경계(`/`)를 요구하므로
|
|
2012
|
+
`renamed-stage-1.ts` 같은 다른 파일은 걸리지 않는다.
|
|
2013
|
+
"""
|
|
2014
|
+
if actual == declared:
|
|
2015
|
+
return True
|
|
2016
|
+
if not actual or not declared:
|
|
2017
|
+
return False
|
|
2018
|
+
if actual.startswith("/") != declared.startswith("/"):
|
|
2019
|
+
longer, shorter = (
|
|
2020
|
+
(actual, declared) if actual.startswith("/") else (declared, actual)
|
|
2021
|
+
)
|
|
2022
|
+
return longer.endswith("/" + shorter)
|
|
2023
|
+
return False
|
|
2024
|
+
|
|
2025
|
+
|
|
2005
2026
|
def _declared_conformance_errors(
|
|
2006
2027
|
declared_manifest: dict,
|
|
2007
2028
|
actual_manifest: dict,
|
|
2008
2029
|
stage_name: str | None,
|
|
2030
|
+
task_root: Path,
|
|
2009
2031
|
) -> list[str]:
|
|
2010
2032
|
"""Compare scoped plan declarations with their one actual manifest entry."""
|
|
2011
2033
|
declared = _scope_manifest_entries(declared_manifest, stage_name).get("entries", [])
|
|
@@ -2030,8 +2052,15 @@ def _declared_conformance_errors(
|
|
|
2030
2052
|
errors.append(f"stage {stage_number} has multiple matching entries")
|
|
2031
2053
|
continue
|
|
2032
2054
|
actual_entry = matches[0]
|
|
2033
|
-
|
|
2034
|
-
|
|
2055
|
+
# 양쪽을 같은 task-root 상대형으로 접은 뒤 대조한다 — 계획이 절대경로를,
|
|
2056
|
+
# 실행자가 상대형을 쓰면 같은 파일이 문자열로는 영영 안 맞는다.
|
|
2057
|
+
actual_script = _normalize_conformance_script(
|
|
2058
|
+
str(actual_entry.get("script") or ""), task_root
|
|
2059
|
+
)
|
|
2060
|
+
declared_script = _normalize_conformance_script(
|
|
2061
|
+
str(declaration.get("script") or ""), task_root
|
|
2062
|
+
)
|
|
2063
|
+
if not _conformance_script_matches(actual_script, declared_script):
|
|
2035
2064
|
errors.append(f"stage {stage_number} script mismatch")
|
|
2036
2065
|
actual_requires = actual_entry.get("requires")
|
|
2037
2066
|
actual_capabilities = (
|
|
@@ -2231,6 +2260,7 @@ def _validate_conformance(
|
|
|
2231
2260
|
declared_manifest,
|
|
2232
2261
|
empty_scoped_manifest,
|
|
2233
2262
|
stage_name,
|
|
2263
|
+
task_root,
|
|
2234
2264
|
):
|
|
2235
2265
|
failures.append(
|
|
2236
2266
|
f"conformance gate BLOCKING: approved plan {error}; "
|
|
@@ -2257,6 +2287,7 @@ def _validate_conformance(
|
|
|
2257
2287
|
declared_manifest,
|
|
2258
2288
|
manifest,
|
|
2259
2289
|
stage_name,
|
|
2290
|
+
task_root,
|
|
2260
2291
|
):
|
|
2261
2292
|
failures.append(
|
|
2262
2293
|
f"conformance gate BLOCKING: approved plan {error} "
|