@erclx/canon 4.69.0 → 4.71.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/auto-ship/SKILL.md +1 -1
- package/claude/skills/canon-cli/SKILL.md +4 -0
- package/claude/skills/deploy-cloudflare/REQUIREMENT.md +37 -0
- package/claude/skills/deploy-cloudflare/SKILL.md +61 -0
- package/claude/skills/draft-and-pick/REQUIREMENT.md +1 -1
- package/claude/skills/draft-and-pick/SKILL.md +1 -1
- package/claude/skills/{identity → draft-identity}/REQUIREMENT.md +2 -2
- package/claude/skills/{identity → draft-identity}/SKILL.md +2 -2
- package/claude/skills/{canon-screencast → draft-screencast}/REQUIREMENT.md +4 -4
- package/claude/skills/{canon-screencast → draft-screencast}/SKILL.md +4 -4
- package/claude/skills/{canon-slides-draft → draft-slides}/REQUIREMENT.md +3 -3
- package/claude/skills/{canon-slides-draft → draft-slides}/SKILL.md +2 -2
- package/claude/skills/{canon-frames-read → read-frames}/REQUIREMENT.md +2 -2
- package/claude/skills/{canon-frames-read → read-frames}/SKILL.md +3 -3
- package/claude/skills/{canon-record → record-screencast}/REQUIREMENT.md +5 -5
- package/claude/skills/{canon-record → record-screencast}/SKILL.md +4 -4
- package/claude/skills/review-pr/SKILL.md +55 -7
- package/claude/skills/role-orchestrator/SKILL.md +2 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +7 -3
- package/claude/skills/role-orchestrator/scripts/poll.sh +79 -34
- package/claude/skills/role-worker/SKILL.md +2 -1
- package/claude/skills/ux-audit/SKILL.md +3 -0
- package/docs/agents/commands.md +2 -0
- package/docs/agents/demo.md +3 -3
- package/docs/agents/index.md +1 -1
- package/docs/agents/pr-reads.md +47 -12
- package/docs/agents/tasks.md +46 -3
- package/docs/workflow/ai-workflow.md +19 -19
- package/docs/workflow/visual-design-workflow.md +1 -1
- package/package.json +3 -2
- package/scripts/core/regen-web-previews.ts +94 -0
- package/src/claude/cases/misc.ts +5 -1
- package/src/claude/cases/workflow.ts +4 -4
- package/src/commands/demo.ts +1 -1
- package/src/commands/pr.ts +130 -1
- package/src/commands/tasks.ts +178 -1
- package/src/commands/teach.ts +2 -0
- package/src/demo/beats.ts +1 -1
- package/src/gate/measures.ts +66 -0
- package/src/gate/stages.ts +11 -0
- package/src/migrate/skill-names.ts +25 -1
- package/src/pr/review-scope.ts +177 -0
- package/src/tasks/archive.ts +206 -3
- package/src/tasks/label.ts +14 -6
- package/src/tasks/validate.ts +22 -0
- package/src/teach/workspace.ts +60 -23
- package/standards/plan.md +1 -1
- package/standards/tasks.md +15 -1
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +103 -0
- package/tooling/cloudflare/manifest.toml +5 -0
- package/tooling/cloudflare/reference.md +24 -0
|
@@ -2,7 +2,8 @@ import { defineRenameRules, type RenameRules } from '@/migrate/rename'
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The twenty-five shipped skills that carried a `claude-` prefix, and the
|
|
5
|
-
* two-word name each takes instead
|
|
5
|
+
* two-word name each takes instead, plus the four that carried a `canon-`
|
|
6
|
+
* prefix naming a subject other than the toolkit itself.
|
|
6
7
|
*
|
|
7
8
|
* The plugin namespace already resolves every one of them as `canon:<name>`,
|
|
8
9
|
* so the prefix bought grouping rather than uniqueness, and the grouping it
|
|
@@ -11,9 +12,28 @@ import { defineRenameRules, type RenameRules } from '@/migrate/rename'
|
|
|
11
12
|
* a listing groups the review triple, the three planning skills, and the three
|
|
12
13
|
* session roles together.
|
|
13
14
|
*
|
|
15
|
+
* The four `canon-` rows split the same way rather than sharing one
|
|
16
|
+
* replacement prefix. `canon-screencast` and `canon-slides-draft` draft a
|
|
17
|
+
* document, which is what the `draft-` family already means across its other
|
|
18
|
+
* members, so they join it as `draft-screencast` and `draft-slides`.
|
|
19
|
+
* `canon-record` and `canon-frames-read` are not drafting anything, so each
|
|
20
|
+
* takes a standalone verb-first name, `record-screencast` and `read-frames`,
|
|
21
|
+
* reading in sequence with `draft-screencast` as one three-step pipeline with
|
|
22
|
+
* no prefix forcing that reading.
|
|
23
|
+
*
|
|
14
24
|
* Every name takes two words. Ten of these would have landed as a bare single
|
|
15
25
|
* word under a plain strip, and a bare word such as `review` or `docs` is a
|
|
16
26
|
* substring of ordinary prose with no token left for a later sweep to find.
|
|
27
|
+
*
|
|
28
|
+
* No bare single-word name may ever join this map, whatever prefix it would
|
|
29
|
+
* otherwise take. `wholeToken: true` below matches a standalone word rather
|
|
30
|
+
* than a namespaced compound, so a one-word key rewrites every unrelated use
|
|
31
|
+
* of that word too. `identity` proved it: a dry run against a fifth row
|
|
32
|
+
* reading `'identity': 'draft-identity'` reported 112 occurrences across 43
|
|
33
|
+
* files, most of them a variable, field, or type name spelling the bare word
|
|
34
|
+
* rather than the skill, including `readonly identity: SelfIdentity` at
|
|
35
|
+
* `src/sessions/resolve.ts:255`, which the rewrite would have turned into
|
|
36
|
+
* invalid TypeScript. The row was never added.
|
|
17
37
|
*/
|
|
18
38
|
export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
|
|
19
39
|
'claude-address-review': 'review-address',
|
|
@@ -41,6 +61,10 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
|
|
|
41
61
|
'claude-ux-measure': 'ux-measure',
|
|
42
62
|
'claude-worker': 'role-worker',
|
|
43
63
|
'claude-worktree': 'session-worktree',
|
|
64
|
+
'canon-screencast': 'draft-screencast',
|
|
65
|
+
'canon-slides-draft': 'draft-slides',
|
|
66
|
+
'canon-record': 'record-screencast',
|
|
67
|
+
'canon-frames-read': 'read-frames',
|
|
44
68
|
}
|
|
45
69
|
|
|
46
70
|
/**
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The last state a review pass covered, read off the pass's own marker rather
|
|
3
|
+
* than off the fields GitHub stamps when a review is submitted.
|
|
4
|
+
*
|
|
5
|
+
* `commit.oid` names whatever the pull request head was at the instant the
|
|
6
|
+
* review was submitted, not the commit the session read. A push landing between
|
|
7
|
+
* the read and the post moves that stamp onto a commit nobody reviewed, and the
|
|
8
|
+
* next pass then scopes its delta past work no reader has seen. `submittedAt`
|
|
9
|
+
* carries the same defect on the time axis. `review-pr` therefore writes the
|
|
10
|
+
* commit it read and the instant it read it into the body itself, and this
|
|
11
|
+
* module is the one place that marker is parsed.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The two headings `review-pr` posts a pass under. */
|
|
15
|
+
export const REVIEW_HEADINGS = ['## Review', '## Review closed'] as const
|
|
16
|
+
|
|
17
|
+
export type ReviewHeading = (typeof REVIEW_HEADINGS)[number]
|
|
18
|
+
|
|
19
|
+
/** Whether the thread's newest pass still owes work on it. */
|
|
20
|
+
export type ReviewState = 'open' | 'closed' | 'none'
|
|
21
|
+
|
|
22
|
+
/** Where the covered state came from. */
|
|
23
|
+
export type ScopeSource =
|
|
24
|
+
/** The pass wrote its own read-time marker, which is the authority. */
|
|
25
|
+
| 'marker'
|
|
26
|
+
/** A pass posted before the marker shipped, read off GitHub's own stamps. */
|
|
27
|
+
| 'fallback'
|
|
28
|
+
/** The thread carries no pass at all, so this is a first pass. */
|
|
29
|
+
| 'none'
|
|
30
|
+
|
|
31
|
+
export interface ReviewScope {
|
|
32
|
+
/** The heading of the newest pass, absent when the thread carries none. */
|
|
33
|
+
readonly heading?: ReviewHeading
|
|
34
|
+
/** The same fact as `heading`, collapsed for a caller that reads only state. */
|
|
35
|
+
readonly state: ReviewState
|
|
36
|
+
/** The commit the newest pass covered. */
|
|
37
|
+
readonly commit?: string
|
|
38
|
+
/** When that pass read the commit, present only under a marker. */
|
|
39
|
+
readonly readAt?: string
|
|
40
|
+
/** When GitHub recorded the pass, which trails `readAt` by the compose window. */
|
|
41
|
+
readonly submittedAt?: string
|
|
42
|
+
readonly source: ScopeSource
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One row of `gh pr view --json reviews`. */
|
|
46
|
+
export interface ReviewRow {
|
|
47
|
+
readonly body?: string
|
|
48
|
+
readonly commit?: { readonly oid?: string } | null
|
|
49
|
+
readonly submittedAt?: string | null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What this reads out of `gh pr view`.
|
|
54
|
+
*
|
|
55
|
+
* Only `reviews` is named, so a caller may hand over a wider payload it fetched
|
|
56
|
+
* for its own reasons without this module growing a field it never opens.
|
|
57
|
+
*/
|
|
58
|
+
export interface ReviewListing {
|
|
59
|
+
readonly reviews?: readonly ReviewRow[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The marker `review-pr` appends as the last line of every body it posts.
|
|
64
|
+
*
|
|
65
|
+
* The sha is bounded at git's own abbreviation range rather than pinned to 40,
|
|
66
|
+
* since an abbreviated sha still names a commit and refusing one drops the pass
|
|
67
|
+
* back to the stamp this exists to replace.
|
|
68
|
+
*
|
|
69
|
+
* Anchoring to a whole line is not on its own what keeps a quoted marker out.
|
|
70
|
+
* It stops the inline form, where the surrounding backticks leave the trimmed
|
|
71
|
+
* line unmatchable, and a marker shown alone inside a fenced block trims to
|
|
72
|
+
* exactly this pattern. Position is what separates the two, which is why
|
|
73
|
+
* `markerOf` reads one line rather than searching.
|
|
74
|
+
*/
|
|
75
|
+
const MARKER =
|
|
76
|
+
/^<!--\s*review-pr:\s*commit=([0-9a-f]{7,40})\s+read-at=(\S+)\s*-->$/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Loose enough to accept every stamp `date -u +%Y-%m-%dT%H:%M:%SZ` and
|
|
80
|
+
* `toISOString` produce, strict enough that a body carrying prose in the field
|
|
81
|
+
* falls back rather than handing a caller a value no date parser reads.
|
|
82
|
+
*/
|
|
83
|
+
const ISO_8601 =
|
|
84
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The heading a body opens with, or undefined when it opens with anything else.
|
|
88
|
+
*
|
|
89
|
+
* Matched for equality on the first line alone. A prefix test also reaches
|
|
90
|
+
* `## Review response`, which belongs to the reply family and would scope a
|
|
91
|
+
* pass to whatever commit a worker's answer carried. The `\r` trim covers a
|
|
92
|
+
* body composed in the GitHub web editor, which stores CRLF.
|
|
93
|
+
*/
|
|
94
|
+
function headingOf(body: string): ReviewHeading | undefined {
|
|
95
|
+
const first = (body.split('\n')[0] ?? '').replace(/\r$/, '')
|
|
96
|
+
return REVIEW_HEADINGS.find((heading) => heading === first)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The marker a body carries, or undefined when it carries none this reader
|
|
101
|
+
* trusts.
|
|
102
|
+
*
|
|
103
|
+
* The last non-empty line and no other. Searching the body for the last match
|
|
104
|
+
* instead reads a marker the body was displaying rather than claiming: a pass
|
|
105
|
+
* that shows the format on its own line inside a fenced block, and carries no
|
|
106
|
+
* marker of its own because it predates this shipping, would hand the next
|
|
107
|
+
* reader a covered commit taken from an illustration. That is the defect this
|
|
108
|
+
* module exists to close, arriving by another route and just as silently.
|
|
109
|
+
*
|
|
110
|
+
* Position costs nothing, since Step 4 of `review-pr` puts the marker on the
|
|
111
|
+
* last line of every body it writes, the `PUT` rewrite included. Trailing blank
|
|
112
|
+
* lines are skipped rather than read as an absent marker, which is the one
|
|
113
|
+
* thing the search was buying.
|
|
114
|
+
*/
|
|
115
|
+
function markerOf(
|
|
116
|
+
body: string,
|
|
117
|
+
): { commit: string; readAt: string } | undefined {
|
|
118
|
+
const lines = body.split('\n')
|
|
119
|
+
|
|
120
|
+
let index = lines.length - 1
|
|
121
|
+
while (index >= 0 && (lines[index] ?? '').trim() === '') index -= 1
|
|
122
|
+
if (index < 0) return undefined
|
|
123
|
+
|
|
124
|
+
const match = MARKER.exec((lines[index] ?? '').trim())
|
|
125
|
+
if (match === null) return undefined
|
|
126
|
+
|
|
127
|
+
const [, commit, readAt] = match
|
|
128
|
+
if (commit === undefined || readAt === undefined) return undefined
|
|
129
|
+
if (!ISO_8601.test(readAt)) return undefined
|
|
130
|
+
|
|
131
|
+
return { commit, readAt }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Resolves what the newest review pass covered.
|
|
136
|
+
*
|
|
137
|
+
* The newest pass rather than the newest marker: a thread whose latest pass
|
|
138
|
+
* predates this mechanism reads through the fallback, and reaching back to an
|
|
139
|
+
* older marked pass would report a commit a later pass has already moved past.
|
|
140
|
+
*/
|
|
141
|
+
export function resolveReviewScope(listing: ReviewListing): ReviewScope {
|
|
142
|
+
let newest: { heading: ReviewHeading; row: ReviewRow } | undefined
|
|
143
|
+
|
|
144
|
+
for (const row of listing.reviews ?? []) {
|
|
145
|
+
const heading = headingOf(row.body ?? '')
|
|
146
|
+
if (heading === undefined) continue
|
|
147
|
+
newest = { heading, row }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (newest === undefined) return { state: 'none', source: 'none' }
|
|
151
|
+
|
|
152
|
+
const { heading, row } = newest
|
|
153
|
+
const state: ReviewState = heading === '## Review' ? 'open' : 'closed'
|
|
154
|
+
const submittedAt = row.submittedAt ?? undefined
|
|
155
|
+
const stamped = submittedAt === undefined ? {} : { submittedAt }
|
|
156
|
+
const marker = markerOf(row.body ?? '')
|
|
157
|
+
|
|
158
|
+
if (marker !== undefined) {
|
|
159
|
+
return {
|
|
160
|
+
heading,
|
|
161
|
+
state,
|
|
162
|
+
commit: marker.commit,
|
|
163
|
+
readAt: marker.readAt,
|
|
164
|
+
...stamped,
|
|
165
|
+
source: 'marker',
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const oid = row.commit?.oid
|
|
170
|
+
return {
|
|
171
|
+
heading,
|
|
172
|
+
state,
|
|
173
|
+
...(oid === undefined || oid === '' ? {} : { commit: oid }),
|
|
174
|
+
...stamped,
|
|
175
|
+
source: 'fallback',
|
|
176
|
+
}
|
|
177
|
+
}
|
package/src/tasks/archive.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { recordDir, recordDirs } from '@/record-root'
|
|
|
8
8
|
const TASKS = 'tasks'
|
|
9
9
|
const PLANS = 'plans'
|
|
10
10
|
const ARCHIVE = 'archive'
|
|
11
|
+
const DECLINED = 'declined'
|
|
12
|
+
const BACKLOG = 'backlog.md'
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* Siblings that sit on the board without being tasks: the generated index, the
|
|
@@ -48,6 +50,21 @@ export const ARCHIVE_REFUSALS = [
|
|
|
48
50
|
|
|
49
51
|
export type ArchiveRefusal = (typeof ARCHIVE_REFUSALS)[number]
|
|
50
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Kept apart from `ARCHIVE_REFUSALS` on purpose. `archive` and `decline`
|
|
55
|
+
* answer different questions, shipped versus decided-against, and a shared
|
|
56
|
+
* refusal set would let one archive a task that cannot yet ship or decline
|
|
57
|
+
* one that already has.
|
|
58
|
+
*/
|
|
59
|
+
export const DECLINE_REFUSALS = [
|
|
60
|
+
'no-board',
|
|
61
|
+
'no-match',
|
|
62
|
+
'ambiguous',
|
|
63
|
+
'bad-input',
|
|
64
|
+
] as const
|
|
65
|
+
|
|
66
|
+
export type DeclineRefusal = (typeof DECLINE_REFUSALS)[number]
|
|
67
|
+
|
|
51
68
|
export type TaskSelector =
|
|
52
69
|
| { readonly kind: 'stem'; readonly stem: string }
|
|
53
70
|
| { readonly kind: 'pull-request'; readonly number: number }
|
|
@@ -80,6 +97,27 @@ export interface ArchiveRefused {
|
|
|
80
97
|
|
|
81
98
|
export type ArchiveOutcome = ArchiveSuccess | ArchiveRefused
|
|
82
99
|
|
|
100
|
+
export interface DeclineSuccess {
|
|
101
|
+
readonly ok: true
|
|
102
|
+
readonly stem: string
|
|
103
|
+
readonly from: string
|
|
104
|
+
readonly to: string
|
|
105
|
+
readonly priorityRowRemoved: boolean
|
|
106
|
+
readonly backlogRowRemoved: boolean
|
|
107
|
+
readonly indexRegenerated: boolean
|
|
108
|
+
/** Undefined when the task cited no live plan, or when another task still holds it. */
|
|
109
|
+
readonly plan: PlanMove | undefined
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface DeclineRefused {
|
|
113
|
+
readonly ok: false
|
|
114
|
+
readonly reason: DeclineRefusal
|
|
115
|
+
readonly message: string
|
|
116
|
+
readonly detail: readonly string[]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type DeclineOutcome = DeclineSuccess | DeclineRefused
|
|
120
|
+
|
|
83
121
|
export interface TaskOutcomes {
|
|
84
122
|
readonly open: readonly string[]
|
|
85
123
|
readonly closed: readonly string[]
|
|
@@ -94,6 +132,10 @@ export function archiveDir(root: string): string {
|
|
|
94
132
|
return recordDir(root, TASKS, ARCHIVE)
|
|
95
133
|
}
|
|
96
134
|
|
|
135
|
+
export function declinedDir(root: string): string {
|
|
136
|
+
return recordDir(root, TASKS, DECLINED)
|
|
137
|
+
}
|
|
138
|
+
|
|
97
139
|
export const OUTCOME_PATTERN = /^- \[([ xX])\] ?(.*)$/
|
|
98
140
|
|
|
99
141
|
/**
|
|
@@ -238,6 +280,62 @@ export function retargetPlanLine(text: string, target: string): string {
|
|
|
238
280
|
return text.replace(PLAN_PATTERN, () => planLine(target))
|
|
239
281
|
}
|
|
240
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Builds the `Declined:` line recording why a task was decided against and by
|
|
285
|
+
* whom. Free prose after the colon, since the line names no file to link,
|
|
286
|
+
* unlike `planLine`.
|
|
287
|
+
*/
|
|
288
|
+
export function declineLine(reason: string, by: string, date: string): string {
|
|
289
|
+
return `Declined: ${reason}, ${by} on ${date}`
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Lines a `Declined:` line anchors after, mirroring `record.ts`'s
|
|
294
|
+
* `ORIGIN_PREFIXES` with `Pull request:` folded in, since a decline can follow
|
|
295
|
+
* a pull request that never merged.
|
|
296
|
+
*/
|
|
297
|
+
const DECLINE_ANCHOR_PREFIXES = [
|
|
298
|
+
'Plan:',
|
|
299
|
+
'Groundwork:',
|
|
300
|
+
'Intake:',
|
|
301
|
+
'Issue:',
|
|
302
|
+
'Pull request:',
|
|
303
|
+
] as const
|
|
304
|
+
|
|
305
|
+
function lastAnchorLine(lines: readonly string[]): number | undefined {
|
|
306
|
+
let found: number | undefined
|
|
307
|
+
|
|
308
|
+
for (const [index, line] of lines.entries()) {
|
|
309
|
+
if (DECLINE_ANCHOR_PREFIXES.some((prefix) => line.startsWith(prefix))) {
|
|
310
|
+
found = index
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return found
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Places the `Declined:` line after the origin lines a task carries, the same
|
|
319
|
+
* scan-and-anchor shape `writePullRequestLine` carries. A decline runs once
|
|
320
|
+
* per task, so there is no existing line to correct, unlike the
|
|
321
|
+
* add/correct/unchanged shape a write safe to run twice needs.
|
|
322
|
+
*/
|
|
323
|
+
function insertDeclinedLine(text: string, line: string): string {
|
|
324
|
+
const lines = text.split('\n')
|
|
325
|
+
const anchor = lastAnchorLine(lines)
|
|
326
|
+
|
|
327
|
+
if (anchor !== undefined) {
|
|
328
|
+
lines.splice(anchor + 1, 0, line)
|
|
329
|
+
return lines.join('\n')
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const heading = lines.findIndex((entry) => entry.startsWith('# '))
|
|
333
|
+
if (heading === -1) return `${line}\n${text}`
|
|
334
|
+
|
|
335
|
+
lines.splice(heading + 1, 0, '', line)
|
|
336
|
+
return lines.join('\n')
|
|
337
|
+
}
|
|
338
|
+
|
|
241
339
|
/**
|
|
242
340
|
* Drops the archived task's row from the ordering table. Rows are matched by
|
|
243
341
|
* the link they carry rather than by a line pattern, because a row holds links
|
|
@@ -267,6 +365,27 @@ function isRowFor(line: string, target: string): boolean {
|
|
|
267
365
|
return first !== undefined && first.includes(target)
|
|
268
366
|
}
|
|
269
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Drops the declined task's bullet from the backlog. A backlog line is a
|
|
370
|
+
* bullet carrying a link rather than a table row, so the match is a bullet
|
|
371
|
+
* prefix and the link target rather than `isRowFor`'s pipe-delimited cell.
|
|
372
|
+
*/
|
|
373
|
+
export function removeBacklogRow(
|
|
374
|
+
text: string,
|
|
375
|
+
stem: string,
|
|
376
|
+
): { readonly text: string; readonly removed: boolean } {
|
|
377
|
+
const target = `](${stem}.md)`
|
|
378
|
+
const lines = text.split('\n')
|
|
379
|
+
const kept = lines.filter((line) => !isBulletFor(line, target))
|
|
380
|
+
|
|
381
|
+
return { text: kept.join('\n'), removed: kept.length !== lines.length }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function isBulletFor(line: string, target: string): boolean {
|
|
385
|
+
const trimmed = line.trimStart()
|
|
386
|
+
return /^[-*]\s/.test(trimmed) && trimmed.includes(target)
|
|
387
|
+
}
|
|
388
|
+
|
|
270
389
|
/**
|
|
271
390
|
* Resolves the `Plan:` target against the board and against the project root
|
|
272
391
|
* both, which is how `docs-fold` reads the same line. It accepts `../plans/x.md`
|
|
@@ -481,11 +600,20 @@ async function matchByPullRequest(
|
|
|
481
600
|
return read.filter((entry) => entry.number === number).map(({ stem }) => stem)
|
|
482
601
|
}
|
|
483
602
|
|
|
484
|
-
|
|
485
|
-
|
|
603
|
+
/**
|
|
604
|
+
* Generic over the refusal vocabulary so `archiveTask` and `declineTask` share
|
|
605
|
+
* one builder despite answering with two disjoint reason sets.
|
|
606
|
+
*/
|
|
607
|
+
function refuse<Reason extends string>(
|
|
608
|
+
reason: Reason,
|
|
486
609
|
message: string,
|
|
487
610
|
detail: readonly string[] = [],
|
|
488
|
-
):
|
|
611
|
+
): {
|
|
612
|
+
readonly ok: false
|
|
613
|
+
readonly reason: Reason
|
|
614
|
+
readonly message: string
|
|
615
|
+
readonly detail: readonly string[]
|
|
616
|
+
} {
|
|
489
617
|
return { ok: false, reason, message, detail }
|
|
490
618
|
}
|
|
491
619
|
|
|
@@ -690,3 +818,78 @@ async function clearPriorityRow(dir: string, stem: string): Promise<boolean> {
|
|
|
690
818
|
|
|
691
819
|
return removed
|
|
692
820
|
}
|
|
821
|
+
|
|
822
|
+
async function clearBacklogRow(dir: string, stem: string): Promise<boolean> {
|
|
823
|
+
const path = join(dir, BACKLOG)
|
|
824
|
+
if (!existsSync(path)) return false
|
|
825
|
+
|
|
826
|
+
const { text, removed } = removeBacklogRow(await readFile(path, 'utf8'), stem)
|
|
827
|
+
if (removed) await writeFile(path, text)
|
|
828
|
+
|
|
829
|
+
return removed
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Declines one task as a single unit: the move, the ordering-or-backlog row
|
|
834
|
+
* removal, and the index regen. Unlike `archiveTask`, it carries no
|
|
835
|
+
* outcome-state gate, since a task decided against can sit at any outcome
|
|
836
|
+
* state, and the two never share a refusal set for the reason
|
|
837
|
+
* `DECLINE_REFUSALS` states.
|
|
838
|
+
*/
|
|
839
|
+
export async function declineTask(
|
|
840
|
+
root: string,
|
|
841
|
+
stem: string,
|
|
842
|
+
reason: string,
|
|
843
|
+
by: string,
|
|
844
|
+
): Promise<DeclineOutcome> {
|
|
845
|
+
const dir = tasksDir(root)
|
|
846
|
+
|
|
847
|
+
if (!existsSync(dir)) {
|
|
848
|
+
return refuse('no-board', `No task board at ${relative(root, dir)}.`)
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const stems = await listTaskStems(dir)
|
|
852
|
+
if (!stems.includes(stem)) {
|
|
853
|
+
const unmatched = describeUnmatchedStem(stems, stem)
|
|
854
|
+
return refuse(unmatched.reason, unmatched.message, unmatched.detail)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const from = join(dir, `${stem}.md`)
|
|
858
|
+
const text = await readFile(from, 'utf8')
|
|
859
|
+
|
|
860
|
+
const plan = await planToArchive(dir, root, stem, text)
|
|
861
|
+
const destination = declinedDir(root)
|
|
862
|
+
const to = join(destination, `${stem}.md`)
|
|
863
|
+
|
|
864
|
+
// The plan moves first, the same order archiveTask uses, so the retarget
|
|
865
|
+
// written below describes a file already at its new path.
|
|
866
|
+
if (plan) {
|
|
867
|
+
await mkdir(dirname(plan.to), { recursive: true })
|
|
868
|
+
await rename(plan.from, plan.to)
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
await mkdir(destination, { recursive: true })
|
|
872
|
+
await rename(from, to)
|
|
873
|
+
|
|
874
|
+
const date = new Date().toISOString().slice(0, 10)
|
|
875
|
+
const declined = insertDeclinedLine(text, declineLine(reason, by, date))
|
|
876
|
+
const final = plan
|
|
877
|
+
? retargetPlanLine(declined, linkTo(destination, plan.to))
|
|
878
|
+
: declined
|
|
879
|
+
await writeFile(to, final)
|
|
880
|
+
|
|
881
|
+
const priorityRowRemoved = await clearPriorityRow(dir, stem)
|
|
882
|
+
const backlogRowRemoved = await clearBacklogRow(dir, stem)
|
|
883
|
+
const regen = await regenOne(dir, { dryRun: false })
|
|
884
|
+
|
|
885
|
+
return {
|
|
886
|
+
ok: true,
|
|
887
|
+
stem,
|
|
888
|
+
from,
|
|
889
|
+
to,
|
|
890
|
+
priorityRowRemoved,
|
|
891
|
+
backlogRowRemoved,
|
|
892
|
+
indexRegenerated: regen.action === 'written',
|
|
893
|
+
plan,
|
|
894
|
+
}
|
|
895
|
+
}
|
package/src/tasks/label.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { relative } from 'node:path'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
archiveDir,
|
|
5
|
+
declinedDir,
|
|
6
|
+
listTaskStems,
|
|
7
|
+
tasksDir,
|
|
8
|
+
} from '@/tasks/archive'
|
|
4
9
|
|
|
5
10
|
/** Every label in the corpus today stops here before rolling to the next major. */
|
|
6
11
|
const MINOR_ROLLOVER = 9
|
|
@@ -68,9 +73,10 @@ function next(label: Label): Label {
|
|
|
68
73
|
|
|
69
74
|
/**
|
|
70
75
|
* Reports the next unused phase label, read off the true maximum across
|
|
71
|
-
* `.canon/tasks/` and its `archive/`
|
|
72
|
-
* live board is blind to every label
|
|
73
|
-
* let two sessions hand out the same label within
|
|
76
|
+
* `.canon/tasks/` and its `archive/` and `declined/` siblings together. A scan
|
|
77
|
+
* confined to the live board is blind to every label a settled folder already
|
|
78
|
+
* spent, which is what let two sessions hand out the same label within
|
|
79
|
+
* minutes of each other.
|
|
74
80
|
*
|
|
75
81
|
* It reports and never writes. Two sessions calling it in the same second can
|
|
76
82
|
* still take the same answer, since the board is gitignored files rather than
|
|
@@ -90,8 +96,10 @@ export async function nextLabel(root: string): Promise<LabelOutcome> {
|
|
|
90
96
|
}
|
|
91
97
|
}
|
|
92
98
|
|
|
93
|
-
const
|
|
94
|
-
|
|
99
|
+
const settled = [archiveDir(root), declinedDir(root)].filter((candidate) =>
|
|
100
|
+
existsSync(candidate),
|
|
101
|
+
)
|
|
102
|
+
const dirs = [dir, ...settled]
|
|
95
103
|
const stems = (await Promise.all(dirs.map((d) => listTaskStems(d)))).flat()
|
|
96
104
|
|
|
97
105
|
const highest = stems
|
package/src/tasks/validate.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readdir, readFile } from 'node:fs/promises'
|
|
|
3
3
|
import { join, resolve } from 'node:path'
|
|
4
4
|
import {
|
|
5
5
|
archiveDir,
|
|
6
|
+
declinedDir,
|
|
6
7
|
isReservedStem,
|
|
7
8
|
readOutcomes,
|
|
8
9
|
readPlanTarget,
|
|
@@ -47,6 +48,7 @@ export const FINDING_KINDS = [
|
|
|
47
48
|
'touches-collided',
|
|
48
49
|
'blocker-settled',
|
|
49
50
|
'blocker-unresolved',
|
|
51
|
+
'blocker-declined',
|
|
50
52
|
] as const
|
|
51
53
|
|
|
52
54
|
export type FindingKind = (typeof FINDING_KINDS)[number]
|
|
@@ -124,6 +126,7 @@ export interface ValidateReport {
|
|
|
124
126
|
readonly rows: number
|
|
125
127
|
readonly backlog: number
|
|
126
128
|
readonly tasks: number
|
|
129
|
+
readonly declined: number
|
|
127
130
|
readonly findings: readonly Finding[]
|
|
128
131
|
readonly untested: readonly Untested[]
|
|
129
132
|
readonly claims: readonly FolderClaim[]
|
|
@@ -970,6 +973,20 @@ async function checkCitedTask(
|
|
|
970
973
|
return settled(group, subject, `waits on ${cited}, which is archived.`)
|
|
971
974
|
}
|
|
972
975
|
|
|
976
|
+
if (existsSync(join(declinedDir(root), `${cited}.md`))) {
|
|
977
|
+
return {
|
|
978
|
+
findings: [
|
|
979
|
+
{
|
|
980
|
+
kind: 'blocker-declined',
|
|
981
|
+
group,
|
|
982
|
+
subject,
|
|
983
|
+
message: `waits on ${cited}, which was declined.`,
|
|
984
|
+
},
|
|
985
|
+
],
|
|
986
|
+
untested: [],
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
973
990
|
return {
|
|
974
991
|
findings: [
|
|
975
992
|
{
|
|
@@ -1158,6 +1175,10 @@ export async function validateBoard(
|
|
|
1158
1175
|
: []
|
|
1159
1176
|
|
|
1160
1177
|
const stems = await listTaskStems(dir)
|
|
1178
|
+
const declinedPath = declinedDir(root)
|
|
1179
|
+
const declined = existsSync(declinedPath)
|
|
1180
|
+
? await listTaskStems(declinedPath)
|
|
1181
|
+
: []
|
|
1161
1182
|
const parked = await checkParked(rows, root, trunk)
|
|
1162
1183
|
|
|
1163
1184
|
const findings = [
|
|
@@ -1175,6 +1196,7 @@ export async function validateBoard(
|
|
|
1175
1196
|
rows: rows.length,
|
|
1176
1197
|
backlog: backlog.length,
|
|
1177
1198
|
tasks: stems.length,
|
|
1199
|
+
declined: declined.length,
|
|
1178
1200
|
findings,
|
|
1179
1201
|
untested: parked.untested,
|
|
1180
1202
|
claims: checkFolderClaims(rows, root),
|