@erclx/canon 4.122.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 +5 -4
- package/claude/skills/auto-ship/references/tier-failures.md +7 -4
- package/claude/skills/backlog-triage/SKILL.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 +17 -6
- package/claude/skills/memory-review/references/later-phases.md +6 -3
- 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/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/workflow/ai-workflow.md +2 -2
- package/docs/workflow/operating-model.md +1 -1
- package/governance/rules/claude/555-tasks.md +2 -1
- package/package.json +1 -1
- package/src/commands/tasks.ts +39 -29
- package/src/gate/stages.ts +1 -0
- 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/tasks.md +18 -125
- package/standards/versioning.md +1 -1
- package/tooling/base/configs/commitlint.config.js +27 -1
- package/tooling/base/reference.md +1 -1
package/src/commands/tasks.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type DeclineOutcome,
|
|
12
12
|
declineTask,
|
|
13
13
|
type PlanCitations,
|
|
14
|
+
type PlanMove,
|
|
14
15
|
planCitations,
|
|
15
16
|
} from '@/tasks/archive'
|
|
16
17
|
import {
|
|
@@ -154,9 +155,9 @@ export function register(program: Command): void {
|
|
|
154
155
|
'after',
|
|
155
156
|
[
|
|
156
157
|
'',
|
|
157
|
-
'The task carries
|
|
158
|
-
'plan, and the archived task keeps a working
|
|
159
|
-
'path. A plan several tasks share stays where it is.',
|
|
158
|
+
'The task carries each plan its Plan: line links when no other live',
|
|
159
|
+
'task cites that plan, and the archived task keeps a working pointer',
|
|
160
|
+
'at each new path. A plan several tasks share stays where it is.',
|
|
160
161
|
'',
|
|
161
162
|
'The ready folder the task names moves to .canon/ready/archive/ with its',
|
|
162
163
|
'plan, and the Ready: line and the plan path to it are retargeted. A',
|
|
@@ -260,6 +261,7 @@ export function register(program: Command): void {
|
|
|
260
261
|
'',
|
|
261
262
|
'Locations:',
|
|
262
263
|
' unstated the task carries no Plan: line',
|
|
264
|
+
' several the Plan: line links more than one plan, named in targets',
|
|
263
265
|
' live the target resolves inside .canon/plans/',
|
|
264
266
|
' archived the target resolves inside .canon/plans/archive/',
|
|
265
267
|
' outside the target resolves somewhere else',
|
|
@@ -448,12 +450,14 @@ export function register(program: Command): void {
|
|
|
448
450
|
'',
|
|
449
451
|
'Exit codes:',
|
|
450
452
|
' 0 the line was added, corrected, or already correct',
|
|
451
|
-
' 1 refused,
|
|
453
|
+
' 1 refused, including several-plans for a line linking more than one',
|
|
454
|
+
' plan, with the reason on stderr or in the JSON record',
|
|
452
455
|
'',
|
|
453
456
|
'It writes Plan: [<label>](<target>) right after the H1, corrects the',
|
|
454
|
-
'target in place when the line exists
|
|
455
|
-
'and a board-relative path the way canon
|
|
456
|
-
'from a linked worktree, since it
|
|
457
|
+
'target in place when the line exists and names the one it replaced,',
|
|
458
|
+
'and resolves both a bare slug and a board-relative path the way canon',
|
|
459
|
+
'tasks plan-answers does. Safe from a linked worktree, since it',
|
|
460
|
+
'resolves the board root in-process.',
|
|
457
461
|
'',
|
|
458
462
|
'Examples:',
|
|
459
463
|
' canon tasks plan-link v28.1-trigger-escalation dispatch-answer-gate',
|
|
@@ -793,6 +797,7 @@ function reportPlanLink(
|
|
|
793
797
|
path: relative(root, outcome.path),
|
|
794
798
|
plan: outcome.plan,
|
|
795
799
|
action: outcome.action,
|
|
800
|
+
replaced: outcome.replaced,
|
|
796
801
|
})}\n`,
|
|
797
802
|
)
|
|
798
803
|
return 0
|
|
@@ -801,6 +806,7 @@ function reportPlanLink(
|
|
|
801
806
|
intro('canon tasks plan-link')
|
|
802
807
|
logStep(outcome.action === 'unchanged' ? 'Already recorded' : 'Recorded')
|
|
803
808
|
logInfo(`${outcome.stem} names plan ${outcome.plan}`)
|
|
809
|
+
if (outcome.replaced) logInfo(`replaced ${outcome.replaced}`)
|
|
804
810
|
if (outcome.action !== 'unchanged') logAdd(relative(root, outcome.path))
|
|
805
811
|
outro()
|
|
806
812
|
|
|
@@ -975,6 +981,10 @@ function reportCitations(
|
|
|
975
981
|
function describeCitations(outcome: PlanCitations): string {
|
|
976
982
|
if (outcome.location === 'unstated') return 'carries no Plan: line.'
|
|
977
983
|
|
|
984
|
+
if (outcome.location === 'several') {
|
|
985
|
+
return `links ${outcome.targets.length} plans (${outcome.targets.join(', ')}), so no single plan is the sweep's to settle.`
|
|
986
|
+
}
|
|
987
|
+
|
|
978
988
|
if (outcome.location === 'archived') {
|
|
979
989
|
return `points at ${outcome.target}, which an earlier sweep already archived.`
|
|
980
990
|
}
|
|
@@ -1396,11 +1406,7 @@ function report(
|
|
|
1396
1406
|
logStep('Archived')
|
|
1397
1407
|
logRemove(relative(root, outcome.from))
|
|
1398
1408
|
logAdd(relative(root, outcome.to))
|
|
1399
|
-
|
|
1400
|
-
logRemove(relative(root, outcome.plan.from))
|
|
1401
|
-
logAdd(relative(root, outcome.plan.to))
|
|
1402
|
-
logInfo('retargeted the Plan: line')
|
|
1403
|
-
}
|
|
1409
|
+
logPlanMoves(outcome.plans, root)
|
|
1404
1410
|
if (outcome.ready) {
|
|
1405
1411
|
logRemove(relative(root, outcome.ready.from))
|
|
1406
1412
|
logAdd(relative(root, outcome.ready.to))
|
|
@@ -1414,6 +1420,24 @@ function report(
|
|
|
1414
1420
|
return 0
|
|
1415
1421
|
}
|
|
1416
1422
|
|
|
1423
|
+
function logPlanMoves(plans: readonly PlanMove[], root: string): void {
|
|
1424
|
+
for (const plan of plans) {
|
|
1425
|
+
logRemove(relative(root, plan.from))
|
|
1426
|
+
logAdd(relative(root, plan.to))
|
|
1427
|
+
}
|
|
1428
|
+
if (plans.length > 0) logInfo('retargeted the Plan: line')
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function planMovesFor(
|
|
1432
|
+
plans: readonly PlanMove[],
|
|
1433
|
+
root: string,
|
|
1434
|
+
): readonly { readonly from: string; readonly to: string }[] {
|
|
1435
|
+
return plans.map((plan) => ({
|
|
1436
|
+
from: relative(root, plan.from),
|
|
1437
|
+
to: relative(root, plan.to),
|
|
1438
|
+
}))
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1417
1441
|
function recordFor(
|
|
1418
1442
|
outcome: ArchiveOutcome,
|
|
1419
1443
|
root: string,
|
|
@@ -1434,12 +1458,7 @@ function recordFor(
|
|
|
1434
1458
|
to: relative(root, outcome.to),
|
|
1435
1459
|
priorityRowRemoved: outcome.priorityRowRemoved,
|
|
1436
1460
|
indexRegenerated: outcome.indexRegenerated,
|
|
1437
|
-
|
|
1438
|
-
? {
|
|
1439
|
-
from: relative(root, outcome.plan.from),
|
|
1440
|
-
to: relative(root, outcome.plan.to),
|
|
1441
|
-
}
|
|
1442
|
-
: null,
|
|
1461
|
+
plans: planMovesFor(outcome.plans, root),
|
|
1443
1462
|
ready: outcome.ready
|
|
1444
1463
|
? {
|
|
1445
1464
|
from: relative(root, outcome.ready.from),
|
|
@@ -1537,11 +1556,7 @@ function reportDecline(
|
|
|
1537
1556
|
logStep('Declined')
|
|
1538
1557
|
logRemove(relative(root, outcome.from))
|
|
1539
1558
|
logAdd(relative(root, outcome.to))
|
|
1540
|
-
|
|
1541
|
-
logRemove(relative(root, outcome.plan.from))
|
|
1542
|
-
logAdd(relative(root, outcome.plan.to))
|
|
1543
|
-
logInfo('retargeted the Plan: line')
|
|
1544
|
-
}
|
|
1559
|
+
logPlanMoves(outcome.plans, root)
|
|
1545
1560
|
if (outcome.priorityRowRemoved) logInfo('cleared the ordering row')
|
|
1546
1561
|
if (outcome.backlogRowRemoved) logInfo('cleared the backlog row')
|
|
1547
1562
|
if (outcome.indexRegenerated) logInfo('regenerated index.md')
|
|
@@ -1571,11 +1586,6 @@ function declineRecordFor(
|
|
|
1571
1586
|
priorityRowRemoved: outcome.priorityRowRemoved,
|
|
1572
1587
|
backlogRowRemoved: outcome.backlogRowRemoved,
|
|
1573
1588
|
indexRegenerated: outcome.indexRegenerated,
|
|
1574
|
-
|
|
1575
|
-
? {
|
|
1576
|
-
from: relative(root, outcome.plan.from),
|
|
1577
|
-
to: relative(root, outcome.plan.to),
|
|
1578
|
-
}
|
|
1579
|
-
: null,
|
|
1589
|
+
plans: planMovesFor(outcome.plans, root),
|
|
1580
1590
|
}
|
|
1581
1591
|
}
|
package/src/gate/stages.ts
CHANGED
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
|
}
|