feature-factory 0.10.3 → 0.10.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/README.md CHANGED
@@ -47,11 +47,13 @@ resolved Git top level:
47
47
  }
48
48
  ```
49
49
 
50
- The root has two required properties, `resolve` and `verify`, and five optional properties: `publish`,
51
- `pr_draft`, `verify_timeout_ms`, `bootstrap`, and `bootstrap_timeout_ms`. Required commands and any present
50
+ The root has two required properties, `resolve` and `verify`, and six optional properties: `publish`,
51
+ `pr_draft`, `verify_timeout_ms`, `bootstrap`, `bootstrap_timeout_ms`, and `max_retries`. Required commands and any present
52
52
  `publish` or `bootstrap` are non-empty strings. There is no `publishing_identity` key, and a file carrying one is malformed because the
53
53
  optional set is closed. A present `pr_draft` must be a JSON boolean and omission means `true`. Both timeouts are
54
54
  positive safe integers. `bootstrap_timeout_ms` requires `bootstrap`.
55
+ A present `max_retries` must be a positive integer. It is the run's attempt budget when `init` runs
56
+ without `--max-retries`, which still outranks it; omission leaves the default `3`.
55
57
  Each omitted timeout independently defaults to `900000`; neither shares the other's budget. The file is
56
58
  operator-owned, committed, and protected as a privileged path: a run cannot create, write, merge,
57
59
  archive, package, or repair it.
@@ -66,9 +68,9 @@ value stops the run instead of publishing under whatever credential the host hap
66
68
  it from `gh`, the token, stored authentication, or Git configuration: an expectation read from the
67
69
  credential being checked would always match.
68
70
 
69
- Validation refuses the first matching defect in this order: unreadable or invalid JSON, a non-object root, or unknown keys; invalid `pr_draft`; invalid `bootstrap`; `bootstrap_timeout_ms` without `bootstrap`; invalid `bootstrap_timeout_ms`; invalid `verify_timeout_ms`; missing or invalid required entries; then invalid `publish`.
71
+ Validation refuses the first matching defect in this order: unreadable or invalid JSON, a non-object root, or unknown keys; invalid `pr_draft`; invalid `bootstrap`; `bootstrap_timeout_ms` without `bootstrap`; invalid `bootstrap_timeout_ms`; invalid `verify_timeout_ms`; missing or invalid required entries; invalid `publish`; then invalid `max_retries`.
70
72
 
71
- The named forms are `.factory.json entry 'pr_draft' must be a boolean`, `.factory.json entry 'bootstrap' must be a non-empty string`, `.factory.json entry 'bootstrap_timeout_ms' requires a declared bootstrap command`, `.factory.json entry 'bootstrap_timeout_ms' must be a positive integer`, and `.factory.json entry 'verify_timeout_ms' must be a positive integer`.
73
+ The named forms are `.factory.json entry 'pr_draft' must be a boolean`, `.factory.json entry 'bootstrap' must be a non-empty string`, `.factory.json entry 'bootstrap_timeout_ms' requires a declared bootstrap command`, `.factory.json entry 'bootstrap_timeout_ms' must be a positive integer`, `.factory.json entry 'verify_timeout_ms' must be a positive integer`, and `.factory.json entry 'max_retries' must be a positive integer`.
72
74
 
73
75
  A present invalid, unreadable, incomplete, wrong-type, whitespace-only, or unknown-property config refuses closed.
74
76
 
@@ -258,8 +260,9 @@ own the same path. Duplicate, target-already-owned, malformed, privileged, repla
258
260
  requests refuse atomically. Resume never amends or reseeds. A merge continues to refuse every unamended
259
261
  or privileged changed path.
260
262
 
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>`.
263
+ A parked slice that exhausted its effective limit can be reopened for one more attempt through
264
+ `factory grant-retry <run-id> <slice-id> --scope slice|all [--max-retries N] --reason <text> --session <id> --repo <sandbox>`;
265
+ `--max-retries` (with `all` only) sets the new run-wide limit in one call instead of raising it by one.
263
266
  Slice scope raises only the target's additive allowance. All scope raises `max_retries` for pending later
264
267
  waves too, but refuses when another slice is blocked or an exhausted post-merge repair record exists. Both
265
268
  scopes reopen only the named slice after exact owner, snapshot, REJECT, evidence, base, current clean head,
@@ -286,14 +289,18 @@ report `null`, skips all three guards; an absent config does not affect them.
286
289
 
287
290
  Before each guard, inherited `GH_TOKEN` must exist and contain at least one character. Missing or empty
288
291
  means identity is unobservable without invoking `gh`, the network, stored authentication, credential
289
- queries, or any fallback. A prepared environment submits exactly this read-only network probe as one
292
+ queries, or any fallback. A prepared environment submits exactly this command as one
290
293
  ordinary host shell step with cwd exactly `RUN_REPO`, inherited environment, and no stdin:
291
294
 
292
295
  ```sh
293
- gh api --method GET /user --jq .login
296
+ factory identity "$R" --json --repo "$RUN_REPO"
294
297
  ```
295
298
 
296
- The direct stdout bytes, stderr bytes, and numeric status are parsed strictly. Only numeric zero, empty
299
+ The CLI runs the read-only network probe `gh api --method GET /user --jq .login` itself, with separate
300
+ stdout and stderr pipes, so a host whose shell tool combines the two streams can still run the guard. It
301
+ returns JSON whose `reason` is null on a match and is otherwise the complete rendered park reason.
302
+
303
+ The probe's stdout bytes, stderr bytes, and numeric status are parsed strictly. Only numeric zero, empty
297
304
  stderr, and exactly one ASCII GitHub login plus one LF are observable; the required LF alone is removed,
298
305
  then the raw login is compared exactly and case-sensitively with the raw declaration. `gh auth status`
299
306
  does not prove the publishing identity.
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
@@ -545,8 +547,8 @@ The optional repository-owned file is `$O/.factory.json`:
545
547
  ```
546
548
 
547
549
  The root must be a JSON object with the two required own properties `resolve` and `verify`,
548
- plus only the optional own properties `publish`, `pr_draft`, `verify_timeout_ms`, `bootstrap`, and
549
- `bootstrap_timeout_ms`. `resolve`, `verify`, `publish`, and `bootstrap` are command strings; every present
550
+ plus only the optional own properties `publish`, `pr_draft`, `verify_timeout_ms`, `bootstrap`,
551
+ `bootstrap_timeout_ms`, and `max_retries`. `resolve`, `verify`, `publish`, and `bootstrap` are command strings; every present
550
552
  command must be non-empty. `publish` was required and invoked nowhere until this release, so every
551
553
  consumer wrote a command that could not run. It is optional now and contributes only the file candidate
552
554
  to the one Step 6 publishing selection. Inherited `FACTORY_PUBLISHING_COMMAND` or the default may win, so
@@ -557,9 +559,10 @@ key is malformed, because the optional set above is closed. `pr_draft` must be a
557
559
  when present and defaults to `true` when absent. Both timeout values must be positive
558
560
  safe integers when present, and `bootstrap_timeout_ms` is valid only with a declared `bootstrap`.
559
561
  `verify_timeout_ms` and `bootstrap_timeout_ms` each independently default to `900000` milliseconds;
560
- neither timeout shares or consumes the other's budget.
562
+ neither timeout shares or consumes the other's budget. A present `max_retries` must be a positive integer. It is the run's attempt budget when `init` runs
563
+ without `--max-retries`, which still outranks it; omission leaves the default `3`.
561
564
 
562
- Validation refuses the first matching defect in this order: unreadable or invalid JSON, a non-object root, or unknown keys; invalid `pr_draft`; invalid `bootstrap`; `bootstrap_timeout_ms` without `bootstrap`; invalid `bootstrap_timeout_ms`; invalid `verify_timeout_ms`; missing or invalid required entries; then invalid `publish`.
565
+ Validation refuses the first matching defect in this order: unreadable or invalid JSON, a non-object root, or unknown keys; invalid `pr_draft`; invalid `bootstrap`; `bootstrap_timeout_ms` without `bootstrap`; invalid `bootstrap_timeout_ms`; invalid `verify_timeout_ms`; missing or invalid required entries; invalid `publish`; then invalid `max_retries`.
563
566
 
564
567
  Do not use the obsolete summary “Validation refuses the first matching defect in this order: unreadable or invalid JSON, a non-object root, or unknown keys; invalid `bootstrap`; `bootstrap_timeout_ms` without `bootstrap`; invalid `bootstrap_timeout_ms`; invalid `verify_timeout_ms`; then missing or invalid required entries.” because it omits the earlier `pr_draft` check.
565
568
 
@@ -1071,53 +1074,41 @@ reconciliation, reading `status.next`, dispatch, or any transition. For a parked
1071
1074
  immediately after explicit resume has been verified `running` with unchanged historical result, real
1072
1075
  next action, and the same fresh owner. No operation may intervene on either side of this guard.
1073
1076
 
1074
- At every one of the three guards, before submitting a host shell step, inspect only the inherited
1075
- environment value and require `GH_TOKEN` to exist and contain at least one character. Missing or empty
1076
- `GH_TOKEN` is immediately the same unobservable reason below. Do not invoke `gh`, hit the network,
1077
- inspect stored authentication, query or attempt credentials, or run any fallback in that case.
1078
-
1079
- After that preflight succeeds, submit exactly this command as one ordinary host shell step with cwd
1080
- exactly `RUN_REPO`, the inherited environment including that nonempty `GH_TOKEN`, and no stdin:
1077
+ At every one of the three guards, submit exactly this command as one ordinary host shell step with cwd
1078
+ exactly `RUN_REPO`, the inherited environment, and no stdin:
1081
1079
 
1082
1080
  ```sh
1083
- gh api --method GET /user --jq .login
1081
+ factory identity "$R" --json --repo "$RUN_REPO"
1084
1082
  ```
1085
1083
 
1086
- Use the host result directly as three separate values: exact stdout bytes, exact stderr bytes, and the
1087
- numeric status. Do not use command substitution, pipes, redirection, shell capture variables, temporary
1088
- files, nested capture, retry, fallback, `gh auth`, credential queries, Git configuration, a token in
1089
- argv, or persistence of output or diagnostics. The real command is a read-only network observation.
1090
-
1091
- The identity is observable only when status is numeric zero, stderr has exactly zero bytes, and stdout
1092
- is exactly one ASCII login followed by exactly one LF byte. The login grammar is
1093
- `^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$`. Every other status or byte sequence is unobservable;
1094
- do not trim, decode-and-normalize, retry, or recover a partial value. Remove only the required final LF
1095
- from an observable value, then compare the raw declared and observed strings exactly and
1096
- case-sensitively before rendering either one.
1097
-
1098
- Render a value for the reason with the deterministic ASCII-only JSON-string renderer. Surround it with
1099
- double quotes. Emit printable ASCII U+0020 through U+007E literally except quote and backslash, which
1100
- use `\"` and `\\`. Use the fixed JSON short escapes `\b`, `\t`, `\n`, `\f`, and `\r` for U+0008,
1101
- U+0009, U+000A, U+000C, and U+000D. Render every other UTF-16 code unit outside U+0020 through U+007E
1102
- as lowercase `\uXXXX`. A non-BMP code point therefore renders as its two surrogate units, and an
1103
- unpaired surrogate renders as its one unit. Leave slash unescaped. This covers C0, C1, DEL, U+0085,
1104
- U+2028, U+2029, and non-BMP input without a literal non-ASCII or control byte.
1084
+ The CLI owns the observation, so the verdict does not depend on what the host's shell tool reports. It
1085
+ refuses without invoking `gh` when inherited `GH_TOKEN` is missing or empty. Otherwise it runs the
1086
+ read-only network observation `gh api --method GET /user --jq .login` with separate stdout and stderr pipes,
1087
+ accepts only numeric status zero, zero-byte stderr, and exactly one ASCII login matching
1088
+ `^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$` followed by one LF, and compares it exactly and
1089
+ case-sensitively with the recorded `publishing_identity`. A host whose shell tool combines stdout and
1090
+ stderr can run this guard; it could never run the probe itself.
1105
1091
 
1106
- An observable unequal value uses exactly:
1092
+ Require exit zero and one JSON object. `reason: null` passes the guard. A non-null `reason` is the
1093
+ complete already-rendered ASCII park reason, rendered with deterministic ASCII-only JSON strings, and is
1094
+ exactly one of:
1107
1095
 
1108
1096
  ```text
1109
1097
  publishing identity mismatch: declared <declared-ascii-json>, observed <observed-ascii-json>; authenticate as <declared-ascii-json> and retry.
1110
1098
  ```
1111
1099
 
1112
- An unobservable result uses exactly:
1113
-
1114
1100
  ```text
1115
1101
  publishing identity unobservable: declared <declared-ascii-json>; launch with inherited GH_TOKEN for <declared-ascii-json> as documented in OPERATING.md and retry.
1116
1102
  ```
1117
1103
 
1104
+ Use the returned `reason` exactly; do not re-render, trim, or edit it. Never run `gh` yourself for this
1105
+ guard, retry, fall back to stored authentication, or put a token in argv. A nonzero exit or output that is
1106
+ not that JSON object is a failed guard: perform no further operation and report only
1107
+ `Outcome: retained-lock-error`.
1108
+
1118
1109
  Never expose the token, raw stdout or stderr, diagnostics, status, command text, target, helper output,
1119
1110
  or environment. On either reason, quiesce every builder, tool, background task, and heartbeat call.
1120
- Bind `PRE_QUOTING_REASON` to the complete already-rendered ASCII reason. Encode it as one deterministic
1111
+ Bind `PRE_QUOTING_REASON` to the complete already-rendered ASCII reason the CLI returned. Encode it as one deterministic
1121
1112
  POSIX shell token by surrounding the complete reason with single quotes and replacing every literal
1122
1113
  `'` inside it with the exact shell sequence `'\''`. Use that encoded token as the sole `--reason`
1123
1114
  argument in the host shell command string:
@@ -2166,12 +2157,12 @@ this is not a claim that a target is unobservable. Use the same three exact reda
2166
2157
  from Step 0.
2167
2158
 
2168
2159
  With `DECLARED_PUBLISHING_IDENTITY`, immediately after exact target equality and before the unchanged
2169
- push, run the same ordinary host observation under its exact cwd, environment, no-stdin, direct-result,
2170
- validation, rendering, redaction, and parking rules. No operation may intervene between equality, this
2160
+ push, run the same identity guard command under its exact cwd, environment, no-stdin, verdict,
2161
+ redaction, and parking rules. No operation may intervene between equality, this
2171
2162
  guard, and the push:
2172
2163
 
2173
2164
  ```sh
2174
- gh api --method GET /user --jq .login
2165
+ factory identity "$R" --json --repo "$RUN_REPO"
2175
2166
  ```
2176
2167
 
2177
2168
  Publish the fully qualified recorded feature ref from `RUN_REPO`, run `gh` from `O` with that exact head
@@ -2181,7 +2172,7 @@ legacy local runs use `O` through the selection already made in Step 0:
2181
2172
 
2182
2173
  ```sh
2183
2174
  git -C "$RUN_REPO" push origin "refs/heads/$FEATURE_BRANCH:refs/heads/$FEATURE_BRANCH"
2184
- gh api --method GET /user --jq .login
2175
+ factory identity "$R" --json --repo "$RUN_REPO"
2185
2176
  (
2186
2177
  cd "$O"
2187
2178
  if [ "$PR_DRAFT" = true ]; then
package/bin/factory.js CHANGED
@@ -14,6 +14,7 @@ import { nextAction, nextActionRecord, readRun, readRunUnchecked } from "../stat
14
14
  import { transition } from "../state/transition.js";
15
15
  import { RUN_JSON_LOCK_DIR, withRunJsonLock } from "../core/run-lock.js";
16
16
  import { buildEvidence, deriveReviewReady, EVIDENCE_KEYS, evidenceRef, git, observeAncestry, observeCleanliness, observeTrackedCleanliness, observeWorktree, privilegedPaths, proveInitContainment, resolveWorktree, runBootstrap, unownedPaths } from "../observe/index.js";
17
+ import { identityReason, observeIdentity } from "../observe/identity.js";
17
18
  import { assertPublicationReady, assertReviewBinding, isApproving, observeMergeProof, readEvidence, readReview, readValidatorReview } from "../observe/review.js";
18
19
  import { readRepositoryConfig, RepositoryConfigError } from "../observe/repository-config.js";
19
20
  import { reverifyRepair } from "../observe/repair-reverification.js";
@@ -40,7 +41,7 @@ export const COMMANDS = Object.freeze({
40
41
  status: Object.freeze(["--repo", "--json"]),
41
42
  "amend-paths": Object.freeze(["--repo", "--add", "--reason", "--session", "--now", "--json"]),
42
43
  resume: Object.freeze(["--repo", "--session", "--now", "--json"]),
43
- "grant-retry": Object.freeze(["--repo", "--scope", "--reason", "--session", "--now", "--json"]),
44
+ "grant-retry": Object.freeze(["--repo", "--scope", "--max-retries", "--reason", "--session", "--now", "--json"]),
44
45
  restore: Object.freeze(["--repo", "--from", "--now", "--json"]),
45
46
  snapshot: Object.freeze(["--repo", "--json"]),
46
47
  decide: Object.freeze(["--repo", "--text", "--session", "--now", "--json"]),
@@ -60,6 +61,7 @@ export const COMMANDS = Object.freeze({
60
61
  pr: Object.freeze(["--repo", "--url", "--now", "--json"]),
61
62
  "reverify-repair": Object.freeze(["--repo", "--now", "--json"]),
62
63
  "effective-push": Object.freeze([]),
64
+ identity: Object.freeze(["--repo", "--json"]),
63
65
  });
64
66
 
65
67
  const BOOLEAN_FLAGS = new Set(["--json", "--repository-verify"]);
@@ -82,7 +84,7 @@ export async function run(argv) {
82
84
  if (!Object.hasOwn(COMMANDS, command)) throw new CliError(`unknown command '${command}' (try --help)`);
83
85
  const { positional, flags } = parse(command, rest);
84
86
  const handler = HANDLERS[command];
85
- if (["init", "status", "snapshot", "lock", "heartbeat", "effective-push"].includes(command)) return handler(positional, flags);
87
+ if (["init", "status", "snapshot", "lock", "heartbeat", "effective-push", "identity"].includes(command)) return handler(positional, flags);
86
88
  const repo = resolve(flags.repo ?? process.cwd()), runId = positional[0];
87
89
  if (command === "restore") {
88
90
  const live = [join(repo, CONTROL_PLANE, runId), join(repo, ".factory-sandboxes", runId, CONTROL_PLANE, runId)]
@@ -799,11 +801,16 @@ const HANDLERS = {
799
801
  if (positional.length !== 2) throw new CliError("factory grant-retry requires exactly <run-id> <slice-id>");
800
802
  const [runId, sliceId] = positional;
801
803
  if (!RETRY_EXTENSION_SCOPES.includes(flags.scope)) throw new CliError(`factory grant-retry requires --scope ${RETRY_EXTENSION_SCOPES.join("|")}`);
804
+ if (flags.maxRetries !== undefined && flags.scope !== "all") throw new CliError("factory grant-retry --max-retries requires --scope all");
802
805
  if (typeof flags.reason !== "string" || !flags.reason.trim()) throw new CliError("factory grant-retry requires nonblank --reason <text>");
803
806
  if (typeof flags.session !== "string" || !flags.session.trim()) throw new CliError("factory grant-retry requires nonblank --session <id>");
804
807
  const runDir = runDirFor(flags, runId), repo = resolve(flags.repo ?? process.cwd());
805
808
  const boundBytes = readFileSync(join(runDir, "run.json")), current = validateRun(JSON.parse(boundBytes.toString("utf8")));
806
809
  if (current.status !== "needs-human") throw new CliError(`factory grant-retry requires current status needs-human; found '${current.status}'`);
810
+ // Instruction, not enforcement, on the size: a higher run-wide limit buys more reviewed attempts, never an
811
+ // unearned merge. Enforced only that it rises, since the grant must reopen the exhausted slice at N+1.
812
+ const grantedMax = flags.scope === "all" ? integer(flags.maxRetries, current.max_retries + 1, "--max-retries") : current.max_retries;
813
+ 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
814
  const owner = assertFreshSessionOwner(runDir, runId, flags.session, "grant-retry"), at = stamp(flags);
808
815
  if (Date.parse(at) <= Date.parse(current.updated_at)) throw new CliError("grant-retry must move updated_at forwards");
809
816
  const qualified = qualifyRetryGrant(repo, runDir, runId, current, sliceId);
@@ -864,7 +871,7 @@ const HANDLERS = {
864
871
  const row = { ...existing, status: "running", attempts: existing.attempts + 1,
865
872
  ...(flags.scope === "slice" ? { extra_attempts: (existing.extra_attempts ?? 0) + 1 } : {}),
866
873
  evidence_ref: null, review_ref: null };
867
- const maxRetries = state.max_retries + (flags.scope === "all" ? 1 : 0), newLimit = maxRetries + (row.extra_attempts ?? 0);
874
+ const maxRetries = grantedMax, newLimit = maxRetries + (row.extra_attempts ?? 0);
868
875
  const audit = { scope: flags.scope, slice_id: sliceId, base_ref: existing.base_ref, attempt: row.attempts, previous_limit: previousLimit,
869
876
  new_limit: newLimit, previous_max_retries: state.max_retries, max_retries: maxRetries,
870
877
  session: flags.session, reason: flags.reason.trim(), at, snapshot_digest: qualified.snapshotDigest,
@@ -1276,6 +1283,16 @@ const HANDLERS = {
1276
1283
  throw new CliError("factory lock requires <claim|steal|release>");
1277
1284
  },
1278
1285
 
1286
+ // Read-only: no lock, no state write. The driver parks with `reason` when it is non-null.
1287
+ async identity(positional, flags) {
1288
+ if (positional.length !== 1) throw new CliError("factory identity requires exactly <run-id>");
1289
+ const [runId] = positional, run = readRun(runDirFor(flags, runId)), declared = run.publishing_identity ?? null;
1290
+ if (declared === null) return emit(flags, { run_id: runId, publishing_identity: null, checked: false, reason: null });
1291
+ const observed = observeIdentity(resolve(flags.repo ?? process.cwd()));
1292
+ return emit(flags, { run_id: runId, publishing_identity: declared, checked: true, observable: observed !== null,
1293
+ reason: identityReason(declared, observed) });
1294
+ },
1295
+
1279
1296
  async heartbeat([runId], flags) {
1280
1297
  const runDir = runDirFor(flags, runId);
1281
1298
  const owner = await refreshSessionLock(runDir, {
@@ -1722,7 +1739,9 @@ export async function dispatchInit(positional, flags, operations = INIT_OPERATIO
1722
1739
  proveContainedBranch();
1723
1740
  let run;
1724
1741
  try {
1725
- run = validateRun({ ...candidate, pr_base: prBase, pr_draft: config?.prDraft ?? true, ...bootstrapEvidence });
1742
+ // An explicit `--max-retries` outranks the committed `.factory.json` default, which outranks 3.
1743
+ const configuredRetries = flags.maxRetries === undefined && config?.maxRetries ? { max_retries: config.maxRetries } : {};
1744
+ run = validateRun({ ...candidate, ...configuredRetries, pr_base: prBase, pr_draft: config?.prDraft ?? true, ...bootstrapEvidence });
1726
1745
  } catch (error) {
1727
1746
  throw new CliError(`final manifest validation failed for sandbox '${S}'; sandbox was retained`, { cause: error });
1728
1747
  }
@@ -1863,11 +1882,12 @@ function usage() {
1863
1882
  factory init <run-id> [--branch B=feature/<run-id>] [--worktree W=.] [--pr-base TARGET] [--issue KEY] [--mode interactive|headless|autonomous]
1864
1883
  factory status <run-id> [--json]
1865
1884
  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]
1885
+ factory grant-retry <run-id> <slice-id> --scope slice|all [--max-retries N] --reason TEXT --session ID [--now ISO]
1867
1886
  factory decide <run-id> --text TEXT --session ID [--now ISO]
1868
1887
  factory resume <run-id> --session ID [--now ISO]
1869
1888
  factory restore <run-id> --repo OPERATOR --from refs/remotes/REMOTE/BRANCH [--now ISO]
1870
1889
  factory snapshot <run-id> --repo OPERATOR
1890
+ factory identity <run-id> [--repo PATH] [--json]
1871
1891
  factory reverify-repair <run-id> <repair-record-id> [--repo PATH] [--now ISO] [--json]
1872
1892
  factory lock <run-id> <claim|steal|release> --session ID [--ttl-ms N]
1873
1893
  factory heartbeat <run-id> --session ID
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}`);
@@ -0,0 +1,44 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ // Enforcement (false green): publishing under an account other than the run's recorded identity. This
4
+ // was driver prose that required the host to return stdout, stderr and status separately; Prime's bash
5
+ // returns them combined, so a compliant Prime driver could never observe the identity (#365). The CLI
6
+ // owns the pipes now, so the verdict no longer depends on what the host's shell tool can report.
7
+ export const IDENTITY_ARGV = Object.freeze(["api", "--method", "GET", "/user", "--jq", ".login"]);
8
+ const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/u;
9
+ const SHORT = new Map([[8, "\\b"], [9, "\\t"], [10, "\\n"], [12, "\\f"], [13, "\\r"]]);
10
+
11
+ // Deterministic ASCII-only JSON string: printable ASCII literal except quote and backslash, the fixed
12
+ // short escapes, and every other UTF-16 code unit as lowercase \uXXXX. Slash stays unescaped.
13
+ export function asciiJson(value) {
14
+ let rendered = "\"";
15
+ for (let index = 0; index < value.length; index += 1) {
16
+ const unit = value.charCodeAt(index);
17
+ if (SHORT.has(unit)) rendered += SHORT.get(unit);
18
+ else if (unit === 34 || unit === 92) rendered += `\\${value[index]}`;
19
+ else if (unit >= 0x20 && unit <= 0x7e) rendered += value[index];
20
+ else rendered += `\\u${unit.toString(16).padStart(4, "0")}`;
21
+ }
22
+ return `${rendered}"`;
23
+ }
24
+
25
+ // Observable only as numeric zero, zero-byte stderr, and exactly one login plus one LF. Nothing is
26
+ // trimmed, retried, or recovered from a partial value.
27
+ export function classifyIdentity({ status, stdout, stderr }) {
28
+ if (status !== 0 || !Buffer.isBuffer(stdout) || !Buffer.isBuffer(stderr) || stderr.length !== 0) return null;
29
+ if (stdout.length < 2 || stdout[stdout.length - 1] !== 10 || stdout.subarray(0, -1).some((byte) => byte > 0x7f)) return null;
30
+ const login = stdout.subarray(0, -1).toString("ascii");
31
+ return LOGIN.test(login) ? login : null;
32
+ }
33
+
34
+ export function identityReason(declared, observed) {
35
+ const name = asciiJson(declared);
36
+ if (observed === null) return `publishing identity unobservable: declared ${name}; launch with inherited GH_TOKEN for ${name} as documented in OPERATING.md and retry.`;
37
+ return observed === declared ? null : `publishing identity mismatch: declared ${name}, observed ${asciiJson(observed)}; authenticate as ${name} and retry.`;
38
+ }
39
+
40
+ // A missing or empty GH_TOKEN never reaches gh, the network, or stored authentication.
41
+ export function observeIdentity(cwd, env = process.env, timeout = 60000) {
42
+ if (typeof env.GH_TOKEN !== "string" || env.GH_TOKEN.length === 0) return null;
43
+ return classifyIdentity(spawnSync("gh", IDENTITY_ARGV, { cwd, env, stdio: ["ignore", "pipe", "pipe"], timeout }));
44
+ }
@@ -21,7 +21,7 @@ export function parseRepositoryConfig(bytes) {
21
21
  // not. It is optional now and consumed when present, so the key means what it says either way.
22
22
  const requiredKeys = ["resolve", "verify"];
23
23
  const optionalCommandKeys = ["publish"];
24
- const allowedKeys = [...requiredKeys, ...optionalCommandKeys, "pr_draft", "verify_timeout_ms", "bootstrap", "bootstrap_timeout_ms"];
24
+ const allowedKeys = [...requiredKeys, ...optionalCommandKeys, "pr_draft", "verify_timeout_ms", "bootstrap", "bootstrap_timeout_ms", "max_retries"];
25
25
  if (!config || typeof config !== "object" || Array.isArray(config)
26
26
  || Object.keys(config).some((keyName) => !allowedKeys.includes(keyName))) {
27
27
  throw new RepositoryConfigError("invalid .factory.json");
@@ -53,8 +53,14 @@ export function parseRepositoryConfig(bytes) {
53
53
  && (typeof config[keyName] !== "string" || !config[keyName].trim()))) {
54
54
  throw new RepositoryConfigError("invalid .factory.json: entry 'publish' must be a non-empty string");
55
55
  }
56
+ // A project default for `init --max-retries`, so it need not ride on every invocation. Instruction, not
57
+ // enforcement: more attempts are more reviewed work, never an unearned merge. A present value is still
58
+ // checked, because a key that is silently ignored reads as a budget that took effect.
59
+ if (Object.hasOwn(config, "max_retries") && (!Number.isSafeInteger(config.max_retries) || config.max_retries <= 0)) {
60
+ throw new RepositoryConfigError("invalid .factory.json: entry 'max_retries' must be a positive integer");
61
+ }
56
62
  const parsed = { command: config.verify, timeoutMs: config.verify_timeout_ms ?? DEFAULT_REPOSITORY_VERIFY_TIMEOUT_MS,
57
- prDraft: config.pr_draft ?? true };
63
+ prDraft: config.pr_draft ?? true, ...(Object.hasOwn(config, "max_retries") ? { maxRetries: config.max_retries } : {}) };
58
64
  return hasBootstrap ? { ...parsed, bootstrapCommand: config.bootstrap,
59
65
  bootstrapTimeoutMs: config.bootstrap_timeout_ms ?? DEFAULT_BOOTSTRAP_TIMEOUT_MS } : parsed;
60
66
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feature-factory",
3
- "version": "0.10.3",
3
+ "version": "0.10.5",
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
  }