@ngockhoale/ukit 3.4.11 → 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 +63 -0
- package/package.json +1 -1
- package/scripts/probe/codex-capability-probe.mjs +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 +61 -0
- package/template_project/.claude/hooks/observability-emit.mjs +4 -0
- package/template_project/.claude/hooks/reset-compact-pressure.sh +3 -2
- package/template_project/.claude/hooks/skill-router.sh +1 -0
- package/template_project/.claude/hooks/verification-guard.sh +1 -1
- package/template_project/.claude/ukit/runtime/async-lock.mjs +7 -1
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +293 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +75 -4
- package/template_project/.omp/hooks/pre/ukit-bridge.js +6 -1
- package/template_project/docs/AI_HANDOFF/RULES.md +26 -1
- package/template_project/ukit/storage/config.json +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,69 @@
|
|
|
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
|
+
|
|
23
|
+
## 3.4.12 - 2026-10-02
|
|
24
|
+
|
|
25
|
+
**Handoff Stop gate is session-scoped — a stale `RUN.md` no longer hijacks unrelated sessions.**
|
|
26
|
+
Before, any session in a repo was bounced with "CONTINUE IMMEDIATELY" whenever
|
|
27
|
+
`docs/AI_HANDOFF/RUN.md` carried a live `Phase:` (or a `done` whose ExitPredicate
|
|
28
|
+
failed, e.g. `git:clean` on a dirty tree) — even a fresh session opened only to plan.
|
|
29
|
+
`evaluateHandoffCursor` now takes the session `transcriptPath` and enforces the
|
|
30
|
+
gate only when that session itself drives the run, proven by STRUCTURED transcript
|
|
31
|
+
evidence (a real user turn with the `/ukit:handoff-fullstack` tag, a `Skill` call for
|
|
32
|
+
it, or a SessionStart attachment carrying the resume banner). Marker strings in tool
|
|
33
|
+
output, bash commands or source files do not count. An unreadable/oversized
|
|
34
|
+
transcript keeps the legacy block (a real run is never released by a lost read).
|
|
35
|
+
The omp bridge passes the transcript path too.
|
|
36
|
+
|
|
37
|
+
- `handoff-resume.sh`: `startup`/`clear` sessions get an informational note (ignore
|
|
38
|
+
unless asked; `/ukit:handoff-fullstack` to continue, `/ukit:handoff-clear` to
|
|
39
|
+
discard) instead of a resume order; `compact`/`resume` still resume automatically.
|
|
40
|
+
- New config `handoff.fullstack.stopGateScope`: `"session"` (default) | `"project"`
|
|
41
|
+
(legacy repo-wide gate).
|
|
42
|
+
- `reset-compact-pressure.sh`: staged-payload read is async (was a sync
|
|
43
|
+
`readFileSync` inside the hook deadline window; fixes BUG-C22-03 test).
|
|
44
|
+
- `async-lock.mjs`: an `EEXIST` from the lock's PARENT-directory mkdir (a regular file
|
|
45
|
+
squatting on the store path) is a hard error, not lock contention — it used to spin
|
|
46
|
+
out the whole acquisition budget and hang crash-path hooks (`reinject-context`,
|
|
47
|
+
`output-compression`) for ~15 s instead of failing fast with a systemMessage.
|
|
48
|
+
- `skill-router.sh`: restored a missing `};` (v3.4.11 syntax error that silently
|
|
49
|
+
disabled the whole router).
|
|
50
|
+
- Hook-deadline conventions: no sync-fs mention in `verification-guard.sh`/
|
|
51
|
+
`observability-emit.mjs`, explicit timeout on the codex probe's `spawnSync`.
|
|
52
|
+
- Test/doc hygiene: tests follow the cycle-91 inventory archive move; baseline
|
|
53
|
+
reconciliation pins the major.minor line instead of exact patch; decision-coverage
|
|
54
|
+
allowlist covers `deriveTypedDelegationAdvice`.
|
|
55
|
+
|
|
56
|
+
## 3.4.11 - 2026-10-01
|
|
57
|
+
|
|
58
|
+
**Gate posture is answer-first; only explicit change orders mint work debt.**
|
|
59
|
+
The router now stamps `routingContext.planOnly` (new `isPlanOnlyRequest` —
|
|
60
|
+
"chỉ lên plan", "plan thôi", "just plan it", "plan … không implement") next to
|
|
61
|
+
`mutationOrder`. The completion gate's `no-mutation-order` valve covers both:
|
|
62
|
+
when a prompt ordered no mutation or asked only for a plan/answer, missing
|
|
63
|
+
`write-evidence` AND `verification-evidence` release loud instead of forcing
|
|
64
|
+
an Edit loop. A prompt that does order a change — or a turn that already
|
|
65
|
+
attempted an edit — keeps the normal gate. To implement a reviewed plan:
|
|
66
|
+
"implement", "sửa", "làm luôn", or `execute this plan`.
|
|
67
|
+
|
|
5
68
|
## 3.4.10 - 2026-10-01
|
|
6
69
|
|
|
7
70
|
**sensitive-data-guard reads .env placeholders.** `classifySecretFile` now
|
package/package.json
CHANGED
|
@@ -34,7 +34,7 @@ const out = [];
|
|
|
34
34
|
const say = (line = '') => { out.push(line); console.log(line); };
|
|
35
35
|
|
|
36
36
|
function commandOnPath(cmd) {
|
|
37
|
-
const r = spawnSync('sh', ['-c', `command -v ${cmd}`], { encoding: 'utf8' });
|
|
37
|
+
const r = spawnSync('sh', ['-c', `command -v ${cmd}`], { encoding: 'utf8', timeout: 5000 });
|
|
38
38
|
return r.status === 0 ? r.stdout.trim() : null;
|
|
39
39
|
}
|
|
40
40
|
|
|
@@ -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,44 @@ 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
|
+
|
|
205
|
+
// A brand-new session (startup / clear) did not start this run and may be about
|
|
206
|
+
// something else entirely (e.g. planning). Tell it the run exists, but do NOT
|
|
207
|
+
// order a resume and do NOT use the resume marker the Stop gate treats as proof
|
|
208
|
+
// that the session owns the run. Resume stays automatic for compact / resume.
|
|
209
|
+
if (source === 'startup' || source === 'clear') {
|
|
210
|
+
process.stdout.write([
|
|
211
|
+
'UKit note: docs/AI_HANDOFF/RUN.md has an unfinished handoff-fullstack run '
|
|
212
|
+
+ `(Phase: ${phase}; Next: ${next || 'not recorded'}).`,
|
|
213
|
+
'It belongs to an earlier session. Ignore it unless the user asks to continue it; their current request comes first.',
|
|
214
|
+
'To pick it up the user can run /ukit:handoff-fullstack; to discard it, /ukit:handoff-clear.',
|
|
215
|
+
].join('\n') + '\n');
|
|
216
|
+
process.exit(0);
|
|
217
|
+
}
|
|
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
|
+
|
|
173
234
|
const out = [
|
|
174
235
|
'UKIT HANDOFF RESUME — an unfinished handoff-fullstack run was found on disk.',
|
|
175
236
|
` Goal: ${goal || '(not recorded)'}`,
|
|
@@ -10,5 +10,9 @@
|
|
|
10
10
|
// so the `execution.started` record lands before any slow .sh member can
|
|
11
11
|
// exhaust the chain budget (a timeout further down would silently skip a
|
|
12
12
|
// trailing emit step).
|
|
13
|
+
//
|
|
14
|
+
// Deadline contract: the chain runner passes `deadlineMs` into runHook and the
|
|
15
|
+
// emit module bounds every write by it (HOOK_DEADLINE_MS fallback), so this
|
|
16
|
+
// thin entry needs no watchdog of its own.
|
|
13
17
|
|
|
14
18
|
export { runHook } from '../ukit/runtime/observability-emit.mjs';
|
|
@@ -195,9 +195,10 @@ async function withLock(lockPath, fn) {
|
|
|
195
195
|
// this hook waiting on EOF.
|
|
196
196
|
const inputFile = process.argv[2];
|
|
197
197
|
(async () => {
|
|
198
|
-
|
|
198
|
+
// Async on purpose — a sync read on a stalled mount would park the deadline timer.
|
|
199
|
+
const payload = await (async () => {
|
|
199
200
|
try {
|
|
200
|
-
const raw =
|
|
201
|
+
const raw = await fsp.readFile(inputFile, "utf8");
|
|
201
202
|
return JSON.parse(raw || "{}");
|
|
202
203
|
} catch {
|
|
203
204
|
return {};
|
|
@@ -433,7 +433,7 @@ const NEW_SOURCE_FILE_RE = /\.(jsx?|tsx?|mjs|cjs|vue|svelte|css|scss|sass|less|h
|
|
|
433
433
|
// `git status --porcelain` positions: untracked entries start '??', index-staged
|
|
434
434
|
// adds start 'A'. Both are "component-create" candidates for the wiring check.
|
|
435
435
|
// W2-D06: async execFile — a wedged git (slow index lock, network fs) must not
|
|
436
|
-
// park the event loop inside the armed deadline block the way
|
|
436
|
+
// park the event loop inside the armed deadline block the way a synchronous exec did.
|
|
437
437
|
// SIGKILL keeps the child bound hard even for a TERM-ignoring process.
|
|
438
438
|
async function readGitChangedPaths(projectRoot) {
|
|
439
439
|
return new Promise((resolve) => {
|
|
@@ -451,6 +451,7 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
|
|
|
451
451
|
// they get the fixed cleanup reserve instead of the acquisition slice.
|
|
452
452
|
const releaseRetry = (op) => withTransientFsRetry(op, { deadlineMs: LOCK_RESERVE_MS });
|
|
453
453
|
|
|
454
|
+
let parentFailed = false;
|
|
454
455
|
while (true) {
|
|
455
456
|
// Abort wins over every other condition, checked once per iteration: an abort
|
|
456
457
|
// landing mid-sweep/mid-op must still surface the typed aborted outcome — the
|
|
@@ -467,7 +468,9 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
|
|
|
467
468
|
try {
|
|
468
469
|
// The lock parent must exist before the atomic acquire — a first-ever run in a
|
|
469
470
|
// fresh project would otherwise fail mkdir with ENOENT.
|
|
471
|
+
parentFailed = true;
|
|
470
472
|
await retry(() => fs.mkdir(path.dirname(lockPath), { recursive: true }));
|
|
473
|
+
parentFailed = false;
|
|
471
474
|
await retry(() => fs.mkdir(lockPath)); // atomic acquire — EEXIST means another holder exists
|
|
472
475
|
inProcessLockHolders.set(lockPath, ownerToken);
|
|
473
476
|
try {
|
|
@@ -503,7 +506,10 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
|
|
|
503
506
|
if (signal?.aborted) {
|
|
504
507
|
return { ok: false, reason: 'aborted', waitedMs: Date.now() - startedAt };
|
|
505
508
|
}
|
|
506
|
-
|
|
509
|
+
// EEXIST from the PARENT mkdir means a regular file squats on the directory path —
|
|
510
|
+
// a broken store, not a lock holder. Waiting out the budget for it would burn the
|
|
511
|
+
// whole acquisition slice and then report 'busy'; surface the real error instead.
|
|
512
|
+
if (error?.code !== 'EEXIST' || parentFailed) throw error;
|
|
507
513
|
}
|
|
508
514
|
|
|
509
515
|
// Someone holds the lock. Reclaim it only after the stale threshold AND only when
|
|
@@ -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
|
+
}
|
|
@@ -201,6 +201,12 @@ export const HANDOFF_CURSOR_DEFAULTS = Object.freeze({
|
|
|
201
201
|
// gate releases instead of trapping the session in an unstoppable loop — same
|
|
202
202
|
// liveness shape as the ledger's noProgressCount breaker.
|
|
203
203
|
maxStalledBlocks: 12,
|
|
204
|
+
// 'session' (default): the gate only bounces a stop when THIS session is the one
|
|
205
|
+
// driving the run (its transcript shows a handoff-fullstack invocation or a
|
|
206
|
+
// resume injection). A fresh session that merely shares the repo with a stale
|
|
207
|
+
// RUN.md — e.g. one opened just to plan something else — is never hijacked.
|
|
208
|
+
// 'project': legacy behaviour, any session in the repo is bounced.
|
|
209
|
+
scope: 'session',
|
|
204
210
|
});
|
|
205
211
|
|
|
206
212
|
// ─── Redaction ────────────────────────────────────────────────────────────
|
|
@@ -636,7 +642,17 @@ export async function evaluateStopReviewPolicy({ projectRoot, completionKind = n
|
|
|
636
642
|
: 0;
|
|
637
643
|
|
|
638
644
|
const ledgerModule = await loadLedgerReceiptModule();
|
|
639
|
-
|
|
645
|
+
// The policy fires its calibration receipt without awaiting it; collect the promises so
|
|
646
|
+
// the receipt is on disk before this evaluator returns (a loaded host otherwise leaves
|
|
647
|
+
// the write racing the next reader).
|
|
648
|
+
const pendingReceipts = [];
|
|
649
|
+
const appendDecisionReceipt = typeof ledgerModule?.appendDecisionReceipt === 'function'
|
|
650
|
+
? (...args) => {
|
|
651
|
+
const written = ledgerModule.appendDecisionReceipt(...args);
|
|
652
|
+
pendingReceipts.push(Promise.resolve(written).catch(() => {}));
|
|
653
|
+
return written;
|
|
654
|
+
}
|
|
655
|
+
: undefined;
|
|
640
656
|
|
|
641
657
|
const decision = policyModule.evaluateReviewPolicy({
|
|
642
658
|
signals: routeSummary.suspicionSignals ?? {},
|
|
@@ -648,6 +664,7 @@ export async function evaluateStopReviewPolicy({ projectRoot, completionKind = n
|
|
|
648
664
|
projectRoot,
|
|
649
665
|
appendDecisionReceipt,
|
|
650
666
|
});
|
|
667
|
+
await Promise.all(pendingReceipts);
|
|
651
668
|
if (!decision || typeof decision !== 'object') return null;
|
|
652
669
|
|
|
653
670
|
// Persist the round counter + last verdict for the next stop (same atomic
|
|
@@ -686,6 +703,9 @@ async function loadHandoffGateConfig(projectRoot) {
|
|
|
686
703
|
maxStalledBlocks: Number.isFinite(gate?.stopGateMaxStalledBlocks)
|
|
687
704
|
? Math.max(1, gate.stopGateMaxStalledBlocks)
|
|
688
705
|
: HANDOFF_CURSOR_DEFAULTS.maxStalledBlocks,
|
|
706
|
+
scope: gate?.stopGateScope === 'project' || gate?.stopGateScope === 'session'
|
|
707
|
+
? gate.stopGateScope
|
|
708
|
+
: HANDOFF_CURSOR_DEFAULTS.scope,
|
|
689
709
|
};
|
|
690
710
|
} catch {
|
|
691
711
|
return { ...HANDOFF_CURSOR_DEFAULTS };
|
|
@@ -835,6 +855,42 @@ function formatPredicateState(result) {
|
|
|
835
855
|
return result.terms.map((t) => `${t.term}=${t.pass ? 'pass' : `FAIL (${t.detail})`}`).join(', ');
|
|
836
856
|
}
|
|
837
857
|
|
|
858
|
+
// ─── Session 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);
|
|
874
|
+
}
|
|
875
|
+
return handoffIntentModulePromise;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
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.
|
|
885
|
+
*/
|
|
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';
|
|
892
|
+
}
|
|
893
|
+
|
|
838
894
|
/**
|
|
839
895
|
* Mutate `.ukit/storage/cache/stop-coordinator/state.json`'s `handoff` slot:
|
|
840
896
|
* { signature, count }. Same signature as last time → count+1; a moved cursor
|
|
@@ -900,7 +956,7 @@ async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs }) {
|
|
|
900
956
|
*
|
|
901
957
|
* Advisory lane: EVERY failure path returns { kind: 'none' }, never throws.
|
|
902
958
|
*/
|
|
903
|
-
export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), lockBudgetMs = LOCK_BUDGET_MS } = {}) {
|
|
959
|
+
export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), lockBudgetMs = LOCK_BUDGET_MS, transcriptPath = null } = {}) {
|
|
904
960
|
const gate = await loadHandoffGateConfig(projectRoot);
|
|
905
961
|
if (!gate.enabled) return { kind: 'none' };
|
|
906
962
|
|
|
@@ -917,8 +973,18 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
|
|
|
917
973
|
if (!phase || terminal === 'blocked') {
|
|
918
974
|
return { kind: 'none' };
|
|
919
975
|
}
|
|
976
|
+
if (terminal === 'done' && !exitPredicate) return { kind: 'none' }; // legacy phase-only release
|
|
977
|
+
|
|
978
|
+
// Session scope: a run owned by another session (or abandoned) must not
|
|
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.
|
|
983
|
+
if (gate.scope === 'session' && await transcriptShowsHandoffRun(transcriptPath) === false) {
|
|
984
|
+
return { kind: 'none' };
|
|
985
|
+
}
|
|
986
|
+
|
|
920
987
|
if (terminal === 'done') {
|
|
921
|
-
if (!exitPredicate) return { kind: 'none' }; // legacy phase-only release
|
|
922
988
|
let predicate;
|
|
923
989
|
try {
|
|
924
990
|
predicate = await evaluateExitPredicate(projectRoot, exitPredicate);
|
|
@@ -1064,7 +1130,12 @@ export async function runStopCoordinator({
|
|
|
1064
1130
|
}
|
|
1065
1131
|
let handoff = null;
|
|
1066
1132
|
try {
|
|
1067
|
-
handoff = await evaluateHandoffCursor({
|
|
1133
|
+
handoff = await evaluateHandoffCursor({
|
|
1134
|
+
projectRoot,
|
|
1135
|
+
now,
|
|
1136
|
+
lockBudgetMs,
|
|
1137
|
+
transcriptPath: typeof payload?.transcript_path === 'string' ? payload.transcript_path : null,
|
|
1138
|
+
});
|
|
1068
1139
|
} catch (error) {
|
|
1069
1140
|
failures.handoff = error?.message || String(error);
|
|
1070
1141
|
process.stderr.write(`[ukit-stop-coordinator] handoff-cursor evaluator failed: ${failures.handoff}\n`);
|
|
@@ -1056,7 +1056,12 @@ export async function runSessionStop(
|
|
|
1056
1056
|
let handoff = null;
|
|
1057
1057
|
if (coordinator) {
|
|
1058
1058
|
try {
|
|
1059
|
-
handoff = await coordinator.evaluateHandoffCursor({
|
|
1059
|
+
handoff = await coordinator.evaluateHandoffCursor({
|
|
1060
|
+
projectRoot,
|
|
1061
|
+
now,
|
|
1062
|
+
lockBudgetMs,
|
|
1063
|
+
transcriptPath: typeof payload.transcript_path === 'string' ? payload.transcript_path : null,
|
|
1064
|
+
});
|
|
1060
1065
|
} catch (error) {
|
|
1061
1066
|
pi.logger?.warn?.(`[UKit] handoff-cursor evaluator failed (advisory lane): ${error?.message || error}`);
|
|
1062
1067
|
}
|
|
@@ -137,12 +137,37 @@ 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
|
|
144
145
|
stop kèm `Next:` step. Breaker `stopGateMaxStalledBlocks` (mặc định 12) thả ra nếu
|
|
145
146
|
cursor không nhích — lối thoát, không phải đường thường.
|
|
147
|
+
- Gate theo session (`stopGateScope: session`, mặc định): chỉ ép session đã chạy `/ukit:handoff-fullstack`
|
|
148
|
+
hoặc nhận resume marker (compact/resume). Session mới (startup/clear) chỉ nhận ghi chú, không bị ép;
|
|
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 đè.
|
|
146
171
|
- `handoff-clear` PHẢI đặt `Phase: done` (hoặc xóa RUN.md) — cursor sống sẽ giữ gate
|
|
147
172
|
chặn stop của session sau.
|
|
148
173
|
- Phase F: docs sync (WORKLOG luôn; PROJECT/CODE_MAP/CHANGELOG khi đổi surface) →
|
|
@@ -525,6 +525,7 @@
|
|
|
525
525
|
"fullstack": {
|
|
526
526
|
"stopGateEnabled": true,
|
|
527
527
|
"stopGateMaxStalledBlocks": 12,
|
|
528
|
+
"stopGateScope": "session",
|
|
528
529
|
"stopGateMaxCrashStreaks": 3,
|
|
529
530
|
"quietScansRequired": 2,
|
|
530
531
|
"idleWatchdogMin": 5,
|
|
@@ -836,6 +837,7 @@
|
|
|
836
837
|
"fullstack": {
|
|
837
838
|
"stopGateEnabled": "B\u1eadt Stop gate handoff: RUN.md Phase != done/blocked \u2192 Stop hook t\u1eeb ch\u1ed1i stop k\u00e8m Next step, gi\u1eef handoff-fullstack ch\u1ea1y ti\u1ebfp thay v\u00ec \u0111\u1ee9ng sau recap. T\u1eaft ch\u1ec9 khi debug.",
|
|
838
839
|
"stopGateMaxStalledBlocks": "Liveness breaker: cursor kh\u00f4ng nh\u00edch qua N l\u1ea7n block li\u00ean ti\u1ebfp th\u00ec gate th\u1ea3 stop (tr\u00e1nh v\u00f2ng l\u1eb7p v\u00f4 t\u1eadn). M\u1eb7c \u0111\u1ecbnh 12.",
|
|
840
|
+
"stopGateScope": "session = Stop gate chỉ ép khi chính session này đang chạy handoff-fullstack (transcript có /ukit:handoff-fullstack hoặc resume marker); session mới mở chỉ để plan/việc khác không bị ép. project = cũ: mọi session trong repo đều bị ép khi RUN.md còn sống.",
|
|
839
841
|
"stopGateMaxCrashStreaks": "Liveness breaker cho coordinator crash: sau N l\u1ea7n block li\u00ean ti\u1ebfp do infrastructure failure, gate th\u1ea3 stop k\u00e8m c\u1ea3nh b\u00e1o l\u1edbn. M\u1eb7c \u0111\u1ecbnh 3.",
|
|
840
842
|
"quietScansRequired": "S\u1ed1 scan li\u00ean ti\u1ebfp kh\u00f4ng th\u1ea5y vi\u1ec7c m\u1edbi tr\u01b0\u1edbc khi handoff-fullstack \u0111\u01b0\u1ee3c ph\u00e9p \u0111\u00f3ng run. M\u1eb7c \u0111\u1ecbnh 2.",
|
|
841
843
|
"idleWatchdogMin": "Kho\u1ea3ng ph\u00fat cho scheduled wakeup n\u1ebfu harness h\u1ed7 tr\u1ee3 (cron/loop). M\u1eb7c \u0111\u1ecbnh 5.",
|