@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.
- package/CHANGELOG.md +17 -0
- package/manifests/documentation.yaml +33 -0
- package/manifests/instructionRules.yaml +10 -0
- package/manifests/platform.full.yaml +67 -0
- package/package.json +1 -1
- package/src/core/applyPlan.js +111 -2
- package/src/core/buildPlan.js +3 -1
- package/src/core/diffPlan.js +49 -1
- package/src/core/executionContracts.js +51 -10
- package/src/core/metadata.js +45 -0
- package/src/core/runInstallPipeline.js +30 -2
- package/src/core/uninstall.js +106 -2
- package/src/manifest/validateManifest.js +25 -1
- package/template_project/.claude/agents/code-reviewer.md +22 -9
- package/template_project/.claude/agents/handoff-planner.md +97 -8
- package/template_project/.claude/commands/ukit/handoff-create.md +114 -19
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +247 -79
- package/template_project/.claude/commands/ukit/handoff-implement.md +75 -40
- package/template_project/.claude/commands/ukit/handoff-review.md +14 -10
- package/template_project/.claude/hooks/auto-allow-bash.sh +73 -12
- package/template_project/.claude/hooks/auto-prune-bash.sh +10 -0
- package/template_project/.claude/hooks/delegation-gate.mjs +21 -0
- package/template_project/.claude/hooks/handoff-model-guard.sh +22 -6
- package/template_project/.claude/hooks/handoff-resume.sh +141 -1
- package/template_project/.claude/hooks/record-execution.mjs +140 -9
- package/template_project/.claude/hooks/skill-router.sh +173 -8
- package/template_project/.claude/settings.json +2 -2
- package/template_project/.claude/skills/advisor-plan/REFERENCE.md +10 -0
- package/template_project/.claude/skills/advisor-plan/SKILL.md +2 -2
- package/template_project/.claude/ukit/index/allocate-task-worktree.mjs +15 -0
- package/template_project/.claude/ukit/index/amend-guard.mjs +116 -0
- package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
- package/template_project/.claude/ukit/index/land-worktree.mjs +243 -21
- package/template_project/.claude/ukit/index/plan-count.mjs +115 -0
- package/template_project/.claude/ukit/index/ready-gate.mjs +170 -0
- package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +293 -4
- package/template_project/.claude/ukit/index/route-resolver.mjs +50 -10
- package/template_project/.claude/ukit/index/route-task.mjs +180 -11
- package/template_project/.claude/ukit/index/source-snapshot.mjs +172 -0
- package/template_project/.claude/ukit/index/task-budget-validator.mjs +109 -3
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +20 -3
- package/template_project/.claude/ukit/runtime/handoff-task-scope.mjs +205 -0
- package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
- package/template_project/.claude/ukit/runtime/plan-dispatch.mjs +32 -5
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +310 -21
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +405 -105
- package/template_project/.claude/ukit/runtime/task-record-reconciliation.mjs +46 -0
- package/template_project/.claude/ukit/runtime/task-watchdog.mjs +28 -2
- package/template_project/.claude/ukit/runtime/worktree-allocation.mjs +75 -0
- package/template_project/.codex/README.md +25 -0
- package/template_project/.omp/RULES.md +22 -22
- package/template_project/.omp/agents/code-reviewer.md +22 -9
- package/template_project/.omp/agents/handoff-planner.md +102 -12
- package/template_project/.omp/hooks/pre/ukit-bridge.js +1 -1
- package/template_project/AGENTS.md +5 -1
- package/template_project/CLAUDE.md +1 -1
- package/template_project/docs/AI_HANDOFF/RULES.md +8 -0
- package/template_project/docs/AI_HANDOFF/tasks/_TEMPLATE.md +8 -1
- package/template_project/instructions/core.md +5 -1
- package/template_project/instructions/layout.yaml +2 -0
- package/template_project/instructions/overlays/omp-rules.md +22 -22
- 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
|
|
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|
|
|
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
|
-
|
|
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
|
-
|
|
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' (
|
|
1150
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|