@erclx/canon 5.43.0 → 5.45.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/README.md +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/backlog-triage/references/apply.md +6 -6
- package/claude/skills/canon-cli/SKILL.md +4 -5
- package/claude/skills/canvas-board/REQUIREMENT.md +1 -1
- package/claude/skills/canvas-board/SKILL.md +1 -1
- package/claude/skills/context-fold/references/context.md +1 -1
- package/claude/skills/create-skill/REQUIREMENT.md +1 -1
- package/claude/skills/create-skill/SKILL.md +2 -2
- package/claude/skills/create-skill/references/skill-requirement.md +68 -9
- package/claude/skills/create-skill/references/skill.md +1 -1
- package/claude/skills/design-identity/REQUIREMENT.md +40 -0
- package/claude/skills/design-identity/SKILL.md +138 -0
- package/claude/skills/{draft-identity → design-identity}/references/card-route.md +2 -2
- package/claude/skills/design-taste/REQUIREMENT.md +6 -6
- package/claude/skills/design-taste/SKILL.md +2 -2
- package/claude/skills/design-taste/references/kinds.md +1 -1
- package/claude/skills/design-taste/references/preflight.md +1 -1
- package/claude/skills/draft-and-pick/REQUIREMENT.md +8 -69
- package/claude/skills/draft-and-pick/SKILL.md +3 -153
- package/claude/skills/draft-doc/REQUIREMENT.md +9 -30
- package/claude/skills/draft-doc/SKILL.md +4 -76
- package/claude/skills/draft-figure/REQUIREMENT.md +8 -42
- package/claude/skills/draft-figure/SKILL.md +3 -77
- package/claude/skills/draft-identity/REQUIREMENT.md +8 -23
- package/claude/skills/draft-identity/SKILL.md +3 -130
- package/claude/skills/draft-ready/REQUIREMENT.md +8 -37
- package/claude/skills/draft-ready/SKILL.md +3 -128
- package/claude/skills/draft-screencast/REQUIREMENT.md +8 -23
- package/claude/skills/draft-screencast/SKILL.md +3 -62
- package/claude/skills/draft-slides/REQUIREMENT.md +8 -27
- package/claude/skills/draft-slides/SKILL.md +3 -72
- package/claude/skills/draw-figure/REQUIREMENT.md +59 -0
- package/claude/skills/draw-figure/SKILL.md +85 -0
- package/claude/skills/{draft-figure → draw-figure}/references/architecture-set.md +2 -2
- package/claude/skills/{draft-figure → draw-figure}/references/hand-drawn.md +2 -2
- package/claude/skills/{draft-figure → draw-figure}/references/mermaid-path.md +2 -2
- package/claude/skills/{draft-figure → draw-figure}/references/mermaid.md +1 -1
- package/claude/skills/make-deck/REQUIREMENT.md +44 -0
- package/claude/skills/make-deck/SKILL.md +80 -0
- package/claude/skills/markdown-craft/REQUIREMENT.md +1 -1
- package/claude/skills/markdown-craft/SKILL.md +2 -2
- package/claude/skills/pick-by-eye/REQUIREMENT.md +36 -0
- package/claude/skills/pick-by-eye/SKILL.md +161 -0
- package/claude/skills/plan-feature/SKILL.md +2 -1
- package/claude/skills/plan-feature/references/plan.md +24 -10
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/plan-intake/SKILL.md +1 -1
- package/claude/skills/plan-ready/REQUIREMENT.md +54 -0
- package/claude/skills/plan-ready/SKILL.md +136 -0
- package/claude/skills/{draft-ready → plan-ready}/references/assembly.md +1 -1
- package/claude/skills/{draft-ready → plan-ready}/references/ready.md +1 -1
- package/claude/skills/record-screencast/REQUIREMENT.md +3 -3
- package/claude/skills/record-screencast/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/REQUIREMENT.md +16 -99
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +5 -2
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +18 -13
- package/claude/skills/role-planner/REQUIREMENT.md +15 -73
- package/claude/skills/role-planner/SKILL.md +2 -1
- package/claude/skills/role-worker/REQUIREMENT.md +13 -41
- package/claude/skills/role-worker/SKILL.md +9 -0
- package/claude/skills/script-screencast/REQUIREMENT.md +40 -0
- package/claude/skills/script-screencast/SKILL.md +70 -0
- package/claude/skills/{draft-screencast → script-screencast}/references/beats-template.md +2 -2
- package/claude/skills/search-craft/REQUIREMENT.md +4 -4
- package/claude/skills/search-craft/SKILL.md +1 -1
- package/claude/skills/target-setup/SKILL.md +3 -2
- package/claude/skills/target-setup/references/detect.md +0 -1
- package/claude/skills/target-setup/references/verify.md +2 -2
- package/claude/skills/task-board/SKILL.md +11 -9
- package/claude/skills/task-board/references/board.md +15 -12
- package/claude/skills/teach-workspace/REQUIREMENT.md +1 -1
- package/claude/skills/teach-workspace/SKILL.md +1 -1
- package/claude/skills/teach-workspace/references/lesson-craft.md +2 -2
- package/claude/skills/teach-workspace/references/promotion.md +5 -5
- package/claude/skills/ui-checklist/SKILL.md +2 -0
- package/claude/skills/write-doc/REQUIREMENT.md +46 -0
- package/claude/skills/write-doc/SKILL.md +83 -0
- package/claude/skills/{draft-doc → write-doc}/references/context.md +2 -2
- package/claude/skills/{draft-doc → write-doc}/references/docs.md +2 -2
- package/claude/skills/{draft-doc → write-doc}/references/readme.md +2 -2
- package/claude/skills/{draft-doc → write-doc}/references/wiki-kind.md +3 -3
- package/docs/agents/install-and-sync.md +2 -2
- package/docs/agents/markdown-audit.md +4 -2
- package/docs/agents/migrate.md +2 -2
- package/docs/agents/output-shape.md +1 -2
- package/docs/target/migrations.md +35 -4
- package/docs/target/projects.md +2 -3
- package/docs/target/scenarios.md +4 -1
- package/docs/workflow/parallel-features.md +3 -2
- package/docs/workflow/skill-map-on-request.md +19 -13
- package/docs/workflow/skill-map.md +13 -12
- package/docs/workflow/visual-design-workflow.md +2 -2
- package/governance/detect.toml +4 -0
- package/governance/ledger/framework/astro.md +73 -0
- package/governance/ledger/framework/fastapi.md +76 -0
- package/governance/ledger/framework/nextjs.md +86 -0
- package/governance/ledger/framework/react.md +83 -0
- package/governance/ledger/framework/shadcn.md +60 -0
- package/governance/ledger/framework/tailwind.md +55 -0
- package/governance/ledger/lang/go.md +61 -0
- package/governance/ledger/lang/php.md +50 -0
- package/governance/ledger/lang/python.md +68 -0
- package/governance/ledger/lang/typescript.md +64 -0
- package/governance/ledger/lib/database.md +65 -0
- package/governance/ledger/lib/e2e-reliability.md +57 -0
- package/governance/ledger/lib/pydantic.md +61 -0
- package/governance/ledger/lib/security-server.md +62 -0
- package/governance/ledger/lib/security-web.md +51 -0
- package/governance/ledger/lib/tanstack-query.md +57 -0
- package/governance/ledger/lib/test-scope.md +43 -0
- package/governance/ledger/lib/testing-go.md +57 -0
- package/governance/ledger/lib/testing-php.md +51 -0
- package/governance/ledger/lib/testing-py.md +67 -0
- package/governance/ledger/lib/testing-ts.md +63 -0
- package/governance/ledger/lib/zod.md +47 -0
- package/governance/ledger/tooling/ci-workflow.md +33 -0
- package/governance/ledger/tooling/code-comments.md +48 -0
- package/governance/ledger/tooling/config-comments.md +27 -0
- package/governance/ledger/tooling/dependencies.md +38 -0
- package/governance/ledger/ui/a11y.md +55 -0
- package/governance/ledger/ui/copy.md +49 -0
- package/governance/ledger/ui/design-reference.md +42 -0
- package/governance/ledger/ui/design-taste.md +19 -0
- package/governance/ledger/ui/forms.md +71 -0
- package/governance/ledger/ui/interface-casing.md +47 -0
- package/governance/ledger/ui/keyboard.md +68 -0
- package/governance/ledger/ui/link-behavior.md +29 -0
- package/governance/ledger/ui/motion.md +33 -0
- package/governance/ledger/ui/surface-capture.md +69 -0
- package/governance/ledger/ui/ux-completeness.md +79 -0
- package/governance/rules/framework/astro.md +12 -14
- package/governance/rules/framework/fastapi.md +20 -24
- package/governance/rules/framework/nextjs.md +19 -27
- package/governance/rules/framework/react.md +22 -33
- package/governance/rules/framework/shadcn.md +11 -15
- package/governance/rules/framework/tailwind.md +12 -14
- package/governance/rules/lang/go.md +7 -16
- package/governance/rules/lang/php.md +3 -3
- package/governance/rules/lang/python.md +9 -18
- package/governance/rules/lang/typescript.md +9 -19
- package/governance/rules/lib/database.md +5 -5
- package/governance/rules/lib/e2e-reliability.md +4 -6
- package/governance/rules/lib/pydantic.md +14 -16
- package/governance/rules/lib/security-server.md +4 -4
- package/governance/rules/lib/security-web.md +6 -10
- package/governance/rules/lib/tanstack-query.md +10 -12
- package/governance/rules/lib/test-scope.md +4 -4
- package/governance/rules/lib/testing-go.md +1 -2
- package/governance/rules/lib/testing-php.md +4 -5
- package/governance/rules/lib/testing-py.md +11 -19
- package/governance/rules/lib/testing-ts.md +7 -16
- package/governance/rules/lib/zod.md +6 -8
- package/governance/rules/tooling/code-comments.md +2 -4
- package/governance/rules/tooling/config-comments.md +1 -4
- package/governance/rules/tooling/dependencies.md +1 -1
- package/governance/rules/ui/a11y.md +16 -45
- package/governance/rules/ui/copy.md +6 -9
- package/governance/rules/ui/forms.md +13 -19
- package/governance/rules/ui/interface-casing.md +5 -7
- package/governance/rules/ui/keyboard.md +38 -0
- package/governance/rules/ui/link-behavior.md +3 -3
- package/governance/rules/ui/motion.md +3 -3
- package/governance/rules/ui/surface-capture.md +15 -27
- package/governance/rules/ui/ux-completeness.md +16 -39
- package/governance/rules/writing/markdown.md +1 -1
- package/package.json +1 -1
- package/scripts/core/install-check.sh +1 -1
- package/src/claude/cases/authoring.ts +15 -3
- package/src/claude/cases/misc.ts +12 -0
- package/src/claude/cases/workflow.ts +19 -1
- package/src/claude/skills-audit.ts +113 -1
- package/src/cli/help.ts +0 -1
- package/src/cli.ts +0 -2
- package/src/commands/claude.ts +34 -11
- package/src/commands/demo.ts +1 -1
- package/src/commands/gov.ts +4 -0
- package/src/commands/init.ts +2 -2
- package/src/commands/markdown.ts +1 -1
- package/src/commands/migrate.ts +4 -0
- package/src/commands/pr.ts +30 -6
- package/src/commands/tasks.ts +383 -2
- package/src/commands/tooling.ts +18 -5
- package/src/context/audit.ts +1 -1
- package/src/demo/beats.ts +1 -1
- package/src/design/components.ts +1 -1
- package/src/gov/audit.ts +25 -4
- package/src/init/plan.ts +1 -8
- package/src/init/steps.ts +0 -8
- package/src/inventory/subjects.ts +1 -1
- package/src/markdown/scope.ts +49 -35
- package/src/migrate/record-layout.ts +1 -1
- package/src/migrate/scratch-evidence.ts +12 -4
- package/src/migrate/skill-names.ts +68 -21
- package/src/pr/frames.ts +14 -3
- package/src/records/backup.ts +3 -3
- package/src/records/validate.ts +31 -0
- package/src/roots/record.ts +17 -8
- package/src/tasks/board.ts +854 -0
- package/src/tasks/record.ts +20 -1
- package/src/tasks/validate.ts +206 -9
- package/src/tooling/inject.ts +26 -7
- package/src/tooling/package.ts +32 -0
- package/src/tooling/read.ts +46 -3
- package/src/tooling/scan.ts +8 -2
- package/tooling/astro/configs/card-src/pages/og-card.astro +1 -1
- package/tooling/astro/reference.md +64 -93
- package/tooling/base/configs/.github/workflows/phase-label-gate.yml +1 -1
- package/tooling/base/configs/scripts/verify.ts +1 -1
- package/tooling/base/reference.md +1 -9
- package/tooling/base/seeds/canon/context/{ci.md → development/ci.md} +1 -1
- package/tooling/base/seeds/canon/context/development/index.md +11 -0
- package/tooling/base/seeds/canon/context/{development.md → development/setup.md} +8 -12
- package/tooling/base/seeds/canon/context/index.md +1 -2
- package/tooling/claude/manifest.toml +1 -5
- package/tooling/claude/reference.md +36 -59
- package/tooling/claude/seeds/.claude/hooks/board-validate.sh +65 -0
- package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +2 -7
- package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +2 -7
- package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +3 -10
- package/tooling/claude/seeds/.claude/hooks/search-reminder.sh +2 -7
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +2 -4
- package/tooling/claude/seeds/.claude/settings.json +4 -0
- package/tooling/nextjs/reference.md +0 -6
- package/tooling/php/reference.md +0 -4
- package/tooling/web/configs/.github/workflows/verify.yml +28 -0
- package/tooling/web/configs/.oxlintrc.json +7 -0
- package/tooling/web/configs/e2e/capture-types.ts +35 -0
- package/tooling/web/configs/e2e/screenshot.ts +23 -72
- package/tooling/web/manifest.toml +1 -1
- package/tooling/web/reference.md +77 -124
- package/tooling/web/seeds/e2e/capture/cases.ts +47 -0
- package/src/commands/wiki.ts +0 -100
- package/src/wiki/init.ts +0 -85
- /package/claude/skills/{draft-figure → draw-figure}/references/figures.md +0 -0
- /package/claude/skills/{draft-slides → make-deck}/references/deck.md +0 -0
- /package/claude/skills/{draft-and-pick → pick-by-eye}/references/live-arms.md +0 -0
- /package/claude/skills/{draft-doc → write-doc}/references/readme/agent-facing.md +0 -0
- /package/claude/skills/{draft-doc → write-doc}/references/readme/application.md +0 -0
- /package/claude/skills/{draft-doc → write-doc}/references/readme/cli.md +0 -0
- /package/claude/skills/{draft-doc → write-doc}/references/readme/library.md +0 -0
- /package/claude/skills/{draft-doc → write-doc}/references/readme/plugin.md +0 -0
- /package/claude/skills/{draft-doc → write-doc}/references/requirements.md +0 -0
- /package/claude/skills/{draft-doc → write-doc}/references/wiki.md +0 -0
|
@@ -0,0 +1,854 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { open, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { basename, join } from 'node:path'
|
|
4
|
+
import {
|
|
5
|
+
linkTo,
|
|
6
|
+
readPlanTargets,
|
|
7
|
+
removeBacklogRow,
|
|
8
|
+
resolveLivePlan,
|
|
9
|
+
tasksDir,
|
|
10
|
+
} from '@/tasks/archive'
|
|
11
|
+
import { readDeclarations } from '@/tasks/reach'
|
|
12
|
+
import {
|
|
13
|
+
BOARD_GROUPS,
|
|
14
|
+
type BoardGroup,
|
|
15
|
+
backlogPath,
|
|
16
|
+
type FindingKind,
|
|
17
|
+
groupRuleFindings,
|
|
18
|
+
orderingPath,
|
|
19
|
+
readBoard,
|
|
20
|
+
} from '@/tasks/validate'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The columns each group owns, fixed by the operator on 2026-10-18. Every
|
|
24
|
+
* cell but `Waiting on` is derived from the task and its plan, so the render
|
|
25
|
+
* is the only writer of the other three.
|
|
26
|
+
*/
|
|
27
|
+
export const GROUP_COLUMNS: Readonly<Record<BoardGroup, readonly string[]>> = {
|
|
28
|
+
'Run now': ['Task', 'Touches', 'Plan'],
|
|
29
|
+
'Up next': ['Task', 'Touches', 'Waiting on'],
|
|
30
|
+
'Needs a plan': ['Task', 'Waiting on'],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** How many declared paths a `Touches` cell spells out before it counts the rest. */
|
|
34
|
+
const TOUCHES_SHOWN = 3
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The widest a `Waiting on` cell may render. The live board's cells measured 14
|
|
38
|
+
* to 131 characters on 2026-10-18 with one table outlier set aside, and the
|
|
39
|
+
* cap sits where all but the longest fit.
|
|
40
|
+
*/
|
|
41
|
+
export const WAITING_LIMIT = 120
|
|
42
|
+
|
|
43
|
+
/** How long a verb waits on another verb's lock before refusing as busy. */
|
|
44
|
+
const LOCK_WAIT_MS = 5_000
|
|
45
|
+
|
|
46
|
+
/** A lock older than this was left by a call that died, since a render takes well under a second. */
|
|
47
|
+
const LOCK_STALE_MS = 30_000
|
|
48
|
+
|
|
49
|
+
const LOCK_POLL_MS = 20
|
|
50
|
+
|
|
51
|
+
/** One row as the board holds it: the task it points at and the one typed cell. */
|
|
52
|
+
export interface PlacedRow {
|
|
53
|
+
readonly stem: string
|
|
54
|
+
/** The label the row's Task cell carried, used when the task file is gone. */
|
|
55
|
+
readonly label: string
|
|
56
|
+
readonly waiting: string | undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The board as a model. Rows are kept per group in order, and everything the
|
|
61
|
+
* model does not own, being the frontmatter and title above the first group,
|
|
62
|
+
* prose under a group heading, and any section that is not a group, is kept as
|
|
63
|
+
* written.
|
|
64
|
+
*/
|
|
65
|
+
export interface Board {
|
|
66
|
+
readonly head: readonly string[]
|
|
67
|
+
readonly groups: Readonly<Record<BoardGroup, readonly PlacedRow[]>>
|
|
68
|
+
readonly notes: Readonly<Record<BoardGroup, readonly string[]>>
|
|
69
|
+
readonly trailing: readonly string[]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type BoardParse =
|
|
73
|
+
| { readonly ok: true; readonly board: Board }
|
|
74
|
+
| { readonly ok: false; readonly line: string }
|
|
75
|
+
|
|
76
|
+
function emptyGroups<T>(): Record<BoardGroup, T[]> {
|
|
77
|
+
return { 'Run now': [], 'Up next': [], 'Needs a plan': [] }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isGroup(heading: string): heading is BoardGroup {
|
|
81
|
+
return (BOARD_GROUPS as readonly string[]).includes(heading)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function splitCells(line: string): string[] {
|
|
85
|
+
return line
|
|
86
|
+
.trim()
|
|
87
|
+
.replace(/^\|/, '')
|
|
88
|
+
.replace(/(?<!\\)\|$/, '')
|
|
89
|
+
.split(/(?<!\\)\|/)
|
|
90
|
+
.map((cell) => cell.trim())
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isSeparator(cells: readonly string[]): boolean {
|
|
94
|
+
return cells.every((cell) => /^:?-{3,}:?$/.test(cell))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The task a Task cell links, a bare sibling filename the way every row spells one. */
|
|
98
|
+
function taskLink(
|
|
99
|
+
cell: string,
|
|
100
|
+
): { readonly stem: string; readonly label: string } | undefined {
|
|
101
|
+
const match = /\[([^\]]*)\]\(([^)]+)\)/.exec(cell)
|
|
102
|
+
if (!match) return undefined
|
|
103
|
+
|
|
104
|
+
const target = match[2].trim().split('#')[0] ?? ''
|
|
105
|
+
if (target.includes('/') || !target.endsWith('.md')) return undefined
|
|
106
|
+
|
|
107
|
+
return { stem: target.slice(0, -'.md'.length), label: match[1].trim() }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Reads the `Waiting on` cell out of a row through the header above it, which
|
|
112
|
+
* still applies to a row stranded behind a closed table. A row one cell short
|
|
113
|
+
* of a header ending in `Waiting on` is read as missing its `Touches` cell, so
|
|
114
|
+
* its last cell stands in. Any other shape answers `undefined`, since guessing
|
|
115
|
+
* would write a path or a plan link into the blocker on the next render.
|
|
116
|
+
*/
|
|
117
|
+
function waitingCell(
|
|
118
|
+
cells: readonly string[],
|
|
119
|
+
header: readonly string[] | undefined,
|
|
120
|
+
): string | undefined {
|
|
121
|
+
if (!header) return undefined
|
|
122
|
+
const at = header.findIndex((cell) => cell.toLowerCase() === 'waiting on')
|
|
123
|
+
if (at === -1) return undefined
|
|
124
|
+
|
|
125
|
+
if (cells.length === header.length) return cells[at]
|
|
126
|
+
|
|
127
|
+
const missingTouches = header.some((cell) => cell.toLowerCase() === 'touches')
|
|
128
|
+
if (
|
|
129
|
+
missingTouches &&
|
|
130
|
+
at === header.length - 1 &&
|
|
131
|
+
cells.length === header.length - 1
|
|
132
|
+
) {
|
|
133
|
+
return cells.at(-1)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return undefined
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Parses `priority.md` into the model. The parse is lenient where `readBoard`
|
|
141
|
+
* is strict: a row after a closed table, under a header short a column, or
|
|
142
|
+
* past a blank line inside a table is still a row of the group it sits under,
|
|
143
|
+
* since the render exists to repair exactly those boards. It refuses a pipe line
|
|
144
|
+
* naming no task, which a render would drop, and a row whose blocker the header
|
|
145
|
+
* cannot place, which a render would rewrite to the wrong cell.
|
|
146
|
+
*/
|
|
147
|
+
export function parseBoard(text: string): BoardParse {
|
|
148
|
+
const lines = text.split('\n')
|
|
149
|
+
const head: string[] = []
|
|
150
|
+
const groups = emptyGroups<PlacedRow>()
|
|
151
|
+
const notes = emptyGroups<string>()
|
|
152
|
+
const trailing: string[] = []
|
|
153
|
+
|
|
154
|
+
let section: BoardGroup | 'head' | 'other' = 'head'
|
|
155
|
+
let header: string[] | undefined
|
|
156
|
+
|
|
157
|
+
for (const line of lines) {
|
|
158
|
+
const heading = /^##\s+(.+?)\s*$/.exec(line)
|
|
159
|
+
if (heading) {
|
|
160
|
+
section = isGroup(heading[1]) ? heading[1] : 'other'
|
|
161
|
+
header = undefined
|
|
162
|
+
if (section === 'other') trailing.push(line)
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (section === 'head') {
|
|
167
|
+
head.push(line)
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (section === 'other') {
|
|
172
|
+
trailing.push(line)
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!line.trimStart().startsWith('|')) {
|
|
177
|
+
if (line.trim() !== '') notes[section].push(line)
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const cells = splitCells(line)
|
|
182
|
+
if (isSeparator(cells)) continue
|
|
183
|
+
|
|
184
|
+
const link = taskLink(cells[0] ?? '')
|
|
185
|
+
if (!link) {
|
|
186
|
+
if ((cells[0] ?? '').toLowerCase() === 'task') {
|
|
187
|
+
header = cells
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
return { ok: false, line: line.trim() }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const owned = GROUP_COLUMNS[section].includes('Waiting on')
|
|
194
|
+
const waiting = owned ? waitingCell(cells, header) : undefined
|
|
195
|
+
if (owned && waiting === undefined) return { ok: false, line: line.trim() }
|
|
196
|
+
|
|
197
|
+
groups[section].push({ stem: link.stem, label: link.label, waiting })
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { ok: true, board: { head, groups, notes, trailing } }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The cells a row renders that the task and its plans decide. */
|
|
204
|
+
export interface DerivedCells {
|
|
205
|
+
readonly task: string
|
|
206
|
+
readonly touches: string
|
|
207
|
+
readonly plan: string
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function titleOf(text: string): string | undefined {
|
|
211
|
+
const lines = text.split('\n')
|
|
212
|
+
let at = 0
|
|
213
|
+
if (lines[0]?.trim() === '---') {
|
|
214
|
+
const close = lines.findIndex(
|
|
215
|
+
(line, index) => index > 0 && line.trim() === '---',
|
|
216
|
+
)
|
|
217
|
+
at = close === -1 ? 0 : close + 1
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const heading = lines.slice(at).find((line) => line.startsWith('# '))
|
|
221
|
+
return heading?.slice(2).trim()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function planLabel(path: string): string {
|
|
225
|
+
const stem = basename(path, '.md')
|
|
226
|
+
return stem.startsWith('feature-') ? stem.slice('feature-'.length) : stem
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The first `TOUCHES_SHOWN` declared paths, then a count of the rest. A task
|
|
231
|
+
* sliced over several plans reads their union, so the tail names the plans in
|
|
232
|
+
* the plural.
|
|
233
|
+
*/
|
|
234
|
+
export function touchesCell(paths: readonly string[], plans: number): string {
|
|
235
|
+
const shown = paths.slice(0, TOUCHES_SHOWN).map((path) => `\`${path}\``)
|
|
236
|
+
const rest = paths.length - shown.length
|
|
237
|
+
if (rest === 0) return shown.join(', ')
|
|
238
|
+
|
|
239
|
+
const lists = plans > 1 ? 'the plans list' : 'the plan lists'
|
|
240
|
+
return `${shown.join(', ')}, and ${rest} more files ${lists}`
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The live plans a task's `Plan:` line resolves to, in line order and once
|
|
245
|
+
* each. An archived plan or a dead link drops out, so a cell never links a plan
|
|
246
|
+
* nobody can open and a Touches cell never counts files a shipped plan listed.
|
|
247
|
+
*/
|
|
248
|
+
async function livePlans(text: string, root: string): Promise<string[]> {
|
|
249
|
+
const dir = tasksDir(root)
|
|
250
|
+
const paths = readPlanTargets(text)
|
|
251
|
+
.map((target) => resolveLivePlan(target.split('#')[0] ?? '', dir, root))
|
|
252
|
+
.filter((path): path is string => path !== undefined && existsSync(path))
|
|
253
|
+
|
|
254
|
+
return [...new Set(paths)]
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Derives the Task, Touches, and Plan cells from the task file and the plans
|
|
259
|
+
* its `Plan:` line links. A task file that is gone keeps the label its row
|
|
260
|
+
* carried and derives nothing else, since `validate` reports the dead row and a
|
|
261
|
+
* render has no business dropping it.
|
|
262
|
+
*/
|
|
263
|
+
export async function deriveCells(
|
|
264
|
+
root: string,
|
|
265
|
+
row: Pick<PlacedRow, 'stem' | 'label'>,
|
|
266
|
+
): Promise<DerivedCells> {
|
|
267
|
+
const file = join(tasksDir(root), `${row.stem}.md`)
|
|
268
|
+
if (!existsSync(file)) {
|
|
269
|
+
return {
|
|
270
|
+
task: `[${row.label || row.stem}](${row.stem}.md)`,
|
|
271
|
+
touches: '',
|
|
272
|
+
plan: '',
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const text = await readFile(file, 'utf8')
|
|
277
|
+
const plans = await livePlans(text, root)
|
|
278
|
+
const declared = await Promise.all(
|
|
279
|
+
plans.map(async (plan) => readDeclarations(await readFile(plan, 'utf8'))),
|
|
280
|
+
)
|
|
281
|
+
const dir = tasksDir(root)
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
task: `[${titleOf(text) ?? (row.label || row.stem)}](${row.stem}.md)`,
|
|
285
|
+
touches: touchesCell([...new Set(declared.flat())], plans.length),
|
|
286
|
+
plan: plans
|
|
287
|
+
.map((plan) => `[${planLabel(plan)}](${linkTo(dir, plan)})`)
|
|
288
|
+
.join(', '),
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function tableLine(cells: readonly string[]): string {
|
|
293
|
+
return `| ${cells.join(' | ')} |`
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function cellFor(
|
|
297
|
+
column: string,
|
|
298
|
+
derived: DerivedCells,
|
|
299
|
+
row: PlacedRow,
|
|
300
|
+
): string {
|
|
301
|
+
if (column === 'Task') return derived.task
|
|
302
|
+
if (column === 'Touches') return derived.touches
|
|
303
|
+
if (column === 'Plan') return derived.plan
|
|
304
|
+
return row.waiting ?? ''
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Renders the whole board from the model, one clean table per group under its
|
|
309
|
+
* owned header, in the order `BOARD_GROUPS` fixes. A group with no rows keeps
|
|
310
|
+
* its heading and header so the next placement has a table to land in.
|
|
311
|
+
*/
|
|
312
|
+
export async function renderBoard(root: string, board: Board): Promise<string> {
|
|
313
|
+
const head = [...board.head]
|
|
314
|
+
while (head.length > 0 && head.at(-1)?.trim() === '') head.pop()
|
|
315
|
+
|
|
316
|
+
const out = [...head]
|
|
317
|
+
|
|
318
|
+
for (const group of BOARD_GROUPS) {
|
|
319
|
+
const columns = GROUP_COLUMNS[group]
|
|
320
|
+
const rows = await Promise.all(
|
|
321
|
+
board.groups[group].map(async (row) => {
|
|
322
|
+
const derived = await deriveCells(root, row)
|
|
323
|
+
return tableLine(columns.map((column) => cellFor(column, derived, row)))
|
|
324
|
+
}),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
out.push('', `## ${group}`, '')
|
|
328
|
+
if (board.notes[group].length > 0) out.push(...board.notes[group], '')
|
|
329
|
+
out.push(
|
|
330
|
+
tableLine(columns),
|
|
331
|
+
tableLine(columns.map((column) => '-'.repeat(column.length))),
|
|
332
|
+
...rows,
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const trailing = [...board.trailing]
|
|
337
|
+
while (trailing.length > 0 && trailing.at(-1)?.trim() === '') trailing.pop()
|
|
338
|
+
if (trailing.length > 0) out.push('', ...trailing)
|
|
339
|
+
|
|
340
|
+
return `${out.join('\n')}\n`
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export const BOARD_REFUSALS = [
|
|
344
|
+
'no-board',
|
|
345
|
+
'no-ordering',
|
|
346
|
+
'no-match',
|
|
347
|
+
'no-row',
|
|
348
|
+
'unreadable-row',
|
|
349
|
+
'board-busy',
|
|
350
|
+
'board-changed',
|
|
351
|
+
'group-rule',
|
|
352
|
+
'no-column',
|
|
353
|
+
'top-group',
|
|
354
|
+
'cell-long',
|
|
355
|
+
'bad-input',
|
|
356
|
+
] as const
|
|
357
|
+
|
|
358
|
+
export type BoardRefusal = (typeof BOARD_REFUSALS)[number]
|
|
359
|
+
|
|
360
|
+
export interface BoardRefused {
|
|
361
|
+
readonly ok: false
|
|
362
|
+
readonly reason: BoardRefusal
|
|
363
|
+
readonly message: string
|
|
364
|
+
readonly detail: readonly string[]
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Where a row lands in its group. A named sibling must already sit in that group. */
|
|
368
|
+
export type Position =
|
|
369
|
+
| { readonly at: 'top' }
|
|
370
|
+
| { readonly at: 'bottom' }
|
|
371
|
+
| { readonly before: string }
|
|
372
|
+
| { readonly after: string }
|
|
373
|
+
|
|
374
|
+
export interface BoardWritten {
|
|
375
|
+
readonly ok: true
|
|
376
|
+
readonly stem: string
|
|
377
|
+
readonly group: BoardGroup
|
|
378
|
+
/** Where the row sat before the write, `backlog` for a backlog line, undefined for neither. */
|
|
379
|
+
readonly from: BoardGroup | 'backlog' | undefined
|
|
380
|
+
/** The row's 1-based position in its group after the write. */
|
|
381
|
+
readonly position: number
|
|
382
|
+
readonly path: string
|
|
383
|
+
/** Whether the call also removed the task's backlog line. */
|
|
384
|
+
readonly backlog: boolean
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export type BoardOutcome = BoardWritten | BoardRefused
|
|
388
|
+
|
|
389
|
+
/** The `Waiting on` a row promoted out of `## Needs a plan` carries when the caller names none. */
|
|
390
|
+
export const PLANNED_WAITING = 'Planned, not dispatched yet'
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The finding kinds each group's rule forbids, read off the same predicates
|
|
394
|
+
* `canon tasks validate` reports through `groupRuleFindings`.
|
|
395
|
+
*/
|
|
396
|
+
const GROUP_RULES: Readonly<Record<BoardGroup, readonly FindingKind[]>> = {
|
|
397
|
+
'Run now': ['plan-unstated', 'touches-unstated'],
|
|
398
|
+
'Up next': ['plan-absent'],
|
|
399
|
+
'Needs a plan': ['plan-parked'],
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** The group one step closer to `## Run now`, which is where `promote` sends a row. */
|
|
403
|
+
const PROMOTES_TO: Readonly<Record<BoardGroup, BoardGroup | undefined>> = {
|
|
404
|
+
'Run now': undefined,
|
|
405
|
+
'Up next': 'Run now',
|
|
406
|
+
'Needs a plan': 'Up next',
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function refuse(
|
|
410
|
+
reason: BoardRefusal,
|
|
411
|
+
message: string,
|
|
412
|
+
detail: readonly string[] = [],
|
|
413
|
+
): BoardRefused {
|
|
414
|
+
return { ok: false, reason, message, detail }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** The width a cell renders at, a link counting as its label alone. */
|
|
418
|
+
export function renderedLength(cell: string): number {
|
|
419
|
+
return cell.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').length
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function checkWaiting(text: string): BoardRefused | undefined {
|
|
423
|
+
if (text.trim() === '' || /[|\n]/.test(text)) {
|
|
424
|
+
return refuse(
|
|
425
|
+
'bad-input',
|
|
426
|
+
'A Waiting on cell is one line of text carrying no pipe.',
|
|
427
|
+
[text],
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const width = renderedLength(text.trim())
|
|
432
|
+
if (width > WAITING_LIMIT) {
|
|
433
|
+
return refuse(
|
|
434
|
+
'cell-long',
|
|
435
|
+
`The Waiting on cell renders ${width} characters, past the ${WAITING_LIMIT} a board cell holds. Name the blocker and drop the history.`,
|
|
436
|
+
[text],
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return undefined
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function findRow(
|
|
444
|
+
board: Board,
|
|
445
|
+
stem: string,
|
|
446
|
+
): { readonly group: BoardGroup; readonly row: PlacedRow } | undefined {
|
|
447
|
+
for (const group of BOARD_GROUPS) {
|
|
448
|
+
const row = board.groups[group].find((entry) => entry.stem === stem)
|
|
449
|
+
if (row) return { group, row }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return undefined
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function anchorOf(position: Position): string | undefined {
|
|
456
|
+
if ('before' in position) return position.before
|
|
457
|
+
if ('after' in position) return position.after
|
|
458
|
+
return undefined
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Takes the row out of every group and puts it back at a position in one, so a
|
|
463
|
+
* row that sat anywhere lands once. A sibling the position names but the group
|
|
464
|
+
* does not hold is refused rather than read as the bottom.
|
|
465
|
+
*/
|
|
466
|
+
function placed(
|
|
467
|
+
board: Board,
|
|
468
|
+
group: BoardGroup,
|
|
469
|
+
row: PlacedRow,
|
|
470
|
+
position: Position,
|
|
471
|
+
): Board | BoardRefused {
|
|
472
|
+
const groups = emptyGroups<PlacedRow>()
|
|
473
|
+
for (const each of BOARD_GROUPS) {
|
|
474
|
+
groups[each] = board.groups[each].filter((entry) => entry.stem !== row.stem)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const rows = groups[group]
|
|
478
|
+
if ('at' in position) {
|
|
479
|
+
groups[group] = position.at === 'top' ? [row, ...rows] : [...rows, row]
|
|
480
|
+
return { ...board, groups }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const anchor = anchorOf(position) ?? ''
|
|
484
|
+
const at = rows.findIndex((entry) => entry.stem === anchor)
|
|
485
|
+
if (at === -1) {
|
|
486
|
+
return refuse(
|
|
487
|
+
'bad-input',
|
|
488
|
+
`${anchor} has no row under ${group}, so ${row.stem} has nothing to sit beside.`,
|
|
489
|
+
[anchor],
|
|
490
|
+
)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const index = 'before' in position ? at : at + 1
|
|
494
|
+
groups[group] = [...rows.slice(0, index), row, ...rows.slice(index)]
|
|
495
|
+
return { ...board, groups }
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
interface Edited {
|
|
499
|
+
readonly board: Board
|
|
500
|
+
readonly group: BoardGroup
|
|
501
|
+
readonly from: BoardGroup | undefined
|
|
502
|
+
/** The group whose rule the write must pass, undefined when the row kept its group. */
|
|
503
|
+
readonly ruled: BoardGroup | undefined
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Runs one verb as one read, one render, and one write of `priority.md`, so a
|
|
508
|
+
* call never leaves a partial table behind. A row changing group is checked
|
|
509
|
+
* against the target group's rule on the rendered board before anything is
|
|
510
|
+
* written, which reads the board exactly as `validate` will.
|
|
511
|
+
*/
|
|
512
|
+
async function writeBoard(
|
|
513
|
+
root: string,
|
|
514
|
+
stem: string,
|
|
515
|
+
edit: (board: Board) => Edited | BoardRefused,
|
|
516
|
+
): Promise<BoardOutcome> {
|
|
517
|
+
const dir = tasksDir(root)
|
|
518
|
+
if (!existsSync(dir)) return refuse('no-board', `No task board at ${dir}.`)
|
|
519
|
+
|
|
520
|
+
const path = orderingPath(root)
|
|
521
|
+
if (!existsSync(path)) {
|
|
522
|
+
return refuse('no-ordering', `No ordering file at ${path}.`)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const file = join(dir, `${stem}.md`)
|
|
526
|
+
if (!existsSync(file)) {
|
|
527
|
+
return refuse('no-match', `No task file at ${file}.`, [stem])
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return withBoardLock(path, () => writeLocked(root, stem, path, edit))
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Holds an exclusive lock file beside the board for one read, render, and
|
|
535
|
+
* write, so two verbs in two sessions cannot each replace the file with an
|
|
536
|
+
* edit that drops the other's. The render reads every task and plan, which is
|
|
537
|
+
* why the stale age sits well above any one call.
|
|
538
|
+
*/
|
|
539
|
+
async function withBoardLock(
|
|
540
|
+
path: string,
|
|
541
|
+
run: () => Promise<BoardOutcome>,
|
|
542
|
+
): Promise<BoardOutcome> {
|
|
543
|
+
const lock = `${path}.lock`
|
|
544
|
+
const deadline = Date.now() + LOCK_WAIT_MS
|
|
545
|
+
|
|
546
|
+
for (;;) {
|
|
547
|
+
try {
|
|
548
|
+
await (await open(lock, 'wx')).close()
|
|
549
|
+
break
|
|
550
|
+
} catch (error) {
|
|
551
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
|
552
|
+
const modified = (await stat(lock).catch(() => undefined))?.mtimeMs
|
|
553
|
+
if (modified !== undefined && Date.now() - modified > LOCK_STALE_MS) {
|
|
554
|
+
await rm(lock, { force: true })
|
|
555
|
+
continue
|
|
556
|
+
}
|
|
557
|
+
if (Date.now() >= deadline) {
|
|
558
|
+
return refuse(
|
|
559
|
+
'board-busy',
|
|
560
|
+
`Another board verb holds ${lock}. Run this one again once it finishes.`,
|
|
561
|
+
)
|
|
562
|
+
}
|
|
563
|
+
await new Promise((settle) => setTimeout(settle, LOCK_POLL_MS))
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
try {
|
|
568
|
+
return await run()
|
|
569
|
+
} finally {
|
|
570
|
+
await rm(lock, { force: true })
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function writeLocked(
|
|
575
|
+
root: string,
|
|
576
|
+
stem: string,
|
|
577
|
+
path: string,
|
|
578
|
+
edit: (board: Board) => Edited | BoardRefused,
|
|
579
|
+
): Promise<BoardOutcome> {
|
|
580
|
+
const before = await readFile(path, 'utf8')
|
|
581
|
+
const parse = parseBoard(before)
|
|
582
|
+
if (!parse.ok) {
|
|
583
|
+
return refuse(
|
|
584
|
+
'unreadable-row',
|
|
585
|
+
`${path} holds a row naming no task or carrying a Waiting on cell its header cannot place, so a render would drop or rewrite it. Fix that row by hand first.`,
|
|
586
|
+
[parse.line],
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const edited = edit(parse.board)
|
|
591
|
+
if ('ok' in edited) return edited
|
|
592
|
+
|
|
593
|
+
const text = await renderBoard(root, edited.board)
|
|
594
|
+
|
|
595
|
+
if (edited.ruled) {
|
|
596
|
+
const forbidden = GROUP_RULES[edited.ruled]
|
|
597
|
+
const broken = (await groupRuleFindings(readBoard(text).rows, root)).filter(
|
|
598
|
+
(finding) => finding.subject === stem && forbidden.includes(finding.kind),
|
|
599
|
+
)
|
|
600
|
+
if (broken.length > 0) {
|
|
601
|
+
return refuse(
|
|
602
|
+
'group-rule',
|
|
603
|
+
`${stem} cannot sit under ${edited.ruled}. ${broken.map((finding) => `${finding.kind}: ${finding.message}`).join(' ')}`,
|
|
604
|
+
broken.map((finding) => finding.kind),
|
|
605
|
+
)
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// The lock holds off other verbs and nothing else, so a hand edit landing
|
|
610
|
+
// while the render awaited its reads is caught by comparing the file again.
|
|
611
|
+
if ((await readFile(path, 'utf8')) !== before) {
|
|
612
|
+
return refuse(
|
|
613
|
+
'board-changed',
|
|
614
|
+
`${path} changed while this call rendered it, so nothing was written. Run the verb again.`,
|
|
615
|
+
)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
await writeFile(path, text)
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
ok: true,
|
|
622
|
+
stem,
|
|
623
|
+
group: edited.group,
|
|
624
|
+
from: edited.from,
|
|
625
|
+
position:
|
|
626
|
+
edited.board.groups[edited.group].findIndex((row) => row.stem === stem) +
|
|
627
|
+
1,
|
|
628
|
+
path,
|
|
629
|
+
backlog: false,
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export interface PlaceOptions {
|
|
634
|
+
readonly waiting?: string
|
|
635
|
+
readonly position?: Position
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Puts a task under a group from anywhere: another group, the backlog, or no
|
|
640
|
+
* surface at all. A backlog line is removed in the same call, since a task on
|
|
641
|
+
* both surfaces is the `row-duplicated` finding and a two-step move strands it
|
|
642
|
+
* between them.
|
|
643
|
+
*/
|
|
644
|
+
export async function placeTask(
|
|
645
|
+
root: string,
|
|
646
|
+
stem: string,
|
|
647
|
+
group: BoardGroup,
|
|
648
|
+
options: PlaceOptions = {},
|
|
649
|
+
): Promise<BoardOutcome> {
|
|
650
|
+
if (options.waiting !== undefined) {
|
|
651
|
+
const bad = checkWaiting(options.waiting)
|
|
652
|
+
if (bad) return bad
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const written = await writeBoard(root, stem, (board) => {
|
|
656
|
+
const found = findRow(board, stem)
|
|
657
|
+
const owns = GROUP_COLUMNS[group].includes('Waiting on')
|
|
658
|
+
const waiting = owns
|
|
659
|
+
? (options.waiting?.trim() ?? found?.row.waiting)
|
|
660
|
+
: undefined
|
|
661
|
+
|
|
662
|
+
if (owns && waiting === undefined) {
|
|
663
|
+
return refuse(
|
|
664
|
+
'bad-input',
|
|
665
|
+
`${group} carries a Waiting on cell and ${stem} has none to keep. Pass --waiting.`,
|
|
666
|
+
)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const next = placed(
|
|
670
|
+
board,
|
|
671
|
+
group,
|
|
672
|
+
{ stem, label: found?.row.label ?? stem, waiting },
|
|
673
|
+
options.position ?? { at: 'bottom' },
|
|
674
|
+
)
|
|
675
|
+
if ('ok' in next) return next
|
|
676
|
+
|
|
677
|
+
return {
|
|
678
|
+
board: next,
|
|
679
|
+
group,
|
|
680
|
+
from: found?.group,
|
|
681
|
+
ruled: found?.group === group ? undefined : group,
|
|
682
|
+
}
|
|
683
|
+
})
|
|
684
|
+
if (!written.ok) return written
|
|
685
|
+
|
|
686
|
+
const removed = await removeFromBacklog(root, stem)
|
|
687
|
+
return {
|
|
688
|
+
...written,
|
|
689
|
+
from: written.from ?? (removed ? 'backlog' : undefined),
|
|
690
|
+
backlog: removed,
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async function removeFromBacklog(root: string, stem: string): Promise<boolean> {
|
|
695
|
+
const path = backlogPath(root)
|
|
696
|
+
if (!existsSync(path)) return false
|
|
697
|
+
|
|
698
|
+
const { text, removed } = removeBacklogRow(await readFile(path, 'utf8'), stem)
|
|
699
|
+
if (removed) await writeFile(path, text)
|
|
700
|
+
return removed
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
export interface PromoteOptions {
|
|
704
|
+
readonly waiting?: string
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Moves a row one group closer to `## Run now`, to the bottom of that group.
|
|
709
|
+
* Dispatch runs it once a launch succeeds, so `## Run now` reads as
|
|
710
|
+
* dispatched, and `plan-link` runs it on a `## Needs a plan` row once the plan
|
|
711
|
+
* is linked.
|
|
712
|
+
*/
|
|
713
|
+
export async function promoteTask(
|
|
714
|
+
root: string,
|
|
715
|
+
stem: string,
|
|
716
|
+
options: PromoteOptions = {},
|
|
717
|
+
): Promise<BoardOutcome> {
|
|
718
|
+
if (options.waiting !== undefined) {
|
|
719
|
+
const bad = checkWaiting(options.waiting)
|
|
720
|
+
if (bad) return bad
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
return writeBoard(root, stem, (board) => {
|
|
724
|
+
const found = findRow(board, stem)
|
|
725
|
+
if (!found) return refuse('no-row', `${stem} has no row on the board.`)
|
|
726
|
+
|
|
727
|
+
const target = PROMOTES_TO[found.group]
|
|
728
|
+
if (!target) {
|
|
729
|
+
return refuse(
|
|
730
|
+
'top-group',
|
|
731
|
+
`${stem} already sits under Run now, the top group.`,
|
|
732
|
+
)
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const waiting = GROUP_COLUMNS[target].includes('Waiting on')
|
|
736
|
+
? (options.waiting?.trim() ?? PLANNED_WAITING)
|
|
737
|
+
: undefined
|
|
738
|
+
const next = placed(
|
|
739
|
+
board,
|
|
740
|
+
target,
|
|
741
|
+
{ ...found.row, waiting },
|
|
742
|
+
{
|
|
743
|
+
at: 'bottom',
|
|
744
|
+
},
|
|
745
|
+
)
|
|
746
|
+
if ('ok' in next) return next
|
|
747
|
+
|
|
748
|
+
return { board: next, group: target, from: found.group, ruled: target }
|
|
749
|
+
})
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** What linking a plan did to the task's row, and why when it did nothing. */
|
|
753
|
+
export interface LinkPromotion {
|
|
754
|
+
readonly moved: boolean
|
|
755
|
+
/** The group the row sat in before, undefined when it has no row. */
|
|
756
|
+
readonly from: BoardGroup | undefined
|
|
757
|
+
readonly note: string
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Promotes a task's row out of `## Needs a plan` once its plan is linked, and
|
|
762
|
+
* leaves every other row where it is. A row already under `## Up next` or
|
|
763
|
+
* `## Run now` is past the planning a link records, and a task with no row was
|
|
764
|
+
* never placed, which is the controller's call rather than the link's.
|
|
765
|
+
*/
|
|
766
|
+
export async function promoteOnLink(
|
|
767
|
+
root: string,
|
|
768
|
+
stem: string,
|
|
769
|
+
options: PromoteOptions = {},
|
|
770
|
+
): Promise<LinkPromotion> {
|
|
771
|
+
const path = orderingPath(root)
|
|
772
|
+
if (!existsSync(path)) {
|
|
773
|
+
return { moved: false, from: undefined, note: 'no ordering file' }
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const parse = parseBoard(await readFile(path, 'utf8'))
|
|
777
|
+
const found = parse.ok ? findRow(parse.board, stem) : undefined
|
|
778
|
+
if (!found) {
|
|
779
|
+
return { moved: false, from: undefined, note: 'no row on the board' }
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (found.group !== 'Needs a plan') {
|
|
783
|
+
return {
|
|
784
|
+
moved: false,
|
|
785
|
+
from: found.group,
|
|
786
|
+
note: `already under ${found.group}`,
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const outcome = await promoteTask(root, stem, options)
|
|
791
|
+
return outcome.ok
|
|
792
|
+
? { moved: true, from: found.group, note: `moved to ${outcome.group}` }
|
|
793
|
+
: { moved: false, from: found.group, note: outcome.message }
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Repositions a row inside the group it already sits in. */
|
|
797
|
+
export async function moveTask(
|
|
798
|
+
root: string,
|
|
799
|
+
stem: string,
|
|
800
|
+
position: Position,
|
|
801
|
+
): Promise<BoardOutcome> {
|
|
802
|
+
if (anchorOf(position) === stem) {
|
|
803
|
+
return refuse('bad-input', `${stem} cannot sit beside itself.`, [stem])
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return writeBoard(root, stem, (board) => {
|
|
807
|
+
const found = findRow(board, stem)
|
|
808
|
+
if (!found) return refuse('no-row', `${stem} has no row on the board.`)
|
|
809
|
+
|
|
810
|
+
const next = placed(board, found.group, found.row, position)
|
|
811
|
+
if ('ok' in next) return next
|
|
812
|
+
|
|
813
|
+
return {
|
|
814
|
+
board: next,
|
|
815
|
+
group: found.group,
|
|
816
|
+
from: found.group,
|
|
817
|
+
ruled: undefined,
|
|
818
|
+
}
|
|
819
|
+
})
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** Rewrites the `Waiting on` cell of a row in place. */
|
|
823
|
+
export async function blockTask(
|
|
824
|
+
root: string,
|
|
825
|
+
stem: string,
|
|
826
|
+
waiting: string,
|
|
827
|
+
): Promise<BoardOutcome> {
|
|
828
|
+
const bad = checkWaiting(waiting)
|
|
829
|
+
if (bad) return bad
|
|
830
|
+
|
|
831
|
+
return writeBoard(root, stem, (board) => {
|
|
832
|
+
const found = findRow(board, stem)
|
|
833
|
+
if (!found) return refuse('no-row', `${stem} has no row on the board.`)
|
|
834
|
+
|
|
835
|
+
if (!GROUP_COLUMNS[found.group].includes('Waiting on')) {
|
|
836
|
+
return refuse(
|
|
837
|
+
'no-column',
|
|
838
|
+
`${stem} sits under ${found.group}, which carries no Waiting on cell.`,
|
|
839
|
+
)
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const groups = { ...board.groups }
|
|
843
|
+
groups[found.group] = board.groups[found.group].map((row) =>
|
|
844
|
+
row.stem === stem ? { ...row, waiting: waiting.trim() } : row,
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
return {
|
|
848
|
+
board: { ...board, groups },
|
|
849
|
+
group: found.group,
|
|
850
|
+
from: found.group,
|
|
851
|
+
ruled: undefined,
|
|
852
|
+
}
|
|
853
|
+
})
|
|
854
|
+
}
|