@bojackduy/opencode-loopd 1.5.2 → 1.6.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.
package/dist/tui.js CHANGED
@@ -22,7 +22,7 @@ import { useKeyboard } from "@opentui/solid";
22
22
  // src/infrastructure/state-repository.ts
23
23
  import { promises as fs } from "fs";
24
24
  import path from "path";
25
- var CURRENT_VERSION = 2;
25
+ var CURRENT_VERSION = 5;
26
26
  function emptyState() {
27
27
  return { version: CURRENT_VERSION, revision: 0, goals: [], runtimes: [], commandLedger: [] };
28
28
  }
@@ -76,6 +76,53 @@ function migrate(state) {
76
76
  blocker: g.blocker ?? undefined
77
77
  }));
78
78
  }
79
+ if (result.version < 3) {
80
+ result.version = 3;
81
+ result.runtimes = result.runtimes.map((rt) => {
82
+ const oldTurnCount = rt.turnCount ?? 0;
83
+ const { turnCount: _deprecatedTurnCount, ...rest } = rt;
84
+ return {
85
+ ...rest,
86
+ budgetTurnCount: rest.budgetTurnCount ?? oldTurnCount,
87
+ runCount: rest.runCount ?? oldTurnCount,
88
+ runGeneration: rest.runGeneration ?? 0,
89
+ freeRetryPending: rest.freeRetryPending ?? false,
90
+ lastRejectionDetails: rest.lastRejectionDetails ?? undefined,
91
+ activePromptMessageID: rest.activePromptMessageID ?? undefined,
92
+ lastActivityAt: rest.lastActivityAt ?? undefined,
93
+ idleCandidateAt: rest.idleCandidateAt ?? undefined,
94
+ activeToolCallIDs: rest.activeToolCallIDs ?? []
95
+ };
96
+ });
97
+ }
98
+ if (result.version < 4) {
99
+ result.version = 4;
100
+ result.runtimes = result.runtimes.map((rt) => ({
101
+ ...rt,
102
+ lastVerificationAttempt: rt.lastVerificationAttempt ?? undefined,
103
+ recentVerificationAttempts: rt.recentVerificationAttempts ?? []
104
+ }));
105
+ }
106
+ if (result.version < 5) {
107
+ result.version = 5;
108
+ result.goals = result.goals.map((goal) => ({
109
+ ...goal,
110
+ config: {
111
+ ...goal.config,
112
+ workspaceWrite: goal.config?.workspaceWrite ?? true
113
+ }
114
+ }));
115
+ result.runtimes = result.runtimes.map((rt) => ({
116
+ ...rt,
117
+ activePromptObservedAt: rt.activePromptObservedAt ?? undefined,
118
+ activeAssistantMessageID: rt.activeAssistantMessageID ?? undefined,
119
+ activeAssistantCompletedAt: rt.activeAssistantCompletedAt ?? undefined,
120
+ idleCandidateGeneration: rt.idleCandidateGeneration ?? undefined,
121
+ unknownStatusCount: rt.unknownStatusCount ?? 0,
122
+ lastUnknownStatusAt: rt.lastUnknownStatusAt ?? undefined,
123
+ workerUnreachableNotifiedAt: rt.workerUnreachableNotifiedAt ?? undefined
124
+ }));
125
+ }
79
126
  return result;
80
127
  }
81
128
  async function writeAtomic(target, contents) {
@@ -643,7 +690,7 @@ function LoopDashboard(props) {
643
690
  return;
644
691
  }
645
692
  });
646
- const goals = () => state()?.goals.filter((g) => g.status !== "complete") || [];
693
+ const goals = () => state()?.goals.filter((g) => showCompleted() || g.status !== "complete") || [];
647
694
  async function executeCommand(cmd) {
648
695
  debugLog("executeCommand raw=", JSON.stringify(cmd));
649
696
  const parsed = parseCommand(cmd);
@@ -1203,7 +1250,7 @@ function LoopDashboard(props) {
1203
1250
  const turnColor = () => {
1204
1251
  if (!runtime() || !maxTurns)
1205
1252
  return phaseColor(runtime()?.phase || "idle", theme());
1206
- const ratio = runtime().turnCount / maxTurns;
1253
+ const ratio = runtime().budgetTurnCount / maxTurns;
1207
1254
  if (ratio >= 1)
1208
1255
  return theme().error;
1209
1256
  if (ratio >= 0.8)
@@ -1250,7 +1297,7 @@ function LoopDashboard(props) {
1250
1297
  })(), (() => {
1251
1298
  var _el$98 = _$createElement("span"), _el$99 = _$createTextNode(` `);
1252
1299
  _$insertNode(_el$98, _el$99);
1253
- _$insert(_el$98, () => runtime().turnCount, null);
1300
+ _$insert(_el$98, () => runtime().budgetTurnCount, null);
1254
1301
  _$insert(_el$98, maxTurns ? `/${maxTurns}` : "", null);
1255
1302
  _$effect((_$p) => _$setProp(_el$98, "style", {
1256
1303
  fg: turnColor()
@@ -1366,9 +1413,12 @@ function LoopDashboard(props) {
1366
1413
  }, _$p));
1367
1414
  return _el$118;
1368
1415
  })(), (() => {
1369
- var _el$120 = _$createElement("span"), _el$121 = _$createTextNode(` turn `);
1416
+ var _el$120 = _$createElement("span"), _el$121 = _$createTextNode(` run `), _el$122 = _$createTextNode(` (budget `), _el$123 = _$createTextNode(`)`);
1370
1417
  _$insertNode(_el$120, _el$121);
1371
- _$insert(_el$120, () => rt().turnCount, null);
1418
+ _$insertNode(_el$120, _el$122);
1419
+ _$insertNode(_el$120, _el$123);
1420
+ _$insert(_el$120, () => rt().runCount, _el$122);
1421
+ _$insert(_el$120, () => rt().budgetTurnCount, _el$123);
1372
1422
  _$effect((_$p) => _$setProp(_el$120, "style", {
1373
1423
  fg: theme().textMuted
1374
1424
  }, _$p));
@@ -1379,113 +1429,113 @@ function LoopDashboard(props) {
1379
1429
  _$insert(_el$109, (() => {
1380
1430
  var _c$8 = _$memo(() => !!lp());
1381
1431
  return () => _c$8() && [(() => {
1382
- var _el$122 = _$createElement("span"), _el$123 = _$createTextNode(`
1432
+ var _el$124 = _$createElement("span"), _el$125 = _$createTextNode(`
1383
1433
  \u2714 `);
1384
- _$insertNode(_el$122, _el$123);
1385
- _$effect((_$p) => _$setProp(_el$122, "style", {
1434
+ _$insertNode(_el$124, _el$125);
1435
+ _$effect((_$p) => _$setProp(_el$124, "style", {
1386
1436
  fg: theme().success
1387
1437
  }, _$p));
1388
- return _el$122;
1438
+ return _el$124;
1389
1439
  })(), (() => {
1390
- var _el$125 = _$createElement("span");
1391
- _$insert(_el$125, () => lp().summary.slice(0, 100));
1392
- _$effect((_$p) => _$setProp(_el$125, "style", {
1440
+ var _el$127 = _$createElement("span");
1441
+ _$insert(_el$127, () => lp().summary.slice(0, 100));
1442
+ _$effect((_$p) => _$setProp(_el$127, "style", {
1393
1443
  fg: theme().text
1394
1444
  }, _$p));
1395
- return _el$125;
1445
+ return _el$127;
1396
1446
  })(), (() => {
1397
- var _el$126 = _$createElement("span"), _el$127 = _$createTextNode(` \u2192 `);
1398
- _$insertNode(_el$126, _el$127);
1399
- _$insert(_el$126, () => lp().next?.slice(0, 60) || "", null);
1400
- _$effect((_$p) => _$setProp(_el$126, "style", {
1447
+ var _el$128 = _$createElement("span"), _el$129 = _$createTextNode(` \u2192 `);
1448
+ _$insertNode(_el$128, _el$129);
1449
+ _$insert(_el$128, () => lp().next?.slice(0, 60) || "", null);
1450
+ _$effect((_$p) => _$setProp(_el$128, "style", {
1401
1451
  fg: theme().textMuted
1402
1452
  }, _$p));
1403
- return _el$126;
1453
+ return _el$128;
1404
1454
  })()];
1405
1455
  })(), null);
1406
1456
  _$insert(_el$109, (() => {
1407
1457
  var _c$9 = _$memo(() => !!blk());
1408
1458
  return () => _c$9() && [(() => {
1409
- var _el$128 = _$createElement("span"), _el$129 = _$createTextNode(`
1459
+ var _el$130 = _$createElement("span"), _el$131 = _$createTextNode(`
1410
1460
  \u2716 blocked: `);
1411
- _$insertNode(_el$128, _el$129);
1412
- _$effect((_$p) => _$setProp(_el$128, "style", {
1461
+ _$insertNode(_el$130, _el$131);
1462
+ _$effect((_$p) => _$setProp(_el$130, "style", {
1413
1463
  fg: theme().error,
1414
1464
  bold: true
1415
1465
  }, _$p));
1416
- return _el$128;
1466
+ return _el$130;
1417
1467
  })(), (() => {
1418
- var _el$131 = _$createElement("span");
1419
- _$insert(_el$131, () => blk().reason.slice(0, 140));
1420
- _$effect((_$p) => _$setProp(_el$131, "style", {
1468
+ var _el$133 = _$createElement("span");
1469
+ _$insert(_el$133, () => blk().reason.slice(0, 140));
1470
+ _$effect((_$p) => _$setProp(_el$133, "style", {
1421
1471
  fg: theme().error
1422
1472
  }, _$p));
1423
- return _el$131;
1473
+ return _el$133;
1424
1474
  })(), (() => {
1425
- var _el$132 = _$createElement("span"), _el$133 = _$createTextNode(` \u2014 `);
1426
- _$insertNode(_el$132, _el$133);
1427
- _$insert(_el$132, () => blk().needed.slice(0, 60), null);
1428
- _$effect((_$p) => _$setProp(_el$132, "style", {
1475
+ var _el$134 = _$createElement("span"), _el$135 = _$createTextNode(` \u2014 `);
1476
+ _$insertNode(_el$134, _el$135);
1477
+ _$insert(_el$134, () => blk().needed.slice(0, 60), null);
1478
+ _$effect((_$p) => _$setProp(_el$134, "style", {
1429
1479
  fg: theme().textMuted
1430
1480
  }, _$p));
1431
- return _el$132;
1481
+ return _el$134;
1432
1482
  })()];
1433
1483
  })(), null);
1434
1484
  _$insert(_el$109, (() => {
1435
1485
  var _c$0 = _$memo(() => !!goal().config.artifactDir);
1436
1486
  return () => _c$0() && [(() => {
1437
- var _el$134 = _$createElement("span"), _el$135 = _$createTextNode(`
1487
+ var _el$136 = _$createElement("span"), _el$137 = _$createTextNode(`
1438
1488
  \uD83D\uDCC1 `);
1439
- _$insertNode(_el$134, _el$135);
1440
- _$effect((_$p) => _$setProp(_el$134, "style", {
1489
+ _$insertNode(_el$136, _el$137);
1490
+ _$effect((_$p) => _$setProp(_el$136, "style", {
1441
1491
  fg: theme().accent
1442
1492
  }, _$p));
1443
- return _el$134;
1493
+ return _el$136;
1444
1494
  })(), (() => {
1445
- var _el$137 = _$createElement("span");
1446
- _$insert(_el$137, () => String(goal().config.artifactDir).replace(String(props.directory), "."));
1447
- _$effect((_$p) => _$setProp(_el$137, "style", {
1495
+ var _el$139 = _$createElement("span");
1496
+ _$insert(_el$139, () => String(goal().config.artifactDir).replace(String(props.directory), "."));
1497
+ _$effect((_$p) => _$setProp(_el$139, "style", {
1448
1498
  fg: theme().textMuted
1449
1499
  }, _$p));
1450
- return _el$137;
1500
+ return _el$139;
1451
1501
  })()];
1452
1502
  })(), null);
1453
1503
  _$insert(_el$109, (() => {
1454
1504
  var _c$1 = _$memo(() => (goal().config.checks?.length ?? 0) > 0);
1455
1505
  return () => _c$1() ? [(() => {
1456
- var _el$138 = _$createElement("span"), _el$139 = _$createTextNode(`
1506
+ var _el$140 = _$createElement("span"), _el$141 = _$createTextNode(`
1457
1507
  \u25A3 checks: `);
1458
- _$insertNode(_el$138, _el$139);
1459
- _$effect((_$p) => _$setProp(_el$138, "style", {
1508
+ _$insertNode(_el$140, _el$141);
1509
+ _$effect((_$p) => _$setProp(_el$140, "style", {
1460
1510
  fg: theme().warning
1461
1511
  }, _$p));
1462
- return _el$138;
1512
+ return _el$140;
1463
1513
  })(), (() => {
1464
- var _el$141 = _$createElement("span");
1465
- _$insert(_el$141, () => goal().config.checks.join(", ").slice(0, 100));
1466
- _$effect((_$p) => _$setProp(_el$141, "style", {
1514
+ var _el$143 = _$createElement("span");
1515
+ _$insert(_el$143, () => goal().config.checks.join(", ").slice(0, 100));
1516
+ _$effect((_$p) => _$setProp(_el$143, "style", {
1467
1517
  fg: theme().textMuted
1468
1518
  }, _$p));
1469
- return _el$141;
1519
+ return _el$143;
1470
1520
  })()] : null;
1471
1521
  })(), null);
1472
1522
  _$insert(_el$109, (() => {
1473
1523
  var _c$10 = _$memo(() => !!rt()?.lastError);
1474
1524
  return () => _c$10() && [(() => {
1475
- var _el$142 = _$createElement("span"), _el$143 = _$createTextNode(`
1525
+ var _el$144 = _$createElement("span"), _el$145 = _$createTextNode(`
1476
1526
  \u26A0 `);
1477
- _$insertNode(_el$142, _el$143);
1478
- _$effect((_$p) => _$setProp(_el$142, "style", {
1527
+ _$insertNode(_el$144, _el$145);
1528
+ _$effect((_$p) => _$setProp(_el$144, "style", {
1479
1529
  fg: theme().error
1480
1530
  }, _$p));
1481
- return _el$142;
1531
+ return _el$144;
1482
1532
  })(), (() => {
1483
- var _el$145 = _$createElement("span");
1484
- _$insert(_el$145, () => rt().lastError.slice(0, 120));
1485
- _$effect((_$p) => _$setProp(_el$145, "style", {
1533
+ var _el$147 = _$createElement("span");
1534
+ _$insert(_el$147, () => rt().lastError.slice(0, 120));
1535
+ _$effect((_$p) => _$setProp(_el$147, "style", {
1486
1536
  fg: theme().error
1487
1537
  }, _$p));
1488
- return _el$145;
1538
+ return _el$147;
1489
1539
  })()];
1490
1540
  })(), null);
1491
1541
  _$effect((_p$) => {
@@ -1535,29 +1585,29 @@ function LoopDashboard(props) {
1535
1585
  },
1536
1586
  children: (ev) => [`
1537
1587
  `, (() => {
1538
- var _el$146 = _$createElement("span");
1539
- _$insert(_el$146, () => String(ev.type));
1540
- _$effect((_$p) => _$setProp(_el$146, "style", {
1588
+ var _el$148 = _$createElement("span");
1589
+ _$insert(_el$148, () => String(ev.type));
1590
+ _$effect((_$p) => _$setProp(_el$148, "style", {
1541
1591
  fg: eventColor(String(ev.type), theme()),
1542
1592
  bold: true
1543
1593
  }, _$p));
1544
- return _el$146;
1594
+ return _el$148;
1545
1595
  })(), (() => {
1546
- var _el$147 = _$createElement("span"), _el$148 = _$createTextNode(` `);
1547
- _$insertNode(_el$147, _el$148);
1548
- _$insert(_el$147, () => ev.goalID?.slice(0, 8), null);
1549
- _$effect((_$p) => _$setProp(_el$147, "style", {
1596
+ var _el$149 = _$createElement("span"), _el$150 = _$createTextNode(` `);
1597
+ _$insertNode(_el$149, _el$150);
1598
+ _$insert(_el$149, () => ev.goalID?.slice(0, 8), null);
1599
+ _$effect((_$p) => _$setProp(_el$149, "style", {
1550
1600
  fg: theme().textMuted
1551
1601
  }, _$p));
1552
- return _el$147;
1602
+ return _el$149;
1553
1603
  })(), _$memo(() => _$memo(() => !!ev.summary)() && (() => {
1554
- var _el$149 = _$createElement("span"), _el$150 = _$createTextNode(` \u2014 `);
1555
- _$insertNode(_el$149, _el$150);
1556
- _$insert(_el$149, () => String(ev.summary).slice(0, 60), null);
1557
- _$effect((_$p) => _$setProp(_el$149, "style", {
1604
+ var _el$151 = _$createElement("span"), _el$152 = _$createTextNode(` \u2014 `);
1605
+ _$insertNode(_el$151, _el$152);
1606
+ _$insert(_el$151, () => String(ev.summary).slice(0, 60), null);
1607
+ _$effect((_$p) => _$setProp(_el$151, "style", {
1558
1608
  fg: theme().text
1559
1609
  }, _$p));
1560
- return _el$149;
1610
+ return _el$151;
1561
1611
  })())]
1562
1612
  }), null);
1563
1613
  _$effect((_p$) => {
@@ -1726,7 +1776,7 @@ var tui = async (api) => {
1726
1776
  run: open
1727
1777
  }],
1728
1778
  bindings: [{
1729
- key: "<leader>d",
1779
+ key: "<leader>o",
1730
1780
  cmd: "opencode.loopd.dashboard",
1731
1781
  desc: "Open loop dashboard"
1732
1782
  }]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-loopd",
4
- "version": "1.5.2",
4
+ "version": "1.6.0",
5
5
  "description": "Codex-inspired background goal engine for OpenCode — autonomous subagents, engine-driven loop, child worker sessions and modal TUI dashboard. Like Claude Code loop for OpenCode.",
6
6
  "type": "module",
7
7
  "license": "AGPL-3.0-only",
@@ -31,27 +31,59 @@ Do not use loopd when:
31
31
  ```
32
32
  created → active → complete
33
33
  → blocked (needs user intervention)
34
+ → budget_limited / usage_limited (engine limits)
34
35
  paused (user-initiated, can resume)
36
+ retry (blocked → active, clears failures)
35
37
  ```
36
38
 
39
+ Only `active` goals own live worker runs. `blocked`/`paused`/`budget_limited` wait for an explicit owner transition (`resume`/`retry`/`nudge`). The loop is engine-driven, not parent-driven.
40
+
41
+ ## Contract & Evaluation Semantics
42
+
43
+ Every goal has an **immutable contract** at creation — the source of truth for completion:
44
+
45
+ * **Objective** — semantic requirements (free text, self-contained). The worker derives concrete requirements from it.
46
+ * **Checks** — deterministic shell commands that **must pass** for `complete_goal` to be accepted. For `workspaceWrite:true` goals they are **mandatory** (explicit `checks` or plugin `defaultChecks`), and they run from `checkCwd` (writers default to project root; artifact-only jobs run from their `artifactDir`).
47
+ * **Agent** — which subagent model runs the worker (`sloppy-agent`, `smart-agent`, …). Required unless `defaultAgent` is configured in `opencode.jsonc` plugin options.
48
+ * **WorkspaceWrite** — `true` (default) = may touch the shared repo; **only one active writer at a time** is allowed (enforced on `start`/`resume`/`retry` with rollback). Set `false` explicitly for artifact-only/read-only work to allow concurrency.
49
+ * **Limits** — `maxTurns` (default 50), `maxNoProgress`, `maxFailures`, `maxEvaluatorRejections` (default 3), `timeoutMs`, `compactEvery`, `progressFile`.
50
+
51
+ **Who decides completion:**
52
+ * **Host is the acceptance authority** — it runs `checks` deterministically. If any check fails, `complete_goal` is **rejected** (`ok:false`, `rejectionCount++`, `freeRetryPending=true` for <3 rejections, `blocked` after 3). The worker gets the exact failure in the next steering.
53
+ * **Model is the proposer** — it must self-audit via `## COMPLETION REVIEW` (derive requirements → locate evidence → judge `proves|contradicts|incomplete|missing`) and only call `complete_goal` when every requirement is proved. If objective and checks conflict, it must `block_goal`, not silently violate either.
54
+
55
+ **Hardened loop guarantees (1da88dc):**
56
+ * **Prompt correlation** — each turn’s prompt gets a `msg-` UUID (`activePromptMessageID`); the engine correlates `message.updated`/`message.part.updated` by that ID and by `runGeneration`. Stale events never release a newer lease.
57
+ * **Generation fencing** — `idleCandidateAt` is tied to `idleCandidateGeneration`; a new prompt clears the candidate. Finalization also requires the transcript anchor: the latest user prompt must be the engine’s prompt **and** its assistant response must be completed.
58
+ * **Maintenance (30s)** — auto-repairs `phase=idle + activeRunID` (stale lease), bounds `unknown` status polls (`unknownStatusCount` threshold 3 → one `notifyOwner` per episode, `workerUnreachableNotifiedAt` deduped), and recovers counters on success. `active`-only polling; `blocked`/`paused` never auto-continue.
59
+ * **Per-goal mutex** — `withGoalOperation(goalID)` serializes `continueTurn`/`pause`/`resume`/`retry`/`clear`/`nudge` per goal, and `turn.acquire` re-checks `leaseIsValid` inside the transaction for exclusivity.
60
+ * **Lease** — `acquireLease`/`releaseLease` + `timeoutMs` (default 5 min); prompt failures release the lease and schedule `waiting_retry` with exponential backoff.
61
+
37
62
  ## Creating a Goal
38
63
 
39
- Use `loopd_create_goal` after clarifying the objective with the user:
64
+ Use `loopd_create_goal` **after** clarifying the objective with the user (what / where / how to verify). The tool validates the contract before spawning:
40
65
 
41
66
  ```
42
67
  loopd_create_goal({
43
68
  name: "short-name",
44
69
  objective: "Detailed description of what the goal should accomplish.",
45
- checks: ["npm test"], // optional: shell commands for completion verification
46
- progressFile: ".opencode/loopd/progress.md", // optional: worker reads/writes this
47
- maxTurns: 50, // optional: safety budget
48
- maxNoProgress: 5, // optional: auto-block without progress
49
- maxFailures: 3, // optional: auto-block on failures
50
- compactEvery: 3, // optional: compact worker session every N turns
70
+ agent: "smart-agent", // required unless plugin defaultAgent is configured
71
+ checks: ["npm test"], // mandatory if workspaceWrite:true (or configure defaultChecks)
72
+ checkCwd: "/project/root", // optional; writers default to project root, readers to artifactDir
73
+ workspaceWrite: true, // default true; set false explicitly for artifact-only/read-only
74
+ progressFile: ".opencode/loopd/progress.md", // optional; defaults to <artifactDir>/progress.md
75
+ maxTurns: 50, // optional; ≥50 enforces FINAL REPORT REQUIRED
76
+ maxNoProgress: 5, // optional; auto-block without progress
77
+ maxFailures: 3, // optional; auto-block on failures
78
+ maxEvaluatorRejections: 3, // optional; block after N check failures (default 3)
79
+ compactEvery: 3, // optional; compact worker session every N turns
80
+ timeoutMs: 300000, // optional; per-turn lease
51
81
  })
52
82
  ```
53
83
 
54
- The goal starts immediately. The user can monitor it via `/loop` (<leader>d).
84
+ Returns `ok:true` with `goalID`, `workerSessionID`, `artifactDir`, `agent`, `checks`, `workspaceWrite`, `defaultsApplied:{agent,checks}`. On contract violation you get `ok:false` with `errorCode: "missing_agent"` or `"missing_checks"` or `"already active"` (writer serialization).
85
+
86
+ The goal starts immediately. The user can monitor it via `/loop` (<leader>d). Plugin options `defaultAgent` / `defaultChecks` in `opencode.jsonc` can supply defaults so callers don’t have to repeat them.
55
87
 
56
88
  ## Worker Tools (Running Inside the Goal)
57
89
 
@@ -111,23 +143,34 @@ block_goal({
111
143
 
112
144
  ## Owner Tools (Parent Chat)
113
145
 
114
- These tools are available in the parent chat that created the goal:
146
+ All are filtered by `ownerSessionID` (only the session that created the goal sees it). They are the **only** way to recover a stuck worker — they mutate the engine state (Entity B) and, when needed, force the bridge to the real subagent session (Entity A).
115
147
 
116
148
  ### list_background_goals
117
149
 
118
- Lists all active goals owned by this session. Shows status and progress.
150
+ Lists all `active` goals owned by this session (name, status, phase, turn, last progress, blocker).
119
151
 
120
152
  ### inspect_background_goal
121
153
 
122
- Shows detailed info: objective, config, progress, blocker, runtime state.
154
+ Shows detailed contract + runtime: `objective`, `config{agent,checks,checkCwd,workspaceWrite,limits,artifactDir}`, `lastProgress`, `completionEvidence`, `blocker`, `runtime{phase,runCount,budgetTurnCount,runGeneration,evaluatorRejectionCount,lastActivityAt,activePromptMessageID,unknownStatusCount}`.
123
155
 
124
156
  ### read_goal_transcript
125
157
 
126
- Reads the last N messages from the worker session. Useful for debugging what the worker is doing.
158
+ Reads the last N messages from the worker session (`role`, `content`, `timestamp`, `messageID`). Use to see if the worker’s steering contained `HOST VERDICT`.
127
159
 
128
160
  ### send_goal_input
129
161
 
130
- Sends a message to the worker's next turn. See table above.
162
+ Appends to the goal’s inbox file; the next `continueTurn` injects it as `## USER INSTRUCTIONS`. **Does not itself re-prompt** — the engine does on next idle/maintenance.
163
+
164
+ ### nudge_goal
165
+
166
+ Force re-prompts a stuck worker even if `sessionStatus` is not `idle`. Clears stale `activeRunID`/`idleCandidateAt`/`activePromptMessageID`/lease, sets `phase=idle`, and calls `continueTurn({force:true})`. Use when `unknownStatusCount` ≥3 or the worker is `running` with no activity. Returns `{ok, message}`.
167
+
168
+ ### pause_goal / resume_goal / retry / clear
169
+
170
+ * `pause_goal` — `active → paused`, `releaseLease`, `abortWorker`. Frees the writer slot.
171
+ * `resume_goal` — `paused → active`, reuses the existing worker session if `sessionStatus` is still `idle`/`busy` (preserves transcript), otherwise creates a new one. Fails with `already active` if another writer is active.
172
+ * `retry` (via `resume` on `blocked`) — `blocked → active`, resets `consecutiveFailures`/`forceFinishRequested`.
173
+ * `clear_goal` — aborts worker and removes `goal` + `runtime` + ledger entry (cannot be undone).
131
174
 
132
175
  ## Dashboard Commands
133
176
 
@@ -165,13 +208,16 @@ Open the dashboard with `/loop` or <leader>d.
165
208
 
166
209
  ## Safety Patterns
167
210
 
168
- 1. **Always call `get_goal` first** — Read the objective before doing any work.
169
- 2. **Progress after durable changes** — Call `report_goal_progress` after file writes or verifications, not after thinking.
170
- 3. **Complete with evidence** — Never call `complete_goal` without concrete proof (test output, file checks passing).
171
- 4. **Block for real blockers only** — Don't block for things you can figure out. Block when you genuinely need user input.
172
- 5. **Use checks for verification** — Set `checks` on goal creation to auto-verify completion (e.g., `["npm test", "test -f README.md"]`).
173
- 6. **Set safety budgets** — Use `maxTurns`, `maxNoProgress`, and `maxFailures` to prevent runaway goals.
174
- 7. **Compact periodically** — Use `compactEvery` to keep the worker session's context manageable.
211
+ 1. **Always call `get_goal` first** — Read the full contract (objective + checks + limits) before doing any work. The `COMPLETION REVIEW` and `HOST VERDICT` in steering are authoritative.
212
+ 2. **Progress after durable changes** — Call `report_goal_progress` after file writes or verifications (resets `consecutiveFailures`/`noProgressCount`), not after thinking.
213
+ 3. **Complete with evidence — host decides** — Never call `complete_goal` without concrete proof. The host will reject it if `checks` fail; you’ll get `Rejection #N` with exact `stderr` and a **free retry** (`budgetTurnCount` not charged for N<3). After 3 rejections the goal is `blocked`.
214
+ 4. **Block for real blockers only** — Dont block for things you can figure out. If objective and `checks` appear contradictory, `block_goal` — don’t silently violate either.
215
+ 5. **Use checks for verification — mandatory for writers** — `workspaceWrite:true` goals **require** `checks` (explicit or `defaultChecks`). Checks run from `checkCwd` (writers → project root by default). `["npm test","bun run typecheck"]` is a good default.
216
+ 6. **Serialize workspace edits** — Keep `workspaceWrite:true` (the default) for code/repo changes. The engine allows **only one active writer**; a second `start`/`resume`/`retry` fails with `already active`. Use `workspaceWrite:false` explicitly for artifact-only research to allow concurrency.
217
+ 7. **Set safety budgets** — `maxTurns` (default 50), `maxNoProgress`, `maxFailures` prevent runaways. On `maxTurns`/`maxNoProgress` the engine injects `FINAL REPORT REQUIRED`; if ignored, it `blocked (force-finish ignored)` after the next idle.
218
+ 8. **Compact periodically** — `compactEvery` keeps the worker session’s context manageable.
219
+ 9. **Recover stuck workers explicitly** — If `inspect` shows `phase=running` with `unknownStatusCount ≥3` or `lastActivityAt` far in the past, use `nudge_goal` (force re-prompt) or `pause`/`resume`. `send_goal_input` alone does not re-prompt.
220
+ 10. **One writer, one check suite** — Don’t run concurrent `start` calls that touch the same files from parallel chats; chain them sequentially or mark the second as `workspaceWrite:false`.
175
221
 
176
222
  ## Example: Creating and Monitoring a Goal
177
223
 
@@ -179,17 +225,24 @@ Open the dashboard with `/loop` or <leader>d.
179
225
  ```
180
226
  User: Write a README for this project and create a changelog.
181
227
 
182
- Agent: I'll set up a background goal for this.
228
+ Agent: I'll set up a background goal for this. What's the verification?
229
+
230
+ User: Just that both files exist.
231
+
232
+ Agent: Got it — creating with a contract.
183
233
 
184
234
  loopd_create_goal({
185
235
  name: "docs-write",
186
236
  objective: "Write a README.md covering: what it is, install, usage, architecture, and examples. Then create CHANGELOG.md with a v1.0.0 entry.",
237
+ agent: "smart-agent",
187
238
  checks: ["test -f README.md", "test -f CHANGELOG.md"],
239
+ checkCwd: "/Users/you/project", // explicit for writers; defaults to project root
240
+ workspaceWrite: true, // default true — serialized
188
241
  progressFile: ".opencode/loopd/docs-progress.md",
189
242
  maxTurns: 20
190
243
  })
191
-
192
- # Goal starts. User can monitor with /loop.
244
+ # → {ok:true, artifactDir: ".opencode/loopd/goals/<id>", defaultsApplied:{...}}
245
+ # Goal starts. User can monitor with /loop. If checks fail, the worker gets HOST VERDICT with exact stderr and a free retry.
193
246
  ```
194
247
 
195
248
  **Worker session (automatic):**