@deftai/directive-content 0.55.2 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +2 -2
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +47 -1
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/scripts/_agents_md.py +494 -0
  9. package/scripts/_cache_fetch.py +635 -0
  10. package/scripts/_cache_quota.py +529 -0
  11. package/scripts/_cache_refresh.py +163 -0
  12. package/scripts/_cache_validate.py +209 -0
  13. package/scripts/_content_root.py +42 -0
  14. package/scripts/_doctor_state.py +277 -0
  15. package/scripts/_event_detect.py +305 -0
  16. package/scripts/_events.py +514 -0
  17. package/scripts/_lifecycle_hygiene.py +568 -0
  18. package/scripts/_pathspec.py +91 -0
  19. package/scripts/_policy_show_cli.py +266 -0
  20. package/scripts/_precutover.py +92 -0
  21. package/scripts/_project_context.py +224 -0
  22. package/scripts/_project_definition_io.py +164 -0
  23. package/scripts/_relocate_snapshot.py +209 -0
  24. package/scripts/_relocate_states.py +343 -0
  25. package/scripts/_resolve_preflight_path.py +152 -0
  26. package/scripts/_safe_subprocess.py +167 -0
  27. package/scripts/_session_start_hook.py +205 -0
  28. package/scripts/_sor_gate_diff.py +365 -0
  29. package/scripts/_stdio_utf8.py +59 -0
  30. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  31. package/scripts/_triage_classify_cli.py +122 -0
  32. package/scripts/_triage_queue_cli.py +625 -0
  33. package/scripts/_triage_scope_cli.py +343 -0
  34. package/scripts/_triage_scope_drift_cli.py +121 -0
  35. package/scripts/_triage_scope_ignores.py +286 -0
  36. package/scripts/_triage_scope_milestone.py +432 -0
  37. package/scripts/_triage_scope_mutations.py +337 -0
  38. package/scripts/_triage_scope_renderers.py +207 -0
  39. package/scripts/_triage_smoketest_stages.py +674 -0
  40. package/scripts/_triage_subscribe_cli.py +140 -0
  41. package/scripts/_triage_welcome_cli.py +421 -0
  42. package/scripts/_vbrief_build.py +239 -0
  43. package/scripts/_vbrief_fidelity.py +479 -0
  44. package/scripts/_vbrief_legacy.py +589 -0
  45. package/scripts/_vbrief_reconciliation.py +883 -0
  46. package/scripts/_vbrief_routing.py +277 -0
  47. package/scripts/_vbrief_safety.py +778 -0
  48. package/scripts/_vbrief_sources.py +312 -0
  49. package/scripts/_vbrief_speckit.py +262 -0
  50. package/scripts/_vbrief_story_quality.py +353 -0
  51. package/scripts/_vbrief_validation.py +299 -0
  52. package/scripts/build_dist.py +412 -0
  53. package/scripts/cache.py +1078 -0
  54. package/scripts/cache_scanner.py +745 -0
  55. package/scripts/candidates_log.py +432 -0
  56. package/scripts/capacity_backfill.py +680 -0
  57. package/scripts/capacity_show.py +653 -0
  58. package/scripts/ci_local.py +689 -0
  59. package/scripts/code_structure_validate.py +765 -0
  60. package/scripts/codebase_default_extractor.py +495 -0
  61. package/scripts/codebase_map.py +304 -0
  62. package/scripts/codebase_map_fresh.py +104 -0
  63. package/scripts/codebase_projection_registry.py +94 -0
  64. package/scripts/codebase_provider.py +582 -0
  65. package/scripts/doctor.py +2257 -0
  66. package/scripts/framework_commands.py +505 -0
  67. package/scripts/gh_rest.py +882 -0
  68. package/scripts/github_auth_modes.py +437 -0
  69. package/scripts/github_body.py +292 -0
  70. package/scripts/ip_risk.py +531 -0
  71. package/scripts/issue_emit.py +670 -0
  72. package/scripts/issue_ingest.py +1064 -0
  73. package/scripts/migrate_preflight.py +418 -0
  74. package/scripts/migrate_vbrief.py +2677 -0
  75. package/scripts/monitor_pr.py +401 -0
  76. package/scripts/pack_migrate_lessons.py +336 -0
  77. package/scripts/pack_migrate_patterns.py +254 -0
  78. package/scripts/pack_migrate_rules.py +350 -0
  79. package/scripts/pack_migrate_skills.py +423 -0
  80. package/scripts/pack_migrate_strategies.py +311 -0
  81. package/scripts/pack_migrate_swarm_spec.py +250 -0
  82. package/scripts/pack_render.py +434 -0
  83. package/scripts/packs_slice.py +712 -0
  84. package/scripts/platform_capabilities.py +336 -0
  85. package/scripts/policy.py +2826 -0
  86. package/scripts/policy_set.py +324 -0
  87. package/scripts/pr_check_closing_keywords.py +524 -0
  88. package/scripts/pr_check_protected_issues.py +267 -0
  89. package/scripts/pr_merge_readiness.py +1004 -0
  90. package/scripts/pr_wait_mergeable.py +669 -0
  91. package/scripts/prd_render.py +159 -0
  92. package/scripts/preflight_architecture_sor.py +974 -0
  93. package/scripts/preflight_branch.py +289 -0
  94. package/scripts/preflight_cache.py +974 -0
  95. package/scripts/preflight_gh.py +721 -0
  96. package/scripts/preflight_implementation.py +272 -0
  97. package/scripts/preflight_story_start.py +838 -0
  98. package/scripts/preflight_wip_cap.py +149 -0
  99. package/scripts/probe_session.py +545 -0
  100. package/scripts/project_render.py +293 -0
  101. package/scripts/quarantine_ext.py +237 -0
  102. package/scripts/reconcile_issues.py +1442 -0
  103. package/scripts/refresh-path.ps1 +107 -0
  104. package/scripts/release.py +2030 -0
  105. package/scripts/release_e2e.py +1011 -0
  106. package/scripts/release_publish.py +486 -0
  107. package/scripts/release_rollback.py +980 -0
  108. package/scripts/relocate.py +1034 -0
  109. package/scripts/resolve_changelog_unreleased.py +667 -0
  110. package/scripts/resolve_version.py +490 -0
  111. package/scripts/resume_conditions.py +706 -0
  112. package/scripts/ritual_sentinel.py +609 -0
  113. package/scripts/roadmap_render.py +635 -0
  114. package/scripts/rule_ownership_lint.py +325 -0
  115. package/scripts/scm.py +591 -0
  116. package/scripts/scope_audit_log.py +387 -0
  117. package/scripts/scope_decompose.py +654 -0
  118. package/scripts/scope_demote.py +509 -0
  119. package/scripts/scope_lifecycle.py +1126 -0
  120. package/scripts/scope_undo.py +772 -0
  121. package/scripts/session_start.py +406 -0
  122. package/scripts/setup_ghx.py +339 -0
  123. package/scripts/setup_windows.ps1 +220 -0
  124. package/scripts/slice_audit.py +585 -0
  125. package/scripts/slice_record.py +530 -0
  126. package/scripts/slice_record_existing.py +692 -0
  127. package/scripts/slug_normalize.py +178 -0
  128. package/scripts/spec_render.py +477 -0
  129. package/scripts/spec_validate.py +238 -0
  130. package/scripts/subagent_monitor.py +658 -0
  131. package/scripts/swarm_complete_cohort.py +644 -0
  132. package/scripts/swarm_launch.py +1206 -0
  133. package/scripts/swarm_readiness.py +554 -0
  134. package/scripts/swarm_verify_review_clean.py +438 -0
  135. package/scripts/swarm_worktrees.py +497 -0
  136. package/scripts/toolchain-check.py +52 -0
  137. package/scripts/triage_actions.py +871 -0
  138. package/scripts/triage_bootstrap.py +1153 -0
  139. package/scripts/triage_bulk.py +630 -0
  140. package/scripts/triage_classify.py +932 -0
  141. package/scripts/triage_help.py +1685 -0
  142. package/scripts/triage_queue.py +1944 -0
  143. package/scripts/triage_reconcile.py +581 -0
  144. package/scripts/triage_refresh.py +643 -0
  145. package/scripts/triage_scope.py +999 -0
  146. package/scripts/triage_scope_drift.py +575 -0
  147. package/scripts/triage_smoketest.py +396 -0
  148. package/scripts/triage_subscribe.py +399 -0
  149. package/scripts/triage_summary.py +1011 -0
  150. package/scripts/triage_welcome.py +1178 -0
  151. package/scripts/ts_check_lane.py +86 -0
  152. package/scripts/validate-links.py +64 -0
  153. package/scripts/validate_strategy_output.py +212 -0
  154. package/scripts/vbrief_activate.py +228 -0
  155. package/scripts/vbrief_migrate_conformance.py +368 -0
  156. package/scripts/vbrief_reconcile_graph.py +306 -0
  157. package/scripts/vbrief_reconcile_labels.py +460 -0
  158. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  159. package/scripts/vbrief_validate.py +1195 -0
  160. package/scripts/verify-stubs.py +61 -0
  161. package/scripts/verify_capacity.py +160 -0
  162. package/scripts/verify_encoding.py +699 -0
  163. package/scripts/verify_hooks_installed.py +206 -0
  164. package/scripts/verify_investigation.py +360 -0
  165. package/scripts/verify_judgment_gates.py +827 -0
  166. package/scripts/verify_no_task_runtime.py +171 -0
  167. package/scripts/verify_scm_boundary.py +509 -0
  168. package/scripts/verify_session_ritual.py +389 -0
  169. package/scripts/verify_tools.py +426 -0
  170. package/scripts/verify_vbrief_conformance.py +478 -0
  171. package/tasks/architecture.yml +13 -0
  172. package/tasks/cache.yml +69 -0
  173. package/tasks/capacity.yml +38 -0
  174. package/tasks/change.yml +46 -0
  175. package/tasks/changelog.yml +24 -0
  176. package/tasks/ci.yml +49 -0
  177. package/tasks/codebase.yml +47 -0
  178. package/tasks/commit.yml +30 -0
  179. package/tasks/core.yml +126 -0
  180. package/tasks/deployments.yml +54 -0
  181. package/tasks/framework.yml +74 -0
  182. package/tasks/install.yml +60 -0
  183. package/tasks/issue.yml +50 -0
  184. package/tasks/migrate.yml +73 -0
  185. package/tasks/packs.yml +92 -0
  186. package/tasks/policy.yml +75 -0
  187. package/tasks/pr.yml +89 -0
  188. package/tasks/prd.yml +39 -0
  189. package/tasks/project.yml +27 -0
  190. package/tasks/reconcile.yml +32 -0
  191. package/tasks/relocate.yml +56 -0
  192. package/tasks/roadmap.yml +28 -0
  193. package/tasks/scm.yml +126 -0
  194. package/tasks/scope-undo.yml +36 -0
  195. package/tasks/scope.yml +141 -0
  196. package/tasks/session.yml +19 -0
  197. package/tasks/setup.yml +37 -0
  198. package/tasks/slice.yml +69 -0
  199. package/tasks/spec.yml +41 -0
  200. package/tasks/swarm.yml +85 -0
  201. package/tasks/toolchain.yml +13 -0
  202. package/tasks/triage-actions.yml +94 -0
  203. package/tasks/triage-bootstrap.yml +43 -0
  204. package/tasks/triage-bulk.yml +75 -0
  205. package/tasks/triage-classify.yml +30 -0
  206. package/tasks/triage-queue.yml +50 -0
  207. package/tasks/triage-reconcile.yml +29 -0
  208. package/tasks/triage-scope-drift.yml +29 -0
  209. package/tasks/triage-scope.yml +31 -0
  210. package/tasks/triage-smoketest.yml +33 -0
  211. package/tasks/triage-subscribe.yml +36 -0
  212. package/tasks/triage-summary.yml +29 -0
  213. package/tasks/triage-welcome.yml +32 -0
  214. package/tasks/ts.yml +328 -0
  215. package/tasks/vbrief.yml +206 -0
  216. package/tasks/verify.yml +292 -0
  217. package/templates/agents-entry.md +1 -1
@@ -0,0 +1,205 @@
1
+ """_session_start_hook.py -- session-start ritual sentinel writer (#1269).
2
+
3
+ Thin wrapper around :func:`scripts.ritual_sentinel.write`. The
4
+ session-start ritual orchestration calls this module at exit to persist
5
+ the sentinel; the module is intentionally minimal so the orchestrator
6
+ side can shell out without re-implementing the on-disk shape.
7
+
8
+ Today no canonical session-start orchestrator script exists in
9
+ ``deft``; this CLI is the entry point a future orchestrator will wire
10
+ into, and meanwhile operators can invoke it manually::
11
+
12
+ python scripts/_session_start_hook.py --write
13
+
14
+ The ``--write`` flag derives the sentinel payload from the current
15
+ ``git`` state:
16
+
17
+ * ``deftVersion`` -- resolved via :mod:`resolve_version` (the same
18
+ priority chain ``task build`` consumes; #723).
19
+ * ``lastBranch`` -- ``git symbolic-ref --short HEAD`` (with
20
+ ``git rev-parse --short HEAD`` as the detached-HEAD fallback,
21
+ recorded as ``"detached:<short-sha>"`` when HEAD is detached).
22
+ * ``lastActiveVbrief`` -- the most-recently-modified
23
+ ``vbrief/active/*.vbrief.json`` file, recorded as a POSIX-style
24
+ relative path. If no candidate file exists, the hook exits ``2``
25
+ with a one-line diagnostic to stderr instead of writing an
26
+ incomplete sentinel.
27
+
28
+ Exit codes
29
+ ----------
30
+
31
+ * ``0`` -- sentinel written.
32
+ * ``2`` -- precondition not satisfied (no active vBRIEF, no git repo,
33
+ etc.). The ritual treats this as fail-open: ritual continues silently.
34
+ * ``1`` -- unexpected error (re-raised :class:`Exception` from the
35
+ writer's ``except`` branch). Surfaces the underlying error to stderr.
36
+
37
+ The hook is intentionally side-effect-free beyond the sentinel write;
38
+ it does NOT mutate git state, the cache, or the vBRIEF lifecycle.
39
+
40
+ Refs #1269.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import argparse
46
+ import os
47
+ import subprocess
48
+ import sys
49
+ from pathlib import Path
50
+
51
+ # Sibling-script import (mirrors the pattern used by
52
+ # ``scripts/resume_conditions.py`` and the other scripts/ modules).
53
+ _SCRIPTS_DIR = Path(__file__).resolve().parent
54
+ if str(_SCRIPTS_DIR) not in sys.path:
55
+ sys.path.insert(0, str(_SCRIPTS_DIR))
56
+
57
+ import resolve_version # type: ignore[import-not-found] # noqa: E402
58
+ import ritual_sentinel # type: ignore[import-not-found] # noqa: E402
59
+
60
+
61
+ def _detect_branch(project_root: Path) -> str | None:
62
+ """Return the current git branch (or short SHA on detached HEAD)."""
63
+ try:
64
+ result = subprocess.run(
65
+ ["git", "symbolic-ref", "--short", "HEAD"],
66
+ cwd=str(project_root),
67
+ capture_output=True,
68
+ text=True,
69
+ timeout=10,
70
+ check=False,
71
+ )
72
+ except (FileNotFoundError, subprocess.TimeoutExpired):
73
+ return None
74
+ if result.returncode == 0:
75
+ branch = (result.stdout or "").strip()
76
+ if branch:
77
+ return branch
78
+ # Detached HEAD -- fall back to the short SHA so the sentinel still
79
+ # records *something* the operator can correlate with their checkout.
80
+ try:
81
+ rev_result = subprocess.run(
82
+ ["git", "rev-parse", "--short", "HEAD"],
83
+ cwd=str(project_root),
84
+ capture_output=True,
85
+ text=True,
86
+ timeout=10,
87
+ check=False,
88
+ )
89
+ except (FileNotFoundError, subprocess.TimeoutExpired):
90
+ return None
91
+ if rev_result.returncode == 0:
92
+ sha = (rev_result.stdout or "").strip()
93
+ if sha:
94
+ return f"detached:{sha}"
95
+ return None
96
+
97
+
98
+ def _detect_latest_active_vbrief(project_root: Path) -> str | None:
99
+ """Return the most-recently-modified active vBRIEF as a POSIX relpath.
100
+
101
+ Fail-open across OSError -- a vBRIEF whose ``stat()`` raises
102
+ (TOCTOU delete between ``glob()`` and ``stat()``, permission
103
+ denied, broken symlink) is skipped rather than crashing the
104
+ ritual. Returns ``None`` when no readable candidate survives.
105
+ """
106
+ active_dir = project_root / "vbrief" / "active"
107
+ try:
108
+ if not active_dir.is_dir():
109
+ return None
110
+ except OSError:
111
+ return None
112
+ candidates: list[tuple[float, Path]] = []
113
+ try:
114
+ children = list(active_dir.glob("*.vbrief.json"))
115
+ except OSError:
116
+ return None
117
+ for child in children:
118
+ try:
119
+ if not child.is_file():
120
+ continue
121
+ mtime = child.stat().st_mtime
122
+ except OSError:
123
+ # Race with another process deleting the file between glob
124
+ # and stat, or permission denied on a specific entry. Skip.
125
+ continue
126
+ candidates.append((mtime, child))
127
+ if not candidates:
128
+ return None
129
+ candidates.sort(key=lambda pair: pair[0], reverse=True)
130
+ latest = candidates[0][1]
131
+ try:
132
+ return latest.relative_to(project_root).as_posix()
133
+ except ValueError:
134
+ return None
135
+
136
+
137
+ def _build_arg_parser() -> argparse.ArgumentParser:
138
+ parser = argparse.ArgumentParser(
139
+ prog="_session_start_hook",
140
+ description="Write the session-start ritual sentinel (#1269).",
141
+ )
142
+ parser.add_argument(
143
+ "--write",
144
+ action="store_true",
145
+ help="Write .deft/last-session.json from the current git state.",
146
+ )
147
+ parser.add_argument(
148
+ "--project-root",
149
+ type=Path,
150
+ default=None,
151
+ help="Project root directory (default: current working directory).",
152
+ )
153
+ return parser
154
+
155
+
156
+ def main(argv: list[str] | None = None) -> int:
157
+ args = _build_arg_parser().parse_args(argv)
158
+ if not args.write:
159
+ # No-op invocation; print the usage hint to stderr and return 0
160
+ # so the ritual orchestration is never broken by a missing flag.
161
+ sys.stderr.write(
162
+ "_session_start_hook.py: pass --write to persist the sentinel.\n"
163
+ )
164
+ return 0
165
+ project_root: Path = (args.project_root or Path(os.getcwd())).resolve()
166
+ branch = _detect_branch(project_root)
167
+ if not branch:
168
+ sys.stderr.write(
169
+ "_session_start_hook.py: could not determine current git branch; "
170
+ "skipping sentinel write.\n"
171
+ )
172
+ return 2
173
+ last_active = _detect_latest_active_vbrief(project_root)
174
+ if not last_active:
175
+ sys.stderr.write(
176
+ "_session_start_hook.py: no active vBRIEF found under "
177
+ "vbrief/active/; skipping sentinel write.\n"
178
+ )
179
+ return 2
180
+ try:
181
+ deft_version = resolve_version.resolve_version()
182
+ except Exception as exc: # noqa: BLE001 -- best-effort
183
+ sys.stderr.write(
184
+ f"_session_start_hook.py: resolve_version failed: {exc}; "
185
+ "skipping sentinel write.\n"
186
+ )
187
+ return 2
188
+ try:
189
+ sentinel_path = ritual_sentinel.write(
190
+ project_root,
191
+ deft_version=deft_version,
192
+ last_active_vbrief=last_active,
193
+ last_branch=branch,
194
+ )
195
+ except Exception as exc: # noqa: BLE001 -- surface to caller
196
+ sys.stderr.write(
197
+ f"_session_start_hook.py: sentinel write failed: {exc}\n"
198
+ )
199
+ return 1
200
+ sys.stdout.write(f"{sentinel_path}\n")
201
+ return 0
202
+
203
+
204
+ if __name__ == "__main__":
205
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,365 @@
1
+ """Diff scanner for the system-of-record architecture gate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from preflight_architecture_sor import (
11
+ LOW_RISK_PATH_PREFIXES,
12
+ LOW_RISK_SUFFIXES,
13
+ SCANNER_EXEMPT_PATHS,
14
+ DetectedSignal,
15
+ GateFinding,
16
+ GateResult,
17
+ _format_failure,
18
+ _load_json_file,
19
+ _system_of_record,
20
+ validate_record,
21
+ )
22
+
23
+
24
+ def _is_low_risk_path(path: str) -> bool:
25
+ clean = path.lstrip("./")
26
+ if clean in SCANNER_EXEMPT_PATHS:
27
+ return True
28
+ if Path(clean).suffix.lower() in LOW_RISK_SUFFIXES:
29
+ return True
30
+ return clean.startswith(LOW_RISK_PATH_PREFIXES)
31
+
32
+
33
+ def _storage_from_line(path: str, line: str) -> str:
34
+ text = f"{path} {line}".lower()
35
+ if re.search(r"\.ya?ml\b", text):
36
+ return "yaml_file"
37
+ if re.search(r"\.toml\b", text):
38
+ return "toml_file"
39
+ if re.search(r"\.(sqlite|sqlite3|db)\b", text):
40
+ return "sqlite_file"
41
+ if re.search(r"\.json\b", text):
42
+ return "json_file"
43
+ return "filesystem"
44
+
45
+
46
+ def _path_name_signal(path: str) -> DetectedSignal | None:
47
+ if _is_low_risk_path(path):
48
+ return None
49
+ name = Path(path).name.lower()
50
+ if re.search(r"(registry|repository|store|manager|service)", name) and re.search(
51
+ r"\.(py|js|jsx|ts|tsx|go|rb|java|kt)$", name
52
+ ):
53
+ return DetectedSignal(
54
+ kind="state_module",
55
+ path=path,
56
+ line=None,
57
+ detail="stateful module name",
58
+ )
59
+ if re.search(r"migrations?/", path) or re.search(r"\bmigration", name):
60
+ return DetectedSignal(
61
+ kind="database_model",
62
+ path=path,
63
+ line=None,
64
+ detail="database migration path",
65
+ storage="database",
66
+ )
67
+ return None
68
+
69
+
70
+ def _looks_like_workflow_state_change(stripped: str) -> bool:
71
+ term = (
72
+ r"(workflow|workflows|job|jobs|queue|queues|runtime|orchestration|job_queue|"
73
+ r"workflow_queue|runtime_state|orchestration_state|worker_state|run_state)"
74
+ )
75
+ action = (
76
+ r"(create|schedule|enqueue|dequeue|start|complete|fail|cancel|retry|update|delete|"
77
+ r"upsert|persist|save|load|restore|claim|lease|dispatch)"
78
+ )
79
+
80
+ if re.match(r"(#|//|/\*|\*)", stripped):
81
+ return False
82
+
83
+ patterns = (
84
+ rf"\b(def|function|func)\s+({action}_{term}|{term}_{action})\b",
85
+ r"\b(class|type)\s+\w*"
86
+ r"(Workflow|Job|Queue|Runtime|Orchestration|WorkerState|RunState)\w*",
87
+ rf"\b({term})\.(append|add|put|enqueue|dequeue|submit|dispatch|schedule|"
88
+ rf"start|complete|fail|cancel|retry|update|delete|save|persist)\s*\(",
89
+ rf"\b({term})\s*\[[^\]]+\]\s*=",
90
+ rf"\b({term})\s*=\s*(new\s+Map\(|\{{\}}|\[\])",
91
+ rf"\b({action}_{term}|{term}_{action})\s*\(",
92
+ )
93
+ return any(re.search(pattern, stripped, flags=re.IGNORECASE) for pattern in patterns)
94
+
95
+
96
+ def _line_signals(path: str, line_no: int | None, line: str) -> list[DetectedSignal]:
97
+ if _is_low_risk_path(path):
98
+ return []
99
+
100
+ stripped = line.strip()
101
+ signals: list[DetectedSignal] = []
102
+
103
+ if re.search(
104
+ r"(write_text|write_bytes|fs\.(writeFile|writeFileSync|appendFile|createWriteStream)|"
105
+ r"Deno\.write(Text)?File|os\.WriteFile|ioutil\.WriteFile|Files\.write|"
106
+ r"open\([^)]*,\s*['\"][^'\"]*[wax])",
107
+ stripped,
108
+ ):
109
+ signals.append(
110
+ DetectedSignal(
111
+ kind="filesystem_write",
112
+ path=path,
113
+ line=line_no,
114
+ detail=stripped,
115
+ storage=_storage_from_line(path, stripped),
116
+ )
117
+ )
118
+
119
+ if re.search(r"\b(localStorage|sessionStorage|indexedDB|caches\.open)\b", stripped):
120
+ signals.append(
121
+ DetectedSignal(
122
+ kind="browser_storage",
123
+ path=path,
124
+ line=line_no,
125
+ detail=stripped,
126
+ storage="browser_storage",
127
+ )
128
+ )
129
+
130
+ path_name = Path(path).name.lower()
131
+ if re.search(r"(registry|repository|store|manager)", path_name) and re.search(
132
+ r"(new\s+Map\(|=\s*\{\}\s*(#|//|$)|:\s*dict\[)",
133
+ stripped,
134
+ ):
135
+ signals.append(
136
+ DetectedSignal(
137
+ kind="in_memory_state",
138
+ path=path,
139
+ line=line_no,
140
+ detail=stripped,
141
+ storage="in_memory",
142
+ )
143
+ )
144
+
145
+ if re.search(
146
+ r"(@\w+\.(post|put|patch|delete)\b|\b(router|app)\.(post|put|patch|delete)\s*\(|"
147
+ r"\b(def|function|func)\s+(create|select|update|delete|upsert)_?)",
148
+ stripped,
149
+ flags=re.IGNORECASE,
150
+ ):
151
+ signals.append(
152
+ DetectedSignal(
153
+ kind="mutation_endpoint",
154
+ path=path,
155
+ line=line_no,
156
+ detail=stripped,
157
+ )
158
+ )
159
+
160
+ if re.search(
161
+ r"(CREATE\s+TABLE|ALTER\s+TABLE|sqlalchemy|db\.Column|models\.Model|"
162
+ r"prisma|typeorm|sequelize|ActiveRecord)",
163
+ stripped,
164
+ flags=re.IGNORECASE,
165
+ ):
166
+ signals.append(
167
+ DetectedSignal(
168
+ kind="database_model",
169
+ path=path,
170
+ line=line_no,
171
+ detail=stripped,
172
+ storage="database",
173
+ )
174
+ )
175
+
176
+ if re.search(
177
+ r"\b(auth|session|permission|membership|role|grant|tenant|organization)\b",
178
+ stripped,
179
+ flags=re.IGNORECASE,
180
+ ):
181
+ signals.append(
182
+ DetectedSignal(
183
+ kind="auth_state",
184
+ path=path,
185
+ line=line_no,
186
+ detail=stripped,
187
+ )
188
+ )
189
+
190
+ if _looks_like_workflow_state_change(stripped):
191
+ signals.append(
192
+ DetectedSignal(
193
+ kind="workflow_state",
194
+ path=path,
195
+ line=line_no,
196
+ detail=stripped,
197
+ )
198
+ )
199
+
200
+ return signals
201
+
202
+
203
+ def scan_diff(diff_text: str) -> tuple[list[DetectedSignal], list[str]]:
204
+ """Scan unified diff text for suspicious stateful patterns."""
205
+ signals: list[DetectedSignal] = []
206
+ changed_paths: list[str] = []
207
+ current_path: str | None = None
208
+ new_line_no: int | None = None
209
+
210
+ for raw_line in diff_text.splitlines():
211
+ if raw_line.startswith("diff --git "):
212
+ parts = raw_line.split()
213
+ if len(parts) >= 4:
214
+ candidate = parts[3]
215
+ current_path = candidate[2:] if candidate.startswith("b/") else candidate
216
+ if current_path not in changed_paths:
217
+ changed_paths.append(current_path)
218
+ path_signal = _path_name_signal(current_path)
219
+ if path_signal is not None:
220
+ signals.append(path_signal)
221
+ new_line_no = None
222
+ continue
223
+
224
+ if raw_line.startswith("+++ "):
225
+ target = raw_line[4:].strip()
226
+ if target == "/dev/null":
227
+ current_path = None
228
+ continue
229
+ current_path = target[2:] if target.startswith("b/") else target
230
+ if current_path not in changed_paths:
231
+ changed_paths.append(current_path)
232
+ path_signal = _path_name_signal(current_path)
233
+ if path_signal is not None:
234
+ signals.append(path_signal)
235
+ continue
236
+
237
+ if raw_line.startswith("@@ "):
238
+ match = re.search(r"\+(\d+)", raw_line)
239
+ new_line_no = int(match.group(1)) if match else None
240
+ continue
241
+
242
+ if current_path is None:
243
+ continue
244
+
245
+ if raw_line.startswith("+") and not raw_line.startswith("+++"):
246
+ line = raw_line[1:]
247
+ signals.extend(_line_signals(current_path, new_line_no, line))
248
+ if new_line_no is not None:
249
+ new_line_no += 1
250
+ elif raw_line.startswith("-") and not raw_line.startswith("---"):
251
+ continue
252
+ elif new_line_no is not None:
253
+ new_line_no += 1
254
+
255
+ return signals, changed_paths
256
+
257
+
258
+ def _git_diff(project_root: Path, base_ref: str) -> tuple[str | None, GateResult | None]:
259
+ try:
260
+ proc = subprocess.run(
261
+ ["git", "diff", "--unified=0", "--no-ext-diff", base_ref, "--"],
262
+ cwd=str(project_root),
263
+ capture_output=True,
264
+ text=True,
265
+ encoding="utf-8",
266
+ errors="replace",
267
+ check=False,
268
+ timeout=30,
269
+ )
270
+ except (OSError, subprocess.TimeoutExpired) as exc:
271
+ return None, GateResult(
272
+ 2,
273
+ f"system-of-record gate misconfigured: could not run git diff: {exc}",
274
+ )
275
+ if proc.returncode != 0:
276
+ detail = proc.stderr.strip() or proc.stdout.strip() or f"git diff exited {proc.returncode}"
277
+ return None, GateResult(
278
+ 2,
279
+ f"system-of-record gate misconfigured: could not diff against {base_ref}: {detail}",
280
+ )
281
+ return proc.stdout, None
282
+
283
+
284
+ def _changed_story_records(
285
+ project_root: Path,
286
+ changed_paths: list[str],
287
+ ) -> tuple[list[tuple[Path, dict[str, Any], dict[str, Any]]], GateResult | None]:
288
+ records: list[tuple[Path, dict[str, Any], dict[str, Any]]] = []
289
+ for rel in changed_paths:
290
+ if not rel.endswith(".vbrief.json"):
291
+ continue
292
+ if not rel.startswith(("vbrief/active/", "vbrief/pending/", "vbrief/proposed/")):
293
+ continue
294
+ path = project_root / rel
295
+ payload, error = _load_json_file(path)
296
+ if error is not None:
297
+ return [], error
298
+ assert payload is not None
299
+ record = _system_of_record(payload)
300
+ if record is not None:
301
+ records.append((path, payload, record))
302
+ return records, None
303
+
304
+
305
+ def evaluate_diff_text(
306
+ diff_text: str,
307
+ *,
308
+ project_root: Path,
309
+ story_path: Path | None = None,
310
+ ) -> GateResult:
311
+ signals, changed_paths = scan_diff(diff_text)
312
+ if not signals:
313
+ return GateResult(0, "OK system-of-record gate passed: no stateful diff signals detected.")
314
+
315
+ payload: dict[str, Any] | None = None
316
+ record: dict[str, Any] | None = None
317
+
318
+ if story_path is not None:
319
+ payload, error = _load_json_file(story_path)
320
+ if error is not None:
321
+ return error
322
+ assert payload is not None
323
+ record = _system_of_record(payload)
324
+ else:
325
+ records, error = _changed_story_records(project_root, changed_paths)
326
+ if error is not None:
327
+ return error
328
+ if len(records) == 1:
329
+ _, payload, record = records[0]
330
+ elif len(records) > 1:
331
+ return GateResult(
332
+ 2,
333
+ "system-of-record gate misconfigured: multiple changed vBRIEFs "
334
+ "contain system-of-record records; pass --story-path.",
335
+ )
336
+
337
+ if record is None:
338
+ finding = GateFinding(
339
+ reason=(
340
+ "Diff contains stateful persistence signals, but no matching "
341
+ "architecture.systemOfRecord design record was supplied or changed."
342
+ ),
343
+ required_fix=(
344
+ "Run `task architecture:sor-preflight -- --story-path <path>` "
345
+ "after adding the design record, or pass --story-path to this diff gate."
346
+ ),
347
+ detected_storage=signals[0].storage,
348
+ )
349
+ return GateResult(1, _format_failure([finding]), (finding,))
350
+
351
+ result = validate_record(record, story_payload=payload, signals=signals)
352
+ if result.code == 0:
353
+ return GateResult(
354
+ 0,
355
+ f"OK system-of-record gate passed: {len(signals)} stateful diff signal(s) matched.",
356
+ )
357
+ return result
358
+
359
+
360
+ def evaluate_diff(project_root: Path, base_ref: str, story_path: Path | None = None) -> GateResult:
361
+ diff_text, error = _git_diff(project_root, base_ref)
362
+ if error is not None:
363
+ return error
364
+ assert diff_text is not None
365
+ return evaluate_diff_text(diff_text, project_root=project_root, story_path=story_path)
@@ -0,0 +1,59 @@
1
+ """_stdio_utf8.py -- Reconfigure sys.stdout / sys.stderr to UTF-8.
2
+
3
+ Belt-and-suspenders guard for Python scripts under ``scripts/`` that emit
4
+ non-ASCII characters (the ``-- / -> / x / !`` style symbols and unicode
5
+ equivalents used for success / pending / error / warning markers).
6
+
7
+ The PRIMARY mechanism for UTF-8 stdout in deft is ``PYTHONUTF8=1`` (set at
8
+ the top level of ``Taskfile.yml`` and on every included task per #540). This
9
+ module is the SECONDARY safeguard for three scenarios where the env var
10
+ does not help:
11
+
12
+ 1. Scripts invoked directly (``python scripts/foo.py``) without going
13
+ through a ``task`` command.
14
+ 2. Subprocess invocations where the parent process strips or overrides
15
+ the environment.
16
+ 3. Child Python processes on Windows where the locale-dependent default
17
+ codec (cp1252 on US-English systems) would otherwise crash on the
18
+ unicode glyphs printed by several scripts (#540).
19
+
20
+ Usage::
21
+
22
+ from _stdio_utf8 import reconfigure_stdio
23
+ reconfigure_stdio()
24
+
25
+ Call once at module top, before any ``print()``. Idempotent: safe to call
26
+ more than once and safe on streams that are already UTF-8.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import sys
32
+
33
+
34
+ def reconfigure_stdio() -> None:
35
+ """Force ``sys.stdout`` / ``sys.stderr`` to UTF-8 without error on failure.
36
+
37
+ Python 3.7+ exposes ``reconfigure()`` on ``TextIOWrapper`` streams; for
38
+ redirected / closed / custom streams that lack it, we silently leave
39
+ encoding untouched. The PYTHONUTF8 env set in the task layer is the
40
+ primary fix; this function exists to defend against invocations that
41
+ bypass the task layer entirely.
42
+ """
43
+ for stream_name in ("stdout", "stderr"):
44
+ stream = getattr(sys, stream_name, None)
45
+ if stream is None:
46
+ continue
47
+ encoding = (getattr(stream, "encoding", "") or "").lower()
48
+ if encoding in ("utf-8", "utf8"):
49
+ continue
50
+ reconfigure = getattr(stream, "reconfigure", None)
51
+ if reconfigure is None:
52
+ continue
53
+ try:
54
+ reconfigure(encoding="utf-8")
55
+ except (AttributeError, OSError, ValueError):
56
+ # Reconfigure can fail on streams that aren't TextIOWrapper
57
+ # (e.g. pytest's capsys, subprocess-captured pipes). Silently
58
+ # continue -- the env var path remains the primary defence.
59
+ continue