Skip to content

Projects: make the status calls from PR activity, and let the labeling job read the API - #533

Merged
jarstelfox merged 14 commits into
masterfrom
claude/projects-follow-the-work
Oct 23, 2026
Merged

jarstelfox merged 14 commits into
masterfrom
claude/projects-follow-the-work

Conversation

@jarstelfox

@jarstelfox jarstelfox commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

🤖

Projects are meant to show what people are actually working on, without anyone keeping them up. Today they rot: the "PHPStan and static analysis" plan stayed active with no lead two months after PHPStan was removed (iFixit/ifixit#64290), because the call to close it waited in Decide for a person who never came. And the labeling job can't read this API in CI at all, since it only has an app token, so it never sees a plan's status or name.

Companion PRs: iFixit/ci-actions#25 (the job) and iFixit/ifixit#65831 (the skill and the job's caller).

Changes

  • A read key for the labeling job. apiReadKeys holds SHA-256 digests; a Bearer value that matches one can GET and never write. App tokens stay refused: any app on the org, or a fork PR's token, would have passed an org check.
  • Pulldasher makes the obvious calls itself after each work sync, once projects.autoCalls is turned on: a parked plan with a new PR goes active (a done or dropped one after two), an active plan idle for 28 days is parked, a plan whose issues all closed (or whose hard end passed) with nothing open is done, a project with 3+ PRs open or merged in the last 14 days and no plan ever gets one, and a planned or active plan with no lead gets one unless a person changed it since its last PR activity. A PR the labeling job filed counts only once its label is projects.settleDays (3) old, and by when it opened, never by when a seed script labeled it; a plan whose issue closed after its last change isn't picked up. Each is recorded as pulldasher with its reason in auto_note. Decide drops the questions a call answered (all of them for a plan it finishes), and lists the last week's calls under "Changed on its own".
  • /api/v1/projects tells the job more: status, last_activity, and examples (titles a person filed first). Labels from the seed scripts run with a person's token count as the job's (projects.machineLabelRuns).

Warning

Apply migration 0042 before deploying. Every roadmap read selects auto_note, so the roadmap, Decide and the projects API all fail without it. Automatic calls ship off: run bin/auto-calls (dry run only) on prod for today and with --at 28 days ahead, read both, then set projects.autoCalls: true. The first sync after that makes every pending call at once.

QA

  • bin/auto-calls on dev lists the calls it would make; spot-check five against the board.
  • With a key in apiReadKeys, curl -H "Authorization: Bearer $KEY" .../api/v1/projects returns 200 with examples.
  • The same key on a PATCH /api/v1/roadmap/:id gets 403.
Decisions worth a look
  • No field-by-field history exists for plans, so an automatic change is stored like a person's (updated_by, status_at) plus auto_note. Reverting is editing the plan.
  • A plan in a project marked ongoing is not reopened by new PRs, the same as Decide's reopened.
  • A closed issue with PRs still open is still asked, not guessed.
  • A lead a person clears stays cleared until the project's PRs move again.
  • loadProjectPulls reads every labeled PR on each sync (marked ponytail:).

🤖 Generated with Claude Code

jarstelfox and others added 14 commits October 23, 2026 13:31
The labeling job runs with its workflow's installation token, which the
API refused, so its reads of roadmap status and project names were
skipped in CI. An installation token on the org Pulldasher serves now
reads; any write with one is refused before GitHub is asked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server is about to name the plans it makes from a project's slug, so
the words for a slug move from the board's portfolio model to
shared/model/projectLabel.ts, where both can read them. The board keeps
importing it from portfolio.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Decide asks people to confirm things that already happened: a plan
picked up or put down again, finished by its issues or its end, a new
project with no plan. Nobody answers, so plans rot. shared/model/
autoCalls.ts turns those facts into roadmap writes: picked up again by a
PR opened or labeled in after the status was set, put down after
idleDays with no PR activity, finished when its issues close or its
hard end passes with nothing open, a plan for a project with 3 or more
PRs, and a lead for a plan with none. Each rule only reads what happened
after the last status change, so a person's call stands.

decideQueue takes auto, which leaves those reasons out (answeredOnItsOwn)
and keeps the judgment calls. issue_closed now says how many PRs are
open, which is what decides whether it can be answered on its own.
labeledBy tells a person's project label from the job's, counting a
script run with a person's token as the job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lib/auto-calls.js reads what Decide reads plus every project's PRs, runs
the shared rules, and writes each call as pulldasher. A status change is
a call like a person's (updated_by, updated_at, status_at) and only lands
while the plan's status is still what was read, so a person's change in
between wins. A lead fills in only while empty and leaves updated_at
alone, so it doesn't count as an answer to Decide. Why goes in the new
roadmap_items.auto_note, with auto_at (migration 0042).

It runs when the work sync finishes, which webhooks on project issues and
the hourly pass start, before open boards are told to fetch again.
projects.autoCalls (default on) turns it off, and then Decide asks those
calls again; projects.idleDays sets when a plan is put down.
GET /api/v1/decide leaves the answered reasons out and says auto_calls.
bin/auto-calls is a dry run of what it would do now, --apply writes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /api/v1/projects adds, per project: status, its plan's status or
null; last_activity, the day its newest PR opened or merged; and
examples, up to 5 of its labeled PRs with by: person or job, a person's
first, newest first within each. A bot's label is the job's, and so is
one a seed script added with a person's token, read from the new
projects.machineLabelRuns windows (seeded with the October runs).

A label renamed after it was put on loses who added it and when
(git-manager getLabelsFromEvents), so it reads as the job's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The board's Decide, its tab count and My work now pass the server's
auto_calls flag to decideQueue, so they leave out the same reasons the
API does. Decide's guesses list gets a Changed on its own fold: each
plan Pulldasher changed in the last 7 days, with why, linked to the
plan. The dummy board shows one, a filled lead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Any GitHub App installed on the org passed the installation check, and so
would a fork PR's GITHUB_TOKEN. A read key replaces it: config.js
apiReadKeys lists SHA-256 digests of keys made for a job, a Bearer value
whose digest matches one (compared in constant time) reads, and any
write with one is refused before anything else is checked. The labeling
job sends one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
projects.autoCalls is now off unless it's true, so a deploy changes no
plan by itself. The README says how to turn it on: deploy, run
bin/auto-calls to see what it would do, then set it true.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server is the one process that makes the roadmap calls, so a script
run beside it can't make the same call twice. bin/auto-calls now only
says what the server would do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A done or dropped plan is picked up again only after 2 PRs open (or are
labeled in while open) since it was marked so: one fix after a finish
isn't a return. A parked plan still picks up on 1.

Put down also waits on the plan's latest update and on a person's last
change, so a plan someone is talking about isn't parked for quiet PRs.

A status call carries the status_at it read, and the write only lands
while status_at matches too (status_at <=> ?), so a call someone made
again since wins.

Leads are filled only on plans planned or in progress, and never on a
plan whose status moves in the same pass, which also kept a lead's note
from replacing the status call's.

A new plan starts the week of Decide's since for it, its first open PR,
not the first PR it ever had.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Decide dropped every reason Pulldasher could answer, so with the calls on
it stopped asking about work that moved with no new PR, a finished plan
whose PRs were still open, reopened work with no plan, and a project
whose plan someone removed. Nobody answered those.

Now the server works out the calls it makes this pass (callsFrom) and
Decide leaves out only the reasons those calls answer
(withoutAnswered): a picked up plan's reopened and moving, a finished
plan's issue_closed, issues_done and ended, and new where a plan is
made. /api/v1/decide and /projects-data send them as pending_calls,
kept a minute and forgotten on every project write, and the board's
Decide, tab count and My work filter by the same list. decideQueue's
auto option is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A person who changed a plan after Pulldasher's call made the plan theirs,
so the line goes. A status call leaves updated_by as pulldasher; a lead
fill leaves who changed it last alone, so that line stays until anyone
changes the plan after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The labeling job re-sorts its own filings at each sweep, so its fresh
label is a guess. A PR the job labeled (a bot, or a machineLabelRuns
window) counts toward a pick-up or an automatic new plan only once its
label is projects.settleDays old (default 3). A person's label, or a
link, counts at once. Decide's own new question still counts every PR.

Also:
- a plan whose project issue closed after it stopped is never picked
  up; Decide asks about that instead
- a done or dropped call answers every reason on its plan
- Decide and /projects-data load with no calls when reading them fails,
  and log it once until a read works
- last_activity is the team's day and counts pushes, as put down does
- an ended plan's end is the team's day, not UTC's
- pick-up reads the plan Decide reads for the project (lastPlan)
- examples leave out PRs closed without merging

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the round 3 review:
- a PR the labeling job filed wakes a plan only by when it opened, never
  by when its label went on, so a seed run filing old PRs isn't new work
  (fixbot#3312, opened Sep 11 and seed-labeled Oct 10, picked up a plan
  parked Oct 1). A person labeling an open PR in still counts.
- a new plan is made for any tracked project with 3 or more settled PRs
  open or merged in the last 14 days and no plan ever, so a burst that
  already merged gets one too; put down parks it once it's quiet.
  Decide's own new question is unchanged.
- an empty lead stays empty when a person changed the plan after its
  project's PRs last moved, since they may have cleared it.
- pick-up's closed-issue check reads the plan's last change, as Decide
  does.
- bin/auto-calls --at YYYY-MM-DD runs the rules as of that day, and the
  README's steps to turn the calls on run it for today and 28 days ahead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jarstelfox
jarstelfox marked this pull request as ready for review October 23, 2026 21:41
@jarstelfox
jarstelfox merged commit b4a774d into master Oct 23, 2026
1 check passed
@jarstelfox
jarstelfox deleted the claude/projects-follow-the-work branch October 23, 2026 21:48
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.

1 participant