@danypops/papyrus 0.5.0 → 0.7.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/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;
@@ -56,12 +57,15 @@ function installService(): void {
56
57
  const USAGE = `Usage:
57
58
  papyrus serve
58
59
  papyrus service <install|start|stop|restart|status>
59
- papyrus migrate task-history [--json]
60
+ papyrus migrate task-scope [--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]
63
65
  papyrus tasks active [--json]
64
66
  papyrus tasks history <id> [--json]
67
+ papyrus tasks scope [project|all|graph <root-id>] [--json]
68
+ papyrus tasks assign-project <id> [project-root] [--json]
65
69
  papyrus tasks focus <id> [--json]
66
70
  papyrus tasks complete <id> [--json]
67
71
  papyrus tasks start <id> [--json]
@@ -69,6 +73,7 @@ const USAGE = `Usage:
69
73
  papyrus tasks reject <id> [--json]
70
74
  papyrus tasks retry <id> [--json]
71
75
  papyrus tasks cancel <id> [--json]
76
+ papyrus tasks automate <id> <on|off> [--json]
72
77
  papyrus tasks depend <id> <prerequisite-id> [--json]`;
73
78
 
74
79
  function usage(): never {
@@ -107,8 +112,8 @@ function planText(plan: TaskExecutionPlan): string {
107
112
  export async function runMigrationCli(args: string[], client: TaskCliClient): Promise<string> {
108
113
  const json = args.includes("--json");
109
114
  const positional = args.filter((arg) => arg !== "--json");
110
- if (positional.length !== 1 || positional[0] !== "task-history") {
111
- throw new Error("migrate requires exactly `task-history`");
115
+ if (positional.length !== 1 || positional[0] !== "task-scope") {
116
+ throw new Error("migrate requires exactly `task-scope`");
112
117
  }
113
118
  const result = await client.call<Record<string, never>, MigrationResult>("system.migrate", {});
114
119
  if (json) return JSON.stringify(result);
@@ -116,7 +121,21 @@ export async function runMigrationCli(args: string[], client: TaskCliClient): Pr
116
121
  return `Migrated schema ${result.from} → ${result.to}: ${result.applied.join(", ")}`;
117
122
  }
118
123
 
119
- export async function runSkillCli(args: string[], client: TaskCliClient): Promise<string> {
124
+ export async function runAutomationCli(args: string[], client: TaskCliClient): Promise<string> {
125
+ const json = args.includes("--json");
126
+ const positional = args.filter((argument) => argument !== "--json");
127
+ if (positional.length !== 1 || (positional[0] !== "status" && positional[0] !== "run")) {
128
+ throw new Error("automation requires exactly `status` or `run`");
129
+ }
130
+ if (positional[0] === "status") {
131
+ const status = await client.call<Record<string, never>, TaskAutomationSettings & { inFlight: boolean }>("automation.status", {});
132
+ return json ? JSON.stringify(status) : `Automation: ${status.enabled ? "enabled" : "disabled"} · interval ${status.intervalMs}ms · max ${status.maxTasksPerSweep} tasks · concurrency ${status.gateConcurrency}`;
133
+ }
134
+ const result = await client.call<Record<string, never>, TaskAutomationResult>("automation.reconcile", {});
135
+ 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}` : ""}`;
136
+ }
137
+
138
+ export async function runSkillCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
120
139
  const json = args.includes("--json");
121
140
  const positional: string[] = [];
122
141
  let runId: string | undefined;
@@ -143,7 +162,7 @@ export async function runSkillCli(args: string[], client: TaskCliClient): Promis
143
162
  positional.push(argument);
144
163
  }
145
164
  if (positional.length !== 2 || positional[0] !== "run") throw new Error("skills requires `run <id>`");
146
- const input: Record<string, unknown> = { id: positional[1], arguments: arguments_ };
165
+ const input: Record<string, unknown> = { id: positional[1], arguments: arguments_, project_root: projectRoot };
147
166
  if (runId) input["run_id"] = runId;
148
167
  const result = await client.call<Record<string, unknown>, {
149
168
  runId: string;
@@ -161,7 +180,7 @@ export async function runSkillCli(args: string[], client: TaskCliClient): Promis
161
180
  ].join("\n");
162
181
  }
163
182
 
164
- export async function runTaskCli(args: string[], client: TaskCliClient): Promise<string> {
183
+ export async function runTaskCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
165
184
  const json = args.includes("--json");
166
185
  const positional = args.filter((arg) => arg !== "--json");
167
186
  const [action, id, dependencyId] = positional;
@@ -170,7 +189,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
170
189
  switch (action) {
171
190
  case "active": {
172
191
  if (id) throw new Error("tasks active accepts no positional arguments");
173
- const active = await client.call<Record<string, never>, CliArtifact | null>("tasks.active", {});
192
+ const active = await client.call<Record<string, string>, CliArtifact | null>("tasks.active", { project_root: projectRoot });
174
193
  result = active;
175
194
  human = active ? `Active: ${artifactLabel(active)}` : "No active task.";
176
195
  break;
@@ -184,6 +203,37 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
184
203
  : [...page.events].reverse().map((event) => `${event.occurredAt} ${event.type} ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"} · ${event.actor}/${event.source}${event.reason ? ` · ${event.reason}` : ""}`).join("\n");
185
204
  break;
186
205
  }
206
+ case "scope": {
207
+ if (!id) {
208
+ const selection = await client.call<Record<string, string>, import("./domain/task-scope.ts").TaskViewSelection>("tasks.scope", { project_root: projectRoot });
209
+ result = selection;
210
+ human = `Task scope: ${selection.label}`;
211
+ break;
212
+ }
213
+ if (id !== "project" && id !== "all" && id !== "graph") throw new Error("tasks scope mode must be project, all, or graph");
214
+ if (id === "graph" && !dependencyId) throw new Error("tasks scope graph requires a root task id");
215
+ if (id !== "graph" && dependencyId) throw new Error(`tasks scope ${id} accepts no root task id`);
216
+ const selection = await client.call<Record<string, unknown>, import("./domain/task-scope.ts").TaskViewSelection>("tasks.set_scope", {
217
+ project_root: projectRoot,
218
+ scope: id,
219
+ ...(dependencyId ? { root_task_id: dependencyId } : {}),
220
+ });
221
+ result = selection;
222
+ human = `Task scope: ${selection.label}`;
223
+ break;
224
+ }
225
+ case "assign-project": {
226
+ if (!id || positional.length > 3) throw new Error("tasks assign-project requires a task id and optional project root");
227
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("tasks.assign_project", {
228
+ id,
229
+ project_root: dependencyId ?? projectRoot,
230
+ actor: "user",
231
+ source: "cli",
232
+ });
233
+ result = artifact;
234
+ human = `Project assigned: ${artifactLabel(artifact)}`;
235
+ break;
236
+ }
187
237
  case "focus": {
188
238
  if (!id || dependencyId) throw new Error("tasks focus requires exactly one task id");
189
239
  const active = await client.call<{ id: string }, CliArtifact>("tasks.focus", { id });
@@ -193,10 +243,10 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
193
243
  }
194
244
  case "graph": {
195
245
  if (id) throw new Error("tasks graph accepts no positional arguments");
196
- const graph = await client.call<{ limit: number }, {
246
+ const graph = await client.call<{ limit: number; project_root: string }, {
197
247
  nodes: Array<{ dependencyIds: string[]; childIds: string[] }>;
198
248
  rootIds: string[];
199
- }>("tasks.graph", { limit: TASK_EXECUTION_MAX_NODES + 1 });
249
+ }>("tasks.graph", { limit: TASK_EXECUTION_MAX_NODES + 1, project_root: projectRoot });
200
250
  result = graph;
201
251
  const dependencies = graph.nodes.reduce((count, node) => count + node.dependencyIds.length, 0);
202
252
  const children = graph.nodes.reduce((count, node) => count + node.childIds.length, 0);
@@ -205,7 +255,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
205
255
  }
206
256
  case "plan": {
207
257
  if (id) throw new Error("tasks plan accepts no positional arguments");
208
- const plan = await client.call<Record<string, never>, TaskExecutionPlan>("tasks.plan", {});
258
+ const plan = await client.call<Record<string, string>, TaskExecutionPlan>("tasks.plan", { project_root: projectRoot });
209
259
  result = plan;
210
260
  human = planText(plan);
211
261
  break;
@@ -241,6 +291,18 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
241
291
  human = `${action[0]!.toUpperCase()}${action.slice(1)}: ${artifactLabel(artifact)}`;
242
292
  break;
243
293
  }
294
+ case "automate": {
295
+ if (!id || (dependencyId !== "on" && dependencyId !== "off") || positional.length !== 3) throw new Error("tasks automate requires a task id and on or off");
296
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("tasks.set_automation", {
297
+ id,
298
+ enabled: dependencyId === "on",
299
+ actor: "user",
300
+ source: "cli",
301
+ });
302
+ result = artifact;
303
+ human = `Automation ${dependencyId}: ${artifactLabel(artifact)}`;
304
+ break;
305
+ }
244
306
  case "depend": {
245
307
  if (!id || !dependencyId || positional.length !== 3) throw new Error("tasks depend requires a task id and prerequisite id");
246
308
  const artifact = await client.call<{ id: string; dependency_id: string }, CliArtifact>("tasks.depend", {
@@ -252,7 +314,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
252
314
  break;
253
315
  }
254
316
  default:
255
- throw new Error("tasks action must be active, focus, graph, plan, history, complete, start, submit, reject, retry, cancel, or depend");
317
+ throw new Error("tasks action must be active, focus, graph, plan, history, scope, assign-project, complete, start, submit, reject, retry, cancel, automate, or depend");
256
318
  }
257
319
  return json ? JSON.stringify(result) : human;
258
320
  }
@@ -265,6 +327,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
265
327
  console.log(await runTaskCli(args.slice(1), client));
266
328
  return;
267
329
  }
330
+ if (command === "automation") {
331
+ const client = await connectPapyrusClient();
332
+ console.log(await runAutomationCli(args.slice(1), client));
333
+ return;
334
+ }
268
335
  if (command === "skills") {
269
336
  const client = await connectPapyrusClient();
270
337
  console.log(await runSkillCli(args.slice(1), client));
package/src/constants.ts CHANGED
@@ -7,7 +7,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
7
7
  export const DAEMON_UNIT_NAME = "papyrus.service";
8
8
  export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
9
9
  export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
10
- export const SQLITE_SCHEMA_VERSION = 3;
10
+ export const SQLITE_SCHEMA_VERSION = 4;
11
11
  export const SERVICE_MAX_BODY_BYTES = 1_048_576;
12
12
  export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
13
13
  export const DB_OPTIMIZE_INTERVAL_MS = 24 * 60 * 60_000;
@@ -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,22 @@ 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;
65
+ /** Persisted project and focused-graph Task view bounds. */
66
+ export const TASK_SCOPE_MAX_TASKS = 1_000;
67
+ export const TASK_PROJECT_ROOT_MAX_LENGTH = 4_096;
51
68
  export const GRAPH_RENDER_PADDING_X = 2;
52
69
  export const GRAPH_RENDER_PADDING_Y = 1;
53
70
  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
  }
package/src/db.ts CHANGED
@@ -122,6 +122,20 @@ CREATE TRIGGER IF NOT EXISTS task_events_no_update BEFORE UPDATE ON task_events
122
122
  BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
123
123
  CREATE TRIGGER IF NOT EXISTS task_events_no_delete BEFORE DELETE ON task_events
124
124
  BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
125
+ CREATE TABLE IF NOT EXISTS task_scopes (
126
+ task_id TEXT PRIMARY KEY REFERENCES artifacts(id),
127
+ project_root TEXT,
128
+ source TEXT NOT NULL CHECK (source IN ('cwd', 'explicit', 'unscoped')),
129
+ assigned_at TEXT NOT NULL
130
+ );
131
+ CREATE INDEX IF NOT EXISTS task_scopes_project_idx ON task_scopes(project_root, task_id);
132
+ CREATE TABLE IF NOT EXISTS task_views (
133
+ project_root TEXT PRIMARY KEY,
134
+ mode TEXT NOT NULL CHECK (mode IN ('project', 'graph', 'all')),
135
+ root_task_id TEXT REFERENCES artifacts(id),
136
+ updated_at TEXT NOT NULL,
137
+ CHECK ((mode = 'graph' AND root_task_id IS NOT NULL) OR (mode != 'graph' AND root_task_id IS NULL))
138
+ );
125
139
  `;
126
140
 
127
141
  const SEED_SQL = `
@@ -185,7 +199,7 @@ export function migrateDb(db: Db): MigrationResult {
185
199
  throw new Error(`database schema ${from} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
186
200
  }
187
201
  if (from === SQLITE_SCHEMA_VERSION) return { from, to: from, applied: [] };
188
- if (from !== 1 && from !== 2) throw new Error(`no explicit migration path from database schema ${from}`);
202
+ if (from !== 1 && from !== 2 && from !== 3) throw new Error(`no explicit migration path from database schema ${from}`);
189
203
  const applied: string[] = [];
190
204
 
191
205
  inTransaction(db, () => {
@@ -243,6 +257,29 @@ export function migrateDb(db: Db): MigrationResult {
243
257
  `);
244
258
  applied.push("task-history");
245
259
  }
260
+ if (schemaVersion(db) === 3) {
261
+ db.exec(`
262
+ CREATE TABLE task_scopes (
263
+ task_id TEXT PRIMARY KEY REFERENCES artifacts(id),
264
+ project_root TEXT,
265
+ source TEXT NOT NULL CHECK (source IN ('cwd', 'explicit', 'unscoped')),
266
+ assigned_at TEXT NOT NULL
267
+ );
268
+ CREATE INDEX task_scopes_project_idx ON task_scopes(project_root, task_id);
269
+ CREATE TABLE task_views (
270
+ project_root TEXT PRIMARY KEY,
271
+ mode TEXT NOT NULL CHECK (mode IN ('project', 'graph', 'all')),
272
+ root_task_id TEXT REFERENCES artifacts(id),
273
+ updated_at TEXT NOT NULL,
274
+ CHECK ((mode = 'graph' AND root_task_id IS NOT NULL) OR (mode != 'graph' AND root_task_id IS NULL))
275
+ );
276
+ INSERT INTO task_scopes (task_id, project_root, source, assigned_at)
277
+ SELECT id, NULL, 'unscoped', strftime('%Y-%m-%dT%H:%M:%fZ','now')
278
+ FROM artifacts WHERE kind = 'task';
279
+ PRAGMA user_version = 4;
280
+ `);
281
+ applied.push("task-project-scope");
282
+ }
246
283
  });
247
284
  return { from, to: schemaVersion(db), applied };
248
285
  }
@@ -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,9 @@ export const TASK_EVENT_TYPES = [
13
13
  "submitted",
14
14
  "completion_attempted",
15
15
  "gates_evaluated",
16
+ "automation_enabled",
17
+ "automation_disabled",
18
+ "project_assigned",
16
19
  "review_rejected",
17
20
  "retried",
18
21
  "completed",
@@ -0,0 +1,39 @@
1
+ import { basename, isAbsolute, normalize } from "node:path";
2
+ import { TASK_PROJECT_ROOT_MAX_LENGTH } from "../constants.ts";
3
+
4
+ export type TaskViewMode = "project" | "graph" | "all";
5
+ export type TaskScopeSource = "cwd" | "explicit" | "unscoped";
6
+
7
+ export interface TaskProjectScope {
8
+ taskId: string;
9
+ projectRoot?: string;
10
+ source: TaskScopeSource;
11
+ }
12
+
13
+ export interface TaskViewPreference {
14
+ projectRoot: string;
15
+ mode: TaskViewMode;
16
+ rootTaskId?: string;
17
+ }
18
+
19
+ export interface TaskViewSelection {
20
+ mode: TaskViewMode;
21
+ label: string;
22
+ projectRoot?: string;
23
+ rootTaskId?: string;
24
+ }
25
+
26
+ export function normalizeProjectRoot(value: string): string {
27
+ if (!isAbsolute(value)) throw new Error("project_root must be an absolute path");
28
+ const normalized = normalize(value);
29
+ if (normalized.length > TASK_PROJECT_ROOT_MAX_LENGTH) {
30
+ throw new Error(`project_root cannot exceed ${TASK_PROJECT_ROOT_MAX_LENGTH} characters`);
31
+ }
32
+ return normalized;
33
+ }
34
+
35
+ export function taskScopeLabel(mode: TaskViewMode, projectRoot?: string, rootTitle?: string): string {
36
+ if (mode === "all") return "All projects";
37
+ const project = projectRoot ? basename(projectRoot) || projectRoot : "Unscoped";
38
+ return mode === "graph" ? `${project} · ${rootTitle ?? "focused graph"}` : project;
39
+ }
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
  }
@@ -0,0 +1,40 @@
1
+ import type { TaskProjectScope, TaskScopeSource, TaskViewMode, TaskViewPreference } from "../domain/task-scope.ts";
2
+
3
+ export interface TaskScopeStore {
4
+ assign(taskId: string, projectRoot: string | undefined, source: TaskScopeSource): TaskProjectScope;
5
+ get(taskId: string): TaskProjectScope | undefined;
6
+ taskIds(projectRoot: string | undefined, limit: number): string[];
7
+ view(projectRoot: string): TaskViewPreference;
8
+ setView(projectRoot: string, mode: TaskViewMode, rootTaskId?: string): TaskViewPreference;
9
+ }
10
+
11
+ export class InMemoryTaskScopeStore implements TaskScopeStore {
12
+ private readonly scopes = new Map<string, TaskProjectScope>();
13
+ private readonly views = new Map<string, TaskViewPreference>();
14
+
15
+ assign(taskId: string, projectRoot: string | undefined, source: TaskScopeSource): TaskProjectScope {
16
+ const scope = { taskId, ...(projectRoot === undefined ? {} : { projectRoot }), source };
17
+ this.scopes.set(taskId, scope);
18
+ return scope;
19
+ }
20
+
21
+ get(taskId: string): TaskProjectScope | undefined { return this.scopes.get(taskId); }
22
+
23
+ taskIds(projectRoot: string | undefined, limit: number): string[] {
24
+ return [...this.scopes.values()]
25
+ .filter((scope) => scope.projectRoot === projectRoot)
26
+ .map((scope) => scope.taskId)
27
+ .sort()
28
+ .slice(0, limit);
29
+ }
30
+
31
+ view(projectRoot: string): TaskViewPreference {
32
+ return this.views.get(projectRoot) ?? { projectRoot, mode: "project" };
33
+ }
34
+
35
+ setView(projectRoot: string, mode: TaskViewMode, rootTaskId?: string): TaskViewPreference {
36
+ const view = { projectRoot, mode, ...(rootTaskId === undefined ? {} : { rootTaskId }) };
37
+ this.views.set(projectRoot, view);
38
+ return view;
39
+ }
40
+ }