pipebrain-mcp 0.39.0 → 0.41.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.
Files changed (3) hide show
  1. package/README.md +10 -6
  2. package/dist/main.js +185 -115
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # pipebrain-mcp
2
2
 
3
- The [Pipebrain](https://pipebrain.dev) MCP server: issues, requirements, content drafts
4
- and deliverables, assets, milestones, and metadata-only secrets, exposed to Claude Code
5
- and Claude Desktop over stdio.
3
+ The [Pipebrain](https://pipebrain.dev) MCP server: issues, requirements, decision records,
4
+ conventions, prompts, content drafts and deliverables, assets, milestones, metadata-only
5
+ secrets, and more of the platform's modules, exposed to Claude Code and Claude Desktop
6
+ over stdio.
6
7
 
7
8
  This package is the **server only**. The command line tool is a separate package,
8
9
  [`pipebrain-cli`](https://www.npmjs.com/package/pipebrain-cli), which provides `ppb`.
@@ -35,8 +36,11 @@ at each launch.
35
36
  | `PIPEBRAIN_TOKEN` | Your `pbt_…` API token (Settings → Organization → API tokens). |
36
37
  | `PIPEBRAIN_API_URL` | API base URL, e.g. `https://api.pipebrain.dev`. Required — there is no default, so a session can always tell which environment it is bound to. |
37
38
 
38
- Secret VALUES never cross this server: the secrets tools return metadata only, and there
39
- is deliberately no value-returning tool. Use `ppb secrets` (from `pipebrain-cli`) for the
40
- reveal plane.
39
+ No tool here RETURNS a secret value: the secrets tools answer with metadata only, a
40
+ response never echoes a value, and there is deliberately no value-returning tool — no get,
41
+ no reveal, no export. Use `ppb secrets` (from `pipebrain-cli`) for the reveal plane.
42
+ Values do travel INBOUND on `set_secret` and `rotate_secret`, so for anything sensitive
43
+ set it with `ppb secrets set` instead (stdin or `--from-file`): a value typed into a tool
44
+ call is in the chat transcript before the server can encrypt it.
41
45
 
42
46
  Sign in at [app.pipebrain.dev](https://app.pipebrain.dev).
package/dist/main.js CHANGED
@@ -365,7 +365,7 @@ import { join } from "node:path";
365
365
  // ../../libs/agent-shared/dist/agent-config-manifest.js
366
366
  var AGENT_CONFIG_ENTRIES = [
367
367
  { target: "agents/blind-spot-pass.md", sha256: "564751a751f7896e89c67e0d20788aff873e599766a3c30360073ba74e4c6099" },
368
- { target: "agents/code-verifier.md", sha256: "5a09f0f9d6b4c4d75068bfc5e314b3d1250de9328806e709d0c32d1369394124" },
368
+ { target: "agents/code-verifier.md", sha256: "c3f0a3b7397f0c903056971e71f2640a979a52e6efd18b624b3d29716d1e43e0" },
369
369
  { target: "agents/heavy-implementer.md", sha256: "7d3af78d4241aaa67c283db245dd4065af6e8f55efce54c2f8623c1f83879830" },
370
370
  { target: "agents/issue-header-writer.md", sha256: "8809b2913c71d021e8fa6c72fe70e2d1db8019b90ed93d85786f5236552c7da6" },
371
371
  { target: "agents/spec-reviewer.md", sha256: "a46dc637006d1f810081046c57edcd731406727810ccc5e0b1c7d7b9b98be5ef" },
@@ -373,19 +373,19 @@ var AGENT_CONFIG_ENTRIES = [
373
373
  { target: "pipebrain/hooks/block-bash4-features.js", sha256: "383b38169a1795d9da34aa1c939349b6a1f76bba1359d0af7fd14c11ea5da25f" },
374
374
  { target: "pipebrain/hooks/block-browser-dialogs.js", sha256: "b4f16a249881e6bd3c8d7f270c955270047a0636ffa99b89ad409fefd44b2b2f" },
375
375
  { target: "pipebrain/hooks/block-credential-echo.js", sha256: "80f95808715744c72347ddcfda7a858b193f5c2b113a3b5aac93435552193437" },
376
- { target: "pipebrain/hooks/pre-commit-publication-fitness.js", sha256: "f2917cd369c03004a8d02410f695efa3400bcd4f2044d1d389e5ad2c7a9dae5f" },
376
+ { target: "pipebrain/hooks/pre-commit-publication-fitness.js", sha256: "68a4f5f15dea22057b7217ad54d5cf8c34a8c4aaa5ce139ace79911acece8504" },
377
377
  { target: "pipebrain/hooks/pre-commit-secret-scan.js", sha256: "1bc634774184e66748b3152016e806ef974449e976920fd710732170fdb4ad6b" },
378
378
  { target: "pipebrain/hooks/stop-orphaned-servers.js", sha256: "c3b847832c60605278e54ca1067c216bee073ab0a4bb046439b5f184b728792a" },
379
379
  { target: "pipebrain/team.md", sha256: "945d53a03e09e4ea4f15abf7c6c98ee2bcc2739ce6203e2056590b6ebece0df9" },
380
380
  { target: "rules/react-tailwind.md", sha256: "80bb51b2d8fd5782b7c742526544e7841ee38aa575faf7a8c7b4217ba5731989" },
381
381
  { target: "rules/shell.md", sha256: "499aac60b6419acb0bb362c0267b9399139dcceafb0eaf67702e40f3118b5c04" },
382
382
  { target: "rules/typescript.md", sha256: "c55eab0c243e38cdf903ab50b1e9d6d621516b893e602e1abff9a999a14e72d4" },
383
- { target: "skills/capture-correction/SKILL.md", sha256: "ebd2ee680a623f54a2a35b2ac75471902f30bd17013c34557fd4ae4a9add3f68" },
383
+ { target: "skills/capture-correction/SKILL.md", sha256: "907ee8fc8e7ea15c8296acd23a816e1f9c6454edcf37a93f18af79b1c542fb75" },
384
384
  { target: "skills/pipebrain-header-pass/SKILL.md", sha256: "10545e3c1c5215b031e1c76281e65cd2ecf7420b3f07a4221824591a8263c5fc" },
385
- { target: "skills/pipebrain-secrets/SKILL.md", sha256: "1b873f0a12c1384fbb84d071c4f46554cbff22fc502223ad58acd29578912ca6" },
386
- { target: "skills/pipebrain-work-session/SKILL.md", sha256: "ed4a9046704b32a69d5146e4eebb2a729e39c92dbcae5cfd6c01fbcaf17caa47" },
385
+ { target: "skills/pipebrain-secrets/SKILL.md", sha256: "52c2136a3cca5612981d50b926195eae3e804911e1e9723dd5eba76e66b52d6f" },
386
+ { target: "skills/pipebrain-work-session/SKILL.md", sha256: "09d5741761b43fc13a12a04117edd8b194aac18974bfedb42dfc76c14030ba87" },
387
387
  { target: "skills/pipebrain-writing/SKILL.md", sha256: "9ab1a188215716907fbb54e03cfdef43bb378800713b000f498bff4ea75462f0" },
388
- { target: "skills/pipebrain/SKILL.md", sha256: "ee35514459d1b2d6de8cba4ad1162b2269adb8f8c2dd6cdccfa4774b8743fde5" },
388
+ { target: "skills/pipebrain/SKILL.md", sha256: "e53a1cb13d4e2e396c638d8f99055f4082edef030a40ffc8806b1f52f0824b57" },
389
389
  { target: "skills/requirements-altitude/SKILL.md", sha256: "60d1916da22a52138c046fcf4e5cd4cc420b7a5679a6652eb76573b7a69a1cf4" }
390
390
  ];
391
391
 
@@ -505,7 +505,7 @@ function apiBindingLabel(apiBaseUrl) {
505
505
  }
506
506
 
507
507
  // ../../libs/agent-shared/dist/doctrine.js
508
- var DOCTRINE = "# Pipebrain doctrine\n\nHow an agent works inside Pipebrain. These rules ship with the product and read the same in every organization, because each exists where disagreeing breaks something the product guarantees rather than merely changing house style.\n\nHow your organization *writes* is its own text, edited in Settings, and follows this document under a heading naming each scope.\n\n## Decisions are yours; escalate on consequence\n\nInside an approved requirement you choose the design, the libraries, the structure, and the sequencing. Do not ask, report, or seek ratification for the choice. Escalate on consequence, not uncertainty: a choice that changes what a user sees, costs money, or contradicts an approved requirement is filed as a question with the options you propose; everything else you decide and carry on. A technical choice worth keeping is a decision record, never a paragraph.\n\n## Every loose end has one destination, and none of them is prose\n\nAnything you notice that is not the work in front of you goes to exactly one of three places: a question, when the operator has to choose; a new issue at triage, when it is real work outside this item's scope; or nowhere. Nowhere is the default, and the right outcome whenever knowing the thing would change nothing anyone does. Never open a section called notes, caveats, observations, things to know, or next steps in a body or a comment. Filing is the report: an item recorded and then repeated in prose asks the operator to triage it twice.\n\n## A decision you need is filed as a question, never written into a body\n\nAn open decision belongs in a question on the issue, filed with `ask_questions`, carrying the options you propose. It does not belong in a body \u2014 not under a heading, and not in a field. A decision written into prose prompts nobody and records no answer, so the operator has no place to make it and a later session cannot tell whether it ever was. Retract one your own work has since settled, so nothing asks the operator to decide something nobody waits on.\n\n## Search before you create\n\nLook for the existing record before adding one. A duplicate splits one thing's history across two identifiers, and the split is permanent \u2014 nothing merges them afterward.\n\n## Link by key; never paste another record's text\n\nReference a requirement, a decision, or an issue by its key, with its title on first mention. Never paste its text into a body. The source changes and the copy does not, and the stale copy is what gets built.\n\n## Cross-cutting requirements arrive on their own\n\nThey bind every epic automatically and come back with a work-item read, at their latest approved revision. Never restate them in a body and never add them as explicit requirement links. An explicit link pins a document at an approved revision, which is a different act.\n\n## Verification modes follow what can be driven\n\nBefore assigning a mode, establish what this project can drive end to end: a harness, and the command that runs it. A criterion is `auto` when an evaluator can drive the input and read the result against the running system, including behavior shown in an interface wherever such a harness exists. What qualifies it is the evaluator's own run, never the existence of a test the implementer wrote.\n\nMark `manual` only when the verdict needs a person's judgment. A criterion you cannot describe as drivable is usually worded too vaguely: reword it against the observable rather than fall back to a person. A `manual` criterion carries instructions for whoever checks it by hand, written for someone who has not seen the code or the conversation that produced it.\n\nA manual criterion carrying no recorded artifact is the human gate working as designed rather than a coverage gap, so it is neither a finding nor weaker than one carrying evidence.\n\n## The bar for filing a bug\n\nFile a bug only when the observed behavior breaks an approved requirement or a defined acceptance criterion, and name which one before you file. Failing that, it is not a bug: code you find unclear, an edge case nobody specified, a performance number nobody set, and anything you would phrase as \"this could be better\" are opinions about the product, so file each as feedback or not at all. The bar exists because a queue holding both real defects and unshared taste has to be read entirely before any of it can be trusted.\n\n## A decision record is written once\n\nIt states what was decided and why, at the time it was decided, and is not edited as understanding moves. A superseding record replaces it, and the chain of the two is the account of how the thinking changed \u2014 which an edited record destroys. Read the existing records before re-opening a settled question.\n\n## A secret value never travels in a tool call\n\nNo tool here returns one, and that is deliberate rather than an omission to be fixed. Nor should you put one in: a value typed into a tool call is in the transcript before the server can encrypt it, so it is already disclosed and the only remedy left is rotation. Set values with the command-line tool, from a file or on standard input, and inject them at run time rather than reading them into context.\n\n## Setting an instruction replaces the whole body\n\nThere is no partial update. Writing an instruction stores exactly the body you supply and discards everything that was there, so a partial write destroys the rest of the text. The procedure is: read the live value, compose the complete replacement, write it, then read the live value again to confirm what landed.\n\nWhat you are reading now is a snapshot taken when this session started, and so are the tool descriptions. An edit reaches the next session, never this one, so reading the text back after a write shows the pre-edit value. An agent that reports a diff of what it just wrote has read the snapshot instead of the record.\n";
508
+ var DOCTRINE = "# Pipebrain doctrine\n\nHow an agent works inside Pipebrain. These rules ship with the product and read the same in every organization, because each exists where disagreeing breaks something the product guarantees rather than merely changing house style.\n\nHow your organization *writes* is its own text, edited in Settings, and follows this document under a heading naming each scope.\n\n## Decisions are yours; escalate on consequence\n\nInside an approved requirement you choose the design, the libraries, the structure, and the sequencing. Do not ask, report, or seek ratification for the choice. Escalate on consequence, not uncertainty: a choice that changes what a user sees, costs money, or contradicts an approved requirement is filed as a question with the options you propose; everything else you decide and carry on. A technical choice worth keeping is a decision record, never a paragraph.\n\n## Every loose end has one destination, and none of them is prose\n\nAnything you notice that is not the work in front of you goes to exactly one of three places: a question, when the operator has to choose; a new issue at triage, when it is real work outside this item's scope; or nowhere. Nowhere is the default, and the right outcome whenever knowing the thing would change nothing anyone does. Never open a section called notes, caveats, observations, things to know, or next steps in a body or a comment. Filing is the report: an item recorded and then repeated in prose asks the operator to triage it twice.\n\n## A decision you need is filed as a question, never written into a body\n\nAn open decision belongs in a question on the issue, filed with `ask_questions`, carrying the options you propose. It does not belong in a body \u2014 not under a heading, and not in a field. A decision written into prose prompts nobody and records no answer, so the operator has no place to make it and a later session cannot tell whether it ever was. Retract one your own work has since settled, so nothing asks the operator to decide something nobody waits on.\n\n## Search before you create\n\nLook for the existing record before adding one. A duplicate splits one thing's history across two identifiers, and the split is permanent \u2014 nothing merges them afterward.\n\n## Link by key; never paste another record's text\n\nReference a requirement, a decision, or an issue by its key, with its title on first mention. Never paste its text into a body. The source changes and the copy does not, and the stale copy is what gets built.\n\n## Cross-cutting requirements arrive on their own\n\nThey bind every epic automatically and come back with a work-item read, at their latest approved revision. Never restate them in a body and never add them as explicit requirement links. An explicit link pins a document at an approved revision, which is a different act.\n\n## Conventions arrive in force; yours is a proposal\n\nApproved conventions arrive with the same work-item read, at their latest approved revision, and they bind work in every project in the organization, not this one alone. Neither restate one in a body nor add one as an explicit link: delivery is already automatic, so a copy is a second version waiting to drift.\n\nOne you create or amend over the agent surface records a proposal. It is not in force until a person approves it in a browser, and only an approved revision reaches any session \u2014 so in the interval, do not work from your own unapproved wording, because adopting a rule for the organization is a person's act, not yours.\n\nThe tier test is a statement's lifetime: one that stops being true when this project ends is a cross-cutting requirement, and one that would still be true on the next project is a convention. The moments worth capturing are an evaluator rejecting work, a criterion moving to failed, and a reason recorded on blocked work. File at moments like those and rarely otherwise: every approved convention is delivered on every read, and it spends a person's review attention before it gets there.\n\n## Verification modes follow what can be driven\n\nBefore assigning a mode, establish what this project can drive end to end: a harness, and the command that runs it. A criterion is `auto` when an evaluator can drive the input and read the result against the running system, including behavior shown in an interface wherever such a harness exists. What qualifies it is the evaluator's own run, never the existence of a test the implementer wrote.\n\nMark `manual` only when the verdict needs a person's judgment. A criterion you cannot describe as drivable is usually worded too vaguely: reword it against the observable rather than fall back to a person. A `manual` criterion carries instructions for whoever checks it by hand, written for someone who has not seen the code or the conversation that produced it.\n\nA manual criterion carrying no recorded artifact is the human gate working as designed rather than a coverage gap, so it is neither a finding nor weaker than one carrying evidence.\n\n## The bar for filing a bug\n\nFile a bug only when the observed behavior breaks an approved requirement or a defined acceptance criterion, and name which one before you file. Failing that, it is not a bug: code you find unclear, an edge case nobody specified, a performance number nobody set, and anything you would phrase as \"this could be better\" are opinions about the product, so file each as feedback or not at all. The bar exists because a queue holding both real defects and unshared taste has to be read entirely before any of it can be trusted.\n\n## A decision record is written once\n\nIt states what was decided and why, at the time it was decided, and is not edited as understanding moves. A superseding record replaces it, and the chain of the two is the account of how the thinking changed \u2014 which an edited record destroys. Read the existing records before re-opening a settled question.\n\n## A secret value never travels in a tool call\n\nNo tool here returns one, and that is deliberate rather than an omission to be fixed. Nor should you put one in: a value typed into a tool call is in the transcript before the server can encrypt it, so it is already disclosed and the only remedy left is rotation. Set values with the command-line tool, from a file or on standard input, and inject them at run time rather than reading them into context.\n\n## Setting an instruction replaces the whole body\n\nThere is no partial update. Writing an instruction stores exactly the body you supply and discards everything that was there, so a partial write destroys the rest of the text. The procedure is: read the live value, compose the complete replacement, write it, then read the live value again to confirm what landed.\n\nWhat you are reading now \u2014 the composed guidance \u2014 is a snapshot taken when this session started, and so are the tool descriptions. An edit reaches the next session's guidance, never this one's, so this document re-read after a write still shows the pre-edit text. The stored rows are a different read: `list_org_instructions` returns the current saved values mid-session, which is what makes the confirmation read above possible. An agent that reports its write as absent has re-read the snapshot instead of the record.\n";
509
509
 
510
510
  // ../../libs/agent-shared/dist/resolve-agent-env.js
511
511
  function pick(env, name) {
@@ -15756,7 +15756,9 @@ var chatMessageReferenceSchema = external_exports.object({
15756
15756
  entityId: uuidSchema,
15757
15757
  revision: positiveIntSchema.nullable(),
15758
15758
  displayId: external_exports.string().nullable(),
15759
- title: external_exports.string().nullable()
15759
+ title: external_exports.string().nullable(),
15760
+ summary: external_exports.string().nullable().optional(),
15761
+ headerStale: external_exports.boolean().optional()
15760
15762
  });
15761
15763
  var chatAttachmentSchema = external_exports.object({
15762
15764
  assetId: uuidSchema,
@@ -16022,6 +16024,19 @@ var setStatusInputSchema = external_exports.strictObject({
16022
16024
  issue: issueRefSchema,
16023
16025
  status: issueStatusSchema
16024
16026
  });
16027
+ var WORK_CONTEXT_COMMENT_LIMIT = 20;
16028
+ var ISSUE_BODY_CHAR_LIMIT = 1e5;
16029
+ var ISSUE_COMMENT_LIMIT = 50;
16030
+ var issueReadWindowSchema = external_exports.strictObject({
16031
+ bodyOffset: nonNegativeIntSchema.optional(),
16032
+ bodyLimit: positiveIntSchema.optional(),
16033
+ commentOffset: nonNegativeIntSchema.optional(),
16034
+ commentLimit: positiveIntSchema.optional()
16035
+ });
16036
+ var getIssueInputSchema = external_exports.strictObject({
16037
+ issue: issueRefSchema,
16038
+ ...issueReadWindowSchema.shape
16039
+ });
16025
16040
  var getWorkContextInputSchema = external_exports.strictObject({
16026
16041
  issue: issueRefSchema.optional(),
16027
16042
  project: projectRefSchema.optional()
@@ -16683,8 +16698,16 @@ var ApiClient = class _ApiClient {
16683
16698
  listIssues(filter = {}) {
16684
16699
  return this.requestList("GET", "/api/v1/issues", { query: filter });
16685
16700
  }
16686
- getIssue(ref) {
16687
- return this.request("GET", `/api/v1/issues/${enc(ref)}`);
16701
+ /**
16702
+ * Reads the full detail, optionally windowed (PIPE-386): `window`'s four fields ride
16703
+ * as the route's query params of the same names, and a response that left anything
16704
+ * out carries `omitted` saying so. No window means the WHOLE record — this client
16705
+ * injects no default, so the split between the bounded agent surface and the
16706
+ * unbounded raw read is decided by the caller that owns it (the MCP handler), not
16707
+ * silently here for everyone.
16708
+ */
16709
+ getIssue(ref, window) {
16710
+ return this.request("GET", `/api/v1/issues/${enc(ref)}`, { query: window });
16688
16711
  }
16689
16712
  updateIssue(ref, fields) {
16690
16713
  return this.request("PATCH", `/api/v1/issues/${enc(ref)}`, { body: fields });
@@ -17090,15 +17113,9 @@ var ApiClient = class _ApiClient {
17090
17113
  deleteDeliverableTarget(key, id) {
17091
17114
  return this.request("DELETE", `/api/v1/projects/${enc(key)}/deliverable-targets/${enc(id)}`);
17092
17115
  }
17093
- getOverview(key) {
17094
- return this.request("GET", `/api/v1/projects/${enc(key)}/overview`);
17095
- }
17096
17116
  getActivity(key, query) {
17097
17117
  return this.requestList("GET", `/api/v1/projects/${enc(key)}/activity`, { query });
17098
17118
  }
17099
- getQueue(key) {
17100
- return this.request("GET", `/api/v1/projects/${enc(key)}/queue`);
17101
- }
17102
17119
  /**
17103
17120
  * Nav search (backlog/212) — a scoped typeahead over one project's issues +
17104
17121
  * requirements. `q` matches the display key/title/(requirement) summary
@@ -17274,7 +17291,13 @@ var ApiClient = class _ApiClient {
17274
17291
  createOrg(input) {
17275
17292
  return this.request("POST", "/api/v1/orgs", { body: input });
17276
17293
  }
17277
- /** Renames and/or re-slugs an org (multi-org §4, web session + ADMIN). */
17294
+ /**
17295
+ * Renames, re-slugs, and/or sets the org's convention warning threshold (multi-org
17296
+ * §4 + PIPE-359, web session + ADMIN). `conventionWarningThreshold` follows
17297
+ * `updateOrgInputSchema`: omitted leaves the setting alone, an explicit `null`
17298
+ * CLEARS it back to the shipped default — the two are different wires, so the field
17299
+ * is typed `number | null` rather than merely optional.
17300
+ */
17278
17301
  updateOrg(id, fields) {
17279
17302
  return this.request("PATCH", `/api/v1/orgs/${enc(id)}`, { body: fields });
17280
17303
  }
@@ -17537,8 +17560,21 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
17537
17560
  // degrades to silence, since a session must not fail to start over an installer.
17538
17561
  instructions: composeIndex(banner, machineNotice())
17539
17562
  });
17540
- server2.registerTool("list_projects", {
17541
- description: "List all projects with their key, environments, milestones (token, label, active), and open-issue counts \u2014 the project switcher and workflow entry point (\xA77).",
17563
+ function registerStrict(name, config3, cb) {
17564
+ const declared = config3.inputSchema;
17565
+ const inputSchema = declared === void 0 ? external_exports.strictObject({}) : "_zod" in declared || "_def" in declared ? declared : external_exports.strictObject(declared);
17566
+ server2.registerTool(
17567
+ name,
17568
+ { ...config3, inputSchema },
17569
+ // The cast retells what the generic already guarantees: the SDK calls the
17570
+ // handler with the parse result of the schema derived from `InputArgs`, and a
17571
+ // strict wrap changes which calls REACH the handler, never the shape of one
17572
+ // that does.
17573
+ cb
17574
+ );
17575
+ }
17576
+ registerStrict("list_projects", {
17577
+ description: "List all projects with their key, environments, milestones (token, label, active), deliverable targets (slug, label, active), and open-issue counts \u2014 the project switcher and workflow entry point (\xA77).",
17542
17578
  // Optional pagination (§9) — bounds mirror list_issues/list_requirements; the
17543
17579
  // server defaults limit to 200 (max 500) and passes limit/offset as query params.
17544
17580
  inputSchema: {
@@ -17546,7 +17582,7 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
17546
17582
  offset: external_exports.number().int().nonnegative().optional()
17547
17583
  }
17548
17584
  }, (args) => run(() => client2.listProjects(args), GUIDANCE_POINTER));
17549
- server2.registerTool("create_project", {
17585
+ registerStrict("create_project", {
17550
17586
  description: "Create a new project \u2014 the workflow bootstrap entry point, since issues and requirements are all project-scoped and nothing can be filed until a project exists. Both `name` (its display label) and `key` are required; the key is 2\u20135 uppercase letters (A\u2013Z, e.g. `DWB`), unique per org, and becomes the prefix of every display id in the project. Returns the created project (\xA77).",
17551
17587
  // Inline shape: @pipeline/domain has no standalone create-project schema — the
17552
17588
  // same `{ name, key }` pair lives only inside createOrgInputSchema.project, so
@@ -17557,8 +17593,8 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
17557
17593
  key: projectKeySchema
17558
17594
  }
17559
17595
  }, (args) => run(() => client2.createProject(args)));
17560
- server2.registerTool("list_waiting", {
17561
- description: "Every gate awaiting a PERSON across ALL projects in the token\u2019s org, longest wait first: a requirement revision awaiting approval, a question awaiting an answer, and work in `verify` holding at least one criterion not yet passed. Each entry names its project, the ref the action is taken on (`KEY-R{n}` for a revision, the ISSUE\u2019s `KEY-{n}` for a question or for work at verify), and `waitingSince` \u2014 the instant the wait began, not a duration. Nothing else is a gate: work in `triage` never appears, and neither does work in verify whose criteria have ALL passed. READ-ONLY, and there is no tool on this surface that clears any of the three \u2014 approving a revision, answering a question and marking a manual criterion are all decisions a person takes in the web app. Use it to see what your work is blocked on before asking, rather than to act on it."
17596
+ registerStrict("list_waiting", {
17597
+ description: "Every gate awaiting a PERSON across ALL projects in the token\u2019s org, longest wait first: a requirement revision awaiting approval, a question awaiting an answer, and work in `verify` holding at least one criterion not yet passed. Each entry names its project, the ref the action is taken on (`KEY-R{n}` for a revision, the ISSUE\u2019s `KEY-{n}` for a question or for work at verify), and `waitingSince` \u2014 the instant the wait began, not a duration. Nothing else is a gate: work in `triage` never appears, and neither does work in verify whose criteria have ALL passed. THIS TOOL is READ-ONLY and clears nothing; what clears each gate differs. Answering a question has no tool on this surface, by design. A requirement revision is approved with `approve_requirement`. A verify gate clears as its criteria pass \u2014 an `auto` one through `set_criterion_state`, a `manual` one only ever by a person in the web app. Use it to see what your work is blocked on before asking, rather than to act on it."
17562
17598
  // NO arguments — not even an org. Org scope arrives from the token, exactly as
17563
17599
  // it does for `list_projects`, and an org parameter would open a cross-org read
17564
17600
  // path that has no precedent anywhere on this surface. The viewer narrowing the
@@ -17577,7 +17613,7 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
17577
17613
  // the operator happens to be looking at.
17578
17614
  run(async () => ({ items: await client2.listWaiting() }), GUIDANCE_POINTER)
17579
17615
  ));
17580
- server2.registerTool(
17616
+ registerStrict(
17581
17617
  "get_work_context",
17582
17618
  {
17583
17619
  // TRIMMED WHEN THE GUIDANCE MOVED HERE (PIPE-341, PIPE-D30). This description was
@@ -17585,7 +17621,7 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
17585
17621
  // one description loaded into EVERY session by `anthropic/alwaysLoad`. The clauses
17586
17622
  // that went were the ones arguing for the rule rather than stating it; what a caller
17587
17623
  // needs to predict a refusal all stayed.
17588
- description: "Everything a session needs before it starts work, in ONE read \u2014 assembled by the API and returned verbatim, with the BINDING GUIDANCE appended after it: the product doctrine and this organization\u2019s own writing rules, in full. This is the one call that returns them; every other read only names it. CALL IT FIRST, before you write anything. Addressed by `issue` OR by `project`, never both and never neither: an issue names its own project, so the pair is refused rather than resolved for you (\xA77). Given an ISSUE: that issue\u2019s full detail \u2014 the same payload `get_issue` returns \u2014 PLUS the BODY TEXT of every requirement it links, at the revision the link is pinned at. That text is the round trip this removes: `get_issue` names which documents bind the work and at which revision, never what they SAY. Comments come CAPPED at the newest `commentLimit`, with `commentCount` \u2014 the issue\u2019s TRUE total \u2014 beside them; read the issue directly for the rest. Given a PROJECT: `available`, the work a session could START right now. It EXCLUDES finished and abandoned work (`done`, `killed`), work another person has claimed, and anything blocked \u2014 the status guard would refuse to start that. Your OWN claims stay, and so does unscheduled work carrying no milestone. `available` comes CAPPED at `availableLimit` rows, with `availableCount` \u2014 the project\u2019s TRUE total of startable work \u2014 beside it. The item form carries no `available`; the project form carries no item. BOTH forms also carry the project half: the release currently being worked (the active milestone) and the project\u2019s cross-cutting requirements WITH their text \u2014 they bind every epic automatically \u2014 and this organization\u2019s approved conventions in full, standing guidance that binds work in EVERY project here, not just this one. READ-ONLY \u2014 this claims nothing, moves nothing and writes nothing.",
17624
+ description: `Everything a session needs before it starts work, in ONE read \u2014 assembled by the API and returned verbatim, with the BINDING GUIDANCE appended after it: the product doctrine and this organization\u2019s own writing rules, in full. This is the one call that returns them; every other read only names it. CALL IT FIRST, before you write anything. Addressed by \`issue\` OR by \`project\`, never both and never neither: an issue names its own project, so the pair is refused rather than resolved for you (\xA77). Given an ISSUE: that issue\u2019s full detail \u2014 the same payload \`get_issue\` returns \u2014 PLUS the BODY TEXT of every requirement it links, at the revision the link is pinned at. That text is the round trip this removes: \`get_issue\` names which documents bind the work and at which revision, never what they SAY. Comments come CAPPED at the newest \`commentLimit\` (default ${WORK_CONTEXT_COMMENT_LIMIT}), with \`commentCount\` \u2014 the issue\u2019s TRUE total \u2014 beside them; read the issue directly for the rest. Given a PROJECT: \`available\`, the work a session could START right now. It EXCLUDES finished and abandoned work (\`done\`, \`killed\`), work another person has claimed, and anything blocked \u2014 the status guard would refuse to start that. Your OWN claims stay, and so does unscheduled work carrying no milestone. \`available\` comes CAPPED at \`availableLimit\` rows, with \`availableCount\` \u2014 the project\u2019s TRUE total of startable work \u2014 beside it. The item form carries no \`available\`; the project form carries no item. BOTH forms also carry the project half: the release currently being worked (the active milestone) and the project\u2019s cross-cutting requirements WITH their text \u2014 they bind every epic automatically \u2014 and this organization\u2019s approved conventions in full, standing guidance that binds work in EVERY project here, not just this one. READ-ONLY \u2014 this claims nothing, moves nothing and writes nothing.`,
17589
17625
  // The WHOLE domain schema rather than its `.shape`: the exactly-one-of rule is a
17590
17626
  // refinement on the OBJECT, and a raw shape drops it — the SDK validates against
17591
17627
  // what it is handed, so the shape alone would quietly accept both arguments and
@@ -17615,7 +17651,7 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
17615
17651
  // cut the previous mechanism to a tenth of the document.
17616
17652
  (args) => run(() => client2.getWorkContext(args), guidance)
17617
17653
  );
17618
- server2.registerTool("create_milestone", {
17654
+ registerStrict("create_milestone", {
17619
17655
  description: "Create a milestone on a project. `token` is the short ref issues point at via their `milestone` field (e.g. `M1`) and is unique per project (a duplicate is rejected); `label` is its display name (e.g. `Feature Verification`). Optional `position` orders it (0-based, defaults to last); optional `active` marks it the current milestone \u2014 AT MOST ONE milestone per project is active, so `active: true` transactionally DEMOTES the previously active one rather than failing. Returns the created milestone (\xA77).",
17620
17656
  inputSchema: {
17621
17657
  project: projectKeySchema,
@@ -17625,7 +17661,7 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
17625
17661
  active: external_exports.boolean().optional()
17626
17662
  }
17627
17663
  }, ({ project, ...input }) => run(() => client2.createMilestone(project, input)));
17628
- server2.registerTool("update_milestone", {
17664
+ registerStrict("update_milestone", {
17629
17665
  description: "Rename or reorder a milestone, addressed by its token (e.g. `M1`) or uuid. Supply at least one of `token`, `label`, or `position` \u2014 an empty edit is rejected. Renaming the `token` does NOT rewrite issues pointing at the old one (`issues.milestone` is a loose ref), so prefer changing `label`. Use set_active_milestone to change `active` (\xA77).",
17630
17666
  inputSchema: {
17631
17667
  project: projectKeySchema,
@@ -17635,14 +17671,14 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
17635
17671
  position: external_exports.number().int().nonnegative().optional()
17636
17672
  }
17637
17673
  }, ({ project, milestone, ...fields }) => run(async () => client2.updateMilestone(project, await resolveMilestoneId(client2, project, milestone), fields)));
17638
- server2.registerTool("set_active_milestone", {
17674
+ registerStrict("set_active_milestone", {
17639
17675
  description: "Make a milestone the project\u2019s active one, addressed by its token (e.g. `M2`) or uuid. At most one milestone per project is active; this clears the previous one in the same transaction. Returns the activated milestone (\xA77).",
17640
17676
  inputSchema: {
17641
17677
  project: projectKeySchema,
17642
17678
  milestone: external_exports.string().min(1)
17643
17679
  }
17644
17680
  }, ({ project, milestone }) => run(async () => client2.activateMilestone(project, await resolveMilestoneId(client2, project, milestone))));
17645
- server2.registerTool("delete_milestone", {
17681
+ registerStrict("delete_milestone", {
17646
17682
  description: "Soft-delete a milestone, addressed by its token (e.g. `M3`) or uuid; its token frees up for reuse. Issues still naming it are NOT rewritten and keep the now-dangling token (`issues.milestone` is a loose ref) \u2014 re-point them first. Returns confirmation metadata only (\xA77).",
17647
17683
  inputSchema: {
17648
17684
  project: projectKeySchema,
@@ -17653,7 +17689,7 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
17653
17689
  await client2.deleteMilestone(project, id);
17654
17690
  return { deleted: true, project, milestone, id };
17655
17691
  }));
17656
- server2.registerTool("create_deliverable_target", {
17692
+ registerStrict("create_deliverable_target", {
17657
17693
  description: "Add a deliverable target (platform) to a project. `slug` is the immutable lowercase-alphanumeric machine id a deliverable stores as its platform (e.g. `bsky`, `threads`) and is unique per project; `label` is its display name (e.g. `Bluesky`). Optional `position` orders it (0-based, defaults to last); optional `active` (default true) gates whether new deliverables can target it. Returns the created target.",
17658
17694
  inputSchema: {
17659
17695
  project: projectKeySchema,
@@ -17663,7 +17699,7 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
17663
17699
  active: external_exports.boolean().optional()
17664
17700
  }
17665
17701
  }, ({ project, ...input }) => run(() => client2.createDeliverableTarget(project, input)));
17666
- server2.registerTool("update_deliverable_target", {
17702
+ registerStrict("update_deliverable_target", {
17667
17703
  description: "Update a deliverable target, addressed by its slug (e.g. `bsky`) or uuid. Supply at least one of `label`, `position`, or `active` \u2014 an empty edit is rejected. The `slug` is immutable (it is the value stored on existing deliverables). Set `active: false` to remove it from the add-deliverable menu while keeping existing deliverables working.",
17668
17704
  inputSchema: {
17669
17705
  project: projectKeySchema,
@@ -17673,7 +17709,7 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
17673
17709
  active: external_exports.boolean().optional()
17674
17710
  }
17675
17711
  }, ({ project, target, ...fields }) => run(async () => client2.updateDeliverableTarget(project, await resolveDeliverableTargetId(client2, project, target), fields)));
17676
- server2.registerTool("delete_deliverable_target", {
17712
+ registerStrict("delete_deliverable_target", {
17677
17713
  description: "Delete a deliverable target, addressed by its slug (e.g. `bsky`) or uuid. Only a target with NO deliverables can be deleted; one already in use is rejected (deactivate it with update_deliverable_target instead). Returns confirmation metadata only.",
17678
17714
  inputSchema: {
17679
17715
  project: projectKeySchema,
@@ -17684,90 +17720,116 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
17684
17720
  await client2.deleteDeliverableTarget(project, id);
17685
17721
  return { deleted: true, project, target, id };
17686
17722
  }));
17687
- server2.registerTool(
17723
+ registerStrict(
17688
17724
  "list_issues",
17689
17725
  {
17690
- description: `List issue summaries (display id, title, type, status, terminal, blocked, priority, milestone, claimer, criteria progress, updated_at, and the header \u2014 \`summary\`, \`why\`, \`affectedSurfaces\`, \`headerStale\`, \`headerDerivedAt\` \u2014 so a list says what each issue is about without opening it) filtered by project, environment, status[], type[], claimedBy (me/unclaimed/uuid), priority[], milestone, text (title+body), parent, blocked, and needsHeader (\xA77). DEFAULT: with no status filter this returns BUILDABLE work only \u2014 ${OPEN_STATUSES.join(", ")}. Terminal work (done, killed) is excluded unless you ask for it by name: pass status explicitly, e.g. status: ["done","killed"] for finished and abandoned work, or list every value to see everything. An explicit status filter is used verbatim and the default is not merged into it. Every row also carries \`terminal\` (PIPE-R15 r4 rule 5). blocked: false returns only work with nothing blocking it, and every issue it returns can be moved to ready without refusal; blocked: true returns only blocked work. needsHeader: true returns exactly the issues whose header is missing or no longer describes the body it was derived from \u2014 the set \`set_issue_header\` still has to be called on \u2014 and needsHeader: false returns only those whose header still holds. Each row\u2019s \`headerStale\` is the same expression this filter selects on, so a row cannot disagree with the filter that returned it. The status default above is not lifted for it: name done and killed explicitly when the sweep is meant to reach finished work as well.`,
17726
+ description: `List issue summaries (display id, title, type, status, terminal, blocked, priority, milestone, claimer, criteria progress, updated_at, and the header \u2014 \`summary\`, \`why\`, \`affectedSurfaces\`, \`headerStale\`, \`headerDerivedAt\` \u2014 so a list says what each issue is about without opening it) filtered by project, environment, status[], type[], claimedBy (me/unclaimed/uuid), priority[], milestone, text (title+body), parent, blocked, and needsHeader (\xA77). DEFAULT: with no status filter this returns BUILDABLE work only \u2014 ${OPEN_STATUSES.join(", ")}. Terminal work (done, killed) is excluded unless you ask for it by name: pass status explicitly, e.g. status: ["done","killed"] for finished and abandoned work, or list every value to see everything. An explicit status filter is used verbatim and the default is not merged into it. The default is the API's own, applied server-side, so an omitted status means the same thing on every surface. Every row also carries \`terminal\` (PIPE-R15 r4 rule 5). blocked: false returns only work with nothing blocking it, and every issue it returns can be moved to ready without refusal; blocked: true returns only blocked work. needsHeader: true returns exactly the issues whose header is missing or no longer describes the body it was derived from \u2014 the set \`set_issue_header\` still has to be called on \u2014 and needsHeader: false returns only those whose header still holds. Each row\u2019s \`headerStale\` is the same expression this filter selects on, so a row cannot disagree with the filter that returned it. The status default above is not lifted for it: name done and killed explicitly when the sweep is meant to reach finished work as well.`,
17691
17727
  inputSchema: listIssuesFilterSchema.shape
17692
17728
  },
17693
- // The default lives HERE, not in the API. PIPE-R15 r4 rule 5 makes the default
17694
- // the enforcement point — an agent that skips a filter still produces confident
17695
- // output, so making terminal work unavailable by default beats documenting that
17696
- // reading it is wrong. It is applied on this surface alone because the work page
17697
- // fetches the project UNFILTERED and applies its status chips client-side; an
17698
- // API-level default would strip out the very rows those chips exist to reveal.
17699
- // Only an OMITTED status is filled in — an explicit one, terminal values and
17700
- // all, passes through untouched.
17701
- (args) => run(() => client2.listIssues(args.status === void 0 ? { ...args, status: [...OPEN_STATUSES] } : args), GUIDANCE_POINTER)
17729
+ // The default lives in the API now, not here (PIPE-D35, Listing issues
17730
+ // without a status returns only non-terminal work, and the default lives in
17731
+ // the API). This handler used to fill an omitted status in with the open
17732
+ // set before the request went out, which was the same rule in two places:
17733
+ // this surface said "open work" while the raw HTTP route said "everything",
17734
+ // and a second API caller met whichever it happened to hit. An omitted
17735
+ // status now passes THROUGH — the API applies the one copy of the default —
17736
+ // and an explicit filter, terminal values and all, is forwarded verbatim as
17737
+ // it always was. (The work page kept its terminal rows by naming every
17738
+ // status on its reads, so nothing over there leant on the defaultless API.)
17739
+ (args) => run(() => client2.listIssues(args), GUIDANCE_POINTER)
17702
17740
  );
17703
- server2.registerTool("get_issue", {
17704
- description: "Full issue detail by display id or uuid: the header \u2014 `summary`, `why`, `affectedSurfaces`, with `headerDerivedAt` and `headerStale`, true when nobody has written a header or the body has moved since one was written, and `set_issue_header` is what writes it \u2014 body, criteria (states, modes, evidence), explicit requirement links (pinned revision + staleness), the project\u2019s cross-cutting requirements at their latest approved revision, this organization\u2019s approved conventions \u2014 standing guidance that BINDS work in every project here, delivered in full at the revision a person approved \u2014 children, and comments (\xA77). Carries `terminal`: true when the issue is finished (`done`) or deliberately abandoned (`killed`) and is therefore NOT buildable work \u2014 check it before acting on an issue you were handed by key. Reading criterion state: `state_evidence` is required only when a TOKEN sets one, so a `manual` criterion \u2014 settable only from a web session \u2014 carries none by design, and the attribution the server already stores (who set it, and when) IS its record. What this payload tells you about ANOTHER issue goes stale. Reference that issue by key and say what you need from it; never restate its status, its criteria, or whether those criteria passed \u2014 such a sentence is true when written and wrong later, and a terminal issue is never revisited to correct it, so the stale reading is what the next reader inherits.",
17705
- inputSchema: { issue: issueRefSchema }
17706
- }, (args) => run(() => client2.getIssue(args.issue), GUIDANCE_POINTER));
17707
- server2.registerTool("create_issue", {
17708
- description: "Create an issue (epic/task/bug/feedback). Omitted status defaults to triage (still subject to \xA76 guards); omitted environment resolves explicit \u2192 parent\u2019s environment \u2192 project default (prod). Optional parent (self/cycle rejected), priority, milestone, requirement_links (pins each at its approved revision; a target with no approved revision is rejected), deliverable_links (content deliverables like DWB-C3/bsky \u2014 pins each at its current revision; the promotion path), and ordered {text, verification, howTo} criteria (\xA77). A `status` other than `triage` or `killed` REQUIRES `milestone` (PIPE-R10 rule 9): work past triage belongs to a release, and the create is refused (`milestone_required`) without one. A `manual` criterion must carry `howTo` \u2014 one or two sentences saying where to look, what to do, and what counts as passing; writing or changing one without it is refused (`how_to_required`), and so is supplying it on an `auto` criterion, which a machine checks. Work of any kind carries criteria \u2014 an epic, a task, a bug alike \u2014 with feedback the one exception, refused with `criteria_not_supported` because it is a report rather than agreed work; `promote_feedback` retypes it into work before anything is built. When a bug is filed against a defined acceptance criterion, pass that criterion\u2019s uuid as `criterionId` and the bug records which criterion it was filed against \u2014 the criterion then names what has already been reported (it arrives as `filedBugs` on every criteria read) beside the action that files again, so a duplicate is prevented by information rather than by removal. Filing a second bug against one criterion is a supported situation, not a mistake: one row can fail for two different reasons at once. Bugs only: any other type naming a criterion is refused (`criterion_requires_bug`). Writing guidance for this tool lives in the product doctrine, which `get_work_context` returns \u2014 see \u201CSearch before you create\u201D and \u201CThe bar for filing a bug\u201D.",
17741
+ registerStrict("get_issue", {
17742
+ description: `Full issue detail by display id or uuid: the header \u2014 \`summary\`, \`why\`, \`affectedSurfaces\`, with \`headerDerivedAt\` and \`headerStale\`, true when nobody has written a header or the body has moved since one was written, and \`set_issue_header\` is what writes it \u2014 body, criteria (states, modes, evidence), explicit requirement links (pinned revision + staleness), the project\u2019s cross-cutting requirements at their latest approved revision, this organization\u2019s approved conventions \u2014 standing guidance that BINDS work in every project here, delivered in full at the revision a person approved \u2014 children, and comments (\xA77). Carries \`terminal\`: true when the issue is finished (\`done\`) or deliberately abandoned (\`killed\`) and is therefore NOT buildable work \u2014 check it before acting on an issue you were handed by key. Reading criterion state: \`state_evidence\` is required only when a TOKEN sets one, so a \`manual\` criterion \u2014 settable only from a web session \u2014 carries none by design, and the attribution the server already stores (who set it, and when) IS its record. What this payload tells you about ANOTHER issue goes stale. Reference that issue by key and say what you need from it; never restate its status, its criteria, or whether those criteria passed. Long records read in PARTS: \`bodyOffset\`/\`bodyLimit\` window the body (UTF-16 offsets; parts concatenate back exactly), \`commentOffset\`/\`commentLimit\` window the oldest-first comment list. Unpaged, the body is capped at ${ISSUE_BODY_CHAR_LIMIT} chars and comments at the newest ${ISSUE_COMMENT_LIMIT}; a response that left anything out says so in \`omitted\` \u2014 the true totals, the omitted comment count, and in \`next\` the exact arguments for the adjacent part (body walks forward, comments toward older) \u2014 follow \`next\` until null to retrieve everything. An issue under both caps reads whole in ONE call, no notice.`,
17743
+ inputSchema: getIssueInputSchema.shape,
17744
+ // The same result-threshold annotation `get_work_context` declares, and the same
17745
+ // key spelled out for the same reason (an unknown `_meta` key is silently
17746
+ // ignored). Without it the client's default output cap sits far below one full
17747
+ // body window, and the bounded response this tool now promises would still reach
17748
+ // an agent as a file reference it may not be able to open — the DOWN-116 failure,
17749
+ // reintroduced by a default this server cannot see. No `alwaysLoad`: that stays
17750
+ // the session-start call's alone.
17751
+ _meta: { "anthropic/maxResultSizeChars": WORK_CONTEXT_RESULT_CHARS }
17752
+ }, ({ issue: issue2, bodyOffset, bodyLimit, commentOffset, commentLimit }) => run(() => (
17753
+ // THE AGENT-SURFACE DEFAULTS (PIPE-386), injected here as explicit wire
17754
+ // params — the ONE deliberate exception to this side's inject-no-default
17755
+ // rule (PIPE-D26), because the two surfaces genuinely differ by design: the
17756
+ // raw HTTP read stays whole (the web reads there, no tool-output limit),
17757
+ // while every response HERE must stay under the ceiling declared above. The
17758
+ // caps are named domain constants, so the rule still has one home; the
17759
+ // comment tail needs no offset — an omitted `commentOffset` beside a present
17760
+ // `commentLimit` IS the newest tail, resolved server-side, so no extra round
17761
+ // trip asks how many comments exist (PIPE-R15 rule 4).
17762
+ client2.getIssue(issue2, {
17763
+ bodyOffset,
17764
+ bodyLimit: bodyLimit ?? ISSUE_BODY_CHAR_LIMIT,
17765
+ commentOffset,
17766
+ commentLimit: commentLimit ?? ISSUE_COMMENT_LIMIT
17767
+ })
17768
+ ), GUIDANCE_POINTER));
17769
+ registerStrict("create_issue", {
17770
+ description: "Create an issue (epic/task/bug/feedback). Omitted status defaults to triage (still subject to \xA76 guards); omitted environment resolves explicit \u2192 parent\u2019s environment \u2192 project default (prod). Optional parent (self/cycle rejected), priority, milestone, requirement_links (pins each at its approved revision; a target with no approved revision is rejected), deliverable_links (content deliverables like DWB-C3/bsky \u2014 pins each at its current revision; the promotion path), and ordered {text, verification, howTo} criteria (\xA77). A `status` other than `triage` or `killed` REQUIRES `milestone` (PIPE-R10 rule 9): work past triage belongs to a release, and the create is refused (`milestone_required`) without one. A `manual` criterion must carry `howTo` \u2014 one or two sentences saying where to look, what to do, and what counts as passing; writing or changing one without it is refused (`how_to_required`), and so is supplying it on an `auto` criterion, which a machine checks. Work of any kind carries criteria \u2014 an epic, a task, a bug alike \u2014 with feedback the one exception, refused with `criteria_not_supported` because it is a report rather than agreed work; `promote_feedback` retypes it into work before anything is built. When a bug is filed against a defined acceptance criterion, pass that criterion\u2019s uuid as `criterionId` and the bug records which criterion it was filed against \u2014 what has already been reported arrives as `filedBugs` on every criteria read. Filing a second bug against one criterion is supported: one row can fail for two different reasons at once. Bugs only: any other type naming a criterion is refused (`criterion_requires_bug`). Writing guidance for this tool lives in the product doctrine, which `get_work_context` returns \u2014 see \u201CSearch before you create\u201D and \u201CThe bar for filing a bug\u201D.",
17709
17771
  inputSchema: createIssueInputSchema.shape
17710
17772
  }, (args) => run(() => client2.createIssue(args)));
17711
- server2.registerTool("promote_feedback", {
17773
+ registerStrict("promote_feedback", {
17712
17774
  description: "Promote a FEEDBACK issue into a NEW epic or task (PIPE-143): creates the issue in the feedback\u2019s project (`title` + `type` required; omitted environment inherits the FEEDBACK\u2019s environment, not the project default; optional status/body/priority/milestone/requirement_links/criteria as in create_issue), records the feedback as its permanent source (`promotedFrom`/`promotedTo` on both details), closes the feedback as done, and comments the new key on it. The feedback\u2019s own title/body are never touched \u2014 write the new issue\u2019s body as real work. Rejected (409) for non-feedback, terminal, or already-promoted sources, and refused (422 `status_guard`, naming each offending child and its status) when the feedback carries a child issue that is not yet `done` or `killed` \u2014 closing the source would strand that child under a finished parent, so finish or kill the child first. Returns the created issue. A `status` other than `triage` or `killed` REQUIRES `milestone` (PIPE-R10 rule 9), exactly as in create_issue; the SOURCE feedback needs none to be closed by the promotion. A `manual` criterion must carry `howTo` \u2014 one or two sentences saying where to look, what to do, and what counts as passing; writing or changing one without it is refused (`how_to_required`), and so is supplying it on an `auto` criterion, which a machine checks. Work of any kind carries criteria \u2014 an epic, a task, a bug alike \u2014 with feedback the one exception, refused with `criteria_not_supported` because it is a report rather than agreed work; this tool is what retypes it into work before anything is built. Writing guidance for this tool lives in the product doctrine, which `get_work_context` returns \u2014 see \u201CSearch before you create\u201D and \u201CThe bar for filing a bug\u201D.",
17713
17775
  inputSchema: {
17714
17776
  issue: external_exports.string().min(1),
17715
17777
  ...promoteFeedbackInputSchema.shape
17716
17778
  }
17717
17779
  }, ({ issue: issue2, ...fields }) => run(() => client2.promoteFeedback(issue2, fields)));
17718
- server2.registerTool("open_review", {
17719
- description: "Open the COMPLETION REVIEW \u2014 the reading of an epic\u2019s WHOLE accumulated change as one body of work, by a third fresh session that neither built it nor evaluated it. It runs at one moment: once the epic\u2019s contract is green and nothing is left to check, and before anyone sets it `done`. It creates one ORDINARY `task` at `ready` carrying the review checklist as its body \u2014 no new record type. The epic form (`epic`) titles it `Completion review \u2014 <KEY-n> <epic title>`, files it as a CHILD of the epic and inherits the epic\u2019s milestone; the milestone form (`project` + `milestone`) titles it `Completion review \u2014 milestone <token>`, carries that token and has no parent \u2014 nothing here models a milestone as complete. Pass EXACTLY ONE of `epic` or `project` + `milestone`: both, or neither, is refused. One review at a time per scope \u2014 a second is refused (`review_already_open`) NAMING the standing one, so read, finish or kill that task instead \u2014 and a review opens only on an epic (`review_target_not_epic`). NO NEW GATE was added to `done`, and the whole mechanism depends on that: file each finding as ordinary work \u2014 a CHILD OF THE EPIC, a sibling of this review \u2014 and the children clause already on `done` holds the epic open until every finding is done or killed. A finding filed UNDER the review gates nothing, because that gate reads one level deep. Killing the review, or any finding, is the recorded waiver: deliberately ungated, and written to the status history with who did it and through which token. An epic that never opened a review still closes exactly as it did before. Writing guidance for the checklist and for each finding lives in the product doctrine, which `get_work_context` returns.",
17780
+ registerStrict("open_review", {
17781
+ description: "Open the COMPLETION REVIEW \u2014 the reading of an epic\u2019s WHOLE accumulated change as one body of work, by a third fresh session that neither built it nor evaluated it. It runs at one moment: once the epic\u2019s contract is green and before anyone sets it `done`. It creates one ORDINARY `task` at `ready` carrying the review checklist as its body. The epic form (`epic`) titles it `Completion review \u2014 <KEY-n> <epic title>`, files it as a CHILD of the epic and inherits the epic\u2019s milestone; the milestone form (`project` + `milestone`) titles it `Completion review \u2014 milestone <token>`, carries that token and has no parent. Pass EXACTLY ONE of `epic` or `project` + `milestone`: both, or neither, is refused. One review at a time per scope \u2014 a second is refused (`review_already_open`) NAMING the standing one, so read, finish or kill that task instead. The scopes overlap ONE way: the epic form\u2019s review also stands for that same project and milestone, so a later milestone-form open is refused; a milestone-form review blocks no epic-form open. A review opens only on a non-terminal epic \u2014 `review_target_not_epic` otherwise, `review_target_terminal` on a `done`/`killed` epic (a re-opened epic reviews again). NO NEW GATE was added to `done`: file each finding as ordinary work \u2014 a CHILD OF THE EPIC, a sibling of this review \u2014 and the children clause already on `done` holds the epic open until every finding is done or killed. A finding filed UNDER the review gates nothing, because that gate reads one level deep. Killing the review, or any finding, is the recorded waiver: deliberately ungated, and written to the status history with who did it and through which token. Writing guidance for the checklist and for each finding lives in the product doctrine, which `get_work_context` returns.",
17720
17782
  // The WHOLE domain schema rather than its `.shape`, for `get_work_context`'s
17721
17783
  // reason: the exactly-one-of rule is a refinement on the OBJECT, and a raw
17722
17784
  // shape drops it — the refusal naming the two forms would never reach the
17723
17785
  // caller, and both arguments (or neither) would be accepted here.
17724
17786
  inputSchema: openReviewInputSchema
17725
17787
  }, (args) => run(() => client2.openReview(args)));
17726
- server2.registerTool("update_issue", {
17788
+ registerStrict("update_issue", {
17727
17789
  description: "Update any subset of an issue\u2019s title, body, type, priority, milestone, environment, parent (self/cycle rejected), requirement_links (add/remove; adding a link to an unapproved requirement is rejected), deliverable_links (add/remove content-deliverable links, add pins at the current revision), or decision_links (add/remove decision records this work sits under \u2014 `KEY-D{n}`, from `list_decisions`). Changing type to `feedback` is rejected while the issue has criteria (\xA77), since feedback carries none; epic, task and bug interchange freely and the criteria ride along, keeping their AC-{n} numbers and their states. Clearing `milestone` (passing null) is rejected while the issue sits past `triage` \u2014 work already scheduled keeps a milestone (PIPE-R10 rule 9); swap it for a different one, or move the issue back to `triage` first. Writing guidance for this tool lives in the product doctrine, which `get_work_context` returns \u2014 see \u201CLink by key; never paste another record\u2019s text\u201D and \u201CA decision you need is filed as a question, never written into a body\u201D.",
17728
17790
  inputSchema: updateIssueInputSchema.shape
17729
17791
  }, ({ issue: issue2, ...fields }) => run(() => client2.updateIssue(issue2, fields)));
17730
- server2.registerTool("set_issue_header", {
17792
+ registerStrict("set_issue_header", {
17731
17793
  description: "Write an issue\u2019s HEADER \u2014 the three lines an operator reads before deciding anything: `summary` (the change, in one sentence), `why` (the user, business, cost or operational reason rather than the technical one; when nothing is user-visible, the cost of leaving it alone) and `affectedSurfaces` (which parts of the product this touches, and who feels it). All three are REQUIRED and the write is whole-value: the header is replaced entire and never merged, so a partial write cannot leave the stored provenance vouching for lines it did not touch. Returns the full issue detail. WRITE ALL THREE IN PLAIN WORDS: no identifiers, no file paths, no type names, no jargon. Their reader is deciding, not building, and the depth belongs in the body underneath them. A `summary` shaped like code \u2014 a repository path, a line number, a symbol written as a call or in backticks \u2014 is refused (400) naming what was found. `summary` is capped at 350 characters; over it the write is refused naming both the cap and the length received. THE HEADER CAN BE WRITTEN NO OTHER WAY: `summary`, `why` or `affectedSurfaces` passed to `create_issue`, `update_issue` or `promote_feedback` is refused (400) naming the field, rather than silently dropped \u2014 create the issue first, then write its header here. Every read of the issue carries `headerStale`, true when the body has changed since these three lines were derived from it and true when no header was ever written; writing here clears it. Writing guidance for these three lines is stated above; the organization\u2019s own writing rules, which `get_work_context` returns, govern the body beneath them.",
17732
17794
  inputSchema: setIssueHeaderInputSchema.shape
17733
17795
  }, ({ issue: issue2, ...fields }) => run(() => client2.setIssueHeader(issue2, fields)));
17734
- server2.registerTool("set_status", {
17796
+ registerStrict("set_status", {
17735
17797
  description: "Set an issue\u2019s status; the \xA76 guards apply and a guard failure returns the specific reason, naming the rows it refused on (\xA77). The criteria gates read whatever criteria the issue carries, whatever its type: verify needs every auto criterion passed, done needs every criterion passed. ONE guard remains EPIC-ONLY, and the asymmetry is the rule rather than an oversight \u2014 an epic that has stated NO criteria is refused at both gates, because an empty list would clear them having committed to nothing, while a task or a bug carrying none moves through both untouched (most work is stated adequately by its body). `done` additionally requires every child of the issue being closed to be terminal \u2014 an epic, a task and a bug alike \u2014 read from each child\u2019s STATUS and never from its criteria. A BLOCKED issue \u2014 one carrying an explicit mark or waiting on a blocker that is not yet `done`/`killed` \u2014 cannot move to `ready` or `in_progress`, and the refusal names the reason and every unresolved blocker, so clear the mark (`clear_blocked`) or finish, kill or `unlink_blocker` each blocker first; every other target still moves while blocked. An issue with NO milestone cannot move to any status but `triage` and `killed` (PIPE-R10 rule 9) \u2014 set one with `update_issue` first; `killed` is exempt, so work nobody scheduled can still be abandoned.",
17736
17798
  inputSchema: setStatusInputSchema.shape
17737
17799
  }, (args) => run(() => client2.setStatus(args.issue, args.status)));
17738
- server2.registerTool("claim_issue", {
17800
+ registerStrict("claim_issue", {
17739
17801
  description: "Atomically claim an issue (\xA76 rule 1): succeeds only if unclaimed; under a race exactly one wins and the loser\u2019s error names the current claimer. Claim is orthogonal to status (\xA77).",
17740
17802
  inputSchema: { issue: issueRefSchema }
17741
17803
  }, (args) => run(() => client2.claimIssue(args.issue)));
17742
- server2.registerTool("release_issue", {
17804
+ registerStrict("release_issue", {
17743
17805
  description: "Release an issue\u2019s claim, clearing claimed_by (\xA77).",
17744
17806
  inputSchema: { issue: issueRefSchema }
17745
17807
  }, (args) => run(() => client2.releaseIssue(args.issue)));
17746
- server2.registerTool("add_comment", {
17808
+ registerStrict("add_comment", {
17747
17809
  description: "Add a markdown comment to an issue, attributed to the operator (and token label over MCP). A comment EXPLAINS a blockage \u2014 the full account of what you hit and why you stopped \u2014 but never records one: `set_blocked` (with a reason) and `link_blocker` (when you are waiting on another issue) are what put an issue in the blocked state (\xA77/WORKFLOW). Writing guidance for a comment lives in the product doctrine, which `get_work_context` returns \u2014 see \u201CEvery loose end has one destination, and none of them is prose\u201D.",
17748
17810
  inputSchema: commentInputSchema.shape
17749
17811
  }, (args) => run(() => client2.addComment(args.issue, args.body)));
17750
- server2.registerTool("set_blocked", {
17812
+ registerStrict("set_blocked", {
17751
17813
  description: "Record an issue as BLOCKED with a required human-readable `reason` \u2014 state on the issue, not a comment: it shows on the issue and counts on the project\u2019s Work page, and only clearing it (`clear_blocked`) ends it. Setting it again on an already-blocked issue replaces the standing reason and keeps the old one as history, attributed to whoever set it. The reason is one or two lines saying what the work is waiting on; the full account of what you tried still belongs in a comment. Returns the updated issue detail, whose `blocked` carries the reason and who set it. Writing guidance for the reason lives in the product doctrine, which `get_work_context` returns.",
17752
17814
  inputSchema: setBlockedInputSchema.shape
17753
17815
  }, (args) => run(() => client2.setBlocked(args.issue, args.reason)));
17754
- server2.registerTool("clear_blocked", {
17816
+ registerStrict("clear_blocked", {
17755
17817
  description: "Clear an issue\u2019s blocked mark \u2014 the blockage is over. Idempotent: clearing an issue that is not blocked is a no-op, not an error. The cleared mark is kept as history with who ended it, so a past blockage stays readable. Returns the updated issue detail (`blocked` is then null).",
17756
17818
  inputSchema: { issue: issueRefSchema }
17757
17819
  }, (args) => run(() => client2.clearBlocked(args.issue)));
17758
- server2.registerTool("link_blocker", {
17820
+ registerStrict("link_blocker", {
17759
17821
  description: "Record that `issue` is WAITING ON `blocker` \u2014 the dependency between two issues, written from the waiting issue\u2019s side. One call records both directions: `get_issue` on the waiting issue lists it under `blockedBy` (with the blocker\u2019s display id, title, and whether it is already `done`/`killed`), and `get_issue` on the blocker lists it under `blocks`, so finishing work shows what it frees. Both issues must be in the SAME project. An issue cannot block itself, and an edge that would close a cycle (directly or through other issues) is refused with nothing recorded. Adding the same blocker twice records no second edge. Returns the waiting issue\u2019s updated detail.",
17760
17822
  inputSchema: linkBlockerInputSchema.shape
17761
17823
  }, (args) => run(() => client2.addBlocker(args.issue, args.blocker)));
17762
- server2.registerTool("unlink_blocker", {
17824
+ registerStrict("unlink_blocker", {
17763
17825
  description: "Remove the blocked-by edge between `issue` and `blocker` \u2014 the dependency is gone, and one call clears it from BOTH issues\u2019 views. Idempotent: unlinking a pair that is not linked is a no-op, not an error. Returns the waiting issue\u2019s updated detail.",
17764
17826
  inputSchema: linkBlockerInputSchema.shape
17765
17827
  }, (args) => run(() => client2.removeBlocker(args.issue, args.blocker)));
17766
- server2.registerTool("set_criteria", {
17828
+ registerStrict("set_criteria", {
17767
17829
  description: "Define or replace the ordered acceptance criteria list ({text, verification, howTo}) on a piece of work. Matched rows keep their stable AC-{n} number; new items get fresh never-reused numbers; removed items are soft-deleted. A matched row keeps its state only when BOTH its text and verification mode are unchanged \u2014 a mode flip resets it to pending (\xA77). A `manual` criterion must carry `howTo` \u2014 one or two sentences saying where to look, what to do, and what counts as passing; writing or changing one without it is refused (`how_to_required`), and so is supplying it on an `auto` criterion, which a machine checks. Work of any kind carries criteria \u2014 an epic, a task, a bug alike \u2014 with feedback the one exception, refused with `criteria_not_supported` because it is a report rather than agreed work; `promote_feedback` retypes it into work before anything is built. Raising a criterion from `manual` to `auto` both resets its state and clears the stored `howTo`, so a criterion moved to `auto` has lost its checking instructions \u2014 moving it back to `manual` does not restore them, and is refused (`how_to_required`) until you write new ones. Writing guidance for a criterion, and for choosing its mode, lives in the product doctrine, which `get_work_context` returns \u2014 see \u201CVerification modes follow what can be driven\u201D.",
17768
17830
  inputSchema: setCriteriaInputSchema.shape
17769
17831
  }, (args) => run(() => client2.setCriteria(args.issue, args.criteria)));
17770
- server2.registerTool("set_criterion_state", {
17832
+ registerStrict("set_criterion_state", {
17771
17833
  description: "Set a criterion\u2019s state (pending/passed/failed) on whatever work carries it \u2014 an epic, a task and a bug alike \u2014 addressed by criterionId ALONE, or by issue + criterionNumber (the stable AC-{n}); exactly one form. Feedback never carries criteria, so neither address resolves to one. A token-authenticated call is REJECTED on a manual criterion (manual state is web-session-only, \xA74); evidence is required when a token marks passed/failed (\xA77). Writing guidance for that evidence lives in the product doctrine, which `get_work_context` returns \u2014 see \u201CVerification modes follow what can be driven\u201D.",
17772
17834
  // `issue` is OPTIONAL: the criterionId-only form needs no issue ref. The
17773
17835
  // exactly-one-of rule can't be expressed in a raw shape, so the handler
@@ -17798,27 +17860,27 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
17798
17860
  evidence: input.evidence
17799
17861
  });
17800
17862
  }));
17801
- server2.registerTool("ask_questions", {
17863
+ registerStrict("ask_questions", {
17802
17864
  description: "Ask the operator a BATCH of questions on an issue \u2014 `questions` takes 1 or more {text, options?}, and a session with one open decision usually has two or three, so ask them in ONE call rather than one at a time (a half-asked set costs the operator a second pass). `options` are the choices you propose, in the order you offer them; omit it to ask in open words. Each question gets a stable per-issue `Q-{n}` number that is NEVER reused. Returns the updated issue detail, and every issue read from then on carries `questions` with their answers \u2014 so read the answer in the `get_issue` you were going to make anyway, and READ the existing questions and answers BEFORE asking again rather than re-asking a decision already taken. Answering is reserved for a person in the web UI: there is deliberately no tool for it on this surface (PIPE-R22 rule 4). A question is withdrawn with `withdraw_question`. Writing guidance for a question lives in the product doctrine, which `get_work_context` returns \u2014 see \u201CA decision you need is filed as a question, never written into a body\u201D.",
17803
17865
  inputSchema: askQuestionsInputSchema.shape
17804
17866
  }, (args) => run(() => client2.askQuestions(args.issue, args.questions)));
17805
- server2.registerTool("withdraw_question", {
17867
+ registerStrict("withdraw_question", {
17806
17868
  description: "Withdraw a question by its stable `Q-{n}` number \u2014 asker only: the asker is the token\u2019s OWNING user, so another agent\u2019s question is not yours to clear. Deliberately NOT human-gated, because the point is to clear a question you no longer need \u2014 the work answered it, or it was wrong to ask \u2014 instead of leaving the operator to answer something nobody is waiting on. It is recorded, never an answer: the question is soft-deleted (kept for the audit trail), its number retires and is not reissued, and the next issue read no longer carries it in `questions`. Returns the updated issue detail.",
17807
17869
  inputSchema: withdrawQuestionInputSchema.shape
17808
17870
  }, (args) => run(() => client2.withdrawQuestion(args.issue, args.number)));
17809
- server2.registerTool("list_requirements", {
17871
+ registerStrict("list_requirements", {
17810
17872
  description: "List requirements filtered by project and text: display id, title, summary (the latest revision\u2019s one/two-line outline description), area (product-area label), cross_cutting flag, lifecycle (draft/approved/amended), latest + approved revision numbers, and linked-epic count (\xA77).",
17811
17873
  inputSchema: listRequirementsFilterSchema.shape
17812
17874
  }, (args) => run(() => client2.listRequirements(args), GUIDANCE_POINTER));
17813
- server2.registerTool("get_requirement", {
17875
+ registerStrict("get_requirement", {
17814
17876
  description: "Get a requirement by display id or uuid. Defaults to the approved revision (latest if never approved) unless `revision` is given; includes lifecycle, revision list, approvals, and linked issues (\xA77).",
17815
17877
  inputSchema: { requirement: requirementRefSchema, revision: positiveIntSchema.optional() }
17816
17878
  }, (args) => run(() => client2.getRequirement(args.requirement, args.revision), GUIDANCE_POINTER));
17817
- server2.registerTool("create_requirement", {
17879
+ registerStrict("create_requirement", {
17818
17880
  description: 'Create a project-scoped requirement document with revision 1 (title, body). Optional summary is a short one/two-line outline description (versioned on the revision, like title/body); optional area is a free-text product-area label ("Backup", "Billing") used to group/filter the outline (metadata, like cross_cutting). Optional cross_cutting flags it as binding every epic in the project automatically \u2014 never link cross-cutting documents (\xA75 rule 6, \xA77). Writing guidance for this tool lives in the product doctrine, which `get_work_context` returns \u2014 see \u201CSearch before you create\u201D and \u201CLink by key; never paste another record\u2019s text\u201D.',
17819
17881
  inputSchema: createRequirementInputSchema.shape
17820
17882
  }, (args) => run(() => client2.createRequirement(args)));
17821
- server2.registerTool("update_requirement", {
17883
+ registerStrict("update_requirement", {
17822
17884
  description: "Update a requirement: title, body, and/or summary APPENDS a new revision (nothing is edited in place; summary versions like title/body); optional note records why. Optional area (product-area label), cross_cutting, and decision_links (add/remove decision records this rule sits under \u2014 `KEY-D{n}`, from `list_decisions`) are metadata changes (no revision) \u2014 toggling cross_cutting ON is rejected while explicit links exist; unlink first. Supplying none of title/body/summary/area/cross_cutting/decision_links is an empty edit (400) (\xA77). Writing guidance for this tool lives in the product doctrine, which `get_work_context` returns \u2014 see \u201CLink by key; never paste another record\u2019s text\u201D.",
17823
17885
  // Inline shape (updateRequirementInputSchema is a refine, no .shape) — add
17824
17886
  // summary/area here explicitly (§210) and decisionLinks (PIPE-84 §6); the
@@ -17834,24 +17896,24 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
17834
17896
  decisionLinks: decisionLinkChangesSchema.optional()
17835
17897
  }
17836
17898
  }, ({ requirement, ...fields }) => run(() => client2.updateRequirement(requirement, fields)));
17837
- server2.registerTool("approve_requirement", {
17899
+ registerStrict("approve_requirement", {
17838
17900
  description: "Approve (pin) a requirement\u2019s latest revision, or a specified `revision`, recording it in approvals history and advancing the approved revision that downstream links and reads build against (\xA75, \xA77).",
17839
17901
  inputSchema: approveRequirementInputSchema.shape
17840
17902
  }, (args) => run(() => client2.approveRequirement(args.requirement, args.revision)));
17841
- server2.registerTool("list_conventions", {
17903
+ registerStrict("list_conventions", {
17842
17904
  description: "List this organization\u2019s conventions \u2014 standing guidance that binds work in EVERY project here, unlike a requirement, which binds one project. Returns display id (`CONV-{n}`), title, the standing text, lifecycle (draft/approved/amended), latest + approved revision numbers, and whether it has been superseded and by which convention. Optional `text` matches over title and body. Retired conventions are INCLUDED and marked, so you can see what the organization used to require rather than proposing it again. Only APPROVED, unretired conventions are actually delivered to agents \u2014 every `get_issue` and `get_work_context` read carries them.",
17843
17905
  inputSchema: listConventionsFilterSchema.shape
17844
17906
  }, (args) => run(() => client2.listConventions(args), GUIDANCE_POINTER));
17845
- server2.registerTool("get_convention", {
17907
+ registerStrict("get_convention", {
17846
17908
  description: "Get one convention by display id (`CONV-{n}`) or uuid: the standing text at the approved revision, its full revision history with who wrote each and when, its approval history, and \u2014 when it has been retired \u2014 which convention replaced it. A convention that was never approved is readable here and is delivered to nobody.",
17847
17909
  inputSchema: { convention: conventionRefSchema }
17848
17910
  }, (args) => run(() => client2.getConvention(args.convention), GUIDANCE_POINTER));
17849
- server2.registerTool("create_convention", {
17911
+ registerStrict("create_convention", {
17850
17912
  description: "Write a convention: organization-wide standing guidance, with no project attached. THE GOVERNING TEST \u2014 if it stops being true when this project ends it is a cross-cutting requirement; if it would still be true on the next project it is a convention. You must assert that test by passing `outlivesAnyProject: true`; the write is refused without it. A statement naming one specific project \u2014 its key, a display id built on it, a repository path, or a database object \u2014 is also refused (422 `convention_names_project_scope`), and the message says to file it as a cross-cutting requirement on that project instead. IT BINDS NOBODY UNTIL IT IS APPROVED, and approval is NOT available on this surface: it requires a signed-in person in the browser, so there is no tool for it here and asking for one is asking for a tool that deliberately does not exist. A new convention starts as a draft; write it, then ask a person to review and approve it. Writing guidance for this tool lives in the product doctrine, which `get_work_context` returns \u2014 see \u201CSearch before you create\u201D and \u201CLink by key; never paste another record\u2019s text\u201D.",
17851
17913
  inputSchema: createConventionInputSchema.shape
17852
17914
  }, (args) => run(() => client2.createConvention(args)));
17853
- server2.registerTool("update_convention", {
17854
- description: "Amend a convention: `title` and/or `body` APPEND a new revision \u2014 nothing is ever edited in place \u2014 and optional `note` records why. Supplying neither is an empty edit (400); a note alone amends nothing. The same project-scope refusal applies as on create, over the amended text. THE AMENDMENT BINDS NOBODY UNTIL IT IS ITSELF APPROVED: until then agents keep receiving the approved revision, so amending is not a way to change what is being delivered \u2014 a signed-in person approving the new revision is.",
17915
+ registerStrict("update_convention", {
17916
+ description: "Amend a convention: `title` and/or `body` APPEND a new revision \u2014 nothing is ever edited in place \u2014 and optional `note` records why. Supplying neither is an empty edit (400); a note alone amends nothing. The same project-scope refusal applies as on create, over the amended text. THE AMENDMENT BINDS NOBODY UNTIL IT IS ITSELF APPROVED: until then agents keep receiving the approved revision, so amending is not a way to change what is being delivered \u2014 a signed-in person approving the new revision is. Writing guidance for this tool lives in the product doctrine, which `get_work_context` returns \u2014 see \u201CConventions arrive in force; yours is a proposal\u201D.",
17855
17917
  // Inline shape (updateConventionInputSchema is a refine, so it has no `.shape`);
17856
17918
  // the empty-edit rule is enforced by the API, not by this shape.
17857
17919
  inputSchema: {
@@ -17861,20 +17923,20 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
17861
17923
  note: external_exports.string().min(1).optional()
17862
17924
  }
17863
17925
  }, ({ convention, ...fields }) => run(() => client2.updateConvention(convention, fields)));
17864
- server2.registerTool("list_prompts", {
17926
+ registerStrict("list_prompts", {
17865
17927
  description: "List this organization\u2019s prompt library \u2014 reusable prompt text, addressed `PROMPT-{n}`. A prompt belongs to the ORGANIZATION rather than to a project, unlike a requirement or a content draft, so with no filter this returns the whole library. Returns display id, title, the current text, the latest revision number, and the attached project when it has one. `project` narrows to the prompts attached to that project \u2014 that attachment is metadata saying what a prompt is ABOUT, never its scope, so a prompt attached elsewhere is still one you may read and run. `text` matches over TITLE AND BODY, because someone looking for a prompt remembers what it says rather than what somebody called it.",
17866
17928
  inputSchema: listPromptsFilterSchema.shape
17867
17929
  }, (args) => run(() => client2.listPrompts(args), GUIDANCE_POINTER));
17868
- server2.registerTool("get_prompt", {
17930
+ registerStrict("get_prompt", {
17869
17931
  description: "Get one prompt by display id (`PROMPT-{n}`) or uuid: its current text \u2014 the LATEST revision, which is simply the prompt, since nothing approves a prompt and nothing pins to an older wording \u2014 plus the full revision history with who wrote each save, through which token, when, and the note saying why. Nothing here was edited in place, so a wording that has been replaced is still readable rather than lost; read the history before rewriting a prompt that already works.",
17870
17932
  inputSchema: { prompt: promptRefSchema }
17871
17933
  }, (args) => run(() => client2.getPrompt(args.prompt), GUIDANCE_POINTER));
17872
- server2.registerTool("create_prompt", {
17934
+ registerStrict("create_prompt", {
17873
17935
  description: "Write a prompt: reusable prompt text kept ORGANIZATION-WIDE (`PROMPT-{n}`) and available in every project here \u2014 unlike a requirement or a content draft, which belong to the one project they are filed under. `body` is the prompt itself and must not be empty; `title` names it; optional `note` records why it was written. Optional `project` ATTACHES the prompt to a project, which says what it is about and nothing more \u2014 the prompt is still the organization\u2019s, and a prompt about no project in particular simply omits it. There is no approval step: it is usable the moment it is written. Nothing deletes a prompt either \u2014 later wording is added as a revision, so what is here now stays readable. Writing guidance for this tool lives in the product doctrine, which `get_work_context` returns \u2014 see \u201CSearch before you create\u201D.",
17874
17936
  inputSchema: createPromptInputSchema.shape
17875
17937
  }, (args) => run(() => client2.createPrompt(args)));
17876
- server2.registerTool("update_prompt", {
17877
- description: "Amend a prompt: `title` and/or `body` APPEND a new revision \u2014 nothing is ever edited in place \u2014 and optional `note` records why. Amending is therefore safe rather than destructive: the earlier text stays readable, the revision chain is the record of how this prompt got to where it is, and the new wording answers immediately because there is nothing to approve. `project` moves the attachment and `project: null` DETACHES it, leaving a prompt the whole organization still has; either way NO revision is written, because the revision chain is the history of the TEXT and moving the attachment changes no text. Supplying none of `title`, `body` or `project` is an empty edit (400) \u2014 a note alone amends nothing.",
17938
+ registerStrict("update_prompt", {
17939
+ description: "Amend a prompt: `title` and/or `body` APPEND a new revision \u2014 nothing is ever edited in place \u2014 and optional `note` records why. Amending is therefore safe rather than destructive: the earlier text stays readable, the revision chain is the record of how this prompt got to where it is, and the new wording answers immediately because there is nothing to approve. `project` moves the attachment and `project: null` DETACHES it, leaving a prompt the whole organization still has; either way NO revision is written, because the revision chain is the history of the TEXT and moving the attachment changes no text. Supplying none of `title`, `body` or `project` is an empty edit (400) \u2014 a note alone amends nothing. Writing guidance for this tool lives in the product doctrine, which `get_work_context` returns.",
17878
17940
  // Inline shape (updatePromptInputSchema is a refine, so it has no `.shape`),
17879
17941
  // mirroring that schema's fields — it is the wording this copy follows, `.min(1)`
17880
17942
  // bounds included, so a caller learns a blank body is refused from the advertised
@@ -17888,94 +17950,102 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
17888
17950
  project: projectRefSchema.nullable().optional()
17889
17951
  }
17890
17952
  }, ({ prompt, ...fields }) => run(() => client2.updatePrompt(prompt, fields)));
17891
- server2.registerTool("create_decision", {
17953
+ registerStrict("create_decision", {
17892
17954
  description: "Record ONE decision as a project-scoped decision record (`KEY-D{n}`): `title` names the decision and `body` says what was decided and why. A new record starts `proposed`; moving it to `accepted` IS the acceptance \u2014 there is no separate approval step. Returns the created record. Title and body can be corrected only while the record is `proposed`; from `accepted` onward it is frozen, and the way to change a decision is to write a NEW record and `supersede_decision` the old one \u2014 which leaves the original readable and says where the current decision went. Nothing on any surface deletes one. Writing guidance for this tool lives in the product doctrine, which `get_work_context` returns \u2014 see \u201CA decision record is written once\u201D and \u201CSearch before you create\u201D.",
17893
17955
  inputSchema: createDecisionInputSchema.shape
17894
17956
  }, (args) => run(() => client2.createDecision(args)));
17895
- server2.registerTool("get_decision", {
17957
+ registerStrict("get_decision", {
17896
17958
  description: "Get a decision record by display id (`KEY-D{n}`) or uuid: title, body, lifecycle state (`proposed`/`accepted`/`superseded`), who accepted it and when, the record that replaced it, where the CURRENT decision lives when this one has been superseded, and everything that references it \u2014 every requirement and every work item, gathered from both directions.",
17897
17959
  inputSchema: getDecisionInputSchema.shape
17898
17960
  }, (args) => run(() => client2.getDecision(args.decision), GUIDANCE_POINTER));
17899
- server2.registerTool("list_decisions", {
17961
+ registerStrict("list_decisions", {
17900
17962
  description: "List decision records filtered by `project` and `text`: display id, title, lifecycle state, the replacement when superseded, and how many requirements and work items cite each one. `text` matches over TITLE AND BODY, so search on what a decision was about rather than on what someone chose to call it.",
17901
17963
  inputSchema: listDecisionsFilterSchema.shape
17902
17964
  }, (args) => run(() => client2.listDecisions(args), GUIDANCE_POINTER));
17903
- server2.registerTool("supersede_decision", {
17965
+ registerStrict("supersede_decision", {
17904
17966
  description: "Replace an ACCEPTED decision record with another one: `decision` moves to `superseded` and names `replacedBy` as where the current decision now lives, so whoever opens the old record is sent to the standing one. This is the only way a decision changes \u2014 the original is never edited and never removed, and the whole chain stays readable. Refused when the record is still `proposed` (nothing has been decided yet, so correct it instead), when a record is named as its own replacement, when the replacement belongs to another project, or when the replacement\u2019s own chain already leads back to this record. Returns the superseded record.",
17905
17967
  inputSchema: supersedeDecisionInputSchema.shape
17906
17968
  }, (args) => run(() => client2.supersedeDecision(args.decision, args.replacedBy)));
17907
- server2.registerTool("list_content", {
17969
+ registerStrict("list_content", {
17908
17970
  description: "List content drafts filtered by project, text, area, and author: display id (KEY-C{n}), title, summary (latest revision), area, author, latest revision number, deliverable count, and linked-issue count. Content is a project-scoped sibling of requirements \u2014 raw material that gets promoted into work, not an issue type.",
17909
17971
  inputSchema: listContentFilterSchema.shape
17910
17972
  }, (args) => run(() => client2.listContent(args), GUIDANCE_POINTER));
17911
- server2.registerTool("get_content", {
17973
+ registerStrict("get_content", {
17912
17974
  description: "Get a content draft by display id (KEY-C{n}) or uuid: its revision history (default latest, or `revision` to pin) plus every per-platform deliverable with its own revisions, posted metadata (url/postedAt), linked issues (with staleness), and linked assets.",
17913
17975
  inputSchema: { content: contentRefSchema, revision: positiveIntSchema.optional() }
17914
17976
  }, (args) => run(() => client2.getContent(args.content, args.revision), GUIDANCE_POINTER));
17915
- server2.registerTool("create_content", {
17977
+ registerStrict("create_content", {
17916
17978
  description: 'Create a project-scoped content draft with revision 1 (title, body). Optional summary is a short outline description (versioned on the revision, like title/body); optional area is a free-text product-area label (metadata). Optional author is the editorial byline the draft is credited to (free text, e.g. "jason") \u2014 distinct from the acting operator, and the field the list filters by. Optional metadata is an open bag for capture provenance (tags, score, sensitivity, source refs) \u2014 stored passively, never acted on. This is the capture-sink target: a captured topic becomes a draft here. Writing guidance for this tool lives in the product doctrine, which `get_work_context` returns \u2014 see \u201CSearch before you create\u201D.',
17917
17979
  inputSchema: createContentInputSchema.shape
17918
17980
  }, (args) => run(() => client2.createContent(args)));
17919
- server2.registerTool("update_content", {
17981
+ registerStrict("update_content", {
17920
17982
  description: "Update a content draft: title, body, and/or summary APPENDS a new revision (nothing is edited in place); optional note records why. Optional area, author, and metadata are metadata changes (no revision). Supplying none of title/body/summary/area/author/metadata is an empty edit (400). Writing guidance for this tool lives in the product doctrine, which `get_work_context` returns.",
17921
17983
  inputSchema: updateContentInputSchema.shape
17922
17984
  }, ({ content, ...fields }) => run(() => client2.updateContent(content, fields)));
17923
- server2.registerTool("create_deliverable", {
17985
+ registerStrict("create_deliverable", {
17924
17986
  description: "Add a per-platform deliverable to a draft with revision 1 (the post text). `platform` is a slug configured for the project \u2014 one of its ACTIVE deliverable targets (see list_projects \u2192 deliverableTargets; defaults are bsky/x/reddit/hn/blog, but projects can add/remove them); an unconfigured slug is rejected. Optional body seeds revision 1. There is one deliverable per platform per draft \u2014 a variant is a revision (update_deliverable), not a second deliverable. Rendered id is DRAFT-KEY/platform (e.g. DWB-C3/bsky). Writing guidance for the post text lives in the product doctrine, which `get_work_context` returns.",
17925
17987
  inputSchema: createDeliverableInputSchema.shape
17926
17988
  }, ({ content, ...fields }) => run(() => client2.createDeliverable(content, fields)));
17927
- server2.registerTool("update_deliverable", {
17989
+ registerStrict("update_deliverable", {
17928
17990
  description: "Append a new revision (post text) to a deliverable, addressed as DRAFT-KEY/platform (e.g. DWB-C3/bsky); optional note records why. Platform identity is immutable \u2014 this only versions the post text. Writing guidance for the post text lives in the product doctrine, which `get_work_context` returns.",
17929
17991
  inputSchema: updateDeliverableInputSchema.shape
17930
17992
  }, ({ deliverable, ...fields }) => run(() => client2.updateDeliverable(deliverable, fields)));
17931
- server2.registerTool("record_post", {
17993
+ registerStrict("record_post", {
17932
17994
  description: "Record that a deliverable (DRAFT-KEY/platform) was posted: its public url and when (postedAt defaults to now), pinning the deliverable\u2019s current revision as the posted one. Pipebrain never posts \u2014 a human posts, then records the url here. Re-calling overwrites (a correction).",
17933
17995
  inputSchema: recordPostInputSchema.shape
17934
17996
  }, ({ deliverable, ...fields }) => run(() => client2.recordPost(deliverable, fields)));
17935
- server2.registerTool("promote_deliverable", {
17936
- description: "Promote a deliverable (DRAFT-KEY/platform, e.g. DWB-C3/bsky) to a NEW issue: creates the issue (`title` + `type` required; optional `status` defaults to triage; optional `body` seeds it \u2014 typically the current post text) AND a PROTECTED link pinning the deliverable\u2019s current revision. That link CANNOT be unlinked \u2014 the issue exists to ship this deliverable. (To link an EXISTING issue instead, use update_issue.deliverable_links, which stays removable.) Returns the created issue. Seeding the body with the deliverable\u2019s own text is the exception to linking rather than pasting \u2014 it is the point of this tool, and the protected link pins the revision that text came from. Writing guidance for this tool lives in the product doctrine, which `get_work_context` returns \u2014 see \u201CSearch before you create\u201D and \u201CLink by key; never paste another record\u2019s text\u201D.",
17997
+ registerStrict("promote_deliverable", {
17998
+ description: "Promote a deliverable (DRAFT-KEY/platform, e.g. DWB-C3/bsky) to a NEW issue: creates the issue (`title` + `type` required; optional `status` defaults to triage, optional `milestone` schedules it, optional `body` seeds it \u2014 typically the current post text) AND a PROTECTED link pinning the deliverable\u2019s current revision. That link CANNOT be unlinked \u2014 the issue exists to ship this deliverable. (To link an EXISTING issue instead, use update_issue.deliverable_links, which stays removable.) Returns the created issue. A `status` other than `triage` or `killed` REQUIRES `milestone` (PIPE-R10 rule 9), exactly as in create_issue: work past triage belongs to a release, and the promotion is refused (`milestone_required`) without one. Seeding the body with the deliverable\u2019s own text is the exception to linking rather than pasting \u2014 it is the point of this tool, and the protected link pins the revision that text came from. Writing guidance for this tool lives in the product doctrine, which `get_work_context` returns \u2014 see \u201CSearch before you create\u201D and \u201CLink by key; never paste another record\u2019s text\u201D.",
17937
17999
  inputSchema: {
17938
18000
  deliverable: external_exports.string().min(1),
17939
18001
  title: external_exports.string().min(1),
17940
18002
  type: issueTypeSchema,
17941
18003
  status: issueStatusSchema.optional(),
18004
+ // Rule 9 (PIPE-227) reaches this door too, so the argument that satisfies it has
18005
+ // to be declared here. The API route, `promoteDeliverable` and the api-client
18006
+ // have all carried `milestone` since that item; only this shape omitted it, which
18007
+ // left the `status` beside it a trap — a caller naming any status past triage
18008
+ // could only be refused (`milestone_required`) with no argument to fix it.
18009
+ // Mirrors `promoteFeedbackInputSchema`'s field, message included.
18010
+ milestone: external_exports.string().min(1, { message: "milestone must not be empty \u2014 omit it to leave the issue unassigned" }).optional(),
17942
18011
  body: markdownSchema.optional(),
17943
18012
  // This tool CREATES an issue, so it is a door the header refusal has to cover
17944
- // (PIPE-373 AC-1). The fields must be DECLARED here rather than left off: the
17945
- // SDK rebuilds this raw shape as a non-strict object, so an undeclared key is
17946
- // STRIPPED rather than refused — the call would succeed, the issue would be
17947
- // created, and the author's three lines would be dropped with nothing said.
18013
+ // (PIPE-373 AC-1). Left undeclared, the strict wrap (PIPE-384) would refuse
18014
+ // the three names anyway — but with the generic unrecognised-argument
18015
+ // sentence. Declared, each refusal carries its tailored redirect saying where
18016
+ // the header IS written, and the published JSON Schema shows the field with
18017
+ // that sentence before anything is called.
17948
18018
  ...refusedIssueHeaderFields
17949
18019
  }
17950
18020
  }, ({ deliverable, ...fields }) => run(() => client2.promoteDeliverable(deliverable, fields)));
17951
- server2.registerTool("list_assets", {
18021
+ registerStrict("list_assets", {
17952
18022
  description: "List project assets (S3-backed files of any type) filtered by project, status (pending/active/rejected), category (image/video/document), and filename text, sorted by date or size (asc/desc): id, filename, contentType, size, sha256, status, hasThumbnail, and linkCount (live references). Metadata only \u2014 the bytes move by path, never through this result.",
17953
18023
  inputSchema: listAssetsFilterSchema.shape
17954
18024
  }, (args) => run(() => client2.listAssets(args), GUIDANCE_POINTER));
17955
- server2.registerTool("get_asset", {
18025
+ registerStrict("get_asset", {
17956
18026
  description: "Get an asset\u2019s metadata by uuid: filename, contentType, size, sha256 (client-computed, server-verified by read-back), status, hasThumbnail, linkCount (live references), and timestamps. Metadata only \u2014 no value/URL is returned over MCP. To get the bytes, use download_asset (a LOCAL server only \u2014 it writes to a path on the machine running the server) or `ppb assets download`.",
17957
18027
  inputSchema: { asset: uuidSchema }
17958
18028
  }, (args) => run(() => client2.getAsset(args.asset), GUIDANCE_POINTER));
17959
18029
  if (surface === "local") {
17960
- server2.registerTool("upload_asset", {
18030
+ registerStrict("upload_asset", {
17961
18031
  description: "Upload a LOCAL FILE (by path) as a project asset and activate it: reads and SHA-256-hashes the file locally, registers the asset, PUTs the bytes directly to storage, and completes (server verifies the digest by read-back). Returns the asset metadata (status active on success). Only the file PATH crosses the wire \u2014 the bytes never do; do NOT pass base64 or file contents. contentType is guessed from the extension when omitted. Then use link_asset to attach the returned asset to a deliverable revision or a content draft.",
17962
18032
  inputSchema: uploadAssetInputSchema.shape
17963
18033
  }, (args) => run(() => uploadAssetFromPath(client2, apiBaseUrl, args)));
17964
- server2.registerTool("download_asset", {
18034
+ registerStrict("download_asset", {
17965
18035
  description: "Download an ACTIVE asset\u2019s bytes to a LOCAL FILE at `path` (created/overwritten), so you can read or view an uploaded image, mockup, or document. The bytes are written to disk and re-verified against the recorded sha256; the RESULT is metadata + the written path \u2014 never the bytes or a URL. Addressed by asset uuid (from list_assets, get_asset, or get_content).",
17966
18036
  inputSchema: downloadAssetInputSchema.shape
17967
18037
  }, (args) => run(() => downloadAssetToPath(client2, args)));
17968
18038
  }
17969
- server2.registerTool("link_asset", {
18039
+ registerStrict("link_asset", {
17970
18040
  description: "Reference an active asset FROM a target. Two targets are wired: `deliverable_revision` (targetId is a content_deliverable_revision uuid, e.g. from get_content) and `content_draft` (targetId is a content_draft uuid \u2014 the asset attaches to the topic doc and is snapshot-copied onto deliverables when they are created). Rejects a non-active asset or a cross-project link.",
17971
18041
  inputSchema: linkAssetInputSchema.shape
17972
18042
  }, (args) => run(() => client2.linkAsset(args)));
17973
- server2.registerTool("unlink_asset", {
18043
+ registerStrict("unlink_asset", {
17974
18044
  description: "Remove an asset reference by its link id (soft-delete). The asset itself is untouched \u2014 this only drops the reference from its target. A link whose target is a CHAT MESSAGE is author-only: anyone but the person who wrote that message is refused with `not_message_author` (403), so an attachment you did not post is not yours to detach. Every other target type is removable by any member.",
17975
18045
  inputSchema: unlinkAssetInputSchema.shape
17976
18046
  }, (args) => run(() => client2.unlinkAsset(args.linkId)));
17977
- server2.registerTool("set_secret", {
17978
- description: "Create a secret or add a new version under a project + environment (metadata only \u2014 the response NEVER echoes the value). `name` is UPPER_SNAKE_CASE (e.g. API_KEY); `environment` is the environment slug (dev/prod); optional `description`. Writing guidance for that description lives in the product doctrine, which `get_work_context` returns.",
18047
+ registerStrict("set_secret", {
18048
+ description: "Create a secret or add a new version under a project + environment. THE VALUE CROSSES INBOUND ONLY \u2014 the response NEVER echoes it \u2014 but inbound is a direction, not a safe one: for a sensitive value prefer the CLI (`ppb secrets set` via stdin/`--from-file`), because a value typed here lands in the chat transcript before the server can encrypt it. `name` is UPPER_SNAKE_CASE (e.g. API_KEY); `environment` is the environment slug (dev/prod); optional `description`. Writing guidance for that description lives in the product doctrine, which `get_work_context` returns.",
17979
18049
  inputSchema: {
17980
18050
  project: projectKeySchema,
17981
18051
  environment: external_exports.string().min(1),
@@ -17984,14 +18054,14 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
17984
18054
  description: external_exports.string().optional()
17985
18055
  }
17986
18056
  }, ({ project, environment, name, value, description }) => run(() => client2.setSecret(project, { env: environment, name, value, description })));
17987
- server2.registerTool("list_secrets", {
18057
+ registerStrict("list_secrets", {
17988
18058
  description: "List a project's secrets as METADATA ONLY (id, name, environment, description, current version, timestamps) \u2014 never any value. Optional `environment` slug filter.",
17989
18059
  inputSchema: {
17990
18060
  project: projectKeySchema,
17991
18061
  environment: external_exports.string().min(1).optional()
17992
18062
  }
17993
18063
  }, ({ project, environment }) => run(() => client2.listSecrets(project, { env: environment }), GUIDANCE_POINTER));
17994
- server2.registerTool("describe_secret", {
18064
+ registerStrict("describe_secret", {
17995
18065
  description: "Describe one secret: its metadata plus its version history (version, created_at, created_by, and whether each version has been crypto-shredded). METADATA ONLY \u2014 no value is ever returned.",
17996
18066
  inputSchema: {
17997
18067
  project: projectKeySchema,
@@ -17999,8 +18069,8 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
17999
18069
  name: secretNameSchema
18000
18070
  }
18001
18071
  }, ({ project, environment, name }) => run(() => client2.describeSecret(project, name, { env: environment }), GUIDANCE_POINTER));
18002
- server2.registerTool("rotate_secret", {
18003
- description: "Rotate an EXISTING secret: store a NEW version and advance the pointer (metadata only \u2014 the response NEVER echoes the value).",
18072
+ registerStrict("rotate_secret", {
18073
+ description: "Rotate an EXISTING secret: store a NEW version and advance the pointer. Same value rules as `set_secret`: the value crosses INBOUND ONLY and the response NEVER echoes it, and for a sensitive value prefer the CLI (`ppb secrets set` adds a new version from stdin/`--from-file`), because a value typed here lands in the chat transcript before the server can encrypt it.",
18004
18074
  inputSchema: {
18005
18075
  project: projectKeySchema,
18006
18076
  environment: external_exports.string().min(1),
@@ -18008,7 +18078,7 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
18008
18078
  value: external_exports.string().min(1)
18009
18079
  }
18010
18080
  }, ({ project, environment, name, value }) => run(() => client2.rotateSecret(project, name, { env: environment, value })));
18011
- server2.registerTool("update_secret_description", {
18081
+ registerStrict("update_secret_description", {
18012
18082
  description: "Update a secret's description (metadata-only edit \u2014 does not change the value or add a version). Writing guidance for that description lives in the product doctrine, which `get_work_context` returns.",
18013
18083
  inputSchema: {
18014
18084
  project: projectKeySchema,
@@ -18017,7 +18087,7 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
18017
18087
  description: external_exports.string()
18018
18088
  }
18019
18089
  }, ({ project, environment, name, description }) => run(() => client2.updateSecretDescription(project, name, { env: environment, description })));
18020
- server2.registerTool("delete_secret", {
18090
+ registerStrict("delete_secret", {
18021
18091
  description: "Delete a secret: soft-delete the entry and crypto-shred all its versions (the value becomes unrecoverable). Returns confirmation metadata only.",
18022
18092
  inputSchema: {
18023
18093
  project: projectKeySchema,
@@ -18028,14 +18098,14 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
18028
18098
  await client2.deleteSecret(project, name, { env: environment });
18029
18099
  return { deleted: true, project, environment, name };
18030
18100
  }));
18031
- server2.registerTool("list_org_instructions", {
18101
+ registerStrict("list_org_instructions", {
18032
18102
  description: "List the org\u2019s agent instructions \u2014 the per-scope texts this server composes into the guidance `get_work_context` returns, each under a heading naming its scope. Edits apply to NEW sessions only; use this to see the CURRENT saved values mid-session (at most one row per scope)."
18033
18103
  }, () => run(() => client2.listOrgInstructions(), GUIDANCE_POINTER));
18034
- server2.registerTool("set_org_instruction", {
18104
+ registerStrict("set_org_instruction", {
18035
18105
  description: "Set (create or replace) one scope\u2019s org-level agent instruction text, max 4000 characters \u2014 over-cap is rejected, never truncated. Scopes: `general` (server-wide), `requirements`, `content`, and `work.<type>` (one per issue type, e.g. `work.epic`). Requires the ADMIN role in the token\u2019s org. Takes effect at the NEXT session start \u2014 running sessions keep the text they launched with. Returns the saved instruction. Writing guidance for an instruction body lives in the product doctrine, which `get_work_context` returns \u2014 see \u201CSetting an instruction replaces the whole body\u201D.",
18036
18106
  inputSchema: setOrgInstructionInputSchema.shape
18037
18107
  }, (args) => run(() => client2.setOrgInstruction(args.scope, args.body)));
18038
- server2.registerTool("delete_org_instruction", {
18108
+ registerStrict("delete_org_instruction", {
18039
18109
  description: "Clear one scope\u2019s org-level agent instruction (soft delete; an unset scope is an error naming it). Requires the ADMIN role in the token\u2019s org. Takes effect at the NEXT session start. Returns the cleared instruction.",
18040
18110
  inputSchema: deleteOrgInstructionInputSchema.shape
18041
18111
  }, (args) => run(() => client2.deleteOrgInstruction(args.scope)));
@@ -18104,7 +18174,7 @@ function loadMcpConfig() {
18104
18174
  }
18105
18175
 
18106
18176
  // src/version.ts
18107
- var VERSION = "0.39.0";
18177
+ var VERSION = "0.41.0";
18108
18178
 
18109
18179
  // src/main.ts
18110
18180
  var config2 = loadMcpConfig();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pipebrain-mcp",
3
- "version": "0.39.0",
4
- "description": "Pipebrain MCP server for Claude Code and Claude Desktop — issues, requirements, content, assets, and metadata-only secrets over the Pipebrain API.",
3
+ "version": "0.41.0",
4
+ "description": "Pipebrain MCP server for Claude Code and Claude Desktop — issues, requirements, decision records, conventions, prompts, content, assets, metadata-only secrets, and more of the platform's modules over the Pipebrain API.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "main": "./dist/main.js",