@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.
Files changed (88) hide show
  1. package/cordis.patch.yml +12 -0
  2. package/lib/bootstrap.d.ts +35 -0
  3. package/lib/client/board.d.ts +10 -0
  4. package/lib/client/contract.d.ts +51 -0
  5. package/lib/client/derive.d.ts +92 -0
  6. package/lib/client/index.d.ts +21 -0
  7. package/lib/client/inspector.d.ts +10 -0
  8. package/lib/client/node.d.ts +71 -0
  9. package/lib/client/settings.d.ts +6 -0
  10. package/lib/client/slots.d.ts +7 -0
  11. package/lib/client/strip.d.ts +7 -0
  12. package/lib/client.d.ts +10 -0
  13. package/lib/client.js +490 -0
  14. package/lib/closeout.d.ts +23 -0
  15. package/lib/commands.d.ts +51 -0
  16. package/lib/delegation.d.ts +92 -0
  17. package/lib/enforcement.d.ts +53 -0
  18. package/lib/events.d.ts +173 -0
  19. package/lib/handoff.d.ts +51 -0
  20. package/lib/index.d.ts +40 -0
  21. package/lib/lifecycle.d.ts +107 -0
  22. package/lib/lock.d.ts +92 -0
  23. package/lib/policy.d.ts +12 -0
  24. package/lib/projection.d.ts +29 -0
  25. package/lib/recursive_closeout.tool.d.ts +8 -0
  26. package/lib/recursive_init.tool.d.ts +2 -0
  27. package/lib/recursive_lint.tool.d.ts +2 -0
  28. package/lib/recursive_lock.tool.d.ts +2 -0
  29. package/lib/recursive_scratch.tool.d.ts +7 -0
  30. package/lib/recursive_status.tool.d.ts +2 -0
  31. package/lib/review.d.ts +39 -0
  32. package/lib/router.d.ts +77 -0
  33. package/lib/run.d.ts +29 -0
  34. package/lib/runtime.d.ts +241 -0
  35. package/lib/scratch.d.ts +18 -0
  36. package/lib/status.d.ts +19 -0
  37. package/lib/types.d.ts +104 -0
  38. package/lib/workspace.d.ts +50 -0
  39. package/package.json +119 -0
  40. package/preset/recursive/agent.cordis.yml +282 -0
  41. package/preset/recursive/preset.yml +3 -0
  42. package/scripts/install-recursive-mode.ps1 +956 -0
  43. package/scripts/install-recursive-mode.py +750 -0
  44. package/scripts/lint-recursive-run.py +2868 -0
  45. package/scripts/recursive-closeout.py +541 -0
  46. package/scripts/recursive-init.py +356 -0
  47. package/scripts/recursive-lock.py +302 -0
  48. package/scripts/recursive-status.py +2124 -0
  49. package/scripts/recursive_phase_rules.py +367 -0
  50. package/scripts/recursive_router_lib.py +2282 -0
  51. package/scripts/test-recursive-mode-smoke.ts +204 -0
  52. package/scripts/verify-locks.py +353 -0
  53. package/src/bootstrap.ts +118 -0
  54. package/src/client/board.tsx +61 -0
  55. package/src/client/contract.ts +58 -0
  56. package/src/client/derive.ts +241 -0
  57. package/src/client/index.ts +28 -0
  58. package/src/client/inspector.tsx +49 -0
  59. package/src/client/node.ts +156 -0
  60. package/src/client/settings.tsx +18 -0
  61. package/src/client/slots.ts +67 -0
  62. package/src/client/strip.tsx +28 -0
  63. package/src/client.ts +11 -0
  64. package/src/closeout.ts +183 -0
  65. package/src/commands.ts +142 -0
  66. package/src/delegation.ts +306 -0
  67. package/src/enforcement.ts +180 -0
  68. package/src/events.ts +173 -0
  69. package/src/handoff.ts +165 -0
  70. package/src/index.ts +283 -0
  71. package/src/lifecycle.ts +235 -0
  72. package/src/lock.ts +369 -0
  73. package/src/policy.ts +56 -0
  74. package/src/projection.ts +237 -0
  75. package/src/recursive_closeout.tool.ts +35 -0
  76. package/src/recursive_init.tool.ts +28 -0
  77. package/src/recursive_lint.tool.ts +29 -0
  78. package/src/recursive_lock.tool.ts +33 -0
  79. package/src/recursive_scratch.tool.ts +42 -0
  80. package/src/recursive_status.tool.ts +24 -0
  81. package/src/review.ts +178 -0
  82. package/src/router.ts +197 -0
  83. package/src/run.ts +85 -0
  84. package/src/runtime.ts +564 -0
  85. package/src/scratch.ts +85 -0
  86. package/src/status.ts +194 -0
  87. package/src/types.ts +112 -0
  88. 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