flowviant 0.102.2 → 0.104.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.
Files changed (141) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +184 -2396
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +34 -3
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/shipMerge.mjs +33 -2
  93. package/bin/lib/standDownExit.mjs +181 -0
  94. package/bin/lib/standDownGate.mjs +48 -0
  95. package/bin/lib/startProject.mjs +279 -0
  96. package/bin/lib/trace.mjs +3 -3
  97. package/bin/lib/turnLock.mjs +68 -0
  98. package/bin/lib/turnProfile.mjs +77 -0
  99. package/bin/lib/uninstall.mjs +55 -45
  100. package/bin/lib/uninstallCopies.mjs +112 -0
  101. package/bin/lib/update.mjs +30 -17
  102. package/bin/lib/uplinkScrub.mjs +265 -0
  103. package/bin/lib/vault.mjs +22 -151
  104. package/bin/lib/vaultDiff.mjs +183 -0
  105. package/bin/lib/versionOrder.mjs +42 -0
  106. package/bin/lib/views.mjs +35 -12
  107. package/bin/lib/wikiRunner.mjs +630 -0
  108. package/bin/lib/work.mjs +188 -3037
  109. package/bin/lib/workAdoptCarry.mjs +94 -0
  110. package/bin/lib/workAgentCheck.mjs +278 -0
  111. package/bin/lib/workAgentMerges.mjs +54 -142
  112. package/bin/lib/workAgentPlans.mjs +14 -12
  113. package/bin/lib/workAgentPrecheck.mjs +393 -0
  114. package/bin/lib/workAgentPublish.mjs +177 -0
  115. package/bin/lib/workAgentReview.mjs +8 -666
  116. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  117. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  118. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  119. package/bin/lib/workAgentTurnReports.mjs +189 -0
  120. package/bin/lib/workAgentTurns.mjs +92 -897
  121. package/bin/lib/workAttachments.mjs +93 -0
  122. package/bin/lib/workBrain.mjs +53 -0
  123. package/bin/lib/workChildren.mjs +110 -0
  124. package/bin/lib/workCommandAudit.mjs +62 -0
  125. package/bin/lib/workDiffs.mjs +2 -1
  126. package/bin/lib/workNarration.mjs +152 -0
  127. package/bin/lib/workPlaceReports.mjs +426 -0
  128. package/bin/lib/workPlaces.mjs +228 -0
  129. package/bin/lib/workPreviews.mjs +31 -9
  130. package/bin/lib/workProcesses.mjs +4 -5
  131. package/bin/lib/workPullRequests.mjs +52 -126
  132. package/bin/lib/workReportQueue.mjs +254 -0
  133. package/bin/lib/workRetire.mjs +185 -0
  134. package/bin/lib/workSessionGroups.mjs +166 -0
  135. package/bin/lib/workSessionRuntime.mjs +164 -0
  136. package/bin/lib/workSessionTokens.mjs +68 -0
  137. package/bin/lib/workSessionTurns.mjs +892 -0
  138. package/bin/lib/workShip.mjs +9 -29
  139. package/bin/lib/workToolLog.mjs +76 -0
  140. package/package.json +3 -2
  141. package/bin/lib/patch.mjs +0 -363
@@ -0,0 +1,265 @@
1
+ /**
2
+ * THE UPLINK SCRUBBER — what this daemon hides from every piece of text it
3
+ * posts, and the one thing in this file.
4
+ *
5
+ * `scrub()` redacts secret values out of every piece of text this daemon posts
6
+ * — turn streams, tool events, the trace, process command lines, wiki
7
+ * progress, deploy logs — and `secretIn()` finds them inside the bytes it
8
+ * cannot rewrite. It is fed from TWO places (2026-09-21, the review): the
9
+ * checkout's `.env*` files (`env.mjs`'s scan hands them to `learnSecrets`), and
10
+ * the PRESENT values of `childEnv`'s `DEPLOY_KEEP` names out of this process's
11
+ * own environment, which is where an operator's `CLOUDFLARE_API_TOKEN`
12
+ * actually lives and which the deploy lane hands to a command whose stdout it
13
+ * then streams to the server. It redacts on SHAPE, never on meaning: a value
14
+ * that looks like an ordinary word is left alone, because a relay that swaps
15
+ * the CLI's own words for `[REDACTED:NODE_ENV]` has stopped being a relay.
16
+ * NOTHING HERE EVER REACHES THE WIRE — it is a list of what to hide, not a
17
+ * report.
18
+ *
19
+ * SPLIT OUT OF `env.mjs` (2026-09-26, the SOLID pass). That module held three
20
+ * things with three unrelated reasons to change — the box keypair
21
+ * (`boxIdentity.mjs`), the env comparison scan (still `env.mjs`), and this
22
+ * redaction policy — and a change to what counts as a secret had to be made in
23
+ * the same file as the dotenv parser and the identity mint. The scan now owns
24
+ * READING the checkout; this module owns DECIDING what to hide and hiding it,
25
+ * and the two meet at exactly one call, `learnSecrets`.
26
+ */
27
+
28
+ // THE DEPLOY LANE'S OWN ALLOWLIST, borrowed for REDACTION ONLY. Deriving the
29
+ // scrub list from the set a deploy command is actually handed is what stops the
30
+ // two drifting; `processEnvSecrets`'s docblock carries the whole argument,
31
+ // including why none of it ever reaches the wire.
32
+ import { processEnvSecrets } from './childEnv.mjs';
33
+
34
+ // ── What `scrub` will redact ───────────────────────────────────────────────
35
+ //
36
+ // THE PREDICATE LIVES ABOVE THE LIST ON PURPOSE (2026-09-21): the list is
37
+ // FILTERED THROUGH IT ONCE, when a scan rebuilds it, rather than tested per
38
+ // call. `scrub` runs on every posted line of every turn stream, tool event and
39
+ // deploy log, and it does a whole-string `split`/`join` per value — so a value
40
+ // that will never match must not survive into the loop at all. Pre-filtering
41
+ // also makes the list smaller than it was before this pass on a typical
42
+ // checkout, because half of a real `.env` is ordinary configuration.
43
+
44
+ /**
45
+ * THE LENGTH FLOOR. A value of one or two characters — `1`, `ab`, `on` — occurs
46
+ * in ordinary prose constantly, so redacting one replaces half of every stream
47
+ * this daemon posts with `[REDACTED:NAME]` and the trace stops being readable
48
+ * at all. Six is the point below which a value is far likelier to be a flag
49
+ * than a secret, and nothing this short is worth making the product illegible
50
+ * for.
51
+ *
52
+ * (It used to carry the comment "mirrors shared ENV_SCRUB_MIN_LENGTH". That
53
+ * constant does not exist anywhere in the monorepo any more — it went with the
54
+ * vault's server half on 2026-09-21 — and a cross-reference to a deleted
55
+ * identifier reads as "do not change this, the other end depends on it", which
56
+ * is a claim about a coupling nobody can check. The floor's own argument is
57
+ * above and it stands on its own.)
58
+ */
59
+ const SCRUB_MIN_LENGTH = 6;
60
+
61
+ /**
62
+ * A VALUE THAT LOOKS LIKE AN ORDINARY WORD IS NOT REDACTED (2026-09-21, the
63
+ * review) — and this is a correctness fix, not a tuning knob.
64
+ *
65
+ * `scrub` now reads the CHECKOUT'S `.env*` files, and a real `.env` is full of
66
+ * configuration that is not secret at all: `NODE_ENV=development`,
67
+ * `HOST=localhost`, `APP_NAME=flowviant`, `LOG_LEVEL=verbose`,
68
+ * `AWS_REGION=us-east-1`. With those in the list, every occurrence of the word
69
+ * "development" in the CLI's own narration came back as
70
+ * `[REDACTED:NODE_ENV]` — so a turn trace, which exists to relay what the CLI
71
+ * said, was relaying something else. A relay does not swap the words it
72
+ * carries. That is a worse failure than under-redaction, because it is silent,
73
+ * universal, and destroys the readout the trace was built for.
74
+ *
75
+ * TWO SHAPE TESTS, applied per value, both cheap and both about SHAPE rather
76
+ * than meaning (nothing here classifies, guesses, or asks a model):
77
+ *
78
+ * · A PLAIN IDENTIFIER — starts with a letter, runs at most sixteen
79
+ * characters, and is made only of letters, digits and `.` `/` `-`. That is
80
+ * what a word, a hostname, a short version string and an enum value all look
81
+ * like. `_` is deliberately NOT in the set: an underscore is rare in prose
82
+ * and common in key material (`sk-live_…`), so its presence is the cheapest
83
+ * honest signal that a string was minted rather than written.
84
+ * · A SHORT VALUE WITH LITTLE VARIETY — under twelve characters and drawing on
85
+ * fewer than three of {lower, upper, digit, symbol}. A real credential short
86
+ * enough to be under twelve characters is mixed; `us-east-1` and `verbose`
87
+ * are not.
88
+ *
89
+ * THE ACCEPTED COST, STATED RATHER THAN HIDDEN: a genuinely secret value that
90
+ * is short and lowercase — `hunter2secret`, a thirteen-character all-letters
91
+ * password — is NOT redacted. Under-redaction is chosen here over making the
92
+ * product illegible, because the alternative is a trace nobody can read and a
93
+ * product that silently rewrites its own CLI's output. An operator whose secret
94
+ * is a dictionary word has a problem this function cannot fix. Anything with a
95
+ * symbol, a case mix, twelve-plus characters, or an underscore — which is every
96
+ * token, key, URL, JWT and hex digest anybody actually pastes into a `.env` —
97
+ * is redacted.
98
+ */
99
+ const SCRUB_PLAIN_RE = /^[A-Za-z][A-Za-z0-9._/-]{0,15}$/;
100
+ function characterClasses(value) {
101
+ let n = 0;
102
+ if (/[a-z]/.test(value)) n += 1;
103
+ if (/[A-Z]/.test(value)) n += 1;
104
+ if (/[0-9]/.test(value)) n += 1;
105
+ if (/[^A-Za-z0-9]/.test(value)) n += 1;
106
+ return n;
107
+ }
108
+ function worthRedacting(value) {
109
+ if (typeof value !== 'string' || value.length < SCRUB_MIN_LENGTH) return false;
110
+ // THE PLAIN-IDENTIFIER EXEMPTION DOES NOT COVER A CASE-AND-DIGIT MIX
111
+ // (2026-09-24, the audit). It was checked first and alone, so a generated
112
+ // password — `Tq8vZ2mKp4Lx9RbN`, the `pwgen -s 16` shape, or a twelve-
113
+ // character `Hx93kPq2Lm7w` — starts with a letter, fits sixteen characters of
114
+ // [A-Za-z0-9] and was left in plain text everywhere this daemon posts, against
115
+ // this block's own promise that "a case mix" is redacted. A word, a hostname,
116
+ // a region or a version string does not draw on upper, lower AND digits at
117
+ // once; a minted secret almost always does.
118
+ if (SCRUB_PLAIN_RE.test(value) && !(/[a-z]/.test(value) && /[A-Z]/.test(value) && /[0-9]/.test(value))) {
119
+ return false;
120
+ }
121
+ if (value.length < 12 && characterClasses(value) < 3) return false;
122
+ return true;
123
+ }
124
+
125
+ // ── Module state ───────────────────────────────────────────────────────────
126
+
127
+ /**
128
+ * WHAT `scrub` REDACTS — `[{ name, value }]`, refilled by every env scan.
129
+ *
130
+ * THIS ARRAY CHANGED HANDS (2026-09-21), and the change is an IMPROVEMENT
131
+ * rather than a salvage. It used to hold the vault's decrypted bundle: only
132
+ * the values Flowviant itself had delivered to this box. With the vault gone
133
+ * that array would be permanently empty and `scrub` would become a silent
134
+ * no-op in roughly ten call sites — the worst possible way for a redactor to
135
+ * stop working, because every one of those sites keeps calling it and nothing
136
+ * looks different until a secret is already in the server's database.
137
+ *
138
+ * So it is filled from the CHECKOUT'S OWN `.env*` FILES, which are the box's
139
+ * REAL secrets: the ones the operator pasted in from their providers, the ones
140
+ * a dev server actually loads, and the ones a CLI turn is overwhelmingly
141
+ * likeliest to echo into a log. The vault could only ever redact what it had
142
+ * delivered; this redacts what is there.
143
+ *
144
+ * ── TWO SOURCES, KEPT IN TWO VARIABLES (2026-09-21, the review) ──
145
+ *
146
+ * `values` is what `scrub` reads and is rebuilt on every scan as
147
+ * `processEnvSecrets()` ++ `fileValues`. The split exists because the two halves
148
+ * have opposite failure modes. The CHECKOUT half can come back empty for two
149
+ * different reasons — the operator deleted `.env`, or this pass could not read
150
+ * the directory — so an empty read must NOT replace it (see `learnSecrets`).
151
+ * The PROCESS half cannot blink: `process.env` is in memory and reading it is
152
+ * infallible, so it is replaced unconditionally, and folding it into one array
153
+ * with the file half would make a single unreadable-directory blip drop the
154
+ * operator's deploy credential out of the redactor too.
155
+ *
156
+ * SEEDED AT IMPORT, deliberately. `scrub` is reachable from the deploy lane and
157
+ * from a turn before the first scan has run, and the process half needs no
158
+ * checkout, no credential and no I/O to be correct — so there is no moment at
159
+ * which this module knows a deploy token and is not hiding it.
160
+ *
161
+ * AND IT IS ALREADY FILTERED. Every entry in `values` has passed
162
+ * `worthRedacting` — see that predicate's block above — so `scrub` itself makes
163
+ * no decisions and simply replaces. `fileValues` keeps the UNFILTERED read,
164
+ * because the filter is about redaction and the read is about what is on disk.
165
+ */
166
+ let fileValues = [];
167
+ let values = processEnvSecrets().filter((v) => worthRedacting(v.value));
168
+
169
+ /**
170
+ * HAND THE SCRUBBER A FRESH READ OF THE CHECKOUT'S `.env*` VALUES —
171
+ * `[{ name, value }]`, unfiltered, exactly as `scanEnvFiles` returns them — and
172
+ * rebuild the list `scrub` reads.
173
+ *
174
+ * THE PROCESS ENVIRONMENT'S INFRA CREDENTIALS ARE MERGED IN HERE, and here is
175
+ * the point: this is the one function every scan already goes through, so the
176
+ * two lists cannot drift and nobody has to remember. They are derived from
177
+ * `childEnv`'s own `DEPLOY_KEEP`, which is the set a deploy command is actually
178
+ * handed — see `processEnvSecrets` for why they are redacted and never
179
+ * reported. They are re-read on every scan rather than frozen at import,
180
+ * because that costs nothing and an environment read once is a list that can
181
+ * only be wrong.
182
+ *
183
+ * AN EMPTY READ DOES NOT CLEAR WHAT WE ALREADY KNOW, and this is the one place
184
+ * the two halves deliberately disagree. A scan that comes back with nothing is
185
+ * two different facts wearing one shape — the operator deleted `.env`, or this
186
+ * pass could not read the directory (EACCES, a mid-sweep rename, a network
187
+ * mount that blinked) — and `scanEnvFiles` swallows per-file errors by design
188
+ * so the poll cannot throw. Replacing the list on an empty read means a blip
189
+ * silently turns the redactor OFF, which is the exact failure mode the whole
190
+ * repoint above exists to avoid, and it is invisible until a secret is already
191
+ * in the server's database. Hence the separate `fileValues`: the process half
192
+ * is refreshed unconditionally (process.env does not blink) while the file half
193
+ * is only ever replaced by a read that found something.
194
+ *
195
+ * So an empty read leaves the previous list standing. The cost is
196
+ * OVER-redaction — a value the operator has since removed or rotated keeps
197
+ * being replaced with `[REDACTED:NAME]` for the life of this process — and
198
+ * over-redacting is the safe direction, the same one every three-state readout
199
+ * in this daemon takes. The REPORT half is the scan's, and is unaffected: it
200
+ * returns the real (empty) read, so the app is told what was actually measured.
201
+ */
202
+ export function learnSecrets(scannedValues) {
203
+ if (scannedValues.length) fileValues = scannedValues;
204
+ // FILTERED ONCE, HERE — see the predicate's own block above: `scrub` is a hot
205
+ // path and a value that can never match must not survive into its loop.
206
+ values = [...processEnvSecrets(), ...fileValues].filter((v) => worthRedacting(v.value));
207
+ }
208
+
209
+ // ── Uplink scrubbing ───────────────────────────────────────────────────────
210
+
211
+ /** Redact every known secret value from daemon-posted text. The list has
212
+ * already been through `worthRedacting`, so this makes no decisions: an
213
+ * ordinary word, a short flag and anything under the length floor are simply
214
+ * not in it. */
215
+ export function scrub(text) {
216
+ if (typeof text !== 'string' || !text) return text;
217
+ let out = text;
218
+ for (const v of values) out = out.split(v.value).join(`[REDACTED:${v.name}]`);
219
+ return out.replace(FLOWVIANT_TOKEN_RE, `[REDACTED:${FLOWVIANT_TOKEN_NAME}]`);
220
+ }
221
+
222
+ /**
223
+ * EVERY FLOWVIANT CREDENTIAL, CAUGHT BY SHAPE (2026-09-24, the audit).
224
+ *
225
+ * The value lists above are the CHECKOUT'S secrets and the process
226
+ * environment's, and neither holds the one secret that costs the project
227
+ * itself: the MACHINE CREDENTIAL. It sits in `~/.flowviant/credentials.json` —
228
+ * for EVERY project connected on this box, 0600 and readable by every turn,
229
+ * since a turn runs as this uid — and the server mints more of the same shape
230
+ * per turn (the work and capture tokens a tab's MCP server carries). A turn
231
+ * that read the store and wrote it into an artifact, a trace or an answer
232
+ * shipped it verbatim, because `scrub` had no value to match; and a design
233
+ * artifact runs scripts in a frame that may navigate itself, so the page that
234
+ * held it could carry it anywhere.
235
+ *
236
+ * Every one of them is `fva_` + 40 characters of nanoid (the server's
237
+ * `AGENT_TOKEN_PREFIX`), so a SHAPE catches them all, including ones this
238
+ * process never saw, with no store read and nothing to keep in sync. Twenty is
239
+ * the floor so the 12-character prefix the app shows for identification
240
+ * (`fva_` + 8) still reads as itself.
241
+ */
242
+ const FLOWVIANT_TOKEN_RE = /fva_[A-Za-z0-9_-]{20,}/g;
243
+ const FLOWVIANT_TOKEN_NAME = 'FLOWVIANT_TOKEN';
244
+
245
+ /**
246
+ * The NAME of the first known secret whose value appears in `bytes` as an
247
+ * exact byte substring, or null. For the payloads `scrub` cannot touch: a
248
+ * binary artifact (a PNG's text chunk, a PDF's uncompressed stream, a zip's
249
+ * stored entry) is not text to rewrite — replacing bytes inside one corrupts
250
+ * it — so the caller WITHHOLDS a hit rather than redacting it. The same list,
251
+ * already filtered by `worthRedacting`, so the two can never disagree about
252
+ * what counts as a secret. It sees only what is stored plainly: a value inside
253
+ * a deflated stream is not a substring of the file, and that is stated where
254
+ * it is used (artifacts.mjs).
255
+ */
256
+ export function secretIn(bytes) {
257
+ if (!Buffer.isBuffer(bytes) || !bytes.length) return null;
258
+ for (const v of values) if (bytes.includes(v.value, 0, 'utf8')) return v.name;
259
+ // The token SHAPE too (see `scrub`). `latin1` maps every byte to one
260
+ // character, so the ASCII pattern matches exactly where the bytes do.
261
+ if (bytes.includes('fva_') && new RegExp(FLOWVIANT_TOKEN_RE.source).test(bytes.toString('latin1'))) {
262
+ return FLOWVIANT_TOKEN_NAME;
263
+ }
264
+ return null;
265
+ }
package/bin/lib/vault.mjs CHANGED
@@ -13,12 +13,9 @@
13
13
  * in `.flowviant-sync.json` inside the vault (dotfile — never walked, never
14
14
  * uploaded).
15
15
  *
16
- * HARD RULE — deletion is opt-in, never inferred: a page we can't read, can't
17
- * upload (oversized / invalid path), or truncated past the cap is CARRIED
18
- * FORWARD at its last-synced state, not turned into a deletion. Only a page
19
- * that verifiably vanished from a readable vault becomes a delete. Otherwise
20
- * an append-only log.md crossing the size cap would silently erase itself
21
- * server-side.
16
+ * HARD RULE — deletion is opt-in, never inferred. That rule, the carry-forward
17
+ * policy and the request plan live in vaultDiff.mjs (planVaultSync); this
18
+ * module is the adapter: walk, read, POST in order, advance the state.
22
19
  */
23
20
 
24
21
  import {
@@ -28,30 +25,11 @@ import {
28
25
  mkdirSync,
29
26
  existsSync,
30
27
  } from 'node:fs';
31
- import { createHash } from 'node:crypto';
32
28
  import { execFileSync } from 'node:child_process';
33
29
  import { join, relative, sep } from 'node:path';
30
+ import { planVaultSync } from './vaultDiff.mjs';
34
31
 
35
32
  const SYNC_STATE = '.flowviant-sync.json';
36
- // Mirror the server contract (shared schema) — a page that violates it is
37
- // skipped with a warning (and carried forward if previously synced), never
38
- // allowed to 400 the whole request and wedge the sync.
39
- const MAX_FILE_BYTES = 262_144;
40
- const MAX_PATH_CHARS = 300;
41
- const MAX_FILES = 400;
42
- const CHUNK_FILES = 30;
43
- const CHUNK_BYTES = 700_000;
44
- const MAX_DELETIONS_PER_REQ = 200;
45
-
46
- /** Daemon-side mirror of the server's isSafeVaultPath. */
47
- const isSafePath = (p) =>
48
- p.length > 0 &&
49
- p.length <= MAX_PATH_CHARS &&
50
- p.endsWith('.md') &&
51
- !p.includes('\\') &&
52
- !p.includes('\0') &&
53
- !p.startsWith('/') &&
54
- p.split('/').every((seg) => seg.length > 0 && seg !== '.' && seg !== '..' && !seg.startsWith('.'));
55
33
 
56
34
  /** Create the vault dir + its private git history (best-effort). */
57
35
  export function ensureVault(dir) {
@@ -111,129 +89,27 @@ function commitVault(dir, message) {
111
89
  export async function syncVault({ dir, url, token, userAgent, finalize, groundedAtSha, repoFullName, warn = () => {}, scrub = (t) => t }) {
112
90
  const walkErrors = { count: 0 };
113
91
  const found = walkMd(dir, dir, [], walkErrors).sort();
114
- if (walkErrors.count > 0 && found.length === 0) {
115
- // Vault root (or everything under it) unreadable — nothing to diff against.
116
- warn(`vault at ${dir} is unreadable — skipping sync; check the vault dir`);
117
- return { pages: 0, uploaded: 0, deleted: 0, skipped: true };
118
- }
119
-
120
- // Partition into uploadable pages and carried-forward ones. Carried = we
121
- // know the page exists (or existed) but can't ship this state — keep the
122
- // server's last-good copy: tracked in `current` (prev hash) + manifest,
123
- // never a deletion.
124
- const current = {}; // path -> sha256 tracked as the post-sync state
125
- const contents = {}; // path -> markdown to upload (subset of current)
126
92
  let prev = {};
127
- try {
128
- prev = JSON.parse(readFileSync(join(dir, SYNC_STATE), 'utf8'));
129
- } catch {
130
- /* first sync */
131
- }
132
- const carry = (p, why) => {
133
- if (prev[p]) {
134
- current[p] = prev[p];
135
- warn(`vault page ${p}: ${why} — keeping the last synced copy`);
136
- } else {
137
- warn(`vault page ${p}: ${why} — not synced`);
138
- }
139
- };
140
-
141
- let kept = 0;
142
- for (const p of found) {
143
- if (!isSafePath(p)) {
144
- carry(p, 'name violates the sync contract (length/characters)');
145
- continue;
146
- }
147
- if (kept >= MAX_FILES) {
148
- carry(p, `vault exceeds ${MAX_FILES} pages`);
149
- continue;
150
- }
151
- let text;
93
+ if (!(walkErrors.count > 0 && found.length === 0)) {
152
94
  try {
153
- text = readFileSync(join(dir, p), 'utf8');
95
+ prev = JSON.parse(readFileSync(join(dir, SYNC_STATE), 'utf8'));
154
96
  } catch {
155
- carry(p, 'unreadable');
156
- continue;
157
- }
158
- // Uplink scrub: the cartographer quotes real repo files, and a repo file
159
- // can contain a synced secret — redact known values before upload. The
160
- // hash is computed on the SCRUBBED text so the diff state stays coherent.
161
- text = scrub(text);
162
- if (Buffer.byteLength(text) > MAX_FILE_BYTES) {
163
- carry(p, 'exceeds 256KB');
164
- continue;
97
+ /* first sync */
165
98
  }
166
- kept++;
167
- contents[p] = text;
168
- current[p] = createHash('sha256').update(text).digest('hex');
169
99
  }
170
-
171
- // Partial walk (an unreadable SUBdirectory): every previously-synced page the
172
- // walk failed to reach must be carried forward, not inferred deleted — a
173
- // transient EMFILE/EACCES on e.g. docs/ must never erase those pages
174
- // server-side. The hard rule: deletion is opt-in, never inferred.
175
- if (walkErrors.count > 0) {
176
- warn(
177
- `vault walk hit ${walkErrors.count} unreadable director${walkErrors.count === 1 ? 'y' : 'ies'} — carrying missing pages forward, no deletions this pass`
178
- );
179
- for (const p of Object.keys(prev)) {
180
- if (!(p in current)) current[p] = prev[p];
181
- }
182
- }
183
-
184
- // A readable vault that suddenly presents ZERO pages while the server holds
185
- // many is almost always a broken/moved dir, not an intentional wipe — refuse
186
- // to mass-delete. (An intentional reset is a fresh Regenerate: the sweep
187
- // rewrites pages, then finalize prunes precisely.)
188
- const prevCount = Object.keys(prev).length;
189
- if (Object.keys(current).length === 0 && prevCount > 0) {
190
- warn(`vault at ${dir} presents 0 pages but ${prevCount} were synced — refusing to delete; check the vault dir`);
191
- return { pages: 0, uploaded: 0, deleted: 0, skipped: true };
192
- }
193
-
194
- const changed = Object.keys(current).filter((p) => p in contents && prev[p] !== current[p]);
195
- const deletions = Object.keys(prev).filter((p) => !(p in current));
196
- const pages = Object.keys(current).length;
197
- if (changed.length === 0 && deletions.length === 0 && !finalize) {
198
- return { pages, uploaded: 0, deleted: 0, skipped: true };
199
- }
200
-
201
- // Finalize manifests are schema-capped server-side at MAX_FILES; carried
202
- // pages can push the tracked set past it. Downgrade to a plain merge (no
203
- // prune) rather than wedge the whole sync on a 400 — nothing is lost, the
204
- // regen request stays pending, and the warning names the cause.
205
- let doFinalize = !!finalize;
206
- if (doFinalize && pages > MAX_FILES) {
207
- warn(`vault tracks ${pages} pages (> ${MAX_FILES}) — skipping the finalize prune this pass`);
208
- doFinalize = false;
209
- }
210
-
211
- // Build the request series: file chunks (count+byte capped), then however
212
- // many deletion batches the 200-cap needs. finalize/sha ride the LAST
213
- // request only, so the server prunes exactly once, after every upsert landed.
214
- const fileChunks = [];
215
- let cur = [];
216
- let bytes = 0;
217
- for (const p of changed) {
218
- const size = Buffer.byteLength(contents[p]);
219
- if (cur.length && (cur.length >= CHUNK_FILES || bytes + size > CHUNK_BYTES)) {
220
- fileChunks.push(cur);
221
- cur = [];
222
- bytes = 0;
100
+ const read = (p) => {
101
+ try {
102
+ return readFileSync(join(dir, p), 'utf8');
103
+ } catch {
104
+ return null;
223
105
  }
224
- cur.push(p);
225
- bytes += size;
226
- }
227
- if (cur.length) fileChunks.push(cur);
228
-
229
- const requests = fileChunks.map((paths) => ({ files: paths.map((p) => ({ path: p, content: contents[p] })), deletions: [] }));
230
- for (let i = 0; i < deletions.length; i += MAX_DELETIONS_PER_REQ) {
231
- requests.push({ files: [], deletions: deletions.slice(i, i + MAX_DELETIONS_PER_REQ) });
232
- }
233
- if (requests.length === 0) requests.push({ files: [], deletions: [] }); // finalize-only
106
+ };
107
+ // What to send is vaultDiff.mjs's decision; this adapter only moves bytes.
108
+ const plan = planVaultSync({ found, walkErrors: walkErrors.count, prev, read, scrub, finalize, groundedAtSha, repoFullName, dir });
109
+ for (const w of plan.warnings) warn(w);
110
+ if (plan.result) return plan.result;
234
111
 
235
- for (let i = 0; i < requests.length; i++) {
236
- const last = i === requests.length - 1;
112
+ for (const body of plan.requests) {
237
113
  const res = await fetch(url, {
238
114
  method: 'POST',
239
115
  headers: {
@@ -242,17 +118,12 @@ export async function syncVault({ dir, url, token, userAgent, finalize, grounded
242
118
  'Content-Type': 'application/json',
243
119
  },
244
120
  signal: AbortSignal.timeout(60_000),
245
- body: JSON.stringify({
246
- ...requests[i],
247
- ...(last && doFinalize ? { finalize: { manifest: Object.keys(current) } } : {}),
248
- ...(last && groundedAtSha ? { groundedAtSha } : {}),
249
- ...(last && repoFullName ? { repoFullName } : {}),
250
- }),
121
+ body: JSON.stringify(body),
251
122
  });
252
123
  if (!res.ok) throw new Error(`wiki-vault sync failed (${res.status})`);
253
124
  }
254
125
 
255
- writeFileSync(join(dir, SYNC_STATE), JSON.stringify(current));
256
- commitVault(dir, doFinalize ? `sweep${groundedAtSha ? ` @ ${groundedAtSha.slice(0, 7)}` : ''}` : `update${groundedAtSha ? ` @ ${groundedAtSha.slice(0, 7)}` : ''}`);
257
- return { pages, uploaded: changed.length, deleted: deletions.length };
126
+ writeFileSync(join(dir, SYNC_STATE), JSON.stringify(plan.state));
127
+ commitVault(dir, plan.finalized ? `sweep${groundedAtSha ? ` @ ${groundedAtSha.slice(0, 7)}` : ''}` : `update${groundedAtSha ? ` @ ${groundedAtSha.slice(0, 7)}` : ''}`);
128
+ return { pages: plan.pages, uploaded: plan.uploaded, deleted: plan.deleted };
258
129
  }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * WHAT A VAULT SYNC SENDS — the pure planner behind syncVault (2026-09-26,
3
+ * SOLID F063).
4
+ *
5
+ * Split out of vault.mjs, where the deletion and carry-forward policy sat
6
+ * inside the walk, the POSTs and the state write, so its central rule could
7
+ * only be driven by building files and intercepting the network. This module
8
+ * decides; vault.mjs walks the directory, reads the prior state, POSTs the
9
+ * planned requests in order, and advances the state only after every one
10
+ * lands.
11
+ *
12
+ * HARD RULE — deletion is opt-in, never inferred: a page we can't read, can't
13
+ * upload (oversized / invalid path), or truncated past the cap is CARRIED
14
+ * FORWARD at its last-synced state, not turned into a deletion. Only a page
15
+ * that verifiably vanished from a readable vault becomes a delete. Otherwise
16
+ * an append-only log.md crossing the size cap would silently erase itself
17
+ * server-side.
18
+ */
19
+
20
+ import { createHash } from 'node:crypto';
21
+
22
+ // Mirror the server contract (shared schema) — a page that violates it is
23
+ // skipped with a warning (and carried forward if previously synced), never
24
+ // allowed to 400 the whole request and wedge the sync.
25
+ export const MAX_FILE_BYTES = 262_144;
26
+ const MAX_PATH_CHARS = 300;
27
+ export const MAX_FILES = 400;
28
+ export const CHUNK_FILES = 30;
29
+ export const CHUNK_BYTES = 700_000;
30
+ export const MAX_DELETIONS_PER_REQ = 200;
31
+
32
+ /** Daemon-side mirror of the server's isSafeVaultPath. */
33
+ export const isSafePath = (p) =>
34
+ p.length > 0 &&
35
+ p.length <= MAX_PATH_CHARS &&
36
+ p.endsWith('.md') &&
37
+ !p.includes('\\') &&
38
+ !p.includes('\0') &&
39
+ !p.startsWith('/') &&
40
+ p.split('/').every((seg) => seg.length > 0 && seg !== '.' && seg !== '..' && !seg.startsWith('.'));
41
+
42
+ const SKIPPED = Object.freeze({ pages: 0, uploaded: 0, deleted: 0, skipped: true });
43
+
44
+ /**
45
+ * Plan one sync.
46
+ *
47
+ * found vault-relative .md paths the walk discovered, sorted
48
+ * walkErrors how many directories the walk could not read
49
+ * prev path -> sha256 of the last successful sync ({} on the first)
50
+ * read(p) the page's markdown, or null when it cannot be read
51
+ * scrub uplink redaction, applied before hashing
52
+ * finalize, groundedAtSha, repoFullName ride the LAST request only
53
+ *
54
+ * Returns `{ warnings, result }` when nothing is sent (result is syncVault's
55
+ * return), else `{ warnings, requests, state, finalized, pages, uploaded,
56
+ * deleted }` — `requests` are the POST bodies in order, `state` the hashes to
57
+ * write once every one has landed.
58
+ */
59
+ export function planVaultSync({ found, walkErrors = 0, prev = {}, read, scrub = (t) => t, finalize = false, groundedAtSha, repoFullName, dir = 'the vault' }) {
60
+ const warnings = [];
61
+ if (walkErrors > 0 && found.length === 0) {
62
+ // Vault root (or everything under it) unreadable — nothing to diff against.
63
+ warnings.push(`vault at ${dir} is unreadable — skipping sync; check the vault dir`);
64
+ return { warnings, result: { ...SKIPPED } };
65
+ }
66
+
67
+ // Partition into uploadable pages and carried-forward ones. Carried = we
68
+ // know the page exists (or existed) but can't ship this state — keep the
69
+ // server's last-good copy: tracked in `current` (prev hash) + manifest,
70
+ // never a deletion.
71
+ const current = {}; // path -> sha256 tracked as the post-sync state
72
+ const contents = {}; // path -> markdown to upload (subset of current)
73
+ const carry = (p, why) => {
74
+ if (prev[p]) {
75
+ current[p] = prev[p];
76
+ warnings.push(`vault page ${p}: ${why} — keeping the last synced copy`);
77
+ } else {
78
+ warnings.push(`vault page ${p}: ${why} — not synced`);
79
+ }
80
+ };
81
+
82
+ let kept = 0;
83
+ for (const p of found) {
84
+ if (!isSafePath(p)) {
85
+ carry(p, 'name violates the sync contract (length/characters)');
86
+ continue;
87
+ }
88
+ if (kept >= MAX_FILES) {
89
+ carry(p, `vault exceeds ${MAX_FILES} pages`);
90
+ continue;
91
+ }
92
+ let text = read(p);
93
+ if (text == null) {
94
+ carry(p, 'unreadable');
95
+ continue;
96
+ }
97
+ // Uplink scrub: the cartographer quotes real repo files, and a repo file
98
+ // can contain a synced secret — redact known values before upload. The
99
+ // hash is computed on the SCRUBBED text so the diff state stays coherent.
100
+ text = scrub(text);
101
+ if (Buffer.byteLength(text) > MAX_FILE_BYTES) {
102
+ carry(p, 'exceeds 256KB');
103
+ continue;
104
+ }
105
+ kept++;
106
+ contents[p] = text;
107
+ current[p] = createHash('sha256').update(text).digest('hex');
108
+ }
109
+
110
+ // Partial walk (an unreadable SUBdirectory): every previously-synced page the
111
+ // walk failed to reach must be carried forward, not inferred deleted — a
112
+ // transient EMFILE/EACCES on e.g. docs/ must never erase those pages
113
+ // server-side. The hard rule: deletion is opt-in, never inferred.
114
+ if (walkErrors > 0) {
115
+ warnings.push(
116
+ `vault walk hit ${walkErrors} unreadable director${walkErrors === 1 ? 'y' : 'ies'} — carrying missing pages forward, no deletions this pass`
117
+ );
118
+ for (const p of Object.keys(prev)) {
119
+ if (!(p in current)) current[p] = prev[p];
120
+ }
121
+ }
122
+
123
+ // A readable vault that suddenly presents ZERO pages while the server holds
124
+ // many is almost always a broken/moved dir, not an intentional wipe — refuse
125
+ // to mass-delete. (An intentional reset is a fresh Regenerate: the sweep
126
+ // rewrites pages, then finalize prunes precisely.)
127
+ const prevCount = Object.keys(prev).length;
128
+ if (Object.keys(current).length === 0 && prevCount > 0) {
129
+ warnings.push(`vault at ${dir} presents 0 pages but ${prevCount} were synced — refusing to delete; check the vault dir`);
130
+ return { warnings, result: { ...SKIPPED } };
131
+ }
132
+
133
+ const changed = Object.keys(current).filter((p) => p in contents && prev[p] !== current[p]);
134
+ const deletions = Object.keys(prev).filter((p) => !(p in current));
135
+ const pages = Object.keys(current).length;
136
+ if (changed.length === 0 && deletions.length === 0 && !finalize) {
137
+ return { warnings, result: { pages, uploaded: 0, deleted: 0, skipped: true } };
138
+ }
139
+
140
+ // Finalize manifests are schema-capped server-side at MAX_FILES; carried
141
+ // pages can push the tracked set past it. Downgrade to a plain merge (no
142
+ // prune) rather than wedge the whole sync on a 400 — nothing is lost, the
143
+ // regen request stays pending, and the warning names the cause.
144
+ let doFinalize = !!finalize;
145
+ if (doFinalize && pages > MAX_FILES) {
146
+ warnings.push(`vault tracks ${pages} pages (> ${MAX_FILES}) — skipping the finalize prune this pass`);
147
+ doFinalize = false;
148
+ }
149
+
150
+ // Build the request series: file chunks (count+byte capped), then however
151
+ // many deletion batches the 200-cap needs. finalize/sha ride the LAST
152
+ // request only, so the server prunes exactly once, after every upsert landed.
153
+ const fileChunks = [];
154
+ let cur = [];
155
+ let bytes = 0;
156
+ for (const p of changed) {
157
+ const size = Buffer.byteLength(contents[p]);
158
+ if (cur.length && (cur.length >= CHUNK_FILES || bytes + size > CHUNK_BYTES)) {
159
+ fileChunks.push(cur);
160
+ cur = [];
161
+ bytes = 0;
162
+ }
163
+ cur.push(p);
164
+ bytes += size;
165
+ }
166
+ if (cur.length) fileChunks.push(cur);
167
+
168
+ const requests = fileChunks.map((paths) => ({ files: paths.map((p) => ({ path: p, content: contents[p] })), deletions: [] }));
169
+ for (let i = 0; i < deletions.length; i += MAX_DELETIONS_PER_REQ) {
170
+ requests.push({ files: [], deletions: deletions.slice(i, i + MAX_DELETIONS_PER_REQ) });
171
+ }
172
+ if (requests.length === 0) requests.push({ files: [], deletions: [] }); // finalize-only
173
+
174
+ const last = requests.length - 1;
175
+ requests[last] = {
176
+ ...requests[last],
177
+ ...(doFinalize ? { finalize: { manifest: Object.keys(current) } } : {}),
178
+ ...(groundedAtSha ? { groundedAtSha } : {}),
179
+ ...(repoFullName ? { repoFullName } : {}),
180
+ };
181
+
182
+ return { warnings, requests, state: current, finalized: doFinalize, pages, uploaded: changed.length, deleted: deletions.length };
183
+ }