@ngockhoale/ukit 3.4.66 → 3.4.68

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 (62) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/manifests/documentation.yaml +33 -0
  3. package/manifests/instructionRules.yaml +10 -0
  4. package/manifests/platform.full.yaml +67 -0
  5. package/package.json +1 -1
  6. package/src/core/applyPlan.js +111 -2
  7. package/src/core/buildPlan.js +3 -1
  8. package/src/core/diffPlan.js +49 -1
  9. package/src/core/executionContracts.js +51 -10
  10. package/src/core/metadata.js +45 -0
  11. package/src/core/runInstallPipeline.js +30 -2
  12. package/src/core/uninstall.js +106 -2
  13. package/src/manifest/validateManifest.js +25 -1
  14. package/template_project/.claude/agents/code-reviewer.md +22 -9
  15. package/template_project/.claude/agents/handoff-planner.md +97 -8
  16. package/template_project/.claude/commands/ukit/handoff-create.md +114 -19
  17. package/template_project/.claude/commands/ukit/handoff-fullstack.md +247 -79
  18. package/template_project/.claude/commands/ukit/handoff-implement.md +75 -40
  19. package/template_project/.claude/commands/ukit/handoff-review.md +14 -10
  20. package/template_project/.claude/hooks/auto-allow-bash.sh +73 -12
  21. package/template_project/.claude/hooks/auto-prune-bash.sh +10 -0
  22. package/template_project/.claude/hooks/delegation-gate.mjs +21 -0
  23. package/template_project/.claude/hooks/handoff-model-guard.sh +22 -6
  24. package/template_project/.claude/hooks/handoff-resume.sh +141 -1
  25. package/template_project/.claude/hooks/record-execution.mjs +140 -9
  26. package/template_project/.claude/hooks/skill-router.sh +173 -8
  27. package/template_project/.claude/settings.json +2 -2
  28. package/template_project/.claude/skills/advisor-plan/REFERENCE.md +10 -0
  29. package/template_project/.claude/skills/advisor-plan/SKILL.md +2 -2
  30. package/template_project/.claude/ukit/index/allocate-task-worktree.mjs +15 -0
  31. package/template_project/.claude/ukit/index/amend-guard.mjs +116 -0
  32. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
  33. package/template_project/.claude/ukit/index/land-worktree.mjs +243 -21
  34. package/template_project/.claude/ukit/index/plan-count.mjs +115 -0
  35. package/template_project/.claude/ukit/index/ready-gate.mjs +170 -0
  36. package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +293 -4
  37. package/template_project/.claude/ukit/index/route-resolver.mjs +50 -10
  38. package/template_project/.claude/ukit/index/route-task.mjs +180 -11
  39. package/template_project/.claude/ukit/index/source-snapshot.mjs +172 -0
  40. package/template_project/.claude/ukit/index/task-budget-validator.mjs +109 -3
  41. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +20 -3
  42. package/template_project/.claude/ukit/runtime/handoff-task-scope.mjs +205 -0
  43. package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
  44. package/template_project/.claude/ukit/runtime/plan-dispatch.mjs +32 -5
  45. package/template_project/.claude/ukit/runtime/resumable-run.mjs +310 -21
  46. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +405 -105
  47. package/template_project/.claude/ukit/runtime/task-record-reconciliation.mjs +46 -0
  48. package/template_project/.claude/ukit/runtime/task-watchdog.mjs +28 -2
  49. package/template_project/.claude/ukit/runtime/worktree-allocation.mjs +75 -0
  50. package/template_project/.codex/README.md +25 -0
  51. package/template_project/.omp/RULES.md +22 -22
  52. package/template_project/.omp/agents/code-reviewer.md +22 -9
  53. package/template_project/.omp/agents/handoff-planner.md +102 -12
  54. package/template_project/.omp/hooks/pre/ukit-bridge.js +1 -1
  55. package/template_project/AGENTS.md +5 -1
  56. package/template_project/CLAUDE.md +1 -1
  57. package/template_project/docs/AI_HANDOFF/RULES.md +8 -0
  58. package/template_project/docs/AI_HANDOFF/tasks/_TEMPLATE.md +8 -1
  59. package/template_project/instructions/core.md +5 -1
  60. package/template_project/instructions/layout.yaml +2 -0
  61. package/template_project/instructions/overlays/omp-rules.md +22 -22
  62. package/template_project/ukit/storage/config.json +1 -1
@@ -1104,10 +1104,16 @@ const VERIFICATION_GOAL = /(?:^|\n)[ \t*#_-]*(?:muc tieu|goal|purpose|objective)
1104
1104
  const CONDITIONAL_BRANCH = /(?:^|\n|\.\s+)[ \t]*(?:\d+[.)]\s*)?(?:neu\s+(?:phat hien\s+(?:false-block|loi|bug|defect)|co\s+(?:source fix|sua doi))|if\s+(?:(?:a\s+)?(?:bug|defect|failure)\s+is\s+found|(?:you\s+)?find\s+(?:a\s+)?(?:bug|defect)))\b[^\n]*?(?=\.\s+|$|\n)/g;
1105
1105
  const GOAL_LABEL = /\b(?:muc tieu|goal|purpose|objective)\s*:/g;
1106
1106
  const DISPOSABLE_SETUP = /\b(?:tao|create)\s+(?:mot\s+)?(?:project|du an)\s+(?:thu nghiem|test|disposable|doc lap)\b(?:\s+(?:doc lap|independent))?/g;
1107
+ // Round-2b (FS-01 B5): a bare "no <verb>, <verb>, ..." enumeration names operations that are NOT
1108
+ // ordered ("no commit, release, publish"). The clause splitter cuts at commas, so without this the
1109
+ // tails ("release", "publish") would look like separate imperative clauses to the request-wide order
1110
+ // guard. Only a "no"-headed verb list is consumed; a real order never starts with "no". Built lazily
1111
+ // because ORDER_VERBS is declared below this function.
1112
+ const noVerbList = () => new RegExp(`\\bno\\s+(?:${ORDER_VERBS})\\b(?:\\s*,\\s*(?:${ORDER_VERBS})\\b)*`, 'g');
1107
1113
  function neutralizeNonOrders(text) {
1108
1114
  let source = String(text).replace(/```[\s\S]*?```/g, ' ').replace(/^[ \t]*>.*$/gm, ' ');
1109
1115
  if (VERIFICATION_GOAL.test(source)) source = source.replace(GOAL_LABEL, ' ').replace(CONDITIONAL_BRANCH, '\n').replace(DISPOSABLE_SETUP, ' ');
1110
- return source
1116
+ return source.replace(noVerbList(), ' ')
1111
1117
  // A list item that DESCRIBES test cases ("Negative controls: yêu cầu thực sự chỉnh config/restart/deploy ...") is
1112
1118
  // specification of the work, not an order; it runs to the next bullet or blank line.
1113
1119
  .replace(/^[ \t]*(?:[-*+\u2022]|\d+[.)])[ \t]*(?:negative|positive)\s+controls?\b[^\n]*(?:\n(?![ \t]*(?:[-*+\u2022]|\d+[.)])|[ \t]*\n)[^\n]*)*/gm, ' ')
@@ -1122,8 +1128,25 @@ function neutralizeNonOrders(text) {
1122
1128
  .replace(/[^.;\n]*\b(?:nao|gi|sao|the nao|bao nhieu)\b[^.;\n]*\?/g, ' ')
1123
1129
  .replace(/\bchinh\s+(?:xac|sach)\b|\b(?:phuong an|cach|de xuat|ban)\s+(?:sua|fix)\b|\b(?:diem|cho|muc|phan)\s+(?:phai|can|nen)\s+(?:sua|fix)\b/g, ' ')
1124
1130
  .replace(/\bwrite\s*\/\s*verification\b|\bverification\s*\/\s*write\b|\bwrite\s+(?:or|and)\s+verification\b/g, ' ')
1131
+ .replace(/\bwrite\s+permission\b/g, 'permission')
1125
1132
  .replace(/\b(?:state|status)\s+changes?\b/g, ' ')
1126
- .replace(/\b(?:proposed|suggested|recommended|minimal|each|the|an?|any|that|this|no|which|exact|smallest|possible)\s+fix(?:es)?\b/g, ' ');
1133
+ .replace(/\b(?:proposed|suggested|recommended|minimal|each|the|an?|any|that|this|no|which|exact|smallest|possible)\s+fix(?:es)?\b/g, ' ')
1134
+ // An output/deliverable spec ("Output: fix direction", "each: title, fix direction", "Output:
1135
+ // fixes / fix list / acceptance to close") NAMES the artifacts the answer must contain; inside
1136
+ // such a clause "fix" is a deliverable noun, not a mutation order, so a read-only audit with a
1137
+ // fix-shaped deliverable list must not owe write evidence (FS-01). A determiner object inside
1138
+ // the span ("Output: fix the bug in parser.ts") stays a real order - fail closed.
1139
+ // Round-2 (FS-01 B1-B4): a span runs through a file-extension dot ("fix items.ts"), a bare singular
1140
+ // "fix" is an order (never a noun), and a span keeps its order unless EVERY fix token in it is a
1141
+ // deliverable noun and no order verb / "then" remains in the clause ("fix list, commit and push").
1142
+ .replace(/(?:^|[\n.;])[ \t]*(?:[-*+•]|\d+[.)])?[ \t]*(?:outputs?|reports?|deliverables?|each|lists?|includes?|fields?|format|sections?|columns?)(?:\s+\w+){0,3}?\s*:(?:[^\n.;]|\.(?=\w))*/g,
1143
+ (span) => {
1144
+ const fixNouns = /\bfix(?:es)?(?:\s+applied\b|\s+(?:directions?|lists?|plans?|suggestions?|proposals?|items?|entries?|notes?|findings?|acceptance|recommendations?)\b)(?=\s*(?:,|$))|\bfixes\b(?=\s*(?:,|$))/g;
1145
+ const residual = span.replace(fixNouns, ' ');
1146
+ if (residual === span) return span;
1147
+ if (new RegExp(`\\b(?:then|${NON_ORDER_VERBS}|${ORDER_VERBS})\\b`).test(residual)) return span;
1148
+ return residual;
1149
+ });
1127
1150
  }
1128
1151
 
1129
1152
  // Explicit scope declaration (intent ownership): when the request itself declares it is read-only
@@ -1133,13 +1156,25 @@ function neutralizeNonOrders(text) {
1133
1156
  // An explicit order (imperative-initial or modal request) keeps the request MIXED/IMPLEMENTATION:
1134
1157
  // the declaration never overrides a real instruction, and fenced/quoted/blockquoted text is never one.
1135
1158
  // A bare verb with no object is the tail of a negated list ("no commit, release, publish"), not an order.
1136
- const READ_ONLY_DECLARATION = /\bread[\s-]*only\s+(?:audit|investigation|review|analysis|inspection|task|request|session|pass|check|diagnos\w+|verification|forensics?)\b|(?:^|\n)[\s*#>_-]*read[\s-]*only\s*[.:!—-]|\b(?:audit|investigation|review|analysis)[\s-]*only\b|[(,]\s*read[\s-]*only\s*[).]|\bchi\s+(?:doc|audit|kiem tra|phan tich|dieu tra)\b|\b(?:make|with)\s+no\s+(?:source\s+|code\s+|file\s+)?(?:changes|edits|modifications)\b/;
1159
+ const READ_ONLY_DECLARATION = /\bread[\s-]*only(?:\s+\w+){0,3}?\s+(?:audit|investigation|review|analysis|inspection|inventory|task|request|session|pass|check|diagnos\w+|verification|forensics?|reports?)\b|(?:^|\n)[\s*#>_-]*read[\s-]*only\s*[.:!—-]|\b(?:audit|investigation|review|analysis|inventory)[\s-]*only\b|[(,]\s*read[\s-]*only\s*[).]|\bchi\s+(?:doc|audit|kiem tra|kiem ke|phan tich|dieu tra)\b|\bkhong\s+sua\b|\b(?:make|with)\s+no\s+(?:source\s+|code\s+|file\s+)?(?:changes|edits|modifications)\b|\b(?:do\s+not|don't|dont|never)\s+(?:\w+\s+){0,2}edits?\b/;
1137
1160
  const ORDER_VERBS = 'implement|fix|apply|refactor|modify|edit|write|update|add|create|build|install|deploy|redeploy|restart|push|commit|rename|remove|delete|replace|change|sua|viet|chinh|them|tao|xoa|enable|disable|set|toggle|configure|migrate|upgrade|publish|release|trien khai|khoi dong lai|(?:run|execute)\\s+(?:git\\s+(?:push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)|(?:npm|yarn)\\s+publish)|git\\s+(?:push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)';
1138
1161
  const IMPERATIVE_ORDER = new RegExp(`^(?:[-*+\\u2022>\\d.)\\s]*)(?:(?:please|hay|vui long|now|also|first|then)\\s+)*(?:${ORDER_VERBS})\\b\\s+\\S`);
1139
1162
  const MODAL_ORDER = new RegExp(`\\b(?:please|hay|vui long|need to|needs to|must|should|want(?:\\s+you)? to|toi muon|can phai|yeu cau)\\s+(?:[\\w-]+\\s+){0,2}(?:${ORDER_VERBS})\\b`);
1140
1163
  function hasExplicitOrder(clauses) {
1141
1164
  return clauses.some((clause) => IMPERATIVE_ORDER.test(clause) || MODAL_ORDER.test(clause));
1142
1165
  }
1166
+ // Round-2b (FS-01 B5): a clause AFTER the output span still orders a change ("Output: fix lists\ncommit
1167
+ // the changes", "Output: fix lists; commit"). Order detection must be request-wide, not span-local: any
1168
+ // non-negated clause whose command position is an ORDER verb - with or without an object, so a bare
1169
+ // trailing "commit" counts - is an explicit order. The vocabulary is the order class the read-only
1170
+ // declaration must never absorb (git/publish/release/apply/run-implementation); noun-ish scaffolding
1171
+ // ("tao timeline ...", "create a list") is NOT an order and must not defeat a read-only audit.
1172
+ // Negated lists ("no commit, release, publish", "Khong npm publish, git push, release") are blanked
1173
+ // first by neutralizeNonOrders, so their bare tails never read as orders.
1174
+ const CLAUSE_ORDER_LEAD = /^(?:[-*+•>\d.)\s]*)(?:(?:please|hay|vui long|now|also|first|then)\s+)*(?:commit|push(?!\s+back\b)|publish|release(?!\s+(?:notes?|process|works?|plan|candidate|checklist)\b)|apply|(?:run|execute)\s+(?:the\s+)?(?:implementation|build|migration|deploy(?:ment)?|publish|release|script|command))\b/;
1175
+ function ordersChangeAcrossClauses(clauses) {
1176
+ return clauses.some((clause) => CLAUSE_ORDER_LEAD.test(clause));
1177
+ }
1143
1178
  function declaredReadOnly(promptText, releasePolicy) {
1144
1179
  if (releasePolicy) return false;
1145
1180
  const plain = String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd').toLowerCase()
@@ -1148,9 +1183,10 @@ function declaredReadOnly(promptText, releasePolicy) {
1148
1183
  const text = neutralizeNonOrders(plain);
1149
1184
  const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/)
1150
1185
  .map((clause) => clause.split(/\bwithout\b/)[0])
1151
- .filter((clause) => !/\b(?:do not|don't|never|khong|dung)\b/.test(clause));
1152
- return /\b(?:audit|inspect|review|analyze|analyse|explain|read|investigate|investigation|forensic|root cause|kiem tra|phan tich|dieu tra|doc log|tim hieu)\b/.test(text)
1153
- && !hasExplicitOrder(clauses);
1186
+ .filter((clause) => !/\b(?:do not|don't|never|khong)\b|^[\s*+•>\d.)-]*(?:(?:vui long|xin|hay|please)\s+)*dung\b/.test(clause));
1187
+ return /\b(?:audit|inspect|review|analyze|analyse|explain|read|investigate|investigation|inventory|forensic|root cause|kiem tra|kiem ke|phan tich|dieu tra|doc log|tim hieu)\b/.test(text)
1188
+ && !hasExplicitOrder(clauses)
1189
+ && !ordersChangeAcrossClauses(clauses);
1154
1190
  }
1155
1191
 
1156
1192
  // Verification-only request. Root cause of a false `write-evidence` block on a "verify / re-run /
@@ -1158,12 +1194,12 @@ function declaredReadOnly(promptText, releasePolicy) {
1158
1194
  // "template/install", "source change") as mutation orders. Order is structural: a request whose
1159
1195
  // command-position verbs are verification verbs, with no explicit order (hasExplicitOrder) and no
1160
1196
  // change-leading clause, orders no change - it owes verification evidence, not a write.
1161
- const VERIFY_LEAD = /^(?:[-*+•>\d.)\s]*)(?:(?:please|hay|vui long|now|also|first|then)\s+)*(?:verify|re-?run|confirm|xac nhan|validate|double-?check)\b/;
1197
+ const VERIFY_LEAD = /^(?:[-*+•>\d.)\s]*)(?:(?:please|hay|vui long|now|also|first|then)\s+)*(?:(?:keep|continue|tiep tuc)\s+)?(?:verify|re-?run|confirm|xac nhan|validate|double-?check|wait(?:ing)?|monitor|watch|cho(?=\s+(?:dung\s+)?(?:job|verifier|build|test|suite|gate|ci|run|task|ket qua|xong|output)\b))\b/;
1162
1198
  const CHANGE_LEAD = /^(?:[-*+•>\d.)\s]*)(?:(?:please|hay|vui long|now|also|first|then)\s+)*(?:make|rebuild|redo|rewrite|restore|revert|correct|clean|patch|resolve|ensure|run(?!(?=[^.]*\b(?:tests?|suite|gate|checks?|lint|typecheck|pilot|smoke|e2e|verif\w*|vitest|jest|audit)\b)))\b/;
1163
1199
  export function isVerificationOnlyRequest(promptText) {
1164
1200
  const plain = String(promptText ?? '').normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[đĐ]/g, 'd').toLowerCase()
1165
1201
  .replace(/```[\s\S]*?```/g, ' ').replace(/^[ \t]*>.*$/gm, ' ');
1166
- const clauses = neutralizeNonOrders(plain).split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/)
1202
+ const clauses = neutralizeNonOrders(plain).split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b|\broi\b)\s*/)
1167
1203
  .map((clause) => clause.split(/\bwithout\b/)[0]);
1168
1204
  return clauses.some((clause) => VERIFY_LEAD.test(clause)) && !hasExplicitOrder(clauses)
1169
1205
  && !clauses.some((clause) => CHANGE_LEAD.test(clause));
@@ -1194,7 +1230,8 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
1194
1230
  ? { kind: 'conditional-release', preflight } : null;
1195
1231
  const text = neutralizeBannedLists(neutralizeNonOrders(String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd').toLowerCase()));
1196
1232
  const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/);
1197
- const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong|dung)\b/.test(clause)
1233
+ // Folded đúng (correctly) is also "dung"; only command-position đừng negates an order.
1234
+ const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong)\b|^[\s*+•>\d.)-]*(?:(?:vui long|xin|hay|please)\s+)*dung\b/.test(clause)
1198
1235
  && !/^[\s*+•>\d.)-]*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
1199
1236
  if (declaredReadOnly(promptText, releasePolicy)) return { taskKind: 'READ_ONLY' };
1200
1237
  const sourceOrder = actionable.some((clause) => /\b(?:implement|fix(?:es)?|apply|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
@@ -1294,8 +1331,11 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
1294
1331
  const statusReport = (configReportQuestion || (!mutationWord
1295
1332
  && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua|da len chua)\b/.test(text)))
1296
1333
  && !operation && !config;
1334
+ // Round-2b (FS-01 B5): order detection is request-wide. A later clause that commands an order verb
1335
+ // ("Output: fix lists\ncommit the changes", "...; publish") keeps the mutation even when the output
1336
+ // span itself was neutralised to deliverable nouns; a true noun-only audit output stays read-only.
1297
1337
  const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|investigate|debug|forensic|root cause|kiem tra|phan tich|dieu tra|doc log|tim hieu)\b/.test(text.replace(/\bread[\s-]*only\b/g, ' ')) || statusReport)
1298
- && !sourceOrder && !codeChangeVerb && !operation && !config;
1338
+ && !sourceOrder && !codeChangeVerb && !operation && !config && !ordersChangeAcrossClauses(actionable);
1299
1339
  const taskKind = operation ? (sourceOrder || codeChangeVerb ? 'MIXED' : 'OPERATIONAL')
1300
1340
  : config ? 'CONFIGURATION' : documentation ? 'DOCUMENTATION_OUTPUT' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
1301
1341
  const id = text.match(/\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/)?.[0] || null;
@@ -1134,30 +1134,183 @@ const HOOK_STATE_SOURCES = ['skill-router', 'skill-router-provisional'];
1134
1134
  // Mirror of execution-ledger.mjs isCompletionGateContinuationText (kept local so the CLI stays
1135
1135
  // import-free). Shape only: trust is decided by the caller's bound pending marker, never by this match.
1136
1136
  const GATE_CONTINUATION_PREFIX = /^UKit completion gate:/;
1137
- const GATE_CONTINUATION_DIAGNOSTICS = /\s\[task=\S+ contract=\S+ accepted-count=\d+ rejected=[^\]\s]*\]\s*$/;
1137
+ // Mirrors execution-ledger.mjs: observed=/unmet=/cover= are optional so gate text
1138
+ // emitted by an older runtime (bare accepted-count/rejected tail) still binds.
1139
+ const GATE_CONTINUATION_DIAGNOSTICS = /\s\[task=\S+ contract=\S+ accepted-count=\d+ rejected=[^\]\s]*(?:\sobserved=[^\]\s]*)?(?:\sunmet=[^\]\s]*)?(?:\scover=\d+\/\d+)?\]\s*$/;
1138
1140
  export function isCompletionGateContinuationText(text) {
1139
1141
  const value = String(text ?? '').trim();
1140
1142
  if (!value || value.length > 2000) return false;
1141
1143
  return GATE_CONTINUATION_PREFIX.test(value) && GATE_CONTINUATION_DIAGNOSTICS.test(value);
1142
1144
  }
1143
1145
 
1146
+ // Guidance-only supplement (hook mirror): order-free note on open work appends to the same task;
1147
+ // anything not provably order-free mints fresh. Regression: tests/hooks/guidanceSupplementBinding.test.js.
1148
+ const GUIDANCE_MAX_CHARS = 2000;
1149
+ const GUIDANCE_LOOKUP_ORDER = /\b(?:audit|inspect|review|analy[sz]e|explain|read|investigate|debug|forensic|check|status|report|show|summari[sz]e|result|results|translate|compare|kiem tra|phan tich|dieu tra|trang thai|ket qua|bao cao|tim hieu|dich|so sanh)\b/;
1150
+ // Linkage: the note must presuppose in-flight work; framing alone never binds.
1151
+ const GUIDANCE_LINKAGE = /\b(?:this|current|in-flight|ongoing|active)\s+(?:task|work|change|scope|one)\b|\bthis\s+one\b|\bthay doi thuc te\b|\bactual changes\b|\bban sua\b|\bviec dang lam\b|\bdang lam\b|\bhien tai\b|\bsau audit\b|\bafter audit\b|\b(?:thay doi|viec|task|lan|cong viec)\s+nay\b/;
1152
+ // Shared mutation vocabulary (= hook MUT_VERBS).
1153
+ const GUIDANCE_MUTATION_VERBS = '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';
1154
+ const GUIDANCE_MUTATION_VERB = new RegExp(`\\b(?:${GUIDANCE_MUTATION_VERBS})\\b`);
1155
+ // Noun-framed "thay đổi" is a measurement, not the imperative; the frame must sit immediately before.
1156
+ const GUIDANCE_NOUN_FRAME_CHANGE = /\b(?:theo|sau|khoi luong|so luong|quy mo|volume|amount|scope|pham vi)\s+thay doi\b/g;
1157
+ // Framing (raw text): needs policy evidence (version, policy/release noun, preservation verb, negation).
1158
+ const GUIDANCE_FRAME = /(?:\d+\.\d+)|\b(?:guidance|policy|release|publish|patch|minor|major|breaking|scope|version|keep|remains?|stays?|preserve|unchanged|giu|bao toan|phien ban|phat hanh|khong|dung|chua|do not|does not|don't|cannot|must not)\b/;
1159
+ // Questions are answer-only turns.
1160
+ const GUIDANCE_QUESTION = /\?|^(?:what|which|when|who|whom|whose|how|why|where|is|are|am|do|does|did|can|could|should|would|will|shall|may|co|ai|gi|sao|nao|the nao|khi nao)\b/;
1161
+ // Order vocabularies are named once; residue predicates and the temporal head share them.
1162
+ const GUIDANCE_ANY_ORDER_VERBS = 'bump|upgrade|downgrade|publish|deploy|nang\\s+cap';
1163
+ const GUIDANCE_LEADING_ORDER_VERBS = 'bump|upgrade|downgrade|nang(?:\\s+cap)?|ha|giam|raise|publish|deploy|ship|release|phat\\s+hanh|update|cap\\s+nhat';
1164
+ const GUIDANCE_ANY_ORDER = new RegExp(`\\b(?:${GUIDANCE_ANY_ORDER_VERBS})\\b`);
1165
+ // A residue opening with a version/imperative verb is a direct order.
1166
+ const GUIDANCE_LEADING_ORDER = new RegExp(`^\\s*(?:please\\s+)?(?:${GUIDANCE_LEADING_ORDER_VERBS})\\b`);
1167
+ // Start-anchored order head of a temporal reference (full vocabulary); LOOKUP verbs stay absent.
1168
+ const GUIDANCE_TEMPORAL_ORDER_HEAD = new RegExp(`^(?:sau|after)\\s+(?:${GUIDANCE_MUTATION_VERBS}|${GUIDANCE_ANY_ORDER_VERBS}|${GUIDANCE_LEADING_ORDER_VERBS})\\b`);
1169
+ // Execution tail: sau/after + optional đó/that/thì/then + an order verb. Checked before reference removal.
1170
+ const GUIDANCE_TEMPORAL_EXECUTION = new RegExp(`\\b(?:sau|after)\\s+(?:(?:do|that)[\\s,:;]+)?(?:(?:thi|then)[\\s,:;]+)?(?:please\\s+)?(?:${GUIDANCE_MUTATION_VERBS}|${GUIDANCE_ANY_ORDER_VERBS}|${GUIDANCE_LEADING_ORDER_VERBS})\\b`);
1171
+ // Short conditional version-policy supplement: consequent uses only glue words; else fails closed.
1172
+ const SHORT_POLICY_MAX_CHARS = 120;
1173
+ const SHORT_POLICY_MARKER = /\b(?:neu|if|khi|when)\b/;
1174
+ const SHORT_POLICY_CLAUSE_SPLIT = /[;\n]|\.(?!\d)/;
1175
+ // The conditional strip hides the head, so the head is checked against the full vocabulary.
1176
+ const CONDITION_HEAD_ORDER = new RegExp(`\\b(?:${GUIDANCE_MUTATION_VERBS}|publish|release|push|commit|bump|upgrade|downgrade)\\b`);
1177
+ // Noun/policy frames strip from the head only; subject+verb ("bạn sửa config") never strips.
1178
+ const CONDITION_HEAD_NOUN_FRAME = /\b(?:(?:vai|mot|may|nhung|cac|hai|ba|bon|nam|\d+)\s+ban|(?:the|an?|this))\s+(?:sua|cap\s+nhat|fix|update|change|run|add)\b|\b(?:update|cap\s+nhat)\s+nhieu\b(?=\s*(?:thi|then|[,.;:]|$))|\bthay\s+doi\s+(?:lon|nho)\b(?=\s*(?:thi|then|[,.;:]|$))/g;
1179
+ // A slash compound is neutral only inside one local nominal chain; otherwise it fails closed.
1180
+ const GUIDANCE_COMPOUND_NOUN = /\b[a-z]+\/[a-z]+\b/g;
1181
+ const GUIDANCE_COMPOUND_WORK_AREA = /\b(?:neu|if|khi|when)\b[^.!?\n]*(?:\bchanges\b|\bthay\s+doi\b)[^.!?\n]*\b(?:lon|nho|nhieu)\b\s*:\s*hoan\s+thien\s+(?:dang\s+ke\s+)?$/;
1182
+ const guidanceCompoundOrKeep = (compound, offset, text) => {
1183
+ const separator = compound.indexOf('/');
1184
+ if (GUIDANCE_MUTATION_VERB.test(compound.slice(0, separator)) && GUIDANCE_MUTATION_VERB.test(compound.slice(separator + 1))) return compound;
1185
+ const construction = GUIDANCE_COMPOUND_WORK_AREA.exec(text.slice(0, offset));
1186
+ if (!construction || /\b(?:thi|then)\b/.test(construction[0]) || !/^\s*(?:$|[,.;:)\n])/.test(text.slice(offset + compound.length))) return compound;
1187
+ return ' ';
1188
+ };
1189
+ const CONDITION_HEAD_SPAN = /\b(?:if|neu)\s+[^.,;:\n]*?(?:\b(?:thi|then)\b|(?=[.,;:\n]|$))/g;
1190
+ const SHORT_POLICY_TERMINATOR = /\b(?:thi|then)\b|[,;]|\.(?!\d)/;
1191
+ const SHORT_POLICY_GLUE = /\b(?:go|to|len|make|bump|use|dung|switch|move|up)\b/;
1192
+ const SHORT_POLICY_VERSION_TEST = /\d+\.(?:\d+|x)/;
1193
+ const SHORT_POLICY_VERSION_ALL = /\b(?:ban\s+|version\s+)?\d+\.(?:\d+|x)(?:\.\d+)?\b/g;
1194
+ const SHORT_POLICY_CONDITION_ORDER = /\b(?:publish|deploy|release|push|commit|ship|bump|upgrade|sua|edit|fix|change|config)\b/;
1195
+ // 'update' is not bare-allowlisted; it survives only via the magnitude frame strip.
1196
+ const SHORT_POLICY_ALLOWED = new Set(['neu', 'khi', 'if', 'when', 'thi', 'then', 'can', 'need', 'needs', 'needed', 'updates', 'nhieu', 'many', 'it', 'this', 'grows', 'thay', 'doi', 'lon', 'big', 'len', 'go', 'to', 'make', 'bump', 'use', 'dung', 'switch', 'move', 'up', 'only', 'cu', 'luon', 'nhe', 'ban', 'minor', 'patch']);
1197
+ export function shortVersionPolicyText(folded) {
1198
+ if (folded.length > SHORT_POLICY_MAX_CHARS) return false;
1199
+ // Clause structure: every clause must itself be conditional. An unconditional clause
1200
+ // ("Update this.", "Bump to 3.5;") is a direct order even when a later clause is a condition.
1201
+ const clauses = folded.split(SHORT_POLICY_CLAUSE_SPLIT).map((clause) => clause.trim()).filter(Boolean);
1202
+ if (clauses.some((clause) => !SHORT_POLICY_MARKER.test(clause))) return false;
1203
+ const marker = SHORT_POLICY_MARKER.exec(folded);
1204
+ if (!marker) return false;
1205
+ const tail = folded.slice(marker.index + marker[0].length);
1206
+ const term = SHORT_POLICY_TERMINATOR.exec(tail);
1207
+ let consequent;
1208
+ if (term) consequent = tail.slice(term.index + term[0].length);
1209
+ else if (marker.index > 0) consequent = folded.slice(0, marker.index);
1210
+ else {
1211
+ const glue = SHORT_POLICY_GLUE.exec(tail);
1212
+ if (!glue) return false;
1213
+ // The skipped condition span must not carry an order verb ("if you publish, go to 3.5").
1214
+ if (SHORT_POLICY_CONDITION_ORDER.test(tail.slice(0, glue.index))) return false;
1215
+ consequent = tail.slice(glue.index);
1216
+ }
1217
+ if (!SHORT_POLICY_VERSION_TEST.test(consequent)) return false;
1218
+ // Judge the WHOLE sentence, not only the consequent ("Publish 3.4.70 now. ..." is an
1219
+ // order); magnitude noun frames (update nhiều / thay đổi lớn) are stripped first.
1220
+ const words = folded.replace(SHORT_POLICY_VERSION_ALL, ' ').replace(CONDITION_HEAD_NOUN_FRAME, ' ').split(/[^a-z]+/).filter(Boolean);
1221
+ return words.every((word) => SHORT_POLICY_ALLOWED.has(word));
1222
+ }
1223
+
1224
+ export function isGuidanceOnlySupplementText(text) {
1225
+ const value = String(text ?? '').trim();
1226
+ if (!value || value.length > GUIDANCE_MAX_CHARS) return false;
1227
+ if (GATE_CONTINUATION_PREFIX.test(value)) return false;
1228
+ const folded = value.normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/đ/g, 'd').toLowerCase();
1229
+ if (/^(?:hi|hello|hey|ok|okay|thanks|thank you|cam on|chao|alo)\b[\s,!.]*$/.test(folded)) return false;
1230
+ if (GUIDANCE_QUESTION.test(folded)) return false;
1231
+ // A short conditional version-policy note is self-describing (its consequent allowlist is
1232
+ // the order check) and needs no linkage phrase; everything else takes the gated path.
1233
+ if (shortVersionPolicyText(folded)) return true;
1234
+ if (!GUIDANCE_FRAME.test(folded)) return false;
1235
+ if (!GUIDANCE_LINKAGE.test(folded)) return false;
1236
+ // Compound nouns (create/coverage) stripped so head check + residue see names, not verbs;
1237
+ // execution compounds (implement/apply) survive the strip and reach the order checks.
1238
+ const probe = folded.replace(GUIDANCE_NOUN_FRAME_CHANGE, ' changes ').replace(GUIDANCE_COMPOUND_NOUN, guidanceCompoundOrKeep);
1239
+ // Conditional HEADS run against the COMPLETE supported vocabulary before the conditional
1240
+ // strip below removes them; noun frames are stripped from the HEAD text only.
1241
+ for (const head of probe.match(CONDITION_HEAD_SPAN) || []) {
1242
+ if (CONDITION_HEAD_ORDER.test(head.replace(CONDITION_HEAD_NOUN_FRAME, ' '))) return false;
1243
+ }
1244
+ // Quoted context and nonexecuting negations are neutralized FIRST, so a quoted or
1245
+ // negated "after release" never reaches the temporal execution check.
1246
+ // A negation's scope is a CLAUSE, not a token run: it ends at a clause boundary (. , ; :
1247
+ // em dash, newline), and at a sequencing or contrast word that starts a new clause
1248
+ // (then / rồi / và sau / sau đó / after that / but / nhưng ...). Coordination ("or publish",
1249
+ // "and publish") stays inside the negation, so a negated coordinated verb keeps its binding.
1250
+ // "do not change config and then after that publish" negates only "change config"; the
1251
+ // affirmative later clause stays visible to the effective order checks.
1252
+ const sanitized = probe
1253
+ .replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
1254
+ .replace(/\b(?:do not|don't|dont|must not|never|not|khong(?:\s+duoc)?|dung|chua)\b(?:(?!\b(?:but|nhung|tuy nhien|however|although|then|roi|va\s+sau|sau\s+do|after\s+that)\b)[^.,;:\n—])*/g, ' ');
1255
+ // Effective temporal execution tail rejects BEFORE the reference strip below removes it.
1256
+ if (GUIDANCE_TEMPORAL_EXECUTION.test(sanitized)) return false;
1257
+ const residue = sanitized
1258
+ .replace(/\b(?:if|neu)\s+[^.,;:\n]*?(?:\b(?:thi|then)\b|(?=[.,;:\n]|$))/g, ' ')
1259
+ // Temporal framing is stripped ONLY when the suffix beginning at sau/after does not
1260
+ // START with a recognized order phrase from the COMPLETE vocabulary ("sau nâng cấp
1261
+ // config" keeps the order whole; "sau đó/audit" strips) — a temporal reference must
1262
+ // never consume a recognized order of any class.
1263
+ .replace(/\b(?:sau|after)\s+[a-z]+/g, (temporal, offset, text) => (GUIDANCE_TEMPORAL_ORDER_HEAD.test(text.slice(offset)) ? temporal : ' '));
1264
+ // LEADING is start-anchored, so it is also applied to the head of each AFFIRMATIVE clause
1265
+ // (the text after a sequence word, colon, em dash, comma/semicolon/period, or contrast word):
1266
+ // "do not change config then release 3.5" opens a new order after "then".
1267
+ // A clause head whose order verb is the SUBJECT of a declarative predicate ("release policy
1268
+ // remains unchanged", "phát hành vẫn theo policy") is a preservation note, not an imperative:
1269
+ // the verb is followed by a declarative predicate word, so that head is not an order. Any
1270
+ // other head (object tail, version, imperative) stays an order.
1271
+ // Declarative subject + FINITE predicate only (remains/stays/van/giu nguyen/khong doi); a bare
1272
+ // participle ("unchanged") is an object complement and never exempts.
1273
+ const DECLARATIVE_SUBJECT_PREDICATE = new RegExp(`^\\s*(?:policy|version|config|chinh\\s+sach)\\s+(?:remains?|stays?|van|se\\s+giu\\s+nguyen|giu\\s+nguyen|khong\\s+doi)\\b|^\\s*(?:release|phat\\s+hanh)\\s+(?:notes?|policy|chinh\\s+sach)\\s+(?:remains?|stays?|van|giu\\s+nguyen|khong\\s+doi)\\b|^\\s*(?:phat\\s+hanh|release)\\s+van\\s+theo\\s+(?:policy|chinh\\s+sach)\\b`);
1274
+ // An "and/và" clause headed by an order verb is an affirmative execution tail.
1275
+ const AFFIRMATIVE_COORDINATED_TAIL = new RegExp(`\\b(?:and|va)\\s+(?:please\\s+)?(?:${GUIDANCE_LEADING_ORDER_VERBS}|${GUIDANCE_ANY_ORDER_VERBS})\\b(?!\\s+(?:notes?|policy|chinh\\s+sach)\\s+(?:remains?|stays?|van|giu\\s+nguyen|khong\\s+doi)\\b)`);
1276
+ const residueHeads = [residue, ...[...residue.matchAll(/[.,;:\n—]|\b(?:then|roi|va\s+sau|sau\s+do|after\s+that|but|nhung|tuy\s+nhien|however|although)\b/g)].map((m) => residue.slice(m.index + m[0].length))];
1277
+ // A preservation head stays exempt only when no affirmative order tail follows it.
1278
+ if (residueHeads.some((head) => GUIDANCE_LEADING_ORDER.test(head) && !(DECLARATIVE_SUBJECT_PREDICATE.test(head) && !AFFIRMATIVE_COORDINATED_TAIL.test(head)))) return false;
1279
+ if (AFFIRMATIVE_COORDINATED_TAIL.test(residue) && residueHeads.some((head) => DECLARATIVE_SUBJECT_PREDICATE.test(head))) return false;
1280
+ if (GUIDANCE_ANY_ORDER.test(residue)) return false;
1281
+ if (GUIDANCE_MUTATION_VERB.test(residue)) return false;
1282
+ if (GUIDANCE_LOOKUP_ORDER.test(residue)) return false;
1283
+ return true;
1284
+ }
1285
+
1144
1286
  export function reuseOrMintTaskInstanceId({ previousState = {}, promptText = '', commandText = '', targetFile = null, explicitId = null, continuingTask = false } = {}) {
1145
1287
  const explicit = typeof explicitId === 'string' ? explicitId.trim() : '';
1146
1288
  if (explicit && TASK_INSTANCE_ID_SHAPE.test(explicit)) return { id: explicit, origin: null };
1147
1289
  const prevId = previousState?.taskInstanceId;
1148
1290
  if (prevId) {
1149
- // 'system' (a gate continuation the HOOK verified via positive provenance —
1150
- // marker or native origin metadata — and stamped into route state) keeps the
1151
- // same-prompt refresh rights as a hook-owned id: the task must persist across
1152
- // the synthetic continuation, resolve-context and tool receipts until a real
1153
- // new request. The CLI itself is not a provenance source: it only honors the
1154
- // origin the hook already established.
1291
+ // A 'system' origin (verified gate continuation stamped by the hook) keeps same-prompt refresh
1292
+ // rights like a hook-owned id; the CLI honors the origin the hook established, never mints it.
1155
1293
  const hookTurn = HOOK_STATE_SOURCES.includes(previousState.source)
1156
1294
  || (previousState.source === 'task-route' && (previousState.taskIdOrigin === 'hook' || previousState.taskIdOrigin === 'system'));
1157
1295
  const stored = previousState.routingContext?.lastExplicitUserPromptText;
1158
1296
  const samePrompt = Boolean(stored) && stored === promptText;
1159
1297
  const promptlessRefresh = Boolean(stored) && !promptText && Boolean(commandText || targetFile);
1160
- if (hookTurn && (samePrompt || promptlessRefresh)) return { id: prevId, origin: 'hook' };
1298
+ // Guidance-only supplement (hook mirror): order-free owner note on open active work appends to
1299
+ // the same task; work orders, lookups, gate text and sealed contracts mint fresh.
1300
+ const guidanceOnly = !targetFile && !commandText
1301
+ && Boolean(stored) && Boolean(previousState?.routeSummary?.executionContract?.taskKind)
1302
+ && isGuidanceOnlySupplementText(promptText)
1303
+ && Array.isArray(previousState?.routeSummary?.executionContract?.completionEvidence)
1304
+ && previousState.routeSummary.executionContract.completionEvidence.length > 0
1305
+ && previousState?.routeSummary?.continuationState?.required !== false;
1306
+ if (hookTurn && (samePrompt || promptlessRefresh || guidanceOnly)) {
1307
+ return guidanceOnly && !samePrompt
1308
+ ? { id: prevId, origin: 'hook', guidance: true }
1309
+ : { id: prevId, origin: 'hook' };
1310
+ }
1311
+ // Native CLI-only owner note: never stamps origin 'hook'; reuses the id only with open obligations.
1312
+ const cliGuidance = previousState.source === 'task-route' && Boolean(stored) && guidanceOnly;
1313
+ if (cliGuidance) return { id: prevId, origin: null, guidance: true };
1161
1314
  if (continuingTask) return { id: prevId, origin: null };
1162
1315
  }
1163
1316
  return { id: `task-${crypto.randomBytes(16).toString('hex')}`, origin: null };
@@ -1223,7 +1376,7 @@ async function main() {
1223
1376
  const continuingTask = Boolean(previousState.taskInstanceId && lastPrompt
1224
1377
  && /^(?:continue|resume|keep going|go on|proceed|tiep tuc|lam tiep)(?:\s+(?:with\s+)?(?:the\s+|this\s+)?(?:previous\s+|last\s+|current\s+|same\s+)?(?:implementation\s+)?(?:task|work))?(?:\s+(?:thoi|di|đi|nhe|nao|nua|a|ha|please|pls|now|then))*\s*[.!]?$/i.test(
1225
1378
  promptText.normalize('NFD').replace(/[̀-ͯ]/g, '').trim()));
1226
- const { id: taskInstanceId, origin: taskIdOrigin } = reuseOrMintTaskInstanceId({
1379
+ const { id: taskInstanceId, origin: taskIdOrigin, guidance: guidanceReuse } = reuseOrMintTaskInstanceId({
1227
1380
  previousState,
1228
1381
  promptText,
1229
1382
  commandText,
@@ -1231,7 +1384,21 @@ async function main() {
1231
1384
  explicitId: readFlagValue(args, '--task-instance-id'),
1232
1385
  continuingTask,
1233
1386
  });
1234
- if (continuingTask) promptText = lastPrompt;
1387
+ // Guidance is neither a new request nor a progress event; restore live obligations at each emission.
1388
+ const guidanceRefresh = Boolean(guidanceReuse) && Boolean(lastPrompt);
1389
+ if (continuingTask || guidanceRefresh) promptText = lastPrompt;
1390
+ const carryGuidanceState = (state) => {
1391
+ if (!guidanceRefresh || state.taskInstanceId !== previousState.taskInstanceId) return state;
1392
+ state.requestKey = previousState.requestKey;
1393
+ state.routingContext = { ...previousState.routingContext };
1394
+ state.routeSummary ||= {};
1395
+ for (const key of ['executionContract', 'expectedSourceFiles', 'completionState', 'continuationState', 'executionMode']) {
1396
+ if (previousState.routeSummary[key] !== undefined) state.routeSummary[key] = previousState.routeSummary[key];
1397
+ else delete state.routeSummary[key];
1398
+ }
1399
+ state.fingerprint = buildRouteStateFingerprint(state);
1400
+ return state;
1401
+ };
1235
1402
  const preparedRoute = await prepareTaskRoute({
1236
1403
  rootDir,
1237
1404
  promptText,
@@ -1326,7 +1493,7 @@ async function main() {
1326
1493
  historySignals,
1327
1494
  };
1328
1495
  }
1329
- const sharedState = { ...reusableState, taskInstanceId, taskIdOrigin, source: 'task-route', ts: Date.now() };
1496
+ const sharedState = carryGuidanceState({ ...reusableState, taskInstanceId, taskIdOrigin, source: 'task-route', ts: Date.now() });
1330
1497
  if (canPersistRouteState) {
1331
1498
  try { await persistSharedRouteState(sharedStatePath, sharedState); }
1332
1499
  catch (error) { console.error(`route-task: state persist degraded — ${error?.message ?? error}`); }
@@ -1423,6 +1590,7 @@ async function main() {
1423
1590
  requestKey,
1424
1591
  routeSummary: rescuedRouteSummary,
1425
1592
  };
1593
+ carryGuidanceState(sharedState);
1426
1594
  sharedState.fingerprint = buildRouteStateFingerprint(sharedState);
1427
1595
  if (canPersistCachedRouteState) {
1428
1596
  // Advisory persistence — an EACCES/ENOSPC under the lock must never lose
@@ -1481,6 +1649,7 @@ async function main() {
1481
1649
  taskInstanceId,
1482
1650
  });
1483
1651
  if (taskIdOrigin) sharedState.taskIdOrigin = taskIdOrigin;
1652
+ carryGuidanceState(sharedState);
1484
1653
  if (canPersistRouteState) {
1485
1654
  // Advisory persistence — an EACCES/ENOSPC under the lock must never lose
1486
1655
  // the printed route; degrade to stderr and print anyway.
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ // source-snapshot.mjs — verbatim SOURCE.md writer for the handoff create/planner phase.
3
+ //
4
+ // Roadmap-create field regression (2026-10-24): a real `claude -p /ukit:handoff-create`
5
+ // run recorded no SOURCE.md / sha256 at all, so the review chain had nothing durable to
6
+ // start from. This helper makes the snapshot step deterministic — ONE simple command,
7
+ // exclusive write, verbatim byte copy, recorded sha256 — instead of a model hand-editing
8
+ // a wrapper file (which is how the copy and the hash get lost or paraphrased).
9
+ //
10
+ // Usage:
11
+ // node .claude/ukit/index/source-snapshot.mjs <original-path> [output-path]
12
+ // node .claude/ukit/index/source-snapshot.mjs --check <snapshot> [original-path]
13
+ //
14
+ // write: copies <original-path> byte-for-byte into the snapshot (default
15
+ // docs/AI_HANDOFF/SOURCE.md, created if missing), recording the origin path,
16
+ // the sha256 of the ORIGINAL bytes and the byte count. Exclusive create: an
17
+ // existing snapshot is never overwritten (exit 3) — re-snapshot deliberately
18
+ // by removing the old file first.
19
+ // --check: re-hashes the embedded copy against the recorded SHA-256 (exit 1 on
20
+ // mismatch/corrupt wrapper); with [original-path], also fails when the live
21
+ // original drifted from the snapshot.
22
+ //
23
+ // Output: deterministic — no timestamps, no ambient state. Exit codes: 0 ok,
24
+ // 1 hash/drift mismatch (or --check failure), 2 usage/read error, 3 target exists.
25
+ //
26
+ // Partial-write recovery (review minor, 2026-10-24): if the write fails AFTER the
27
+ // exclusive open created the file (e.g. ENOSPC mid-write), the partial snapshot is
28
+ // removed so a plain retry does not hit the exit-3 tombstone. The file is only ever
29
+ // unlinked after THIS invocation's own `wx` open succeeded — a pre-existing snapshot
30
+ // always fails the open with EEXIST first and is never touched. `writeSnapshot` is
31
+ // also exported (main-guarded CLI) so tests can inject a torn write in-process.
32
+
33
+ import fs from 'node:fs';
34
+ import path from 'node:path';
35
+ import process from 'node:process';
36
+ import { createHash } from 'node:crypto';
37
+ import { fileURLToPath } from 'node:url';
38
+
39
+ function isDirectRun() {
40
+ try {
41
+ return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ const BEGIN_MARKER = '<!-- BEGIN VERBATIM SOURCE — every byte below this line is the unmodified original -->';
48
+ const DEFAULT_OUTPUT = path.join('docs', 'AI_HANDOFF', 'SOURCE.md');
49
+
50
+ function sha256(buf) {
51
+ return createHash('sha256').update(buf).digest('hex');
52
+ }
53
+
54
+ export function writeSnapshot(sourceArg, outputArg) {
55
+ const original = path.resolve(sourceArg);
56
+ let bytes;
57
+ try {
58
+ bytes = fs.readFileSync(original);
59
+ } catch (err) {
60
+ console.error(`source-snapshot: cannot read original ${sourceArg} (${err && err.code ? err.code : 'error'})`);
61
+ process.exit(2);
62
+ }
63
+ const output = path.resolve(outputArg || DEFAULT_OUTPUT);
64
+ const header = [
65
+ '# SOURCE.md — verbatim source snapshot',
66
+ '',
67
+ `- Origin: ${original}`,
68
+ `- SHA-256: ${sha256(bytes)}`,
69
+ `- Bytes: ${bytes.length}`,
70
+ '- The copy below is byte-identical to the original file. Verify with:',
71
+ ` node .claude/ukit/index/source-snapshot.mjs --check ${output}`,
72
+ '',
73
+ BEGIN_MARKER,
74
+ ];
75
+ const snapshot = Buffer.concat([Buffer.from(header.join('\n'), 'utf8'), Buffer.from('\n', 'utf8'), bytes]);
76
+
77
+ let fd;
78
+ try {
79
+ fs.mkdirSync(path.dirname(output), { recursive: true });
80
+ // Exclusive flag: an existing snapshot is never overwritten (EEXIST → exit 3).
81
+ fd = fs.openSync(output, 'wx');
82
+ } catch (err) {
83
+ if (err && err.code === 'EEXIST') {
84
+ console.error(`source-snapshot: refuses overwrite — ${output} already exists`);
85
+ process.exit(3);
86
+ }
87
+ console.error(`source-snapshot: cannot write ${output} (${err && err.code ? err.code : 'error'})`);
88
+ process.exit(2);
89
+ }
90
+ try {
91
+ fs.writeFileSync(fd, snapshot);
92
+ fs.closeSync(fd);
93
+ } catch (err) {
94
+ // The `wx` open above CREATED this file, so the partial snapshot is ours — remove
95
+ // it so a retry does not hit exit 3. A pre-existing snapshot can never reach this
96
+ // path: the exclusive open would already have failed with EEXIST.
97
+ try { fs.closeSync(fd); } catch { /* already closed */ }
98
+ try { fs.unlinkSync(output); } catch { /* best effort — report below still fires */ }
99
+ console.error(`source-snapshot: cannot write ${output} (${err && err.code ? err.code : 'error'}) — partial snapshot removed, safe to retry`);
100
+ process.exit(2);
101
+ }
102
+ console.log(`source-snapshot: wrote ${output}`);
103
+ console.log(`source-snapshot: origin ${original}`);
104
+ console.log(`source-snapshot: sha256 ${sha256(bytes)} (${bytes.length} bytes)`);
105
+ process.exit(0);
106
+ }
107
+
108
+ function checkSnapshot(snapshotArg, originalArg) {
109
+ const snapshotPath = path.resolve(snapshotArg);
110
+ let raw;
111
+ try {
112
+ raw = fs.readFileSync(snapshotPath);
113
+ } catch (err) {
114
+ console.error(`source-snapshot: cannot read snapshot ${snapshotArg} (${err && err.code ? err.code : 'error'})`);
115
+ process.exit(1);
116
+ }
117
+ // Byte-offset arithmetic must use the marker's UTF-8 byte length, not the string's
118
+ // .length — the marker holds multi-byte dashes, and a short offset corrupts the hash.
119
+ const markerBuf = Buffer.from(`${BEGIN_MARKER}\n`, 'utf8');
120
+ const markerIdx = raw.indexOf(markerBuf);
121
+ if (markerIdx < 0) {
122
+ console.error(`source-snapshot: ${snapshotArg} has no verbatim marker — not a snapshot this tool wrote`);
123
+ process.exit(1);
124
+ }
125
+ const copy = raw.subarray(markerIdx + markerBuf.length);
126
+ const recorded = /^- SHA-256: ([0-9a-f]{64})$/m.exec(raw.toString('utf8'));
127
+ if (!recorded) {
128
+ console.error(`source-snapshot: ${snapshotArg} records no SHA-256 line`);
129
+ process.exit(1);
130
+ }
131
+ const actual = sha256(copy);
132
+ if (actual !== recorded[1]) {
133
+ console.error(`source-snapshot: sha256 MISMATCH for embedded copy — recorded ${recorded[1]}, copy hashes ${actual}`);
134
+ process.exit(1);
135
+ }
136
+ if (originalArg) {
137
+ let original;
138
+ try {
139
+ original = fs.readFileSync(path.resolve(originalArg));
140
+ } catch (err) {
141
+ console.error(`source-snapshot: cannot read original ${originalArg} (${err && err.code ? err.code : 'error'})`);
142
+ process.exit(1);
143
+ }
144
+ if (!original.equals(copy)) {
145
+ console.error(`source-snapshot: DRIFT — live original ${originalArg} differs from the snapshot copy`);
146
+ process.exit(1);
147
+ }
148
+ }
149
+ console.log(`source-snapshot: ok — copy matches recorded sha256 ${actual}${originalArg ? ' and the live original' : ''}`);
150
+ process.exit(0);
151
+ }
152
+
153
+ // Main guard: the CLI dispatch only runs when executed directly, so tests can import
154
+ // the exported helpers without triggering a write.
155
+ const isMain = isDirectRun();
156
+
157
+ if (isMain) {
158
+ const [, , first, second, third] = process.argv;
159
+ if (first === '--check') {
160
+ if (!second) {
161
+ console.error('usage: source-snapshot.mjs --check <snapshot> [original-path]');
162
+ process.exit(2);
163
+ }
164
+ checkSnapshot(second, third);
165
+ } else if (first) {
166
+ writeSnapshot(first, second);
167
+ } else {
168
+ console.error('usage: source-snapshot.mjs <original-path> [output-path]');
169
+ console.error(' source-snapshot.mjs --check <snapshot> [original-path]');
170
+ process.exit(2);
171
+ }
172
+ }