@erclx/canon 4.122.0 → 4.124.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/auto-ship/SKILL.md +7 -8
- package/claude/skills/auto-ship/references/tier-failures.md +7 -4
- package/claude/skills/backlog-triage/SKILL.md +1 -1
- package/claude/skills/codebase-layout/REQUIREMENT.md +1 -1
- package/claude/skills/codebase-layout/SKILL.md +1 -1
- package/claude/skills/create-rule/SKILL.md +5 -2
- package/claude/skills/design-taste/SKILL.md +1 -1
- package/claude/skills/draft-ready/REQUIREMENT.md +1 -1
- package/claude/skills/draft-ready/SKILL.md +1 -1
- package/claude/skills/git-ship/SKILL.md +4 -3
- package/claude/skills/memory-capture/SKILL.md +14 -4
- package/claude/skills/memory-review/REQUIREMENT.md +4 -2
- package/claude/skills/memory-review/SKILL.md +18 -7
- package/claude/skills/memory-review/references/later-phases.md +7 -4
- package/claude/skills/memory-review/references/receipt-format.md +19 -3
- package/claude/skills/role-orchestrator/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -2
- package/claude/skills/session-resume/SKILL.md +4 -3
- package/claude/skills/task-board/SKILL.md +1 -1
- package/claude/skills/task-board/references/archive.md +1 -1
- package/claude/skills/task-board/references/decline.md +1 -1
- package/claude/skills/test-first/REQUIREMENT.md +1 -1
- package/docs/agents/counts.md +3 -3
- package/docs/agents/drift-surfaces.md +2 -2
- package/docs/agents/rule-citations.md +4 -4
- package/docs/agents/tasks-archive.md +3 -3
- package/docs/agents/tasks-plans.md +5 -3
- package/docs/agents/tasks-validate.md +1 -1
- package/docs/agents/tasks.md +1 -1
- package/docs/agents/test-order.md +1 -1
- package/docs/index.md +3 -0
- package/docs/target-migrations.md +93 -0
- package/docs/target-projects.md +4 -216
- package/docs/target-scenarios.md +56 -0
- package/docs/target-sync.md +93 -0
- package/docs/workflow/ai-workflow.md +4 -203
- package/docs/workflow/index.md +2 -0
- package/docs/workflow/operating-model.md +18 -47
- package/docs/workflow/parallel-features.md +77 -0
- package/docs/workflow/skill-map.md +148 -0
- package/governance/renames.toml +34 -0
- package/governance/rules/{claude/555-tasks.md → canon/655-tasks.md} +2 -1
- package/governance/rules/{claude/560-diagrams.md → canon/660-diagrams.md} +1 -1
- package/governance/rules/{claude/562-session.md → canon/662-session.md} +1 -1
- package/governance/rules/code/000-code.md +21 -0
- package/governance/rules/lib/300-testing-ts.md +0 -4
- package/governance/rules/lib/330-testing-py.md +0 -4
- package/governance/rules/{claude/500-prose.md → writing/800-prose.md} +1 -1
- package/governance/rules/{claude/501-markdown.md → writing/801-markdown.md} +1 -1
- package/governance/rules/{claude/502-mermaid.md → writing/802-mermaid.md} +1 -1
- package/governance/stacks/base.toml +7 -6
- package/governance/stacks/node.toml +1 -1
- package/governance/stacks/python.toml +1 -1
- package/package.json +1 -1
- package/scripts/core/install-check.sh +3 -5
- package/src/claude/routing.ts +1 -1
- package/src/commands/gov.ts +1 -1
- package/src/commands/tasks.ts +39 -29
- package/src/gate/measures.ts +1 -6
- package/src/gate/stages.ts +1 -0
- package/src/gov/citations.ts +1 -1
- package/src/gov/list.ts +1 -1
- package/src/gov/restated.ts +2 -2
- package/src/init/steps.ts +1 -1
- package/src/labels/spelling.ts +1 -1
- package/src/records/prune.ts +1 -1
- package/src/shipped/references.ts +1 -1
- package/src/tasks/archive.ts +212 -105
- package/src/tasks/reach.ts +1 -1
- package/src/tasks/record.ts +59 -16
- package/src/tasks/validate.ts +1 -1
- package/standards/board.md +136 -0
- package/standards/commit.md +5 -0
- package/standards/groundwork.md +25 -36
- package/standards/index.md +2 -1
- package/standards/plan.md +20 -34
- package/standards/readme.md +8 -77
- package/standards/requirements.md +2 -2
- package/standards/rule.md +4 -6
- package/standards/tasks.md +18 -125
- package/standards/versioning.md +1 -1
- package/tooling/base/configs/commitlint.config.js +27 -1
- package/tooling/base/reference.md +2 -2
- package/tooling/claude/seeds/.claude/hooks/path-form.sh +1 -1
- package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +1 -1
- package/tooling/web/reference.md +1 -1
- package/governance/rules/core/000-constitution.md +0 -30
- package/governance/rules/core/020-concurrency.md +0 -22
- package/governance/rules/core/030-error-handling.md +0 -31
- package/governance/rules/core/040-performance.md +0 -20
- package/governance/rules/core/050-logging.md +0 -20
- package/governance/rules/core/060-naming.md +0 -19
- package/governance/rules/snippets/600-at-references.md +0 -9
- /package/governance/rules/{core/025-indexes.md → canon/601-indexes.md} +0 -0
- /package/governance/rules/{core/035-tasks.md → canon/602-tasks.md} +0 -0
- /package/governance/rules/{core/045-memory.md → canon/603-memory.md} +0 -0
- /package/governance/rules/{core/055-scratch.md → canon/604-scratch.md} +0 -0
- /package/governance/rules/{core/085-worktrees.md → canon/605-worktrees.md} +0 -0
- /package/governance/rules/{core/087-git.md → canon/606-git.md} +0 -0
- /package/governance/rules/{claude/510-context.md → canon/610-context.md} +0 -0
- /package/governance/rules/{claude/511-indexes.md → canon/611-indexes.md} +0 -0
- /package/governance/rules/{claude/520-wireframes.md → canon/620-wireframes.md} +0 -0
- /package/governance/rules/{claude/530-requirements.md → canon/630-requirements.md} +0 -0
- /package/governance/rules/{claude/540-architecture.md → canon/640-architecture.md} +0 -0
- /package/governance/rules/{claude/545-decisions.md → canon/645-decisions.md} +0 -0
- /package/governance/rules/{claude/550-design.md → canon/650-design.md} +0 -0
- /package/governance/rules/{claude/556-groundwork.md → canon/656-groundwork.md} +0 -0
- /package/governance/rules/{claude/557-intake.md → canon/657-intake.md} +0 -0
- /package/governance/rules/{claude/558-plan.md → canon/658-plan.md} +0 -0
- /package/governance/rules/{claude/559-memory.md → canon/659-memory.md} +0 -0
- /package/governance/rules/{claude/561-teach.md → canon/661-teach.md} +0 -0
- /package/governance/rules/{claude/563-ready.md → canon/663-ready.md} +0 -0
- /package/governance/rules/{core/005-behavior.md → claude/565-behavior.md} +0 -0
- /package/governance/rules/{core/015-output.md → claude/566-output.md} +0 -0
- /package/governance/rules/{core/070-planning.md → claude/567-planning.md} +0 -0
- /package/governance/rules/{core → code}/010-testing.md +0 -0
- /package/governance/rules/{ci → tooling}/700-ci-workflow.md +0 -0
- /package/governance/rules/{core/065-spelling.md → tooling/765-spelling.md} +0 -0
- /package/governance/rules/{core/075-dependencies.md → tooling/775-dependencies.md} +0 -0
- /package/governance/rules/{core/080-config-comments.md → tooling/780-config-comments.md} +0 -0
- /package/governance/rules/{core/090-code-comments.md → tooling/790-code-comments.md} +0 -0
- /package/governance/rules/{claude/580-readme.md → writing/880-readme.md} +0 -0
- /package/governance/rules/{claude/591-standard-authoring.md → writing/891-standard-authoring.md} +0 -0
package/src/tasks/archive.ts
CHANGED
|
@@ -90,9 +90,9 @@ export interface ArchiveSuccess {
|
|
|
90
90
|
readonly to: string
|
|
91
91
|
readonly priorityRowRemoved: boolean
|
|
92
92
|
readonly indexRegenerated: boolean
|
|
93
|
-
/**
|
|
94
|
-
readonly
|
|
95
|
-
/** Undefined when
|
|
93
|
+
/** Every live plan the task cited that no other live task still holds, in line order. */
|
|
94
|
+
readonly plans: readonly PlanMove[]
|
|
95
|
+
/** Undefined when no plan moves, or when no ready folder resolves and can move. */
|
|
96
96
|
readonly ready: ReadyMove | undefined
|
|
97
97
|
readonly closed: number
|
|
98
98
|
readonly cut: number
|
|
@@ -115,8 +115,8 @@ export interface DeclineSuccess {
|
|
|
115
115
|
readonly priorityRowRemoved: boolean
|
|
116
116
|
readonly backlogRowRemoved: boolean
|
|
117
117
|
readonly indexRegenerated: boolean
|
|
118
|
-
/**
|
|
119
|
-
readonly
|
|
118
|
+
/** Every live plan the task cited that no other live task still holds, in line order. */
|
|
119
|
+
readonly plans: readonly PlanMove[]
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
export interface DeclineRefused {
|
|
@@ -251,46 +251,78 @@ export function readPullRequest(text: string): readonly number[] {
|
|
|
251
251
|
return match[1].split(',').map((entry) => Number(entry.trim().slice(1)))
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
/** A markdown link on a `Plan:` line, its target captured. */
|
|
255
|
+
const PLAN_LINK_PATTERN = /\[[^\]]*\]\(([^)]+)\)/g
|
|
256
|
+
|
|
254
257
|
/**
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
* line that follows it.
|
|
258
|
+
* Finds the first `Plan:` line outside a fenced sample. Every reader and both
|
|
259
|
+
* writers locate the line through this, so none of them can act on a sample a
|
|
260
|
+
* task displays while another reads the real line below it.
|
|
259
261
|
*/
|
|
260
|
-
|
|
262
|
+
export function planLineIndex(lines: readonly string[]): number {
|
|
263
|
+
const fenced = fenceMask(lines)
|
|
264
|
+
|
|
265
|
+
return lines.findIndex(
|
|
266
|
+
(line, index) => !fenced[index] && line.startsWith('Plan:'),
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
interface PlanToken {
|
|
271
|
+
readonly target: string
|
|
272
|
+
readonly start: number
|
|
273
|
+
readonly end: number
|
|
274
|
+
}
|
|
261
275
|
|
|
262
276
|
/**
|
|
263
|
-
*
|
|
264
|
-
* bare
|
|
277
|
+
* The targets a located `Plan:` line carries, each with its span on the line.
|
|
278
|
+
* Links win over the bare form. A bare remainder yields its leading run of
|
|
279
|
+
* tokens reading as a path, one carrying `/` or ending in `.md`, and stops at
|
|
280
|
+
* the first that does not, so a path inside trailing prose never reads as a
|
|
281
|
+
* second plan. It falls back to the first token when that one is not a path.
|
|
265
282
|
*/
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
283
|
+
function planTokens(line: string): readonly PlanToken[] {
|
|
284
|
+
const links = [...line.matchAll(PLAN_LINK_PATTERN)].map((match) => ({
|
|
285
|
+
target: match[1],
|
|
286
|
+
start: match.index,
|
|
287
|
+
end: match.index + match[0].length,
|
|
288
|
+
}))
|
|
289
|
+
if (links.length > 0) return links
|
|
290
|
+
|
|
291
|
+
const bare = [...line.slice('Plan:'.length).matchAll(/[^\s,]+/g)].map(
|
|
292
|
+
(match) => ({
|
|
293
|
+
target: match[0],
|
|
294
|
+
start: 'Plan:'.length + match.index,
|
|
295
|
+
end: 'Plan:'.length + match.index + match[0].length,
|
|
296
|
+
}),
|
|
297
|
+
)
|
|
298
|
+
const prose = bare.findIndex(
|
|
299
|
+
({ target }) => !target.includes('/') && !target.endsWith('.md'),
|
|
300
|
+
)
|
|
301
|
+
const paths = prose === -1 ? bare : bare.slice(0, prose)
|
|
302
|
+
|
|
303
|
+
return paths.length > 0 ? paths : bare.slice(0, 1)
|
|
270
304
|
}
|
|
271
305
|
|
|
272
306
|
/**
|
|
273
|
-
* Reads every target the first `Plan:` line outside a fenced sample carries
|
|
274
|
-
*
|
|
275
|
-
* holding exactly one pointer, so this is how a caller tells a line linking
|
|
276
|
-
* several plans apart from no line at all.
|
|
307
|
+
* Reads every target the first `Plan:` line outside a fenced sample carries.
|
|
308
|
+
* The path is returned as written, relative to the board.
|
|
277
309
|
*/
|
|
278
310
|
export function readPlanTargets(text: string): readonly string[] {
|
|
279
311
|
const lines = text.split('\n')
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
(candidate, index) => !fenced[index] && candidate.startsWith('Plan:'),
|
|
283
|
-
)
|
|
284
|
-
if (line === undefined) return []
|
|
312
|
+
const index = planLineIndex(lines)
|
|
313
|
+
if (index === -1) return []
|
|
285
314
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
(match) => match[1],
|
|
289
|
-
)
|
|
290
|
-
if (links.length > 0) return links
|
|
315
|
+
return planTokens(lines[index]).map(({ target }) => target)
|
|
316
|
+
}
|
|
291
317
|
|
|
292
|
-
|
|
293
|
-
|
|
318
|
+
/**
|
|
319
|
+
* The single target a task's `Plan:` line carries, or `undefined` for no line
|
|
320
|
+
* and for a line linking several plans alike. A caller that must tell those two
|
|
321
|
+
* apart reads `readPlanTargets` instead.
|
|
322
|
+
*/
|
|
323
|
+
export function readPlanTarget(text: string): string | undefined {
|
|
324
|
+
const targets = readPlanTargets(text)
|
|
325
|
+
return targets.length === 1 ? targets[0] : undefined
|
|
294
326
|
}
|
|
295
327
|
|
|
296
328
|
/**
|
|
@@ -300,28 +332,50 @@ export function readPlanTargets(text: string): readonly string[] {
|
|
|
300
332
|
* one line shape rather than two.
|
|
301
333
|
*/
|
|
302
334
|
export function planLine(target: string): string {
|
|
335
|
+
return `Plan: ${planLink(target)}`
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function planLink(target: string): string {
|
|
303
339
|
const name = basename(target)
|
|
304
340
|
const label = name.endsWith('.md') ? name.slice(0, -'.md'.length) : name
|
|
305
341
|
|
|
306
|
-
return `
|
|
342
|
+
return `[${label}](${target})`
|
|
307
343
|
}
|
|
308
344
|
|
|
309
345
|
/**
|
|
310
|
-
* Points the task's `Plan:` line at the plan's new home
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
346
|
+
* Points each moved link on the task's `Plan:` line at the plan's new home,
|
|
347
|
+
* keyed by the target as the line wrote it. A link the map does not name keeps
|
|
348
|
+
* its path, since a plan another task still cites stays live. A moved bare
|
|
349
|
+
* path is written back as a link, the shape `planLine` gives a single one.
|
|
314
350
|
*
|
|
315
|
-
* The
|
|
316
|
-
* because `$&` and its siblings are substitution sequences inside
|
|
317
|
-
*
|
|
318
|
-
*
|
|
351
|
+
* The line is rebuilt by concatenation rather than a replacement string,
|
|
352
|
+
* because `$&` and its siblings are substitution sequences inside one. A plan
|
|
353
|
+
* filename carrying one would write a path nobody typed, and `.canon/plans/` is
|
|
354
|
+
* gitignored, so nothing recovers the pointer it replaced.
|
|
319
355
|
*/
|
|
320
|
-
export function retargetPlanLine(
|
|
321
|
-
|
|
356
|
+
export function retargetPlanLine(
|
|
357
|
+
text: string,
|
|
358
|
+
moves: ReadonlyMap<string, string>,
|
|
359
|
+
): string {
|
|
360
|
+
const lines = text.split('\n')
|
|
361
|
+
const index = planLineIndex(lines)
|
|
362
|
+
if (index === -1) return text
|
|
363
|
+
|
|
364
|
+
const line = lines[index]
|
|
365
|
+
let rewritten = ''
|
|
366
|
+
let at = 0
|
|
367
|
+
for (const token of planTokens(line)) {
|
|
368
|
+
const to = moves.get(token.target)
|
|
369
|
+
if (to === undefined) continue
|
|
370
|
+
rewritten += line.slice(at, token.start) + planLink(to)
|
|
371
|
+
at = token.end
|
|
372
|
+
}
|
|
373
|
+
lines[index] = rewritten + line.slice(at)
|
|
374
|
+
|
|
375
|
+
return lines.join('\n')
|
|
322
376
|
}
|
|
323
377
|
|
|
324
|
-
/** The `Ready:` line in either form,
|
|
378
|
+
/** The `Ready:` line in either form, the link's target captured ahead of the bare path. */
|
|
325
379
|
const READY_PATTERN = /^Ready:[ \t]*(?:\[[^\]]*\]\(([^)]+)\)|(\S+))[ \t]*$/m
|
|
326
380
|
|
|
327
381
|
/** A ready folder path as a plan's constraints write it, the folder name captured. */
|
|
@@ -508,7 +562,8 @@ function atOneRoot(path: string, plans: string[], root: string): string {
|
|
|
508
562
|
}
|
|
509
563
|
|
|
510
564
|
/**
|
|
511
|
-
* Names the other live tasks whose `Plan:` line lands on the same file
|
|
565
|
+
* Names the other live tasks whose `Plan:` line lands on the same file through
|
|
566
|
+
* any of its links. This is
|
|
512
567
|
* the rule `context-fold` applies before it archives a plan, held here so one
|
|
513
568
|
* question has one implementation: a plan another live task still cites is a
|
|
514
569
|
* plan the sweep is correct to leave, and a guard that read the folder instead
|
|
@@ -528,11 +583,13 @@ export async function otherTasksCitingPlan(
|
|
|
528
583
|
|
|
529
584
|
const read = await Promise.all(
|
|
530
585
|
stems.map(async (stem) => {
|
|
531
|
-
const
|
|
586
|
+
const targets = readPlanTargets(
|
|
532
587
|
await readFile(join(dir, `${stem}.md`), 'utf8'),
|
|
533
588
|
)
|
|
534
|
-
const
|
|
535
|
-
|
|
589
|
+
const cites = targets.some(
|
|
590
|
+
(target) => resolveLivePlan(target, dir, root) === plan,
|
|
591
|
+
)
|
|
592
|
+
return cites ? stem : undefined
|
|
536
593
|
}),
|
|
537
594
|
)
|
|
538
595
|
|
|
@@ -542,10 +599,12 @@ export async function otherTasksCitingPlan(
|
|
|
542
599
|
/**
|
|
543
600
|
* Where a task's `Plan:` target resolves, which is what decides whether the
|
|
544
601
|
* plan is the sweep's to move. `unstated` is a task carrying no line at all,
|
|
545
|
-
* and it is distinct from a line resolving somewhere unexpected.
|
|
602
|
+
* and it is distinct from a line resolving somewhere unexpected. `several` is a
|
|
603
|
+
* line linking more than one plan, which answers no single location.
|
|
546
604
|
*/
|
|
547
605
|
export const CITATION_LOCATIONS = [
|
|
548
606
|
'unstated',
|
|
607
|
+
'several',
|
|
549
608
|
'live',
|
|
550
609
|
'archived',
|
|
551
610
|
'outside',
|
|
@@ -556,7 +615,10 @@ export type CitationLocation = (typeof CITATION_LOCATIONS)[number]
|
|
|
556
615
|
export interface PlanCitations {
|
|
557
616
|
readonly ok: true
|
|
558
617
|
readonly stem: string
|
|
618
|
+
/** The one target the line carries. Undefined when it carries none or several. */
|
|
559
619
|
readonly target: string | undefined
|
|
620
|
+
/** Every target the line carries, in line order. */
|
|
621
|
+
readonly targets: readonly string[]
|
|
560
622
|
readonly location: CitationLocation
|
|
561
623
|
/** Other live tasks landing on the same file. Empty unless `location` is `live`. */
|
|
562
624
|
readonly citedBy: readonly string[]
|
|
@@ -590,13 +652,17 @@ export async function planCitations(
|
|
|
590
652
|
return refuse(unmatched.reason, unmatched.message, unmatched.detail)
|
|
591
653
|
}
|
|
592
654
|
|
|
593
|
-
const
|
|
594
|
-
|
|
655
|
+
const targets = readPlanTargets(
|
|
656
|
+
await readFile(join(dir, `${stem}.md`), 'utf8'),
|
|
657
|
+
)
|
|
658
|
+
const [target] = targets
|
|
659
|
+
if (target === undefined || targets.length > 1) {
|
|
595
660
|
return {
|
|
596
661
|
ok: true,
|
|
597
662
|
stem,
|
|
598
663
|
target: undefined,
|
|
599
|
-
|
|
664
|
+
targets,
|
|
665
|
+
location: target === undefined ? 'unstated' : 'several',
|
|
600
666
|
citedBy: [],
|
|
601
667
|
}
|
|
602
668
|
}
|
|
@@ -611,13 +677,14 @@ export async function planCitations(
|
|
|
611
677
|
)
|
|
612
678
|
? 'archived'
|
|
613
679
|
: 'outside'
|
|
614
|
-
return { ok: true, stem, target, location, citedBy: [] }
|
|
680
|
+
return { ok: true, stem, target, targets, location, citedBy: [] }
|
|
615
681
|
}
|
|
616
682
|
|
|
617
683
|
return {
|
|
618
684
|
ok: true,
|
|
619
685
|
stem,
|
|
620
686
|
target,
|
|
687
|
+
targets,
|
|
621
688
|
location: 'live',
|
|
622
689
|
citedBy: await otherTasksCitingPlan(dir, root, live, stem),
|
|
623
690
|
}
|
|
@@ -810,37 +877,34 @@ export async function archiveTask(
|
|
|
810
877
|
)
|
|
811
878
|
}
|
|
812
879
|
|
|
813
|
-
const
|
|
814
|
-
const ready =
|
|
880
|
+
const plans = await plansToArchive(dir, root, stem, text)
|
|
881
|
+
const ready =
|
|
882
|
+
plans.moves.length > 0 &&
|
|
883
|
+
(await readyToArchive(dir, root, text, plans.moves))
|
|
815
884
|
const destination = archiveDir(root)
|
|
816
885
|
const to = join(destination, `${stem}.md`)
|
|
817
886
|
|
|
818
|
-
// The
|
|
819
|
-
//
|
|
820
|
-
// pointer at a folder holding nothing, and `.canon/plans/` is gitignored, so
|
|
821
|
-
// no history recovers the target it named. The folder follows the
|
|
822
|
-
//
|
|
887
|
+
// The plans move first so the line written below describes files already at
|
|
888
|
+
// their new paths. Writing the retarget first and failing a move would leave
|
|
889
|
+
// a pointer at a folder holding nothing, and `.canon/plans/` is gitignored, so
|
|
890
|
+
// no history recovers the target it named. The folder follows the plans and
|
|
891
|
+
// all precede the task, so a failed rename never leaves the task archived
|
|
823
892
|
// with its folder live, which the unattended post-merge hook cannot repair.
|
|
824
|
-
|
|
825
|
-
await mkdir(dirname(plan.to), { recursive: true })
|
|
826
|
-
await rename(plan.from, plan.to)
|
|
827
|
-
}
|
|
893
|
+
await movePlans(plans.moves)
|
|
828
894
|
|
|
829
895
|
if (ready) {
|
|
830
896
|
await mkdir(dirname(ready.to), { recursive: true })
|
|
831
897
|
await rename(ready.from, ready.to)
|
|
832
|
-
await
|
|
833
|
-
plan.to,
|
|
834
|
-
retargetReadyPaths(await readFile(plan.to, 'utf8'), basename(ready.from)),
|
|
835
|
-
)
|
|
898
|
+
await retargetReadyInPlans(plans.moves, basename(ready.from))
|
|
836
899
|
}
|
|
837
900
|
|
|
838
901
|
await mkdir(destination, { recursive: true })
|
|
839
902
|
await rename(from, to)
|
|
840
|
-
const
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
903
|
+
const retargeted = rebaseRelativeLinks(
|
|
904
|
+
retargetPlanLine(text, plans.links(dir)),
|
|
905
|
+
dir,
|
|
906
|
+
destination,
|
|
907
|
+
)
|
|
844
908
|
await writeFile(
|
|
845
909
|
to,
|
|
846
910
|
ready
|
|
@@ -858,43 +922,71 @@ export async function archiveTask(
|
|
|
858
922
|
to,
|
|
859
923
|
priorityRowRemoved,
|
|
860
924
|
indexRegenerated: regen.action === 'written',
|
|
861
|
-
|
|
925
|
+
plans: plans.moves,
|
|
862
926
|
ready: ready || undefined,
|
|
863
927
|
closed: closed.length,
|
|
864
928
|
cut: cut.length,
|
|
865
929
|
}
|
|
866
930
|
}
|
|
867
931
|
|
|
932
|
+
interface PlansToArchive {
|
|
933
|
+
readonly moves: readonly PlanMove[]
|
|
934
|
+
/** Each moved target as the line wrote it, mapped to its link from `taskDir`. */
|
|
935
|
+
readonly links: (taskDir: string) => ReadonlyMap<string, string>
|
|
936
|
+
}
|
|
937
|
+
|
|
868
938
|
/**
|
|
869
|
-
* The
|
|
870
|
-
* what settles a plan, and the hook reaches this
|
|
871
|
-
* move sits inside the archive rather than in a
|
|
872
|
-
* the task archived and
|
|
939
|
+
* The plans this task carries into the archive with it, one per live target on
|
|
940
|
+
* its `Plan:` line. The merge is what settles a plan, and the hook reaches this
|
|
941
|
+
* with nobody watching, so the move sits inside the archive rather than in a
|
|
942
|
+
* second call that could leave the task archived and a plan live.
|
|
873
943
|
*
|
|
874
944
|
* A plan another live task still cites stays where it is. Moving it on the
|
|
875
945
|
* first task to close strands every other pointer at a path that has gone, and
|
|
876
946
|
* the sibling has no history behind it to repair the line from.
|
|
877
947
|
*
|
|
878
|
-
* A target resolving to no file
|
|
948
|
+
* A target resolving to no file moves nothing too. A pointer somebody typed
|
|
879
949
|
* wrong is not a plan to move, and refusing the whole archive over it would
|
|
880
950
|
* park the board behind a repair the merge cannot make.
|
|
881
951
|
*/
|
|
882
|
-
async function
|
|
952
|
+
async function plansToArchive(
|
|
883
953
|
dir: string,
|
|
884
954
|
root: string,
|
|
885
955
|
stem: string,
|
|
886
956
|
text: string,
|
|
887
|
-
): Promise<
|
|
888
|
-
const
|
|
889
|
-
|
|
890
|
-
|
|
957
|
+
): Promise<PlansToArchive> {
|
|
958
|
+
const read = await Promise.all(
|
|
959
|
+
readPlanTargets(text).map(async (target) => {
|
|
960
|
+
const live = resolveLivePlan(target, dir, root)
|
|
961
|
+
if (!live || !existsSync(live)) return undefined
|
|
891
962
|
|
|
892
|
-
|
|
893
|
-
|
|
963
|
+
const shared = await otherTasksCitingPlan(dir, root, live, stem)
|
|
964
|
+
return shared.length > 0 ? undefined : { target, live }
|
|
965
|
+
}),
|
|
966
|
+
)
|
|
967
|
+
|
|
968
|
+
const moves = new Map<string, PlanMove>()
|
|
969
|
+
const targets = new Map<string, string>()
|
|
970
|
+
for (const entry of read) {
|
|
971
|
+
if (!entry) continue
|
|
972
|
+
const to = join(recordDir(root, PLANS, ARCHIVE), basename(entry.live))
|
|
973
|
+
moves.set(entry.live, { from: entry.live, to })
|
|
974
|
+
targets.set(entry.target, to)
|
|
975
|
+
}
|
|
894
976
|
|
|
895
977
|
return {
|
|
896
|
-
|
|
897
|
-
|
|
978
|
+
moves: [...moves.values()],
|
|
979
|
+
links: (taskDir) =>
|
|
980
|
+
new Map(
|
|
981
|
+
[...targets].map(([target, to]) => [target, linkTo(taskDir, to)]),
|
|
982
|
+
),
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
async function movePlans(moves: readonly PlanMove[]): Promise<void> {
|
|
987
|
+
for (const move of moves) {
|
|
988
|
+
await mkdir(dirname(move.to), { recursive: true })
|
|
989
|
+
await rename(move.from, move.to)
|
|
898
990
|
}
|
|
899
991
|
}
|
|
900
992
|
|
|
@@ -903,7 +995,8 @@ async function planToArchive(
|
|
|
903
995
|
* task's `Ready:` line, falling back to a folder path in the plan's own text for
|
|
904
996
|
* a task written before the line was defined. It moves only with its plan,
|
|
905
997
|
* since the folder is the plan's verbatim source and a plan another task still
|
|
906
|
-
* cites still needs it, so the caller passes the
|
|
998
|
+
* cites still needs it, so the caller passes the plans `plansToArchive`
|
|
999
|
+
* settled. Their texts are searched in line order and the first folder wins.
|
|
907
1000
|
*
|
|
908
1001
|
* A target naming no folder, one already archived, or a destination taken
|
|
909
1002
|
* yields nothing and refuses nothing, the way a mistyped `Plan:` does.
|
|
@@ -912,11 +1005,14 @@ async function readyToArchive(
|
|
|
912
1005
|
dir: string,
|
|
913
1006
|
root: string,
|
|
914
1007
|
text: string,
|
|
915
|
-
|
|
1008
|
+
plans: readonly PlanMove[],
|
|
916
1009
|
): Promise<ReadyMove | undefined> {
|
|
917
|
-
const
|
|
918
|
-
|
|
919
|
-
|
|
1010
|
+
const planTexts = await Promise.all(
|
|
1011
|
+
plans.map((plan) => readFile(plan.from, 'utf8')),
|
|
1012
|
+
)
|
|
1013
|
+
const live = [text, ...planTexts]
|
|
1014
|
+
.map((candidate) => readyFolder(dir, root, candidate))
|
|
1015
|
+
.find((folder) => folder !== undefined)
|
|
920
1016
|
if (!live) return undefined
|
|
921
1017
|
|
|
922
1018
|
const to = join(recordDir(root, READY, ARCHIVE), basename(live))
|
|
@@ -946,6 +1042,19 @@ function readyFolder(
|
|
|
946
1042
|
)
|
|
947
1043
|
}
|
|
948
1044
|
|
|
1045
|
+
async function retargetReadyInPlans(
|
|
1046
|
+
plans: readonly PlanMove[],
|
|
1047
|
+
folder: string,
|
|
1048
|
+
): Promise<void> {
|
|
1049
|
+
await Promise.all(
|
|
1050
|
+
plans.map(async (plan) => {
|
|
1051
|
+
const text = await readFile(plan.to, 'utf8')
|
|
1052
|
+
const retargeted = retargetReadyPaths(text, folder)
|
|
1053
|
+
if (retargeted !== text) await writeFile(plan.to, retargeted)
|
|
1054
|
+
}),
|
|
1055
|
+
)
|
|
1056
|
+
}
|
|
1057
|
+
|
|
949
1058
|
/** Points the plan's literal folder paths at `ready/archive/`, since the folder no longer sits where they say. */
|
|
950
1059
|
function retargetReadyPaths(text: string, folder: string): string {
|
|
951
1060
|
return text.replace(
|
|
@@ -1078,29 +1187,27 @@ export async function declineTask(
|
|
|
1078
1187
|
const from = join(dir, `${stem}.md`)
|
|
1079
1188
|
const text = await readFile(from, 'utf8')
|
|
1080
1189
|
|
|
1081
|
-
const
|
|
1190
|
+
const plans = await plansToArchive(dir, root, stem, text)
|
|
1082
1191
|
const destination = declinedDir(root)
|
|
1083
1192
|
const to = join(destination, `${stem}.md`)
|
|
1084
1193
|
|
|
1085
|
-
// The
|
|
1086
|
-
// written below describes
|
|
1087
|
-
|
|
1088
|
-
await mkdir(dirname(plan.to), { recursive: true })
|
|
1089
|
-
await rename(plan.from, plan.to)
|
|
1090
|
-
}
|
|
1194
|
+
// The plans move first, the same order archiveTask uses, so the retarget
|
|
1195
|
+
// written below describes files already at their new paths.
|
|
1196
|
+
await movePlans(plans.moves)
|
|
1091
1197
|
|
|
1092
1198
|
await mkdir(destination, { recursive: true })
|
|
1093
1199
|
await rename(from, to)
|
|
1094
1200
|
|
|
1095
1201
|
const date = new Date().toISOString().slice(0, 10)
|
|
1096
1202
|
const declined = insertDeclinedLine(
|
|
1097
|
-
rebaseRelativeLinks(
|
|
1203
|
+
rebaseRelativeLinks(
|
|
1204
|
+
retargetPlanLine(text, plans.links(dir)),
|
|
1205
|
+
dir,
|
|
1206
|
+
destination,
|
|
1207
|
+
),
|
|
1098
1208
|
declineLine(reason, by, date),
|
|
1099
1209
|
)
|
|
1100
|
-
|
|
1101
|
-
? retargetPlanLine(declined, linkTo(destination, plan.to))
|
|
1102
|
-
: declined
|
|
1103
|
-
await writeFile(to, final)
|
|
1210
|
+
await writeFile(to, declined)
|
|
1104
1211
|
|
|
1105
1212
|
const priorityRowRemoved = await clearPriorityRow(dir, stem)
|
|
1106
1213
|
const backlogRowRemoved = await clearBacklogRow(dir, stem)
|
|
@@ -1114,6 +1221,6 @@ export async function declineTask(
|
|
|
1114
1221
|
priorityRowRemoved,
|
|
1115
1222
|
backlogRowRemoved,
|
|
1116
1223
|
indexRegenerated: regen.action === 'written',
|
|
1117
|
-
|
|
1224
|
+
plans: plans.moves,
|
|
1118
1225
|
}
|
|
1119
1226
|
}
|
package/src/tasks/reach.ts
CHANGED
|
@@ -11,7 +11,7 @@ const PLANS = 'plans'
|
|
|
11
11
|
const MARKDOWN = '.md'
|
|
12
12
|
const NONE_IDENTIFIED = 'None identified.'
|
|
13
13
|
|
|
14
|
-
/** The one group a dispatch reads, per `standards/
|
|
14
|
+
/** The one group a dispatch reads, per `standards/board.md`. */
|
|
15
15
|
const DISPATCH_GROUP = 'Run now'
|
|
16
16
|
|
|
17
17
|
export const REACH_REFUSALS = [
|
package/src/tasks/record.ts
CHANGED
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
listTaskStems,
|
|
10
10
|
OUTCOME_PATTERN,
|
|
11
11
|
planLine,
|
|
12
|
-
|
|
12
|
+
planLineIndex,
|
|
13
|
+
readPlanTargets,
|
|
13
14
|
readPullRequest,
|
|
14
15
|
tasksDir,
|
|
15
16
|
} from '@/tasks/archive'
|
|
@@ -26,6 +27,9 @@ const ORIGIN_PREFIXES = ['Plan:', 'Groundwork:', 'Intake:', 'Issue:'] as const
|
|
|
26
27
|
* a guessed write would archive the wrong task. A caller that passed a
|
|
27
28
|
* malformed argument has a defect nobody would otherwise hear about, so it
|
|
28
29
|
* reports rather than joining the swallowed set.
|
|
30
|
+
*
|
|
31
|
+
* `several-plans` answers a `Plan:` line that already links more than one plan.
|
|
32
|
+
* `plan-link` cannot know which link to drop, so it writes nothing.
|
|
29
33
|
*/
|
|
30
34
|
export const RECORD_REFUSALS = [
|
|
31
35
|
'no-board',
|
|
@@ -34,6 +38,7 @@ export const RECORD_REFUSALS = [
|
|
|
34
38
|
'no-outcomes',
|
|
35
39
|
'out-of-range',
|
|
36
40
|
'no-plan',
|
|
41
|
+
'several-plans',
|
|
37
42
|
'bad-input',
|
|
38
43
|
] as const
|
|
39
44
|
|
|
@@ -68,6 +73,8 @@ export interface PlanRecorded {
|
|
|
68
73
|
readonly path: string
|
|
69
74
|
readonly plan: string
|
|
70
75
|
readonly action: LineAction
|
|
76
|
+
/** The target the line named before a correction. Undefined unless it differed. */
|
|
77
|
+
readonly replaced: string | undefined
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
export type PlanOutcome = PlanRecorded | RecordRefused
|
|
@@ -149,31 +156,60 @@ function lastOriginLine(lines: readonly string[]): number | undefined {
|
|
|
149
156
|
return found
|
|
150
157
|
}
|
|
151
158
|
|
|
159
|
+
export type PlanLineWrite =
|
|
160
|
+
| {
|
|
161
|
+
readonly ok: true
|
|
162
|
+
readonly text: string
|
|
163
|
+
readonly action: LineAction
|
|
164
|
+
readonly replaced: string | undefined
|
|
165
|
+
}
|
|
166
|
+
| { readonly ok: false; readonly targets: readonly string[] }
|
|
167
|
+
|
|
152
168
|
/**
|
|
153
169
|
* Places the `Plan:` line right after the H1, and corrects the target in place
|
|
154
170
|
* when the line exists, since a task runs under one plan. `Plan:` is the first
|
|
155
171
|
* origin line a task carries, so it anchors on the heading itself rather than
|
|
156
172
|
* on the last origin line above it.
|
|
173
|
+
*
|
|
174
|
+
* A line already linking several plans is refused whole, even when one of them
|
|
175
|
+
* is the target, since overwriting it drops links the caller never named and
|
|
176
|
+
* reporting it unchanged hides a line that breaks the one-plan rule.
|
|
157
177
|
*/
|
|
158
|
-
export function writePlanLine(
|
|
159
|
-
text: string,
|
|
160
|
-
target: string,
|
|
161
|
-
): { readonly text: string; readonly action: LineAction } {
|
|
178
|
+
export function writePlanLine(text: string, target: string): PlanLineWrite {
|
|
162
179
|
const line = planLine(target)
|
|
163
180
|
const lines = text.split('\n')
|
|
164
|
-
const existing = lines
|
|
181
|
+
const existing = planLineIndex(lines)
|
|
165
182
|
|
|
166
183
|
if (existing !== -1) {
|
|
167
|
-
|
|
184
|
+
const targets = readPlanTargets(lines[existing])
|
|
185
|
+
if (targets.length > 1) return { ok: false, targets }
|
|
186
|
+
|
|
187
|
+
const [previous] = targets
|
|
188
|
+
const replaced = previous !== target ? previous : undefined
|
|
189
|
+
if (lines[existing] === line) {
|
|
190
|
+
return { ok: true, text, action: 'unchanged', replaced }
|
|
191
|
+
}
|
|
168
192
|
lines[existing] = line
|
|
169
|
-
return { text: lines.join('\n'), action: 'corrected' }
|
|
193
|
+
return { ok: true, text: lines.join('\n'), action: 'corrected', replaced }
|
|
170
194
|
}
|
|
171
195
|
|
|
172
196
|
const heading = lines.findIndex((entry) => entry.startsWith('# '))
|
|
173
|
-
if (heading === -1)
|
|
197
|
+
if (heading === -1) {
|
|
198
|
+
return {
|
|
199
|
+
ok: true,
|
|
200
|
+
text: `${line}\n${text}`,
|
|
201
|
+
action: 'added',
|
|
202
|
+
replaced: undefined,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
174
205
|
|
|
175
206
|
lines.splice(heading + 1, 0, '', line)
|
|
176
|
-
return {
|
|
207
|
+
return {
|
|
208
|
+
ok: true,
|
|
209
|
+
text: lines.join('\n'),
|
|
210
|
+
action: 'added',
|
|
211
|
+
replaced: undefined,
|
|
212
|
+
}
|
|
177
213
|
}
|
|
178
214
|
|
|
179
215
|
/**
|
|
@@ -230,14 +266,12 @@ async function matchByPlan(
|
|
|
230
266
|
const read = await Promise.all(
|
|
231
267
|
stems.map(async (stem) => ({
|
|
232
268
|
stem,
|
|
233
|
-
|
|
269
|
+
targets: readPlanTargets(await readFile(join(dir, `${stem}.md`), 'utf8')),
|
|
234
270
|
})),
|
|
235
271
|
)
|
|
236
272
|
|
|
237
273
|
return read
|
|
238
|
-
.filter(
|
|
239
|
-
(entry) => entry.target !== undefined && planKey(entry.target) === key,
|
|
240
|
-
)
|
|
274
|
+
.filter((entry) => entry.targets.some((target) => planKey(target) === key))
|
|
241
275
|
.map(({ stem }) => stem)
|
|
242
276
|
}
|
|
243
277
|
|
|
@@ -345,11 +379,20 @@ export async function recordPlan(
|
|
|
345
379
|
}
|
|
346
380
|
|
|
347
381
|
const target = linkTo(dir, plan)
|
|
348
|
-
const
|
|
382
|
+
const written = writePlanLine(await readFile(path, 'utf8'), target)
|
|
383
|
+
|
|
384
|
+
if (!written.ok) {
|
|
385
|
+
return refuse(
|
|
386
|
+
'several-plans',
|
|
387
|
+
`${opened.stem} already links ${written.targets.length} plans on its Plan: line, so plan-link cannot tell which one to replace. Edit the line by hand to name one plan.`,
|
|
388
|
+
written.targets,
|
|
389
|
+
)
|
|
390
|
+
}
|
|
349
391
|
|
|
392
|
+
const { text, action, replaced } = written
|
|
350
393
|
if (action !== 'unchanged') await writeFile(path, text)
|
|
351
394
|
|
|
352
|
-
return { ok: true, stem: opened.stem, path, plan: target, action }
|
|
395
|
+
return { ok: true, stem: opened.stem, path, plan: target, action, replaced }
|
|
353
396
|
}
|
|
354
397
|
|
|
355
398
|
/**
|