@optitransfer/proofline 0.2.0-next.0 → 0.2.0-next.1

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/src/contract.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // Contract + plan model: proofline.json at the repo root (committed).
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { readJson, writeJson, sha256, canon } from './util.mjs';
4
+ import { readJson, writeJson, sha256, canon, git, matchAny } from './util.mjs';
5
5
 
6
6
  export const SCHEMA = 'proofline/1';
7
7
  export const PHASES = ['intake', 'design', 'plan', 'implement', 'verify', 'integrate', 'release', 'monitor'];
@@ -12,6 +12,26 @@ export const STATUS = {
12
12
  // Task status vocabulary (derived, never self-declared)
13
13
  export const TASK_STATUS = ['not_started', 'in_progress', 'verified', 'failed', 'blocked'];
14
14
 
15
+ /** Stop rules a scaffolded task carries. brief and steer still run when a loaded task has none. */
16
+ export const DEFAULT_STOP_RULES = [
17
+ "If a criterion cannot pass without weakening a rule, widening a bypass list, or signing with a key that is not the owner's: STOP and report. Never mark a boundary criterion passed by hand.",
18
+ 'If the environment a criterion needs is missing (repository admin, the owner key, a container runtime, OIDC): report BLOCKED with the exact missing item.',
19
+ ];
20
+
21
+ const KINDS = ['feature', 'docs', 'release'];
22
+
23
+ /** A path the scaffold can place inside scope. Prefer `prefer` when it already matches. */
24
+ function slotted(allow, prefer) {
25
+ if (matchAny(prefer, allow)) return prefer;
26
+ for (const g of allow) {
27
+ if (g.endsWith('/**')) {
28
+ const p = `${g.slice(0, -3)}/change.test.mjs`;
29
+ if (matchAny(p, allow)) return p;
30
+ }
31
+ }
32
+ return prefer;
33
+ }
34
+
15
35
  export const DEFAULT_FORBIDDEN = {
16
36
  test_skip_only: true, // .skip( .only( {skip: test.todo it.skip xit(
17
37
  timeout_raise: true, // any numeric timeout increased in the diff
@@ -65,6 +85,39 @@ export function makeTask({ id, title, phase = 'implement', owner, depends_on = [
65
85
  };
66
86
  }
67
87
 
88
+ /**
89
+ * Print-ready task: standard criteria and the default stop rules, for an agent to fill in.
90
+ * kind feature: a red-then-green test and the suite. docs: a grep. release: one command.
91
+ * Scope and pushed are always present. Does not write the contract.
92
+ */
93
+ export function scaffoldTask({ title, scope, kind = 'feature', id = 'TASK' } = {}) {
94
+ if (!title || !String(title).trim()) throw new Error('scaffold needs a title');
95
+ if (!KINDS.includes(kind)) throw new Error('--kind must be feature, docs or release');
96
+ const allow = String(scope ?? '').split(',').map((s) => s.trim()).filter(Boolean);
97
+ if (!allow.length) throw new Error('scaffold needs a scope');
98
+ const criteria = [];
99
+ const add = (statement, verifier) => criteria.push({ statement, verifier });
100
+ if (kind === 'docs') {
101
+ const file = allow.find((g) => !/[*?]/.test(g)) || slotted(allow, 'README.md').replace(/change\.test\.mjs$/, 'change.md');
102
+ add('the docs name the change', { type: 'grep', file, pattern: 'CHANGE', must: 'present' });
103
+ } else if (kind === 'release') {
104
+ add('the release check exits 0', { type: 'command', cmd: 'npm test', expect_exit: 0 });
105
+ } else {
106
+ add('the new behavior fails on the base and passes now', { type: 'test_red_green', test_file: slotted(allow, 'test/change.test.mjs'), cmd: 'node --test {test}' });
107
+ add('suite green', { type: 'command', cmd: 'npm test', expect_exit: 0 });
108
+ }
109
+ add('Changes stay in scope', { type: 'scope' });
110
+ add('Committed and pushed to main', { type: 'pushed' });
111
+ return makeTask({ id, title, phase: kind === 'release' ? 'release' : 'implement', scope: allow, criteria, stop_rules: DEFAULT_STOP_RULES });
112
+ }
113
+
114
+ /** Double-quoted `sh -c` / `bash -c` whose quotes contain `$`. The verifier runs bash -c itself, so an outer shell would expand `$` first. */
115
+ function expandingShell(cmd) {
116
+ if (typeof cmd !== 'string') return false;
117
+ const m = cmd.match(/(?:^|[;&|]\s*)(?:sh|bash)\s+-c\s+"([^"]*)"/);
118
+ return !!(m && m[1].includes('$'));
119
+ }
120
+
68
121
  const isName = (x) => typeof x === 'string' && x.trim() !== '';
69
122
 
70
123
  /** A task belongs to an owner's lane when it is theirs or has no owner. No owner given: every task. */
@@ -117,6 +170,49 @@ export function validate(c) {
117
170
  return errs;
118
171
  }
119
172
 
173
+ /** The shapes contract validate refuses, on top of validate(). The hook keeps validate() so a fixture without these fields still gates. */
174
+ function lintErrors(c) {
175
+ const errors = [];
176
+ for (const t of c.tasks || []) {
177
+ if (!Array.isArray(t.stop_rules) || !t.stop_rules.length || t.stop_rules.some((s) => typeof s !== 'string' || !s.trim())) errors.push(`${t.id}: no stop_rules`);
178
+ if (!(t.criteria || []).some((a) => a?.verifier?.type === 'scope')) errors.push(`${t.id}: no scope criterion`);
179
+ const allow = Array.isArray(t.scope?.allow) ? t.scope.allow : [];
180
+ for (const a of t.criteria || []) {
181
+ const v = a.verifier;
182
+ if (!v) continue;
183
+ for (const key of ['file', 'path', 'test_file', 'entry']) {
184
+ const rel = v[key];
185
+ if (typeof rel !== 'string' || !rel || !allow.length) continue;
186
+ if (!matchAny(rel, allow)) errors.push(`${t.id}/${a.id}: ${key} ${rel} is outside scope`);
187
+ }
188
+ if (expandingShell(v.cmd)) errors.push(`${t.id}/${a.id}: command is a double-quoted sh -c or bash -c that contains $`);
189
+ }
190
+ }
191
+ return errors;
192
+ }
193
+
194
+ /** Structural errors from validate, the scaffold lint, and a warning when a grep already matches on the base revision. */
195
+ export function lint(c, { root, base } = {}) {
196
+ const errors = [...validate(c), ...lintErrors(c)];
197
+ const warnings = [];
198
+ if (!root || !base) return { errors, warnings };
199
+ const rev = git(['rev-parse', '--verify', `${base}^{commit}`], root);
200
+ if (!rev.ok) return { errors, warnings };
201
+ const sha = rev.out;
202
+ for (const t of c.tasks || []) {
203
+ for (const a of t.criteria || []) {
204
+ const v = a.verifier;
205
+ if (v?.type !== 'grep' || typeof v.file !== 'string' || typeof v.pattern !== 'string') continue;
206
+ const shown = git(['show', `${sha}:${v.file}`], root);
207
+ if (!shown.ok) continue;
208
+ let re;
209
+ try { re = new RegExp(v.pattern, v.flags || 'm'); } catch { continue; }
210
+ if (re.test(shown.out)) warnings.push(`${t.id}/${a.id}: grep /${v.pattern}/ already matches ${v.file} on ${sha}`);
211
+ }
212
+ }
213
+ return { errors, warnings };
214
+ }
215
+
120
216
  export function topo(tasks) {
121
217
  const by = new Map(tasks.map((t) => [t.id, t])); const out = []; const st = new Map();
122
218
  const visit = (id) => {
@@ -160,4 +256,149 @@ export function weakenings(base, cur) {
160
256
  return out;
161
257
  }
162
258
 
259
+ /** Fields whose canonical bytes differ. This is the refusal. weakenings() stays the explanation. */
260
+ export function contractFieldChanges(base, cur) {
261
+ if (!base || !cur) return [];
262
+ if (contractHash(base) === contractHash(cur)) return [];
263
+ const out = [];
264
+ const walk = (a, b, path) => {
265
+ if (Object.is(a, b)) return;
266
+ const aArr = Array.isArray(a);
267
+ const bArr = Array.isArray(b);
268
+ if (aArr || bArr) {
269
+ const left = aArr ? a : [];
270
+ const right = bArr ? b : [];
271
+ const n = Math.max(left.length, right.length);
272
+ for (let i = 0; i < n; i++) {
273
+ const id = (right[i] && right[i].id) || (left[i] && left[i].id) || String(i);
274
+ walk(left[i], right[i], path ? `${path}/${id}` : String(id));
275
+ }
276
+ return;
277
+ }
278
+ if (a && b && typeof a === "object" && typeof b === "object") {
279
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
280
+ for (const k of keys) walk(a[k], b[k], path ? `${path}/${k}` : k);
281
+ return;
282
+ }
283
+ const shown = (v) => (v === undefined ? "absent" : JSON.stringify(v));
284
+ out.push(`${path}: ${shown(a)} -> ${shown(b)}`);
285
+ };
286
+ walk(base, cur, "");
287
+ return out;
288
+ }
289
+
163
290
  export function loadFile(f) { return JSON.parse(fs.readFileSync(f, 'utf8')); }
291
+
292
+ /** End index of the JSON array named `key`, so an append can keep every byte before it. */
293
+ function arrayEnd(text, key) {
294
+ const marker = `"${key}"`;
295
+ const at = text.indexOf(marker);
296
+ if (at < 0) return null;
297
+ const bracket = text.indexOf('[', at);
298
+ if (bracket < 0) return null;
299
+ let depth = 0;
300
+ let inStr = false;
301
+ let esc = false;
302
+ for (let i = bracket; i < text.length; i++) {
303
+ const c = text[i];
304
+ if (inStr) {
305
+ if (esc) esc = false;
306
+ else if (c === '\\') esc = true;
307
+ else if (c === '"') inStr = false;
308
+ continue;
309
+ }
310
+ if (c === '"') { inStr = true; continue; }
311
+ if (c === '[') depth++;
312
+ else if (c === ']') {
313
+ depth--;
314
+ if (depth === 0) return i;
315
+ }
316
+ }
317
+ return null;
318
+ }
319
+
320
+ function insertValues(text, key, values) {
321
+ if (!values.length) return text;
322
+ const end = arrayEnd(text, key);
323
+ if (end == null) throw new Error(`no ${key} array`);
324
+ const body = text.slice(text.lastIndexOf('[', end) + 1, end);
325
+ const lines = values.map((v) => ' ' + JSON.stringify(v)).join(',\n');
326
+ const chunk = body.trim() ? `,\n${lines}` : `\n${lines}\n `;
327
+ return text.slice(0, end) + chunk + text.slice(end);
328
+ }
329
+
330
+ function ownerTable(spec) {
331
+ const map = new Map();
332
+ if (!spec) return map;
333
+ for (const pair of String(spec).split(',')) {
334
+ const [from, to] = pair.split('=').map((s) => s.trim());
335
+ if (from && to) map.set(from, to);
336
+ }
337
+ return map;
338
+ }
339
+
340
+ /** The contract text with tasks appended. A duplicate id throws and the caller writes nothing. Existing bytes stay. */
341
+ export function appendTaskText(text, tasks) {
342
+ const current = JSON.parse(text);
343
+ const have = new Set((current.tasks || []).map((t) => t.id));
344
+ for (const t of tasks) {
345
+ if (!t.id || have.has(t.id)) throw new Error(`duplicate task id ${t.id || '(missing)'}`);
346
+ have.add(t.id);
347
+ }
348
+ return insertValues(text, 'tasks', tasks);
349
+ }
350
+
351
+ /**
352
+ * Append a fragment's tasks and new owner rules. A duplicate id is refused and the file is left untouched.
353
+ * Existing bytes stay. The return is the sha256 of the file after the append.
354
+ */
355
+ export function mergeContract(root, fragmentPath, { owners } = {}) {
356
+ const fragment = loadFile(fragmentPath);
357
+ const tasks = (Array.isArray(fragment) ? fragment : fragment.tasks || []).map((t) => ({ ...t }));
358
+ const rules = Array.isArray(fragment) ? [] : (fragment.owner_rules || []).filter((r) => typeof r === 'string');
359
+ const map = ownerTable(owners);
360
+ for (const t of tasks) if (typeof t.owner === 'string' && map.has(t.owner)) t.owner = map.get(t.owner);
361
+ const file = contractPath(root);
362
+ const before = fs.readFileSync(file, 'utf8');
363
+ const current = JSON.parse(before);
364
+ const haveRules = new Set(current.owner_rules || []);
365
+ const freshRules = rules.filter((r) => !haveRules.has(r));
366
+ let next = appendTaskText(before, tasks);
367
+ if (freshRules.length) {
368
+ if (arrayEnd(next, 'owner_rules') == null) throw new Error('no owner_rules array');
369
+ next = insertValues(next, 'owner_rules', freshRules);
370
+ }
371
+ if (next !== before) fs.writeFileSync(file, next);
372
+ const bytes = fs.readFileSync(file);
373
+ return { sha256: sha256(bytes), added: tasks.map((t) => t.id), owner_rules: freshRules };
374
+ }
375
+
376
+ /** One line per requirement id: the tasks that name it, or MISSING. An id a task names that the file does not list is unknown. */
377
+ export function coverageReport(root, requirementsPath, { contracts } = {}) {
378
+ const text = fs.readFileSync(requirementsPath, 'utf8');
379
+ const ids = [];
380
+ for (const line of text.split(/\r?\n/)) {
381
+ const m = line.match(/^- ([^:]+):/);
382
+ if (m) ids.push(m[1].trim());
383
+ }
384
+ const known = new Set(ids);
385
+ const covered = new Map(ids.map((id) => [id, []]));
386
+ const unknown = [];
387
+ const files = contracts && contracts.length ? contracts : [contractPath(root)];
388
+ for (const f of files) {
389
+ const c = loadFile(f);
390
+ for (const t of c.tasks || []) {
391
+ for (const req of t.requirements || []) {
392
+ if (known.has(req)) covered.get(req).push(t.id);
393
+ else unknown.push(`${req} unknown ${t.id}`);
394
+ }
395
+ }
396
+ }
397
+ const lines = ids.map((id) => {
398
+ const tasks = covered.get(id);
399
+ return tasks.length ? `${id} ${tasks.join(',')}` : `${id} MISSING`;
400
+ });
401
+ lines.push(...unknown);
402
+ const ok = !lines.some((l) => l.endsWith(' MISSING') || l.includes(' unknown '));
403
+ return { ok, lines };
404
+ }
package/src/engine.mjs CHANGED
@@ -8,6 +8,7 @@ import { scanForbidden } from './forbidden.mjs';
8
8
  import { parseClaims, checkClaim } from './claims.mjs';
9
9
  import { record, readLog, verifyChain } from './evidence.mjs';
10
10
  import * as RC from './cache.mjs';
11
+ import { resolveIsolation, COVERAGE_LIMITS } from './isolate.mjs';
11
12
 
12
13
  export const KNOWN_LIMITS = [
13
14
  'Proofline runs on the same machine as the agent: an agent with shell access could edit .proofline/ or proofline.json. The hash chain makes edits detectable, contract weakening vs the base revision is flagged, and CI re-verifies from a clean checkout; protect proofline.json with CODEOWNERS/branch protection for a hard boundary.',
@@ -43,14 +44,23 @@ export function baseTrailer(root) {
43
44
 
44
45
  /**
45
46
  * Where the base revision comes from, first match wins: --base, the task's own `base` in the contract,
46
- * the base pinned when the task started (`proofline next`), a Proofline-Base trailer on HEAD, the default
47
- * (defaults.base, else <defaults.remote>/<defaults.branch>).
47
+ * the most recent base pinned when the task started (`proofline next`, and a later `next --base`),
48
+ * a Proofline-Base trailer on HEAD, the default (defaults.base, else <defaults.remote>/<defaults.branch>).
48
49
  */
50
+ export function latestStart(root, taskId) {
51
+ const log = readLog(root);
52
+ for (let i = log.length - 1; i >= 0; i--) {
53
+ const e = log[i];
54
+ if (e.type === 'task_started' && e.data?.task === taskId && e.data.base) return e.data.base;
55
+ }
56
+ return null;
57
+ }
58
+
49
59
  export function baseFor(root, c, t, explicit) {
50
60
  if (explicit) return { ref: explicit, source: '--base' };
51
61
  if (t.base) return { ref: t.base, source: 'contract' };
52
- const started = readLog(root).find((e) => e.type === 'task_started' && e.data.task === t.id);
53
- if (started?.data.base) return { ref: started.data.base, source: 'pinned at start' };
62
+ const started = latestStart(root, t.id);
63
+ if (started) return { ref: started, source: 'pinned at start' };
54
64
  const tr = baseTrailer(root);
55
65
  if (tr) return { ref: tr, source: 'commit trailer' };
56
66
  return { ref: C.defaultBase(c), source: 'default' };
@@ -74,6 +84,26 @@ export function taskBase(root, c, t, explicit) {
74
84
  return { ref, source, base, problems };
75
85
  }
76
86
 
87
+ /** One line when the working contract differs from `explicit` (or the default base) and no marker approves it. */
88
+ export function unapprovedContractChange(root, explicit) {
89
+ const c = C.load(root);
90
+ const ref = explicit || C.defaultBase(c);
91
+ const base = resolveBase(root, ref);
92
+ if (!base) return null;
93
+ const s = git(['show', `${base}:proofline.json`], root);
94
+ if (!s.ok) return null;
95
+ let baseContract;
96
+ try { baseContract = JSON.parse(s.out); } catch { return null; }
97
+ if (C.contractHash(baseContract) === C.contractHash(c)) return null;
98
+ let raw;
99
+ try { raw = fs.readFileSync(path.join(root, 'proofline.json')); } catch { return null; }
100
+ if (approvalMarker(root, raw).approved) return null;
101
+ const weak = C.weakenings(baseContract, c);
102
+ return weak.length
103
+ ? `contract weakened vs base (needs owner approval): ${weak.join('; ')}`
104
+ : 'contract bytes changed vs base (needs owner approval)';
105
+ }
106
+
77
107
  const BASE_NEXT = 'gate against the revision the task started from: pass --base <rev> (for one commit, its parent), or start the task with `proofline next`, which pins the base. Do not pick a base that hides part of the work.';
78
108
 
79
109
  /** The "## Base" section of a brief: which base verify and gate will use, and whether they can. */
@@ -84,31 +114,39 @@ export function baseBrief(root, c, t, explicit) {
84
114
  return L.join('\n');
85
115
  }
86
116
 
87
- export function verifyTask(root, taskId, { claims, only, skipTypes = [], base: explicitBase, machine, fresh = false } = {}) {
117
+ export function verifyTask(root, taskId, opts = {}) {
118
+ const { claims, only, skipTypes = [], base: explicitBase, machine, fresh = false } = opts;
88
119
  const c = C.load(root);
89
120
  const t = taskById(c, taskId);
121
+ const iso = resolveIsolation(c, opts);
90
122
  const errs = C.validate(c);
91
123
  // Base is pinned when the task starts, so pushing the work does not move the goalposts.
92
124
  const { ref: baseRef, source: baseSource, base, problems: baseProblems } = taskBase(root, c, t, explicitBase);
93
- const ctx = { root, contract: c, task: t, base, changed: base ? changedFiles(root, base) : new Map(), measured: {}, machine, baseline: c.baseline?.metrics, tolerancePct: c.defaults?.regression_tolerance_pct, flakyReruns: c.defaults?.flaky_reruns ?? 1 };
125
+ const ctx = { root, contract: c, task: t, base, changed: base ? changedFiles(root, base) : new Map(), measured: {}, machine, baseline: c.baseline?.metrics, tolerancePct: c.defaults?.regression_tolerance_pct, flakyReruns: c.defaults?.flaky_reruns ?? 1, sandbox: iso.sandbox };
94
126
  const results = [];
95
127
  const reuse = RC.settings(c);
96
128
  for (const a of t.criteria) {
97
129
  if (only && !only.includes(a.id)) continue;
98
130
  if (skipTypes.includes(a.verifier.type)) continue;
99
131
  let r;
100
- // Reuse (opt-in): a command or metric PASS recorded on this HEAD with a clean tree is not run again; --fresh runs it.
101
- const k = reuse.on && RC.REUSABLE.includes(a.verifier.type) ? RC.keyFor(root, a.verifier, ctx) : null;
102
- const hit = k && !fresh ? RC.lookup(root, k.key, reuse) : null;
103
- if (hit) {
104
- r = RC.reusedResult(hit);
105
- if (a.verifier.type === 'metric') ctx.measured[a.verifier.name || a.verifier.key] = hit.data.evidence.measured;
132
+ const sandboxed = ['command', 'test_red_green', 'metric'].includes(a.verifier.type);
133
+ if (iso.block && sandboxed) {
134
+ r = { status: 'error', detail: iso.block };
106
135
  } else {
107
- try { r = verifiers[a.verifier.type](a.verifier, ctx); } catch (e) { r = { status: 'error', detail: `verifier crashed: ${e.message}` }; }
108
- // Only a PASS is stored, and only if the tree is still the one the check started on and the log is intact.
109
- if (k && r.status === 'pass' && RC.keyFor(root, a.verifier, ctx)?.key === k.key && verifyChain(root).ok) RC.store(root, k, t.id, a, r);
136
+ // Reuse (opt-in): a command or metric PASS recorded on this HEAD with a clean tree is not run again; --fresh runs it.
137
+ const k = reuse.on && RC.REUSABLE.includes(a.verifier.type) ? RC.keyFor(root, a.verifier, ctx) : null;
138
+ const hit = k && !fresh ? RC.lookup(root, k.key, reuse) : null;
139
+ if (hit) {
140
+ r = RC.reusedResult(hit);
141
+ if (a.verifier.type === 'metric') ctx.measured[a.verifier.name || a.verifier.key] = hit.data.evidence.measured;
142
+ } else {
143
+ try { r = verifiers[a.verifier.type](a.verifier, ctx); } catch (e) { r = { status: 'error', detail: `verifier crashed: ${e.message}` }; }
144
+ // Only a PASS is stored, and only if the tree is still the one the check started on and the log is intact.
145
+ if (k && r.status === 'pass' && RC.keyFor(root, a.verifier, ctx)?.key === k.key && verifyChain(root).ok) RC.store(root, k, t.id, a, r);
146
+ }
110
147
  }
111
- results.push({ id: a.id, statement: a.statement, mandatory: a.mandatory !== false, type: a.verifier.type, ...r });
148
+ const heuristic = a.verifier.type === 'grep' || a.verifier.type === 'wiring';
149
+ results.push({ id: a.id, statement: a.statement, mandatory: a.mandatory !== false, type: a.verifier.type, ...(heuristic ? { heuristic: true } : {}), ...r });
112
150
  }
113
151
  const rules = { ...C.DEFAULT_FORBIDDEN, ...(c.forbidden || {}) };
114
152
  const forbidden = scanForbidden(root, base, rules);
@@ -117,9 +155,11 @@ export function verifyTask(root, taskId, { claims, only, skipTypes = [], base: e
117
155
  const weak = C.weakenings(baseContract, c);
118
156
  // The forbidden scan skips proofline.json (it declares the deny-list); a contract change is reported here instead.
119
157
  let contractChange = !base ? 'unknown' : !baseContract ? 'added' : C.contractHash(baseContract) === C.contractHash(c) ? 'unchanged' : 'changed';
158
+ const fields = baseContract ? C.contractFieldChanges(baseContract, c) : [];
120
159
  let marker = null;
121
- const approveWeakening = () => {
122
- if (!weak.length) return false;
160
+ const hashChanged = contractChange === 'changed';
161
+ const approveChange = () => {
162
+ if (!hashChanged) return false;
123
163
  let raw = null;
124
164
  try { raw = fs.readFileSync(path.join(root, 'proofline.json')); } catch { raw = null; }
125
165
  const mark = approvalMarker(root, raw);
@@ -134,7 +174,13 @@ export function verifyTask(root, taskId, { claims, only, skipTypes = [], base: e
134
174
  if (errs.length) blocked.push(...errs.map((e) => `contract invalid: ${e}`));
135
175
  blocked.push(...baseProblems);
136
176
  if (!chain.ok) blocked.push(`evidence log tampered at entry ${chain.at}: ${chain.reason}`);
137
- if (!approveWeakening()) for (const w of weak) blocked.push(`contract weakened vs base (needs owner approval): ${w}`);
177
+ if (hashChanged && !approveChange()) {
178
+ // One blocked line. weakenings() is the explanation, so a real weakening stays length 1.
179
+ if (weak.length) blocked.push(`contract weakened vs base (needs owner approval): ${weak.join('; ')}`);
180
+ else blocked.push('contract bytes changed vs base (needs owner approval)');
181
+ } else if (!hashChanged) {
182
+ for (const w of weak) blocked.push(`contract weakened vs base (needs owner approval): ${w}`);
183
+ }
138
184
  for (const r of results) {
139
185
  if (r.status === 'error') blocked.push(`${r.id}: could not run: ${r.detail}`);
140
186
  else if (r.status === 'blocked') blocked.push(`${r.id}: ${r.detail}`);
@@ -161,7 +207,7 @@ export function verifyTask(root, taskId, { claims, only, skipTypes = [], base: e
161
207
  const pushed = pr ? pr.status === 'pass'
162
208
  : !!hi.head && !git(['status', '--porcelain'], root).out.split('\n').some((l) => l && !l.includes('.proofline/')) && git(['rev-parse', '--verify', '--quiet', `refs/remotes/${tgt.remote}/${tgt.branch}^{commit}`], root).out === hi.head;
163
209
  const reused = results.filter((r) => r.reused).map((r) => ({ id: r.id, type: r.type, ts: r.reused.ts, entry: r.reused.entry }));
164
- return { pushed, task: t.id, title: t.title, phase: t.phase, decision, base, base_ref: baseRef, base_source: baseSource, ...hi, contract_hash: C.contractHash(c), results, reuse: !reuse.on ? 'off' : fresh ? 'fresh' : 'on', reused, forbidden, weakenings: weak, contract_change: contractChange, marker, blocked, failing, measured: ctx.measured, claims: claimResults, changed: [...ctx.changed.keys()], ts: new Date().toISOString() };
210
+ return { isolated: iso.isolated, env_digest: iso.env_digest, heuristic: true, coverage_limits: COVERAGE_LIMITS, pushed, task: t.id, title: t.title, phase: t.phase, decision, base, base_ref: baseRef, base_source: baseSource, ...hi, contract_hash: C.contractHash(c), results, reuse: !reuse.on ? 'off' : fresh ? 'fresh' : 'on', reused, forbidden, weakenings: weak, contract_fields: fields, contract_change: contractChange, marker, blocked, failing, measured: ctx.measured, claims: claimResults, changed: [...ctx.changed.keys()], ts: new Date().toISOString() };
165
211
  }
166
212
 
167
213
  /** Corrective prompt tied to each failed criterion. */
@@ -199,7 +245,7 @@ export function steer(v, c) {
199
245
  }
200
246
  for (const f of v.forbidden) L.push(`${n++}. FORBIDDEN ${f.rule} in ${f.file}: ${f.line}\n Why: ${f.why}. Next step: revert it and fix the underlying cause.`);
201
247
  for (const cr of v.claims.filter((x) => !x.ok)) L.push(`${n++}. CLAIM REJECTED: "${cr.claim}"\n ${cr.issues.join('\n ')}\n Next step: report only what the evidence shows.`);
202
- L.push('', 'STOP RULES:', ...t.stop_rules.map((s) => `- ${s}`), '- After fixing, call verify again. Max corrective cycles: ' + (c.defaults?.max_corrective_cycles ?? 3) + '; after that report BLOCKED with root cause, what you tried, what you need.');
248
+ L.push('', 'STOP RULES:', ...(Array.isArray(t.stop_rules) ? t.stop_rules : []).map((s) => `- ${s}`), '- After fixing, call verify again. Max corrective cycles: ' + (c.defaults?.max_corrective_cycles ?? 3) + '; after that report BLOCKED with root cause, what you tried, what you need.');
203
249
  return L.join('\n');
204
250
  }
205
251
 
@@ -277,26 +323,29 @@ export function phaseStatus(root, c = C.load(root)) {
277
323
  * The next task. With `owner`, only that owner's tasks and unowned ones are handed out, and the phase gate
278
324
  * applies to that lane; depends_on still waits on any owner's task.
279
325
  */
280
- export function next(root, { owner } = {}) {
326
+ export function next(root, { owner, skip = [] } = {}) {
281
327
  const c = C.load(root);
282
328
  C.checkOwner(c, owner);
283
329
  const st = taskStatus(root, c);
330
+ const skipSet = new Set(skip);
331
+ // A skipped task has landed. It does not stay active, and it does not keep its phase or its dependents waiting.
332
+ const open = (t) => st[t.id].status !== 'verified' && !skipSet.has(t.id);
284
333
  const lane = c.tasks.filter((t) => C.inLane(t, owner));
285
334
  const order = (C.topo(c.tasks) || c.tasks).filter((t) => C.inLane(t, owner));
286
- const firstOpenPhase = C.PHASES.find((p) => lane.some((t) => t.phase === p && st[t.id].status !== 'verified'));
335
+ const firstOpenPhase = C.PHASES.find((p) => lane.some((t) => t.phase === p && open(t)));
287
336
  if (!firstOpenPhase && owner) {
288
- const others = c.tasks.filter((t) => !C.inLane(t, owner) && st[t.id].status !== 'verified').map((t) => `${t.id} (${t.owner})`);
337
+ const others = c.tasks.filter((t) => !C.inLane(t, owner) && st[t.id].status !== 'verified' && !skipSet.has(t.id)).map((t) => `${t.id} (${t.owner})`);
289
338
  return { done: true, owner, message: `Every task for ${owner} and every unowned task is verified.${others.length ? ` Open for other owners: ${others.join(', ')}.` : ' Run `proofline gate --all` before release.'}` };
290
339
  }
291
340
  if (!firstOpenPhase) return { done: true, message: 'All tasks verified. Run `proofline gate --all` before release.' };
292
341
  for (const t of order) {
293
- if (st[t.id].status === 'verified') continue;
342
+ if (!open(t)) continue;
294
343
  if (t.phase !== firstOpenPhase) continue; // phase gate: earlier phases must be fully verified
295
- const deps = (t.depends_on || []).filter((d) => st[d]?.status !== 'verified');
344
+ const deps = (t.depends_on || []).filter((d) => st[d]?.status !== 'verified' && !skipSet.has(d));
296
345
  if (deps.length) continue;
297
346
  return { done: false, task: t.id, phase: t.phase, status: st[t.id].status, brief: brief(c, t, st) };
298
347
  }
299
- const waiting = order.filter((t) => st[t.id].status !== 'verified').map((t) => `${t.id} (waits on ${(t.depends_on || []).filter((d) => st[d]?.status !== 'verified').join(', ') || 'phase gate'})`);
348
+ const waiting = order.filter((t) => open(t)).map((t) => `${t.id} (waits on ${(t.depends_on || []).filter((d) => st[d]?.status !== 'verified' && !skipSet.has(d)).join(', ') || 'phase gate'})`);
300
349
  return { done: false, task: null, message: `No task${owner ? ` for ${owner}` : ''} is unblocked in phase ${firstOpenPhase}: ${waiting.join('; ')}` };
301
350
  }
302
351
 
@@ -324,6 +373,6 @@ export function brief(c, t, st = {}) {
324
373
  '## Mandatory loop', '1. Write the failing test(s) first and run them: paste the failing output.', '2. Implement the cause-level fix on the default path (wired in, default config).', `3. \`proofline verify ${t.id}\` → fix until PASS. Read the corrective instruction it prints.`, `4. Commit (one task per commit), push to ${pushTarget(c, t).remote} ${pushTarget(c, t).branch} with hooks on.`, `5. \`proofline gate ${t.id} --claims "<your report line>"\` and paste the report.`, '',
325
374
  '## Forbidden (detected automatically)', `- skip/only/todo in tests; raising timeouts; net removal of assertions; deleting tests; --no-verify; env bypasses (${(f.bypass_env || []).join(', ')}); lowering protected values (${(f.protected_values || []).map((p) => p.file).join(', ') || 'none configured'}); editing thresholds/commands/criteria in proofline.json.`, '',
326
375
  '## Owner rules', ...(c.owner_rules || []).map((r) => `- ${r}`), '',
327
- '## Stop rules', ...t.stop_rules.map((s) => `- ${s}`), '',
376
+ '## Stop rules', ...(Array.isArray(t.stop_rules) ? t.stop_rules : []).map((s) => `- ${s}`), '',
328
377
  '## Report line (exact format)', `${t.id} | done/partial/not done/blocked | <commit> | <test names> | <metric=value vs target> | <what is missing>`].join('\n');
329
378
  }