jackherd 1.14.0 → 1.15.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/CHANGELOG.md +15 -0
- package/README.md +27 -19
- package/assets/icons/skills.svg +1 -0
- package/dist/config.d.ts +9 -1
- package/dist/config.js +24 -1
- package/dist/plan-format.d.ts +24 -1
- package/dist/plan-format.js +25 -5
- package/docs/plan-contract.md +84 -0
- package/docs/skills-dark.svg +15 -2
- package/docs/skills.svg +15 -2
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/herdr-plugin/restore-plans.ts +56 -6
- package/install.sh +1 -1
- package/kit/hooks/hooks/merge-guard/board-settings.ts +5 -2
- package/kit/hooks/hooks/merge-guard/pull-request.ts +28 -15
- package/kit/hooks/hooks/merge-guard/run.ts +6 -3
- package/kit/hooks/hooks/merge-guard/settings.ts +12 -1
- package/kit/hooks/hooks/model-log/transcript.ts +4 -7
- package/kit/hooks/hooks/open-question/record.ts +4 -3
- package/kit/hooks/hooks/shared/dialog-answers.ts +7 -3
- package/kit/hooks/hooks/shared/log-vocabulary.ts +13 -0
- package/kit/hooks/hooks/shared/transcript.ts +7 -4
- package/kit/hooks/hooks/task-hint/decision.ts +3 -2
- package/kit/hooks/hooks/task-hint/run.ts +2 -4
- package/kit/rules/rules/kit/pull-requests.md +1 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +22 -9
- package/kit/workflow/skills/orchestrate/handover/index.ts +8 -2
- package/kit/workflow/skills/orchestrate/references/adopt-plan.md +51 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +5 -5
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +19 -0
- package/src/check/command.ts +1 -1
- package/src/check/file-kinds.ts +10 -8
- package/src/check/parallel-report.ts +10 -3
- package/src/check/settings.ts +4 -0
- package/src/check/skill-budget-defaults.ts +17 -5
- package/src/check/skill-budgets.ts +49 -19
- package/src/claude/paths.ts +12 -0
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +27 -0
- package/src/context/fill.ts +2 -1
- package/src/context/log-vocabulary.ts +22 -0
- package/src/context/session-log.ts +2 -1
- package/src/lane-prompt/build.ts +10 -5
- package/src/lane-prompt/command.ts +6 -1
- package/src/lane-prompt/input.ts +4 -4
- package/src/lane-prompt/texts.ts +18 -10
- package/src/plan-contract/command.ts +69 -0
- package/src/plan-contract/contract.ts +144 -0
- package/src/plan-format/dependencies.ts +1 -4
- package/src/plan-format/index.ts +4 -0
- package/src/plan-format/lanes.ts +11 -0
- package/src/plan-format/markdown.ts +6 -5
- package/src/plan-format/model-table.ts +27 -0
- package/src/plans/activity-read.ts +98 -0
- package/src/plans/ask-coordinator.ts +20 -11
- package/src/plans/layout.ts +49 -4
- package/src/plans/live.ts +17 -4
- package/src/plans/plan-panes.ts +25 -5
- package/src/plans/snapshot.ts +18 -8
- package/src/plans/subagents-read.ts +4 -3
- package/src/plans/subagents.ts +2 -1
- package/src/plans/texts.ts +27 -1
- package/src/settled/send.ts +80 -4
- package/src/shared/parse-json.ts +8 -0
- package/src/sync/command.ts +17 -10
- package/src/sync/foreign-skills.ts +40 -0
- package/src/sync/global-kit.ts +31 -21
- package/src/sync/init.ts +2 -2
- package/src/sync/kit-exclude.ts +39 -0
- package/src/sync/plan-file.ts +1 -1
- package/src/sync/render.ts +2 -1
- package/src/sync/report.ts +37 -2
- package/src/sync/sync-result.ts +5 -2
- package/src/sync/sync.ts +13 -5
- package/src/task-signals/claude-signals.ts +6 -5
- package/src/watch/command.ts +56 -17
- package/src/watch/lane-message.ts +1 -1
- package/src/watch/lane-progress.ts +51 -2
- package/src/watch/lane-report.ts +57 -2
- package/src/watch/lane-watch.ts +2 -8
- package/src/watch/task-parallel-touched.ts +9 -2
- package/src/watch/texts.ts +4 -0
package/src/sync/global-kit.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AGENT_PATH, claudePath, prefixedKitPath, RULES_DIRECTORY, SKILL_PATH, SKILLS_DIRECTORY } from '../claude/paths'
|
|
2
2
|
import { SKILL_PREFIX } from '../config'
|
|
3
3
|
import { TOOL_DIRECTORY } from '../config/location'
|
|
4
4
|
import { REPOSITORY_TOKENS } from '../config/repository-values'
|
|
5
|
+
import { escapeRegExp } from '../plan-format/markdown'
|
|
5
6
|
import { CLI_NAME, PACKAGE_NAME } from '../version/release'
|
|
6
7
|
import type { KitFile, KitSource } from './kit'
|
|
7
8
|
import { TOOL_ROOT } from './kit-path'
|
|
@@ -15,8 +16,6 @@ const GLOBAL_RULES_DIRECTORY = `${TOOL_ROOT}/${RULES_DIRECTORY}`
|
|
|
15
16
|
/** Put at the top of a global file that names values each repository decides; `sync --global` renders them as `<name>`. */
|
|
16
17
|
export const REPOSITORY_VALUES_NOTE = `Values in angle brackets such as \`<test>\`, \`<install>\` or \`<baseBranch>\` are the current repository's own: run \`${CLI_NAME} config\` once in it to read them all, or \`${CLI_NAME} config <name>\` for one.`
|
|
17
18
|
|
|
18
|
-
const SKILL_PATH = new RegExp(`^${SKILLS_DIRECTORY}/([^/]+)/(.+)$`)
|
|
19
|
-
const AGENT_PATH = new RegExp(`^${AGENTS_DIRECTORY}/([^/]+)\\.md$`)
|
|
20
19
|
const SKILL_ENTRY = new RegExp(`^${SKILLS_DIRECTORY}/[^/]+/SKILL\\.md$`)
|
|
21
20
|
const RULE_PATH = new RegExp(`^${RULES_DIRECTORY}/kit/(.+)$`)
|
|
22
21
|
|
|
@@ -24,8 +23,6 @@ const FRONTMATTER = /^---\n([\s\S]*?)\n---\n/
|
|
|
24
23
|
const NAME_LINE = /^name: (.+)$/m
|
|
25
24
|
const DESCRIPTION_LINE = /^description: '(.*)'$/m
|
|
26
25
|
|
|
27
|
-
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
28
|
-
|
|
29
26
|
export const globalNote = (name: string, kind: EntryKind): string =>
|
|
30
27
|
`Global ${PACKAGE_NAME} copy. In a project that has its own ${name} ${kind}, use that one instead. `
|
|
31
28
|
|
|
@@ -38,20 +35,12 @@ const assertUsablePrefix = (prefix: string): void => {
|
|
|
38
35
|
/** Where the status line lands: `~/.jackherd/statusline.sh`; `init --global` offers it from there. */
|
|
39
36
|
const GLOBAL_STATUS_LINE_PATH = `${TOOL_ROOT}/statusline.sh`
|
|
40
37
|
|
|
41
|
-
/** The path in a project's `.claude/`: skills and agents prefixed, everything else where the kit puts it. */
|
|
42
|
-
const projectPath = (path: string, prefix: string): string => {
|
|
43
|
-
const skill = SKILL_PATH.exec(path)
|
|
44
|
-
if (skill) return `${SKILLS_DIRECTORY}/${prefix}${skill[1]}/${skill[2]}`
|
|
45
|
-
const agent = AGENT_PATH.exec(path)
|
|
46
|
-
return agent ? `${AGENTS_DIRECTORY}/${prefix}${agent[1]}.md` : path
|
|
47
|
-
}
|
|
48
|
-
|
|
49
38
|
/** The path in `~/.claude`, or null for what stays out of a global install (the hooks). */
|
|
50
39
|
const globalPath = (path: string, prefix: string): string | null => {
|
|
51
40
|
if (path === 'statusline.sh') return GLOBAL_STATUS_LINE_PATH
|
|
52
41
|
const rule = RULE_PATH.exec(path)
|
|
53
42
|
if (rule) return `${GLOBAL_RULES_DIRECTORY}/${rule[1]}`
|
|
54
|
-
return SKILL_PATH.test(path) || AGENT_PATH.test(path) ?
|
|
43
|
+
return SKILL_PATH.test(path) || AGENT_PATH.test(path) ? prefixedKitPath(path, prefix) : null
|
|
55
44
|
}
|
|
56
45
|
|
|
57
46
|
const entryKind = (path: string): EntryKind | null => {
|
|
@@ -69,16 +58,37 @@ const kitNames = (files: KitFile[]): string[] => {
|
|
|
69
58
|
return [...new Set(names)].sort((left, right) => right.length - left.length)
|
|
70
59
|
}
|
|
71
60
|
|
|
72
|
-
/**
|
|
73
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Names as a skill or agent calls another: slash commands, `subagent_type`, backticked names. `rename` gives the new
|
|
63
|
+
* name inside the call, `suffix` follows the whole call, after a closing quote or backtick.
|
|
64
|
+
*/
|
|
65
|
+
export const rewriteCalls = (
|
|
66
|
+
text: string,
|
|
67
|
+
alternation: string,
|
|
68
|
+
rename: (name: string) => string,
|
|
69
|
+
suffix = '',
|
|
70
|
+
): string =>
|
|
74
71
|
text
|
|
75
72
|
.replace(
|
|
73
|
+
new RegExp(`(?<![\\w.~/-])/(${alternation})(?![\\w/-])`, 'g'),
|
|
74
|
+
(_call, name: string) => `/${rename(name)}${suffix}`,
|
|
75
|
+
)
|
|
76
|
+
.replace(
|
|
77
|
+
new RegExp(`subagent_type: '(${alternation})'`, 'g'),
|
|
78
|
+
(_call, name: string) => `subagent_type: '${rename(name)}'${suffix}`,
|
|
79
|
+
)
|
|
80
|
+
.replace(new RegExp(`\`(${alternation})\``, 'g'), (_call, name: string) => `\`${rename(name)}\`${suffix}`)
|
|
81
|
+
|
|
82
|
+
/** The calls of `rewriteCalls` and relative links, each with the prefix. */
|
|
83
|
+
const rewriteNames = (text: string, alternation: string, prefix: string): string =>
|
|
84
|
+
rewriteCalls(
|
|
85
|
+
text.replace(
|
|
76
86
|
new RegExp(`\\.\\./${SKILLS_DIRECTORY}/(${alternation})(?![\\w-])`, 'g'),
|
|
77
87
|
`../${SKILLS_DIRECTORY}/${prefix}$1`,
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
88
|
+
),
|
|
89
|
+
alternation,
|
|
90
|
+
(name) => `${prefix}${name}`,
|
|
91
|
+
)
|
|
82
92
|
|
|
83
93
|
const PROJECT_SKILLS = claudePath(SKILLS_DIRECTORY)
|
|
84
94
|
const GLOBAL_SKILLS = `~/${PROJECT_SKILLS}`
|
|
@@ -151,7 +161,7 @@ export const toGlobalKit = (kit: KitSource, prefix: string): KitSource => {
|
|
|
151
161
|
}
|
|
152
162
|
|
|
153
163
|
const toProjectFile = (file: KitFile, names: string[], prefix: string): KitFile => {
|
|
154
|
-
const path =
|
|
164
|
+
const path = prefixedKitPath(file.path, prefix)
|
|
155
165
|
if (!file.path.endsWith('.md') || file.path.startsWith('hooks/')) return { ...file, path }
|
|
156
166
|
const alternation = names.map(escapeRegExp).join('|')
|
|
157
167
|
const body = rewriteNames(rewriteSkillPaths(file.template, alternation, prefix, PROJECT_SKILLS), alternation, prefix)
|
package/src/sync/init.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { type JackherdConfig, parseJackherdConfig, type TerminalAdapter } from '
|
|
|
5
5
|
import { type RepositoryFacts, STACKS, withDetected } from '../config/detect'
|
|
6
6
|
import { CONFIG_FILE, CONFIG_SCHEMA_REFERENCE, TOOL_DIRECTORY } from '../config/location'
|
|
7
7
|
import { readRepositoryFacts } from '../config/repository-facts'
|
|
8
|
+
import { DEFAULT_SKILL_PREFIX } from '../config/schema'
|
|
8
9
|
import type { KitSource } from './kit'
|
|
9
10
|
import { moveGlobalLegacyFiles, moveProjectLegacyFiles } from './legacy-files'
|
|
10
11
|
import { assertNotHomeDirectory, assertOwnDirectoriesInProject, createFileExclusively } from './project-fs'
|
|
@@ -35,8 +36,7 @@ export type InitResult = {
|
|
|
35
36
|
terminalAdapter: TerminalAdapter
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
export const DEFAULT_SKILL_PREFIX = 'jh-'
|
|
39
|
+
export { DEFAULT_SKILL_PREFIX }
|
|
40
40
|
|
|
41
41
|
const SKILL_ENTRY = new RegExp(`^${SKILLS_DIRECTORY}/([^/]+)/SKILL\\.md$`)
|
|
42
42
|
const AGENT_ENTRY = new RegExp(`^${AGENTS_DIRECTORY}/([^/]+)\\.md$`)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { AGENT_PATH, SKILL_PATH } from '../claude/paths'
|
|
2
|
+
import { escapeRegExp } from '../plan-format/markdown'
|
|
3
|
+
import { rewriteCalls } from './global-kit'
|
|
4
|
+
import type { KitFile, KitSource } from './kit'
|
|
5
|
+
|
|
6
|
+
/** The skill or agent a kit file belongs to, by its unprefixed name; null for rules, hooks and the status line. */
|
|
7
|
+
const entryNameOf = ({ path }: KitFile): string | null => (SKILL_PATH.exec(path) ?? AGENT_PATH.exec(path))?.[1] ?? null
|
|
8
|
+
|
|
9
|
+
/** Kit texts an agent reads; the hooks' Markdown and every script stay as the kit wrote them. */
|
|
10
|
+
const isAgentText = ({ path }: KitFile): boolean => path.endsWith('.md') && !path.startsWith('hooks/')
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A call of an excluded name reads `/devops (not installed)`, so an agent sees the skill is missing instead of a dead
|
|
14
|
+
* call. Links and `.claude/skills/<name>/` paths stay as they are: a mark inside a path would break the link it is in.
|
|
15
|
+
*/
|
|
16
|
+
const markedNotInstalled = (file: KitFile, alternation: string): KitFile =>
|
|
17
|
+
isAgentText(file)
|
|
18
|
+
? { ...file, template: rewriteCalls(file.template, alternation, (name) => name, ' (not installed)') }
|
|
19
|
+
: file
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The kit without the skills and agents `kit.exclude` names: a name takes its skill directory and its agent alike, and
|
|
23
|
+
* every call of it in the remaining texts is marked as not installed. Applied before `skillPrefix`, so the names are
|
|
24
|
+
* the kit's own. A name the kit has neither of throws, so a typo never reads as excluded.
|
|
25
|
+
*/
|
|
26
|
+
export const withoutExcluded = (kit: KitSource, exclude: readonly string[]): KitSource => {
|
|
27
|
+
if (exclude.length === 0) return kit
|
|
28
|
+
const known = new Set(kit.files.map(entryNameOf))
|
|
29
|
+
const unknown = exclude.filter((name) => !known.has(name))
|
|
30
|
+
if (unknown.length > 0) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`kit.exclude names no kit skill or agent: ${unknown.join(', ')}. Correct the name, or take it off the list if this kit version no longer ships it.`,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
const excluded = new Set(exclude)
|
|
36
|
+
const alternation = exclude.map(escapeRegExp).join('|')
|
|
37
|
+
const files = kit.files.filter((file) => !excluded.has(entryNameOf(file) ?? ''))
|
|
38
|
+
return { ...kit, files: files.map((file) => markedNotInstalled(file, alternation)) }
|
|
39
|
+
}
|
package/src/sync/plan-file.ts
CHANGED
|
@@ -80,7 +80,7 @@ const planKitDropped = (input: FileInput): FilePlan => {
|
|
|
80
80
|
if (checksumOf(input.local) === input.entry.base) {
|
|
81
81
|
return plan(input, FILE_OUTCOME.removed, [{ kind: 'remove', target: 'file' }, ...cleanUpKitNew(input)], undefined)
|
|
82
82
|
}
|
|
83
|
-
return plan(input, FILE_OUTCOME.untracked,
|
|
83
|
+
return plan(input, FILE_OUTCOME.untracked, cleanUpKitNew(input), undefined)
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
const planConflict = (input: FileInput, rendered: RenderedFile): FilePlan => {
|
package/src/sync/render.ts
CHANGED
|
@@ -47,6 +47,7 @@ const placeholderValues = (config: JackherdConfig): Map<string, string> => {
|
|
|
47
47
|
...prefixed('guard.standingApproval', STANDING_APPROVAL_TEXTS[config.responseLanguage]),
|
|
48
48
|
...prefixed('approval', APPROVAL_TEXTS[config.responseLanguage]),
|
|
49
49
|
['kit.merge.withoutChecks', checkFreeList(config.merge.withoutChecks)],
|
|
50
|
+
['kit.merge.openAsDraft', config.merge.openAsDraft ? 'on' : 'off'],
|
|
50
51
|
...prefixed('heading', { ...header, ...sections, ...columns, phase }),
|
|
51
52
|
])
|
|
52
53
|
}
|
|
@@ -58,7 +59,7 @@ export const listPlaceholders = (template: string): string[] =>
|
|
|
58
59
|
/**
|
|
59
60
|
* Fills the kit placeholders from the project config: `{{kit.model.<role>}}`,
|
|
60
61
|
* `{{kit.command.<test|typecheck|lint|format|install|dev>}}`, `{{kit.lanes.<copyCommand|lockFile|verifyInstall>}}`, `{{kit.word.<laneClosed|openSubagents>}}`,
|
|
61
|
-
* `{{kit.board.<pattern|file|term>}}`, `{{kit.guard.<selfMerge|selfMergeLabel>}}`, `{{kit.guard.standingApproval.<approve|until>}}`, `{{kit.merge
|
|
62
|
+
* `{{kit.board.<pattern|file|term>}}`, `{{kit.guard.<selfMerge|selfMergeLabel>}}`, `{{kit.guard.standingApproval.<approve|until>}}`, `{{kit.merge.<withoutChecks|openAsDraft>}}`, `{{kit.heading.<key>}}` from the plan
|
|
62
63
|
* vocabulary, the two languages, `{{kit.responseAddress}}`, the plan and worktree directory and the base branch. A
|
|
63
64
|
* command or lock file the config leaves unset renders as `unsetValueText`, never as a guessed default. Other
|
|
64
65
|
* `{{…}}` text is left alone; an unknown `kit.` placeholder throws rather than reaching a project
|
package/src/sync/report.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { PROJECT_CONFIG_PATH } from '../config/location'
|
|
2
|
+
import { DEFAULT_SKILL_PREFIX } from '../config/schema'
|
|
1
3
|
import { CLI_NAME } from '../version/release'
|
|
4
|
+
import { foreignSkillNamesOf, SKILLS_LOCK_FILE, skillEntryPath } from './foreign-skills'
|
|
2
5
|
import { shownPath } from './kit-path'
|
|
3
6
|
import { FILE_OUTCOME, type FileOutcome } from './plan-file'
|
|
4
7
|
import { HOOK_OUTCOME, type HookOutcome } from './plan-hooks'
|
|
@@ -61,7 +64,7 @@ const FILE_SECTIONS: { outcome: FileOutcome; heading: string; line: (path: strin
|
|
|
61
64
|
},
|
|
62
65
|
{
|
|
63
66
|
outcome: FILE_OUTCOME.untracked,
|
|
64
|
-
heading: 'No longer in the kit, kept with your changes and no longer tracked',
|
|
67
|
+
heading: 'No longer in the kit or excluded, kept with your changes and no longer tracked',
|
|
65
68
|
line: (path) => ` ${shownPath(path)}`,
|
|
66
69
|
},
|
|
67
70
|
{ outcome: FILE_OUTCOME.resolved, heading: 'Conflicts resolved', line: (path) => ` ${shownPath(path)}` },
|
|
@@ -78,6 +81,28 @@ const HOOK_SECTIONS: { outcome: HookOutcome; heading: string }[] = [
|
|
|
78
81
|
|
|
79
82
|
const RESOLVE_HINT = `Resolve a conflict: merge the .kit-new into your file and delete it, or run \`${CLI_NAME} sync --accept <path>\` to take the kit version.`
|
|
80
83
|
|
|
84
|
+
const FOREIGN_SKILL_HEADING = `A skill from ${SKILLS_LOCK_FILE} sits on the kit's path, your file was kept`
|
|
85
|
+
|
|
86
|
+
/** A conflict, or a kept file: `npx skills add` overwrites the kit's file, and an unchanged kit reports it as kept. */
|
|
87
|
+
const FOREIGN_SKILL_OUTCOMES: readonly FileOutcome[] = [FILE_OUTCOME.conflict, FILE_OUTCOME.kept]
|
|
88
|
+
|
|
89
|
+
const foreignSkillHint = (names: readonly string[]): string =>
|
|
90
|
+
` Set "skillPrefix": "${DEFAULT_SKILL_PREFIX}" in ${PROJECT_CONFIG_PATH} and run \`${CLI_NAME} sync\`: the kit moves to ${names.map((name) => `/${DEFAULT_SKILL_PREFIX}${name}`).join(', ')} and the foreign skill stays. Or install the skill as a plugin with its own namespace.`
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The lines for a kit skill that `skills-lock.json` lists and that sync kept or left in conflict, heading and the
|
|
94
|
+
* two ways out; empty when there is none. `foreign` is the set of skill names from that file.
|
|
95
|
+
*/
|
|
96
|
+
export const foreignSkillLines = (result: SyncResult, foreign: ReadonlySet<string>): string[] => {
|
|
97
|
+
const occupied = result.files
|
|
98
|
+
.filter(({ outcome }) => FOREIGN_SKILL_OUTCOMES.includes(outcome))
|
|
99
|
+
.map(({ path }) => path)
|
|
100
|
+
const names = foreignSkillNamesOf(occupied, foreign)
|
|
101
|
+
if (names.length === 0) return []
|
|
102
|
+
const lines = names.map((name) => ` ${shownPath(skillEntryPath(name))}`)
|
|
103
|
+
return ['', `${FOREIGN_SKILL_HEADING}:`, ...lines, foreignSkillHint(names)]
|
|
104
|
+
}
|
|
105
|
+
|
|
81
106
|
export const exitCodeOf = (result: SyncResult): number => {
|
|
82
107
|
const outcomes = [...result.files, ...result.hooks].map(({ outcome }) => outcome)
|
|
83
108
|
return outcomes.some((outcome) => NEEDS_ATTENTION.includes(outcome)) ? SYNC_EXIT.conflicts : SYNC_EXIT.clean
|
|
@@ -98,6 +123,9 @@ const summaryLine = (outcomes: Outcome[]): string => {
|
|
|
98
123
|
|
|
99
124
|
const section = (heading: string, lines: string[]): string[] => (lines.length > 0 ? ['', `${heading}:`, ...lines] : [])
|
|
100
125
|
|
|
126
|
+
const excludedLines = (excluded: readonly string[] = []): string[] =>
|
|
127
|
+
excluded.length > 0 ? ['', `Left out by kit.exclude: ${excluded.join(', ')}`] : []
|
|
128
|
+
|
|
101
129
|
export const formatReport = (result: SyncResult): string => {
|
|
102
130
|
const fileLines = FILE_SECTIONS.flatMap(({ outcome, heading, line }) =>
|
|
103
131
|
section(
|
|
@@ -113,5 +141,12 @@ export const formatReport = (result: SyncResult): string => {
|
|
|
113
141
|
)
|
|
114
142
|
const hint = exitCodeOf(result) === SYNC_EXIT.conflicts ? ['', RESOLVE_HINT] : []
|
|
115
143
|
const summary = summaryLine([...result.files, ...result.hooks].map(({ outcome }) => outcome))
|
|
116
|
-
return [
|
|
144
|
+
return [
|
|
145
|
+
...(result.notes ?? []),
|
|
146
|
+
summary,
|
|
147
|
+
...excludedLines(result.excluded),
|
|
148
|
+
...fileLines,
|
|
149
|
+
...hookLines,
|
|
150
|
+
...hint,
|
|
151
|
+
].join('\n')
|
|
117
152
|
}
|
package/src/sync/sync-result.ts
CHANGED
|
@@ -5,5 +5,8 @@ export type FileReport = { path: string; outcome: FileOutcome }
|
|
|
5
5
|
|
|
6
6
|
export type HookReport = { name: string; outcome: HookOutcome }
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* `notes` name the config values the repository did not decide, printed before the summary. `excluded` are the kit
|
|
10
|
+
* skills and agents `kit.exclude` left out, by the kit's name.
|
|
11
|
+
*/
|
|
12
|
+
export type SyncResult = { files: FileReport[]; hooks: HookReport[]; notes?: string[]; excluded?: string[] }
|
package/src/sync/sync.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { emptiedDirectories } from './emptied-directories'
|
|
|
12
12
|
import { ensureGitignoreLines } from './gitignore'
|
|
13
13
|
import { toGlobalKit, toProjectKit } from './global-kit'
|
|
14
14
|
import type { KitSource } from './kit'
|
|
15
|
+
import { withoutExcluded } from './kit-exclude'
|
|
15
16
|
import { kitNewPath, toolRootPath } from './kit-path'
|
|
16
17
|
import { moveGlobalLegacyFiles, moveProjectLegacyFiles, removeEmptyLegacyDirectories } from './legacy-files'
|
|
17
18
|
import { EMPTY_MANIFEST, type Manifest, parseManifest, serializeManifest } from './manifest'
|
|
@@ -297,9 +298,10 @@ const applyPlan = async (
|
|
|
297
298
|
return notes
|
|
298
299
|
}
|
|
299
300
|
|
|
300
|
-
const syncResultOf = (plan: SyncPlan): SyncResult => ({
|
|
301
|
+
const syncResultOf = (plan: SyncPlan, excluded: readonly string[]): SyncResult => ({
|
|
301
302
|
files: plan.files.map(({ path, outcome }) => ({ path, outcome })),
|
|
302
303
|
hooks: plan.hooks.outcomes,
|
|
304
|
+
...(excluded.length > 0 ? { excluded: [...excluded] } : {}),
|
|
303
305
|
})
|
|
304
306
|
|
|
305
307
|
/**
|
|
@@ -319,7 +321,7 @@ export const syncProjectWithConfig = async (request: SyncRequest): Promise<Confi
|
|
|
319
321
|
const directories = { claudeDirectory, toolDirectory, locate: inClaudeDirectory(claudeDirectory) }
|
|
320
322
|
const plan = await buildPlan({
|
|
321
323
|
...directories,
|
|
322
|
-
kit: toProjectKit(request.kit, config.skillPrefix),
|
|
324
|
+
kit: toProjectKit(withoutExcluded(request.kit, config.kit.exclude), config.skillPrefix),
|
|
323
325
|
config,
|
|
324
326
|
accept: request.accept,
|
|
325
327
|
readHooks: projectHooks(claudeDirectory, request.kit, config),
|
|
@@ -327,7 +329,10 @@ export const syncProjectWithConfig = async (request: SyncRequest): Promise<Confi
|
|
|
327
329
|
const keptDirectoryNotes = request.check ? [] : await applyPlan(directories, plan)
|
|
328
330
|
if (!request.check) await writeConfigRewrite(toolDirectory, read)
|
|
329
331
|
if (!request.check) await ensureGitignoreLines(request.projectRoot)
|
|
330
|
-
return {
|
|
332
|
+
return {
|
|
333
|
+
result: { ...syncResultOf(plan, config.kit.exclude), notes: [...moved, ...notes, ...keptDirectoryNotes] },
|
|
334
|
+
config,
|
|
335
|
+
}
|
|
331
336
|
}
|
|
332
337
|
|
|
333
338
|
export const runSync = async (request: SyncRequest): Promise<SyncResult> =>
|
|
@@ -354,7 +359,7 @@ export const syncGlobalWithConfig = async (request: GlobalSyncRequest): Promise<
|
|
|
354
359
|
const directories = { claudeDirectory, toolDirectory, locate: inGlobalDirectories(claudeDirectory, toolDirectory) }
|
|
355
360
|
const plan = await buildPlan({
|
|
356
361
|
...directories,
|
|
357
|
-
kit: toGlobalKit(request.kit, config.skillPrefix),
|
|
362
|
+
kit: toGlobalKit(withoutExcluded(request.kit, config.kit.exclude), config.skillPrefix),
|
|
358
363
|
config,
|
|
359
364
|
accept: request.accept,
|
|
360
365
|
readHooks: noHooks,
|
|
@@ -363,7 +368,10 @@ export const syncGlobalWithConfig = async (request: GlobalSyncRequest): Promise<
|
|
|
363
368
|
const keptDirectoryNotes = await applyPlan(directories, plan)
|
|
364
369
|
await writeConfigRewrite(toolDirectory, read)
|
|
365
370
|
await removeEmptyLegacyDirectories(claudeDirectory)
|
|
366
|
-
return {
|
|
371
|
+
return {
|
|
372
|
+
result: { ...syncResultOf(plan, config.kit.exclude), notes: [...moved, ...read.notes, ...keptDirectoryNotes] },
|
|
373
|
+
config,
|
|
374
|
+
}
|
|
367
375
|
}
|
|
368
376
|
|
|
369
377
|
export const runGlobalSync = async (request: GlobalSyncRequest): Promise<SyncResult> =>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CLAUDE_TOOL, CONTENT_BLOCK_TYPE } from '../context/log-vocabulary'
|
|
1
2
|
import { parseLogEntry } from '../context/session-log'
|
|
2
3
|
import { isRecord } from '../shared/is-record'
|
|
3
4
|
import { cleanTaskText } from './text'
|
|
@@ -76,9 +77,9 @@ const onTodoWrite = (tracker: Tracker, input: Record<string, unknown>): void =>
|
|
|
76
77
|
const onToolUse = (tracker: Tracker, block: Block): void => {
|
|
77
78
|
const input = block.input
|
|
78
79
|
if (!isRecord(input)) return
|
|
79
|
-
if (block.name ===
|
|
80
|
-
if (block.name ===
|
|
81
|
-
if (block.name ===
|
|
80
|
+
if (block.name === CLAUDE_TOOL.taskCreate && typeof block.id === 'string') tracker.pending.set(block.id, input)
|
|
81
|
+
if (block.name === CLAUDE_TOOL.taskUpdate) onTaskUpdate(tracker, input)
|
|
82
|
+
if (block.name === CLAUDE_TOOL.todoWrite) onTodoWrite(tracker, input)
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
const visit = (tracker: Tracker, entry: Record<string, unknown>): void => {
|
|
@@ -86,8 +87,8 @@ const visit = (tracker: Tracker, entry: Record<string, unknown>): void => {
|
|
|
86
87
|
if (!Number.isNaN(time)) tracker.time = time
|
|
87
88
|
if (typeof entry.cwd === 'string') tracker.cwd = entry.cwd
|
|
88
89
|
for (const block of blocksOf(entry)) {
|
|
89
|
-
if (block.type ===
|
|
90
|
-
if (block.type ===
|
|
90
|
+
if (block.type === CONTENT_BLOCK_TYPE.toolUse) onToolUse(tracker, block)
|
|
91
|
+
if (block.type === CONTENT_BLOCK_TYPE.toolResult) onResult(tracker, entry, block)
|
|
91
92
|
}
|
|
92
93
|
}
|
|
93
94
|
|
package/src/watch/command.ts
CHANGED
|
@@ -95,7 +95,14 @@ import { type LaneReadings, laneFillEvents, recordLaneLevels } from './lane-fill
|
|
|
95
95
|
import { laneMessage, rememberedStatusLines, statusBlock } from './lane-message'
|
|
96
96
|
import { type LaneModelReading, laneModelEvents, recordLaneModels } from './lane-model'
|
|
97
97
|
import { laneParallelEvents, recordLaneParallels } from './lane-parallel'
|
|
98
|
-
import {
|
|
98
|
+
import {
|
|
99
|
+
boardCheckoutRows,
|
|
100
|
+
watchedLaneParallel,
|
|
101
|
+
watchedLaneProgress,
|
|
102
|
+
watchedPlanPullRequests,
|
|
103
|
+
watchedReadyPhases,
|
|
104
|
+
workedPhaseKeys,
|
|
105
|
+
} from './lane-progress'
|
|
99
106
|
import {
|
|
100
107
|
appointmentEventLine,
|
|
101
108
|
approvalDutyLines,
|
|
@@ -114,6 +121,8 @@ import {
|
|
|
114
121
|
otherPullRequestsLine,
|
|
115
122
|
parallelDutyLines,
|
|
116
123
|
planMissingDutyLines,
|
|
124
|
+
type ReadyPhase,
|
|
125
|
+
readyPhaseDutyLines,
|
|
117
126
|
sourceEventLines,
|
|
118
127
|
sourceFailingStatusLine,
|
|
119
128
|
watchEventLine,
|
|
@@ -556,12 +565,14 @@ const laneReportOf = (
|
|
|
556
565
|
}
|
|
557
566
|
}
|
|
558
567
|
|
|
568
|
+
type BoardReadOrNote = { progress: Record<string, LaneProgress>; checkoutRows: number; notes: string[] }
|
|
569
|
+
|
|
559
570
|
/** `boardReadOf`, or when the board cannot be read, nothing of it and a note that says why. */
|
|
560
571
|
const boardReadOrNote = async (
|
|
561
572
|
watch: Watch,
|
|
562
573
|
lanes: readonly string[],
|
|
563
574
|
pullRequests: readonly WatchedPullRequest[],
|
|
564
|
-
): Promise<
|
|
575
|
+
): Promise<BoardReadOrNote> => {
|
|
565
576
|
try {
|
|
566
577
|
return { ...(await boardReadOf(watch, lanes, pullRequests)), notes: [] }
|
|
567
578
|
} catch (error) {
|
|
@@ -577,15 +588,15 @@ const boardReadOrNote = async (
|
|
|
577
588
|
* The lane lines of the status block, the coordinator's line, the line of the own pull requests without a lane that
|
|
578
589
|
* follows it (`others`), and the lines after the block (notes, a gh outage).
|
|
579
590
|
*/
|
|
580
|
-
const statusLinesOf =
|
|
591
|
+
const statusLinesOf = (
|
|
581
592
|
watch: Watch,
|
|
582
593
|
watching: Watching,
|
|
583
594
|
state: WatchState,
|
|
584
|
-
|
|
595
|
+
{ progress, checkoutRows, notes }: BoardReadOrNote,
|
|
596
|
+
): { laneLines: string[]; coordinator: string; others: string[]; rest: string[] } => {
|
|
585
597
|
const github = answered(watching.github)
|
|
586
598
|
const pullRequests = github?.pullRequests ?? []
|
|
587
599
|
const others = otherPullRequestsLine(pullRequests, github?.own ?? [], watch.texts)
|
|
588
|
-
const { progress, checkoutRows, notes } = await boardReadOrNote(watch, watching.lanes, pullRequests)
|
|
589
600
|
const closeLaneHint = hintFor(watch, REFERENCE.closeLane)
|
|
590
601
|
const laneLines = watching.lanes.map((lane) =>
|
|
591
602
|
laneReportLine(
|
|
@@ -648,6 +659,19 @@ const mainCheckoutLineAfter = async (
|
|
|
648
659
|
return mainCheckoutLineOf(watch)
|
|
649
660
|
}
|
|
650
661
|
|
|
662
|
+
/** The ready phases of the stopped lanes that rest (`done`, `idle`); only their plans are read. */
|
|
663
|
+
const readyPhasesOfResting = (
|
|
664
|
+
watch: Watch,
|
|
665
|
+
watching: Watching,
|
|
666
|
+
stopped: readonly string[],
|
|
667
|
+
progress: Readonly<Record<string, LaneProgress>>,
|
|
668
|
+
): Record<string, ReadyPhase[]> => {
|
|
669
|
+
const resting = stopped.filter((lane) => IDLE_STATUSES.has(watching.current[lane]?.status ?? ''))
|
|
670
|
+
if (resting.length === 0) return {}
|
|
671
|
+
const lanes = (readSprintLanes(watch) ?? []).filter((lane) => resting.includes(lane.name))
|
|
672
|
+
return watchedReadyPhases(watch.root, watch.settings, watch.sprintFile, lanes, workedPhaseKeys(progress))
|
|
673
|
+
}
|
|
674
|
+
|
|
651
675
|
/**
|
|
652
676
|
* Every line of one return, gathered before any is printed, so the output is never cut halfway by a slow lookup, and
|
|
653
677
|
* the state with what it showed: the report times of the stopped lanes and the status lines. A state without status
|
|
@@ -663,8 +687,11 @@ const reportOf = async (
|
|
|
663
687
|
const stops = stoppedLaneSections(watch, watching, stopped, next)
|
|
664
688
|
const eventLines = await eventLinesOf(watch, events, watching.github)
|
|
665
689
|
const mainCheckout = await mainCheckoutLineAfter(watch, watching, events)
|
|
690
|
+
const board = await boardReadOrNote(watch, watching.lanes, answered(watching.github)?.pullRequests ?? [])
|
|
691
|
+
const ready = readyPhasesOfResting(watch, watching, stopped, board.progress)
|
|
666
692
|
const dutyLines = [
|
|
667
693
|
...blockedDutyLines(stopped, watching.current, watch.texts),
|
|
694
|
+
...readyPhaseDutyLines(stopped, watching.current, ready, watch.texts),
|
|
668
695
|
...mergeDutyLines(events, watching.lanes, watch.texts),
|
|
669
696
|
...(mainCheckout === null ? [] : [mainCheckout]),
|
|
670
697
|
...laneFillDutyLines(events, watch.texts),
|
|
@@ -674,7 +701,7 @@ const reportOf = async (
|
|
|
674
701
|
...planMissingDutyLines(events, watch.texts),
|
|
675
702
|
...approvalDutyLines(events, watch.texts),
|
|
676
703
|
]
|
|
677
|
-
const status =
|
|
704
|
+
const status = statusLinesOf(watch, watching, next, board)
|
|
678
705
|
const block = statusBlock(status.laneLines, status.coordinator, next.statusLines, status.others)
|
|
679
706
|
const shownReports = stopped.filter((lane) => stops.reportMtimes[lane] !== next.reportMtimes[lane])
|
|
680
707
|
return {
|
|
@@ -1350,7 +1377,8 @@ const saveState = (watch: Watch, watching: Watching) => {
|
|
|
1350
1377
|
writePrivateFile(watch.files.state, JSON.stringify(unread ? watching.state : recordedOf(watch, watching)))
|
|
1351
1378
|
}
|
|
1352
1379
|
|
|
1353
|
-
|
|
1380
|
+
/** Watches until the lanes end or the kit changes on disk; true when a kit update ended it. */
|
|
1381
|
+
const watchLanes = async (watch: Watch): Promise<boolean> => {
|
|
1354
1382
|
const takeOver = await takeOverLock(watch.files.lock)
|
|
1355
1383
|
const state = await readState(watch.files)
|
|
1356
1384
|
const agents = listAgents()
|
|
@@ -1389,8 +1417,9 @@ const watchLanes = async (watch: Watch) => {
|
|
|
1389
1417
|
await Bun.sleep(POLL_MS)
|
|
1390
1418
|
pollLanes(watch, watching)
|
|
1391
1419
|
await announceUpdate?.()
|
|
1392
|
-
if (endedByKitUpdate(kit, effects)) return
|
|
1420
|
+
if (endedByKitUpdate(kit, effects)) return true
|
|
1393
1421
|
}
|
|
1422
|
+
return false
|
|
1394
1423
|
} finally {
|
|
1395
1424
|
sourceChanges?.close()
|
|
1396
1425
|
stopPublisher?.()
|
|
@@ -1456,6 +1485,20 @@ const watchOf = (options: WatchOptions): Watch => {
|
|
|
1456
1485
|
}
|
|
1457
1486
|
}
|
|
1458
1487
|
|
|
1488
|
+
/** How a watch ended: the kit's source changed under it, or the loop stopped with an exit code. */
|
|
1489
|
+
type WatchEnd = { kind: 'kitUpdated' } | { kind: 'stopped'; code: number }
|
|
1490
|
+
|
|
1491
|
+
const watchToEnd = async (watch: Watch): Promise<WatchEnd> => {
|
|
1492
|
+
try {
|
|
1493
|
+
return (await watchLanes(watch)) ? { kind: 'kitUpdated' } : { kind: 'stopped', code: 0 }
|
|
1494
|
+
} catch (error) {
|
|
1495
|
+
console.log(`watcher stopped: ${errorText(error)}`)
|
|
1496
|
+
return { kind: 'stopped', code: 1 }
|
|
1497
|
+
} finally {
|
|
1498
|
+
releaseLock(watch.files.lock)
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1459
1502
|
export const run = async (args: string[]): Promise<number> => {
|
|
1460
1503
|
let options: WatchOptions
|
|
1461
1504
|
try {
|
|
@@ -1480,13 +1523,9 @@ export const run = async (args: string[]): Promise<number> => {
|
|
|
1480
1523
|
console.error(errorText(error))
|
|
1481
1524
|
return 1
|
|
1482
1525
|
}
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
return 1
|
|
1489
|
-
} finally {
|
|
1490
|
-
releaseLock(watch.files.lock)
|
|
1491
|
-
}
|
|
1526
|
+
const end = await watchToEnd(watch)
|
|
1527
|
+
// The loop's timers and child handles outlive it, so the process would stay beside the watcher that replaces it;
|
|
1528
|
+
// the state is saved and the lock released, as on SIGTERM.
|
|
1529
|
+
if (end.kind === 'kitUpdated') process.exit(0)
|
|
1530
|
+
return end.code
|
|
1492
1531
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
-
import { AGENT_STATUS } from '
|
|
2
|
+
import { AGENT_STATUS } from '../terminal/types'
|
|
3
3
|
import { budgetQuestions, questionCount, questionSections } from './report-questions'
|
|
4
4
|
|
|
5
5
|
/** A lane's report file as the watcher found it: its path, when it was last written, its text. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
|
-
import { type LaneProgress, parallelGroupsOfPlan, resolvePlanPath, type SprintLane } from '../plan-format'
|
|
3
|
+
import { currentPhase, type LaneProgress, parallelGroupsOfPlan, resolvePlanPath, type SprintLane } from '../plan-format'
|
|
4
4
|
import { boardRowsOf } from '../plans/board-rows'
|
|
5
5
|
import { type BaseReader, baseReaderOf, gitStatusOf } from '../plans/git'
|
|
6
6
|
import { laneWorkOf, readPlans, textAtBase } from '../plans/lane-plans'
|
|
@@ -8,6 +8,7 @@ import { laneHeads, type PlanPullRequest, planPullRequests } from '../plans/plan
|
|
|
8
8
|
import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from '../plans/settings'
|
|
9
9
|
import { workedLaneProgress } from '../plans/worked-phases'
|
|
10
10
|
import { parallelBatchOf } from './lane-parallel'
|
|
11
|
+
import { type ReadyPhase, readyPhasesOf } from './lane-report'
|
|
11
12
|
import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
|
|
12
13
|
|
|
13
14
|
/** The open pull requests of the watcher's list that change a plan, as the herd chooses them. */
|
|
@@ -47,6 +48,10 @@ export const watchedLaneProgress = async (
|
|
|
47
48
|
return workedLaneProgress(lane, plans)
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
/** The plan link of a lane's newest row that links one, as the board spells it; null without such a row. */
|
|
52
|
+
const newestPlanHref = (lane: SprintLane): string | null =>
|
|
53
|
+
lane.steps.filter((step) => step.planHref !== null).at(-1)?.planHref ?? null
|
|
54
|
+
|
|
50
55
|
/** The repository path of the plan of a lane's newest row that links one; null without such a row. */
|
|
51
56
|
export const lanePlanPath = (
|
|
52
57
|
root: string,
|
|
@@ -54,7 +59,7 @@ export const lanePlanPath = (
|
|
|
54
59
|
boardFile: string,
|
|
55
60
|
lane: SprintLane,
|
|
56
61
|
): string | null => {
|
|
57
|
-
const href = lane
|
|
62
|
+
const href = newestPlanHref(lane)
|
|
58
63
|
return href ? resolvePlanPath(dirname(boardRepoPathOf(root, settings, boardFile)), href) : null
|
|
59
64
|
}
|
|
60
65
|
|
|
@@ -102,6 +107,50 @@ export const watchedLaneParallel = (
|
|
|
102
107
|
return groups === null ? [] : parallelBatchOf(groups)
|
|
103
108
|
}
|
|
104
109
|
|
|
110
|
+
/** A phase the lanes work on, keyed by its plan link as the board spells it, since two plans may share an id. */
|
|
111
|
+
const workedPhaseKey = (planHref: string, id: string): string => `${planHref}#${id}`
|
|
112
|
+
|
|
113
|
+
/** The ids of the phases in the plan of `planHref` that a lane works on, out of `workedPhaseKeys`. */
|
|
114
|
+
const phaseIdsWorkedIn = (worked: ReadonlySet<string>, planHref: string): Set<string> => {
|
|
115
|
+
const prefix = workedPhaseKey(planHref, '')
|
|
116
|
+
return new Set([...worked].flatMap((key) => (key.startsWith(prefix) ? [key.slice(prefix.length)] : [])))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The phases the lanes work on: the current phase of each lane's progress that has one, with its plan link. */
|
|
120
|
+
export const workedPhaseKeys = (progress: Readonly<Record<string, LaneProgress>>): Set<string> =>
|
|
121
|
+
new Set(
|
|
122
|
+
Object.values(progress).flatMap((each) => {
|
|
123
|
+
const phase = currentPhase(each)
|
|
124
|
+
const planHref = each.current === null ? null : each.steps[each.current.stepIndex]?.step.planHref
|
|
125
|
+
return phase && planHref ? [workedPhaseKey(planHref, phase.id)] : []
|
|
126
|
+
}),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Per lane, the open phases of its plan whose dependencies are all ticked off and that no lane works on (`worked`, from
|
|
131
|
+
* `workedPhaseKeys`), by the plan's dependency table and ticks, as the lane's worktree has them; empty for a lane
|
|
132
|
+
* without a readable plan.
|
|
133
|
+
*/
|
|
134
|
+
export const watchedReadyPhases = (
|
|
135
|
+
root: string,
|
|
136
|
+
settings: PlansSettings,
|
|
137
|
+
boardFile: string,
|
|
138
|
+
lanes: readonly SprintLane[],
|
|
139
|
+
worked: ReadonlySet<string>,
|
|
140
|
+
): Record<string, ReadyPhase[]> =>
|
|
141
|
+
Object.fromEntries(
|
|
142
|
+
lanes.map((lane) => {
|
|
143
|
+
const planHref = newestPlanHref(lane)
|
|
144
|
+
const text = watchedLanePlanText(root, settings, boardFile, lane)
|
|
145
|
+
return [
|
|
146
|
+
lane.name,
|
|
147
|
+
text === null || planHref === null
|
|
148
|
+
? []
|
|
149
|
+
: readyPhasesOf(text, settings.planLanguage, phaseIdsWorkedIn(worked, planHref)),
|
|
150
|
+
]
|
|
151
|
+
}),
|
|
152
|
+
)
|
|
153
|
+
|
|
105
154
|
/**
|
|
106
155
|
* The rows the checkout's board file (`checkoutText`) has beyond the board at the base ref, counted as the herd
|
|
107
156
|
* adds them; 0 when the base ref lacks the board, which is then read from the checkout as a whole.
|