planrails 0.2.0 → 0.2.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1 — 2026-09-12
4
+
5
+ A careful post-release review, including an end-to-end test of the published
6
+ package, found one robustness gap in the checker and fixed it.
7
+
8
+ - The checker finds a task table by its columns (`id`, `status`, `proof`,
9
+ `evidence`), not by a `## Tasks` heading. So tasks under `## Phase 2 Tasks`,
10
+ `## Backlog`, any heading, or none at all are all gated — a phased plan is no
11
+ longer rejected with a confusing "no readable Tasks table". A table under a
12
+ literal `## Tasks` heading that is missing a column still names which one.
13
+ - Verified against the live 0.2.0 package: `npx planrails init` copies only into
14
+ `.project-management/`, leaves `package.json` and a pnpm lockfile untouched,
15
+ writes no `node_modules` and no `CLAUDE.md`, and the checker catches every
16
+ faked-"done" bypass. 33 tests.
17
+
3
18
  ## 0.2.0 — 2026-09-12
4
19
 
5
20
  Rebuilt as a planner prompt and one checker, with a two-command CLI that only
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "planrails",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Plans that survive a lost session, and \"done\" that means done. A planner prompt and a tiny checker.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -80,41 +80,55 @@ const isRow = (l) => l.trim().startsWith("|");
80
80
  const isHeading = (l) => /^#{1,6}\s/.test(l.trim());
81
81
  const isSeparator = (l) => /^\|[\s:|-]+\|?\s*$/.test(l.trim());
82
82
 
83
+ const REQUIRED = ["id", "status", "proof", "evidence"];
84
+ /** The column indices of a row, and which required columns it is missing. */
85
+ function headerCols(rowText) {
86
+ const h = cells(rowText).map((c) => c.toLowerCase());
87
+ const ci = { id: h.indexOf("id"), task: h.indexOf("task"), status: h.indexOf("status"), proof: h.indexOf("proof"), evidence: h.indexOf("evidence") };
88
+ return { ci, missing: REQUIRED.filter((k) => ci[k] === -1) };
89
+ }
90
+
83
91
  /**
84
- * Parse the task rows of a plan. Scans EVERY "## Tasks" section; within a
85
- * section it collects all table rows to the next heading, so a blank line in the
86
- * middle does not end the table and a second table is not lost. Columns are
87
- * matched by name, in any order. Returns { found, tasks, missingCols }.
92
+ * Parse the task rows of a plan. A "task table" is any markdown table whose
93
+ * header carries the four columns id, status, proof, evidence found by its
94
+ * columns, not by a heading, so tasks under "## Tasks", "## Phase 2 Tasks",
95
+ * "## Backlog", or a second table are all read; blank lines inside a table are
96
+ * tolerated. Returns { found, tasks, missingCols }.
88
97
  */
89
98
  export function parseTasks(text) {
90
99
  const lines = text.split(/\r?\n/);
91
- let found = false;
92
100
  const tasks = [];
93
- const missingCols = new Set();
94
- for (let i = 0; i < lines.length; i++) {
95
- if (!/^#{1,6}\s+tasks\b/i.test(lines[i].trim())) continue;
96
- const rows = [];
101
+ let found = false;
102
+ let i = 0;
103
+ while (i < lines.length) {
104
+ if (!isRow(lines[i]) || isSeparator(lines[i]) || headerCols(lines[i]).missing.length) { i++; continue; }
105
+ found = true; // lines[i] is a task-table header (a | row naming all four columns)
106
+ const { ci } = headerCols(lines[i]);
97
107
  let j = i + 1;
98
108
  for (; j < lines.length; j++) {
99
- if (isHeading(lines[j])) break;
100
- if (isRow(lines[j])) rows.push({ text: lines[j], line: j + 1 });
101
- }
102
- i = j - 1;
103
- if (!rows.length) continue;
104
- const header = cells(rows[0].text).map((h) => h.toLowerCase());
105
- const ci = { id: header.indexOf("id"), task: header.indexOf("task"), status: header.indexOf("status"), proof: header.indexOf("proof"), evidence: header.indexOf("evidence") };
106
- const missing = ["id", "status", "proof", "evidence"].filter((k) => ci[k] === -1);
107
- if (missing.length) { missing.forEach((m) => missingCols.add(m)); continue; }
108
- found = true;
109
- for (const row of rows.slice(1)) {
110
- if (isSeparator(row.text)) continue;
111
- const c = cells(row.text);
109
+ const l = lines[j];
110
+ if (isHeading(l)) break; // a heading ends the table
111
+ if (l.trim() === "") continue; // a blank line inside the table does not
112
+ if (!isRow(l)) break; // prose ends the table
113
+ if (isSeparator(l)) continue;
114
+ if (headerCols(l).missing.length === 0) break; // the next table's header — reprocess it
115
+ const c = cells(l);
112
116
  const at = (idx) => (idx >= 0 && idx < c.length ? c[idx] : "");
113
- if (norm(at(ci.status)) === "status" && norm(at(ci.id)) === "id") continue; // a repeated header row
114
- tasks.push({ id: at(ci.id), task: at(ci.task), status: at(ci.status), proof: at(ci.proof), evidence: at(ci.evidence), line: row.line });
117
+ tasks.push({ id: at(ci.id), task: at(ci.task), status: at(ci.status), proof: at(ci.proof), evidence: at(ci.evidence), line: j + 1 });
118
+ }
119
+ i = j;
120
+ }
121
+ // A helpful message for the common slip: a "## Tasks" table missing one column.
122
+ let missingCols = [];
123
+ if (!found) {
124
+ for (let k = 0; k < lines.length && !missingCols.length; k++) {
125
+ if (!/^#{1,6}\s+tasks\b/i.test(lines[k].trim())) continue;
126
+ for (let m = k + 1; m < lines.length && !isHeading(lines[m]); m++) {
127
+ if (isRow(lines[m]) && !isSeparator(lines[m])) { const miss = headerCols(lines[m]).missing; if (miss.length && miss.length < REQUIRED.length) missingCols = miss; break; }
128
+ }
115
129
  }
116
130
  }
117
- return { found, tasks, missingCols: [...missingCols] };
131
+ return { found, tasks, missingCols };
118
132
  }
119
133
 
120
134
  /** The command inside a proof cell (backticks stripped), or null for `owner`/prose/empty. */
@@ -129,7 +143,7 @@ export function checkPlan({ id, text, verify = false, run = null }) {
129
143
  const { found, tasks, missingCols } = parseTasks(text);
130
144
  if (!found) {
131
145
  if (missingCols.length) problems.push(`${id}: the Tasks table is missing the ${missingCols.map((c) => `"${c}"`).join(", ")} column(s)`);
132
- else problems.push(`${id}: no readable Tasks table (needs a | id | task | status | proof | evidence | table under a "## Tasks" heading)`);
146
+ else problems.push(`${id}: no readable Tasks table (needs a | id | task | status | proof | evidence | table)`);
133
147
  return problems;
134
148
  }
135
149
  for (const t of tasks) {