flowviant 0.75.0 → 0.76.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.
@@ -0,0 +1,187 @@
1
+ /**
2
+ * READING A PLANNER'S ANSWER.
3
+ *
4
+ * The scratch agent behind a Deploy press is asked for one JSON object and
5
+ * nothing else. This is what turns its final message into a proposal, and it is
6
+ * its own module because it is the one piece of that turn that can quietly LIE:
7
+ * everything else either works or throws, while a half-parsed proposal renders
8
+ * as a plausible-looking board somebody accepts.
9
+ *
10
+ * LENIENT ON PACKAGING, STRICT ON SHAPE.
11
+ *
12
+ * A model asked for one object will sometimes fence it and sometimes not, and
13
+ * sometimes say "Here you go:" first. Refusing the whole plan over that spends
14
+ * the operator's own model quota to produce nothing, so the packaging is
15
+ * forgiven. What is NOT forgiven is the shape: an agent holding no cards, a
16
+ * `taskIds` that is not an array of strings, or no agents at all is a proposal
17
+ * nobody can accept, and `null` — which the caller reports in the machine's own
18
+ * words — beats rendering an empty board with an Accept button on it.
19
+ *
20
+ * Every string is CAPPED here rather than downstream. This text is model
21
+ * output about untrusted card content, and it becomes a row.
22
+ *
23
+ * Run: node --test bin/lib/agentPlan.test.mjs
24
+ */
25
+
26
+ /** The biggest a single proposal may be. Bounds on a machine, not a policy:
27
+ * the server caps these again at its own boundary. */
28
+ const MAX_AGENTS = 20;
29
+ const MAX_TASKS_PER_AGENT = 60;
30
+ const MAX_NAME = 80;
31
+ const MAX_NOTE = 1000;
32
+
33
+ /**
34
+ * Find the object.
35
+ *
36
+ * A fenced block first, because that is what was asked for. Otherwise every `{`
37
+ * in the text is tried as a start, and its BALANCED end is found by counting
38
+ * braces while skipping string literals — the first candidate that parses into
39
+ * something with an `agents` array wins.
40
+ *
41
+ * The obvious cheap version — first `{` to last `}` — is wrong in a way a test
42
+ * caught: a planner that writes "I looked at {the auth module} first" before its
43
+ * JSON produces a span starting at the wrong brace, and the whole plan is lost
44
+ * to a sentence. Scanning candidates costs nothing at this size and cannot be
45
+ * defeated by prose.
46
+ */
47
+ function extract(raw) {
48
+ const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
49
+ const candidates = [];
50
+ if (fenced) candidates.push(fenced[1]);
51
+ for (let i = 0; i < raw.length; i++) {
52
+ if (raw[i] !== '{') continue;
53
+ const end = balanced(raw, i);
54
+ if (end > i) candidates.push(raw.slice(i, end + 1));
55
+ }
56
+ for (const body of candidates) {
57
+ if (!body.trim()) continue;
58
+ try {
59
+ const v = JSON.parse(body);
60
+ if (v && typeof v === 'object' && Array.isArray(v.agents)) return v;
61
+ } catch {
62
+ /* the next candidate may be the object */
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ /** The index of the `}` that closes the `{` at `from`, or -1. Skips string
69
+ * literals so a brace inside a card title cannot end the object early. */
70
+ function balanced(s, from) {
71
+ let depth = 0;
72
+ let inStr = false;
73
+ let esc = false;
74
+ for (let i = from; i < s.length; i++) {
75
+ const ch = s[i];
76
+ if (inStr) {
77
+ if (esc) esc = false;
78
+ else if (ch === '\\') esc = true;
79
+ else if (ch === '"') inStr = false;
80
+ continue;
81
+ }
82
+ if (ch === '"') inStr = true;
83
+ else if (ch === '{') depth++;
84
+ else if (ch === '}' && --depth === 0) return i;
85
+ }
86
+ return -1;
87
+ }
88
+
89
+ export function parseProposal(text) {
90
+ const raw = String(text ?? '');
91
+ const parsed = extract(raw);
92
+ if (!parsed) return null;
93
+
94
+ const agents = [];
95
+ for (const [i, g] of parsed.agents.slice(0, MAX_AGENTS).entries()) {
96
+ if (!g || typeof g !== 'object') continue;
97
+ const taskIds = Array.isArray(g.taskIds)
98
+ ? g.taskIds.filter((t) => typeof t === 'string' && t.trim()).slice(0, MAX_TASKS_PER_AGENT)
99
+ : [];
100
+ // AN AGENT WITH NO CARDS IS NOT AN AGENT. Dropping it is right rather than
101
+ // merely tidy: accepting one would create a worktree and a branch for
102
+ // nothing, and it would sit in Working forever with an empty queue.
103
+ if (taskIds.length === 0) continue;
104
+ agents.push({
105
+ tempId: String(g.tempId || `a${i + 1}`).slice(0, 64),
106
+ name: String(g.name ?? '').slice(0, MAX_NAME),
107
+ taskIds,
108
+ ...(Number.isFinite(g.pointsBudget) && g.pointsBudget > 0
109
+ ? { pointsBudget: Math.min(Math.round(g.pointsBudget), 100_000) }
110
+ : {}),
111
+ ...(Array.isArray(g.waitsOn)
112
+ ? { waitsOn: g.waitsOn.filter((w) => typeof w === 'string' && w).slice(0, 20) }
113
+ : {}),
114
+ ...(typeof g.intoAgentId === 'string' && g.intoAgentId
115
+ ? { intoAgentId: g.intoAgentId.slice(0, 64) }
116
+ : {}),
117
+ });
118
+ }
119
+ if (agents.length === 0) return null;
120
+ return {
121
+ agents,
122
+ ...(typeof parsed.note === 'string' && parsed.note.trim()
123
+ ? { note: parsed.note.slice(0, MAX_NOTE) }
124
+ : {}),
125
+ };
126
+ }
127
+
128
+ /**
129
+ * READING AN AGENT'S ANSWER at the end of a turn.
130
+ *
131
+ * Same lenient-packaging / strict-shape rule as `parseProposal`, and the same
132
+ * reason: the turn already ran and the operator already paid for it, so
133
+ * refusing the whole thing over a stray "Here you go:" throws away real work.
134
+ *
135
+ * NULL IS A REAL ANSWER AND THE MOST IMPORTANT ONE. It means the turn declared
136
+ * NEITHER delivered nor blocked — a signed-out CLI, a crash, an exhausted
137
+ * quota, or a model that simply stopped — and the caller reports it as
138
+ * `nothing`, which sends the agent to Stuck. Optimistic status from a machine
139
+ * that quit is the one lie this board cannot afford, so anything ambiguous ends
140
+ * up here rather than being read as success.
141
+ */
142
+ export function parseTurnResult(text) {
143
+ const parsedRaw = String(text ?? '');
144
+ const fenced = parsedRaw.match(/```(?:json)?\s*([\s\S]*?)```/);
145
+ const candidates = [];
146
+ if (fenced) candidates.push(fenced[1]);
147
+ for (let i = 0; i < parsedRaw.length; i++) {
148
+ if (parsedRaw[i] !== '{') continue;
149
+ const end = balanced(parsedRaw, i);
150
+ if (end > i) candidates.push(parsedRaw.slice(i, end + 1));
151
+ }
152
+ for (const body of candidates) {
153
+ let v;
154
+ try {
155
+ v = JSON.parse(body);
156
+ } catch {
157
+ continue;
158
+ }
159
+ if (!v || typeof v !== 'object') continue;
160
+ if (v.status === 'blocked') {
161
+ const question = typeof v.question === 'string' ? v.question.trim() : '';
162
+ // A "blocked" with no question is not an answer anybody can act on — it
163
+ // parks an agent with nothing to reply to. Treated as `nothing`, which
164
+ // at least says truthfully that the machine went quiet.
165
+ if (!question) continue;
166
+ return { outcome: 'question', answer: question.slice(0, 8000) };
167
+ }
168
+ if (v.status === 'delivered') {
169
+ return {
170
+ outcome: 'delivered',
171
+ answer: (typeof v.summary === 'string' ? v.summary : '').slice(0, 8000),
172
+ raised: Array.isArray(v.raised)
173
+ ? v.raised
174
+ .filter((r) => r && typeof r.title === 'string' && r.title.trim())
175
+ .slice(0, 10)
176
+ .map((r) => ({
177
+ title: r.title.trim().slice(0, 300),
178
+ ...(typeof r.brief === 'string' && r.brief.trim()
179
+ ? { brief: r.brief.trim().slice(0, 2000) }
180
+ : {}),
181
+ }))
182
+ : [],
183
+ };
184
+ }
185
+ }
186
+ return null;
187
+ }
package/bin/lib/fleet.mjs CHANGED
@@ -817,6 +817,10 @@ export async function runFleetDaemon() {
817
817
  processDiffJobs,
818
818
  processKillJobs,
819
819
  processPrJobs,
820
+ processAgentPlanJobs,
821
+ processAgentTurnJobs,
822
+ processAgentMergeJobs,
823
+ freshenManualPlaces,
820
824
  heldSessionIds,
821
825
  processPreviewJobs,
822
826
  livePreviewIds,
@@ -1616,6 +1620,21 @@ export async function runFleetDaemon() {
1616
1620
  // `gh` credential; a settle never closes a card (done is observed by the
1617
1621
  // landed walk when the merge reaches base).
1618
1622
  processPrJobs(roster.prJobs);
1623
+ // A Deploy press waiting for a plan. AFTER the job lanes above and before
1624
+ // the worktree report, for no reason other than that it reads directories
1625
+ // those lanes may still be writing — it measures, so a stale read is a
1626
+ // slightly worse hint and never a wrong action.
1627
+ processAgentPlanJobs(roster.agentPlanJobs);
1628
+ // …and an agent's next card. After the plan jobs because a press becoming
1629
+ // agents is the thing that produces these.
1630
+ processAgentTurnJobs(roster.agentTurnJobs);
1631
+ // …and a branch somebody approved. After the turns: a merge takes the
1632
+ // place's WRITER lock, and writer preference means it goes ahead of any
1633
+ // reader queued behind it anyway.
1634
+ processAgentMergeJobs(roster.agentMergeJobs);
1635
+ // Catch each person's manual worktree up to base while it is clean. Silent,
1636
+ // fast-forward only, and it never touches an agent's branch.
1637
+ freshenManualPlaces();
1619
1638
  // …and what the SURVIVING ones hold: branch, ahead-of-base, diffstat.
1620
1639
  // Throttled inside, never awaited — a `git status` the human cannot run
1621
1640
  // themselves from a browser, relayed. After retirement so a directory that
@@ -501,3 +501,254 @@ export const REGROUND_KICKOFF = ({ sha, title, files, vaultDir, predictedPages =
501
501
  `chapter that covers them), append the feature-history entry to log.md,\n` +
502
502
  `then output REGROUND_DONE.`;
503
503
 
504
+
505
+ /**
506
+ * THE PLANNER — the scratch agent behind a Deploy press (2026-09-03).
507
+ *
508
+ * Somebody selected cards and pressed Deploy. This turn's whole job is to
509
+ * decide HOW THAT WORK SHOULD BE SPLIT across agents, and then stop. It writes
510
+ * no code, edits no files and starts nothing: a person reads what it proposes,
511
+ * edits it on the board, and accepting is what spawns anything.
512
+ *
513
+ * It runs READ-ONLY IN THE CHECKOUT under CONSULT_PERM — no Write, no Edit, no
514
+ * mkdir, no rm, and no MCP at all. The proposal comes back as its final
515
+ * message, not through a tool, which is what lets that permission set be this
516
+ * narrow. A planner authors a decision, and there is no file on this machine it
517
+ * has any business touching.
518
+ *
519
+ * The two facts it is asked to weigh are the only two that are actually
520
+ * knowable here: what the cards SAY, and what each live agent has already
521
+ * TOUCHED. Everything else — how long something will take, who should own it,
522
+ * whether it is a good idea — is not a question a planner can answer from a
523
+ * repository, and asking for it produces confident invention.
524
+ */
525
+ export const SYSTEM_PLAN = `You are the human's own Claude, planning a batch of work in their repository.
526
+
527
+ You are READ-ONLY. You cannot write, edit or create files, and you have no tools
528
+ beyond reading the repo. Do not try. Your entire output is a plan.
529
+
530
+ WHAT YOU ARE DECIDING: a set of task cards has been selected. Split them across
531
+ one or more AGENTS. An agent is one CLI in one git worktree on one branch,
532
+ working its cards ONE AT A TIME in the order you give, and landing all of them
533
+ as a single reviewable branch.
534
+
535
+ THE RULES THAT MATTER:
536
+
537
+ 1. SEQUENTIAL WORK BELONGS IN ONE AGENT. If B needs A's code to exist, put them
538
+ in the same agent, A first. Splitting a chain across agents means the second
539
+ one waits for the first to MERGE before it can even start.
540
+
541
+ 2. SPLIT ONLY WHAT CAN GENUINELY RUN AT THE SAME TIME. Two agents editing the
542
+ same files land two branches that conflict, and somebody resolves it by hand.
543
+ Read the repo to find out whether they collide — do not guess from titles.
544
+
545
+ 3. AN AGENT IS ONE REVIEWABLE BRANCH. Everything you put in one agent gets
546
+ approved or rejected TOGETHER. Cards a person would want to judge separately
547
+ belong in separate agents.
548
+
549
+ 4. FEWER, LARGER AGENTS BEAT MANY SMALL ONES. Only a limited number run at once;
550
+ past that they queue, and a queue of tiny agents is slower than a few real
551
+ ones. Never propose more agents than the cap you were given.
552
+
553
+ 5. NAME EACH AGENT AFTER THE WORK ("auth", "billing webhooks"), not after a
554
+ number. People say these names out loud.
555
+
556
+ 6. A POINTS BUDGET bounds how far an agent may grow while it works — it files
557
+ its own follow-up cards when it finds things, and the budget is where it
558
+ stops and asks. Set it from the size of what you put in, leaving some room.
559
+ Omit it if the cards carry no sizes.
560
+
561
+ 7. YOU MAY ADD CARDS TO A LIVE AGENT instead of creating a new one, when the
562
+ work needs what that agent has already built and has not merged yet. Use its
563
+ id in "intoAgentId".
564
+
565
+ ANSWER WITH ONE JSON OBJECT AND NOTHING ELSE — no prose before it, no prose
566
+ after it. Wrap it in a \`\`\`json fence:
567
+
568
+ \`\`\`json
569
+ {
570
+ "note": "one sentence on why you split it this way",
571
+ "agents": [
572
+ {
573
+ "tempId": "a1",
574
+ "name": "auth",
575
+ "taskIds": ["<card id>", "<card id>"],
576
+ "pointsBudget": 8,
577
+ "waitsOn": [],
578
+ "intoAgentId": null
579
+ }
580
+ ]
581
+ }
582
+ \`\`\`
583
+
584
+ Every selected card id must appear EXACTLY ONCE across all agents. Use the ids
585
+ exactly as given. "waitsOn" holds tempIds of agents that must MERGE first.`;
586
+
587
+ /**
588
+ * The planner's turn.
589
+ *
590
+ * The cards and the live agents are FENCED: their titles, briefs and criteria
591
+ * are written by whoever files cards in this project and by agents themselves,
592
+ * and this turn reads a repository afterwards. The instruction that matters —
593
+ * "split this" — is ours and sits outside the fence.
594
+ */
595
+ export const AGENT_PLAN_KICKOFF = ({ tasks, liveAgents, agentCap }) => {
596
+ const cards = tasks
597
+ .map(
598
+ (t) =>
599
+ `- id: ${t.id}\n title: ${t.title}\n` +
600
+ (t.points ? ` points: ${t.points}\n` : '') +
601
+ (t.anchors?.length ? ` owns: ${t.anchors.join(', ')}\n` : '') +
602
+ (t.brief ? ` brief: ${t.brief}\n` : '') +
603
+ (t.criteria?.length ? ` done when:\n${t.criteria.map((c) => ` - ${c}`).join('\n')}\n` : '')
604
+ )
605
+ .join('\n');
606
+ const live = liveAgents.length
607
+ ? liveAgents
608
+ .map(
609
+ (a) =>
610
+ `- id: ${a.id}\n name: ${a.name || '(unnamed)'}\n status: ${a.status}\n` +
611
+ (a.changedFiles?.length
612
+ ? ` has already changed:\n${a.changedFiles.map((f) => ` - ${f}`).join('\n')}\n`
613
+ : ' has changed nothing yet\n')
614
+ )
615
+ .join('\n')
616
+ : '(none)';
617
+ return (
618
+ `Split this batch of work across agents.\n\n` +
619
+ `At most ${agentCap} agent${agentCap === 1 ? '' : 's'} run at once on this machine; ` +
620
+ `propose no more than that.\n\n` +
621
+ `${fence('THE SELECTED CARDS', cards)}\n\n` +
622
+ `${fence('AGENTS ALREADY RUNNING (you may add to one)', live)}\n\n` +
623
+ `Read whatever you need from the repository to decide whether these collide. ` +
624
+ `Then answer with the JSON object and nothing else.`
625
+ );
626
+ };
627
+
628
+ /**
629
+ * AN AGENT'S SYSTEM PROMPT — the contract for one card in one worktree.
630
+ *
631
+ * Deliberately not SYSTEM_WORK. A Workbench tab is a CONVERSATION: it narrates
632
+ * through tools, holds context across many turns, and a person is watching. An
633
+ * agent turn is a TASK — it starts, does one card, and ends — and nobody is
634
+ * watching while it runs. So the whole contract is: do this card, commit it,
635
+ * and end with a JSON object saying what happened.
636
+ *
637
+ * IT HAS NO TOOLS BEYOND THE REPOSITORY. There is no MCP on this turn at all,
638
+ * which is why everything it needs to say has to fit in that final object and
639
+ * everything it needs to PROVE is measured from git afterwards. An agent naming
640
+ * its own commit shas would be a receipt pointing at whatever it liked; the
641
+ * daemon reads the log instead.
642
+ *
643
+ * THE ONE THING IT MUST NOT DO IS GUESS. A turn that ends with a question costs
644
+ * a person one reply; a turn that guesses costs them a review, a rejection and
645
+ * a second run — and the guess arrives wearing a confident summary.
646
+ */
647
+ export const SYSTEM_AGENT = `You are the human's own Claude, working one task in a git worktree of their
648
+ repository. Nobody is watching this run. You have the repo and nothing else —
649
+ no project tools, no board, no chat.
650
+
651
+ WHAT TO DO:
652
+
653
+ 1. Do the card you are given. Read whatever you need first. Follow the
654
+ repository's own conventions over anything you would do by default.
655
+
656
+ 2. COMMIT YOUR WORK before you finish. Small, real commit messages. Uncommitted
657
+ work is work nobody can review or merge.
658
+
659
+ 3. If you cannot finish because you need a DECISION only a person can make —
660
+ an ambiguous requirement, a choice between two designs, a missing credential
661
+ — STOP AND ASK. Do not guess. A question costs one reply; a guess costs a
662
+ review, a rejection and a second run, and it arrives looking finished.
663
+
664
+ 4. If you find something broken that is NOT this card — a bug, a failing test
665
+ you did not cause — you may fix it, and you must SAY SO by raising it. It
666
+ becomes its own card so a person can see it happened rather than finding it
667
+ in the diff.
668
+
669
+ COMMIT TRAILER: every commit you make must end with a line reading
670
+ Flowviant-Task: <the card id you were given>
671
+ It is how the board attaches your commits to the card; a commit without it is
672
+ work nobody can trace back.
673
+
674
+ END YOUR TURN WITH ONE JSON OBJECT AND NOTHING AFTER IT, in a \`\`\`json fence:
675
+
676
+ \`\`\`json
677
+ {
678
+ "status": "delivered",
679
+ "summary": "one or two sentences on what you actually changed",
680
+ "raised": [{ "title": "short title", "brief": "what is wrong and what you did" }]
681
+ }
682
+ \`\`\`
683
+
684
+ or, if you are stopping to ask:
685
+
686
+ \`\`\`json
687
+ {
688
+ "status": "blocked",
689
+ "question": "the specific thing you need decided, in one or two sentences"
690
+ }
691
+ \`\`\`
692
+
693
+ "raised" is optional and only for work you did that was NOT this card. Do not
694
+ list the card itself there. Do not put commit shas in the summary — they are
695
+ read from git.`;
696
+
697
+ /**
698
+ * The turn itself.
699
+ *
700
+ * The card is FENCED: its title, brief and criteria are written by whoever
701
+ * files cards in this project, and this turn is about to edit code. The
702
+ * instruction is ours and sits outside the fence.
703
+ *
704
+ * The queue POSITION is stated because it changes behaviour: an agent that
705
+ * thinks it is finishing tidies up, writes summaries and stops; one that knows
706
+ * three more cards are coming leaves the ground ready for them.
707
+ */
708
+ export const AGENT_TASK_KICKOFF = ({ agentName, task, position, total }) =>
709
+ `You are the agent "${safeName(agentName)}", working card ${position} of ${total} ` +
710
+ `on this branch. Everything you commit here is reviewed and merged TOGETHER with ` +
711
+ `the other cards in this run.\n\n` +
712
+ `${fence('THE CARD', taskBlock(task))}\n\n` +
713
+ `When you commit, put this trailer on the LAST line of each commit message so ` +
714
+ `the card can find its own commits:\n` +
715
+ `Flowviant-Task: ${task?.id ?? ''}\n\n` +
716
+ `Do it, commit it, and end with the JSON object.`;
717
+
718
+ /**
719
+ * An agent's NAME is model-authored — the planner chose it — and it is
720
+ * interpolated at the head of the prompt, OUTSIDE the fence, where a sentence
721
+ * would read as an instruction from us. Fencing the name would be absurd (it is
722
+ * two words in the middle of ours), so it is reduced to something that cannot
723
+ * be a sentence: one line, no fence delimiters, and short.
724
+ */
725
+ const safeName = (n) =>
726
+ String(n ?? '')
727
+ .replace(/[\r\n]+/g, ' ')
728
+ .replace(/<<<|>>>/g, '')
729
+ .replace(/"/g, "'")
730
+ .trim()
731
+ .slice(0, 60) || 'agent';
732
+
733
+ const taskBlock = (task) =>
734
+ `id: ${task?.id ?? ''}\n` +
735
+ `title: ${task?.title ?? ''}\n` +
736
+ (task?.brief ? `\nbrief:\n${task.brief}\n` : '') +
737
+ (task?.criteria?.length ? `\ndone when:\n${task.criteria.map((c) => `- ${c}`).join('\n')}\n` : '') +
738
+ (task?.anchors?.length ? `\nthis card owns:\n${task.anchors.map((a) => `- ${a}`).join('\n')}\n` : '');
739
+
740
+ /**
741
+ * A PERSON SPOKE TO THE AGENT — usually the answer to its own question.
742
+ *
743
+ * It is the same shape as a task turn on purpose: the agent still ends with the
744
+ * JSON object, because whatever it does next either finishes the card it was on
745
+ * or blocks again, and those are the only two things the board can act on.
746
+ */
747
+ export const AGENT_HUMAN_KICKOFF = ({ agentName, message, askedByName, task, position, total }) =>
748
+ `You are the agent "${safeName(agentName)}"` +
749
+ (task ? `, working card ${position} of ${total} on this branch` : '') +
750
+ `.\n\n` +
751
+ `${fence('WHO IS TALKING', askedByName || 'a member of this project')}\n\n` +
752
+ `${fence('WHAT THEY SAID', message)}\n\n` +
753
+ (task ? `${fence('THE CARD YOU ARE ON', taskBlock(task))}\n\n` : '') +
754
+ `Carry on, and end with the JSON object as usual.`;