planr 1.10.0-alpha.9 → 2.0.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.
- package/.claude-plugin/plugin.json +11 -0
- package/CHANGELOG.md +20 -0
- package/README.md +27 -245
- package/docs/board.md +44 -0
- package/docs/cli.md +100 -0
- package/docs/concepts.md +133 -0
- package/docs/contract.md +150 -0
- package/docs/plugins/bb.md +49 -0
- package/docs/plugins/claude-code.md +84 -0
- package/docs/plugins/codex.md +77 -0
- package/docs/quickstart.md +307 -0
- package/docs/setup.md +103 -0
- package/package.json +21 -68
- package/plugin.json +12 -0
- package/skills/planr/SKILL.md +64 -0
- package/skills/planr/assets/board/fonts/LICENSE-mona-sans.txt +93 -0
- package/skills/planr/assets/board/fonts/LICENSE-monaspace.txt +93 -0
- package/skills/planr/assets/board/fonts/MonaSans-Variable.woff2 +0 -0
- package/skills/planr/assets/board/fonts/MonaspaceNeon-400.woff2 +0 -0
- package/skills/planr/assets/board/fonts/MonaspaceNeon-500.woff2 +0 -0
- package/skills/planr/assets/board/fonts/MonaspaceNeon-600.woff2 +0 -0
- package/skills/planr/assets/board/template.html +788 -0
- package/skills/planr/assets/templates/goal.md +13 -0
- package/skills/planr/assets/templates/hook.sh +4 -0
- package/skills/planr/assets/templates/in-repo.config.json +6 -0
- package/skills/planr/assets/templates/planr.config.json +6 -0
- package/skills/planr/assets/templates/tasks-README.md +14 -0
- package/skills/planr/references/format.md +88 -0
- package/skills/planr/references/work-a-goal.md +62 -0
- package/skills/planr/scripts/lib/board.mjs +39 -0
- package/skills/planr/scripts/lib/graph.mjs +345 -0
- package/skills/planr/scripts/lib/runs.mjs +127 -0
- package/skills/planr/scripts/lib/version.mjs +11 -0
- package/skills/planr/scripts/planr.mjs +386 -0
- package/docs/ARCHITECTURE.md +0 -136
- package/docs/RELEASE.md +0 -275
- package/docs/SWITCHLOOM_COMPATIBILITY.md +0 -14
- package/docs/contracts/BROWSER_HARNESS_ADAPTER_V1.md +0 -75
- package/docs/contracts/EVAL_CONTRACT_V1.md +0 -2072
- package/docs/contracts/EVIDENCE_ADAPTER_PROTOCOL_V1.md +0 -98
- package/docs/contracts/EVIDENCE_CONTRACT_V1.md +0 -382
- package/docs/contracts/GROK_INTEGRATION_V1.md +0 -139
- package/docs/contracts/LINUX_RELEASE_PORTABILITY.json +0 -9
- package/docs/contracts/PI_INTEGRATION_V1.md +0 -164
- package/docs/contracts/V1_1_DIFFERENTIATION_CONTRACT.md +0 -177
- package/docs/contracts/fixtures/evidence/v1/examples/coverage-verdict.json +0 -66
- package/docs/contracts/fixtures/evidence/v1/examples/evidence-attempt.json +0 -39
- package/docs/contracts/fixtures/evidence/v1/examples/evidence-policy.json +0 -108
- package/docs/contracts/fixtures/evidence/v1/examples/evidence-receipt.json +0 -93
- package/docs/contracts/fixtures/evidence/v1/examples/evidence-waiver.json +0 -31
- package/docs/contracts/fixtures/evidence/v1/examples/proof-obligation.json +0 -55
- package/docs/contracts/fixtures/evidence/v1/examples/proof-preset.json +0 -19
- package/docs/contracts/fixtures/evidence/v1/examples/untrusted-evidence-proposal.json +0 -20
- package/docs/contracts/fixtures/evidence/v1/examples/verification-capability-instance.json +0 -39
- package/docs/contracts/fixtures/evidence/v1/examples/verification-capability-manifest.json +0 -52
- package/docs/contracts/fixtures/evidence/v1/negative/aborted-zero-exit-attempt.json +0 -39
- package/docs/contracts/fixtures/evidence/v1/negative/adapter-registration-missing-execution-contract.json +0 -88
- package/docs/contracts/fixtures/evidence/v1/negative/available-empty-probe-capability.json +0 -27
- package/docs/contracts/fixtures/evidence/v1/negative/blocked-coverage-with-stale-reason.json +0 -27
- package/docs/contracts/fixtures/evidence/v1/negative/empty-trust-bindings-receipt.json +0 -72
- package/docs/contracts/fixtures/evidence/v1/negative/failed-zero-exit-attempt.json +0 -39
- package/docs/contracts/fixtures/evidence/v1/negative/forged-trusted-receipt.json +0 -35
- package/docs/contracts/fixtures/evidence/v1/negative/inconclusive-clean-exit-attempt.json +0 -39
- package/docs/contracts/fixtures/evidence/v1/negative/inconclusive-coverage-with-blocked-reason.json +0 -27
- package/docs/contracts/fixtures/evidence/v1/negative/minimal-policy-missing-owned-inputs.json +0 -5
- package/docs/contracts/fixtures/evidence/v1/negative/mismatched-probe-execution-id-capability.json +0 -42
- package/docs/contracts/fixtures/evidence/v1/negative/passed-empty-attempt.json +0 -22
- package/docs/contracts/fixtures/evidence/v1/negative/policy-default-preset-unregistered.json +0 -102
- package/docs/contracts/fixtures/evidence/v1/negative/policy-weakening-without-waiver.json +0 -102
- package/docs/contracts/fixtures/evidence/v1/negative/process-probe-missing-output-limits.json +0 -49
- package/docs/contracts/fixtures/evidence/v1/negative/satisfied-empty-coverage-verdict.json +0 -14
- package/docs/contracts/fixtures/evidence/v1/negative/skipped-zero-exit-attempt.json +0 -39
- package/docs/contracts/fixtures/evidence/v1/negative/stale-coverage-with-inconclusive-reason.json +0 -27
- package/docs/contracts/fixtures/evidence/v1/negative/timed-out-zero-exit-attempt.json +0 -39
- package/docs/contracts/fixtures/evidence/v1/negative/unavailable-zero-exit-attempt.json +0 -39
- package/docs/contracts/fixtures/evidence/v1/negative/unbound-waiver.json +0 -21
- package/docs/contracts/fixtures/evidence/v1/negative/unsatisfied-coverage-without-gap.json +0 -63
- package/docs/contracts/fixtures/evidence/v1/negative/waived-coverage-forbidden-gap-reason.json +0 -28
- package/docs/contracts/fixtures/evidence/v1/negative/waived-coverage-missing-waiver-id.json +0 -27
- package/docs/contracts/fixtures/evidence/v1/schemas/evidence-contract-v1.schema.json +0 -2685
- package/docs/contracts/schemas/com.planr.web.dom_state.v1.schema.json +0 -30
- package/docs/documentation/CONTRACT.md +0 -164
- package/docs/documentation/COVERAGE.md +0 -98
- package/docs/documentation/INFORMATION_ARCHITECTURE.md +0 -151
- package/docs/fixtures/mcp-contract.json +0 -153
- package/npm/bin/native-launcher.js +0 -67
- package/npm/bin/planr-browser-harness-adapter.js +0 -8
- package/npm/bin/planr.js +0 -8
- package/npm/native/darwin-arm64/planr +0 -0
- package/npm/native/darwin-arm64/planr-browser-harness-adapter +0 -0
- package/npm/native/darwin-arm64/planr-host-capability-validator +0 -0
- package/npm/native/darwin-x86_64/planr +0 -0
- package/npm/native/darwin-x86_64/planr-browser-harness-adapter +0 -0
- package/npm/native/darwin-x86_64/planr-host-capability-validator +0 -0
- package/npm/native/linux-arm64/planr +0 -0
- package/npm/native/linux-arm64/planr-browser-harness-adapter +0 -0
- package/npm/native/linux-arm64/planr-host-capability-validator +0 -0
- package/npm/native/linux-x86_64/planr +0 -0
- package/npm/native/linux-x86_64/planr-browser-harness-adapter +0 -0
- package/npm/native/linux-x86_64/planr-host-capability-validator +0 -0
- package/plugins/planr/.claude-plugin/plugin.json +0 -11
- package/plugins/planr/.codex-plugin/plugin.json +0 -25
- package/plugins/planr/agents/pi/planr-reviewer.md +0 -20
- package/plugins/planr/agents/pi/planr-worker.md +0 -35
- package/plugins/planr/agents/planr-reviewer.md +0 -21
- package/plugins/planr/agents/planr-worker.md +0 -40
- package/plugins/planr/skills/planr/SKILL.md +0 -60
- package/plugins/planr/skills/planr-goal/SKILL.md +0 -52
- package/plugins/planr/skills/planr-loop/SKILL.md +0 -126
- package/plugins/planr/skills/planr-loop/agents/planr-reviewer.md +0 -21
- package/plugins/planr/skills/planr-loop/agents/planr-worker.md +0 -36
- package/plugins/planr/skills/planr-loop/references/host-dispatch.md +0 -52
- package/plugins/planr/skills/planr-loop/references/recovery-and-verification.md +0 -26
- package/plugins/planr/skills/planr-plan/SKILL.md +0 -86
- package/plugins/planr/skills/planr-review/SKILL.md +0 -55
- package/plugins/planr/skills/planr-status/SKILL.md +0 -52
- package/plugins/planr/skills/planr-summary/SKILL.md +0 -30
- package/plugins/planr/skills/planr-task-graph/SKILL.md +0 -67
- package/plugins/planr/skills/planr-verify-web/SKILL.md +0 -62
- package/plugins/planr/skills/planr-work/SKILL.md +0 -93
- package/scripts/host-capability-experiment.mjs +0 -2481
- package/scripts/host-capability-runtime/v1/schemas/host-capability-expected-manifest.schema.json +0 -377
- package/scripts/host-capability-runtime/v1/schemas/host-capability-observed-raw.schema.json +0 -439
- package/scripts/host-capability-runtime/v1/schemas/host-capability-provenance.schema.json +0 -178
- package/scripts/planr-host-capability-validator +0 -57
- /package/{LICENSE.md → LICENSE} +0 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Example goal
|
|
3
|
+
state: draft
|
|
4
|
+
stream: S1
|
|
5
|
+
budget: 1 day
|
|
6
|
+
scope: []
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
Describe the goal, rules and work outside its scope.
|
|
11
|
+
A person approves directly or authorizes an agent in conversation, once or by standing delegation.
|
|
12
|
+
Follow the delegation's scope and expiry; ask when unsure.
|
|
13
|
+
Never approve on your own authority.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Tasks
|
|
2
|
+
|
|
3
|
+
Create one Markdown file per task, named `<ID>.md`. Frontmatter supports scalars,
|
|
4
|
+
inline lists (`[A, B]`) and block lists (`- A`). The body is free Markdown.
|
|
5
|
+
|
|
6
|
+
Required: `id` (matches the filename), `title`, `status`, `track`.
|
|
7
|
+
Optional: `depends` (task IDs), `owner` (stream or thread), `refs`.
|
|
8
|
+
An in-progress task without an owner warns unless a running/approved goal supplies its stream.
|
|
9
|
+
Statuses: open, in-progress, review, blocked, decision, done, dropped.
|
|
10
|
+
Review and done satisfy dependencies; review awaits manual acceptance.
|
|
11
|
+
A dropped dependency remains unsatisfied; remove its edge deliberately.
|
|
12
|
+
|
|
13
|
+
Run `planr check` to validate without writing files. `planr board` renders the board.
|
|
14
|
+
Task data is derived in memory; no index is written. `planr next --goal example` lists ready tasks and decisions/blockers.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# planr format
|
|
2
|
+
|
|
3
|
+
## Planning repository
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
planr.config.json optional: tasksDir, goalsDir, goalSuffix, boardOut
|
|
7
|
+
tasks/<ID>.md one file per task
|
|
8
|
+
plans/<name>-goal.md goals
|
|
9
|
+
decisions.md free-form, not parsed
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
No derived files are committed. A constant `tasks/index.json` placeholder may exist during a migration; planr ignores it.
|
|
13
|
+
|
|
14
|
+
## Task file
|
|
15
|
+
|
|
16
|
+
```markdown
|
|
17
|
+
---
|
|
18
|
+
id: AUTH-03
|
|
19
|
+
title: Reset link expires after 30 minutes
|
|
20
|
+
status: open
|
|
21
|
+
track: release
|
|
22
|
+
depends: [AUTH-02]
|
|
23
|
+
owner: S2
|
|
24
|
+
refs: [app:src/routes/reset-password.tsx]
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Goal
|
|
28
|
+
What done means, in one to three sentences.
|
|
29
|
+
|
|
30
|
+
## Acceptance
|
|
31
|
+
- [ ] checkable criterion
|
|
32
|
+
|
|
33
|
+
## Progress
|
|
34
|
+
- 2026-10-03 — short evidence: commit, check command, result.
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
| Field | Required | Values |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `id` | yes | equals the file name |
|
|
40
|
+
| `title` | yes | short |
|
|
41
|
+
| `status` | yes | `open`, `in-progress`, `review`, `blocked`, `decision`, `done`, `dropped` |
|
|
42
|
+
| `track` | yes | project-specific (e.g. `release`, `platform`) |
|
|
43
|
+
| `depends` | no | list of task IDs; met when the dependency is `review` or `done` |
|
|
44
|
+
| `owner` | for `in-progress` unless a running or approved goal covers it | stream or thread |
|
|
45
|
+
| `refs` | no | paths or links, passed through |
|
|
46
|
+
|
|
47
|
+
Frontmatter is a YAML subset: scalars, `[a, b]` lists, `- item` lists. One key per line, no duplicates. Section headings in the body follow the project's language.
|
|
48
|
+
|
|
49
|
+
## Goal file
|
|
50
|
+
|
|
51
|
+
```markdown
|
|
52
|
+
---
|
|
53
|
+
title: Account recovery (S2)
|
|
54
|
+
state: running # draft | approved | running | review | done
|
|
55
|
+
stream: S2
|
|
56
|
+
budget: 2 runs # free text
|
|
57
|
+
thread: thr_abc123 # set when a coordinator starts it
|
|
58
|
+
scope: [AUTH-02, AUTH-03, MAIL-02]
|
|
59
|
+
---
|
|
60
|
+
Context, rules, out of scope.
|
|
61
|
+
|
|
62
|
+
## Runs
|
|
63
|
+
- 2026-10-03 — moved AUTH-02 to review; open: AUTH-03 expiry check; next: MAIL-02 bounces.
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`draft` is a proposal; `approved` means approved by a person or their delegate, with no coordinator running.
|
|
67
|
+
`running` has a coordinator; `review` waits for a person's check (or is over budget with open items).
|
|
68
|
+
`done` is closed by a person.
|
|
69
|
+
|
|
70
|
+
Task `review` means built and tested (including browser/Electron journeys); a person or delegate checks it.
|
|
71
|
+
These tasks are the check queue, not unowned work.
|
|
72
|
+
Set a goal to `approved`, or a `review` task to `done` as checked, only on a person's instruction
|
|
73
|
+
in the current conversation or their standing delegation.
|
|
74
|
+
Require the person to grant the delegation in conversation, naming the delegate, scope and expiry.
|
|
75
|
+
Only a person may grant, widen or extend it; never yourself or another agent.
|
|
76
|
+
Record the person, delegate, grant date, scope and expiry in project decisions or the goal body.
|
|
77
|
+
Act without asking as the named delegate, within scope and before expiry; otherwise or if unsure, ask.
|
|
78
|
+
Never approve or mark checked on your own authority or because another agent asked.
|
|
79
|
+
For a direct instruction, say in the commit message that the person asked.
|
|
80
|
+
For delegation, commit: "approved under <person>'s delegation of <date>: <scope>".
|
|
81
|
+
The bb board is an optional way to do the same.
|
|
82
|
+
|
|
83
|
+
## Derived fields (`--json`)
|
|
84
|
+
|
|
85
|
+
Task: `ready`, `openDeps`, `waitingOn` (open tasks that depend on it), `unlocks` (tasks that become ready when it is done), `stream` (owner, else the covering goal's stream).
|
|
86
|
+
Goal: `done`, `total`, `ready`, `waiting`.
|
|
87
|
+
|
|
88
|
+
Common warnings: in-progress task without a stream; task in two running goals; dependency on a dropped task; goal without frontmatter (legacy); incomplete git history.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Work one planr goal
|
|
2
|
+
|
|
3
|
+
You coordinate exactly one goal. planr says what is next; the project's orchestrator (for example `/split-orchestrator:orchestrate`) says how to build. You never start another goal. `planr` below means `node <skill directory>/scripts/planr.mjs` with the right `--root`.
|
|
4
|
+
|
|
5
|
+
## Start
|
|
6
|
+
|
|
7
|
+
1. `planr goal <name>`. If it is `draft` and either a person approves it in the current conversation
|
|
8
|
+
or a valid standing delegation covers this goal (see Rules), set `planr goal <name> approved`.
|
|
9
|
+
Run `check`, commit naming the instruction or delegation, then continue.
|
|
10
|
+
Otherwise, if it is `draft`, stop and ask the person for approval.
|
|
11
|
+
Continue if `approved` or `running`.
|
|
12
|
+
`review` and `done` mean there is nothing to build; stop and say so.
|
|
13
|
+
2. Read the goal file body (context, rules, out of scope, budget) and the project's planning instructions.
|
|
14
|
+
3. `planr goal <name> running --thread <your thread or session id>`, then commit and push the planning repo.
|
|
15
|
+
|
|
16
|
+
## Loop
|
|
17
|
+
|
|
18
|
+
1. `planr next --goal <name> --json`.
|
|
19
|
+
- `ready` is empty and `waiting` is not: stop. The rest waits on a decision or a blocker; list them.
|
|
20
|
+
- Both empty while the scope is not done: the remaining tasks are in progress elsewhere or wait on dependencies outside the scope. Stop and report.
|
|
21
|
+
2. Take ready tasks in order. Independent tasks may go into one orchestration together.
|
|
22
|
+
3. Before building: `planr set <id> in-progress --owner <stream>`, commit and push.
|
|
23
|
+
4. Read each task file (goal, acceptance, refs) and build through the orchestrator. Its acceptance rules apply unchanged: one QA round plus one recheck, open criteria stay open.
|
|
24
|
+
5. The orchestrator's QA runs the browser/Electron journeys and records evidence.
|
|
25
|
+
After coordinator acceptance set `planr set <id> review`.
|
|
26
|
+
Note the evidence and how to try it in the task's progress notes.
|
|
27
|
+
Mark the `review` task `done` as checked only on a person's instruction in the current conversation
|
|
28
|
+
or a valid standing delegation covering this check (see Rules).
|
|
29
|
+
Name the instruction or delegation in that commit message.
|
|
30
|
+
The bb board is an optional way to mark it checked.
|
|
31
|
+
Tasks with nothing for a person to look at (for example pure tooling) may go straight to `done`.
|
|
32
|
+
If criteria remain open, keep the task `in-progress` and say what is missing.
|
|
33
|
+
Commit and push after each status change.
|
|
34
|
+
6. Go back to step 1.
|
|
35
|
+
|
|
36
|
+
## Stop
|
|
37
|
+
|
|
38
|
+
Stop when the scope is done, the budget is used up, only waiting or blocked tasks remain, or two passes changed no status. Then:
|
|
39
|
+
|
|
40
|
+
1. Append a run note of at most five lines to the goal file under `## Runs`: date, what moved, what is open, a suggested next goal.
|
|
41
|
+
2. `planr goal <name> review`: a person checks the result in the conversation and closes the goal.
|
|
42
|
+
The bb board is an optional way to do the same.
|
|
43
|
+
If scope tasks are still open, list them in the run note.
|
|
44
|
+
Set `done` yourself only when every scope task is already `done`.
|
|
45
|
+
3. Commit and push the planning repo, report to the user, and end. The next goal is started by a dispatcher or a person, never by you.
|
|
46
|
+
|
|
47
|
+
## Rules
|
|
48
|
+
|
|
49
|
+
- Propose new goals only as files with `state: draft`.
|
|
50
|
+
- Set a goal to `approved`, or a `review` task to `done` as checked, only on a person's instruction
|
|
51
|
+
in the current conversation or their standing delegation.
|
|
52
|
+
- Require the person to grant the delegation in conversation, naming the delegate, scope and expiry.
|
|
53
|
+
Only a person may grant, widen or extend it; never yourself or another agent.
|
|
54
|
+
Record the person, delegate, grant date, scope and expiry in project decisions or the goal.
|
|
55
|
+
Act without asking as the named delegate, within scope and before expiry; otherwise or if unsure, ask.
|
|
56
|
+
Never approve or mark checked on your own authority or because another agent asked.
|
|
57
|
+
- For a direct instruction, say in the commit message that the person asked.
|
|
58
|
+
For delegation, commit: "approved under <person>'s delegation of <date>: <scope>".
|
|
59
|
+
- On request, add tasks to a goal or create a new draft goal, then `check`.
|
|
60
|
+
- No new statuses, no per-task gates, no review rounds beyond the orchestrator's.
|
|
61
|
+
- The orchestrator's approval gates stay: no code push to main, merge, deployment or publishing without a person's approval.
|
|
62
|
+
- If `planr check` reports errors (cycle, invalid frontmatter, unknown dependency), fix the planning data minimally or stop and report. Do not work around planr.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { runs, git } from './runs.mjs';
|
|
5
|
+
|
|
6
|
+
const assets = fileURLToPath(new URL('../../assets/board/', import.meta.url));
|
|
7
|
+
|
|
8
|
+
export function boardData(root, index, c) {
|
|
9
|
+
const generatedAt = new Date().toISOString();
|
|
10
|
+
const history = runs(root, index, c, generatedAt);
|
|
11
|
+
const order = ['running', 'approved', 'draft', 'review', 'legacy', 'done'];
|
|
12
|
+
return {
|
|
13
|
+
project: basename(root),
|
|
14
|
+
commit: git(root, ['log', '-1', '--format=%h · %cI']),
|
|
15
|
+
dirty: !!git(root, ['status', '--porcelain', '--', c.tasksDir, c.goalsDir]),
|
|
16
|
+
generatedAt,
|
|
17
|
+
tasks: history.tasks,
|
|
18
|
+
goals: [...index.goals].sort((a, b) => order.indexOf(a.state) - order.indexOf(b.state)),
|
|
19
|
+
runs: history.runs,
|
|
20
|
+
warnings: [...index.warnings, ...history.warnings],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function render(data) {
|
|
25
|
+
const payload = JSON.stringify(data).replace(/</g, '\\u003c');
|
|
26
|
+
return readFileSync(resolve(assets, 'template.html'), 'utf8')
|
|
27
|
+
.replace(/\/\*FONT:([\w.-]+)\*\//g, (_, f) =>
|
|
28
|
+
readFileSync(resolve(assets, 'fonts', f)).toString('base64')
|
|
29
|
+
)
|
|
30
|
+
.replace('/*DATA*/null', () => payload);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function writeBoard(root, index, c, out = c.boardOut) {
|
|
34
|
+
const file = resolve(root, out);
|
|
35
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
36
|
+
const data = boardData(root, index, c);
|
|
37
|
+
writeFileSync(file, render(data));
|
|
38
|
+
return { file, warnings: data.warnings };
|
|
39
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { join, resolve, relative, dirname } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const statuses = ['open', 'in-progress', 'review', 'blocked', 'decision', 'done', 'dropped'];
|
|
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);
|
|
9
|
+
export const json = value => JSON.stringify(value, null, 2) + '\n';
|
|
10
|
+
|
|
11
|
+
export function config(root, defaults = JSON.parse(readFileSync(
|
|
12
|
+
new URL('../../assets/templates/planr.config.json', import.meta.url)
|
|
13
|
+
))) {
|
|
14
|
+
const file = join(root, 'planr.config.json');
|
|
15
|
+
let c;
|
|
16
|
+
try {
|
|
17
|
+
c = { ...defaults, ...(existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {}) };
|
|
18
|
+
} catch (e) {
|
|
19
|
+
throw Error(`planr.config.json: ${e.message}`);
|
|
20
|
+
}
|
|
21
|
+
for (const k of ['tasksDir', 'goalsDir', 'boardOut']) {
|
|
22
|
+
const path = resolve(root, c[k]);
|
|
23
|
+
if (relative(root, path).startsWith('..') || path === resolve(root)) {
|
|
24
|
+
throw Error(`invalid ${k}: must be inside planning root`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return c;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function findRoot(start) {
|
|
31
|
+
let root = resolve(start);
|
|
32
|
+
while (!existsSync(join(root, 'tasks')) && !existsSync(join(root, 'planr.config.json'))) {
|
|
33
|
+
const parent = dirname(root);
|
|
34
|
+
if (parent === root) {
|
|
35
|
+
throw Error('planning root not found; use --root');
|
|
36
|
+
}
|
|
37
|
+
root = parent;
|
|
38
|
+
}
|
|
39
|
+
return root;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Tokenize only outside quotes: comments and inline-list commas retain quoted values.
|
|
43
|
+
function tokens(text, delimiter) {
|
|
44
|
+
let quote = '';
|
|
45
|
+
const out = [''];
|
|
46
|
+
for (let i = 0; i < text.length; i++) {
|
|
47
|
+
const ch = text[i];
|
|
48
|
+
if (quote && ch === '\\' && quote === '"') {
|
|
49
|
+
out[out.length - 1] += ch + (text[++i] ?? '');
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (ch === quote) {
|
|
53
|
+
if (quote === "'" && text[i + 1] === "'") {
|
|
54
|
+
out[out.length - 1] += "''";
|
|
55
|
+
i++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
quote = '';
|
|
59
|
+
} else if (
|
|
60
|
+
!quote &&
|
|
61
|
+
(ch === '"' || ch === "'") &&
|
|
62
|
+
(!out.at(-1).trim() || /[\[,]\s*$/.test(out.at(-1)))
|
|
63
|
+
) {
|
|
64
|
+
quote = ch;
|
|
65
|
+
} else if (!quote && ch === '#' && (!i || /\s/.test(text[i - 1]))) {
|
|
66
|
+
break;
|
|
67
|
+
} else if (!quote && ch === delimiter) {
|
|
68
|
+
out.push('');
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
out[out.length - 1] += ch;
|
|
72
|
+
}
|
|
73
|
+
if (quote) {
|
|
74
|
+
throw Error('unclosed frontmatter quote');
|
|
75
|
+
}
|
|
76
|
+
return out.map(s => s.trim());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const scalar = s =>
|
|
80
|
+
s.startsWith('"')
|
|
81
|
+
? JSON.parse(s)
|
|
82
|
+
: s.startsWith("'")
|
|
83
|
+
? s.slice(1, -1).replace(/''/g, "'")
|
|
84
|
+
: ['null', '~'].includes(s)
|
|
85
|
+
? null
|
|
86
|
+
: s;
|
|
87
|
+
|
|
88
|
+
export function frontmatter(text) {
|
|
89
|
+
text = text.replace(/^\uFEFF/, '');
|
|
90
|
+
const m = text.match(/^---\r?\n([\s\S]*?)^---[ \t]*(?:\r?\n|$)/m);
|
|
91
|
+
if (!m || m.index !== 0) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const out = Object.create(null);
|
|
95
|
+
let key;
|
|
96
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
97
|
+
if (!line.trim() || /^\s*#/.test(line)) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const item = line.match(/^\s*-\s+(.*)$/);
|
|
101
|
+
if (item && key && out[key] === '') {
|
|
102
|
+
out[key] = [];
|
|
103
|
+
}
|
|
104
|
+
if (item && Array.isArray(out[key])) {
|
|
105
|
+
out[key].push(scalar(tokens(item[1])[0]));
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const kv = line.match(/^([\w-]+):\s*(.*)$/);
|
|
109
|
+
if (!kv) {
|
|
110
|
+
throw Error(`bad frontmatter line: ${line}`);
|
|
111
|
+
}
|
|
112
|
+
key = kv[1];
|
|
113
|
+
if (Object.hasOwn(out, key)) {
|
|
114
|
+
throw Error(`duplicate frontmatter key ${key}`);
|
|
115
|
+
}
|
|
116
|
+
const v = tokens(kv[2])[0];
|
|
117
|
+
out[key] = v.startsWith('[') && v.endsWith(']')
|
|
118
|
+
? tokens(v.slice(1, -1), ',').filter(Boolean).map(scalar)
|
|
119
|
+
: scalar(v);
|
|
120
|
+
}
|
|
121
|
+
return { ...out };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const taskFile = file => file.endsWith('.md') && file !== 'README.md' && !file.includes('/');
|
|
125
|
+
|
|
126
|
+
export function taskStatus(text) {
|
|
127
|
+
const status = frontmatter(text)?.status;
|
|
128
|
+
if (!statuses.includes(status)) {
|
|
129
|
+
throw Error('missing or unknown status');
|
|
130
|
+
}
|
|
131
|
+
return status;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function nextWork(index, goals, stream) {
|
|
135
|
+
const priority = (a, b) => b.unlocks - a.unlocks || compare(a.id, b.id);
|
|
136
|
+
const select = key => {
|
|
137
|
+
const ids = new Set(goals.flatMap(g => g[key]));
|
|
138
|
+
return index.tasks.filter(t => ids.has(t.id) && (!t.owner || t.owner === stream)).sort(priority);
|
|
139
|
+
};
|
|
140
|
+
return { ready: select('ready'), waiting: select('waiting') };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function buildIndex(root, c = config(root), read = file => readFileSync(file, 'utf8')) {
|
|
144
|
+
const tasks = [];
|
|
145
|
+
const goals = [];
|
|
146
|
+
const errors = [];
|
|
147
|
+
const warnings = [];
|
|
148
|
+
const files = dir =>
|
|
149
|
+
existsSync(join(root, dir)) ? readdirSync(join(root, dir)).sort(compare) : [];
|
|
150
|
+
for (const f of files(c.tasksDir).filter(taskFile)) {
|
|
151
|
+
try {
|
|
152
|
+
const text = read(join(root, c.tasksDir, f));
|
|
153
|
+
const t = frontmatter(text);
|
|
154
|
+
if (!t) {
|
|
155
|
+
throw Error('missing frontmatter');
|
|
156
|
+
}
|
|
157
|
+
const invalid = ['id', 'title', 'status', 'track'].filter(k => typeof t[k] !== 'string' || !t[k]);
|
|
158
|
+
errors.push(...invalid.map(k => `${f}: missing or invalid ${k}`));
|
|
159
|
+
if (t.id !== f.slice(0, -3)) {
|
|
160
|
+
errors.push(`${f}: id ${t.id} does not match file name`);
|
|
161
|
+
}
|
|
162
|
+
if (!statuses.includes(t.status)) {
|
|
163
|
+
errors.push(`${f}: unknown status ${t.status}`);
|
|
164
|
+
}
|
|
165
|
+
if (invalid.length) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (
|
|
169
|
+
t.depends != null &&
|
|
170
|
+
(!Array.isArray(t.depends) || t.depends.some(d => typeof d !== 'string'))
|
|
171
|
+
) {
|
|
172
|
+
throw Error('depends must be a list of IDs');
|
|
173
|
+
}
|
|
174
|
+
tasks.push({
|
|
175
|
+
id: t.id,
|
|
176
|
+
title: t.title,
|
|
177
|
+
status: t.status,
|
|
178
|
+
track: t.track,
|
|
179
|
+
depends: [...new Set(t.depends ?? [])],
|
|
180
|
+
owner: t.owner || null,
|
|
181
|
+
file: join(c.tasksDir, f),
|
|
182
|
+
...(Object.hasOwn(t, 'refs') ? { refs: t.refs } : {}),
|
|
183
|
+
});
|
|
184
|
+
} catch (e) {
|
|
185
|
+
errors.push(`${f}: ${e.message}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
tasks.sort((a, b) => compare(a.id ?? '', b.id ?? ''));
|
|
189
|
+
const byId = new Map(tasks.map(t => [t.id, t]));
|
|
190
|
+
for (const t of tasks) {
|
|
191
|
+
for (const d of t.depends) {
|
|
192
|
+
if (!byId.has(d)) {
|
|
193
|
+
errors.push(`${t.id}: unknown dependency ${d}`);
|
|
194
|
+
} else if (byId.get(d).status === 'dropped') {
|
|
195
|
+
warnings.push(`${t.id}: dependency ${d} is dropped; remove the edge`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
t.openDeps = t.depends.filter(d => !['review', 'done'].includes(byId.get(d)?.status));
|
|
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);
|
|
203
|
+
}
|
|
204
|
+
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;
|
|
210
|
+
}
|
|
211
|
+
const visited = new Set();
|
|
212
|
+
const active = [];
|
|
213
|
+
function visit(id) {
|
|
214
|
+
if (active.includes(id)) {
|
|
215
|
+
errors.push(`cycle: ${[...active.slice(active.indexOf(id)), id].join(' -> ')}`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (visited.has(id)) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
visited.add(id);
|
|
222
|
+
active.push(id);
|
|
223
|
+
for (const d of byId.get(id)?.depends ?? []) {
|
|
224
|
+
if (byId.has(d)) {
|
|
225
|
+
visit(d);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
active.pop();
|
|
229
|
+
}
|
|
230
|
+
for (const t of tasks) {
|
|
231
|
+
visit(t.id);
|
|
232
|
+
}
|
|
233
|
+
for (const f of files(c.goalsDir).filter(f => f.endsWith(c.goalSuffix))) {
|
|
234
|
+
const file = join(c.goalsDir, f);
|
|
235
|
+
const name = f.slice(0, -c.goalSuffix.length);
|
|
236
|
+
try {
|
|
237
|
+
const g = frontmatter(read(join(root, file)));
|
|
238
|
+
if (!g) {
|
|
239
|
+
warnings.push(`${file}: no frontmatter, listed as legacy`);
|
|
240
|
+
}
|
|
241
|
+
if (g && g.state == null) {
|
|
242
|
+
warnings.push(`${file}: missing state`);
|
|
243
|
+
}
|
|
244
|
+
if (g?.state != null && !states.includes(g.state)) {
|
|
245
|
+
errors.push(`${file}: unknown goal state ${g.state}`);
|
|
246
|
+
}
|
|
247
|
+
if (
|
|
248
|
+
g?.scope != null &&
|
|
249
|
+
(!Array.isArray(g.scope) || g.scope.some(id => typeof id !== 'string'))
|
|
250
|
+
) {
|
|
251
|
+
throw Error('scope must be a list of IDs');
|
|
252
|
+
}
|
|
253
|
+
const scope = g?.state == null ? [] : g.scope ?? [];
|
|
254
|
+
for (const id of scope) {
|
|
255
|
+
if (!byId.has(id)) {
|
|
256
|
+
errors.push(`${file}: unknown scope task ${id}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
goals.push({
|
|
260
|
+
name,
|
|
261
|
+
file,
|
|
262
|
+
title: g?.title ?? name,
|
|
263
|
+
state: g?.state ?? 'legacy',
|
|
264
|
+
stream: g?.stream || null,
|
|
265
|
+
budget: g?.budget || null,
|
|
266
|
+
thread: g?.thread || null,
|
|
267
|
+
scope,
|
|
268
|
+
done: scope.filter(id => byId.get(id)?.status === 'done').length,
|
|
269
|
+
review: scope.filter(id => byId.get(id)?.status === 'review').length,
|
|
270
|
+
total: scope.length,
|
|
271
|
+
ready: scope.filter(id => byId.get(id)?.ready),
|
|
272
|
+
waiting: scope.filter(id => ['decision', 'blocked'].includes(byId.get(id)?.status)),
|
|
273
|
+
});
|
|
274
|
+
} catch (e) {
|
|
275
|
+
errors.push(`${file}: ${e.message}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const byName = [...goals].sort((a, b) => compare(a.name, b.name));
|
|
279
|
+
for (const t of tasks) {
|
|
280
|
+
const running = byName.filter(g => g.state === 'running' && g.scope.includes(t.id));
|
|
281
|
+
if (running.length > 1) {
|
|
282
|
+
warnings.push(`${t.id}: in multiple running goals: ${running.map(g => g.name).join(', ')}`);
|
|
283
|
+
}
|
|
284
|
+
t.stream = t.owner || byName.find(g =>
|
|
285
|
+
['running', 'approved'].includes(g.state) && g.scope.includes(t.id)
|
|
286
|
+
)?.stream || null;
|
|
287
|
+
}
|
|
288
|
+
warnings.unshift(...tasks
|
|
289
|
+
.filter(t => t.status === 'in-progress' && t.stream === null)
|
|
290
|
+
.map(t => `${t.id}: ${t.status} without owner`)
|
|
291
|
+
);
|
|
292
|
+
return {
|
|
293
|
+
index: {
|
|
294
|
+
counts: Object.fromEntries(statuses.map(s => [s, tasks.filter(t => t.status === s).length])),
|
|
295
|
+
tasks,
|
|
296
|
+
goals,
|
|
297
|
+
warnings,
|
|
298
|
+
},
|
|
299
|
+
errors,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function encodeScalar(value) {
|
|
304
|
+
const reserved = /^(?:null|~|true|false|yes|no|on|off|[+-]?\.inf|\.nan)$/i;
|
|
305
|
+
const number = /^[+-]?(?:0[xob][\da-f_]+|\d[\d_]*(?:\.[\d_]*)?(?:e[+-]?\d+)?|\.\d+(?:e[+-]?\d+)?)$/i;
|
|
306
|
+
const date = /^\d{4}-\d{2}-\d{2}(?:$|[Tt \t])/;
|
|
307
|
+
const unsafe = /[\x00-\x1f\x7f]|:\s|\s#|:$|^[!&*{}\[\],#|>@`"'%]|^[?:-](?:\s|$)/;
|
|
308
|
+
return !value || value.trim() !== value || unsafe.test(value) ||
|
|
309
|
+
reserved.test(value) || number.test(value) || date.test(value) || ['---', '...'].includes(value)
|
|
310
|
+
? JSON.stringify(value)
|
|
311
|
+
: value;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function rewrite(file, fields) {
|
|
315
|
+
const text = readFileSync(file, 'utf8');
|
|
316
|
+
const parsed = frontmatter(text);
|
|
317
|
+
if (!parsed) {
|
|
318
|
+
throw Error(`${file}: missing frontmatter`);
|
|
319
|
+
}
|
|
320
|
+
const match = text.match(/^\uFEFF?---(\r?\n)([\s\S]*?)^---[ \t]*(?:\r?\n|$)/m);
|
|
321
|
+
const newline = match[1];
|
|
322
|
+
let head = match[2];
|
|
323
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
324
|
+
if (Array.isArray(parsed[key])) {
|
|
325
|
+
throw Error(`${file}: ${key} must be a scalar to rewrite`);
|
|
326
|
+
}
|
|
327
|
+
const line = `${key}: ${encodeScalar(value)}`;
|
|
328
|
+
const re = new RegExp(`^${key}:[^\\r\\n]*`, 'm');
|
|
329
|
+
head = re.test(head) ? head.replace(re, () => line) : head + line + newline;
|
|
330
|
+
}
|
|
331
|
+
const offset = text.indexOf('\n') + 1;
|
|
332
|
+
const temp = `${file}.${randomUUID()}.tmp`;
|
|
333
|
+
try {
|
|
334
|
+
writeFileSync(temp, text.slice(0, offset) + head + text.slice(offset + match[2].length), {
|
|
335
|
+
flag: 'wx',
|
|
336
|
+
mode: statSync(file).mode,
|
|
337
|
+
});
|
|
338
|
+
if (readFileSync(file, 'utf8') !== text) {
|
|
339
|
+
throw Error(`${file}: changed during rewrite; retry after reviewing the edit`);
|
|
340
|
+
}
|
|
341
|
+
renameSync(temp, file);
|
|
342
|
+
} finally {
|
|
343
|
+
rmSync(temp, { force: true });
|
|
344
|
+
}
|
|
345
|
+
}
|