@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.
- package/.agents/skills/kit-update/SKILL.md +13 -0
- package/.agents/skills/to-prd/SKILL.md +18 -0
- package/.claude/hooks/drift-guard.py +471 -10
- package/.claude/skills/kit-update/SKILL.md +13 -0
- package/.claude/skills/to-prd/SKILL.md +18 -0
- package/README.md +12 -0
- package/agent-workflow-kit.package.json +6 -6
- package/package.json +2 -1
- package/scripts/build-kit.test.mjs +30 -14
- package/scripts/test_census_backstop.py +626 -0
|
@@ -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
|
|
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:
|
|
@@ -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`
|