@ngockhoale/ukit 3.4.29 → 3.4.31
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 +29 -0
- package/manifests/documentation.yaml +100 -0
- package/package.json +1 -1
- package/src/core/executionContracts.js +32 -0
- package/src/core/permissionPolicy.js +1 -1
- package/src/decision/client.js +15 -0
- package/src/decision/protocol.js +12 -14
- package/src/index/buildIndex.js +244 -4
- package/src/index/impactContext.js +91 -11
- package/src/index/languageTools.js +126 -0
- package/src/index/paths.js +5 -1
- package/src/index/playbookRegistry.js +108 -9
- package/src/index/queryIndex.js +415 -1
- package/src/index/routeCatalog.js +1 -1
- package/src/index/routeResolver.js +2 -2
- package/src/index/taskRouting.js +28 -4
- package/template_project/.claude/commands/ukit/handoff-create.md +5 -0
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +43 -4
- package/template_project/.claude/hooks/block-dangerous.mjs +7 -1
- package/template_project/.claude/hooks/handoff-model-guard.sh +235 -21
- package/template_project/.claude/hooks/record-execution.mjs +21 -0
- package/template_project/.claude/hooks/skill-router.sh +187 -16
- package/template_project/.claude/settings.json +43 -1
- package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
- package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +10 -4
- package/template_project/.claude/ukit/index/impact-context.mjs +70 -15
- package/template_project/.claude/ukit/index/land-worktree.mjs +2 -2
- package/template_project/.claude/ukit/index/lib/index-core.mjs +883 -14
- package/template_project/.claude/ukit/index/playbook-registry.mjs +108 -9
- package/template_project/.claude/ukit/index/query-index.mjs +100 -7
- package/template_project/.claude/ukit/index/review-verdict.mjs +14 -4
- package/template_project/.claude/ukit/index/route-catalog.mjs +1 -1
- package/template_project/.claude/ukit/index/route-resolver.mjs +34 -2
- package/template_project/.claude/ukit/index/route-task.mjs +243 -38
- package/template_project/.claude/ukit/index/sidecar-decision.mjs +3 -59
- package/template_project/.claude/ukit/index/unic-decision.mjs +27 -14
- package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +43 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +612 -56
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +122 -6
- package/template_project/.claude/ukit/runtime/loop-state.mjs +1 -1
- package/template_project/.claude/ukit/runtime/operation-evidence.mjs +87 -0
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +417 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +635 -28
- package/template_project/.omp/hooks/pre/ukit-bridge.js +40 -5
- package/template_project/AGENTS.md +1 -1
- package/template_project/CLAUDE.md +1 -1
- package/template_project/instructions/core.md +1 -1
- package/template_user/playbooks/feature-implementation.md +9 -0
- package/template_user/playbooks/migration.md +4 -0
package/src/index/queryIndex.js
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
3
4
|
|
|
4
5
|
import { getArtifactPath, getIndexDir, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION, isLikelyTestFilePath } from './paths.js';
|
|
5
6
|
import { importsNeedAliasContext, loadImportAliasContext, resolveImportSpecifier } from './importResolution.js';
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
buildSearchDescriptor,
|
|
9
|
+
boundedDamerauDistance,
|
|
10
|
+
expandIdentifierQueryVariants,
|
|
11
|
+
isCompactCharSubsequence,
|
|
12
|
+
isOrderedTokenSubsequence,
|
|
13
|
+
normalizeIdentifierTokens,
|
|
14
|
+
FUZZY_EDIT_DISTANCE_MIN_LENGTH,
|
|
15
|
+
} from './languageTools.js';
|
|
7
16
|
|
|
8
17
|
const ARTIFACT_CACHE = new Map();
|
|
9
18
|
const QUERY_SEARCH_BUNDLE_CACHE = new Map();
|
|
@@ -361,15 +370,30 @@ function scoreDirectFileMatch({ queryDescriptor, filePath, searchDescriptor }) {
|
|
|
361
370
|
reasons.push(`symbol_compact:${symbolDescriptor.name}`);
|
|
362
371
|
}
|
|
363
372
|
|
|
373
|
+
let symbolContributed = false;
|
|
374
|
+
|
|
364
375
|
for (const token of queryDescriptor.tokens) {
|
|
365
376
|
if (symbolDescriptor.descriptor.tokens.has(token)) {
|
|
366
377
|
score += 3;
|
|
378
|
+
symbolContributed = true;
|
|
367
379
|
reasons.push(`symbol_token:${symbolDescriptor.name}:${token}`);
|
|
368
380
|
} else if (symbolDescriptor.descriptor.rawLower.includes(token)) {
|
|
369
381
|
score += 1;
|
|
382
|
+
symbolContributed = true;
|
|
370
383
|
reasons.push(`symbol_contains:${symbolDescriptor.name}`);
|
|
371
384
|
}
|
|
372
385
|
}
|
|
386
|
+
|
|
387
|
+
// Fuzzy floor for misspelt or compacted symbol queries ('custmer_code' →
|
|
388
|
+
// 'customer_code'): subsequence or bounded edit-distance match on the
|
|
389
|
+
// already-precomputed compacts. Only fires when the symbol earned nothing
|
|
390
|
+
// from the stronger tiers above.
|
|
391
|
+
if (!symbolContributed && queryDescriptor.compact.length >= 6) {
|
|
392
|
+
if (symbolFuzzyMatches(queryDescriptor.compact, symbolDescriptor.descriptor.compact)) {
|
|
393
|
+
score += 3;
|
|
394
|
+
reasons.push(`symbol_fuzzy:${symbolDescriptor.name}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
373
397
|
}
|
|
374
398
|
|
|
375
399
|
return {
|
|
@@ -391,6 +415,396 @@ function groupBy(items, keyFn) {
|
|
|
391
415
|
return map;
|
|
392
416
|
}
|
|
393
417
|
|
|
418
|
+
// Fuzzy predicate shared by scoreDirectFileMatch and resolveSymbolCandidates:
|
|
419
|
+
// ordered containment (either direction, on tokens or compacts) or a bounded
|
|
420
|
+
// Damerau distance. Cheap length gates run first so the common non-fuzzy case
|
|
421
|
+
// never builds a distance matrix.
|
|
422
|
+
function symbolFuzzyMatches(queryCompact, symbolCompact) {
|
|
423
|
+
if (!queryCompact || !symbolCompact) {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
if (Math.abs(queryCompact.length - symbolCompact.length) > 2) {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
if (isCompactCharSubsequence(queryCompact, symbolCompact) || isCompactCharSubsequence(symbolCompact, queryCompact)) {
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
if (
|
|
433
|
+
queryCompact.length >= FUZZY_EDIT_DISTANCE_MIN_LENGTH
|
|
434
|
+
&& symbolCompact.length >= FUZZY_EDIT_DISTANCE_MIN_LENGTH
|
|
435
|
+
&& boundedDamerauDistance(queryCompact, symbolCompact, 2) <= 2
|
|
436
|
+
) {
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ── Symbol-level resolution ──
|
|
443
|
+
// Ranks symbol rows against a (possibly misspelt or phrase-form) query:
|
|
444
|
+
// exact > normalized (case/underscore/hyphen/camel-insensitive) > ordered
|
|
445
|
+
// token/compact subsequence > bounded edit distance. Fuzzy-only results never
|
|
446
|
+
// auto-substitute: they carry confidence 'fuzzy' and needsConfirmation, and any
|
|
447
|
+
// tie inside `scoreMargin` marks the whole leading band ambiguous.
|
|
448
|
+
export function resolveSymbolCandidates(query, symbols, { limit = 8, scoreMargin = 5 } = {}) {
|
|
449
|
+
const normalizedQuery = String(query ?? '').trim();
|
|
450
|
+
if (!normalizedQuery || !Array.isArray(symbols)) {
|
|
451
|
+
return [];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const baseTokens = normalizeIdentifierTokens(normalizedQuery);
|
|
455
|
+
if (baseTokens.length === 0) {
|
|
456
|
+
return [];
|
|
457
|
+
}
|
|
458
|
+
const baseCompact = baseTokens.join('');
|
|
459
|
+
const variants = expandIdentifierQueryVariants(normalizedQuery);
|
|
460
|
+
|
|
461
|
+
const rows = [];
|
|
462
|
+
const seen = new Set();
|
|
463
|
+
for (const symbol of symbols) {
|
|
464
|
+
if (!symbol || typeof symbol?.name !== 'string') {
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const symbolTokens = normalizeIdentifierTokens(symbol.name);
|
|
468
|
+
if (symbolTokens.length === 0) {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
const dedupeKey = `${symbol.name}\u0000${symbol.filePath ?? ''}\u0000${symbol.parent ?? ''}\u0000${symbol.schema ?? ''}`;
|
|
472
|
+
if (seen.has(dedupeKey)) {
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
seen.add(dedupeKey);
|
|
476
|
+
|
|
477
|
+
const match = scoreSymbolCandidate({
|
|
478
|
+
query: normalizedQuery,
|
|
479
|
+
baseTokens,
|
|
480
|
+
baseCompact,
|
|
481
|
+
variants,
|
|
482
|
+
symbol,
|
|
483
|
+
symbolTokens,
|
|
484
|
+
symbolCompact: symbolTokens.join(''),
|
|
485
|
+
});
|
|
486
|
+
if (match) {
|
|
487
|
+
rows.push(match);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
rows.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name) || String(a.filePath ?? '').localeCompare(String(b.filePath ?? '')));
|
|
492
|
+
|
|
493
|
+
const top = rows.slice(0, Math.max(limit, 0));
|
|
494
|
+
const topScore = top[0]?.score ?? 0;
|
|
495
|
+
const ambiguousBand = top.filter((candidate) => topScore - candidate.score <= scoreMargin);
|
|
496
|
+
const ambiguous = ambiguousBand.length > 1;
|
|
497
|
+
|
|
498
|
+
return top.map((candidate) => ({
|
|
499
|
+
...candidate,
|
|
500
|
+
needsConfirmation: candidate.confidence === 'fuzzy' || ambiguous,
|
|
501
|
+
}));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function scoreSymbolCandidate({ query, baseTokens, baseCompact, variants, symbol, symbolTokens, symbolCompact }) {
|
|
505
|
+
const row = {
|
|
506
|
+
name: symbol.name,
|
|
507
|
+
filePath: symbol.filePath ?? null,
|
|
508
|
+
line: Number.isFinite(symbol.line) ? symbol.line : null,
|
|
509
|
+
kind: symbol.type ?? 'symbol',
|
|
510
|
+
parent: symbol.parent ?? null,
|
|
511
|
+
schema: symbol.schema ?? null,
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
if (symbol.name === query) {
|
|
515
|
+
return { ...row, score: 100, reason: 'exact', confidence: 'exact' };
|
|
516
|
+
}
|
|
517
|
+
if (symbolCompact === baseCompact) {
|
|
518
|
+
return { ...row, score: 90, reason: 'normalized', confidence: 'high' };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
let best = null;
|
|
522
|
+
for (const variantTokens of variants) {
|
|
523
|
+
const variantCompact = variantTokens.join('');
|
|
524
|
+
if (isOrderedTokenSubsequence(variantTokens, symbolTokens)) {
|
|
525
|
+
const candidate = { score: 70, reason: 'token-subsequence' };
|
|
526
|
+
if (!best || candidate.score > best.score) {
|
|
527
|
+
best = candidate;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (isCompactCharSubsequence(variantCompact, symbolCompact)) {
|
|
531
|
+
const candidate = { score: 65, reason: 'compact-subsequence' };
|
|
532
|
+
if (!best || candidate.score > best.score) {
|
|
533
|
+
best = candidate;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (
|
|
537
|
+
variantCompact.length >= FUZZY_EDIT_DISTANCE_MIN_LENGTH
|
|
538
|
+
&& symbolCompact.length >= FUZZY_EDIT_DISTANCE_MIN_LENGTH
|
|
539
|
+
) {
|
|
540
|
+
const distance = boundedDamerauDistance(variantCompact, symbolCompact, 2);
|
|
541
|
+
if (distance <= 2) {
|
|
542
|
+
const candidate = { score: 60 - distance * 10, reason: `edit-distance:${distance}` };
|
|
543
|
+
if (!best || candidate.score > best.score) {
|
|
544
|
+
best = candidate;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (!best) {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
return { ...row, ...best, confidence: 'fuzzy' };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Loads symbols.json (schema-gated, poisoned rows dropped) and resolves the
|
|
557
|
+
// query against every indexed symbol row, including SQL columns and
|
|
558
|
+
// global-registered frontend functions.
|
|
559
|
+
export async function querySymbolCandidates({ rootDir = process.cwd(), query, limit = 8, scoreMargin = 5 } = {}) {
|
|
560
|
+
const normalizedQuery = String(query ?? '').trim();
|
|
561
|
+
if (!normalizedQuery) {
|
|
562
|
+
return [];
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const absoluteRoot = path.resolve(rootDir);
|
|
566
|
+
const symbolsArtifact = await readArtifact(absoluteRoot, INDEX_ARTIFACTS.symbols);
|
|
567
|
+
const safeSymbolsArtifact = safeSchemaArtifact(symbolsArtifact);
|
|
568
|
+
return resolveSymbolCandidates(normalizedQuery, safeItems(safeSymbolsArtifact), { limit, scoreMargin });
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ── Fallback source search (index unavailable) ──
|
|
572
|
+
// Bounded ripgrep-first text search so query-index / impact-context can answer
|
|
573
|
+
// from source when the index is missing or its rebuild fails. `rg` runs via
|
|
574
|
+
// child_process when present; otherwise (or on rg failure) a bounded node
|
|
575
|
+
// directory walk honors the same ignore-dir set as the index builder. Rows are
|
|
576
|
+
// { filePath, line } and require EVERY query token to appear in the file.
|
|
577
|
+
export const FALLBACK_SEARCH_IGNORE_DIRS = [
|
|
578
|
+
'node_modules', '.git', '.cache', '.next', '.nuxt', '.output', '.vercel',
|
|
579
|
+
'.turbo', '.yarn', 'dist', 'build', 'out', 'coverage', 'tmp', 'temp',
|
|
580
|
+
'vendor', '.venv', 'venv',
|
|
581
|
+
];
|
|
582
|
+
const FALLBACK_SEARCH_IGNORE_DIR_SET = new Set(FALLBACK_SEARCH_IGNORE_DIRS);
|
|
583
|
+
const FALLBACK_SEARCH_EXTENSIONS = new Set([
|
|
584
|
+
'.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.sql', '.json',
|
|
585
|
+
'.md', '.yaml', '.yml', '.sh', '.css', '.scss', '.sass', '.less', '.html',
|
|
586
|
+
]);
|
|
587
|
+
const FALLBACK_SEARCH_DEFAULTS = {
|
|
588
|
+
maxFiles: 2000,
|
|
589
|
+
maxFileBytes: 512 * 1024,
|
|
590
|
+
rgTimeoutMs: 5000,
|
|
591
|
+
// Traversal bounds: maxFiles caps MATCHED candidates only, so the walk also needs a cap on
|
|
592
|
+
// everything it merely visits (huge SQL/data trees, generated dirs not in the ignore set).
|
|
593
|
+
maxEntries: 20000,
|
|
594
|
+
maxDepth: 12,
|
|
595
|
+
maxVerify: 400,
|
|
596
|
+
walkBudgetMs: 3000,
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
export async function boundedSourceSearch({
|
|
600
|
+
rootDir = process.cwd(),
|
|
601
|
+
query,
|
|
602
|
+
limit = 8,
|
|
603
|
+
maxFiles = FALLBACK_SEARCH_DEFAULTS.maxFiles,
|
|
604
|
+
maxFileBytes = FALLBACK_SEARCH_DEFAULTS.maxFileBytes,
|
|
605
|
+
engine = 'auto',
|
|
606
|
+
maxEntries = FALLBACK_SEARCH_DEFAULTS.maxEntries,
|
|
607
|
+
maxDepth = FALLBACK_SEARCH_DEFAULTS.maxDepth,
|
|
608
|
+
maxVerify = FALLBACK_SEARCH_DEFAULTS.maxVerify,
|
|
609
|
+
walkBudgetMs = FALLBACK_SEARCH_DEFAULTS.walkBudgetMs,
|
|
610
|
+
} = {}) {
|
|
611
|
+
const normalizedQuery = String(query ?? '').trim();
|
|
612
|
+
if (!normalizedQuery) {
|
|
613
|
+
return [];
|
|
614
|
+
}
|
|
615
|
+
const truncation = new Set();
|
|
616
|
+
const foldedQuery = buildSearchDescriptor(normalizedQuery).rawLower;
|
|
617
|
+
const tokens = [...new Set(normalizeIdentifierTokens(foldedQuery))];
|
|
618
|
+
if (tokens.length === 0) {
|
|
619
|
+
return [];
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const absoluteRoot = path.resolve(rootDir);
|
|
623
|
+
let candidatePaths = null;
|
|
624
|
+
if (engine === 'auto') {
|
|
625
|
+
candidatePaths = await ripgrepCandidatePaths({ absoluteRoot, tokens, maxFileBytes });
|
|
626
|
+
}
|
|
627
|
+
if (candidatePaths === null) {
|
|
628
|
+
candidatePaths = await walkCandidatePaths({
|
|
629
|
+
absoluteRoot, maxFiles, maxFileBytes, maxEntries, maxDepth, walkBudgetMs, truncation,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const rows = [];
|
|
634
|
+
let verified = 0;
|
|
635
|
+
for (const relativePath of candidatePaths) {
|
|
636
|
+
if (verified >= maxVerify) {
|
|
637
|
+
truncation.add('verify-cap');
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
verified += 1;
|
|
641
|
+
const verification = await readMatchingLine(absoluteRoot, relativePath, tokens, maxFileBytes);
|
|
642
|
+
if (!verification) {
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
rows.push({ filePath: relativePath, line: verification.line, reason: 'fallback-search' });
|
|
646
|
+
if (rows.length >= Math.max(limit, 0)) {
|
|
647
|
+
break;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
// A partial scan must never read as exhaustive: callers that care read `rows.truncated`
|
|
651
|
+
// (non-enumerable, so array equality and JSON output are unchanged).
|
|
652
|
+
Object.defineProperty(rows, 'truncated', { value: [...truncation], enumerable: false });
|
|
653
|
+
return rows;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function execFileRipgrep(args, timeoutMs) {
|
|
657
|
+
return new Promise((resolve) => {
|
|
658
|
+
execFile('rg', args, { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 }, (error, stdout) => {
|
|
659
|
+
if (error) {
|
|
660
|
+
// rg exit code 1 = "no matches" (a valid empty answer; error.code is the
|
|
661
|
+
// number 1). A spawn failure (code 'ENOENT'), a timeout, or exit ≥2
|
|
662
|
+
// means rg is unusable and the walk must take over.
|
|
663
|
+
resolve(error.code === 1 ? [] : null);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
resolve(String(stdout ?? ''));
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async function ripgrepCandidatePaths({ absoluteRoot, tokens, maxFileBytes }) {
|
|
672
|
+
const stdout = await execFileRipgrep(
|
|
673
|
+
[
|
|
674
|
+
'-i', '--no-heading', '--line-number', '--max-count', '3',
|
|
675
|
+
'--max-filesize', String(Math.round(maxFileBytes / 1024)) + 'K',
|
|
676
|
+
...FALLBACK_SEARCH_IGNORE_DIRS.flatMap((dir) => ['--glob', `!${dir}/**`]),
|
|
677
|
+
'--glob', '!**/*.min.*',
|
|
678
|
+
...tokens.flatMap((token) => ['-e', token]),
|
|
679
|
+
absoluteRoot,
|
|
680
|
+
],
|
|
681
|
+
FALLBACK_SEARCH_DEFAULTS.rgTimeoutMs,
|
|
682
|
+
);
|
|
683
|
+
if (stdout === null) {
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const paths = [];
|
|
688
|
+
const seen = new Set();
|
|
689
|
+
for (const line of stdout.split('\n')) {
|
|
690
|
+
const separator = line.indexOf(':');
|
|
691
|
+
if (separator <= 0) {
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
const matchedPath = line.slice(0, separator);
|
|
695
|
+
if (!matchedPath || seen.has(matchedPath)) {
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
seen.add(matchedPath);
|
|
699
|
+
const relativePath = path.relative(absoluteRoot, matchedPath).replace(/\\/g, '/');
|
|
700
|
+
if (relativePath && !relativePath.startsWith('..')) {
|
|
701
|
+
paths.push(relativePath);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return paths;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Depth-first walk collecting candidate text files (size- and count-bounded).
|
|
708
|
+
// Token verification happens once in boundedSourceSearch, so the walk only
|
|
709
|
+
// filters by ignore-set, extension, and size.
|
|
710
|
+
async function walkCandidatePaths({
|
|
711
|
+
absoluteRoot, maxFiles, maxFileBytes, maxEntries, maxDepth, walkBudgetMs, truncation = new Set(),
|
|
712
|
+
}) {
|
|
713
|
+
const results = [];
|
|
714
|
+
let visited = 0;
|
|
715
|
+
const deadline = Date.now() + walkBudgetMs;
|
|
716
|
+
const exhausted = () => {
|
|
717
|
+
if (results.length >= maxFiles) { truncation.add('max-files'); return true; }
|
|
718
|
+
if (visited >= maxEntries) { truncation.add('max-entries'); return true; }
|
|
719
|
+
if (Date.now() > deadline) { truncation.add('time-budget'); return true; }
|
|
720
|
+
return false;
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
const walk = async (dir, depth) => {
|
|
724
|
+
if (exhausted()) {
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
if (depth > maxDepth) {
|
|
728
|
+
truncation.add('max-depth');
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
let entries;
|
|
732
|
+
try {
|
|
733
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
734
|
+
} catch {
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
738
|
+
if (exhausted()) {
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
visited += 1;
|
|
742
|
+
if (entry.isSymbolicLink()) {
|
|
743
|
+
continue; // never follow links out of the tree (cycles, escapes)
|
|
744
|
+
}
|
|
745
|
+
const entryPath = path.join(dir, entry.name);
|
|
746
|
+
if (entry.isDirectory()) {
|
|
747
|
+
if (FALLBACK_SEARCH_IGNORE_DIR_SET.has(entry.name) || entry.name.startsWith('.')) {
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
await walk(entryPath, depth + 1);
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
if (!entry.isFile() || !FALLBACK_SEARCH_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
try {
|
|
757
|
+
const stat = await fs.stat(entryPath);
|
|
758
|
+
if (stat.size > maxFileBytes) {
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
} catch {
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
results.push(path.relative(absoluteRoot, entryPath).replace(/\\/g, '/'));
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
await walk(absoluteRoot, 0);
|
|
769
|
+
return results;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function readMatchingLine(absoluteRoot, relativePath, tokens, maxFileBytes) {
|
|
773
|
+
if (!relativePath) {
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
let content;
|
|
777
|
+
try {
|
|
778
|
+
content = await fs.readFile(path.join(absoluteRoot, relativePath), 'utf8');
|
|
779
|
+
} catch {
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
if (content.length > maxFileBytes) {
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
const foldedContent = buildSearchDescriptor(content.slice(0, 64 * 1024)).rawLower;
|
|
786
|
+
if (!tokens.every((token) => foldedContent.includes(token))) {
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
const rawLines = content.split('\n');
|
|
790
|
+
const foldedLines = foldedContent.split('\n');
|
|
791
|
+
for (const [index, rawLine] of rawLines.entries()) {
|
|
792
|
+
const foldedLine = foldedLines[index] ?? '';
|
|
793
|
+
if (tokens.every((token) => foldedLine.includes(token))) {
|
|
794
|
+
return { line: index + 1 };
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
// All tokens present in the file but never on one line — still a hit; point
|
|
798
|
+
// at the first line containing any token.
|
|
799
|
+
for (const [index, rawLine] of rawLines.entries()) {
|
|
800
|
+
const foldedLine = foldedLines[index] ?? '';
|
|
801
|
+
if (tokens.some((token) => foldedLine.includes(token))) {
|
|
802
|
+
return { line: index + 1 };
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return { line: null };
|
|
806
|
+
}
|
|
807
|
+
|
|
394
808
|
function buildFileSearchDescriptors(files, symbolMap) {
|
|
395
809
|
const descriptors = new Map();
|
|
396
810
|
|
|
@@ -68,7 +68,7 @@ export const ROUTE_CATALOG = [
|
|
|
68
68
|
path: '.claude/skills/postgres/SKILL.md',
|
|
69
69
|
order: 4.5,
|
|
70
70
|
signals: [
|
|
71
|
-
{ type: 'prompt', regex: /\b(sql|postgres|postgresql|migration|schema|
|
|
71
|
+
{ type: 'prompt', regex: /\b(sql|postgres|postgresql|migration|schema|trigger|stored (?:procedure|function)|materialized view|query plan|explain|(?:create|alter|drop|truncate) (?:or replace )?(?:table|view|index|function))\b/i, score: 5 },
|
|
72
72
|
{ type: 'command', regex: /\b(psql|prisma|drizzle|knex|sequelize|typeorm)\b/i, score: 4 },
|
|
73
73
|
{ type: 'file', regex: /\.(?:sql|psql)$/i, score: 5 },
|
|
74
74
|
{ type: 'file', regex: /(?:^|\/)(?:SQL\/|sql\/|database\/|db\/|migrations?\/|prisma\/schema\.prisma$)/i, score: 4 },
|
|
@@ -509,12 +509,12 @@ function unique(values) {
|
|
|
509
509
|
|
|
510
510
|
// Completion contract for a lane. Informational routes carry no completion debt;
|
|
511
511
|
// every contract lane returns the still-missing evidence classes plus a reason.
|
|
512
|
-
export function buildCompletionState({ executionMode = null, verificationRecommendation = null } = {}) {
|
|
512
|
+
export function buildCompletionState({ executionMode = null, verificationRecommendation = null, executionContract = null } = {}) {
|
|
513
513
|
if (!executionMode || executionMode === 'informational') {
|
|
514
514
|
return null;
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
-
const contract = buildExecutionContract(executionMode);
|
|
517
|
+
const contract = executionContract || buildExecutionContract(executionMode);
|
|
518
518
|
const missingEvidence = [...(contract?.completionEvidence ?? [])];
|
|
519
519
|
const requiresVerification = missingEvidence.includes('verification-evidence');
|
|
520
520
|
if (
|
package/src/index/taskRouting.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { classifyTaskSemantics, applyTaskSemantics } from '../core/executionContracts.js';
|
|
1
2
|
import fs from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
|
|
@@ -113,6 +114,15 @@ If the user says "new task", re-route — do not treat the message as the next s
|
|
|
113
114
|
4. Implement the smallest change satisfying the predicate.
|
|
114
115
|
5. Verify against the predicate on the real artifact — not "it compiles".
|
|
115
116
|
6. Widen once: check the impact surface the route named, no broader.
|
|
117
|
+
Reference reuse: when the ask points at an existing surface ("like the Customer
|
|
118
|
+
page", "giống trang X", "same as module Y"), read that surface first and mirror
|
|
119
|
+
its structure — the reference defines the done predicate, not just inspiration.
|
|
120
|
+
Reuse-first: when the ask names an existing function, helper, or pattern
|
|
121
|
+
("using validation from Z", "dùng hàm có sẵn"), reuse it — extend or wrap
|
|
122
|
+
before writing new logic, and name the reuse in the report.
|
|
123
|
+
Clone: when the ask duplicates a feature ("clone the Customer page", "nhân bản
|
|
124
|
+
tính năng X"), copy the source feature's structure, rename and adapt each
|
|
125
|
+
surface, and verify the clone against the same predicate class as the original.
|
|
116
126
|
Reply: what changed, the predicate, the evidence it now holds.
|
|
117
127
|
Ask the human only for: irreversible writes, a genuine preference call no experiment
|
|
118
128
|
settles, or a real dead end. Everything else: do it, report it.`,
|
|
@@ -660,10 +670,11 @@ export function buildRouteSummary({
|
|
|
660
670
|
executionScores,
|
|
661
671
|
executionCandidates,
|
|
662
672
|
});
|
|
663
|
-
const executionContract = buildExecutionContract(executionMode);
|
|
673
|
+
const executionContract = applyTaskSemantics(buildExecutionContract(executionMode), classifyTaskSemantics({ promptText: routingContext.lastExplicitUserPromptText || routingContext.promptText, targetFile: routingContext.targetFile }));
|
|
664
674
|
const continuousExecution = buildContinuousExecutionPolicy(autonomyLevel);
|
|
665
675
|
const completionState = buildCompletionState({
|
|
666
676
|
executionMode,
|
|
677
|
+
executionContract,
|
|
667
678
|
verificationRecommendation,
|
|
668
679
|
});
|
|
669
680
|
const continuationState = buildContinuationState({
|
|
@@ -871,8 +882,16 @@ function deriveExecutionScores({
|
|
|
871
882
|
} = {}) {
|
|
872
883
|
const raw = `${promptText ?? ''}\n${commandText ?? ''}`.toLowerCase();
|
|
873
884
|
const sharedRisk = isSharedImpactFile(targetFile);
|
|
874
|
-
|
|
875
|
-
|
|
885
|
+
// Vietnamese edit verbs (\b never matches around diacritics — JS word chars
|
|
886
|
+
// are ASCII-only), so a small Vietnamese edit like "đổi màu nút thành đỏ"
|
|
887
|
+
// or "sửa cột …" scores as the small mutating task it is instead of
|
|
888
|
+
// falling to the all-zero shared-edit floor.
|
|
889
|
+
const vietnameseEditVerb = /(?<![A-Za-z0-9_])(?:sửa|sua|đổi|doi|thay|chỉnh|chinh|cập\s+nhật|cap\s+nhat)(?![A-Za-z0-9_])/.test(raw);
|
|
890
|
+
const directTransformSignal = /\b(change|replace|set|rename|convert|swap)\b/.test(raw)
|
|
891
|
+
|| /\bchange\s+.+\s+to\s+.+\b/.test(raw)
|
|
892
|
+
// "đổi X thành Y" is the Vietnamese "change X to Y".
|
|
893
|
+
|| /(?<![A-Za-z0-9_])(?:đổi|doi|sửa|sua|đặt|dat)\s+[^\n,.;]{1,48}?\s+(?:thành|thanh|to)\s+/.test(raw);
|
|
894
|
+
const smallFixSignal = /\b(adjust|tweak|patch|fix|modify|update|apply)\b/.test(raw) || vietnameseEditVerb;
|
|
876
895
|
const buildSignal = /\b(build|create|add|implement|feature)\b/.test(raw);
|
|
877
896
|
const debugSignal = intentMode === 'debug-specific' || /\b(root cause|debug|triage|flaky|investigate|why)\b/.test(raw);
|
|
878
897
|
const reviewSignal = intentMode === 'review-specific' || /\b(review|audit|verify|release readiness)\b/.test(raw);
|
|
@@ -880,6 +899,7 @@ function deriveExecutionScores({
|
|
|
880
899
|
const impactSignal = /\b(map impact|impact|blast radius|all affected|across (?:runtime|templates|helpers|adapters|mirrors)|affected adapters|affected mirrors|inspect impact)\b/.test(raw);
|
|
881
900
|
const boundedEditSignal = /\b(one[- ]line|small|tiny|single|local|guard|log line|loading message|label)\b/.test(raw);
|
|
882
901
|
const implementSignal = /\b(implement|apply|update|modify|add|create|ship|deliver|fix)\b/.test(raw)
|
|
902
|
+
|| vietnameseEditVerb
|
|
883
903
|
|| /\bchange\s+.+\s+to\s+.+\b/.test(raw);
|
|
884
904
|
const explicitTarget = Boolean(targetFile);
|
|
885
905
|
|
|
@@ -1082,6 +1102,8 @@ function hasAdvisoryOnlyAssertion({ promptText = '', commandText = '' } = {}) {
|
|
|
1082
1102
|
const WAIT_TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and|nhung|roi|then|later|sau\\s+do)\\s+[^.,;\\n]*?\\b(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
|
|
1083
1103
|
const residue = trimmed
|
|
1084
1104
|
.replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
|
|
1105
|
+
// "tạo plan" / "create a plan": the plan is the deliverable, not a mutation order.
|
|
1106
|
+
.replace(/\b(?:tao|lap|create|make|write|draft)\s+(?:cho\s+toi\s+)?(?:a\s+|an\s+|the\s+|1\s+|mot\s+)?(?:plan|ke\s+hoach|phuong\s+an|lo\s+trinh|roadmap)\b/g, ' ')
|
|
1085
1107
|
.replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
|
|
1086
1108
|
.replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
|
|
1087
1109
|
.replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
|
|
@@ -1193,6 +1215,8 @@ function hasMutationOrder({ promptText = '', commandText = '' } = {}) {
|
|
|
1193
1215
|
const WAIT_TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and|nhung|roi|then|later|sau\\s+do)\\s+[^.,;\\n]*?\\b(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
|
|
1194
1216
|
const residue = trimmed
|
|
1195
1217
|
.replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
|
|
1218
|
+
// "tạo plan" / "create a plan": the plan is the deliverable, not a mutation order.
|
|
1219
|
+
.replace(/\b(?:tao|lap|create|make|write|draft)\s+(?:cho\s+toi\s+)?(?:a\s+|an\s+|the\s+|1\s+|mot\s+)?(?:plan|ke\s+hoach|phuong\s+an|lo\s+trinh|roadmap)\b/g, ' ')
|
|
1196
1220
|
// Descriptive state, not an order: "đã/vừa [được] + verb" ("hooks vừa được cài đặt") reports
|
|
1197
1221
|
// something already done. Only the verb is stripped, so a later imperative still survives.
|
|
1198
1222
|
.replace(/(?<![a-z0-9_])(?:da|vua)\s+(?:duoc\s+)?(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\s+the|cap\s+nhat|viet|chay|cai|chinh|trien\s+khai|cau\s+hinh)(?![a-z0-9_])/g, ' ')
|
|
@@ -1815,7 +1839,7 @@ function hasConcreteTaskSignal(lower, raw, targetFile, { taskQueueNext = false }
|
|
|
1815
1839
|
}
|
|
1816
1840
|
|
|
1817
1841
|
return /\b(bug|debug|error|crash|broken|failing|stack trace|triage|fix|implement|build|create|add|ship|deliver|refactor|integrate|integration|scaffold|feature|update|change|modify|inject|apply|review|audit|diff|pr feedback|code review|auth|login|api|endpoint|test|spec)\b/.test(lower)
|
|
1818
|
-
|| /(?<![A-Za-z0-9_])(sửa|fix|lỗi|bug|debug|implement|cài|thêm|review|kiểm tra|soát|đăng nhập)(?![A-Za-z0-9_])/.test(raw);
|
|
1842
|
+
|| /(?<![A-Za-z0-9_])(sửa|đổi|thay|chỉnh|fix|lỗi|bug|debug|implement|cài|thêm|review|kiểm tra|soát|đăng nhập)(?![A-Za-z0-9_])/.test(raw);
|
|
1819
1843
|
}
|
|
1820
1844
|
|
|
1821
1845
|
function hasDocsSpecificTaskSignal(lower, raw, targetFile, { taskQueueNext = false } = {}) {
|
|
@@ -132,6 +132,11 @@ by reading, and record the resolution in `PLAN.md §3`.
|
|
|
132
132
|
Record every answer verbatim in `PLAN.md §1 Intent`. Downstream phases treat that section as
|
|
133
133
|
the human's own words and never re-ask.
|
|
134
134
|
|
|
135
|
+
**Headless / non-interactive** (`-p`/print mode, no interactive question tool, or an unattended
|
|
136
|
+
run): there is nobody to answer, so do not ask. Take each question's `(Recommended)` option,
|
|
137
|
+
record every defaulted choice in `PLAN.md §1 Intent` as `Assumed (headless): …`, and continue.
|
|
138
|
+
Ending the turn on a question with no plan written is a failed create.
|
|
139
|
+
|
|
135
140
|
After the batched call, run Steps 1–2.5 to completion without further questions.
|
|
136
141
|
|
|
137
142
|
---
|
|
@@ -74,9 +74,32 @@ QuietScans: <n>/<required> # only while sweeping for stragglers near the end
|
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
`ExitPredicate` grammar: `all(<term>[,<term>...])` or a single term; terms are
|
|
77
|
-
`index:all-done`, `git:clean`, `file-exists:<relpath
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
`index:all-done`, `git:clean`, `file-exists:<relpath>`; goal-level runs add
|
|
78
|
+
`agents:none-running` and `verify:pass` (an unprovable term reads as unmet, never
|
|
79
|
+
as an error). The stop gate evaluates it mechanically when the cursor claims
|
|
80
|
+
`Phase: done` — a `done` claim with failing terms is bounced back with the
|
|
81
|
+
per-term state. A `done` claim with NO `ExitPredicate` line evaluates the default
|
|
82
|
+
`all(index:all-done)` over the current backlog (a project with no `INDEX.md` at
|
|
83
|
+
all keeps the plain phase-only release). Keep the line verbatim on every rewrite.
|
|
84
|
+
|
|
85
|
+
**Goal status vs cycle.** The run reports two layers: the CYCLE (the `Phase:`/
|
|
86
|
+
`Cursor:` lines above) and the GOAL — `Status: RUNNING|PAUSED|BLOCKED|COMPLETED`,
|
|
87
|
+
derived mechanically from the cursor, the current `INDEX.md` backlog (including
|
|
88
|
+
review-created repair tasks and tasks added mid-run), running agents, and
|
|
89
|
+
verification evidence. `COMPLETED` is only ever derived — cursor `Phase: done`,
|
|
90
|
+
every in-scope task `done`/`cancelled_superseded`, `agents:none-running`,
|
|
91
|
+
`verify:pass`, dependency-consistent (no task `done` behind an unfinished
|
|
92
|
+
dependency), final snapshot consistent — never claimed from model text. An
|
|
93
|
+
optional `Paused: yes` line marks a quota/user pause: the gate releases instead
|
|
94
|
+
of forcing continuation, and nothing resumes until an explicit instruction.
|
|
95
|
+
|
|
96
|
+
**Run-all rule.** When the invocation is a run-all instruction, the run cursor records `GoalScope: run-all`; a finalized (header-only `INDEX.md`) cycle then does NOT release the stop while consumable `queued/` blueprints remain or the queue is unreadable — open the next cycle. A run-all instruction ("run all remaining tasks", "finish
|
|
97
|
+
everything", "chạy hết các task còn lại") binds the GOAL, not one cycle: a
|
|
98
|
+
`Phase: done` cursor over an unfinished backlog is bounced straight back with the
|
|
99
|
+
default `all(index:all-done)` until the backlog is empty. A single-task prompt
|
|
100
|
+
("run task 3") never triggers the goal loop. The loop stays bounded: the stall
|
|
101
|
+
breaker (unchanged cursor AND task snapshot) and the per-task
|
|
102
|
+
`maxAttemptsPerFailure` cap release a run that keeps failing the same wall.
|
|
80
103
|
|
|
81
104
|
This file is the resume contract. It costs one small write per step and is what turns an
|
|
82
105
|
interrupted run into a continuable one.
|
|
@@ -350,6 +373,8 @@ git add docs/AI_HANDOFF/ && git commit -m "handoff: plan — <goal>"
|
|
|
350
373
|
|
|
351
374
|
Replace `<goal>` with the one-sentence goal from ACTIVE.md. This locks the plan in git before any implementation begins.
|
|
352
375
|
|
|
376
|
+
If the lite agent reports it has no shell/Bash tool (or its shell call is rejected), run the same two git commands yourself in the main session and continue — a missing shell on the sidecar lane is never a blocker for P3. Only `blocked` the run if the main session's own `git commit` fails.
|
|
377
|
+
|
|
353
378
|
> **For the human operator, on a tool with no agent support (Codex) — not an instruction to the model:** manually switch to the lite model for P2/P3.
|
|
354
379
|
|
|
355
380
|
---
|
|
@@ -745,11 +770,24 @@ When `handoff.fullstack.autoArchive` is true (default), archive per `handoff-cle
|
|
|
745
770
|
conventions so a future session needs no transcript to understand the cycle:
|
|
746
771
|
|
|
747
772
|
1. Move the completed cycle's records (PLAN.md, SPEC.md, task files, ACTIVE.md snapshot)
|
|
748
|
-
into `docs/AI_HANDOFF/archive/cycle-<NN
|
|
773
|
+
into `docs/AI_HANDOFF/archive/cycle-<NN>/`, then — BEFORE resetting `INDEX.md` — run
|
|
774
|
+
`node .claude/ukit/runtime/resumable-run.mjs archive-cycle . <NN>`. It writes the verified
|
|
775
|
+
INDEX rows to `archive/cycle-<NN>/INDEX.md` and stamps each terminal task header, so the
|
|
776
|
+
stop predicate reads the INDEX status, not a stale executor-written header. It is
|
|
777
|
+
idempotent; after an interrupted archive just run it again.
|
|
749
778
|
2. If `archive/` holds more than 3 cycle folders → fold the oldest into a one-line
|
|
750
779
|
summary in `HISTORY.md` and remove it.
|
|
751
780
|
3. Reset `ACTIVE.md` to its empty template; clear `INDEX.md` rows to a fresh header;
|
|
752
781
|
remove `tasks/TASK-*.md`; clear `PLAN.md`/`SPEC.md` (templates stay).
|
|
782
|
+
**Predicate terms:** the stop gate's `index:all-done` accepts a header-only INDEX when
|
|
783
|
+
every task in `archive/cycle-<NN>/` is terminal-success (`done`/`cancelled_superseded`)
|
|
784
|
+
according to that cycle's INDEX snapshot (legacy archives without one fall back to the task
|
|
785
|
+
header). A finalized cycle is NOT run-all completion: under `GoalScope: run-all` the
|
|
786
|
+
predicate still fails while `queued/<slug>/` blueprints (not `QueueIntent:
|
|
787
|
+
deliberate-future`) remain. Keep archived task files until the goal is complete. When the
|
|
788
|
+
`ExitPredicate` contains `verify:pass`, run the final verification command through the
|
|
789
|
+
shell in THIS session after the last edit (the receipt is per-session; a pipe without
|
|
790
|
+
`pipefail` is unproven).
|
|
753
791
|
Then **verify the write** on each reset file (read back the empty header /
|
|
754
792
|
template marker).
|
|
755
793
|
4. Commit the docs + archive changes:
|
|
@@ -759,6 +797,7 @@ conventions so a future session needs no transcript to understand the cycle:
|
|
|
759
797
|
|
|
760
798
|
Set `Phase: done` in `docs/AI_HANDOFF/RUN.md`, disarm any watchdog job armed at the
|
|
761
799
|
start, and emit the Final Report below.
|
|
800
|
+
|
|
762
801
|
Then **verify the write** (read back `Phase: done`) — a lost cursor write
|
|
763
802
|
leaves the next session resuming a finished run.
|
|
764
803
|
|
|
@@ -207,7 +207,13 @@ async function evaluate({ command, projectRoot, env, deadlineMs, signal }) {
|
|
|
207
207
|
const cmdHead = normalizeExecWord(head);
|
|
208
208
|
let scan = command;
|
|
209
209
|
if (SAFE_TEXT_HEADS.has(cmdHead) && !PIPE_TO_SHELL_RE.test(command)) {
|
|
210
|
-
|
|
210
|
+
// A quoted-delimiter heredoc body (<<'EOF') is inert literal text — no
|
|
211
|
+
// expansion, no execution. Unquoted delimiters expand $(...) so they stay
|
|
212
|
+
// scanned. Only the body is dropped; the command line itself is still scanned.
|
|
213
|
+
scan = command
|
|
214
|
+
.replace(/(<<-?[ \t]*(['"])([A-Za-z_][\w]*)\2[^\n]*\n)[\s\S]*?\n[ \t]*\3[ \t]*(?=\n|$)/g, '$1Q')
|
|
215
|
+
.replace(/'[^']*'/g, "'Q'")
|
|
216
|
+
.replace(/"[^"]*"/g, '"Q"');
|
|
211
217
|
}
|
|
212
218
|
|
|
213
219
|
for (const literal of DANGEROUS_LITERALS) {
|