jackherd 1.12.7 → 1.14.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/INSTALL.md +1 -1
- package/README.md +17 -17
- package/dist/config.d.ts +35 -11
- package/dist/config.js +49 -83
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/merge-guard/board-settings.ts +7 -32
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +2 -29
- package/kit/hooks/hooks/plain-question/code-cache.ts +70 -0
- package/kit/hooks/hooks/plain-question/index.ts +80 -0
- package/kit/hooks/hooks/plain-question/run.ts +50 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +19 -0
- package/kit/hooks/hooks/shared/boards.ts +96 -0
- package/kit/hooks/hooks/shared/plan-codes.ts +68 -0
- package/kit/hooks/hooks.json +9 -0
- package/kit/review/skills/diagnose/SKILL.md +2 -2
- package/kit/review/skills/diagnose/references/feedback-loops.md +4 -13
- package/kit/rules/rules/kit/code-style.md +15 -18
- package/kit/rules/rules/kit/testing.md +7 -28
- package/kit/workflow/skills/implement/SKILL.md +3 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +4 -1
- package/kit/workflow/skills/orchestrate/references/events.md +6 -1
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +2 -1
- package/kit/workflow/skills/start/SKILL.md +9 -6
- package/kit/workflow/skills/start/references/missing-commands.md +17 -0
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +38 -11
- package/src/check/command.ts +167 -35
- package/src/check/file-kinds.ts +142 -0
- package/src/check/plan-structure-check.ts +34 -1
- package/src/check/plan.ts +230 -45
- package/src/check/report.ts +111 -3
- package/src/check/result.ts +13 -3
- package/src/check/roots.ts +47 -10
- package/src/check/schema.ts +20 -3
- package/src/check/settings.ts +25 -4
- package/src/check/skill-budget-defaults.ts +7 -4
- package/src/config/base-branch.ts +2 -0
- package/src/config/command.ts +32 -13
- package/src/config/detect.ts +242 -74
- package/src/config/location.ts +6 -0
- package/src/config/repository-facts.ts +32 -7
- package/src/config/repository-values.ts +162 -5
- package/src/config/schema.ts +34 -16
- package/src/lane-prompt/texts.ts +2 -2
- package/src/plan-format/bare-codes.ts +28 -0
- package/src/plans/lane-plans.ts +2 -1
- package/src/sync/command-context.ts +4 -2
- package/src/sync/herdr-plugin.ts +30 -6
- package/src/sync/init.ts +17 -16
- package/src/sync/render.ts +4 -6
- package/src/watch/command.ts +32 -1
- package/src/watch/lane-progress.ts +6 -1
- package/src/watch/lane-report.ts +10 -0
- package/src/watch/plan-missing-read.ts +47 -0
- package/src/watch/plan-missing.ts +43 -0
- package/src/watch/reference-paths.ts +1 -0
- package/src/watch/texts.ts +4 -0
- package/src/watch/watch-events.ts +7 -0
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { CLI_NAME } from '../version/release'
|
|
2
|
+
import {
|
|
3
|
+
type DetectedValues,
|
|
4
|
+
detectedValues,
|
|
5
|
+
frozenInstallCommand,
|
|
6
|
+
type RepositoryFacts,
|
|
7
|
+
STACKS,
|
|
8
|
+
stackOfCommand,
|
|
9
|
+
} from './detect'
|
|
10
|
+
import { PROJECT_CONFIG_PATH, unsetValueText } from './location'
|
|
11
|
+
import { COMMAND_KEYS, type JackherdConfig } from './schema'
|
|
2
12
|
|
|
3
13
|
/** The values each repository decides for itself, by the short name `jh config` and global skills use. */
|
|
4
14
|
export const REPOSITORY_VALUE = {
|
|
@@ -9,6 +19,7 @@ export const REPOSITORY_VALUE = {
|
|
|
9
19
|
install: 'install',
|
|
10
20
|
dev: 'dev',
|
|
11
21
|
lockFile: 'lockFile',
|
|
22
|
+
verifyInstall: 'verifyInstall',
|
|
12
23
|
baseBranch: 'baseBranch',
|
|
13
24
|
} as const
|
|
14
25
|
|
|
@@ -22,6 +33,7 @@ const PLACEHOLDER: Record<RepositoryValue, string> = {
|
|
|
22
33
|
install: 'kit.command.install',
|
|
23
34
|
dev: 'kit.command.dev',
|
|
24
35
|
lockFile: 'kit.lanes.lockFile',
|
|
36
|
+
verifyInstall: 'kit.lanes.verifyInstall',
|
|
25
37
|
baseBranch: 'kit.baseBranch',
|
|
26
38
|
}
|
|
27
39
|
|
|
@@ -34,13 +46,158 @@ export const REPOSITORY_TOKENS: ReadonlyMap<string, string> = new Map(
|
|
|
34
46
|
NAMES.map((name) => [PLACEHOLDER[name], `<${name}>`]),
|
|
35
47
|
)
|
|
36
48
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
49
|
+
const UNSET_PATH: Record<RepositoryValue, string> = {
|
|
50
|
+
test: 'commands.test',
|
|
51
|
+
typecheck: 'commands.typecheck',
|
|
52
|
+
lint: 'commands.lint',
|
|
53
|
+
format: 'commands.format',
|
|
54
|
+
install: 'commands.install',
|
|
55
|
+
dev: 'commands.dev',
|
|
56
|
+
lockFile: 'lanes.lockFile',
|
|
57
|
+
verifyInstall: 'commands.install',
|
|
58
|
+
baseBranch: 'baseBranch',
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What the config (after detection) sets for each value; undefined for one it leaves unset. */
|
|
62
|
+
const rawRepositoryValues = ({
|
|
63
|
+
commands,
|
|
64
|
+
lanes,
|
|
65
|
+
baseBranch,
|
|
66
|
+
}: JackherdConfig): Record<RepositoryValue, string | undefined> => ({
|
|
67
|
+
test: commands.test,
|
|
68
|
+
typecheck: commands.typecheck,
|
|
69
|
+
lint: commands.lint,
|
|
70
|
+
format: commands.format,
|
|
71
|
+
install: commands.install,
|
|
72
|
+
dev: commands.dev,
|
|
73
|
+
lockFile: lanes.lockFile,
|
|
74
|
+
verifyInstall: commands.install === undefined ? undefined : frozenInstallCommand(commands.install),
|
|
75
|
+
baseBranch,
|
|
41
76
|
})
|
|
42
77
|
|
|
78
|
+
/** A value the config leaves unset reads as the text saying where to set it, as in the kit texts. */
|
|
79
|
+
export const repositoryValuesOf = (config: JackherdConfig): Record<RepositoryValue, string> => {
|
|
80
|
+
const raw = rawRepositoryValues(config)
|
|
81
|
+
return Object.fromEntries(NAMES.map((name) => [name, raw[name] ?? unsetValueText(UNSET_PATH[name])])) as Record<
|
|
82
|
+
RepositoryValue,
|
|
83
|
+
string
|
|
84
|
+
>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Whether the config (after detection) decides `name`; `repositoryValuesOf` words an unset one as where to set it. */
|
|
88
|
+
export const isRepositoryValueSet = (config: JackherdConfig, name: RepositoryValue): boolean =>
|
|
89
|
+
rawRepositoryValues(config)[name] !== undefined
|
|
90
|
+
|
|
91
|
+
/** Every kit placeholder a repository decides, as `[placeholder, value]` with `repositoryValuesOf`'s values. */
|
|
92
|
+
export const repositoryPlaceholderValues = (config: JackherdConfig): [string, string][] => {
|
|
93
|
+
const values = repositoryValuesOf(config)
|
|
94
|
+
return NAMES.map((name) => [PLACEHOLDER[name], values[name]])
|
|
95
|
+
}
|
|
96
|
+
|
|
43
97
|
export const formatRepositoryValues = (values: Record<RepositoryValue, string>): string[] => {
|
|
44
98
|
const width = Math.max(...NAMES.map((name) => name.length)) + 2
|
|
45
99
|
return NAMES.map((name) => `${name.padEnd(width)}${values[name]}`)
|
|
46
100
|
}
|
|
101
|
+
|
|
102
|
+
const FINDING_SECTION = { commands: 'commands', lanes: 'lanes', check: 'check' } as const
|
|
103
|
+
|
|
104
|
+
type FindingSection = (typeof FINDING_SECTION)[keyof typeof FINDING_SECTION]
|
|
105
|
+
|
|
106
|
+
type FindingSubject = {
|
|
107
|
+
section: FindingSection
|
|
108
|
+
key: string
|
|
109
|
+
value: string | undefined
|
|
110
|
+
/** What the repository's stack would set here; undefined where it has no tool for it. */
|
|
111
|
+
detected: string | undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const findingSubjects = ({ commands, lanes, check }: JackherdConfig, facts: RepositoryFacts): FindingSubject[] => {
|
|
115
|
+
const detected: DetectedValues = detectedValues(facts)
|
|
116
|
+
return [
|
|
117
|
+
...COMMAND_KEYS.map((key) => ({
|
|
118
|
+
section: FINDING_SECTION.commands,
|
|
119
|
+
key,
|
|
120
|
+
value: commands[key],
|
|
121
|
+
detected: detected.commands[key],
|
|
122
|
+
})),
|
|
123
|
+
{
|
|
124
|
+
section: FINDING_SECTION.lanes,
|
|
125
|
+
key: 'lockFile',
|
|
126
|
+
value: lanes.lockFile,
|
|
127
|
+
detected: detected.lanes.lockFile,
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
section: FINDING_SECTION.check,
|
|
131
|
+
key: 'lintFilesCommand',
|
|
132
|
+
value: check?.lintFilesCommand,
|
|
133
|
+
detected: detected.check.lintFilesCommand,
|
|
134
|
+
},
|
|
135
|
+
]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The line to copy into the config: what the stack detected in the repository would set for `subject`. */
|
|
139
|
+
const copyableLine = ({ section, key, detected }: FindingSubject): string => {
|
|
140
|
+
const scope = `under "${section}" in ${PROJECT_CONFIG_PATH}`
|
|
141
|
+
return detected === undefined
|
|
142
|
+
? `Set ${JSON.stringify(key)} ${scope} yourself.`
|
|
143
|
+
: `Set ${JSON.stringify(key)}: ${JSON.stringify(detected)} ${scope}.`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A lock file of a known stack that the repository does not have; null for any other. */
|
|
147
|
+
const lockFileContradiction = (lockFile: string, facts: RepositoryFacts): string | null => {
|
|
148
|
+
const known = Object.values(STACKS).some((stack) => stack.lockFiles.includes(lockFile))
|
|
149
|
+
return known && !facts.lockFiles.includes(lockFile) ? `${lockFile} does not exist` : null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A command of a known stack whose lock files the repository lacks; null for another command and in a repository without any lock file, which has not installed yet. */
|
|
153
|
+
const commandContradiction = (command: string, facts: RepositoryFacts): string | null => {
|
|
154
|
+
const stack = stackOfCommand(command)
|
|
155
|
+
if (stack === undefined || facts.lockFiles.length === 0) return null
|
|
156
|
+
const { lockFiles } = STACKS[stack]
|
|
157
|
+
return lockFiles.some((file) => facts.lockFiles.includes(file))
|
|
158
|
+
? null
|
|
159
|
+
: `\`${command}\` needs ${lockFiles.join(' or ')}, which is missing`
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const contradiction = ({ section, value }: FindingSubject, facts: RepositoryFacts): string | null => {
|
|
163
|
+
if (value === undefined) return null
|
|
164
|
+
return section === FINDING_SECTION.lanes ? lockFileContradiction(value, facts) : commandContradiction(value, facts)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const findingOf = (subject: FindingSubject, facts: RepositoryFacts): string | null => {
|
|
168
|
+
const path = `${subject.section}.${subject.key}`
|
|
169
|
+
if (subject.value === undefined) return `${CLI_NAME}: ${path} is missing. ${copyableLine(subject)}`
|
|
170
|
+
const reason = contradiction(subject, facts)
|
|
171
|
+
return reason === null ? null : `${CLI_NAME}: ${path} contradicts the repository: ${reason}. ${copyableLine(subject)}`
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* One line for each command, `lanes.lockFile` and `check.lintFilesCommand` that is missing or that a package
|
|
176
|
+
* manager's lock file contradicts (a `bun` command beside only `composer.lock`; a known lock file that does not
|
|
177
|
+
* exist), each with the config line the repository's stack would set, or the note to set it by hand. A command of no
|
|
178
|
+
* known manager and a lock file of no known stack are the project's own and stay unchecked; so does every command
|
|
179
|
+
* in a repository without any lock file, which has not installed yet.
|
|
180
|
+
*/
|
|
181
|
+
export const commandFindings = (config: JackherdConfig, facts: RepositoryFacts): string[] =>
|
|
182
|
+
findingSubjects(config, facts).flatMap((subject) => findingOf(subject, facts) ?? [])
|
|
183
|
+
|
|
184
|
+
/** The config key of the finding about `name`; `baseBranch` has none. */
|
|
185
|
+
const FINDING_KEY: Record<RepositoryValue, { section: FindingSection; key: string } | null> = {
|
|
186
|
+
test: { section: FINDING_SECTION.commands, key: 'test' },
|
|
187
|
+
typecheck: { section: FINDING_SECTION.commands, key: 'typecheck' },
|
|
188
|
+
lint: { section: FINDING_SECTION.commands, key: 'lint' },
|
|
189
|
+
format: { section: FINDING_SECTION.commands, key: 'format' },
|
|
190
|
+
install: { section: FINDING_SECTION.commands, key: 'install' },
|
|
191
|
+
dev: { section: FINDING_SECTION.commands, key: 'dev' },
|
|
192
|
+
lockFile: { section: FINDING_SECTION.lanes, key: 'lockFile' },
|
|
193
|
+
verifyInstall: { section: FINDING_SECTION.commands, key: 'install' },
|
|
194
|
+
baseBranch: null,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The findings of `commandFindings` that are about `name` alone. */
|
|
198
|
+
export const valueFindings = (config: JackherdConfig, facts: RepositoryFacts, name: RepositoryValue): string[] => {
|
|
199
|
+
const wanted = FINDING_KEY[name]
|
|
200
|
+
return findingSubjects(config, facts)
|
|
201
|
+
.filter(({ section, key }) => section === wanted?.section && key === wanted.key)
|
|
202
|
+
.flatMap((subject) => findingOf(subject, facts) ?? [])
|
|
203
|
+
}
|
package/src/config/schema.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { CONTENT_FIELDS, SOURCE_FIELD } from '../watch/source-fields'
|
|
|
6
6
|
import { isSourcePath } from '../watch/source-path'
|
|
7
7
|
import { BUN_FAILED_TEST_PATTERN, CLEAN_REVIEW_TEXT } from '../watch/watch-defaults'
|
|
8
8
|
import { MAX_AUTO_WINDOW_DAYS, windowDays } from './auto-window'
|
|
9
|
-
import {
|
|
9
|
+
import { DEFAULT_BASE_BRANCH } from './base-branch'
|
|
10
10
|
import { DEFAULT_LINK_KINDS, isRegularExpression } from './link-kinds'
|
|
11
11
|
import { hasNestedQuantifier } from './nested-quantifier'
|
|
12
12
|
import { isRepositoryDirectory } from './repository-directory'
|
|
@@ -28,6 +28,15 @@ export const MODEL_ROLE = {
|
|
|
28
28
|
|
|
29
29
|
export type ModelRole = (typeof MODEL_ROLE)[keyof typeof MODEL_ROLE]
|
|
30
30
|
|
|
31
|
+
/** The model of each role when the config names none: a new project's, and one written by hand without `models`. */
|
|
32
|
+
export const DEFAULT_MODELS: Record<ModelRole, string> = {
|
|
33
|
+
[MODEL_ROLE.plan]: 'claude-opus-5-5',
|
|
34
|
+
[MODEL_ROLE.implement]: 'claude-sonnet-5-5',
|
|
35
|
+
[MODEL_ROLE.review]: 'claude-sonnet-5-5',
|
|
36
|
+
[MODEL_ROLE.explore]: 'claude-sonnet-5-5',
|
|
37
|
+
[MODEL_ROLE.orchestrate]: 'claude-opus-5-5',
|
|
38
|
+
}
|
|
39
|
+
|
|
31
40
|
export const isTerminalAdapter = (value: string): value is TerminalAdapter =>
|
|
32
41
|
(Object.values(TERMINAL_ADAPTER) as string[]).includes(value)
|
|
33
42
|
|
|
@@ -49,6 +58,26 @@ const command = z.string().min(1)
|
|
|
49
58
|
|
|
50
59
|
const tokenCount = z.number().int().positive()
|
|
51
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The project's own commands. Each is optional: detection fills what the repository's stack decides, and a command
|
|
63
|
+
* nobody set stays out instead of a guessed one (a kit text then says where to set it).
|
|
64
|
+
*/
|
|
65
|
+
const commandsSchema = z.strictObject({
|
|
66
|
+
test: command.optional(),
|
|
67
|
+
typecheck: command.optional(),
|
|
68
|
+
lint: command.optional(),
|
|
69
|
+
format: command.optional(),
|
|
70
|
+
/** Installs a new lane's dependencies. */
|
|
71
|
+
install: command.optional(),
|
|
72
|
+
/** Starts a lane's dev server, run as `PORT=<port> <dev>`; a project supplement may name another. */
|
|
73
|
+
dev: command.optional(),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
export type CommandKey = keyof z.infer<typeof commandsSchema>
|
|
77
|
+
|
|
78
|
+
/** Every key of the `commands` block, as the schema names them. */
|
|
79
|
+
export const COMMAND_KEYS = Object.keys(commandsSchema.shape) as CommandKey[]
|
|
80
|
+
|
|
52
81
|
/** The window `plans.context.defaultWindow` assumes when the config leaves it out. */
|
|
53
82
|
export const DEFAULT_CONTEXT_WINDOW = 200_000
|
|
54
83
|
|
|
@@ -450,28 +479,17 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
450
479
|
skillPrefix: z
|
|
451
480
|
.union([z.literal(''), z.string().regex(SKILL_PREFIX, 'lower-case letters and digits ending in a dash, e.g. kit-')])
|
|
452
481
|
.default(''),
|
|
453
|
-
commands:
|
|
454
|
-
.strictObject({
|
|
455
|
-
test: command.default(BUN_COMMANDS.test),
|
|
456
|
-
typecheck: command.default(BUN_COMMANDS.typecheck),
|
|
457
|
-
lint: command.default(BUN_COMMANDS.lint),
|
|
458
|
-
format: command.default(BUN_COMMANDS.format),
|
|
459
|
-
/** Installs a new lane's dependencies. */
|
|
460
|
-
install: command.default(BUN_COMMANDS.install),
|
|
461
|
-
/** Starts a lane's dev server, run as `PORT=<port> <dev>`; a project supplement may name another. */
|
|
462
|
-
dev: command.default(BUN_COMMANDS.dev),
|
|
463
|
-
})
|
|
464
|
-
.prefault({}),
|
|
482
|
+
commands: commandsSchema.prefault({}),
|
|
465
483
|
/** How `/orchestrate` sets up a lane's worktree. */
|
|
466
484
|
lanes: z
|
|
467
485
|
.strictObject({
|
|
468
486
|
/** Untracked files copied into every new worktree; empty means the files `.worktreeinclude` lists. */
|
|
469
487
|
copy: z.array(laneCopyPath).default([]),
|
|
470
|
-
/** The dependency lock file a rebase takes from the base branch and regenerates, never merges by hand. */
|
|
471
|
-
lockFile: lockFilePath.
|
|
488
|
+
/** The dependency lock file a rebase takes from the base branch and regenerates, never merges by hand; detected from the repository when left out. */
|
|
489
|
+
lockFile: lockFilePath.optional(),
|
|
472
490
|
})
|
|
473
491
|
.prefault({}),
|
|
474
|
-
models: z.record(z.enum(Object.values(MODEL_ROLE)), modelId),
|
|
492
|
+
models: z.record(z.enum(Object.values(MODEL_ROLE)), modelId).default(DEFAULT_MODELS),
|
|
475
493
|
terminal: z
|
|
476
494
|
.strictObject({ adapter: z.enum(Object.values(TERMINAL_ADAPTER)).default(TERMINAL_ADAPTER.none) })
|
|
477
495
|
.prefault({}),
|
package/src/lane-prompt/texts.ts
CHANGED
|
@@ -50,7 +50,7 @@ export const LANE_PROMPT_TEXTS: Record<PlanLanguage, LanePromptTexts> = {
|
|
|
50
50
|
report: (reportPath) =>
|
|
51
51
|
`The report at the end of each step goes to \`${reportPath}\` in the worktree, in the short form: one line of diff stat, one line per check with its numbers, open points, questions as a text block under the heading \`## Questions\` (whatever the report’s language: the watcher finds the section by that word and shows it whole); everything else is in the plan, by reference.`,
|
|
52
52
|
textBlock:
|
|
53
|
-
'Every question to the coordinator is a text block at the end of your turn, never a dialog (`AskUserQuestion`), also where a skill or a project rule asks for one, the commit confirmation of `/pre-commit` included: the question with the situation, the problem and what each option leads to, in everyday words (the user may not know your terms), the options, your recommendation, the evidence at `file:line`.',
|
|
53
|
+
'Every question to the coordinator is a text block at the end of your turn, never a dialog (`AskUserQuestion`), also where a skill or a project rule asks for one, the commit confirmation of `/pre-commit` included: the question with the situation, the problem and what each option leads to, in everyday words (the user may not know your terms; a plan code like `P7` appears only in brackets after the words), the options, your recommendation, the evidence at `file:line`.',
|
|
54
54
|
secret:
|
|
55
55
|
'A value only the user knows (a secret, password, token or one-time code) never passes through you, your arguments, your output or a file you write: ask the coordinator for the step as a question.',
|
|
56
56
|
askRule:
|
|
@@ -84,7 +84,7 @@ export const LANE_PROMPT_TEXTS: Record<PlanLanguage, LanePromptTexts> = {
|
|
|
84
84
|
report: (reportPath) =>
|
|
85
85
|
`Den Bericht am Ende jedes Schritts schreibst du nach \`${reportPath}\` im Worktree, in der Kurzform: eine Zeile Diff-Stat, eine Zeile je Check mit seinen Zahlen, offene Punkte, Fragen als Textblock unter der Überschrift \`## Questions\` (in jeder Berichtssprache: der Watcher findet den Abschnitt an diesem Wort und zeigt ihn ganz); alles andere steht im Plan, per Verweis.`,
|
|
86
86
|
textBlock:
|
|
87
|
-
'Jede Frage an den Koordinator ist ein Textblock am Ende deines Turns, nie ein Dialog (`AskUserQuestion`), auch wo ein Skill oder eine Projektregel einen verlangt, die Commit-Bestätigung von `/pre-commit` eingeschlossen: die Frage mit der Lage, dem Problem und wohin jede Option führt, in Alltagsworten (der User kennt deine Begriffe vielleicht nicht), die Optionen, deine Empfehlung, der Beleg als `datei:zeile`.',
|
|
87
|
+
'Jede Frage an den Koordinator ist ein Textblock am Ende deines Turns, nie ein Dialog (`AskUserQuestion`), auch wo ein Skill oder eine Projektregel einen verlangt, die Commit-Bestätigung von `/pre-commit` eingeschlossen: die Frage mit der Lage, dem Problem und wohin jede Option führt, in Alltagsworten (der User kennt deine Begriffe vielleicht nicht; ein Plan-Kürzel wie `P7` nur in Klammern hinter den Worten), die Optionen, deine Empfehlung, der Beleg als `datei:zeile`.',
|
|
88
88
|
secret:
|
|
89
89
|
'Ein Wert, den nur der User kennt (ein Geheimnis, Passwort, Token oder Einmalcode), läuft nie über dich, deine Argumente, deine Ausgabe oder eine Datei, die du schreibst: bitte den Koordinator mit einer Frage um diesen Schritt.',
|
|
90
90
|
askRule:
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { bareCodesOf } from '../../kit/hooks/hooks/shared/plan-codes'
|
|
2
|
+
import { type PlanLanguage, WAITING_VOCABULARY } from '../vocabulary'
|
|
3
|
+
import { firstTableRows, firstTableWithHeader, sectionLines, sectionStart } from './markdown'
|
|
4
|
+
|
|
5
|
+
/** A changed row of the waiting section (1-based line) whose item cell names `code` bare, outside brackets. */
|
|
6
|
+
export type RowWithBareCode = { line: number; code: string }
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The changed rows under the board's waiting section whose item cell names a plan code bare (the first one per row):
|
|
10
|
+
* the user reads the words, the code stands at most in brackets after them. Rows the previous version had word for
|
|
11
|
+
* word stay. `codes` are the codes the board's plans define, or null to use the fallback pattern.
|
|
12
|
+
*/
|
|
13
|
+
export const boardRowsWithBareCodes = (
|
|
14
|
+
markdown: string,
|
|
15
|
+
language: PlanLanguage,
|
|
16
|
+
previousLines: ReadonlySet<string>,
|
|
17
|
+
codes: ReadonlySet<string> | null,
|
|
18
|
+
): RowWithBareCode[] => {
|
|
19
|
+
const { heading, columns } = WAITING_VOCABULARY[language]
|
|
20
|
+
const start = sectionStart(markdown, heading)
|
|
21
|
+
const lines = sectionLines(markdown, heading)
|
|
22
|
+
const item = firstTableWithHeader(lines).header.indexOf(columns.item)
|
|
23
|
+
if (item === -1) return []
|
|
24
|
+
return firstTableRows(lines).flatMap(({ index, line, cells }) => {
|
|
25
|
+
const code = previousLines.has(line) ? undefined : bareCodesOf(cells[item] ?? '', codes)[0]
|
|
26
|
+
return code === undefined ? [] : [{ line: start + index + 1, code }]
|
|
27
|
+
})
|
|
28
|
+
}
|
package/src/plans/lane-plans.ts
CHANGED
|
@@ -27,7 +27,8 @@ export type PlanSources = {
|
|
|
27
27
|
parsed?: PlanRound
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
/** A repository path that a link resolved above the repository root. */
|
|
31
|
+
export const isOutsideRepository = (path: string): boolean => path.startsWith('..')
|
|
31
32
|
|
|
32
33
|
/** The file as the base ref has it; null when the ref lacks it or the path leaves the repository. */
|
|
33
34
|
export const textAtBase = (base: BaseReader, path: string): string | null =>
|
|
@@ -20,7 +20,7 @@ export type CommandContext = {
|
|
|
20
20
|
locate: () => Promise<KitLocation>
|
|
21
21
|
log: (line: string) => void
|
|
22
22
|
/** Hooks the herdr plugin in and returns the line to print, if any. Must not throw: a failure is a line, not an error. */
|
|
23
|
-
linkHerdrPlugin: (packageRoot: string) => string | null
|
|
23
|
+
linkHerdrPlugin: (roots: { packageRoot: string; projectRoot: string }) => string | null
|
|
24
24
|
/** A yes/no question to the user; null when there is no terminal to ask in. */
|
|
25
25
|
ask: (question: string) => Promise<boolean | null>
|
|
26
26
|
/** A choice among fixed answers; Enter takes `fallback`; null when there is no terminal to ask in. */
|
|
@@ -99,7 +99,9 @@ export const runWithKit = async (
|
|
|
99
99
|
try {
|
|
100
100
|
const location = await context.locate()
|
|
101
101
|
const { notes, result } = await action(await readKitSource(location))
|
|
102
|
-
const herdrNote = dryRun
|
|
102
|
+
const herdrNote = dryRun
|
|
103
|
+
? null
|
|
104
|
+
: context.linkHerdrPlugin({ packageRoot: location.packageRoot, projectRoot: context.projectRoot })
|
|
103
105
|
for (const note of herdrNote ? [...notes, herdrNote] : notes) context.log(note)
|
|
104
106
|
context.log(formatReport(result))
|
|
105
107
|
return dryRun ? driftExitCodeOf(result) : exitCodeOf(result)
|
package/src/sync/herdr-plugin.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { realpathSync } from 'node:fs'
|
|
2
2
|
import { join, resolve } from 'node:path'
|
|
3
|
-
import { isLinkedWorktree } from '../plans/git'
|
|
3
|
+
import { isLinkedWorktree, mainCheckout } from '../plans/git'
|
|
4
4
|
import { herdrBinary, spawnTool, type ToolRun } from './tools'
|
|
5
5
|
|
|
6
6
|
export const HERDR_PLUGIN_ID = 'crossdelta.jackherd'
|
|
@@ -115,7 +115,8 @@ const joinLines = (...lines: (string | null)[]): string | null => {
|
|
|
115
115
|
* Hooks the kit's herdr plugin into herdr when `init` or `sync` runs inside herdr (`HERDR_ENV=1`), and links it
|
|
116
116
|
* again when herdr's link points at another directory or herdr cannot read its manifest (`herdr plugin link`
|
|
117
117
|
* replaces a plugin of the same id). What each copy may link:
|
|
118
|
-
* - a checkout takes over any link
|
|
118
|
+
* - a checkout takes over any link while it runs for its own repository (a worktree of it included), so a repository
|
|
119
|
+
* that develops the kit keeps its link; run for another repository it acts like an installed package;
|
|
119
120
|
* - an installed package only replaces a missing or unreadable link and leaves a working one standing, also one to
|
|
120
121
|
* another installed package: otherwise the global package and a project's dependency would take it from each other;
|
|
121
122
|
* - a lane's worktree never links, since its path goes away with the lane, and only names a broken link.
|
|
@@ -160,16 +161,39 @@ const realPathOf = (path: string): string => {
|
|
|
160
161
|
}
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
/** The repository a path belongs to (its main checkout, also from a linked worktree); null outside git. */
|
|
165
|
+
const repositoryOf = (path: string): string | null => {
|
|
166
|
+
try {
|
|
167
|
+
return mainCheckout(path)
|
|
168
|
+
} catch {
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A checkout takes over only at home: when the project lies in the checkout's own repository. For another repository
|
|
175
|
+
* (or a project outside git) it acts like an installed package, so `init` there does not move the link.
|
|
176
|
+
*/
|
|
177
|
+
export const checkoutCopyFor = (projectRepository: string | null, packageRepository: string | null): PackageCopy =>
|
|
178
|
+
packageRepository === null || projectRepository === packageRepository ? PACKAGE_COPY.checkout : PACKAGE_COPY.installed
|
|
179
|
+
|
|
163
180
|
/** A lane's worktree first, so a package installed into one never links either; a workspace link resolves to its checkout. */
|
|
164
|
-
const packageCopyOf = (packageRoot: string): PackageCopy => {
|
|
181
|
+
const packageCopyOf = (packageRoot: string, projectRoot: string): PackageCopy => {
|
|
165
182
|
if (isLinkedWorktree(packageRoot)) return PACKAGE_COPY.laneWorktree
|
|
166
|
-
|
|
183
|
+
if (isInstalledCopy(realPathOf(packageRoot))) return PACKAGE_COPY.installed
|
|
184
|
+
return checkoutCopyFor(repositoryOf(projectRoot), repositoryOf(packageRoot))
|
|
167
185
|
}
|
|
168
186
|
|
|
169
|
-
export const linkHerdrPluginFrom = (
|
|
187
|
+
export const linkHerdrPluginFrom = ({
|
|
188
|
+
packageRoot,
|
|
189
|
+
projectRoot,
|
|
190
|
+
}: {
|
|
191
|
+
packageRoot: string
|
|
192
|
+
projectRoot: string
|
|
193
|
+
}): string | null =>
|
|
170
194
|
linkHerdrPlugin({
|
|
171
195
|
packageRoot,
|
|
172
196
|
env: process.env,
|
|
173
197
|
run: spawnHerdr(process.env),
|
|
174
|
-
copy: packageCopyOf(packageRoot),
|
|
198
|
+
copy: packageCopyOf(packageRoot, projectRoot),
|
|
175
199
|
})
|
package/src/sync/init.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { homedir } from 'node:os'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { AGENTS_DIRECTORY, SKILLS_DIRECTORY } from '../claude/paths'
|
|
4
|
-
import { type JackherdConfig,
|
|
5
|
-
import { type RepositoryFacts, withDetected } from '../config/detect'
|
|
4
|
+
import { type JackherdConfig, parseJackherdConfig, type TerminalAdapter } from '../config'
|
|
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
8
|
import type { KitSource } from './kit'
|
|
@@ -35,16 +35,6 @@ export type InitResult = {
|
|
|
35
35
|
terminalAdapter: TerminalAdapter
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
// TODO: default model per role for a fresh project; the role list
|
|
39
|
-
// itself is still a draft in config/schema.ts.
|
|
40
|
-
export const DEFAULT_MODELS: Record<ModelRole, string> = {
|
|
41
|
-
[MODEL_ROLE.plan]: 'claude-opus-5-5',
|
|
42
|
-
[MODEL_ROLE.implement]: 'claude-sonnet-5-5',
|
|
43
|
-
[MODEL_ROLE.review]: 'claude-sonnet-5-5',
|
|
44
|
-
[MODEL_ROLE.explore]: 'claude-sonnet-5-5',
|
|
45
|
-
[MODEL_ROLE.orchestrate]: 'claude-opus-5-5',
|
|
46
|
-
}
|
|
47
|
-
|
|
48
38
|
/** What a global install, and a project that already has skills of kit names, puts before every kit name. */
|
|
49
39
|
export const DEFAULT_SKILL_PREFIX = 'jh-'
|
|
50
40
|
|
|
@@ -64,13 +54,24 @@ export const collidingNames = (kit: KitSource, exists: (path: string) => boolean
|
|
|
64
54
|
return agent ? [`${agent[1]} agent`] : []
|
|
65
55
|
})
|
|
66
56
|
|
|
57
|
+
/** Whether the repository holds the lock file of a stack that installs into `node_modules`, where `$schema` points. */
|
|
58
|
+
const hasNodeLockFile = (facts: RepositoryFacts): boolean =>
|
|
59
|
+
Object.values(STACKS).some(
|
|
60
|
+
({ installsIntoNodeModules, lockFiles }) =>
|
|
61
|
+
installsIntoNodeModules && lockFiles.some((file) => facts.lockFiles.includes(file)),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
/** The editor's `$schema` pointer, only where its target can exist; elsewhere it would point at nothing. */
|
|
65
|
+
const schemaReference = (facts: RepositoryFacts): { $schema?: string } =>
|
|
66
|
+
hasNodeLockFile(facts) ? { $schema: CONFIG_SCHEMA_REFERENCE } : {}
|
|
67
|
+
|
|
67
68
|
/**
|
|
68
69
|
* The default config with every default spelled out, so the project sees what it can change; commands, lock file
|
|
69
|
-
* and base branch as detected in the project.
|
|
70
|
+
* and base branch as detected in the project. A command detection could not decide stays out, never guessed.
|
|
70
71
|
*/
|
|
71
72
|
const defaultConfigText = (facts: RepositoryFacts, skillPrefix: string, setup: SetupConfig | undefined): string => {
|
|
72
|
-
const config = parseJackherdConfig(withDetected({
|
|
73
|
-
return `${JSON.stringify({
|
|
73
|
+
const config = parseJackherdConfig(withDetected({ skillPrefix, ...setup }, facts).raw)
|
|
74
|
+
return `${JSON.stringify({ ...schemaReference(facts), ...config }, null, 2)}\n`
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
/** What each repository decides for itself; a global config that spelled them out would set them for all. */
|
|
@@ -84,7 +85,7 @@ const withoutRepositoryValues = ({ baseBranch: _base, commands: _commands, lanes
|
|
|
84
85
|
* lock file or base branch, so every repository gets its own detected ones.
|
|
85
86
|
*/
|
|
86
87
|
const defaultGlobalConfigText = (setup: SetupConfig | undefined): string => {
|
|
87
|
-
const config = parseJackherdConfig({
|
|
88
|
+
const config = parseJackherdConfig({ skillPrefix: DEFAULT_SKILL_PREFIX, ...setup })
|
|
88
89
|
return `${JSON.stringify(withoutRepositoryValues(config), null, 2)}\n`
|
|
89
90
|
}
|
|
90
91
|
|
package/src/sync/render.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { JackherdConfig } from '../config'
|
|
2
|
-
import {
|
|
2
|
+
import { repositoryPlaceholderValues } from '../config/repository-values'
|
|
3
3
|
import {
|
|
4
4
|
ADDRESS_FORM,
|
|
5
5
|
APPROVAL_TEXTS,
|
|
@@ -35,13 +35,10 @@ const placeholderValues = (config: JackherdConfig): Map<string, string> => {
|
|
|
35
35
|
['kit.responseAddress', ADDRESS_FORM[config.responseLanguage][config.responseAddress]],
|
|
36
36
|
['kit.planLanguage', LANGUAGE_NAME[config.planLanguage]],
|
|
37
37
|
['kit.planDirectory', config.planDirectory],
|
|
38
|
-
['kit.baseBranch', config.baseBranch],
|
|
39
38
|
['kit.worktreeDirectory', config.worktreeDirectory],
|
|
40
39
|
...prefixed('model', config.models),
|
|
41
|
-
...
|
|
40
|
+
...repositoryPlaceholderValues(config),
|
|
42
41
|
['kit.lanes.copyCommand', laneCopyCommand(config.lanes.copy)],
|
|
43
|
-
['kit.lanes.lockFile', config.lanes.lockFile],
|
|
44
|
-
['kit.lanes.verifyInstall', frozenInstallCommand(config.commands.install)],
|
|
45
42
|
['kit.word.laneClosed', laneClosed],
|
|
46
43
|
['kit.word.openSubagents', openSubagents],
|
|
47
44
|
...prefixed('board', config.board),
|
|
@@ -62,7 +59,8 @@ export const listPlaceholders = (template: string): string[] =>
|
|
|
62
59
|
* Fills the kit placeholders from the project config: `{{kit.model.<role>}}`,
|
|
63
60
|
* `{{kit.command.<test|typecheck|lint|format|install|dev>}}`, `{{kit.lanes.<copyCommand|lockFile|verifyInstall>}}`, `{{kit.word.<laneClosed|openSubagents>}}`,
|
|
64
61
|
* `{{kit.board.<pattern|file|term>}}`, `{{kit.guard.<selfMerge|selfMergeLabel>}}`, `{{kit.guard.standingApproval.<approve|until>}}`, `{{kit.merge.withoutChecks}}`, `{{kit.heading.<key>}}` from the plan
|
|
65
|
-
* vocabulary, the two languages, `{{kit.responseAddress}}`, the plan and worktree directory and the base branch.
|
|
62
|
+
* vocabulary, the two languages, `{{kit.responseAddress}}`, the plan and worktree directory and the base branch. A
|
|
63
|
+
* command or lock file the config leaves unset renders as `unsetValueText`, never as a guessed default. Other
|
|
66
64
|
* `{{…}}` text is left alone; an unknown `kit.` placeholder throws rather than reaching a project
|
|
67
65
|
* file unrendered. `overrides` win over the config: a global kit renders what each repository decides as
|
|
68
66
|
* `<name>` (`REPOSITORY_TOKENS`).
|
package/src/watch/command.ts
CHANGED
|
@@ -113,6 +113,7 @@ import {
|
|
|
113
113
|
noTaskSignalDutyLines,
|
|
114
114
|
otherPullRequestsLine,
|
|
115
115
|
parallelDutyLines,
|
|
116
|
+
planMissingDutyLines,
|
|
116
117
|
sourceEventLines,
|
|
117
118
|
sourceFailingStatusLine,
|
|
118
119
|
watchEventLine,
|
|
@@ -136,6 +137,8 @@ import {
|
|
|
136
137
|
import { mainCheckoutDutyLine } from './main-checkout'
|
|
137
138
|
import { monitorToolAvailable } from './monitor-tool'
|
|
138
139
|
import { parseWatchOptions, type WatchOptions } from './options'
|
|
140
|
+
import type { PlanMissingEvent, PlanMissingState } from './plan-missing'
|
|
141
|
+
import { readPlanMissingEvents } from './plan-missing-read'
|
|
139
142
|
import {
|
|
140
143
|
ENSURE_TRIGGER,
|
|
141
144
|
type EnsureTrigger,
|
|
@@ -311,6 +314,8 @@ type Watching = {
|
|
|
311
314
|
laneStalls: StallState
|
|
312
315
|
/** The open artifact sends in their grace or already reported as `unlinked artifact`; persisted like the fingerprints. */
|
|
313
316
|
unlinkedArtifacts: UnlinkedState
|
|
317
|
+
/** Per working lane, the plan last reported as `plan missing on base`; persisted like the fingerprints. */
|
|
318
|
+
planMissing: PlanMissingState
|
|
314
319
|
/** The approval requests of the last poll; null before the first read, so the stored ids stay. */
|
|
315
320
|
approvalRequests: ApprovalRequestFile[] | null
|
|
316
321
|
/** The board's appointment table at the last poll and the time it was read at; null before the first read and while the board cannot be read. */
|
|
@@ -365,6 +370,7 @@ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
|
|
|
365
370
|
noTaskSignals: watching.noTaskSignals,
|
|
366
371
|
laneStalls: watching.laneStalls,
|
|
367
372
|
unlinkedArtifacts: watching.unlinkedArtifacts,
|
|
373
|
+
planMissing: watching.planMissing,
|
|
368
374
|
approvals: watching.approvalRequests ? recordApprovals(watching.approvalRequests) : withReads.approvals,
|
|
369
375
|
sources: recordWatchedSources(withReads.sources, watching.sourceRuns, watch.sources, watching.githubReadAt),
|
|
370
376
|
appointments: watching.appointments
|
|
@@ -665,6 +671,7 @@ const reportOf = async (
|
|
|
665
671
|
...laneModelDutyLines(events, watch.texts),
|
|
666
672
|
...parallelDutyLines(events, watch.texts),
|
|
667
673
|
...noTaskSignalDutyLines(events, watch.texts),
|
|
674
|
+
...planMissingDutyLines(events, watch.texts),
|
|
668
675
|
...approvalDutyLines(events, watch.texts),
|
|
669
676
|
]
|
|
670
677
|
const status = await statusLinesOf(watch, watching, next)
|
|
@@ -1129,6 +1136,27 @@ const unlinkedArtifactEventsOf = (watch: Watch, watching: Watching): UnlinkedArt
|
|
|
1129
1136
|
}
|
|
1130
1137
|
}
|
|
1131
1138
|
|
|
1139
|
+
/**
|
|
1140
|
+
* This heartbeat's `plan missing on base` events: the working lanes whose board row, as their worktree has it, links a
|
|
1141
|
+
* plan the base ref lacks, each once; records the state in `watching`. Nothing when the boards or git cannot be read:
|
|
1142
|
+
* the next heartbeat reads again.
|
|
1143
|
+
*/
|
|
1144
|
+
const planMissingEventsOf = (watch: Watch, watching: Watching): PlanMissingEvent[] => {
|
|
1145
|
+
try {
|
|
1146
|
+
const { events, state } = readPlanMissingEvents(
|
|
1147
|
+
watch.root,
|
|
1148
|
+
watch.settings,
|
|
1149
|
+
watch.sprintFile,
|
|
1150
|
+
workingLanes(watching),
|
|
1151
|
+
watching.planMissing,
|
|
1152
|
+
)
|
|
1153
|
+
watching.planMissing = state
|
|
1154
|
+
return events
|
|
1155
|
+
} catch {
|
|
1156
|
+
return []
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1132
1160
|
/** The progress of the working lanes, with the pull requests of the last GitHub read; nothing while no lane works. */
|
|
1133
1161
|
const workingProgressOf = async (watch: Watch, watching: Watching): Promise<Record<string, LaneProgress>> => {
|
|
1134
1162
|
const working = workingLanes(watching)
|
|
@@ -1137,13 +1165,14 @@ const workingProgressOf = async (watch: Watch, watching: Watching): Promise<Reco
|
|
|
1137
1165
|
return progress
|
|
1138
1166
|
}
|
|
1139
1167
|
|
|
1140
|
-
/** The events a heartbeat of a report brings: the parallel-task proposals, the lanes without a task signal, the unlinked artifacts. */
|
|
1168
|
+
/** The events a heartbeat of a report brings: the parallel-task proposals, the lanes without a task signal, the unlinked artifacts, the plans missing on the base. */
|
|
1141
1169
|
const heartbeatEventsOf = async (watch: Watch, watching: Watching, now: number): Promise<WatchEvent[]> => {
|
|
1142
1170
|
const progress = await workingProgressOf(watch, watching)
|
|
1143
1171
|
return [
|
|
1144
1172
|
...taskParallelEventsOf(watch, watching, now),
|
|
1145
1173
|
...noTaskSignalEventsOf(watch, watching, progress, now),
|
|
1146
1174
|
...unlinkedArtifactEventsOf(watch, watching),
|
|
1175
|
+
...planMissingEventsOf(watch, watching),
|
|
1147
1176
|
]
|
|
1148
1177
|
}
|
|
1149
1178
|
|
|
@@ -1206,6 +1235,7 @@ const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: numbe
|
|
|
1206
1235
|
...taskParallelEventsOf(watch, watching, now),
|
|
1207
1236
|
...noTaskSignalEventsOf(watch, watching, progress, now),
|
|
1208
1237
|
...unlinkedArtifactEventsOf(watch, watching),
|
|
1238
|
+
...planMissingEventsOf(watch, watching),
|
|
1209
1239
|
]
|
|
1210
1240
|
for (const event of events) console.log(`${watchEventLine(event, null)}${hintFor(watch, eventReference(event))}`)
|
|
1211
1241
|
return sent
|
|
@@ -1307,6 +1337,7 @@ const initialWatching = (
|
|
|
1307
1337
|
noTaskSignals: start.state.noTaskSignals,
|
|
1308
1338
|
laneStalls: start.state.laneStalls,
|
|
1309
1339
|
unlinkedArtifacts: start.state.unlinkedArtifacts,
|
|
1340
|
+
planMissing: start.state.planMissing,
|
|
1310
1341
|
approvalRequests: null,
|
|
1311
1342
|
appointments: null,
|
|
1312
1343
|
reportQuestions: null,
|
|
@@ -48,7 +48,12 @@ export const watchedLaneProgress = async (
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/** The repository path of the plan of a lane's newest row that links one; null without such a row. */
|
|
51
|
-
const lanePlanPath = (
|
|
51
|
+
export const lanePlanPath = (
|
|
52
|
+
root: string,
|
|
53
|
+
settings: PlansSettings,
|
|
54
|
+
boardFile: string,
|
|
55
|
+
lane: SprintLane,
|
|
56
|
+
): string | null => {
|
|
52
57
|
const href = lane.steps.filter((step) => step.planHref !== null).at(-1)?.planHref
|
|
53
58
|
return href ? resolvePlanPath(dirname(boardRepoPathOf(root, settings, boardFile)), href) : null
|
|
54
59
|
}
|