@ngockhoale/ukit 3.4.70 → 3.4.72

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
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.4.72 - 2026-10-28
6
+
7
+ - Native evidence: reject background, masked and test-expression exits as operation/verification proof; retain supported foreground controls across shared Claude Code/Codex/OMP fixtures. No live-host certification.
8
+ - Completion: retained publication-only debt uses task-owned registry proof only without a real operation goal; unrelated Git/service duties remain enforced.
9
+ - Self-improve: serialize deferred AUTO queue replay/merge, retain snapshots/provenance and corrupt/legacy entries, drain only written tasks; dry-run remains read-only.
10
+ - Release recovery: bind mandatory phase exits and independent final review to unchanged candidate bytes; publish lifecycle refuses missing prerequisites. Local evidence files are integrity checks, not cryptographic identity proof. Final acceptance and isolated registry consumers remain pending; 3.4.71 incident evidence is preserved below.
11
+
12
+ ## 3.4.71 - 2026-10-28
13
+
14
+ **F-7 intent scope, release-freeze coordination and Claude Code operation-receipt exit proof; no native-host certification.**
15
+
16
+ - Classification (F-7): an inspection verb followed by an interrogative object ("review how the publish and deploy flow works", "explain why update failed") no longer lets its coordinated fragments become orders. The collapse ends at `then` or any coordinator followed by an action verb, so real orders after the question (including bare-verb, Vietnamese and adverb-interposed forms) keep their kind. Independent review probed ~490 prompt pairs against 3.4.70 with no remaining executable-to-READ_ONLY regression except intended compound-noun cases.
17
+ - Self-improve: added a verifier freeze marker and deferred AUTO queue. Later review found repeated frozen passes could overwrite queued proposals; the original no-loss claim was incorrect.
18
+ - Completion evidence: added Claude Code clean-PostToolUse shell-exit inference. Later review found background-child attribution unsafe; shell success alone is not native-operation success. No live-host certification.
19
+ - Verification: full `yarn test` 698 files / 11,371 passed / 3 skipped, exit 0; official verifier PASS. Unpacked budget 11,200,000 owner-approved. Published before final independent CHANGES-REQUESTED review; normal-update testing also changed the owner's global installation instead of staying isolated. 3.4.71 was published, not fully accepted. Wrapper/correlation/stale-obligation and P2 work remained open.
20
+
5
21
  ## 3.4.70 - 2026-10-26
6
22
 
7
23
  **Task-owned completion evidence and bounded reminder recovery; no native-host certification.**
@@ -4,6 +4,7 @@ description: >-
4
4
  Machine-readable ownership/source registry for every documentation surface.
5
5
  Hand-authored per SPEC §7; enforced by tests/consistency/docRegistry.test.js.
6
6
  entries:
7
+ - {id: release-3472, path: docs/analysis/RELEASE-3472-EVIDENCE-INTEGRITY.md, class: canonical, audience: [maintainer, agent], owner: release, source_of_truth: docs/analysis/RELEASE-3472-EVIDENCE-INTEGRITY.md, merge_strategy: none, load_policy: on-demand, validation: [manual], archive_policy: never}
7
8
  - id: systematic-loop-audit-2026-10-26
8
9
  path: docs/analysis/SYSTEMATIC-LOOP-AUDIT-2026-10-26.md
9
10
  class: canonical
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.70",
3
+ "version": "3.4.72",
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",
@@ -34,6 +34,7 @@
34
34
  "!scripts/dev/capture-omp-stall.mjs",
35
35
  "!scripts/install/generate-semantic-engine.mjs",
36
36
  "!scripts/release/progress-reporter.mjs",
37
+ "!scripts/release/publish-guard-core.mjs",
37
38
  "!scripts/audit/c100/attribution-gap.mjs",
38
39
  "!scripts/audit/c100/implementer-attribution.mjs",
39
40
  "!scripts/audit/c100/trees-lifetimes.mjs",
@@ -76,6 +77,7 @@
76
77
  "test:artifact": "vitest run tests/integration/packageArtifact.test.js tests/integration/artifactReferenceIntegrity.test.js",
77
78
  "test:release-core": "vitest run --exclude tests/integration/packageArtifact.test.js",
78
79
  "release:verify": "node ./scripts/release/verify-release.mjs",
80
+ "prepublishOnly": "node ./scripts/release/publish-guard.mjs",
79
81
  "test": "vitest run",
80
82
  "test:liveness": "vitest run tests/liveness/",
81
83
  "test:handoff": "node ./scripts/test/run-handoff-isolated.mjs",
@@ -0,0 +1,23 @@
1
+ import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // Release freeze marker: while a release verifier owns a candidate fingerprint, generators that write
5
+ // into the candidate tree (self-improve AUTO tasks) must defer. Lives under .ukit/storage/, which the
6
+ // fingerprint excludes. A marker is live only while its owner pid is alive and within the TTL.
7
+ export const FREEZE_REL = path.join('.ukit', 'storage', 'release-freeze.json');
8
+ const TTL_MS = 3 * 60 * 60 * 1000;
9
+
10
+ export function engageFreeze(root, info = {}) {
11
+ const file = path.join(root, FREEZE_REL);
12
+ mkdirSync(path.dirname(file), { recursive: true });
13
+ writeFileSync(file, `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString(), ...info })}\n`);
14
+ return () => { try { if (JSON.parse(readFileSync(file, 'utf8')).pid === process.pid) unlinkSync(file); } catch { /* already thawed */ } };
15
+ }
16
+
17
+ export function activeFreeze(root, now = Date.now()) {
18
+ let marker;
19
+ try { marker = JSON.parse(readFileSync(path.join(root, FREEZE_REL), 'utf8')); } catch { return null; }
20
+ if (!marker || !Number.isInteger(marker.pid) || now - Date.parse(marker.startedAt) > TTL_MS) return null;
21
+ try { process.kill(marker.pid, 0); } catch (error) { if (error.code !== 'EPERM') return null; }
22
+ return marker;
23
+ }
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ // Shipped prepublishOnly dispatcher for the release publish gate (3.4.72).
3
+ // In the UKit development repository it hands over to the maintainer-only gate core
4
+ // (scripts/release/publish-guard-core.mjs, which enforces verification PASS, independent
5
+ // exactly-APPROVED review and genuine owner authorization before any npm publish).
6
+ // In an installed copy of @ngockhoale/ukit the core does not exist — publishing happens
7
+ // only from the development repository — and this script must fail CLOSED with a clear
8
+ // refusal, never a MODULE_NOT_FOUND crash.
9
+ import { existsSync } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const core = path.join(path.dirname(fileURLToPath(import.meta.url)), 'publish-guard-core.mjs');
14
+ if (!existsSync(core)) {
15
+ console.error('[release:publish-guard] REFUSED: the release publish gate is maintainer-only and is not part of the installed @ngockhoale/ukit package.');
16
+ console.error('[release:publish-guard] `npm publish` is only supported from the UKit development repository, where scripts/release/publish-guard-core.mjs enforces the release prerequisites.');
17
+ process.exit(1);
18
+ }
19
+ const gate = await import(core);
20
+ await gate.main();
@@ -0,0 +1,63 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+
4
+ // Single source of truth for the release verifier's mandatory phases: verify-release.mjs executes
5
+ // them in this order, and publish-guard.mjs binds a run's result.json phases to exactly these
6
+ // labels, in this order, at this count — a run may never declare its own step list.
7
+ // Ships with verify-release.mjs so installed --post-publish checks can load.
8
+ export const npmCacheDir = path.join(os.tmpdir(), 'ukit-npm-cache');
9
+
10
+ export const releaseSteps = [
11
+ {
12
+ label: 'Rendered instructions up to date (fix: yarn docs:render)',
13
+ command: 'yarn',
14
+ args: ['docs:render:check'],
15
+ env: process.env,
16
+ },
17
+ {
18
+ label: 'Live documentation preflight (headers, budgets, current status)',
19
+ command: 'yarn',
20
+ args: ['vitest', 'run', 'tests/consistency/liveStateDocs.test.js', 'tests/consistency/advisorStatusDocs.test.js'],
21
+ env: process.env,
22
+ },
23
+ {
24
+ label: 'Consistency suite (template<->active sync, watchdog coverage, render parity)',
25
+ command: 'yarn',
26
+ args: ['vitest', 'run', 'tests/consistency'],
27
+ env: process.env,
28
+ },
29
+ {
30
+ label: 'Artifact smoke',
31
+ command: 'yarn',
32
+ args: ['test:artifact'],
33
+ env: process.env,
34
+ },
35
+ {
36
+ label: 'Doc contracts',
37
+ command: 'yarn',
38
+ args: ['vitest', 'run', 'tests/consistency/docContracts.test.js'],
39
+ env: process.env,
40
+ },
41
+ {
42
+ // Bounded behavioral lane: runs the handoff suite in a disposable copy (never the checkout).
43
+ label: 'Handoff isolated lane',
44
+ command: 'yarn',
45
+ args: ['test:handoff'],
46
+ env: process.env,
47
+ },
48
+ {
49
+ label: 'Core test suite',
50
+ command: 'yarn',
51
+ args: ['test:release-core', '--bail', '5'], // stop after 5 failures instead of finishing a doomed 4 min run
52
+ env: process.env,
53
+ },
54
+ {
55
+ label: 'Local package dry-run',
56
+ command: 'npm',
57
+ args: ['pack', '--dry-run', '--json', '--cache', npmCacheDir],
58
+ env: {
59
+ ...process.env,
60
+ npm_config_cache: npmCacheDir,
61
+ },
62
+ },
63
+ ];
@@ -4,21 +4,14 @@ import crypto from 'node:crypto';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { candidateFingerprint } from './candidate-state.mjs';
7
+ import { engageFreeze } from './freeze.mjs';
8
+ import { npmCacheDir, releaseSteps } from './release-steps.mjs';
7
9
 
8
10
  const rootDir = process.cwd();
9
- const npmCacheDir = path.join(os.tmpdir(), 'ukit-npm-cache');
10
11
 
11
- // BUG-C22-20: every spawned child carries an explicit timeout + killSignal so a
12
- // wedged npm/yarn child (network partition, hung credential helper) fails the
13
- // step instead of hanging the verifier forever. Per-step budgets: release test
14
- // steps are the slowest (full suite); the registry read is a single network call.
15
- // UKIT_VERIFY_STEP_TIMEOUT_MS overrides the step budget for tests.
16
12
  const STEP_TIMEOUT_MS = Number(process.env.UKIT_VERIFY_STEP_TIMEOUT_MS) || 10 * 60 * 1000; // 10 min
17
13
  const REGISTRY_TIMEOUT_MS = Number(process.env.UKIT_VERIFY_REGISTRY_TIMEOUT_MS) || 60 * 1000; // 1 min
18
14
 
19
- // Opt-in post-publish registry-parity check (Release Policy invariant: npm and git always hold
20
- // the same latest version). Only meaningful AFTER `npm publish` — before publish the registry is
21
- // behind by definition — so it is gated behind --post-publish and never runs by default.
22
15
  const postPublish = process.argv.includes('--post-publish');
23
16
 
24
17
  if (postPublish) {
@@ -41,12 +34,8 @@ if (postPublish) {
41
34
  }
42
35
 
43
36
 
44
- // Single-instance guard (full verification takes ~4 min). Starting a second run while one is
45
- // alive (e.g. after the first was moved to the background by a tool timeout) only makes both
46
- // slower and flakier — wait for / read the first run's result instead.
47
37
  let lockPath = null;
48
38
  function acquireVerifyLock() {
49
- // Nested runs (the release suite's own tests spawn this script) and test workers must not contend.
50
39
  if (process.env.UKIT_RELEASE_VERIFY_ACTIVE === '1' || process.env.VITEST || process.env.UKIT_VERIFY_NO_LOCK === '1') return;
51
40
  lockPath = path.join(os.tmpdir(), `ukit-release-verify-${crypto.createHash('sha1').update(rootDir).digest('hex').slice(0, 10)}.lock`);
52
41
  if (existsSync(lockPath)) {
@@ -67,68 +56,15 @@ function acquireVerifyLock() {
67
56
 
68
57
  acquireVerifyLock();
69
58
 
70
- // Cheap, deterministic gates FIRST (seconds): a stale instruction render or a consistency drift
71
- // used to surface only after the ~4 min full suite.
72
- const steps = [
73
- {
74
- label: 'Rendered instructions up to date (fix: yarn docs:render)',
75
- command: 'yarn',
76
- args: ['docs:render:check'],
77
- env: process.env,
78
- },
79
- {
80
- label: 'Live documentation preflight (headers, budgets, current status)',
81
- command: 'yarn',
82
- args: ['vitest', 'run', 'tests/consistency/liveStateDocs.test.js', 'tests/consistency/advisorStatusDocs.test.js'],
83
- env: process.env,
84
- },
85
- {
86
- label: 'Consistency suite (template<->active sync, watchdog coverage, render parity)',
87
- command: 'yarn',
88
- args: ['vitest', 'run', 'tests/consistency'],
89
- env: process.env,
90
- },
91
- {
92
- label: 'Artifact smoke',
93
- command: 'yarn',
94
- args: ['test:artifact'],
95
- env: process.env,
96
- },
97
- {
98
- label: 'Doc contracts',
99
- command: 'yarn',
100
- args: ['vitest', 'run', 'tests/consistency/docContracts.test.js'],
101
- env: process.env,
102
- },
103
- {
104
- // Bounded behavioral lane: runs the handoff suite in a disposable copy (never the checkout).
105
- label: 'Handoff isolated lane',
106
- command: 'yarn',
107
- args: ['test:handoff'],
108
- env: process.env,
109
- },
110
- {
111
- label: 'Core test suite',
112
- command: 'yarn',
113
- args: ['test:release-core', '--bail', '5'], // stop after 5 failures instead of finishing a doomed 4 min run
114
- env: process.env,
115
- },
116
- {
117
- label: 'Local package dry-run',
118
- command: 'npm',
119
- args: ['pack', '--dry-run', '--json', '--cache', npmCacheDir],
120
- env: {
121
- ...process.env,
122
- npm_config_cache: npmCacheDir,
123
- },
124
- },
125
- ];
59
+ const steps = releaseSteps;
126
60
 
127
61
  const runDirectory = mkdtempSync(path.join(os.tmpdir(), 'ukit-release-run-'));
128
62
  const candidate = candidateFingerprint(rootDir);
129
- const run = { pid: process.pid, candidate, startedAt: new Date().toISOString(), phase: null, result: 'running', exitCode: null };
63
+ const run = { pid: process.pid, candidate, startedAt: new Date().toISOString(), phase: null, result: 'running', exitCode: null, expectedPhases: steps.length, phases: [] };
130
64
  const persistRun = () => writeFileSync(path.join(runDirectory, 'result.json'), `${JSON.stringify(run, null, 2)}\n`);
131
65
  persistRun();
66
+ const thaw = engageFreeze(rootDir, { candidate });
67
+ process.on('exit', thaw);
132
68
  console.log(`[release:verify] Candidate ${candidate}; run evidence ${runDirectory}`);
133
69
  process.on('exit', (code) => {
134
70
  run.exitCode = code;
@@ -149,11 +85,20 @@ for (const step of steps) {
149
85
  run.phaseStartedAt = new Date().toISOString();
150
86
  run.phaseResult = null;
151
87
  step.env = { ...step.env, UKIT_VERIFY_PROGRESS_FILE: path.join(runDirectory, `phase-${steps.indexOf(step)}-progress.json`) };
88
+ const candidateBefore = candidateFingerprint(rootDir);
152
89
  persistRun();
153
90
  console.log(`\n[release:verify] ${step.label}`);
154
91
  console.log(`[release:verify] > ${step.command} ${step.args.join(' ')}`);
155
92
 
156
93
  const exitCode = await runStep(step);
94
+ const candidateAfter = candidateFingerprint(rootDir);
95
+ run.phases.push({
96
+ label: step.label,
97
+ ...run.phaseResult,
98
+ candidateBefore,
99
+ candidateAfter,
100
+ });
101
+ persistRun();
157
102
  assertCandidate();
158
103
  if (exitCode !== 0) {
159
104
  console.error(`[release:verify] FAILED at step: ${step.label}`);
@@ -166,16 +111,11 @@ run.result = 'passed';
166
111
  persistRun();
167
112
  console.log('\n[release:verify] All release checks passed.');
168
113
 
169
- // BUG-C22-20: bounded spawn — an armed watchdog SIGKILLs the child's whole
170
- // process group on expiry (detached: true puts the child in its own group so
171
- // grandchildren like `sh -c "sleep …"` can't keep our stdio pipes open).
172
114
  function spawnBounded(command, args, opts, timeoutMs) {
173
115
  const child = spawn(command, args, { ...opts, detached: true });
174
116
  child.ukitTimedOut = false;
175
117
  const watchdog = setTimeout(() => {
176
118
  child.ukitTimedOut = true;
177
- // One bounded native snapshot before termination, never a polling watcher.
178
- // ps contains no argv/environment (credentials); unsupported hosts record the error.
179
119
  if (!postPublish && opts.env?.UKIT_VERIFY_PROGRESS_FILE) try {
180
120
  const snapshot = execFileSync('ps', ['-axo', 'pid,ppid,pgid,stat,etime,time,comm'], {
181
121
  encoding: 'utf8', timeout: 1000, maxBuffer: 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'],
@@ -1,39 +1,29 @@
1
- // codeProposals.js — self-improve code lane (SELF_IMPROVE_CODE_LANE plan).
2
- //
3
- // Turns mined, recurring failure patterns into HANDOFF TASK FILES
4
- // (`docs/AI_HANDOFF/tasks/AUTO-<seq>.md`) — proposals for the normal
5
- // executor + reviewer + tests chain, never code writes or commits from
6
- // self-improve itself. Whitelist-only fix classes; anything outside the
7
- // deterministic mapping is `skipped`.
8
- //
9
- // Contracts:
10
- // * NEVER THROWS — missing dirs, unreadable files, write failures →
11
- // partial result, no exception escapes.
12
- // * Eligible: count >= minCount (default 3) AND sessions >= 2.
13
- // * Whitelist fix classes only (timeout/hang, missing-fallback,
14
- // empty-output, stale-doc); unknown shapes → status 'skipped'.
15
- // * Denylist: targets under src/decision/, src/core/agentRuntime/,
16
- // runtimeConfig, auth/security/secret/credential/token code are never
17
- // proposed.
18
- // * Dedupe: a signature already carried by any `.md` in
19
- // docs/AI_HANDOFF/tasks/ or docs/AI_HANDOFF/archive/ → 'duplicate'.
20
- // * Cap: at most maxTasks (default 3) AUTO files per pass.
21
- // * Writes are confined to docs/AI_HANDOFF/tasks/AUTO-<seq>.md via
22
- // exclusive-create (`wx`); `dryRun` writes nothing.
1
+ // codeProposals.js — self-improve code lane (SELF_IMPROVE_CODE_LANE plan):
2
+ // mined recurring failure patterns → docs/AI_HANDOFF/tasks/AUTO-<seq>.md
3
+ // proposals for the normal executor+reviewer+tests chain (never direct code
4
+ // writes/commits). Contracts: never throws (failures → partial result);
5
+ // eligible = count >= minCount(3) AND sessions >= 2; whitelist fix classes
6
+ // (timeout/hang, missing-fallback, empty-output, stale-doc) else 'skipped';
7
+ // denylist src/decision/, src/core/agentRuntime/, runtimeConfig,
8
+ // auth/security/secret/credential/token targets; signature already in any
9
+ // tasks//archive/ .md → 'duplicate'; cap maxTasks(3)/pass; writes confined to
10
+ // AUTO-<seq>.md via `wx`; dryRun writes nothing.
23
11
 
24
12
  import fs from 'node:fs/promises';
25
13
  import path from 'node:path';
14
+ import { withFileLock, writeFileAtomic } from '../core/fileOps.js';
15
+ import { activeFreeze } from '../../scripts/release/freeze.mjs';
26
16
 
27
17
  const TASKS_DIR_REL = path.join('docs', 'AI_HANDOFF', 'tasks');
28
18
  const ARCHIVE_DIR_REL = path.join('docs', 'AI_HANDOFF', 'archive');
29
19
  const ARTIFACT_REL = path.join('.ukit', 'storage', 'cache', 'failure-patterns.json');
20
+ const DEFERRED_REL = path.join('.ukit', 'storage', 'cache', 'deferred-auto-tasks.json');
30
21
  const PLAN_REL = 'docs/plans/SELF_IMPROVE_CODE_LANE.md';
31
22
  const SIGNATURE_FIELD_RE = /signature["']?\s*:\s*(?:`([^`\n]*)`|"((?:[^"\\\n]|\\.)*)"|'([^'\n]*)')/gi;
32
23
  const MIN_SESSIONS = 2;
33
24
  const AUTO_NAME_RE = /^AUTO-(\d+)\.md$/;
34
25
 
35
- // Protected lanes — the same areas keepMainModelFor guards. A mined pattern
36
- // pointing here is skipped, never proposed.
26
+ // Protected lanes (same areas keepMainModelFor guards) — never proposed.
37
27
  const DENIED_TARGET_RES = [
38
28
  /^src\/decision\//,
39
29
  /^src\/core\/agentRuntime\//,
@@ -43,7 +33,7 @@ const DENIED_TARGET_RES = [
43
33
  /secret|credential|token/i,
44
34
  ];
45
35
 
46
- // Whitelist fix classes — bounded deterministic mapping only.
36
+ // Whitelist — bounded deterministic mapping only.
47
37
  const FIX_CLASSES = [
48
38
  {
49
39
  id: 'timeout-hang',
@@ -72,7 +62,7 @@ function isObject(value) {
72
62
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
73
63
  }
74
64
 
75
- // Evidence haystack: signature + example commands + files, lowercased.
65
+ // Evidence haystack: signature + commands + files, lowercased.
76
66
  function haystack(pattern) {
77
67
  return [
78
68
  pattern.signature,
@@ -81,10 +71,7 @@ function haystack(pattern) {
81
71
  ].join(' ').toLowerCase();
82
72
  }
83
73
 
84
- /**
85
- * Map a mined pattern to a whitelisted fix class.
86
- * @returns {{id: string, change: string}|null}
87
- */
74
+ /** Map a mined pattern to a whitelisted fix class; null = none. */
88
75
  export function mapPatternToFixTarget(pattern) {
89
76
  if (!isObject(pattern)) return null;
90
77
  const hay = haystack(pattern);
@@ -94,10 +81,9 @@ export function mapPatternToFixTarget(pattern) {
94
81
  return null;
95
82
  }
96
83
 
97
- // W3-F4: strip ./ and resolve ../ so syntactic variants can't slip past the
98
- // denylist. On case-insensitive filesystems (win32/darwin) a case variant
99
- // names the same denied file, so the patterns are folded there — Linux keeps
100
- // case-sensitive matching (denylist semantics unchanged, planner note).
84
+ // W3-F4: resolve ./ and ../ so variants can't slip past the denylist; on
85
+ // case-insensitive filesystems (win32/darwin) case-fold the patterns (a case
86
+ // variant names the same denied file); Linux stays case-sensitive.
101
87
  const DENY_TARGET_RES = (process.platform === 'win32' || process.platform === 'darwin')
102
88
  ? DENIED_TARGET_RES.map((re) => (re.ignoreCase ? re : new RegExp(re.source, `${re.flags}i`)))
103
89
  : DENIED_TARGET_RES;
@@ -148,7 +134,7 @@ async function listMarkdownFiles(dir, depth = 0) {
148
134
  return files;
149
135
  }
150
136
 
151
- // Signatures already claimed by a task/archived file + highest AUTO seq.
137
+ // Claimed signatures (tasks+archive .md) + highest AUTO seq.
152
138
  async function scanHandoffDirs(projectRoot) {
153
139
  const signatures = new Set();
154
140
  let maxSeq = 0;
@@ -167,8 +153,7 @@ async function scanHandoffDirs(projectRoot) {
167
153
  for (const match of text.matchAll(SIGNATURE_FIELD_RE)) {
168
154
  const raw = (match[1] ?? match[2] ?? match[3] ?? '').trim();
169
155
  if (!raw) continue;
170
- // JSON "…" captures may carry escapes; decode so the scanned signature
171
- // equals the raw pattern.signature stored in the evidence block.
156
+ // decode JSON escapes so scanned signature == evidence-block pattern.signature
172
157
  let sig = raw;
173
158
  if (match[2] !== undefined) {
174
159
  try {
@@ -184,6 +169,43 @@ async function scanHandoffDirs(projectRoot) {
184
169
  return { signatures, maxSeq };
185
170
  }
186
171
 
172
+ // Deferred AUTO queue — freeze-pushed-back signatures, each with the full
173
+ // vetted pattern snapshot so a thaw replays the deferral even if the original
174
+ // pattern vanished. Merged, never overwritten: first provenance wins; entries
175
+ // whose task now exists drain (no duplicate AUTOs); corrupt file preserved
176
+ // untouched, surfaced via result.error.
177
+ function corruptQueueMessage(reason) {
178
+ return `deferred queue corrupt; preserved without overwrite: ${DEFERRED_REL}${reason ? ` (${reason})` : ''}`;
179
+ }
180
+
181
+ // Returns the RAW array — consumers validate; malformed content survives intact.
182
+ async function loadDeferredQueue(projectRoot) {
183
+ let raw;
184
+ try {
185
+ raw = await fs.readFile(path.join(projectRoot, DEFERRED_REL), 'utf8');
186
+ } catch (err) {
187
+ if (err?.code === 'ENOENT') return { queue: [] };
188
+ // EACCES/EISDIR/...: corrupt, not empty.
189
+ return { queue: [], corrupt: true, reason: err?.code ?? String(err) };
190
+ }
191
+ try {
192
+ const parsed = JSON.parse(raw);
193
+ if (parsed && Array.isArray(parsed.deferred)) return { queue: parsed.deferred };
194
+ } catch {
195
+ // corrupt — reported, never rewritten.
196
+ }
197
+ return { queue: [], corrupt: true, reason: 'malformed content' };
198
+ }
199
+
200
+ async function saveDeferredQueue(projectRoot, queue) {
201
+ const file = path.join(projectRoot, DEFERRED_REL);
202
+ if (queue.length === 0) {
203
+ await fs.rm(file, { force: true });
204
+ return;
205
+ }
206
+ await writeFileAtomic(file, `${JSON.stringify({ deferred: queue }, null, 2)}\n`);
207
+ }
208
+
187
209
  function renderTaskFile({ seq, pattern, fixClass, targets, now }) {
188
210
  const id = `AUTO-${String(seq).padStart(3, '0')}`;
189
211
  const signature = pattern.signature;
@@ -263,15 +285,10 @@ Auto-generated by \`ukit self-improve\` at ${new Date(now).toISOString()} from m
263
285
 
264
286
  /**
265
287
  * Propose AUTO handoff task files from mined failure patterns.
266
- *
267
- * @param {string} projectRoot
268
- * @param {{ minCount?: number, maxTasks?: number, dryRun?: boolean,
269
- * now?: number }} [options]
270
- * @returns {Promise<{generatedAt: string, dryRun: boolean,
271
- * patternsScanned: number, eligible: number, created: number,
272
- * results: Array<{signature: string, fixClass: string|null,
273
- * status: 'proposed'|'duplicate'|'skipped', reason?: string,
274
- * taskFile: string|null, written: boolean}>, error?: string}>}
288
+ * @returns {Promise<{generatedAt: string, dryRun: boolean, patternsScanned:
289
+ * number, eligible: number, created: number, deferred?: number, error?: string,
290
+ * results: Array<{signature: string, fixClass: string|null, status: string,
291
+ * reason?: string, taskFile: string|null, written: boolean}>}>}
275
292
  */
276
293
  export async function proposeCodeFixTasks(projectRoot, {
277
294
  minCount = 3,
@@ -288,14 +305,38 @@ export async function proposeCodeFixTasks(projectRoot, {
288
305
  results: [],
289
306
  };
290
307
 
291
- try {
292
- const patterns = await loadPatterns(projectRoot);
293
- result.patternsScanned = patterns.length;
308
+ // One serialized pass — read → scan → replay → task writes → queue merge —
309
+ // under the shared token-owned file lock: independent processes can neither
310
+ // lose queue entries nor double-create AUTO tasks. Fail-closed: expired
311
+ // wait skips the whole pass (journaled by fileOps) and surfaces an error.
312
+ const runPass = async () => {
313
+ const live = await loadPatterns(projectRoot);
314
+ result.patternsScanned = live.length;
294
315
  const known = await scanHandoffDirs(projectRoot);
316
+ const queueRead = await loadDeferredQueue(projectRoot);
317
+ if (queueRead.corrupt) {
318
+ result.error = corruptQueueMessage(queueRead.reason);
319
+ }
320
+ // Replay queued snapshots (original provenance) first, then unqueued live patterns.
321
+ const candidates = [];
322
+ if (!queueRead.corrupt) {
323
+ for (const entry of queueRead.queue) {
324
+ if (isObject(entry) && isObject(entry.pattern) && typeof entry.pattern.signature === 'string') {
325
+ candidates.push({ pattern: entry.pattern, origin: entry });
326
+ }
327
+ }
328
+ }
329
+ const replaySignatures = new Set(candidates.map((candidate) => candidate.pattern.signature));
330
+ for (const pattern of live) {
331
+ const signature = typeof pattern?.signature === 'string' ? pattern.signature : null;
332
+ if (!signature || replaySignatures.has(signature)) continue;
333
+ candidates.push({ pattern, origin: null });
334
+ }
295
335
  let seq = known.maxSeq;
296
336
  let emitted = 0;
337
+ const deferred = [];
297
338
 
298
- for (const pattern of patterns) {
339
+ for (const { pattern, origin } of candidates) {
299
340
  const signature = typeof pattern?.signature === 'string' ? pattern.signature : null;
300
341
  if (!signature) continue;
301
342
  const entry = {
@@ -344,8 +385,44 @@ export async function proposeCodeFixTasks(projectRoot, {
344
385
 
345
386
  if (dryRun) continue;
346
387
 
388
+ // A release verifier owns the candidate bytes — writing now would
389
+ // invalidate its fingerprint. Record (never drop); regenerate after thaw.
390
+ const recordDeferral = (frozenBy) => {
391
+ entry.status = 'skipped';
392
+ entry.reason = 'release-frozen';
393
+ entry.taskFile = null;
394
+ result.created -= 1;
395
+ emitted -= 1;
396
+ seq -= 1;
397
+ known.signatures.delete(signature);
398
+ // replayed entries are already queued — push/count only new deferrals.
399
+ if (!origin) {
400
+ deferred.push({
401
+ signature,
402
+ fixClass: fixClass.id,
403
+ targets,
404
+ pattern,
405
+ deferredAt: origin?.deferredAt ?? result.generatedAt,
406
+ frozenBy,
407
+ });
408
+ }
409
+ };
410
+
411
+ // A verifier may already be engaged — defer before touching the tree.
412
+ const freezeBefore = activeFreeze(projectRoot);
413
+ if (freezeBefore) {
414
+ recordDeferral(origin?.frozenBy ?? freezeBefore.pid);
415
+ continue;
416
+ }
417
+
347
418
  try {
348
419
  await fs.mkdir(path.join(projectRoot, TASKS_DIR_REL), { recursive: true });
420
+ // Recheck after the awaited mkdir — a verifier may engage mid-pass.
421
+ const freezeNow = activeFreeze(projectRoot);
422
+ if (freezeNow) {
423
+ recordDeferral(origin?.frozenBy ?? freezeNow.pid);
424
+ continue;
425
+ }
349
426
  await fs.writeFile(
350
427
  path.join(projectRoot, TASKS_DIR_REL, fileName),
351
428
  renderTaskFile({ seq, pattern, fixClass, targets, now }),
@@ -358,8 +435,65 @@ export async function proposeCodeFixTasks(projectRoot, {
358
435
  entry.written = false;
359
436
  result.created -= 1;
360
437
  emitted -= 1;
438
+ // undo the propose-time claim: nothing landed, so the queued entry survives.
439
+ known.signatures.delete(signature);
361
440
  }
362
441
  }
442
+ if (!dryRun && !queueRead.corrupt) {
443
+ // Merge, never overwrite: keep earlier deferrals (first provenance
444
+ // wins), drain entries whose task exists, preserve malformed in place.
445
+ try {
446
+ const fresh = await loadDeferredQueue(projectRoot);
447
+ if (fresh.corrupt) {
448
+ result.error = corruptQueueMessage(fresh.reason);
449
+ } else {
450
+ const merged = new Map(); // first wins — earlier provenance is preserved
451
+ for (const item of fresh.queue) {
452
+ if (isObject(item) && typeof item.signature === 'string' && !merged.has(item.signature)) {
453
+ merged.set(item.signature, item);
454
+ }
455
+ }
456
+ for (const item of deferred) {
457
+ if (!merged.has(item.signature)) merged.set(item.signature, item);
458
+ }
459
+ for (const signature of merged.keys()) {
460
+ if (known.signatures.has(signature)) merged.delete(signature);
461
+ }
462
+ // Rebuild: merged versions (drained dropped, on-disk dups once),
463
+ // malformed in place, then append deferrals not yet on disk.
464
+ const out = [];
465
+ const emittedSignatures = new Set();
466
+ for (const item of fresh.queue) {
467
+ if (isObject(item) && typeof item.signature === 'string') {
468
+ if (emittedSignatures.has(item.signature)) continue;
469
+ emittedSignatures.add(item.signature);
470
+ const kept = merged.get(item.signature);
471
+ if (kept) out.push(kept);
472
+ } else {
473
+ out.push(item);
474
+ }
475
+ }
476
+ for (const [signature, item] of merged) {
477
+ if (!emittedSignatures.has(signature)) out.push(item);
478
+ }
479
+ await saveDeferredQueue(projectRoot, out);
480
+ if (deferred.length) result.deferred = deferred.length;
481
+ }
482
+ } catch (err) {
483
+ result.error = `deferred queue save failed: ${err?.message ?? String(err)}`;
484
+ }
485
+ }
486
+ return true; // sentinel: "ran" vs withFileLock skip-undefined
487
+ };
488
+
489
+ try {
490
+ if (dryRun) {
491
+ // dryRun writes nothing — read-only, no lock (acquiring it writes state).
492
+ await runPass();
493
+ } else {
494
+ const ran = await withFileLock(path.join(projectRoot, DEFERRED_REL), runPass);
495
+ if (!ran) result.error = `deferred-queue lock busy; pass skipped: ${DEFERRED_REL}`;
496
+ }
363
497
  } catch (err) {
364
498
  result.error = err?.message ?? String(err);
365
499
  }
@@ -201,9 +201,9 @@ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || ''
201
201
  setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
202
202
 
203
203
  (async () => {
204
- // BEGIN GENERATED SEMANTIC ENGINE sha256=4a3ee8d8789589bab57ab21ad404a2a09a1ff45b31e5d5204c87a2604e305fd2
204
+ // BEGIN GENERATED SEMANTIC ENGINE sha256=914a1f777384deec2f0c27b74e66346855c752f09e8662ccb7f307086088555c
205
205
  // Canonical task semantics; intent never grants authorization or proves completion.
206
- const SEMANTIC_ENGINE_VERSION = 'round4-semantic-1:1dcaa0253e7bc7f4356b02fe5de33b1edda6f0e00d9f2022d9c8cdbe907003a2';
206
+ const SEMANTIC_ENGINE_VERSION = 'round4-semantic-1:092abf191252a5a8f601b66235084726e76c2757b6875cfe5a5243dbbc6f9f6c';
207
207
 
208
208
  function semanticContractIdentity({ promptText = '', targetFile = '', taskInstanceId = null } = {}) {
209
209
  return { taskInstanceId, targetFile, engineVersion: SEMANTIC_ENGINE_VERSION,
@@ -429,7 +429,7 @@ function ordersPublication(promptText = '') {
429
429
  }
430
430
 
431
431
  function ordersPublicationText(promptText = '') {
432
- let active = publicationScope(promptText).text.replace(/```[^]*?```|~~~[^]*?~~~/g, '')
432
+ let active = collapseInspectionSentences(publicationScope(promptText).text).replace(/```[^]*?```|~~~[^]*?~~~/g, '')
433
433
  .replace(/^[ \t]*>.*$/gm, '').replace(/[“"][^”"\n]*[”"]|‘[^’\n]*’/g, '');
434
434
  for (const branch of defectRepairBranches(active)) active = active.replace(branch, '');
435
435
  let ordered = false;
@@ -597,11 +597,19 @@ function classifyTaskSemantics(input = {}) {
597
597
  return analyzeTaskIntent(input).semantic;
598
598
  }
599
599
 
600
+ // A sentence led by an inspection verb whose object is an interrogative clause ("review how the publish and
601
+ // deploy flow works", "explain why update failed") describes behaviour; none of its coordinated fragments is an
602
+ // order. Collapse it to its lead verb so "and"/"then" splitting cannot promote a fragment to an imperative.
603
+ // The collapse stops at "then" or at any coordinator (and/also/comma/roi) followed by an action verb, so an order
604
+ // coordinated after the question stays visible; an action word used as a compound-noun modifier ("deploy flow") is not.
605
+ const INSPECTION_SENTENCE = /(^|[;\n]|\.\s+)([\s*+•>\d.)-]*(?:(?:please|just|hay|vui long|now|also)\s+)*)(audit|inspect|review|analy[sz]e|explain|investigate|read|kiem tra|phan tich|dieu tra)\s+(?:(?:again|carefully|exactly)\s+)?(?:how|what|why|whether|when|where|which|ra sao|the sao|tai sao)\b[^;\n]*?(?=\.(?:\s|$)|[;\n]|$|\s*,?\s*(?:and\s+)?then\b|(?:(?:\s*,\s*(?:(?:and|then|also|but|roi|va|nhung)\s+)*|\s+(?:and|then|also|but|roi|va|nhung)\s+(?:(?:and|then|also)\s+)*)(?:(?:also|now|please|just|finally|afterwards|after that|next|later|eventually|lastly)\s+)*(?:deploy|redeploy|publish|push|fix|commit|release|ship|restart|apply|implement|edit|modify|update|tag|merge|rebase|refactor|rewrite|patch|delete|remove|run|add|build|create|revert|install|migrate|write|change|set|enable|disable|drop|reset|clean|git|npm|yarn|sua|sửa|phat hanh|xoa)\b(?!\s+(?:flow|flows|step|steps|process|processes|pipeline|pipelines|script|scripts|command|commands|logic|path|paths|stage|stages|workflow|workflows|phase|phases|routine|routines|mechanism|handler|handlers|procedure|procedures|policy|policies|lifecycle|sequence|job|jobs|task|tasks|mode|modes|gate|gates)\b)))/gi;
606
+ const collapseInspectionSentences = (text) => text.replace(INSPECTION_SENTENCE, (_m, head, lead, verb) => `${head}${lead}${verb}`);
607
+
600
608
  function actionableClauses(promptText) {
601
- const text = neutralizeBannedLists(neutralizeNonOrders(String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd').toLowerCase()));
609
+ const text = neutralizeBannedLists(neutralizeNonOrders(collapseInspectionSentences(String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd').toLowerCase())));
602
610
  const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/);
603
611
  const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong)\b|^[\s*+•>\d.)-]*(?:(?:vui long|xin|hay|please)\s+)*dung\b/.test(clause)
604
- && !/^[\s*+•>\d.)-]*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause)
612
+ && !/^[\s*+•>\d.)-]*(?:(?:please|just|hay|vui long|now|also)\s+)*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause)
605
613
  // A fragment led by a subordinator continues the previous clause's predicate ("…, including source
606
614
  // fixes F-3" is prose about the past, never a new imperative).
607
615
  && !/^[\s*+•>\d.)-]*(?:including|such as|e\.g\.|i\.e\.|via|per)\b/.test(clause));
@@ -1,6 +1,6 @@
1
1
  // Canonical task semantics; intent never grants authorization or proves completion.
2
2
  import crypto from 'node:crypto';
3
- export const SEMANTIC_ENGINE_VERSION = 'round4-semantic-1:1dcaa0253e7bc7f4356b02fe5de33b1edda6f0e00d9f2022d9c8cdbe907003a2';
3
+ export const SEMANTIC_ENGINE_VERSION = 'round4-semantic-1:092abf191252a5a8f601b66235084726e76c2757b6875cfe5a5243dbbc6f9f6c';
4
4
 
5
5
  export function semanticContractIdentity({ promptText = '', targetFile = '', taskInstanceId = null } = {}) {
6
6
  return { taskInstanceId, targetFile, engineVersion: SEMANTIC_ENGINE_VERSION,
@@ -226,7 +226,7 @@ export function ordersPublication(promptText = '') {
226
226
  }
227
227
 
228
228
  function ordersPublicationText(promptText = '') {
229
- let active = publicationScope(promptText).text.replace(/```[^]*?```|~~~[^]*?~~~/g, '')
229
+ let active = collapseInspectionSentences(publicationScope(promptText).text).replace(/```[^]*?```|~~~[^]*?~~~/g, '')
230
230
  .replace(/^[ \t]*>.*$/gm, '').replace(/[“"][^”"\n]*[”"]|‘[^’\n]*’/g, '');
231
231
  for (const branch of defectRepairBranches(active)) active = active.replace(branch, '');
232
232
  let ordered = false;
@@ -394,11 +394,19 @@ export function classifyTaskSemantics(input = {}) {
394
394
  return analyzeTaskIntent(input).semantic;
395
395
  }
396
396
 
397
+ // A sentence led by an inspection verb whose object is an interrogative clause ("review how the publish and
398
+ // deploy flow works", "explain why update failed") describes behaviour; none of its coordinated fragments is an
399
+ // order. Collapse it to its lead verb so "and"/"then" splitting cannot promote a fragment to an imperative.
400
+ // The collapse stops at "then" or at any coordinator (and/also/comma/roi) followed by an action verb, so an order
401
+ // coordinated after the question stays visible; an action word used as a compound-noun modifier ("deploy flow") is not.
402
+ const INSPECTION_SENTENCE = /(^|[;\n]|\.\s+)([\s*+•>\d.)-]*(?:(?:please|just|hay|vui long|now|also)\s+)*)(audit|inspect|review|analy[sz]e|explain|investigate|read|kiem tra|phan tich|dieu tra)\s+(?:(?:again|carefully|exactly)\s+)?(?:how|what|why|whether|when|where|which|ra sao|the sao|tai sao)\b[^;\n]*?(?=\.(?:\s|$)|[;\n]|$|\s*,?\s*(?:and\s+)?then\b|(?:(?:\s*,\s*(?:(?:and|then|also|but|roi|va|nhung)\s+)*|\s+(?:and|then|also|but|roi|va|nhung)\s+(?:(?:and|then|also)\s+)*)(?:(?:also|now|please|just|finally|afterwards|after that|next|later|eventually|lastly)\s+)*(?:deploy|redeploy|publish|push|fix|commit|release|ship|restart|apply|implement|edit|modify|update|tag|merge|rebase|refactor|rewrite|patch|delete|remove|run|add|build|create|revert|install|migrate|write|change|set|enable|disable|drop|reset|clean|git|npm|yarn|sua|sửa|phat hanh|xoa)\b(?!\s+(?:flow|flows|step|steps|process|processes|pipeline|pipelines|script|scripts|command|commands|logic|path|paths|stage|stages|workflow|workflows|phase|phases|routine|routines|mechanism|handler|handlers|procedure|procedures|policy|policies|lifecycle|sequence|job|jobs|task|tasks|mode|modes|gate|gates)\b)))/gi;
403
+ const collapseInspectionSentences = (text) => text.replace(INSPECTION_SENTENCE, (_m, head, lead, verb) => `${head}${lead}${verb}`);
404
+
397
405
  function actionableClauses(promptText) {
398
- const text = neutralizeBannedLists(neutralizeNonOrders(String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd').toLowerCase()));
406
+ const text = neutralizeBannedLists(neutralizeNonOrders(collapseInspectionSentences(String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd').toLowerCase())));
399
407
  const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/);
400
408
  const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong)\b|^[\s*+•>\d.)-]*(?:(?:vui long|xin|hay|please)\s+)*dung\b/.test(clause)
401
- && !/^[\s*+•>\d.)-]*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause)
409
+ && !/^[\s*+•>\d.)-]*(?:(?:please|just|hay|vui long|now|also)\s+)*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause)
402
410
  // A fragment led by a subordinator continues the previous clause's predicate ("…, including source
403
411
  // fixes F-3" is prose about the past, never a new imperative).
404
412
  && !/^[\s*+•>\d.)-]*(?:including|such as|e\.g\.|i\.e\.|via|per)\b/.test(clause));
@@ -42,18 +42,13 @@ export function cronOperationReceipt(toolName, input, response, transportSuccess
42
42
  operation: { domain: capability.domain, role: capability.role, scope, ...state } };
43
43
  }
44
44
 
45
- // R2/R3: non-source operations (git integrity, service restart + HTTP check). A Bash command is
46
- // proof only when it IS the checked command (not text inside echo/quotes) and its exit status
47
- // is the command's own: no pipe without pipefail, no `;`/newline/`||` chain that can mask it.
48
- // The goal names WHAT was ordered (verbs, services, probe path/status): a receipt for another
49
- // verb, service, repo or endpoint never discharges it, and a dry run is not the operation.
45
+ // R2/R3: attributable native operations only; wrong targets, dry runs and masked exits never discharge goals.
50
46
  const GIT_MUTATION = /^git\s+(?:push|commit|clean|reset|merge|rebase|stash|tag|checkout|restore|pull|cherry-pick|branch)\b/;
51
47
  const GIT_VERB = /^git\s+(push|commit|clean|reset|merge|rebase|stash|tag|checkout|restore|pull|cherry-pick|branch)\b/;
52
48
  const GIT_STATE = /^git\s+(?:fsck\b|diff\s+(?:--check|--exit-code|--quiet)\b|status\s+(?:--porcelain|-s|--short)\b)/;
53
49
  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
- // 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; successful native mutations are required separately.
50
+ // Remote authority, not stale tracking refs: gate binds sha to clean live HEAD/tracking
51
+ // remote. Native mutation success remains a separate requirement.
57
52
  const GIT_LS_REMOTE = /^git\s+ls-remote\s+(\S+)\s+(refs\/heads\/\S+)\s*$/;
58
53
  // Native GitHub repository postcondition: `gh repo view [repo] --json url,visibility` asks GitHub itself
59
54
  // (like ls-remote asks the Git remote). Strict: unmasked single command, BOTH JSON fields present, enum
@@ -66,7 +61,7 @@ const CURL = /^curl\s/;
66
61
  // A leading `cd <dir>;` / `cd <dir> &&` only selects the working directory: it prints nothing and the shell's
67
62
  // exit status is the next command's, so it must not turn an otherwise single, checked command into a "masked"
68
63
  // compound. Only ONE leading cd is dropped; any other `;`, `||`, pipe or extra segment still masks.
69
- const LEADING_CD = /^\s*cd\s+(?:"[^"\n]*"|'[^'\n]*'|[^\s;&|<>$`()]+)\s*(?:;|&&)\s*(?=\S)/;
64
+ const LEADING_CD = /^\s*cd\s+(?:"[^"\n$`\\]*"|'[^'\n]*'|[^\s;&|<>$`()]+)\s*(?:;|&&)\s*(?=\S)/;
70
65
  export function stripLeadingCd(command) {
71
66
  return typeof command === 'string' ? command.replace(LEADING_CD, '') : command;
72
67
  }
@@ -79,6 +74,31 @@ function requiredTargetOf(segment, required) {
79
74
  function maskShellArguments(command) {
80
75
  return command.replace(/'[^']*'|"(?:\\.|[^"\\])*"|\\./g, (word) => ' '.repeat(word.length));
81
76
  }
77
+ // This is a bounded attribution recognizer, not a shell parser. A native shell exit
78
+ // proves a simple foreground command (or all commands in an && chain), never a
79
+ // background child, pipeline, substitution, group, or status-swallowing sequence.
80
+ export function preservesNativeOperationExit(raw) {
81
+ if (typeof raw !== 'string' || !raw.trim()) return false;
82
+ const text = stripLeadingCd(raw).trim();
83
+ let quote = null;
84
+ for (let i = 0; i < text.length; i += 1) {
85
+ const c = text[i];
86
+ if (c === '\\' && quote !== "'") { if (++i >= text.length) return false; continue; }
87
+ if (quote === "'") { if (c === "'") quote = null; continue; }
88
+ // Expansions inside double quotes still execute; never hide them as arguments.
89
+ if (c === '`' || (c === '$' && text[i + 1] === '(')) return false;
90
+ if (quote === '"') { if (c === '"') quote = null; continue; }
91
+ if (c === "'" || c === '"') { quote = c; continue; }
92
+ if (';|(){}[]\n\r'.includes(c) || c === '#') return false;
93
+ if (c === '&') {
94
+ if (text[i + 1] === '&') { i += 1; continue; }
95
+ if (text[i - 1] === '>' || text[i - 1] === '<' || text[i + 1] === '>') continue;
96
+ return false;
97
+ }
98
+ }
99
+ if (quote) return false;
100
+ return !/(?:^|&&)\s*(?:!|\.(?:\s|$)|(?:if|then|else|elif|fi|for|while|until|case|function|eval|exec|source|nohup|coproc|setsid|test|command|time|trap)\b)/.test(text);
101
+ }
82
102
  export function bashOperationReceipt(rawCommand, response, transportSuccess, goal, { sessionId, harness } = {}) {
83
103
  const command = stripLeadingCd(rawCommand);
84
104
  const domain = goal?.domain;
@@ -88,7 +108,7 @@ export function bashOperationReceipt(rawCommand, response, transportSuccess, goa
88
108
  // Preserve offsets while hiding quoted arguments: only unquoted && separates commands.
89
109
  const bare = maskShellArguments(command);
90
110
  // Pipelines have no per-command exit proof here; the word "pipefail" is not shell state.
91
- const masked = /;|\n|\|/.test(bare.replace(/&&/g, ''));
111
+ const masked = !preservesNativeOperationExit(rawCommand);
92
112
  const real = [];
93
113
  let start = 0;
94
114
  for (const separator of bare.matchAll(/&&/g)) {
@@ -185,7 +205,7 @@ const SEMVER_LINE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
185
205
  export function releaseReceipt(rawCommand, response, transportSuccess) {
186
206
  if (typeof rawCommand !== 'string') return null;
187
207
  const command = stripLeadingCd(rawCommand);
188
- const masked = /;|\n|\|/.test(maskShellArguments(command));
208
+ const masked = !preservesNativeOperationExit(rawCommand);
189
209
  const segments = command.split('&&').map((seg) => seg.trim()).filter((seg) => seg && !/^cd\s+\S+$/.test(seg));
190
210
  if (segments.length !== 1) return null;
191
211
  const [seg] = segments;
@@ -8,7 +8,7 @@ import fsSync from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { withAsyncLock, LOCK_MAX_SLICE_MS } from './async-lock.mjs';
11
- import { cronOperationReceipt, bashOperationReceipt, releaseOperationReceipt } from './cron-operation-adapter.mjs';
11
+ import { cronOperationReceipt, bashOperationReceipt, releaseOperationReceipt, preservesNativeOperationExit } from './cron-operation-adapter.mjs';
12
12
  import * as playbookExecution from './playbook-execution.mjs';
13
13
  import { operationCompletionPolicy, foldOperationEvidence, compactOperation, conditionalAbsenceGoal } from './operation-evidence.mjs';
14
14
 
@@ -705,10 +705,19 @@ function insideQuote(text, index) {
705
705
 
706
706
  const SHELL_C_WRAPPER = /(?:^|[\s;&|({])(?:ba|z|da|k)?sh\s+(?:-o\s+\w+\s+|-[a-z]+\s+)*-[a-z]*c\s+(?:'([^']*)'|"((?:[^"\\]|\\.)*)")/gi;
707
707
 
708
+ // Keep executable substitutions visible even inside double quotes; single-quoted
709
+ // examples are inert. Recognition only records UNPROVEN — it never executes shell text.
710
+ function executableSubstitutions(command) {
711
+ return String(command || '').replace(/'[^']*'/g, '').match(/\$\([^]*?\)|`[^`]*`/g) || [];
712
+ }
713
+
708
714
  function isVerificationCommand(command, depth = 0) {
709
715
  const raw = String(command || '').trim();
710
716
  if (!raw) return false;
711
717
  if (depth < 2) {
718
+ for (const sub of executableSubstitutions(raw)) {
719
+ if (isVerificationCommand(sub.slice(sub.startsWith('$(') ? 2 : 1, -1), depth + 1)) return true;
720
+ }
712
721
  for (const match of raw.matchAll(SHELL_C_WRAPPER)) {
713
722
  // A wrapper that is itself inside a quoted literal (`git commit -m "sh -c 'yarn test'"`) ran nothing.
714
723
  if (insideQuote(raw, match.index)) continue;
@@ -762,6 +771,7 @@ function verificationShellUnits(command) {
762
771
 
763
772
  function maskedVerificationStage(command, depth = 0) {
764
773
  const text = String(command || '').trim();
774
+ if (executableSubstitutions(text).length) return text;
765
775
  const units = verificationShellUnits(text);
766
776
  if (!units?.length || depth > 2) return text;
767
777
  let pipefail = false;
@@ -774,7 +784,7 @@ function maskedVerificationStage(command, depth = 0) {
774
784
  // turn the status of an earlier runner into PASS, even when it prints that status.
775
785
  if (sawRunner && units[i - 1]?.separator === ';' && !/^[)}]+$/.test(plain)) return unit.text;
776
786
  // Command position only: `vitest -t case` is an argument, not a loop.
777
- if (/^(?:for|while|until|case|function|done)\b/.test(plain)) return unit.text;
787
+ if (/^(?:\[|(?:for|while|until|case|function|done|test|coproc|setsid)\b)/.test(plain)) return unit.text;
778
788
  // A later `exit 0`/`true`/`:` replaces the runner's status; a leading `!` inverts it.
779
789
  if (sawRunner && /^(?:exit\s+0|true|:)$/.test(plain)) return unit.text;
780
790
  if (/^!\s/.test(plain) && isVerificationCommand(unit.text)) return unit.text;
@@ -809,6 +819,16 @@ function maskedVerificationStage(command, depth = 0) {
809
819
  // Announce through the in-proc hook emitter only (never fd 1 from library callers).
810
820
  // Host launch acknowledgement of a backgrounded Bash call (Claude Code: structured
811
821
  // `backgroundTaskId`, or the fixed "running in background" text). Structured field first.
822
+ // Claude Code reports a non-zero Bash exit as PostToolUseFailure and never attaches an exit code to a
823
+ // successful result, so its clean PostToolUse (a real `interrupted: false` result, no background launch, no
824
+ // return-code interpretation such as grep's "No matches found") is the host's proof of exit 0. Any other
825
+ // host, or a response without that exact shape, keeps the fail-closed rule.
826
+ function hostProvesCleanBashExit(harness, payload, response) {
827
+ return harness === 'claude-code' && payload?.hook_event_name === 'PostToolUse'
828
+ && response && typeof response === 'object' && !Array.isArray(response)
829
+ && response.interrupted === false && !response.backgroundTaskId && !response.returnCodeInterpretation;
830
+ }
831
+
812
832
  function isBackgroundLaunchAck(response) {
813
833
  if (response && typeof response === 'object' && !Array.isArray(response)) {
814
834
  if (typeof response.backgroundTaskId === 'string' && response.backgroundTaskId) return true;
@@ -1450,18 +1470,9 @@ function appendReceipt(receipts, receipt) {
1450
1470
  return [...(receipts || []), compactReceipt(receipt)].slice(-MAX_RECEIPTS);
1451
1471
  }
1452
1472
 
1453
- // TASK-026: the monotone verifiable-progress BANK. The digest used to be recomputed from
1454
- // ledger.receipts, which appendReceipt caps at MAX_RECEIPTS by recency — so REPLAYED evidence
1455
- // re-entered the set as older receipts evicted, moving the digest with zero new progress and
1456
- // pinning noProgressCount at 0 forever (a stuck loop rewriting >MAX_RECEIPTS distinct targets
1457
- // in rotation never reached VIBECODE_NO_PROGRESS_CAP). Evidence is now banked MONOTONICALLY:
1458
- // the first successful write to a target, or the first successful run of a verification
1459
- // command, mints a bank key that is NEVER evicted by later receipts — so a replay is a no-op
1460
- // however much recency churn happens around it. Keys are hashed to keep the bank compact; it
1461
- // lives for one request only (a genuinely new promptKey gets a fresh ledger). Reads are
1462
- // deliberately excluded: an endless read-only loop must not reset an absolute liveness
1463
- // escape. Route state is not consulted (the bank is derived from the ledger itself) so
1464
- // journaled replays stay route-state-free.
1473
+ // TASK-026: monotone request-scoped progress bank; receipt-window eviction must never
1474
+ // turn replayed writes/verifications into new progress. Hashed keys never evict within
1475
+ // a request; reads never count. Derived from ledger alone, including journal replay.
1465
1476
  const EVIDENCE_BANK_KEY_LENGTH = 16;
1466
1477
  const BANK_FIELDS = { write: 'bankedWrites', verification: 'bankedVerifications' };
1467
1478
 
@@ -1678,22 +1689,11 @@ function freshLedger(payload, routeState, harness) {
1678
1689
  };
1679
1690
  }
1680
1691
 
1681
- // --- TASK-027: fail-closed ledger events with a bounded lock-timeout journal -------------------
1682
- // The ledger used to lock fail-open: when the wait expired, the read-modify-write ran
1683
- // unlocked and parallel writers interleaved — evidence was lost exactly when the machine
1684
- // was most contended. Every mutation now goes through recordLedgerEvent:
1685
- // * lock acquired -> pending journal events are reconciled first (idempotent, by
1686
- // eventId), then the caller's event is applied, and ONE atomic ledger write lands.
1687
- // Reconciliation therefore happens inside the caller's own deadline, before its own
1688
- // evidence.
1689
- // * lock expired/aborted -> the main ledger is NEVER touched; the event is appended
1690
- // once to `<ledger>.journal` (JSONL, route-scoped) and reported as `journaled`.
1691
- // Journal invariants: records are route-scoped (the sessionKey must match the journal
1692
- // owner), idempotent (eventId deduped against ledger.journalSeen), bounded (record cap at
1693
- // append time, bounded quarantine, bounded seen-list), and atomic per record (single
1694
- // O_APPEND write; a torn trailing line is quarantined, never applied). Records carry
1695
- // receipts and route metadata only — never prompt bodies or tool output; the receipt
1696
- // whitelist below is the gate.
1692
+ // TASK-027: fail-closed ledger lock. Acquired: reconcile journal by eventId, apply,
1693
+ // atomically commit within caller deadline. Expired/aborted: never touch main ledger;
1694
+ // append once to route-scoped JSONL. Session ownership, record/quarantine/seen caps,
1695
+ // single O_APPEND records and torn-line quarantine bound replay. Whitelisted receipt
1696
+ // metadata only: never prompt bodies or tool output.
1697
1697
  const JOURNAL_VERSION = 1;
1698
1698
  const MAX_JOURNAL_SEEN = 64;
1699
1699
  const MAX_JOURNAL_RECORDS = 128;
@@ -2120,23 +2120,11 @@ async function drainJournal(target, { ledger, payload, signal, deadlineMs, proje
2120
2120
  return { ledger: next, applied, quarantined: malformed.length, commit };
2121
2121
  }
2122
2122
 
2123
- // --- Playbook execution trace (observable progress, no LLM) ---------------------
2124
- // A playbook is a text cue; the only durable signal of "was it followed" is the
2125
- // receipt stream the hooks already record. This section folds that stream into
2126
- // a bounded, deterministic `playbookTrace` record:
2127
- // loaded — a Read receipt hit playbooks/<id>.md
2128
- // stepsDone — receipt-derived evidence per the playbook's step map
2129
- // stepsSkipped — non-essential steps on a simple/trivial route, justified
2130
- // outputs — bounded list of written targets
2131
- // verification — 'pending' | 'passed' | 'failed' | 'n/a'
2132
- // No LLM call, no new store: everything derives from ledger.receipts plus the
2133
- // route signals stamped beside them (playbookReason, taskType). The record is
2134
- // persisted on the ledger at receipt-apply time so `ukit status` and the stop
2135
- // message read it without re-deriving an evictable receipt window. Session and
2136
- // request scoping are inherited from the ledger itself: a ledger only ever
2137
- // holds its own session's receipts, and evaluateCompletion derives from the
2138
- // same-request effective ledger, so another session's or patch's evidence can
2139
- // never satisfy a step.
2123
+ // Playbook trace: deterministic, bounded receipt-derived progress, no LLM/new store.
2124
+ // loaded = playbook Read; stepsDone = step-map evidence; stepsSkipped = justified
2125
+ // non-essential simple/trivial work; outputs = written targets; verification =
2126
+ // pending/passed/failed/n/a. Persist at receipt-apply time to survive window eviction.
2127
+ // Ledger session/request ownership prevents another task's evidence satisfying steps.
2140
2128
 
2141
2129
  const PLAYBOOK_TRACE_EVIDENCE_MAX = 200;
2142
2130
  const MAX_PLAYBOOK_OUTPUTS = 8;
@@ -2322,29 +2310,14 @@ export async function playbookStatus(projectRoot, payload = {}) {
2322
2310
  };
2323
2311
  }
2324
2312
 
2325
- // --- TASK-009 (BL-011): bug-fix completion floor --------------------------------
2326
- // A green verdict on a `playbookId: bug-fix` route requires verbatim
2327
- // `repro-before` (failing) + `repro-after` (passing) evidence receipts on the
2328
- // SAME surface plus a named root cause in the exec-ledger — a symptom patch
2329
- // without receipts cannot pass the gate. A defect that honest attempts cannot
2330
- // reproduce ends INCONCLUSIVE with the attempt count and the runtime-forensics
2331
- // follow-on named; the floor never fabricates a pass.
2332
- //
2333
- // Receipts may carry an explicit ARCH §Evidence Schema attestation
2334
- // (`receipt.evidence = {class, surface, result, claim}`) — minted from
2335
- // `payload.evidence`, `payload.tool_input.evidence`, or a leading
2336
- // `UKIT_EVIDENCE=class=…;surface=…` env assignment on a Bash command. On a
2337
- // bug-fix ledger the apply step additionally classifies every un-attested
2338
- // verification receipt itself: a failed verification is a `repro-before`
2339
- // (the old failure reproduced verbatim), and a success on a surface with a
2340
- // recorded failure is the `repro-after` — so the live hook path produces the
2341
- // floor without any attestation at all. Derived classes are stamped back onto
2342
- // the receipt, which keeps them verbatim inside the receipt window.
2343
- //
2344
- // The bank lives in `ledger.bugFixFloor` and is monotone per request: receipts
2345
- // evicted by the MAX_RECEIPTS window can never un-bank evidence. `seen` dedupes
2346
- // per surface+class so replayed journal records and eval-time receipt folds
2347
- // are idempotent.
2313
+ // TASK-009 (BL-011): bug-fix floor requires failing repro-before + passing repro-after
2314
+ // on the SAME surface and a named root cause. Unreproduced defects stay INCONCLUSIVE
2315
+ // with attempt count/forensics follow-on; no invented pass. Explicit {class,surface,
2316
+ // result,claim} evidence comes from payload/tool_input or leading UKIT_EVIDENCE env.
2317
+ // Otherwise failed verification derives repro-before; later same-surface success
2318
+ // derives repro-after. Stamp derived classes onto receipts. ledger.bugFixFloor is
2319
+ // monotone request-scoped; seen stamps make journal/eval replay idempotent despite
2320
+ // receipt-window eviction.
2348
2321
  const BUGFIX_PLAYBOOK_ID = 'bug-fix';
2349
2322
  const BUGFIX_REPRO_BEFORE = 'repro-before';
2350
2323
  const BUGFIX_REPRO_AFTER = 'repro-after';
@@ -3275,8 +3248,11 @@ export async function recordExecutionReceipt({
3275
3248
  Object.assign(receipt, bgCompletion);
3276
3249
  } else if (operationReceipt) {
3277
3250
  Object.assign(receipt, operationReceipt);
3278
- // An absent native exit is not successful execution, even when no error flag was supplied.
3279
- if (toolName === 'Bash' && exitCode === null) {
3251
+ // An absent native exit is not successful execution, even when no error flag was supplied —
3252
+ // except where the host's own contract carries the proof (see hostProvesCleanBashExit).
3253
+ if (toolName === 'Bash' && (!preservesNativeOperationExit(toolInput.command)
3254
+ || malformedNativeExitPayload(payload)
3255
+ || (exitCode === null && !hostProvesCleanBashExit(harness, payload, toolResponse)))) {
3280
3256
  receipt.exitStatusUnproven = true;
3281
3257
  receipt.success = false;
3282
3258
  }
@@ -3490,7 +3466,14 @@ function evidenceSatisfied(evidence, ledger = {}, state = {}, { cwd } = {}) {
3490
3466
  try { return fsSync.statSync(target).isFile() && fsSync.statSync(target).size > 0; } catch { return false; }
3491
3467
  }
3492
3468
  if (evidence === 'operation-verification') {
3493
- return operationCompletionPolicy(scopedContract(state, ledger), null, withGitAuthority(ledger, cwd))?.complete === true;
3469
+ const contract = scopedContract(state, ledger);
3470
+ // Publication-only contracts have no generic native goal. A retained recipe/legacy
3471
+ // class belongs to the same registry-bound postcondition, not an impossible null
3472
+ // policy. Never use registry proof to discharge a real Git/service goal.
3473
+ if (contract?.publication && !contract.operationGoal) {
3474
+ return releasePublicationMissing(state, ledger, cwd) === null;
3475
+ }
3476
+ return operationCompletionPolicy(contract, null, withGitAuthority(ledger, cwd))?.complete === true;
3494
3477
  }
3495
3478
  if (evidence === 'write-evidence') {
3496
3479
  const expected = routeSummary.expectedSourceFiles || [];