flowviant 0.115.0 → 0.117.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 (135) hide show
  1. package/README.md +31 -12
  2. package/bin/cli.mjs +13 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentInternalWait.mjs +83 -0
  5. package/bin/lib/agentPlan.mjs +56 -24
  6. package/bin/lib/agentPublish.mjs +12 -13
  7. package/bin/lib/agentTaskKinds.mjs +4 -7
  8. package/bin/lib/agySessions.mjs +3 -3
  9. package/bin/lib/artifactPreview.mjs +92 -18
  10. package/bin/lib/artifactReporter.mjs +16 -8
  11. package/bin/lib/artifacts.mjs +64 -60
  12. package/bin/lib/authproxy.mjs +2 -1
  13. package/bin/lib/childEnv.mjs +9 -4
  14. package/bin/lib/claude.mjs +10 -20
  15. package/bin/lib/claudePersonal.mjs +71 -6
  16. package/bin/lib/claudePosture.mjs +119 -42
  17. package/bin/lib/claudeSessions.mjs +7 -7
  18. package/bin/lib/claudeStream.mjs +7 -11
  19. package/bin/lib/cliLimit.mjs +2 -2
  20. package/bin/lib/codexHome.mjs +17 -0
  21. package/bin/lib/codexPersonal.mjs +44 -3
  22. package/bin/lib/codexRelay.mjs +21 -5
  23. package/bin/lib/codexResearch.mjs +180 -0
  24. package/bin/lib/config.mjs +37 -24
  25. package/bin/lib/conversationIds.mjs +51 -0
  26. package/bin/lib/credentials.mjs +2 -7
  27. package/bin/lib/daemonLocks.mjs +88 -0
  28. package/bin/lib/daemonState.mjs +5 -5
  29. package/bin/lib/deploy.mjs +6 -9
  30. package/bin/lib/deployCheckout.mjs +26 -22
  31. package/bin/lib/deployRunner.mjs +54 -18
  32. package/bin/lib/deployWire.mjs +32 -23
  33. package/bin/lib/desktopContract.mjs +3 -5
  34. package/bin/lib/env.mjs +104 -35
  35. package/bin/lib/fleet.mjs +7 -9
  36. package/bin/lib/fleetPost.mjs +172 -25
  37. package/bin/lib/fleetReports.mjs +130 -170
  38. package/bin/lib/fleetStartup.mjs +6 -6
  39. package/bin/lib/fleetWire.mjs +42 -0
  40. package/bin/lib/git.mjs +111 -61
  41. package/bin/lib/gitMergeIdentity.mjs +2 -8
  42. package/bin/lib/grant.mjs +23 -7
  43. package/bin/lib/holder.mjs +108 -81
  44. package/bin/lib/hooks/readGuard.mjs +41 -7
  45. package/bin/lib/install.mjs +80 -33
  46. package/bin/lib/instance.mjs +36 -83
  47. package/bin/lib/intakeDraft.mjs +4 -1
  48. package/bin/lib/knowledgeFileSync.mjs +31 -0
  49. package/bin/lib/knowledgeFiles.mjs +28 -83
  50. package/bin/lib/landed.mjs +22 -29
  51. package/bin/lib/launchCommand.mjs +91 -0
  52. package/bin/lib/listeners.mjs +0 -5
  53. package/bin/lib/localSessions.mjs +52 -39
  54. package/bin/lib/login.mjs +22 -12
  55. package/bin/lib/loginPath.mjs +22 -17
  56. package/bin/lib/machineListing.mjs +6 -3
  57. package/bin/lib/mcp-cli.mjs +9 -10
  58. package/bin/lib/noWindowEnv.mjs +23 -3
  59. package/bin/lib/ownedDirs.mjs +99 -0
  60. package/bin/lib/pinnedCapture.mjs +383 -0
  61. package/bin/lib/prWorkflow.mjs +94 -17
  62. package/bin/lib/preflight.mjs +8 -25
  63. package/bin/lib/preview.mjs +19 -25
  64. package/bin/lib/previewGatePolicy.mjs +34 -9
  65. package/bin/lib/previewRegistry.mjs +4 -11
  66. package/bin/lib/printable.mjs +70 -7
  67. package/bin/lib/procRegistry.mjs +33 -13
  68. package/bin/lib/procScan.mjs +2 -3
  69. package/bin/lib/procStat.mjs +76 -0
  70. package/bin/lib/processGroup.mjs +48 -0
  71. package/bin/lib/processes.mjs +10 -24
  72. package/bin/lib/projectToolRuntimes.mjs +101 -16
  73. package/bin/lib/projectTools.mjs +50 -32
  74. package/bin/lib/prompts.mjs +41 -19
  75. package/bin/lib/repoPath.mjs +19 -0
  76. package/bin/lib/repoState.mjs +17 -19
  77. package/bin/lib/runTurn.mjs +268 -210
  78. package/bin/lib/runtimeAntigravity.mjs +16 -5
  79. package/bin/lib/runtimeCapabilities.mjs +140 -20
  80. package/bin/lib/runtimeClaude.mjs +55 -5
  81. package/bin/lib/runtimeCodex.mjs +245 -205
  82. package/bin/lib/runtimeDetection.mjs +2 -2
  83. package/bin/lib/runtimeEvents.mjs +16 -10
  84. package/bin/lib/runtimeLimits.mjs +5 -6
  85. package/bin/lib/runtimeOfferings.mjs +24 -11
  86. package/bin/lib/runtimes.mjs +10 -0
  87. package/bin/lib/shipSweep.mjs +5 -3
  88. package/bin/lib/shot.mjs +91 -17
  89. package/bin/lib/sleep.mjs +11 -0
  90. package/bin/lib/startProject.mjs +5 -5
  91. package/bin/lib/statePaths.mjs +40 -0
  92. package/bin/lib/tty.mjs +25 -15
  93. package/bin/lib/turnLock.mjs +7 -14
  94. package/bin/lib/turnProfile.mjs +1 -1
  95. package/bin/lib/ui.mjs +0 -3
  96. package/bin/lib/uninstall.mjs +30 -26
  97. package/bin/lib/update.mjs +79 -27
  98. package/bin/lib/uplinkScrub.mjs +296 -31
  99. package/bin/lib/vault.mjs +78 -10
  100. package/bin/lib/vaultDiff.mjs +5 -2
  101. package/bin/lib/views.mjs +37 -12
  102. package/bin/lib/wikiRunner.mjs +40 -61
  103. package/bin/lib/work.mjs +17 -7
  104. package/bin/lib/workAdoptCarry.mjs +2 -4
  105. package/bin/lib/workAgentCheck.mjs +104 -57
  106. package/bin/lib/workAgentMerges.mjs +62 -57
  107. package/bin/lib/workAgentPlans.mjs +99 -145
  108. package/bin/lib/workAgentPrecheck.mjs +71 -138
  109. package/bin/lib/workAgentPublish.mjs +6 -8
  110. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  111. package/bin/lib/workAgentTurnExecution.mjs +46 -53
  112. package/bin/lib/workAgentTurnReports.mjs +9 -37
  113. package/bin/lib/workAgentTurns.mjs +4 -3
  114. package/bin/lib/workAttachments.mjs +66 -11
  115. package/bin/lib/workBrain.mjs +9 -4
  116. package/bin/lib/workCappedTurn.mjs +109 -0
  117. package/bin/lib/workChildren.mjs +5 -5
  118. package/bin/lib/workCommandAudit.mjs +11 -2
  119. package/bin/lib/workDiffs.mjs +52 -6
  120. package/bin/lib/workIntake.mjs +35 -60
  121. package/bin/lib/workPlaceReports.mjs +7 -4
  122. package/bin/lib/workPlaces.mjs +96 -30
  123. package/bin/lib/workPreviews.mjs +47 -112
  124. package/bin/lib/workProcesses.mjs +8 -35
  125. package/bin/lib/workPullRequests.mjs +37 -56
  126. package/bin/lib/workReportQueue.mjs +100 -148
  127. package/bin/lib/workRetire.mjs +2 -1
  128. package/bin/lib/workSessionRuntime.mjs +38 -37
  129. package/bin/lib/workSessionTurns.mjs +111 -189
  130. package/bin/lib/workShip.mjs +43 -23
  131. package/bin/lib/workTurnShape.mjs +163 -0
  132. package/npm-shrinkwrap.json +59 -0
  133. package/package.json +3 -2
  134. package/bin/lib/fleetJobs.mjs +0 -120
  135. package/bin/lib/patchRevert.mjs +0 -60
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * THE READ GUARD (2026-09-23, 0.97.0) — a Claude Code `PreToolUse` hook that
4
- * refuses a Bash command able to WRITE A FILE OR RUN A PROGRAM, attached to
5
- * every curated posture (consult, plan, wiki, design, research) and never to
6
- * the build posture, whose `--dangerously-skip-permissions` is the operator's
7
- * own choice.
4
+ * refuses a Bash command able to WRITE A FILE OR RUN A PROGRAM, or (2026-10-10)
5
+ * to READ PAST THE TURN'S FENCE, attached to every curated posture (consult,
6
+ * plan, wiki, design, research) and never to the build posture, whose
7
+ * `--dangerously-skip-permissions` is the operator's own choice.
8
8
  *
9
9
  * WHY A HOOK AND NOT A NARROWER LIST. `--allowedTools 'Bash(git log:*)'` is a
10
10
  * PREFIX match: it says which program may start, never what its arguments may
@@ -50,6 +50,22 @@
50
50
  * · an environment assignment — any `GIT_*=` anywhere, and any `NAME=` as the
51
51
  * command's first word (`GIT_DIR`, `GIT_EXTERNAL_DIFF`, `PAGER`,
52
52
  * `LESSOPEN` all change what a reader runs).
53
+ * · (2026-10-10, the audit) a GIT READER AIMED OFF THE CHECKOUT. The CLI
54
+ * path-validates `cat`, `ls` and `head` (measured, claudePosture.mjs
55
+ * CONSULT_BASH) but nobody measured git, and `git diff` reads any file on
56
+ * the box: `--no-index` compares two arbitrary paths, and git switches to
57
+ * it on its own when a path lies outside the work tree — `git diff
58
+ * ~/.ssh/id_ed25519 /dev/null` prints the key. So `--no-index` is refused
59
+ * anywhere, and for git every argument that starts with `/` or `~` or has a
60
+ * `..` segment (split on `/`, `:` and `=`, so a revision range `a..b` or
61
+ * `main...HEAD` still passes). `cat`/`ls`/`head` keep their absolute
62
+ * paths: the wiki's vault and the knowledge library are named that way.
63
+ * · (2026-10-10) THE CHECKOUT'S SECRET FILES, BY NAME, for every reader:
64
+ * `.env`, `.env.<anything>`, `.dev.vars` and `.dev.vars.<anything>` — the
65
+ * shell's half of the postures' `Read(./.env*)` denies, which say nothing
66
+ * about `cat` — while the templates (`.example`, `.sample`, `.template`)
67
+ * still read. By NAME: a secret someone committed still shows in a whole
68
+ * `git show` or `git log -p`, as it would to anyone holding the repository.
53
69
  *
54
70
  * FAILS CLOSED: input it cannot parse is refused, because a fence that opens
55
71
  * whenever the CLI changes its hook payload is a fence that opens silently.
@@ -67,8 +83,15 @@
67
83
 
68
84
  import { pathToFileURL } from 'node:url';
69
85
 
70
- /** Substrings that turn a git reader into a writer or an executor. */
71
- const BANNED_FLAGS = ['--output', '--ext-diff', '--textconv', '--exec-path', '--git-dir', '--work-tree', '--config'];
86
+ /** Substrings that turn a git reader into a writer or an executor — or, for
87
+ * `--no-index` (2026-10-10), into a reader of any file on the box. */
88
+ const BANNED_FLAGS = ['--output', '--ext-diff', '--textconv', '--exec-path', '--git-dir', '--work-tree', '--config', '--no-index'];
89
+
90
+ /** A checkout secret file, and the templates beside it that still read. */
91
+ const SECRET_FILE = /^(\.env|\.dev\.vars)(\..+)?$/;
92
+ const SECRET_TEMPLATE = /\.(example|sample|template)$/;
93
+ /** The pieces of one word a path could hide in: `a/b`, `HEAD:a/b`, `--x=a/b`. */
94
+ const pathSegments = (w) => w.split(/[/:=]/);
72
95
 
73
96
  /** Shell composition: each is a second command or a redirect. */
74
97
  const SHELL = [
@@ -121,12 +144,23 @@ export function readGuardRefusal(command) {
121
144
  if (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
122
145
  return `an environment assignment (\`${words[0].split('=')[0]}=\`)`;
123
146
  }
147
+ for (const w of words) {
148
+ const secret = pathSegments(w).find((seg) => SECRET_FILE.test(seg) && !SECRET_TEMPLATE.test(seg));
149
+ if (secret) return `reading a secret file (\`${secret}\`)`;
150
+ }
151
+ if (words.length && /(^|\/)git$/.test(words[0])) {
152
+ for (const w of words.slice(1)) {
153
+ const value = w.startsWith('-') && w.includes('=') ? w.slice(w.indexOf('=') + 1) : w;
154
+ if (value.startsWith('/') || value.startsWith('~')) return `a path off the checkout (\`${w}\`)`;
155
+ if (pathSegments(w).includes('..')) return `a path that climbs out of the checkout (\`${w}\`)`;
156
+ }
157
+ }
124
158
  return null;
125
159
  }
126
160
 
127
161
  /** The sentence the model reads when a call is refused. */
128
162
  export const readGuardSentence = (why) =>
129
- `flowviant: this turn is read-only, so ${why} is refused here — it can write a file or run a program. ` +
163
+ `flowviant: this turn is read-only, so ${why} is refused here — it can write a file, run a program or read past this turn's fence. ` +
130
164
  'Run one plain read command at a time with no redirects, pipes or expansions, or use the Read, Grep and Glob tools.';
131
165
 
132
166
  function main() {
@@ -5,18 +5,31 @@
5
5
  * silently and never clobbering an existing install. Interactive login stays
6
6
  * yours (`claude` sign-in, `flowviant gh-auth`).
7
7
  *
8
- * gh is dropped into ~/.flowviant/bin (like cloudflared) rather than a system
9
- * path, so there's nothing to conflict with; addLocalBinToPath() puts that dir
10
- * on PATH so the daemon (and `flowviant gh-auth`) find it.
8
+ * gh is dropped into ~/.flowviant/bin (like cloudflared, and pinned and
9
+ * checksummed like it) rather than a system path, so there's nothing to
10
+ * conflict with; addLocalBinToPath() puts that dir on PATH so the daemon (and
11
+ * `flowviant gh-auth`) find it.
11
12
  */
12
13
 
13
14
  import { execFileSync } from 'node:child_process';
15
+ import { createHash } from 'node:crypto';
14
16
  import { writeFileSync, existsSync, mkdirSync, chmodSync, rmSync, cpSync } from 'node:fs';
15
17
  import { join } from 'node:path';
16
18
  import { homedir, platform, arch } from 'node:os';
17
19
 
18
20
  export const LOCAL_BIN = join(homedir(), '.flowviant', 'bin');
19
21
 
22
+ /** Is `bin` on PATH and answering `--version`? BOUNDED: a broken shim that
23
+ * hangs here would stall preflight — and the daemon behind it — forever. */
24
+ export function commandPresent(bin, { timeout = 10_000, exec = execFileSync } = {}) {
25
+ try {
26
+ exec(bin, ['--version'], { stdio: 'ignore', timeout });
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
20
33
  /** Put ~/.flowviant/bin first on PATH so bundled binaries (gh, cloudflared)
21
34
  * resolve by bare name for this process and everything it spawns. Idempotent. */
22
35
  export function addLocalBinToPath() {
@@ -61,53 +74,87 @@ export function installClaude(log) {
61
74
  }
62
75
  }
63
76
 
64
- async function latestGhVersion() {
65
- const res = await fetch('https://api.github.com/repos/cli/cli/releases/latest', {
66
- headers: { 'User-Agent': 'flowviant', Accept: 'application/vnd.github+json' },
67
- redirect: 'follow',
68
- });
69
- if (!res.ok) throw new Error(`gh release lookup failed (http ${res.status})`);
70
- const tag = (await res.json())?.tag_name;
71
- if (!tag) throw new Error('no gh release tag');
72
- return String(tag).replace(/^v/, '');
73
- }
77
+ /**
78
+ * gh: PINNED AND VERIFIED, LIKE CLOUDFLARED (audit 2026-10-10, F088/F110/F662).
79
+ * This used to ask api.github.com for `releases/latest`, untar whatever came
80
+ * back and chmod it — no digest, no timeout — into the directory
81
+ * addLocalBinToPath puts FIRST on PATH, where it then runs with the operator's
82
+ * GitHub login for every push and merge. preview.mjs's rule is the rule here:
83
+ * we only execute what we verified. Bumping this is a release act: take the
84
+ * digests from the release's own `gh_<version>_checksums.txt` and replace the
85
+ * version and every digest together.
86
+ */
87
+ export const GH_VERSION = '2.102.0';
88
+ export const GH_SHA256 = {
89
+ 'linux-amd64': 'bb766f710eef8ede859c18578c72c327597cd4c8a85b06001b1f3843c6019386',
90
+ 'linux-arm64': '7862c86c72f43df3a2d93ddde6f473285b4e2af61b494849846827e513ef6484',
91
+ 'macOS-amd64': 'b245f24eb2bf5f75b426b4c26da3651a107f8d5b6f4fddfbfccc5679041378b3',
92
+ 'macOS-arm64': 'da922c20d1792e5b2cbf375593d7a658acf034c12c84e007e71c76ef959c337e',
93
+ 'windows-amd64': 'ae64e556ecc240b200f7eba60d550e4bb60d78e860e69dd88c449405b86067f4',
94
+ 'windows-arm64': '5dcf12aa8525eabd0c46ec414f323ab6cf65229fc2cd46543cc705001bbaf223',
95
+ };
74
96
 
75
- /** Fetch the gh release archive into ~/.flowviant/bin and extract the binary —
76
- * the cloudflared pattern: isolated, no sudo, nothing to conflict with. Returns
77
- * the binary path or null (falls back to manual instructions). */
78
- export async function installGh(log) {
79
- const os = platform();
80
- const a = arch() === 'arm64' ? 'arm64' : 'amd64';
97
+ const GH_MANUAL = 'Install from https://cli.github.com, then run: gh auth login';
98
+
99
+ /** Fetch the pinned gh release archive, verify it, and put the binary in
100
+ * ~/.flowviant/bin — isolated, no sudo, nothing to conflict with. The digest
101
+ * is checked BEFORE anything is written or extracted (an archive is code
102
+ * too). Returns the binary path, or null after saying why and how to install
103
+ * it by hand. `fetch`, `os`, `cpu`, `dir` and the pin are injectable for the
104
+ * tests; nothing in the daemon passes them. */
105
+ export async function installGh(
106
+ log,
107
+ {
108
+ fetch: fetchImpl = (...a) => fetch(...a),
109
+ os = platform(),
110
+ cpu = arch(),
111
+ dir = LOCAL_BIN,
112
+ version = GH_VERSION,
113
+ sha256 = GH_SHA256,
114
+ timeoutMs = 120_000,
115
+ } = {},
116
+ ) {
117
+ const a = cpu === 'arm64' ? 'arm64' : cpu === 'x64' ? 'amd64' : cpu;
81
118
  const osName = os === 'darwin' ? 'macOS' : os === 'win32' ? 'windows' : 'linux';
82
119
  const ext = os === 'linux' ? 'tar.gz' : 'zip';
83
120
  const binName = os === 'win32' ? 'gh.exe' : 'gh';
84
- const dest = join(LOCAL_BIN, binName);
121
+ const dest = join(dir, binName);
122
+ const want = sha256[`${osName}-${a}`];
123
+ if (!want) {
124
+ log?.(`could not install gh automatically: this machine (${os}/${cpu}) has no pinned gh build to fetch. ${GH_MANUAL}`);
125
+ return null;
126
+ }
85
127
  let archive;
86
128
  let innerDir;
87
129
  try {
88
- mkdirSync(LOCAL_BIN, { recursive: true });
89
- const ver = await latestGhVersion();
90
- const stem = `gh_${ver}_${osName}_${a}`;
91
- const url = `https://github.com/cli/cli/releases/download/v${ver}/${stem}.${ext}`;
92
- log?.(`fetching gh ${ver} (${osName}-${a})…`);
93
- const res = await fetch(url, { redirect: 'follow' });
130
+ const stem = `gh_${version}_${osName}_${a}`;
131
+ const url = `https://github.com/cli/cli/releases/download/v${version}/${stem}.${ext}`;
132
+ log?.(`fetching gh ${version} (${osName}-${a})…`);
133
+ const res = await fetchImpl(url, { redirect: 'follow', signal: AbortSignal.timeout(timeoutMs) });
94
134
  if (!res.ok) throw new Error(`http ${res.status}`);
95
- archive = join(LOCAL_BIN, `${stem}.${ext}`);
96
- writeFileSync(archive, Buffer.from(await res.arrayBuffer()));
135
+ const buf = Buffer.from(await res.arrayBuffer());
136
+ const got = createHash('sha256').update(buf).digest('hex');
137
+ if (got !== want) {
138
+ log?.(
139
+ `refused to install gh ${version}: the download did not match its pinned checksum (expected ${want.slice(0, 12)}…, got ${got.slice(0, 12)}…). ${GH_MANUAL}`,
140
+ );
141
+ return null;
142
+ }
143
+ mkdirSync(dir, { recursive: true });
144
+ archive = join(dir, `${stem}.${ext}`);
145
+ writeFileSync(archive, buf);
97
146
  // linux ships tar.gz (GNU tar); mac + windows ship zip (bsdtar reads zip).
98
- execFileSync('tar', [os === 'linux' ? '-xzf' : '-xf', archive, '-C', LOCAL_BIN], {
147
+ execFileSync('tar', [os === 'linux' ? '-xzf' : '-xf', archive, '-C', dir], {
99
148
  stdio: 'ignore',
100
149
  });
101
- innerDir = join(LOCAL_BIN, stem);
150
+ innerDir = join(dir, stem);
102
151
  const inner = join(innerDir, 'bin', binName);
103
152
  if (!existsSync(inner)) throw new Error('gh binary not found in the archive');
104
153
  cpSync(inner, dest);
105
154
  if (os !== 'win32') chmodSync(dest, 0o755);
106
155
  return dest;
107
156
  } catch (e) {
108
- log?.(
109
- `could not install gh automatically (${e?.message ?? e}). Install from https://cli.github.com, then run: gh auth login`,
110
- );
157
+ log?.(`could not install gh automatically (${e?.message ?? e}). ${GH_MANUAL}`);
111
158
  return null;
112
159
  } finally {
113
160
  if (archive) rmSync(archive, { force: true });
@@ -87,7 +87,6 @@ import {
87
87
  closeSync,
88
88
  mkdirSync,
89
89
  openSync,
90
- readdirSync,
91
90
  readFileSync,
92
91
  renameSync,
93
92
  rmSync,
@@ -96,36 +95,25 @@ import {
96
95
  } from 'node:fs';
97
96
  import { homedir, platform } from 'node:os';
98
97
  import { join } from 'node:path';
99
- import { createHash } from 'node:crypto';
100
98
  import { VERSION, PROCESS_STARTED_AT } from './config.mjs';
101
99
  import { compareVersions } from './versionOrder.mjs';
102
100
  import { samePath } from './repoPath.mjs';
101
+ import { instanceLockPath, listDaemonLocks, readLockFile, readLockHolder } from './daemonLocks.mjs';
102
+ import { processAlive } from './procRegistry.mjs';
103
+ import { procStat } from './procStat.mjs';
103
104
 
104
- /** Deliberately a HASH: a credential must never become a filename. */
105
- export function instanceLockPath(fleetToken) {
106
- const key = createHash('sha256').update(String(fleetToken || 'anon')).digest('hex').slice(0, 12);
107
- return join(homedir(), '.flowviant', `daemon-${key}.lock`);
108
- }
105
+ // The lock file's name, record and scan live in daemonLocks.mjs (a leaf the
106
+ // readers share, 2026-10-10 F090); re-exported so callers keep one import.
107
+ export {
108
+ DAEMON_LOCK_RE,
109
+ instanceLockPath,
110
+ listDaemonLocks,
111
+ parseLockRecord,
112
+ readLockHolder,
113
+ } from './daemonLocks.mjs';
109
114
 
110
- /** Signal 0 — a liveness probe, not a kill. EPERM means alive and not ours. */
111
- function alive(pid) {
112
- if (!Number.isInteger(pid) || pid <= 0) return false;
113
- try {
114
- process.kill(pid, 0);
115
- return true;
116
- } catch (e) {
117
- return e.code === 'EPERM';
118
- }
119
- }
120
-
121
- function readHolder(path) {
122
- try {
123
- const v = JSON.parse(readFileSync(path, 'utf8'));
124
- return v && Number.isInteger(v.pid) && v.pid > 0 ? v : null;
125
- } catch {
126
- return null; // absent, truncated, or half-written — treat as no holder
127
- }
128
- }
115
+ /** Absent, truncated, half-written, or no positive pid — no holder. */
116
+ const readHolder = (path) => readLockFile(path);
129
117
 
130
118
  /**
131
119
  * WHAT THE DAEMON HOLDING THIS CREDENTIAL'S LOCK IS FINISHING — the `target →
@@ -136,21 +124,16 @@ function readHolder(path) {
136
124
  * already measured running.
137
125
  */
138
126
  export function drainingFor(fleetToken) {
139
- const what = readHolder(instanceLockPath(fleetToken))?.draining?.what;
127
+ const what = readLockHolder(fleetToken)?.draining?.what;
140
128
  return typeof what === 'string' && what ? what : null;
141
129
  }
142
130
 
143
131
  /** Read-only desktop measurement. No lock is unknown because an unguarded or
144
132
  * older daemon may still run; a stale lock with a dead pid measures empty. */
145
133
  export function daemonRunningFor(fleetToken) {
146
- const path = instanceLockPath(fleetToken);
147
- let raw;
148
- try { raw = readFileSync(path, 'utf8'); }
149
- catch { return null; }
150
- let holder;
151
- try { holder = JSON.parse(raw); } catch { return null; }
152
- if (!Number.isInteger(holder?.pid) || holder.pid <= 0) return null;
153
- if (!alive(holder.pid)) return false;
134
+ const holder = readLockHolder(fleetToken);
135
+ if (!holder) return null;
136
+ if (!processAlive(holder.pid)) return false;
154
137
  return stillTheHolder(holder);
155
138
  }
156
139
 
@@ -210,10 +193,7 @@ const ANCESTOR_DEPTH = 8;
210
193
  function parentOf(pid) {
211
194
  try {
212
195
  if (platform() === 'linux') {
213
- const raw = readFileSync(`/proc/${pid}/stat`, 'utf8');
214
- const close = raw.lastIndexOf(')');
215
- if (close < 0) return null;
216
- const ppid = Number.parseInt(raw.slice(close + 1).trim().split(/\s+/)[1], 10);
196
+ const ppid = procStat(pid)?.ppid;
217
197
  return Number.isInteger(ppid) && ppid > 0 ? ppid : null;
218
198
  }
219
199
  if (platform() === 'darwin') {
@@ -276,29 +256,14 @@ function neighbourLockPath(holder, fallback) {
276
256
  return NEIGHBOUR_PATHS.get(holder) ?? fallback;
277
257
  }
278
258
 
279
- /** Every daemon lock file on this machine, absolute, sorted for a stable read.
280
- * ONE scan, because two callers walk this directory for opposite reasons —
281
- * daemonInSameRepo looking for a neighbour to refuse or replace, stopAllDaemons
282
- * sweeping the lot — and the FILENAME PATTERN is the thing that must not drift
283
- * between them: it is what separates our locks from anything else living in
284
- * ~/.flowviant. An unreadable (or absent) directory is not an error here, it is
285
- * an empty machine. */
286
- function lockFiles() {
287
- try {
288
- return readdirSync(join(homedir(), '.flowviant'))
289
- .filter((f) => /^daemon-[0-9a-f]{12}\.lock$/.test(f))
290
- .sort()
291
- .map((f) => join(homedir(), '.flowviant', f));
292
- } catch {
293
- return [];
294
- }
295
- }
296
-
259
+ // Every daemon lock file on this machine comes from ONE scan, listDaemonLocks
260
+ // (daemonLocks.mjs): daemonInSameRepo looks for a neighbour to refuse or
261
+ // replace, stopAllDaemons sweeps the lot, and uninstall protects what a live
262
+ // daemon runs from — and the filename pattern must not drift between them.
297
263
  export function daemonInSameRepo(repoRoot, ownPath) {
298
- for (const path of lockFiles()) {
264
+ for (const { path, holder } of listDaemonLocks()) {
299
265
  if (path === ownPath) continue; // our own credential — the lock above owns that question
300
- const holder = readHolder(path);
301
- if (!holder || !alive(holder.pid)) continue;
266
+ if (!holder || !processAlive(holder.pid)) continue;
302
267
  if (isOurReexecParent(holder.pid)) continue; // ourselves mid self-update re-exec
303
268
  if (samePath(holder.repoRoot, repoRoot)) {
304
269
  NEIGHBOUR_PATHS.set(holder, path);
@@ -369,23 +334,11 @@ function processStartedAt(pid) {
369
334
  if (!Number.isInteger(pid) || pid <= 0) return null;
370
335
  try {
371
336
  if (platform() === 'linux') {
372
- // FIELD 22 OF /proc/<pid>/stat, and the parse is the whole trap. Field 2
373
- // is `comm`, which the kernel wraps in parens and which may contain BOTH
374
- // spaces and parens — it is the first 15 bytes of the executable's name,
375
- // and a name is not a token. A naive split on whitespace taking $22 then
376
- // lands on `nice` for any such process, and `nice` is a small integer, so
377
- // the answer is not a parse error but a plausible-looking "started at
378
- // boot" that sails past any isFinite guard. Measured: a binary named
379
- // `ev (i l) x` read 28308s too old that way.
380
- //
381
- // Every field after `comm` is a number or a single-character state, so no
382
- // ')' can appear later: the LAST ')' in the line is always the kernel's
383
- // own closing paren, even when comm itself ends in one.
384
- const raw = readFileSync(`/proc/${pid}/stat`, 'utf8');
385
- const close = raw.lastIndexOf(')');
386
- if (close < 0) return null;
387
- const fields = raw.slice(close + 1).trim().split(/\s+/); // fields[0] IS field 3
388
- const ticks = Number.parseInt(fields[19], 10); // field 22 => 22 - 3
337
+ // FIELD 22 OF /proc/<pid>/stat, and the parse is the whole trap: field 2
338
+ // is `comm`, which may contain BOTH spaces and parens, and a naive split
339
+ // read a binary named `ev (i l) x` 28308s too old. procStat.mjs is the one
340
+ // parser of that line and says why its reading is the right one.
341
+ const ticks = procStat(pid)?.starttime;
389
342
  if (!Number.isFinite(ticks) || ticks < 0) return null;
390
343
  // `/proc/uptime` rather than `/proc/stat`'s btime, and not for precision
391
344
  // alone (measured 6ms out against 162ms). Date.now() and uptime are read
@@ -653,7 +606,7 @@ function standDown(holder, path, log, { waitOutDrain = false } = {}) {
653
606
  if (holder.pid === process.pid) return { failed: 'refusing to signal this very process' };
654
607
  const standing = () => {
655
608
  const now = readHolder(path);
656
- if (!now || !alive(now.pid)) return null;
609
+ if (!now || !processAlive(now.pid)) return null;
657
610
  return now;
658
611
  };
659
612
  const before = standing();
@@ -737,7 +690,7 @@ function standDown(holder, path, log, { waitOutDrain = false } = {}) {
737
690
  * collapsing them once had the daemon telling people a live holder was stale.
738
691
  */
739
692
  function takeOverFrom(holder, path, log, { allowDowngrade = false } = {}) {
740
- if (!holder?.pid || !alive(holder.pid)) return null; // already gone
693
+ if (!holder?.pid || !processAlive(holder.pid)) return null; // already gone
741
694
  const identified = stillTheHolder(holder);
742
695
  if (identified === null) {
743
696
  // We know nothing about this pid, and said so. The remedy is a human
@@ -834,7 +787,7 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
834
787
  } catch (e) {
835
788
  if (e.code !== 'EEXIST') return { ok: true, release: () => {}, unguarded: true };
836
789
  const holder = readHolder(path);
837
- if (!holder || !alive(holder.pid)) {
790
+ if (!holder || !processAlive(holder.pid)) {
838
791
  // A crashed daemon's leftover. Clear it and take it on the next pass —
839
792
  // ownership-verified, because a concurrent start may have cleared and
840
793
  // re-created it in the gap since our read.
@@ -843,7 +796,7 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
843
796
  // Unreadable content: re-read before clearing, so a half-written
844
797
  // record a peer is writing RIGHT NOW is not deleted mid-write.
845
798
  const again = readHolder(path);
846
- if (again && alive(again.pid)) continue; // it finished writing — a real holder now
799
+ if (again && processAlive(again.pid)) continue; // it finished writing — a real holder now
847
800
  try {
848
801
  rmSync(path, { force: true });
849
802
  } catch {
@@ -982,7 +935,7 @@ function stopLock(path, log, tally) {
982
935
  const where = holder.repoRoot ? ` in ${holder.repoRoot}` : '';
983
936
  const what = holder.version ? ` ${holder.version}` : '';
984
937
 
985
- if (!alive(holder.pid)) {
938
+ if (!processAlive(holder.pid)) {
986
939
  log(`pid ${holder.pid}${where} is already gone — nothing to stop.`);
987
940
  return;
988
941
  }
@@ -1117,7 +1070,7 @@ export function stopDaemonFor(fleetToken, { log = (m) => console.log(m) } = {})
1117
1070
  */
1118
1071
  export function stopAllDaemons({ log = (m) => console.log(m) } = {}) {
1119
1072
  const tally = { stopped: 0, unconfirmed: 0, failed: 0, running: 0, draining: 0 };
1120
- for (const path of lockFiles()) stopLock(path, log, tally);
1073
+ for (const { path } of listDaemonLocks()) stopLock(path, log, tally);
1121
1074
  const { stopped, unconfirmed, failed, running } = tally;
1122
1075
 
1123
1076
  // WHAT WE ACTUALLY MEASURED IS LOCKS, so that is what this says. The old
@@ -20,7 +20,10 @@ export function parseIntakeDraft(text, typeHint = null, scrub = envScrub) {
20
20
  const taskType = agentTaskTypeOf(typeHint) ?? v.taskType;
21
21
  const clean = (value) => String(scrub(value.trim()));
22
22
  return {
23
- title: clean(v.title),
23
+ // ONE LINE (2026-10-10): the title is printed in later prompts, and a
24
+ // line break in it forged structure there; parse.ts collapses a ticket's
25
+ // own title the same way.
26
+ title: clean(v.title).replace(/\s+/g, ' ').trim(),
24
27
  description: clean(v.description),
25
28
  acceptanceCriteria: v.acceptanceCriteria.map(clean),
26
29
  taskType,
@@ -73,6 +73,37 @@ export function writeAtomic(path, data) {
73
73
  * list. */
74
74
  export const isCurrent = (state) => state === 'current' || state === 'wrote';
75
75
 
76
+ /**
77
+ * ONE STATE INTO A SYNC'S RESULT (2026-10-10, the audit's F111): `wrote` and
78
+ * `refused` are listed as such, and anything not current (`stale`, `failed`)
79
+ * clears `ok` — the retry contract: the driver backs off and refetches — and
80
+ * is listed as failed. The four fetched kinds restated this by hand. What a
81
+ * refusal means to the caller (a name no longer kept, a path no longer
82
+ * wanted) stays at the call site. Returns `state`, so the caller can branch.
83
+ */
84
+ export function recordSyncState(result, state, path) {
85
+ if (state === 'refused') result.refused.push(path);
86
+ else if (state === 'wrote') result.wrote.push(path);
87
+ else if (!isCurrent(state)) {
88
+ result.ok = false;
89
+ result.failed.push(path);
90
+ }
91
+ return state;
92
+ }
93
+
94
+ /** A generated text file (INSTRUCTIONS.md, LIBRARY.md) made to hold `text`:
95
+ * left alone when the REGULAR file there already does, else written
96
+ * atomically. True when it was written. */
97
+ export function writeTextIfChanged(path, text) {
98
+ try {
99
+ if (lstatSync(path).isFile() && readFileSync(path, 'utf8') === text) return false;
100
+ } catch {
101
+ /* absent */
102
+ }
103
+ writeAtomic(path, text);
104
+ return true;
105
+ }
106
+
76
107
  /**
77
108
  * Make `path` hold the manifest's file.
78
109
  *