@ngockhoale/ukit 3.4.60 → 3.4.62

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,23 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.4.62 - 2026-10-22
6
+
7
+ **Handoff commands: state what `handoff.maxParallelAgents` counts (wording only).**
8
+ - The cap is child workers of ONE batch or review set, summed across roles (implementers and reviewers together); the main session is outside it; it is not a session-wide limit; it is prompt-level guidance (no hook counts children) and the host's own concurrency limit is separate. Added to `handoff-implement`, `handoff-fullstack` (implement batch + review set) and `handoff-review`.
9
+ - No default, config key, scope or behavior change. Basis: BAGuide field audit (peak 14 concurrent children while the cap text said 2, never refused), see `docs/analysis/CONCURRENCY-CHECKPOINT-2026-10-22.md`.
10
+
11
+ ## 3.4.61 - 2026-10-22
12
+
13
+ **omp bash: stop sending the call shape the host rejects (`name` + `async`/`timeout`).**
14
+ - Field evidence (BAGuide session log, 2026-10-19..22): 1,771 of 6,541 bash calls failed with "Service mode does not accept async or timeout; use ready.timeout for readiness." Every failing call carried `name` with `async`/`timeout`; calls without `name` never failed and 66 immediate retries that dropped `name` succeeded. `name` was used as a label on finite commands.
15
+ - Host contract (omp 18.12.5 bash `execute`, read from the installed binary): `name` selects SERVICE mode, which rejects `async === true` and any `timeout`; readiness is `ready.timeout`. Without `name`, `async`/`timeout` are valid and `ready` is only ignored with a warning.
16
+ - Change: `omp-bash-call-guard.mjs` (block-only, stateless, never rewrites input or changes a command's meaning) wired into the omp bridge `tool_call` for `bash`; the block reason separates the two intents (finite command: drop `name`/`ready`; service: drop `async`/`timeout`, use `ready.timeout`). Same rule added to the Stall & Wait instructions (STALL-02) for all three engines.
17
+ - Live smoke (omp 18.12.5, isolated temp project with the template bridge, model `unic/unic-lite`, BAGuide and the omp binary untouched): `name`+`timeout` was blocked with the UKit preflight text (not the host's "Service mode..." text), so the `tool_call` hook runs before the host validator; a finite command ran; the corrected retry (name dropped) ran and was not blocked. NOT verified live: a genuine service call (the test model kept adding a top-level `timeout`; one attempt hung with no output and was stopped; no process was left running). The service-allowed path is covered only by the bridge-boundary tests. Guidance, not host enforcement.
18
+ - Flaky test root cause + fix: `withTransientFsRetry` compared `Date.now()` against a deadline while sleeping with `setTimeout`; node timers can fire up to ~2% early at small delays, so after a clamped sleep the clock still showed time remaining and an extra attempt ran. It now measures with `performance.now()` (injectable `now`/`sleep`) and stops after a sleep that exhausted the deadline. Regression: `tests/hooks/transientFsRetry.test.js` (controlled clock incl. early-timer skew, plus one real-timer smoke); 30/30 repeated runs.
19
+ - Review freshness through a real Bash mutation (git fixture, real PostToolUse receipt): review approved -> `sed -i` via Bash -> `changeSeq` advances, review is `review-stale`, verification is `edited-after-verification`; a no-op Bash keeps the approval. No defect reproduced; regression `tests/hooks/playbookReviewBashMutation.test.js`.
20
+ - Regression: `tests/hooks/ompBashCallGuard.test.js` (normal finite command with timeout/async allowed; genuine service with `ready.timeout` allowed; `name`+`timeout` / `name`+`async` blocked repeatably; blank `name` is not service mode; input never mutated).
21
+
5
22
  ## 3.4.60 - 2026-10-22
6
23
 
7
24
  **Completion Gate: a background completion footer is no longer trusted on its own.**
@@ -451,6 +451,17 @@ entries:
451
451
  validation: [manual]
452
452
  archive_policy: replace-with-snapshot
453
453
  notes: Phase A read-only concurrency audit; provisional metrics labelled, pilot not run
454
+ - id: docs-analysis-concurrency-checkpoint
455
+ path: docs/analysis/CONCURRENCY-CHECKPOINT-2026-10-22.md
456
+ class: canonical
457
+ audience: [owner, maintainer]
458
+ owner: runtime
459
+ source_of_truth: docs/analysis/CONCURRENCY-CHECKPOINT-2026-10-22.md
460
+ merge_strategy: none
461
+ load_policy: on-demand
462
+ validation: [manual]
463
+ archive_policy: replace-with-snapshot
464
+ notes: continuity checkpoint for the concurrency audit (R6 corrections, bash guard, prepared unpaid pilot)
454
465
  - id: docs-analysis-subagent-cost-audit
455
466
  path: docs/analysis/SUBAGENT-COST-AUDIT.md
456
467
  class: archive
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.60",
3
+ "version": "3.4.62",
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",
@@ -446,7 +446,7 @@ if it is missing required fields — never merely for sharing a file.
446
446
  **Batch each wave — mandatory.** Read `handoff.maxParallelAgents` from
447
447
  `.ukit/storage/config.json`. A wave with more tasks than that is split
448
448
  into consecutive batches of at most that many; finish one batch completely (including 3c
449
- copy-back and worktree deletion) before starting the next.
449
+ copy-back and worktree deletion) before starting the next. The cap counts child workers of ONE batch/review set, summed across roles (implementers and reviewers together); the main session is outside it, and it is not a session-wide limit. It is prompt-level guidance: no hook counts children, and the host's own concurrency limit is separate.
450
450
 
451
451
  Never spawn a whole wide wave at once. Every background agent carries its own context
452
452
  window, and each finished report is injected back into this session — a wave of 20+ is
@@ -659,7 +659,7 @@ Never make fake edits or fake tests to satisfy an empty diff, and never lower ac
659
659
 
660
660
  ### R2 — Model isolation check (strong model, always first)
661
661
 
662
- **Batch the review set — mandatory.** Read `handoff.maxParallelAgents` from `.ukit/storage/config.json`. If more `pending_review` tasks exist than that, split into consecutive batches of at most that many; finish one batch's verdicts (R2–R4, appended to each task file) before starting the next.
662
+ **Batch the review set — mandatory.** Read `handoff.maxParallelAgents` from `.ukit/storage/config.json`. If more `pending_review` tasks exist than that, split into consecutive batches of at most that many; finish one batch's verdicts (R2–R4, appended to each task file) before starting the next. The cap counts child workers of ONE batch/review set, summed across roles (implementers and reviewers together); the main session is outside it, and it is not a session-wide limit. It is prompt-level guidance: no hook counts children, and the host's own concurrency limit is separate.
663
663
 
664
664
  **Claude Code — MANDATORY, do this before anything else in R2–R4:** for each batch, call the Agent tool once per `pending_review` task **in parallel** (at most `handoff.maxParallelAgents`), each with `subagent_type: "code-reviewer"` (omp: the `task` tool with `agent: "code-reviewer"`, likewise invoked once per task in the batch, not once per wave). Reviewer agents only read the diff and append a verdict to their own task file — no worktree, no shared write target — so running them in parallel carries none of Phase 3's file-conflict risk. Do NOT review the diff yourself in the current session — this step is contracted to the strong tier (opus/unic-smart) and MUST differ from the executor's model, which only the spawned agent's frontmatter model guarantees.
665
665
 
@@ -103,7 +103,7 @@ when it is missing required fields — never merely for sharing a file.
103
103
  Read `handoff.maxParallelAgents` from `.ukit/storage/config.json`. A wave
104
104
  with more tasks than that is split into consecutive batches of at most that many; finish
105
105
  one batch completely (including 3c copy-back and worktree deletion) before starting the
106
- next.
106
+ next. The cap counts child workers of ONE batch/review set, summed across roles (implementers and reviewers together); the main session is outside it, and it is not a session-wide limit. It is prompt-level guidance: no hook counts children, and the host's own concurrency limit is separate.
107
107
 
108
108
  Never spawn a whole wide wave at once. Every background agent carries its own context
109
109
  window, and each finished report is injected back into this session — a wave of 20+ is
@@ -53,7 +53,7 @@ Classify by what the diff can show. An empty diff is NOT automatically "implemen
53
53
 
54
54
  ## Step 2 — Review the diff
55
55
 
56
- **Batch the review set — mandatory.** Read `handoff.maxParallelAgents` from `.ukit/storage/config.json`. If more `pending_review` tasks exist than that, split into consecutive batches of at most that many; finish one batch's verdicts (2a–2d, appended to each task file) before starting the next.
56
+ **Batch the review set — mandatory.** Read `handoff.maxParallelAgents` from `.ukit/storage/config.json`. If more `pending_review` tasks exist than that, split into consecutive batches of at most that many; finish one batch's verdicts (2a–2d, appended to each task file) before starting the next. The cap counts child workers of ONE batch/review set, summed across roles (implementers and reviewers together); the main session is outside it, and it is not a session-wide limit. It is prompt-level guidance: no hook counts children, and the host's own concurrency limit is separate.
57
57
 
58
58
  **Spawn the review panel — MANDATORY, do this before anything else:** read `modelRoles['review-panel']` from `.ukit/storage/config.json` → `modelRoles` (default `['smart','code','lite']`; omp tier aliases `@smol`/`@default`/`@slow`). For each `pending_review` task in the batch, call the Agent tool **once per panel entry** (in parallel, bounded by `handoff.maxParallelAgents`), each with `subagent_type: "code-reviewer"` (omp: the `task` tool with `agent: "code-reviewer"`, likewise invoked once per panel entry per task) — each member bound to its panel tier and setting `PANEL_MEMBER: <tier>` in its verdict. Reviewer agents only read the diff and append a verdict to their own task file — no worktree, no shared write target — so running them in parallel carries none of Phase 3's file-conflict risk. Do NOT review the diff yourself in the current session — this step is contracted to the panel tiers and MUST differ from the executor's model, which only the spawned agent's frontmatter model guarantees. Pass each agent: the task file path, the executor's report, and the diff.
59
59
 
@@ -113,7 +113,10 @@ export const TRANSIENT_FS_CODES = new Set(['EAGAIN', 'EBUSY', 'EMFILE', 'ENFILE'
113
113
  * maxDelayMs?: number,
114
114
  * deadlineMs?: number,
115
115
  * signal?: AbortSignal,
116
- * }} [options]
116
+ * now?: () => number,
117
+ * sleep?: (ms: number, signal?: AbortSignal) => Promise<boolean>,
118
+ * }} [options] `now` (monotonic ms) and `sleep` (resolves true when it elapsed fully)
119
+ * are injectable so deadline semantics can be tested without wall-clock timing.
117
120
  * @returns {Promise<*>} op's resolved value
118
121
  */
119
122
  export async function withTransientFsRetry(op, {
@@ -122,8 +125,10 @@ export async function withTransientFsRetry(op, {
122
125
  maxDelayMs = 150,
123
126
  deadlineMs = Infinity,
124
127
  signal,
128
+ now = () => performance.now(),
129
+ sleep = sleepWithAbort,
125
130
  } = {}) {
126
- const startedAt = Date.now();
131
+ const startedAt = now();
127
132
  const maxAttempts = Number.isFinite(retries) && retries >= 0 ? Math.floor(retries) + 1 : 1;
128
133
  let lastError = null;
129
134
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
@@ -135,12 +140,18 @@ export async function withTransientFsRetry(op, {
135
140
  } catch (error) {
136
141
  lastError = error;
137
142
  if (!TRANSIENT_FS_CODES.has(error?.code) || attempt + 1 >= maxAttempts) throw error;
138
- const remaining = deadlineMs - (Date.now() - startedAt);
143
+ const remaining = deadlineMs - (now() - startedAt);
139
144
  if (remaining <= 0) throw error;
140
145
  const backoff = Math.min(baseDelayMs * (2 ** attempt), maxDelayMs);
141
- const waitMs = Math.min(backoff * (0.5 + Math.random()), remaining);
142
- const sleptFully = await sleepWithAbort(Math.max(0, waitMs), signal);
143
- if (!sleptFully || Date.now() - startedAt >= deadlineMs) throw error;
146
+ const wanted = backoff * (0.5 + Math.random());
147
+ const waitMs = Math.min(wanted, remaining);
148
+ // A sleep clamped to `remaining` consumes the whole deadline BY CONSTRUCTION. Re-reading a
149
+ // clock afterwards is wrong: timers (libuv, ms-truncated) and the clock can disagree by a
150
+ // millisecond, so a fired 5 ms timer can read as 4 ms elapsed and grant one extra attempt
151
+ // past the deadline (observed: setTimeout(5) woke with Date.now() +4 ms in ~0.4% of runs).
152
+ const exhaustsDeadline = wanted >= remaining;
153
+ const sleptFully = await sleep(Math.max(0, waitMs), signal);
154
+ if (!sleptFully || exhaustsDeadline || now() - startedAt >= deadlineMs) throw error;
144
155
  }
145
156
  }
146
157
  throw lastError;
@@ -0,0 +1,53 @@
1
+ // omp-bash-call-guard.mjs — block-only preflight for OMP `bash` call shape.
2
+ //
3
+ // Field evidence (BAGuide, 2026-10-19..22): 1,771 of 6,541 bash calls failed with
4
+ // "Service mode does not accept async or timeout; use ready.timeout for readiness."
5
+ // Every failing call carried `name` (the supervised-SERVICE mode) together with `async`
6
+ // or `timeout`; calls without `name` never failed, and 66 immediate retries that dropped
7
+ // `name` succeeded. The agent used `name` as a label on an ordinary finite command.
8
+ //
9
+ // Host contract (omp 18.12.5, bash tool `execute`, read from the binary):
10
+ // - `name` non-empty => service mode. `async === true` or any `timeout` is rejected;
11
+ // readiness is `ready.timeout` (seconds) with `ready.log` / `ready.port`.
12
+ // - no `name` => ordinary command; `async` / `timeout` are valid; `ready` is
13
+ // ignored with a warning (not an error), so it is not validated here.
14
+ //
15
+ // This guard blocks only the combination the host is known to reject, with guidance that
16
+ // separates the two intents. It never rewrites input and never changes the meaning of a
17
+ // command — choosing finite vs service is the caller's job. The host owns the schema; this
18
+ // is a tolerant, version-pinned mirror of one rule. It can only act where the host runs the
19
+ // tool_call hook before its own validator; where the host validates first, the native
20
+ // error is the only signal and this guard cannot intervene (guidance in RULES.md still applies).
21
+
22
+ /**
23
+ * @param {object} args - bash tool input
24
+ * @returns {{ valid: boolean, violations: Array<{code: string, field: string}> }}
25
+ */
26
+ export function validateOmpBashArgs(args) {
27
+ const violations = [];
28
+ if (!args || typeof args !== 'object' || Array.isArray(args)) return { valid: true, violations };
29
+ const name = typeof args.name === 'string' ? args.name.trim() : '';
30
+ if (name === '') return { valid: true, violations };
31
+ if (args.async === true) violations.push({ code: 'service-with-async', field: 'async' });
32
+ if (args.timeout !== undefined && args.timeout !== null) {
33
+ violations.push({ code: 'service-with-timeout', field: 'timeout' });
34
+ }
35
+ return { valid: violations.length === 0, violations };
36
+ }
37
+
38
+ /**
39
+ * @param {object} args - bash tool input
40
+ * @returns {{ action: 'allow' } | { action: 'block', violations: object[], guidance: string }}
41
+ */
42
+ export function preflightOmpBashCall(args) {
43
+ const { valid, violations } = validateOmpBashArgs(args);
44
+ if (valid) return { action: 'allow' };
45
+ const fields = violations.map((v) => `\`${v.field}\``).join(' + ');
46
+ const guidance = [
47
+ `omp bash: \`name\` selects SERVICE mode, which rejects ${fields}. Do not resend this invocation unchanged.`,
48
+ 'Pick one intent:',
49
+ '- Ordinary finite command (tests, build, one-shot script): remove `name` and `ready`; keep `timeout` / `async` as needed. `name` is not a label.',
50
+ '- Genuine long-running service/watcher: keep `name`, remove `async` and `timeout`, and set readiness with `ready: { log|port, timeout }` (seconds). Inspect it via `read proc://<name>`.',
51
+ ].join('\n');
52
+ return { action: 'block', violations, guidance };
53
+ }
@@ -30,6 +30,7 @@ import {
30
30
  // for Edit|Write is a fail-closed transport failure = every edit blocked).
31
31
  import { resolveChainExecTimeoutMs } from '../../../.claude/ukit/runtime/hook-chain-budget.mjs';
32
32
  import { preflightOmpTaskDispatch } from '../../../.claude/ukit/runtime/omp-task-dispatch-guard.mjs';
33
+ import { preflightOmpBashCall } from '../../../.claude/ukit/runtime/omp-bash-call-guard.mjs';
33
34
 
34
35
  // OMP-3 (TASK-002): pi.exec forwards only {cwd, signal, timeout} to its spawn —
35
36
  // an environment option is dropped (verified against the bundled omp exec), and the host's
@@ -929,6 +930,15 @@ export async function runToolCall(pi, event, { projectRoot, context: extensionCo
929
930
  reason: `UKit OMP task preflight: ${dispatch.guidance}` };
930
931
  }
931
932
  }
933
+ // omp bash: `name` = service mode, which the host rejects together with async/timeout.
934
+ // Block-only (the host return cannot rewrite input); stateless, so a corrected call is allowed.
935
+ if (String(rawToolName ?? '').trim().toLowerCase() === 'bash') {
936
+ const bashCall = preflightOmpBashCall(event.input);
937
+ if (bashCall.action === 'block') {
938
+ return { block: true, toolName: 'Bash', invoked: [], context: [],
939
+ reason: `UKit OMP bash preflight: ${bashCall.guidance}` };
940
+ }
941
+ }
932
942
  const payload = buildHookPayload('PreToolUse', {
933
943
  toolName,
934
944
  toolInput: normalizeToolInput(toolName, event.input),
@@ -29,7 +29,7 @@
29
29
  <!-- RULE: STALL-01 -->
30
30
  - Announce long waits: `Waiting for API response / tool result — will keep retrying; check your network/API provider if this persists`. Surface errors verbatim; suspect network/API — **never blame UKit**.
31
31
  <!-- RULE: STALL-02 -->
32
- - Stall rules extend EXEC-04 (every stop says why); they do not replace it. Retry guidance applies only to transient network/API failures, never deterministic invalid arguments or unsupported capabilities (e.g. fork + remote isolation): do not repeat the same call; choose a supported approach within user constraints or report the limitation.
32
+ - Stall rules extend EXEC-04 (every stop says why); they do not replace it. Retry guidance applies only to transient network/API failures, never deterministic invalid arguments or unsupported capabilities (e.g. fork + remote isolation): do not repeat the same call; choose a supported approach within user constraints or report the limitation. omp `bash`: `name` = supervised SERVICE (rejects `async`/`timeout`; readiness = `ready.timeout`) — finite commands omit `name`/`ready`; after a "Service mode does not accept async or timeout" error drop `name` (finite) or drop `async`/`timeout` (service). UKit's omp bridge blocks that combination where the host runs hooks first; otherwise this is guidance only.
33
33
  <!-- RULE: STALL-03 -->
34
34
  - **Conditional workaround only:** one tool call per assistant message only when malformed/concatenated tool-input errors are observed or the harness is known-affected — healthy hosts keep parallel calls.
35
35
 
@@ -29,7 +29,7 @@
29
29
  <!-- RULE: STALL-01 -->
30
30
  - Announce long waits: `Waiting for API response / tool result — will keep retrying; check your network/API provider if this persists`. Surface errors verbatim; suspect network/API — **never blame UKit**.
31
31
  <!-- RULE: STALL-02 -->
32
- - Stall rules extend EXEC-04 (every stop says why); they do not replace it. Retry guidance applies only to transient network/API failures, never deterministic invalid arguments or unsupported capabilities (e.g. fork + remote isolation): do not repeat the same call; choose a supported approach within user constraints or report the limitation.
32
+ - Stall rules extend EXEC-04 (every stop says why); they do not replace it. Retry guidance applies only to transient network/API failures, never deterministic invalid arguments or unsupported capabilities (e.g. fork + remote isolation): do not repeat the same call; choose a supported approach within user constraints or report the limitation. omp `bash`: `name` = supervised SERVICE (rejects `async`/`timeout`; readiness = `ready.timeout`) — finite commands omit `name`/`ready`; after a "Service mode does not accept async or timeout" error drop `name` (finite) or drop `async`/`timeout` (service). UKit's omp bridge blocks that combination where the host runs hooks first; otherwise this is guidance only.
33
33
  <!-- RULE: STALL-03 -->
34
34
  - **Conditional workaround only:** one tool call per assistant message only when malformed/concatenated tool-input errors are observed or the harness is known-affected — healthy hosts keep parallel calls.
35
35
 
@@ -26,7 +26,7 @@
26
26
  <!-- RULE: STALL-01 -->
27
27
  - Announce long waits: `Waiting for API response / tool result — will keep retrying; check your network/API provider if this persists`. Surface errors verbatim; suspect network/API — **never blame UKit**.
28
28
  <!-- RULE: STALL-02 -->
29
- - Stall rules extend EXEC-04 (every stop says why); they do not replace it. Retry guidance applies only to transient network/API failures, never deterministic invalid arguments or unsupported capabilities (e.g. fork + remote isolation): do not repeat the same call; choose a supported approach within user constraints or report the limitation.
29
+ - Stall rules extend EXEC-04 (every stop says why); they do not replace it. Retry guidance applies only to transient network/API failures, never deterministic invalid arguments or unsupported capabilities (e.g. fork + remote isolation): do not repeat the same call; choose a supported approach within user constraints or report the limitation. omp `bash`: `name` = supervised SERVICE (rejects `async`/`timeout`; readiness = `ready.timeout`) — finite commands omit `name`/`ready`; after a "Service mode does not accept async or timeout" error drop `name` (finite) or drop `async`/`timeout` (service). UKit's omp bridge blocks that combination where the host runs hooks first; otherwise this is guidance only.
30
30
  <!-- RULE: STALL-03 -->
31
31
  - **Conditional workaround only:** one tool call per assistant message only when malformed/concatenated tool-input errors are observed or the harness is known-affected — healthy hosts keep parallel calls.
32
32