@ngockhoale/ukit 2.7.5 → 2.7.7

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 CHANGED
@@ -2,6 +2,95 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.7.7 - 2026-09-21
6
+
7
+ Audit-report remediation + hook observability — cycle C42 (TASK-001..011): all 12
8
+ findings from the three 2026-09-20 reports closed; the reports moved to
9
+ `docs/AI_REPORT/archive/`.
10
+
11
+ - B1: `hook-chain-runner` short-circuits on a permission decision under
12
+ `--emit-verdict`. A `hookSpecificOutput` decision on a step now owns the
13
+ verdict and stops the chain there, so the decision JSON is emitted as the
14
+ whole stdout instead of being concatenated in front of the next step's
15
+ output, and a genuinely-skipped fail-closed gate is no longer exempted from
16
+ the fail-closed verdict. C8 (same task): an invalid `:N` budget suffix
17
+ (`:0`, `:-1`) warns and runs the step on its default budget instead of
18
+ silently parsing; a non-numeric suffix stays a literal path.
19
+ - B2: `yarn test <files>` — the repo's dominant verification form — is now
20
+ recognized by the verification-minutes estimator alongside `yarn vitest
21
+ [run]`, so task budgets are no longer inflated by the 1-minute flat
22
+ fallback.
23
+ - B3: doctor strips `{{...}}` placeholders before `parse()`ing the omp config
24
+ template, so a placeholder in key position no longer makes the `yaml` lib
25
+ emit "Keys with collection values will be stringified" on every run.
26
+ - C5: new scoped `scripts/install/sync-installed-mirror.mjs` (`--check` reports
27
+ drift, bare run rewrites). It reuses the real install pipeline
28
+ (`buildInstallPlan` → `diffInstallPlan` → `applyDiffResults`) so drift is
29
+ decided by exactly the code `ukit install` uses, and it only ever touches
30
+ `.claude/**`/`.omp/**` plan targets — never `src/`, `docs/`, or user-authored
31
+ files. Parity coverage widened to `.claude/ukit/index/`.
32
+ - C7: hook-chain failure surfaces pinned by
33
+ `tests/hooks/hookChainFailureSurface.test.js` (output overflow on a
34
+ fail-closed gate, budget exhausted mid-chain, signal death); the decision to
35
+ drop non-verdict stderr is documented in `UKIT_INTERNALS.md`.
36
+ - O1: telemetry wired into the four previously unmeasured hooks
37
+ (`reinject-context`, `auto-prune-bash`, `reset-compact-pressure`,
38
+ `session-episode`) via a no-staging arm path in the `hook-telemetry` engine.
39
+ Four hooks now emit rows where they emitted none.
40
+ - O2: audit snapshots carry provenance (`generatedAt` + telemetry row/file
41
+ counts), and `scripts/perf/diff-perf-findings.mjs` diffs two snapshots per
42
+ finding id without touching live telemetry.
43
+ - O3: telemetry rows split stdin-stage time from execution time (`stageMs`),
44
+ so a slow producer is distinguishable from a slow step.
45
+ - O4/O6: telemetry consumers audited and the consolidated row shape pinned
46
+ (`tests/hooks/chainTelemetryRowShape.test.js`); doctor escalates when a
47
+ failure actually landed on a chain carrying a fail-closed gate or exhausted
48
+ its budget — purely additive to the existing detail/remedy.
49
+ - O5: telemetry append overhead measured; numbers recorded in
50
+ `scripts/perf/perf-measure.md`.
51
+ - Fixed `tests/consistency/ompDocsSync.test.js` case 4: the frozen-history
52
+ guard treated the `RULES.md` §Clear Handoff step-2 archive rotation (delete
53
+ the oldest cycle, fold it into `HISTORY.md`) as tampering, so the test was
54
+ red at HEAD and at the `v2.7.2`/`v2.7.5` tags. The guard now exempts a
55
+ cycle-dir deletion only when a matching `HISTORY.md` row exists and the
56
+ post-rotation archive is back within the 3-dir cap; rewrites, renames, and
57
+ unrecorded deletions still fail.
58
+ - Fixed `src/core/memory/store.js`: the session-archive cap appended and
59
+ `slice(-cap)`ed, so it evicted by insertion position instead of age — a
60
+ session archived late carrying an older end time outlived a newer one written
61
+ earlier. It now drops the oldest by end time (ties keep insertion order),
62
+ matching the key `archiveSessions` already uses. The regression case in
63
+ `tests/core/sessionArchiveBound.test.js` had a `Date.now()`-per-session
64
+ fixture whose outcome depended on whether the clock ticked mid-array, so it
65
+ passed on an unloaded box and failed under load; the fixture is now
66
+ deterministic and the case fails against the old code.
67
+
68
+ ## 2.7.6 - 2026-09-20
69
+
70
+ Permission posture work — cycle C41 (TASK-001..004): omp prompts aligned to
71
+ Claude Code — routine commands auto-allow, gates kept only for destructive
72
+ operations.
73
+
74
+ - `rm -rf *` static catch-all removed from `templates/.omp/config.yml`
75
+ `bash.patterns`, `templates/.claude/settings.json`, and
76
+ `permissionPolicy.CANONICAL_DENY` (16→15). The `block-dangerous` hook remains
77
+ the authority for generic `rm -rf <dir>` (fail-closed), so coverage for
78
+ real destructive targets is unchanged while routine/safe cleanup paths no
79
+ longer hit the static deny on both hosts.
80
+ - `ompConfigMerge` now coerces preserved legacy `prompt`/`ask` bash.patterns
81
+ to `allow` instead of `deny` — under rendered `yolo` a broad preserved
82
+ pattern (worst case `*`) could globally brick bash. Preserved `deny` still
83
+ passes through; canonical deny + the hook still gate destructive commands.
84
+ - `permissionDoctor` new FAIL check `omp-approval-mode-not-pinned`: under
85
+ `orchestration.permissionMode: unattended`, fires when omp `approvalMode`
86
+ is missing or not `yolo`; plus an advisory audit of preserved bash.patterns.
87
+ `applicable:false` when `.omp/config.yml` is absent or mode ≠ unattended.
88
+ - `sensitive-data-guard` added to `CANONICAL_PROTECTED` (no precedence
89
+ change); new `tests/manifest/c41OmpPosture.test.js` asserts routine
90
+ commands are not prompted, all destructive classes stay denied, and
91
+ enumerates the full prompt-producing gate inventory (stale-spec-guard,
92
+ sensitive-data-guard, bridge, FAIL_CLOSED).
93
+
5
94
  ## 2.7.5 - 2026-09-20
6
95
 
7
96
  Hook performance work — cycle C40 (TASK-001..007): `.mjs` module steps,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.7.5",
3
+ "version": "2.7.7",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env node
2
+ // C5 (docs/AI_REPORT/archive/2026-09-20-CORRECTNESS.md §C5) — scoped mirror sync/check.
3
+ //
4
+ // The installed `.claude/` + `.omp/` mirror is a *rendered* copy of `templates/.claude`
5
+ // + `templates/.omp`, and hand-copying it drifted three separate ways this cycle:
6
+ // a template change never re-copied, a raw copy that kept its literal `{{token}}`
7
+ // because nobody rendered it, and `settings.json`'s two gateway-injected env keys,
8
+ // which make whole-file byte identity impossible by construction. Parallel lanes
9
+ // overwrote each other because there was no tool — only whoever remembered.
10
+ //
11
+ // node scripts/install/sync-installed-mirror.mjs --check report drift, exit non-zero
12
+ // node scripts/install/sync-installed-mirror.mjs rewrite drifted targets
13
+ //
14
+ // This reuses the real install pipeline (`buildInstallPlan` → `diffInstallPlan` →
15
+ // `applyDiffResults` → `applyGatewayResilienceEnv`) rather than reimplementing it, so
16
+ // "drift" is decided by exactly the code `ukit install` uses — including the
17
+ // gateway-env carve-out in `src/core/diffPlan.js` and the render step in
18
+ // `src/render/buildTemplateVariables.js`. Render-tokenized templates are compared in
19
+ // rendered form; `settings.json` is compared section-wise, never byte-wise.
20
+ //
21
+ // Scope is deliberately narrow — the tool only ever considers plan targets that live
22
+ // under `.claude/**` or `.omp/**`, so it can never touch `src/`, `docs/`, or any
23
+ // user-authored file:
24
+ //
25
+ // * `mergeStrategy: 'skip'` entries (e.g. `.claude/settings.local.json`) are
26
+ // user-owned, not template-derived. They are excluded from drift AND from apply —
27
+ // a hands-off file must never read as drift, and this tool is not an installer.
28
+ // * Mirror-only detection is limited to the target directories of *selected,
29
+ // non-link* manifest items — directories UKit owns wholesale from `templates/`.
30
+ // A blanket walk of `.claude/` reports ~226 false positives, because pack-gated
31
+ // skill libraries (e.g. `.claude/skills/frontend-vue/**` on a `core`-only project)
32
+ // are present in the mirror but intentionally outside the current plan. Those
33
+ // directories are covered by `tests/consistency/templateParity.test.js` instead.
34
+ import fs from 'node:fs/promises';
35
+ import path from 'node:path';
36
+ import { fileURLToPath } from 'node:url';
37
+
38
+ import { buildPathConfig } from '../../src/core/paths.js';
39
+ import { loadManifest } from '../../src/manifest/loadManifest.js';
40
+ import { detectStack } from '../../src/stack/detectStack.js';
41
+ import { detectProjectContext } from '../../src/context/detectProjectContext.js';
42
+ import { detectProviders } from '../../src/context/detectProviders.js';
43
+ import { loadRuntimeConfig } from '../../src/core/runtimeConfig.js';
44
+ import { buildTemplateVariables } from '../../src/render/buildVariables.js';
45
+ import { buildInstallPlan } from '../../src/core/buildPlan.js';
46
+ import { diffInstallPlan } from '../../src/core/diffPlan.js';
47
+ import { applyDiffResults } from '../../src/core/applyPlan.js';
48
+ import { applyGatewayResilienceEnv } from '../../src/core/gatewayResilienceEnv.js';
49
+
50
+ const MANAGED_PREFIXES = ['.claude/', '.omp/'];
51
+ const USAGE = 'usage: sync-installed-mirror.mjs [--check]';
52
+
53
+ function isManaged(relativePath) {
54
+ return MANAGED_PREFIXES.some((prefix) => relativePath.startsWith(prefix));
55
+ }
56
+
57
+ function toRelative(projectRoot, targetPath) {
58
+ return path.relative(projectRoot, targetPath).split(path.sep).join('/');
59
+ }
60
+
61
+ async function readPackageVersion(packageRoot) {
62
+ try {
63
+ const pkg = JSON.parse(await fs.readFile(path.join(packageRoot, 'package.json'), 'utf8'));
64
+ return typeof pkg.version === 'string' ? pkg.version : null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ // The full set of paths the plan manages, used to decide whether a file found in the
71
+ // mirror is unmanaged. Built from every entry (not just the managed ones) so a path
72
+ // that is managed by an out-of-scope entry is never reported as mirror-only.
73
+ function buildPlanTargetSet(plan, projectRoot) {
74
+ return new Set(
75
+ plan.entries
76
+ .map((entry) => toRelative(projectRoot, entry.targetPath))
77
+ .filter(Boolean),
78
+ );
79
+ }
80
+
81
+ async function collectFilesRecursively(dir, out) {
82
+ let entries;
83
+ try {
84
+ entries = await fs.readdir(dir, { withFileTypes: true });
85
+ } catch {
86
+ return out; // missing or unreadable directory — the create-drift lane covers it
87
+ }
88
+
89
+ for (const entry of entries) {
90
+ const fullPath = path.join(dir, entry.name);
91
+ // Symlinks are the adapter-link lane's business (`type: 'link'` items are excluded
92
+ // from mirror-only detection), and following one would walk another tool's tree.
93
+ if (entry.isSymbolicLink()) continue;
94
+ if (entry.isDirectory()) {
95
+ await collectFilesRecursively(fullPath, out);
96
+ continue;
97
+ }
98
+ if (entry.isFile()) out.push(fullPath);
99
+ }
100
+
101
+ return out;
102
+ }
103
+
104
+ // Directories UKit installs wholesale from a template directory. Any file inside one
105
+ // that the plan does not ship is mirror-only drift rather than user content.
106
+ async function collectManagedDirectoryRoots({ plan, templatesRoot }) {
107
+ const roots = [];
108
+
109
+ for (const item of plan.selectedItems) {
110
+ if (item.type === 'link') continue;
111
+ const target = String(item.targetPath ?? '');
112
+ if (!isManaged(target)) continue;
113
+
114
+ const source = path.join(templatesRoot, String(item.sourceTemplate ?? ''));
115
+ let stat;
116
+ try {
117
+ stat = await fs.stat(source);
118
+ } catch {
119
+ continue;
120
+ }
121
+ if (stat.isDirectory()) roots.push(target.replace(/\/+$/, ''));
122
+ }
123
+
124
+ return roots;
125
+ }
126
+
127
+ async function collectMirrorOnlyPaths({ plan, projectRoot, templatesRoot }) {
128
+ const planTargets = buildPlanTargetSet(plan, projectRoot);
129
+ const roots = await collectManagedDirectoryRoots({ plan, templatesRoot });
130
+ const mirrorOnly = [];
131
+
132
+ for (const root of roots) {
133
+ const files = await collectFilesRecursively(path.join(projectRoot, root), []);
134
+ for (const file of files) {
135
+ const relativePath = toRelative(projectRoot, file);
136
+ if (!planTargets.has(relativePath)) mirrorOnly.push(relativePath);
137
+ }
138
+ }
139
+
140
+ return [...new Set(mirrorOnly)].sort();
141
+ }
142
+
143
+ async function main() {
144
+ const args = process.argv.slice(2);
145
+ const unknown = args.filter((arg) => arg !== '--check');
146
+ if (unknown.length > 0) {
147
+ console.error(`${USAGE}\nunknown flag: ${unknown[0]}`);
148
+ process.exit(2);
149
+ }
150
+
151
+ const checkOnly = args.includes('--check');
152
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
153
+ const projectRoot = process.cwd();
154
+ const pathConfig = buildPathConfig({ packageRoot, projectRoot });
155
+
156
+ const manifest = await loadManifest(pathConfig.manifestPath);
157
+ const stackContext = await detectStack(projectRoot);
158
+ const projectContext = await detectProjectContext(projectRoot);
159
+ const providerContext = await detectProviders(projectRoot);
160
+ const runtimeConfig = await loadRuntimeConfig(projectRoot);
161
+
162
+ // Same variable set as `runInstallPipeline`, so render-tokenized templates compare
163
+ // in the exact form `ukit install` would have written them.
164
+ const variables = buildTemplateVariables({
165
+ projectContext,
166
+ stackContext,
167
+ packageVersion: await readPackageVersion(packageRoot),
168
+ providerContext,
169
+ runtimeConfig,
170
+ });
171
+
172
+ const plan = await buildInstallPlan({
173
+ manifest,
174
+ stackContext,
175
+ templatesRoot: pathConfig.templatesRoot,
176
+ variables,
177
+ projectRoot,
178
+ // Omitted: `ukit install`'s own default adapter set (claude + codex + omp), so the
179
+ // `.omp/**` half of the mirror is in scope.
180
+ selectedAdapterItemIds: undefined,
181
+ });
182
+
183
+ const managedEntries = plan.entries.filter((entry) => {
184
+ if (entry.mergeStrategy === 'skip') return false;
185
+ return isManaged(toRelative(projectRoot, entry.targetPath));
186
+ });
187
+ const managedRelativePaths = new Set(managedEntries.map((entry) => toRelative(projectRoot, entry.targetPath)));
188
+
189
+ const diffResults = await diffInstallPlan({ entries: managedEntries });
190
+ const drift = diffResults
191
+ .filter((entry) => entry.action === 'create' || entry.action === 'update')
192
+ .map((entry) => ({ entry, relativePath: toRelative(projectRoot, entry.targetPath) }))
193
+ .sort((a, b) => a.relativePath.localeCompare(b.relativePath));
194
+
195
+ const mirrorOnly = await collectMirrorOnlyPaths({
196
+ plan,
197
+ projectRoot,
198
+ templatesRoot: pathConfig.templatesRoot,
199
+ });
200
+
201
+ if (checkOnly) {
202
+ for (const { entry, relativePath } of drift) {
203
+ console.log(`DRIFT ${relativePath} (${entry.action})`);
204
+ }
205
+ for (const relativePath of mirrorOnly) {
206
+ console.log(`MIRROR-ONLY ${relativePath}`);
207
+ }
208
+
209
+ if (drift.length === 0 && mirrorOnly.length === 0) {
210
+ console.log(`ukit mirror check: clean (${managedRelativePaths.size} scoped targets)`);
211
+ return 0;
212
+ }
213
+
214
+ console.log(
215
+ `ukit mirror check: ${drift.length} drifted, ${mirrorOnly.length} mirror-only `
216
+ + `(${managedRelativePaths.size} scoped targets)`,
217
+ );
218
+ return 1;
219
+ }
220
+
221
+ const { writes } = await applyDiffResults(
222
+ drift.map(({ entry }) => entry),
223
+ { backupRoot: pathConfig.backupRoot, projectRoot },
224
+ );
225
+ for (const write of writes) {
226
+ console.log(`${write.action === 'create' ? 'CREATED' : 'UPDATED'} ${toRelative(projectRoot, write.targetPath)}`);
227
+ }
228
+
229
+ // Re-running the managed-gateway step is what makes a rewritten `settings.json`
230
+ // whole again: the rendered template does not carry the two injected env keys, so
231
+ // serializing the template alone would drop them.
232
+ const gateway = await applyGatewayResilienceEnv({ projectRoot });
233
+ if (gateway.changed) {
234
+ console.log(`ukit mirror sync: re-applied managed gateway env keys (${[...gateway.applied, ...gateway.migrated].join(', ')})`);
235
+ }
236
+
237
+ for (const relativePath of mirrorOnly) {
238
+ console.log(`MIRROR-ONLY ${relativePath} — not shipped by any template; left in place, remove it by hand if stale`);
239
+ }
240
+
241
+ console.log(`ukit mirror sync: ${writes.length} written, ${mirrorOnly.length} mirror-only left in place`);
242
+ return 0;
243
+ }
244
+
245
+ try {
246
+ process.exit(await main());
247
+ } catch (error) {
248
+ console.error(error && error.message ? error.message : error);
249
+ process.exit(1);
250
+ }