@ngockhoale/ukit 2.7.6 → 2.7.8

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 (47) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/package.json +1 -1
  3. package/scripts/install/sync-installed-mirror.mjs +250 -0
  4. package/scripts/perf/audit-perf.mjs +287 -36
  5. package/scripts/perf/diff-perf-findings.mjs +136 -0
  6. package/scripts/perf/perf-findings.json +260 -206
  7. package/scripts/perf/perf-measure.md +271 -0
  8. package/src/context/detectProjectContext.js +5 -0
  9. package/src/core/codeintel/invalidation.js +4 -0
  10. package/src/core/fileOps.js +40 -117
  11. package/src/core/hookChainDoctor.js +65 -2
  12. package/src/core/memory/store.js +22 -1
  13. package/src/core/taskBudgetValidator.js +9 -6
  14. package/src/core/unattendedDoctor.js +8 -1
  15. package/src/render/buildVariables.js +10 -0
  16. package/templates/.claude/agents/bug-debugger.md +1 -1
  17. package/templates/.claude/agents/feature-implementer.md +2 -2
  18. package/templates/.claude/commands/ukit/handoff-create.md +1 -1
  19. package/templates/.claude/commands/ukit/handoff-fullstack.md +1 -1
  20. package/templates/.claude/commands/ukit/handoff-implement.md +1 -1
  21. package/templates/.claude/commands/ukit/handoff-review.md +1 -1
  22. package/templates/.claude/hooks/auto-prune-bash.sh +19 -0
  23. package/templates/.claude/hooks/context-hardcap-gate.sh +4 -1
  24. package/templates/.claude/hooks/handoff-model-guard.sh +22 -11
  25. package/templates/.claude/hooks/reinject-context.sh +22 -0
  26. package/templates/.claude/hooks/reset-compact-pressure.sh +29 -0
  27. package/templates/.claude/hooks/session-episode.sh +20 -0
  28. package/templates/.claude/hooks/skill-router.sh +15 -8
  29. package/templates/.claude/hooks/verification-guard.sh +3 -0
  30. package/templates/.claude/ukit/index/route-task.mjs +237 -32
  31. package/templates/.claude/ukit/index/task-budget-validator.mjs +6 -2
  32. package/templates/.claude/ukit/runtime/async-lock.mjs +144 -10
  33. package/templates/.claude/ukit/runtime/compact-threshold.mjs +5 -2
  34. package/templates/.claude/ukit/runtime/execution-ledger.mjs +217 -17
  35. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +156 -24
  36. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +57 -0
  37. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +84 -12
  38. package/templates/.claude/ukit/runtime/hook-telemetry.sh +50 -0
  39. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +35 -20
  40. package/templates/.claude/ukit/runtime/token-utils.mjs +37 -126
  41. package/templates/.codex/settings.json +1 -5
  42. package/templates/.omp/agents/bug-debugger.md +1 -1
  43. package/templates/.omp/agents/feature-implementer.md +2 -2
  44. package/templates/.omp/hooks/pre/ukit-bridge.js +157 -26
  45. package/templates/docs/AI_HANDOFF/INDEX.md +1 -1
  46. package/templates/docs/AI_HANDOFF/RULES.md +6 -6
  47. package/templates/ukit/storage/config.json +2 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,111 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.7.8 - 2026-09-22
6
+
7
+ Stall/hang fixes — cycle C43 (TASK-001..008): the indefinite-stall producers
8
+ root-caused in `docs/AI_REPORT/2026-09-21-NORMAL_CHAT_STALL.md` are fixed
9
+ across Claude Code, omp, and Codex runtimes.
10
+
11
+ - **CX-8**: route-state writes are atomic + locked — torn `skill-router-state.json`
12
+ writes can no longer wedge the router.
13
+ - **OMP-3/F-2**: omp receipt dedupe — a tool result is recorded once; duplicate
14
+ receipts no longer double-count execution records.
15
+ - **RC-4/OMP-1/F-3/F-4**: breaker-freeze family — budgeted reclaim, pstart
16
+ stamps, fail-open streaks; continuation sidecar merge gate stamps
17
+ `lastContinuationAt` strictly newer than its base and materializes counter
18
+ state when the main ledger file is absent.
19
+ - **F-5/F-5b**: `withFileLock` policy + reclaim — stale locks reclaimed via
20
+ pstart mismatch; recycled-pid regression tests added.
21
+ - **F-15**: verifier-classification — verification outcomes classified so a
22
+ non-verification failure no longer masquerades as a test failure.
23
+ - **F-RC-2**: `hook-chain-runner` exits promptly after verdict — bounded stdout
24
+ flush then `process.exit`; an abandoned in-proc step promise can no longer
25
+ pin the event loop for the full registered timeout.
26
+ - **F-10/OMP-2**: omp Stop parity — omp sessions get the same handoff-cursor
27
+ Stop-gate lane as Claude Code.
28
+ - **OMP-6/F-8/CX-10**: `createPayloadReference` staging runs under a deadline
29
+ with fail-open to inline payload (never throws, host loop not frozen); omp
30
+ SessionStart carries `session_id` so `reset-compact-pressure` no longer
31
+ wipes every session's pressure records; codex `requiredBeforeCompletion`
32
+ renders only commands that exist in `package.json`.
33
+ - Housekeeping: stale "Kilo" tool labels scrubbed from handoff docs, agent
34
+ report contracts, and config help text (functional support was already
35
+ removed; the regression tests and gateway comments documenting that removal
36
+ are intentionally kept).
37
+
38
+ ## 2.7.7 - 2026-09-21
39
+
40
+ Audit-report remediation + hook observability — cycle C42 (TASK-001..011): all 12
41
+ findings from the three 2026-09-20 reports closed; the reports moved to
42
+ `docs/AI_REPORT/archive/`.
43
+
44
+ - B1: `hook-chain-runner` short-circuits on a permission decision under
45
+ `--emit-verdict`. A `hookSpecificOutput` decision on a step now owns the
46
+ verdict and stops the chain there, so the decision JSON is emitted as the
47
+ whole stdout instead of being concatenated in front of the next step's
48
+ output, and a genuinely-skipped fail-closed gate is no longer exempted from
49
+ the fail-closed verdict. C8 (same task): an invalid `:N` budget suffix
50
+ (`:0`, `:-1`) warns and runs the step on its default budget instead of
51
+ silently parsing; a non-numeric suffix stays a literal path.
52
+ - B2: `yarn test <files>` — the repo's dominant verification form — is now
53
+ recognized by the verification-minutes estimator alongside `yarn vitest
54
+ [run]`, so task budgets are no longer inflated by the 1-minute flat
55
+ fallback.
56
+ - B3: doctor strips `{{...}}` placeholders before `parse()`ing the omp config
57
+ template, so a placeholder in key position no longer makes the `yaml` lib
58
+ emit "Keys with collection values will be stringified" on every run.
59
+ - C5: new scoped `scripts/install/sync-installed-mirror.mjs` (`--check` reports
60
+ drift, bare run rewrites). It reuses the real install pipeline
61
+ (`buildInstallPlan` → `diffInstallPlan` → `applyDiffResults`) so drift is
62
+ decided by exactly the code `ukit install` uses, and it only ever touches
63
+ `.claude/**`/`.omp/**` plan targets — never `src/`, `docs/`, or user-authored
64
+ files. Parity coverage widened to `.claude/ukit/index/`.
65
+ - C7: hook-chain failure surfaces pinned by
66
+ `tests/hooks/hookChainFailureSurface.test.js` (output overflow on a
67
+ fail-closed gate, budget exhausted mid-chain, signal death); the decision to
68
+ drop non-verdict stderr is documented in `UKIT_INTERNALS.md`.
69
+ - O1: telemetry wired into the four previously unmeasured hooks
70
+ (`reinject-context`, `auto-prune-bash`, `reset-compact-pressure`,
71
+ `session-episode`) via a no-staging arm path in the `hook-telemetry` engine.
72
+ Four hooks now emit rows where they emitted none.
73
+ - O2: audit snapshots carry provenance (`generatedAt` + telemetry row/file
74
+ counts), and `scripts/perf/diff-perf-findings.mjs` diffs two snapshots per
75
+ finding id without touching live telemetry.
76
+ - O3: telemetry rows split stdin-stage time from execution time (`stageMs`),
77
+ so a slow producer is distinguishable from a slow step.
78
+ - O4/O6: telemetry consumers audited and the consolidated row shape pinned
79
+ (`tests/hooks/chainTelemetryRowShape.test.js`); doctor escalates when a
80
+ failure actually landed on a chain carrying a fail-closed gate or exhausted
81
+ its budget — purely additive to the existing detail/remedy.
82
+ - O5: telemetry append overhead measured; numbers recorded in
83
+ `scripts/perf/perf-measure.md`.
84
+ - Fixed `tests/consistency/ompDocsSync.test.js` case 4: the frozen-history
85
+ guard treated the `RULES.md` §Clear Handoff step-2 archive rotation (delete
86
+ the oldest cycle, fold it into `HISTORY.md`) as tampering, so the test was
87
+ red at HEAD and at the `v2.7.2`/`v2.7.5` tags. The guard now exempts a
88
+ cycle-dir deletion only when a matching `HISTORY.md` row exists and the
89
+ post-rotation archive is back within the 3-dir cap; rewrites, renames, and
90
+ unrecorded deletions still fail.
91
+ - Fixed `src/core/memory/store.js`: the session-archive cap appended and
92
+ `slice(-cap)`ed, so it evicted by insertion position instead of age — a
93
+ session archived late carrying an older end time outlived a newer one written
94
+ earlier. It now drops the oldest by end time (ties keep insertion order),
95
+ matching the key `archiveSessions` already uses. The regression case in
96
+ `tests/core/sessionArchiveBound.test.js` had a `Date.now()`-per-session
97
+ fixture whose outcome depended on whether the clock ticked mid-array, so it
98
+ passed on an unloaded box and failed under load; the fixture is now
99
+ deterministic and the case fails against the old code.
100
+ - `withFileLock` (both `templates/.claude/ukit/runtime/token-utils.mjs` and its
101
+ `src/core/fileOps.js` twin) is now fail-closed: a lock wait that exceeds
102
+ `maxWaitMs` skips the mutation instead of running it unlocked, and the drop is
103
+ journaled to `<file>.lock-drops.jsonl` (bounded 128-record JSONL). Both twins
104
+ delegate to `async-lock.mjs`, so owner stamping (pid + token + pstart),
105
+ recycled-pid detection, and claim+quarantine stale reclaim exist exactly once;
106
+ a stale lock whose recorded pid was recycled is now reclaimed instead of
107
+ wedging every mutation for that file. `writeJson`/`writeFileAtomic` fall back
108
+ to copy+unlink when `rename` fails with EXDEV (cross-mount tmp dirs).
109
+
5
110
  ## 2.7.6 - 2026-09-20
6
111
 
7
112
  Permission posture work — cycle C41 (TASK-001..004): omp prompts aligned to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.7.6",
3
+ "version": "2.7.8",
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
+ }