@ngockhoale/ukit 2.7.6 → 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 +105 -0
- package/package.json +1 -1
- package/scripts/install/sync-installed-mirror.mjs +250 -0
- package/scripts/perf/audit-perf.mjs +287 -36
- package/scripts/perf/diff-perf-findings.mjs +136 -0
- package/scripts/perf/perf-findings.json +260 -206
- package/scripts/perf/perf-measure.md +271 -0
- package/src/context/detectProjectContext.js +5 -0
- package/src/core/codeintel/invalidation.js +4 -0
- package/src/core/fileOps.js +40 -117
- package/src/core/hookChainDoctor.js +65 -2
- package/src/core/memory/store.js +22 -1
- package/src/core/taskBudgetValidator.js +9 -6
- package/src/core/unattendedDoctor.js +8 -1
- 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/auto-prune-bash.sh +19 -0
- package/templates/.claude/hooks/context-hardcap-gate.sh +4 -1
- package/templates/.claude/hooks/handoff-model-guard.sh +22 -11
- package/templates/.claude/hooks/reinject-context.sh +22 -0
- package/templates/.claude/hooks/reset-compact-pressure.sh +29 -0
- package/templates/.claude/hooks/session-episode.sh +20 -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/task-budget-validator.mjs +6 -2
- 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 +156 -24
- package/templates/.claude/ukit/runtime/hook-payload-store.mjs +57 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +84 -12
- package/templates/.claude/ukit/runtime/hook-telemetry.sh +50 -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
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
// DEFAULT_CRITERIA — frozen thresholds used by `validateTaskFile` by default.
|
|
9
9
|
// VERIFICATION_MINUTE_TABLE — frozen literal table for `yarn test:release-core` and
|
|
10
10
|
// `node scripts/release/verify-release.mjs`. `yarn vitest run`
|
|
11
|
-
//
|
|
12
|
-
//
|
|
11
|
+
// and the `yarn test` alias (`package.json`:
|
|
12
|
+
// `"test": "vitest run"`) are NOT in the table — their cost
|
|
13
|
+
// is computed dynamically (0.5 per file argument), see
|
|
14
|
+
// `estimateVerificationMinutes`.
|
|
13
15
|
// estimateVerificationMinutes — sums per-command minutes over a verification list.
|
|
14
16
|
// validateTaskFile — parses a TASK-xxx.md and returns
|
|
15
17
|
// `{ verdict, reasons: [{ criterion, detail }] }`.
|
|
@@ -39,8 +41,9 @@ export const VERIFICATION_MINUTE_TABLE = Object.freeze({
|
|
|
39
41
|
'node scripts/release/verify-release.mjs': 2,
|
|
40
42
|
});
|
|
41
43
|
|
|
42
|
-
// Per-command minute estimate. `yarn vitest run`
|
|
43
|
-
//
|
|
44
|
+
// Per-command minute estimate. `yarn vitest [run]` and the `yarn test` alias (the repo's
|
|
45
|
+
// dominant verification form) → 0.5 per FILE argument (per non-flag token after the runner
|
|
46
|
+
// word); anything else falls back to 1 flat.
|
|
44
47
|
export function estimateVerificationMinutes(commands) {
|
|
45
48
|
if (!Array.isArray(commands)) return 0;
|
|
46
49
|
let total = 0;
|
|
@@ -51,8 +54,8 @@ export function estimateVerificationMinutes(commands) {
|
|
|
51
54
|
total += VERIFICATION_MINUTE_TABLE[cmd];
|
|
52
55
|
continue;
|
|
53
56
|
}
|
|
54
|
-
if (/^yarn\s+vitest(\s+run)
|
|
55
|
-
// 0.5 minutes per non-flag argument after `run
|
|
57
|
+
if (/^yarn\s+(?:vitest(\s+run)?|test)(?=\s|$)/.test(cmd)) {
|
|
58
|
+
// 0.5 minutes per non-flag argument after the runner word (`run`, or `yarn test` itself).
|
|
56
59
|
const tokens = cmd.split(/\s+/);
|
|
57
60
|
const runIdx = tokens.indexOf('run');
|
|
58
61
|
const tail = runIdx >= 0 ? tokens.slice(runIdx + 1) : tokens.slice(2);
|
|
@@ -16,6 +16,13 @@ const INSTALL_REPAIR = 'Run ukit install';
|
|
|
16
16
|
const OMP_CONFIG_TIMEOUT_MS = 5000;
|
|
17
17
|
const COMPLETION_LOOP_HEADER = '## Unattended Completion Loop';
|
|
18
18
|
|
|
19
|
+
// B3 / FR-003 — the shipped template carries `{{token}}` placeholders that `ukit install`
|
|
20
|
+
// renders. `parse()` must not see them raw: `{{compact.autoCompactWindow}}` reads as a flow
|
|
21
|
+
// mapping in KEY position, and the `yaml` lib then warns "Keys with collection values will be
|
|
22
|
+
// stringified" on every doctor run. Same neutralization `tests/consistency/ompAgentParity.test.js`
|
|
23
|
+
// already applies; deliberately generic (no key-specific case) so a new placeholder is covered.
|
|
24
|
+
const TEMPLATE_PLACEHOLDER_PATTERN = /\{\{[^}]+\}\}/g;
|
|
25
|
+
|
|
19
26
|
async function readOmpConfig(projectRoot) {
|
|
20
27
|
const configPath = path.join(projectRoot, '.omp', 'config.yml');
|
|
21
28
|
try {
|
|
@@ -51,7 +58,7 @@ function execOmpConfigGet(projectRoot, ompPath) {
|
|
|
51
58
|
async function readTemplateDenyMatches() {
|
|
52
59
|
try {
|
|
53
60
|
const text = await fs.readFile(path.join(PACKAGE_ROOT, 'templates', '.omp', 'config.yml'), 'utf8');
|
|
54
|
-
const parsed = parse(text);
|
|
61
|
+
const parsed = parse(text.replace(TEMPLATE_PLACEHOLDER_PATTERN, '0'));
|
|
55
62
|
const patterns = parsed?.bash?.patterns;
|
|
56
63
|
if (!Array.isArray(patterns)) return [];
|
|
57
64
|
return patterns
|
|
@@ -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`.
|
|
@@ -11,6 +11,25 @@ if [ ! -f "$SETTINGS_LOCAL" ] || [ ! -f "$USAGE_FILE" ]; then
|
|
|
11
11
|
exit 0
|
|
12
12
|
fi
|
|
13
13
|
|
|
14
|
+
# O1 (TASK-009): armed AFTER the missing-state short-circuit above, so the
|
|
15
|
+
# nothing-to-prune path stays as free as it is today and only real work is measured.
|
|
16
|
+
# This hook stages no stdin (SPEC §14), so it owns its own EXIT trap: the trap
|
|
17
|
+
# returns the status it captured and the always-exit-0 posture is unchanged.
|
|
18
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
19
|
+
# shellcheck source=/dev/null
|
|
20
|
+
source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
|
|
21
|
+
if [ "${UKIT_TEL_ARMED:-}" = "1" ]; then
|
|
22
|
+
ukit_hook_telemetry_arm
|
|
23
|
+
__ukit_tel_finish() {
|
|
24
|
+
local __ukit_tel_rc=$?
|
|
25
|
+
if [ "${UKIT_TEL_ARMED:-}" = "1" ] && command -v ukit_hook_telemetry_finish >/dev/null 2>&1; then
|
|
26
|
+
UKIT_TEL_RC="$__ukit_tel_rc" UKIT_TEL_EVENT="SessionStart" ukit_hook_telemetry_finish
|
|
27
|
+
fi
|
|
28
|
+
return "$__ukit_tel_rc"
|
|
29
|
+
}
|
|
30
|
+
trap __ukit_tel_finish EXIT
|
|
31
|
+
fi
|
|
32
|
+
|
|
14
33
|
NOW_UTC=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
15
34
|
|
|
16
35
|
node -e '
|
|
@@ -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
|
}
|
|
@@ -9,6 +9,28 @@ fi
|
|
|
9
9
|
HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
10
10
|
SCRIPT_PATH="$HOOK_DIR/../ukit/runtime/reinject-context.mjs"
|
|
11
11
|
|
|
12
|
+
# O1 (TASK-009): this hook stages no stdin (SPEC §14), so the shared cleanup path in
|
|
13
|
+
# hook-input.sh never runs for it and nothing here would ever be measured. Arm the
|
|
14
|
+
# no-staging start marker and finish it from this hook's own EXIT trap: arming costs
|
|
15
|
+
# one mktemp and reads no clock, and the single bounded `--finish` child is the
|
|
16
|
+
# telemetry process rather than a hook work step. A missing runtime (pre-install
|
|
17
|
+
# tree) leaves the flag unset — exactly as unmeasured as today — and the trap returns
|
|
18
|
+
# the status it captured, so the `exit $?` below still reaches the host unchanged.
|
|
19
|
+
# shellcheck source=/dev/null
|
|
20
|
+
source "$HOOK_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
|
|
21
|
+
if [ "${UKIT_TEL_ARMED:-}" = "1" ]; then
|
|
22
|
+
ukit_hook_telemetry_arm
|
|
23
|
+
__ukit_tel_finish() {
|
|
24
|
+
local __ukit_tel_rc=$?
|
|
25
|
+
if [ "${UKIT_TEL_ARMED:-}" = "1" ] && command -v ukit_hook_telemetry_finish >/dev/null 2>&1; then
|
|
26
|
+
# The event travels per call — no staged envelope exists to carry it.
|
|
27
|
+
UKIT_TEL_RC="$__ukit_tel_rc" UKIT_TEL_EVENT="PreCompact" ukit_hook_telemetry_finish
|
|
28
|
+
fi
|
|
29
|
+
return "$__ukit_tel_rc"
|
|
30
|
+
}
|
|
31
|
+
trap __ukit_tel_finish EXIT
|
|
32
|
+
fi
|
|
33
|
+
|
|
12
34
|
if [ -f "$SCRIPT_PATH" ]; then
|
|
13
35
|
UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT_PATH"
|
|
14
36
|
exit $?
|
|
@@ -24,6 +24,25 @@ if [ ! -f "$PRESSURE_FILE" ]; then
|
|
|
24
24
|
exit 0
|
|
25
25
|
fi
|
|
26
26
|
|
|
27
|
+
# O1 (TASK-009): armed AFTER the no-pressure-file short-circuit above, so the common
|
|
28
|
+
# "nothing to reset" start stays unmeasured while real resets get timed. This hook
|
|
29
|
+
# stages no stdin (SPEC §14), so it owns its EXIT trap; the trap restores the captured
|
|
30
|
+
# status and the always-exit-0 posture below is unchanged.
|
|
31
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
32
|
+
# shellcheck source=/dev/null
|
|
33
|
+
source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
|
|
34
|
+
if [ "${UKIT_TEL_ARMED:-}" = "1" ]; then
|
|
35
|
+
ukit_hook_telemetry_arm
|
|
36
|
+
__ukit_tel_finish() {
|
|
37
|
+
local __ukit_tel_rc=$?
|
|
38
|
+
if [ "${UKIT_TEL_ARMED:-}" = "1" ] && command -v ukit_hook_telemetry_finish >/dev/null 2>&1; then
|
|
39
|
+
UKIT_TEL_RC="$__ukit_tel_rc" UKIT_TEL_EVENT="SessionStart" ukit_hook_telemetry_finish
|
|
40
|
+
fi
|
|
41
|
+
return "$__ukit_tel_rc"
|
|
42
|
+
}
|
|
43
|
+
trap __ukit_tel_finish EXIT
|
|
44
|
+
fi
|
|
45
|
+
|
|
27
46
|
# The lock directory lives beside the state file; its parent exists because the file does.
|
|
28
47
|
node -e '
|
|
29
48
|
// Deadline must exceed the bounded lock wait in this block (maxWaitMs = 5000) so the
|
|
@@ -132,6 +151,13 @@ process.stdin.on("end", () => {
|
|
|
132
151
|
}
|
|
133
152
|
})();
|
|
134
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() : "";
|
|
135
161
|
const pressurePath = process.argv[1];
|
|
136
162
|
|
|
137
163
|
withLock(`${pressurePath}.lock`, async () => {
|
|
@@ -160,6 +186,9 @@ process.stdin.on("end", () => {
|
|
|
160
186
|
|
|
161
187
|
const sessions = { ...raw.sessions };
|
|
162
188
|
delete sessions[sessionId];
|
|
189
|
+
if (transcriptKey && transcriptKey !== sessionId) {
|
|
190
|
+
delete sessions[transcriptKey];
|
|
191
|
+
}
|
|
163
192
|
const newest = Object.entries(sessions)
|
|
164
193
|
.sort(([, a], [, b]) => (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0))[0]?.[1] ?? null;
|
|
165
194
|
const tempPath = `${pressurePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
@@ -37,6 +37,26 @@ if [ "$__ukit_ep_gate" != "1" ]; then
|
|
|
37
37
|
fi
|
|
38
38
|
unset __ukit_ep_gate
|
|
39
39
|
|
|
40
|
+
# O1 (TASK-009): armed only past the gate above, so a gate-off SessionEnd stays a pure
|
|
41
|
+
# fast exit. This hook stages no stdin (SPEC §14) and owns its EXIT trap; the trap
|
|
42
|
+
# returns the captured status, so the always-exit-0 contract is unchanged. The row is
|
|
43
|
+
# not session-attributed: the staged payload is reaped below before this trap runs, and
|
|
44
|
+
# re-deriving the id in shell would mean another spawn on the teardown path.
|
|
45
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
46
|
+
# shellcheck source=/dev/null
|
|
47
|
+
source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
|
|
48
|
+
if [ "${UKIT_TEL_ARMED:-}" = "1" ]; then
|
|
49
|
+
ukit_hook_telemetry_arm
|
|
50
|
+
__ukit_tel_finish() {
|
|
51
|
+
local __ukit_tel_rc=$?
|
|
52
|
+
if [ "${UKIT_TEL_ARMED:-}" = "1" ] && command -v ukit_hook_telemetry_finish >/dev/null 2>&1; then
|
|
53
|
+
UKIT_TEL_RC="$__ukit_tel_rc" UKIT_TEL_EVENT="SessionEnd" ukit_hook_telemetry_finish
|
|
54
|
+
fi
|
|
55
|
+
return "$__ukit_tel_rc"
|
|
56
|
+
}
|
|
57
|
+
trap __ukit_tel_finish EXIT
|
|
58
|
+
fi
|
|
59
|
+
|
|
40
60
|
# Bounded stdin read (existing hook style): cap +1 byte in background so a
|
|
41
61
|
# producer that never closes the pipe cannot park the session teardown.
|
|
42
62
|
UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-episode-in.XXXXXX")" || exit 0
|
|
@@ -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 [
|