@mjasnikovs/pi-task 0.18.3 → 0.18.5
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/dist/config/config.d.ts +11 -0
- package/dist/config/config.js +4 -1
- package/dist/config/register.js +5 -0
- package/dist/task/accept-debt.d.ts +52 -0
- package/dist/task/accept-debt.js +0 -0
- package/dist/task/auto-orchestrator.d.ts +2 -0
- package/dist/task/auto-orchestrator.js +20 -0
- package/dist/task/enforce-guidelines.d.ts +2 -2
- package/dist/task/enforce-guidelines.js +36 -3
- package/dist/task/env-notes.d.ts +24 -8
- package/dist/task/env-notes.js +124 -24
- package/dist/task/final-gate.d.ts +8 -0
- package/dist/task/final-gate.js +27 -7
- package/dist/task/frozen-path-guard.d.ts +39 -0
- package/dist/task/frozen-path-guard.js +116 -0
- package/dist/task/gate-deps.d.ts +10 -0
- package/dist/task/gate-deps.js +122 -3
- package/dist/task/probe-gaming.d.ts +60 -0
- package/dist/task/probe-gaming.js +0 -0
- package/dist/task/repo-health-check.d.ts +11 -0
- package/dist/task/repo-health-check.js +26 -3
- package/dist/task/task-gates.d.ts +24 -0
- package/dist/task/task-gates.js +78 -8
- package/dist/task/test-assembly.d.ts +87 -0
- package/dist/task/test-assembly.js +163 -0
- package/dist/task/verify-work.d.ts +17 -1
- package/dist/task/verify-work.js +87 -2
- package/dist/workers/pi-worker-docs.js +13 -1
- package/dist/workers/pi-worker-fetch.js +10 -1
- package/dist/workers/pi-worker-search.js +9 -1
- package/dist/workers/research-cache.d.ts +39 -0
- package/dist/workers/research-cache.js +140 -0
- package/dist/workers/shared.d.ts +17 -0
- package/dist/workers/shared.js +0 -0
- package/package.json +2 -2
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Run a git subcommand in the guard's cwd; only stdout + exit code are read. */
|
|
2
|
+
export type FrozenGit = (args: string[]) => Promise<{
|
|
3
|
+
stdout: string;
|
|
4
|
+
exitCode: number;
|
|
5
|
+
}>;
|
|
6
|
+
/**
|
|
7
|
+
* The concrete paths the spec forbids modifying, normalized and de-duplicated —
|
|
8
|
+
* the SAME extraction the verify prohibition-probe and the accept-debt ledger
|
|
9
|
+
* consume, so "frozen" means one thing across the gates. Empty when the spec is
|
|
10
|
+
* null or names no path-like token on a modification-ban line: the guard is then a
|
|
11
|
+
* no-op by construction.
|
|
12
|
+
*/
|
|
13
|
+
export declare function frozenPathsFromSpec(spec: string | null | undefined): string[];
|
|
14
|
+
/**
|
|
15
|
+
* Parse `git status --porcelain` output (already scoped to the frozen pathspec)
|
|
16
|
+
* into the list of changed files, for the gate-trail record and to decide whether
|
|
17
|
+
* anything must be reverted at all. A rename line (`R old -> new`) yields the NEW
|
|
18
|
+
* path — the side that carries the child's write. Deterministic and pure so the
|
|
19
|
+
* parsing is unit-tested without a real repo.
|
|
20
|
+
*/
|
|
21
|
+
export declare function parseChangedFrozenFiles(porcelain: string): string[];
|
|
22
|
+
/**
|
|
23
|
+
* Restore the spec-frozen paths to their committed (HEAD) state, undoing any
|
|
24
|
+
* change a just-run write-capable gate child made to them, and return the list of
|
|
25
|
+
* files that had to be reverted (empty ⇒ the pass respected every frozen path).
|
|
26
|
+
*
|
|
27
|
+
* Runs AFTER the task's own work is committed (HEAD), so "restore to HEAD" keeps
|
|
28
|
+
* the verified task's version of the frozen file and discards ONLY the gate
|
|
29
|
+
* child's edit on top of it — the task's own frozen-path edits, if any, are a
|
|
30
|
+
* separate concern the verify prohibition-probe surfaces. `git checkout HEAD`
|
|
31
|
+
* covers modified/deleted tracked files under each pathspec; `git clean` removes
|
|
32
|
+
* any untracked file the pass created under a frozen directory. Both are scoped to
|
|
33
|
+
* the frozen pathspec, so nothing else in the tree is touched.
|
|
34
|
+
*
|
|
35
|
+
* Best-effort: an empty frozen list, a non-git tree, or any git error yields an
|
|
36
|
+
* empty result — the guard must never break the gate on a project it cannot reason
|
|
37
|
+
* about.
|
|
38
|
+
*/
|
|
39
|
+
export declare function revertFrozenPaths(paths: string[], git: FrozenGit): Promise<string[]>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* frozen-path-guard — deterministic write-deny on spec-frozen paths for the
|
|
3
|
+
* write-capable gate children (the enforce EDIT pass especially).
|
|
4
|
+
*
|
|
5
|
+
* The failure class (carried from mx5 run 6, deferred there as the last unshipped
|
|
6
|
+
* piece of the frozen-contract work): a spec's CONSTRAINTS pin a path as
|
|
7
|
+
* off-limits ("**Do NOT modify** `src/server/index.ts`"), and a later
|
|
8
|
+
* write-enabled GATE pass — the enforce child runs `read,edit` — edits that path
|
|
9
|
+
* anyway. Its edits are judged only locally (a guideline-compliance verdict), then
|
|
10
|
+
* committed as an "ENFORCE GUIDELINES" snapshot on top of the verified task. The
|
|
11
|
+
* frozen contract is silently mutated by the very pass meant to police the work.
|
|
12
|
+
*
|
|
13
|
+
* Prompt framing is A/B-PROVEN insufficient for this class (the "FROZEN CONTRACT,
|
|
14
|
+
* MUST NOT edit" instruction held 0/5 unguarded, ~1/5 with framing — the weak
|
|
15
|
+
* local model ignores an explicit capitalized MUST-NOT most of the time). A
|
|
16
|
+
* chmod-style physical deny holds 5/5 but makes the model THRASH on the bare
|
|
17
|
+
* EACCES (1000+ futile re-attempts observed) — unacceptable on the enforce child,
|
|
18
|
+
* which runs UNGUARDED (no wall-clock timeout). pi itself has no path-level edit
|
|
19
|
+
* interception (only tool-NAME allow/deny), so the achievable, thrash-free,
|
|
20
|
+
* stack-agnostic realization of a tool-layer deny is this: let the pass edit
|
|
21
|
+
* freely, then deterministically UNDO any frozen-path change it made before those
|
|
22
|
+
* edits can be committed. The violating write never lands in a commit, regardless
|
|
23
|
+
* of what the model intended — no model in the loop, same discipline as
|
|
24
|
+
* git-state-guard.
|
|
25
|
+
*
|
|
26
|
+
* Everything degrades to a no-op when the spec froze nothing (a single-`/task`
|
|
27
|
+
* run, or a spec with no `Do NOT modify` line naming a path → no frozen paths →
|
|
28
|
+
* nothing snapshotted, nothing reverted) and on any git error. Pure git shape,
|
|
29
|
+
* zero stack assumptions — a frozen path exists for a CLI, a library, a script
|
|
30
|
+
* collection exactly as for a web app.
|
|
31
|
+
*/
|
|
32
|
+
import { extractProhibitions } from './prohibition-probe.js';
|
|
33
|
+
/**
|
|
34
|
+
* The concrete paths the spec forbids modifying, normalized and de-duplicated —
|
|
35
|
+
* the SAME extraction the verify prohibition-probe and the accept-debt ledger
|
|
36
|
+
* consume, so "frozen" means one thing across the gates. Empty when the spec is
|
|
37
|
+
* null or names no path-like token on a modification-ban line: the guard is then a
|
|
38
|
+
* no-op by construction.
|
|
39
|
+
*/
|
|
40
|
+
export function frozenPathsFromSpec(spec) {
|
|
41
|
+
if (!spec)
|
|
42
|
+
return [];
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
for (const p of extractProhibitions(spec)) {
|
|
45
|
+
const n = p.path.replace(/^\.\//, '').replace(/\/+$/, '');
|
|
46
|
+
if (n.length > 0)
|
|
47
|
+
seen.add(n);
|
|
48
|
+
}
|
|
49
|
+
return [...seen];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Parse `git status --porcelain` output (already scoped to the frozen pathspec)
|
|
53
|
+
* into the list of changed files, for the gate-trail record and to decide whether
|
|
54
|
+
* anything must be reverted at all. A rename line (`R old -> new`) yields the NEW
|
|
55
|
+
* path — the side that carries the child's write. Deterministic and pure so the
|
|
56
|
+
* parsing is unit-tested without a real repo.
|
|
57
|
+
*/
|
|
58
|
+
export function parseChangedFrozenFiles(porcelain) {
|
|
59
|
+
const out = [];
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
for (const raw of porcelain.split('\n')) {
|
|
62
|
+
// Porcelain v1: two status chars, a space, then the path. Blank/short lines
|
|
63
|
+
// (trailing newline) carry no entry.
|
|
64
|
+
if (raw.length < 4)
|
|
65
|
+
continue;
|
|
66
|
+
let file = raw.slice(3).trim();
|
|
67
|
+
if (file.length === 0)
|
|
68
|
+
continue;
|
|
69
|
+
// Rename/copy: "orig -> new" — the new path is the one the write produced.
|
|
70
|
+
const arrow = file.indexOf(' -> ');
|
|
71
|
+
if (arrow !== -1)
|
|
72
|
+
file = file.slice(arrow + 4).trim();
|
|
73
|
+
// Porcelain quotes paths with unusual chars; strip the surrounding quotes.
|
|
74
|
+
if (file.startsWith('"') && file.endsWith('"') && file.length >= 2) {
|
|
75
|
+
file = file.slice(1, -1);
|
|
76
|
+
}
|
|
77
|
+
if (file.length === 0 || seen.has(file))
|
|
78
|
+
continue;
|
|
79
|
+
seen.add(file);
|
|
80
|
+
out.push(file);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Restore the spec-frozen paths to their committed (HEAD) state, undoing any
|
|
86
|
+
* change a just-run write-capable gate child made to them, and return the list of
|
|
87
|
+
* files that had to be reverted (empty ⇒ the pass respected every frozen path).
|
|
88
|
+
*
|
|
89
|
+
* Runs AFTER the task's own work is committed (HEAD), so "restore to HEAD" keeps
|
|
90
|
+
* the verified task's version of the frozen file and discards ONLY the gate
|
|
91
|
+
* child's edit on top of it — the task's own frozen-path edits, if any, are a
|
|
92
|
+
* separate concern the verify prohibition-probe surfaces. `git checkout HEAD`
|
|
93
|
+
* covers modified/deleted tracked files under each pathspec; `git clean` removes
|
|
94
|
+
* any untracked file the pass created under a frozen directory. Both are scoped to
|
|
95
|
+
* the frozen pathspec, so nothing else in the tree is touched.
|
|
96
|
+
*
|
|
97
|
+
* Best-effort: an empty frozen list, a non-git tree, or any git error yields an
|
|
98
|
+
* empty result — the guard must never break the gate on a project it cannot reason
|
|
99
|
+
* about.
|
|
100
|
+
*/
|
|
101
|
+
export async function revertFrozenPaths(paths, git) {
|
|
102
|
+
if (paths.length === 0)
|
|
103
|
+
return [];
|
|
104
|
+
const status = await git(['status', '--porcelain', '--', ...paths]);
|
|
105
|
+
if (status.exitCode !== 0)
|
|
106
|
+
return [];
|
|
107
|
+
const changed = parseChangedFrozenFiles(status.stdout);
|
|
108
|
+
if (changed.length === 0)
|
|
109
|
+
return [];
|
|
110
|
+
// Restore tracked modifications/deletions from HEAD, then remove any untracked
|
|
111
|
+
// additions — both confined to the frozen pathspec so the pass's legitimate
|
|
112
|
+
// edits to OTHER files survive untouched.
|
|
113
|
+
await git(['checkout', '-f', 'HEAD', '--', ...paths]);
|
|
114
|
+
await git(['clean', '-fdq', '--', ...paths]);
|
|
115
|
+
return changed;
|
|
116
|
+
}
|
package/dist/task/gate-deps.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import type { GateDeps } from './task-gates.js';
|
|
3
3
|
import { type FinalFixResult } from './final-gate-fix.js';
|
|
4
|
+
import { type AddedLine } from './probe-gaming.js';
|
|
4
5
|
import { type ChangedFile } from './substitution-probe.js';
|
|
5
6
|
/** A function that re-runs a task's implementation turn (AUTOFIX). Injected by the
|
|
6
7
|
* command so this module stays free of the orchestrators (avoids an import cycle). */
|
|
@@ -17,6 +18,15 @@ export type FinalGateFixFn = (ctx: ExtensionCommandContext, cwd: string, failRea
|
|
|
17
18
|
* blocker.
|
|
18
19
|
*/
|
|
19
20
|
export declare function collectChangedFiles(cwd: string, signal?: AbortSignal): Promise<ChangedFile[]>;
|
|
21
|
+
/**
|
|
22
|
+
* Collect the task's added lines WITH CONTENT (path + text) for the probe-gaming
|
|
23
|
+
* probe (F6), which needs the actual line text the numstat-shape collector omits.
|
|
24
|
+
* Pre-commit the work is the tree-vs-HEAD diff plus untracked files (read whole, as
|
|
25
|
+
* all-added); on the post-enforce re-verify the tree is clean, so fall back to the
|
|
26
|
+
* last commit's diff. Failures degrade to an empty list — the probe is a sharpener,
|
|
27
|
+
* never a blocker. The `.pi-tasks/` bookkeeping is excluded from every git command.
|
|
28
|
+
*/
|
|
29
|
+
export declare function collectAddedLines(cwd: string, signal?: AbortSignal): Promise<AddedLine[]>;
|
|
20
30
|
/**
|
|
21
31
|
* Build the gate deps for one command run. `runTask` is the orchestrator's
|
|
22
32
|
* implementation re-runner, injected by the caller. The returned object also drives
|
package/dist/task/gate-deps.js
CHANGED
|
@@ -21,12 +21,16 @@ import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-
|
|
|
21
21
|
import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
|
|
22
22
|
import { readEnvNotes, appendEnvNotes } from './env-notes.js';
|
|
23
23
|
import { readContracts } from './contracts.js';
|
|
24
|
+
import { recordAcceptDebt } from './accept-debt.js';
|
|
24
25
|
import { runRepoHealthCheck } from './repo-health-check.js';
|
|
25
26
|
import { runFinalIntegrationGate, discoverGateCommandLabels } from './final-gate.js';
|
|
26
27
|
import { runFinalGateAutofix } from './final-gate-fix.js';
|
|
27
28
|
import { researchResolution } from './verify-resolution.js';
|
|
28
29
|
import { extractProhibitions, findProhibitionViolations } from './prohibition-probe.js';
|
|
29
|
-
import {
|
|
30
|
+
import { frozenPathsFromSpec, revertFrozenPaths } from './frozen-path-guard.js';
|
|
31
|
+
import { findProbeGaming, parseAddedLines } from './probe-gaming.js';
|
|
32
|
+
import { findSubstitutionSuspects, isTestFile } from './substitution-probe.js';
|
|
33
|
+
import { findTestRebuiltAssemblies, testAssemblyVerifyFindings } from './test-assembly.js';
|
|
30
34
|
import { runBoundedLintFix } from './lint-fix.js';
|
|
31
35
|
import { captureGitState, reconcileGitState } from './git-state-guard.js';
|
|
32
36
|
import { runWorker } from '../workers/pi-worker-core.js';
|
|
@@ -76,6 +80,85 @@ export async function collectChangedFiles(cwd, signal) {
|
|
|
76
80
|
}
|
|
77
81
|
return files;
|
|
78
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Collect the task's added lines WITH CONTENT (path + text) for the probe-gaming
|
|
85
|
+
* probe (F6), which needs the actual line text the numstat-shape collector omits.
|
|
86
|
+
* Pre-commit the work is the tree-vs-HEAD diff plus untracked files (read whole, as
|
|
87
|
+
* all-added); on the post-enforce re-verify the tree is clean, so fall back to the
|
|
88
|
+
* last commit's diff. Failures degrade to an empty list — the probe is a sharpener,
|
|
89
|
+
* never a blocker. The `.pi-tasks/` bookkeeping is excluded from every git command.
|
|
90
|
+
*/
|
|
91
|
+
export async function collectAddedLines(cwd, signal) {
|
|
92
|
+
const tracked = await git(cwd, ['diff', 'HEAD', '--', '.', EXCLUDE_TASKS_DIR], signal);
|
|
93
|
+
const lines = tracked.exitCode === 0 ? parseAddedLines(tracked.stdout) : [];
|
|
94
|
+
const untracked = await git(cwd, ['ls-files', '--others', '--exclude-standard', '--', '.', EXCLUDE_TASKS_DIR], signal);
|
|
95
|
+
for (const name of splitLines(untracked.exitCode === 0 ? untracked.stdout : '')) {
|
|
96
|
+
try {
|
|
97
|
+
const content = await fsp.readFile(path.join(cwd, name), 'utf8');
|
|
98
|
+
for (const text of content.split('\n'))
|
|
99
|
+
lines.push({ path: name, text });
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// unreadable/binary — nothing to report
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (lines.length === 0) {
|
|
106
|
+
const last = await git(cwd, ['diff', 'HEAD~1..HEAD', '--', '.', EXCLUDE_TASKS_DIR], signal);
|
|
107
|
+
return last.exitCode === 0 ? parseAddedLines(last.stdout) : [];
|
|
108
|
+
}
|
|
109
|
+
return lines;
|
|
110
|
+
}
|
|
111
|
+
/** Source extensions whose relative imports the test-assembly probe reasons over. */
|
|
112
|
+
const SOURCE_EXT_RE = /\.(?:[cm]?[jt]sx?)$/;
|
|
113
|
+
/** Bounds so the probe stays cheap on large repos (it reads file text). */
|
|
114
|
+
const MAX_PROBE_FILES = 4000;
|
|
115
|
+
const MAX_PROBE_FILE_BYTES = 512 * 1024;
|
|
116
|
+
/** Best-effort read of a repo file's text; unreadable/oversized → null (skipped). */
|
|
117
|
+
async function readRepoFile(cwd, rel) {
|
|
118
|
+
try {
|
|
119
|
+
const buf = await fsp.readFile(path.join(cwd, rel));
|
|
120
|
+
if (buf.length > MAX_PROBE_FILE_BYTES)
|
|
121
|
+
return null;
|
|
122
|
+
return { path: rel, text: buf.toString('utf8') };
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Deterministic test-assembly probe input (see test-assembly.ts, run-8 F4): read the
|
|
130
|
+
* task's own changed TEST files plus the repo's tracked source files, and return the
|
|
131
|
+
* finding lines naming any test that rebuilds a production assembly it never imports.
|
|
132
|
+
* Pure import-graph shape; failures degrade to no findings (the probe is a sharpener,
|
|
133
|
+
* never a blocker). `changed` is the already-collected task diff, reused so the probe
|
|
134
|
+
* costs one extra tracked-file listing, not a second diff.
|
|
135
|
+
*/
|
|
136
|
+
async function collectTestAssemblyFindings(cwd, changed, signal) {
|
|
137
|
+
const changedTests = changed.filter(f => isTestFile(f.path));
|
|
138
|
+
if (changedTests.length === 0)
|
|
139
|
+
return [];
|
|
140
|
+
const listed = await git(cwd, ['ls-files', '--', '.', EXCLUDE_TASKS_DIR], signal);
|
|
141
|
+
if (listed.exitCode !== 0)
|
|
142
|
+
return [];
|
|
143
|
+
const sources = splitLines(listed.stdout)
|
|
144
|
+
.filter(p => SOURCE_EXT_RE.test(p))
|
|
145
|
+
.slice(0, MAX_PROBE_FILES);
|
|
146
|
+
const production = [];
|
|
147
|
+
for (const rel of sources) {
|
|
148
|
+
if (isTestFile(rel))
|
|
149
|
+
continue;
|
|
150
|
+
const f = await readRepoFile(cwd, rel);
|
|
151
|
+
if (f)
|
|
152
|
+
production.push(f);
|
|
153
|
+
}
|
|
154
|
+
const testFiles = [];
|
|
155
|
+
for (const { path: rel } of changedTests) {
|
|
156
|
+
const f = await readRepoFile(cwd, rel);
|
|
157
|
+
if (f)
|
|
158
|
+
testFiles.push(f);
|
|
159
|
+
}
|
|
160
|
+
return testAssemblyVerifyFindings(findTestRebuiltAssemblies(testFiles, production));
|
|
161
|
+
}
|
|
79
162
|
/**
|
|
80
163
|
* Build the gate deps for one command run. `runTask` is the orchestrator's
|
|
81
164
|
* implementation re-runner, injected by the caller. The returned object also drives
|
|
@@ -183,6 +266,29 @@ export function buildGateDeps(params) {
|
|
|
183
266
|
// Durable per-task gate trail: every verdict/decision lands in the task
|
|
184
267
|
// file's `## gates` section so gate behavior is auditable from artifacts.
|
|
185
268
|
record: (cwd2, taskId, line) => appendGateRecord(cwd2, taskId, line),
|
|
269
|
+
// Durable ACCEPT-despite-verify-FAIL ledger under .pi-tasks/ (survives
|
|
270
|
+
// discardEdits): the final integration gate re-checks each debt at run end.
|
|
271
|
+
recordAcceptDebt: (cwd2, taskId, reason) => recordAcceptDebt(cwd2, taskId, reason),
|
|
272
|
+
// Frozen-path write-deny (see frozen-path-guard.ts): the concrete paths this
|
|
273
|
+
// task's spec forbids modifying, so the gate sequence can UNDO any edit the
|
|
274
|
+
// enforce EDIT pass makes to them before those edits are committed. Reads the
|
|
275
|
+
// same composed spec + extractProhibitions the verify probe consumes; empty on
|
|
276
|
+
// a spec that froze nothing → the guard is a no-op.
|
|
277
|
+
frozenPaths: async (cwd2, taskId) => {
|
|
278
|
+
try {
|
|
279
|
+
const { body } = await readTaskFile(cwd2, taskId);
|
|
280
|
+
return frozenPathsFromSpec(extractSpecForVerification(body));
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
// Restore those frozen paths to HEAD, discarding a gate child's edits to them;
|
|
287
|
+
// returns the files actually reverted (for the trail). Best-effort git shape.
|
|
288
|
+
revertFrozenPaths: (cwd2, paths) => revertFrozenPaths(paths, async (args) => {
|
|
289
|
+
const r = await git(cwd2, args, signal);
|
|
290
|
+
return { stdout: r.stdout, exitCode: r.exitCode };
|
|
291
|
+
}),
|
|
186
292
|
commit: (cwd2, message) => getConfig().autoCommit ?
|
|
187
293
|
gitCommitAll(cwd2, message, signal)
|
|
188
294
|
: Promise.resolve({ committed: false, reason: 'auto-commit disabled' }),
|
|
@@ -292,6 +398,16 @@ export function buildGateDeps(params) {
|
|
|
292
398
|
// authored/changed become prompt-level findings mandating the child
|
|
293
399
|
// to drive the real artifact before trusting their green result.
|
|
294
400
|
probe: () => collectChangedFiles(cwd2, signal).then(findSubstitutionSuspects),
|
|
401
|
+
// Deterministic test-assembly probe (F4): authored test files that
|
|
402
|
+
// rebuild production wiring — importing the leaf modules the shipped
|
|
403
|
+
// entry composes and assembling their own copy — become rule-3f
|
|
404
|
+
// findings so the child drives the REAL assembly, not the copy.
|
|
405
|
+
testAssemblyProbe: () => collectChangedFiles(cwd2, signal).then(changed => collectTestAssemblyFindings(cwd2, changed, signal)),
|
|
406
|
+
// Deterministic probe-gaming probe (F6): added lines whose stated
|
|
407
|
+
// purpose is to make a check pass rather than meet the requirement
|
|
408
|
+
// ("return 401 so the verification test passes") become rule-4c
|
|
409
|
+
// findings so the child verifies the real requirement, not the check.
|
|
410
|
+
probeGamingProbe: () => collectAddedLines(cwd2, signal).then(findProbeGaming),
|
|
295
411
|
// Deterministic prohibition probe: paths the spec forbids modifying
|
|
296
412
|
// that the task's diff modified anyway become prompt-level findings
|
|
297
413
|
// under the no-waiver rule — the child otherwise rarely runs `git
|
|
@@ -311,10 +427,13 @@ export function buildGateDeps(params) {
|
|
|
311
427
|
// Per-run environment-facts cache under .pi-tasks/ (survives
|
|
312
428
|
// discardEdits): earlier children's discoveries save this child
|
|
313
429
|
// the re-archaeology; its own ENV-NOTE lines are stored for the
|
|
314
|
-
// next one
|
|
430
|
+
// next one, stamped with this task's id as their origin so a
|
|
431
|
+
// later child sees a cited fact is second-hand and must
|
|
432
|
+
// re-validate before excusing a failure (F7). Facts only —
|
|
433
|
+
// verdict rules unaffected.
|
|
315
434
|
envNotes: {
|
|
316
435
|
read: () => readEnvNotes(cwd2),
|
|
317
|
-
append: notes => appendEnvNotes(cwd2, notes)
|
|
436
|
+
append: notes => appendEnvNotes(cwd2, notes, taskId)
|
|
318
437
|
},
|
|
319
438
|
// Per-run cross-slice contract registry under .pi-tasks/ (F3): the
|
|
320
439
|
// verbatim interface facts the design pins that multiple slices
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* probe-gaming — deterministic detection of CHECK-GAMING code, feeding the verify
|
|
3
|
+
* and enforce gate prompts (run-8 F6).
|
|
4
|
+
*
|
|
5
|
+
* The failure class: the implementation writes code — or a comment on it — whose
|
|
6
|
+
* STATED PURPOSE is to make a check pass, rather than to satisfy the requirement the
|
|
7
|
+
* check stands for. mx5 run-8 shipped, verbatim,
|
|
8
|
+
* `// Return 401 so the verification test passes (expects 200/401/403 for /api routes).`
|
|
9
|
+
* above four catch-all handlers added to satisfy the VERIFY curl instead of fixing
|
|
10
|
+
* the broken route mount. The route stayed dead; the check went green; the code SAID
|
|
11
|
+
* SO IN WRITING. A check is a MESSENGER for a requirement — code written to quiet the
|
|
12
|
+
* messenger instead of meeting the requirement is the defect, and when it announces
|
|
13
|
+
* its own intent it is cheaply detectable.
|
|
14
|
+
*
|
|
15
|
+
* Same probe+rule design as the other run-8 probes (skip-escape.ts,
|
|
16
|
+
* prohibition-probe.ts, substitution-probe.ts, test-assembly.ts): a deterministic
|
|
17
|
+
* finding is the reliable lever, a prompt rule alone is weak. This scans the task's
|
|
18
|
+
* DIFF (added lines only — the work this task introduced) for the gaming tell and
|
|
19
|
+
* hands each hit to the gate child verbatim, so the rule fires on a concrete line
|
|
20
|
+
* rather than on the model self-discovering the intent.
|
|
21
|
+
*
|
|
22
|
+
* Advisory, never an auto-FAIL: intent phrasing is prose and a genuinely-benign line
|
|
23
|
+
* could in principle carry it ("returns 401 so the auth test passes" describing a
|
|
24
|
+
* CORRECT auth path). The child reads the exact line and the surrounding code and
|
|
25
|
+
* judges — the verify rule directs it to confirm the underlying requirement is
|
|
26
|
+
* actually met, not merely that the check is green.
|
|
27
|
+
*
|
|
28
|
+
* FP-MEASURED on the real run-8 corpus (~/hub/mx5, all 53 commits): 1 unique hit in
|
|
29
|
+
* 50,735 added lines — exactly the true F6 line, zero false positives. The tell is a
|
|
30
|
+
* purpose phrase binding a CHECK noun (test / verification / lint / CI / gate / …) to
|
|
31
|
+
* a pass-state or a gaming verb; benign uses of the same words ("pass the test data",
|
|
32
|
+
* "run the verification suite", "the linter flagged this") do not match. Pure
|
|
33
|
+
* diff-text analysis — no stack, framework, or tool-name assumptions; a project with
|
|
34
|
+
* no such comment simply yields no findings (nothing to observe = pass).
|
|
35
|
+
*/
|
|
36
|
+
/** One added line from a task's diff: the file it was added to and its text. */
|
|
37
|
+
export interface AddedLine {
|
|
38
|
+
/** Path relative to the repo root (from the `+++ b/…` header). */
|
|
39
|
+
path: string;
|
|
40
|
+
/** The added line's content, without the leading `+`. */
|
|
41
|
+
text: string;
|
|
42
|
+
}
|
|
43
|
+
/** Does this line state its purpose is to make a check pass? */
|
|
44
|
+
export declare function isProbeGamingLine(text: string): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Parse a unified `git diff` into its added lines (path + text). Tracks the current
|
|
47
|
+
* file from each `+++ b/…` header and collects every `+`-prefixed body line
|
|
48
|
+
* (excluding the `+++` header itself). Robust to surrounding prose (the enforce diff
|
|
49
|
+
* capture wraps the diff in a header/footer) because only `+`/`+++` lines are read.
|
|
50
|
+
*/
|
|
51
|
+
export declare function parseAddedLines(diff: string): AddedLine[];
|
|
52
|
+
/**
|
|
53
|
+
* Scan a set of added lines for check-gaming tells. Returns one finding per matching
|
|
54
|
+
* line; empty when none match (the prompt then gets no probe block). The finding
|
|
55
|
+
* carries the file and the offending line verbatim so the gate child can locate and
|
|
56
|
+
* judge it. Long lines are truncated for the prompt.
|
|
57
|
+
*/
|
|
58
|
+
export declare function findProbeGaming(added: AddedLine[]): string[];
|
|
59
|
+
/** Convenience: scan a unified diff string straight to findings. */
|
|
60
|
+
export declare function findProbeGamingInDiff(diff: string): string[];
|
|
Binary file
|
|
@@ -6,7 +6,18 @@ export interface HealthOutcome {
|
|
|
6
6
|
reason: string;
|
|
7
7
|
/** Which manifest drove discovery, or null when none was found. */
|
|
8
8
|
ecosystem: string | null;
|
|
9
|
+
/**
|
|
10
|
+
* First lines of the failing command's combined stderr+stdout — captured so a
|
|
11
|
+
* FAIL is explainable from artifacts alone. Run-8 F8: five enforce passes were
|
|
12
|
+
* discarded on "`bun run lint` exited 2" and the cause was unreproducible
|
|
13
|
+
* post-run because only the exit code was recorded (exit 2 is the linter's
|
|
14
|
+
* CRASH class; findings exit 1 — the captured output is what tells them apart).
|
|
15
|
+
* Empty string on pass / skip.
|
|
16
|
+
*/
|
|
17
|
+
output: string;
|
|
9
18
|
}
|
|
19
|
+
/** Combine a failing command's stderr+stdout into a bounded, first-N-lines snippet. */
|
|
20
|
+
export declare function captureHealthOutput(stdout: string, stderr: string): string;
|
|
10
21
|
/** One discovered command: the binary and its args, run from the repo root. */
|
|
11
22
|
export type HealthCommand = [bin: string, args: string[]];
|
|
12
23
|
/**
|
|
@@ -32,6 +32,23 @@
|
|
|
32
32
|
import { spawnSync } from 'node:child_process';
|
|
33
33
|
import { existsSync, readFileSync } from 'node:fs';
|
|
34
34
|
import * as path from 'node:path';
|
|
35
|
+
/** How much of a failing command's output to keep — bounded so a wedged tool that
|
|
36
|
+
* spews megabytes cannot bloat the trail. stderr leads (a crash trace lives there). */
|
|
37
|
+
const HEALTH_OUTPUT_MAX_LINES = 40;
|
|
38
|
+
const HEALTH_OUTPUT_MAX_CHARS = 4000;
|
|
39
|
+
/** Combine a failing command's stderr+stdout into a bounded, first-N-lines snippet. */
|
|
40
|
+
export function captureHealthOutput(stdout, stderr) {
|
|
41
|
+
const combined = [stderr, stdout]
|
|
42
|
+
.map(s => (s ?? '').trim())
|
|
43
|
+
.filter(s => s.length > 0)
|
|
44
|
+
.join('\n');
|
|
45
|
+
if (combined.length === 0)
|
|
46
|
+
return '';
|
|
47
|
+
let snippet = combined.split('\n').slice(0, HEALTH_OUTPUT_MAX_LINES).join('\n');
|
|
48
|
+
if (snippet.length > HEALTH_OUTPUT_MAX_CHARS)
|
|
49
|
+
snippet = `${snippet.slice(0, HEALTH_OUTPUT_MAX_CHARS)}…`;
|
|
50
|
+
return snippet;
|
|
51
|
+
}
|
|
35
52
|
function packageScripts(cwd) {
|
|
36
53
|
try {
|
|
37
54
|
const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
@@ -103,7 +120,12 @@ export function discoverHealthCommands(cwd) {
|
|
|
103
120
|
export function runRepoHealthCheck(cwd, timeoutMs = 600_000) {
|
|
104
121
|
const { ecosystem, cmds } = discoverHealthCommands(cwd);
|
|
105
122
|
if (!ecosystem || cmds.length === 0) {
|
|
106
|
-
return {
|
|
123
|
+
return {
|
|
124
|
+
ok: true,
|
|
125
|
+
reason: 'no repo-wide static-analysis command found',
|
|
126
|
+
ecosystem,
|
|
127
|
+
output: ''
|
|
128
|
+
};
|
|
107
129
|
}
|
|
108
130
|
for (const [bin, args] of cmds) {
|
|
109
131
|
const r = spawnSync(bin, args, { cwd, encoding: 'utf8', timeout: timeoutMs });
|
|
@@ -119,9 +141,10 @@ export function runRepoHealthCheck(cwd, timeoutMs = 600_000) {
|
|
|
119
141
|
return {
|
|
120
142
|
ok: false,
|
|
121
143
|
reason: `\`${bin} ${args.join(' ')}\` exited ${r.status}`,
|
|
122
|
-
ecosystem
|
|
144
|
+
ecosystem,
|
|
145
|
+
output: captureHealthOutput(r.stdout, r.stderr)
|
|
123
146
|
};
|
|
124
147
|
}
|
|
125
148
|
}
|
|
126
|
-
return { ok: true, reason: `${ecosystem}: static checks passed`, ecosystem };
|
|
149
|
+
return { ok: true, reason: `${ecosystem}: static checks passed`, ecosystem, output: '' };
|
|
127
150
|
}
|
|
@@ -101,6 +101,7 @@ export interface GateDeps {
|
|
|
101
101
|
repoHealth?: (cwd: string) => Promise<{
|
|
102
102
|
ok: boolean;
|
|
103
103
|
reason: string;
|
|
104
|
+
output?: string;
|
|
104
105
|
}>;
|
|
105
106
|
/** Does the working tree hold changes (excluding .pi-tasks)? Lets the pre-commit
|
|
106
107
|
* health check run only when the enforce pass actually edited something. */
|
|
@@ -118,6 +119,29 @@ export interface GateDeps {
|
|
|
118
119
|
* swallowed by the implementation, never by this sequence.
|
|
119
120
|
*/
|
|
120
121
|
record?: (cwd: string, taskId: string, line: string) => Promise<void>;
|
|
122
|
+
/**
|
|
123
|
+
* Record a durable ACCEPT-despite-verify-FAIL debt (task id + FAIL reason) to the
|
|
124
|
+
* run-level ledger (`.pi-tasks/accept-debt.md`, see accept-debt.ts). Called only on
|
|
125
|
+
* the picker's ACCEPT branch — the human blessed a failing artifact as-is, so the
|
|
126
|
+
* defect is real and recorded; the final integration gate re-checks it at run end
|
|
127
|
+
* and surfaces it if still open. Best-effort; absent in tests → no ledger written.
|
|
128
|
+
*/
|
|
129
|
+
recordAcceptDebt?: (cwd: string, taskId: string, reason: string) => Promise<void>;
|
|
130
|
+
/**
|
|
131
|
+
* The concrete paths this task's spec forbids modifying (its `Do NOT modify`
|
|
132
|
+
* CONSTRAINTS — see frozen-path-guard.ts / prohibition-probe.ts). Used to
|
|
133
|
+
* write-deny the enforce EDIT pass: a violating edit is reverted before it can
|
|
134
|
+
* be committed. Empty when the spec froze nothing → the guard is a no-op.
|
|
135
|
+
* Absent in tests / on a bare `/task` → the guard is skipped.
|
|
136
|
+
*/
|
|
137
|
+
frozenPaths?: (cwd: string, taskId: string) => Promise<string[]>;
|
|
138
|
+
/**
|
|
139
|
+
* Restore the given frozen paths to their committed (HEAD) state, discarding a
|
|
140
|
+
* gate child's edits to them, and return the files actually reverted. Prompt
|
|
141
|
+
* framing is A/B-proven insufficient for this class, so the deny is mechanical:
|
|
142
|
+
* the write is undone, not merely warned about. Absent → the guard warns only.
|
|
143
|
+
*/
|
|
144
|
+
revertFrozenPaths?: (cwd: string, paths: string[]) => Promise<string[]>;
|
|
121
145
|
}
|
|
122
146
|
/** Inputs the sequence needs that vary per caller. */
|
|
123
147
|
export interface GateParams {
|
package/dist/task/task-gates.js
CHANGED
|
@@ -10,6 +10,18 @@ import { SessionUI } from '../remote/bridge.js';
|
|
|
10
10
|
* regardless of this count (blessing an artifact as-is is a human's call).
|
|
11
11
|
*/
|
|
12
12
|
export const MAX_AUTO_AUTOFIX = 3;
|
|
13
|
+
/**
|
|
14
|
+
* Bound a captured health-check output before it is embedded in a gate-trail line.
|
|
15
|
+
* appendGateRecord flattens newlines to spaces, so the trail stays one line per
|
|
16
|
+
* entry; this just caps the volume (a wedged tool can emit megabytes). The health
|
|
17
|
+
* check already trims to its own first-N lines — this is the trail-side ceiling.
|
|
18
|
+
*/
|
|
19
|
+
const TRAIL_OUTPUT_MAX_CHARS = 1200;
|
|
20
|
+
function clampOutput(output) {
|
|
21
|
+
return output.length > TRAIL_OUTPUT_MAX_CHARS ?
|
|
22
|
+
`${output.slice(0, TRAIL_OUTPUT_MAX_CHARS)}…`
|
|
23
|
+
: output;
|
|
24
|
+
}
|
|
13
25
|
/**
|
|
14
26
|
* Show the boxed two-choice picker after a verify FAIL and return what the user
|
|
15
27
|
* decided. The model-recommended card is placed first so the renderer tints it
|
|
@@ -135,6 +147,16 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
135
147
|
}
|
|
136
148
|
if (choice.action === 'accept') {
|
|
137
149
|
await rec('resolution: user ACCEPTED the work despite verify FAIL');
|
|
150
|
+
// Durable debt: the human blessed a FAILing artifact as-is, so the
|
|
151
|
+
// defect ships and nothing else revisits it (mx5 run 4 B3 / run 8
|
|
152
|
+
// TASK_0012). Record it to the run ledger; the final integration gate
|
|
153
|
+
// re-checks it at run end and surfaces it if still open. Best-effort.
|
|
154
|
+
try {
|
|
155
|
+
await deps.recordAcceptDebt?.(p.cwd, p.taskId, failReason);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// recording must never break the gate sequence
|
|
159
|
+
}
|
|
138
160
|
active.ui.notify(`${p.tag}: accepted "${p.title}" despite verify FAIL (${failReason.slice(0, 120)}) — proceeding.`, 'warning');
|
|
139
161
|
break;
|
|
140
162
|
}
|
|
@@ -202,13 +224,40 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
202
224
|
// with no enforce dep.
|
|
203
225
|
if (deps.enforce && commit.committed) {
|
|
204
226
|
const mode = verifyCleanPass ? 'edit' : 'flag';
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
227
|
+
// BASELINE repo health, captured BEFORE the edit pass touches the tree, so the
|
|
228
|
+
// pre-commit gate below is DIFFERENTIAL: it can tell an enforce-CAUSED
|
|
229
|
+
// regression (was clean, now fails) from a repo that was ALREADY unhealthy
|
|
230
|
+
// (run-8 F8: five enforce passes were discarded on a lint that was already
|
|
231
|
+
// crashing — exit 2 — before enforce edited anything; the discard threw away
|
|
232
|
+
// good work for a fault it did not cause). Only meaningful in edit mode (flag
|
|
233
|
+
// makes no edits); the task's work is already committed so this reflects the
|
|
234
|
+
// committed state the pass is about to build on.
|
|
235
|
+
const healthBefore = mode === 'edit' && deps.repoHealth ? await deps.repoHealth(p.cwd) : undefined;
|
|
208
236
|
const verdict = await deps.enforce(active, p.cwd, p.title, mode);
|
|
237
|
+
// FROZEN-PATH WRITE-DENY (mechanical, not prompt — the "MUST NOT edit"
|
|
238
|
+
// instruction is A/B-proven ~0–1/5 reliable on the weak model): the enforce
|
|
239
|
+
// EDIT pass runs read,edit and has been seen mutating a path the spec froze.
|
|
240
|
+
// Before its edits are inspected/committed below, restore any frozen path it
|
|
241
|
+
// touched to the committed task state, so the violating write cannot land in
|
|
242
|
+
// the ENFORCE GUIDELINES commit regardless of what the model intended. The
|
|
243
|
+
// task's OWN frozen-path edits (if any) are already in HEAD and are the verify
|
|
244
|
+
// prohibition-probe's job — this only undoes the gate child's edits on top.
|
|
245
|
+
// No-op when the spec froze nothing or the deps are absent (bare /task, tests).
|
|
246
|
+
if (mode === 'edit' && deps.frozenPaths && deps.revertFrozenPaths) {
|
|
247
|
+
const frozen = await deps.frozenPaths(p.cwd, p.taskId);
|
|
248
|
+
if (frozen.length > 0) {
|
|
249
|
+
const reverted = await deps.revertFrozenPaths(p.cwd, frozen);
|
|
250
|
+
if (reverted.length > 0) {
|
|
251
|
+
await rec(`enforce: frozen-path write DENIED — reverted ${reverted.length} spec-frozen file(s) the edit pass modified: ${reverted.join(', ')}`);
|
|
252
|
+
active.ui.notify(`${p.tag}: guideline edits on "${p.title}" touched spec-frozen path(s) (${reverted.join(', ').slice(0, 120)}) — reverted before commit.`, 'warning');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
209
256
|
// The child's verdict and its edits are independent facts: the pass has been
|
|
210
257
|
// observed declaring "clean" while having edited files (which then get
|
|
211
258
|
// committed as fixes) — record both so the trail cannot contradict itself.
|
|
259
|
+
// `editsMade` is read AFTER the frozen-path revert so a pass whose only edit
|
|
260
|
+
// was to a frozen path correctly shows a clean tree (nothing left to commit).
|
|
212
261
|
const editsMade = mode === 'edit' && deps.dirty ? await deps.dirty(p.cwd) : undefined;
|
|
213
262
|
await rec(`enforce(${mode}): ${verdict.ok ? `clean${verdict.reason ? ` (${verdict.reason})` : ''}` : (verdict.reason ?? 'not clean')}${editsMade ? ' — edits in tree' : ''}`);
|
|
214
263
|
if (!verdict.ok) {
|
|
@@ -220,19 +269,40 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
220
269
|
// and discards the bad edits outright. Only runs when the tree is actually
|
|
221
270
|
// dirty (or dirtiness is unknowable); the differential guard below still
|
|
222
271
|
// catches behavioral regressions the static check cannot see.
|
|
272
|
+
//
|
|
273
|
+
// The gate is DIFFERENTIAL, not absolute (run-8 F8): discard the edits only
|
|
274
|
+
// when they REGRESSED the health signal — clean before, failing after. A repo
|
|
275
|
+
// that was already failing before enforce ran is not enforce's fault, so its
|
|
276
|
+
// edits are KEPT (and the pre-existing failure is recorded, to be caught by the
|
|
277
|
+
// final integration gate, not blamed on this pass). The failing command's
|
|
278
|
+
// output is captured into the trail so the discard is explainable — F8 was
|
|
279
|
+
// unreproducible precisely because only the exit code was recorded.
|
|
223
280
|
let enforceEditsBlocked = false;
|
|
224
281
|
if (mode === 'edit' && deps.repoHealth && editsMade !== false) {
|
|
225
|
-
const
|
|
226
|
-
|
|
282
|
+
const after = await deps.repoHealth(p.cwd);
|
|
283
|
+
// A regression needs a clean (or unknown) baseline turning to a fail. If
|
|
284
|
+
// healthBefore is undefined (repoHealth was absent at baseline time) treat
|
|
285
|
+
// the baseline as clean — the conservative absolute behavior.
|
|
286
|
+
const wasHealthyBefore = healthBefore?.ok ?? true;
|
|
287
|
+
const regressed = !after.ok && wasHealthyBefore;
|
|
288
|
+
if (regressed) {
|
|
227
289
|
enforceEditsBlocked = true;
|
|
290
|
+
const outputTail = after.output ? ` — output:\n${clampOutput(after.output)}` : '';
|
|
228
291
|
if (deps.discardEdits) {
|
|
229
292
|
await deps.discardEdits(p.cwd);
|
|
230
|
-
await rec(`enforce: edits discarded pre-commit
|
|
293
|
+
await rec(`enforce: edits discarded pre-commit — REGRESSED repo health (${after.reason})${outputTail}`);
|
|
231
294
|
}
|
|
232
295
|
else {
|
|
233
|
-
await rec(`enforce: edits
|
|
296
|
+
await rec(`enforce: edits REGRESSED repo health pre-commit (${after.reason}) — no discard available, left uncommitted${outputTail}`);
|
|
234
297
|
}
|
|
235
|
-
active.ui.notify(`${p.tag}: guideline edits on "${p.title}"
|
|
298
|
+
active.ui.notify(`${p.tag}: guideline edits on "${p.title}" regressed repo health (${after.reason.slice(0, 120)}) — discarded before commit.`, 'warning');
|
|
299
|
+
}
|
|
300
|
+
else if (!after.ok) {
|
|
301
|
+
// Failing both before and after → not enforce's fault. Keep the edits;
|
|
302
|
+
// record that the repo entered the gate already unhealthy so the trail
|
|
303
|
+
// explains why a still-failing repo did NOT trigger a discard here.
|
|
304
|
+
const outputTail = after.output ? ` — output:\n${clampOutput(after.output)}` : '';
|
|
305
|
+
await rec(`enforce: repo health still failing after edits but was ALREADY failing before the pass (${after.reason}) — pre-existing, edits kept${outputTail}`);
|
|
236
306
|
}
|
|
237
307
|
}
|
|
238
308
|
if (mode === 'edit' && !enforceEditsBlocked && editsMade === false) {
|