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.
- package/README.md +41 -0
- package/bin/cli.mjs +44 -0
- package/bin/lib/agentAsk.mjs +123 -0
- package/bin/lib/agentBrief.mjs +36 -0
- package/bin/lib/agentFootprint.mjs +76 -0
- package/bin/lib/agentInternalWait.mjs +4 -3
- package/bin/lib/agentPlan.mjs +86 -7
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentRepositories.mjs +48 -0
- package/bin/lib/agentTaskKinds.mjs +1 -1
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +183 -19
- package/bin/lib/artifacts.mjs +6 -3
- package/bin/lib/checkoutIdentity.mjs +25 -0
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudeInput.mjs +32 -0
- package/bin/lib/claudePosture.mjs +110 -14
- package/bin/lib/claudeSessions.mjs +20 -0
- package/bin/lib/claudeStream.mjs +20 -2
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexResearch.mjs +16 -3
- package/bin/lib/commandSpecs.mjs +6 -0
- package/bin/lib/credentials.mjs +52 -17
- package/bin/lib/daemonLocks.mjs +26 -6
- package/bin/lib/desktopContract.mjs +3 -0
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/fleet.mjs +65 -10
- package/bin/lib/fleetReports.mjs +44 -3
- package/bin/lib/fleetRoster.mjs +1 -89
- package/bin/lib/fleetStartup.mjs +11 -14
- package/bin/lib/holder.mjs +4 -37
- package/bin/lib/hooks/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +13 -4
- package/bin/lib/imageArtifact.mjs +74 -0
- package/bin/lib/instance.mjs +20 -7
- package/bin/lib/landed.mjs +2 -2
- package/bin/lib/login.mjs +39 -4
- package/bin/lib/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/placeLock.mjs +9 -7
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -0
- package/bin/lib/prWorkflow.mjs +15 -0
- package/bin/lib/projectLanded.mjs +16 -0
- package/bin/lib/projectRepositories.mjs +60 -0
- package/bin/lib/projectToolRuntimes.mjs +227 -3
- package/bin/lib/projectTools.mjs +63 -5
- package/bin/lib/projectWorktreeHome.mjs +25 -0
- package/bin/lib/prompts.mjs +170 -42
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/repositoryDelivery.mjs +64 -0
- package/bin/lib/repositoryMerge.mjs +116 -0
- package/bin/lib/repositoryPlaces.mjs +179 -0
- package/bin/lib/repositorySetup.mjs +58 -0
- package/bin/lib/repositoryTool.mjs +71 -0
- package/bin/lib/runTurn.mjs +58 -18
- package/bin/lib/runtimeAntigravity.mjs +2 -1
- package/bin/lib/runtimeCapabilities.mjs +77 -3
- package/bin/lib/runtimeClaude.mjs +31 -10
- package/bin/lib/runtimeCodex.mjs +28 -59
- package/bin/lib/runtimeDetection.mjs +6 -0
- package/bin/lib/runtimeEvents.mjs +12 -1
- package/bin/lib/runtimeOfferings.mjs +75 -3
- package/bin/lib/shipMerge.mjs +5 -2
- package/bin/lib/startProject.mjs +4 -4
- package/bin/lib/trace.mjs +8 -3
- package/bin/lib/turnDeclaration.mjs +88 -0
- package/bin/lib/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnLock.mjs +48 -8
- package/bin/lib/turnProfile.mjs +15 -3
- package/bin/lib/work.mjs +48 -29
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentCheck.mjs +6 -5
- package/bin/lib/workAgentMerges.mjs +19 -2
- package/bin/lib/workAgentPlans.mjs +21 -70
- package/bin/lib/workAgentPrecheck.mjs +10 -5
- package/bin/lib/workAgentPublish.mjs +18 -5
- package/bin/lib/workAgentRecovery.mjs +85 -0
- package/bin/lib/workAgentReview.mjs +5 -3
- package/bin/lib/workAgentTurnBegun.mjs +6 -4
- package/bin/lib/workAgentTurnExecution.mjs +167 -29
- package/bin/lib/workAgentTurnOutcome.mjs +20 -5
- package/bin/lib/workAgentTurns.mjs +92 -12
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workCappedTurn.mjs +10 -0
- package/bin/lib/workCommandAudit.mjs +8 -1
- package/bin/lib/workDelivery.mjs +21 -0
- package/bin/lib/workDiffs.mjs +18 -11
- package/bin/lib/workLiveMessages.mjs +129 -0
- package/bin/lib/workPlaceReports.mjs +5 -0
- package/bin/lib/workPlaces.mjs +24 -3
- package/bin/lib/workRetire.mjs +5 -0
- package/bin/lib/workSessionTurns.mjs +219 -52
- package/bin/lib/workShip.mjs +17 -16
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +6 -4
- package/package.json +7 -3
- 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
|
package/bin/lib/claudeStream.mjs
CHANGED
|
@@ -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 === '
|
|
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.');
|
package/bin/lib/commandSpecs.mjs
CHANGED
|
@@ -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,
|
package/bin/lib/credentials.mjs
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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 &&
|
|
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
|
|
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]
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
package/bin/lib/daemonLocks.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|