@danypops/papyrus 0.44.5 → 0.44.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.44.5",
3
+ "version": "0.44.6",
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"],
@@ -15,6 +15,7 @@ import type { ArtifactTrashRecord } from "../domain/artifact-trash.ts";
15
15
  import {
16
16
  createArtifact,
17
17
  getArtifact,
18
+ getArtifactByAlias,
18
19
  getArtifactTrash,
19
20
  linkArtifacts,
20
21
  listArtifactTrash,
@@ -47,6 +48,10 @@ export class SQLiteArtifactStore implements AtomicArtifactStore, ArtifactTrashSt
47
48
  return getArtifact(this.db, id, options);
48
49
  }
49
50
 
51
+ getByAlias(alias: string): Artifact | null {
52
+ return getArtifactByAlias(this.db, alias);
53
+ }
54
+
50
55
  query(filter: ArtifactQuery): Artifact[] {
51
56
  return queryArtifacts(this.db, filter);
52
57
  }
package/src/cli.ts CHANGED
@@ -196,7 +196,7 @@ function usage(): never {
196
196
 
197
197
  type TaskCliClient = Pick<PapyrusClient, "call">;
198
198
  type MigrationResult = { from: number; to: number; applied: string[] };
199
- type CliArtifact = { id: string; title: string; status: string; body?: string };
199
+ type CliArtifact = { id: string; alias: string; title: string; status: string; body?: string };
200
200
  type CliTaskLease = {
201
201
  taskId: string;
202
202
  owner: string;
@@ -239,7 +239,7 @@ function parseJsonAnyFlag(value: string | undefined, flag: string): unknown {
239
239
  }
240
240
 
241
241
  function artifactLabel(artifact: CliArtifact): string {
242
- return `${artifact.id} ${artifact.title}`;
242
+ return `${artifact.alias} ${artifact.title}`;
243
243
  }
244
244
 
245
245
  function planText(plan: TaskExecutionPlan): string {
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 = 23;
12
+ export const SQLITE_SCHEMA_VERSION = 24;
13
13
  export const SERVICE_MAX_BODY_BYTES = 1_048_576;
14
14
 
15
15
  export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
package/src/db.ts CHANGED
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
3
3
  import { dirname } from "node:path";
4
4
  import { runMigrations, type SqliteMigrationRunner } from "@danypops/vehicle-server/storage";
5
5
  import { SQLITE_BUSY_TIMEOUT_MS, SQLITE_SCHEMA_VERSION } from "./constants.ts";
6
+ import { generateUniqueAlias, slugify } from "./domain/artifact-alias.ts";
6
7
 
7
8
  const require_ = createRequire(import.meta.url);
8
9
  const IS_BUN = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
@@ -99,8 +100,10 @@ CREATE TABLE IF NOT EXISTS artifacts (
99
100
  extra TEXT DEFAULT '{}',
100
101
  created_at TEXT NOT NULL,
101
102
  updated_at TEXT NOT NULL,
103
+ alias TEXT,
102
104
  FOREIGN KEY (kind, status) REFERENCES statuses(kind, name)
103
105
  );
106
+ CREATE UNIQUE INDEX IF NOT EXISTS artifacts_alias_idx ON artifacts(alias);
104
107
  CREATE TABLE IF NOT EXISTS edges (
105
108
  from_id TEXT NOT NULL REFERENCES artifacts(id),
106
109
  relation TEXT NOT NULL REFERENCES relation_names(name),
@@ -702,6 +705,33 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
702
705
  `);
703
706
  },
704
707
  },
708
+ {
709
+ version: 24,
710
+ name: "artifact-aliases",
711
+ // See domain/artifact-alias.ts. A real column + unique index, backfilled here rather than
712
+ // computed lazily on read -- IF NOT EXISTS/guarded column add throughout since a fixture
713
+ // that bootstraps from the current SCHEMA text (which already declares alias) and then only
714
+ // fakes an older user_version to exercise this migration path must not fail with "duplicate
715
+ // column name", the same class of concern version 16's comment covers.
716
+ up: (db) => {
717
+ const existing = new Set((db.prepare("PRAGMA table_info(artifacts)").all() as Array<{ name: string }>).map((row) => row.name));
718
+ if (!existing.has("alias")) db.exec("ALTER TABLE artifacts ADD COLUMN alias TEXT");
719
+ const rows = db.prepare("SELECT id, title FROM artifacts WHERE alias IS NULL ORDER BY created_at, id").all() as Array<{
720
+ id: string;
721
+ title: string;
722
+ }>;
723
+ const taken = new Set(
724
+ (db.prepare("SELECT alias FROM artifacts WHERE alias IS NOT NULL").all() as Array<{ alias: string }>).map((row) => row.alias),
725
+ );
726
+ const update = db.prepare("UPDATE artifacts SET alias = ? WHERE id = ?");
727
+ for (const row of rows) {
728
+ const alias = generateUniqueAlias(slugify(row.title), (candidate) => taken.has(candidate));
729
+ taken.add(alias);
730
+ update.run(alias, row.id);
731
+ }
732
+ db.exec("CREATE UNIQUE INDEX IF NOT EXISTS artifacts_alias_idx ON artifacts(alias)");
733
+ },
734
+ },
705
735
  ];
706
736
 
707
737
  /**
@@ -0,0 +1,36 @@
1
+ /**
2
+ * A short, globally-unique, human/agent-typeable name for an artifact -- alongside its
3
+ * opaque UUID identity (ops.ts's createArtifact), not instead of it. Derived from title by
4
+ * default so the alias stays recognizable, but title itself is free-form prose and not
5
+ * unique -- this module owns turning that into something safe to type, remember, and index.
6
+ */
7
+
8
+ const MAX_ALIAS_LENGTH = 50;
9
+ const FALLBACK_BASE = "artifact";
10
+ const ALIAS_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
11
+
12
+ /** Lowercased, hyphen-separated, ASCII-alphanumeric-only; never empty (falls back to a generic base). */
13
+ export function slugify(title: string): string {
14
+ const slug = title
15
+ .toLowerCase()
16
+ .replace(/'/g, "")
17
+ .replace(/[^a-z0-9]+/g, "-")
18
+ .replace(/^-+|-+$/g, "")
19
+ .slice(0, MAX_ALIAS_LENGTH)
20
+ .replace(/-+$/g, "");
21
+ return slug.length > 0 ? slug : FALLBACK_BASE;
22
+ }
23
+
24
+ /** Appends the lowest available "-N" suffix (starting at 2) until `isTaken` reports free. */
25
+ export function generateUniqueAlias(base: string, isTaken: (candidate: string) => boolean): string {
26
+ if (!isTaken(base)) return base;
27
+ for (let suffix = 2; ; suffix++) {
28
+ const candidate = `${base}-${suffix}`;
29
+ if (!isTaken(candidate)) return candidate;
30
+ }
31
+ }
32
+
33
+ /** Format an explicit caller-supplied alias must satisfy -- the same shape generateUniqueAlias always produces. */
34
+ export function isValidAlias(alias: string): boolean {
35
+ return alias.length > 0 && alias.length <= MAX_ALIAS_LENGTH && ALIAS_PATTERN.test(alias);
36
+ }
@@ -15,6 +15,8 @@ export interface Artifact {
15
15
  extra: Record<string, unknown>;
16
16
  created_at: string;
17
17
  updated_at: string;
18
+ /** Short, globally-unique, human/agent-typeable name -- see domain/artifact-alias.ts. Always present once created; the id remains the true backend identity. */
19
+ alias: string;
18
20
  edges?: ArtifactEdge[];
19
21
  }
20
22
 
@@ -28,12 +30,15 @@ export interface CreateArtifactInput {
28
30
  id?: string;
29
31
  subtype?: string;
30
32
  templateId?: string;
33
+ /** Overrides the title-derived auto-generated alias -- must be unique and match isValidAlias's format, or creation throws a real conflict/validation error. */
34
+ alias?: string;
31
35
  }
32
36
 
33
37
  export interface UpdateArtifactInput {
34
38
  title?: string;
35
39
  body?: string;
36
40
  labels?: string[];
41
+ alias?: string;
37
42
  }
38
43
 
39
44
  export interface ArtifactQuery {
package/src/ops.ts CHANGED
@@ -7,6 +7,7 @@ import { ARTIFACT_TRASH_RETENTION_MS, DEFAULT_STATUS_BY_KIND } from "./constants
7
7
  import type { Db } from "./db.ts";
8
8
  import { inTransaction } from "./db.ts";
9
9
  import type { Artifact, ArtifactQuery, CreateArtifactInput, UpdateArtifactInput } from "./domain/artifact.ts";
10
+ import { generateUniqueAlias, isValidAlias, slugify } from "./domain/artifact-alias.ts";
10
11
  import type { ArtifactTrashRecord } from "./domain/artifact-trash.ts";
11
12
 
12
13
  export type { ArtifactTrashRecord } from "./domain/artifact-trash.ts";
@@ -131,9 +132,27 @@ function rowToArtifact(row: Record<string, unknown>): Artifact {
131
132
  extra: JSON.parse((row.extra as string) ?? "{}"),
132
133
  created_at: row.created_at as string,
133
134
  updated_at: row.updated_at as string,
135
+ alias: row.alias as string,
134
136
  };
135
137
  }
136
138
 
139
+ function isAliasTaken(db: Db, alias: string, excludeId?: string): boolean {
140
+ const row = excludeId
141
+ ? db.prepare("SELECT 1 FROM artifacts WHERE alias = ? AND id != ?").get(alias, excludeId)
142
+ : db.prepare("SELECT 1 FROM artifacts WHERE alias = ?").get(alias);
143
+ return row != null;
144
+ }
145
+
146
+ /** Auto-generates a unique alias from title, or validates+reserves an explicit override. Throws a real conflict/validation error rather than silently suffixing a caller-chosen alias. */
147
+ function resolveAlias(db: Db, title: string, explicit: string | undefined, excludeId?: string): string {
148
+ if (explicit === undefined) return generateUniqueAlias(slugify(title), (candidate) => isAliasTaken(db, candidate, excludeId));
149
+ if (!isValidAlias(explicit)) {
150
+ throw new Error(`"${explicit}" is not a valid alias -- lowercase letters, digits, and single hyphens only, max 50 characters`);
151
+ }
152
+ if (isAliasTaken(db, explicit, excludeId)) throw new Error(`alias "${explicit}" is already taken by another artifact`);
153
+ return explicit;
154
+ }
155
+
137
156
  /**
138
157
  * Appends one immutable row to the generic, kind-agnostic mutation event log.
139
158
  * This is the one choke point every ArtifactStore mutation funnels through, so every
@@ -267,15 +286,21 @@ export function createArtifact(db: Db, input: CreateInput, context?: ArtifactEve
267
286
  const extra = JSON.stringify(resolved.extra ?? {});
268
287
  const subtype = resolved.subtype ?? "";
269
288
  inTransaction(db, () => {
289
+ const alias = resolveAlias(db, resolved.title, resolved.alias);
270
290
  const stmt = db.prepare(
271
- "INSERT INTO artifacts (id, kind, title, status, subtype, body, labels, extra, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
291
+ "INSERT INTO artifacts (id, kind, title, status, subtype, body, labels, extra, created_at, updated_at, alias) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
272
292
  );
273
- stmt.run(id, resolved.kind, resolved.title, status, subtype, resolved.body ?? "", labels, extra, now, now);
293
+ stmt.run(id, resolved.kind, resolved.title, status, subtype, resolved.body ?? "", labels, extra, now, now, alias);
274
294
  appendArtifactEvent(db, { artifactId: id, type: "created", toStatus: status, ...context });
275
295
  });
276
296
  return getArtifact(db, id)!;
277
297
  }
278
298
 
299
+ export function getArtifactByAlias(db: Db, alias: string): Artifact | null {
300
+ const row = db.prepare("SELECT * FROM artifacts WHERE alias = ?").get(alias) as Record<string, unknown> | null;
301
+ return row ? rowToArtifact(row) : null;
302
+ }
303
+
279
304
  export function getArtifact(db: Db, id: string, opts?: { tree?: boolean; depth?: number; maxNodes?: number }): Artifact | null {
280
305
  const row = db.prepare("SELECT * FROM artifacts WHERE id = ?").get(id) as Record<string, unknown> | null;
281
306
  if (!row) return null;
@@ -517,11 +542,13 @@ export function updateArtifactContent(db: Db, id: string, input: UpdateArtifactI
517
542
  if (!artifact) return null;
518
543
  const now = new Date().toISOString();
519
544
  inTransaction(db, () => {
520
- db.prepare("UPDATE artifacts SET title = ?, body = ?, labels = ?, updated_at = ? WHERE id = ?").run(
545
+ const alias = input.alias === undefined ? artifact.alias : resolveAlias(db, input.title ?? artifact.title, input.alias, id);
546
+ db.prepare("UPDATE artifacts SET title = ?, body = ?, labels = ?, updated_at = ?, alias = ? WHERE id = ?").run(
521
547
  input.title ?? artifact.title,
522
548
  input.body ?? artifact.body,
523
549
  JSON.stringify(input.labels ?? artifact.labels),
524
550
  now,
551
+ alias,
525
552
  id,
526
553
  );
527
554
  appendArtifactEvent(db, { artifactId: id, type: "updated", ...context });
@@ -16,6 +16,8 @@ import type { ArtifactEventContext } from "../domain/artifact-event.ts";
16
16
  export interface ArtifactStore {
17
17
  create(input: CreateArtifactInput, context?: ArtifactEventContext): Artifact;
18
18
  get(id: string, options?: ArtifactGraphOptions): Artifact | null;
19
+ /** Exact, globally-unique lookup by alias (domain/artifact-alias.ts) -- unlike title, never ambiguous, never scoped. */
20
+ getByAlias(alias: string): Artifact | null;
19
21
  query(filter: ArtifactQuery): Artifact[];
20
22
  link(link: ArtifactLink, context?: ArtifactEventContext): void;
21
23
  /** Idempotent: removing an already-absent relationship is a no-op that returns false, not an error. */
@@ -146,7 +146,7 @@ export function matchArtifactByName(candidates: readonly Artifact[], name: strin
146
146
  if (matches.length > 1) {
147
147
  throw new VehicleError(
148
148
  "artifact-name-ambiguous",
149
- `${matches.length} artifacts are named "${name}": ${matches.map((a) => `${a.title} (${a.id})`).join(", ")} -- use id to disambiguate`,
149
+ `${matches.length} artifacts are named "${name}": ${matches.map((a) => `${a.title} (${a.alias})`).join(", ")} -- use id or alias to disambiguate`,
150
150
  { category: "conflict" },
151
151
  );
152
152
  }
@@ -154,16 +154,21 @@ export function matchArtifactByName(candidates: readonly Artifact[], name: strin
154
154
  }
155
155
 
156
156
  /**
157
- * Resolves a name to an id, retrying against `fetchWidened` (an unscoped/cross-project
157
+ * Resolves a name to an id. Checks `artifacts.getByAlias` first -- a real, indexed,
158
+ * globally-unique match, unlike title -- before falling back to today's scoped
159
+ * title-based matching, retrying against `fetchWidened` (an unscoped/cross-project
158
160
  * search) only when `fetchCandidates` finds nothing. Owns the match-or-widen control
159
161
  * flow only -- the caller supplies its own scoped/widened list calls, since scoping
160
162
  * differs per domain. Omit `fetchWidened` when there is no wider scope to retry.
161
163
  */
162
164
  export function resolveArtifactIdWidened(
165
+ artifacts: ArtifactStore,
163
166
  name: string,
164
167
  fetchCandidates: () => readonly Artifact[],
165
168
  fetchWidened?: () => readonly Artifact[],
166
169
  ): string {
170
+ const byAlias = artifacts.getByAlias(name.trim());
171
+ if (byAlias) return byAlias.id;
167
172
  try {
168
173
  return matchArtifactByName(fetchCandidates(), name);
169
174
  } catch (error) {
@@ -172,18 +177,11 @@ export function resolveArtifactIdWidened(
172
177
  }
173
178
  }
174
179
 
175
- /** Synchronous equivalent of pi-papyrus's own artifactLabelsById -- server-side, a direct ArtifactStore.get() replaces the extra RPC round-trip that helper needed client-side. Disambiguates same-titled artifacts by appending their id. */
180
+ /** Synchronous equivalent of pi-papyrus's own artifactLabelsById -- server-side, a direct ArtifactStore.get() replaces the extra RPC round-trip that helper needed client-side. Always suffixes the alias -- a short, meaningful, globally-unique reference, unlike the raw UUID it replaces. */
176
181
  export function labelsById(artifacts: ArtifactStore, ids: readonly string[]): Map<string, string> {
177
182
  const uniqueIds = [...new Set(ids)];
178
183
  const resolved = uniqueIds.map((id) => artifacts.get(id)).filter((artifact): artifact is Artifact => artifact !== null);
179
- const titleCounts = new Map<string, number>();
180
- for (const artifact of resolved) titleCounts.set(artifact.title, (titleCounts.get(artifact.title) ?? 0) + 1);
181
- return new Map(
182
- resolved.map((artifact) => [
183
- artifact.id,
184
- (titleCounts.get(artifact.title) ?? 0) > 1 ? `${artifact.title} (${artifact.id})` : artifact.title,
185
- ]),
186
- );
184
+ return new Map(resolved.map((artifact) => [artifact.id, `${artifact.title} (${artifact.alias})`]));
187
185
  }
188
186
 
189
187
  export interface WorkflowRunNarrativeInput {
@@ -49,10 +49,10 @@ function roundsTranscript(rounds: readonly DiscussionRound[]): string {
49
49
  * so there is no widened retry to attempt -- one unscoped candidate set is
50
50
  * the whole search space already.
51
51
  */
52
- function resolveDiscussionId(discussions: Discussions, id: unknown, name: unknown): string {
52
+ function resolveDiscussionId(artifacts: ArtifactStore, discussions: Discussions, id: unknown, name: unknown): string {
53
53
  if (typeof id === "string" && id.length > 0) return id;
54
54
  if (typeof name !== "string" || name.length === 0) throw validationError("id or name is required");
55
- return resolveArtifactIdWidened(name, () => discussions.list({}));
55
+ return resolveArtifactIdWidened(artifacts, name, () => discussions.list({}));
56
56
  }
57
57
 
58
58
  /**
@@ -65,7 +65,7 @@ function resolveDiscussionId(discussions: Discussions, id: unknown, name: unknow
65
65
  function resolveRealTaskId(artifacts: ArtifactStore, id: unknown, name: unknown): string | undefined {
66
66
  if (typeof id === "string" && id.length > 0) return id;
67
67
  if (typeof name !== "string" || name.length === 0) return undefined;
68
- return resolveArtifactIdWidened(name, () => artifacts.query({ kind: "task", excludeSubtype: DISCUSSION_SUBTYPE, text: name }));
68
+ return resolveArtifactIdWidened(artifacts, name, () => artifacts.query({ kind: "task", excludeSubtype: DISCUSSION_SUBTYPE, text: name }));
69
69
  }
70
70
 
71
71
  function resolveRealTaskIds(artifacts: ArtifactStore, ids: unknown, names: unknown): string[] | undefined {
@@ -204,7 +204,7 @@ export function registerDiscussVehicleOperations(registry: VehicleRegistry, disc
204
204
  (input) => {
205
205
  normalizeOptions(input);
206
206
  defaultActorToAgent(input);
207
- return { ...input, id: resolveDiscussionId(discussions, input.id, input.name) };
207
+ return { ...input, id: resolveDiscussionId(artifacts, discussions, input.id, input.name) };
208
208
  },
209
209
  (raw) => {
210
210
  const result = raw as DiscussionAndRounds;
@@ -221,7 +221,7 @@ export function registerDiscussVehicleOperations(registry: VehicleRegistry, disc
221
221
  "local-write",
222
222
  { id: stringProp, name: stringProp, reason: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
223
223
  [],
224
- (input) => ({ ...input, id: resolveDiscussionId(discussions, input.id, input.name) }),
224
+ (input) => ({ ...input, id: resolveDiscussionId(artifacts, discussions, input.id, input.name) }),
225
225
  (raw) => {
226
226
  const artifact = raw as Artifact;
227
227
  return { ...artifact, content: [contentBlock(artifactLine(artifact))] };
@@ -234,7 +234,7 @@ export function registerDiscussVehicleOperations(registry: VehicleRegistry, disc
234
234
  "local-write",
235
235
  { id: stringProp, name: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
236
236
  [],
237
- (input) => ({ ...input, id: resolveDiscussionId(discussions, input.id, input.name) }),
237
+ (input) => ({ ...input, id: resolveDiscussionId(artifacts, discussions, input.id, input.name) }),
238
238
  (raw) => {
239
239
  const artifact = raw as Artifact;
240
240
  return { ...artifact, content: [contentBlock(artifactLine(artifact))] };
@@ -247,7 +247,7 @@ export function registerDiscussVehicleOperations(registry: VehicleRegistry, disc
247
247
  "local-write",
248
248
  { id: stringProp, name: stringProp, settlement: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
249
249
  ["settlement"],
250
- (input) => ({ ...input, id: resolveDiscussionId(discussions, input.id, input.name) }),
250
+ (input) => ({ ...input, id: resolveDiscussionId(artifacts, discussions, input.id, input.name) }),
251
251
  (raw) => {
252
252
  const artifact = raw as Artifact;
253
253
  return { ...artifact, content: [contentBlock(artifactLine(artifact))] };
@@ -269,7 +269,7 @@ export function registerDiscussVehicleOperations(registry: VehicleRegistry, disc
269
269
  },
270
270
  [],
271
271
  (input) => {
272
- const discussionId = resolveDiscussionId(discussions, input.id, input.name);
272
+ const discussionId = resolveDiscussionId(artifacts, discussions, input.id, input.name);
273
273
  const taskId = resolveRealTaskId(artifacts, input.task_id, input.task_name);
274
274
  if (!taskId) throw validationError("task_id or task_name is required");
275
275
  return { ...input, id: discussionId, task_id: taskId };
@@ -297,7 +297,7 @@ export function registerDiscussVehicleOperations(registry: VehicleRegistry, disc
297
297
  },
298
298
  [],
299
299
  (input) => {
300
- const discussionId = resolveDiscussionId(discussions, input.id, input.name);
300
+ const discussionId = resolveDiscussionId(artifacts, discussions, input.id, input.name);
301
301
  const taskId = resolveRealTaskId(artifacts, input.task_id, input.task_name);
302
302
  if (!taskId) throw validationError("task_id or task_name is required");
303
303
  return { ...input, id: discussionId, task_id: taskId };
@@ -319,7 +319,7 @@ export function registerDiscussVehicleOperations(registry: VehicleRegistry, disc
319
319
  "read",
320
320
  { id: stringProp, name: stringProp },
321
321
  [],
322
- (input) => ({ ...input, id: resolveDiscussionId(discussions, input.id, input.name) }),
322
+ (input) => ({ ...input, id: resolveDiscussionId(artifacts, discussions, input.id, input.name) }),
323
323
  (raw) => {
324
324
  const result = raw as DiscussionAndRounds;
325
325
  return { ...result, content: [contentBlock(`${artifactLine(result.discussion)}\n\n${roundsTranscript(result.rounds)}`)] };
@@ -332,7 +332,7 @@ export function registerDiscussVehicleOperations(registry: VehicleRegistry, disc
332
332
  "read",
333
333
  { id: stringProp, name: stringProp, after_round: numberProp, limit: numberProp },
334
334
  [],
335
- (input) => ({ ...input, id: resolveDiscussionId(discussions, input.id, input.name) }),
335
+ (input) => ({ ...input, id: resolveDiscussionId(artifacts, discussions, input.id, input.name) }),
336
336
  (raw) => {
337
337
  const rounds = raw as DiscussionRound[];
338
338
  return { rounds, content: [contentBlock(roundsTranscript(rounds))] };
@@ -32,6 +32,7 @@ function resolveDocId(
32
32
  if (typeof id === "string" && id.length > 0) return id;
33
33
  if (typeof name !== "string" || name.length === 0) throw validationError("id or name is required");
34
34
  return resolveArtifactIdWidened(
35
+ artifacts,
35
36
  name,
36
37
  () => listDocuments(artifacts, scopes, { text: name, projectRoot }),
37
38
  projectRoot === undefined ? undefined : () => listDocuments(artifacts, scopes, { text: name }),
@@ -42,7 +43,7 @@ function resolveDocId(
42
43
  function resolveTargetId(artifacts: ArtifactStore, id: unknown, name: unknown): string {
43
44
  if (typeof id === "string" && id.length > 0) return id;
44
45
  if (typeof name !== "string" || name.length === 0) throw validationError("target_id or target_name is required");
45
- return resolveArtifactIdWidened(name, () => artifacts.query({ text: name }));
46
+ return resolveArtifactIdWidened(artifacts, name, () => artifacts.query({ text: name }));
46
47
  }
47
48
 
48
49
  export function registerDocsVehicleOperations(
@@ -26,17 +26,17 @@ const OWNER = "notes";
26
26
  const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
27
27
 
28
28
  /** Resolves a note's id from either an explicit id or its title within projectRoot. */
29
- function resolveNoteId(notes: Notes, projectRoot: string, id: unknown, name: unknown): string {
29
+ function resolveNoteId(artifacts: ArtifactStore, notes: Notes, projectRoot: string, id: unknown, name: unknown): string {
30
30
  if (typeof id === "string" && id.length > 0) return id;
31
31
  if (typeof name !== "string" || name.length === 0) throw validationError("id or name is required");
32
- return resolveArtifactIdWidened(name, () => notes.list({ projectRoot, text: name }));
32
+ return resolveArtifactIdWidened(artifacts, name, () => notes.list({ projectRoot, text: name }));
33
33
  }
34
34
 
35
35
  /** Cross-kind equivalent for a promotion target -- a target can be a task, doc, rule, or playbook, not just a note. Unscoped by project, matching the exact behavior of the artifact.query-backed resolution it replaces. */
36
36
  function resolveArtifactId(artifacts: ArtifactStore, id: unknown, name: unknown): string {
37
37
  if (typeof id === "string" && id.length > 0) return id;
38
38
  if (typeof name !== "string" || name.length === 0) throw validationError("target_id or target_name is required");
39
- return resolveArtifactIdWidened(name, () => artifacts.query({ text: name }));
39
+ return resolveArtifactIdWidened(artifacts, name, () => artifacts.query({ text: name }));
40
40
  }
41
41
 
42
42
  /**
@@ -100,7 +100,7 @@ export function registerNotesVehicleOperations(registry: VehicleRegistry, notes:
100
100
  "read",
101
101
  { id: stringProp, name: stringProp, project_root: stringProp },
102
102
  ["project_root"],
103
- (input) => ({ ...input, id: resolveNoteId(notes, input.project_root as string, input.id, input.name) }),
103
+ (input) => ({ ...input, id: resolveNoteId(artifacts, notes, input.project_root as string, input.id, input.name) }),
104
104
  );
105
105
 
106
106
  define(
@@ -116,7 +116,7 @@ export function registerNotesVehicleOperations(registry: VehicleRegistry, notes:
116
116
  direction: { type: "string", enum: ["asc", "desc"] },
117
117
  },
118
118
  ["project_root"],
119
- (input) => ({ ...input, id: resolveNoteId(notes, input.project_root as string, input.id, input.name) }),
119
+ (input) => ({ ...input, id: resolveNoteId(artifacts, notes, input.project_root as string, input.id, input.name) }),
120
120
  );
121
121
 
122
122
  define(
@@ -133,7 +133,7 @@ export function registerNotesVehicleOperations(registry: VehicleRegistry, notes:
133
133
  reason: stringProp,
134
134
  },
135
135
  ["project_root"],
136
- (input) => ({ ...input, id: resolveNoteId(notes, input.project_root as string, input.id, input.name) }),
136
+ (input) => ({ ...input, id: resolveNoteId(artifacts, notes, input.project_root as string, input.id, input.name) }),
137
137
  );
138
138
 
139
139
  define(
@@ -154,7 +154,7 @@ export function registerNotesVehicleOperations(registry: VehicleRegistry, notes:
154
154
  ["project_root"],
155
155
  (input) => ({
156
156
  ...input,
157
- id: resolveNoteId(notes, input.project_root as string, input.id, input.name),
157
+ id: resolveNoteId(artifacts, notes, input.project_root as string, input.id, input.name),
158
158
  target_id: resolveArtifactId(artifacts, input.target_id, input.target_name),
159
159
  }),
160
160
  );
@@ -174,6 +174,6 @@ export function registerNotesVehicleOperations(registry: VehicleRegistry, notes:
174
174
  reason: stringProp,
175
175
  },
176
176
  ["project_root", "disposition"],
177
- (input) => ({ ...input, id: resolveNoteId(notes, input.project_root as string, input.id, input.name) }),
177
+ (input) => ({ ...input, id: resolveNoteId(artifacts, notes, input.project_root as string, input.id, input.name) }),
178
178
  );
179
179
  }
@@ -59,7 +59,7 @@ export interface PlaybooksVehicleDeps {
59
59
  function resolvePlaybookId(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: unknown, name: unknown): string {
60
60
  if (typeof id === "string" && id.length > 0) return id;
61
61
  if (typeof name !== "string" || name.length === 0) throw validationError("id or name is required");
62
- return resolveArtifactIdWidened(name, () => listPlaybooks(artifacts, scopes, { text: name }));
62
+ return resolveArtifactIdWidened(artifacts, name, () => listPlaybooks(artifacts, scopes, { text: name }));
63
63
  }
64
64
 
65
65
  export function registerPlaybooksVehicleOperations(registry: VehicleRegistry, deps: PlaybooksVehicleDeps): void {
@@ -33,6 +33,7 @@ function resolveRuleId(
33
33
  if (typeof id === "string" && id.length > 0) return id;
34
34
  if (typeof name !== "string" || name.length === 0) throw validationError("id or name is required");
35
35
  return resolveArtifactIdWidened(
36
+ artifacts,
36
37
  name,
37
38
  () => listRules(artifacts, scopes, { text: name, projectRoot }),
38
39
  projectRoot === undefined ? undefined : () => listRules(artifacts, scopes, { text: name }),
@@ -47,7 +48,7 @@ function resolveRuleId(
47
48
  function resolveTaskId(artifacts: ArtifactStore, _projectRoot: string | undefined, id: unknown, name: unknown): string | undefined {
48
49
  if (typeof id === "string" && id.length > 0) return id;
49
50
  if (typeof name !== "string" || name.length === 0) return undefined;
50
- return resolveArtifactIdWidened(name, () => artifacts.query({ kind: "task", text: name }));
51
+ return resolveArtifactIdWidened(artifacts, name, () => artifacts.query({ kind: "task", text: name }));
51
52
  }
52
53
 
53
54
  export function registerRulesVehicleOperations(registry: VehicleRegistry, artifacts: ArtifactStore, scopes: ArtifactScopeStore): void {
@@ -68,6 +68,7 @@ export interface TasksVehicleDeps {
68
68
  * carried, hard-won from real cross-project depend/contain friction.
69
69
  */
70
70
  function resolveTaskId(
71
+ artifacts: ArtifactStore,
71
72
  tasks: Tasks,
72
73
  filter: { projectRoot?: string; scope?: TaskViewMode; rootTaskId?: string },
73
74
  id: unknown,
@@ -77,6 +78,7 @@ function resolveTaskId(
77
78
  if (typeof name !== "string" || name.length === 0) throw validationError("id or name is required");
78
79
  if (!filter.projectRoot) throw validationError("project_root is required when resolving a task by name");
79
80
  return resolveArtifactIdWidened(
81
+ artifacts,
80
82
  name,
81
83
  () => tasks.list({ ...filter, text: name }),
82
84
  filter.scope === undefined ? () => tasks.list({ ...filter, scope: "all", text: name }) : undefined,
@@ -84,13 +86,20 @@ function resolveTaskId(
84
86
  }
85
87
 
86
88
  /** Resolves root_task_name first and scoped to "project" only, matching the removed tool's own resolution order -- every other name lookup below must see the caller's FINAL scope/root selection, which root_task_id itself feeds into. */
87
- function resolveRootTaskId(tasks: Tasks, projectRoot: string | undefined, rootTaskId: unknown, rootTaskName: unknown): string | undefined {
89
+ function resolveRootTaskId(
90
+ artifacts: ArtifactStore,
91
+ tasks: Tasks,
92
+ projectRoot: string | undefined,
93
+ rootTaskId: unknown,
94
+ rootTaskName: unknown,
95
+ ): string | undefined {
88
96
  if (typeof rootTaskId === "string" && rootTaskId.length > 0) return rootTaskId;
89
97
  if (typeof rootTaskName !== "string" || rootTaskName.length === 0) return undefined;
90
- return resolveTaskId(tasks, { projectRoot, scope: "project" }, undefined, rootTaskName);
98
+ return resolveTaskId(artifacts, tasks, { projectRoot, scope: "project" }, undefined, rootTaskName);
91
99
  }
92
100
 
93
101
  function resolveArrayField(
102
+ artifacts: ArtifactStore,
94
103
  tasks: Tasks,
95
104
  filter: { projectRoot?: string; scope?: TaskViewMode; rootTaskId?: string },
96
105
  ids: unknown,
@@ -98,7 +107,7 @@ function resolveArrayField(
98
107
  ): string[] | undefined {
99
108
  if (Array.isArray(ids)) return ids as string[];
100
109
  if (!Array.isArray(names) || names.length === 0) return undefined;
101
- return names.map((entry) => resolveTaskId(tasks, filter, undefined, String(entry)));
110
+ return names.map((entry) => resolveTaskId(artifacts, tasks, filter, undefined, String(entry)));
102
111
  }
103
112
 
104
113
  const readSchemaProps = {
@@ -192,13 +201,13 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
192
201
  /** Shared by every action taking a single id/name: resolves root_task_name first, then name -> id against the final scope. */
193
202
  const resolveIdAndScope = (input: Record<string, unknown>): Record<string, unknown> => {
194
203
  const projectRoot = input.project_root as string | undefined;
195
- const rootTaskId = resolveRootTaskId(tasks, projectRoot, input.root_task_id, input.root_task_name);
204
+ const rootTaskId = resolveRootTaskId(artifacts, tasks, projectRoot, input.root_task_id, input.root_task_name);
196
205
  const scope = input.scope as TaskViewMode | undefined;
197
206
  const filter = { projectRoot, scope, rootTaskId };
198
207
  return {
199
208
  ...input,
200
209
  ...(rootTaskId ? { root_task_id: rootTaskId } : {}),
201
- id: resolveTaskId(tasks, filter, input.id, input.name),
210
+ id: resolveTaskId(artifacts, tasks, filter, input.id, input.name),
202
211
  };
203
212
  };
204
213
 
@@ -230,9 +239,9 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
230
239
  typeof input.parent_id === "string" && input.parent_id.length > 0
231
240
  ? input.parent_id
232
241
  : typeof input.parent_name === "string" && input.parent_name.length > 0
233
- ? resolveTaskId(tasks, filter, undefined, input.parent_name)
242
+ ? resolveTaskId(artifacts, tasks, filter, undefined, input.parent_name)
234
243
  : undefined;
235
- const dependsOn = resolveArrayField(tasks, filter, input.depends_on, input.depends_on_names);
244
+ const dependsOn = resolveArrayField(artifacts, tasks, filter, input.depends_on, input.depends_on_names);
236
245
  return { ...input, ...(parentId ? { parent_id: parentId } : {}), ...(dependsOn ? { depends_on: dependsOn } : {}) };
237
246
  },
238
247
  );
@@ -263,7 +272,7 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
263
272
  readSchemaProps,
264
273
  ["project_root"],
265
274
  (input) => {
266
- const rootTaskId = resolveRootTaskId(tasks, input.project_root as string, input.root_task_id, input.root_task_name);
275
+ const rootTaskId = resolveRootTaskId(artifacts, tasks, input.project_root as string, input.root_task_id, input.root_task_name);
267
276
  return { ...input, ...(rootTaskId ? { root_task_id: rootTaskId } : {}) };
268
277
  },
269
278
  );
@@ -275,7 +284,7 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
275
284
  readSchemaProps,
276
285
  ["project_root"],
277
286
  (input) => {
278
- const rootTaskId = resolveRootTaskId(tasks, input.project_root as string, input.root_task_id, input.root_task_name);
287
+ const rootTaskId = resolveRootTaskId(artifacts, tasks, input.project_root as string, input.root_task_id, input.root_task_name);
279
288
  return { ...input, ...(rootTaskId ? { root_task_id: rootTaskId } : {}) };
280
289
  },
281
290
  );
@@ -287,7 +296,7 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
287
296
  readSchemaProps,
288
297
  ["project_root"],
289
298
  (input) => {
290
- const rootTaskId = resolveRootTaskId(tasks, input.project_root as string, input.root_task_id, input.root_task_name);
299
+ const rootTaskId = resolveRootTaskId(artifacts, tasks, input.project_root as string, input.root_task_id, input.root_task_name);
291
300
  return { ...input, ...(rootTaskId ? { root_task_id: rootTaskId } : {}) };
292
301
  },
293
302
  (input) => {
@@ -352,7 +361,7 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
352
361
  },
353
362
  ["project_root", "scope"],
354
363
  (input) => {
355
- const rootTaskId = resolveRootTaskId(tasks, input.project_root as string, input.root_task_id, input.root_task_name);
364
+ const rootTaskId = resolveRootTaskId(artifacts, tasks, input.project_root as string, input.root_task_id, input.root_task_name);
356
365
  return { ...input, ...(rootTaskId ? { root_task_id: rootTaskId } : {}) };
357
366
  },
358
367
  );
@@ -363,7 +372,10 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
363
372
  "local-write",
364
373
  { id: stringProp, name: stringProp, project_root: stringProp, session_id: stringProp },
365
374
  ["project_root"],
366
- (input) => ({ ...input, id: resolveTaskId(tasks, { projectRoot: input.project_root as string | undefined }, input.id, input.name) }),
375
+ (input) => ({
376
+ ...input,
377
+ id: resolveTaskId(artifacts, tasks, { projectRoot: input.project_root as string | undefined }, input.id, input.name),
378
+ }),
367
379
  );
368
380
 
369
381
  define(
@@ -415,7 +427,10 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
415
427
  "Sets the active Task Focus (singular per scope) to this Task. Multiple sessions can focus the same task while only one holds its lease.",
416
428
  { id: stringProp, name: stringProp, project_root: stringProp },
417
429
  [],
418
- (input) => ({ ...input, id: resolveTaskId(tasks, { projectRoot: input.project_root as string | undefined }, input.id, input.name) }),
430
+ (input) => ({
431
+ ...input,
432
+ id: resolveTaskId(artifacts, tasks, { projectRoot: input.project_root as string | undefined }, input.id, input.name),
433
+ }),
419
434
  );
420
435
  focusOperation("pause", "Pauses the active Task Focus without clearing it.", { reason: stringProp }, [], (input) => input);
421
436
  focusOperation("unpause", "Resumes a paused Task Focus.", {}, [], (input) => input);
@@ -588,8 +603,8 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
588
603
  const filter = { projectRoot: input.project_root as string | undefined, scope: input.scope as TaskViewMode | undefined };
589
604
  return {
590
605
  ...input,
591
- id: resolveTaskId(tasks, filter, input.id, input.name),
592
- dependency_id: resolveTaskId(tasks, filter, input.dependency_id, input.dependency_name),
606
+ id: resolveTaskId(artifacts, tasks, filter, input.id, input.name),
607
+ dependency_id: resolveTaskId(artifacts, tasks, filter, input.dependency_id, input.dependency_name),
593
608
  };
594
609
  },
595
610
  );
@@ -612,8 +627,8 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
612
627
  const filter = { projectRoot: input.project_root as string | undefined, scope: input.scope as TaskViewMode | undefined };
613
628
  return {
614
629
  ...input,
615
- id: resolveTaskId(tasks, filter, input.id, input.name),
616
- dependency_id: resolveTaskId(tasks, filter, input.dependency_id, input.dependency_name),
630
+ id: resolveTaskId(artifacts, tasks, filter, input.id, input.name),
631
+ dependency_id: resolveTaskId(artifacts, tasks, filter, input.dependency_id, input.dependency_name),
617
632
  };
618
633
  },
619
634
  );
@@ -636,8 +651,8 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
636
651
  const filter = { projectRoot: input.project_root as string | undefined, scope: input.scope as TaskViewMode | undefined };
637
652
  return {
638
653
  ...input,
639
- parent_id: resolveTaskId(tasks, filter, input.parent_id, input.parent_name),
640
- child_id: resolveTaskId(tasks, filter, input.child_id, input.child_name),
654
+ parent_id: resolveTaskId(artifacts, tasks, filter, input.parent_id, input.parent_name),
655
+ child_id: resolveTaskId(artifacts, tasks, filter, input.child_id, input.child_name),
641
656
  };
642
657
  },
643
658
  );
@@ -660,8 +675,8 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
660
675
  const filter = { projectRoot: input.project_root as string | undefined, scope: input.scope as TaskViewMode | undefined };
661
676
  return {
662
677
  ...input,
663
- parent_id: resolveTaskId(tasks, filter, input.parent_id, input.parent_name),
664
- child_id: resolveTaskId(tasks, filter, input.child_id, input.child_name),
678
+ parent_id: resolveTaskId(artifacts, tasks, filter, input.parent_id, input.parent_name),
679
+ child_id: resolveTaskId(artifacts, tasks, filter, input.child_id, input.child_name),
665
680
  };
666
681
  },
667
682
  );
@@ -682,7 +697,7 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
682
697
  [],
683
698
  (input) => ({
684
699
  ...input,
685
- id: resolveTaskId(tasks, { projectRoot: input.project_root as string | undefined }, input.id, input.name),
700
+ id: resolveTaskId(artifacts, tasks, { projectRoot: input.project_root as string | undefined }, input.id, input.name),
686
701
  owner: input.owner ?? input.session_id,
687
702
  }),
688
703
  );
@@ -700,7 +715,10 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
700
715
  session_id: stringProp,
701
716
  },
702
717
  ["owner", "token"],
703
- (input) => ({ ...input, id: resolveTaskId(tasks, { projectRoot: input.project_root as string | undefined }, input.id, input.name) }),
718
+ (input) => ({
719
+ ...input,
720
+ id: resolveTaskId(artifacts, tasks, { projectRoot: input.project_root as string | undefined }, input.id, input.name),
721
+ }),
704
722
  );
705
723
  define(
706
724
  "release_lease",
@@ -708,7 +726,10 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
708
726
  "local-write",
709
727
  { id: stringProp, name: stringProp, owner: stringProp, token: stringProp, project_root: stringProp, session_id: stringProp },
710
728
  ["owner", "token"],
711
- (input) => ({ ...input, id: resolveTaskId(tasks, { projectRoot: input.project_root as string | undefined }, input.id, input.name) }),
729
+ (input) => ({
730
+ ...input,
731
+ id: resolveTaskId(artifacts, tasks, { projectRoot: input.project_root as string | undefined }, input.id, input.name),
732
+ }),
712
733
  );
713
734
  define(
714
735
  "lease",
@@ -716,7 +737,10 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
716
737
  "read",
717
738
  { id: stringProp, name: stringProp, project_root: stringProp },
718
739
  [],
719
- (input) => ({ ...input, id: resolveTaskId(tasks, { projectRoot: input.project_root as string | undefined }, input.id, input.name) }),
740
+ (input) => ({
741
+ ...input,
742
+ id: resolveTaskId(artifacts, tasks, { projectRoot: input.project_root as string | undefined }, input.id, input.name),
743
+ }),
720
744
  );
721
745
 
722
746
  define(