@erclx/canon 4.3.0 → 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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +34 -9
- package/claude/skills/repo-metadata/REQUIREMENT.md +37 -0
- package/claude/skills/repo-metadata/SKILL.md +51 -0
- package/docs/agents/commands.md +64 -62
- package/docs/agents/sandbox.md +3 -1
- package/docs/agents/tasks.md +35 -0
- package/docs/operating-model.md +1 -1
- package/package.json +1 -1
- package/scripts/lib/sandbox-dispatch.sh +182 -0
- package/src/claude/cases/misc.ts +4 -0
- package/src/cli.ts +4 -0
- package/src/commands/repo.ts +393 -0
- package/src/commands/tasks.ts +100 -0
- package/src/paths.ts +16 -0
- package/src/repo/metadata.ts +206 -0
- package/src/tasks/answers.ts +195 -0
- package/src/tasks/archive.ts +2 -9
|
@@ -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
|
+
}
|
package/src/tasks/archive.ts
CHANGED
|
@@ -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
|
|
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`
|