flowviant 0.117.0 → 0.124.1

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 (103) hide show
  1. package/README.md +41 -0
  2. package/bin/cli.mjs +44 -0
  3. package/bin/lib/agentAsk.mjs +123 -0
  4. package/bin/lib/agentBrief.mjs +36 -0
  5. package/bin/lib/agentFootprint.mjs +76 -0
  6. package/bin/lib/agentInternalWait.mjs +4 -3
  7. package/bin/lib/agentPlan.mjs +86 -7
  8. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  9. package/bin/lib/agentRepositories.mjs +48 -0
  10. package/bin/lib/agentTaskKinds.mjs +1 -1
  11. package/bin/lib/artifactContracts.mjs +91 -20
  12. package/bin/lib/artifactIteration.mjs +182 -0
  13. package/bin/lib/artifactRecovery.mjs +36 -0
  14. package/bin/lib/artifactReporter.mjs +183 -19
  15. package/bin/lib/artifacts.mjs +6 -3
  16. package/bin/lib/checkoutIdentity.mjs +25 -0
  17. package/bin/lib/claudeAuth.mjs +15 -5
  18. package/bin/lib/claudeInput.mjs +32 -0
  19. package/bin/lib/claudePosture.mjs +110 -14
  20. package/bin/lib/claudeSessions.mjs +20 -0
  21. package/bin/lib/claudeStream.mjs +20 -2
  22. package/bin/lib/codexProjectRead.mjs +63 -0
  23. package/bin/lib/codexResearch.mjs +16 -3
  24. package/bin/lib/commandSpecs.mjs +6 -0
  25. package/bin/lib/credentials.mjs +52 -17
  26. package/bin/lib/daemonLocks.mjs +26 -6
  27. package/bin/lib/desktopContract.mjs +3 -0
  28. package/bin/lib/discoveryContract.mjs +19 -0
  29. package/bin/lib/fleet.mjs +65 -10
  30. package/bin/lib/fleetReports.mjs +44 -3
  31. package/bin/lib/fleetRoster.mjs +1 -89
  32. package/bin/lib/fleetStartup.mjs +11 -14
  33. package/bin/lib/holder.mjs +4 -37
  34. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  35. package/bin/lib/hooks/readGuard.mjs +13 -4
  36. package/bin/lib/imageArtifact.mjs +74 -0
  37. package/bin/lib/instance.mjs +20 -7
  38. package/bin/lib/landed.mjs +2 -2
  39. package/bin/lib/login.mjs +39 -4
  40. package/bin/lib/maintenance.mjs +47 -0
  41. package/bin/lib/maintenanceInventory.mjs +39 -0
  42. package/bin/lib/mcpInstall.mjs +163 -0
  43. package/bin/lib/mcpInventory.mjs +119 -0
  44. package/bin/lib/placeLock.mjs +9 -7
  45. package/bin/lib/planDependencies.mjs +70 -0
  46. package/bin/lib/postureTools.mjs +85 -0
  47. package/bin/lib/prWorkflow.mjs +15 -0
  48. package/bin/lib/projectLanded.mjs +16 -0
  49. package/bin/lib/projectRepositories.mjs +60 -0
  50. package/bin/lib/projectToolRuntimes.mjs +227 -3
  51. package/bin/lib/projectTools.mjs +63 -5
  52. package/bin/lib/projectWorktreeHome.mjs +25 -0
  53. package/bin/lib/prompts.mjs +170 -42
  54. package/bin/lib/repositoryBaseline.mjs +68 -0
  55. package/bin/lib/repositoryDelivery.mjs +64 -0
  56. package/bin/lib/repositoryMerge.mjs +116 -0
  57. package/bin/lib/repositoryPlaces.mjs +179 -0
  58. package/bin/lib/repositorySetup.mjs +58 -0
  59. package/bin/lib/repositoryTool.mjs +71 -0
  60. package/bin/lib/runTurn.mjs +58 -18
  61. package/bin/lib/runtimeAntigravity.mjs +2 -1
  62. package/bin/lib/runtimeCapabilities.mjs +77 -3
  63. package/bin/lib/runtimeClaude.mjs +31 -10
  64. package/bin/lib/runtimeCodex.mjs +28 -59
  65. package/bin/lib/runtimeDetection.mjs +6 -0
  66. package/bin/lib/runtimeEvents.mjs +12 -1
  67. package/bin/lib/runtimeOfferings.mjs +75 -3
  68. package/bin/lib/shipMerge.mjs +5 -2
  69. package/bin/lib/startProject.mjs +4 -4
  70. package/bin/lib/trace.mjs +8 -3
  71. package/bin/lib/turnDeclaration.mjs +88 -0
  72. package/bin/lib/turnDiagnostics.mjs +93 -0
  73. package/bin/lib/turnLock.mjs +48 -8
  74. package/bin/lib/turnProfile.mjs +15 -3
  75. package/bin/lib/work.mjs +48 -29
  76. package/bin/lib/workAgentBrief.mjs +92 -0
  77. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  78. package/bin/lib/workAgentCheck.mjs +6 -5
  79. package/bin/lib/workAgentMerges.mjs +19 -2
  80. package/bin/lib/workAgentPlans.mjs +21 -70
  81. package/bin/lib/workAgentPrecheck.mjs +10 -5
  82. package/bin/lib/workAgentPublish.mjs +18 -5
  83. package/bin/lib/workAgentRecovery.mjs +85 -0
  84. package/bin/lib/workAgentReview.mjs +5 -3
  85. package/bin/lib/workAgentTurnBegun.mjs +6 -4
  86. package/bin/lib/workAgentTurnExecution.mjs +167 -29
  87. package/bin/lib/workAgentTurnOutcome.mjs +20 -5
  88. package/bin/lib/workAgentTurns.mjs +92 -12
  89. package/bin/lib/workArtifactReconcile.mjs +79 -0
  90. package/bin/lib/workCappedTurn.mjs +10 -0
  91. package/bin/lib/workCommandAudit.mjs +8 -1
  92. package/bin/lib/workDelivery.mjs +21 -0
  93. package/bin/lib/workDiffs.mjs +18 -11
  94. package/bin/lib/workLiveMessages.mjs +129 -0
  95. package/bin/lib/workPlaceReports.mjs +5 -0
  96. package/bin/lib/workPlaces.mjs +24 -3
  97. package/bin/lib/workRetire.mjs +5 -0
  98. package/bin/lib/workSessionTurns.mjs +219 -52
  99. package/bin/lib/workShip.mjs +17 -16
  100. package/bin/lib/workToolLog.mjs +70 -3
  101. package/bin/lib/workTurnShape.mjs +6 -4
  102. package/package.json +7 -3
  103. package/npm-shrinkwrap.json +0 -59
@@ -111,6 +111,26 @@ export function titleForSession(cwd, sessionId) {
111
111
  return null;
112
112
  }
113
113
 
114
+ /** Native user UUIDs are delivery evidence after a crash before the replay
115
+ * receipt reached the API. Unknown/unreadable is null, never an empty claim. */
116
+ export function messageIdsForSession(cwd, sessionId) {
117
+ if (!cwd || !CLAUDE_SESSION_RE.test(sessionId ?? '')) return null;
118
+ const dirs = [String(cwd)];
119
+ try { const real = realpathSync(cwd); if (real !== cwd) dirs.push(real); } catch { /* gone */ }
120
+ for (const dir of dirs) {
121
+ const file = join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'projects', claudeProjectDir(dir), `${sessionId}.jsonl`);
122
+ try {
123
+ if (statSync(file).size > 64 * 1024 * 1024) continue;
124
+ const ids = new Set();
125
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
126
+ try { const row = JSON.parse(line); if (row.type === 'user' && typeof row.uuid === 'string') ids.add(row.uuid); } catch { /* torn tail */ }
127
+ }
128
+ return ids;
129
+ } catch { /* try real cwd */ }
130
+ }
131
+ return null;
132
+ }
133
+
114
134
  /**
115
135
  * THE DIRECTORY CLAUDE CODE FILES A CWD'S TRANSCRIPTS UNDER — its own rule,
116
136
  * copied exactly (2.1.281): every character that is not an ASCII letter or
@@ -93,7 +93,16 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
93
93
  emit(a.label + '\n');
94
94
  onActivity?.(a);
95
95
  };
96
- if (ev.type === 'assistant' && Array.isArray(ev.message?.content)) {
96
+ if (ev.type === 'stream_event') {
97
+ // Partial messages announce reasoning before the completed assistant
98
+ // block. Relay only that measured start here: text and tool inputs still
99
+ // come from the complete blocks below, once, with their full contents.
100
+ const block = ev.event?.content_block;
101
+ if (ev.event?.type === 'content_block_start' &&
102
+ (block?.type === 'thinking' || block?.type === 'redacted_thinking')) {
103
+ push({ kind: 'think', label: THINK_MARKER });
104
+ }
105
+ } else if (ev.type === 'assistant' && Array.isArray(ev.message?.content)) {
97
106
  // THE MODEL THAT ANSWERED (2026-09-29, turnModel.mjs) — the fallback for an
98
107
  // init that named none. The turn's own messages only: a subagent's carry
99
108
  // `parent_tool_use_id`, and its model is not the turn's.
@@ -141,9 +150,17 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
141
150
  // The STRUCTURED form of the same event, for the transcript's tool
142
151
  // cards — raw name + input, so the collector can keep what the
143
152
  // one-line humanizer drops (an Edit's counts, the plan's items).
144
- onToolEvent?.(b.name, b.input || {});
153
+ onToolEvent?.(b.name, b.input || {}, { id: b.id });
145
154
  }
146
155
  }
156
+ } else if (ev.type === 'user' && Array.isArray(ev.message?.content)) {
157
+ for (const b of ev.message.content) {
158
+ if (b.type === 'tool_result') onToolEvent?.(null, {}, {
159
+ id: b.tool_use_id, result: true, isError: b.is_error === true,
160
+ output: typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.slice(0, 8).filter(x => x?.type === 'text' && typeof x.text === 'string').map(x => x.text).join('\n') : '',
161
+ exitCode: ev.tool_use_result?.exit_code ?? ev.tool_use_result?.exitCode,
162
+ });
163
+ }
147
164
  } else if (ev.type === 'system' && ev.subtype === 'init') {
148
165
  // WHAT THIS MACHINE'S CLI CAN BE ASKED FOR BY NAME. The init event is the
149
166
  // CLI's OWN answer — it has already resolved personal skills, this repo's
@@ -177,6 +194,7 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
177
194
  skills: Array.isArray(ev.skills) ? ev.skills.map(String) : undefined,
178
195
  sessionId: typeof ev.session_id === 'string' ? ev.session_id : undefined,
179
196
  mcpServers: Array.isArray(ev.mcp_servers) ? ev.mcp_servers : undefined,
197
+ tools: Array.isArray(ev.tools) ? ev.tools : undefined,
180
198
  });
181
199
  }
182
200
  } else if (ev.type === 'result') {
@@ -0,0 +1,63 @@
1
+ /** Actual project files and Git history, with no host-wide read fallback.
2
+ * The CLI retains its provider/MCP login outside the tool sandbox. Shells
3
+ * receive neither that environment nor native image reads/shell snapshots.
4
+ */
5
+ import { execFileSync } from 'node:child_process';
6
+ import { lstatSync, readdirSync, realpathSync } from 'node:fs';
7
+ import { isAbsolute, join, dirname } from 'node:path';
8
+ import { git } from './git.mjs';
9
+ import { codexResearchHelper } from './codexResearch.mjs';
10
+ import { unattendedEnv } from './noWindowEnv.mjs';
11
+
12
+ export function projectReadBoundary({ cwd, knowledgeDir, env = process.env }) {
13
+ if (process.platform !== 'linux') throw new Error('Project-scoped Codex reads require Linux or WSL.');
14
+ if (!isAbsolute(cwd ?? '')) throw new Error('Project-scoped Codex reads require an absolute project directory.');
15
+ const helper = codexResearchHelper(env);
16
+ const version = execFileSync(helper, ['--version'], { encoding: 'utf8', timeout: 10000, env: unattendedEnv(env) }).match(/^codex-cli (\d+)\.(\d+)\.(\d+)/);
17
+ if (!version || version.slice(1).map(Number).reduce((n, v) => n * 1000 + v, 0) < 159002)
18
+ throw new Error('Project-scoped reads need Codex CLI 0.159.2 or newer. Update Codex before retrying.');
19
+ const project = realpathSync(cwd);
20
+ // Resolve both: linked worktrees have their own HEAD/index and shared
21
+ // objects/refs in the common directory. Never grant the parent checkout.
22
+ const gitOpts = { timeout: 10000, env: { PATH: env.PATH, HOME: '/nonexistent', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' } };
23
+ const local = realpathSync(git(['rev-parse', '--absolute-git-dir'], project, gitOpts));
24
+ const common = realpathSync(git(['rev-parse', '--path-format=absolute', '--git-common-dir'], project, gitOpts));
25
+ const paths = [project, local, common];
26
+ // Git directories also hold daemon login/thread/browser stores. They are
27
+ // not history. Mask non-Git directories, including other worktree stores.
28
+ const metadata = new Set(['HEAD', 'index', 'commondir', 'gitdir', 'config', 'config.worktree', 'objects', 'refs', 'logs', 'packed-refs', 'shallow', 'description', 'info', 'branches']);
29
+ const denied = [];
30
+ for (const dir of new Set([local, common])) {
31
+ for (const name of readdirSync(dir)) {
32
+ if (dir === common && name === 'worktrees' && local !== common) {
33
+ for (const sibling of readdirSync(join(common, name))) {
34
+ const path = join(common, name, sibling);
35
+ if (path !== local && path !== dirname(local)) denied.push(path);
36
+ }
37
+ } else if (!metadata.has(name) && lstatSync(join(dir, name)).isDirectory()) denied.push(join(dir, name));
38
+ }
39
+ }
40
+ if (knowledgeDir) paths.push(realpathSync(knowledgeDir));
41
+ return { paths: [...new Set(paths)], denied, helper };
42
+ }
43
+
44
+ export function projectReadFence({ paths, denied = [], helper }) {
45
+ const rules = { ':root': 'deny', ':minimal': 'read', [helper]: 'read' };
46
+ for (const path of paths) {
47
+ if (!isAbsolute(path) || path === '/') throw new Error('Invalid project read boundary.');
48
+ rules[path] = 'read';
49
+ }
50
+ for (const path of denied) rules[path] = 'deny';
51
+ const table = '{' + Object.entries(rules).map(([path, access]) => `${JSON.stringify(path)}=${JSON.stringify(access)}`).join(',') + '}';
52
+ return [
53
+ '-c', `permissions.flowviantproject.filesystem=${table}`,
54
+ '-c', 'permissions.flowviantproject.network.enabled=false',
55
+ '-c', 'default_permissions="flowviantproject"',
56
+ '-c', 'approval_policy="never"',
57
+ '-c', 'project_doc_max_bytes=0',
58
+ '-c', 'features.shell_snapshot=false', '-c', 'allow_login_shell=false',
59
+ '-c', 'shell_environment_policy.inherit="none"',
60
+ '-c', 'shell_environment_policy.set={PATH="/usr/bin:/bin",HOME="/nonexistent",GIT_CONFIG_NOSYSTEM="1",GIT_CONFIG_GLOBAL="/dev/null",GIT_PAGER="cat",BROWSER="none"}',
61
+ '--disable', 'view_image',
62
+ ];
63
+ }
@@ -4,6 +4,7 @@
4
4
  * Web search is a CLI tool, not direct shell network access.
5
5
  */
6
6
  import { execFileSync } from 'node:child_process';
7
+ import { codexHome } from './codexHome.mjs';
7
8
  import { git } from './git.mjs';
8
9
  import { ensureRealSubdir, realDirState } from './ownedDirs.mjs';
9
10
  import { closeSync, constants, copyFileSync, existsSync, fstatSync, openSync, readFileSync, readSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
@@ -77,12 +78,16 @@ export function codexResearchHelper(env = process.env) {
77
78
  * in the shared fenced tail (sub-agents, browser, image generation, the
78
79
  * person's config and rules), with the web left on: it is this posture's tool.
79
80
  */
80
- export function researchFence({ cwd, helper }) {
81
+ export function researchFence({ cwd, helper, generatedImages }) {
81
82
  if (process.platform !== 'linux' || !isAbsolute(cwd ?? '') || !isAbsolute(helper ?? ''))
82
83
  throw new Error('Isolated Codex research requires Linux or WSL and absolute sandbox paths.');
83
84
  const artifacts = join(cwd, '.flowviant/artifacts');
84
85
  const scratch = join(cwd, '.flowviant/tmp');
85
86
  const rules = { ':root': 'deny', ':minimal': 'read', [helper]: 'read', [cwd]: 'read', [artifacts]: 'write', [scratch]: 'write' };
87
+ if (generatedImages) {
88
+ if (!isAbsolute(generatedImages)) throw new Error('Generated image directory must be absolute.');
89
+ rules[generatedImages] = 'read';
90
+ }
86
91
  const table = '{' + Object.entries(rules).map(([path, access]) => `${JSON.stringify(path)}=${JSON.stringify(access)}`).join(',') + '}';
87
92
  return [
88
93
  '-c', `permissions.flowviantresearch.filesystem=${table}`,
@@ -93,7 +98,7 @@ export function researchFence({ cwd, helper }) {
93
98
  '-c', 'features.shell_snapshot=false', '-c', 'allow_login_shell=false',
94
99
  '-c', 'shell_environment_policy.inherit="none"',
95
100
  '-c', `shell_environment_policy.set={PATH="/usr/bin:/bin",HOME=${JSON.stringify(scratch)},TMPDIR=${JSON.stringify(scratch)},BROWSER="none"}`,
96
- '--disable', 'view_image',
101
+ '--disable', 'view_image', // native host-file reads bypass the tool fence
97
102
  '--skip-git-repo-check',
98
103
  ];
99
104
  }
@@ -168,9 +173,17 @@ export async function runIsolatedResearch(opts, run, {
168
173
  const rewrite = (text) => typeof text === 'string'
169
174
  ? text.replaceAll(original, snapshot).replaceAll(opts.knowledgeDir || '\0', knowledge || '\0') : text;
170
175
  const intro = 'This fresh research turn uses an isolated read-only copy. Prior reports are available under .flowviant/artifacts/; previous CLI conversations are not resumed because they may contain host secrets. Git metadata, .env files, credential files, symlinks and personal CLI settings are omitted. Write the Markdown report only under .flowviant/artifacts/. Web search is available; shell networking is disabled. Use exec_command with shell=/bin/bash and login=false; personal shells are outside the fence.\n\n';
176
+ // Only native output pixels, never the surrounding login/config home.
177
+ let generatedImages;
178
+ if (opts.mcpEnv?.CODEX_HOME) {
179
+ const imageHome = realpathSync(codexHome({ ...process.env, ...opts.mcpEnv }));
180
+ if (!ensureRealSubdir(imageHome, 'generated_images'))
181
+ throw new Error('The native generated image directory is not a real directory.');
182
+ generatedImages = join(imageHome, 'generated_images');
183
+ }
171
184
  const result = await run({ ...opts, resume: false, resumeThreadId: null, resumeConversationId: null, cwd: snapshot, knowledgeDir: knowledge,
172
185
  prompt: intro + rewrite(opts.prompt), system: rewrite(opts.system),
173
- researchIsolation: { cwd: snapshot, helper } });
186
+ researchIsolation: { cwd: snapshot, helper, generatedImages } });
174
187
  // Made, or found, real at publication too: every component checked again.
175
188
  if (!ensureRealSubdir(original, '.flowviant/artifacts'))
176
189
  throw new Error('The research artifact destination is not a real directory.');
@@ -21,6 +21,12 @@
21
21
  */
22
22
 
23
23
  export const COMMANDS = [
24
+ {
25
+ name: 'maintenance', usesCheckout: false, hidden: true,
26
+ usage: 'flowviant maintenance <request|release> <token>',
27
+ summary: 'Hold new runs for a manual WSL restart; existing work finishes.',
28
+ group: 'manage',
29
+ },
24
30
  {
25
31
  name: 'start',
26
32
  usesCheckout: true,
@@ -15,7 +15,9 @@
15
15
  * {
16
16
  * fleetToken, projectId, mcpUrl, // the LEGACY MIRROR
17
17
  * projects: { // v2: every connected project
18
- * [projectId]: { fleetToken, mcpUrl, name, repoRoot, savedAt }
18
+ * [projectId]: { fleetToken, mcpUrl, name, repoRoot,
19
+ * repositories: { app: '/code/app', cli: '/code/cli' },
20
+ * repositoriesRegistered, savedAt }
19
21
  * }
20
22
  * }
21
23
  *
@@ -27,7 +29,7 @@
27
29
  * WHICH CREDENTIAL A START USES — resolution, in order:
28
30
  * --fleet / FLOWVIANT_FLEET the operator said, verbatim (config.mjs).
29
31
  * --project <name|id> an entry, named without a prompt.
30
- * the entry BOUND to this repo repoRoot recorded at login/confirm, compared
32
+ * the entry BOUND to this repo any named root recorded at login/confirm, compared
31
33
  * by realpath — the no-ambiguity path.
32
34
  * one unbound entry served, with a one-time TTY confirm that
33
35
  * binds it (headless keeps the old behaviour —
@@ -36,7 +38,7 @@
36
38
  * stored project and asks (startProject.mjs), or — with
37
39
  * no TTY — refuses in words that name them all.
38
40
  *
39
- * The repoRoot binding is a SAFETY line, not bookkeeping: serving project X
41
+ * Each repository binding is a SAFETY line, not bookkeeping: serving project X
40
42
  * from a repo that is not X's checkout materializes X's session worktrees and
41
43
  * decrypted env inside the wrong repository. Binding happens only at moments a
42
44
  * HUMAN was present (login, an answered confirm, an explicit pick) — never
@@ -46,7 +48,7 @@
46
48
 
47
49
  import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
48
50
  import { homedir } from 'node:os';
49
- import { join } from 'node:path';
51
+ import { basename, join } from 'node:path';
50
52
  import { printable } from './printable.mjs';
51
53
  import { connectedOn } from './credentialDate.mjs';
52
54
  import { terminalCommand } from './launchCommand.mjs';
@@ -101,6 +103,8 @@ export function listStoredProjects() {
101
103
  mcpUrl: e.mcpUrl ?? null,
102
104
  name: typeof e.name === 'string' && e.name ? e.name : null,
103
105
  repoRoot: typeof e.repoRoot === 'string' && e.repoRoot ? e.repoRoot : null,
106
+ repositories: storedRepositories(e),
107
+ repositoriesRegistered: e.repositoriesRegistered === true,
104
108
  savedAt: e.savedAt ?? null,
105
109
  });
106
110
  }
@@ -111,6 +115,7 @@ export function listStoredProjects() {
111
115
  mcpUrl: f.mcpUrl ?? null,
112
116
  name: null,
113
117
  repoRoot: null,
118
+ repositories: {},
114
119
  savedAt: null,
115
120
  });
116
121
  }
@@ -202,10 +207,11 @@ export function projectRowLabel(e, entries) {
202
207
  export function repoCollisions(entries) {
203
208
  const groups = [];
204
209
  for (const e of entries) {
205
- if (!e?.repoRoot) continue;
206
- const g = groups.find((x) => samePath(x.repoRoot, e.repoRoot));
207
- if (g) g.entries.push(e);
208
- else groups.push({ repoRoot: e.repoRoot, entries: [e] });
210
+ for (const repoRoot of Object.values(storedRepositories(e))) {
211
+ const g = groups.find((x) => samePath(x.repoRoot, repoRoot));
212
+ if (g) { if (!g.entries.includes(e)) g.entries.push(e); }
213
+ else groups.push({ repoRoot, entries: [e] });
214
+ }
209
215
  }
210
216
  return groups.filter((g) => g.entries.length > 1);
211
217
  }
@@ -228,7 +234,7 @@ export function repoCollisions(entries) {
228
234
  */
229
235
  export function boundElsewhere(entries, repoRoot, projectId) {
230
236
  if (!repoRoot) return [];
231
- return entries.filter((e) => e.projectId !== projectId && samePath(e.repoRoot, repoRoot));
237
+ return entries.filter((e) => e.projectId !== projectId && isStoredRepo(e, repoRoot));
232
238
  }
233
239
 
234
240
  /**
@@ -313,17 +319,45 @@ function mutate(fn) {
313
319
  return f;
314
320
  }
315
321
 
322
+ /** v2 repoRoot remains the primary mirror; v3 retains every named checkout. */
323
+ function storedRepositories(e) {
324
+ const entries = e?.repositories && typeof e.repositories === 'object' && !Array.isArray(e.repositories)
325
+ ? Object.entries(e.repositories).filter(([name, root]) => /^[a-z0-9][a-z0-9_-]*$/.test(name) && typeof root === 'string' && root)
326
+ : [];
327
+ if (e?.repoRoot && !entries.some(([, root]) => samePath(root, e.repoRoot))) entries.push(['app', e.repoRoot]);
328
+ return Object.fromEntries(entries);
329
+ }
330
+
331
+ export function isStoredRepo(entry, repoRoot) {
332
+ return Boolean(repoRoot && Object.values(storedRepositories(entry)).some((root) => samePath(root, repoRoot)));
333
+ }
334
+
335
+ function bindEntry(entry, repoRoot, repositoryName, isPrimary = false) {
336
+ const repos = storedRepositories(entry);
337
+ const existing = Object.entries(repos).find(([, root]) => samePath(root, repoRoot));
338
+ const name = repositoryName ?? existing?.[0] ?? (Object.keys(repos).length ? basename(repoRoot).toLowerCase().replace(/[^a-z0-9_-]/g, '-') : 'app');
339
+ if (Object.hasOwn(repos, name) && !samePath(repos[name], repoRoot)) {
340
+ throw new Error(`Repository ${name} is already connected at ${repos[name]}.`);
341
+ }
342
+ // A settings rename keeps the path while replacing the old name.
343
+ if (existing && existing[0] !== name) delete repos[existing[0]];
344
+ entry.repositories = { ...repos, [name]: repoRoot };
345
+ if (!entry.repoRoot || isPrimary) entry.repoRoot = repoRoot;
346
+ }
347
+
316
348
  /** A fresh login: upsert the entry AND point the legacy mirror at it. */
317
- export function saveLogin({ fleetToken, projectId, mcpUrl, name, repoRoot }) {
349
+ export function saveLogin({ fleetToken, projectId, mcpUrl, name, repoRoot, repositoryName, isPrimary, repositoryBindings = [], repositoriesRegistered }) {
318
350
  mutate((f) => {
319
351
  f.projects[projectId] = {
320
352
  ...(f.projects[projectId] ?? {}),
321
353
  fleetToken,
322
354
  mcpUrl: mcpUrl ?? null,
323
355
  ...(safeName(name) ? { name: safeName(name) } : {}),
324
- ...(repoRoot ? { repoRoot } : {}),
325
356
  savedAt: new Date().toISOString(),
357
+ ...(repositoriesRegistered !== undefined ? { repositoriesRegistered } : {}),
326
358
  };
359
+ for (const binding of repositoryBindings) bindEntry(f.projects[projectId], binding.repoRoot, binding.name, binding.isPrimary);
360
+ if (repoRoot) bindEntry(f.projects[projectId], repoRoot, repositoryName, isPrimary);
327
361
  f.fleetToken = fleetToken;
328
362
  f.projectId = projectId;
329
363
  f.mcpUrl = mcpUrl ?? null;
@@ -337,7 +371,7 @@ export function selectStoredProject(projectId, { bindRepoRoot } = {}) {
337
371
  mutate((f) => {
338
372
  const e = f.projects[projectId];
339
373
  if (!e) return;
340
- if (bindRepoRoot) e.repoRoot = bindRepoRoot;
374
+ if (bindRepoRoot) bindEntry(e, bindRepoRoot);
341
375
  f.fleetToken = e.fleetToken;
342
376
  f.projectId = projectId;
343
377
  f.mcpUrl = e.mcpUrl ?? null;
@@ -397,10 +431,10 @@ export function forgetStoredProject(ref) {
397
431
  return { entry: gone };
398
432
  }
399
433
 
400
- export function bindStoredRepo(projectId, repoRoot) {
434
+ export function bindStoredRepo(projectId, repoRoot, repositoryName) {
401
435
  if (!repoRoot) return;
402
436
  mutate((f) => {
403
- if (f.projects[projectId]) f.projects[projectId].repoRoot = repoRoot;
437
+ if (f.projects[projectId]) bindEntry(f.projects[projectId], repoRoot, repositoryName);
404
438
  });
405
439
  }
406
440
 
@@ -454,15 +488,16 @@ export function resolveStoredCredential(argv = process.argv, cwd = process.cwd()
454
488
  const projectArg = i >= 0 ? argv[i + 1] : undefined;
455
489
  if (projectArg !== undefined) {
456
490
  const m = matchStoredProject(projectArg);
457
- return m.entry ? { entry: m.entry, source: 'project-flag' } : { error: m.error };
491
+ const repoRoot = detectRepoRoot(cwd);
492
+ return m.entry ? { entry: isStoredRepo(m.entry, repoRoot) ? { ...m.entry, repoRoot } : m.entry, source: 'project-flag' } : { error: m.error };
458
493
  }
459
494
  const entries = listStoredProjects();
460
495
  if (entries.length === 0) return { none: true };
461
496
  const repoRoot = detectRepoRoot(cwd);
462
497
 
463
498
  if (repoRoot) {
464
- const bound = entries.filter((e) => samePath(e.repoRoot, repoRoot));
465
- if (bound.length === 1) return { entry: bound[0], source: 'repo', repoRoot };
499
+ const bound = entries.filter((e) => isStoredRepo(e, repoRoot));
500
+ if (bound.length === 1) return { entry: { ...bound[0], repoRoot }, source: 'repo', repoRoot };
466
501
  if (bound.length > 1) return { choices: bound, reason: 'multiple-bound', repoRoot };
467
502
  }
468
503
 
@@ -22,12 +22,28 @@ import { createHash } from 'node:crypto';
22
22
  import { homedir } from 'node:os';
23
23
  import { join } from 'node:path';
24
24
 
25
- /** Every daemon lock's file name: `daemon-` + 12 hex of the credential's hash. */
25
+ /** Every daemon lock's file name: `daemon-` + 12 hex of its identity hash. */
26
26
  export const DAEMON_LOCK_RE = /^daemon-[0-9a-f]{12}\.lock$/;
27
27
 
28
28
  /** Deliberately a HASH: a credential must never become a filename. */
29
29
  export function instanceLockPath(fleetToken, home = homedir()) {
30
- const key = createHash('sha256').update(String(fleetToken || 'anon')).digest('hex').slice(0, 12);
30
+ const projectId = projectForCredential(fleetToken, home);
31
+ const key = createHash('sha256').update(projectId ? `project:${projectId}` : String(fleetToken || 'anon')).digest('hex').slice(0, 12);
32
+ return join(home, '.flowviant', `daemon-${key}.lock`);
33
+ }
34
+
35
+ /** Keep status, stop, draining and startup on the same project identity. */
36
+ export function projectForCredential(token, home = homedir()) {
37
+ try {
38
+ const stored = JSON.parse(nodeFs.readFileSync(join(home, '.flowviant', 'credentials.json'), 'utf8'));
39
+ return Object.entries(stored.projects ?? {}).find(([, entry]) => entry?.fleetToken === token)?.[0] ??
40
+ (stored.fleetToken === token ? stored.projectId : null);
41
+ } catch { return null; }
42
+ }
43
+
44
+ /** An old daemon may still hold the credential-keyed lock. */
45
+ export function legacyInstanceLockPath(token, home = homedir()) {
46
+ const key = createHash('sha256').update(String(token || 'anon')).digest('hex').slice(0, 12);
31
47
  return join(home, '.flowviant', `daemon-${key}.lock`);
32
48
  }
33
49
 
@@ -57,7 +73,9 @@ export function readLockFile(path, { fs = nodeFs } = {}) {
57
73
 
58
74
  /** The record in this credential's lock, or null. */
59
75
  export function readLockHolder(fleetToken) {
60
- return readLockFile(instanceLockPath(fleetToken));
76
+ const current = readLockFile(instanceLockPath(fleetToken));
77
+ const legacy = readLockFile(legacyInstanceLockPath(fleetToken));
78
+ return current && legacy?.pid === current.pid && legacy.draining ? legacy : current ?? legacy;
61
79
  }
62
80
 
63
81
  /**
@@ -68,14 +86,16 @@ export function readLockHolder(fleetToken) {
68
86
  * neighbour in the same repo, `flowviant stop` sweeping the lot, uninstall
69
87
  * protecting the copy a live daemon runs from. An unreadable (or absent)
70
88
  * directory is not an error here, it is an empty machine. `fs` and `home` are
71
- * injectable for uninstall's test seams.
89
+ * injectable for uninstall's test seams. The manual WSL restart asks for
90
+ * `strict`: only a missing directory is empty; other scan failures throw.
72
91
  */
73
- export function listDaemonLocks({ fs = nodeFs, home = homedir() } = {}) {
92
+ export function listDaemonLocks({ fs = nodeFs, home = homedir(), strict = false } = {}) {
74
93
  const dir = join(home, '.flowviant');
75
94
  let names;
76
95
  try {
77
96
  names = fs.readdirSync(dir);
78
- } catch {
97
+ } catch (error) {
98
+ if (strict && error.code !== 'ENOENT') throw error;
79
99
  return [];
80
100
  }
81
101
  return names
@@ -64,6 +64,9 @@ export function desktopStatus({ entries = listStoredProjects(), runtimes = detec
64
64
  holder: liveState?.holder ?? null,
65
65
  // Measured by the daemon; null = unknown, never idle (see `busyOf`).
66
66
  busy: busyOf(liveState, nowMs),
67
+ maintenance: busyOf(liveState, nowMs) !== null && typeof liveState?.maintenance === 'string' ? liveState.maintenance : null,
68
+ maintenanceEpoch: busyOf(liveState, nowMs) !== null && typeof liveState?.maintenanceEpoch === 'string' ? liveState.maintenanceEpoch : null,
69
+ liveAgents: busyOf(liveState, nowMs) !== null && Number.isInteger(liveState?.liveAgents) && liveState.liveAgents >= 0 ? liveState.liveAgents : null,
67
70
  // What a standing-down daemon is finishing (`web → prod`), from its
68
71
  // lock's draining mark; null when it is not draining or unknown. A
69
72
  // daemon→desktop report field: an older tray ignores it.
@@ -0,0 +1,19 @@
1
+ /** A discovery contract for the person's CLI, never a server-side diagnosis. */
2
+ export const REGRESSION_INVESTIGATION = `REGRESSION INVESTIGATION — for "this existed before and now is gone", restoration requests, and bugs that persist after Done:
3
+ 1. Keep the REQUESTED OUTCOME separate from a proposed cause. The runtime baseline names cwd, HEAD, execution base and dirty state; it does not prove you inspected relevant code. Reconcile differing or dirty baselines before relying on them.
4
+ 2. Before naming a cause, formula or restoration location, trace current rendering and data flow: locate the UI label/symbol, its render conditions and formatting, the calculation, its data provider, missing/loading inputs, and relevant tests. Reading tests is not running them; never claim tests passed merely because you read them. Do not run write-producing tests or builds under this read-only profile.
5
+ 3. Inspect available local Git history with supported plain readers: git log --oneline -- <relative-path>, git log -p --follow -- <relative-path>, git log -S delta -- <relative-path>, git show <commit>:<relative-path>, and relevant diffs. Compare the previous implementation with current code. Missing/shallow history or an unavailable deployed version limits the comparison; say exactly which comparison remains unverified.
6
+ 4. Explain only supported evidence. A surviving hidden display needs its render condition investigated, not an invented replacement formula. A missing input needs its provider/comparison data traced, not a claim that the display was deleted. A removed implementation needs a verified historical file/commit and a compatibility check against current code before proposing restoration.
7
+ 5. A board card marked Done is history, not proof that an implementation shipped or that the reported behavior works in the deployed version. In capture, dedupe with list_cards and list_staged; use find_cards/read_card for held or completed matches. Refine a pending/staged match in place. A recurrence after Done is new work with followUpOf, never proof of its cause. Scratch planning instead compares the supplied cards and live-agent context; it has no staging MCP.
8
+ 6. A failed read requires supported recovery before claiming repository discovery is unavailable. Preserve the actual failure and each attempted alternative; distinguish syntax refusal, permission denial, missing path/history and absent tool. Runtime outcomes are observations; model-authored path/line/commit citations and causal explanations remain separate claims. A request alone proves no successful read.
9
+ 7. In capture, still STAGE FIRST: if discovery remains unresolved, stage an investigation-first fix for the requested outcome, requiring reproduction and cause establishment before implementation. State unverified causes in assumed, explicit discovery limits and failures/attempts in the brief, and do not invent a formula or restoration path. Use optional grounding when the served staging tool advertises it; otherwise put requested outcome, observed code/history, discovery limits and recovery attempts in clearly separate description sections. Stage before asking the one remaining person-only question, and edit_staged after its answer rather than creating a twin. In scratch planning, retain that investigation-first ordering in the proposal; do not turn an unverified explanation into implementation instructions.`;
10
+
11
+ export function discoveryGuide(runtime, lane) {
12
+ const readers = runtime === 'claude'
13
+ ? `Claude: scoped Read and Glob, in-workspace Grep, and approved plain shell readers (cat/head/ls/wc and git log/show/diff/rev-parse). The read guard rejects shell composition, expansions/globs and unsafe paths; quoted pipes and bracket Git paths can still be refused. Quotes/escaping do not bypass the guard. Prefer native Read/Grep/Glob after such a refusal. Replace git log -G 'delta|change' with separate plain git log -S delta and git log -S change calls. For a bracket working-tree path use native Read with the literal path; for history use a supported parent-relative Git path or a scoped commit diff and inspect the relevant entry. Never disable the guard or request unrestricted Bash.`
14
+ : runtime === 'codex'
15
+ ? `Codex: this profile uses a read-only sandbox with shell discovery such as cat, head, rg and plain Git readers; Claude's dedicated Read/Grep/Glob and read guard are not guaranteed here. Use only tools advertised this turn. No native web, subagents, browser/apps or unrestricted shell are granted. Read only the repository and explicitly supplied project knowledge.`
16
+ : `This runtime: use the native file/list/search readers actually advertised this turn. Shell availability is unverified and may be denied; do not claim all reading is blocked after one shell refusal. Keep the read-only profile.`;
17
+ return `DISCOVERY CAPABILITIES (${runtime}, ${lane}):\n${readers}\n` +
18
+ `Recovery order: use a cwd-relative path; use one plain reader rather than a composed command; use native Read/Grep/Glob where supported, or shell cat/head/rg where permitted; use git show <commit>:<relative-path> when a working-tree file is missing and history is available. If a utility is absent, switch to an advertised reader. Do not try to evade a secret/path denial. Re-read the actual result after each attempt. Preserve the first failure and subsequent recovery attempts, including an unresolved result; then state the remaining limit rather than asking the person to disable protections. ${lane === 'capture' ? 'Only the scoped Flowviant staging tools write records; never change repository files.' : 'Scratch planning has no Flowviant MCP; return a proposal only.'}`;
19
+ }