@ngockhoale/ukit 3.4.50 → 3.4.52
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 +18 -0
- package/manifests/documentation.yaml +11 -0
- package/package.json +1 -1
- package/src/core/executionContracts.js +36 -4
- package/src/core/reviewPanelAggregate.js +71 -3
- package/template_project/.claude/agents/code-reviewer.md +41 -6
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +43 -11
- package/template_project/.claude/commands/ukit/handoff-review.md +34 -8
- package/template_project/.claude/hooks/handoff-resume.sh +17 -3
- package/template_project/.claude/hooks/skill-router.sh +89 -11
- package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +71 -3
- package/template_project/.claude/ukit/index/route-resolver.mjs +36 -4
- package/template_project/.claude/ukit/index/route-task.mjs +24 -1
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +162 -9
- package/template_project/.claude/ukit/runtime/omp-task-dispatch-guard.mjs +262 -0
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +8 -0
- package/template_project/.omp/agents/code-reviewer.md +41 -6
- package/template_project/.omp/hooks/pre/ukit-bridge.js +38 -0
|
@@ -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
|
-
|
|
1969
|
-
|
|
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
|
-
|
|
1975
|
-
|
|
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)
|
|
3910
|
-
persisted.routeSummary.executionContract
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1206
|
-
|
|
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
|
-
|
|
1212
|
-
|
|
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,
|
|
@@ -697,10 +801,13 @@ function compactReceipt(receipt) {
|
|
|
697
801
|
|
|
698
802
|
// --- Typed verdicts (SPEC-typed-verdicts §2) -----------------------------------
|
|
699
803
|
// One optional verdict record rides the ledger entry — additive, no new store.
|
|
700
|
-
// kind: 'live-verified' | 'test-verified' | 'check-only' | 'blocked' | 'failed'
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
|
|
804
|
+
// kind: 'live-verified' | 'test-verified' | 'check-only' | 'blocked' | 'failed'
|
|
805
|
+
// | 'unproven'.
|
|
806
|
+
// 'unproven' is a masked wrapper exit 0 (pipe/`sh -c` consumer): the runner may have
|
|
807
|
+
// passed or failed — the receipt proves NEITHER, so it is never a satisfying kind
|
|
808
|
+
// (fail-closed) and never a definite 'failed'. Currency is proven by headSha + baseSha
|
|
809
|
+
// + patch-id, not by commit messages or a green check from an older SHA.
|
|
810
|
+
const VERDICT_KINDS = new Set(['live-verified', 'test-verified', 'check-only', 'blocked', 'failed', 'unproven']);
|
|
704
811
|
const VERDICT_SATISFYING_KINDS = new Set(['test-verified', 'live-verified']);
|
|
705
812
|
const MAX_VERDICT_HISTORY = 8;
|
|
706
813
|
|
|
@@ -1029,7 +1136,10 @@ function mintVerificationVerdict(receipt, payload, projectRoot, sessionRoot = pr
|
|
|
1029
1136
|
}
|
|
1030
1137
|
let kind = supplied.kind;
|
|
1031
1138
|
if (!VERDICT_KINDS.has(kind)) {
|
|
1032
|
-
|
|
1139
|
+
// A masked wrapper exit 0 proves the runner's status neither way: its own kind,
|
|
1140
|
+
// never 'failed' (the runner may have passed) and never a satisfying kind.
|
|
1141
|
+
if (receipt.exitStatusUnproven === true) kind = 'unproven';
|
|
1142
|
+
else if (receipt.success !== true) kind = 'failed';
|
|
1033
1143
|
else kind = isTestGateCommand(receipt.command) ? 'test-verified' : 'check-only';
|
|
1034
1144
|
}
|
|
1035
1145
|
return {
|
|
@@ -1116,6 +1226,18 @@ function terminalShellCommandUnit(command) {
|
|
|
1116
1226
|
) next += 1;
|
|
1117
1227
|
return next;
|
|
1118
1228
|
};
|
|
1229
|
+
// A redirection's leading IO number (`2>&1`, `2> err.log`, `3< in`) belongs to the stream
|
|
1230
|
+
// operator, not to the command: drop a trailing digit run ONLY when it is the whole
|
|
1231
|
+
// current shell word (preceded by whitespace or the start of the buffer). The scanner
|
|
1232
|
+
// only reaches redirections outside quotes, so a quoted literal (`-t "2" > out`) ends in
|
|
1233
|
+
// a quote character and is never stripped; an ordinary numeric argument separated from
|
|
1234
|
+
// the operator by whitespace (`--testTimeout 2 > out`, `-n 2 > out`) keeps its digits.
|
|
1235
|
+
const stripIoNumber = () => {
|
|
1236
|
+
let end = pipeBuffer.length;
|
|
1237
|
+
while (end > 0 && /\d/.test(pipeBuffer[end - 1])) end -= 1;
|
|
1238
|
+
if (end === pipeBuffer.length || (end > 0 && !/\s/.test(pipeBuffer[end - 1]))) return;
|
|
1239
|
+
pipeBuffer = pipeBuffer.slice(0, end);
|
|
1240
|
+
};
|
|
1119
1241
|
|
|
1120
1242
|
for (let index = 0; index < text.length; index += 1) {
|
|
1121
1243
|
const char = text[index];
|
|
@@ -1148,6 +1270,7 @@ function terminalShellCommandUnit(command) {
|
|
|
1148
1270
|
}
|
|
1149
1271
|
if (char === '$' && text[index + 1] === '(') return null;
|
|
1150
1272
|
if (char === '>' || char === '<') {
|
|
1273
|
+
stripIoNumber();
|
|
1151
1274
|
let next = index + 1;
|
|
1152
1275
|
if (text[next] === '>' && char === '>') next += 1;
|
|
1153
1276
|
if (text[next] === '&') next += 1;
|
|
@@ -2375,7 +2498,13 @@ function applyReceiptCore(ledger, receipt, { vibecode = false } = {}) {
|
|
|
2375
2498
|
}
|
|
2376
2499
|
next.verificationAttempted = true;
|
|
2377
2500
|
next.verificationSucceeded = next.verificationSucceeded || receipt.success;
|
|
2378
|
-
|
|
2501
|
+
// SPEC-typed-verdicts §2: an UNPROVEN run (masked wrapper exit 0) is neither a pass
|
|
2502
|
+
// nor a definite failure — it must not grow failure counts, the loop streak or a
|
|
2503
|
+
// blocker, and it never moves `verificationFailed`. Only a PROVEN non-zero exit is a
|
|
2504
|
+
// definite failure; fail-closed acceptance is unchanged (only a current
|
|
2505
|
+
// test-verified/live-verified verdict ever satisfies the gate).
|
|
2506
|
+
const unproven = receipt.exitStatusUnproven === true;
|
|
2507
|
+
next.verificationFailed = next.verificationFailed || (!receipt.success && !unproven);
|
|
2379
2508
|
// TASK-026 fix round 1: bank the FIRST successful run of this command (same
|
|
2380
2509
|
// terminal-shell identity the loop tracker uses), so rerunning a banked check is a
|
|
2381
2510
|
// replay — never progress — regardless of receipt-window eviction.
|
|
@@ -2406,7 +2535,8 @@ function applyReceiptCore(ledger, receipt, { vibecode = false } = {}) {
|
|
|
2406
2535
|
|
|
2407
2536
|
// Verification-loop tracking. `terminalShellCommandUnit` gives the loop identity:
|
|
2408
2537
|
// the same failing check rerun — `setup && yarn test` and `yarn test 2>&1 | tail`
|
|
2409
|
-
// both count as the same command
|
|
2538
|
+
// both count as the same command (redirection IO numbers are stripped), while a
|
|
2539
|
+
// different check resets the streak.
|
|
2410
2540
|
const fingerprint = terminalShellCommandUnit(receipt.command) || receipt.command;
|
|
2411
2541
|
const counts = { ...(next.verificationFailureCounts || {}) };
|
|
2412
2542
|
if (receipt.success) {
|
|
@@ -2415,7 +2545,18 @@ function applyReceiptCore(ledger, receipt, { vibecode = false } = {}) {
|
|
|
2415
2545
|
if (next.failedVerificationStreak?.fingerprint === fingerprint) {
|
|
2416
2546
|
next.failedVerificationStreak = null;
|
|
2417
2547
|
}
|
|
2418
|
-
|
|
2548
|
+
// A valid, newer pass of the SAME check clears its OWN verification-loop blocker:
|
|
2549
|
+
// same terminal-shell identity the tracker minted it with, applied to this task's
|
|
2550
|
+
// ledger in receive order (a replayed older event is deduped by journalSeen, and a
|
|
2551
|
+
// foreign task never reaches this ledger — recordLedgerEvent rejects
|
|
2552
|
+
// receipt-task-mismatch and a new task re-keys to a fresh ledger). Unrelated
|
|
2553
|
+
// command evidence never clears another check's blocker; an unproven or failed
|
|
2554
|
+
// run (success:false) never clears anything, so the exhausted-retry blocker still
|
|
2555
|
+
// ends the run instead of inviting an automatic recovery loop.
|
|
2556
|
+
if (next.blocker?.kind === 'verification-loop' && next.blocker.command === fingerprint) {
|
|
2557
|
+
next.blocker = null;
|
|
2558
|
+
}
|
|
2559
|
+
} else if (!unproven) {
|
|
2419
2560
|
const streakFingerprint = next.failedVerificationStreak?.fingerprint;
|
|
2420
2561
|
const streakCount = streakFingerprint === fingerprint
|
|
2421
2562
|
? Number(next.failedVerificationStreak.count || 0) + 1
|
|
@@ -3355,8 +3496,9 @@ function recoveryInstruction(missingEvidence, ledger = {}, routeSummary = {}, {
|
|
|
3355
3496
|
}
|
|
3356
3497
|
// A pipe-masked run is UNPROVEN, not failed: name the real gap (no provable exit status)
|
|
3357
3498
|
// instead of telling the model to "fix the failure" of a run that may have passed.
|
|
3499
|
+
// Unproven never sets `verificationFailed`, so this must not key off that flag.
|
|
3358
3500
|
const lastVerification = [...(ledger.receipts || [])].reverse().find((r) => r?.kind === 'verification');
|
|
3359
|
-
if (!instruction &&
|
|
3501
|
+
if (!instruction && lastVerification?.exitStatusUnproven === true) {
|
|
3360
3502
|
instruction = `The last verification (\`${String(lastVerification.command || '').slice(0, 80)}\`) ran in a pipeline, so its exit status is unproven. Re-run it without the pipe, or with \`set -o pipefail\`, and inspect the exit code.`;
|
|
3361
3503
|
}
|
|
3362
3504
|
if (!instruction && ledger.verificationFailed) {
|
|
@@ -4442,6 +4584,17 @@ async function runEvaluateStop() {
|
|
|
4442
4584
|
if (result.continue) {
|
|
4443
4585
|
if (result.finalNotice) await markNotified(projectRoot, payload, ledger);
|
|
4444
4586
|
else await incrementContinuation(projectRoot, payload, ledger, state?.requestKey || null, evidencePromptKey(state));
|
|
4587
|
+
// Provenance: the harness will re-inject this reason as a prompt. Bind it to this
|
|
4588
|
+
// session+task+exact-reason (hash) so the router can recognise our own continuation
|
|
4589
|
+
// (advisory — a failed write only leaves it unbound).
|
|
4590
|
+
try {
|
|
4591
|
+
await recordPendingCompletionContinuation(projectRoot, {
|
|
4592
|
+
sessionId: explicitSessionId(payload),
|
|
4593
|
+
taskInstanceId: state?.taskInstanceId || null,
|
|
4594
|
+
contract: state?.routeSummary?.executionContract?.taskKind || null,
|
|
4595
|
+
reason: result.reason,
|
|
4596
|
+
});
|
|
4597
|
+
} catch { /* advisory */ }
|
|
4445
4598
|
process.stdout.write(`${JSON.stringify({ decision: 'block', reason: result.reason })}\n`);
|
|
4446
4599
|
return;
|
|
4447
4600
|
}
|