flowviant 0.95.0 → 0.97.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.
@@ -543,7 +543,12 @@ export const RUNTIMES = {
543
543
  * "consult" and "plan" currently mean. That is a statement about where the
544
544
  * contract was written, not a claim that only Claude could ever satisfy it.
545
545
  */
546
- profiles: ['build', 'wiki', 'consult', 'plan'],
546
+ // `design` and `research` (0.97.0) are Claude's alone: each is an
547
+ // `--allowedTools` list with a PATH-SCOPED write (claude.mjs), and neither
548
+ // codex's sandbox modes nor agy's flags were measured to express "write
549
+ // only this directory". A runtime that does not declare them is refused
550
+ // such a card before spawn, in words — never handed a build turn instead.
551
+ profiles: ['build', 'wiki', 'consult', 'plan', 'design', 'research'],
547
552
  mcp: claudeMcp,
548
553
  /**
549
554
  * Claude takes the operating contract as a real system prompt, which is the
@@ -1040,7 +1045,16 @@ export const runtimeById = (id) => RUNTIMES[id] ?? RUNTIMES.claude;
1040
1045
  * the single drivability test, so a machine-wide MCP config disqualified it from
1041
1046
  * two jobs that never open an MCP connection.
1042
1047
  */
1043
- const PROFILE_NEEDS_MCP = { build: true, wiki: false, consult: false, plan: true };
1048
+ const PROFILE_NEEDS_MCP = {
1049
+ build: true,
1050
+ wiki: false,
1051
+ consult: false,
1052
+ plan: true,
1053
+ // An agent turn has no MCP at all (SYSTEM_AGENT's header) — everything it
1054
+ // says rides the final JSON — so neither non-code posture needs one.
1055
+ design: false,
1056
+ research: false,
1057
+ };
1044
1058
 
1045
1059
  /**
1046
1060
  * A build needs the control plane, but NOT necessarily an MCP config of its own.
@@ -1209,6 +1223,68 @@ export function knownSkills() {
1209
1223
  return skillsCache;
1210
1224
  }
1211
1225
 
1226
+ /**
1227
+ * WHICH MCP SERVERS AND CLAUDE.AI CONNECTORS THE CLI MOUNTED, AND HOW EACH
1228
+ * STANDS (2026-09-23, 0.97.0) — learned off the same `system.init` event the
1229
+ * skills are, so it costs nothing and cannot drift from what a turn reaches.
1230
+ *
1231
+ * MEASURED on Claude Code 2.1.281 (`claude -p hi --model haiku --output-format
1232
+ * stream-json --verbose`): the init event carries `mcp_servers: [{ name,
1233
+ * status, source }]` — statuses seen `connected`, `failed`, `needs-auth` and
1234
+ * `pending`; a claude.ai connector arrives named `claude.ai <Name>` with
1235
+ * `source: "claudeai"`, a server a turn mounts with `--mcp-config` with
1236
+ * `source: "dynamic"`. A connector that needs a one-time sign-in AT THIS BOX
1237
+ * reads `needs-auth` here long before a turn fails on it, and the app's
1238
+ * Machines page names it.
1239
+ *
1240
+ * `flowviant` IS EXCLUDED: it is the server THIS daemon mounts on every tab
1241
+ * turn, always present, and not something a person can act on. A status off
1242
+ * the closed list is relayed as `other` rather than dropped — the CLI said
1243
+ * something about the server, and silence would be the daemon deciding it did
1244
+ * not. Bounded (40 entries, names cut at 60) and sorted, the `recordSkills`
1245
+ * discipline: a stable param, so an unchanged report writes nothing.
1246
+ *
1247
+ * ONLY WHAT IS NOT CONNECTED LEAVES THE BOX (2026-09-23). The first cut
1248
+ * relayed every server, connected ones included — so the operator's whole set
1249
+ * of signed-in services (their mail, their brokerage, their calendar) rode the
1250
+ * poll to the server and into every teammate's read of the Machines page,
1251
+ * which renders only the servers that are NOT connected anyway. A connected
1252
+ * connector is good news and good news is not a line; it is also nobody
1253
+ * else's business. So `connected` is dropped HERE, before the cap, and what is
1254
+ * sent is exactly what the page can say something about.
1255
+ *
1256
+ * THREE STATES, the skills split: null = no turn has taught us (the param is
1257
+ * not sent), [] = nothing of the person's own needs anything (every server is
1258
+ * connected, or none is mounted), a list = the ones that do. Last turn wins,
1259
+ * so a connector somebody signs into leaves the list on the next turn's init,
1260
+ * and an all-connected turn sends `[]`, which clears a stale list server-side.
1261
+ */
1262
+ let mcpServersCache = null;
1263
+
1264
+ export const MCP_STATUSES = new Set(['connected', 'failed', 'needs-auth', 'pending']);
1265
+ export const MAX_MCP_SERVERS = 40;
1266
+ export const MCP_NAME_MAX = 60;
1267
+
1268
+ export function recordMcpServers(list) {
1269
+ if (!Array.isArray(list)) return;
1270
+ const seen = new Map();
1271
+ for (const e of list) {
1272
+ if (!e || typeof e !== 'object' || typeof e.name !== 'string') continue;
1273
+ const n = e.name.trim().slice(0, MCP_NAME_MAX).trim();
1274
+ if (!n || n === 'flowviant' || seen.has(n)) continue;
1275
+ if (e.status === 'connected') continue; // see the header: never relayed
1276
+ seen.set(n, { n, s: MCP_STATUSES.has(e.status) ? e.status : 'other' });
1277
+ }
1278
+ mcpServersCache = [...seen.values()]
1279
+ .sort((a, b) => (a.n < b.n ? -1 : a.n > b.n ? 1 : 0))
1280
+ .slice(0, MAX_MCP_SERVERS);
1281
+ }
1282
+
1283
+ /** What to send on the roster poll as `mcp` — null until a turn has taught us. */
1284
+ export function knownMcpServers() {
1285
+ return mcpServersCache;
1286
+ }
1287
+
1212
1288
  /**
1213
1289
  * LEARN WHAT `/` CAN OFFER, ON A MACHINE NO TURN HAS TAUGHT.
1214
1290
  *
@@ -1338,6 +1414,8 @@ export function parseInitLine(line) {
1338
1414
  // record nothing. Conflating the two would keep the probe scanning a whole
1339
1415
  // turn's output on a CLI that does not report them.
1340
1416
  skills: Array.isArray(ev.skills) ? ev.skills : null,
1417
+ // The CLI's mounted MCP servers (0.97.0) — `recordMcpServers` normalises.
1418
+ mcpServers: Array.isArray(ev.mcp_servers) ? ev.mcp_servers : null,
1341
1419
  sessionId: typeof ev.session_id === 'string' ? ev.session_id : null,
1342
1420
  };
1343
1421
  }
@@ -1399,6 +1477,7 @@ export function probeSkillsOnce(cwd) {
1399
1477
  const init = parseInitLine(line);
1400
1478
  if (!init) continue;
1401
1479
  if (init.skills) recordSkills(init.skills);
1480
+ if (init.mcpServers) recordMcpServers(init.mcpServers);
1402
1481
  finish(init.sessionId);
1403
1482
  return;
1404
1483
  }
package/bin/lib/work.mjs CHANGED
@@ -75,7 +75,9 @@ import {
75
75
  WORK_TURN_KICKOFF_PLAIN,
76
76
  SYSTEM_PLAN,
77
77
  AGENT_PLAN_KICKOFF,
78
- SYSTEM_AGENT,
78
+ SYSTEM_AGENT_FOR,
79
+ agentTaskKindOf,
80
+ unknownAgentTaskKind,
79
81
  SYSTEM_PRECHECK,
80
82
  AGENT_TASK_KICKOFF,
81
83
  AGENT_TASK_SPEC,
@@ -84,13 +86,14 @@ import {
84
86
  withProjectContext,
85
87
  } from './prompts.mjs';
86
88
  import { knowledgeDirFor, FLOWVIANT_OWN_PATHS } from './knowledge.mjs';
87
- import { createArtifactReporter, snapshotArtifacts } from './artifacts.mjs';
88
- import { myPubB64, scrub as envScrub } from './env.mjs';
89
+ import { changedArtifacts, createArtifactReporter, scanArtifacts, snapshotArtifacts } from './artifacts.mjs';
90
+ import { myPubB64, scrub as envScrub, secretIn as envSecretIn } from './env.mjs';
89
91
  import {
90
92
  detectRuntimes,
91
93
  canRun,
92
94
  pickRuntimeFor,
93
95
  recordSkills,
96
+ recordMcpServers,
94
97
  toolEventOf,
95
98
  removeProbeTranscript,
96
99
  CLAUDE_TOOL_PROSE_KINDS,
@@ -171,6 +174,22 @@ const WORK_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
171
174
  * the machine's own default — a turn that runs — rather than a flag no CLI
172
175
  * understands and a tab that fails every message.
173
176
  */
177
+ /**
178
+ * THE ONE SENTENCE A PLAN TURN'S SYSTEM PROMPT GAINS (0.97.0), composed here
179
+ * rather than in prompts.mjs because it is a MODIFIER on whichever contract
180
+ * the tab runs, not a contract of its own.
181
+ *
182
+ * The enforcement is not this sentence — it is `--permission-mode plan`, under
183
+ * which the CLI itself refuses every write (claude.mjs, PLAN_MODE_PERM). What
184
+ * the sentence buys is the ANSWER: the plain contract above it says "edit
185
+ * freely, commit", and a turn told only that would spend itself discovering
186
+ * refusals. It also says not to reach for `ExitPlanMode` (measured: disabled
187
+ * under `-p`, and the probe turn then asked the person to leave plan mode
188
+ * themselves) — leaving plan mode is the person's switch in the tab.
189
+ */
190
+ export const PLAN_TURN_SENTENCE =
191
+ 'THIS IS A PLANNING TURN: the person switched this tab to plan mode, so read what you need, decide, and answer with the plan itself as your reply — change nothing (the CLI refuses every write this turn, whatever the mechanics above say about editing, committing or artifacts), and do not try to leave plan mode; they switch it off in the tab when they want the plan carried out.';
192
+
174
193
  function brainFor(job) {
175
194
  const out = {};
176
195
  const model = typeof job?.model === 'string' ? job.model.trim() : '';
@@ -258,6 +277,9 @@ export function createWorkManager({
258
277
  token: FLEET_TOKEN,
259
278
  userAgent: USER_AGENT,
260
279
  scrub: envScrub,
280
+ // …and for the binaries it cannot scrub, the same list as a byte check:
281
+ // a hit is withheld, never uploaded rewritten (artifacts.mjs).
282
+ secretIn: envSecretIn,
261
283
  log: (line) => warn(line),
262
284
  });
263
285
  /**
@@ -3135,6 +3157,22 @@ export function createWorkManager({
3135
3157
  });
3136
3158
  return;
3137
3159
  }
3160
+ /**
3161
+ * A PLAN TURN (0.97.0): the tab's switch, off the job. CLAUDE ONLY —
3162
+ * `--permission-mode plan` is Claude Code's, and a codex or agy turn
3163
+ * has no way to spell it, so it is refused HERE in words rather than
3164
+ * run as a build turn wearing the word. The server never sends the
3165
+ * key for a non-Claude tab; this is the machine's own half. Never on
3166
+ * a capture chat, which is read-only by its own profile already.
3167
+ */
3168
+ const planTurn = job.planMode === true && job.capture !== true;
3169
+ if (planTurn && rt.id !== 'claude') {
3170
+ await settleWorkTurn(job.id, {
3171
+ ok: false,
3172
+ answer: `plan mode runs on Claude Code only, and this tab is ${rt.id} — turn plan off in the tab, then send the message again`,
3173
+ });
3174
+ return;
3175
+ }
3138
3176
  // A PLAIN tab (agy) mounts no MCP: no credential to mint, no config
3139
3177
  // to write. The trade is stated in SYSTEM_WORK_PLAIN — no cards, no
3140
3178
  // streaming — and the honesty survives on the existing rails: the
@@ -3279,9 +3317,17 @@ export function createWorkManager({
3279
3317
  const adoptNote = adopting
3280
3318
  ? '[ADOPTED SESSION — this conversation was brought in from a terminal. Begin your reply with a 2-3 sentence recap of where it left off and what state carried over, then answer the message.]'
3281
3319
  : '';
3282
- const mcp = plainTab
3283
- ? { args: [], env: null, dir: null }
3284
- : mcpFor(rt.id, mint.token, getMcpUrl());
3320
+ // A PLAN TURN RUNS PLAIN TOO (0.97.0): measured on 2.1.281, plan mode
3321
+ // refuses every `mcp__flowviant` call ("Cannot call … while in plan
3322
+ // mode") unless the tool is annotated read-only, and none of the
3323
+ // session tools are — so mounting the control plane would hand the
3324
+ // turn a list of tools it can only fail at. The credential is still
3325
+ // minted above: its lease answers (held elsewhere, gone) are about
3326
+ // the SESSION, and a plan turn needs them as much as any other.
3327
+ const mcp =
3328
+ plainTab || planTurn
3329
+ ? { args: [], env: null, dir: null }
3330
+ : mcpFor(rt.id, mint.token, getMcpUrl());
3285
3331
  // The tab's model/effort, if it named any. Spread into turnArgs so
3286
3332
  // BOTH runTurn calls below carry it — the retry is the same turn on
3287
3333
  // the same brain, not a quieter second opinion.
@@ -3428,7 +3474,7 @@ export function createWorkManager({
3428
3474
  const turnArgs = {
3429
3475
  // A plain tab has no tools to name and no session id to pass —
3430
3476
  // its kickoff asks for one complete report instead of a stream.
3431
- prompt: plainTab
3477
+ prompt: plainTab || planTurn
3432
3478
  ? WORK_TURN_KICKOFF_PLAIN({
3433
3479
  sessionName: job.sessionName,
3434
3480
  message,
@@ -3448,6 +3494,10 @@ export function createWorkManager({
3448
3494
  askedByName: job.askedByName,
3449
3495
  }),
3450
3496
  planPerm: captureTab,
3497
+ // `--permission-mode plan` INSTEAD of the build posture — never
3498
+ // beside `--dangerously-skip-permissions`, which silently wins
3499
+ // (claude.mjs, PLAN_MODE_PERM). Present only on a plan turn.
3500
+ ...(planTurn ? { planMode: true } : {}),
3451
3501
  // The adopt turn resumes the TERMINAL conversation by forking it
3452
3502
  // into this cwd (claude: --resume <id> --fork-session). After it
3453
3503
  // speaks once, the fork lives natively here and turn 2+ is the
@@ -3460,12 +3510,18 @@ export function createWorkManager({
3460
3510
  ...(() => {
3461
3511
  const knowledgeDir = knowledgeDirFor(repoRoot);
3462
3512
  return {
3463
- system: withProjectContext(
3464
- plainTab ? SYSTEM_WORK_PLAIN : captureTab ? SYSTEM_CAPTURE : SYSTEM_WORK,
3465
- // ARTIFACTS (0.94.0): every tab but the read-only capture
3466
- // chat, and only while the server can show one.
3467
- { knowledgeDir, artifacts: !captureTab && getArtifactsAccepted() }
3468
- ),
3513
+ // A PLAN TURN (0.97.0) runs the PLAIN contract — it has no
3514
+ // Flowviant tools, see `mcp` above — with no artifacts
3515
+ // paragraph (plan mode cannot write one) and the planning
3516
+ // sentence appended. Every other tab is untouched.
3517
+ system: planTurn
3518
+ ? `${withProjectContext(SYSTEM_WORK_PLAIN, { knowledgeDir, artifacts: false })}\n\n${PLAN_TURN_SENTENCE}`
3519
+ : withProjectContext(
3520
+ plainTab ? SYSTEM_WORK_PLAIN : captureTab ? SYSTEM_CAPTURE : SYSTEM_WORK,
3521
+ // ARTIFACTS (0.94.0): every tab but the read-only capture
3522
+ // chat, and only while the server can show one.
3523
+ { knowledgeDir, artifacts: !captureTab && getArtifactsAccepted() }
3524
+ ),
3469
3525
  // The directory is OUTSIDE a worktree's cwd; Claude Code is
3470
3526
  // told it may read there (`--add-dir`) rather than left to
3471
3527
  // refuse a read-only capture turn a path it was just handed.
@@ -3494,6 +3550,9 @@ export function createWorkManager({
3494
3550
  // learned from a turn rather than looked up.
3495
3551
  onInit: (i) => {
3496
3552
  recordSkills(i.skills);
3553
+ // The CLI's mounted MCP servers and connectors (0.97.0) — the
3554
+ // same free fact off the same event; see recordMcpServers.
3555
+ recordMcpServers(i.mcpServers);
3497
3556
  // The conversation this turn is actually speaking under. Held
3498
3557
  // and persisted after the turn ends, so the NEXT one resumes
3499
3558
  // this exact thread rather than whatever the directory saw
@@ -4868,11 +4927,62 @@ export function createWorkManager({
4868
4927
  const branch = readGit(['symbolic-ref', '--quiet', '--short', 'HEAD']);
4869
4928
 
4870
4929
  const rt = job.runtime || 'claude';
4871
- if (!canRun(RUNTIMES[rt], 'build')) {
4930
+ /**
4931
+ * WHAT THIS CARD HANDS BACK, AND THE POSTURE THAT FOLLOWS (0.97.0).
4932
+ *
4933
+ * `code` (the default — absent on the job IS code) is the build turn,
4934
+ * byte-for-byte what every agent turn was. `design` and `research` run
4935
+ * their own contract (SYSTEM_AGENT_DESIGN / _RESEARCH) under their own
4936
+ * posture, which can write ONLY `.flowviant/artifacts/` (claude.mjs).
4937
+ *
4938
+ * A RUNTIME THAT CANNOT EXPRESS THE POSTURE IS REFUSED, NOT IMPROVISED.
4939
+ * Codex and antigravity declare neither (runtimes.mjs), and the only
4940
+ * alternative to refusing is running the card as a BUILD turn with
4941
+ * permissions skipped — an agent writing code for an ask that was a
4942
+ * mockup, reporting success. `nothing` puts the agent in Stuck saying so.
4943
+ *
4944
+ * THE JOB'S OWN KIND FIRST (2026-09-23). A `human` turn — a send-back
4945
+ * from the review deck, an answer — usually names NO card: the queue has
4946
+ * emptied, or the card it is about was just re-queued behind it. Read off
4947
+ * `job.task` alone, a send-back on a design agent ran as a BUILD turn
4948
+ * under the code contract with permissions skipped — the one turn in the
4949
+ * owner's loop ("iterate in the review column, the agent opened") that
4950
+ * could edit code on an ask that was a mockup. So the server projects the
4951
+ * kind of the card the turn is ABOUT onto the job itself (`taskKind`,
4952
+ * sent only for design and research; the same `DAEMON_TASK_KIND_MIN`
4953
+ * floor as the card's own key, no new one), and the card's key stays the
4954
+ * fallback for a server that sends only that. Absent on both is code.
4955
+ */
4956
+ /**
4957
+ * A KIND THIS DAEMON DOES NOT KNOW IS REFUSED, NEVER BUILT (2026-09-23).
4958
+ * `agentTaskKindOf` reads anything unrecognised as code — right for a
4959
+ * printed label, wrong here, where code means the build posture with
4960
+ * permissions skipped. A server that learned a fourth kind ahead of
4961
+ * this daemon is exactly the case the version floor cannot cover (the
4962
+ * handout does not re-check it), and the answer is an update, said in
4963
+ * the word the server used. `nothing` puts the agent in Stuck with it.
4964
+ */
4965
+ const strangeKind = unknownAgentTaskKind(job.taskKind ?? job.task?.taskKind);
4966
+ if (strangeKind !== null) {
4967
+ await postAgentTurn({
4968
+ turnId,
4969
+ outcome: 'nothing',
4970
+ answer: `this daemon does not know the card kind '${strangeKind}' — update it`,
4971
+ branch,
4972
+ worktree: wt,
4973
+ });
4974
+ return;
4975
+ }
4976
+ const taskKind = agentTaskKindOf(job.taskKind ?? job.task?.taskKind);
4977
+ const posture = taskKind === 'code' ? 'build' : taskKind;
4978
+ if (!canRun(RUNTIMES[rt], posture)) {
4872
4979
  await postAgentTurn({
4873
4980
  turnId,
4874
4981
  outcome: 'nothing',
4875
- answer: `this machine cannot run ${rt}`,
4982
+ answer:
4983
+ posture === 'build'
4984
+ ? `this machine cannot run ${rt}`
4985
+ : 'design and research cards run on Claude on this machine',
4876
4986
  branch,
4877
4987
  worktree: wt,
4878
4988
  });
@@ -5003,13 +5113,16 @@ export function createWorkManager({
5003
5113
  // The knowledge paragraph, when this box holds a library — the same
5004
5114
  // composer the tabs use, so an agent and a tab can never be told two
5005
5115
  // different things about the same directory.
5006
- system: withProjectContext(SYSTEM_AGENT, {
5116
+ system: withProjectContext(SYSTEM_AGENT_FOR(taskKind), {
5007
5117
  knowledgeDir: knowledgeDirFor(repoRoot),
5008
5118
  // ARTIFACTS (0.94.0), while the server can show one — an agent's
5009
5119
  // land on its page, under the facts row.
5010
5120
  artifacts: getArtifactsAccepted(),
5011
5121
  }),
5012
5122
  knowledgeDir: knowledgeDirFor(repoRoot),
5123
+ // Named only for the two non-code kinds; a code turn passes nothing
5124
+ // and keeps the build branch it always had.
5125
+ ...(posture !== 'build' ? { posture } : {}),
5013
5126
  cwd: wt,
5014
5127
  runtime: rt,
5015
5128
  resume,
@@ -5057,6 +5170,16 @@ export function createWorkManager({
5057
5170
  onUsage: (u) => {
5058
5171
  usage = u;
5059
5172
  },
5173
+ // WHAT THE CLI SAYS IT CAN BE ASKED FOR, AND WHICH CONNECTORS NEED A
5174
+ // LOGIN — the same free fact off the same init event the tab lane
5175
+ // records (2026-09-23). Without it a box that only ever runs agents
5176
+ // taught the app its skills and connectors ONCE, from the startup
5177
+ // probe, and never again: a connector signed into at the box kept
5178
+ // reading "needs authorization" for the life of the process.
5179
+ onInit: (i) => {
5180
+ recordSkills(i.skills);
5181
+ recordMcpServers(i.mcpServers);
5182
+ },
5060
5183
  onSpawn: (ch) => {
5061
5184
  child = ch;
5062
5185
  // The AGENT it serves — what the machine snapshot charges this
@@ -5161,6 +5284,60 @@ export function createWorkManager({
5161
5284
  });
5162
5285
  return;
5163
5286
  }
5287
+ /**
5288
+ * A DESIGN OR RESEARCH CARD IS NOT DELIVERED UNTIL ITS ARTIFACT EXISTS
5289
+ * (0.97.0) — measured, never taken on the agent's word.
5290
+ *
5291
+ * Its whole product is one file under `.flowviant/artifacts/`: a `.html`
5292
+ * mockup for design, a `.md` write-up for research. A turn that says
5293
+ * "delivered" without having written one would land the agent in Review
5294
+ * with nothing to look at and a summary describing a page that does not
5295
+ * exist — so it settles `nothing` with the measured sentence, and the
5296
+ * agent lands in Stuck with a true reason instead.
5297
+ *
5298
+ * WHAT COUNTS: on a TASK turn, only what the scan found NEW OR CHANGED
5299
+ * this turn (`changedArtifacts` against the snapshot taken before the
5300
+ * spawn) — a second design card in the same agent must not pass on the
5301
+ * first card's page. On a HUMAN turn (an answer, a send-back) a matching
5302
+ * file already standing in the directory also counts: "keep it as it
5303
+ * is" is a legitimate answer to a question the agent asked after
5304
+ * drawing, and the turn did not have to rewrite the page to deliver it.
5305
+ *
5306
+ * …AND ON A REDO (2026-09-23): a TASK turn re-running a card this agent
5307
+ * already delivered, which is what a send-back's "Needs work" queues —
5308
+ * the human turn in front of it usually rewrote the mockup already, so
5309
+ * the task turn that follows truthfully says "revised last turn" with
5310
+ * nothing new to write, and the task rule sent it to Stuck with a false
5311
+ * sentence. The server marks such a job `redo: true` (a link delivered
5312
+ * before, or carrying a `needs_work` verdict), and then a standing match
5313
+ * counts, as it does for a human turn. No floor: an older server never
5314
+ * sends the key, and absent keeps the stricter task rule.
5315
+ *
5316
+ * Commits are still reported if any exist — the posture prevents them,
5317
+ * and a report never lies by omission about what is on the branch.
5318
+ */
5319
+ if (res.outcome === 'delivered' && taskKind !== 'code') {
5320
+ const want = taskKind === 'design' ? /\.html?$/i : /\.md$/i;
5321
+ const standing = scanArtifacts(wt);
5322
+ const wrote = changedArtifacts(artifactsBefore, standing).some((e) => want.test(e.name));
5323
+ const present =
5324
+ wrote || ((job.kind !== 'task' || job.redo === true) && standing.some((e) => want.test(e.name)));
5325
+ if (!present) {
5326
+ await postAgentTurn({
5327
+ turnId,
5328
+ outcome: 'nothing',
5329
+ answer:
5330
+ taskKind === 'design'
5331
+ ? 'the turn ended without writing a mockup under .flowviant/artifacts/'
5332
+ : 'the turn ended without writing a write-up under .flowviant/artifacts/',
5333
+ ...(commits.length ? { commits } : {}),
5334
+ ...(usage ? { usage } : {}),
5335
+ branch,
5336
+ worktree: wt,
5337
+ });
5338
+ return;
5339
+ }
5340
+ }
5164
5341
  const reply = await postAgentTurn({
5165
5342
  turnId,
5166
5343
  outcome: res.outcome,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.95.0",
3
+ "version": "0.97.0",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {