Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 13 additions & 1 deletion rules/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
bompus marked this conversation as resolved.
Resolved
Comment thread
bompus marked this conversation as resolved.
Resolved
built:" (`- [ ]`, not deferred), names the stage and carries `trigger: <what it
waits on>` (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
Comment thread
bompus marked this conversation as resolved.
Resolved
Comment thread
bompus marked this conversation as resolved.
Resolved
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
Expand Down
3 changes: 2 additions & 1 deletion skills/hr-handoff/references/reconciliation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<line>` (`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.
Expand Down
7 changes: 5 additions & 2 deletions skills/hr-what-next/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <plan>`
computes this floor (add `--checkout <path>` for each checkout touched, or
`--app-managed <path>` for one an app retires, which is reported but not a
Expand Down
25 changes: 23 additions & 2 deletions skills/hr-what-next/scripts/check-open-work.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -77,6 +81,9 @@ import { basename, dirname, join, resolve, sep } from "node:path";
const usage =
"usage: check-open-work.mjs <plan.md> [--checkout <path>]... [--app-managed <path>]... [--scratch <dir>] [--repo <owner/repo>] [--offline] [--today <YYYY-MM-DD>] [--archive] [--json] [--session <id> --turn <id>]\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;
Expand Down Expand Up @@ -674,7 +681,8 @@ function unansweredOffers(items) {
/** @type {Map<string, string>} */
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));
Expand All @@ -700,6 +708,7 @@ async function run(options) {
owner: plan.owner ?? null,
open: [],
awaitingUser: [],
unbuiltStages: [],
unansweredOffers: [],
stale: [],
recheckDue: [],
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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);
Expand Down
79 changes: 79 additions & 0 deletions skills/hr-what-next/test/check-open-work.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
Loading