@ngockhoale/ukit 3.4.67 → 3.4.68

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 (62) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/manifests/documentation.yaml +33 -0
  3. package/manifests/instructionRules.yaml +10 -0
  4. package/manifests/platform.full.yaml +67 -0
  5. package/package.json +1 -1
  6. package/src/core/applyPlan.js +111 -2
  7. package/src/core/buildPlan.js +3 -1
  8. package/src/core/diffPlan.js +49 -1
  9. package/src/core/executionContracts.js +49 -8
  10. package/src/core/metadata.js +45 -0
  11. package/src/core/runInstallPipeline.js +30 -2
  12. package/src/core/uninstall.js +106 -2
  13. package/src/manifest/validateManifest.js +25 -1
  14. package/template_project/.claude/agents/code-reviewer.md +22 -9
  15. package/template_project/.claude/agents/handoff-planner.md +97 -8
  16. package/template_project/.claude/commands/ukit/handoff-create.md +114 -19
  17. package/template_project/.claude/commands/ukit/handoff-fullstack.md +247 -79
  18. package/template_project/.claude/commands/ukit/handoff-implement.md +75 -40
  19. package/template_project/.claude/commands/ukit/handoff-review.md +14 -10
  20. package/template_project/.claude/hooks/auto-allow-bash.sh +73 -12
  21. package/template_project/.claude/hooks/auto-prune-bash.sh +10 -0
  22. package/template_project/.claude/hooks/delegation-gate.mjs +21 -0
  23. package/template_project/.claude/hooks/handoff-model-guard.sh +22 -6
  24. package/template_project/.claude/hooks/handoff-resume.sh +141 -1
  25. package/template_project/.claude/hooks/record-execution.mjs +140 -9
  26. package/template_project/.claude/hooks/skill-router.sh +173 -8
  27. package/template_project/.claude/settings.json +2 -2
  28. package/template_project/.claude/skills/advisor-plan/REFERENCE.md +10 -0
  29. package/template_project/.claude/skills/advisor-plan/SKILL.md +2 -2
  30. package/template_project/.claude/ukit/index/allocate-task-worktree.mjs +15 -0
  31. package/template_project/.claude/ukit/index/amend-guard.mjs +116 -0
  32. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
  33. package/template_project/.claude/ukit/index/land-worktree.mjs +243 -21
  34. package/template_project/.claude/ukit/index/plan-count.mjs +115 -0
  35. package/template_project/.claude/ukit/index/ready-gate.mjs +170 -0
  36. package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +293 -4
  37. package/template_project/.claude/ukit/index/route-resolver.mjs +48 -8
  38. package/template_project/.claude/ukit/index/route-task.mjs +180 -11
  39. package/template_project/.claude/ukit/index/source-snapshot.mjs +172 -0
  40. package/template_project/.claude/ukit/index/task-budget-validator.mjs +109 -3
  41. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +20 -3
  42. package/template_project/.claude/ukit/runtime/handoff-task-scope.mjs +205 -0
  43. package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
  44. package/template_project/.claude/ukit/runtime/plan-dispatch.mjs +32 -5
  45. package/template_project/.claude/ukit/runtime/resumable-run.mjs +310 -21
  46. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +405 -105
  47. package/template_project/.claude/ukit/runtime/task-record-reconciliation.mjs +46 -0
  48. package/template_project/.claude/ukit/runtime/task-watchdog.mjs +28 -2
  49. package/template_project/.claude/ukit/runtime/worktree-allocation.mjs +75 -0
  50. package/template_project/.codex/README.md +25 -0
  51. package/template_project/.omp/RULES.md +22 -22
  52. package/template_project/.omp/agents/code-reviewer.md +22 -9
  53. package/template_project/.omp/agents/handoff-planner.md +102 -12
  54. package/template_project/.omp/hooks/pre/ukit-bridge.js +1 -1
  55. package/template_project/AGENTS.md +5 -1
  56. package/template_project/CLAUDE.md +1 -1
  57. package/template_project/docs/AI_HANDOFF/RULES.md +8 -0
  58. package/template_project/docs/AI_HANDOFF/tasks/_TEMPLATE.md +8 -1
  59. package/template_project/instructions/core.md +5 -1
  60. package/template_project/instructions/layout.yaml +2 -0
  61. package/template_project/instructions/overlays/omp-rules.md +22 -22
  62. package/template_project/ukit/storage/config.json +1 -1
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ // plan-count.mjs — CR-11: an owner-stated plan count is delivered as that many real plan
3
+ // units, never as group labels inside one PLAN.md.
4
+ //
5
+ // A "real plan unit" is a directory carrying its own PLAN.md **and** INDEX.md, where both
6
+ // files are non-empty regular files with a recognisable markdown heading (`# …`):
7
+ // - the audited root itself (canonical `docs/AI_HANDOFF/` or a queued root) counts once
8
+ // when it has both files;
9
+ // - each `queued/<slug>/` under it with both files counts once. Units are deduplicated by
10
+ // realpath, so a symlinked alias of an already-counted directory is not a second unit.
11
+ // A queued dir with only some of the files (or files that are empty / headingless) is
12
+ // reported as incomplete and never satisfies the count.
13
+ //
14
+ // Usage:
15
+ // node .claude/ukit/index/plan-count.mjs <handoff-root> <expected-count>
16
+ //
17
+ // Output:
18
+ // `PLAN_COUNT: ok (<found>/<expected>)` or
19
+ // `plan count: <found> real plan units found, owner stated <expected>` plus one
20
+ // `plan unit incomplete -> <dir>` line per queued dir that has only some of the files.
21
+ // Exit 0 = satisfied, 1 = count not met, 2 = usage.
22
+
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import process from 'node:process';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ function isDirectRun() {
29
+ try {
30
+ return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ const HEADING = /^#{1,6}\s+\S/m;
37
+
38
+ // A recognisable planning file: regular (not a symlink), non-empty, carries a heading.
39
+ function isRealPlanFile(file) {
40
+ try {
41
+ const st = fs.lstatSync(file);
42
+ if (!st.isFile() || st.size === 0) return false;
43
+ return HEADING.test(fs.readFileSync(file, 'utf8'));
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ function hasAnyPlanFile(dir) {
50
+ return fs.existsSync(path.join(dir, 'PLAN.md')) || fs.existsSync(path.join(dir, 'INDEX.md'));
51
+ }
52
+
53
+ function isPlanUnit(dir) {
54
+ return isRealPlanFile(path.join(dir, 'PLAN.md')) && isRealPlanFile(path.join(dir, 'INDEX.md'));
55
+ }
56
+
57
+ function main() {
58
+ const root = process.argv[2];
59
+ const expectedRaw = process.argv[3];
60
+ const expected = Number(expectedRaw);
61
+ if (!root || !Number.isInteger(expected) || expected < 1) {
62
+ console.log('PLAN_COUNT: usage error — expected `<root> <positive-integer>`');
63
+ process.exit(2);
64
+ }
65
+
66
+ const units = [];
67
+ const seen = new Set();
68
+ const incomplete = [];
69
+ const addUnit = (dir) => {
70
+ let real;
71
+ try {
72
+ real = fs.realpathSync(dir);
73
+ } catch {
74
+ return;
75
+ }
76
+ if (seen.has(real)) return;
77
+ seen.add(real);
78
+ units.push(dir);
79
+ };
80
+
81
+ if (isPlanUnit(root)) addUnit(root);
82
+
83
+ let slugs;
84
+ try {
85
+ slugs = fs.readdirSync(path.join(root, 'queued'));
86
+ } catch {
87
+ slugs = [];
88
+ }
89
+ for (const slug of slugs.sort()) {
90
+ const dir = path.join(root, 'queued', slug);
91
+ let stat;
92
+ try {
93
+ stat = fs.statSync(dir); // follows symlinks: a symlinked unit dir is still a directory
94
+ } catch {
95
+ continue;
96
+ }
97
+ if (!stat.isDirectory()) continue;
98
+ if (isPlanUnit(dir)) addUnit(dir);
99
+ else if (hasAnyPlanFile(dir)) incomplete.push(dir);
100
+ }
101
+
102
+ for (const dir of incomplete) {
103
+ console.log(`plan unit incomplete -> ${dir} (a real unit needs its own non-empty PLAN.md and INDEX.md with a heading)`);
104
+ }
105
+ if (units.length < expected) {
106
+ console.log(`plan count: ${units.length} real plan units found, owner stated ${expected}`);
107
+ process.exit(1);
108
+ }
109
+ console.log(`PLAN_COUNT: ok (${units.length}/${expected})`);
110
+ process.exit(0);
111
+ }
112
+
113
+ if (isDirectRun()) {
114
+ main();
115
+ }
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ // ready-gate.mjs — CR-06: `ready` is a verdict, verified from disk, never from memory.
3
+ //
4
+ // Verifies that every task currently marked `ready` (task file `- Status:` line and/or the
5
+ // INDEX.md row) has BOTH gates recorded on disk:
6
+ // 1. a tool-written `Validator: ok <YYYY-MM-DD>` (or `Validator: dismissed <YYYY-MM-DD> — reason`)
7
+ // line inside the task's `## Discussion` section — a verdict held only in session
8
+ // memory does not count (compaction-safe);
9
+ // 2. `PLAN_REVIEW: Approved` in the root's PLAN.md.
10
+ // It also flags index/task status mismatches (one side flipped, the other did not).
11
+ //
12
+ // Usage:
13
+ // node .claude/ukit/index/ready-gate.mjs [handoff-root] (default: docs/AI_HANDOFF)
14
+ //
15
+ // Output:
16
+ // `READY_GATE: ok` when every ready task is evidenced;
17
+ // otherwise one named `tasks not ready: <gate> -> <TASK-ID>` / `index/task mismatch -> <TASK-ID>`
18
+ // line per problem. Exit 0 = gates pass, exit 1 = gate failure (the flip step must stop),
19
+ // exit 2 = usage error.
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import process from 'node:process';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ function isDirectRun() {
27
+ try {
28
+ return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ const READY_STATUSES = new Set(['ready']);
35
+
36
+ function readFileSafe(file) {
37
+ try {
38
+ return fs.readFileSync(file, 'utf8');
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function fileStatus(markdown) {
45
+ const m = /^-\s*Status:\s*`?([a-z_]+)`?/im.exec(String(markdown || ''));
46
+ return m ? m[1].toLowerCase() : null;
47
+ }
48
+
49
+ function discussionSection(markdown) {
50
+ const text = String(markdown || '');
51
+ const heading = /^##\s+Discussion\s*$/m.exec(text);
52
+ if (!heading) return null;
53
+ const rest = text.slice(heading.index + heading[0].length);
54
+ const boundary = /^##\s|^-{3,}\s*$/m.exec(rest);
55
+ return boundary ? rest.slice(0, boundary.index) : rest;
56
+ }
57
+
58
+ // Only the exact line shapes count, anchored to a line start (CR tolerated for CRLF files):
59
+ // - `Validator: ok <YYYY-MM-DD> ukit@<ver>` — written by task-budget-validator --record only;
60
+ // - `Validator: dismissed <YYYY-MM-DD> — <reason>` — the hand-written documented override.
61
+ // Forged 2-field lines, headings, quoted prose and mid-sentence mentions do not satisfy the gate.
62
+ const VALIDATOR_OK_LINE = /^Validator: ok \d{4}-\d{2}-\d{2} ukit@\S+[ \t\r]*$/m;
63
+ const VALIDATOR_DISMISSED_LINE = /^Validator: dismissed \d{4}-\d{2}-\d{2} — \S.*?[ \t\r]*$/m;
64
+
65
+ function hasValidatorRecord(markdown) {
66
+ const section = discussionSection(markdown);
67
+ if (section == null) return false;
68
+ return VALIDATOR_OK_LINE.test(section) || VALIDATOR_DISMISSED_LINE.test(section);
69
+ }
70
+
71
+ // INDEX.md is a markdown table whose header row locates the Status column; rows before the
72
+ // table or with fewer cells are ignored (history prose, not rows).
73
+ function indexStatuses(root) {
74
+ const raw = readFileSafe(path.join(root, 'INDEX.md'));
75
+ if (raw == null) return null;
76
+ const statuses = new Map();
77
+ const lines = raw.split('\n');
78
+ let statusCol = -1;
79
+ for (const line of lines) {
80
+ if (!line.startsWith('|')) continue;
81
+ const cells = line.split('|').slice(1, -1).map((c) => c.trim());
82
+ if (statusCol < 0) {
83
+ statusCol = cells.findIndex((c) => /^status$/i.test(c));
84
+ continue;
85
+ }
86
+ if (cells.length <= statusCol) continue;
87
+ const id = cells[0];
88
+ if (!/^TASK-/i.test(id)) continue;
89
+ statuses.set(id, cells[statusCol].toLowerCase());
90
+ }
91
+ return statuses;
92
+ }
93
+
94
+ function listTaskFiles(root) {
95
+ const tasksDir = path.join(root, 'tasks');
96
+ let entries;
97
+ try {
98
+ entries = fs.readdirSync(tasksDir);
99
+ } catch {
100
+ return [];
101
+ }
102
+ return entries
103
+ .filter((f) => /^TASK-.*\.md$/i.test(f))
104
+ .map((f) => path.join(tasksDir, f))
105
+ .sort();
106
+ }
107
+
108
+ function main() {
109
+ const root = process.argv[2] || 'docs/AI_HANDOFF';
110
+ if (process.argv[3]) {
111
+ console.log(`READY_GATE: usage error — expected at most one root argument, got ${process.argv.length - 2}`);
112
+ process.exit(2);
113
+ }
114
+
115
+ const problems = [];
116
+ const indexStatusesMap = indexStatuses(root);
117
+ if (indexStatusesMap == null) {
118
+ console.log(`tasks not ready: index missing -> ${path.join(root, 'INDEX.md')}`);
119
+ process.exit(1);
120
+ }
121
+
122
+ const plan = readFileSafe(path.join(root, 'PLAN.md'));
123
+ // The planner footer line `PLAN_REVIEW: Approved by <reviewer model>` (line start only).
124
+ const planApproved = plan != null && /^PLAN_REVIEW: Approved by \S/m.test(plan);
125
+ let anyReady = false;
126
+
127
+ for (const file of listTaskFiles(root)) {
128
+ // Normalise the TASK- prefix so `task-001.md` and `TASK-001` name the same INDEX row.
129
+ const id = path.basename(file, '.md').replace(/^task-/i, 'TASK-');
130
+ const markdown = readFileSafe(file);
131
+ if (markdown == null) continue;
132
+ const status = fileStatus(markdown);
133
+ const indexed = indexStatusesMap.get(id);
134
+ indexStatusesMap.delete(id);
135
+
136
+ const claimedReady = READY_STATUSES.has(status) || READY_STATUSES.has(indexed);
137
+ if (!claimedReady) continue;
138
+ anyReady = true;
139
+
140
+ if (status !== indexed) {
141
+ problems.push(`index/task mismatch -> ${id} (file=${status || 'none'} index=${indexed || 'none'})`);
142
+ }
143
+ if (!hasValidatorRecord(markdown)) {
144
+ problems.push(`tasks not ready: validator -> ${id} (no recorded Validator: ok/dismissed line in ## Discussion)`);
145
+ }
146
+ }
147
+
148
+ // INDEX rows pointing at a task file that no longer exists: a ready row without its file
149
+ // is unverifiable evidence.
150
+ for (const [id, status] of indexStatusesMap) {
151
+ if (READY_STATUSES.has(status)) {
152
+ problems.push(`index/task mismatch -> ${id} (index=ready, task file missing)`);
153
+ }
154
+ }
155
+
156
+ if (anyReady && !planApproved) {
157
+ problems.push('tasks not ready: plan review (PLAN.md has no PLAN_REVIEW: Approved)');
158
+ }
159
+
160
+ if (problems.length > 0) {
161
+ for (const p of problems.sort()) console.log(p);
162
+ process.exit(1);
163
+ }
164
+ console.log('READY_GATE: ok');
165
+ process.exit(0);
166
+ }
167
+
168
+ if (isDirectRun()) {
169
+ main();
170
+ }
@@ -7,9 +7,136 @@ function parseArgs(argv) {
7
7
  return {
8
8
  dryRun: argv.includes('--dry-run'),
9
9
  quiet: argv.includes('--quiet'),
10
+ sanitize: argv.includes('--sanitize'),
10
11
  };
11
12
  }
12
13
 
14
+ // LA-02: the sanitizer removes ONLY rules that satisfy BOTH gates:
15
+ // (a) the rule token is in the SAME denylist the auto-allow hook enforces,
16
+ // and (b) the rule has proven UKit provenance: present in managedRules
17
+ // AND (record.added === true OR an audit-log auto_add event).
18
+ // The denylist is parsed out of auto-allow-bash.sh (the marked
19
+ // UKIT:AUTO-ALLOW:DENYLIST block) so hook and sanitizer share ONE definition.
20
+ const DENYLIST_BEGIN_MARKER = 'UKIT:AUTO-ALLOW:DENYLIST:BEGIN';
21
+ const DENYLIST_END_MARKER = 'UKIT:AUTO-ALLOW:DENYLIST:END';
22
+
23
+ function hookPathForScript() {
24
+ return path.resolve(
25
+ path.dirname(fileURLToPath(import.meta.url)),
26
+ '..',
27
+ '..',
28
+ 'hooks',
29
+ 'auto-allow-bash.sh',
30
+ );
31
+ }
32
+
33
+ async function loadHookDenyTokens() {
34
+ const hookPath = hookPathForScript();
35
+ let source;
36
+ try {
37
+ source = await fs.readFile(hookPath, 'utf8');
38
+ } catch {
39
+ throw new Error(
40
+ `auto-allow hook not found (${hookPath}) — refusing to sanitize without the shared denylist`,
41
+ );
42
+ }
43
+ const start = source.indexOf(DENYLIST_BEGIN_MARKER);
44
+ const end = source.indexOf(DENYLIST_END_MARKER);
45
+ if (start === -1 || end === -1 || end < start) {
46
+ throw new Error(
47
+ `${DENYLIST_BEGIN_MARKER} block not found in auto-allow-bash.sh — refusing to sanitize`,
48
+ );
49
+ }
50
+ const block = source.slice(start, end);
51
+ const tokens = new Set();
52
+ const assignment = /^UKIT_DENY_[A-Z_]+='([^']*)'$/gm;
53
+ let match;
54
+ while ((match = assignment.exec(block)) !== null) {
55
+ for (const token of match[1].split(/\s+/)) {
56
+ if (token) tokens.add(token);
57
+ }
58
+ }
59
+ if (tokens.size === 0) {
60
+ throw new Error('auto-allow denylist block parsed as empty — refusing to sanitize');
61
+ }
62
+ return tokens;
63
+ }
64
+
65
+ // The audit log rotates, so an absent/unreadable file simply yields no
66
+ // audit-side proof; record.added === true remains the other provenance path.
67
+ async function loadAuditAutoAddRules(auditPath) {
68
+ let raw;
69
+ try {
70
+ raw = await fs.readFile(auditPath, 'utf8');
71
+ } catch {
72
+ return new Set();
73
+ }
74
+ const rules = new Set();
75
+ for (const line of raw.split('\n')) {
76
+ if (!line.trim()) continue;
77
+ try {
78
+ const event = JSON.parse(line);
79
+ if (event?.event === 'auto_add' && typeof event.rule === 'string') {
80
+ rules.add(event.rule);
81
+ }
82
+ } catch {
83
+ // Unparseable lines carry no proof — skipped, never fatal.
84
+ }
85
+ }
86
+ return rules;
87
+ }
88
+
89
+ // Classify each allow rule as REMOVE (both gates pass) or SKIP (with reason).
90
+ function classifyAllowRules({ allow, managedRules, auditRules, denyTokens }) {
91
+ const removals = [];
92
+ const skips = [];
93
+ for (const rule of allow) {
94
+ const parsed = /^Bash\(([^:]+):\*\)$/.exec(String(rule));
95
+ if (!parsed) {
96
+ skips.push({ rule, reason: 'not a simple Bash(<token>:*) rule — left in place' });
97
+ continue;
98
+ }
99
+ const token = parsed[1];
100
+ const base = token.includes('/') ? token.slice(token.lastIndexOf('/') + 1) : token;
101
+ if (!denyTokens.has(base)) {
102
+ skips.push({ rule, reason: 'not a UKit auto-allow denylist token — left in place' });
103
+ continue;
104
+ }
105
+ const meta = managedRules[rule];
106
+ const hasManaged = Boolean(meta) && typeof meta === 'object';
107
+ const proven = hasManaged && (meta.added === true || auditRules.has(rule));
108
+ if (proven) {
109
+ removals.push({
110
+ rule,
111
+ via: meta.added === true ? 'managedRules added:true' : 'audit-log auto_add',
112
+ });
113
+ } else {
114
+ skips.push({
115
+ rule,
116
+ reason:
117
+ 'unproven, left in place — no managedRules record with added:true and no audit-log '
118
+ + 'auto_add event. NOTE: rules minted before UKit recorded provenance cannot be '
119
+ + 'proven; run the full reset (this script without --sanitize) to clear ALL '
120
+ + 'managed rules.',
121
+ });
122
+ }
123
+ }
124
+ return { removals, skips };
125
+ }
126
+
127
+ // Preserve the original file's indent and trailing-newline style; JSON.parse
128
+ // already preserves key order, so stringify only replaces the removed array
129
+ // entries.
130
+ function serializeLike(originalText, value) {
131
+ if (typeof originalText !== 'string') {
132
+ return JSON.stringify(value, null, 2) + '\n';
133
+ }
134
+ const indentMatch = /^[ \t]+/m.exec(originalText);
135
+ const indent = indentMatch ? Math.min(indentMatch[0].length, 10) : 2;
136
+ const trailing = originalText.endsWith('\n') ? '\n' : '';
137
+ return JSON.stringify(value, null, indent) + trailing;
138
+ }
139
+
13
140
  // W3-03 fail-closed read: a state file that exists but will not parse must
14
141
  // abort the reset — the previous `{}` fallback let a corrupt/JSONC
15
142
  // settings.local.json be rewritten as an empty object, wiping the whole allow
@@ -30,12 +157,14 @@ async function readJson(filePath, fallback) {
30
157
 
31
158
  // Same atomic shape as the auto-allow hook's writeJsonAtomic: tmp + rename so
32
159
  // readers (Claude Code re-parses settings.local.json to grant permissions)
33
- // see old-or-new, never a torn file.
34
- async function writeJsonAtomic(filePath, value) {
160
+ // see old-or-new, never a torn file. `text` (optional) overrides the default
161
+ // 2-space serialization — the sanitizer passes serializeLike(...) output to
162
+ // preserve the file's original indent and key order.
163
+ async function writeJsonAtomic(filePath, value, text) {
35
164
  await fs.mkdir(path.dirname(filePath), { recursive: true });
36
165
  const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
37
166
  try {
38
- await fs.writeFile(tempPath, JSON.stringify(value, null, 2) + '\n');
167
+ await fs.writeFile(tempPath, typeof text === 'string' ? text : JSON.stringify(value, null, 2) + '\n');
39
168
  await fs.rename(tempPath, filePath);
40
169
  } catch (error) {
41
170
  try {
@@ -118,9 +247,169 @@ async function applyReset({ settingsLocalPath, usagePath, auditPath }) {
118
247
  return outcome.value;
119
248
  }
120
249
 
250
+ // LA-02 sanitizer. Reports every decision; writes settings.local.json ONLY
251
+ // when at least one proven denylist rule is removed, so untouched state stays
252
+ // byte-identical. Dry-run lists planned removals and writes nothing.
253
+ function reportSanitizePlan({ mode, removals, skips }) {
254
+ if (removals.length === 0) {
255
+ console.log('[UKit] Sanitize: no proven UKit-minted denylist rule(s) to remove.');
256
+ } else {
257
+ console.log(
258
+ `[UKit] Sanitize: ${mode} ${removals.length} auto-minted denylist rule(s) `
259
+ + 'from .claude/settings.local.json.',
260
+ );
261
+ console.log(`${mode === 'Would remove' ? 'Would remove' : 'Removed'}:`);
262
+ for (const { rule, via } of removals) {
263
+ console.log(`- ${rule} (provenance: ${via})`);
264
+ }
265
+ }
266
+ if (skips.length > 0) {
267
+ console.log('Kept (skipped):');
268
+ for (const { rule, reason } of skips) {
269
+ console.log(`- ${rule}: ${reason}`);
270
+ }
271
+ }
272
+ }
273
+
274
+ async function sanitizePlanFromState({ settings, usage, auditPath, denyTokens }) {
275
+ const auditRules = await loadAuditAutoAddRules(auditPath);
276
+ const allow = Array.isArray(settings?.permissions?.allow) ? settings.permissions.allow : [];
277
+ const managedRules =
278
+ usage?.managedRules && typeof usage.managedRules === 'object' ? usage.managedRules : {};
279
+ return classifyAllowRules({ allow, managedRules, auditRules, denyTokens });
280
+ }
281
+
282
+ async function applySanitize({ settingsLocalPath, usagePath, auditPath, denyTokens }) {
283
+ const lockModulePath = path.join(runtimeDirForScript(), 'async-lock.mjs');
284
+ let lockModule = null;
285
+ try {
286
+ lockModule = await import(pathToFileURL(lockModulePath).href);
287
+ } catch {
288
+ lockModule = null;
289
+ }
290
+ if (!lockModule || typeof lockModule.withAsyncLock !== 'function') {
291
+ throw new Error(
292
+ 'async-lock.mjs is unavailable — refusing to write permission state unlocked (degraded install)',
293
+ );
294
+ }
295
+ await fs.mkdir(path.dirname(usagePath), { recursive: true });
296
+
297
+ const outcome = await lockModule.withAsyncLock(usagePath, {}, async () => {
298
+ // Re-read BOTH state files inside the critical section — a racing
299
+ // hook write may have landed between preflight and lock acquisition.
300
+ const settingsState = await readJson(settingsLocalPath, {});
301
+ if (settingsState.corrupt) {
302
+ throw new Error(
303
+ `settings.local.json exists but is not valid JSON (${settingsLocalPath}) — refusing to overwrite it`,
304
+ );
305
+ }
306
+ const usageState = await readJson(usagePath, {});
307
+ if (usageState.corrupt) {
308
+ throw new Error(
309
+ `permission-usage.json exists but is not valid JSON (${usagePath}) — refusing to guess managed rules`,
310
+ );
311
+ }
312
+ const settings = settingsState.value && typeof settingsState.value === 'object' ? settingsState.value : {};
313
+ const usage = usageState.value && typeof usageState.value === 'object' ? usageState.value : {};
314
+
315
+ const { removals, skips } = await sanitizePlanFromState({ settings, usage, auditPath, denyTokens });
316
+ if (removals.length === 0) {
317
+ return { removals, skips, changed: false };
318
+ }
319
+
320
+ const removalSet = new Set(removals.map((entry) => entry.rule));
321
+ settings.permissions =
322
+ settings.permissions && typeof settings.permissions === 'object' ? settings.permissions : {};
323
+ settings.permissions.allow = (Array.isArray(settings.permissions.allow)
324
+ ? settings.permissions.allow
325
+ : []
326
+ ).filter((rule) => !removalSet.has(rule));
327
+
328
+ let originalText = null;
329
+ try {
330
+ originalText = await fs.readFile(settingsLocalPath, 'utf8');
331
+ } catch {
332
+ originalText = null;
333
+ }
334
+ await fs.mkdir(path.dirname(settingsLocalPath), { recursive: true });
335
+ await writeJsonAtomic(settingsLocalPath, settings, serializeLike(originalText, settings));
336
+
337
+ const managedRules =
338
+ usage.managedRules && typeof usage.managedRules === 'object' ? usage.managedRules : {};
339
+ for (const { rule } of removals) {
340
+ delete managedRules[rule];
341
+ }
342
+ usage.lastSanitizedAt = new Date().toISOString();
343
+ await writeJsonAtomic(usagePath, usage);
344
+
345
+ const lines = removals
346
+ .map(({ rule, via }) =>
347
+ JSON.stringify({ ts: new Date().toISOString(), event: 'sanitize_remove', rule, via }))
348
+ .join('\n');
349
+ await fs.mkdir(path.dirname(auditPath), { recursive: true });
350
+ await fs.appendFile(auditPath, lines + '\n', 'utf8');
351
+
352
+ return { removals, skips, changed: true };
353
+ });
354
+
355
+ if (!outcome.ok) {
356
+ // Fail closed (TASK-028 policy): a contended lock means a hook write is in
357
+ // flight — never write permission state unlocked. Nothing was mutated.
358
+ throw new Error(
359
+ `could not acquire the permission-state lock (${outcome.reason}) — sanitize aborted, nothing was changed`,
360
+ );
361
+ }
362
+ return outcome.value;
363
+ }
364
+
365
+ async function runSanitize({ rootDir, dryRun, quiet }) {
366
+ // Fail closed BEFORE any state is touched: without the shared denylist the
367
+ // sanitizer must not guess what is removable.
368
+ const denyTokens = await loadHookDenyTokens();
369
+
370
+ const settingsLocalPath = path.join(rootDir, '.claude', 'settings.local.json');
371
+ const usagePath = path.join(rootDir, '.claude', 'ukit', 'permission-usage.json');
372
+ const auditPath = path.join(rootDir, '.claude', 'ukit', 'permission-audit.log');
373
+
374
+ if (dryRun) {
375
+ // Preflight reads for the report only; nothing is written in dry-run.
376
+ const settingsState = await readJson(settingsLocalPath, {});
377
+ if (settingsState.corrupt) {
378
+ throw new Error(
379
+ `settings.local.json exists but is not valid JSON (${settingsLocalPath}) — refusing to plan against it`,
380
+ );
381
+ }
382
+ const usageState = await readJson(usagePath, {});
383
+ if (usageState.corrupt) {
384
+ throw new Error(
385
+ `permission-usage.json exists but is not valid JSON (${usagePath}) — refusing to guess managed rules`,
386
+ );
387
+ }
388
+ const { removals, skips } = await sanitizePlanFromState({
389
+ settings: settingsState.value,
390
+ usage: usageState.value,
391
+ auditPath,
392
+ denyTokens,
393
+ });
394
+ if (!quiet) {
395
+ reportSanitizePlan({ mode: 'Would remove', removals, skips });
396
+ }
397
+ return;
398
+ }
399
+
400
+ const { removals, skips } = await applySanitize({ settingsLocalPath, usagePath, auditPath, denyTokens });
401
+ if (!quiet) {
402
+ reportSanitizePlan({ mode: 'Removed', removals, skips });
403
+ }
404
+ }
405
+
121
406
  async function main() {
122
- const { dryRun, quiet } = parseArgs(process.argv.slice(2));
407
+ const { dryRun, quiet, sanitize } = parseArgs(process.argv.slice(2));
123
408
  const rootDir = process.cwd();
409
+ if (sanitize) {
410
+ await runSanitize({ rootDir, dryRun, quiet });
411
+ return;
412
+ }
124
413
  const settingsLocalPath = path.join(rootDir, '.claude', 'settings.local.json');
125
414
  const usagePath = path.join(rootDir, '.claude', 'ukit', 'permission-usage.json');
126
415
  const auditPath = path.join(rootDir, '.claude', 'ukit', 'permission-audit.log');