jackherd 1.23.3 → 1.25.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 +9 -0
- package/INSTALL.md +16 -0
- package/README.md +23 -22
- package/dist/{chunk-JDO6O3UO.js → chunk-O365KHHP.js} +139 -3
- package/dist/config.d.ts +13 -1
- package/dist/config.js +178 -132
- package/dist/plan-format.d.ts +121 -3
- package/dist/plan-format.js +176 -35
- package/docs/plan-contract.md +52 -5
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/index.ts +2 -2
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +1 -1
- package/kit/hooks/hooks/shared/board-lanes.ts +33 -12
- package/kit/hooks/hooks/shared/boards.ts +22 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +2 -1
- package/kit/workflow/skills/orchestrate/references/backend.md +30 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +1 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +3 -3
- package/kit/workflow/skills/orchestrate/references/start-lane.md +2 -2
- package/kit/workflow/skills/plan/SKILL.md +3 -1
- package/kit/workflow/skills/plan/references/template-English.md +2 -0
- package/kit/workflow/skills/plan/references/template-German.md +2 -0
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +49 -0
- package/src/backend/command-line.ts +116 -0
- package/src/backend/command.ts +14 -0
- package/src/backend/control.ts +194 -0
- package/src/backend/handover.ts +28 -0
- package/src/backend/herdr.ts +147 -0
- package/src/backend/lock.ts +84 -0
- package/src/backend/process-cwd.ts +32 -0
- package/src/backend/schema.ts +47 -0
- package/src/backend/state.ts +57 -0
- package/src/backend/step.ts +93 -0
- package/src/backend/system.ts +150 -0
- package/src/backend/watch.ts +75 -0
- package/src/brief/command.ts +1 -1
- package/src/brief/lane-facts.ts +2 -2
- package/src/check/command.ts +2 -1
- package/src/check/plan-structure-check.ts +68 -14
- package/src/check/settings.ts +6 -1
- package/src/check/skill-budget-defaults.ts +6 -3
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +6 -0
- package/src/lane/close-board.ts +4 -4
- package/src/lane/close-command.ts +2 -2
- package/src/lane/dev-command.ts +1 -1
- package/src/lane/dev.ts +2 -2
- package/src/lane/port-command.ts +1 -1
- package/src/plan-contract/command.ts +2 -1
- package/src/plan-contract/contract.ts +30 -9
- package/src/plan-format/bare-codes.ts +4 -4
- package/src/plan-format/dependencies.ts +5 -5
- package/src/plan-format/lanes.ts +3 -4
- package/src/plan-format/markdown.ts +125 -10
- package/src/plan-format/open-status.ts +15 -0
- package/src/plan-format/phases.ts +45 -4
- package/src/plan-format/plan-head.ts +31 -9
- package/src/plan-format/plan-structure.ts +43 -7
- package/src/plan-format/progress.ts +3 -0
- package/src/plan-format/sprint-document.ts +4 -4
- package/src/plan-format/unrecognized-sections.ts +21 -0
- package/src/plan-format/waiting.ts +13 -13
- package/src/plans/board-close.ts +25 -16
- package/src/plans/board-pick.ts +1 -1
- package/src/plans/ensure-panes.ts +2 -2
- package/src/plans/layout-summary.ts +8 -1
- package/src/plans/layout.ts +14 -5
- package/src/plans/live.ts +1 -1
- package/src/plans/plan-panes.ts +1 -1
- package/src/plans/settings.ts +6 -0
- package/src/plans/snapshot.ts +9 -5
- package/src/plans/sprint-export.ts +1 -1
- package/src/plans/texts.ts +3 -0
- package/src/plans/view.ts +1 -0
- package/src/resume/coordinator-read.ts +2 -2
- package/src/settled/command.ts +1 -1
- package/src/settled/settle.ts +5 -5
- package/src/shared/shell-word.ts +2 -0
- package/src/sync/render.ts +20 -5
- package/src/sync/sync-plan.ts +6 -2
- package/src/sync/sync.ts +3 -2
- package/src/vocabulary/index.ts +192 -4
- package/src/watch/appointments.ts +13 -20
- package/src/watch/command.ts +32 -5
- package/src/watch/lane-progress.ts +1 -1
- package/src/watch/merge-overlap-read.ts +2 -2
- package/src/watch/plan-missing-read.ts +3 -1
- package/src/watch/reference-paths.ts +4 -0
package/src/sync/render.ts
CHANGED
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
APPROVAL_TEXTS,
|
|
6
6
|
CARD_FIELDS,
|
|
7
7
|
LANGUAGE_NAME,
|
|
8
|
+
PLAN_LANGUAGE,
|
|
8
9
|
PLAN_VOCABULARY,
|
|
10
|
+
type PlanLanguage,
|
|
9
11
|
RECOMMENDED_MARK,
|
|
10
12
|
REPORT_FIELDS,
|
|
11
13
|
SELF_MERGE_LABEL,
|
|
@@ -35,9 +37,20 @@ const checkFreeList = (patterns: readonly string[]): string =>
|
|
|
35
37
|
const prefixed = (prefix: string, values: Record<string, string>): [string, string][] =>
|
|
36
38
|
Object.entries(values).map(([key, value]) => [`kit.${prefix}.${key}`, value])
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
const TEMPLATE_FILE = /(?:^|\/)template-([A-Za-z]+)\.md$/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The language a plan template file is written in, from its name (`template-English.md` is English); undefined for any
|
|
44
|
+
* other path. Such a file renders with the vocabulary of that language, never with the project's plan language.
|
|
45
|
+
*/
|
|
46
|
+
export const templateLanguageOf = (path: string): PlanLanguage | undefined => {
|
|
47
|
+
const name = TEMPLATE_FILE.exec(path)?.[1]
|
|
48
|
+
return Object.values(PLAN_LANGUAGE).find((language) => LANGUAGE_NAME[language] === name)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Every placeholder a kit file may use, with its value for this project; the plan words follow `vocabularyLanguage`. */
|
|
52
|
+
const placeholderValues = (config: JackherdConfig, vocabularyLanguage: PlanLanguage): Map<string, string> => {
|
|
53
|
+
const { header, sections, columns, phase, laneClosed, openSubagents } = PLAN_VOCABULARY[vocabularyLanguage]
|
|
41
54
|
return new Map([
|
|
42
55
|
['kit.responseLanguage', LANGUAGE_NAME[config.responseLanguage]],
|
|
43
56
|
['kit.responseAddress', ADDRESS_FORM[config.responseLanguage][config.responseAddress]],
|
|
@@ -74,15 +87,17 @@ export const listPlaceholders = (template: string): string[] =>
|
|
|
74
87
|
* vocabulary, the two languages, `{{kit.responseAddress}}`, the plan and worktree directory and the base branch. A
|
|
75
88
|
* command or lock file the config leaves unset renders as `unsetValueText`, never as a guessed default. Other
|
|
76
89
|
* `{{…}}` text is left alone; an unknown `kit.` placeholder throws rather than reaching a project
|
|
77
|
-
* file unrendered. `
|
|
90
|
+
* file unrendered. `templateLanguage` (`templateLanguageOf` of the file) fills the plan words from that language's
|
|
91
|
+
* vocabulary instead of the project's plan language. `overrides` win over the config: a global kit renders what each repository decides as
|
|
78
92
|
* `<name>` (`REPOSITORY_TOKENS`).
|
|
79
93
|
*/
|
|
80
94
|
export const renderTemplate = (
|
|
81
95
|
template: string,
|
|
82
96
|
config: JackherdConfig,
|
|
83
97
|
overrides: ReadonlyMap<string, string> = new Map(),
|
|
98
|
+
templateLanguage?: PlanLanguage,
|
|
84
99
|
): string => {
|
|
85
|
-
const values = placeholderValues(config)
|
|
100
|
+
const values = placeholderValues(config, templateLanguage ?? config.planLanguage)
|
|
86
101
|
return template.replace(PLACEHOLDER, (_match, name: string) => {
|
|
87
102
|
const value = overrides.get(name) ?? values.get(name)
|
|
88
103
|
if (value === undefined) throw new Error(`Unknown kit placeholder {{${name}}}`)
|
package/src/sync/sync-plan.ts
CHANGED
|
@@ -77,13 +77,17 @@ export const assertOutsideWorktrees = (paths: Iterable<string>, config: Jackherd
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
/** `render` gets the kit path too, so a `template-<Language>.md` can be filled in its own language. */
|
|
81
|
+
export const renderKitFiles = (
|
|
82
|
+
kit: KitSource,
|
|
83
|
+
render: (template: string, path: string) => string,
|
|
84
|
+
): Map<string, RenderedFile> => {
|
|
81
85
|
const rendered = new Map<string, RenderedFile>()
|
|
82
86
|
for (const file of kit.files) {
|
|
83
87
|
const parsed = parseKitPath(file.path)
|
|
84
88
|
if (!parsed.ok) throw new Error(`Kit file ${file.path} ${parsed.reason}`)
|
|
85
89
|
if (rendered.has(parsed.path)) throw new Error(`Kit file ${parsed.path} appears twice`)
|
|
86
|
-
const content = render(file.template)
|
|
90
|
+
const content = render(file.template, parsed.path)
|
|
87
91
|
const mode = file.executable ? FILE_MODE.executable : FILE_MODE.regular
|
|
88
92
|
rendered.set(parsed.path, { content, checksum: checksum(content), mode })
|
|
89
93
|
}
|
package/src/sync/sync.ts
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
removeFileSafely,
|
|
30
30
|
writeFileSafely,
|
|
31
31
|
} from './project-fs'
|
|
32
|
-
import { renderTemplate } from './render'
|
|
32
|
+
import { renderTemplate, templateLanguageOf } from './render'
|
|
33
33
|
import { parseSettings, type Settings } from './settings-hooks'
|
|
34
34
|
import {
|
|
35
35
|
type AcceptRequest,
|
|
@@ -184,7 +184,8 @@ const buildPlan = async ({
|
|
|
184
184
|
}: PlanInput): Promise<SyncPlan> => {
|
|
185
185
|
const { manifest, text: manifestText } = await readManifest(toolDirectory)
|
|
186
186
|
const accept = parseAcceptPaths(acceptPaths)
|
|
187
|
-
const render = (template: string
|
|
187
|
+
const render = (template: string, path: string) =>
|
|
188
|
+
renderTemplate(template, config, overrides, templateLanguageOf(path))
|
|
188
189
|
const rendered = renderKitFiles(kit, render)
|
|
189
190
|
assertOutsideWorktrees([...rendered.keys(), ...Object.keys(manifest.files), ...accept.files], config)
|
|
190
191
|
const files = await planFiles(locate, rendered, manifest, accept)
|
package/src/vocabulary/index.ts
CHANGED
|
@@ -2,6 +2,12 @@ export const PLAN_LANGUAGE = { de: 'de', en: 'en' } as const
|
|
|
2
2
|
|
|
3
3
|
export type PlanLanguage = (typeof PLAN_LANGUAGE)[keyof typeof PLAN_LANGUAGE]
|
|
4
4
|
|
|
5
|
+
/** The languages to try when reading: `language` first, then every other known one. */
|
|
6
|
+
export const languagesFrom = (language: PlanLanguage): PlanLanguage[] => [
|
|
7
|
+
language,
|
|
8
|
+
...Object.values(PLAN_LANGUAGE).filter((other) => other !== language),
|
|
9
|
+
]
|
|
10
|
+
|
|
5
11
|
/** The language's name as a reply or a question says it. */
|
|
6
12
|
export const LANGUAGE_NAME: Record<PlanLanguage, string> = {
|
|
7
13
|
[PLAN_LANGUAGE.en]: 'English',
|
|
@@ -175,6 +181,111 @@ export const WAITING_VOCABULARY: Record<
|
|
|
175
181
|
},
|
|
176
182
|
}
|
|
177
183
|
|
|
184
|
+
/**
|
|
185
|
+
* The closed set of keys a section carries in a marker, `<!-- jh: <key> -->` on the line directly under its heading.
|
|
186
|
+
* The marker names the section whatever language the heading is written in.
|
|
187
|
+
*/
|
|
188
|
+
export const SECTION_KEY = {
|
|
189
|
+
waiting: 'waiting',
|
|
190
|
+
appointments: 'appointments',
|
|
191
|
+
lanes: 'lanes',
|
|
192
|
+
goal: 'goal',
|
|
193
|
+
history: 'history',
|
|
194
|
+
plans: 'plans',
|
|
195
|
+
decisions: 'decisions',
|
|
196
|
+
implementation: 'implementation',
|
|
197
|
+
model: 'model',
|
|
198
|
+
dependencies: 'dependencies',
|
|
199
|
+
documentation: 'documentation',
|
|
200
|
+
forYou: 'for-you',
|
|
201
|
+
next: 'next',
|
|
202
|
+
} as const
|
|
203
|
+
|
|
204
|
+
export type SectionKey = (typeof SECTION_KEY)[keyof typeof SECTION_KEY]
|
|
205
|
+
|
|
206
|
+
export const isSectionKey = (value: string): value is SectionKey =>
|
|
207
|
+
(Object.values(SECTION_KEY) as string[]).includes(value)
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The marker key of each line of the head section (`- **Stand:** <!-- jh: head-status --> …`). The marker names the
|
|
211
|
+
* line whatever language its label is written in.
|
|
212
|
+
*/
|
|
213
|
+
export const HEAD_KEY: Record<HeadLine, string> = {
|
|
214
|
+
changes: 'head-changes',
|
|
215
|
+
status: 'head-status',
|
|
216
|
+
decisions: 'head-decisions',
|
|
217
|
+
tryNow: 'head-try-now',
|
|
218
|
+
next: 'head-next',
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The closed set of keys a line that opens with bold words carries in a marker
|
|
223
|
+
* (`**Kein Review-Gate** <!-- jh: no-review-gate --> reason`). The marker names the line whatever language it is in.
|
|
224
|
+
*/
|
|
225
|
+
export const LINE_KEY = { noReviewGate: 'no-review-gate', purpose: 'purpose' } as const
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The closed set of keys a table column carries in a marker in its header cell (`Hängt ab von <!-- jh: depends-on -->`).
|
|
229
|
+
* The marker names the column whatever language the header is written in.
|
|
230
|
+
*/
|
|
231
|
+
export const COLUMN_KEY = {
|
|
232
|
+
since: 'since',
|
|
233
|
+
item: 'item',
|
|
234
|
+
origin: 'origin',
|
|
235
|
+
unblocks: 'unblocks',
|
|
236
|
+
status: 'status',
|
|
237
|
+
when: 'when',
|
|
238
|
+
action: 'action',
|
|
239
|
+
dependsOn: 'depends-on',
|
|
240
|
+
writes: 'writes',
|
|
241
|
+
serialMarker: 'serial-marker',
|
|
242
|
+
} as const
|
|
243
|
+
|
|
244
|
+
export type ColumnKey = (typeof COLUMN_KEY)[keyof typeof COLUMN_KEY]
|
|
245
|
+
|
|
246
|
+
/** The header names the column `key` goes by in `language`: its name first, then the names that count as the same column. */
|
|
247
|
+
export const columnWords = (key: ColumnKey, language: PlanLanguage): readonly string[] => {
|
|
248
|
+
const waiting = WAITING_VOCABULARY[language]
|
|
249
|
+
const appointments = APPOINTMENT_VOCABULARY[language]
|
|
250
|
+
const { columns } = PLAN_VOCABULARY[language]
|
|
251
|
+
const words: Record<ColumnKey, readonly string[]> = {
|
|
252
|
+
[COLUMN_KEY.since]: [waiting.columns.since],
|
|
253
|
+
[COLUMN_KEY.item]: [waiting.columns.item],
|
|
254
|
+
[COLUMN_KEY.origin]: [waiting.columns.origin],
|
|
255
|
+
[COLUMN_KEY.unblocks]: [waiting.columns.unblocks],
|
|
256
|
+
[COLUMN_KEY.status]: [
|
|
257
|
+
waiting.columns.status,
|
|
258
|
+
...waiting.statusAlso,
|
|
259
|
+
appointments.columns.status,
|
|
260
|
+
...appointments.statusAlso,
|
|
261
|
+
],
|
|
262
|
+
[COLUMN_KEY.when]: [appointments.columns.when],
|
|
263
|
+
[COLUMN_KEY.action]: [appointments.columns.action],
|
|
264
|
+
[COLUMN_KEY.dependsOn]: [columns.dependsOn],
|
|
265
|
+
[COLUMN_KEY.writes]: [columns.writes],
|
|
266
|
+
[COLUMN_KEY.serialMarker]: [columns.serialMarker],
|
|
267
|
+
}
|
|
268
|
+
return words[key]
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The closed set of keys a status carries in a marker in its cell (`erledigt <!-- jh: status-done -->`): `open` for a
|
|
273
|
+
* board row that still waits, the others for the status of a plan. The marker names the status whatever language the
|
|
274
|
+
* cell is written in.
|
|
275
|
+
*/
|
|
276
|
+
export const STATUS_KEY = {
|
|
277
|
+
open: 'status-open',
|
|
278
|
+
done: 'status-done',
|
|
279
|
+
abandoned: 'status-abandoned',
|
|
280
|
+
inProgress: 'status-in-progress',
|
|
281
|
+
draft: 'status-draft',
|
|
282
|
+
} as const
|
|
283
|
+
|
|
284
|
+
export type StatusKey = (typeof STATUS_KEY)[keyof typeof STATUS_KEY]
|
|
285
|
+
|
|
286
|
+
export const isStatusKey = (value: string): value is StatusKey =>
|
|
287
|
+
(Object.values(STATUS_KEY) as string[]).includes(value)
|
|
288
|
+
|
|
178
289
|
export const isPlanLanguage = (value: string): value is PlanLanguage =>
|
|
179
290
|
(Object.values(PLAN_LANGUAGE) as string[]).includes(value)
|
|
180
291
|
|
|
@@ -248,6 +359,8 @@ export type PlanVocabulary = {
|
|
|
248
359
|
phase: string
|
|
249
360
|
/** Opens the line by which a phase says it has no review gate on purpose: `**Kein Review-Gate:** <reason>`. */
|
|
250
361
|
noReviewGate: string
|
|
362
|
+
/** Opens the line directly under a phase heading that says in one sentence what is different afterwards, for whom: `**Worum es geht:** <sentence>`. */
|
|
363
|
+
phasePurpose: string
|
|
251
364
|
/** Marks a lane as closed when its name cell contains it. */
|
|
252
365
|
laneClosed: string
|
|
253
366
|
/** Stands in a board row's phases cell when that pull request builds only part of the phase. */
|
|
@@ -296,6 +409,7 @@ export const PLAN_VOCABULARY: Record<PlanLanguage, PlanVocabulary> = {
|
|
|
296
409
|
columns: { dependsOn: 'Hängt ab von', writes: 'Schreibt', serialMarker: 'Seriell-Marker' },
|
|
297
410
|
phase: 'Phase',
|
|
298
411
|
noReviewGate: 'Kein Review-Gate',
|
|
412
|
+
phasePurpose: 'Worum es geht',
|
|
299
413
|
laneClosed: 'abgeräumt',
|
|
300
414
|
partialPhase: '(Teil)',
|
|
301
415
|
openSubagents: 'offene Sub-Agenten',
|
|
@@ -339,6 +453,7 @@ export const PLAN_VOCABULARY: Record<PlanLanguage, PlanVocabulary> = {
|
|
|
339
453
|
columns: { dependsOn: 'Depends on', writes: 'Writes', serialMarker: 'Serial marker' },
|
|
340
454
|
phase: 'Phase',
|
|
341
455
|
noReviewGate: 'No review gate',
|
|
456
|
+
phasePurpose: 'What it is about',
|
|
342
457
|
laneClosed: 'cleaned up',
|
|
343
458
|
partialPhase: '(part)',
|
|
344
459
|
openSubagents: 'open subagents',
|
|
@@ -362,20 +477,93 @@ export const PLAN_VOCABULARY: Record<PlanLanguage, PlanVocabulary> = {
|
|
|
362
477
|
|
|
363
478
|
const STATUS_PRECEDENCE = [PLAN_STATUS.done, PLAN_STATUS.abandoned, PLAN_STATUS.inProgress, PLAN_STATUS.draft] as const
|
|
364
479
|
|
|
365
|
-
|
|
366
|
-
|
|
480
|
+
const STATUS_OF_KEY: Partial<Record<StatusKey, PlanStatus>> = {
|
|
481
|
+
[STATUS_KEY.done]: PLAN_STATUS.done,
|
|
482
|
+
[STATUS_KEY.abandoned]: PLAN_STATUS.abandoned,
|
|
483
|
+
[STATUS_KEY.inProgress]: PLAN_STATUS.inProgress,
|
|
484
|
+
[STATUS_KEY.draft]: PLAN_STATUS.draft,
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** A `<!-- jh: <key> -->` marker anywhere in a text; the key is group 1. */
|
|
488
|
+
export const MARKER_IN_TEXT = /<!--\s*jh:\s*([a-z-]+)\s*-->/
|
|
489
|
+
|
|
490
|
+
const statusOfMarker = (statusText: string): PlanStatus | undefined => {
|
|
491
|
+
const key = MARKER_IN_TEXT.exec(statusText)?.[1]
|
|
492
|
+
return key !== undefined && isStatusKey(key) ? STATUS_OF_KEY[key] : undefined
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const statusOfWords = (language: PlanLanguage, statusText: string): PlanStatus | undefined => {
|
|
367
496
|
const { statusWords } = PLAN_VOCABULARY[language]
|
|
368
497
|
const lowerStatus = statusText.toLowerCase()
|
|
369
498
|
const matches = ({ leading, anywhere }: StatusWord) =>
|
|
370
499
|
leading.some((word) => lowerStatus.startsWith(word)) || anywhere.some((fragment) => lowerStatus.includes(fragment))
|
|
371
|
-
|
|
500
|
+
return STATUS_PRECEDENCE.find((status) => matches(statusWords[status]))
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Classifies a free-text `Status:` line. A status marker (`<!-- jh: status-done -->`) decides, any language; else the
|
|
505
|
+
* first matching status in precedence order of the words of `language`, then of any other known language.
|
|
506
|
+
*/
|
|
507
|
+
export const classifyStatus = (language: PlanLanguage, statusText: string): PlanStatus => {
|
|
508
|
+
const marked = statusOfMarker(statusText)
|
|
509
|
+
if (marked) return marked
|
|
510
|
+
const text = statusText.replace(MARKER_IN_TEXT, '')
|
|
511
|
+
const matched = languagesFrom(language)
|
|
512
|
+
.map((candidate) => statusOfWords(candidate, text))
|
|
513
|
+
.find((status) => status !== undefined)
|
|
372
514
|
if (matched) return matched
|
|
373
|
-
return
|
|
515
|
+
return text.trim() ? PLAN_STATUS.other : PLAN_STATUS.none
|
|
374
516
|
}
|
|
375
517
|
|
|
376
518
|
/** `### Phase 3: Title` as the plan spells it in `language`. */
|
|
377
519
|
export const phaseHeading = (language: PlanLanguage, id: string, title: string): string =>
|
|
378
520
|
`### ${PLAN_VOCABULARY[language].phase} ${id}: ${title}`
|
|
379
521
|
|
|
522
|
+
/** The heading text of the section `key` names, as `language` writes it. */
|
|
523
|
+
export const headingOfSection = (key: SectionKey, language: PlanLanguage): string => {
|
|
524
|
+
const { sections } = PLAN_VOCABULARY[language]
|
|
525
|
+
const headings: Record<SectionKey, string> = {
|
|
526
|
+
[SECTION_KEY.waiting]: WAITING_VOCABULARY[language].heading,
|
|
527
|
+
[SECTION_KEY.appointments]: APPOINTMENT_VOCABULARY[language].heading,
|
|
528
|
+
[SECTION_KEY.lanes]: sections.lanes,
|
|
529
|
+
[SECTION_KEY.goal]: sections.goal,
|
|
530
|
+
[SECTION_KEY.history]: sections.history,
|
|
531
|
+
[SECTION_KEY.plans]: sections.plans,
|
|
532
|
+
[SECTION_KEY.decisions]: sections.decisions,
|
|
533
|
+
[SECTION_KEY.implementation]: sections.implementation,
|
|
534
|
+
[SECTION_KEY.model]: sections.modelRecommendations,
|
|
535
|
+
[SECTION_KEY.dependencies]: sections.dependencies,
|
|
536
|
+
[SECTION_KEY.documentation]: sections.documentation,
|
|
537
|
+
[SECTION_KEY.forYou]: sections.forYou,
|
|
538
|
+
[SECTION_KEY.next]: NEXT_VOCABULARY[language].heading,
|
|
539
|
+
}
|
|
540
|
+
return headings[key]
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** The key and language of the section a heading text names, read the way a caller spells it; `null` when no language does. */
|
|
544
|
+
export const sectionOfHeading = (headingText: string): { key: SectionKey; language: PlanLanguage } | null => {
|
|
545
|
+
for (const language of Object.values(PLAN_LANGUAGE)) {
|
|
546
|
+
const key = Object.values(SECTION_KEY).find((candidate) => headingOfSection(candidate, language) === headingText)
|
|
547
|
+
if (key !== undefined) return { key, language }
|
|
548
|
+
}
|
|
549
|
+
return null
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* The closed set of keys a lanes-table cell carries in a marker: `lane-closed` in the name cell of a lane that was
|
|
554
|
+
* cleaned up, `partial-phase` in the phases cell of a row that builds only part of a phase.
|
|
555
|
+
*/
|
|
556
|
+
export const CELL_KEY = { laneClosed: 'lane-closed', partialPhase: 'partial-phase' } as const
|
|
557
|
+
|
|
558
|
+
export type CellKey = (typeof CELL_KEY)[keyof typeof CELL_KEY]
|
|
559
|
+
|
|
560
|
+
/** The words of every known language that mark a lane as cleaned up. */
|
|
561
|
+
export const LANE_CLOSED_WORDS: readonly string[] = Object.values(PLAN_VOCABULARY).map(({ laneClosed }) => laneClosed)
|
|
562
|
+
|
|
563
|
+
/** The words of every known language that mark a row as building only part of a phase. */
|
|
564
|
+
export const PARTIAL_PHASE_WORDS: readonly string[] = Object.values(PLAN_VOCABULARY).map(
|
|
565
|
+
({ partialPhase }) => partialPhase,
|
|
566
|
+
)
|
|
567
|
+
|
|
380
568
|
export const sectionHeading = (language: PlanLanguage, section: keyof PlanVocabulary['sections']): string =>
|
|
381
569
|
`## ${PLAN_VOCABULARY[language].sections[section]}`
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { DAY_MS, twoDigits } from '../plan-format/clock'
|
|
2
|
-
import { firstTableWithHeader, sectionLines } from '../plan-format/markdown'
|
|
2
|
+
import { columnIndex, firstTableWithHeader, sectionLines } from '../plan-format/markdown'
|
|
3
|
+
import { isOpenStatus } from '../plan-format/open-status'
|
|
3
4
|
import { isRecord } from '../shared/is-record'
|
|
4
|
-
import { APPOINTMENT_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
5
|
+
import { APPOINTMENT_VOCABULARY, COLUMN_KEY, type PlanLanguage } from '../vocabulary'
|
|
5
6
|
|
|
6
7
|
export const APPOINTMENT_EVENT = { due: 'due', unreadable: 'appointment unreadable' } as const
|
|
7
8
|
|
|
@@ -98,22 +99,15 @@ const unreadableKey = ({ whenText, action }: UnreadableAppointment): string => `
|
|
|
98
99
|
|
|
99
100
|
const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim()
|
|
100
101
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return lower === '' || APPOINTMENT_VOCABULARY[language].open.some((word) => lower.startsWith(word))
|
|
105
|
-
}
|
|
102
|
+
const OPEN_WORDS = Object.values(APPOINTMENT_VOCABULARY).flatMap(({ open }) => open)
|
|
103
|
+
|
|
104
|
+
const isOpen = (status: string): boolean => isOpenStatus(status, OPEN_WORDS)
|
|
106
105
|
|
|
107
106
|
/** The row as an appointment, as an unreadable row when it is open and its time cannot be read, else as nothing. */
|
|
108
|
-
const tableOfRow = (
|
|
109
|
-
cells: string[],
|
|
110
|
-
[when, action, status]: number[],
|
|
111
|
-
language: PlanLanguage,
|
|
112
|
-
now: Date,
|
|
113
|
-
): AppointmentTable => {
|
|
107
|
+
const tableOfRow = (cells: string[], [when, action, status]: number[], now: Date): AppointmentTable => {
|
|
114
108
|
const whenText = cells[when] ?? ''
|
|
115
109
|
const actionText = oneLine(cells[action] ?? '')
|
|
116
|
-
const open = isOpen(cells[status] ?? ''
|
|
110
|
+
const open = isOpen(cells[status] ?? '')
|
|
117
111
|
const time = parseAppointmentTime(whenText, now)
|
|
118
112
|
if (time)
|
|
119
113
|
return {
|
|
@@ -130,19 +124,18 @@ const tableOfRow = (
|
|
|
130
124
|
* listed as unreadable as a whole, with its header and the missing names; a section without a table has no rows.
|
|
131
125
|
*/
|
|
132
126
|
export const readAppointmentTable = (board: string, language: PlanLanguage, now: Date): AppointmentTable => {
|
|
133
|
-
const { heading, columns
|
|
127
|
+
const { heading, columns } = APPOINTMENT_VOCABULARY[language]
|
|
134
128
|
const { header, rows } = firstTableWithHeader(sectionLines(board, heading))
|
|
135
129
|
if (header.length === 0) return { appointments: [], unreadable: [] }
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
const missing = needed.filter((
|
|
130
|
+
const needed = [COLUMN_KEY.when, COLUMN_KEY.action, COLUMN_KEY.status] as const
|
|
131
|
+
const positions = needed.map((key) => columnIndex(header, key, language))
|
|
132
|
+
const missing = needed.filter((_, index) => positions[index] === -1).map((key) => columns[key])
|
|
139
133
|
if (missing.length > 0)
|
|
140
134
|
return {
|
|
141
135
|
appointments: [],
|
|
142
136
|
unreadable: [{ whenText: header.join(' | '), action: `missing columns: ${missing.join(', ')}` }],
|
|
143
137
|
}
|
|
144
|
-
const
|
|
145
|
-
const tables = rows.map((cells) => tableOfRow(cells, positions, language, now))
|
|
138
|
+
const tables = rows.map((cells) => tableOfRow(cells, positions, now))
|
|
146
139
|
return {
|
|
147
140
|
appointments: tables.flatMap(({ appointments }) => appointments),
|
|
148
141
|
unreadable: tables.flatMap(({ unreadable }) => unreadable),
|
package/src/watch/command.ts
CHANGED
|
@@ -32,6 +32,14 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
32
32
|
import { homedir } from 'node:os'
|
|
33
33
|
import { basename, dirname, join, relative } from 'node:path'
|
|
34
34
|
import { writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
|
|
35
|
+
import {
|
|
36
|
+
type BackendWatch,
|
|
37
|
+
backendCheckOf,
|
|
38
|
+
INITIAL_BACKEND_WATCH,
|
|
39
|
+
keepBackend,
|
|
40
|
+
type MainCheckoutBackend,
|
|
41
|
+
mainCheckoutBackendOf,
|
|
42
|
+
} from '../backend/watch'
|
|
35
43
|
import { sourcesWithPersonal } from '../config/personal-sources'
|
|
36
44
|
import { readConfig, readResolvedConfig } from '../config/read-config'
|
|
37
45
|
import { readRepositoryIdentity } from '../config/repository-identity'
|
|
@@ -200,6 +208,7 @@ import { recordedReadsOf } from './recorded-reads'
|
|
|
200
208
|
import {
|
|
201
209
|
annotateEach,
|
|
202
210
|
annotateFirstLine,
|
|
211
|
+
BACKEND_REFERENCE,
|
|
203
212
|
eventReference,
|
|
204
213
|
laneSetEventLines,
|
|
205
214
|
REFERENCE,
|
|
@@ -295,6 +304,8 @@ type Watch = {
|
|
|
295
304
|
selfMergeMinutes: number
|
|
296
305
|
/** Runs inside herdr with the herdr adapter, so missing herd panes can be brought back. */
|
|
297
306
|
herdr: boolean
|
|
307
|
+
/** `lanes.backend` and the worktree directory of the main checkout's config; undefined without one, then the backend is never touched. */
|
|
308
|
+
backend: MainCheckoutBackend | undefined
|
|
298
309
|
}
|
|
299
310
|
|
|
300
311
|
/** The coordinator's agent name (null when none is found), its last read (null before the first one) and the tokens of that read (null without a value). */
|
|
@@ -372,6 +383,8 @@ type Watching = {
|
|
|
372
383
|
/** The questions of each watched lane's report at the last poll (`questionsFingerprint`); null before the first read, so the stored ones stay. */
|
|
373
384
|
reportQuestions: Record<string, string> | null
|
|
374
385
|
planViews: PlanViews
|
|
386
|
+
/** When the backend pane was last checked and whether a failure of it was already said. */
|
|
387
|
+
backend: BackendWatch
|
|
375
388
|
}
|
|
376
389
|
|
|
377
390
|
/** What the duty lines of one return are made of. */
|
|
@@ -596,7 +609,7 @@ const boardReadOf = async (
|
|
|
596
609
|
pullRequests: readonly WatchedPullRequest[],
|
|
597
610
|
): Promise<{ progress: Record<string, LaneProgress>; checkoutRows: number }> => {
|
|
598
611
|
const text = await Bun.file(watch.sprintFile).text()
|
|
599
|
-
const sprintLanes = parseSprintLanes(text, watch.settings.
|
|
612
|
+
const sprintLanes = parseSprintLanes(text, watch.settings.boardLanguage)
|
|
600
613
|
const watched = sprintLanes.filter((lane) => lanes.includes(lane.name))
|
|
601
614
|
const planPulls = watchedPlanPullRequests(pullRequests, watch.settings)
|
|
602
615
|
const headReaderOf = readerOncePerRef((ref) => baseReaderOf(watch.root, ref))
|
|
@@ -1095,13 +1108,13 @@ const readBoardText = (watch: Watch): string | null => {
|
|
|
1095
1108
|
const readAppointments = (watch: Watch, now: number): Watching['appointments'] => {
|
|
1096
1109
|
const board = readBoardText(watch)
|
|
1097
1110
|
if (board === null) return null
|
|
1098
|
-
return { table: readAppointmentTable(board, watch.settings.
|
|
1111
|
+
return { table: readAppointmentTable(board, watch.settings.boardLanguage, new Date(now)), readAt: now }
|
|
1099
1112
|
}
|
|
1100
1113
|
|
|
1101
1114
|
/** The line of what waits on the user's answer, from the board's own section; null when nothing is open or the board cannot be read. */
|
|
1102
1115
|
const waitingLineOf = (watch: Watch, hint = ''): string | null => {
|
|
1103
1116
|
return waitingLine(
|
|
1104
|
-
waitingItemsOf(readBoardText(watch), watch.settings.
|
|
1117
|
+
waitingItemsOf(readBoardText(watch), watch.settings.boardLanguage, readWaitingRecords(watch.root), Date.now()),
|
|
1105
1118
|
hint,
|
|
1106
1119
|
)
|
|
1107
1120
|
}
|
|
@@ -1109,7 +1122,7 @@ const waitingLineOf = (watch: Watch, hint = ''): string | null => {
|
|
|
1109
1122
|
/** The board's lanes, null when the board cannot be read. */
|
|
1110
1123
|
const readSprintLanes = (watch: Watch): SprintLane[] | null => {
|
|
1111
1124
|
const board = readBoardText(watch)
|
|
1112
|
-
return board === null ? null : parseSprintLanes(board, watch.settings.
|
|
1125
|
+
return board === null ? null : parseSprintLanes(board, watch.settings.boardLanguage)
|
|
1113
1126
|
}
|
|
1114
1127
|
|
|
1115
1128
|
/** The sprint's lane names; the previous ones when the sprint cannot be read, so no lane drops out over it. */
|
|
@@ -1134,6 +1147,16 @@ const keepViews = (watch: Watch, watching: Watching, trigger: EnsureTrigger) =>
|
|
|
1134
1147
|
})
|
|
1135
1148
|
}
|
|
1136
1149
|
|
|
1150
|
+
/** Keeps the shared backend pane once it is due (`BACKEND_CHECK_MS`); only inside herdr and only with `lanes.backend`. */
|
|
1151
|
+
const keepBackendPane = async (watch: Watch, watching: Watching) => {
|
|
1152
|
+
if (!watch.herdr || !watch.backend) return
|
|
1153
|
+
watching.backend = await keepBackend(watching.backend, Date.now(), {
|
|
1154
|
+
check: backendCheckOf(watch.root, watch.backend),
|
|
1155
|
+
log: console.log,
|
|
1156
|
+
hint: hintFor(watch, BACKEND_REFERENCE),
|
|
1157
|
+
})
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1137
1160
|
/** Takes up lanes that were found or dropped since the last poll and returns the lanes it added; named lanes never change. */
|
|
1138
1161
|
const followLaneSet = (watch: Watch, watching: Watching): string[] => {
|
|
1139
1162
|
if (watch.options.lanes !== null) return []
|
|
@@ -1426,7 +1449,7 @@ const selfMergesOf = (watch: Watch, watching: Watching, now: number): number[] =
|
|
|
1426
1449
|
const open = Object.entries(watching.state.pullRequests).flatMap(([number, { state }]) =>
|
|
1427
1450
|
state === PULL_REQUEST_STATE.open ? [Number(number)] : [],
|
|
1428
1451
|
)
|
|
1429
|
-
return selfMergePullRequests(board, watch.settings.
|
|
1452
|
+
return selfMergePullRequests(board, watch.settings.boardLanguage, open, now, watch.selfMergeMinutes)
|
|
1430
1453
|
}
|
|
1431
1454
|
|
|
1432
1455
|
const clearInputOf = (
|
|
@@ -1570,6 +1593,7 @@ const initialWatching = (
|
|
|
1570
1593
|
appointments: null,
|
|
1571
1594
|
reportQuestions: null,
|
|
1572
1595
|
planViews: { failureReported: false },
|
|
1596
|
+
backend: INITIAL_BACKEND_WATCH,
|
|
1573
1597
|
})
|
|
1574
1598
|
|
|
1575
1599
|
/** Saves the state a stop leaves behind: the one from before an unfinished report or an unread GitHub answer, else the latest. */
|
|
@@ -1593,6 +1617,7 @@ const watchLanes = async (watch: Watch): Promise<boolean> => {
|
|
|
1593
1617
|
|
|
1594
1618
|
const watching = initialWatching(watch, { lanes, sprintLanes, state: { ...state, armed: arm.state }, armed, agents })
|
|
1595
1619
|
keepViews(watch, watching, { kind: ENSURE_TRIGGER.start })
|
|
1620
|
+
await keepBackendPane(watch, watching)
|
|
1596
1621
|
const resumeRecord = keepRecord(
|
|
1597
1622
|
{ root: watch.root, board: watch.sprintFile, coordinator: coordinatorNameFor(watch, agents), pid: process.pid },
|
|
1598
1623
|
Date.now(),
|
|
@@ -1638,6 +1663,7 @@ const watchLanes = async (watch: Watch): Promise<boolean> => {
|
|
|
1638
1663
|
while (await pollOnce(watch, watching)) {
|
|
1639
1664
|
await Bun.sleep(POLL_MS)
|
|
1640
1665
|
pollLanes(watch, watching)
|
|
1666
|
+
await keepBackendPane(watch, watching)
|
|
1641
1667
|
resumeRecord.follow(watching.current, Date.now(), observed())
|
|
1642
1668
|
await announceUpdate?.()
|
|
1643
1669
|
if (endedByKitUpdate(kit, effects)) return true
|
|
@@ -1710,6 +1736,7 @@ const watchOf = (options: WatchOptions): Watch => {
|
|
|
1710
1736
|
stageTools: stageToolsOf(config.guard.approvals.standing),
|
|
1711
1737
|
selfMergeMinutes: config.guard.selfMerge.approvalMinutes,
|
|
1712
1738
|
herdr: insideHerdr(settings.adapter, process.env),
|
|
1739
|
+
backend: mainCheckoutBackendOf(root, checkoutRoot, config),
|
|
1713
1740
|
}
|
|
1714
1741
|
}
|
|
1715
1742
|
|
|
@@ -224,5 +224,5 @@ export const boardCheckoutRows = (
|
|
|
224
224
|
checkoutText: string,
|
|
225
225
|
): number => {
|
|
226
226
|
const atBase = textAtBase(baseReaderOf(root, baseRefOf(settings)), boardRepoPathOf(root, settings, boardFile))
|
|
227
|
-
return atBase === null ? 0 : boardRowsOf(atBase, checkoutText, settings.
|
|
227
|
+
return atBase === null ? 0 : boardRowsOf(atBase, checkoutText, settings.boardLanguage).rowsFromCheckout
|
|
228
228
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { STACKS } from '../config/detect'
|
|
2
2
|
import { isObjectId } from '../lane/close-survey'
|
|
3
3
|
import { parseSprintLanes } from '../plan-format'
|
|
4
|
-
import type
|
|
4
|
+
import { type PlansSettings } from '../plans/settings'
|
|
5
5
|
import { lanePlanPath, watchedLanePlanText } from './lane-progress'
|
|
6
6
|
import type { OverlapOf } from './lane-report'
|
|
7
7
|
import { type LaneScope, type MergeOverlap, overlapOf } from './merge-duties'
|
|
@@ -39,7 +39,7 @@ const scopeOf = (
|
|
|
39
39
|
boardText: string,
|
|
40
40
|
name: string,
|
|
41
41
|
): LaneScope | null => {
|
|
42
|
-
const lane = parseSprintLanes(boardText, settings.
|
|
42
|
+
const lane = parseSprintLanes(boardText, settings.boardLanguage).find((candidate) => candidate.name === name)
|
|
43
43
|
if (lane === undefined) return null
|
|
44
44
|
const text = watchedLanePlanText(root, settings, boardFile, lane)
|
|
45
45
|
if (text === null) return null
|
|
@@ -19,7 +19,9 @@ import {
|
|
|
19
19
|
const worktreePlanOf = (root: string, settings: PlansSettings, boardFile: string, laneName: string): string | null => {
|
|
20
20
|
const file = join(worktreeOf(root, settings, laneName), boardRepoPathOf(root, settings, boardFile))
|
|
21
21
|
if (!existsSync(file)) return null
|
|
22
|
-
const lane = parseSprintLanes(readFileSync(file, 'utf8'), settings.
|
|
22
|
+
const lane = parseSprintLanes(readFileSync(file, 'utf8'), settings.boardLanguage).find(
|
|
23
|
+
({ name }) => name === laneName,
|
|
24
|
+
)
|
|
23
25
|
const path = lane === undefined ? null : lanePlanPath(root, settings, boardFile, lane)
|
|
24
26
|
return path === null || isOutsideRepository(path) ? null : path
|
|
25
27
|
}
|
|
@@ -24,10 +24,14 @@ export const REFERENCE = {
|
|
|
24
24
|
watchWithoutMonitor: 'references/watch-without-monitor.md',
|
|
25
25
|
adoptPlan: 'references/adopt-plan.md',
|
|
26
26
|
waiting: 'references/waiting.md',
|
|
27
|
+
backend: 'references/backend.md',
|
|
27
28
|
} as const
|
|
28
29
|
|
|
29
30
|
export type Reference = (typeof REFERENCE)[keyof typeof REFERENCE]
|
|
30
31
|
|
|
32
|
+
/** Where the coordinator reads what a `backend:` line of the watcher asks for: the shared backend. */
|
|
33
|
+
export const BACKEND_REFERENCE: Reference = REFERENCE.backend
|
|
34
|
+
|
|
31
35
|
/** A copy of the orchestrate skill: the directory as the session reads it, and where that is on disk. */
|
|
32
36
|
export type SkillCopy = { shown: string; path: string }
|
|
33
37
|
|