@ngockhoale/ukit 3.4.37 → 3.4.39
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 +15 -0
- package/package.json +1 -1
- package/template_project/.claude/hooks/skill-router.sh +3 -0
- package/template_project/.claude/ukit/index/route-task.mjs +3 -0
- package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +19 -8
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +46 -6
- package/template_project/.claude/ukit/runtime/operation-evidence.mjs +12 -6
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,21 @@ All notable changes to UKit are documented here.
|
|
|
9
9
|
- New `/ukit:ci-check` command + `ci-check.mjs` helper (core pack): prechecks, fail-closed budget guard, local preflight, at most one dispatch per invocation, write-ahead run-id state, `status` (never dispatches), `--ref`, `--dry-run`, per-OS report. Never pushes, merges or publishes.
|
|
10
10
|
- Default-branch bootstrap stub documented in `docs/design/CI-CROSS-PLATFORM-MATRIX.md` (requires separate approval).
|
|
11
11
|
|
|
12
|
+
## 3.4.39 - 2026-10-12
|
|
13
|
+
|
|
14
|
+
**Completion Gate: unrelated compound commands no longer erase authoritative git evidence; seals honour operation freshness.**
|
|
15
|
+
- Root cause: a compound command such as `git rev-parse HEAD && git status --porcelain && npm view …` was parsed as a whole-tree `git status`, its mixed stdout read as a dirty tree, and that failed state record replaced the earlier valid `git ls-remote` proof (`missing operation-verification`).
|
|
16
|
+
- Fix: a git state observation is only minted from ONE unmasked, transport-successful command; compound/masked/failed ones mint nothing and cannot overwrite proof. A standalone dirty status or a diverged remote still invalidates earlier success.
|
|
17
|
+
- Seals now record the operation sequence: a later git mutation, failed state observation, or remote sha != HEAD voids the seal (previously only content fingerprint did).
|
|
18
|
+
- Regressions: `completionClosure.test.js` (full 7-step sequence through the real hook chain), `gitSyncPostcondition.test.js`. No real Windows run.
|
|
19
|
+
|
|
20
|
+
## 3.4.38 - 2026-10-12
|
|
21
|
+
|
|
22
|
+
**Completion Gate safety closure: mutation orders always get a contract; git sync needs authoritative remote proof.**
|
|
23
|
+
- Issue 1 root cause: `isInformationalPrompt`'s consult-shape (a stray "Should I …?" / "…hay không" / "có phải") overrode a surviving imperative, so a real mutation order routed `informational` with no completion contract and Stop released with a non-blocking "nothing was verified" notice. Fix (router + `route-task.mjs` parity): a consult shape never demotes a prompt that still has a mutation order. Pure consults ("Should I fix X?") stay informational and never demand write evidence.
|
|
24
|
+
- Issue 2: the 3.4.37 `git status -sb` `synced-state` proof read a local tracking ref (stale-able) and is removed. A masked commit/push is now discharged only by a standalone, unmasked `git ls-remote <remote> refs/heads/<branch>` observed after every mutation attempt whose sha equals live HEAD, on the branch's own tracking remote, with a clean worktree (gate-time `gitAuthority`). Remote behind, stale tracking ref, uncommitted changes, wrong remote/branch, forged/piped/failed output, other task/session: BLOCK.
|
|
25
|
+
- Regressions through the real hook chain: `tests/hooks/completionClosure.test.js`; unit: `gitSyncPostcondition.test.js`. No real Windows run.
|
|
26
|
+
|
|
12
27
|
## 3.4.37 - 2026-10-12
|
|
13
28
|
|
|
14
29
|
**Completion Gate: a clean, in-sync branch observation discharges a masked commit+push.**
|
package/package.json
CHANGED
|
@@ -2068,6 +2068,9 @@ function applyTaskSemantics(contract, semantic) {
|
|
|
2068
2068
|
|| /\b(?:chua|duoc khong|dung khong|khong nhi|nhi|roi)\s*[.!?]*\s*$/.test(folded)
|
|
2069
2069
|
|| (!politeAsk && /\b(?:khong|không)\s*[?.!]?\s*$/.test(folded))
|
|
2070
2070
|
|| /\bchi\s+(?:hoi|tu\s+van|giai\s+thich|xem)\b/.test(folded);
|
|
2071
|
+
// P1: a consult-shaped clause ("Should I also bump…?", a Vietnamese tail) must never demote a prompt that
|
|
2072
|
+
// still carries a real imperative order — otherwise the order loses its completion contract.
|
|
2073
|
+
if (consultShape && hasMutationOrder({ promptText, commandText })) return false;
|
|
2071
2074
|
if (scores) {
|
|
2072
2075
|
if (!consultShape && (
|
|
2073
2076
|
scores.implementSignal
|
|
@@ -4325,6 +4325,9 @@ function isInformationalPrompt({
|
|
|
4325
4325
|
|| /\b(?:chua|duoc khong|dung khong|khong nhi|nhi|roi)\s*[.!?]*\s*$/.test(folded)
|
|
4326
4326
|
|| (!politeAsk && /\b(?:khong|không)\s*[?.!]?\s*$/.test(folded))
|
|
4327
4327
|
|| /\bchi\s+(?:hoi|tu\s+van|giai\s+thich|xem)\b/.test(folded);
|
|
4328
|
+
// P1: a consult-shaped clause ("Should I also bump…?", a Vietnamese tail) must never demote a prompt that
|
|
4329
|
+
// still carries a real imperative order — otherwise the order loses its completion contract.
|
|
4330
|
+
if (consultShape && hasMutationOrder({ promptText, commandText })) return false;
|
|
4328
4331
|
if (scores) {
|
|
4329
4332
|
if (!consultShape && (
|
|
4330
4333
|
scores.implementSignal
|
|
@@ -51,9 +51,10 @@ const GIT_MUTATION = /^git\s+(?:push|commit|clean|reset|merge|rebase|stash|tag|c
|
|
|
51
51
|
const GIT_VERB = /^git\s+(push|commit|clean|reset|merge|rebase|stash|tag|checkout|restore|pull|cherry-pick|branch)\b/;
|
|
52
52
|
const GIT_STATE = /^git\s+(?:fsck\b|diff\s+(?:--check|--exit-code|--quiet)\b|status\s+(?:--porcelain|-s|--short)\b)/;
|
|
53
53
|
const SERVICE_MUTATION = /^(?:docker(?:\s+compose)?|docker-compose)\s+(?:restart|up|down|stop|start|build)\b|^(?:sudo\s+)?systemctl\s+(?:restart|start|stop|reload)\b|^pm2\s+(?:restart|reload|start)\b|^kubectl\s+(?:rollout|apply)\b|^launchctl\s+kickstart\b|^(?:\.\/)?(?:scripts\/)?deploy[\w.-]*(?:\s|$)|^(?:npm|yarn|pnpm)\s+(?:run\s+)?deploy\b/;
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
54
|
+
// Authoritative remote observation: `git ls-remote <remote> refs/heads/<branch>` asks the remote itself (a local
|
|
55
|
+
// tracking ref / `git status -sb` can be stale). The gate later requires that sha to equal live HEAD on the
|
|
56
|
+
// branch's own tracking remote with a clean tree, so a masked commit/push is recovered without re-running it.
|
|
57
|
+
const GIT_LS_REMOTE = /^git\s+ls-remote\s+(\S+)\s+(refs\/heads\/\S+)\s*$/;
|
|
57
58
|
const DRY_RUN = /(?:^|\s)(?:--dry-run|--check|--no-act|-n)(?:\s|=|$)/;
|
|
58
59
|
const CURL = /^curl\s/;
|
|
59
60
|
function requiredTargetOf(segment, required) {
|
|
@@ -71,14 +72,19 @@ export function bashOperationReceipt(command, response, transportSuccess, goal,
|
|
|
71
72
|
const real = command.split('&&').map((seg) => seg.trim());
|
|
72
73
|
const text = responseText(response);
|
|
73
74
|
const hasOutputField = typeof response === 'string' || typeof response?.stdout === 'string' || typeof response?.output === 'string';
|
|
74
|
-
let role = null; let proven = false; let target = null; let
|
|
75
|
+
let role = null; let proven = false; let target = null; let remoteSync = null;
|
|
75
76
|
for (const seg of real) {
|
|
76
77
|
if (!seg || /^cd\s+\S+$/.test(seg) || DRY_RUN.test(seg.replace(/'[^']*'|"[^"]*"/g, '""'))) continue;
|
|
77
78
|
if (domain === 'git-integrity') {
|
|
78
|
-
|
|
79
|
+
const lsRemote = seg.match(GIT_LS_REMOTE);
|
|
80
|
+
if (lsRemote) {
|
|
79
81
|
const lines = text.split('\n').map((l) => l.trimEnd()).filter(Boolean);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
const hit = hasOutputField && lines.length === 1 ? lines[0].match(/^([0-9a-f]{40}|[0-9a-f]{64})\t(refs\/heads\/\S+)$/) : null;
|
|
83
|
+
role = 'state';
|
|
84
|
+
if (hit && hit[2] === lsRemote[2] && lsRemote[1].length <= 256 && lsRemote[2].length <= 256) {
|
|
85
|
+
remoteSync = { remote: lsRemote[1], ref: hit[2], sha: hit[1] };
|
|
86
|
+
proven = true;
|
|
87
|
+
}
|
|
82
88
|
} else if (GIT_STATE.test(seg)) {
|
|
83
89
|
const wanted = goal.check;
|
|
84
90
|
const kind = /\bfsck\b/.test(seg) ? 'fsck' : /\bdiff\b/.test(seg) ? 'diff-check' : 'status';
|
|
@@ -109,11 +115,16 @@ export function bashOperationReceipt(command, response, transportSuccess, goal,
|
|
|
109
115
|
}
|
|
110
116
|
}
|
|
111
117
|
if (!role) return null;
|
|
118
|
+
// A git STATE observation is attributable only when it is one unmasked, transport-successful command: in a
|
|
119
|
+
// compound/masked chain the stdout of its parts is mixed, so it can neither prove nor refute the state and
|
|
120
|
+
// must not overwrite earlier authoritative evidence. (A standalone dirty status still records as failed.)
|
|
121
|
+
if (domain === 'git-integrity' && role === 'state'
|
|
122
|
+
&& (masked || transportSuccess !== true || real.filter((seg) => seg && !/^cd\s+\S+$/.test(seg)).length !== 1)) return null;
|
|
112
123
|
const ok = transportSuccess === true && !masked && Boolean(scope) && (role === 'mutation' || proven);
|
|
113
124
|
return { kind: 'operation', success: ok, transportSuccess: transportSuccess === true, harness,
|
|
114
125
|
...(masked ? { exitStatusUnproven: true } : {}),
|
|
115
126
|
operation: { domain, role, scope, target: role === 'state' ? null : target,
|
|
116
|
-
...(role === 'state' ? { state: 'verified', complete: ok, ...(
|
|
127
|
+
...(role === 'state' ? { state: 'verified', complete: ok, ...(remoteSync && ok ? { remoteSync } : {}) } : {}) } };
|
|
117
128
|
}
|
|
118
129
|
|
|
119
130
|
// Release evidence: `npm publish` and `npm view <pkg> version` as REAL Bash tool results. The command must
|
|
@@ -3004,7 +3004,7 @@ function requiredEvidence(state = {}) {
|
|
|
3004
3004
|
function evidenceSatisfied(evidence, ledger = {}, state = {}, { cwd } = {}) {
|
|
3005
3005
|
const routeSummary = state?.routeSummary || {};
|
|
3006
3006
|
if (evidence === 'operation-verification') {
|
|
3007
|
-
return operationCompletionPolicy(scopedContract(state, ledger), null, ledger)?.complete === true;
|
|
3007
|
+
return operationCompletionPolicy(scopedContract(state, ledger), null, withGitAuthority(ledger, cwd))?.complete === true;
|
|
3008
3008
|
}
|
|
3009
3009
|
if (evidence === 'write-evidence') {
|
|
3010
3010
|
const expected = routeSummary.expectedSourceFiles || [];
|
|
@@ -3272,6 +3272,21 @@ function scopedContract(state, ledger) {
|
|
|
3272
3272
|
scope: contract.operationGoal.scope || (session ? `session:${session}` : null) } };
|
|
3273
3273
|
}
|
|
3274
3274
|
|
|
3275
|
+
// Gate-time local git truth for the remote-parity proof (cwd-bound, so it lives here, not in the pure evidence module).
|
|
3276
|
+
function gitAuthority(cwd) {
|
|
3277
|
+
if (!cwd) return null;
|
|
3278
|
+
const head = gitOutput(['rev-parse', 'HEAD'], cwd);
|
|
3279
|
+
const branch = gitOutput(['symbolic-ref', '--short', 'HEAD'], cwd);
|
|
3280
|
+
if (!head || !branch) return null;
|
|
3281
|
+
const remote = gitOutput(['config', `branch.${branch}.remote`], cwd);
|
|
3282
|
+
const dirty = gitOutput(['status', '--porcelain'], cwd);
|
|
3283
|
+
return { head, branch, remote, clean: dirty === null && gitOutput(['rev-parse', '--git-dir'], cwd) !== null };
|
|
3284
|
+
}
|
|
3285
|
+
function withGitAuthority(ledger, cwd) {
|
|
3286
|
+
const gitAuth = ledger && Object.keys(ledger).length ? gitAuthority(cwd) : null;
|
|
3287
|
+
return gitAuth ? { ...ledger, gitAuthority: gitAuth } : ledger;
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3275
3290
|
function effectiveRequestLedger(state = {}, ledger = {}) {
|
|
3276
3291
|
if (state?.sessionId && ledger?.sessionId && state.sessionId !== ledger.sessionId) return {};
|
|
3277
3292
|
if (state?.taskInstanceId || ledger?.taskInstanceId) {
|
|
@@ -3394,7 +3409,7 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
|
|
|
3394
3409
|
// continuation budget — the request already accumulated. A different prompt keeps a clean slate.
|
|
3395
3410
|
const effectiveLedger = effectiveRequestLedger(state, ledger);
|
|
3396
3411
|
const operationStatus = operationCompletionPolicy(scopedContract(state, effectiveLedger),
|
|
3397
|
-
null, effectiveLedger);
|
|
3412
|
+
null, withGitAuthority(effectiveLedger, cwd));
|
|
3398
3413
|
const operationalTask = operationStatus !== null;
|
|
3399
3414
|
const missingEvidence = evidence.filter((item) => !evidenceSatisfied(item, effectiveLedger, state, { cwd }));
|
|
3400
3415
|
if (operationalTask && !operationStatus.complete && !missingEvidence.includes('operation-verification')) missingEvidence.push('operation-verification');
|
|
@@ -3778,14 +3793,39 @@ async function readSeal(projectRoot, payload, taskInstanceId) {
|
|
|
3778
3793
|
return seal && typeof seal === 'object' ? seal : null;
|
|
3779
3794
|
}
|
|
3780
3795
|
|
|
3781
|
-
|
|
3796
|
+
// Operation freshness of a seal: the newest operation-proof sequence at seal time. After it, any NEW mutation
|
|
3797
|
+
// attempt, any failed state observation, or a remote observation whose sha is no longer HEAD voids the seal
|
|
3798
|
+
// (successful unrelated observations do not). Content fingerprint alone cannot see remote/mutation changes.
|
|
3799
|
+
function operationSeq(ledger) {
|
|
3800
|
+
let max = 0;
|
|
3801
|
+
for (const [key, entry] of Object.entries(ledger?.operationBank || {})) {
|
|
3802
|
+
if (key === 'seenToolUseIds' || key === 'overflow') continue;
|
|
3803
|
+
for (const proof of [entry?.mutation, entry?.state]) if (Number.isSafeInteger(proof?.sequence)) max = Math.max(max, proof.sequence);
|
|
3804
|
+
}
|
|
3805
|
+
return max;
|
|
3806
|
+
}
|
|
3807
|
+
function sealOperationsFresh(seal, ledger, projectRoot) {
|
|
3808
|
+
const since = Number.isSafeInteger(seal?.opSeq) ? seal.opSeq : 0;
|
|
3809
|
+
const head = gitOutput(['rev-parse', 'HEAD'], projectRoot);
|
|
3810
|
+
for (const [key, entry] of Object.entries(ledger?.operationBank || {})) {
|
|
3811
|
+
if (key === 'seenToolUseIds' || key === 'overflow') continue;
|
|
3812
|
+
if (entry?.mutation?.sequence > since) return false;
|
|
3813
|
+
const st = entry?.state;
|
|
3814
|
+
if (!st) continue;
|
|
3815
|
+
if (st.sequence > since && st.success !== true) return false;
|
|
3816
|
+
if (st.operation?.remoteSync && st.operation.remoteSync.sha !== head) return false;
|
|
3817
|
+
}
|
|
3818
|
+
return !ledger?.operationBank?.overflow;
|
|
3819
|
+
}
|
|
3820
|
+
|
|
3821
|
+
async function writeSeal(projectRoot, payload, state, result, ledger) {
|
|
3782
3822
|
const taskInstanceId = state?.taskInstanceId;
|
|
3783
3823
|
const contentFp = contentFingerprint(projectRoot);
|
|
3784
3824
|
if (!taskInstanceId || !contentFp || result.complete !== true) return;
|
|
3785
3825
|
if (!(requiredEvidence(state).length > 0)) return;
|
|
3786
3826
|
const file = sealPath(projectRoot, payload);
|
|
3787
3827
|
const seals = await readJson(file, {}) || {};
|
|
3788
|
-
seals[taskInstanceId] = { contentFp, headSha: gitOutput(['rev-parse', 'HEAD'], projectRoot), ts: Date.now() };
|
|
3828
|
+
seals[taskInstanceId] = { contentFp, headSha: gitOutput(['rev-parse', 'HEAD'], projectRoot), opSeq: operationSeq(ledger), ts: Date.now() };
|
|
3789
3829
|
const keep = Object.entries(seals).sort((a, b) => (b[1]?.ts || 0) - (a[1]?.ts || 0)).slice(0, SEAL_MAX);
|
|
3790
3830
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
3791
3831
|
await writeJsonAtomic(file, Object.fromEntries(keep));
|
|
@@ -3828,12 +3868,12 @@ async function runEvaluateStop() {
|
|
|
3828
3868
|
if (result.continue === true && !result.finalNotice) {
|
|
3829
3869
|
try {
|
|
3830
3870
|
const seal = await readSeal(projectRoot, payload, state?.taskInstanceId);
|
|
3831
|
-
if (seal?.contentFp && contentFingerprint(projectRoot) === seal.contentFp) {
|
|
3871
|
+
if (seal?.contentFp && contentFingerprint(projectRoot) === seal.contentFp && sealOperationsFresh(seal, ledger, projectRoot)) {
|
|
3832
3872
|
result = { continue: false, notify: false, complete: true, missingEvidence: [], gateRelease: 'sealed-complete' };
|
|
3833
3873
|
}
|
|
3834
3874
|
} catch { /* a seal read failure keeps the strict gate */ }
|
|
3835
3875
|
} else if (result.complete === true && (!result.gateRelease || result.gateRelease === 'native-operation-verified')) {
|
|
3836
|
-
try { await writeSeal(projectRoot, payload, state, result); } catch { /* advisory */ }
|
|
3876
|
+
try { await writeSeal(projectRoot, payload, state, result, ledger); } catch { /* advisory */ }
|
|
3837
3877
|
}
|
|
3838
3878
|
|
|
3839
3879
|
// TASK-C85-019 fix C: a mutating-mode route released by a no-mutation valve
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Adapter-normalized proof only; neither native output nor agent prose is evidence here.
|
|
2
2
|
const MAX_OPERATIONS = 16;
|
|
3
|
+
const validRemoteSync = (r) => Boolean(r) && typeof r === 'object' && ['remote', 'ref', 'sha'].every((k) => typeof r[k] === 'string' && r[k].length > 0 && r[k].length <= 256);
|
|
3
4
|
export function compactOperation(operation) {
|
|
4
5
|
if (!operation || typeof operation !== 'object' || Array.isArray(operation)) return { invalid: true };
|
|
5
6
|
const bounded = (value) => typeof value === 'string' && value.length > 0 && value.length <= 256;
|
|
@@ -12,7 +13,7 @@ export function compactOperation(operation) {
|
|
|
12
13
|
return { domain: operation.domain, scope: operation.scope, role: operation.role, target: operation.target,
|
|
13
14
|
...(operation.state !== undefined ? { state: operation.state } : {}),
|
|
14
15
|
...(operation.presentTargets !== undefined ? { presentTargets: operation.presentTargets === null ? null : [...operation.presentTargets] } : {}),
|
|
15
|
-
complete: operation.complete === true, ...(operation.
|
|
16
|
+
complete: operation.complete === true, ...(validRemoteSync(operation.remoteSync) ? { remoteSync: { ...operation.remoteSync } } : {}) };
|
|
16
17
|
}
|
|
17
18
|
export function foldOperationEvidence(bank = {}, receipt, ledger) {
|
|
18
19
|
const raw = receipt?.operation;
|
|
@@ -46,7 +47,7 @@ export function foldOperationEvidence(bank = {}, receipt, ledger) {
|
|
|
46
47
|
...(op.state !== undefined ? { state: op.state } : {}),
|
|
47
48
|
...(op.presentTargets !== undefined ? { presentTargets: [...op.presentTargets] } : {}),
|
|
48
49
|
complete: op.complete === true,
|
|
49
|
-
...(op.
|
|
50
|
+
...(validRemoteSync(op.remoteSync) ? { remoteSync: { ...op.remoteSync } } : {}),
|
|
50
51
|
} };
|
|
51
52
|
return { ...bank, seenToolUseIds: receipt.toolUseId ? [...seen, receipt.toolUseId] : seen,
|
|
52
53
|
[key]: { ...prior, [op.role]: proof } };
|
|
@@ -76,10 +77,15 @@ export function operationEvidenceStatus(goal, ledger = {}) {
|
|
|
76
77
|
&& (absent || (!Object.hasOwn(op || {}, 'presentTargets') && op?.target === goal.target));
|
|
77
78
|
if (!observation?.success || !observation.toolUseId || !Number.isSafeInteger(observation.sequence)
|
|
78
79
|
|| op.complete !== true || !stateMatches) return incomplete;
|
|
79
|
-
// Independent postcondition
|
|
80
|
-
// mutation attempt
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
// Independent postcondition (masked/failed commit+push recovery): the REMOTE itself (git ls-remote, observed
|
|
81
|
+
// after every mutation attempt) must report live HEAD on this branch's own tracking remote, with a clean tree.
|
|
82
|
+
// `ledger.gitAuthority` is the gate-time local truth; without it (or on any mismatch) nothing is proven.
|
|
83
|
+
const auth = ledger.gitAuthority;
|
|
84
|
+
if (goal.domain === 'git-integrity' && validRemoteSync(op.remoteSync) && auth && auth.clean === true
|
|
85
|
+
&& requiredTargets.every((t) => t === 'commit' || t === 'push')
|
|
86
|
+
&& attempts.every((r) => observation.sequence > r.sequence)
|
|
87
|
+
&& op.remoteSync.sha === auth.head && op.remoteSync.ref === `refs/heads/${auth.branch}`
|
|
88
|
+
&& op.remoteSync.remote === auth.remote) return { complete: true, operationProof: 'remote-parity' };
|
|
83
89
|
if (goal.completionSemantics === 'ensure-state' && attempts.length === 0) {
|
|
84
90
|
return { complete: true, operationProof: 'desired-state-only' };
|
|
85
91
|
}
|