superlab 0.1.62 → 0.1.64
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/lib/auto_state.cjs +3 -0
- package/lib/i18n.cjs +10 -4
- package/lib/install.cjs +1 -1
- package/lib/lab_write_contract.json +1 -1
- package/lib/paper_topology.cjs +91 -0
- package/lib/rule_preflight.cjs +49 -1
- package/package-assets/codex/prompts/lab/write.md +1 -1
- package/package-assets/codex/prompts/lab-write.md +1 -1
- package/package-assets/codex/prompts/lab:write.md +1 -1
- package/package-assets/codex/prompts/lab/357/274/232write.md +1 -1
- package/package-assets/shared/lab/.managed/scripts/paper_topology.py +91 -0
- package/package-assets/shared/lab/.managed/scripts/render_rule_preflight.py +115 -0
- package/package-assets/shared/lab/.managed/scripts/validate_manuscript_delivery.py +2 -0
- package/package-assets/shared/lab/.managed/scripts/validate_paper_topology.py +83 -0
- package/package-assets/shared/lab/.managed/scripts/validate_rule_preflight.py +183 -0
- package/package-assets/shared/lab/.managed/scripts/validate_section_draft.py +85 -32
- package/package-assets/shared/lab/.managed/templates/iteration-report.md +1 -0
- package/package-assets/shared/lab/.managed/templates/write-iteration.md +8 -0
- package/package-assets/shared/lab/context/auto-status.md +1 -0
- package/package-assets/shared/skills/lab/SKILL.md +5 -0
- package/package-assets/shared/skills/lab/stages/auto.md +1 -1
- package/package-assets/shared/skills/lab/stages/write.md +8 -2
- package/package.json +1 -1
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from validate_rule_preflight import compute_preflight_stamp
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_args():
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
description="Render or inject a machine-generated Rule Preflight block for a managed lab stage artifact."
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument("--artifact", required=True, help="Markdown artifact to update in place")
|
|
17
|
+
parser.add_argument("--stage", required=True, help="Stage name, e.g. write or auto")
|
|
18
|
+
parser.add_argument("--project-root", required=True, help="Project root containing .lab/.managed/rule-manifest.json")
|
|
19
|
+
parser.add_argument("--mode", required=True, help="Resolved mode for this round")
|
|
20
|
+
parser.add_argument("--target", required=True, help="Resolved target for this round")
|
|
21
|
+
parser.add_argument("--override-reason", default="", help="Optional override reason")
|
|
22
|
+
parser.add_argument("--lang", choices=("en", "zh"), default="en", help="Render language for the section")
|
|
23
|
+
return parser.parse_args()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_manifest(project_root: Path, stage: str) -> dict:
|
|
27
|
+
manifest_path = project_root / ".lab" / ".managed" / "rule-manifest.json"
|
|
28
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
29
|
+
stage_entry = (manifest.get("stages") or {}).get(stage)
|
|
30
|
+
if not stage_entry:
|
|
31
|
+
raise SystemExit(f"stage `{stage}` is missing from {manifest_path}")
|
|
32
|
+
return {
|
|
33
|
+
"rule_source_file": stage_entry.get("rule_source_file", ""),
|
|
34
|
+
"rule_source_revision": stage_entry.get("rule_source_revision", ""),
|
|
35
|
+
"project_version": manifest.get("package_version", ""),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def render_preflight_block(values: dict[str, str], lang: str) -> str:
|
|
40
|
+
values = dict(values)
|
|
41
|
+
values["preflight_stamp"] = compute_preflight_stamp(values)
|
|
42
|
+
if lang == "zh":
|
|
43
|
+
lines = [
|
|
44
|
+
"## 规则预检",
|
|
45
|
+
"",
|
|
46
|
+
f"- 规则来源文件: {values['rule_source_file']}",
|
|
47
|
+
f"- 规则来源修订: {values['rule_source_revision']}",
|
|
48
|
+
f"- 项目版本: {values['project_version']}",
|
|
49
|
+
f"- 解析后的阶段: {values['resolved_stage']}",
|
|
50
|
+
f"- 解析后的模式: {values['resolved_mode']}",
|
|
51
|
+
f"- 解析后的目标: {values['resolved_target']}",
|
|
52
|
+
f"- 预检签名: {values['preflight_stamp']}",
|
|
53
|
+
f"- 如有覆盖理由: {values['override_reason']}",
|
|
54
|
+
"",
|
|
55
|
+
]
|
|
56
|
+
else:
|
|
57
|
+
lines = [
|
|
58
|
+
"## Rule Preflight",
|
|
59
|
+
"",
|
|
60
|
+
f"- Rule source file: {values['rule_source_file']}",
|
|
61
|
+
f"- Rule source revision: {values['rule_source_revision']}",
|
|
62
|
+
f"- Project version: {values['project_version']}",
|
|
63
|
+
f"- Resolved stage: {values['resolved_stage']}",
|
|
64
|
+
f"- Resolved mode: {values['resolved_mode']}",
|
|
65
|
+
f"- Resolved target: {values['resolved_target']}",
|
|
66
|
+
f"- Preflight stamp: {values['preflight_stamp']}",
|
|
67
|
+
f"- Override reason, if any: {values['override_reason']}",
|
|
68
|
+
"",
|
|
69
|
+
]
|
|
70
|
+
return "\n".join(lines)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def replace_or_insert_rule_preflight(text: str, block: str) -> str:
|
|
74
|
+
pattern = re.compile(r"^##\s+(?:Rule Preflight|规则预检)\s*$", flags=re.MULTILINE)
|
|
75
|
+
match = pattern.search(text)
|
|
76
|
+
if match:
|
|
77
|
+
start = match.start()
|
|
78
|
+
next_heading = re.search(r"^##\s+", text[match.end():], flags=re.MULTILINE)
|
|
79
|
+
end = match.end() + next_heading.start() if next_heading else len(text)
|
|
80
|
+
prefix = text[:start].rstrip() + "\n\n"
|
|
81
|
+
suffix = text[end:].lstrip("\n")
|
|
82
|
+
return prefix + block.rstrip() + ("\n\n" + suffix if suffix else "\n")
|
|
83
|
+
|
|
84
|
+
title_match = re.search(r"^# .*$", text, flags=re.MULTILINE)
|
|
85
|
+
if not title_match:
|
|
86
|
+
return block.rstrip() + "\n\n" + text.lstrip()
|
|
87
|
+
insert_at = title_match.end()
|
|
88
|
+
prefix = text[:insert_at].rstrip() + "\n\n"
|
|
89
|
+
suffix = text[insert_at:].lstrip("\n")
|
|
90
|
+
return prefix + block.rstrip() + ("\n\n" + suffix if suffix else "\n")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main():
|
|
94
|
+
args = parse_args()
|
|
95
|
+
artifact_path = Path(args.artifact)
|
|
96
|
+
project_root = Path(args.project_root)
|
|
97
|
+
values = read_manifest(project_root, args.stage)
|
|
98
|
+
values.update(
|
|
99
|
+
{
|
|
100
|
+
"resolved_stage": args.stage,
|
|
101
|
+
"resolved_mode": args.mode,
|
|
102
|
+
"resolved_target": args.target,
|
|
103
|
+
"override_reason": args.override_reason,
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
block = render_preflight_block(values, args.lang)
|
|
107
|
+
original = artifact_path.read_text(encoding="utf-8") if artifact_path.exists() else ""
|
|
108
|
+
updated = replace_or_insert_rule_preflight(original, block)
|
|
109
|
+
artifact_path.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
artifact_path.write_text(updated, encoding="utf-8")
|
|
111
|
+
print("rule preflight written")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
main()
|
|
@@ -5,6 +5,7 @@ import re
|
|
|
5
5
|
import sys
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from validate_rule_preflight import validate_rule_preflight
|
|
8
|
+
from validate_paper_topology import validate_topology_artifacts
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
ABSOLUTE_PATH_MARKERS = ("/Users/", "/home/", "/tmp/", "/private/tmp/")
|
|
@@ -609,6 +610,7 @@ def check_latest_write_iteration_preflight(paper_dir: Path, issues: list[str]):
|
|
|
609
610
|
issues.append("missing latest write iteration needed for rule preflight validation")
|
|
610
611
|
return
|
|
611
612
|
issues.extend(validate_rule_preflight(latest_iteration, "write", project_root=project_root))
|
|
613
|
+
issues.extend(validate_topology_artifacts(project_root))
|
|
612
614
|
|
|
613
615
|
|
|
614
616
|
def main():
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from paper_topology import (
|
|
9
|
+
classify_paper_path,
|
|
10
|
+
find_project_root,
|
|
11
|
+
iter_path_tokens,
|
|
12
|
+
line_is_deprecated,
|
|
13
|
+
resolve_paper_topology,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_args():
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
description="Validate that plan/context artifacts point at the active paper topology instead of legacy side layers."
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument("--project-root", required=True, help="Project root containing .lab/config/workflow.json")
|
|
22
|
+
return parser.parse_args()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_text(path: Path) -> str:
|
|
26
|
+
return path.read_text(encoding="utf-8")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_file(file_path: Path, topology: dict, issues: list[str]):
|
|
30
|
+
if not file_path.exists():
|
|
31
|
+
return
|
|
32
|
+
text = read_text(file_path)
|
|
33
|
+
current_heading_deprecated = False
|
|
34
|
+
for line_number, line in enumerate(text.splitlines(), start=1):
|
|
35
|
+
stripped = line.strip()
|
|
36
|
+
if stripped.startswith("#"):
|
|
37
|
+
current_heading_deprecated = line_is_deprecated(stripped)
|
|
38
|
+
for _, _, token in iter_path_tokens(line):
|
|
39
|
+
role, normalized_path = classify_paper_path(token, topology)
|
|
40
|
+
if role in {"active-canonical", "active-workflow-language"}:
|
|
41
|
+
continue
|
|
42
|
+
if current_heading_deprecated or line_is_deprecated(line):
|
|
43
|
+
continue
|
|
44
|
+
if role == "legacy-paper-layer":
|
|
45
|
+
issues.append(
|
|
46
|
+
f"{file_path}:line {line_number} treats legacy-paper-layer path as active topology: {token} -> {normalized_path}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_topology_artifacts(project_root: Path) -> list[str]:
|
|
51
|
+
topology = resolve_paper_topology(project_root)
|
|
52
|
+
files_to_check = [
|
|
53
|
+
project_root / ".lab" / "writing" / "plan.md",
|
|
54
|
+
topology["deliverables_root"] / "artifact-status.md",
|
|
55
|
+
project_root / ".lab" / "context" / "evidence-index.md",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
issues: list[str] = []
|
|
59
|
+
for file_path in files_to_check:
|
|
60
|
+
validate_file(file_path, topology, issues)
|
|
61
|
+
return issues
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main():
|
|
65
|
+
args = parse_args()
|
|
66
|
+
project_root = find_project_root(Path(args.project_root).resolve())
|
|
67
|
+
if project_root is None:
|
|
68
|
+
print("could not find .lab/config/workflow.json for project root", file=sys.stderr)
|
|
69
|
+
return 1
|
|
70
|
+
|
|
71
|
+
issues = validate_topology_artifacts(project_root)
|
|
72
|
+
|
|
73
|
+
if issues:
|
|
74
|
+
for issue in issues:
|
|
75
|
+
print(issue, file=sys.stderr)
|
|
76
|
+
return 1
|
|
77
|
+
|
|
78
|
+
print("paper topology is consistent")
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import argparse
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SECTION_PATTERNS = (r"^##\s+Rule Preflight\s*$", r"^##\s+规则预检\s*$")
|
|
11
|
+
FIELD_LABELS = {
|
|
12
|
+
"rule_source_file": ("Rule source file", "规则来源文件"),
|
|
13
|
+
"rule_source_revision": ("Rule source revision", "规则来源修订"),
|
|
14
|
+
"project_version": ("Project version", "项目版本"),
|
|
15
|
+
"resolved_stage": ("Resolved stage", "解析后的阶段"),
|
|
16
|
+
"resolved_mode": ("Resolved mode", "解析后的模式"),
|
|
17
|
+
"resolved_target": ("Resolved target", "解析后的目标"),
|
|
18
|
+
"preflight_stamp": ("Preflight stamp", "预检签名"),
|
|
19
|
+
"override_reason": ("Override reason, if any", "Override 理由(如有)"),
|
|
20
|
+
}
|
|
21
|
+
BASE_REQUIRED_FIELD_KEYS = (
|
|
22
|
+
"rule_source_file",
|
|
23
|
+
"rule_source_revision",
|
|
24
|
+
"project_version",
|
|
25
|
+
"resolved_stage",
|
|
26
|
+
"resolved_mode",
|
|
27
|
+
"resolved_target",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_args():
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
description="Validate that a managed stage artifact records a consistent Rule Preflight block."
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument("--artifact", required=True, help="Path to the managed stage artifact")
|
|
36
|
+
parser.add_argument("--stage", required=True, help="Stage name, e.g. write or auto")
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--project-root",
|
|
39
|
+
required=False,
|
|
40
|
+
help="Optional project root; if omitted, infer it by walking upward for .lab/config/workflow.json",
|
|
41
|
+
)
|
|
42
|
+
return parser.parse_args()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def read_text(path: Path) -> str:
|
|
46
|
+
return path.read_text(encoding="utf-8")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def find_project_root(start_path: Path) -> Path | None:
|
|
50
|
+
for parent in (start_path.parent, *start_path.parents):
|
|
51
|
+
workflow_config = parent / ".lab" / "config" / "workflow.json"
|
|
52
|
+
if workflow_config.exists():
|
|
53
|
+
return parent
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def extract_section_body(text: str, patterns: tuple[str, ...]) -> str:
|
|
58
|
+
for pattern in patterns:
|
|
59
|
+
match = re.search(pattern, text, flags=re.MULTILINE)
|
|
60
|
+
if not match:
|
|
61
|
+
continue
|
|
62
|
+
start = match.end()
|
|
63
|
+
next_heading = re.search(r"^##\s+", text[start:], flags=re.MULTILINE)
|
|
64
|
+
end = start + next_heading.start() if next_heading else len(text)
|
|
65
|
+
return text[start:end].strip()
|
|
66
|
+
return ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def extract_field_value(body: str, labels: tuple[str, ...]) -> str | None:
|
|
70
|
+
for label in labels:
|
|
71
|
+
pattern = re.compile(
|
|
72
|
+
rf"^\s*(?:-|\d+\.)\s*{re.escape(label)}[::][ \t]*([^\n]+?)\s*$",
|
|
73
|
+
flags=re.MULTILINE,
|
|
74
|
+
)
|
|
75
|
+
for match in pattern.finditer(body):
|
|
76
|
+
return match.group(1).strip()
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def has_meaningful_value(value: str | None) -> bool:
|
|
81
|
+
if value is None:
|
|
82
|
+
return False
|
|
83
|
+
normalized = value.strip().lower()
|
|
84
|
+
return normalized not in {"", "-", "n/a", "na", "none", "null", "todo", "tbd"}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def compute_preflight_stamp(values: dict[str, str | None]) -> str:
|
|
88
|
+
payload = {
|
|
89
|
+
"ruleSourceFile": values.get("rule_source_file") or "",
|
|
90
|
+
"ruleSourceRevision": values.get("rule_source_revision") or "",
|
|
91
|
+
"projectVersion": values.get("project_version") or "",
|
|
92
|
+
"resolvedStage": values.get("resolved_stage") or "",
|
|
93
|
+
"resolvedMode": values.get("resolved_mode") or "",
|
|
94
|
+
"resolvedTarget": values.get("resolved_target") or "",
|
|
95
|
+
"overrideReason": values.get("override_reason") or "",
|
|
96
|
+
}
|
|
97
|
+
return hashlib.sha256(json.dumps(payload, ensure_ascii=False).encode("utf-8")).hexdigest()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def validate_rule_preflight(artifact_path: Path, stage: str, project_root: Path | None = None) -> list[str]:
|
|
101
|
+
issues: list[str] = []
|
|
102
|
+
if project_root is None:
|
|
103
|
+
project_root = find_project_root(artifact_path)
|
|
104
|
+
if project_root is None:
|
|
105
|
+
issues.append("could not infer project root for rule preflight validation")
|
|
106
|
+
return issues
|
|
107
|
+
|
|
108
|
+
manifest_path = project_root / ".lab" / ".managed" / "rule-manifest.json"
|
|
109
|
+
if not manifest_path.exists():
|
|
110
|
+
issues.append(f"missing managed rule manifest: {manifest_path.as_posix()}")
|
|
111
|
+
return issues
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
manifest = json.loads(read_text(manifest_path))
|
|
115
|
+
except json.JSONDecodeError:
|
|
116
|
+
issues.append(f"managed rule manifest is invalid JSON: {manifest_path.as_posix()}")
|
|
117
|
+
return issues
|
|
118
|
+
|
|
119
|
+
stage_entry = (manifest.get("stages") or {}).get(stage)
|
|
120
|
+
if not stage_entry:
|
|
121
|
+
issues.append(f"managed rule manifest does not define stage `{stage}`")
|
|
122
|
+
return issues
|
|
123
|
+
|
|
124
|
+
if not artifact_path.exists():
|
|
125
|
+
issues.append(f"artifact does not exist: {artifact_path.as_posix()}")
|
|
126
|
+
return issues
|
|
127
|
+
|
|
128
|
+
body = extract_section_body(read_text(artifact_path), SECTION_PATTERNS)
|
|
129
|
+
if not body:
|
|
130
|
+
issues.append("missing Rule Preflight section")
|
|
131
|
+
return issues
|
|
132
|
+
|
|
133
|
+
values = {
|
|
134
|
+
key: extract_field_value(body, labels)
|
|
135
|
+
for key, labels in FIELD_LABELS.items()
|
|
136
|
+
}
|
|
137
|
+
required_field_keys = list(BASE_REQUIRED_FIELD_KEYS)
|
|
138
|
+
if int(manifest.get("layout_version") or 0) >= 2:
|
|
139
|
+
required_field_keys.append("preflight_stamp")
|
|
140
|
+
missing = [key for key in required_field_keys if not has_meaningful_value(values[key])]
|
|
141
|
+
if missing:
|
|
142
|
+
pretty = ", ".join(FIELD_LABELS[key][0] for key in missing)
|
|
143
|
+
issues.append(f"missing required rule preflight fields: {pretty}")
|
|
144
|
+
return issues
|
|
145
|
+
|
|
146
|
+
if values["rule_source_file"] != stage_entry.get("rule_source_file"):
|
|
147
|
+
issues.append(
|
|
148
|
+
f"rule preflight source file mismatch: expected {stage_entry.get('rule_source_file')}, got {values['rule_source_file']}"
|
|
149
|
+
)
|
|
150
|
+
if values["rule_source_revision"] != stage_entry.get("rule_source_revision"):
|
|
151
|
+
issues.append(
|
|
152
|
+
"rule preflight source revision mismatch"
|
|
153
|
+
)
|
|
154
|
+
if values["project_version"] != manifest.get("package_version"):
|
|
155
|
+
issues.append(
|
|
156
|
+
f"rule preflight project version mismatch: expected {manifest.get('package_version')}, got {values['project_version']}"
|
|
157
|
+
)
|
|
158
|
+
if values["resolved_stage"].strip().lower() != stage.strip().lower():
|
|
159
|
+
issues.append(
|
|
160
|
+
f"rule preflight stage mismatch: expected {stage}, got {values['resolved_stage']}"
|
|
161
|
+
)
|
|
162
|
+
if int(manifest.get("layout_version") or 0) >= 2:
|
|
163
|
+
expected_stamp = compute_preflight_stamp(values)
|
|
164
|
+
if values["preflight_stamp"] != expected_stamp:
|
|
165
|
+
issues.append("rule preflight stamp mismatch")
|
|
166
|
+
return issues
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main():
|
|
170
|
+
args = parse_args()
|
|
171
|
+
artifact_path = Path(args.artifact)
|
|
172
|
+
project_root = Path(args.project_root) if args.project_root else None
|
|
173
|
+
issues = validate_rule_preflight(artifact_path, args.stage, project_root=project_root)
|
|
174
|
+
if not issues:
|
|
175
|
+
print("rule preflight is valid")
|
|
176
|
+
return 0
|
|
177
|
+
for issue in issues:
|
|
178
|
+
print(issue, file=sys.stderr)
|
|
179
|
+
return 1
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
raise SystemExit(main())
|
|
@@ -4,6 +4,13 @@ import json
|
|
|
4
4
|
import re
|
|
5
5
|
import sys
|
|
6
6
|
from pathlib import Path
|
|
7
|
+
from paper_topology import (
|
|
8
|
+
classify_paper_path,
|
|
9
|
+
find_project_root,
|
|
10
|
+
load_workflow_config,
|
|
11
|
+
resolve_paper_topology,
|
|
12
|
+
)
|
|
13
|
+
from validate_paper_topology import validate_topology_artifacts
|
|
7
14
|
from validate_rule_preflight import validate_rule_preflight
|
|
8
15
|
|
|
9
16
|
|
|
@@ -60,26 +67,6 @@ def has_local_naming_bridge(text: str) -> bool:
|
|
|
60
67
|
)
|
|
61
68
|
|
|
62
69
|
|
|
63
|
-
def find_project_root(section_path: Path) -> Path | None:
|
|
64
|
-
for parent in (section_path.parent, *section_path.parents):
|
|
65
|
-
workflow_config = parent / ".lab" / "config" / "workflow.json"
|
|
66
|
-
if workflow_config.exists():
|
|
67
|
-
return parent
|
|
68
|
-
return None
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def load_workflow_config(project_root: Path | None) -> dict:
|
|
72
|
-
if project_root is None:
|
|
73
|
-
return {}
|
|
74
|
-
workflow_config = project_root / ".lab" / "config" / "workflow.json"
|
|
75
|
-
if not workflow_config.exists():
|
|
76
|
-
return {}
|
|
77
|
-
try:
|
|
78
|
-
return json.loads(workflow_config.read_text(encoding="utf-8"))
|
|
79
|
-
except json.JSONDecodeError:
|
|
80
|
-
return {}
|
|
81
|
-
|
|
82
|
-
|
|
83
70
|
def extract_markdown_field(text: str, section_heading: str, field_label: str) -> str:
|
|
84
71
|
section_match = re.search(
|
|
85
72
|
rf"^##\s+{re.escape(section_heading)}\s*$([\s\S]*?)(?=^##\s+|\Z)",
|
|
@@ -317,6 +304,53 @@ def check_neighbor_asset_files(section: str, section_path: Path, issues: list[st
|
|
|
317
304
|
)
|
|
318
305
|
|
|
319
306
|
|
|
307
|
+
def check_paper_topology_targeting(section_path: Path, issues: list[str]):
|
|
308
|
+
project_root = find_project_root(section_path)
|
|
309
|
+
workflow_config = load_workflow_config(project_root)
|
|
310
|
+
if project_root is None or not workflow_config:
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
topology = resolve_paper_topology(project_root, workflow_config)
|
|
314
|
+
target_role, _ = classify_paper_path(section_path, topology)
|
|
315
|
+
if target_role in {"active-canonical", "active-workflow-language"}:
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
iteration_dir = project_root / ".lab" / "writing" / "iterations"
|
|
319
|
+
if not iteration_dir.exists():
|
|
320
|
+
issues.append(
|
|
321
|
+
"section target is outside the active paper topology, but no write-iteration audit explains why this out-of-band edit was acceptable"
|
|
322
|
+
)
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
iteration_files = sorted(iteration_dir.glob("*.md"))
|
|
326
|
+
if not iteration_files:
|
|
327
|
+
issues.append(
|
|
328
|
+
"section target is outside the active paper topology, but no write-iteration audit explains why this out-of-band edit was acceptable"
|
|
329
|
+
)
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
iteration_text = read_text(iteration_files[-1])
|
|
333
|
+
recorded_role = extract_markdown_field(
|
|
334
|
+
iteration_text,
|
|
335
|
+
"Paper Topology Target",
|
|
336
|
+
"Resolved target path role:",
|
|
337
|
+
)
|
|
338
|
+
out_of_band_reason = extract_markdown_field(
|
|
339
|
+
iteration_text,
|
|
340
|
+
"Paper Topology Target",
|
|
341
|
+
"If the target path was outside the active paper topology, why was out-of-band editing acceptable:",
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
if recorded_role and target_role not in recorded_role:
|
|
345
|
+
issues.append(
|
|
346
|
+
f"section target is outside the active paper topology, but the latest write iteration records a different target role ({recorded_role})"
|
|
347
|
+
)
|
|
348
|
+
if not has_meaningful_field_value(out_of_band_reason):
|
|
349
|
+
issues.append(
|
|
350
|
+
"section target is outside the active paper topology, but the latest write iteration does not explain why this out-of-band edit was acceptable"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
320
354
|
def check_workflow_language_targeting(section_path: Path, issues: list[str]):
|
|
321
355
|
project_root = find_project_root(section_path)
|
|
322
356
|
workflow_config = load_workflow_config(project_root)
|
|
@@ -325,7 +359,10 @@ def check_workflow_language_targeting(section_path: Path, issues: list[str]):
|
|
|
325
359
|
if not workflow_language or not paper_language or workflow_language == paper_language:
|
|
326
360
|
return
|
|
327
361
|
|
|
328
|
-
if
|
|
362
|
+
topology = resolve_paper_topology(project_root, workflow_config) if project_root else None
|
|
363
|
+
target_role = classify_paper_path(section_path, topology)[0] if topology else ""
|
|
364
|
+
|
|
365
|
+
if target_role != "active-canonical":
|
|
329
366
|
return
|
|
330
367
|
|
|
331
368
|
if project_root is None:
|
|
@@ -388,6 +425,13 @@ def check_write_rule_preflight(section_path: Path, issues: list[str]):
|
|
|
388
425
|
issues.extend(validate_rule_preflight(latest_iteration, "write", project_root=project_root))
|
|
389
426
|
|
|
390
427
|
|
|
428
|
+
def check_active_paper_topology(section_path: Path, issues: list[str]):
|
|
429
|
+
project_root = find_project_root(section_path)
|
|
430
|
+
if project_root is None:
|
|
431
|
+
return
|
|
432
|
+
issues.extend(validate_topology_artifacts(project_root))
|
|
433
|
+
|
|
434
|
+
|
|
391
435
|
def check_abstract(text: str, issues: list[str]):
|
|
392
436
|
numbers = re.findall(r"\b\d+(?:\.\d+)?\b", text)
|
|
393
437
|
if len(numbers) > 6:
|
|
@@ -514,24 +558,33 @@ def main():
|
|
|
514
558
|
return 1
|
|
515
559
|
|
|
516
560
|
text = read_text(section_path)
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
check_write_rule_preflight(section_path,
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
561
|
+
blocking_issues: list[str] = []
|
|
562
|
+
warning_issues: list[str] = []
|
|
563
|
+
check_write_rule_preflight(section_path, blocking_issues)
|
|
564
|
+
check_active_paper_topology(section_path, blocking_issues)
|
|
565
|
+
check_paper_topology_targeting(section_path, blocking_issues)
|
|
566
|
+
check_workflow_language_targeting(section_path, blocking_issues)
|
|
567
|
+
check_common_section_gate_risks(text, warning_issues)
|
|
568
|
+
check_section_style_policy(text, args.section, warning_issues)
|
|
569
|
+
SECTION_CHECKS[args.section](text, warning_issues)
|
|
570
|
+
check_neighbor_asset_files(args.section, section_path, warning_issues)
|
|
571
|
+
|
|
572
|
+
if not blocking_issues and not warning_issues:
|
|
526
573
|
print("section draft is valid")
|
|
527
574
|
return 0
|
|
528
575
|
|
|
529
576
|
if args.mode == "draft":
|
|
530
|
-
|
|
577
|
+
if blocking_issues:
|
|
578
|
+
for issue in blocking_issues:
|
|
579
|
+
print(issue, file=sys.stderr)
|
|
580
|
+
for issue in warning_issues:
|
|
581
|
+
print(f"WARNING: {issue}")
|
|
582
|
+
return 1
|
|
583
|
+
for issue in warning_issues:
|
|
531
584
|
print(f"WARNING: {issue}")
|
|
532
585
|
return 0
|
|
533
586
|
|
|
534
|
-
for issue in
|
|
587
|
+
for issue in [*blocking_issues, *warning_issues]:
|
|
535
588
|
print(issue, file=sys.stderr)
|
|
536
589
|
return 1
|
|
537
590
|
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
- Resolved stage:
|
|
9
9
|
- Resolved mode:
|
|
10
10
|
- Resolved target:
|
|
11
|
+
- Preflight stamp:
|
|
11
12
|
- Override reason, if any:
|
|
12
13
|
|
|
13
14
|
## Round
|
|
@@ -92,6 +93,13 @@
|
|
|
92
93
|
- If both canonical manuscript and workflow-language layer were edited, why:
|
|
93
94
|
- Was cross-language sync explicitly requested or required by final-draft/export:
|
|
94
95
|
|
|
96
|
+
## Paper Topology Target
|
|
97
|
+
|
|
98
|
+
- Active canonical paper root:
|
|
99
|
+
- Active workflow-language paper root:
|
|
100
|
+
- Resolved target path role:
|
|
101
|
+
- If the target path was outside the active paper topology, why was out-of-band editing acceptable:
|
|
102
|
+
|
|
95
103
|
## Protocol / Scope Impact Audit
|
|
96
104
|
|
|
97
105
|
- Did this round replace a canonical experiment or evaluation protocol:
|
|
@@ -37,7 +37,9 @@ Use this skill when the user invokes `/lab:*` or asks for the structured researc
|
|
|
37
37
|
- `Resolved stage`
|
|
38
38
|
- `Resolved mode`
|
|
39
39
|
- `Resolved target`
|
|
40
|
+
- `Preflight stamp`
|
|
40
41
|
- `Override reason, if any`
|
|
42
|
+
- Generate the `Rule Preflight` block from `.lab/.managed/rule-manifest.json` with the managed preflight renderer instead of handwriting it from memory.
|
|
41
43
|
- Treat missing, stale, or contradictory `Rule Preflight` data as a stage-contract failure.
|
|
42
44
|
- Project-installed rules take priority over model memory. If remembered patterns conflict with the installed rule source, follow the installed source recorded in `.lab/.managed/rule-manifest.json`.
|
|
43
45
|
- Final paper output should default to LaTeX, and its manuscript language should be decided separately from the workflow language.
|
|
@@ -225,8 +227,10 @@ Use this skill when the user invokes `/lab:*` or asks for the structured researc
|
|
|
225
227
|
- Ordinary manuscript drafting rounds should follow `workflow_language`.
|
|
226
228
|
- In those ordinary rounds, the manuscript is still LaTeX, so the initial `.tex` section drafts must stay in `workflow_language` rather than jumping straight to `paper_language`.
|
|
227
229
|
- When `workflow_language` and `paper_language` differ, treat the workflow-language paper layer as active and use it as the default ordinary working layer.
|
|
230
|
+
- Resolve the active paper topology from `.lab/config/workflow.json` before drafting; only paths under `<deliverables_root>/paper/` or `<deliverables_root>/paper/workflow-language/` count as managed manuscript targets.
|
|
228
231
|
- Ordinary write rounds should still edit one target paper layer at a time, not both language layers at once.
|
|
229
232
|
- If the user names a concrete file or layer, treat that as the only target for the round unless they also explicitly request synchronization.
|
|
233
|
+
- If the named file falls outside the active paper topology, treat it as an out-of-band file edit instead of a managed manuscript round.
|
|
230
234
|
- If a workflow-language paper layer is active and the round still targets the canonical manuscript, record why canonical-only writing was acceptable in the latest write iteration artifact.
|
|
231
235
|
- If `paper_language_finalization_decision=convert-to-paper-language`, explicit canonical-manuscript work may target the canonical `paper_language` manuscript, but that does not make canonical the default ordinary working layer while workflow-language remains active.
|
|
232
236
|
- Treat the workflow-language paper as a real persisted paper layer, not as a review layer.
|
|
@@ -279,6 +283,7 @@ Use this skill when the user invokes `/lab:*` or asks for the structured researc
|
|
|
279
283
|
- Record the round target layer in the latest write iteration artifact as `canonical manuscript`, `workflow-language paper layer`, or `both`.
|
|
280
284
|
- If workflow-language was active and the round still targeted the canonical manuscript, record why canonical-only writing was acceptable in the latest write iteration artifact.
|
|
281
285
|
- If both layers were edited, record why the cross-language sync was required and whether it was explicitly requested by the user or required by final-draft/export finalization.
|
|
286
|
+
- Record the active canonical paper root, active workflow-language paper root, resolved target path role, and any out-of-band justification in the latest write iteration artifact.
|
|
282
287
|
- Record the protocol/scope impact audit in the latest write iteration artifact whenever a round replaces or rewrites a canonical experiment/evaluation protocol.
|
|
283
288
|
- If the round is an export or remote-publication round, record the export target, whether the workflow-language paper layer was included in the exported or pushed bundle, and why any canonical-only export was acceptable.
|
|
284
289
|
- If a claim is not supported by evidence, weaken or remove it.
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
## Rule Preflight
|
|
30
30
|
|
|
31
31
|
- Read `.lab/.managed/rule-manifest.json` before arming auto mode.
|
|
32
|
-
- The visible `Auto preflight` summary must also record the installed rule source file, rule source revision, project version, resolved stage, resolved mode, resolved target, and any override reason.
|
|
32
|
+
- The visible `Auto preflight` summary must also record the installed rule source file, rule source revision, project version, resolved stage, resolved mode, resolved target, a machine-generated preflight stamp, and any override reason.
|
|
33
33
|
- Keep the same `Rule Preflight` fields in `.lab/context/auto-status.md` while the campaign is live.
|
|
34
34
|
- If the installed auto rule and the current campaign behavior disagree, stop and fix the contract or record a valid override reason before launching the loop.
|
|
35
35
|
|