@ngockhoale/ukit 3.0.3 → 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.
- package/CHANGELOG.md +17 -0
- package/bin/ukit +12 -4
- package/manifests/engineConformance.yaml +29 -0
- package/manifests/platform.full.yaml +13 -0
- package/package.json +2 -1
- package/scripts/audit/decision-coverage.mjs +295 -0
- package/scripts/bench/outline-savings.mjs +19 -4
- package/scripts/bench/parallel-agents.mjs +15 -4
- package/scripts/bench/runGold.mjs +22 -4
- package/scripts/bench/v3-ceremony.mjs +7 -1
- package/scripts/bug/triage.mjs +56 -17
- package/scripts/index/build-index.mjs +94 -28
- package/scripts/index/query-index.mjs +48 -14
- package/scripts/index/refresh-index.mjs +142 -62
- package/scripts/perf/audit-perf.mjs +8 -2
- package/scripts/skill/audit-skill.mjs +54 -25
- package/src/bug/triageBug.js +9 -6
- package/src/cli/adapters.js +6 -0
- package/src/cli/commands/code.js +7 -1
- package/src/cli/commands/indexArgs.js +4 -2
- package/src/cli/commands/indexTools.js +8 -1
- package/src/cli/commands/install.js +13 -0
- package/src/cli/commands/memory.js +10 -3
- package/src/cli/commands/status.js +17 -1
- package/src/cli/commands/update.js +7 -0
- package/src/context/detectProjectContext.js +3 -1
- package/src/core/codeintel/analogy.js +1 -1
- package/src/core/codeintel/diagnostics.js +9 -6
- package/src/core/codeintel/graph.js +14 -8
- package/src/core/codeintel/impact.js +0 -1
- package/src/core/codeintel/invalidation.js +5 -3
- package/src/core/codeintel/packet.js +11 -0
- package/src/core/codeintel/router.js +17 -3
- package/src/core/codeintel/semanticProvider.js +1 -1
- package/src/core/codeintel/summaries.js +11 -7
- package/src/core/compact/contextBudget.js +26 -12
- package/src/core/compact/index.js +15 -8
- package/src/core/docContracts.js +10 -2
- package/src/core/experiments/deliberation.js +321 -0
- package/src/core/experiments/dynamicWorkflow.js +492 -0
- package/src/core/fileOps.js +8 -1
- package/src/core/gatewayProbe.js +29 -1
- package/src/core/gatewayResilienceEnv.js +44 -3
- package/src/core/handoffDocValidator.js +3 -1
- package/src/core/hookChainDoctor.js +16 -1
- package/src/core/memory/deltaOverlays.js +448 -0
- package/src/core/memory/learningCandidates.js +302 -0
- package/src/core/memory/migrate.js +59 -32
- package/src/core/memory/recordStore.js +24 -1
- package/src/core/memory/store.js +44 -8
- package/src/core/memory/storeV2.js +48 -33
- package/src/core/memory/userMemory.js +11 -10
- package/src/core/output/index.js +15 -10
- package/src/core/permissionPolicy.js +8 -0
- package/src/core/runtimeConfig.js +224 -4
- package/src/core/sensitiveValueScanner.js +10 -2
- package/src/core/taskBudgetValidator.js +12 -17
- package/src/core/taskProgressGuard.js +59 -9
- package/src/core/unattendedDoctor.js +5 -2
- package/src/core/uninstall.js +37 -8
- package/src/decision/client.js +371 -0
- package/src/decision/lease.js +198 -0
- package/src/decision/preflight.js +492 -0
- package/src/decision/protocol.js +308 -0
- package/src/decision/registry.js +384 -0
- package/src/decision/shadow.js +281 -0
- package/src/decision/statePacket.js +165 -0
- package/src/diagnostics/failurePatterns.js +2 -1
- package/src/diagnostics/ledgerFiles.js +3 -1
- package/src/diagnostics/routeOutcomes.js +1 -29
- package/src/index/buildIndex.js +23 -11
- package/src/index/impactContext.js +21 -2
- package/src/index/importResolution.js +13 -7
- package/src/index/queryIndex.js +11 -5
- package/src/index/resolveContext.js +16 -6
- package/src/index/taskRouting.js +63 -2
- package/src/index/verificationPlan.js +12 -1
- package/src/learning/patternProposals.js +6 -0
- package/src/render/renderTemplate.js +1 -1
- package/src/skill/auditSkill.js +3 -1
- package/src/stack/detectStack.js +3 -1
- package/template_project/.claude/agents/handoff-planner.md +2 -5
- package/template_project/.claude/hooks/auto-allow-bash.sh +5 -0
- package/template_project/.claude/hooks/block-dangerous.mjs +11 -4
- package/template_project/.claude/hooks/context-hardcap-gate.sh +10 -1
- package/template_project/.claude/hooks/handoff-model-guard.sh +14 -4
- package/template_project/.claude/hooks/handoff-resume.sh +10 -1
- package/template_project/.claude/hooks/protect-files.sh +0 -1
- package/template_project/.claude/hooks/record-execution.mjs +13 -1
- package/template_project/.claude/hooks/sensitive-data-guard.mjs +71 -5
- package/template_project/.claude/hooks/session-episode.sh +9 -2
- package/template_project/.claude/skills/pdf-processing-pro/SKILL.md +1 -1
- package/template_project/.claude/ukit/index/handoff-doc-validator.mjs +3 -1
- package/template_project/.claude/ukit/index/lib/index-core.mjs +123 -39
- package/template_project/.claude/ukit/index/route-task.mjs +444 -0
- package/template_project/.claude/ukit/index/task-budget-validator.mjs +12 -16
- package/template_project/.claude/ukit/index/unic-decision.mjs +786 -0
- package/template_project/.claude/ukit/index/verify-context.mjs +11 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +116 -9
- package/template_project/.claude/ukit/runtime/project-important.mjs +9 -7
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +48 -0
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +596 -0
- package/template_project/.claude/ukit/runtime/sensitive-value-scanner.mjs +4 -7
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +1 -1
- package/template_project/docs/AI_HANDOFF/PLAN.md +7 -7
- package/template_project/docs/AI_HANDOFF/RULES.md +1 -1
- package/template_project/ukit/storage/config.json +34 -1
- package/template_project/.claude/ukit/skill-router-state.json +0 -1
- 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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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) {
|
package/src/index/queryIndex.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
196
|
+
if (containsSignalWord(lower, RISKY_SIGNALS)) {
|
|
187
197
|
return 'non-trivial';
|
|
188
198
|
}
|
|
189
199
|
|
|
190
200
|
// Check trivial
|
|
191
|
-
if (
|
|
201
|
+
if (containsSignalWord(lower, TRIVIAL_SIGNALS)) {
|
|
192
202
|
// But if intent also mentions multiple files or broad scope, upgrade
|
|
193
|
-
if (lower
|
|
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
|
|
231
|
+
return containsSignalWord(normalizedIntent, EXPANSION_SIGNALS);
|
|
222
232
|
}
|
|
223
233
|
function enforceContextBudget(result, maxFiles) {
|
|
224
234
|
const prioritizedGroups = [
|
package/src/index/taskRouting.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/skill/auditSkill.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/stack/detectStack.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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-§
|
|
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
|
-
//
|
|
349
|
-
//
|
|
350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
if (/
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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: `${
|
|
310
|
+
found.push({ label: `${effectiveHead} reads ${kind}`, value: cleaned, isPath: true });
|
|
247
311
|
}
|
|
248
312
|
}
|
|
249
313
|
}
|
|
250
314
|
|
|
251
|
-
const headBase =
|
|
315
|
+
const headBase = effectiveHead.replace(/^.*\//, '');
|
|
252
316
|
const CRED_USER_TOOLS = new Set(['curl', 'wget', 'ftp', 'lftp', 'aria2c', 'http', 'https']);
|
|
253
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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;
|