feature-factory 0.7.4 → 0.8.0
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 +21 -11
- package/WORKFLOW.md +33 -15
- package/bin/factory.js +19 -1
- package/observe/repository-config.js +6 -1
- package/package.json +2 -2
- package/state/schema.js +5 -1
package/README.md
CHANGED
|
@@ -30,7 +30,6 @@ resolved Git top level:
|
|
|
30
30
|
"resolve": "<non-empty shell command>",
|
|
31
31
|
"verify": "<non-empty shell command>",
|
|
32
32
|
"publish": "<non-empty shell command>",
|
|
33
|
-
"publishing_identity": "<non-empty account name>",
|
|
34
33
|
"pr_draft": true,
|
|
35
34
|
"verify_timeout_ms": 900000,
|
|
36
35
|
"bootstrap": "<non-empty shell command>",
|
|
@@ -38,15 +37,25 @@ resolved Git top level:
|
|
|
38
37
|
}
|
|
39
38
|
```
|
|
40
39
|
|
|
41
|
-
The root has
|
|
40
|
+
The root has three required properties and four optional properties: `pr_draft`, `verify_timeout_ms`,
|
|
42
41
|
`bootstrap`, and `bootstrap_timeout_ms`. `resolve`, `verify`, `publish`, and a present `bootstrap` are non-empty command
|
|
43
|
-
strings. `publishing_identity`
|
|
44
|
-
|
|
42
|
+
strings. There is no `publishing_identity` key, and a file carrying one is malformed because the
|
|
43
|
+
optional set is closed. A present `pr_draft` must be a JSON boolean and omission means `true`. Both timeouts are
|
|
45
44
|
positive safe integers. `bootstrap_timeout_ms` requires `bootstrap`.
|
|
46
45
|
Each omitted timeout independently defaults to `900000`; neither shares the other's budget. The file is
|
|
47
46
|
operator-owned, committed, and protected as a privileged path: a run cannot create, write, merge,
|
|
48
47
|
archive, package, or repair it.
|
|
49
48
|
|
|
49
|
+
The publishing identity is not a config key. `factory init` resolves it from `--publishing-identity
|
|
50
|
+
<account>` or the inherited `FACTORY_PUBLISHING_IDENTITY`, refuses when neither supplies at least one
|
|
51
|
+
character, and records the resolved value immutably in `run.json`, where `status --json` reports it as
|
|
52
|
+
`publishing_identity`. The account a run publishes as is a property of the environment it runs in, not of
|
|
53
|
+
the repository, and a tracked file cannot hold two values for one repository published from both a
|
|
54
|
+
maintainer's checkout and an automated host. Absence refuses rather than skipping the guard, so a forgotten
|
|
55
|
+
value stops the run instead of publishing under whatever credential the host happens to carry. Never derive
|
|
56
|
+
it from `gh`, the token, stored authentication, or Git configuration: an expectation read from the
|
|
57
|
+
credential being checked would always match.
|
|
58
|
+
|
|
50
59
|
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`; then missing or invalid required entries.
|
|
51
60
|
|
|
52
61
|
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`.
|
|
@@ -200,18 +209,19 @@ own the same path. Duplicate, target-already-owned, malformed, privileged, repla
|
|
|
200
209
|
requests refuse atomically. Resume never amends or reseeds. A merge continues to refuse every unamended
|
|
201
210
|
or privileged changed path.
|
|
202
211
|
|
|
203
|
-
`resolve
|
|
212
|
+
`resolve` and `verify` are consumed now, and the run's recorded `publishing_identity` is compared at the publication guards. Configured `publish` remains unconsumed and is not invoked.
|
|
204
213
|
Effective push-target capture and comparison are active through the package-owned `factory effective-push` command; they are not deferred to configured `publish`.
|
|
205
|
-
The
|
|
206
|
-
case-folding, or reserialization.
|
|
207
|
-
|
|
214
|
+
The recorded `publishing_identity` is read from `status` exactly as reported, without trimming,
|
|
215
|
+
normalization, case-folding, or reserialization. `init` refuses when neither the flag nor the environment
|
|
216
|
+
supplies at least one character, so a created run always carries one; only a manifest written before 0.8.0
|
|
217
|
+
can report `null`. `publishing_identity` is a recorded run field reported by `status`, resolved by `init` from a flag or the environment. The independent `factory effective-push` command adds no state or flag.
|
|
208
218
|
|
|
209
|
-
With a
|
|
219
|
+
With a recorded identity, every mode checks it at exactly three boundaries: immediately
|
|
210
220
|
after verified post-lock ownership, or immediately after an explicit resume is verified running with
|
|
211
221
|
the same fresh owner and before reconciliation or other work; immediately before `git push`, after
|
|
212
222
|
effective push-target equality; and immediately before `gh pr create`, after the push is known
|
|
213
|
-
successful. No operation intervenes across a guard boundary.
|
|
214
|
-
|
|
223
|
+
successful. No operation intervenes across a guard boundary. Only a manifest written before 0.8.0, which can
|
|
224
|
+
report `null`, skips all three guards; an absent config does not affect them.
|
|
215
225
|
|
|
216
226
|
Before each guard, inherited `GH_TOKEN` must exist and contain at least one character. Missing or empty
|
|
217
227
|
means identity is unobservable without invoking `gh`, the network, stored authentication, credential
|
package/WORKFLOW.md
CHANGED
|
@@ -243,7 +243,6 @@ The optional repository-owned file is `$O/.factory.json`:
|
|
|
243
243
|
"resolve": "<non-empty shell command>",
|
|
244
244
|
"verify": "<non-empty shell command>",
|
|
245
245
|
"publish": "<non-empty shell command>",
|
|
246
|
-
"publishing_identity": "<non-empty account name>",
|
|
247
246
|
"pr_draft": true,
|
|
248
247
|
"verify_timeout_ms": 900000,
|
|
249
248
|
"bootstrap": "<non-empty shell command>",
|
|
@@ -251,11 +250,13 @@ The optional repository-owned file is `$O/.factory.json`:
|
|
|
251
250
|
}
|
|
252
251
|
```
|
|
253
252
|
|
|
254
|
-
The root must be a JSON object with the
|
|
255
|
-
|
|
253
|
+
The root must be a JSON object with the three required own properties `resolve`, `verify`, and `publish`,
|
|
254
|
+
plus only the optional own properties `pr_draft`, `verify_timeout_ms`, `bootstrap`, and
|
|
256
255
|
`bootstrap_timeout_ms`. `resolve`, `verify`, `publish`, and `bootstrap` are command strings; every present
|
|
257
|
-
command must be non-empty. `publishing_identity`
|
|
258
|
-
|
|
256
|
+
command must be non-empty. There is no `publishing_identity` key: the account a run publishes as is a
|
|
257
|
+
property of the environment it runs in, not of the repository, and a tracked file cannot hold two values
|
|
258
|
+
for one repository published from both a maintainer's checkout and an automated host. A file carrying that
|
|
259
|
+
key is malformed, because the optional set above is closed. `pr_draft` must be a JSON boolean
|
|
259
260
|
when present and defaults to `true` when absent. Both timeout values must be positive
|
|
260
261
|
safe integers when present, and `bootstrap_timeout_ms` is valid only with a declared `bootstrap`.
|
|
261
262
|
`verify_timeout_ms` and `bootstrap_timeout_ms` each independently default to `900000` milliseconds;
|
|
@@ -270,15 +271,31 @@ The two bootstrap keys are known keys. Invalid `bootstrap` outranks missing requ
|
|
|
270
271
|
timeout defect, including an invalid or otherwise orphaned bootstrap timeout. An orphaned
|
|
271
272
|
`bootstrap_timeout_ms` outranks its own invalid shape, and a valid bootstrap with an invalid timeout
|
|
272
273
|
names only `bootstrap_timeout_ms`. Validate this order before executing `resolve`.
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
274
|
+
The publishing identity is not read from this file and not resolved by the driver. `factory init` resolves
|
|
275
|
+
it in code -- `--publishing-identity <account>` when passed, otherwise the inherited
|
|
276
|
+
`FACTORY_PUBLISHING_IDENTITY` -- and refuses when neither supplies at least one character, creating no
|
|
277
|
+
sandbox and no run. It records the resolved value immutably in `run.json`, and `status --json` reports it
|
|
278
|
+
as `publishing_identity`. Bind `DECLARED_PUBLISHING_IDENTITY` from that reported value exactly as
|
|
279
|
+
reported, without trimming, normalizing, case-folding, or reserializing it, and never re-resolve it from
|
|
280
|
+
the environment, a file, or anything else.
|
|
281
|
+
Do not tighten the existing non-whitespace validation to the observed-login grammar: `init` requires only
|
|
282
|
+
that the value contain at least one character, and a declared account name that the observed-login grammar
|
|
283
|
+
would reject is still a legitimate declaration to compare against.
|
|
284
|
+
Never pass `--publishing-identity` from this workflow. The driver has no source for the value -- that is
|
|
285
|
+
the point of resolving it in the CLI -- so the flag would be constructed from an unbound shell variable,
|
|
286
|
+
expand to an empty argument, and be indistinguishable from an operator supplying one. `init` reads the
|
|
287
|
+
inherited environment itself. The flag exists for an explicit human or scripted invocation that has a value
|
|
288
|
+
to state.
|
|
289
|
+
Because init refuses without one, every run created at or after 0.8.0 carries a nonempty value; a manifest
|
|
290
|
+
written earlier may report `null`, which is the only case that skips the publishing-identity guards.
|
|
291
|
+
Never derive this value from `gh`, the token, stored authentication, or Git configuration: an expectation
|
|
292
|
+
read from the credential being checked would always match, and the guard would stop guarding.
|
|
276
293
|
Credential values must not appear in the file; command strings may refer only to credentials supplied
|
|
277
294
|
through inherited environment-variable names.
|
|
278
295
|
|
|
279
|
-
An absent `$O/.factory.json` means no resolver is declared
|
|
280
|
-
the
|
|
281
|
-
|
|
296
|
+
An absent `$O/.factory.json` means no resolver is declared, per the absence rule below. It says nothing
|
|
297
|
+
about the publishing identity, which comes from `init` rather than from this file, so a repository with no
|
|
298
|
+
config file still carries a recorded identity and still runs every publishing-identity guard. If the path is present but malformed, do not execute any entry
|
|
282
299
|
and refuse exactly:
|
|
283
300
|
|
|
284
301
|
> invalid factory config: .factory.json; no session or run created.
|
|
@@ -394,7 +411,8 @@ init and explicit resume. Neither applies to `resolve`, slice observation, or Ga
|
|
|
394
411
|
change platform placement, background-tool, title-association, host-session, or publication behavior.
|
|
395
412
|
`story-reader` remains lookup-free and capability-free beyond its existing generic read tools.
|
|
396
413
|
|
|
397
|
-
`resolve
|
|
414
|
+
`resolve` and `verify` are consumed now, and the run's recorded `publishing_identity` is compared at the
|
|
415
|
+
guards below. Configured `publish` remains unconsumed and is not invoked.
|
|
398
416
|
|
|
399
417
|
Configured `bootstrap` is consumed only by CLI-owned fresh init and explicit resume; the workflow consumer validates it but never executes it itself.
|
|
400
418
|
|
|
@@ -405,7 +423,7 @@ Effective push-target capture and comparison are active through the package-owne
|
|
|
405
423
|
| `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. |
|
|
406
424
|
| `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. |
|
|
407
425
|
| `publish` | Future ordinary shell step in repository-root cwd with inherited environment; no structured stdin or factory-specific payload is defined | Exit status is authoritative; stdout is informational and unparsed | Zero means the command reported success; non-zero means it reported failure | Not invoked. Existing `git push`, `gh pr create`, and `factory pr` behavior remains unchanged; effective push-target equality is enforced separately by <code>factory effective-push</code>. |
|
|
408
|
-
| `publishing_identity` | No runtime input;
|
|
426
|
+
| `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. |
|
|
409
427
|
|
|
410
428
|
When both bootstrap keys are absent, init and resume are exact no-ops for bootstrap: no execution, manifest fields, output, or response-shape change.
|
|
411
429
|
|
|
@@ -498,10 +516,10 @@ path not disclosed and verified for this recovery. Without a path omission, skip
|
|
|
498
516
|
In either case order 7 remains the same explicit resume command; the resume command never amends paths,
|
|
499
517
|
changes `test_plan`, or reseeds the plan.
|
|
500
518
|
|
|
501
|
-
When
|
|
519
|
+
When the run reports a nonempty `publishing_identity`, the mandatory guard below is the exact
|
|
502
520
|
boundary between completion of resume order 7 and the first operation in resume order 8. Nothing may
|
|
503
521
|
intervene between the verified running/same-owner result and that guard, or between a successful guard
|
|
504
|
-
and reconciliation.
|
|
522
|
+
and reconciliation. A pre-0.8.0 manifest reporting `null` preserves the nine orders without adding an operation.
|
|
505
523
|
|
|
506
524
|
For order 1 require the intended run ID, a valid manifest, recorded branch and mode, current parked status, and the original terminal result. Order 2 stays after selection and containment and before effective-push proof. Order 3 never absorbs containment, binding, or the post-selection exact-ref guard. During order 4 preserve every existing exact-ref recheck and the stated provenance sequence. No unrelated observation or effect occurs between order 5 and claim or justified steal. Order 6 requires `lock_session === SESSION_ID`, a fresh lock, unchanged parked status, and a terminal result deeply equal to the one first observed. Invoke `factory resume "$R" --session "$SESSION_ID" --repo "$RUN_REPO"` for order 7 — the same session order 6 just verified as the fresh owner — then require that owner unchanged. Resume refuses without it, and refuses a lock that is absent, stale, or held by anyone else. Order 8 may replay only the existing recorded-merge reconciliation path and must not move pre-lock proofs across the lock boundary. Order 9 never uses the pre-resume observation or the stop reason.
|
|
507
525
|
|
package/bin/factory.js
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
} from "../state/session-lock.js";
|
|
28
28
|
|
|
29
29
|
export const COMMANDS = Object.freeze({
|
|
30
|
-
init: Object.freeze(["--repo", "--branch", "--worktree", "--pr-base", "--issue", "--issue-key", "--mode", "--max-parallel-slices", "--max-retries", "--now", "--json"]),
|
|
30
|
+
init: Object.freeze(["--repo", "--branch", "--worktree", "--pr-base", "--issue", "--issue-key", "--publishing-identity", "--mode", "--max-parallel-slices", "--max-retries", "--now", "--json"]),
|
|
31
31
|
status: Object.freeze(["--repo", "--json"]),
|
|
32
32
|
"amend-paths": Object.freeze(["--repo", "--add", "--reason", "--session", "--now", "--json"]),
|
|
33
33
|
resume: Object.freeze(["--repo", "--session", "--now", "--json"]),
|
|
@@ -902,6 +902,7 @@ const HANDLERS = {
|
|
|
902
902
|
mode: run.mode,
|
|
903
903
|
branch: run.branch,
|
|
904
904
|
pr_base: run.pr_base ?? null,
|
|
905
|
+
publishing_identity: run.publishing_identity ?? null,
|
|
905
906
|
pr_draft: run.pr_draft ?? true,
|
|
906
907
|
lock: lock.state, dead_lock: run.status === "running" && lock.state === "stale",
|
|
907
908
|
lock_session: lock.owner?.session ?? null,
|
|
@@ -1372,6 +1373,22 @@ function preflightInit(positional, flags) {
|
|
|
1372
1373
|
// the flag, so a run that had read its real issue recorded none. Disagreement refuses, because the key is
|
|
1373
1374
|
// appended as `Closes #<key>` and preferring one silently would close a stranger's issue.
|
|
1374
1375
|
if (flags.issue !== undefined && flags.issueKey !== undefined && flags.issue !== flags.issueKey) throw refusal("--issue and --issue-key disagree; pass one");
|
|
1376
|
+
// Enforcement: the publishing identity is the expectation every publication guard compares the observed
|
|
1377
|
+
// `gh api /user` login against, so a run without one publishes unchecked. It used to be a required key in
|
|
1378
|
+
// the checked-in `.factory.json`, which cannot hold two values for one repository published from two
|
|
1379
|
+
// environments -- 0.7.5 tried an environment override read by the driver, and the driver never looked.
|
|
1380
|
+
// Resolved here instead, in code, so no agent has to comply: the flag wins, then the environment.
|
|
1381
|
+
// Absence refuses rather than skipping the guard, because a forgotten value must not silently publish
|
|
1382
|
+
// as whatever credential the host happens to carry.
|
|
1383
|
+
// `??` was wrong here: it falls through only on nullish, so `--publishing-identity ""` -- what an unbound
|
|
1384
|
+
// shell variable expands to -- would mask a perfectly good inherited value and refuse the run. Both
|
|
1385
|
+
// sources are held to the same rule instead: a source counts only if it carries at least one character,
|
|
1386
|
+
// and the flag wins over the environment only when it actually supplies one.
|
|
1387
|
+
const publishingIdentity = [flags.publishingIdentity, process.env.FACTORY_PUBLISHING_IDENTITY]
|
|
1388
|
+
.find((candidate) => typeof candidate === "string" && candidate.length > 0);
|
|
1389
|
+
if (publishingIdentity === undefined) {
|
|
1390
|
+
throw refusal("factory init requires a publishing identity; pass --publishing-identity <account> or set FACTORY_PUBLISHING_IDENTITY");
|
|
1391
|
+
}
|
|
1375
1392
|
const runId = positional[0];
|
|
1376
1393
|
try {
|
|
1377
1394
|
const at = stamp(flags);
|
|
@@ -1379,6 +1396,7 @@ function preflightInit(positional, flags) {
|
|
|
1379
1396
|
version: SCHEMA_VERSION,
|
|
1380
1397
|
run_id: runId,
|
|
1381
1398
|
issue_key: flags.issue ?? flags.issueKey ?? null,
|
|
1399
|
+
publishing_identity: publishingIdentity,
|
|
1382
1400
|
branch: flags.branch ?? `feature/${runId}`,
|
|
1383
1401
|
worktree: flags.worktree ?? ".",
|
|
1384
1402
|
pr_base: flags.prBase ?? null,
|
|
@@ -11,7 +11,12 @@ export function parseRepositoryConfig(bytes) {
|
|
|
11
11
|
} catch {
|
|
12
12
|
throw new RepositoryConfigError("invalid .factory.json");
|
|
13
13
|
}
|
|
14
|
-
|
|
14
|
+
// 0.8.0 dropped `publishing_identity`: the account a run publishes as belongs to the environment it runs
|
|
15
|
+
// in, not to a tracked file that can hold one value for a repository published from two of them. It is
|
|
16
|
+
// resolved by `init` from a flag or the environment and recorded in `run.json`. The allowed set below is
|
|
17
|
+
// closed, so a file still carrying the key is malformed rather than silently ignored -- which is what
|
|
18
|
+
// makes the removal visible to whoever has to edit it.
|
|
19
|
+
const requiredKeys = ["publish", "resolve", "verify"];
|
|
15
20
|
const allowedKeys = [...requiredKeys, "pr_draft", "verify_timeout_ms", "bootstrap", "bootstrap_timeout_ms"];
|
|
16
21
|
if (!config || typeof config !== "object" || Array.isArray(config)
|
|
17
22
|
|| Object.keys(config).some((keyName) => !allowedKeys.includes(keyName))) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feature-factory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Durable, observed control plane for /feature runs. Host-agnostic: no opencode dependency.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,6 +30,6 @@
|
|
|
30
30
|
"node": ">=22"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
|
-
"test": "node --import ../../tools/resolve-guard.mjs --test test/*.test.js"
|
|
33
|
+
"test": "FACTORY_PUBLISHING_IDENTITY=factory-test node --import ../../tools/resolve-guard.mjs --test test/*.test.js"
|
|
34
34
|
}
|
|
35
35
|
}
|
package/state/schema.js
CHANGED
|
@@ -15,6 +15,10 @@ export const RUN_KEYS = Object.freeze([
|
|
|
15
15
|
"version", "run_id", "issue_key", "branch", "worktree", "pr_base", "pr_draft", "created_at", "updated_at",
|
|
16
16
|
"status", "mode", "max_parallel_slices", "max_retries",
|
|
17
17
|
"gates", "steps", "slices", "validator", "terminal_result", "pr_url",
|
|
18
|
+
// Recorded at init from the flag or the environment, never from a checked-in file: the account
|
|
19
|
+
// a run is expected to publish as is a property of where it runs, not of the repository. Absent
|
|
20
|
+
// only in manifests written before 0.8.0, which still read.
|
|
21
|
+
"publishing_identity",
|
|
18
22
|
// Digest of the plan bytes the brief gate approved, so the seed ratifies that plan and not a
|
|
19
23
|
// later edit of the same filename. See the check in `slices-seed`.
|
|
20
24
|
"plan_digest",
|
|
@@ -109,7 +113,7 @@ export function validateRun(run) {
|
|
|
109
113
|
for (const key of ["branch", "worktree"]) required(errors, run, key, "run");
|
|
110
114
|
for (const key of ["created_at", "updated_at"]) pattern(errors, run, key, ISO, "run");
|
|
111
115
|
for (const key of ["max_parallel_slices", "max_retries"]) positiveInt(errors, run, key, "run");
|
|
112
|
-
for (const key of ["issue_key", "pr_base", "pr_url", "plan_digest"]) optionalString(errors, run, key, "run");
|
|
116
|
+
for (const key of ["issue_key", "pr_base", "pr_url", "plan_digest", "publishing_identity"]) optionalString(errors, run, key, "run");
|
|
113
117
|
if (Object.hasOwn(run, "pr_draft") && typeof run.pr_draft !== "boolean") {
|
|
114
118
|
errors.push({ path: "run.pr_draft", message: "must be a boolean" });
|
|
115
119
|
}
|