@ngockhoale/ukit 3.4.12 → 3.4.14

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.
@@ -855,79 +855,40 @@ function formatPredicateState(result) {
855
855
  return result.terms.map((t) => `${t.term}=${t.pass ? 'pass' : `FAIL (${t.detail})`}`).join(', ');
856
856
  }
857
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 ?? '')));
858
+ // ─── Session intent (does THIS session CURRENTLY drive the run?) ──────────
859
+ // Structured-evidence rules live in the shared handoff-intent module so the
860
+ // SessionStart resume hook and this Stop lane classify a transcript identically.
861
+ // Only the MOST RECENT handoff intent counts: a session that ran
862
+ // /ukit:handoff-fullstack earlier and has since run /ukit:handoff-create is
863
+ // planning, not implementing, and must not be bounced into a stale RUN.md. The
864
+ // converse also holds — a create-then-fullstack session reacquires ownership.
865
+ // Evidence is structured only (real user-turn command tags, Skill calls,
866
+ // SessionStart resume injections); raw text is never used, because the marker
867
+ // strings also appear in tool output, bash commands and source files any session
868
+ // may read (TASK-007).
869
+ let handoffIntentModulePromise = null;
870
+ function loadHandoffIntentModule() {
871
+ if (!handoffIntentModulePromise) {
872
+ handoffIntentModulePromise = import(new URL('./handoff-intent.mjs', import.meta.url).href)
873
+ .catch(() => null);
885
874
  }
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;
875
+ return handoffIntentModulePromise;
891
876
  }
892
877
 
893
878
  /**
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.
879
+ * Does the transcript show this session as the CURRENT owner of a handoff-fullstack
880
+ * run? Returns true (yes), false (whole window scanned, latest intent is not
881
+ * fullstack), or null (unknown: no path, unreadable, budget hit, or the shared
882
+ * module is unavailable). Callers treat null as "cannot prove this session is not
883
+ * the owner" and keep the legacy blocking behaviour — a real run must never be
884
+ * released by a lost read.
899
885
  */
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
- }
886
+ export async function transcriptShowsHandoffRun(transcriptPath, opts = {}) {
887
+ const mod = await loadHandoffIntentModule();
888
+ if (!mod || typeof mod.transcriptHandoffIntent !== 'function') return null;
889
+ const { intent, known } = await mod.transcriptHandoffIntent(transcriptPath, opts);
890
+ if (!known) return null;
891
+ return intent === 'fullstack';
931
892
  }
932
893
 
933
894
  /**
@@ -1015,8 +976,10 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
1015
976
  if (terminal === 'done' && !exitPredicate) return { kind: 'none' }; // legacy phase-only release
1016
977
 
1017
978
  // 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.
979
+ // hijack an unrelated session, and a session whose CURRENT intent is planning
980
+ // must not be bounced into a stale run. Only a positive "scanned the whole
981
+ // transcript, latest intent is not fullstack" releases; unknown (null) keeps
982
+ // the block, so a real run is never released by a lost read.
1020
983
  if (gate.scope === 'session' && await transcriptShowsHandoffRun(transcriptPath) === false) {
1021
984
  return { kind: 'none' };
1022
985
  }
@@ -137,7 +137,8 @@ chạy **đến khi không còn gì để làm**:
137
137
  `HANDOFF FULLSTACK BLOCKED`. Recap/checkpoint không phải điểm dừng — dùng form
138
138
  `CHECKPOINT — WORK CONTINUING` rồi làm tiếp ngay.
139
139
  - Phase 0 sweep gom mọi unfinished work: INDEX, task files, cycle cũ (HISTORY/archive),
140
- `docs/TASKS.md` `Ready for AI`, uncommitted WIP.
140
+ `docs/TASKS.md` `Ready for AI`, blueprint ngủ đông trong `docs/AI_HANDOFF/queued/*/`,
141
+ uncommitted WIP.
141
142
  - Quiet-period: chỉ đóng run khi backlog rỗng + `handoff.fullstack.quietScansRequired`
142
143
  (mặc định 2) scan liên tiếp không thấy việc mới.
143
144
  - Stop gate (Claude Code): `RUN.md` `Phase:` ≠ `done`/`blocked` → Stop hook từ chối
@@ -146,6 +147,27 @@ chạy **đến khi không còn gì để làm**:
146
147
  - Gate theo session (`stopGateScope: session`, mặc định): chỉ ép session đã chạy `/ukit:handoff-fullstack`
147
148
  hoặc nhận resume marker (compact/resume). Session mới (startup/clear) chỉ nhận ghi chú, không bị ép;
148
149
  muốn nối run cũ thì chạy `/ukit:handoff-fullstack`. Không đọc được transcript → giữ hành vi chặn cũ.
150
+ - **Tách ý định — create CHỈ plan**: `/ukit:handoff-create` chỉ viết SPEC.md/PLAN.md/tasks, không sửa
151
+ code sản phẩm, không gọi executor, không tự escalate sang implement; plan dài cứ queue ở `ready`
152
+ chờ lệnh tường minh. Ý định handoff tính theo **lệnh gần nhất** trong transcript, chỉ từ structured
153
+ evidence của đúng host: Claude Code (`<command-name>` tag), omp (`message` role user có dòng đầu là
154
+ heading mở rộng `# /ukit:handoff-*`, và `custom_message` `ukit-hook-context`), Codex (`response_item`
155
+ message `input_text`). Format transcript lạ → trả `null` (không đoán), nên caller giữ nguyên thế
156
+ fail-closed. Kết quả: session vừa chạy create — kể cả khi trước đó từng chạy fullstack — có ý định hiện
157
+ tại là *planning*, nên Stop gate lẫn hook compact/resume đều không ép nó chạy lại `RUN.md` cũ.
158
+ `/ukit:handoff-fullstack` gọi tường minh sẽ giành lại quyền sở hữu và resume việc đã queue; run đang
159
+ implement vẫn bị ép hoàn tất. Không đọc được transcript → giữ hành vi cũ (nghiêng về block/resume),
160
+ không release nhầm run thật.
161
+ - **Create không bao giờ ghi đè run sống**: `RUN.md` có `Phase:` ≠ `done`/`blocked` = run fullstack đang
162
+ sống. `/ukit:handoff-create` lúc đó KHÔNG được chạm `RUN.md`/`INDEX.md`/`PLAN.md`/`SPEC.md`/`ACTIVE.md`/
163
+ `tasks/TASK-*.md` — kể cả khi mọi task canonical đang `ready` (luật "re-run allowed" không áp dụng).
164
+ Thay vào đó ghi plan mới vào **queued path** `docs/AI_HANDOFF/queued/<slug>/` (SPEC/PLAN/INDEX/HANDOFF
165
+ + tasks/) như một blueprint ngủ đông, rồi báo đường dẫn. Chỉ khi human **tường minh** ra lệnh bỏ run
166
+ (`/ukit:handoff-clear` → `Phase: done`/xóa RUN.md) mới được plan thẳng vào canonical. Executor không
167
+ bao giờ được kích hoạt ở phase này; planner chỉ ghi dưới đúng một output root được chỉ định.
168
+ - **Không drop việc đã queue**: `/ukit:handoff-fullstack` P2 sweep các blueprint `queued/*/` — cycle mới
169
+ kéo chúng vào `tasks/TASK-xxx.md` như mọi việc khác, hoặc ghi rõ lý do out-of-scope trong `PLAN.md §1/§2`.
170
+ Resume (RUN.md đang sống) giữ nguyên blueprint cho cycle sau, không ghi đè.
149
171
  - `handoff-clear` PHẢI đặt `Phase: done` (hoặc xóa RUN.md) — cursor sống sẽ giữ gate
150
172
  chặn stop của session sau.
151
173
  - Phase F: docs sync (WORKLOG luôn; PROJECT/CODE_MAP/CHANGELOG khi đổi surface) →