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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +13 -0
- package/README.md +0 -4
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/planr/SKILL.md +1 -1
- package/skills/planr/scripts/lib/graph.mjs +17 -10
- package/skills/planr/scripts/lib/runs.mjs +55 -21
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
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.
|
|
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"
|
package/skills/planr/SKILL.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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.
|
|
206
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
51
|
-
?
|
|
52
|
-
|
|
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
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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,
|
|
136
|
+
append(record, before);
|
|
112
137
|
}
|
|
113
|
-
if (mode) {
|
|
114
|
-
|
|
115
|
-
|
|
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);
|