planr 2.0.0-beta.1 → 2.0.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "planr",
3
- "version": "2.0.0-beta.1",
3
+ "version": "2.0.1",
4
4
  "description": "Small planning layer for agent-built products: Markdown task graph, CLI, board.",
5
5
  "author": {
6
6
  "name": "instructa"
package/CHANGELOG.md CHANGED
@@ -3,6 +3,19 @@
3
3
  This file follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
4
4
  Versions follow [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [2.0.1] - 2026-10-20
7
+
8
+ ### Fixed
9
+
10
+ - Run history is rebuilt in linear time: `check` and `board` take 0.3 s instead of 3 s on a repository
11
+ with 2,700 commits and stay under 10 s at ten times that size. Output is unchanged.
12
+ - A failed Git history read now reports `history: … runs unavailable` instead of an empty history.
13
+
14
+ ## [2.0.0] - 2026-10-05
15
+
16
+ First stable release of planr 2.0, unchanged from 2.0.0-beta.1; see the entry below for the changes.
17
+ Version 1.x remains available as `planr@1` on npm and `instructa/tap/planr@1` on Homebrew.
18
+
6
19
  ## [2.0.0-beta.1] - 2026-10-05
7
20
 
8
21
  ### Changed
package/README.md CHANGED
@@ -2,10 +2,6 @@
2
2
 
3
3
  A small planning layer for agent-built products, using Markdown files in Git.
4
4
 
5
- During the 2.0 beta, use `npm install -g planr@next` or `npx planr@next`; plain `planr` installs 1.x
6
- until 2.0.0 ships.
7
- Plugin installs get the beta through the pinned marketplaces.
8
-
9
5
  When several coding agents work on one product, planr shows what is ready,
10
6
  blocked, waiting on you & what changed. Tasks & their dependencies live in a
11
7
  planning repository, usually separate from your code & often private.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "planr",
3
- "version": "2.0.0-beta.1",
3
+ "version": "2.0.1",
4
4
  "description": "Small planning layer for agent-built products: Markdown task graph, CLI, board.",
5
5
  "type": "module",
6
6
  "bin": { "planr": "skills/planr/scripts/planr.mjs" },
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "planr",
4
- "version": "2.0.0-beta.1",
4
+ "version": "2.0.1",
5
5
  "description": "Small planning layer for agent-built products: Markdown task graph, CLI, board.",
6
6
  "author": {
7
7
  "name": "instructa"
@@ -3,7 +3,7 @@ name: planr
3
3
  license: MIT
4
4
  description: Plan and track product work as a Markdown task graph in a planning repository - see what is ready, blocked or waiting on a decision, change task status safely, and work through one approved goal with the project's orchestrator. Use when the project keeps tasks as tasks/<ID>.md and goals as plans/<name>-goal.md, or when asked what is next, about task status, ready or blocked tasks, or to "work goal <name>". Not for building code itself.
5
5
  metadata:
6
- version: "2.0.0-beta.1"
6
+ version: "2.0.1"
7
7
  ---
8
8
 
9
9
  # planr
@@ -4,8 +4,8 @@ import { join, resolve, relative, dirname } from 'node:path';
4
4
 
5
5
  export const statuses = ['open', 'in-progress', 'review', 'blocked', 'decision', 'done', 'dropped'];
6
6
  export const states = ['draft', 'approved', 'running', 'done', 'review'];
7
- export const compare = (a, b) =>
8
- a.localeCompare(b, 'en', { numeric: true }) || (a < b ? -1 : a > b ? 1 : 0);
7
+ const collator = new Intl.Collator('en', { numeric: true });
8
+ export const compare = (a, b) => collator.compare(a, b) || (a < b ? -1 : a > b ? 1 : 0);
9
9
  export const json = value => JSON.stringify(value, null, 2) + '\n';
10
10
 
11
11
  export function config(root, defaults = JSON.parse(readFileSync(
@@ -197,16 +197,23 @@ export function buildIndex(root, c = config(root), read = file => readFileSync(f
197
197
  }
198
198
  t.openDeps = t.depends.filter(d => !['review', 'done'].includes(byId.get(d)?.status));
199
199
  t.ready = t.status === 'open' && !t.openDeps.length;
200
- t.waitingOn = tasks
201
- .filter(x => !['done', 'dropped'].includes(x.status) && x.depends.includes(t.id))
202
- .map(x => x.id);
200
+ }
201
+ // Reverse edges in one pass; tasks are sorted, so each list keeps the sorted order.
202
+ const waitingOn = new Map(tasks.map(t => [t.id, []]));
203
+ const unlocks = new Map();
204
+ for (const x of tasks) {
205
+ if (!['done', 'dropped'].includes(x.status)) {
206
+ for (const d of new Set(x.depends)) {
207
+ waitingOn.get(d)?.push(x.id);
208
+ }
209
+ }
210
+ if (x.status === 'open' && x.openDeps.length === 1) {
211
+ unlocks.set(x.openDeps[0], (unlocks.get(x.openDeps[0]) ?? 0) + 1);
212
+ }
203
213
  }
204
214
  for (const t of tasks) {
205
- t.unlocks = ['review', 'done', 'dropped'].includes(t.status)
206
- ? 0
207
- : tasks.filter(x =>
208
- x.status === 'open' && x.openDeps.length === 1 && x.openDeps[0] === t.id
209
- ).length;
215
+ t.waitingOn = [...waitingOn.get(t.id)];
216
+ t.unlocks = ['review', 'done', 'dropped'].includes(t.status) ? 0 : unlocks.get(t.id) ?? 0;
210
217
  }
211
218
  const visited = new Set();
212
219
  const active = [];
@@ -1,4 +1,4 @@
1
- import { execFileSync } from 'node:child_process';
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
2
  import { posix } from 'node:path';
3
3
  import { compare, taskFile, taskStatus } from './graph.mjs';
4
4
 
@@ -17,6 +17,16 @@ export function git(root, args, input) {
17
17
  }
18
18
  }
19
19
 
20
+ // History reads must fail loudly: a silently empty or truncated read would rewrite every run.
21
+ function strict(root, args, input) {
22
+ const out = spawnSync('git', args, { cwd: root, input, maxBuffer: 1 << 30 });
23
+ if (out.error || out.status !== 0) {
24
+ const reason = out.error?.message ?? String(out.stderr).trim().split('\n')[0];
25
+ throw new Error(`git ${args[0]} failed: ${reason}`);
26
+ }
27
+ return out.stdout;
28
+ }
29
+
20
30
  export function runs(root, index, c, now = new Date().toISOString()) {
21
31
  const mode = git(root, ['rev-parse', '--is-shallow-repository']);
22
32
  const warnings = mode === 'false' ? [] : [
@@ -25,10 +35,18 @@ export function runs(root, index, c, now = new Date().toISOString()) {
25
35
  ];
26
36
  const prefix = git(root, ['rev-parse', '--show-prefix']) +
27
37
  posix.normalize(c.tasksDir).replace(/\/$/, '') + '/';
28
- const log = mode ? git(root, [
29
- 'log', '--first-parent', '--reverse', '--format=%x00PLANR%x09%H%x09%cI%x09%s',
30
- '--raw', '-z', '--no-abbrev', '--no-renames', '--root', '--diff-merges=first-parent', '--', c.tasksDir,
31
- ]) : '';
38
+ let log = '';
39
+ let batch = Buffer.alloc(0);
40
+ let failed = false;
41
+ try {
42
+ log = mode && git(root, ['rev-parse', '--verify', '--quiet', 'HEAD']) ? strict(root, [
43
+ 'log', '--first-parent', '--reverse', '--format=%x00PLANR%x09%H%x09%cI%x09%s', '--raw', '-z',
44
+ '--no-abbrev', '--no-renames', '--root', '--diff-merges=first-parent', '--', c.tasksDir,
45
+ ]).toString('utf8').trimEnd() : '';
46
+ } catch (e) {
47
+ warnings.push(`history: ${e.message}; runs unavailable`);
48
+ failed = true;
49
+ }
32
50
  const records = [];
33
51
  const parts = log.split('\0');
34
52
  for (let i = 0; i < parts.length; i++) {
@@ -47,9 +65,13 @@ export function runs(root, index, c, now = new Date().toISOString()) {
47
65
  const hashes = [...new Set(records.flatMap(r =>
48
66
  r.files.filter(f => !f.removed).map(f => f.blob)
49
67
  ))];
50
- const batch = hashes.length
51
- ? git(root, ['cat-file', '--batch'], hashes.join('\n') + '\n')
52
- : Buffer.alloc(0);
68
+ try {
69
+ batch = hashes.length ? strict(root, ['cat-file', '--batch'], hashes.join('\n') + '\n') : batch;
70
+ } catch (e) {
71
+ warnings.push(`history: ${e.message}; runs unavailable`);
72
+ failed = true;
73
+ records.length = 0;
74
+ }
53
75
  const blobs = new Map();
54
76
  let cursor = 0;
55
77
  while (cursor < batch.length) {
@@ -67,13 +89,14 @@ export function runs(root, index, c, now = new Date().toISOString()) {
67
89
  }
68
90
  const result = [];
69
91
  const history = new Map(index.tasks.map(t => [t.id, []]));
70
- let previous = new Map();
71
- const changesBetween = current => [...new Set([...previous.keys(), ...current.keys()])]
72
- .sort(compare)
73
- .filter(id => current.get(id) !== previous.get(id))
74
- .map(id => ({ id, from: previous.get(id) ?? null, to: current.get(id) ?? 'removed' }));
75
- const append = (s, current) => {
76
- const changes = changesBetween(current);
92
+ // One status map is updated in place per commit; only the IDs a commit touched are compared and sorted.
93
+ const current = new Map();
94
+ const append = (s, before) => {
95
+ const changes = [...before]
96
+ .filter(([id, from]) => current.get(id) !== from)
97
+ .map(([id]) => id)
98
+ .sort(compare)
99
+ .map(id => ({ id, from: before.get(id) ?? null, to: current.get(id) ?? 'removed' }));
77
100
  if (changes.length) {
78
101
  const initial = !result.length;
79
102
  result.push({
@@ -92,12 +115,14 @@ export function runs(root, index, c, now = new Date().toISOString()) {
92
115
  history.get(change.id)?.push({ run: result.length - 1, from: change.from, to: change.to });
93
116
  }
94
117
  }
95
- previous = current;
96
118
  };
97
119
  for (const record of records) {
98
- const current = new Map(previous);
120
+ const before = new Map();
99
121
  for (const f of record.files) {
100
122
  const id = f.file.slice(prefix.length, -3);
123
+ if (!before.has(id)) {
124
+ before.set(id, current.get(id));
125
+ }
101
126
  if (f.removed) {
102
127
  current.delete(id);
103
128
  } else {
@@ -108,11 +133,20 @@ export function runs(root, index, c, now = new Date().toISOString()) {
108
133
  }
109
134
  }
110
135
  }
111
- append(record, current);
136
+ append(record, before);
112
137
  }
113
- if (mode) {
114
- append({ commit: null, date: now, subject: 'Uncommitted changes' },
115
- new Map(index.tasks.map(t => [t.id, t.status])));
138
+ if (mode && !failed) {
139
+ const before = new Map(current);
140
+ current.clear();
141
+ for (const t of index.tasks) {
142
+ current.set(t.id, t.status);
143
+ }
144
+ for (const id of current.keys()) {
145
+ if (!before.has(id)) {
146
+ before.set(id, undefined);
147
+ }
148
+ }
149
+ append({ commit: null, date: now, subject: 'Uncommitted changes' }, before);
116
150
  }
117
151
  const tasks = index.tasks.map(t => {
118
152
  const h = history.get(t.id);