@bompus/house-rules 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CODE_OF_CONDUCT.md +83 -0
  2. package/README.md +91 -60
  3. package/compose.mjs +8 -2
  4. package/composition.mjs +11 -3
  5. package/config-view.mjs +18 -3
  6. package/config.mjs +74 -13
  7. package/docs/assets/overview.png +0 -0
  8. package/docs/configuration.md +85 -9
  9. package/package.json +4 -2
  10. package/rules/core.md +118 -209
  11. package/rules/modifiers/backlog-outline.md +25 -0
  12. package/rules/modifiers/backlog-visual.md +16 -0
  13. package/rules/modifiers/coded-offers.md +47 -17
  14. package/rules/modifiers/cross-session-relay.md +23 -0
  15. package/rules/modifiers/low-quota-handoff.md +4 -3
  16. package/rules/modifiers/model-default-questions.md +33 -0
  17. package/rules/modifiers/multi-agent.md +6 -2
  18. package/rules/modifiers/no-attribution.md +7 -1
  19. package/rules/modifiers/periodic-updates.md +25 -0
  20. package/rules/modifiers/plan-files.md +35 -3
  21. package/rules/modifiers/release-batching.md +4 -2
  22. package/rules/modifiers/shared-host-load.md +13 -42
  23. package/rules/modifiers/squash-landing.md +1 -1
  24. package/rules/modifiers/upstream-contributions.md +45 -0
  25. package/rules/modifiers/web-image-formats.md +35 -0
  26. package/rules/references/backlog-view.md +154 -0
  27. package/rules/references/landing.md +14 -14
  28. package/rules/references/multi-agent.md +6 -2
  29. package/rules/references/release-batching.md +27 -4
  30. package/rules/references/squash-landing.md +10 -4
  31. package/rules/references/thread-visuals.md +45 -0
  32. package/setup.mjs +87 -17
  33. package/skills/hr-agent-guidance-audit/SKILL.md +10 -5
  34. package/skills/hr-agent-guidance-audit/references/prune-workflow.md +72 -0
  35. package/skills/hr-agent-guidance-audit/references/records-and-lessons.md +1 -1
  36. package/skills/hr-agent-guidance-audit/references/retirement-verdicts.md +49 -0
  37. package/skills/hr-agent-guidance-refresh/SKILL.md +25 -57
  38. package/skills/hr-agent-guidance-refresh/references/verification.md +1 -1
  39. package/skills/hr-audit-choices/SKILL.md +1 -1
  40. package/skills/hr-benchmarking/SKILL.md +122 -159
  41. package/skills/hr-benchmarking/references/javascript.md +113 -0
  42. package/skills/hr-benchmarking/references/tools.md +62 -57
  43. package/skills/hr-better-accessibility/SKILL.md +1 -1
  44. package/skills/hr-better-accessibility/screen-readers.md +1 -0
  45. package/skills/hr-code-review/SKILL.md +19 -3
  46. package/skills/hr-code-review/agents/openai.yaml +1 -1
  47. package/skills/hr-code-review/references/acp-client.md +43 -0
  48. package/skills/{hr-change-impact/SKILL.md → hr-code-review/references/impact.md} +2 -12
  49. package/skills/hr-code-review/references/panel-adjudication.md +58 -0
  50. package/skills/hr-code-review/references/panel-plan.md +61 -0
  51. package/skills/hr-code-review/references/panel-recommendations.json +105 -0
  52. package/skills/hr-code-review/references/panel-recommendations.md +46 -0
  53. package/skills/hr-code-review/references/panel-setup.md +54 -0
  54. package/skills/hr-code-review/references/reviewer-role.md +31 -0
  55. package/skills/hr-code-review/scripts/acp-panel-client.mjs +299 -0
  56. package/skills/hr-code-review/scripts/check-receipts.mjs +263 -0
  57. package/skills/hr-code-review/scripts/panel-plan.mjs +69 -0
  58. package/skills/hr-code-review/scripts/panel-preferences.mjs +220 -0
  59. package/skills/hr-design-exploration/SKILL.md +24 -1
  60. package/skills/hr-design-exploration/agents/openai.yaml +2 -2
  61. package/skills/hr-diagnosing-bugs/SKILL.md +2 -2
  62. package/skills/hr-diagnosing-bugs/references/reporting.md +29 -4
  63. package/skills/hr-diagnosing-bugs/scripts/check-pr-offer.mjs +47 -0
  64. package/skills/hr-explain-code/SKILL.md +1 -1
  65. package/skills/hr-extract-shared-steps/SKILL.md +1 -1
  66. package/skills/hr-forgotten-work-audit/SKILL.md +130 -0
  67. package/skills/hr-forgotten-work-audit/references/brief-template.md +56 -0
  68. package/skills/hr-forgotten-work-audit/references/result-format.md +46 -0
  69. package/skills/hr-handoff/LICENSE +21 -0
  70. package/skills/hr-handoff/NOTICE.md +29 -0
  71. package/skills/hr-handoff/SKILL.md +24 -1
  72. package/skills/hr-handoff/references/reconciliation.md +25 -6
  73. package/skills/hr-handoff/references/successor-thread.md +76 -0
  74. package/skills/hr-handoff/scripts/check-reconciliation.mjs +48 -6
  75. package/skills/hr-house-rules-setup/SKILL.md +22 -8
  76. package/skills/hr-lean-plan/SKILL.md +4 -0
  77. package/skills/hr-maintainability-review/SKILL.md +14 -4
  78. package/skills/hr-maintainability-review/agents/openai.yaml +1 -1
  79. package/skills/hr-manual-qa/SKILL.md +1 -1
  80. package/skills/hr-ordering-tests/SKILL.md +1 -1
  81. package/skills/hr-plain-prose/SKILL.md +7 -4
  82. package/skills/hr-pr-followup/SKILL.md +6 -3
  83. package/skills/hr-pr-followup/references/review-helpers.md +21 -0
  84. package/skills/hr-pr-followup/scripts/check-landing.py +204 -0
  85. package/skills/hr-pr-followup/scripts/merge-pr.sh +100 -0
  86. package/skills/hr-pr-followup/scripts/verify-landed.sh +184 -0
  87. package/skills/hr-read-reddit/SKILL.md +1 -1
  88. package/skills/hr-research-shared-links/SKILL.md +103 -0
  89. package/skills/hr-sqlite/SKILL.md +174 -0
  90. package/skills/hr-sqlite/agents/openai.yaml +2 -0
  91. package/skills/hr-sqlite/references/evidence.md +94 -0
  92. package/skills/hr-sqlite/references/tool-decisions.md +39 -0
  93. package/skills/hr-sqlite/scripts/inspect-sqlite.py +245 -0
  94. package/skills/hr-stock-ui-audit/SKILL.md +1 -1
  95. package/skills/hr-test-audit/LICENSE +21 -0
  96. package/skills/hr-test-audit/NOTICE.md +67 -0
  97. package/skills/hr-usage-report/SKILL.md +90 -0
  98. package/skills/hr-usage-report/agents/openai.yaml +2 -0
  99. package/skills/hr-usage-report/assets/example-report.json +125 -0
  100. package/skills/hr-usage-report/assets/report-template.html +377 -0
  101. package/skills/hr-usage-report/scripts/render-report.ts +86 -0
  102. package/skills/hr-usage-report/scripts/usage-by-model.ts +499 -0
  103. package/skills/hr-what-next/SKILL.md +100 -10
  104. package/skills/hr-what-next/references/reporting.md +13 -22
  105. package/skills/hr-what-next/scripts/check-open-work.mjs +711 -0
  106. package/skills/hr-writing-pr/SKILL.md +29 -14
  107. package/skills/hr-writing-pr/scripts/benefit-chart.py +200 -0
  108. package/docs/npm-release.md +0 -45
  109. package/rules/modifiers/swarmail.md +0 -12
  110. package/skills/hr-benchmarking/references/priority-findings-20261006.md +0 -115
  111. package/skills/hr-change-impact/agents/openai.yaml +0 -5
@@ -0,0 +1,711 @@
1
+ #!/usr/bin/env node
2
+ // Open-work check for a session plan: lists what is still open before anyone says
3
+ // "nothing remains" or archives the plan. See ../SKILL.md and the plan-files rule.
4
+ // Exit 0: nothing open; 1: invalid input; 2: open work remains or archiving refused.
5
+ // "awaiting your yes/no" lists the deferred items whose trigger is only the user's own action
6
+ // ("user selects", "user's decision", "user asks for the backlog"): they were deferred, not decided,
7
+ // so the user still owes a yes, a no or a trigger other than their own. The match is a heuristic on the
8
+ // trigger text, which must be the user plus a verb such as selects, picks, decides, asks or approves and
9
+ // at most a short object ("it", "the backlog"); a trigger with a date, event or another condition
10
+ // stays an ordinary deferred item. The group is a subset of `open` and does not change the exit code.
11
+ // "possibly finished" lists open or deferred items whose every named pull request or issue is merged or
12
+ // closed on GitHub: done work left open, or a trigger that has fired. A named commit that a --checkout has
13
+ // outside its default branch keeps the item off the list. Bare numbers ("PR #4") are looked up only in
14
+ // --repo. The check needs `gh`; without it, offline, or with --offline it is skipped and "skipped" says so.
15
+ // "re-check due" lists items whose `recheck: YYYY-MM-DD` date is past or within 7 days. Both groups are
16
+ // subsets of `open` and do not change the exit code.
17
+ // "unanswered offers" counts the open (`- [ ]`) ledger items that name an offer, one per offer ID, and warns at
18
+ // three or more, the point where the offers rule says to present no further offer. A line names an offer when
19
+ // an ID (capitals and digits joined by hyphens) follows the word "offer" directly ("Offer ABC-12 ...", "Pilot offer ABC-12").
20
+ // The offer is answered when any ledger line naming it is marked dropped, says it was selected, chosen,
21
+ // picked or answered, or says what happened to the other options (the DISPOSITION words). An ID after
22
+ // "by", "into" or "with" ("superseded by offer ABC-13") is the replacement, not the line's subject.
23
+ // Deferred and done items are never counted. The count is a text match like the groups above; it warns
24
+ // and does not change the exit code.
25
+ // A plan with a `Project: <name>` line is checked against <notes>/audits/<name>/last-audit.json, the record
26
+ // hr-forgotten-work-audit writes: a missing, unreadable or older-than-30-days record is a warning. A plan
27
+ // without that line gets no audit warning.
28
+ import { createHash } from "node:crypto";
29
+ import { pathToFileURL } from "node:url";
30
+ import { spawn, spawnSync } from "node:child_process";
31
+ import {
32
+ existsSync,
33
+ mkdirSync,
34
+ readdirSync,
35
+ readFileSync,
36
+ realpathSync,
37
+ renameSync,
38
+ statSync,
39
+ } from "node:fs";
40
+ import { basename, dirname, join, resolve, sep } from "node:path";
41
+
42
+ const usage =
43
+ "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" +
44
+ '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.';
45
+ const TERMINAL_STATUS = /^(completed?|done|archived)\b/i;
46
+ const COMPLETED_STATUS = /^completed\b/i;
47
+ const MARKS = { " ": "open", "~": "deferred", x: "done", "-": "dropped" };
48
+ const REQUIRED = {
49
+ "~": ["trigger", "a trigger"],
50
+ x: ["evidence", "evidence"],
51
+ "-": ["by", "who dropped it"],
52
+ };
53
+ const USER_ONLY_TRIGGER =
54
+ /^(?:when |once |until )?(?:the )?user(?:'s)?\s+(?:select\w*|pick\w*|choos\w*|decid\w*|decision|choice|answer\w*|direction|approv\w*|ask\w*|say\w*|request\w*)(?:\s+(?:for |to )?(?:it|this|them|that|one|the backlog|the item|a decision))?$/i;
55
+ const NARRATIVE =
56
+ /^\s*(?:[-*]\s+|#+\s+)?\**(pending|waiting|deferred|blocked-on-user|blocked-by|todo|follow-?ups?)\b/i;
57
+ // A labelled follow-up inside a prose sentence ("Merged. Deferred: the listing", "Still open for later: x").
58
+ const PROSE_MARKER =
59
+ /(?:^|[.;!?]\s+|\*\*)\s*(?:follow-?ups?|deferred|todo|(?:still )?open for later)\**\s*:/i;
60
+ // An offer line that records an answer, and words that say what happened to the other options.
61
+ // "user reply" and "user go" are not answer words: "awaiting user reply" and "user reply pending" say the offer is
62
+ // still open, so only a line that says it was selected, chosen, picked or answered counts as an answer.
63
+ const ANSWERED_OFFER =
64
+ /\boffer\b.*\b(?:selected|chose|chosen|picked|answered)\b|\b(?:selected|chose|chosen|picked|answered)\b.*\boffer\b/i;
65
+ // "deferred" and "dropped" count only next to an option code ("1B deferred"), since a line can defer
66
+ // something else ("rollout deferred until the release").
67
+ const DISPOSITION =
68
+ /not selected|unselected|unpicked|declined|superseded|passed over|only option|all selected|no other options?|\b\d+[a-z]\b[^.;]{0,30}\b(?:deferred|dropped)\b|\b(?:deferred|dropped)\b[^.;]{0,15}\b\d+[a-z]\b/i;
69
+ const OFFER_ID = /\b[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+\b/g;
70
+ // An ID right after the word "offer": the offer the line is about. The ID is checked against OFFER_ID's
71
+ // shape afterwards because the word "offer" is matched in any case.
72
+ const OFFER_SUBJECT = /\boffer\b[^\w]{0,3}([\w-]+)/gi;
73
+ const OFFER_ID_ONLY = /^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+$/;
74
+ const REPLACEMENT_OF = /\b(?:by|into|with)\s+(?:the\s+)?$/i;
75
+ // The offers rule stops presenting new offers at this many open and unanswered ones.
76
+ const OFFER_LIMIT = 3;
77
+ // A deferred item that waits on another thread (an upstream issue or pull request, a maintainer, another
78
+ // session), or an open question sent to one, needs a `recheck:` date: "trigger: reply" never fires itself.
79
+ const EXTERNAL =
80
+ /https:\/\/github\.com\/|\b[\w.-]+\/[\w.-]+#\d+|\bPR\s*#?\s*\d+|(?<![\w/#&])#\d+\b|\bupstream\b|\bmaintainers?\b|\b(?:another|other|the [\w-]+) (?:session|thread)\b/i;
81
+ const SENT_QUESTION =
82
+ /\b(?:asked|relayed|sent)\b.*\b(?:session|thread|owner|maintainer)s?\b|\b(?:awaiting|waiting (?:on|for)) (?:a |the )?(?:reply|answer|response)\b/i;
83
+ const RECHECK = /\brecheck:\s*(\d{4}-\d{2}-\d{2})/i;
84
+ const GITHUB_URL = /https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/(?:pull|issues)\/(\d+)/g;
85
+ const GITHUB_REF = /(?<![\w./-])([\w.-]+)\/([\w.-]+)#(\d+)\b/g;
86
+ const BARE_REF = /(?:\bPR\s*#?\s*|(?<![\w/#&])#)(\d{1,6})\b/gi;
87
+ const COMMIT = /\b[0-9a-f]{7,40}\b/g;
88
+ // Monthly: items in a full audit were lost within two to four weeks of being promised.
89
+ const AUDIT_DAYS = 30;
90
+ const PROJECT_NAME = /^\w[\w.-]*$/;
91
+ // A real calendar date: "2026-02-31" has the shape but not the round trip.
92
+ function validDate(value) {
93
+ const date = new Date(`${value}T00:00:00Z`);
94
+ return (
95
+ /^\d{4}-\d{2}-\d{2}$/.test(value) &&
96
+ !Number.isNaN(date.getTime()) &&
97
+ date.toISOString().slice(0, 10) === value
98
+ );
99
+ }
100
+
101
+ function parseArguments(argv) {
102
+ const options = {
103
+ plan: null,
104
+ checkouts: [],
105
+ appManaged: [],
106
+ scratch: null,
107
+ repo: null,
108
+ offline: false,
109
+ today: new Date().toLocaleDateString("sv"),
110
+ archive: false,
111
+ json: false,
112
+ sessionId: null,
113
+ turnId: null,
114
+ };
115
+ for (let index = 0; index < argv.length; index++) {
116
+ const argument = argv[index];
117
+ if (argument === "--archive") options.archive = true;
118
+ else if (argument === "--json") options.json = true;
119
+ else if (argument === "--offline") options.offline = true;
120
+ else if (
121
+ [
122
+ "--checkout",
123
+ "--app-managed",
124
+ "--scratch",
125
+ "--repo",
126
+ "--today",
127
+ "--session",
128
+ "--turn",
129
+ ].includes(argument)
130
+ ) {
131
+ const value = argv[++index];
132
+ if (!value) throw new Error(`${argument} needs a value\n${usage}`);
133
+ if (argument === "--session") options.sessionId = value;
134
+ else if (argument === "--turn") options.turnId = value;
135
+ else if (argument === "--checkout") options.checkouts.push(value);
136
+ else if (argument === "--app-managed") options.appManaged.push(value);
137
+ else if (argument === "--repo") {
138
+ if (!/^[\w.-]+\/[\w.-]+$/.test(value)) throw new Error(`--repo needs owner/repo\n${usage}`);
139
+ options.repo = value;
140
+ } else if (argument === "--today") {
141
+ if (!validDate(value)) throw new Error(`--today needs YYYY-MM-DD\n${usage}`);
142
+ options.today = value;
143
+ } else options.scratch = value;
144
+ } else if (argument.startsWith("--") || options.plan) throw new Error(usage);
145
+ else options.plan = argument;
146
+ }
147
+ if (!options.plan) throw new Error(usage);
148
+ if (Boolean(options.sessionId) !== Boolean(options.turnId))
149
+ throw new Error("--session and --turn must be supplied together");
150
+ if (options.sessionId && options.archive)
151
+ throw new Error("a reconciliation receipt cannot archive its plan");
152
+ return options;
153
+ }
154
+
155
+ const clip = (text, length = 90) => (text.length > length ? `${text.slice(0, length - 1)}…` : text);
156
+
157
+ function parsePlan(text) {
158
+ const lines = text.split(/\r?\n/);
159
+ const header = lines.slice(0, 40);
160
+ const field = (name) =>
161
+ header
162
+ .map((line) => line.match(new RegExp(`^\\s*(?:\\*\\*)?${name}:?(?:\\*\\*)?:?\\s*(.+)$`, "i")))
163
+ .find(Boolean)?.[1]
164
+ .trim();
165
+ const errors = [];
166
+ const status = field("Status");
167
+ const owner = field("Owner");
168
+ const purpose = field("Purpose");
169
+ // Only an exact `Project:` field: "Project URL: ..." is a different header.
170
+ const project = header
171
+ .map((line) => line.match(/^\s*(?:\*\*)?Project(?:\*\*)?:(?:\*\*)?\s*(.+)$/i))
172
+ .find(Boolean)?.[1]
173
+ .trim();
174
+ if (!status) errors.push("no `Status:` line in the first 40 lines");
175
+ if (!owner) errors.push("no `Owner:` line in the first 40 lines");
176
+ const start = lines.findIndex((line) => /^#{1,6}\s+Ledger\s*$/i.test(line));
177
+ if (start < 0) errors.push("no `## Ledger` section");
178
+ let end = lines.length;
179
+ if (start >= 0) {
180
+ const level = lines[start].match(/^#+/)[0].length;
181
+ const next = lines.findIndex(
182
+ (line, i) => i > start && new RegExp(`^#{1,${level}}\\s`).test(line),
183
+ );
184
+ if (next >= 0) end = next;
185
+ }
186
+ const items = [];
187
+ for (let i = start + 1; start >= 0 && i < end; i++) {
188
+ const match = lines[i].match(/^\s*[-*]\s+\[([ x~-])\]\s+(.*)$/i);
189
+ if (match) {
190
+ items.push({ line: i + 1, mark: match[1].toLowerCase(), text: match[2] });
191
+ } else if (items.length && /^\s{2,}\S/.test(lines[i]) && !/^\s*[-*]\s/.test(lines[i])) {
192
+ items[items.length - 1].text += ` ${lines[i].trim()}`;
193
+ }
194
+ }
195
+ if (start >= 0 && !items.length)
196
+ errors.push("the ledger has no items (`- [ ] ...`, `- [~] ...`, `- [x] ...`, `- [-] ...`)");
197
+ for (const item of items) {
198
+ const required = REQUIRED[item.mark];
199
+ if (required && !new RegExp(`\\b${required[0]}:\\s*\\S`, "i").test(item.text))
200
+ errors.push(
201
+ `L${item.line}: a ${MARKS[item.mark]} item needs \`${required[0]}:\` (${required[1]})`,
202
+ );
203
+ }
204
+ const narrative = [];
205
+ lines.forEach((line, i) => {
206
+ const inLedger = start >= 0 && i > start && i < end;
207
+ if (
208
+ !inLedger &&
209
+ i > 0 &&
210
+ (NARRATIVE.test(line) || PROSE_MARKER.test(line) || /^\s*[-*]\s+\[[ ~]\]/.test(line))
211
+ )
212
+ narrative.push({ line: i + 1, text: line.trim() });
213
+ });
214
+ return { status, owner, purpose, project, items, errors, narrative };
215
+ }
216
+
217
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
218
+
219
+ // Identity is local to these exact UTF-8 plan bytes. No readiness is inferred here.
220
+ export function planSnapshot(planText) {
221
+ const plan = parsePlan(planText);
222
+ return {
223
+ planSHA256: sha256(planText),
224
+ errors: plan.errors,
225
+ openItems: plan.items
226
+ .filter((item) => item.mark === " " || item.mark === "~")
227
+ .map((item) => ({
228
+ id: `L${item.line}:${sha256(item.mark + item.text)}`,
229
+ ...item,
230
+ recheck: item.text.match(RECHECK)?.[1] ?? null,
231
+ })),
232
+ };
233
+ }
234
+
235
+ function notesRoot(planPath) {
236
+ const parts = resolve(planPath).split(sep);
237
+ const at = parts.lastIndexOf("plans");
238
+ return at > 0
239
+ ? { notes: parts.slice(0, at).join(sep) || sep, plans: parts.slice(0, at + 1).join(sep), parts }
240
+ : null;
241
+ }
242
+
243
+ // An owner line can name several identities ("Alice, session 7"); a manifest is the plan's only when
244
+ // its whole owner equals one of them, so a manifest owned by "session" is not claimed. A manifest that
245
+ // cannot be read, or has no owner, could be the plan's, so it is reported instead of skipped.
246
+ function ownedScratch(directory, owner) {
247
+ const result = { owned: [], unreadable: [] };
248
+ if (!existsSync(directory)) return result;
249
+ const identities = [owner, ...owner.split(/[,;]/)].map((part) => part.trim().toLowerCase());
250
+ for (const name of readdirSync(directory)) {
251
+ const manifest = join(directory, name, "manifest.json");
252
+ if (!existsSync(manifest)) continue;
253
+ try {
254
+ const parsed = JSON.parse(readFileSync(manifest, "utf8"));
255
+ if (typeof parsed.owner !== "string") throw new Error("no owner");
256
+ if (identities.includes(parsed.owner.trim().toLowerCase())) result.owned.push(name);
257
+ } catch {
258
+ result.unreadable.push(manifest);
259
+ }
260
+ }
261
+ return result;
262
+ }
263
+
264
+ function git(path, ...args) {
265
+ const run = spawnSync("git", ["-C", path, ...args], { encoding: "utf8" });
266
+ return run.status === 0 ? run.stdout : null;
267
+ }
268
+
269
+ function checkoutState(path) {
270
+ const status = git(path, "status", "--porcelain=v1", "--branch");
271
+ if (status === null) return { error: `cannot read git state of ${path}` };
272
+ const [branchLine, ...changes] = status.split("\n").filter(Boolean);
273
+ const unpushed = (git(path, "log", "--oneline", "HEAD", "--not", "--remotes") ?? "")
274
+ .split("\n")
275
+ .filter(Boolean);
276
+ const branch = branchLine.replace(/^## /, "").split(/\.\.\.| /)[0];
277
+ return { branch, dirty: changes.length, unpushed: unpushed.length };
278
+ }
279
+
280
+ // A name counts only as a whole token, so "main" inside "update main docs" or "/a/b" inside "/a/bc" does not.
281
+ const GENERIC_BRANCHES = ["main", "master", "HEAD", "No"];
282
+ function mentions(text, name) {
283
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
284
+ return new RegExp(`(^|[^\\w./-])${escaped}($|[^\\w./-])`).test(text);
285
+ }
286
+
287
+ function awaitsUser(item) {
288
+ if (item.mark !== "~") return false;
289
+ const trigger = item.text.match(/\btrigger:\s*(.+)$/i)?.[1];
290
+ return USER_ONLY_TRIGGER.test(trigger?.trim().replace(/[.)\s]+$/, "") ?? "");
291
+ }
292
+
293
+ function accountFor(resource, names, items, findings) {
294
+ const naming = items.filter((item) => names.some((name) => mentions(item.text, name)));
295
+ if (!naming.length) findings.push(`${resource} is not named in any ledger item`);
296
+ else if (!naming.some((item) => item.mark === " " || item.mark === "~"))
297
+ findings.push(
298
+ `${resource} still exists but its ledger item is ${MARKS[naming[0].mark]} (L${naming[0].line})`,
299
+ );
300
+ }
301
+
302
+ // The pull requests, issues and commits an item names. A bare number is looked up in `repo` only.
303
+ // A reference the line already reports as merged, landed or superseded ("fix landed in #120",
304
+ // "left out of #182") is context for a remaining part, not the item's subject, so it is skipped.
305
+ const REPORTED = /\b(?:merged?|landed|closed|superseded|left out of|squash\w*)\b/i;
306
+ function references(text, repo) {
307
+ const github = new Set();
308
+ let bare = 0;
309
+ // Only the clause around the reference counts: in "#1 merged; cleanup #2" the word belongs to #1.
310
+ const reported = (offset, length) => {
311
+ const before = text
312
+ .slice(Math.max(0, offset - 25), offset)
313
+ .split(/[;,()]|\.\s/)
314
+ .at(-1);
315
+ const after = text.slice(offset + length, offset + length + 25).split(/[;,()]|\.\s/)[0];
316
+ return REPORTED.test(`${before} ${after}`);
317
+ };
318
+ let rest = text;
319
+ for (const pattern of [GITHUB_URL, GITHUB_REF])
320
+ rest = rest.replace(pattern, (match, owner, name, number, offset) => {
321
+ if (!reported(offset, match.length)) github.add(`${owner}/${name}#${number}`);
322
+ return " ".repeat(match.length);
323
+ });
324
+ for (const match of rest.matchAll(BARE_REF)) {
325
+ if (reported(match.index, match[0].length)) continue;
326
+ if (repo) github.add(`${repo}#${match[1]}`);
327
+ else bare++;
328
+ }
329
+ return { github: [...github], bare, commits: [...new Set(rest.match(COMMIT) ?? [])] };
330
+ }
331
+
332
+ function gh(...args) {
333
+ return new Promise((done) => {
334
+ let stdout = "";
335
+ let stderr = "";
336
+ const child = spawn("gh", args, { timeout: 30_000 });
337
+ child.stdout.on("data", (chunk) => (stdout += chunk));
338
+ child.stderr.on("data", (chunk) => (stderr += chunk));
339
+ child.on("error", (error) =>
340
+ done({ error: error.code === "ENOENT" ? "gh not found" : error.message }),
341
+ );
342
+ child.on("close", (code) =>
343
+ done(code === 0 ? { stdout } : { error: stderr.trim() || `gh exit ${code}` }),
344
+ );
345
+ });
346
+ }
347
+
348
+ // "merged", "closed", "open" or "unknown" (not found) per reference; null when GitHub cannot be asked.
349
+ async function githubStates(refs, offline) {
350
+ const states = new Map();
351
+ if (!refs.length) return { states };
352
+ if (offline) return { states, skipped: "GitHub check skipped: --offline" };
353
+ const lookup = async (ref) => {
354
+ const [, owner, name, number] = ref.match(/^(.+)\/(.+)#(\d+)$/);
355
+ const result = await gh("api", `repos/${owner}/${name}/issues/${number}`);
356
+ if (result.error) {
357
+ if (/\b404\b|not found/i.test(result.error) && result.error !== "gh not found")
358
+ return states.set(ref, "unknown");
359
+ throw new Error(result.error.split("\n")[0]);
360
+ }
361
+ const issue = JSON.parse(result.stdout);
362
+ states.set(ref, issue.pull_request?.merged_at ? "merged" : issue.state);
363
+ };
364
+ try {
365
+ // The first lookup runs alone, so an offline host or a missing gh costs one call, not one per item.
366
+ await lookup(refs[0]);
367
+ const queue = refs.slice(1);
368
+ await Promise.all(
369
+ Array.from({ length: 8 }, async () => {
370
+ while (queue.length) await lookup(queue.shift());
371
+ }),
372
+ );
373
+ } catch (error) {
374
+ return { states: new Map(), skipped: `GitHub check skipped: ${error.message}` };
375
+ }
376
+ return { states };
377
+ }
378
+
379
+ function defaultBranch(path) {
380
+ const head = git(path, "rev-parse", "--abbrev-ref", "origin/HEAD")?.trim();
381
+ if (head && head !== "origin/HEAD") return head;
382
+ return ["origin/main", "origin/master"].find((ref) =>
383
+ git(path, "rev-parse", "--verify", "--quiet", ref),
384
+ );
385
+ }
386
+
387
+ // "landed" when a checkout's default branch contains the commit, "not landed" when a checkout has it
388
+ // elsewhere, and undefined when no checkout knows it (a session id or other hex text, not a commit).
389
+ // Open items name landed commits mostly as context ("pin is still 6b5c7aef"), so a landed commit never
390
+ // marks an item finished by itself; an unlanded one keeps it off the list.
391
+ function commitState(sha, checkouts) {
392
+ let state;
393
+ for (const { path, base } of checkouts) {
394
+ if (git(path, "rev-parse", "--verify", "--quiet", `${sha}^{commit}`) === null) continue;
395
+ if (
396
+ base &&
397
+ spawnSync("git", ["-C", path, "merge-base", "--is-ancestor", sha, base]).status === 0
398
+ )
399
+ return { state: "landed", label: `${sha} in ${base}` };
400
+ state = { state: "not landed", label: sha };
401
+ }
402
+ return state;
403
+ }
404
+
405
+ async function possiblyFinished(open, options, report) {
406
+ const named = open.map((item) => ({ item, ...references(item.text, options.repo) }));
407
+ const bare = named.reduce((total, entry) => total + entry.bare, 0);
408
+ if (bare)
409
+ report.skipped.push(
410
+ `${bare} bare pull request or issue number(s) not checked: pass --repo <owner/repo>`,
411
+ );
412
+ const { states, skipped } = await githubStates(
413
+ [...new Set(named.flatMap((entry) => entry.github))],
414
+ options.offline,
415
+ );
416
+ if (skipped) report.skipped.push(skipped);
417
+ const checkouts = options.checkouts.map((path) => ({ path, base: defaultBranch(path) }));
418
+ const finished = [];
419
+ for (const { item, github, bare, commits } of named) {
420
+ if (bare) continue;
421
+ const refs = github.map((ref) => ({
422
+ state: states.get(ref) ?? "unknown",
423
+ label: `${ref} ${states.get(ref) ?? "unchecked"}`,
424
+ }));
425
+ for (const sha of commits) {
426
+ const commit = commitState(sha, checkouts);
427
+ if (commit) refs.push(commit);
428
+ }
429
+ if (
430
+ refs.some((ref) => ref.state !== "landed") &&
431
+ refs.every((ref) => ["merged", "closed", "landed"].includes(ref.state))
432
+ )
433
+ finished.push(`${row(item)} — ${refs.map((ref) => ref.label).join(", ")}`);
434
+ }
435
+ return finished;
436
+ }
437
+
438
+ const row = (item) => `L${item.line} [${item.mark}] ${clip(item.text)}`;
439
+
440
+ function addDays(date, days) {
441
+ const next = new Date(`${date}T00:00:00Z`);
442
+ next.setUTCDate(next.getUTCDate() + days);
443
+ return next.toISOString().slice(0, 10);
444
+ }
445
+
446
+ // The project's last forgotten-work audit: missing, unreadable or older than AUDIT_DAYS is a warning.
447
+ function auditWarning(project, roots, today) {
448
+ if (!PROJECT_NAME.test(project))
449
+ return `\`Project: ${clip(project, 40)}\` is not a plain project name, so its audit record was not checked`;
450
+ if (!roots)
451
+ return `cannot locate the audit record for ${project}: the plan is not under a plans/ directory`;
452
+ const path = join(roots.notes, "audits", project, "last-audit.json");
453
+ if (!existsSync(path))
454
+ return `no forgotten-work audit recorded for ${project} (expected ${path}); run hr-forgotten-work-audit`;
455
+ let record;
456
+ try {
457
+ record = JSON.parse(readFileSync(path, "utf8"));
458
+ if (
459
+ !validDate(record?.date) ||
460
+ typeof record.scope !== "string" ||
461
+ typeof record.result !== "string"
462
+ )
463
+ throw new Error();
464
+ } catch {
465
+ return `cannot read the audit record ${path} (needs a JSON object with date YYYY-MM-DD, scope and result)`;
466
+ }
467
+ if (record.date > today)
468
+ return `the audit record ${path} is dated after today (${record.date}), so it was not counted`;
469
+ const age = Math.round((Date.parse(today) - Date.parse(record.date)) / 86_400_000);
470
+ if (age > AUDIT_DAYS)
471
+ return `last forgotten-work audit for ${project} was ${record.date}, ${age} days ago (over ${AUDIT_DAYS}); run hr-forgotten-work-audit`;
472
+ return null;
473
+ }
474
+
475
+ // Long plans can carry dozens of one kind of warning; show five and count the rest.
476
+ function capped(warnings, entries, kind) {
477
+ warnings.push(...entries.slice(0, 5));
478
+ if (entries.length > 5) warnings.push(`${entries.length - 5} more ${kind}`);
479
+ }
480
+
481
+ function rechecks(open, today, warnings) {
482
+ const due = [];
483
+ const undated = [];
484
+ const soon = addDays(today, 7);
485
+ for (const item of open) {
486
+ const written = item.text.match(RECHECK)?.[1];
487
+ if (written && !validDate(written)) {
488
+ undated.push(`L${item.line} has \`recheck: ${written}\`, which is not a calendar date`);
489
+ continue;
490
+ }
491
+ const date = written;
492
+ if (date && date < today) due.push(`${row(item)} — overdue since ${date}`);
493
+ else if (date && date <= soon) due.push(`${row(item)} — due ${date}`);
494
+ else if (
495
+ !date &&
496
+ ((item.mark === "~" && EXTERNAL.test(item.text)) || SENT_QUESTION.test(item.text))
497
+ )
498
+ undated.push(
499
+ `L${item.line} waits on another thread but has no \`recheck: YYYY-MM-DD\` date: ${clip(item.text, 60)}`,
500
+ );
501
+ }
502
+ capped(warnings, undated, "item(s) waiting on another thread without a recheck: date");
503
+ return due;
504
+ }
505
+
506
+ // An answered offer must say what happened to the options the user did not pick, on its own line or on
507
+ // another ledger line that names the same offer ID.
508
+ function offerWarnings(items, warnings) {
509
+ const missing = [];
510
+ for (const item of items) {
511
+ if (!ANSWERED_OFFER.test(item.text) || DISPOSITION.test(item.text)) continue;
512
+ const ids = new Set(item.text.match(OFFER_ID) ?? []);
513
+ const recorded = items.some(
514
+ (other) =>
515
+ other !== item &&
516
+ DISPOSITION.test(other.text) &&
517
+ (other.text.match(OFFER_ID) ?? []).some((id) => ids.has(id)),
518
+ );
519
+ if (!recorded)
520
+ missing.push(
521
+ `L${item.line} answered offer without the other options' outcome (not selected, deferred): ${clip(item.text, 60)}`,
522
+ );
523
+ }
524
+ capped(warnings, missing, "answered offer(s) without the other options' outcome");
525
+ }
526
+
527
+ // The offer IDs a line is about, leaving out one it only names as a replacement ("superseded by offer X").
528
+ function offersNamed(text) {
529
+ const ids = [];
530
+ for (const match of text.matchAll(OFFER_SUBJECT)) {
531
+ if (OFFER_ID_ONLY.test(match[1]) && !REPLACEMENT_OF.test(text.slice(0, match.index)))
532
+ ids.push(match[1]);
533
+ }
534
+ return ids;
535
+ }
536
+
537
+ // One entry per offer ID that has an open line and no line recording an answer, a drop or a supersession.
538
+ function unansweredOffers(items) {
539
+ const closed = new Set();
540
+ for (const item of items)
541
+ if (item.mark === "-" || ANSWERED_OFFER.test(item.text) || DISPOSITION.test(item.text))
542
+ for (const id of offersNamed(item.text)) closed.add(id);
543
+ const unanswered = new Map();
544
+ for (const item of items)
545
+ if (item.mark === " ")
546
+ for (const id of offersNamed(item.text))
547
+ if (!closed.has(id) && !unanswered.has(id)) unanswered.set(id, row(item));
548
+ return [...unanswered.values()];
549
+ }
550
+
551
+ async function run(options) {
552
+ const planPath = resolve(options.plan);
553
+ if (!existsSync(planPath) || !statSync(planPath).isFile())
554
+ throw new Error(`cannot read ${options.plan}`);
555
+ const planText = readFileSync(planPath, "utf8");
556
+ const plan = parsePlan(planText);
557
+ const report = {
558
+ plan: planPath,
559
+ status: plan.status ?? null,
560
+ owner: plan.owner ?? null,
561
+ open: [],
562
+ awaitingUser: [],
563
+ unansweredOffers: [],
564
+ stale: [],
565
+ recheckDue: [],
566
+ skipped: [],
567
+ findings: [],
568
+ warnings: [],
569
+ errors: [...plan.errors],
570
+ ...(options.sessionId
571
+ ? {
572
+ reconciliation: {
573
+ version: 1,
574
+ sessionId: options.sessionId,
575
+ turnId: options.turnId,
576
+ ...planSnapshot(planText),
577
+ },
578
+ }
579
+ : {}),
580
+ };
581
+ const open = plan.items.filter((item) => item.mark === " " || item.mark === "~");
582
+ report.open = open.map(row);
583
+ if (report.errors.length) return report;
584
+ report.awaitingUser = open.filter(awaitsUser).map(row);
585
+ if (TERMINAL_STATUS.test(plan.status) && open.length)
586
+ report.findings.push(
587
+ `Status says "${plan.status}" but ${open.length} ledger item(s) are open or deferred`,
588
+ );
589
+ const roots = notesRoot(planPath);
590
+ const scratch = options.scratch ?? (roots ? join(roots.notes, "tmp") : null);
591
+ if (scratch) {
592
+ const { owned, unreadable } = ownedScratch(scratch, plan.owner);
593
+ for (const manifest of unreadable)
594
+ report.errors.push(
595
+ `cannot read the scratch manifest ${manifest} (needs a JSON object with a string owner)`,
596
+ );
597
+ for (const name of owned) accountFor(`scratch ${name}`, [name], plan.items, report.findings);
598
+ }
599
+ for (const path of options.checkouts) {
600
+ const state = checkoutState(path);
601
+ if (state.error) report.errors.push(state.error);
602
+ else if (state.dirty || state.unpushed)
603
+ accountFor(
604
+ `checkout ${path} (${state.dirty} changed file(s), ${state.unpushed} unpushed commit(s))`,
605
+ [path, ...(GENERIC_BRANCHES.includes(state.branch) ? [] : [state.branch])],
606
+ plan.items,
607
+ report.findings,
608
+ );
609
+ }
610
+ // An app retires its own checkouts, so their state is reported but is not a finding.
611
+ for (const path of options.appManaged) {
612
+ const state = checkoutState(path);
613
+ if (state.error) report.errors.push(state.error);
614
+ else if (state.dirty || state.unpushed)
615
+ report.warnings.push(
616
+ `app-managed checkout ${path} (${state.dirty} changed file(s), ${state.unpushed} unpushed commit(s)) is left for the app to retire`,
617
+ );
618
+ }
619
+ if (!plan.purpose)
620
+ report.warnings.push(
621
+ "no `Purpose:` line in the first 40 lines (the session's purpose, one sentence)",
622
+ );
623
+ for (const entry of plan.narrative.slice(0, 5))
624
+ report.warnings.push(`L${entry.line} outside the ledger: ${clip(entry.text, 70)}`);
625
+ if (plan.narrative.length > 5)
626
+ report.warnings.push(`${plan.narrative.length - 5} more narrative marker line(s)`);
627
+ offerWarnings(plan.items, report.warnings);
628
+ report.unansweredOffers = unansweredOffers(plan.items);
629
+ if (report.unansweredOffers.length >= OFFER_LIMIT)
630
+ report.warnings.push(
631
+ `${report.unansweredOffers.length} offers are open and unanswered; the offers rule says to present no further offer at ${OFFER_LIMIT} or more. Queue the new decision in a walk-through (hr-what-next, Walk-through) instead`,
632
+ );
633
+ const audit = plan.project && auditWarning(plan.project, roots, options.today);
634
+ if (audit) report.warnings.push(audit);
635
+ report.recheckDue = rechecks(open, options.today, report.warnings);
636
+ report.stale = await possiblyFinished(open, options, report);
637
+ if (options.archive && !report.errors.length) {
638
+ if (!COMPLETED_STATUS.test(plan.status))
639
+ report.findings.push(`archiving refused: Status is "${plan.status}", not completed`);
640
+ else if (roots?.parts.at(-3) !== "plans")
641
+ report.errors.push("the plan is not at plans/<topic>/plan.md (already archived?)");
642
+ else if (open.length === 0 && !report.findings.length) {
643
+ const target = join(
644
+ roots.plans,
645
+ "archive",
646
+ `${basename(dirname(planPath))}-${new Date().toLocaleDateString("sv")}`,
647
+ );
648
+ if (existsSync(target)) report.errors.push(`${target} already exists`);
649
+ else {
650
+ mkdirSync(dirname(target), { recursive: true });
651
+ renameSync(dirname(planPath), target);
652
+ report.archived = target;
653
+ }
654
+ }
655
+ }
656
+ return report;
657
+ }
658
+
659
+ async function main() {
660
+ let options;
661
+ let report;
662
+ try {
663
+ options = parseArguments(process.argv.slice(2));
664
+ report = await run(options);
665
+ } catch (error) {
666
+ console.error(error.message);
667
+ process.exit(1);
668
+ }
669
+ const code = report.errors.length ? 1 : report.findings.length || report.open.length ? 2 : 0;
670
+ if (report.reconciliation)
671
+ Object.assign(report.reconciliation, {
672
+ exit: code,
673
+ errors: report.errors,
674
+ findings: report.findings,
675
+ });
676
+ if (options.json) console.log(JSON.stringify({ exit: code, ...report }, null, 2));
677
+ else {
678
+ console.log(
679
+ `plan: ${report.plan}\nstatus: ${report.status ?? "?"} | owner: ${report.owner ?? "?"}`,
680
+ );
681
+ if (code !== 1) console.log(`unanswered offers: ${report.unansweredOffers.length}`);
682
+ for (const [label, rows] of [
683
+ ["error", report.errors],
684
+ ["open", report.open],
685
+ ["awaiting your yes/no", report.awaitingUser],
686
+ ["unanswered offer", report.unansweredOffers],
687
+ ["possibly finished", report.stale],
688
+ ["re-check due", report.recheckDue],
689
+ ["skipped", report.skipped],
690
+ ["finding", report.findings],
691
+ ["warning", report.warnings],
692
+ ])
693
+ for (const row of rows) console.log(`${label}: ${row}`);
694
+ if (report.archived) console.log(`archived: ${report.archived}`);
695
+ console.log(
696
+ code === 0
697
+ ? "result: nothing open"
698
+ : code === 1
699
+ ? "result: invalid input"
700
+ : `result: ${report.open.length} open item(s) (${report.awaitingUser.length} awaiting your yes/no), ${report.findings.length} finding(s)`,
701
+ );
702
+ }
703
+ process.exit(code);
704
+ }
705
+
706
+ if (
707
+ process.argv[1] &&
708
+ existsSync(process.argv[1]) &&
709
+ import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href
710
+ )
711
+ await main();