@jesdi/skills 0.0.30 → 0.0.32
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 +12 -6
- package/package.json +2 -2
- package/skills/implement-spec/ARCHITECTURE.md +113 -0
- package/skills/implement-spec/README.md +215 -0
- package/skills/implement-spec/SKILL.md +18 -70
- package/skills/implement-spec/driver.py +580 -0
- package/skills/implement-spec/driver_agents.py +145 -0
- package/skills/implement-spec/driver_answers.py +79 -0
- package/skills/implement-spec/driver_decisions.py +152 -0
- package/skills/implement-spec/driver_final.py +534 -0
- package/skills/implement-spec/driver_fixloop.py +170 -0
- package/skills/implement-spec/driver_gate.py +47 -0
- package/skills/implement-spec/driver_graph.py +42 -0
- package/skills/implement-spec/driver_merge.py +105 -0
- package/skills/implement-spec/driver_report.py +115 -0
- package/skills/implement-spec/driver_review.py +126 -0
- package/skills/implement-spec/driver_review_page.py +51 -0
- package/skills/implement-spec/driver_run.py +262 -0
- package/skills/implement-spec/driver_slots.py +64 -0
- package/skills/implement-spec/driver_state.py +108 -0
- package/skills/implement-spec/driver_summary.py +40 -0
- package/skills/implement-spec/driver_tickets.py +132 -0
- package/skills/implement-spec/driver_tidy.py +57 -0
- package/skills/implement-spec/models.json +11 -0
- package/skills/implement-spec/prompts/conflict.md +32 -0
- package/skills/implement-spec/prompts/correction.md +4 -0
- package/skills/implement-spec/prompts/correctness.md +34 -0
- package/skills/implement-spec/prompts/final-fixer.md +36 -0
- package/skills/implement-spec/prompts/final-reviewer.md +36 -0
- package/skills/implement-spec/prompts/final-scoped.md +1 -0
- package/skills/implement-spec/prompts/fixer.md +35 -0
- package/skills/implement-spec/prompts/implementer.md +33 -0
- package/skills/implement-spec/prompts/interrupted.md +1 -0
- package/skills/implement-spec/prompts/moved.md +2 -0
- package/skills/implement-spec/prompts/overruled.md +5 -0
- package/skills/implement-spec/prompts/pr-body.md +19 -0
- package/skills/implement-spec/prompts/pr-writer.md +31 -0
- package/skills/implement-spec/prompts/rebase-conflict.md +33 -0
- package/skills/implement-spec/prompts/rereview.md +3 -0
- package/skills/implement-spec/prompts/rerun.md +2 -0
- package/skills/implement-spec/prompts/reviewer.md +34 -0
- package/skills/implement-spec/prompts/stands.md +2 -0
- package/skills/implement-spec/prompts/test-writer.md +34 -0
- package/skills/implement-spec/prompts/timeout-restored.md +2 -0
- package/skills/implement-spec/prompts/timeout-shared.md +2 -0
- package/skills/implement-spec/prompts/timeout.md +2 -0
- package/skills/implement-spec/run.sh +101 -0
- package/skills/implement-spec/tests/conftest.py +50 -0
- package/skills/implement-spec/tests/test_agents.py +358 -0
- package/skills/implement-spec/tests/test_amendments.py +117 -0
- package/skills/implement-spec/tests/test_cli.py +221 -0
- package/skills/implement-spec/tests/test_decisions.py +434 -0
- package/skills/implement-spec/tests/test_driver.py +767 -0
- package/skills/implement-spec/tests/test_final.py +442 -0
- package/skills/implement-spec/tests/test_fixloop.py +523 -0
- package/skills/implement-spec/tests/test_graph.py +32 -0
- package/skills/implement-spec/tests/test_landing.py +355 -0
- package/skills/implement-spec/tests/test_merge.py +144 -0
- package/skills/implement-spec/tests/test_models.py +47 -0
- package/skills/implement-spec/tests/test_report.py +162 -0
- package/skills/implement-spec/tests/test_review.py +338 -0
- package/skills/implement-spec/tests/test_review_page.py +67 -0
- package/skills/implement-spec/tests/test_slots.py +32 -0
- package/skills/implement-spec/tests/test_state.py +68 -0
- package/skills/implement-spec/tests/test_stops.py +272 -0
- package/skills/implement-spec/tests/test_summary.py +28 -0
- package/skills/implement-spec/tests/test_tickets.py +72 -0
- package/skills/implement-spec/tests/test_tidy.py +604 -0
- package/skills/implement-spec/tests/test_walk.py +459 -0
- package/skills/review-page/template.html +1 -1
- package/skills/to-tickets/SKILL.md +19 -2
- package/skills-manifest.json +7 -7
- package/skills/implement-spec/history.md +0 -52
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Model entries, the command line per provider, prompt rendering and the blocking launch.
|
|
2
|
+
|
|
3
|
+
A model entry is `provider/model@effort`; a bare `model@effort` is anthropic.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import signal
|
|
13
|
+
import subprocess
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
SKILL = Path(__file__).resolve().parent
|
|
19
|
+
# Minutes per role.
|
|
20
|
+
TIMEOUTS = {"explorer": 30, "test-writer": 30, "implementer": 90, "reviewer": 45, "correctness": 30, "fixer": 60,
|
|
21
|
+
"final-reviewer": 60, "conflict": 30, "pr-writer": 20}
|
|
22
|
+
PROVIDERS = {"anthropic": ("claude", ("low", "medium", "high", "xhigh", "max")),
|
|
23
|
+
"openai": ("codex", ("minimal", "low", "medium", "high", "xhigh"))}
|
|
24
|
+
READ_ONLY = ("reviewer", "correctness", "final-reviewer", "pr-writer")
|
|
25
|
+
# A process group is here only while its leader is not reaped: then no other process can have its id.
|
|
26
|
+
LIVE: set[int] = set()
|
|
27
|
+
STOPPED = False
|
|
28
|
+
_LIVE = threading.Lock()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def split(entry: str) -> tuple[str, str, str]:
|
|
32
|
+
head, _, effort = str(entry).partition("@")
|
|
33
|
+
provider, _, model = head.partition("/") if "/" in head else ("anthropic", "", head)
|
|
34
|
+
return provider, model, effort
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _entry_error(role: str, entry: str, first: bool) -> str | None:
|
|
38
|
+
provider, model, effort = split(entry)
|
|
39
|
+
if provider not in PROVIDERS:
|
|
40
|
+
return f"{role}: model entry {entry} names an unknown provider (known: {', '.join(PROVIDERS)})"
|
|
41
|
+
cli, efforts = PROVIDERS[provider]
|
|
42
|
+
if not model or effort not in efforts:
|
|
43
|
+
return f"{role}: model entry {entry} needs a model and an effort from {', '.join(efforts)}"
|
|
44
|
+
if first and not shutil.which(cli):
|
|
45
|
+
return f"{role}: {cli} is not on PATH (model entry {entry})"
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def correctness_entry(models: dict[str, list[str]]) -> str:
|
|
50
|
+
"""The first correctness entry on another provider than the implementer's first entry; "" when there is none."""
|
|
51
|
+
own = split(models["implementer"][0])[0]
|
|
52
|
+
return next((entry for entry in models["correctness"] if split(entry)[0] != own), "")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_models(path: str | None) -> tuple[dict[str, list[str]], list[str]]:
|
|
56
|
+
"""The skill's default lists with the roles of `path` on top, and every entry error."""
|
|
57
|
+
models = json.loads((SKILL / "models.json").read_text())
|
|
58
|
+
try:
|
|
59
|
+
models = {**models, **(json.loads(Path(path).read_text()) if path else {})} # a list is a TypeError
|
|
60
|
+
# The entry of each role whose CLI must be on PATH; entries[0]: an empty list is an error.
|
|
61
|
+
first = {**{role: entries[0] for role, entries in models.items()}, "correctness": correctness_entry(models)}
|
|
62
|
+
found = [_entry_error(role, entry, entry == first[role]) for role, entries in models.items() for entry in entries]
|
|
63
|
+
if not first["correctness"]:
|
|
64
|
+
found.append(f"correctness: no entry of its list ({', '.join(models['correctness'])}) is on another "
|
|
65
|
+
f"provider than the implementer's first entry {models['implementer'][0]}; the correctness "
|
|
66
|
+
"pass never runs on the implementer's provider")
|
|
67
|
+
except (OSError, ValueError, AttributeError, LookupError, TypeError) as exc:
|
|
68
|
+
return {}, [f"--models {path}: {exc!r}"]
|
|
69
|
+
return models, [error for error in found if error]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def command(role: str, entry: str, report: str) -> list[str]:
|
|
73
|
+
"""The CLI call for one agent; the prompt goes to its stdin."""
|
|
74
|
+
provider, model, effort = split(entry)
|
|
75
|
+
if provider == "openai":
|
|
76
|
+
# No --ephemeral: the rollout on disk is the trace. A read-only agent cannot write its report, so -o saves it.
|
|
77
|
+
tail = ["--sandbox", "read-only", "-o", report] if role in READ_ONLY else ["--sandbox", "workspace-write"]
|
|
78
|
+
return ["codex", "exec", "--model", model, "-c", f'model_reasoning_effort="{effort}"', *tail]
|
|
79
|
+
# stream-json keeps tool output in the log and ends with a result that has total_cost_usd.
|
|
80
|
+
return ["claude", "-p", "--model", model, "--effort", effort, "--permission-mode", "auto",
|
|
81
|
+
"--output-format", "stream-json", "--verbose"]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def render(name: str, values: dict[str, str]) -> str:
|
|
85
|
+
return re.sub(r"\{([a-z_]+)\}", lambda m: values[m.group(1)], (SKILL / "prompts" / f"{name}.md").read_text())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def tail(prompt: str, report: str) -> str:
|
|
89
|
+
"""The end of a rendered prompt, from the line that names the report path."""
|
|
90
|
+
return prompt[prompt.rfind("\n", 0, prompt.rfind(report)):]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def launch(argv: list[str], prompt: Path, log: Path, seconds: float, slot: str = ".") -> int | None:
|
|
94
|
+
"""Run one agent in `slot` to its end, output to `log`. Returns the exit code, None after a timeout."""
|
|
95
|
+
with prompt.open() as stdin, log.open("w") as out:
|
|
96
|
+
with _LIVE:
|
|
97
|
+
if STOPPED:
|
|
98
|
+
raise InterruptedError("the run is stopped")
|
|
99
|
+
# Its own process group, so the children that edit the tree stop with it.
|
|
100
|
+
proc = subprocess.Popen(argv, cwd=slot, stdin=stdin, stdout=out, stderr=subprocess.STDOUT,
|
|
101
|
+
start_new_session=True)
|
|
102
|
+
LIVE.add(proc.pid)
|
|
103
|
+
try:
|
|
104
|
+
ended = _ended(proc.pid, seconds)
|
|
105
|
+
finally:
|
|
106
|
+
# Kill before the reap, under the lock of stop(): until then no other process can have the group id.
|
|
107
|
+
with _LIVE:
|
|
108
|
+
_kill(proc.pid)
|
|
109
|
+
LIVE.discard(proc.pid)
|
|
110
|
+
code = proc.wait()
|
|
111
|
+
return code if ended else None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _ended(pid: int, seconds: float) -> bool:
|
|
115
|
+
"""True when the child `pid` ends within `seconds`; it is not reaped."""
|
|
116
|
+
# ponytail: waitid has no timeout, so this polls as `Popen.wait(timeout)` does, at most each 50 ms.
|
|
117
|
+
end, pause = time.monotonic() + seconds, 0.0005
|
|
118
|
+
while not os.waitid(os.P_PID, pid, os.WEXITED | os.WNOWAIT | os.WNOHANG):
|
|
119
|
+
left = end - time.monotonic()
|
|
120
|
+
if left <= 0:
|
|
121
|
+
return False
|
|
122
|
+
pause = min(pause * 2, left, 0.05)
|
|
123
|
+
time.sleep(pause)
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _kill(group: int) -> None:
|
|
128
|
+
"""SIGKILL a process group. No such group means all gone; macOS answers EPERM for a group of zombies."""
|
|
129
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
130
|
+
os.killpg(group, signal.SIGKILL)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def stop() -> None:
|
|
134
|
+
"""Kill every running agent and let no other start."""
|
|
135
|
+
global STOPPED
|
|
136
|
+
with _LIVE:
|
|
137
|
+
STOPPED = True
|
|
138
|
+
for group in list(LIVE):
|
|
139
|
+
_kill(group)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def cost(log: Path) -> str | None:
|
|
143
|
+
"""The dollar cost the CLI reported; None for codex, which reports tokens only."""
|
|
144
|
+
found = re.findall(r'"total_cost_usd":\s*([0-9.]+)', log.read_text(errors="replace"))
|
|
145
|
+
return found[-1] if found else None
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""The answers file `.agent/implement-answers.json`, in the shape of skills/review-page/schema.md.
|
|
2
|
+
|
|
3
|
+
{"v": 1, "stage": "implement", "submitted": "changes", "revision": <decisions.json revision>,
|
|
4
|
+
"answers": {<question id>: <option id>, "<question id>.note": <text>}}
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
FILE = Path(".agent/implement-answers.json")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _offered(document: dict) -> dict[str, list[str]]:
|
|
15
|
+
"""question id -> its option ids, for the open questions."""
|
|
16
|
+
return {q["id"]: [option["id"] for option in q["options"]] for q in document["questions"]}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pair(text: str) -> tuple[str, str]:
|
|
20
|
+
"""One `--answer <question id>=<option id>`."""
|
|
21
|
+
question, equals, option = text.partition("=")
|
|
22
|
+
if not (question and equals and option):
|
|
23
|
+
raise ValueError(text)
|
|
24
|
+
return question, option
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def given(pairs: list[tuple[str, str]], document: dict) -> list[str]:
|
|
28
|
+
"""Write the answers file for `--answer`, bound to `document`. Returns the usage errors; then no file is written."""
|
|
29
|
+
offered = _offered(document)
|
|
30
|
+
errors = [f"--answer {question}={option}: " + (f"{question} offers {', '.join(offered[question])}"
|
|
31
|
+
if question in offered else f"no open question {question}")
|
|
32
|
+
for question, option in pairs if option not in offered.get(question, [])]
|
|
33
|
+
if pairs and not errors:
|
|
34
|
+
FILE.write_text(json.dumps({"v": 1, "stage": "implement", "submitted": "changes",
|
|
35
|
+
"revision": document["revision"], "answers": dict(pairs)}, indent=1) + "\n")
|
|
36
|
+
return errors
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _unbound(file: object, revision: str) -> str:
|
|
40
|
+
"""Why no answer of the file counts; "" when the file is a submission for this revision."""
|
|
41
|
+
if not isinstance(file, dict) or not isinstance(file.get("answers"), dict):
|
|
42
|
+
return "it is not an object with an `answers` object"
|
|
43
|
+
if (file.get("v"), file.get("stage")) != (1, "implement"):
|
|
44
|
+
return "it is not a version 1 file of the implement stage"
|
|
45
|
+
if not file.get("submitted"):
|
|
46
|
+
return "it is a draft: `submitted` is not set"
|
|
47
|
+
if file.get("revision") != revision:
|
|
48
|
+
return (f"its revision {str(file.get('revision'))[:12]} is not the revision {revision[:12]} of the "
|
|
49
|
+
"decisions file: the questions changed after it was written")
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _each(given: dict, offered: dict[str, list[str]]) -> tuple[dict, list[str]]:
|
|
54
|
+
"""(the answers and notes that count, a sentence per ignored one)."""
|
|
55
|
+
answers, ignored = {}, []
|
|
56
|
+
for key, value in given.items():
|
|
57
|
+
question = key.removesuffix(".note")
|
|
58
|
+
if question not in offered:
|
|
59
|
+
ignored.append(f"The answer for {key} in {FILE} is ignored: there is no open question {question}.")
|
|
60
|
+
elif key == question and value not in offered[question]:
|
|
61
|
+
ignored.append(f"The answer {value!r} for {question} in {FILE} is ignored: the question offers "
|
|
62
|
+
f"{', '.join(offered[question])}.")
|
|
63
|
+
elif isinstance(value, str):
|
|
64
|
+
answers[key] = value
|
|
65
|
+
return answers, ignored
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def read(document: dict) -> tuple[dict, list[str]]:
|
|
69
|
+
"""(the answers that count, a ledger sentence per ignored thing). With no open question nothing is read."""
|
|
70
|
+
offered = _offered(document)
|
|
71
|
+
if not (offered and FILE.exists()):
|
|
72
|
+
return {}, []
|
|
73
|
+
try:
|
|
74
|
+
file = json.loads(FILE.read_text())
|
|
75
|
+
except (OSError, ValueError) as exc:
|
|
76
|
+
return {}, [f"The answers file {FILE} is ignored: it is not readable JSON: {exc}."]
|
|
77
|
+
if why := _unbound(file, document["revision"]):
|
|
78
|
+
return {}, [f"The answers file {FILE} is ignored: {why}."]
|
|
79
|
+
return _each(file["answers"], offered)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Human stops as bounded questions: the question kinds, decisions.json and what an answer does.
|
|
2
|
+
|
|
3
|
+
A question in state.json `run.questions`; `answer` is None while it is open:
|
|
4
|
+
{id, title, why, evidence, options: [{id, description}], recommended, ticket, kind, subject, stop, answer}
|
|
5
|
+
decisions.json: {run: <run.revision>, questions: [the open ones, first six fields], revision: <SHA-256 of the rest>}."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
from typing import NamedTuple
|
|
11
|
+
|
|
12
|
+
PUBLIC = ("id", "title", "why", "evidence", "options", "recommended")
|
|
13
|
+
CONTRADICTS = "locked test contradicts spec:"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Option(NamedTuple):
|
|
17
|
+
description: str
|
|
18
|
+
ruling: str
|
|
19
|
+
fields: dict # what the ticket record gets when its stop is settled; {} keeps the ticket BLOCKED
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Kind(NamedTuple):
|
|
23
|
+
parks: bool # an open question of this kind ends the run with exit 2
|
|
24
|
+
title: str
|
|
25
|
+
why: str
|
|
26
|
+
recommended: str
|
|
27
|
+
options: dict[str, Option]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
KINDS = {
|
|
31
|
+
"locked-test": Kind(
|
|
32
|
+
True,
|
|
33
|
+
"Ticket {ticket}: the implementer reports that a locked test contradicts the spec: {subject}",
|
|
34
|
+
"The implementer of ticket {ticket} stopped with BLOCKED. The ticket and each ticket that it blocks wait "
|
|
35
|
+
"until the test or the implementer is overruled.",
|
|
36
|
+
"overrule-implementer",
|
|
37
|
+
{"overrule-test": Option(
|
|
38
|
+
"The test is wrong. The test-writer corrects it in one correction run, then the implementer runs again.",
|
|
39
|
+
"Ruling: {ticket} locked test overruled: the test-writer corrects {subject}, then the implementer runs "
|
|
40
|
+
"again.", {"phase": "test-writer", "note": "overruled"}),
|
|
41
|
+
"overrule-implementer": Option(
|
|
42
|
+
"The test is right. The implementer runs again and makes the test green as it is.",
|
|
43
|
+
"Ruling: {ticket} locked test stands: the implementer is overruled on {subject} and runs again.",
|
|
44
|
+
{"phase": "implement", "note": "stands"})}),
|
|
45
|
+
"finding": Kind(
|
|
46
|
+
False,
|
|
47
|
+
"Ticket {ticket}: a finding is open after fix round 4: {subject}",
|
|
48
|
+
"Four fix rounds did not close this finding, so ticket {ticket} is BLOCKED and each ticket that it blocks "
|
|
49
|
+
"waits.",
|
|
50
|
+
"split",
|
|
51
|
+
{"accept": Option(
|
|
52
|
+
"Close the finding as it is. The ticket merges when each of its open findings is accepted.",
|
|
53
|
+
"Ruling: {ticket} finding closed as it is (accept): {subject}.", {"phase": "merge"}),
|
|
54
|
+
"split": Option(
|
|
55
|
+
"Keep the ticket BLOCKED. The open part becomes a ticket of its own.",
|
|
56
|
+
"Ruling: {ticket} stays BLOCKED, the open part becomes a new ticket (split): {subject}.", {}),
|
|
57
|
+
"redesign": Option(
|
|
58
|
+
"Keep the ticket BLOCKED. The design changes before more work on it.",
|
|
59
|
+
"Ruling: {ticket} stays BLOCKED, the design changes first (redesign): {subject}.", {})}),
|
|
60
|
+
# A final-review question belongs to no ticket: its `ticket` is "".
|
|
61
|
+
"final-finding": Kind(
|
|
62
|
+
True,
|
|
63
|
+
"Final review: a finding is open after the second review round: {subject}",
|
|
64
|
+
"The final review ran its two rounds and this finding is open. Nothing is pushed while it is open.",
|
|
65
|
+
"fix",
|
|
66
|
+
{"fix": Option(
|
|
67
|
+
"Keep the finding open. The run ends with nothing pushed, and the finding is fixed before a push.",
|
|
68
|
+
"Ruling: final-review finding stays open and nothing is pushed (fix): {subject}.", {}),
|
|
69
|
+
"accept": Option(
|
|
70
|
+
"Close the finding as it is. The branch can be pushed when each open finding is accepted.",
|
|
71
|
+
"Ruling: final-review finding closed as it is (accept): {subject}.", {})}),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def contradicted(role: str, report: dict) -> str:
|
|
76
|
+
"""The locked tests an implementer's BLOCKED report says contradict the spec; "" when it is not that stop."""
|
|
77
|
+
if role != "implementer" or report.get("status") != "BLOCKED":
|
|
78
|
+
return ""
|
|
79
|
+
return "; ".join(filter(None, (concern[len(CONTRADICTS):].strip() for concern in report["concerns"]
|
|
80
|
+
if concern.startswith(CONTRADICTS))))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def question(kind: str, ticket: str, subject: str, evidence: list[str], asked: list[dict], stop: int) -> dict:
|
|
84
|
+
"""A new open question, id `<NN>-<kind>-<n>` (`<kind>-<n>` with no ticket); n counts in `asked`, the whole run."""
|
|
85
|
+
made, values = KINDS[kind], {"ticket": ticket, "subject": subject}
|
|
86
|
+
number = 1 + sum((q["ticket"], q["kind"]) == (ticket, kind) for q in asked)
|
|
87
|
+
return {"id": f"{ticket}-{kind}-{number}".lstrip("-"), "title": made.title.format(**values), "why": made.why.format(**values),
|
|
88
|
+
"evidence": evidence, "recommended": made.recommended,
|
|
89
|
+
"options": [{"id": name, "description": option.description} for name, option in made.options.items()],
|
|
90
|
+
"ticket": ticket, "kind": kind, "subject": subject, "stop": stop, "answer": None}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def stop(run: dict, kind: str, ticket: str, subjects: list[str], evidence: list[str]) -> None:
|
|
94
|
+
"""Count one stop in `run` and add one open question of `kind` per subject. No subject is no stop."""
|
|
95
|
+
run["stops"] += bool(subjects)
|
|
96
|
+
for subject in subjects:
|
|
97
|
+
run["questions"].append(question(kind, ticket, subject, evidence, run["questions"], run["stops"]))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def decides(kind: str, subjects: int) -> str:
|
|
101
|
+
return f"the operator decides on {subjects} question{'s' * (subjects > 1)} of kind {kind}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def document(run: dict) -> dict:
|
|
105
|
+
"""decisions.json for `run` of state.json."""
|
|
106
|
+
body = {"run": run["revision"],
|
|
107
|
+
"questions": [{name: q[name] for name in PUBLIC} for q in run["questions"] if q["answer"] is None]}
|
|
108
|
+
canonical = json.dumps(body, sort_keys=True, separators=(",", ":"))
|
|
109
|
+
return {"revision": hashlib.sha256(canonical.encode()).hexdigest(), **body}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def rulings(questions: list[dict]) -> list[str]:
|
|
113
|
+
"""The ruling sentence of each answered question, without the note of the operator."""
|
|
114
|
+
return [KINDS[q["kind"]].options[q["answer"]].ruling.format(**q) for q in questions if q["answer"] is not None]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def parked(questions: list[dict]) -> bool:
|
|
118
|
+
return any(q["answer"] is None and KINDS[q["kind"]].parks for q in questions)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _settled(questions: list[dict], ticket: str, stop: int) -> dict:
|
|
122
|
+
"""The ticket fields from the answers of one stop; {} while one is open or one keeps the ticket BLOCKED."""
|
|
123
|
+
fields = [q["answer"] and KINDS[q["kind"]].options[q["answer"]].fields
|
|
124
|
+
for q in questions if (q["ticket"], q["stop"]) == (ticket, stop)]
|
|
125
|
+
return {name: value for one in fields for name, value in one.items()} if all(fields) else {}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def apply(questions: list[dict], answers: dict) -> tuple[list[str], dict[str, dict]]:
|
|
129
|
+
"""Answer the open questions in `answers`. Returns (a ruling per answer, ticket -> fields that let it go on)."""
|
|
130
|
+
rulings, stops = [], {}
|
|
131
|
+
for q in questions:
|
|
132
|
+
if q["answer"] is None and q["id"] in answers:
|
|
133
|
+
q["answer"] = answers[q["id"]]
|
|
134
|
+
note = answers.get(f"{q['id']}.note")
|
|
135
|
+
rulings.append(KINDS[q["kind"]].options[q["answer"]].ruling.format(**q)
|
|
136
|
+
+ f" Note of the operator: {note}" * bool(note))
|
|
137
|
+
stops[q["ticket"]] = q["stop"]
|
|
138
|
+
moved = {ticket: _settled(questions, ticket, stop) for ticket, stop in stops.items()}
|
|
139
|
+
return rulings, {ticket: fields for ticket, fields in moved.items() if fields}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def text(open_questions: list[dict], answers: str, page: str) -> str:
|
|
143
|
+
"""What the operator reads at a stop in local mode."""
|
|
144
|
+
lines = []
|
|
145
|
+
for q in open_questions:
|
|
146
|
+
lines += [f"Question {q['id']}: {q['title']}", f" Why: {q['why']}", f" Evidence: {', '.join(q['evidence'])}"]
|
|
147
|
+
lines += [f" {o['id']}{' (recommended)' * (o['id'] == q['recommended'])}: {o['description']}"
|
|
148
|
+
for o in q["options"]]
|
|
149
|
+
if lines:
|
|
150
|
+
lines.append(f"Answer each question and run the driver again: --answer <question id>=<option id>, or submit "
|
|
151
|
+
f"{page}, which writes {answers}.")
|
|
152
|
+
return "".join(line + "\n" for line in lines)
|