@erclx/canon 4.121.0 → 4.123.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 +6 -5
- package/claude/skills/auto-ship/references/tier-failures.md +7 -4
- package/claude/skills/backlog-triage/REQUIREMENT.md +47 -0
- package/claude/skills/backlog-triage/SKILL.md +139 -0
- package/claude/skills/canon-rollout/SKILL.md +6 -6
- package/claude/skills/context-fold/REQUIREMENT.md +75 -0
- package/claude/skills/context-fold/SKILL.md +162 -0
- package/claude/skills/{docs-fold → context-fold}/references/anchor-sweep.md +1 -1
- package/claude/skills/{docs-fold → context-fold}/references/classify.md +4 -2
- package/claude/skills/context-fold/references/context-refresh.md +48 -0
- package/claude/skills/context-fold/references/promoted-pages.md +29 -0
- package/claude/skills/context-fold/references/receipt-sweep.md +43 -0
- package/claude/skills/context-fold/references/unusable-baseline.md +25 -0
- package/claude/skills/{docs-fold → context-fold}/references/wireframe-sweep.md +1 -1
- package/claude/skills/docs-fold/REQUIREMENT.md +8 -58
- package/claude/skills/docs-fold/SKILL.md +3 -278
- package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
- package/claude/skills/docs-sync/SKILL.md +1 -1
- package/claude/skills/draft-context/REQUIREMENT.md +2 -2
- package/claude/skills/draft-context/SKILL.md +3 -3
- package/claude/skills/draft-docs/REQUIREMENT.md +1 -1
- package/claude/skills/draft-ready/SKILL.md +1 -1
- package/claude/skills/draft-wireframes/REQUIREMENT.md +1 -1
- package/claude/skills/draft-wireframes/SKILL.md +2 -2
- package/claude/skills/git-pr/SKILL.md +8 -146
- package/claude/skills/git-pr/references/evidence.md +114 -0
- package/claude/skills/git-pr/references/labels.md +20 -0
- package/claude/skills/git-pr/references/task-number.md +26 -0
- package/claude/skills/git-ship/SKILL.md +5 -4
- package/claude/skills/memory-capture/REQUIREMENT.md +2 -2
- package/claude/skills/memory-capture/SKILL.md +19 -9
- package/claude/skills/memory-review/REQUIREMENT.md +5 -3
- package/claude/skills/memory-review/SKILL.md +22 -107
- package/claude/skills/memory-review/references/later-phases.md +111 -0
- package/claude/skills/memory-review/references/receipt-format.md +19 -3
- package/claude/skills/plan-feature/REQUIREMENT.md +1 -1
- package/claude/skills/review-address/REQUIREMENT.md +1 -1
- package/claude/skills/review-address/SKILL.md +1 -1
- package/claude/skills/review-branch/SKILL.md +1 -1
- package/claude/skills/role-orchestrator/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +4 -2
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +2 -2
- package/claude/skills/session-resume/REQUIREMENT.md +1 -1
- package/claude/skills/session-resume/SKILL.md +4 -3
- package/claude/skills/session-worktree/SKILL.md +4 -10
- package/claude/skills/task-board/REQUIREMENT.md +1 -1
- package/claude/skills/task-board/SKILL.md +7 -81
- package/claude/skills/task-board/references/archive.md +54 -0
- package/claude/skills/task-board/references/decline.md +40 -0
- package/claude/skills/teach-workspace/SKILL.md +8 -105
- package/claude/skills/teach-workspace/references/lesson-craft.md +51 -1
- package/claude/skills/teach-workspace/references/promotion.md +41 -0
- package/docs/agents/architecture-record.md +26 -0
- package/docs/agents/commands.md +87 -154
- package/docs/agents/context-audit-checks.md +2 -20
- package/docs/agents/context-audit.md +1 -1
- package/docs/agents/context-classify.md +1 -1
- package/docs/agents/drift-surfaces.md +192 -0
- package/docs/agents/index.md +12 -4
- package/docs/agents/install-and-sync.md +2 -181
- package/docs/agents/markdown-audit-weight.md +73 -0
- package/docs/agents/markdown-audit.md +3 -90
- package/docs/agents/migrate.md +34 -0
- package/docs/agents/records-stale.md +62 -0
- package/docs/agents/records.md +5 -1
- package/docs/agents/sandbox.md +1 -1
- package/docs/agents/tasks-archive.md +81 -0
- package/docs/agents/tasks-plans.md +149 -0
- package/docs/agents/tasks-validate.md +131 -0
- package/docs/agents/tasks.md +7 -334
- package/docs/target-projects.md +3 -3
- package/docs/workflow/ai-workflow.md +34 -29
- package/docs/workflow/operating-model.md +1 -1
- package/governance/rules/claude/555-tasks.md +2 -1
- package/governance/rules/core/045-memory.md +1 -1
- package/package.json +1 -1
- package/src/claude/cases/setup.ts +1 -1
- package/src/claude/cases/workflow.ts +22 -1
- package/src/commands/records.ts +146 -0
- package/src/commands/sandbox.ts +6 -4
- package/src/commands/tasks.ts +39 -29
- package/src/context/classify/extract.ts +1 -1
- package/src/context/classify/ollama.ts +1 -1
- package/src/context/classify/run.ts +1 -1
- package/src/gate/measures.ts +1 -1
- package/src/gate/stages.ts +1 -0
- package/src/migrate/skill-names.ts +15 -2
- package/src/records/stale.ts +243 -0
- package/src/sandbox/expect.ts +1 -1
- package/src/tasks/archive.ts +223 -92
- package/src/tasks/reach.ts +1 -1
- package/src/tasks/record.ts +60 -17
- package/src/tasks/validate.ts +37 -4
- 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/memory.md +13 -1
- package/standards/plan.md +20 -34
- package/standards/readme.md +8 -77
- package/standards/requirements.md +2 -2
- package/standards/tasks.md +19 -126
- package/standards/versioning.md +1 -1
- package/tooling/astro/reference.md +1 -1
- package/tooling/base/configs/.husky/post-merge +1 -1
- package/tooling/base/configs/commitlint.config.js +27 -1
- package/tooling/base/reference.md +1 -1
- package/tooling/claude/reference.md +1 -1
- package/tooling/go/reference.md +1 -1
- package/tooling/nextjs/reference.md +1 -1
- package/tooling/php/reference.md +1 -1
- package/tooling/python/reference.md +1 -1
- package/tooling/vite-react/reference.md +1 -1
- package/tooling/web/reference.md +1 -1
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,22 +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.
|
|
282
|
+
*/
|
|
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)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Reads every target the first `Plan:` line outside a fenced sample carries.
|
|
308
|
+
* The path is returned as written, relative to the board.
|
|
309
|
+
*/
|
|
310
|
+
export function readPlanTargets(text: string): readonly string[] {
|
|
311
|
+
const lines = text.split('\n')
|
|
312
|
+
const index = planLineIndex(lines)
|
|
313
|
+
if (index === -1) return []
|
|
314
|
+
|
|
315
|
+
return planTokens(lines[index]).map(({ target }) => target)
|
|
316
|
+
}
|
|
317
|
+
|
|
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.
|
|
265
322
|
*/
|
|
266
323
|
export function readPlanTarget(text: string): string | undefined {
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
return match[1] ?? match[2]
|
|
324
|
+
const targets = readPlanTargets(text)
|
|
325
|
+
return targets.length === 1 ? targets[0] : undefined
|
|
270
326
|
}
|
|
271
327
|
|
|
272
328
|
/**
|
|
@@ -276,28 +332,50 @@ export function readPlanTarget(text: string): string | undefined {
|
|
|
276
332
|
* one line shape rather than two.
|
|
277
333
|
*/
|
|
278
334
|
export function planLine(target: string): string {
|
|
335
|
+
return `Plan: ${planLink(target)}`
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function planLink(target: string): string {
|
|
279
339
|
const name = basename(target)
|
|
280
340
|
const label = name.endsWith('.md') ? name.slice(0, -'.md'.length) : name
|
|
281
341
|
|
|
282
|
-
return `
|
|
342
|
+
return `[${label}](${target})`
|
|
283
343
|
}
|
|
284
344
|
|
|
285
345
|
/**
|
|
286
|
-
* Points the task's `Plan:` line at the plan's new home
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
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.
|
|
290
350
|
*
|
|
291
|
-
* The
|
|
292
|
-
* because `$&` and its siblings are substitution sequences inside
|
|
293
|
-
*
|
|
294
|
-
*
|
|
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.
|
|
295
355
|
*/
|
|
296
|
-
export function retargetPlanLine(
|
|
297
|
-
|
|
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')
|
|
298
376
|
}
|
|
299
377
|
|
|
300
|
-
/** The `Ready:` line in either form,
|
|
378
|
+
/** The `Ready:` line in either form, the link's target captured ahead of the bare path. */
|
|
301
379
|
const READY_PATTERN = /^Ready:[ \t]*(?:\[[^\]]*\]\(([^)]+)\)|(\S+))[ \t]*$/m
|
|
302
380
|
|
|
303
381
|
/** A ready folder path as a plan's constraints write it, the folder name captured. */
|
|
@@ -431,7 +509,7 @@ function isBulletFor(line: string, target: string): boolean {
|
|
|
431
509
|
|
|
432
510
|
/**
|
|
433
511
|
* Resolves the `Plan:` target against the board and against the project root
|
|
434
|
-
* both, which is how `
|
|
512
|
+
* both, which is how `context-fold` reads the same line. It accepts `../plans/x.md`
|
|
435
513
|
* and `.canon/plans/x.md` as one file, so a gate reading only the first form
|
|
436
514
|
* would pass the second and strand the plan this exists to protect.
|
|
437
515
|
*
|
|
@@ -484,8 +562,9 @@ function atOneRoot(path: string, plans: string[], root: string): string {
|
|
|
484
562
|
}
|
|
485
563
|
|
|
486
564
|
/**
|
|
487
|
-
* Names the other live tasks whose `Plan:` line lands on the same file
|
|
488
|
-
*
|
|
565
|
+
* Names the other live tasks whose `Plan:` line lands on the same file through
|
|
566
|
+
* any of its links. This is
|
|
567
|
+
* the rule `context-fold` applies before it archives a plan, held here so one
|
|
489
568
|
* question has one implementation: a plan another live task still cites is a
|
|
490
569
|
* plan the sweep is correct to leave, and a guard that read the folder instead
|
|
491
570
|
* refused every task sharing one plan and deadlocked the board against the
|
|
@@ -504,11 +583,13 @@ export async function otherTasksCitingPlan(
|
|
|
504
583
|
|
|
505
584
|
const read = await Promise.all(
|
|
506
585
|
stems.map(async (stem) => {
|
|
507
|
-
const
|
|
586
|
+
const targets = readPlanTargets(
|
|
508
587
|
await readFile(join(dir, `${stem}.md`), 'utf8'),
|
|
509
588
|
)
|
|
510
|
-
const
|
|
511
|
-
|
|
589
|
+
const cites = targets.some(
|
|
590
|
+
(target) => resolveLivePlan(target, dir, root) === plan,
|
|
591
|
+
)
|
|
592
|
+
return cites ? stem : undefined
|
|
512
593
|
}),
|
|
513
594
|
)
|
|
514
595
|
|
|
@@ -518,10 +599,12 @@ export async function otherTasksCitingPlan(
|
|
|
518
599
|
/**
|
|
519
600
|
* Where a task's `Plan:` target resolves, which is what decides whether the
|
|
520
601
|
* plan is the sweep's to move. `unstated` is a task carrying no line at all,
|
|
521
|
-
* 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.
|
|
522
604
|
*/
|
|
523
605
|
export const CITATION_LOCATIONS = [
|
|
524
606
|
'unstated',
|
|
607
|
+
'several',
|
|
525
608
|
'live',
|
|
526
609
|
'archived',
|
|
527
610
|
'outside',
|
|
@@ -532,7 +615,10 @@ export type CitationLocation = (typeof CITATION_LOCATIONS)[number]
|
|
|
532
615
|
export interface PlanCitations {
|
|
533
616
|
readonly ok: true
|
|
534
617
|
readonly stem: string
|
|
618
|
+
/** The one target the line carries. Undefined when it carries none or several. */
|
|
535
619
|
readonly target: string | undefined
|
|
620
|
+
/** Every target the line carries, in line order. */
|
|
621
|
+
readonly targets: readonly string[]
|
|
536
622
|
readonly location: CitationLocation
|
|
537
623
|
/** Other live tasks landing on the same file. Empty unless `location` is `live`. */
|
|
538
624
|
readonly citedBy: readonly string[]
|
|
@@ -542,7 +628,7 @@ export type CitationOutcome = PlanCitations | ArchiveRefused
|
|
|
542
628
|
|
|
543
629
|
/**
|
|
544
630
|
* Answers where one task's plan sits and who else holds it, which is the whole
|
|
545
|
-
* of the last-live-citation rule. `
|
|
631
|
+
* of the last-live-citation rule. `context-fold` reads this rather than scanning
|
|
546
632
|
* the board itself, so the sweep that moves a plan and the gate that refuses a
|
|
547
633
|
* task archive cannot drift into disagreeing about which plan is free.
|
|
548
634
|
*
|
|
@@ -566,13 +652,17 @@ export async function planCitations(
|
|
|
566
652
|
return refuse(unmatched.reason, unmatched.message, unmatched.detail)
|
|
567
653
|
}
|
|
568
654
|
|
|
569
|
-
const
|
|
570
|
-
|
|
655
|
+
const targets = readPlanTargets(
|
|
656
|
+
await readFile(join(dir, `${stem}.md`), 'utf8'),
|
|
657
|
+
)
|
|
658
|
+
const [target] = targets
|
|
659
|
+
if (target === undefined || targets.length > 1) {
|
|
571
660
|
return {
|
|
572
661
|
ok: true,
|
|
573
662
|
stem,
|
|
574
663
|
target: undefined,
|
|
575
|
-
|
|
664
|
+
targets,
|
|
665
|
+
location: target === undefined ? 'unstated' : 'several',
|
|
576
666
|
citedBy: [],
|
|
577
667
|
}
|
|
578
668
|
}
|
|
@@ -587,13 +677,14 @@ export async function planCitations(
|
|
|
587
677
|
)
|
|
588
678
|
? 'archived'
|
|
589
679
|
: 'outside'
|
|
590
|
-
return { ok: true, stem, target, location, citedBy: [] }
|
|
680
|
+
return { ok: true, stem, target, targets, location, citedBy: [] }
|
|
591
681
|
}
|
|
592
682
|
|
|
593
683
|
return {
|
|
594
684
|
ok: true,
|
|
595
685
|
stem,
|
|
596
686
|
target,
|
|
687
|
+
targets,
|
|
597
688
|
location: 'live',
|
|
598
689
|
citedBy: await otherTasksCitingPlan(dir, root, live, stem),
|
|
599
690
|
}
|
|
@@ -758,7 +849,7 @@ export async function archiveTask(
|
|
|
758
849
|
const from = join(dir, `${stem}.md`)
|
|
759
850
|
const text = await readFile(from, 'utf8')
|
|
760
851
|
|
|
761
|
-
// `
|
|
852
|
+
// `context-fold` ticks outcomes at ship time, before the merge, so an earlier
|
|
762
853
|
// slice merging after the last slice shipped would find every box ticked
|
|
763
854
|
// while the last slice is still open. Only the last number closes the task.
|
|
764
855
|
const last = readPullRequest(text).at(-1)
|
|
@@ -786,37 +877,34 @@ export async function archiveTask(
|
|
|
786
877
|
)
|
|
787
878
|
}
|
|
788
879
|
|
|
789
|
-
const
|
|
790
|
-
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))
|
|
791
884
|
const destination = archiveDir(root)
|
|
792
885
|
const to = join(destination, `${stem}.md`)
|
|
793
886
|
|
|
794
|
-
// The
|
|
795
|
-
//
|
|
796
|
-
// pointer at a folder holding nothing, and `.canon/plans/` is gitignored, so
|
|
797
|
-
// no history recovers the target it named. The folder follows the
|
|
798
|
-
//
|
|
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
|
|
799
892
|
// with its folder live, which the unattended post-merge hook cannot repair.
|
|
800
|
-
|
|
801
|
-
await mkdir(dirname(plan.to), { recursive: true })
|
|
802
|
-
await rename(plan.from, plan.to)
|
|
803
|
-
}
|
|
893
|
+
await movePlans(plans.moves)
|
|
804
894
|
|
|
805
895
|
if (ready) {
|
|
806
896
|
await mkdir(dirname(ready.to), { recursive: true })
|
|
807
897
|
await rename(ready.from, ready.to)
|
|
808
|
-
await
|
|
809
|
-
plan.to,
|
|
810
|
-
retargetReadyPaths(await readFile(plan.to, 'utf8'), basename(ready.from)),
|
|
811
|
-
)
|
|
898
|
+
await retargetReadyInPlans(plans.moves, basename(ready.from))
|
|
812
899
|
}
|
|
813
900
|
|
|
814
901
|
await mkdir(destination, { recursive: true })
|
|
815
902
|
await rename(from, to)
|
|
816
|
-
const
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
903
|
+
const retargeted = rebaseRelativeLinks(
|
|
904
|
+
retargetPlanLine(text, plans.links(dir)),
|
|
905
|
+
dir,
|
|
906
|
+
destination,
|
|
907
|
+
)
|
|
820
908
|
await writeFile(
|
|
821
909
|
to,
|
|
822
910
|
ready
|
|
@@ -834,43 +922,71 @@ export async function archiveTask(
|
|
|
834
922
|
to,
|
|
835
923
|
priorityRowRemoved,
|
|
836
924
|
indexRegenerated: regen.action === 'written',
|
|
837
|
-
|
|
925
|
+
plans: plans.moves,
|
|
838
926
|
ready: ready || undefined,
|
|
839
927
|
closed: closed.length,
|
|
840
928
|
cut: cut.length,
|
|
841
929
|
}
|
|
842
930
|
}
|
|
843
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
|
+
|
|
844
938
|
/**
|
|
845
|
-
* The
|
|
846
|
-
* what settles a plan, and the hook reaches this
|
|
847
|
-
* move sits inside the archive rather than in a
|
|
848
|
-
* 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.
|
|
849
943
|
*
|
|
850
944
|
* A plan another live task still cites stays where it is. Moving it on the
|
|
851
945
|
* first task to close strands every other pointer at a path that has gone, and
|
|
852
946
|
* the sibling has no history behind it to repair the line from.
|
|
853
947
|
*
|
|
854
|
-
* A target resolving to no file
|
|
948
|
+
* A target resolving to no file moves nothing too. A pointer somebody typed
|
|
855
949
|
* wrong is not a plan to move, and refusing the whole archive over it would
|
|
856
950
|
* park the board behind a repair the merge cannot make.
|
|
857
951
|
*/
|
|
858
|
-
async function
|
|
952
|
+
async function plansToArchive(
|
|
859
953
|
dir: string,
|
|
860
954
|
root: string,
|
|
861
955
|
stem: string,
|
|
862
956
|
text: string,
|
|
863
|
-
): Promise<
|
|
864
|
-
const
|
|
865
|
-
|
|
866
|
-
|
|
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
|
|
962
|
+
|
|
963
|
+
const shared = await otherTasksCitingPlan(dir, root, live, stem)
|
|
964
|
+
return shared.length > 0 ? undefined : { target, live }
|
|
965
|
+
}),
|
|
966
|
+
)
|
|
867
967
|
|
|
868
|
-
const
|
|
869
|
-
|
|
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
|
+
}
|
|
870
976
|
|
|
871
977
|
return {
|
|
872
|
-
|
|
873
|
-
|
|
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)
|
|
874
990
|
}
|
|
875
991
|
}
|
|
876
992
|
|
|
@@ -879,7 +995,8 @@ async function planToArchive(
|
|
|
879
995
|
* task's `Ready:` line, falling back to a folder path in the plan's own text for
|
|
880
996
|
* a task written before the line was defined. It moves only with its plan,
|
|
881
997
|
* since the folder is the plan's verbatim source and a plan another task still
|
|
882
|
-
* 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.
|
|
883
1000
|
*
|
|
884
1001
|
* A target naming no folder, one already archived, or a destination taken
|
|
885
1002
|
* yields nothing and refuses nothing, the way a mistyped `Plan:` does.
|
|
@@ -888,11 +1005,14 @@ async function readyToArchive(
|
|
|
888
1005
|
dir: string,
|
|
889
1006
|
root: string,
|
|
890
1007
|
text: string,
|
|
891
|
-
|
|
1008
|
+
plans: readonly PlanMove[],
|
|
892
1009
|
): Promise<ReadyMove | undefined> {
|
|
893
|
-
const
|
|
894
|
-
|
|
895
|
-
|
|
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)
|
|
896
1016
|
if (!live) return undefined
|
|
897
1017
|
|
|
898
1018
|
const to = join(recordDir(root, READY, ARCHIVE), basename(live))
|
|
@@ -922,6 +1042,19 @@ function readyFolder(
|
|
|
922
1042
|
)
|
|
923
1043
|
}
|
|
924
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
|
+
|
|
925
1058
|
/** Points the plan's literal folder paths at `ready/archive/`, since the folder no longer sits where they say. */
|
|
926
1059
|
function retargetReadyPaths(text: string, folder: string): string {
|
|
927
1060
|
return text.replace(
|
|
@@ -1054,29 +1187,27 @@ export async function declineTask(
|
|
|
1054
1187
|
const from = join(dir, `${stem}.md`)
|
|
1055
1188
|
const text = await readFile(from, 'utf8')
|
|
1056
1189
|
|
|
1057
|
-
const
|
|
1190
|
+
const plans = await plansToArchive(dir, root, stem, text)
|
|
1058
1191
|
const destination = declinedDir(root)
|
|
1059
1192
|
const to = join(destination, `${stem}.md`)
|
|
1060
1193
|
|
|
1061
|
-
// The
|
|
1062
|
-
// written below describes
|
|
1063
|
-
|
|
1064
|
-
await mkdir(dirname(plan.to), { recursive: true })
|
|
1065
|
-
await rename(plan.from, plan.to)
|
|
1066
|
-
}
|
|
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)
|
|
1067
1197
|
|
|
1068
1198
|
await mkdir(destination, { recursive: true })
|
|
1069
1199
|
await rename(from, to)
|
|
1070
1200
|
|
|
1071
1201
|
const date = new Date().toISOString().slice(0, 10)
|
|
1072
1202
|
const declined = insertDeclinedLine(
|
|
1073
|
-
rebaseRelativeLinks(
|
|
1203
|
+
rebaseRelativeLinks(
|
|
1204
|
+
retargetPlanLine(text, plans.links(dir)),
|
|
1205
|
+
dir,
|
|
1206
|
+
destination,
|
|
1207
|
+
),
|
|
1074
1208
|
declineLine(reason, by, date),
|
|
1075
1209
|
)
|
|
1076
|
-
|
|
1077
|
-
? retargetPlanLine(declined, linkTo(destination, plan.to))
|
|
1078
|
-
: declined
|
|
1079
|
-
await writeFile(to, final)
|
|
1210
|
+
await writeFile(to, declined)
|
|
1080
1211
|
|
|
1081
1212
|
const priorityRowRemoved = await clearPriorityRow(dir, stem)
|
|
1082
1213
|
const backlogRowRemoved = await clearBacklogRow(dir, stem)
|
|
@@ -1090,6 +1221,6 @@ export async function declineTask(
|
|
|
1090
1221
|
priorityRowRemoved,
|
|
1091
1222
|
backlogRowRemoved,
|
|
1092
1223
|
indexRegenerated: regen.action === 'written',
|
|
1093
|
-
|
|
1224
|
+
plans: plans.moves,
|
|
1094
1225
|
}
|
|
1095
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 = [
|