@ngockhoale/ukit 3.3.2 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/manifests/engineConformance.yaml +17 -1
  3. package/manifests/hostCapabilities.yaml +68 -1
  4. package/manifests/platform.full.yaml +138 -0
  5. package/manifests/platform.user.yaml +255 -3
  6. package/package.json +1 -1
  7. package/scripts/bench/subagent-orchestrator-corpus.mjs +275 -0
  8. package/scripts/bench/subagent-orchestrator-eval.mjs +565 -0
  9. package/scripts/probe/codex-capability-probe.mjs +169 -0
  10. package/src/cli/commands/doctor.js +168 -0
  11. package/src/cli/commands/indexTools.js +7 -0
  12. package/src/cli/commands/metrics.js +66 -2
  13. package/src/cli/commands/playbook.js +4 -4
  14. package/src/cli/commands/vm.js +49 -8
  15. package/src/core/agentRuntime/adapters.js +328 -27
  16. package/src/core/agentRuntime/artifacts.js +89 -0
  17. package/src/core/agentRuntime/context.js +345 -1
  18. package/src/core/agentRuntime/contract.js +296 -0
  19. package/src/core/agentRuntime/eventStore.js +176 -0
  20. package/src/core/agentRuntime/shadowRun.js +481 -5
  21. package/src/core/agentRuntime/telemetry.js +121 -0
  22. package/src/core/observability/emit/lifecycle.js +68 -1
  23. package/src/core/observability/emit/sessionBoot.js +393 -0
  24. package/src/core/observability/privacy/allowlist.js +10 -1
  25. package/src/core/observability/schema/registry.js +10 -0
  26. package/src/core/runtimeConfig.js +133 -0
  27. package/src/core/userPlaybooks.js +18 -3
  28. package/src/decision/registry.js +19 -0
  29. package/src/diagnostics/feedbackEvents.js +7 -4
  30. package/src/diagnostics/routeOutcomes.js +51 -6
  31. package/src/diagnostics/skillAccuracy.js +43 -3
  32. package/src/index/crossCheckMatrix.js +412 -0
  33. package/src/index/fixLoopEscalation.js +453 -0
  34. package/src/index/playbookRegistry.js +691 -0
  35. package/src/index/reviewPolicy.js +368 -0
  36. package/src/index/routeResolver.js +915 -0
  37. package/src/index/sessionHistoryExtractor.js +359 -0
  38. package/src/index/taskRouting.js +764 -581
  39. package/src/index/tierSelection.js +308 -0
  40. package/src/index/verificationMap.js +404 -0
  41. package/template_project/.claude/hooks/observability-emit.mjs +14 -0
  42. package/template_project/.claude/hooks/record-execution.mjs +19 -1
  43. package/template_project/.claude/hooks/skill-router.sh +691 -25
  44. package/template_project/.claude/hooks/verification-guard.sh +230 -1
  45. package/template_project/.claude/settings.json +2 -2
  46. package/template_project/.claude/ukit/index/cross-check-matrix.mjs +415 -0
  47. package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +456 -0
  48. package/template_project/.claude/ukit/index/playbook-registry.mjs +690 -0
  49. package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +20 -2
  50. package/template_project/.claude/ukit/index/review-policy.mjs +376 -0
  51. package/template_project/.claude/ukit/index/route-resolver.mjs +1059 -0
  52. package/template_project/.claude/ukit/index/route-task.mjs +1253 -846
  53. package/template_project/.claude/ukit/index/session-history-extractor.mjs +362 -0
  54. package/template_project/.claude/ukit/index/tier-selection.mjs +309 -0
  55. package/template_project/.claude/ukit/index/verification-map.mjs +403 -0
  56. package/template_project/.claude/ukit/index/worktree-sweep.mjs +195 -0
  57. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +789 -11
  58. package/template_project/.claude/ukit/runtime/observability-emit.mjs +1102 -0
  59. package/template_project/.claude/ukit/runtime/reinject-context.mjs +9 -1
  60. package/template_project/.claude/ukit/runtime/resumable-run.mjs +149 -5
  61. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +323 -6
  62. package/template_project/.codex/README.md +8 -0
  63. package/template_project/.omp/hooks/pre/ukit-bridge.js +8 -1
  64. package/template_project/ukit/README.md +1 -1
  65. package/template_project/ukit/storage/config.json +20 -0
  66. package/template_user/playbooks/architecture-decision.md +28 -0
  67. package/template_user/playbooks/autonomous-run.md +43 -0
  68. package/template_user/playbooks/autopilot-full.md +59 -0
  69. package/template_user/playbooks/autopilot-stack.md +54 -0
  70. package/template_user/playbooks/babysit.md +39 -0
  71. package/template_user/playbooks/bug-fix.md +3 -1
  72. package/template_user/playbooks/{issue-implementation.md → feature-implementation.md} +4 -2
  73. package/template_user/playbooks/hillclimb.md +44 -0
  74. package/template_user/playbooks/investigation.md +21 -0
  75. package/template_user/playbooks/migration.md +21 -0
  76. package/template_user/playbooks/open-pr.md +48 -0
  77. package/template_user/playbooks/orchestrate.md +45 -0
  78. package/template_user/playbooks/performance.md +33 -0
  79. package/template_user/playbooks/prototype.md +28 -0
  80. package/template_user/playbooks/refactor.md +19 -0
  81. package/template_user/playbooks/release.md +28 -0
  82. package/template_user/playbooks/runtime-forensics.md +23 -0
  83. package/template_user/playbooks/session-pickup.md +31 -0
  84. package/template_user/playbooks/shipping.md +53 -0
  85. package/template_user/playbooks/skill-evaluation.md +48 -0
  86. package/template_user/playbooks/small-feature.md +20 -0
  87. package/template_user/playbooks/verification-map.json +153 -0
  88. package/template_user/playbooks/verification.md +22 -0
  89. package/template_user/playbooks/worktree-cleanup.md +37 -0
@@ -0,0 +1,403 @@
1
+ // verification-map.mjs — verification-map table + recipe resolution
2
+ // (C85 TASK-014, BL-014 / SPEC FR-002 / ARCH §Verification Map).
3
+ //
4
+ // Installed mirror of src/index/verificationMap.js. The map is DATA:
5
+ // template_user/playbooks/verification-map.json carries one
6
+ // launch/doctor/drive/expected/evidence/cleanup row per playbook work group
7
+ // (verbatim from ARCH §Verification Map) plus a per-artifact-class recipe table
8
+ // (ui|cli|docs|config). This module is the single query surface for both shell
9
+ // consumers (verification-guard.sh imports this file and resolves recipes
10
+ // without parsing prose) and node consumers (stop-coordinator.mjs completion
11
+ // policy, ukit doctor's recipe-vs-package.json check).
12
+ //
13
+ // Resolution order mirrors the playbook registry:
14
+ // <projectRoot>/.ukit/playbooks/verification-map.json
15
+ // <homeDir>/.ukit/playbooks/verification-map.json
16
+ // packaged template_user/playbooks/verification-map.json (builtin)
17
+ // The first file that parses AND validates wins; a malformed higher tier cannot
18
+ // shadow a valid lower tier. Every miss/malformed case fails closed to
19
+ // map=null — consumers fall back to pre-map behavior, never fabricated checks.
20
+ //
21
+ // Parity is locked by tests/consistency/verificationMapParity.test.js.
22
+
23
+ import fs from 'node:fs/promises';
24
+ import fsSync from 'node:fs';
25
+ import os from 'node:os';
26
+ import path from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ const MAP_FILENAME = 'verification-map.json';
30
+ // The mirror is four dirs below the project root — '../../../..' lands on
31
+ // the checkout root so the repo resolves the same template_user tree; on a
32
+ // real installed project the walk lands outside the project (no
33
+ // template_user dir), ENOENT skips the tier, and the ~/.ukit seed is the
34
+ // effective builtin. The canonical twin (src/index/) walks two dirs.
35
+ // Install-aware callers may pass `builtinPath` explicitly.
36
+ const PACKAGE_BUILTIN_MAP_PATH = path.resolve(
37
+ path.dirname(fileURLToPath(import.meta.url)),
38
+ '..', '..', '..', '..',
39
+ 'template_user', 'playbooks', MAP_FILENAME,
40
+ );
41
+
42
+ export const ARTIFACT_CLASSES = Object.freeze(['ui', 'cli', 'docs', 'config']);
43
+ const ARTIFACT_CLASS_SET = new Set(ARTIFACT_CLASSES);
44
+
45
+ const PLAYBOOK_ROW_KEYS = ['launch', 'doctor', 'drive', 'expected', 'evidence', 'cleanup'];
46
+
47
+ // ─── Artifact classification ────────────────────────────────────────────────
48
+ // The artifact class comes from the files a change actually touched — the same
49
+ // git --porcelain path list verification-guard.sh already computes. Priority is
50
+ // deliberate: any UI file makes the diff `ui` (the ARCH falsifying bar blocks an
51
+ // unverified UI change), explicit CLI surfaces next, and docs/config only when
52
+ // the whole diff is docs/config. A diff with no signal classifies `null` —
53
+ // `recipeForArtifact` then returns null and every consumer keeps the pre-map
54
+ // behavior (SPEC §10: unknown class → no recipe → unchanged).
55
+
56
+ const UI_PATH_RE = /(^|\/)(components?|pages?|views?|screens?|layouts?|widgets?)\//i;
57
+ const UI_EXT_RE = /\.(jsx|tsx|vue|svelte|css|scss|sass|less|html|styl)$/i;
58
+ const DOCS_PATH_RE = /(^|\/)(docs?|wiki)\//i;
59
+ const DOCS_EXT_RE = /\.(md|mdx|rst|adoc|txt)$/i;
60
+ const CONFIG_BASENAME_RE = /^(?:[^/]*\.(json|ya?ml|toml|ini|env|cfg|conf)|\.?[A-Za-z][\w.-]*(rc|file|config|ignore|rc\.js|rc\.cjs|rc\.mjs|rc\.json|rc\.ya?ml)|dockerfile|compose[^/]*\.ya?ml|makefile|justfile)$/i;
61
+ const CLI_PATH_RE = /(^|\/)(bin|cli|cmd|commands)\//i;
62
+ const CLI_EXT_RE = /\.(sh|bash|zsh|ps1|bat|cmd)$/i;
63
+ const LOCKFILE_RE = /(^|\/)(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|composer\.lock|gemfile\.lock|poetry\.lock|cargo\.lock|go\.sum)$/i;
64
+ // Runtime-produced files are never the artifact being verified: UKit internal
65
+ // storage (.ukit/**) and the router's own state files appear in every real
66
+ // diff, and counting them would poison the whole-set docs/config rules.
67
+ const INTERNAL_STATE_RE = /(^|\/)\.ukit\/|(^|\/)(?:skill-router-state|route-cache|verification-progress|decisions)\.json$|(^|\/)decisions\.tsv$/i;
68
+ /**
69
+ * Classify a change's artifact class from the set of paths it touched.
70
+ * `changedPaths` are repo-relative paths (git --porcelain entries already have
71
+ * their status prefix stripped by the caller). Returns one of ARTIFACT_CLASSES
72
+ * or null for "no classifiable signal" (pre-map behavior everywhere downstream).
73
+ */
74
+
75
+ // `git status --porcelain` C-quotes paths containing spaces/non-ASCII
76
+ // ("src/My Widget.jsx") — the trailing quote must go before the class regexes
77
+ // run or the entry silently misclassifies to null and the recipe lane skips
78
+ // (fail-open). `-z` callers never produce quotes; this is a defensive net for
79
+ // the porcelain form. Octal escapes (\303\244) are not valid JSON — the
80
+ // fallback keeps the inner text, still classified by extension/dir, never
81
+ // dropped.
82
+ function unquotePorcelainPath(entry) {
83
+ if (entry.length >= 2 && entry.startsWith('"') && entry.endsWith('"')) {
84
+ const inner = entry.slice(1, -1);
85
+ try {
86
+ const parsed = JSON.parse(entry);
87
+ if (typeof parsed === 'string') return parsed;
88
+ } catch { /* octal escapes are not valid JSON — keep the inner text */ }
89
+ return inner;
90
+ }
91
+ return entry;
92
+ }
93
+
94
+ export function classifyArtifactClass(changedPaths = []) {
95
+ const paths = (Array.isArray(changedPaths) ? changedPaths : [])
96
+ .map((entry) => String(entry || '').trim())
97
+ .filter(Boolean)
98
+ // Rename entries carry "old -> new"; the destination is the changed artifact.
99
+ .map((entry) => entry.split(' -> ').pop())
100
+ .map(unquotePorcelainPath)
101
+ .filter((entry) => !INTERNAL_STATE_RE.test(entry));
102
+ if (paths.length === 0) return null;
103
+ // Lockfile-only diffs carry no verification recipe — they are dependency
104
+ // bookkeeping, verified by the next real change that runs the project.
105
+ if (paths.every((entry) => LOCKFILE_RE.test(entry))) return null;
106
+ if (paths.some((entry) => UI_PATH_RE.test(entry) || UI_EXT_RE.test(entry))) return 'ui';
107
+ if (paths.some((entry) => CLI_PATH_RE.test(entry) || CLI_EXT_RE.test(entry))) return 'cli';
108
+ if (paths.every((entry) => DOCS_PATH_RE.test(entry) || DOCS_EXT_RE.test(entry))) return 'docs';
109
+ if (paths.every((entry) => CONFIG_BASENAME_RE.test(path.basename(entry)))) return 'config';
110
+ return null;
111
+ }
112
+
113
+ // ─── Map loading ────────────────────────────────────────────────────────────
114
+
115
+ export function verificationMapCandidatePaths({ projectRoot = null, homeDir = null, builtinPath = null } = {}) {
116
+ const candidates = [];
117
+ if (projectRoot) {
118
+ candidates.push({ path: path.join(projectRoot, '.ukit', 'playbooks', MAP_FILENAME), source: 'project' });
119
+ }
120
+ if (homeDir !== null) {
121
+ const base = homeDir || os.homedir();
122
+ candidates.push({ path: path.join(base, '.ukit', 'playbooks', MAP_FILENAME), source: 'user' });
123
+ }
124
+ candidates.push({ path: builtinPath ?? PACKAGE_BUILTIN_MAP_PATH, source: 'builtin' });
125
+ return candidates;
126
+ }
127
+
128
+ function isPlainObject(value) {
129
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
130
+ }
131
+
132
+ /**
133
+ * Shape check. A valid map needs `version: 1`, at least one playbook row
134
+ * carrying every required recipe column, and an `artifactClasses` table whose
135
+ * rows are objects. Returns { ok, reason } — `reason` is a bounded code the
136
+ // doctor check prints verbatim (never the raw parse text on the report line).
137
+ */
138
+ export function validateVerificationMap(map) {
139
+ if (!isPlainObject(map)) return { ok: false, reason: 'not-an-object' };
140
+ if (map.version !== 1) return { ok: false, reason: 'unsupported-version' };
141
+ if (!isPlainObject(map.playbooks) || Object.keys(map.playbooks).length === 0) {
142
+ return { ok: false, reason: 'missing-playbooks' };
143
+ }
144
+ for (const [id, row] of Object.entries(map.playbooks)) {
145
+ if (!isPlainObject(row)) return { ok: false, reason: `playbook-row-not-object:${id}` };
146
+ for (const key of PLAYBOOK_ROW_KEYS) {
147
+ if (key === 'evidence') {
148
+ if (!Array.isArray(row.evidence) || row.evidence.length === 0
149
+ || !row.evidence.every((entry) => typeof entry === 'string' && entry.trim())) {
150
+ return { ok: false, reason: `playbook-row-missing-evidence:${id}` };
151
+ }
152
+ } else if (typeof row[key] !== 'string' || !row[key].trim()) {
153
+ return { ok: false, reason: `playbook-row-missing-key:${id}.${key}` };
154
+ }
155
+ }
156
+ }
157
+ if (!isPlainObject(map.artifactClasses)) return { ok: false, reason: 'missing-artifact-classes' };
158
+ for (const [name, row] of Object.entries(map.artifactClasses)) {
159
+ if (!ARTIFACT_CLASS_SET.has(name)) return { ok: false, reason: `unknown-artifact-class:${name}` };
160
+ if (!isPlainObject(row)) return { ok: false, reason: `artifact-row-not-object:${name}` };
161
+ if (row.evidenceRequired !== undefined
162
+ && (!Array.isArray(row.evidenceRequired)
163
+ || !row.evidenceRequired.every((entry) => typeof entry === 'string' && entry.trim()))) {
164
+ return { ok: false, reason: `artifact-row-bad-evidence:${name}` };
165
+ }
166
+ }
167
+ return { ok: true, reason: null };
168
+ }
169
+
170
+ /**
171
+ * Inspect (never throws): walk the candidate tiers, return the first valid map
172
+ * plus its provenance. On total failure `map` is null and `reason` says why —
173
+ * `no-map` (nothing readable at any tier) or a validation reason from the
174
+ * last readable-but-invalid candidate. Consumers treat map===null as
175
+ // "pre-map behavior", the fail-closed contract of ARCH §Failure Modes.
176
+ */
177
+ export async function inspectVerificationMap({ projectRoot = null, homeDir = null, builtinPath = null } = {}) {
178
+ let lastInvalidReason = 'no-map';
179
+ for (const candidate of verificationMapCandidatePaths({ projectRoot, homeDir, builtinPath })) {
180
+ let raw;
181
+ try {
182
+ raw = await fs.readFile(candidate.path, 'utf8');
183
+ } catch {
184
+ continue;
185
+ }
186
+ let parsed;
187
+ try {
188
+ parsed = JSON.parse(raw);
189
+ } catch {
190
+ lastInvalidReason = `invalid-json:${candidate.source}`;
191
+ continue;
192
+ }
193
+ const verdict = validateVerificationMap(parsed);
194
+ if (!verdict.ok) {
195
+ lastInvalidReason = `${verdict.reason}@${candidate.source}`;
196
+ continue;
197
+ }
198
+ return { map: parsed, path: candidate.path, source: candidate.source, reason: null };
199
+ }
200
+ return { map: null, path: null, source: null, reason: lastInvalidReason };
201
+ }
202
+
203
+ /**
204
+ * loadVerificationMap(root?) → VerificationMap|null.
205
+ * `root` is the project root (project-tier map lookup); pass an options object
206
+ * `{projectRoot, homeDir, builtinPath}` to control every tier. Fails closed:
207
+ * missing/malformed map → null.
208
+ */
209
+ export async function loadVerificationMap(rootOrOptions = null) {
210
+ const options = isPlainObject(rootOrOptions)
211
+ ? rootOrOptions
212
+ : { projectRoot: rootOrOptions || null };
213
+ const { map } = await inspectVerificationMap(options);
214
+ return map;
215
+ }
216
+
217
+ // ─── Recipe resolution ──────────────────────────────────────────────────────
218
+
219
+ function normalizeArtifactClass(artifactClass) {
220
+ const normalized = String(artifactClass || '').trim().toLowerCase();
221
+ return ARTIFACT_CLASS_SET.has(normalized) ? normalized : null;
222
+ }
223
+
224
+ /**
225
+ * recipeForArtifact({playbookId, artifactClass, map?}) → recipe | null.
226
+ *
227
+ * The resolved recipe is the artifact-class row merged with the routed
228
+ * playbook row (for observability — the class row is what gates):
229
+ * { playbookId, artifactClass, check, harness?, fallback?, evidenceRequired[],
230
+ * playbook: {launch,doctor,drive,expected,evidence[],cleanup}|null }
231
+ *
232
+ * - unknown/missing `artifactClass` → null (pre-map behavior, SPEC §10);
233
+ * - `map` omitted → loaded on demand (project tier under `projectRoot`);
234
+ * - map===null / missing class row → null;
235
+ * - unknown/absent `playbookId` → `playbook: null`, the class row still resolves
236
+ * (recipes key on the artifact, not the route).
237
+ */
238
+ export async function recipeForArtifact({
239
+ playbookId = null,
240
+ artifactClass = null,
241
+ map: mapOption,
242
+ projectRoot = null,
243
+ } = {}) {
244
+ const cls = normalizeArtifactClass(artifactClass);
245
+ if (!cls) return null;
246
+ const map = mapOption !== undefined ? mapOption : await loadVerificationMap(projectRoot);
247
+ if (!isPlainObject(map)) return null;
248
+ const classRow = map.artifactClasses?.[cls];
249
+ if (!isPlainObject(classRow)) return null;
250
+
251
+ const playbookRow = (() => {
252
+ const id = String(playbookId || '').trim();
253
+ if (!id) return null;
254
+ const row = map.playbooks?.[id];
255
+ if (!isPlainObject(row)) return null;
256
+ const shaped = {};
257
+ for (const key of PLAYBOOK_ROW_KEYS) {
258
+ shaped[key] = key === 'evidence'
259
+ ? (Array.isArray(row.evidence) ? [...row.evidence] : [])
260
+ : (typeof row[key] === 'string' ? row[key] : '');
261
+ }
262
+ return shaped;
263
+ })();
264
+
265
+ const harness = isPlainObject(classRow.harness) && typeof classRow.harness.packageScript === 'string'
266
+ ? { packageScript: classRow.harness.packageScript }
267
+ : null;
268
+
269
+ return {
270
+ playbookId: playbookRow ? String(playbookId).trim() : null,
271
+ artifactClass: cls,
272
+ name: typeof classRow.name === 'string' ? classRow.name : cls,
273
+ check: typeof classRow.check === 'string' && classRow.check.trim() ? classRow.check : null,
274
+ harness,
275
+ fallback: typeof classRow.fallback === 'string' && classRow.fallback.trim() ? classRow.fallback.trim() : null,
276
+ evidenceRequired: Array.isArray(classRow.evidenceRequired)
277
+ ? classRow.evidenceRequired.filter((entry) => typeof entry === 'string' && entry.trim())
278
+ : [],
279
+ playbook: playbookRow,
280
+ };
281
+ }
282
+
283
+ // ─── Receipt satisfaction ───────────────────────────────────────────────────
284
+ // Shared by the stop gate (execution-ledger.mjs requires evidence) and the
285
+ // guard's advisory findings — the ledger carries the same semantics pinned by
286
+ // tests/consistency/receiptSatisfactionParity.test.js (runtime/ cannot import
287
+ // index/ — partial installs break — so parity is enforced by test, not a
288
+ // shared import). A required class token may carry `|` alternatives
289
+ // (`render-observation|io-case` — the ARCH evidence cell verbatim): any ONE
290
+ // alternative satisfies it. A class is satisfied when the ledger carries a
291
+ // receipt whose attested `evidence.class` matches an alternative
292
+ // (UKIT_EVIDENCE=class=…;surface=… — direct proof the guard may not have
293
+ // observed), or when the playbook-finding bank records the class — whole
294
+ // token or one alternative — as status 'satisfied'. Findings are latest-wins:
295
+ // ledger.playbookFindings (latest per class, banked outside the evictable
296
+ // receipt window) governs — a later 'missing' finding revokes an earlier
297
+ // 'satisfied' one, and a satisfied class survives receipt eviction via the
298
+ // bank. A failed attestation never satisfies — a failed run that happened to
299
+ // attest a class is not proof of the class.
300
+
301
+ export function receiptEvidenceSatisfied(requiredClass, ledger = {}) {
302
+ const token = String(requiredClass || '').trim();
303
+ const alternatives = token
304
+ .split('|')
305
+ .map((entry) => entry.trim())
306
+ .filter(Boolean);
307
+ if (alternatives.length === 0) return false;
308
+ const matchesClass = (value) => {
309
+ const cls = typeof value === 'string' ? value.trim() : '';
310
+ return cls === token || alternatives.includes(cls);
311
+ };
312
+ // The banked latest-per-class status governs the receipt window; several
313
+ // banked keys can match one anyOf token, so the newest entry wins. A
314
+ // playbook-finding receipt folds into the bank regardless of its success
315
+ // flag — the receipt scan below mirrors that.
316
+ let bankedTs = -1;
317
+ let bankedStatus = null;
318
+ const bank = ledger?.playbookFindings;
319
+ if (bank && typeof bank === 'object') {
320
+ for (const [key, entry] of Object.entries(bank)) {
321
+ if (!matchesClass(key)) continue;
322
+ const ts = typeof entry?.ts === 'number' ? entry.ts : 0;
323
+ if (ts >= bankedTs) {
324
+ bankedTs = ts;
325
+ bankedStatus = typeof entry?.status === 'string' ? entry.status : null;
326
+ }
327
+ }
328
+ }
329
+ const receipts = Array.isArray(ledger?.receipts) ? ledger.receipts : [];
330
+ let attested = false;
331
+ let scannedStatus = null;
332
+ for (const receipt of receipts) {
333
+ if (!receipt || typeof receipt !== 'object') continue;
334
+ if (receipt.kind === 'playbook-finding') {
335
+ if (matchesClass(receipt.class) && typeof receipt.status === 'string') {
336
+ scannedStatus = receipt.status; // chronological — last write wins
337
+ }
338
+ continue;
339
+ }
340
+ if (receipt.success === false) continue;
341
+ const cls = receipt?.evidence?.class;
342
+ if (typeof cls === 'string' && alternatives.includes(cls.trim())) attested = true;
343
+ }
344
+ if (attested) return true;
345
+ const findingStatus = bankedTs >= 0 ? bankedStatus : scannedStatus;
346
+ return findingStatus === 'satisfied';
347
+ }
348
+
349
+ // ─── Doctor recipe check ────────────────────────────────────────────────────
350
+ /**
351
+ * doctorRecipeCheck({map, packageJsonScripts}) → { checked, missing[], mapAbsent }.
352
+ *
353
+ * ARCH §Failure Modes "verification recipe demands absent harness": a recipe
354
+ * whose named package.json script is absent degrades to a capability-negotiated
355
+ * FALLBACK RECEIPT — the returned `fallback` text is the receipt spec to print,
356
+ * never a fabricated command. `map===null` (absent/malformed) reports
357
+ * mapAbsent with zero missing entries — a broken map is itself a doctor warning
358
+ // (the caller composes the row), not silent data.
359
+ */
360
+ export function doctorRecipeCheck({ map = null, packageJsonScripts = {} } = {}) {
361
+ if (!isPlainObject(map)) {
362
+ return { checked: 0, missing: [], mapAbsent: true };
363
+ }
364
+ const scripts = isPlainObject(packageJsonScripts) ? packageJsonScripts : {};
365
+ const missing = [];
366
+ let checked = 0;
367
+ for (const [className, row] of Object.entries(map.artifactClasses || {})) {
368
+ const script = row?.harness?.packageScript;
369
+ if (typeof script !== 'string' || !script.trim()) continue;
370
+ checked += 1;
371
+ if (!Object.hasOwn(scripts, script)) {
372
+ missing.push({
373
+ artifactClass: className,
374
+ script,
375
+ check: typeof row.check === 'string' ? row.check : null,
376
+ fallback: typeof row.fallback === 'string' ? row.fallback : null,
377
+ });
378
+ }
379
+ }
380
+ return { checked, missing, mapAbsent: false };
381
+ }
382
+
383
+ // Sync layer for hook runtimes that evaluate inside a deadline (the guard's
384
+ // git-porcelain path is already synchronous there). Same fail-closed contract.
385
+ export function loadVerificationMapSync(rootOrOptions = null) {
386
+ const options = isPlainObject(rootOrOptions)
387
+ ? rootOrOptions
388
+ : { projectRoot: rootOrOptions || null };
389
+ const { projectRoot = null, homeDir = null, builtinPath = null } = options;
390
+ for (const candidate of verificationMapCandidatePaths({ projectRoot, homeDir, builtinPath })) {
391
+ let raw;
392
+ try {
393
+ raw = fsSync.readFileSync(candidate.path, 'utf8');
394
+ } catch {
395
+ continue;
396
+ }
397
+ try {
398
+ const parsed = JSON.parse(raw);
399
+ if (validateVerificationMap(parsed).ok) return parsed;
400
+ } catch {}
401
+ }
402
+ return null;
403
+ }
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * worktree-sweep.mjs — safety-gated worktree cleanup (C88 TASK-C88-007,
4
+ * CENSUS PB-U22, SPEC FR-007).
5
+ *
6
+ * Enumerate → classify → gate → remove only classified-safe entries. Dry-run
7
+ * is the default and mutates nothing; `--apply` runs `git worktree remove`
8
+ * exclusively on entries classified `safe`. Never a blanket sweep — every
9
+ * skipped entry keeps a named verdict.
10
+ *
11
+ * Classification (in priority order, first match wins):
12
+ * safe — prunable/stale worktree (missing dir or unborn branch) that is
13
+ * clean, OR branch fully merged into the default branch with zero
14
+ * commits ahead, no uncommitted work, no active run
15
+ * skip-locked — porcelain `locked` flag (a human locked it)
16
+ * skip-detached-head — detached HEAD (nothing merges into anything)
17
+ * skip-dirty — `git -C <wt> status --porcelain` non-empty or
18
+ * unreadable (unknown state is never removable)
19
+ * skip-active — resumable-run records present (.ukit/storage/runs/)
20
+ * OR docs/AI_HANDOFF/RUN.md phase not done/blocked,
21
+ * OR the branch has commits not merged into default
22
+ *
23
+ * CLI: node worktree-sweep.mjs [--apply] [--root <dir>]
24
+ * exit 0 on a successful report/apply (even when nothing is removable);
25
+ * non-zero only on invocation error (bad flag, not-a-repo, git spawn
26
+ * failure). A failed `worktree remove` is reported per-entry, not fatal.
27
+ */
28
+
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import { spawnSync } from 'node:child_process';
32
+
33
+ // Same discipline as provision-worktree.mjs (BUG-C21-08): every git call
34
+ // carries a bounded timeout — a wedged git must never block forever.
35
+ const GIT_CALL_TIMEOUT_MS = (() => {
36
+ const raw = Number(process.env.UKIT_SWEEP_GIT_TIMEOUT_MS);
37
+ return Number.isFinite(raw) && raw > 0 ? raw : 15000;
38
+ })();
39
+
40
+ function fail(msg) {
41
+ process.stderr.write(`worktree-sweep: ${msg}\n`);
42
+ process.exit(1);
43
+ }
44
+
45
+ function git(root, args) {
46
+ const res = spawnSync('git', ['-C', root, ...args], {
47
+ encoding: 'utf8',
48
+ timeout: GIT_CALL_TIMEOUT_MS,
49
+ killSignal: 'SIGKILL',
50
+ });
51
+ if (res.error) {
52
+ fail(`git ${args[0]} failed to run: ${res.error.message ?? res.error}`);
53
+ }
54
+ return res;
55
+ }
56
+
57
+ // --- args ------------------------------------------------------------------
58
+ const args = process.argv.slice(2);
59
+ let apply = false;
60
+ let root = null;
61
+ for (let i = 0; i < args.length; i += 1) {
62
+ const a = args[i];
63
+ if (a === '--apply') apply = true;
64
+ else if (a === '--root') { root = args[++i]; if (root === undefined) fail('missing value for --root'); }
65
+ else fail(`unknown flag: ${a}`);
66
+ }
67
+ root = path.resolve(root ?? process.cwd());
68
+
69
+ // --- enumerate --------------------------------------------------------------
70
+ const inside = git(root, ['rev-parse', '--is-inside-work-tree']);
71
+ if (inside.status !== 0 || !/^true\b/.test(inside.stdout.trim())) {
72
+ fail(`not a git repository: ${root}`);
73
+ }
74
+
75
+ const list = git(root, ['worktree', 'list', '--porcelain']);
76
+ if (list.status !== 0) fail(`git worktree list failed: ${list.stderr.trim()}`);
77
+
78
+ // Parse porcelain records: `worktree <path>` starts a block; `HEAD`, `branch`,
79
+ // `locked`, `prunable`, `detached`, `bare` follow.
80
+ const worktrees = [];
81
+ let cur = null;
82
+ for (const line of list.stdout.split('\n')) {
83
+ if (line.startsWith('worktree ')) {
84
+ if (cur) worktrees.push(cur);
85
+ cur = { path: line.slice('worktree '.length), branch: null, detached: false, locked: false, prunable: false, bare: false };
86
+ } else if (cur) {
87
+ if (line.startsWith('branch ')) cur.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '');
88
+ else if (line === 'detached') cur.detached = true;
89
+ else if (line.startsWith('locked')) cur.locked = true;
90
+ else if (line.startsWith('prunable')) cur.prunable = true;
91
+ else if (line === 'bare') cur.bare = true;
92
+ }
93
+ }
94
+ if (cur) worktrees.push(cur);
95
+
96
+ // The main worktree is always the first record — it is the checkout running
97
+ // this sweep and is never a removal candidate.
98
+ const main = worktrees.shift();
99
+ const candidates = worktrees;
100
+
101
+ // Default branch: prefer remote HEAD, then local main/master, then the main
102
+ // worktree's own checked-out branch.
103
+ function resolveDefaultBranch() {
104
+ const symref = git(root, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']);
105
+ if (symref.status === 0 && symref.stdout.trim()) {
106
+ return symref.stdout.trim().replace(/^origin\//, '');
107
+ }
108
+ for (const name of ['main', 'master']) {
109
+ if (git(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`]).status === 0) return name;
110
+ }
111
+ return main && main.branch ? main.branch : null;
112
+ }
113
+ const defaultBranch = resolveDefaultBranch();
114
+ const mergedSet = new Set();
115
+ if (defaultBranch) {
116
+ const merged = git(root, ['branch', '--merged', defaultBranch, '--format=%(refname:short)']);
117
+ if (merged.status === 0) {
118
+ for (const b of merged.stdout.split('\n').map((s) => s.trim()).filter(Boolean)) mergedSet.add(b);
119
+ }
120
+ }
121
+
122
+ // --- classify ---------------------------------------------------------------
123
+ function isDirty(wtPath) {
124
+ const res = git(wtPath, ['status', '--porcelain']);
125
+ // Unreadable status → treat as dirty: unknown state is never removable.
126
+ if (res.status !== 0) return true;
127
+ return res.stdout.trim().length > 0;
128
+ }
129
+
130
+ function hasActiveRun(wtPath) {
131
+ // A suspended/in-flight resumable record (any .ukit/storage/runs/*.json)
132
+ // marks the worktree as carrying an active run.
133
+ try {
134
+ const runsDir = path.join(wtPath, '.ukit', 'storage', 'runs');
135
+ if (fs.statSync(runsDir).isDirectory()
136
+ && fs.readdirSync(runsDir).some((f) => f.endsWith('.json'))) {
137
+ return true;
138
+ }
139
+ } catch { /* absent */ }
140
+ // An AI_HANDOFF cursor whose phase is not done/blocked marks an in-flight
141
+ // handoff run in that worktree.
142
+ try {
143
+ const raw = fs.readFileSync(path.join(wtPath, 'docs', 'AI_HANDOFF', 'RUN.md'), 'utf8');
144
+ const m = raw.match(/^Phase:\s*(\S+)/m);
145
+ if (m && m[1] !== 'done' && m[1] !== 'blocked') return true;
146
+ } catch { /* no cursor */ }
147
+ return false;
148
+ }
149
+
150
+ function classify(wt) {
151
+ if (wt.locked) return 'skip-locked';
152
+ if (wt.prunable || !fs.existsSync(wt.path)) {
153
+ // Stale: the checkout directory is already gone — clean by definition.
154
+ return 'safe';
155
+ }
156
+ if (wt.detached || !wt.branch) return 'skip-detached-head';
157
+ if (isDirty(wt.path)) return 'skip-dirty';
158
+ if (hasActiveRun(wt.path)) return 'skip-active';
159
+ const branchExists = git(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${wt.branch}`]).status === 0;
160
+ if (!branchExists) return 'safe'; // stale: branch gone/unborn, and clean
161
+ if (mergedSet.has(wt.branch)) {
162
+ // `git branch --merged` lists tip-merged branches; zero commits ahead is
163
+ // implied — a merged tip IS zero-ahead. Double-check cheaply.
164
+ const ahead = git(root, ['rev-list', '--count', `${defaultBranch}..${wt.branch}`]);
165
+ if (ahead.status === 0 && Number(ahead.stdout.trim()) === 0) return 'safe';
166
+ }
167
+ return 'skip-active'; // branch carries unmerged work — a run may resume it
168
+ }
169
+
170
+ // --- report + apply ---------------------------------------------------------
171
+ const results = candidates.map((wt) => ({ ...wt, verdict: classify(wt) }));
172
+
173
+ const mode = apply ? 'apply' : 'dry-run';
174
+ console.log(`worktree-sweep (${mode}) — root: ${root}`);
175
+ console.log(`default branch: ${defaultBranch ?? '(unresolved)'}`);
176
+ if (results.length === 0) {
177
+ console.log('no removable worktrees: nothing to do');
178
+ }
179
+ for (const r of results) {
180
+ let line = `${r.path} branch=${r.branch ?? '(none)'} verdict=${r.verdict}`;
181
+ if (apply && r.verdict === 'safe') {
182
+ const rm = spawnSync('git', ['-C', root, 'worktree', 'remove', r.path], {
183
+ encoding: 'utf8',
184
+ timeout: GIT_CALL_TIMEOUT_MS,
185
+ killSignal: 'SIGKILL',
186
+ });
187
+ if (rm.status === 0) line += ' removed';
188
+ else line += ` remove-failed: ${(rm.stderr || rm.error?.message || '').trim() || 'unknown error'}`;
189
+ }
190
+ console.log(line);
191
+ }
192
+ if (!apply && results.some((r) => r.verdict === 'safe')) {
193
+ console.log('dry-run: no removal performed — re-run with --apply to remove safe entries');
194
+ }
195
+ process.exit(0);