@nanobpm/nano-workforce 0.160.0 → 0.161.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.161.0](https://github.com/nanobpm/nano-workforce/compare/v0.160.0...v0.161.0) (2026-08-29)
2
+
3
+ ### Features
4
+
5
+ * addressable operator guide via getAgentGuide(section?) over MCP ([#615](https://github.com/nanobpm/nano-workforce/issues/615)) ([8d59f54](https://github.com/nanobpm/nano-workforce/commit/8d59f541e25478ff7a076debc053d988f1d79b29)), closes [#611](https://github.com/nanobpm/nano-workforce/issues/611)
6
+
1
7
  ## [0.160.0](https://github.com/nanobpm/nano-workforce/compare/v0.159.1...v0.160.0) (2026-08-29)
2
8
 
3
9
  ### Features
@@ -0,0 +1,115 @@
1
+ // Tests for the addressable operator guide (epic #605 slice S5, issue #611): the stable section
2
+ // registry, the fence-aware markdown splitter, the table of contents, and per-section rendering.
3
+ // The drift guard here is the load-bearing test — it fails the build if the authored guide
4
+ // (`docs/agent-guide.md`) grows/loses a `## ` section without a matching `GUIDE_SECTIONS` entry, so
5
+ // the addressable surface can never silently diverge from the prose.
6
+ import { readFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { test } from "node:test";
10
+ import { assert, assertEquals } from "#test-assert";
11
+ import {
12
+ GUIDE_SECTIONS,
13
+ guideToc,
14
+ renderAgentGuide,
15
+ renderGuideSection,
16
+ splitGuideSections,
17
+ } from "./agentGuide.ts";
18
+
19
+ const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
20
+ const RAW_GUIDE = readFileSync(join(REPO_ROOT, "docs", "agent-guide.md"), "utf8");
21
+
22
+ test("DRIFT GUARD: every `## ` section in docs/agent-guide.md has exactly one registry entry", () => {
23
+ const { sections } = splitGuideSections(RAW_GUIDE);
24
+ assertEquals(
25
+ sections.length,
26
+ GUIDE_SECTIONS.length,
27
+ `docs/agent-guide.md has ${sections.length} top-level sections but GUIDE_SECTIONS lists ` +
28
+ `${GUIDE_SECTIONS.length}. Add/remove a { id, summary } entry so the addressable surface ` +
29
+ "matches the prose.",
30
+ );
31
+ });
32
+
33
+ test("section ids are unique and non-empty; summaries are non-empty", () => {
34
+ const ids = new Set<string>();
35
+ for (const s of GUIDE_SECTIONS) {
36
+ assert(s.id.length > 0, "section id must be non-empty");
37
+ assert(!ids.has(s.id), `duplicate section id "${s.id}"`);
38
+ ids.add(s.id);
39
+ assert(s.summary.trim().length > 0, `section "${s.id}" needs a one-line summary`);
40
+ }
41
+ });
42
+
43
+ test("the fence-aware splitter ignores `## ` inside fenced code blocks", () => {
44
+ const raw = [
45
+ "# Title",
46
+ "",
47
+ "## 0. Real heading",
48
+ "body",
49
+ "```",
50
+ "## not a heading (inside a fence)",
51
+ "```",
52
+ "more body",
53
+ "## 1. Second heading",
54
+ "tail",
55
+ ].join("\n");
56
+ const { sections } = splitGuideSections(raw);
57
+ assertEquals(sections.length, 2);
58
+ assertEquals(sections[0].title, "0. Real heading");
59
+ assert(sections[0].body.includes("## not a heading"), "fenced content stays in its section body");
60
+ assertEquals(sections[1].title, "1. Second heading");
61
+ });
62
+
63
+ test("guideToc lists every registry id with a derived title and its summary", () => {
64
+ const toc = guideToc();
65
+ assertEquals(toc.length, GUIDE_SECTIONS.length);
66
+ for (let i = 0; i < toc.length; i++) {
67
+ assertEquals(toc[i].id, GUIDE_SECTIONS[i].id);
68
+ assertEquals(toc[i].summary, GUIDE_SECTIONS[i].summary);
69
+ assert(toc[i].title.length > 0, `toc[${i}] must carry the derived heading title`);
70
+ }
71
+ // The TOC is compact by construction — comfortably under any tool-result limit.
72
+ assert(JSON.stringify(toc).length < 4000, "the table of contents must stay small");
73
+ });
74
+
75
+ test("delivery-graphs is addressable, smaller than the full guide, and base-keyed", () => {
76
+ const base = "https://example.test/app/api";
77
+ const section = renderGuideSection("delivery-graphs", base);
78
+ assert(section !== undefined, "delivery-graphs must resolve");
79
+ const full = renderAgentGuide(base);
80
+ assert(section!.length > 0);
81
+ assert(section!.length < full.length, "a single section must be smaller than the whole guide");
82
+ assert(section!.length < 30000, "the delivery-graphs section must fit a typical tool-result budget");
83
+ assert(!section!.includes("__BASE__"), "placeholders must be substituted in a section render");
84
+ assert(!section!.includes("__ENGINE__"), "engine placeholder must be substituted too");
85
+ });
86
+
87
+ test("a compact section is a small fraction of the whole guide", () => {
88
+ const base = "https://example.test/app/api";
89
+ const orient = renderGuideSection("orient", base)!;
90
+ const full = renderAgentGuide(base);
91
+ assert(orient.length < full.length / 10, "the orient section must be a small fraction of the guide");
92
+ });
93
+
94
+ test("every registry id resolves to a non-empty section whose heading matches its title", () => {
95
+ const base = "https://example.test/app/api";
96
+ const toc = guideToc();
97
+ for (const { id, title } of toc) {
98
+ const md = renderGuideSection(id, base);
99
+ assert(md !== undefined, `section "${id}" must resolve`);
100
+ assert(md!.startsWith(`## ${title}`), `section "${id}" body must open with its heading`);
101
+ }
102
+ });
103
+
104
+ test("an unknown section id resolves to undefined (the op turns that into a 400)", () => {
105
+ assertEquals(renderGuideSection("does-not-exist", "https://x/app/api"), undefined);
106
+ });
107
+
108
+ test("no content regression: each section body is a verbatim slice of the raw guide", () => {
109
+ // Rendering keys examples to an instance; the UN-substituted section bodies must be exact
110
+ // substrings of the authored doc, so the addressable surface never rewrites guide content.
111
+ const { sections } = splitGuideSections(RAW_GUIDE);
112
+ for (const s of sections) {
113
+ assert(RAW_GUIDE.includes(s.body), `section "${s.title}" must be a verbatim slice of the guide`);
114
+ }
115
+ });
package/app/agentGuide.ts CHANGED
@@ -59,8 +59,117 @@ export function resolveEngineBase(): string {
59
59
  /**
60
60
  * Render the guide for a given app control-API base (e.g. "https://host/app/api"). The engine base
61
61
  * is resolved from the environment. Substitutes every `__BASE__`/`__ENGINE__` occurrence.
62
+ *
63
+ * This is the FULL guide, byte-for-byte the same content the non-MCP fallback doors serve
64
+ * (`GET /app/api/agent`, and — via the skill — `GET /app/api/agent/skill`). The addressable helpers
65
+ * below (`guideToc` / `renderGuideSection`) never touch this path, so those doors stay unchanged.
62
66
  */
63
67
  export function renderAgentGuide(apiBase: string): string {
64
68
  const base = apiBase.replace(/\/+$/, "");
65
69
  return RAW_GUIDE.replaceAll("__BASE__", base).replaceAll("__ENGINE__", resolveEngineBase());
66
70
  }
71
+
72
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
73
+ // Addressable guide (epic nano-workforce#605, slice S5, issue #611).
74
+ //
75
+ // The full guide is ~43KB — a single `getAgentInstructions` call can exceed an agent's tool-result
76
+ // limit, forcing it to persist the blob and carve out the section it wanted out-of-band. The guide
77
+ // is already well-structured as top-level `## N. Title` sections, so we make each one individually
78
+ // addressable: a compact table of contents (id + one-line summary), and per-section retrieval.
79
+ //
80
+ // STABLE IDS ARE THE CONTRACT. `GUIDE_SECTIONS` below is the single source of truth for the stable
81
+ // section ids and their summaries, in document order. The section *bodies* are derived by parsing
82
+ // the authored markdown (`docs/agent-guide.md`) at module load — never duplicated here — so prose
83
+ // edits never drift from the addressable surface. The parity is guarded by a drift test
84
+ // (`app/agentGuide.test.ts`): add or remove a `## ` section in the doc without updating this
85
+ // registry and the build fails.
86
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
87
+
88
+ /** One addressable top-level guide section: a stable id and a one-line summary, in document order.
89
+ * The `id` is the durable handle agents pass to `getAgentGuide(section)`; keep it stable across
90
+ * prose edits (rename the heading freely, never the id). */
91
+ export interface GuideSectionMeta {
92
+ readonly id: string;
93
+ readonly summary: string;
94
+ }
95
+
96
+ /** The stable section registry — ONE per `## N.` heading in `docs/agent-guide.md`, in order. Adding
97
+ * a section to the doc requires adding an entry here (enforced by the drift test). */
98
+ export const GUIDE_SECTIONS: readonly GuideSectionMeta[] = [
99
+ { id: "orient", summary: "Orient first: confirm which instance you're driving; see the live version and every PR in flight." },
100
+ { id: "submit-pr", summary: "Submit a PR to the review-convergence loop — review-only vs. converge-and-merge, dependency barriers, round caps." },
101
+ { id: "submit-epic", summary: "Hand a whole issue to the fleet: plan → implement → converge across coding agents, against a required base branch." },
102
+ { id: "escalations", summary: "Find and answer parked human-in-the-loop escalations (PR-review, feature, plan-review, trial-merge) by user-task key." },
103
+ { id: "lifecycle", summary: "The PR/epic lifecycle and status vocabulary, so you can reason about where a run currently sits." },
104
+ { id: "debug", summary: "Debug: find the engine process instance behind a PR and inspect its jobs, incidents, and element-instances." },
105
+ { id: "debug-models", summary: "Debug the deployed BPMN models and the agent prompts a running instance is actually using." },
106
+ { id: "unstick", summary: "Unstick a wedged process — publish a correlating message, cancel, or otherwise recover a stalled instance." },
107
+ { id: "raise-issue", summary: "Raise an issue or open a PR against the nano-workforce repository itself." },
108
+ { id: "delivery-graphs", summary: "Author, preview, compile/stage and run an agent-authored delivery graph (ADR 0005): node/wait/connector vocabulary." },
109
+ ] as const;
110
+
111
+ /** A parsed section: its stable id + summary (from the registry), the derived heading `title` (the
112
+ * markdown text after `## `, e.g. "9. Author and run a delivery graph (ADR 0005)"), and the raw
113
+ * section markdown `body` INCLUDING the heading line, with `__BASE__`/`__ENGINE__` un-substituted. */
114
+ export interface ParsedGuideSection extends GuideSectionMeta {
115
+ readonly title: string;
116
+ readonly body: string;
117
+ }
118
+
119
+ /** Split raw guide markdown into its top-level `## ` sections, fence-aware (a `## ` inside a fenced
120
+ * code block is content, not a heading). Everything before the first heading is the preamble.
121
+ * Pure/​testable — takes the raw text so the drift test can feed it the on-disk doc directly. */
122
+ export function splitGuideSections(raw: string): { preamble: string; sections: { title: string; body: string }[] } {
123
+ const lines = raw.split("\n");
124
+ const sections: { title: string; lines: string[] }[] = [];
125
+ const preambleLines: string[] = [];
126
+ let inFence = false;
127
+ let current: { title: string; lines: string[] } | null = null;
128
+ for (const line of lines) {
129
+ if (/^(```|~~~)/.test(line.trim())) inFence = !inFence;
130
+ const heading = !inFence ? /^## (.+)$/.exec(line) : null;
131
+ if (heading) {
132
+ current = { title: heading[1].trim(), lines: [line] };
133
+ sections.push(current);
134
+ } else if (current) {
135
+ current.lines.push(line);
136
+ } else {
137
+ preambleLines.push(line);
138
+ }
139
+ }
140
+ return {
141
+ preamble: preambleLines.join("\n"),
142
+ sections: sections.map((s) => ({ title: s.title, body: s.lines.join("\n").replace(/\s+$/, "") })),
143
+ };
144
+ }
145
+
146
+ /** The parsed sections of THIS deployment's guide, zipped against the stable registry, computed once
147
+ * at module load. When the doc is unreadable (fallback guide, no `## ` headings) this is empty — the
148
+ * addressable surface degrades to "no sections", never throws. Length/registry parity is asserted by
149
+ * the drift test, not at runtime, so a mismatched deploy still serves what it can. */
150
+ const PARSED_SECTIONS: readonly ParsedGuideSection[] = (() => {
151
+ const { sections } = splitGuideSections(RAW_GUIDE);
152
+ const n = Math.min(sections.length, GUIDE_SECTIONS.length);
153
+ const out: ParsedGuideSection[] = [];
154
+ for (let i = 0; i < n; i++) {
155
+ out.push({ ...GUIDE_SECTIONS[i], title: sections[i].title, body: sections[i].body });
156
+ }
157
+ return out;
158
+ })();
159
+
160
+ /** The compact table of contents: every addressable section's id, derived heading title, and stable
161
+ * one-line summary, in document order. Small by construction — safe to return whole under any
162
+ * tool-result limit. Titles reflect THIS deployment's doc; summaries come from the registry. */
163
+ export function guideToc(): { id: string; title: string; summary: string }[] {
164
+ return PARSED_SECTIONS.map((s) => ({ id: s.id, title: s.title, summary: s.summary }));
165
+ }
166
+
167
+ /** Render a SINGLE section's markdown for a given app control-API base, `__BASE__`/`__ENGINE__`
168
+ * substituted exactly as {@link renderAgentGuide} does for the whole guide. Returns `undefined` for
169
+ * an unknown id (the caller turns that into a 400 listing the valid ids). */
170
+ export function renderGuideSection(id: string, apiBase: string): string | undefined {
171
+ const section = PARSED_SECTIONS.find((s) => s.id === id);
172
+ if (!section) return undefined;
173
+ const base = apiBase.replace(/\/+$/, "");
174
+ return section.body.replaceAll("__BASE__", base).replaceAll("__ENGINE__", resolveEngineBase());
175
+ }
@@ -150,6 +150,22 @@ Agents without MCP are unchanged — resolve the instance, then
150
150
  live guide. `GET /app/api/agent` and `GET /app/api/agent/skill` keep working exactly as
151
151
  before.
152
152
 
153
+ ### Addressable guide (MCP) — `getAgentGuide`
154
+
155
+ The full guide is ~43KB — a single `getAgentInstructions` call can overrun a tool-result
156
+ limit. Over MCP, prefer the **addressable** companion tool `getAgentGuide(section?)`
157
+ (`GET /app/api/agent/guide`):
158
+
159
+ - **No argument** → a compact **table of contents**: every stable section id
160
+ (`orient`, `submit-pr`, `submit-epic`, `escalations`, `lifecycle`, `debug`,
161
+ `debug-models`, `unstick`, `raise-issue`, `delivery-graphs`) with a one-line summary.
162
+ - **`section=<id>`** → **only** that section's markdown, small enough to fit a typical
163
+ limit. An unknown id is rejected with `issues[{path,message}]` listing the valid ids.
164
+
165
+ The section ids are the single source of truth in `app/agentGuide.ts` (`GUIDE_SECTIONS`),
166
+ derived-and-checked against the authored `docs/agent-guide.md`. The `getAgentInstructions`
167
+ / `GET /agent` full-guide door is unchanged — the addressable tool is additive.
168
+
153
169
  ## 6. Regression harness — pin the MCP surface from nwf's side
154
170
 
155
171
  The MCP projection layer (schema shape, argument encoding, session handshake) is
@@ -0,0 +1,89 @@
1
+ // Addressable operator-guide regression net (epic #605 slice S5, issue #611).
2
+ //
3
+ // Drives the app's REAL runtime-served MCP endpoint (`/app/mcp`, ADR 0067) via the reusable
4
+ // `e2e/support/mcp-harness.ts` module (slice S1, #607) — it does NOT re-implement the handshake.
5
+ // It PINS the addressable-guide contract from a client's point of view:
6
+ //
7
+ // • `getAgentGuide` is projected with a `$ref`-free, explicitly-typed input schema (S0 invariant);
8
+ // • no argument → a compact table of contents listing every stable section id + summary, small
9
+ // enough to fit a typical tool-result limit;
10
+ // • `section=<id>` → ONLY that section, far smaller than the whole guide (the defect this fixes:
11
+ // `getAgentInstructions` returns ~43KB that overran the limit);
12
+ // • an unknown id → a uniform `issues[{path,message}]` validation error;
13
+ // • the full-guide fallback door (`getAgentInstructions`) is UNCHANGED — still the whole guide.
14
+ //
15
+ // Run with `npm run e2e`.
16
+ import assert from "node:assert/strict";
17
+ import { after, before, describe, test } from "node:test";
18
+ import { assertSchemaSelfContained, bootMcpHarness, type McpHarness } from "./support/mcp-harness.ts";
19
+
20
+ describe("S5 — the addressable operator guide over MCP (#611)", () => {
21
+ let h: McpHarness;
22
+ before(async () => {
23
+ h = await bootMcpHarness();
24
+ });
25
+ after(async () => {
26
+ await h.stop();
27
+ });
28
+
29
+ test("getAgentGuide is projected with a self-contained, explicitly-typed input schema", async () => {
30
+ const tools = await h.listTools();
31
+ const tool = tools.find((t) => t.name === "getAgentGuide");
32
+ assert(tool, "getAgentGuide must be projected onto the MCP surface");
33
+ assertSchemaSelfContained(tool!.inputSchema, "getAgentGuide");
34
+ const props = (tool!.inputSchema as { properties?: Record<string, unknown> }).properties ?? {};
35
+ const section = props.section as { type?: string } | undefined;
36
+ assert(section, "getAgentGuide must expose a `section` argument");
37
+ assert.equal(section!.type, "string", "`section` must be an explicitly-typed string");
38
+ });
39
+
40
+ test("no argument returns a compact table of contents with every section id", async () => {
41
+ const res = await h.callTool("getAgentGuide", {});
42
+ assert(!res.isError, `getAgentGuide (TOC) must not error: ${res.text}`);
43
+ const body = res.json as { kind?: string; sections?: { id: string; title: string; summary: string }[] };
44
+ assert.equal(body.kind, "toc");
45
+ assert(Array.isArray(body.sections) && body.sections.length > 0, "the TOC must list sections");
46
+ const ids = body.sections!.map((s) => s.id);
47
+ for (const id of ["orient", "submit-pr", "submit-epic", "escalations", "delivery-graphs"]) {
48
+ assert(ids.includes(id), `the TOC must list "${id}"`);
49
+ }
50
+ for (const s of body.sections!) {
51
+ assert(s.title.length > 0 && s.summary.length > 0, `TOC entry "${s.id}" needs a title + summary`);
52
+ }
53
+ // The whole point: the TOC is tiny relative to the ~43KB monolith.
54
+ assert(res.text.length < 4000, "the TOC must fit comfortably under a tool-result limit");
55
+ });
56
+
57
+ test("section=delivery-graphs returns ONLY that section, under a typical result budget", async () => {
58
+ const res = await h.callTool("getAgentGuide", { section: "delivery-graphs" });
59
+ assert(!res.isError, `getAgentGuide(delivery-graphs) must not error: ${res.text}`);
60
+ const body = res.json as { kind?: string; section?: { id: string; instructions: string } };
61
+ assert.equal(body.kind, "section");
62
+ assert.equal(body.section?.id, "delivery-graphs");
63
+ assert(body.section!.instructions.length > 200, "the section must carry real content");
64
+ assert(!body.section!.instructions.includes("__BASE__"), "placeholders must be substituted");
65
+
66
+ // It must be smaller than the full guide the fallback door still serves — a proper subset.
67
+ const full = await h.callTool("getAgentInstructions", {});
68
+ assert(res.text.length < full.text.length, "one section must be smaller than the whole guide");
69
+ assert(res.text.length < 30000, "one section must fit a typical tool-result budget in a single call");
70
+ });
71
+
72
+ test("an unknown section id is rejected with issues[{path,message}]", async () => {
73
+ const res = await h.callTool("getAgentGuide", { section: "no-such-section" });
74
+ assert(res.isError, "an unknown section id must surface as a tool-level error");
75
+ const body = res.json as { issues?: { path: string; message: string }[] };
76
+ assert(Array.isArray(body.issues) && body.issues.length >= 1, "must answer with issues[]");
77
+ assert.equal(body.issues![0].path, "section");
78
+ });
79
+
80
+ test("the full-guide fallback door is unchanged — still the whole guide", async () => {
81
+ const res = await h.callTool("getAgentInstructions", {});
82
+ assert(!res.isError, `getAgentInstructions must still answer: ${res.text}`);
83
+ const body = res.json as { instructions?: string };
84
+ assert(typeof body.instructions === "string", "getAgentInstructions still returns the full guide");
85
+ // The monolith still contains a section the addressable tool now carves out — no content regression.
86
+ assert(body.instructions!.includes("delivery graph"), "the full guide still contains every section");
87
+ assert(body.instructions!.length > 10000, "the full guide door is unshrunk");
88
+ });
89
+ });
package/openapi.yaml CHANGED
@@ -35,6 +35,37 @@ components:
35
35
  properties:
36
36
  error:
37
37
  type: string
38
+ ValidationError:
39
+ description: >-
40
+ The uniform validation-error contract: a human-readable `error` plus a path-qualified
41
+ `issues[]` naming each offending input. Returned by endpoints that reject a bad query/body
42
+ param (e.g. `GET /agent/guide` with an unknown `section` id).
43
+ type: object
44
+ additionalProperties: false
45
+ required:
46
+ - error
47
+ - issues
48
+ properties:
49
+ error:
50
+ type: string
51
+ description: A human-readable summary of the rejection.
52
+ issues:
53
+ type: array
54
+ minItems: 1
55
+ items:
56
+ type: object
57
+ additionalProperties: false
58
+ required:
59
+ - path
60
+ - message
61
+ properties:
62
+ path:
63
+ type: string
64
+ description: JSON-path pointer at the offending input (e.g. `section`).
65
+ message:
66
+ type: string
67
+ description: Human-actionable description of the failure.
68
+ description: The path-qualified `{ path, message }` failures (at least one).
38
69
  ActivePr:
39
70
  type: object
40
71
  description: A tracked PR that is not in a terminal (converged/abandoned) state.
@@ -881,6 +912,82 @@ components:
881
912
  skill:
882
913
  type: string
883
914
  description: The full operator skill (SKILL.md) as markdown, including its YAML frontmatter.
915
+ AgentGuideResponse:
916
+ type: object
917
+ description: The addressable operator-guide response served by `getAgentGuide`. Two shapes,
918
+ discriminated by `kind`. `kind:"toc"` (no `section` argument) carries `sections` — the compact
919
+ table of contents, one entry per stable section id. `kind:"section"` (a `section` id given)
920
+ carries `section` — that one section's markdown, with its examples keyed to this instance.
921
+ additionalProperties: false
922
+ required:
923
+ - kind
924
+ - appVersion
925
+ - generatedAt
926
+ - baseUrl
927
+ properties:
928
+ kind:
929
+ type: string
930
+ description: '"toc" when listing sections (no `section` argument); "section" when returning one.'
931
+ enum:
932
+ - toc
933
+ - section
934
+ appVersion:
935
+ type: string
936
+ nullable: true
937
+ description: The running app version this guide matches (null when unreadable).
938
+ generatedAt:
939
+ type: string
940
+ description: When this response was rendered (ISO-8601).
941
+ baseUrl:
942
+ type: string
943
+ description: The app control-API base the examples target (e.g. https://host/app/api).
944
+ engineBase:
945
+ type: string
946
+ description: The engine's Camunda-8 v2 REST base this app talks to (present on a section response).
947
+ sections:
948
+ type: array
949
+ description: The table of contents — present when `kind` is "toc". One entry per addressable section.
950
+ items:
951
+ type: object
952
+ additionalProperties: false
953
+ required:
954
+ - id
955
+ - title
956
+ - summary
957
+ properties:
958
+ id:
959
+ type: string
960
+ description: The stable section id to pass back as `getAgentGuide(section)`.
961
+ title:
962
+ type: string
963
+ description: The section's heading text (e.g. "9. Author and run a delivery graph (ADR 0005)").
964
+ summary:
965
+ type: string
966
+ description: A one-line summary of what the section covers.
967
+ section:
968
+ type: object
969
+ description: The requested section — present when `kind` is "section".
970
+ additionalProperties: false
971
+ required:
972
+ - id
973
+ - title
974
+ - format
975
+ - instructions
976
+ properties:
977
+ id:
978
+ type: string
979
+ description: The stable section id that was requested.
980
+ title:
981
+ type: string
982
+ description: The section's heading text.
983
+ format:
984
+ type: string
985
+ description: The `instructions` media format. Always "markdown".
986
+ enum:
987
+ - markdown
988
+ instructions:
989
+ type: string
990
+ description: The section's markdown, with example commands keyed to this instance.
884
991
  SubmitResult:
885
992
  type: object
886
993
  required:
@@ -3080,6 +3187,53 @@ paths:
3080
3187
  application/json:
3081
3188
  schema:
3082
3189
  $ref: "#/components/schemas/ErrorBody"
3190
+ /agent/guide:
3191
+ get:
3192
+ operationId: getAgentGuide
3193
+ summary: The operator guide, ADDRESSABLE — fetch one section instead of the whole ~43KB blob.
3194
+ Read-only, pure, idempotent. Call with NO `section` to get a compact table of contents (every
3195
+ stable section id + a one-line summary); call with `section` set to a TOC id (e.g.
3196
+ `delivery-graphs`) to get ONLY that section's markdown, small enough to fit a typical
3197
+ tool-result limit. This is the MCP-friendly companion to `getAgentInstructions`, which still
3198
+ returns the full guide unchanged for non-MCP callers. Typical flow — first call
3199
+ `getAgentGuide` (no arg) to see the ids, then `getAgentGuide(section=<id>)` for the one you
3200
+ need. An unknown id is rejected with `issues[{path,message}]` that lists the valid ids.
3201
+ security:
3202
+ - hookSecret: []
3203
+ - {}
3204
+ parameters:
3205
+ # Self-contained tool input (epic #605 S0 convention): a single inline `type: string` query
3206
+ # param — no `$ref`, an explicit type and example — so the projected MCP tool schema is
3207
+ # client-usable as-is (no request body; the inline-mcp-bodies generator does not apply here).
3208
+ - name: section
3209
+ in: query
3210
+ required: false
3211
+ schema:
3212
+ type: string
3213
+ example: delivery-graphs
3214
+ description: OPTIONAL stable section id (from the table of contents `getAgentGuide` returns
3215
+ with no argument), e.g. `orient`, `submit-pr`, `submit-epic`, `escalations`, `lifecycle`,
3216
+ `debug`, `debug-models`, `unstick`, `raise-issue`, `delivery-graphs`. Omit it to get the
3217
+ table of contents. An unknown id yields a 400 listing the valid ids.
3218
+ responses:
3219
+ "200":
3220
+ description: Either the table of contents (no `section`) or a single section's markdown.
3221
+ content:
3222
+ application/json:
3223
+ schema:
3224
+ $ref: "#/components/schemas/AgentGuideResponse"
3225
+ "400":
3226
+ description: The `section` id is not a known section; `issues` lists the valid ids.
3227
+ content:
3228
+ application/json:
3229
+ schema:
3230
+ $ref: "#/components/schemas/ValidationError"
3231
+ "401":
3232
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
3233
+ content:
3234
+ application/json:
3235
+ schema:
3236
+ $ref: "#/components/schemas/ErrorBody"
3083
3237
  /actions/start/convergence-loop:
3084
3238
  post:
3085
3239
  operationId: startConvergenceLoop
@@ -0,0 +1,100 @@
1
+ // Tests for GET /app/api/agent/guide → operation `getAgentGuide` (epic #605 slice S5, issue #611):
2
+ // the addressable operator guide. No `section` → a compact table of contents; `section=<id>` → just
3
+ // that section; an unknown id → 400 with `issues[{path,message}]`. Mirrors the getAgentInstructions
4
+ // test's request shape and shared-secret guard pattern.
5
+ import { test } from "node:test";
6
+ import { assert, assertEquals } from "#test-assert";
7
+ import type { AppApi } from "@nanobpm/urban";
8
+ import { noopLog } from "../test/log.ts";
9
+ import { GUIDE_SECTIONS } from "../app/agentGuide.ts";
10
+ import handler from "./getAgentGuide.ts";
11
+
12
+ const app = { log: noopLog() } as any as AppApi;
13
+
14
+ function input(query: Record<string, string> = {}, headers: Record<string, string> = {}) {
15
+ return {
16
+ req: {
17
+ method: "GET",
18
+ path: "/app/api/agent/guide",
19
+ query: new URLSearchParams(query),
20
+ headers: new Headers(headers),
21
+ text: async () => "",
22
+ } as any,
23
+ params: {},
24
+ query,
25
+ body: undefined,
26
+ };
27
+ }
28
+
29
+ test("no section → the table of contents, one entry per registry section", async () => {
30
+ const r = (await handler(input(), app)) as any;
31
+ assertEquals(r.status, 200);
32
+ assertEquals(r.body.kind, "toc");
33
+ assert(typeof r.body.baseUrl === "string" && r.body.baseUrl.length > 0);
34
+ assert(Array.isArray(r.body.sections));
35
+ assertEquals(r.body.sections.length, GUIDE_SECTIONS.length);
36
+ const ids = r.body.sections.map((s: any) => s.id);
37
+ for (const s of GUIDE_SECTIONS) assert(ids.includes(s.id), `TOC must list "${s.id}"`);
38
+ for (const s of r.body.sections) {
39
+ assert(typeof s.title === "string" && s.title.length > 0);
40
+ assert(typeof s.summary === "string" && s.summary.length > 0);
41
+ }
42
+ });
43
+
44
+ test("the TOC is far smaller than the full guide (fits a tool-result limit)", async () => {
45
+ const r = (await handler(input(), app)) as any;
46
+ assert(JSON.stringify(r.body).length < 4000, "the TOC response must stay compact");
47
+ });
48
+
49
+ test("section=delivery-graphs → just that section's markdown, base-keyed", async () => {
50
+ const r = (await handler(input({ section: "delivery-graphs" }), app)) as any;
51
+ assertEquals(r.status, 200);
52
+ assertEquals(r.body.kind, "section");
53
+ assertEquals(r.body.section.id, "delivery-graphs");
54
+ assertEquals(r.body.section.format, "markdown");
55
+ assert(r.body.section.instructions.length > 200);
56
+ assert(!r.body.section.instructions.includes("__BASE__"), "placeholders must be substituted");
57
+ assert(typeof r.body.engineBase === "string" && r.body.engineBase.length > 0);
58
+ });
59
+
60
+ test("a section is much smaller than the whole guide", async () => {
61
+ const toc = (await handler(input(), app)) as any;
62
+ const section = (await handler(input({ section: "orient" }), app)) as any;
63
+ assert(
64
+ JSON.stringify(section.body).length < 30000,
65
+ "a single section must comfortably fit a typical tool-result limit",
66
+ );
67
+ assertEquals(toc.body.kind, "toc");
68
+ });
69
+
70
+ test("an unknown section id → 400 with issues[{path,message}] listing valid ids", async () => {
71
+ const r = (await handler(input({ section: "nope" }), app)) as any;
72
+ assertEquals(r.status, 400);
73
+ assert(typeof r.body.error === "string");
74
+ assert(Array.isArray(r.body.issues) && r.body.issues.length === 1);
75
+ assertEquals(r.body.issues[0].path, "section");
76
+ assert(r.body.issues[0].message.includes("delivery-graphs"), "the 400 must name the valid ids");
77
+ });
78
+
79
+ test("blank/whitespace section is treated as no section (TOC)", async () => {
80
+ const r = (await handler(input({ section: " " }), app)) as any;
81
+ assertEquals(r.status, 200);
82
+ assertEquals(r.body.kind, "toc");
83
+ });
84
+
85
+ test("shared-secret guard: rejects when the secret is set and header is wrong", async () => {
86
+ const prev = process.env.NANO_PR_WEBHOOK_SECRET;
87
+ process.env.NANO_PR_WEBHOOK_SECRET = "s3cr3t";
88
+ try {
89
+ // Re-import with the secret set so the module-level SECRET picks it up.
90
+ const mod = await import(`./getAgentGuide.ts?secret=${Date.now()}`);
91
+ const guarded = mod.default;
92
+ const rejected = (await guarded(input({}, {}), app)) as any;
93
+ assertEquals(rejected.status, 401);
94
+ const ok = (await guarded(input({}, { "x-hook-secret": "s3cr3t" }), app)) as any;
95
+ assertEquals(ok.status, 200);
96
+ } finally {
97
+ if (prev === undefined) delete process.env.NANO_PR_WEBHOOK_SECRET;
98
+ else process.env.NANO_PR_WEBHOOK_SECRET = prev;
99
+ }
100
+ });
@@ -0,0 +1,92 @@
1
+ // GET /app/api/agent/guide → operationId `getAgentGuide` (epic nano-workforce#605, slice S5,
2
+ // issue #611). The ADDRESSABLE operator guide: fetch one section instead of the whole ~43KB blob
3
+ // that `getAgentInstructions` returns (which can exceed an agent's tool-result limit, forcing it to
4
+ // persist the blob and carve out a section out-of-band).
5
+ //
6
+ // • No `section` query param → a compact table of contents: every stable section id + a one-line
7
+ // summary (`kind: "toc"`). Small by construction — safe under any tool-result limit.
8
+ // • `section=<id>` → ONLY that section's markdown (`kind: "section"`), examples keyed to THIS
9
+ // instance's control-API base + engine base, exactly as the full guide keys them.
10
+ // • An unknown id → 400 with `issues: [{ path: "section", message }]` listing the valid ids
11
+ // (the uniform validation-error contract).
12
+ //
13
+ // This is the MCP-friendly companion to `getAgentInstructions`; the full-guide doors
14
+ // (`GET /agent`, `GET /agent/skill`) are untouched and byte-identical. Read-only, pure, idempotent.
15
+ //
16
+ // The optional shared-secret guard mirrors /agent and /version: enforced HERE only when
17
+ // NANO_PR_WEBHOOK_SECRET is set (the runtime does not enforce OpenAPI `security`).
18
+ import { guideToc, renderGuideSection, resolveEngineBase } from "../app/agentGuide.ts";
19
+ import { resolveApiBase } from "../app/resolveApiBase.ts";
20
+ import { buildVersionInfo, envVar } from "../app/version.ts";
21
+ import { defineOperation } from "../nano-generated/operations.ts";
22
+
23
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
24
+
25
+ export default defineOperation("getAgentGuide", ({ query, req }, app) => {
26
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
27
+ app.log.warn("getAgentGuide rejected: missing/invalid shared secret");
28
+ return { status: 401, body: { error: "unauthorized" } };
29
+ }
30
+
31
+ const baseUrl = resolveApiBase(req, "agent/guide");
32
+ const rawSection = query.section;
33
+ const section = typeof rawSection === "string" ? rawSection.trim() : "";
34
+
35
+ // No section → the table of contents.
36
+ if (!section) {
37
+ return {
38
+ status: 200,
39
+ body: {
40
+ kind: "toc",
41
+ appVersion: buildVersionInfo().version,
42
+ generatedAt: new Date().toISOString(),
43
+ baseUrl,
44
+ sections: guideToc(),
45
+ },
46
+ };
47
+ }
48
+
49
+ // A section id → just that section, or a 400 that names the valid ids.
50
+ const instructions = renderGuideSection(section, baseUrl);
51
+ if (instructions === undefined) {
52
+ // Derive the valid ids from the PARSED table of contents — the sections this deployment can
53
+ // actually serve — not the static registry. When the guide doc is unreadable (RAW_GUIDE
54
+ // fallback, no `##` headings) the TOC is empty and NO id is retrievable, so say so explicitly
55
+ // rather than list registry ids that would themselves 400.
56
+ const validIds = guideToc().map((s) => s.id);
57
+ const detail =
58
+ validIds.length > 0
59
+ ? `valid ids: ${validIds.join(", ")}`
60
+ : "no sections are available in this deployment";
61
+ return {
62
+ status: 400,
63
+ body: {
64
+ error: `unknown guide section "${section}"`,
65
+ issues: [
66
+ {
67
+ path: "section",
68
+ message: `unknown section id "${section}"; ${detail}`,
69
+ },
70
+ ],
71
+ },
72
+ };
73
+ }
74
+
75
+ const title = guideToc().find((s) => s.id === section)?.title ?? section;
76
+ return {
77
+ status: 200,
78
+ body: {
79
+ kind: "section",
80
+ appVersion: buildVersionInfo().version,
81
+ generatedAt: new Date().toISOString(),
82
+ baseUrl,
83
+ engineBase: resolveEngineBase(),
84
+ section: {
85
+ id: section,
86
+ title,
87
+ format: "markdown",
88
+ instructions,
89
+ },
90
+ },
91
+ };
92
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.160.0",
3
+ "version": "0.161.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",