@ngockhoale/ukit 3.0.2 → 3.0.4

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.
Files changed (109) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/bin/ukit +12 -4
  3. package/manifests/engineConformance.yaml +29 -0
  4. package/manifests/platform.full.yaml +13 -0
  5. package/package.json +2 -1
  6. package/scripts/audit/decision-coverage.mjs +295 -0
  7. package/scripts/bench/outline-savings.mjs +19 -4
  8. package/scripts/bench/parallel-agents.mjs +15 -4
  9. package/scripts/bench/runGold.mjs +22 -4
  10. package/scripts/bench/v3-ceremony.mjs +7 -1
  11. package/scripts/bug/triage.mjs +56 -17
  12. package/scripts/index/build-index.mjs +94 -28
  13. package/scripts/index/query-index.mjs +48 -14
  14. package/scripts/index/refresh-index.mjs +142 -62
  15. package/scripts/perf/audit-perf.mjs +8 -2
  16. package/scripts/skill/audit-skill.mjs +54 -25
  17. package/src/bug/triageBug.js +9 -6
  18. package/src/cli/adapters.js +6 -0
  19. package/src/cli/commands/code.js +7 -1
  20. package/src/cli/commands/indexArgs.js +4 -2
  21. package/src/cli/commands/indexTools.js +8 -1
  22. package/src/cli/commands/install.js +13 -0
  23. package/src/cli/commands/memory.js +10 -3
  24. package/src/cli/commands/status.js +17 -1
  25. package/src/cli/commands/update.js +7 -0
  26. package/src/context/detectProjectContext.js +3 -1
  27. package/src/core/codeintel/analogy.js +1 -1
  28. package/src/core/codeintel/diagnostics.js +9 -6
  29. package/src/core/codeintel/graph.js +14 -8
  30. package/src/core/codeintel/impact.js +0 -1
  31. package/src/core/codeintel/invalidation.js +5 -3
  32. package/src/core/codeintel/packet.js +11 -0
  33. package/src/core/codeintel/router.js +17 -3
  34. package/src/core/codeintel/semanticProvider.js +1 -1
  35. package/src/core/codeintel/summaries.js +11 -7
  36. package/src/core/compact/contextBudget.js +26 -12
  37. package/src/core/compact/index.js +15 -8
  38. package/src/core/docContracts.js +10 -2
  39. package/src/core/experiments/deliberation.js +321 -0
  40. package/src/core/experiments/dynamicWorkflow.js +492 -0
  41. package/src/core/fileOps.js +8 -1
  42. package/src/core/gatewayProbe.js +29 -1
  43. package/src/core/gatewayResilienceEnv.js +44 -3
  44. package/src/core/handoffDocValidator.js +3 -1
  45. package/src/core/hookChainDoctor.js +16 -1
  46. package/src/core/memory/deltaOverlays.js +448 -0
  47. package/src/core/memory/learningCandidates.js +302 -0
  48. package/src/core/memory/migrate.js +59 -32
  49. package/src/core/memory/recordStore.js +24 -1
  50. package/src/core/memory/store.js +44 -8
  51. package/src/core/memory/storeV2.js +48 -33
  52. package/src/core/memory/userMemory.js +11 -10
  53. package/src/core/output/index.js +15 -10
  54. package/src/core/permissionPolicy.js +8 -0
  55. package/src/core/runtimeConfig.js +224 -4
  56. package/src/core/sensitiveValueScanner.js +10 -2
  57. package/src/core/taskBudgetValidator.js +12 -17
  58. package/src/core/taskProgressGuard.js +59 -9
  59. package/src/core/unattendedDoctor.js +5 -2
  60. package/src/core/uninstall.js +37 -8
  61. package/src/decision/client.js +371 -0
  62. package/src/decision/lease.js +198 -0
  63. package/src/decision/preflight.js +492 -0
  64. package/src/decision/protocol.js +308 -0
  65. package/src/decision/registry.js +384 -0
  66. package/src/decision/shadow.js +281 -0
  67. package/src/decision/statePacket.js +165 -0
  68. package/src/diagnostics/failurePatterns.js +2 -1
  69. package/src/diagnostics/ledgerFiles.js +3 -1
  70. package/src/diagnostics/routeOutcomes.js +1 -29
  71. package/src/index/buildIndex.js +23 -11
  72. package/src/index/impactContext.js +21 -2
  73. package/src/index/importResolution.js +13 -7
  74. package/src/index/queryIndex.js +11 -5
  75. package/src/index/resolveContext.js +16 -6
  76. package/src/index/taskRouting.js +63 -2
  77. package/src/index/verificationPlan.js +12 -1
  78. package/src/learning/patternProposals.js +6 -0
  79. package/src/render/renderTemplate.js +1 -1
  80. package/src/skill/auditSkill.js +3 -1
  81. package/src/stack/detectStack.js +3 -1
  82. package/template_project/.claude/agents/handoff-planner.md +2 -5
  83. package/template_project/.claude/hooks/auto-allow-bash.sh +5 -0
  84. package/template_project/.claude/hooks/block-dangerous.mjs +11 -4
  85. package/template_project/.claude/hooks/context-hardcap-gate.sh +10 -1
  86. package/template_project/.claude/hooks/handoff-model-guard.sh +14 -4
  87. package/template_project/.claude/hooks/handoff-resume.sh +10 -1
  88. package/template_project/.claude/hooks/protect-files.sh +0 -1
  89. package/template_project/.claude/hooks/record-execution.mjs +13 -1
  90. package/template_project/.claude/hooks/sensitive-data-guard.mjs +71 -5
  91. package/template_project/.claude/hooks/session-episode.sh +9 -2
  92. package/template_project/.claude/skills/pdf-processing-pro/SKILL.md +1 -1
  93. package/template_project/.claude/ukit/index/handoff-doc-validator.mjs +3 -1
  94. package/template_project/.claude/ukit/index/lib/index-core.mjs +123 -39
  95. package/template_project/.claude/ukit/index/route-task.mjs +444 -0
  96. package/template_project/.claude/ukit/index/task-budget-validator.mjs +12 -16
  97. package/template_project/.claude/ukit/index/unic-decision.mjs +786 -0
  98. package/template_project/.claude/ukit/index/verify-context.mjs +11 -0
  99. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +116 -9
  100. package/template_project/.claude/ukit/runtime/project-important.mjs +9 -7
  101. package/template_project/.claude/ukit/runtime/reinject-context.mjs +48 -0
  102. package/template_project/.claude/ukit/runtime/resumable-run.mjs +596 -0
  103. package/template_project/.claude/ukit/runtime/sensitive-value-scanner.mjs +4 -7
  104. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +1 -1
  105. package/template_project/docs/AI_HANDOFF/PLAN.md +7 -7
  106. package/template_project/docs/AI_HANDOFF/RULES.md +1 -1
  107. package/template_project/ukit/storage/config.json +34 -1
  108. package/template_project/.claude/ukit/skill-router-state.json +0 -1
  109. package/template_project/.ukit/storage/cache/hook-latency/unknown.jsonl +0 -4
@@ -291,19 +291,22 @@ function buildBaseUrlDirs({ rootDir, compilerOptions }) {
291
291
  }
292
292
 
293
293
  async function resolveExtendedConfigPath(configDir, extendsValue) {
294
- const withExtension = extendsValue.endsWith('.json')
295
- ? extendsValue
296
- : `${extendsValue}.json`;
297
- if (withExtension.startsWith('.') || withExtension.startsWith('/')) {
294
+ if (extendsValue.startsWith('.') || extendsValue.startsWith('/')) {
295
+ const withExtension = extendsValue.endsWith('.json')
296
+ ? extendsValue
297
+ : `${extendsValue}.json`;
298
298
  return path.resolve(configDir, withExtension);
299
299
  }
300
300
 
301
- const parts = withExtension.split('/').filter(Boolean);
301
+ // Package specifiers split on the RAW value: appending `.json` first would
302
+ // turn a bare `extends: "acme-base"` into the package name `acme-base.json`,
303
+ // which never exists under node_modules.
304
+ const parts = extendsValue.split('/').filter(Boolean);
302
305
  if (parts.length === 0) {
303
306
  return null;
304
307
  }
305
308
 
306
- const packageName = withExtension.startsWith('@')
309
+ const packageName = extendsValue.startsWith('@')
307
310
  ? parts.slice(0, 2).join('/')
308
311
  : parts[0];
309
312
  const packageRest = parts.slice(packageName.startsWith('@') ? 2 : 1).join('/');
@@ -312,7 +315,10 @@ async function resolveExtendedConfigPath(configDir, extendsValue) {
312
315
  return null;
313
316
  }
314
317
 
315
- return path.join(packageRoot, packageRest || 'tsconfig.json');
318
+ const rest = packageRest && !packageRest.endsWith('.json')
319
+ ? `${packageRest}.json`
320
+ : packageRest;
321
+ return path.join(packageRoot, rest || 'tsconfig.json');
316
322
  }
317
323
 
318
324
  async function findNodeModulePackageRoot(startDir, packageName) {
@@ -24,7 +24,8 @@ function setBoundedCacheEntry(map, key, value, maxEntries = MAX_CACHE_ENTRIES) {
24
24
  }
25
25
 
26
26
  export async function queryCodeIndex({ rootDir = process.cwd(), query, limit = 5 } = {}) {
27
- if (!query || !query.trim()) {
27
+ const normalizedQuery = String(query ?? '').trim();
28
+ if (!normalizedQuery) {
28
29
  return [];
29
30
  }
30
31
  if (limit <= 0) {
@@ -32,7 +33,6 @@ export async function queryCodeIndex({ rootDir = process.cwd(), query, limit = 5
32
33
  }
33
34
 
34
35
  const absoluteRoot = path.resolve(rootDir);
35
- const normalizedQuery = String(query).trim();
36
36
  const queryDescriptor = buildSearchDescriptor(normalizedQuery, {
37
37
  expandVietnameseAliases: true,
38
38
  });
@@ -590,12 +590,12 @@ function buildResolvedImportGraphs(rootDir, imports, indexedFileSet, importAlias
590
590
  // ── Index V2: Analog Query ──
591
591
 
592
592
  export async function queryAnalog({ rootDir = process.cwd(), filePath, limit = 5 } = {}) {
593
- if (!filePath || !filePath.trim()) {
593
+ const normalizedFilePath = String(filePath ?? '').trim();
594
+ if (!normalizedFilePath) {
594
595
  return [];
595
596
  }
596
597
 
597
598
  const absoluteRoot = path.resolve(rootDir);
598
- const normalizedFilePath = String(filePath).trim();
599
599
  const analogCacheKey = JSON.stringify({
600
600
  rootDir: absoluteRoot,
601
601
  filePath: normalizedFilePath,
@@ -670,7 +670,13 @@ function freezeAnalogResults(results) {
670
670
  }
671
671
 
672
672
  function ensureArtifact(artifact) {
673
- return artifact && typeof artifact === 'object' ? artifact : { items: [] };
673
+ if (!artifact || typeof artifact !== 'object') {
674
+ return { items: [] };
675
+ }
676
+ // A schema-current artifact whose `items` is not an array (truncated or
677
+ // hand-corrupted JSON) must degrade to an empty item list — callers iterate
678
+ // `items` unconditionally and a non-array value crashes the query path.
679
+ return Array.isArray(artifact.items) ? artifact : { ...artifact, items: [] };
674
680
  }
675
681
 
676
682
  function escapeJsonString(value) {
@@ -33,10 +33,20 @@ const EXPANSION_SIGNALS = [
33
33
  'project',
34
34
  'repo',
35
35
  'workspace',
36
- 'all ',
37
- 'every ',
36
+ 'all',
37
+ 'every',
38
38
  ];
39
39
 
40
+ // Signals must match whole words: bare `includes` let 'score' fire 'core',
41
+ // 'author' fire 'auth', 'context' fire 'text', and 'install ' fire 'all '.
42
+ function escapeSignalRegExp(signal) {
43
+ return signal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
44
+ }
45
+
46
+ function containsSignalWord(lower, signals) {
47
+ return signals.some((signal) => new RegExp(`\\b${escapeSignalRegExp(signal)}\\b`).test(lower));
48
+ }
49
+
40
50
  export async function resolveContext({
41
51
  rootDir = process.cwd(),
42
52
  intent = '',
@@ -183,14 +193,14 @@ export function classifyTask(intent) {
183
193
  const lower = intent.toLowerCase();
184
194
 
185
195
  // Check risky first (highest priority)
186
- if (RISKY_SIGNALS.some((signal) => lower.includes(signal))) {
196
+ if (containsSignalWord(lower, RISKY_SIGNALS)) {
187
197
  return 'non-trivial';
188
198
  }
189
199
 
190
200
  // Check trivial
191
- if (TRIVIAL_SIGNALS.some((signal) => lower.includes(signal))) {
201
+ if (containsSignalWord(lower, TRIVIAL_SIGNALS)) {
192
202
  // But if intent also mentions multiple files or broad scope, upgrade
193
- if (lower.includes('all ') || lower.includes('every ') || lower.includes('multiple')) {
203
+ if (/\ball\b/.test(lower) || /\bevery\b/.test(lower) || /\bmultiple\b/.test(lower)) {
194
204
  return 'simple';
195
205
  }
196
206
  return 'trivial';
@@ -218,7 +228,7 @@ function shouldQueryIndex({
218
228
  return true;
219
229
  }
220
230
 
221
- return EXPANSION_SIGNALS.some((signal) => normalizedIntent.includes(signal));
231
+ return containsSignalWord(normalizedIntent, EXPANSION_SIGNALS);
222
232
  }
223
233
  function enforceContextBudget(result, maxFiles) {
224
234
  const prioritizedGroups = [
@@ -14,7 +14,8 @@ import {
14
14
  ROUTE_EFFORTS,
15
15
  resolveModelTier,
16
16
  } from '../core/executionContracts.js';
17
- import { resolveModelRoles, readMergedRuntimeConfig } from '../core/runtimeConfig.js';
17
+ import { resolveModelRoles, readMergedRuntimeConfig, resolveConfigStage } from '../core/runtimeConfig.js';
18
+ import { runShadowDecisions } from '../decision/shadow.js';
18
19
  import { resolvePlaybook } from '../core/userPlaybooks.js';
19
20
 
20
21
  // --- v3 route-side advisory blocks (docs/pstack/SPEC-playbook-todo.md, ------------------
@@ -378,6 +379,10 @@ function isDeliveryOnlyRequest({ signalText = '', scores = {}, targetFile = null
378
379
  && !targetFile;
379
380
  }
380
381
 
382
+
383
+ // @decision-point route.intent-kind.v1 — intent kind is a registered
384
+ // consequential decision (DECISION_REGISTRY); this deterministic mapping is
385
+ // its fallback policy implementation.
381
386
  // CONTRACTS.md "Intent vocabulary mapping": taskType informs mode priors, not
382
387
  // intent.kind; intentMode maps to kind as question/explanation → informational,
383
388
  // ship/deliver → delivery, code change → mutation, root-cause/diagnosis →
@@ -629,6 +634,9 @@ export async function deriveTaskRoute({
629
634
  autonomyLevel = 'balanced',
630
635
  runtimeConfig = null,
631
636
  homeDir = undefined,
637
+ // TASK-004 (FR-008/FR-009): decision-plane runtime seam — transport/env/
638
+ // hostCapabilities overrides for the shadow evaluator. Unused at stage off.
639
+ decisionPlaneOptions = null,
632
640
  } = {}) {
633
641
  const absoluteRoot = path.resolve(rootDir);
634
642
  // M01.1: additive route-schema stage. Absent config / absent key / unknown value all
@@ -793,6 +801,45 @@ export async function deriveTaskRoute({
793
801
  });
794
802
  const approachSelector = routeSummary?.approachSelector ?? null;
795
803
 
804
+ // TASK-004 (SPEC §5 FR-008/FR-009): decision-plane hook. Stage resolves via
805
+ // the shared resolver — absent/malformed → 'off' → zero cost, byte-identical
806
+ // route. shadow → redacted receipt only (deterministic policy authoritative);
807
+ // canary/default → validated preflight bundle attached. Any failure degrades
808
+ // to the deterministic baseline with one bounded diagnostic.
809
+ const decisionPlaneStage = resolveConfigStage(resolvedRuntimeConfig, 'decisionPlane.stage');
810
+ if (decisionPlaneStage !== 'off' && resolvedRuntimeConfig?.decisionPlane?.enabled !== false) {
811
+ try {
812
+ const shadow = await runShadowDecisions({
813
+ route: routeSummary,
814
+ config: resolvedRuntimeConfig,
815
+ transport: decisionPlaneOptions?.transport,
816
+ hostCapabilities: decisionPlaneOptions?.hostCapabilities,
817
+ env: decisionPlaneOptions?.env,
818
+ projectRoot: absoluteRoot,
819
+ homeDir,
820
+ now: decisionPlaneOptions?.now,
821
+ deadlineMs: decisionPlaneOptions?.deadlineMs,
822
+ });
823
+ if (shadow?.receipt) {
824
+ routeSummary.decisionPlane = {
825
+ stage: decisionPlaneStage,
826
+ receipt: shadow.receipt,
827
+ ...(shadow.preflight ? { preflight: shadow.preflight } : {}),
828
+ };
829
+ if (shadow.receipt.fallbackCode) {
830
+ degradedWarnings.push(
831
+ `decision-plane ${decisionPlaneStage}: ${shadow.receipt.outcomeClass} (${shadow.receipt.fallbackCode})`,
832
+ );
833
+ }
834
+ }
835
+ } catch (error) {
836
+ degradedWarnings.push(
837
+ `decision-plane ${decisionPlaneStage}: unavailable (${error?.message ?? String(error)})`,
838
+ );
839
+ }
840
+ }
841
+
842
+
796
843
  return {
797
844
  activeSkills,
798
845
  routingContext: {
@@ -831,6 +878,10 @@ export function buildRouteSummary({
831
878
  routeSchemaStage = 'off',
832
879
  runtimeConfig = null,
833
880
  resolvedWorkflowPolicy = null,
881
+ // TASK-004 (FR-009): additive decision-plane block for callers that already
882
+ // hold a shadow/canary result (deriveTaskRoute attaches post-build since the
883
+ // evaluator consumes the built summary). Absent → no field, byte-identical.
884
+ decisionPlane = null,
834
885
  } = {}) {
835
886
  const autonomyLevel = routingContext.autonomyLevel ?? 'balanced';
836
887
  const delegationRecommendation = deriveDelegationRecommendation({
@@ -1015,6 +1066,9 @@ export function buildRouteSummary({
1015
1066
  ...(workflowPolicy ? { workflowPolicySource: resolvedWorkflowPolicy?.source ?? 'builtin' } : {}),
1016
1067
  principleIndex,
1017
1068
  modelRolesBlock,
1069
+ // TASK-004 additive field — present only when a caller supplies a
1070
+ // decision-plane result (stage != 'off').
1071
+ ...(decisionPlane ? { decisionPlane } : {}),
1018
1072
  line: summaryLine || 'task=unknown',
1019
1073
  };
1020
1074
  }
@@ -1168,6 +1222,10 @@ function isInformationalPrompt({
1168
1222
  return questionSignal;
1169
1223
  }
1170
1224
 
1225
+
1226
+ // @decision-point preflight.execution-lane.v1 — the execution lane is a
1227
+ // registered preflight decision; this deterministic ladder is its
1228
+ // 'deterministic-route' fallback policy implementation.
1171
1229
  function deriveExecutionMode({
1172
1230
  promptText = '',
1173
1231
  commandText = '',
@@ -2105,7 +2163,10 @@ function inferTaskType({ promptText, commandText, selectedIds, targetFile = null
2105
2163
 
2106
2164
  function shellEscape(str) {
2107
2165
  if (typeof str !== 'string') return '';
2108
- return "'" + str.replace(/'/g, "'\''") + "'";
2166
+ // POSIX single-quote escape: close, escaped quote, reopen. The JS literal
2167
+ // needs a double backslash — "'\''" collapses to ''' and silently drops
2168
+ // the apostrophe from the routed intent text.
2169
+ return "'" + str.replace(/'/g, "'\\''") + "'";
2109
2170
  }
2110
2171
 
2111
2172
  function buildHelperCommand({
@@ -7,7 +7,7 @@ import { resolveContext } from './resolveContext.js';
7
7
  const RISKY_SKILL_IDS = new Set(['discover-security', 'repo-maintenance']);
8
8
  // SPEC-typed-verdicts §2.5: commands matching a test runner can mint a
9
9
  // `test-verified` verdict; everything else a plan routes is `check-only`.
10
- const TEST_RUNNER_COMMAND = /(?:^|\s)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$)|(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+test(?:\s|$)/i;
10
+ const TEST_RUNNER_COMMAND = /(?:^|\s)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$)|(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+test(?::[^\s]+)?(?:\s|$)/i;
11
11
  const TRACKED_VERIFICATION_FILES = [
12
12
  'package.json',
13
13
  'package-lock.json',
@@ -140,6 +140,17 @@ export async function deriveVerificationPlan({
140
140
  reasons.push('highRiskFallback');
141
141
  commands.push(buildScriptCommand(packageManager, 'test'));
142
142
  }
143
+
144
+ // Non-empty floor (RULES.md test-selection §3): when nothing targeted and
145
+ // no fallback resolved, the plan must still route the broadest test
146
+ // script the project declares — an empty selection is never permitted.
147
+ if (commands.length === 0 && fallbackCommands.length === 0) {
148
+ const floorScript = scripts['test:release-core'] ? 'test:release-core' : (scripts.test ? 'test' : null);
149
+ if (floorScript) {
150
+ reasons.push('nonEmptyFloor');
151
+ fallbackCommands.push(buildScriptCommand(packageManager, floorScript));
152
+ }
153
+ }
143
154
  }
144
155
 
145
156
  if (commands.length === 0 && fallbackCommands.length === 0) {
@@ -59,6 +59,12 @@ async function knownSignaturesAndTexts(projectRoot, projectId) {
59
59
  const pending = await listPendingPatternCandidates(projectRoot, projectId);
60
60
  for (const entry of pending) {
61
61
  texts.add(normalizeText(entry.text));
62
+ // Legacy pending candidates carry `signature` at top level (v2 records
63
+ // carry it under meta, collected below). Missing this let a same-signature
64
+ // pattern with a changed count/text slip past dedupe as 'proposed'.
65
+ if (typeof entry.signature === 'string' && entry.signature) {
66
+ signatures.add(entry.signature);
67
+ }
62
68
  }
63
69
  } catch {
64
70
  // listing failed → degrade, store dedupe is last line of defense
@@ -1,6 +1,6 @@
1
1
  const TOKEN_PATTERN = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g;
2
2
 
3
- function flattenObject(input, prefix = '', output = {}) {
3
+ function flattenObject(input, prefix = '', output = Object.create(null)) {
4
4
  if (input === null || input === undefined) {
5
5
  return output;
6
6
  }
@@ -80,7 +80,9 @@ export async function statusSkillAudit({ rootDir = process.cwd(), skillName } =
80
80
 
81
81
  const openRationalizations = history
82
82
  .flatMap((record) => record.rationalizationsFound ?? [])
83
- .filter((item) => !(typeof item === 'object' && item.closed === true));
83
+ // `typeof null === 'object'` — a null entry in a hand-edited history file
84
+ // crashed status on `item.closed`. Optional chaining keeps nulls open.
85
+ .filter((item) => item?.closed !== true);
84
86
 
85
87
  return { latest, openRationalizations, entryCount: history.length };
86
88
  }
@@ -131,7 +131,9 @@ async function detectDuraOne(projectRoot) {
131
131
 
132
132
  export async function detectStack(projectRoot) {
133
133
  const packageJsonPath = path.join(projectRoot, 'package.json');
134
- const packageJson = await readJsonIfExists(packageJsonPath);
134
+ // A corrupt package.json must degrade to the core pack — the same precedent
135
+ // detectProjectContext follows — not crash install/routing with a SyntaxError.
136
+ const packageJson = await readJsonIfExists(packageJsonPath).catch(() => null);
135
137
  const dependencySet = collectDependencySet(packageJson);
136
138
 
137
139
  const frontendSignals = collectDependencySignals(dependencySet, FRONTEND_DEPENDENCIES);
@@ -86,7 +86,7 @@ defect — fix it before writing tasks.
86
86
 
87
87
  ## Phase 1 — Write PLAN.md
88
88
 
89
- Write all 7 sections to `docs/AI_HANDOFF/PLAN.md`:
89
+ Write all 6 sections to `docs/AI_HANDOFF/PLAN.md`:
90
90
 
91
91
  ```
92
92
  §1 Intent — what problem, what success looks like
@@ -104,9 +104,6 @@ Write all 7 sections to `docs/AI_HANDOFF/PLAN.md`:
104
104
  command. If the project genuinely has none, state that explicitly —
105
105
  do not omit silently.
106
106
  §6 Acceptance — checklist of done criteria (prefer verifiable/command-based criteria)
107
- §7 Global Constraints — one line each: version floors, dependency limits, naming/copy
108
- rules, platform requirements. Every TASK-xxx.md inherits this section
109
- by reference — do not repeat these constraints inside each task.
110
107
  ```
111
108
 
112
109
  **§4 is non-negotiable.** No test plan = plan not ready.
@@ -118,7 +115,7 @@ Append this footer to `PLAN.md` — mandatory, checked by a hook before the writ
118
115
  PLANNER_MODEL: <your exact model ID — e.g. claude-opus-5>
119
116
  ```
120
117
 
121
- Your output does not go straight to implementation: an independent `code-reviewer` pass (`REVIEW_TARGET_TYPE=plan`) reviews `PLAN.md` next. If it returns `Issues Found`, you'll be re-invoked to revise and resubmit — write §1-§7 tight enough to pass on the first pass.
118
+ Your output does not go straight to implementation: an independent `code-reviewer` pass (`REVIEW_TARGET_TYPE=plan`) reviews `PLAN.md` next. If it returns `Issues Found`, you'll be re-invoked to revise and resubmit — write §1-§6 tight enough to pass on the first pass.
122
119
 
123
120
  ## Phase 2 — Split into TASK-xxx.md
124
121
 
@@ -81,6 +81,10 @@ if [ -z "$COMMAND" ]; then
81
81
  exit 0
82
82
  fi
83
83
 
84
+ # set -f: unquoted $COMMAND word-splits AND glob-expands — a bare `*` or `*.sh`
85
+ # command would otherwise resolve to cwd filenames and persist an allow rule
86
+ # for a file that was never the invoked binary.
87
+ set -f
84
88
  BIN=""
85
89
  for token in $COMMAND; do
86
90
  case "$token" in
@@ -91,6 +95,7 @@ for token in $COMMAND; do
91
95
  BIN="$token"
92
96
  break
93
97
  done
98
+ set +f
94
99
 
95
100
  if [ -z "$BIN" ]; then
96
101
  exit 0
@@ -101,7 +101,7 @@ const DANGEROUS_LITERALS = [
101
101
  'rm -rf /',
102
102
  'rm -rf ~',
103
103
  'git push --force',
104
- 'git push -f ',
104
+ 'git push -f',
105
105
  'git reset --hard',
106
106
  'git clean -fd',
107
107
  '> /dev/sda',
@@ -345,9 +345,16 @@ export function isDirectRun() {
345
345
  async function main() {
346
346
  const env = process.env;
347
347
  const HOOK_DEADLINE_MS = Number.parseInt(env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
348
- // .sh parity: the deadline bounded the extraction node — expiry produced an
349
- // empty command and a pass (exit 0, no output).
350
- const timer = setTimeout(() => process.exit(0), HOOK_DEADLINE_MS);
348
+ // Fail-closed (BUG-C21-03 parity): the deadline bounds the WHOLE scan —
349
+ // extraction plus evaluation. A wedged scan (stalled realpath, dead mount)
350
+ // must refuse exactly like runHook's TIMEOUT_REFUSAL, never silently pass an
351
+ // uninspected payload: the pre-port .sh had no scan deadline, so a hang ended
352
+ // as a non-zero hook error = blocked; a silent exit 0 would pass it.
353
+ const timer = setTimeout(() => {
354
+ try { process.stdout.write(TIMEOUT_REFUSAL.stdout); } catch {}
355
+ try { process.stderr.write(TIMEOUT_REFUSAL.stderr); } catch {}
356
+ process.exit(TIMEOUT_REFUSAL.code);
357
+ }, HOOK_DEADLINE_MS);
351
358
  if (timer && typeof timer.unref === 'function') timer.unref();
352
359
 
353
360
  let rawInput = '';
@@ -438,4 +438,13 @@ async function readRunCursor() {
438
438
  });
439
439
  NODE
440
440
 
441
- exit $?
441
+ NODE_RC=$?
442
+ # Fail-open parity with the in-block catch and the deadline path: a node crash
443
+ # (missing runtime, OOM, preload failure) means the cap was never evaluated —
444
+ # announce the degrade (SPEC §8) and release, never a silent non-blocking hook
445
+ # error. Exit 2 (a real hard-cap block) passes through untouched.
446
+ if [ "$NODE_RC" -ne 0 ] && [ "$NODE_RC" -ne 2 ]; then
447
+ printf '%s\n' '{"systemMessage":"UKit context hard-cap gate: evaluation crashed before finishing; this tool call proceeded without the hard-cap check. Run `ukit install` if this repeats."}'
448
+ exit 0
449
+ fi
450
+ exit "$NODE_RC"
@@ -131,9 +131,11 @@ function block(message) {
131
131
  function tierOf(model) {
132
132
  if (!model) return null;
133
133
  if (/unic-vision/i.test(model)) return 'vision';
134
- if (/opus|unic-smart/i.test(model)) return 'smart';
135
- if (/sonnet|unic-code/i.test(model)) return 'code';
136
- if (/haiku|unic-lite/i.test(model)) return 'lite';
134
+ // omp tier aliases resolve through .omp/config.yml modelRoles:
135
+ // @slow → smart/opus lane, @default → code/sonnet lane, @smol → lite/haiku lane.
136
+ if (/opus|unic-smart|@?slow\b/i.test(model)) return 'smart';
137
+ if (/sonnet|unic-code|@?default\b/i.test(model)) return 'code';
138
+ if (/haiku|unic-lite|@?smol\b/i.test(model)) return 'lite';
137
139
  return 'unknown';
138
140
  }
139
141
 
@@ -384,4 +386,12 @@ process.exit(0);
384
386
  })();
385
387
  NODE
386
388
 
387
- exit $?
389
+ NODE_RC=$?
390
+ # Fail-open parity with the deadline path above: a node crash (missing runtime,
391
+ # OOM, preload failure) means the tier check never ran — announce the degrade
392
+ # (SPEC §8) and release, never a silent non-blocking hook error.
393
+ if [ "$NODE_RC" -ne 0 ] && [ "$NODE_RC" -ne 2 ]; then
394
+ printf '%s\n' '{"systemMessage":"UKit handoff-model-guard: evaluation crashed before finishing; this tool call proceeded without the model check. Run `ukit install` if this repeats."}'
395
+ exit 0
396
+ fi
397
+ exit "$NODE_RC"
@@ -196,7 +196,16 @@ async function emitOrdinaryResume(payload, source) {
196
196
 
197
197
  process.stdout.write(`${out.join('\n')}\n`);
198
198
  process.exit(0);
199
- })();
199
+ })().catch((err) => {
200
+ // Advisory hook: an unexpected error must never surface as a silent masked
201
+ // exit — announce the degrade (SPEC §8(ii)) and exit 0 like every other path.
202
+ try {
203
+ process.stdout.write(`${JSON.stringify({
204
+ systemMessage: `UKit handoff-resume crashed (${String(err?.message ?? err).slice(0, 200)}) — the resume check was skipped this session start.`,
205
+ })}\n`);
206
+ } catch {}
207
+ process.exit(0);
208
+ });
200
209
  NODE
201
210
 
202
211
  exit 0
@@ -138,7 +138,6 @@ if [ -n "$PROTECTED_MATCH" ]; then
138
138
  # under bypassPermissions an exit-0 `ask` auto-approves — dead for a protected-file
139
139
  # refusal — so the direct host emits `deny`; the chain keeps `ask` for the human.
140
140
  __ukit_protect_reason='UKit protected-file guard blocked this edit. Ask the user to modify the protected file manually.'
141
- echo "BLOCKED: Cannot modify '$FILE_PATH' — matches protected pattern '$PROTECTED_MATCH'. Ask the user to modify this file manually." >&2
142
141
  if command -v ukit_emit_permission_decision >/dev/null 2>&1; then
143
142
  if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then
144
143
  ukit_emit_permission_decision ask "$__ukit_protect_reason"
@@ -118,6 +118,18 @@ export function isDirectRun() {
118
118
 
119
119
  async function main() {
120
120
  const env = process.env;
121
+ // The .sh wrapper exports UKIT_HOOK_DEADLINE_MS but this entry point never
122
+ // armed it: a wedged ledger import or stalled read hung the hook until the
123
+ // host killed it — a silent pass for a receipt that was never recorded.
124
+ // Deadline expiry is a failed runtime: emit the same loud line the .sh
125
+ // prints for a non-zero node exit (advisory posture kept: exit 0).
126
+ const HOOK_DEADLINE_MS = Number.parseInt(env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
127
+ const timer = setTimeout(() => {
128
+ try { process.stdout.write(RUNTIME_FAILED); } catch {}
129
+ process.exit(0);
130
+ }, HOOK_DEADLINE_MS);
131
+ if (timer && typeof timer.unref === 'function') timer.unref();
132
+
121
133
  let rawInput = '';
122
134
  try {
123
135
  rawInput = await fsp.readFile(env.INPUT_FILE || '', 'utf8');
@@ -125,7 +137,7 @@ async function main() {
125
137
  // No staged payload — silent advisory pass, same as an unreadable INPUT.
126
138
  }
127
139
  const projectRoot = env.PROJECT_ROOT || process.cwd();
128
- const verdict = await record({ rawInput, projectRoot, env });
140
+ const verdict = await record({ rawInput, projectRoot, env, deadlineMs: HOOK_DEADLINE_MS });
129
141
  if (verdict.stdout) process.stdout.write(verdict.stdout);
130
142
  process.exit(0);
131
143
  }
@@ -238,19 +238,85 @@ async function evaluate({ rawInput, projectRoot, env }) {
238
238
  continue;
239
239
  }
240
240
 
241
- if (DUMP_VERBS.has(head)) {
242
- for (const token of tokens.slice(1)) {
241
+ // Wrapper executables run the real command as their operand — `sudo cat
242
+ // .env` dumps the file exactly like `cat .env`, so the dump-verb check
243
+ // must resolve the EFFECTIVE head, not tokens[0]. Each wrapper maps to
244
+ // the number of positional operands it takes BEFORE the command (sudo
245
+ // takes none — `sudo curl` runs curl; `timeout` takes one — its
246
+ // duration). Unknown flags consume their operand only when it cannot be
247
+ // a command (not a verb/wrapper/assignment/flag), so a miss degrades to
248
+ // the old tokens[0] behavior rather than a false positive.
249
+ const WRAPPER_POSITIONALS = new Map([
250
+ ['sudo', 0], ['doas', 0], ['pkexec', 0], ['unshare', 0], ['nsenter', 0],
251
+ ['setpriv', 0], ['capsh', 0], ['systemd-run', 0], ['env', 0], ['nice', 0],
252
+ ['ionice', 0], ['stdbuf', 0], ['chrt', 0], ['nohup', 0], ['watch', 0],
253
+ ['xargs', 0], ['strace', 0], ['ltrace', 0], ['time', 0], ['command', 0],
254
+ ['builtin', 0], ['exec', 0], ['eval', 0],
255
+ ['bash', 0], ['sh', 0], ['zsh', 0], ['dash', 0], ['ksh', 0],
256
+ ['runuser', 1], ['sg', 1], ['su', 1], ['ssh', 1], ['mosh', 1],
257
+ ['timeout', 1], ['chroot', 1], ['setarch', 1], ['taskset', 1],
258
+ ['flock', 1],
259
+ ['docker', 2], ['podman', 2], ['nerdctl', 2], ['ctr', 2],
260
+ ['kubectl', 2], ['machinectl', 2],
261
+ ]);
262
+ const unquote = (t) => String(t || '').replace(/^["']+|["']+$/g, '');
263
+ const isVerbToken = (t) => DUMP_VERBS.has(unquote(t).replace(/^.*\//, ''));
264
+ const wrapperPositionals = (t) => WRAPPER_POSITIONALS.get(unquote(t).replace(/^.*\//, ''));
265
+ const isAssignToken = (t) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(t);
266
+ const isFlagToken = (t) => /^-[^-]?|--/.test(t);
267
+ let headIdx = 0;
268
+ let wrapperPositionalPending = 0;
269
+ for (;;) {
270
+ const t = tokens[headIdx];
271
+ if (t === undefined) break;
272
+ if (t === '--') { headIdx += 1; break; }
273
+ if (isAssignToken(t)) { headIdx += 1; continue; }
274
+ const positionalCount = wrapperPositionals(t);
275
+ if (positionalCount !== undefined) {
276
+ wrapperPositionalPending = positionalCount;
277
+ headIdx += 1;
278
+ continue;
279
+ }
280
+ if (isFlagToken(t)) {
281
+ headIdx += 1;
282
+ const next = tokens[headIdx];
283
+ // A flag's operand is consumed only when it cannot be the command:
284
+ // verbs, wrappers, assignments and further flags are never operands.
285
+ if (next !== undefined
286
+ && !isFlagToken(next)
287
+ && !isAssignToken(next)
288
+ && !isVerbToken(next)
289
+ && wrapperPositionals(next) === undefined) {
290
+ headIdx += 1;
291
+ }
292
+ continue;
293
+ }
294
+ if (wrapperPositionalPending > 0 && !isVerbToken(t)) {
295
+ // The wrapper's own operand (user/host/duration/image), then the
296
+ // next token is the real command.
297
+ wrapperPositionalPending -= 1;
298
+ headIdx += 1;
299
+ continue;
300
+ }
301
+ break;
302
+ }
303
+ const effectiveHead = unquote(tokens[headIdx] ?? '');
304
+
305
+ if (DUMP_VERBS.has(effectiveHead)) {
306
+ for (const token of tokens.slice(headIdx + 1)) {
243
307
  const cleaned = token.replace(/^["']+|["',:]+$/g, '');
244
308
  const kind = classifySecretFile(cleaned);
245
309
  if (kind && !pathAllowed(cleaned)) {
246
- found.push({ label: `${head} reads ${kind}`, value: cleaned, isPath: true });
310
+ found.push({ label: `${effectiveHead} reads ${kind}`, value: cleaned, isPath: true });
247
311
  }
248
312
  }
249
313
  }
250
314
 
251
- const headBase = head.replace(/^.*\//, '');
315
+ const headBase = effectiveHead.replace(/^.*\//, '');
252
316
  const CRED_USER_TOOLS = new Set(['curl', 'wget', 'ftp', 'lftp', 'aria2c', 'http', 'https']);
253
- const inlineUser = /(^|\s)(-u|--user)\s+["']?([^\s"':]+):([^\s"']+)/.exec(segment);
317
+ // `-uuser:pass`, `-u user:pass`, `--user=user:pass`, `--user user:pass` —
318
+ // the old `\s+` separator missed the joined and `=` forms entirely.
319
+ const inlineUser = /(^|\s)(-u|--user)[\s=]*["']?([^\s"':]+):([^\s"']+)/.exec(segment);
254
320
  if (inlineUser && CRED_USER_TOOLS.has(headBase)) {
255
321
  const numericIds = /^\d+$/.test(inlineUser[3]) && /^\d+$/.test(inlineUser[4]);
256
322
  if (!numericIds) {
@@ -64,11 +64,18 @@ if [ -e /dev/fd/0 ]; then
64
64
  exec 8<&0
65
65
  head -c 65537 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
66
66
  UKIT_HEAD_PID=$!
67
- # UKIT_HOOK_STAGE_MS:-2000 — bounded wait for the staged payload.
67
+ # UKIT_HOOK_STAGE_MS:-2000 — bounded wait for the staged payload (same derived
68
+ # budget every other hook uses; a hardcoded sleep ignored the operator knob).
69
+ __ukit_ep_stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
70
+ case "$__ukit_ep_stage_ms" in
71
+ ''|*[!0-9]*) __ukit_ep_stage_ms=2000 ;;
72
+ esac
73
+ [ "$__ukit_ep_stage_ms" -gt 0 ] 2>/dev/null || __ukit_ep_stage_ms=2000
74
+ printf -v __ukit_ep_stage_s '%d.%03d' $((__ukit_ep_stage_ms / 1000)) $((__ukit_ep_stage_ms % 1000))
68
75
  # TASK-002: detach the watchdog's inherited stdout/stderr — otherwise a
69
76
  # captured-stdio caller (hook-chain-runner) sees the pipe held open ~2s
70
77
  # after the script exits, inflating the SessionEnd residual to ~2000ms.
71
- ( sleep 2; kill "$UKIT_HEAD_PID" 2>/dev/null ) >/dev/null 2>&1 &
78
+ ( sleep "$__ukit_ep_stage_s"; kill "$UKIT_HEAD_PID" 2>/dev/null ) >/dev/null 2>&1 &
72
79
  UKIT_WATCH_PID=$!
73
80
  wait "$UKIT_HEAD_PID" 2>/dev/null
74
81
  kill "$UKIT_WATCH_PID" 2>/dev/null
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: PDF Processing Pro
2
+ name: pdf-processing-pro
3
3
  description: Production-ready PDF processing with forms, tables, OCR, validation, and batch operations. Use when working with complex PDF workflows in production environments, processing large volumes of PDFs, or requiring robust error handling and validation.
4
4
  ---
5
5
 
@@ -63,7 +63,9 @@ function planSectionPositions(sections) {
63
63
  const positions = new Map();
64
64
  for (const s of sections) {
65
65
  for (const token of PLAN_SECTIONS) {
66
- if (!positions.has(token) && s.name.startsWith(token)) positions.set(token, s.pos);
66
+ // `## §10`/`## §12` must not satisfy `§1` — the token only matches when it
67
+ // is the whole heading or is followed by a non-digit boundary (space, colon…).
68
+ if (!positions.has(token) && new RegExp(`^${token}(?!\\d)`).test(s.name)) positions.set(token, s.pos);
67
69
  }
68
70
  }
69
71
  return positions;