@arbiterforge/ca-pi 0.15.12 → 0.16.1

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 (64) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/plugins/ca-pi/CHANGELOG.md +33 -0
  4. package/plugins/ca-pi/SKILLS.md +1 -1
  5. package/plugins/ca-pi/agents/infra-author.md +7 -1
  6. package/plugins/ca-pi/agents/scout.md +40 -8
  7. package/plugins/ca-pi/generated/command-catalog.json +1 -1
  8. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
  9. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
  10. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
  11. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
  12. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
  13. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
  14. package/plugins/ca-pi/helpers/artifacts/release.json +104 -6
  15. package/plugins/ca-pi/hooks/_activationlib.py +97 -13
  16. package/plugins/ca-pi/hooks/_arbiterstatelib.py +7 -2
  17. package/plugins/ca-pi/hooks/_artifactauthoritylib.py +313 -76
  18. package/plugins/ca-pi/hooks/_artifactlib.py +166 -2
  19. package/plugins/ca-pi/hooks/_artifactpromptlib.py +106 -2
  20. package/plugins/ca-pi/hooks/_bashguardlib.py +14 -0
  21. package/plugins/ca-pi/hooks/_contextreportlib.py +564 -6
  22. package/plugins/ca-pi/hooks/_contextselectlib.py +464 -0
  23. package/plugins/ca-pi/hooks/_contextsnapshotlib.py +999 -0
  24. package/plugins/ca-pi/hooks/_debughandofflib.py +767 -0
  25. package/plugins/ca-pi/hooks/_hooklib.py +1 -0
  26. package/plugins/ca-pi/hooks/_host.py +1 -1
  27. package/plugins/ca-pi/hooks/_intentlib.py +5 -5
  28. package/plugins/ca-pi/hooks/_protectedlib.py +3 -3
  29. package/plugins/ca-pi/hooks/_protectedstatelib.py +152 -2
  30. package/plugins/ca-pi/hooks/_provenancelib.py +604 -34
  31. package/plugins/ca-pi/hooks/_readinjectlib.py +131 -1
  32. package/plugins/ca-pi/hooks/artifact-authority.py +20 -1
  33. package/plugins/ca-pi/hooks/debug-handoff.py +85 -0
  34. package/plugins/ca-pi/hooks/doctor.py +4 -2
  35. package/plugins/ca-pi/hooks/hostapi.py +1 -1
  36. package/plugins/ca-pi/hooks/init-codearbiter.py +143 -15
  37. package/plugins/ca-pi/hooks/prompt-submit.py +14 -0
  38. package/plugins/ca-pi/hooks/session-start.py +26 -7
  39. package/plugins/ca-pi/includes/author-tdd-workflow.md +16 -4
  40. package/plugins/ca-pi/includes/helper-invocation.md +128 -0
  41. package/plugins/ca-pi/includes/pi-host-notes.md +13 -0
  42. package/plugins/ca-pi/includes/routing-table.md +1 -1
  43. package/plugins/ca-pi/package.json +1 -1
  44. package/plugins/ca-pi/routines/INDEX.md +1 -1
  45. package/plugins/ca-pi/routines/commit-gate/SKILL.md +5 -3
  46. package/plugins/ca-pi/routines/context-check/SKILL.md +103 -17
  47. package/plugins/ca-pi/routines/context-creation/SKILL.md +290 -54
  48. package/plugins/ca-pi/routines/debug/SKILL.md +303 -55
  49. package/plugins/ca-pi/routines/debug/references/debug-handoff.schema.json +879 -0
  50. package/plugins/ca-pi/routines/debug/references/handoff.md +155 -0
  51. package/plugins/ca-pi/routines/decompose/SKILL.md +13 -0
  52. package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +34 -0
  53. package/plugins/ca-pi/routines/tdd/SKILL.md +91 -17
  54. package/plugins/ca-pi/skills/ca-commit/SKILL.md +5 -3
  55. package/plugins/ca-pi/skills/ca-context-check/SKILL.md +103 -17
  56. package/plugins/ca-pi/skills/ca-create-context/SKILL.md +290 -54
  57. package/plugins/ca-pi/skills/ca-debug/SKILL.md +303 -55
  58. package/plugins/ca-pi/skills/ca-decompose/SKILL.md +13 -0
  59. package/plugins/ca-pi/skills/ca-feature/SKILL.md +1 -1
  60. package/plugins/ca-pi/skills/ca-fix/SKILL.md +36 -3
  61. package/plugins/ca-pi/skills/ca-init/SKILL.md +19 -0
  62. package/plugins/ca-pi/skills/ca-review/SKILL.md +33 -4
  63. package/plugins/ca-pi/skills/ca-status/SKILL.md +5 -0
  64. package/plugins/ca-pi/skills/ca-task/SKILL.md +55 -8
@@ -123,6 +123,8 @@ import json
123
123
  import math
124
124
  import os
125
125
  import re
126
+ import tempfile
127
+ import time
126
128
  import unicodedata
127
129
 
128
130
  import _provenancelib
@@ -791,11 +793,23 @@ def governing_docs(rel, index, runner=None):
791
793
  # paths. This is git-free. A non-provenance Read exits here with zero
792
794
  # git calls.
793
795
  entry_paths = set()
796
+ v2_docs = []
794
797
  if isinstance(provenance, dict):
795
- for record in provenance.values():
798
+ for doc_name, record in provenance.items():
796
799
  try:
797
800
  if not isinstance(record, dict):
798
801
  continue
802
+ if record.get("schema") == _provenancelib.V2_SCHEMA_VERSION:
803
+ if not _provenancelib.valid_provenance_record(record):
804
+ continue
805
+ cited = any(
806
+ evidence.get("kind") == "content" and evidence.get("path") == rel
807
+ for field in record["fields"] for claim in field["claims"]
808
+ for evidence in claim["evidence"]
809
+ )
810
+ if cited or record["document"]["path"] == rel:
811
+ v2_docs.append(doc_name)
812
+ continue
799
813
  entries = record.get("entries")
800
814
  if not isinstance(entries, list):
801
815
  continue
@@ -810,6 +824,13 @@ def governing_docs(rel, index, runner=None):
810
824
  except Exception: # noqa: BLE001
811
825
  continue
812
826
 
827
+ # A v2 content dependency is relevant advisory context, but its raw
828
+ # digest and semantic review cannot be proved by the legacy Git-hash
829
+ # gate. Point to the action-boundary audit without claiming freshness.
830
+ for doc_name in v2_docs:
831
+ result.append({"text": "{}.md context identity and semantic review unverified here -- run context-check".format(
832
+ _strip_context_controls(doc_name)), "tier": "standards"})
833
+
813
834
  # Step B: only call batch_hash when rel is a known provenance entry path.
814
835
  # Absence from entry_paths → SKIP; zero git calls.
815
836
  if rel and rel in entry_paths:
@@ -1166,6 +1187,104 @@ def spec_pointers(rel, index):
1166
1187
 
1167
1188
  _artifact_legacy_compute_injection = compute_injection
1168
1189
 
1190
+ def _session_context_generation_path(root, session_id):
1191
+ # One bounded value per session avoids a shared unbounded session map and
1192
+ # lets unrelated sessions advance without a read/modify/write race.
1193
+ return marker_path(root, session_id, 'compaction-generation',
1194
+ prefix='readinject-context-')
1195
+
1196
+
1197
+ def _linklike(path):
1198
+ return os.path.islink(path) or getattr(os.path, 'isjunction', lambda _: False)(path)
1199
+
1200
+
1201
+ def _safe_context_generation_path(root, session_id):
1202
+ if not isinstance(root, (str, os.PathLike)) or not os.path.isabs(root):
1203
+ return None
1204
+ path = _session_context_generation_path(root, session_id)
1205
+ parent = os.path.dirname(path)
1206
+ if (_linklike(root) or _linklike(os.path.join(root, '.codearbiter')) or _linklike(parent)
1207
+ or _linklike(path) or _linklike(path + '.lock')):
1208
+ return None
1209
+ return path
1210
+
1211
+
1212
+ def read_context_generation(root, session_id):
1213
+ """Read a bounded worktree-local epoch; None means corrupt/unsafe/unknown."""
1214
+ try:
1215
+ path = _safe_context_generation_path(root, session_id)
1216
+ if path is None:
1217
+ return None
1218
+ with open(path, 'rb') as stream:
1219
+ raw = stream.read(257)
1220
+ if len(raw) > 256 or not raw or not raw.isdigit():
1221
+ return None
1222
+ value = int(raw)
1223
+ return value if value <= 1_000_000_000 else None
1224
+ except FileNotFoundError:
1225
+ return 0
1226
+ except Exception: # noqa: BLE001 - advisory hook never blocks a read
1227
+ return None
1228
+
1229
+
1230
+ def bump_context_generation(root, session_id):
1231
+ """Invalidate surviving read markers after compaction, without a scan."""
1232
+ if not session_id:
1233
+ return False
1234
+ lock = None
1235
+ lock_acquired = False
1236
+ temporary = None
1237
+ try:
1238
+ path = _safe_context_generation_path(root, session_id)
1239
+ if path is None:
1240
+ return False
1241
+ os.makedirs(os.path.dirname(path), exist_ok=True)
1242
+ if _safe_context_generation_path(root, session_id) != path:
1243
+ return False
1244
+ lock = path + '.lock'
1245
+ for _ in range(25):
1246
+ try:
1247
+ os.mkdir(lock)
1248
+ lock_acquired = True
1249
+ break
1250
+ except FileExistsError:
1251
+ time.sleep(0.01)
1252
+ else:
1253
+ return False
1254
+ current = read_context_generation(root, session_id)
1255
+ if current is None or current >= 1_000_000_000:
1256
+ return False # Corrupt bytes stay intact and force redelivery.
1257
+ fd, temporary = tempfile.mkstemp(prefix='readinject-context-',
1258
+ dir=os.path.dirname(path))
1259
+ with os.fdopen(fd, 'wb') as stream:
1260
+ stream.write(str(current + 1).encode('ascii'))
1261
+ if _safe_context_generation_path(root, session_id) != path:
1262
+ return False
1263
+ os.replace(temporary, path)
1264
+ temporary = None
1265
+ return True
1266
+ except Exception: # noqa: BLE001 - task-time delivery remains the primary route
1267
+ return False
1268
+ finally:
1269
+ if temporary is not None:
1270
+ try:
1271
+ os.unlink(temporary)
1272
+ except OSError:
1273
+ pass
1274
+ if lock_acquired:
1275
+ try:
1276
+ os.rmdir(lock)
1277
+ except OSError:
1278
+ pass
1279
+
1280
+
1281
+ def _context_epoch_session(root, session_id):
1282
+ generation = read_context_generation(root, session_id)
1283
+ if generation is None:
1284
+ return None
1285
+ return (str(session_id) + '|context:' + str(generation)
1286
+ if generation else session_id)
1287
+
1169
1288
 
1170
1289
  def compute_injection(root, session_id, rel, runner=None):
1171
1290
  # Bind the injected pointers and dedup epoch to ONE validated HTML snapshot.
@@ -1173,8 +1292,19 @@ def compute_injection(root, session_id, rel, runner=None):
1173
1292
  # under B's marker, suppressing a later stable B read. HTML therefore builds
1174
1293
  # once before dedup; the same entries drive both context and epoch. This is
1175
1294
  # advisory identity only, never approval or execution authority.
1295
+ # Preserve the legacy self-read zero-I/O path before any epoch lookup.
1176
1296
  if not isinstance(rel, str) or rel.replace("\\", "/").split("/", 1)[0] == ".codearbiter":
1177
1297
  return _artifact_legacy_compute_injection(root, session_id, rel, runner)
1298
+ session_id = _context_epoch_session(root, session_id)
1299
+ if session_id is None:
1300
+ # Unknown/corrupt epoch: bounded advisory redelivery without writing
1301
+ # a fresh marker for every read. The actor delivery planner owns its
1302
+ # separate two-attempt accounting and action gap.
1303
+ try:
1304
+ index = build_index(root)
1305
+ return assemble_context(governing_docs(rel, index, runner), budget=150)
1306
+ except Exception: # noqa: BLE001
1307
+ return ""
1178
1308
  try:
1179
1309
  from _artifactlib import has_html
1180
1310
  if not has_html(root):
@@ -11,6 +11,7 @@ import sys
11
11
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
12
12
  import _artifactauthoritylib # noqa: E402
13
13
  import _artifactlib # noqa: E402
14
+ import _artifactpromptlib # noqa: E402
14
15
  import hostapi # noqa: E402
15
16
 
16
17
 
@@ -54,6 +55,15 @@ def main(argv=None) -> int:
54
55
  "--codex-review-profile", choices=("native-v1",), default=None,
55
56
  help="select only after qualifying the actual registered native V1 UUID interface",
56
57
  )
58
+ context_arm = sub.add_parser("arm-context")
59
+ context_arm.add_argument("--root", required=True)
60
+ context_arm.add_argument("--request-file", required=True)
61
+ context_cancel = sub.add_parser("cancel-context")
62
+ context_cancel.add_argument("--root", required=True)
63
+ context_cancel.add_argument("--document-id", required=True)
64
+ context_apply = sub.add_parser("apply-context")
65
+ context_apply.add_argument("--root", required=True)
66
+ context_apply.add_argument("--receipt", required=True)
57
67
  verify = sub.add_parser("verify")
58
68
  verify.add_argument("--root", required=True)
59
69
  verify.add_argument("--request-id", required=True)
@@ -66,6 +76,9 @@ def main(argv=None) -> int:
66
76
  recover.add_argument("--disposition", required=True, choices=("failed", "abandoned"))
67
77
  args = parser.parse_args(argv)
68
78
  root = Path(args.root).resolve(strict=True)
79
+ if args.command == "cancel-context":
80
+ print(json.dumps(_artifactauthoritylib.cancel_context_preview(root, args.document_id), sort_keys=True))
81
+ return 0
69
82
  client = _artifactlib.ArtifactClient(
70
83
  root, _artifactlib.helper_installation(__file__)
71
84
  )
@@ -96,6 +109,11 @@ def main(argv=None) -> int:
96
109
  result["verify_command"] = (
97
110
  f'python "{script}" verify --root "{root}" --request-id {result["request_id"]}'
98
111
  )
112
+ elif args.command == "arm-context":
113
+ preview = _artifactlib.read_context_preview_request(root, args.request_file)
114
+ result = _artifactauthoritylib.arm_context_preview(root, client, preview)
115
+ elif args.command == "apply-context":
116
+ result = client.call("context-apply", {"receipt": args.receipt})
99
117
  elif args.command == "verify":
100
118
  result = _artifactauthoritylib.run_verification(
101
119
  root, client, args.request_id,
@@ -113,6 +131,7 @@ def main(argv=None) -> int:
113
131
  if __name__ == "__main__":
114
132
  try:
115
133
  raise SystemExit(main())
116
- except (_artifactauthoritylib.AuthorityError, _artifactlib.ArtifactError) as exc:
134
+ except (_artifactauthoritylib.AuthorityError, _artifactlib.ArtifactError,
135
+ _artifactpromptlib.PromptRouteError) as exc:
117
136
  sys.stderr.write(str(exc) + "\n")
118
137
  raise SystemExit(1)
@@ -0,0 +1,85 @@
1
+ # codeArbiter — private bounded debug handoff validation entry.
2
+ # Read only stdin and the installed validator; emit closed, redacted diagnostics.
3
+ # Bytecode suppression precedes the local import and no project state is touched.
4
+ #
5
+ # main(argv) -> int: validate one bounded packet and write one result envelope.
6
+ import sys
7
+
8
+ sys.dont_write_bytecode = True
9
+
10
+ import json
11
+ import re
12
+
13
+ import _debughandofflib as handoff
14
+
15
+
16
+ PROTOCOL = "codearbiter.debug-validation/1.0.0"
17
+ DIRECT_CODES = frozenset({
18
+ "INPUT_LIMIT", "DEPTH_LIMIT", "INVALID_UTF8", "INVALID_JSON",
19
+ "DUPLICATE_KEY", "UNSUPPORTED_PROTOCOL", "REQUIRED_FIELD",
20
+ "UNKNOWN_FIELD", "TYPE", "VALUE",
21
+ })
22
+ REFERENCE_CODES = frozenset({"DUPLICATE_ID", "DANGLING_REFERENCE"})
23
+ PATH = re.compile(r"\$(?:\.[A-Za-z_]+|\[[0-9]+\])*\Z", re.ASCII)
24
+
25
+
26
+ def _result(valid, errors, truncated=False):
27
+ return {"protocol": PROTOCOL, "valid": valid,
28
+ "errors": errors, "truncated": truncated}
29
+
30
+
31
+ def _diagnostic(code, path="$"):
32
+ if code in DIRECT_CODES or code in ("USAGE", "INPUT_IO", "INTERNAL"):
33
+ public_code = code
34
+ elif code in REFERENCE_CODES:
35
+ public_code = "REFERENCE"
36
+ else:
37
+ public_code = "SEMANTIC"
38
+ if type(path) is not str or len(path) > 256 or not PATH.fullmatch(path):
39
+ return {"code": "INTERNAL", "path": "$"}
40
+ return {"code": public_code, "path": path}
41
+
42
+
43
+ def _write(result):
44
+ raw = (json.dumps(result, ensure_ascii=True, separators=(",", ":"))
45
+ + "\n").encode("utf-8")
46
+ if len(raw) > 8192:
47
+ result = _result(False, [{"code": "INTERNAL", "path": "$"}])
48
+ raw = (json.dumps(result, separators=(",", ":")) + "\n").encode("utf-8")
49
+ exit_code = 3
50
+ else:
51
+ exit_code = 0 if result["valid"] else 2
52
+ if result["errors"] and result["errors"][0]["code"] in ("INPUT_IO", "INTERNAL"):
53
+ exit_code = 3
54
+ try:
55
+ sys.stdout.buffer.write(raw)
56
+ sys.stdout.buffer.flush()
57
+ except OSError:
58
+ return 3
59
+ return exit_code
60
+
61
+
62
+ def main(argv=None):
63
+ if argv is None:
64
+ argv = sys.argv[1:]
65
+ if argv != ["validate"]:
66
+ return _write(_result(False, [_diagnostic("USAGE")]))
67
+ try:
68
+ raw = sys.stdin.buffer.read(65537)
69
+ except OSError:
70
+ return _write(_result(False, [_diagnostic("INPUT_IO")]))
71
+ except Exception:
72
+ return _write(_result(False, [_diagnostic("INTERNAL")]))
73
+ try:
74
+ packet, error = handoff.decode_packet(raw)
75
+ if error is not None:
76
+ return _write(_result(False, [_diagnostic(error)]))
77
+ found, truncated = handoff.validate_semantics_bounded(packet, ceiling=16)
78
+ errors = [_diagnostic(code, path) for code, path in found]
79
+ return _write(_result(not found, errors, truncated))
80
+ except Exception:
81
+ return _write(_result(False, [_diagnostic("INTERNAL")]))
82
+
83
+
84
+ if __name__ == "__main__":
85
+ raise SystemExit(main())
@@ -23,7 +23,9 @@ from _gitexec import git_executable, root_bound_git_env # noqa: E402
23
23
  import hostapi # noqa: E402 — host seam (ADR-0011): plugin-root resolution
24
24
  import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
25
25
  import _githooks # noqa: E402 — #556: git-hook drop-in registry freshness
26
- from _hooklib import frontmatter_enabled, get_host, set_host, utf8_stdio # noqa: E402
26
+ from _hooklib import ( # noqa: E402
27
+ frontmatter_enabled, get_host, initialized_body_text, set_host, utf8_stdio,
28
+ )
27
29
 
28
30
  HOOK_SCRIPTS = ("session-start.py", "pre-bash.py", "pre-write.py",
29
31
  "pre-edit.py", "post-write-edit.py", "prune-transcript.py",
@@ -298,7 +300,7 @@ def check_repo():
298
300
  if enabled:
299
301
  with open(ctx, encoding="utf-8", errors="replace") as f:
300
302
  body = f.read()
301
- if "<!--INITIALIZED-->" in body:
303
+ if initialized_body_text(body):
302
304
  ok("project is initialized (<!--INITIALIZED--> marker present)")
303
305
  else:
304
306
  warn(f"no <!--INITIALIZED--> marker — startup will route to "
@@ -313,7 +313,7 @@ class Host:
313
313
 
314
314
  name = "claude"
315
315
  adapter_name = "ca"
316
- adapter_version = "2.23.4"
316
+ adapter_version = "2.24.1"
317
317
 
318
318
  # Update-notifier descriptor. Each independently versioned host overrides
319
319
  # these three values in its per-plugin _host.py. Keeping the target,
@@ -12,11 +12,16 @@
12
12
  # Usage:
13
13
  # python init-codearbiter.py [--root PATH] [--stage N]
14
14
  # python init-codearbiter.py --check # report state, create nothing
15
+ # python init-codearbiter.py --check --passive --root PATH # external pre-session read
15
16
  # python init-codearbiter.py --repair-lock-exclusion [--root PATH]
17
+ # python init-codearbiter.py --root PATH --context-request FILE # bounded writer bridge, including initialized refresh
16
18
 
17
19
  import argparse
20
+ import json
18
21
  import subprocess
19
22
  import os
23
+ import re
24
+ import stat
20
25
  import sys
21
26
 
22
27
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -25,6 +30,8 @@ from _taskboardlib import DONE_TASKS # noqa: E402 — shared archive template (
25
30
  import hostapi # noqa: E402 — host seam (ADR-0011)
26
31
  import _hooklib # noqa: E402 — set_host DI seam (#257)
27
32
  import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
33
+ from _activationlib import passive_activation_inventory # noqa: E402
34
+ import _artifactlib # noqa: E402
28
35
 
29
36
  # NOTE: this stub deliberately does NOT contain the initialization sentinel
30
37
  # (an HTML comment wrapping the word INITIALIZED). The SessionStart hook greps
@@ -122,17 +129,132 @@ def project_root(opt):
122
129
  return os.path.abspath(os.getcwd())
123
130
 
124
131
 
132
+ def _regular_scaffold_entry(path, *, directory):
133
+ """Reject aliases and reparse points before reading or creating scaffold state."""
134
+ try:
135
+ info = os.lstat(path)
136
+ except OSError:
137
+ return False
138
+ reparse = getattr(info, "st_file_attributes", 0) & getattr(
139
+ stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
140
+ return not reparse and (stat.S_ISDIR(info.st_mode) if directory
141
+ else stat.S_ISREG(info.st_mode))
142
+
143
+
144
+ def _scaffold_inventory(cad, ctx, requested_stage):
145
+ """Return absent scaffold names only when surviving state is safe to retain."""
146
+ names = ("CONTEXT.md", *FILES)
147
+ if os.path.lexists(cad) and not _regular_scaffold_entry(cad, directory=True):
148
+ raise SystemExit(f"REFUSING: {cad} is not a regular project-state directory.")
149
+ present = {name for name in names if os.path.lexists(os.path.join(cad, name))}
150
+ if not present:
151
+ if os.path.isdir(cad) and os.listdir(cad):
152
+ raise SystemExit(f"REFUSING: {cad} has existing state without a scaffold; inspect it first.")
153
+ return list(names), False
154
+ for name in present:
155
+ path = os.path.join(cad, name)
156
+ if not _regular_scaffold_entry(path, directory=False):
157
+ raise SystemExit(f"REFUSING: {path} is not a regular scaffold file.")
158
+ if "CONTEXT.md" not in present:
159
+ # A missing stub amid content/provenance may be an interrupted native
160
+ # transaction. Only the known scaffold prefix can be resumed here.
161
+ unexpected = set(os.listdir(cad)) - set(FILES)
162
+ if unexpected:
163
+ raise SystemExit(f"REFUSING: {ctx} is absent amid other project state; inspect prior write outcome.")
164
+ else:
165
+ try:
166
+ with open(ctx, encoding="utf-8") as handle:
167
+ body = handle.read()
168
+ except (OSError, UnicodeError) as exc:
169
+ raise SystemExit(f"REFUSING: cannot inspect {ctx}: {exc}") from None
170
+ enabled, malformed = _hooklib.frontmatter_enabled_text(body)
171
+ lines = body.splitlines()
172
+ boundary = next((index for index, line in enumerate(lines[1:], 1)
173
+ if line.strip() == "---"), None)
174
+ stages = ([match.group(1) for line in lines[1:boundary]
175
+ if (match := re.fullmatch(r"\s*stage:\s*([1-9][0-9]*)\s*", line))]
176
+ if boundary is not None else [])
177
+ if (not enabled or malformed or len(stages) != 1 or
178
+ _hooklib.initialized_body_text(body)):
179
+ raise SystemExit(f"REFUSING: {ctx} is invalid or initialized; inspect before recovery.")
180
+ if requested_stage is not None and int(stages[0]) != requested_stage:
181
+ raise SystemExit(f"REFUSING: {ctx} stage differs from --stage; preserve existing context.")
182
+ headers = {"open-tasks.md": "# Open tasks", "done-tasks.md": "# Done tasks",
183
+ "open-questions.md": "# Open questions",
184
+ "overrides.log": "# codeArbiter override log"}
185
+ for name in present & headers.keys():
186
+ path = os.path.join(cad, name)
187
+ try:
188
+ with open(path, encoding="utf-8") as handle:
189
+ valid = handle.readline().rstrip("\r\n").startswith(headers[name])
190
+ except (OSError, UnicodeError):
191
+ valid = False
192
+ if not valid:
193
+ raise SystemExit(f"REFUSING: {path} has an unrecognized header; preserve it for review.")
194
+ if "last-checkpoint" in present:
195
+ try:
196
+ with open(os.path.join(cad, "last-checkpoint"), encoding="utf-8") as handle:
197
+ valid = bool(re.fullmatch(r"[0-9]+\s*", handle.read()))
198
+ except (OSError, UnicodeError):
199
+ valid = False
200
+ if not valid:
201
+ raise SystemExit("REFUSING: last-checkpoint is malformed; preserve it for review.")
202
+ return [name for name in names if name not in present], True
203
+
204
+
205
+ def _create_scaffold_file(path, content, *, newline=None):
206
+ """Create once; a concurrent new file is a conflict, never an overwrite."""
207
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
208
+ flags |= getattr(os, "O_NOFOLLOW", 0)
209
+ try:
210
+ fd = os.open(path, flags, 0o666)
211
+ except FileExistsError:
212
+ raise SystemExit(f"REFUSING: {path} appeared during scaffold recovery.") from None
213
+ with os.fdopen(fd, "w", encoding="utf-8", newline=newline) as handle:
214
+ handle.write(content)
215
+
216
+
125
217
  def main(argv=None):
126
218
  ap = argparse.ArgumentParser(add_help=True)
127
219
  ap.add_argument("--root")
128
220
  ap.add_argument("--stage", type=int)
129
221
  ap.add_argument("--check", action="store_true")
222
+ ap.add_argument("--passive", action="store_true", help=(
223
+ "inspect explicit root before host activation; no Git or network probes"))
224
+ ap.add_argument("--context-request", help=argparse.SUPPRESS)
130
225
  ap.add_argument("--repair-lock-exclusion", action="store_true", help=(
131
226
  "repair only Git-local info/exclude for an existing scaffold; retain the "
132
227
  "task-board OS lock, never rewrite project state (cannot combine with --check/--stage)"))
133
228
  args = ap.parse_args(argv)
229
+ if args.context_request:
230
+ if not args.root or args.stage is not None or args.check or args.passive or args.repair_lock_exclusion:
231
+ ap.error("--context-request requires --root and excludes scaffold flags")
232
+ try:
233
+ root = _artifactlib._trusted_directory(args.root, "UNSAFE_ROOT")
234
+ request = _artifactlib.read_context_preview_request(root, args.context_request)
235
+ operation, payload = _artifactlib.validate_context_writer_request(request)
236
+ client = _artifactlib.context_writer_client(root)
237
+ if operation == "preview":
238
+ from _artifactauthoritylib import arm_context_preview
239
+ result = arm_context_preview(root, client, payload)
240
+ else:
241
+ result = client.call("context-apply", payload)
242
+ except RuntimeError as exc:
243
+ raise SystemExit(f"REFUSING context request: {exc}") from None
244
+ print(json.dumps(result, sort_keys=True))
245
+ return
246
+ if args.passive:
247
+ if not args.check or not args.root or args.stage is not None or args.repair_lock_exclusion:
248
+ ap.error("--passive requires --check and --root; excludes --stage and repair")
249
+ try:
250
+ report = passive_activation_inventory(args.root)
251
+ except (OSError, UnicodeError, ValueError) as exc:
252
+ raise SystemExit(f"REFUSING passive inspection: {exc}") from None
253
+ print(json.dumps(report, sort_keys=True))
254
+ return
134
255
  if args.repair_lock_exclusion and (args.check or args.stage is not None):
135
256
  ap.error("--repair-lock-exclusion cannot combine with --check or --stage")
257
+ requested_stage = args.stage
136
258
  if args.stage is None:
137
259
  args.stage = 1
138
260
 
@@ -149,13 +271,17 @@ def main(argv=None):
149
271
  name = os.path.basename(root.rstrip("/\\")) or "project"
150
272
 
151
273
  if args.check:
274
+ if os.path.lexists(cad) and not _regular_scaffold_entry(cad, directory=True):
275
+ raise SystemExit(f"REFUSING: {cad} is not a regular project-state directory.")
276
+ if os.path.lexists(ctx) and not _regular_scaffold_entry(ctx, directory=False):
277
+ raise SystemExit(f"REFUSING: {ctx} is not a regular scaffold file.")
152
278
  if os.path.exists(ctx):
153
279
  print(f"ALREADY SCAFFOLDED: {ctx} exists.")
154
280
  with open(ctx, encoding="utf-8", errors="replace") as f:
155
281
  text = f.read()
156
- import re
157
- initd = bool(re.search(r"(?m)^\s*<!--\s*INITIALIZED\s*-->\s*$", text))
158
- print("arbiter: " + ("enabled" if re.search(r"(?m)^arbiter:\s*enabled\s*$", text) else "not enabled"))
282
+ initd = _hooklib.initialized_body_text(text)
283
+ enabled, _malformed = _hooklib.frontmatter_enabled_text(text)
284
+ print("arbiter: " + ("enabled" if enabled else "not enabled"))
159
285
  print("initialized body: " + ("yes" if initd else
160
286
  "no (run " + _cc + " or " + _dc + ")"))
161
287
  else:
@@ -164,7 +290,8 @@ def main(argv=None):
164
290
 
165
291
  from _taskexcludelib import ensure_task_lock_excluded, TaskExclusionError
166
292
  if args.repair_lock_exclusion:
167
- if not os.path.isfile(ctx) or os.path.islink(ctx):
293
+ if (not _regular_scaffold_entry(cad, directory=True) or
294
+ not _regular_scaffold_entry(ctx, directory=False)):
168
295
  raise SystemExit("REFUSING: task lock exclusion repair requires an existing scaffold.")
169
296
  try:
170
297
  result = ensure_task_lock_excluded(root, required=True)
@@ -173,7 +300,8 @@ def main(argv=None):
173
300
  print(f"task lock exclusion: {result}")
174
301
  return
175
302
 
176
- if os.path.exists(ctx):
303
+ absent, recovering = _scaffold_inventory(cad, ctx, requested_stage)
304
+ if recovering and not absent:
177
305
  raise SystemExit(
178
306
  f"REFUSING: {ctx} already exists. .codearbiter/ is already scaffolded here. "
179
307
  f"To populate it, run {_cc} or {_dc}; to repair, edit by hand.")
@@ -187,19 +315,16 @@ def main(argv=None):
187
315
 
188
316
  os.makedirs(cad, exist_ok=True)
189
317
  created = []
190
- if not os.path.exists(ctx):
191
- with open(ctx, "w", encoding="utf-8") as f:
192
- f.write(CONTEXT.format(stage=args.stage, name=name,
193
- create_context=_cc, decompose=_dc))
318
+ if "CONTEXT.md" in absent:
319
+ _create_scaffold_file(ctx, CONTEXT.format(stage=args.stage, name=name,
320
+ create_context=_cc, decompose=_dc))
194
321
  created.append("CONTEXT.md")
195
322
  for fname, content in FILES.items():
196
- fp = os.path.join(cad, fname)
197
- if not os.path.exists(fp):
323
+ if fname in absent:
198
324
  # Match taskwrite's LF output for this shared append-only template
199
325
  # on Windows too; other scaffold files retain their existing EOLs.
200
326
  newline = "\n" if fname == "done-tasks.md" else None
201
- with open(fp, "w", encoding="utf-8", newline=newline) as f:
202
- f.write(content)
327
+ _create_scaffold_file(os.path.join(cad, fname), content, newline=newline)
203
328
  created.append(fname)
204
329
 
205
330
  # #161: install the git-level enforcement backstop (pre-commit/pre-push) so
@@ -215,9 +340,12 @@ def main(argv=None):
215
340
  except Exception as e: # noqa: BLE001
216
341
  print(f"git hooks: install skipped ({e})", file=sys.stderr)
217
342
 
218
- print(f"SCAFFOLDED .codearbiter/ at {cad}")
343
+ print(f"{'RECOVERED' if recovering else 'SCAFFOLDED'} .codearbiter/ at {cad}")
219
344
  print("created: " + ", ".join(created))
220
- print(f"arbiter: enabled (stage {args.stage}); CONTEXT.md is a stub (no <!--INITIALIZED--> sentinel).")
345
+ if recovering and "CONTEXT.md" not in created:
346
+ print("arbiter: enabled; existing uninitialized CONTEXT.md and history retained.")
347
+ else:
348
+ print(f"arbiter: enabled (stage {args.stage}); CONTEXT.md is a stub (no <!--INITIALIZED--> sentinel).")
221
349
  print(f"Next: run {_cc} (source exists) or {_dc} (greenfield) to populate.")
222
350
 
223
351
 
@@ -65,6 +65,7 @@ import sys
65
65
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
66
66
  import hostapi # noqa: E402 — host seam (ADR-0011)
67
67
  import _approvallib # noqa: E402 — host-owned artifact approval capture
68
+ import _artifactauthoritylib # noqa: E402 — exact context preview approval capture
68
69
  import _hooklib # noqa: E402
69
70
  import _modelib # noqa: E402 — mode plane core (T-06..T-16, Lane A)
70
71
  import _prerequisitelib # noqa: E402 — host-owned prerequisite capture
@@ -485,6 +486,15 @@ def _handle_user_prompt_submit(payload, host):
485
486
  )
486
487
  if reconciliation:
487
488
  contexts.append(reconciliation)
489
+ context_preview = _artifactauthoritylib.capture_context_preview_from_hook(
490
+ root=root,
491
+ plugin_root=host.plugin_root(),
492
+ prompt=prompt,
493
+ host=host.name,
494
+ session_id=session_id,
495
+ )
496
+ if context_preview:
497
+ contexts.append(context_preview)
488
498
  approval_context = "\n".join(contexts)
489
499
  if not active:
490
500
  if not approval_context:
@@ -513,6 +523,10 @@ def _handle_precompact(payload, host):
513
523
  if not _hooklib.arbiter_active(root):
514
524
  return 0
515
525
  _bump_compaction_generation(_hooklib.marker_root(payload), session_id)
526
+ # Read-time context pointers have their own worktree-local epoch. A
527
+ # surviving read marker cannot prove that a compacted actor still carries
528
+ # those pointers. Keep this separate from the persona generation above.
529
+ _readinjectlib.bump_context_generation(root, session_id)
516
530
  return 0
517
531
 
518
532