@danypops/papyrus 0.45.3 → 0.46.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
@@ -23,6 +23,10 @@ papyrus tasks pause
23
23
  papyrus tasks unpause
24
24
  papyrus tasks complete <task-id>
25
25
 
26
+ # Resolve a human project name safely before scoped operations
27
+ papyrus tasks projects --query lector --json
28
+ papyrus tasks resolve-project Lector --json
29
+
26
30
  # Deferred human-intent inbox
27
31
  papyrus notes capture "Review release provenance later"
28
32
  papyrus notes list --json
@@ -38,6 +42,12 @@ It blocks every Papyrus push whose destination is not `DanyPops/papyrus`, includ
38
42
 
39
43
  The daemon uses WAL, foreign keys, a bounded busy timeout, versioned migrations, periodic passive checkpoints, and periodic `PRAGMA optimize`. Keep the database on a local filesystem; SQLite WAL does not support network filesystems.
40
44
 
45
+ Task project names are explicit registrations, never ambient-directory guesses. `tasks.projects` searches bounded registered identities; `tasks.resolve_project` requires one case-insensitive exact id, name, alias, or canonical root and fails on unknown or ambiguous references. Pass its returned `projectRoot` as `project_root` to subsequent task operations. `tasks.register_project` can rename or move an existing identity while preserving its stable id and prior name as an alias.
46
+
47
+ `tasks.create` accepts an optional `idempotency_key`. Replays with the same caller, canonical project root, key, and payload return the original response without another mutation; conflicting payload reuse is rejected. Keys are retained for seven days, isolated across callers and projects, and then expire. Retry only when reusing the exact key and payload; an unkeyed create remains unsafe to replay after an ambiguous transport failure.
48
+
49
+ Task lease responses are name-first: `tasks.claim`, `tasks.heartbeat_lease`, and `tasks.lease` return the reusable artifact alias as `taskName` plus `taskTitle`, not the backend UUID. Use `taskName` for later Task operations; retain the lease token for heartbeat or release.
50
+
41
51
  ### Context Mesh persistence model
42
52
 
43
53
  `artifacts` is the shared graph-identity supertype, not a second copy of every application's database. `edges` references that single identity table at both endpoints, preserving foreign-key integrity for cross-domain links. Domain extension tables exist only where application invariants require indexed relational state: Task chronology/focus/scope and Discourse posts/events/session cursors/projection checkpoints. This is a class-table/table-per-type variant with explicit child-to-parent foreign keys; Papyrus does not use SQLite table inheritance or orphan-prone `(target_type, target_id)` links.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.45.3",
3
+ "version": "0.46.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"],
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * Subtype/relation ownership guards (isDiscourseSubtype, NOTE_SUBTYPE, task-kind checks)
6
6
  * were previously re-implemented at every write call site across src/service.ts and
7
- * src/domain-services.ts. This is the one deep enforcement point: a claim expresses
7
+ * src/docs/docs-service.ts, src/rules/rules-service.ts, src/playbook/playbook-service.ts
8
+ * (formerly one combined src/domain-services.ts). This is the one deep enforcement point: a claim expresses
8
9
  * which module owns which artifact kind/subtype or relation and what message a
9
10
  * non-owner gets for a given action; AuthorizedArtifactWriter enforces claims for the
10
11
  * mechanical link/unlink/status paths where the target artifact's persisted kind/subtype
@@ -18,7 +18,8 @@ interface TaskContext extends CommandContext {
18
18
  }
19
19
 
20
20
  type CliTaskLease = {
21
- taskId: string;
21
+ taskName: string;
22
+ taskTitle: string;
22
23
  owner: string;
23
24
  token: string;
24
25
  claimedAt: string;
@@ -195,7 +196,11 @@ const claimCommand = buildCommand({
195
196
  ttl_ms: flags.ttlMs,
196
197
  note: flags.note,
197
198
  });
198
- render.call(this, lease, `Claimed by "${lease.owner}" until ${lease.leaseExpiresAt} (token ${lease.token}).`);
199
+ render.call(
200
+ this,
201
+ lease,
202
+ `Claimed ${lease.taskName} (${lease.taskTitle}) by "${lease.owner}" until ${lease.leaseExpiresAt} (token ${lease.token}).`,
203
+ );
199
204
  },
200
205
  parameters: {
201
206
  flags: {
@@ -216,7 +221,7 @@ const heartbeatLeaseCommand = buildCommand({
216
221
  token: flags.token,
217
222
  ttl_ms: flags.ttlMs,
218
223
  });
219
- render.call(this, lease, `Renewed until ${lease.leaseExpiresAt}.`);
224
+ render.call(this, lease, `Renewed ${lease.taskName} (${lease.taskTitle}) until ${lease.leaseExpiresAt}.`);
220
225
  },
221
226
  parameters: {
222
227
  flags: {
@@ -251,7 +256,11 @@ const releaseLeaseCommand = buildCommand({
251
256
  const leaseCommand = buildCommand({
252
257
  func: async function (this: TaskContext, _flags: Record<string, never>, id: string) {
253
258
  const lease = await this.client.call<Record<string, unknown>, CliTaskLease | null>("tasks.lease", { id });
254
- render.call(this, lease, lease ? `Leased by "${lease.owner}" until ${lease.leaseExpiresAt}.` : "No live lease.");
259
+ render.call(
260
+ this,
261
+ lease,
262
+ lease ? `${lease.taskName} (${lease.taskTitle}) is leased by "${lease.owner}" until ${lease.leaseExpiresAt}.` : "No live lease.",
263
+ );
255
264
  },
256
265
  parameters: { flags: {}, positional: { kind: "tuple", parameters: [{ brief: "Task id", parse: String, placeholder: "id" }] } },
257
266
  docs: { brief: "Show this Task's current lease" },
@@ -314,6 +323,7 @@ const createCommand = buildCommand({
314
323
  templateId?: string;
315
324
  parentId?: string;
316
325
  dependsOnJson?: string[];
326
+ idempotencyKey?: string;
317
327
  sessionId?: string;
318
328
  },
319
329
  ) {
@@ -329,6 +339,7 @@ const createCommand = buildCommand({
329
339
  parent_id: flags.parentId,
330
340
  depends_on: flags.dependsOnJson,
331
341
  project_root: this.projectRoot,
342
+ idempotency_key: flags.idempotencyKey,
332
343
  actor: "user",
333
344
  source: "cli",
334
345
  session_id: flags.sessionId,
@@ -353,6 +364,13 @@ const createCommand = buildCommand({
353
364
  placeholder: "json",
354
365
  optional: true,
355
366
  },
367
+ idempotencyKey: {
368
+ brief: "Retry key for an exact create payload",
369
+ kind: "parsed",
370
+ parse: String,
371
+ placeholder: "key",
372
+ optional: true,
373
+ },
356
374
  sessionId: {
357
375
  brief: "Attribute this creation to one agent session",
358
376
  kind: "parsed",
@@ -595,6 +613,70 @@ const historyCommand = buildCommand({
595
613
  docs: { brief: "Task's append-only lifecycle event history" },
596
614
  });
597
615
 
616
+ const projectsCommand = buildCommand({
617
+ func: async function (this: TaskContext, flags: { query?: string; limit?: number }) {
618
+ const projects = await this.client.call<Record<string, unknown>, Array<{ name: string; projectRoot: string }>>("tasks.projects", {
619
+ query: flags.query,
620
+ limit: flags.limit,
621
+ });
622
+ render.call(
623
+ this,
624
+ projects,
625
+ projects.length === 0
626
+ ? "No registered task projects."
627
+ : projects.map((project) => `${project.name} — ${project.projectRoot}`).join("\n"),
628
+ );
629
+ },
630
+ parameters: {
631
+ flags: {
632
+ query: { brief: "Filter by project name, alias, or root", kind: "parsed", parse: String, placeholder: "text", optional: true },
633
+ limit: { brief: "Maximum results", kind: "parsed", parse: numberParser, placeholder: "n", optional: true },
634
+ },
635
+ },
636
+ docs: { brief: "List registered Task project scopes" },
637
+ });
638
+
639
+ const resolveProjectCommand = buildCommand({
640
+ func: async function (this: TaskContext, _flags: Record<string, never>, name: string) {
641
+ const project = await this.client.call<Record<string, unknown>, { name: string; projectRoot: string }>("tasks.resolve_project", {
642
+ name,
643
+ });
644
+ render.call(this, project, `${project.name} — ${project.projectRoot}`);
645
+ },
646
+ parameters: {
647
+ flags: {},
648
+ positional: { kind: "tuple", parameters: [{ brief: "Project name, alias, id, or root", parse: String, placeholder: "name" }] },
649
+ },
650
+ docs: { brief: "Resolve a project reference to its canonical root" },
651
+ });
652
+
653
+ const registerProjectCommand = buildCommand({
654
+ func: async function (this: TaskContext, flags: { name?: string; aliasesJson?: string[]; project?: string }, projectRoot: string) {
655
+ const registered = await this.client.call<Record<string, unknown>, { name: string; projectRoot: string }>("tasks.register_project", {
656
+ project_root: projectRoot,
657
+ name: flags.name,
658
+ aliases: flags.aliasesJson,
659
+ project: flags.project,
660
+ });
661
+ render.call(this, registered, `${registered.name} — ${registered.projectRoot}`);
662
+ },
663
+ parameters: {
664
+ flags: {
665
+ name: { brief: "Stable project display name", kind: "parsed", parse: String, placeholder: "name", optional: true },
666
+ aliasesJson: { brief: "JSON string array of aliases", kind: "parsed", parse: parseStringArray, placeholder: "json", optional: true },
667
+ project: {
668
+ brief: "Existing project reference when renaming or moving",
669
+ kind: "parsed",
670
+ parse: String,
671
+ placeholder: "reference",
672
+ optional: true,
673
+ },
674
+ },
675
+ positional: { kind: "tuple", parameters: [{ brief: "Canonical absolute project root", parse: String, placeholder: "project-root" }] },
676
+ },
677
+ docs: { brief: "Register, rename, or move a Task project" },
678
+ });
679
+
598
680
  const scopeCommand = buildCommand({
599
681
  func: async function (this: TaskContext, _flags: Record<string, never>, mode?: string, rootTaskId?: string) {
600
682
  if (mode === undefined) {
@@ -857,6 +939,9 @@ const app = buildApplication(
857
939
  ),
858
940
  update: updateCommand,
859
941
  history: historyCommand,
942
+ projects: projectsCommand,
943
+ "resolve-project": resolveProjectCommand,
944
+ "register-project": registerProjectCommand,
860
945
  scope: scopeCommand,
861
946
  "assign-project": assignProjectCommand,
862
947
  focus: focusCommand,
package/src/constants.ts CHANGED
@@ -9,7 +9,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
9
9
  export const DAEMON_UNIT_NAME = "papyrus.service";
10
10
  export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
11
11
  export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
12
- export const SQLITE_SCHEMA_VERSION = 24;
12
+ export const SQLITE_SCHEMA_VERSION = 26;
13
13
  export const SERVICE_MAX_BODY_BYTES = 1_048_576;
14
14
 
15
15
  export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
@@ -262,6 +262,11 @@ export const TASK_SCOPE_MAX_TASKS = 1_000;
262
262
  /** Docs/Rules/Skills project scope listing bound, mirroring TASK_SCOPE_MAX_TASKS. */
263
263
  export const ARTIFACT_SCOPE_MAX_ARTIFACTS = 1_000;
264
264
  export const TASK_PROJECT_ROOT_MAX_LENGTH = 4_096;
265
+ export const TASK_PROJECT_NAME_MAX_LENGTH = 200;
266
+ export const TASK_PROJECT_ALIAS_MAX_COUNT = 20;
267
+ export const TASK_PROJECT_LIST_MAX_RESULTS = 100;
268
+ export const TASK_CREATE_IDEMPOTENCY_KEY_MAX_LENGTH = 200;
269
+ export const TASK_CREATE_IDEMPOTENCY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
265
270
  export const GRAPH_RENDER_PADDING_X = 2;
266
271
  export const GRAPH_RENDER_PADDING_Y = 1;
267
272
  export const GRAPH_RENDER_BOX_PADDING = 0;
package/src/db.ts CHANGED
@@ -1,6 +1,7 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { mkdirSync } from "node:fs";
2
3
  import { createRequire } from "node:module";
3
- import { dirname } from "node:path";
4
+ import { basename, dirname } from "node:path";
4
5
  import { runMigrations, type SqliteMigrationRunner } from "@danypops/vehicle-server/storage";
5
6
  import { generateUniqueAlias, slugify } from "./artifact/artifact-alias.ts";
6
7
  import { SQLITE_BUSY_TIMEOUT_MS, SQLITE_SCHEMA_VERSION } from "./constants.ts";
@@ -156,6 +157,25 @@ CREATE TABLE IF NOT EXISTS task_views (
156
157
  updated_at TEXT NOT NULL,
157
158
  CHECK ((mode = 'graph' AND root_task_id IS NOT NULL) OR (mode != 'graph' AND root_task_id IS NULL))
158
159
  );
160
+ CREATE TABLE IF NOT EXISTS task_projects (
161
+ id TEXT PRIMARY KEY,
162
+ name TEXT NOT NULL,
163
+ aliases_json TEXT NOT NULL DEFAULT '[]',
164
+ project_root TEXT NOT NULL UNIQUE,
165
+ created_at TEXT NOT NULL,
166
+ updated_at TEXT NOT NULL
167
+ );
168
+ CREATE INDEX IF NOT EXISTS task_projects_name_idx ON task_projects(name);
169
+ CREATE TABLE IF NOT EXISTS task_create_requests (
170
+ request_scope TEXT NOT NULL,
171
+ idempotency_key TEXT NOT NULL,
172
+ request_hash TEXT NOT NULL,
173
+ response_json TEXT NOT NULL,
174
+ created_at TEXT NOT NULL,
175
+ expires_at TEXT NOT NULL,
176
+ PRIMARY KEY (request_scope, idempotency_key)
177
+ );
178
+ CREATE INDEX IF NOT EXISTS task_create_requests_expiry_idx ON task_create_requests(expires_at);
159
179
  CREATE TABLE IF NOT EXISTS artifact_events (
160
180
  id INTEGER PRIMARY KEY AUTOINCREMENT,
161
181
  artifact_id TEXT NOT NULL REFERENCES artifacts(id),
@@ -298,6 +318,7 @@ INSERT OR IGNORE INTO statuses VALUES ('review','task');
298
318
  INSERT OR IGNORE INTO statuses VALUES ('rejected','task');
299
319
  INSERT OR IGNORE INTO statuses VALUES ('done','task');
300
320
  INSERT OR IGNORE INTO statuses VALUES ('canceled','task');
321
+ INSERT OR IGNORE INTO statuses VALUES ('draft','rule');
301
322
  INSERT OR IGNORE INTO statuses VALUES ('active','rule');
302
323
  INSERT OR IGNORE INTO statuses VALUES ('deprecated','rule');
303
324
  INSERT OR IGNORE INTO statuses VALUES ('active','playbook');
@@ -732,6 +753,52 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
732
753
  db.exec("CREATE UNIQUE INDEX IF NOT EXISTS artifacts_alias_idx ON artifacts(alias)");
733
754
  },
734
755
  },
756
+ {
757
+ version: 25,
758
+ name: "rule-draft-status",
759
+ up: (db) => {
760
+ db.exec("INSERT OR IGNORE INTO statuses SELECT 'draft','rule' WHERE EXISTS (SELECT 1 FROM kinds WHERE name = 'rule')");
761
+ },
762
+ },
763
+ {
764
+ version: 26,
765
+ name: "task-projects-and-create-idempotency",
766
+ up: (db) => {
767
+ db.exec(`
768
+ CREATE TABLE IF NOT EXISTS task_projects (
769
+ id TEXT PRIMARY KEY,
770
+ name TEXT NOT NULL,
771
+ aliases_json TEXT NOT NULL DEFAULT '[]',
772
+ project_root TEXT NOT NULL UNIQUE,
773
+ created_at TEXT NOT NULL,
774
+ updated_at TEXT NOT NULL
775
+ );
776
+ CREATE INDEX IF NOT EXISTS task_projects_name_idx ON task_projects(name);
777
+ CREATE TABLE IF NOT EXISTS task_create_requests (
778
+ request_scope TEXT NOT NULL,
779
+ idempotency_key TEXT NOT NULL,
780
+ request_hash TEXT NOT NULL,
781
+ response_json TEXT NOT NULL,
782
+ created_at TEXT NOT NULL,
783
+ expires_at TEXT NOT NULL,
784
+ PRIMARY KEY (request_scope, idempotency_key)
785
+ );
786
+ CREATE INDEX IF NOT EXISTS task_create_requests_expiry_idx ON task_create_requests(expires_at);
787
+ `);
788
+ const roots = db
789
+ .prepare("SELECT DISTINCT project_root FROM task_scopes WHERE project_root IS NOT NULL ORDER BY project_root")
790
+ .all() as Array<{
791
+ project_root: string;
792
+ }>;
793
+ const insert = db.prepare(
794
+ "INSERT OR IGNORE INTO task_projects (id, name, aliases_json, project_root, created_at, updated_at) VALUES (?, ?, '[]', ?, ?, ?)",
795
+ );
796
+ for (const row of roots) {
797
+ const now = new Date().toISOString();
798
+ insert.run(randomUUID(), basename(row.project_root) || row.project_root, row.project_root, now, now);
799
+ }
800
+ },
801
+ },
735
802
  ];
736
803
 
737
804
  /**
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Doc domain composition logic (create/list/show/transition/update/link), split out of the
3
+ * former domain-services.ts into its own per-domain file alongside rules/rules-service.ts and
4
+ * playbook/playbook-service.ts. Shared, kind-agnostic helpers live in ../domain-service-shared.ts.
5
+ */
6
+
7
+ import { type Artifact, requireLocallyOwnedContent } from "../artifact/artifact.ts";
8
+ import type { ArtifactEventContext } from "../artifact/artifact-event.ts";
9
+ import type { ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
10
+ import type { ArtifactStore } from "../artifact/artifact-store.ts";
11
+ import type { ArtifactAction, AuthorityRegistry } from "../authority-registry.ts";
12
+ import { normalizeProjectRoot } from "../domain/task-scope.ts";
13
+ import {
14
+ assertBodyBounds,
15
+ assertLabelsBounds,
16
+ assertTitleBounds,
17
+ type ListFilter,
18
+ listScoped,
19
+ requireContentUpdateFields,
20
+ requireKind,
21
+ runTransition,
22
+ type TransitionTable,
23
+ type UpdateContentInput,
24
+ } from "../domain-service-shared.ts";
25
+ import { NOTE_SUBTYPE } from "../note/note-service.ts";
26
+
27
+ function rejectsNoteTemplate(artifacts: ArtifactStore, templateId: string | undefined, subtype: string | undefined): boolean {
28
+ if (subtype === NOTE_SUBTYPE) return true;
29
+ if (!templateId) return false;
30
+ const template = artifacts.get(templateId);
31
+ const defaults = template?.extra.defaults;
32
+ return (
33
+ typeof defaults === "object" &&
34
+ defaults !== null &&
35
+ !Array.isArray(defaults) &&
36
+ (defaults as Record<string, unknown>).subtype === NOTE_SUBTYPE
37
+ );
38
+ }
39
+
40
+ /** caller never owns NOTE_SUBTYPE, so requireArtifactAllowed always throws — the trailing throw only satisfies TypeScript's control-flow analysis for a `never`-returning function. */
41
+ function requireNotesFacade(authority: AuthorityRegistry, caller: string): never {
42
+ authority.requireArtifactAllowed("doc", NOTE_SUBTYPE, "create", caller);
43
+ throw new Error("note creation requires notes.capture");
44
+ }
45
+
46
+ function templateSubtype(artifacts: ArtifactStore, templateId: string | undefined): string | undefined {
47
+ if (!templateId) return undefined;
48
+ const defaults = artifacts.get(templateId)?.extra.defaults;
49
+ if (typeof defaults !== "object" || defaults === null || Array.isArray(defaults)) return undefined;
50
+ const subtype = (defaults as Record<string, unknown>).subtype;
51
+ return typeof subtype === "string" ? subtype : undefined;
52
+ }
53
+
54
+ // No default action: linkDocument's own bug (both target and source checks silently defaulting to
55
+ // "status" here) was exactly what let a plain reference edge to a Task trip the tasks.* lifecycle
56
+ // guard, which is scoped to actual status changes only. Every call site now names its real action.
57
+ function requireMutableDocument(document: Artifact, authority: AuthorityRegistry, action: ArtifactAction): Artifact {
58
+ authority.requireArtifactAllowed(document.kind, document.subtype, action, "docs");
59
+ return document;
60
+ }
61
+
62
+ export interface CreateDocumentInput {
63
+ title: string;
64
+ body?: string;
65
+ subtype?: string;
66
+ labels?: string[];
67
+ extra?: Record<string, unknown>;
68
+ templateId?: string;
69
+ /** Optional at creation, unlike Tasks -- omitting it leaves the Doc in the unscoped bucket, matching today's default behavior for every existing caller. */
70
+ projectRoot?: string;
71
+ }
72
+
73
+ export type UpdateDocumentInput = UpdateContentInput;
74
+
75
+ export type DocumentTransition = "activate" | "archive" | "reopen";
76
+ export type DocumentRelation = "references" | "documents" | "supersedes" | "relates_to" | "contains" | "part_of";
77
+
78
+ const DOCUMENT_TRANSITIONS: TransitionTable<DocumentTransition, string> = {
79
+ activate: { from: ["draft"], to: "active" },
80
+ archive: { from: ["draft", "active"], to: "archived" },
81
+ reopen: { from: ["archived"], to: "draft" },
82
+ };
83
+
84
+ export function createDocument(
85
+ artifacts: ArtifactStore,
86
+ scopes: ArtifactScopeStore,
87
+ input: CreateDocumentInput,
88
+ authority: AuthorityRegistry,
89
+ context?: ArtifactEventContext,
90
+ ): Artifact {
91
+ if (rejectsNoteTemplate(artifacts, input.templateId, input.subtype)) requireNotesFacade(authority, "docs");
92
+ authority.requireArtifactAllowed("doc", input.subtype ?? templateSubtype(artifacts, input.templateId), "create", "docs");
93
+ const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
94
+ const document = artifacts.create(
95
+ {
96
+ kind: "doc",
97
+ // Explicit, not defaultStatusFor's "first status row by rowid" fallback -- the same
98
+ // heuristic that made Task creation non-deterministic on a migrated database. Every
99
+ // creation path that has no caller-supplied initial status must set one explicitly.
100
+ status: "draft",
101
+ title: input.title,
102
+ body: input.body,
103
+ subtype: input.subtype,
104
+ labels: input.labels,
105
+ extra: input.extra,
106
+ templateId: input.templateId,
107
+ },
108
+ context,
109
+ );
110
+ scopes.assign(document.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
111
+ return document;
112
+ }
113
+
114
+ export function listDocuments(artifacts: ArtifactStore, scopes: ArtifactScopeStore, filter: ListFilter): Artifact[] {
115
+ return listScoped(artifacts, scopes, "doc", filter, NOTE_SUBTYPE);
116
+ }
117
+
118
+ export function assignDocumentProject(
119
+ artifacts: ArtifactStore,
120
+ scopes: ArtifactScopeStore,
121
+ id: string,
122
+ projectRoot: string | undefined,
123
+ ): Artifact {
124
+ requireDocument(artifacts, id); // rejects Notes -- project reassignment for notes goes through notes.* like everything else about them
125
+ scopes.assign(
126
+ id,
127
+ projectRoot === undefined ? undefined : normalizeProjectRoot(projectRoot),
128
+ projectRoot === undefined ? "unscoped" : "explicit",
129
+ );
130
+ return artifacts.get(id)!;
131
+ }
132
+
133
+ function requireDocument(artifacts: ArtifactStore, id: string): Artifact {
134
+ const document = requireKind(artifacts, id, "doc");
135
+ if (document.subtype === NOTE_SUBTYPE) throw new Error("note access requires a notes.* operation");
136
+ return document;
137
+ }
138
+
139
+ export function showDocument(artifacts: ArtifactStore, id: string): Artifact {
140
+ requireDocument(artifacts, id);
141
+ return artifacts.get(id, { tree: true })!;
142
+ }
143
+
144
+ export function transitionDocument(
145
+ artifacts: ArtifactStore,
146
+ id: string,
147
+ action: DocumentTransition,
148
+ authority: AuthorityRegistry,
149
+ context?: ArtifactEventContext,
150
+ ): Artifact {
151
+ const document = requireLocallyOwnedContent(requireMutableDocument(requireDocument(artifacts, id), authority, "status"));
152
+ return runTransition(artifacts, document, "doc", action, DOCUMENT_TRANSITIONS, context);
153
+ }
154
+
155
+ /**
156
+ * Docs are immutable-by-convention only in the sense that no path existed to change them --
157
+ * this is that path. A read-only external projection (see requireLocallyOwnedContent) still
158
+ * refuses, on purpose: rewriting it here would silently fork from whatever system actually
159
+ * owns it (e.g. web-spider's ingested pages), with nothing to ever reconcile the two again.
160
+ */
161
+ export function updateDocument(
162
+ artifacts: ArtifactStore,
163
+ id: string,
164
+ input: UpdateDocumentInput,
165
+ authority: AuthorityRegistry,
166
+ context?: ArtifactEventContext,
167
+ ): Artifact {
168
+ requireContentUpdateFields(input);
169
+ assertTitleBounds(input.title);
170
+ assertBodyBounds(input.body);
171
+ assertLabelsBounds(input.labels);
172
+ const _document = requireLocallyOwnedContent(requireMutableDocument(requireDocument(artifacts, id), authority, "update"));
173
+ const updated = artifacts.updateContent(id, input, context);
174
+ if (!updated) throw new Error(`document "${id}" not found`);
175
+ return updated;
176
+ }
177
+
178
+ export function linkDocument(
179
+ artifacts: ArtifactStore,
180
+ id: string,
181
+ relation: DocumentRelation,
182
+ targetId: string,
183
+ authority: AuthorityRegistry,
184
+ context?: ArtifactEventContext,
185
+ ): Artifact {
186
+ requireLocallyOwnedContent(requireMutableDocument(requireDocument(artifacts, id), authority, "link"));
187
+ const target = artifacts.get(targetId);
188
+ if (!target) throw new Error(`target artifact "${targetId}" not found`);
189
+ requireLocallyOwnedContent(requireMutableDocument(target, authority, "link"));
190
+ artifacts.link({ from: id, relation, to: targetId }, context);
191
+ return showDocument(artifacts, id);
192
+ }
@@ -16,6 +16,12 @@ export interface TaskLease {
16
16
  note?: string;
17
17
  }
18
18
 
19
+ /** Name-first operation response. The UUID remains an internal lease-store key. */
20
+ export interface TaskLeaseView extends Omit<TaskLease, "taskId"> {
21
+ taskName: string;
22
+ taskTitle: string;
23
+ }
24
+
19
25
  export function validateLeaseOwner(owner: string): string {
20
26
  if (owner.length === 0 || owner.length > TASK_LEASE_OWNER_MAX_LENGTH) {
21
27
  throw new Error(`lease owner must be between 1 and ${TASK_LEASE_OWNER_MAX_LENGTH} characters`);
@@ -10,6 +10,22 @@ export interface TaskProjectScope {
10
10
  source: TaskScopeSource;
11
11
  }
12
12
 
13
+ export interface TaskProject {
14
+ id: string;
15
+ name: string;
16
+ aliases: string[];
17
+ projectRoot: string;
18
+ createdAt: string;
19
+ updatedAt: string;
20
+ }
21
+
22
+ export interface RegisterTaskProjectInput {
23
+ projectRoot: string;
24
+ name?: string;
25
+ aliases?: string[];
26
+ existingId?: string;
27
+ }
28
+
13
29
  export interface TaskViewPreference {
14
30
  projectRoot: string;
15
31
  mode: TaskViewMode;