@erclx/canon 5.48.0 → 5.49.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 +1 -1
- package/claude/skills/plan-feature/SKILL.md +18 -15
- package/claude/skills/plan-feature/references/batches.md +5 -5
- package/claude/skills/plan-feature/references/plan.md +82 -33
- package/claude/skills/plan-groundwork/SKILL.md +8 -0
- package/claude/skills/plan-groundwork/references/groundwork.md +2 -1
- package/claude/skills/plan-intake/SKILL.md +8 -0
- package/claude/skills/plan-intake/references/intake.md +9 -1
- package/claude/skills/plan-ready/REQUIREMENT.md +3 -3
- package/claude/skills/plan-ready/SKILL.md +3 -3
- package/claude/skills/plan-ready/references/assembly.md +7 -7
- package/claude/skills/plan-ready/references/ready.md +8 -6
- package/claude/skills/review-ui/REQUIREMENT.md +1 -1
- package/claude/skills/review-ui/SKILL.md +4 -11
- package/claude/skills/review-ui/references/close-out.md +1 -1
- package/claude/skills/review-ui/references/drive-copy.md +38 -0
- package/claude/skills/review-ui/references/post.md +24 -3
- package/claude/skills/review-ui/references/verb-absent.md +18 -0
- package/claude/skills/role-orchestrator/SKILL.md +3 -2
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +60 -0
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -0
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
- package/claude/skills/role-planner/SKILL.md +1 -1
- package/claude/skills/role-worker/SKILL.md +4 -2
- package/claude/skills/session-compact/references/handoff-note.md +3 -1
- package/claude/skills/session-map/references/session.md +1 -1
- package/claude/skills/task-board/references/board.md +2 -2
- package/claude/skills/task-board/references/tasks.md +1 -1
- package/docs/agents/records.md +1 -1
- package/docs/workflow/parallel-features.md +2 -2
- package/package.json +1 -1
- package/src/commands/pr.ts +411 -23
- package/src/commands/records.ts +45 -8
- package/src/commands/tasks.ts +12 -0
- package/src/hooks/post-merge.ts +11 -0
- package/src/pr/drive-copy.ts +218 -0
- package/src/pr/local.ts +26 -0
- package/src/pr/rest.ts +2 -0
- package/src/records/structure.ts +147 -0
- package/src/records/validate.ts +113 -12
- package/src/tasks/archive.ts +137 -4
- package/src/tasks/board.ts +68 -13
- package/src/tasks/validate.ts +39 -2
- package/tooling/base/configs/.github/workflows/pr-local-preview.yml +1 -1
package/src/tasks/archive.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
+
import { execa } from 'execa'
|
|
2
3
|
import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
3
4
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
5
|
+
import { gitEnv } from '@/git/env'
|
|
4
6
|
import { regenOne } from '@/indexes/regen'
|
|
7
|
+
import { rerenderRow } from '@/tasks/board'
|
|
8
|
+
import { planBranch } from '@/tasks/branch'
|
|
5
9
|
import { isUnder } from '@/tasks/paths'
|
|
6
10
|
import { recordDir, recordDirs } from '@/roots/record'
|
|
7
11
|
|
|
@@ -128,8 +132,25 @@ export interface ArchiveRefused {
|
|
|
128
132
|
readonly reason: ArchiveRefusal
|
|
129
133
|
readonly message: string
|
|
130
134
|
readonly detail: readonly string[]
|
|
135
|
+
/**
|
|
136
|
+
* The slice plan a merged pull request settled while its task stayed open,
|
|
137
|
+
* moved into the archive. Set only on `earlier-slice` and `open-outcomes`.
|
|
138
|
+
*/
|
|
139
|
+
readonly slice?: PlanMove
|
|
131
140
|
}
|
|
132
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Answers the head branch a pull request merged from, or undefined when it
|
|
144
|
+
* cannot be read. Tests supply one rather than reaching for `gh`.
|
|
145
|
+
*/
|
|
146
|
+
export type HeadReader = (pullRequest: number) => Promise<string | undefined>
|
|
147
|
+
|
|
148
|
+
export interface ArchiveOptions {
|
|
149
|
+
readonly head?: HeadReader
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const GH_TIMEOUT_MS = 15_000
|
|
153
|
+
|
|
133
154
|
export type ArchiveOutcome = ArchiveSuccess | ArchiveRefused
|
|
134
155
|
|
|
135
156
|
export interface DeclineSuccess {
|
|
@@ -925,6 +946,7 @@ async function resolveStem(
|
|
|
925
946
|
export async function archiveTask(
|
|
926
947
|
root: string,
|
|
927
948
|
selector: TaskSelector,
|
|
949
|
+
options: ArchiveOptions = {},
|
|
928
950
|
): Promise<ArchiveOutcome> {
|
|
929
951
|
const dir = tasksDir(root)
|
|
930
952
|
|
|
@@ -944,9 +966,13 @@ export async function archiveTask(
|
|
|
944
966
|
// while the last slice is still open. Only the last number closes the task.
|
|
945
967
|
const last = readPullRequest(text).at(-1)
|
|
946
968
|
if (selector.kind === 'pull-request' && last !== selector.number) {
|
|
947
|
-
return
|
|
948
|
-
|
|
949
|
-
|
|
969
|
+
return withMergedSlice(
|
|
970
|
+
refuse(
|
|
971
|
+
'earlier-slice',
|
|
972
|
+
`${stem} lists #${last} after #${selector.number}, so the task closes when #${last} merges.`,
|
|
973
|
+
),
|
|
974
|
+
{ dir, root, stem, text, number: selector.number },
|
|
975
|
+
options.head ?? ghHeadReader(root),
|
|
950
976
|
)
|
|
951
977
|
}
|
|
952
978
|
|
|
@@ -966,11 +992,18 @@ export async function archiveTask(
|
|
|
966
992
|
const { open, closed, cut, operator } = readOutcomes(text)
|
|
967
993
|
|
|
968
994
|
if (open.length > 0) {
|
|
969
|
-
|
|
995
|
+
const refused = refuse(
|
|
970
996
|
'open-outcomes',
|
|
971
997
|
`${stem} has ${open.length} open outcome(s). Close them, or cut one with \`- ~~<outcome>~~ <why>\`, then archive.`,
|
|
972
998
|
open,
|
|
973
999
|
)
|
|
1000
|
+
return selector.kind === 'pull-request'
|
|
1001
|
+
? withMergedSlice(
|
|
1002
|
+
refused,
|
|
1003
|
+
{ dir, root, stem, text, number: selector.number },
|
|
1004
|
+
options.head ?? ghHeadReader(root),
|
|
1005
|
+
)
|
|
1006
|
+
: refused
|
|
974
1007
|
}
|
|
975
1008
|
|
|
976
1009
|
if (closed.length === 0 && cut.length === 0 && operator.length === 0) {
|
|
@@ -1038,6 +1071,106 @@ export async function archiveTask(
|
|
|
1038
1071
|
}
|
|
1039
1072
|
}
|
|
1040
1073
|
|
|
1074
|
+
interface MergedSlice {
|
|
1075
|
+
readonly dir: string
|
|
1076
|
+
readonly root: string
|
|
1077
|
+
readonly stem: string
|
|
1078
|
+
readonly text: string
|
|
1079
|
+
readonly number: number
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Moves the slice plan a merged pull request built into the archive while its
|
|
1084
|
+
* task stays open, and retargets the task's `Plan:` line at it, so the board
|
|
1085
|
+
* row drops the slice through `livePlans`. The refusal still answers, since
|
|
1086
|
+
* the task did not close.
|
|
1087
|
+
*
|
|
1088
|
+
* Only a task citing more than one live plan has a slice to drop. The plan is
|
|
1089
|
+
* the one whose derived branch is the pull request's head, so a plan a worker
|
|
1090
|
+
* renamed off its branch matches nothing and moves nothing. A plan another
|
|
1091
|
+
* live task cites stays, for the reason `plansToArchive` gives, and an
|
|
1092
|
+
* unreadable head leaves everything as it was.
|
|
1093
|
+
*/
|
|
1094
|
+
async function withMergedSlice(
|
|
1095
|
+
refused: ArchiveRefused,
|
|
1096
|
+
merged: MergedSlice,
|
|
1097
|
+
head: HeadReader,
|
|
1098
|
+
): Promise<ArchiveRefused> {
|
|
1099
|
+
const { dir, root, stem, text } = merged
|
|
1100
|
+
const live = readPlanTargets(text).flatMap((target) => {
|
|
1101
|
+
const path = resolveLivePlan(target, dir, root)
|
|
1102
|
+
return path && existsSync(path) ? [{ target, path }] : []
|
|
1103
|
+
})
|
|
1104
|
+
if (new Set(live.map((entry) => entry.path)).size < 2) return refused
|
|
1105
|
+
|
|
1106
|
+
const branch = await head(merged.number)
|
|
1107
|
+
if (branch === undefined) return refused
|
|
1108
|
+
|
|
1109
|
+
const slice = live.find((entry) => {
|
|
1110
|
+
const derived = planBranch(root, entry.path)
|
|
1111
|
+
return derived.ok && derived.branch === branch
|
|
1112
|
+
})
|
|
1113
|
+
if (!slice) return refused
|
|
1114
|
+
|
|
1115
|
+
const shared = await otherTasksCitingPlan(dir, root, slice.path, stem)
|
|
1116
|
+
if (shared.length > 0) return refused
|
|
1117
|
+
|
|
1118
|
+
const move = {
|
|
1119
|
+
from: slice.path,
|
|
1120
|
+
to: join(recordDir(root, PLANS, ARCHIVE), basename(slice.path)),
|
|
1121
|
+
}
|
|
1122
|
+
if (existsSync(move.to)) return refused
|
|
1123
|
+
|
|
1124
|
+
await movePlans([move])
|
|
1125
|
+
const targets = live.filter((entry) => entry.path === slice.path)
|
|
1126
|
+
await writeFile(
|
|
1127
|
+
join(dir, `${stem}.md`),
|
|
1128
|
+
retargetPlanLine(
|
|
1129
|
+
text,
|
|
1130
|
+
new Map(targets.map((entry) => [entry.target, linkTo(dir, move.to)])),
|
|
1131
|
+
),
|
|
1132
|
+
)
|
|
1133
|
+
// The closing path clears its row, and this one keeps it, so the row is
|
|
1134
|
+
// rendered again to drop the slice from its Plan and Touches cells. A row
|
|
1135
|
+
// the board cannot write, being absent or busy, stays for `validate` to report.
|
|
1136
|
+
await rerenderRow(root, stem)
|
|
1137
|
+
|
|
1138
|
+
return { ...refused, slice: move }
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Reads a pull request's head branch through `gh`, in the repository `root`
|
|
1143
|
+
* resolves to. No `gh` on the path, or a failed read, answers undefined, so a
|
|
1144
|
+
* machine without it keeps a slice plan live rather than guessing.
|
|
1145
|
+
*/
|
|
1146
|
+
export function ghHeadReader(root: string): HeadReader {
|
|
1147
|
+
return async (pullRequest) => {
|
|
1148
|
+
if (Bun.which('gh') === null) return undefined
|
|
1149
|
+
|
|
1150
|
+
try {
|
|
1151
|
+
// `gh` resolves its repository through git, so the hook's resolution
|
|
1152
|
+
// variables would point it elsewhere. `reclaim.ts` strips them the same way.
|
|
1153
|
+
const result = await execa(
|
|
1154
|
+
'gh',
|
|
1155
|
+
[
|
|
1156
|
+
'pr',
|
|
1157
|
+
'view',
|
|
1158
|
+
String(pullRequest),
|
|
1159
|
+
'--json',
|
|
1160
|
+
'headRefName',
|
|
1161
|
+
'--jq',
|
|
1162
|
+
'.headRefName',
|
|
1163
|
+
],
|
|
1164
|
+
{ cwd: root, timeout: GH_TIMEOUT_MS, env: gitEnv(), extendEnv: false },
|
|
1165
|
+
)
|
|
1166
|
+
const branch = result.stdout.trim()
|
|
1167
|
+
return branch.length > 0 ? branch : undefined
|
|
1168
|
+
} catch {
|
|
1169
|
+
return undefined
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1041
1174
|
interface PlansToArchive {
|
|
1042
1175
|
readonly moves: readonly PlanMove[]
|
|
1043
1176
|
/** Each moved target as the line wrote it, mapped to its link from `taskDir`. */
|
package/src/tasks/board.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
BOARD_GROUPS,
|
|
14
14
|
type BoardGroup,
|
|
15
15
|
backlogPath,
|
|
16
|
+
declaredRows,
|
|
16
17
|
type FindingKind,
|
|
17
18
|
groupRuleFindings,
|
|
18
19
|
orderingPath,
|
|
@@ -30,8 +31,15 @@ export const GROUP_COLUMNS: Readonly<Record<BoardGroup, readonly string[]>> = {
|
|
|
30
31
|
'Needs a plan': ['Task', 'Waiting on'],
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* How many folders a `Touches` cell names. A backticked path never wraps, so
|
|
36
|
+
* three of them pushed a table past an editor preview, and two short folders
|
|
37
|
+
* fit beside the count.
|
|
38
|
+
*/
|
|
39
|
+
const TOUCHES_FOLDERS = 2
|
|
40
|
+
|
|
41
|
+
/** How deep a `Touches` folder reads, as in `src/tasks/` or `claude/skills/`. */
|
|
42
|
+
const FOLDER_DEPTH = 2
|
|
35
43
|
|
|
36
44
|
/**
|
|
37
45
|
* The widest a `Waiting on` cell may render. The live board's cells measured 14
|
|
@@ -205,6 +213,8 @@ export interface DerivedCells {
|
|
|
205
213
|
readonly task: string
|
|
206
214
|
readonly touches: string
|
|
207
215
|
readonly plan: string
|
|
216
|
+
/** The declared paths the Touches cell counts, which the overlap checks read in its place. */
|
|
217
|
+
readonly paths: readonly string[]
|
|
208
218
|
}
|
|
209
219
|
|
|
210
220
|
function titleOf(text: string): string | undefined {
|
|
@@ -227,17 +237,41 @@ function planLabel(path: string): string {
|
|
|
227
237
|
}
|
|
228
238
|
|
|
229
239
|
/**
|
|
230
|
-
* The
|
|
231
|
-
*
|
|
232
|
-
* the plural.
|
|
240
|
+
* The folder a path sits under, `FOLDER_DEPTH` segments deep at most and with a
|
|
241
|
+
* trailing slash, or undefined for a file at the root.
|
|
233
242
|
*/
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
243
|
+
function folderOf(path: string): string | undefined {
|
|
244
|
+
const segments = path.split('/').slice(0, -1).slice(0, FOLDER_DEPTH)
|
|
245
|
+
return segments.length === 0 ? undefined : `${segments.join('/')}/`
|
|
246
|
+
}
|
|
238
247
|
|
|
239
|
-
|
|
240
|
-
|
|
248
|
+
/**
|
|
249
|
+
* A count of the declared paths and the folders holding most of them, at most
|
|
250
|
+
* `TOUCHES_FOLDERS`, with ties going to the folder declared first. The file
|
|
251
|
+
* list lives in the plan the row links, so the cell only says where a row
|
|
252
|
+
* works. Root files count and name no folder.
|
|
253
|
+
*/
|
|
254
|
+
export function touchesCell(paths: readonly string[]): string {
|
|
255
|
+
if (paths.length === 0) return ''
|
|
256
|
+
|
|
257
|
+
const counts = new Map<string, number>()
|
|
258
|
+
for (const path of paths) {
|
|
259
|
+
const folder = folderOf(path)
|
|
260
|
+
if (folder !== undefined) counts.set(folder, (counts.get(folder) ?? 0) + 1)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const folders = [...counts.entries()]
|
|
264
|
+
.sort((left, right) => right[1] - left[1])
|
|
265
|
+
.map(([folder]) => folder)
|
|
266
|
+
const shown = folders
|
|
267
|
+
.slice(0, TOUCHES_FOLDERS)
|
|
268
|
+
.map((folder) => `\`${folder}\``)
|
|
269
|
+
const files = paths.length === 1 ? '1 file' : `${paths.length} files`
|
|
270
|
+
if (shown.length === 0) return files
|
|
271
|
+
|
|
272
|
+
const rest = folders.length - shown.length
|
|
273
|
+
const tail = rest === 0 ? '' : `, and ${rest} more`
|
|
274
|
+
return `${files} under ${shown.join(', ')}${tail}`
|
|
241
275
|
}
|
|
242
276
|
|
|
243
277
|
/**
|
|
@@ -270,6 +304,7 @@ export async function deriveCells(
|
|
|
270
304
|
task: `[${row.label || row.stem}](${row.stem}.md)`,
|
|
271
305
|
touches: '',
|
|
272
306
|
plan: '',
|
|
307
|
+
paths: [],
|
|
273
308
|
}
|
|
274
309
|
}
|
|
275
310
|
|
|
@@ -279,13 +314,15 @@ export async function deriveCells(
|
|
|
279
314
|
plans.map(async (plan) => readDeclarations(await readFile(plan, 'utf8'))),
|
|
280
315
|
)
|
|
281
316
|
const dir = tasksDir(root)
|
|
317
|
+
const paths = [...new Set(declared.flat())]
|
|
282
318
|
|
|
283
319
|
return {
|
|
284
320
|
task: `[${titleOf(text) ?? (row.label || row.stem)}](${row.stem}.md)`,
|
|
285
|
-
touches: touchesCell(
|
|
321
|
+
touches: touchesCell(paths),
|
|
286
322
|
plan: plans
|
|
287
323
|
.map((plan) => `[${planLabel(plan)}](${linkTo(dir, plan)})`)
|
|
288
324
|
.join(', '),
|
|
325
|
+
paths,
|
|
289
326
|
}
|
|
290
327
|
}
|
|
291
328
|
|
|
@@ -594,7 +631,8 @@ async function writeLocked(
|
|
|
594
631
|
|
|
595
632
|
if (edited.ruled) {
|
|
596
633
|
const forbidden = GROUP_RULES[edited.ruled]
|
|
597
|
-
const
|
|
634
|
+
const rows = await declaredRows(readBoard(text).rows, root)
|
|
635
|
+
const broken = (await groupRuleFindings(rows, root)).filter(
|
|
598
636
|
(finding) => finding.subject === stem && forbidden.includes(finding.kind),
|
|
599
637
|
)
|
|
600
638
|
if (broken.length > 0) {
|
|
@@ -793,6 +831,23 @@ export async function promoteOnLink(
|
|
|
793
831
|
: { moved: false, from: found.group, note: outcome.message }
|
|
794
832
|
}
|
|
795
833
|
|
|
834
|
+
/**
|
|
835
|
+
* Rewrites the board with a task's row where it sits, so its derived cells
|
|
836
|
+
* catch up with a plan that moved under it. A merged slice archives its plan
|
|
837
|
+
* from the post-merge hook, where no other board verb runs.
|
|
838
|
+
*/
|
|
839
|
+
export async function rerenderRow(
|
|
840
|
+
root: string,
|
|
841
|
+
stem: string,
|
|
842
|
+
): Promise<BoardOutcome> {
|
|
843
|
+
return writeBoard(root, stem, (board) => {
|
|
844
|
+
const found = findRow(board, stem)
|
|
845
|
+
if (!found) return refuse('no-row', `${stem} has no row on the board.`)
|
|
846
|
+
|
|
847
|
+
return { board, group: found.group, from: found.group, ruled: undefined }
|
|
848
|
+
})
|
|
849
|
+
}
|
|
850
|
+
|
|
796
851
|
/** Repositions a row inside the group it already sits in. */
|
|
797
852
|
export async function moveTask(
|
|
798
853
|
root: string,
|
package/src/tasks/validate.ts
CHANGED
|
@@ -23,6 +23,9 @@ import { gitTrunkReader, type TrunkReader } from '@/tasks/trunk'
|
|
|
23
23
|
const ORDERING_FILE = 'priority.md'
|
|
24
24
|
const BACKLOG_FILE = 'backlog.md'
|
|
25
25
|
|
|
26
|
+
/** The opening `touchesCell` writes, as in `12 files under` or a bare `2 files`. */
|
|
27
|
+
const DERIVED_COUNT = /^\d+ files?(?: under |$)/
|
|
28
|
+
|
|
26
29
|
/**
|
|
27
30
|
* The readiness headings `claude/skills/task-board/references/board.md` fixes. The names are the
|
|
28
31
|
* contract rather than a suggestion, so a board grouping under names of its own
|
|
@@ -152,7 +155,11 @@ export interface BoardRow {
|
|
|
152
155
|
readonly label: string
|
|
153
156
|
readonly stem: string | undefined
|
|
154
157
|
readonly plan: string | undefined
|
|
155
|
-
/**
|
|
158
|
+
/**
|
|
159
|
+
* Absent when the group fixes no `Touches` column, empty when it read none.
|
|
160
|
+
* `readBoard` reads the written cell, and `declaredRows` replaces it with the
|
|
161
|
+
* paths the task's plans declare.
|
|
162
|
+
*/
|
|
156
163
|
readonly touches: readonly string[] | undefined
|
|
157
164
|
/** The `Touches` cell as written, which the stale check compares against the derivation. */
|
|
158
165
|
readonly touchesText: string | undefined
|
|
@@ -235,8 +242,14 @@ function stemOf(target: string): string | undefined {
|
|
|
235
242
|
* An extension opens with a letter, which is what separates `commands.md` from
|
|
236
243
|
* a task version like `v40.2`. Reading the version as a path would collide two
|
|
237
244
|
* rows that merely cite the same task.
|
|
245
|
+
*
|
|
246
|
+
* A cell a board verb derived opens with its file count, and its folders say
|
|
247
|
+
* where the row works rather than claiming every file under them, so it yields
|
|
248
|
+
* nothing. Its reader takes the declared paths from the plans instead.
|
|
238
249
|
*/
|
|
239
250
|
export function readPaths(cell: string): string[] {
|
|
251
|
+
if (DERIVED_COUNT.test(cell.trim())) return []
|
|
252
|
+
|
|
240
253
|
const spans = cell.match(/`[^`]+`/g) ?? []
|
|
241
254
|
const paths = spans
|
|
242
255
|
.map((span) => span.slice(1, -1).trim())
|
|
@@ -1516,6 +1529,29 @@ async function checkStaleTouches(
|
|
|
1516
1529
|
return found.filter((finding): finding is Finding => finding !== undefined)
|
|
1517
1530
|
}
|
|
1518
1531
|
|
|
1532
|
+
/**
|
|
1533
|
+
* Gives each row the paths its task's live plans declare in place of the ones
|
|
1534
|
+
* read off its `Touches` cell, which states a count and folders rather than
|
|
1535
|
+
* files. A row whose task is gone, or whose plans declare nothing, keeps what
|
|
1536
|
+
* its cell named, so a hand-written cell still feeds the overlap checks.
|
|
1537
|
+
*/
|
|
1538
|
+
export async function declaredRows(
|
|
1539
|
+
rows: readonly BoardRow[],
|
|
1540
|
+
root: string,
|
|
1541
|
+
): Promise<BoardRow[]> {
|
|
1542
|
+
return Promise.all(
|
|
1543
|
+
rows.map(async (row) => {
|
|
1544
|
+
if (row.touches === undefined || !row.stem) return row
|
|
1545
|
+
|
|
1546
|
+
const { paths } = await deriveCells(root, {
|
|
1547
|
+
stem: row.stem,
|
|
1548
|
+
label: row.label,
|
|
1549
|
+
})
|
|
1550
|
+
return paths.length === 0 ? row : { ...row, touches: paths }
|
|
1551
|
+
}),
|
|
1552
|
+
)
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1519
1555
|
/**
|
|
1520
1556
|
* The claims a group name makes about a row, being a stated plan and file set
|
|
1521
1557
|
* under `## Run now`, a live plan under `## Up next`, and none under
|
|
@@ -1566,10 +1602,11 @@ export async function validateBoard(
|
|
|
1566
1602
|
}
|
|
1567
1603
|
|
|
1568
1604
|
const {
|
|
1569
|
-
rows,
|
|
1605
|
+
rows: written,
|
|
1570
1606
|
groups,
|
|
1571
1607
|
findings: shapeFindings,
|
|
1572
1608
|
} = readBoard(await readFile(ordering, 'utf8'))
|
|
1609
|
+
const rows = await declaredRows(written, root)
|
|
1573
1610
|
|
|
1574
1611
|
if (groups.length === 0) {
|
|
1575
1612
|
return refuse(
|
|
@@ -24,7 +24,7 @@ jobs:
|
|
|
24
24
|
with:
|
|
25
25
|
bun-version: latest
|
|
26
26
|
|
|
27
|
-
- name: Drop the preview line
|
|
27
|
+
- name: Drop the evidence preview line and mask UI review addresses
|
|
28
28
|
if: github.event.pull_request.head.repo.full_name == github.repository
|
|
29
29
|
env:
|
|
30
30
|
GH_TOKEN: ${{ github.token }}
|