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.
@@ -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 "workflow-language" in section_path.parts:
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
- issues: list[str] = []
518
- check_common_section_gate_risks(text, issues)
519
- check_write_rule_preflight(section_path, issues)
520
- check_workflow_language_targeting(section_path, issues)
521
- check_section_style_policy(text, args.section, issues)
522
- SECTION_CHECKS[args.section](text, issues)
523
- check_neighbor_asset_files(args.section, section_path, issues)
524
-
525
- if not issues:
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
- for issue in issues:
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 issues:
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
@@ -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:
@@ -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
  ## Runtime State
@@ -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