Skip to content

docs(examples): a portfolio manager flow that plans the week and reports what it asked for - #579

Merged
edgehero merged 1 commit into
mainfrom
docs/506-portfolio-example
Oct 4, 2026
Merged

edgehero merged 1 commit into
mainfrom
docs/506-portfolio-example

Conversation

@edgehero

@edgehero edgehero commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

Closes #506.

A worked example of the portfolio manager: a flow an operator copies, commits and runs as a flagged cron job, plus docs/portfolio-manager.md explaining what the harness guarantees and what the flow must not rely on.

The example (examples/portfolio-manager/)

  • priorities.md: the operator's goals per project (goal this month, deadlines, "on fire" notes, a minimum weight), and in its front matter the repository and issue the report goes to. The flow treats this file as the operator's instructions and anything read from a forge as data.
  • triggers.portfolio.example.json: a Monday 06:00 UTC cron entry with "portfolio": true. It deliberately does not set "github": true, which would hand the deployment's credential to a job that reads untrusted text; a narrow token is bound through run.secrets as REPORT_GH_TOKEN instead.
  • .pi/skills/portfolio-manager/: the flow reads the snapshot and the priorities, optionally reads numbers (milestones and label counts, never titles or bodies) from the project repositories, chooses relative weights, writes /outbox/priorities.json with the snapshot's plan id as its basis, and runs report.mjs.
  • report.mjs: Node only, no dependencies. It writes a short report that opens with the last plan's outcome from the snapshot, then what was requested this week and the spend so far, and posts it as an issue comment through gh or as {text} to a webhook. It always exits 0, so a failed post never stops the plan from being collected, and it says "requested", never "applied", because the worker applies after the job exits. --dry-run prints and posts nothing.
  • .pi/skills/portfolio-fixture/ and plan.fixture.json: a flow that copies a fixed plan, for a first zero-spend run without any model judgement.

The docs page

What the harness enforces, what the snapshot holds and why it has no text, what the flow must not rely on, how to give the manager's own folder a project with a floor (otherwise its own runs are refused when the split leaves nothing for them), the egress lines to add, the threat model in two sentences, and the zero-spend test. The worked numbers are the ones the allocator really produces for the example's envelope, and a test rebuilds them from the allocator so they cannot drift. The page says plainly what the local runs showed: a 3B model ran the fixture flow but not the judgement flow, while a 7B model with a 16k context ran the judgement flow and its plans applied, though not on every run; pick a model that follows the steps.

Specs

No behaviour changes. UNCHANGED, checked: INT-OUTBOX-CONTRACT, INT-TRIGGERS-FILE-CONTRACT, INT-CONTAINER-JOB-INPUTS, INT-PRIORITIES-PLAN-CONTRACT.

Tests

worker/test/examples.test.mjs (the trigger parses through the shared loader with the flag and without github, the skill names, the fixture against the page's envelope, the page's numbers rebuilt from the allocator, every spec ID the page names exists) and worker/test/portfolio-report.test.mjs (a byte-exact dry run, a refused last attempt, no channel, a loopback webhook posted once, a gh stub receiving exactly issue comment <n> --repo <r> --body-file -, a failed post still exiting 0, control and markup characters in reasons, hung webhook and gh posts timing out, a throwing clock still exiting 0, invalid repository and webhook URLs refused, redirects refused, and the flow's plan carrying no validUntil). The by-hand steps ran at zero spend with local models, and the real GitHub post was replaced by a gh stub. With qwen2.5:7b (16k context) the judgement flow wrote plans that followed priorities.md and applied; marking platform "on fire" raised its weight from 1 to 3 on the next run. Of four later runs after the flow stopped writing validUntil, one plan applied, one was refused for a quoted null basis (the template now spells it out), and two wrote no plan. qwen2.5:3b ran the fixture flow only.

Webhooks

A webhook URL is a credential, so the report posts only over https (plain http only to a loopback host, for local testing) and refuses redirects. Agent text in the report sits in code spans with control, format and markup characters removed, and every post has a timeout so a hung endpoint cannot hold the job past its plan's collection.

Where this follows the issue loosely

  • The job's own log holds only the runner's events, so the report is read by running report.mjs --dry-run on the job's retained sandbox files; the docs give the command.
  • A revert in the panel is not a portfolio job's attempt, so the next report shows the reverted split under "In force" rather than in "Last plan".
  • An extra portfolio-fixture flow is shipped for the first zero-spend run, so nobody has to hand-edit a copy of the skill.
  • The panel view the docs point to is b (the ALLOCATION view in feat(admin): the budget split tools, the write guard and the allocation view #576).

@edgehero
edgehero force-pushed the docs/506-portfolio-example branch 3 times, most recently from b1bbaf2 to 8d4a583 Compare October 4, 2026 14:13
…rts what it asked for

Refs #506

#504 and #505 give the mechanics (an envelope, a deterministic split, a
snapshot in and a plan out). Deciding what matters this week is judgement,
so it belongs in a flow the operator owns. This adds the example to copy and
the page that says what the harness guarantees.

examples/portfolio-manager/:
- priorities.md: the goals template, one section per envelope entry, with
  report-repo and report-issue front matter for the GitHub channel.
- triggers.portfolio.example.json: the weekly cron entry, portfolio: true,
  excludeTools edit, no github (the deployment credential must not reach a
  job that reads issue text); the report token is REPORT_GH_TOKEN through
  run.secrets, since GH_TOKEN and GITHUB_TOKEN are MINTED_TOKEN_VARS.
- plan.fixture.json: weights 3/2/1/0 with basis null, for the zero spend test.
- .pi/skills/portfolio-manager/SKILL.md: the flow, every command spelled out.
  The plan carries no validUntil, so it stays in force for the envelope's
  maxPlanDays (14 in the example) unless a newer plan replaces it, which
  outlives a weekly run. A flow computing "next Monday" was dropped: a model
  ignored the date command and wrote an instant that lapsed before the next
  run. The basis line spells out the bare null for a first plan.
- .pi/skills/portfolio-manager/report.mjs: Node only, no dependencies. The
  report opens with "Last plan: applied / refused (reason)" from
  snapshot.lastAttempt, then what this run requested (never "applied": the
  host decides after exit), then spend from the micro-dollar fields. It posts
  to the first channel set: gh issue comment <n> --repo <r> --body-file -
  with GH_TOKEN=$REPORT_GH_TOKEN for that call, or a POST of {text} to
  REPORT_WEBHOOK_URL. --dry-run posts nothing. The exit code is 0 on every
  path, a throw included, because the plan is collected only after a
  completed exit; a failed or timed out post prints report-not-sent: <token>
  and never the URL. A webhook URL is a credential, so plain http is taken
  for a loopback host only and no redirect is followed. The report repo
  must start with a letter or a digit on both sides (gh must never read it
  as an option). Agent reasons and member labels are shown in code spans
  after a control, format and separator character gate, with < and >
  replaced so Slack reads no <!here> or <url|text>. A refusal's field is
  checked against a vendored copy of PLAN_FIELDS. A snapshot that exists
  but is unreadable or not JSON is named as such.
- .pi/skills/portfolio-fixture/SKILL.md: the one command test flow.

docs/portfolio-manager.md, linked from README.md, docs/triggers.md and
examples/README.md: the split of responsibility, the snapshot (ids, numbers
and operator labels), what the flow must not rely on, the egress lines, the
threat model, the zero spend test (reading the report with --dry-run from the
retained sandbox, since the job log holds no command output), and revert
(ALLOCATION view, key b, r on a row). The manager's folder sits in project ops with a $5 floor (at or above
PI_MAX_COST_USD), or its own job is refused allocation-cap once a plan gives
_other nothing. The worked numbers are the allocator's: neutral with _other
at default weight 1 is shop $28.75, platform $28.75, ops $23.75, _other
$18.75 (not 50/50); the fixture gives $47.50/$35.00/$17.50/$0.00 unclamped;
a next 1/6/1/0 plan is clamped to $25.00/$60.00/$15.00.

Tests:
- worker/test/examples.test.mjs: the trigger parses through parseTriggers
  with portfolio true, no github, no reserved secret name, and matches the
  page's copy; every example skill's frontmatter name matches its directory
  and SKILL_NAME_RE; plan.fixture.json passes parsePlan against the page's
  own envelope; the page's table and clamp sentence are generated from
  neutralAllocation and allocate; every spec ID the page names is a
  heading in specs/; the three links exist.
- worker/test/portfolio-report.test.mjs imports the example's report.mjs
  (examples/ is in no workspace's test glob): a byte exact dry run on a
  snapshot built by buildPortfolioSnapshot, refused lastAttempt reasons,
  no snapshot, an unreadable or broken snapshot, no channel in a real child
  process (exit 0), a loopback node:http webhook POSTed once, a gh stub on
  PATH recording argv, token and stdin, failed posts, a hung webhook and a
  hung gh timing out (injected timeouts), a throw exiting 0, http webhooks
  off loopback (lookalikes such as localhost.evil.com included) and a
  redirect refused, gh's own output kept out of the job's, bad repos and issues, member and
  project id rows, a bidi override, a bell, a line separator and Slack
  markup in the byte exact dry run, the PLAN_FIELDS copy pinned to
  priorities.mjs, and injected clocks throughout.
- examples.test.mjs also checks that the flow's plan shape and the fixture
  carry no validUntil and that the resolved validity is now plus
  maxPlanDays (red when a validUntil is put back into the shape).
- Mutations, each red then green: the exit code flipped on a failed post,
  "Applied this week" printed, basis dropped from the fixture, and the
  review's set (gate dropped, repo rule dropped, either timeout dropped,
  catch-all returning 1, lastAttempt reason or field unchecked, member
  label raw, any protocol or any http host taken, spend row id unchecked,
  redirect followed, < and > kept, an unreadable snapshot read as missing,
  a loopback prefix match, gh's stdio inherited).

Lab (zero spend, local Ollama priced in the overlay, own Valkey, the image
built from this tree): the fixture run applied 88fed590e1dd444e with the
page's numbers, writer portfolio-job; report.mjs ran in the job image with
exit 0 for no-channel, gh failing and webhook failing, and a gh stub got the
exact argv. qwen2.5:3b did not run the judgement flow (no plan, or one
refused as plan-parse-error with the job still completed). qwen2.5:7b with
a 16k context ran it with the plain task: basis the fixture's id, weights
from priorities.md, applied; after platform was marked on fire and
committed, the next plan raised platform from 1 to 3 and applied unclamped
(platform $20.71 to $42.14). With validUntil gone, four more 7B runs on a
fresh deployment: one applied (basis null, validUntil resolved to applied
plus 14 days), two wrote no plan, and one wrote "basis": "null" (refused
plan-invalid, job completed), which the flow now spells out. The page
records all of it and the context size.
The real GitHub post (by hand step 5) was not run.

Specs: UNCHANGED, checked: INT-OUTBOX-CONTRACT, INT-TRIGGERS-FILE-CONTRACT,
INT-CONTAINER-JOB-INPUTS, INT-PRIORITIES-PLAN-CONTRACT. No behaviour changes.

Signed-off-by: Rob Boerman <robboerman@live.nl>
@edgehero
edgehero force-pushed the docs/506-portfolio-example branch from 8d4a583 to 9ad97ee Compare October 4, 2026 14:38
@edgehero
edgehero merged commit 5913653 into main Oct 4, 2026
7 checks passed
@edgehero
edgehero deleted the docs/506-portfolio-example branch October 4, 2026 20:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Example portfolio-manager flow: plan the week from a snapshot and a priorities file, then report to the operator with its own tools

1 participant