feature-factory 0.10.2 → 0.10.4

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/README.md CHANGED
@@ -258,8 +258,9 @@ own the same path. Duplicate, target-already-owned, malformed, privileged, repla
258
258
  requests refuse atomically. Resume never amends or reseeds. A merge continues to refuse every unamended
259
259
  or privileged changed path.
260
260
 
261
- A parked slice that exhausted its effective limit can receive exactly one audited extension through
262
- `factory grant-retry <run-id> <slice-id> --scope slice|all --reason <text> --session <id> --repo <sandbox>`.
261
+ A parked slice that exhausted its effective limit can be reopened for one more attempt through
262
+ `factory grant-retry <run-id> <slice-id> --scope slice|all [--max-retries N] --reason <text> --session <id> --repo <sandbox>`;
263
+ `--max-retries` (with `all` only) sets the new run-wide limit in one call instead of raising it by one.
263
264
  Slice scope raises only the target's additive allowance. All scope raises `max_retries` for pending later
264
265
  waves too, but refuses when another slice is blocked or an exhausted post-merge repair record exists. Both
265
266
  scopes reopen only the named slice after exact owner, snapshot, REJECT, evidence, base, current clean head,
package/WORKFLOW.md CHANGED
@@ -224,12 +224,14 @@ scope deliberately:
224
224
 
225
225
  ```sh
226
226
  factory grant-retry "$R" "$SLICE_ID" --scope slice --reason "$EXTENSION_REASON" --session "$SESSION_ID" --repo "$RUN_REPO"
227
- factory grant-retry "$R" "$SLICE_ID" --scope all --reason "$EXTENSION_REASON" --session "$SESSION_ID" --repo "$RUN_REPO"
227
+ factory grant-retry "$R" "$SLICE_ID" --scope all [--max-retries "$N"] --reason "$EXTENSION_REASON" --session "$SESSION_ID" --repo "$RUN_REPO"
228
228
  ```
229
229
 
230
- `slice` raises only that slice's additive allowance. `all` raises the run-wide default, including every
231
- pending later wave, but still reopens only `SLICE_ID`; it refuses while another slice is blocked or an
232
- exhausted post-merge repair exists. Both scopes require `blocked@N` exactly at the current effective limit;
230
+ `slice` raises only that slice's additive allowance by one. `all` raises the run-wide default, including
231
+ every pending later wave, but still reopens only `SLICE_ID`; it refuses while another slice is blocked or an
232
+ exhausted post-merge repair exists. `all` raises by one unless `--max-retries N` sets the new run-wide limit
233
+ directly; `N` must exceed the current limit. Choose `N` for the whole remaining plan: once granted, later
234
+ rejections below it retry without another operator grant. Both scopes require `blocked@N` exactly at the current effective limit;
233
235
  a matching REJECT, evidence, immutable base and live clean branch head; the exact fresh lock owner; a
234
236
  complete current park snapshot; and immutable attempt-N review and evidence archives. A legacy run missing
235
237
  an archive gets a preparation-only refusal: publish the changed plane and invoke the grant again. They
@@ -106,7 +106,9 @@ Use exactly these field names and exactly this `status` vocabulary. The orchestr
106
106
  block to `factory observe --claim`, which compares each field against what it observes itself and
107
107
  records every disagreement as a review finding. `completed` is the word the evidence uses; any
108
108
  other spelling reads as a disagreement about status and blocks your own slice. `files_changed`
109
- must list every path, and `tests.exit` must be the real exit code — a claimed zero against an
109
+ must list every path changed since the slice's `base_ref`, not since your last attempt — on a retry
110
+ the observer diffs the whole slice, because that is what merges, and a list of only this attempt's
111
+ edits is a disagreement. `tests.exit` must be the real exit code — a claimed zero against an
110
112
  observed failure is the single most important disagreement this mechanism catches.
111
113
 
112
114
  If the brief is wrong or impossible as written (e.g. the entity doesn't support it), stop, set `status: blocked` with the reason in `blockers`, and report the conflict — do not silently improvise a different design.
@@ -114,7 +114,9 @@ Use exactly these field names and exactly this `status` vocabulary. The orchestr
114
114
  block to `factory observe --claim`, which compares each field against what it observes itself and
115
115
  records every disagreement as a review finding. `completed` is the word the evidence uses; any
116
116
  other spelling reads as a disagreement about status and blocks your own slice. `files_changed`
117
- must list every path, and `tests.exit` must be the real exit code — a claimed zero against an
117
+ must list every path changed since the slice's `base_ref`, not since your last attempt — on a retry
118
+ the observer diffs the whole slice, because that is what merges, and a list of only this attempt's
119
+ edits is a disagreement. `tests.exit` must be the real exit code — a claimed zero against an
118
120
  observed failure is the single most important disagreement this mechanism catches.
119
121
 
120
122
  If the design brief and the brief conflict, or a token/component the design needs doesn't exist, stop, set `status: blocked` with the reason in `blockers`, and report — don't hardcode around it.
package/bin/factory.js CHANGED
@@ -40,7 +40,7 @@ export const COMMANDS = Object.freeze({
40
40
  status: Object.freeze(["--repo", "--json"]),
41
41
  "amend-paths": Object.freeze(["--repo", "--add", "--reason", "--session", "--now", "--json"]),
42
42
  resume: Object.freeze(["--repo", "--session", "--now", "--json"]),
43
- "grant-retry": Object.freeze(["--repo", "--scope", "--reason", "--session", "--now", "--json"]),
43
+ "grant-retry": Object.freeze(["--repo", "--scope", "--max-retries", "--reason", "--session", "--now", "--json"]),
44
44
  restore: Object.freeze(["--repo", "--from", "--now", "--json"]),
45
45
  snapshot: Object.freeze(["--repo", "--json"]),
46
46
  decide: Object.freeze(["--repo", "--text", "--session", "--now", "--json"]),
@@ -799,11 +799,16 @@ const HANDLERS = {
799
799
  if (positional.length !== 2) throw new CliError("factory grant-retry requires exactly <run-id> <slice-id>");
800
800
  const [runId, sliceId] = positional;
801
801
  if (!RETRY_EXTENSION_SCOPES.includes(flags.scope)) throw new CliError(`factory grant-retry requires --scope ${RETRY_EXTENSION_SCOPES.join("|")}`);
802
+ if (flags.maxRetries !== undefined && flags.scope !== "all") throw new CliError("factory grant-retry --max-retries requires --scope all");
802
803
  if (typeof flags.reason !== "string" || !flags.reason.trim()) throw new CliError("factory grant-retry requires nonblank --reason <text>");
803
804
  if (typeof flags.session !== "string" || !flags.session.trim()) throw new CliError("factory grant-retry requires nonblank --session <id>");
804
805
  const runDir = runDirFor(flags, runId), repo = resolve(flags.repo ?? process.cwd());
805
806
  const boundBytes = readFileSync(join(runDir, "run.json")), current = validateRun(JSON.parse(boundBytes.toString("utf8")));
806
807
  if (current.status !== "needs-human") throw new CliError(`factory grant-retry requires current status needs-human; found '${current.status}'`);
808
+ // Instruction, not enforcement, on the size: a higher run-wide limit buys more reviewed attempts, never an
809
+ // unearned merge. Enforced only that it rises, since the grant must reopen the exhausted slice at N+1.
810
+ const grantedMax = flags.scope === "all" ? integer(flags.maxRetries, current.max_retries + 1, "--max-retries") : current.max_retries;
811
+ if (flags.scope === "all" && grantedMax <= current.max_retries) throw new CliError(`grant-retry --max-retries must exceed the current run-wide limit ${current.max_retries}`);
807
812
  const owner = assertFreshSessionOwner(runDir, runId, flags.session, "grant-retry"), at = stamp(flags);
808
813
  if (Date.parse(at) <= Date.parse(current.updated_at)) throw new CliError("grant-retry must move updated_at forwards");
809
814
  const qualified = qualifyRetryGrant(repo, runDir, runId, current, sliceId);
@@ -864,7 +869,7 @@ const HANDLERS = {
864
869
  const row = { ...existing, status: "running", attempts: existing.attempts + 1,
865
870
  ...(flags.scope === "slice" ? { extra_attempts: (existing.extra_attempts ?? 0) + 1 } : {}),
866
871
  evidence_ref: null, review_ref: null };
867
- const maxRetries = state.max_retries + (flags.scope === "all" ? 1 : 0), newLimit = maxRetries + (row.extra_attempts ?? 0);
872
+ const maxRetries = grantedMax, newLimit = maxRetries + (row.extra_attempts ?? 0);
868
873
  const audit = { scope: flags.scope, slice_id: sliceId, base_ref: existing.base_ref, attempt: row.attempts, previous_limit: previousLimit,
869
874
  new_limit: newLimit, previous_max_retries: state.max_retries, max_retries: maxRetries,
870
875
  session: flags.session, reason: flags.reason.trim(), at, snapshot_digest: qualified.snapshotDigest,
@@ -1863,7 +1868,7 @@ function usage() {
1863
1868
  factory init <run-id> [--branch B=feature/<run-id>] [--worktree W=.] [--pr-base TARGET] [--issue KEY] [--mode interactive|headless|autonomous]
1864
1869
  factory status <run-id> [--json]
1865
1870
  factory amend-paths <run-id> <slice-id> --add PATH [--add PATH ...] --reason TEXT --session ID [--now ISO]
1866
- factory grant-retry <run-id> <slice-id> --scope slice|all --reason TEXT --session ID [--now ISO]
1871
+ factory grant-retry <run-id> <slice-id> --scope slice|all [--max-retries N] --reason TEXT --session ID [--now ISO]
1867
1872
  factory decide <run-id> --text TEXT --session ID [--now ISO]
1868
1873
  factory resume <run-id> --session ID [--now ISO]
1869
1874
  factory restore <run-id> --repo OPERATOR --from refs/remotes/REMOTE/BRANCH [--now ISO]
package/core/contracts.js CHANGED
@@ -66,7 +66,7 @@ const envelope = contract({
66
66
  const scope = mode === "grant-retry-all" ? "all" : "slice";
67
67
  if (after.status !== "needs-human" || !isDeepStrictEqual(after.terminal_result, before.terminal_result)) throw new Error("grant-retry must preserve the parked envelope and terminal_result");
68
68
  if (Date.parse(after.updated_at) <= Date.parse(before.updated_at)) throw new Error("grant-retry must move updated_at forwards");
69
- if (scope === "all" ? after.max_retries !== before.max_retries + 1 : after.max_retries !== before.max_retries) throw new Error(`grant-retry ${scope} has an invalid run-wide retry limit`);
69
+ if (scope === "all" ? !(after.max_retries > before.max_retries) : after.max_retries !== before.max_retries) throw new Error(`grant-retry ${scope} has an invalid run-wide retry limit`);
70
70
  if (after.retry_extensions.length !== before.retry_extensions.length + 1 || !isDeepStrictEqual(after.retry_extensions.slice(0, -1), before.retry_extensions) || after.retry_extensions.at(-1)?.scope !== scope) throw new Error("grant-retry must append one matching audit record");
71
71
  for (const key of Object.keys(before).filter((key) => !["updated_at", "max_retries", "retry_extensions"].includes(key))) if (!isDeepStrictEqual(before[key], after[key])) throw new Error(`grant-retry cannot change envelope.${key}`);
72
72
  for (const key of Object.keys(current).filter((key) => !Object.hasOwn(before, key) && key !== "slices")) if (!isDeepStrictEqual(current[key], candidate[key])) throw new Error(`grant-retry cannot change run.${key}`);
@@ -340,7 +340,8 @@ const slices = contract({
340
340
  if (scope === "all" && before.some((entry, entryIndex) => entryIndex !== index && entry.status === "blocked")) throw new Error("grant-retry all cannot strand another blocked slice below the raised limit");
341
341
  if (prior.id !== slice.id || prior.status !== "blocked" || slice.status !== "running") throw new Error("grant-retry requires one blocked slice to become running");
342
342
  const previousLimit = effectiveRetryLimit(current, prior), nextLimit = effectiveRetryLimit(candidate, slice);
343
- if (prior.attempts !== previousLimit || slice.attempts !== prior.attempts + 1 || nextLimit !== previousLimit + 1) throw new Error("grant-retry must open exactly N+1 from the exhausted effective limit");
343
+ const raise = scope === "all" ? candidate.max_retries - current.max_retries : 1;
344
+ if (prior.attempts !== previousLimit || slice.attempts !== prior.attempts + 1 || nextLimit !== previousLimit + raise) throw new Error("grant-retry must open exactly N+1 from the exhausted effective limit");
344
345
  const previousExtra = prior.extra_attempts ?? 0, nextExtra = slice.extra_attempts ?? 0;
345
346
  if (scope === "slice" ? nextExtra !== previousExtra + 1 : nextExtra !== previousExtra) throw new Error(`grant-retry ${scope} has an invalid slice-specific extension`);
346
347
  for (const key of new Set([...Object.keys(prior), ...Object.keys(slice)])) if (!["status", "attempts", "extra_attempts", "evidence_ref", "review_ref"].includes(key) && !isDeepStrictEqual(prior[key], slice[key])) throw new Error(`grant-retry cannot change slice '${slice.id}' ${key}`);
package/observe/index.js CHANGED
@@ -123,10 +123,16 @@ export function runTests(worktree, command, { runner = spawnSync, skipReason = n
123
123
  return { cmd: shellCommand ? command : command.join(" "), exit, observed: exit !== null, skipped_reason: null };
124
124
  }
125
125
 
126
- // Readiness requires completed, clean, changed, observed-diff evidence and tests
127
- // observed passing or ratified as explicitly skipped with a reason.
126
+ // Readiness requires completed, clean, changed, observed-diff evidence, no disagreement between
127
+ // the builder's claim and the observation, and tests observed passing or ratified as explicitly
128
+ // skipped with a reason.
128
129
  export function deriveReviewReady(evidence) {
129
130
  if (evidence.status !== "completed") return false;
131
+ // Enforcement (false green): a claim that disagrees with observation is itself the finding. It
132
+ // lives here, not beside the writer, because `readEvidence` recomputes readiness from this
133
+ // function alone; with the term only at write time, every mismatched record read back as
134
+ // tampered and wedged its slice where `slice blocked` could never record it.
135
+ if (evidence.claim_reconciliation?.mismatches?.length > 0) return false;
130
136
  // A tree with uncommitted changes cannot produce evidence about the commit it
131
137
  // claims, whatever the tests said.
132
138
  if (evidence.worktree_clean !== true) return false;
@@ -250,11 +256,8 @@ export function buildEvidence({ subject, runId, attempt, branch, baseRef, worktr
250
256
  review_ready: false,
251
257
  claim_reconciliation: { claimed: false, mismatches: [] },
252
258
  };
253
- evidence.review_ready = deriveReviewReady(evidence);
254
259
  evidence.claim_reconciliation = reconcileClaim(claim, evidence);
255
- // A claim that disagrees with what we observed cannot be review-ready: the
256
- // disagreement is itself the finding.
257
- if (evidence.claim_reconciliation.mismatches.length > 0) evidence.review_ready = false;
260
+ evidence.review_ready = deriveReviewReady(evidence);
258
261
  return evidence;
259
262
  }
260
263
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feature-factory",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "description": "Durable, observed control plane for /feature runs. Host-agnostic: no opencode dependency.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/state/schema.js CHANGED
@@ -181,9 +181,10 @@ function retryExtensions(errors, run) {
181
181
  for (const key of ["attempt", "previous_limit", "new_limit", "previous_max_retries", "max_retries"]) positiveInt(errors, entry, key, path);
182
182
  if (!RETRY_EXTENSION_SCOPES.includes(entry.scope) || !ID.test(entry.slice_id)
183
183
  || ["attempt", "previous_limit", "new_limit", "previous_max_retries", "max_retries"].some((key) => !Number.isSafeInteger(entry[key]) || entry[key] < 1)) crossCheck = false;
184
- if (Number.isSafeInteger(entry.previous_limit) && entry.new_limit !== entry.previous_limit + 1) errors.push({ path: `${path}.new_limit`, message: "must advance exactly one" });
184
+ const raise = entry.scope === "all" ? entry.max_retries - entry.previous_max_retries : 1;
185
+ if (Number.isSafeInteger(entry.previous_limit) && entry.new_limit !== entry.previous_limit + raise) errors.push({ path: `${path}.new_limit`, message: "must advance by the granted raise" });
185
186
  if (entry.scope === "slice" && entry.max_retries !== entry.previous_max_retries) errors.push({ path: `${path}.max_retries`, message: "must stay unchanged for slice scope" });
186
- if (entry.scope === "all" && entry.max_retries !== entry.previous_max_retries + 1) errors.push({ path: `${path}.max_retries`, message: "must advance exactly one for all scope" });
187
+ if (entry.scope === "all" && !(raise >= 1)) errors.push({ path: `${path}.max_retries`, message: "must rise for all scope" });
187
188
  for (const key of ["session", "reason"]) required(errors, entry, key, path);
188
189
  pattern(errors, entry, "at", ISO, path);
189
190
  pattern(errors, entry, "snapshot_digest", DIGEST, path);
@@ -196,7 +197,7 @@ function retryExtensions(errors, run) {
196
197
  }
197
198
  });
198
199
  if (!crossCheck) return;
199
- let maxRetries = run.max_retries - value.filter((entry) => entry.scope === "all").length;
200
+ let maxRetries = run.max_retries - value.reduce((sum, entry) => sum + (entry.scope === "all" ? entry.max_retries - entry.previous_max_retries : 0), 0);
200
201
  if (maxRetries < 1) return void errors.push({ path: "run.retry_extensions", message: "contains more run-wide grants than the final max_retries permits" });
201
202
  const extras = new Map(), latestGrant = new Map(), grantBases = new Map(), archiveRefs = new Set();
202
203
  let previousAt = null;
@@ -212,10 +213,10 @@ function retryExtensions(errors, run) {
212
213
  previousAt = Date.parse(entry.at);
213
214
  const previousLimit = maxRetries + previousExtra;
214
215
  if (entry.previous_max_retries !== maxRetries || entry.previous_limit !== previousLimit) errors.push({ path, message: "does not continue the recorded retry limits" });
215
- if (entry.scope === "all") maxRetries += 1;
216
+ if (entry.scope === "all") maxRetries = entry.max_retries;
216
217
  else extras.set(entry.slice_id, previousExtra + 1);
217
218
  const newLimit = maxRetries + (extras.get(entry.slice_id) ?? 0);
218
- if (entry.max_retries !== maxRetries || entry.new_limit !== newLimit || entry.attempt !== newLimit) errors.push({ path, message: "does not bind the granted attempt and resulting limits" });
219
+ if (entry.max_retries !== maxRetries || entry.new_limit !== newLimit || entry.attempt !== previousLimit + 1) errors.push({ path, message: "does not bind the granted attempt and resulting limits" });
219
220
  if (grantBases.has(entry.slice_id) && grantBases.get(entry.slice_id) !== entry.base_ref) errors.push({ path: `${path}.base_ref`, message: "changes the slice's immutable retry base" });
220
221
  grantBases.set(entry.slice_id, entry.base_ref); latestGrant.set(entry.slice_id, entry.attempt);
221
222
  }