@ngockhoale/ukit 2.3.14 → 2.3.17

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.
@@ -0,0 +1,292 @@
1
+ import fs from 'node:fs/promises';
2
+ import fsSync from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ // Managed defaults that keep Claude Code responsive when it is pointed at a custom
7
+ // gateway (any non-empty ANTHROPIC_BASE_URL). `CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK='1'`
8
+ // forces streaming mode so the client never falls back to a non-streaming POST that can
9
+ // stall for minutes waiting for the full response. `CLAUDE_STREAM_IDLE_TIMEOUT_MS='600000'`
10
+ // raises Claude Code's internal idle timeout from its default (the gateway-side keep-alive
11
+ // can sit idle longer than the default when the upstream is slow).
12
+ //
13
+ // These are written ONLY when no user-set value already lives in the project's
14
+ // `.claude/settings.json` env block — never clobber, always preserve. Re-running
15
+ // `ukit install` is a no-op once the keys are in place.
16
+ //
17
+ // Probe order mirrors `templates/.claude/ukit/index/unic-gateway.mjs` (env → project
18
+ // `.claude/settings.json` env → home `~/.claude/settings.json` env) but generalizes to ANY
19
+ // non-empty `ANTHROPIC_BASE_URL`, not only `unicjsc.com`. The runtime template stays
20
+ // scoped to UNIC because that's the lane-routing signal; here we only care that the user
21
+ // is pointed at ANY gateway, so we apply resilience regardless of vendor.
22
+
23
+ export const GATEWAY_RESILIENCE_ENV_DEFAULTS = Object.freeze({
24
+ CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: '1',
25
+ CLAUDE_STREAM_IDLE_TIMEOUT_MS: '600000',
26
+ });
27
+
28
+ const BASE_URL_ENV_VAR = 'ANTHROPIC_BASE_URL';
29
+ const SETTINGS_RELATIVE_PATH = path.join('.claude', 'settings.json');
30
+
31
+ function readBaseUrlFromEnvValue(value) {
32
+ return typeof value === 'string' && value.trim().length > 0 ? value : null;
33
+ }
34
+
35
+ function readBaseUrlFromEnvBlock(envBlock) {
36
+ if (!envBlock || typeof envBlock !== 'object' || Array.isArray(envBlock)) {
37
+ return null;
38
+ }
39
+ return readBaseUrlFromEnvValue(envBlock[BASE_URL_ENV_VAR]);
40
+ }
41
+
42
+ // Sync probe over a settings file. The probe is bounded to two small files at install
43
+ // time (project + home), so a synchronous fs read keeps the detector's call signature
44
+ // simple and matches the task interface contract (`detectCustomGateway` is sync). Errors
45
+ // (ENOENT, EACCES, malformed JSON) are swallowed and treated as "no base URL found here"
46
+ // so a corrupt user file can never crash the detector.
47
+ function readBaseUrlFromSettingsFileSync(settingsPath) {
48
+ let raw;
49
+ try {
50
+ raw = fsSync.readFileSync(settingsPath, 'utf8');
51
+ } catch {
52
+ return null;
53
+ }
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(raw);
57
+ } catch {
58
+ return null;
59
+ }
60
+ return readBaseUrlFromEnvBlock(parsed?.env);
61
+ }
62
+
63
+ /**
64
+ * Detect whether ANY non-empty ANTHROPIC_BASE_URL is in scope, mirroring the probe order of
65
+ * `templates/.claude/ukit/index/unic-gateway.mjs` but generalized. Probe order is strictly
66
+ * env → project `.claude/settings.json` → home `~/.claude/settings.json`; first non-empty
67
+ * hit wins, and the returned `source` tells the caller which one matched.
68
+ *
69
+ * Synchronous by contract — the probe reads at most two small settings files via
70
+ * `fs.readFileSync` (cheap on install; never touches the network). Errors reading or
71
+ * parsing any file are swallowed so a corrupt settings file cannot crash the detector.
72
+ *
73
+ * @param {{ rootDir: string, homeDir: string, env?: object }} options
74
+ * @returns {{ customGateway: boolean, baseUrl: string|null, source: 'env'|'claude-settings'|null }}
75
+ */
76
+ export function detectCustomGateway({ rootDir, homeDir, env } = {}) {
77
+ const envValue = env ? readBaseUrlFromEnvValue(env[BASE_URL_ENV_VAR]) : null;
78
+ if (envValue) {
79
+ return { customGateway: true, baseUrl: envValue, source: 'env' };
80
+ }
81
+
82
+ if (rootDir) {
83
+ const projectSettingsPath = path.join(rootDir, SETTINGS_RELATIVE_PATH);
84
+ const projectValue = readBaseUrlFromSettingsFileSync(projectSettingsPath);
85
+ if (projectValue) {
86
+ return { customGateway: true, baseUrl: projectValue, source: 'claude-settings' };
87
+ }
88
+ }
89
+
90
+ if (homeDir) {
91
+ const homeSettingsPath = path.join(homeDir, SETTINGS_RELATIVE_PATH);
92
+ const homeValue = readBaseUrlFromSettingsFileSync(homeSettingsPath);
93
+ if (homeValue) {
94
+ return { customGateway: true, baseUrl: homeValue, source: 'claude-settings' };
95
+ }
96
+ }
97
+
98
+ return { customGateway: false, baseUrl: null, source: null };
99
+ }
100
+
101
+ /**
102
+ * Async variant used by `applyGatewayResilienceEnv` so the settings-file probes can run
103
+ * without blocking the event loop. Mirrors `detectCustomGateway` exactly; the only
104
+ * difference is the Promise wrapper for callers that already sit in an async pipeline.
105
+ */
106
+ export function detectCustomGatewayAsync({ rootDir, homeDir, env } = {}) {
107
+ return Promise.resolve(detectCustomGateway({ rootDir, homeDir, env }));
108
+ }
109
+
110
+ function backupPathFor(settingsPath) {
111
+ return `${settingsPath}.ukit-backup`;
112
+ }
113
+
114
+ /**
115
+ * Apply the managed env defaults to the project's `.claude/settings.json` env block when
116
+ * a custom gateway is detected. Skips a key the user has set to a non-default value —
117
+ * the contract is "add or already-at-default", never "overwrite".
118
+ *
119
+ * @param {{ projectRoot: string, homeDir?: string, env?: object }} options
120
+ * @returns {Promise<{
121
+ * customGateway: boolean,
122
+ * source: 'env'|'claude-settings'|null,
123
+ * baseUrl: string|null,
124
+ * applied: string[],
125
+ * unchanged: string[],
126
+ * skipped: string[],
127
+ * changed: boolean,
128
+ * reason?: string,
129
+ * }>}
130
+ */
131
+ export async function applyGatewayResilienceEnv({
132
+ projectRoot,
133
+ homeDir = os.homedir(),
134
+ env = process.env,
135
+ } = {}) {
136
+ const detection = detectCustomGateway({ rootDir: projectRoot, homeDir, env });
137
+
138
+ if (!detection.customGateway) {
139
+ return {
140
+ customGateway: false,
141
+ source: null,
142
+ baseUrl: null,
143
+ applied: [],
144
+ unchanged: [],
145
+ skipped: [],
146
+ changed: false,
147
+ };
148
+ }
149
+
150
+ const settingsPath = path.join(projectRoot, SETTINGS_RELATIVE_PATH);
151
+ await fs.mkdir(path.dirname(settingsPath), { recursive: true });
152
+
153
+ let raw;
154
+ try {
155
+ raw = await fs.readFile(settingsPath, 'utf8');
156
+ } catch (error) {
157
+ if (error && error.code === 'ENOENT') {
158
+ raw = null;
159
+ } else {
160
+ return {
161
+ ...detection,
162
+ applied: [],
163
+ unchanged: [],
164
+ skipped: [],
165
+ changed: false,
166
+ reason: `failed to read settings: ${error?.message ?? String(error)}`,
167
+ };
168
+ }
169
+ }
170
+
171
+ let settings;
172
+ if (raw === null) {
173
+ settings = {};
174
+ } else {
175
+ try {
176
+ settings = JSON.parse(raw);
177
+ } catch (error) {
178
+ return {
179
+ ...detection,
180
+ applied: [],
181
+ unchanged: [],
182
+ skipped: [],
183
+ changed: false,
184
+ reason: `failed to parse settings JSON: ${error?.message ?? String(error)}`,
185
+ };
186
+ }
187
+ }
188
+
189
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
190
+ return {
191
+ ...detection,
192
+ applied: [],
193
+ unchanged: [],
194
+ skipped: [],
195
+ changed: false,
196
+ reason: 'settings JSON is not an object',
197
+ };
198
+ }
199
+
200
+ if (!settings.env || typeof settings.env !== 'object' || Array.isArray(settings.env)) {
201
+ settings.env = {};
202
+ }
203
+
204
+ const applied = [];
205
+ const unchanged = [];
206
+ const skipped = [];
207
+ let mutated = false;
208
+
209
+ for (const [key, defaultValue] of Object.entries(GATEWAY_RESILIENCE_ENV_DEFAULTS)) {
210
+ if (!(key in settings.env)) {
211
+ settings.env[key] = defaultValue;
212
+ applied.push(key);
213
+ mutated = true;
214
+ continue;
215
+ }
216
+ const currentValue = settings.env[key];
217
+ if (typeof currentValue === 'string' && currentValue === defaultValue) {
218
+ unchanged.push(key);
219
+ continue;
220
+ }
221
+ // User owns a non-default value — never touch it.
222
+ skipped.push(key);
223
+ }
224
+
225
+ if (!mutated) {
226
+ return {
227
+ ...detection,
228
+ applied,
229
+ unchanged,
230
+ skipped,
231
+ changed: false,
232
+ };
233
+ }
234
+
235
+ // Backup-then-write, same convention as `src/core/repairBrokenHooks.js`.
236
+ if (raw !== null) {
237
+ await fs.writeFile(backupPathFor(settingsPath), raw, 'utf8');
238
+ }
239
+ await fs.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
240
+
241
+ return {
242
+ ...detection,
243
+ applied,
244
+ unchanged,
245
+ skipped,
246
+ changed: true,
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Human-readable lines for the install report. Empty when nothing was applied and there
252
+ * is no gateway to report on.
253
+ */
254
+ export function formatGatewayResilienceReport(report) {
255
+ if (!report) {
256
+ return [];
257
+ }
258
+
259
+ const lines = [];
260
+
261
+ if (!report.customGateway) {
262
+ lines.push('No custom gateway detected (ANTHROPIC_BASE_URL unset in env, project, and home settings).');
263
+ return lines;
264
+ }
265
+
266
+ const sourceLabel = report.source === 'env'
267
+ ? 'environment'
268
+ : 'Claude Code settings';
269
+ lines.push(
270
+ `Custom gateway detected (${sourceLabel}: ${report.baseUrl}). Writing resilience env defaults to project .claude/settings.json.`,
271
+ );
272
+
273
+ for (const key of report.applied || []) {
274
+ lines.push(` - ${key} = ${JSON.stringify(GATEWAY_RESILIENCE_ENV_DEFAULTS[key])} (applied)`);
275
+ }
276
+ for (const key of report.unchanged || []) {
277
+ lines.push(` - ${key} = ${JSON.stringify(GATEWAY_RESILIENCE_ENV_DEFAULTS[key])} (already set)`);
278
+ }
279
+ for (const key of report.skipped || []) {
280
+ lines.push(` - ${key} = <user value preserved>`);
281
+ }
282
+
283
+ if (report.changed) {
284
+ lines.push('Backup: .claude/settings.json.ukit-backup');
285
+ }
286
+
287
+ if (report.reason) {
288
+ lines.push(`Note: ${report.reason}`);
289
+ }
290
+
291
+ return lines;
292
+ }
@@ -13,6 +13,7 @@ import { writeInstallMetadata } from './metadata.js';
13
13
  import { cleanupLegacyPaths, migrateLegacyRuntimeRoot } from './migrateLegacy.js';
14
14
  import { ensureGitignore } from './ensureGitignore.js';
15
15
  import { repairBrokenHooks } from './repairBrokenHooks.js';
16
+ import { applyGatewayResilienceEnv } from './gatewayResilienceEnv.js';
16
17
  import { cleanupEmptyParents, readJsonIfExists, removeFileOrLinkOnly, resolveProjectRelativePath } from './fileOps.js';
17
18
 
18
19
  const AUTO_PRUNE_OBSOLETE_PREFIXES = [
@@ -324,6 +325,14 @@ export async function runInstallPipeline({
324
325
  // install) clears it for them. Backups are written next to each settings file.
325
326
  const hookRepair = await repairBrokenHooks({ projectRoot: pathConfig.projectRoot });
326
327
 
328
+ // If the project is pointed at any custom gateway (env, project settings, or home
329
+ // settings), write two managed env defaults that keep Claude Code from stalling on
330
+ // non-streaming fallback and idle timeouts. Re-running install is a no-op once the
331
+ // keys are in place.
332
+ const gatewayResilience = await applyGatewayResilienceEnv({
333
+ projectRoot: pathConfig.projectRoot,
334
+ });
335
+
327
336
  await pruneObsoleteManagedPaths({
328
337
  installMetaData,
329
338
  projectRoot: pathConfig.projectRoot,
@@ -359,6 +368,7 @@ export async function runInstallPipeline({
359
368
  rows: toDiffRows(diffResults),
360
369
  writes,
361
370
  hookRepair,
371
+ gatewayResilience,
362
372
  };
363
373
  }
364
374
 
@@ -371,5 +381,6 @@ export async function runInstallPipeline({
371
381
  rows: toDiffRows(diffResults),
372
382
  writes: [],
373
383
  hookRepair: { removals: [], files: [] },
384
+ gatewayResilience: { customGateway: false, reason: 'dry-run' },
374
385
  };
375
386
  }
@@ -0,0 +1,241 @@
1
+ // taskBudgetValidator.js — TASK-014 / Cycle C12
2
+ //
3
+ // Pure validator + dumb table-driven verification-minutes estimator. Turns the
4
+ // planner's "needs_breakdown" prose rule into a measurable gate that runs BEFORE
5
+ // any task is marked `ready`.
6
+ //
7
+ // Exports:
8
+ // DEFAULT_CRITERIA — frozen thresholds used by `validateTaskFile` by default.
9
+ // VERIFICATION_MINUTE_TABLE — frozen literal table for `yarn test:release-core` and
10
+ // `node scripts/release/verify-release.mjs`. `yarn vitest run`
11
+ // is NOT in the table — its cost is computed dynamically
12
+ // (0.5 per file argument), see `estimateVerificationMinutes`.
13
+ // estimateVerificationMinutes — sums per-command minutes over a verification list.
14
+ // validateTaskFile — parses a TASK-xxx.md and returns
15
+ // `{ verdict, reasons: [{ criterion, detail }] }`.
16
+ //
17
+ // Criteria (strictly-greater violation rule):
18
+ // - target-file-count : bullet lines under `## Target Files`
19
+ // - test-case-count : table rows under `## Test Cases` (excluding header + separator)
20
+ // - verification-minutes : `estimateVerificationMinutes` over commands in
21
+ // the first ```bash fence under `## Verification Commands`
22
+ // - investigate-first : `## Goal` body contains both /investigate/i and /first/i
23
+ // - missing-field : required section(s) absent (Goal, Test Cases,
24
+ // Verification Commands) — empty / random markdown must
25
+ // never crash.
26
+ //
27
+ // Parsing is deliberately markdown-light and dependency-free so the validator can be
28
+ // duplicated byte-for-byte into the shipped CLI twin (templates/.claude/ukit/index/
29
+ // task-budget-validator.mjs) without dragging `src/` into user installs.
30
+
31
+ export const DEFAULT_CRITERIA = Object.freeze({
32
+ maxTargetFiles: 3,
33
+ maxTestCases: 8,
34
+ maxVerificationMinutes: 10,
35
+ });
36
+
37
+ export const VERIFICATION_MINUTE_TABLE = Object.freeze({
38
+ 'yarn test:release-core': 3,
39
+ 'node scripts/release/verify-release.mjs': 2,
40
+ });
41
+
42
+ // Per-command minute estimate. `yarn vitest run` → 0.5 per FILE argument
43
+ // (i.e. per non-flag token after `run`); anything else falls back to 1 flat.
44
+ export function estimateVerificationMinutes(commands) {
45
+ if (!Array.isArray(commands)) return 0;
46
+ let total = 0;
47
+ for (const raw of commands) {
48
+ const cmd = String(raw || '').trim();
49
+ if (!cmd) continue;
50
+ if (Object.prototype.hasOwnProperty.call(VERIFICATION_MINUTE_TABLE, cmd)) {
51
+ total += VERIFICATION_MINUTE_TABLE[cmd];
52
+ continue;
53
+ }
54
+ if (/^yarn\s+vitest(\s+run)?\b/.test(cmd)) {
55
+ // 0.5 minutes per non-flag argument after `run` (or after `yarn vitest`).
56
+ const tokens = cmd.split(/\s+/);
57
+ const runIdx = tokens.indexOf('run');
58
+ const tail = runIdx >= 0 ? tokens.slice(runIdx + 1) : tokens.slice(2);
59
+ const fileArgs = tail.filter((t) => !t.startsWith('-') && t.length > 0);
60
+ total += fileArgs.length * 0.5;
61
+ continue;
62
+ }
63
+ total += 1;
64
+ }
65
+ // Round to 1 decimal place so the summary reason is human-friendly.
66
+ return Math.round(total * 10) / 10;
67
+ }
68
+
69
+ // Section helpers. We treat any line starting with `## ` as a new section header.
70
+ // Section bodies run until the next `## ` (or EOF).
71
+ function splitSections(markdown) {
72
+ const lines = String(markdown || '').split('\n');
73
+ const sections = new Map(); // heading text → body lines (without the heading line)
74
+ let currentHeading = null;
75
+ let buffer = [];
76
+ for (const line of lines) {
77
+ const m = /^##\s+(.+?)\s*$/.exec(line);
78
+ if (m) {
79
+ if (currentHeading !== null) sections.set(currentHeading, buffer);
80
+ currentHeading = m[1].trim();
81
+ buffer = [];
82
+ } else if (currentHeading !== null) {
83
+ buffer.push(line);
84
+ }
85
+ }
86
+ if (currentHeading !== null) sections.set(currentHeading, buffer);
87
+ return sections;
88
+ }
89
+
90
+ function getSectionBody(sections, name) {
91
+ if (!sections.has(name)) return null;
92
+ return sections.get(name).join('\n');
93
+ }
94
+
95
+ // Count bullets under `## Target Files` — lines whose first non-whitespace char is `- `
96
+ function countTargetFiles(body) {
97
+ if (body == null) return 0;
98
+ const lines = body.split('\n');
99
+ let count = 0;
100
+ for (const line of lines) {
101
+ if (/^\s*-\s+\S/.test(line)) count += 1;
102
+ }
103
+ return count;
104
+ }
105
+
106
+ // Count test-case rows under `## Test Cases`. The header row `| # | Loại | ...` and the
107
+ // separator row `|---|-----|...` are both skipped.
108
+ function countTestCases(body) {
109
+ if (body == null) return 0;
110
+ const lines = body.split('\n');
111
+ let count = 0;
112
+ let sawHeader = false;
113
+ let sawSeparator = false;
114
+ for (const line of lines) {
115
+ const trimmed = line.trim();
116
+ if (!trimmed.startsWith('|')) continue;
117
+ if (!sawHeader) {
118
+ sawHeader = true;
119
+ continue;
120
+ }
121
+ if (!sawSeparator && /^\|[-\s|]+\|\s*$/.test(trimmed)) {
122
+ sawSeparator = true;
123
+ continue;
124
+ }
125
+ count += 1;
126
+ }
127
+ return count;
128
+ }
129
+
130
+ // Extract the first ```bash ... ``` fence under `## Verification Commands` and split
131
+ // into non-empty lines.
132
+ function extractVerificationCommands(body) {
133
+ if (body == null) return [];
134
+ const lines = body.split('\n');
135
+ const fenceStart = /^```\s*bash\s*$/;
136
+ const fenceEnd = /^```\s*$/;
137
+ let inFence = false;
138
+ const out = [];
139
+ for (const line of lines) {
140
+ if (!inFence) {
141
+ if (fenceStart.test(line.trim())) inFence = true;
142
+ continue;
143
+ }
144
+ if (fenceEnd.test(line.trim())) break;
145
+ out.push(line);
146
+ }
147
+ return out.map((l) => l.trim()).filter(Boolean);
148
+ }
149
+
150
+ // Trim the Goal body — strip code fences, blank lines, leading whitespace noise so
151
+ // the investigate/first regex sees prose only.
152
+ function goalText(body) {
153
+ if (body == null) return '';
154
+ return body
155
+ .split('\n')
156
+ .map((l) => l.replace(/^#+\s*/, '').trim())
157
+ .filter(Boolean)
158
+ .join(' ');
159
+ }
160
+
161
+ export function validateTaskFile(markdown, criteria = DEFAULT_CRITERIA) {
162
+ const reasons = [];
163
+
164
+ // Defensive: any non-string input is treated as empty markdown. Validator must never
165
+ // throw — it's an advisory gate that downstream agents and the shipped CLI both rely on.
166
+ const text = typeof markdown === 'string' ? markdown : '';
167
+ const sections = splitSections(text);
168
+
169
+ const goalBody = getSectionBody(sections, 'Goal');
170
+ const targetBody = getSectionBody(sections, 'Target Files');
171
+ const testBody = getSectionBody(sections, 'Test Cases');
172
+ const verifyBody = getSectionBody(sections, 'Verification Commands');
173
+
174
+ // missing-field — only the sections that carry real gate semantics are required.
175
+ // Acceptance / Test Files are not gate-bearing.
176
+ const requiredSections = [
177
+ ['Goal', goalBody],
178
+ ['Test Cases', testBody],
179
+ ['Verification Commands', verifyBody],
180
+ ];
181
+ for (const [name, body] of requiredSections) {
182
+ if (body == null || body.trim() === '') {
183
+ reasons.push({
184
+ criterion: 'missing-field',
185
+ detail: `required section \`## ${name}\` is missing or empty`,
186
+ });
187
+ }
188
+ }
189
+
190
+ // target-file-count
191
+ if (targetBody != null) {
192
+ const n = countTargetFiles(targetBody);
193
+ if (n > criteria.maxTargetFiles) {
194
+ reasons.push({
195
+ criterion: 'target-file-count',
196
+ detail: `${n} target files exceeds maxTargetFiles=${criteria.maxTargetFiles}`,
197
+ });
198
+ }
199
+ }
200
+
201
+ // test-case-count
202
+ if (testBody != null) {
203
+ const n = countTestCases(testBody);
204
+ if (n > criteria.maxTestCases) {
205
+ reasons.push({
206
+ criterion: 'test-case-count',
207
+ detail: `${n} test cases exceeds maxTestCases=${criteria.maxTestCases}`,
208
+ });
209
+ }
210
+ }
211
+
212
+ // verification-minutes — only count when we actually have commands to score.
213
+ if (verifyBody != null) {
214
+ const commands = extractVerificationCommands(verifyBody);
215
+ if (commands.length > 0) {
216
+ const minutes = estimateVerificationMinutes(commands);
217
+ if (minutes > criteria.maxVerificationMinutes) {
218
+ reasons.push({
219
+ criterion: 'verification-minutes',
220
+ detail: `estimated ${minutes} verification minutes exceeds maxVerificationMinutes=${criteria.maxVerificationMinutes}`,
221
+ });
222
+ }
223
+ }
224
+ }
225
+
226
+ // investigate-first — regex pair, per Discussion 2026-09-12 (intentionally dumb).
227
+ if (goalBody != null) {
228
+ const prose = goalText(goalBody);
229
+ if (/\binvestigate\b/i.test(prose) && /\bfirst\b/i.test(prose)) {
230
+ reasons.push({
231
+ criterion: 'investigate-first',
232
+ detail: 'Goal contains both "investigate" and "first" — split the task into a spike + a build step',
233
+ });
234
+ }
235
+ }
236
+
237
+ return {
238
+ verdict: reasons.length === 0 ? 'ok' : 'needs_breakdown',
239
+ reasons,
240
+ };
241
+ }