@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.
- package/.agents/skills/kit-update/SKILL.md +13 -0
- package/.agents/skills/setup-workflow/SKILL.md +39 -1
- package/.agents/skills/setup-workflow/census.md +83 -0
- package/.agents/skills/to-prd/SKILL.md +18 -0
- package/.claude/hooks/drift-guard.py +471 -10
- package/.claude/logs/drift-guard.log +3 -0
- package/.claude/skills/kit-update/SKILL.md +13 -0
- package/.claude/skills/setup-workflow/SKILL.md +39 -1
- package/.claude/skills/setup-workflow/census.md +83 -0
- package/.claude/skills/to-prd/SKILL.md +18 -0
- package/README.md +15 -0
- package/agent-workflow-kit.package.json +26 -8
- package/package.json +1 -1
- package/scripts/test_census_backstop.py +626 -0
- package/scripts/test_skill_setup_workflow_seeds.py +316 -0
|
@@ -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
|
|
18
|
-
(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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,
|
|
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).
|