@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.37",
3
+ "version": "3.4.39",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- // Whole-tree branch status: a clean tree whose branch line carries no [ahead]/[behind]/gone marker proves
55
- // commit + push landed regardless of how they were run (a masked heredoc chain mints no mutation receipt).
56
- const GIT_SYNC = /^git\s+status\s+(?:-sb|-bs|--short\s+--branch|--branch\s+--short)\s*$/;
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 synced = false;
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
- if (GIT_SYNC.test(seg)) {
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
- role = 'state'; synced = hasOutputField && lines.length === 1 && /^## [^\s.]\S*\.\.\.\S+$/.test(lines[0]);
81
- proven = synced;
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, ...(synced && ok ? { synced: true } : {}) } : {}) } };
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
- async function writeSeal(projectRoot, payload, state, result) {
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.synced === true ? { synced: true } : {}) };
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.synced === true ? { synced: true } : {}),
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: a trusted clean+in-sync branch observation made AFTER every recorded git
80
- // mutation attempt (failed or masked ones included) proves commit/push landed. Never for other targets.
81
- if (goal.domain === 'git-integrity' && op.synced === true && requiredTargets.every((t) => t === 'commit' || t === 'push')
82
- && attempts.every((r) => observation.sequence > r.sequence)) return { complete: true, operationProof: 'synced-state' };
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
  }