@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.
@@ -740,10 +740,22 @@ function workspaceFingerprint(cwd) {
740
740
  }
741
741
  }
742
742
 
743
- // Verification currency excludes UKit's mutable receipts/router state, not source files.
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 = ['.', ':(exclude).ukit/storage/**', ':(exclude).claude/ukit/skill-router-state.json'];
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
- instruction = `Verification verdict is stale (head moved since ${sha}). Re-run the routed verification at current head.`;
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
- # Auto-compact must trigger BEFORE context-hardcap-gate.sh starts blocking tool
128
- # calls (compact.hardCapTokens, default 500000). Rendered at install time from the
129
- # same derived number as env.CLAUDE_CODE_AUTO_COMPACT_WINDOW in
130
- # template_project/.claude/settings.json (70% of hardCapTokens), so omp and Claude Code
131
- # cannot drift apart and retuning both is a single edit to
132
- # template_project/ukit/storage/config.json.
133
- #
134
- # Key VERIFIED against omp v17.4.2 (2026-08-22): `compaction.thresholdTokens` is a
135
- # fixed token limit for context maintenance and overrides the percentage threshold
136
- # when set; omp's own default is -1 ("use compaction.thresholdPercent"). The key this
137
- # file shipped with in 2.2.1 — `compact.autoCompactWindow` — was a plan-time guess and
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
- thresholdTokens: {{compact.autoCompactWindow}}
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