@erclx/canon 4.2.1 → 4.4.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.
@@ -1,5 +1,6 @@
1
1
  import { relative } from 'node:path'
2
2
  import type { Command } from 'commander'
3
+ import { type AnswersOutcome, planAnswers } from '@/tasks/answers'
3
4
  import {
4
5
  type ArchiveOutcome,
5
6
  archiveTask,
@@ -54,6 +55,11 @@ interface CitationsCommandOptions {
54
55
  readonly root?: string
55
56
  }
56
57
 
58
+ interface AnswersCommandOptions {
59
+ readonly json?: boolean
60
+ readonly root?: string
61
+ }
62
+
57
63
  interface PullRequestCommandOptions {
58
64
  readonly json?: boolean
59
65
  readonly plan?: string
@@ -173,6 +179,42 @@ export function register(program: Command): void {
173
179
  process.exitCode = await runCitations(task, opts)
174
180
  })
175
181
 
182
+ tasks
183
+ .command('plan-answers')
184
+ .description('Report whether a plan still waits on the operator to answer')
185
+ .argument('<plan>', 'Plan path or its slug, as in dispatch-answer-gate')
186
+ .helpOption('-h, --help', 'Show this help message')
187
+ .option('--json', 'Emit a machine-readable record on stdout')
188
+ .option('--root <path>', 'Board root, defaulting to the main worktree')
189
+ .addHelpText(
190
+ 'after',
191
+ [
192
+ '',
193
+ 'Exit codes:',
194
+ ' 0 the plan is launchable',
195
+ ' 1 refused as no-plan, archived, or bad-input',
196
+ ' 2 the plan waits on the operator, and open names every slot',
197
+ '',
198
+ 'A blank Answer accepts the Suggested line above it, so only',
199
+ '`- Suggested: needs your call, <why>` over an empty slot is a stop.',
200
+ 'It reports and never writes. Branch on launchable rather than on the',
201
+ 'exit code, which a shell function wrapping canon can flatten to zero.',
202
+ '',
203
+ 'A relative path resolves against the project root first and against',
204
+ '.claude/tasks/ second, so the ../plans/ link a board row writes works.',
205
+ '',
206
+ 'Examples:',
207
+ ' canon tasks plan-answers dispatch-answer-gate',
208
+ ' canon tasks plan-answers .claude/plans/feature-dispatch-answer-gate.md',
209
+ ' canon tasks plan-answers ../plans/feature-dispatch-answer-gate.md',
210
+ ' canon tasks plan-answers dispatch-answer-gate --json',
211
+ '',
212
+ ].join('\n'),
213
+ )
214
+ .action(async (plan: string, opts: AnswersCommandOptions) => {
215
+ process.exitCode = await runAnswers(plan, opts)
216
+ })
217
+
176
218
  tasks
177
219
  .command('pull-request')
178
220
  .description('Record a pull request number on the task a branch closes')
@@ -539,6 +581,64 @@ function describeCitations(outcome: PlanCitations): string {
539
581
  return `shares ${outcome.target} with ${outcome.citedBy.join(', ')}, so the sweep leaves it.`
540
582
  }
541
583
 
584
+ async function runAnswers(
585
+ plan: string,
586
+ opts: AnswersCommandOptions,
587
+ ): Promise<number> {
588
+ const root = opts.root ?? (await mainWorktreeRoot())
589
+ const outcome = await planAnswers(root, plan)
590
+
591
+ return reportAnswers(outcome, opts.json ?? false, root)
592
+ }
593
+
594
+ function reportAnswers(
595
+ outcome: AnswersOutcome,
596
+ emitJson: boolean,
597
+ root: string,
598
+ ): number {
599
+ if (!outcome.ok) {
600
+ if (emitJson) {
601
+ process.stdout.write(
602
+ `${JSON.stringify({ ok: false, reason: outcome.reason, message: outcome.message })}\n`,
603
+ )
604
+ return 1
605
+ }
606
+
607
+ intro('canon tasks plan-answers')
608
+ logStep('Refused')
609
+ logError(outcome.message)
610
+ outro()
611
+ return 1
612
+ }
613
+
614
+ if (emitJson) {
615
+ process.stdout.write(`${JSON.stringify({ ...outcome, root })}\n`)
616
+ return outcome.launchable ? 0 : EXIT_FINDINGS
617
+ }
618
+
619
+ intro('canon tasks plan-answers')
620
+ logStep(outcome.plan)
621
+
622
+ if (outcome.launchable) {
623
+ logInfo('waits on nobody, so the dispatch may launch it.')
624
+ outro()
625
+ return 0
626
+ }
627
+
628
+ for (const question of outcome.open) {
629
+ logWarn(`${question.label} ${question.why}`)
630
+ }
631
+
632
+ const one = outcome.open.length === 1
633
+
634
+ logError(
635
+ `${outcome.open.length} question${one ? '' : 's'} ${one ? 'waits' : 'wait'} on you, so the dispatch holds this row.`,
636
+ )
637
+ outro()
638
+
639
+ return EXIT_FINDINGS
640
+ }
641
+
542
642
  function reportValidation(
543
643
  outcome: ValidateOutcome,
544
644
  emitJson: boolean,
package/src/paths.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { sep } from 'node:path'
2
+
3
+ /**
4
+ * Whether a resolved path is the directory itself or sits inside it. The
5
+ * separator guard is the whole of it: a bare prefix test reads
6
+ * `.claude/plans-archive` as living under `.claude/plans`, which is a sibling
7
+ * rather than a child and is exactly the pair the plan folders spell.
8
+ *
9
+ * It lives at the root rather than beside either caller because both resolve
10
+ * plan paths and only one of them may reach `src/tasks/archive.ts`, whose
11
+ * index regeneration pulls in a Bun-only import that a test running under
12
+ * Vitest cannot load.
13
+ */
14
+ export function isUnder(path: string, dir: string): boolean {
15
+ return path === dir || path.startsWith(`${dir}${sep}`)
16
+ }
@@ -0,0 +1,206 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ /**
5
+ * What this run can compute from the tree alone. A field stays absent rather
6
+ * than empty when nothing local resolves it, so a caller never reads
7
+ * "nothing to propose" as "propose removing what is already there."
8
+ */
9
+ export interface MetadataProposal {
10
+ readonly description?: string
11
+ readonly homepage?: string
12
+ readonly topics?: readonly string[]
13
+ }
14
+
15
+ /** What the remote already carries, read by the caller rather than here. */
16
+ export interface CurrentMetadata {
17
+ readonly description: string
18
+ readonly homepage: string
19
+ readonly topics: readonly string[]
20
+ }
21
+
22
+ export interface MetadataDiff {
23
+ readonly description?: { readonly current: string; readonly proposed: string }
24
+ readonly homepage?: { readonly current: string; readonly proposed: string }
25
+ readonly topics?: {
26
+ readonly added: readonly string[]
27
+ readonly removed: readonly string[]
28
+ }
29
+ }
30
+
31
+ /** GitHub's own cap on the About field. */
32
+ const MAX_DESCRIPTION_LENGTH = 350
33
+
34
+ /** GitHub's own cap on topics per repository. */
35
+ const MAX_TOPICS = 20
36
+
37
+ /** GitHub's own shape for a topic: lowercase, alphanumeric, internal hyphens. */
38
+ const TOPIC_PATTERN = /^[a-z0-9][a-z0-9-]*$/
39
+
40
+ /**
41
+ * Whether an already-trimmed, already-lowercased string is a shape GitHub
42
+ * accepts as a topic. Exported so a caller validating an operator-supplied
43
+ * topic list checks against the same rule this reader silently filters
44
+ * `package.json`'s `keywords` through, rather than reimplementing it against
45
+ * a looser test such as non-emptiness alone.
46
+ */
47
+ export function isValidTopic(topic: string): boolean {
48
+ return TOPIC_PATTERN.test(topic)
49
+ }
50
+
51
+ /**
52
+ * A bare image, or an image wrapped in a link, which is the shape a shields.io
53
+ * badge takes. The wrapped alternative goes first, since the bare form would
54
+ * otherwise match its inner image alone and leave the wrapping link behind.
55
+ */
56
+ const BADGE_TOKEN = /\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)|!\[[^\]]*\]\([^)]*\)/g
57
+
58
+ /** True once every badge token is stripped and nothing remains. */
59
+ function isBadgeLine(line: string): boolean {
60
+ return line.replace(BADGE_TOKEN, '').trim() === ''
61
+ }
62
+
63
+ function stripInlineMarkdown(line: string): string {
64
+ return line
65
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
66
+ .replace(/(\*\*|__)(.+?)\1/g, '$2')
67
+ .replace(/(\*|_|`)(.+?)\1/g, '$2')
68
+ .trim()
69
+ }
70
+
71
+ /**
72
+ * The first prose line past the title and any badge row, stripped of inline
73
+ * markdown and capped at GitHub's About field length.
74
+ *
75
+ * `undefined` only when the file carries nothing past its title and badges,
76
+ * per the skill requirement's refusal boundary: a resolvable line that reads
77
+ * as a title rather than a sentence is still proposed, since rejecting it is
78
+ * a judgment for the person reading the proposal rather than for this reader.
79
+ */
80
+ export function extractOpeningLine(readme: string): string | undefined {
81
+ for (const raw of readme.split('\n')) {
82
+ const line = raw.trim()
83
+ if (line === '' || line.startsWith('#') || isBadgeLine(line)) continue
84
+
85
+ const stripped = stripInlineMarkdown(line)
86
+ if (stripped === '') continue
87
+
88
+ return stripped.length > MAX_DESCRIPTION_LENGTH
89
+ ? `${stripped.slice(0, MAX_DESCRIPTION_LENGTH - 1)}…`
90
+ : stripped
91
+ }
92
+ return undefined
93
+ }
94
+
95
+ /**
96
+ * `package.json`'s `keywords` read as candidate topics, the field the wider
97
+ * npm ecosystem already uses for this. Invalid entries are dropped silently
98
+ * rather than refused, since a manifest mixing free-text keywords with
99
+ * topic-shaped ones is ordinary and only the shaped half transfers.
100
+ */
101
+ function readTopics(keywords: unknown): readonly string[] | undefined {
102
+ if (!Array.isArray(keywords)) return undefined
103
+
104
+ const topics = new Set<string>()
105
+ for (const entry of keywords) {
106
+ if (typeof entry !== 'string') continue
107
+ const topic = entry.trim().toLowerCase()
108
+ if (isValidTopic(topic)) topics.add(topic)
109
+ if (topics.size === MAX_TOPICS) break
110
+ }
111
+ return [...topics]
112
+ }
113
+
114
+ interface PackageFields {
115
+ readonly homepage?: unknown
116
+ readonly keywords?: unknown
117
+ }
118
+
119
+ async function readManifest(root: string): Promise<PackageFields | undefined> {
120
+ try {
121
+ const parsed: unknown = JSON.parse(
122
+ await readFile(join(root, 'package.json'), 'utf8'),
123
+ )
124
+ return (parsed ?? undefined) as PackageFields | undefined
125
+ } catch {
126
+ return undefined
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Computes what this tree can propose for its own repository metadata, with
132
+ * no network call: an About text from the README's opening line, and a
133
+ * homepage and a topic set from `package.json`, the one manifest every
134
+ * target project this ships to already carries.
135
+ *
136
+ * A field a target project declares nowhere stays absent rather than empty,
137
+ * so the caller comparing this against the remote never treats a field this
138
+ * reader has no opinion on as a proposal to clear it.
139
+ */
140
+ export async function proposeMetadata(root: string): Promise<MetadataProposal> {
141
+ const [readme, manifest] = await Promise.all([
142
+ readFile(join(root, 'README.md'), 'utf8').catch(() => undefined),
143
+ readManifest(root),
144
+ ])
145
+
146
+ const description =
147
+ readme === undefined ? undefined : extractOpeningLine(readme)
148
+
149
+ const homepage =
150
+ typeof manifest?.homepage === 'string' && manifest.homepage.trim() !== ''
151
+ ? manifest.homepage.trim()
152
+ : undefined
153
+
154
+ const topics = readTopics(manifest?.keywords)
155
+
156
+ return {
157
+ ...(description !== undefined && { description }),
158
+ ...(homepage !== undefined && { homepage }),
159
+ ...(topics !== undefined && topics.length > 0 && { topics }),
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Compares a computed proposal against what the remote already carries.
165
+ *
166
+ * A field the proposal has no opinion on is never diffed, which is what
167
+ * keeps a target project with no `keywords` field from seeing every existing
168
+ * topic reported as a removal.
169
+ */
170
+ export function compareMetadata(
171
+ current: CurrentMetadata,
172
+ proposed: MetadataProposal,
173
+ ): MetadataDiff {
174
+ const diff: {
175
+ description?: { current: string; proposed: string }
176
+ homepage?: { current: string; proposed: string }
177
+ topics?: { added: readonly string[]; removed: readonly string[] }
178
+ } = {}
179
+
180
+ if (
181
+ proposed.description !== undefined &&
182
+ proposed.description !== current.description
183
+ ) {
184
+ diff.description = {
185
+ current: current.description,
186
+ proposed: proposed.description,
187
+ }
188
+ }
189
+
190
+ if (
191
+ proposed.homepage !== undefined &&
192
+ proposed.homepage !== current.homepage
193
+ ) {
194
+ diff.homepage = { current: current.homepage, proposed: proposed.homepage }
195
+ }
196
+
197
+ if (proposed.topics !== undefined) {
198
+ const currentSet = new Set(current.topics)
199
+ const proposedSet = new Set(proposed.topics)
200
+ const added = proposed.topics.filter((topic) => !currentSet.has(topic))
201
+ const removed = current.topics.filter((topic) => !proposedSet.has(topic))
202
+ if (added.length > 0 || removed.length > 0) diff.topics = { added, removed }
203
+ }
204
+
205
+ return diff
206
+ }
@@ -0,0 +1,195 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { isAbsolute, join, relative, resolve } from 'node:path'
4
+ import { isUnder } from '@/paths'
5
+ import { readQuestions, splitPlanSections } from '@/records/validate'
6
+
7
+ const PLANS_DIR = join('.claude', 'plans')
8
+ const PLANS_ARCHIVE_DIR = join(PLANS_DIR, 'archive')
9
+ const TASKS_DIR = join('.claude', 'tasks')
10
+
11
+ /**
12
+ * The suggestion the plan standard fixes for a question that turns on the
13
+ * operator's preference rather than on a technical default. Every other
14
+ * suggestion is accepted by a blank slot, so only this phrase over an empty
15
+ * `- Answer:` is a stop.
16
+ */
17
+ const OPERATOR_CALL = 'needs your call'
18
+
19
+ const SUGGESTED_PREFIX = '- Suggested:'
20
+ const ANSWER_PREFIX = '- Answer:'
21
+
22
+ export const ANSWER_REFUSALS = ['no-plan', 'archived', 'bad-input'] as const
23
+
24
+ export type AnswerRefusal = (typeof ANSWER_REFUSALS)[number]
25
+
26
+ export interface AnswersRefused {
27
+ readonly ok: false
28
+ readonly reason: AnswerRefusal
29
+ readonly message: string
30
+ readonly detail: readonly string[]
31
+ }
32
+
33
+ /**
34
+ * One question the dispatch has to hand back. It carries the label rather than
35
+ * contributing to a count, since a dispatcher that refuses a row without naming
36
+ * the slot has nothing to give the operator.
37
+ */
38
+ export interface OpenQuestion {
39
+ readonly label: string
40
+ readonly why: string
41
+ }
42
+
43
+ export interface PlanAnswers {
44
+ readonly ok: true
45
+ readonly plan: string
46
+ readonly launchable: boolean
47
+ readonly open: readonly OpenQuestion[]
48
+ }
49
+
50
+ export type AnswersOutcome = PlanAnswers | AnswersRefused
51
+
52
+ /**
53
+ * The spellings a caller reaches a plan by, in the order they are tried. A bare
54
+ * slug names the live folder outright, and a path is resolved against the
55
+ * project root and against the board directory both.
56
+ *
57
+ * The second base is the one a dispatcher actually has to hand. A board row
58
+ * writes its `Plan:` link relative to `.claude/tasks/`, so the href reads
59
+ * `../plans/feature-<slug>.md`, and resolving that against the root alone lands
60
+ * a directory above the repository and refuses a plan that exists.
61
+ *
62
+ * `resolveLivePlan` reads a task's own line against the same two bases and is
63
+ * not this function. It tries the board first and tests containment under the
64
+ * live plans folder, where this tries the root first and tests nothing, so the
65
+ * two agree on the spellings a board writes and part company outside them.
66
+ * Sharing the bases is what makes a board link resolve for both, and the
67
+ * archive exclusion in `planAnswers` is stated separately for that reason.
68
+ *
69
+ * Root order is what keeps the documented forms unchanged. A reference that
70
+ * resolves from the root is taken there, and the board base is reached only by
71
+ * a path the root could not answer.
72
+ */
73
+ export function planCandidates(root: string, reference: string): string[] {
74
+ if (reference.includes('/') || reference.endsWith('.md')) {
75
+ if (isAbsolute(reference)) return [reference]
76
+
77
+ return [resolve(root, reference), resolve(join(root, TASKS_DIR), reference)]
78
+ }
79
+
80
+ const slug = reference.startsWith('feature-')
81
+ ? reference.slice('feature-'.length)
82
+ : reference
83
+
84
+ return [join(root, PLANS_DIR, `feature-${slug}.md`)]
85
+ }
86
+
87
+ function suggestionOf(body: readonly string[]): string | undefined {
88
+ const line = body.find((entry) => entry.startsWith(SUGGESTED_PREFIX))
89
+
90
+ return line?.slice(SUGGESTED_PREFIX.length).trim()
91
+ }
92
+
93
+ /**
94
+ * An absent `- Answer:` line reads the same as a blank one here. The slot being
95
+ * missing is a conformance defect `canon tasks validate` already names, and a
96
+ * gate that answered it differently would refuse a row for a reason the
97
+ * validator has already reported.
98
+ */
99
+ function isAnswered(body: readonly string[]): boolean {
100
+ const line = body.find((entry) => entry.startsWith(ANSWER_PREFIX))
101
+ if (line === undefined) return false
102
+
103
+ return line.slice(ANSWER_PREFIX.length).trim().length > 0
104
+ }
105
+
106
+ /**
107
+ * The standard writes the reason behind a comma and the corpus also writes it
108
+ * behind a full stop, so both separators come off. Reporting the phrase with
109
+ * whatever punctuation followed it hands the operator a stray mark where the
110
+ * reason should start.
111
+ */
112
+ function reasonOf(suggested: string): string {
113
+ const rest = suggested.slice(OPERATOR_CALL.length).replace(/^[,.;:\s]+/, '')
114
+
115
+ return rest.length > 0 ? rest : 'no reason stated'
116
+ }
117
+
118
+ /**
119
+ * A question carrying no suggestion at all is also a stop at execution, and it
120
+ * is not read here. `checkQuestionContract` reports it as `suggestion-missing`
121
+ * and the runbook dispatches a row whose plan is already verified, so testing
122
+ * it again would put one rule in two places that ship on different cadences.
123
+ */
124
+ function openQuestions(lines: readonly string[]): OpenQuestion[] {
125
+ const open: OpenQuestion[] = []
126
+
127
+ for (const question of readQuestions(lines)) {
128
+ const suggested = suggestionOf(question.body)
129
+ if (!suggested?.toLowerCase().startsWith(OPERATOR_CALL)) continue
130
+ if (isAnswered(question.body)) continue
131
+
132
+ open.push({ label: question.label, why: reasonOf(suggested) })
133
+ }
134
+
135
+ return open
136
+ }
137
+
138
+ /**
139
+ * Answers whether a plan is launchable, which is whether it still waits on the
140
+ * operator for a call only they can make. It reads the question block through
141
+ * the same `readQuestions` the plan validator runs, so the gate and the
142
+ * conformance check cannot drift into disagreeing about what a question is.
143
+ *
144
+ * It reports and never writes. Holding the row, naming the slot, and reaching
145
+ * the operator belong to the dispatcher, which is where the decision already
146
+ * sits.
147
+ */
148
+ export async function planAnswers(
149
+ root: string,
150
+ reference: string,
151
+ ): Promise<AnswersOutcome> {
152
+ if (reference.trim().length === 0) {
153
+ return refuse('bad-input', 'No plan named. Pass a plan path or its slug.')
154
+ }
155
+
156
+ const candidates = planCandidates(root, reference)
157
+ const path = candidates.find((candidate) => existsSync(candidate))
158
+
159
+ if (!path) {
160
+ // Naming every base keeps a task-relative link from reporting the one place
161
+ // it does not resolve, since `relative` hands that spelling straight back.
162
+ const looked = candidates.map((entry) => relative(root, entry)).join(' or ')
163
+
164
+ return refuse('no-plan', `No plan at ${looked}.`, [reference])
165
+ }
166
+
167
+ // An archived plan answers every question and would report as launchable, so
168
+ // the name would clear a dispatch that `claude-autoship` Step 1 then refuses
169
+ // as already-shipped work. Catching it here is a step earlier than the worker.
170
+ if (isUnder(path, join(root, PLANS_ARCHIVE_DIR))) {
171
+ return refuse(
172
+ 'archived',
173
+ `${relative(root, path)} sits in the plans archive, so it describes work that already shipped.`,
174
+ [reference],
175
+ )
176
+ }
177
+
178
+ const sections = splitPlanSections(await readFile(path, 'utf8'))
179
+ const open = openQuestions(sections.get('Questions') ?? [])
180
+
181
+ return {
182
+ ok: true,
183
+ plan: relative(root, path),
184
+ launchable: open.length === 0,
185
+ open,
186
+ }
187
+ }
188
+
189
+ function refuse(
190
+ reason: AnswerRefusal,
191
+ message: string,
192
+ detail: readonly string[] = [],
193
+ ): AnswersRefused {
194
+ return { ok: false, reason, message, detail }
195
+ }
@@ -1,7 +1,8 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises'
3
- import { join, relative, resolve, sep } from 'node:path'
3
+ import { join, relative, resolve } from 'node:path'
4
4
  import { regenOne } from '@/indexes/regen'
5
+ import { isUnder } from '@/paths'
5
6
 
6
7
  const TASKS_DIR = join('.claude', 'tasks')
7
8
  const ARCHIVE_DIR = join(TASKS_DIR, 'archive')
@@ -180,14 +181,6 @@ function isRowFor(line: string, target: string): boolean {
180
181
  return first !== undefined && first.includes(target)
181
182
  }
182
183
 
183
- /**
184
- * Tests containment rather than a string prefix, so a sibling whose name merely
185
- * extends the folder's is not read as being inside it.
186
- */
187
- function isUnder(path: string, dir: string): boolean {
188
- return path === dir || path.startsWith(`${dir}${sep}`)
189
- }
190
-
191
184
  /**
192
185
  * Resolves the `Plan:` target against the board and against the project root
193
186
  * both, which is how `claude-docs` reads the same line. It accepts `../plans/x.md`