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.
- 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/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/lead/plan-body-verification.md +12 -5
- 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 +46 -0
- package/runtime/python/okstra_ctl/direct_work.py +109 -0
- package/runtime/python/okstra_ctl/group_context.py +14 -1
- 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_narrative.py +4 -4
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +49 -40
- 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/worker_prompt_headers.py +1 -0
- 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
|
@@ -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
|
-
규칙이 일치한다.
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
"
|
|
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
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
"""
|
|
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
|
)
|