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..66f19e97 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,17 @@ 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), 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 +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 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..24d21167 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,9 @@ 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 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; @@ -674,7 +681,8 @@ function unansweredOffers(items) { /** @type {Map} */ const unanswered = new Map(); for (const item of items) { - if (item.mark === " ") { + // 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)); @@ -700,6 +708,7 @@ async function run(options) { owner: plan.owner ?? null, open: [], awaitingUser: [], + unbuiltStages: [], unansweredOffers: [], stale: [], recheckDue: [], @@ -732,6 +741,17 @@ async function run(options) { ); } report.awaitingUser = open.filter(awaitsUser).map(row); + // 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}`); + 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 +916,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 +944,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..d78c805d 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,85 @@ 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) 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", + "- [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("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("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({ + 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: [