@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 +18 -0
- package/package.json +1 -1
- package/template_project/.claude/commands/ukit/handoff-create.md +80 -12
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +31 -2
- package/template_project/.claude/hooks/handoff-resume.sh +47 -0
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +293 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +33 -70
- package/template_project/docs/AI_HANDOFF/RULES.md +23 -1
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
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:**
|
|
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. `
|
|
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
|
|
859
|
-
// Structured
|
|
860
|
-
//
|
|
861
|
-
//
|
|
862
|
-
//
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
function
|
|
871
|
-
if (
|
|
872
|
-
|
|
873
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
895
|
-
* Returns true (
|
|
896
|
-
* (unknown: no path, unreadable,
|
|
897
|
-
* null as "cannot prove this session is not
|
|
898
|
-
* blocking behaviour — a real run must never be
|
|
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
|
-
|
|
902
|
-
|
|
903
|
-
} =
|
|
904
|
-
if (
|
|
905
|
-
|
|
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
|
|
1019
|
-
//
|
|
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`,
|
|
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) →
|