@ngockhoale/ukit 2.7.7 → 2.7.8
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 +42 -0
- package/package.json +1 -1
- package/src/context/detectProjectContext.js +5 -0
- package/src/core/codeintel/invalidation.js +4 -0
- package/src/core/fileOps.js +40 -117
- package/src/render/buildVariables.js +10 -0
- package/templates/.claude/agents/bug-debugger.md +1 -1
- package/templates/.claude/agents/feature-implementer.md +2 -2
- package/templates/.claude/commands/ukit/handoff-create.md +1 -1
- package/templates/.claude/commands/ukit/handoff-fullstack.md +1 -1
- package/templates/.claude/commands/ukit/handoff-implement.md +1 -1
- package/templates/.claude/commands/ukit/handoff-review.md +1 -1
- package/templates/.claude/hooks/context-hardcap-gate.sh +4 -1
- package/templates/.claude/hooks/handoff-model-guard.sh +22 -11
- package/templates/.claude/hooks/reset-compact-pressure.sh +10 -0
- package/templates/.claude/hooks/skill-router.sh +15 -8
- package/templates/.claude/hooks/verification-guard.sh +3 -0
- package/templates/.claude/ukit/index/route-task.mjs +237 -32
- package/templates/.claude/ukit/runtime/async-lock.mjs +144 -10
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +5 -2
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +217 -17
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +38 -4
- package/templates/.claude/ukit/runtime/hook-payload-store.mjs +57 -0
- package/templates/.claude/ukit/runtime/stop-coordinator.mjs +35 -20
- package/templates/.claude/ukit/runtime/token-utils.mjs +37 -126
- package/templates/.codex/settings.json +1 -5
- package/templates/.omp/agents/bug-debugger.md +1 -1
- package/templates/.omp/agents/feature-implementer.md +2 -2
- package/templates/.omp/hooks/pre/ukit-bridge.js +157 -26
- package/templates/docs/AI_HANDOFF/INDEX.md +1 -1
- package/templates/docs/AI_HANDOFF/RULES.md +6 -6
- package/templates/ukit/storage/config.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 2.7.8 - 2026-09-22
|
|
6
|
+
|
|
7
|
+
Stall/hang fixes — cycle C43 (TASK-001..008): the indefinite-stall producers
|
|
8
|
+
root-caused in `docs/AI_REPORT/2026-09-21-NORMAL_CHAT_STALL.md` are fixed
|
|
9
|
+
across Claude Code, omp, and Codex runtimes.
|
|
10
|
+
|
|
11
|
+
- **CX-8**: route-state writes are atomic + locked — torn `skill-router-state.json`
|
|
12
|
+
writes can no longer wedge the router.
|
|
13
|
+
- **OMP-3/F-2**: omp receipt dedupe — a tool result is recorded once; duplicate
|
|
14
|
+
receipts no longer double-count execution records.
|
|
15
|
+
- **RC-4/OMP-1/F-3/F-4**: breaker-freeze family — budgeted reclaim, pstart
|
|
16
|
+
stamps, fail-open streaks; continuation sidecar merge gate stamps
|
|
17
|
+
`lastContinuationAt` strictly newer than its base and materializes counter
|
|
18
|
+
state when the main ledger file is absent.
|
|
19
|
+
- **F-5/F-5b**: `withFileLock` policy + reclaim — stale locks reclaimed via
|
|
20
|
+
pstart mismatch; recycled-pid regression tests added.
|
|
21
|
+
- **F-15**: verifier-classification — verification outcomes classified so a
|
|
22
|
+
non-verification failure no longer masquerades as a test failure.
|
|
23
|
+
- **F-RC-2**: `hook-chain-runner` exits promptly after verdict — bounded stdout
|
|
24
|
+
flush then `process.exit`; an abandoned in-proc step promise can no longer
|
|
25
|
+
pin the event loop for the full registered timeout.
|
|
26
|
+
- **F-10/OMP-2**: omp Stop parity — omp sessions get the same handoff-cursor
|
|
27
|
+
Stop-gate lane as Claude Code.
|
|
28
|
+
- **OMP-6/F-8/CX-10**: `createPayloadReference` staging runs under a deadline
|
|
29
|
+
with fail-open to inline payload (never throws, host loop not frozen); omp
|
|
30
|
+
SessionStart carries `session_id` so `reset-compact-pressure` no longer
|
|
31
|
+
wipes every session's pressure records; codex `requiredBeforeCompletion`
|
|
32
|
+
renders only commands that exist in `package.json`.
|
|
33
|
+
- Housekeeping: stale "Kilo" tool labels scrubbed from handoff docs, agent
|
|
34
|
+
report contracts, and config help text (functional support was already
|
|
35
|
+
removed; the regression tests and gateway comments documenting that removal
|
|
36
|
+
are intentionally kept).
|
|
37
|
+
|
|
5
38
|
## 2.7.7 - 2026-09-21
|
|
6
39
|
|
|
7
40
|
Audit-report remediation + hook observability — cycle C42 (TASK-001..011): all 12
|
|
@@ -64,6 +97,15 @@ findings from the three 2026-09-20 reports closed; the reports moved to
|
|
|
64
97
|
fixture whose outcome depended on whether the clock ticked mid-array, so it
|
|
65
98
|
passed on an unloaded box and failed under load; the fixture is now
|
|
66
99
|
deterministic and the case fails against the old code.
|
|
100
|
+
- `withFileLock` (both `templates/.claude/ukit/runtime/token-utils.mjs` and its
|
|
101
|
+
`src/core/fileOps.js` twin) is now fail-closed: a lock wait that exceeds
|
|
102
|
+
`maxWaitMs` skips the mutation instead of running it unlocked, and the drop is
|
|
103
|
+
journaled to `<file>.lock-drops.jsonl` (bounded 128-record JSONL). Both twins
|
|
104
|
+
delegate to `async-lock.mjs`, so owner stamping (pid + token + pstart),
|
|
105
|
+
recycled-pid detection, and claim+quarantine stale reclaim exist exactly once;
|
|
106
|
+
a stale lock whose recorded pid was recycled is now reclaimed instead of
|
|
107
|
+
wedging every mutation for that file. `writeJson`/`writeFileAtomic` fall back
|
|
108
|
+
to copy+unlink when `rename` fails with EXDEV (cross-mount tmp dirs).
|
|
67
109
|
|
|
68
110
|
## 2.7.6 - 2026-09-20
|
|
69
111
|
|
package/package.json
CHANGED
|
@@ -24,5 +24,10 @@ export async function detectProjectContext(projectRoot) {
|
|
|
24
24
|
os: process.platform,
|
|
25
25
|
nodeVersion: process.version,
|
|
26
26
|
},
|
|
27
|
+
// CX-10: raw package.json scripts so renderers can emit only commands that
|
|
28
|
+
// actually exist (verify-context.mjs:186-191 does the same existence check).
|
|
29
|
+
scripts: packageJson?.scripts && typeof packageJson.scripts === 'object'
|
|
30
|
+
? packageJson.scripts
|
|
31
|
+
: {},
|
|
27
32
|
};
|
|
28
33
|
}
|
|
@@ -105,6 +105,10 @@ export async function notifyEdit(projectRoot, relPaths) {
|
|
|
105
105
|
for (let attempt = 0; attempt < MERGE_MAX_ATTEMPTS; attempt += 1) {
|
|
106
106
|
try {
|
|
107
107
|
const paths = await withFileLock(dirtyPath, mergeOnce);
|
|
108
|
+
// TASK-004: withFileLock is fail-closed — a contended lock skips the merge
|
|
109
|
+
// (journaled to dirty.json.lock-drops.jsonl) and resolves undefined. Return
|
|
110
|
+
// the current state like the exhausted-retry path; never retry unlocked.
|
|
111
|
+
if (paths === undefined) break;
|
|
108
112
|
return { dirty: paths };
|
|
109
113
|
} catch (error) {
|
|
110
114
|
lastError = error;
|
package/src/core/fileOps.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import crypto from 'node:crypto';
|
|
2
1
|
import fs from 'node:fs/promises';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
|
|
4
|
+
// The shipped runtime carries the single lock implementation; src and the
|
|
5
|
+
// installed package always ship templates/ together (package.json files list),
|
|
6
|
+
// so the protocol twin delegates instead of keeping a driftable copy.
|
|
7
|
+
import { journalDroppedLockMutation, withAsyncLock } from '../../templates/.claude/ukit/runtime/async-lock.mjs';
|
|
8
|
+
|
|
5
9
|
export async function pathExists(targetPath) {
|
|
6
10
|
try {
|
|
7
11
|
await fs.access(targetPath);
|
|
@@ -104,7 +108,17 @@ export async function writeFileAtomic(filePath, content) {
|
|
|
104
108
|
} else {
|
|
105
109
|
await fs.writeFile(tempPath, content, 'utf8');
|
|
106
110
|
}
|
|
107
|
-
|
|
111
|
+
try {
|
|
112
|
+
await fs.rename(tempPath, filePath);
|
|
113
|
+
} catch (renameError) {
|
|
114
|
+
// EXDEV: the tmp file and the destination sit on different mounts (union
|
|
115
|
+
// mounts, per-dir bind mounts, tmpfs overlays), so rename cannot link them.
|
|
116
|
+
// The payload is already fully written — copy it over and unlink the tmp.
|
|
117
|
+
// Less atomic than rename, but the update must not be silently lost.
|
|
118
|
+
if (renameError?.code !== 'EXDEV') throw renameError;
|
|
119
|
+
await fs.copyFile(tempPath, filePath);
|
|
120
|
+
await fs.rm(tempPath, { force: true });
|
|
121
|
+
}
|
|
108
122
|
} catch (error) {
|
|
109
123
|
try {
|
|
110
124
|
await fs.rm(tempPath, { force: true });
|
|
@@ -161,130 +175,39 @@ export async function writeJson(filePath, data) {
|
|
|
161
175
|
const LOCK_STALE_MS = 10_000;
|
|
162
176
|
const LOCK_MAX_WAIT_MS = 5_000;
|
|
163
177
|
|
|
164
|
-
function lockBackoffDelayMs() {
|
|
165
|
-
return 3 + Math.floor(Math.random() * 9);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function sleep(ms) {
|
|
169
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function isPidAlive(pid) {
|
|
173
|
-
try {
|
|
174
|
-
process.kill(pid, 0);
|
|
175
|
-
return true;
|
|
176
|
-
} catch (error) {
|
|
177
|
-
// EPERM: the process exists but belongs to another user — still alive.
|
|
178
|
-
return error?.code === 'EPERM';
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
async function readLockOwner(lockPath) {
|
|
183
|
-
try {
|
|
184
|
-
const raw = JSON.parse(await fs.readFile(path.join(lockPath, 'owner'), 'utf8'));
|
|
185
|
-
const pid = Number(raw?.pid);
|
|
186
|
-
return Number.isInteger(pid) && pid > 0
|
|
187
|
-
? { pid, token: typeof raw?.token === 'string' ? raw.token : null }
|
|
188
|
-
: null;
|
|
189
|
-
} catch {
|
|
190
|
-
return null;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Same-pid holders are parallel async flows whose liveness a pid probe cannot prove.
|
|
195
|
-
const inProcessLockHolders = new Map();
|
|
196
|
-
|
|
197
178
|
/**
|
|
198
179
|
* Serialize read-modify-write mutations of a shared state file — across processes
|
|
199
180
|
* (hook invocations run as separate node processes) and across concurrent async
|
|
200
181
|
* flows in one process (parallel subagents). The lock is a directory created next
|
|
201
182
|
* to the target file: `mkdir` is atomic, so exactly one caller can create it.
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
183
|
+
*
|
|
184
|
+
* The entire lock protocol lives in templates/.claude/ukit/runtime/async-lock.mjs
|
|
185
|
+
* (TASK-004 fix round 1): owner stamping (pid + token + pstart), recycled-pid
|
|
186
|
+
* detection via recordedProcessGone, claim+quarantine stale reclaim, and verified
|
|
187
|
+
* release exist exactly once there — this function and the token-utils.mjs twin
|
|
188
|
+
* both delegate to it so the two protocol copies can never drift apart again.
|
|
189
|
+
*
|
|
190
|
+
* FAIL-CLOSED (TASK-004, unified with async-lock/ledger policy): if the lock cannot
|
|
191
|
+
* be acquired within maxWaitMs the callback is SKIPPED — never run unlocked — and
|
|
192
|
+
* the drop is journaled to `<file>.lock-drops.jsonl`. These state files are
|
|
193
|
+
* advisory caches: losing an update was already the accepted outcome of the old
|
|
194
|
+
* fail-open race; now it is explicit and journaled instead of a silent torn write.
|
|
212
195
|
* @param {string} filePath - state file the mutation targets (lock lives beside it)
|
|
213
196
|
* @param {() => Promise<*>} fn - critical section; its result is returned
|
|
214
|
-
* @returns {Promise
|
|
197
|
+
* @returns {Promise<*|undefined>} whatever fn resolves with, or undefined when the
|
|
198
|
+
* lock wait expired and the mutation was skipped (journaled)
|
|
215
199
|
*/
|
|
216
200
|
export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxWaitMs = LOCK_MAX_WAIT_MS } = {}) {
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
locked = true;
|
|
228
|
-
inProcessLockHolders.set(lockPath, ownerToken);
|
|
229
|
-
try {
|
|
230
|
-
await fs.writeFile(
|
|
231
|
-
path.join(lockPath, 'owner'),
|
|
232
|
-
`${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now() })}\n`,
|
|
233
|
-
'utf8',
|
|
234
|
-
);
|
|
235
|
-
ownerStamped = true;
|
|
236
|
-
} catch {
|
|
237
|
-
ownerStamped = false; // unverifiable release skips removal; stale reclaim cleans up
|
|
238
|
-
}
|
|
239
|
-
break;
|
|
240
|
-
} catch (error) {
|
|
241
|
-
if (error?.code !== 'EEXIST') throw error;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Someone holds the lock. Reclaim it only when the holder is provably gone.
|
|
245
|
-
try {
|
|
246
|
-
const stat = await fs.stat(lockPath);
|
|
247
|
-
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
248
|
-
const owner = await readLockOwner(lockPath);
|
|
249
|
-
const liveInProcess = inProcessLockHolders.has(lockPath);
|
|
250
|
-
const reclaimable = !owner || owner.pid === process.pid
|
|
251
|
-
? !liveInProcess
|
|
252
|
-
: !isPidAlive(owner.pid);
|
|
253
|
-
if (reclaimable) {
|
|
254
|
-
await fs.rm(lockPath, { recursive: true, force: true });
|
|
255
|
-
continue; // the slot is free now — retry immediately
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
} catch (statError) {
|
|
259
|
-
// BUG-C22-17: a persistent stat error (EPERM/ENOTDIR/EIO on a failing
|
|
260
|
-
// mount, or ELOOP/ENOENT on a dangling symlink where mkdir still reports
|
|
261
|
-
// EEXIST) must not busy-spin — a bare `continue` skipped both the
|
|
262
|
-
// maxWait break and the backoff sleep, looping mkdir→stat→throw forever.
|
|
263
|
-
if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
|
|
264
|
-
if (statError?.code === 'ENOENT') continue; // lock vanished — retry immediately
|
|
265
|
-
await sleep(lockBackoffDelayMs());
|
|
266
|
-
continue;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
|
|
270
|
-
await sleep(lockBackoffDelayMs());
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
try {
|
|
274
|
-
return await fn();
|
|
275
|
-
} finally {
|
|
276
|
-
if (locked) {
|
|
277
|
-
try {
|
|
278
|
-
const current = ownerStamped ? await readLockOwner(lockPath) : null;
|
|
279
|
-
if (current && current.token === ownerToken) {
|
|
280
|
-
await fs.rm(lockPath, { recursive: true, force: true });
|
|
281
|
-
}
|
|
282
|
-
if (inProcessLockHolders.get(lockPath) === ownerToken) inProcessLockHolders.delete(lockPath);
|
|
283
|
-
} catch {
|
|
284
|
-
// best-effort release; a stale lock is reclaimed by the next waiter
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
}
|
|
201
|
+
const outcome = await withAsyncLock(filePath, { deadlineMs: maxWaitMs, staleMs }, fn);
|
|
202
|
+
if (outcome?.ok === true) return outcome.value;
|
|
203
|
+
// Fail closed: the mutation is dropped, never run unlocked. The drop is
|
|
204
|
+
// journaled so the lost update is auditable; callers treat undefined as
|
|
205
|
+
// "update skipped" (they already tolerated losing it silently).
|
|
206
|
+
await journalDroppedLockMutation(filePath, {
|
|
207
|
+
reason: 'lock-wait-expired',
|
|
208
|
+
waitedMs: outcome?.waitedMs ?? maxWaitMs,
|
|
209
|
+
});
|
|
210
|
+
return undefined;
|
|
288
211
|
}
|
|
289
212
|
|
|
290
213
|
/**
|
|
@@ -39,6 +39,16 @@ export function buildTemplateVariables({ projectContext, stackContext, packageVe
|
|
|
39
39
|
os: projectContext.runtime.os,
|
|
40
40
|
nodeVersion: projectContext.runtime.nodeVersion,
|
|
41
41
|
},
|
|
42
|
+
verification: {
|
|
43
|
+
// CX-10: render only commands whose scripts exist in the target
|
|
44
|
+
// package.json — a hardcoded lint/typecheck list produced a permanently
|
|
45
|
+
// failing verification contract on projects without those scripts.
|
|
46
|
+
requiredBeforeCompletion: JSON.stringify(
|
|
47
|
+
['test', 'lint', 'typecheck']
|
|
48
|
+
.filter((name) => projectContext?.scripts?.[name])
|
|
49
|
+
.map((name) => `${projectContext.runtime.packageManager} ${name}`),
|
|
50
|
+
),
|
|
51
|
+
},
|
|
42
52
|
stack: {
|
|
43
53
|
// original flags
|
|
44
54
|
frontend: stackContext.flags.frontend,
|
|
@@ -50,7 +50,7 @@ Systematic debugging — understand before fixing.
|
|
|
50
50
|
|
|
51
51
|
```
|
|
52
52
|
STATUS: DONE | BLOCKED | PARTIAL
|
|
53
|
-
EXECUTOR_TOOL: [claude-code |
|
|
53
|
+
EXECUTOR_TOOL: [claude-code | codex | omp | other]
|
|
54
54
|
EXECUTOR_MODEL: [exact model name you are running as. "unknown" if you cannot tell.]
|
|
55
55
|
EXECUTOR_SUBAGENT: [subagent name within your host, if any, else "-"]
|
|
56
56
|
SUMMARY: [1-2 sentences — root cause and fix]
|
|
@@ -72,9 +72,9 @@ and even then, report it, don't ask about it.
|
|
|
72
72
|
|
|
73
73
|
```
|
|
74
74
|
STATUS: DONE | BLOCKED | PARTIAL
|
|
75
|
-
EXECUTOR_TOOL: [claude-code |
|
|
75
|
+
EXECUTOR_TOOL: [claude-code | codex | omp | other]
|
|
76
76
|
EXECUTOR_MODEL: [exact model name you are running as — e.g. unic-code, claude-sonnet-4-5, gpt-5-mini. If you truly cannot tell, write "unknown" — reviewer treats unknown as suspicious and asks the human to confirm.]
|
|
77
|
-
EXECUTOR_SUBAGENT: [name of the subagent you are, if your host has multiple — e.g. "
|
|
77
|
+
EXECUTOR_SUBAGENT: [name of the subagent you are, if your host has multiple — e.g. "Claude:feature-implementer", "omp:task". Otherwise "-".]
|
|
78
78
|
SUMMARY: [1-2 sentences of what was implemented]
|
|
79
79
|
TEST_PLAN_FOLLOWED: [task §4 / inline / N/A — reason]
|
|
80
80
|
FILES_CHANGED:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# /ukit:handoff-create — Phase 1 + 2: Plan
|
|
2
2
|
|
|
3
3
|
**Role: PLANNER**
|
|
4
|
-
**Tool: any** (Claude Code / Codex / omp
|
|
4
|
+
**Tool: any** (Claude Code / Codex / omp — your choice)
|
|
5
5
|
**Model split:**
|
|
6
6
|
- Read/understand → lite model (haiku · unic-lite · cheapest available)
|
|
7
7
|
- Write plan + tasks → strong model (Opus · unic-smart · strongest available)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# /ukit:handoff-implement — Phase 3: Execute
|
|
2
2
|
|
|
3
3
|
**Role: EXECUTOR (orchestrated)**
|
|
4
|
-
**Tool: any** (Claude Code / Codex / omp
|
|
4
|
+
**Tool: any** (Claude Code / Codex / omp — your choice)
|
|
5
5
|
**Model: code model** (Sonnet · unic-code · cheap-smart)
|
|
6
6
|
|
|
7
7
|
> **omp model tiers:** the tiers above map to `.omp/config.yml`'s `modelRoles`, referenced from agent frontmatter as `@smol` / `@default` / `@slow`.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# /ukit:handoff-review — Phase 4: Review
|
|
2
2
|
|
|
3
3
|
**Role: REVIEWER**
|
|
4
|
-
**Tool: any** (Claude Code / Codex / omp
|
|
4
|
+
**Tool: any** (Claude Code / Codex / omp — your choice)
|
|
5
5
|
**Model: strong model, MUST differ from executor** (Opus · unic-smart · strongest available)
|
|
6
6
|
|
|
7
7
|
> **omp model tiers:** the tiers above map to `.omp/config.yml`'s `modelRoles`, referenced from agent frontmatter as `@smol` / `@default` / `@slow`.
|
|
@@ -238,7 +238,10 @@ async function readRunCursor() {
|
|
|
238
238
|
if (synced.reset || synced.probed) {
|
|
239
239
|
// Persist a clean probe too: hook processes are ephemeral, so otherwise every
|
|
240
240
|
// mutation would reopen and parse the same 4MB transcript tail.
|
|
241
|
-
|
|
241
|
+
// TASK-004: withFileLock is fail-closed — a contended pressure lock skips the
|
|
242
|
+
// write and resolves undefined (journaled). Keep the synced in-memory state;
|
|
243
|
+
// the gate still decides on correct data, the persist just lands next run.
|
|
244
|
+
state = (await mod.writeCompactPressureState(projectRoot, synced.state, sessionConfig)) ?? synced.state;
|
|
242
245
|
}
|
|
243
246
|
} catch {
|
|
244
247
|
state = await mod.buildCompactPressureState(rawState, sessionConfig);
|
|
@@ -187,16 +187,24 @@ if (toolName === 'Write' || toolName === 'Edit') {
|
|
|
187
187
|
const newVisible = stripHtmlComments(newContent);
|
|
188
188
|
const currentVisible = stripHtmlComments(currentContent);
|
|
189
189
|
|
|
190
|
+
// A documented, human-approved override closes the tier contract for this
|
|
191
|
+
// cycle — e.g. a gateway with no smart-tier model where the human approved
|
|
192
|
+
// planning on the available tier. Same semantics as the push-time check.
|
|
193
|
+
const planHasHumanOverride = (text) =>
|
|
194
|
+
/## Model-Tier Guard Override/.test(text) && /OVERRIDE_APPROVED_BY:\s*human/i.test(text);
|
|
195
|
+
|
|
190
196
|
if (isPlan && /## Planner Report/.test(newVisible)) {
|
|
191
197
|
const plannerModel = extractField(newVisible, 'PLANNER_MODEL');
|
|
192
|
-
if (!
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
198
|
+
if (!planHasHumanOverride(newVisible)) {
|
|
199
|
+
if (!plannerModel || /^unknown$/i.test(plannerModel)) {
|
|
200
|
+
block('PLANNER_MODEL missing/unknown in PLAN.md. Planning must run via Agent tool subagent_type: "handoff-planner" (opus/unic-smart) and self-report its model.');
|
|
201
|
+
}
|
|
202
|
+
if (tierOf(plannerModel) === 'vision') {
|
|
203
|
+
block(`PLANNER_MODEL "${plannerModel}" ${VISION_LANE_MESSAGE}`);
|
|
204
|
+
}
|
|
205
|
+
if (tierOf(plannerModel) !== 'smart') {
|
|
206
|
+
block(`PLANNER_MODEL "${plannerModel}" is not strong/opus tier. Planning must run via Agent tool subagent_type: "handoff-planner" (opus/unic-smart).`);
|
|
207
|
+
}
|
|
200
208
|
}
|
|
201
209
|
}
|
|
202
210
|
|
|
@@ -209,8 +217,9 @@ if (toolName === 'Write' || toolName === 'Edit') {
|
|
|
209
217
|
const isFreshTaskFile = !fileExists;
|
|
210
218
|
if (isFreshTaskFile) {
|
|
211
219
|
const planContent = (await pathExists(planPath)) ? await readTextSafe(planPath) : '';
|
|
212
|
-
const
|
|
213
|
-
|
|
220
|
+
const planVisible = stripHtmlComments(planContent);
|
|
221
|
+
const plannerModel = extractField(planVisible, 'PLANNER_MODEL');
|
|
222
|
+
if (!planHasHumanOverride(planVisible) && (!plannerModel || tierOf(plannerModel) !== 'smart')) {
|
|
214
223
|
block(`Cannot create ${taskId}.md — PLAN.md has no valid smart-tier PLANNER_MODEL yet. Run planning via Agent tool subagent_type: "handoff-planner" (opus/unic-smart) first.`);
|
|
215
224
|
}
|
|
216
225
|
|
|
@@ -285,13 +294,15 @@ if (toolName === 'Write' || toolName === 'Edit') {
|
|
|
285
294
|
if (hasReviewerVerdict && !currentReviewerReal) {
|
|
286
295
|
const executorModel = extractField(currentVisible, 'EXECUTOR_MODEL') || extractField(newVisible, 'EXECUTOR_MODEL');
|
|
287
296
|
const reviewerModel = extractField(newVisible, 'REVIEWER_MODEL');
|
|
297
|
+
const planContentForReview = (await pathExists(planPath)) ? await readTextSafe(planPath) : '';
|
|
298
|
+
const reviewOverride = planHasHumanOverride(stripHtmlComments(planContentForReview));
|
|
288
299
|
if (isPlaceholderValue(reviewerModel) || /^unknown$/i.test(reviewerModel)) {
|
|
289
300
|
if (!isFreshTaskFile) {
|
|
290
301
|
block(`${taskId}: REVIEWER_MODEL missing/placeholder. Review must run via Agent tool subagent_type: "code-reviewer" (opus/unic-smart) and self-report its model.`);
|
|
291
302
|
}
|
|
292
303
|
// Else: skeleton Reviewer Verdict on a freshly created task file — Phase 4
|
|
293
304
|
// appends the real verdict and it is validated there.
|
|
294
|
-
} else {
|
|
305
|
+
} else if (!reviewOverride) {
|
|
295
306
|
if (tierOf(reviewerModel) === 'vision') {
|
|
296
307
|
block(`${taskId}: REVIEWER_MODEL "${reviewerModel}" ${VISION_LANE_MESSAGE}`);
|
|
297
308
|
}
|
|
@@ -151,6 +151,13 @@ process.stdin.on("end", () => {
|
|
|
151
151
|
}
|
|
152
152
|
})();
|
|
153
153
|
const sessionId = typeof payload.session_id === "string" ? payload.session_id.trim() : "";
|
|
154
|
+
// TASK-008 fix round: the pressure writer keys records by payload.session_id,
|
|
155
|
+
// and hosts that omit session_id on prompt/tool events (omp) now send the
|
|
156
|
+
// transcript path as the session key. When session_start DOES carry a real id
|
|
157
|
+
// but the writers did not, the record lives under the transcript key — delete
|
|
158
|
+
// both so the targeted reset cannot silently miss. Transcript paths are
|
|
159
|
+
// unique per session, so this can never touch a sibling record.
|
|
160
|
+
const transcriptKey = typeof payload.transcript_path === "string" ? payload.transcript_path.trim() : "";
|
|
154
161
|
const pressurePath = process.argv[1];
|
|
155
162
|
|
|
156
163
|
withLock(`${pressurePath}.lock`, async () => {
|
|
@@ -179,6 +186,9 @@ process.stdin.on("end", () => {
|
|
|
179
186
|
|
|
180
187
|
const sessions = { ...raw.sessions };
|
|
181
188
|
delete sessions[sessionId];
|
|
189
|
+
if (transcriptKey && transcriptKey !== sessionId) {
|
|
190
|
+
delete sessions[transcriptKey];
|
|
191
|
+
}
|
|
182
192
|
const newest = Object.entries(sessions)
|
|
183
193
|
.sort(([, a], [, b]) => (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0))[0]?.[1] ?? null;
|
|
184
194
|
const tempPath = `${pressurePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
@@ -2523,6 +2523,10 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2523
2523
|
if (!entry || typeof entry !== 'object') {
|
|
2524
2524
|
return;
|
|
2525
2525
|
}
|
|
2526
|
+
// The sandbox starts without .ukit/storage/cache. Create it before the
|
|
2527
|
+
// sidecar append and lock acquisition; otherwise both operations fail with
|
|
2528
|
+
// ENOENT and withStateLock falls through to an unlocked read-modify-write.
|
|
2529
|
+
await ensureDir(filePath);
|
|
2526
2530
|
|
|
2527
2531
|
// BUG-C23-15: the entry is STAGED to an append-only sidecar before the lock is
|
|
2528
2532
|
// attempted (small appendFile is atomic — no read-modify-write, so no lost
|
|
@@ -2605,14 +2609,17 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2605
2609
|
if (!error || error.code !== 'EEXIST') return;
|
|
2606
2610
|
try {
|
|
2607
2611
|
const stat = fsSync.statSync(lockPath);
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2612
|
+
let owner = null;
|
|
2613
|
+
try {
|
|
2614
|
+
owner = JSON.parse(fsSync.readFileSync(path.join(lockPath, 'owner'), 'utf8'));
|
|
2615
|
+
} catch {}
|
|
2616
|
+
// The watchdog may call process.exit while this process is in the
|
|
2617
|
+
// async merge. Its finally block cannot run after that synchronous
|
|
2618
|
+
// exit, so the exit fold must reclaim its own abandoned lock before
|
|
2619
|
+
// folding the staged sidecar. A live foreign owner remains protected.
|
|
2620
|
+
const ownInterruptedLock = owner?.pid === process.pid;
|
|
2621
|
+
if (ownInterruptedLock || Date.now() - stat.mtimeMs > 10000) {
|
|
2622
|
+
if (ownInterruptedLock || !owner || !isPidAlive(owner.pid)) {
|
|
2616
2623
|
fsSync.rmSync(lockPath, { recursive: true, force: true });
|
|
2617
2624
|
continue;
|
|
2618
2625
|
}
|
|
@@ -215,6 +215,9 @@ function isVerificationCommand(command) {
|
|
|
215
215
|
].some((pattern) => pattern.test(command));
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
// F-15: execution-ledger.mjs mirrors this list in its own
|
|
219
|
+
// isExplicitBroadVerificationRequest — the completion gate honors the same
|
|
220
|
+
// explicit-broad signal when classifying a scope='broad' receipt. Keep both in sync.
|
|
218
221
|
function isExplicitBroadVerificationRequest(promptText) {
|
|
219
222
|
const text = String(promptText || '').toLowerCase();
|
|
220
223
|
return [
|