@alphacoder-v0/pi-loops 0.20.0 → 0.22.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 (48) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/CONTRACT.md +139 -0
  3. package/README.md +11 -7
  4. package/README.zh-CN.md +6 -4
  5. package/docs/cli.md +19 -1
  6. package/docs/downstream.md +60 -0
  7. package/docs/goal.md +4 -2
  8. package/docs/loops.md +55 -9
  9. package/docs/recipes.md +94 -18
  10. package/docs/session-archive.md +8 -3
  11. package/docs/web-ui-parity.md +11 -1
  12. package/package.json +5 -2
  13. package/recipes/_tracker/issue-tracker-local.md +3 -1
  14. package/recipes/autoresearch/experiment.md +30 -6
  15. package/recipes/autoresearch/recipe.toml +2 -0
  16. package/recipes/changelog-draft/recipe.toml +4 -0
  17. package/recipes/changelog-draft/release.md +8 -3
  18. package/recipes/ci-sweeper/recipe.toml +4 -0
  19. package/recipes/ci-sweeper/sweep.md +2 -1
  20. package/recipes/daily-digest/digest.md +5 -4
  21. package/recipes/daily-digest/recipe.toml +2 -0
  22. package/recipes/deps-sweeper/README.md +11 -0
  23. package/recipes/deps-sweeper/audit.md +99 -0
  24. package/recipes/deps-sweeper/recipe.toml +17 -0
  25. package/recipes/ecosystem/recipe.toml +3 -0
  26. package/recipes/ecosystem/scan.md +1 -1
  27. package/recipes/issue-loop/implement.md +17 -4
  28. package/recipes/issue-loop/labels.sh +18 -0
  29. package/recipes/issue-loop/recipe.toml +2 -0
  30. package/recipes/issue-loop/triage.md +16 -2
  31. package/recipes/pr-watch/recipe.toml +3 -0
  32. package/skills/pi-loops/SKILL.md +3 -2
  33. package/src/archive.ts +97 -2
  34. package/src/cli.ts +103 -16
  35. package/src/goal.ts +11 -0
  36. package/src/hooks.ts +25 -1
  37. package/src/host-runtime.ts +4 -8
  38. package/src/inbox.ts +118 -55
  39. package/src/job-signal.ts +78 -0
  40. package/src/pi-loops.ts +177 -109
  41. package/src/presence.ts +5 -0
  42. package/src/protocol.ts +45 -2
  43. package/src/recipe.ts +158 -2
  44. package/src/scheduler.ts +6 -2
  45. package/src/store.ts +8 -3
  46. package/src/tools.ts +17 -11
  47. package/src/triggers.ts +8 -3
  48. package/src/web.mjs +73 -7
package/src/pi-loops.ts CHANGED
@@ -15,16 +15,17 @@ import * as path from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
17
17
  import { ProjectTrustStore, VERSION as PI_VERSION, getAgentDir, readStoredCredential } from "@earendil-works/pi-coding-agent";
18
- import { ARCHIVE_EXT, defaultExportPath, exportSession, importSession } from "./archive.ts";
18
+ import { ARCHIVE_EXT, type ActivateMode, applyImport, automationOf, defaultExportPath, exportSession, importSession, offerToEnable, parseActivateMode } from "./archive.ts";
19
19
  import { Box, Text } from "@earendil-works/pi-tui";
20
20
  import { Type } from "typebox";
21
21
  import { parseAddArgs, parseSetArgs, splitCommand, tokenize } from "./args.ts";
22
22
  import { envFlag, loadConfig } from "./config.ts";
23
- import { GOAL_ENTRY, type GoalAction, type GoalState, MAX_CONTINUATIONS, applyDecision, branchMovedSince, continuationPrompt, evaluatorPrompt, latestGoal, newGoal, parseDecision, pauseFor, transcriptFromMessages } from "./goal.ts";
24
- import { withinProject } from "./presence.ts";
23
+ import { GOAL_ENTRY, type GoalAction, type GoalState, MAX_CONTINUATIONS, abortedTurn, applyDecision, branchMovedSince, continuationPrompt, evaluatorPrompt, latestGoal, newGoal, parseDecision, pauseFor, transcriptFromMessages } from "./goal.ts";
24
+ import { sameProject } from "./presence.ts";
25
25
  import { isExactlyTrusted, sessionTrustCovers } from "./trust.ts";
26
- import { HookRunner, type HookEventData, messageKind, messageSummary, resultSummary, truncateSummary } from "./hooks.ts";
26
+ import { HookRunner, type HookEventData, messageKind, messageSummary, resultSummary, runEndEvent, runStartEvent, truncateSummary } from "./hooks.ts";
27
27
  import { failingSummary } from "./job-health.ts";
28
+ import { QUIET_MARK_AFTER, SIGNAL_WINDOW_MS, loopSignal, signalSummary } from "./job-signal.ts";
28
29
  import { LoopsLog, pruneLogs } from "./log.ts";
29
30
  import { type InboxEntry, belongsToProject, inProject, resolveInboxRef } from "./inbox.ts";
30
31
  import { McpPool } from "./mcp-pool.ts";
@@ -38,7 +39,7 @@ import { createHash } from "node:crypto";
38
39
  import { computeDue, computeNext, formatLocal, formatSchedule, localOffset, parseSchedule, stamp } from "./schedule.ts";
39
40
  import { applyJobEdit } from "./job-edit.ts";
40
41
  import { asleepNote, runsIn } from "./job-owner.ts";
41
- import { AUTONOMY_LEVELS, type AutonomyLevel, INSTALL_ROOT, type Recipe, type UpdateResult, addLineFor, ensureExcluded, installDirFor, installFiles, installedRecipes, isRecipeName, listRecipes, MAX_SETUP_SHOWN, TRACKER_FILE, loadRecipe, packagedRecipesDir, parseAddWords, planInstall, playbookFiles, purgeInstall, readRecord, requireRecipeName, resolveRecipeRef, updateFiles } from "./recipe.ts";
42
+ import { AUTONOMY_LEVELS, type AutonomyLevel, INSTALL_ROOT, RECIPE_TIERS, type Recipe, type UpdateResult, addLineFor, ensureExcluded, installDirFor, installFiles, installedRecipes, isRecipeName, listRecipes, MAX_SETUP_SHOWN, TRACKER_FILE, loadRecipe, neverSection, packagedRecipesDir, parseAddWords, planInstall, playbookFiles, preflightChecks, purgeInstall, readRecord, renderPreflight, requireRecipeName, resolveRecipeRef, runPreflight, updateFiles } from "./recipe.ts";
42
43
  import { LoopScheduler, type SessionSnapshot } from "./scheduler.ts";
43
44
  import { MAX_PROMPT_BYTES, type LoopJob, type RunRecord, defaultLoopsDir, newId, resolveJobRef, sessionExists } from "./store.ts";
44
45
  import { parentRuntimeFlags, type SubagentRequest } from "./runner.ts";
@@ -191,9 +192,7 @@ export default function piLoops(pi: ExtensionAPI) {
191
192
  onRunStart: (job, runId) => {
192
193
  auditCronStart(triggers.store, job, runId);
193
194
  refreshBadge();
194
- // The same two events the headless host fires. Whether a run happens here or there is
195
- // an accident of who held the clock, and a hook rule should not be able to tell.
196
- fireRunHook({ event: "run_start", run_job: job.name ?? job.id, run_id: runId, message_summary: truncateSummary(`${job.name ?? job.id}: ${job.prompt}`) });
195
+ fireRunHook(runStartEvent(job, runId));
197
196
  },
198
197
  onCatchUp: (job, dueAt) => {
199
198
  if (lastCtx?.hasUI && sameProject(job.cwd, session.cwd)) lastCtx.ui.notify(`cron ${job.name ?? job.id}: catching up the run missed at ${formatLocal(dueAt)}`, "info");
@@ -201,16 +200,7 @@ export default function piLoops(pi: ExtensionAPI) {
201
200
  onRunFinished: ({ job, record, findings, result }) => {
202
201
  auditCronFinish(triggers.store, job, record, result.stopReason === "aborted");
203
202
  refreshBadge();
204
- fireRunHook({
205
- event: "run_end",
206
- run_job: job.name ?? job.id,
207
- run_id: record.runId,
208
- run_ok: record.ok,
209
- run_findings: record.findings,
210
- run_error: record.error ? redact(record.error) : null,
211
- run_cost_usd: record.usage?.cost ?? null,
212
- message_summary: truncateSummary(record.ok ? `${job.name ?? job.id}: ok · ${record.findings} finding(s)` : `${job.name ?? job.id}: failed: ${record.error ?? "unknown error"}`),
213
- });
203
+ fireRunHook(runEndEvent(job, record));
214
204
  if (!lastCtx?.hasUI) return;
215
205
  // Another project's findings and prompt previews do not belong in this transcript; the
216
206
  // audit sink below and the inbox already carry them to where they do.
@@ -255,7 +245,7 @@ export default function piLoops(pi: ExtensionAPI) {
255
245
  // message into the parent session). Only into a chat that belongs to the rule's project;
256
246
  // a different project's chat gets nothing — the finding goes to the inbox instead.
257
247
  if (trigger.cwd && !sameProject(trigger.cwd, session.cwd)) {
258
- await scheduler.inbox.append({ source: `trigger:${trigger.sourceLabel}`, text: content.replace(/^\[Trigger [^\]]+\]\s*/, ""), runId: trigger.traceId, jobId: trigger.sourceLabel, cwd: trigger.cwd });
248
+ await scheduler.inbox.append({ source: `trigger:${trigger.sourceLabel}`, text: content.replace(/^\[Trigger [^\]]+\]\s*/, ""), run_id: trigger.traceId, job_id: trigger.sourceLabel, cwd: trigger.cwd });
259
249
  refreshBadge();
260
250
  return "inbox";
261
251
  }
@@ -267,7 +257,7 @@ export default function piLoops(pi: ExtensionAPI) {
267
257
  },
268
258
  onInjectAndRun: async (prompt, trigger) => {
269
259
  if (trigger.cwd && !sameProject(trigger.cwd, session.cwd)) {
270
- await scheduler.inbox.append({ source: `trigger:${trigger.sourceLabel}`, text: prompt.replace(/^\[Trigger [^\]]+\]\s*/, ""), runId: trigger.traceId, jobId: trigger.sourceLabel, cwd: trigger.cwd });
260
+ await scheduler.inbox.append({ source: `trigger:${trigger.sourceLabel}`, text: prompt.replace(/^\[Trigger [^\]]+\]\s*/, ""), run_id: trigger.traceId, job_id: trigger.sourceLabel, cwd: trigger.cwd });
271
261
  refreshBadge();
272
262
  return "inbox";
273
263
  }
@@ -661,7 +651,8 @@ export default function piLoops(pi: ExtensionAPI) {
661
651
  if (!lastCtx?.hasUI) return;
662
652
  const parts: string[] = [];
663
653
  const n = scheduler.inbox.newCount();
664
- if (n > 0) parts.push(`Inbox: ${n} new`);
654
+ const decisions = scheduler.inbox.decisionCount();
655
+ if (n > 0) parts.push(`Inbox: ${n} new${decisions ? ` (${decisions} decision${decisions === 1 ? "" : "s"})` : ""}`);
665
656
  // Machine-wide like the inbox count and the running list above it: the clock is one per host,
666
657
  // and a loop failing in another checkout is still this machine's automation going quiet.
667
658
  const failing = failingSummary(scheduler.store.load());
@@ -731,6 +722,10 @@ export default function piLoops(pi: ExtensionAPI) {
731
722
 
732
723
  function jobLines(jobs: LoopJob[]): string[] {
733
724
  const now = Date.now();
725
+ // Read once for the whole listing: the run log and the inbox are each one file, and a
726
+ // project with a dozen loops would otherwise parse them a dozen times per `/cron`.
727
+ const allRuns = jobs.some((j) => j.stateful) ? scheduler.store.allRuns() : [];
728
+ const allInbox = jobs.some((j) => j.stateful) ? scheduler.inbox.list() : [];
734
729
  return jobs.map((job, i) => {
735
730
  // A next run is shown where it will happen: a plain job of a session not open here has none.
736
731
  const next = job.enabled && runsIn(job, session.sessionId)
@@ -746,12 +741,18 @@ export default function piLoops(pi: ExtensionAPI) {
746
741
  const asleepMark = asleepNote(job, session.sessionId);
747
742
  const dormant = asleepMark ? `[${asleepMark}]` : undefined;
748
743
  const orphan = job.stateful && !fs.existsSync(job.cwd) ? "[orphan: cwd missing]" : undefined;
749
- const marks = [job.stateful ? "[stateful]" : undefined, job.verify ? "[verify]" : undefined, dormant, orphan, job.running ? `running ${job.running.runId}` : undefined, job.catchUp ? undefined : "[no-catchup]"]
744
+ // What the loop's runs came to (src/job-signal.ts): `quiet ×N` in the marks once N empty
745
+ // runs are the newest, and the findings line — filed, claimed, dismissed — over 30 days.
746
+ // A loop was, until now, judged by whether it ran; this is whether it was worth running.
747
+ const signal = job.stateful ? loopSignal(job.id, allRuns, allInbox, now - SIGNAL_WINDOW_MS) : undefined;
748
+ const quiet = signal && signal.quiet >= QUIET_MARK_AFTER ? `[quiet ×${signal.quiet}]` : undefined;
749
+ const marks = [job.stateful ? "[stateful]" : undefined, job.verify ? "[verify]" : undefined, dormant, orphan, quiet, job.running ? `running ${job.running.runId}` : undefined, job.catchUp ? undefined : "[no-catchup]"]
750
750
  .filter(Boolean)
751
751
  .join(" ");
752
752
  const head = `${String(i + 1).padStart(2)}. ${job.id}${job.name ? ` "${job.name}"` : ""} ${job.enabled ? "enabled" : "disabled"} ${formatSchedule(job.schedule)}${marks ? ` ${marks}` : ""}`;
753
753
  const action = ` action: ${previewRedacted(job.prompt, 120)}`;
754
- const meta = ` next ${next ? formatLocal(next) : "—"} · runs ${job.runCount}${job.skippedOverlap ? ` · overlap skips ${job.skippedOverlap}` : ""} · ${homeRel(job.cwd)}`;
754
+ const worth = signal ? signalSummary(signal) : undefined;
755
+ const meta = ` next ${next ? formatLocal(next) : "—"} · runs ${job.runCount}${job.skippedOverlap ? ` · overlap skips ${job.skippedOverlap}` : ""}${worth ? ` · 30d: ${worth}` : ""} · ${homeRel(job.cwd)}`;
755
756
  const err = job.lastError ? ` last error: ${previewRedacted(job.lastError, 100)}` : job.lastFiredAt ? ` last fired: ${job.lastFiredAt}` : undefined;
756
757
  return [head, action, meta, err].filter((l): l is string => !!l);
757
758
  }).flat();
@@ -810,7 +811,7 @@ export default function piLoops(pi: ExtensionAPI) {
810
811
  "/cron host [start|stop] the headless host that keeps the clock after the last pi quits; start = hand off on quit even with [host] auto = false",
811
812
  "/inbox triage findings from stateful jobs (/inbox help)",
812
813
  `/session-export [path] [--exclude-triggers] transcript + this project's cron jobs, trigger rules and loop state as one ${ARCHIVE_EXT} archive`,
813
- "/session-import <path> [--activate-triggers=on|off] [--cwd <dir>] [--resume] restore it here (automation stays off unless activated)",
814
+ "/session-import <path> [--activate-triggers=off|ask|on] [--cwd <dir>] [--resume] restore it here (automation stays off unless activated)",
814
815
  ];
815
816
 
816
817
  const cronCompletions = (prefix: string) => {
@@ -825,7 +826,7 @@ export default function piLoops(pi: ExtensionAPI) {
825
826
  // Numbers refer to the project-scoped list (/loop); ids and names resolve machine-wide.
826
827
  const pick = (ref: string): LoopJob | undefined => {
827
828
  const all = jobs();
828
- const job = /^\d+$/.test(ref.trim()) ? resolveJobRef(all.filter((j) => sameProject(j.cwd, session.cwd)), ref) : resolveJobRef(all, ref);
829
+ const job = /^\d+$/.test(ref.trim()) ? resolveJobRef(all.filter((j) => sameProject(j.cwd, session.cwd)), ref, { ordinals: true }) : resolveJobRef(all, ref);
829
830
  if (!job) ctx.ui.notify(ref ? `no cron job with id '${ref}'` : `usage: /cron ${sub} <id>`, "warning");
830
831
  return job;
831
832
  };
@@ -1025,10 +1026,19 @@ export default function piLoops(pi: ExtensionAPI) {
1025
1026
  const byJob = [...spend.byJob.entries()].sort((a, b) => b[1].cost - a[1].cost);
1026
1027
  const budget = scheduler.budgetState();
1027
1028
  const label = window === "all" ? "since the run log was last rotated" : window === "today" ? "today" : `the last ${window}`;
1029
+ // The price beside what it bought: a loop's findings in the same window, and what a
1030
+ // person did with them. `$0.410 28 run(s) pr-watch — 6 findings · 6 dismissed` is
1031
+ // a line that answers itself.
1032
+ const runsInLog = scheduler.store.allRuns();
1033
+ const inboxAll = scheduler.inbox.list();
1034
+ const worthOf = (id: string) => signalSummary(loopSignal(id, runsInLog, inboxAll, since));
1028
1035
  show(ctx, `Automation cost ${label}: $${spend.total.toFixed(3)} over ${spend.runs} run(s)`, [
1029
1036
  ...(spend.rotated > 0 ? [` including $${spend.rotated.toFixed(3)} from runs the log has already rotated away`] : []),
1030
1037
  ...(budget.cap > 0 ? [` today's budget: $${budget.spent.toFixed(2)} of $${budget.cap.toFixed(2)}${budget.over ? " — dispatching is paused" : ""}`] : [" no budget cap set ([limits] daily_budget_usd)"]),
1031
- ...byJob.slice(0, 12).map(([id, e]) => ` $${e.cost.toFixed(3)} ${e.runs} run(s) ${e.name ?? id}`),
1038
+ ...byJob.slice(0, 12).map(([id, e]) => {
1039
+ const worth = worthOf(id);
1040
+ return ` $${e.cost.toFixed(3)} ${e.runs} run(s) ${e.name ?? id}${worth ? ` — ${worth}` : ""}`;
1041
+ }),
1032
1042
  ...(byJob.length > 12 ? [` (+${byJob.length - 12} more)`] : []),
1033
1043
  ...(spend.runs ? [] : [" nothing has run in this window"]),
1034
1044
  ]);
@@ -1196,13 +1206,14 @@ export default function piLoops(pi: ExtensionAPI) {
1196
1206
  pi.registerCommand("crontab", { description: "Alias of /cron", getArgumentCompletions: cronCompletions, handler: cronHandler });
1197
1207
  pi.registerCommand("loop", { description: "Alias of /cron", getArgumentCompletions: cronCompletions, handler: cronHandler });
1198
1208
 
1199
- const INBOX_USAGE = "[list|all|claim <n>|dismiss <n>|clear|help] [--all]";
1209
+ const INBOX_USAGE = "[list|all|claim <n>|dismiss <n> [reason]|clear|help] [--all]";
1200
1210
 
1201
1211
  const INBOX_HELP = [
1202
1212
  "/inbox list new findings of this project /inbox --all every project on this machine",
1203
1213
  "/inbox all [--all] include claimed/dismissed history",
1204
1214
  "/inbox claim <n|id> mark claimed and hand it to the agent as a real turn",
1205
- "/inbox dismiss <n|id> mark dismissed /inbox clear [--all] dismiss the ones listed",
1215
+ "/inbox dismiss <n|id> [reason] mark dismissed; a reason is shown to the loop's next run",
1216
+ "/inbox clear [--all] dismiss the ones listed",
1206
1217
  ];
1207
1218
 
1208
1219
  /** The project a finding came from, as `/cron` shows a job's: the directory name is enough to tell them apart. */
@@ -1215,18 +1226,23 @@ export default function piLoops(pi: ExtensionAPI) {
1215
1226
  // time pi-loops puts on a screen. It used to be the stored string with the `Z` sliced
1216
1227
  // off — a UTC time wearing the shape of a local one, eight hours from the `next` on the
1217
1228
  // `/cron` line above it, with nothing on either to say which was which.
1218
- const when = formatLocal(Date.parse(e.createdAt));
1219
- const mark = e.verified ? "✓ " : "";
1229
+ const when = formatLocal(Date.parse(e.created_at));
1230
+ // ⚑ is a checkpoint — a decision the finding asks of a person; ✓ is the checker's mark.
1231
+ const mark = `${e.kind === "checkpoint" ? "⚑ " : ""}${e.verified ? "✓ " : ""}`;
1220
1232
  // The project comes first of the three: with loops running in several checkouts it is what
1221
1233
  // decides whether a finding is this morning's problem, and claiming runs it in that cwd.
1222
- if (!numbered) return ` [${e.status}] ${mark}${redact(e.text)} (${projectOf(e.cwd)}, ${e.source})`;
1234
+ const why = e.dismiss_reason ? ` — dismissed: ${redact(e.dismiss_reason)}` : "";
1235
+ if (!numbered) return ` [${e.status}] ${mark}${redact(e.text)}${why} (${projectOf(e.cwd)}, ${e.source})`;
1223
1236
  return ` ${i + 1}. [${e.id.slice(0, 12)}] ${mark}${redact(e.text)} (${projectOf(e.cwd)}, ${e.source}, ${when})`;
1224
1237
  });
1225
1238
  }
1226
1239
 
1227
1240
  function claimPrompt(e: InboxEntry): string {
1228
- const verified = e.verified ? `\n(An independent checker reviewed and kept this finding${e.verifiedReason ? `: ${e.verifiedReason}` : ""}.)` : "";
1229
- return `A recurring loop (${e.source}, running in ${e.cwd}) reported this finding — investigate and address it:\n${e.text}${verified}`;
1241
+ const verified = e.verified ? `\n(An independent checker reviewed and kept this finding${e.verified_reason ? `: ${e.verified_reason}` : ""}.)` : "";
1242
+ // A checkpoint is a decision the loop prepared and left to a person; the claim is that
1243
+ // person's yes, so the turn carries the decision out rather than re-investigating it.
1244
+ const checkpoint = e.kind === "checkpoint" ? "\nThis finding is a checkpoint: claiming it is the person's approval of the decision it recommends — carry that decision out, and stop where the finding says a person acts." : "";
1245
+ return `A recurring loop (${e.source}, running in ${e.cwd}) reported this finding — investigate and address it:\n${e.text}${verified}${checkpoint}`;
1230
1246
  }
1231
1247
 
1232
1248
  /* ------------------------------------------------------------------ /goal */
@@ -1457,7 +1473,8 @@ export default function piLoops(pi: ExtensionAPI) {
1457
1473
  const elsewhere = all.length - entries.length;
1458
1474
  if (elsewhere) lines.push(`+ ${elsewhere} finding${elsewhere === 1 ? "" : "s"} in other projects — /inbox --all`);
1459
1475
  lines.push("claim with /inbox claim <n>, dismiss with /inbox dismiss <n>");
1460
- show(ctx, `Inbox (${where}, ${entries.length} new, times ${localOffset(Date.now())}):`, lines);
1476
+ const decisions = entries.filter((e) => e.kind === "checkpoint").length;
1477
+ show(ctx, `Inbox (${where}, ${entries.length} new${decisions ? `, ${decisions} need${decisions === 1 ? "s" : ""} a decision` : ""}, times ${localOffset(Date.now())}):`, lines);
1461
1478
  return;
1462
1479
  }
1463
1480
  case "all": {
@@ -1470,19 +1487,45 @@ export default function piLoops(pi: ExtensionAPI) {
1470
1487
  return;
1471
1488
  case "claim":
1472
1489
  case "dismiss": {
1490
+ // `/inbox dismiss 3 that file is generated`: the first word names the finding, the rest is
1491
+ // why, and the reason goes to the loop that reported it, in its next run's prompt. A
1492
+ // claim takes the whole argument as the ref, as it always has. For a dismiss, `--all`
1493
+ // only counts before the reason starts (`dismiss --all 3 …`, `dismiss 3 --all …`): a
1494
+ // reason that mentions `--all` in passing must not re-number the list the person is
1495
+ // looking at and dismiss another project's finding.
1496
+ let target = ref;
1497
+ let reason = "";
1498
+ let machineWide = everywhere;
1499
+ if (sub === "dismiss") {
1500
+ const words = rest.split(/\s+/).filter(Boolean);
1501
+ machineWide = words[0] === "--all";
1502
+ if (machineWide) words.shift();
1503
+ target = words.shift() ?? "";
1504
+ if (words[0] === "--all") {
1505
+ machineWide = true;
1506
+ words.shift();
1507
+ }
1508
+ reason = words.join(" ");
1509
+ }
1473
1510
  // Numbers are the numbers on screen — this project's list, as `/cron` does it; an id
1474
1511
  // or a prefix still resolves machine-wide, so a finding can be claimed from anywhere.
1475
- const entries = /^\d+$/.test(ref) ? scoped(scheduler.inbox.listNew()) : scheduler.inbox.listNew();
1476
- const entry = resolveInboxRef(entries, ref);
1512
+ const all = scheduler.inbox.listNew();
1513
+ const entries = /^\d+$/.test(target) && !machineWide ? inProject(all, session.cwd, sameProject) : all;
1514
+ const entry = resolveInboxRef(entries, target, { ordinals: true });
1477
1515
  if (!entry) {
1478
- const n = Number(ref);
1479
- ctx.ui.notify(!ref ? "usage: /inbox claim|dismiss <n or inb-id>" : Number.isInteger(n) ? `no inbox entry #${n} in ${where} (have ${entries.length}; /inbox --all lists every project)` : `no new inbox entry matching '${ref}'`, "warning");
1516
+ const n = Number(target);
1517
+ ctx.ui.notify(!target ? "usage: /inbox claim <n or inb-id> | /inbox dismiss <n or inb-id> [reason]" : Number.isInteger(n) ? `no inbox entry #${n} in ${where} (have ${entries.length}; /inbox --all lists every project)` : `no new inbox entry matching '${target}'`, "warning");
1480
1518
  return;
1481
1519
  }
1482
- await scheduler.inbox.setStatus(entry.id, sub === "claim" ? "claimed" : "dismissed", sub === "claim" ? session.sessionId : undefined);
1520
+ await scheduler.inbox.setStatus(entry.id, sub === "claim" ? "claimed" : "dismissed", sub === "claim" ? session.sessionId : undefined, reason || undefined);
1483
1521
  refreshBadge();
1484
1522
  if (sub === "dismiss") {
1485
- ctx.ui.notify(`dismissed: ${previewRedacted(entry.text, 80)}`, "info");
1523
+ const loop = scheduler.store.load().find((j) => j.id === entry.job_id);
1524
+ // A trigger's finding carries its source label where a loop's carries a job id; a
1525
+ // trigger has no notes and no next run to tell.
1526
+ const gone = entry.job_id?.startsWith("cron-") ? "the loop that reported it is gone, so nobody is told" : "a trigger reported it, and a trigger keeps no notes, so nobody is told";
1527
+ const told = reason ? ` — ${loop ? `${loop.name ?? loop.id} is told why on its next run` : gone}` : "";
1528
+ ctx.ui.notify(`dismissed: ${previewRedacted(entry.text, 80)}${told}`, "info");
1486
1529
  return;
1487
1530
  }
1488
1531
  pi.sendUserMessage(claimPrompt(entry), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
@@ -1553,7 +1596,7 @@ export default function piLoops(pi: ExtensionAPI) {
1553
1596
  const store = triggers.store;
1554
1597
  const pickRule = (ref: string) => {
1555
1598
  const all = store.load();
1556
- const rule = /^\d+$/.test(ref.trim()) ? resolveRuleRef(all.filter((r) => sameProject(r.cwd, session.cwd)), ref) : resolveRuleRef(all, ref);
1599
+ const rule = /^\d+$/.test(ref.trim()) ? resolveRuleRef(all.filter((r) => sameProject(r.cwd, session.cwd)), ref, { ordinals: true }) : resolveRuleRef(all, ref);
1557
1600
  if (!rule) ctx.ui.notify(ref ? `no dynamic trigger rule with id '${ref}'` : `usage: /triggers ${sub} <id>${sub === "remove" || sub === "rm" || sub === "delete" ? "|--all" : ""}`, "warning");
1558
1601
  return rule;
1559
1602
  };
@@ -1841,7 +1884,7 @@ export default function piLoops(pi: ExtensionAPI) {
1841
1884
 
1842
1885
  const RECIPE_HELP = [
1843
1886
  "/recipe the packaged recipes, and which of them are installed here",
1844
- "/recipe show <name|path> what one would install: playbooks, jobs, levels, setup script",
1887
+ "/recipe show <name|path> what one would install: playbooks, jobs, levels, what the runs may never do, setup script",
1845
1888
  "/recipe add <name|path> install it in this project: copies the playbooks to .agents/skills/<name>/,",
1846
1889
  " runs the setup script after showing it, creates the jobs. --level report|propose|act skips the question",
1847
1890
  "/recipe update <name> merge the packaged playbooks into the installed copies; your edits are kept",
@@ -1858,6 +1901,44 @@ export default function piLoops(pi: ExtensionAPI) {
1858
1901
  act: "also promote, close and merge — the terminal states",
1859
1902
  };
1860
1903
 
1904
+ /**
1905
+ * What the playbooks forbid, as `/recipe show` and the install confirmation print it: one
1906
+ * block per playbook that has a `## Never` section. It is the safety envelope of an unattended
1907
+ * run, and the one part of a playbook worth reading before saying yes to the install.
1908
+ */
1909
+ /**
1910
+ * What the project has and lacks for this recipe's runs, checked now rather than found out by
1911
+ * the first run at three in the morning. Reported, never enforced: the install goes through
1912
+ * whatever the lines say, because a missing `gh` login is fixed in a minute and a job that is
1913
+ * not there is not.
1914
+ */
1915
+ async function preflightLines(recipe: Recipe, project: string, level: AutonomyLevel): Promise<string[]> {
1916
+ let tracker: string | undefined;
1917
+ try {
1918
+ tracker = fs.readFileSync(path.join(project, TRACKER_FILE), "utf8");
1919
+ } catch {
1920
+ tracker = undefined;
1921
+ }
1922
+ return renderPreflight(await runPreflight(preflightChecks(recipe.manifest, level, tracker), project));
1923
+ }
1924
+
1925
+ function neverLines(recipe: Recipe): string[] {
1926
+ const out: string[] = [];
1927
+ for (const file of playbookFiles(recipe.manifest)) {
1928
+ if (!file.endsWith(".md")) continue;
1929
+ let text: string;
1930
+ try {
1931
+ text = fs.readFileSync(path.join(recipe.dir, file), "utf8");
1932
+ } catch {
1933
+ continue;
1934
+ }
1935
+ const never = neverSection(text);
1936
+ if (!never.length) continue;
1937
+ out.push(` never (${file}):`, ...never.map((l) => ` ${l}`));
1938
+ }
1939
+ return out;
1940
+ }
1941
+
1861
1942
 
1862
1943
  /** The prompt that hands the tracker description to the session; the text lives beside the recipes. */
1863
1944
  function trackerSetupPrompt(recipe: Recipe, project: string): string {
@@ -1939,6 +2020,7 @@ export default function piLoops(pi: ExtensionAPI) {
1939
2020
  return;
1940
2021
  }
1941
2022
  const plan = planInstall(recipe, project, level);
2023
+ const preflight = await preflightLines(recipe, project, level);
1942
2024
  let overwrite = false;
1943
2025
  if (plan.changed.length) {
1944
2026
  overwrite = await ctx.ui.confirm(`Overwrite ${plan.changed.length} edited file(s)?`, [...plan.changed.map((f) => ` ${f}`), "", "These differ from what would be written. Yes replaces them with the packaged version; No keeps your copies (the record is still written, and /recipe update merges later)."].join("\n"));
@@ -1960,10 +2042,12 @@ export default function piLoops(pi: ExtensionAPI) {
1960
2042
  `Files → ${homeRel(plan.targetDir)}/ (listed in .git/info/exclude, so nothing enters the repository):`,
1961
2043
  ...plan.files.map((f) => ` ${f}${plan.changed.includes(f) ? (overwrite ? " (overwritten)" : " (kept as edited)") : ""}`),
1962
2044
  ...(setupText !== undefined ? ["", `Setup script ${m.setup} (${setupText.split("\n").length} lines, printed above and at ${path.join(recipe.dir, m.setup ?? "")}) runs once with this session's environment.`] : []),
2045
+ ...(neverLines(recipe).length ? ["", "What the playbooks forbid a run to do, at every level:", ...neverLines(recipe)] : []),
1963
2046
  "",
1964
2047
  "Jobs:",
1965
2048
  ...plan.addLines.map((l) => ` /cron add ${l}`),
1966
2049
  ...(m.budgetHintUsd ? ["", `Budget hint: about $${m.budgetHintUsd}/day — cap it with [limits] daily_budget_usd in config.toml`] : []),
2050
+ ...(preflight.length ? ["", "Before the first run (checked now; nothing here blocks the install):", ...preflight] : []),
1967
2051
  ];
1968
2052
  const ok = await ctx.ui.confirm(`Install recipe ${m.name} at level "${level}"?`, body.join("\n"));
1969
2053
  if (!ok) {
@@ -2021,11 +2105,20 @@ export default function piLoops(pi: ExtensionAPI) {
2021
2105
  const packaged = listRecipes();
2022
2106
  const installed = new Map(installedRecipes(project).map((r) => [r.recipe, r]));
2023
2107
  const jobs = scheduler.store.load().filter((j) => j.recipe && sameProject(j.cwd, project));
2024
- const lines = packaged.map((r) => {
2108
+ const line = (r: Recipe) => {
2025
2109
  const rec = installed.get(r.manifest.name);
2026
2110
  const n = jobs.filter((j) => j.recipe === r.manifest.name).length;
2027
2111
  return ` ${r.manifest.name.padEnd(17)} ${(rec ? `installed: ${rec.level}, ${n} job(s)` : "—").padEnd(28)} ${r.manifest.summary}`;
2028
- });
2112
+ };
2113
+ // Starters first: they read and file findings, and are the ones to install before
2114
+ // anything that writes to a tracker or opens a pull request.
2115
+ const lines: string[] = [];
2116
+ for (const tier of RECIPE_TIERS) {
2117
+ const group = packaged.filter((r) => r.manifest.tier === tier);
2118
+ if (!group.length) continue;
2119
+ lines.push(tier === "starter" ? " starter — reads, and files findings; install one of these first:" : " advanced — writes to a worktree, a tracker or a pull request; read the playbooks first:");
2120
+ lines.push(...group.map(line));
2121
+ }
2029
2122
  for (const rec of installed.values()) {
2030
2123
  if (packaged.some((r) => r.manifest.name === rec.recipe)) continue;
2031
2124
  lines.push(` ${rec.recipe.padEnd(17)} installed from ${homeRel(rec.source)} (${rec.level})`);
@@ -2043,8 +2136,12 @@ export default function piLoops(pi: ExtensionAPI) {
2043
2136
  const m = recipe.manifest;
2044
2137
  const rel = path.join(INSTALL_ROOT, m.name);
2045
2138
  const record = readRecord(installDirFor(project, m.name));
2139
+ const topLevel = m.levels[m.levels.length - 1];
2140
+ const showPreflight = await preflightLines(recipe, project, topLevel);
2046
2141
  const lines = [
2047
2142
  ` ${m.summary}`,
2143
+ ` tier: ${m.tier}${m.tier === "starter" ? " (reads, and files findings)" : " (writes to a worktree, a tracker or a pull request)"}`,
2144
+ ...(m.usefulWhen.length ? [" useful when:", ...m.usefulWhen.map((u) => ` ${u}`)] : []),
2048
2145
  ` levels: ${m.levels.map((l) => `${l} (${LEVEL_TEXT[l]})`).join("; ")}`,
2049
2146
  ` tracker: ${m.needsTracker ? `needs ${TRACKER_FILE} — ${fs.existsSync(path.join(project, TRACKER_FILE)) ? "present" : "missing here; /recipe add writes it with you"}` : "not needed"}`,
2050
2147
  ` files → ${rel}/: ${playbookFiles(m).join(", ")}`,
@@ -2052,6 +2149,8 @@ export default function piLoops(pi: ExtensionAPI) {
2052
2149
  ...(m.budgetHintUsd ? [` budget hint: about $${m.budgetHintUsd}/day at the default schedules — [limits] daily_budget_usd in config.toml`] : []),
2053
2150
  " jobs:",
2054
2151
  ...m.jobs.map((j) => ` /cron add ${addLineFor(j, rel)}`),
2152
+ ...neverLines(recipe),
2153
+ ...(showPreflight.length ? [` before the first run (at ${topLevel}, checked now):`, ...showPreflight] : []),
2055
2154
  ...(record ? [` installed here: ${record.level}, ${record.installedAt}, pi-loops ${record.version}`] : []),
2056
2155
  ];
2057
2156
  show(ctx, `recipe ${m.name} (${homeRel(recipe.dir)})`, lines);
@@ -2233,12 +2332,7 @@ export default function piLoops(pi: ExtensionAPI) {
2233
2332
  const outputPath = path.resolve(session.cwd, pathArgs[0] ?? defaultExportPath(session.cwd, sessionId));
2234
2333
  ctx.ui.notify(ARCHIVE_WARNING, "warning"); // Printed before the attempt, success or not
2235
2334
  try {
2236
- // The store is machine-global, so the session that
2237
- // created a job or rule is what scopes the archive; jobs from before `createdBy`
2238
- // existed fall back to the project.
2239
- const mine = (owner: { sessionId?: string } | undefined, cwd: string) => (owner?.sessionId ? owner.sessionId === sessionId : sameProject(cwd, session.cwd));
2240
- const jobs = scheduler.store.load().filter((j) => sameProject(j.cwd, session.cwd) && mine(j.createdBy, j.cwd));
2241
- const rules = triggers.store.load().filter((r) => sameProject(r.cwd, session.cwd) && mine(r.createdBy, r.cwd));
2335
+ const { jobs, rules } = automationOf(scheduler.store.load(), triggers.store.load(), session.cwd, sessionId);
2242
2336
  const states: Record<string, string> = {};
2243
2337
  for (const job of jobs) {
2244
2338
  const loopState = job.stateful ? scheduler.store.readState(job.id) : undefined;
@@ -2259,26 +2353,26 @@ export default function piLoops(pi: ExtensionAPI) {
2259
2353
  handler: async (args, ctx) => {
2260
2354
  lastCtx = ctx;
2261
2355
  const parts = args.split(/\s+/).filter(Boolean);
2262
- let activate = false;
2356
+ let mode: ActivateMode = "ask";
2263
2357
  let resume = false;
2264
2358
  let targetCwd = session.cwd;
2265
2359
  const positional: string[] = [];
2266
- for (let i = 0; i < parts.length; i++) {
2267
- const arg = parts[i];
2268
- if (arg === "--resume") resume = true;
2269
- else if (arg.startsWith("--activate-triggers=")) {
2270
- const value = arg.slice("--activate-triggers=".length);
2271
- if (value === "on") activate = true;
2272
- else if (value === "off") activate = false;
2273
- else {
2274
- ctx.ui.notify(`--activate-triggers=${value} is not supported (use on|off)`, "warning");
2275
- return;
2276
- }
2277
- } else if (arg === "--cwd") targetCwd = path.resolve(session.cwd, parts[++i] ?? ".");
2278
- else positional.push(arg);
2360
+ try {
2361
+ for (let i = 0; i < parts.length; i++) {
2362
+ const arg = parts[i];
2363
+ if (arg === "--resume") resume = true;
2364
+ // Both forms, as the command line takes them.
2365
+ else if (arg.startsWith("--activate-triggers=")) mode = parseActivateMode(arg.slice("--activate-triggers=".length));
2366
+ else if (arg === "--activate-triggers") mode = parseActivateMode(parts[++i]);
2367
+ else if (arg === "--cwd") targetCwd = path.resolve(session.cwd, parts[++i] ?? ".");
2368
+ else positional.push(arg);
2369
+ }
2370
+ } catch (err: any) {
2371
+ ctx.ui.notify(err?.message ?? String(err), "warning");
2372
+ return;
2279
2373
  }
2280
2374
  if (positional.length !== 1) {
2281
- ctx.ui.notify("usage: /session-import <path> [--activate-triggers=on|off] [--cwd <dir>] [--resume]", "warning");
2375
+ ctx.ui.notify("usage: /session-import <path> [--activate-triggers=off|ask|on] [--cwd <dir>] [--resume]", "warning");
2282
2376
  return;
2283
2377
  }
2284
2378
  const archivePath = path.resolve(session.cwd, positional[0]);
@@ -2289,32 +2383,13 @@ export default function piLoops(pi: ExtensionAPI) {
2289
2383
  archivePath,
2290
2384
  sessionDir: ctx.sessionManager.getSessionDir(),
2291
2385
  targetCwd,
2292
- activate,
2386
+ activate: mode === "on",
2293
2387
  existingJobs: store.load(),
2294
2388
  existingRules: triggers.store.load(),
2295
2389
  existingJobIds: new Set(store.load().map((j) => j.id)),
2296
2390
  existingRuleIds: new Set(triggers.store.load().map((r) => r.id)),
2297
2391
  });
2298
- try {
2299
- if (imp.jobs.length) await store.mutate((jobs) => ({ jobs: [...jobs, ...imp.jobs], result: undefined }));
2300
- for (const [id, text] of Object.entries(imp.states)) store.writeState(id, text);
2301
- if (imp.rules.length) await triggers.store.mutate((rules) => rules.push(...imp.rules));
2302
- } catch (err) {
2303
- // Roll back, best effort and in this order: a half-imported archive must leave neither
2304
- // an orphan session nor a partial store, and the original error is what gets reported.
2305
- const ids = new Set(imp.jobs.map((j) => j.id));
2306
- const attempt = (fn: () => void) => {
2307
- try {
2308
- fn();
2309
- } catch {
2310
- /* best effort */
2311
- }
2312
- };
2313
- attempt(() => fs.rmSync(imp.sessionPath, { force: true }));
2314
- await store.mutate((jobs) => ({ jobs: jobs.filter((j) => !ids.has(j.id)), result: undefined })).catch(() => {});
2315
- for (const id of ids) attempt(() => fs.rmSync(store.statePath(id), { force: true }));
2316
- throw err;
2317
- }
2392
+ await applyImport(imp, store, triggers.store);
2318
2393
  for (const job of imp.jobs) cronControlAudit("add", "slash", undefined, job);
2319
2394
  refreshBadge();
2320
2395
  const skipped = (imp.skippedJobs ?? 0) + (imp.skippedRules ?? 0);
@@ -2324,22 +2399,12 @@ export default function piLoops(pi: ExtensionAPI) {
2324
2399
  ...(imp.notes ?? []).map((n: string) => `note: ${n}`),
2325
2400
  `resume with: pi --session ${imp.sessionPath}`,
2326
2401
  ]);
2327
- // Offer to switch originally-enabled automation back on.
2328
- const pending = imp.originallyEnabledJobs.length + imp.originallyEnabledRules.length;
2329
- if (!activate && pending && ctx.hasUI) {
2330
- const ok = await ctx.ui.confirm("Enable imported automation?", `${imp.originallyEnabledJobs.length} cron job(s) and ${imp.originallyEnabledRules.length} trigger rule(s) were enabled in the source session. Enable them here now?`);
2331
- if (ok) {
2332
- for (const id of imp.originallyEnabledJobs) {
2333
- const before = store.load().find((j) => j.id === id);
2334
- const after = await store.update(id, (j) => {
2335
- j.enabled = true;
2336
- });
2337
- if (before && after) cronControlAudit("enable", "slash", before, after);
2338
- }
2339
- for (const id of imp.originallyEnabledRules) await triggers.store.setEnabled(id, true);
2340
- refreshBadge();
2341
- ctx.ui.notify(`enabled ${imp.originallyEnabledJobs.length} cron job(s) and ${imp.originallyEnabledRules.length} trigger rule(s)`, "info");
2342
- }
2402
+ // Offer to switch originally-enabled automation back on. Nothing is asked with no UI to
2403
+ // ask in, and `off` means nothing is asked at all.
2404
+ const enabled = await offerToEnable(imp, store, triggers.store, mode === "ask" && ctx.hasUI ? (question, detail) => ctx.ui.confirm(question, detail) : undefined, (before, after) => cronControlAudit("enable", "slash", before, after));
2405
+ if (enabled.jobs || enabled.rules) {
2406
+ refreshBadge();
2407
+ ctx.ui.notify(`enabled ${enabled.jobs} cron job(s) and ${enabled.rules} trigger rule(s)`, "info");
2343
2408
  }
2344
2409
  if (resume) await ctx.switchSession(imp.sessionPath);
2345
2410
  } catch (err: any) {
@@ -2365,12 +2430,6 @@ export default function piLoops(pi: ExtensionAPI) {
2365
2430
 
2366
2431
  /* ------------------------------------------------------------ tools */
2367
2432
 
2368
- /**
2369
- * The same project, whichever path this pi was opened through: a worktree, a symlink or a
2370
- * subdirectory of the project root all belong to the rule or job that names the root.
2371
- */
2372
- const sameProject = (a: string, b: string): boolean => withinProject(b, a) || withinProject(a, b);
2373
-
2374
2433
  /**
2375
2434
  * Where a diagnostic goes: the log file always, and the chat as a warning — pi's `showStatus`
2376
2435
  * replaces the previous status line in place, so several info-level notices in one tick collapse
@@ -2506,7 +2565,16 @@ export default function piLoops(pi: ExtensionAPI) {
2506
2565
  goalMessages = event.messages ?? [];
2507
2566
  const last: any = (event.messages ?? []).at(-1);
2508
2567
  lastTurnStopReason = typeof last?.stopReason === "string" ? last.stopReason : undefined;
2509
- if (lastTurnStopReason === "aborted") goalAbort?.abort();
2568
+ if (lastTurnStopReason === "aborted") {
2569
+ goalAbort?.abort();
2570
+ // Esc pauses the goal, not only this turn's judgement: the person said stop, and a goal
2571
+ // that went on pursuing would send them back to work on their next message.
2572
+ const stopped = goal && abortedTurn(goal);
2573
+ if (stopped) {
2574
+ persistGoal(ctx, stopped.state);
2575
+ notifyOrLog(ctx, `[goal] paused: ${stopped.action.kind === "pause" ? stopped.action.reason : ""}`, "warning");
2576
+ }
2577
+ }
2510
2578
  await fireHook({ event: "agent_end" }, ctx);
2511
2579
  refreshBadge();
2512
2580
  });
package/src/presence.ts CHANGED
@@ -107,6 +107,11 @@ export function withinProject(root: string, p: string): boolean {
107
107
  return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
108
108
  }
109
109
 
110
+ /** A worktree, a symlinked path or a subdirectory is the same project, as the runtime treats it. */
111
+ export function sameProject(a: string, b: string): boolean {
112
+ return withinProject(b, a) || withinProject(a, b);
113
+ }
114
+
110
115
  /**
111
116
  * The process that should act for the project rooted at `cwd` on `host`: a live pi opened in it —
112
117
  * or in a subdirectory of it, or through a symlink to it — lowest pid as the tie-break so every
package/src/protocol.ts CHANGED
@@ -21,6 +21,30 @@ export const FIRST_RUN_MARKER = "(first run)";
21
21
  */
22
22
  export const LOOP_STATE_CLOSE = "[/loop-state]";
23
23
  export const OUTPUT_PROTOCOL_HEADING = "Output protocol (mandatory):";
24
+ /** The block between the notes and the job text that carries what a person said when dismissing (`/inbox dismiss <n> <reason>`). */
25
+ export const DISMISSED_OPEN = "[dismissed]";
26
+ export const DISMISSED_CLOSE = "[/dismissed]";
27
+ /** How many dismissed-with-reason findings one run is shown; the newest survive. */
28
+ export const DISMISSED_PER_RUN = 8;
29
+
30
+ /** One finding a person dismissed with a reason, as the loop that reported it is told. */
31
+ export interface DismissedFeedback {
32
+ text: string;
33
+ reason: string;
34
+ }
35
+
36
+ /**
37
+ * A checkpoint is a finding that asks a person to decide, and docs/recipes.md fixes its shape:
38
+ * `<the decision> · waits: <what waits on it> · if not: <what happens if nobody acts>`. The
39
+ * middle dot before `waits:` is the mark — the word alone is prose ("the deploy waits: on DNS"),
40
+ * and a loop that wants its finding read as a decision writes the shape the playbooks write.
41
+ * Everything else is news: a merged pull request, a red check, a digest line. Every finding is one
42
+ * of the two, so the answer is always a kind.
43
+ */
44
+ export type FindingKind = "checkpoint" | "news";
45
+ export function findingKind(text: string): FindingKind {
46
+ return /(^|\s)·\s*waits:/i.test(text) ? "checkpoint" : "news";
47
+ }
24
48
 
25
49
  export function capChars(text: string, max: number): string {
26
50
  const chars = Array.from(text.trim());
@@ -28,8 +52,23 @@ export function capChars(text: string, max: number): string {
28
52
  return `${chars.slice(0, max).join("")}…`;
29
53
  }
30
54
 
31
- export function composeLoopPrompt(action: string, previousState: string | undefined, meta?: { name?: string; runAt?: string }): string {
55
+ export function composeLoopPrompt(action: string, previousState: string | undefined, meta?: { name?: string; runAt?: string; dismissed?: DismissedFeedback[] }): string {
32
56
  const state = previousState && previousState.trim() ? capChars(previousState, LOOP_STATE_MAX_CHARS) : FIRST_RUN_MARKER;
57
+ /**
58
+ * Feedback, not permission. A reason is shown to the one run after it was given and then only
59
+ * lives on in that run's notes: the person spoke once, and the notes are the only thing a loop
60
+ * carries between runs — a second channel that the notes cap does not bound would be a memory the
61
+ * loop could not edit or forget. Newest last, so the most recent word is nearest the task.
62
+ */
63
+ const dismissed = (meta?.dismissed ?? []).slice(-DISMISSED_PER_RUN);
64
+ const feedback = dismissed.length
65
+ ? [
66
+ `${DISMISSED_OPEN} (findings of yours a person dismissed since your previous run, and why — do not report these again unless what they describe has changed; put what you need to remember that in your notes)`,
67
+ ...dismissed.map((d) => `- "${capChars(d.text.replace(/\s+/g, " "), INBOX_TEXT_MAX_CHARS)}" — ${capChars(d.reason.replace(/\s+/g, " "), INBOX_TEXT_MAX_CHARS)}`),
68
+ DISMISSED_CLOSE,
69
+ "",
70
+ ]
71
+ : [];
33
72
  const header = meta?.name ? `You are running the recurring loop "${meta.name}"` : "You are running a recurring loop";
34
73
  /**
35
74
  * The run time, and how to write one.
@@ -50,6 +89,7 @@ export function composeLoopPrompt(action: string, previousState: string | undefi
50
89
  state,
51
90
  LOOP_STATE_CLOSE,
52
91
  "",
92
+ ...feedback,
53
93
  action.trim(),
54
94
  "",
55
95
  OUTPUT_PROTOCOL_HEADING,
@@ -111,7 +151,10 @@ export function stripProtocolTags(text: string): string {
111
151
  * what a display wants — a prompt from somewhere else is still the best thing to show.
112
152
  */
113
153
  export function jobTextOf(prompt: string): string {
114
- return prompt.split(LOOP_STATE_CLOSE).pop()?.split(OUTPUT_PROTOCOL_HEADING)[0] ?? prompt;
154
+ const afterState = prompt.split(LOOP_STATE_CLOSE).pop()?.split(OUTPUT_PROTOCOL_HEADING)[0] ?? prompt;
155
+ // The dismissed block sits between the notes and the job text; it is the person's words, not the job's.
156
+ const afterFeedback = afterState.trimStart().startsWith(DISMISSED_OPEN) ? afterState.split(DISMISSED_CLOSE).slice(1).join(DISMISSED_CLOSE) : afterState;
157
+ return afterFeedback;
115
158
  }
116
159
 
117
160
  /* ---------------------------------------------------------- maker/checker */