@danypops/papyrus 0.11.4 → 0.13.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 +16 -2
- package/extension/src/active-task-continuation.ts +6 -0
- package/extension/src/artifact-browser.ts +13 -7
- package/extension/src/artifact-status-presentation.ts +53 -0
- package/extension/src/context-budget.ts +173 -0
- package/extension/src/context-view.ts +172 -0
- package/extension/src/docs.ts +6 -5
- package/extension/src/domain-tools.ts +108 -52
- package/extension/src/index.ts +124 -38
- package/extension/src/notes.ts +16 -4
- package/extension/src/rules.ts +7 -7
- package/extension/src/skill-catalog-footprint.ts +183 -0
- package/extension/src/skills.ts +2 -3
- package/extension/src/task-focus-events.ts +57 -0
- package/extension/src/task-widget.ts +13 -1
- package/extension/src/tasks.ts +51 -15
- package/extension/src/tool-rendering/artifact-card.ts +117 -0
- package/extension/src/tool-rendering/artifact-list.ts +179 -0
- package/extension/src/tool-rendering/index.ts +107 -0
- package/extension/src/tool-rendering/render-model.ts +406 -0
- package/package.json +4 -2
- package/src/adapters/in-memory-conversation-journal-store.ts +48 -0
- package/src/adapters/sqlite-artifact-scope-store.ts +36 -0
- package/src/adapters/sqlite-artifact-store.ts +20 -11
- package/src/adapters/sqlite-discourse-store.ts +325 -0
- package/src/adapters/sqlite-graph-projection-store.ts +41 -0
- package/src/adapters/sqlite-task-focus-store.ts +34 -15
- package/src/authority-registry.ts +115 -0
- package/src/cli.ts +904 -124
- package/src/constants.ts +77 -5
- package/src/conversation-journal-service.ts +87 -0
- package/src/db.ts +285 -33
- package/src/domain/artifact-event.ts +99 -0
- package/src/domain/conversation-journal.ts +168 -0
- package/src/domain/discourse-store.ts +142 -0
- package/src/domain/graph-projection.ts +74 -0
- package/src/domain/skill-definition.ts +57 -8
- package/src/domain/task-event.ts +4 -0
- package/src/domain-services.ts +201 -40
- package/src/graph-projection-service.ts +103 -0
- package/src/id-migration.ts +200 -0
- package/src/module-registry.ts +53 -0
- package/src/modules/docs.ts +77 -0
- package/src/modules/graph-projection.ts +82 -0
- package/src/modules/notes.ts +76 -0
- package/src/modules/rules.ts +81 -0
- package/src/modules/skills.ts +113 -0
- package/src/modules/tasks.ts +164 -0
- package/src/ops.ts +142 -15
- package/src/ports/artifact-scope-store.ts +20 -0
- package/src/ports/artifact-store.ts +10 -5
- package/src/ports/conversation-journal-store.ts +17 -0
- package/src/ports/graph-projection-store.ts +15 -0
- package/src/ports/task-focus-store.ts +62 -20
- package/src/service.ts +218 -223
- package/src/skill-execution.ts +169 -75
- package/src/task-service.ts +70 -38
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* id-migration.ts — plan/apply/verify tooling for rewriting every existing artifact id to a
|
|
3
|
+
* UUID (see src/ops.ts: new artifacts already get crypto.randomUUID() by default; this tool
|
|
4
|
+
* closes the gap for artifacts that predate that change).
|
|
5
|
+
*
|
|
6
|
+
* This is deliberately NOT a daemon operation. Rewriting every artifact's primary key is a
|
|
7
|
+
* one-shot, high-blast-radius, mostly-irreversible operation on the database file itself, not
|
|
8
|
+
* a request a running service should accept over RPC. The intended, required sequence — never
|
|
9
|
+
* skip a step — is:
|
|
10
|
+
*
|
|
11
|
+
* 1. mirrorDatabase(liveDb, mirrorPath) -- consistent, compacted copy; the original is
|
|
12
|
+
* never opened for writing by anything below.
|
|
13
|
+
* 2. planIdMigration(mirror) + applyIdMigration(mirror, plan) -- mutate the MIRROR only.
|
|
14
|
+
* 3. verifyIdMigration(mirror, plan) -- must report { ok: true } before proceeding.
|
|
15
|
+
* 4. Only then: promote the validated mirror file to replace production (a plain file swap,
|
|
16
|
+
* done by the CLI once step 3 has passed — this module does not perform that swap itself,
|
|
17
|
+
* so there is no code path in this file that can touch a production file that hasn't
|
|
18
|
+
* already been proven correct as a mirror).
|
|
19
|
+
*
|
|
20
|
+
* Coverage: every column that is a structural foreign key to artifacts.id is remapped and
|
|
21
|
+
* verified via PRAGMA foreign_key_check (this is the correctness-critical half — a miss here
|
|
22
|
+
* means a broken database, not a stale reference). A second, best-effort pass exact-substring-
|
|
23
|
+
* replaces old ids wherever they appear inside a known set of free-text/JSON columns (title,
|
|
24
|
+
* body, extra, and the two Task-event text fields) — this is how a prose cross-reference like
|
|
25
|
+
* "see task some-old-id for the parent epic" keeps pointing at the right artifact after its id
|
|
26
|
+
* changes. Discourse post JSON payloads (content_json/command_json/references_json) are
|
|
27
|
+
* explicitly NOT scanned — that is Discourse-internal structure this tool does not have enough
|
|
28
|
+
* context on yet, tracked as a known limitation rather than guessed at.
|
|
29
|
+
*/
|
|
30
|
+
import type { Db } from "./db.ts";
|
|
31
|
+
import { inTransaction } from "./db.ts";
|
|
32
|
+
|
|
33
|
+
/** This tool is designed and tested at Papyrus's current graph scale, not unbounded. */
|
|
34
|
+
export const ID_MIGRATION_MAX_ARTIFACTS = 50_000;
|
|
35
|
+
|
|
36
|
+
export interface IdMigrationPlan {
|
|
37
|
+
readonly idMap: ReadonlyMap<string, string>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface IdMigrationReport {
|
|
41
|
+
readonly artifactsRemapped: number;
|
|
42
|
+
readonly edgesRemapped: number;
|
|
43
|
+
readonly textOccurrencesRemapped: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface IdMigrationVerification {
|
|
47
|
+
readonly ok: boolean;
|
|
48
|
+
readonly problems: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Every column that structurally references an artifact id, enforced FK or not. */
|
|
52
|
+
const FK_COLUMNS: ReadonlyArray<{ table: string; column: string }> = [
|
|
53
|
+
{ table: "edges", column: "from_id" },
|
|
54
|
+
{ table: "edges", column: "to_id" },
|
|
55
|
+
{ table: "task_focus", column: "task_id" },
|
|
56
|
+
{ table: "task_events", column: "task_id" },
|
|
57
|
+
{ table: "task_scopes", column: "task_id" },
|
|
58
|
+
{ table: "task_views", column: "root_task_id" },
|
|
59
|
+
{ table: "discourse_threads", column: "artifact_id" },
|
|
60
|
+
{ table: "discourse_posts", column: "artifact_id" },
|
|
61
|
+
{ table: "artifact_events", column: "artifact_id" },
|
|
62
|
+
{ table: "artifact_events", column: "related_id" },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Free-text/JSON columns that may embed a plain-text mention of an artifact id, beyond the
|
|
67
|
+
* structural FK columns above. See the module doc comment for what this deliberately excludes.
|
|
68
|
+
*/
|
|
69
|
+
const TEXT_SCAN_COLUMNS: ReadonlyArray<{ table: string; column: string }> = [
|
|
70
|
+
{ table: "artifacts", column: "title" },
|
|
71
|
+
{ table: "artifacts", column: "body" },
|
|
72
|
+
{ table: "artifacts", column: "extra" },
|
|
73
|
+
{ table: "task_events", column: "reason" },
|
|
74
|
+
{ table: "task_events", column: "evidence_json" },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/** Audit tables whose append-only guard must be suspended for exactly this migration's duration. */
|
|
78
|
+
const APPEND_ONLY_GUARD_TRIGGERS = ["task_events_no_update", "task_events_no_delete", "artifact_events_no_update", "artifact_events_no_delete"];
|
|
79
|
+
|
|
80
|
+
function tableExists(db: Db, table: string): boolean {
|
|
81
|
+
return db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) != null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function planIdMigration(db: Db): IdMigrationPlan {
|
|
85
|
+
const rows = db.prepare("SELECT id FROM artifacts").all() as Array<{ id: string }>;
|
|
86
|
+
if (rows.length > ID_MIGRATION_MAX_ARTIFACTS) {
|
|
87
|
+
throw new Error(`id migration is bounded to ${ID_MIGRATION_MAX_ARTIFACTS} artifacts; found ${rows.length}`);
|
|
88
|
+
}
|
|
89
|
+
const idMap = new Map<string, string>();
|
|
90
|
+
for (const row of rows) idMap.set(row.id, crypto.randomUUID());
|
|
91
|
+
return { idMap };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Mutates `db` in place. Callers must only ever pass a mirror (see mirrorDatabase), never a
|
|
96
|
+
* database a live daemon may still be serving reads/writes against.
|
|
97
|
+
*/
|
|
98
|
+
export function applyIdMigration(db: Db, plan: IdMigrationPlan): IdMigrationReport {
|
|
99
|
+
const { idMap } = plan;
|
|
100
|
+
if (idMap.size === 0) return { artifactsRemapped: 0, edgesRemapped: 0, textOccurrencesRemapped: 0 };
|
|
101
|
+
|
|
102
|
+
// PRAGMA foreign_keys is a no-op inside an open transaction in SQLite, so it must be set
|
|
103
|
+
// before BEGIN, not inside inTransaction's callback.
|
|
104
|
+
db.exec("PRAGMA foreign_keys = OFF");
|
|
105
|
+
try {
|
|
106
|
+
return inTransaction(db, () => {
|
|
107
|
+
const triggerDdl = new Map<string, string>();
|
|
108
|
+
for (const name of APPEND_ONLY_GUARD_TRIGGERS) {
|
|
109
|
+
const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?").get(name) as { sql: string } | undefined;
|
|
110
|
+
if (row) { triggerDdl.set(name, row.sql); db.exec(`DROP TRIGGER ${name}`); }
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
let edgesRemapped = 0;
|
|
114
|
+
for (const { table, column } of FK_COLUMNS) {
|
|
115
|
+
if (!tableExists(db, table)) continue;
|
|
116
|
+
const stmt = db.prepare(`UPDATE ${table} SET ${column} = ? WHERE ${column} = ?`);
|
|
117
|
+
for (const [oldId, newId] of idMap) {
|
|
118
|
+
stmt.run(newId, oldId);
|
|
119
|
+
if (table === "edges") edgesRemapped++;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// artifacts.id itself, once nothing else still points at the old value.
|
|
124
|
+
const idStmt = db.prepare("UPDATE artifacts SET id = ? WHERE id = ?");
|
|
125
|
+
for (const [oldId, newId] of idMap) idStmt.run(newId, oldId);
|
|
126
|
+
|
|
127
|
+
let textOccurrencesRemapped = 0;
|
|
128
|
+
for (const { table, column } of TEXT_SCAN_COLUMNS) {
|
|
129
|
+
if (!tableExists(db, table)) continue;
|
|
130
|
+
const rows = db.prepare(`SELECT rowid AS rowid, ${column} AS value FROM ${table} WHERE ${column} IS NOT NULL`).all() as Array<{ rowid: number; value: string }>;
|
|
131
|
+
const updateStmt = db.prepare(`UPDATE ${table} SET ${column} = ? WHERE rowid = ?`);
|
|
132
|
+
for (const row of rows) {
|
|
133
|
+
let value = row.value;
|
|
134
|
+
let changed = false;
|
|
135
|
+
for (const [oldId, newId] of idMap) {
|
|
136
|
+
if (value.includes(oldId)) {
|
|
137
|
+
value = value.split(oldId).join(newId);
|
|
138
|
+
changed = true;
|
|
139
|
+
textOccurrencesRemapped++;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (changed) updateStmt.run(value, row.rowid);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { artifactsRemapped: idMap.size, edgesRemapped, textOccurrencesRemapped };
|
|
147
|
+
} finally {
|
|
148
|
+
for (const [, sql] of triggerDdl) db.exec(sql);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
} finally {
|
|
152
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
153
|
+
// File-backed databases run in WAL mode (see openDb): a committed transaction can be
|
|
154
|
+
// fully durable yet still live only in the -wal sidecar file, not yet folded into the
|
|
155
|
+
// main database file. A caller that copies just the main file (mirrorDatabase's whole
|
|
156
|
+
// point, and promote's file swap) would silently see stale pre-migration content unless
|
|
157
|
+
// this is forced. A no-op for :memory: databases (nothing to checkpoint).
|
|
158
|
+
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Read-only. Must be run against the mirror after applyIdMigration, before any promotion.
|
|
164
|
+
* Checks (a) referential integrity holds with no violations, (b) every old id is fully gone
|
|
165
|
+
* from artifacts.id and every FK column, (c) row counts are unchanged for artifacts/edges/
|
|
166
|
+
* both audit logs, and (d) every artifact's content is unchanged except for id substitution.
|
|
167
|
+
*/
|
|
168
|
+
export function verifyIdMigration(db: Db, plan: IdMigrationPlan): IdMigrationVerification {
|
|
169
|
+
const problems: string[] = [];
|
|
170
|
+
|
|
171
|
+
const violations = db.prepare("PRAGMA foreign_key_check").all();
|
|
172
|
+
if (violations.length > 0) problems.push(`${violations.length} foreign key violation(s) found after migration`);
|
|
173
|
+
|
|
174
|
+
for (const oldId of plan.idMap.keys()) {
|
|
175
|
+
if (db.prepare("SELECT 1 FROM artifacts WHERE id = ?").get(oldId) != null) {
|
|
176
|
+
problems.push(`old id "${oldId}" is still present in artifacts.id`);
|
|
177
|
+
}
|
|
178
|
+
for (const { table, column } of FK_COLUMNS) {
|
|
179
|
+
if (!tableExists(db, table)) continue;
|
|
180
|
+
const leak = db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${column} = ?`).get(oldId) as { n: number };
|
|
181
|
+
if (leak.n > 0) problems.push(`old id "${oldId}" still referenced in ${table}.${column} (${leak.n} row(s))`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const artifactCount = (db.prepare("SELECT COUNT(*) AS n FROM artifacts").get() as { n: number }).n;
|
|
186
|
+
if (artifactCount !== plan.idMap.size) {
|
|
187
|
+
problems.push(`expected ${plan.idMap.size} artifacts after migration, found ${artifactCount}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { ok: problems.length === 0, problems };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Produces a consistent, compacted, independent copy of `db` at `path` via SQLite's own
|
|
195
|
+
* VACUUM INTO — safe regardless of the source's WAL state, and does not require the caller to
|
|
196
|
+
* coordinate checkpointing. The original connection and file are never written to by this call.
|
|
197
|
+
*/
|
|
198
|
+
export function mirrorDatabase(db: Db, path: string): void {
|
|
199
|
+
db.prepare("VACUUM INTO ?").run(path);
|
|
200
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* module-registry.ts — Branch-by-Abstraction step 1 of the modules/projections refactor
|
|
3
|
+
* (see docs reducing-papyrus-consumer-change-amplification-with-modules--pvdo and
|
|
4
|
+
* papyrus-full-context-mesh-and-domain-storage-ownership-bound-qhzp).
|
|
5
|
+
*
|
|
6
|
+
* A statically registered operation descriptor replaces one entry of the central
|
|
7
|
+
* operation switch in src/service.ts. This is intentionally minimal for this slice:
|
|
8
|
+
* one registry, one contract, no dynamic loading, no migration/authority/CLI descriptors
|
|
9
|
+
* yet — those are separate follow-up steps. The goal is to prove the shape end-to-end
|
|
10
|
+
* for a real module (Notes) without changing any observable behavior.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface OperationDefinition<Input = unknown, Output = unknown> {
|
|
14
|
+
/** Dotted operation name, e.g. "notes.capture". Must be unique across every registered module. */
|
|
15
|
+
readonly name: string;
|
|
16
|
+
/** Owning module id, e.g. "notes". Used for boot diagnostics and future authority/migration scoping. */
|
|
17
|
+
readonly moduleId: string;
|
|
18
|
+
execute(input: Input): Output | Promise<Output>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* O(1) name -> descriptor lookup. Boot-time registration is O(N) and rejects duplicate
|
|
23
|
+
* names immediately rather than silently letting the last registration win, so a module
|
|
24
|
+
* collision fails fast instead of producing quiet cross-module dispatch bugs.
|
|
25
|
+
*/
|
|
26
|
+
export class OperationRegistry {
|
|
27
|
+
private readonly operations = new Map<string, OperationDefinition>();
|
|
28
|
+
|
|
29
|
+
register(operation: OperationDefinition): void {
|
|
30
|
+
const existing = this.operations.get(operation.name);
|
|
31
|
+
if (existing) {
|
|
32
|
+
throw new Error(`operation "${operation.name}" is already registered by module "${existing.moduleId}"`);
|
|
33
|
+
}
|
|
34
|
+
this.operations.set(operation.name, operation);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
registerAll(operations: readonly OperationDefinition[]): void {
|
|
38
|
+
for (const operation of operations) this.register(operation);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get(name: string): OperationDefinition | undefined {
|
|
42
|
+
return this.operations.get(name);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
has(name: string): boolean {
|
|
46
|
+
return this.operations.has(name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Bounded by registration count, not a runtime query — safe to call freely for diagnostics/CLI listing. */
|
|
50
|
+
list(): string[] {
|
|
51
|
+
return [...this.operations.keys()].sort();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* modules/docs.ts — Docs as a Papyrus-native registered module
|
|
3
|
+
* (step 5, continued, of the incremental refactor in
|
|
4
|
+
* reducing-papyrus-consumer-change-amplification-with-modules--pvdo).
|
|
5
|
+
*
|
|
6
|
+
* Imports only src/domain-services.ts's Doc functions, which are already generic
|
|
7
|
+
* ArtifactStore-based with no other module's concrete class dependency.
|
|
8
|
+
*/
|
|
9
|
+
import type { AuthorityRegistry } from "../authority-registry.ts";
|
|
10
|
+
import { assignDocumentProject, createDocument, linkDocument, listDocuments, showDocument, transitionDocument, type DocumentRelation } from "../domain-services.ts";
|
|
11
|
+
import type { OperationDefinition } from "../module-registry.ts";
|
|
12
|
+
import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
|
|
13
|
+
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
14
|
+
|
|
15
|
+
const MODULE_ID = "docs";
|
|
16
|
+
|
|
17
|
+
type OperationInput = Record<string, unknown>;
|
|
18
|
+
|
|
19
|
+
function string(input: OperationInput, key: string): string {
|
|
20
|
+
const value = input[key];
|
|
21
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${key} is required`);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function optionalString(input: OperationInput, key: string): string | undefined {
|
|
26
|
+
const value = input[key];
|
|
27
|
+
if (value === undefined) return undefined;
|
|
28
|
+
if (typeof value !== "string") throw new Error(`${key} must be a string`);
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function optionalNumber(input: OperationInput, key: string): number | undefined {
|
|
33
|
+
const value = input[key];
|
|
34
|
+
if (value === undefined) return undefined;
|
|
35
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${key} must be a number`);
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const eventContext = (input: OperationInput) => ({
|
|
40
|
+
actor: optionalString(input, "actor"),
|
|
41
|
+
source: optionalString(input, "source"),
|
|
42
|
+
sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const artifactFilter = (input: OperationInput) => ({
|
|
46
|
+
status: optionalString(input, "status"),
|
|
47
|
+
text: optionalString(input, "text"),
|
|
48
|
+
limit: optionalNumber(input, "limit"),
|
|
49
|
+
projectRoot: optionalString(input, "project_root"),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** Registers every docs.* operation against the shared ArtifactStore port. Behavior is unchanged from the prior inline handlers in src/service.ts. */
|
|
53
|
+
/** This module's own operation names, the single source of truth src/service.ts's EXPECTED_OPERATION_NAMES spreads in rather than re-listing by hand. */
|
|
54
|
+
export const DOCS_OPERATION_NAMES = [
|
|
55
|
+
"docs.create", "docs.list", "docs.show", "docs.activate", "docs.archive", "docs.reopen", "docs.link", "docs.assign_project",
|
|
56
|
+
] as const;
|
|
57
|
+
|
|
58
|
+
export function docsOperations(artifacts: ArtifactStore, scopes: ArtifactScopeStore, authority: AuthorityRegistry): OperationDefinition[] {
|
|
59
|
+
const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
|
|
60
|
+
name, moduleId: MODULE_ID, execute,
|
|
61
|
+
});
|
|
62
|
+
return [
|
|
63
|
+
define("docs.create", (input: OperationInput) => createDocument(artifacts, scopes, {
|
|
64
|
+
title: string(input, "title"), body: optionalString(input, "body"), subtype: optionalString(input, "subtype"),
|
|
65
|
+
labels: input["labels"] as string[] | undefined, extra: input["extra"] as Record<string, unknown> | undefined,
|
|
66
|
+
templateId: optionalString(input, "template_id") ?? optionalString(input, "templateId"),
|
|
67
|
+
projectRoot: optionalString(input, "project_root"),
|
|
68
|
+
}, authority, eventContext(input))),
|
|
69
|
+
define("docs.list", (input: OperationInput) => listDocuments(artifacts, scopes, artifactFilter(input))),
|
|
70
|
+
define("docs.show", (input: OperationInput) => showDocument(artifacts, string(input, "id"))),
|
|
71
|
+
define("docs.activate", (input: OperationInput) => transitionDocument(artifacts, string(input, "id"), "activate", authority, eventContext(input))),
|
|
72
|
+
define("docs.archive", (input: OperationInput) => transitionDocument(artifacts, string(input, "id"), "archive", authority, eventContext(input))),
|
|
73
|
+
define("docs.reopen", (input: OperationInput) => transitionDocument(artifacts, string(input, "id"), "reopen", authority, eventContext(input))),
|
|
74
|
+
define("docs.link", (input: OperationInput) => linkDocument(artifacts, string(input, "id"), string(input, "relation") as DocumentRelation, string(input, "target_id"), authority, eventContext(input))),
|
|
75
|
+
define("docs.assign_project", (input: OperationInput) => assignDocumentProject(artifacts, scopes, string(input, "id"), optionalString(input, "project_root"))),
|
|
76
|
+
];
|
|
77
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* modules/graph-projection.ts — the generic graph projection protocol as a registered
|
|
3
|
+
* Papyrus-native module (step 6 of the incremental refactor in
|
|
4
|
+
* reducing-papyrus-consumer-change-amplification-with-modules--pvdo). See
|
|
5
|
+
* src/domain/graph-projection.ts and src/graph-projection-service.ts for the protocol
|
|
6
|
+
* itself; this file only parses/validates raw operation input into that typed shape.
|
|
7
|
+
*/
|
|
8
|
+
import type { AuthorityRegistry } from "../authority-registry.ts";
|
|
9
|
+
import { GRAPH_PROJECTION_SCHEMA_VERSION, type GraphProjectionBatch, type ProjectedArtifact, type ProjectedEdge } from "../domain/graph-projection.ts";
|
|
10
|
+
import { GraphProjection } from "../graph-projection-service.ts";
|
|
11
|
+
import type { OperationDefinition } from "../module-registry.ts";
|
|
12
|
+
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
13
|
+
import type { GraphProjectionStore } from "../ports/graph-projection-store.ts";
|
|
14
|
+
|
|
15
|
+
const MODULE_ID = "graph_projection";
|
|
16
|
+
|
|
17
|
+
type OperationInput = Record<string, unknown>;
|
|
18
|
+
|
|
19
|
+
function string(input: OperationInput, key: string): string {
|
|
20
|
+
const value = input[key];
|
|
21
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${key} is required`);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function number(input: OperationInput, key: string): number {
|
|
26
|
+
const value = input[key];
|
|
27
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${key} is required and must be a number`);
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
32
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseArtifact(raw: unknown, index: number): ProjectedArtifact {
|
|
36
|
+
if (!isRecord(raw)) throw new Error(`artifacts[${index}] must be an object`);
|
|
37
|
+
return {
|
|
38
|
+
externalId: string(raw, "external_id"),
|
|
39
|
+
kind: string(raw, "kind"),
|
|
40
|
+
...(typeof raw["subtype"] === "string" ? { subtype: raw["subtype"] } : {}),
|
|
41
|
+
title: string(raw, "title"),
|
|
42
|
+
...(typeof raw["body"] === "string" ? { body: raw["body"] } : {}),
|
|
43
|
+
...(Array.isArray(raw["labels"]) ? { labels: raw["labels"] as string[] } : {}),
|
|
44
|
+
...(isRecord(raw["extra"]) ? { extra: raw["extra"] } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseEdge(raw: unknown, index: number): ProjectedEdge {
|
|
49
|
+
if (!isRecord(raw)) throw new Error(`edges[${index}] must be an object`);
|
|
50
|
+
return { from: string(raw, "from"), relation: string(raw, "relation"), to: string(raw, "to") };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseBatch(input: OperationInput): GraphProjectionBatch {
|
|
54
|
+
const schemaVersion = string(input, "schema_version");
|
|
55
|
+
const rawArtifacts = input["artifacts"];
|
|
56
|
+
const rawEdges = input["edges"] ?? [];
|
|
57
|
+
if (!Array.isArray(rawArtifacts)) throw new Error("artifacts must be an array");
|
|
58
|
+
if (!Array.isArray(rawEdges)) throw new Error("edges must be an array");
|
|
59
|
+
return {
|
|
60
|
+
schemaVersion: schemaVersion as typeof GRAPH_PROJECTION_SCHEMA_VERSION,
|
|
61
|
+
producerId: string(input, "producer_id"),
|
|
62
|
+
batchId: string(input, "batch_id"),
|
|
63
|
+
sequence: number(input, "sequence"),
|
|
64
|
+
artifacts: rawArtifacts.map(parseArtifact),
|
|
65
|
+
edges: rawEdges.map(parseEdge),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Registers graph_projection.apply and graph_projection.checkpoint against one GraphProjection instance. */
|
|
70
|
+
/** This module's own operation names, the single source of truth src/service.ts's EXPECTED_OPERATION_NAMES spreads in rather than re-listing by hand. */
|
|
71
|
+
export const GRAPH_PROJECTION_OPERATION_NAMES = ["graph_projection.apply", "graph_projection.checkpoint"] as const;
|
|
72
|
+
|
|
73
|
+
export function graphProjectionOperations(artifacts: ArtifactStore, store: GraphProjectionStore, authority: AuthorityRegistry): OperationDefinition[] {
|
|
74
|
+
const projection = new GraphProjection(artifacts, store, authority);
|
|
75
|
+
const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
|
|
76
|
+
name, moduleId: MODULE_ID, execute,
|
|
77
|
+
});
|
|
78
|
+
return [
|
|
79
|
+
define("graph_projection.apply", (input: OperationInput) => projection.apply(parseBatch(input))),
|
|
80
|
+
define("graph_projection.checkpoint", (input: OperationInput) => projection.checkpoint(string(input, "producer_id"))),
|
|
81
|
+
];
|
|
82
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* modules/notes.ts — Notes as the first Papyrus-native registered module
|
|
3
|
+
* (step 5 of the incremental refactor in reducing-papyrus-consumer-change-amplification-with-modules--pvdo,
|
|
4
|
+
* combined with step 1 for a real proof-of-shape rather than an empty abstraction).
|
|
5
|
+
*
|
|
6
|
+
* Notes was chosen first because it owns no bespoke schema (it reuses the generic doc
|
|
7
|
+
* table via NOTE_SUBTYPE) and has exactly six operations — the smallest real module to
|
|
8
|
+
* prove the OperationRegistry shape against before extracting Tasks or Docs.
|
|
9
|
+
*
|
|
10
|
+
* This module does not import another module's infrastructure (src/task-service.ts,
|
|
11
|
+
* src/domain-services.ts, etc.) — only its own src/note-service.ts and the shared
|
|
12
|
+
* OperationInput parsing helpers, matching the "module code does not import another
|
|
13
|
+
* module's infrastructure" constraint.
|
|
14
|
+
*/
|
|
15
|
+
import type { OperationDefinition } from "../module-registry.ts";
|
|
16
|
+
import { Notes, type NoteDisposition } from "../note-service.ts";
|
|
17
|
+
|
|
18
|
+
const MODULE_ID = "notes";
|
|
19
|
+
|
|
20
|
+
type OperationInput = Record<string, unknown>;
|
|
21
|
+
|
|
22
|
+
function string(input: OperationInput, key: string): string {
|
|
23
|
+
const value = input[key];
|
|
24
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${key} is required`);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function optionalString(input: OperationInput, key: string): string | undefined {
|
|
29
|
+
const value = input[key];
|
|
30
|
+
if (value === undefined) return undefined;
|
|
31
|
+
if (typeof value !== "string") throw new Error(`${key} must be a string`);
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function optionalNumber(input: OperationInput, key: string): number | undefined {
|
|
36
|
+
const value = input[key];
|
|
37
|
+
if (value === undefined) return undefined;
|
|
38
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${key} must be a number`);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** This module's own operation names, the single source of truth src/service.ts's EXPECTED_OPERATION_NAMES spreads in rather than re-listing by hand. */
|
|
43
|
+
export const NOTES_OPERATION_NAMES = [
|
|
44
|
+
"notes.capture", "notes.list", "notes.show", "notes.consume", "notes.promote", "notes.archive",
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
/** Registers every notes.* operation against one Notes instance. Behavior is unchanged from the prior inline handlers in src/service.ts. */
|
|
48
|
+
export function notesOperations(notes: Notes): OperationDefinition[] {
|
|
49
|
+
const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
|
|
50
|
+
name, moduleId: MODULE_ID, execute,
|
|
51
|
+
});
|
|
52
|
+
return [
|
|
53
|
+
define("notes.capture", (input: OperationInput) => notes.capture({
|
|
54
|
+
body: string(input, "body"), title: optionalString(input, "title"), projectRoot: string(input, "project_root"),
|
|
55
|
+
actor: optionalString(input, "actor"), source: optionalString(input, "source"), sessionId: optionalString(input, "session_id"),
|
|
56
|
+
})),
|
|
57
|
+
define("notes.list", (input: OperationInput) => notes.list({
|
|
58
|
+
projectRoot: string(input, "project_root"), status: optionalString(input, "status") as "draft" | "active" | "archived" | undefined,
|
|
59
|
+
text: optionalString(input, "text"), limit: optionalNumber(input, "limit"),
|
|
60
|
+
})),
|
|
61
|
+
define("notes.show", (input: OperationInput) => notes.show(string(input, "id"), string(input, "project_root"))),
|
|
62
|
+
define("notes.consume", (input: OperationInput) => notes.consume(string(input, "id"), {
|
|
63
|
+
projectRoot: string(input, "project_root"), actor: optionalString(input, "actor"), source: optionalString(input, "source"),
|
|
64
|
+
sessionId: optionalString(input, "session_id"), reason: optionalString(input, "reason"),
|
|
65
|
+
})),
|
|
66
|
+
define("notes.promote", (input: OperationInput) => notes.promote(string(input, "id"), string(input, "target_id"), {
|
|
67
|
+
projectRoot: string(input, "project_root"), actor: optionalString(input, "actor"), source: optionalString(input, "source"),
|
|
68
|
+
sessionId: optionalString(input, "session_id"), reason: optionalString(input, "reason"),
|
|
69
|
+
})),
|
|
70
|
+
define("notes.archive", (input: OperationInput) => notes.archive(string(input, "id"), {
|
|
71
|
+
projectRoot: string(input, "project_root"), disposition: string(input, "disposition") as NoteDisposition,
|
|
72
|
+
actor: optionalString(input, "actor"), source: optionalString(input, "source"), sessionId: optionalString(input, "session_id"),
|
|
73
|
+
reason: optionalString(input, "reason"),
|
|
74
|
+
})),
|
|
75
|
+
];
|
|
76
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* modules/rules.ts — Rules as a Papyrus-native registered module
|
|
3
|
+
* (step 5, continued, of the incremental refactor in
|
|
4
|
+
* reducing-papyrus-consumer-change-amplification-with-modules--pvdo).
|
|
5
|
+
*
|
|
6
|
+
* rules.injectable is intentionally NOT registered here even though its operation name
|
|
7
|
+
* starts with "rules.": its implementation requires tasks.active() (the current Task
|
|
8
|
+
* Focus) to decide which scoped rules apply, a genuine cross-module concern. It stays a
|
|
9
|
+
* composition-root operation in src/service.ts rather than importing Tasks internals
|
|
10
|
+
* into this module or introducing a premature "modules call each other through the
|
|
11
|
+
* registry" convention.
|
|
12
|
+
*/
|
|
13
|
+
import { assignRuleProject, createRule, gateTaskWithRule, listRules, previewRule, showRule, transitionRule } from "../domain-services.ts";
|
|
14
|
+
import type { OperationDefinition } from "../module-registry.ts";
|
|
15
|
+
import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
|
|
16
|
+
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
17
|
+
|
|
18
|
+
const MODULE_ID = "rules";
|
|
19
|
+
|
|
20
|
+
type OperationInput = Record<string, unknown>;
|
|
21
|
+
|
|
22
|
+
function string(input: OperationInput, key: string): string {
|
|
23
|
+
const value = input[key];
|
|
24
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${key} is required`);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function optionalString(input: OperationInput, key: string): string | undefined {
|
|
29
|
+
const value = input[key];
|
|
30
|
+
if (value === undefined) return undefined;
|
|
31
|
+
if (typeof value !== "string") throw new Error(`${key} must be a string`);
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function optionalNumber(input: OperationInput, key: string): number | undefined {
|
|
36
|
+
const value = input[key];
|
|
37
|
+
if (value === undefined) return undefined;
|
|
38
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${key} must be a number`);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const eventContext = (input: OperationInput) => ({
|
|
43
|
+
actor: optionalString(input, "actor"),
|
|
44
|
+
source: optionalString(input, "source"),
|
|
45
|
+
sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const artifactFilter = (input: OperationInput) => ({
|
|
49
|
+
status: optionalString(input, "status"),
|
|
50
|
+
text: optionalString(input, "text"),
|
|
51
|
+
limit: optionalNumber(input, "limit"),
|
|
52
|
+
projectRoot: optionalString(input, "project_root"),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** Registers every rules.* operation except rules.injectable (see module comment). Behavior is unchanged from the prior inline handlers in src/service.ts. */
|
|
56
|
+
/** This module's own operation names, the single source of truth src/service.ts's EXPECTED_OPERATION_NAMES spreads in rather than re-listing by hand. rules.injectable is deliberately absent -- see the module comment above. */
|
|
57
|
+
export const RULES_OPERATION_NAMES = [
|
|
58
|
+
"rules.create", "rules.list", "rules.show", "rules.preview", "rules.enable", "rules.disable", "rules.gate", "rules.assign_project",
|
|
59
|
+
] as const;
|
|
60
|
+
|
|
61
|
+
export function rulesOperations(artifacts: ArtifactStore, scopes: ArtifactScopeStore): OperationDefinition[] {
|
|
62
|
+
const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
|
|
63
|
+
name, moduleId: MODULE_ID, execute,
|
|
64
|
+
});
|
|
65
|
+
return [
|
|
66
|
+
define("rules.create", (input: OperationInput) => createRule(artifacts, scopes, {
|
|
67
|
+
title: string(input, "title"), body: optionalString(input, "body"), condition: optionalString(input, "condition"),
|
|
68
|
+
action: optionalString(input, "rule_action") ?? optionalString(input, "governance_action"),
|
|
69
|
+
severity: optionalString(input, "severity") as "block" | "warn" | "info" | undefined,
|
|
70
|
+
labels: input["labels"] as string[] | undefined, extra: input["extra"] as Record<string, unknown> | undefined,
|
|
71
|
+
projectRoot: optionalString(input, "project_root"),
|
|
72
|
+
}, eventContext(input))),
|
|
73
|
+
define("rules.list", (input: OperationInput) => listRules(artifacts, scopes, artifactFilter(input))),
|
|
74
|
+
define("rules.show", (input: OperationInput) => showRule(artifacts, string(input, "id"))),
|
|
75
|
+
define("rules.preview", (input: OperationInput) => previewRule(artifacts, string(input, "id"))),
|
|
76
|
+
define("rules.enable", (input: OperationInput) => transitionRule(artifacts, string(input, "id"), "enable", eventContext(input))),
|
|
77
|
+
define("rules.disable", (input: OperationInput) => transitionRule(artifacts, string(input, "id"), "disable", eventContext(input))),
|
|
78
|
+
define("rules.gate", (input: OperationInput) => gateTaskWithRule(artifacts, string(input, "id"), string(input, "task_id"), eventContext(input))),
|
|
79
|
+
define("rules.assign_project", (input: OperationInput) => assignRuleProject(artifacts, scopes, string(input, "id"), optionalString(input, "project_root"))),
|
|
80
|
+
];
|
|
81
|
+
}
|