@danypops/papyrus 0.5.0 → 0.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/README.md CHANGED
@@ -37,6 +37,8 @@ papyrus tasks plan
37
37
  papyrus tasks depend <task-id> <prerequisite-id>
38
38
  papyrus tasks start <task-id>
39
39
  papyrus tasks complete <task-id>
40
+ papyrus tasks automate <task-id> <on|off>
41
+ papyrus automation status
40
42
  ```
41
43
 
42
44
  For repository work, install the versioned ownership guard once:
@@ -138,8 +140,31 @@ papyrus tasks complete <id> --json
138
140
  papyrus tasks reject <id> --json
139
141
  papyrus tasks retry <id> --json
140
142
  papyrus tasks cancel <id> --json
143
+ papyrus tasks automate <id> <on|off> --json
144
+ papyrus automation status --json
145
+ papyrus automation run --json
141
146
  ```
142
147
 
148
+ ### Opt-in supervised automation
149
+
150
+ Background graph reconciliation is off by default and requires two independent opt-ins: daemon configuration and `automation.enabled` on each Task. Only opted-in Tasks already in `review` are eligible for automatic gate/checklist review; Papyrus never skips the review lifecycle. When one completes, directly dependent opted-in successors that become ready may move from `todo` to `in-progress`. Every completion, rejection, and start is written to append-only history with actor `daemon`, source `automation-reconciler`, reason, and bounded gate evidence.
151
+
152
+ Enable the daemon with a systemd user-service override and restart it:
153
+
154
+ ```ini
155
+ [Service]
156
+ Environment=PAPYRUS_AUTOMATION_ENABLED=1
157
+ ```
158
+
159
+ ```bash
160
+ systemctl --user edit papyrus.service
161
+ systemctl --user restart papyrus.service
162
+ papyrus tasks automate <task-id> on
163
+ papyrus automation status
164
+ ```
165
+
166
+ Secure defaults are a 60-second interval, 10 Task transitions per sweep, gate concurrency 1, and a 120-second sweep deadline. Optional environment settings are `PAPYRUS_AUTOMATION_INTERVAL_MS` (10 seconds–1 hour), `PAPYRUS_AUTOMATION_MAX_TASKS` (1–100), `PAPYRUS_AUTOMATION_GATE_CONCURRENCY` (1–4), and `PAPYRUS_AUTOMATION_MAX_RUNTIME_MS` (1 ms–10 minutes). Candidate scans are capped at 1,000 review Tasks, sweeps are single-flight, subprocess gates inherit the sweep deadline, result arrays are bounded by the Task limit, and logs contain counts rather than gate output. `papyrus automation run` uses the same policy and refuses to reconcile while global automation is disabled.
167
+
143
168
  Checklist criteria are an item-to-proof map. Every new item requires one or more typed references to inspectable evidence; proof presence does not imply that the evidence passed an executable gate:
144
169
 
145
170
  ```ts
@@ -31,7 +31,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
31
31
  pi.registerTool({
32
32
  name: "tasks",
33
33
  label: "Tasks",
34
- description: "Task domain tool. ACTIONS: create, list, show, history, graph, plan, active, focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, contain. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. Prefer this over low-level papyrus_* tools for task work.",
34
+ description: "Task domain tool. ACTIONS: create, list, show, history, graph, plan, active, focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, set_automation, depend, contain. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. Prefer this over low-level papyrus_* tools for task work.",
35
35
  parameters: Type.Object({
36
36
  action: Type.String(),
37
37
  id: Type.Optional(Type.String()),
@@ -44,6 +44,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
44
44
  direction: Type.Optional(Type.Union([Type.Literal("asc"), Type.Literal("desc")])),
45
45
  reason: Type.Optional(Type.String()),
46
46
  session_id: Type.Optional(Type.String()),
47
+ enabled: Type.Optional(Type.Boolean()),
47
48
  labels: Type.Optional(Type.Array(Type.String())),
48
49
  extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
49
50
  gates: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Unknown()))),
@@ -123,6 +124,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
123
124
  reject: "tasks.reject",
124
125
  retry: "tasks.retry",
125
126
  cancel: "tasks.cancel",
127
+ set_automation: "tasks.set_automation",
126
128
  depend: "tasks.depend",
127
129
  contain: "tasks.contain",
128
130
  } as const;
@@ -30,6 +30,12 @@ const STATUS_ACTIONS: Record<string, string[]> = {
30
30
 
31
31
  type TaskRow = Artifact;
32
32
 
33
+ function taskAutomationEnabled(task: Artifact): boolean {
34
+ const automation = task.extra["automation"];
35
+ return typeof automation === "object" && automation !== null && !Array.isArray(automation)
36
+ && (automation as Record<string, unknown>)["enabled"] === true;
37
+ }
38
+
33
39
  export interface TaskHierarchyRow {
34
40
  task: TaskRow;
35
41
  depth: number;
@@ -86,9 +92,11 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
86
92
  if (action.type !== "action" || !action.row) continue;
87
93
 
88
94
  const active = graph.nodes.find((node) => node.task.id === action.row!.id)?.active === true;
95
+ const automationEnabled = taskAutomationEnabled(action.row);
89
96
  const choices = [
90
97
  "Show details",
91
98
  ...(!active && action.row.status !== "done" && action.row.status !== "canceled" ? ["Make active"] : []),
99
+ ...(action.row.status !== "done" && action.row.status !== "canceled" ? [automationEnabled ? "Disable automation" : "Enable automation"] : []),
92
100
  ...(action.row.status === "review" ? ["Run gates"] : []),
93
101
  ...(STATUS_ACTIONS[action.row.status] ?? []),
94
102
  ];
@@ -107,6 +115,20 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
107
115
  } catch (error) {
108
116
  ctx.ui.notify(`Focus failed: ${error instanceof Error ? error.message : error}`, "error");
109
117
  }
118
+ } else if (choice === "Enable automation" || choice === "Disable automation") {
119
+ try {
120
+ const enabled = choice === "Enable automation";
121
+ const updated = await callService<Record<string, unknown>, Artifact>("tasks.set_automation", {
122
+ id: action.row.id,
123
+ enabled,
124
+ actor: "user",
125
+ source: "tasks-tui",
126
+ });
127
+ action.row.extra = updated.extra;
128
+ ctx.ui.notify(`Automation ${enabled ? "enabled" : "disabled"}: ${action.row.title}`, enabled ? "warning" : "info");
129
+ } catch (error) {
130
+ ctx.ui.notify(`Automation setting failed: ${error instanceof Error ? error.message : error}`, "error");
131
+ }
110
132
  } else if (choice === "Run gates") {
111
133
  try {
112
134
  const results = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", { id: action.row.id, actor: "user", source: "tasks-tui" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -1,5 +1,5 @@
1
1
  import type { Db } from "../db.ts";
2
- import type { GateResult } from "../domain/gate.ts";
2
+ import type { GateResult, GateRunOptions } from "../domain/gate.ts";
3
3
  import type { GateRunner } from "../ports/gate-runner.ts";
4
4
  import { runGates, runGatesAsync } from "../ops.ts";
5
5
 
@@ -10,7 +10,7 @@ export class SQLiteGateRunner implements GateRunner {
10
10
  return runGates(this.db, artifactId);
11
11
  }
12
12
 
13
- runAsync(artifactId: string): Promise<GateResult[]> {
14
- return runGatesAsync(this.db, artifactId);
13
+ runAsync(artifactId: string, options?: GateRunOptions): Promise<GateResult[]> {
14
+ return runGatesAsync(this.db, artifactId, options);
15
15
  }
16
16
  }
package/src/cli.ts CHANGED
@@ -10,6 +10,7 @@ import { serveMain } from "./daemon.ts";
10
10
  import type { GateResult } from "./domain/gate.ts";
11
11
  import type { TaskExecutionPlan } from "./task-execution.ts";
12
12
  import type { TaskBlockage, TaskCompletion } from "./task-service.ts";
13
+ import type { TaskAutomationResult, TaskAutomationSettings } from "./task-automation.ts";
13
14
 
14
15
  export interface SystemdUnitOptions {
15
16
  bunBin: string;
@@ -57,6 +58,7 @@ const USAGE = `Usage:
57
58
  papyrus serve
58
59
  papyrus service <install|start|stop|restart|status>
59
60
  papyrus migrate task-history [--json]
61
+ papyrus automation <status|run> [--json]
60
62
  papyrus skills run <id> [--arguments-json <json>] [--run-id <id>] [--json]
61
63
  papyrus tasks plan [--json]
62
64
  papyrus tasks graph [--json]
@@ -69,6 +71,7 @@ const USAGE = `Usage:
69
71
  papyrus tasks reject <id> [--json]
70
72
  papyrus tasks retry <id> [--json]
71
73
  papyrus tasks cancel <id> [--json]
74
+ papyrus tasks automate <id> <on|off> [--json]
72
75
  papyrus tasks depend <id> <prerequisite-id> [--json]`;
73
76
 
74
77
  function usage(): never {
@@ -116,6 +119,20 @@ export async function runMigrationCli(args: string[], client: TaskCliClient): Pr
116
119
  return `Migrated schema ${result.from} → ${result.to}: ${result.applied.join(", ")}`;
117
120
  }
118
121
 
122
+ export async function runAutomationCli(args: string[], client: TaskCliClient): Promise<string> {
123
+ const json = args.includes("--json");
124
+ const positional = args.filter((argument) => argument !== "--json");
125
+ if (positional.length !== 1 || (positional[0] !== "status" && positional[0] !== "run")) {
126
+ throw new Error("automation requires exactly `status` or `run`");
127
+ }
128
+ if (positional[0] === "status") {
129
+ const status = await client.call<Record<string, never>, TaskAutomationSettings & { inFlight: boolean }>("automation.status", {});
130
+ return json ? JSON.stringify(status) : `Automation: ${status.enabled ? "enabled" : "disabled"} · interval ${status.intervalMs}ms · max ${status.maxTasksPerSweep} tasks · concurrency ${status.gateConcurrency}`;
131
+ }
132
+ const result = await client.call<Record<string, never>, TaskAutomationResult>("automation.reconcile", {});
133
+ return json ? JSON.stringify(result) : `Automation sweep: ${result.examined} examined · ${result.completed} completed · ${result.rejected} rejected · ${result.started} started · ${result.errors.length} errors${result.skipped ? ` · skipped ${result.skipped}` : ""}`;
134
+ }
135
+
119
136
  export async function runSkillCli(args: string[], client: TaskCliClient): Promise<string> {
120
137
  const json = args.includes("--json");
121
138
  const positional: string[] = [];
@@ -241,6 +258,18 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
241
258
  human = `${action[0]!.toUpperCase()}${action.slice(1)}: ${artifactLabel(artifact)}`;
242
259
  break;
243
260
  }
261
+ case "automate": {
262
+ if (!id || (dependencyId !== "on" && dependencyId !== "off") || positional.length !== 3) throw new Error("tasks automate requires a task id and on or off");
263
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("tasks.set_automation", {
264
+ id,
265
+ enabled: dependencyId === "on",
266
+ actor: "user",
267
+ source: "cli",
268
+ });
269
+ result = artifact;
270
+ human = `Automation ${dependencyId}: ${artifactLabel(artifact)}`;
271
+ break;
272
+ }
244
273
  case "depend": {
245
274
  if (!id || !dependencyId || positional.length !== 3) throw new Error("tasks depend requires a task id and prerequisite id");
246
275
  const artifact = await client.call<{ id: string; dependency_id: string }, CliArtifact>("tasks.depend", {
@@ -252,7 +281,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
252
281
  break;
253
282
  }
254
283
  default:
255
- throw new Error("tasks action must be active, focus, graph, plan, history, complete, start, submit, reject, retry, cancel, or depend");
284
+ throw new Error("tasks action must be active, focus, graph, plan, history, complete, start, submit, reject, retry, cancel, automate, or depend");
256
285
  }
257
286
  return json ? JSON.stringify(result) : human;
258
287
  }
@@ -265,6 +294,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
265
294
  console.log(await runTaskCli(args.slice(1), client));
266
295
  return;
267
296
  }
297
+ if (command === "automation") {
298
+ const client = await connectPapyrusClient();
299
+ console.log(await runAutomationCli(args.slice(1), client));
300
+ return;
301
+ }
268
302
  if (command === "skills") {
269
303
  const client = await connectPapyrusClient();
270
304
  console.log(await runSkillCli(args.slice(1), client));
package/src/constants.ts CHANGED
@@ -15,6 +15,7 @@ export const GATE_COMMAND_TIMEOUT_MS = 30_000;
15
15
  export const GATE_TEST_TIMEOUT_MS = 60_000;
16
16
  export const GATE_OUTPUT_LIMIT = 200;
17
17
  export const GATE_MAX_BUFFER_BYTES = 1_048_576;
18
+ export const GATE_FILE_MAX_BYTES = 1_048_576;
18
19
 
19
20
  /** Compact task-context limits keep recurring prompt injection bounded. */
20
21
  export const TASK_CONTEXT_CURRENT_LIMIT = 3;
@@ -48,6 +49,19 @@ export const TASK_HISTORY_MAX_LIMIT = 100;
48
49
  export const TASK_EVENT_MAX_EVIDENCE_BYTES = 65_536;
49
50
  export const TASK_EVENT_ACTOR_MAX_LENGTH = 128;
50
51
  export const TASK_EVENT_REASON_MAX_LENGTH = 2_000;
52
+ /** Explicitly opt-in supervised Task graph reconciliation bounds. */
53
+ export const TASK_AUTOMATION_INTERVAL_MS = 60_000;
54
+ export const TASK_AUTOMATION_MIN_INTERVAL_MS = 10_000;
55
+ export const TASK_AUTOMATION_MAX_INTERVAL_MS = 3_600_000;
56
+ export const TASK_AUTOMATION_MAX_TASKS_PER_SWEEP = 10;
57
+ export const TASK_AUTOMATION_HARD_MAX_TASKS_PER_SWEEP = 100;
58
+ export const TASK_AUTOMATION_GATE_CONCURRENCY = 1;
59
+ export const TASK_AUTOMATION_MAX_GATE_CONCURRENCY = 4;
60
+ export const TASK_AUTOMATION_MAX_RUNTIME_MS = 120_000;
61
+ export const TASK_AUTOMATION_HARD_MAX_RUNTIME_MS = 600_000;
62
+ export const TASK_AUTOMATION_MAX_CANDIDATE_SCAN = 1_000;
63
+ export const TASK_AUTOMATION_ERROR_ID_MAX_LENGTH = 128;
64
+ export const TASK_AUTOMATION_ERROR_MESSAGE_MAX_LENGTH = 500;
51
65
  export const GRAPH_RENDER_PADDING_X = 2;
52
66
  export const GRAPH_RENDER_PADDING_Y = 1;
53
67
  export const GRAPH_RENDER_BOX_PADDING = 0;
package/src/daemon.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  import { DAEMON_HOST, DB_OPTIMIZE_INTERVAL_MS, WAL_CHECKPOINT_INTERVAL_MS, dbPath } from "./constants.ts";
2
2
  import { clearDaemonPort, daemonStateDir, loadOrCreateToken, writeDaemonPort } from "./daemon-state.ts";
3
3
  import { createApp, createPapyrusService } from "./service.ts";
4
+ import { scheduleTaskAutomation, taskAutomationSettings, type TaskAutomationResult } from "./task-automation.ts";
5
+ import { logEvent } from "./log.ts";
4
6
 
5
7
  /** Start the supervised, long-running Papyrus service. */
6
8
  export function serveMain(): void {
7
9
  const stateDir = daemonStateDir();
8
10
  const token = loadOrCreateToken(stateDir);
9
- const service = createPapyrusService(dbPath());
11
+ const automation = taskAutomationSettings(process.env);
12
+ const service = createPapyrusService(dbPath(), { automation });
10
13
  const app = createApp({ service, token });
11
14
  const server = Bun.serve({
12
15
  hostname: DAEMON_HOST,
@@ -19,11 +22,23 @@ export function serveMain(): void {
19
22
  }
20
23
  writeDaemonPort(stateDir, server.port);
21
24
  const checkpointTimer = setInterval(() => {
22
- try { service.checkpoint(); } catch (error) { console.error("[papyrus] checkpoint failed", error); }
25
+ try { service.checkpoint(); } catch (error) { logEvent("error", "checkpoint_failed", { message: error instanceof Error ? error.message : String(error) }); }
23
26
  }, WAL_CHECKPOINT_INTERVAL_MS);
24
27
  const optimizeTimer = setInterval(() => {
25
- try { service.optimize(); } catch (error) { console.error("[papyrus] optimize failed", error); }
28
+ try { service.optimize(); } catch (error) { logEvent("error", "optimize_failed", { message: error instanceof Error ? error.message : String(error) }); }
26
29
  }, DB_OPTIMIZE_INTERVAL_MS);
30
+ const stopAutomation = scheduleTaskAutomation(automation, async () => {
31
+ const result = await service.execute("automation.reconcile", {}) as TaskAutomationResult;
32
+ logEvent(result.errors.length > 0 ? "warn" : "info", "automation_sweep", {
33
+ examined: result.examined,
34
+ completed: result.completed,
35
+ rejected: result.rejected,
36
+ started: result.started,
37
+ errors: result.errors.length,
38
+ timedOut: result.timedOut,
39
+ skipped: result.skipped,
40
+ });
41
+ }, (error) => logEvent("error", "automation_sweep_failed", { message: error instanceof Error ? error.message : String(error) }));
27
42
 
28
43
  let stopping = false;
29
44
  const shutdown = () => {
@@ -31,11 +46,12 @@ export function serveMain(): void {
31
46
  stopping = true;
32
47
  clearInterval(checkpointTimer);
33
48
  clearInterval(optimizeTimer);
49
+ stopAutomation();
34
50
  clearDaemonPort(stateDir);
35
51
  service.close();
36
52
  void server.stop(true).finally(() => process.exit(0));
37
53
  };
38
54
  process.on("SIGINT", shutdown);
39
55
  process.on("SIGTERM", shutdown);
40
- console.error(`[papyrus] listening on ${DAEMON_HOST}:${server.port}`);
56
+ logEvent("info", "listening", { host: DAEMON_HOST, port: server.port, automationEnabled: automation.enabled });
41
57
  }
@@ -4,6 +4,11 @@ export interface Gate {
4
4
  expect?: string;
5
5
  }
6
6
 
7
+ export interface GateRunOptions {
8
+ /** Absolute Unix epoch deadline for the full gate sequence. */
9
+ deadlineMs?: number;
10
+ }
11
+
7
12
  export interface GateResult {
8
13
  gate: Gate;
9
14
  passed: boolean;
@@ -13,6 +13,8 @@ export const TASK_EVENT_TYPES = [
13
13
  "submitted",
14
14
  "completion_attempted",
15
15
  "gates_evaluated",
16
+ "automation_enabled",
17
+ "automation_disabled",
16
18
  "review_rejected",
17
19
  "retried",
18
20
  "completed",
package/src/log.ts ADDED
@@ -0,0 +1,6 @@
1
+ export type LogLevel = "info" | "warn" | "error";
2
+
3
+ /** Credential-safe structured daemon event. Callers must pass bounded, non-sensitive fields. */
4
+ export function logEvent(level: LogLevel, event: string, fields: Record<string, unknown> = {}): void {
5
+ console.error(JSON.stringify({ timestamp: new Date().toISOString(), level, component: "papyrus-daemon", event, ...fields }));
6
+ }
package/src/ops.ts CHANGED
@@ -7,7 +7,7 @@ import { exec } from "node:child_process";
7
7
  import type { Db } from "./db.ts";
8
8
  import { inTransaction } from "./db.ts";
9
9
  import type { Artifact, CreateArtifactInput } from "./domain/artifact.ts";
10
- import type { Gate, GateResult } from "./domain/gate.ts";
10
+ import type { Gate, GateResult, GateRunOptions } from "./domain/gate.ts";
11
11
  export type { Artifact } from "./domain/artifact.ts";
12
12
  export type { Gate, GateResult } from "./domain/gate.ts";
13
13
  export type CreateInput = CreateArtifactInput;
@@ -20,6 +20,7 @@ import {
20
20
  GATE_TEST_TIMEOUT_MS,
21
21
  GATE_OUTPUT_LIMIT,
22
22
  GATE_MAX_BUFFER_BYTES,
23
+ GATE_FILE_MAX_BYTES,
23
24
  } from "./constants.ts";
24
25
 
25
26
  const require_ = createRequire(import.meta.url);
@@ -234,6 +235,12 @@ export function injectableRules(db: Db): Array<{ id: string; title: string; body
234
235
  });
235
236
  }
236
237
 
238
+ function readBoundedGateFile(path: string): string {
239
+ const { readFileSync, statSync } = require_("node:fs");
240
+ if (statSync(path).size > GATE_FILE_MAX_BYTES) throw new Error(`file exceeds ${GATE_FILE_MAX_BYTES} bytes`);
241
+ return readFileSync(path, "utf-8") as string;
242
+ }
243
+
237
244
  export function runGates(db: Db, artifactId: string): GateResult[] {
238
245
  const art = getArtifact(db, artifactId);
239
246
  if (!art) throw new Error("artifact not found");
@@ -246,9 +253,8 @@ export function runGates(db: Db, artifactId: string): GateResult[] {
246
253
  return { gate, passed: exists, output: exists ? "exists" : "not found" };
247
254
  }
248
255
  case "contains": {
249
- const { readFileSync } = require_("node:fs");
250
256
  try {
251
- const content = readFileSync(gate.target, "utf-8");
257
+ const content = readBoundedGateFile(gate.target);
252
258
  const found = gate.expect ? content.includes(gate.expect) : content.length > 0;
253
259
  return { gate, passed: found, output: found ? "found" : `"${gate.expect ?? ""}" not found` };
254
260
  } catch {
@@ -299,9 +305,8 @@ function runNonProcessGate(gate: Gate): GateResult {
299
305
  return { gate, passed: exists, output: exists ? "exists" : "not found" };
300
306
  }
301
307
  if (gate.type === "contains") {
302
- const { readFileSync } = require_("node:fs");
303
308
  try {
304
- const content = readFileSync(gate.target, "utf-8");
309
+ const content = readBoundedGateFile(gate.target);
305
310
  const found = gate.expect ? content.includes(gate.expect) : content.length > 0;
306
311
  return { gate, passed: found, output: found ? "found" : `"${gate.expect ?? ""}" not found` };
307
312
  } catch {
@@ -312,15 +317,21 @@ function runNonProcessGate(gate: Gate): GateResult {
312
317
  }
313
318
 
314
319
  /** Gate runner for daemon request paths; subprocess gates never block the event loop. */
315
- export async function runGatesAsync(db: Db, artifactId: string): Promise<GateResult[]> {
320
+ export async function runGatesAsync(db: Db, artifactId: string, options: GateRunOptions = {}): Promise<GateResult[]> {
316
321
  const art = getArtifact(db, artifactId);
317
322
  if (!art) throw new Error("artifact not found");
318
323
  const gates = (art.extra["gates"] as Gate[]) ?? [];
319
324
  const results: GateResult[] = [];
320
325
  for (const gate of gates) {
326
+ const remainingMs = options.deadlineMs === undefined ? undefined : options.deadlineMs - Date.now();
327
+ if (remainingMs !== undefined && remainingMs <= 0) {
328
+ results.push({ gate, passed: false, output: "gate runtime deadline exceeded" });
329
+ continue;
330
+ }
321
331
  if (gate.type === "command" || gate.type === "test") {
322
332
  const command = gate.type === "test" ? `npx vitest run ${gate.target} --reporter=dot` : gate.target;
323
- const timeout = gate.type === "test" ? GATE_TEST_TIMEOUT_MS : GATE_COMMAND_TIMEOUT_MS;
333
+ const configuredTimeout = gate.type === "test" ? GATE_TEST_TIMEOUT_MS : GATE_COMMAND_TIMEOUT_MS;
334
+ const timeout = remainingMs === undefined ? configuredTimeout : Math.max(1, Math.min(configuredTimeout, remainingMs));
324
335
  const executed = await executeGateCommand(command, timeout);
325
336
  results.push({
326
337
  gate,
@@ -1,6 +1,6 @@
1
- import type { GateResult } from "../domain/gate.ts";
1
+ import type { GateResult, GateRunOptions } from "../domain/gate.ts";
2
2
 
3
3
  export interface GateRunner {
4
4
  run(artifactId: string): GateResult[];
5
- runAsync(artifactId: string): Promise<GateResult[]>;
5
+ runAsync(artifactId: string, options?: GateRunOptions): Promise<GateResult[]>;
6
6
  }
package/src/service.ts CHANGED
@@ -13,6 +13,7 @@ import type { GateRunner } from "./ports/gate-runner.ts";
13
13
  import type { TaskEventStore } from "./ports/task-event-store.ts";
14
14
  import { projectTaskExecution } from "./task-execution.ts";
15
15
  import { Tasks, type TaskStatus } from "./task-service.ts";
16
+ import { TaskAutomationReconciler, taskAutomationSettings, type TaskAutomationSettings } from "./task-automation.ts";
16
17
  import {
17
18
  createArtifactTemplate,
18
19
  createDocument,
@@ -40,6 +41,8 @@ import { instantiateSkillWorkflow } from "./skill-execution.ts";
40
41
 
41
42
  export const EXPECTED_OPERATION_NAMES = [
42
43
  "system.migrate",
44
+ "automation.status",
45
+ "automation.reconcile",
43
46
  "artifact.create",
44
47
  "artifact.query",
45
48
  "artifact.show",
@@ -61,6 +64,7 @@ export const EXPECTED_OPERATION_NAMES = [
61
64
  "tasks.complete",
62
65
  "tasks.run_gates",
63
66
  "tasks.set_checklist",
67
+ "tasks.set_automation",
64
68
  "tasks.context",
65
69
  "tasks.reject",
66
70
  "tasks.retry",
@@ -144,6 +148,7 @@ function handlers(
144
148
  artifacts: ArtifactStore,
145
149
  gates: GateRunner,
146
150
  tasks: Tasks,
151
+ automation: TaskAutomationReconciler,
147
152
  events: TaskEventStore,
148
153
  migrate: () => unknown,
149
154
  ): Record<OperationName, OperationHandler> {
@@ -164,6 +169,8 @@ function handlers(
164
169
  });
165
170
  return {
166
171
  "system.migrate": () => migrate(),
172
+ "automation.status": () => automation.status(),
173
+ "automation.reconcile": () => automation.reconcile(),
167
174
  "artifact.create": (input) => {
168
175
  const normalized = normalizeCreateInput(input);
169
176
  if (normalized.kind !== "task") return artifacts.create(normalized);
@@ -241,6 +248,10 @@ function handlers(
241
248
  "tasks.complete": (input) => tasks.completeAsync(string(input, "id"), eventContext(input)),
242
249
  "tasks.run_gates": (input) => tasks.runGates(string(input, "id"), eventContext(input)),
243
250
  "tasks.set_checklist": (input) => tasks.setChecklist(string(input, "id"), input["checklist"] as Checklist),
251
+ "tasks.set_automation": (input) => {
252
+ if (typeof input["enabled"] !== "boolean") throw new Error("enabled must be a boolean");
253
+ return tasks.setAutomation(string(input, "id"), input["enabled"], eventContext(input));
254
+ },
244
255
  "tasks.context": () => taskContext(artifacts, tasks.active()?.id),
245
256
  "tasks.reject": (input) => tasks.transition(string(input, "id"), "reject", eventContext(input)),
246
257
  "tasks.retry": (input) => tasks.transition(string(input, "id"), "retry", eventContext(input)),
@@ -293,14 +304,15 @@ function handlers(
293
304
  };
294
305
  }
295
306
 
296
- export function createPapyrusService(path: string): PapyrusService {
307
+ export function createPapyrusService(path: string, options: { automation?: TaskAutomationSettings } = {}): PapyrusService {
297
308
  const db = openDb(path);
298
309
  const artifacts = new SQLiteArtifactStore(db);
299
310
  const gates = new SQLiteGateRunner(db);
300
311
  const focus = new SQLiteTaskFocusStore(db);
301
312
  const events = new SQLiteTaskEventStore(db);
302
313
  const tasks = new Tasks(artifacts, gates, focus, events);
303
- const registry = handlers(artifacts, gates, tasks, events, () => migrateDb(db));
314
+ const automation = new TaskAutomationReconciler(tasks, options.automation ?? taskAutomationSettings({}));
315
+ const registry = handlers(artifacts, gates, tasks, automation, events, () => migrateDb(db));
304
316
  const state = (): SchemaState => {
305
317
  const current = schemaVersion(db);
306
318
  return { current, required: SQLITE_SCHEMA_VERSION, migrationRequired: current !== SQLITE_SCHEMA_VERSION };
@@ -311,7 +323,7 @@ export function createPapyrusService(path: string): PapyrusService {
311
323
  async execute(operation, input = {}) {
312
324
  const handler = registry[operation as OperationName];
313
325
  if (!handler) throw new UnknownOperationError(`unknown operation "${operation}"`);
314
- if (operation !== "system.migrate" && state().migrationRequired) {
326
+ if (operation !== "system.migrate" && operation !== "automation.status" && state().migrationRequired) {
315
327
  throw new MigrationRequiredError("database migration required; run `papyrus migrate task-history`");
316
328
  }
317
329
  return handler(input);
@@ -0,0 +1,188 @@
1
+ import {
2
+ TASK_AUTOMATION_ERROR_ID_MAX_LENGTH,
3
+ TASK_AUTOMATION_ERROR_MESSAGE_MAX_LENGTH,
4
+ TASK_AUTOMATION_GATE_CONCURRENCY,
5
+ TASK_AUTOMATION_HARD_MAX_RUNTIME_MS,
6
+ TASK_AUTOMATION_HARD_MAX_TASKS_PER_SWEEP,
7
+ TASK_AUTOMATION_INTERVAL_MS,
8
+ TASK_AUTOMATION_MAX_CANDIDATE_SCAN,
9
+ TASK_AUTOMATION_MAX_GATE_CONCURRENCY,
10
+ TASK_AUTOMATION_MAX_INTERVAL_MS,
11
+ TASK_AUTOMATION_MAX_RUNTIME_MS,
12
+ TASK_AUTOMATION_MAX_TASKS_PER_SWEEP,
13
+ TASK_AUTOMATION_MIN_INTERVAL_MS,
14
+ } from "./constants.ts";
15
+ import type { Artifact } from "./domain/artifact.ts";
16
+ import { projectTaskExecution } from "./task-execution.ts";
17
+ import type { Tasks } from "./task-service.ts";
18
+
19
+ export interface TaskAutomationSettings {
20
+ enabled: boolean;
21
+ intervalMs: number;
22
+ maxTasksPerSweep: number;
23
+ gateConcurrency: number;
24
+ maxRuntimeMs: number;
25
+ }
26
+
27
+ export interface TaskAutomationResult {
28
+ skipped?: "disabled" | "in-flight";
29
+ examined: number;
30
+ completed: number;
31
+ rejected: number;
32
+ started: number;
33
+ errors: Array<{ taskId: string; message: string }>;
34
+ timedOut: boolean;
35
+ }
36
+
37
+ function boundedInteger(
38
+ env: Record<string, string | undefined>,
39
+ name: string,
40
+ fallback: number,
41
+ minimum: number,
42
+ maximum: number,
43
+ ): number {
44
+ const source = env[name];
45
+ if (source === undefined || source === "") return fallback;
46
+ const value = Number(source);
47
+ if (!Number.isInteger(value) || value < minimum || value > maximum) {
48
+ throw new Error(`${name} must be an integer between ${minimum} and ${maximum}`);
49
+ }
50
+ return value;
51
+ }
52
+
53
+ export function taskAutomationSettings(env: Record<string, string | undefined> = process.env): TaskAutomationSettings {
54
+ const enabled = env["PAPYRUS_AUTOMATION_ENABLED"] === "1";
55
+ if (env["PAPYRUS_AUTOMATION_ENABLED"] !== undefined && env["PAPYRUS_AUTOMATION_ENABLED"] !== "0" && !enabled) {
56
+ throw new Error("PAPYRUS_AUTOMATION_ENABLED must be 0 or 1");
57
+ }
58
+ return {
59
+ enabled,
60
+ intervalMs: boundedInteger(env, "PAPYRUS_AUTOMATION_INTERVAL_MS", TASK_AUTOMATION_INTERVAL_MS, TASK_AUTOMATION_MIN_INTERVAL_MS, TASK_AUTOMATION_MAX_INTERVAL_MS),
61
+ maxTasksPerSweep: boundedInteger(env, "PAPYRUS_AUTOMATION_MAX_TASKS", TASK_AUTOMATION_MAX_TASKS_PER_SWEEP, 1, TASK_AUTOMATION_HARD_MAX_TASKS_PER_SWEEP),
62
+ gateConcurrency: boundedInteger(env, "PAPYRUS_AUTOMATION_GATE_CONCURRENCY", TASK_AUTOMATION_GATE_CONCURRENCY, 1, TASK_AUTOMATION_MAX_GATE_CONCURRENCY),
63
+ maxRuntimeMs: boundedInteger(env, "PAPYRUS_AUTOMATION_MAX_RUNTIME_MS", TASK_AUTOMATION_MAX_RUNTIME_MS, 1, TASK_AUTOMATION_HARD_MAX_RUNTIME_MS),
64
+ };
65
+ }
66
+
67
+ function automationEnabled(task: Artifact): boolean {
68
+ const automation = task.extra["automation"];
69
+ return typeof automation === "object"
70
+ && automation !== null
71
+ && !Array.isArray(automation)
72
+ && (automation as Record<string, unknown>)["enabled"] === true;
73
+ }
74
+
75
+ function emptyResult(skipped?: TaskAutomationResult["skipped"]): TaskAutomationResult {
76
+ return { ...(skipped ? { skipped } : {}), examined: 0, completed: 0, rejected: 0, started: 0, errors: [], timedOut: false };
77
+ }
78
+
79
+ function boundedError(taskId: string, error: unknown): TaskAutomationResult["errors"][number] {
80
+ const message = error instanceof Error ? error.message : String(error);
81
+ return {
82
+ taskId: taskId.slice(0, TASK_AUTOMATION_ERROR_ID_MAX_LENGTH),
83
+ message: message.slice(0, TASK_AUTOMATION_ERROR_MESSAGE_MAX_LENGTH),
84
+ };
85
+ }
86
+
87
+ export interface TaskAutomationScheduler {
88
+ setInterval(callback: () => void, intervalMs: number): unknown;
89
+ clearInterval(handle: unknown): void;
90
+ }
91
+
92
+ const SYSTEM_SCHEDULER: TaskAutomationScheduler = {
93
+ setInterval: (callback, intervalMs) => setInterval(callback, intervalMs),
94
+ clearInterval: (handle) => clearInterval(handle as ReturnType<typeof setInterval>),
95
+ };
96
+
97
+ export function scheduleTaskAutomation(
98
+ settings: TaskAutomationSettings,
99
+ sweep: () => Promise<unknown>,
100
+ onError: (error: unknown) => void,
101
+ scheduler: TaskAutomationScheduler = SYSTEM_SCHEDULER,
102
+ ): () => void {
103
+ if (!settings.enabled) return () => {};
104
+ const handle = scheduler.setInterval(() => { void sweep().catch(onError); }, settings.intervalMs);
105
+ return () => scheduler.clearInterval(handle);
106
+ }
107
+
108
+ export class TaskAutomationReconciler {
109
+ private inFlight = false;
110
+
111
+ constructor(
112
+ private readonly tasks: Tasks,
113
+ private readonly settings: TaskAutomationSettings,
114
+ private readonly now: () => number = () => Date.now(),
115
+ ) {}
116
+
117
+ status(): TaskAutomationSettings & { inFlight: boolean } {
118
+ return { ...this.settings, inFlight: this.inFlight };
119
+ }
120
+
121
+ async reconcile(): Promise<TaskAutomationResult> {
122
+ if (!this.settings.enabled) return emptyResult("disabled");
123
+ if (this.inFlight) return emptyResult("in-flight");
124
+ this.inFlight = true;
125
+ try { return await this.runSweep(); }
126
+ finally { this.inFlight = false; }
127
+ }
128
+
129
+ private async runSweep(): Promise<TaskAutomationResult> {
130
+ const result = emptyResult();
131
+ const deadline = this.now() + this.settings.maxRuntimeMs;
132
+ const candidates = this.tasks.list({ status: "review", limit: TASK_AUTOMATION_MAX_CANDIDATE_SCAN })
133
+ .filter(automationEnabled)
134
+ .sort((left, right) => left.id.localeCompare(right.id))
135
+ .slice(0, this.settings.maxTasksPerSweep);
136
+ const completedIds = new Set<string>();
137
+
138
+ for (let offset = 0; offset < candidates.length; offset += this.settings.gateConcurrency) {
139
+ if (this.now() >= deadline) { result.timedOut = true; break; }
140
+ const batch = candidates.slice(offset, offset + this.settings.gateConcurrency);
141
+ await Promise.all(batch.map(async (task) => {
142
+ result.examined += 1;
143
+ try {
144
+ const completion = await this.tasks.completeAsync(task.id, {
145
+ actor: "daemon",
146
+ source: "automation-reconciler",
147
+ reason: "automation-enabled review reconciliation",
148
+ }, { focusSuccessor: false, gateDeadlineMs: deadline });
149
+ if (completion.completed) {
150
+ result.completed += 1;
151
+ completedIds.add(task.id);
152
+ } else result.rejected += 1;
153
+ } catch (error) {
154
+ result.errors.push(boundedError(task.id, error));
155
+ }
156
+ }));
157
+ }
158
+
159
+ let remaining = Math.max(0, this.settings.maxTasksPerSweep - result.examined);
160
+ if (remaining > 0 && completedIds.size > 0 && this.now() < deadline) {
161
+ let graph: ReturnType<Tasks["graph"]>;
162
+ try { graph = this.tasks.graph(); }
163
+ catch (error) {
164
+ result.errors.push(boundedError("graph", error));
165
+ return result;
166
+ }
167
+ const stateById = new Map(projectTaskExecution(graph).nodes.map((node) => [node.id, node.state]));
168
+ for (const node of [...graph.nodes].sort((left, right) => left.task.id.localeCompare(right.task.id))) {
169
+ if (remaining === 0 || this.now() >= deadline) break;
170
+ if (node.task.status !== "todo" || !automationEnabled(node.task) || stateById.get(node.task.id) !== "ready") continue;
171
+ if (!node.dependencyIds.some((id) => completedIds.has(id))) continue;
172
+ try {
173
+ this.tasks.transition(node.task.id, "start", {
174
+ actor: "daemon",
175
+ source: "automation-reconciler",
176
+ reason: "automation-enabled successor became ready",
177
+ });
178
+ result.started += 1;
179
+ remaining -= 1;
180
+ } catch (error) {
181
+ result.errors.push(boundedError(node.task.id, error));
182
+ }
183
+ }
184
+ }
185
+ if (this.now() >= deadline) result.timedOut = true;
186
+ return result;
187
+ }
188
+ }
@@ -46,6 +46,11 @@ export interface ChecklistReview {
46
46
  reason?: string;
47
47
  }
48
48
 
49
+ export interface TaskCompletionOptions {
50
+ focusSuccessor?: boolean;
51
+ gateDeadlineMs?: number;
52
+ }
53
+
49
54
  export interface TaskCompletion {
50
55
  artifact: Artifact;
51
56
  gates: GateResult[];
@@ -215,23 +220,23 @@ export class Tasks {
215
220
  });
216
221
  }
217
222
 
218
- complete(id: string, context: TaskEventContext = {}): TaskCompletion {
223
+ complete(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): TaskCompletion {
219
224
  const task = this.requireReview(id);
220
225
  const attemptId = crypto.randomUUID();
221
226
  this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
222
227
  const checklist = this.reviewChecklist(task);
223
228
  const results = this.gates.run(id);
224
- return this.resolveCompletion(id, attemptId, results, checklist, context);
229
+ return this.resolveCompletion(id, attemptId, results, checklist, context, options);
225
230
  }
226
231
 
227
- async completeAsync(id: string, context: TaskEventContext = {}): Promise<TaskCompletion> {
232
+ async completeAsync(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): Promise<TaskCompletion> {
228
233
  const task = this.requireReview(id);
229
234
  const attemptId = crypto.randomUUID();
230
235
  this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
231
236
  const checklist = this.reviewChecklist(task);
232
- const results = await this.gates.runAsync(id);
237
+ const results = await this.gates.runAsync(id, { deadlineMs: options.gateDeadlineMs });
233
238
  this.requireReview(id);
234
- return this.resolveCompletion(id, attemptId, results, checklist, context);
239
+ return this.resolveCompletion(id, attemptId, results, checklist, context, options);
235
240
  }
236
241
 
237
242
  async runGates(id: string, context: TaskEventContext = {}): Promise<GateResult[]> {
@@ -251,6 +256,19 @@ export class Tasks {
251
256
  return this.artifacts.setExtra(id, { ...task.extra, checklist: validateChecklist(checklist) })!;
252
257
  }
253
258
 
259
+ setAutomation(id: string, enabled: boolean, context: TaskEventContext = {}): Artifact {
260
+ return this.events.atomic(() => {
261
+ const task = this.require(id);
262
+ const current = task.extra["automation"];
263
+ const automation = typeof current === "object" && current !== null && !Array.isArray(current)
264
+ ? current as Record<string, unknown>
265
+ : {};
266
+ const updated = this.artifacts.setExtra(id, { ...task.extra, automation: { ...automation, enabled } })!;
267
+ this.appendEvent({ taskId: id, type: enabled ? "automation_enabled" : "automation_disabled" }, context);
268
+ return updated;
269
+ });
270
+ }
271
+
254
272
  depend(id: string, dependencyId: string): Artifact {
255
273
  this.require(id);
256
274
  this.require(dependencyId);
@@ -345,6 +363,7 @@ export class Tasks {
345
363
  gates: GateResult[],
346
364
  checklist: ChecklistReview[],
347
365
  context: TaskEventContext,
366
+ options: TaskCompletionOptions,
348
367
  ): TaskCompletion {
349
368
  const failed = gates.some((gate) => !gate.passed) || checklist.some((item) => !item.accepted);
350
369
  if (failed) {
@@ -361,10 +380,10 @@ export class Tasks {
361
380
  return { artifact, gates, checklist, completed: false, focused: this.active(), blocked: [] };
362
381
  });
363
382
  }
364
- return this.events.atomic(() => this.finish(id, attemptId, gates, checklist, context));
383
+ return this.events.atomic(() => this.finish(id, attemptId, gates, checklist, context, options));
365
384
  }
366
385
 
367
- private finish(id: string, attemptId: string, gates: GateResult[], checklist: ChecklistReview[], context: TaskEventContext): TaskCompletion {
386
+ private finish(id: string, attemptId: string, gates: GateResult[], checklist: ChecklistReview[], context: TaskEventContext, options: TaskCompletionOptions): TaskCompletion {
368
387
  const successorIds = this.relationships(id)
369
388
  .filter((edge) => edge.relation === "depends_on" && edge.to === id)
370
389
  .map((edge) => edge.from);
@@ -392,7 +411,7 @@ export class Tasks {
392
411
  blocked.push({ artifact: successor, dependencyIds });
393
412
  continue;
394
413
  }
395
- if (!focused) {
414
+ if (options.focusSuccessor !== false && !focused) {
396
415
  this.focusStore.set(successor.id);
397
416
  focused = successor;
398
417
  }