@try-works/dsh-recursive-mode 0.1.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/cordis.patch.yml +12 -0
- package/lib/bootstrap.d.ts +35 -0
- package/lib/client/board.d.ts +10 -0
- package/lib/client/contract.d.ts +51 -0
- package/lib/client/derive.d.ts +92 -0
- package/lib/client/index.d.ts +21 -0
- package/lib/client/inspector.d.ts +10 -0
- package/lib/client/node.d.ts +71 -0
- package/lib/client/settings.d.ts +6 -0
- package/lib/client/slots.d.ts +7 -0
- package/lib/client/strip.d.ts +7 -0
- package/lib/client.d.ts +10 -0
- package/lib/client.js +490 -0
- package/lib/closeout.d.ts +23 -0
- package/lib/commands.d.ts +51 -0
- package/lib/delegation.d.ts +92 -0
- package/lib/enforcement.d.ts +53 -0
- package/lib/events.d.ts +173 -0
- package/lib/handoff.d.ts +51 -0
- package/lib/index.d.ts +40 -0
- package/lib/lifecycle.d.ts +107 -0
- package/lib/lock.d.ts +92 -0
- package/lib/policy.d.ts +12 -0
- package/lib/projection.d.ts +29 -0
- package/lib/recursive_closeout.tool.d.ts +8 -0
- package/lib/recursive_init.tool.d.ts +2 -0
- package/lib/recursive_lint.tool.d.ts +2 -0
- package/lib/recursive_lock.tool.d.ts +2 -0
- package/lib/recursive_scratch.tool.d.ts +7 -0
- package/lib/recursive_status.tool.d.ts +2 -0
- package/lib/review.d.ts +39 -0
- package/lib/router.d.ts +77 -0
- package/lib/run.d.ts +29 -0
- package/lib/runtime.d.ts +241 -0
- package/lib/scratch.d.ts +18 -0
- package/lib/status.d.ts +19 -0
- package/lib/types.d.ts +104 -0
- package/lib/workspace.d.ts +50 -0
- package/package.json +119 -0
- package/preset/recursive/agent.cordis.yml +282 -0
- package/preset/recursive/preset.yml +3 -0
- package/scripts/install-recursive-mode.ps1 +956 -0
- package/scripts/install-recursive-mode.py +750 -0
- package/scripts/lint-recursive-run.py +2868 -0
- package/scripts/recursive-closeout.py +541 -0
- package/scripts/recursive-init.py +356 -0
- package/scripts/recursive-lock.py +302 -0
- package/scripts/recursive-status.py +2124 -0
- package/scripts/recursive_phase_rules.py +367 -0
- package/scripts/recursive_router_lib.py +2282 -0
- package/scripts/test-recursive-mode-smoke.ts +204 -0
- package/scripts/verify-locks.py +353 -0
- package/src/bootstrap.ts +118 -0
- package/src/client/board.tsx +61 -0
- package/src/client/contract.ts +58 -0
- package/src/client/derive.ts +241 -0
- package/src/client/index.ts +28 -0
- package/src/client/inspector.tsx +49 -0
- package/src/client/node.ts +156 -0
- package/src/client/settings.tsx +18 -0
- package/src/client/slots.ts +67 -0
- package/src/client/strip.tsx +28 -0
- package/src/client.ts +11 -0
- package/src/closeout.ts +183 -0
- package/src/commands.ts +142 -0
- package/src/delegation.ts +306 -0
- package/src/enforcement.ts +180 -0
- package/src/events.ts +173 -0
- package/src/handoff.ts +165 -0
- package/src/index.ts +283 -0
- package/src/lifecycle.ts +235 -0
- package/src/lock.ts +369 -0
- package/src/policy.ts +56 -0
- package/src/projection.ts +237 -0
- package/src/recursive_closeout.tool.ts +35 -0
- package/src/recursive_init.tool.ts +28 -0
- package/src/recursive_lint.tool.ts +29 -0
- package/src/recursive_lock.tool.ts +33 -0
- package/src/recursive_scratch.tool.ts +42 -0
- package/src/recursive_status.tool.ts +24 -0
- package/src/review.ts +178 -0
- package/src/router.ts +197 -0
- package/src/run.ts +85 -0
- package/src/runtime.ts +564 -0
- package/src/scratch.ts +85 -0
- package/src/status.ts +194 -0
- package/src/types.ts +112 -0
- package/src/workspace.ts +67 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Shared phase-ordering rules, prerequisite enforcement, and lock-receipt helpers
|
|
4
|
+
for recursive-mode.
|
|
5
|
+
|
|
6
|
+
All gate-related scripts import this module so that phase sequencing, prerequisite
|
|
7
|
+
checks, and receipt I/O follow a single canonical model.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Canonical phase sequence
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
PHASE_SEQUENCE: list[str] = [
|
|
23
|
+
"00-requirements.md",
|
|
24
|
+
"00-worktree.md",
|
|
25
|
+
"01-as-is.md",
|
|
26
|
+
"01.5-root-cause.md",
|
|
27
|
+
"02-to-be-plan.md",
|
|
28
|
+
"03-implementation-summary.md",
|
|
29
|
+
"03.5-code-review.md",
|
|
30
|
+
"04-test-summary.md",
|
|
31
|
+
"05-manual-qa.md",
|
|
32
|
+
"06-decisions-update.md",
|
|
33
|
+
"07-state-update.md",
|
|
34
|
+
"08-memory-impact.md",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
OPTIONAL_PHASES: frozenset[str] = frozenset(
|
|
38
|
+
{
|
|
39
|
+
"01-as-is.md",
|
|
40
|
+
"01.5-root-cause.md",
|
|
41
|
+
"02-to-be-plan.md",
|
|
42
|
+
"03-implementation-summary.md",
|
|
43
|
+
"03.5-code-review.md",
|
|
44
|
+
"04-test-summary.md",
|
|
45
|
+
"05-manual-qa.md",
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
MANDATORY_PHASES: frozenset[str] = frozenset(set(PHASE_SEQUENCE) - OPTIONAL_PHASES)
|
|
50
|
+
|
|
51
|
+
# Lock receipts live in this subdirectory of the run directory.
|
|
52
|
+
LOCKS_SUBDIR = "locks"
|
|
53
|
+
RECEIPT_SUFFIX = ".receipt.json"
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Lock-hash helpers (kept here so callers don't need to duplicate them)
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
_LOCK_HASH_LINE_RE = re.compile(r"(?m)^[ \t]*LockHash:.*(?:\n|$)")
|
|
60
|
+
_STATUS_RE = re.compile(r'(?m)^[ \t]*Status:\s*(?:`|")?(\w+)(?:`|")?\s*$')
|
|
61
|
+
_LOCK_HASH_RE = re.compile(r'(?m)^[ \t]*LockHash:\s*(?:`|")?([a-fA-F0-9]{64})(?:`|")?\s*$')
|
|
62
|
+
_LOCKED_AT_RE = re.compile(r'(?m)^[ \t]*LockedAt:\s*(?:`|")?([^`"\r\n]+)(?:`|")?\s*$')
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def normalize_for_lock_hash(content: str) -> str:
|
|
66
|
+
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
|
|
67
|
+
return _LOCK_HASH_LINE_RE.sub("", normalized)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def lock_hash_from_content(content: str) -> str:
|
|
71
|
+
normalized = normalize_for_lock_hash(content)
|
|
72
|
+
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Phase index helpers
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def phase_index(artifact_file: str) -> int:
|
|
81
|
+
"""Return the position of an artifact in PHASE_SEQUENCE, or -1 if not found."""
|
|
82
|
+
try:
|
|
83
|
+
return PHASE_SEQUENCE.index(artifact_file)
|
|
84
|
+
except ValueError:
|
|
85
|
+
return -1
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def is_core_artifact(artifact_file: str) -> bool:
|
|
89
|
+
"""Return True if artifact_file is a known core phase artifact."""
|
|
90
|
+
return artifact_file in PHASE_SEQUENCE
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Lock-validity helpers
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_lock_status(artifact_path: Path) -> str:
|
|
99
|
+
"""
|
|
100
|
+
Return one of:
|
|
101
|
+
"MISSING" – file does not exist
|
|
102
|
+
"DRAFT" – file exists but Status is not LOCKED
|
|
103
|
+
"STALE_LOCK" – Status is LOCKED but hash or metadata is invalid
|
|
104
|
+
"LOCKED" – Status is LOCKED and hash is valid
|
|
105
|
+
"""
|
|
106
|
+
if not artifact_path.exists():
|
|
107
|
+
return "MISSING"
|
|
108
|
+
content = artifact_path.read_text(encoding="utf-8")
|
|
109
|
+
status_match = _STATUS_RE.search(content)
|
|
110
|
+
if not status_match or status_match.group(1) != "LOCKED":
|
|
111
|
+
return "DRAFT"
|
|
112
|
+
hash_match = _LOCK_HASH_RE.search(content)
|
|
113
|
+
locked_at_match = _LOCKED_AT_RE.search(content)
|
|
114
|
+
if not hash_match or not locked_at_match:
|
|
115
|
+
return "STALE_LOCK"
|
|
116
|
+
stored_hash = hash_match.group(1).lower()
|
|
117
|
+
actual_hash = lock_hash_from_content(content)
|
|
118
|
+
if stored_hash != actual_hash:
|
|
119
|
+
return "STALE_LOCK"
|
|
120
|
+
return "LOCKED"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def is_lock_valid(artifact_path: Path) -> bool:
|
|
124
|
+
"""Return True only when get_lock_status returns "LOCKED"."""
|
|
125
|
+
return get_lock_status(artifact_path) == "LOCKED"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# Prerequisite model
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def get_prerequisites(artifact_file: str, run_dir: Path) -> list[str]:
|
|
134
|
+
"""
|
|
135
|
+
Return the ordered list of phase artifact file names that must be LOCKED
|
|
136
|
+
before ``artifact_file`` may be scaffolded or locked.
|
|
137
|
+
|
|
138
|
+
The rule is simple: every phase that appears earlier in PHASE_SEQUENCE AND
|
|
139
|
+
is already present on disk in ``run_dir`` must be LOCKED first. Optional
|
|
140
|
+
phases that have not been created yet are not prerequisites.
|
|
141
|
+
"""
|
|
142
|
+
idx = phase_index(artifact_file)
|
|
143
|
+
if idx <= 0:
|
|
144
|
+
return []
|
|
145
|
+
return [phase for phase in PHASE_SEQUENCE[:idx] if (run_dir / phase).exists()]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def get_prerequisite_blockers(
|
|
149
|
+
artifact_file: str, run_dir: Path
|
|
150
|
+
) -> list[dict[str, str]]:
|
|
151
|
+
"""
|
|
152
|
+
Return a list of prerequisite artifacts that are currently blocking
|
|
153
|
+
``artifact_file`` from being scaffolded or locked.
|
|
154
|
+
|
|
155
|
+
Each entry is a dict with keys:
|
|
156
|
+
"artifact" – filename (e.g. "00-requirements.md")
|
|
157
|
+
"status" – "MISSING", "DRAFT", or "STALE_LOCK"
|
|
158
|
+
"path" – absolute path as string
|
|
159
|
+
"""
|
|
160
|
+
blockers: list[dict[str, str]] = []
|
|
161
|
+
for prereq in get_prerequisites(artifact_file, run_dir):
|
|
162
|
+
prereq_path = run_dir / prereq
|
|
163
|
+
status = get_lock_status(prereq_path)
|
|
164
|
+
if status != "LOCKED":
|
|
165
|
+
blockers.append(
|
|
166
|
+
{
|
|
167
|
+
"artifact": prereq,
|
|
168
|
+
"status": status,
|
|
169
|
+
"path": str(prereq_path),
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
return blockers
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def get_next_legal_phase(run_dir: Path) -> str | None:
|
|
176
|
+
"""
|
|
177
|
+
Return the file name of the first phase in PHASE_SEQUENCE that is not yet
|
|
178
|
+
LOCKED and whose prerequisites are all LOCKED, or None if the run is
|
|
179
|
+
complete or blocked.
|
|
180
|
+
|
|
181
|
+
Optional phases that were never created are treated as intentionally
|
|
182
|
+
skipped — they are invisible to this function unless they exist on disk.
|
|
183
|
+
"""
|
|
184
|
+
for phase in PHASE_SEQUENCE:
|
|
185
|
+
phase_path = run_dir / phase
|
|
186
|
+
# Optional phases that don't exist were intentionally omitted; skip them.
|
|
187
|
+
if phase in OPTIONAL_PHASES and not phase_path.exists():
|
|
188
|
+
continue
|
|
189
|
+
lock_status = get_lock_status(phase_path)
|
|
190
|
+
if lock_status == "LOCKED":
|
|
191
|
+
continue
|
|
192
|
+
# Phase is not locked; check whether prerequisites are satisfied.
|
|
193
|
+
blockers = get_prerequisite_blockers(phase, run_dir)
|
|
194
|
+
if not blockers:
|
|
195
|
+
return phase
|
|
196
|
+
# Phase (mandatory or existing optional) has unresolved prerequisites.
|
|
197
|
+
return None
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# Lock receipt I/O
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def receipt_path(run_dir: Path, artifact_file: str) -> Path:
|
|
207
|
+
stem = Path(artifact_file).stem
|
|
208
|
+
return run_dir / LOCKS_SUBDIR / f"{stem}{RECEIPT_SUFFIX}"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def read_receipt(run_dir: Path, artifact_file: str) -> dict | None:
|
|
212
|
+
"""Read the JSON lock receipt for an artifact, or None if not found/invalid."""
|
|
213
|
+
path = receipt_path(run_dir, artifact_file)
|
|
214
|
+
if not path.exists():
|
|
215
|
+
return None
|
|
216
|
+
try:
|
|
217
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
218
|
+
except (json.JSONDecodeError, OSError):
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def write_receipt(
|
|
223
|
+
run_dir: Path, artifact_file: str, artifact_path: Path
|
|
224
|
+
) -> dict:
|
|
225
|
+
"""
|
|
226
|
+
Write a lock receipt for ``artifact_file`` recording:
|
|
227
|
+
- artifact path and content hash
|
|
228
|
+
- ISO-8601 locked_at timestamp
|
|
229
|
+
- prerequisite file names and their current content hashes
|
|
230
|
+
- hash of the previous receipt (for chaining)
|
|
231
|
+
- self-hash of the receipt JSON
|
|
232
|
+
|
|
233
|
+
Raises RuntimeError if any prerequisite is not LOCKED at the time of
|
|
234
|
+
writing, ensuring the receipt chain is internally consistent.
|
|
235
|
+
|
|
236
|
+
Returns the written receipt dict.
|
|
237
|
+
"""
|
|
238
|
+
content = artifact_path.read_text(encoding="utf-8")
|
|
239
|
+
artifact_hash = lock_hash_from_content(content)
|
|
240
|
+
|
|
241
|
+
prereq_hashes: dict[str, str] = {}
|
|
242
|
+
for prereq in get_prerequisites(artifact_file, run_dir):
|
|
243
|
+
prereq_path = run_dir / prereq
|
|
244
|
+
if prereq_path.exists():
|
|
245
|
+
prereq_status = get_lock_status(prereq_path)
|
|
246
|
+
if prereq_status != "LOCKED":
|
|
247
|
+
raise RuntimeError(
|
|
248
|
+
f"Cannot write receipt for {artifact_file!r}: "
|
|
249
|
+
f"prerequisite {prereq!r} is not LOCKED (status: {prereq_status})"
|
|
250
|
+
)
|
|
251
|
+
prereq_content = prereq_path.read_text(encoding="utf-8")
|
|
252
|
+
prereq_hashes[prereq] = lock_hash_from_content(prereq_content)
|
|
253
|
+
|
|
254
|
+
existing = read_receipt(run_dir, artifact_file)
|
|
255
|
+
prev_receipt_hash: str | None = existing.get("receipt_hash") if existing else None
|
|
256
|
+
|
|
257
|
+
receipt: dict = {
|
|
258
|
+
"artifact": artifact_file,
|
|
259
|
+
"artifact_path": str(artifact_path),
|
|
260
|
+
"artifact_hash": artifact_hash,
|
|
261
|
+
"locked_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
262
|
+
"prerequisite_hashes": prereq_hashes,
|
|
263
|
+
"previous_receipt_hash": prev_receipt_hash,
|
|
264
|
+
}
|
|
265
|
+
receipt_content = json.dumps(receipt, sort_keys=True)
|
|
266
|
+
receipt["receipt_hash"] = hashlib.sha256(receipt_content.encode("utf-8")).hexdigest()
|
|
267
|
+
|
|
268
|
+
locks_dir = run_dir / LOCKS_SUBDIR
|
|
269
|
+
locks_dir.mkdir(exist_ok=True)
|
|
270
|
+
rpath = receipt_path(run_dir, artifact_file)
|
|
271
|
+
rpath.write_text(json.dumps(receipt, indent=2, sort_keys=True), encoding="utf-8")
|
|
272
|
+
return receipt
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def invalidate_receipt(run_dir: Path, artifact_file: str) -> bool:
|
|
276
|
+
"""Remove the lock receipt for ``artifact_file``. Returns True if a receipt existed."""
|
|
277
|
+
rpath = receipt_path(run_dir, artifact_file)
|
|
278
|
+
if rpath.exists():
|
|
279
|
+
rpath.unlink()
|
|
280
|
+
return True
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
# Stale-chain detection
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def get_stale_downstream_phases(
|
|
290
|
+
artifact_file: str, run_dir: Path
|
|
291
|
+
) -> list[dict[str, str]]:
|
|
292
|
+
"""
|
|
293
|
+
Return phases that have receipts referencing ``artifact_file`` as a
|
|
294
|
+
prerequisite but whose stored hash no longer matches the current content.
|
|
295
|
+
|
|
296
|
+
Each entry: {"artifact": str, "reason": str}
|
|
297
|
+
"""
|
|
298
|
+
idx = phase_index(artifact_file)
|
|
299
|
+
if idx < 0:
|
|
300
|
+
return []
|
|
301
|
+
|
|
302
|
+
current_hash: str | None = None
|
|
303
|
+
artifact_path = run_dir / artifact_file
|
|
304
|
+
if artifact_path.exists():
|
|
305
|
+
current_hash = lock_hash_from_content(
|
|
306
|
+
artifact_path.read_text(encoding="utf-8")
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
stale: list[dict[str, str]] = []
|
|
310
|
+
for downstream in PHASE_SEQUENCE[idx + 1 :]:
|
|
311
|
+
receipt = read_receipt(run_dir, downstream)
|
|
312
|
+
if receipt is None:
|
|
313
|
+
continue
|
|
314
|
+
prereq_hashes = receipt.get("prerequisite_hashes", {})
|
|
315
|
+
if artifact_file not in prereq_hashes:
|
|
316
|
+
continue
|
|
317
|
+
stored_hash = prereq_hashes[artifact_file]
|
|
318
|
+
if current_hash is None or stored_hash != current_hash:
|
|
319
|
+
stale.append(
|
|
320
|
+
{
|
|
321
|
+
"artifact": downstream,
|
|
322
|
+
"reason": f"prerequisite {artifact_file!r} hash changed",
|
|
323
|
+
}
|
|
324
|
+
)
|
|
325
|
+
return stale
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def get_all_stale_receipts(run_dir: Path) -> list[dict[str, str]]:
|
|
329
|
+
"""
|
|
330
|
+
Scan all lock receipts in ``run_dir`` and return entries where a
|
|
331
|
+
prerequisite's current content hash differs from the hash recorded at
|
|
332
|
+
lock time.
|
|
333
|
+
|
|
334
|
+
Each entry: {"artifact": str, "stale_prereq": str, "reason": str}
|
|
335
|
+
"""
|
|
336
|
+
stale: list[dict[str, str]] = []
|
|
337
|
+
for phase in PHASE_SEQUENCE:
|
|
338
|
+
receipt = read_receipt(run_dir, phase)
|
|
339
|
+
if receipt is None:
|
|
340
|
+
continue
|
|
341
|
+
prereq_hashes = receipt.get("prerequisite_hashes", {})
|
|
342
|
+
locked_at = receipt.get("locked_at", "unknown")
|
|
343
|
+
for prereq, stored_hash in prereq_hashes.items():
|
|
344
|
+
prereq_path = run_dir / prereq
|
|
345
|
+
if not prereq_path.exists():
|
|
346
|
+
stale.append(
|
|
347
|
+
{
|
|
348
|
+
"artifact": phase,
|
|
349
|
+
"stale_prereq": prereq,
|
|
350
|
+
"reason": f"prerequisite {prereq!r} no longer exists",
|
|
351
|
+
}
|
|
352
|
+
)
|
|
353
|
+
continue
|
|
354
|
+
current_hash = lock_hash_from_content(
|
|
355
|
+
prereq_path.read_text(encoding="utf-8")
|
|
356
|
+
)
|
|
357
|
+
if stored_hash != current_hash:
|
|
358
|
+
stale.append(
|
|
359
|
+
{
|
|
360
|
+
"artifact": phase,
|
|
361
|
+
"stale_prereq": prereq,
|
|
362
|
+
"reason": (
|
|
363
|
+
f"prerequisite {prereq!r} content changed since lock at {locked_at}"
|
|
364
|
+
),
|
|
365
|
+
}
|
|
366
|
+
)
|
|
367
|
+
return stale
|