okstra 0.198.2 → 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 (35) 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/prompts/lead/okstra-lead-contract.md +1 -1
  7. package/runtime/prompts/lead/plan-body-verification.md +12 -5
  8. package/runtime/prompts/lead/report-writer.md +1 -1
  9. package/runtime/prompts/profiles/_common-contract.md +1 -1
  10. package/runtime/prompts/profiles/implementation-planning.md +1 -1
  11. package/runtime/python/okstra_ctl/analysis_packet.py +46 -0
  12. package/runtime/python/okstra_ctl/direct_work.py +109 -0
  13. package/runtime/python/okstra_ctl/group_context.py +14 -1
  14. package/runtime/python/okstra_ctl/material.py +29 -0
  15. package/runtime/python/okstra_ctl/model_io/lines.py +1 -0
  16. package/runtime/python/okstra_ctl/model_io/renderers.py +6 -0
  17. package/runtime/python/okstra_ctl/plan_items.py +39 -5
  18. package/runtime/python/okstra_ctl/plan_items_cli.py +61 -6
  19. package/runtime/python/okstra_ctl/recap.py +6 -0
  20. package/runtime/python/okstra_ctl/render.py +8 -4
  21. package/runtime/python/okstra_ctl/report_narrative.py +4 -4
  22. package/runtime/python/okstra_ctl/report_synthesis_packet.py +49 -40
  23. package/runtime/python/okstra_ctl/run.py +5 -0
  24. package/runtime/python/okstra_ctl/set_work_status.py +90 -40
  25. package/runtime/python/okstra_ctl/task_list_cli.py +2 -0
  26. package/runtime/python/okstra_ctl/worker_prompt_headers.py +1 -0
  27. package/runtime/python/okstra_project/state.py +4 -0
  28. package/runtime/schemas/final-report-v3.0.schema.json +10 -0
  29. package/runtime/skills/okstra-inspect/SKILL.md +6 -1
  30. package/runtime/skills/okstra-inspect/facets/recap.md +6 -1
  31. package/runtime/skills/okstra-inspect/facets/status.md +12 -0
  32. package/runtime/templates/reports/html/assets/base.css +4 -0
  33. package/runtime/templates/reports/html/assets/base.js +30 -0
  34. package/runtime/validators/validate-run.py +47 -6
  35. package/runtime/validators/validate_session_conformance.py +5 -0
@@ -3,8 +3,7 @@
3
3
  okstra-inspect status.4 가 Edit 도구로 하던 수동 JSON 편집(키 순서·개행 보존
4
4
  규칙을 프롬프트로 강제)을 CLI 로 수렴시킨다. 직렬화는 render._write_json 과
5
5
  동일한 json.dumps(indent=2, ensure_ascii=False) + "\n" 이므로 재렌더와 byte
6
- 규칙이 일치한다. discovery/task-catalog.json 여기서 재생성하지 않는다
7
- 다음 run 렌더가 manifest 를 재투영할 때까지 stale 할 수 있다.
6
+ 규칙이 일치한다. 직접 수행 결과는 작업 정본을 저장한 뒤 그룹 기억에 공유한다.
8
7
  """
9
8
  from __future__ import annotations
10
9
 
@@ -15,6 +14,8 @@ from datetime import datetime, timezone
15
14
  from pathlib import Path
16
15
 
17
16
  from okstra_ctl import group_context
17
+ from okstra_ctl.direct_work import find_brief_tasks, publish_direct_work, record_direct_work
18
+ from okstra_ctl.run_context import task_mutex
18
19
  from okstra_ctl.ids import slugify_task_segment
19
20
  from okstra_ctl.fixed_text import line, scalar
20
21
  from okstra_ctl.paths import task_dir, task_manifest_file
@@ -24,6 +25,7 @@ from okstra_project import (
24
25
  StateError,
25
26
  resolve_project_root,
26
27
  resolve_task_reference,
28
+ project_json_path,
27
29
  )
28
30
 
29
31
  ALLOWED_WORK_STATUSES = ("todo", "in-progress", "blocked", "done")
@@ -40,6 +42,7 @@ def _emit(payload: dict, *, text: bool = False) -> None:
40
42
  ("workStatus", "Work status"), ("workStatusUpdatedAt", "Updated at"),
41
43
  ("workStatusNote", "Note"), ("taskManifestPath", "Task manifest"),
42
44
  ("groupContextPath", "Group context"),
45
+ ("latestWorkRecordPath", "Direct work record"), ("statusRecorded", "Status recorded"),
43
46
  ):
44
47
  if key in payload:
45
48
  lines.append(line(label, payload.get(key)).rstrip("\n"))
@@ -65,6 +68,7 @@ def _manifest_path(project_root: Path, entry: dict) -> Path:
65
68
 
66
69
  _CLI_EPILOG = r"""Usage:
67
70
  okstra set-work-status <token> <status> [--note <text>] [--task-group <g>]
71
+ [--note-file <path>]
68
72
  [--project-root <dir>] [--cwd <dir>] [--json]
69
73
 
70
74
  <token> is a full task-key (<project-id>:<task-group>:<task-id>) or a bare
@@ -75,6 +79,13 @@ only when --note is passed) using the same JSON serialization as the manifest
75
79
  renderer. Output: JSON { ok, taskKey, previousWorkStatus, workStatus, ... };
76
80
  ok:false stages: resolve | catalog | not-found | ambiguous (pick from
77
81
  matches[]) | manifest-missing | manifest-invalid.
82
+
83
+ An existing brief can be registered without starting a run. For direct completion,
84
+ provide --note or --note-file describing the work and verification (or why no
85
+ checks were run). Direct records are shared through group-context and inspect.
86
+ No run, phase completion, or cross-verification success is synthesized.
87
+ stage:share with statusRecorded:true means the status was saved but sharing
88
+ failed; repeat the same command to retry without duplicating the result.
78
89
  """
79
90
 
80
91
 
@@ -98,6 +109,7 @@ def _parse_args(argv: list[str] | None):
98
109
  parser.add_argument(
99
110
  "--task-group", default="", help="scope a bare task-id match to this task-group"
100
111
  )
112
+ parser.add_argument("--note-file", type=Path, help="read the work and verification summary from a UTF-8 file")
101
113
  parser.add_argument("--project-root", default="", help="project root for catalog lookup")
102
114
  parser.add_argument("--cwd", default=".", help="cwd for project root resolution")
103
115
  parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
@@ -115,7 +127,11 @@ def _resolve_entry(args):
115
127
  token = args.token
116
128
  task_group = args.task_group
117
129
  if token.count(":") == 2:
118
- _, key_group, key_id = token.split(":")
130
+ project_id, key_group, key_id = token.split(":")
131
+ project = load_owned_object(project_json_path(project_root), artifact="project config")
132
+ if project_id != project.get("projectId"):
133
+ _emit({"ok": False, "stage": "resolve", "reason": "Task key belongs to another project"}, text=args.text)
134
+ return None, None, 2
119
135
  token = key_id
120
136
  task_group = key_group or task_group
121
137
 
@@ -123,6 +139,18 @@ def _resolve_entry(args):
123
139
  matches = resolve_task_reference(
124
140
  project_root, token, task_group=task_group or None
125
141
  )
142
+ briefs = find_brief_tasks(project_root, token, task_group)
143
+ by_key = {item["taskKey"].casefold(): item for item in matches}
144
+ for brief in briefs:
145
+ key = brief["taskKey"].casefold()
146
+ if key in by_key:
147
+ for field, value in brief.items():
148
+ by_key[key].setdefault(field, value)
149
+ else:
150
+ matches.append(brief)
151
+ except (OSError, ValueError) as exc:
152
+ _emit({"ok": False, "stage": "resolve", "reason": str(exc)}, text=args.text)
153
+ return None, None, 2
126
154
  except StateError as exc:
127
155
  _emit({"ok": False, "stage": "catalog", "reason": str(exc)}, text=args.text)
128
156
  return None, None, 2
@@ -136,14 +164,18 @@ def _resolve_entry(args):
136
164
  return project_root, matches[0], 0
137
165
 
138
166
 
139
- def _updated_payload(manifest_path: Path, manifest: dict, entry: dict, args) -> dict:
167
+ def _updated_payload(project_root: Path, manifest_path: Path, manifest: dict, entry: dict, args) -> dict:
140
168
  previous = manifest.get("workStatus", "")
169
+ previous_note = manifest.get("workStatusNote", "")
141
170
  manifest["workStatus"] = args.status
142
- manifest["workStatusUpdatedAt"] = datetime.now(timezone.utc).strftime(
143
- "%Y-%m-%dT%H:%M:%SZ"
144
- )
171
+ if previous != args.status or (args.note is not None and args.note != previous_note):
172
+ manifest["workStatusUpdatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
173
+ manifest.setdefault("workStatusUpdatedAt", datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"))
174
+ manifest["updatedAt"] = manifest["workStatusUpdatedAt"]
145
175
  if args.note is not None:
146
176
  manifest["workStatusNote"] = args.note
177
+ if args.status == "done" and (manifest.get("registrationSource") == "direct" or args.note is not None):
178
+ record_direct_work(project_root, manifest_path, manifest)
147
179
  write_owned_object_atomic(manifest_path, manifest, artifact="task manifest")
148
180
  return {
149
181
  "ok": True, "taskKey": entry.get("taskKey", ""),
@@ -151,6 +183,7 @@ def _updated_payload(manifest_path: Path, manifest: dict, entry: dict, args) ->
151
183
  "workStatusUpdatedAt": manifest["workStatusUpdatedAt"],
152
184
  "workStatusNote": manifest.get("workStatusNote", ""),
153
185
  "taskManifestPath": str(manifest_path),
186
+ "latestWorkRecordPath": manifest.get("latestWorkRecordPath", ""),
154
187
  }
155
188
 
156
189
 
@@ -177,44 +210,61 @@ def _refresh_group_queue(project_root: Path, entry: dict) -> str:
177
210
  return str(target) if target is not None else ""
178
211
 
179
212
 
180
- def main(argv: list[str] | None = None) -> int:
181
- args = _parse_args(argv)
182
- project_root, entry, resolution_exit = _resolve_entry(args)
183
- if resolution_exit:
184
- return resolution_exit
185
- assert project_root is not None and entry is not None
213
+ def _write_status(project_root: Path, entry: dict, args) -> dict:
186
214
  manifest_path = _manifest_path(project_root, entry)
187
- if not manifest_path.exists():
188
- _emit(
189
- {
190
- "ok": False,
191
- "stage": "manifest-missing",
192
- "taskKey": entry.get("taskKey", ""),
193
- "taskManifestPath": str(manifest_path),
194
- },
195
- text=args.text,
196
- )
197
- return 1
198
- try:
215
+ manifest_path.resolve().relative_to((project_root / ".okstra" / "tasks").resolve())
216
+ if manifest_path.exists():
199
217
  manifest = load_owned_object(manifest_path, artifact="task manifest")
200
- except ValueError as exc:
201
- _emit(
202
- {
203
- "ok": False,
204
- "stage": "manifest-invalid",
205
- "taskManifestPath": str(manifest_path),
206
- "reason": str(exc),
207
- },
208
- text=args.text,
218
+ elif entry.get("_briefVerified"):
219
+ if (manifest_path.parent / "runs").exists() or entry.get("latestRunPath"):
220
+ raise ValueError("Task manifest is missing for a task with run history")
221
+ identity_fields = (
222
+ "schemaVersion", "projectId", "projectRoot", "taskGroup", "taskId", "taskKey",
223
+ "taskGroupPathSegment", "taskIdPathSegment", "taskBriefPath", "taskRootPath", "taskManifestPath",
209
224
  )
210
- return 1
211
-
212
- payload = _updated_payload(manifest_path, manifest, entry, args)
213
- refreshed = _refresh_group_queue(project_root, entry)
225
+ manifest = {key: entry[key] for key in identity_fields if key in entry}
226
+ manifest["registrationSource"] = "direct"
227
+ else:
228
+ return {"ok": False, "stage": "manifest-missing", "taskKey": entry["taskKey"]}
229
+ if str(manifest.get("taskKey", "")).casefold() != entry["taskKey"].casefold():
230
+ raise ValueError("Task manifest identity does not match the requested task")
231
+ if manifest.get("registrationSource") == "direct" or args.note is not None:
232
+ project_id, group, task_id = entry["taskKey"].split(":")
233
+ for field, value in (("projectId", project_id), ("taskGroup", group), ("taskId", task_id)):
234
+ manifest.setdefault(field, value)
235
+ payload = _updated_payload(project_root, manifest_path, manifest, entry, args)
236
+ if manifest.get("registrationSource") == "direct" or manifest.get("latestWorkRecordPath"):
237
+ project_id, group, task_id = entry["taskKey"].split(":")
238
+ try:
239
+ refreshed = publish_direct_work(project_root, {
240
+ **manifest, "projectId": project_id, "taskGroup": group, "taskId": task_id,
241
+ })
242
+ except (OSError, ValueError) as exc:
243
+ return {**payload, "ok": False, "statusRecorded": True, "stage": "share", "reason": str(exc)}
244
+ else:
245
+ refreshed = _refresh_group_queue(project_root, entry)
214
246
  if refreshed:
215
- payload["groupContextPath"] = refreshed
247
+ payload["groupContextPath"] = str(refreshed)
248
+ return payload
249
+
250
+
251
+ def main(argv: list[str] | None = None) -> int:
252
+ args = _parse_args(argv)
253
+ try:
254
+ if args.note_file:
255
+ if args.note is not None:
256
+ raise ValueError("Use either --note or --note-file")
257
+ args.note = args.note_file.read_text(encoding="utf-8")
258
+ project_root, entry, resolution_exit = _resolve_entry(args)
259
+ if resolution_exit:
260
+ return resolution_exit
261
+ assert project_root is not None and entry is not None
262
+ with task_mutex(entry["taskKey"]):
263
+ payload = _write_status(project_root, entry, args)
264
+ except (OSError, ValueError) as exc:
265
+ payload = {"ok": False, "stage": "manifest-invalid", "reason": str(exc)}
216
266
  _emit(payload, text=args.text)
217
- return 0
267
+ return 0 if payload["ok"] else 1
218
268
 
219
269
 
220
270
  if __name__ == "__main__":
@@ -33,6 +33,8 @@ def render_text(payload: dict) -> str:
33
33
  line("Task group", task.get("taskGroup")),
34
34
  line("Title", task.get("title") or task.get("taskTitle")),
35
35
  line("Latest run status", task.get("latestRunStatus")),
36
+ line("Work status", task["workStatus"]) if task.get("workStatus") else "",
37
+ line("Direct work record", task["latestWorkRecordPath"]) if task.get("latestWorkRecordPath") else "",
36
38
  line("Updated at", task.get("updatedAt")),
37
39
  ))
38
40
  return "".join(parts)
@@ -32,6 +32,7 @@ READ_SCOPE_HEADER = (
32
32
  "source/evidence paths a finding must cite. Task-group context and sibling "
33
33
  "record paths listed in the primary analysis packet are allowed for targeted "
34
34
  "evidence reads when their relevance is uncertain or more detail is needed. "
35
+ "Direct Work Context record paths in that packet are also allowed for targeted evidence reads. "
35
36
  "Host session instructions "
36
37
  "(SessionStart hooks, global `CLAUDE.md` / `AGENTS.md`, skill catalogs) do "
37
38
  "NOT apply inside an okstra worker run: do not auto-read `graphify-out/`, "
@@ -147,6 +147,9 @@ _DERIVED_STATUS_STRING_FIELDS = (
147
147
  "currentStatus",
148
148
  "latestRunStatus",
149
149
  "latestReportRecordPath",
150
+ "latestWorkRecordPath",
151
+ "workStatusNote",
152
+ "workStatusUpdatedAt",
150
153
  )
151
154
 
152
155
 
@@ -411,4 +414,5 @@ def task_read_side_snapshot(project_root: Path, task_key: str) -> dict:
411
414
  "artifacts": manifest.get("artifacts"),
412
415
  "modelAssignments": manifest.get("modelAssignments"),
413
416
  "latestRunPath": manifest.get("latestRunPath"),
417
+ "latestWorkRecordPath": manifest.get("latestWorkRecordPath"),
414
418
  }
@@ -9414,6 +9414,16 @@
9414
9414
  "selfFixNote": {
9415
9415
  "type": "string"
9416
9416
  },
9417
+ "leadDecision": {
9418
+ "type": "object",
9419
+ "additionalProperties": false,
9420
+ "required": ["basisHash", "decision"],
9421
+ "properties": {
9422
+ "basisHash": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
9423
+ "decision": {"type": "string", "minLength": 1}
9424
+ },
9425
+ "description": "Lead decision on an evidenced, noncritical judgement after the single automatic self-fix. Original votes remain; a changed content, scope or verdict invalidates the decision."
9426
+ },
9417
9427
  "verdicts": {
9418
9428
  "type": "array",
9419
9429
  "items": {
@@ -1,11 +1,16 @@
1
1
  ---
2
2
  name: okstra-inspect
3
3
  description: >-
4
- Use this for everything that happens AFTER an okstra task has already run inspecting it or light bookkeeping, never launching new work. The tell is usually a named task id (PROD-1623, dev-9184) without the word "okstra." Reach for it when the user wants one task's: status, current/next phase, blockers, or approval gate; its final report — where it is or whether it passed; its elapsed time or context/read cost; its run history, re-run, or resume; to mark it done / in-progress / blocked / todo; or a failed run's error logs gathered into a report. The recap facet also takes a task-group: which briefs are done / in progress / not started, what is next, each task's latest conclusion. Also builds an anonymized cross-project error zip and audits run health. NOT for starting a run (okstra-run), a group's run/time/error totals (okstra-rollup), schedules (okstra-schedule-gen), a brief (okstra-brief-gen), setup (okstra-setup), or cross-project management (okstra-manager).
4
+ Inspect okstra tasks and record user-managed status, including direct completion of brief-backed tasks that have never run. The tell is usually a named task id (PROD-1623, dev-9184) without the word "okstra." Reach for it when the user wants one task's: status, current/next phase, blockers, or approval gate; its final report — where it is or whether it passed; its elapsed time or context/read cost; its run history, re-run, or resume; to mark it done / in-progress / blocked / todo; or a failed run's error logs gathered into a report. The recap facet also takes a task-group: which briefs are done / in progress / not started, what is next, each task's latest conclusion. Also builds an anonymized cross-project error zip and audits run health. NOT for starting a run (okstra-run), a group's run/time/error totals (okstra-rollup), schedules (okstra-schedule-gen), a brief (okstra-brief-gen), setup (okstra-setup), or cross-project management (okstra-manager).
5
5
  ---
6
6
 
7
7
  # OKSTRA Inspect
8
8
 
9
+ Also handles tasks that have never run: a status mutation can register an existing brief,
10
+ and direct completion records are available to status, recap, group context, and later runs.
11
+ For an explicit status change, dispatch to status.4 before catalog-only task selection.
12
+ An unregistered brief will not appear in that selection yet.
13
+
9
14
  Single read-side entry point for okstra runtime inspection plus the one status mutation that belongs here (`workStatus`) and read-derived artifact rendering (`errors` report). Each sub-command's full procedure lives in a lazily loaded facet file — after dispatch, Read exactly the one facet you need.
10
15
 
11
16
  | Sub-command | Facet file | What it does |
@@ -19,13 +19,18 @@ If the user asks for a recap without naming a task or a group (e.g. "summarize t
19
19
 
20
20
  ### recap.2 — Assemble the before/after summary
21
21
 
22
+ When `Direct work record` is present, read that work record and report its summary, evidence,
23
+ limitations, and current `Work status`, including when `Run count` is zero. Direct work records
24
+ are JSON work artifacts, not final reports; do not send them to `render-final-report`. In group
25
+ output, `Memory source: direct` identifies these records even when `Memory run` is empty.
26
+
22
27
  Use the CLI output as the source of truth:
23
28
 
24
29
  ```bash
25
30
  okstra model-io recap-input --project-root <projectRoot> --task-ref <resolved-target>
26
31
  ```
27
32
 
28
- Read the fixed-text `Run count` and repeated `Transition` blocks. Narrate each block's `From phase → To phase`, `Status`, `Last completed phase`, `Next phase`, and `Report` in the emitted chronological order. `Status`, `Last completed phase`, and `Next phase` are each run's end state from its own run-manifest, so a `prepared` transition is a run that was prepared and never ran, and it carries no `Report`. If `Run count: 0`, answer only "This task has no recorded runs." and do not claim to have read any file.
33
+ Read the fixed-text `Run count` and repeated `Transition` blocks. Narrate each block's `From phase → To phase`, `Status`, `Last completed phase`, `Next phase`, and `Report` in the emitted chronological order. `Status`, `Last completed phase`, and `Next phase` are each run's end state from its own run-manifest, so a `prepared` transition is a run that was prepared and never ran, and it carries no `Report`. If `Run count: 0` and `Direct work record` is absent, report that the task has no recorded runs alongside its current work status.
29
34
 
30
35
  `Next phase` is already a fixed scalar projection. Narrate it when non-empty, and otherwise use `Next phase status`: `pending` means that run did not settle the route, `blocked` means it stopped on something outside the run, and `terminal` means the lifecycle ended there.
31
36
 
@@ -108,6 +108,18 @@ Branches 5–7 are decided by `workflow.nextRecommendedPhase.status` when `await
108
108
 
109
109
  Recognize requests to change a task's `workStatus` and update the corresponding `task-manifest.json`.
110
110
 
111
+ This includes briefs whose tasks have never run. Call this command directly with the named
112
+ token; do not require a catalog match or a prior `okstra-run`. The command resolves the brief
113
+ and registers it when needed. For "do this small task directly and record completion", perform
114
+ the authorized work, then record `done` with `--note` or `--note-file` containing the changes,
115
+ verification results or reasons checks were not run, and remaining limitations. These inputs
116
+ are checked by `set-work-status` and covered by `test_okstra_set_work_status.py`.
117
+
118
+ Read `Direct work record` alongside `Work status`. A direct result is user-recorded work,
119
+ not a passed cross-verification run. The command shares the result in group context and
120
+ later run inputs. `Stage: share` with `Status recorded: True` means the status was saved
121
+ but sharing failed; report both facts and retry the same command. Do not report full success.
122
+
111
123
  **Trigger patterns** (recognize both):
112
124
 
113
125
  Natural language: "change DEV-6827 to done", "mark PROD-1623 as blocked", "set DEV-9047 in progress", "Mark DEV-6827 as done".
@@ -15,6 +15,10 @@ body { margin: 0; background: Canvas; color: CanvasText; }
15
15
  main { display: grid; gap: 1rem; padding-bottom: 3rem; }
16
16
  section { padding: 1.4rem; border: 1px solid color-mix(in srgb, CanvasText 14%, transparent); border-radius: 16px; background: color-mix(in srgb, Canvas 94%, CanvasText 6%); }
17
17
  h2 { margin-top: 0; font-size: 1.45rem; }
18
+ .report-section-toggle > summary { cursor: pointer; }
19
+ .report-section-toggle > summary > h2 { display: inline; margin: 0; }
20
+ .report-section-toggle[open] > summary { margin-bottom: 1rem; }
21
+ .report-section-toggle > summary:focus-visible { outline: 2px solid Highlight; outline-offset: 4px; }
18
22
  h3 { margin-bottom: .35rem; }
19
23
  /* The report opens with what it holds. It scrolls away with the rest rather
20
24
  than sticking: on a phone a pinned index of a dozen sections is the page. */
@@ -2,4 +2,34 @@
2
2
  "use strict";
3
3
 
4
4
  document.documentElement.classList.add("js-enabled");
5
+
6
+ for (const section of document.querySelectorAll("section")) {
7
+ const heading = section.querySelector(":scope > h2");
8
+ if (!heading) continue;
9
+ const details = document.createElement("details");
10
+ details.className = "report-section-toggle";
11
+ const summary = document.createElement("summary");
12
+ summary.append(heading);
13
+ details.append(summary);
14
+ while (section.firstChild) details.append(section.firstChild);
15
+ section.append(details);
16
+ }
17
+
18
+ function revealTarget(hash) {
19
+ const target = document.getElementById(hash.slice(1));
20
+ if (!target) return;
21
+ const sectionDetails = target.querySelector(":scope > details.report-section-toggle");
22
+ if (sectionDetails) sectionDetails.open = true;
23
+ for (let parent = target.parentElement; parent; parent = parent.parentElement) {
24
+ if (parent.tagName === "DETAILS") parent.open = true;
25
+ }
26
+ target.scrollIntoView({ block: "start" });
27
+ }
28
+
29
+ document.addEventListener("click", (event) => {
30
+ const link = event.target.closest("a[href^='#']");
31
+ if (link) revealTarget(link.getAttribute("href"));
32
+ });
33
+ window.addEventListener("hashchange", () => revealTarget(window.location.hash));
34
+ revealTarget(window.location.hash);
5
35
  })();
@@ -89,6 +89,8 @@ from okstra_ctl.plan_items import ( # noqa: E402
89
89
  advisory_plan_body_gating,
90
90
  analyser_key as _analyser_key,
91
91
  is_critic_worker,
92
+ lead_decision_basis,
93
+ self_fix_rounds,
92
94
  stage_scope_bucket as _item_stage_scope_bucket,
93
95
  voting_analyser_keys,
94
96
  )
@@ -3987,17 +3989,48 @@ def _resolved_noncritical_dissent_ids(data: dict) -> set[str]:
3987
3989
  return _user_accepted_plan_item_ids(data)
3988
3990
 
3989
3991
 
3992
+ def _plan_item_decision_authority(item: dict, pbv: dict) -> str | None:
3993
+ """자동 수정 이후의 설계 판단만 리드가 결정하며 사실·사용자 권한은 남긴다."""
3994
+ classification = _classify_plan_item_gate(item)
3995
+ votes = [row for row in item.get("verdicts", []) if isinstance(row, dict)]
3996
+ non_result = any(row.get("verdict") not in {"AGREE", "SUPPLEMENT", "DISAGREE"} for row in votes)
3997
+ if classification not in {"majority-disagree", "needs-reverify", "all-non-result"} and not non_result:
3998
+ return None
3999
+ if _stage_scope_bucket(item, pbv) != "in-scope" or item.get("block") == "record":
4000
+ return None
4001
+ disagrees = [row for row in votes if row.get("verdict") == "DISAGREE"]
4002
+ verified = item.get("contentHash")
4003
+ if (
4004
+ not self_fix_rounds(pbv) or pbv.get("gating") is False
4005
+ or not verified or item.get("verifiedContentHash") != verified
4006
+ or _is_correctness_critical(item) or not disagrees
4007
+ or len(voting_analyser_keys([item])) < 2
4008
+ or non_result
4009
+ or any(row.get("claimKind") not in {None, "judgement"}
4010
+ or row.get("fixability") != "planner-fixable"
4011
+ or row.get("breakageKind") not in {"b", "c", "e"} for row in disagrees)
4012
+ ):
4013
+ return "user"
4014
+ return "lead"
4015
+
4016
+
4017
+ def _lead_decision_applies(item: dict, pbv: dict) -> bool:
4018
+ decision = item.get("leadDecision")
4019
+ return (
4020
+ isinstance(decision, dict)
4021
+ and bool(str(decision.get("decision") or "").strip())
4022
+ and decision.get("basisHash") == lead_decision_basis(item)
4023
+ and _plan_item_decision_authority(item, pbv) == "lead"
4024
+ )
4025
+
4026
+
3990
4027
  def _is_dissent_downgraded(
3991
4028
  item: dict,
3992
4029
  pbv: dict,
3993
4030
  accepted_item_ids: set[str],
3994
4031
  ) -> bool:
3995
- """Whether a surviving `majority-disagree` item stops blocking approval.
3996
-
3997
- 사용자 진행 처분(`accept-risk` / `select` / `answer`)이 있으면 표는 남기고
3998
- 게이트만 `has-dissent` 로 내린다. 분류와 자가수정 소진 여부는 보지 않는다.
3999
- """
4000
- return (
4032
+ """유효한 리드 결정 또는 사용자 진행 처분은 반대 표를 보존하며 차단을 해소한다."""
4033
+ return _lead_decision_applies(item, pbv) or (
4001
4034
  _classify_plan_item_gate(item) == "majority-disagree"
4002
4035
  and str(item.get("id") or "") in accepted_item_ids
4003
4036
  )
@@ -5025,6 +5058,11 @@ def _validate_self_fix_grouping(data: dict, failures: list[str]) -> None:
5025
5058
  return
5026
5059
 
5027
5060
  rounds = [g.get("round") for g in groups if isinstance(g.get("round"), int)]
5061
+ if len(set(rounds)) > 1:
5062
+ failures.append(
5063
+ "final-report data.json: automatic self-fix is limited to one rewrite; "
5064
+ "resolve remaining items through lead decisions or user confirmation."
5065
+ )
5028
5066
  if rounds and max(rounds) != rounds_applied:
5029
5067
  failures.append(
5030
5068
  "final-report data.json: planBodyVerification "
@@ -6089,6 +6127,7 @@ def _validate_unresolved_tie_was_reverified(
6089
6127
  if isinstance(item, dict)
6090
6128
  and not item.get("carriedForwardFromSeq")
6091
6129
  and str(item.get("id") or "").strip() not in decided
6130
+ and not _lead_decision_applies(item, pbv)
6092
6131
  and _stage_scope_bucket(item, pbv) == "in-scope"
6093
6132
  and _is_unsettled_tie(item)
6094
6133
  })
@@ -7291,6 +7330,8 @@ def _gate_summary_item(
7291
7330
  "classification": classification,
7292
7331
  "stateClassification": _state_classification(item, classification),
7293
7332
  "correctnessCritical": _is_correctness_critical(item),
7333
+ "decisionAuthority": _plan_item_decision_authority(item, pbv),
7334
+ "leadDecisionApplied": _lead_decision_applies(item, pbv),
7294
7335
  # 왜 안 막는지가 기록에 남아야 한다. 이 값이 없으면 범위 밖 강등과
7295
7336
  # 실제 합의가 산출물에서 같은 모양으로 읽힌다.
7296
7337
  "stageScope": _stage_scope_bucket(item, pbv),
@@ -1270,6 +1270,11 @@ def _check_activity_round_counts(
1270
1270
  f"recorded={recorded_verifications}."
1271
1271
  )
1272
1272
  recorded_self_fixes = len(indexed.get("self-fix-applied", []))
1273
+ if recorded_self_fixes > 1:
1274
+ errors.append(
1275
+ "activity contract: automatic self-fix is limited to one rewrite; "
1276
+ "resolve remaining items through lead decisions or user confirmation."
1277
+ )
1273
1278
  expected_self_fixes = _self_fix_event_expectation(
1274
1279
  run_dir, suffix, self_fix_rounds,
1275
1280
  )