okstra 0.171.0 → 0.173.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 +8 -6
- package/docs/architecture/storage-model.md +11 -0
- package/docs/architecture.md +29 -14
- package/docs/cli.md +40 -7
- package/docs/for-ai/skills/okstra-user-response.md +2 -2
- package/docs/performance-improvement-plan-v2.md +6 -5
- package/docs/project-structure-overview.md +24 -14
- package/docs/task-process/README.md +5 -3
- package/docs/task-process/error-analysis.md +2 -2
- package/docs/task-process/final-verification.md +2 -2
- package/docs/task-process/implementation-option-selection.md +70 -0
- package/docs/task-process/implementation-planning.md +23 -15
- package/docs/task-process/requirements-discovery.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +30 -6
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/lib/okstra/globals.sh +1 -0
- package/runtime/bin/lib/okstra/usage.sh +3 -0
- package/runtime/bin/okstra.sh +2 -0
- package/runtime/prompts/duties/direction-selection-worker.md +44 -0
- package/runtime/prompts/duties/planning-worker.md +12 -4
- package/runtime/prompts/launch.template.md +4 -0
- package/runtime/prompts/lead/adapters/cmux.md +1 -1
- package/runtime/prompts/lead/context-loader.md +1 -1
- package/runtime/prompts/lead/convergence.md +5 -5
- package/runtime/prompts/lead/okstra-lead-contract.md +42 -17
- package/runtime/prompts/lead/plan-body-verification.md +42 -14
- package/runtime/prompts/lead/report-writer.md +38 -15
- package/runtime/prompts/lead/team-contract.md +2 -0
- package/runtime/prompts/profiles/_clarification-recommendation.md +3 -1
- package/runtime/prompts/profiles/_common-contract.md +3 -2
- package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
- package/runtime/prompts/profiles/error-analysis.md +3 -3
- package/runtime/prompts/profiles/final-verification.md +3 -3
- package/runtime/prompts/profiles/forbidden-actions.json +7 -0
- package/runtime/prompts/profiles/implementation-option-selection.md +35 -0
- package/runtime/prompts/profiles/implementation-planning.md +56 -37
- package/runtime/prompts/profiles/implementation.md +2 -1
- package/runtime/prompts/profiles/improvement-discovery.md +1 -1
- package/runtime/prompts/profiles/requirements-discovery.md +3 -3
- package/runtime/prompts/wizard/prompts.ko.json +9 -1
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +1 -1
- package/runtime/python/okstra_ctl/agent_activity.py +306 -0
- package/runtime/python/okstra_ctl/agent_invocation.py +1 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +6 -0
- package/runtime/python/okstra_ctl/clarification_items.py +37 -20
- package/runtime/python/okstra_ctl/exact_coverage.py +128 -0
- package/runtime/python/okstra_ctl/fix_cycles.py +3 -1
- package/runtime/python/okstra_ctl/implementation_direction.py +836 -0
- package/runtime/python/okstra_ctl/implementation_options.py +479 -0
- package/runtime/python/okstra_ctl/lead_events.py +47 -4
- package/runtime/python/okstra_ctl/plan_items.py +51 -3
- package/runtime/python/okstra_ctl/render.py +12 -3
- package/runtime/python/okstra_ctl/render_final_report.py +1 -0
- package/runtime/python/okstra_ctl/report_contract.py +45 -13
- package/runtime/python/okstra_ctl/report_finalize.py +51 -14
- package/runtime/python/okstra_ctl/report_html/common.py +5 -3
- package/runtime/python/okstra_ctl/report_html/render.py +4 -2
- package/runtime/python/okstra_ctl/report_html/router.py +4 -0
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +32 -0
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +42 -11
- package/runtime/python/okstra_ctl/report_translation.py +14 -0
- package/runtime/python/okstra_ctl/report_views.py +148 -12
- package/runtime/python/okstra_ctl/run.py +350 -2
- package/runtime/python/okstra_ctl/scope_provenance.py +15 -9
- package/runtime/python/okstra_ctl/user_response.py +75 -0
- package/runtime/python/okstra_ctl/wizard.py +144 -0
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +150 -0
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
- package/runtime/python/okstra_ctl/workflow.py +29 -7
- package/runtime/schemas/final-report-v2.0.schema.json +1623 -143
- package/runtime/skills/okstra-user-response/SKILL.md +2 -2
- package/runtime/templates/reports/final-report-v2.template.md +12 -0
- package/runtime/templates/reports/final-verification-input.template.md +1 -1
- package/runtime/templates/reports/html/assets/base.css +7 -0
- package/runtime/templates/reports/html/base.template.html +3 -2
- package/runtime/templates/reports/html/i18n/en.json +27 -2
- package/runtime/templates/reports/html/i18n/ko.json +27 -2
- package/runtime/templates/reports/html/macros/forms.html +42 -4
- package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +49 -0
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +61 -2
- package/runtime/templates/reports/i18n/en.json +17 -0
- package/runtime/templates/reports/implementation-input.template.md +4 -2
- package/runtime/templates/reports/implementation-planning-input.template.md +18 -4
- package/runtime/templates/reports/improvement-discovery-input.template.md +1 -1
- package/runtime/templates/reports/md/tasks/implementation-option-selection.template.md +13 -0
- package/runtime/templates/reports/md/tasks/implementation-planning.template.md +17 -0
- package/runtime/templates/reports/report.js +137 -21
- package/runtime/templates/reports/task-brief.template.md +9 -3
- package/runtime/templates/reports/user-response.template.md +28 -5
- package/runtime/templates/worker-prompt-preamble.md +16 -0
- package/runtime/validators/validate-implementation-plan-stages.py +106 -1
- package/runtime/validators/validate-report-views.py +2 -2
- package/runtime/validators/validate-run.py +1124 -54
- package/runtime/validators/validate_improvement_report.py +5 -1
- package/runtime/validators/validate_session_conformance.py +523 -35
- package/src/cli-registry.mjs +7 -0
- package/src/commands/execute/codex-run.mjs +1 -0
- package/src/commands/execute/render-bundle.mjs +1 -0
- package/src/commands/report/agent-activity.mjs +21 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Record lead activity and project it into final-report data."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from okstra_ctl.lead_events import (
|
|
16
|
+
LeadEvent,
|
|
17
|
+
append_activity_event,
|
|
18
|
+
read_lead_events,
|
|
19
|
+
)
|
|
20
|
+
from okstra_ctl.worker_audit_ledger import (
|
|
21
|
+
EvidenceCommand,
|
|
22
|
+
parse_evidence_commands,
|
|
23
|
+
read_evidence_commands,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
ACTIVITY_KINDS = frozenset({
|
|
28
|
+
"worker-dispatched",
|
|
29
|
+
"worker-completed",
|
|
30
|
+
"verification-round-completed",
|
|
31
|
+
"self-fix-applied",
|
|
32
|
+
"user-decision-required",
|
|
33
|
+
"user-decision-evaluated",
|
|
34
|
+
})
|
|
35
|
+
ACTIVITY_OUTCOMES = frozenset({
|
|
36
|
+
"pending",
|
|
37
|
+
"completed",
|
|
38
|
+
"failed",
|
|
39
|
+
"blocked",
|
|
40
|
+
"resolved",
|
|
41
|
+
})
|
|
42
|
+
ACTIVITY_FIELDS = (
|
|
43
|
+
"activityId",
|
|
44
|
+
"kind",
|
|
45
|
+
"agent",
|
|
46
|
+
"summary",
|
|
47
|
+
"planItemIds",
|
|
48
|
+
"resultPath",
|
|
49
|
+
"commands",
|
|
50
|
+
"evidenceRefs",
|
|
51
|
+
"outcome",
|
|
52
|
+
)
|
|
53
|
+
_ACTIVITY_ID_RE = re.compile(r"^A-(\d{3,})$")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ActivityProjectionError(ValueError):
|
|
57
|
+
"""Raised when activity cannot be recorded or projected safely."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _read_json_object(path: Path) -> dict[str, Any]:
|
|
61
|
+
try:
|
|
62
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
63
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
64
|
+
raise ActivityProjectionError(f"cannot read JSON object {path}: {exc}") from exc
|
|
65
|
+
if not isinstance(payload, dict):
|
|
66
|
+
raise ActivityProjectionError(f"JSON value must be an object: {path}")
|
|
67
|
+
return payload
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _resolve_project_path(project_root: Path, value: str | Path) -> Path:
|
|
71
|
+
path = Path(value)
|
|
72
|
+
return path if path.is_absolute() else project_root / path
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _require_string(payload: Mapping[str, Any], key: str) -> str:
|
|
76
|
+
value = payload.get(key)
|
|
77
|
+
if not isinstance(value, str) or not value.strip():
|
|
78
|
+
raise ActivityProjectionError(f"required string missing: {key}")
|
|
79
|
+
return value.strip()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _manifest_run_seq(manifest: Mapping[str, Any]) -> str:
|
|
83
|
+
sequences = manifest.get("runSequencesByCategory")
|
|
84
|
+
if not isinstance(sequences, Mapping):
|
|
85
|
+
raise ActivityProjectionError(
|
|
86
|
+
"run manifest has no runSequencesByCategory object"
|
|
87
|
+
)
|
|
88
|
+
return _require_string(sequences, "manifests")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _utc_now() -> str:
|
|
92
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _activity_row(event: LeadEvent) -> dict[str, Any]:
|
|
96
|
+
details = dict(event.details)
|
|
97
|
+
missing = [key for key in ACTIVITY_FIELDS if key not in details]
|
|
98
|
+
if missing:
|
|
99
|
+
raise ActivityProjectionError(
|
|
100
|
+
"activity event is missing " + ", ".join(missing)
|
|
101
|
+
)
|
|
102
|
+
if details["kind"] not in ACTIVITY_KINDS:
|
|
103
|
+
raise ActivityProjectionError(
|
|
104
|
+
f"unsupported activity kind: {details['kind']}"
|
|
105
|
+
)
|
|
106
|
+
if details["outcome"] not in ACTIVITY_OUTCOMES:
|
|
107
|
+
raise ActivityProjectionError(
|
|
108
|
+
f"unsupported activity outcome: {details['outcome']}"
|
|
109
|
+
)
|
|
110
|
+
activity_id = details["activityId"]
|
|
111
|
+
if (
|
|
112
|
+
not isinstance(activity_id, str)
|
|
113
|
+
or _ACTIVITY_ID_RE.fullmatch(activity_id) is None
|
|
114
|
+
):
|
|
115
|
+
raise ActivityProjectionError(f"invalid activityId: {activity_id}")
|
|
116
|
+
return {key: details[key] for key in ACTIVITY_FIELDS}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _validate_activity_order(rows: Sequence[Mapping[str, Any]]) -> None:
|
|
120
|
+
ids = [str(row["activityId"]) for row in rows]
|
|
121
|
+
if len(ids) != len(set(ids)):
|
|
122
|
+
raise ActivityProjectionError("activityId values must be unique")
|
|
123
|
+
numbers = [int(_ACTIVITY_ID_RE.fullmatch(value).group(1)) for value in ids]
|
|
124
|
+
if any(current <= previous for previous, current in zip(numbers, numbers[1:])):
|
|
125
|
+
raise ActivityProjectionError(
|
|
126
|
+
"activity events must be in strict activityId order"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _atomic_write_json(path: Path, payload: Mapping[str, Any]) -> None:
|
|
131
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
temporary_path: Path | None = None
|
|
133
|
+
try:
|
|
134
|
+
with tempfile.NamedTemporaryFile(
|
|
135
|
+
mode="w",
|
|
136
|
+
encoding="utf-8",
|
|
137
|
+
dir=path.parent,
|
|
138
|
+
prefix=f".{path.name}.",
|
|
139
|
+
suffix=".tmp",
|
|
140
|
+
delete=False,
|
|
141
|
+
) as handle:
|
|
142
|
+
temporary_path = Path(handle.name)
|
|
143
|
+
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
|
144
|
+
handle.write("\n")
|
|
145
|
+
os.replace(temporary_path, path)
|
|
146
|
+
finally:
|
|
147
|
+
if temporary_path is not None and temporary_path.exists():
|
|
148
|
+
temporary_path.unlink()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def record_activity(
|
|
152
|
+
project_root: Path,
|
|
153
|
+
run_manifest_path: Path,
|
|
154
|
+
details: Mapping[str, Any],
|
|
155
|
+
timestamp: str | None = None,
|
|
156
|
+
) -> LeadEvent:
|
|
157
|
+
"""Append one activity event using identity from its run manifest."""
|
|
158
|
+
manifest = _read_json_object(run_manifest_path)
|
|
159
|
+
if manifest.get("activityContractVersion") != 1:
|
|
160
|
+
raise ActivityProjectionError(
|
|
161
|
+
"run manifest does not declare activityContractVersion 1"
|
|
162
|
+
)
|
|
163
|
+
event = LeadEvent(
|
|
164
|
+
event_type="activity",
|
|
165
|
+
lead_runtime=_require_string(manifest, "leadRuntime"),
|
|
166
|
+
task_key=_require_string(manifest, "taskKey"),
|
|
167
|
+
task_type=_require_string(manifest, "taskType"),
|
|
168
|
+
run_seq=_manifest_run_seq(manifest),
|
|
169
|
+
timestamp=timestamp or _utc_now(),
|
|
170
|
+
details=dict(details),
|
|
171
|
+
)
|
|
172
|
+
events_path = _resolve_project_path(
|
|
173
|
+
project_root, _require_string(manifest, "leadEventsPath")
|
|
174
|
+
)
|
|
175
|
+
return append_activity_event(events_path, event)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def project_agent_activity(
|
|
179
|
+
project_root: Path,
|
|
180
|
+
run_manifest_path: Path,
|
|
181
|
+
data_path: Path,
|
|
182
|
+
) -> tuple[dict[str, Any], ...]:
|
|
183
|
+
"""Replace only ``agentActivity`` with this run's canonical events."""
|
|
184
|
+
manifest = _read_json_object(run_manifest_path)
|
|
185
|
+
if manifest.get("activityContractVersion") != 1:
|
|
186
|
+
return ()
|
|
187
|
+
events_path = _resolve_project_path(
|
|
188
|
+
project_root, _require_string(manifest, "leadEventsPath")
|
|
189
|
+
)
|
|
190
|
+
run_seq = _manifest_run_seq(manifest)
|
|
191
|
+
task_key = _require_string(manifest, "taskKey")
|
|
192
|
+
task_type = _require_string(manifest, "taskType")
|
|
193
|
+
events = (
|
|
194
|
+
event
|
|
195
|
+
for event in read_lead_events(events_path)
|
|
196
|
+
if event.event_type == "activity"
|
|
197
|
+
and event.task_key == task_key
|
|
198
|
+
and event.task_type == task_type
|
|
199
|
+
and event.run_seq == run_seq
|
|
200
|
+
)
|
|
201
|
+
rows = tuple(_activity_row(event) for event in events)
|
|
202
|
+
_validate_activity_order(rows)
|
|
203
|
+
data = _read_json_object(data_path)
|
|
204
|
+
data["agentActivity"] = list(rows)
|
|
205
|
+
_atomic_write_json(data_path, data)
|
|
206
|
+
return rows
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _parse_command_records(raw_records: Sequence[str]) -> tuple[EvidenceCommand, ...]:
|
|
210
|
+
content = "\n".join(
|
|
211
|
+
f"- Evidence command: {record}" for record in raw_records
|
|
212
|
+
)
|
|
213
|
+
commands, failures = parse_evidence_commands(content)
|
|
214
|
+
if failures:
|
|
215
|
+
raise ActivityProjectionError("; ".join(failures))
|
|
216
|
+
return commands
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _activity_commands(args: argparse.Namespace) -> tuple[EvidenceCommand, ...]:
|
|
220
|
+
commands = list(_parse_command_records(args.command_record))
|
|
221
|
+
if args.audit_sidecar is not None:
|
|
222
|
+
audit_commands, failures = read_evidence_commands(args.audit_sidecar)
|
|
223
|
+
if failures:
|
|
224
|
+
raise ActivityProjectionError("; ".join(failures))
|
|
225
|
+
commands.extend(audit_commands)
|
|
226
|
+
return tuple(commands)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _conversation_activity_line(details: Mapping[str, Any]) -> str:
|
|
230
|
+
plan_items = ",".join(details["planItemIds"]) or "<none>"
|
|
231
|
+
result_path = str(details["resultPath"] or "<none>")
|
|
232
|
+
summary = json.dumps(details["summary"], ensure_ascii=False)
|
|
233
|
+
return (
|
|
234
|
+
f"ACTIVITY: id={details['activityId']} agent={details['agent']} "
|
|
235
|
+
f"summary={summary} items={plan_items} result={result_path} "
|
|
236
|
+
f"outcome={details['outcome']}"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _append(args: argparse.Namespace) -> int:
|
|
241
|
+
commands = _activity_commands(args)
|
|
242
|
+
details = {
|
|
243
|
+
"kind": args.kind,
|
|
244
|
+
"agent": args.agent,
|
|
245
|
+
"summary": args.summary,
|
|
246
|
+
"planItemIds": args.plan_item_id,
|
|
247
|
+
"resultPath": args.result_path,
|
|
248
|
+
"commands": [command.to_record() for command in commands],
|
|
249
|
+
"evidenceRefs": args.evidence_ref,
|
|
250
|
+
"outcome": args.outcome,
|
|
251
|
+
}
|
|
252
|
+
event = record_activity(args.project_root, args.run_manifest, details)
|
|
253
|
+
payload = dict(event.details)
|
|
254
|
+
payload["ok"] = True
|
|
255
|
+
payload["activityLine"] = _conversation_activity_line(payload)
|
|
256
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
257
|
+
return 0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _project(args: argparse.Namespace) -> int:
|
|
261
|
+
rows = project_agent_activity(args.project_root, args.run_manifest, args.data)
|
|
262
|
+
print(json.dumps(
|
|
263
|
+
{"ok": True, "count": len(rows), "agentActivity": rows},
|
|
264
|
+
ensure_ascii=False,
|
|
265
|
+
indent=2,
|
|
266
|
+
))
|
|
267
|
+
return 0
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _parser() -> argparse.ArgumentParser:
|
|
271
|
+
parser = argparse.ArgumentParser(prog="okstra agent-activity")
|
|
272
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
273
|
+
append_parser = subparsers.add_parser("append")
|
|
274
|
+
append_parser.add_argument("--project-root", type=Path, required=True)
|
|
275
|
+
append_parser.add_argument("--run-manifest", type=Path, required=True)
|
|
276
|
+
append_parser.add_argument(
|
|
277
|
+
"--kind", choices=sorted(ACTIVITY_KINDS), required=True
|
|
278
|
+
)
|
|
279
|
+
append_parser.add_argument("--agent", required=True)
|
|
280
|
+
append_parser.add_argument("--summary", required=True)
|
|
281
|
+
append_parser.add_argument(
|
|
282
|
+
"--outcome", choices=sorted(ACTIVITY_OUTCOMES), required=True
|
|
283
|
+
)
|
|
284
|
+
append_parser.add_argument("--plan-item-id", action="append", default=[])
|
|
285
|
+
append_parser.add_argument("--evidence-ref", action="append", default=[])
|
|
286
|
+
append_parser.add_argument("--command-record", action="append", default=[])
|
|
287
|
+
append_parser.add_argument("--result-path", default="")
|
|
288
|
+
append_parser.add_argument("--audit-sidecar", type=Path)
|
|
289
|
+
project_parser = subparsers.add_parser("project")
|
|
290
|
+
project_parser.add_argument("--project-root", type=Path, required=True)
|
|
291
|
+
project_parser.add_argument("--run-manifest", type=Path, required=True)
|
|
292
|
+
project_parser.add_argument("--data", type=Path, required=True)
|
|
293
|
+
return parser
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def main(argv: list[str] | None = None) -> int:
|
|
297
|
+
args = _parser().parse_args(argv)
|
|
298
|
+
try:
|
|
299
|
+
return _append(args) if args.command == "append" else _project(args)
|
|
300
|
+
except ActivityProjectionError as exc:
|
|
301
|
+
print(f"okstra agent-activity: {exc}", file=sys.stderr)
|
|
302
|
+
return 1
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == "__main__":
|
|
306
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -57,6 +57,12 @@ WORKER_PROFILE_SECTIONS_BY_TASK_TYPE = {
|
|
|
57
57
|
"Brief consumption",
|
|
58
58
|
"Worker diagnosis procedure",
|
|
59
59
|
),
|
|
60
|
+
"implementation-option-selection": (
|
|
61
|
+
"Brief consumption",
|
|
62
|
+
"Worker direction-selection procedure",
|
|
63
|
+
"Pre-selection context exploration",
|
|
64
|
+
"Option evaluation rules",
|
|
65
|
+
),
|
|
60
66
|
"implementation-planning": (
|
|
61
67
|
"Brief consumption",
|
|
62
68
|
"Worker planning procedure",
|
|
@@ -372,6 +372,13 @@ APPROVAL_BLOCKS = frozenset({"approval"})
|
|
|
372
372
|
# user-response skill must list them — counting only `approval` made a report
|
|
373
373
|
# whose open items were all `next-phase` look like it had none.
|
|
374
374
|
USER_INPUT_BLOCKS = frozenset({"approval", "next-phase"})
|
|
375
|
+
ANSWER_DISPOSITIONS = frozenset({
|
|
376
|
+
"answer",
|
|
377
|
+
"select",
|
|
378
|
+
"accept-risk",
|
|
379
|
+
"request-revision",
|
|
380
|
+
"reject",
|
|
381
|
+
})
|
|
375
382
|
|
|
376
383
|
|
|
377
384
|
@dataclass(frozen=True)
|
|
@@ -391,28 +398,39 @@ class ClarificationScan:
|
|
|
391
398
|
def scan_approval_gate(report_path: Path) -> ClarificationScan:
|
|
392
399
|
"""Scan for unresolved ``Blocks=approval`` rows (``Status`` in
|
|
393
400
|
``{open, answered}``), refusing to guess whenever the schema drifted."""
|
|
394
|
-
return scan_clarification_blockers(
|
|
401
|
+
return scan_clarification_blockers(
|
|
402
|
+
report_path, APPROVAL_BLOCKS, honor_sidecar_answers=False
|
|
403
|
+
)
|
|
395
404
|
|
|
396
405
|
|
|
397
406
|
def scan_open_user_input(report_path: Path) -> ClarificationScan:
|
|
398
407
|
"""Scan for every unresolved row that still owes the user an answer
|
|
399
408
|
(``Blocks`` in ``{approval, next-phase}``)."""
|
|
400
|
-
return scan_clarification_blockers(
|
|
409
|
+
return scan_clarification_blockers(
|
|
410
|
+
report_path, USER_INPUT_BLOCKS, honor_sidecar_answers=True
|
|
411
|
+
)
|
|
401
412
|
|
|
402
413
|
|
|
403
414
|
def scan_clarification_blockers(
|
|
404
|
-
report_path: Path,
|
|
415
|
+
report_path: Path,
|
|
416
|
+
blocking_values: frozenset[str],
|
|
417
|
+
*,
|
|
418
|
+
honor_sidecar_answers: bool,
|
|
405
419
|
) -> ClarificationScan:
|
|
406
420
|
"""Shared fail-closed clarification walk for both gates above — schema-v2
|
|
407
|
-
reads its rows from the data sibling
|
|
408
|
-
user's
|
|
409
|
-
|
|
421
|
+
reads its rows from the data sibling and schema-v1 from the §1 table.
|
|
422
|
+
``honor_sidecar_answers`` controls whether a user's sidecar hides answered
|
|
423
|
+
rows from this scan; approval deliberately passes ``False``."""
|
|
410
424
|
v2_scan = _scan_v2_blockers(report_path, blocking_values)
|
|
411
425
|
scan = (
|
|
412
426
|
v2_scan if v2_scan is not None
|
|
413
427
|
else scan_section_1_blockers(_read_report_text(report_path), blocking_values)
|
|
414
428
|
)
|
|
415
|
-
return
|
|
429
|
+
return (
|
|
430
|
+
_resolve_blockers_answered_by_user(report_path, scan)
|
|
431
|
+
if honor_sidecar_answers
|
|
432
|
+
else scan
|
|
433
|
+
)
|
|
416
434
|
|
|
417
435
|
|
|
418
436
|
def _resolve_blockers_answered_by_user(
|
|
@@ -422,9 +440,8 @@ def _resolve_blockers_answered_by_user(
|
|
|
422
440
|
|
|
423
441
|
답의 정본은 사용자의 `user-responses/` 사이드카다. 리포트의 `Status` 는 그
|
|
424
442
|
run 이 스스로 적어둔 값이고, 답이 사이드카로만 들어오는 경로(HTML 뷰의
|
|
425
|
-
`Export user response`, `okstra user-response write`)에서는 갱신되지
|
|
426
|
-
|
|
427
|
-
영원히 미해결로 남아 다음 phase 를 막는다.
|
|
443
|
+
`Export user response`, `okstra user-response write`)에서는 갱신되지 않는다.
|
|
444
|
+
사용자 입력 목록은 사이드카 답변이 있는 항목을 다시 묻지 않는다.
|
|
428
445
|
|
|
429
446
|
fail-closed 는 그대로다: 행 자체를 못 읽은 스캔(`unreadable_reason`)은
|
|
430
447
|
어떤 id 가 blocker 인지 모르는 상태이므로 사이드카로 덮지 않는다.
|
|
@@ -630,10 +647,10 @@ def sidecar_answers(source: Path) -> dict[str, str]:
|
|
|
630
647
|
사용자가 답한 항목이 무엇인지 아는 단일 참조점 — carry-in 병합도, 승인
|
|
631
648
|
게이트도, 스킬의 열린 항목 목록도 전부 이 한 곳을 본다.
|
|
632
649
|
|
|
633
|
-
`disposition` 이 `
|
|
634
|
-
다시 물어달라" 이지 답이 아니므로
|
|
635
|
-
"A reframe is not an answer")
|
|
636
|
-
|
|
650
|
+
`disposition` 이 `ANSWER_DISPOSITIONS` 에 속하는 항목만 답으로 센다.
|
|
651
|
+
`reframe` 은 "다음 run 에서 다시 물어달라" 이지 답이 아니므로
|
|
652
|
+
(`skills/okstra-user-response/SKILL.md` 의 "A reframe is not an answer")
|
|
653
|
+
답 집합에서 빠져야 사용자 입력 목록에 그 항목이 계속 남는다.
|
|
637
654
|
|
|
638
655
|
같은 id 가 여러 사이드카에 나오면 이름순 마지막(최신 seq)이 이긴다 — 최신이
|
|
639
656
|
reframe 이거나 값이 비면 앞선 답을 지운다. 그래야 답을 물렀을 때 그 항목이
|
|
@@ -646,7 +663,7 @@ def sidecar_answers(source: Path) -> dict[str, str]:
|
|
|
646
663
|
for entry in parse_user_response_entries(
|
|
647
664
|
sidecar.read_text(encoding="utf-8")
|
|
648
665
|
):
|
|
649
|
-
if entry.value and entry.disposition
|
|
666
|
+
if entry.value and entry.disposition in ANSWER_DISPOSITIONS:
|
|
650
667
|
answers[entry.response_id] = entry.value
|
|
651
668
|
else:
|
|
652
669
|
answers.pop(entry.response_id, None)
|
|
@@ -759,7 +776,7 @@ def _v2_section_1_body(entries: list) -> str:
|
|
|
759
776
|
"""schema-v2 `clarificationItems[]` 를 §1 표 본문으로.
|
|
760
777
|
|
|
761
778
|
메타 셀은 렌더러가 쓰는 모양 그대로다 — `Status:` 는 따옴표 없이 써야
|
|
762
|
-
`_reconcile_user_input` 이 답을 병합하면서 상태를
|
|
779
|
+
`_reconcile_user_input` 이 답을 병합하면서 상태를 answered 로 넘길 수 있다."""
|
|
763
780
|
rows = [e for e in entries if isinstance(e, dict) and e.get("id")]
|
|
764
781
|
if not rows:
|
|
765
782
|
return f"\n{_SECTION_1_EMPTY_STATE}"
|
|
@@ -783,7 +800,7 @@ def _v2_section_1_body(entries: list) -> str:
|
|
|
783
800
|
# The final-report renderer writes `Status: open` / `Status: answered` unquoted
|
|
784
801
|
# in the stacked meta cell; only those two are unresolved. Resolve in place so
|
|
785
802
|
# the meta cell's other fields (ID, Ticket, Kind, Blocks) are left untouched.
|
|
786
|
-
|
|
803
|
+
_STATUS_ANSWER_RE = re.compile(r"(Status:\s*)(?:open|answered)\b", re.IGNORECASE)
|
|
787
804
|
|
|
788
805
|
|
|
789
806
|
def _locate_user_input_column(lines: list[str]) -> tuple[int, int]:
|
|
@@ -799,7 +816,7 @@ def _locate_user_input_column(lines: list[str]) -> tuple[int, int]:
|
|
|
799
816
|
|
|
800
817
|
def _reconcile_row(line: str, ui_col: int, answers: dict[str, str]) -> str:
|
|
801
818
|
"""답이 있고 open/answered 인 행이면 `User input` 칸을 그 답으로 채우고 Status 를
|
|
802
|
-
|
|
819
|
+
answered 로 바꾼 줄을, 그 외에는 원본 줄을 그대로 돌려준다.
|
|
803
820
|
|
|
804
821
|
칸에 이미 값이 있어도 사용자의 사이드카 답이 이긴다. 그 칸을 채우는 것은
|
|
805
822
|
run 자신(직전 렌더가 옮겨 적은 값)이고, 사용자가 나중에 답을 바꾸면 둘이
|
|
@@ -819,13 +836,13 @@ def _reconcile_row(line: str, ui_col: int, answers: dict[str, str]) -> str:
|
|
|
819
836
|
if not 0 <= ui_col < len(raw):
|
|
820
837
|
return line
|
|
821
838
|
raw[ui_col] = answers[item.row_id]
|
|
822
|
-
raw[0] =
|
|
839
|
+
raw[0] = _STATUS_ANSWER_RE.sub(r"\1answered", raw[0])
|
|
823
840
|
return "| " + " | ".join(to_cell_text(c) for c in raw) + " |"
|
|
824
841
|
|
|
825
842
|
|
|
826
843
|
def _reconcile_user_input(section: str, answers: dict[str, str]) -> str:
|
|
827
844
|
"""§1 표에서 사이드카 답이 있는 미해결 행의 `User input` 칸을 답으로 채우고
|
|
828
|
-
Status 를
|
|
845
|
+
Status 를 answered 로 바꾼 §1 본문을 돌려준다.
|
|
829
846
|
|
|
830
847
|
답의 정본 위치를 §1 표 안으로 옮긴다 — 표만 읽는 승인 게이트·프롬프트
|
|
831
848
|
빌더·검증 워커가 모두 답을 보게 하려는 것. 사이드카는 §1 표 밖 별도 섹션에만
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Pure exact requirement coverage calculation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExactCoverageError(ValueError):
|
|
11
|
+
"""Raised when coverage input cannot define a valid comparison."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ExactCoverageResult:
|
|
16
|
+
coverage_percent: float
|
|
17
|
+
scope_precision_percent: float
|
|
18
|
+
covered_count: int
|
|
19
|
+
total_count: int
|
|
20
|
+
unmapped_commitments: tuple[str, ...]
|
|
21
|
+
contradicted_requirements: tuple[str, ...]
|
|
22
|
+
verdict: Literal["exact", "under", "over", "contradicted"]
|
|
23
|
+
|
|
24
|
+
def as_report_summary(self) -> dict[str, object]:
|
|
25
|
+
return {
|
|
26
|
+
"coveragePercent": self.coverage_percent,
|
|
27
|
+
"scopePrecisionPercent": self.scope_precision_percent,
|
|
28
|
+
"coveredCount": self.covered_count,
|
|
29
|
+
"totalCount": self.total_count,
|
|
30
|
+
"unmappedCommitments": list(self.unmapped_commitments),
|
|
31
|
+
"contradictedRequirements": list(self.contradicted_requirements),
|
|
32
|
+
"coverageVerdict": self.verdict,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def calculate_exact_coverage(
|
|
37
|
+
original_requirement_ids: Sequence[str],
|
|
38
|
+
coverage_status_by_id: Mapping[str, str],
|
|
39
|
+
commitment_requirement_ids: Mapping[str, Sequence[str]],
|
|
40
|
+
) -> ExactCoverageResult:
|
|
41
|
+
original_ids = tuple(original_requirement_ids)
|
|
42
|
+
if not original_ids:
|
|
43
|
+
raise ExactCoverageError("original requirement denominator is empty")
|
|
44
|
+
if len(set(original_ids)) != len(original_ids):
|
|
45
|
+
raise ExactCoverageError("original requirement ids contain duplicates")
|
|
46
|
+
|
|
47
|
+
original_id_set = set(original_ids)
|
|
48
|
+
if set(coverage_status_by_id) != original_id_set:
|
|
49
|
+
raise ExactCoverageError("coverage rows must match original requirement ids")
|
|
50
|
+
allowed_statuses = {"covered", "not-covered", "contradicted"}
|
|
51
|
+
if any(status not in allowed_statuses for status in coverage_status_by_id.values()):
|
|
52
|
+
raise ExactCoverageError("coverage status is invalid")
|
|
53
|
+
if not commitment_requirement_ids:
|
|
54
|
+
raise ExactCoverageError("implementation commitment denominator is empty")
|
|
55
|
+
|
|
56
|
+
unmapped = tuple(
|
|
57
|
+
commitment_id
|
|
58
|
+
for commitment_id, requirement_ids in commitment_requirement_ids.items()
|
|
59
|
+
if not requirement_ids
|
|
60
|
+
or any(requirement_id not in original_id_set for requirement_id in requirement_ids)
|
|
61
|
+
)
|
|
62
|
+
contradicted = tuple(
|
|
63
|
+
requirement_id
|
|
64
|
+
for requirement_id in original_ids
|
|
65
|
+
if coverage_status_by_id[requirement_id] == "contradicted"
|
|
66
|
+
)
|
|
67
|
+
covered_count = sum(
|
|
68
|
+
coverage_status_by_id[requirement_id] == "covered"
|
|
69
|
+
for requirement_id in original_ids
|
|
70
|
+
)
|
|
71
|
+
coverage_percent = round(covered_count / len(original_ids) * 100, 2)
|
|
72
|
+
traceable_count = len(commitment_requirement_ids) - len(unmapped)
|
|
73
|
+
scope_precision_percent = round(
|
|
74
|
+
traceable_count / len(commitment_requirement_ids) * 100,
|
|
75
|
+
2,
|
|
76
|
+
)
|
|
77
|
+
verdict: Literal["exact", "under", "over", "contradicted"]
|
|
78
|
+
if contradicted:
|
|
79
|
+
verdict = "contradicted"
|
|
80
|
+
elif covered_count != len(original_ids):
|
|
81
|
+
verdict = "under"
|
|
82
|
+
elif unmapped:
|
|
83
|
+
verdict = "over"
|
|
84
|
+
else:
|
|
85
|
+
verdict = "exact"
|
|
86
|
+
|
|
87
|
+
return ExactCoverageResult(
|
|
88
|
+
coverage_percent=coverage_percent,
|
|
89
|
+
scope_precision_percent=scope_precision_percent,
|
|
90
|
+
covered_count=covered_count,
|
|
91
|
+
total_count=len(original_ids),
|
|
92
|
+
unmapped_commitments=unmapped,
|
|
93
|
+
contradicted_requirements=contradicted,
|
|
94
|
+
verdict=verdict,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def calculate_plan_exact_coverage(
|
|
99
|
+
original_requirement_ids: Sequence[str],
|
|
100
|
+
coverage_status_by_id: Mapping[str, str],
|
|
101
|
+
stage_requirement_ids: Mapping[int, Sequence[str]],
|
|
102
|
+
file_requirement_ids: Mapping[str, Sequence[str]],
|
|
103
|
+
) -> tuple[ExactCoverageResult, tuple[int, ...], tuple[str, ...]]:
|
|
104
|
+
"""Apply exact coverage to the executable stage and file-change scope."""
|
|
105
|
+
commitments = {
|
|
106
|
+
**{
|
|
107
|
+
f"stage:{stage}": requirement_ids
|
|
108
|
+
for stage, requirement_ids in stage_requirement_ids.items()
|
|
109
|
+
},
|
|
110
|
+
**{
|
|
111
|
+
f"file:{path}": requirement_ids
|
|
112
|
+
for path, requirement_ids in file_requirement_ids.items()
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
result = calculate_exact_coverage(
|
|
116
|
+
original_requirement_ids,
|
|
117
|
+
coverage_status_by_id,
|
|
118
|
+
commitments,
|
|
119
|
+
)
|
|
120
|
+
unmapped_stages = tuple(
|
|
121
|
+
stage for stage, requirement_ids in stage_requirement_ids.items()
|
|
122
|
+
if not requirement_ids
|
|
123
|
+
)
|
|
124
|
+
unmapped_files = tuple(
|
|
125
|
+
path for path, requirement_ids in file_requirement_ids.items()
|
|
126
|
+
if not requirement_ids
|
|
127
|
+
)
|
|
128
|
+
return result, unmapped_stages, unmapped_files
|
|
@@ -27,7 +27,9 @@ _LOCK_FILENAME = ".fix-cycles.lock"
|
|
|
27
27
|
# 완료(release-handoff) task 에 fix-cycle 로 재진입할 수 있는 entry phase 들.
|
|
28
28
|
# prepare(run.py) 의 게이트와 wizard 의 감지 술어가 공유하는 SSOT.
|
|
29
29
|
FIX_CYCLE_ENTRY_PHASES = (
|
|
30
|
-
"requirements-discovery", "error-analysis", "implementation-
|
|
30
|
+
"requirements-discovery", "error-analysis", "implementation-option-selection",
|
|
31
|
+
"implementation-planning",
|
|
32
|
+
)
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
def fix_cycles_path(task_root: Path) -> Path:
|