@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
@@ -8,8 +8,10 @@
8
8
  // DEFAULT_CRITERIA — frozen thresholds used by `validateTaskFile` by default.
9
9
  // VERIFICATION_MINUTE_TABLE — frozen literal table for `yarn test:release-core` and
10
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`.
11
+ // and the `yarn test` alias (`package.json`:
12
+ // `"test": "vitest run"`) are NOT in the table — their cost
13
+ // is computed dynamically (0.5 per file argument), see
14
+ // `estimateVerificationMinutes`.
13
15
  // estimateVerificationMinutes — sums per-command minutes over a verification list.
14
16
  // validateTaskFile — parses a TASK-xxx.md and returns
15
17
  // `{ verdict, reasons: [{ criterion, detail }] }`.
@@ -39,8 +41,9 @@ export const VERIFICATION_MINUTE_TABLE = Object.freeze({
39
41
  'node scripts/release/verify-release.mjs': 2,
40
42
  });
41
43
 
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
+ // Per-command minute estimate. `yarn vitest [run]` and the `yarn test` alias (the repo's
45
+ // dominant verification form) → 0.5 per FILE argument (per non-flag token after the runner
46
+ // word); anything else falls back to 1 flat.
44
47
  export function estimateVerificationMinutes(commands) {
45
48
  if (!Array.isArray(commands)) return 0;
46
49
  let total = 0;
@@ -51,8 +54,8 @@ export function estimateVerificationMinutes(commands) {
51
54
  total += VERIFICATION_MINUTE_TABLE[cmd];
52
55
  continue;
53
56
  }
54
- if (/^yarn\s+vitest(\s+run)?\b/.test(cmd)) {
55
- // 0.5 minutes per non-flag argument after `run` (or after `yarn vitest`).
57
+ if (/^yarn\s+(?:vitest(\s+run)?|test)(?=\s|$)/.test(cmd)) {
58
+ // 0.5 minutes per non-flag argument after the runner word (`run`, or `yarn test` itself).
56
59
  const tokens = cmd.split(/\s+/);
57
60
  const runIdx = tokens.indexOf('run');
58
61
  const tail = runIdx >= 0 ? tokens.slice(runIdx + 1) : tokens.slice(2);
@@ -16,6 +16,13 @@ const INSTALL_REPAIR = 'Run ukit install';
16
16
  const OMP_CONFIG_TIMEOUT_MS = 5000;
17
17
  const COMPLETION_LOOP_HEADER = '## Unattended Completion Loop';
18
18
 
19
+ // B3 / FR-003 — the shipped template carries `{{token}}` placeholders that `ukit install`
20
+ // renders. `parse()` must not see them raw: `{{compact.autoCompactWindow}}` reads as a flow
21
+ // mapping in KEY position, and the `yaml` lib then warns "Keys with collection values will be
22
+ // stringified" on every doctor run. Same neutralization `tests/consistency/ompAgentParity.test.js`
23
+ // already applies; deliberately generic (no key-specific case) so a new placeholder is covered.
24
+ const TEMPLATE_PLACEHOLDER_PATTERN = /\{\{[^}]+\}\}/g;
25
+
19
26
  async function readOmpConfig(projectRoot) {
20
27
  const configPath = path.join(projectRoot, '.omp', 'config.yml');
21
28
  try {
@@ -51,7 +58,7 @@ function execOmpConfigGet(projectRoot, ompPath) {
51
58
  async function readTemplateDenyMatches() {
52
59
  try {
53
60
  const text = await fs.readFile(path.join(PACKAGE_ROOT, 'templates', '.omp', 'config.yml'), 'utf8');
54
- const parsed = parse(text);
61
+ const parsed = parse(text.replace(TEMPLATE_PLACEHOLDER_PATTERN, '0'));
55
62
  const patterns = parsed?.bash?.patterns;
56
63
  if (!Array.isArray(patterns)) return [];
57
64
  return patterns
@@ -39,6 +39,16 @@ export function buildTemplateVariables({ projectContext, stackContext, packageVe
39
39
  os: projectContext.runtime.os,
40
40
  nodeVersion: projectContext.runtime.nodeVersion,
41
41
  },
42
+ verification: {
43
+ // CX-10: render only commands whose scripts exist in the target
44
+ // package.json — a hardcoded lint/typecheck list produced a permanently
45
+ // failing verification contract on projects without those scripts.
46
+ requiredBeforeCompletion: JSON.stringify(
47
+ ['test', 'lint', 'typecheck']
48
+ .filter((name) => projectContext?.scripts?.[name])
49
+ .map((name) => `${projectContext.runtime.packageManager} ${name}`),
50
+ ),
51
+ },
42
52
  stack: {
43
53
  // original flags
44
54
  frontend: stackContext.flags.frontend,
@@ -50,7 +50,7 @@ Systematic debugging — understand before fixing.
50
50
 
51
51
  ```
52
52
  STATUS: DONE | BLOCKED | PARTIAL
53
- EXECUTOR_TOOL: [claude-code | kilo-code | codex | other]
53
+ EXECUTOR_TOOL: [claude-code | codex | omp | other]
54
54
  EXECUTOR_MODEL: [exact model name you are running as. "unknown" if you cannot tell.]
55
55
  EXECUTOR_SUBAGENT: [subagent name within your host, if any, else "-"]
56
56
  SUMMARY: [1-2 sentences — root cause and fix]
@@ -72,9 +72,9 @@ and even then, report it, don't ask about it.
72
72
 
73
73
  ```
74
74
  STATUS: DONE | BLOCKED | PARTIAL
75
- EXECUTOR_TOOL: [claude-code | kilo-code | codex | other]
75
+ EXECUTOR_TOOL: [claude-code | codex | omp | other]
76
76
  EXECUTOR_MODEL: [exact model name you are running as — e.g. unic-code, claude-sonnet-4-5, gpt-5-mini. If you truly cannot tell, write "unknown" — reviewer treats unknown as suspicious and asks the human to confirm.]
77
- EXECUTOR_SUBAGENT: [name of the subagent you are, if your host has multiple — e.g. "Kilo:code", "Claude:feature-implementer". Otherwise "-".]
77
+ EXECUTOR_SUBAGENT: [name of the subagent you are, if your host has multiple — e.g. "Claude:feature-implementer", "omp:task". Otherwise "-".]
78
78
  SUMMARY: [1-2 sentences of what was implemented]
79
79
  TEST_PLAN_FOLLOWED: [task §4 / inline / N/A — reason]
80
80
  FILES_CHANGED:
@@ -1,7 +1,7 @@
1
1
  # /ukit:handoff-create — Phase 1 + 2: Plan
2
2
 
3
3
  **Role: PLANNER**
4
- **Tool: any** (Claude Code / Codex / omp / Kilo — your choice)
4
+ **Tool: any** (Claude Code / Codex / omp — your choice)
5
5
  **Model split:**
6
6
  - Read/understand → lite model (haiku · unic-lite · cheapest available)
7
7
  - Write plan + tasks → strong model (Opus · unic-smart · strongest available)
@@ -1,7 +1,7 @@
1
1
  # /ukit:handoff-fullstack — Full Pipeline: Plan → Implement → Review
2
2
 
3
3
  **Role: ORCHESTRATOR**
4
- **Tool: any** (Claude Code / Codex / omp / Kilo — your choice)
4
+ **Tool: any** (Claude Code / Codex / omp — your choice)
5
5
 
6
6
  ## Model Split
7
7
 
@@ -1,7 +1,7 @@
1
1
  # /ukit:handoff-implement — Phase 3: Execute
2
2
 
3
3
  **Role: EXECUTOR (orchestrated)**
4
- **Tool: any** (Claude Code / Codex / omp / Kilo — your choice)
4
+ **Tool: any** (Claude Code / Codex / omp — your choice)
5
5
  **Model: code model** (Sonnet · unic-code · cheap-smart)
6
6
 
7
7
  > **omp model tiers:** the tiers above map to `.omp/config.yml`'s `modelRoles`, referenced from agent frontmatter as `@smol` / `@default` / `@slow`.
@@ -1,7 +1,7 @@
1
1
  # /ukit:handoff-review — Phase 4: Review
2
2
 
3
3
  **Role: REVIEWER**
4
- **Tool: any** (Claude Code / Codex / omp / Kilo — your choice)
4
+ **Tool: any** (Claude Code / Codex / omp — your choice)
5
5
  **Model: strong model, MUST differ from executor** (Opus · unic-smart · strongest available)
6
6
 
7
7
  > **omp model tiers:** the tiers above map to `.omp/config.yml`'s `modelRoles`, referenced from agent frontmatter as `@smol` / `@default` / `@slow`.
@@ -11,6 +11,25 @@ if [ ! -f "$SETTINGS_LOCAL" ] || [ ! -f "$USAGE_FILE" ]; then
11
11
  exit 0
12
12
  fi
13
13
 
14
+ # O1 (TASK-009): armed AFTER the missing-state short-circuit above, so the
15
+ # nothing-to-prune path stays as free as it is today and only real work is measured.
16
+ # This hook stages no stdin (SPEC §14), so it owns its own EXIT trap: the trap
17
+ # returns the status it captured and the always-exit-0 posture is unchanged.
18
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19
+ # shellcheck source=/dev/null
20
+ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
21
+ if [ "${UKIT_TEL_ARMED:-}" = "1" ]; then
22
+ ukit_hook_telemetry_arm
23
+ __ukit_tel_finish() {
24
+ local __ukit_tel_rc=$?
25
+ if [ "${UKIT_TEL_ARMED:-}" = "1" ] && command -v ukit_hook_telemetry_finish >/dev/null 2>&1; then
26
+ UKIT_TEL_RC="$__ukit_tel_rc" UKIT_TEL_EVENT="SessionStart" ukit_hook_telemetry_finish
27
+ fi
28
+ return "$__ukit_tel_rc"
29
+ }
30
+ trap __ukit_tel_finish EXIT
31
+ fi
32
+
14
33
  NOW_UTC=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
15
34
 
16
35
  node -e '
@@ -238,7 +238,10 @@ async function readRunCursor() {
238
238
  if (synced.reset || synced.probed) {
239
239
  // Persist a clean probe too: hook processes are ephemeral, so otherwise every
240
240
  // mutation would reopen and parse the same 4MB transcript tail.
241
- state = await mod.writeCompactPressureState(projectRoot, synced.state, sessionConfig);
241
+ // TASK-004: withFileLock is fail-closed — a contended pressure lock skips the
242
+ // write and resolves undefined (journaled). Keep the synced in-memory state;
243
+ // the gate still decides on correct data, the persist just lands next run.
244
+ state = (await mod.writeCompactPressureState(projectRoot, synced.state, sessionConfig)) ?? synced.state;
242
245
  }
243
246
  } catch {
244
247
  state = await mod.buildCompactPressureState(rawState, sessionConfig);
@@ -187,16 +187,24 @@ if (toolName === 'Write' || toolName === 'Edit') {
187
187
  const newVisible = stripHtmlComments(newContent);
188
188
  const currentVisible = stripHtmlComments(currentContent);
189
189
 
190
+ // A documented, human-approved override closes the tier contract for this
191
+ // cycle — e.g. a gateway with no smart-tier model where the human approved
192
+ // planning on the available tier. Same semantics as the push-time check.
193
+ const planHasHumanOverride = (text) =>
194
+ /## Model-Tier Guard Override/.test(text) && /OVERRIDE_APPROVED_BY:\s*human/i.test(text);
195
+
190
196
  if (isPlan && /## Planner Report/.test(newVisible)) {
191
197
  const plannerModel = extractField(newVisible, 'PLANNER_MODEL');
192
- if (!plannerModel || /^unknown$/i.test(plannerModel)) {
193
- block('PLANNER_MODEL missing/unknown in PLAN.md. Planning must run via Agent tool subagent_type: "handoff-planner" (opus/unic-smart) and self-report its model.');
194
- }
195
- if (tierOf(plannerModel) === 'vision') {
196
- block(`PLANNER_MODEL "${plannerModel}" ${VISION_LANE_MESSAGE}`);
197
- }
198
- if (tierOf(plannerModel) !== 'smart') {
199
- block(`PLANNER_MODEL "${plannerModel}" is not strong/opus tier. Planning must run via Agent tool subagent_type: "handoff-planner" (opus/unic-smart).`);
198
+ if (!planHasHumanOverride(newVisible)) {
199
+ if (!plannerModel || /^unknown$/i.test(plannerModel)) {
200
+ block('PLANNER_MODEL missing/unknown in PLAN.md. Planning must run via Agent tool subagent_type: "handoff-planner" (opus/unic-smart) and self-report its model.');
201
+ }
202
+ if (tierOf(plannerModel) === 'vision') {
203
+ block(`PLANNER_MODEL "${plannerModel}" ${VISION_LANE_MESSAGE}`);
204
+ }
205
+ if (tierOf(plannerModel) !== 'smart') {
206
+ block(`PLANNER_MODEL "${plannerModel}" is not strong/opus tier. Planning must run via Agent tool subagent_type: "handoff-planner" (opus/unic-smart).`);
207
+ }
200
208
  }
201
209
  }
202
210
 
@@ -209,8 +217,9 @@ if (toolName === 'Write' || toolName === 'Edit') {
209
217
  const isFreshTaskFile = !fileExists;
210
218
  if (isFreshTaskFile) {
211
219
  const planContent = (await pathExists(planPath)) ? await readTextSafe(planPath) : '';
212
- const plannerModel = extractField(stripHtmlComments(planContent), 'PLANNER_MODEL');
213
- if (!plannerModel || tierOf(plannerModel) !== 'smart') {
220
+ const planVisible = stripHtmlComments(planContent);
221
+ const plannerModel = extractField(planVisible, 'PLANNER_MODEL');
222
+ if (!planHasHumanOverride(planVisible) && (!plannerModel || tierOf(plannerModel) !== 'smart')) {
214
223
  block(`Cannot create ${taskId}.md — PLAN.md has no valid smart-tier PLANNER_MODEL yet. Run planning via Agent tool subagent_type: "handoff-planner" (opus/unic-smart) first.`);
215
224
  }
216
225
 
@@ -285,13 +294,15 @@ if (toolName === 'Write' || toolName === 'Edit') {
285
294
  if (hasReviewerVerdict && !currentReviewerReal) {
286
295
  const executorModel = extractField(currentVisible, 'EXECUTOR_MODEL') || extractField(newVisible, 'EXECUTOR_MODEL');
287
296
  const reviewerModel = extractField(newVisible, 'REVIEWER_MODEL');
297
+ const planContentForReview = (await pathExists(planPath)) ? await readTextSafe(planPath) : '';
298
+ const reviewOverride = planHasHumanOverride(stripHtmlComments(planContentForReview));
288
299
  if (isPlaceholderValue(reviewerModel) || /^unknown$/i.test(reviewerModel)) {
289
300
  if (!isFreshTaskFile) {
290
301
  block(`${taskId}: REVIEWER_MODEL missing/placeholder. Review must run via Agent tool subagent_type: "code-reviewer" (opus/unic-smart) and self-report its model.`);
291
302
  }
292
303
  // Else: skeleton Reviewer Verdict on a freshly created task file — Phase 4
293
304
  // appends the real verdict and it is validated there.
294
- } else {
305
+ } else if (!reviewOverride) {
295
306
  if (tierOf(reviewerModel) === 'vision') {
296
307
  block(`${taskId}: REVIEWER_MODEL "${reviewerModel}" ${VISION_LANE_MESSAGE}`);
297
308
  }
@@ -9,6 +9,28 @@ fi
9
9
  HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
10
10
  SCRIPT_PATH="$HOOK_DIR/../ukit/runtime/reinject-context.mjs"
11
11
 
12
+ # O1 (TASK-009): this hook stages no stdin (SPEC §14), so the shared cleanup path in
13
+ # hook-input.sh never runs for it and nothing here would ever be measured. Arm the
14
+ # no-staging start marker and finish it from this hook's own EXIT trap: arming costs
15
+ # one mktemp and reads no clock, and the single bounded `--finish` child is the
16
+ # telemetry process rather than a hook work step. A missing runtime (pre-install
17
+ # tree) leaves the flag unset — exactly as unmeasured as today — and the trap returns
18
+ # the status it captured, so the `exit $?` below still reaches the host unchanged.
19
+ # shellcheck source=/dev/null
20
+ source "$HOOK_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
21
+ if [ "${UKIT_TEL_ARMED:-}" = "1" ]; then
22
+ ukit_hook_telemetry_arm
23
+ __ukit_tel_finish() {
24
+ local __ukit_tel_rc=$?
25
+ if [ "${UKIT_TEL_ARMED:-}" = "1" ] && command -v ukit_hook_telemetry_finish >/dev/null 2>&1; then
26
+ # The event travels per call — no staged envelope exists to carry it.
27
+ UKIT_TEL_RC="$__ukit_tel_rc" UKIT_TEL_EVENT="PreCompact" ukit_hook_telemetry_finish
28
+ fi
29
+ return "$__ukit_tel_rc"
30
+ }
31
+ trap __ukit_tel_finish EXIT
32
+ fi
33
+
12
34
  if [ -f "$SCRIPT_PATH" ]; then
13
35
  UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT_PATH"
14
36
  exit $?
@@ -24,6 +24,25 @@ if [ ! -f "$PRESSURE_FILE" ]; then
24
24
  exit 0
25
25
  fi
26
26
 
27
+ # O1 (TASK-009): armed AFTER the no-pressure-file short-circuit above, so the common
28
+ # "nothing to reset" start stays unmeasured while real resets get timed. This hook
29
+ # stages no stdin (SPEC §14), so it owns its EXIT trap; the trap restores the captured
30
+ # status and the always-exit-0 posture below is unchanged.
31
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
32
+ # shellcheck source=/dev/null
33
+ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
34
+ if [ "${UKIT_TEL_ARMED:-}" = "1" ]; then
35
+ ukit_hook_telemetry_arm
36
+ __ukit_tel_finish() {
37
+ local __ukit_tel_rc=$?
38
+ if [ "${UKIT_TEL_ARMED:-}" = "1" ] && command -v ukit_hook_telemetry_finish >/dev/null 2>&1; then
39
+ UKIT_TEL_RC="$__ukit_tel_rc" UKIT_TEL_EVENT="SessionStart" ukit_hook_telemetry_finish
40
+ fi
41
+ return "$__ukit_tel_rc"
42
+ }
43
+ trap __ukit_tel_finish EXIT
44
+ fi
45
+
27
46
  # The lock directory lives beside the state file; its parent exists because the file does.
28
47
  node -e '
29
48
  // Deadline must exceed the bounded lock wait in this block (maxWaitMs = 5000) so the
@@ -132,6 +151,13 @@ process.stdin.on("end", () => {
132
151
  }
133
152
  })();
134
153
  const sessionId = typeof payload.session_id === "string" ? payload.session_id.trim() : "";
154
+ // TASK-008 fix round: the pressure writer keys records by payload.session_id,
155
+ // and hosts that omit session_id on prompt/tool events (omp) now send the
156
+ // transcript path as the session key. When session_start DOES carry a real id
157
+ // but the writers did not, the record lives under the transcript key — delete
158
+ // both so the targeted reset cannot silently miss. Transcript paths are
159
+ // unique per session, so this can never touch a sibling record.
160
+ const transcriptKey = typeof payload.transcript_path === "string" ? payload.transcript_path.trim() : "";
135
161
  const pressurePath = process.argv[1];
136
162
 
137
163
  withLock(`${pressurePath}.lock`, async () => {
@@ -160,6 +186,9 @@ process.stdin.on("end", () => {
160
186
 
161
187
  const sessions = { ...raw.sessions };
162
188
  delete sessions[sessionId];
189
+ if (transcriptKey && transcriptKey !== sessionId) {
190
+ delete sessions[transcriptKey];
191
+ }
163
192
  const newest = Object.entries(sessions)
164
193
  .sort(([, a], [, b]) => (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0))[0]?.[1] ?? null;
165
194
  const tempPath = `${pressurePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
@@ -37,6 +37,26 @@ if [ "$__ukit_ep_gate" != "1" ]; then
37
37
  fi
38
38
  unset __ukit_ep_gate
39
39
 
40
+ # O1 (TASK-009): armed only past the gate above, so a gate-off SessionEnd stays a pure
41
+ # fast exit. This hook stages no stdin (SPEC §14) and owns its EXIT trap; the trap
42
+ # returns the captured status, so the always-exit-0 contract is unchanged. The row is
43
+ # not session-attributed: the staged payload is reaped below before this trap runs, and
44
+ # re-deriving the id in shell would mean another spawn on the teardown path.
45
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
46
+ # shellcheck source=/dev/null
47
+ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
48
+ if [ "${UKIT_TEL_ARMED:-}" = "1" ]; then
49
+ ukit_hook_telemetry_arm
50
+ __ukit_tel_finish() {
51
+ local __ukit_tel_rc=$?
52
+ if [ "${UKIT_TEL_ARMED:-}" = "1" ] && command -v ukit_hook_telemetry_finish >/dev/null 2>&1; then
53
+ UKIT_TEL_RC="$__ukit_tel_rc" UKIT_TEL_EVENT="SessionEnd" ukit_hook_telemetry_finish
54
+ fi
55
+ return "$__ukit_tel_rc"
56
+ }
57
+ trap __ukit_tel_finish EXIT
58
+ fi
59
+
40
60
  # Bounded stdin read (existing hook style): cap +1 byte in background so a
41
61
  # producer that never closes the pipe cannot park the session teardown.
42
62
  UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-episode-in.XXXXXX")" || exit 0
@@ -2523,6 +2523,10 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2523
2523
  if (!entry || typeof entry !== 'object') {
2524
2524
  return;
2525
2525
  }
2526
+ // The sandbox starts without .ukit/storage/cache. Create it before the
2527
+ // sidecar append and lock acquisition; otherwise both operations fail with
2528
+ // ENOENT and withStateLock falls through to an unlocked read-modify-write.
2529
+ await ensureDir(filePath);
2526
2530
 
2527
2531
  // BUG-C23-15: the entry is STAGED to an append-only sidecar before the lock is
2528
2532
  // attempted (small appendFile is atomic — no read-modify-write, so no lost
@@ -2605,14 +2609,17 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2605
2609
  if (!error || error.code !== 'EEXIST') return;
2606
2610
  try {
2607
2611
  const stat = fsSync.statSync(lockPath);
2608
- if (Date.now() - stat.mtimeMs > 10000) {
2609
- try {
2610
- const owner = JSON.parse(fsSync.readFileSync(path.join(lockPath, 'owner'), 'utf8'));
2611
- if (!owner || !Number.isInteger(owner.pid) || !isPidAlive(owner.pid)) {
2612
- fsSync.rmSync(lockPath, { recursive: true, force: true });
2613
- continue;
2614
- }
2615
- } catch {
2612
+ let owner = null;
2613
+ try {
2614
+ owner = JSON.parse(fsSync.readFileSync(path.join(lockPath, 'owner'), 'utf8'));
2615
+ } catch {}
2616
+ // The watchdog may call process.exit while this process is in the
2617
+ // async merge. Its finally block cannot run after that synchronous
2618
+ // exit, so the exit fold must reclaim its own abandoned lock before
2619
+ // folding the staged sidecar. A live foreign owner remains protected.
2620
+ const ownInterruptedLock = owner?.pid === process.pid;
2621
+ if (ownInterruptedLock || Date.now() - stat.mtimeMs > 10000) {
2622
+ if (ownInterruptedLock || !owner || !isPidAlive(owner.pid)) {
2616
2623
  fsSync.rmSync(lockPath, { recursive: true, force: true });
2617
2624
  continue;
2618
2625
  }
@@ -215,6 +215,9 @@ function isVerificationCommand(command) {
215
215
  ].some((pattern) => pattern.test(command));
216
216
  }
217
217
 
218
+ // F-15: execution-ledger.mjs mirrors this list in its own
219
+ // isExplicitBroadVerificationRequest — the completion gate honors the same
220
+ // explicit-broad signal when classifying a scope='broad' receipt. Keep both in sync.
218
221
  function isExplicitBroadVerificationRequest(promptText) {
219
222
  const text = String(promptText || '').toLowerCase();
220
223
  return [