@ngockhoale/ukit 3.4.28 → 3.4.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/manifests/documentation.yaml +88 -0
  3. package/package.json +1 -1
  4. package/src/core/buildPlan.js +11 -1
  5. package/src/core/permissionPolicy.js +1 -1
  6. package/src/core/status.js +3 -1
  7. package/src/index/buildIndex.js +244 -4
  8. package/src/index/impactContext.js +91 -11
  9. package/src/index/languageTools.js +126 -0
  10. package/src/index/paths.js +5 -1
  11. package/src/index/playbookRegistry.js +108 -9
  12. package/src/index/queryIndex.js +415 -1
  13. package/src/index/routeCatalog.js +1 -1
  14. package/src/index/taskRouting.js +25 -3
  15. package/template_project/.claude/commands/ukit/handoff-create.md +5 -0
  16. package/template_project/.claude/commands/ukit/handoff-fullstack.md +43 -4
  17. package/template_project/.claude/hooks/block-dangerous.mjs +7 -1
  18. package/template_project/.claude/hooks/handoff-model-guard.sh +235 -21
  19. package/template_project/.claude/hooks/record-execution.mjs +23 -0
  20. package/template_project/.claude/hooks/skill-router.sh +105 -7
  21. package/template_project/.claude/settings.json +21 -1
  22. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
  23. package/template_project/.claude/ukit/index/impact-context.mjs +70 -15
  24. package/template_project/.claude/ukit/index/land-worktree.mjs +2 -2
  25. package/template_project/.claude/ukit/index/lib/index-core.mjs +883 -14
  26. package/template_project/.claude/ukit/index/playbook-registry.mjs +108 -9
  27. package/template_project/.claude/ukit/index/query-index.mjs +100 -7
  28. package/template_project/.claude/ukit/index/route-catalog.mjs +1 -1
  29. package/template_project/.claude/ukit/index/route-task.mjs +208 -31
  30. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +455 -24
  31. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +122 -6
  32. package/template_project/.claude/ukit/runtime/resumable-run.mjs +417 -0
  33. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +635 -28
  34. package/template_project/.omp/hooks/pre/ukit-bridge.js +32 -4
  35. package/template_user/playbooks/feature-implementation.md +9 -0
  36. package/template_user/playbooks/migration.md +4 -0
@@ -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 { buildSearchDescriptor } from './languageTools.js';
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|table|view|index|trigger|function|stored procedure|materialized view|query plan|explain)\b/i, score: 5 },
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 },
@@ -113,6 +113,15 @@ If the user says "new task", re-route — do not treat the message as the next s
113
113
  4. Implement the smallest change satisfying the predicate.
114
114
  5. Verify against the predicate on the real artifact — not "it compiles".
115
115
  6. Widen once: check the impact surface the route named, no broader.
116
+ Reference reuse: when the ask points at an existing surface ("like the Customer
117
+ page", "giống trang X", "same as module Y"), read that surface first and mirror
118
+ its structure — the reference defines the done predicate, not just inspiration.
119
+ Reuse-first: when the ask names an existing function, helper, or pattern
120
+ ("using validation from Z", "dùng hàm có sẵn"), reuse it — extend or wrap
121
+ before writing new logic, and name the reuse in the report.
122
+ Clone: when the ask duplicates a feature ("clone the Customer page", "nhân bản
123
+ tính năng X"), copy the source feature's structure, rename and adapt each
124
+ surface, and verify the clone against the same predicate class as the original.
116
125
  Reply: what changed, the predicate, the evidence it now holds.
117
126
  Ask the human only for: irreversible writes, a genuine preference call no experiment
118
127
  settles, or a real dead end. Everything else: do it, report it.`,
@@ -871,8 +880,16 @@ function deriveExecutionScores({
871
880
  } = {}) {
872
881
  const raw = `${promptText ?? ''}\n${commandText ?? ''}`.toLowerCase();
873
882
  const sharedRisk = isSharedImpactFile(targetFile);
874
- const directTransformSignal = /\b(change|replace|set|rename|convert|swap)\b/.test(raw) || /\bchange\s+.+\s+to\s+.+\b/.test(raw);
875
- const smallFixSignal = /\b(adjust|tweak|patch|fix|modify|update|apply)\b/.test(raw);
883
+ // Vietnamese edit verbs (\b never matches around diacritics — JS word chars
884
+ // are ASCII-only), so a small Vietnamese edit like "đổi màu nút thành đỏ"
885
+ // or "sửa cột …" scores as the small mutating task it is instead of
886
+ // falling to the all-zero shared-edit floor.
887
+ 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);
888
+ const directTransformSignal = /\b(change|replace|set|rename|convert|swap)\b/.test(raw)
889
+ || /\bchange\s+.+\s+to\s+.+\b/.test(raw)
890
+ // "đổi X thành Y" is the Vietnamese "change X to Y".
891
+ || /(?<![A-Za-z0-9_])(?:đổi|doi|sửa|sua|đặt|dat)\s+[^\n,.;]{1,48}?\s+(?:thành|thanh|to)\s+/.test(raw);
892
+ const smallFixSignal = /\b(adjust|tweak|patch|fix|modify|update|apply)\b/.test(raw) || vietnameseEditVerb;
876
893
  const buildSignal = /\b(build|create|add|implement|feature)\b/.test(raw);
877
894
  const debugSignal = intentMode === 'debug-specific' || /\b(root cause|debug|triage|flaky|investigate|why)\b/.test(raw);
878
895
  const reviewSignal = intentMode === 'review-specific' || /\b(review|audit|verify|release readiness)\b/.test(raw);
@@ -880,6 +897,7 @@ function deriveExecutionScores({
880
897
  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
898
  const boundedEditSignal = /\b(one[- ]line|small|tiny|single|local|guard|log line|loading message|label)\b/.test(raw);
882
899
  const implementSignal = /\b(implement|apply|update|modify|add|create|ship|deliver|fix)\b/.test(raw)
900
+ || vietnameseEditVerb
883
901
  || /\bchange\s+.+\s+to\s+.+\b/.test(raw);
884
902
  const explicitTarget = Boolean(targetFile);
885
903
 
@@ -1082,6 +1100,8 @@ function hasAdvisoryOnlyAssertion({ promptText = '', commandText = '' } = {}) {
1082
1100
  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
1101
  const residue = trimmed
1084
1102
  .replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
1103
+ // "tạo plan" / "create a plan": the plan is the deliverable, not a mutation order.
1104
+ .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
1105
  .replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
1086
1106
  .replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
1087
1107
  .replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
@@ -1193,6 +1213,8 @@ function hasMutationOrder({ promptText = '', commandText = '' } = {}) {
1193
1213
  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
1214
  const residue = trimmed
1195
1215
  .replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
1216
+ // "tạo plan" / "create a plan": the plan is the deliverable, not a mutation order.
1217
+ .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
1218
  // Descriptive state, not an order: "đã/vừa [được] + verb" ("hooks vừa được cài đặt") reports
1197
1219
  // something already done. Only the verb is stripped, so a later imperative still survives.
1198
1220
  .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 +1837,7 @@ function hasConcreteTaskSignal(lower, raw, targetFile, { taskQueueNext = false }
1815
1837
  }
1816
1838
 
1817
1839
  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);
1840
+ || /(?<![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
1841
  }
1820
1842
 
1821
1843
  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>`. The stop gate evaluates it
78
- mechanically when the cursor claims `Phase: done` — a `done` claim with failing terms
79
- is bounced back with the per-term state. Keep the line verbatim on every rewrite.
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
- scan = command.replace(/'[^']*'/g, "'Q'").replace(/"[^"]*"/g, '"Q"');
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) {