@danypops/papyrus 0.21.6 → 0.22.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
@@ -119,9 +119,9 @@ Agent-facing domain tools own lifecycle invariants and sit above this store API:
119
119
 
120
120
  - **`tasks`** — create/update/list/show/plan, manage the singleton active focus, replace evidence-bearing checklists, hierarchy/dependencies, lifecycle transitions, non-blocking gates, and review completion that focuses one deterministic ready successor without claiming effort
121
121
  - **`notes`** — capture/list/show deferred human intent, mark it consumed, promote it to an existing Task/Doc/Rule/Skill, or archive it with an explicit disposition
122
- - **`docs`** — create/list/show, activate/archive/reopen, and document-safe graph links; Note mutations remain behind the Notes facade
123
- - **`rules`** — create/list/show/preview, enable/disable, and attach governance gates to tasks
124
- - **`skills`** — create/list/show/invoke/run, enable/disable, create compatibility templates, and atomically instantiate parameterized workflow runs
122
+ - **`docs`** — create/update/list/show, activate/archive/reopen, and document-safe graph links; Note mutations remain behind the Notes facade
123
+ - **`rules`** — create/update/list/show/preview, enable/disable, and attach governance gates to tasks
124
+ - **`skills`** — create/update/list/show/invoke/run, enable/disable, create compatibility templates, and atomically instantiate parameterized workflow runs
125
125
 
126
126
  Every tool operation is registered in the daemon’s `/api/v1/ops` registry; parity is verified in tests. The task consumer uses the `tasks.graph` operation, which returns task nodes with explicit parent, child, and dependency IDs rather than leaking SQLite rows or asking the UI to reconstruct relationships.
127
127
 
@@ -131,14 +131,18 @@ Internally, application services depend on the `ArtifactStore` and `GateRunner`
131
131
 
132
132
  Every agent domain tool (tasks, docs, rules, skills, 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), `template_name` (skills instantiate), 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.
133
133
 
134
+ ### Mutability
135
+
136
+ Tasks, Docs, Rules, and Skills 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/Skills 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.
137
+
134
138
  ## Interactive frontends
135
139
 
136
140
  - `/tasks` — project/focused-graph scope, task lifecycle, append-only history, gates, dependencies, and nested metadata
137
141
  - `/note <request>` — directly capture one project-scoped deferred request without creating a Task
138
142
  - `/notes` — searchable project Notes inbox with consume, promote, and disposition-aware archive actions
139
- - `/docs` — searchable non-Note documents, lifecycle, details, and graph links
140
- - `/rules` — severity/condition rows, exact injection preview, enable/disable, and task gating
141
- - `/skills` — trigger/tools rows, invocation into the editor, and artifact templates
143
+ - `/docs` — searchable non-Note documents, lifecycle, details, edit, and graph links
144
+ - `/rules` — severity/condition rows, exact injection preview, edit, enable/disable, and task gating
145
+ - `/skills` — trigger/tools rows, edit, invocation into the editor, and artifact templates
142
146
 
143
147
  All frontends use daemon-backed domain operations; none opens SQLite from the Pi process. **Show details** opens a bounded navigable view across Tasks, Notes, Docs, Rules, legacy Skills, templates, and workflow Skills. User-authored bodies render as width-aware Markdown with headings, emphasis, links, quotes, lists, tables, inline/fenced code, syntax highlighting, and every color/decorative style derived dynamically from the active Pi theme. Generated lifecycle, metadata, checklist, gate, history, and relationship sections keep explicit semantic theme colors. `↑/↓` scrolls, `←/→` pans wide relationships, and Esc returns to the browser; non-interactive clients receive stable source text.
144
148
 
@@ -6,12 +6,12 @@
6
6
  * anywhere in @earendil-works/pi-coding-agent or pi-tui (checked both) -- so this is a small,
7
7
  * genuinely domain-specific checkbox-list component, not a generic library replacement.
8
8
  */
9
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
9
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
10
  import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
11
11
  import type { DiscussionOptionsMode } from "../../src/domain/discussion.ts";
12
12
 
13
13
  /** Toggle with space, confirm with enter (refuses an empty confirm -- at least one pick is required), cancel with escape. */
14
- async function pickMultiple(ctx: ExtensionCommandContext, title: string, options: string[]): Promise<string[] | undefined> {
14
+ async function pickMultiple(ctx: ExtensionContext, title: string, options: string[]): Promise<string[] | undefined> {
15
15
  return ctx.ui.custom<string[] | undefined>((tui, theme, _keybindings, done) => {
16
16
  const checked = new Set<number>();
17
17
  let selectedIndex = 0;
@@ -49,8 +49,13 @@ async function pickMultiple(ctx: ExtensionCommandContext, title: string, options
49
49
  });
50
50
  }
51
51
 
52
- /** Picks one (single) or several (multi) of the given options, or undefined if the user cancels. */
53
- export async function pickDiscussionOptions(ctx: ExtensionCommandContext, mode: DiscussionOptionsMode, options: string[]): Promise<string[] | undefined> {
52
+ /**
53
+ * Picks one (single) or several (multi) of the given options, or undefined if the user cancels.
54
+ * Takes the base ExtensionContext (just .ui) rather than the wider ExtensionCommandContext, since
55
+ * a tool's execute() only ever receives the former -- the discuss tool's own live mode reuses
56
+ * this same picker, not just the /discuss TUI panel.
57
+ */
58
+ export async function pickDiscussionOptions(ctx: ExtensionContext, mode: DiscussionOptionsMode, options: string[]): Promise<string[] | undefined> {
54
59
  if (mode === "single") {
55
60
  const pick = await ctx.ui.select("Pick one:", options);
56
61
  return pick ? [pick] : undefined;
@@ -24,12 +24,21 @@ export async function showDocs(ctx: ExtensionCommandContext): Promise<void> {
24
24
  statusOrder: ["draft", "active", "archived"],
25
25
  presentation: DOC_STATUS_PRESENTATION,
26
26
  rowMeta: documentRowMeta,
27
- actions: (document) => ["Show details", "Link artifact", ...(DOC_ACTIONS[document.status] ?? [])],
27
+ actions: (document) => ["Show details", "Edit", "Link artifact", ...(DOC_ACTIONS[document.status] ?? [])],
28
28
  handleAction: async (choice, document, commandCtx) => {
29
29
  if (choice === "Show details") {
30
30
  await showArtifactDetails(commandCtx, document.id, "docs.show");
31
31
  return;
32
32
  }
33
+ if (choice === "Edit") {
34
+ const title = await commandCtx.ui.input("Title:", document.title);
35
+ if (title === undefined) return; // canceled
36
+ const body = await commandCtx.ui.input("Body:", document.body);
37
+ if (body === undefined) return; // canceled
38
+ const updated = await callService<Record<string, unknown>, Artifact>("docs.update", { id: document.id, title, body });
39
+ commandCtx.ui.notify(`Updated "${updated.title}"`, "info");
40
+ return;
41
+ }
33
42
  if (choice === "Link artifact") {
34
43
  const targetId = await commandCtx.ui.input("Target artifact id:", "");
35
44
  if (!targetId) return;
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import type { Artifact } from "../../src/domain/artifact.ts";
4
4
  import { PROOF_TYPES } from "../../src/domain/checklist.ts";
@@ -8,7 +8,8 @@ import type { TaskHistoryPage } from "../../src/domain/task-event.ts";
8
8
  import type { TaskCompletion, TaskGraph } from "../../src/task-service.ts";
9
9
  import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
10
10
  import type { DiscussionAndRounds } from "../../src/discussion-service.ts";
11
- import type { DiscussionRound } from "../../src/domain/discussion.ts";
11
+ import { readDiscussionExtra, type DiscussionRound } from "../../src/domain/discussion.ts";
12
+ import { pickDiscussionOptions } from "./discussion-picker.ts";
12
13
  import type { OperationName } from "../../src/service.ts";
13
14
  import { emitTaskFocusEvent } from "./task-focus-events.ts";
14
15
  import { sessionSecretField } from "./session-identity.ts";
@@ -30,6 +31,24 @@ function text(message: string, details: unknown = {}) {
30
31
  return { content: [{ type: "text" as const, text: modelContent.text }], details };
31
32
  }
32
33
 
34
+ /**
35
+ * live:true's synchronous half: renders the same picker /discuss's own "Reply" action uses when
36
+ * the just-created round posed a structured choice, or a plain freeform prompt otherwise -- so
37
+ * "ask" covers both a completely open question and a choice tied to this specific Discussion.
38
+ * Returns undefined on cancel or when no interactive UI is available, never throws -- an
39
+ * unanswered live prompt still leaves the round it already recorded intact.
40
+ */
41
+ async function liveAnswer(ctx: ExtensionContext, discussion: Artifact): Promise<{ content: string; selected?: string[] } | undefined> {
42
+ if (!ctx.hasUI) return undefined;
43
+ const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
44
+ if (pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode) {
45
+ const selected = await pickDiscussionOptions(ctx, pending.pendingOptionsMode, pending.pendingOptions);
46
+ return selected ? { content: selected.join(", "), selected } : undefined;
47
+ }
48
+ const content = await ctx.ui.input(`Reply to "${discussion.title}":`, "");
49
+ return content ? { content } : undefined;
50
+ }
51
+
33
52
  /**
34
53
  * Every domain tool's primary interfacing point is an artifact's NAME, not its id -- id is a
35
54
  * backend implementation detail (a stable key other operations need, and titles aren't
@@ -397,7 +416,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
397
416
  pi.registerTool({
398
417
  name: "docs",
399
418
  label: "Documents",
400
- description: "Document domain tool. ACTIONS: create, list, show, activate, archive, reopen, link, assign_project, remove, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. remove moves a Doc to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the doc's exact title) over `id`, and `target_name` over `target_id` for link -- both are backend implementation details, resolved from name automatically (target_name searches across every kind, since a link target can be a doc, task, rule, or skill). Prefer this over low-level papyrus_* tools for document work.",
419
+ description: "Document domain tool. ACTIONS: create, list, show, activate, archive, reopen, link, assign_project, update, remove, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. update changes title/body/labels (at least one required) and is refused for a read-only external projection (e.g. web-spider-ingested Docs) -- capture a correction as a new linked Doc instead. remove moves a Doc to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the doc's exact title) over `id`, and `target_name` over `target_id` for link -- both are backend implementation details, resolved from name automatically (target_name searches across every kind, since a link target can be a doc, task, rule, or skill). Prefer this over low-level papyrus_* tools for document work.",
401
420
  parameters: Type.Object({
402
421
  action: Type.String(),
403
422
  id: Type.Optional(Type.String()),
@@ -443,7 +462,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
443
462
  }
444
463
  const trashResult = await handleArtifactRemoveRestore(action, params);
445
464
  if (trashResult) return trashResult;
446
- const operations = { activate: "docs.activate", archive: "docs.archive", reopen: "docs.reopen", link: "docs.link", assign_project: "docs.assign_project" } as const;
465
+ const operations = { activate: "docs.activate", archive: "docs.archive", reopen: "docs.reopen", link: "docs.link", assign_project: "docs.assign_project", update: "docs.update" } as const;
447
466
  const operation = operations[action as keyof typeof operations];
448
467
  if (!operation) throw new Error(`unknown docs action: ${action}`);
449
468
  const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
@@ -457,7 +476,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
457
476
  pi.registerTool({
458
477
  name: "rules",
459
478
  label: "Rules",
460
- description: "Rule domain tool. ACTIONS: create, list, show, preview, enable, disable, gate, assign_project, remove, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. Active rules inject into the agent system prompt. remove moves a Rule to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the rule's exact title) over `id`, and `task_name` over `task_id` for gate -- both are backend implementation details, resolved from name automatically.",
479
+ description: "Rule domain tool. ACTIONS: create, list, show, preview, enable, disable, gate, assign_project, update, remove, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. Active rules inject into the agent system prompt. update changes title/body/labels (at least one required); body updates still enforce the same combined condition+action+body context-tax bound as creation, and are refused for a read-only external projection. remove moves a Rule to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the rule's exact title) over `id`, and `task_name` over `task_id` for gate -- both are backend implementation details, resolved from name automatically.",
461
480
  parameters: Type.Object({
462
481
  action: Type.String(), id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
463
482
  body: Type.Optional(Type.String()), condition: Type.Optional(Type.String()), rule_action: Type.Optional(Type.String()),
@@ -491,7 +510,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
491
510
  }
492
511
  const trashResult = await handleArtifactRemoveRestore(action, params);
493
512
  if (trashResult) return trashResult;
494
- const operations = { show: "rules.show", enable: "rules.enable", disable: "rules.disable", gate: "rules.gate", assign_project: "rules.assign_project" } as const;
513
+ const operations = { show: "rules.show", enable: "rules.enable", disable: "rules.disable", gate: "rules.gate", assign_project: "rules.assign_project", update: "rules.update" } as const;
495
514
  const operation = operations[action as keyof typeof operations];
496
515
  if (!operation) throw new Error(`unknown rules action: ${action}`);
497
516
  const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
@@ -505,7 +524,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
505
524
  pi.registerTool({
506
525
  name: "skills",
507
526
  label: "Skills",
508
- description: "Papyrus Skill workflow and compatibility-template domain tool. Papyrus Skills are parameterized Task/Rule/Doc bundles, distinct from prompt-only skills. ACTIONS: create, create_template, list, show, invoke, run, enable, disable, instantiate, assign_project, remove, restore. run validates arguments and atomically creates one scoped workflow run. project_root is optional at creation (omitted = unscoped) for create/create_template; assign_project reassigns it later, or unscopes when project_root is omitted. remove moves a Skill to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the skill's exact title) over `id`, and `template_name` over `template_id` for instantiate -- both are backend implementation details, resolved from name automatically.",
527
+ description: "Papyrus Skill workflow and compatibility-template domain tool. Papyrus Skills are parameterized Task/Rule/Doc bundles, distinct from prompt-only skills. ACTIONS: create, create_template, list, show, invoke, run, enable, disable, instantiate, assign_project, update, remove, restore. run validates arguments and atomically creates one scoped workflow run. project_root is optional at creation (omitted = unscoped) for create/create_template; assign_project reassigns it later, or unscopes when project_root is omitted. update changes title/body/labels (at least one required) and is refused for a read-only external projection. remove moves a Skill to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the skill's exact title) over `id`, and `template_name` over `template_id` for instantiate -- both are backend implementation details, resolved from name automatically.",
509
528
  parameters: Type.Object({
510
529
  action: Type.String(), id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
511
530
  body: Type.Optional(Type.String()), trigger: Type.Optional(Type.String()), steps: Type.Optional(Type.Array(Type.String())),
@@ -570,7 +589,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
570
589
  }
571
590
  const trashResult = await handleArtifactRemoveRestore(action, params);
572
591
  if (trashResult) return trashResult;
573
- const operations = { show: "skills.show", enable: "skills.enable", disable: "skills.disable", instantiate: "skills.instantiate", assign_project: "skills.assign_project" } as const;
592
+ const operations = { show: "skills.show", enable: "skills.enable", disable: "skills.disable", instantiate: "skills.instantiate", assign_project: "skills.assign_project", update: "skills.update" } as const;
574
593
  const operation = operations[action as keyof typeof operations];
575
594
  if (!operation) throw new Error(`unknown skills action: ${action}`);
576
595
  const artifact = await callService<Record<string, unknown>, Artifact>(operation, action === "instantiate" ? request : params);
@@ -584,7 +603,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
584
603
  pi.registerTool({
585
604
  name: "discuss",
586
605
  label: "Discuss",
587
- description: "Native Papyrus deliberation with a real lifecycle -- distinct from a one-shot ask: a Discussion persists, takes multiple rounds, and can genuinely block a Task's completion until settled or deferred. ACTIONS: open, reply, defer, resume, settle, block, unblock, show, rounds, list. open starts round 1 and optionally blocks_task_ids immediately. reply is refused once deferred or settled -- resume first. defer is explicitly non-blocking (paused, resumable); settle is terminal and archives the discussion. block/unblock manage the blocking relationship to a task independently of open. A task's completion is refused while any active Discussion blocks it. open/reply can pose a structured choice via options (2-10 entries) + options_mode ('single' mutually exclusive, 'multi' allows several); reply answers a currently pending choice via selected, validated against it. PREFER `name` (the discussion's exact title) over `id`, `task_name`/`blocks_task_names` over `task_id`/`blocks_task_ids` -- all are backend implementation details, resolved from name automatically.",
606
+ description: "Native Papyrus deliberation with a real lifecycle -- distinct from a one-shot ask: a Discussion persists, takes multiple rounds, and can genuinely block a Task's completion until settled or deferred. ACTIONS: open, reply, defer, resume, settle, block, unblock, show, rounds, list. open starts round 1 and optionally blocks_task_ids immediately. reply is refused once deferred or settled -- resume first. defer is explicitly non-blocking (paused, resumable); settle is terminal and archives the discussion. block/unblock manage the blocking relationship to a task independently of open. A task's completion is refused while any active Discussion blocks it. open/reply can pose a structured choice via options (2-10 entries) + options_mode ('single' mutually exclusive, 'multi' allows several); reply answers a currently pending choice via selected, validated against it. Pass live:true on open or reply to get the human's answer synchronously in this same call, via an interactive prompt (the pending choice's picker if one was posed, otherwise a freeform question) -- covers a completely open question with no artifact (open with no prior discussion) and a question tied to a specific existing artifact (reply, addressed by name) alike. Only takes effect with an interactive UI available; otherwise degrades silently to the normal async round. PREFER `name` (the discussion's exact title) over `id`, `task_name`/`blocks_task_names` over `task_id`/`blocks_task_ids` -- all are backend implementation details, resolved from name automatically.",
588
607
  parameters: Type.Object({
589
608
  action: Type.String(),
590
609
  id: Type.Optional(Type.String()),
@@ -606,6 +625,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
606
625
  options: Type.Optional(Type.Array(Type.String())),
607
626
  options_mode: Type.Optional(Type.String()),
608
627
  selected: Type.Optional(Type.Array(Type.String())),
628
+ live: Type.Optional(Type.Boolean()),
609
629
  }),
610
630
  renderCall(args, theme) { return renderPapyrusToolCall("Discuss", args, theme); },
611
631
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
@@ -619,13 +639,19 @@ export function registerDomainTools(pi: ExtensionAPI): void {
619
639
  { nameKey: "task_name", idKey: "task_id", listOperation: "tasks.list", baseRequest: taskScope },
620
640
  ]);
621
641
  await resolveNameArrayField(params, "blocks_task_names", "blocks_task_ids", "tasks.list", taskScope);
622
- if (action === "open") {
623
- const result = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.open", params);
624
- return text(`Opened discussion ${artifactLine(result.discussion)}`, createArtifactDetails("discuss.open", result.discussion));
625
- }
626
- if (action === "reply") {
627
- const result = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.reply", params);
628
- return text(`Round ${result.rounds[0]?.roundNumber} added to "${result.discussion.title}"`, createArtifactDetails("discuss.reply", result.discussion));
642
+ if (action === "open" || action === "reply") {
643
+ const operation = action === "open" ? "discuss.open" : "discuss.reply";
644
+ const result = await callService<Record<string, unknown>, DiscussionAndRounds>(operation, params);
645
+ const fallback = action === "open"
646
+ ? text(`Opened discussion ${artifactLine(result.discussion)}`, createArtifactDetails("discuss.open", result.discussion))
647
+ : text(`Round ${result.rounds[0]?.roundNumber} added to "${result.discussion.title}"`, createArtifactDetails("discuss.reply", result.discussion));
648
+ if (params.live !== true) return fallback;
649
+ const answer = await liveAnswer(ctx, result.discussion);
650
+ if (!answer) return fallback;
651
+ const answered = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.reply", {
652
+ id: result.discussion.id, actor: "human", content: answer.content, ...(answer.selected ? { selected: answer.selected } : {}), source: "discuss-live",
653
+ });
654
+ return text(`"${answered.discussion.title}": ${answer.content}`, createArtifactDetails("discuss.reply", answered.discussion));
629
655
  }
630
656
  if (action === "block" || action === "unblock") {
631
657
  const operation = action === "block" ? "discuss.block" : "discuss.unblock";
@@ -25,10 +25,17 @@ export async function showRules(ctx: ExtensionCommandContext): Promise<void> {
25
25
  statusOrder: ["active", "deprecated"],
26
26
  presentation: RULE_STATUS_PRESENTATION,
27
27
  rowMeta: ruleRowMeta,
28
- actions: (rule) => ["Show details", "Preview injection", "Link gated task", rule.status === "active" ? "Disable" : "Enable"],
28
+ actions: (rule) => ["Show details", "Edit", "Preview injection", "Link gated task", rule.status === "active" ? "Disable" : "Enable"],
29
29
  handleAction: async (choice, rule, commandCtx) => {
30
30
  if (choice === "Show details") await showArtifactDetails(commandCtx, rule.id, "rules.show");
31
- else if (choice === "Preview injection") {
31
+ else if (choice === "Edit") {
32
+ const title = await commandCtx.ui.input("Title:", rule.title);
33
+ if (title === undefined) return; // canceled
34
+ const body = await commandCtx.ui.input("Body:", rule.body);
35
+ if (body === undefined) return; // canceled
36
+ const updated = await callService<Record<string, unknown>, Artifact>("rules.update", { id: rule.id, title, body });
37
+ commandCtx.ui.notify(`Updated "${updated.title}"`, "info");
38
+ } else if (choice === "Preview injection") {
32
39
  const preview = await callService<Record<string, unknown>, string>("rules.preview", { id: rule.id });
33
40
  commandCtx.ui.notify(preview, "info");
34
41
  } else if (choice === "Link gated task") {
@@ -76,12 +76,20 @@ export async function showSkills(ctx: ExtensionCommandContext): Promise<void> {
76
76
  rowMeta: skillRowMeta,
77
77
  actions: (skill) => [
78
78
  "Show details",
79
+ "Edit",
79
80
  skill.subtype === "artifact-template" ? "Use template" : skill.subtype === "workflow" ? "Run workflow" : "Invoke skill",
80
81
  skill.status === "active" ? "Disable" : "Enable",
81
82
  ],
82
83
  handleAction: async (choice, skill, commandCtx) => {
83
84
  if (choice === "Show details") await showArtifactDetails(commandCtx, skill.id, "skills.show");
84
- else if (choice === "Run workflow") {
85
+ else if (choice === "Edit") {
86
+ const title = await commandCtx.ui.input("Title:", skill.title);
87
+ if (title === undefined) return; // canceled
88
+ const body = await commandCtx.ui.input("Body:", skill.body);
89
+ if (body === undefined) return; // canceled
90
+ const updated = await callService<Record<string, unknown>, Artifact>("skills.update", { id: skill.id, title, body });
91
+ commandCtx.ui.notify(`Updated "${updated.title}"`, "info");
92
+ } else if (choice === "Run workflow") {
85
93
  const source = await commandCtx.ui.input("Workflow arguments JSON:", "{}");
86
94
  if (source === undefined) return;
87
95
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.21.6",
3
+ "version": "0.22.0",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -22,7 +22,7 @@ import type { ArtifactEventContext } from "./domain/artifact-event.ts";
22
22
  import type { Artifact, ArtifactLink } from "./domain/artifact.ts";
23
23
  import type { ArtifactStore } from "./ports/artifact-store.ts";
24
24
 
25
- export type ArtifactAction = "create" | "link" | "status";
25
+ export type ArtifactAction = "create" | "link" | "status" | "update";
26
26
 
27
27
  export interface AuthorityClaim {
28
28
  /** Module id that owns this kind/subtype/relation, e.g. "notes", "tasks". */
package/src/cli.ts CHANGED
@@ -92,6 +92,7 @@ const USAGE = `Usage:
92
92
  papyrus docs activate|archive|reopen <id> [--json]
93
93
  papyrus docs link <id> <relation> <target-id> [--json]
94
94
  papyrus docs assign-project <id> [project-root] [--json]
95
+ papyrus docs update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--json]
95
96
  papyrus rules create --title <title> [--body <body>] [--condition <text>] [--rule-action <text>] [--severity block|warn|info] [--labels-json <json>] [--extra-json <json>] [--project-root <path>] [--json]
96
97
  papyrus rules list [--status <status>] [--text <query>] [--limit <count>] [--project-root <path>] [--json]
97
98
  papyrus rules show <id> [--json]
@@ -100,6 +101,7 @@ const USAGE = `Usage:
100
101
  papyrus rules gate <rule-id> <task-id> [--json]
101
102
  papyrus rules injectable [--json]
102
103
  papyrus rules assign-project <id> [project-root] [--json]
104
+ papyrus rules update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--json]
103
105
  papyrus skills run <id> [--arguments-json <json>] [--run-id <id>] [--json]
104
106
  papyrus skills create --title <title> [--body <body>] [--trigger <text>] [--steps-json <json>] [--tools-json <json>] [--definition-json <json>] [--labels-json <json>] [--extra-json <json>] [--project-root <path>] [--json]
105
107
  papyrus skills create-template --title <title> --target-kind <kind> [--defaults-json <json>] [--required-json <json>] [--body <body>] [--labels-json <json>] [--project-root <path>] [--json]
@@ -109,6 +111,7 @@ const USAGE = `Usage:
109
111
  papyrus skills enable|disable <id> [--json]
110
112
  papyrus skills instantiate <template-id> [--title <title>] [--body <body>] [--status <status>] [--labels-json <json>] [--extra-json <json>] [--json]
111
113
  papyrus skills assign-project <id> [project-root] [--json]
114
+ papyrus skills update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--json]
112
115
  papyrus notes capture <request> [--title <title>] [--json]
113
116
  papyrus notes list [--status <draft|active|archived>] [--text <query>] [--limit <count>] [--json]
114
117
  papyrus notes show <id> [--json]
@@ -466,8 +469,16 @@ export async function runSkillCli(args: string[], client: TaskCliClient, project
466
469
  human = `Created: ${artifactLabel(artifact)}`;
467
470
  break;
468
471
  }
472
+ case "update": {
473
+ if (!id || second) throw new Error("skills update requires exactly one skill id");
474
+ if (title === undefined && body === undefined && labels === undefined) throw new Error("skills update requires --title, --body, or --labels-json");
475
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("skills.update", { id, title, body, labels });
476
+ result = artifact;
477
+ human = `${artifactLabel(artifact)}`;
478
+ break;
479
+ }
469
480
  default:
470
- throw new Error("skills action must be run, create, create-template, list, show, invoke, enable, disable, instantiate, or assign-project");
481
+ throw new Error("skills action must be run, create, create-template, list, show, invoke, enable, disable, instantiate, assign-project, or update");
471
482
  }
472
483
  return json ? JSON.stringify(result) : human;
473
484
  }
@@ -632,8 +643,16 @@ export async function runDocsCli(args: string[], client: TaskCliClient): Promise
632
643
  human = `Linked ${id} --${second}--> ${third}`;
633
644
  break;
634
645
  }
646
+ case "update": {
647
+ if (!id || second) throw new Error("docs update requires exactly one document id");
648
+ if (title === undefined && body === undefined && labels === undefined) throw new Error("docs update requires --title, --body, or --labels-json");
649
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("docs.update", { id, title, body, labels });
650
+ result = artifact;
651
+ human = `${artifactLabel(artifact)}`;
652
+ break;
653
+ }
635
654
  default:
636
- throw new Error("docs action must be create, list, show, activate, archive, reopen, link, or assign-project");
655
+ throw new Error("docs action must be create, list, show, activate, archive, reopen, link, assign-project, or update");
637
656
  }
638
657
  return json ? JSON.stringify(result) : human;
639
658
  }
@@ -736,8 +755,16 @@ export async function runRulesCli(args: string[], client: TaskCliClient, project
736
755
  human = rows.length === 0 ? "No injectable rules." : rows.map((row) => row.title).join("\n");
737
756
  break;
738
757
  }
758
+ case "update": {
759
+ if (!id || second) throw new Error("rules update requires exactly one rule id");
760
+ if (title === undefined && body === undefined && labels === undefined) throw new Error("rules update requires --title, --body, or --labels-json");
761
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("rules.update", { id, title, body, labels });
762
+ result = artifact;
763
+ human = `${artifactLabel(artifact)}`;
764
+ break;
765
+ }
739
766
  default:
740
- throw new Error("rules action must be create, list, show, preview, enable, disable, gate, injectable, or assign-project");
767
+ throw new Error("rules action must be create, list, show, preview, enable, disable, gate, injectable, assign-project, or update");
741
768
  }
742
769
  return json ? JSON.stringify(result) : human;
743
770
  }
package/src/constants.ts CHANGED
@@ -119,6 +119,11 @@ export const TASK_TITLE_MAX_LENGTH = 500;
119
119
  export const TASK_BODY_MAX_LENGTH = 100_000;
120
120
  export const TASK_LABEL_MAX_COUNT = 64;
121
121
  export const TASK_LABEL_MAX_LENGTH = 128;
122
+ /** Mutable Doc/Rule/Skill content bounds -- same numbers as Task's, since these are the same kind of freeform content at the same scale. Rules also enforce their own stricter RULE_TEXT_HARD_LIMIT_CHARACTERS on top of this. */
123
+ export const ARTIFACT_TITLE_MAX_LENGTH = 500;
124
+ export const ARTIFACT_BODY_MAX_LENGTH = 100_000;
125
+ export const ARTIFACT_LABEL_MAX_COUNT = 64;
126
+ export const ARTIFACT_LABEL_MAX_LENGTH = 128;
122
127
  /** Append-only Task chronology query and evidence bounds. */
123
128
  export const TASK_HISTORY_DEFAULT_LIMIT = 25;
124
129
  export const TASK_HISTORY_MAX_LIMIT = 100;
@@ -67,3 +67,27 @@ export interface RelationshipQuery {
67
67
  artifactIds?: string[];
68
68
  limit?: number;
69
69
  }
70
+
71
+ /**
72
+ * A label of the form "source:<system>" marks an artifact as ingested/projected from an
73
+ * external, non-Papyrus system (e.g. web-spider's own "source:web-spider" convention on the
74
+ * Docs it creates) -- content Papyrus does not own and cannot safely rewrite without silently
75
+ * diverging from the true source. Editing one directly would look like a correction but really
76
+ * just be a local fork nobody re-syncs.
77
+ */
78
+ export const EXTERNAL_SOURCE_LABEL_PREFIX = "source:";
79
+
80
+ /** The external system name from a "source:<system>" label, or undefined if this artifact has no such label (i.e. it's Papyrus-native content). */
81
+ export function externalSourceOf(artifact: Pick<Artifact, "labels">): string | undefined {
82
+ const label = artifact.labels.find((entry) => entry.startsWith(EXTERNAL_SOURCE_LABEL_PREFIX));
83
+ return label === undefined ? undefined : label.slice(EXTERNAL_SOURCE_LABEL_PREFIX.length) || undefined;
84
+ }
85
+
86
+ /** Throws if the artifact is a read-only external projection; a caller must never silently rewrite content it doesn't own the source of. */
87
+ export function requireLocallyOwnedContent(artifact: Artifact): Artifact {
88
+ const system = externalSourceOf(artifact);
89
+ if (system !== undefined) {
90
+ throw new Error(`"${artifact.title}" is a read-only projection from ${system}; edit it there, or capture a correction as a new linked Doc, until a write-back capability is integrated`);
91
+ }
92
+ return artifact;
93
+ }
@@ -1,17 +1,51 @@
1
1
  import {
2
+ ARTIFACT_BODY_MAX_LENGTH,
3
+ ARTIFACT_LABEL_MAX_COUNT,
4
+ ARTIFACT_LABEL_MAX_LENGTH,
2
5
  ARTIFACT_SCOPE_MAX_ARTIFACTS,
6
+ ARTIFACT_TITLE_MAX_LENGTH,
3
7
  RULE_TEXT_HARD_LIMIT_CHARACTERS,
4
8
  SKILL_INVOCATION_MAX_CALL_DEPTH,
5
9
  SKILL_INVOCATION_MAX_LINKED_ARTIFACTS,
6
10
  } from "./constants.ts";
7
- import type { Artifact, CreateArtifactInput } from "./domain/artifact.ts";
11
+ import { requireLocallyOwnedContent, type Artifact, type CreateArtifactInput } from "./domain/artifact.ts";
8
12
  import type { ArtifactEventContext } from "./domain/artifact-event.ts";
9
13
  import { normalizeProjectRoot } from "./domain/task-scope.ts";
10
14
  import { validateSkillDefinition } from "./domain/skill-definition.ts";
11
15
  import type { ArtifactStore } from "./ports/artifact-store.ts";
12
16
  import type { ArtifactScopeStore } from "./ports/artifact-scope-store.ts";
13
17
  import { NOTE_SUBTYPE } from "./note-service.ts";
14
- import type { AuthorityRegistry } from "./authority-registry.ts";
18
+ import { type ArtifactAction, type AuthorityRegistry } from "./authority-registry.ts";
19
+
20
+ export interface UpdateContentInput {
21
+ title?: string;
22
+ body?: string;
23
+ labels?: string[];
24
+ }
25
+
26
+ function requireContentUpdateFields(input: UpdateContentInput): void {
27
+ if (input.title === undefined && input.body === undefined && input.labels === undefined) {
28
+ throw new Error("update requires title, body, or labels");
29
+ }
30
+ }
31
+
32
+ function assertTitleBounds(title: string | undefined): void {
33
+ if (title !== undefined && (title.trim().length === 0 || title.length > ARTIFACT_TITLE_MAX_LENGTH)) {
34
+ throw new Error(`title must be between 1 and ${ARTIFACT_TITLE_MAX_LENGTH} characters`);
35
+ }
36
+ }
37
+
38
+ function assertBodyBounds(body: string | undefined): void {
39
+ if (body !== undefined && body.length > ARTIFACT_BODY_MAX_LENGTH) throw new Error(`body cannot exceed ${ARTIFACT_BODY_MAX_LENGTH} characters`);
40
+ }
41
+
42
+ function assertLabelsBounds(labels: string[] | undefined): void {
43
+ if (labels === undefined) return;
44
+ if (labels.length > ARTIFACT_LABEL_MAX_COUNT) throw new Error(`labels cannot exceed ${ARTIFACT_LABEL_MAX_COUNT} entries`);
45
+ if (labels.some((label) => label.length === 0 || label.length > ARTIFACT_LABEL_MAX_LENGTH)) {
46
+ throw new Error(`each label must be between 1 and ${ARTIFACT_LABEL_MAX_LENGTH} characters`);
47
+ }
48
+ }
15
49
 
16
50
  export interface ListFilter {
17
51
  status?: string;
@@ -83,8 +117,8 @@ function templateSubtype(artifacts: ArtifactStore, templateId: string | undefine
83
117
  return typeof subtype === "string" ? subtype : undefined;
84
118
  }
85
119
 
86
- function requireMutableDocument(document: Artifact, authority: AuthorityRegistry): Artifact {
87
- authority.requireArtifactAllowed(document.kind, document.subtype, "status", "docs");
120
+ function requireMutableDocument(document: Artifact, authority: AuthorityRegistry, action: ArtifactAction = "status"): Artifact {
121
+ authority.requireArtifactAllowed(document.kind, document.subtype, action, "docs");
88
122
  return document;
89
123
  }
90
124
 
@@ -99,6 +133,8 @@ export interface CreateDocumentInput {
99
133
  projectRoot?: string;
100
134
  }
101
135
 
136
+ export type UpdateDocumentInput = UpdateContentInput;
137
+
102
138
  export type DocumentTransition = "activate" | "archive" | "reopen";
103
139
  export type DocumentRelation = "references" | "documents" | "supersedes" | "relates_to" | "contains" | "part_of";
104
140
 
@@ -157,6 +193,23 @@ export function transitionDocument(artifacts: ArtifactStore, id: string, action:
157
193
  return artifacts.setStatus(id, transition.to, context)!;
158
194
  }
159
195
 
196
+ /**
197
+ * Docs are immutable-by-convention only in the sense that no path existed to change them --
198
+ * this is that path. A read-only external projection (see requireLocallyOwnedContent) still
199
+ * refuses, on purpose: rewriting it here would silently fork from whatever system actually
200
+ * owns it (e.g. web-spider's ingested pages), with nothing to ever reconcile the two again.
201
+ */
202
+ export function updateDocument(artifacts: ArtifactStore, id: string, input: UpdateDocumentInput, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
203
+ requireContentUpdateFields(input);
204
+ assertTitleBounds(input.title);
205
+ assertBodyBounds(input.body);
206
+ assertLabelsBounds(input.labels);
207
+ const document = requireLocallyOwnedContent(requireMutableDocument(requireDocument(artifacts, id), authority, "update"));
208
+ const updated = artifacts.updateContent(id, input, context);
209
+ if (!updated) throw new Error(`document "${id}" not found`);
210
+ return updated;
211
+ }
212
+
160
213
  export function linkDocument(artifacts: ArtifactStore, id: string, relation: DocumentRelation, targetId: string, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
161
214
  requireMutableDocument(requireDocument(artifacts, id), authority);
162
215
  const target = artifacts.get(targetId);
@@ -258,6 +311,24 @@ export function transitionRule(artifacts: ArtifactStore, id: string, action: Rul
258
311
  return artifacts.setStatus(id, target, context)!;
259
312
  }
260
313
 
314
+ export type UpdateRuleInput = UpdateContentInput;
315
+
316
+ /** A Rule's body update stays under the same combined condition+action+body ceiling as creation -- a permanent per-turn injection cost doesn't get looser just because it's an edit, not a create. */
317
+ export function updateRule(artifacts: ArtifactStore, id: string, input: UpdateRuleInput, context?: ArtifactEventContext): Artifact {
318
+ requireContentUpdateFields(input);
319
+ assertTitleBounds(input.title);
320
+ assertLabelsBounds(input.labels);
321
+ const rule = requireLocallyOwnedContent(requireKind(artifacts, id, "rule"));
322
+ if (input.body !== undefined) {
323
+ const condition = typeof rule.extra["condition"] === "string" ? rule.extra["condition"] : undefined;
324
+ const action = typeof rule.extra["action"] === "string" ? rule.extra["action"] : undefined;
325
+ assertRuleTextWithinBounds(condition, action, input.body);
326
+ }
327
+ const updated = artifacts.updateContent(id, input, context);
328
+ if (!updated) throw new Error(`rule "${id}" not found`);
329
+ return updated;
330
+ }
331
+
261
332
  export function gateTaskWithRule(artifacts: ArtifactStore, ruleId: string, taskId: string, context?: ArtifactEventContext): Artifact {
262
333
  requireKind(artifacts, ruleId, "rule");
263
334
  requireKind(artifacts, taskId, "task");
@@ -353,6 +424,19 @@ export function showSkill(artifacts: ArtifactStore, id: string): Artifact {
353
424
  return artifacts.get(id, { tree: true })!;
354
425
  }
355
426
 
427
+ export type UpdateSkillInput = UpdateContentInput;
428
+
429
+ export function updateSkill(artifacts: ArtifactStore, id: string, input: UpdateSkillInput, context?: ArtifactEventContext): Artifact {
430
+ requireContentUpdateFields(input);
431
+ assertTitleBounds(input.title);
432
+ assertBodyBounds(input.body);
433
+ assertLabelsBounds(input.labels);
434
+ const skill = requireLocallyOwnedContent(requireKind(artifacts, id, "skill"));
435
+ const updated = artifacts.updateContent(skill.id, input, context);
436
+ if (!updated) throw new Error(`skill "${id}" not found`);
437
+ return updated;
438
+ }
439
+
356
440
  function skillInvocationBody(skill: Artifact): string {
357
441
  if (skill.subtype === "artifact-template") {
358
442
  return `Create an artifact using Papyrus template "${skill.title}".\ntemplate_id: ${skill.id}\nAsk for or infer all required template fields, then call the skills domain tool instantiate action.`;
@@ -7,7 +7,7 @@
7
7
  * ArtifactStore-based with no other module's concrete class dependency.
8
8
  */
9
9
  import type { AuthorityRegistry } from "../authority-registry.ts";
10
- import { assignDocumentProject, createDocument, linkDocument, listDocuments, showDocument, transitionDocument, type DocumentRelation } from "../domain-services.ts";
10
+ import { assignDocumentProject, createDocument, linkDocument, listDocuments, showDocument, transitionDocument, updateDocument, type DocumentRelation } from "../domain-services.ts";
11
11
  import type { OperationDefinition } from "../module-registry.ts";
12
12
  import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
13
13
  import type { ArtifactStore } from "../ports/artifact-store.ts";
@@ -52,7 +52,7 @@ const artifactFilter = (input: OperationInput) => ({
52
52
  /** Registers every docs.* operation against the shared ArtifactStore port. Behavior is unchanged from the prior inline handlers in src/service.ts. */
53
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
54
  export const DOCS_OPERATION_NAMES = [
55
- "docs.create", "docs.list", "docs.show", "docs.activate", "docs.archive", "docs.reopen", "docs.link", "docs.assign_project",
55
+ "docs.create", "docs.list", "docs.show", "docs.activate", "docs.archive", "docs.reopen", "docs.link", "docs.assign_project", "docs.update",
56
56
  ] as const;
57
57
 
58
58
  export function docsOperations(artifacts: ArtifactStore, scopes: ArtifactScopeStore, authority: AuthorityRegistry): OperationDefinition[] {
@@ -73,5 +73,8 @@ export function docsOperations(artifacts: ArtifactStore, scopes: ArtifactScopeSt
73
73
  define("docs.reopen", (input: OperationInput) => transitionDocument(artifacts, string(input, "id"), "reopen", authority, eventContext(input))),
74
74
  define("docs.link", (input: OperationInput) => linkDocument(artifacts, string(input, "id"), string(input, "relation") as DocumentRelation, string(input, "target_id"), authority, eventContext(input))),
75
75
  define("docs.assign_project", (input: OperationInput) => assignDocumentProject(artifacts, scopes, string(input, "id"), optionalString(input, "project_root"))),
76
+ define("docs.update", (input: OperationInput) => updateDocument(artifacts, string(input, "id"), {
77
+ title: optionalString(input, "title"), body: optionalString(input, "body"), labels: input["labels"] as string[] | undefined,
78
+ }, authority, eventContext(input))),
76
79
  ];
77
80
  }
@@ -10,7 +10,7 @@
10
10
  * into this module or introducing a premature "modules call each other through the
11
11
  * registry" convention.
12
12
  */
13
- import { assignRuleProject, createRule, gateTaskWithRule, listRules, previewRule, showRule, transitionRule } from "../domain-services.ts";
13
+ import { assignRuleProject, createRule, gateTaskWithRule, listRules, previewRule, showRule, transitionRule, updateRule } from "../domain-services.ts";
14
14
  import type { OperationDefinition } from "../module-registry.ts";
15
15
  import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
16
16
  import type { ArtifactStore } from "../ports/artifact-store.ts";
@@ -55,7 +55,7 @@ const artifactFilter = (input: OperationInput) => ({
55
55
  /** Registers every rules.* operation except rules.injectable (see module comment). Behavior is unchanged from the prior inline handlers in src/service.ts. */
56
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
57
  export const RULES_OPERATION_NAMES = [
58
- "rules.create", "rules.list", "rules.show", "rules.preview", "rules.enable", "rules.disable", "rules.gate", "rules.assign_project",
58
+ "rules.create", "rules.list", "rules.show", "rules.preview", "rules.enable", "rules.disable", "rules.gate", "rules.assign_project", "rules.update",
59
59
  ] as const;
60
60
 
61
61
  export function rulesOperations(artifacts: ArtifactStore, scopes: ArtifactScopeStore): OperationDefinition[] {
@@ -77,5 +77,8 @@ export function rulesOperations(artifacts: ArtifactStore, scopes: ArtifactScopeS
77
77
  define("rules.disable", (input: OperationInput) => transitionRule(artifacts, string(input, "id"), "disable", eventContext(input))),
78
78
  define("rules.gate", (input: OperationInput) => gateTaskWithRule(artifacts, string(input, "id"), string(input, "task_id"), eventContext(input))),
79
79
  define("rules.assign_project", (input: OperationInput) => assignRuleProject(artifacts, scopes, string(input, "id"), optionalString(input, "project_root"))),
80
+ define("rules.update", (input: OperationInput) => updateRule(artifacts, string(input, "id"), {
81
+ title: optionalString(input, "title"), body: optionalString(input, "body"), labels: input["labels"] as string[] | undefined,
82
+ }, eventContext(input))),
80
83
  ];
81
84
  }
@@ -17,7 +17,7 @@
17
17
  * extraction.
18
18
  */
19
19
  import type { AuthorityRegistry } from "../authority-registry.ts";
20
- import { assignSkillProject, createArtifactTemplate, createSkill, listSkills, showSkill, skillInvocation, transitionSkill } from "../domain-services.ts";
20
+ import { assignSkillProject, createArtifactTemplate, createSkill, listSkills, showSkill, skillInvocation, transitionSkill, updateSkill } from "../domain-services.ts";
21
21
  import type { OperationDefinition } from "../module-registry.ts";
22
22
  import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
23
23
  import type { ArtifactStore } from "../ports/artifact-store.ts";
@@ -79,7 +79,7 @@ export interface SkillsModuleDeps {
79
79
  /** Registers every skills.* operation except skills.instantiate (see module comment). Behavior is unchanged from the prior inline handlers in src/service.ts. */
80
80
  /** 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. skills.instantiate is deliberately absent -- see the module comment above. */
81
81
  export const SKILLS_OPERATION_NAMES = [
82
- "skills.create", "skills.create_template", "skills.list", "skills.show", "skills.invoke", "skills.run", "skills.enable", "skills.disable", "skills.assign_project",
82
+ "skills.create", "skills.create_template", "skills.list", "skills.show", "skills.invoke", "skills.run", "skills.enable", "skills.disable", "skills.assign_project", "skills.update",
83
83
  ] as const;
84
84
 
85
85
  export function skillsOperations({ artifacts, events, scopes, artifactScopes, authority }: SkillsModuleDeps): OperationDefinition[] {
@@ -109,5 +109,8 @@ export function skillsOperations({ artifacts, events, scopes, artifactScopes, au
109
109
  define("skills.enable", (input: OperationInput) => transitionSkill(artifacts, string(input, "id"), "enable", eventContext(input))),
110
110
  define("skills.disable", (input: OperationInput) => transitionSkill(artifacts, string(input, "id"), "disable", eventContext(input))),
111
111
  define("skills.assign_project", (input: OperationInput) => assignSkillProject(artifacts, artifactScopes, string(input, "id"), optionalString(input, "project_root"))),
112
+ define("skills.update", (input: OperationInput) => updateSkill(artifacts, string(input, "id"), {
113
+ title: optionalString(input, "title"), body: optionalString(input, "body"), labels: input["labels"] as string[] | undefined,
114
+ }, eventContext(input))),
112
115
  ];
113
116
  }
package/src/service.ts CHANGED
@@ -144,6 +144,7 @@ const notesAuthorityClaim: AuthorityClaim = {
144
144
  denyMessage: (action) => {
145
145
  if (action === "link") return "note relationships require a notes.* operation so disposition provenance is preserved";
146
146
  if (action === "status") return "note lifecycle changes require a notes.* operation so disposition provenance is preserved";
147
+ if (action === "update") return "note content changes require a notes.* operation so disposition provenance is preserved";
147
148
  return "note creation requires notes.capture";
148
149
  },
149
150
  };
@@ -341,6 +342,7 @@ function handlers(
341
342
  "docs.reopen": forwardToModule("docs.reopen"),
342
343
  "docs.link": forwardToModule("docs.link"),
343
344
  "docs.assign_project": forwardToModule("docs.assign_project"),
345
+ "docs.update": forwardToModule("docs.update"),
344
346
  "notes.capture": forwardToModule("notes.capture"),
345
347
  "notes.list": forwardToModule("notes.list"),
346
348
  "notes.show": forwardToModule("notes.show"),
@@ -355,6 +357,7 @@ function handlers(
355
357
  "rules.disable": forwardToModule("rules.disable"),
356
358
  "rules.gate": forwardToModule("rules.gate"),
357
359
  "rules.assign_project": forwardToModule("rules.assign_project"),
360
+ "rules.update": forwardToModule("rules.update"),
358
361
  "skills.create": forwardToModule("skills.create"),
359
362
  "skills.create_template": forwardToModule("skills.create_template"),
360
363
  "skills.list": forwardToModule("skills.list"),
@@ -364,6 +367,7 @@ function handlers(
364
367
  "skills.enable": forwardToModule("skills.enable"),
365
368
  "skills.disable": forwardToModule("skills.disable"),
366
369
  "skills.assign_project": forwardToModule("skills.assign_project"),
370
+ "skills.update": forwardToModule("skills.update"),
367
371
  "skills.instantiate": (input) => {
368
372
  const templateId = string(input, "template_id");
369
373
  const template = artifacts.get(templateId);