@ngockhoale/ukit 3.4.10 → 3.4.12

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,60 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.4.12 - 2026-10-02
6
+
7
+ **Handoff Stop gate is session-scoped — a stale `RUN.md` no longer hijacks unrelated sessions.**
8
+ Before, any session in a repo was bounced with "CONTINUE IMMEDIATELY" whenever
9
+ `docs/AI_HANDOFF/RUN.md` carried a live `Phase:` (or a `done` whose ExitPredicate
10
+ failed, e.g. `git:clean` on a dirty tree) — even a fresh session opened only to plan.
11
+ `evaluateHandoffCursor` now takes the session `transcriptPath` and enforces the
12
+ gate only when that session itself drives the run, proven by STRUCTURED transcript
13
+ evidence (a real user turn with the `/ukit:handoff-fullstack` tag, a `Skill` call for
14
+ it, or a SessionStart attachment carrying the resume banner). Marker strings in tool
15
+ output, bash commands or source files do not count. An unreadable/oversized
16
+ transcript keeps the legacy block (a real run is never released by a lost read).
17
+ The omp bridge passes the transcript path too.
18
+
19
+ - `handoff-resume.sh`: `startup`/`clear` sessions get an informational note (ignore
20
+ unless asked; `/ukit:handoff-fullstack` to continue, `/ukit:handoff-clear` to
21
+ discard) instead of a resume order; `compact`/`resume` still resume automatically.
22
+ - New config `handoff.fullstack.stopGateScope`: `"session"` (default) | `"project"`
23
+ (legacy repo-wide gate).
24
+ - `reset-compact-pressure.sh`: staged-payload read is async (was a sync
25
+ `readFileSync` inside the hook deadline window; fixes BUG-C22-03 test).
26
+ - `async-lock.mjs`: an `EEXIST` from the lock's PARENT-directory mkdir (a regular file
27
+ squatting on the store path) is a hard error, not lock contention — it used to spin
28
+ out the whole acquisition budget and hang crash-path hooks (`reinject-context`,
29
+ `output-compression`) for ~15 s instead of failing fast with a systemMessage.
30
+ - `skill-router.sh`: restored a missing `};` (v3.4.11 syntax error that silently
31
+ disabled the whole router).
32
+ - Hook-deadline conventions: no sync-fs mention in `verification-guard.sh`/
33
+ `observability-emit.mjs`, explicit timeout on the codex probe's `spawnSync`.
34
+ - Test/doc hygiene: tests follow the cycle-91 inventory archive move; baseline
35
+ reconciliation pins the major.minor line instead of exact patch; decision-coverage
36
+ allowlist covers `deriveTypedDelegationAdvice`.
37
+
38
+ ## 3.4.11 - 2026-10-01
39
+
40
+ **Gate posture is answer-first; only explicit change orders mint work debt.**
41
+ The router now stamps `routingContext.planOnly` (new `isPlanOnlyRequest` —
42
+ "chỉ lên plan", "plan thôi", "just plan it", "plan … không implement") next to
43
+ `mutationOrder`. The completion gate's `no-mutation-order` valve covers both:
44
+ when a prompt ordered no mutation or asked only for a plan/answer, missing
45
+ `write-evidence` AND `verification-evidence` release loud instead of forcing
46
+ an Edit loop. A prompt that does order a change — or a turn that already
47
+ attempted an edit — keeps the normal gate. To implement a reviewed plan:
48
+ "implement", "sửa", "làm luôn", or `execute this plan`.
49
+
50
+ ## 3.4.10 - 2026-10-01
51
+
52
+ **sensitive-data-guard reads .env placeholders.** `classifySecretFile` now
53
+ exempts every dotenv name carrying a distributable marker
54
+ (`example|examples|sample|template|dist`) — `.env.example.local`,
55
+ `.env.production.example`, `.env.sample`, `.env.dist`, `.env.template`
56
+ alongside the old `.env.example` — for Read/Grep and Bash lanes alike.
57
+ Real env files (`.env`, `.env.local`, `.env.production`) stay blocked.
58
+
5
59
  ## 3.4.8 - 2026-10-01
6
60
 
7
61
  (3.4.7 and 3.4.9 were lost to npm staged-version E409 limbo; 3.4.8 landed and its `latest` tag was assigned via `npm dist-tag`.)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.10",
3
+ "version": "3.4.12",
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",
@@ -34,7 +34,7 @@ const out = [];
34
34
  const say = (line = '') => { out.push(line); console.log(line); };
35
35
 
36
36
  function commandOnPath(cmd) {
37
- const r = spawnSync('sh', ['-c', `command -v ${cmd}`], { encoding: 'utf8' });
37
+ const r = spawnSync('sh', ['-c', `command -v ${cmd}`], { encoding: 'utf8', timeout: 5000 });
38
38
  return r.status === 0 ? r.stdout.trim() : null;
39
39
  }
40
40
 
@@ -170,6 +170,20 @@ async function emitOrdinaryResume(payload, source) {
170
170
  const cursor = field('Cursor');
171
171
  const next = field('Next');
172
172
 
173
+ // A brand-new session (startup / clear) did not start this run and may be about
174
+ // something else entirely (e.g. planning). Tell it the run exists, but do NOT
175
+ // order a resume and do NOT use the resume marker the Stop gate treats as proof
176
+ // that the session owns the run. Resume stays automatic for compact / resume.
177
+ if (source === 'startup' || source === 'clear') {
178
+ process.stdout.write([
179
+ 'UKit note: docs/AI_HANDOFF/RUN.md has an unfinished handoff-fullstack run '
180
+ + `(Phase: ${phase}; Next: ${next || 'not recorded'}).`,
181
+ 'It belongs to an earlier session. Ignore it unless the user asks to continue it; their current request comes first.',
182
+ 'To pick it up the user can run /ukit:handoff-fullstack; to discard it, /ukit:handoff-clear.',
183
+ ].join('\n') + '\n');
184
+ process.exit(0);
185
+ }
186
+
173
187
  const out = [
174
188
  'UKIT HANDOFF RESUME — an unfinished handoff-fullstack run was found on disk.',
175
189
  ` Goal: ${goal || '(not recorded)'}`,
@@ -10,5 +10,9 @@
10
10
  // so the `execution.started` record lands before any slow .sh member can
11
11
  // exhaust the chain budget (a timeout further down would silently skip a
12
12
  // trailing emit step).
13
+ //
14
+ // Deadline contract: the chain runner passes `deadlineMs` into runHook and the
15
+ // emit module bounds every write by it (HOOK_DEADLINE_MS fallback), so this
16
+ // thin entry needs no watchdog of its own.
13
17
 
14
18
  export { runHook } from '../ukit/runtime/observability-emit.mjs';
@@ -195,9 +195,10 @@ async function withLock(lockPath, fn) {
195
195
  // this hook waiting on EOF.
196
196
  const inputFile = process.argv[2];
197
197
  (async () => {
198
- const payload = (() => {
198
+ // Async on purpose — a sync read on a stalled mount would park the deadline timer.
199
+ const payload = await (async () => {
199
200
  try {
200
- const raw = require("fs").readFileSync(inputFile, "utf8");
201
+ const raw = await fsp.readFile(inputFile, "utf8");
201
202
  return JSON.parse(raw || "{}");
202
203
  } catch {
203
204
  return {};
@@ -2157,6 +2157,32 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2157
2157
  return /(?<![A-Za-z0-9_])(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\s+the|cap\s+nhat|viet|chay|cai|chinh|trien\s+khai|cau\s+hinh)(?![A-Za-z0-9_])/.test(residue);
2158
2158
  }
2159
2159
 
2160
+ // USER-REPORTED (2026-10-01): "chỉ lên plan cho tôi" / "just plan it" asks
2161
+ // for a plan and nothing more — the deliverable is a document in the reply,
2162
+ // not a mutation. mutationOrder=false alone does NOT cover these: they name
2163
+ // files and future work, so the router lands them on local-build/local-fix,
2164
+ // mints write debt, and the gate forces an implement the user never asked
2165
+ // for. Plan-only = explicit plan-request signal AND no surviving mutation
2166
+ // order ("lên plan rồi sửa luôn" is an implement request, not plan-only).
2167
+ function isPlanOnlyRequest({ promptText = '', commandText = '' } = {}) {
2168
+ const folded = `${promptText ?? ''}\n${commandText ?? ''}`
2169
+ .toLowerCase()
2170
+ .normalize('NFD')
2171
+ .replace(/[\u0300-\u036f]/g, '')
2172
+ .replace(/\u0111/g, 'd');
2173
+ const trimmed = folded.trim();
2174
+ if (!trimmed) return false;
2175
+ const planSignal =
2176
+ /\b(?:chi|just|only)\s+(?:len|lap|vach|lam)\b[^.,;\n]{0,40}\b(?:plan|ke\s+hoach|phuong\s+an|lo\s+trinh|roadmap|blueprint)\b/.test(trimmed)
2177
+ || /\b(?:len|lap|vach|de\s+xuat|draft)\s+(?:ke\s+hoach|plan|phuong\s+an|lo\s+trinh|roadmap)\b/.test(trimmed)
2178
+ || /\b(?:plan|planning)\s+(?:only|first|mode)\b/.test(trimmed)
2179
+ || /\b(?:ke\s+hoach|plan|phuong\s+an|lo\s+trinh|roadmap)\b[^.,;\n]{0,40}\b(?:khong|chua|khong\s+can)\s+(?:implement|code|thuc\s+hien|thuc\s+thi|sua|lam|build|viet)\b/.test(trimmed)
2180
+ || (/\b(?:khong|chua|dung)\s+(?:implement|code|thuc\s+hien|thuc\s+thi|sua|lam|viet\s+code)\b/.test(trimmed)
2181
+ && /\b(?:plan|ke\s+hoach|phuong\s+an|roadmap)\b/.test(trimmed));
2182
+ if (!planSignal) return false;
2183
+ return !hasMutationOrder({ promptText, commandText });
2184
+ }
2185
+
2160
2186
  function deriveExecutionMode({
2161
2187
  promptText = '',
2162
2188
  commandText = '',
@@ -2844,6 +2870,9 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2844
2870
  ...(typeof routingContext.mutationOrder === 'boolean'
2845
2871
  ? { mutationOrder: routingContext.mutationOrder }
2846
2872
  : {}),
2873
+ ...(typeof routingContext.planOnly === 'boolean'
2874
+ ? { planOnly: routingContext.planOnly }
2875
+ : {}),
2847
2876
  };
2848
2877
  }
2849
2878
 
@@ -3518,6 +3547,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
3518
3547
  // mutation so the Stop gate can release a misrouted write-debt instead
3519
3548
  // of looping "make an Edit" on an answer-only question.
3520
3549
  mutationOrder: promptText.trim() ? hasMutationOrder({ promptText, commandText }) : null,
3550
+ planOnly: promptText.trim() ? isPlanOnlyRequest({ promptText, commandText }) : null,
3521
3551
  };
3522
3552
  const previousContext = await buildPreviousContextSnapshot({
3523
3553
  projectRoot,
@@ -3615,6 +3645,9 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
3615
3645
  // Was this prompt a real mutation order? The Stop gate reads this to
3616
3646
  // release read-only stops that were misrouted into a write-debt mode.
3617
3647
  mutationOrder: hasMutationOrder({ promptText, commandText }),
3648
+ // User-requested default-answer posture: a prompt that only wants a
3649
+ // plan/answer releases the gate even on a mutating route.
3650
+ planOnly: isPlanOnlyRequest({ promptText, commandText }),
3618
3651
  };
3619
3652
  const useIndexedContext = shouldUseIndexedContext({
3620
3653
  activeSkills: selected,
@@ -433,7 +433,7 @@ const NEW_SOURCE_FILE_RE = /\.(jsx?|tsx?|mjs|cjs|vue|svelte|css|scss|sass|less|h
433
433
  // `git status --porcelain` positions: untracked entries start '??', index-staged
434
434
  // adds start 'A'. Both are "component-create" candidates for the wiring check.
435
435
  // W2-D06: async execFile — a wedged git (slow index lock, network fs) must not
436
- // park the event loop inside the armed deadline block the way execFileSync did.
436
+ // park the event loop inside the armed deadline block the way a synchronous exec did.
437
437
  // SIGKILL keeps the child bound hard even for a TERM-ignoring process.
438
438
  async function readGitChangedPaths(projectRoot) {
439
439
  return new Promise((resolve) => {
@@ -451,6 +451,7 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
451
451
  // they get the fixed cleanup reserve instead of the acquisition slice.
452
452
  const releaseRetry = (op) => withTransientFsRetry(op, { deadlineMs: LOCK_RESERVE_MS });
453
453
 
454
+ let parentFailed = false;
454
455
  while (true) {
455
456
  // Abort wins over every other condition, checked once per iteration: an abort
456
457
  // landing mid-sweep/mid-op must still surface the typed aborted outcome — the
@@ -467,7 +468,9 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
467
468
  try {
468
469
  // The lock parent must exist before the atomic acquire — a first-ever run in a
469
470
  // fresh project would otherwise fail mkdir with ENOENT.
471
+ parentFailed = true;
470
472
  await retry(() => fs.mkdir(path.dirname(lockPath), { recursive: true }));
473
+ parentFailed = false;
471
474
  await retry(() => fs.mkdir(lockPath)); // atomic acquire — EEXIST means another holder exists
472
475
  inProcessLockHolders.set(lockPath, ownerToken);
473
476
  try {
@@ -503,7 +506,10 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
503
506
  if (signal?.aborted) {
504
507
  return { ok: false, reason: 'aborted', waitedMs: Date.now() - startedAt };
505
508
  }
506
- if (error?.code !== 'EEXIST') throw error;
509
+ // EEXIST from the PARENT mkdir means a regular file squats on the directory path —
510
+ // a broken store, not a lock holder. Waiting out the budget for it would burn the
511
+ // whole acquisition slice and then report 'busy'; surface the real error instead.
512
+ if (error?.code !== 'EEXIST' || parentFailed) throw error;
507
513
  }
508
514
 
509
515
  // Someone holds the lock. Reclaim it only after the stale threshold AND only when
@@ -326,12 +326,15 @@ function hasUnfinishedCompletion(state = {}, ledger = {}) {
326
326
  if (!IMPLEMENT_MODES.has(mode)) return false;
327
327
  const required = requiredEvidence(state);
328
328
  if (required.length === 0) return false;
329
- // Read-only prompts misrouted into a mutating mode stamp
330
- // routingContext.mutationOrder === false; their write-debt is a misroute, not
331
- // unfinished work (see evaluateCompletion's 'no-mutation-order' valve).
332
- const effectiveRequired = state?.routingContext?.mutationOrder === false
333
- && ledger?.writeAttempted !== true
334
- ? required.filter((item) => item !== 'write-evidence')
329
+ // USER-REPORTED (2026-10-01): default posture is answer/plan/review — a
330
+ // prompt that never ordered a mutation, or an explicit plan-only request,
331
+ // must not mint unfinished write/verification debt even when it was
332
+ // misrouted into a mutating mode (see evaluateCompletion's
333
+ // 'no-mutation-order' valve). A real edit attempt keeps the debt.
334
+ const readOnlyPrompt = state?.routingContext?.mutationOrder === false
335
+ || state?.routingContext?.planOnly === true;
336
+ const effectiveRequired = readOnlyPrompt && ledger?.writeAttempted !== true
337
+ ? required.filter((item) => item !== 'write-evidence' && item !== 'verification-evidence')
335
338
  : required;
336
339
  const unfinished = effectiveRequired.some((item) => !evidenceSatisfied(item, ledger, state));
337
340
  if (unfinished) return true;
@@ -2637,30 +2640,29 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
2637
2640
  };
2638
2641
  }
2639
2642
 
2640
- // USER-REPORTED (2026-10-01): a read-only question can still be routed into a
2641
- // mutating mode (find-cause/local-fix) when the prompt mentions mutation or
2642
- // failure vocabulary as SUBJECT matter ("nút Excel gọi hàm nào?", "find the
2643
- // function that runs on click") — the router's question-shape heuristics
2644
- // miss it, and the gate then demands a write the turn can never honestly
2645
- // produce: Stop → "make an Edit" → AskUserQuestion → Stop. The router now
2646
- // stamps routingContext.mutationOrder (hasMutationOrder on the raw prompt);
2647
- // `false` means the request never ordered a mutation, so write-debt is a
2648
- // misroute, not unfinished work. `null`/absent keeps the pre-stamp
2649
- // conservative path (no release) — only a positively-computed false opens
2650
- // the valve, and only when no mutation was actually attempted (a real edit
2651
- // keeps the normal gate regardless of prompt wording).
2652
- const mutationOrder = state?.routingContext?.mutationOrder;
2643
+ // USER-REPORTED (2026-10-01): the default posture is answer/plan/review —
2644
+ // "chỉ lên plan", "trả lời cho tôi", "xem lại plan" carry no mutation order
2645
+ // and the turn's deliverable is the reply itself. Two stamped signals cover
2646
+ // it: routingContext.mutationOrder === false (no imperative verb survived
2647
+ // the clause strips) or routingContext.planOnly === true (explicit
2648
+ // plan-request wording). When either holds, write-evidence AND
2649
+ // verification-evidence debt from a misrouted mutating lane are
2650
+ // misroutes, not unfinished work — release loud so the stop names why it
2651
+ // is allowed. `null`/absent stays conservative; an actual edit attempt
2652
+ // (writeAttempted) keeps the normal gate regardless of prompt wording.
2653
+ const readOnlyPrompt = state?.routingContext?.mutationOrder === false
2654
+ || state?.routingContext?.planOnly === true;
2653
2655
  if (
2654
- mutationOrder === false
2655
- && missingEvidence.includes('write-evidence')
2656
+ readOnlyPrompt
2656
2657
  && !effectiveLedger.writeAttempted
2658
+ && missingEvidence.some((item) => item === 'write-evidence' || item === 'verification-evidence')
2657
2659
  ) {
2658
2660
  return {
2659
2661
  continue: false,
2660
2662
  notify: true,
2661
2663
  gateRelease: 'no-mutation-order',
2662
2664
  missingEvidence,
2663
- reason: 'UKit completion gate released a read-only stop: this prompt ordered no mutation, so missing write-evidence is a misroute, not unfinished work. If an edit IS needed, re-send the task as an explicit change request.',
2665
+ reason: 'UKit completion gate released a read-only stop: this prompt asked for an answer or a plan, not a change, so missing write/verification evidence is a misroute, not unfinished work. To implement, re-send the task as an explicit change request ("implement", "sửa", "làm luôn", "execute this plan").',
2664
2666
  };
2665
2667
  }
2666
2668
 
@@ -201,6 +201,12 @@ export const HANDOFF_CURSOR_DEFAULTS = Object.freeze({
201
201
  // gate releases instead of trapping the session in an unstoppable loop — same
202
202
  // liveness shape as the ledger's noProgressCount breaker.
203
203
  maxStalledBlocks: 12,
204
+ // 'session' (default): the gate only bounces a stop when THIS session is the one
205
+ // driving the run (its transcript shows a handoff-fullstack invocation or a
206
+ // resume injection). A fresh session that merely shares the repo with a stale
207
+ // RUN.md — e.g. one opened just to plan something else — is never hijacked.
208
+ // 'project': legacy behaviour, any session in the repo is bounced.
209
+ scope: 'session',
204
210
  });
205
211
 
206
212
  // ─── Redaction ────────────────────────────────────────────────────────────
@@ -636,7 +642,17 @@ export async function evaluateStopReviewPolicy({ projectRoot, completionKind = n
636
642
  : 0;
637
643
 
638
644
  const ledgerModule = await loadLedgerReceiptModule();
639
- const appendDecisionReceipt = ledgerModule?.appendDecisionReceipt;
645
+ // The policy fires its calibration receipt without awaiting it; collect the promises so
646
+ // the receipt is on disk before this evaluator returns (a loaded host otherwise leaves
647
+ // the write racing the next reader).
648
+ const pendingReceipts = [];
649
+ const appendDecisionReceipt = typeof ledgerModule?.appendDecisionReceipt === 'function'
650
+ ? (...args) => {
651
+ const written = ledgerModule.appendDecisionReceipt(...args);
652
+ pendingReceipts.push(Promise.resolve(written).catch(() => {}));
653
+ return written;
654
+ }
655
+ : undefined;
640
656
 
641
657
  const decision = policyModule.evaluateReviewPolicy({
642
658
  signals: routeSummary.suspicionSignals ?? {},
@@ -648,6 +664,7 @@ export async function evaluateStopReviewPolicy({ projectRoot, completionKind = n
648
664
  projectRoot,
649
665
  appendDecisionReceipt,
650
666
  });
667
+ await Promise.all(pendingReceipts);
651
668
  if (!decision || typeof decision !== 'object') return null;
652
669
 
653
670
  // Persist the round counter + last verdict for the next stop (same atomic
@@ -686,6 +703,9 @@ async function loadHandoffGateConfig(projectRoot) {
686
703
  maxStalledBlocks: Number.isFinite(gate?.stopGateMaxStalledBlocks)
687
704
  ? Math.max(1, gate.stopGateMaxStalledBlocks)
688
705
  : HANDOFF_CURSOR_DEFAULTS.maxStalledBlocks,
706
+ scope: gate?.stopGateScope === 'project' || gate?.stopGateScope === 'session'
707
+ ? gate.stopGateScope
708
+ : HANDOFF_CURSOR_DEFAULTS.scope,
689
709
  };
690
710
  } catch {
691
711
  return { ...HANDOFF_CURSOR_DEFAULTS };
@@ -835,6 +855,81 @@ function formatPredicateState(result) {
835
855
  return result.terms.map((t) => `${t.term}=${t.pass ? 'pass' : `FAIL (${t.detail})`}`).join(', ');
836
856
  }
837
857
 
858
+ // ─── Session ownership (does THIS session drive the run?) ─────────────────
859
+ // Structured evidence that a session itself started or resumed a handoff-fullstack
860
+ // run. Raw-text matching is NOT enough: the marker strings appear in tool output,
861
+ // bash commands and source files any session may read, so only these shapes count:
862
+ // - a real user turn carrying the slash-command tag for handoff-fullstack
863
+ // - a Skill tool call naming handoff-fullstack
864
+ // - a SessionStart hook attachment carrying the resume injection
865
+ const HANDOFF_COMMAND_TAG = /<command-name>\/?(?:ukit:)?handoff-fullstack\b/;
866
+ const HANDOFF_RESUME_BANNER = 'UKIT HANDOFF RESUME \u2014 an unfinished handoff-fullstack run';
867
+ const OWNERSHIP_SCAN_BUDGET_MS = 1200;
868
+ const OWNERSHIP_SCAN_MAX_BYTES = 64 * 1024 * 1024;
869
+
870
+ function textOfUserTurn(content) {
871
+ if (typeof content === 'string') return content;
872
+ if (!Array.isArray(content)) return '';
873
+ // tool_result blocks are tool output, never the user's own words.
874
+ return content.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n');
875
+ }
876
+
877
+ function entryShowsHandoffRun(entry) {
878
+ if (!entry || typeof entry !== 'object') return false;
879
+ if (entry.type === 'user' && entry.message && entry.message.role === 'user') {
880
+ return HANDOFF_COMMAND_TAG.test(textOfUserTurn(entry.message.content));
881
+ }
882
+ if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) {
883
+ return entry.message.content.some((b) => b && b.type === 'tool_use' && b.name === 'Skill'
884
+ && /^(?:ukit:)?handoff-fullstack$/.test(String(b.input?.skill ?? '')));
885
+ }
886
+ if (entry.type === 'attachment' && entry.attachment && entry.attachment.hookEvent === 'SessionStart') {
887
+ const a = entry.attachment;
888
+ return [a.content, a.stdout, a.text].some((v) => typeof v === 'string' && v.includes(HANDOFF_RESUME_BANNER));
889
+ }
890
+ return false;
891
+ }
892
+
893
+ /**
894
+ * Stream the session transcript (JSONL) for structured handoff-run evidence.
895
+ * Returns true (found), false (whole transcript scanned, none found) or null
896
+ * (unknown: no path, unreadable, or scan budget/size cap hit). Callers treat
897
+ * null as "cannot prove this session is not the owner" and keep the legacy
898
+ * blocking behaviour — a real run must never be released by a lost read.
899
+ */
900
+ export async function transcriptShowsHandoffRun(transcriptPath, {
901
+ budgetMs = OWNERSHIP_SCAN_BUDGET_MS,
902
+ maxBytes = OWNERSHIP_SCAN_MAX_BYTES,
903
+ } = {}) {
904
+ if (typeof transcriptPath !== 'string' || !transcriptPath.trim()) return null;
905
+ let handle;
906
+ try {
907
+ handle = await fs.open(transcriptPath, 'r');
908
+ const deadline = Date.now() + budgetMs;
909
+ const chunk = Buffer.allocUnsafe(1024 * 1024);
910
+ let carry = '';
911
+ let total = 0;
912
+ const scanLine = (line) => {
913
+ // Cheap prefilter: skip JSON.parse for lines that cannot be evidence.
914
+ if (!line.includes('handoff-fullstack') && !line.includes('HANDOFF RESUME')) return false;
915
+ try { return entryShowsHandoffRun(JSON.parse(line)); } catch { return false; }
916
+ };
917
+ for (;;) {
918
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
919
+ if (!bytesRead) return scanLine(carry);
920
+ total += bytesRead;
921
+ const lines = (carry + chunk.toString('utf8', 0, bytesRead)).split('\n');
922
+ carry = lines.pop() ?? '';
923
+ for (const line of lines) if (scanLine(line)) return true;
924
+ if (total >= maxBytes || Date.now() > deadline) return null;
925
+ }
926
+ } catch {
927
+ return null;
928
+ } finally {
929
+ try { await handle?.close(); } catch {}
930
+ }
931
+ }
932
+
838
933
  /**
839
934
  * Mutate `.ukit/storage/cache/stop-coordinator/state.json`'s `handoff` slot:
840
935
  * { signature, count }. Same signature as last time → count+1; a moved cursor
@@ -900,7 +995,7 @@ async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs }) {
900
995
  *
901
996
  * Advisory lane: EVERY failure path returns { kind: 'none' }, never throws.
902
997
  */
903
- export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), lockBudgetMs = LOCK_BUDGET_MS } = {}) {
998
+ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), lockBudgetMs = LOCK_BUDGET_MS, transcriptPath = null } = {}) {
904
999
  const gate = await loadHandoffGateConfig(projectRoot);
905
1000
  if (!gate.enabled) return { kind: 'none' };
906
1001
 
@@ -917,8 +1012,16 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
917
1012
  if (!phase || terminal === 'blocked') {
918
1013
  return { kind: 'none' };
919
1014
  }
1015
+ if (terminal === 'done' && !exitPredicate) return { kind: 'none' }; // legacy phase-only release
1016
+
1017
+ // Session scope: a run owned by another session (or abandoned) must not
1018
+ // hijack an unrelated session. Only a positive "scanned the whole transcript,
1019
+ // no handoff evidence" releases; unknown (null) keeps the block.
1020
+ if (gate.scope === 'session' && await transcriptShowsHandoffRun(transcriptPath) === false) {
1021
+ return { kind: 'none' };
1022
+ }
1023
+
920
1024
  if (terminal === 'done') {
921
- if (!exitPredicate) return { kind: 'none' }; // legacy phase-only release
922
1025
  let predicate;
923
1026
  try {
924
1027
  predicate = await evaluateExitPredicate(projectRoot, exitPredicate);
@@ -1064,7 +1167,12 @@ export async function runStopCoordinator({
1064
1167
  }
1065
1168
  let handoff = null;
1066
1169
  try {
1067
- handoff = await evaluateHandoffCursor({ projectRoot, now, lockBudgetMs });
1170
+ handoff = await evaluateHandoffCursor({
1171
+ projectRoot,
1172
+ now,
1173
+ lockBudgetMs,
1174
+ transcriptPath: typeof payload?.transcript_path === 'string' ? payload.transcript_path : null,
1175
+ });
1068
1176
  } catch (error) {
1069
1177
  failures.handoff = error?.message || String(error);
1070
1178
  process.stderr.write(`[ukit-stop-coordinator] handoff-cursor evaluator failed: ${failures.handoff}\n`);
@@ -1056,7 +1056,12 @@ export async function runSessionStop(
1056
1056
  let handoff = null;
1057
1057
  if (coordinator) {
1058
1058
  try {
1059
- handoff = await coordinator.evaluateHandoffCursor({ projectRoot, now, lockBudgetMs });
1059
+ handoff = await coordinator.evaluateHandoffCursor({
1060
+ projectRoot,
1061
+ now,
1062
+ lockBudgetMs,
1063
+ transcriptPath: typeof payload.transcript_path === 'string' ? payload.transcript_path : null,
1064
+ });
1060
1065
  } catch (error) {
1061
1066
  pi.logger?.warn?.(`[UKit] handoff-cursor evaluator failed (advisory lane): ${error?.message || error}`);
1062
1067
  }
@@ -143,6 +143,9 @@ chạy **đến khi không còn gì để làm**:
143
143
  - Stop gate (Claude Code): `RUN.md` `Phase:` ≠ `done`/`blocked` → Stop hook từ chối
144
144
  stop kèm `Next:` step. Breaker `stopGateMaxStalledBlocks` (mặc định 12) thả ra nếu
145
145
  cursor không nhích — lối thoát, không phải đường thường.
146
+ - Gate theo session (`stopGateScope: session`, mặc định): chỉ ép session đã chạy `/ukit:handoff-fullstack`
147
+ hoặc nhận resume marker (compact/resume). Session mới (startup/clear) chỉ nhận ghi chú, không bị ép;
148
+ muốn nối run cũ thì chạy `/ukit:handoff-fullstack`. Không đọc được transcript → giữ hành vi chặn cũ.
146
149
  - `handoff-clear` PHẢI đặt `Phase: done` (hoặc xóa RUN.md) — cursor sống sẽ giữ gate
147
150
  chặn stop của session sau.
148
151
  - Phase F: docs sync (WORKLOG luôn; PROJECT/CODE_MAP/CHANGELOG khi đổi surface) →
@@ -525,6 +525,7 @@
525
525
  "fullstack": {
526
526
  "stopGateEnabled": true,
527
527
  "stopGateMaxStalledBlocks": 12,
528
+ "stopGateScope": "session",
528
529
  "stopGateMaxCrashStreaks": 3,
529
530
  "quietScansRequired": 2,
530
531
  "idleWatchdogMin": 5,
@@ -836,6 +837,7 @@
836
837
  "fullstack": {
837
838
  "stopGateEnabled": "B\u1eadt Stop gate handoff: RUN.md Phase != done/blocked \u2192 Stop hook t\u1eeb ch\u1ed1i stop k\u00e8m Next step, gi\u1eef handoff-fullstack ch\u1ea1y ti\u1ebfp thay v\u00ec \u0111\u1ee9ng sau recap. T\u1eaft ch\u1ec9 khi debug.",
838
839
  "stopGateMaxStalledBlocks": "Liveness breaker: cursor kh\u00f4ng nh\u00edch qua N l\u1ea7n block li\u00ean ti\u1ebfp th\u00ec gate th\u1ea3 stop (tr\u00e1nh v\u00f2ng l\u1eb7p v\u00f4 t\u1eadn). M\u1eb7c \u0111\u1ecbnh 12.",
840
+ "stopGateScope": "session = Stop gate chỉ ép khi chính session này đang chạy handoff-fullstack (transcript có /ukit:handoff-fullstack hoặc resume marker); session mới mở chỉ để plan/việc khác không bị ép. project = cũ: mọi session trong repo đều bị ép khi RUN.md còn sống.",
839
841
  "stopGateMaxCrashStreaks": "Liveness breaker cho coordinator crash: sau N l\u1ea7n block li\u00ean ti\u1ebfp do infrastructure failure, gate th\u1ea3 stop k\u00e8m c\u1ea3nh b\u00e1o l\u1edbn. M\u1eb7c \u0111\u1ecbnh 3.",
840
842
  "quietScansRequired": "S\u1ed1 scan li\u00ean ti\u1ebfp kh\u00f4ng th\u1ea5y vi\u1ec7c m\u1edbi tr\u01b0\u1edbc khi handoff-fullstack \u0111\u01b0\u1ee3c ph\u00e9p \u0111\u00f3ng run. M\u1eb7c \u0111\u1ecbnh 2.",
841
843
  "idleWatchdogMin": "Kho\u1ea3ng ph\u00fat cho scheduled wakeup n\u1ebfu harness h\u1ed7 tr\u1ee3 (cron/loop). M\u1eb7c \u0111\u1ecbnh 5.",