@nanobpm/nano-workforce 0.160.0 → 0.162.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/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:
@@ -2353,6 +2460,71 @@ components:
2353
2460
  description: >-
2354
2461
  A NAVIGATIONAL cockpit deep-link to the staged proposal (helps the agent hand the human a
2355
2462
  link). It is a pointer only — NOT a dispatch handle; nothing in this response can start a run.
2463
+ SequenceIssuesIntent:
2464
+ description: >-
2465
+ The `sequenceIssues` INTENT (epic nano-workforce#605, S4) — a high-level shape that GENERATES
2466
+ the canonical "implement issue → converge → merge" delivery graph (operator-guide §9.4) instead
2467
+ of making an agent hand-author its node/edge JSON. It names an ordered list of `issues` to
2468
+ sequence (each issue's implementation starts once the PRIOR issue has merged) and an OPTIONAL
2469
+ leading `behind` gate (wait for that issue/epic/feature to be fully merged first, §9.5). The
2470
+ door GENERATES, then STAGES the graph through the same compile+stage flow as
2471
+ `compileDeliveryGraph` — it never dispatches (dispatch is an operator-only cockpit action, ADR
2472
+ 0005 Decision 7). For each issue it emits `agent` (`senior:feature`, emits a `pr` fact) →
2473
+ `connector` (`converge-merge`, late-binding that `pr`) → `wait[pr, merged]` (a realistic
2474
+ `poll.timeoutMs`), threading the `pr` fact per §9.4. Invalid input (empty `issues`, an
2475
+ unparseable ref) is a 400 carrying `issues: [{ path, message }]`; nothing is staged.
2476
+ type: object
2477
+ additionalProperties: false
2478
+ example:
2479
+ behind: nanobpm/nano-ide#488
2480
+ issues:
2481
+ - nanobpm/nano-workforce#567
2482
+ - nanobpm/nano-workforce#568
2483
+ required:
2484
+ - issues
2485
+ properties:
2486
+ behind:
2487
+ type: string
2488
+ minLength: 1
2489
+ maxLength: 255
2490
+ description: >-
2491
+ OPTIONAL gate — an `owner/repo#NN` issue/epic/feature reference. When present, a leading
2492
+ `wait[epic]` node gates the whole sequence on that reference reaching "fully merged" (every
2493
+ opened slice/PR landed, §9.5) before the first issue's implementation starts.
2494
+ issues:
2495
+ type: array
2496
+ minItems: 1
2497
+ maxItems: 64
2498
+ items:
2499
+ type: string
2500
+ minLength: 1
2501
+ maxLength: 255
2502
+ description: An `owner/repo#N` issue reference to implement + converge + merge, in sequence.
2503
+ description: >-
2504
+ The ordered issues to sequence — each is implemented by a `senior:feature` agent that opens
2505
+ a PR, driven to convergence + merge, and the NEXT issue's implementation starts only once
2506
+ the prior issue has merged. At least one; at most 64 (keeps the generated graph within the
2507
+ compiler's node ceiling).
2508
+ SequenceIssuesRejected:
2509
+ description: >-
2510
+ A rejected `sequenceIssues` intent — the input failed validation (empty/oversized `issues`, an
2511
+ unparseable `owner/repo#N` reference, or an unknown target/probe per the delivery-graph
2512
+ vocabulary). Every failure is path-qualified so the caller can fix the exact offending input.
2513
+ Nothing was generated or staged.
2514
+ type: object
2515
+ additionalProperties: false
2516
+ required:
2517
+ - error
2518
+ - issues
2519
+ properties:
2520
+ error:
2521
+ type: string
2522
+ description: A human-readable summary of why the intent was rejected.
2523
+ issues:
2524
+ type: array
2525
+ items:
2526
+ $ref: "#/components/schemas/DeliveryCompileError"
2527
+ description: The path-qualified validation failures (at least one).
2356
2528
  FeatureStart:
2357
2529
  description: The start-feature request body — a SINGLE-issue feature run. Names the target issue
2358
2530
  by EXACTLY ONE of `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a
@@ -3080,6 +3252,53 @@ paths:
3080
3252
  application/json:
3081
3253
  schema:
3082
3254
  $ref: "#/components/schemas/ErrorBody"
3255
+ /agent/guide:
3256
+ get:
3257
+ operationId: getAgentGuide
3258
+ summary: The operator guide, ADDRESSABLE — fetch one section instead of the whole ~43KB blob.
3259
+ Read-only, pure, idempotent. Call with NO `section` to get a compact table of contents (every
3260
+ stable section id + a one-line summary); call with `section` set to a TOC id (e.g.
3261
+ `delivery-graphs`) to get ONLY that section's markdown, small enough to fit a typical
3262
+ tool-result limit. This is the MCP-friendly companion to `getAgentInstructions`, which still
3263
+ returns the full guide unchanged for non-MCP callers. Typical flow — first call
3264
+ `getAgentGuide` (no arg) to see the ids, then `getAgentGuide(section=<id>)` for the one you
3265
+ need. An unknown id is rejected with `issues[{path,message}]` that lists the valid ids.
3266
+ security:
3267
+ - hookSecret: []
3268
+ - {}
3269
+ parameters:
3270
+ # Self-contained tool input (epic #605 S0 convention): a single inline `type: string` query
3271
+ # param — no `$ref`, an explicit type and example — so the projected MCP tool schema is
3272
+ # client-usable as-is (no request body; the inline-mcp-bodies generator does not apply here).
3273
+ - name: section
3274
+ in: query
3275
+ required: false
3276
+ schema:
3277
+ type: string
3278
+ example: delivery-graphs
3279
+ description: OPTIONAL stable section id (from the table of contents `getAgentGuide` returns
3280
+ with no argument), e.g. `orient`, `submit-pr`, `submit-epic`, `escalations`, `lifecycle`,
3281
+ `debug`, `debug-models`, `unstick`, `raise-issue`, `delivery-graphs`. Omit it to get the
3282
+ table of contents. An unknown id yields a 400 listing the valid ids.
3283
+ responses:
3284
+ "200":
3285
+ description: Either the table of contents (no `section`) or a single section's markdown.
3286
+ content:
3287
+ application/json:
3288
+ schema:
3289
+ $ref: "#/components/schemas/AgentGuideResponse"
3290
+ "400":
3291
+ description: The `section` id is not a known section; `issues` lists the valid ids.
3292
+ content:
3293
+ application/json:
3294
+ schema:
3295
+ $ref: "#/components/schemas/ValidationError"
3296
+ "401":
3297
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
3298
+ content:
3299
+ application/json:
3300
+ schema:
3301
+ $ref: "#/components/schemas/ErrorBody"
3083
3302
  /actions/start/convergence-loop:
3084
3303
  post:
3085
3304
  operationId: startConvergenceLoop
@@ -3338,6 +3557,83 @@ paths:
3338
3557
  application/json:
3339
3558
  schema:
3340
3559
  $ref: "#/components/schemas/ErrorBody"
3560
+ /actions/start/sequence-issues:
3561
+ post:
3562
+ operationId: sequenceIssues
3563
+ summary: Generate + STAGE the canonical "implement issue → converge → merge" delivery graph from a high-level intent (never dispatches). (epic #605 / S4)
3564
+ description: >-
3565
+ An INTENT-SHAPED door (epic nano-workforce#605, S4). ADR 0005's delivery graph is a closed
3566
+ vocabulary and operator-guide §9.4 already names the canonical shape for "implement issue →
3567
+ converge → merge", but an agent still had to hand-author the full node/edge JSON — sequencing
3568
+ four issues behind a gate meant constructing 13 nodes and 12 edges by hand. This door takes the
3569
+ high-level intent `{ behind?, issues[] }` and GENERATES that canonical graph, then STAGES it
3570
+ through the SAME compile+stage flow the raw `compileDeliveryGraph` door uses (one compiler, one
3571
+ staging path — idempotency + digest inherited, not re-implemented). It returns a preview and a
3572
+ navigational `reviewUrl` and NOTHING that can trigger a run: dispatch is an OPERATOR action in
3573
+ the cockpit (ADR 0005 Decision 7 / issue #460).
3574
+
3575
+
3576
+ For each issue it emits the canonical chain — `agent` (`senior:feature`, emits a typed `pr`
3577
+ fact) → `connector` (`converge-merge`, late-binding that `pr`) → `wait[pr, merged]` (a realistic
3578
+ `poll.timeoutMs`) — and threads the `pr` fact along fact-qualified edges (§9.4). The issues run
3579
+ in SEQUENCE: each issue's implementation starts once the PRIOR issue has merged. When `behind`
3580
+ is given, a leading `wait[epic]` gate (§9.5) makes the whole sequence wait for that reference to
3581
+ be fully merged first.
3582
+
3583
+
3584
+ INPUT — the intent OBJECT `{ "issues": ["owner/repo#A", …] }` with an OPTIONAL
3585
+ `"behind": "owner/repo#NN"` gate (this is the object-body door). SIDE EFFECTS — impure: a valid intent is STAGED as a proposal.
3586
+ IDEMPOTENCY — content-addressed by the compiled `digest` (an identical intent re-stages the same
3587
+ digest). VALIDATION — invalid input (empty `issues`, an unparseable `owner/repo#N` reference, an
3588
+ unknown target/probe per the S3 vocabulary) is a `400` with `issues: [{ path, message }]`;
3589
+ nothing is staged. NEXT — surface the returned `reviewUrl` to the operator; poll
3590
+ `listStagedProposals` to see the staged digest.
3591
+ requestBody:
3592
+ required: true
3593
+ content:
3594
+ application/json:
3595
+ schema:
3596
+ # BEGIN generated:mcp-body source=#/components/schemas/SequenceIssuesIntent (scripts/inline-mcp-bodies.ts — do not hand-edit)
3597
+ description: 'The `sequenceIssues` INTENT (epic nano-workforce#605, S4) — a high-level shape that GENERATES the canonical "implement issue → converge → merge" delivery graph (operator-guide §9.4) instead of making an agent hand-author its node/edge JSON. It names an ordered list of `issues` to sequence (each issue''s implementation starts once the PRIOR issue has merged) and an OPTIONAL leading `behind` gate (wait for that issue/epic/feature to be fully merged first, §9.5). The door GENERATES, then STAGES the graph through the same compile+stage flow as `compileDeliveryGraph` — it never dispatches (dispatch is an operator-only cockpit action, ADR 0005 Decision 7). For each issue it emits `agent` (`senior:feature`, emits a `pr` fact) → `connector` (`converge-merge`, late-binding that `pr`) → `wait[pr, merged]` (a realistic `poll.timeoutMs`), threading the `pr` fact per §9.4. Invalid input (empty `issues`, an unparseable ref) is a 400 carrying `issues: [{ path, message }]`; nothing is staged.'
3598
+ type: object
3599
+ additionalProperties: false
3600
+ example:
3601
+ behind: nanobpm/nano-ide#488
3602
+ issues:
3603
+ - nanobpm/nano-workforce#567
3604
+ - nanobpm/nano-workforce#568
3605
+ required:
3606
+ - issues
3607
+ properties:
3608
+ behind:
3609
+ type: string
3610
+ minLength: 1
3611
+ maxLength: 255
3612
+ description: OPTIONAL gate — an `owner/repo#NN` issue/epic/feature reference. When present, a leading `wait[epic]` node gates the whole sequence on that reference reaching "fully merged" (every opened slice/PR landed, §9.5) before the first issue's implementation starts.
3613
+ issues:
3614
+ type: array
3615
+ minItems: 1
3616
+ maxItems: 64
3617
+ items:
3618
+ type: string
3619
+ minLength: 1
3620
+ maxLength: 255
3621
+ description: An `owner/repo#N` issue reference to implement + converge + merge, in sequence.
3622
+ description: The ordered issues to sequence — each is implemented by a `senior:feature` agent that opens a PR, driven to convergence + merge, and the NEXT issue's implementation starts only once the prior issue has merged. At least one; at most 64 (keeps the generated graph within the compiler's node ceiling).
3623
+ # END generated:mcp-body
3624
+ responses:
3625
+ "200":
3626
+ description: The intent generated a valid delivery graph — it compiled and is STAGED for operator review; the response carries a preview and a navigational reviewUrl (no dispatch handle).
3627
+ content:
3628
+ application/json:
3629
+ schema:
3630
+ $ref: "#/components/schemas/CompileDeliveryGraphStaged"
3631
+ "400":
3632
+ description: The intent was invalid (empty/oversized issues, an unparseable reference, or an unknown target/probe) — path-qualified issues, nothing generated or staged.
3633
+ content:
3634
+ application/json:
3635
+ schema:
3636
+ $ref: "#/components/schemas/SequenceIssuesRejected"
3341
3637
  /actions/compile-delivery-graph:
3342
3638
  post:
3343
3639
  operationId: compileDeliveryGraph
@@ -12,74 +12,36 @@
12
12
  // cockpit; the response tells the agent its role ends here, turning the boundary into a self-documenting
13
13
  // protocol. A malformed graph is a 400 carrying path-qualified errors; nothing is staged.
14
14
 
15
- import { compileDeliveryGraph } from "../app/deliveryGraphCompiler.ts";
16
- import {
17
- buildProposalPreview,
18
- buildProposalRow,
19
- proposalLogicalKey,
20
- proposalReviewUrl,
21
- stageProposal,
22
- } from "../app/deliveryGraphProposals.ts";
23
- import { deliveryGraphDigest } from "../app/deliveryRunner.ts";
15
+ import { compileAndStageDeliveryGraph } from "../app/deliveryGraphStage.ts";
24
16
  import { resolvePublicOrigin } from "../app/resolveApiBase.ts";
25
17
  import { defineOperation } from "../nano-generated/operations.ts";
26
18
 
27
- const STAGED_MESSAGE =
28
- "The graph compiled and is staged for operator review. Ask the operator to preview and approve — or request modifications — in the cockpit. Dispatch is an operator action; there is no start endpoint.";
29
-
30
19
  export default defineOperation("compileDeliveryGraph", async ({ body, req }, app) => {
31
- // The runtime validates the body's SHAPE against `DeliveryGraph` before we run; the compiler adds
32
- // the SEMANTIC checks (acyclicity, edge integrity, fact resolution). A directly-invoked delegate
33
- // could still pass `undefined` — the compiler reads its input as `unknown` and maps that to a clean
34
- // `ok:false`, never a 500.
35
- const result = await compileDeliveryGraph(body);
36
- if (!result.ok) {
37
- app.log.warn("compile-delivery-graph rejected", { errors: result.errors.length });
38
- return { status: 400, body: result };
39
- }
40
-
41
- // Persist the compiled graph as a `staged` proposal — the agent's surface ends here. Superseded by
42
- // logical key + TTL inside `stageProposal`.
43
- const digest = deliveryGraphDigest(result.bpmn);
44
- const name =
45
- typeof result.resolved.name === "string" && result.resolved.name.trim() !== ""
46
- ? result.resolved.name.trim()
47
- : null;
48
- const preview = buildProposalPreview(result);
49
- await stageProposal(
20
+ // The runtime validates the body's SHAPE against `DeliveryGraph` before we run; the shared
21
+ // compile+stage flow adds the SEMANTIC checks (acyclicity, edge integrity, fact resolution) and,
22
+ // when valid, persists the compiled graph as a `staged` proposal. A directly-invoked delegate could
23
+ // still pass `undefined` the compiler reads its input as `unknown` and maps that to a clean
24
+ // `ok:false`, never a 500. The navigational `reviewUrl` is keyed to the ORIGIN this request arrived
25
+ // on (tunnel, proxy prefix, …), not the static deployment-wide NANO_WORKFORCE_BASE_URL, so the
26
+ // operator driving this instance can actually open it (#577).
27
+ const staged = await compileAndStageDeliveryGraph(
50
28
  app.data,
51
- buildProposalRow({
52
- digest,
53
- logicalKey: proposalLogicalKey(name, digest),
54
- title: name,
55
- graphJson: JSON.stringify(body),
56
- preview,
57
- nodeCount: result.resolved.nodes.length,
58
- humanNodeCount: result.humanNodes.length,
59
- sideEffectCount: result.sideEffects.length,
60
- sideEffecting: result.sideEffects.length > 0,
61
- }),
29
+ body,
30
+ JSON.stringify(body),
31
+ resolvePublicOrigin(req),
62
32
  );
33
+ if (!staged.ok) {
34
+ app.log.warn("compile-delivery-graph rejected", { errors: staged.body.errors.length });
35
+ return { status: 400, body: staged.body };
36
+ }
63
37
 
64
38
  app.log.info("compile-delivery-graph staged", {
65
- digest,
66
- nodes: result.resolved.nodes.length,
67
- humanNodes: result.humanNodes.length,
68
- sideEffects: result.sideEffects.length,
39
+ digest: staged.digest,
40
+ nodes: staged.nodeCount,
41
+ humanNodes: staged.humanNodeCount,
42
+ sideEffects: staged.sideEffectCount,
69
43
  });
70
44
 
71
45
  // The response carries a preview + a navigational pointer and NO dispatch handle (issue #460).
72
- return {
73
- status: 200,
74
- body: {
75
- status: "ready",
76
- message: STAGED_MESSAGE,
77
- digest,
78
- preview,
79
- // Navigational, human-facing link → keyed to the ORIGIN this request arrived on (tunnel,
80
- // proxy prefix, …), not the static deployment-wide NANO_WORKFORCE_BASE_URL, so the operator
81
- // driving this instance can actually open it (#577).
82
- reviewUrl: proposalReviewUrl(digest, resolvePublicOrigin(req)),
83
- },
84
- };
46
+ return { status: staged.status, body: staged.body };
85
47
  });
@@ -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
+ });