@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 +16 -0
- package/manifests/documentation.yaml +1 -0
- package/package.json +3 -1
- package/scripts/release/freeze.mjs +23 -0
- package/scripts/release/publish-guard.mjs +20 -0
- package/scripts/release/release-steps.mjs +63 -0
- package/scripts/release/verify-release.mjs +15 -75
- package/src/learning/codeProposals.js +184 -50
- package/template_project/.claude/hooks/skill-router.sh +13 -5
- package/template_project/.claude/ukit/index/publication-intent.mjs +12 -4
- package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +31 -11
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +56 -73
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
|
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
|
|
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 +
|
|
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:
|
|
98
|
-
//
|
|
99
|
-
// names the same denied file
|
|
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
|
-
//
|
|
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
|
|
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
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
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=
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
55
|
-
//
|
|
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 =
|
|
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 =
|
|
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:
|
|
1454
|
-
//
|
|
1455
|
-
//
|
|
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
|
-
//
|
|
1682
|
-
//
|
|
1683
|
-
//
|
|
1684
|
-
//
|
|
1685
|
-
//
|
|
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
|
-
//
|
|
2124
|
-
//
|
|
2125
|
-
//
|
|
2126
|
-
// a
|
|
2127
|
-
//
|
|
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
|
-
//
|
|
2326
|
-
//
|
|
2327
|
-
//
|
|
2328
|
-
//
|
|
2329
|
-
//
|
|
2330
|
-
//
|
|
2331
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 || [];
|