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