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
package/bin/lib/vault.mjs CHANGED
@@ -27,16 +27,79 @@ import {
27
27
  } from 'node:fs';
28
28
  import { execFileSync } from 'node:child_process';
29
29
  import { join, relative, sep } from 'node:path';
30
+ import { gitRaw } from './git.mjs';
30
31
  import { planVaultSync } from './vaultDiff.mjs';
31
32
 
32
33
  const SYNC_STATE = '.flowviant-sync.json';
33
34
 
34
- /** Create the vault dir + its private git history (best-effort). */
35
+ /**
36
+ * EVERY GIT CALL IN THE VAULT, AND NONE OF THE VAULT'S OWN PROGRAMS
37
+ * (2026-10-10, the audit). The vault is a directory a cartographer turn writes:
38
+ * Codex's wiki profile can write all of it, `.git` included, and Claude's list
39
+ * denies its `.git` only as far as the CLI keeps its word. A `core.fsmonitor`
40
+ * planted in `<vault>/.git/config` ran on the daemon's own `git add -A` — the
41
+ * daemon, with the daemon's environment, outside every turn's fence — and a
42
+ * planted hook would run on its `git commit`. So the daemon's git here runs
43
+ * with the monitor off and hooks pointed at nothing, and commits `--no-verify`.
44
+ * Command-line `-c` outranks every config file.
45
+ */
46
+ const VAULT_GIT_FENCE = Object.freeze(['-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null']);
47
+ export function vaultGit(args, dir) {
48
+ return execFileSync('git', [...VAULT_GIT_FENCE, ...args], { cwd: dir, stdio: 'ignore' });
49
+ }
50
+
51
+ /**
52
+ * …AND NO PROGRAM THE VAULT'S CONFIG NAMES. `-c` can switch off the monitor
53
+ * and the hooks by name, but not a program the config invents a name for: a
54
+ * `[filter "x"] clean = <cmd>` beside a `.gitattributes` of `* filter=x` ran
55
+ * on the fenced `git add -A` all the same (measured, git 2.55), and so would a
56
+ * `gpg.program` under `commit.gpgSign`. Nothing a vault needs is in its config
57
+ * beyond what `git init` writes, so a config holding anything else was not the
58
+ * daemon's — and the local commit is skipped, in words, rather than run. The
59
+ * sync itself is unaffected: it reads files, never git. A config git cannot
60
+ * read is the same answer.
61
+ */
62
+ const INIT_CONFIG_KEYS = new Set([
63
+ 'core.repositoryformatversion',
64
+ 'core.filemode',
65
+ 'core.bare',
66
+ 'core.logallrefupdates',
67
+ 'core.ignorecase',
68
+ 'core.precomposeunicode',
69
+ 'core.symlinks',
70
+ 'extensions.objectformat',
71
+ 'extensions.refstorage',
72
+ ]);
73
+ export function vaultConfigForeignKey(dir) {
74
+ let listed;
75
+ try {
76
+ // `--local` reads the config THIS repository resolves to — through a
77
+ // planted `commondir` or a `.git` file too. It does NOT follow includes
78
+ // (a file option turns them off), so an `include.path` is caught by its
79
+ // own key (measured, git 2.55). Reading config runs nothing.
80
+ listed = gitRaw([...VAULT_GIT_FENCE, 'config', '--local', '--list', '-z'], dir, { stdio: ['ignore', 'pipe', 'ignore'] });
81
+ } catch {
82
+ return '(unreadable)';
83
+ }
84
+ // `-z`: each entry is `key\nvalue\0`, so a value with a newline in it
85
+ // cannot pass for a key of its own.
86
+ for (const entry of listed.split('\0')) {
87
+ if (!entry) continue;
88
+ const key = entry.split('\n')[0].toLowerCase();
89
+ if (!INIT_CONFIG_KEYS.has(key)) return key;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /** Create the vault dir + its private git history (best-effort). With NO
95
+ * template (2026-10-10): a person's `init.templateDir` can carry a `config`
96
+ * of its own, which `git init` copies in (measured, git 2.55) — and which the
97
+ * check above would then refuse on every sync. A vault needs none of it. */
35
98
  export function ensureVault(dir) {
36
99
  mkdirSync(dir, { recursive: true });
37
100
  if (!existsSync(join(dir, '.git'))) {
38
101
  try {
39
- execFileSync('git', ['init', '-q'], { cwd: dir, stdio: 'ignore' });
102
+ vaultGit(['init', '-q', '--template='], dir);
40
103
  } catch {
41
104
  /* git unavailable — the vault still works, just unversioned */
42
105
  }
@@ -68,14 +131,19 @@ function walkMd(dir, base = dir, out = [], errors = { count: 0 }) {
68
131
 
69
132
  /** Best-effort local history commit — identity pinned so it works on machines
70
133
  * with no global git config, and never touches the user's identity. */
71
- function commitVault(dir, message) {
134
+ function commitVault(dir, message, warn) {
135
+ // No `.git` of its own (git was unavailable at init, or a turn removed it):
136
+ // unversioned — and never a `git add -A` that walks UP into whatever
137
+ // repository encloses the vault.
138
+ if (!existsSync(join(dir, '.git'))) return;
139
+ const foreign = vaultConfigForeignKey(dir);
140
+ if (foreign) {
141
+ warn(`wiki vault history not committed — its git config holds \`${foreign}\`, which Flowviant did not write (${dir})`);
142
+ return;
143
+ }
72
144
  try {
73
- execFileSync('git', ['add', '-A'], { cwd: dir, stdio: 'ignore' });
74
- execFileSync(
75
- 'git',
76
- ['-c', 'user.name=flowviant', '-c', 'user.email=wiki@flowviant.local', 'commit', '-q', '-m', message],
77
- { cwd: dir, stdio: 'ignore' }
78
- );
145
+ vaultGit(['add', '-A'], dir);
146
+ vaultGit(['-c', 'user.name=flowviant', '-c', 'user.email=wiki@flowviant.local', 'commit', '-q', '--no-verify', '-m', message], dir);
79
147
  } catch {
80
148
  /* nothing to commit / git unavailable — fine */
81
149
  }
@@ -124,6 +192,6 @@ export async function syncVault({ dir, url, token, userAgent, finalize, grounded
124
192
  }
125
193
 
126
194
  writeFileSync(join(dir, SYNC_STATE), JSON.stringify(plan.state));
127
- commitVault(dir, plan.finalized ? `sweep${groundedAtSha ? ` @ ${groundedAtSha.slice(0, 7)}` : ''}` : `update${groundedAtSha ? ` @ ${groundedAtSha.slice(0, 7)}` : ''}`);
195
+ commitVault(dir, plan.finalized ? `sweep${groundedAtSha ? ` @ ${groundedAtSha.slice(0, 7)}` : ''}` : `update${groundedAtSha ? ` @ ${groundedAtSha.slice(0, 7)}` : ''}`, warn);
128
196
  return { pages: plan.pages, uploaded: plan.uploaded, deleted: plan.deleted };
129
197
  }
@@ -21,9 +21,12 @@ import { createHash } from 'node:crypto';
21
21
 
22
22
  // Mirror the server contract (shared schema) — a page that violates it is
23
23
  // skipped with a warning (and carried forward if previously synced), never
24
- // allowed to 400 the whole request and wedge the sync.
24
+ // allowed to 400 the whole request and wedge the sync. The release parity gate
25
+ // (scripts/check-app-parity.mjs, 2026-10-10, audit F142) holds these to the
26
+ // shared WIKI_VAULT_* names, CHUNK_FILES within the per-request file cap, and
27
+ // isSafePath to isSafeVaultPath over a vector list.
25
28
  export const MAX_FILE_BYTES = 262_144;
26
- const MAX_PATH_CHARS = 300;
29
+ export const MAX_PATH_CHARS = 300;
27
30
  export const MAX_FILES = 400;
28
31
  export const CHUNK_FILES = 30;
29
32
  export const CHUNK_BYTES = 700_000;
package/bin/lib/views.mjs CHANGED
@@ -7,32 +7,37 @@
7
7
  * status line for a server that did not answer is left out, never guessed.
8
8
  */
9
9
 
10
- import { execFileSync, spawn } from 'node:child_process';
10
+ import { spawn } from 'node:child_process';
11
11
  import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs';
12
- import { FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
12
+ import { StringDecoder } from 'node:string_decoder';
13
+ import { APP_URL, FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
13
14
  import { detectRepoRoot, listStoredProjects, projectLabel, resolveStoredCredential } from './credentials.mjs';
14
15
  import { desktopStatusRemote, installChannel } from './desktopContract.mjs';
15
16
  import { daemonLogPath } from './daemonState.mjs';
17
+ import { git as runGit } from './git.mjs';
16
18
  import { daemonRunningFor } from './instance.mjs';
17
19
  import { agoFrom } from './machineListing.mjs';
20
+ import { printable, printableText } from './printable.mjs';
18
21
  import { detectRuntimes } from './runtimeDetection.mjs';
19
22
  import { c } from './ui.mjs';
20
23
  import { versionBelow } from './versionOrder.mjs';
21
24
 
22
- const APP_URL = process.env.FLOWVIANT_APP_URL || 'https://app.flowviant.com';
23
-
24
25
  // "Behind" in these views is versionBelow (versionOrder.mjs): an unreadable
25
26
  // version is never marked older — a read-only view states nothing it cannot
26
27
  // measure.
27
28
 
28
29
  function agentLine(agent, now) {
29
- if (agent.asks) return `${agent.name} · ${c.yellow('asks you a question')}`;
30
+ // A member names an agent; this prints it to a terminal. Scrubbed like every
31
+ // other server string this daemon prints (2026-10-10, the audit's F151;
32
+ // see printable.mjs).
33
+ const name = printable(agent.name);
34
+ if (agent.asks) return `${name} · ${c.yellow('asks you a question')}`;
30
35
  const since = agent.status === 'working' && !agent.parked && agent.since ? Date.parse(agent.since) : NaN;
31
36
  const minutes = Number.isFinite(since) ? Math.max(0, Math.floor((now - since) / 60_000)) : null;
32
37
  const took = minutes == null ? '' : ` ${minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`}`;
33
38
  const word = agent.parked ? 'parked' : agent.status;
34
39
  const cards = `${agent.delivered} of ${agent.total} ${agent.total === 1 ? 'card' : 'cards'}${agent.delivered ? ' delivered' : ''}`;
35
- return `${agent.name} · ${word === 'working' ? c.blue(`working${took}`) : word} · ${cards}`;
40
+ return `${name} · ${word === 'working' ? c.blue(`working${took}`) : word} · ${cards}`;
36
41
  }
37
42
 
38
43
  /** `flowviant status`, rendered from the same document `status --json --remote` prints. */
@@ -51,7 +56,7 @@ export function renderStatus(status, { now = Date.now() } = {}) {
51
56
  lines.push(label('here', `not running — run ${c.cyan('flowviant')} in that repo`));
52
57
  } else if (project.running === true) {
53
58
  const serves = role === 'serving' ? c.green('serving')
54
- : role === 'inactive' ? `inactive${remote?.servingBoxName ? ` — ${remote.servingBoxName} serves this project` : ''}`
59
+ : role === 'inactive' ? `inactive${remote?.servingBoxName ? ` — ${printable(remote.servingBoxName)} serves this project` : ''}`
55
60
  : project.holder === 'serving' ? c.green('serving') : null;
56
61
  lines.push(label('here', serves ? `running · ${serves}` : 'running'));
57
62
  }
@@ -61,7 +66,9 @@ export function renderStatus(status, { now = Date.now() } = {}) {
61
66
  if (agents && agents.length > 0) {
62
67
  agents.forEach((agent, i) => lines.push(label(i === 0 ? 'agents' : '', agentLine(agent, now))));
63
68
  }
64
- if (remote?.pressure?.reason) lines.push(label('waiting', `Waiting for the machine — ${remote.pressure.reason}.`));
69
+ // The server scrubs these two (machineBoxes.ts, machinePressure.ts); the
70
+ // CLI keeps its belt, as printable.mjs explains.
71
+ if (remote?.pressure?.reason) lines.push(label('waiting', `Waiting for the machine — ${printable(remote.pressure.reason)}.`));
65
72
  for (const rt of project.runtimes ?? []) {
66
73
  if (rt.parkedByLimit && rt.message) lines.push(label('limit', `${rt.id} hit its limit: “${rt.message}”`));
67
74
  }
@@ -94,19 +101,36 @@ export function resolveProjectForView(argv = process.argv, cwd = process.cwd())
94
101
  };
95
102
  }
96
103
 
97
- /** The last `count` lines of a file, read from its end. */
104
+ /**
105
+ * The last `count` lines of a file, read from its end — PRINTABLE (2026-10-10,
106
+ * the audit's F133). A daemon before this release logged member-written card
107
+ * titles raw, so a replay could carry an OSC 52 into whichever terminal runs
108
+ * `flowviant logs`. `printableText` keeps the lines' shape; a log written on a
109
+ * TTY loses its colours with everything else.
110
+ */
98
111
  export function tailLines(path, count) {
99
112
  const size = statSync(path).size;
100
113
  const want = Math.min(size, 256 * 1024);
101
114
  const buf = Buffer.alloc(want);
102
115
  const fd = openSync(path, 'r');
103
116
  try { readSync(fd, buf, 0, want, size - want); } finally { closeSync(fd); }
104
- const lines = buf.toString('utf8').split('\n');
117
+ const lines = printableText(buf.toString('utf8')).split('\n');
105
118
  if (lines.at(-1) === '') lines.pop();
106
119
  if (want < size) lines.shift(); // the first line is cut mid-way
107
120
  return { text: lines.slice(-count).join('\n'), size };
108
121
  }
109
122
 
123
+ /**
124
+ * What `logs -f` prints of each new chunk: decoded as a STREAM, because a read
125
+ * can end inside a multi-byte character, and scrubbed like the tail. A sequence
126
+ * split across two reads loses its ESC in the first and its terminator in the
127
+ * second, so what is left of it is plain text.
128
+ */
129
+ export function createLogFollower() {
130
+ const decoder = new StringDecoder('utf8');
131
+ return (buf) => printableText(decoder.write(buf));
132
+ }
133
+
110
134
  export async function runLogs(argv = process.argv) {
111
135
  const which = resolveProjectForView(argv);
112
136
  if (!which.entry) { console.error(which.message); process.exit(1); }
@@ -125,6 +149,7 @@ export async function runLogs(argv = process.argv) {
125
149
  // Poll rather than fs.watch: the daemon rotates the file (a rename), and a
126
150
  // watch on the old inode would go quiet exactly then.
127
151
  let offset = existsSync(path) ? statSync(path).size : 0;
152
+ const followed = createLogFollower();
128
153
  for (;;) {
129
154
  await new Promise((resolve) => setTimeout(resolve, 500));
130
155
  if (!existsSync(path)) continue;
@@ -134,7 +159,7 @@ export async function runLogs(argv = process.argv) {
134
159
  const buf = Buffer.alloc(size - offset);
135
160
  const fd = openSync(path, 'r');
136
161
  try { readSync(fd, buf, 0, buf.length, offset); } finally { closeSync(fd); }
137
- process.stdout.write(buf);
162
+ process.stdout.write(followed(buf));
138
163
  offset = size;
139
164
  }
140
165
  }
@@ -173,7 +198,7 @@ export async function runDoctor({ cwd = process.cwd(), fetchImpl = fetch } = {})
173
198
  const add = (state, text, fix) => { if (state === false) failed++; lines.push(check(state, text, fix)); };
174
199
 
175
200
  let git = null;
176
- try { git = String(execFileSync('git', ['--version'], { timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] })).trim(); } catch { /* absent */ }
201
+ try { git = runGit(['--version'], undefined, { timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }); } catch { /* absent */ }
177
202
  add(Boolean(git), git ?? 'git is not installed', 'Install git; agents work in git worktrees.');
178
203
 
179
204
  const installed = detectRuntimes().filter((rt) => rt.installed);
@@ -21,24 +21,23 @@
21
21
  */
22
22
 
23
23
  import { existsSync } from 'node:fs';
24
- import { execFileSync } from 'node:child_process';
25
24
  import { homedir } from 'node:os';
26
25
  import { join } from 'node:path';
27
26
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
28
- import { git, resetWorktree, originSlug, isSafePathSegment } from './git.mjs';
27
+ import { git, gitOrNull, gitRaw, resetWorktree, originSlug, isSafePathSegment, SHA1_ABBREV_RE } from './git.mjs';
29
28
  import { c, note, ok, warn } from './ui.mjs';
30
29
  import { sawSentinel } from './claude.mjs';
31
30
  import { runTurn as defaultRunTurn } from './runTurn.mjs';
32
31
  import { SYSTEM_WIKI, WIKI_KICKOFF, SYSTEM_REGROUND, REGROUND_KICKOFF } from './prompts.mjs';
33
32
  import { ensureVault, syncVault } from './vault.mjs';
34
33
  import { scrub as envScrub } from './uplinkScrub.mjs';
35
- import { RUNTIMES } from './runtimes.mjs';
34
+ import { runtimeLabel } from './runtimes.mjs';
36
35
  import { pickRuntimeFor as defaultPickRuntimeFor } from './runtimeDetection.mjs';
37
- import { recordMcpServers, recordSkills } from './runtimeCapabilities.mjs';
38
36
  import { THINK_MARKER } from './runtimeEvents.mjs';
39
37
  import { fleetEndpoint } from './fleetWire.mjs';
40
- import { postToFleet } from './fleetPost.mjs';
38
+ import { fleetHeaders, postToFleet } from './fleetPost.mjs';
41
39
  import { addUsage } from './turnUsage.mjs';
40
+ import { printable } from './printable.mjs';
42
41
 
43
42
  export function createWikiRunner({
44
43
  repoRoot,
@@ -126,11 +125,7 @@ export function createWikiRunner({
126
125
  try {
127
126
  await fetch(WIKI_PROGRESS_URL, {
128
127
  method: 'POST',
129
- headers: {
130
- Authorization: `Bearer ${FLEET_TOKEN}`,
131
- 'User-Agent': USER_AGENT,
132
- 'Content-Type': 'application/json',
133
- },
128
+ headers: fleetHeaders(),
134
129
  signal: AbortSignal.timeout(15_000),
135
130
  body: JSON.stringify(safe),
136
131
  });
@@ -148,22 +143,10 @@ export function createWikiRunner({
148
143
  * sweep queued, for the rest of the day. Best-effort: an older server 404s
149
144
  * once and the TTL is still the backstop it always was.
150
145
  */
151
- const postWikiAbandoned = async () => {
152
- try {
153
- await fetch(WIKI_ABANDONED_URL, {
154
- method: 'POST',
155
- headers: {
156
- Authorization: `Bearer ${FLEET_TOKEN}`,
157
- 'User-Agent': USER_AGENT,
158
- 'Content-Type': 'application/json',
159
- },
160
- signal: AbortSignal.timeout(15_000),
161
- body: '{}',
162
- });
163
- } catch {
164
- /* best-effort — the request's own TTL still expires it */
165
- }
166
- };
146
+ // No longer a near copy of `postToFleet` (decisions.md's 2026-09-26 open
147
+ // item, closed audit 2026-10-10 F136): the same post, on its own 15 s
148
+ // ceiling. Never throws — the request's own TTL still expires it.
149
+ const postWikiAbandoned = () => postToFleet(WIKI_ABANDONED_URL, {}, { timeoutMs: 15_000 });
167
150
 
168
151
  const enqueueSweep = (job) => {
169
152
  if (!job || job.requestedAt === lastSweepAt) return;
@@ -187,7 +170,19 @@ export function createWikiRunner({
187
170
  type: 'reground',
188
171
  intentId,
189
172
  prUrl,
190
- title: title || 'a delivered task',
173
+ // A CARD'S TITLE, which any member writes (and intake can seed from a
174
+ // stranger's ticket), printed by the drain's narration six times and put
175
+ // in the prompt. SANITIZED AT THE INTAKE like the pages below (2026-10-10,
176
+ // the audit's F097): an OSC 52 in a title rewrote the operator's
177
+ // clipboard the moment the card shipped. printable() takes the whole
178
+ // display-control class, newlines included, so a title is one line.
179
+ title:
180
+ (typeof title === 'string'
181
+ ? printable(title)
182
+ .replace(/\s+/g, ' ')
183
+ .trim()
184
+ .slice(0, 300)
185
+ : '') || 'a delivered task',
191
186
  // What the PLAN thought this would invalidate. A hint, not the truth —
192
187
  // the turn still reads the real changed files; this catches pages whose
193
188
  // frontmatter file list has drifted, or that document a concept rather
@@ -239,13 +234,11 @@ export function createWikiRunner({
239
234
  if (!Array.isArray(shas) || shas.length === 0) return null;
240
235
  const files = new Set();
241
236
  for (const sha of shas.slice(0, 50)) {
242
- if (!/^[0-9a-f]{7,40}$/i.test(String(sha))) continue;
237
+ // git.mjs's SHA-1 shape, either case — the shape this lookup has
238
+ // always taken (audit 2026-10-10, F749).
239
+ if (!SHA1_ABBREV_RE.test(String(sha).toLowerCase())) continue;
243
240
  try {
244
- const out = execFileSync(
245
- 'git',
246
- ['show', '--name-only', '--pretty=format:', String(sha)],
247
- { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
248
- );
241
+ const out = gitRaw(['show', '--name-only', '--pretty=format:', String(sha)], repoRoot);
249
242
  for (const line of out.split('\n')) {
250
243
  const f = line.trim();
251
244
  if (f) files.add(f);
@@ -399,12 +392,8 @@ export function createWikiRunner({
399
392
  }
400
393
  }
401
394
  resetWorktree(wikiWt, getBaseRef());
402
- let sha = '';
403
- try {
404
- sha = git(['rev-parse', 'HEAD'], wikiWt);
405
- } catch {
406
- /* detached/no HEAD — still writes the map, just ungrounded */
407
- }
395
+ // No HEAD → '' — still writes the map, just ungrounded.
396
+ const sha = gitOrNull(['rev-parse', 'HEAD'], wikiWt) ?? '';
408
397
  // Sync the vault after the turn regardless of the sentinel: a died
409
398
  // sweep's partial pages still persist (merge, no prune) — only a
410
399
  // COMPLETED sweep finalizes, so an interrupted one can't erase pages.
@@ -441,7 +430,7 @@ export function createWikiRunner({
441
430
  warn('wiki generation skipped — no installed CLI can run a vault-scoped turn');
442
431
  return;
443
432
  }
444
- const wikiLabel = RUNTIMES[wikiRt].label;
433
+ const wikiLabel = runtimeLabel(wikiRt);
445
434
  if (task.type === 'sweep') {
446
435
  sweepAttempts++;
447
436
  note(`${c.cyan('wiki')} ${c.dim(`— regenerating: your ${wikiLabel} is reading the repo…`)}`);
@@ -454,19 +443,11 @@ export function createWikiRunner({
454
443
  vaultDir,
455
444
  runtime: wikiRt,
456
445
  label: c.cyan('[wiki]'),
457
- streamJson: true,
446
+ // No skills or connectors are harvested off a wiki turn: it is
447
+ // fenced (no settings source, strict MCP), so its init names less
448
+ // than the machine has (runTurn.mjs, `initHarvestFor`).
458
449
  onActivity,
459
450
  onUsage: onUsage(wikiRt),
460
- // FREE SKILLS, off a turn that was running anyway. This stream is
461
- // already parsed and its init event already carries the CLI's own
462
- // resolved skill set — the same fact a tab turn teaches — so the
463
- // only thing missing was the handler. The wiki turn runs in a
464
- // detached worktree of THIS repo, so its `.claude/skills` and the
465
- // machine's personal ones resolve identically to a tab's.
466
- onInit: (i) => {
467
- recordSkills(i.skills);
468
- recordMcpServers(i.mcpServers);
469
- },
470
451
  onSpawn: (ch) => {
471
452
  wikiChild = ch;
472
453
  },
@@ -512,14 +493,8 @@ export function createWikiRunner({
512
493
  vaultDir,
513
494
  runtime: wikiRt,
514
495
  label: c.cyan('[wiki]'),
515
- streamJson: true,
516
496
  onActivity,
517
497
  onUsage: onUsage(wikiRt),
518
- // Same free harvest as the sweep above.
519
- onInit: (i) => {
520
- recordSkills(i.skills);
521
- recordMcpServers(i.mcpServers);
522
- },
523
498
  onSpawn: (ch) => {
524
499
  wikiChild = ch;
525
500
  },
@@ -579,10 +554,14 @@ export function createWikiRunner({
579
554
  // Terminal frame so the app cover clears promptly (don't wait for the
580
555
  // freshness window to lapse). force-sent past the throttle.
581
556
  await postWikiProgress(frame({ done: true, ...(usage ? { usage } : {}) }), true);
582
- // Safety net: the wiki turn is read-only on the repo by CONTRACT, but
583
- // permission enforcement is a curated tool list, not a path jail —
584
- // discard anything a confused turn wrote to the worktree so it can
585
- // never leak into a later turn or a push.
557
+ // Safety net: the wiki turn is read-only on the repo by CONTRACT.
558
+ // Claude's list writes only the vault (claudePosture.mjs
559
+ // `wikiPermFor`, 2026-10-10) and Codex's kernel profile likewise,
560
+ // but Antigravity's flags cannot say "only the vault" — so discard
561
+ // anything a turn wrote to the worktree before it can leak into a
562
+ // later turn or a push. It reaches the worktree ONLY: a write
563
+ // anywhere else is the posture's to refuse, never this reset's to
564
+ // undo.
586
565
  try {
587
566
  resetWorktree(wikiWt, getBaseRef());
588
567
  } catch {
package/bin/lib/work.mjs CHANGED
@@ -40,11 +40,16 @@
40
40
  * workSessionRuntime.mjs which CLI a tab speaks, which conversation resumes
41
41
  * workAdoptCarry.mjs adoption's dirty carry
42
42
  * workSessionTurns.mjs a tab's turn, start to settle
43
+ * workTurnShape.mjs what that turn is — plain, plan, capture or build —
44
+ * and its prompt pair (pure, 2026-10-10)
43
45
  * workRetire.mjs held sessions, retirement, manual-place freshening
44
46
  * workToolLog.mjs one turn's tool cards, folded and capped (pure)
45
47
  * workCommandAudit.mjs one turn's `$ …` audit, batched to the server
46
48
  * workBrain.mjs the model and effort a turn may put on argv (pure)
47
49
  * workIntake.mjs one incoming ticket drafted into a card, read-only (2026-09-28)
50
+ * workCappedTurn.mjs one capped read-only consult turn: its child, its slot,
51
+ * its cap and its transcript — the planner, the pre-review
52
+ * and intake all run one (2026-10-10)
48
53
  * turnLock.mjs whether a live CLI of ours holds a directory's turn
49
54
  * (and, split earlier: workDiffs, workPreviews, workPullRequests, workShip,
50
55
  * workAgentPlans, workAgentCheck, workAgentPrecheck, workAgentReview,
@@ -71,6 +76,7 @@ import { createWorkChildren } from './workChildren.mjs';
71
76
  import { createWorkAgentPublish } from './workAgentPublish.mjs';
72
77
  import { createWorkPlaceReports } from './workPlaceReports.mjs';
73
78
  import { createWorkReportQueue } from './workReportQueue.mjs';
79
+ import { postBestEffort } from './fleetPost.mjs';
74
80
  import { createWorkSessionTokens } from './workSessionTokens.mjs';
75
81
  import { createWorkNarration } from './workNarration.mjs';
76
82
  import { createWorkAttachments } from './workAttachments.mjs';
@@ -186,7 +192,7 @@ export function createWorkManager({
186
192
  const gitNet = (args, ms) => gitNetIn(args, repoRoot, ms);
187
193
 
188
194
  // ── THE SESSION'S OWN HALF — built first, because the lanes below are handed it.
189
- const { sessionPlaces, placeOf, learnPlaces, placeDir, placeWtFor, sessionMetaPath } =
195
+ const { placeOf, notePlace, learnPlaces, placeDir, branchAt, placeWtFor, sessionMetaPath } =
190
196
  createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged });
191
197
  const { sessionGroups, noteSessionGroup, pruneSessionGroups, sessionProcesses } =
192
198
  createWorkSessionGroups({ repoRoot });
@@ -211,7 +217,6 @@ export function createWorkManager({
211
217
  const {
212
218
  pendingWorkReports,
213
219
  pendingShipReports,
214
- postBestEffort,
215
220
  REJECT_RETRY_MS,
216
221
  settleWorkTurn,
217
222
  settleShip,
@@ -241,7 +246,7 @@ export function createWorkManager({
241
246
  inPlace,
242
247
  workChildren,
243
248
  workAttempts,
244
- sessionPlaces,
249
+ notePlace,
245
250
  placeOf,
246
251
  placeWtFor,
247
252
  sessionMetaPath,
@@ -282,8 +287,9 @@ export function createWorkManager({
282
287
 
283
288
  const { processPrJobs } = createWorkPullRequests({
284
289
  placeOf,
290
+ placeDir,
291
+ branchAt,
285
292
  repoRoot,
286
- baseDir,
287
293
  baseRef,
288
294
  gitNet,
289
295
  landed,
@@ -298,6 +304,8 @@ export function createWorkManager({
298
304
  baseDir,
299
305
  gitNet,
300
306
  baseRef,
307
+ placeDir,
308
+ branchAt,
301
309
  placeWtFor,
302
310
  sessionMetaPath,
303
311
  shipping,
@@ -457,9 +465,11 @@ export function createWorkManager({
457
465
  processAgentPlanJobs,
458
466
  processIntakeJobs,
459
467
  processAgentTurnJobs,
460
- // Only the displacement stand-down calls this — see its comment. Exported
461
- // rather than hooked into `shutdownWork` because the signal handlers cannot
462
- // await, and a settle that is not awaited is a settle that did not happen.
468
+ // Only the roster's stand-down calls this — a displacement, a removal or a
469
+ // commanded stop (holder.mjs, `standDownDisplaced`); see its comment.
470
+ // Exported rather than hooked into `shutdownWork` because the signal
471
+ // handlers cannot await, and a settle that is not awaited is a settle that
472
+ // did not happen.
463
473
  settleAgentTurns,
464
474
  processAgentMergeJobs,
465
475
  freshenManualPlaces,
@@ -6,7 +6,6 @@
6
6
  * promise of its own (the source is never written, a problem becomes one line
7
7
  * for the agent to say), reached once, from the session lane's adopt path.
8
8
  */
9
- import { execFileSync } from 'node:child_process';
10
9
  import { cpSync, lstatSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
11
10
  import { dirname, join } from 'node:path';
12
11
  import { git, gitRaw, splitNul } from './git.mjs';
@@ -31,9 +30,8 @@ export function createAdoptCarry({ sessionMetaPath }) {
31
30
  // text file) must round-trip byte-exact or the apply corrupts what it
32
31
  // carries. 64MB of headroom — a dirtier tree than that fails the read
33
32
  // here and is SAID, below, rather than half-applied.
34
- const patch = execFileSync('git', ['diff', 'HEAD', '--binary'], {
35
- cwd: srcCwd,
36
- stdio: ['ignore', 'pipe', 'pipe'],
33
+ const patch = gitRaw(['diff', 'HEAD', '--binary'], srcCwd, {
34
+ encoding: 'buffer',
37
35
  maxBuffer: 64 * 1024 * 1024,
38
36
  });
39
37
  if (patch.length) {