@ikon85/agent-workflow-kit 0.16.0 → 0.16.2

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.
@@ -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 ->
@@ -76,6 +76,24 @@ gh project item-list 1 --owner <owner> --limit 500 --format json # check targe
76
76
  - `<!-- prd-source-id: <id> -->` + `<!-- prd-content-fp: <hash> -->` (see step 3).
77
77
  5. **Mode B label normalization:** if the reused issue carries wrong/multiple `type:*`, missing `priority:*`, `ready-for-agent`, or `needs-info` → **normalize onto the PRD contract** (exactly one `type:*`, one `priority:*`, no `ready-for-agent`/`needs-info`). Exception per the discriminator (§2): `type:cluster` always, or Wave **without** `wave-stub` → **no** normalization, **hard stop** (step 2); a `wave-stub`-labeled Stufe-1p target normalizes normally — it is a valid Mode B target.
78
78
 
79
+ ### Census freshness before a cross-cutting lock
80
+
81
+ When the PRD claims completeness across several product surfaces, run
82
+ `python3 .claude/hooks/drift-guard.py --census-status` before the board write.
83
+ An activated census reporting `refresh_required` means the cross-cutting PRD
84
+ must not be locked: run `$census-update`, resolve every open surface, and retry.
85
+ `disabled`, `no_census`, `bootstrap`, or `offline` stays visible and fail-open;
86
+ perform and report the existing manual walk instead. This gate does not apply
87
+ to an orthogonal, surface-local PRD.
88
+
89
+ A justified change-local override may acknowledge only a proven mechanical
90
+ false positive. It must carry `scope: "this change"`, a non-empty `reason`, and
91
+ the exact `topologyFingerprint` reported as `change_binding` by the current
92
+ status check. That binding is valid only for those freshly scanned topology
93
+ facts; a later topology change makes the persisted override stale. The
94
+ override never changes scanner facts, builder/topology fingerprints, open
95
+ verdicts, or state resolution, and therefore cannot green real drift.
96
+
79
97
  ## 4b. Program-PRD body (mode=program)
80
98
 
81
99
  mode=program writes the Program-PRD per `.claude/skills/to-prd/PROGRAM-PRD-FORMAT.md` instead of the `<prd-template>` below — a **parallel** grammar for the program altitude, not a replacement (a Feature-PRD keeps using `<prd-template>` unchanged). It carries: the `## Scope` chapter with stable Scope-Item IDs (`S1`, `S2`, …), the machine-parsable `## Wellenplan` table (`Welle | Status | Name | Phase | Slices | Gate | covers`), the `## Phasen-Gates` checklist (only when the program uses phases), the `## Slices` per-slice detail chapter (one `####` section per planned slice, per `SLICE-METADATA-FORMAT.md`'s grammar), and the Abbruch-Konvention. `scripts/program_graph.py` (`board-sync.py validate-graph`) is the parser — to-prd writes the shape, it does not itself validate the graph (that is `to-waves`'s job, run once the Program-PRD exists).
@@ -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:
@@ -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 ->
@@ -76,6 +76,24 @@ gh project item-list 1 --owner <owner> --limit 500 --format json # check targe
76
76
  - `<!-- prd-source-id: <id> -->` + `<!-- prd-content-fp: <hash> -->` (see step 3).
77
77
  5. **Mode B label normalization:** if the reused issue carries wrong/multiple `type:*`, missing `priority:*`, `ready-for-agent`, or `needs-info` → **normalize onto the PRD contract** (exactly one `type:*`, one `priority:*`, no `ready-for-agent`/`needs-info`). Exception per the discriminator (§2): `type:cluster` always, or Wave **without** `wave-stub` → **no** normalization, **hard stop** (step 2); a `wave-stub`-labeled Stufe-1p target normalizes normally — it is a valid Mode B target.
78
78
 
79
+ ### Census freshness before a cross-cutting lock
80
+
81
+ When the PRD claims completeness across several product surfaces, run
82
+ `python3 .claude/hooks/drift-guard.py --census-status` before the board write.
83
+ An activated census reporting `refresh_required` means the cross-cutting PRD
84
+ must not be locked: run `$census-update`, resolve every open surface, and retry.
85
+ `disabled`, `no_census`, `bootstrap`, or `offline` stays visible and fail-open;
86
+ perform and report the existing manual walk instead. This gate does not apply
87
+ to an orthogonal, surface-local PRD.
88
+
89
+ A justified change-local override may acknowledge only a proven mechanical
90
+ false positive. It must carry `scope: "this change"`, a non-empty `reason`, and
91
+ the exact `topologyFingerprint` reported as `change_binding` by the current
92
+ status check. That binding is valid only for those freshly scanned topology
93
+ facts; a later topology change makes the persisted override stale. The
94
+ override never changes scanner facts, builder/topology fingerprints, open
95
+ verdicts, or state resolution, and therefore cannot green real drift.
96
+
79
97
  ## 4b. Program-PRD body (mode=program)
80
98
 
81
99
  mode=program writes the Program-PRD per `.claude/skills/to-prd/PROGRAM-PRD-FORMAT.md` instead of the `<prd-template>` below — a **parallel** grammar for the program altitude, not a replacement (a Feature-PRD keeps using `<prd-template>` unchanged). It carries: the `## Scope` chapter with stable Scope-Item IDs (`S1`, `S2`, …), the machine-parsable `## Wellenplan` table (`Welle | Status | Name | Phase | Slices | Gate | covers`), the `## Phasen-Gates` checklist (only when the program uses phases), the `## Slices` per-slice detail chapter (one `####` section per planned slice, per `SLICE-METADATA-FORMAT.md`'s grammar), and the Abbruch-Konvention. `scripts/program_graph.py` (`board-sync.py validate-graph`) is the parser — to-prd writes the shape, it does not itself validate the graph (that is `to-waves`'s job, run once the Program-PRD exists).
package/README.md CHANGED
@@ -332,6 +332,18 @@ still reference. Flags: `--force` (overwrite pre-existing files on `init`),
332
332
 
333
333
  ## Release notes
334
334
 
335
+ ### 0.16.2
336
+
337
+ - Metadata-only release.
338
+
339
+ ### 0.16.1
340
+
341
+ - changed: `.agents/skills/kit-update/SKILL.md`
342
+ - changed: `.agents/skills/to-prd/SKILL.md`
343
+ - changed: `.claude/hooks/drift-guard.py`
344
+ - changed: `.claude/skills/kit-update/SKILL.md`
345
+ - changed: `.claude/skills/to-prd/SKILL.md`
346
+
335
347
  ### 0.16.0
336
348
 
337
349
  - added: `.agents/skills/setup-workflow/census.md`