@ngockhoale/ukit 3.4.49 → 3.4.51

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.
@@ -1965,14 +1965,46 @@ function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
1965
1965
  if (!releasePolicy && !operation && isVerificationOnlyRequest(promptText)) return { taskKind: 'READ_ONLY', verificationOnly: true };
1966
1966
  // A config MENTION is not a config change: the same clause needs a mutation verb, and a
1967
1967
  // request that also changes source/code is a coding task (config is part of its tests/fixtures).
1968
- const config = !sourceOrder && actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause)
1969
- && /\b(?:set|change|toggle|enable|disable|switch|turn|configure|modify|update|edit|doi|bat|tat|chinh)\b/.test(clause));
1968
+ // A DIAGNOSTIC report of past changes ("which parameters/configuration has changed, if any" /
1969
+ // "tham so/cau hinh nao da thay doi, neu co") is a question about history, never an authorization
1970
+ // to mutate: only a forward order ("change X to Y") stays a config mutation.
1971
+ const CONFIG_CHANGE_REPORT_CLAUSE = /\b(?:da|has|have|had|already)\s+(?:doi|thay doi|changed?|updated?)\b|\b(?:nao|gi|which|what|report|bao cao)\b[^.;]*?\b(?:doi|thay doi|changed?|updated?)\b/;
1972
+ // A relative past-change clause inside a FORWARD imperative ("Set the config that
1973
+ // has changed to dark mode") names WHICH config — it does not make the sentence a
1974
+ // report. The override holds only when the clause's FIRST mutation word precedes
1975
+ // the config noun AND no reporting/question frame opens the clause or precedes
1976
+ // that word: in "Report what change was made to the config" the mutation word is
1977
+ // a question's noun, never an order.
1978
+ const CONFIG_MUTATION_VERB = /\b(?:set|change|toggle|enable|disable|switch|turn|configure|modify|update|edit|doi|bat|tat|chinh)\b/;
1979
+ const CONFIG_NOUN = /\b(?:configuration|config|toggle|cau hinh)\b/;
1980
+ const CONFIG_REPORT_FRAME = /^\s*(?:report|show|tell|list|describe|explain|summari[sz]e|bao cao|cho biet|liet ke|mo ta)\b/;
1981
+ const CONFIG_QUESTION_FRAME_PREFIX = /\b(?:what|which|how|report|bao cao|nao|gi)\b/;
1982
+ const config = !sourceOrder && actionable.some((clause) => {
1983
+ if (!CONFIG_NOUN.test(clause) || !CONFIG_MUTATION_VERB.test(clause)) return false;
1984
+ if (CONFIG_REPORT_FRAME.test(clause)) return false;
1985
+ const verb = CONFIG_MUTATION_VERB.exec(clause);
1986
+ if (CONFIG_QUESTION_FRAME_PREFIX.test(clause.slice(0, verb.index))) return false;
1987
+ const noun = CONFIG_NOUN.exec(clause);
1988
+ return !CONFIG_CHANGE_REPORT_CLAUSE.test(clause) || Boolean(noun && noun.index > verb.index);
1989
+ });
1970
1990
  const repository = Boolean(targetFile) || /[\w.-]+\.(?:json|ya?ml|toml|ini)\b/.test(text);
1971
1991
  // A status/result lookup ("check the suite output and report") orders no change: it needs a
1972
1992
  // lookup verb AND no mutation vocabulary anywhere in the request (fail-closed otherwise).
1973
1993
  const mutationWord = /\b(?:implement|fix|refactor|modify|edit|write|update|add|create|build|make|install|deploy|remove|delete|rename|replace|change|sua|viet|chinh|them|tao|xoa|doi|cai|cap nhat)\b/.test(text);
1974
- const statusReport = !mutationWord && !operation && !config
1975
- && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua|da len chua)\b/.test(text);
1994
+ // A bounded, recognizable reporting QUESTION about what changed ("Tham số/cấu hình
1995
+ // nào đã thay đổi, nếu có." / "which configuration has changed, if any") is a
1996
+ // status report even without a lookup verb — but ONLY when no actionable clause
1997
+ // actually orders a mutation: a mixed request ("Add a dark-mode button; report
1998
+ // which configuration changed") keeps its implementation contract and evidence.
1999
+ // The two lookbehinds keep a question's noun ("what change was made") and the
2000
+ // Vietnamese past-tense verb "thay đổi" from counting as mutation orders.
2001
+ const ACTIONABLE_MUTATION_ORDER = /(?<!\b(?:what|which|how|nao|gi)\s)(?<!thay )\b(?:implement|fix(?:es)?|refactor|modify|edit|write|update|add|create|build|make|install|deploy|remove|delete|rename|replace|change|sua|viet|chinh|them|tao|xoa|doi|cai|cap nhat)\b/;
2002
+ const configReportQuestion = !actionable.some((clause) => ACTIONABLE_MUTATION_ORDER.test(clause))
2003
+ && actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh|tham so)\b/.test(clause)
2004
+ && CONFIG_CHANGE_REPORT_CLAUSE.test(clause));
2005
+ const statusReport = (configReportQuestion || (!mutationWord
2006
+ && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua|da len chua)\b/.test(text)))
2007
+ && !operation && !config;
1976
2008
  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) || statusReport)
1977
2009
  && !sourceOrder && !codeChangeVerb && !operation && !config;
1978
2010
  const taskKind = operation ? (sourceOrder || (nativeDomain !== 'scheduled-job' && codeChangeVerb) ? 'MIXED' : 'OPERATIONAL')
@@ -3346,6 +3378,14 @@ function applyTaskSemantics(contract, semantic) {
3346
3378
  ts: Date.now(),
3347
3379
  source: state?.source || 'skill-router',
3348
3380
  requestKey: state?.requestKey || null,
3381
+ // Continuation-identity audit join: which task this row served and where its
3382
+ // id/contract came from (gate continuation vs fresh route). Telemetry only —
3383
+ // deliberately excluded from the dedupe key. The hook passes the PERSISTED
3384
+ // route state here, so these mirror the state's own audit stamps.
3385
+ taskInstanceId: state?.taskInstanceId || null,
3386
+ taskIdOrigin: state?.taskIdOrigin || null,
3387
+ taskKind: routeSummary?.executionContract?.taskKind || null,
3388
+ contractOrigin: state?.contractOrigin || null,
3349
3389
  adapter: routingContext.adapter || 'claude',
3350
3390
  promptFingerprint: stableMachineDigest({
3351
3391
  prompt: routingContext.lastExplicitUserPromptText || '',
@@ -3844,6 +3884,10 @@ function applyTaskSemantics(contract, semantic) {
3844
3884
  async function persistRouteState(text) {
3845
3885
  const persisted = JSON.parse(text);
3846
3886
  persisted.taskInstanceId = taskInstanceId;
3887
+ // Bounded audit metadata (short strings, additive): where this turn's task id and
3888
+ // contract came from — 'system' marks a synthetic continuation that resumed the
3889
+ // previous task instead of minting a fresh one. No consumer branches on these yet.
3890
+ persisted.taskIdOrigin = continuingTask ? (systemOriginTurn ? 'system' : 'continue') : 'fresh';
3847
3891
  if (continuingTask && previous.routingContext?.lastExplicitUserPromptText) {
3848
3892
  persisted.routingContext ||= {};
3849
3893
  persisted.routingContext.lastExplicitUserPromptText = previous.routingContext.lastExplicitUserPromptText;
@@ -3862,6 +3906,7 @@ function applyTaskSemantics(contract, semantic) {
3862
3906
  // original request's, never one re-derived from the injected prompt's wording.
3863
3907
  persisted.routeSummary ||= {};
3864
3908
  persisted.routeSummary.executionContract = previous.routeSummary.executionContract;
3909
+ persisted.contractOrigin = 'carried-system';
3865
3910
  for (const key of ['expectedSourceFiles', 'completionState', 'continuationState', 'executionMode']) {
3866
3911
  if (previous.routeSummary[key] !== undefined) persisted.routeSummary[key] = previous.routeSummary[key];
3867
3912
  else delete persisted.routeSummary[key];
@@ -3896,6 +3941,7 @@ function applyTaskSemantics(contract, semantic) {
3896
3941
  persisted.routeSummary ||= { executionMode: 'local-build' };
3897
3942
  persisted.routeSummary.executionContract = applyTaskSemantics(
3898
3943
  persisted.routeSummary.executionContract || buildExecutionContract('local-build'), semantic);
3944
+ if (persisted.contractOrigin !== 'carried-system') persisted.contractOrigin = 'classified';
3899
3945
  delete persisted.routeSummary.completionState;
3900
3946
  delete persisted.routeSummary.continuationState;
3901
3947
  } else if (semantic.taskKind !== 'CODE_IMPLEMENTATION' || persisted.routeSummary) {
@@ -3906,8 +3952,11 @@ function applyTaskSemantics(contract, semantic) {
3906
3952
  if (carriedStatus && persisted.routingContext?.mutationOrder === true) {
3907
3953
  delete persisted.routeSummary.executionContract;
3908
3954
  }
3909
- if (!carriedStatus || persisted.routingContext?.mutationOrder === true) persisted.routeSummary.executionContract = applyTaskSemantics(
3910
- persisted.routeSummary.executionContract || buildExecutionContract(persisted.routeSummary.executionMode === 'informational' ? 'local-build' : persisted.routeSummary.executionMode), semantic);
3955
+ if (!carriedStatus || persisted.routingContext?.mutationOrder === true) {
3956
+ persisted.routeSummary.executionContract = applyTaskSemantics(
3957
+ persisted.routeSummary.executionContract || buildExecutionContract(persisted.routeSummary.executionMode === 'informational' ? 'local-build' : persisted.routeSummary.executionMode), semantic);
3958
+ if (persisted.contractOrigin !== 'carried-system') persisted.contractOrigin = 'classified';
3959
+ }
3911
3960
  if (semantic.taskKind === 'OPERATIONAL' || semantic.taskKind === 'READ_ONLY'
3912
3961
  || (semantic.taskKind === 'CONFIGURATION' && semantic.configurationScope === 'external')) {
3913
3962
  persisted.routeSummary.completionState = buildCompletionState({
@@ -3925,7 +3974,7 @@ function applyTaskSemantics(contract, semantic) {
3925
3974
  }
3926
3975
  text = JSON.stringify(persisted);
3927
3976
  await writeJsonFileAtomic(statePath, text);
3928
- if (!sessionId) return;
3977
+ if (!sessionId) return persisted;
3929
3978
  try {
3930
3979
  const dir = path.join(projectRoot, '.ukit', 'storage', 'cache', 'route-state');
3931
3980
  await writeJsonFileAtomic(path.join(dir, `${String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'default'}.json`), text);
@@ -3938,6 +3987,7 @@ function applyTaskSemantics(contract, semantic) {
3938
3987
  }
3939
3988
  }
3940
3989
  } catch {}
3990
+ return persisted;
3941
3991
  }
3942
3992
  const storedPrevious = await readJson(statePath, {});
3943
3993
  // The shared slot may belong to another session; recover only our owned sidecar.
@@ -3967,10 +4017,38 @@ function applyTaskSemantics(contract, semantic) {
3967
4017
  // verification-only turn can never satisfy). Only transcript metadata set by the harness or the
3968
4018
  // fixed compaction preamble counts - never the prompt's claims about itself.
3969
4019
  let coordinationTurn = false;
4020
+ // Provenance: our OWN gate block re-injected as a prompt. Trust only the exact pending marker
4021
+ // the gate recorded for this session + task + EXACT reason text (hash) within TTL (consumed on
4022
+ // match), and only when the host did not declare the prompt user-originated: a user quoting the
4023
+ // gate text is a genuine fresh request even while a marker is still fresh. Ledger module loads
4024
+ // only for gate-shaped prompts, so ordinary turns pay nothing.
4025
+ const promptOrigin = String(payload.ukit_prompt_origin ?? '').trim().toLowerCase();
4026
+ let boundGateContinuation = false;
4027
+ {
4028
+ const gatePrompt = extractPrompt(payload).trim();
4029
+ if (gatePrompt && sessionId && promptOrigin === 'system' && /^UKit completion gate:/.test(gatePrompt)) {
4030
+ try {
4031
+ const ledgerRuntime = await import(pathToFileURL(path.join(projectRoot, '.claude', 'ukit', 'runtime', 'execution-ledger.mjs')).href);
4032
+ const gateTaskId = ledgerRuntime.completionContinuationTaskId(gatePrompt);
4033
+ if (gateTaskId && previous.taskInstanceId === gateTaskId) {
4034
+ const taken = await ledgerRuntime.takePendingCompletionContinuation(projectRoot, {
4035
+ sessionId,
4036
+ taskInstanceId: gateTaskId,
4037
+ reasonHash: ledgerRuntime.completionContinuationReasonHash(gatePrompt),
4038
+ });
4039
+ boundGateContinuation = Boolean(taken);
4040
+ }
4041
+ } catch { boundGateContinuation = false; }
4042
+ }
4043
+ }
3970
4044
  const systemOriginTurn = (() => {
3971
4045
  const promptTrim = extractPrompt(payload).trim();
3972
4046
  if (!promptTrim) return false;
3973
4047
  if (/^This session is being continued from a previous conversation/i.test(promptTrim)) return true;
4048
+ // Bridge-owned metadata only (ukit-bridge.js maps the host's session-stop-continuation
4049
+ // attribution); an arbitrary prompt prefix proves nothing and is never trusted here.
4050
+ if (payload.ukit_harness === 'omp' && promptOrigin === 'system') return true;
4051
+ if (boundGateContinuation) return true;
3974
4052
  const tp = payload.transcript_path ?? payload.transcriptPath;
3975
4053
  if (!tp || typeof tp !== 'string') return false;
3976
4054
  try {
@@ -4392,11 +4470,11 @@ function applyTaskSemantics(contract, semantic) {
4392
4470
  }
4393
4471
 
4394
4472
  await ensureDir(statePath);
4395
- await persistRouteState(JSON.stringify(reusedState));
4473
+ const persistedReused = await persistRouteState(JSON.stringify(reusedState));
4396
4474
  await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
4397
4475
  routingContext: reusedState.routingContext || {},
4398
4476
  routeSummary: rescuedRouteSummary || {},
4399
- state: reusedState,
4477
+ state: persistedReused || reusedState,
4400
4478
  }));
4401
4479
 
4402
4480
  // BL-017: detached decisions.tsv shadow emission for the reused route —
@@ -4533,11 +4611,11 @@ function applyTaskSemantics(contract, semantic) {
4533
4611
  ...(helpers ? { helpers } : {}),
4534
4612
  routeSummary: compactRouteSummary(routeSummary),
4535
4613
  };
4536
- await persistRouteState(JSON.stringify(sharedState));
4614
+ const persistedRouteState = await persistRouteState(JSON.stringify(sharedState));
4537
4615
  await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
4538
4616
  routingContext,
4539
4617
  routeSummary,
4540
- state: sharedState,
4618
+ state: persistedRouteState || sharedState,
4541
4619
  }));
4542
4620
 
4543
4621
  // BL-017: detached decisions.tsv shadow emission — fire-and-forget child
@@ -30,12 +30,73 @@ const SEVERITIES = ['critical', 'important', 'minor'];
30
30
  // counted as absent and named in `unparsed`.
31
31
  export function parseVerdictBlock(text) {
32
32
  const src = String(text ?? '');
33
- const verdictMatch = src.match(/^VERDICT\s*:\s*(APPROVED-WITH-MINOR|APPROVED|CHANGES-REQUESTED|CRITICAL)\s*$/im);
33
+ // `-` and `_` are one vocabulary: the handoff commands and some reviewers
34
+ // write `changes_requested` / `critical_block` while others write the hyphen
35
+ // forms — both spellings parse to the same canonical verdict so a blocking
36
+ // reviewer can never be dropped as unparsed while the panel approves.
37
+ // `[ \t]` (not `\s`) around the colon: `\s` would swallow the newline and
38
+ // let a stray word on the next line fabricate a verdict.
39
+ const verdictMatch = src.match(
40
+ /^VERDICT[ \t]*:[ \t]*(APPROVED[-_]WITH[-_]MINOR|APPROVED|CHANGES[-_]REQUESTED|CRITICAL[-_]BLOCK|CRITICAL)[ \t]*\r?$/im,
41
+ );
34
42
  if (!verdictMatch) return null;
43
+ const rawVerdict = verdictMatch[1].toUpperCase().replace(/_/g, '-');
44
+ const verdict = rawVerdict === 'CRITICAL-BLOCK' ? 'CRITICAL' : rawVerdict;
35
45
 
36
46
  const memberMatch = src.match(/^PANEL_MEMBER\s*:\s*(\S+)\s*$/im);
37
47
  const member = memberMatch && memberMatch[1] !== '-' ? memberMatch[1] : null;
38
48
 
49
+ // VERIFICATION_RERUN status (PASS | FAIL | NOT_RUN | N/A). The status may
50
+ // sit inline on the heading line AND/OR under the indented `result:` line —
51
+ // both positions are read, and an explicit FAIL or NOT_RUN in either wins
52
+ // over a PASS elsewhere (fail closed on contradiction). Bare prose
53
+ // (`N pass / M fail`) and a literal template echo (`VERIFICATION_RERUN:
54
+ // PASS | FAIL | …` — still carrying the `|` vocabulary) parse as no status,
55
+ // leaving the field null (= no required metadata → pre-existing behavior).
56
+ // FAIL = the re-run executed and failed; NOT_RUN = the required verification
57
+ // never executed — either blocks approval.
58
+ //
59
+ // Layout is read exhaustively and conservatively: `[ \t]` (not `\s`) after
60
+ // the heading colon so the match cannot swallow the newline before an
61
+ // indented child (a `result:` FIRST child used to be hidden this way), the
62
+ // global match reads REPEATED VERIFICATION_RERUN headers, and every
63
+ // `result:` child line of each header is collected so a contradictory
64
+ // second line cannot hide behind the first. FAIL/NOT_RUN dominate.
65
+ const STATUS_RANK = { FAIL: 3, NOT_RUN: 2, PASS: 1, 'N/A': 0 };
66
+ const statuses = [];
67
+ const commands = [];
68
+ const vrRe = /^VERIFICATION_RERUN[ \t]*:[ \t]*(.*)$/img;
69
+ let vr;
70
+ while ((vr = vrRe.exec(src)) !== null) {
71
+ const inline = vr[1].trim();
72
+ const inlineStatus = inline.includes('|') ? null : inline.match(/^(PASS|FAIL|NOT_RUN|N\/A)\b/i);
73
+ if (inlineStatus) statuses.push(inlineStatus[1].toUpperCase());
74
+ for (const line of src.slice(vr.index + vr[0].length).split('\n')) {
75
+ if (/^\S/.test(line)) break; // next top-level field ends this header's children
76
+ const result = line.match(/^[ \t]*result[ \t]*:[ \t]*(.*)$/i);
77
+ if (result) {
78
+ const status = result[1].trim().match(/^(PASS|FAIL|NOT_RUN|N\/A)\b/i);
79
+ if (status) statuses.push(status[1].toUpperCase());
80
+ continue; // keep reading — a contradictory later `result:` must still block
81
+ }
82
+ const command = line.match(/^[ \t]*command[ \t]*:[ \t]*(.*)$/i);
83
+ if (command) commands.push(command[1].trim());
84
+ }
85
+ }
86
+ const dominant = statuses.length > 0
87
+ ? [...statuses].sort((a, b) => STATUS_RANK[b] - STATUS_RANK[a])[0]
88
+ : null;
89
+ // Fail closed on applicability: an N/A claim beside a DECLARED executable
90
+ // command contradicts the block's own evidence — the required check exists,
91
+ // so the N/A counts as NOT_RUN (the check was not run). A placeholder
92
+ // command (`-`, `none`, `(none …)`, `n/a …`) or no command line keeps N/A
93
+ // legitimate for genuinely check-free reviews.
94
+ const hasExecutableCommand = commands.some((c) => {
95
+ const t = c.trim().toLowerCase();
96
+ return t !== '' && t !== '-' && t !== 'none' && !t.startsWith('(none') && !t.startsWith('n/a');
97
+ });
98
+ const verificationRerun = dominant === 'N/A' && hasExecutableCommand ? 'NOT_RUN' : dominant;
99
+
39
100
  const findings = { critical: [], important: [], minor: [] };
40
101
  const findingsMatch = src.match(/^FINDINGS:\s*$/im);
41
102
  if (findingsMatch) {
@@ -70,7 +131,7 @@ export function parseVerdictBlock(text) {
70
131
  }
71
132
  }
72
133
 
73
- return { member, verdict: verdictMatch[1].toUpperCase(), findings };
134
+ return { member, verdict, findings, verificationRerun };
74
135
  }
75
136
 
76
137
  // Normalize a finding bullet for identity comparison: two members report the
@@ -120,8 +181,15 @@ export function aggregatePanelVerdicts(verdicts) {
120
181
  const hasCritical =
121
182
  parsed.some((p) => p.verdict === 'CRITICAL') || parsed.some((p) => p.findings.critical.length > 0);
122
183
 
184
+ // Fail closed on explicit verification evidence: a re-run that FAILED, or
185
+ // one never executed (NOT_RUN), cannot sit behind an approving prose
186
+ // verdict — the block stops counting toward approval entirely.
187
+ const hasBlockingVerification = parsed.some(
188
+ (p) => p.verificationRerun === 'FAIL' || p.verificationRerun === 'NOT_RUN',
189
+ );
190
+
123
191
  let verdict;
124
- if (hasCritical || changeRequests >= 1 || parsed.length === 0) verdict = 'changes_requested';
192
+ if (hasCritical || changeRequests >= 1 || hasBlockingVerification || parsed.length === 0) verdict = 'changes_requested';
125
193
  else if (approvals >= 2) verdict = 'approved';
126
194
  else verdict = 'approved_minor';
127
195
 
@@ -1202,14 +1202,46 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
1202
1202
  if (!releasePolicy && !operation && isVerificationOnlyRequest(promptText)) return { taskKind: 'READ_ONLY', verificationOnly: true };
1203
1203
  // A config MENTION is not a config change: the same clause needs a mutation verb, and a
1204
1204
  // request that also changes source/code is a coding task (config is part of its tests/fixtures).
1205
- const config = !sourceOrder && actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause)
1206
- && /\b(?:set|change|toggle|enable|disable|switch|turn|configure|modify|update|edit|doi|bat|tat|chinh)\b/.test(clause));
1205
+ // A DIAGNOSTIC report of past changes ("which parameters/configuration has changed, if any" /
1206
+ // "tham so/cau hinh nao da thay doi, neu co") is a question about history, never an authorization
1207
+ // to mutate: only a forward order ("change X to Y") stays a config mutation.
1208
+ const CONFIG_CHANGE_REPORT_CLAUSE = /\b(?:da|has|have|had|already)\s+(?:doi|thay doi|changed?|updated?)\b|\b(?:nao|gi|which|what|report|bao cao)\b[^.;]*?\b(?:doi|thay doi|changed?|updated?)\b/;
1209
+ // A relative past-change clause inside a FORWARD imperative ("Set the config that
1210
+ // has changed to dark mode") names WHICH config — it does not make the sentence a
1211
+ // report. The override holds only when the clause's FIRST mutation word precedes
1212
+ // the config noun AND no reporting/question frame opens the clause or precedes
1213
+ // that word: in "Report what change was made to the config" the mutation word is
1214
+ // a question's noun, never an order.
1215
+ const CONFIG_MUTATION_VERB = /\b(?:set|change|toggle|enable|disable|switch|turn|configure|modify|update|edit|doi|bat|tat|chinh)\b/;
1216
+ const CONFIG_NOUN = /\b(?:configuration|config|toggle|cau hinh)\b/;
1217
+ const CONFIG_REPORT_FRAME = /^\s*(?:report|show|tell|list|describe|explain|summari[sz]e|bao cao|cho biet|liet ke|mo ta)\b/;
1218
+ const CONFIG_QUESTION_FRAME_PREFIX = /\b(?:what|which|how|report|bao cao|nao|gi)\b/;
1219
+ const config = !sourceOrder && actionable.some((clause) => {
1220
+ if (!CONFIG_NOUN.test(clause) || !CONFIG_MUTATION_VERB.test(clause)) return false;
1221
+ if (CONFIG_REPORT_FRAME.test(clause)) return false;
1222
+ const verb = CONFIG_MUTATION_VERB.exec(clause);
1223
+ if (CONFIG_QUESTION_FRAME_PREFIX.test(clause.slice(0, verb.index))) return false;
1224
+ const noun = CONFIG_NOUN.exec(clause);
1225
+ return !CONFIG_CHANGE_REPORT_CLAUSE.test(clause) || Boolean(noun && noun.index > verb.index);
1226
+ });
1207
1227
  const repository = Boolean(targetFile) || /[\w.-]+\.(?:json|ya?ml|toml|ini)\b/.test(text);
1208
1228
  // A status/result lookup ("check the suite output and report") orders no change: it needs a
1209
1229
  // lookup verb AND no mutation vocabulary anywhere in the request (fail-closed otherwise).
1210
1230
  const mutationWord = /\b(?:implement|fix|refactor|modify|edit|write|update|add|create|build|make|install|deploy|remove|delete|rename|replace|change|sua|viet|chinh|them|tao|xoa|doi|cai|cap nhat)\b/.test(text);
1211
- const statusReport = !mutationWord && !operation && !config
1212
- && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua|da len chua)\b/.test(text);
1231
+ // A bounded, recognizable reporting QUESTION about what changed ("Tham số/cấu hình
1232
+ // nào đã thay đổi, nếu có." / "which configuration has changed, if any") is a
1233
+ // status report even without a lookup verb — but ONLY when no actionable clause
1234
+ // actually orders a mutation: a mixed request ("Add a dark-mode button; report
1235
+ // which configuration changed") keeps its implementation contract and evidence.
1236
+ // The two lookbehinds keep a question's noun ("what change was made") and the
1237
+ // Vietnamese past-tense verb "thay đổi" from counting as mutation orders.
1238
+ const ACTIONABLE_MUTATION_ORDER = /(?<!\b(?:what|which|how|nao|gi)\s)(?<!thay )\b(?:implement|fix(?:es)?|refactor|modify|edit|write|update|add|create|build|make|install|deploy|remove|delete|rename|replace|change|sua|viet|chinh|them|tao|xoa|doi|cai|cap nhat)\b/;
1239
+ const configReportQuestion = !actionable.some((clause) => ACTIONABLE_MUTATION_ORDER.test(clause))
1240
+ && actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh|tham so)\b/.test(clause)
1241
+ && CONFIG_CHANGE_REPORT_CLAUSE.test(clause));
1242
+ const statusReport = (configReportQuestion || (!mutationWord
1243
+ && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua|da len chua)\b/.test(text)))
1244
+ && !operation && !config;
1213
1245
  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)
1214
1246
  && !sourceOrder && !codeChangeVerb && !operation && !config;
1215
1247
  const taskKind = operation ? (sourceOrder || codeChangeVerb ? 'MIXED' : 'OPERATIONAL')
@@ -1131,13 +1131,29 @@ const HOOK_STATE_SOURCES = ['skill-router', 'skill-router-provisional'];
1131
1131
  * or any state the hook did not own, so a repeated request still mints a new id and unrelated evidence
1132
1132
  * stays rejected. An explicit --task-instance-id is honored when well-formed.
1133
1133
  */
1134
+ // Mirror of execution-ledger.mjs isCompletionGateContinuationText (kept local so the CLI stays
1135
+ // import-free). Shape only: trust is decided by the caller's bound pending marker, never by this match.
1136
+ const GATE_CONTINUATION_PREFIX = /^UKit completion gate:/;
1137
+ const GATE_CONTINUATION_DIAGNOSTICS = /\s\[task=\S+ contract=\S+ accepted-count=\d+ rejected=[^\]\s]*\]\s*$/;
1138
+ export function isCompletionGateContinuationText(text) {
1139
+ const value = String(text ?? '').trim();
1140
+ if (!value || value.length > 2000) return false;
1141
+ return GATE_CONTINUATION_PREFIX.test(value) && GATE_CONTINUATION_DIAGNOSTICS.test(value);
1142
+ }
1143
+
1134
1144
  export function reuseOrMintTaskInstanceId({ previousState = {}, promptText = '', commandText = '', targetFile = null, explicitId = null, continuingTask = false } = {}) {
1135
1145
  const explicit = typeof explicitId === 'string' ? explicitId.trim() : '';
1136
1146
  if (explicit && TASK_INSTANCE_ID_SHAPE.test(explicit)) return { id: explicit, origin: null };
1137
1147
  const prevId = previousState?.taskInstanceId;
1138
1148
  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.
1139
1155
  const hookTurn = HOOK_STATE_SOURCES.includes(previousState.source)
1140
- || (previousState.source === 'task-route' && previousState.taskIdOrigin === 'hook');
1156
+ || (previousState.source === 'task-route' && (previousState.taskIdOrigin === 'hook' || previousState.taskIdOrigin === 'system'));
1141
1157
  const stored = previousState.routingContext?.lastExplicitUserPromptText;
1142
1158
  const samePrompt = Boolean(stored) && stored === promptText;
1143
1159
  const promptlessRefresh = Boolean(stored) && !promptText && Boolean(commandText || targetFile);
@@ -6125,6 +6141,13 @@ function buildRouteAuditEntry({ route = null, state = null } = {}) {
6125
6141
  fastPathKind: routeSummary.fastPathKind ?? null,
6126
6142
  source: state?.source ?? 'task-route',
6127
6143
  requestKey: state?.requestKey ?? null,
6144
+ // Continuation-identity audit join: which task this row served and where its
6145
+ // id/contract came from (gate continuation vs fresh route). Telemetry only —
6146
+ // deliberately excluded from dedupeKey/fingerprint inputs.
6147
+ taskInstanceId: state?.taskInstanceId ?? null,
6148
+ taskIdOrigin: state?.taskIdOrigin ?? null,
6149
+ taskKind: routeSummary?.executionContract?.taskKind ?? null,
6150
+ contractOrigin: state?.contractOrigin ?? null,
6128
6151
  adapter: routingContext.adapter ?? 'claude',
6129
6152
  promptFingerprint: stableMachineDigest({
6130
6153
  prompt: routingContext.lastExplicitUserPromptText ?? '',