@ikon85/agent-workflow-kit 0.15.0 → 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.
@@ -14,17 +14,23 @@ Accepted gap: Bash redirect / tee / cp into `.handoff/` is not covered — the
14
14
  threat model is "skill/agent forgot", not an adversary; handoff writes via Write.
15
15
 
16
16
  Mechanism: self-filters to `.handoff/*.md`; extracts the issue (content anchor
17
- first, filename fallback); delegates ALL coherence to scripts/execute-ready-check.py
18
- (--mode handoff). Deny = exit 2 + stderr (house pattern: enforce-worktree.py,
19
- block-secrets.py). Override: a deliberate `<!-- guard-ack: #<n> r<N> reason:… by-user -->`
20
- in the content. fail-closed once a target is parsed (the checker enforces that);
21
- fail-OPEN when no handoff target is identifiable (not the stale handoff we guard).
17
+ first, filename fallback); delegates graph coherence to
18
+ scripts/execute-ready-check.py (`--mode handoff`) and census state/fingerprint
19
+ evaluation to `scripts/census/index.mjs`. Deny = exit 2 + stderr (house pattern:
20
+ enforce-worktree.py, block-secrets.py). A deliberate
21
+ `<!-- guard-ack: #<n> r<N> reason:… by-user -->` overrides only the graph gate,
22
+ never activated census drift. fail-closed once a target is parsed (the checker
23
+ enforces that); fail-OPEN when no handoff target is identifiable (not the stale
24
+ handoff we guard).
22
25
 
23
26
  Audit log: .claude/logs/drift-guard.log
24
27
  """
25
28
  import importlib.util
26
29
  import json
30
+ import os
27
31
  import re
32
+ import stat
33
+ import subprocess
28
34
  import sys
29
35
  from pathlib import Path
30
36
 
@@ -41,11 +47,163 @@ GUARD_ACK_RE = re.compile(r"<!--\s*guard-ack:\s*#?\d+\s+r\d+\s+reason:.+\bby-use
41
47
 
42
48
  # Load the shared checker (hyphenated filename → importlib). Repo root = parents[2].
43
49
  _CHECKER_PATH = Path(__file__).resolve().parents[2] / "scripts" / "execute-ready-check.py"
50
+ _CENSUS_MODULE_PATH = Path(__file__).resolve().parents[2] / "scripts" / "census" / "index.mjs"
51
+ CENSUS_BRIDGE_TIMEOUT_SECONDS = 15
52
+ CENSUS_PROOF_TIMEOUT_MS = 5_000
53
+
54
+ _CENSUS_SCAN = r"""
55
+ import { readFileSync } from 'node:fs';
56
+ import { spawnSync } from 'node:child_process';
57
+ import { pathToFileURL } from 'node:url';
58
+ const input = JSON.parse(readFileSync(0, 'utf8'));
59
+ const {
60
+ modulePath,
61
+ repoRoot,
62
+ profileJson,
63
+ activeJson,
64
+ localScanners,
65
+ profileLocalScanners,
66
+ localScannersValid,
67
+ activeLocalScanners,
68
+ activeLocalScannersValid,
69
+ proofTimeoutMs,
70
+ } = input;
71
+ const writeResult = process.stdout.write.bind(process.stdout);
72
+ // Repository-local proof modules are allowed to be noisy. Keep their output
73
+ // captured so neither scanner/test output nor accidentally-read content reaches
74
+ // the hook protocol or the CLI status response.
75
+ process.stdout.write = () => true;
76
+ process.stderr.write = () => true;
77
+ const {
78
+ CENSUS_BUILDER_VERSION,
79
+ CENSUS_VERDICTS,
80
+ diffCensus,
81
+ fingerprintCensus,
82
+ resolveCensusState,
83
+ scanCensus,
84
+ } = await import(pathToFileURL(modulePath).href);
85
+ const profile = JSON.parse(profileJson);
86
+ const active = activeJson ? JSON.parse(activeJson) : null;
87
+ const behaviorFamilies = (profile.decisions || []).map(({ family, status }) => ({
88
+ name: family,
89
+ status,
90
+ }));
91
+ const fresh = await scanCensus({
92
+ repoRoot,
93
+ enabled: Boolean(profile.enabled),
94
+ hasActive: active !== null,
95
+ behaviorFamilies,
96
+ });
97
+ const calculated = fingerprintCensus(fresh);
98
+ if (calculated.builder !== fresh.fingerprints.builder
99
+ || calculated.topology !== fresh.fingerprints.topology) {
100
+ throw new Error('census fingerprint API returned inconsistent facts');
101
+ }
102
+ const reasons = [];
103
+ const withProofTimeout = async (operation) => {
104
+ let timer;
105
+ try {
106
+ return await Promise.race([
107
+ operation(),
108
+ new Promise((_, reject) => {
109
+ timer = setTimeout(() => reject(new Error('local proof timed out')), proofTimeoutMs);
110
+ }),
111
+ ]);
112
+ } finally {
113
+ clearTimeout(timer);
114
+ }
115
+ };
116
+ const proofReason = (surface) => typeof surface === 'string' && surface.length > 0
117
+ ? `proof:${surface}`
118
+ : 'proof:profile-history';
119
+ if (active !== null) {
120
+ if (!localScannersValid || !activeLocalScannersValid) {
121
+ reasons.push('proof:profile-history');
122
+ } else if (JSON.stringify(profileLocalScanners) !== JSON.stringify(activeLocalScanners)) {
123
+ const changedSurfaces = new Set([
124
+ ...profileLocalScanners.map(({ surface }) => surface),
125
+ ...activeLocalScanners.map(({ surface }) => surface),
126
+ ]);
127
+ if (changedSurfaces.size === 0) reasons.push('proof:profile-history');
128
+ for (const surface of changedSurfaces) reasons.push(proofReason(surface));
129
+ }
130
+ }
131
+ for (const record of localScanners) {
132
+ const reason = `proof:${record.surface}`;
133
+ if (record.validationError) {
134
+ reasons.push(reason);
135
+ continue;
136
+ }
137
+ const test = spawnSync(process.execPath, ['--test', record.testPath], {
138
+ cwd: repoRoot,
139
+ encoding: 'utf8',
140
+ stdio: 'pipe',
141
+ timeout: proofTimeoutMs,
142
+ });
143
+ if (test.status !== 0 || test.error) {
144
+ reasons.push(reason);
145
+ continue;
146
+ }
147
+ try {
148
+ const scannerModule = await import(`${pathToFileURL(record.modulePath).href}?proof=${Date.now()}`);
149
+ const scanner = scannerModule[record.exportName];
150
+ const result = typeof scanner === 'function'
151
+ ? await withProofTimeout(() => scanner())
152
+ : null;
153
+ if (!Array.isArray(result)
154
+ || !result.every((surface) => typeof surface === 'string')
155
+ || !result.includes(record.surface)) reasons.push(reason);
156
+ } catch {
157
+ reasons.push(reason);
158
+ }
159
+ }
160
+ if (active !== null && active.fingerprints?.builder !== fresh.fingerprints.builder) reasons.push('builder');
161
+ if (active !== null && active.fingerprints?.topology !== fresh.fingerprints.topology) reasons.push('topology');
162
+ const uniqueReasons = [...new Set(reasons)];
163
+ const hasOpen = uniqueReasons.some((reason) => reason.startsWith('proof:'))
164
+ || [...fresh.families.surfaces, ...fresh.families.behaviors]
165
+ .some(({ status }) => status === CENSUS_VERDICTS.open);
166
+ if (hasOpen) uniqueReasons.push('open');
167
+ const delta = active === null ? null : diffCensus(active, fresh);
168
+ const denominatorUnchanged = delta !== null
169
+ && Object.values(delta).every((paths) => paths.length === 0);
170
+ const familiesUnchanged = active !== null
171
+ && JSON.stringify(active.families) === JSON.stringify(fresh.families);
172
+ const mechanicalFalsePositive = uniqueReasons.length === 1
173
+ && uniqueReasons[0] === 'topology'
174
+ && denominatorUnchanged
175
+ && familiesUnchanged;
176
+ const state = resolveCensusState({
177
+ enabled: Boolean(profile.enabled),
178
+ hasActive: active !== null,
179
+ hasOpen: hasOpen || uniqueReasons.includes('builder') || uniqueReasons.includes('topology'),
180
+ });
181
+ writeResult(JSON.stringify({
182
+ builderVersion: CENSUS_BUILDER_VERSION,
183
+ changeBinding: fresh.fingerprints.topology,
184
+ fresh: {
185
+ ...fresh,
186
+ profileReport: {
187
+ decisions: profile.decisions || [],
188
+ localScanners: profile.localScanners,
189
+ overrides: profile.overrides || [],
190
+ },
191
+ state,
192
+ },
193
+ mechanicalFalsePositive,
194
+ reasons: uniqueReasons,
195
+ state,
196
+ }));
197
+ """
44
198
 
45
199
 
46
200
  _CHECKER = None
47
201
 
48
202
 
203
+ class CensusFileError(Exception):
204
+ """A census control file failed the contained-regular-file contract."""
205
+
206
+
49
207
  def _load_checker():
50
208
  """Load + cache the shared checker (module exec is not free — load once per
51
209
  process, not once per call site)."""
@@ -65,6 +223,135 @@ def is_handoff_write(payload: dict) -> bool:
65
223
  return bool(HANDOFF_PATH_RE.search(fp))
66
224
 
67
225
 
226
+ def _git_root(start: Path) -> Path:
227
+ """Resolve the owning repository without treating process cwd as ownership."""
228
+ completed = subprocess.run(
229
+ ["git", "-C", str(start), "rev-parse", "--show-toplevel"],
230
+ capture_output=True,
231
+ check=True,
232
+ text=True,
233
+ timeout=5,
234
+ )
235
+ return Path(completed.stdout.strip()).resolve()
236
+
237
+
238
+ def resolve_census_root_from_cwd(cwd: Path) -> Path:
239
+ """CLI entry points may be invoked from any directory inside the repo."""
240
+ return _git_root(cwd.resolve())
241
+
242
+
243
+ def resolve_handoff_repo_root(payload: dict) -> Path:
244
+ """Return the repository that owns the handoff target.
245
+
246
+ The raw target identifies the claimed repository, while the resolved target
247
+ proves that `.handoff` did not escape it through a symlink. This permits a
248
+ hook launched from another checkout without trusting an arbitrary payload
249
+ path as the census root.
250
+ """
251
+ raw = (payload.get("tool_input") or {}).get("file_path", "")
252
+ if not raw:
253
+ raise ValueError("missing handoff target repository path")
254
+ target = Path(raw)
255
+ if not target.is_absolute():
256
+ target = Path(os.path.abspath(Path.cwd() / target))
257
+ else:
258
+ target = Path(os.path.abspath(target))
259
+ if target.parent.name != ".handoff":
260
+ raise ValueError("handoff target is not rooted in a target repository .handoff directory")
261
+ claimed_root = _git_root(target.parent.parent)
262
+ expected_handoff = claimed_root / ".handoff"
263
+ if target.parent != expected_handoff:
264
+ raise ValueError("handoff target repository does not own the claimed .handoff path")
265
+ resolved_target = target.resolve(strict=False)
266
+ try:
267
+ relative = resolved_target.relative_to(claimed_root)
268
+ except ValueError as error:
269
+ raise ValueError("handoff target repository containment check failed") from error
270
+ if not relative.parts or relative.parts[0] != ".handoff":
271
+ raise ValueError("handoff target repository containment check failed")
272
+ return claimed_root
273
+
274
+
275
+ def _path_entry_exists(path: Path) -> bool:
276
+ """Unlike exists(), count dangling symlinks as present activation state."""
277
+ try:
278
+ path.lstat()
279
+ return True
280
+ except FileNotFoundError:
281
+ return False
282
+
283
+
284
+ def _contained_regular_file(repo_root: Path, local_path) -> Path | None:
285
+ """Resolve a readable repo-local regular file without following a final link."""
286
+ if not isinstance(local_path, str) or not local_path or Path(local_path).is_absolute():
287
+ return None
288
+ lexical_root = Path(os.path.abspath(repo_root))
289
+ candidate = Path(os.path.abspath(lexical_root / local_path))
290
+ try:
291
+ candidate.relative_to(lexical_root)
292
+ except ValueError:
293
+ return None
294
+ try:
295
+ info = candidate.lstat()
296
+ if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode):
297
+ return None
298
+ if not info.st_mode & (stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH):
299
+ return None
300
+ canonical_root = lexical_root.resolve(strict=True)
301
+ canonical = candidate.resolve(strict=True)
302
+ canonical.relative_to(canonical_root)
303
+ except (FileNotFoundError, OSError, RuntimeError, ValueError):
304
+ return None
305
+ return candidate
306
+
307
+
308
+ def _read_census_control(repo_root: Path, local_path: str) -> str:
309
+ target = _contained_regular_file(repo_root, local_path)
310
+ if target is None:
311
+ raise CensusFileError(f"invalid {Path(local_path).name}")
312
+ try:
313
+ return target.read_text(encoding="utf-8")
314
+ except (OSError, UnicodeError) as error:
315
+ raise CensusFileError(f"unreadable {Path(local_path).name}") from error
316
+
317
+
318
+ def _normalize_local_scanners(records) -> tuple[bool, list[dict]]:
319
+ """Return the exact proof-record contract without trusting arbitrary JSON shapes."""
320
+ if not isinstance(records, list):
321
+ return False, []
322
+ normalized = []
323
+ for raw in records:
324
+ if not isinstance(raw, dict):
325
+ return False, normalized
326
+ record = {}
327
+ for key in ("surface", "module", "export", "test"):
328
+ value = raw.get(key)
329
+ if not isinstance(value, str) or not value.strip():
330
+ return False, normalized
331
+ record[key] = value.strip()
332
+ normalized.append(record)
333
+ return True, normalized
334
+
335
+
336
+ def _validated_local_scanners(repo_root: Path, records: list[dict]) -> list[dict]:
337
+ """Convert profile records to proof inputs without exposing unsafe paths."""
338
+ validated = []
339
+ for raw in records:
340
+ surface = raw["surface"]
341
+ module_path = _contained_regular_file(repo_root, raw.get("module"))
342
+ test_path = _contained_regular_file(repo_root, raw.get("test"))
343
+ export_name = raw.get("export")
344
+ valid_export = isinstance(export_name, str) and bool(export_name.strip())
345
+ validated.append({
346
+ "surface": surface,
347
+ "modulePath": str(module_path) if module_path else "",
348
+ "testPath": str(test_path) if test_path else "",
349
+ "exportName": export_name.strip() if valid_export else "",
350
+ "validationError": not (module_path and test_path and valid_export),
351
+ })
352
+ return validated
353
+
354
+
68
355
  def extract_content(payload: dict) -> str:
69
356
  ti = payload.get("tool_input") or {}
70
357
  tool = payload.get("tool_name")
@@ -97,7 +384,7 @@ def run_check(issue: int, intent: str) -> dict:
97
384
  try:
98
385
  checker = _load_checker()
99
386
  return checker.build_and_evaluate(issue, "handoff", intent)
100
- except Exception as e:
387
+ except (Exception, SystemExit) as e:
101
388
  # checker itself unavailable → fail-closed (a target was identified)
102
389
  log(HOOK_NAME, f"checker load/exec failed: {e} → fail-closed")
103
390
  return {"deny_recommended": True, "graph_coherent": False,
@@ -108,23 +395,186 @@ def run_check(issue: int, intent: str) -> dict:
108
395
  def _infer_intent(content: str) -> str:
109
396
  try:
110
397
  return _load_checker().infer_intent(content)
111
- except Exception:
398
+ except (Exception, SystemExit):
112
399
  return "build"
113
400
 
114
401
 
402
+ def scan_census_status(repo_root: Path, profile_json=None, active_json=None,
403
+ proof_timeout_ms=CENSUS_PROOF_TIMEOUT_MS) -> dict:
404
+ """Scan census facts through the shipped JavaScript API, never a hook-local
405
+ copy of its state or fingerprint rules."""
406
+ active_path = repo_root / ".census" / "active.json"
407
+ profile = profile_json if profile_json is not None else _read_census_control(
408
+ repo_root, ".census/profile.json"
409
+ )
410
+ active = active_json
411
+ if active is None:
412
+ active = _read_census_control(repo_root, ".census/active.json") \
413
+ if _path_entry_exists(active_path) else ""
414
+ try:
415
+ profile_body = json.loads(profile)
416
+ except (json.JSONDecodeError, TypeError) as error:
417
+ raise CensusFileError("invalid profile.json") from error
418
+ if not isinstance(profile_body, dict):
419
+ raise CensusFileError("invalid profile.json")
420
+ if not isinstance(proof_timeout_ms, int) or isinstance(proof_timeout_ms, bool):
421
+ raise ValueError("proof timeout must be an integer")
422
+ proof_timeout_ms = max(1, min(
423
+ proof_timeout_ms,
424
+ CENSUS_BRIDGE_TIMEOUT_SECONDS * 1_000 - 1,
425
+ ))
426
+ profile_scanners_valid, profile_scanners = _normalize_local_scanners(
427
+ profile_body.get("localScanners")
428
+ )
429
+ active_scanners_valid = True
430
+ active_scanners = []
431
+ if active:
432
+ try:
433
+ active_body = json.loads(active)
434
+ report = active_body.get("profileReport") if isinstance(active_body, dict) else None
435
+ active_scanners_valid, active_scanners = _normalize_local_scanners(
436
+ report.get("localScanners") if isinstance(report, dict) else None
437
+ )
438
+ except (json.JSONDecodeError, TypeError):
439
+ active_scanners_valid = False
440
+ # Each record has two independently bounded proof phases (test + scanner).
441
+ # Keep their aggregate ceiling below the outer bridge budget as well as
442
+ # making every inner timeout strictly smaller than it.
443
+ proof_timeout_ms = min(
444
+ proof_timeout_ms,
445
+ max(1, (CENSUS_BRIDGE_TIMEOUT_SECONDS * 1_000 - 2_000)
446
+ // max(1, len(profile_scanners) * 2)),
447
+ )
448
+ completed = subprocess.run(
449
+ ["node", "--input-type=module", "-e", _CENSUS_SCAN],
450
+ capture_output=True,
451
+ check=True,
452
+ input=json.dumps({
453
+ "modulePath": str(_CENSUS_MODULE_PATH),
454
+ "repoRoot": str(repo_root),
455
+ "profileJson": profile,
456
+ "activeJson": active,
457
+ "localScanners": _validated_local_scanners(repo_root, profile_scanners),
458
+ "profileLocalScanners": profile_scanners,
459
+ "localScannersValid": profile_scanners_valid,
460
+ "activeLocalScanners": active_scanners,
461
+ "activeLocalScannersValid": active_scanners_valid,
462
+ "proofTimeoutMs": proof_timeout_ms,
463
+ }),
464
+ text=True,
465
+ timeout=CENSUS_BRIDGE_TIMEOUT_SECONDS,
466
+ )
467
+ return json.loads(completed.stdout)
468
+
469
+
470
+ def evaluate_census(repo_root: Path, proof_timeout_ms=CENSUS_PROOF_TIMEOUT_MS) -> dict:
471
+ """Return the activation-aware handoff verdict.
472
+
473
+ Missing/disabled/unactivated/unavailable census remains visible but does not
474
+ gate ordinary work. Only an explicitly enabled, activated census may fail
475
+ closed. Consumer overrides are reported and deliberately never fed into
476
+ scanning, fingerprinting, or state resolution.
477
+ """
478
+ profile_path = repo_root / ".census" / "profile.json"
479
+ active_path = repo_root / ".census" / "active.json"
480
+ profile_present = _path_entry_exists(profile_path)
481
+ active_present = _path_entry_exists(active_path)
482
+ if not profile_present:
483
+ if active_present:
484
+ return {"state": "failed", "block_handoff": False,
485
+ "detail": "active census has no valid profile",
486
+ "reasons": ["profile"], "overrides": [], "override_applied": False}
487
+ return {"state": "no_census", "block_handoff": False, "detail": "manual walk required",
488
+ "reasons": [], "overrides": [], "override_applied": False}
489
+ try:
490
+ profile_json = _read_census_control(repo_root, ".census/profile.json")
491
+ profile = json.loads(profile_json)
492
+ if not isinstance(profile, dict):
493
+ raise ValueError("profile must be an object")
494
+ except (CensusFileError, json.JSONDecodeError, ValueError, TypeError):
495
+ return {"state": "failed", "block_handoff": False,
496
+ "detail": "census profile is invalid or unreadable",
497
+ "reasons": ["profile"], "overrides": [], "override_applied": False}
498
+ try:
499
+ active_json = _read_census_control(repo_root, ".census/active.json") \
500
+ if active_present else ""
501
+ result = scan_census_status(repo_root, profile_json, active_json, proof_timeout_ms)
502
+ except (OSError, subprocess.TimeoutExpired) as error:
503
+ activated = active_present and bool(profile.get("enabled"))
504
+ return {"state": "offline", "block_handoff": activated, "detail": str(error),
505
+ "reasons": [], "overrides": profile.get("overrides", []), "override_applied": False}
506
+ except Exception:
507
+ activated = active_present and bool(profile.get("enabled"))
508
+ return {"state": "failed", "block_handoff": activated,
509
+ "detail": "census scan or active snapshot is invalid",
510
+ "reasons": ["scan"], "overrides": profile.get("overrides", []),
511
+ "override_applied": False}
512
+ state = result["state"]
513
+ activated = active_present
514
+ overrides = profile.get("overrides", [])
515
+ change_binding = result["changeBinding"]
516
+ justified_change_local = any(
517
+ override.get("scope") == "this change"
518
+ and isinstance(override.get("reason"), str)
519
+ and bool(override.get("reason").strip())
520
+ and override.get("topologyFingerprint") == change_binding
521
+ for override in overrides
522
+ if isinstance(override, dict)
523
+ )
524
+ override_applied = result["mechanicalFalsePositive"] and justified_change_local
525
+ return {
526
+ "state": state,
527
+ "block_handoff": activated and state == "refresh_required" and not override_applied,
528
+ "detail": f"builder {result['builderVersion']}",
529
+ "reasons": result["reasons"],
530
+ "overrides": overrides,
531
+ "override_applied": override_applied,
532
+ "change_binding": result["changeBinding"],
533
+ "mechanical_false_positive": result["mechanicalFalsePositive"],
534
+ }
535
+
536
+
537
+ def build_census_block_message(issue: int, result: dict) -> str:
538
+ reasons = ", ".join(result.get("reasons", [])) or "activated census is stale"
539
+ lines = [
540
+ f"CENSUS — Build-Handoff für #{issue} BLOCKED ({result.get('state', 'refresh_required')}):",
541
+ "",
542
+ f" · {reasons}",
543
+ " · run `$census-update` and activate a verified current census",
544
+ ]
545
+ if result.get("overrides"):
546
+ lines += [
547
+ " · change-local overrides remain visible but cannot green real drift",
548
+ ]
549
+ return "\n".join(lines)
550
+
551
+
115
552
  def should_block(payload: dict):
116
553
  """Returns (block: bool, message: str)."""
117
554
  if not is_handoff_write(payload):
118
555
  return False, ""
119
556
  content = extract_content(payload)
120
- if GUARD_ACK_RE.search(content):
121
- log(HOOK_NAME, "guard-ack override present → allow")
122
- return False, ""
123
557
  issue = extract_issue(payload, content)
124
558
  if issue is None:
125
559
  log(HOOK_NAME, "no identifiable issue target → fail-open allow")
126
560
  return False, ""
127
561
  intent = _infer_intent(content)
562
+ try:
563
+ census_root = resolve_handoff_repo_root(payload)
564
+ census = evaluate_census(census_root)
565
+ except Exception as error:
566
+ census = {
567
+ "state": "failed",
568
+ "block_handoff": intent == "build",
569
+ "reasons": [f"target repository unavailable ({error})"],
570
+ "overrides": [],
571
+ }
572
+ log(HOOK_NAME, f"census state={census['state']} reasons={census.get('reasons', [])}")
573
+ if intent == "build" and census.get("block_handoff"):
574
+ return True, build_census_block_message(issue, census)
575
+ if GUARD_ACK_RE.search(content):
576
+ log(HOOK_NAME, "guard-ack override present → allow graph gate only")
577
+ return False, ""
128
578
  result = run_check(issue, intent)
129
579
  if result.get("deny_recommended"):
130
580
  return True, build_block_message(issue, intent, result)
@@ -155,6 +605,17 @@ def build_block_message(issue: int, intent: str, result: dict) -> str:
155
605
 
156
606
 
157
607
  def main() -> int:
608
+ if sys.argv[1:] == ["--census-status"]:
609
+ try:
610
+ root = resolve_census_root_from_cwd(Path.cwd())
611
+ result = evaluate_census(root)
612
+ except Exception as error:
613
+ result = {"state": "failed", "block_handoff": False,
614
+ "detail": f"target repository unavailable ({error})",
615
+ "reasons": ["repository"], "overrides": [],
616
+ "override_applied": False}
617
+ print(json.dumps(result, sort_keys=True))
618
+ return 0
158
619
  try:
159
620
  payload = json.load(sys.stdin)
160
621
  except Exception as e:
@@ -0,0 +1,3 @@
1
+ 2026-07-15T18:45:54 census state=failed reasons=["target repository unavailable (Command '['git', '-C', '/tmp/repo', 'rev-parse', '--show-toplevel']' returned non-zero exit status 128.)"]
2
+ 2026-07-15T18:45:55 census state=failed reasons=['target repository unavailable (handoff target repository containment check failed)']
3
+ 2026-07-15T18:45:59 census state=failed reasons=['scan']
@@ -44,6 +44,19 @@ the matching GitHub release contain the same artifact.
44
44
  resume the transaction through the update API's `resumeFrom` option. Do not
45
45
  copy staged files into the consumer by hand.
46
46
 
47
+ 5. Check the optional project census after the update:
48
+
49
+ ```sh
50
+ python3 .claude/hooks/drift-guard.py --census-status
51
+ ```
52
+
53
+ When the report names a newer census builder or `refresh_required`, advise
54
+ the user to run `$census-update`. The kit updater must never overwrite a
55
+ consumer-owned census, profile, local scanner, decision, or override. A
56
+ missing, disabled, unactivated, or temporarily unavailable census remains a
57
+ visible manual-walk condition and does not invalidate an otherwise verified
58
+ kit update.
59
+
47
60
  ## State contract
48
61
 
49
62
  The update API reports `checking -> preview/awaiting_decision -> staging ->
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: setup-workflow
3
- description: "Scaffolds the project layer the portable workflow skills assume — issue tracker, triage labels, domain-doc layout, GitHub-Projects board field-IDs, spec-self-critique + spec-completeness seeds, workflow overview, and the deploy target. Writes `docs/agents/*`, `docs/conventions/spec-completeness.md`, and the `## Workflow` / `## Agent skills` / `## Prod` blocks in CLAUDE.md/AGENTS.md. Idempotent: a re-run reconciles per file/section and never overwrites filled content. Run once after installing the skills (or `npx <pkg> init`), or when a skill reports missing project-layer context. Adapted from Matt Pocock's `setup-matt-pocock-skills` (MIT)."
3
+ description: "Scaffolds the project layer the portable workflow skills assume — issue tracker, triage labels, domain-doc layout, board field IDs, spec seeds, workflow overview, optional census choice, and deploy target. Writes `docs/agents/*`, `docs/conventions/spec-completeness.md`, and the `## Workflow` / `## Agent skills` / `## Prod` blocks in CLAUDE.md/AGENTS.md. Idempotent: a re-run reconciles per file/section and never overwrites filled content. Run once after installing the skills (or `npx <pkg> init`), or when a skill reports missing project-layer context. Adapted from Matt Pocock's `setup-matt-pocock-skills` (MIT)."
4
4
  disable-model-invocation: true
5
5
  ---
6
6
 
@@ -25,6 +25,7 @@ This is a prompt-driven skill, not a deterministic script. Explore, present what
25
25
  | `## Workflow` in CLAUDE.md **and** AGENTS.md | generic entry-point map seeded from [workflow-overview.md](./workflow-overview.md) (Section F + Write) |
26
26
  | `## Agent skills` + `## Prod` in CLAUDE.md **and** AGENTS.md | one-line pointers + deploy target (Sections C/G + Write) |
27
27
  | `.github/workflows/agent-workflow-kit-update.yml` | optional tested Kit update pull request for GitHub consumers (Section A2) |
28
+ | `docs/agents/census.md` | optional-census choice, paths, state, and safe disable contract seeded from [census.md](./census.md) (Section A3) |
28
29
 
29
30
  ## Idempotency contract — read before writing anything
30
31
 
@@ -54,6 +55,7 @@ Read the current state; don't assume. For every target file, read its first line
54
55
  - `CLAUDE.md` and `AGENTS.md` at the repo root — which exist? Do they already have a `## Workflow` / `## Agent skills` / `## Prod` block?
55
56
  - `CONTEXT.md`, `CONTEXT-MAP.md`, `docs/adr/` — domain-doc layout.
56
57
  - `docs/agents/`, `docs/agents/skills/`, `docs/conventions/` — prior output of this skill.
58
+ - `docs/agents/census.md`, `.census/profile.json`, `.census/active.json` — an existing census choice or consumer-owned census to adopt.
57
59
  - `gh auth status` (if GitHub) — is `gh` authenticated, and with which scopes?
58
60
 
59
61
  ### 2. Section A — Issue tracker
@@ -105,6 +107,34 @@ For GitLab, local, other, or an unknown provider, give only a provider-neutral e
105
107
 
106
108
  **Conditional board-write note (only when the GitHub tracker uses a managed board):** if Section D ends with `board-sync.md` at `mode: github-projects-v2`, add one line to `issue-tracker.md`: *"Board writes (item-add, status/wave/cluster field edits, sub-issue links) go through the board-sync helper, not bare `gh issue create`/`gh project item-*`."* Do **not** add this for GitLab, local, other, or `mode: none`.
107
109
 
110
+ ### 2b. Section A3 — Optional project census
111
+
112
+ > A project census is an optional, consumer-owned map that counts product
113
+ > surfaces and lists behavior families separately. It can make later plans and
114
+ > handoffs more complete, but setup cannot honestly claim coverage before the
115
+ > repository has been scanned and verified.
116
+
117
+ Read [census.md](./census.md) in full before presenting the choice. If
118
+ `docs/agents/census.md` already records `yes`, `later`, or `no`, adopt that
119
+ choice and do not prompt again on an ordinary setup rerun. Also adopt an
120
+ existing, explicitly documented census path. If no choice exists, ask in plain
121
+ language: *"Should setup prepare the optional project census now?"* Offer
122
+ exactly:
123
+
124
+ - **Yes** — create or adopt the project layer and minimal enabled profile, run
125
+ only the shipped self-test, and report the honest `bootstrap` / "not yet
126
+ meaningful" state. Do not scan, activate, or install a hook or gate.
127
+ - **Later** — record a deferral. Create no census profile, hook, or gate; a
128
+ later explicit `census-update` invocation may activate without setup.
129
+ - **No** — record the opt-out as `disabled`. Create no census profile, hook, or
130
+ gate and do not prompt again unless the user explicitly changes the choice.
131
+
132
+ Use the complete `missing / yes / later / no / existing / explicit-enable /
133
+ disable` matrix in the seed. A later explicit `census-update` invocation may
134
+ activate without rerunning setup. Disable enforcement first, but retain every
135
+ consumer-owned profile, scanner, test, and active snapshot unless the user
136
+ separately approves deletion. Repeated runs are no-ops after reconciliation.
137
+
108
138
  ### 3. Section B — Triage labels
109
139
 
110
140
  > When `triage` processes an incoming issue it applies labels (or your tracker's equivalent). It needs strings you've actually configured, or it creates duplicates.
@@ -182,6 +212,14 @@ Seed `docs/agents/code-review.md` from [code-review.md](./code-review.md) — th
182
212
 
183
213
  For each `docs/...` file: obey the idempotency contract (the "Idempotency contract" section). Prepend the sentinel with the resolved `state` (and `mode` for board-sync).
184
214
 
215
+ For `docs/agents/census.md`, seed [census.md](./census.md), prepend the normal
216
+ sentinel, and record the selected choice directly below it as
217
+ `<!-- census: choice=<yes|later|no> -->`. On adoption, also record the discovered
218
+ repository-relative profile and active-snapshot paths. Never overwrite a
219
+ pre-existing consumer-owned census file. `yes`, `later`, `no`, and an adopted
220
+ existing census are terminal for ordinary setup reruns. Only an explicit user
221
+ request changes a recorded choice.
222
+
185
223
  For the **`## Workflow`**, **`## Agent skills`**, and **`## Prod`** blocks, reconcile per section in **both** CLAUDE.md and AGENTS.md that exist:
186
224
 
187
225
  - If **both** files exist → write/update the block in **both** (keep them coherent — Codex is a first-class surface).