@danypops/papyrus 0.47.1 → 0.48.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
@@ -1,29 +1,45 @@
1
1
  # @danypops/papyrus
2
2
 
3
- The daemon, CLI, and domain services behind Papyrus's graph artifact store. Installable and runnable standalone, with no Pi dependency the Pi extension lives in the sibling `@danypops/pi-papyrus` package.
3
+ The daemon, CLI, and domain services behind Papyrus's graph artifact store: evidence-bearing Tasks, Docs, Rules, Playbooks, and Notes over one authenticated local database. Runs standalone as a plain daemon and CLI. `@danypops/pi-papyrus` projects the same daemon into native Pi tools.
4
4
 
5
- ## Storage and service
5
+ ## Contents
6
6
 
7
- ```text
8
- $XDG_DATA_HOME/papyrus/papyrus.db # durable graph
9
- $XDG_RUNTIME_DIR/papyrus/{port,token} # private daemon discovery
7
+ - [Install](#install)
8
+ - [Quick start](#quick-start)
9
+ - [Schema protocol](#schema-protocol)
10
+ - [Hierarchy and traversal](#hierarchy-and-traversal)
11
+ - [Playbooks](#playbooks)
12
+ - [Naming vs. ids](#naming-vs-ids)
13
+ - [Mutability](#mutability)
14
+ - [Idempotent lifecycle mutations](#idempotent-lifecycle-mutations)
15
+ - [Removing an artifact](#removing-an-artifact)
16
+ - [Context Mesh persistence model](#context-mesh-persistence-model)
17
+ - [Storage and service](#storage-and-service)
18
+ - [Related packages](#related-packages)
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ bun add @danypops/papyrus
10
24
  ```
11
25
 
26
+ The `papyrus` binary ships with the package.
27
+
28
+ ## Quick start
29
+
12
30
  ```bash
13
- bun src/cli.ts service install # install, enable, and start user service
14
- bun src/cli.ts service status
15
- bun src/cli.ts service restart
31
+ papyrus service install # install, enable, and start the user service
32
+ papyrus service status
33
+ papyrus service restart
16
34
 
17
- # Authenticated daemon-backed Task operations (add --json for machine output)
35
+ # Task operations (add --json for machine output)
18
36
  papyrus tasks plan
19
37
  papyrus tasks depend <task-id> <prerequisite-id>
20
38
  papyrus tasks update <task-id> --title "Revised task"
21
39
  papyrus tasks focus <task-id>
22
- papyrus tasks pause
23
- papyrus tasks unpause
24
40
  papyrus tasks complete <task-id>
25
41
 
26
- # Resolve a human project name safely before scoped operations
42
+ # Resolve a registered project name before scoped operations
27
43
  papyrus tasks projects --query lector --json
28
44
  papyrus tasks resolve-project Lector --json
29
45
 
@@ -32,79 +48,90 @@ papyrus notes capture "Review release provenance later"
32
48
  papyrus notes list --json
33
49
  ```
34
50
 
35
- For repository work, install the versioned ownership guard once (from the workspace root):
51
+ ## Schema protocol
36
52
 
37
- ```bash
38
- bun run guard:install
39
- ```
53
+ Papyrus enforces four artifact kinds, each with its own status vocabulary:
40
54
 
41
- It blocks every Papyrus push whose destination is not `DanyPops/papyrus`, including explicit fallback URLs that bypass `origin`.
42
-
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.
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.
55
+ - `doc` knowledge: specifications, decisions, and research
56
+ - `task` — work: desired outcomes, gates, checklists, and dependencies
57
+ - `rule` governance injected into the Pi system prompt
58
+ - `playbook` — a trigger and an ordered list of steps whose validated arguments render a connected collection of deterministic Tasks plus contextual Rules and Docs
48
59
 
49
- Task lifecycle mutations (`start`, `submit`, `reject`, `retry`, `cancel`, `reopen`, `complete`, `pause`, and `unpause`) are destination-state idempotent: repeating an already-achieved transition is a successful `changed: false` no-op and creates no duplicate history. Supply an `idempotency_key` for every mutation whose response could be lost. After an unknown outcome, call `tasks.show` and `tasks.mutation_status` with the original key, then replay only that exact operation/key if needed—never invent a new key from stale state. Completed receipts are retained for seven days; concurrent duplicate completion calls share one gate run. A genuinely incompatible transition returns typed `invalid-transition` details with current/intended status, allowed actions, and recovery guidance.
60
+ Every edge endpoint resolves to a real artifact, and every edge relation is registered in `relation_names`. Relations are universal: any kind can link to any other kind.
50
61
 
51
- 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.
62
+ ## Hierarchy and traversal
52
63
 
53
- ### Context Mesh persistence model
64
+ `contains`/`part_of` express parent/child structure; `depends_on` expresses execution ordering. Dependency edges form an executable DAG: a self-dependency or cycle is rejected, fan-in waits for every prerequisite, and fan-out can expose several ready successors while active focus stays singular. Graph reads are cycle-safe and bounded by `depth`/`max_nodes` (default 4/100, ceiling 20/1,000). Executable task plans are bounded to 1,000 tasks and 10,000 relationships.
54
65
 
55
- `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.
66
+ ## Playbooks
56
67
 
57
- The owning application remains the mutation authority. Discourse commits its extension rows and `context-thread`/`context-message` Doc projections atomically through `discourse.store`; generic artifact, document, lifecycle, and graph-link operations reject those owned subtypes and the `reply_to`/`discusses` relations. SQLite triggers additionally verify that each extension row references the expected Doc subtype. Domain tables are canonical for domain invariants; graph bodies and metadata are read-oriented projections committed in the same transaction.
68
+ A Playbook step is a plain prose string (a Task) or a structured object: `{kind:'doc',...}` creates a Doc, `{kind:'rule',...}` creates a Rule, `{kind:'call',...}` nests another Playbook's own run as a pipeline step. `playbooks.invoke` validates and normalizes arguments, renders placeholders in memory, validates the complete graph, then persists artifacts and edges in one transaction. Dependencies, containment, gates, checklists, and context all survive rendering. A run's Rules are active only while focus belongs to that run; Docs keep their invocation context and provenance.
58
69
 
59
- The authenticated CLI exposes the same operation for diagnostics and adapter parity:
70
+ A run result carries a stable schema: Playbook id, run id, normalized arguments, created ids grouped by kind, ready root task ids, and the bounded execution plan. An explicit run id produces deterministic artifact ids (`<run-id>-<blueprint-ref>`); a collision rolls the whole run back.
60
71
 
61
72
  ```bash
62
- papyrus discourse store read_thread --store-id team-forum \
63
- --input-json '{"forumId":"engineering","topicId":"reviews","threadId":"mesh","limit":25}' \
73
+ papyrus playbooks invoke <playbook-id> \
74
+ --arguments-json '{"project":"Papyrus"}' \
64
75
  --json
65
76
  ```
66
77
 
67
- ## Schema protocol (enforceable)
78
+ ## Naming vs. ids
68
79
 
69
- Papyrus enforces four artifact kinds:
80
+ Every agent-facing domain (tasks, docs, rules, playbooks, notes, discuss) addresses artifacts by `name` (the exact title) anywhere `id` would otherwise be required — `dependency_name`/`parent_name`/`child_name`/`root_task_name`/`depends_on_names` (tasks), `target_name` (docs link, searched across every kind), `task_name` (rules gate, discuss block/unblock), and `blocks_task_names` (discuss open). Resolution is an exact, case-insensitive, trimmed title match scoped like a plain list call; an ambiguous name's error lists the real ids, the one place disambiguation needs them. A returned result leads with name and status; id surfaces only when two artifacts in the same result share a title. `id` itself keeps working, in every tool.
70
81
 
71
- - `doc` — knowledge: specifications, decisions, and research
72
- - `task` — work: desired outcomes, gates, checklists, and dependencies
73
- - `rule` governance injected into the Pi system prompt
74
- - `playbook` — a trigger and an ordered list of steps whose validated arguments render a connected collection of deterministic Tasks plus contextual Rules and Docs
82
+ ## Mutability
83
+
84
+ Tasks, Docs, Rules, and Playbooks all support `update` (title/body/labels, at least one field required) alongside creation. Every update shares creation's own bounds (Rules keep their own tighter condition+action+body ceiling) and lands on the artifact's append-only mutation history, queryable via `graph.history`. An artifact carrying a `source:<system>` label (e.g. `source:web-spider`) is a read-only projection owned by that system; `update` is refused with a clear error, and a correction belongs in a new linked Doc until that system ships its own write-back path. Notes route every content change through their own facade.
85
+
86
+ ## Idempotent lifecycle mutations
75
87
 
76
- Each kind has an enforced status vocabulary. Every edge endpoint must exist, and every edge relation must be registered in `relation_names`. Relations are universal: any artifact kind can link to any other kind.
88
+ `tasks.create` accepts an optional `idempotency_key`, scoped to the caller and canonical project root. Replaying the same key with the same payload returns the original response; a changed payload is rejected. Keys expire after seven days.
77
89
 
78
- ### Hierarchy and traversal
90
+ Task lifecycle mutations (`start`, `submit`, `reject`, `retry`, `cancel`, `reopen`, `complete`, `pause`, `unpause`) are destination-state idempotent: repeating an already-reached transition is a `changed: false` no-op with no duplicate history. Pass `idempotency_key` on any mutation whose response might get lost; after an unclear outcome, call `tasks.show` and `tasks.mutation_status` with that same key before deciding the next action. Completed receipts are retained for seven days; concurrent duplicate completion calls share one gate run. An incompatible transition returns typed `invalid-transition` details with current/intended status, allowed actions, and recovery guidance.
79
91
 
80
- Use `contains` and `part_of` for explicit parent/child structure; use `depends_on` for execution ordering. Dependency edges form an executable DAG: self-dependencies and cycles are rejected, fan-in waits for every prerequisite, and fan-out can expose several ready successors while active focus remains singular. Graph reads are cycle-safe and bounded by `depth` and `max_nodes` (defaults: depth 4, 100 nodes; hard ceilings: depth 20, 1,000 nodes). Executable task plans are additionally bounded to 1,000 tasks and 10,000 relationships.
92
+ `tasks.claim`, `tasks.heartbeat_lease`, and `tasks.lease` return the reusable artifact alias as `taskName` plus `taskTitle`; use `taskName` for later Task operations and keep the lease token for heartbeat/release.
81
93
 
82
- ### Playbooks
94
+ Task project names are registered identities, resolved explicitly rather than guessed from a working directory. `tasks.projects` searches bounded registered identities; `tasks.resolve_project` matches one case-insensitive exact id, name, alias, or canonical root, and reports unknown or ambiguous references directly. Pass the returned `projectRoot` into subsequent task operations. `tasks.register_project` renames or moves an existing identity while keeping its stable id and folding the prior name into its aliases.
83
95
 
84
- A Playbook's steps are a plain prose string (a Task), or a structured object: `{kind:'doc',...}` creates a Doc, `{kind:'rule',...}` creates a Rule, `{kind:'call',...}` nests another Playbook's own run as a pipeline step. `playbooks.invoke` validates and normalizes all arguments, safely renders placeholders in memory, validates the complete graph, then persists artifacts and edges in one transaction. Task dependencies, containment, gates, checklists, and context survive rendering. Run Rules are injected only while active focus belongs to that run. Docs retain invocation context and provenance; missing evidence references remain unknown and no gate runs during instantiation.
96
+ ## Removing an artifact
85
97
 
86
- A run result has a stable schema: Playbook ID, run ID, normalized arguments, created IDs grouped by kind, ready root task IDs, and the bounded execution plan. Explicit run IDs produce deterministic artifact IDs (`<run-id>-<blueprint-ref>`); collisions roll back the entire run.
98
+ An artifact gets a permanent, immutable `created` row in the mutation event log the moment it exists, so removal is a time-gated trash entry. `remove` (the shared `artifact.remove`/`artifact.remove_subtree` operations every domain routes through, or `papyrus artifact remove <id> [--reason <text>]`) moves an artifact to the trash: excluded from every list/query immediately, still reachable directly by id, and recoverable via `restore` for 30 days. `remove` on a Task currently holding live Focus in any scope is refused.
99
+
100
+ Past the 30-day deadline, the daemon's periodic sweep performs a real, cascading delete — the one deliberate exception to Papyrus's append-only history, enforced by a database trigger checked at delete time.
101
+
102
+ ## Context Mesh persistence model
103
+
104
+ `artifacts` is the shared graph-identity supertype; `edges` references that single identity table at both endpoints, keeping foreign-key integrity across domains. Domain extension tables exist only where application invariants need indexed relational state — Task chronology/focus/scope, Discourse posts/events/session cursors/projection checkpoints — a class-table/table-per-type layout with explicit child-to-parent foreign keys.
105
+
106
+ The owning application stays the mutation authority for its own extension rows: Discourse commits its rows and `context-thread`/`context-message` Doc projections atomically through `discourse.store`, while generic artifact/document/lifecycle/graph-link operations reject those owned subtypes and the `reply_to`/`discusses` relations. SQLite triggers verify each extension row references its expected Doc subtype. Domain tables stay canonical for domain invariants; graph bodies and metadata are read-oriented projections committed in the same transaction.
87
107
 
88
108
  ```bash
89
- papyrus playbooks invoke <playbook-id> \
90
- --arguments-json '{"project":"Papyrus"}' \
109
+ papyrus discourse store read_thread --store-id team-forum \
110
+ --input-json '{"forumId":"engineering","topicId":"reviews","threadId":"mesh","limit":25}' \
91
111
  --json
92
112
  ```
93
113
 
94
- ### Removing an artifact
114
+ ## Storage and service
95
115
 
96
- Artifacts are never hard-deleted on request: every artifact gets a permanent, immutable `created` row in the mutation event log the moment it exists, so removal is a real, time-gated trash rather than a status flip. `remove` (the shared `artifact.remove`/`artifact.remove_subtree` operations every agent-facing domain routes through, or `papyrus artifact remove <id> [--reason <text>]`) moves an artifact to the trash: it is immediately excluded from every list/query, still directly reachable by id, and fully recoverable via `restore` until its purge deadline (30 days later) passes. `remove` refuses a Task that is the live Task Focus in any scope.
116
+ ```text
117
+ $XDG_DATA_HOME/papyrus/papyrus.db # durable graph
118
+ $XDG_RUNTIME_DIR/papyrus/{port,token} # private daemon discovery
119
+ ```
120
+
121
+ The daemon runs SQLite with WAL, foreign keys, a bounded busy timeout, versioned migrations, periodic passive checkpoints, and periodic `PRAGMA optimize`. Keep the database on a local filesystem — WAL depends on real local file locking.
97
122
 
98
- Once the deadline passes, the daemon's periodic sweep performs a real, cascading, irreversible deletion the one deliberate, narrow exception to Papyrus's otherwise-absolute append-only history, enforced by the database itself (not merely application code) via a trigger condition checked at delete time.
123
+ Application services depend on the `ArtifactStore` and `GateRunner` ports; SQLite and subprocess execution are adapters the daemon composes, so task behavior is unit-tested against fakes without a database. Task visualization projects the same `TaskGraph` into semantic display graphs through a `GraphRenderer` port the Pi adapter in `@danypops/pi-papyrus` renders terminal Unicode via `beautiful-mermaid` behind that port, so the task domain carries no Mermaid syntax.
99
124
 
100
- ### Naming vs. ids
125
+ For repository work, install the versioned ownership guard from the workspace root:
101
126
 
102
- Every agent domain tool (tasks, docs, rules, playbooks, notes, discuss) addresses its artifacts by `name` (the exact title) wherever `id` would otherwise be required -- `dependency_name`/`parent_name`/`child_name`/`root_task_name`/`depends_on_names` (tasks), `target_name` (docs link, searches every kind since a link target can be any of them), `task_name` (rules gate, discuss block/unblock), and `blocks_task_names` (discuss open) are the name-based equivalents of their `*_id` counterparts. Resolution is an exact, case-insensitive, trimmed title match scoped like a plain list call; an unmatched or ambiguous name fails with a clear error (ambiguous names list the real ids, since that's the one point disambiguation genuinely needs them). Results returned to the agent likewise lead with name and status, never id, unless two artifacts in the same result share a title -- id is a backend implementation detail, not a conversational handle. `id` itself still works exactly as before for every action, in every tool.
127
+ ```bash
128
+ bun run guard:install
129
+ ```
103
130
 
104
- ### Mutability
131
+ It checks a push's destination against `DanyPops/papyrus`, including an explicit fallback URL, so a push bound for the wrong remote fails locally before it reaches GitHub.
105
132
 
106
- Tasks, Docs, Rules, and Playbooks all support first-class `update` (title/body/labels, at least one required) alongside creation -- a Doc's body is no longer immutable once created. Every update is bounded the same way creation is (Rules keep their own stricter combined condition+action+body ceiling; Docs/Playbooks share Tasks' own length bounds) and recorded on the artifact's append-only mutation history, queryable via `graph.history`. An artifact carrying a `source:<system>` label (e.g. `source:web-spider` on an ingested page) is a read-only projection from a system Papyrus doesn't own the source of; updating one is refused with a clear error rather than silently forking it -- capture a correction as a new linked Doc instead until a write-back capability to that system exists. Notes stay behind their own facade for any content change, same as every other Notes mutation.
133
+ `src/index.ts` is this package's public surface for `@danypops/pi-papyrus` and any other consumer: explicit, named exports curated for outside use.
107
134
 
108
- Internally, application services depend on the `ArtifactStore` and `GateRunner` ports. SQLite and subprocess execution are adapters composed only by the daemon; task behavior is unit-tested against fakes without a database. Task visualization projects the same `TaskGraph` into semantic display graphs and sends them through a `GraphRenderer` port -- the Pi adapter (in `@danypops/pi-papyrus`) uses `beautiful-mermaid` for terminal Unicode output without leaking Mermaid syntax into the task domain.
135
+ ## Related packages
109
136
 
110
- `src/index.ts` is this package's public surface for `@danypops/pi-papyrus` (and any other real npm consumer): explicit, named exports only, not a blanket re-export of internal daemon plumbing.
137
+ - **[`@danypops/pi-papyrus`](https://www.npmjs.com/package/@danypops/pi-papyrus)** the Pi extension: native tools, TUI panels, and context injection over this daemon's authenticated loopback connection.
package/package.json CHANGED
@@ -1,9 +1,8 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.47.1",
3
+ "version": "0.48.0",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
- "keywords": ["pi-package"],
7
6
  "main": "./src/index.ts",
8
7
  "types": "./src/index.ts",
9
8
  "bin": {
@@ -1,20 +1,50 @@
1
1
  import type { TaskScopeSource } from "../domain/task-scope.ts";
2
2
 
3
3
  /**
4
- * Project scoping for Docs/Rules/Playbooks, mirroring TaskScopeStore's shape (task_scopes) but
5
- * kept as its own table/port rather than folding non-Task kinds into Task-named
6
- * infrastructure. TaskScopeSource ("cwd" | "explicit" | "unscoped") is already kind-agnostic
7
- * and reused as-is -- no reason to redefine the same three values under a new name.
4
+ * Project scoping for Docs/Rules/Playbooks: an artifact is either explicitly global (applies
5
+ * everywhere) or bound to a bounded, non-empty set of registered project ids -- never inferred
6
+ * from an accidentally empty join table, which is why `mode` is its own explicit field rather
7
+ * than "projectIds.length === 0 means global". Membership is by project id (from the shared
8
+ * ProjectRegistryStore) internally, so a registered project's root can move without a
9
+ * best-effort string rewrite across every artifact that references it -- assign/get/ids keep
10
+ * taking/returning a root for compatibility with every existing caller, resolved to/from a
11
+ * project id under the hood.
8
12
  */
13
+ export type ArtifactScopeMode = "global" | "projects";
14
+
9
15
  export interface ArtifactScope {
16
+ artifactId: string;
17
+ mode: ArtifactScopeMode;
18
+ /** Registered project ids this artifact applies to. Always empty when mode is "global"; always non-empty when mode is "projects". */
19
+ projectIds: string[];
20
+ source: TaskScopeSource;
21
+ }
22
+
23
+ export interface LegacyArtifactScope {
10
24
  artifactId: string;
11
25
  projectRoot?: string;
12
26
  source: TaskScopeSource;
13
27
  }
14
28
 
15
29
  export interface ArtifactScopeStore {
16
- assign(artifactId: string, projectRoot: string | undefined, source: TaskScopeSource): ArtifactScope;
17
- get(artifactId: string): ArtifactScope | undefined;
18
- /** Bounded id listing for one project (or the unscoped bucket when projectRoot is undefined). */
30
+ /** The real, non-lossy multi-membership view. Defaults to global/unscoped for an artifact with no scope row yet. */
31
+ scope(artifactId: string): ArtifactScope;
32
+ /** Single-root compatibility view over scope(): a global or unscoped artifact omits projectRoot; a "projects" mode artifact with exactly one membership resolves it back to that project's current root; more than one membership (only reachable through the new multi-project primitives below) omits projectRoot, since this shape cannot represent more than one. */
33
+ get(artifactId: string): LegacyArtifactScope | undefined;
34
+ /** Single-root compatibility shim: registers/resolves projectRoot and replaces the artifact's scope with exactly that one membership, or setGlobal() when projectRoot is undefined. Every existing caller (rules/docs/playbooks assign_project) keeps working unchanged. */
35
+ assign(artifactId: string, projectRoot: string | undefined, source: TaskScopeSource): LegacyArtifactScope;
36
+ /** Sets an artifact to explicitly global, clearing any project membership. */
37
+ setGlobal(artifactId: string, source: TaskScopeSource): ArtifactScope;
38
+ /** Replaces an artifact's entire project membership set with exactly these (registered) project ids -- must be non-empty; use setGlobal to clear scoping entirely. */
39
+ replaceProjects(artifactId: string, projectIds: readonly string[], source: TaskScopeSource): ArtifactScope;
40
+ /** Adds one project to an artifact's membership (idempotent -- adding an already-present id is a no-op), switching mode to "projects" if it was global. Enforces the bounded maximum membership count. */
41
+ addProject(artifactId: string, projectId: string, source: TaskScopeSource): ArtifactScope;
42
+ /** Removes one project from an artifact's membership (idempotent -- removing an absent id is a no-op). Rejects removing the last membership while mode is "projects": a caller must explicitly call setGlobal instead of accidentally broadening scope by emptying the set. */
43
+ removeProject(artifactId: string, projectId: string): ArtifactScope;
44
+ /** Bounded id listing for one project root (or the global/unscoped bucket when projectRoot is undefined) -- an unregistered root always yields an empty list, since nothing can be scoped to a project that was never registered. */
19
45
  ids(projectRoot: string | undefined, limit: number): string[];
46
+ /** True when artifactId's scope includes projectId (mode "projects" and a member), or is "global" (applies everywhere). False for an unscoped artifact with no row -- matching a Rule/Doc/Playbook's default of "applies everywhere" being represented by the same "global" default scope() already returns, but injection call sites decide their own applicability policy; this is the raw membership fact only. */
47
+ appliesToProject(artifactId: string, projectId: string): boolean;
48
+ /** Root-based convenience over appliesToProject, for a caller (e.g. rules.injectable) that only has a project root, not a resolved id -- resolves the same way ids() does. An unregistered root (no project was ever registered for it) means only a global-mode artifact applies; projectRoot === undefined means "no project context at all", so only global-mode artifacts apply either way. */
49
+ appliesToProjectRoot(artifactId: string, projectRoot: string | undefined): boolean;
20
50
  }
@@ -0,0 +1,123 @@
1
+ import { ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT } from "../constants.ts";
2
+ import type { TaskScopeSource } from "../domain/task-scope.ts";
3
+ import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
4
+ import { InMemoryProjectRegistryStore } from "../stores/in-memory-project-registry-store.ts";
5
+ import type { ArtifactScope, ArtifactScopeStore, LegacyArtifactScope } from "./artifact-scope-store.ts";
6
+
7
+ interface Row {
8
+ mode: "global" | "projects";
9
+ projectIds: Set<string>;
10
+ source: TaskScopeSource;
11
+ }
12
+
13
+ export class InMemoryArtifactScopeStore implements ArtifactScopeStore {
14
+ private readonly rows = new Map<string, Row>();
15
+ private readonly registry: InMemoryProjectRegistryStore;
16
+
17
+ // Membership is stored by project id, never by root, so a registry root move (see
18
+ // ProjectRegistryStore.registerProject) needs no rewrite here at all -- unlike
19
+ // InMemoryTaskScopeStore, this store never subscribes to root-move notifications.
20
+ constructor(registry?: ProjectRegistryStore) {
21
+ this.registry = registry instanceof InMemoryProjectRegistryStore ? registry : new InMemoryProjectRegistryStore();
22
+ }
23
+
24
+ private toScope(artifactId: string, row: Row | undefined): ArtifactScope {
25
+ return row
26
+ ? { artifactId, mode: row.mode, projectIds: [...row.projectIds], source: row.source }
27
+ : { artifactId, mode: "global", projectIds: [], source: "unscoped" };
28
+ }
29
+
30
+ scope(artifactId: string): ArtifactScope {
31
+ return this.toScope(artifactId, this.rows.get(artifactId));
32
+ }
33
+
34
+ get(artifactId: string): LegacyArtifactScope | undefined {
35
+ const row = this.rows.get(artifactId);
36
+ if (!row) return undefined;
37
+ const onlyProjectId = row.mode === "projects" && row.projectIds.size === 1 ? [...row.projectIds][0] : undefined;
38
+ const projectRoot = onlyProjectId === undefined ? undefined : this.registry.byId(onlyProjectId)?.projectRoot;
39
+ return { artifactId, ...(projectRoot === undefined ? {} : { projectRoot }), source: row.source };
40
+ }
41
+
42
+ assign(artifactId: string, projectRoot: string | undefined, source: TaskScopeSource): LegacyArtifactScope {
43
+ if (projectRoot === undefined) {
44
+ this.setGlobal(artifactId, source);
45
+ return { artifactId, source };
46
+ }
47
+ const project = this.registry.registerProject({ projectRoot });
48
+ this.replaceProjects(artifactId, [project.id], source);
49
+ return { artifactId, projectRoot: project.projectRoot, source };
50
+ }
51
+
52
+ setGlobal(artifactId: string, source: TaskScopeSource): ArtifactScope {
53
+ const row: Row = { mode: "global", projectIds: new Set(), source };
54
+ this.rows.set(artifactId, row);
55
+ return this.toScope(artifactId, row);
56
+ }
57
+
58
+ replaceProjects(artifactId: string, projectIds: readonly string[], source: TaskScopeSource): ArtifactScope {
59
+ if (projectIds.length === 0) throw new Error("replaceProjects requires at least one project id; use setGlobal to clear scoping");
60
+ if (projectIds.length > ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT) {
61
+ throw new Error(`an artifact cannot belong to more than ${ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT} projects`);
62
+ }
63
+ const row: Row = { mode: "projects", projectIds: new Set(projectIds), source };
64
+ this.rows.set(artifactId, row);
65
+ return this.toScope(artifactId, row);
66
+ }
67
+
68
+ addProject(artifactId: string, projectId: string, source: TaskScopeSource): ArtifactScope {
69
+ const existing = this.rows.get(artifactId);
70
+ const projectIds = new Set(existing?.mode === "projects" ? existing.projectIds : []);
71
+ if (!projectIds.has(projectId) && projectIds.size >= ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT) {
72
+ throw new Error(`an artifact cannot belong to more than ${ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT} projects`);
73
+ }
74
+ projectIds.add(projectId);
75
+ const row: Row = { mode: "projects", projectIds, source };
76
+ this.rows.set(artifactId, row);
77
+ return this.toScope(artifactId, row);
78
+ }
79
+
80
+ removeProject(artifactId: string, projectId: string): ArtifactScope {
81
+ const existing = this.rows.get(artifactId);
82
+ if (existing?.mode !== "projects" || !existing.projectIds.has(projectId)) return this.toScope(artifactId, existing);
83
+ if (existing.projectIds.size === 1) {
84
+ throw new Error("cannot remove the last project membership; call setGlobal to make this artifact apply everywhere instead");
85
+ }
86
+ const projectIds = new Set(existing.projectIds);
87
+ projectIds.delete(projectId);
88
+ const row: Row = { mode: "projects", projectIds, source: existing.source };
89
+ this.rows.set(artifactId, row);
90
+ return this.toScope(artifactId, row);
91
+ }
92
+
93
+ ids(projectRoot: string | undefined, limit: number): string[] {
94
+ if (projectRoot === undefined) {
95
+ return [...this.rows.entries()]
96
+ .filter(([, row]) => row.mode === "global")
97
+ .map(([artifactId]) => artifactId)
98
+ .sort()
99
+ .slice(0, limit);
100
+ }
101
+ const project = this.registry.byRoot(projectRoot);
102
+ if (!project) return [];
103
+ return [...this.rows.entries()]
104
+ .filter(([, row]) => row.mode === "projects" && row.projectIds.has(project.id))
105
+ .map(([artifactId]) => artifactId)
106
+ .sort()
107
+ .slice(0, limit);
108
+ }
109
+
110
+ appliesToProject(artifactId: string, projectId: string): boolean {
111
+ const row = this.rows.get(artifactId);
112
+ if (!row || row.mode === "global") return true;
113
+ return row.projectIds.has(projectId);
114
+ }
115
+
116
+ appliesToProjectRoot(artifactId: string, projectRoot: string | undefined): boolean {
117
+ const row = this.rows.get(artifactId);
118
+ if (!row || row.mode === "global") return true;
119
+ if (projectRoot === undefined) return false;
120
+ const project = this.registry.byRoot(projectRoot);
121
+ return project !== undefined && row.projectIds.has(project.id);
122
+ }
123
+ }
@@ -1,45 +1,166 @@
1
+ import { ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT } from "../constants.ts";
1
2
  import type { Db } from "../db.ts";
2
3
  import { inTransaction } from "../db.ts";
3
4
  import type { TaskScopeSource } from "../domain/task-scope.ts";
4
- import type { ArtifactScope, ArtifactScopeStore } from "./artifact-scope-store.ts";
5
+ import { SQLiteProjectRegistryStore } from "../stores/sqlite-project-registry-store.ts";
6
+ import type { ArtifactScope, ArtifactScopeMode, ArtifactScopeStore, LegacyArtifactScope } from "./artifact-scope-store.ts";
7
+
8
+ interface ScopeRow {
9
+ artifact_id: string;
10
+ mode: ArtifactScopeMode;
11
+ source: TaskScopeSource;
12
+ }
5
13
 
6
14
  export class SQLiteArtifactScopeStore implements ArtifactScopeStore {
7
- constructor(private readonly db: Db) {}
15
+ private readonly registry: SQLiteProjectRegistryStore;
8
16
 
9
- assign(artifactId: string, projectRoot: string | undefined, source: TaskScopeSource): ArtifactScope {
10
- inTransaction(this.db, () => {
11
- this.db
12
- .prepare(`
13
- INSERT INTO artifact_scopes (artifact_id, project_root, source, assigned_at)
14
- VALUES (?, ?, ?, ?)
17
+ // Membership is by project id, not root -- a registry root move (see
18
+ // SQLiteProjectRegistryStore.registerProject) needs no rewrite of artifact_scope_projects at
19
+ // all, unlike SQLiteTaskScopeStore's own task_scopes/task_views rewrite.
20
+ constructor(private readonly db: Db) {
21
+ this.registry = new SQLiteProjectRegistryStore(db);
22
+ }
23
+
24
+ private membershipIds(artifactId: string): string[] {
25
+ return (
26
+ this.db.prepare("SELECT project_id FROM artifact_scope_projects WHERE artifact_id = ? ORDER BY project_id").all(artifactId) as Array<{
27
+ project_id: string;
28
+ }>
29
+ ).map((row) => row.project_id);
30
+ }
31
+
32
+ private readRow(artifactId: string): ScopeRow | undefined {
33
+ const row = this.db
34
+ .prepare("SELECT artifact_id, mode, source FROM artifact_scopes WHERE artifact_id = ?")
35
+ .get(artifactId) as ScopeRow | null;
36
+ return row ?? undefined;
37
+ }
38
+
39
+ scope(artifactId: string): ArtifactScope {
40
+ const row = this.readRow(artifactId);
41
+ if (!row) return { artifactId, mode: "global", projectIds: [], source: "unscoped" };
42
+ return { artifactId, mode: row.mode, projectIds: row.mode === "projects" ? this.membershipIds(artifactId) : [], source: row.source };
43
+ }
44
+
45
+ get(artifactId: string): LegacyArtifactScope | undefined {
46
+ const row = this.readRow(artifactId);
47
+ if (!row) return undefined;
48
+ if (row.mode !== "projects") return { artifactId, source: row.source };
49
+ const ids = this.membershipIds(artifactId);
50
+ if (ids.length !== 1) return { artifactId, source: row.source };
51
+ const project = this.registry.matchingProjects(ids[0]!).find((candidate) => candidate.id === ids[0]);
52
+ return { artifactId, ...(project ? { projectRoot: project.projectRoot } : {}), source: row.source };
53
+ }
54
+
55
+ assign(artifactId: string, projectRoot: string | undefined, source: TaskScopeSource): LegacyArtifactScope {
56
+ if (projectRoot === undefined) {
57
+ this.setGlobal(artifactId, source);
58
+ return { artifactId, source };
59
+ }
60
+ return inTransaction(this.db, () => {
61
+ const project = this.registry.registerProject({ projectRoot });
62
+ this.replaceProjects(artifactId, [project.id], source);
63
+ return { artifactId, projectRoot: project.projectRoot, source };
64
+ });
65
+ }
66
+
67
+ private upsertScopeRow(artifactId: string, mode: ArtifactScopeMode, source: TaskScopeSource): void {
68
+ this.db
69
+ .prepare(`
70
+ INSERT INTO artifact_scopes (artifact_id, project_root, mode, source, assigned_at)
71
+ VALUES (?, NULL, ?, ?, ?)
15
72
  ON CONFLICT(artifact_id) DO UPDATE SET
16
- project_root = excluded.project_root,
73
+ project_root = NULL,
74
+ mode = excluded.mode,
17
75
  source = excluded.source,
18
76
  assigned_at = excluded.assigned_at
19
77
  `)
20
- .run(artifactId, projectRoot ?? null, source, new Date().toISOString());
78
+ .run(artifactId, mode, source, new Date().toISOString());
79
+ }
80
+
81
+ setGlobal(artifactId: string, source: TaskScopeSource): ArtifactScope {
82
+ return inTransaction(this.db, () => {
83
+ this.upsertScopeRow(artifactId, "global", source);
84
+ this.db.prepare("DELETE FROM artifact_scope_projects WHERE artifact_id = ?").run(artifactId);
85
+ return { artifactId, mode: "global", projectIds: [], source };
86
+ });
87
+ }
88
+
89
+ replaceProjects(artifactId: string, projectIds: readonly string[], source: TaskScopeSource): ArtifactScope {
90
+ if (projectIds.length === 0) throw new Error("replaceProjects requires at least one project id; use setGlobal to clear scoping");
91
+ if (projectIds.length > ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT) {
92
+ throw new Error(`an artifact cannot belong to more than ${ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT} projects`);
93
+ }
94
+ return inTransaction(this.db, () => {
95
+ this.upsertScopeRow(artifactId, "projects", source);
96
+ this.db.prepare("DELETE FROM artifact_scope_projects WHERE artifact_id = ?").run(artifactId);
97
+ const insert = this.db.prepare("INSERT OR IGNORE INTO artifact_scope_projects (artifact_id, project_id) VALUES (?, ?)");
98
+ const unique = [...new Set(projectIds)];
99
+ for (const projectId of unique) insert.run(artifactId, projectId);
100
+ return { artifactId, mode: "projects", projectIds: unique, source };
101
+ });
102
+ }
103
+
104
+ addProject(artifactId: string, projectId: string, source: TaskScopeSource): ArtifactScope {
105
+ return inTransaction(this.db, () => {
106
+ const current = this.membershipIds(artifactId);
107
+ if (!current.includes(projectId) && current.length >= ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT) {
108
+ throw new Error(`an artifact cannot belong to more than ${ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT} projects`);
109
+ }
110
+ this.upsertScopeRow(artifactId, "projects", source);
111
+ this.db.prepare("INSERT OR IGNORE INTO artifact_scope_projects (artifact_id, project_id) VALUES (?, ?)").run(artifactId, projectId);
112
+ return { artifactId, mode: "projects", projectIds: this.membershipIds(artifactId), source };
21
113
  });
22
- return { artifactId, ...(projectRoot === undefined ? {} : { projectRoot }), source };
23
114
  }
24
115
 
25
- get(artifactId: string): ArtifactScope | undefined {
26
- const row = this.db.prepare("SELECT artifact_id, project_root, source FROM artifact_scopes WHERE artifact_id = ?").get(artifactId) as {
27
- artifact_id: string;
28
- project_root: string | null;
29
- source: TaskScopeSource;
30
- } | null;
31
- return row
32
- ? { artifactId: row.artifact_id, ...(row.project_root === null ? {} : { projectRoot: row.project_root }), source: row.source }
33
- : undefined;
116
+ removeProject(artifactId: string, projectId: string): ArtifactScope {
117
+ return inTransaction(this.db, () => {
118
+ const row = this.readRow(artifactId);
119
+ const current = row?.mode === "projects" ? this.membershipIds(artifactId) : [];
120
+ if (row?.mode !== "projects" || !current.includes(projectId)) return this.scope(artifactId);
121
+ if (current.length === 1) {
122
+ throw new Error("cannot remove the last project membership; call setGlobal to make this artifact apply everywhere instead");
123
+ }
124
+ this.db.prepare("DELETE FROM artifact_scope_projects WHERE artifact_id = ? AND project_id = ?").run(artifactId, projectId);
125
+ return { artifactId, mode: "projects", projectIds: this.membershipIds(artifactId), source: row.source };
126
+ });
34
127
  }
35
128
 
36
129
  ids(projectRoot: string | undefined, limit: number): string[] {
37
- const rows =
38
- projectRoot === undefined
39
- ? this.db.prepare("SELECT artifact_id FROM artifact_scopes WHERE project_root IS NULL ORDER BY artifact_id LIMIT ?").all(limit)
40
- : this.db
41
- .prepare("SELECT artifact_id FROM artifact_scopes WHERE project_root = ? ORDER BY artifact_id LIMIT ?")
42
- .all(projectRoot, limit);
43
- return (rows as Array<{ artifact_id: string }>).map((row) => row.artifact_id);
130
+ if (projectRoot === undefined) {
131
+ return (
132
+ this.db.prepare("SELECT artifact_id FROM artifact_scopes WHERE mode = 'global' ORDER BY artifact_id LIMIT ?").all(limit) as Array<{
133
+ artifact_id: string;
134
+ }>
135
+ ).map((row) => row.artifact_id);
136
+ }
137
+ const project = this.db.prepare("SELECT id FROM task_projects WHERE project_root = ?").get(projectRoot) as { id: string } | null;
138
+ if (!project) return [];
139
+ return (
140
+ this.db
141
+ .prepare(`
142
+ SELECT asp.artifact_id AS artifact_id
143
+ FROM artifact_scope_projects asp
144
+ JOIN artifact_scopes s ON s.artifact_id = asp.artifact_id AND s.mode = 'projects'
145
+ WHERE asp.project_id = ?
146
+ ORDER BY asp.artifact_id
147
+ LIMIT ?
148
+ `)
149
+ .all(project.id, limit) as Array<{ artifact_id: string }>
150
+ ).map((row) => row.artifact_id);
151
+ }
152
+
153
+ appliesToProject(artifactId: string, projectId: string): boolean {
154
+ const row = this.readRow(artifactId);
155
+ if (!row || row.mode === "global") return true;
156
+ return this.membershipIds(artifactId).includes(projectId);
157
+ }
158
+
159
+ appliesToProjectRoot(artifactId: string, projectRoot: string | undefined): boolean {
160
+ const row = this.readRow(artifactId);
161
+ if (!row || row.mode === "global") return true;
162
+ if (projectRoot === undefined) return false;
163
+ const project = this.db.prepare("SELECT id FROM task_projects WHERE project_root = ?").get(projectRoot) as { id: string } | null;
164
+ return project !== null && this.membershipIds(artifactId).includes(project.id);
44
165
  }
45
166
  }