@arbiterforge/ca-pi 0.6.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 (206) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +558 -0
  3. package/package.json +35 -0
  4. package/plugins/ca-pi/CHANGELOG.md +1030 -0
  5. package/plugins/ca-pi/COMMANDS.md +90 -0
  6. package/plugins/ca-pi/ORCHESTRATOR.md +159 -0
  7. package/plugins/ca-pi/SKILLS.md +47 -0
  8. package/plugins/ca-pi/SPRINT.md +142 -0
  9. package/plugins/ca-pi/agents/INDEX.md +31 -0
  10. package/plugins/ca-pi/agents/architecture-drift-reviewer.md +86 -0
  11. package/plugins/ca-pi/agents/auth-crypto-reviewer.md +60 -0
  12. package/plugins/ca-pi/agents/backend-author.md +60 -0
  13. package/plugins/ca-pi/agents/checkpoint-aggregator.md +111 -0
  14. package/plugins/ca-pi/agents/coverage-auditor.md +71 -0
  15. package/plugins/ca-pi/agents/decision-challenger.md +116 -0
  16. package/plugins/ca-pi/agents/dependency-reviewer.md +79 -0
  17. package/plugins/ca-pi/agents/design-quality-reviewer.md +80 -0
  18. package/plugins/ca-pi/agents/finding-triage.md +86 -0
  19. package/plugins/ca-pi/agents/frontend-author.md +64 -0
  20. package/plugins/ca-pi/agents/grader.md +173 -0
  21. package/plugins/ca-pi/agents/infra-author.md +64 -0
  22. package/plugins/ca-pi/agents/map-deps.md +35 -0
  23. package/plugins/ca-pi/agents/map-structure.md +37 -0
  24. package/plugins/ca-pi/agents/migration-reviewer.md +65 -0
  25. package/plugins/ca-pi/agents/scout.md +127 -0
  26. package/plugins/ca-pi/agents/security-reviewer.md +72 -0
  27. package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +65 -0
  28. package/plugins/ca-pi/extensions/codearbiter-child.js +1885 -0
  29. package/plugins/ca-pi/extensions/codearbiter.js +9802 -0
  30. package/plugins/ca-pi/generated/command-catalog.json +197 -0
  31. package/plugins/ca-pi/generated/roles.json +213 -0
  32. package/plugins/ca-pi/helpers/windows-supervisor.js +205 -0
  33. package/plugins/ca-pi/hooks/_activationlib.py +196 -0
  34. package/plugins/ca-pi/hooks/_arbiterstatelib.py +208 -0
  35. package/plugins/ca-pi/hooks/_babysitlib.py +76 -0
  36. package/plugins/ca-pi/hooks/_bashguardlib.py +1667 -0
  37. package/plugins/ca-pi/hooks/_boxlib.py +131 -0
  38. package/plugins/ca-pi/hooks/_colorlib.py +304 -0
  39. package/plugins/ca-pi/hooks/_durabilitylib.py +186 -0
  40. package/plugins/ca-pi/hooks/_entrylib.py +41 -0
  41. package/plugins/ca-pi/hooks/_fmtlib.py +161 -0
  42. package/plugins/ca-pi/hooks/_gitexec.py +45 -0
  43. package/plugins/ca-pi/hooks/_githooks.py +920 -0
  44. package/plugins/ca-pi/hooks/_gitlib.py +110 -0
  45. package/plugins/ca-pi/hooks/_hooklib.py +595 -0
  46. package/plugins/ca-pi/hooks/_host.py +115 -0
  47. package/plugins/ca-pi/hooks/_intentlib.py +242 -0
  48. package/plugins/ca-pi/hooks/_ledgerlib.py +1035 -0
  49. package/plugins/ca-pi/hooks/_metricslib.py +709 -0
  50. package/plugins/ca-pi/hooks/_pathnorm.py +74 -0
  51. package/plugins/ca-pi/hooks/_planfilelib.py +664 -0
  52. package/plugins/ca-pi/hooks/_previewlib.py +193 -0
  53. package/plugins/ca-pi/hooks/_protectedlib.py +312 -0
  54. package/plugins/ca-pi/hooks/_protectedstatelib.py +411 -0
  55. package/plugins/ca-pi/hooks/_provenancelib.py +971 -0
  56. package/plugins/ca-pi/hooks/_prunelib.py +1398 -0
  57. package/plugins/ca-pi/hooks/_prunepolicy.py +235 -0
  58. package/plugins/ca-pi/hooks/_readinjectlib.py +1080 -0
  59. package/plugins/ca-pi/hooks/_releaselib.py +2657 -0
  60. package/plugins/ca-pi/hooks/_scopelib.py +262 -0
  61. package/plugins/ca-pi/hooks/_segmentslib.py +278 -0
  62. package/plugins/ca-pi/hooks/_sensitivelib.py +270 -0
  63. package/plugins/ca-pi/hooks/_sessionlib.py +78 -0
  64. package/plugins/ca-pi/hooks/_sloplib.py +244 -0
  65. package/plugins/ca-pi/hooks/_standuplib.py +214 -0
  66. package/plugins/ca-pi/hooks/_subagentslib.py +219 -0
  67. package/plugins/ca-pi/hooks/_taskboardlib.py +1088 -0
  68. package/plugins/ca-pi/hooks/_updatelib.py +278 -0
  69. package/plugins/ca-pi/hooks/babysit.py +47 -0
  70. package/plugins/ca-pi/hooks/boardsync.py +129 -0
  71. package/plugins/ca-pi/hooks/doctor.py +420 -0
  72. package/plugins/ca-pi/hooks/git-enforce.py +325 -0
  73. package/plugins/ca-pi/hooks/hostapi.py +460 -0
  74. package/plugins/ca-pi/hooks/init-codearbiter.py +225 -0
  75. package/plugins/ca-pi/hooks/metrics.py +62 -0
  76. package/plugins/ca-pi/hooks/migration-pass.py +129 -0
  77. package/plugins/ca-pi/hooks/pi-bridge.py +543 -0
  78. package/plugins/ca-pi/hooks/post-write-edit.py +231 -0
  79. package/plugins/ca-pi/hooks/pre-bash.py +90 -0
  80. package/plugins/ca-pi/hooks/pre-edit.py +284 -0
  81. package/plugins/ca-pi/hooks/pre-read.py +81 -0
  82. package/plugins/ca-pi/hooks/pre-write.py +217 -0
  83. package/plugins/ca-pi/hooks/preview.py +69 -0
  84. package/plugins/ca-pi/hooks/prune-transcript.py +232 -0
  85. package/plugins/ca-pi/hooks/releasehash.py +216 -0
  86. package/plugins/ca-pi/hooks/security-pass.py +139 -0
  87. package/plugins/ca-pi/hooks/session-start.py +1218 -0
  88. package/plugins/ca-pi/hooks/statusline.py +736 -0
  89. package/plugins/ca-pi/hooks/taskwrite.py +351 -0
  90. package/plugins/ca-pi/hooks/update-refresh.py +51 -0
  91. package/plugins/ca-pi/hooks/wire-statusline.py +435 -0
  92. package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +55 -0
  93. package/plugins/ca-pi/includes/anti-slop-design/color.md +43 -0
  94. package/plugins/ca-pi/includes/anti-slop-design/core.md +244 -0
  95. package/plugins/ca-pi/includes/anti-slop-design/images.md +32 -0
  96. package/plugins/ca-pi/includes/anti-slop-design/layout.md +45 -0
  97. package/plugins/ca-pi/includes/anti-slop-design/medium-cli.md +39 -0
  98. package/plugins/ca-pi/includes/anti-slop-design/medium-dataviz.md +43 -0
  99. package/plugins/ca-pi/includes/anti-slop-design/medium-diagram.md +35 -0
  100. package/plugins/ca-pi/includes/anti-slop-design/medium-documents.md +70 -0
  101. package/plugins/ca-pi/includes/anti-slop-design/medium-slides.md +30 -0
  102. package/plugins/ca-pi/includes/anti-slop-design/medium-web.md +39 -0
  103. package/plugins/ca-pi/includes/anti-slop-design/typography.md +51 -0
  104. package/plugins/ca-pi/includes/author-tdd-workflow.md +14 -0
  105. package/plugins/ca-pi/includes/compaction-charter.md +16 -0
  106. package/plugins/ca-pi/includes/cut-docs.md +16 -0
  107. package/plugins/ca-pi/includes/dev-mode.md +30 -0
  108. package/plugins/ca-pi/includes/farm.md +237 -0
  109. package/plugins/ca-pi/includes/fresh-verification.md +14 -0
  110. package/plugins/ca-pi/includes/harvest.md +69 -0
  111. package/plugins/ca-pi/includes/maturity-coverage.md +102 -0
  112. package/plugins/ca-pi/includes/pi-host-notes.md +69 -0
  113. package/plugins/ca-pi/includes/redirect.md +69 -0
  114. package/plugins/ca-pi/includes/reference-map.md +22 -0
  115. package/plugins/ca-pi/includes/review-matrix.md +14 -0
  116. package/plugins/ca-pi/includes/reviewer-contract.md +53 -0
  117. package/plugins/ca-pi/includes/routing-table.md +47 -0
  118. package/plugins/ca-pi/includes/security-gate-record.md +22 -0
  119. package/plugins/ca-pi/includes/smarts/core.md +90 -0
  120. package/plugins/ca-pi/includes/smarts/decision-log-format.md +56 -0
  121. package/plugins/ca-pi/routines/INDEX.md +32 -0
  122. package/plugins/ca-pi/routines/brainstorming/SKILL.md +122 -0
  123. package/plugins/ca-pi/routines/commit-gate/SKILL.md +151 -0
  124. package/plugins/ca-pi/routines/context-check/SKILL.md +85 -0
  125. package/plugins/ca-pi/routines/context-creation/SKILL.md +171 -0
  126. package/plugins/ca-pi/routines/crypto-compliance/SKILL.md +41 -0
  127. package/plugins/ca-pi/routines/debug/SKILL.md +99 -0
  128. package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +104 -0
  129. package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +74 -0
  130. package/plugins/ca-pi/routines/decision-variance/SKILL.md +147 -0
  131. package/plugins/ca-pi/routines/decompose/SKILL.md +168 -0
  132. package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +76 -0
  133. package/plugins/ca-pi/routines/executing-plans/SKILL.md +83 -0
  134. package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +91 -0
  135. package/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md +233 -0
  136. package/plugins/ca-pi/routines/refactor/SKILL.md +91 -0
  137. package/plugins/ca-pi/routines/release/SKILL.md +315 -0
  138. package/plugins/ca-pi/routines/secret-handling/SKILL.md +67 -0
  139. package/plugins/ca-pi/routines/security-architecture/SKILL.md +63 -0
  140. package/plugins/ca-pi/routines/skill-author/SKILL.md +108 -0
  141. package/plugins/ca-pi/routines/skill-author/references/skill-template.md +58 -0
  142. package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +149 -0
  143. package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +145 -0
  144. package/plugins/ca-pi/routines/tdd/SKILL.md +139 -0
  145. package/plugins/ca-pi/routines/tribunal/SKILL.md +109 -0
  146. package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +29 -0
  147. package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +64 -0
  148. package/plugins/ca-pi/routines/tribunal/references/finding-record.md +27 -0
  149. package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +47 -0
  150. package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +22 -0
  151. package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +23 -0
  152. package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +20 -0
  153. package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +24 -0
  154. package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +22 -0
  155. package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +21 -0
  156. package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +22 -0
  157. package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +23 -0
  158. package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +22 -0
  159. package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +24 -0
  160. package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +21 -0
  161. package/plugins/ca-pi/routines/tribunal/references/report.md +19 -0
  162. package/plugins/ca-pi/routines/tribunal/references/schemas.md +58 -0
  163. package/plugins/ca-pi/routines/tribunal/references/telemetry.md +28 -0
  164. package/plugins/ca-pi/routines/tribunal/references/triage.md +53 -0
  165. package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +85 -0
  166. package/plugins/ca-pi/routines/writing-plans/SKILL.md +129 -0
  167. package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +50 -0
  168. package/plugins/ca-pi/skills/ca-add-dep/SKILL.md +88 -0
  169. package/plugins/ca-pi/skills/ca-adr/SKILL.md +30 -0
  170. package/plugins/ca-pi/skills/ca-adr-status/SKILL.md +30 -0
  171. package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +36 -0
  172. package/plugins/ca-pi/skills/ca-audit/SKILL.md +51 -0
  173. package/plugins/ca-pi/skills/ca-btw/SKILL.md +23 -0
  174. package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +50 -0
  175. package/plugins/ca-pi/skills/ca-chore/SKILL.md +58 -0
  176. package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +55 -0
  177. package/plugins/ca-pi/skills/ca-commands/SKILL.md +21 -0
  178. package/plugins/ca-pi/skills/ca-commit/SKILL.md +27 -0
  179. package/plugins/ca-pi/skills/ca-conflict/SKILL.md +61 -0
  180. package/plugins/ca-pi/skills/ca-context-check/SKILL.md +32 -0
  181. package/plugins/ca-pi/skills/ca-create-context/SKILL.md +32 -0
  182. package/plugins/ca-pi/skills/ca-debug/SKILL.md +42 -0
  183. package/plugins/ca-pi/skills/ca-decompose/SKILL.md +30 -0
  184. package/plugins/ca-pi/skills/ca-dev/SKILL.md +42 -0
  185. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +44 -0
  186. package/plugins/ca-pi/skills/ca-feature/SKILL.md +105 -0
  187. package/plugins/ca-pi/skills/ca-fix/SKILL.md +42 -0
  188. package/plugins/ca-pi/skills/ca-init/SKILL.md +56 -0
  189. package/plugins/ca-pi/skills/ca-metrics/SKILL.md +80 -0
  190. package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +34 -0
  191. package/plugins/ca-pi/skills/ca-override/SKILL.md +72 -0
  192. package/plugins/ca-pi/skills/ca-pr/SKILL.md +61 -0
  193. package/plugins/ca-pi/skills/ca-preview/SKILL.md +86 -0
  194. package/plugins/ca-pi/skills/ca-prune/SKILL.md +100 -0
  195. package/plugins/ca-pi/skills/ca-reconcile/SKILL.md +43 -0
  196. package/plugins/ca-pi/skills/ca-refactor/SKILL.md +43 -0
  197. package/plugins/ca-pi/skills/ca-release/SKILL.md +57 -0
  198. package/plugins/ca-pi/skills/ca-review/SKILL.md +63 -0
  199. package/plugins/ca-pi/skills/ca-spike/SKILL.md +41 -0
  200. package/plugins/ca-pi/skills/ca-sprint/SKILL.md +44 -0
  201. package/plugins/ca-pi/skills/ca-standup/SKILL.md +112 -0
  202. package/plugins/ca-pi/skills/ca-status/SKILL.md +56 -0
  203. package/plugins/ca-pi/skills/ca-task/SKILL.md +61 -0
  204. package/plugins/ca-pi/skills/ca-threat-model/SKILL.md +45 -0
  205. package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +44 -0
  206. package/plugins/ca-pi/skills/ca-watch/SKILL.md +78 -0
@@ -0,0 +1,971 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — provenance store for per-doc source evidence and drift detection.
3
+ #
4
+ # Captures the scout evidence that backs each derived `.codearbiter/` doc
5
+ # (tech-stack.md, coding-standards.md, security-controls.md, CONTEXT.md,
6
+ # code-map.md) as a per-doc JSON file at `.codearbiter/.provenance/<doc>.json`.
7
+ # When a tracked source file changes, drift detection (compute_drift) finds the
8
+ # gap; commit-gate auto-heals it (pillar 3 of context-drift-provenance spec).
9
+ #
10
+ # Design invariants (mirroring _taskboardlib.py / _metricslib.py):
11
+ # - Stdlib only; no third-party imports ever — runs on stock Python.
12
+ # - Zero side effects at import time: no git calls, no file I/O on import.
13
+ # - Pure functions are fully testable with synthetic data (no real file
14
+ # needed). write_provenance() and read_provenance() are the ONLY functions
15
+ # that touch the filesystem.
16
+ # - Never raise on malformed input — degrade gracefully (this runs on the
17
+ # SessionStart linchpin path).
18
+ # - Hashing uses git hash-object via an injectable runner (batch_hash);
19
+ # raw byte sha256 is intentionally NOT used — git honors .gitattributes
20
+ # EOL normalization so an LF<->CRLF flip (documented Edit hazard on
21
+ # Windows) never false-flags as drift.
22
+ #
23
+ # Schema (per-doc provenance file, v1):
24
+ # {
25
+ # "schema": 1,
26
+ # "doc": "tech-stack",
27
+ # "created": "2026-06-26",
28
+ # "interview_derived": false,
29
+ # "entries": [
30
+ # {
31
+ # "path": "plugins/ca/tools/package.json",
32
+ # "hash": "<git oid or null>",
33
+ # "drift_trigger": true,
34
+ # "claims": [
35
+ # {
36
+ # "lines": "12-40",
37
+ # "claim": "Node 20 runtime declared in package.json",
38
+ # "confidence": "strong"
39
+ # }
40
+ # ]
41
+ # }
42
+ # ]
43
+ # }
44
+ #
45
+ # Files live at: .codearbiter/.provenance/<doc>.json
46
+ # JSON format: pretty-printed, sorted keys, trailing newline, utf-8, LF endings.
47
+ #
48
+ # Public API:
49
+ # new_record(doc, *, interview_derived=False, entries=None, created=None)
50
+ # -> dict canonical record with schema=1
51
+ # write_provenance(path, record) -> None pretty JSON; creates parent dirs
52
+ # valid_provenance_record(record) -> bool True iff a well-formed v1
53
+ # record (top-level shape)
54
+ # read_provenance(path) -> dict | None None on missing/corrupt
55
+ # — including valid JSON whose
56
+ # shape is not a v1 record
57
+ # batch_hash(paths, runner) -> dict[str, str] one git hash-object --stdin-paths
58
+ # call; input-order-preserving; {}
59
+ # on empty paths or runner failure
60
+ # classify_source(path) -> bool True iff path is drift_trigger
61
+ # (config/manifest/schema/security-entry);
62
+ # False for general source; never raises
63
+ # compute_drift(provenance_map, current_hashes)
64
+ # -> dict {doc: [{"path":…,"kind":"changed"|"missing"}]}
65
+ # kind="changed": path present, hash diverged.
66
+ # kind="missing": path absent from current_hashes
67
+ # (source renamed/deleted — AC-05/T-06).
68
+ # Both kinds filtered to drift_trigger:true only
69
+ # (AC-09/T-05). Docs with no drift omitted (→ {}).
70
+ # load_provenance_dir(provenance_dir, skipped=None)
71
+ # -> dict {doc: record}; {} on missing/corrupt dir.
72
+ # Per-FILE error boundary: one bad record
73
+ # never suppresses a later valid one.
74
+ # `skipped` (optional list) collects up to
75
+ # MAX_SKIPPED_REPORTED rejected basenames.
76
+ # startup_drift_line(root, runner=None)
77
+ # -> str "" when clean (AC-06);
78
+ # "context drift: N stale source(s) across M doc(s) -- run /ca:context-check"
79
+ # when drift > 0 (AC-07). ASCII-only, exactly one line.
80
+ # changed_scope(doc_provenance, drift) -> list[str] drifted paths for this doc only (both
81
+ # changed+missing kinds), in drift order;
82
+ # [] when the doc has no drift entry or
83
+ # any input is malformed/None (never raises)
84
+ # rebaseline(provenance, current_hashes) -> dict new record with each drift_trigger
85
+ # entry's hash set to current_hashes[path];
86
+ # absent paths left as-is; never raises
87
+ # heal_worklist(staged_paths, provenance, current_hashes) -> list[str]
88
+ # commit-gate worklist: staged paths that are
89
+ # drift_trigger:true with a diverged or absent
90
+ # current hash; [] when no staged file is tracked
91
+ # (cost guarantee — ordinary commits pay nothing, AC-13)
92
+ # lint_code_map(text) -> list[str] cap / multi-line violations
93
+ # write_stub(path, doc, *, interview_derived=True, created=None) -> None
94
+ # write greenfield stub: interview_derived=True, entries=[]
95
+
96
+ import datetime
97
+ import glob
98
+ import json
99
+ import ntpath
100
+ import os
101
+ import posixpath
102
+ import re
103
+ import subprocess
104
+
105
+ from _gitexec import git_executable
106
+
107
+ import _hooklib
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Constants
111
+ # ---------------------------------------------------------------------------
112
+
113
+ SCHEMA_VERSION = 1
114
+
115
+ # Maximum seconds allowed for a single git read call. A hung git process must
116
+ # never stall the SessionStart linchpin hook indefinitely; timeout degrades to
117
+ # the existing except-Exception degrade paths (batch_hash → {}, startup_drift_line → "").
118
+ GIT_TIMEOUT = 5 # seconds; a git read must never stall SessionStart
119
+
120
+ # Tunable cap for code-map entry count. 50 enforces module/concern granularity
121
+ # (coarse index only); raise it only when a project legitimately has more top-level
122
+ # concerns than this. The lint is the guard against the code map drifting into
123
+ # a full file index — never allow it to grow past this without deliberate review.
124
+ CODE_MAP_MAX_ENTRIES = 50
125
+
126
+ # Regex matching a column-0 entry bullet: starts with '- `' (dash, space, backtick).
127
+ # Concern '## heading' lines are NOT entries. Captures the path between backticks.
128
+ _ENTRY_RE = re.compile(r"^- `([^`]+)`")
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # classify_source constants
132
+ # ---------------------------------------------------------------------------
133
+
134
+ # Fixed filenames that are always drift_trigger — exact case match (no lowering).
135
+ # These are canonical ecosystem names that do not vary in capitalisation except
136
+ # for Cargo.toml / Gemfile / Gemfile.lock (capital-first by convention).
137
+ _DRIFT_FIXED_NAMES = frozenset({
138
+ "package.json",
139
+ "package-lock.json",
140
+ "yarn.lock",
141
+ "pnpm-lock.yaml",
142
+ "pyproject.toml",
143
+ "go.mod",
144
+ "Cargo.toml",
145
+ "Gemfile",
146
+ "Gemfile.lock",
147
+ })
148
+
149
+ # Path-pattern rules (applied to the separator-normalised path, case-insensitive):
150
+ # • requirements*.txt — basename wildcard for any requirements file
151
+ # • .github/workflows/ — CI/pipeline yaml anywhere in the path
152
+ # • *.prisma — Prisma schema files
153
+ # • *.sql — SQL files (schema dumps, migrations)
154
+ # • migrations/ — any path segment named "migrations"
155
+ # • .env.example / .env.sample / .env.template — env templates
156
+ _DRIFT_PATH_RE = re.compile(
157
+ r"""
158
+ # requirements*.txt (basename: starts with 'requirements', ends with '.txt')
159
+ (?:^|/) requirements [^/]* \.txt $
160
+ |
161
+ # CI / pipeline yaml: under .github/workflows/
162
+ (?:^|/) \.github/workflows/ [^/]+ \.ya?ml $
163
+ |
164
+ # Prisma schema files (any directory)
165
+ [^/]+ \.prisma $
166
+ |
167
+ # SQL files (any directory)
168
+ [^/]+ \.sql $
169
+ |
170
+ # Any path containing a 'migrations/' segment
171
+ (?:^|/) migrations /
172
+ |
173
+ # Env templates: .env.example / .env.sample / .env.template
174
+ (?:^|/) \.env\. (?:example|sample|template) $
175
+ """,
176
+ re.IGNORECASE | re.VERBOSE,
177
+ )
178
+
179
+ # Tokeniser that splits a normalised path on separator characters AND camelCase
180
+ # transitions so security-entry keywords are matched as whole tokens, not as
181
+ # substrings. Separator characters covered: / \ . _ - (backslash is already
182
+ # normalised to / before classify_source calls split, but kept here for defence).
183
+ # camelCase boundary: (?<=[a-z])(?=[A-Z]) — e.g. "authMiddleware" → ["auth","Middleware"].
184
+ _SECURITY_TOKEN_SPLIT_RE = re.compile(r"[\\/._\-]|(?<=[a-z])(?=[A-Z])")
185
+
186
+ # Security-entry token keywords. A path is a drift_trigger when any token in the
187
+ # tokenised, lowercased normalised path is an exact member of this set.
188
+ # MATCH : auth.ts, src/middleware/cors.py, jwt.go, authMiddleware.ts, jwt_utils.py
189
+ # NO-MATCH : author.py, AuthorCard.tsx, oauth.ts (tokens are "author"/"Author"/"oauth")
190
+ _SECURITY_ENTRY_TOKENS = frozenset({"auth", "middleware", "jwt"})
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Constructor helper
194
+ # ---------------------------------------------------------------------------
195
+
196
+
197
+ def new_record(doc, *, interview_derived=False, entries=None, created=None):
198
+ """Build a canonical provenance record dict with schema=SCHEMA_VERSION.
199
+
200
+ `doc` — short name of the derived doc, e.g. "tech-stack".
201
+ `interview_derived`— True for greenfield stubs (no source files yet).
202
+ `entries` — list of entry dicts; defaults to [].
203
+ `created` — ISO date string; defaults to today (datetime.date).
204
+
205
+ Never raises. Returns a new dict on every call; the caller owns it.
206
+ """
207
+ if created is None:
208
+ created = datetime.date.today().isoformat()
209
+ return {
210
+ "schema": SCHEMA_VERSION,
211
+ "doc": str(doc),
212
+ "created": str(created),
213
+ "interview_derived": bool(interview_derived),
214
+ "entries": list(entries) if entries is not None else [],
215
+ }
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Filesystem functions (the ONLY functions that touch the filesystem)
220
+ # ---------------------------------------------------------------------------
221
+
222
+
223
+ def write_provenance(path, record):
224
+ """Write `record` as pretty JSON to `path`, atomically.
225
+
226
+ Format: indent=2, sorted keys, ensure_ascii=False, trailing newline, utf-8,
227
+ LF line endings (canonical EOL for this repo). Creates the parent directory
228
+ if it does not exist. Routed through _hooklib.write_text_atomic (sibling
229
+ temp file + os.replace) so a crash mid-write leaves the previous provenance
230
+ record intact instead of a truncated/corrupt file (reliability-016).
231
+ """
232
+ parent = os.path.dirname(path)
233
+ if parent:
234
+ os.makedirs(parent, exist_ok=True)
235
+ text = json.dumps(record, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
236
+ _hooklib.write_text_atomic(path, text, newline="\n")
237
+
238
+
239
+ # Top-level provenance-record contract (v1). Each required key maps to the
240
+ # type(s) a canonical record — the one new_record() builds — always carries.
241
+ # `bool` is excluded from the `schema` check explicitly because in Python
242
+ # `True == 1`, and a record whose schema is literally `true` is corrupt, not v1.
243
+ _RECORD_FIELD_TYPES = (
244
+ ("doc", str),
245
+ ("created", str),
246
+ ("interview_derived", bool),
247
+ ("entries", list),
248
+ )
249
+
250
+
251
+ def valid_provenance_record(record):
252
+ """True iff `record` is a well-formed v1 provenance record (#410).
253
+
254
+ Checks the TOP-LEVEL shape only: the store's own frame. Entry-level
255
+ tolerance stays where it already lives — compute_drift and the read-inject
256
+ pointer both skip a malformed entry without dropping its record — so one
257
+ odd claim never costs a doc its entire provenance.
258
+
259
+ A schema other than SCHEMA_VERSION is rejected rather than best-effort
260
+ parsed: every field expectation below is v1-specific, so admitting an
261
+ unknown version would mean reading it with the wrong rules. Bumping
262
+ SCHEMA_VERSION therefore requires revisiting this function deliberately.
263
+ Never raises.
264
+ """
265
+ if not isinstance(record, dict):
266
+ return False
267
+ schema = record.get("schema")
268
+ if isinstance(schema, bool) or not isinstance(schema, int):
269
+ return False
270
+ if schema != SCHEMA_VERSION:
271
+ return False
272
+ for key, want in _RECORD_FIELD_TYPES:
273
+ if key not in record or not isinstance(record[key], want):
274
+ return False
275
+ return True
276
+
277
+
278
+ def read_provenance(path):
279
+ """Read provenance JSON from `path`; return the dict, or None if missing/corrupt.
280
+
281
+ Never raises — mirrors read_board() in _taskboardlib.py. A missing file,
282
+ a permission error, or malformed JSON all return None; the caller degrades
283
+ gracefully.
284
+
285
+ #410: "corrupt" now includes SYNTACTICALLY VALID JSON of the wrong shape.
286
+ The documented return type is `dict | None`, but the raw json.load result
287
+ was handed straight back — so a file containing `[]` was admitted to the
288
+ store as a list and the first `.get()` downstream raised AttributeError.
289
+ Honouring the documented type here is what lets every caller treat a
290
+ non-None result as a usable record.
291
+ """
292
+ try:
293
+ with open(path, encoding="utf-8") as f:
294
+ record = json.load(f)
295
+ except (OSError, ValueError):
296
+ return None
297
+ return record if valid_provenance_record(record) else None
298
+
299
+
300
+ # ---------------------------------------------------------------------------
301
+ # Git hashing (injectable runner contract)
302
+ # ---------------------------------------------------------------------------
303
+
304
+
305
+ def _default_hash_runner(args, stdin_text):
306
+ """Run `git <args>` feeding stdin_text on stdin; return stdout as str.
307
+
308
+ Runner contract: runner(args, stdin_text) -> str
309
+
310
+ Uses subprocess.run with capture_output=True, text=True, encoding="utf-8".
311
+ Called only by batch_hash; never called at import time (zero side effects).
312
+ """
313
+ result = subprocess.run(
314
+ [git_executable()] + list(args),
315
+ input=stdin_text,
316
+ capture_output=True,
317
+ text=True,
318
+ encoding="utf-8",
319
+ timeout=GIT_TIMEOUT,
320
+ )
321
+ return result.stdout
322
+
323
+
324
+ def batch_hash(paths, runner=None):
325
+ """Hash all paths in a single git hash-object --stdin-paths call.
326
+
327
+ `paths` — list of repo-relative file paths.
328
+ `runner` — injectable; contract: runner(args, stdin_text) -> str.
329
+ Default is _default_hash_runner (uses subprocess, offline-safe
330
+ to inject a fake in tests).
331
+
332
+ Issues exactly ONE runner call for any non-empty paths list.
333
+ Returns {path: git_oid} preserving input order (insertion order = input
334
+ order via dict(zip(paths, oids))).
335
+
336
+ Degrade-not-fail: empty paths → {} with zero runner calls; a runner that
337
+ raises or returns fewer oids than paths → {} (or safely-zippable subset);
338
+ never raises.
339
+ """
340
+ if runner is None:
341
+ runner = _default_hash_runner
342
+ if not paths:
343
+ return {}
344
+ stdin_text = "\n".join(paths) + "\n"
345
+ try:
346
+ stdout = runner(["hash-object", "--stdin-paths"], stdin_text)
347
+ oids = [line for line in stdout.splitlines() if line]
348
+ return dict(zip(paths, oids))
349
+ except Exception:
350
+ return {}
351
+
352
+
353
+ # ---------------------------------------------------------------------------
354
+ # Source classification (drift_trigger predicate)
355
+ # ---------------------------------------------------------------------------
356
+
357
+
358
+ def classify_source(path):
359
+ """Return True iff path is a drift_trigger (config/manifest/schema/security-entry).
360
+
361
+ drift_trigger: True — low-churn, high-signal sources where derived-doc claims
362
+ live: package manifests, lockfiles, CI yaml, schema/
363
+ migration files, env templates, and auth/middleware/jwt
364
+ entry files.
365
+ drift_trigger: False — general implementation source (ts, py, go, tsx, md, …)
366
+ that still feeds the code-map/audit but never rings the
367
+ drift alarm.
368
+
369
+ Normalises path separators (backslash → forward slash) before matching, so a
370
+ Windows path classifies identically to its POSIX equivalent. Security-entry
371
+ keywords (auth, middleware, jwt) are matched as whole tokens anywhere in the
372
+ normalised path — the path is split on separators and camelCase transitions,
373
+ then lowercased, so "author.py" and "AuthorCard.tsx" do NOT fire but
374
+ "authMiddleware.ts" and "src/middleware/cors.py" do. Extension matching is
375
+ case-insensitive; fixed filenames (e.g. package.json, Cargo.toml) are exact.
376
+
377
+ Never raises — a None or garbage path returns False.
378
+ """
379
+ if not path:
380
+ return False
381
+ try:
382
+ norm = str(path).replace("\\", "/")
383
+ except Exception:
384
+ return False
385
+
386
+ # Extract the basename (last path segment after normalization).
387
+ basename = norm.rsplit("/", 1)[-1]
388
+
389
+ # 1. Fixed filename exact match (case-sensitive, per spec).
390
+ if basename in _DRIFT_FIXED_NAMES:
391
+ return True
392
+
393
+ # 2. Path-pattern rules (requirements*.txt, CI yaml, *.prisma, *.sql,
394
+ # migrations/, .env.*).
395
+ if _DRIFT_PATH_RE.search(norm):
396
+ return True
397
+
398
+ # 3. Security-entry token match: any token in the full normalised path (split
399
+ # on separators + camelCase boundaries, then lowercased) must be an exact
400
+ # member of _SECURITY_ENTRY_TOKENS. Whole-token matching prevents false
401
+ # positives from substrings such as "author" (contains "auth") or
402
+ # "AuthorCard" (camelCase token "Author", not "auth"). Matching the full
403
+ # path (not just the basename) catches path-segment entries like
404
+ # src/middleware/cors.py where "middleware" is a directory name.
405
+ path_tokens = [t.lower() for t in _SECURITY_TOKEN_SPLIT_RE.split(norm) if t]
406
+ if _SECURITY_ENTRY_TOKENS.intersection(path_tokens):
407
+ return True
408
+
409
+ return False
410
+
411
+
412
+ # ---------------------------------------------------------------------------
413
+ # Drift detection
414
+ # ---------------------------------------------------------------------------
415
+
416
+
417
+ def compute_drift(provenance_map, current_hashes):
418
+ """Detect changed-hash and missing entries across all docs in provenance_map.
419
+
420
+ provenance_map — {doc_name: provenance_record} as returned by
421
+ new_record/read_provenance. A None or malformed record
422
+ value is skipped without raising.
423
+ current_hashes — {path: git_oid} as returned by batch_hash.
424
+
425
+ Returns {doc_name: [{"path": str, "kind": str}, ...]} containing only docs
426
+ that have at least one diverged or missing entry. A doc with no drift is
427
+ OMITTED so that an empty result ({}) signals a fully clean state.
428
+
429
+ Drift kinds:
430
+ • "changed" — entry path IS present in current_hashes AND the stored
431
+ hash differs from current_hashes[path].
432
+ • "missing" — entry path is ABSENT from current_hashes entirely
433
+ (the source file was renamed or deleted).
434
+
435
+ Both kinds respect the drift_trigger filter (AC-09): only entries with
436
+ drift_trigger == True are ever considered. An absent or falsy
437
+ drift_trigger is treated as False — the conservative default — so
438
+ general architecture source captured for the code-map/audit never rings
439
+ the drift alarm regardless of whether the path is present or absent.
440
+
441
+ Never raises — malformed entries, None records, and unexpected structures
442
+ are all skipped gracefully.
443
+ """
444
+ result = {}
445
+ try:
446
+ items = provenance_map.items()
447
+ except Exception:
448
+ return {}
449
+
450
+ for doc_name, record in items:
451
+ try:
452
+ if record is None:
453
+ continue
454
+ entries = record.get("entries")
455
+ if not entries:
456
+ continue
457
+ doc_drifts = []
458
+ for entry in entries:
459
+ try:
460
+ # AC-09: only explicit True fires drift; absent/falsy → skip.
461
+ if entry.get("drift_trigger") is not True:
462
+ continue
463
+ path = entry.get("path")
464
+ stored_hash = entry.get("hash")
465
+ if path is None:
466
+ continue
467
+ if path not in current_hashes:
468
+ # AC-05 (T-06): drift_trigger:true entry absent from
469
+ # current_hashes means the source was renamed/deleted.
470
+ doc_drifts.append({"path": path, "kind": "missing"})
471
+ continue
472
+ current_hash = current_hashes[path]
473
+ if current_hash != stored_hash:
474
+ doc_drifts.append({"path": path, "kind": "changed"})
475
+ except Exception:
476
+ continue
477
+ if doc_drifts:
478
+ result[doc_name] = doc_drifts
479
+ except Exception:
480
+ continue
481
+
482
+ return result
483
+
484
+
485
+ # ---------------------------------------------------------------------------
486
+ # Per-doc drift scope (commit-gate auto-heal / /ca:context-check locality)
487
+ # ---------------------------------------------------------------------------
488
+
489
+
490
+ def changed_scope(doc_provenance, drift):
491
+ """Return drifted paths for this doc only — never another doc's paths (AC-11).
492
+
493
+ doc_provenance — a single doc's provenance record (must have a 'doc' field).
494
+ drift — the full compute_drift output {doc_name: [{"path","kind"}, ...]}.
495
+
496
+ Returns the list of path strings drifted for doc_provenance["doc"] in the
497
+ order they appear under that doc in drift. Both "changed" and "missing" kinds
498
+ are included. If the doc has no drift entry, returns []. If doc_provenance is
499
+ malformed/None, or drift is malformed, returns [] without raising.
500
+ """
501
+ try:
502
+ doc_name = doc_provenance.get("doc")
503
+ if not doc_name:
504
+ return []
505
+ doc_drifts = drift.get(doc_name)
506
+ if not doc_drifts:
507
+ return []
508
+ result = []
509
+ for entry in doc_drifts:
510
+ try:
511
+ result.append(entry["path"])
512
+ except Exception:
513
+ continue
514
+ return result
515
+ except Exception:
516
+ return []
517
+
518
+
519
+ # ---------------------------------------------------------------------------
520
+ # Re-baseline (commit-gate auto-heal — "claim still holds" branch, AC-12)
521
+ # ---------------------------------------------------------------------------
522
+
523
+
524
+ def rebaseline(provenance, current_hashes):
525
+ """Return a new record with each entry's hash updated to current_hashes[path].
526
+
527
+ Functional style: returns a new dict (shallow copy of the record; each
528
+ entry dict is also a new dict) so the caller's original is never mutated.
529
+ Only the 'hash' field of matching entries changes — 'claims', 'drift_trigger',
530
+ 'path', and all top-level record fields ('doc', 'schema', 'created',
531
+ 'interview_derived') are left untouched.
532
+
533
+ For entries whose path is absent from current_hashes the stored hash is kept
534
+ unchanged — a genuinely-deleted file is a separate decision, not a silent
535
+ re-baseline.
536
+
537
+ Never raises — None or malformed input returns the input unchanged.
538
+ """
539
+ try:
540
+ if provenance is None:
541
+ return provenance
542
+ # Shallow-copy the top-level record so we return a new object.
543
+ result = dict(provenance)
544
+ entries = provenance.get("entries")
545
+ if not isinstance(entries, list):
546
+ return result
547
+ # Build a safe hash lookup; degrade to empty dict on None/malformed.
548
+ try:
549
+ hashes = dict(current_hashes) if current_hashes is not None else {}
550
+ except Exception:
551
+ hashes = {}
552
+ new_entries = []
553
+ for entry in entries:
554
+ try:
555
+ new_entry = dict(entry)
556
+ path = new_entry.get("path")
557
+ if path is not None and path in hashes:
558
+ new_entry["hash"] = hashes[path]
559
+ new_entries.append(new_entry)
560
+ except Exception:
561
+ # Malformed entry: append as-is without crashing.
562
+ new_entries.append(entry)
563
+ result["entries"] = new_entries
564
+ return result
565
+ except Exception:
566
+ return provenance
567
+
568
+
569
+ # ---------------------------------------------------------------------------
570
+ # Commit-gate auto-heal selector (AC-13)
571
+ # ---------------------------------------------------------------------------
572
+
573
+
574
+ def heal_worklist(staged_paths, provenance, current_hashes):
575
+ """Return staged paths that are drift_trigger:true entries with diverged/absent hashes.
576
+
577
+ staged_paths — list of repo-relative paths staged for this commit.
578
+ provenance — {doc_name: record} map (same shape as compute_drift uses).
579
+ current_hashes — {path: git_oid} for the staged paths.
580
+
581
+ Returns the subset of staged_paths that are BOTH:
582
+ (a) present as a drift_trigger:true entry in some doc's provenance, AND
583
+ (b) diverged — current_hashes.get(path) differs from the stored hash,
584
+ OR path is absent from current_hashes (staged deletion/rename).
585
+
586
+ A staged path that is not a provenance entry, or is drift_trigger:false, or
587
+ whose hash matches → excluded. Preserves staged_paths order; deduplicates.
588
+ Empty staged_paths, or no staged file tracked → [] (cost guarantee: ordinary
589
+ commits touching no provenance source do zero re-scout work, AC-13).
590
+
591
+ Never raises — malformed map/None → [].
592
+ """
593
+ try:
594
+ if not staged_paths:
595
+ return []
596
+
597
+ # Build {path: stored_hash} for every drift_trigger:true entry across all docs.
598
+ # First occurrence wins for a path that appears in multiple docs.
599
+ drift_trigger_map = {}
600
+ try:
601
+ items = provenance.items()
602
+ except Exception:
603
+ return []
604
+
605
+ for _doc_name, record in items:
606
+ try:
607
+ if record is None:
608
+ continue
609
+ entries = record.get("entries")
610
+ if not entries:
611
+ continue
612
+ for entry in entries:
613
+ try:
614
+ if entry.get("drift_trigger") is not True:
615
+ continue
616
+ path = entry.get("path")
617
+ if path is None:
618
+ continue
619
+ if path not in drift_trigger_map:
620
+ drift_trigger_map[path] = entry.get("hash")
621
+ except Exception:
622
+ continue
623
+ except Exception:
624
+ continue
625
+
626
+ # Build a safe hash lookup; degrade to {} on None/malformed.
627
+ try:
628
+ hashes = dict(current_hashes) if current_hashes is not None else {}
629
+ except Exception:
630
+ hashes = {}
631
+
632
+ # Filter staged_paths: include only drift_trigger:true paths that diverged.
633
+ # Preserve order; deduplicate via a seen-set.
634
+ seen = set()
635
+ result = []
636
+ for path in staged_paths:
637
+ try:
638
+ if path in seen:
639
+ continue
640
+ seen.add(path)
641
+ if path not in drift_trigger_map:
642
+ continue
643
+ stored_hash = drift_trigger_map[path]
644
+ # Include when absent from current_hashes (staged deletion/rename)
645
+ # OR when the hash has diverged.
646
+ if path not in hashes or hashes[path] != stored_hash:
647
+ result.append(path)
648
+ except Exception:
649
+ continue
650
+
651
+ return result
652
+ except Exception:
653
+ return []
654
+
655
+
656
+ # ---------------------------------------------------------------------------
657
+ # Provenance directory loader + SessionStart drift line
658
+ # ---------------------------------------------------------------------------
659
+
660
+
661
+ MAX_SKIPPED_REPORTED = 5 # bounded corruption diagnostic (#410)
662
+
663
+
664
+ def load_provenance_dir(provenance_dir, skipped=None):
665
+ """Load all provenance records from provenance_dir into {doc: record}.
666
+
667
+ Globs <provenance_dir>/*.json, calls read_provenance on each file, and
668
+ skips any that return None (missing, unreadable, or corrupt JSON — which
669
+ since #410 includes valid JSON of the wrong shape). Each surviving record
670
+ is keyed by its 'doc' field; if 'doc' is absent or empty, falls back to the
671
+ filename stem so the dict never loses an entry. A missing or non-directory
672
+ provenance_dir returns {}.
673
+
674
+ #410: the per-file work sits inside its OWN error boundary. The whole glob
675
+ loop used to share one try/except, so the first record that raised aborted
676
+ the scan and every LATER valid record vanished with no diagnostic —
677
+ SessionStart drift reporting and commit-gate auto-heal then ran against a
678
+ silently truncated map. One bad file now costs exactly itself.
679
+
680
+ `skipped`, when a list is passed, collects the BASENAMES of rejected files
681
+ (at most MAX_SKIPPED_REPORTED) so a caller that has somewhere to put a
682
+ warning can name the corrupt record. Names only — never file contents, which
683
+ may hold paths or claims that do not belong in a log line.
684
+
685
+ NO PRODUCTION CALLER PASSES `skipped` TODAY, and that is deliberate rather
686
+ than an oversight. The two real call sites cannot carry the diagnostic:
687
+ startup_drift_line is bound by AC-08 ("degrade to silence" — an all-corrupt
688
+ provenance dir MUST return '', pinned by test_degrade_corrupt_json), and
689
+ build_index runs on every PreToolUse:Read, where a per-call warning would be
690
+ noise on the hottest path in the hook layer. Surfacing corruption to a user
691
+ therefore belongs to /ca:context-check or /ca:doctor, which are surface
692
+ prose and out of this module's reach. The channel exists, is bounded, and is
693
+ tested; anything reading it is a follow-up.
694
+
695
+ Never raises — filesystem errors and malformed records are skipped (this
696
+ runs on the SessionStart linchpin path).
697
+ """
698
+ result = {}
699
+
700
+ def note(fpath):
701
+ if skipped is None or len(skipped) >= MAX_SKIPPED_REPORTED:
702
+ return
703
+ try:
704
+ skipped.append(os.path.basename(fpath))
705
+ except Exception: # noqa: BLE001 — a diagnostic must never break the load
706
+ pass
707
+
708
+ try:
709
+ if not os.path.isdir(provenance_dir):
710
+ return {}
711
+ paths = glob.glob(os.path.join(provenance_dir, "*.json"))
712
+ except Exception: # noqa: BLE001 — an unreadable dir is an empty map
713
+ return {}
714
+
715
+ for fpath in paths:
716
+ try:
717
+ record = read_provenance(fpath)
718
+ if record is None:
719
+ note(fpath)
720
+ continue
721
+ doc = record.get("doc") or os.path.splitext(os.path.basename(fpath))[0]
722
+ result[str(doc)] = record
723
+ except Exception: # noqa: BLE001 — per-file boundary: skip THIS file only
724
+ note(fpath)
725
+ return result
726
+
727
+
728
+ def _make_root_runner(root):
729
+ """Return a batch_hash-compatible runner bound to root via git -C.
730
+
731
+ The default runner used by startup_drift_line resolves repo-relative
732
+ paths from <root> rather than the process cwd. T-16 injects its own
733
+ runner; tests inject a fake to avoid real git calls.
734
+ """
735
+ def runner(args, stdin_text):
736
+ result = subprocess.run(
737
+ [git_executable(), "-C", root] + list(args),
738
+ input=stdin_text,
739
+ capture_output=True,
740
+ text=True,
741
+ encoding="utf-8",
742
+ timeout=GIT_TIMEOUT,
743
+ )
744
+ return result.stdout
745
+ return runner
746
+
747
+
748
+ def _is_confined_provenance_path(root, path):
749
+ """Return whether path is a safe, non-root descendant of root.
750
+
751
+ Provenance JSON is repository data, so treat its paths as untrusted before
752
+ feeding them to git's newline-delimited ``--stdin-paths`` protocol. Check
753
+ both POSIX and Windows absolute/drive syntax so a record remains safe when
754
+ a checkout moves between hosts, then resolve on the current host to catch
755
+ traversal and symlink escapes.
756
+ """
757
+ try:
758
+ if not isinstance(path, str) or not path:
759
+ return False
760
+ if any(char in path for char in ("\x00", "\n", "\r")):
761
+ return False
762
+ if posixpath.isabs(path) or ntpath.isabs(path) or ntpath.splitdrive(path)[0]:
763
+ return False
764
+
765
+ portable_path = posixpath.normpath(path.replace("\\", "/"))
766
+ if portable_path in (".", "..") or portable_path.startswith("../"):
767
+ return False
768
+
769
+ confined_root = os.path.realpath(os.path.abspath(root))
770
+ candidate = os.path.realpath(os.path.join(confined_root, path))
771
+ return (
772
+ candidate != confined_root
773
+ and os.path.commonpath((confined_root, candidate)) == confined_root
774
+ )
775
+ except (OSError, TypeError, ValueError):
776
+ return False
777
+
778
+
779
+ def startup_drift_line(root, runner=None, cmd_ref=None):
780
+ """Return a one-line drift summary for SessionStart, or '' when clean (AC-06).
781
+
782
+ Pipeline:
783
+ 1. Load all provenance records from <root>/.codearbiter/.provenance/.
784
+ 2. If the map is empty, return '' immediately (nothing to check).
785
+ 3. Reject unsafe drift-trigger paths, then collect the safe set across all
786
+ docs. Rejected entries are also excluded from drift comparison so they
787
+ cannot become false missing-file alarms.
788
+ 4. Split into existing paths (os.path.exists(<root>/<path>)) and missing
789
+ ones. Hash only the existing ones via batch_hash — git hash-object
790
+ errors on non-existent paths and would corrupt the batch. Absent paths
791
+ stay absent from current_hashes so compute_drift reports them as
792
+ kind='missing' (a deleted drift_trigger source IS drift).
793
+ 4a. AC-08 degrade-to-silence on hash failure: if batch_hash returned
794
+ fewer hashes than existing_paths (runner raised, git is unavailable,
795
+ or git aborted mid-stream producing a partial stdout), the tooling
796
+ has failed — not the source files. Return '' rather than feeding
797
+ a short hash map to compute_drift, which would falsely flag the
798
+ un-hashed existing files as 'missing'. A path that genuinely does
799
+ not exist on disk is still reported as missing (it is not in
800
+ existing_paths so the count comparison is unaffected).
801
+ 5. Call compute_drift(pm, current_hashes).
802
+ 6. Empty result → return '' (AC-06: silent when docs are fresh).
803
+ Non-empty → return exactly one ASCII line (AC-07):
804
+ "context drift: N stale source(s) across M doc(s) -- run /ca:context-check"
805
+ where N = sum of all drifted entry paths and M = number of affected docs.
806
+
807
+ runner: injectable; contract: runner(args, stdin_text) -> str.
808
+ The default runner uses 'git -C root hash-object --stdin-paths' so
809
+ provenance paths are resolved from root. T-16 injects its own root-bound
810
+ runner; tests inject a fake.
811
+
812
+ Never raises — all errors degrade to '' (safe for the SessionStart path).
813
+ """
814
+ try:
815
+ if runner is None:
816
+ runner = _make_root_runner(root)
817
+ provenance_dir = os.path.join(root, ".codearbiter", ".provenance")
818
+ pm = load_provenance_dir(provenance_dir)
819
+ if not pm:
820
+ return ""
821
+
822
+ # Collect safe drift_trigger:true paths across all docs (deduped,
823
+ # order-stable). Build a filtered in-memory map at the same time so a
824
+ # rejected path cannot reappear as a false missing-file drift result.
825
+ drift_trigger_paths = []
826
+ safe_pm = {}
827
+ seen = set()
828
+ for doc, record in pm.items():
829
+ try:
830
+ entries = record.get("entries") or []
831
+ safe_entries = []
832
+ for entry in entries:
833
+ try:
834
+ if entry.get("drift_trigger") is True:
835
+ path = entry.get("path")
836
+ if not _is_confined_provenance_path(root, path):
837
+ continue
838
+ if path not in seen:
839
+ drift_trigger_paths.append(path)
840
+ seen.add(path)
841
+ safe_entries.append(entry)
842
+ except Exception:
843
+ continue
844
+ safe_record = dict(record)
845
+ safe_record["entries"] = safe_entries
846
+ safe_pm[doc] = safe_record
847
+ except Exception:
848
+ continue
849
+ pm = safe_pm
850
+
851
+ if not pm:
852
+ return ""
853
+
854
+ # Existence-aware hashing: only hash files that exist on disk.
855
+ # Non-existent paths are intentionally absent from current_hashes so
856
+ # compute_drift reports them as kind='missing' (deleted source = drift).
857
+ existing_paths = [
858
+ p for p in drift_trigger_paths
859
+ if os.path.exists(os.path.join(root, p))
860
+ ]
861
+ current_hashes = batch_hash(existing_paths, runner) if existing_paths else {}
862
+
863
+ # AC-08: degrade-to-silence if the hash step did not return a hash for
864
+ # every existing drift_trigger path (git unavailable, runner raised, or a
865
+ # partial/aborted stdout). Feeding a short hash map to compute_drift would
866
+ # falsely report the un-hashed files as 'missing'. Conservative: stay silent.
867
+ if len(current_hashes) < len(existing_paths):
868
+ return ""
869
+
870
+ drift = compute_drift(pm, current_hashes)
871
+ if not drift:
872
+ return ""
873
+ # AC-07: one ASCII line: stale-source count, affected-doc count, pointer.
874
+ stale_count = sum(len(v) for v in drift.values())
875
+ doc_count = len(drift)
876
+ ref = cmd_ref("context-check") if cmd_ref else "/ca:context-check"
877
+ return (
878
+ "context drift: {} stale source(s) across {} doc(s)"
879
+ " -- run {}".format(stale_count, doc_count, ref)
880
+ )
881
+ except Exception:
882
+ return ""
883
+
884
+
885
+ # ---------------------------------------------------------------------------
886
+ # Code-map linter (AC-15)
887
+ # ---------------------------------------------------------------------------
888
+
889
+
890
+ def lint_code_map(text):
891
+ """Lint a code-map.md string for entry-cap and multi-line-role violations.
892
+
893
+ Code-map format: markdown where entries are column-0 '- `path` -- role'
894
+ bullets. Concern '## <name>' headings are structural labels, NOT entries.
895
+
896
+ Checks applied:
897
+ 1. Entry count > CODE_MAP_MAX_ENTRIES -> one warning naming count and cap.
898
+ The cap enforces module/concern granularity: the code map must stay a
899
+ coarse index, never a full file listing.
900
+ 2. For each entry whose immediately-following physical line starts with
901
+ whitespace (an indented continuation — the role spilled onto a second
902
+ line) -> one warning per offending entry, naming the entry number and
903
+ path. Column-0 '- ' bullets and '## ' headings do NOT start with
904
+ whitespace so are excluded automatically.
905
+
906
+ Returns a list of human-readable ASCII warning strings; [] means clean.
907
+ Empty or None text -> [] (a missing/empty code map has no violations).
908
+ Never raises.
909
+ """
910
+ if not text:
911
+ return []
912
+ try:
913
+ lines = text.splitlines()
914
+ entry_paths = [] # ordered list of extracted paths (one per entry)
915
+ multi_warnings = [] # per-entry multi-line-role warnings (built inline)
916
+
917
+ for i, line in enumerate(lines):
918
+ m = _ENTRY_RE.match(line)
919
+ if not m:
920
+ continue
921
+ entry_path = m.group(1)
922
+ entry_num = len(entry_paths) + 1 # 1-indexed
923
+ entry_paths.append(entry_path)
924
+
925
+ # Multi-line role: the next physical line starts with whitespace AND
926
+ # contains non-whitespace content (a genuine continuation, not blank
927
+ # vertical spacing). Column-0 bullets ('- ') and headings ('## ')
928
+ # do not start with whitespace and are excluded by this check.
929
+ if i + 1 < len(lines):
930
+ next_line = lines[i + 1]
931
+ if next_line.strip() and next_line[0:1] in (" ", "\t"):
932
+ multi_warnings.append(
933
+ "multi-line role at entry {} (path {}) -- roles must be one line".format(
934
+ entry_num, entry_path
935
+ )
936
+ )
937
+
938
+ result = []
939
+ count = len(entry_paths)
940
+ if count > CODE_MAP_MAX_ENTRIES:
941
+ result.append(
942
+ "code map has {} entries (cap {}) -- coarsen to module/concern granularity".format(
943
+ count, CODE_MAP_MAX_ENTRIES
944
+ )
945
+ )
946
+ result.extend(multi_warnings)
947
+ return result
948
+ except Exception:
949
+ return []
950
+
951
+
952
+ # ---------------------------------------------------------------------------
953
+ # Greenfield stub writer (AC-18)
954
+ # ---------------------------------------------------------------------------
955
+
956
+
957
+ def write_stub(path, doc, *, interview_derived=True, created=None):
958
+ """Write a greenfield provenance stub to `path` for `doc`.
959
+
960
+ The stub record is new_record(doc, interview_derived=interview_derived,
961
+ entries=[], created=created), written via write_provenance (which creates
962
+ parent directories, pretty-prints JSON with LF endings, and does not raise
963
+ beyond documented I/O errors). interview_derived defaults to True — stubs
964
+ are for greenfield docs where no source files exist yet. created passes
965
+ through to new_record; None means today (datetime.date.today()).
966
+
967
+ Reuses new_record + write_provenance — contains no duplicated JSON logic.
968
+ Never raises beyond what write_provenance already does.
969
+ """
970
+ record = new_record(doc, interview_derived=interview_derived, entries=[], created=created)
971
+ write_provenance(path, record)