@ngockhoale/ukit 3.4.71 → 3.4.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -3
- package/manifests/documentation.yaml +2 -0
- package/package.json +3 -1
- package/scripts/release/publish-guard.mjs +20 -0
- package/scripts/release/release-steps.mjs +63 -0
- package/scripts/release/verify-release.mjs +12 -75
- package/src/learning/codeProposals.js +169 -61
- package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +39 -26
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +50 -76
- package/template_project/.claude/ukit/runtime/operation-evidence.mjs +5 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,14 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 3.4.73 - 2026-10-28
|
|
6
|
+
|
|
7
|
+
- Git history proof tolerates unrelated untracked files; tracked/staged drift and explicit whole-tree clean obligations remain strict. No filename exemptions.
|
|
8
|
+
- Native ledger-to-Stop fixtures cover Claude Code/Codex/omp, repeated Stop and untracked shipped-source publication drift; not live-host certification.
|
|
9
|
+
|
|
10
|
+
## 3.4.72 - 2026-10-28
|
|
11
|
+
|
|
12
|
+
- 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.
|
|
13
|
+
- Completion: retained publication-only debt uses task-owned registry proof only without a real operation goal; unrelated Git/service duties remain enforced.
|
|
14
|
+
- Self-improve: serialize deferred AUTO queue replay/merge, retain snapshots/provenance and corrupt/legacy entries, drain only written tasks; dry-run remains read-only.
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
5
17
|
## 3.4.71 - 2026-10-28
|
|
6
18
|
|
|
7
19
|
**F-7 intent scope, release-freeze coordination and Claude Code operation-receipt exit proof; no native-host certification.**
|
|
8
20
|
|
|
9
21
|
- 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:
|
|
11
|
-
- Completion evidence:
|
|
12
|
-
- Verification: full `yarn test` 698 files / 11,371 passed / 3 skipped, exit 0
|
|
22
|
+
- 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.
|
|
23
|
+
- 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.
|
|
24
|
+
- 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
25
|
|
|
14
26
|
## 3.4.70 - 2026-10-26
|
|
15
27
|
|
|
@@ -4,6 +4,8 @@ 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: post-3472-git, path: docs/analysis/POST-3472-GIT-OPERATION-VERIFICATION.md, class: canonical, audience: [maintainer], owner: release, source_of_truth: docs/analysis/POST-3472-GIT-OPERATION-VERIFICATION.md, merge_strategy: none, load_policy: on-demand, validation: [manual], archive_policy: never}
|
|
8
|
+
- {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
9
|
- id: systematic-loop-audit-2026-10-26
|
|
8
10
|
path: docs/analysis/SYSTEMATIC-LOOP-AUDIT-2026-10-26.md
|
|
9
11
|
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.73",
|
|
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
|
-
|
|
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
|
-
//
|
|
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';
|
|
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
|
|
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
|
|
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 +
|
|
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:
|
|
100
|
-
//
|
|
101
|
-
// names the same denied file
|
|
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
|
-
//
|
|
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
|
|
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
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
|
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
|
|
352
|
-
//
|
|
353
|
-
|
|
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
|
-
|
|
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 (
|
|
382
|
-
|
|
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
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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,31 +42,21 @@ 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
|
-
// Native GitHub
|
|
59
|
-
// (like ls-remote asks the Git remote). Strict: unmasked single command, BOTH JSON fields present, enum
|
|
60
|
-
// visibility — the gate then binds the observed url to the gate-time Git remote URL
|
|
61
|
-
// (ledger.gitAuthority.remoteUrl), so a masked/public/wrong-repo reply never completes publication.
|
|
53
|
+
// Native GitHub visibility needs both fields; the gate binds the observed URL to the live remote.
|
|
62
54
|
const GH_REPO_VIEW = /^gh\s+repo\s+view(?:\s+(\S+))?\s+--json\s+([A-Za-z0-9_,]+)\s*$/;
|
|
63
55
|
const GH_VISIBILITY = /^(?:PUBLIC|PRIVATE|INTERNAL)$/;
|
|
64
56
|
const DRY_RUN = /(?:^|\s)(?:--dry-run|--check|--no-act|-n)(?:\s|=|$)/;
|
|
65
57
|
const CURL = /^curl\s/;
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
// 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)/;
|
|
58
|
+
// Drop one leading cd; remaining separators still mask native exit attribution.
|
|
59
|
+
const LEADING_CD = /^\s*cd\s+(?:"[^"\n$`\\]*"|'[^'\n]*'|[^\s;&|<>$`()]+)\s*(?:;|&&)\s*(?=\S)/;
|
|
70
60
|
export function stripLeadingCd(command) {
|
|
71
61
|
return typeof command === 'string' ? command.replace(LEADING_CD, '') : command;
|
|
72
62
|
}
|
|
@@ -79,6 +69,31 @@ function requiredTargetOf(segment, required) {
|
|
|
79
69
|
function maskShellArguments(command) {
|
|
80
70
|
return command.replace(/'[^']*'|"(?:\\.|[^"\\])*"|\\./g, (word) => ' '.repeat(word.length));
|
|
81
71
|
}
|
|
72
|
+
// This is a bounded attribution recognizer, not a shell parser. A native shell exit
|
|
73
|
+
// proves a simple foreground command (or all commands in an && chain), never a
|
|
74
|
+
// background child, pipeline, substitution, group, or status-swallowing sequence.
|
|
75
|
+
export function preservesNativeOperationExit(raw) {
|
|
76
|
+
if (typeof raw !== 'string' || !raw.trim()) return false;
|
|
77
|
+
const text = stripLeadingCd(raw).trim();
|
|
78
|
+
let quote = null;
|
|
79
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
80
|
+
const c = text[i];
|
|
81
|
+
if (c === '\\' && quote !== "'") { if (++i >= text.length) return false; continue; }
|
|
82
|
+
if (quote === "'") { if (c === "'") quote = null; continue; }
|
|
83
|
+
// Expansions inside double quotes still execute; never hide them as arguments.
|
|
84
|
+
if (c === '`' || (c === '$' && text[i + 1] === '(')) return false;
|
|
85
|
+
if (quote === '"') { if (c === '"') quote = null; continue; }
|
|
86
|
+
if (c === "'" || c === '"') { quote = c; continue; }
|
|
87
|
+
if (';|(){}[]\n\r'.includes(c) || c === '#') return false;
|
|
88
|
+
if (c === '&') {
|
|
89
|
+
if (text[i + 1] === '&') { i += 1; continue; }
|
|
90
|
+
if (text[i - 1] === '>' || text[i - 1] === '<' || text[i + 1] === '>') continue;
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (quote) return false;
|
|
95
|
+
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);
|
|
96
|
+
}
|
|
82
97
|
export function bashOperationReceipt(rawCommand, response, transportSuccess, goal, { sessionId, harness } = {}) {
|
|
83
98
|
const command = stripLeadingCd(rawCommand);
|
|
84
99
|
const domain = goal?.domain;
|
|
@@ -88,7 +103,7 @@ export function bashOperationReceipt(rawCommand, response, transportSuccess, goa
|
|
|
88
103
|
// Preserve offsets while hiding quoted arguments: only unquoted && separates commands.
|
|
89
104
|
const bare = maskShellArguments(command);
|
|
90
105
|
// Pipelines have no per-command exit proof here; the word "pipefail" is not shell state.
|
|
91
|
-
const masked =
|
|
106
|
+
const masked = !preservesNativeOperationExit(rawCommand);
|
|
92
107
|
const real = [];
|
|
93
108
|
let start = 0;
|
|
94
109
|
for (const separator of bare.matchAll(/&&/g)) {
|
|
@@ -134,7 +149,10 @@ export function bashOperationReceipt(rawCommand, response, transportSuccess, goa
|
|
|
134
149
|
const wholeTree = kind !== 'status' || !/\s--\s|\s--porcelain=\S*\s+\S|(?:--porcelain|-s|--short)\s+[^\s-]/.test(seg);
|
|
135
150
|
if (!wholeTree || (wanted && kind !== wanted)) continue;
|
|
136
151
|
role = 'state';
|
|
137
|
-
|
|
152
|
+
const historyOnly = required.length > 0 && required.every((verb) => ['commit', 'push', 'tag'].includes(verb));
|
|
153
|
+
const untrackedOnly = /^git\s+status\s+--porcelain\s*$/.test(seg)
|
|
154
|
+
&& text.split('\n').every((line) => !line || line.startsWith('?? '));
|
|
155
|
+
proven = kind === 'status' ? hasOutputField && (text === '' || (historyOnly && untrackedOnly)) : true;
|
|
138
156
|
} else if (GIT_VERB.test(seg) && role !== 'state') {
|
|
139
157
|
role = 'mutation';
|
|
140
158
|
target = seg.match(GIT_VERB)[1];
|
|
@@ -157,15 +175,10 @@ export function bashOperationReceipt(rawCommand, response, transportSuccess, goa
|
|
|
157
175
|
}
|
|
158
176
|
}
|
|
159
177
|
if (!role) return null;
|
|
160
|
-
//
|
|
161
|
-
// compound/masked chain the stdout of its parts is mixed, so it can neither prove nor refute the state and
|
|
162
|
-
// must not overwrite earlier authoritative evidence. (A standalone dirty status still records as failed.)
|
|
178
|
+
// Mixed/masked stdout cannot prove or refute state; standalone dirty status still fails.
|
|
163
179
|
if (domain === 'git-integrity' && role === 'state'
|
|
164
180
|
&& (masked || real.filter((seg) => seg && !/^cd\s+\S+$/.test(seg)).length !== 1)) return null;
|
|
165
|
-
//
|
|
166
|
-
// state receipt (no github field) so it invalidates any earlier PRIVATE proof until a fresh successful
|
|
167
|
-
// view lands — the postcondition must hold at the LATEST check, not at some earlier one. Other git
|
|
168
|
-
// state commands keep the rule that a transport failure is unattributable and records nothing.
|
|
181
|
+
// Failed GitHub views invalidate prior visibility; other failed Git transports mint no state proof.
|
|
169
182
|
if (domain === 'git-integrity' && role === 'state' && transportSuccess !== true && !ghObserved) return null;
|
|
170
183
|
const ok = transportSuccess === true && !masked && Boolean(scope) && (role === 'mutation' || proven);
|
|
171
184
|
return { kind: 'operation', success: ok, transportSuccess: transportSuccess === true, harness,
|
|
@@ -185,7 +198,7 @@ const SEMVER_LINE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
|
185
198
|
export function releaseReceipt(rawCommand, response, transportSuccess) {
|
|
186
199
|
if (typeof rawCommand !== 'string') return null;
|
|
187
200
|
const command = stripLeadingCd(rawCommand);
|
|
188
|
-
const masked =
|
|
201
|
+
const masked = !preservesNativeOperationExit(rawCommand);
|
|
189
202
|
const segments = command.split('&&').map((seg) => seg.trim()).filter((seg) => seg && !/^cd\s+\S+$/.test(seg));
|
|
190
203
|
if (segments.length !== 1) return null;
|
|
191
204
|
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:
|
|
1464
|
-
//
|
|
1465
|
-
//
|
|
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
|
-
//
|
|
1692
|
-
//
|
|
1693
|
-
//
|
|
1694
|
-
//
|
|
1695
|
-
//
|
|
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
|
-
//
|
|
2134
|
-
//
|
|
2135
|
-
//
|
|
2136
|
-
// a
|
|
2137
|
-
//
|
|
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
|
-
//
|
|
2336
|
-
//
|
|
2337
|
-
//
|
|
2338
|
-
//
|
|
2339
|
-
//
|
|
2340
|
-
//
|
|
2341
|
-
//
|
|
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' &&
|
|
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
|
-
|
|
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 || [];
|
|
@@ -3808,15 +3780,17 @@ function gitAuthority(cwd) {
|
|
|
3808
3780
|
const remote = gitOutput(['config', `branch.${branch}.remote`], cwd);
|
|
3809
3781
|
const remoteUrl = remote ? gitOutput(['config', `remote.${remote}.url`], cwd) : null;
|
|
3810
3782
|
// gitOutput conflates empty output with failure; cleanliness needs an explicit successful status read.
|
|
3811
|
-
let clean = false;
|
|
3783
|
+
let clean = false; let trackedClean = false;
|
|
3812
3784
|
try {
|
|
3813
|
-
|
|
3785
|
+
const status = execFileSync('git', ['status', '--porcelain'], {
|
|
3814
3786
|
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000,
|
|
3815
|
-
})
|
|
3787
|
+
});
|
|
3788
|
+
clean = status.trim() === '';
|
|
3789
|
+
trackedClean = status.split('\n').every((line) => !line || line.startsWith('?? '));
|
|
3816
3790
|
} catch { /* an unreadable working tree is not clean */ }
|
|
3817
3791
|
// Annotated tags are peeled by --points-at; this state corroborates successful native tag execution.
|
|
3818
3792
|
const tagsAtHead = (gitOutput(['tag', '--points-at', 'HEAD'], cwd) || '').split('\n').map((t) => t.trim()).filter(Boolean).slice(0, 16);
|
|
3819
|
-
return { head, branch, remote, remoteUrl, tagsAtHead, clean };
|
|
3793
|
+
return { head, branch, remote, remoteUrl, tagsAtHead, clean, trackedClean };
|
|
3820
3794
|
}
|
|
3821
3795
|
function withGitAuthority(ledger, cwd) {
|
|
3822
3796
|
const gitAuth = ledger && Object.keys(ledger).length ? gitAuthority(cwd) : null;
|
|
@@ -49,10 +49,7 @@ export function foldOperationEvidence(bank = {}, receipt, ledger) {
|
|
|
49
49
|
if (receipt.toolUseId && (seen.includes(receipt.toolUseId) || Object.values(bank).some((entry) =>
|
|
50
50
|
[entry?.mutation, entry?.state].some((proof) => proof?.toolUseId === receipt.toolUseId)))) return bank;
|
|
51
51
|
if (receipt.toolUseId && (!bounded(receipt.toolUseId) || seen.length >= 256)) return { ...bank, overflow: true };
|
|
52
|
-
//
|
|
53
|
-
// it: both are `state` receipts under one key, so the later status used to overwrite the remote proof. The
|
|
54
|
-
// proof is stamped with the sequence it was observed at and carried over only while no mutation (failed or
|
|
55
|
-
// not) of this domain/scope was recorded after it; the gate still compares its sha to live HEAD.
|
|
52
|
+
// Carry remote parity across successful state checks only until the next mutation; gate checks live HEAD.
|
|
56
53
|
const lastMutation = Object.values(bank).reduce((max, entry) => {
|
|
57
54
|
const m = entry?.mutation;
|
|
58
55
|
return m?.operation?.domain === op.domain && m.operation.scope === op.scope && Number.isSafeInteger(m.sequence) ? Math.max(max, m.sequence) : max;
|
|
@@ -127,15 +124,15 @@ export function operationEvidenceStatus(goal, ledger = {}) {
|
|
|
127
124
|
if (goal.visibility === 'PRIVATE' && !(validGithubView(op?.github)
|
|
128
125
|
&& op.github.visibility === 'PRIVATE' && githubRepository(auth?.remoteUrl) !== null
|
|
129
126
|
&& githubRepository(op.github.url) === githubRepository(auth.remoteUrl))) return incomplete;
|
|
130
|
-
|
|
127
|
+
// Commit/push prove committed history; an explicit clean goal still owns the whole tree.
|
|
128
|
+
const cleanEnough = auth?.clean === true || (auth?.trackedClean === true && !requiredTargets.includes('clean'));
|
|
129
|
+
if (goal.domain === 'git-integrity' && executed && validRemoteSync(op.remoteSync) && cleanEnough
|
|
131
130
|
&& requiredTargets.every((t) => t === 'commit' || t === 'push' || t === 'clean' || (t === 'tag' && Array.isArray(auth.tagsAtHead) && auth.tagsAtHead.length > 0))
|
|
132
131
|
&& attempts.every((r) => (Number.isSafeInteger(op.remoteSync.observedSequence) ? op.remoteSync.observedSequence : observation.sequence) > r.sequence)
|
|
133
132
|
&& op.remoteSync.sha === auth.head && op.remoteSync.ref === `refs/heads/${auth.branch}`
|
|
134
133
|
&& op.remoteSync.remote === auth.remote) return { complete: true, operationProof: 'remote-parity' };
|
|
135
|
-
// Push
|
|
134
|
+
// Push needs remote proof; divergent observations cannot fall back to local checks.
|
|
136
135
|
if (goal.domain === 'git-integrity' && requiredTargets.includes('push')) return incomplete;
|
|
137
|
-
// A remote observation that disagrees with live local truth is divergence, not a verified state: it can never
|
|
138
|
-
// be discharged by the fallback below just because a push command exited 0.
|
|
139
136
|
if (goal.domain === 'git-integrity' && validRemoteSync(op.remoteSync) && auth
|
|
140
137
|
&& (op.remoteSync.sha !== auth.head || op.remoteSync.ref !== `refs/heads/${auth.branch}`
|
|
141
138
|
|| (auth.remote && op.remoteSync.remote !== auth.remote))) return incomplete;
|