flowviant 0.103.0 → 0.105.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 +130 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactContracts.mjs +153 -0
  7. package/bin/lib/artifactPolicy.mjs +45 -0
  8. package/bin/lib/artifactPreview.mjs +52 -0
  9. package/bin/lib/artifactReporter.mjs +163 -0
  10. package/bin/lib/artifacts.mjs +49 -228
  11. package/bin/lib/authReject.mjs +16 -4
  12. package/bin/lib/authproxy.mjs +47 -304
  13. package/bin/lib/boxIdentity.mjs +245 -0
  14. package/bin/lib/childEnv.mjs +11 -9
  15. package/bin/lib/claude.mjs +14 -851
  16. package/bin/lib/claudeAuth.mjs +3 -3
  17. package/bin/lib/claudePosture.mjs +370 -0
  18. package/bin/lib/claudeSessions.mjs +411 -0
  19. package/bin/lib/claudeStream.mjs +207 -0
  20. package/bin/lib/cliLimit.mjs +34 -0
  21. package/bin/lib/commandSpecs.mjs +173 -0
  22. package/bin/lib/config.mjs +11 -42
  23. package/bin/lib/credentialDate.mjs +22 -0
  24. package/bin/lib/credentials.mjs +21 -26
  25. package/bin/lib/daemonLogging.mjs +55 -0
  26. package/bin/lib/daemonState.mjs +64 -0
  27. package/bin/lib/deploy.mjs +148 -647
  28. package/bin/lib/deployCheckout.mjs +265 -0
  29. package/bin/lib/deployConfig.mjs +142 -0
  30. package/bin/lib/deployRunner.mjs +311 -0
  31. package/bin/lib/deployWire.mjs +41 -0
  32. package/bin/lib/desktopContract.mjs +56 -105
  33. package/bin/lib/env.mjs +49 -484
  34. package/bin/lib/fleet.mjs +183 -2383
  35. package/bin/lib/fleetJobs.mjs +120 -0
  36. package/bin/lib/fleetPost.mjs +40 -0
  37. package/bin/lib/fleetReports.mjs +392 -0
  38. package/bin/lib/fleetRoster.mjs +470 -0
  39. package/bin/lib/fleetStartup.mjs +196 -0
  40. package/bin/lib/fleetWake.mjs +45 -0
  41. package/bin/lib/fleetWire.mjs +46 -0
  42. package/bin/lib/git.mjs +2 -2
  43. package/bin/lib/gitMergeIdentity.mjs +51 -0
  44. package/bin/lib/grant.mjs +5 -3
  45. package/bin/lib/help.mjs +3 -125
  46. package/bin/lib/holder.mjs +482 -0
  47. package/bin/lib/instance.mjs +124 -46
  48. package/bin/lib/knowledge.mjs +16 -799
  49. package/bin/lib/knowledgeFetch.mjs +38 -0
  50. package/bin/lib/knowledgeFileSync.mjs +112 -0
  51. package/bin/lib/knowledgeFiles.mjs +489 -0
  52. package/bin/lib/knowledgeLibrary.mjs +299 -0
  53. package/bin/lib/landed.mjs +2 -1
  54. package/bin/lib/listeners.mjs +99 -141
  55. package/bin/lib/localSessions.mjs +30 -555
  56. package/bin/lib/login.mjs +3 -2
  57. package/bin/lib/loginPath.mjs +3 -5
  58. package/bin/lib/machineDisconnect.mjs +203 -0
  59. package/bin/lib/machineEnv.mjs +22 -0
  60. package/bin/lib/machineLimits.mjs +61 -0
  61. package/bin/lib/machineListing.mjs +338 -0
  62. package/bin/lib/machines.mjs +78 -455
  63. package/bin/lib/machinesCommand.mjs +214 -0
  64. package/bin/lib/mcp-cli.mjs +2 -1
  65. package/bin/lib/patchRevert.mjs +60 -0
  66. package/bin/lib/prWorkflow.mjs +157 -0
  67. package/bin/lib/preflight.mjs +2 -1
  68. package/bin/lib/pressurePolicy.mjs +104 -0
  69. package/bin/lib/preview.mjs +2 -272
  70. package/bin/lib/previewFramePolicy.mjs +112 -0
  71. package/bin/lib/previewGatePolicy.mjs +236 -0
  72. package/bin/lib/previewRegistry.mjs +235 -0
  73. package/bin/lib/printable.mjs +2 -2
  74. package/bin/lib/procRegistry.mjs +28 -18
  75. package/bin/lib/procScan.mjs +146 -0
  76. package/bin/lib/processes.mjs +29 -37
  77. package/bin/lib/projectToolRuntimes.mjs +138 -0
  78. package/bin/lib/projectTools.mjs +13 -88
  79. package/bin/lib/prompts.mjs +105 -139
  80. package/bin/lib/repoPath.mjs +26 -0
  81. package/bin/lib/resources.mjs +7 -69
  82. package/bin/lib/resumeLost.mjs +107 -0
  83. package/bin/lib/runTurn.mjs +324 -0
  84. package/bin/lib/runtimeAntigravity.mjs +230 -0
  85. package/bin/lib/runtimeCapabilities.mjs +334 -0
  86. package/bin/lib/runtimeClaude.mjs +126 -0
  87. package/bin/lib/runtimeCodex.mjs +217 -0
  88. package/bin/lib/runtimeDetection.mjs +121 -0
  89. package/bin/lib/runtimeEvents.mjs +482 -0
  90. package/bin/lib/runtimeOfferings.mjs +4 -1
  91. package/bin/lib/runtimes.mjs +42 -1402
  92. package/bin/lib/safeFileName.mjs +74 -0
  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 +17 -17
  107. package/bin/lib/wikiRunner.mjs +630 -0
  108. package/bin/lib/work.mjs +188 -3034
  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 +219 -0
  119. package/bin/lib/workAgentTurnReports.mjs +189 -0
  120. package/bin/lib/workAgentTurns.mjs +91 -898
  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,299 @@
1
+ /**
2
+ * THE KNOWLEDGE LIBRARY'S RULES, pure (2026-09-26, split out of knowledge.mjs —
3
+ * SOLID SRP, F049): where every manifest entry may land, under what name, at
4
+ * what cap, and what the generated catalog says. No disk, no network — the
5
+ * materialisation (knowledgeFiles.mjs), the download (knowledgeFetch.mjs) and
6
+ * the roster driver (knowledge.mjs) all ask these, so a change to the bundle
7
+ * format or the naming rule is a change here and nowhere else.
8
+ *
9
+ * ── THE KEPT LIBRARY (2026-09-23, 0.97.0) ──
10
+ *
11
+ * The manifest may carry `library: { items: [...] }` — the designs and
12
+ * write-ups the project KEPT when a person accepted them — and each item lands
13
+ * at its own relative path, `designs/<slug>-v<N>.html` or
14
+ * `research/<slug>-v<N>.md`, with a generated `LIBRARY.md` at the root naming
15
+ * every one (path, title, kind, the card that asked for it, the date, what it
16
+ * supersedes). So "implement design A" is an agent opening LIBRARY.md, finding
17
+ * design A, and reading the page. At 0.99.0 a kept design is one folder,
18
+ * including its original relative sidecar paths and measured preview. At
19
+ * 0.105.0 the end-product kinds add `models/<slug>-v<N>/` (a 3D model, a
20
+ * folder like a design: its viewer, its model files, its textures) and
21
+ * `decks/<slug>-v<N>.html` (a presentation, one file).
22
+ *
23
+ * SUBDIRECTORIES, AND EXACTLY FOUR. The safe-name rule for a knowledge file
24
+ * forbids every separator; a library item is the one thing allowed ONE (or,
25
+ * for a bundle kind, a versioned folder), and only after one of the four
26
+ * literal prefixes, with a stem that is already safe and the extension its
27
+ * kind hands back. `..`, another prefix, a leading dot, a folder for a
28
+ * single-file kind — refused, never rewritten, because a rewritten path could
29
+ * collide with another item's real one. The four directories are OURS the way
30
+ * the library directory is: a symlink or a file planted there is removed (the
31
+ * link, never its target) and a real directory made.
32
+ *
33
+ * ABSENT MEANS "LEAVE IT ALONE", the same rule the whole key keeps: a manifest
34
+ * with no `library` key is an older server, and `designs/`, `models/`,
35
+ * `decks/`, `research/` and `LIBRARY.md` are neither synced nor swept.
36
+ * `library: { items: [] }` is the emptied catalog, SAID — every directory and
37
+ * the catalog go.
38
+ */
39
+
40
+ import { safeFileName } from './safeFileName.mjs';
41
+
42
+ /** Relative to the checkout, under `.flowviant/` beside the turn uploads and
43
+ * the artifacts — see `FLOWVIANT_OWN_PATHS` for why that is NOT one exclude
44
+ * line covering all three. */
45
+ export const KNOWLEDGE_DIR = '.flowviant/knowledge';
46
+
47
+ /**
48
+ * THE PATHS UNDER `.flowviant/` THAT ARE OURS, and the exclude file names these
49
+ * and nothing wider (2026-09-23).
50
+ *
51
+ * The first cut excluded `/.flowviant/` whole, from every place a turn spawns
52
+ * in — the attachment fetch's line, which until this release was written only
53
+ * in repos somebody had attached a file in. But `.flowviant/` is not only ours:
54
+ * the repo DECLARES `.flowviant/check.json` and `.flowviant/deploy.json` there,
55
+ * committed like any other file. An exclude line never hides a TRACKED file,
56
+ * so an existing one was safe — and a NEW one was not: an agent asked to "add a
57
+ * check command" writes `.flowviant/check.json`, its `git add -A` skips it as
58
+ * ignored, the commit lands without it, and `git status` shows nothing to
59
+ * explain why. Writing that line into every worktree on every turn would have
60
+ * made the product's own configuration uncommittable by the product's own
61
+ * agents. So the exclude names the four things this daemon writes there, and a
62
+ * repo's own `.flowviant/*.json` stays ordinary, committable work.
63
+ *
64
+ * (A repo that already carries the old `/.flowviant/` line keeps it — it is in
65
+ * the user's own exclude file, and removing a line we cannot prove we alone
66
+ * wrote is not ours to do.)
67
+ */
68
+ export const FLOWVIANT_OWN_PATHS = [
69
+ '.flowviant/knowledge/',
70
+ '.flowviant/knowledge.rev*',
71
+ '.flowviant/artifacts/',
72
+ '.flowviant/uploads/',
73
+ ];
74
+ /** The generated catalog of the kept library, at the knowledge root. Reserved
75
+ * like INSTRUCTIONS.md: a person's file of that name is suffixed, because the
76
+ * prompt tells the CLI this one is the catalog. */
77
+ export const LIBRARY_FILE = 'LIBRARY.md';
78
+ const DESIGN_EXT = Object.freeze(['.html', '.htm', '.gltf', '.obj', '.glb', '.bin']);
79
+ /**
80
+ * THE KEPT LIBRARY'S KINDS — the daemon's ONE table of them (2026-09-27,
81
+ * SOLID fix-up of the end-product kinds). Per kind:
82
+ * - `dir`: the folder under the knowledge directory it lands in;
83
+ * - `bundle`: kept as a FOLDER (`<dir>/<slug>-vN/…`) rather than one file;
84
+ * - `ext`: the extensions its entry (or, for a bundle, its files) may carry;
85
+ * - `preview`: its kept entry is a page the server may measure a PNG of;
86
+ * - `word`: what it is called in the catalog.
87
+ * `dir` and `bundle` are copies of the app's `LIBRARY_KINDS` rows
88
+ * (packages/shared/src/schemas/libraryKinds.ts), and the release gate
89
+ * (scripts/check-app-parity.mjs) holds both equal to the app's; the keys are
90
+ * every card kind but code (`knowledgeLibrary.test.mjs`). Every other library
91
+ * fact below — `LIBRARY_DIRS`, the path rule, the reference rule, the
92
+ * previews, the catalog — reads this, so a kind is one row here. It was five
93
+ * parallel tables keyed by kind, and a kind missed in one of them threw.
94
+ * `model` is the 3D-model kind, `deck` the presentation kind.
95
+ */
96
+ export const LIBRARY_KINDS = Object.freeze({
97
+ design: Object.freeze({ dir: 'designs', bundle: true, ext: DESIGN_EXT, preview: true, word: 'design' }),
98
+ model: Object.freeze({ dir: 'models', bundle: true, ext: Object.freeze([...DESIGN_EXT, '.mtl']), preview: true, word: '3D model' }),
99
+ deck: Object.freeze({ dir: 'decks', bundle: false, ext: Object.freeze(['.html', '.htm']), preview: true, word: 'deck' }),
100
+ research: Object.freeze({ dir: 'research', bundle: false, ext: Object.freeze(['.md']), preview: false, word: 'research' }),
101
+ });
102
+ /** The four subdirectories a kept item may live in, by kind — read off
103
+ * `LIBRARY_KINDS`. */
104
+ export const LIBRARY_DIRS = Object.freeze(
105
+ Object.fromEntries(Object.entries(LIBRARY_KINDS).map(([kind, k]) => [kind, k.dir]))
106
+ );
107
+ const libraryKind = (kind) => (typeof kind === 'string' && Object.hasOwn(LIBRARY_KINDS, kind) ? LIBRARY_KINDS[kind] : null);
108
+
109
+ /**
110
+ * IS THIS A PATH A CARD MAY NAME AS A REFERENCE (0.105.0)? `<dir>/<name>` for
111
+ * any library folder, and `<dir>/<name>/` — a kept bundle referenced as its
112
+ * FOLDER, the shape the app's `libraryRefPath` sends — only for a `bundle`
113
+ * kind. The name's first character is never a dot, so `..` and a hidden
114
+ * segment cannot pass. Built from `LIBRARY_KINDS`, never a re-spelled folder
115
+ * list: a copy of the list here dropped every kept design bundle from 0.99.0
116
+ * to 0.104.0.
117
+ */
118
+ export function isLibraryReference(name) {
119
+ if (typeof name !== 'string') return false;
120
+ const m = /^([^/]+)\/[A-Za-z0-9_][A-Za-z0-9._-]*(\/?)$/.exec(name);
121
+ return !!m && Object.values(LIBRARY_KINDS).some((k) => k.dir === m[1] && (m[2] === '' || k.bundle));
122
+ }
123
+ /** The server caps a library at 200 items; a longer manifest is cut. */
124
+ const MAX_LIBRARY_ITEMS = 256;
125
+
126
+ /** The person's standing brief, written from the manifest's `instructions`.
127
+ * A RESERVED name: a library FILE called this gets a suffix, because the
128
+ * prompt tells the CLI to read this one first and a stranger's file must
129
+ * never be able to stand in for the person's own words. */
130
+ export const INSTRUCTIONS_FILE = 'INSTRUCTIONS.md';
131
+ /** The server's per-file ceiling (`KNOWLEDGE_FILE_MAX_BYTES`), re-checked here:
132
+ * this is somebody's disk, and one place checking is one deploy away from
133
+ * zero places — the attachment fetch's own rule. */
134
+ export const KNOWLEDGE_FILE_MAX_BYTES = 10 * 1024 * 1024;
135
+ export const KNOWLEDGE_BINARY_MODEL_MAX_BYTES = 20 * 1024 * 1024;
136
+ export const knowledgeMaxFor = (name, base) => /\.(glb|bin)$/i.test(name)
137
+ ? Math.max(base, KNOWLEDGE_BINARY_MODEL_MAX_BYTES) : base;
138
+ /** The manifest is capped at the server (25 files); a manifest longer than
139
+ * this is not one the product produced, and is cut rather than trusted. */
140
+ export const MAX_FILES = 64;
141
+
142
+ /**
143
+ * `safeFileName` (bin/lib/safeFileName.mjs — the server's rule, one daemon
144
+ * home): drops every path separator, never starts with a dot or a dash, and
145
+ * THE EXTENSION SURVIVES A CUT — byte-identical to what the server computes,
146
+ * so a knowledge file synced under this name and one requested by name later
147
+ * never disagree over the cut. An empty name is `file`.
148
+ */
149
+ export const safeKnowledgeName = (raw) => safeFileName(raw, 'file');
150
+
151
+ /**
152
+ * The LOCAL name each manifest entry is written under, in manifest order.
153
+ * Collisions get a numeric suffix (`spec.md`, `spec-2.md`) — two files a
154
+ * person uploaded with one name are two files, and silently writing one over
155
+ * the other loses one. Deterministic in the manifest's order, so the same
156
+ * manifest always yields the same names and a re-sync never renames a file.
157
+ * `INSTRUCTIONS.md` is reserved (compared case-insensitively — macOS and
158
+ * Windows disks are).
159
+ */
160
+ export function planKnowledgeNames(files) {
161
+ // The catalog's name and the library directories (two at 0.97.0, four at
162
+ // 0.105.0) are reserved beside the brief: a person's `LIBRARY.md` or a file
163
+ // called `designs` must never stand where the catalog or a directory has to.
164
+ const taken = new Set([
165
+ INSTRUCTIONS_FILE.toLowerCase(),
166
+ LIBRARY_FILE.toLowerCase(),
167
+ ...Object.values(LIBRARY_DIRS),
168
+ ]);
169
+ const out = [];
170
+ for (const f of files) {
171
+ const name = safeKnowledgeName(f.name);
172
+ const dot = name.lastIndexOf('.');
173
+ const stem = dot > 0 ? name.slice(0, dot) : name;
174
+ const ext = dot > 0 ? name.slice(dot) : '';
175
+ let candidate = name;
176
+ for (let n = 2; taken.has(candidate.toLowerCase()); n++) candidate = `${stem}-${n}${ext}`;
177
+ taken.add(candidate.toLowerCase());
178
+ out.push({ ...f, local: candidate });
179
+ }
180
+ return out;
181
+ }
182
+
183
+ /**
184
+ * A kept item's relative path, or null when it is not EXACTLY
185
+ * `<its kind's dir>/<safe stem>.<its kind's extension>` (or, for a bundle
186
+ * kind, a file inside `<dir>/<slug>-vN/`) — see the header. Refused rather
187
+ * than rewritten: a rewritten path is one the server never named, and it
188
+ * could land on another item's real one.
189
+ */
190
+ export function safeLibraryPath(name, kind) {
191
+ const k = libraryKind(kind);
192
+ if (typeof name !== 'string' || !k) return null;
193
+ const parts = name.split('/');
194
+ if (parts.length !== 2 && !(k.bundle && parts.length >= 3 && parts.length <= 7)) return null;
195
+ const [dir, ...rest] = parts;
196
+ const file = rest.at(-1);
197
+ if (dir !== k.dir) return null;
198
+ if (rest.length >= 2 && !/^[a-z0-9-]+-v[1-9][0-9]*$/.test(rest[0])) return null;
199
+ if (rest.slice(1, -1).some((p) => !safeBundleSegment(p))) return null;
200
+ if (!file || (rest.length === 1 ? file !== safeKnowledgeName(file) : !safeBundleSegment(file))) return null;
201
+ if (!k.ext.some((ext) => file.toLowerCase().endsWith(ext) && file.length > ext.length)) return null;
202
+ return name;
203
+ }
204
+
205
+ function safeBundleSegment(part) {
206
+ return typeof part === 'string' && part.length > 0 && part.length <= 80 &&
207
+ /^[A-Za-z0-9][A-Za-z0-9._ -]*$/.test(part) && !part.endsWith('.');
208
+ }
209
+
210
+ function safeBundleFile(path, rootDir) {
211
+ if (typeof path !== 'string' || !path.startsWith(`${rootDir}/`)) return null;
212
+ const relative = path.slice(rootDir.length + 1);
213
+ if (relative === 'preview.png') return null;
214
+ const parts = relative.split('/');
215
+ if (!parts.length || parts.some((p) => !safeBundleSegment(p))) return null;
216
+ if (!/\.(gltf|obj|mtl|glb|bin|png|jpg|jpeg|gif|webp|svg|json)$/i.test(relative)) return null;
217
+ return path;
218
+ }
219
+
220
+ /** The manifest's library items that are safe to write, in manifest order, or
221
+ * null when the key is ABSENT (an older server — leave everything alone). */
222
+ export function libraryItemsOf(manifest) {
223
+ const lib = manifest?.library;
224
+ if (lib === undefined || lib === null) return null;
225
+ if (typeof lib !== 'object' || !Array.isArray(lib.items)) return null;
226
+ const out = [];
227
+ const seen = new Set();
228
+ for (const it of lib.items.slice(0, MAX_LIBRARY_ITEMS)) {
229
+ if (!it || typeof it.id !== 'string' || !/^[0-9a-f-]{8,64}$/i.test(it.id)) continue;
230
+ const path = safeLibraryPath(it.name, it.kind);
231
+ if (!path || seen.has(path.toLowerCase())) continue;
232
+ seen.add(path.toLowerCase());
233
+ const bundleDir = path.split('/').length >= 3 ? path.split('/').slice(0, 2).join('/') : null;
234
+ const expectedPreview = bundleDir ? `${bundleDir}/preview.png` : it.name.replace(/\.html$/, '.png');
235
+ const preview = libraryKind(it.kind).preview;
236
+ const previewPath = preview && it.preview && it.preview.name === expectedPreview ? expectedPreview : null;
237
+ const files = bundleDir && Array.isArray(it.files) ? it.files.map((f, index) => ({ ...f,
238
+ index, path: safeBundleFile(f.name, bundleDir),
239
+ })).filter((f) => f.path && !seen.has(f.path.toLowerCase())) : [];
240
+ for (const f of files) seen.add(f.path.toLowerCase());
241
+ out.push({ ...it, path, files, previewPath, previewUnknown: preview && path.endsWith('.html') && it.preview === undefined });
242
+ }
243
+ return out;
244
+ }
245
+
246
+ /** One line of the catalog: server strings are made single-line and capped —
247
+ * they are a person's card title and an artifact's name, headed into a file
248
+ * an agent reads, so they may not carry a line break that forges a second
249
+ * entry. */
250
+ const catalogText = (v, max) =>
251
+ String(v ?? '')
252
+ .replace(/[\r\n\t]+/g, ' ')
253
+ .replace(/\s+/g, ' ')
254
+ .trim()
255
+ .slice(0, max);
256
+
257
+ /**
258
+ * THE CATALOG, generated whole from the manifest on every sync:
259
+ *
260
+ * - designs/landing-v2.html — Landing mockup (design, from card "Redesign
261
+ * landing page", 2026-09-23, supersedes designs/landing-v1.html)
262
+ *
263
+ * (one line per item, oldest first — the server's order — so a sync that adds
264
+ * one item adds one line and moves nothing). Null when there are no items, in
265
+ * which case the file is removed rather than written empty.
266
+ */
267
+ export function renderLibraryCatalog(items) {
268
+ if (!items || items.length === 0) return null;
269
+ const lines = items.map((it) => {
270
+ const facts = [libraryKind(it.kind)?.word];
271
+ const card = catalogText(it.taskTitle, 200);
272
+ if (card) facts.push(`from card "${card.replace(/"/g, "'")}"`);
273
+ const day = typeof it.createdAt === 'string' ? it.createdAt.slice(0, 10) : '';
274
+ if (/^\d{4}-\d{2}-\d{2}$/.test(day)) facts.push(day);
275
+ const sup = typeof it.supersedes === 'string' ? catalogText(it.supersedes, 200) : '';
276
+ if (sup) facts.push(`supersedes ${sup}`);
277
+ const title = catalogText(it.title, 200) || it.path;
278
+ return `- ${it.path} — ${title} (${facts.join(', ')})`;
279
+ });
280
+ return (
281
+ '# Library\n\n' +
282
+ 'The mockups, 3D models, decks and research this project kept. Paths are relative to this directory;\n' +
283
+ 'a newer version supersedes an older one of the same name.\n\n' +
284
+ `${lines.join('\n')}\n`
285
+ );
286
+ }
287
+
288
+ /** Does this manifest look like one the server produced? Anything else is
289
+ * ignored whole rather than half-applied. */
290
+ export function isKnowledgeManifest(k) {
291
+ return (
292
+ !!k &&
293
+ typeof k === 'object' &&
294
+ Number.isInteger(k.rev) &&
295
+ k.rev >= 0 &&
296
+ (k.instructions === null || k.instructions === undefined || typeof k.instructions === 'string') &&
297
+ Array.isArray(k.files)
298
+ );
299
+ }
@@ -32,8 +32,9 @@ import { git, baseBranchName } from './git.mjs';
32
32
  import { commitRecord, stripDelims, taskIdsFromMessage } from './worktreeDiff.mjs';
33
33
  import { warn } from './ui.mjs';
34
34
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
35
+ import { fleetEndpoint } from './fleetWire.mjs';
35
36
 
36
- const LANDED_URL = FLEET_URL.replace(/\/agents\/?$/, '/base-landed');
37
+ const LANDED_URL = fleetEndpoint('base-landed', FLEET_URL);
37
38
  /** The server accepts 50 per report. A bigger range walks OLDEST-FIRST in
38
39
  * batches: the persisted tip advances to the last commit actually walked,
39
40
  * so the remainder is picked up on the next beat rather than skipped forever
@@ -53,10 +53,10 @@
53
53
 
54
54
  import { execFileSync } from 'node:child_process';
55
55
  import { createConnection } from 'node:net';
56
- import { readFileSync, readdirSync, readlinkSync, realpathSync } from 'node:fs';
56
+ import { readFileSync } from 'node:fs';
57
57
  import { platform } from 'node:os';
58
58
  import { rssBytes } from './processes.mjs';
59
- import { sep } from 'node:path';
59
+ import { insideDir, socketHoldersIn } from './procScan.mjs';
60
60
 
61
61
  /**
62
62
  * INFRASTRUCTURE CHILDREN ARE NOT LISTENERS. cloudflared opens its own local
@@ -78,10 +78,9 @@ export const forgetInfraPid = (pid) => {
78
78
  infraPids.delete(pid);
79
79
  };
80
80
 
81
- /** A box with more processes than this is not one we walk per sweep. The scan
82
- * is one readlink per pid and runs every reconcile; this is the runaway
83
- * bound, not a capacity statement. */
84
- const MAX_PIDS = 4000;
81
+ /** A process that IS Flowviant is not a process Flowviant reports — see
82
+ * `infraPids` above and the daemon's own gate in `scanLinux`. */
83
+ const isFlowviant = (pid) => pid === process.pid || infraPids.has(pid);
85
84
  /**
86
85
  * Rows reported per session.
87
86
  *
@@ -249,64 +248,35 @@ function labelFor(pid) {
249
248
  }
250
249
  }
251
250
 
252
- function scanLinux(worktree) {
251
+ /**
252
+ * The worktree's listening sockets, from the kernel's table and the processes
253
+ * standing in the worktree — `{ rows, complete }`. The walk of /proc is
254
+ * `procScan.mjs`'s (bounded on the processes it reads in the worktree, never
255
+ * on which pids are looked at); `complete: false` means it stopped with pids
256
+ * unread, and a listener among them is UNMEASURED, not absent.
257
+ *
258
+ * Exported with `io` ONLY so the scan-order property can be pinned without a
259
+ * box that runs four thousand processes. Nothing in the daemon passes it.
260
+ */
261
+ export function scanLinux(worktree, io) {
253
262
  const inodes = listeningByInode();
254
- if (inodes.size === 0) return [];
255
-
256
- let root;
257
- try {
258
- root = realpathSync(worktree);
259
- } catch {
260
- return []; // the directory is gone — retired under us
261
- }
262
- const prefix = root.endsWith(sep) ? root : root + sep;
263
- const inside = (p) => p === root || p.startsWith(prefix);
264
-
265
- let pids;
266
- try {
267
- pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d));
268
- } catch {
269
- return [];
270
- }
271
- if (pids.length > MAX_PIDS) pids = pids.slice(0, MAX_PIDS);
263
+ if (inodes.size === 0) return { rows: [], complete: true };
264
+
265
+ // NEVER THE DAEMON ITSELF. `startAuthProxy` binds a loopback port IN THIS
266
+ // PROCESS to gate a preview, and the daemon's own cwd is inside the
267
+ // checkout — so the gate was attributed to the repo place and surfaced as
268
+ // a listener the operator could click Stop on. Stopping it would take down
269
+ // the door in front of a live share, and it is not a thing anybody
270
+ // started: it is us. Same rule `processes.mjs` keeps by never reporting the
271
+ // group leader, for the same reason — a process that IS Flowviant is not a
272
+ // process Flowviant reports.
273
+ const scan = socketHoldersIn(worktree, { skip: isFlowviant, io });
274
+ if (scan === null) return { rows: [], complete: true };
272
275
 
273
276
  const found = new Map(); // port -> row
274
- for (const pid of pids) {
275
- // Cheap filter FIRST: one readlink rejects almost every process on the box,
276
- // and only survivors pay for a readdir of their fd table.
277
- let cwd;
278
- try {
279
- cwd = readlinkSync(`/proc/${pid}/cwd`);
280
- } catch {
281
- continue; // not ours, or gone
282
- }
283
- if (!inside(cwd)) continue;
284
- // NEVER THE DAEMON ITSELF. `startAuthProxy` binds a loopback port IN THIS
285
- // PROCESS to gate a preview, and the daemon's own cwd is inside the
286
- // checkout — so the gate was attributed to the repo place and surfaced as
287
- // a listener the operator could click Stop on. Stopping it would take down
288
- // the door in front of a live share, and it is not a thing anybody
289
- // started: it is us. Same rule `processes.mjs` keeps by never reporting the
290
- // group leader, for the same reason — a process that IS Flowviant is not a
291
- // process Flowviant reports.
292
- if (Number(pid) === process.pid || infraPids.has(Number(pid))) continue;
293
-
294
- let fds;
295
- try {
296
- fds = readdirSync(`/proc/${pid}/fd`);
297
- } catch {
298
- continue;
299
- }
300
- for (const fd of fds) {
301
- let link;
302
- try {
303
- link = readlinkSync(`/proc/${pid}/fd/${fd}`);
304
- } catch {
305
- continue;
306
- }
307
- const m = /^socket:\[(\d+)\]$/.exec(link);
308
- if (!m) continue;
309
- const hit = inodes.get(m[1]);
277
+ for (const { pid, inodes: held } of scan.holders) {
278
+ for (const inode of held) {
279
+ const hit = inodes.get(inode);
310
280
  if (!hit) continue;
311
281
  if (found.has(hit.port)) continue;
312
282
  const chosen = chosenPort(hit.port);
@@ -319,13 +289,13 @@ function scanLinux(worktree) {
319
289
  // was thrown away the moment the row was built. Keeping it is what lets
320
290
  // a surface group nine sockets under the ONE program that opened them,
321
291
  // and it is the only thing a kill could ever be aimed at.
322
- pid: Number(pid),
292
+ pid,
323
293
  ...(rss != null ? { rss } : {}),
324
294
  ...(chosen === undefined ? {} : { chosen }),
325
295
  });
326
296
  }
327
297
  }
328
- return [...found.values()];
298
+ return { rows: [...found.values()], complete: scan.complete };
329
299
  }
330
300
 
331
301
  // ── macOS ──────────────────────────────────────────────────────────────────
@@ -363,12 +333,10 @@ function scanDarwin(worktree) {
363
333
  } catch {
364
334
  /* no memory on this box — rows simply carry no rss */
365
335
  }
366
- const self = String(process.pid);
367
336
  for (const line of lsof(['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pn']).split('\n')) {
368
337
  // The daemon's own preview gate is not something the driver started — see
369
338
  // the linux branch for the whole argument. Nor its cloudflared children.
370
- if (line.startsWith('p'))
371
- pid = line.slice(1) === self || infraPids.has(Number(line.slice(1))) ? null : line.slice(1);
339
+ if (line.startsWith('p')) pid = isFlowviant(Number(line.slice(1))) ? null : line.slice(1);
372
340
  else if (line.startsWith('n') && pid) {
373
341
  const m = /:(\d+)$/.exec(line.slice(1));
374
342
  if (!m) continue;
@@ -380,13 +348,8 @@ function scanDarwin(worktree) {
380
348
  }
381
349
  if (byPid.size === 0) return [];
382
350
 
383
- let root;
384
- try {
385
- root = realpathSync(worktree);
386
- } catch {
387
- return [];
388
- }
389
- const prefix = root.endsWith(sep) ? root : root + sep;
351
+ const inside = insideDir(worktree);
352
+ if (!inside) return [];
390
353
 
391
354
  // Pass 2: the cwd of exactly those pids, in ONE batched call.
392
355
  const out = new Map();
@@ -394,8 +357,7 @@ function scanDarwin(worktree) {
394
357
  for (const line of lsof(['-a', '-d', 'cwd', '-F', 'pn', '-p', [...byPid.keys()].join(',')]).split('\n')) {
395
358
  if (line.startsWith('p')) cur = line.slice(1);
396
359
  else if (line.startsWith('n') && cur) {
397
- const cwd = line.slice(1);
398
- if (cwd !== root && !cwd.startsWith(prefix)) continue;
360
+ if (!inside(line.slice(1))) continue;
399
361
  for (const row of byPid.get(cur) || []) {
400
362
  if (out.has(row.port)) continue;
401
363
  const chosen = chosenPort(row.port);
@@ -452,17 +414,39 @@ export function byChosenThenPort(a, b) {
452
414
  * Repository block already settled that trade for branches and worktrees: the
453
415
  * capped list rides beside the count it was cut from. An empty array on an
454
416
  * unsupported platform means "we did not look" — callers must not turn it into
455
- * "nothing is running".
417
+ * "nothing is running". `incomplete: true` rides along when the /proc walk hit
418
+ * its work bound (`procScan.mjs`) with pids still unread.
419
+ *
420
+ * `incomplete` IS NOT RELAYED, deliberately, for now. The listener reports
421
+ * (`workPlaceReports.mjs`, `repoState.mjs`) send `rows`/`total` only: a new report field
422
+ * needs no floor, but it needs a reader on the server and a sentence on the
423
+ * web, and neither exists — sending it would be a field nobody reads. The cut
424
+ * needs thousands of processes standing in ONE worktree; the share path, where
425
+ * the cut would change an answer, says it in words (`originFor`). Relaying it
426
+ * to the Workbench is a product call, not this module's.
427
+ *
428
+ * `io` is the test seam `scanLinux` documents; nothing in the daemon passes it.
456
429
  */
457
- export function measureListeners(worktree) {
430
+ export function measureListeners(worktree, io) {
458
431
  if (!worktree) return { rows: [], total: 0 };
459
- let rows;
432
+ let scan;
460
433
  try {
461
- rows = platform() === 'linux' ? scanLinux(worktree) : platform() === 'darwin' ? scanDarwin(worktree) : [];
434
+ scan =
435
+ platform() === 'linux'
436
+ ? scanLinux(worktree, io)
437
+ : { rows: platform() === 'darwin' ? scanDarwin(worktree) : [], complete: true };
462
438
  } catch {
463
439
  return { rows: [], total: 0 };
464
440
  }
465
- return { rows: rows.sort(byChosenThenPort).slice(0, MAX_ROWS), total: rows.length };
441
+ const { rows, complete } = scan;
442
+ return {
443
+ rows: rows.sort(byChosenThenPort).slice(0, MAX_ROWS),
444
+ total: rows.length,
445
+ // SAID ONLY WHEN IT HAPPENED: the walk stopped with pids unread, so the
446
+ // rows are real and the total is a floor — a listener missing from them
447
+ // was not looked for, which is not the same fact as "not running".
448
+ ...(complete ? {} : { incomplete: true }),
449
+ };
466
450
  }
467
451
 
468
452
  /** The capped rows alone, for callers with nowhere to put a total. */
@@ -537,23 +521,36 @@ export function isListening(port, timeoutMs = 1500) {
537
521
  * file cannot measure answers the loopback it always dialled — the same
538
522
  * behaviour as before, never a refusal invented from ignorance.
539
523
  */
540
- export function originFor(worktree, port) {
524
+ export function originFor(worktree, port, io) {
541
525
  if (!worktree || !Number.isInteger(port) || port <= 0 || port > 65535) {
542
526
  return { error: `nothing is listening on port ${port} in this worktree.` };
543
527
  }
544
- let sockets;
528
+ let scan;
545
529
  try {
546
- sockets =
530
+ scan =
547
531
  platform() === 'linux'
548
- ? socketsOnPortLinux(worktree, port)
532
+ ? socketsOnPortLinux(worktree, port, io)
549
533
  : platform() === 'darwin'
550
- ? socketsOnPortDarwin(worktree, port)
534
+ ? { sockets: socketsOnPortDarwin(worktree, port), complete: true }
551
535
  : null;
552
536
  } catch {
553
- sockets = null;
537
+ scan = null;
538
+ }
539
+ if (scan == null) return { host: '127.0.0.1' };
540
+ const picked = pickOrigin(scan.sockets, port);
541
+ // A socket on this port whose holder the walk never reached is read as
542
+ // FOREIGN by the rule — the safe reading for a shadow check — so an
543
+ // unshadowed socket of ours is shared exactly as a complete walk would share
544
+ // it. Only when the rule would REFUSE does the cut matter: then "nothing is
545
+ // listening" (or "another process") may be about a socket the kernel holds
546
+ // for a process the walk never reached, and that is said instead (F007;
547
+ // review 2026-09-26: never a refusal built on the cut alone).
548
+ if (picked.error && !scan.complete && scan.sockets.some((s) => !s.ours)) {
549
+ return {
550
+ error: `this machine runs more processes in this worktree than one look reads, so which one holds port ${port} could not be told — it was not shared.`,
551
+ };
554
552
  }
555
- if (sockets == null) return { host: '127.0.0.1' };
556
- return pickOrigin(sockets, port);
553
+ return picked;
557
554
  }
558
555
 
559
556
  /**
@@ -617,7 +614,9 @@ function hexAddr(hex, family) {
617
614
  return `${head}::${tail}`;
618
615
  }
619
616
 
620
- function socketsOnPortLinux(worktree, port) {
617
+ /** `{ sockets, complete }` on success, null when /proc cannot be listed.
618
+ * `io` is the test seam `scanLinux` documents. */
619
+ export function socketsOnPortLinux(worktree, port, io) {
621
620
  const byInode = new Map(); // inode -> { family, addr }
622
621
  for (const [f, family] of [
623
622
  ['/proc/net/tcp', 4],
@@ -637,51 +636,17 @@ function socketsOnPortLinux(worktree, port) {
637
636
  if (c[9] && c[9] !== '0') byInode.set(c[9], { family, addr: hexAddr(a, family) });
638
637
  }
639
638
  }
640
- if (byInode.size === 0) return [];
641
- let root;
642
- try {
643
- root = realpathSync(worktree);
644
- } catch {
645
- return [];
646
- }
647
- const prefix = root.endsWith(sep) ? root : root + sep;
639
+ if (byInode.size === 0) return { sockets: [], complete: true };
640
+ const scan = socketHoldersIn(worktree, { skip: isFlowviant, io });
641
+ if (scan === null) return null;
648
642
  const ours = new Set();
649
- let pids;
650
- try {
651
- pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d));
652
- } catch {
653
- return null;
654
- }
655
- if (pids.length > MAX_PIDS) pids = pids.slice(0, MAX_PIDS);
656
- for (const pid of pids) {
657
- let cwd;
658
- try {
659
- cwd = readlinkSync(`/proc/${pid}/cwd`);
660
- } catch {
661
- continue;
662
- }
663
- if (cwd !== root && !cwd.startsWith(prefix)) continue;
664
- if (Number(pid) === process.pid || infraPids.has(Number(pid))) continue;
665
- let fds;
666
- try {
667
- fds = readdirSync(`/proc/${pid}/fd`);
668
- } catch {
669
- continue;
670
- }
671
- for (const fd of fds) {
672
- let link;
673
- try {
674
- link = readlinkSync(`/proc/${pid}/fd/${fd}`);
675
- } catch {
676
- continue;
677
- }
678
- const m = /^socket:\[(\d+)\]$/.exec(link);
679
- if (m && byInode.has(m[1])) ours.add(m[1]);
680
- }
681
- }
643
+ for (const { inodes } of scan.holders) for (const inode of inodes) if (byInode.has(inode)) ours.add(inode);
682
644
  // A socket whose holder we could not read (another user's process) is
683
645
  // FOREIGN — the safe reading for a refusal check.
684
- return [...byInode.entries()].map(([inode, s]) => ({ ...s, ours: ours.has(inode) }));
646
+ return {
647
+ sockets: [...byInode.entries()].map(([inode, s]) => ({ ...s, ours: ours.has(inode) })),
648
+ complete: scan.complete,
649
+ };
685
650
  }
686
651
 
687
652
  function socketsOnPortDarwin(worktree, port) {
@@ -700,22 +665,15 @@ function socketsOnPortDarwin(worktree, port) {
700
665
  }
701
666
  }
702
667
  if (!rows.length) return [];
703
- let root;
704
- try {
705
- root = realpathSync(worktree);
706
- } catch {
707
- return [];
708
- }
709
- const prefix = root.endsWith(sep) ? root : root + sep;
668
+ const within = insideDir(worktree);
669
+ if (!within) return [];
710
670
  const inside = new Set();
711
- const self = String(process.pid);
712
671
  let cur = null;
713
672
  const pids = [...new Set(rows.map((r) => r.pid))];
714
673
  for (const line of lsof(['-a', '-d', 'cwd', '-F', 'pn', '-p', pids.join(',')]).split('\n')) {
715
674
  if (line.startsWith('p')) cur = line.slice(1);
716
675
  else if (line.startsWith('n') && cur) {
717
- const cwd = line.slice(1);
718
- if ((cwd === root || cwd.startsWith(prefix)) && cur !== self && !infraPids.has(Number(cur))) inside.add(cur);
676
+ if (within(line.slice(1)) && !isFlowviant(Number(cur))) inside.add(cur);
719
677
  }
720
678
  }
721
679
  return rows.map((r) => ({ family: r.family, addr: r.addr, ours: inside.has(r.pid) }));