feature-factory 0.10.4 → 0.10.6

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
 
@@ -287,14 +289,18 @@ report `null`, skips all three guards; an absent config does not affect them.
287
289
 
288
290
  Before each guard, inherited `GH_TOKEN` must exist and contain at least one character. Missing or empty
289
291
  means identity is unobservable without invoking `gh`, the network, stored authentication, credential
290
- 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
291
293
  ordinary host shell step with cwd exactly `RUN_REPO`, inherited environment, and no stdin:
292
294
 
293
295
  ```sh
294
- gh api --method GET /user --jq .login
296
+ factory identity "$R" --json --repo "$RUN_REPO"
295
297
  ```
296
298
 
297
- 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
298
304
  stderr, and exactly one ASCII GitHub login plus one LF are observable; the required LF alone is removed,
299
305
  then the raw login is compared exactly and case-sensitively with the raw declaration. `gh auth status`
300
306
  does not prove the publishing identity.
package/WORKFLOW.md CHANGED
@@ -547,8 +547,8 @@ The optional repository-owned file is `$O/.factory.json`:
547
547
  ```
548
548
 
549
549
  The root must be a JSON object with the two required own properties `resolve` and `verify`,
550
- plus only the optional own properties `publish`, `pr_draft`, `verify_timeout_ms`, `bootstrap`, and
551
- `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
552
552
  command must be non-empty. `publish` was required and invoked nowhere until this release, so every
553
553
  consumer wrote a command that could not run. It is optional now and contributes only the file candidate
554
554
  to the one Step 6 publishing selection. Inherited `FACTORY_PUBLISHING_COMMAND` or the default may win, so
@@ -559,9 +559,10 @@ key is malformed, because the optional set above is closed. `pr_draft` must be a
559
559
  when present and defaults to `true` when absent. Both timeout values must be positive
560
560
  safe integers when present, and `bootstrap_timeout_ms` is valid only with a declared `bootstrap`.
561
561
  `verify_timeout_ms` and `bootstrap_timeout_ms` each independently default to `900000` milliseconds;
562
- 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`.
563
564
 
564
- 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`.
565
566
 
566
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.
567
568
 
@@ -908,6 +909,12 @@ with `git check-ref-format --branch "$FEATURE_BRANCH"`. Bind
908
909
  `git -C "$O" show-ref --verify --quiet "$FEATURE_REF"` to exit exactly 1 for ref absence. Exit 0 means
909
910
  present and every other result is a lookup error; either refuses before init.
910
911
 
912
+ Bind `KEY` from how `R` was bound, never from anything else: when the configured resolver returned a
913
+ payload, `KEY` is exactly `R`, its `run_id`; when `R` is a ticket key from the request or branch, `KEY`
914
+ is that key; when `R` was derived from free text, `KEY` is absent and `--issue` is omitted. Never add a
915
+ `#` or other decoration. `issue_key` is immutable after init, and publication reads it for the title
916
+ prefix and closing reference, so a run whose resolver found issue `25` must record `25`.
917
+
911
918
  An explicit `PR_BASE` wins. Otherwise require the symbolic branch in the configured operator worktree;
912
919
  detached, missing, escaping, or unprovable worktree state is refused by init. Request one fresh sandbox
913
920
  by running exactly the command below, command first, including each bracketed flag only when its value
@@ -1073,53 +1080,41 @@ reconciliation, reading `status.next`, dispatch, or any transition. For a parked
1073
1080
  immediately after explicit resume has been verified `running` with unchanged historical result, real
1074
1081
  next action, and the same fresh owner. No operation may intervene on either side of this guard.
1075
1082
 
1076
- At every one of the three guards, before submitting a host shell step, inspect only the inherited
1077
- environment value and require `GH_TOKEN` to exist and contain at least one character. Missing or empty
1078
- `GH_TOKEN` is immediately the same unobservable reason below. Do not invoke `gh`, hit the network,
1079
- inspect stored authentication, query or attempt credentials, or run any fallback in that case.
1080
-
1081
- After that preflight succeeds, submit exactly this command as one ordinary host shell step with cwd
1082
- exactly `RUN_REPO`, the inherited environment including that nonempty `GH_TOKEN`, and no stdin:
1083
+ At every one of the three guards, submit exactly this command as one ordinary host shell step with cwd
1084
+ exactly `RUN_REPO`, the inherited environment, and no stdin:
1083
1085
 
1084
1086
  ```sh
1085
- gh api --method GET /user --jq .login
1087
+ factory identity "$R" --json --repo "$RUN_REPO"
1086
1088
  ```
1087
1089
 
1088
- Use the host result directly as three separate values: exact stdout bytes, exact stderr bytes, and the
1089
- numeric status. Do not use command substitution, pipes, redirection, shell capture variables, temporary
1090
- files, nested capture, retry, fallback, `gh auth`, credential queries, Git configuration, a token in
1091
- argv, or persistence of output or diagnostics. The real command is a read-only network observation.
1092
-
1093
- The identity is observable only when status is numeric zero, stderr has exactly zero bytes, and stdout
1094
- is exactly one ASCII login followed by exactly one LF byte. The login grammar is
1095
- `^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$`. Every other status or byte sequence is unobservable;
1096
- do not trim, decode-and-normalize, retry, or recover a partial value. Remove only the required final LF
1097
- from an observable value, then compare the raw declared and observed strings exactly and
1098
- case-sensitively before rendering either one.
1090
+ The CLI owns the observation, so the verdict does not depend on what the host's shell tool reports. It
1091
+ refuses without invoking `gh` when inherited `GH_TOKEN` is missing or empty. Otherwise it runs the
1092
+ read-only network observation `gh api --method GET /user --jq .login` with separate stdout and stderr pipes,
1093
+ accepts only numeric status zero, zero-byte stderr, and exactly one ASCII login matching
1094
+ `^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$` followed by one LF, and compares it exactly and
1095
+ case-sensitively with the recorded `publishing_identity`. A host whose shell tool combines stdout and
1096
+ stderr can run this guard; it could never run the probe itself.
1099
1097
 
1100
- Render a value for the reason with the deterministic ASCII-only JSON-string renderer. Surround it with
1101
- double quotes. Emit printable ASCII U+0020 through U+007E literally except quote and backslash, which
1102
- use `\"` and `\\`. Use the fixed JSON short escapes `\b`, `\t`, `\n`, `\f`, and `\r` for U+0008,
1103
- U+0009, U+000A, U+000C, and U+000D. Render every other UTF-16 code unit outside U+0020 through U+007E
1104
- as lowercase `\uXXXX`. A non-BMP code point therefore renders as its two surrogate units, and an
1105
- unpaired surrogate renders as its one unit. Leave slash unescaped. This covers C0, C1, DEL, U+0085,
1106
- U+2028, U+2029, and non-BMP input without a literal non-ASCII or control byte.
1107
-
1108
- An observable unequal value uses exactly:
1098
+ Require exit zero and one JSON object. `reason: null` passes the guard. A non-null `reason` is the
1099
+ complete already-rendered ASCII park reason, rendered with deterministic ASCII-only JSON strings, and is
1100
+ exactly one of:
1109
1101
 
1110
1102
  ```text
1111
1103
  publishing identity mismatch: declared <declared-ascii-json>, observed <observed-ascii-json>; authenticate as <declared-ascii-json> and retry.
1112
1104
  ```
1113
1105
 
1114
- An unobservable result uses exactly:
1115
-
1116
1106
  ```text
1117
1107
  publishing identity unobservable: declared <declared-ascii-json>; launch with inherited GH_TOKEN for <declared-ascii-json> as documented in OPERATING.md and retry.
1118
1108
  ```
1119
1109
 
1110
+ Use the returned `reason` exactly; do not re-render, trim, or edit it. Never run `gh` yourself for this
1111
+ guard, retry, fall back to stored authentication, or put a token in argv. A nonzero exit or output that is
1112
+ not that JSON object is a failed guard: perform no further operation and report only
1113
+ `Outcome: retained-lock-error`.
1114
+
1120
1115
  Never expose the token, raw stdout or stderr, diagnostics, status, command text, target, helper output,
1121
1116
  or environment. On either reason, quiesce every builder, tool, background task, and heartbeat call.
1122
- Bind `PRE_QUOTING_REASON` to the complete already-rendered ASCII reason. Encode it as one deterministic
1117
+ Bind `PRE_QUOTING_REASON` to the complete already-rendered ASCII reason the CLI returned. Encode it as one deterministic
1123
1118
  POSIX shell token by surrounding the complete reason with single quotes and replacing every literal
1124
1119
  `'` inside it with the exact shell sequence `'\''`. Use that encoded token as the sole `--reason`
1125
1120
  argument in the host shell command string:
@@ -2168,12 +2163,12 @@ this is not a claim that a target is unobservable. Use the same three exact reda
2168
2163
  from Step 0.
2169
2164
 
2170
2165
  With `DECLARED_PUBLISHING_IDENTITY`, immediately after exact target equality and before the unchanged
2171
- push, run the same ordinary host observation under its exact cwd, environment, no-stdin, direct-result,
2172
- validation, rendering, redaction, and parking rules. No operation may intervene between equality, this
2166
+ push, run the same identity guard command under its exact cwd, environment, no-stdin, verdict,
2167
+ redaction, and parking rules. No operation may intervene between equality, this
2173
2168
  guard, and the push:
2174
2169
 
2175
2170
  ```sh
2176
- gh api --method GET /user --jq .login
2171
+ factory identity "$R" --json --repo "$RUN_REPO"
2177
2172
  ```
2178
2173
 
2179
2174
  Publish the fully qualified recorded feature ref from `RUN_REPO`, run `gh` from `O` with that exact head
@@ -2183,7 +2178,7 @@ legacy local runs use `O` through the selection already made in Step 0:
2183
2178
 
2184
2179
  ```sh
2185
2180
  git -C "$RUN_REPO" push origin "refs/heads/$FEATURE_BRANCH:refs/heads/$FEATURE_BRANCH"
2186
- gh api --method GET /user --jq .login
2181
+ factory identity "$R" --json --repo "$RUN_REPO"
2187
2182
  (
2188
2183
  cd "$O"
2189
2184
  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";
@@ -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)]
@@ -1281,6 +1283,16 @@ const HANDLERS = {
1281
1283
  throw new CliError("factory lock requires <claim|steal|release>");
1282
1284
  },
1283
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
+
1284
1296
  async heartbeat([runId], flags) {
1285
1297
  const runDir = runDirFor(flags, runId);
1286
1298
  const owner = await refreshSessionLock(runDir, {
@@ -1727,7 +1739,9 @@ export async function dispatchInit(positional, flags, operations = INIT_OPERATIO
1727
1739
  proveContainedBranch();
1728
1740
  let run;
1729
1741
  try {
1730
- 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 });
1731
1745
  } catch (error) {
1732
1746
  throw new CliError(`final manifest validation failed for sandbox '${S}'; sandbox was retained`, { cause: error });
1733
1747
  }
@@ -1873,6 +1887,7 @@ function usage() {
1873
1887
  factory resume <run-id> --session ID [--now ISO]
1874
1888
  factory restore <run-id> --repo OPERATOR --from refs/remotes/REMOTE/BRANCH [--now ISO]
1875
1889
  factory snapshot <run-id> --repo OPERATOR
1890
+ factory identity <run-id> [--repo PATH] [--json]
1876
1891
  factory reverify-repair <run-id> <repair-record-id> [--repo PATH] [--now ISO] [--json]
1877
1892
  factory lock <run-id> <claim|steal|release> --session ID [--ttl-ms N]
1878
1893
  factory heartbeat <run-id> --session ID
@@ -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.4",
3
+ "version": "0.10.6",
4
4
  "description": "Durable, observed control plane for /feature runs. Host-agnostic: no opencode dependency.",
5
5
  "type": "module",
6
6
  "license": "MIT",