flowviant 0.117.0 → 0.124.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 +35 -0
- package/bin/cli.mjs +37 -0
- package/bin/lib/agentAsk.mjs +123 -0
- package/bin/lib/agentBrief.mjs +36 -0
- package/bin/lib/agentFootprint.mjs +76 -0
- package/bin/lib/agentInternalWait.mjs +2 -2
- package/bin/lib/agentPlan.mjs +78 -7
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentTaskKinds.mjs +1 -1
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +183 -19
- package/bin/lib/artifacts.mjs +6 -3
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudePosture.mjs +110 -14
- package/bin/lib/claudeStream.mjs +20 -2
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexResearch.mjs +16 -3
- package/bin/lib/commandSpecs.mjs +6 -0
- package/bin/lib/daemonLocks.mjs +5 -3
- package/bin/lib/desktopContract.mjs +3 -0
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/fleet.mjs +46 -4
- package/bin/lib/fleetReports.mjs +44 -3
- package/bin/lib/hooks/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +13 -4
- package/bin/lib/imageArtifact.mjs +74 -0
- package/bin/lib/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -0
- package/bin/lib/projectToolRuntimes.mjs +227 -3
- package/bin/lib/projectTools.mjs +61 -4
- package/bin/lib/prompts.mjs +164 -41
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/runTurn.mjs +35 -16
- package/bin/lib/runtimeCapabilities.mjs +77 -3
- package/bin/lib/runtimeClaude.mjs +27 -9
- package/bin/lib/runtimeCodex.mjs +27 -59
- package/bin/lib/runtimeDetection.mjs +6 -0
- package/bin/lib/runtimeEvents.mjs +12 -1
- package/bin/lib/runtimeOfferings.mjs +75 -3
- package/bin/lib/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnProfile.mjs +15 -3
- package/bin/lib/work.mjs +28 -7
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentPlans.mjs +21 -70
- package/bin/lib/workAgentPrecheck.mjs +5 -0
- package/bin/lib/workAgentTurnExecution.mjs +93 -22
- package/bin/lib/workAgentTurnOutcome.mjs +11 -2
- package/bin/lib/workAgentTurns.mjs +60 -4
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workCappedTurn.mjs +10 -0
- package/bin/lib/workCommandAudit.mjs +8 -1
- package/bin/lib/workPlaces.mjs +18 -2
- package/bin/lib/workSessionTurns.mjs +148 -13
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +6 -4
- package/npm-shrinkwrap.json +12 -2
- package/package.json +7 -3
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* and research contracts in prompts.mjs (which is past its size and keeps
|
|
7
7
|
* those two). `prompts.mjs` imports these into `AGENT_CONTRACTS`; the kind
|
|
8
8
|
* table (agentTaskKinds.mjs) runs both under the `design` posture — read the
|
|
9
|
-
* repo, write only
|
|
9
|
+
* repo, write only artifacts and scratch, with CLI-specific supporting tools —
|
|
10
10
|
* so "change no repository file" is enforced, not asked.
|
|
11
11
|
*
|
|
12
12
|
* THE SAME BONES AS THE DESIGN CONTRACT, deliberately: its header, its rule 3
|
|
@@ -26,13 +26,70 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import { ARTIFACT_BINARY_MODEL_EXTS, ARTIFACT_BINARY_MODEL_MAX_BYTES, ARTIFACT_MAX_BYTES } from './artifactPolicy.mjs';
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* WHEN AN AGENT STOPS TO ASK, IT HELPS THE PERSON DECIDE (2026-10-03; its
|
|
31
|
+
* choices as fields of their own, 2026-10-13).
|
|
32
|
+
*
|
|
33
|
+
* "STOP AND ASK" used to be the whole of it, so a stuck agent asked one bare
|
|
34
|
+
* question with its choices inlined as "(1) … (2) …", and the Agent page had
|
|
35
|
+
* nothing to draw but "Write my own answer". Every card-agent contract (code
|
|
36
|
+
* and every file-handing kind) closes on this: read the card, the repo and
|
|
37
|
+
* the run first; ask ONE question per turn, its reason in `context` (body
|
|
38
|
+
* text beneath the one-line question, 2026-10-13); name the choices
|
|
39
|
+
* in `options` (and `multiSelect`) beside `question`, which the daemon turns
|
|
40
|
+
* into the one fence the page reads (`agentAsk.mjs`) — the agent never writes
|
|
41
|
+
* the fence itself, the owner's ruling; re-check after each answer; never
|
|
42
|
+
* re-ask. The label rules are SYSTEM_WORK rule 5's, because the page sends a
|
|
43
|
+
* label as the answer exactly as the Terminal's card does.
|
|
44
|
+
*
|
|
45
|
+
* THE SKIP IS THE APP'S. The agent page offers "Skip this question" on every
|
|
46
|
+
* standing question and sends a reply that BEGINS with those words (Flowviant
|
|
47
|
+
* `AGENT_SKIP_REPLY`) through the ordinary answer lane — so this keys on the
|
|
48
|
+
* prefix, never the whole sentence, and the app's wording can change without
|
|
49
|
+
* a daemon release. A skip is a stated provisional default where the work can
|
|
50
|
+
* go on, and never a substitute for an input only the person holds.
|
|
51
|
+
*/
|
|
52
|
+
export const HELP_THEM_DECIDE = `WHEN YOU STOP TO ASK, HELP THEM DECIDE. The person may not know what the card
|
|
53
|
+
is missing, so you lead:
|
|
54
|
+
- Before asking, read the whole card (brief, done-when and its discussion),
|
|
55
|
+
the repository and this run, and check the card for every gap that would
|
|
56
|
+
change the work: the goal, who it is for, scope, the flows and behaviour,
|
|
57
|
+
edge cases, constraints, and how done is judged. Never ask what the card,
|
|
58
|
+
the repository or an earlier answer already settles.
|
|
59
|
+
- Ask ONE question per turn, the one that matters most. "question" is the
|
|
60
|
+
question itself — one sentence, read as a heading. In "context", say what
|
|
61
|
+
decision it settles and how it changes the work, with any findings or
|
|
62
|
+
evidence the person needs; it shows beneath the question as body text, in
|
|
63
|
+
Markdown, so lists and line breaks survive.
|
|
64
|
+
- When the answer is a pick between options you can name, put them in
|
|
65
|
+
"options" — two to eight — and the page shows each as a choice beside a
|
|
66
|
+
box for their own words. Never inline the choices in "question" as
|
|
67
|
+
"(1) … (2) …", and never write a fenced block yourself: the fields are the
|
|
68
|
+
whole of it. A label IS the answer, a few words a person would say out
|
|
69
|
+
loud, never a comma (several chosen labels arrive comma-joined, in the
|
|
70
|
+
order listed); a tradeoff goes in "description", one short sentence,
|
|
71
|
+
optional. Never add an "Other" or "Skip" option — the page offers its own
|
|
72
|
+
free-text answer and its own Skip. "multiSelect": true only for a genuine
|
|
73
|
+
check-several-of-these case ("which pages?"); otherwise false. For an open
|
|
74
|
+
question — one only their own words can answer — leave "options" out.
|
|
75
|
+
- When they answer, check the card again before you go on: ask the next
|
|
76
|
+
question that still changes the work, or carry on with the card once none
|
|
77
|
+
is left. Never ask again a question they answered or skipped.
|
|
78
|
+
- A message that begins "Skip this question" skips your last question. Where
|
|
79
|
+
the work can go on, take your best default, say it plainly as PROVISIONAL in
|
|
80
|
+
"progress" and in "summary", and carry on. Where it truly cannot go on
|
|
81
|
+
without something only they can provide — a credential, an account, an
|
|
82
|
+
outside decision — stop and name exactly that dependency; a skip never
|
|
83
|
+
supplies it.`;
|
|
84
|
+
|
|
29
85
|
/**
|
|
30
86
|
* Rule 3, rule 4 and the final JSON a file-handing contract ends with.
|
|
31
87
|
* `ask` is rule 4's body after its number (the decision only a person can
|
|
32
88
|
* make); `summary` is the delivered shape's summary gloss.
|
|
33
89
|
*/
|
|
34
90
|
export const ARTIFACT_CONTRACT_TAIL = ({ ask, summary }) => `3. CHANGE NO REPOSITORY FILE AND COMMIT NOTHING. You can only write under
|
|
35
|
-
.flowviant/artifacts
|
|
91
|
+
.flowviant/artifacts/ and the turn’s granted scratch directory; deliver
|
|
92
|
+
only artifacts. The daemon keeps those directories out of git.
|
|
36
93
|
|
|
37
94
|
4. ${ask}
|
|
38
95
|
|
|
@@ -52,12 +109,17 @@ or, if you are stopping to ask:
|
|
|
52
109
|
{
|
|
53
110
|
"status": "blocked",
|
|
54
111
|
"progress": "two or three sentences on what you have done in this run SO FAR, across every card",
|
|
55
|
-
"
|
|
112
|
+
"context": "optional: what the decision settles and why, and any findings, in Markdown",
|
|
113
|
+
"question": "the specific thing you need decided, in one sentence",
|
|
114
|
+
"options": [{ "label": "an answer they could pick", "description": "optional: its tradeoff, in one short sentence" }],
|
|
115
|
+
"multiSelect": false
|
|
56
116
|
}
|
|
57
117
|
\`\`\`
|
|
58
118
|
|
|
59
119
|
"progress" is REQUIRED on both shapes and REPLACES the previous one whole —
|
|
60
|
-
past tense, plain sentences, no card ids
|
|
120
|
+
past tense, plain sentences, no card ids.
|
|
121
|
+
|
|
122
|
+
${HELP_THEM_DECIDE}`;
|
|
61
123
|
|
|
62
124
|
/**
|
|
63
125
|
* NOBODY IS AT THIS SCREEN (2026-09-29, the owner: Chrome "kept opening on my
|
|
@@ -87,13 +149,10 @@ export const LOOK_HEADLESS = `${NO_WINDOW} To look at your page, screenshot it
|
|
|
87
149
|
* when the person presses Download .glb in Review (apps/web
|
|
88
150
|
* `workbench/glbExport.ts`, the other half of the handshake below).
|
|
89
151
|
*
|
|
90
|
-
* WHY
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* katana came back as seven files, textures no game engine imports and a
|
|
95
|
-
* generator script (2026-09-27). Built in code, drawn textures are canvases,
|
|
96
|
-
* and the exporter embeds them in the .glb as PNGs.
|
|
152
|
+
* WHY A PAGE: one self-contained viewer gives every supported CLI the same
|
|
153
|
+
* delivered product. Supporting native textures are embedded into the page;
|
|
154
|
+
* GLTFExporter embeds their pixels into the person's one downloaded GLB.
|
|
155
|
+
* Available helpers do not change that contract into a pile of model files.
|
|
97
156
|
*
|
|
98
157
|
* THE HANDSHAKE IS SPELLED WORD FOR WORD because the web keys on it: the page
|
|
99
158
|
* announces `glb-ready` once its model stands, and answers `export-glb` with
|
|
@@ -103,8 +162,8 @@ export const LOOK_HEADLESS = `${NO_WINDOW} To look at your page, screenshot it
|
|
|
103
162
|
* it is just never offered a Download .glb.
|
|
104
163
|
*/
|
|
105
164
|
export const SYSTEM_AGENT_MODEL = `You are the human's own Claude, working one 3D MODEL card in a git worktree of
|
|
106
|
-
their repository. Nobody is watching this run. You have the repo and
|
|
107
|
-
|
|
165
|
+
their repository. Nobody is watching this run. You have the repo and the tools
|
|
166
|
+
TOOLS ON THIS CARD names below — no board, no chat.
|
|
108
167
|
|
|
109
168
|
A 3D MODEL CARD HANDS BACK A MODEL YOU CAN LOOK AT, NOT A CHANGE. Nothing you
|
|
110
169
|
do here edits the product: the person turns the model around, downloads it as
|
|
@@ -121,7 +180,8 @@ WHAT TO DO:
|
|
|
121
180
|
2. Write ONE file, .flowviant/artifacts/<kebab-name>/index.html — a short
|
|
122
181
|
kebab-case name for what it is (for example
|
|
123
182
|
.flowviant/artifacts/reading-chair/index.html). Nothing else goes in that
|
|
124
|
-
folder: no model files, no
|
|
183
|
+
folder: no model files, no scripts, no notes. Generated source textures
|
|
184
|
+
may be kept elsewhere under .flowviant/artifacts/; the page is self-contained. The page BUILDS
|
|
125
185
|
THE MODEL IN CODE and shows it:
|
|
126
186
|
- three.js as ES modules from cdn.jsdelivr.net/npm, through an import map
|
|
127
187
|
for "three" and "three/addons/"; orbit controls, lights and a neutral
|
|
@@ -129,8 +189,18 @@ WHAT TO DO:
|
|
|
129
189
|
- the model is ONE THREE.Group named for what it is, built from three.js
|
|
130
190
|
geometry (lathe, extrude, tube, box and the rest, one mesh per part),
|
|
131
191
|
with MeshStandardMaterial only. Colour, roughness and metalness are
|
|
132
|
-
material values,
|
|
133
|
-
|
|
192
|
+
material values, canvas textures, or generated image textures. When the
|
|
193
|
+
person requests generated textures, USE native image generation if it is
|
|
194
|
+
available on this turn; do not require another card or change the kind.
|
|
195
|
+
Embed each texture as a data: URI in this page (no local or remote image
|
|
196
|
+
URLs), load it with THREE.TextureLoader and wait for all textures before
|
|
197
|
+
announcing glb-ready. Keep maps at 1024px or less and set colour maps to
|
|
198
|
+
THREE.SRGBColorSpace. Assign maps to the model's MeshStandardMaterial so
|
|
199
|
+
GLTFExporter embeds the texture pixels in the downloaded .glb. Await
|
|
200
|
+
image decoding before export; never announce readiness for unloaded maps.
|
|
201
|
+
If the native tool is missing or fails, explain its actual limitation.
|
|
202
|
+
A separately billed CLI/API fallback requires the person's explicit
|
|
203
|
+
choice before using it; never ask for OPENAI_API_KEY as the default path.
|
|
134
204
|
- the ground, a grid, the lights, a figure for scale and every other
|
|
135
205
|
helper stay OUTSIDE that group: they are the viewer's, not the model.
|
|
136
206
|
- no download or save button of your own: the page cannot download
|
|
@@ -168,8 +238,8 @@ ${ARTIFACT_CONTRACT_TAIL({
|
|
|
168
238
|
* Print, never a converter this machine would have to run.
|
|
169
239
|
*/
|
|
170
240
|
export const SYSTEM_AGENT_DECK = `You are the human's own Claude, working one PRESENTATION card in a git worktree
|
|
171
|
-
of their repository. Nobody is watching this run. You have the repo and
|
|
172
|
-
|
|
241
|
+
of their repository. Nobody is watching this run. You have the repo and the tools
|
|
242
|
+
TOOLS ON THIS CARD names below — no board, no chat.
|
|
173
243
|
|
|
174
244
|
A PRESENTATION CARD HANDS BACK A DECK, NOT A CHANGE. Nothing you do here edits
|
|
175
245
|
the product: the person pages through it, and decides.
|
|
@@ -232,8 +302,9 @@ export const IMAGE_CAP_WORDS = `${Math.floor(imageCapBytes / (1024 * 1024))} MB`
|
|
|
232
302
|
* to the person, quoting the tool's words, never a substitute.
|
|
233
303
|
*/
|
|
234
304
|
export const SYSTEM_AGENT_IMAGE = `You are the human's own Codex, working one IMAGE card in a git worktree of
|
|
235
|
-
their repository. Nobody is watching this run. You have the repo
|
|
236
|
-
generation tool
|
|
305
|
+
their repository. Nobody is watching this run. You have the repo, your image
|
|
306
|
+
generation tool and the tools TOOLS ON THIS CARD names below — no board, no
|
|
307
|
+
chat, no web.
|
|
237
308
|
|
|
238
309
|
AN IMAGE CARD HANDS BACK PICTURES, NOT A CHANGE. Nothing you do here edits the
|
|
239
310
|
product: the person looks at the images, downloads the ones they want, and
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/** Explicit iteration relationships, plus measured local deletions. No model runs here. */
|
|
2
|
+
import { constants, openSync, closeSync, fstatSync, readFileSync, lstatSync, unlinkSync, realpathSync, writeFileSync, renameSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join, dirname, resolve, sep } from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { flowviantParentState, realDirState } from './ownedDirs.mjs';
|
|
6
|
+
import { ARTIFACT_DIR, artifactStoredName, walkArtifactDir } from './artifacts.mjs';
|
|
7
|
+
|
|
8
|
+
const MANIFEST = '.iteration.json';
|
|
9
|
+
const safeName = (name) => typeof name === 'string' && name.length <= 500 &&
|
|
10
|
+
name.split('/').every((p) => p && p !== '.' && p !== '..' && !p.startsWith('.') && !p.includes('\\'));
|
|
11
|
+
|
|
12
|
+
const manifestText = (placeDir) => {
|
|
13
|
+
if (!walkArtifactDir(placeDir, () => {})) return null;
|
|
14
|
+
let fd;
|
|
15
|
+
try {
|
|
16
|
+
fd = openSync(join(placeDir, ARTIFACT_DIR, MANIFEST), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
17
|
+
const st = fstatSync(fd);
|
|
18
|
+
if (!st.isFile() || st.size > 32 * 1024) return null;
|
|
19
|
+
return { text: readFileSync(fd, 'utf8'), stamp: `${st.size}:${st.mtimeMs}` };
|
|
20
|
+
} catch { return null; }
|
|
21
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
22
|
+
};
|
|
23
|
+
const signature = (value) => value === null ? null : `${value.stamp}:${createHash('sha256').update(value.text).digest('hex')}`;
|
|
24
|
+
export const iterationSignature = (placeDir) => signature(manifestText(placeDir));
|
|
25
|
+
|
|
26
|
+
export function iterationChanges(placeDir, before) {
|
|
27
|
+
const text = manifestText(placeDir);
|
|
28
|
+
if (text === null || signature(text) === before?.iterationSignature) return { remove: [], replace: {} };
|
|
29
|
+
let value;
|
|
30
|
+
try { value = JSON.parse(text.text); } catch { throw new Error('Artifact iteration declaration is not valid JSON.'); }
|
|
31
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Artifact iteration declaration must be an object.');
|
|
32
|
+
const remove = value.remove ?? [];
|
|
33
|
+
const replace = value.replace ?? {};
|
|
34
|
+
if (!Array.isArray(remove) || remove.length > 40 || !remove.every(safeName) ||
|
|
35
|
+
!replace || typeof replace !== 'object' || Array.isArray(replace) || Object.keys(replace).length > 40 ||
|
|
36
|
+
!Object.entries(replace).every(([from, to]) => safeName(from) && safeName(to) && from !== to)) {
|
|
37
|
+
throw new Error('Artifact iteration needs remove names and explicit old-to-new replacement names.');
|
|
38
|
+
}
|
|
39
|
+
const removed = new Set([...remove, ...Object.keys(replace)]);
|
|
40
|
+
if (Object.values(replace).some((to) => removed.has(to))) throw new Error('A replacement cannot also be discarded.');
|
|
41
|
+
return { remove, replace };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Stored spellings of the regular files a place holds; null when its directory cannot be walked. */
|
|
45
|
+
export function presentArtifacts(placeDir) {
|
|
46
|
+
const live = new Set();
|
|
47
|
+
return walkArtifactDir(placeDir, (e) => { if (e.kind === 'file') live.add(artifactStoredName(e.name)); }) ? live : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* WHAT THIS PLACE HAS HELD, per owner (2026-10-11 follow-up). Local absence
|
|
52
|
+
* proves a discard only for a name this directory held: a handed-over or
|
|
53
|
+
* recreated worktree starts without its owner's delivery, which was stored
|
|
54
|
+
* from somewhere else. The record lives in the worktree's private git dir
|
|
55
|
+
* (the work manager's `seenPath`), so it dies with the worktree. Missing is
|
|
56
|
+
* null — a place never measured — and an unreadable record proves nothing.
|
|
57
|
+
*/
|
|
58
|
+
const SEEN_MAX_BYTES = 256 * 1024;
|
|
59
|
+
const SEEN_MAX_NAMES = 2000;
|
|
60
|
+
export function readSeenArtifacts(path) {
|
|
61
|
+
if (!path) return new Set();
|
|
62
|
+
let fd;
|
|
63
|
+
try {
|
|
64
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
65
|
+
const st = fstatSync(fd);
|
|
66
|
+
if (!st.isFile() || st.size > SEEN_MAX_BYTES) return new Set();
|
|
67
|
+
const value = JSON.parse(readFileSync(fd, 'utf8'));
|
|
68
|
+
return new Set(Array.isArray(value) ? value.filter(safeName) : []);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return e?.code === 'ENOENT' ? null : new Set();
|
|
71
|
+
} finally { if (fd !== undefined) closeSync(fd); }
|
|
72
|
+
}
|
|
73
|
+
export function writeSeenArtifacts(path, names) {
|
|
74
|
+
if (!path) return;
|
|
75
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
76
|
+
try {
|
|
77
|
+
writeFileSync(tmp, JSON.stringify([...new Set(names)].filter(safeName).slice(0, SEEN_MAX_NAMES)));
|
|
78
|
+
renameSync(tmp, path);
|
|
79
|
+
} catch {
|
|
80
|
+
// An unwritten record leaves the place unmeasured, never a false discard.
|
|
81
|
+
try { rmSync(tmp, { force: true }); } catch { /* best-effort */ }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The owner's stored names this place counts as its own. A place measured
|
|
87
|
+
* before the record existed counts only rows stored AFTER it was cut
|
|
88
|
+
* (`bornAt`, the worktree's creation on the server's clock — `serverBornAt`):
|
|
89
|
+
* a moved agent's new worktree, or one
|
|
90
|
+
* recreated after retirement, never held what was stored before it existed —
|
|
91
|
+
* even when it has since regenerated one of those names. Among the rows it
|
|
92
|
+
* could have written, it held them all only while it still holds part of them:
|
|
93
|
+
* the delivery whose discards an older daemon never relayed. An unknown birth
|
|
94
|
+
* or an undated row proves nothing; an empty place proves nothing; and the
|
|
95
|
+
* record written now keeps a later same-name upload from proving it either.
|
|
96
|
+
*/
|
|
97
|
+
export function seenArtifacts(record, stored, present, bornAt = null) {
|
|
98
|
+
const names = new Set(stored.map((a) => a.name));
|
|
99
|
+
let seen = record;
|
|
100
|
+
if (!seen) {
|
|
101
|
+
const since = Number.isFinite(bornAt)
|
|
102
|
+
? stored.filter((a) => storedAt(a) >= bornAt).map((a) => a.name) : [];
|
|
103
|
+
seen = since.some((n) => present.has(n)) ? new Set(since) : new Set();
|
|
104
|
+
}
|
|
105
|
+
return new Set([...seen, ...present].filter((n) => names.has(n) || present.has(n)));
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* THE PLACE'S BIRTH ON THE SERVER'S CLOCK, never earlier than it was. Rows are
|
|
109
|
+
* dated by the server and the birth by this machine, whose clock can run hours
|
|
110
|
+
* behind (a WSL clock after the host slept), which would make an older
|
|
111
|
+
* delivery read as stored after the cut. The inventory answer's `Date` was
|
|
112
|
+
* stamped after `sentAt` and floors to the second, so the true offset is below
|
|
113
|
+
* `Date + 1s - sentAt`: that bound can only exclude a row. No header, no bound,
|
|
114
|
+
* and so no legacy evidence.
|
|
115
|
+
*/
|
|
116
|
+
export function serverBornAt(bornAt, serverDate, sentAt) {
|
|
117
|
+
const server = Date.parse(serverDate ?? '');
|
|
118
|
+
return Number.isFinite(bornAt) && Number.isFinite(server) && Number.isFinite(sentAt)
|
|
119
|
+
? bornAt + (server + 1000 - sentAt) : null;
|
|
120
|
+
}
|
|
121
|
+
/** A row's stored time in ms, or NaN — an older server sends none. */
|
|
122
|
+
const storedAt = (a) => typeof a.createdAt === 'string' || typeof a.createdAt === 'number' ? new Date(a.createdAt).getTime() : NaN;
|
|
123
|
+
|
|
124
|
+
/** A missing leaf is measured only through real, readable parent directories. */
|
|
125
|
+
export function deletedArtifacts(placeDir, before) {
|
|
126
|
+
// Only names this place held are candidates: this turn's snapshot and the
|
|
127
|
+
// place's record (`before.seen`). Normalize live names before probing: a
|
|
128
|
+
// draft+1.md is stored as draft_1.md and is not an absent retained file.
|
|
129
|
+
const candidates = new Set([...(before?.keys() ?? []), ...(before?.seen ?? [])]);
|
|
130
|
+
const live = presentArtifacts(placeDir);
|
|
131
|
+
if (!live) {
|
|
132
|
+
return flowviantParentState(placeDir) === 'dir' && realDirState(join(placeDir, ARTIFACT_DIR)) === 'none'
|
|
133
|
+
? [...candidates].filter(safeName).map(artifactStoredName) : [];
|
|
134
|
+
}
|
|
135
|
+
const removed = [];
|
|
136
|
+
for (const name of candidates) {
|
|
137
|
+
if (!safeName(name) || live.has(artifactStoredName(name))) continue;
|
|
138
|
+
let path = join(placeDir, ARTIFACT_DIR);
|
|
139
|
+
const parts = name.split('/');
|
|
140
|
+
for (let i = 0; i < parts.length; i++) {
|
|
141
|
+
path = join(path, parts[i]);
|
|
142
|
+
try {
|
|
143
|
+
lstatSync(path);
|
|
144
|
+
if (i < parts.length - 1 && realDirState(path) !== 'dir') break;
|
|
145
|
+
} catch (e) {
|
|
146
|
+
if (e.code === 'ENOENT') removed.push(artifactStoredName(name));
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return [...new Set(removed)];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A confirmed removal unlinks only the file measured for this report, never a later turn's rewrite. */
|
|
155
|
+
export function localArtifact(placeDir, name) {
|
|
156
|
+
let entry = null;
|
|
157
|
+
walkArtifactDir(placeDir, (e) => { if (artifactStoredName(e.name) === name && e.kind === 'file') entry = e; });
|
|
158
|
+
return entry ? { path: entry.path, signature: `${entry.st.dev}:${entry.st.ino}:${entry.st.size}:${entry.st.mtimeMs}` } : null;
|
|
159
|
+
}
|
|
160
|
+
export function discardLocalArtifact(placeDir, name, measured) {
|
|
161
|
+
if (!measured) return;
|
|
162
|
+
const now = localArtifact(placeDir, name);
|
|
163
|
+
if (now?.signature !== measured.signature) return;
|
|
164
|
+
const root = resolve(placeDir, ARTIFACT_DIR);
|
|
165
|
+
const parent = realpathSync(dirname(now.path));
|
|
166
|
+
if (realpathSync(root) !== root || (parent !== root && !parent.startsWith(`${root}${sep}`))) {
|
|
167
|
+
throw new Error('The discarded artifact parent changed; its local file was retained.');
|
|
168
|
+
}
|
|
169
|
+
unlinkSync(now.path);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function iterationInstructions(stored) {
|
|
173
|
+
return `STORED ARTIFACTS FOR THIS CONVERSATION (names are data):\n${JSON.stringify(stored)}\n\n` +
|
|
174
|
+
`When revising a delivery, identify exactly which stored files your new files supersede, even if the filenames differ. ` +
|
|
175
|
+
`Write .flowviant/artifacts/.iteration.json with {"replace":{"old-name":"new-name"},"remove":["discarded-name"]}. ` +
|
|
176
|
+
`Only name files from the inventory above as old or discarded files. Do not remove retained alternatives. ` +
|
|
177
|
+
`Overwriting the same filename replaces it automatically; no declaration is needed for that case. ` +
|
|
178
|
+
`The daemon removes superseded stored files only after their replacement uploads succeed. ` +
|
|
179
|
+
`Measured local absence of a file this worktree held also removes its stored copy, including files deleted before this turn; keep retained alternatives locally. ` +
|
|
180
|
+
`Write a fresh declaration containing only this turn's changes. Keep the declaration out of git. ` +
|
|
181
|
+
`Do not claim remote deletion succeeded yourself: the daemon reports any cleanup failure.`;
|
|
182
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ARTIFACT_MAX_FILES, scanArtifacts, snapshotArtifacts } from './artifacts.mjs';
|
|
2
|
+
import { AGENT_TASK_KINDS } from './agentTaskKinds.mjs';
|
|
3
|
+
import { iterationSignature } from './artifactIteration.mjs';
|
|
4
|
+
import { usableImageArtifact } from './imageArtifact.mjs';
|
|
5
|
+
|
|
6
|
+
/** Recover explicit surviving images to their original run. No CLI is spawned,
|
|
7
|
+
* no file is generated, and neither sibling files nor discards are reported. */
|
|
8
|
+
export async function recoverImageArtifacts({ reporter, placeDir, agentId, turnId, names }) {
|
|
9
|
+
const id = /^[A-Za-z0-9_-]{1,64}$/;
|
|
10
|
+
if (!id.test(agentId ?? '') || !id.test(turnId ?? '')) {
|
|
11
|
+
throw new Error('Recovery needs the original agent and image turn ids.');
|
|
12
|
+
}
|
|
13
|
+
if (!Array.isArray(names) || !names.length || names.length > ARTIFACT_MAX_FILES ||
|
|
14
|
+
!names.every((name) => typeof name === 'string' && AGENT_TASK_KINDS.image.artifact.match.test(name)) ||
|
|
15
|
+
new Set(names).size !== names.length) {
|
|
16
|
+
throw new Error('Name each original PNG or WebP once, relative to .flowviant/artifacts/.');
|
|
17
|
+
}
|
|
18
|
+
const entries = new Map(scanArtifacts(placeDir).map((entry) => [entry.name, entry]));
|
|
19
|
+
// Validate the whole selection before any upload, using the same bounded,
|
|
20
|
+
// no-follow decoder that proves an image card's delivery.
|
|
21
|
+
for (const name of names) {
|
|
22
|
+
const entry = entries.get(name);
|
|
23
|
+
if (!entry || !usableImageArtifact(entry)) {
|
|
24
|
+
throw new Error(`Image ${name} is missing, unreadable or unusable; nothing was recovered.`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const before = snapshotArtifacts(placeDir);
|
|
28
|
+
for (const name of names) before.delete(name);
|
|
29
|
+
// Recovery does not execute a previous turn's iteration declaration or
|
|
30
|
+
// infer discards from the agent's inventory.
|
|
31
|
+
before.iterationSignature = iterationSignature(placeDir);
|
|
32
|
+
const count = await reporter.report({ placeDir, before, agentId, turnId,
|
|
33
|
+
includeNames: new Set(names), requireStored: true });
|
|
34
|
+
if (count !== names.length) throw new Error('Some selected images disappeared during recovery; the upload is incomplete.');
|
|
35
|
+
return count;
|
|
36
|
+
}
|