@ngockhoale/ukit 3.4.71 → 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,14 +2,21 @@
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
+
5
12
  ## 3.4.71 - 2026-10-28
6
13
 
7
14
  **F-7 intent scope, release-freeze coordination and Claude Code operation-receipt exit proof; no native-host certification.**
8
15
 
9
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.
10
- - Self-improve: while `verify-release` owns a candidate fingerprint it writes a freeze marker under `.ukit/storage/`; `codeProposals` records proposals in `.ukit/storage/cache/deferred-auto-tasks.json` instead of writing `AUTO-*.md`, and writes them on the next pass after thaw. Dead-owner or expired markers are ignored; nothing is dropped.
11
- - Completion evidence: a clean Claude Code `PostToolUse` Bash result (`interrupted: false`, no background launch, no return-code interpretation) is the host's proof of exit 0 for git/npm operation receipts, which previously stayed `exitStatusUnproven` forever. Pipelines, interrupted results, failures and other hosts (omp, unknown) remain fail-closed.
12
- - Verification: full `yarn test` 698 files / 11,371 passed / 3 skipped, exit 0. Artifact unpacked-size budget raised 11,190,000 -> 11,200,000 for the new release-freeze helper (owner confirmation requested). Not addressed: wrapper test-exit masking audit, registry-consumer `operation-verification` correlation, stale-obligation reconciliation, P2 items; no live OMP/Claude host capture.
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.
13
20
 
14
21
  ## 3.4.70 - 2026-10-26
15
22
 
@@ -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.71",
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,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
+ ];
@@ -5,21 +5,13 @@ import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { candidateFingerprint } from './candidate-state.mjs';
7
7
  import { engageFreeze } from './freeze.mjs';
8
+ import { npmCacheDir, releaseSteps } from './release-steps.mjs';
8
9
 
9
10
  const rootDir = process.cwd();
10
- const npmCacheDir = path.join(os.tmpdir(), 'ukit-npm-cache');
11
11
 
12
- // BUG-C22-20: every spawned child carries an explicit timeout + killSignal so a
13
- // wedged npm/yarn child (network partition, hung credential helper) fails the
14
- // step instead of hanging the verifier forever. Per-step budgets: release test
15
- // steps are the slowest (full suite); the registry read is a single network call.
16
- // UKIT_VERIFY_STEP_TIMEOUT_MS overrides the step budget for tests.
17
12
  const STEP_TIMEOUT_MS = Number(process.env.UKIT_VERIFY_STEP_TIMEOUT_MS) || 10 * 60 * 1000; // 10 min
18
13
  const REGISTRY_TIMEOUT_MS = Number(process.env.UKIT_VERIFY_REGISTRY_TIMEOUT_MS) || 60 * 1000; // 1 min
19
14
 
20
- // Opt-in post-publish registry-parity check (Release Policy invariant: npm and git always hold
21
- // the same latest version). Only meaningful AFTER `npm publish` — before publish the registry is
22
- // behind by definition — so it is gated behind --post-publish and never runs by default.
23
15
  const postPublish = process.argv.includes('--post-publish');
24
16
 
25
17
  if (postPublish) {
@@ -42,12 +34,8 @@ if (postPublish) {
42
34
  }
43
35
 
44
36
 
45
- // Single-instance guard (full verification takes ~4 min). Starting a second run while one is
46
- // alive (e.g. after the first was moved to the background by a tool timeout) only makes both
47
- // slower and flakier — wait for / read the first run's result instead.
48
37
  let lockPath = null;
49
38
  function acquireVerifyLock() {
50
- // Nested runs (the release suite's own tests spawn this script) and test workers must not contend.
51
39
  if (process.env.UKIT_RELEASE_VERIFY_ACTIVE === '1' || process.env.VITEST || process.env.UKIT_VERIFY_NO_LOCK === '1') return;
52
40
  lockPath = path.join(os.tmpdir(), `ukit-release-verify-${crypto.createHash('sha1').update(rootDir).digest('hex').slice(0, 10)}.lock`);
53
41
  if (existsSync(lockPath)) {
@@ -68,66 +56,11 @@ function acquireVerifyLock() {
68
56
 
69
57
  acquireVerifyLock();
70
58
 
71
- // Cheap, deterministic gates FIRST (seconds): a stale instruction render or a consistency drift
72
- // used to surface only after the ~4 min full suite.
73
- const steps = [
74
- {
75
- label: 'Rendered instructions up to date (fix: yarn docs:render)',
76
- command: 'yarn',
77
- args: ['docs:render:check'],
78
- env: process.env,
79
- },
80
- {
81
- label: 'Live documentation preflight (headers, budgets, current status)',
82
- command: 'yarn',
83
- args: ['vitest', 'run', 'tests/consistency/liveStateDocs.test.js', 'tests/consistency/advisorStatusDocs.test.js'],
84
- env: process.env,
85
- },
86
- {
87
- label: 'Consistency suite (template<->active sync, watchdog coverage, render parity)',
88
- command: 'yarn',
89
- args: ['vitest', 'run', 'tests/consistency'],
90
- env: process.env,
91
- },
92
- {
93
- label: 'Artifact smoke',
94
- command: 'yarn',
95
- args: ['test:artifact'],
96
- env: process.env,
97
- },
98
- {
99
- label: 'Doc contracts',
100
- command: 'yarn',
101
- args: ['vitest', 'run', 'tests/consistency/docContracts.test.js'],
102
- env: process.env,
103
- },
104
- {
105
- // Bounded behavioral lane: runs the handoff suite in a disposable copy (never the checkout).
106
- label: 'Handoff isolated lane',
107
- command: 'yarn',
108
- args: ['test:handoff'],
109
- env: process.env,
110
- },
111
- {
112
- label: 'Core test suite',
113
- command: 'yarn',
114
- args: ['test:release-core', '--bail', '5'], // stop after 5 failures instead of finishing a doomed 4 min run
115
- env: process.env,
116
- },
117
- {
118
- label: 'Local package dry-run',
119
- command: 'npm',
120
- args: ['pack', '--dry-run', '--json', '--cache', npmCacheDir],
121
- env: {
122
- ...process.env,
123
- npm_config_cache: npmCacheDir,
124
- },
125
- },
126
- ];
59
+ const steps = releaseSteps;
127
60
 
128
61
  const runDirectory = mkdtempSync(path.join(os.tmpdir(), 'ukit-release-run-'));
129
62
  const candidate = candidateFingerprint(rootDir);
130
- 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: [] };
131
64
  const persistRun = () => writeFileSync(path.join(runDirectory, 'result.json'), `${JSON.stringify(run, null, 2)}\n`);
132
65
  persistRun();
133
66
  const thaw = engageFreeze(rootDir, { candidate });
@@ -152,11 +85,20 @@ for (const step of steps) {
152
85
  run.phaseStartedAt = new Date().toISOString();
153
86
  run.phaseResult = null;
154
87
  step.env = { ...step.env, UKIT_VERIFY_PROGRESS_FILE: path.join(runDirectory, `phase-${steps.indexOf(step)}-progress.json`) };
88
+ const candidateBefore = candidateFingerprint(rootDir);
155
89
  persistRun();
156
90
  console.log(`\n[release:verify] ${step.label}`);
157
91
  console.log(`[release:verify] > ${step.command} ${step.args.join(' ')}`);
158
92
 
159
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();
160
102
  assertCandidate();
161
103
  if (exitCode !== 0) {
162
104
  console.error(`[release:verify] FAILED at step: ${step.label}`);
@@ -169,16 +111,11 @@ run.result = 'passed';
169
111
  persistRun();
170
112
  console.log('\n[release:verify] All release checks passed.');
171
113
 
172
- // BUG-C22-20: bounded spawn — an armed watchdog SIGKILLs the child's whole
173
- // process group on expiry (detached: true puts the child in its own group so
174
- // grandchildren like `sh -c "sleep …"` can't keep our stdio pipes open).
175
114
  function spawnBounded(command, args, opts, timeoutMs) {
176
115
  const child = spawn(command, args, { ...opts, detached: true });
177
116
  child.ukitTimedOut = false;
178
117
  const watchdog = setTimeout(() => {
179
118
  child.ukitTimedOut = true;
180
- // One bounded native snapshot before termination, never a polling watcher.
181
- // ps contains no argv/environment (credentials); unsupported hosts record the error.
182
119
  if (!postPublish && opts.env?.UKIT_VERIFY_PROGRESS_FILE) try {
183
120
  const snapshot = execFileSync('ps', ['-axo', 'pid,ppid,pgid,stat,etime,time,comm'], {
184
121
  encoding: 'utf8', timeout: 1000, maxBuffer: 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'],
@@ -1,28 +1,17 @@
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';
26
15
  import { activeFreeze } from '../../scripts/release/freeze.mjs';
27
16
 
28
17
  const TASKS_DIR_REL = path.join('docs', 'AI_HANDOFF', 'tasks');
@@ -34,8 +23,7 @@ const SIGNATURE_FIELD_RE = /signature["']?\s*:\s*(?:`([^`\n]*)`|"((?:[^"\\\n]|\\
34
23
  const MIN_SESSIONS = 2;
35
24
  const AUTO_NAME_RE = /^AUTO-(\d+)\.md$/;
36
25
 
37
- // Protected lanes — the same areas keepMainModelFor guards. A mined pattern
38
- // pointing here is skipped, never proposed.
26
+ // Protected lanes (same areas keepMainModelFor guards) — never proposed.
39
27
  const DENIED_TARGET_RES = [
40
28
  /^src\/decision\//,
41
29
  /^src\/core\/agentRuntime\//,
@@ -45,7 +33,7 @@ const DENIED_TARGET_RES = [
45
33
  /secret|credential|token/i,
46
34
  ];
47
35
 
48
- // Whitelist fix classes — bounded deterministic mapping only.
36
+ // Whitelist — bounded deterministic mapping only.
49
37
  const FIX_CLASSES = [
50
38
  {
51
39
  id: 'timeout-hang',
@@ -74,7 +62,7 @@ function isObject(value) {
74
62
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
75
63
  }
76
64
 
77
- // Evidence haystack: signature + example commands + files, lowercased.
65
+ // Evidence haystack: signature + commands + files, lowercased.
78
66
  function haystack(pattern) {
79
67
  return [
80
68
  pattern.signature,
@@ -83,10 +71,7 @@ function haystack(pattern) {
83
71
  ].join(' ').toLowerCase();
84
72
  }
85
73
 
86
- /**
87
- * Map a mined pattern to a whitelisted fix class.
88
- * @returns {{id: string, change: string}|null}
89
- */
74
+ /** Map a mined pattern to a whitelisted fix class; null = none. */
90
75
  export function mapPatternToFixTarget(pattern) {
91
76
  if (!isObject(pattern)) return null;
92
77
  const hay = haystack(pattern);
@@ -96,10 +81,9 @@ export function mapPatternToFixTarget(pattern) {
96
81
  return null;
97
82
  }
98
83
 
99
- // W3-F4: strip ./ and resolve ../ so syntactic variants can't slip past the
100
- // denylist. On case-insensitive filesystems (win32/darwin) a case variant
101
- // names the same denied file, so the patterns are folded there — Linux keeps
102
- // 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.
103
87
  const DENY_TARGET_RES = (process.platform === 'win32' || process.platform === 'darwin')
104
88
  ? DENIED_TARGET_RES.map((re) => (re.ignoreCase ? re : new RegExp(re.source, `${re.flags}i`)))
105
89
  : DENIED_TARGET_RES;
@@ -150,7 +134,7 @@ async function listMarkdownFiles(dir, depth = 0) {
150
134
  return files;
151
135
  }
152
136
 
153
- // Signatures already claimed by a task/archived file + highest AUTO seq.
137
+ // Claimed signatures (tasks+archive .md) + highest AUTO seq.
154
138
  async function scanHandoffDirs(projectRoot) {
155
139
  const signatures = new Set();
156
140
  let maxSeq = 0;
@@ -169,8 +153,7 @@ async function scanHandoffDirs(projectRoot) {
169
153
  for (const match of text.matchAll(SIGNATURE_FIELD_RE)) {
170
154
  const raw = (match[1] ?? match[2] ?? match[3] ?? '').trim();
171
155
  if (!raw) continue;
172
- // JSON "…" captures may carry escapes; decode so the scanned signature
173
- // equals the raw pattern.signature stored in the evidence block.
156
+ // decode JSON escapes so scanned signature == evidence-block pattern.signature
174
157
  let sig = raw;
175
158
  if (match[2] !== undefined) {
176
159
  try {
@@ -186,6 +169,43 @@ async function scanHandoffDirs(projectRoot) {
186
169
  return { signatures, maxSeq };
187
170
  }
188
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
+
189
209
  function renderTaskFile({ seq, pattern, fixClass, targets, now }) {
190
210
  const id = `AUTO-${String(seq).padStart(3, '0')}`;
191
211
  const signature = pattern.signature;
@@ -265,15 +285,10 @@ Auto-generated by \`ukit self-improve\` at ${new Date(now).toISOString()} from m
265
285
 
266
286
  /**
267
287
  * Propose AUTO handoff task files from mined failure patterns.
268
- *
269
- * @param {string} projectRoot
270
- * @param {{ minCount?: number, maxTasks?: number, dryRun?: boolean,
271
- * now?: number }} [options]
272
- * @returns {Promise<{generatedAt: string, dryRun: boolean,
273
- * patternsScanned: number, eligible: number, created: number,
274
- * results: Array<{signature: string, fixClass: string|null,
275
- * status: 'proposed'|'duplicate'|'skipped', reason?: string,
276
- * 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}>}>}
277
292
  */
278
293
  export async function proposeCodeFixTasks(projectRoot, {
279
294
  minCount = 3,
@@ -290,16 +305,38 @@ export async function proposeCodeFixTasks(projectRoot, {
290
305
  results: [],
291
306
  };
292
307
 
293
- try {
294
- const patterns = await loadPatterns(projectRoot);
295
- 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;
296
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
+ }
297
335
  let seq = known.maxSeq;
298
336
  let emitted = 0;
299
- const frozen = dryRun ? null : activeFreeze(projectRoot);
300
337
  const deferred = [];
301
338
 
302
- for (const pattern of patterns) {
339
+ for (const { pattern, origin } of candidates) {
303
340
  const signature = typeof pattern?.signature === 'string' ? pattern.signature : null;
304
341
  if (!signature) continue;
305
342
  const entry = {
@@ -348,9 +385,9 @@ export async function proposeCodeFixTasks(projectRoot, {
348
385
 
349
386
  if (dryRun) continue;
350
387
 
351
- // A release verifier owns the candidate bytes: writing a task now would invalidate its fingerprint.
352
- // Record the proposal (never drop it); the next pass after thaw regenerates it from the same pattern.
353
- if (frozen) {
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) => {
354
391
  entry.status = 'skipped';
355
392
  entry.reason = 'release-frozen';
356
393
  entry.taskFile = null;
@@ -358,12 +395,34 @@ export async function proposeCodeFixTasks(projectRoot, {
358
395
  emitted -= 1;
359
396
  seq -= 1;
360
397
  known.signatures.delete(signature);
361
- deferred.push({ signature, fixClass: fixClass.id, targets, deferredAt: result.generatedAt, frozenBy: frozen.pid });
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);
362
415
  continue;
363
416
  }
364
417
 
365
418
  try {
366
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
+ }
367
426
  await fs.writeFile(
368
427
  path.join(projectRoot, TASKS_DIR_REL, fileName),
369
428
  renderTaskFile({ seq, pattern, fixClass, targets, now }),
@@ -376,15 +435,64 @@ export async function proposeCodeFixTasks(projectRoot, {
376
435
  entry.written = false;
377
436
  result.created -= 1;
378
437
  emitted -= 1;
438
+ // undo the propose-time claim: nothing landed, so the queued entry survives.
439
+ known.signatures.delete(signature);
379
440
  }
380
441
  }
381
- if (deferred.length) {
382
- result.deferred = deferred.length;
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.
383
445
  try {
384
- const file = path.join(projectRoot, DEFERRED_REL);
385
- await fs.mkdir(path.dirname(file), { recursive: true });
386
- await fs.writeFile(file, `${JSON.stringify({ deferred }, null, 2)}\n`);
387
- } catch { /* result.deferred still reports them */ }
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}`;
388
496
  }
389
497
  } catch (err) {
390
498
  result.error = err?.message ?? String(err);
@@ -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;
@@ -1460,18 +1470,9 @@ function appendReceipt(receipts, receipt) {
1460
1470
  return [...(receipts || []), compactReceipt(receipt)].slice(-MAX_RECEIPTS);
1461
1471
  }
1462
1472
 
1463
- // TASK-026: the monotone verifiable-progress BANK. The digest used to be recomputed from
1464
- // ledger.receipts, which appendReceipt caps at MAX_RECEIPTS by recency — so REPLAYED evidence
1465
- // re-entered the set as older receipts evicted, moving the digest with zero new progress and
1466
- // pinning noProgressCount at 0 forever (a stuck loop rewriting >MAX_RECEIPTS distinct targets
1467
- // in rotation never reached VIBECODE_NO_PROGRESS_CAP). Evidence is now banked MONOTONICALLY:
1468
- // the first successful write to a target, or the first successful run of a verification
1469
- // command, mints a bank key that is NEVER evicted by later receipts — so a replay is a no-op
1470
- // however much recency churn happens around it. Keys are hashed to keep the bank compact; it
1471
- // lives for one request only (a genuinely new promptKey gets a fresh ledger). Reads are
1472
- // deliberately excluded: an endless read-only loop must not reset an absolute liveness
1473
- // escape. Route state is not consulted (the bank is derived from the ledger itself) so
1474
- // 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.
1475
1476
  const EVIDENCE_BANK_KEY_LENGTH = 16;
1476
1477
  const BANK_FIELDS = { write: 'bankedWrites', verification: 'bankedVerifications' };
1477
1478
 
@@ -1688,22 +1689,11 @@ function freshLedger(payload, routeState, harness) {
1688
1689
  };
1689
1690
  }
1690
1691
 
1691
- // --- TASK-027: fail-closed ledger events with a bounded lock-timeout journal -------------------
1692
- // The ledger used to lock fail-open: when the wait expired, the read-modify-write ran
1693
- // unlocked and parallel writers interleaved — evidence was lost exactly when the machine
1694
- // was most contended. Every mutation now goes through recordLedgerEvent:
1695
- // * lock acquired -> pending journal events are reconciled first (idempotent, by
1696
- // eventId), then the caller's event is applied, and ONE atomic ledger write lands.
1697
- // Reconciliation therefore happens inside the caller's own deadline, before its own
1698
- // evidence.
1699
- // * lock expired/aborted -> the main ledger is NEVER touched; the event is appended
1700
- // once to `<ledger>.journal` (JSONL, route-scoped) and reported as `journaled`.
1701
- // Journal invariants: records are route-scoped (the sessionKey must match the journal
1702
- // owner), idempotent (eventId deduped against ledger.journalSeen), bounded (record cap at
1703
- // append time, bounded quarantine, bounded seen-list), and atomic per record (single
1704
- // O_APPEND write; a torn trailing line is quarantined, never applied). Records carry
1705
- // receipts and route metadata only — never prompt bodies or tool output; the receipt
1706
- // 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.
1707
1697
  const JOURNAL_VERSION = 1;
1708
1698
  const MAX_JOURNAL_SEEN = 64;
1709
1699
  const MAX_JOURNAL_RECORDS = 128;
@@ -2130,23 +2120,11 @@ async function drainJournal(target, { ledger, payload, signal, deadlineMs, proje
2130
2120
  return { ledger: next, applied, quarantined: malformed.length, commit };
2131
2121
  }
2132
2122
 
2133
- // --- Playbook execution trace (observable progress, no LLM) ---------------------
2134
- // A playbook is a text cue; the only durable signal of "was it followed" is the
2135
- // receipt stream the hooks already record. This section folds that stream into
2136
- // a bounded, deterministic `playbookTrace` record:
2137
- // loaded — a Read receipt hit playbooks/<id>.md
2138
- // stepsDone — receipt-derived evidence per the playbook's step map
2139
- // stepsSkipped — non-essential steps on a simple/trivial route, justified
2140
- // outputs — bounded list of written targets
2141
- // verification — 'pending' | 'passed' | 'failed' | 'n/a'
2142
- // No LLM call, no new store: everything derives from ledger.receipts plus the
2143
- // route signals stamped beside them (playbookReason, taskType). The record is
2144
- // persisted on the ledger at receipt-apply time so `ukit status` and the stop
2145
- // message read it without re-deriving an evictable receipt window. Session and
2146
- // request scoping are inherited from the ledger itself: a ledger only ever
2147
- // holds its own session's receipts, and evaluateCompletion derives from the
2148
- // same-request effective ledger, so another session's or patch's evidence can
2149
- // 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.
2150
2128
 
2151
2129
  const PLAYBOOK_TRACE_EVIDENCE_MAX = 200;
2152
2130
  const MAX_PLAYBOOK_OUTPUTS = 8;
@@ -2332,29 +2310,14 @@ export async function playbookStatus(projectRoot, payload = {}) {
2332
2310
  };
2333
2311
  }
2334
2312
 
2335
- // --- TASK-009 (BL-011): bug-fix completion floor --------------------------------
2336
- // A green verdict on a `playbookId: bug-fix` route requires verbatim
2337
- // `repro-before` (failing) + `repro-after` (passing) evidence receipts on the
2338
- // SAME surface plus a named root cause in the exec-ledger — a symptom patch
2339
- // without receipts cannot pass the gate. A defect that honest attempts cannot
2340
- // reproduce ends INCONCLUSIVE with the attempt count and the runtime-forensics
2341
- // follow-on named; the floor never fabricates a pass.
2342
- //
2343
- // Receipts may carry an explicit ARCH §Evidence Schema attestation
2344
- // (`receipt.evidence = {class, surface, result, claim}`) — minted from
2345
- // `payload.evidence`, `payload.tool_input.evidence`, or a leading
2346
- // `UKIT_EVIDENCE=class=…;surface=…` env assignment on a Bash command. On a
2347
- // bug-fix ledger the apply step additionally classifies every un-attested
2348
- // verification receipt itself: a failed verification is a `repro-before`
2349
- // (the old failure reproduced verbatim), and a success on a surface with a
2350
- // recorded failure is the `repro-after` — so the live hook path produces the
2351
- // floor without any attestation at all. Derived classes are stamped back onto
2352
- // the receipt, which keeps them verbatim inside the receipt window.
2353
- //
2354
- // The bank lives in `ledger.bugFixFloor` and is monotone per request: receipts
2355
- // evicted by the MAX_RECEIPTS window can never un-bank evidence. `seen` dedupes
2356
- // per surface+class so replayed journal records and eval-time receipt folds
2357
- // 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.
2358
2321
  const BUGFIX_PLAYBOOK_ID = 'bug-fix';
2359
2322
  const BUGFIX_REPRO_BEFORE = 'repro-before';
2360
2323
  const BUGFIX_REPRO_AFTER = 'repro-after';
@@ -3287,7 +3250,9 @@ export async function recordExecutionReceipt({
3287
3250
  Object.assign(receipt, operationReceipt);
3288
3251
  // An absent native exit is not successful execution, even when no error flag was supplied —
3289
3252
  // except where the host's own contract carries the proof (see hostProvesCleanBashExit).
3290
- if (toolName === 'Bash' && exitCode === null && !hostProvesCleanBashExit(harness, payload, toolResponse)) {
3253
+ if (toolName === 'Bash' && (!preservesNativeOperationExit(toolInput.command)
3254
+ || malformedNativeExitPayload(payload)
3255
+ || (exitCode === null && !hostProvesCleanBashExit(harness, payload, toolResponse)))) {
3291
3256
  receipt.exitStatusUnproven = true;
3292
3257
  receipt.success = false;
3293
3258
  }
@@ -3501,7 +3466,14 @@ function evidenceSatisfied(evidence, ledger = {}, state = {}, { cwd } = {}) {
3501
3466
  try { return fsSync.statSync(target).isFile() && fsSync.statSync(target).size > 0; } catch { return false; }
3502
3467
  }
3503
3468
  if (evidence === 'operation-verification') {
3504
- 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;
3505
3477
  }
3506
3478
  if (evidence === 'write-evidence') {
3507
3479
  const expected = routeSummary.expectedSourceFiles || [];