okstra 0.198.2 → 0.199.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.
Files changed (40) 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/host-orchestration/implementation-planning.md +7 -0
  7. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  8. package/runtime/prompts/lead/plan-body-verification.md +12 -5
  9. package/runtime/prompts/lead/report-writer.md +1 -1
  10. package/runtime/prompts/profiles/_common-contract.md +1 -1
  11. package/runtime/prompts/profiles/implementation-planning.md +2 -2
  12. package/runtime/python/okstra_ctl/analysis_packet.py +56 -1
  13. package/runtime/python/okstra_ctl/direct_work.py +109 -0
  14. package/runtime/python/okstra_ctl/group_context.py +14 -1
  15. package/runtime/python/okstra_ctl/material.py +29 -0
  16. package/runtime/python/okstra_ctl/model_io/lines.py +1 -0
  17. package/runtime/python/okstra_ctl/model_io/renderers.py +6 -0
  18. package/runtime/python/okstra_ctl/plan_items.py +39 -5
  19. package/runtime/python/okstra_ctl/plan_items_cli.py +61 -6
  20. package/runtime/python/okstra_ctl/recap.py +6 -0
  21. package/runtime/python/okstra_ctl/render.py +8 -4
  22. package/runtime/python/okstra_ctl/report_narrative.py +4 -4
  23. package/runtime/python/okstra_ctl/report_synthesis_packet.py +49 -40
  24. package/runtime/python/okstra_ctl/run.py +5 -0
  25. package/runtime/python/okstra_ctl/set_work_status.py +90 -40
  26. package/runtime/python/okstra_ctl/stage_map.py +6 -4
  27. package/runtime/python/okstra_ctl/task_list_cli.py +2 -0
  28. package/runtime/python/okstra_ctl/worker_prompt_headers.py +1 -0
  29. package/runtime/python/okstra_project/state.py +4 -0
  30. package/runtime/schemas/final-report-v2.0.schema.json +1 -0
  31. package/runtime/schemas/final-report-v3.0.schema.json +11 -0
  32. package/runtime/skills/okstra-inspect/SKILL.md +6 -1
  33. package/runtime/skills/okstra-inspect/facets/recap.md +6 -1
  34. package/runtime/skills/okstra-inspect/facets/status.md +12 -0
  35. package/runtime/skills/okstra-run/SKILL.md +7 -0
  36. package/runtime/templates/reports/html/assets/base.css +4 -0
  37. package/runtime/templates/reports/html/assets/base.js +30 -0
  38. package/runtime/validators/validate-implementation-plan-stages.py +10 -15
  39. package/runtime/validators/validate-run.py +47 -6
  40. 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__":
@@ -159,15 +159,17 @@ def _stage_map_table_lines(text: str, source_plan_path: str) -> list[str]:
159
159
  ]
160
160
 
161
161
 
162
- def _parse_depends_on(
162
+ def parse_stage_dependencies(
163
163
  value: str, row_number: int, source_plan_path: str,
164
164
  ) -> tuple[int, ...]:
165
+ """계획 작성 검증과 실행 시 읽기가 같은 의존성 문법을 사용한다."""
166
+ value = value.strip()
165
167
  if value in {"", "(none)"}:
166
168
  return ()
167
169
  dependencies: list[int] = []
168
170
  for token in value.split(","):
169
171
  normalized = token.strip()
170
- if not normalized.isdigit() or int(normalized) < 1:
172
+ if not re.fullmatch(r"[1-9][0-9]*", normalized):
171
173
  raise StageMapError(
172
174
  "stage_map",
173
175
  f"Stage Map row {row_number} has invalid depends-on token "
@@ -215,7 +217,7 @@ def _parse_stage_map_row(
215
217
  return StageMapStage(
216
218
  stage_number,
217
219
  cells[1],
218
- _parse_depends_on(cells[2].strip(), row_number, source_plan_path),
220
+ parse_stage_dependencies(cells[2], row_number, source_plan_path),
219
221
  step_count,
220
222
  cells[4],
221
223
  )
@@ -337,7 +339,7 @@ def _parse_data_stage_map_row(
337
339
  return StageMapStage(
338
340
  stage_number,
339
341
  title,
340
- _parse_depends_on(depends_on.strip(), row_number, source_plan_path),
342
+ parse_stage_dependencies(depends_on, row_number, source_plan_path),
341
343
  step_count,
342
344
  exit_summary,
343
345
  )
@@ -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
  }
@@ -7741,6 +7741,7 @@
7741
7741
  "dependsOn": {
7742
7742
  "type": "string",
7743
7743
  "minLength": 1,
7744
+ "pattern": "^\\s*(?:\\(none\\)|[1-9][0-9]*(?:\\s*,\\s*[1-9][0-9]*)*)\\s*$",
7744
7745
  "description": "Literal Stage Map depends-on cell: `(none)` or a comma-separated stage number list."
7745
7746
  },
7746
7747
  "stepCount": {
@@ -7839,6 +7839,7 @@
7839
7839
  "dependsOn": {
7840
7840
  "type": "string",
7841
7841
  "minLength": 1,
7842
+ "pattern": "^\\s*(?:\\(none\\)|[1-9][0-9]*(?:\\s*,\\s*[1-9][0-9]*)*)\\s*$",
7842
7843
  "description": "Literal Stage Map depends-on cell: `(none)` or a comma-separated stage number list."
7843
7844
  },
7844
7845
  "stepCount": {
@@ -9414,6 +9415,16 @@
9414
9415
  "selfFixNote": {
9415
9416
  "type": "string"
9416
9417
  },
9418
+ "leadDecision": {
9419
+ "type": "object",
9420
+ "additionalProperties": false,
9421
+ "required": ["basisHash", "decision"],
9422
+ "properties": {
9423
+ "basisHash": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
9424
+ "decision": {"type": "string", "minLength": 1}
9425
+ },
9426
+ "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."
9427
+ },
9417
9428
  "verdicts": {
9418
9429
  "type": "array",
9419
9430
  "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".
@@ -354,6 +354,13 @@ re-plan it without a RED step. Check `okstra stage-map <task-key>` first: when
354
354
  what is left. That command refuses unless the commit exists and the stage's
355
355
  conformance gate permits progress, so it cannot close a stage the run validator
356
356
  would have blocked.
357
+
358
+ If `okstra stage-map` cannot read the prior plan during this planning run,
359
+ follow the analysis packet's Stage Ledger recovery guidance. A notation error
360
+ in an existing `dependsOn` cell is work for the new plan, not a reason to ask
361
+ the user to edit JSON. Preserve the prior report, existing stage numbers and
362
+ completed work; do not close or execute a stage from an unreadable map.
363
+ An ambiguous dependency or stage identity remains a blocker.
357
364
  <!-- END FRAGMENT: host-orchestration-implementation-planning -->
358
365
 
359
366
  <!-- BEGIN FRAGMENT: host-orchestration-implementation -->
@@ -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
  })();
@@ -30,6 +30,7 @@ from okstra_ctl.stage_map import ( # noqa: E402
30
30
  STAGE_MAP_HEADING,
31
31
  StageMapError,
32
32
  StageMapStage,
33
+ parse_stage_dependencies,
33
34
  parse_stage_map_text,
34
35
  schema_v2_report,
35
36
  )
@@ -79,17 +80,6 @@ def _check_stage_map_present(text: str) -> List[ValidationError]:
79
80
  return []
80
81
 
81
82
 
82
- def _parse_depends_on_cell(raw: str) -> List[int] | None:
83
- """Stage numbers from schema-v2 `stageMap[].dependsOn`."""
84
- value = raw.strip()
85
- if value in ("(none)", ""):
86
- return []
87
- try:
88
- return [int(x.strip()) for x in value.split(",") if x.strip()]
89
- except ValueError:
90
- return None
91
-
92
-
93
83
  def _stage_numbers_monotonic(
94
84
  stages: List[StageMapStage],
95
85
  ) -> List[ValidationError]:
@@ -495,11 +485,16 @@ def _data_stage_metas(
495
485
  stage_map: List[dict],
496
486
  ) -> Tuple[List[StageMapStage], List[ValidationError]]:
497
487
  rows = []
498
- for row in stage_map:
488
+ errors = []
489
+ for row_number, row in enumerate(stage_map, start=1):
499
490
  if not isinstance(row, dict) or not isinstance(row.get("stage"), int):
500
491
  continue
501
- depends = _parse_depends_on_cell(str(row.get("dependsOn") or ""))
502
- if depends is None:
492
+ try:
493
+ depends = parse_stage_dependencies(
494
+ str(row.get("dependsOn") or ""), row_number, ""
495
+ )
496
+ except StageMapError as exc:
497
+ errors.append(ValidationError("S2", row["stage"], exc.reason))
503
498
  continue
504
499
  rows.append(StageMapStage(
505
500
  row["stage"],
@@ -508,7 +503,7 @@ def _data_stage_metas(
508
503
  row.get("stepCount") if isinstance(row.get("stepCount"), int) else -1,
509
504
  str(row.get("exitContractSummary") or ""),
510
505
  ))
511
- return rows, _stage_numbers_monotonic(rows)
506
+ return rows, errors or _stage_numbers_monotonic(rows)
512
507
 
513
508
 
514
509
  def _check_data_slice_tdd(
@@ -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
  )