@a-t-h-i/bot-lobby 0.6.4 → 0.6.5

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.
@@ -59,6 +59,7 @@ import type { EffortRouter } from "../classifier/effort.ts";
59
59
  import { answerClarify } from "../classifier/triage.ts";
60
60
  import { appendMetrics, metricFromRun } from "../state/metrics.ts";
61
61
  import { markCommentsAddressed, pendingComments, readPlanComments } from "../state/comments.ts";
62
+ import { fastNext, onFastTrack, parseRoster, qaRequired, qaStillDue, qaTookPart, rosterWords, trackSummary } from "./track.ts";
62
63
 
63
64
  export const ORCHESTRATE_ACTIONS = [
64
65
  "clarify",
@@ -76,6 +77,7 @@ export const ORCHESTRATE_ACTIONS = [
76
77
  "resume",
77
78
  "decide",
78
79
  "budget",
80
+ "track",
79
81
  "status",
80
82
  "cancel",
81
83
  ] as const;
@@ -112,6 +114,10 @@ export interface OrchestrateParams {
112
114
  note?: string;
113
115
  reason?: string;
114
116
  text?: string;
117
+ /** track: the path the task takes. */
118
+ track?: "fast" | "full";
119
+ /** track: who takes part (designer, backend, qa, researcher). */
120
+ roster?: string[];
115
121
  }
116
122
 
117
123
  export interface WorkflowDeps {
@@ -204,6 +210,7 @@ export function describeTask(task: Task): string {
204
210
  `${task.id} — state: ${task.state}${task.paused ? " (paused)" : ""}`,
205
211
  `Title: ${task.title}`,
206
212
  `Request: ${truncate(taskRequest(task), 200)}`,
213
+ task.track ? trackSummary(task.track) : "",
207
214
  task.domains.length > 0 ? `Domains: ${task.domains.join(", ")}` : "",
208
215
  `Review iterations: ${Object.entries(task.reviewIterations).map(([d, n]) => `${d}=${n}`).join(", ")}`,
209
216
  pendingApprovals(task).length > 0
@@ -512,10 +519,12 @@ function handlePlan(task: Task, params: OrchestrateParams, deps: WorkflowDeps):
512
519
  requireState(task, ["planning", ...AMEND_PLAN_STATES]);
513
520
  const plan = params.plan?.trim();
514
521
  if (!plan) throw new Error("plan requires the plan text");
515
- const missing = validatePlan(plan);
522
+ // The fast track keeps a short plan: the full plan's required areas are the full workflow's.
523
+ const missing = onFastTrack(task) ? [] : validatePlan(plan);
516
524
  if (missing.length > 0) throw new Error(`plan is missing: ${missing.join(", ")}`);
517
525
  const amending = AMEND_PLAN_STATES.includes(task.state);
518
526
  task.plan = plan;
527
+ if (task.track?.autoPlan) delete task.track.autoPlan;
519
528
  writeFileEnsured(join(taskDirFor(deps.root, deps.configDir, task.id), "plan.md"), plan);
520
529
  const addressed = addressComments(task, deps);
521
530
  if (amending) {
@@ -570,7 +579,9 @@ function recordAdvisoryPushbacks(task: Task, entries: Array<{ pushback?: Pushbac
570
579
  }
571
580
  }
572
581
 
573
- function workerReport(outcome: WorkerOutcome, approvals: Approval[], pushback?: Approval): string {
582
+ const FULL_NEXT = "Next: inspect the diff, then run action=qa once this domain's work is complete.";
583
+
584
+ function workerReport(outcome: WorkerOutcome, approvals: Approval[], pushback?: Approval, next = FULL_NEXT): string {
574
585
  const { result, run, issues } = outcome;
575
586
  const objection = result.pushback;
576
587
  return [
@@ -593,7 +604,7 @@ function workerReport(outcome: WorkerOutcome, approvals: Approval[], pushback?:
593
604
  pushback && objection
594
605
  ? `Pushback recorded (${pushback.id}): ${truncate(objection.reason, 240)}. Resolve with action=resolve_approval before re-delegating ${result.domain}.`
595
606
  : "",
596
- "Next: inspect the diff, then run action=qa once this domain's work is complete.",
607
+ next,
597
608
  ]
598
609
  .filter((line) => line.length > 0)
599
610
  .join("\n");
@@ -835,17 +846,44 @@ function absorbWorkerOutcome(task: Task, deps: WorkflowDeps, outcome: WorkerOutc
835
846
  const pushback = recordPushback(task, outcome);
836
847
  task.blockers = [...task.blockers.filter((blocker) => blocker.domain !== domain), ...outcome.result.blockers];
837
848
  updateScratchpad(deps, task, outcome);
838
- const spent = timeReport(outcome);
839
- return spent ? `${workerReport(outcome, approvals, pushback)}\n${spent}` : workerReport(outcome, approvals, pushback);
849
+ const grown = qaJoinsGrownFastTask(task, outcome);
850
+ const report = workerReport(outcome, approvals, pushback, onFastTrack(task) ? fastNext(task) : FULL_NEXT);
851
+ return [report, grown, timeReport(outcome)].filter(Boolean).join("\n");
840
852
  }
841
853
 
854
+ /**
855
+ * A fast task whose worker asks for a new dependency or an architecture
856
+ * change is not the small change it read as: QA joins, so it is checked
857
+ * before it completes. Returns the line for the oracle, or "".
858
+ */
859
+ function qaJoinsGrownFastTask(task: Task, outcome: WorkerOutcome): string {
860
+ const track = task.track;
861
+ if (!track || track.path !== "fast" || track.roster.includes("qa")) return "";
862
+ const asks = [...outcome.result.dependencyNeeds, ...outcome.result.architectureChanges];
863
+ if (asks.length === 0) return "";
864
+ track.roster = parseRoster([...track.roster, "qa"]);
865
+ track.reasons = [...track.reasons, `tests: ${AGENT_LABELS[outcome.result.domain]} asked for a dependency or an architecture change`];
866
+ recordDecision(task, `QA joined the fast track: ${AGENT_LABELS[outcome.result.domain]} asked for ${truncate(asks.join("; "), 200)}.`);
867
+ return "QA now takes part: a fast-track change that needs a new dependency or an architecture change is checked before it completes.";
868
+ }
869
+
870
+ /** States a fast task starts its work from: before anything is planned, and never while the user decides on a proposal. */
871
+ const FAST_START_STATES: readonly TaskState[] = ["created", "clarifying", "scouting", "synthesizing"];
872
+
842
873
  async function handleImplement(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
843
- requireState(task, ["planning", "implementing", "reviewing"]);
874
+ const starting = onFastTrack(task) && FAST_START_STATES.includes(task.state);
875
+ if (!starting && FAST_START_STATES.includes(task.state)) {
876
+ throw new Error(`action not allowed in state "${task.state}": on the full workflow the user approves a proposal first (a small, clear change can take the fast track: action=track track=fast)`);
877
+ }
878
+ if (!starting) requireState(task, ["planning", "implementing", "reviewing"]);
844
879
  const assignments = parseAssignments(params);
845
880
  for (const { domain } of assignments) assertNoPendingApprovals(task, domain);
846
881
  for (const { domain } of assignments) if (!task.domains.includes(domain)) task.domains.push(domain);
847
882
  // Under a time budget every step is given its share before any starts; a spent budget starts none.
848
883
  const times = workerTimes(task, deps, assignments);
884
+ if (task.track && onFastTrack(task)) task.track.roster = parseRoster([...task.track.roster, ...assignments.map((entry) => entry.domain)]);
885
+ if (starting) startFast(task);
886
+ if (task.track?.autoPlan) addFastSteps(task, deps, assignments);
849
887
  if (task.state !== "implementing") transition(task, "implementing");
850
888
  await takeBaseline(task, deps);
851
889
  let report: string;
@@ -863,6 +901,61 @@ async function handleImplement(task: Task, params: OrchestrateParams, deps: Work
863
901
  return note ? `${report}\n\n${note}` : report;
864
902
  }
865
903
 
904
+ /**
905
+ * The fast track's start: the request is small and clear, so it goes from
906
+ * shaping straight to planning without a proposal round (the engine walks the
907
+ * same states, so every transition stays legal), and the engine keeps a short
908
+ * plan whose steps are the delegations themselves.
909
+ */
910
+ function startFast(task: Task): void {
911
+ const walk: Partial<Record<TaskState, TaskState[]>> = {
912
+ created: ["clarifying", "awaiting_approval", "planning"],
913
+ clarifying: ["awaiting_approval", "planning"],
914
+ scouting: ["synthesizing", "awaiting_approval", "planning"],
915
+ synthesizing: ["awaiting_approval", "planning"],
916
+ };
917
+ for (const state of walk[task.state] ?? []) transition(task, state);
918
+ const track = task.track!;
919
+ if (!task.plan) {
920
+ task.plan = [
921
+ "## Objective",
922
+ oneLine(taskRequest(task), 600),
923
+ "",
924
+ "## Track",
925
+ `Fast track (${track.size}): ${rosterWords(track.roster)}. No scouts, proposal round or plan review; ${qaRequired(task) ? "QA takes part before completion (its tests as the last step, or the QA gate)" : "no QA gate, as nothing here needs tests"}.`,
926
+ "",
927
+ "## Steps",
928
+ ].join("\n");
929
+ track.autoPlan = true;
930
+ }
931
+ recordDecision(task, `Fast track: started without a proposal round (${track.size}; ${rosterWords(track.roster)}).`);
932
+ }
933
+
934
+ /** `Step 3: …`, `Steps 2-4 — …` at the start of a delegation. */
935
+ const STEP_PREFIX = /^\s*(?:\*\*)?steps?\s*#?\s*(\d+)(?:\s*(?:-|\u2013|\u2014|to|and|&)\s*#?\s*(\d+))?(?:\*\*)?\s*[:.)\u2013\u2014-]?\s*/i;
936
+
937
+ /**
938
+ * The fast track's plan grows with its delegations: a delegation that does
939
+ * not name a step already in the plan adds one, so the lobby's checklist
940
+ * follows the work without a plan document.
941
+ */
942
+ function addFastSteps(task: Task, deps: WorkflowDeps, assignments: readonly Assignment[]): void {
943
+ const plan = task.plan ?? "";
944
+ const lines = plan.split("\n");
945
+ const heading = lines.findIndex((line) => /^##\s+steps\s*$/i.test(line));
946
+ let count = heading < 0 ? 0 : lines.slice(heading + 1).filter((line) => /^\d+\.\s/.test(line)).length;
947
+ const added: string[] = [];
948
+ for (const { domain, instruction } of assignments) {
949
+ const named = STEP_PREFIX.exec(instruction);
950
+ if (named && Number(named[2] ?? named[1]) <= count) continue;
951
+ count += 1;
952
+ added.push(`${count}. ${AGENT_LABELS[domain]}: ${oneLine(instruction.replace(STEP_PREFIX, ""), 160) || "its part of the request"}`);
953
+ }
954
+ if (added.length === 0) return;
955
+ task.plan = `${plan.trimEnd()}\n${added.join("\n")}`;
956
+ writeFileEnsured(join(taskDirFor(deps.root, deps.configDir, task.id), "plan.md"), task.plan);
957
+ }
958
+
866
959
  /**
867
960
  * Several domains at once. The workers share one file desk: each claims a file
868
961
  * before editing it, queues for a busy one, and hands it over with a note; a
@@ -1159,9 +1252,10 @@ function handleCompact(task: Task, params: OrchestrateParams, deps: WorkflowDeps
1159
1252
 
1160
1253
  /** §63: record history, drop scratchpads, then mark the task completed. */
1161
1254
  async function handleComplete(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
1162
- requireState(task, ["reviewing", "blocked"]);
1163
- // Without a QA pass only the user can let the task finish: they are asked, never overruled.
1164
- if (task.qaVerdict !== "pass" && !task.qaWaiver) await offerAcceptance(task, deps);
1255
+ // The fast track completes straight from its work; the full workflow from review.
1256
+ requireState(task, onFastTrack(task) ? ["implementing", "reviewing", "blocked"] : ["reviewing", "blocked"]);
1257
+ // Without QA's part (where the track asks for it) only the user can let the task finish: they are asked, never overruled.
1258
+ if (qaRequired(task) && !qaTookPart(task) && !task.qaWaiver) await offerAcceptance(task, deps);
1165
1259
  if (task.state === "blocked") throw new Error("cannot complete: the task is blocked, and the user did not accept its work as it is");
1166
1260
  const blockers = completionBlockers(task, pendingApprovals(task).length);
1167
1261
  if (blockers.length > 0) throw new Error(`cannot complete: ${blockers.join("; ")}`);
@@ -1170,6 +1264,7 @@ async function handleComplete(task: Task, params: OrchestrateParams, deps: Workf
1170
1264
  recordCompletion(deps, task, summary);
1171
1265
  removeTaskScratchpads(deps.root, deps.configDir, task.id);
1172
1266
  task.blockers = [];
1267
+ if (task.state === "implementing") transition(task, "reviewing");
1173
1268
  transition(task, "completed");
1174
1269
  const oversized = overThreshold(readDataRoots(deps.root, deps.configDir), deps.config.knowledge.compactionThreshold);
1175
1270
  const advice =
@@ -1227,6 +1322,48 @@ function handleResume(task: Task, params: OrchestrateParams): string {
1227
1322
  return "Task resumed. Continue with action=implement.";
1228
1323
  }
1229
1324
 
1325
+ /**
1326
+ * `action=track`: with neither `track` nor `roster`, the task's track; with
1327
+ * them, the oracle corrects the engine's read of the request. The fast track
1328
+ * is taken only before the work is planned, never against the user's --full
1329
+ * or the settings; once work is under way a track only gets stricter (the
1330
+ * full workflow, more members, QA never dropped).
1331
+ */
1332
+ function handleTrack(task: Task, params: OrchestrateParams, deps: WorkflowDeps): string {
1333
+ const current = task.track;
1334
+ if (!params.track && !params.roster) return current ? trackSummary(current) : "This task began before tracks: it takes the full workflow.";
1335
+ const reason = oneLine(params.reason?.trim() || params.text?.trim() || "", 240);
1336
+ if (!reason) throw new Error("track requires reason: why the task takes that path, or needs those members");
1337
+ const path = params.track ?? current?.path ?? "full";
1338
+ const shaping = FAST_START_STATES.includes(task.state);
1339
+ if (path === "fast" && current?.path !== "fast") {
1340
+ if (!deps.config.workflow.fastTrack) throw new Error("the fast track is off in settings (workflow.fastTrack): this task takes the full workflow");
1341
+ if (current?.userChoice === "full") throw new Error("the user asked for the full workflow on this task (--full)");
1342
+ if (!shaping) throw new Error(`a task takes the fast track before its work is planned; this one is ${task.state}`);
1343
+ }
1344
+ const asked = params.roster ? parseRoster(params.roster) : [...(current?.roster ?? [])];
1345
+ // The full workflow always ends with the QA gate.
1346
+ const roster = path === "full" ? parseRoster([...asked, "qa"]) : asked;
1347
+ if (!shaping && current?.roster.includes("qa") && !roster.includes("qa")) throw new Error("QA stays on the roster once the work is under way");
1348
+ const grew = current?.path === "fast" && path === "full";
1349
+ task.track = {
1350
+ path,
1351
+ size: current?.size ?? "small",
1352
+ roster,
1353
+ reasons: [`oracle: ${reason}`, ...(current?.reasons ?? [])].slice(0, 8),
1354
+ source: "oracle",
1355
+ ...(current?.userChoice ? { userChoice: current.userChoice } : {}),
1356
+ ...(current?.autoPlan ? { autoPlan: true } : {}),
1357
+ at: new Date().toISOString(),
1358
+ };
1359
+ recordDecision(task, `Track: ${path === "fast" ? "fast track" : "full workflow"}; ${rosterWords(roster)} — ${reason}`);
1360
+ if (path === "fast") {
1361
+ return `Fast track: ${rosterWords(roster)}. Delegate straight away with action=implement (open each task with "Step N:"); no scouts, proposal or plan. ${qaRequired(task) ? "QA takes part before completion: its tests as the last step, or action=qa." : "No QA gate: nothing here needs tests."} Then action=complete.`;
1362
+ }
1363
+ if (!shaping) return `Full workflow from here: ${rosterWords(roster)}. The QA gate runs before the task completes${grew ? "; tell the user in one line why the task grew" : ""}.`;
1364
+ return `Full workflow: ${rosterWords(roster)}. Scout what the request touches, then propose a short bullet list for approval.`;
1365
+ }
1366
+
1230
1367
  function handleDecide(task: Task, params: OrchestrateParams): string {
1231
1368
  const text = params.text?.trim();
1232
1369
  if (!text) throw new Error("decide requires text");
@@ -1254,7 +1391,7 @@ type WorkerTime = AgentTime & { id: string };
1254
1391
  /** The task's budget and where it stands, when it has one. */
1255
1392
  function budgetFor(task: Task, deps: WorkflowDeps): { budget: TaskBudget; state: BudgetState } | undefined {
1256
1393
  const budget = readBudget(deps.root, deps.configDir, task.id);
1257
- return budget ? { budget, state: budgetState(task.id, budget, task.qaVerdict === "pass") } : undefined;
1394
+ return budget ? { budget, state: budgetState(task.id, budget, !qaStillDue(task)) } : undefined;
1258
1395
  }
1259
1396
 
1260
1397
  /** No new work starts once the budget is spent: the oracle asks the user for more, or wraps up. */
@@ -1501,6 +1638,7 @@ const HANDLERS: Record<OrchestrateAction, (task: Task, params: OrchestrateParams
1501
1638
  resume: handleResume,
1502
1639
  decide: handleDecide,
1503
1640
  budget: handleBudget,
1641
+ track: handleTrack,
1504
1642
  status: (task) => describeTask(task),
1505
1643
  cancel: handleCancel,
1506
1644
  };