feature-factory 0.10.7 → 0.10.8

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
@@ -152,7 +152,7 @@ truncation, redaction, timeout, retry, cache, payload transport, or session beha
152
152
 
153
153
  ### Sandbox bootstrap
154
154
 
155
- Only the CLI executes configured `bootstrap`: once during fresh init after clone, containment, and PR-base observation but before manifest publication, and again on every explicit resume while the run remains parked. The exact configured string runs unchanged with `shell: true`, inherited environment and stdin, cwd exactly the selected sandbox, and child stdout and stderr both routed to CLI stderr. Init JSON stdout therefore remains exactly one response object. Bootstrap has its own configured timeout or independent `900000` millisecond default, with no retry and no use of the verify budget.
155
+ Only the CLI executes configured `bootstrap`: once during fresh init after clone, containment, and PR-base observation but before manifest publication, and again on every explicit resume while the run remains parked. It also runs immediately before every configured `verify` execution, in the slice or integration worktree that verify runs in, so `verify` always sees bootstrap output installed from the tree it checks. The exact configured string runs unchanged with `shell: true`, inherited environment and stdin, cwd exactly the selected sandbox, and child stdout and stderr both routed to CLI stderr. Init JSON stdout therefore remains exactly one response object. Bootstrap has its own configured timeout or independent `900000` millisecond default, with no retry and no use of the verify budget.
156
156
 
157
157
  After every attempt, the CLI checks tracked worktree and index paths only and ignores untracked dependency output. Unobservable tracked state refuses before dirty tracked paths; dirty paths refuse before unavailable or nonzero exit, and diagnostics name exact repository-relative paths. A clean numeric zero stores paired `bootstrap_command` and `bootstrap_exit` manifest evidence. The command is exact; the exit is a non-negative integer or `null`. Ordinary transitions preserve the pair, and status output does not expose it.
158
158
 
package/WORKFLOW.md CHANGED
@@ -723,8 +723,8 @@ Effective push-target capture and comparison are active through the package-owne
723
723
 
724
724
  | Entry | Declared input | Return shape | Failure meaning | Current behavior |
725
725
  |---|---|---|---|---|
726
- | `bootstrap` | Exact configured string as one shell command with `shell: true`, inherited environment and stdin, cwd exactly the selected sandbox, and child stdout and stderr both routed to CLI stderr. Each execution receives its own `bootstrap_timeout_ms`, independently `900000` when omitted. | Numeric exit status or unavailable `null`; output is visible on CLI stderr and never parsed | Clean zero succeeds; dirty or unobservable tracked state outranks unavailable or nonzero exit | Invoked by the CLI once during configured fresh init and again on every explicit configured resume; never invoked by resolver, merge verification or replay, direct repository verification, slice or Gate 3 observation, effective push, or publication. |
727
- | `verify` | Ordinary shell step in the exact integration-worktree cwd with inherited environment; no structured stdin or factory-specific payload is defined. Each attempt receives the full configured `verify_timeout_ms`, silently `900000` when omitted. | Exit status is authoritative; stdout and stderr are inherited, informational, and unparsed | Zero means success; non-zero means repository verification failed; no numeric child status means unavailable | Invoked after each newly recorded merge through `observe --repository-verify`, with at most two executions in that merge invocation. The timeout and retry never apply to resolver, slice, or Gate 3 commands. |
726
+ | `bootstrap` | Exact configured string as one shell command with `shell: true`, inherited environment and stdin, cwd exactly the selected sandbox, and child stdout and stderr both routed to CLI stderr. Each execution receives its own `bootstrap_timeout_ms`, independently `900000` when omitted. | Numeric exit status or unavailable `null`; output is visible on CLI stderr and never parsed | Clean zero succeeds; dirty or unobservable tracked state outranks unavailable or nonzero exit | Invoked by the CLI once during configured fresh init, again on every explicit configured resume, and immediately before every configured `verify` execution in the tree that verify runs in: the slice worktree during slice observation, and the integration worktree for post-merge verify, its replay, and direct repository verification. Never invoked by the resolver, effective push, publication, or a replay that reuses recorded verify evidence. |
727
+ | `verify` | Ordinary shell step in the exact integration-worktree cwd with inherited environment; no structured stdin or factory-specific payload is defined. Each attempt receives the full configured `verify_timeout_ms`, silently `900000` when omitted. | Exit status is authoritative; stdout and stderr are routed to CLI stderr, informational, and unparsed | Zero means success; non-zero means repository verification failed; no numeric child status means unavailable | Invoked after each newly recorded merge through `observe --repository-verify`, with at most two executions in that merge invocation. The timeout and retry never apply to resolver, slice, or Gate 3 commands. |
728
728
  | `publish` | Optional file candidate for the one Step 6 publishing selection. A nonblank inherited `FACTORY_PUBLISHING_COMMAND` selects its exact string; the same variable set empty or to whitespace selects the default; when the variable is unset this entry is selected if present, otherwise the default. A selected nondefault command runs as one shell step in `RUN_REPO` cwd, with no stdin or positional arguments and inherited environment plus exact `PR_BASE`, `FEATURE_BRANCH`, `PR_DRAFT`, `PR_TITLE`, and absolute `PR_BODY_FILE`. | Exit status is authoritative; the last nonempty stdout line must be an absolute HTTPS URL and becomes `PR_URL` | Zero plus that URL is recordable; any other result is indeterminate and parks before `factory pr` | The resolved selection replaces only `gh pr create`, after the factory-owned exact push and post-push identity guard. `factory pr` is unchanged and still records the URL. |
729
729
  | `publishing_identity` | No runtime input; read the value `status` reports for the run, recorded at init from `--publishing-identity` or the inherited `FACTORY_PUBLISHING_IDENTITY` | Exact case-sensitive string compared with the observed login | Absent at init refuses before any sandbox exists; mismatch or unobservable identity parks the run | Active at the three mandatory guards below; only a manifest written before 0.8.0 can report `null` and skip them. |
730
730
 
@@ -1528,12 +1528,16 @@ Per slice:
1528
1528
  unless its ratified `test_plan` is empty — the waiver comes from the plan, not from you.
1529
1529
 
1530
1530
  When `.factory.json` declares `verify`, this step also runs it on the slice's commit, after the
1531
- ratified test command and with `FACTORY_VERIFY_SCOPE=slice` in its environment, and records the
1532
- result as `repository_verify`. A failing repository verify makes the slice not `review_ready`, the
1531
+ ratified test command, and records the result as `repository_verify`. A declared `bootstrap` runs in
1532
+ the slice worktree, which never received init's bootstrap output, immediately before that verify and
1533
+ only when it will run; a bootstrap refusal is the evidence's blocked reason and the verify does not run. A failing repository verify makes the slice not `review_ready`, the
1533
1534
  same as a failing test, so the review rejects and the slice retries; an empty `test_plan` does not
1534
1535
  waive it. Its output appears on stderr: give the failing part of it (the lint, format, or test
1535
1536
  failure) to the builder with the rejection, because otherwise the retry cannot see what to fix. This
1536
1537
  is what keeps a one-line lint failure from surfacing only after merge, where it cannot be repaired.
1538
+ When a merge's tree is byte-identical to the slice commit whose repository verify passed, the
1539
+ post-merge verify reuses that result instead of running again, and its evidence names the slice commit
1540
+ in `reused_from`; any other merge, such as one after a sibling merged, runs the verify as before.
1537
1541
 
1538
1542
  `BUILDER_REPORT` is a path and not the report. Write the builder's returned report to
1539
1543
  `BUILDER_REPORT=".factory/$R/artifacts/$SLICE_ID-builder-attempt-$SLICE_ATTEMPT.json"` and pass that path,
package/bin/factory.js CHANGED
@@ -394,16 +394,23 @@ function bootstrapOutcome(worktree, config, phase) {
394
394
  }
395
395
 
396
396
 
397
+ // Enforcement (#376): verify runs on a tree bootstrap has just prepared, the same way in every place it runs.
398
+ // Bootstrap output is untracked, so a slice worktree never had it and a merge that changes what bootstrap
399
+ // installs was verified against the dependencies from init. A verify that does not execute runs no bootstrap.
400
+ function bootstrapBeforeVerify(worktree, config, phase) {
401
+ return config?.bootstrapCommand ? bootstrapOutcome(worktree, config, phase).refusal : null;
402
+ }
403
+
397
404
  function branchPoint(run) {
398
405
  const base = run.slices.find((slice) => Array.isArray(slice.depends_on) && slice.depends_on.length === 0)?.base_ref;
399
406
  if (!/^[0-9a-f]{40}$/u.test(base ?? "")) throw new CliError("first seeded root slice has no immutable 40-character base_ref");
400
407
  return base;
401
408
  }
402
409
 
403
- async function writeObservedEvidence({ repo, runDir, runId, subject, attempt, branch, baseRef, worktree, status, blockedReason, claim, testCommand, skipReason, shellCommand, testTimeoutMs, repositoryVerify = null }) {
410
+ async function writeObservedEvidence({ repo, runDir, runId, subject, attempt, branch, baseRef, worktree, status, blockedReason, claim, testCommand, skipReason, shellCommand, testTimeoutMs, repositoryVerify = null, reused = null }) {
404
411
  const evidence = buildEvidence({
405
412
  subject, attempt, branch, baseRef, worktree, status, blockedReason, claim, runId,
406
- testCommand, skipReason, shellCommand, testTimeoutMs, repositoryVerify,
413
+ testCommand, skipReason, shellCommand, testTimeoutMs, repositoryVerify, reused,
407
414
  });
408
415
  const ancestry = observeAncestry(worktree, baseRef, "HEAD");
409
416
  if (ancestry !== "ancestor") {
@@ -421,7 +428,7 @@ async function writeObservedEvidence({ repo, runDir, runId, subject, attempt, br
421
428
  function canonicalRepositoryVerifyEvidence(evidence, { runId, run, integration, verifyCommand }) {
422
429
  const baseRef = branchPoint(run);
423
430
  // `repository_verify` is slice-only: null on test-verifier evidence, absent on evidence written before #372.
424
- const keys = Object.keys(evidence).filter((key) => key !== "repository_verify").sort();
431
+ const keys = Object.keys(evidence).filter((key) => !["repository_verify", "reused_from"].includes(key)).sort();
425
432
  const commandNames = [
426
433
  "git rev-parse HEAD",
427
434
  `git --literal-pathspecs diff --name-only -z ${baseRef}...HEAD`,
@@ -439,8 +446,9 @@ function canonicalRepositoryVerifyEvidence(evidence, { runId, run, integration,
439
446
  && ((tests.observed === true && Number.isInteger(tests.exit))
440
447
  || (tests.observed === false && tests.exit === null));
441
448
  const reconciliation = evidence.claim_reconciliation;
442
- return JSON.stringify(keys) === JSON.stringify(EVIDENCE_KEYS.filter((key) => key !== "repository_verify").sort())
449
+ return JSON.stringify(keys) === JSON.stringify(EVIDENCE_KEYS.filter((key) => !["repository_verify", "reused_from"].includes(key)).sort())
443
450
  && (evidence.repository_verify ?? null) === null
451
+ && (evidence.reused_from === undefined || sameTree(integration.worktree, evidence.reused_from, evidence.commit))
444
452
  && evidence.subject === "test-verifier" && evidence.run_id === runId
445
453
  && Number.isSafeInteger(evidence.attempt) && evidence.attempt >= 1
446
454
  && evidence.branch === run.branch && evidence.base_ref === baseRef
@@ -523,16 +531,37 @@ function repositoryVerifyRetrySafety(repo, run, mergeCommit) {
523
531
  return integration;
524
532
  }
525
533
 
526
- async function runRepositoryVerifyAttempts({ repo, runDir, runId, run, mergeCommit, verify, integration }) {
534
+ // Enforcement (#374): a post-merge verify is skipped only when the merge's tree is byte-identical to a slice
535
+ // commit whose own repository verify ran the same command green -- the same bytes already passed. A tree
536
+ // equality also implies the same committed `.factory.json`, so command and timeout cannot differ.
537
+ function sameTree(worktree, left, right) {
538
+ const tree = (sha) => git(worktree, ["rev-parse", "--verify", `${sha}^{tree}`]);
539
+ const [a, b] = [tree(left), tree(right)];
540
+ return a.ok && b.ok && a.stdout.trim() === b.stdout.trim();
541
+ }
542
+
543
+ function reusableSliceVerify({ runDir, runId, ref, worktree, mergeCommit, command }) {
544
+ let evidence;
545
+ try { evidence = readEvidence(runDir, ref, { runId }); } catch { return null; }
546
+ const verified = evidence.repository_verify;
547
+ if (!verified || verified.observed !== true || verified.exit !== 0 || verified.cmd !== command) return null;
548
+ if (!sameTree(worktree, evidence.commit, mergeCommit)) return null;
549
+ return { commit: evidence.commit, tests: { cmd: command, exit: 0, observed: true, skipped_reason: null } };
550
+ }
551
+
552
+ async function runRepositoryVerifyAttempts({ repo, runDir, runId, run, mergeCommit, verify, integration, reused = null }) {
527
553
  const baseRef = branchPoint(run);
528
554
  let attemptIntegration = integration;
529
555
  // False-green enforcement: one invocation gets at most two executions, never an unbounded recovery loop.
530
556
  for (let attempt = 1; attempt <= 2; attempt += 1) {
557
+ // Every execution is freshly bootstrapped, the retry included; a reused result executes nothing (#376).
558
+ const refusal = attempt === 1 && reused ? null : bootstrapBeforeVerify(attemptIntegration.worktree, verify, "post-merge verify");
559
+ if (refusal) throw new CliError(`${refusal} after recorded merge ${mergeCommit}; merged slice remains recorded; stop before advancing.`);
531
560
  const { evidence } = await writeObservedEvidence({
532
561
  repo, runDir, runId, subject: "test-verifier", attempt, branch: run.branch,
533
562
  baseRef, worktree: attemptIntegration.worktree, status: "completed", blockedReason: null,
534
563
  claim: null, testCommand: verify.command, skipReason: null, shellCommand: true,
535
- testTimeoutMs: verify.timeoutMs,
564
+ testTimeoutMs: verify.timeoutMs, reused: attempt === 1 ? reused : null,
536
565
  });
537
566
  const classified = classifyRepositoryVerifyEvidence(runDir, {
538
567
  runId, run, integration: attemptIntegration, verifyCommand: verify.command,
@@ -546,7 +575,7 @@ async function runRepositoryVerifyAttempts({ repo, runDir, runId, run, mergeComm
546
575
  return null;
547
576
  }
548
577
 
549
- async function verifyRecordedMerge({ repo, runDir, runId, mergeCommit }) {
578
+ async function verifyRecordedMerge({ repo, runDir, runId, mergeCommit, sliceEvidenceRef = null }) {
550
579
  const run = readRun(runDir);
551
580
  const integration = requireIntegrationWorktree(repo, run, run.worktree);
552
581
  if (integration.head !== mergeCommit) {
@@ -562,7 +591,9 @@ async function verifyRecordedMerge({ repo, runDir, runId, mergeCommit }) {
562
591
  throw error;
563
592
  }
564
593
  if (verify === null) return null;
565
- return runRepositoryVerifyAttempts({ repo, runDir, runId, run, mergeCommit, verify, integration });
594
+ const reused = sliceEvidenceRef && reusableSliceVerify({ runDir, runId, ref: sliceEvidenceRef,
595
+ worktree: integration.worktree, mergeCommit, command: verify.command });
596
+ return runRepositoryVerifyAttempts({ repo, runDir, runId, run, mergeCommit, verify, integration, reused });
566
597
  }
567
598
 
568
599
  const HANDLERS = {
@@ -1092,7 +1123,7 @@ const HANDLERS = {
1092
1123
  // step needs it: `observe --base` is compared for exact equality against this value at
1093
1124
  // merge time. The skill previously said to read it from `factory status`, which does not
1094
1125
  // expose it — so the documented path could not be followed at all.
1095
- if (status === "merged") await verifyRecordedMerge({ repo, runDir, runId, mergeCommit: row.merge_commit });
1126
+ if (status === "merged") await verifyRecordedMerge({ repo, runDir, runId, mergeCommit: row.merge_commit, sliceEvidenceRef: row.evidence_ref });
1096
1127
  // Slice attempts are budgeted the same way, so their rejected verdicts vanish the same way.
1097
1128
  const sliceReviewArchive = await archiveReviewAttempt(runDir, retryReviewRef ?? row.review_ref);
1098
1129
  await archiveReviewAttempt(runDir, retryEvidenceRef ?? row.evidence_ref);
@@ -1126,6 +1157,8 @@ const HANDLERS = {
1126
1157
  if (error instanceof RepositoryConfigError) throw new CliError(error.message);
1127
1158
  throw error;
1128
1159
  }
1160
+ const refusal = bootstrapBeforeVerify(worktree, repositoryVerify, "repository verification");
1161
+ if (refusal) throw new CliError(refusal);
1129
1162
  }
1130
1163
 
1131
1164
  let claim = null;
@@ -1189,7 +1222,8 @@ const HANDLERS = {
1189
1222
  testCommand: flags.repositoryVerify ? repositoryVerify.command : flags.testCmd ? flags.testCmd.split(" ").filter(Boolean) : null,
1190
1223
  skipReason, shellCommand: flags.repositoryVerify === true,
1191
1224
  testTimeoutMs: flags.repositoryVerify ? repositoryVerify.timeoutMs : undefined,
1192
- repositoryVerify: sliceVerify && { command: sliceVerify.command, timeoutMs: sliceVerify.timeoutMs },
1225
+ repositoryVerify: sliceVerify && { command: sliceVerify.command, timeoutMs: sliceVerify.timeoutMs,
1226
+ prepare: () => bootstrapBeforeVerify(worktree, sliceVerify, "slice observation") },
1193
1227
  });
1194
1228
  return emit(flags, {
1195
1229
  run_id: runId, subject, evidence_ref: evidenceRef(subject),
package/observe/index.js CHANGED
@@ -14,7 +14,7 @@ export const EVIDENCE_KEYS = Object.freeze([
14
14
  "subject", "run_id", "attempt", "branch", "base_ref", "worktree", "status", "blocked_reason",
15
15
  "worktree_clean",
16
16
  "files_changed", "diff_stat", "diff_observed", "commands", "tests", "commit",
17
- "observed_by", "review_ready", "claim_reconciliation", "repository_verify",
17
+ "observed_by", "review_ready", "claim_reconciliation", "repository_verify", "reused_from",
18
18
  ]);
19
19
 
20
20
  export function git(cwd, args, { runner = spawnSync } = {}) {
@@ -204,7 +204,7 @@ export function privilegedPaths(filesChanged) {
204
204
  || PRIVILEGED_EXACT.includes(file));
205
205
  }
206
206
 
207
- export function buildEvidence({ subject, runId, attempt, branch, baseRef, worktree, status, blockedReason = null, claim = null, testCommand = null, skipReason = null, shellCommand = false, testTimeoutMs = DEFAULT_REPOSITORY_VERIFY_TIMEOUT_MS, repositoryVerify = null, options = {} }) {
207
+ export function buildEvidence({ subject, runId, attempt, branch, baseRef, worktree, status, blockedReason = null, claim = null, testCommand = null, skipReason = null, shellCommand = false, testTimeoutMs = DEFAULT_REPOSITORY_VERIFY_TIMEOUT_MS, repositoryVerify = null, reused = null, options = {} }) {
208
208
  // Cleanliness is established before anything else is observed, because every later
209
209
  // fact - the diff, the commit, and above all the test result - is only about the
210
210
  // recorded commit if the tree has nothing uncommitted in it.
@@ -212,20 +212,26 @@ export function buildEvidence({ subject, runId, attempt, branch, baseRef, worktr
212
212
  const observation = observeWorktree(worktree, baseRef, options);
213
213
  // Tests are not run at all against a dirty tree: running them would produce a
214
214
  // result about bytes that are not going to merge.
215
- const tests = cleanliness.clean
215
+ // `reused` carries a slice's green repository verify for a merge whose tree is byte-identical (#374).
216
+ const tests = cleanliness.clean && reused ? reused.tests : cleanliness.clean
216
217
  ? runTests(worktree, testCommand, { ...options, skipReason, shellCommand, timeoutMs: testTimeoutMs,
217
- ...(shellCommand ? { env: { ...process.env, FACTORY_VERIFY_SCOPE: "integration" } } : {}) })
218
+ // Output to stderr, stdin inherited: `slice merged --json` and `observe --json` stay one JSON object.
219
+ ...(shellCommand ? { stdio: ["inherit", 2, 2] } : {}) })
218
220
  : { cmd: testCommand ? (shellCommand ? testCommand : testCommand.join(" ")) : null, exit: null, observed: false, skipped_reason: null };
219
221
  // Enforcement (false green, #372): a slice ran only its ratified test command, so the repository's
220
222
  // configured `verify` (lint, format, full suite) first ran after merge, where production repair is
221
223
  // forbidden and one Clippy warning parked a 13-slice run. The same suite now runs on the slice's commit.
222
224
  // Skipped after a failing test run, which is already not review-ready; its output goes to stderr so
223
- // `observe --json` stays one JSON object. `FACTORY_VERIFY_SCOPE` tells the command which run this is.
224
- const verifyRuns = repositoryVerify && cleanliness.clean && !(tests.observed && tests.exit !== 0);
225
+ // `observe --json` stays one JSON object. A bootstrap refusal (#376) means the tree is not prepared, so the
226
+ // verify does not run and the refusal is the evidence's blocked reason.
227
+ // Bootstrap is the verify's own first step (#376 review): it runs only when the verify will, and after the
228
+ // ratified test, so nothing the test does can remove its output before the verify reads it.
229
+ const eligible = repositoryVerify && cleanliness.clean && !(tests.observed && tests.exit !== 0);
230
+ const bootstrapRefusal = eligible ? repositoryVerify.prepare?.() ?? null : null;
231
+ const verifyRuns = eligible && !bootstrapRefusal;
225
232
  const verified = !repositoryVerify ? null : verifyRuns
226
233
  ? (({ cmd, exit, observed }) => ({ cmd, exit, observed }))(runTests(worktree, repositoryVerify.command,
227
- { ...options, shellCommand: true, timeoutMs: repositoryVerify.timeoutMs, stdio: ["ignore", 2, 2],
228
- env: { ...process.env, FACTORY_VERIFY_SCOPE: "slice" } }))
234
+ { ...options, shellCommand: true, timeoutMs: repositoryVerify.timeoutMs, stdio: ["ignore", 2, 2] }))
229
235
  : { cmd: repositoryVerify.command, exit: null, observed: false };
230
236
 
231
237
  // Third round, finding 1: cleanliness was a pre-test snapshot, so a test that wrote
@@ -257,7 +263,8 @@ export function buildEvidence({ subject, runId, attempt, branch, baseRef, worktr
257
263
  base_ref: baseRef,
258
264
  worktree,
259
265
  status,
260
- blocked_reason: blockedReason ?? cleanliness.reason ?? (stableUnderTest ? null : "worktree changed while the tests ran"),
266
+ blocked_reason: blockedReason ?? cleanliness.reason ?? bootstrapRefusal
267
+ ?? (stableUnderTest ? null : "worktree changed while the tests ran"),
261
268
  // Named for what it asserts: clean before the run, still clean after, and HEAD did
262
269
  // not move. A pre-test snapshot alone was not enough.
263
270
  worktree_clean: stableUnderTest,
@@ -271,6 +278,7 @@ export function buildEvidence({ subject, runId, attempt, branch, baseRef, worktr
271
278
  review_ready: false,
272
279
  claim_reconciliation: { claimed: false, mismatches: [] },
273
280
  repository_verify: verified,
281
+ ...(reused ? { reused_from: reused.commit } : {}),
274
282
  };
275
283
  evidence.claim_reconciliation = reconcileClaim(claim, evidence);
276
284
  evidence.review_ready = deriveReviewReady(evidence);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feature-factory",
3
- "version": "0.10.7",
3
+ "version": "0.10.8",
4
4
  "description": "Durable, observed control plane for /feature runs. Host-agnostic: no opencode dependency.",
5
5
  "type": "module",
6
6
  "license": "MIT",