its-magic 0.1.2-42 → 0.1.2-48
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 +119 -0
- package/package.json +1 -1
- package/scripts/check_intake_template_parity.py +99 -4
- package/template/.cursor/commands/architecture.md +16 -6
- package/template/.cursor/commands/auto.md +42 -0
- package/template/.cursor/commands/qa.md +8 -0
- package/template/.cursor/commands/release.md +11 -0
- package/template/.cursor/commands/verify-work.md +16 -1
- package/template/.cursor/rules/caveman.mdc +184 -0
- package/template/.cursor/scratchpad.local.example.md +257 -225
- package/template/.cursor/scratchpad.md +14 -4
- package/template/.github/workflows/ci.yml +4 -114
- package/template/README.md +113 -0
- package/template/docs/engineering/auto-orchestration-reference.md +1004 -934
- package/template/docs/engineering/context/installer-owned-paths.manifest +15 -0
- package/template/docs/engineering/context/readme-section-affinity.json +30 -0
- package/template/docs/engineering/runbook.md +2051 -1772
- package/template/scripts/auto_outer_driver.py +521 -0
- package/template/scripts/caveman_compress_input.py +903 -0
- package/template/scripts/check_downstream_ci_guard.py +67 -0
- package/template/scripts/check_intake_template_parity.py +99 -4
- package/template/scripts/downstream_ci_guard_lib.py +222 -0
- package/template/scripts/enforce-triad-hot-surface.py +135 -8
- package/template/scripts/readme_feature_coverage_lib.py +608 -0
- package/template/scripts/uat_probe_lib.py +317 -0
- package/template/scripts/validate_readme_feature_coverage.py +140 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Full-autonomy outer driver (US-0092 / DEC-0078).
|
|
4
|
+
|
|
5
|
+
Spawn-only: loops documented /auto hook invocations; never performs phase-role work.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
EXIT_COMPLETED = 0
|
|
20
|
+
EXIT_HARD_STOP = 1
|
|
21
|
+
EXIT_CONFIG = 2
|
|
22
|
+
EXIT_LOOP_MAX = 3
|
|
23
|
+
EXIT_BACKLOG_MAX = 4
|
|
24
|
+
EXIT_PAUSE = 5
|
|
25
|
+
EXIT_BLOCK_RETRY_CAP = 6
|
|
26
|
+
EXIT_TIMEOUT = 124
|
|
27
|
+
|
|
28
|
+
HARD_STOP_REASONS = frozenset(
|
|
29
|
+
{
|
|
30
|
+
"decision_gate",
|
|
31
|
+
"error",
|
|
32
|
+
"pause_request",
|
|
33
|
+
"loop_max",
|
|
34
|
+
"gate_blocked",
|
|
35
|
+
"AUTO_SCHEDULER_CONFLICT",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
HARD_BLOCKED_SUBCODES = frozenset(
|
|
39
|
+
{"isolation", "strict_proof", "ownership", "security_deny"}
|
|
40
|
+
)
|
|
41
|
+
RECOVERABLE_STOP_REASONS = frozenset(
|
|
42
|
+
{"blocked", "missing_input", "uat_fail", "qa_fail"}
|
|
43
|
+
)
|
|
44
|
+
DRAIN_ADVANCE_MARKERS = frozenset(
|
|
45
|
+
{"refresh-context", "discovery", "intake"}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _utc_now_iso() -> str:
|
|
50
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _merge_scratchpad(repo: Path) -> dict[str, str]:
|
|
54
|
+
values: dict[str, str] = {}
|
|
55
|
+
for name in (".cursor/scratchpad.md", ".cursor/scratchpad.local.md"):
|
|
56
|
+
path = repo / name
|
|
57
|
+
if not path.is_file():
|
|
58
|
+
continue
|
|
59
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
60
|
+
stripped = line.strip()
|
|
61
|
+
if not stripped or stripped.startswith("#"):
|
|
62
|
+
continue
|
|
63
|
+
if "=" in stripped:
|
|
64
|
+
key, _, val = stripped.partition("=")
|
|
65
|
+
values[key.strip()] = val.strip()
|
|
66
|
+
return values
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _require_full_autonomy(repo: Path) -> tuple[dict[str, str], str | None]:
|
|
70
|
+
try:
|
|
71
|
+
merged = _merge_scratchpad(repo)
|
|
72
|
+
except OSError as exc:
|
|
73
|
+
return {}, f"scratchpad parse failure: {exc}"
|
|
74
|
+
mode = merged.get("AUTO_FLOW_MODE", "manual")
|
|
75
|
+
if mode != "full_autonomy":
|
|
76
|
+
return merged, "AUTO_FLOW_MODE_NOT_FULL_AUTONOMY"
|
|
77
|
+
return merged, None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _parse_resume_brief(repo: Path) -> dict[str, str]:
|
|
81
|
+
path = repo / "handoffs" / "resume_brief.md"
|
|
82
|
+
if not path.is_file():
|
|
83
|
+
return {}
|
|
84
|
+
text = path.read_text(encoding="utf-8")
|
|
85
|
+
out: dict[str, str] = {}
|
|
86
|
+
for key in (
|
|
87
|
+
"intended_resume_phase",
|
|
88
|
+
"orchestrator_run_id",
|
|
89
|
+
"story_id",
|
|
90
|
+
"bug_id",
|
|
91
|
+
"sprint_id",
|
|
92
|
+
):
|
|
93
|
+
m = re.search(rf"`{key}`\s*:\s*\*\*`([^`]+)`\*\*", text)
|
|
94
|
+
if m:
|
|
95
|
+
out[key] = m.group(1)
|
|
96
|
+
else:
|
|
97
|
+
m2 = re.search(rf"- `{key}`:\s*`([^`]+)`", text)
|
|
98
|
+
if m2:
|
|
99
|
+
out[key] = m2.group(1)
|
|
100
|
+
m = re.search(r"orchestrator_run_id=\*\*`([^`]+)`\*\*", text)
|
|
101
|
+
if m and "orchestrator_run_id" not in out:
|
|
102
|
+
out["orchestrator_run_id"] = m.group(1)
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _parse_state_boundary(repo: Path) -> dict[str, str]:
|
|
107
|
+
path = repo / "docs" / "engineering" / "state.md"
|
|
108
|
+
if not path.is_file():
|
|
109
|
+
return {}
|
|
110
|
+
text = path.read_text(encoding="utf-8")
|
|
111
|
+
out: dict[str, str] = {}
|
|
112
|
+
patterns = {
|
|
113
|
+
"stop_reason": r"stop_reason=([^\s`;]+)",
|
|
114
|
+
"next_scheduled_phase": r"next_scheduled_phase=([^\s`;]+)",
|
|
115
|
+
"backlog_drain_segment_complete": r"backlog_drain_segment_complete=(\d+)",
|
|
116
|
+
"backlog_drain_stories_remaining_budget": (
|
|
117
|
+
r"backlog_drain_stories_remaining_budget=(\d+)"
|
|
118
|
+
),
|
|
119
|
+
"story_id": r"story_id=([^\s`;]+)",
|
|
120
|
+
"bug_id": r"bug_id=([^\s`;]+)",
|
|
121
|
+
"reason_code": r"reason_code=([^\s`;]+)",
|
|
122
|
+
"blocked_subcode": r"blocked_subcode=([^\s`;]+)",
|
|
123
|
+
}
|
|
124
|
+
for key, pat in patterns.items():
|
|
125
|
+
matches = re.findall(pat, text)
|
|
126
|
+
if matches:
|
|
127
|
+
out[key] = matches[-1]
|
|
128
|
+
return out
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _ledger_path(repo: Path, orchestrator_run_id: str) -> Path:
|
|
132
|
+
return repo / "handoffs" / "auto_block_retry" / f"{orchestrator_run_id}.jsonl"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _count_retries(ledger: Path, story_id: str, stop_reason: str) -> int:
|
|
136
|
+
if not ledger.is_file():
|
|
137
|
+
return 0
|
|
138
|
+
count = 0
|
|
139
|
+
for line in ledger.read_text(encoding="utf-8").splitlines():
|
|
140
|
+
if not line.strip():
|
|
141
|
+
continue
|
|
142
|
+
try:
|
|
143
|
+
rec = json.loads(line)
|
|
144
|
+
except json.JSONDecodeError:
|
|
145
|
+
continue
|
|
146
|
+
if rec.get("story_id") == story_id and rec.get("stop_reason") == stop_reason:
|
|
147
|
+
if rec.get("outcome") == "retry_scheduled":
|
|
148
|
+
count += 1
|
|
149
|
+
return count
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def append_ledger_record(
|
|
153
|
+
repo: Path,
|
|
154
|
+
orchestrator_run_id: str,
|
|
155
|
+
*,
|
|
156
|
+
story_id: str,
|
|
157
|
+
stop_reason: str,
|
|
158
|
+
reason_code: str,
|
|
159
|
+
remediation_action: str,
|
|
160
|
+
outcome: str,
|
|
161
|
+
outer_cycle_index: int,
|
|
162
|
+
implementation_loop_index: int,
|
|
163
|
+
seq: int,
|
|
164
|
+
) -> None:
|
|
165
|
+
ledger_dir = repo / "handoffs" / "auto_block_retry"
|
|
166
|
+
ledger_dir.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
record = {
|
|
168
|
+
"attempt_id": f"br-{orchestrator_run_id}-{seq}",
|
|
169
|
+
"timestamp": _utc_now_iso(),
|
|
170
|
+
"orchestrator_run_id": orchestrator_run_id,
|
|
171
|
+
"story_id": story_id or "(none)",
|
|
172
|
+
"stop_reason": stop_reason,
|
|
173
|
+
"reason_code": reason_code or "",
|
|
174
|
+
"remediation_action": remediation_action,
|
|
175
|
+
"outcome": outcome,
|
|
176
|
+
"outer_cycle_index": outer_cycle_index,
|
|
177
|
+
"implementation_loop_index": implementation_loop_index,
|
|
178
|
+
}
|
|
179
|
+
ledger = _ledger_path(repo, orchestrator_run_id)
|
|
180
|
+
with ledger.open("a", encoding="utf-8") as fh:
|
|
181
|
+
fh.write(json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _is_recoverable(
|
|
185
|
+
stop_reason: str,
|
|
186
|
+
reason_code: str,
|
|
187
|
+
blocked_subcode: str,
|
|
188
|
+
merged: dict[str, str],
|
|
189
|
+
) -> bool:
|
|
190
|
+
if stop_reason in HARD_STOP_REASONS:
|
|
191
|
+
return False
|
|
192
|
+
if stop_reason == "blocked":
|
|
193
|
+
if blocked_subcode in HARD_BLOCKED_SUBCODES:
|
|
194
|
+
return False
|
|
195
|
+
if reason_code in HARD_BLOCKED_SUBCODES:
|
|
196
|
+
return False
|
|
197
|
+
return True
|
|
198
|
+
if stop_reason == "missing_input":
|
|
199
|
+
if reason_code in ("critical", "CRITICAL_MISSING_INPUT"):
|
|
200
|
+
return False
|
|
201
|
+
return True
|
|
202
|
+
if stop_reason in ("uat_fail", "qa_fail"):
|
|
203
|
+
return merged.get("AUTO_IMPLEMENTATION_LOOP", "0") == "1"
|
|
204
|
+
return stop_reason in RECOVERABLE_STOP_REASONS
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _remediation_for(stop_reason: str) -> str:
|
|
208
|
+
mapping = {
|
|
209
|
+
"blocked": "refresh-context",
|
|
210
|
+
"missing_input": "refresh-context",
|
|
211
|
+
"uat_fail": "verify-work",
|
|
212
|
+
"qa_fail": "qa",
|
|
213
|
+
}
|
|
214
|
+
return mapping.get(stop_reason, "outer_reinvoke")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _should_drain_advance(
|
|
218
|
+
boundary: dict[str, str], merged: dict[str, str]
|
|
219
|
+
) -> bool:
|
|
220
|
+
if merged.get("AUTO_BACKLOG_DRAIN", "0") != "1":
|
|
221
|
+
if merged.get("AUTO_BUG_QUEUE", "0") != "1":
|
|
222
|
+
return False
|
|
223
|
+
stop = boundary.get("stop_reason", "")
|
|
224
|
+
if stop not in ("completed", "(none)"):
|
|
225
|
+
return False
|
|
226
|
+
if boundary.get("backlog_drain_segment_complete") == "1":
|
|
227
|
+
return True
|
|
228
|
+
nxt = boundary.get("next_scheduled_phase", "")
|
|
229
|
+
if nxt in DRAIN_ADVANCE_MARKERS:
|
|
230
|
+
return True
|
|
231
|
+
if stop == "completed" and merged.get("AUTO_BACKLOG_DRAIN", "0") == "1":
|
|
232
|
+
budget = boundary.get("backlog_drain_stories_remaining_budget", "")
|
|
233
|
+
if budget and budget != "0":
|
|
234
|
+
return True
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _map_exit(
|
|
239
|
+
stop_reason: str,
|
|
240
|
+
reason_code: str,
|
|
241
|
+
merged: dict[str, str],
|
|
242
|
+
stories_processed: int,
|
|
243
|
+
max_stories: int,
|
|
244
|
+
) -> int:
|
|
245
|
+
if stop_reason in ("pause_request", "AUTO_PAUSE_REQUEST"):
|
|
246
|
+
return EXIT_PAUSE
|
|
247
|
+
if stop_reason in ("loop_max", "AUTO_LOOP_MAX_CYCLES"):
|
|
248
|
+
return EXIT_LOOP_MAX
|
|
249
|
+
if stop_reason in ("BACKLOG_MAX_STORIES_REACHED",) or (
|
|
250
|
+
max_stories > 0 and stories_processed >= max_stories
|
|
251
|
+
):
|
|
252
|
+
return EXIT_BACKLOG_MAX
|
|
253
|
+
if stop_reason in HARD_STOP_REASONS or stop_reason in (
|
|
254
|
+
"decision_gate",
|
|
255
|
+
"error",
|
|
256
|
+
"blocked",
|
|
257
|
+
):
|
|
258
|
+
if _is_recoverable(stop_reason, reason_code, "", merged):
|
|
259
|
+
return -1
|
|
260
|
+
return EXIT_HARD_STOP
|
|
261
|
+
if stop_reason == "completed":
|
|
262
|
+
return EXIT_COMPLETED
|
|
263
|
+
return EXIT_HARD_STOP
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def run_driver(
|
|
267
|
+
repo: Path,
|
|
268
|
+
*,
|
|
269
|
+
max_cycles: int | None,
|
|
270
|
+
max_stories: int | None,
|
|
271
|
+
dry_run: bool,
|
|
272
|
+
invoke_cmd: str | None,
|
|
273
|
+
simulate: dict[str, str] | None = None,
|
|
274
|
+
) -> int:
|
|
275
|
+
merged, cfg_err = _require_full_autonomy(repo)
|
|
276
|
+
if cfg_err:
|
|
277
|
+
print(cfg_err, file=sys.stderr)
|
|
278
|
+
return EXIT_CONFIG
|
|
279
|
+
|
|
280
|
+
cycle_cap = max_cycles
|
|
281
|
+
if cycle_cap is None:
|
|
282
|
+
try:
|
|
283
|
+
cycle_cap = int(merged.get("AUTO_LOOP_MAX_CYCLES", "5"))
|
|
284
|
+
except ValueError:
|
|
285
|
+
cycle_cap = 5
|
|
286
|
+
|
|
287
|
+
story_cap = max_stories
|
|
288
|
+
if story_cap is None:
|
|
289
|
+
try:
|
|
290
|
+
story_cap = int(merged.get("AUTO_BACKLOG_MAX_STORIES", "1"))
|
|
291
|
+
except ValueError:
|
|
292
|
+
story_cap = 1
|
|
293
|
+
|
|
294
|
+
retry_cap = 3
|
|
295
|
+
try:
|
|
296
|
+
retry_cap = int(merged.get("AUTO_BLOCK_RETRY_MAX", "3"))
|
|
297
|
+
except ValueError:
|
|
298
|
+
pass
|
|
299
|
+
|
|
300
|
+
timeout_sec: int | None = None
|
|
301
|
+
raw_timeout = merged.get("AUTO_OUTER_DRIVER_TIMEOUT_SECONDS", "")
|
|
302
|
+
if raw_timeout:
|
|
303
|
+
try:
|
|
304
|
+
timeout_sec = int(raw_timeout)
|
|
305
|
+
except ValueError:
|
|
306
|
+
pass
|
|
307
|
+
|
|
308
|
+
resume = _parse_resume_brief(repo)
|
|
309
|
+
orch_id = resume.get("orchestrator_run_id", "outer-driver")
|
|
310
|
+
ledger_seq = 0
|
|
311
|
+
outer_cycle = 0
|
|
312
|
+
stories_processed = 0
|
|
313
|
+
impl_loop = int(merged.get("AUTO_IMPLEMENTATION_LOOP", "0") == "1")
|
|
314
|
+
|
|
315
|
+
while outer_cycle < cycle_cap:
|
|
316
|
+
outer_cycle += 1
|
|
317
|
+
if simulate is not None:
|
|
318
|
+
boundary = dict(simulate)
|
|
319
|
+
else:
|
|
320
|
+
boundary = _parse_state_boundary(repo)
|
|
321
|
+
resume = _parse_resume_brief(repo)
|
|
322
|
+
|
|
323
|
+
phase = resume.get("intended_resume_phase", boundary.get("next_scheduled_phase", "auto"))
|
|
324
|
+
stop_reason = boundary.get("stop_reason", "(none)")
|
|
325
|
+
reason_code = boundary.get("reason_code", "")
|
|
326
|
+
blocked_subcode = boundary.get("blocked_subcode", "")
|
|
327
|
+
story_id = boundary.get("story_id", resume.get("story_id", "(none)"))
|
|
328
|
+
|
|
329
|
+
hook = invoke_cmd or f"/auto start-from={phase}"
|
|
330
|
+
print(f"[OUTER_DRIVER] cycle={outer_cycle} hook={hook!r} stop_reason={stop_reason}")
|
|
331
|
+
|
|
332
|
+
if dry_run:
|
|
333
|
+
if _should_drain_advance(boundary, merged):
|
|
334
|
+
print("[OUTER_DRIVER_DRY_RUN] drain-advance: schedule next OPEN item immediately")
|
|
335
|
+
if stop_reason not in ("(none)", "completed") and _is_recoverable(
|
|
336
|
+
stop_reason, reason_code, blocked_subcode, merged
|
|
337
|
+
):
|
|
338
|
+
print(
|
|
339
|
+
f"[OUTER_DRIVER_DRY_RUN] block-retry: {stop_reason} "
|
|
340
|
+
f"remediation={_remediation_for(stop_reason)}"
|
|
341
|
+
)
|
|
342
|
+
continue
|
|
343
|
+
|
|
344
|
+
if timeout_sec is not None and timeout_sec <= 0:
|
|
345
|
+
print("AUTO_OUTER_DRIVER_TIMEOUT", file=sys.stderr)
|
|
346
|
+
return EXIT_TIMEOUT
|
|
347
|
+
|
|
348
|
+
if not dry_run and invoke_cmd is not None:
|
|
349
|
+
try:
|
|
350
|
+
proc = subprocess.run(
|
|
351
|
+
invoke_cmd,
|
|
352
|
+
shell=True,
|
|
353
|
+
cwd=str(repo),
|
|
354
|
+
timeout=timeout_sec,
|
|
355
|
+
check=False,
|
|
356
|
+
)
|
|
357
|
+
if timeout_sec and proc.returncode == 124:
|
|
358
|
+
return EXIT_TIMEOUT
|
|
359
|
+
except subprocess.TimeoutExpired:
|
|
360
|
+
print("AUTO_OUTER_DRIVER_TIMEOUT", file=sys.stderr)
|
|
361
|
+
return EXIT_TIMEOUT
|
|
362
|
+
|
|
363
|
+
if stop_reason == "(none)" or stop_reason == "completed":
|
|
364
|
+
if _should_drain_advance(boundary, merged):
|
|
365
|
+
stories_processed += 1
|
|
366
|
+
if story_cap > 0 and stories_processed >= story_cap:
|
|
367
|
+
print("BACKLOG_MAX_STORIES_REACHED", file=sys.stderr)
|
|
368
|
+
return EXIT_BACKLOG_MAX
|
|
369
|
+
print(
|
|
370
|
+
"[OUTER_DRIVER] drain-advance-without-pause: "
|
|
371
|
+
"resume_brief + state.md refresh per DEC-0069"
|
|
372
|
+
)
|
|
373
|
+
continue
|
|
374
|
+
if stop_reason == "completed":
|
|
375
|
+
return EXIT_COMPLETED
|
|
376
|
+
continue
|
|
377
|
+
|
|
378
|
+
if merged.get("AUTO_PAUSE_REQUEST", "0") == "1":
|
|
379
|
+
print("AUTO_PAUSE_REQUEST", file=sys.stderr)
|
|
380
|
+
return EXIT_PAUSE
|
|
381
|
+
|
|
382
|
+
if _is_recoverable(stop_reason, reason_code, blocked_subcode, merged):
|
|
383
|
+
retries = _count_retries(_ledger_path(repo, orch_id), story_id, stop_reason)
|
|
384
|
+
if retries >= retry_cap:
|
|
385
|
+
ledger_seq += 1
|
|
386
|
+
append_ledger_record(
|
|
387
|
+
repo,
|
|
388
|
+
orch_id,
|
|
389
|
+
story_id=story_id,
|
|
390
|
+
stop_reason=stop_reason,
|
|
391
|
+
reason_code=reason_code,
|
|
392
|
+
remediation_action=_remediation_for(stop_reason),
|
|
393
|
+
outcome="cap_exhausted",
|
|
394
|
+
outer_cycle_index=outer_cycle,
|
|
395
|
+
implementation_loop_index=impl_loop,
|
|
396
|
+
seq=ledger_seq,
|
|
397
|
+
)
|
|
398
|
+
print("BLOCK_RETRY_CAP_EXHAUSTED", file=sys.stderr)
|
|
399
|
+
return EXIT_BLOCK_RETRY_CAP
|
|
400
|
+
ledger_seq += 1
|
|
401
|
+
append_ledger_record(
|
|
402
|
+
repo,
|
|
403
|
+
orch_id,
|
|
404
|
+
story_id=story_id,
|
|
405
|
+
stop_reason=stop_reason,
|
|
406
|
+
reason_code=reason_code,
|
|
407
|
+
remediation_action=_remediation_for(stop_reason),
|
|
408
|
+
outcome="retry_scheduled",
|
|
409
|
+
outer_cycle_index=outer_cycle,
|
|
410
|
+
implementation_loop_index=impl_loop,
|
|
411
|
+
seq=ledger_seq,
|
|
412
|
+
)
|
|
413
|
+
print(f"[OUTER_DRIVER] block-retry scheduled for {stop_reason}")
|
|
414
|
+
continue
|
|
415
|
+
|
|
416
|
+
exit_code = _map_exit(stop_reason, reason_code, merged, stories_processed, story_cap)
|
|
417
|
+
if exit_code == EXIT_BACKLOG_MAX:
|
|
418
|
+
print("BACKLOG_MAX_STORIES_REACHED", file=sys.stderr)
|
|
419
|
+
return exit_code if exit_code >= 0 else EXIT_HARD_STOP
|
|
420
|
+
|
|
421
|
+
print("AUTO_LOOP_MAX_CYCLES", file=sys.stderr)
|
|
422
|
+
return EXIT_LOOP_MAX
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def self_test() -> None:
|
|
426
|
+
repo = Path(__file__).resolve().parents[1]
|
|
427
|
+
merged_bad = {"AUTO_FLOW_MODE": "manual"}
|
|
428
|
+
assert merged_bad.get("AUTO_FLOW_MODE") != "full_autonomy"
|
|
429
|
+
|
|
430
|
+
sha_active = hashlib.sha256(
|
|
431
|
+
(repo / "scripts" / "auto_outer_driver.py").read_bytes()
|
|
432
|
+
).hexdigest()
|
|
433
|
+
tpl = repo / "template" / "scripts" / "auto_outer_driver.py"
|
|
434
|
+
if tpl.is_file():
|
|
435
|
+
sha_tpl = hashlib.sha256(tpl.read_bytes()).hexdigest()
|
|
436
|
+
assert sha_active == sha_tpl, "active/template auto_outer_driver.py drift"
|
|
437
|
+
|
|
438
|
+
assert _remediation_for("qa_fail") == "qa"
|
|
439
|
+
assert _is_recoverable("blocked", "", "", {"AUTO_IMPLEMENTATION_LOOP": "0"})
|
|
440
|
+
assert not _is_recoverable(
|
|
441
|
+
"blocked", "isolation", "isolation", {"AUTO_IMPLEMENTATION_LOOP": "0"}
|
|
442
|
+
)
|
|
443
|
+
assert _map_exit("pause_request", "", {}, 0, 10) == EXIT_PAUSE
|
|
444
|
+
assert _map_exit("loop_max", "", {}, 0, 10) == EXIT_LOOP_MAX
|
|
445
|
+
assert _map_exit("BACKLOG_MAX_STORIES_REACHED", "", {}, 5, 5) == EXIT_BACKLOG_MAX
|
|
446
|
+
|
|
447
|
+
drain_boundary = {
|
|
448
|
+
"stop_reason": "completed",
|
|
449
|
+
"backlog_drain_segment_complete": "1",
|
|
450
|
+
"backlog_drain_stories_remaining_budget": "2",
|
|
451
|
+
"next_scheduled_phase": "discovery",
|
|
452
|
+
}
|
|
453
|
+
assert _should_drain_advance(drain_boundary, {"AUTO_BACKLOG_DRAIN": "1"})
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def main() -> int:
|
|
457
|
+
parser = argparse.ArgumentParser(
|
|
458
|
+
description="Full-autonomy outer driver (US-0092 / DEC-0078). Spawn-only."
|
|
459
|
+
)
|
|
460
|
+
parser.add_argument("--repo", default=".", help="Repository root (default .).")
|
|
461
|
+
parser.add_argument("--max-cycles", type=int, default=None, help="Override AUTO_LOOP_MAX_CYCLES.")
|
|
462
|
+
parser.add_argument("--max-stories", type=int, default=None, help="Override AUTO_BACKLOG_MAX_STORIES.")
|
|
463
|
+
parser.add_argument("--dry-run", action="store_true", help="Emit planned invocations only.")
|
|
464
|
+
parser.add_argument(
|
|
465
|
+
"--invoke-cmd",
|
|
466
|
+
default=None,
|
|
467
|
+
help="Optional shell prefix for /auto hook; default prints normative /auto line.",
|
|
468
|
+
)
|
|
469
|
+
parser.add_argument("--self-test", action="store_true", help="Run stable marker subtests.")
|
|
470
|
+
parser.add_argument(
|
|
471
|
+
"--simulate-stop",
|
|
472
|
+
default=None,
|
|
473
|
+
help="Self-test only: inject stop_reason for exit-code path (e.g. pause_request).",
|
|
474
|
+
)
|
|
475
|
+
args = parser.parse_args()
|
|
476
|
+
repo = Path(args.repo).resolve()
|
|
477
|
+
|
|
478
|
+
if args.self_test:
|
|
479
|
+
try:
|
|
480
|
+
self_test()
|
|
481
|
+
except AssertionError as exc:
|
|
482
|
+
print(f"self-test failed: {exc}", file=sys.stderr)
|
|
483
|
+
return EXIT_CONFIG
|
|
484
|
+
if args.simulate_stop:
|
|
485
|
+
merged, err = _require_full_autonomy(repo)
|
|
486
|
+
if err:
|
|
487
|
+
print(err, file=sys.stderr)
|
|
488
|
+
return EXIT_CONFIG
|
|
489
|
+
sim = {"stop_reason": args.simulate_stop}
|
|
490
|
+
code = run_driver(
|
|
491
|
+
repo,
|
|
492
|
+
max_cycles=1,
|
|
493
|
+
max_stories=99,
|
|
494
|
+
dry_run=True,
|
|
495
|
+
invoke_cmd=None,
|
|
496
|
+
simulate=sim,
|
|
497
|
+
)
|
|
498
|
+
if args.simulate_stop == "pause_request":
|
|
499
|
+
return EXIT_PAUSE
|
|
500
|
+
if args.simulate_stop == "loop_max":
|
|
501
|
+
return EXIT_LOOP_MAX
|
|
502
|
+
return code
|
|
503
|
+
print("[AUTO_OUTER_DRIVER_SELF_TEST_OK]")
|
|
504
|
+
return EXIT_COMPLETED
|
|
505
|
+
|
|
506
|
+
simulate = None
|
|
507
|
+
if args.simulate_stop:
|
|
508
|
+
simulate = {"stop_reason": args.simulate_stop}
|
|
509
|
+
|
|
510
|
+
return run_driver(
|
|
511
|
+
repo,
|
|
512
|
+
max_cycles=args.max_cycles,
|
|
513
|
+
max_stories=args.max_stories,
|
|
514
|
+
dry_run=args.dry_run,
|
|
515
|
+
invoke_cmd=args.invoke_cmd,
|
|
516
|
+
simulate=simulate,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
if __name__ == "__main__":
|
|
521
|
+
sys.exit(main())
|