From b155c93a12a97f5fce020a714f978df94f5aa01d Mon Sep 17 00:00:00 2001 From: bompus Date: Fri, 13 Nov 2026 11:07:08 -0700 Subject: [PATCH 1/3] An unbuilt stage of approved work is open, never parked A later phase, 'PR N' or follow-up pull request of a design the user approved is written as its own open ledger line that starts 'Approved, not built:', is a candidate for the offer when the stage before it lands and on every 'next?', and is the project's list (no issue). check-open-work.mjs lists such lines and reports a deferred one as a finding; hr-what-next's floor and hr-handoff's reconciliation reference name them. --- CHANGELOG.md | 1 + rules/core.md | 11 +++++++++- .../hr-handoff/references/reconciliation.md | 3 ++- skills/hr-what-next/SKILL.md | 7 ++++-- .../hr-what-next/scripts/check-open-work.mjs | 20 ++++++++++++++++- .../test/check-open-work.test.mjs | 22 +++++++++++++++++++ 6 files changed, 59 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99e20a6d..628882d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Notable changes to house-rules. Versions follow [semantic versioning](https://se ## Unreleased +- An unbuilt stage of work the user approved (a later phase, "PR N", a follow-up pull request of an approved design) is open work, never a parked item: `rules/core.md` § Session purpose says to write it as its own open ledger line that starts "Approved, not built:", and § End of every reply makes it a candidate for the offer when the stage before it lands and on every "next?". That line is the project's list, kept in the plan's Ledger with no issue. `hr-what-next` lists such lines in its open-work floor, `scripts/check-open-work.mjs` prints them as "approved, not built" and reports a deferred line that starts this way as a finding, and `hr-handoff`'s reconciliation reference says the checker carries them forward like any open line. - A delegated worker never gives the user an offer or a question card: `rules/core.md` § End of every reply (which every session reads; the Offers replacements of `coded-offers` and `model-default-questions` cannot drop it) says it returns the choice, with options and a recommendation, in its final report to its parent, which decides or asks the user. The brief-contents rules in `rules/references/coordination-and-isolation.md` (default rules), `rules/references/multi-agent.md` and the `multi-agent` modifier say to put that in every worker's brief, and the lines that treated a child's question as legitimate (the T3 delegated-task wake and the two-minute check) now treat it as a brief that lacked the rule or a child that broke it; the `question-cards` modifier's answer-a-delegated-card line says it applies only when one was raised despite the rule. - The T3 steps that read a thread or list threads state their own start, `limit` and `maxCharsPerItem`, so they read the same on T3 builds that differ in order and defaults (a read with no `afterPosition` returns the oldest items first on older builds and the newest first on newer ones; pingdotgg/t3code #33428): `rules/references/multi-agent.md` (the child check, the progress and end-of-thread reads, a finished child's result) and `hr-handoff`'s `references/successor-thread.md` (the header read for `projectId`, the list and the read-back after a settle). A header-only read is `limit: 1`, `maxCharsPerItem: 1`, `runLimit: 1`; the end of a thread is read from `afterPosition` max(0, `itemCount` minus 60), highest `position` newest. - `hr-sqlite`'s `references/evidence.md` adds a rerun of three measured rows (planner statistics after `PRAGMA optimize`, `synchronous` FULL against NORMAL, and `backup()` against `VACUUM INTO`) on Node 26.13.1 with SQLite 3.54.1. The first figures stay with their versions; the section says the new host, disk and data differ, so the gaps are not attributable to Node or SQLite. Planner statistics still fixed the query (20.4, 6.7 and 8.3 ms to 0.0059 ms); the `synchronous` gap is 7.5 times on this disk, not 87. diff --git a/rules/core.md b/rules/core.md index 8d3c8ab5..1bbd2482 100644 --- a/rules/core.md +++ b/rules/core.md @@ -19,7 +19,8 @@ A completed step, a status answer or a notification does not skip that check: "next I'll…" line or an offer to continue does not end the work. 2. The current request needs a decision or the check finds a ready candidate needing selection: give an offer using § Offers. Include decisions needed to - finish authorized work and owned changes that need landing. Present the next + finish authorized work, owned changes that need landing and the next unbuilt + stage of approved work (§ Session purpose). Present the next ready decision on the session's purpose automatically; the user need not ask "next?". Parked items (§ Session purpose) are not candidates. 3. Otherwise, answer the current request and state the remaining blockers or @@ -219,6 +220,14 @@ Finishing the purpose does not widen that scope. Keep parked items in the ledger counting them does not make them eligible for offers. Report a security or production risk at once. +An unbuilt stage of work the user approved (a later phase, "PR N", a follow-up +pull request of an approved design) is open work, never a parked item. When the +stage is deferred, write it as its own open ledger line that starts "Approved, not +built:" (`- [ ]`, not deferred) and names the stage and what it waits on. It is a +candidate for the offer when the stage before it lands and on every "next?" +(§ End of every reply); one such line per stage is the project's list, kept in the +plan's Ledger, and no issue is created for it unless the user asks. + A parked item becomes its own top-level conversation when the user asks, or under their standing preference for spin-offs. When parked items are in the requested scope, offer them together at a natural stopping point; each new diff --git a/skills/hr-handoff/references/reconciliation.md b/skills/hr-handoff/references/reconciliation.md index 0fac9f81..606c40f8 100644 --- a/skills/hr-handoff/references/reconciliation.md +++ b/skills/hr-handoff/references/reconciliation.md @@ -23,7 +23,8 @@ A predecessor plan needs no conversion. Name its `plan.md` as the source `file`; the checker reads each item line of its `## Ledger` section (`- [ ]`, `- [~]`, `- [x]`, `- [-]`) as a record named `L` (`L12`). Other text in the section is not read, so turn any follow-up written there into an item line -before the handoff. Item lines elsewhere in the file are not read either. The +before the handoff. An unbuilt stage of approved work is such an open line that starts "Approved, not built:", +so the checker carries it forward like any open item. Item lines elsewhere in the file are not read either. The checker counts them and reports a gap that names the first five lines (the open or deferred ones, when there are any), so the receipt is partial and cannot claim complete coverage until they move into the ledger section. diff --git a/skills/hr-what-next/SKILL.md b/skills/hr-what-next/SKILL.md index a2f57c0c..7400fffa 100644 --- a/skills/hr-what-next/SKILL.md +++ b/skills/hr-what-next/SKILL.md @@ -22,8 +22,11 @@ completed state before the completion message; a report alone does not do so. floor whatever the scope: every question from an earlier offer in this conversation that the user has not answered, every item deferred with only the user's selection as its trigger, every owned uncommitted file, unpushed commit - or held checkout, and the number of other live ledger items (deferred, - awaiting selection, blocked or follow-up) with a link to the ledger. + or held checkout, every open line that starts "Approved, not built:" (an + unbuilt stage of approved work: it is not parked, so it is eligible for the + offer; `scripts/check-open-work.mjs` lists it), and the number of other live + ledger items (deferred, awaiting selection, blocked or follow-up) with a link + to the ledger. When the session keeps a plan file, `scripts/check-open-work.mjs ` computes this floor (add `--checkout ` for each checkout touched, or `--app-managed ` for one an app retires, which is reported but not a diff --git a/skills/hr-what-next/scripts/check-open-work.mjs b/skills/hr-what-next/scripts/check-open-work.mjs index b0aa24a6..4e3695e2 100644 --- a/skills/hr-what-next/scripts/check-open-work.mjs +++ b/skills/hr-what-next/scripts/check-open-work.mjs @@ -8,6 +8,9 @@ // trigger text, which must be the user plus a verb such as selects, picks, decides, asks or approves and // at most a short object ("it", "the backlog"); a trigger with a date, event or another condition // stays an ordinary deferred item. The group is a subset of `open` and does not change the exit code. +// "approved, not built" lists the open lines that start "Approved, not built": the stages of approved work that +// were deferred, which stay candidates for every "next?" and the offer after the stage before them lands. A deferred +// (`[~]`) line that starts this way is a finding: it was filed in the wrong bucket. // "close-out due" prints when every open item is deferred and the plan is not completed: the session's work // is finished, so the user owes a decision on each item and the reply offers All done or Pause. A plan whose // Status is paused without a done line recording the user's Pause choice is a finding; with one, the pause @@ -59,6 +62,7 @@ import { basename, dirname, join, resolve, sep } from "node:path"; * @property {string | null} owner * @property {string[]} open * @property {string[]} awaitingUser + * @property {string[]} unbuiltStages * @property {string[]} unansweredOffers * @property {string[]} stale * @property {string[]} recheckDue @@ -77,6 +81,8 @@ import { basename, dirname, join, resolve, sep } from "node:path"; const usage = "usage: check-open-work.mjs [--checkout ]... [--app-managed ]... [--scratch ] [--repo ] [--offline] [--today ] [--archive] [--json] [--session --turn ]\n" + 'The output lists deferred items whose trigger is only the user\'s own action ("user selects", "user asks for the backlog") as "awaiting your yes/no"; this is a text match, see the header.'; +// An unbuilt stage of work the user approved (a later phase, "PR N"): an open line that starts this way, never a deferred one. +const UNBUILT_STAGE = /^approved,? not built\b/i; const TERMINAL_STATUS = /^(completed?|done|archived)\b/i; const COMPLETED_STATUS = /^completed\b/i; const PAUSED_STATUS = /^paused\b/i; @@ -700,6 +706,7 @@ async function run(options) { owner: plan.owner ?? null, open: [], awaitingUser: [], + unbuiltStages: [], unansweredOffers: [], stale: [], recheckDue: [], @@ -732,6 +739,16 @@ async function run(options) { ); } report.awaitingUser = open.filter(awaitsUser).map(row); + report.unbuiltStages = open + .filter((item) => item.mark === " " && UNBUILT_STAGE.test(item.text)) + .map(row); + for (const item of open) { + if (item.mark === "~" && UNBUILT_STAGE.test(item.text)) { + report.findings.push( + `L${item.line} is an unbuilt stage of approved work filed as deferred; make it an open line (§ Session purpose)`, + ); + } + } if (TERMINAL_STATUS.test(plan.status ?? "") && open.length) { report.findings.push( `Status says "${plan.status}" but ${open.length} ledger item(s) are open or deferred`, @@ -896,6 +913,7 @@ async function main() { for (const [label, rows] of /** @type {[string, string[]][]} */ ([ ["error", report.errors], ["open", report.open], + ["approved, not built", report.unbuiltStages], ["awaiting your yes/no", report.awaitingUser], ["unanswered offer", report.unansweredOffers], ["possibly finished", report.stale], @@ -923,7 +941,7 @@ async function main() { ? "result: nothing open" : code === 1 ? `result: invalid input${report.invalidPlan ? " (the checks that read only the plan text still ran)" : ""}` - : `result: ${report.open.length} open item(s) (${report.awaitingUser.length} awaiting your yes/no), ${report.findings.length} finding(s)`, + : `result: ${report.open.length} open item(s) (${report.awaitingUser.length} awaiting your yes/no, ${report.unbuiltStages.length} approved, not built), ${report.findings.length} finding(s)`, ); } process.exit(code); diff --git a/skills/hr-what-next/test/check-open-work.test.mjs b/skills/hr-what-next/test/check-open-work.test.mjs index e88772bc..4caece01 100644 --- a/skills/hr-what-next/test/check-open-work.test.mjs +++ b/skills/hr-what-next/test/check-open-work.test.mjs @@ -107,6 +107,28 @@ test("a deferral whose only trigger is the user's own action is listed as awaiti expect(text).toContain("3 awaiting your yes/no"); }); +test("an open line that starts 'Approved, not built' is listed as an unbuilt approved stage, a deferred one is a finding", () => { + const { plan } = setup({ + ledger: [ + "- [ ] Approved, not built: PR 5 betting-line points on the card (design approved 1a; after PR 4 lands)", + "- [ ] approved, not built: weights history stage 2", + "- [~] Approved, not built: parked by mistake - trigger: user selects", + "- [ ] a: an ordinary open line that says approved, not built later", + "- [x] Approved, not built: PR 3 (built) ... evidence: merged", + ], + }); + const { code, report } = check(plan); + expect(code).toBe(2); + expect(report.unbuiltStages).toHaveLength(2); + expect(report.unbuiltStages.join("\n")).toContain("PR 5 betting-line points"); + expect(report.findings.join("\n")).toContain( + "L8 is an unbuilt stage of approved work filed as deferred", + ); + const text = spawnSync(process.execPath, [script, plan], { encoding: "utf8" }).stdout; + expect(text).toContain("approved, not built: L6 [ ] Approved, not built: PR 5"); + expect(text).toContain("2 approved, not built"); +}); + test("a deferral with a dated, event or compound trigger is not listed as awaiting a yes or no", () => { const { plan } = setup({ ledger: [ From 8653cfc6b1cece2862d3a1fb1cda7eeefa7961bc Mon Sep 17 00:00:00 2001 From: bompus Date: Fri, 13 Nov 2026 11:25:52 -0700 Subject: [PATCH 2/3] Unbuilt stages: the line ends with trigger:, names its approving offer without counting as an open one, and keeps its whole text in the report An open stage line that names the offer approving it no longer counts as an unanswered offer; the 'approved, not built' group prints the whole line so the trigger is not cut at 90 characters; the rule asks for a trailing 'trigger:' (which the stop hook already reads as a wait) and 'recheck:' for another thread, and says a session that lands a stage reads the plan holding the next one. --- rules/core.md | 6 ++-- .../hr-what-next/scripts/check-open-work.mjs | 6 ++-- .../test/check-open-work.test.mjs | 35 ++++++++++++++++++- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/rules/core.md b/rules/core.md index 1bbd2482..26893289 100644 --- a/rules/core.md +++ b/rules/core.md @@ -223,10 +223,12 @@ production risk at once. An unbuilt stage of work the user approved (a later phase, "PR N", a follow-up pull request of an approved design) is open work, never a parked item. When the stage is deferred, write it as its own open ledger line that starts "Approved, not -built:" (`- [ ]`, not deferred) and names the stage and what it waits on. It is a +built:" (`- [ ]`, not deferred), names the stage and ends with `trigger: ` (and `recheck: YYYY-MM-DD` when that is another thread). It is a candidate for the offer when the stage before it lands and on every "next?" (§ End of every reply); one such line per stage is the project's list, kept in the -plan's Ledger, and no issue is created for it unless the user asks. +plan's Ledger, and no issue is created for it unless the user asks. A session that +lands a stage reads the plan that holds the next one. A parked item becomes its own top-level conversation when the user asks, or under their standing preference for spin-offs. When parked items are in the diff --git a/skills/hr-what-next/scripts/check-open-work.mjs b/skills/hr-what-next/scripts/check-open-work.mjs index 4e3695e2..2c7e2bd1 100644 --- a/skills/hr-what-next/scripts/check-open-work.mjs +++ b/skills/hr-what-next/scripts/check-open-work.mjs @@ -680,7 +680,8 @@ function unansweredOffers(items) { /** @type {Map} */ const unanswered = new Map(); for (const item of items) { - if (item.mark === " ") { + // A stage that names the offer approving it records an answer, not an open offer. + if (item.mark === " " && !UNBUILT_STAGE.test(item.text)) { for (const id of offersNamed(item.text)) { if (!closed.has(id) && !unanswered.has(id)) { unanswered.set(id, row(item)); @@ -739,9 +740,10 @@ async function run(options) { ); } report.awaitingUser = open.filter(awaitsUser).map(row); + // Whole text: the stage's `trigger:` is at the end, and a report needs it to say what the stage waits on. report.unbuiltStages = open .filter((item) => item.mark === " " && UNBUILT_STAGE.test(item.text)) - .map(row); + .map((item) => `L${item.line} [ ] ${item.text}`); for (const item of open) { if (item.mark === "~" && UNBUILT_STAGE.test(item.text)) { report.findings.push( diff --git a/skills/hr-what-next/test/check-open-work.test.mjs b/skills/hr-what-next/test/check-open-work.test.mjs index 4caece01..ad17696f 100644 --- a/skills/hr-what-next/test/check-open-work.test.mjs +++ b/skills/hr-what-next/test/check-open-work.test.mjs @@ -110,7 +110,7 @@ test("a deferral whose only trigger is the user's own action is listed as awaiti test("an open line that starts 'Approved, not built' is listed as an unbuilt approved stage, a deferred one is a finding", () => { const { plan } = setup({ ledger: [ - "- [ ] Approved, not built: PR 5 betting-line points on the card (design approved 1a; after PR 4 lands)", + "- [ ] Approved, not built: PR 5 betting-line points on the card (design approved 1a) trigger: PR 4 lands", "- [ ] approved, not built: weights history stage 2", "- [~] Approved, not built: parked by mistake - trigger: user selects", "- [ ] a: an ordinary open line that says approved, not built later", @@ -129,6 +129,39 @@ test("an open line that starts 'Approved, not built' is listed as an unbuilt app expect(text).toContain("2 approved, not built"); }); +test("approved stages that name answered offers are not unanswered offers, and a genuinely open offer still counts", () => { + const { plan } = setup({ + ledger: [ + "- [ ] Approved, not built: search stage, approved in Offer SEARCH-1 trigger: PR 4 lands", + "- [ ] Approved, not built: export stage, approved in Offer SEARCH-2 trigger: PR 5 lands", + "- [ ] Approved, not built: sync stage, approved in Offer SEARCH-3 trigger: PR 6 lands", + "- [ ] Offer DEMO-9 question 1 still unanswered", + ], + }); + const { report } = check(plan); + expect(report.unansweredOffers).toHaveLength(1); + expect(report.unansweredOffers.join("\n")).toContain("DEMO-9"); +}); + +test("an approved stage keeps its whole text, trigger included, in text and JSON output", () => { + const trigger = "trigger: the export stage lands and its schema is frozen after the review round"; + const { plan } = setup({ + ledger: [`- [ ] Approved, not built: ${"a long stage description ".repeat(5)}${trigger}`], + }); + const { report } = check(plan); + expect(report.unbuiltStages[0]).toContain(trigger); + const text = spawnSync(process.execPath, [script, plan], { encoding: "utf8" }).stdout; + expect(text).toContain(trigger); +}); + +test("an open approved stage with a recheck date is listed due when the date arrives", () => { + const { plan } = setup({ + ledger: ["- [ ] Approved, not built: PR 5 ... trigger: upstream release recheck: 2026-10-01"], + }); + const { report } = check(plan, "--today", "2026-10-02"); + expect(report.recheckDue.join("\n")).toContain("overdue since 2026-10-01"); +}); + test("a deferral with a dated, event or compound trigger is not listed as awaiting a yes or no", () => { const { plan } = setup({ ledger: [ From 267646b2060bcfad4ed6f309fcc1f0a21fc04210 Mon Sep 17 00:00:00 2001 From: bompus Date: Fri, 13 Nov 2026 11:37:04 -0700 Subject: [PATCH 3/3] Unbuilt stages: carry trigger: anywhere before a final done-when, count a pending stage offer The rule no longer says the line ends with trigger:, which collided with done-when's last-field rule; a stage line that says its offer was asked and is pending counts as an unanswered offer. --- rules/core.md | 5 ++-- .../hr-what-next/scripts/check-open-work.mjs | 7 +++--- .../test/check-open-work.test.mjs | 24 +++++++++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/rules/core.md b/rules/core.md index 26893289..66f19e97 100644 --- a/rules/core.md +++ b/rules/core.md @@ -223,8 +223,9 @@ production risk at once. An unbuilt stage of work the user approved (a later phase, "PR N", a follow-up pull request of an approved design) is open work, never a parked item. When the stage is deferred, write it as its own open ledger line that starts "Approved, not -built:" (`- [ ]`, not deferred), names the stage and ends with `trigger: ` (and `recheck: YYYY-MM-DD` when that is another thread). It is a +built:" (`- [ ]`, not deferred), names the stage and carries `trigger: ` (and `recheck: YYYY-MM-DD` when that is another thread; a `done-when:` +clause, if any, comes last). It is a candidate for the offer when the stage before it lands and on every "next?" (§ End of every reply); one such line per stage is the project's list, kept in the plan's Ledger, and no issue is created for it unless the user asks. A session that diff --git a/skills/hr-what-next/scripts/check-open-work.mjs b/skills/hr-what-next/scripts/check-open-work.mjs index 2c7e2bd1..24d21167 100644 --- a/skills/hr-what-next/scripts/check-open-work.mjs +++ b/skills/hr-what-next/scripts/check-open-work.mjs @@ -83,6 +83,7 @@ const usage = 'The output lists deferred items whose trigger is only the user\'s own action ("user selects", "user asks for the backlog") as "awaiting your yes/no"; this is a text match, see the header.'; // An unbuilt stage of work the user approved (a later phase, "PR N"): an open line that starts this way, never a deferred one. const UNBUILT_STAGE = /^approved,? not built\b/i; +const PENDING_OFFER = /\b(?:asked|pending|unanswered)\b/i; const TERMINAL_STATUS = /^(completed?|done|archived)\b/i; const COMPLETED_STATUS = /^completed\b/i; const PAUSED_STATUS = /^paused\b/i; @@ -680,8 +681,8 @@ function unansweredOffers(items) { /** @type {Map} */ const unanswered = new Map(); for (const item of items) { - // A stage that names the offer approving it records an answer, not an open offer. - if (item.mark === " " && !UNBUILT_STAGE.test(item.text)) { + // An offer a stage line names is the approval it records, unless the line says the offer was asked and is pending. + if (item.mark === " " && (!UNBUILT_STAGE.test(item.text) || PENDING_OFFER.test(item.text))) { for (const id of offersNamed(item.text)) { if (!closed.has(id) && !unanswered.has(id)) { unanswered.set(id, row(item)); @@ -740,7 +741,7 @@ async function run(options) { ); } report.awaitingUser = open.filter(awaitsUser).map(row); - // Whole text: the stage's `trigger:` is at the end, and a report needs it to say what the stage waits on. + // Whole text: a report needs the stage's `trigger:`, which a 90-character row can cut off. report.unbuiltStages = open .filter((item) => item.mark === " " && UNBUILT_STAGE.test(item.text)) .map((item) => `L${item.line} [ ] ${item.text}`); diff --git a/skills/hr-what-next/test/check-open-work.test.mjs b/skills/hr-what-next/test/check-open-work.test.mjs index ad17696f..d78c805d 100644 --- a/skills/hr-what-next/test/check-open-work.test.mjs +++ b/skills/hr-what-next/test/check-open-work.test.mjs @@ -143,6 +143,30 @@ test("approved stages that name answered offers are not unanswered offers, and a expect(report.unansweredOffers.join("\n")).toContain("DEMO-9"); }); +test("a stage line that says its offer was asked and is pending counts as an unanswered offer", () => { + const { plan } = setup({ + ledger: [ + "- [ ] Offer DEMO-1 question 1 still unanswered", + "- [ ] Offer DEMO-2 question 1 still unanswered", + "- [ ] Approved, not built: export stage, asked in Offer NEXT-3, reply pending trigger: PR 4 lands", + ], + }); + const { report } = check(plan); + expect(report.unansweredOffers).toHaveLength(3); + expect(report.warnings.join("\n")).toContain("3 offers are open and unanswered"); +}); + +test("a stage line with trigger, recheck and a final done-when clause is accepted", () => { + const { plan } = setup({ + ledger: [ + "- [ ] Approved, not built: PR 5 trigger: PR 4 lands recheck: 2099-01-01 done-when: pr-merged acme/tool#5", + ], + }); + const { report } = check(plan, "--offline"); + expect(report.errors).toEqual([]); + expect(report.unbuiltStages).toHaveLength(1); +}); + test("an approved stage keeps its whole text, trigger included, in text and JSON output", () => { const trigger = "trigger: the export stage lands and its schema is frozen after the review round"; const { plan } = setup({