@ngockhoale/ukit 3.3.3 → 3.4.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/CHANGELOG.md +44 -0
- package/manifests/engineConformance.yaml +17 -1
- package/manifests/hostCapabilities.yaml +68 -1
- package/manifests/platform.full.yaml +138 -0
- package/manifests/platform.user.yaml +255 -3
- package/package.json +1 -1
- package/scripts/bench/subagent-orchestrator-corpus.mjs +275 -0
- package/scripts/bench/subagent-orchestrator-eval.mjs +565 -0
- package/scripts/probe/codex-capability-probe.mjs +169 -0
- package/src/cli/commands/doctor.js +168 -0
- package/src/cli/commands/indexTools.js +7 -0
- package/src/cli/commands/metrics.js +66 -2
- package/src/cli/commands/playbook.js +4 -4
- package/src/cli/commands/vm.js +49 -8
- package/src/core/agentRuntime/adapters.js +328 -27
- package/src/core/agentRuntime/artifacts.js +89 -0
- package/src/core/agentRuntime/context.js +345 -1
- package/src/core/agentRuntime/contract.js +296 -0
- package/src/core/agentRuntime/eventStore.js +176 -0
- package/src/core/agentRuntime/shadowRun.js +481 -5
- package/src/core/agentRuntime/telemetry.js +121 -0
- package/src/core/observability/emit/lifecycle.js +68 -1
- package/src/core/observability/emit/sessionBoot.js +393 -0
- package/src/core/observability/privacy/allowlist.js +10 -1
- package/src/core/observability/schema/registry.js +10 -0
- package/src/core/runtimeConfig.js +133 -0
- package/src/core/userPlaybooks.js +18 -3
- package/src/decision/registry.js +19 -0
- package/src/diagnostics/feedbackEvents.js +7 -4
- package/src/diagnostics/routeOutcomes.js +51 -6
- package/src/diagnostics/skillAccuracy.js +43 -3
- package/src/index/crossCheckMatrix.js +412 -0
- package/src/index/fixLoopEscalation.js +453 -0
- package/src/index/playbookRegistry.js +691 -0
- package/src/index/reviewPolicy.js +368 -0
- package/src/index/routeResolver.js +915 -0
- package/src/index/sessionHistoryExtractor.js +359 -0
- package/src/index/taskRouting.js +764 -581
- package/src/index/tierSelection.js +308 -0
- package/src/index/verificationMap.js +404 -0
- package/template_project/.claude/hooks/observability-emit.mjs +14 -0
- package/template_project/.claude/hooks/record-execution.mjs +19 -1
- package/template_project/.claude/hooks/skill-router.sh +691 -25
- package/template_project/.claude/hooks/verification-guard.sh +230 -1
- package/template_project/.claude/settings.json +2 -2
- package/template_project/.claude/ukit/index/cross-check-matrix.mjs +415 -0
- package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +456 -0
- package/template_project/.claude/ukit/index/playbook-registry.mjs +690 -0
- package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +20 -2
- package/template_project/.claude/ukit/index/review-policy.mjs +376 -0
- package/template_project/.claude/ukit/index/route-resolver.mjs +1059 -0
- package/template_project/.claude/ukit/index/route-task.mjs +1253 -846
- package/template_project/.claude/ukit/index/session-history-extractor.mjs +362 -0
- package/template_project/.claude/ukit/index/tier-selection.mjs +309 -0
- package/template_project/.claude/ukit/index/verification-map.mjs +403 -0
- package/template_project/.claude/ukit/index/worktree-sweep.mjs +195 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +789 -11
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +1102 -0
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +9 -1
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +149 -5
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +323 -6
- package/template_project/.codex/README.md +8 -0
- package/template_project/.omp/hooks/pre/ukit-bridge.js +8 -1
- package/template_project/ukit/README.md +1 -1
- package/template_project/ukit/storage/config.json +20 -0
- package/template_user/playbooks/architecture-decision.md +28 -0
- package/template_user/playbooks/autonomous-run.md +43 -0
- package/template_user/playbooks/autopilot-full.md +59 -0
- package/template_user/playbooks/autopilot-stack.md +54 -0
- package/template_user/playbooks/babysit.md +39 -0
- package/template_user/playbooks/bug-fix.md +3 -1
- package/template_user/playbooks/{issue-implementation.md → feature-implementation.md} +4 -2
- package/template_user/playbooks/hillclimb.md +44 -0
- package/template_user/playbooks/investigation.md +21 -0
- package/template_user/playbooks/migration.md +21 -0
- package/template_user/playbooks/open-pr.md +48 -0
- package/template_user/playbooks/orchestrate.md +45 -0
- package/template_user/playbooks/performance.md +33 -0
- package/template_user/playbooks/prototype.md +28 -0
- package/template_user/playbooks/refactor.md +19 -0
- package/template_user/playbooks/release.md +28 -0
- package/template_user/playbooks/runtime-forensics.md +23 -0
- package/template_user/playbooks/session-pickup.md +31 -0
- package/template_user/playbooks/shipping.md +53 -0
- package/template_user/playbooks/skill-evaluation.md +48 -0
- package/template_user/playbooks/small-feature.md +20 -0
- package/template_user/playbooks/verification-map.json +153 -0
- package/template_user/playbooks/verification.md +22 -0
- 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);
|