okstra 0.159.0 → 0.161.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.md +1 -1
- package/docs/architecture/storage-model.md +2 -0
- package/docs/architecture.md +2 -1
- package/docs/cli.md +8 -3
- package/docs/for-ai/README.md +2 -2
- package/docs/for-ai/skills/okstra-inspect.md +3 -0
- package/docs/for-ai/skills/okstra-run.md +2 -1
- package/docs/for-ai/skills/okstra-user-response.md +5 -5
- package/docs/project-structure-overview.md +5 -1
- package/docs/task-process/implementation.md +28 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-claude-exec.sh +4 -1
- package/runtime/prompts/host-orchestration/README.md +18 -0
- package/runtime/prompts/host-orchestration/implementation.md +57 -0
- package/runtime/prompts/launch.template.md +10 -1
- package/runtime/prompts/lead/adapters/claude-code.md +1 -1
- package/runtime/prompts/lead/adapters/cmux.md +67 -0
- package/runtime/prompts/lead/context-loader.md +5 -2
- package/runtime/prompts/lead/convergence.md +3 -1
- package/runtime/prompts/lead/plan-body-verification.md +21 -2
- package/runtime/prompts/lead/team-contract.md +2 -1
- package/runtime/prompts/profiles/_clarification-recommendation.md +11 -1
- package/runtime/prompts/profiles/_common-contract.md +3 -1
- package/runtime/prompts/profiles/implementation-planning.md +2 -0
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +3 -0
- package/runtime/python/okstra_ctl/clarification_items.py +9 -0
- package/runtime/python/okstra_ctl/cmux.py +531 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +6 -6
- package/runtime/python/okstra_ctl/convergence.py +168 -11
- package/runtime/python/okstra_ctl/dispatch_core.py +76 -7
- package/runtime/python/okstra_ctl/dispatch_state.py +16 -0
- package/runtime/python/okstra_ctl/error_issue.py +640 -0
- package/runtime/python/okstra_ctl/error_report.py +56 -0
- package/runtime/python/okstra_ctl/error_zip.py +23 -10
- package/runtime/python/okstra_ctl/incremental_scope.py +159 -19
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +18 -5
- package/runtime/python/okstra_ctl/issue_signals.py +186 -0
- package/runtime/python/okstra_ctl/lead_runtime.py +30 -2
- package/runtime/python/okstra_ctl/paths.py +38 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +167 -3
- package/runtime/python/okstra_ctl/profile_show.py +134 -0
- package/runtime/python/okstra_ctl/recap.py +63 -0
- package/runtime/python/okstra_ctl/render.py +7 -2
- package/runtime/python/okstra_ctl/render_final_report.py +7 -22
- package/runtime/python/okstra_ctl/report_translation.py +4 -0
- package/runtime/python/okstra_ctl/report_views.py +7 -3
- package/runtime/python/okstra_ctl/run.py +54 -3
- package/runtime/python/okstra_ctl/run_audit.py +477 -0
- package/runtime/python/okstra_ctl/team.py +50 -11
- package/runtime/python/okstra_ctl/user_response.py +25 -10
- package/runtime/python/okstra_ctl/verdict_blocks.py +183 -0
- package/runtime/python/okstra_ctl/wizard.py +64 -10
- package/runtime/python/okstra_ctl/worker_audit_check.py +44 -0
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +207 -0
- package/runtime/python/okstra_ctl/worker_heartbeat.py +9 -3
- package/runtime/python/okstra_ctl/worker_liveness.py +81 -9
- package/runtime/schemas/final-report-v1.0.schema.json +14 -0
- package/runtime/schemas/final-report-v2.0.schema.json +51 -1
- package/runtime/skills/okstra-inspect/SKILL.md +3 -1
- package/runtime/skills/okstra-inspect/facets/error-issue.md +77 -0
- package/runtime/skills/okstra-inspect/facets/run-audit.md +34 -0
- package/runtime/skills/okstra-run/SKILL.md +28 -10
- package/runtime/skills/okstra-user-response/SKILL.md +18 -18
- package/runtime/templates/reports/final-report.template.md +4 -0
- package/runtime/templates/reports/html/i18n/en.json +5 -1
- package/runtime/templates/reports/html/i18n/ko.json +5 -1
- package/runtime/templates/reports/html/macros/forms.html +15 -0
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -0
- package/runtime/templates/reports/i18n/en.json +2 -0
- package/runtime/validators/validate-run.py +267 -208
- package/runtime/validators/validate-workflow.sh +6 -0
- package/runtime/validators/validate_session_conformance.py +135 -31
- package/src/cli-registry.mjs +34 -0
- package/src/commands/execute/incremental-scope.mjs +10 -0
- package/src/commands/execute/worker-audit-check.mjs +35 -0
- package/src/commands/inspect/error-issue.mjs +27 -0
- package/src/commands/inspect/profile-show.mjs +29 -0
- package/src/commands/inspect/run-audit.mjs +26 -0
|
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|
|
15
15
|
|
|
16
16
|
import os
|
|
17
17
|
import re
|
|
18
|
+
from collections.abc import Callable, Sequence
|
|
18
19
|
from dataclasses import dataclass, replace
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
from typing import Optional
|
|
@@ -41,9 +42,46 @@ __all__ = [
|
|
|
41
42
|
"task_runs_dir",
|
|
42
43
|
"container_paths",
|
|
43
44
|
"okstra_home",
|
|
45
|
+
"find_asset_root",
|
|
44
46
|
]
|
|
45
47
|
|
|
46
48
|
|
|
49
|
+
def find_asset_root(
|
|
50
|
+
relative: Sequence[str],
|
|
51
|
+
*,
|
|
52
|
+
start: Optional[Path] = None,
|
|
53
|
+
is_present: Callable[[Path], bool] = Path.is_file,
|
|
54
|
+
) -> Optional[Path]:
|
|
55
|
+
"""Return the runtime root that carries ``relative``, or None.
|
|
56
|
+
|
|
57
|
+
okstra's assets ship in three layouts that put the same tree at a different
|
|
58
|
+
depth relative to this package: a repo checkout has `scripts/okstra_ctl/`,
|
|
59
|
+
the built runtime has `runtime/python/okstra_ctl/`, and an install has
|
|
60
|
+
`~/.okstra/lib/python/okstra_ctl/` with the asset trees two levels further
|
|
61
|
+
up at `~/.okstra/`. Probing for the asset therefore beats counting parents,
|
|
62
|
+
which is right for exactly one of the three.
|
|
63
|
+
|
|
64
|
+
Pass ``is_present=Path.is_dir`` when ``relative`` names a directory.
|
|
65
|
+
|
|
66
|
+
This reads `OKSTRA_HOME` straight from the environment rather than calling
|
|
67
|
+
`okstra_home()`, and the difference is load-bearing: `okstra_home()` falls
|
|
68
|
+
back to `~/.okstra` when the variable is unset, which would let an
|
|
69
|
+
installed copy shadow the checkout a developer is running from. An unset
|
|
70
|
+
variable — or one whose tree lacks the asset — falls through to the walk.
|
|
71
|
+
"""
|
|
72
|
+
override = os.environ.get("OKSTRA_HOME")
|
|
73
|
+
if override:
|
|
74
|
+
root = Path(override)
|
|
75
|
+
if is_present(root.joinpath(*relative)):
|
|
76
|
+
return root
|
|
77
|
+
|
|
78
|
+
here = Path(start or __file__).resolve()
|
|
79
|
+
for parent in [here, *here.parents]:
|
|
80
|
+
if is_present(parent.joinpath(*relative)):
|
|
81
|
+
return parent
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
47
85
|
_STAGED_TASK_TYPES = ("implementation", "final-verification")
|
|
48
86
|
# 발견용: bash `find -name 'final-report-*.md'` 와 같은 범위 — seq 이전 세대의
|
|
49
87
|
# 타임스탬프 파일명도 잡아야 옛 번들에서 조용히 실패하지 않는다.
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
"""CLI adapter for deterministic implementation-planning item extraction.
|
|
1
|
+
"""CLI adapter for deterministic implementation-planning item extraction.
|
|
2
|
+
|
|
3
|
+
`extract` / `validate` own the `P-*` queue; `collect-verdicts` / `apply-verdicts`
|
|
4
|
+
own the round's votes on that queue. Both halves exist so the round's fidelity
|
|
5
|
+
does not depend on a parser the lead re-writes each time.
|
|
6
|
+
"""
|
|
2
7
|
from __future__ import annotations
|
|
3
8
|
|
|
4
9
|
import argparse
|
|
@@ -10,6 +15,7 @@ from typing import Any
|
|
|
10
15
|
|
|
11
16
|
from .convergence_store import write_json_atomic
|
|
12
17
|
from .plan_items import PlanItemContractError, extract_plan_items
|
|
18
|
+
from .verdict_blocks import VerdictBlock, VerdictBlockError, parse_verdict_blocks
|
|
13
19
|
|
|
14
20
|
|
|
15
21
|
def _load_json_object(path: Path) -> dict[str, Any]:
|
|
@@ -49,6 +55,21 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
49
55
|
validate = commands.add_parser("validate")
|
|
50
56
|
validate.add_argument("--data", type=Path, required=True)
|
|
51
57
|
validate.add_argument("--items", type=Path, required=True)
|
|
58
|
+
collect = commands.add_parser(
|
|
59
|
+
"collect-verdicts",
|
|
60
|
+
help="read this round's worker responses into a verdicts envelope",
|
|
61
|
+
)
|
|
62
|
+
collect.add_argument("--result", action="append", default=[], required=True,
|
|
63
|
+
metavar="<worker-id>=<path>",
|
|
64
|
+
help="one worker's plan-verify result file (repeatable)")
|
|
65
|
+
collect.add_argument("--items", type=Path, required=True)
|
|
66
|
+
collect.add_argument("--output", type=Path, required=True)
|
|
67
|
+
apply_verdicts = commands.add_parser(
|
|
68
|
+
"apply-verdicts",
|
|
69
|
+
help="overwrite planBodyVerification.planItems[].verdicts in data.json",
|
|
70
|
+
)
|
|
71
|
+
apply_verdicts.add_argument("--data", type=Path, required=True)
|
|
72
|
+
apply_verdicts.add_argument("--verdicts", type=Path, required=True)
|
|
52
73
|
return parser
|
|
53
74
|
|
|
54
75
|
|
|
@@ -66,11 +87,154 @@ def _validate(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
66
87
|
return {"ok": True, "operation": "validate", "path": str(args.items)}
|
|
67
88
|
|
|
68
89
|
|
|
90
|
+
def _split_result_arg(raw: str) -> tuple[str, Path]:
|
|
91
|
+
worker, separator, path = raw.partition("=")
|
|
92
|
+
if not separator or not worker.strip() or not path.strip():
|
|
93
|
+
raise PlanItemContractError(
|
|
94
|
+
f"--result must be <worker-id>=<path>, got: {raw}"
|
|
95
|
+
)
|
|
96
|
+
return worker.strip(), Path(path.strip())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _assigned_item_ids(items_path: Path) -> list[str]:
|
|
100
|
+
envelope = _load_json_object(items_path)
|
|
101
|
+
items = envelope.get("items")
|
|
102
|
+
if not isinstance(items, list):
|
|
103
|
+
raise PlanItemContractError(f"items envelope has no `items` array: {items_path}")
|
|
104
|
+
ids: list[str] = []
|
|
105
|
+
for item in items:
|
|
106
|
+
item_id = item.get("id") if isinstance(item, Mapping) else None
|
|
107
|
+
if not isinstance(item_id, str) or not item_id:
|
|
108
|
+
raise PlanItemContractError(f"every item needs an `id`: {items_path}")
|
|
109
|
+
ids.append(item_id)
|
|
110
|
+
return ids
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _verdict_row(worker: str, block: VerdictBlock) -> dict[str, Any]:
|
|
114
|
+
"""One `planItems[].verdicts[]` row. Optional fields stay absent when empty
|
|
115
|
+
so the recorded table shows what the worker actually said."""
|
|
116
|
+
row: dict[str, Any] = {"worker": worker, "verdict": block.verdict}
|
|
117
|
+
for key, value in (
|
|
118
|
+
("breakageKind", block.breakage_kind),
|
|
119
|
+
("fixability", block.fixability),
|
|
120
|
+
("note", block.note),
|
|
121
|
+
("priorDissent", block.prior_dissent),
|
|
122
|
+
):
|
|
123
|
+
if value:
|
|
124
|
+
row[key] = value
|
|
125
|
+
return row
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _worker_blocks(
|
|
129
|
+
raw_results: list[str], assigned: set[str]
|
|
130
|
+
) -> list[tuple[str, dict[str, VerdictBlock]]]:
|
|
131
|
+
"""Each worker's parsed response, refusing any queue mismatch.
|
|
132
|
+
|
|
133
|
+
A missing vote and an invented item are both silent in a hand-written
|
|
134
|
+
parser; each is a round scored on a table that does not match the queue.
|
|
135
|
+
"""
|
|
136
|
+
collected: list[tuple[str, dict[str, VerdictBlock]]] = []
|
|
137
|
+
for raw in raw_results:
|
|
138
|
+
worker, path = _split_result_arg(raw)
|
|
139
|
+
try:
|
|
140
|
+
blocks = parse_verdict_blocks(path.read_text(encoding="utf-8"))
|
|
141
|
+
except (OSError, UnicodeError) as exc:
|
|
142
|
+
raise PlanItemContractError(f"cannot read result {path}: {exc}") from exc
|
|
143
|
+
answered = set(blocks)
|
|
144
|
+
missing = sorted(assigned - answered)
|
|
145
|
+
if missing:
|
|
146
|
+
raise PlanItemContractError(
|
|
147
|
+
f"worker `{worker}` was assigned {len(assigned)} items but "
|
|
148
|
+
f"returned no verdict for {missing} — an unanswered item cannot "
|
|
149
|
+
f"be scored, and dropping it silently is what makes a round look "
|
|
150
|
+
f"complete when it is not"
|
|
151
|
+
)
|
|
152
|
+
unknown = sorted(answered - assigned)
|
|
153
|
+
if unknown:
|
|
154
|
+
raise PlanItemContractError(
|
|
155
|
+
f"worker `{worker}` returned verdicts for {unknown}, which are "
|
|
156
|
+
f"not in the persisted plan-item queue"
|
|
157
|
+
)
|
|
158
|
+
collected.append((worker, blocks))
|
|
159
|
+
return collected
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _collect_verdicts(args: argparse.Namespace) -> dict[str, Any]:
|
|
163
|
+
assigned = _assigned_item_ids(args.items)
|
|
164
|
+
collected = _worker_blocks(args.result, set(assigned))
|
|
165
|
+
envelope = {
|
|
166
|
+
"schemaVersion": "1.0",
|
|
167
|
+
"taskType": "implementation-planning",
|
|
168
|
+
"planItems": [
|
|
169
|
+
{
|
|
170
|
+
"id": item_id,
|
|
171
|
+
"verdicts": [
|
|
172
|
+
_verdict_row(worker, blocks[item_id])
|
|
173
|
+
for worker, blocks in collected
|
|
174
|
+
],
|
|
175
|
+
}
|
|
176
|
+
for item_id in assigned
|
|
177
|
+
],
|
|
178
|
+
}
|
|
179
|
+
write_json_atomic(args.output, envelope)
|
|
180
|
+
return {"ok": True, "operation": "collect-verdicts", "path": str(args.output)}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _plan_body_items(data: dict[str, Any], data_path: Path) -> list[dict[str, Any]]:
|
|
184
|
+
verification = _planning(data).get("planBodyVerification")
|
|
185
|
+
if not isinstance(verification, Mapping):
|
|
186
|
+
raise PlanItemContractError(
|
|
187
|
+
f"implementationPlanning.planBodyVerification must be an object: {data_path}"
|
|
188
|
+
)
|
|
189
|
+
items = verification.get("planItems")
|
|
190
|
+
if not isinstance(items, list):
|
|
191
|
+
raise PlanItemContractError(
|
|
192
|
+
f"planBodyVerification.planItems must be an array: {data_path}"
|
|
193
|
+
)
|
|
194
|
+
return items
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
|
|
198
|
+
data = _load_json_object(args.data)
|
|
199
|
+
incoming = _load_json_object(args.verdicts).get("planItems")
|
|
200
|
+
if not isinstance(incoming, list):
|
|
201
|
+
raise PlanItemContractError("verdicts envelope has no `planItems` array")
|
|
202
|
+
rows = {
|
|
203
|
+
item["id"]: item.get("verdicts", [])
|
|
204
|
+
for item in incoming
|
|
205
|
+
if isinstance(item, Mapping) and isinstance(item.get("id"), str)
|
|
206
|
+
}
|
|
207
|
+
recorded = _plan_body_items(data, args.data)
|
|
208
|
+
known = {item.get("id") for item in recorded if isinstance(item, Mapping)}
|
|
209
|
+
missing = sorted(item_id for item_id in rows if item_id not in known)
|
|
210
|
+
if missing:
|
|
211
|
+
raise PlanItemContractError(
|
|
212
|
+
f"the report's planBodyVerification has no row for {missing} — the "
|
|
213
|
+
f"gate is re-derived from that table, so a verdict with nowhere to "
|
|
214
|
+
f"land would be scored as if it were never cast"
|
|
215
|
+
)
|
|
216
|
+
for item in recorded:
|
|
217
|
+
if isinstance(item, Mapping) and item.get("id") in rows:
|
|
218
|
+
# Overwrite, never merge: the contract records one round at a time,
|
|
219
|
+
# and a merged table lets a previous round's votes keep voting.
|
|
220
|
+
item["verdicts"] = rows[item["id"]]
|
|
221
|
+
write_json_atomic(args.data, data)
|
|
222
|
+
return {"ok": True, "operation": "apply-verdicts", "path": str(args.data)}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
_HANDLERS = {
|
|
226
|
+
"extract": _extract,
|
|
227
|
+
"validate": _validate,
|
|
228
|
+
"collect-verdicts": _collect_verdicts,
|
|
229
|
+
"apply-verdicts": _apply_verdicts,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
69
233
|
def main(argv: list[str] | None = None) -> int:
|
|
70
234
|
args = _parser().parse_args(argv)
|
|
71
235
|
try:
|
|
72
|
-
result =
|
|
73
|
-
except (PlanItemContractError, OSError, ValueError) as exc:
|
|
236
|
+
result = _HANDLERS[args.command](args)
|
|
237
|
+
except (PlanItemContractError, VerdictBlockError, OSError, ValueError) as exc:
|
|
74
238
|
print(f"plan-items: {exc}", file=sys.stderr)
|
|
75
239
|
return 2
|
|
76
240
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Read-only flattened view of a phase profile (`okstra profile show`).
|
|
2
|
+
|
|
3
|
+
A profile is assembled from three places — the top-level body, its
|
|
4
|
+
`{{INCLUDE:}}` targets, and the lazy-read sidecars the body's own table names —
|
|
5
|
+
so grepping the top-level file and finding nothing does not mean the rule is
|
|
6
|
+
absent. This prints the whole thing, so one grep answers "does this task-type
|
|
7
|
+
cover X".
|
|
8
|
+
|
|
9
|
+
Read-only is the point, not a nicety: `render-bundle` would answer the same
|
|
10
|
+
question, but it writes a manifest and registers the run in `recent.jsonl`.
|
|
11
|
+
That side effect is exactly why it cannot be used to look something up.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .paths import find_asset_root
|
|
22
|
+
from .run import PrepareError, _expand_profile_includes
|
|
23
|
+
|
|
24
|
+
_PROFILES_REL = ("prompts", "profiles")
|
|
25
|
+
|
|
26
|
+
# The sidecar table spells its targets as inline-code repo-relative paths, e.g.
|
|
27
|
+
# `prompts/profiles/_implementation-executor.md`. Reading the list from the
|
|
28
|
+
# profile body rather than hard-coding it here is deliberate: a list in code
|
|
29
|
+
# goes stale the moment a profile adds a sidecar, and does so silently.
|
|
30
|
+
_SIDECAR_RE = re.compile(r"`(prompts/profiles/_[\w-]+\.md)`")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def workspace_root(start: Path | None = None) -> Path:
|
|
34
|
+
"""Locate the runtime root that carries the `prompts/profiles` tree."""
|
|
35
|
+
root = find_asset_root(_PROFILES_REL, start=start, is_present=Path.is_dir)
|
|
36
|
+
if root is not None:
|
|
37
|
+
return root
|
|
38
|
+
|
|
39
|
+
raise PrepareError(
|
|
40
|
+
"could not locate prompts/profiles. Set OKSTRA_HOME or run from a "
|
|
41
|
+
"checkout that contains prompts/profiles/."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def profile_path(root: Path, task_type: str) -> Path:
|
|
46
|
+
path = root.joinpath(*_PROFILES_REL, f"{task_type}.md")
|
|
47
|
+
if not path.is_file():
|
|
48
|
+
raise PrepareError(f"unknown task-type: {task_type} (no {path})")
|
|
49
|
+
return path
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def sidecar_bodies(root: Path, profile_text: str) -> list[str]:
|
|
53
|
+
"""Every lazy-read sidecar reachable from the profile body, breadth-first.
|
|
54
|
+
|
|
55
|
+
The walk runs to a fixpoint because sidecars name sidecars of their own:
|
|
56
|
+
`_implementation-executor.md` points at the coding-conventions preflight,
|
|
57
|
+
the diff-review sweep, and the completion self-check. Stopping after one
|
|
58
|
+
hop would rebuild the very false negative this command exists to prevent,
|
|
59
|
+
one level down.
|
|
60
|
+
|
|
61
|
+
Each body is expanded rather than read raw, so nested `{{INCLUDE:}}`
|
|
62
|
+
directives resolve and the maintainer-only HTML comments drop out — the
|
|
63
|
+
same treatment the lead's rendered profile gets.
|
|
64
|
+
"""
|
|
65
|
+
bodies: list[str] = []
|
|
66
|
+
seen: set[str] = set()
|
|
67
|
+
pending = list(dict.fromkeys(_SIDECAR_RE.findall(profile_text)))
|
|
68
|
+
while pending:
|
|
69
|
+
relative = pending.pop(0)
|
|
70
|
+
if relative in seen:
|
|
71
|
+
continue
|
|
72
|
+
seen.add(relative)
|
|
73
|
+
path = root / relative
|
|
74
|
+
if not path.is_file():
|
|
75
|
+
continue
|
|
76
|
+
body = _expand_profile_includes(path)
|
|
77
|
+
bodies.append(f"\n\n<!-- lazy-read sidecar: {relative} -->\n\n{body}")
|
|
78
|
+
pending.extend(ref for ref in _SIDECAR_RE.findall(body) if ref not in seen)
|
|
79
|
+
return bodies
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def render(root: Path, task_type: str, *, resolved: bool) -> str:
|
|
83
|
+
path = profile_path(root, task_type)
|
|
84
|
+
if not resolved:
|
|
85
|
+
return path.read_text(encoding="utf-8")
|
|
86
|
+
expanded = _expand_profile_includes(path)
|
|
87
|
+
return expanded + "".join(sidecar_bodies(root, expanded))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _write_stdout(text: str) -> None:
|
|
91
|
+
"""Write the profile, tolerating a reader that stops early.
|
|
92
|
+
|
|
93
|
+
The command's whole purpose is to be piped into `grep` or `head`, and both
|
|
94
|
+
close the pipe as soon as they have enough. Without this, that normal usage
|
|
95
|
+
ends in a BrokenPipeError traceback — and the interpreter raises a second
|
|
96
|
+
one when it flushes stdout at shutdown, which is why the fd is redirected
|
|
97
|
+
to devnull rather than merely swallowing the first exception.
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
sys.stdout.write(text)
|
|
101
|
+
sys.stdout.flush()
|
|
102
|
+
except BrokenPipeError:
|
|
103
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
104
|
+
try:
|
|
105
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
106
|
+
finally:
|
|
107
|
+
os.close(devnull)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def main(argv: list[str] | None = None) -> int:
|
|
111
|
+
parser = argparse.ArgumentParser(
|
|
112
|
+
prog="okstra profile show",
|
|
113
|
+
description="Print a phase profile, optionally fully resolved (read-only).",
|
|
114
|
+
)
|
|
115
|
+
parser.add_argument("command", choices=("show",))
|
|
116
|
+
parser.add_argument("task_type")
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
"--resolved",
|
|
119
|
+
action="store_true",
|
|
120
|
+
help="expand {{INCLUDE:}} targets and append the lazy-read sidecars",
|
|
121
|
+
)
|
|
122
|
+
args = parser.parse_args(argv)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
text = render(workspace_root(), args.task_type, resolved=args.resolved)
|
|
126
|
+
except PrepareError as exc:
|
|
127
|
+
print(f"profile show: {exc}", file=sys.stderr)
|
|
128
|
+
return 2
|
|
129
|
+
_write_stdout(text)
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -11,7 +11,9 @@ import json
|
|
|
11
11
|
import sys
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
|
+
from okstra_ctl.clarification_items import sidecar_answers, user_response_sidecars
|
|
14
15
|
from okstra_ctl.ids import slugify_task_segment
|
|
16
|
+
from okstra_ctl.incremental_scope import preview_link_availability_for_report
|
|
15
17
|
from okstra_ctl.paths import task_timeline_file
|
|
16
18
|
from okstra_project import read_task_key
|
|
17
19
|
from okstra_ctl.run_context import dir_flock
|
|
@@ -33,6 +35,63 @@ def _load_timeline(task_root: Path) -> list[dict]:
|
|
|
33
35
|
|
|
34
36
|
|
|
35
37
|
|
|
38
|
+
_STRUCTURAL_CHANGE_NOTE = (
|
|
39
|
+
"An answer that overturns the selected option, restructures the Stage Map, "
|
|
40
|
+
'or changes the recommended approach requires --full-reason "<what changes '
|
|
41
|
+
'and how>". The back-trace resolves stages; it cannot judge whether the '
|
|
42
|
+
"plan's shape survived, so that call is the lead's and must be declared."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _latest_planning_report(runs: list[dict], project_root: Path) -> Path | None:
|
|
47
|
+
"""The most recent `implementation-planning` report still on disk."""
|
|
48
|
+
for run in reversed(runs):
|
|
49
|
+
if not isinstance(run, dict):
|
|
50
|
+
continue
|
|
51
|
+
if run.get("taskType") != "implementation-planning":
|
|
52
|
+
continue
|
|
53
|
+
relative = str(run.get("reportPath") or "")
|
|
54
|
+
if not relative:
|
|
55
|
+
continue
|
|
56
|
+
report = project_root / relative
|
|
57
|
+
if report.is_file():
|
|
58
|
+
return report
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def rerun_readiness(project_root: Path, runs: list[dict]) -> dict | None:
|
|
63
|
+
"""What the next clarification re-run needs, assembled from disk.
|
|
64
|
+
|
|
65
|
+
Every field is derivable before the run starts, and each one used to live
|
|
66
|
+
somewhere else: the flag value in the lead prompt (read only *after* the
|
|
67
|
+
run begins), the answered ids in sidecars, the re-verification mode nowhere
|
|
68
|
+
at all until `incremental-scope --preview`. Scattering them is why the user
|
|
69
|
+
had to ask for each one instead of being told.
|
|
70
|
+
|
|
71
|
+
``None`` when nothing is waiting to be carried — no planning report, or no
|
|
72
|
+
answered clarification beside it.
|
|
73
|
+
"""
|
|
74
|
+
report = _latest_planning_report(runs, project_root)
|
|
75
|
+
if report is None:
|
|
76
|
+
return None
|
|
77
|
+
answered = sorted(sidecar_answers(report))
|
|
78
|
+
if not answered:
|
|
79
|
+
return None
|
|
80
|
+
return {
|
|
81
|
+
"sourceReport": project_rel(report, project_root),
|
|
82
|
+
"answeredClarifications": answered,
|
|
83
|
+
"answeredClarificationsCsv": ",".join(answered),
|
|
84
|
+
"sidecars": [
|
|
85
|
+
project_rel(sidecar, project_root)
|
|
86
|
+
for sidecar in user_response_sidecars(report)
|
|
87
|
+
],
|
|
88
|
+
"reverifyPreview": preview_link_availability_for_report(
|
|
89
|
+
report, set(answered)
|
|
90
|
+
),
|
|
91
|
+
"structuralChangeNote": _STRUCTURAL_CHANGE_NOTE,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
36
95
|
def assemble_recap(task_root: Path, project_root: Path) -> dict:
|
|
37
96
|
runs = _load_timeline(task_root)
|
|
38
97
|
transitions = []
|
|
@@ -62,6 +121,10 @@ def assemble_recap(task_root: Path, project_root: Path) -> dict:
|
|
|
62
121
|
"runCount": len(runs),
|
|
63
122
|
"transitions": transitions,
|
|
64
123
|
"latestPhaseStates": latest_states,
|
|
124
|
+
# `null` when nothing is waiting to be carried — the key is always
|
|
125
|
+
# present so a consumer can tell "no re-run pending" from "this recap
|
|
126
|
+
# predates the block".
|
|
127
|
+
"rerunReadiness": rerun_readiness(project_root, runs),
|
|
65
128
|
}
|
|
66
129
|
|
|
67
130
|
|
|
@@ -31,7 +31,8 @@ from okstra_project.dirs import TASK_MANIFEST_FILENAME, OKSTRA_DIR_NAME, project
|
|
|
31
31
|
from . import fix_cycles
|
|
32
32
|
from .analysis_inputs import ANALYSIS_TASK_TYPES
|
|
33
33
|
from .paths import okstra_home
|
|
34
|
-
from .
|
|
34
|
+
from .dispatch_state import BACKEND_CMUX_PANE
|
|
35
|
+
from .lead_runtime import lead_runtime_info, with_cmux_dispatch
|
|
35
36
|
from .models import UnknownProviderError, provider_ids, provider_spec
|
|
36
37
|
from .runner_resolution import native_provider_for_host
|
|
37
38
|
from .path_hints import compact_active_run_context, hydrate_run_context
|
|
@@ -80,7 +81,10 @@ def _lead_runtime(ctx: dict) -> str:
|
|
|
80
81
|
|
|
81
82
|
|
|
82
83
|
def _lead_info(ctx: dict):
|
|
83
|
-
|
|
84
|
+
info = lead_runtime_info(_lead_runtime(ctx))
|
|
85
|
+
if ctx.get("TERMINAL_BACKEND") == BACKEND_CMUX_PANE:
|
|
86
|
+
return with_cmux_dispatch(info)
|
|
87
|
+
return info
|
|
84
88
|
|
|
85
89
|
|
|
86
90
|
def _lead_agent(ctx: dict) -> str:
|
|
@@ -1444,6 +1448,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
|
|
|
1444
1448
|
"hostRuntime": ctx.get("HOST_RUNTIME", "") or _lead_runtime(ctx),
|
|
1445
1449
|
"leadRuntime": _lead_runtime(ctx),
|
|
1446
1450
|
"leadRuntimeRequest": ctx.get("LEAD_RUNTIME_REQUEST", "") or _lead_runtime(ctx),
|
|
1451
|
+
"terminalBackend": ctx.get("TERMINAL_BACKEND", ""),
|
|
1447
1452
|
"runtimeResolution": _runtime_resolution(ctx),
|
|
1448
1453
|
"leadAssignment": _lead_assignment(ctx),
|
|
1449
1454
|
"workerAssignments": _worker_assignments(ctx),
|
|
@@ -52,6 +52,7 @@ from okstra_ctl.final_report_schema import (
|
|
|
52
52
|
from okstra_ctl.i18n import I18nError, SUPPORTED_LANGS, load_dictionary, make_jinja_global
|
|
53
53
|
from okstra_ctl.md_table import UNESCAPED_PIPE_RE, to_cell_text
|
|
54
54
|
from okstra_ctl.models import UnknownModelError, resolve_model_metadata
|
|
55
|
+
from okstra_ctl.paths import find_asset_root
|
|
55
56
|
from okstra_ctl.report_contract import TASK_TYPE_DATA_PROPERTY, markdown_template_for
|
|
56
57
|
from okstra_ctl.report_markdown import ReportSections
|
|
57
58
|
from okstra_ctl.schema_excerpt import excerpt_cut_from_version
|
|
@@ -694,17 +695,9 @@ def find_default_template(start: Path | None = None) -> Path:
|
|
|
694
695
|
|
|
695
696
|
Raises ``FinalReportRenderError`` if neither path is present.
|
|
696
697
|
"""
|
|
697
|
-
|
|
698
|
-
if
|
|
699
|
-
|
|
700
|
-
if candidate.is_file():
|
|
701
|
-
return candidate
|
|
702
|
-
|
|
703
|
-
here = Path(start or __file__).resolve()
|
|
704
|
-
for parent in [here, *here.parents]:
|
|
705
|
-
candidate = parent.joinpath(*DEFAULT_TEMPLATE_REL)
|
|
706
|
-
if candidate.is_file():
|
|
707
|
-
return candidate
|
|
698
|
+
root = find_asset_root(DEFAULT_TEMPLATE_REL, start=start)
|
|
699
|
+
if root is not None:
|
|
700
|
+
return root.joinpath(*DEFAULT_TEMPLATE_REL)
|
|
708
701
|
|
|
709
702
|
raise FinalReportRenderError(
|
|
710
703
|
"could not locate final-report.template.md. Set OKSTRA_HOME or "
|
|
@@ -724,17 +717,9 @@ def find_default_template_for_data(
|
|
|
724
717
|
f"unsupported final-report schemaVersion: {version}"
|
|
725
718
|
) from exc
|
|
726
719
|
|
|
727
|
-
|
|
728
|
-
if
|
|
729
|
-
|
|
730
|
-
if candidate.is_file():
|
|
731
|
-
return candidate
|
|
732
|
-
|
|
733
|
-
here = Path(start or __file__).resolve()
|
|
734
|
-
for parent in [here, *here.parents]:
|
|
735
|
-
candidate = parent.joinpath(*relative_path)
|
|
736
|
-
if candidate.is_file():
|
|
737
|
-
return candidate
|
|
720
|
+
root = find_asset_root(relative_path, start=start)
|
|
721
|
+
if root is not None:
|
|
722
|
+
return root.joinpath(*relative_path)
|
|
738
723
|
|
|
739
724
|
raise FinalReportRenderError(
|
|
740
725
|
f"could not locate {relative_path[-1]}. Set OKSTRA_HOME or run from a "
|
|
@@ -24,7 +24,9 @@ from typing import Any, Iterator, Mapping, NamedTuple
|
|
|
24
24
|
# Values the renderer reads as text and nothing else.
|
|
25
25
|
PROSE_KEYS = frozenset({
|
|
26
26
|
"acceptance",
|
|
27
|
+
"addedWork",
|
|
27
28
|
"alternativesConsidered",
|
|
29
|
+
"answer",
|
|
28
30
|
"approach",
|
|
29
31
|
"approvalDisposition",
|
|
30
32
|
"approvalEvidence",
|
|
@@ -50,6 +52,7 @@ PROSE_KEYS = frozenset({
|
|
|
50
52
|
"declinedFixRecommendations",
|
|
51
53
|
"description",
|
|
52
54
|
"details",
|
|
55
|
+
"directionChange",
|
|
53
56
|
"disagreement",
|
|
54
57
|
"discrepancy",
|
|
55
58
|
"disproveWith",
|
|
@@ -236,6 +239,7 @@ STRUCTURAL_KEYS = frozenset({
|
|
|
236
239
|
"runManifest",
|
|
237
240
|
"runSeq",
|
|
238
241
|
"scope", # 'PF-001' alongside prose
|
|
242
|
+
"scopeImpact", # clarification reach tokens the gate matches on
|
|
239
243
|
"sections",
|
|
240
244
|
"shortSha",
|
|
241
245
|
"signature",
|
|
@@ -693,12 +693,16 @@ def _strip_leading_letter_label(text: str) -> str:
|
|
|
693
693
|
return re.sub(r"^\([a-z]\)\s*", "", text)
|
|
694
694
|
|
|
695
695
|
|
|
696
|
-
def
|
|
696
|
+
def parse_expected_form_options(expected_form: str) -> list[tuple[str, str]]:
|
|
697
697
|
"""Parse the ``Expected form`` contract format
|
|
698
698
|
(``Recommended: <answer> — <rationale>; Alternatives: <options>``,
|
|
699
699
|
`_common-contract.md` §Clarification request policy) into select
|
|
700
700
|
``(value, label)`` options. Returns ``[]`` when the cell carries no
|
|
701
|
-
``Recommended:`` cue — the caller falls back to the statement enum.
|
|
701
|
+
``Recommended:`` cue — the caller falls back to the statement enum.
|
|
702
|
+
|
|
703
|
+
The single parser for this cell. Both the HTML view and
|
|
704
|
+
``user_response.show_open_rows`` call it; a second implementation is
|
|
705
|
+
exactly how the two option boards drifted apart once already."""
|
|
702
706
|
if not expected_form:
|
|
703
707
|
return []
|
|
704
708
|
expected_form = _PICK_ONE_ANNOTATION.sub("", expected_form)
|
|
@@ -791,7 +795,7 @@ def _form_control(
|
|
|
791
795
|
# 계약(_common-contract.md §Clarification request policy)이 1순위,
|
|
792
796
|
# statement 안 (a)(b)(c) 열거가 fallback. 후보가 있으면 select+기타 input.
|
|
793
797
|
if kind_lc == "decision":
|
|
794
|
-
opts =
|
|
798
|
+
opts = parse_expected_form_options(expected_form)
|
|
795
799
|
if not opts:
|
|
796
800
|
opts = [
|
|
797
801
|
(letter, f"({letter}) {text}")
|