feature-factory 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/WORKFLOW.md +46 -20
- package/bin/factory.js +54 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -115,6 +115,8 @@ re-initialising the same run id. It is published by a staged, verified swap, so
|
|
|
115
115
|
snapshot. A failed snapshot is reported and never prevents the park. `blocked`
|
|
116
116
|
and `partial` are not snapshotted, and a snapshot is evidence for recovery rather than a resumable run.
|
|
117
117
|
|
|
118
|
+
Qualified status reports `park_snapshot` for a parked run: the published path, or `null` when no snapshot
|
|
119
|
+
exists. That is how an outside observer verifies the snapshot happened rather than assuming it.
|
|
118
120
|
Malformed config, malformed payload, a non-zero exit, or unavailable exit status refuses before any
|
|
119
121
|
run effect and never falls back:
|
|
120
122
|
|
package/WORKFLOW.md
CHANGED
|
@@ -99,36 +99,50 @@ must APPROVE before you accept that step. Story, research, and design are not au
|
|
|
99
99
|
|
|
100
100
|
Before any intake action, including ticket, story, or design detection, branch intent, run-id
|
|
101
101
|
derivation, manifest or state reads, and every `factory` command, process the raw invocation arguments
|
|
102
|
-
as follows.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
102
|
+
as follows.
|
|
103
|
+
|
|
104
|
+
**The platform skill owns invocation-option admission, and this workflow never parses the
|
|
105
|
+
invocation.** Each host skill defines the complete leading option prefix its `/feature` invocation
|
|
106
|
+
accepts -- placement, mode, and host options such as `--base` and `--max-retries` -- consumes exactly
|
|
107
|
+
those spans and their separators, and supplies this workflow two things: the **admitted mode tokens**,
|
|
108
|
+
which are the exact case-sensitive `--autonomous` and `--headless` tokens it consumed, and the
|
|
109
|
+
**admitted remainder**, the request bytes beginning at the first token it did not consume, preserved
|
|
110
|
+
unchanged. This workflow never decides where that prefix ends. Placement is not a run mode.
|
|
111
|
+
|
|
112
|
+
Defining a terminator here would contradict the skill that already applied one, and every option later
|
|
113
|
+
added to a host would become request content on this side only. That is not hypothetical. `--base` and
|
|
114
|
+
`--max-retries` were added to the host skills while this section still ended the prefix at the first
|
|
115
|
+
non-mode token, so the two documents disagreed over whether `--max-retries 5 <run-id>` was an option
|
|
116
|
+
pair or part of the run id. Read alone, this section silently derived the run id `max-retries-5-1606`;
|
|
117
|
+
read alongside the skill, a driver correctly refused to run at all. Both documents governing the same
|
|
118
|
+
bytes is the defect, so only one of them does.
|
|
119
|
+
|
|
120
|
+
The rules below are host-independent and are stated over the admitted mode tokens and the admitted
|
|
121
|
+
remainder.
|
|
122
|
+
|
|
123
|
+
1. If both distinct mode tokens were admitted, in either order, return exactly:
|
|
110
124
|
`conflicting mode flags: --autonomous and --headless; choose one`. Return immediately, before any
|
|
111
125
|
intake, run-id derivation, state read, or CLI action. Never fall back to interactive or another
|
|
112
126
|
mode.
|
|
113
|
-
2. Otherwise
|
|
114
|
-
|
|
115
|
-
run-id derivation.
|
|
127
|
+
2. Otherwise use only the unchanged admitted remainder for ticket detection, story content, design
|
|
128
|
+
detection, branch intent, and run-id derivation.
|
|
116
129
|
3. Apply exactly one mapping for a new manifest:
|
|
117
130
|
- `--autonomous` maps only to `factory init --mode autonomous`.
|
|
118
131
|
- `--headless` maps only to `factory init --mode headless`.
|
|
119
|
-
- With no
|
|
132
|
+
- With no admitted mode token, omit `--mode`; existing `factory init` records
|
|
120
133
|
`interactive`.
|
|
121
134
|
|
|
122
135
|
Those three compatibility phrases name init command stems, not runnable invocations. The selected
|
|
123
136
|
fresh-run invocation is fully qualified in Step 0 and ends with `--repo "$RUN_REPO"`.
|
|
124
137
|
|
|
125
|
-
Repeated copies of one
|
|
126
|
-
|
|
138
|
+
Repeated copies of one mode token are idempotent: the skill consumes them all and this workflow
|
|
139
|
+
selects that mode once. A mode token the skill did not admit, standing after the first request token,
|
|
140
|
+
is request content and neither selects nor conflicts.
|
|
127
141
|
Natural-language intent, `--interactive`, capitalization variants, abbreviations, assignment or
|
|
128
142
|
punctuation forms, quoted lookalikes, and near misses are request content, not selectors. Do not add a
|
|
129
143
|
generic malformed-option rejection.
|
|
130
144
|
|
|
131
|
-
After successful nonconflicting admission, reject an empty
|
|
145
|
+
After successful nonconflicting admission, reject an empty or whitespace-only admitted remainder
|
|
132
146
|
with exactly `missing /feature request; no run created.` This rejection and a mode conflict precede
|
|
133
147
|
run-id derivation and every tool, client, state, or CLI action.
|
|
134
148
|
|
|
@@ -148,12 +162,24 @@ resume; invocation flags do not select resumed behavior:
|
|
|
148
162
|
An inability to ask a human never promotes interactive or headless to autonomous.
|
|
149
163
|
|
|
150
164
|
Mode result needs-human means parked and explicitly resumable; only completed, partial, and blocked are final.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
165
|
+
**The parked stop is one ordered sequence, and every rule in this document that says a run parks enters
|
|
166
|
+
it.** Those rules name the cause; they do not restate the steps. Execute all three, in order, before
|
|
167
|
+
reporting anything:
|
|
168
|
+
|
|
169
|
+
1. Enter the parked stop with factory terminal R needs-human --reason TEXT; leave it only by explicit factory resume R --session $SESSION_ID --repo S, which refuses unless that session already holds a fresh lock: claim, then verify, then resume.
|
|
170
|
+
2. Immediately after recording a `needs-human` terminalization, and before reporting the park to the operator,
|
|
171
|
+
publish a parked control-plane snapshot exactly as *Parked control-plane snapshot* below requires. A park
|
|
172
|
+
is not reported until that snapshot is published or its failure is recorded in the report.
|
|
173
|
+
3. Report top-level needs-human as parked with its reason and explicit factory resume command.
|
|
174
|
+
|
|
175
|
+
A park that completes only step 1 is an unreported park with no recovery evidence, which is the state
|
|
176
|
+
this sequence exists to prevent. Verify step 2 the way an outside observer would: qualified status
|
|
177
|
+
reports `park_snapshot` as the published path, or `null` when no snapshot exists. Status reports the path
|
|
178
|
+
only while the snapshot is a complete copy of the live plane by the step 3 inventory and its manifest still
|
|
179
|
+
matches the live one byte for byte, so `null` also covers an interrupted or altered copy and a snapshot
|
|
180
|
+
left by an earlier park: neither is evidence for this park. Publishing again is what makes it correspond.
|
|
181
|
+
|
|
155
182
|
For top-level needs-human, status exposes the durable next action, but no command may execute it before explicit factory resume.
|
|
156
|
-
Report top-level needs-human as parked with its reason and explicit factory resume command.
|
|
157
183
|
Retain the sandbox for top-level needs-human while parked, then explicitly resume it after the external fix.
|
|
158
184
|
A park that asks a question about the request itself -- a contradiction between criteria, a scope lock,
|
|
159
185
|
or a pinned constraint -- is not fixed by resuming. Resume continues from the existing manifest and
|
package/bin/factory.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// The orchestrator calls this CLI instead of writing control-plane state directly.
|
|
5
5
|
// Flags are declared per command; unknown options fail rather than becoming missing fields.
|
|
6
6
|
// Schema validation surrounds every state write.
|
|
7
|
-
import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync } from "node:fs";
|
|
8
|
-
import { join, resolve } from "node:path";
|
|
7
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, realpathSync } from "node:fs";
|
|
8
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
9
9
|
import { pathToFileURL } from "node:url";
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
import { isDeepStrictEqual } from "node:util";
|
|
@@ -153,6 +153,57 @@ function briefDigestFor(decision, state, runDir) {
|
|
|
153
153
|
return state.plan_digest;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
// Observability, not enforcement: the copy stays a driver step, because this CLI is forbidden copy and
|
|
157
|
+
// delete primitives. What this answers has to be exact, and two earlier versions were not. "Does the
|
|
158
|
+
// pathname exist" reported a snapshot from an earlier park as this park's evidence. Matching only `run.json`
|
|
159
|
+
// then proved one file was copied after the current terminalization, not that the publication finished --
|
|
160
|
+
// a driver that created the directory and copied that file first, or an interrupted copy, still read as
|
|
161
|
+
// published. Both were false greens in the exact case this exists to catch, and both were caught in review.
|
|
162
|
+
// So compare the inventory the publication contract already defines, in the same terms it defines it:
|
|
163
|
+
// `.` and every descendant, each recording relative path, type, permission mode, SHA-256 for a regular
|
|
164
|
+
// file and link target for a symlink, sorted lexically by relative path, with unsupported entry types
|
|
165
|
+
// rejected. Two earlier versions of this comparison were narrower than the contract they claimed to check.
|
|
166
|
+
// Recording sizes rather than digests -- on the theory that hashing was too costly for a continuously
|
|
167
|
+
// polled command -- passed a same-length byte change and a mode-only change as faithful. Then walking into
|
|
168
|
+
// the root without recording it passed a snapshot whose own directory mode differed from the plane's. Both
|
|
169
|
+
// reported a tree that fails the contract's verification as published, while the documentation said altered
|
|
170
|
+
// trees yield `null`, and a signal that disagrees with its own description is the thing this whole change
|
|
171
|
+
// exists to remove. Hashing the plane costs about a millisecond; the cost theory was right and its
|
|
172
|
+
// conclusion was wrong. Both caught in review.
|
|
173
|
+
// Every path component is checked with `lstat` and never followed, since `lstat` on the final entry alone
|
|
174
|
+
// still follows intermediate symlinks.
|
|
175
|
+
function planeInventory(root) {
|
|
176
|
+
const entries = [];
|
|
177
|
+
const record = (rel, full) => {
|
|
178
|
+
const stat = lstatSync(full);
|
|
179
|
+
const mode = (stat.mode & 0o7777).toString(8);
|
|
180
|
+
if (stat.isSymbolicLink()) entries.push(`${rel} l ${mode} ${readlinkSync(full)}`);
|
|
181
|
+
else if (stat.isDirectory()) {
|
|
182
|
+
entries.push(`${rel} d ${mode}`);
|
|
183
|
+
for (const name of readdirSync(full)) record(rel === "." ? name : `${rel}/${name}`, join(full, name));
|
|
184
|
+
} else if (stat.isFile()) entries.push(`${rel} f ${mode} ${createHash("sha256").update(readFileSync(full)).digest("hex")}`);
|
|
185
|
+
else throw new CliError(`unsupported entry type in '${full}'`);
|
|
186
|
+
};
|
|
187
|
+
record(".", root);
|
|
188
|
+
return entries.sort().join("\n");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function observedParkSnapshot(repo, runId, runDir) {
|
|
192
|
+
const container = dirname(repo);
|
|
193
|
+
if (basename(container) !== ".factory-sandboxes" || basename(repo) !== runId) return null;
|
|
194
|
+
const operatorRoot = dirname(container);
|
|
195
|
+
const candidate = join(operatorRoot, CONTROL_PLANE, ".parked", runId);
|
|
196
|
+
try {
|
|
197
|
+
for (const component of [join(operatorRoot, CONTROL_PLANE), join(operatorRoot, CONTROL_PLANE, ".parked"), candidate]) {
|
|
198
|
+
if (!lstatSync(component).isDirectory()) return null;
|
|
199
|
+
}
|
|
200
|
+
if (planeInventory(candidate) !== planeInventory(runDir)) return null;
|
|
201
|
+
return readFileSync(join(candidate, "run.json")).equals(readFileSync(join(runDir, "run.json"))) ? candidate : null;
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
156
207
|
function runDirFor(flags, runId) {
|
|
157
208
|
if (!runId) throw new CliError("a <run-id> is required");
|
|
158
209
|
return join(resolve(flags.repo ?? process.cwd()), CONTROL_PLANE, runId);
|
|
@@ -903,6 +954,7 @@ const HANDLERS = {
|
|
|
903
954
|
branch: run.branch,
|
|
904
955
|
pr_base: run.pr_base ?? null,
|
|
905
956
|
publishing_identity: run.publishing_identity ?? null,
|
|
957
|
+
park_snapshot: run.status === "needs-human" ? observedParkSnapshot(resolve(flags.repo ?? process.cwd()), runId, runDir) : null,
|
|
906
958
|
pr_draft: run.pr_draft ?? true,
|
|
907
959
|
lock: lock.state, dead_lock: run.status === "running" && lock.state === "stale",
|
|
908
960
|
lock_session: lock.owner?.session ?? null,
|