@ngockhoale/ukit 3.4.12 → 3.4.13

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,24 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.4.13 - 2026-10-04
6
+
7
+ **`/ukit:handoff-create` is strictly planning-only; the Stop gate follows the session's CURRENT handoff intent.**
8
+ `handoff-create` writes SPEC/PLAN/task files under `docs/AI_HANDOFF/` and stops with tasks
9
+ `ready` (queued) — it never edits product code, launches executors, or escalates itself
10
+ into implementation, even when an unfinished `RUN.md` exists or after compact/resume.
11
+ New shared `handoff-intent.mjs` classifies a transcript by its MOST RECENT structured
12
+ intent (`/ukit:handoff-create` vs `/ukit:handoff-fullstack` command tag, `Skill` call,
13
+ SessionStart resume banner). The Stop gate and `handoff-resume.sh` both use it: a session
14
+ that planned after an earlier fullstack run is no longer bounced into the stale run,
15
+ while a session actually driving `handoff-fullstack` (including create → fullstack
16
+ re-acquire) is still held to completion — the completion gate and ExitPredicate are
17
+ unchanged. Unknown/unreadable/windowed-out transcripts stay fail-closed. Claude Code,
18
+ Codex and omp share the same runtime, with native transcript envelopes normalized.
19
+ Planning alongside a live run preserves its cursor, index and tasks and writes a
20
+ separate queued plan instead. Tests: `handoffIntentSeparation`,
21
+ `handoffResumeIntent`, `handoffCreateQueueing`.
22
+
5
23
  ## 3.4.12 - 2026-10-02
6
24
 
7
25
  **Handoff Stop gate is session-scoped — a stale `RUN.md` no longer hijacks unrelated sessions.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.12",
3
+ "version": "3.4.13",
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",
@@ -14,6 +14,52 @@ $ARGUMENTS
14
14
  > **Can be run multiple times.** If the plan or tasks aren't satisfactory after the first run, re-run `/ukit:handoff-create` to refine until the plan is good — as long as no task has been implemented yet.
15
15
  > **NO git commit. NO git push.** This phase only writes docs files. No code changes.
16
16
 
17
+ ## Planning-only contract — this command never implements
18
+
19
+ `/ukit:handoff-create` produces **SPEC.md, PLAN.md, and decomposed task files under
20
+ `docs/AI_HANDOFF/` — nothing else.** It never edits product code, never launches an
21
+ executor, and never escalates itself into implementation. Long plans are simply
22
+ **queued**: after Step 2.5 the command stops with the tasks in `status=ready`, and
23
+ implementation waits for an explicit human action.
24
+
25
+ This holds even when `docs/AI_HANDOFF/RUN.md` already carries an unfinished
26
+ handoff-fullstack cursor. Running this command **makes the session's handoff intent
27
+ planning**, so the Stop gate and the SessionStart compact/resume hook both keep quiet
28
+ until the human explicitly resumes.
29
+
30
+ ### A live fullstack run is never overwritten — the new plan is QUEUED
31
+
32
+ Before writing anything, read `docs/AI_HANDOFF/RUN.md`. If it exists and its `Phase:`
33
+ is **not** `done`/`blocked`, a fullstack run is live and owns the canonical plan:
34
+
35
+ - **Never touch** `RUN.md`, `INDEX.md`, `PLAN.md`, `SPEC.md`, `ACTIVE.md`, or
36
+ `tasks/TASK-*.md`. Overwriting them — which the "all tasks are `ready`" rule below
37
+ would otherwise allow — destroys in-flight tasks, executor reports and verdicts.
38
+ - Write the whole new plan under the **queued path** instead:
39
+ `docs/AI_HANDOFF/queued/<slug>/` — `SPEC.md`, `PLAN.md`, `INDEX.md`, `HANDOFF.md`
40
+ (activation notes: what it depends on, when to start) and `tasks/`. This is a
41
+ blueprint, not a runnable cycle: no `tasks/TASK-*.md` row is created and nothing is
42
+ executed.
43
+ - Report the queued path; the live run continues untouched.
44
+
45
+ **Explicit abandonment is the only way to replace a live run.** Only when the human
46
+ explicitly says to abandon/clear the current run, first run `/ukit:handoff-clear`
47
+ (sets `Phase: done` / removes `RUN.md`), then plan against the canonical files. A mere
48
+ mention of the new work is not abandonment — absent that explicit instruction, always
49
+ take the queued path.
50
+
51
+ ### Planning-only output root (decided here, passed to the planner)
52
+
53
+ The planner writes to exactly ONE root, chosen above: the **canonical root**
54
+ `docs/AI_HANDOFF/` when no run is live, else the **queued root**
55
+ `docs/AI_HANDOFF/queued/<slug>/`. Pass it to the planner as its planning-only output
56
+ root; the planner writes nothing outside it and never launches an executor.
57
+
58
+ - To implement later: `/ukit:handoff-implement` (plan-only flow), or
59
+ `/ukit:handoff-fullstack` (one-shot pipeline) — that explicit invocation reacquires
60
+ ownership, resumes a live run, or sweeps a queued blueprint in once the run is closed.
61
+ - To abandon a paused run first: `/ukit:handoff-clear`.
62
+
17
63
  ---
18
64
 
19
65
  ## Question Policy — this is the one phase that may ask
@@ -47,7 +93,8 @@ After the batched call, run Steps 1–2.5 to completion without further question
47
93
  2. Read `docs/AI_HANDOFF/ACTIVE.md` → active cycle info (or "no active cycle")
48
94
  3. Read `docs/AI_HANDOFF/RULES.md` → PLAN.md 6-section format + Task Gate required fields
49
95
  4. Read `docs/AI_HANDOFF/tasks/_TEMPLATE.md` → task file structure
50
- 5. Return a compact summary. Do NOT write anything yet.
96
+ 5. Read `docs/AI_HANDOFF/RUN.md` → its `Phase:` line (or "no run"). This decides the output root.
97
+ 6. Return a compact summary. Do NOT write anything yet.
51
98
 
52
99
  > **For the human operator, on a tool with no agent support (Codex) — not an instruction to the model:** manually switch to the lite model, run the steps above yourself, keep the summary in context.
53
100
 
@@ -55,11 +102,19 @@ After the batched call, run Steps 1–2.5 to completion without further question
55
102
 
56
103
  ## Step 2 — Write plan + tasks (strong model)
57
104
 
58
- **Claude Code — MANDATORY, do this before anything else:** call the Agent tool with `subagent_type: "handoff-planner"` (omp: the `task` tool with `agent: "handoff-planner"`), passing it the Step 1 summary and the problem/feature description. Do NOT write PLAN.md or task files yourself in the current session — this step is contracted to the strong tier (opus/unic-smart), which only the spawned agent's frontmatter model guarantees.
105
+ **Claude Code — MANDATORY, do this before anything else:** call the Agent tool with `subagent_type: "handoff-planner"` (omp: the `task` tool with `agent: "handoff-planner"`), passing it the Step 1 summary, the problem/feature description, and the **planning-only output root** (`docs/AI_HANDOFF/` when no run is live, else `docs/AI_HANDOFF/queued/<slug>/`) decided in the live-run guard below. Do NOT write PLAN.md or task files yourself in the current session — this step is contracted to the strong tier (opus/unic-smart), which only the spawned agent's frontmatter model guarantees. The planner never launches an executor.
59
106
 
60
107
  The planner agent does the following (use Step 1 summary — do NOT re-read files):
61
108
 
62
- 1. Check INDEX.md task statuses:
109
+ 0. **Live-run guard — decide OUTPUT_ROOT first (see "A live fullstack run is never
110
+ overwritten").** If Step 1 reported `RUN.md` with a `Phase:` that is not `done`/`blocked`,
111
+ a fullstack run is live: set `OUTPUT_ROOT = docs/AI_HANDOFF/queued/<slug>/` (`<slug>` =
112
+ a short kebab name for this work), write ONLY there, and skip steps 1, 6 and 7 — the
113
+ canonical `RUN.md`/`INDEX.md`/`PLAN.md`/`SPEC.md`/`ACTIVE.md`/`tasks/` stay untouched.
114
+ Otherwise `OUTPUT_ROOT = docs/AI_HANDOFF/`. Everything below writes under `OUTPUT_ROOT`.
115
+
116
+ 1. Check INDEX.md task statuses (canonical root only — skip when the live-run guard routed
117
+ you to the queued root):
63
118
  - All tasks are `ready` (planning only) → **re-run allowed**: overwrite PLAN.md and TASK-xxx.md freely — this is iterative refinement.
64
119
  - Any task is `pending_review`, `changes_requested`, `merge_conflict`, or `blocked` → **do not overwrite**: that cycle is mid-flight and its task files carry executor reports and verdicts. Report which tasks are in flight and point the human at `/ukit:handoff-review` (to finish the cycle) or `/ukit:handoff-clear` (to abandon it). Planning is the one phase where stopping is correct — there is nothing safe to do automatically here.
65
120
  - No tasks → fresh cycle, proceed normally.
@@ -69,7 +124,7 @@ The planner agent does the following (use Step 1 summary — do NOT re-read file
69
124
  BASE=$(git symbolic-ref --short HEAD)
70
125
  ```
71
126
 
72
- 3. Write `docs/AI_HANDOFF/SPEC.md` — the detailed implementation spec (BẮT BUỘC before tasks).
127
+ 3. Write `${OUTPUT_ROOT}SPEC.md` — the detailed implementation spec (BẮT BUỘC before tasks).
73
128
  Follow `docs/AI_HANDOFF/SPEC.md`'s template sections (or `template_project/docs/AI_HANDOFF/SPEC.md`
74
129
  on a fresh tree): problem/context, goals, non-goals, user journeys, functional requirements
75
130
  with Given/When/Then + error cases, fullstack scope (backend, schema/migrations, API
@@ -83,7 +138,7 @@ The planner agent does the following (use Step 1 summary — do NOT re-read file
83
138
  resolved to a chosen default recorded inline — the plan phase is the only question
84
139
  window, so anything left "TBD" becomes a guess downstream.
85
140
 
86
- 4. Write `docs/AI_HANDOFF/PLAN.md` — all 6 sections mandatory:
141
+ 4. Write `${OUTPUT_ROOT}PLAN.md` — all 6 sections mandatory:
87
142
  - §1 Intent — problem + success definition
88
143
  - §2 Scope — in / out of scope. **Add a constraint**: same-wave tasks must not modify the same file (prevents merge conflicts). If two tasks need the same file, make one depend on the other.
89
144
  - §3 Approach — solution, trade-offs, alternatives rejected
@@ -97,7 +152,7 @@ The planner agent does the following (use Step 1 summary — do NOT re-read file
97
152
  PLANNER_MODEL: <your exact model ID>
98
153
  ```
99
154
 
100
- 5. Create `docs/AI_HANDOFF/tasks/TASK-001.md`, `TASK-002.md`... from `_TEMPLATE.md`
155
+ 5. Create `${OUTPUT_ROOT}tasks/TASK-001.md`, `TASK-002.md`... from `_TEMPLATE.md`
101
156
  Every task MUST have:
102
157
  - Spec references (SPEC.md section IDs this task implements)
103
158
  - Target Files (exact paths — no two tasks in same wave share a file)
@@ -108,9 +163,12 @@ The planner agent does the following (use Step 1 summary — do NOT re-read file
108
163
  - Acceptance Criteria (verifiable checklist)
109
164
  Missing any field → status: `needs_breakdown`, never `ready`
110
165
 
111
- 6. Update `INDEX.md` — one row per task, `status=ready`
166
+ 6. Update `${OUTPUT_ROOT}INDEX.md` — one row per task, `status=ready`. **Skip entirely when
167
+ the live-run guard routed you to the queued root** — a blueprint carries no runnable
168
+ `ready` row; its `HANDOFF.md` activation notes are the record instead.
112
169
 
113
- 7. Update `ACTIVE.md`:
170
+ 7. Update `ACTIVE.md` (canonical root only — **skip when queued**; the queued blueprint's
171
+ `HANDOFF.md` replaces it):
114
172
  ```
115
173
  Cycle: <ID> Date: <YYYY-MM-DD> Base: <BASE>
116
174
  Goal: <1 sentence>
@@ -120,9 +178,10 @@ The planner agent does the following (use Step 1 summary — do NOT re-read file
120
178
  ```
121
179
  Note: wave structure is inferred from task Dependencies fields — not stored here.
122
180
 
123
- 8. Report: task IDs, dependency graph, any `needs_breakdown` + reason
181
+ 8. Report: task IDs, dependency graph, any `needs_breakdown` + reason — and, when queued,
182
+ the `OUTPUT_ROOT` path plus why it was queued (the live run it must not disturb).
124
183
 
125
- > **For the human operator, on a tool with no agent support (Codex) — not an instruction to the model:** manually switch to the strong model, execute steps 1–7 above yourself.
184
+ > **For the human operator, on a tool with no agent support (Codex) — not an instruction to the model:** manually switch to the strong model, execute steps 0–8 above yourself.
126
185
 
127
186
  ---
128
187
 
@@ -137,7 +196,7 @@ The planner agent does the following (use Step 1 summary — do NOT re-read file
137
196
  Two independent strong-model passes shape the plan before any code is written — that gate is
138
197
  intact. What it no longer does is hand a stalled plan back and wait.
139
198
 
140
- **Claude Code — MANDATORY, do this before anything else:** call the Agent tool with `subagent_type: "code-reviewer"` (omp: the `task` tool with `agent: "code-reviewer"`), passing `REVIEW_TARGET_TYPE=plan` and the paths to `docs/AI_HANDOFF/PLAN.md` AND `docs/AI_HANDOFF/SPEC.md`. This MUST be a separate agent invocation from Step 2's `handoff-planner` call (fresh context) — same-session self-review defeats the purpose of an independent gate.
199
+ **Claude Code — MANDATORY, do this before anything else:** call the Agent tool with `subagent_type: "code-reviewer"` (omp: the `task` tool with `agent: "code-reviewer"`), passing `REVIEW_TARGET_TYPE=plan` and the paths to `${OUTPUT_ROOT}PLAN.md` AND `${OUTPUT_ROOT}SPEC.md` (canonical root, or the queued root when one was chosen). This MUST be a separate agent invocation from Step 2's `handoff-planner` call (fresh context) — same-session self-review defeats the purpose of an independent gate.
141
200
 
142
201
  1. Reviewer reads `SPEC.md` + `PLAN.md` (no diff, no task files, no executor report), checks Completeness / Consistency / Clarity / Scope / YAGNI plus the spec quality gate — every requirement testable, every fullstack layer covered, dependencies explicit, no vague instruction left — see `.claude/agents/code-reviewer.md` → Spec/Plan Review — and appends its verdict to PLAN.md's `## Plan Review Log` (new round entry, prior rounds kept).
143
202
  2. `Issues Found` → route back to Step 2: planner revises `PLAN.md` and the affected `TASK-xxx.md` files to address every finding, then re-submit for another Step 2.5 review (this becomes the next round). Do NOT commit or hand off to executor on `Issues Found`.
@@ -147,4 +206,13 @@ intact. What it no longer does is hand a stalled plan back and wait.
147
206
 
148
207
  ---
149
208
 
150
- **Next:** switch to code model → `/ukit:handoff-implement`
209
+ **Next:** the plan is complete and every task is `ready` (queued — nothing has been
210
+ implemented). To start implementation, the human runs `/ukit:handoff-implement` for the
211
+ plan-only flow, or `/ukit:handoff-fullstack` to drive plan → implement → review as one
212
+ one-shot pipeline. This command does not do either on its own.
213
+
214
+ When a live fullstack run forced the plan into `docs/AI_HANDOFF/queued/<slug>/`, that
215
+ blueprint is dormant: `/ukit:handoff-fullstack` sweeps it into the canonical plan once the
216
+ current run is closed (or the human explicitly abandons it with `/ukit:handoff-clear`),
217
+ copying the queued tasks into `tasks/` at that point — exactly like any other queued
218
+ blueprint. Nothing is dropped, and nothing is auto-implemented before then.
@@ -111,6 +111,23 @@ all treats it as closed. It is *paused*, not abandoned: an explicit `/ukit:hando
111
111
  re-invocation resumes it as a continuation; `ukit handoff-clear` abandons it. Only when
112
112
  `RUN.md` is absent or `Phase: done` does a new request start a fresh cycle.
113
113
 
114
+ ### Explicit invocation reacquires ownership
115
+
116
+ `/ukit:handoff-create` is planning-only and makes a session's handoff intent *planning* — so
117
+ while the human is planning, neither the Stop gate nor the SessionStart compact/resume hook
118
+ forces a paused run back into implementation. This command is the explicit counter-action:
119
+
120
+ - Invoking `/ukit:handoff-fullstack` (or running the `handoff-fullstack` skill) makes the
121
+ session's handoff intent *implementation* again — ownership is reacquired, the queued
122
+ `ready` tasks from the create phase are picked up, and a `Phase:` outside `done`/`blocked`
123
+ in `RUN.md` is once more held to completion by the Stop gate.
124
+ - Work queued by planning is **not lost**: it waits in `INDEX.md`/`PLAN.md` until this
125
+ explicit invocation (or `/ukit:handoff-implement`) claims it.
126
+
127
+ Enforcement is therefore never weakened — it is scoped to *which* command the session
128
+ actually invoked. A session driving this pipeline still cannot stop early; a session that
129
+ only planned never gets bounced into a run it did not ask for.
130
+
114
131
  ---
115
132
 
116
133
  ## §0 — Completion contract (terminal outputs)
@@ -153,11 +170,23 @@ work, not only the current plan:
153
170
  3. Previous cycles — `docs/AI_HANDOFF/HISTORY.md` and `archive/` for cycles closed with
154
171
  unfinished tasks; their leftovers join THIS cycle.
155
172
  4. `docs/TASKS.md` — `Ready for AI` items are newly-assigned work; fold them into the plan.
156
- 5. `git status` / `git diff` — uncommitted work-in-progress that must be finished or
173
+ 5. `docs/AI_HANDOFF/queued/*/` — dormant blueprints. A planning run that could **not**
174
+ touch the canonical plan (a live run was in flight) queues its SPEC/PLAN/tasks under
175
+ `docs/AI_HANDOFF/queued/<slug>/` instead of overwriting (see `/ukit:handoff-create`).
176
+ When this run is planning a fresh cycle (P2), sweep those blueprints in: copy a
177
+ blueprint's tasks into `tasks/TASK-xxx.md` and schedule them like any other item. Never
178
+ overwrite a blueprint — a blueprint the run decides to defer stays queued.
179
+ 6. `git status` / `git diff` — uncommitted work-in-progress that must be finished or
157
180
  checkpointed, never silently dropped.
158
181
 
159
182
  The inventory feeds P2: the planner either schedules every discovered item or records why
160
- it is out of scope (§1/§2 of PLAN.md). Silent omission is a plan defect.
183
+ it is out of scope (§1/§2 of PLAN.md). Silent omission is a plan defect — a queued
184
+ blueprint dropped without either scheduling or a recorded reason is exactly that defect.
185
+
186
+ > **Never overwrite a live run to consume a queued blueprint.** A blueprint is folded in
187
+ > only through P2's normal plan-write path, and only when no run is mid-flight; on a
188
+ > Resume (RUN.md `Phase:` not `done`/`blocked`) the run continues from its own cursor and
189
+ > leaves the queued blueprint untouched for the next cycle.
161
190
 
162
191
  **Recovery rule — stuck task records.** For every `pending`, stale `in_progress`, or
163
192
  `blocked` task whose record cannot be cleanly resumed (invalid state, orphaned worktree
@@ -105,8 +105,31 @@ setTimeout(() => {
105
105
  const projectRoot = process.env.PROJECT_ROOT;
106
106
  const runPath = path.join(projectRoot, 'docs', 'AI_HANDOFF', 'RUN.md');
107
107
  const runtimePath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'execution-ledger.mjs');
108
+ const intentPath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'handoff-intent.mjs');
109
+
110
+ // The session's CURRENT handoff intent, from structured transcript evidence (the
111
+ // shared handoff-intent module the Stop gate uses). `intent === 'create'` means the
112
+ // latest thing the user asked this session to do is plan — so a live RUN.md from an
113
+ // earlier fullstack run must NOT be forced back into implementation here. Unknown
114
+ // (`known` false: no transcript path, unreadable, shared module missing) keeps the
115
+ // existing fail-toward-resume behaviour, exactly as the Stop gate keeps its block.
116
+ async function readHandoffIntent(transcriptPath) {
117
+ try {
118
+ const mod = await import(pathToFileURL(intentPath).href);
119
+ if (typeof mod.transcriptHandoffIntent !== 'function') return { intent: null, known: false };
120
+ return await mod.transcriptHandoffIntent(transcriptPath);
121
+ } catch {
122
+ return { intent: null, known: false };
123
+ }
124
+ }
108
125
 
109
126
  async function emitOrdinaryResume(payload, source) {
127
+ // A session actively driving (or handed) a fullstack run is owned by that run —
128
+ // the Stop gate holds the stop, not the ordinary routed-task resume lane.
129
+ const { intent } = await readHandoffIntent(
130
+ typeof payload.transcript_path === 'string' ? payload.transcript_path : null,
131
+ );
132
+ if (intent === 'fullstack') return false;
110
133
  let runtimeExists = false;
111
134
  try { await fs.promises.access(runtimePath); runtimeExists = true; } catch {}
112
135
  if (!runtimeExists) return false;
@@ -170,6 +193,15 @@ async function emitOrdinaryResume(payload, source) {
170
193
  const cursor = field('Cursor');
171
194
  const next = field('Next');
172
195
 
196
+ // The session's CURRENT handoff intent — structured transcript evidence, the
197
+ // same shared module the Stop gate uses. A session whose latest request is
198
+ // planning (`/ukit:handoff-create`) must never be pushed into implementing a
199
+ // stale RUN.md, compact or not. `known` false (no transcript path, unreadable,
200
+ // module absent) keeps the existing resume behaviour.
201
+ const { intent } = await readHandoffIntent(
202
+ typeof payload.transcript_path === 'string' ? payload.transcript_path : null,
203
+ );
204
+
173
205
  // A brand-new session (startup / clear) did not start this run and may be about
174
206
  // something else entirely (e.g. planning). Tell it the run exists, but do NOT
175
207
  // order a resume and do NOT use the resume marker the Stop gate treats as proof
@@ -184,6 +216,21 @@ async function emitOrdinaryResume(payload, source) {
184
216
  process.exit(0);
185
217
  }
186
218
 
219
+ // Planning intent outranks a live cursor: the user asked this session to plan,
220
+ // so report the paused run and let the planning continue. This is the one place
221
+ // an automatic resume must yield — /ukit:handoff-create is planning-only by
222
+ // contract, and /ukit:handoff-fullstack remains the explicit way to resume.
223
+ if (intent === 'create') {
224
+ process.stdout.write([
225
+ 'UKit note: docs/AI_HANDOFF/RUN.md has an unfinished handoff-fullstack run '
226
+ + `(Phase: ${phase}; Next: ${next || 'not recorded'}), but this session's current request`,
227
+ 'is planning (/ukit:handoff-create). Stop gate and resume will not force the run here —',
228
+ 'finish the planning first. To resume implementation explicitly, run /ukit:handoff-fullstack;',
229
+ 'to discard the run, /ukit:handoff-clear.',
230
+ ].join('\n') + '\n');
231
+ process.exit(0);
232
+ }
233
+
187
234
  const out = [
188
235
  'UKIT HANDOFF RESUME — an unfinished handoff-fullstack run was found on disk.',
189
236
  ` Goal: ${goal || '(not recorded)'}`,
@@ -0,0 +1,293 @@
1
+ // handoff-intent.mjs — durable intent separation for the handoff pipeline.
2
+ //
3
+ // Why this exists: a single session can carry more than one handoff command over
4
+ // its life. The Stop gate (stop-coordinator.mjs) and the SessionStart resume hook
5
+ // (handoff-resume.sh) must react to the session's CURRENT intent, not to any
6
+ // historical one. A session that ran `/ukit:handoff-fullstack` earlier and is now
7
+ // planning with `/ukit:handoff-create` must NOT be bounced into implementing a
8
+ // stale RUN.md — while a session actively driving a fullstack run must still be
9
+ // held to completion. "Session ran fullstack once" is therefore not ownership;
10
+ // "the most recent handoff intent is fullstack" is.
11
+ //
12
+ // The intent is derived from STRUCTURED transcript evidence only — real user-turn
13
+ // slash-command tags (Claude Code), native command-expansion headings (omp, Codex),
14
+ // Skill tool calls, and SessionStart / host-injected hook context. Raw text matching
15
+ // is deliberately NOT used: the marker strings also appear in tool output, bash
16
+ // commands and source files that any session may read (TASK-007 established this;
17
+ // this module keeps it). Each host is recognised by its OWN real envelope shape; an
18
+ // unknown format returns `null` rather than a guess, so fail-closed callers keep
19
+ // behaving fail-closed.
20
+ //
21
+ // Exports (independently testable — callers import the module directly):
22
+ // HANDOFF_FULLSTACK_TAG / HANDOFF_CREATE_TAG — the two user-turn command tags
23
+ // HANDOFF_RESUME_BANNER — the SessionStart resume marker
24
+ // classifyHandoffEntry(entry) — 'fullstack' | 'create' | null for one JSONL entry
25
+ // classifyHandoffLine(line) — same, from a raw JSONL line
26
+ // transcriptHandoffIntent(path, opts) — { intent, known } for a whole transcript;
27
+ // `intent` is the MOST RECENT qualifying intent, `known=false` when the scan
28
+ // could not complete (missing/unreadable path, budget overrun) — callers keep
29
+ // their fail-closed posture on `known=false`.
30
+
31
+ import fs from 'node:fs/promises';
32
+
33
+ export const HANDOFF_FULLSTACK_TAG = /<command-name>\/?(?:ukit:)?handoff-fullstack\b/;
34
+ export const HANDOFF_CREATE_TAG = /<command-name>\/?(?:ukit:)?handoff-create\b/;
35
+ export const HANDOFF_RESUME_BANNER = 'UKIT HANDOFF RESUME — an unfinished handoff-fullstack run';
36
+
37
+ // omp and Codex do NOT wrap a slash command in `<command-name>` tags the way
38
+ // Claude Code's user turn does. Their native evidence is the command *expansion*:
39
+ // omp replaces `/ukit:handoff-fullstack` with the command body, whose FIRST
40
+ // non-empty line is the H1 `# /ukit:handoff-fullstack — …`; Codex records the same
41
+ // expanded body as a `response_item`/`message` with `input_text` blocks. Matching
42
+ // the FIRST line only (not any heading in the body) keeps prose references to the
43
+ // command inside the document from being mistaken for an invocation.
44
+ const NATIVE_COMMAND_HEADING = /^\s{0,3}#{1,3}\s+\/?(?:ukit:)?(handoff-create|handoff-fullstack)\b/;
45
+
46
+ // The unique customType the omp bridge stamps on host-injected UKit hook context
47
+ // (`sendContext` → `hookContextMessage`). A resume banner only counts as intent
48
+ // when it arrives inside one of these — an `irc:incoming` / `advisor` message that
49
+ // happens to quote the banner is another agent's text, not this session's intent.
50
+ const UKIT_HOOK_CONTEXT_TYPE = 'ukit-hook-context';
51
+
52
+ // Block types that carry TOOL OUTPUT rather than the user's own words. A user
53
+ // turn that contains one of these is a tool_result round-trip (the harness wraps
54
+ // it in `role: user`), never a command the user typed — so it is never intent.
55
+ const TOOL_OUTPUT_BLOCK_TYPES = new Set(['tool_result', 'toolResult', 'tool-result']);
56
+
57
+ /** First non-empty line of a string, trimmed' or ''. */
58
+ function firstNonEmptyLine(text) {
59
+ for (const line of String(text).split('\n')) {
60
+ if (line.trim()) return line;
61
+ }
62
+ return '';
63
+ }
64
+
65
+ /**
66
+ * Does this text open with a native (omp/Codex) command-expansion heading?
67
+ * Only the FIRST non-empty line is inspected, so an H1 that appears later in the
68
+ * document body — e.g. a marketing heading inside the command's own markdown —
69
+ * is never treated as the session's intent.
70
+ * @returns {'create' | 'fullstack' | null}
71
+ */
72
+ function nativeCommandIntent(text) {
73
+ const m = firstNonEmptyLine(text).match(NATIVE_COMMAND_HEADING);
74
+ if (!m) return null;
75
+ return m[1] === 'handoff-create' ? 'create' : 'fullstack';
76
+ }
77
+
78
+ // Bounds for the tail scan. Recency is what matters, so the reader walks the
79
+ // NEWEST lines first and stops at the first qualifying intent. A transcript
80
+ // larger than `maxBytes` is scanned from its tail only: a recent intent is still
81
+ // found, but "no intent in the window" is reported as `known=false` (fail-closed),
82
+ // since the session's single command tag may predate the window. A deadline
83
+ // overrun also returns `known=false` rather than a guess.
84
+ export const INTENT_SCAN_BUDGET_MS = 1200;
85
+ export const INTENT_SCAN_MAX_BYTES = 32 * 1024 * 1024;
86
+
87
+ /**
88
+ * Split a user turn's content into (a) the user's own words, (b) the individual
89
+ * text blocks a native command expansion may arrive in, and (c) whether any block
90
+ * is TOOL OUTPUT. A `tool_result` round-trip is the harness wrapping tool output in
91
+ * a `role: user` turn — it is never something the user typed, so it never carries
92
+ * intent (TASK-007). Both Claude Code (`text`) and Codex (`input_text`) text blocks
93
+ * are user words.
94
+ */
95
+ function splitUserContent(content) {
96
+ if (typeof content === 'string') return { text: content, blocks: content ? [content] : [], hasToolOutput: false };
97
+ if (!Array.isArray(content)) return { text: '', blocks: [], hasToolOutput: false };
98
+ let text = '';
99
+ let hasToolOutput = false;
100
+ const blocks = [];
101
+ for (const b of content) {
102
+ if (!b || typeof b !== 'object') continue;
103
+ if (TOOL_OUTPUT_BLOCK_TYPES.has(b.type)) { hasToolOutput = true; continue; }
104
+ if ((b.type === 'text' || b.type === 'input_text') && typeof b.text === 'string') {
105
+ text += (text ? '\n' : '') + b.text;
106
+ blocks.push(b.text);
107
+ }
108
+ }
109
+ return { text, blocks, hasToolOutput };
110
+ }
111
+
112
+ /**
113
+ * Classify a user turn (Claude Code `type:'user'`, omp `type:'message'` role user,
114
+ * Codex `type:'response_item'` payload message role user). Structured evidence only:
115
+ * a Claude Code `<command-name>` tag, or a native command-expansion heading on the
116
+ * first line of a user text block.
117
+ * @returns {'fullstack' | 'create' | null}
118
+ */
119
+ function classifyUserTurn(content) {
120
+ const { text, blocks, hasToolOutput } = splitUserContent(content);
121
+ // Check the planning tag first: a single turn carrying both is a create turn.
122
+ if (HANDOFF_CREATE_TAG.test(text)) return 'create';
123
+ if (HANDOFF_FULLSTACK_TAG.test(text)) return 'fullstack';
124
+ // Native (omp / Codex) command expansion — the FIRST line of a user text block.
125
+ // A user turn that is purely tool output has no user text to match, so a
126
+ // `tool_result` carrying the marker string can never register as intent.
127
+ for (const block of blocks) {
128
+ const intent = nativeCommandIntent(block);
129
+ if (intent) return intent;
130
+ }
131
+ if (hasToolOutput) return null;
132
+ return null;
133
+ }
134
+
135
+ /**
136
+ * Classify assistant tool-call blocks. Handles both Claude Code (`tool_use`, name
137
+ * `Skill`, input.skill) and omp (`toolCall`, name/arguments.skill).
138
+ */
139
+ function classifyAssistantBlocks(blocks) {
140
+ if (!Array.isArray(blocks)) return null;
141
+ for (const b of blocks) {
142
+ if (!b || typeof b !== 'object') continue;
143
+ if (b.type === 'tool_use' && b.name === 'Skill') {
144
+ const skill = String(b.input?.skill ?? '');
145
+ if (/^(?:ukit:)?handoff-create$/.test(skill)) return 'create';
146
+ if (/^(?:ukit:)?handoff-fullstack$/.test(skill)) return 'fullstack';
147
+ continue;
148
+ }
149
+ if (b.type === 'toolCall') {
150
+ const name = String(b.name ?? '');
151
+ const skill = String(b.arguments?.skill ?? '');
152
+ if (/^(?:ukit:)?handoff-create$/i.test(name) || /^(?:ukit:)?handoff-create$/.test(skill)) return 'create';
153
+ if (/^(?:ukit:)?handoff-fullstack$/i.test(name) || /^(?:ukit:)?handoff-fullstack$/.test(skill)) return 'fullstack';
154
+ }
155
+ }
156
+ return null;
157
+ }
158
+
159
+ /**
160
+ * Classify one transcript entry as a handoff intent.
161
+ *
162
+ * Recognises the REAL shapes of the three supported hosts, and refuses to guess on
163
+ * any other envelope:
164
+ * - Claude Code: `{ type:'user', message:{ role:'user', content } }`,
165
+ * `{ type:'assistant', message:{ content:[tool_use…] } }`,
166
+ * `{ type:'attachment', attachment:{ hookEvent:'SessionStart', … } }`
167
+ * - omp: `{ type:'message', message:{ role:'user'|'assistant', content:[…] } }`
168
+ * (content blocks `text` / `toolCall`), and host-injected hook
169
+ * context `{ type:'custom_message', customType:'ukit-hook-context', … }`
170
+ * - Codex: `{ type:'response_item', payload:{ type:'message', role:'user', content:[input_text…] } }`
171
+ *
172
+ * Anything else — including a format we do not recognise — returns null (never a
173
+ * guessed intent), so the caller's fail-closed posture is preserved.
174
+ * @returns {'fullstack' | 'create' | null}
175
+ */
176
+ export function classifyHandoffEntry(entry) {
177
+ if (!entry || typeof entry !== 'object') return null;
178
+ if (entry.type === 'user' && entry.message && entry.message.role === 'user') {
179
+ return classifyUserTurn(entry.message.content);
180
+ }
181
+ if (entry.type === 'message' && entry.message && entry.message.role === 'user') {
182
+ return classifyUserTurn(entry.message.content);
183
+ }
184
+ if (entry.type === 'message' && entry.message && entry.message.role === 'assistant') {
185
+ return classifyAssistantBlocks(entry.message.content);
186
+ }
187
+ if (entry.type === 'response_item' && entry.payload) {
188
+ const payload = entry.payload;
189
+ if (payload.type === 'message' && payload.role === 'user') {
190
+ return classifyUserTurn(payload.content);
191
+ }
192
+ // Codex records tool calls as separate `function_call` items (string args), not
193
+ // a Skill tool call — they cannot carry a typed slash-command intent.
194
+ return null;
195
+ }
196
+ if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) {
197
+ return classifyAssistantBlocks(entry.message.content);
198
+ }
199
+ if (entry.type === 'attachment' && entry.attachment && entry.attachment.hookEvent === 'SessionStart') {
200
+ const a = entry.attachment;
201
+ if ([a.content, a.stdout, a.text].some((v) => typeof v === 'string' && v.includes(HANDOFF_RESUME_BANNER))) {
202
+ return 'fullstack';
203
+ }
204
+ }
205
+ // omp host-injected hook context (the bridge's `ukit-hook-context` customType).
206
+ // Gated on the customType so another agent's `irc:incoming` / an `advisor` message
207
+ // that merely quotes the banner cannot register as this session's intent.
208
+ if (entry.type === 'custom_message' && entry.customType === UKIT_HOOK_CONTEXT_TYPE) {
209
+ if (typeof entry.content === 'string' && entry.content.includes(HANDOFF_RESUME_BANNER)) {
210
+ return 'fullstack';
211
+ }
212
+ }
213
+ return null;
214
+ }
215
+
216
+ /**
217
+ * Classify one raw JSONL transcript line. Returns null when the line is not
218
+ * parseable or carries no handoff intent.
219
+ */
220
+ export function classifyHandoffLine(line) {
221
+ try {
222
+ return classifyHandoffEntry(JSON.parse(line));
223
+ } catch {
224
+ return null;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Read a session transcript and return its MOST RECENT handoff intent.
230
+ * @returns {Promise<{ intent: 'fullstack'|'create'|null, known: boolean }>}
231
+ * `known=false` means the scan could not be completed (no path, unreadable,
232
+ * budget overrun); callers keep their existing blocking behaviour there.
233
+ */
234
+ export async function transcriptHandoffIntent(transcriptPath, {
235
+ budgetMs = INTENT_SCAN_BUDGET_MS,
236
+ maxBytes = INTENT_SCAN_MAX_BYTES,
237
+ } = {}) {
238
+ if (typeof transcriptPath !== 'string' || !transcriptPath.trim()) {
239
+ return { intent: null, known: false };
240
+ }
241
+ let handle;
242
+ try {
243
+ handle = await fs.open(transcriptPath, 'r');
244
+ const stat = await handle.stat();
245
+ const size = Number(stat.size) || 0;
246
+ const start = Math.max(0, size - maxBytes);
247
+ const deadline = Date.now() + budgetMs;
248
+ const chunk = Buffer.allocUnsafe(Math.min(Math.max(1, size - start), 4 * 1024 * 1024));
249
+ let text = '';
250
+ let pos = start;
251
+ while (pos < size) {
252
+ if (Date.now() > deadline) return { intent: null, known: false };
253
+ const toRead = Math.min(chunk.length, size - pos);
254
+ const { bytesRead } = await handle.read(chunk, 0, toRead, pos);
255
+ if (!bytesRead) break;
256
+ text += chunk.toString('utf8', 0, bytesRead);
257
+ pos += bytesRead;
258
+ }
259
+ let lines = text.split('\n');
260
+ // A tail window starts mid-line: the first fragment is not a whole entry.
261
+ if (start > 0) lines = lines.slice(1);
262
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
263
+ const line = lines[i];
264
+ if (!line) continue;
265
+ // Cheap prefilter — skip JSON.parse for lines that cannot be evidence.
266
+ if (!line.includes('handoff-create') && !line.includes('handoff-fullstack') && !line.includes('HANDOFF RESUME')) {
267
+ continue;
268
+ }
269
+ const intent = classifyHandoffLine(line);
270
+ if (intent) return { intent, known: true };
271
+ }
272
+ // Nothing found. Only a scan of the WHOLE transcript proves "no handoff
273
+ // intent"; a tail window that missed it (the invoking turn usually appears
274
+ // once, near the start of an omp session) cannot, so it stays unknown and
275
+ // callers keep blocking.
276
+ return { intent: null, known: start === 0 };
277
+ } catch {
278
+ return { intent: null, known: false };
279
+ } finally {
280
+ try { await handle?.close(); } catch {}
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Back-compat boolean view of the same scan: did this transcript ever show the
286
+ * session driving (or being handed) a handoff-fullstack run? Returns true, false,
287
+ * or null (unknown) exactly as the original stop-coordinator helper did.
288
+ */
289
+ export async function transcriptShowsHandoffRun(transcriptPath, opts = {}) {
290
+ const { intent, known } = await transcriptHandoffIntent(transcriptPath, opts);
291
+ if (!known) return null;
292
+ return intent === 'fullstack';
293
+ }
@@ -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) →