@ngockhoale/ukit 3.4.1 → 3.4.2
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 +19 -0
- package/package.json +1 -1
- package/src/cli/commands/code.js +29 -5
- package/src/cli/commands/decision.js +18 -4
- package/src/cli/commands/doctor.js +7 -3
- package/src/cli/commands/install.js +29 -4
- package/src/cli/commands/memory.js +25 -5
- package/src/cli/commands/telemetry.js +18 -1
- package/src/cli/commands/vm.js +7 -1
- package/src/context/detectProjectContext.js +7 -2
- package/src/core/agentRuntime/contract.js +5 -1
- package/src/core/agentRuntime/eventStore.js +54 -7
- package/src/core/agentRuntime/recovery.js +22 -15
- package/src/core/agentRuntime/supervisor.js +71 -13
- package/src/core/applyPlan.js +11 -1
- package/src/core/codeintel/compiler.js +51 -8
- package/src/core/codeintel/diagnostics.js +124 -33
- package/src/core/codeintel/freshness.js +25 -12
- package/src/core/codeintel/invalidation.js +11 -3
- package/src/core/codeintel/retriever.js +53 -20
- package/src/core/codeintel/router.js +19 -9
- package/src/core/codeintel/summaries.js +4 -3
- package/src/core/codeintel/vectorProvider.js +30 -4
- package/src/core/compact/index.js +24 -7
- package/src/core/compact/threshold.js +49 -14
- package/src/core/diffPlan.js +51 -23
- package/src/core/ensureGitignore.js +19 -2
- package/src/core/fileOps.js +61 -0
- package/src/core/memory/hygiene.js +51 -1
- package/src/core/memory/migrate.js +41 -21
- package/src/core/memory/store.js +96 -61
- package/src/core/metadata.js +37 -2
- package/src/core/observability/adapters/ingest.js +30 -2
- package/src/core/observability/emit/config.js +19 -4
- package/src/core/observability/emit/crash.js +3 -1
- package/src/core/observability/emit/recorder.js +15 -7
- package/src/core/observability/privacy/sanitizeObserved.js +3 -1
- package/src/core/observability/segments/internal.js +36 -8
- package/src/core/observability/segments/retention.js +11 -0
- package/src/core/observability/support/import.js +27 -1
- package/src/core/output/index.js +16 -1
- package/src/core/permissionDoctor.js +72 -9
- package/src/core/repairBrokenHooks.js +15 -2
- package/src/core/reviewPanelAggregate.js +26 -10
- package/src/core/runInstallPipeline.js +71 -22
- package/src/core/runtimeConfig.js +2 -0
- package/src/core/status.js +2 -0
- package/src/core/taskBudgetValidator.js +7 -1
- package/src/core/taskProgressGuard.js +11 -1
- package/src/core/unattendedDoctor.js +36 -5
- package/src/core/uninstall.js +52 -12
- package/src/core/update.js +5 -1
- package/src/decision/client.js +158 -27
- package/src/decision/reviewVerdict.js +23 -7
- package/src/diagnostics/failurePatterns.js +1 -1
- package/src/diagnostics/feedbackEvents.js +1 -1
- package/src/diagnostics/routeOutcomes.js +42 -4
- package/src/diagnostics/skillAccuracy.js +35 -4
- package/src/index/buildIndex.js +123 -26
- package/src/index/fixLoopEscalation.js +3 -0
- package/src/index/gitHooks.js +99 -29
- package/src/index/importResolution.js +7 -1
- package/src/index/playbookRegistry.js +15 -11
- package/src/index/queryIndex.js +28 -10
- package/src/index/routeResolver.js +8 -3
- package/src/index/taskRouting.js +37 -2
- package/src/learning/codeProposals.js +24 -5
- package/src/learning/selfImprove.js +29 -5
- package/src/learning/tunedOverlay.js +18 -7
- package/src/learning/tuning.js +10 -4
- package/src/skill/auditSkill.js +46 -7
- package/template_project/.claude/commands/ukit/handoff-review.md +4 -1
- package/template_project/.claude/hooks/auto-allow-bash.sh +10 -1
- package/template_project/.claude/hooks/block-dangerous.mjs +10 -2
- package/template_project/.claude/hooks/handoff-model-guard.sh +46 -16
- package/template_project/.claude/hooks/reset-compact-pressure.sh +128 -72
- package/template_project/.claude/hooks/sensitive-data-guard.mjs +394 -11
- package/template_project/.claude/hooks/session-episode.sh +60 -28
- package/template_project/.claude/hooks/verification-guard.sh +26 -15
- package/template_project/.claude/skills/pptx/scripts/thumbnail.py +6 -1
- package/template_project/.claude/ukit/index/lib/index-core.mjs +156 -39
- package/template_project/.claude/ukit/index/playbook-registry.mjs +15 -11
- package/template_project/.claude/ukit/index/post-edit-verify.mjs +25 -4
- package/template_project/.claude/ukit/index/pre-edit-backup.mjs +4 -0
- package/template_project/.claude/ukit/index/provision-worktree.mjs +15 -10
- package/template_project/.claude/ukit/index/query-index.mjs +13 -6
- package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +127 -25
- package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +36 -14
- package/template_project/.claude/ukit/index/review-verdict.mjs +93 -19
- package/template_project/.claude/ukit/index/route-resolver.mjs +8 -3
- package/template_project/.claude/ukit/index/route-task.mjs +15 -0
- package/template_project/.claude/ukit/index/safe-patch.mjs +4 -1
- package/template_project/.claude/ukit/index/sidecar-decision.mjs +43 -10
- package/template_project/.claude/ukit/index/stale-spec-check.mjs +13 -3
- package/template_project/.claude/ukit/index/task-budget-validator.mjs +7 -1
- package/template_project/.claude/ukit/index/unic-decision.mjs +179 -28
- package/template_project/.claude/ukit/index/unic-gateway.mjs +33 -8
- package/template_project/.claude/ukit/index/verify-context.mjs +9 -2
- package/template_project/.claude/ukit/index/worktree-sweep.mjs +89 -31
- package/template_project/.claude/ukit/runtime/compact-threshold.mjs +47 -15
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +63 -30
- package/template_project/.claude/ukit/runtime/hook-field-salvage.mjs +49 -13
- package/template_project/.claude/ukit/runtime/hook-input.sh +48 -13
- package/template_project/.claude/ukit/runtime/hook-telemetry.mjs +92 -7
- package/template_project/.claude/ukit/runtime/memory-freshness.mjs +17 -0
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -10
- package/template_project/.claude/ukit/runtime/output-compression.mjs +11 -0
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +24 -3
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +62 -32
- package/template_project/.claude/ukit/runtime/token-utils.mjs +57 -14
|
@@ -53,17 +53,50 @@ async function readJson(filePath) {
|
|
|
53
53
|
// shape as ring entries; malformed lines are skipped like parseSidecarLines.
|
|
54
54
|
const SEGMENTS_REL = 'route-audit.segments.jsonl';
|
|
55
55
|
|
|
56
|
+
// C92-H-05: the sidecar is append-only with no writer-side cap, so the readers
|
|
57
|
+
// bound consumption instead — at most the newest SEGMENTS_MAX_BYTES tail. Same
|
|
58
|
+
// 256 KiB as RETRIEVER_LANES_MAX_BYTES (src/core/codeintel/retriever.js:273),
|
|
59
|
+
// the sibling JSONL budget. A mid-slice read starts on a partial line — the
|
|
60
|
+
// first fragment is dropped, mirroring the truncated-tail tolerance below.
|
|
61
|
+
// Keeping the tail (not the head) is load-bearing: a head-anchored read would
|
|
62
|
+
// report year-old routing accuracy as current.
|
|
63
|
+
export const SEGMENTS_MAX_BYTES = 256 * 1024;
|
|
64
|
+
|
|
56
65
|
async function readJsonLines(filePath) {
|
|
57
66
|
const rows = [];
|
|
58
67
|
try {
|
|
59
|
-
const
|
|
60
|
-
|
|
68
|
+
const stat = await fs.stat(filePath);
|
|
69
|
+
if (!stat.isFile() || stat.size <= 0) {
|
|
70
|
+
return rows;
|
|
71
|
+
}
|
|
72
|
+
let raw;
|
|
73
|
+
if (stat.size <= SEGMENTS_MAX_BYTES) {
|
|
74
|
+
raw = await fs.readFile(filePath, 'utf8');
|
|
75
|
+
} else {
|
|
76
|
+
// Tail-anchored read: newest bytes only, so cost stays flat no matter
|
|
77
|
+
// how old the sidecar gets.
|
|
78
|
+
const handle = await fs.open(filePath, 'r');
|
|
79
|
+
let bytesRead = 0;
|
|
80
|
+
const buffer = Buffer.allocUnsafe(SEGMENTS_MAX_BYTES);
|
|
81
|
+
try {
|
|
82
|
+
({ bytesRead } = await handle.read(buffer, 0, SEGMENTS_MAX_BYTES, stat.size - SEGMENTS_MAX_BYTES));
|
|
83
|
+
} finally {
|
|
84
|
+
await handle.close().catch(() => {});
|
|
85
|
+
}
|
|
86
|
+
raw = buffer.toString('utf8', 0, bytesRead);
|
|
87
|
+
}
|
|
88
|
+
const lines = raw.split('\n');
|
|
89
|
+
if (stat.size > SEGMENTS_MAX_BYTES) {
|
|
90
|
+
// First line of a byte-offset slice is a fragment — always drop it.
|
|
91
|
+
lines.shift();
|
|
92
|
+
}
|
|
93
|
+
for (const line of lines) {
|
|
61
94
|
if (!line.trim()) continue;
|
|
62
95
|
try {
|
|
63
96
|
const item = JSON.parse(line);
|
|
64
97
|
if (item && typeof item === 'object' && !Array.isArray(item)) rows.push(item);
|
|
65
98
|
} catch {
|
|
66
|
-
// skip malformed segment lines
|
|
99
|
+
// skip malformed segment lines (incl. a truncated tail mid-append)
|
|
67
100
|
}
|
|
68
101
|
}
|
|
69
102
|
} catch {
|
|
@@ -123,9 +156,14 @@ export async function collectRouteOutcomes(projectRoot, { limitLedgers = 500 } =
|
|
|
123
156
|
}
|
|
124
157
|
}
|
|
125
158
|
const ledgerKeys = new Set();
|
|
159
|
+
// C92-H-05: keyed Map replaces the per-row ledgers.find — O(ledgers) once
|
|
160
|
+
// instead of O(rows × ledgers) inside the join loop (mirrors
|
|
161
|
+
// skillAccuracy.js's ledgerByKey).
|
|
162
|
+
const ledgerByKey = new Map();
|
|
126
163
|
for (const ledger of ledgers) {
|
|
127
164
|
if (ledger && typeof ledger.requestKey === 'string' && ledger.requestKey) {
|
|
128
165
|
ledgerKeys.add(ledger.requestKey);
|
|
166
|
+
ledgerByKey.set(ledger.requestKey, ledger);
|
|
129
167
|
}
|
|
130
168
|
}
|
|
131
169
|
|
|
@@ -139,7 +177,7 @@ export async function collectRouteOutcomes(projectRoot, { limitLedgers = 500 } =
|
|
|
139
177
|
modeBucket.routes += 1;
|
|
140
178
|
taskBucket.routes += 1;
|
|
141
179
|
modeTierBucket.routes += 1;
|
|
142
|
-
const ledger =
|
|
180
|
+
const ledger = ledgerByKey.get(entry.requestKey);
|
|
143
181
|
if (!ledger) continue;
|
|
144
182
|
result.joined += 1;
|
|
145
183
|
result.joinedRows.push({ audit: entry, ledger });
|
|
@@ -24,6 +24,13 @@ const AUDIT_REL = path.join(CACHE_DIR_REL, 'route-audit.json');
|
|
|
24
24
|
// BL-004: rows evicted past the 40-entry ring cap spill to this append-only
|
|
25
25
|
// JSONL sidecar — same row shape; malformed lines skipped like parseSidecarLines.
|
|
26
26
|
const SEGMENTS_REL = path.join(CACHE_DIR_REL, 'route-audit.segments.jsonl');
|
|
27
|
+
|
|
28
|
+
// C92-H-05: the sidecar is append-only with no writer-side cap, so the readers
|
|
29
|
+
// bound consumption instead — at most the newest SEGMENTS_MAX_BYTES tail. Same
|
|
30
|
+
// 256 KiB as RETRIEVER_LANES_MAX_BYTES (src/core/codeintel/retriever.js:273) and
|
|
31
|
+
// routeOutcomes.js's SEGMENTS_MAX_BYTES. Tail-anchored (newest rows kept): a
|
|
32
|
+
// head-anchored read would report year-old trigger accuracy as current.
|
|
33
|
+
export const SEGMENTS_MAX_BYTES = 256 * 1024;
|
|
27
34
|
const ARTIFACT_REL = path.join('.ukit', 'storage', 'learning', 'skill-accuracy.json');
|
|
28
35
|
const CONFIG_REL = path.join('.ukit', 'storage', 'config.json');
|
|
29
36
|
const MAX_SKILL_IDS = 8;
|
|
@@ -68,14 +75,38 @@ async function readJson(filePath) {
|
|
|
68
75
|
async function readJsonLines(filePath) {
|
|
69
76
|
const rows = [];
|
|
70
77
|
try {
|
|
71
|
-
const
|
|
72
|
-
|
|
78
|
+
const stat = await fs.stat(filePath);
|
|
79
|
+
if (!stat.isFile() || stat.size <= 0) {
|
|
80
|
+
return rows;
|
|
81
|
+
}
|
|
82
|
+
let raw;
|
|
83
|
+
if (stat.size <= SEGMENTS_MAX_BYTES) {
|
|
84
|
+
raw = await fs.readFile(filePath, 'utf8');
|
|
85
|
+
} else {
|
|
86
|
+
// Tail-anchored read: newest bytes only, so cost stays flat no matter
|
|
87
|
+
// how old the sidecar gets.
|
|
88
|
+
const handle = await fs.open(filePath, 'r');
|
|
89
|
+
let bytesRead = 0;
|
|
90
|
+
const buffer = Buffer.allocUnsafe(SEGMENTS_MAX_BYTES);
|
|
91
|
+
try {
|
|
92
|
+
({ bytesRead } = await handle.read(buffer, 0, SEGMENTS_MAX_BYTES, stat.size - SEGMENTS_MAX_BYTES));
|
|
93
|
+
} finally {
|
|
94
|
+
await handle.close().catch(() => {});
|
|
95
|
+
}
|
|
96
|
+
raw = buffer.toString('utf8', 0, bytesRead);
|
|
97
|
+
}
|
|
98
|
+
const lines = raw.split('\n');
|
|
99
|
+
if (stat.size > SEGMENTS_MAX_BYTES) {
|
|
100
|
+
// First line of a byte-offset slice is a fragment — always drop it.
|
|
101
|
+
lines.shift();
|
|
102
|
+
}
|
|
103
|
+
for (const line of lines) {
|
|
73
104
|
if (!line.trim()) continue;
|
|
74
105
|
try {
|
|
75
106
|
const item = JSON.parse(line);
|
|
76
107
|
if (item && typeof item === 'object') rows.push(item);
|
|
77
108
|
} catch {
|
|
78
|
-
// skip malformed segment lines
|
|
109
|
+
// skip malformed segment lines (incl. a truncated tail mid-append)
|
|
79
110
|
}
|
|
80
111
|
}
|
|
81
112
|
} catch {
|
|
@@ -99,7 +130,7 @@ function skillIdsOf(entry) {
|
|
|
99
130
|
|
|
100
131
|
async function writeArtifact(filePath, result) {
|
|
101
132
|
const dir = path.dirname(filePath);
|
|
102
|
-
const tmp = path.join(dir, `.skill-accuracy-${process.pid}.tmp`);
|
|
133
|
+
const tmp = path.join(dir, `.skill-accuracy-${process.pid}-${Math.random().toString(16).slice(2)}.tmp`);
|
|
103
134
|
try {
|
|
104
135
|
await fs.mkdir(dir, { recursive: true });
|
|
105
136
|
await fs.writeFile(tmp, JSON.stringify(result, null, 2));
|
package/src/index/buildIndex.js
CHANGED
|
@@ -3,6 +3,12 @@ import path from 'node:path';
|
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
5
|
|
|
6
|
+
// Cross-process build serialization (C92-B-01): the same withAsyncLock
|
|
7
|
+
// protocol ensureIndexFresh uses in the installed index-core mirror, so every
|
|
8
|
+
// buildCodeIndex caller — CLI, git hook, install — serializes without needing
|
|
9
|
+
// its own call-site lock.
|
|
10
|
+
import { withAsyncLock } from '../../template_project/.claude/ukit/runtime/async-lock.mjs';
|
|
11
|
+
|
|
6
12
|
import { getArtifactPath, getIndexDir, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION, isLikelyTestFilePath, normalizeRelative } from './paths.js';
|
|
7
13
|
import { importsNeedAliasContext, loadImportAliasContextState, resolveImportSpecifier } from './importResolution.js';
|
|
8
14
|
import { clearIndexArtifactCache } from './queryIndex.js';
|
|
@@ -105,6 +111,23 @@ export class IndexDiscoveryTimeoutError extends Error {
|
|
|
105
111
|
}
|
|
106
112
|
}
|
|
107
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Fail-closed lock timeout (C92-B-01): a contended index-build lock that stays
|
|
116
|
+
* held past INDEX_BUILD_LOCK_WAIT_MS means another builder is still publishing
|
|
117
|
+
* (or a dead holder's lock could not be reclaimed). Building unlocked would
|
|
118
|
+
* interleave tmp+rename writes into a torn artifact set, so the caller gets a
|
|
119
|
+
* typed failure instead of a race.
|
|
120
|
+
*/
|
|
121
|
+
export class IndexBuildLockTimeoutError extends Error {
|
|
122
|
+
constructor({ waitedMs, lockPath }) {
|
|
123
|
+
super(`Index build lock ${lockPath} was still held after ${waitedMs}ms; refusing to publish unlocked.`);
|
|
124
|
+
this.name = 'IndexBuildLockTimeoutError';
|
|
125
|
+
this.code = 'INDEX_BUILD_LOCK_TIMEOUT';
|
|
126
|
+
this.waitedMs = waitedMs;
|
|
127
|
+
this.lockPath = lockPath;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
108
131
|
const EXCLUDED_DIR_NAMES = new Set([
|
|
109
132
|
'node_modules',
|
|
110
133
|
'.git',
|
|
@@ -142,7 +165,56 @@ const CALL_IGNORE_WORDS = new Set([
|
|
|
142
165
|
'if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'typeof',
|
|
143
166
|
]);
|
|
144
167
|
|
|
168
|
+
export const INDEX_BUILD_LOCK_NAME = 'index-build';
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* How long a builder waits for a build already in flight before failing
|
|
172
|
+
* closed. Must comfortably cover a worst-case build of a large repo — the
|
|
173
|
+
* wait replaces what used to be a silent interleaved publish, so an overly
|
|
174
|
+
* short budget would trade torn artifacts for spurious timeouts.
|
|
175
|
+
*/
|
|
176
|
+
export const INDEX_BUILD_LOCK_WAIT_MS = 60_000;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* C92-B-01: every buildCodeIndex entry point publishes under the shared
|
|
180
|
+
* `index-build` lock. Per-artifact tmp+rename was never the problem — two
|
|
181
|
+
* builders with ASYMMETRIC write sets interleaved their renames, so a reader
|
|
182
|
+
* (or the next incremental merge) could consume meta.json from build A and
|
|
183
|
+
* files.json from build B. The lock lives inside the builder because the
|
|
184
|
+
* callers (refresh-index.mjs, build-index.mjs, indexTools, install) were all
|
|
185
|
+
* unlocked; ensureIndexFresh's own lock on the same path stays compatible —
|
|
186
|
+
* it drives buildCodeIndexInner directly while it holds the lock.
|
|
187
|
+
* Fail-closed: a lock that cannot be acquired inside the wait budget throws
|
|
188
|
+
* IndexBuildLockTimeoutError instead of publishing unlocked.
|
|
189
|
+
*/
|
|
145
190
|
export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = null, discoverySnapshot = null, signal = null, deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS } = {}) {
|
|
191
|
+
// A signal aborted before we even look at the lock must classify exactly
|
|
192
|
+
// like a discovery cancellation: callers/tests rely on the typed
|
|
193
|
+
// IndexDiscoveryTimeoutError(aborted) and on no artifact directory ever
|
|
194
|
+
// appearing for a build that never started.
|
|
195
|
+
if (signal?.aborted) {
|
|
196
|
+
throw new IndexDiscoveryTimeoutError({ deadlineMs, phase: 'build', elapsedMs: 0, aborted: true });
|
|
197
|
+
}
|
|
198
|
+
const absoluteRoot = path.resolve(rootDir);
|
|
199
|
+
const lockBase = path.join(getIndexDir(absoluteRoot), INDEX_BUILD_LOCK_NAME);
|
|
200
|
+
const outcome = await withAsyncLock(
|
|
201
|
+
lockBase,
|
|
202
|
+
{ signal, deadlineMs: INDEX_BUILD_LOCK_WAIT_MS },
|
|
203
|
+
() => buildCodeIndexInner({ rootDir: absoluteRoot, changedFiles, discoverySnapshot, signal, deadlineMs }),
|
|
204
|
+
);
|
|
205
|
+
if (outcome?.ok === true) {
|
|
206
|
+
return outcome.value;
|
|
207
|
+
}
|
|
208
|
+
if (outcome?.reason === 'aborted') {
|
|
209
|
+
throw new IndexCancelledError({ phase: 'build-lock' });
|
|
210
|
+
}
|
|
211
|
+
throw new IndexBuildLockTimeoutError({
|
|
212
|
+
waitedMs: outcome?.waitedMs ?? INDEX_BUILD_LOCK_WAIT_MS,
|
|
213
|
+
lockPath: `${lockBase}.lock`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function buildCodeIndexInner({ rootDir = process.cwd(), changedFiles = null, discoverySnapshot = null, signal = null, deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS } = {}) {
|
|
146
218
|
const absoluteRoot = path.resolve(rootDir);
|
|
147
219
|
const indexDir = getIndexDir(absoluteRoot);
|
|
148
220
|
|
|
@@ -153,11 +225,22 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
|
|
|
153
225
|
// full path below — correctness wins over the optimization, and incremental
|
|
154
226
|
// output must stay equivalent to a clean full rebuild.
|
|
155
227
|
const changedHintPlan = normalizeChangedFileHints(changedFiles);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
228
|
+
let incrementalFallbackReason = changedHintPlan && !changedHintPlan.ok
|
|
229
|
+
? 'unsafe-changed-file-hints'
|
|
230
|
+
: null;
|
|
231
|
+
if (changedHintPlan?.ok) {
|
|
232
|
+
const incremental = await tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths: changedHintPlan.paths, signal });
|
|
233
|
+
if (incremental?.rejected) {
|
|
234
|
+
// C92-B-02: a rejected merge is no longer silent. The reason lands on the
|
|
235
|
+
// build summary, and corruption-class rejections are also warned so the
|
|
236
|
+
// CLI callers that ignore the summary still surface it.
|
|
237
|
+
incrementalFallbackReason = incremental.detail
|
|
238
|
+
? `${incremental.rejected} (${incremental.detail})`
|
|
239
|
+
: incremental.rejected;
|
|
240
|
+
if (incremental.detail) {
|
|
241
|
+
console.warn(`[index] incremental refresh rejected: meta.artifactItemCounts ${incremental.rejected} (${incremental.detail}) — falling back to full rebuild`);
|
|
242
|
+
}
|
|
243
|
+
} else if (incremental) {
|
|
161
244
|
return incremental;
|
|
162
245
|
}
|
|
163
246
|
}
|
|
@@ -531,6 +614,11 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
|
|
|
531
614
|
reusedAnalogs: canReuseAnalogs,
|
|
532
615
|
preservedRuntimeCaches,
|
|
533
616
|
mode: 'full',
|
|
617
|
+
// C92-B-02: when changed-file hints were offered but the incremental merge
|
|
618
|
+
// refused them, this names the rejection (e.g.
|
|
619
|
+
// 'artifact-item-counts-mismatch (files.json: recorded 4, actual 5)') so
|
|
620
|
+
// the fallback is diagnosable instead of silently returning 'full' forever.
|
|
621
|
+
incrementalFallbackReason,
|
|
534
622
|
parsedFiles: filesToParse.slice(),
|
|
535
623
|
removedFiles: [],
|
|
536
624
|
};
|
|
@@ -654,20 +742,11 @@ function isValidCoveredItems(items, fileRecords, keyFn) {
|
|
|
654
742
|
}
|
|
655
743
|
|
|
656
744
|
/**
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
* match the array actually loaded — a mismatch (or a pre-count meta) falls back
|
|
662
|
-
* to full discovery.
|
|
745
|
+
* C92-B-02: every rejection from tryIncrementalIndexUpdate carries a reason
|
|
746
|
+
* (and, for integrity failures, a detail string) instead of a bare null, so a
|
|
747
|
+
* torn or corrupted artifact set lands on the build summary as
|
|
748
|
+
* incrementalFallbackReason rather than silently forcing 'full' forever.
|
|
663
749
|
*/
|
|
664
|
-
function artifactItemCountsMatch(metaArtifact, counts) {
|
|
665
|
-
const recorded = metaArtifact?.artifactItemCounts;
|
|
666
|
-
if (!recorded || typeof recorded !== 'object' || Array.isArray(recorded)) {
|
|
667
|
-
return false;
|
|
668
|
-
}
|
|
669
|
-
return Object.entries(counts).every(([name, count]) => Number(recorded[name]) === count);
|
|
670
|
-
}
|
|
671
750
|
|
|
672
751
|
/**
|
|
673
752
|
* Stat every hinted path and classify it as an upsert, a removal, or — for
|
|
@@ -801,7 +880,7 @@ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths,
|
|
|
801
880
|
[INDEX_ARTIFACTS.calls]: callsArtifact?.items?.length ?? -1,
|
|
802
881
|
[INDEX_ARTIFACTS.relations]: relationsArtifact?.items?.length ?? -1,
|
|
803
882
|
};
|
|
804
|
-
const
|
|
883
|
+
const previousArtifactsSchemaAndShapeUsable = [
|
|
805
884
|
metaArtifact,
|
|
806
885
|
filesArtifact,
|
|
807
886
|
symbolsArtifact,
|
|
@@ -816,16 +895,29 @@ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths,
|
|
|
816
895
|
&& isValidCoveredItems(importsArtifact.items, filesArtifact.items, (item) => item.from)
|
|
817
896
|
&& isValidCoveredItems(callsArtifact.items, filesArtifact.items, (item) => item.filePath)
|
|
818
897
|
&& isValidCoveredItems(relationsArtifact.items, filesArtifact.items, (item) => item.filePath)
|
|
819
|
-
&& Array.isArray(relationsArtifact?.sourceSnapshot?.styleFiles)
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
898
|
+
&& Array.isArray(relationsArtifact?.sourceSnapshot?.styleFiles);
|
|
899
|
+
if (!previousArtifactsSchemaAndShapeUsable) {
|
|
900
|
+
return { rejected: 'previous-artifacts-unusable' };
|
|
901
|
+
}
|
|
902
|
+
// C92-B-02: the count gate used to fold silently into the same null as every
|
|
903
|
+
// other unusability, so a torn publish permanently disabled incremental
|
|
904
|
+
// refresh with no diagnostic. Name the artifacts that disagree with meta.
|
|
905
|
+
const recordedCounts = metaArtifact?.artifactItemCounts;
|
|
906
|
+
const countsRecorded = recordedCounts && typeof recordedCounts === 'object' && !Array.isArray(recordedCounts);
|
|
907
|
+
const countMismatchDetail = !countsRecorded
|
|
908
|
+
? 'artifactItemCounts missing from meta.json'
|
|
909
|
+
: Object.entries(artifactItemCounts)
|
|
910
|
+
.filter(([name, actual]) => Number(recordedCounts[name]) !== actual)
|
|
911
|
+
.map(([name, actual]) => `${name}: recorded ${recordedCounts[name] ?? 'missing'}, actual ${actual}`)
|
|
912
|
+
.join(', ');
|
|
913
|
+
if (!countsRecorded || countMismatchDetail) {
|
|
914
|
+
return { rejected: 'artifact-item-counts-mismatch', detail: countMismatchDetail };
|
|
823
915
|
}
|
|
824
916
|
|
|
825
917
|
const previousRecordsByPath = new Map(filesArtifact.items.map((item) => [item.filePath, item]));
|
|
826
918
|
const hintUpdates = await resolveChangedHintUpdates(absoluteRoot, changedPaths, previousRecordsByPath);
|
|
827
919
|
if (!hintUpdates) {
|
|
828
|
-
return
|
|
920
|
+
return { rejected: 'changed-hints-unresolvable' };
|
|
829
921
|
}
|
|
830
922
|
|
|
831
923
|
// Merge the hint outcome into one sorted record set — the same shape and
|
|
@@ -894,7 +986,7 @@ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths,
|
|
|
894
986
|
}
|
|
895
987
|
}, signal, 'stat-style'));
|
|
896
988
|
if (styleEntries.some((entry) => entry?.statError)) {
|
|
897
|
-
return
|
|
989
|
+
return { rejected: 'style-stat-unresolvable' };
|
|
898
990
|
}
|
|
899
991
|
const styleFilePaths = styleEntries.filter(Boolean).map((entry) => normalizeRelative(absoluteRoot, entry.absolutePath));
|
|
900
992
|
|
|
@@ -2869,7 +2961,12 @@ async function evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, e
|
|
|
2869
2961
|
|| Number.isNaN(maxAgeMs)
|
|
2870
2962
|
|| maxAgeMs < 0
|
|
2871
2963
|
|| (now - effectiveGeneratedAtMs) >= maxAgeMs
|
|
2872
|
-
|| !metaArtifact?.sourceFingerprint
|
|
2964
|
+
|| !metaArtifact?.sourceFingerprint
|
|
2965
|
+
// C92-B-03: an artifact set written under a different INDEX_SCHEMA_VERSION
|
|
2966
|
+
// — older OR newer — is not fresh. `!==` covers the package-downgrade
|
|
2967
|
+
// direction too; serving the old schema made `outline:` silently vanish
|
|
2968
|
+
// while ranking kept answering.
|
|
2969
|
+
|| metaArtifact?.schemaVersion !== INDEX_SCHEMA_VERSION;
|
|
2873
2970
|
|
|
2874
2971
|
// Legacy boolean callers (query-index, triage, verify-context) stop here and
|
|
2875
2972
|
// let buildCodeIndex do its own discovery when a rebuild is needed.
|
|
@@ -377,6 +377,9 @@ export async function applyFixLoopEscalation({
|
|
|
377
377
|
} catch {
|
|
378
378
|
batchResult = null;
|
|
379
379
|
}
|
|
380
|
+
if (!isPlainObject(batchResult)) {
|
|
381
|
+
batchResult = { status: 'unavailable', fallbackCode: 'adapter-failed', answers: [] };
|
|
382
|
+
}
|
|
380
383
|
}
|
|
381
384
|
|
|
382
385
|
const modelLane = batchResult
|
package/src/index/gitHooks.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
1
2
|
import fs from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
|
|
4
|
-
import { escapeRegExp } from '../core/fileOps.js';
|
|
5
|
+
import { escapeRegExp, writeFileAtomic, withFileLock } from '../core/fileOps.js';
|
|
5
6
|
|
|
6
7
|
const HOOK_NAMES = ['post-commit', 'post-merge', 'post-checkout'];
|
|
7
8
|
const BEGIN = '# UKit index auto-refresh (begin)';
|
|
8
9
|
const END = '# UKit index auto-refresh (end)';
|
|
9
10
|
|
|
10
11
|
export async function installIndexRefreshHooks({ projectRoot }) {
|
|
11
|
-
const hooksDir =
|
|
12
|
+
const hooksDir = resolveHooksDir(projectRoot);
|
|
12
13
|
await ensureHooksDir(hooksDir);
|
|
13
14
|
|
|
14
15
|
let installed = 0;
|
|
@@ -16,19 +17,31 @@ export async function installIndexRefreshHooks({ projectRoot }) {
|
|
|
16
17
|
|
|
17
18
|
for (const hookName of HOOK_NAMES) {
|
|
18
19
|
const hookPath = path.join(hooksDir, hookName);
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
20
|
+
// C90-20: serialize the read-modify-write — two ukit processes touching the
|
|
21
|
+
// same hook file interleave without a lock, and writeFileAtomic's tmp+rename
|
|
22
|
+
// guarantees a crash mid-write never publishes a torn hook to git.
|
|
23
|
+
const result = await withFileLock(hookPath, async () => {
|
|
24
|
+
const existing = await readTextOrEmpty(hookPath);
|
|
25
|
+
const base = existing || '#!/bin/sh\n';
|
|
26
|
+
|
|
27
|
+
// C91-007 review fix: a COMPLETE block is only 'unchanged' when its content
|
|
28
|
+
// matches the current generated block byte-for-byte. Pre-C90-17 installs
|
|
29
|
+
// produced a complete but synchronous, deadline-free block — marker-only
|
|
30
|
+
// detection would keep counting it 'unchanged' forever and the deadline
|
|
31
|
+
// fix would never reach exactly the users who already installed.
|
|
32
|
+
const existingBlock = extractManagedBlock(base);
|
|
33
|
+
const wanted = buildManagedBlock();
|
|
34
|
+
if (existingBlock === wanted) return 'unchanged';
|
|
35
|
+
|
|
36
|
+
const cleanedBase = removeManagedBlockFragments(base);
|
|
37
|
+
const next = `${cleanedBase.trimEnd()}\n\n${wanted}\n`;
|
|
38
|
+
await writeFileAtomic(hookPath, next);
|
|
39
|
+
await fs.chmod(hookPath, 0o755);
|
|
40
|
+
return 'installed';
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (result === 'installed') installed += 1;
|
|
44
|
+
else unchanged += 1; // includes lock-wait-expired (dropped mutation, journaled)
|
|
32
45
|
}
|
|
33
46
|
|
|
34
47
|
return {
|
|
@@ -39,7 +52,7 @@ export async function installIndexRefreshHooks({ projectRoot }) {
|
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
export async function removeIndexRefreshHooks({ projectRoot }) {
|
|
42
|
-
const hooksDir =
|
|
55
|
+
const hooksDir = resolveHooksDir(projectRoot);
|
|
43
56
|
await ensureHooksDir(hooksDir);
|
|
44
57
|
|
|
45
58
|
let removed = 0;
|
|
@@ -47,17 +60,19 @@ export async function removeIndexRefreshHooks({ projectRoot }) {
|
|
|
47
60
|
|
|
48
61
|
for (const hookName of HOOK_NAMES) {
|
|
49
62
|
const hookPath = path.join(hooksDir, hookName);
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
const result = await withFileLock(hookPath, async () => {
|
|
64
|
+
const existing = await readTextOrEmpty(hookPath);
|
|
65
|
+
if (!existing || !hasCompleteManagedBlock(existing)) return 'unchanged';
|
|
66
|
+
|
|
67
|
+
const next = removeManagedBlock(existing);
|
|
68
|
+
const cleaned = next.trim().length === 0 ? '#!/bin/sh\n' : `${next.trimEnd()}\n`;
|
|
69
|
+
await writeFileAtomic(hookPath, cleaned);
|
|
70
|
+
await fs.chmod(hookPath, 0o755);
|
|
71
|
+
return 'removed';
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (result === 'removed') removed += 1;
|
|
75
|
+
else unchanged += 1;
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
return {
|
|
@@ -73,11 +88,40 @@ function buildManagedBlock() {
|
|
|
73
88
|
'ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0',
|
|
74
89
|
'SCRIPT="$ROOT/.claude/ukit/index/refresh-index.mjs"',
|
|
75
90
|
'[ -f "$SCRIPT" ] || exit 0',
|
|
76
|
-
|
|
91
|
+
// C90-17: never run the rebuild synchronously inside a git operation — the
|
|
92
|
+
// refresh is backgrounded (`&`) and armed with the same UKIT_HOOK_DEADLINE_MS
|
|
93
|
+
// convention other UKit hooks use. The sleeper kills the node child if it
|
|
94
|
+
// wedges (stalled mount, pathological enumeration); the env var also reaches
|
|
95
|
+
// the child so a deadline-aware script can exit cleanly first.
|
|
96
|
+
// Non-numeric or empty env values are sanitized to the default (same
|
|
97
|
+
// ''|*[!0-9]* guard as completion-gate.sh) — otherwise `sleep $((...))`
|
|
98
|
+
// aborts the killer subshell and the refresh runs unbounded.
|
|
99
|
+
'case "$UKIT_HOOK_DEADLINE_MS" in \'\'|*[!0-9]*) UKIT_HOOK_DEADLINE_MS=30000 ;; esac',
|
|
100
|
+
'export UKIT_HOOK_DEADLINE_MS',
|
|
101
|
+
'node "$SCRIPT" >/dev/null 2>&1 &',
|
|
102
|
+
'UKIT_REFRESH_PID=$!',
|
|
103
|
+
// The killer sleeps past the deadline before signaling, so the PID was
|
|
104
|
+
// captured before the delay — a recycled PID is only at risk in the narrow
|
|
105
|
+
// window after node exits, and `kill -0` closes most of it (kills the
|
|
106
|
+
// signal if our child is already gone; a recycled PID reused in-window is
|
|
107
|
+
// a documented theoretical residual).
|
|
108
|
+
'( sleep $((UKIT_HOOK_DEADLINE_MS / 1000 + 1)) 2>/dev/null; kill -0 "$UKIT_REFRESH_PID" 2>/dev/null && kill -9 "$UKIT_REFRESH_PID" 2>/dev/null ) </dev/null >/dev/null 2>&1 &',
|
|
77
109
|
END,
|
|
78
110
|
].join('\n');
|
|
79
111
|
}
|
|
80
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Extract the complete managed block (BEGIN..END inclusive), or null when the
|
|
115
|
+
* block is absent, partial, or the markers are misordered.
|
|
116
|
+
*/
|
|
117
|
+
function extractManagedBlock(content) {
|
|
118
|
+
const beginIndex = content.indexOf(BEGIN);
|
|
119
|
+
if (beginIndex < 0) return null;
|
|
120
|
+
const endIndex = content.indexOf(END, beginIndex + BEGIN.length);
|
|
121
|
+
if (endIndex < 0) return null;
|
|
122
|
+
return content.slice(beginIndex, endIndex + END.length);
|
|
123
|
+
}
|
|
124
|
+
|
|
81
125
|
function hasCompleteManagedBlock(content) {
|
|
82
126
|
const beginIndex = content.indexOf(BEGIN);
|
|
83
127
|
const endIndex = content.indexOf(END, beginIndex + BEGIN.length);
|
|
@@ -113,6 +157,32 @@ function removeManagedBlockFragments(content) {
|
|
|
113
157
|
return kept.join('\n').replace(/\n{3,}/g, '\n\n');
|
|
114
158
|
}
|
|
115
159
|
|
|
160
|
+
/**
|
|
161
|
+
* C90-03: resolve the hooks dir through git, never from `.git` layout — linked
|
|
162
|
+
* worktrees have a `.git` FILE and `core.hooksPath` relocates the dir entirely.
|
|
163
|
+
* `git rev-parse --git-path hooks` honors both. Output may be relative (resolved
|
|
164
|
+
* against the cwd we spawned in = projectRoot) or absolute. Falls back to the
|
|
165
|
+
* classic `<root>/.git/hooks` when git isn't usable in this root, preserving
|
|
166
|
+
* the pre-C90 behavior for stub fixtures.
|
|
167
|
+
*/
|
|
168
|
+
function resolveHooksDir(projectRoot) {
|
|
169
|
+
try {
|
|
170
|
+
const result = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], {
|
|
171
|
+
cwd: projectRoot,
|
|
172
|
+
encoding: 'utf8',
|
|
173
|
+
timeout: 5000,
|
|
174
|
+
maxBuffer: 64 * 1024,
|
|
175
|
+
});
|
|
176
|
+
const resolved = (result.stdout ?? '').trim();
|
|
177
|
+
if (!result.error && result.status === 0 && resolved) {
|
|
178
|
+
return path.isAbsolute(resolved) ? resolved : path.resolve(projectRoot, resolved);
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
// git unavailable — fall through to the legacy path
|
|
182
|
+
}
|
|
183
|
+
return path.join(projectRoot, '.git', 'hooks');
|
|
184
|
+
}
|
|
185
|
+
|
|
116
186
|
async function ensureHooksDir(hooksDir) {
|
|
117
187
|
try {
|
|
118
188
|
const stat = await fs.stat(hooksDir);
|
|
@@ -120,7 +190,7 @@ async function ensureHooksDir(hooksDir) {
|
|
|
120
190
|
throw new Error();
|
|
121
191
|
}
|
|
122
192
|
} catch {
|
|
123
|
-
throw new Error(
|
|
193
|
+
throw new Error(`No git hooks directory found at ${hooksDir}. Run this inside a git project.`);
|
|
124
194
|
}
|
|
125
195
|
}
|
|
126
196
|
|
|
@@ -111,11 +111,17 @@ export function resolveImportSpecifier({
|
|
|
111
111
|
indexedFileSet,
|
|
112
112
|
aliasContext,
|
|
113
113
|
}) {
|
|
114
|
-
if (!specifier
|
|
114
|
+
if (typeof specifier !== 'string' || !specifier.trim()) {
|
|
115
115
|
return null;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
if (specifier.startsWith('.')) {
|
|
119
|
+
// A relative specifier cannot be resolved without the importing file, so a
|
|
120
|
+
// malformed (non-string) edge.from must skip the edge instead of throwing
|
|
121
|
+
// inside path.posix.dirname.
|
|
122
|
+
if (typeof fromFilePath !== 'string') {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
119
125
|
const fromDir = path.posix.dirname(fromFilePath);
|
|
120
126
|
const base = path.posix.normalize(path.posix.join(fromDir, specifier));
|
|
121
127
|
return resolveCandidateBase(base, indexedFileSet);
|
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
// locked by tests/consistency/registryParity.test.js.
|
|
19
19
|
|
|
20
20
|
import fs from 'node:fs/promises';
|
|
21
|
-
import fsSync from 'node:fs';
|
|
22
21
|
import os from 'node:os';
|
|
23
22
|
import path from 'node:path';
|
|
24
23
|
import { fileURLToPath } from 'node:url';
|
|
@@ -78,12 +77,14 @@ const AUTONOMOUS_TRIGGER_RE = /\b(finish (?:this|the) backlog|bounded (?:long )?
|
|
|
78
77
|
const RELEASE_TRIGGER_RE = /\b(publish|ship it|release|bump (?:the )?version|deploy|cut a release|tag (?:the )?release)\b/i;
|
|
79
78
|
const HANDOFF_CYCLE_TRIGGER_RE = /\b(handoff|multi[- ]?task cycle|task pipeline|execute the plan|worktree)\b/i;
|
|
80
79
|
|
|
81
|
-
// session-pickup discriminator needs the resumable-record check.
|
|
82
|
-
|
|
80
|
+
// session-pickup discriminator needs the resumable-record check. ASYNC scan
|
|
81
|
+
// (W3-PB2): the resolve path runs inside hook processes where a sync
|
|
82
|
+
// readdirSync parks the event loop and can starve the hook self-deadline.
|
|
83
|
+
async function hasResumableRecord(projectRoot) {
|
|
83
84
|
if (!projectRoot) return false;
|
|
84
85
|
try {
|
|
85
86
|
const runsDir = path.join(projectRoot, '.ukit', 'storage', 'runs');
|
|
86
|
-
const entries =
|
|
87
|
+
const entries = await fs.readdir(runsDir, { withFileTypes: true });
|
|
87
88
|
return entries.some((entry) => entry.isFile() && entry.name.endsWith('.json'));
|
|
88
89
|
} catch {
|
|
89
90
|
return false;
|
|
@@ -289,7 +290,7 @@ function matchFirst(text, regexes) {
|
|
|
289
290
|
return -1;
|
|
290
291
|
}
|
|
291
292
|
|
|
292
|
-
function buildRouteSignals({
|
|
293
|
+
async function buildRouteSignals({
|
|
293
294
|
signalText,
|
|
294
295
|
executionMode,
|
|
295
296
|
escalationTriggers,
|
|
@@ -358,7 +359,7 @@ function buildRouteSignals({
|
|
|
358
359
|
testHarness: TEST_HARNESS_RE.test(signalText),
|
|
359
360
|
measurable: /\d/.test(signalText) || MEASURABLE_RE.test(signalText),
|
|
360
361
|
dataMove: DATA_MOVE_RE.test(signalText),
|
|
361
|
-
hasResumableRecord: hasResumableRecord(projectRoot),
|
|
362
|
+
hasResumableRecord: await hasResumableRecord(projectRoot),
|
|
362
363
|
// BL-026 review fix: ask to convert/author a skill — beats defect verbs in
|
|
363
364
|
// the same sentence ("make this repeated fix flow a skill").
|
|
364
365
|
skillAsk: SKILL_ASK_RE.test(signalText),
|
|
@@ -376,14 +377,17 @@ const MEASURABLE_RE = /\b(latency|baseline|benchmark|profile|cpu|memory|heap|tak
|
|
|
376
377
|
// REQUIRED — a registry file without them is malformed (FR-007), where the
|
|
377
378
|
// workflow-policy loader tolerates filename-stem ids.
|
|
378
379
|
function parsePlaybookMeta(raw, filePath) {
|
|
379
|
-
|
|
380
|
+
// W3-PB1: normalize CRLF before fence matching — a Windows-authored
|
|
381
|
+
// frontmatter block is still frontmatter, not malformed.
|
|
382
|
+
const text = String(raw).replace(/\r\n/g, '\n');
|
|
383
|
+
if (!text.startsWith('---\n')) {
|
|
380
384
|
return null;
|
|
381
385
|
}
|
|
382
|
-
const fenceIndex =
|
|
386
|
+
const fenceIndex = text.indexOf('\n---', 3);
|
|
383
387
|
if (fenceIndex === -1) {
|
|
384
388
|
return null; // unterminated frontmatter
|
|
385
389
|
}
|
|
386
|
-
const metaText =
|
|
390
|
+
const metaText = text.slice(4, fenceIndex);
|
|
387
391
|
const meta = {};
|
|
388
392
|
for (const line of metaText.split('\n')) {
|
|
389
393
|
const match = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
|
|
@@ -408,7 +412,7 @@ function parsePlaybookMeta(raw, filePath) {
|
|
|
408
412
|
if (lanes.length === 0) {
|
|
409
413
|
return null;
|
|
410
414
|
}
|
|
411
|
-
const body =
|
|
415
|
+
const body = text.slice(fenceIndex + 4).replace(/^\n/, '');
|
|
412
416
|
if (body.trim() === '') {
|
|
413
417
|
return null; // empty body → not a playbook
|
|
414
418
|
}
|
|
@@ -553,7 +557,7 @@ export async function resolvePlaybookId({
|
|
|
553
557
|
...(Array.isArray(escalationTriggers) ? escalationTriggers : []),
|
|
554
558
|
...deriveEscalationTriggers(floor),
|
|
555
559
|
])];
|
|
556
|
-
const signals = buildRouteSignals({
|
|
560
|
+
const signals = await buildRouteSignals({
|
|
557
561
|
signalText,
|
|
558
562
|
executionMode,
|
|
559
563
|
escalationTriggers: mergedTriggers,
|