@ngockhoale/ukit 3.4.21 → 3.4.23

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 (26) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/manifests/documentation.yaml +11 -0
  3. package/manifests/platform.full.yaml +42 -0
  4. package/package.json +1 -1
  5. package/src/core/docContracts.js +60 -6
  6. package/src/core/hookChainDoctor.js +39 -3
  7. package/template_project/.claude/.gitattributes +2 -0
  8. package/template_project/.claude/agents/feature-implementer.md +10 -3
  9. package/template_project/.claude/commands/ukit/handoff-clear.md +27 -12
  10. package/template_project/.claude/commands/ukit/handoff-fullstack.md +65 -32
  11. package/template_project/.claude/commands/ukit/handoff-implement.md +44 -23
  12. package/template_project/.claude/hooks/handoff-resume.sh +36 -11
  13. package/template_project/.claude/hooks/protect-files.sh +10 -2
  14. package/template_project/.claude/hooks/vision-router.sh +11 -1
  15. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +418 -0
  16. package/template_project/.claude/ukit/index/extract-image.mjs +77 -22
  17. package/template_project/.claude/ukit/index/land-worktree.mjs +543 -0
  18. package/template_project/.claude/ukit/index/stale-spec-check.mjs +48 -1
  19. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +86 -28
  20. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +143 -6
  21. package/template_project/.claude/ukit/runtime/hook-process.mjs +120 -4
  22. package/template_project/.claude/ukit/runtime/output-compression.mjs +46 -20
  23. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +16 -2
  24. package/template_project/.omp/agents/feature-implementer.md +10 -3
  25. package/template_project/.omp/hooks/pre/ukit-bridge.js +12 -0
  26. package/template_project/docs/UKIT_INTERNALS.md +14 -2
@@ -18,15 +18,29 @@
18
18
  // assumes group semantics.
19
19
  //
20
20
  // Contract (TASK-016 Interfaces):
21
- // runHookProcess({command, args, input, deadlineMs, maxBuffer, ...}) ->
22
- // Promise<{code, signal, stdout, stderr, failureKind, elapsedMs}>
21
+ // runHookProcess({command, args, input, deadlineMs, maxBuffer, spawnImpl, ...}) ->
22
+ // Promise<{code, signal, stdout, stderr, failureKind, elapsedMs, spawnFailed, errorCode}>
23
23
  // failureKind: 'none' | 'deadline' | 'signal' | 'overflow' | 'error'
24
24
  // 'deadline' — this runner killed the child because the deadline expired
25
25
  // 'signal' — the child died from a signal this runner did not send
26
26
  // 'overflow' — captured output exceeded maxBuffer; the tree was culled
27
27
  // 'error' — the child never spawned (ENOENT and friends)
28
+ // spawnFailed: true only when the process was NEVER started — derived from child
29
+ // event order (an 'error' with no preceding 'spawn' event, or spawn() throwing
30
+ // synchronously), never from timing. An 'error' after 'spawn' is not a spawn failure.
31
+ // errorCode: the error's `code` (ENOENT, EINVAL, EPIPE, ...) or null.
32
+ // spawnImpl: injectable spawn function (default child_process.spawn) for tests.
33
+ //
34
+ // Windows .sh launching (resolveHookCommand / resolveWindowsBash):
35
+ // Windows cannot exec a `.sh` file directly, so on win32 a `.sh` hook is launched
36
+ // through an ABSOLUTE Git Bash path (never a bare `bash`, which Windows would look up
37
+ // in the child cwd first). Script paths are passed with forward slashes. POSIX spawns
38
+ // the script directly, unchanged. UKIT_HOOK_SHELL_PLATFORM overrides the platform seen
39
+ // by resolveHookCommand only (tests/diagnostics); UKIT_BASH_BIN overrides the bash path.
28
40
 
29
41
  import { spawn } from 'node:child_process';
42
+ import fs from 'node:fs';
43
+ import path from 'node:path';
30
44
 
31
45
  // POSIX only. Windows kill() targets the direct child; process groups do not
32
46
  // exist there, so pretending otherwise would be a silent, wrong assumption.
@@ -41,6 +55,93 @@ export const KILL_GRACE_MS = 500;
41
55
  // runner past its own budget.
42
56
  const HARD_SETTLE_SLACK_MS = 2000;
43
57
 
58
+ // Error codes meaning "the launcher itself could not be started".
59
+ export const SPAWN_UNAVAILABLE_CODES = new Set([
60
+ 'ENOENT',
61
+ 'EINVAL',
62
+ 'EACCES',
63
+ 'EPERM',
64
+ 'ENOEXEC',
65
+ 'EFTYPE',
66
+ ]);
67
+
68
+ /** Windows path -> forward slashes, so bash's dirname/$0 logic works. */
69
+ export function toBashPath(p) {
70
+ return String(p).replace(/\\/g, '/');
71
+ }
72
+
73
+ const win32 = path.win32;
74
+
75
+ function safeExists(exists, candidate) {
76
+ try {
77
+ return Boolean(exists(candidate));
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Locate Git Bash on Windows. Returns an absolute path or null — never a bare
85
+ * or relative name (Windows resolves bare names in the child cwd first, which
86
+ * would let a planted bash.exe in a cloned repo run).
87
+ */
88
+ export function resolveWindowsBash({ env = process.env, exists = fs.existsSync } = {}) {
89
+ const override = env.UKIT_BASH_BIN;
90
+ if (typeof override === 'string' && override && win32.isAbsolute(override)) return override;
91
+
92
+ const probe = (candidate) =>
93
+ typeof candidate === 'string' &&
94
+ candidate !== '' &&
95
+ win32.isAbsolute(candidate) &&
96
+ safeExists(exists, candidate);
97
+
98
+ const claude = env.CLAUDE_CODE_GIT_BASH_PATH;
99
+ if (probe(claude)) return claude;
100
+
101
+ const defaults = [
102
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
103
+ 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
104
+ ];
105
+ if (typeof env.LOCALAPPDATA === 'string' && env.LOCALAPPDATA) {
106
+ defaults.push(win32.join(env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe'));
107
+ }
108
+ for (const candidate of defaults) {
109
+ if (probe(candidate)) return candidate;
110
+ }
111
+
112
+ const trimSlash = (p) => p.replace(/\\+$/, '').toLowerCase();
113
+ const system32 = trimSlash(win32.join(env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', 'System32'));
114
+ const rawPath = env.PATH ?? env.Path ?? '';
115
+ for (const rawEntry of String(rawPath).split(';')) {
116
+ const entry = rawEntry.trim().replace(/^"+|"+$/g, '').trim();
117
+ if (!entry || !win32.isAbsolute(entry)) continue;
118
+ const norm = trimSlash(win32.normalize(entry));
119
+ if (norm === system32 || norm.endsWith('\\microsoft\\windowsapps')) continue;
120
+ const candidate = win32.join(entry, 'bash.exe');
121
+ if (safeExists(exists, candidate)) return candidate;
122
+ }
123
+ return null;
124
+ }
125
+
126
+ /**
127
+ * Decide how to launch a hook script. win32 + `.sh` -> Git Bash (command may be
128
+ * null when none was found); everything else spawns the script directly.
129
+ */
130
+ export function resolveHookCommand(
131
+ scriptPath,
132
+ { env = process.env, platform = env.UKIT_HOOK_SHELL_PLATFORM || process.platform, exists = fs.existsSync } = {},
133
+ ) {
134
+ if (typeof scriptPath !== 'string') return { command: '', args: [], viaBash: false };
135
+ if (platform === 'win32' && /\.sh$/i.test(scriptPath)) {
136
+ return {
137
+ command: resolveWindowsBash({ env, exists }),
138
+ args: [toBashPath(scriptPath)],
139
+ viaBash: true,
140
+ };
141
+ }
142
+ return { command: scriptPath, args: [], viaBash: false };
143
+ }
144
+
44
145
  /**
45
146
  * Signal a process GROUP by pid (negative-pid kill). Swallows ESRCH — the
46
147
  * child may have exited between arming the timer and this call, or the pid may
@@ -73,18 +174,20 @@ export function runHookProcess({
73
174
  cwd,
74
175
  env = process.env,
75
176
  graceMs = KILL_GRACE_MS,
177
+ spawnImpl = spawn,
76
178
  }) {
77
179
  return new Promise((resolve) => {
78
180
  const startedAt = Date.now();
79
181
  let child;
80
182
  try {
81
- child = spawn(command, args, {
183
+ child = spawnImpl(command, args, {
82
184
  cwd,
83
185
  env,
84
186
  // Detached ONLY on POSIX: the child becomes a group leader so the whole
85
187
  // tree dies together. Windows never gets a group assumption.
86
188
  detached: GROUP_KILL_SUPPORTED,
87
189
  stdio: ['pipe', 'pipe', 'pipe'],
190
+ windowsHide: true,
88
191
  });
89
192
  } catch (err) {
90
193
  resolve({
@@ -94,6 +197,8 @@ export function runHookProcess({
94
197
  stderr: String(err?.message || err),
95
198
  failureKind: 'error',
96
199
  elapsedMs: Date.now() - startedAt,
200
+ spawnFailed: true,
201
+ errorCode: err?.code ?? null,
97
202
  });
98
203
  return;
99
204
  }
@@ -104,6 +209,8 @@ export function runHookProcess({
104
209
  let hardSettled = false;
105
210
  let overflowed = false;
106
211
  let spawnError = null;
212
+ let spawned = false;
213
+ let spawnFailed = false;
107
214
  let stdoutBuf = Buffer.alloc(0);
108
215
  let stderrBuf = Buffer.alloc(0);
109
216
  const stdoutRef = { value: stdoutBuf };
@@ -146,6 +253,8 @@ export function runHookProcess({
146
253
  stderr: stderrRef.value.toString('utf8'),
147
254
  failureKind,
148
255
  elapsedMs: Date.now() - startedAt,
256
+ spawnFailed,
257
+ errorCode: spawnError?.code ?? null,
149
258
  });
150
259
  };
151
260
 
@@ -238,9 +347,16 @@ export function runHookProcess({
238
347
  }, deadlineMs + Math.max(0, graceMs) + HARD_SETTLE_SLACK_MS);
239
348
  }
240
349
 
350
+ // Event order, not timing, tells "never started" from "failed after start".
351
+ child.once('spawn', () => {
352
+ spawned = true;
353
+ });
241
354
  child.on('error', (err) => {
242
355
  // Spawn failures (ENOENT and friends) emit 'error' without 'close'.
243
- if (!spawnError) spawnError = err;
356
+ if (!spawnError) {
357
+ spawnError = err;
358
+ spawnFailed = !spawned;
359
+ }
244
360
  finish();
245
361
  });
246
362
  child.on('close', () => {
@@ -1204,6 +1204,18 @@ function extractExitCode(payload) {
1204
1204
  return null;
1205
1205
  }
1206
1206
 
1207
+ // FR-M5-004 (TASK-C97-014): child identity is a top-level, nonempty, trimmed string
1208
+ // `agent_id` OR `agent_type` — the same documented subagent marker skill-router.sh
1209
+ // reads for its sidechain guard. Nested output text that merely contains marker
1210
+ // words is never authority, and missing/blank/nonstring markers fall through to the
1211
+ // existing main-session accounting unchanged.
1212
+ function isChildToolPayload(payload) {
1213
+ if (!payload || typeof payload !== 'object') return false;
1214
+ return [payload.agent_id, payload.agent_type].some(
1215
+ (marker) => typeof marker === 'string' && marker.trim() !== '',
1216
+ );
1217
+ }
1218
+
1207
1219
  function extractText(payload, preferredKeys = []) {
1208
1220
  const queue = [payload];
1209
1221
  const seen = new Set();
@@ -1376,6 +1388,10 @@ async function main() {
1376
1388
 
1377
1389
  const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
1378
1390
  const config = await loadRuntimeConfig(projectRoot);
1391
+ // FR-M5-004 (TASK-C97-014): resolved once from the parsed payload and applied at
1392
+ // BOTH raw-output pressure updates below. Deliberately NOT an early exit — the
1393
+ // compression, cache, history, tee and hard-cap paths all keep running for a child.
1394
+ const isChildOutput = isChildToolPayload(payload);
1379
1395
  // Session-scoped pressure bookkeeping: output tokens must land in the calling
1380
1396
  // session's record, never a sibling session's.
1381
1397
  const sessionConfig = {
@@ -1448,16 +1464,21 @@ async function main() {
1448
1464
  recoveryReason: cached.metadata?.recoveryReason ?? null,
1449
1465
  truncated: Boolean(cached.metadata?.truncated),
1450
1466
  });
1451
- await updateCompactPressureFromOutput(projectRoot, {
1452
- timestamp: Date.now(),
1453
- command: cachedCommand,
1454
- profile: cachedProfile,
1455
- summary: String(cached.content),
1456
- tokensBefore: cached.tokensBefore ?? 0,
1457
- tokensAfter: cached.tokensAfter ?? 0,
1458
- savedTokens: cached.savedTokens ?? 0,
1459
- exitCode,
1460
- }, sessionConfig);
1467
+ // FR-M5-004: a subagent's raw output must not be charged to its parent's
1468
+ // compact pressure. Suppress ONLY this pressure write — cache/history/tee
1469
+ // writes above, the summary on stdout below, and every safety gate are untouched.
1470
+ if (!isChildOutput) {
1471
+ await updateCompactPressureFromOutput(projectRoot, {
1472
+ timestamp: Date.now(),
1473
+ command: cachedCommand,
1474
+ profile: cachedProfile,
1475
+ summary: String(cached.content),
1476
+ tokensBefore: cached.tokensBefore ?? 0,
1477
+ tokensAfter: cached.tokensAfter ?? 0,
1478
+ savedTokens: cached.savedTokens ?? 0,
1479
+ exitCode,
1480
+ }, sessionConfig);
1481
+ }
1461
1482
  process.stdout.write(String(cached.content));
1462
1483
  return;
1463
1484
  }
@@ -1548,16 +1569,21 @@ async function main() {
1548
1569
  savedTokens: result.savedTokens,
1549
1570
  metadata: { command: result.command, exitCode },
1550
1571
  });
1551
- await updateCompactPressureFromOutput(projectRoot, {
1552
- timestamp: Date.now(),
1553
- command: result.command,
1554
- profile: result.profile,
1555
- summary: result.summary,
1556
- tokensBefore: result.tokensBefore,
1557
- tokensAfter: result.tokensAfter,
1558
- savedTokens: result.savedTokens,
1559
- exitCode,
1560
- }, sessionConfig);
1572
+ // FR-M5-004: same suppression on the fresh path — a child's raw output never
1573
+ // charges the parent's compact estimate; compression, history, tee, the recovery
1574
+ // anchor and every downstream gate keep their existing behavior.
1575
+ if (!isChildOutput) {
1576
+ await updateCompactPressureFromOutput(projectRoot, {
1577
+ timestamp: Date.now(),
1578
+ command: result.command,
1579
+ profile: result.profile,
1580
+ summary: result.summary,
1581
+ tokensBefore: result.tokensBefore,
1582
+ tokensAfter: result.tokensAfter,
1583
+ savedTokens: result.savedTokens,
1584
+ exitCode,
1585
+ }, sessionConfig);
1586
+ }
1561
1587
 
1562
1588
  if (config?.tokenPipeline?.promptCache) {
1563
1589
  await writePromptCacheEntry(projectRoot, {
@@ -966,7 +966,7 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
966
966
  } catch {
967
967
  return { kind: 'none' };
968
968
  }
969
- const { goal, phase, cursor, next, exitPredicate } = parseRunCursor(text);
969
+ const { command, goal, phase, cursor, next, exitPredicate } = parseRunCursor(text);
970
970
  // Shared terminal predicate — tests/handoff/cycle21 greps this exact shape across
971
971
  // all four RUN.md consumers; keep `^(done|blocked)` as one expression.
972
972
  const terminal = String(phase).match(/^(done|blocked)\b/i)?.[1]?.toLowerCase() || '';
@@ -980,7 +980,8 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
980
980
  // must not be bounced into a stale run. Only a positive "scanned the whole
981
981
  // transcript, latest intent is not fullstack" releases; unknown (null) keeps
982
982
  // the block, so a real run is never released by a lost read.
983
- if (gate.scope === 'session' && await transcriptShowsHandoffRun(transcriptPath) === false) {
983
+ const transcriptOwner = gate.scope === 'session' ? await transcriptShowsHandoffRun(transcriptPath) : true;
984
+ if (gate.scope === 'session' && transcriptOwner === false) {
984
985
  return { kind: 'none' };
985
986
  }
986
987
 
@@ -1008,6 +1009,19 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
1008
1009
  return { kind: 'block', reason: lines.join('\n') };
1009
1010
  }
1010
1011
 
1012
+ // Nonterminal planning cursor (TASK-C97-006, FR-M2-003): a RUN.md whose Command
1013
+ // is EXACTLY `handoff-create` is the planner's own cursor — it does not demand
1014
+ // fullstack implementation even when the session's intent is unknown (no
1015
+ // transcript path, unreadable, known-null). Stop releases UNLESS the latest
1016
+ // structured intent positively reacquired the run (`fullstack`: an explicit
1017
+ // handoff-implement or handoff-fullstack turn). This is the ONLY command
1018
+ // exception: actual handoff-fullstack/implement/review and a missing/unknown
1019
+ // Command keep the legacy posture, including the unknown fail-closed block. The
1020
+ // terminal `done` branch above is untouched.
1021
+ if (String(command).trim().toLowerCase() === 'handoff-create' && transcriptOwner !== true) {
1022
+ return { kind: 'none' };
1023
+ }
1024
+
1011
1025
  const signature = `${phase}|${cursor}|${next}`;
1012
1026
  const streak = await bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs });
1013
1027
  if (streak !== null && streak > gate.maxStalledBlocks) {
@@ -138,9 +138,16 @@ mode only; daily flow does not get a Progress section.
138
138
  reports `undeclared-drift`.
139
139
  - **Milestone commit.** The commit lives on your worktree branch only (e.g.
140
140
  `handoff/task-xxx`). Use a `-m "milestone: <name>"` message. Never commit outside your
141
- worktree, never push. The orchestrator's copy-back step (`git diff --name-only` against
142
- base) diffs the working tree, so intermediate commits on the worktree branch are free —
143
- **copy-back is unaffected** by milestone commits.
141
+ worktree, never push. The orchestrator's copy-back step lands the branch's final
142
+ base-relative snapshot (`land-worktree.mjs` diffs the working tree against the immutable
143
+ base), so intermediate commits on the worktree branch are free — **copy-back is
144
+ unaffected** by milestone commits.
145
+ - **Final clean milestone commit (required before PASS).** End your run with one final
146
+ milestone commit so the worktree's snapshot equals its committed tree — nothing staged,
147
+ nothing unstaged, no nonignored untracked file left behind. A Git-clean snapshot is what
148
+ lets a later receipt-gated cleanup remove the worktree without `--force`; a tree dirty
149
+ against the snapshot is retained with a named reason, never forced. Do not report PASS on
150
+ a dirty worktree.
144
151
  - **Resume.** When a previous run aborted, you will be respawned. Read `## Progress`,
145
152
  jump straight to the last entry whose `last-green:` is not `none`, and continue from
146
153
  there. Do NOT re-plan, do NOT restart from RED, do NOT re-write tests that already
@@ -273,6 +273,18 @@ function parseStructuredDecision(stdout) {
273
273
 
274
274
  function translateExecResult(scriptName, execResult) {
275
275
  const failureKind = typeof execResult?.failureKind === 'string' ? execResult.failureKind : null;
276
+ // The runner could not launch a .sh hook at all (win32 without bash): a loud warning,
277
+ // never a block, and no captured content travels with it.
278
+ if (failureKind === 'spawn-unavailable') {
279
+ const launchStderr = typeof execResult?.stderr === 'string' ? execResult.stderr : '';
280
+ return {
281
+ block: false,
282
+ warning: launchStderr.trim()
283
+ || `${scriptName} could not be launched and was skipped (spawn-unavailable) — install Git for Windows or set UKIT_BASH_BIN.`,
284
+ stdout: '',
285
+ stderr: launchStderr,
286
+ };
287
+ }
276
288
  // Infrastructure outcomes produce no verdict: no captured content travels with
277
289
  // them (overflowed stdout is truncated mid-stream and may embed secrets).
278
290
  const infrastructure = failureKind
@@ -280,13 +280,25 @@ Internals: `src/core/permissionPolicy.js` compiles the semantic policy (preceden
280
280
  non-zero on its own), `output-overflow` (capture exceeded the 2 MiB maxBuffer and the
281
281
  tree was culled), `timeout` (the runner killed the child at its deadline), `signal`
282
282
  (the child died from a signal the runner did not send), `error` (the child never
283
- spawned), `budget-exhausted` (the script never ran — the chain total budget was spent
284
- first). The `--emit-verdict` contract maps them onto one exit code: `ok`/`exit-code 0`
283
+ spawned), `spawn-unavailable` (win32 only: no usable bash could be found or started for
284
+ a `.sh` script — see the launcher bullet below), `budget-exhausted` (the script never
285
+ ran — the chain total budget was spent first). The `--emit-verdict` contract maps them onto one exit code: `ok`/`exit-code 0`
285
286
  and any non-fail-closed failure → exit 0 (fail OPEN); a real `exit-code 2` block, or
286
287
  ANY failure on a fail-closed gate (`output-overflow`, `timeout`, `signal`, `error`,
287
288
  `budget-exhausted`) → exit 2 (fail CLOSED). An overflowed capture is dropped whole
288
289
  before it can be replayed, so a truncated stream can never masquerade as a verdict.
289
290
  `tests/hooks/hookChainFailureSurface.test.js` pins these surfaces.
291
+ - **Windows bash launcher.** On win32 a `.sh` hook is not executed directly; the runner
292
+ launches it through Git Bash. The bash binary is the first hit of: an absolute
293
+ `UKIT_BASH_BIN` → `CLAUDE_CODE_GIT_BASH_PATH` → the Git for Windows default install
294
+ paths → absolute `PATH` entries (the System32 and WindowsApps `bash.exe`, i.e. WSL, are
295
+ skipped; a relative or cwd `bash.exe` is never used). The script path is passed with
296
+ forward slashes. When no candidate exists or the spawn fails, the entry is
297
+ `spawn-unavailable`: it warns instead of denying every Edit/Write/Bash. That kind is
298
+ win32-launcher-only — POSIX spawn errors stay `error` and fail closed, and timeouts,
299
+ real `exit-code 2` blocks, missing gate files and `block-dangerous.sh` stay fail closed
300
+ on every platform. `UKIT_HOOK_SHELL_PLATFORM` is a diagnostic override of the platform
301
+ the launcher assumes.
290
302
  - **stderr is a diagnostic channel, not verdict payload.** Under `--emit-verdict` the
291
303
  runner drops the stderr of every non-verdict script: stdout is the verdict and Claude
292
304
  Code parses it as ONE JSON document, so interleaving arbitrary hook diagnostics there