@ngockhoale/ukit 2.7.7 → 2.7.9
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/src/context/detectProjectContext.js +5 -0
- package/src/core/codeintel/invalidation.js +4 -0
- package/src/core/diffPlan.js +60 -1
- package/src/core/fileOps.js +46 -119
- 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-clear.md +11 -0
- package/templates/.claude/commands/ukit/handoff-create.md +1 -1
- package/templates/.claude/commands/ukit/handoff-fullstack.md +26 -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/index/stale-spec-check.mjs +38 -3
- package/templates/.claude/ukit/runtime/async-lock.mjs +240 -42
- 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 +131 -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 +216 -51
- 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,51 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
|
|
6
|
+
## 2.7.9 - 2026-09-22
|
|
7
|
+
|
|
8
|
+
- **User permission mode is never overwritten**: `ukit install`/`ukit update`
|
|
9
|
+
used to rewrite `.claude/settings.json` wholesale, so the template's
|
|
10
|
+
`permissions.defaultMode: "bypassPermissions"` silently clobbered a
|
|
11
|
+
user-chosen mode (e.g. `plan`) on every install. `defaultMode` is now
|
|
12
|
+
user-owned in `merge_env_overwrite_with_backup`: excluded from the drift
|
|
13
|
+
comparison and merged back into the written content. The template default
|
|
14
|
+
still applies on fresh installs or when the user never set a mode.
|
|
15
|
+
(`src/core/diffPlan.js`, +4 tests in `tests/core/diffPlan.test.js`)
|
|
16
|
+
|
|
17
|
+
## 2.7.8 - 2026-09-22
|
|
18
|
+
|
|
19
|
+
Stall/hang fixes — cycle C43 (TASK-001..008): the indefinite-stall producers
|
|
20
|
+
root-caused in `docs/AI_REPORT/2026-09-21-NORMAL_CHAT_STALL.md` are fixed
|
|
21
|
+
across Claude Code, omp, and Codex runtimes.
|
|
22
|
+
|
|
23
|
+
- **CX-8**: route-state writes are atomic + locked — torn `skill-router-state.json`
|
|
24
|
+
writes can no longer wedge the router.
|
|
25
|
+
- **OMP-3/F-2**: omp receipt dedupe — a tool result is recorded once; duplicate
|
|
26
|
+
receipts no longer double-count execution records.
|
|
27
|
+
- **RC-4/OMP-1/F-3/F-4**: breaker-freeze family — budgeted reclaim, pstart
|
|
28
|
+
stamps, fail-open streaks; continuation sidecar merge gate stamps
|
|
29
|
+
`lastContinuationAt` strictly newer than its base and materializes counter
|
|
30
|
+
state when the main ledger file is absent.
|
|
31
|
+
- **F-5/F-5b**: `withFileLock` policy + reclaim — stale locks reclaimed via
|
|
32
|
+
pstart mismatch; recycled-pid regression tests added.
|
|
33
|
+
- **F-15**: verifier-classification — verification outcomes classified so a
|
|
34
|
+
non-verification failure no longer masquerades as a test failure.
|
|
35
|
+
- **F-RC-2**: `hook-chain-runner` exits promptly after verdict — bounded stdout
|
|
36
|
+
flush then `process.exit`; an abandoned in-proc step promise can no longer
|
|
37
|
+
pin the event loop for the full registered timeout.
|
|
38
|
+
- **F-10/OMP-2**: omp Stop parity — omp sessions get the same handoff-cursor
|
|
39
|
+
Stop-gate lane as Claude Code.
|
|
40
|
+
- **OMP-6/F-8/CX-10**: `createPayloadReference` staging runs under a deadline
|
|
41
|
+
with fail-open to inline payload (never throws, host loop not frozen); omp
|
|
42
|
+
SessionStart carries `session_id` so `reset-compact-pressure` no longer
|
|
43
|
+
wipes every session's pressure records; codex `requiredBeforeCompletion`
|
|
44
|
+
renders only commands that exist in `package.json`.
|
|
45
|
+
- Housekeeping: stale "Kilo" tool labels scrubbed from handoff docs, agent
|
|
46
|
+
report contracts, and config help text (functional support was already
|
|
47
|
+
removed; the regression tests and gateway comments documenting that removal
|
|
48
|
+
are intentionally kept).
|
|
49
|
+
|
|
5
50
|
## 2.7.7 - 2026-09-21
|
|
6
51
|
|
|
7
52
|
Audit-report remediation + hook observability — cycle C42 (TASK-001..011): all 12
|
|
@@ -64,6 +109,15 @@ findings from the three 2026-09-20 reports closed; the reports moved to
|
|
|
64
109
|
fixture whose outcome depended on whether the clock ticked mid-array, so it
|
|
65
110
|
passed on an unloaded box and failed under load; the fixture is now
|
|
66
111
|
deterministic and the case fails against the old code.
|
|
112
|
+
- `withFileLock` (both `templates/.claude/ukit/runtime/token-utils.mjs` and its
|
|
113
|
+
`src/core/fileOps.js` twin) is now fail-closed: a lock wait that exceeds
|
|
114
|
+
`maxWaitMs` skips the mutation instead of running it unlocked, and the drop is
|
|
115
|
+
journaled to `<file>.lock-drops.jsonl` (bounded 128-record JSONL). Both twins
|
|
116
|
+
delegate to `async-lock.mjs`, so owner stamping (pid + token + pstart),
|
|
117
|
+
recycled-pid detection, and claim+quarantine stale reclaim exist exactly once;
|
|
118
|
+
a stale lock whose recorded pid was recycled is now reclaimed instead of
|
|
119
|
+
wedging every mutation for that file. `writeJson`/`writeFileAtomic` fall back
|
|
120
|
+
to copy+unlink when `rename` fails with EXDEV (cross-mount tmp dirs).
|
|
67
121
|
|
|
68
122
|
## 2.7.6 - 2026-09-20
|
|
69
123
|
|
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/diffPlan.js
CHANGED
|
@@ -34,6 +34,54 @@ async function checkLinkStatus(targetPath, linkTarget) {
|
|
|
34
34
|
// template, so a template-side change to e.g. CLAUDE_CODE_AUTO_COMPACT_WINDOW still triggers
|
|
35
35
|
// a real reinstall. Without this carve-out, the managed gateway keys written by the post-apply
|
|
36
36
|
// step would cause a spurious "update" on every subsequent install.
|
|
37
|
+
//
|
|
38
|
+
// `permissions.defaultMode` is user-owned: the user picks their permission mode
|
|
39
|
+
// (bypassPermissions / plan / acceptEdits / default) and UKit must never change it.
|
|
40
|
+
// It is excluded from the drift comparison AND re-injected into the rendered content
|
|
41
|
+
// before write (mergeSettingsJson), so an install/update can never flip the user's mode.
|
|
42
|
+
// When the user has NOT set it, the template's shipped default applies on create/update.
|
|
43
|
+
const USER_OWNED_PERMISSION_KEYS = new Set(['defaultMode']);
|
|
44
|
+
|
|
45
|
+
function parseSettingsJson(content) {
|
|
46
|
+
if (typeof content !== 'string') return null;
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(content);
|
|
49
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Strip user-owned permission keys from a parsed permissions object for comparison.
|
|
56
|
+
// Returns a new object; never mutates the input.
|
|
57
|
+
function stripUserOwnedPermissionKeys(permissions) {
|
|
58
|
+
if (!permissions || typeof permissions !== 'object' || Array.isArray(permissions)) {
|
|
59
|
+
return permissions;
|
|
60
|
+
}
|
|
61
|
+
const stripped = { ...permissions };
|
|
62
|
+
for (const key of USER_OWNED_PERMISSION_KEYS) delete stripped[key];
|
|
63
|
+
return stripped;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Merge rendered settings.json with the user's existing file: the rendered template
|
|
67
|
+
// wins every key EXCEPT user-owned permission keys (currently permissions.defaultMode),
|
|
68
|
+
// which keep the value the user set. Returns the merged JSON as a string, or the
|
|
69
|
+
// rendered content unchanged when either side is unparseable.
|
|
70
|
+
export function mergeSettingsJson(existingContent, renderedContent) {
|
|
71
|
+
const existing = parseSettingsJson(existingContent);
|
|
72
|
+
const rendered = parseSettingsJson(renderedContent);
|
|
73
|
+
if (!existing || !rendered) return renderedContent;
|
|
74
|
+
const merged = { ...rendered };
|
|
75
|
+
if (existing.permissions && typeof existing.permissions === 'object' && !Array.isArray(existing.permissions)) {
|
|
76
|
+
const mergedPermissions = { ...(rendered.permissions && typeof rendered.permissions === 'object' ? rendered.permissions : {}) };
|
|
77
|
+
for (const key of USER_OWNED_PERMISSION_KEYS) {
|
|
78
|
+
if (key in existing.permissions) mergedPermissions[key] = existing.permissions[key];
|
|
79
|
+
}
|
|
80
|
+
merged.permissions = mergedPermissions;
|
|
81
|
+
}
|
|
82
|
+
return `${JSON.stringify(merged, null, 2)}\n`;
|
|
83
|
+
}
|
|
84
|
+
|
|
37
85
|
function settingsJsonIgnoresGatewayEnvDiff(existingContent, renderedContent) {
|
|
38
86
|
if (typeof existingContent !== 'string' || typeof renderedContent !== 'string') {
|
|
39
87
|
return false;
|
|
@@ -56,7 +104,13 @@ function settingsJsonIgnoresGatewayEnvDiff(existingContent, renderedContent) {
|
|
|
56
104
|
for (const key of Object.keys(parsedRendered)) {
|
|
57
105
|
if (key === 'env') continue;
|
|
58
106
|
if (!(key in parsedExisting)) return false;
|
|
59
|
-
|
|
107
|
+
const renderedValue = key === 'permissions'
|
|
108
|
+
? stripUserOwnedPermissionKeys(parsedRendered[key])
|
|
109
|
+
: parsedRendered[key];
|
|
110
|
+
const existingValue = key === 'permissions'
|
|
111
|
+
? stripUserOwnedPermissionKeys(parsedExisting[key])
|
|
112
|
+
: parsedExisting[key];
|
|
113
|
+
if (JSON.stringify(existingValue) !== JSON.stringify(renderedValue)) {
|
|
60
114
|
return false;
|
|
61
115
|
}
|
|
62
116
|
}
|
|
@@ -128,7 +182,12 @@ function resolveFileAction(entry, existingContent) {
|
|
|
128
182
|
// Settings.json: ignore UKit-managed gateway resilience env keys (post-apply written
|
|
129
183
|
// and may carry user overrides). All other top-level keys — and the rest of the env
|
|
130
184
|
// block — must still match the template, so genuine template updates still reinstall.
|
|
185
|
+
// User-owned permission keys (permissions.defaultMode) are excluded from the drift
|
|
186
|
+
// check AND merged back into the content that gets written, so install/update can
|
|
187
|
+
// never flip the user's chosen permission mode.
|
|
188
|
+
const merged = mergeSettingsJson(existingContent, entry.renderedContent);
|
|
131
189
|
action = settingsJsonIgnoresGatewayEnvDiff(existingContent, entry.renderedContent) ? 'unchanged' : 'update';
|
|
190
|
+
return { ...entry, renderedContent: merged, exists, action, existingContent };
|
|
132
191
|
} else if (existingContent === entry.renderedContent) {
|
|
133
192
|
action = 'unchanged';
|
|
134
193
|
} else if (entry.mergeStrategy === 'skip') {
|
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, withTransientFsRetry } 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);
|
|
@@ -99,12 +103,26 @@ export async function writeFileAtomic(filePath, content) {
|
|
|
99
103
|
const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
100
104
|
|
|
101
105
|
try {
|
|
106
|
+
// C44: temp write, rename, and the EXDEV copy+rm fallback each retry
|
|
107
|
+
// transient kernel errors (EAGAIN/EBUSY/EMFILE/ENFILE/ESTALE) — the same
|
|
108
|
+
// transient-open EAGAIN observed on external APFS volumes under metadata
|
|
109
|
+
// churn. Non-transient codes (EXDEV, EISDIR) throw immediately, unchanged.
|
|
102
110
|
if (Buffer.isBuffer(content)) {
|
|
103
|
-
await fs.writeFile(tempPath, content);
|
|
111
|
+
await withTransientFsRetry(() => fs.writeFile(tempPath, content));
|
|
104
112
|
} else {
|
|
105
|
-
await fs.writeFile(tempPath, content, 'utf8');
|
|
113
|
+
await withTransientFsRetry(() => fs.writeFile(tempPath, content, 'utf8'));
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
await withTransientFsRetry(() => fs.rename(tempPath, filePath));
|
|
117
|
+
} catch (renameError) {
|
|
118
|
+
// EXDEV: the tmp file and the destination sit on different mounts (union
|
|
119
|
+
// mounts, per-dir bind mounts, tmpfs overlays), so rename cannot link them.
|
|
120
|
+
// The payload is already fully written — copy it over and unlink the tmp.
|
|
121
|
+
// Less atomic than rename, but the update must not be silently lost.
|
|
122
|
+
if (renameError?.code !== 'EXDEV') throw renameError;
|
|
123
|
+
await withTransientFsRetry(() => fs.copyFile(tempPath, filePath));
|
|
124
|
+
await withTransientFsRetry(() => fs.rm(tempPath, { force: true }));
|
|
106
125
|
}
|
|
107
|
-
await fs.rename(tempPath, filePath);
|
|
108
126
|
} catch (error) {
|
|
109
127
|
try {
|
|
110
128
|
await fs.rm(tempPath, { force: true });
|
|
@@ -161,130 +179,39 @@ export async function writeJson(filePath, data) {
|
|
|
161
179
|
const LOCK_STALE_MS = 10_000;
|
|
162
180
|
const LOCK_MAX_WAIT_MS = 5_000;
|
|
163
181
|
|
|
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
182
|
/**
|
|
198
183
|
* Serialize read-modify-write mutations of a shared state file — across processes
|
|
199
184
|
* (hook invocations run as separate node processes) and across concurrent async
|
|
200
185
|
* flows in one process (parallel subagents). The lock is a directory created next
|
|
201
186
|
* to the target file: `mkdir` is atomic, so exactly one caller can create it.
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
187
|
+
*
|
|
188
|
+
* The entire lock protocol lives in templates/.claude/ukit/runtime/async-lock.mjs
|
|
189
|
+
* (TASK-004 fix round 1): owner stamping (pid + token + pstart), recycled-pid
|
|
190
|
+
* detection via recordedProcessGone, claim+quarantine stale reclaim, and verified
|
|
191
|
+
* release exist exactly once there — this function and the token-utils.mjs twin
|
|
192
|
+
* both delegate to it so the two protocol copies can never drift apart again.
|
|
193
|
+
*
|
|
194
|
+
* FAIL-CLOSED (TASK-004, unified with async-lock/ledger policy): if the lock cannot
|
|
195
|
+
* be acquired within maxWaitMs the callback is SKIPPED — never run unlocked — and
|
|
196
|
+
* the drop is journaled to `<file>.lock-drops.jsonl`. These state files are
|
|
197
|
+
* advisory caches: losing an update was already the accepted outcome of the old
|
|
198
|
+
* fail-open race; now it is explicit and journaled instead of a silent torn write.
|
|
212
199
|
* @param {string} filePath - state file the mutation targets (lock lives beside it)
|
|
213
200
|
* @param {() => Promise<*>} fn - critical section; its result is returned
|
|
214
|
-
* @returns {Promise
|
|
201
|
+
* @returns {Promise<*|undefined>} whatever fn resolves with, or undefined when the
|
|
202
|
+
* lock wait expired and the mutation was skipped (journaled)
|
|
215
203
|
*/
|
|
216
204
|
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
|
-
}
|
|
205
|
+
const outcome = await withAsyncLock(filePath, { deadlineMs: maxWaitMs, staleMs }, fn);
|
|
206
|
+
if (outcome?.ok === true) return outcome.value;
|
|
207
|
+
// Fail closed: the mutation is dropped, never run unlocked. The drop is
|
|
208
|
+
// journaled so the lost update is auditable; callers treat undefined as
|
|
209
|
+
// "update skipped" (they already tolerated losing it silently).
|
|
210
|
+
await journalDroppedLockMutation(filePath, {
|
|
211
|
+
reason: 'lock-wait-expired',
|
|
212
|
+
waitedMs: outcome?.waitedMs ?? maxWaitMs,
|
|
213
|
+
});
|
|
214
|
+
return undefined;
|
|
288
215
|
}
|
|
289
216
|
|
|
290
217
|
/**
|
|
@@ -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:
|
|
@@ -42,6 +42,11 @@ Write `docs/AI_HANDOFF/archive/cycle-NNN.md` (if there is anything worth archivi
|
|
|
42
42
|
```
|
|
43
43
|
If `archive/` has > 3 files → delete oldest, append 1-line summary to `HISTORY.md`.
|
|
44
44
|
|
|
45
|
+
After writing the archive file, **verify the write** — read it back and confirm
|
|
46
|
+
the `ABORTED` heading is present; on mismatch or read failure retry once, on
|
|
47
|
+
second failure stop and report (same rule as handoff-fullstack's "State-file
|
|
48
|
+
write verification").
|
|
49
|
+
|
|
45
50
|
## Step 4 — Reset state files
|
|
46
51
|
|
|
47
52
|
```
|
|
@@ -54,6 +59,12 @@ RUN.md → set `Phase: done` (or delete) — a live cursor that is neither `do
|
|
|
54
59
|
tasks/TASK-*.md → delete all (keep _TEMPLATE.md)
|
|
55
60
|
```
|
|
56
61
|
|
|
62
|
+
Every file rewritten above is a state file — **verify the write** on each:
|
|
63
|
+
read it back and confirm the marker you just wrote (empty INDEX header,
|
|
64
|
+
`_(no active cycle)_`, `Phase: done`). On mismatch or read failure retry the
|
|
65
|
+
write once; on second failure STOP and report — never leave unverified state
|
|
66
|
+
files behind, or the next cycle resumes from stale bytes.
|
|
67
|
+
|
|
57
68
|
## Step 5 — Report
|
|
58
69
|
|
|
59
70
|
```
|
|
@@ -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-fullstack — Full Pipeline: Plan → Implement → Review
|
|
2
2
|
|
|
3
3
|
**Role: ORCHESTRATOR**
|
|
4
|
-
**Tool: any** (Claude Code / Codex / omp
|
|
4
|
+
**Tool: any** (Claude Code / Codex / omp — your choice)
|
|
5
5
|
|
|
6
6
|
## Model Split
|
|
7
7
|
|
|
@@ -75,6 +75,23 @@ QuietScans: <n>/<required> # only while sweeping for stragglers near the end
|
|
|
75
75
|
This file is the resume contract. It costs one small write per step and is what turns an
|
|
76
76
|
interrupted run into a continuable one.
|
|
77
77
|
|
|
78
|
+
### State-file write verification — "verify the write"
|
|
79
|
+
|
|
80
|
+
Every Write/Edit to a handoff state file — `INDEX.md`, `ACTIVE.md`, `RUN.md`,
|
|
81
|
+
`SPEC.md`, `PLAN.md`, `tasks/TASK-*.md` — can be silently lost to a transient
|
|
82
|
+
filesystem error (the Write tool's own `open()` has been observed to fail with
|
|
83
|
+
EAGAIN while the write is reported as done). After each such write:
|
|
84
|
+
|
|
85
|
+
1. **Read it back** and confirm the marker you just wrote is present — the
|
|
86
|
+
`Status:` row, `Phase:`/`Cursor:` line, or section heading the write was
|
|
87
|
+
supposed to change.
|
|
88
|
+
2. **On mismatch or read failure, retry the write once.**
|
|
89
|
+
3. **On second failure, STOP the pipeline** and report the file plus the error —
|
|
90
|
+
never continue on unverified state. A stale `INDEX.md`/`RUN.md` is how a run
|
|
91
|
+
ships silent corruption.
|
|
92
|
+
|
|
93
|
+
Wherever a step below says **verify the write**, it means this rule.
|
|
94
|
+
|
|
78
95
|
### Resume — when the run is re-invoked mid-flight
|
|
79
96
|
|
|
80
97
|
If `docs/AI_HANDOFF/RUN.md` exists with `Phase:` not `done` or `blocked`, this is a
|
|
@@ -240,6 +257,7 @@ The planner agent does the following (use P1 summary — do NOT re-read files):
|
|
|
240
257
|
6. **Update `docs/AI_HANDOFF/INDEX.md`** — one row per task, `status=ready`. Every
|
|
241
258
|
unfinished item from the Phase 0 sweep is either a row here or superseded by a
|
|
242
259
|
`-R<n>` recovery row.
|
|
260
|
+
Then **verify the write** (read back the new rows).
|
|
243
261
|
|
|
244
262
|
7. **Update `docs/AI_HANDOFF/ACTIVE.md`:**
|
|
245
263
|
```
|
|
@@ -249,6 +267,7 @@ The planner agent does the following (use P1 summary — do NOT re-read files):
|
|
|
249
267
|
Tasks: <N> total
|
|
250
268
|
Status: planning_done — ready for executor
|
|
251
269
|
```
|
|
270
|
+
Then **verify the write** (read back the `Status:` line).
|
|
252
271
|
|
|
253
272
|
8. **Report:** task IDs, dependency graph, recovery/superseded tasks, any
|
|
254
273
|
`needs_breakdown` tasks + reason.
|
|
@@ -444,6 +463,7 @@ already on disk (task files hold the full logs, git holds the code). Do all four
|
|
|
444
463
|
|
|
445
464
|
2. **Update the run cursor** — rewrite `docs/AI_HANDOFF/RUN.md` with `Phase: I3`,
|
|
446
465
|
`Cursor: wave <N> done`, `Next: wave <N+1>` (or `I4` if that was the last wave).
|
|
466
|
+
Then **verify the write** (read back the `Phase:`/`Cursor:` lines).
|
|
447
467
|
|
|
448
468
|
3. **Collapse the wave in working memory.** From this point on, refer to the finished wave
|
|
449
469
|
only by its one-line-per-task summary (`TASK-xxx PASS <files>`). Do not re-read the task
|
|
@@ -469,6 +489,7 @@ logs used to cost. That difference is what makes a multi-wave cycle finish in on
|
|
|
469
489
|
- PASS tasks → `pending_review`
|
|
470
490
|
- FAIL tasks → `blocked`
|
|
471
491
|
- EXECUTOR_MODEL missing → `needs_executor_report`
|
|
492
|
+
Then **verify the write** (read back the updated status column).
|
|
472
493
|
|
|
473
494
|
2. Verify cleanup:
|
|
474
495
|
```bash
|
|
@@ -647,6 +668,8 @@ conventions so a future session needs no transcript to understand the cycle:
|
|
|
647
668
|
summary in `HISTORY.md` and remove it.
|
|
648
669
|
3. Reset `ACTIVE.md` to its empty template; clear `INDEX.md` rows to a fresh header;
|
|
649
670
|
remove `tasks/TASK-*.md`; clear `PLAN.md`/`SPEC.md` (templates stay).
|
|
671
|
+
Then **verify the write** on each reset file (read back the empty header /
|
|
672
|
+
template marker).
|
|
650
673
|
4. Commit the docs + archive changes:
|
|
651
674
|
`git add -A && git commit -m "handoff: finalize + archive cycle <ID>"`
|
|
652
675
|
|
|
@@ -654,6 +677,8 @@ conventions so a future session needs no transcript to understand the cycle:
|
|
|
654
677
|
|
|
655
678
|
Set `Phase: done` in `docs/AI_HANDOFF/RUN.md`, disarm any watchdog job armed at the
|
|
656
679
|
start, and emit the Final Report below.
|
|
680
|
+
Then **verify the write** (read back `Phase: done`) — a lost cursor write
|
|
681
|
+
leaves the next session resuming a finished run.
|
|
657
682
|
|
|
658
683
|
---
|
|
659
684
|
|
|
@@ -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
|
}
|