@ngockhoale/ukit 3.4.32 → 3.4.33
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 +7 -0
- package/manifests/documentation.yaml +11 -0
- package/package.json +1 -1
- package/src/core/diffPlan.js +87 -8
- package/src/core/executionContracts.js +8 -2
- package/src/core/metadata.js +5 -0
- package/src/core/ompConfigMerge.js +60 -0
- package/src/core/ompProjectConfig.js +462 -0
- package/src/core/runInstallPipeline.js +25 -1
- package/src/core/uninstall.js +28 -1
- package/src/render/buildVariables.js +4 -0
- package/template_project/.claude/hooks/handoff-model-guard.sh +37 -2
- package/template_project/.claude/hooks/record-execution.mjs +9 -0
- package/template_project/.claude/hooks/skill-router.sh +67 -7
- package/template_project/.claude/ukit/index/route-resolver.mjs +19 -5
- package/template_project/.claude/ukit/index/route-task.mjs +97 -1
- package/template_project/.claude/ukit/runtime/context-budget-policy.mjs +309 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +28 -5
- package/template_project/.claude/ukit/runtime/plan-dispatch.mjs +74 -0
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +7 -0
- package/template_project/.omp/config.yml +30 -14
|
@@ -740,10 +740,22 @@ function workspaceFingerprint(cwd) {
|
|
|
740
740
|
}
|
|
741
741
|
}
|
|
742
742
|
|
|
743
|
-
// Verification currency excludes UKit's mutable receipts/router state
|
|
743
|
+
// Verification currency excludes UKit's mutable receipts/router state and the living
|
|
744
|
+
// bookkeeping docs (never test inputs; the project rules REQUIRE agents to update them after
|
|
745
|
+
// work, so counting them made every wrap-up edit invalidate a still-valid verdict). Source,
|
|
746
|
+
// tests and every other doc stay in the fingerprint.
|
|
747
|
+
const VERIFICATION_FINGERPRINT_EXCLUDES = [
|
|
748
|
+
':(exclude).ukit/storage/**',
|
|
749
|
+
':(exclude).claude/ukit/skill-router-state.json',
|
|
750
|
+
':(exclude)docs/WORKLOG.md',
|
|
751
|
+
':(exclude)docs/STATUS.md',
|
|
752
|
+
':(exclude)docs/TASKS.md',
|
|
753
|
+
':(exclude)docs/MEMORY.md',
|
|
754
|
+
];
|
|
755
|
+
|
|
744
756
|
function verificationDirtyFingerprint(cwd) {
|
|
745
757
|
try {
|
|
746
|
-
const paths = ['.',
|
|
758
|
+
const paths = ['.', ...VERIFICATION_FINGERPRINT_EXCLUDES];
|
|
747
759
|
const options = { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500, maxBuffer: 16 * 1024 * 1024 };
|
|
748
760
|
const diff = execFileSync('git', ['diff', 'HEAD', '--no-ext-diff', '--', ...paths], options);
|
|
749
761
|
const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '-z', '--', ...paths], options);
|
|
@@ -824,6 +836,8 @@ function gitPatchId(diffText) {
|
|
|
824
836
|
// SPEC §2.2 — is this verdict still valid at the current head?
|
|
825
837
|
// Returns { status, current, headSha } where status is one of:
|
|
826
838
|
// 'current' | 'stale' | 'stale-recoverable' | 'untracked' (no git / no sha data).
|
|
839
|
+
// `reason` names WHY a stale verdict is stale: 'workspace-changed' (same HEAD, tracked/untracked
|
|
840
|
+
// content differs from what was verified) vs 'head-moved'.
|
|
827
841
|
// Cost: rev-parse plus a bounded diff/untracked snapshot for new verdicts;
|
|
828
842
|
// when head moved, the existing diff/patch-id comparison also applies.
|
|
829
843
|
export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
|
|
@@ -840,7 +854,7 @@ export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
|
|
|
840
854
|
}
|
|
841
855
|
const currentHead = gitOutput(['rev-parse', 'HEAD'], cwd);
|
|
842
856
|
if (verdict.headSha && Object.hasOwn(verdict, 'dirtyFp') && (!verdict.dirtyFp || verificationDirtyFingerprint(cwd) !== verdict.dirtyFp)) {
|
|
843
|
-
return { status: 'stale', current: false, headSha: currentHead };
|
|
857
|
+
return { status: 'stale', current: false, headSha: currentHead, reason: 'workspace-changed' };
|
|
844
858
|
}
|
|
845
859
|
if (!currentHead) {
|
|
846
860
|
// No git repo (or git unavailable): currency cannot be proven either way —
|
|
@@ -855,7 +869,7 @@ export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
|
|
|
855
869
|
}
|
|
856
870
|
// Head moved. With no base/patch-id recorded there is nothing to compare — stale.
|
|
857
871
|
if (!verdict.baseSha || !verdict.patchId) {
|
|
858
|
-
return { status: 'stale', current: false, headSha: currentHead };
|
|
872
|
+
return { status: 'stale', current: false, headSha: currentHead, reason: 'head-moved' };
|
|
859
873
|
}
|
|
860
874
|
const diff = gitOutput(['diff', `${verdict.baseSha}..${currentHead}`], cwd);
|
|
861
875
|
const patchId = gitPatchId(diff);
|
|
@@ -3073,7 +3087,10 @@ function recoveryInstruction(missingEvidence, ledger = {}, routeSummary = {}, {
|
|
|
3073
3087
|
const currency = verdictCurrent(ledger.verdict, { cwd });
|
|
3074
3088
|
if (currency.current !== true) {
|
|
3075
3089
|
const sha = ledger.verdict.headSha ? String(ledger.verdict.headSha).slice(0, 12) : 'unknown';
|
|
3076
|
-
|
|
3090
|
+
const cause = currency.reason === 'workspace-changed'
|
|
3091
|
+
? `source/test files changed since it ran at ${sha} (HEAD unchanged or not the cause)`
|
|
3092
|
+
: `head moved since ${sha}`;
|
|
3093
|
+
instruction = `Verification verdict is stale (${cause}). Re-run the routed verification at current head.`;
|
|
3077
3094
|
}
|
|
3078
3095
|
}
|
|
3079
3096
|
}
|
|
@@ -3264,6 +3281,12 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
|
|
|
3264
3281
|
) {
|
|
3265
3282
|
return { continue: false, notify: false, complete: true, missingEvidence: [] };
|
|
3266
3283
|
}
|
|
3284
|
+
// A status/result lookup (READ_ONLY + statusReport) orders no change: there is no write or
|
|
3285
|
+
// verification debt to enforce, and exiting here must not hide a mutation contract —
|
|
3286
|
+
// statusReport is only ever stamped when the request carried no mutation vocabulary.
|
|
3287
|
+
if (routeSummary.executionContract?.taskKind === 'READ_ONLY' && routeSummary.executionContract.statusReport === true) {
|
|
3288
|
+
return { continue: false, notify: false, complete: true, missingEvidence: [] };
|
|
3289
|
+
}
|
|
3267
3290
|
if (evidence.length === 0) {
|
|
3268
3291
|
// A route with no completion evidence has no actionable block reason — blocking would
|
|
3269
3292
|
// loop unboundedly. Release loud instead of ending indistinguishable from a stall.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Thin IO over the stored decomposition plan: dependency-gated dispatch + completion of
|
|
2
|
+
// subtasks. Reuses the resumable-run record (no second store) and is called from the
|
|
3
|
+
// existing Agent|Task PreToolUse/PostToolUse hooks. Fail-open on IO trouble, fail-closed
|
|
4
|
+
// on an explicit dependency/duplicate violation.
|
|
5
|
+
import {
|
|
6
|
+
readResumableRun, writeResumableRun,
|
|
7
|
+
} from './resumable-run.mjs';
|
|
8
|
+
import { evaluateContextBudget, SUBTASK_MARKER_RE, checkDispatch, applySubtaskStatus } from './context-budget-policy.mjs';
|
|
9
|
+
|
|
10
|
+
const ROLE_BY_AGENT = { 'feature-implementer': 'implement-subagent', 'bug-debugger': 'implement-subagent', 'code-reviewer': 'review-subagent', 'handoff-planner': 'subagent', 'ukit-small-task-maintainer': 'simple-subagent', 'ukit-vision-analyst': 'simple-subagent' };
|
|
11
|
+
|
|
12
|
+
/** Advisory soft-budget check of a subtask's scoped context for the dispatched agent role. Never blocks. */
|
|
13
|
+
export function subtaskBudgetAdvice(task, toolInput) {
|
|
14
|
+
const agent = String(toolInput?.subagent_type ?? toolInput?.agent ?? '').toLowerCase();
|
|
15
|
+
const role = ROLE_BY_AGENT[agent] ?? 'subagent';
|
|
16
|
+
const verdict = evaluateContextBudget({ role, promptTokens: task?.scopeTokens });
|
|
17
|
+
return { role, ...verdict };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function subtaskIdFromInput(toolInput) {
|
|
21
|
+
const input = toolInput && typeof toolInput === 'object' ? toolInput : {};
|
|
22
|
+
const texts = [input.prompt, input.description, input.task, ...(Array.isArray(input.tasks) ? input.tasks.flatMap((t) => [t?.task, t?.prompt, t?.description]) : [])];
|
|
23
|
+
for (const t of texts) {
|
|
24
|
+
const m = typeof t === 'string' ? SUBTASK_MARKER_RE.exec(t) : null;
|
|
25
|
+
if (m) return m[1];
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const parentOf = (subtaskId) => subtaskId.replace(/\.(?:\d+|integrate)$/, '');
|
|
31
|
+
|
|
32
|
+
async function load(projectRoot, subtaskId) {
|
|
33
|
+
const taskId = parentOf(subtaskId);
|
|
34
|
+
const read = await readResumableRun(projectRoot, { taskId });
|
|
35
|
+
if (!read.record) return null;
|
|
36
|
+
const decisions = read.record.decisions ?? [];
|
|
37
|
+
const index = decisions.findIndex((d) => d && d.type === 'decomposition');
|
|
38
|
+
return index < 0 ? null : { record: read.record, decisions, index };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function save(projectRoot, loaded, plan) {
|
|
42
|
+
const decisions = loaded.decisions.slice();
|
|
43
|
+
decisions[loaded.index] = { type: 'decomposition', ...plan };
|
|
44
|
+
return writeResumableRun(projectRoot, { ...loaded.record, decisions, updatedAt: Date.now() });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** PreToolUse: `{ok:true}` (marks running) or `{ok:false, reason, missing?}`. */
|
|
48
|
+
export async function dispatchSubtask(projectRoot, subtaskId, toolInput = null) {
|
|
49
|
+
try {
|
|
50
|
+
const loaded = await load(projectRoot, subtaskId);
|
|
51
|
+
if (!loaded) return { ok: true, tracked: false };
|
|
52
|
+
const plan = loaded.decisions[loaded.index];
|
|
53
|
+
const verdict = checkDispatch(plan, subtaskId);
|
|
54
|
+
if (!verdict.ok) return verdict;
|
|
55
|
+
const saved = await save(projectRoot, loaded, applySubtaskStatus(plan, subtaskId, 'running'));
|
|
56
|
+
return { ok: true, tracked: saved.ok === true, budget: subtaskBudgetAdvice(verdict.task, toolInput) };
|
|
57
|
+
} catch {
|
|
58
|
+
return { ok: true, tracked: false };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** PostToolUse: done on success, back to pending (retryable) on failure. */
|
|
63
|
+
export async function completeSubtask(projectRoot, subtaskId, { success }) {
|
|
64
|
+
try {
|
|
65
|
+
const loaded = await load(projectRoot, subtaskId);
|
|
66
|
+
if (!loaded) return { ok: false, reason: 'no-plan' };
|
|
67
|
+
const plan = loaded.decisions[loaded.index];
|
|
68
|
+
if (!plan.tasks?.some((t) => t.id === subtaskId)) return { ok: false, reason: 'unknown-subtask' };
|
|
69
|
+
const saved = await save(projectRoot, loaded, applySubtaskStatus(plan, subtaskId, success ? 'done' : 'pending'));
|
|
70
|
+
return { ok: saved.ok === true };
|
|
71
|
+
} catch {
|
|
72
|
+
return { ok: false, reason: 'io' };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -115,6 +115,13 @@ async function buildResumableRunLines(projectRoot, state, config) {
|
|
|
115
115
|
if (phase) lines.push(`- Resumed run phase: ${phase}`);
|
|
116
116
|
const nextAction = typeof record.nextAction === 'string' ? record.nextAction.trim() : '';
|
|
117
117
|
if (nextAction) lines.push(`- Resumed next action: ${nextAction}`);
|
|
118
|
+
const plan = (Array.isArray(record.decisions) ? record.decisions : []).find((d) => d && d.type === 'decomposition');
|
|
119
|
+
if (plan && Array.isArray(plan.tasks)) {
|
|
120
|
+
lines.push(`- Resumed decomposition plan (${plan.tasks.length} steps, parent acceptance preserved): dispatch scoped subtasks, do not replan unless sources change`);
|
|
121
|
+
for (const t of plan.tasks.slice(0, 6)) {
|
|
122
|
+
lines.push(` - ${t.id}: ${(t.files ?? []).join(', ') || 'integration'} deps=[${(t.deps ?? []).join(',')}]`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
118
125
|
for (const hypothesis of (Array.isArray(record.hypotheses) ? record.hypotheses : []).slice(0, 3)) {
|
|
119
126
|
const text = String(hypothesis ?? '').trim();
|
|
120
127
|
if (text) lines.push(`- Resumed hypothesis: ${text}`);
|
|
@@ -124,18 +124,34 @@ bash:
|
|
|
124
124
|
memory:
|
|
125
125
|
backend: off
|
|
126
126
|
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
# omp rejects it outright (`omp config get compact.autoCompactWindow` → "Unknown
|
|
139
|
-
# setting"), so auto-compact never moved off omp's default. See PLAN.md §3 D13.
|
|
127
|
+
# omp-side auto-compaction, project-scoped (omp reads <project>/.omp/config.yml and it
|
|
128
|
+
# takes precedence over ~/.omp/agent/config.yml, which UKit never touches).
|
|
129
|
+
# Keys VERIFIED against omp v18.8.9 (`omp config get compaction.<key>`):
|
|
130
|
+
# thresholdTokens is a fixed token limit that overrides thresholdPercent when > 0
|
|
131
|
+
# (omp default -1).
|
|
132
|
+
# thresholdTokens = min(90000, Claude Code auto-compact window), so a small-context
|
|
133
|
+
# budget is never exceeded by the omp threshold. Starting default, not a hard limit:
|
|
134
|
+
# edit any value here and `ukit update` keeps it. The legacy shipped 350000 is migrated
|
|
135
|
+
# only when UKit's install ledger proves UKit wrote it; `ukit uninstall` reverts just
|
|
136
|
+
# UKit's own additions and keeps your edits. Auto-compact must still trigger BEFORE
|
|
137
|
+
# context-hardcap-gate blocks tool calls.
|
|
140
138
|
compaction:
|
|
141
|
-
|
|
139
|
+
enabled: true
|
|
140
|
+
thresholdTokens: {{omp.compactionThresholdTokens}}
|
|
141
|
+
midTurnEnabled: true
|
|
142
|
+
keepRecentTokens: 20000
|
|
143
|
+
|
|
144
|
+
# Per-agent native compaction thresholds (omp v18.8.9 `task.agentCompactionThresholdOverrides`,
|
|
145
|
+
# keyed by agent name, positive integer tokens). SOFT targets for scoped subagent context:
|
|
146
|
+
# omp compacts the agent's session when it grows past the value — this is NOT a request
|
|
147
|
+
# hard cap (one step can still jump past it). Per-key merge: any agent key you set stays;
|
|
148
|
+
# your other `task:` settings are never touched; `ukit uninstall` removes only these keys
|
|
149
|
+
# when they still carry the shipped value.
|
|
150
|
+
task:
|
|
151
|
+
agentCompactionThresholdOverrides:
|
|
152
|
+
feature-implementer: 80000
|
|
153
|
+
bug-debugger: 80000
|
|
154
|
+
code-reviewer: 80000
|
|
155
|
+
handoff-planner: 60000
|
|
156
|
+
ukit-small-task-maintainer: 40000
|
|
157
|
+
ukit-vision-analyst: 40000
|