@try-works/dsh-recursive-mode 0.1.3 → 0.1.4
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 +15 -10
- package/lib/bootstrap.d.ts +9 -2
- package/lib/client/apply-guard.d.ts +14 -0
- package/lib/client/board.d.ts +5 -4
- package/lib/client/contract.d.ts +57 -24
- package/lib/client/host-api.d.ts +26 -0
- package/lib/client/index.d.ts +16 -9
- package/lib/client/inspector.d.ts +3 -3
- package/lib/client/slots.d.ts +4 -6
- package/lib/client/strip.d.ts +4 -3
- package/lib/client/use-live.d.ts +9 -0
- package/lib/client.js +252 -174
- package/lib/index.d.ts +11 -11
- package/lib/index.js +972 -4504
- package/lib/init-templates.d.ts +41 -0
- package/lib/live-route.d.ts +69 -0
- package/lib/phase-rules.d.ts +34 -0
- package/lib/runtime.d.ts +24 -17
- package/lib/snapshot.d.ts +11 -0
- package/package.json +4 -3
- package/preset/recursive/agent.cordis.yml +7 -4
- package/preset/recursive/preset.yml +1 -1
- package/references/agents-block.md +95 -0
- package/references/artifact-template.md +2574 -0
- package/references/bodies/claude.md +7 -0
- package/references/bodies/codex-agents.md +95 -0
- package/references/bodies/copilot.md +7 -0
- package/references/bodies/cursorrules.md +7 -0
- package/references/bodies/decisions.md +5 -0
- package/references/bodies/delegated-verification.md +49 -0
- package/references/bodies/memory-router.md +45 -0
- package/references/bodies/phase8-skill-memory.md +48 -0
- package/references/bodies/plans-bridge.md +43 -0
- package/references/bodies/recursive-agents-router.md +64 -0
- package/references/bodies/skill-discovery.md +51 -0
- package/references/bodies/skill-memory-router.md +42 -0
- package/references/bodies/state.md +5 -0
- package/references/bootstrap/RECURSIVE.md +2362 -0
- package/references/scripts/__pycache__/recursive_phase_rules.cpython-314.pyc +0 -0
- package/references/scripts/lint-recursive-run.ps1 +25 -0
- package/references/scripts/lint-recursive-run.py +2870 -0
- package/references/scripts/recursive-closeout.ps1 +38 -0
- package/references/scripts/recursive-closeout.py +541 -0
- package/references/scripts/recursive-init.ps1 +515 -0
- package/references/scripts/recursive-init.py +356 -0
- package/references/scripts/recursive-lock.ps1 +26 -0
- package/references/scripts/recursive-lock.py +302 -0
- package/references/scripts/recursive-review-bundle.ps1 +58 -0
- package/references/scripts/recursive-review-bundle.py +503 -0
- package/references/scripts/recursive-router-cli-configure.ps1 +2 -0
- package/references/scripts/recursive-router-cli-configure.py +9 -0
- package/references/scripts/recursive-router-cli-init.ps1 +2 -0
- package/references/scripts/recursive-router-cli-init.py +9 -0
- package/references/scripts/recursive-router-cli-invoke.ps1 +2 -0
- package/references/scripts/recursive-router-cli-invoke.py +9 -0
- package/references/scripts/recursive-router-cli-probe.ps1 +2 -0
- package/references/scripts/recursive-router-cli-probe.py +9 -0
- package/references/scripts/recursive-router-cli-resolve.ps1 +2 -0
- package/references/scripts/recursive-router-cli-resolve.py +9 -0
- package/references/scripts/recursive-router-cli-validate.ps1 +2 -0
- package/references/scripts/recursive-router-cli-validate.py +9 -0
- package/references/scripts/recursive-router-configure.ps1 +27 -0
- package/references/scripts/recursive-router-configure.py +74 -0
- package/references/scripts/recursive-router-init.ps1 +17 -0
- package/references/scripts/recursive-router-init.py +29 -0
- package/references/scripts/recursive-router-invoke.ps1 +47 -0
- package/references/scripts/recursive-router-invoke.py +103 -0
- package/references/scripts/recursive-router-probe.ps1 +25 -0
- package/references/scripts/recursive-router-probe.py +44 -0
- package/references/scripts/recursive-router-resolve.ps1 +26 -0
- package/references/scripts/recursive-router-resolve.py +46 -0
- package/references/scripts/recursive-router-validate.ps1 +17 -0
- package/references/scripts/recursive-router-validate.py +27 -0
- package/references/scripts/recursive-status.ps1 +23 -0
- package/references/scripts/recursive-status.py +2124 -0
- package/references/scripts/recursive-subagent-action.ps1 +98 -0
- package/references/scripts/recursive-subagent-action.py +197 -0
- package/references/scripts/recursive-training-extract.ps1 +23 -0
- package/references/scripts/recursive-training-extract.py +99 -0
- package/references/scripts/recursive-training-grpo.ps1 +52 -0
- package/references/scripts/recursive-training-grpo.py +1341 -0
- package/references/scripts/recursive-training-loader.ps1 +78 -0
- package/references/scripts/recursive-training-loader.py +558 -0
- package/references/scripts/recursive-training-mcp.ps1 +29 -0
- package/references/scripts/recursive-training-mcp.py +261 -0
- package/references/scripts/recursive-training-phase8-trigger.ps1 +55 -0
- package/references/scripts/recursive-training-phase8-trigger.py +137 -0
- package/references/scripts/recursive-training-sync.ps1 +29 -0
- package/references/scripts/recursive-training-sync.py +183 -0
- package/references/scripts/recursive_phase_rules.py +367 -0
- package/references/scripts/recursive_router_cli_lib.py +2 -0
- package/references/scripts/recursive_router_lib.py +2282 -0
- package/references/scripts/verify-locks.ps1 +25 -0
- package/references/scripts/verify-locks.py +353 -0
- package/scripts/__pycache__/lint-recursive-run.cpython-314.pyc +0 -0
- package/scripts/__pycache__/recursive_phase_rules.cpython-314.pyc +0 -0
- package/scripts/install-preset.cmd +7 -0
- package/scripts/install-preset.js +101 -0
- package/scripts/lint-recursive-run.py +7 -5
- package/scripts/test-recursive-mode-smoke.ts +29 -28
- package/src/bootstrap.ts +253 -39
- package/src/client/apply-guard.ts +23 -0
- package/src/client/board.tsx +0 -0
- package/src/client/contract.ts +72 -24
- package/src/client/host-api.ts +90 -0
- package/src/client/index.ts +22 -11
- package/src/client/inspector.tsx +7 -9
- package/src/client/slots.ts +70 -26
- package/src/client/strip.tsx +9 -9
- package/src/client/use-live.ts +52 -0
- package/src/index.ts +89 -146
- package/src/init-templates.ts +276 -0
- package/src/live-route.ts +163 -0
- package/src/phase-rules.ts +262 -0
- package/src/policy.ts +28 -4
- package/src/runtime.ts +129 -77
- package/src/snapshot.ts +104 -0
- package/lib/client/node.d.ts +0 -71
- package/lib/events.d.ts +0 -173
- package/lib/projection.d.ts +0 -29
- package/src/client/node.ts +0 -156
- package/src/events.ts +0 -173
- package/src/projection.ts +0 -237
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[CmdletBinding()]
|
|
2
|
+
param(
|
|
3
|
+
[string]$RunId = "",
|
|
4
|
+
[string]$RepoRoot = (Get-Location).Path,
|
|
5
|
+
[switch]$Fix,
|
|
6
|
+
[switch]$ShowHashes
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
Set-StrictMode -Version Latest
|
|
10
|
+
$ErrorActionPreference = "Stop"
|
|
11
|
+
|
|
12
|
+
$python = if ($env:PYTHON) { $env:PYTHON } elseif (Get-Command python -ErrorAction SilentlyContinue) { "python" } else { $null }
|
|
13
|
+
if (-not $python) {
|
|
14
|
+
Write-Host "[FAIL] Python executable not found in PATH."
|
|
15
|
+
exit 1
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
$scriptPath = Join-Path $PSScriptRoot "verify-locks.py"
|
|
19
|
+
$argsList = @($scriptPath, "--repo-root", $RepoRoot)
|
|
20
|
+
if ($RunId) { $argsList += @("--run-id", $RunId) }
|
|
21
|
+
if ($Fix.IsPresent) { $argsList += "--fix" }
|
|
22
|
+
if ($ShowHashes.IsPresent) { $argsList += "--show-hashes" }
|
|
23
|
+
|
|
24
|
+
& $python @argsList
|
|
25
|
+
exit $LASTEXITCODE
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Verify recursive-mode artifact lock integrity.
|
|
4
|
+
|
|
5
|
+
Python equivalent to scripts/verify-locks.ps1.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import hashlib
|
|
12
|
+
import importlib.util
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_phase_rules_module():
|
|
20
|
+
module_path = Path(__file__).with_name("recursive_phase_rules.py")
|
|
21
|
+
spec = importlib.util.spec_from_file_location("recursive_phase_rules", module_path)
|
|
22
|
+
if spec is None or spec.loader is None:
|
|
23
|
+
raise RuntimeError(f"Unable to load phase rules module from {module_path}")
|
|
24
|
+
module = importlib.util.module_from_spec(spec)
|
|
25
|
+
try:
|
|
26
|
+
spec.loader.exec_module(module)
|
|
27
|
+
except FileNotFoundError:
|
|
28
|
+
raise RuntimeError(f"Phase rules module not found: {module_path}")
|
|
29
|
+
return module
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
STATUS_RE = re.compile(r'(?m)^[ \t]*Status:\s*(?:`|")?(\w+)(?:`|")?\s*$')
|
|
33
|
+
LOCK_HASH_RE = re.compile(r'(?m)^[ \t]*LockHash:\s*(?:`|")?([a-fA-F0-9]{64})(?:`|")?\s*$')
|
|
34
|
+
LOCKED_AT_RE = re.compile(r'(?m)^[ \t]*LockedAt:\s*(?:`|")?([^`"\r\n]+)(?:`|")?\s*$')
|
|
35
|
+
LOCK_HASH_LINE_RE = re.compile(r'(?m)^[ \t]*LockHash:.*(?:\n|$)')
|
|
36
|
+
WORKFLOW_VERSION_RE = re.compile(r'(?m)^[ \t]*Workflow version:\s*(?:`|")?([^`"\r\n]+)(?:`|")?\s*$')
|
|
37
|
+
CURRENT_WORKFLOW_PROFILE = "recursive-mode-audit-v2"
|
|
38
|
+
STRICT_WORKFLOW_PROFILE = "recursive-mode-audit-v1"
|
|
39
|
+
COMPAT_WORKFLOW_PROFILE = "memory-phase8"
|
|
40
|
+
STRICT_WORKFLOW_PROFILES = {CURRENT_WORKFLOW_PROFILE, STRICT_WORKFLOW_PROFILE}
|
|
41
|
+
AUDITED_PHASE_FILES = {
|
|
42
|
+
"01-as-is.md",
|
|
43
|
+
"01.5-root-cause.md",
|
|
44
|
+
"02-to-be-plan.md",
|
|
45
|
+
"03-implementation-summary.md",
|
|
46
|
+
"03.5-code-review.md",
|
|
47
|
+
"04-test-summary.md",
|
|
48
|
+
"06-decisions-update.md",
|
|
49
|
+
"07-state-update.md",
|
|
50
|
+
"08-memory-impact.md",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class LockResult:
|
|
56
|
+
valid: bool
|
|
57
|
+
error: str | None = None
|
|
58
|
+
stored_hash: str | None = None
|
|
59
|
+
actual_hash: str | None = None
|
|
60
|
+
locked_at: str | None = None
|
|
61
|
+
fixed: bool = False
|
|
62
|
+
new_locked_at: str | None = None
|
|
63
|
+
new_hash: str | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def write_status(status: str, message: str) -> None:
|
|
67
|
+
print(f"[{status}] {message}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def normalize_for_lock_hash(content: str) -> str:
|
|
71
|
+
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
|
|
72
|
+
normalized = LOCK_HASH_LINE_RE.sub("", normalized)
|
|
73
|
+
return normalized
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def lock_hash_from_content(content: str) -> str:
|
|
77
|
+
normalized = normalize_for_lock_hash(content)
|
|
78
|
+
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_workflow_profile(run_dir: Path) -> str:
|
|
82
|
+
requirements_path = run_dir / "00-requirements.md"
|
|
83
|
+
if requirements_path.exists():
|
|
84
|
+
content = requirements_path.read_text(encoding="utf-8")
|
|
85
|
+
match = WORKFLOW_VERSION_RE.search(content)
|
|
86
|
+
if match:
|
|
87
|
+
workflow_version = match.group(1).strip()
|
|
88
|
+
if workflow_version == CURRENT_WORKFLOW_PROFILE:
|
|
89
|
+
return CURRENT_WORKFLOW_PROFILE
|
|
90
|
+
if workflow_version == STRICT_WORKFLOW_PROFILE:
|
|
91
|
+
return STRICT_WORKFLOW_PROFILE
|
|
92
|
+
if workflow_version == COMPAT_WORKFLOW_PROFILE:
|
|
93
|
+
return COMPAT_WORKFLOW_PROFILE
|
|
94
|
+
|
|
95
|
+
for artifact in ("06-decisions-update.md", "07-state-update.md", "08-memory-impact.md"):
|
|
96
|
+
if (run_dir / artifact).exists():
|
|
97
|
+
return COMPAT_WORKFLOW_PROFILE
|
|
98
|
+
|
|
99
|
+
return "legacy"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_lock_valid(artifact_path: Path, fix: bool = False) -> LockResult:
|
|
103
|
+
if not artifact_path.exists():
|
|
104
|
+
return LockResult(valid=False, error="File not found")
|
|
105
|
+
|
|
106
|
+
content = artifact_path.read_text(encoding="utf-8")
|
|
107
|
+
|
|
108
|
+
status_match = STATUS_RE.search(content)
|
|
109
|
+
if not status_match:
|
|
110
|
+
return LockResult(valid=False, error="No Status field found")
|
|
111
|
+
status = status_match.group(1)
|
|
112
|
+
if status != "LOCKED":
|
|
113
|
+
return LockResult(valid=False, error=f"Status is '{status}', expected 'LOCKED'")
|
|
114
|
+
|
|
115
|
+
hash_match = LOCK_HASH_RE.search(content)
|
|
116
|
+
if not hash_match:
|
|
117
|
+
return LockResult(valid=False, error="No LockHash field found")
|
|
118
|
+
stored_hash = hash_match.group(1).lower()
|
|
119
|
+
|
|
120
|
+
locked_at_match = LOCKED_AT_RE.search(content)
|
|
121
|
+
if not locked_at_match:
|
|
122
|
+
return LockResult(valid=False, error="No LockedAt field found")
|
|
123
|
+
locked_at = locked_at_match.group(1)
|
|
124
|
+
|
|
125
|
+
actual_hash = lock_hash_from_content(content)
|
|
126
|
+
if stored_hash == actual_hash:
|
|
127
|
+
return LockResult(valid=True, locked_at=locked_at, stored_hash=stored_hash)
|
|
128
|
+
|
|
129
|
+
result = LockResult(
|
|
130
|
+
valid=False,
|
|
131
|
+
error="Hash mismatch",
|
|
132
|
+
stored_hash=stored_hash,
|
|
133
|
+
actual_hash=actual_hash,
|
|
134
|
+
locked_at=locked_at,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if fix:
|
|
138
|
+
new_locked_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
139
|
+
content_with_new_locked_at = re.sub(
|
|
140
|
+
r"(?m)^[ \t]*LockedAt:.*$",
|
|
141
|
+
f"LockedAt: `{new_locked_at}`",
|
|
142
|
+
content,
|
|
143
|
+
)
|
|
144
|
+
new_hash = lock_hash_from_content(content_with_new_locked_at)
|
|
145
|
+
new_content = re.sub(
|
|
146
|
+
r"(?m)^[ \t]*LockHash:.*$",
|
|
147
|
+
f"LockHash: `{new_hash}`",
|
|
148
|
+
content_with_new_locked_at,
|
|
149
|
+
)
|
|
150
|
+
artifact_path.write_text(new_content, encoding="utf-8", newline="")
|
|
151
|
+
|
|
152
|
+
result.fixed = True
|
|
153
|
+
result.new_locked_at = new_locked_at
|
|
154
|
+
result.new_hash = new_hash
|
|
155
|
+
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_gate(content: str, name: str) -> tuple[bool, str | None]:
|
|
160
|
+
pass_re = re.compile(rf"{name}:\s*PASS")
|
|
161
|
+
fail_re = re.compile(rf"{name}:\s*FAIL")
|
|
162
|
+
if pass_re.search(content):
|
|
163
|
+
return True, None
|
|
164
|
+
if fail_re.search(content):
|
|
165
|
+
return False, f"{name} gate shows FAIL"
|
|
166
|
+
return False, f"No {name} gate result found"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main() -> int:
|
|
170
|
+
parser = argparse.ArgumentParser(description="Verify recursive-mode artifact lock integrity.")
|
|
171
|
+
parser.add_argument("--run-id", default="", help="Run ID to verify. If omitted, scans all runs.")
|
|
172
|
+
parser.add_argument("--repo-root", default=".", help="Repository root path.")
|
|
173
|
+
parser.add_argument("--fix", action="store_true", help="Fix incorrect lock hashes (updates LockedAt + LockHash).")
|
|
174
|
+
parser.add_argument("--show-hashes", action="store_true", help="Show valid lock hashes in output.")
|
|
175
|
+
args = parser.parse_args()
|
|
176
|
+
|
|
177
|
+
resolved_repo_root = Path(args.repo_root).resolve()
|
|
178
|
+
print("\nRecursive Lock Verification")
|
|
179
|
+
print("=====================\n")
|
|
180
|
+
print(f"Repository: {resolved_repo_root}")
|
|
181
|
+
print(f"Run ID: {args.run_id if args.run_id else '(scanning all runs)'}\n")
|
|
182
|
+
|
|
183
|
+
run_base_dir = resolved_repo_root / ".recursive" / "run"
|
|
184
|
+
if not run_base_dir.exists():
|
|
185
|
+
write_status("FAIL", f"recursive run directory not found: {run_base_dir}")
|
|
186
|
+
return 1
|
|
187
|
+
|
|
188
|
+
if args.run_id:
|
|
189
|
+
runs = [args.run_id]
|
|
190
|
+
else:
|
|
191
|
+
runs = sorted([d.name for d in run_base_dir.iterdir() if d.is_dir()])
|
|
192
|
+
|
|
193
|
+
artifacts = [
|
|
194
|
+
{"file": "00-requirements.md", "name": "Requirements", "optional": False},
|
|
195
|
+
{"file": "00-worktree.md", "name": "Worktree Setup", "optional": False},
|
|
196
|
+
{"file": "01-as-is.md", "name": "AS-IS Analysis", "optional": True},
|
|
197
|
+
{"file": "01.5-root-cause.md", "name": "Root Cause", "optional": True},
|
|
198
|
+
{"file": "02-to-be-plan.md", "name": "TO-BE Plan", "optional": True},
|
|
199
|
+
{"file": "03-implementation-summary.md", "name": "Implementation", "optional": True},
|
|
200
|
+
{"file": "03.5-code-review.md", "name": "Code Review", "optional": True},
|
|
201
|
+
{"file": "04-test-summary.md", "name": "Test Summary", "optional": True},
|
|
202
|
+
{"file": "05-manual-qa.md", "name": "Manual QA", "optional": True},
|
|
203
|
+
{"file": "06-decisions-update.md", "name": "Decisions Update", "optional": False},
|
|
204
|
+
{"file": "07-state-update.md", "name": "State Update", "optional": False},
|
|
205
|
+
{"file": "08-memory-impact.md", "name": "Memory Impact", "optional": False},
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
total_runs = 0
|
|
209
|
+
valid_runs = 0
|
|
210
|
+
fixed_runs = 0
|
|
211
|
+
failed_runs = 0
|
|
212
|
+
stale_chain_runs = 0
|
|
213
|
+
|
|
214
|
+
phase_rules = load_phase_rules_module()
|
|
215
|
+
|
|
216
|
+
for run in runs:
|
|
217
|
+
total_runs += 1
|
|
218
|
+
run_dir = run_base_dir / run
|
|
219
|
+
workflow_profile = get_workflow_profile(run_dir)
|
|
220
|
+
print(f"Checking run: {run}")
|
|
221
|
+
print("-" * 50)
|
|
222
|
+
print(f"Workflow profile: {workflow_profile}")
|
|
223
|
+
|
|
224
|
+
run_valid = True
|
|
225
|
+
run_fixed = False
|
|
226
|
+
|
|
227
|
+
for artifact in artifacts:
|
|
228
|
+
artifact_path = run_dir / artifact["file"]
|
|
229
|
+
is_required = not artifact["optional"]
|
|
230
|
+
if workflow_profile == "legacy" and artifact["file"] in {"06-decisions-update.md", "07-state-update.md", "08-memory-impact.md"}:
|
|
231
|
+
is_required = False
|
|
232
|
+
if not artifact_path.exists():
|
|
233
|
+
if not is_required:
|
|
234
|
+
write_status("INFO", f"{artifact['name']}: Not found (optional)")
|
|
235
|
+
else:
|
|
236
|
+
write_status("FAIL", f"{artifact['name']}: Missing (required)")
|
|
237
|
+
run_valid = False
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
content = artifact_path.read_text(encoding="utf-8")
|
|
241
|
+
if not re.search(r'(?m)^[ \t]*Status:\s*(?:`|")?LOCKED(?:`|")?\s*$', content):
|
|
242
|
+
write_status("WARN", f"{artifact['name']}: Not locked (DRAFT)")
|
|
243
|
+
run_valid = False
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
result = test_lock_valid(artifact_path, fix=args.fix)
|
|
247
|
+
if result.valid:
|
|
248
|
+
write_status("PASS", f"{artifact['name']}: Valid (locked at {result.locked_at})")
|
|
249
|
+
if args.show_hashes and result.stored_hash:
|
|
250
|
+
print(f" Hash: {result.stored_hash}")
|
|
251
|
+
else:
|
|
252
|
+
if result.fixed:
|
|
253
|
+
write_status("WARN", f"{artifact['name']}: Fixed hash mismatch (was tampered)")
|
|
254
|
+
if result.stored_hash:
|
|
255
|
+
print(f" Old hash: {result.stored_hash}")
|
|
256
|
+
if result.new_hash:
|
|
257
|
+
print(f" New hash: {result.new_hash}")
|
|
258
|
+
if result.new_locked_at:
|
|
259
|
+
print(f" Updated at: {result.new_locked_at}")
|
|
260
|
+
run_fixed = True
|
|
261
|
+
else:
|
|
262
|
+
write_status("FAIL", f"{artifact['name']}: {result.error}")
|
|
263
|
+
if result.stored_hash and result.actual_hash:
|
|
264
|
+
print(f" Stored: {result.stored_hash}")
|
|
265
|
+
print(f" Actual: {result.actual_hash}")
|
|
266
|
+
run_valid = False
|
|
267
|
+
|
|
268
|
+
# Gate checks
|
|
269
|
+
updated_content = artifact_path.read_text(encoding="utf-8")
|
|
270
|
+
coverage_ok, coverage_reason = test_gate(updated_content, "Coverage")
|
|
271
|
+
approval_ok, approval_reason = test_gate(updated_content, "Approval")
|
|
272
|
+
audit_required = workflow_profile in STRICT_WORKFLOW_PROFILES and artifact["file"] in AUDITED_PHASE_FILES
|
|
273
|
+
audit_ok = True
|
|
274
|
+
audit_reason = None
|
|
275
|
+
if audit_required:
|
|
276
|
+
audit_ok, audit_reason = test_gate(updated_content, "Audit")
|
|
277
|
+
if not coverage_ok:
|
|
278
|
+
write_status("FAIL", f"{artifact['name']}: Coverage gate - {coverage_reason}")
|
|
279
|
+
run_valid = False
|
|
280
|
+
if not approval_ok:
|
|
281
|
+
write_status("FAIL", f"{artifact['name']}: Approval gate - {approval_reason}")
|
|
282
|
+
run_valid = False
|
|
283
|
+
if not audit_ok:
|
|
284
|
+
write_status("FAIL", f"{artifact['name']}: Audit gate - {audit_reason}")
|
|
285
|
+
run_valid = False
|
|
286
|
+
|
|
287
|
+
addenda_dir = run_dir / "addenda"
|
|
288
|
+
if addenda_dir.exists():
|
|
289
|
+
addenda_files = sorted(addenda_dir.glob("*.md"))
|
|
290
|
+
if addenda_files:
|
|
291
|
+
print("\nAddenda:")
|
|
292
|
+
for addendum in addenda_files:
|
|
293
|
+
result = test_lock_valid(addendum, fix=args.fix)
|
|
294
|
+
if result.valid:
|
|
295
|
+
write_status("PASS", f" {addendum.name}: Valid")
|
|
296
|
+
elif result.fixed:
|
|
297
|
+
write_status("WARN", f" {addendum.name}: Fixed")
|
|
298
|
+
run_fixed = True
|
|
299
|
+
else:
|
|
300
|
+
write_status("FAIL", f" {addendum.name}: {result.error}")
|
|
301
|
+
run_valid = False
|
|
302
|
+
|
|
303
|
+
# Stale-chain detection: check whether any prerequisite hash in a receipt
|
|
304
|
+
# no longer matches the current artifact content.
|
|
305
|
+
stale_entries = phase_rules.get_all_stale_receipts(run_dir)
|
|
306
|
+
run_stale = False
|
|
307
|
+
if stale_entries:
|
|
308
|
+
print("\nStale lock-chain receipts:")
|
|
309
|
+
for entry in stale_entries:
|
|
310
|
+
write_status("WARN", f" {entry['artifact']}: {entry['reason']}")
|
|
311
|
+
run_stale = True
|
|
312
|
+
|
|
313
|
+
print()
|
|
314
|
+
if run_stale:
|
|
315
|
+
stale_chain_runs += 1
|
|
316
|
+
run_valid = False
|
|
317
|
+
write_status("WARN", f"Run '{run}': Stale lock-chain receipts detected (re-lock in phase order)")
|
|
318
|
+
elif run_valid and not run_fixed:
|
|
319
|
+
valid_runs += 1
|
|
320
|
+
write_status("PASS", f"Run '{run}': All locks valid")
|
|
321
|
+
elif run_fixed:
|
|
322
|
+
fixed_runs += 1
|
|
323
|
+
write_status("WARN", f"Run '{run}': Locks fixed (tampering detected)")
|
|
324
|
+
else:
|
|
325
|
+
failed_runs += 1
|
|
326
|
+
write_status("FAIL", f"Run '{run}': Lock verification failed")
|
|
327
|
+
print()
|
|
328
|
+
|
|
329
|
+
print("=====================")
|
|
330
|
+
print("Summary")
|
|
331
|
+
print("=====================\n")
|
|
332
|
+
print(f"Total runs checked: {total_runs}")
|
|
333
|
+
write_status("PASS", f"Valid runs: {valid_runs}")
|
|
334
|
+
if stale_chain_runs > 0:
|
|
335
|
+
write_status("WARN", f"Stale-chain runs: {stale_chain_runs}")
|
|
336
|
+
if fixed_runs > 0:
|
|
337
|
+
write_status("WARN", f"Fixed runs (tampered): {fixed_runs}")
|
|
338
|
+
if failed_runs > 0:
|
|
339
|
+
write_status("FAIL", f"Failed runs: {failed_runs}")
|
|
340
|
+
print()
|
|
341
|
+
|
|
342
|
+
if failed_runs == 0 and stale_chain_runs == 0:
|
|
343
|
+
write_status("PASS", "All locks verified successfully")
|
|
344
|
+
return 0
|
|
345
|
+
if failed_runs == 0:
|
|
346
|
+
write_status("WARN", "Lock hashes valid but stale-chain receipts detected")
|
|
347
|
+
return 1
|
|
348
|
+
write_status("FAIL", "Some locks failed verification")
|
|
349
|
+
return 1
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
if __name__ == "__main__":
|
|
353
|
+
raise SystemExit(main())
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
rem install-preset.cmd — thin launcher for scripts\install-preset.js
|
|
3
|
+
rem Materializes the `recursive` agent preset into %DSH_HOME%\.agent-presets\recursive\
|
|
4
|
+
rem with an absolute file-URL row into the profile-installed package.
|
|
5
|
+
setlocal
|
|
6
|
+
node "%~dp0install-preset.js" %*
|
|
7
|
+
exit /b %ERRORLEVEL%
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* install-preset.js — materialize the `recursive` agent preset into the user's
|
|
4
|
+
* DSH home so `agentPreset.list` returns `recursive` and selecting it mounts the
|
|
5
|
+
* full per-session recursive-mode surface.
|
|
6
|
+
*
|
|
7
|
+
* R2 resolution (02-to-be-plan.addendum-r4-r2-mount-resolution.md), option (B):
|
|
8
|
+
* the preset's server-surface row is written as an ABSOLUTE file URL into the
|
|
9
|
+
* PROFILE-installed package (lib/index.js), NOT a vendored copy. This keeps the
|
|
10
|
+
* nested @deepseek-ai/cordis + @deepseek-ai/dsh-tools imports resolving through
|
|
11
|
+
* the profile's flat-fallback junctions (same cordis symbols => Service identity
|
|
12
|
+
* preserved), and `ctx.recursive` resolves correctly inside the isolate realm.
|
|
13
|
+
*
|
|
14
|
+
* Idempotent + atomic: writes to a temp file in the same directory, then renames.
|
|
15
|
+
*
|
|
16
|
+
* Usage: node install-preset.js [--profile <name>] [--dsh-home <dir>] [--force]
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
|
|
19
|
+
import { dirname, join, resolve } from 'node:path'
|
|
20
|
+
import { homedir } from 'node:os'
|
|
21
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
22
|
+
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
24
|
+
const PACKAGE_ROOT = resolve(__dirname, '..')
|
|
25
|
+
const PRESET_SOURCE_DIR = join(PACKAGE_ROOT, 'preset', 'recursive')
|
|
26
|
+
const PRESET_SOURCE = join(PRESET_SOURCE_DIR, 'agent.cordis.yml')
|
|
27
|
+
const PRESET_METADATA_SOURCE = join(PRESET_SOURCE_DIR, 'preset.yml')
|
|
28
|
+
const PLACEHOLDER = '@@RECURSIVE_SERVER_ENTRY@@'
|
|
29
|
+
const PKG_NAME = '@try-works/dsh-recursive-mode'
|
|
30
|
+
|
|
31
|
+
function parseArgs(argv) {
|
|
32
|
+
const out = { profile: 'web', dshHome: '', force: false }
|
|
33
|
+
for (let i = 0; i < argv.length; i++) {
|
|
34
|
+
const a = argv[i]
|
|
35
|
+
if (a === '--profile' && argv[i + 1]) { out.profile = argv[++i]; continue }
|
|
36
|
+
if (a === '--dsh-home' && argv[i + 1]) { out.dshHome = argv[++i]; continue }
|
|
37
|
+
if (a === '--force') { out.force = true; continue }
|
|
38
|
+
}
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fail(msg) {
|
|
43
|
+
console.error('[install-preset] FAIL: ' + msg)
|
|
44
|
+
process.exitCode = 1
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const args = parseArgs(process.argv.slice(2))
|
|
48
|
+
const dshHome = args.dshHome || process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
49
|
+
|
|
50
|
+
// Locate the profile install dir and its lib/index.js.
|
|
51
|
+
const profilePkgDir = join(dshHome, 'profiles', args.profile, 'node_modules', ...PKG_NAME.split('/'))
|
|
52
|
+
const serverEntry = join(profilePkgDir, 'lib', 'index.js')
|
|
53
|
+
|
|
54
|
+
if (!existsSync(serverEntry)) {
|
|
55
|
+
fail(
|
|
56
|
+
'Profile-installed package entry not found: ' + serverEntry + "\n" +
|
|
57
|
+
' Install the package into the profile first: pnpm dsh plugin --profile ' + args.profile + ' add ' + PKG_NAME,
|
|
58
|
+
)
|
|
59
|
+
process.exit(1)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const serverUrl = pathToFileURL(serverEntry).href
|
|
63
|
+
|
|
64
|
+
// Read the preset template and materialize the placeholder.
|
|
65
|
+
const template = readFileSync(PRESET_SOURCE, 'utf8')
|
|
66
|
+
if (!template.includes(PLACEHOLDER)) {
|
|
67
|
+
fail('Preset template missing placeholder ' + PLACEHOLDER + ' in ' + PRESET_SOURCE)
|
|
68
|
+
process.exit(1)
|
|
69
|
+
}
|
|
70
|
+
const materialized = template.replaceAll(PLACEHOLDER, serverUrl)
|
|
71
|
+
|
|
72
|
+
// Destination: ~/.dsh/.agent-presets/recursive/agent.cordis.yml
|
|
73
|
+
const presetDir = join(dshHome, '.agent-presets', 'recursive')
|
|
74
|
+
const presetFile = join(presetDir, 'agent.cordis.yml')
|
|
75
|
+
|
|
76
|
+
if (existsSync(presetFile) && !args.force) {
|
|
77
|
+
const existing = readFileSync(presetFile, 'utf8')
|
|
78
|
+
if (existing === materialized) {
|
|
79
|
+
console.log('[install-preset] OK (already up to date): ' + presetFile)
|
|
80
|
+
console.log(' server entry: ' + serverUrl)
|
|
81
|
+
process.exit(0)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
mkdirSync(presetDir, { recursive: true })
|
|
86
|
+
const tmp = join(presetDir, '.agent.cordis.yml.tmp')
|
|
87
|
+
writeFileSync(tmp, materialized, 'utf8')
|
|
88
|
+
renameSync(tmp, presetFile)
|
|
89
|
+
|
|
90
|
+
// Copy the optional display metadata (preset.yml) beside the composition so the
|
|
91
|
+
// picker shows the human-facing name instead of the id. Absent source is fine.
|
|
92
|
+
const metadataFile = join(presetDir, 'preset.yml')
|
|
93
|
+
if (existsSync(PRESET_METADATA_SOURCE)) {
|
|
94
|
+
const metadataTmp = join(presetDir, '.preset.yml.tmp')
|
|
95
|
+
writeFileSync(metadataTmp, readFileSync(PRESET_METADATA_SOURCE, 'utf8'), 'utf8')
|
|
96
|
+
renameSync(metadataTmp, metadataFile)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log('[install-preset] OK: ' + presetFile)
|
|
100
|
+
console.log(' server entry: ' + serverUrl)
|
|
101
|
+
console.log(' (re-run is idempotent; add --force to overwrite an existing file)')
|
|
@@ -743,7 +743,7 @@ def get_git_changed_files(repo_root: Path, diff_basis: dict[str, str | None]) ->
|
|
|
743
743
|
|
|
744
744
|
try:
|
|
745
745
|
diff_result = subprocess.run(
|
|
746
|
-
["git", "-C", str(repo_root), *normalized_basis["git_args"]],
|
|
746
|
+
["git", "-C", str(repo_root), *normalized_basis["git_args"], "--relative"],
|
|
747
747
|
check=False,
|
|
748
748
|
capture_output=True,
|
|
749
749
|
text=True,
|
|
@@ -774,7 +774,7 @@ def get_git_changed_files(repo_root: Path, diff_basis: dict[str, str | None]) ->
|
|
|
774
774
|
return sorted(set(path.strip() for path in changed if path.strip())), None
|
|
775
775
|
|
|
776
776
|
|
|
777
|
-
def get_phase_owned_actual_changed_files(file_name: str, actual_changed_files: list[str] | None) -> list[str] | None:
|
|
777
|
+
def get_phase_owned_actual_changed_files(file_name: str, actual_changed_files: list[str] | None, repo_root: Path | None = None) -> list[str] | None:
|
|
778
778
|
if actual_changed_files is None:
|
|
779
779
|
return None
|
|
780
780
|
if file_name == "02-to-be-plan.md":
|
|
@@ -797,7 +797,9 @@ def get_phase_owned_actual_changed_files(file_name: str, actual_changed_files: l
|
|
|
797
797
|
owned_paths.append(path)
|
|
798
798
|
|
|
799
799
|
if file_name in PRODUCT_DIFF_PHASE_FILES:
|
|
800
|
-
|
|
800
|
+
if repo_root is None:
|
|
801
|
+
return owned_paths
|
|
802
|
+
return [p for p in owned_paths if (repo_root / p).exists()]
|
|
801
803
|
if file_name in DECISIONS_DIFF_PHASE_FILES | STATE_DIFF_PHASE_FILES | MEMORY_DIFF_PHASE_FILES:
|
|
802
804
|
return owned_paths
|
|
803
805
|
return None
|
|
@@ -1432,7 +1434,7 @@ def lint_requirement_completion_status(
|
|
|
1432
1434
|
)
|
|
1433
1435
|
|
|
1434
1436
|
if file_path.name in REQUIREMENT_CHANGED_FILE_ACCOUNTING_FILES:
|
|
1435
|
-
expected_scope = set(get_phase_owned_actual_changed_files(file_path.name, actual_changed_files) or [])
|
|
1437
|
+
expected_scope = set(get_phase_owned_actual_changed_files(file_path.name, actual_changed_files, run_dir.parent.parent.parent) or [])
|
|
1436
1438
|
if expected_scope:
|
|
1437
1439
|
claimed_changed_files = set()
|
|
1438
1440
|
for fields in entries.values():
|
|
@@ -2476,7 +2478,7 @@ def lint_audit_sections(
|
|
|
2476
2478
|
if audit_status == "PASS" and gaps_body and not re.search(r"\bnone\b", gaps_body, re.IGNORECASE):
|
|
2477
2479
|
issues.append("Audit: PASS is invalid while Gaps Found still lists unresolved in-scope gaps")
|
|
2478
2480
|
|
|
2479
|
-
expected_changed_files = get_phase_owned_actual_changed_files(file_path.name, actual_changed_files)
|
|
2481
|
+
expected_changed_files = get_phase_owned_actual_changed_files(file_path.name, actual_changed_files, run_dir.parent.parent.parent)
|
|
2480
2482
|
if file_path.name in DIFF_AUDITED_FILES and expected_changed_files is not None:
|
|
2481
2483
|
if diff_basis_error:
|
|
2482
2484
|
issues.append(f"Cannot verify git diff basis: {diff_basis_error}")
|
|
@@ -25,8 +25,8 @@ import { resolveRole, capabilityProbe, loadRouterPolicy } from '../src/router.ts
|
|
|
25
25
|
import { validateReferences, writeActionRecord, reviewOutputSchema } from '../src/delegation.ts'
|
|
26
26
|
import { writeChildScratch, readParentScratch } from '../src/scratch.ts'
|
|
27
27
|
import { foldRecursivePhase, validateTransition, LifecycleDriver, coupleGateBlockToGoal, type SessionEventLike, type PhaseTransitionIntent } from '../src/lifecycle.ts'
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
28
|
+
import { snapshotWorkspace } from '../src/snapshot.ts'
|
|
29
|
+
import { RECURSIVE_API_PREFIX, makeRecursiveRoutes, mountRecursiveRoutesOnce } from '../src/live-route.ts'
|
|
30
30
|
import { columnForRun, cardFacts, nodeKeyOf, expandPhaseRows } from '../src/client/derive.ts'
|
|
31
31
|
import { evaluatePreStepGate, evaluateToolGuard, resolveEnforcementConfig, DEFAULT_ENFORCEMENT, detectTamper } from '../src/enforcement.ts'
|
|
32
32
|
import { renderRecursivePolicy } from '../src/policy.ts'
|
|
@@ -165,34 +165,35 @@ async function main() {
|
|
|
165
165
|
check('R6 goal coupling no-op', coupleGateBlockToGoal(null, {}, { id: 'g' }, { code: 'G', message: 'x' }) === false)
|
|
166
166
|
|
|
167
167
|
|
|
168
|
-
// ---- Phase D checks (
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
]
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
check('R9 out-of-workspace event rejected (same ref)', fold === before)
|
|
186
|
-
// R5/R6 derive
|
|
187
|
-
check('R5 columnForRun maps to implementation lane', columnForRun(card) === '3/3.5')
|
|
188
|
-
const facts = cardFacts(card)
|
|
189
|
-
check('R5 cardFacts progress + tampered + gateBlocked', facts.lockedCount === 1 && facts.tampered === true && facts.gateBlocked === true)
|
|
168
|
+
// ---- Phase D checks (SP2 R1 live route, replaces the session projection) ----
|
|
169
|
+
// The live route folds the FILESYSTEM (snapshotWorkspace), not session events.
|
|
170
|
+
const snapshot = snapshotWorkspace(wsA)
|
|
171
|
+
check('R2 snapshot groups by worktreeRoot', Object.keys(snapshot).length === 1 && snapshot[wsA] !== undefined)
|
|
172
|
+
const card = snapshot[wsA]?.['10-smoke']
|
|
173
|
+
// Wire contract parity: phases are keyed by FILENAME (the deleted projection.ts
|
|
174
|
+
// keyed recursive/phase events by filename), which derive.ts phaseGroupOf parses.
|
|
175
|
+
// 03 is DRAFT here (written for the tool-guard test); 06 exists from closeoutPhase.
|
|
176
|
+
check('R2/R9 snapshot card facts', card !== undefined && card.worktreeRoot === wsA && card.phases['03-implementation-summary.md']?.status === 'DRAFT')
|
|
177
|
+
// R9 worktree-scope: a run directory outside wsA is never folded.
|
|
178
|
+
check('R9 snapshot is workspace-scoped', Object.keys(snapshot).every(root => root === wsA))
|
|
179
|
+
// R5/R6 derive over the snapshot card (furthest present phase is 06 -> closeout lane).
|
|
180
|
+
check('R5 columnForRun maps to the furthest present phase', columnForRun(card as never) === '6-8')
|
|
181
|
+
const facts = cardFacts(card as never)
|
|
182
|
+
// 00-requirements.md is LOCKED (written above); 03 is DRAFT; no tamper;
|
|
183
|
+
// no gate-block (the fs carries none of the transient event facts).
|
|
184
|
+
check('R5 cardFacts progress + tampered + gateBlocked', facts.lockedCount === 1 && facts.tampered === false && facts.gateBlocked === false)
|
|
190
185
|
// R7 node key
|
|
191
186
|
check('R7 nodeKeyOf includes worktreeRoot', nodeKeyOf('10-smoke', wsA).includes(wsA))
|
|
192
|
-
check('R6 expandPhaseRows always shows 3.5', expandPhaseRows(card).some(r => r.phase === '03.5'))
|
|
193
|
-
// R2
|
|
194
|
-
check('R2
|
|
195
|
-
|
|
187
|
+
check('R6 expandPhaseRows always shows 3.5', expandPhaseRows(card as never).some(r => r.phase === '03.5'))
|
|
188
|
+
// R2 route: prefix + mountOnce-global (second mount no-ops)
|
|
189
|
+
check('R2 route prefix', RECURSIVE_API_PREFIX === '/.recursive/api')
|
|
190
|
+
const routes = makeRecursiveRoutes({ resolveRoot: async () => wsA, snapshot: async (root: string) => snapshotWorkspace(root), revision: () => 1 })
|
|
191
|
+
check('R2 route count', routes.length === 2 && routes[0].kind === 'exact' && routes[1].kind === 'exact')
|
|
192
|
+
let registerCount = 0
|
|
193
|
+
const fakeServer = { register: () => { registerCount++; return () => {} } }
|
|
194
|
+
const mk = () => mountRecursiveRoutesOnce('@try-works/dsh-recursive-mode', () => makeRecursiveRoutes({ resolveRoot: async () => wsA, snapshot: async (root: string) => snapshotWorkspace(root), revision: () => 1 }), fakeServer)
|
|
195
|
+
mk(); mk()
|
|
196
|
+
check('R2 mountOnce-global (second mount no-ops)', registerCount === 2)
|
|
196
197
|
|
|
197
198
|
console.log(failures === 0 ? 'SMOKE PASS' : 'SMOKE FAIL (' + failures + ' failures)')
|
|
198
199
|
process.exitCode = failures === 0 ? 0 : 1
|