Repository navigation
docs(examples): a portfolio manager flow that plans the week and reports what it asked for - #579
Merged
Merged
Conversation
edgehero
force-pushed
the
docs/506-portfolio-example
branch
3 times, most recently
from
October 4, 2026 14:13
b1bbaf2 to
8d4a583
Compare
…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
force-pushed
the
docs/506-portfolio-example
branch
from
October 4, 2026 14:38
8d4a583 to
9ad97ee
Compare
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdexplaining 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 throughrun.secretsasREPORT_GH_TOKENinstead..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.jsonwith the snapshot's plan id as its basis, and runsreport.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 throughghor 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-runprints and posts nothing..pi/skills/portfolio-fixture/andplan.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 withoutgithub, 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) andworker/test/portfolio-report.test.mjs(a byte-exact dry run, a refused last attempt, no channel, a loopback webhook posted once, aghstub receiving exactlyissue comment <n> --repo <r> --body-file -, a failed post still exiting 0, control and markup characters in reasons, hung webhook andghposts timing out, a throwing clock still exiting 0, invalid repository and webhook URLs refused, redirects refused, and the flow's plan carrying novalidUntil). The by-hand steps ran at zero spend with local models, and the real GitHub post was replaced by aghstub. With qwen2.5:7b (16k context) the judgement flow wrote plans that followedpriorities.mdand applied; marking platform "on fire" raised its weight from 1 to 3 on the next run. Of four later runs after the flow stopped writingvalidUntil, one plan applied, one was refused for a quotednullbasis (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
report.mjs --dry-runon the job's retained sandbox files; the docs give the command.portfolio-fixtureflow is shipped for the first zero-spend run, so nobody has to hand-edit a copy of the skill.b(the ALLOCATION view in feat(admin): the budget split tools, the write guard and the allocation view #576).