okstra 0.110.0 → 0.112.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/README.kr.md +3 -2
- package/README.md +3 -2
- package/bin/okstra +7 -1
- package/docs/for-ai/README.md +2 -2
- package/docs/for-ai/skills/okstra-brief.md +2 -3
- package/docs/for-ai/skills/okstra-container-build.md +2 -3
- package/docs/for-ai/skills/okstra-inspect.md +5 -12
- package/docs/for-ai/skills/okstra-rollup.md +3 -4
- package/docs/for-ai/skills/okstra-run.md +10 -10
- package/docs/for-ai/skills/okstra-schedule.md +2 -7
- package/docs/for-ai/skills/okstra-setup.md +1 -1
- package/docs/kr/architecture/storage-model.md +1 -2
- package/docs/kr/architecture.md +11 -12
- package/docs/kr/cli.md +6 -4
- package/docs/project-structure-overview.md +14 -12
- package/docs/task-process/README.md +4 -4
- package/docs/task-process/common-flow.md +8 -5
- package/docs/task-process/implementation.md +4 -5
- package/docs/task-process/release-handoff.md +3 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/antigravity-worker.md +1 -0
- package/runtime/agents/workers/codex-worker.md +1 -0
- package/runtime/prompts/coding-preflight/overview.md +3 -1
- package/runtime/prompts/launch.template.md +1 -5
- package/runtime/prompts/lead/context-loader.md +2 -4
- package/runtime/prompts/lead/convergence.md +11 -240
- package/runtime/prompts/lead/okstra-lead-contract.md +70 -34
- package/runtime/prompts/lead/plan-body-verification.md +240 -0
- package/runtime/prompts/lead/report-writer.md +7 -7
- package/runtime/prompts/lead/team-contract.md +15 -17
- package/runtime/prompts/profiles/_common-contract.md +2 -38
- package/runtime/prompts/profiles/_implementation-diff-review.md +43 -0
- package/runtime/prompts/profiles/_implementation-executor.md +9 -11
- package/runtime/prompts/profiles/_implementation-self-check.md +11 -5
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +2 -2
- package/runtime/prompts/profiles/implementation.md +1 -1
- package/runtime/python/okstra_ctl/codex_dispatch.py +3 -0
- package/runtime/python/okstra_ctl/consumers.py +4 -0
- package/runtime/python/okstra_ctl/handoff.py +5 -23
- package/runtime/python/okstra_ctl/implementation_stage.py +11 -11
- package/runtime/python/okstra_ctl/path_hints.py +1 -0
- package/runtime/python/okstra_ctl/paths.py +3 -0
- package/runtime/python/okstra_ctl/render.py +8 -0
- package/runtime/python/okstra_ctl/report_views.py +76 -26
- package/runtime/python/okstra_ctl/set_work_status.py +147 -0
- package/runtime/python/okstra_ctl/stage_targets.py +152 -0
- package/runtime/python/okstra_ctl/team_reconcile.py +1 -1
- package/runtime/python/okstra_ctl/wizard.py +47 -0
- package/runtime/python/okstra_project/__init__.py +2 -0
- package/runtime/python/okstra_project/state.py +44 -2
- package/runtime/skills/okstra-brief/SKILL.md +32 -176
- package/runtime/skills/okstra-brief/references/reporter-confirmations.md +71 -0
- package/runtime/skills/okstra-brief/references/tracker-recursion.md +90 -0
- package/runtime/skills/okstra-container-build/SKILL.md +7 -20
- package/runtime/skills/okstra-graphify/SKILL.md +8 -8
- package/runtime/skills/okstra-inspect/SKILL.md +27 -43
- package/runtime/skills/okstra-rollup/SKILL.md +6 -6
- package/runtime/skills/okstra-run/SKILL.md +27 -32
- package/runtime/skills/okstra-schedule/SKILL.md +64 -419
- package/runtime/skills/okstra-setup/SKILL.md +25 -223
- package/runtime/skills/okstra-setup/references/project-config.md +188 -0
- package/runtime/templates/reports/schedule.template.md +2 -2
- package/runtime/templates/worker-prompt-preamble.md +1 -1
- package/runtime/validators/validate-run.py +7 -7
- package/src/cli-registry.mjs +14 -0
- package/src/commands/execute/wizard.mjs +3 -1
- package/src/commands/inspect/set-work-status.mjs +31 -0
- package/src/commands/inspect/task-show.mjs +11 -31
- package/src/commands/lifecycle/check-project.mjs +68 -56
- package/src/commands/lifecycle/install.mjs +1 -0
- package/src/commands/lifecycle/preflight.mjs +82 -0
|
@@ -1010,6 +1010,10 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
|
|
|
1010
1010
|
"leadRuntime": _lead_runtime(ctx),
|
|
1011
1011
|
"leadAdapter": _lead_adapter(ctx),
|
|
1012
1012
|
"workCategory": work_category,
|
|
1013
|
+
# user-managed status (set-work-status CLI) — carried across re-renders
|
|
1014
|
+
"workStatus": existing.get("workStatus", ""),
|
|
1015
|
+
"workStatusUpdatedAt": existing.get("workStatusUpdatedAt", ""),
|
|
1016
|
+
"workStatusNote": existing.get("workStatusNote", ""),
|
|
1013
1017
|
"taskBriefPath": ctx.get("BRIEF_RELATIVE_PATH", ""),
|
|
1014
1018
|
"recommendedWorkers": reviewers,
|
|
1015
1019
|
"relatedTasks": related_tasks,
|
|
@@ -1740,6 +1744,9 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
|
|
|
1740
1744
|
convergence_path = ctx.get("OKSTRA_CONVERGENCE_PATH") or str(
|
|
1741
1745
|
home_prompts / "lead" / "convergence.md"
|
|
1742
1746
|
)
|
|
1747
|
+
plan_body_verification_path = ctx.get("OKSTRA_PLAN_BODY_VERIFICATION_PATH") or str(
|
|
1748
|
+
home_prompts / "lead" / "plan-body-verification.md"
|
|
1749
|
+
)
|
|
1743
1750
|
report_writer_path = ctx.get("OKSTRA_REPORT_WRITER_PATH") or str(
|
|
1744
1751
|
home_prompts / "lead" / "report-writer.md"
|
|
1745
1752
|
)
|
|
@@ -1753,6 +1760,7 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
|
|
|
1753
1760
|
f"- Context loader: `{context_loader_path}`\n"
|
|
1754
1761
|
f"- Team contract: `{team_contract_path}`\n"
|
|
1755
1762
|
f"- Convergence contract: `{convergence_path}`\n"
|
|
1763
|
+
f"- Plan-body verification contract (implementation-planning Phase 6 sub-step only): `{plan_body_verification_path}`\n"
|
|
1756
1764
|
f"- Report writer contract: `{report_writer_path}`\n"
|
|
1757
1765
|
f"- Coding preflight pack: `{coding_preflight_dir}`"
|
|
1758
1766
|
)
|
|
@@ -2,11 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
Single product, single source of truth:
|
|
4
4
|
|
|
5
|
-
* ``
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
* ``build_report_view_model(src_md, *, run_meta)`` — deterministic
|
|
6
|
+
server-side Report View Model for human readers. It carries source digest,
|
|
7
|
+
rendered body HTML, response IDs, sidecar hints, and optional approval
|
|
8
|
+
context.
|
|
9
|
+
* ``render_report_view_model(model, *, css, js)`` — self-contained HTML
|
|
10
|
+
renderer over that model. Sections §1/§3/§4 user-actionable rows (those
|
|
11
|
+
reachable from §1 ``C-*`` IDs) get embedded ``<form>`` controls. §5.6 /
|
|
12
|
+
§5.7 / §5.8 deliverable sub-sections are explicitly excluded from form
|
|
13
|
+
attachment — they are read-only deliverables.
|
|
14
|
+
* ``render_html(src_md, *, run_meta)`` remains a compatibility wrapper around
|
|
15
|
+
the model builder + renderer.
|
|
10
16
|
|
|
11
17
|
User responses are NEVER merged back into the original report. The HTML
|
|
12
18
|
serialises a ``user-response`` markdown sidecar via ``Export user
|
|
@@ -101,34 +107,56 @@ class RunMeta:
|
|
|
101
107
|
source_report: str # relative path of the .md the HTML is derived from
|
|
102
108
|
|
|
103
109
|
|
|
104
|
-
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class ReportViewModel:
|
|
112
|
+
run_meta: RunMeta
|
|
113
|
+
title: str
|
|
114
|
+
body_html: str
|
|
115
|
+
source_digest: str
|
|
116
|
+
response_ids: tuple[str, ...]
|
|
117
|
+
sidecar_name: str
|
|
118
|
+
sidecar_dir: str
|
|
119
|
+
approval_ctx: PlanApprovalContext | None = None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def build_report_view_model(
|
|
105
123
|
src_md: str,
|
|
106
124
|
*,
|
|
107
125
|
run_meta: RunMeta,
|
|
108
|
-
css: str,
|
|
109
|
-
js: str,
|
|
110
126
|
approval_ctx: PlanApprovalContext | None = None,
|
|
111
|
-
) ->
|
|
112
|
-
"""Return a single self-contained HTML document for ``src_md``.
|
|
113
|
-
|
|
114
|
-
``css`` / ``js`` are inlined verbatim. No external URLs are written
|
|
115
|
-
into the document (validator enforces this — see
|
|
116
|
-
validate-report-views.py).
|
|
117
|
-
"""
|
|
118
|
-
# Compute the digest over the source MD body. The validator
|
|
119
|
-
# recomputes the same digest from the current MD and compares it to
|
|
120
|
-
# the value embedded in run-meta below.
|
|
127
|
+
) -> ReportViewModel:
|
|
121
128
|
digest = source_digest(src_md)
|
|
122
129
|
body_md = _strip_leading_frontmatter(src_md)
|
|
123
130
|
body_html, toc_headings = _markdown_to_html(body_md)
|
|
124
131
|
body_html = _inject_toc(body_html, toc_headings)
|
|
125
|
-
response_ids =
|
|
132
|
+
response_ids = tuple(
|
|
133
|
+
sorted({item.row_id for item in (parse_clarification_items(body_md) or [])})
|
|
134
|
+
)
|
|
135
|
+
return ReportViewModel(
|
|
136
|
+
run_meta=run_meta,
|
|
137
|
+
title=f"{run_meta.task_key} — {run_meta.task_type} #{run_meta.seq}",
|
|
138
|
+
body_html=body_html,
|
|
139
|
+
source_digest=digest,
|
|
140
|
+
response_ids=response_ids,
|
|
141
|
+
sidecar_name=f"user-response-{run_meta.task_type}-{run_meta.seq}.md",
|
|
142
|
+
sidecar_dir=f"runs/{run_meta.task_type}/user-responses/",
|
|
143
|
+
approval_ctx=approval_ctx,
|
|
144
|
+
)
|
|
126
145
|
|
|
127
|
-
title = html.escape(f"{run_meta.task_key} — {run_meta.task_type} #{run_meta.seq}")
|
|
128
|
-
response_ids_json = "[" + ",".join('"' + html.escape(rid) + '"' for rid in response_ids) + "]"
|
|
129
|
-
sidecar_name = html.escape(f"user-response-{run_meta.task_type}-{run_meta.seq}.md")
|
|
130
|
-
sidecar_dir = html.escape(f"runs/{run_meta.task_type}/user-responses/")
|
|
131
146
|
|
|
147
|
+
def render_report_view_model(
|
|
148
|
+
model: ReportViewModel,
|
|
149
|
+
*,
|
|
150
|
+
css: str,
|
|
151
|
+
js: str,
|
|
152
|
+
) -> str:
|
|
153
|
+
title = html.escape(model.title)
|
|
154
|
+
response_ids_json = "[" + ",".join(
|
|
155
|
+
'"' + html.escape(rid) + '"' for rid in model.response_ids
|
|
156
|
+
) + "]"
|
|
157
|
+
sidecar_name = html.escape(model.sidecar_name)
|
|
158
|
+
sidecar_dir = html.escape(model.sidecar_dir)
|
|
159
|
+
run_meta = model.run_meta
|
|
132
160
|
return (
|
|
133
161
|
f"<!DOCTYPE html>\n"
|
|
134
162
|
f"<html lang=\"ko\">\n"
|
|
@@ -143,8 +171,8 @@ def render_html(
|
|
|
143
171
|
f" <div>{title}</div>\n"
|
|
144
172
|
f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
|
|
145
173
|
f"</header>\n"
|
|
146
|
-
f"<main>{body_html}</main>\n"
|
|
147
|
-
f"{_plan_approval_section(approval_ctx, run_meta) if approval_ctx else ''}"
|
|
174
|
+
f"<main>{model.body_html}</main>\n"
|
|
175
|
+
f"{_plan_approval_section(model.approval_ctx, run_meta) if model.approval_ctx else ''}"
|
|
148
176
|
f"<footer class=\"report-footer\">\n"
|
|
149
177
|
f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
|
|
150
178
|
f" <p class=\"user-response-hint\">Export 클릭 시 <code>{sidecar_name}</code> 가 다운로드됩니다 — "
|
|
@@ -158,7 +186,7 @@ def render_html(
|
|
|
158
186
|
f"\"task-type\":\"{html.escape(run_meta.task_type)}\","
|
|
159
187
|
f"\"seq\":\"{html.escape(run_meta.seq)}\","
|
|
160
188
|
f"\"source-report\":\"{html.escape(run_meta.source_report)}\","
|
|
161
|
-
f"\"source-sha256\":\"{
|
|
189
|
+
f"\"source-sha256\":\"{model.source_digest}\","
|
|
162
190
|
f"\"response-ids\":{response_ids_json}}}"
|
|
163
191
|
f"</script>\n"
|
|
164
192
|
f"<script>{js}</script>\n"
|
|
@@ -167,6 +195,28 @@ def render_html(
|
|
|
167
195
|
)
|
|
168
196
|
|
|
169
197
|
|
|
198
|
+
def render_html(
|
|
199
|
+
src_md: str,
|
|
200
|
+
*,
|
|
201
|
+
run_meta: RunMeta,
|
|
202
|
+
css: str,
|
|
203
|
+
js: str,
|
|
204
|
+
approval_ctx: PlanApprovalContext | None = None,
|
|
205
|
+
) -> str:
|
|
206
|
+
"""Return a single self-contained HTML document for ``src_md``.
|
|
207
|
+
|
|
208
|
+
``css`` / ``js`` are inlined verbatim. No external URLs are written
|
|
209
|
+
into the document (validator enforces this — see
|
|
210
|
+
validate-report-views.py).
|
|
211
|
+
"""
|
|
212
|
+
model = build_report_view_model(
|
|
213
|
+
src_md,
|
|
214
|
+
run_meta=run_meta,
|
|
215
|
+
approval_ctx=approval_ctx,
|
|
216
|
+
)
|
|
217
|
+
return render_report_view_model(model, css=css, js=js)
|
|
218
|
+
|
|
219
|
+
|
|
170
220
|
def _markdown_to_html(
|
|
171
221
|
src_md: str,
|
|
172
222
|
) -> tuple[str, list[tuple[int, str, str]]]:
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""task-manifest.json 의 workStatus 를 갱신하는 단일 CLI 진입점.
|
|
2
|
+
|
|
3
|
+
okstra-inspect status.4 가 Edit 도구로 하던 수동 JSON 편집(키 순서·개행 보존
|
|
4
|
+
규칙을 프롬프트로 강제)을 CLI 로 수렴시킨다. 직렬화는 render._write_json 과
|
|
5
|
+
동일한 json.dumps(indent=2, ensure_ascii=False) + "\n" 이므로 재렌더와 byte
|
|
6
|
+
규칙이 일치한다. discovery/task-catalog.json 은 여기서 재생성하지 않는다 —
|
|
7
|
+
다음 run 렌더가 manifest 를 재투영할 때까지 stale 할 수 있다.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from okstra_ctl.ids import slugify_task_segment
|
|
18
|
+
from okstra_project import (
|
|
19
|
+
ResolverError,
|
|
20
|
+
StateError,
|
|
21
|
+
resolve_project_root,
|
|
22
|
+
resolve_task_reference,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
ALLOWED_WORK_STATUSES = ("todo", "in-progress", "blocked", "done")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _emit(payload: dict) -> None:
|
|
29
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _manifest_path(project_root: Path, entry: dict) -> Path:
|
|
33
|
+
rel = entry.get("taskManifestPath") or ""
|
|
34
|
+
if rel:
|
|
35
|
+
candidate = Path(rel)
|
|
36
|
+
return candidate if candidate.is_absolute() else project_root / candidate
|
|
37
|
+
group_seg = entry.get("taskGroupPathSegment") or slugify_task_segment(
|
|
38
|
+
entry.get("taskGroup", "")
|
|
39
|
+
)
|
|
40
|
+
id_seg = entry.get("taskIdPathSegment") or slugify_task_segment(
|
|
41
|
+
entry.get("taskId", "")
|
|
42
|
+
)
|
|
43
|
+
return project_root / ".okstra" / "tasks" / group_seg / id_seg / "task-manifest.json"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main(argv: list[str] | None = None) -> int:
|
|
47
|
+
parser = argparse.ArgumentParser(
|
|
48
|
+
description="Set a task's user-managed workStatus in task-manifest.json."
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"token",
|
|
52
|
+
help="full task-key (<project-id>:<task-group>:<task-id>) or bare task-id",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument("status", choices=ALLOWED_WORK_STATUSES)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--note",
|
|
57
|
+
default=None,
|
|
58
|
+
help="set workStatusNote; omit to leave any existing note untouched",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--task-group", default="", help="scope a bare task-id match to this task-group"
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument("--project-root", default="", help="project root for catalog lookup")
|
|
64
|
+
parser.add_argument("--cwd", default=".", help="cwd for project root resolution")
|
|
65
|
+
parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
|
|
66
|
+
args = parser.parse_args(argv)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
project_root = resolve_project_root(explicit_root=args.project_root, cwd=args.cwd)
|
|
70
|
+
except ResolverError as exc:
|
|
71
|
+
_emit({"ok": False, "stage": "resolve", "reason": str(exc)})
|
|
72
|
+
return 2
|
|
73
|
+
|
|
74
|
+
token = args.token
|
|
75
|
+
task_group = args.task_group
|
|
76
|
+
if token.count(":") == 2:
|
|
77
|
+
_, key_group, key_id = token.split(":")
|
|
78
|
+
token = key_id
|
|
79
|
+
task_group = key_group or task_group
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
matches = resolve_task_reference(
|
|
83
|
+
project_root, token, task_group=task_group or None
|
|
84
|
+
)
|
|
85
|
+
except StateError as exc:
|
|
86
|
+
_emit({"ok": False, "stage": "catalog", "reason": str(exc)})
|
|
87
|
+
return 2
|
|
88
|
+
|
|
89
|
+
if not matches:
|
|
90
|
+
_emit({"ok": False, "stage": "not-found", "token": args.token})
|
|
91
|
+
return 1
|
|
92
|
+
if len(matches) > 1:
|
|
93
|
+
_emit({"ok": False, "stage": "ambiguous", "matches": matches})
|
|
94
|
+
return 2
|
|
95
|
+
|
|
96
|
+
entry = matches[0]
|
|
97
|
+
manifest_path = _manifest_path(project_root, entry)
|
|
98
|
+
if not manifest_path.exists():
|
|
99
|
+
_emit(
|
|
100
|
+
{
|
|
101
|
+
"ok": False,
|
|
102
|
+
"stage": "manifest-missing",
|
|
103
|
+
"taskKey": entry.get("taskKey", ""),
|
|
104
|
+
"taskManifestPath": str(manifest_path),
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
return 1
|
|
108
|
+
try:
|
|
109
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
110
|
+
except ValueError as exc:
|
|
111
|
+
_emit(
|
|
112
|
+
{
|
|
113
|
+
"ok": False,
|
|
114
|
+
"stage": "manifest-invalid",
|
|
115
|
+
"taskManifestPath": str(manifest_path),
|
|
116
|
+
"reason": str(exc),
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
return 1
|
|
120
|
+
|
|
121
|
+
previous = manifest.get("workStatus", "")
|
|
122
|
+
manifest["workStatus"] = args.status
|
|
123
|
+
manifest["workStatusUpdatedAt"] = datetime.now(timezone.utc).strftime(
|
|
124
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
125
|
+
)
|
|
126
|
+
if args.note is not None:
|
|
127
|
+
manifest["workStatusNote"] = args.note
|
|
128
|
+
manifest_path.write_text(
|
|
129
|
+
json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
_emit(
|
|
133
|
+
{
|
|
134
|
+
"ok": True,
|
|
135
|
+
"taskKey": entry.get("taskKey", ""),
|
|
136
|
+
"previousWorkStatus": previous,
|
|
137
|
+
"workStatus": args.status,
|
|
138
|
+
"workStatusUpdatedAt": manifest["workStatusUpdatedAt"],
|
|
139
|
+
"workStatusNote": manifest.get("workStatusNote", ""),
|
|
140
|
+
"taskManifestPath": str(manifest_path),
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -38,6 +38,158 @@ class FinalVerificationTarget:
|
|
|
38
38
|
reports: list[str]
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class StageLifecycle:
|
|
43
|
+
stage: int
|
|
44
|
+
depends_on: list[int]
|
|
45
|
+
done: bool
|
|
46
|
+
started: bool
|
|
47
|
+
reserved: bool
|
|
48
|
+
verified_accepted: bool
|
|
49
|
+
pr_covered: bool
|
|
50
|
+
head_commit: str
|
|
51
|
+
report_path: str
|
|
52
|
+
|
|
53
|
+
def handoff_eligibility_record(self) -> dict[str, Any]:
|
|
54
|
+
reasons: list[str] = []
|
|
55
|
+
if self.pr_covered:
|
|
56
|
+
reasons.append("already-in-pr")
|
|
57
|
+
else:
|
|
58
|
+
if not self.done:
|
|
59
|
+
reasons.append("not-done")
|
|
60
|
+
if not self.verified_accepted:
|
|
61
|
+
reasons.append("not-verified-accepted")
|
|
62
|
+
return {
|
|
63
|
+
"stage": self.stage,
|
|
64
|
+
"depends_on": list(self.depends_on),
|
|
65
|
+
"eligible": not reasons,
|
|
66
|
+
"reasons": reasons,
|
|
67
|
+
"head_commit": self.head_commit,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class StageLifecycleSnapshot:
|
|
73
|
+
stage_map: list[dict[str, Any]]
|
|
74
|
+
rows: list[dict[str, Any]]
|
|
75
|
+
done_rows: list[dict[str, Any]]
|
|
76
|
+
done_by_stage: dict[int, dict[str, Any]]
|
|
77
|
+
done_stages: set[int]
|
|
78
|
+
started_stages: set[int]
|
|
79
|
+
reserved_stages: set[int]
|
|
80
|
+
verified_accepted_stages: set[int]
|
|
81
|
+
pr_covered_stages: set[int]
|
|
82
|
+
lifecycles: list[StageLifecycle]
|
|
83
|
+
|
|
84
|
+
def lifecycle_for(self, stage_number: int) -> StageLifecycle:
|
|
85
|
+
for lifecycle in self.lifecycles:
|
|
86
|
+
if lifecycle.stage == stage_number:
|
|
87
|
+
return lifecycle
|
|
88
|
+
raise StageTargetError(f"stage {stage_number} not in Stage Map")
|
|
89
|
+
|
|
90
|
+
def resolve_effective_stages(
|
|
91
|
+
self,
|
|
92
|
+
requested: str,
|
|
93
|
+
budget: int = RUN_STEP_BUDGET,
|
|
94
|
+
) -> list[int]:
|
|
95
|
+
return resolve_effective_stages(
|
|
96
|
+
self.stage_map,
|
|
97
|
+
self.done_stages,
|
|
98
|
+
requested,
|
|
99
|
+
budget=budget,
|
|
100
|
+
started_stages=self.started_stages,
|
|
101
|
+
reserved_stages=self.reserved_stages,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def resolve_implementation_stage(self, requested: str) -> int:
|
|
105
|
+
return self.resolve_effective_stages(requested)[0]
|
|
106
|
+
|
|
107
|
+
def concurrent_stage_numbers(self, *, selected_stage: int) -> list[int]:
|
|
108
|
+
return sorted(self.reserved_stages - self.done_stages - {selected_stage})
|
|
109
|
+
|
|
110
|
+
def handoff_eligibility(self) -> list[dict[str, Any]]:
|
|
111
|
+
return [
|
|
112
|
+
lifecycle.handoff_eligibility_record()
|
|
113
|
+
for lifecycle in self.lifecycles
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _stage_consumer_state_from_rows(rows: list[dict[str, Any]]) -> Any:
|
|
118
|
+
from .consumers import stage_consumer_state_from_rows
|
|
119
|
+
|
|
120
|
+
return stage_consumer_state_from_rows(rows)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def stage_lifecycle_snapshot_from_state(
|
|
124
|
+
stage_map: list[dict[str, Any]],
|
|
125
|
+
consumer_state: Any,
|
|
126
|
+
*,
|
|
127
|
+
reserved_stages: set[int] | None = None,
|
|
128
|
+
) -> StageLifecycleSnapshot:
|
|
129
|
+
reserved = set(reserved_stages or set())
|
|
130
|
+
lifecycles: list[StageLifecycle] = []
|
|
131
|
+
for stage in stage_map:
|
|
132
|
+
stage_number = stage["stage_number"]
|
|
133
|
+
done_row = consumer_state.done_by_stage.get(stage_number) or {}
|
|
134
|
+
lifecycles.append(StageLifecycle(
|
|
135
|
+
stage=stage_number,
|
|
136
|
+
depends_on=list(stage.get("depends_on") or []),
|
|
137
|
+
done=stage_number in consumer_state.done_stages,
|
|
138
|
+
started=stage_number in consumer_state.started_stages,
|
|
139
|
+
reserved=stage_number in reserved,
|
|
140
|
+
verified_accepted=stage_number in (
|
|
141
|
+
consumer_state.verified_accepted_stages
|
|
142
|
+
),
|
|
143
|
+
pr_covered=stage_number in consumer_state.pr_covered_stages,
|
|
144
|
+
head_commit=str(done_row.get("head_commit") or ""),
|
|
145
|
+
report_path=str(done_row.get("report_path") or ""),
|
|
146
|
+
))
|
|
147
|
+
return StageLifecycleSnapshot(
|
|
148
|
+
stage_map=stage_map,
|
|
149
|
+
rows=list(consumer_state.rows),
|
|
150
|
+
done_rows=list(consumer_state.done_rows),
|
|
151
|
+
done_by_stage=dict(consumer_state.done_by_stage),
|
|
152
|
+
done_stages=set(consumer_state.done_stages),
|
|
153
|
+
started_stages=set(consumer_state.started_stages),
|
|
154
|
+
reserved_stages=reserved,
|
|
155
|
+
verified_accepted_stages=set(consumer_state.verified_accepted_stages),
|
|
156
|
+
pr_covered_stages=set(consumer_state.pr_covered_stages),
|
|
157
|
+
lifecycles=lifecycles,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def stage_lifecycle_snapshot_from_rows(
|
|
162
|
+
stage_map: list[dict[str, Any]],
|
|
163
|
+
rows: list[dict[str, Any]],
|
|
164
|
+
*,
|
|
165
|
+
reserved_stages: set[int] | None = None,
|
|
166
|
+
) -> StageLifecycleSnapshot:
|
|
167
|
+
return stage_lifecycle_snapshot_from_state(
|
|
168
|
+
stage_map,
|
|
169
|
+
_stage_consumer_state_from_rows(rows),
|
|
170
|
+
reserved_stages=reserved_stages,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def read_stage_lifecycle_snapshot(
|
|
175
|
+
stage_map: list[dict[str, Any]],
|
|
176
|
+
plan_run_root: Path,
|
|
177
|
+
*,
|
|
178
|
+
recover_from_carry: bool = False,
|
|
179
|
+
reserved_stages: set[int] | None = None,
|
|
180
|
+
) -> StageLifecycleSnapshot:
|
|
181
|
+
from .consumers import read_stage_consumer_state
|
|
182
|
+
|
|
183
|
+
return stage_lifecycle_snapshot_from_state(
|
|
184
|
+
stage_map,
|
|
185
|
+
read_stage_consumer_state(
|
|
186
|
+
plan_run_root,
|
|
187
|
+
recover_from_carry=recover_from_carry,
|
|
188
|
+
),
|
|
189
|
+
reserved_stages=reserved_stages,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
41
193
|
def resolve_effective_stages(
|
|
42
194
|
stages: list[dict[str, Any]],
|
|
43
195
|
done_stages: set[int],
|
|
@@ -15,7 +15,7 @@ recorded pane — those are left for graceful shutdown.
|
|
|
15
15
|
CC v2.1.178 removed ``TeamDelete``: the implicit team ends with the session and
|
|
16
16
|
run-end teardown only dismisses teammates via ``shutdown_request``. Reconcile
|
|
17
17
|
keeps the roster honest so a dismissed-but-dead member does not linger as a
|
|
18
|
-
live pill — see
|
|
18
|
+
live pill — see okstra-lead-contract.md "Run-scoped pane & teammate lifecycle".
|
|
19
19
|
|
|
20
20
|
Roster resolution (``--project-root``) exists because ``team-state.lead.sessionId``
|
|
21
21
|
is a phase-3 snapshot: Claude Code re-issues the session id across resume /
|
|
@@ -12,6 +12,7 @@ Public surface:
|
|
|
12
12
|
- ``next_prompt()`` - deterministic; pure read on state.
|
|
13
13
|
- ``submit()`` - validate + advance.
|
|
14
14
|
- ``render_args()`` - final args for ``okstra render-bundle``.
|
|
15
|
+
- ``wizard_outcome()`` - final args plus persistence actions and confirmation.
|
|
15
16
|
|
|
16
17
|
The skill calls these via the ``okstra wizard`` CLI subcommand; it never
|
|
17
18
|
imports this module directly.
|
|
@@ -3707,6 +3708,36 @@ def confirmation_block(state: WizardState) -> str:
|
|
|
3707
3708
|
return "\n".join(lines)
|
|
3708
3709
|
|
|
3709
3710
|
|
|
3711
|
+
def _wizard_persist_actions(state: WizardState) -> list[dict[str, str]]:
|
|
3712
|
+
if state.task_type != "release-handoff":
|
|
3713
|
+
return []
|
|
3714
|
+
if not state.pr_template_path:
|
|
3715
|
+
return []
|
|
3716
|
+
if state.pr_template_scope not in ("project", "global"):
|
|
3717
|
+
return []
|
|
3718
|
+
return [
|
|
3719
|
+
{
|
|
3720
|
+
"command": "config.set",
|
|
3721
|
+
"key": "pr-template-path",
|
|
3722
|
+
"scope": state.pr_template_scope,
|
|
3723
|
+
"value": state.pr_template_path,
|
|
3724
|
+
}
|
|
3725
|
+
]
|
|
3726
|
+
|
|
3727
|
+
|
|
3728
|
+
def wizard_outcome(state: WizardState) -> dict[str, Any]:
|
|
3729
|
+
"""Public outcome for callers that need launch data and follow-up writes."""
|
|
3730
|
+
if state.aborted:
|
|
3731
|
+
raise WizardError("wizard was aborted by the user — outcome is unavailable")
|
|
3732
|
+
if state.confirmed is not True:
|
|
3733
|
+
raise WizardError("wizard is not complete — outcome is unavailable")
|
|
3734
|
+
return {
|
|
3735
|
+
"renderArgs": render_args(state),
|
|
3736
|
+
"persistActions": _wizard_persist_actions(state),
|
|
3737
|
+
"confirmationText": confirmation_block(state),
|
|
3738
|
+
}
|
|
3739
|
+
|
|
3740
|
+
|
|
3710
3741
|
# ---- File I/O helpers (used by CLI) -------------------------------------
|
|
3711
3742
|
|
|
3712
3743
|
def load_state_file(path: Path) -> WizardState:
|
|
@@ -3731,6 +3762,7 @@ def _cli(argv: list[str]) -> int:
|
|
|
3731
3762
|
step --state-file PATH (--answer VALUE | --no-submit)
|
|
3732
3763
|
render-args --state-file PATH
|
|
3733
3764
|
confirmation --state-file PATH
|
|
3765
|
+
outcome --state-file PATH
|
|
3734
3766
|
"""
|
|
3735
3767
|
import argparse
|
|
3736
3768
|
|
|
@@ -3759,6 +3791,9 @@ def _cli(argv: list[str]) -> int:
|
|
|
3759
3791
|
p_conf = sub.add_parser("confirmation")
|
|
3760
3792
|
p_conf.add_argument("--state-file", required=True)
|
|
3761
3793
|
|
|
3794
|
+
p_outcome = sub.add_parser("outcome")
|
|
3795
|
+
p_outcome.add_argument("--state-file", required=True)
|
|
3796
|
+
|
|
3762
3797
|
args = parser.parse_args(argv)
|
|
3763
3798
|
state_path = Path(args.state_file)
|
|
3764
3799
|
|
|
@@ -3836,6 +3871,18 @@ def _cli(argv: list[str]) -> int:
|
|
|
3836
3871
|
ensure_ascii=False, indent=2))
|
|
3837
3872
|
return 0
|
|
3838
3873
|
|
|
3874
|
+
if args.cmd == "outcome":
|
|
3875
|
+
state = load_state_file(state_path)
|
|
3876
|
+
try:
|
|
3877
|
+
out = wizard_outcome(state)
|
|
3878
|
+
except WizardError as exc:
|
|
3879
|
+
print(json.dumps({"ok": False, "error": str(exc)},
|
|
3880
|
+
ensure_ascii=False, indent=2))
|
|
3881
|
+
return 0
|
|
3882
|
+
print(json.dumps({"ok": True, "outcome": out},
|
|
3883
|
+
ensure_ascii=False, indent=2))
|
|
3884
|
+
return 0
|
|
3885
|
+
|
|
3839
3886
|
return 2
|
|
3840
3887
|
|
|
3841
3888
|
|
|
@@ -38,6 +38,7 @@ from .state import (
|
|
|
38
38
|
resolve_task_identity,
|
|
39
39
|
resolve_task_reference,
|
|
40
40
|
slugify,
|
|
41
|
+
task_read_side_snapshot,
|
|
41
42
|
)
|
|
42
43
|
|
|
43
44
|
__all__ = [
|
|
@@ -64,6 +65,7 @@ __all__ = [
|
|
|
64
65
|
"resolve_task_identity",
|
|
65
66
|
"resolve_task_reference",
|
|
66
67
|
"slugify",
|
|
68
|
+
"task_read_side_snapshot",
|
|
67
69
|
"tasks_root",
|
|
68
70
|
"upsert_project_json",
|
|
69
71
|
]
|
|
@@ -37,6 +37,10 @@ from .dirs import (
|
|
|
37
37
|
class StateError(Exception):
|
|
38
38
|
"""state file 읽기/파싱 실패 — 호출자가 surface 해야 할 오류."""
|
|
39
39
|
|
|
40
|
+
def __init__(self, message: str, *, stage: Optional[str] = None):
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
self.stage = stage
|
|
43
|
+
|
|
40
44
|
|
|
41
45
|
def _load_json(path: Path) -> Optional[dict]:
|
|
42
46
|
if not path.is_file():
|
|
@@ -268,10 +272,16 @@ def resolve_task_identity(project_root: Path, task_key: str) -> dict:
|
|
|
268
272
|
"""
|
|
269
273
|
task_root = find_task_root(project_root, task_key)
|
|
270
274
|
if task_root is None:
|
|
271
|
-
raise StateError(
|
|
275
|
+
raise StateError(
|
|
276
|
+
f"task root not found for {task_key!r}",
|
|
277
|
+
stage="task_root_missing",
|
|
278
|
+
)
|
|
272
279
|
manifest = read_task_manifest(task_root)
|
|
273
280
|
if manifest is None:
|
|
274
|
-
raise StateError(
|
|
281
|
+
raise StateError(
|
|
282
|
+
f"task-manifest.json missing under {task_root}",
|
|
283
|
+
stage="manifest_missing",
|
|
284
|
+
)
|
|
275
285
|
workflow = manifest.get("workflow") or {}
|
|
276
286
|
if not isinstance(workflow, dict):
|
|
277
287
|
workflow = {}
|
|
@@ -292,3 +302,35 @@ def resolve_task_identity(project_root: Path, task_key: str) -> dict:
|
|
|
292
302
|
"taskBriefPath": manifest.get("taskBriefPath") or "",
|
|
293
303
|
"manifest": manifest,
|
|
294
304
|
}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def task_read_side_snapshot(project_root: Path, task_key: str) -> dict:
|
|
308
|
+
"""task-show / inspect 용 curated task 상태 view 를 반환한다.
|
|
309
|
+
|
|
310
|
+
resolve_task_identity 가 내부 raw manifest 를 들고 있지만, 이 accessor 는
|
|
311
|
+
caller 에게 manifest shape 를 노출하지 않는 read-side 계약이다.
|
|
312
|
+
"""
|
|
313
|
+
identity = resolve_task_identity(project_root, task_key)
|
|
314
|
+
manifest = identity["manifest"]
|
|
315
|
+
workflow = manifest.get("workflow")
|
|
316
|
+
if not isinstance(workflow, dict):
|
|
317
|
+
workflow = {}
|
|
318
|
+
return {
|
|
319
|
+
"projectRoot": identity["projectRoot"],
|
|
320
|
+
"taskKey": manifest.get("taskKey"),
|
|
321
|
+
"taskType": manifest.get("taskType"),
|
|
322
|
+
"taskRoot": identity["taskRoot"],
|
|
323
|
+
"taskBriefPath": manifest.get("taskBriefPath"),
|
|
324
|
+
"workflow": {
|
|
325
|
+
"currentPhase": workflow.get("currentPhase"),
|
|
326
|
+
"currentPhaseState": workflow.get("currentPhaseState"),
|
|
327
|
+
"lastCompletedPhase": workflow.get("lastCompletedPhase"),
|
|
328
|
+
"nextRecommendedPhase": workflow.get("nextRecommendedPhase"),
|
|
329
|
+
"routingStatus": workflow.get("routingStatus"),
|
|
330
|
+
"phaseStates": workflow.get("phaseStates"),
|
|
331
|
+
},
|
|
332
|
+
"resultContract": manifest.get("resultContract"),
|
|
333
|
+
"artifacts": manifest.get("artifacts"),
|
|
334
|
+
"modelAssignments": manifest.get("modelAssignments"),
|
|
335
|
+
"latestRunPath": manifest.get("latestRunPath"),
|
|
336
|
+
}
|