@danypops/papyrus 0.49.0 → 0.50.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/package.json +1 -1
- package/src/cli/docs-command.ts +92 -0
- package/src/docs/docs-service.ts +78 -2
- package/src/domain-service-shared.ts +22 -2
- package/src/handlers/docs.ts +80 -5
- package/src/handlers/registry.ts +1 -1
- package/src/modules/docs.ts +47 -7
- package/src/ops.ts +22 -10
- package/src/service.ts +6 -1
package/package.json
CHANGED
package/src/cli/docs-command.ts
CHANGED
|
@@ -114,6 +114,93 @@ const showCommand = buildCommand({
|
|
|
114
114
|
docs: { brief: "Show one Doc" },
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
interface CliArtifactScope {
|
|
118
|
+
artifactId: string;
|
|
119
|
+
mode: string;
|
|
120
|
+
projectIds: string[];
|
|
121
|
+
source: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function renderScope(scope: CliArtifactScope): string {
|
|
125
|
+
return scope.mode === "global" ? "global (applies to every project)" : `projects: ${scope.projectIds.join(", ")}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const scopeCommand = buildCommand({
|
|
129
|
+
func: async function (this: DocsContext, _flags: Record<string, never>, id: string) {
|
|
130
|
+
const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("docs.scope", { id });
|
|
131
|
+
render.call(this, scope, renderScope(scope));
|
|
132
|
+
},
|
|
133
|
+
parameters: { flags: {}, positional: { kind: "tuple", parameters: [{ brief: "Document id", parse: String, placeholder: "id" }] } },
|
|
134
|
+
docs: { brief: "Show a Doc's real project scope" },
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const setGlobalCommand = buildCommand({
|
|
138
|
+
func: async function (this: DocsContext, _flags: Record<string, never>, id: string) {
|
|
139
|
+
const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("docs.set_global", { id });
|
|
140
|
+
render.call(this, scope, renderScope(scope));
|
|
141
|
+
},
|
|
142
|
+
parameters: { flags: {}, positional: { kind: "tuple", parameters: [{ brief: "Document id", parse: String, placeholder: "id" }] } },
|
|
143
|
+
docs: { brief: "Make a Doc apply in every project" },
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const addProjectCommand = buildCommand({
|
|
147
|
+
func: async function (this: DocsContext, _flags: Record<string, never>, id: string, project: string) {
|
|
148
|
+
const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("docs.add_project", { id, project });
|
|
149
|
+
render.call(this, scope, renderScope(scope));
|
|
150
|
+
},
|
|
151
|
+
parameters: {
|
|
152
|
+
flags: {},
|
|
153
|
+
positional: {
|
|
154
|
+
kind: "tuple",
|
|
155
|
+
parameters: [
|
|
156
|
+
{ brief: "Document id", parse: String, placeholder: "id" },
|
|
157
|
+
{ brief: "Project id/name/alias/root to add", parse: String, placeholder: "project" },
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
docs: { brief: "Add one project to a Doc's membership" },
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const removeProjectCommand = buildCommand({
|
|
165
|
+
func: async function (this: DocsContext, _flags: Record<string, never>, id: string, project: string) {
|
|
166
|
+
const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("docs.remove_project", { id, project });
|
|
167
|
+
render.call(this, scope, renderScope(scope));
|
|
168
|
+
},
|
|
169
|
+
parameters: {
|
|
170
|
+
flags: {},
|
|
171
|
+
positional: {
|
|
172
|
+
kind: "tuple",
|
|
173
|
+
parameters: [
|
|
174
|
+
{ brief: "Document id", parse: String, placeholder: "id" },
|
|
175
|
+
{ brief: "Project id/name/alias/root to remove", parse: String, placeholder: "project" },
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
docs: { brief: "Remove one project from a Doc's membership" },
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const replaceProjectsCommand = buildCommand({
|
|
183
|
+
func: async function (this: DocsContext, flags: { projectsJson: string[] }, id: string) {
|
|
184
|
+
const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("docs.replace_projects", {
|
|
185
|
+
id,
|
|
186
|
+
projects: flags.projectsJson,
|
|
187
|
+
});
|
|
188
|
+
render.call(this, scope, renderScope(scope));
|
|
189
|
+
},
|
|
190
|
+
parameters: {
|
|
191
|
+
flags: {
|
|
192
|
+
projectsJson: {
|
|
193
|
+
brief: "JSON string array of project id/name/alias/root references",
|
|
194
|
+
kind: "parsed",
|
|
195
|
+
parse: parseStringArray,
|
|
196
|
+
placeholder: "json",
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
positional: { kind: "tuple", parameters: [{ brief: "Document id", parse: String, placeholder: "id" }] },
|
|
200
|
+
},
|
|
201
|
+
docs: { brief: "Replace a Doc's entire project membership" },
|
|
202
|
+
});
|
|
203
|
+
|
|
117
204
|
function buildStatusTransitionCommand(action: "activate" | "archive" | "reopen") {
|
|
118
205
|
return buildCommand({
|
|
119
206
|
func: async function (this: DocsContext, _flags: Record<string, never>, id: string) {
|
|
@@ -173,6 +260,11 @@ const app = buildApplication(
|
|
|
173
260
|
create: createCommand,
|
|
174
261
|
list: listCommand,
|
|
175
262
|
"assign-project": assignProjectCommand,
|
|
263
|
+
scope: scopeCommand,
|
|
264
|
+
"set-global": setGlobalCommand,
|
|
265
|
+
"add-project": addProjectCommand,
|
|
266
|
+
"remove-project": removeProjectCommand,
|
|
267
|
+
"replace-projects": replaceProjectsCommand,
|
|
176
268
|
show: showCommand,
|
|
177
269
|
activate: buildStatusTransitionCommand("activate"),
|
|
178
270
|
archive: buildStatusTransitionCommand("archive"),
|
package/src/docs/docs-service.ts
CHANGED
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
import { type Artifact, requireLocallyOwnedContent } from "../artifact/artifact.ts";
|
|
8
8
|
import type { ArtifactEventContext } from "../artifact/artifact-event.ts";
|
|
9
|
-
import type { ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
|
|
9
|
+
import type { ArtifactScope, ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
|
|
10
10
|
import type { ArtifactStore } from "../artifact/artifact-store.ts";
|
|
11
11
|
import type { ArtifactAction, AuthorityRegistry } from "../authority-registry.ts";
|
|
12
|
+
import { ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT } from "../constants.ts";
|
|
13
|
+
import { resolveProjectReference } from "../domain/project-registry.ts";
|
|
12
14
|
import { normalizeProjectRoot } from "../domain/task-scope.ts";
|
|
13
15
|
import {
|
|
14
16
|
assertBodyBounds,
|
|
@@ -23,6 +25,7 @@ import {
|
|
|
23
25
|
type UpdateContentInput,
|
|
24
26
|
} from "../domain-service-shared.ts";
|
|
25
27
|
import { NOTE_SUBTYPE } from "../note/note-service.ts";
|
|
28
|
+
import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
|
|
26
29
|
|
|
27
30
|
function rejectsNoteTemplate(artifacts: ArtifactStore, templateId: string | undefined, subtype: string | undefined): boolean {
|
|
28
31
|
if (subtype === NOTE_SUBTYPE) return true;
|
|
@@ -68,6 +71,8 @@ export interface CreateDocumentInput {
|
|
|
68
71
|
templateId?: string;
|
|
69
72
|
/** Optional at creation, unlike Tasks -- omitting it leaves the Doc in the unscoped bucket, matching today's default behavior for every existing caller. */
|
|
70
73
|
projectRoot?: string;
|
|
74
|
+
/** Bounded exact registered project references (id/name/alias/root) -- fail-closed unlike projectRoot's auto-register-by-root legacy form. Takes precedence over projectRoot when both are given. */
|
|
75
|
+
projectReferences?: string[];
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
export type UpdateDocumentInput = UpdateContentInput;
|
|
@@ -87,9 +92,13 @@ export function createDocument(
|
|
|
87
92
|
input: CreateDocumentInput,
|
|
88
93
|
authority: AuthorityRegistry,
|
|
89
94
|
context?: ArtifactEventContext,
|
|
95
|
+
registry?: ProjectRegistryStore,
|
|
90
96
|
): Artifact {
|
|
91
97
|
if (rejectsNoteTemplate(artifacts, input.templateId, input.subtype)) requireNotesFacade(authority, "docs");
|
|
92
98
|
authority.requireArtifactAllowed("doc", input.subtype ?? templateSubtype(artifacts, input.templateId), "create", "docs");
|
|
99
|
+
if (input.projectReferences !== undefined && input.projectReferences.length > 0 && registry === undefined) {
|
|
100
|
+
throw new Error("projectReferences requires a project registry");
|
|
101
|
+
}
|
|
93
102
|
const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
|
|
94
103
|
const document = artifacts.create(
|
|
95
104
|
{
|
|
@@ -107,7 +116,15 @@ export function createDocument(
|
|
|
107
116
|
},
|
|
108
117
|
context,
|
|
109
118
|
);
|
|
110
|
-
|
|
119
|
+
if (input.projectReferences !== undefined && input.projectReferences.length > 0) {
|
|
120
|
+
if (input.projectReferences.length > ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT) {
|
|
121
|
+
throw new Error(`projectReferences may include at most ${ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT} entries`);
|
|
122
|
+
}
|
|
123
|
+
const ids = input.projectReferences.map((reference) => resolveProjectReference(registry!, reference).id);
|
|
124
|
+
scopes.replaceProjects(document.id, ids, "explicit");
|
|
125
|
+
} else {
|
|
126
|
+
scopes.assign(document.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
|
|
127
|
+
}
|
|
111
128
|
return document;
|
|
112
129
|
}
|
|
113
130
|
|
|
@@ -130,6 +147,65 @@ export function assignDocumentProject(
|
|
|
130
147
|
return artifacts.get(id)!;
|
|
131
148
|
}
|
|
132
149
|
|
|
150
|
+
/**
|
|
151
|
+
* The multi-project scope surface docs.assign_project cannot express (more than one membership,
|
|
152
|
+
* or exact fail-closed reference resolution instead of assign's auto-register-by-root). Mirrors
|
|
153
|
+
* rules.ts's own ruleScope/setRuleGlobal/replaceRuleProjects/addRuleProject/removeRuleProject --
|
|
154
|
+
* id is resolved through requireDocument so these reject the same way against a non-Doc, unknown
|
|
155
|
+
* id, or a Note as every other docs.* mutation; the project REFERENCE (name/alias/root) is
|
|
156
|
+
* resolved through the shared registry's resolveProjectReference, so an unknown or ambiguous
|
|
157
|
+
* project fails closed with bounded candidates rather than silently creating a new registration.
|
|
158
|
+
*/
|
|
159
|
+
export function docScope(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string): ArtifactScope {
|
|
160
|
+
requireDocument(artifacts, id);
|
|
161
|
+
return scopes.scope(id);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function setDocGlobal(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string): ArtifactScope {
|
|
165
|
+
requireDocument(artifacts, id);
|
|
166
|
+
return scopes.setGlobal(id, "explicit");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function replaceDocProjects(
|
|
170
|
+
artifacts: ArtifactStore,
|
|
171
|
+
scopes: ArtifactScopeStore,
|
|
172
|
+
registry: ProjectRegistryStore,
|
|
173
|
+
id: string,
|
|
174
|
+
projectReferences: readonly string[],
|
|
175
|
+
): ArtifactScope {
|
|
176
|
+
requireDocument(artifacts, id);
|
|
177
|
+
if (projectReferences.length === 0) throw new Error("projectReferences must be non-empty; use docs.set_global to clear scoping");
|
|
178
|
+
if (projectReferences.length > ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT) {
|
|
179
|
+
throw new Error(`projectReferences may include at most ${ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT} entries`);
|
|
180
|
+
}
|
|
181
|
+
const ids = projectReferences.map((reference) => resolveProjectReference(registry, reference).id);
|
|
182
|
+
return scopes.replaceProjects(id, ids, "explicit");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function addDocProject(
|
|
186
|
+
artifacts: ArtifactStore,
|
|
187
|
+
scopes: ArtifactScopeStore,
|
|
188
|
+
registry: ProjectRegistryStore,
|
|
189
|
+
id: string,
|
|
190
|
+
projectReference: string,
|
|
191
|
+
): ArtifactScope {
|
|
192
|
+
requireDocument(artifacts, id);
|
|
193
|
+
const project = resolveProjectReference(registry, projectReference);
|
|
194
|
+
return scopes.addProject(id, project.id, "explicit");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function removeDocProject(
|
|
198
|
+
artifacts: ArtifactStore,
|
|
199
|
+
scopes: ArtifactScopeStore,
|
|
200
|
+
registry: ProjectRegistryStore,
|
|
201
|
+
id: string,
|
|
202
|
+
projectReference: string,
|
|
203
|
+
): ArtifactScope {
|
|
204
|
+
requireDocument(artifacts, id);
|
|
205
|
+
const project = resolveProjectReference(registry, projectReference);
|
|
206
|
+
return scopes.removeProject(id, project.id);
|
|
207
|
+
}
|
|
208
|
+
|
|
133
209
|
function requireDocument(artifacts: ArtifactStore, id: string): Artifact {
|
|
134
210
|
const document = requireKind(artifacts, id, "doc");
|
|
135
211
|
if (document.subtype === NOTE_SUBTYPE) throw new Error("note access requires a notes.* operation");
|
|
@@ -53,8 +53,10 @@ export interface ListFilter {
|
|
|
53
53
|
status?: string;
|
|
54
54
|
text?: string;
|
|
55
55
|
limit?: number;
|
|
56
|
-
/** When supplied, results are limited to artifacts
|
|
56
|
+
/** When supplied, results are limited to artifacts with EXACT membership in this project (or the unscoped bucket) -- audit semantics, never includes a global artifact unless it happens to also carry this exact membership (it never does; global and "projects" are mutually exclusive modes). Mutually exclusive with applicableToProjectRoot -- pass at most one. */
|
|
57
57
|
projectRoot?: string;
|
|
58
|
+
/** When supplied instead of projectRoot, results are APPLICABLE to this project: every global artifact, plus every artifact whose bounded membership set includes this registered project root -- "would this show up for someone working in this project", not "is this project its only home". Distinct from projectRoot's exact-membership audit semantics. An empty string is not accepted -- use normalizeProjectRoot's own validation. */
|
|
59
|
+
applicableToProjectRoot?: string;
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
/**
|
|
@@ -62,7 +64,8 @@ export interface ListFilter {
|
|
|
62
64
|
* via ArtifactScopeStore first and post-filter by kind/status/text (mirrors Tasks.list's
|
|
63
65
|
* established scoped-listing shape); otherwise fall back to the existing unscoped query
|
|
64
66
|
* path unchanged, so every caller that predates project scoping keeps working exactly as
|
|
65
|
-
* before.
|
|
67
|
+
* before. filter.applicableToProjectRoot takes a separate, additive branch -- see its own
|
|
68
|
+
* doc comment on ListFilter for why this is not the same query as filter.projectRoot.
|
|
66
69
|
*/
|
|
67
70
|
export function listScoped(
|
|
68
71
|
artifacts: ArtifactStore,
|
|
@@ -71,6 +74,23 @@ export function listScoped(
|
|
|
71
74
|
filter: ListFilter,
|
|
72
75
|
excludeSubtype?: string,
|
|
73
76
|
): Artifact[] {
|
|
77
|
+
if (filter.applicableToProjectRoot !== undefined) {
|
|
78
|
+
const limit = filter.limit ?? ARTIFACT_SCOPE_MAX_ARTIFACTS;
|
|
79
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > ARTIFACT_SCOPE_MAX_ARTIFACTS) {
|
|
80
|
+
throw new Error(`list limit must be between 1 and ${ARTIFACT_SCOPE_MAX_ARTIFACTS}`);
|
|
81
|
+
}
|
|
82
|
+
const projectRoot = normalizeProjectRoot(filter.applicableToProjectRoot);
|
|
83
|
+
// Bounded (not a genuinely unlimited table scan) by capping the underlying query at
|
|
84
|
+
// ARTIFACT_SCOPE_MAX_ARTIFACTS before the applicability filter -- the same bounded-
|
|
85
|
+
// approximation tradeoff listInjectableRules already makes for "active rules," accepted
|
|
86
|
+
// here since a real kind/status/text-matching artifact count beyond that bound is not a
|
|
87
|
+
// realistic Papyrus deployment shape.
|
|
88
|
+
return artifacts
|
|
89
|
+
.query({ kind, excludeSubtype, status: filter.status, text: filter.text, limit: ARTIFACT_SCOPE_MAX_ARTIFACTS })
|
|
90
|
+
.filter((artifact) => scopes.appliesToProjectRoot(artifact.id, projectRoot))
|
|
91
|
+
.sort((left, right) => right.updated_at.localeCompare(left.updated_at) || left.id.localeCompare(right.id))
|
|
92
|
+
.slice(0, limit);
|
|
93
|
+
}
|
|
74
94
|
if (filter.projectRoot === undefined)
|
|
75
95
|
return artifacts.query({ kind, excludeSubtype, status: filter.status, text: filter.text, limit: filter.limit });
|
|
76
96
|
const limit = filter.limit ?? ARTIFACT_SCOPE_MAX_ARTIFACTS;
|
package/src/handlers/docs.ts
CHANGED
|
@@ -9,10 +9,21 @@ import type { ArtifactStore } from "../artifact/artifact-store.ts";
|
|
|
9
9
|
import type { AuthorityRegistry } from "../authority-registry.ts";
|
|
10
10
|
import { listDocuments } from "../docs/docs-service.ts";
|
|
11
11
|
import { docsOperations } from "../modules/docs.ts";
|
|
12
|
+
import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
|
|
12
13
|
import { booleanProp, createOperationDefiner, numberProp, resolveArtifactIdWidened, stringProp, validationError } from "./shared.ts";
|
|
13
14
|
|
|
14
15
|
const OWNER = "docs";
|
|
15
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Resolves a doc's id from either an explicit id or its title. When project_root is given and
|
|
19
|
+
* the project-scoped search finds nothing, widens only to a doc that actually APPLIES to this
|
|
20
|
+
* project (global, or explicitly scoped to it via applicableToProjectRoot) -- never to a
|
|
21
|
+
* same-named doc that belongs to a different project. A prior version widened to every doc of
|
|
22
|
+
* that name across every project unconditionally once the scoped search came up empty, silently
|
|
23
|
+
* leaking a name-based mutation across project boundaries (the same real bug rules.ts's own
|
|
24
|
+
* resolveRuleId had before its own fix). A caller wanting a genuine all-project search can
|
|
25
|
+
* already get it by omitting project_root entirely -- unchanged, unscoped behavior.
|
|
26
|
+
*/
|
|
16
27
|
function resolveDocId(
|
|
17
28
|
artifacts: ArtifactStore,
|
|
18
29
|
scopes: ArtifactScopeStore,
|
|
@@ -26,7 +37,7 @@ function resolveDocId(
|
|
|
26
37
|
artifacts,
|
|
27
38
|
name,
|
|
28
39
|
() => listDocuments(artifacts, scopes, { text: name, projectRoot }),
|
|
29
|
-
projectRoot === undefined ? undefined : () => listDocuments(artifacts, scopes, { text: name }),
|
|
40
|
+
projectRoot === undefined ? undefined : () => listDocuments(artifacts, scopes, { text: name, applicableToProjectRoot: projectRoot }),
|
|
30
41
|
);
|
|
31
42
|
}
|
|
32
43
|
|
|
@@ -42,14 +53,15 @@ export function registerDocsVehicleOperations(
|
|
|
42
53
|
artifacts: ArtifactStore,
|
|
43
54
|
scopes: ArtifactScopeStore,
|
|
44
55
|
authority: AuthorityRegistry,
|
|
56
|
+
projectRegistry: ProjectRegistryStore,
|
|
45
57
|
): void {
|
|
46
|
-
const moduleOperations = new Map(docsOperations(artifacts, scopes, authority).map((op) => [op.name, op]));
|
|
58
|
+
const moduleOperations = new Map(docsOperations(artifacts, scopes, authority, projectRegistry).map((op) => [op.name, op]));
|
|
47
59
|
const call = (name: string, input: Record<string, unknown>): unknown => moduleOperations.get(name)!.execute(input);
|
|
48
60
|
const define = createOperationDefiner(registry, OWNER, "docs", ["docs:read", "docs:write"], call);
|
|
49
61
|
|
|
50
62
|
define(
|
|
51
63
|
"create",
|
|
52
|
-
"Creates a Doc -- descriptive knowledge, not actionable work. project_root is optional (omitted = unscoped).",
|
|
64
|
+
"Creates a Doc -- descriptive knowledge, not actionable work. project_root is optional (omitted = unscoped). projects (a list of exact registered project id/name/alias/root references) creates it bounded to several projects at once instead, taking precedence over project_root when both are given.",
|
|
53
65
|
"local-write",
|
|
54
66
|
{
|
|
55
67
|
title: stringProp,
|
|
@@ -59,6 +71,7 @@ export function registerDocsVehicleOperations(
|
|
|
59
71
|
extra: { type: "object" } as unknown as { type: string },
|
|
60
72
|
template_id: stringProp,
|
|
61
73
|
project_root: stringProp,
|
|
74
|
+
projects: { type: "array" } as unknown as { type: string },
|
|
62
75
|
},
|
|
63
76
|
["title"],
|
|
64
77
|
(input) => input,
|
|
@@ -66,9 +79,9 @@ export function registerDocsVehicleOperations(
|
|
|
66
79
|
|
|
67
80
|
define(
|
|
68
81
|
"list",
|
|
69
|
-
"Lists Docs matching an optional status/text filter
|
|
82
|
+
"Lists Docs matching an optional status/text filter. project_root alone scopes to EXACT membership in that project (audit semantics); project_root plus applicable:true instead lists every Doc APPLICABLE to it (global Docs plus Docs whose membership includes it). Returns a lean summary (no body) by default -- pass full: true for the complete artifact.",
|
|
70
83
|
"read",
|
|
71
|
-
{ status: stringProp, text: stringProp, limit: numberProp, project_root: stringProp, full: booleanProp },
|
|
84
|
+
{ status: stringProp, text: stringProp, limit: numberProp, project_root: stringProp, applicable: booleanProp, full: booleanProp },
|
|
72
85
|
[],
|
|
73
86
|
(input) => input,
|
|
74
87
|
);
|
|
@@ -134,6 +147,68 @@ export function registerDocsVehicleOperations(
|
|
|
134
147
|
(input) => ({ ...input, id: resolveDocId(artifacts, scopes, undefined, input.id, input.name) }),
|
|
135
148
|
);
|
|
136
149
|
|
|
150
|
+
define(
|
|
151
|
+
"scope",
|
|
152
|
+
"Shows a Doc's real project scope: global (applies everywhere) or the bounded set of registered projects it applies to.",
|
|
153
|
+
"read",
|
|
154
|
+
{ id: stringProp, name: stringProp, project_root: stringProp },
|
|
155
|
+
[],
|
|
156
|
+
(input) => ({ ...input, id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
define(
|
|
160
|
+
"set_global",
|
|
161
|
+
"Makes a Doc apply in every project, clearing any project membership. The only way to widen a project-bound Doc back to global -- removing its last membership through remove_project is rejected instead.",
|
|
162
|
+
"local-write",
|
|
163
|
+
{ id: stringProp, name: stringProp, project_root: stringProp },
|
|
164
|
+
[],
|
|
165
|
+
(input) => ({ ...input, id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
define(
|
|
169
|
+
"add_project",
|
|
170
|
+
"Adds one registered project (exact id, name, alias, or root) to a Doc's membership, switching it from global to project-bound if it was global. Idempotent if the project is already a member.",
|
|
171
|
+
"local-write",
|
|
172
|
+
{
|
|
173
|
+
id: stringProp,
|
|
174
|
+
name: stringProp,
|
|
175
|
+
project: { ...stringProp, description: "Exact project id, name, alias, or registered root to add." },
|
|
176
|
+
project_root: stringProp,
|
|
177
|
+
},
|
|
178
|
+
["project"],
|
|
179
|
+
(input) => ({ ...input, id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
define(
|
|
183
|
+
"remove_project",
|
|
184
|
+
"Removes one registered project from a Doc's membership. Rejected while it is the Doc's only remaining membership -- call set_global first if the Doc should stop being project-bound entirely.",
|
|
185
|
+
"local-write",
|
|
186
|
+
{
|
|
187
|
+
id: stringProp,
|
|
188
|
+
name: stringProp,
|
|
189
|
+
project: { ...stringProp, description: "Exact project id, name, alias, or registered root to remove." },
|
|
190
|
+
project_root: stringProp,
|
|
191
|
+
},
|
|
192
|
+
["project"],
|
|
193
|
+
(input) => ({ ...input, id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
define(
|
|
197
|
+
"replace_projects",
|
|
198
|
+
"Replaces a Doc's entire project membership with exactly this bounded, non-empty list of registered project references (id/name/alias/root). Use set_global instead to clear scoping entirely.",
|
|
199
|
+
"local-write",
|
|
200
|
+
{
|
|
201
|
+
id: stringProp,
|
|
202
|
+
name: stringProp,
|
|
203
|
+
projects: { type: "array", description: "Non-empty list of exact project id/name/alias/root references." } as unknown as {
|
|
204
|
+
type: string;
|
|
205
|
+
},
|
|
206
|
+
project_root: stringProp,
|
|
207
|
+
},
|
|
208
|
+
["projects"],
|
|
209
|
+
(input) => ({ ...input, id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
|
|
210
|
+
);
|
|
211
|
+
|
|
137
212
|
define(
|
|
138
213
|
"update",
|
|
139
214
|
"Changes a Doc's title/body/labels (at least one required). Refused for a read-only external projection (e.g. web-spider-ingested Docs) -- capture a correction as a new linked Doc instead.",
|
package/src/handlers/registry.ts
CHANGED
|
@@ -51,7 +51,7 @@ export function createPapyrusVehicleRegistry(deps: PapyrusVehicleDeps): VehicleR
|
|
|
51
51
|
registry.setExposeHandlerFailureDetails(true);
|
|
52
52
|
registerNotesVehicleOperations(registry, deps.notes, deps.artifacts);
|
|
53
53
|
registerRulesVehicleOperations(registry, deps.artifacts, deps.scopes, deps.projectRegistry);
|
|
54
|
-
registerDocsVehicleOperations(registry, deps.artifacts, deps.scopes, deps.authority);
|
|
54
|
+
registerDocsVehicleOperations(registry, deps.artifacts, deps.scopes, deps.authority, deps.projectRegistry);
|
|
55
55
|
registerPlaybooksVehicleOperations(registry, {
|
|
56
56
|
artifacts: deps.artifacts,
|
|
57
57
|
events: deps.events,
|
package/src/modules/docs.ts
CHANGED
|
@@ -12,16 +12,22 @@ import type { ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
|
|
|
12
12
|
import type { ArtifactStore } from "../artifact/artifact-store.ts";
|
|
13
13
|
import type { AuthorityRegistry } from "../authority-registry.ts";
|
|
14
14
|
import {
|
|
15
|
+
addDocProject,
|
|
15
16
|
assignDocumentProject,
|
|
16
17
|
createDocument,
|
|
17
18
|
type DocumentRelation,
|
|
19
|
+
docScope,
|
|
18
20
|
linkDocument,
|
|
19
21
|
listDocuments,
|
|
22
|
+
removeDocProject,
|
|
23
|
+
replaceDocProjects,
|
|
24
|
+
setDocGlobal,
|
|
20
25
|
showDocument,
|
|
21
26
|
transitionDocument,
|
|
22
27
|
updateDocument,
|
|
23
28
|
} from "../docs/docs-service.ts";
|
|
24
29
|
import type { OperationDefinition } from "../module-registry.ts";
|
|
30
|
+
import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
|
|
25
31
|
import { type OperationInput, optionalBoolean, optionalNumber, optionalString, string } from "./operation-input.ts";
|
|
26
32
|
|
|
27
33
|
const MODULE_ID = "docs";
|
|
@@ -32,12 +38,23 @@ const eventContext = (input: OperationInput) => ({
|
|
|
32
38
|
sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"),
|
|
33
39
|
});
|
|
34
40
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
/**
|
|
42
|
+
* applicable=true switches project_root's meaning from exact-membership audit listing to
|
|
43
|
+
* applicable listing (global Docs plus Docs whose bounded membership includes this project) --
|
|
44
|
+
* see ListFilter's own doc comment on projectRoot vs applicableToProjectRoot for why these are
|
|
45
|
+
* two distinct, non-overlapping query modes rather than one.
|
|
46
|
+
*/
|
|
47
|
+
const artifactFilter = (input: OperationInput) => {
|
|
48
|
+
const projectRoot = optionalString(input, "project_root");
|
|
49
|
+
const applicable = optionalBoolean(input, "applicable") === true;
|
|
50
|
+
if (applicable && projectRoot === undefined) throw new Error("applicable requires project_root");
|
|
51
|
+
return {
|
|
52
|
+
status: optionalString(input, "status"),
|
|
53
|
+
text: optionalString(input, "text"),
|
|
54
|
+
limit: optionalNumber(input, "limit"),
|
|
55
|
+
...(applicable ? { applicableToProjectRoot: projectRoot } : { projectRoot }),
|
|
56
|
+
};
|
|
57
|
+
};
|
|
41
58
|
|
|
42
59
|
/** Registers every docs.* operation against the shared ArtifactStore port. Behavior is unchanged from the prior inline handlers in src/service.ts. */
|
|
43
60
|
/** 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. */
|
|
@@ -50,10 +67,20 @@ export const DOCS_OPERATION_NAMES = [
|
|
|
50
67
|
"docs.reopen",
|
|
51
68
|
"docs.link",
|
|
52
69
|
"docs.assign_project",
|
|
70
|
+
"docs.scope",
|
|
71
|
+
"docs.set_global",
|
|
72
|
+
"docs.add_project",
|
|
73
|
+
"docs.remove_project",
|
|
74
|
+
"docs.replace_projects",
|
|
53
75
|
"docs.update",
|
|
54
76
|
] as const;
|
|
55
77
|
|
|
56
|
-
export function docsOperations(
|
|
78
|
+
export function docsOperations(
|
|
79
|
+
artifacts: ArtifactStore,
|
|
80
|
+
scopes: ArtifactScopeStore,
|
|
81
|
+
authority: AuthorityRegistry,
|
|
82
|
+
registry: ProjectRegistryStore,
|
|
83
|
+
): OperationDefinition[] {
|
|
57
84
|
const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
|
|
58
85
|
name,
|
|
59
86
|
moduleId: MODULE_ID,
|
|
@@ -72,9 +99,11 @@ export function docsOperations(artifacts: ArtifactStore, scopes: ArtifactScopeSt
|
|
|
72
99
|
extra: input.extra as Record<string, unknown> | undefined,
|
|
73
100
|
templateId: optionalString(input, "template_id") ?? optionalString(input, "templateId"),
|
|
74
101
|
projectRoot: optionalString(input, "project_root"),
|
|
102
|
+
projectReferences: input.projects as string[] | undefined,
|
|
75
103
|
},
|
|
76
104
|
authority,
|
|
77
105
|
eventContext(input),
|
|
106
|
+
registry,
|
|
78
107
|
),
|
|
79
108
|
),
|
|
80
109
|
define("docs.list", (input: OperationInput) => {
|
|
@@ -104,6 +133,17 @@ export function docsOperations(artifacts: ArtifactStore, scopes: ArtifactScopeSt
|
|
|
104
133
|
define("docs.assign_project", (input: OperationInput) =>
|
|
105
134
|
assignDocumentProject(artifacts, scopes, string(input, "id"), optionalString(input, "project_root")),
|
|
106
135
|
),
|
|
136
|
+
define("docs.scope", (input: OperationInput) => docScope(artifacts, scopes, string(input, "id"))),
|
|
137
|
+
define("docs.set_global", (input: OperationInput) => setDocGlobal(artifacts, scopes, string(input, "id"))),
|
|
138
|
+
define("docs.add_project", (input: OperationInput) =>
|
|
139
|
+
addDocProject(artifacts, scopes, registry, string(input, "id"), string(input, "project")),
|
|
140
|
+
),
|
|
141
|
+
define("docs.remove_project", (input: OperationInput) =>
|
|
142
|
+
removeDocProject(artifacts, scopes, registry, string(input, "id"), string(input, "project")),
|
|
143
|
+
),
|
|
144
|
+
define("docs.replace_projects", (input: OperationInput) =>
|
|
145
|
+
replaceDocProjects(artifacts, scopes, registry, string(input, "id"), (input.projects as string[] | undefined) ?? []),
|
|
146
|
+
),
|
|
107
147
|
define("docs.update", (input: OperationInput) =>
|
|
108
148
|
updateDocument(
|
|
109
149
|
artifacts,
|
package/src/ops.ts
CHANGED
|
@@ -599,22 +599,34 @@ function readBoundedGateFile(path: string): string {
|
|
|
599
599
|
return readFileSync(path, "utf-8") as string;
|
|
600
600
|
}
|
|
601
601
|
|
|
602
|
-
/**
|
|
603
|
-
*
|
|
602
|
+
/**
|
|
603
|
+
* Shared by the sync and async process-gate runners so "test" is never a second, independently
|
|
604
|
+
* maintained copy of "command"'s own command-template selection.
|
|
605
|
+
*
|
|
606
|
+
* "test" runs `gate.target` verbatim, exactly like "command" -- the only real difference is a
|
|
607
|
+
* more generous default timeout (GATE_TEST_TIMEOUT_MS vs GATE_COMMAND_TIMEOUT_MS), since a test
|
|
608
|
+
* suite routinely runs longer than an arbitrary command. It previously wrapped target in
|
|
609
|
+
* `npx vitest run ${target} --reporter=dot`, silently wrong for every real consumer in this
|
|
610
|
+
* ecosystem (all Bun-native, none use vitest): a target that was itself a full command (e.g.
|
|
611
|
+
* `bun test path/to.test.ts`, exactly what every existing gate/checklist example here has always
|
|
612
|
+
* shown) got parsed by vitest as three separate positional args, triggering vitest's own broad
|
|
613
|
+
* discovery across the whole repo instead of running the intended command at all -- a real
|
|
614
|
+
* incident (task ab1463e2) that produced an unrelated multi-suite vitest failure cascade instead
|
|
615
|
+
* of the actual target ever running.
|
|
616
|
+
*/
|
|
604
617
|
function processGateCommand(gate: Gate): { command: string; timeout: number } {
|
|
605
|
-
if (gate.type === "test")
|
|
606
|
-
return { command: `npx vitest run ${gate.target} --reporter=dot`, timeout: gate.timeoutMs ?? GATE_TEST_TIMEOUT_MS };
|
|
618
|
+
if (gate.type === "test") return { command: gate.target, timeout: gate.timeoutMs ?? GATE_TEST_TIMEOUT_MS };
|
|
607
619
|
return { command: gate.target, timeout: gate.timeoutMs ?? GATE_COMMAND_TIMEOUT_MS };
|
|
608
620
|
}
|
|
609
621
|
|
|
610
622
|
/**
|
|
611
623
|
* spawnSync + manual stdout/stderr concatenation, not execSync: execSync's return value is stdout
|
|
612
|
-
* only. Many real commands (bun test's own per-test lines and its pass/fail summary among them
|
|
613
|
-
*
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
624
|
+
* only. Many real commands (bun test's own per-test lines and its pass/fail summary among them)
|
|
625
|
+
* write their actual output to stderr, so an execSync-based match against gate.expect saw only the
|
|
626
|
+
* first line of a banner and never the result -- every such gate failed regardless of whether the
|
|
627
|
+
* command actually passed. This one function now serves both "command" and "test" gates;
|
|
628
|
+
* previously "test" was a second, separately-maintained execSync path that never checked
|
|
629
|
+
* gate.expect at all.
|
|
618
630
|
*/
|
|
619
631
|
function runProcessGateSync(gate: Gate, cwd?: string): GateResult {
|
|
620
632
|
const { spawnSync } = require_("node:child_process");
|
package/src/service.ts
CHANGED
|
@@ -406,6 +406,11 @@ function handlers(
|
|
|
406
406
|
"docs.reopen": forwardToModule("docs.reopen"),
|
|
407
407
|
"docs.link": forwardToModule("docs.link"),
|
|
408
408
|
"docs.assign_project": forwardToModule("docs.assign_project"),
|
|
409
|
+
"docs.scope": forwardToModule("docs.scope"),
|
|
410
|
+
"docs.set_global": forwardToModule("docs.set_global"),
|
|
411
|
+
"docs.add_project": forwardToModule("docs.add_project"),
|
|
412
|
+
"docs.remove_project": forwardToModule("docs.remove_project"),
|
|
413
|
+
"docs.replace_projects": forwardToModule("docs.replace_projects"),
|
|
409
414
|
"docs.update": forwardToModule("docs.update"),
|
|
410
415
|
"notes.capture": forwardToModule("notes.capture"),
|
|
411
416
|
"notes.list": forwardToModule("notes.list"),
|
|
@@ -498,7 +503,7 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
498
503
|
moduleRegistry.registerAll(sessionIdentityOperations(sessionIdentity));
|
|
499
504
|
moduleRegistry.registerAll(discussOperations(discussions));
|
|
500
505
|
moduleRegistry.registerAll(tasksOperations(tasks, artifacts, sessionIdentity));
|
|
501
|
-
moduleRegistry.registerAll(docsOperations(artifacts, artifactScopes, authority));
|
|
506
|
+
moduleRegistry.registerAll(docsOperations(artifacts, artifactScopes, authority, projectRegistry));
|
|
502
507
|
moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes, projectRegistry));
|
|
503
508
|
moduleRegistry.registerAll(playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks, sessionIdentity }));
|
|
504
509
|
moduleRegistry.registerAll(graphProjectionOperations(artifacts, projections, authority));
|