okstra 0.158.1 → 0.160.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 +1 -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/agents/workers/report-writer-worker.md +1 -1
- 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/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/report-writer.md +1 -1
- 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/codex_dispatch.py +6 -6
- package/runtime/python/okstra_ctl/convergence.py +168 -11
- package/runtime/python/okstra_ctl/dispatch_core.py +4 -2
- 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/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_final_report.py +11 -62
- package/runtime/python/okstra_ctl/report_html/filters.py +6 -1
- package/runtime/python/okstra_ctl/report_html/render.py +9 -8
- package/runtime/python/okstra_ctl/report_html/run_usage.py +110 -0
- package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +69 -16
- package/runtime/python/okstra_ctl/report_html/visualizations.py +107 -14
- 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 +41 -2
- package/runtime/python/okstra_ctl/run_audit.py +477 -0
- package/runtime/python/okstra_ctl/usage_cells.py +47 -0
- 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 +56 -2
- 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/assets/base.css +14 -1
- package/runtime/templates/reports/html/base.template.html +42 -0
- package/runtime/templates/reports/html/i18n/en.json +30 -1
- package/runtime/templates/reports/html/i18n/ko.json +30 -1
- package/runtime/templates/reports/html/macros/forms.html +15 -0
- package/runtime/templates/reports/html/macros/visualizations.html +3 -2
- 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 +331 -208
- package/runtime/validators/validate_session_conformance.py +102 -32
- 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
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""What the run cost, per agent — the Phase 7 usage cells as reader-facing text.
|
|
2
|
+
|
|
3
|
+
`executionStatus` is the only block that holds a duration per agent, so the
|
|
4
|
+
table is built from it rather than from `tokenUsage.workerDetails`, which
|
|
5
|
+
repeats the same token figures without saying how long each agent ran.
|
|
6
|
+
|
|
7
|
+
The formatting happens here rather than in the template because a null cell has
|
|
8
|
+
to read as "not measured" everywhere it appears, and `usage_cells` is where both
|
|
9
|
+
report views agree on how that looks.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from ..usage_cells import format_duration_ms, format_int, format_usd
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _number(value: object) -> int | float | None:
|
|
17
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
18
|
+
return None
|
|
19
|
+
return value
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _totals_row(row: object) -> dict[str, str]:
|
|
23
|
+
values = row if isinstance(row, dict) else {}
|
|
24
|
+
return {
|
|
25
|
+
"rawTokens": format_int(values.get("totalTokens")),
|
|
26
|
+
"billableTokens": format_int(values.get("billableTokens")),
|
|
27
|
+
"cost": format_usd(values.get("costUsd")),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _agent_row(row: dict) -> dict[str, str]:
|
|
32
|
+
"""One agent's identity and what it spent.
|
|
33
|
+
|
|
34
|
+
The CLI cells stay empty rather than `--` when the agent made no CLI call:
|
|
35
|
+
they render as a second line inside the token and cost cells, and a `--`
|
|
36
|
+
there would read as a missing measurement instead of an absent charge.
|
|
37
|
+
"""
|
|
38
|
+
cli_tokens = _number(row.get("cliTotalTokens")) or 0
|
|
39
|
+
cli_cost = _number(row.get("cliCostUsd")) or 0
|
|
40
|
+
return {
|
|
41
|
+
"agent": str(row.get("agent") or ""),
|
|
42
|
+
"role": str(row.get("role") or ""),
|
|
43
|
+
"model": str(row.get("model") or ""),
|
|
44
|
+
"status": str(row.get("status") or ""),
|
|
45
|
+
"rawTokens": format_int(row.get("totalTokens")),
|
|
46
|
+
"billableTokens": format_int(row.get("billableTokens")),
|
|
47
|
+
"cost": format_usd(row.get("costUsd")),
|
|
48
|
+
"duration": format_duration_ms(row.get("durationMs")),
|
|
49
|
+
"cliTokens": format_int(cli_tokens) if cli_tokens else "",
|
|
50
|
+
"cliCost": format_usd(cli_cost) if cli_cost else "",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _sum(rows: list[dict], key: str) -> int | float:
|
|
55
|
+
return sum(_number(row.get(key)) or 0 for row in rows)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _unaccounted(rows: list[dict], grand: dict) -> dict[str, str] | None:
|
|
59
|
+
"""The part of the total that no row above carries, when there is one.
|
|
60
|
+
|
|
61
|
+
Two documented paths leave a gap. Sessions that match no worker are summed
|
|
62
|
+
into `unattributedWorkerUsage`, and where two report rows share one
|
|
63
|
+
team-state aggregate only the first is attributed, leaving the second's
|
|
64
|
+
cells null. Both land in the total, so without this row the column adds up
|
|
65
|
+
to less than the figure beneath it and neither number can be trusted.
|
|
66
|
+
"""
|
|
67
|
+
grand_tokens = _number(grand.get("totalTokens"))
|
|
68
|
+
if grand_tokens is None:
|
|
69
|
+
return None
|
|
70
|
+
gap = grand_tokens - _sum(rows, "totalTokens")
|
|
71
|
+
if gap <= 0:
|
|
72
|
+
return None
|
|
73
|
+
billable_gap = (_number(grand.get("billableTokens")) or 0) - _sum(rows, "billableTokens")
|
|
74
|
+
cost_gap = (_number(grand.get("costUsd")) or 0) - _sum(rows, "costUsd")
|
|
75
|
+
return {
|
|
76
|
+
"rawTokens": format_int(gap),
|
|
77
|
+
"billableTokens": format_int(max(0, billable_gap)),
|
|
78
|
+
"cost": format_usd(max(0.0, cost_gap)),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_MEASURED_KEYS = ("totalTokens", "billableTokens", "costUsd", "durationMs")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def run_usage(data: dict) -> dict[str, object] | None:
|
|
86
|
+
"""The run-cost table, or nothing when the run has no measured figure.
|
|
87
|
+
|
|
88
|
+
Phase 7 fills these cells before the HTML is rendered, so an all-null table
|
|
89
|
+
means the collector found no session to read. A grid of `--` states nothing
|
|
90
|
+
the reader can act on, so the section stays out of the document entirely.
|
|
91
|
+
"""
|
|
92
|
+
rows = [row for row in (data.get("executionStatus") or []) if isinstance(row, dict)]
|
|
93
|
+
usage = data.get("tokenUsage") or {}
|
|
94
|
+
totals = {name: _totals_row(usage.get(name)) for name in ("lead", "worker", "grand")}
|
|
95
|
+
measured = any(
|
|
96
|
+
_number(row.get(key)) is not None for row in rows for key in _MEASURED_KEYS
|
|
97
|
+
) or any(
|
|
98
|
+
_number((usage.get(name) or {}).get(key)) is not None
|
|
99
|
+
for name in ("lead", "worker", "grand")
|
|
100
|
+
for key in _MEASURED_KEYS
|
|
101
|
+
)
|
|
102
|
+
if not measured:
|
|
103
|
+
return None
|
|
104
|
+
cli_cost = _number((usage.get("cli") or {}).get("costUsd")) or 0
|
|
105
|
+
return {
|
|
106
|
+
"rows": [_agent_row(row) for row in rows],
|
|
107
|
+
"unaccounted": _unaccounted(rows, usage.get("grand") or {}),
|
|
108
|
+
**totals,
|
|
109
|
+
"cliCost": format_usd(cli_cost) if cli_cost else "",
|
|
110
|
+
}
|
|
@@ -1,32 +1,85 @@
|
|
|
1
1
|
"""Human-first error-analysis view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import re
|
|
5
|
+
|
|
4
6
|
from ..common import evidence_index
|
|
5
7
|
from ..models import HumanReportView, VisualEdge, VisualNode
|
|
6
8
|
from ..visualizations import cause_graph_figure
|
|
7
9
|
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
_LEAD_CLAUSE = re.compile(r"\s[—–-]\s|(?<=[.。!?])\s")
|
|
12
|
+
_MAX_LABEL_CHARS = 44
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _short_label(statement: str) -> str:
|
|
16
|
+
"""The candidate's name, taken from the opening clause of its statement.
|
|
17
|
+
|
|
18
|
+
`causeCandidates` carries no title field: `statement` is the full claim,
|
|
19
|
+
routinely several sentences long. Passed through whole it filled the
|
|
20
|
+
figure's name cell with prose and ran the node text out of the drawing, so
|
|
21
|
+
the figure takes the lead clause and leaves the claim to the cards below.
|
|
22
|
+
"""
|
|
23
|
+
head = _LEAD_CLAUSE.split(statement.strip(), maxsplit=1)[0].strip()
|
|
24
|
+
if len(head) <= _MAX_LABEL_CHARS:
|
|
25
|
+
return head
|
|
26
|
+
return head[:_MAX_LABEL_CHARS].rstrip() + "…"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _cause_node(row: dict, leading_cause_id: str) -> VisualNode:
|
|
30
|
+
# The figure names its nodes and nothing more. Both the failure sentence
|
|
31
|
+
# and each candidate's disproof already have a section of their own, and
|
|
32
|
+
# repeating them here printed every one of them twice.
|
|
33
|
+
leading = row["id"] == leading_cause_id
|
|
34
|
+
return VisualNode(
|
|
35
|
+
row["id"],
|
|
36
|
+
_short_label(row["statement"]),
|
|
37
|
+
"candidate",
|
|
38
|
+
"leading" if leading else row["confidence"],
|
|
39
|
+
"",
|
|
40
|
+
note="Leading cause" if leading else f'Confidence {row["confidence"]}',
|
|
12
41
|
)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _cause_edges(rows: list[dict], symptom_id: str) -> tuple[VisualEdge, ...]:
|
|
45
|
+
"""The chain between candidates, then the symptom each chain ends at.
|
|
46
|
+
|
|
47
|
+
Candidates are not always competing guesses for one spot. A propagation
|
|
48
|
+
chain — the extraction breaks, the error is swallowed, the empty result is
|
|
49
|
+
stamped a success — needs every link to hold for the symptom to appear, and
|
|
50
|
+
`downstreamOf` is where the diagnosis says so. Drawing every candidate
|
|
51
|
+
straight at the symptom instead claimed they were alternatives.
|
|
52
|
+
|
|
53
|
+
Only a candidate nothing else is downstream of reaches the symptom: an
|
|
54
|
+
upstream link would otherwise be drawn as its own explanation of the
|
|
55
|
+
symptom as well as a step on the way there.
|
|
56
|
+
"""
|
|
57
|
+
known = {row["id"] for row in rows}
|
|
58
|
+
upstream = {
|
|
59
|
+
row["id"]: [step for step in row.get("downstreamOf", []) if step in known]
|
|
60
|
+
for row in rows
|
|
61
|
+
}
|
|
62
|
+
has_downstream = {step for steps in upstream.values() for step in steps}
|
|
63
|
+
chain = tuple(
|
|
64
|
+
VisualEdge(step, row["id"], "then", "chain")
|
|
65
|
+
for row in rows
|
|
66
|
+
for step in upstream[row["id"]]
|
|
23
67
|
)
|
|
24
|
-
|
|
25
|
-
VisualEdge(row
|
|
68
|
+
return chain + tuple(
|
|
69
|
+
VisualEdge(row["id"], symptom_id, "may cause", "hypothesis")
|
|
70
|
+
for row in rows
|
|
71
|
+
if row["id"] not in has_downstream
|
|
26
72
|
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _cause_figure(error: dict):
|
|
76
|
+
symptom = VisualNode("symptom", "Observed failure", "effect", "risk", "", note="Symptom")
|
|
77
|
+
rows = error.get("causeCandidates", [])
|
|
78
|
+
leading_cause_id = (error.get("routing") or {}).get("leadingCauseId") or ""
|
|
79
|
+
causes = tuple(_cause_node(row, leading_cause_id) for row in rows)
|
|
27
80
|
return cause_graph_figure(
|
|
28
81
|
nodes=(symptom, *causes),
|
|
29
|
-
edges=
|
|
82
|
+
edges=_cause_edges(rows, symptom.id),
|
|
30
83
|
title="Cause hypotheses and observed symptom",
|
|
31
84
|
)
|
|
32
85
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import html
|
|
5
|
+
import unicodedata
|
|
5
6
|
from dataclasses import replace
|
|
6
7
|
from typing import Iterable, Sequence
|
|
7
8
|
|
|
@@ -11,6 +12,11 @@ from .models import FigureModel, VisualEdge, VisualNode
|
|
|
11
12
|
_ROWS_PER_COLUMN = 8
|
|
12
13
|
_COLUMN_WIDTH = 260
|
|
13
14
|
_ROW_HEIGHT = 100
|
|
15
|
+
_NODE_WIDTH = 180
|
|
16
|
+
_NODE_MIN_HEIGHT = 50
|
|
17
|
+
_LABEL_PADDING = 12
|
|
18
|
+
_LINE_HEIGHT = 16
|
|
19
|
+
_MAX_LABEL_LINES = 3
|
|
14
20
|
|
|
15
21
|
|
|
16
22
|
def _group_columns(nodes: Sequence[VisualNode]) -> list[list[str]]:
|
|
@@ -141,10 +147,103 @@ _ARROW_MARKER = (
|
|
|
141
147
|
)
|
|
142
148
|
|
|
143
149
|
|
|
150
|
+
def _drawable_text(value: str) -> str:
|
|
151
|
+
"""Strip the markdown code fences a drawing cannot render.
|
|
152
|
+
|
|
153
|
+
Labels carry backticks around identifiers because every other text surface
|
|
154
|
+
turns them into `<code>`. Drawn literally they are stray characters inside
|
|
155
|
+
the box, and they consume width the label needs.
|
|
156
|
+
"""
|
|
157
|
+
return value.replace("`", "")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _text_width(text: str) -> float:
|
|
161
|
+
"""Approximate the advance width of the 13px label font.
|
|
162
|
+
|
|
163
|
+
An SVG carries no font metrics, so wrapping has to estimate. East-Asian
|
|
164
|
+
characters occupy a full em; the rest average a little over half of one.
|
|
165
|
+
"""
|
|
166
|
+
return sum(13.0 if unicodedata.east_asian_width(c) in ("W", "F") else 6.8 for c in text)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _split_oversized_word(word: str, limit: float) -> list[str]:
|
|
170
|
+
"""Cut a word wider than the box into box-width pieces.
|
|
171
|
+
|
|
172
|
+
Korean and Japanese labels arrive as long unbroken runs, and a path or an
|
|
173
|
+
identifier can be wider than the box on its own.
|
|
174
|
+
"""
|
|
175
|
+
pieces: list[str] = []
|
|
176
|
+
current = ""
|
|
177
|
+
for char in word:
|
|
178
|
+
if current and _text_width(current + char) > limit:
|
|
179
|
+
pieces.append(current)
|
|
180
|
+
current = char
|
|
181
|
+
else:
|
|
182
|
+
current += char
|
|
183
|
+
return pieces + [current] if current else pieces
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _ellipsize(line: str, limit: float) -> str:
|
|
187
|
+
while line and _text_width(line + "…") > limit:
|
|
188
|
+
line = line[:-1]
|
|
189
|
+
return line + "…"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _wrap_label(label: str) -> list[str]:
|
|
193
|
+
"""Break a label into the lines that fit inside one node box.
|
|
194
|
+
|
|
195
|
+
The label used to be drawn as a single line whatever its length, so a node
|
|
196
|
+
carrying a sentence painted it straight out of the box and off the canvas.
|
|
197
|
+
Text past the last line is cut here rather than drawn, because the
|
|
198
|
+
figure's fallback table prints the label in full.
|
|
199
|
+
"""
|
|
200
|
+
limit = _NODE_WIDTH - 2 * _LABEL_PADDING
|
|
201
|
+
lines: list[str] = []
|
|
202
|
+
current = ""
|
|
203
|
+
for word in label.split():
|
|
204
|
+
pieces = _split_oversized_word(word, limit) if _text_width(word) > limit else [word]
|
|
205
|
+
for piece in pieces:
|
|
206
|
+
candidate = f"{current} {piece}".strip()
|
|
207
|
+
if current and _text_width(candidate) > limit:
|
|
208
|
+
lines.append(current)
|
|
209
|
+
current = piece
|
|
210
|
+
else:
|
|
211
|
+
current = candidate
|
|
212
|
+
if current:
|
|
213
|
+
lines.append(current)
|
|
214
|
+
if len(lines) > _MAX_LABEL_LINES:
|
|
215
|
+
lines = lines[:_MAX_LABEL_LINES]
|
|
216
|
+
lines[-1] = _ellipsize(lines[-1], limit)
|
|
217
|
+
return lines or [""]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _node_group(node: VisualNode, x: int, y: int, lines: Sequence[str], box_height: int) -> str:
|
|
221
|
+
baseline = y + (box_height - len(lines) * _LINE_HEIGHT) // 2 + _LINE_HEIGHT - 4
|
|
222
|
+
spans = "".join(
|
|
223
|
+
f'<tspan x="{x + _LABEL_PADDING}" y="{baseline + index * _LINE_HEIGHT}">'
|
|
224
|
+
f"{html.escape(line)}</tspan>"
|
|
225
|
+
for index, line in enumerate(lines)
|
|
226
|
+
)
|
|
227
|
+
status = html.escape(node.status)
|
|
228
|
+
detail = _drawable_text(node.detail)
|
|
229
|
+
tooltip = f"{_drawable_text(node.label)}: {node.status}." + (f" {detail}" if detail else "")
|
|
230
|
+
return (
|
|
231
|
+
f'<g data-node-id="{html.escape(node.id)}" class="node node-{status}">'
|
|
232
|
+
f"<title>{html.escape(tooltip)}</title>"
|
|
233
|
+
f'<rect x="{x}" y="{y}" width="{_NODE_WIDTH}" height="{box_height}" rx="8"/>'
|
|
234
|
+
f"<text>{spans}</text></g>"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
144
238
|
def _svg_document(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> str:
|
|
145
239
|
positions = _node_positions(nodes, edges)
|
|
146
|
-
|
|
147
|
-
|
|
240
|
+
wrapped = {node.id: _wrap_label(_drawable_text(node.label)) for node in nodes}
|
|
241
|
+
line_count = max((len(lines) for lines in wrapped.values()), default=1)
|
|
242
|
+
# One height for every box: a figure whose rows are all the same depth
|
|
243
|
+
# keeps the arrow between two columns horizontal.
|
|
244
|
+
box_height = max(_NODE_MIN_HEIGHT, line_count * _LINE_HEIGHT + 2 * _LABEL_PADDING)
|
|
245
|
+
width = max((x for x, _ in positions.values()), default=50) + _NODE_WIDTH + 50
|
|
246
|
+
height = max((y for _, y in positions.values()), default=55) + box_height + 40
|
|
148
247
|
parts = [f'<svg viewBox="0 0 {width} {height}" role="img" xmlns="http://www.w3.org/2000/svg">']
|
|
149
248
|
parts.append(_ARROW_MARKER)
|
|
150
249
|
for edge in edges:
|
|
@@ -154,8 +253,11 @@ def _svg_document(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> s
|
|
|
154
253
|
x2, y2 = positions[edge.target]
|
|
155
254
|
# Leave the source box on its right edge and arrive on the target's
|
|
156
255
|
# left, so a left-to-right layering reads as one direction of travel.
|
|
157
|
-
|
|
158
|
-
|
|
256
|
+
forward = (x1 + _NODE_WIDTH, x2)
|
|
257
|
+
backward = (x1, x2 + _NODE_WIDTH)
|
|
258
|
+
same_column = (x1 + _NODE_WIDTH // 2, x2 + _NODE_WIDTH // 2)
|
|
259
|
+
start_x, end_x = forward if x2 > x1 else (backward if x2 < x1 else same_column)
|
|
260
|
+
start_y, end_y = y1 + box_height // 2, y2 + box_height // 2
|
|
159
261
|
span = abs(x2 - x1) // _COLUMN_WIDTH
|
|
160
262
|
if span > 1:
|
|
161
263
|
# An edge that skips a column would otherwise be drawn straight
|
|
@@ -177,16 +279,7 @@ def _svg_document(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> s
|
|
|
177
279
|
)
|
|
178
280
|
for node in nodes:
|
|
179
281
|
x, y = positions[node.id]
|
|
180
|
-
|
|
181
|
-
label = html.escape(node.label)
|
|
182
|
-
status = html.escape(node.status)
|
|
183
|
-
detail = html.escape(node.detail)
|
|
184
|
-
parts.append(
|
|
185
|
-
f'<g data-node-id="{node_id}" class="node node-{status}">'
|
|
186
|
-
f"<title>{label}: {status}. {detail}</title>"
|
|
187
|
-
f'<rect x="{x}" y="{y}" width="180" height="50" rx="8"/>'
|
|
188
|
-
f'<text x="{x + 12}" y="{y + 30}">{label}</text></g>'
|
|
189
|
-
)
|
|
282
|
+
parts.append(_node_group(node, x, y, wrapped[node.id], box_height))
|
|
190
283
|
parts.append("</svg>")
|
|
191
284
|
return "".join(parts)
|
|
192
285
|
|
|
@@ -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}")
|
|
@@ -45,6 +45,7 @@ from .clarification_items import (
|
|
|
45
45
|
clarification_response_with_sidecars,
|
|
46
46
|
scan_approval_gate,
|
|
47
47
|
)
|
|
48
|
+
from .error_report import prior_run_error_digest
|
|
48
49
|
from .qa_commands import format_errors as _format_qa_errors, validate_qa_commands
|
|
49
50
|
from .material import (
|
|
50
51
|
build_analysis_material,
|
|
@@ -857,6 +858,10 @@ class _ResolvedAssets:
|
|
|
857
858
|
lead_contract: Path
|
|
858
859
|
run_validator: Path
|
|
859
860
|
brief_validator: Path
|
|
861
|
+
# None when this task-type's host orchestration carries no gates; see
|
|
862
|
+
# prompts/host-orchestration/README.md. Absence is a normal state, not a
|
|
863
|
+
# broken install, so it is exempt from the _INSTALL_HINT contract above.
|
|
864
|
+
host_rules_file: Path | None
|
|
860
865
|
|
|
861
866
|
|
|
862
867
|
def _resolve_runtime_assets(workspace_root: Path, inp: PrepareInputs) -> _ResolvedAssets:
|
|
@@ -892,6 +897,9 @@ def _resolve_runtime_assets(workspace_root: Path, inp: PrepareInputs) -> _Resolv
|
|
|
892
897
|
raise PrepareError(
|
|
893
898
|
f"required okstra template or lead contract missing: {required}.{_INSTALL_HINT}"
|
|
894
899
|
)
|
|
900
|
+
host_rules_file = (
|
|
901
|
+
workspace_root / "prompts" / "host-orchestration" / f"{inp.task_type}.md"
|
|
902
|
+
)
|
|
895
903
|
return _ResolvedAssets(
|
|
896
904
|
profile_file=profile_file,
|
|
897
905
|
prompt_template=prompt_template,
|
|
@@ -900,6 +908,7 @@ def _resolve_runtime_assets(workspace_root: Path, inp: PrepareInputs) -> _Resolv
|
|
|
900
908
|
lead_contract=lead_contract,
|
|
901
909
|
run_validator=run_validator,
|
|
902
910
|
brief_validator=brief_validator,
|
|
911
|
+
host_rules_file=host_rules_file if host_rules_file.is_file() else None,
|
|
903
912
|
)
|
|
904
913
|
|
|
905
914
|
|
|
@@ -1766,8 +1775,24 @@ def _write_verification_target_artifact(
|
|
|
1766
1775
|
ctx["VERIFICATION_TARGET"] = target_path.read_text(encoding="utf-8")
|
|
1767
1776
|
|
|
1768
1777
|
|
|
1778
|
+
def _write_prior_run_error_digest(ctx: dict, instruction_set: Path) -> None:
|
|
1779
|
+
"""Stage the earlier runs' actionable errors — only when there are any.
|
|
1780
|
+
|
|
1781
|
+
No file at all when the task recorded none, rather than one saying "none":
|
|
1782
|
+
a staged file the lead must open to learn it is empty costs a read every
|
|
1783
|
+
run and teaches the lead to stop opening that path.
|
|
1784
|
+
"""
|
|
1785
|
+
digest = prior_run_error_digest(Path(ctx["TASK_ROOT"]))
|
|
1786
|
+
if digest:
|
|
1787
|
+
(instruction_set / "prior-run-errors.md").write_text(digest, encoding="utf-8")
|
|
1788
|
+
|
|
1789
|
+
|
|
1769
1790
|
def _write_instruction_set_sources(
|
|
1770
|
-
inp: PrepareInputs,
|
|
1791
|
+
inp: PrepareInputs,
|
|
1792
|
+
ctx: dict,
|
|
1793
|
+
profile_content: str,
|
|
1794
|
+
review_material: str,
|
|
1795
|
+
host_rules_file: Path | None,
|
|
1771
1796
|
) -> Path:
|
|
1772
1797
|
"""instruction-set 디렉터리에 profile/material/brief/clarification/directive 와
|
|
1773
1798
|
reference-expectations 를 기록하고 디렉터리 경로를 돌려준다."""
|
|
@@ -1775,6 +1800,7 @@ def _write_instruction_set_sources(
|
|
|
1775
1800
|
instruction_set.mkdir(parents=True, exist_ok=True)
|
|
1776
1801
|
_write_analysis_evidence_artifact(ctx, instruction_set)
|
|
1777
1802
|
_write_verification_target_artifact(inp, ctx, instruction_set)
|
|
1803
|
+
_write_prior_run_error_digest(ctx, instruction_set)
|
|
1778
1804
|
profile_rendered = profile_content
|
|
1779
1805
|
if inp.task_type == "implementation":
|
|
1780
1806
|
profile_rendered += "\n\n{{DESIGN_PREP_CONTEXT}}\n\n{{FIX_RUN_CONTEXT}}"
|
|
@@ -1814,6 +1840,19 @@ def _write_instruction_set_sources(
|
|
|
1814
1840
|
)
|
|
1815
1841
|
(instruction_set / "analysis-material.md").write_text(review_material, encoding="utf-8")
|
|
1816
1842
|
shutil.copyfile(inp.brief_path, instruction_set / "task-brief.md")
|
|
1843
|
+
# A rule that lives only in the conversation is one compaction away from
|
|
1844
|
+
# gone, with no signal that it went. On disk it survives, and the session
|
|
1845
|
+
# conformance check can ask afterwards whether it was read. The token is
|
|
1846
|
+
# set unconditionally because render_template_with_ctx fail-fasts on a
|
|
1847
|
+
# token the launch template references but ctx lacks.
|
|
1848
|
+
host_rules_relative = ""
|
|
1849
|
+
if host_rules_file is not None:
|
|
1850
|
+
staged_host_rules = instruction_set / "host-orchestration-rules.md"
|
|
1851
|
+
shutil.copyfile(host_rules_file, staged_host_rules)
|
|
1852
|
+
host_rules_relative = relative_to_project_root(
|
|
1853
|
+
staged_host_rules, Path(ctx["PROJECT_ROOT"])
|
|
1854
|
+
)
|
|
1855
|
+
ctx["HOST_ORCHESTRATION_RULES_RELATIVE_PATH"] = host_rules_relative
|
|
1817
1856
|
if inp.clarification_response_path:
|
|
1818
1857
|
(instruction_set / "clarification-response.md").write_text(
|
|
1819
1858
|
clarification_response_with_sidecars(Path(inp.clarification_response_path)),
|
|
@@ -2556,7 +2595,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2556
2595
|
|
|
2557
2596
|
# ---- write instruction-set scaffolding + lead prompt ----
|
|
2558
2597
|
instruction_set = _write_instruction_set_sources(
|
|
2559
|
-
inp, ctx, profile_content, review_material
|
|
2598
|
+
inp, ctx, profile_content, review_material, assets.host_rules_file
|
|
2560
2599
|
)
|
|
2561
2600
|
prompt_text = _render_lead_prompt_and_snapshot(
|
|
2562
2601
|
inp, ctx, instruction_set, final_report_template, prompt_template
|