@nanobpm/nano-workforce 0.159.1 → 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,15 @@
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
+
7
+ ## [0.160.0](https://github.com/nanobpm/nano-workforce/compare/v0.159.1...v0.160.0) (2026-08-29)
8
+
9
+ ### Features
10
+
11
+ * **delivery-graph:** getDeliveryGraphVocabulary read tool — closed vocabulary + wait semantics as structured data (S3) ([#616](https://github.com/nanobpm/nano-workforce/issues/616)) ([8d3e90a](https://github.com/nanobpm/nano-workforce/commit/8d3e90a294f9ed195c5cc1b7a30cffb1799a053a)), closes [nanobpm/nano-workforce#605](https://github.com/nanobpm/nano-workforce/issues/605) [#609](https://github.com/nanobpm/nano-workforce/issues/609)
12
+
1
13
  ## [0.159.1](https://github.com/nanobpm/nano-workforce/compare/v0.159.0...v0.159.1) (2026-08-29)
2
14
 
3
15
  ### Bug Fixes
@@ -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
+ }
@@ -0,0 +1,102 @@
1
+ // app/deliveryGraphVocabulary.test.ts — the DRIFT GUARD for the delivery-graph vocabulary surface
2
+ // (epic nano-workforce#605, S3/#609). The vocabulary (`getDeliveryGraphVocabulary`) exists so agents
3
+ // discover the closed node/probe/connector vocabulary from the surface instead of reading source; if
4
+ // a new probe kind or connector target lands in the compiler WITHOUT a matching vocabulary entry, the
5
+ // surface silently lies. These tests fail the build in exactly that case: they assert the vocabulary's
6
+ // key sets are byte-identical to the closed sets in `app/deliveryGraph.ts` / `app/readiness.ts` /
7
+ // `app/convergeTargets.ts` (AGENTS.md — "no drift surfaces").
8
+ import assert from "node:assert/strict";
9
+ import { test } from "node:test";
10
+ import { CONVERGE_MERGE_TARGET, CONVERGE_TARGET, isConvergeTarget, MERGE_MAIN_TARGET } from "./convergeTargets.ts";
11
+ import { DELIVERY_FACT_TYPES, DELIVERY_GUARD_SCALAR_TYPES, DELIVERY_NODE_KINDS } from "./deliveryGraph.ts";
12
+ import { deliveryGraphVocabulary } from "./deliveryGraphVocabulary.ts";
13
+ import {
14
+ DEFAULT_EVERY_MS,
15
+ DEFAULT_TIMEOUT_MS,
16
+ EPIC_CONDITIONS,
17
+ ON_TIMEOUTS,
18
+ PR_CONDITIONS,
19
+ PROBE_KINDS,
20
+ } from "./readiness.ts";
21
+
22
+ const sorted = (xs: readonly string[]): string[] => [...xs].sort();
23
+
24
+ test("node kinds cover exactly DELIVERY_NODE_KINDS (add a kind to the compiler ⇒ must add a vocab entry)", () => {
25
+ const vocab = deliveryGraphVocabulary();
26
+ assert.deepEqual(
27
+ sorted(vocab.nodeKinds.map((n) => n.kind)),
28
+ sorted(DELIVERY_NODE_KINDS),
29
+ "vocabulary node kinds drifted from DELIVERY_NODE_KINDS — extend NODE_KIND_DETAIL",
30
+ );
31
+ });
32
+
33
+ test("wait probe kinds cover exactly PROBE_KINDS (add a probe kind ⇒ must add a vocab entry)", () => {
34
+ const vocab = deliveryGraphVocabulary();
35
+ assert.deepEqual(
36
+ sorted(vocab.waitProbeKinds.map((p) => p.kind)),
37
+ sorted(PROBE_KINDS),
38
+ "vocabulary wait probe kinds drifted from PROBE_KINDS — extend WAIT_PROBE_DETAIL",
39
+ );
40
+ });
41
+
42
+ test("pr / epic probe conditions match the closed PR_CONDITIONS / EPIC_CONDITIONS", () => {
43
+ const vocab = deliveryGraphVocabulary();
44
+ const pr = vocab.waitProbeKinds.find((p) => p.kind === "pr");
45
+ const epic = vocab.waitProbeKinds.find((p) => p.kind === "epic");
46
+ assert.ok(pr && epic, "pr and epic probe entries must exist");
47
+ assert.deepEqual(sorted(pr.conditions ?? []), sorted(PR_CONDITIONS), "pr conditions drifted from PR_CONDITIONS");
48
+ assert.deepEqual(sorted(epic.conditions ?? []), sorted(EPIC_CONDITIONS), "epic conditions drifted from EPIC_CONDITIONS");
49
+ });
50
+
51
+ test("every real converge-enrollment target has a real vocab entry (add a target ⇒ must add a vocab entry)", () => {
52
+ const vocab = deliveryGraphVocabulary();
53
+ const realTargets = vocab.connectorTargets.filter((t) => t.status === "real").map((t) => t.target);
54
+ for (const target of [CONVERGE_TARGET, CONVERGE_MERGE_TARGET, MERGE_MAIN_TARGET]) {
55
+ assert.ok(
56
+ realTargets.includes(target),
57
+ `converge target '${target}' is missing a 'real' vocabulary entry — extend REAL_CONNECTOR_TARGETS`,
58
+ );
59
+ // Guard the classification too: a target the compiler treats as converge-enrollment must be marked real.
60
+ assert.ok(isConvergeTarget(target), `sanity: '${target}' must be an isConvergeTarget`);
61
+ }
62
+ // Exactly the converge set is "real"; nothing else is claimed real, and the stub sentinel is present.
63
+ assert.deepEqual(sorted(realTargets), sorted([CONVERGE_TARGET, CONVERGE_MERGE_TARGET, MERGE_MAIN_TARGET]));
64
+ assert.ok(
65
+ vocab.connectorTargets.some((t) => t.status === "forward-declared"),
66
+ "the forward-declared stub sentinel must be present so agents learn the real-vs-stub split",
67
+ );
68
+ });
69
+
70
+ test("onTimeout options match the closed ON_TIMEOUTS", () => {
71
+ const vocab = deliveryGraphVocabulary();
72
+ assert.deepEqual(sorted(vocab.onTimeout.map((o) => o.value)), sorted(ON_TIMEOUTS), "onTimeout options drifted from ON_TIMEOUTS");
73
+ });
74
+
75
+ test("fact types + guard scalar types are derived verbatim", () => {
76
+ const vocab = deliveryGraphVocabulary();
77
+ assert.deepEqual(vocab.factTypes, [...DELIVERY_FACT_TYPES]);
78
+ assert.deepEqual(vocab.guardScalarTypes, [...DELIVERY_GUARD_SCALAR_TYPES]);
79
+ });
80
+
81
+ test("poll-budget carries the real defaults and names the 30-minute trap", () => {
82
+ const vocab = deliveryGraphVocabulary();
83
+ assert.equal(vocab.pollBudget.defaultTimeoutMs, DEFAULT_TIMEOUT_MS);
84
+ assert.equal(vocab.pollBudget.defaultEveryMs, DEFAULT_EVERY_MS);
85
+ assert.match(vocab.pollBudget.rule, /poll\.timeoutMs/);
86
+ assert.match(vocab.pollBudget.rule, /30 minutes|1800000/);
87
+ });
88
+
89
+ test("the epic probe states the FEATURE-RUN observation semantics (the #605 evidence gap)", () => {
90
+ const vocab = deliveryGraphVocabulary();
91
+ const epic = vocab.waitProbeKinds.find((p) => p.kind === "epic");
92
+ assert.ok(epic, "epic probe entry must exist");
93
+ assert.match(epic.observes, /rootRequestKey/i);
94
+ assert.match(epic.observes, /regardless of/i);
95
+ assert.match(epic.observes, /feature/i);
96
+ assert.match(epic.ready, /stage:"merged"|stage:\\"merged\\"|merged.*active:false/);
97
+ });
98
+
99
+ test("fact-threading rule names the unbound-pr rejection", () => {
100
+ const vocab = deliveryGraphVocabulary();
101
+ assert.match(vocab.factThreading.rule, /unbound-pr/);
102
+ });