@ngockhoale/ukit 3.4.50 → 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 ?? '',
@@ -456,6 +456,110 @@ export async function writeResumeIntent(projectRoot, payload = {}, { now = Date.
456
456
  return intent;
457
457
  }
458
458
 
459
+ // ─── Gate-continuation provenance ───────────────────────────────────────────
460
+ // When the Stop gate blocks, the harness re-injects the block reason as the next
461
+ // prompt (omp emits it as an agent-attributed `session-stop-continuation`). That
462
+ // text is the gate's OWN voice, not a user order. Reproducible defect shape when
463
+ // such a prompt is routed as a new task: a fresh taskInstanceId is minted, a
464
+ // contract is re-derived from the gate's own wording, and the gate text is
465
+ // stamped as the explicit user prompt — evidence recorded under the original
466
+ // task then fails ownership and the gate can loop on its own feedback.
467
+ //
468
+ // Provenance is NOT content-based: the shape check below only recognizes our own
469
+ // envelope. Trust comes from (a) the host declaring POSITIVE system provenance
470
+ // (ukit_prompt_origin=system — a marker may then be consumed on an exact
471
+ // session+task+reason-hash match within TTL) or (b) native adapter provenance
472
+ // (ukit_harness=omp + origin=system), which resumes with or without a marker.
473
+ // A marker is a CORRELATION binding, never proof of the speaker: without positive
474
+ // provenance the prompt is conservatively treated as the user's own, so a real
475
+ // user paste of the gate text stays a genuine fresh request even with a live
476
+ // marker, and a user-declared origin never consumes a marker at all.
477
+ export const GATE_CONTINUATION_TTL_MS = 30 * 60 * 1000;
478
+ const GATE_CONTINUATION_PREFIX = /^UKit completion gate:/;
479
+ // The exact diagnostics tail evaluateCompletion appends to every gate reason.
480
+ const GATE_CONTINUATION_DIAGNOSTICS = /\s\[task=\S+ contract=\S+ accepted-count=\d+ rejected=[^\]\s]*\]\s*$/;
481
+ const GATE_CONTINUATION_MAX_CHARS = 2000;
482
+
483
+ export function isCompletionGateContinuationText(text) {
484
+ const value = String(text ?? '').trim();
485
+ if (!value || value.length > GATE_CONTINUATION_MAX_CHARS) return false;
486
+ return GATE_CONTINUATION_PREFIX.test(value) && GATE_CONTINUATION_DIAGNOSTICS.test(value);
487
+ }
488
+
489
+ export function completionContinuationTaskId(text) {
490
+ if (!isCompletionGateContinuationText(text)) return null;
491
+ return String(text).trim().match(/\[task=([^\]\s]+) contract=/)?.[1] || null;
492
+ }
493
+
494
+ // Binds a marker to the EXACT reason text the gate emitted (hash only — the
495
+ // marker record never carries prompt text). Prevents a different gate-shaped
496
+ // message about the same task from being mistaken for our own continuation.
497
+ export function completionContinuationReasonHash(text) {
498
+ return crypto.createHash('sha256').update(String(text ?? '').trim(), 'utf8').digest('hex');
499
+ }
500
+
501
+ function completionContinuationPath(projectRoot) {
502
+ return path.join(projectRoot, '.ukit', 'storage', 'cache', 'completion-continuation.json');
503
+ }
504
+
505
+ // Bounded ring (newest first): session, task id, contract kind and reason hash
506
+ // only — never prompt text. Fail-closed: no task id or no reason text, no
507
+ // marker. Advisory: a failed write degrades to today's behavior (the envelope
508
+ // is then just an unbound user turn), never breaks the gate. Read-modify-write
509
+ // runs under the ledger lock so concurrent recorders/takers serialize —
510
+ // unlocked, two takes could both "win" and parallel records could clobber the
511
+ // ring down to a single entry. A lock failure is conservative on both sides:
512
+ // record nothing, resume nothing.
513
+ export async function recordPendingCompletionContinuation(projectRoot, { sessionId, taskInstanceId, contract, reason }, { now = Date.now() } = {}) {
514
+ if (!taskInstanceId || !String(reason ?? '').trim()) return null;
515
+ const file = completionContinuationPath(projectRoot);
516
+ try {
517
+ const outcome = await withLedgerLock(file, {}, async () => {
518
+ const store = await readJson(file, {}) || {};
519
+ const entries = Array.isArray(store.entries) ? store.entries : [];
520
+ const next = [
521
+ { sessionId: sessionId == null ? null : String(sessionId), taskInstanceId: String(taskInstanceId), contract: contract == null ? null : String(contract), reasonHash: completionContinuationReasonHash(reason), ts: now },
522
+ ...entries.filter((entry) => entry?.taskInstanceId !== taskInstanceId),
523
+ ].slice(0, 4);
524
+ await writeJsonAtomic(file, { entries: next });
525
+ return next[0];
526
+ });
527
+ return outcome?.ok === true ? outcome.value ?? null : null;
528
+ } catch {
529
+ return null;
530
+ }
531
+ }
532
+
533
+ // Exact-bound take: the envelope's own task id AND the consuming session AND
534
+ // the reason hash must all match a fresh marker, otherwise nothing is returned
535
+ // and nothing is consumed (an unbound or re-worded quote of the gate text stays
536
+ // a genuine user turn). A call without the reason hash never consumes. The
537
+ // matched entry is removed under the same lock that read it, so of N concurrent
538
+ // takers exactly one wins.
539
+ export async function takePendingCompletionContinuation(projectRoot, { sessionId, taskInstanceId, reasonHash }, { now = Date.now(), consume = true } = {}) {
540
+ if (!taskInstanceId || !reasonHash) return null;
541
+ const file = completionContinuationPath(projectRoot);
542
+ try {
543
+ const outcome = await withLedgerLock(file, {}, async () => {
544
+ const store = await readJson(file, {}) || {};
545
+ const entries = Array.isArray(store.entries) ? store.entries : [];
546
+ const index = entries.findIndex((entry) => entry?.taskInstanceId === String(taskInstanceId)
547
+ && (entry.sessionId ?? null) === (sessionId == null ? null : String(sessionId))
548
+ && typeof entry.reasonHash === 'string' && entry.reasonHash === String(reasonHash)
549
+ && Number.isFinite(entry.ts) && now - entry.ts <= GATE_CONTINUATION_TTL_MS);
550
+ if (index < 0) return null;
551
+ if (consume) {
552
+ const next = entries.filter((_, i) => i !== index);
553
+ await writeJsonAtomic(file, { entries: next });
554
+ }
555
+ return entries[index];
556
+ });
557
+ return outcome?.ok === true ? outcome.value ?? null : null;
558
+ } catch {
559
+ return null;
560
+ }
561
+ }
562
+
459
563
  function explicitError(payload = {}) {
460
564
  return [
461
565
  payload.isError,
@@ -4442,6 +4546,17 @@ async function runEvaluateStop() {
4442
4546
  if (result.continue) {
4443
4547
  if (result.finalNotice) await markNotified(projectRoot, payload, ledger);
4444
4548
  else await incrementContinuation(projectRoot, payload, ledger, state?.requestKey || null, evidencePromptKey(state));
4549
+ // Provenance: the harness will re-inject this reason as a prompt. Bind it to this
4550
+ // session+task+exact-reason (hash) so the router can recognise our own continuation
4551
+ // (advisory — a failed write only leaves it unbound).
4552
+ try {
4553
+ await recordPendingCompletionContinuation(projectRoot, {
4554
+ sessionId: explicitSessionId(payload),
4555
+ taskInstanceId: state?.taskInstanceId || null,
4556
+ contract: state?.routeSummary?.executionContract?.taskKind || null,
4557
+ reason: result.reason,
4558
+ });
4559
+ } catch { /* advisory */ }
4445
4560
  process.stdout.write(`${JSON.stringify({ decision: 'block', reason: result.reason })}\n`);
4446
4561
  return;
4447
4562
  }