jackherd 1.24.0 → 1.26.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 +1 -1
- package/README.md +22 -22
- package/dist/{chunk-JDO6O3UO.js → chunk-O365KHHP.js} +139 -3
- package/dist/config.d.ts +4 -0
- package/dist/config.js +4 -1
- 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/open-question/record.ts +81 -1
- package/kit/hooks/hooks/open-question/run.ts +17 -3
- package/kit/hooks/hooks/question-session-end/index.ts +18 -0
- package/kit/hooks/hooks/question-session-end/run.ts +37 -0
- package/kit/hooks/hooks/sent-file/record.ts +4 -1
- package/kit/hooks/hooks/shared/board-lanes.ts +33 -12
- package/kit/hooks/hooks/shared/boards.ts +22 -6
- package/kit/hooks/hooks.json +8 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +3 -3
- package/kit/workflow/skills/orchestrate/references/waiting.md +15 -1
- 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 +7 -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 +9 -4
- package/src/config/schema.ts +3 -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/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/closable-records.ts +11 -10
- package/src/plans/close-reason.ts +19 -0
- 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 +3 -2
- package/src/plans/question-records.ts +23 -2
- package/src/plans/questions-read.ts +2 -2
- package/src/plans/sent-files-read.ts +2 -2
- package/src/plans/sent-files.ts +12 -4
- package/src/plans/settings.ts +3 -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/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/waiting/command.ts +82 -26
- package/src/watch/appointments.ts +13 -20
- package/src/watch/command.ts +100 -6
- package/src/watch/lane-progress.ts +1 -1
- package/src/watch/lane-report.ts +4 -0
- package/src/watch/merge-overlap-read.ts +2 -2
- package/src/watch/obsolete-proposals.ts +175 -0
- package/src/watch/obsolete-records.ts +77 -0
- package/src/watch/plan-missing-read.ts +3 -1
- package/src/watch/reference-paths.ts +2 -0
- package/src/watch/watch-events.ts +20 -0
|
@@ -13,12 +13,14 @@ import {
|
|
|
13
13
|
laneProgress,
|
|
14
14
|
longPhaseTitles,
|
|
15
15
|
mergedPhasesWithOpenBoxes,
|
|
16
|
+
newPhasesWithoutPurpose,
|
|
16
17
|
newPhasesWithoutReviewGate,
|
|
17
18
|
openBoxesOfPhase,
|
|
18
19
|
PHASE_CELL_LIMIT,
|
|
19
20
|
PHASE_TITLE_LIMIT,
|
|
20
21
|
type PhaseWithOpenBoxes,
|
|
21
22
|
type PhaseWithoutGate,
|
|
23
|
+
type PhaseWithoutPurpose,
|
|
22
24
|
type PhaseWithoutRow,
|
|
23
25
|
PLAN_VOCABULARY,
|
|
24
26
|
type PlanLanguage,
|
|
@@ -38,7 +40,10 @@ import {
|
|
|
38
40
|
shortNameFindings,
|
|
39
41
|
} from '../plan-format'
|
|
40
42
|
import { boardRowsWithBareCodes, type RowWithBareCode } from '../plan-format/bare-codes'
|
|
43
|
+
import { namesState } from '../plan-format/markdown'
|
|
44
|
+
import { firstUnrecognizedSectionLine } from '../plan-format/unrecognized-sections'
|
|
41
45
|
import { mergePlanPhases } from '../plans/phase-source'
|
|
46
|
+
import { CELL_KEY, PARTIAL_PHASE_WORDS, SECTION_KEY } from '../vocabulary'
|
|
42
47
|
import { changedPlansOf } from './deferral-arrows'
|
|
43
48
|
import { CHECK_STATUS, type CheckResult } from './result'
|
|
44
49
|
import { OPEN_BOXES_LEVEL, type OpenBoxesLevel } from './schema'
|
|
@@ -55,6 +60,8 @@ export type PlanCheckInput = {
|
|
|
55
60
|
paths: readonly string[]
|
|
56
61
|
planDirectory: string
|
|
57
62
|
language: PlanLanguage
|
|
63
|
+
/** The language the boards are read in first; `language` when absent. */
|
|
64
|
+
boardLanguage?: PlanLanguage
|
|
58
65
|
previousOf: ContentAt
|
|
59
66
|
baseOf: ContentAt
|
|
60
67
|
/** Where a board's plans are read, `root` when absent: the checkout, the main checkout and the lane worktrees. */
|
|
@@ -124,6 +131,16 @@ const gateReport = (language: PlanLanguage): Report<PhaseWithoutGate> => ({
|
|
|
124
131
|
clean: 'every new phase has a review gate',
|
|
125
132
|
})
|
|
126
133
|
|
|
134
|
+
const purposeReport = (language: PlanLanguage): Report<PhaseWithoutPurpose> => ({
|
|
135
|
+
name: 'Plan phase purposes',
|
|
136
|
+
noun: 'plan',
|
|
137
|
+
rule: 'new phases say what they are about',
|
|
138
|
+
unmet: CHECK_STATUS.fail,
|
|
139
|
+
text: ({ path, line, id, title }) =>
|
|
140
|
+
`${path}:${line} Phase ${id} ${title} has tasks but no **${PLAN_VOCABULARY[language].phasePurpose}:** line; say in one sentence what is different afterwards, for whom, directly under the heading`,
|
|
141
|
+
clean: 'every new phase says what it is about',
|
|
142
|
+
})
|
|
143
|
+
|
|
127
144
|
const rowReport: Report<RowWithoutPhase> = {
|
|
128
145
|
name: 'Board rows without a phase',
|
|
129
146
|
noun: 'board',
|
|
@@ -179,6 +196,21 @@ const unlistedReport: Report<PhaseWithoutRow> = {
|
|
|
179
196
|
clean: 'every phase has a row in the dependency table',
|
|
180
197
|
}
|
|
181
198
|
|
|
199
|
+
const sectionReport: Report<{ line: number }> = {
|
|
200
|
+
name: 'Plan sections',
|
|
201
|
+
noun: 'plan',
|
|
202
|
+
rule: 'jh reads at least one section',
|
|
203
|
+
unmet: CHECK_STATUS.warn,
|
|
204
|
+
text: ({ path, line }) =>
|
|
205
|
+
`${path}:${line} none of the ## headings is a section jh reads, so every reader finds the document empty; write one in a known language or put <!-- jh: <key> --> on the line directly under it (keys: ${Object.values(SECTION_KEY).join(', ')})`,
|
|
206
|
+
clean: 'jh reads a section of every changed plan and board',
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const sectionsOf = ({ path, current }: Changed): Found<{ line: number }>[] => {
|
|
210
|
+
const line = firstUnrecognizedSectionLine(current)
|
|
211
|
+
return line === null ? [] : [{ path, line }]
|
|
212
|
+
}
|
|
213
|
+
|
|
182
214
|
const shortNameText = (found: Found<ShortNameFinding>): string =>
|
|
183
215
|
found.problem === SHORT_NAME_PROBLEM.long
|
|
184
216
|
? `${found.path}:${found.line} Phase ${found.phase}: the short name has ${found.length} characters (limit ${SHORT_NAME_LIMIT})`
|
|
@@ -207,10 +239,15 @@ const withPath =
|
|
|
207
239
|
<T>(path: string) =>
|
|
208
240
|
(found: T): Found<T> => ({ ...found, path })
|
|
209
241
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
242
|
+
/** The ids of the phases the plan had before the change; none for a plan the change creates. */
|
|
243
|
+
const previousPhaseIds = ({ previous }: Changed, language: PlanLanguage): Set<string> =>
|
|
244
|
+
previous ? phaseIdsOfPlan(previous, language) : new Set()
|
|
245
|
+
|
|
246
|
+
const gatesOf = (file: Changed, language: PlanLanguage): Found<PhaseWithoutGate>[] =>
|
|
247
|
+
newPhasesWithoutReviewGate(file.current, language, previousPhaseIds(file, language)).map(withPath(file.path))
|
|
248
|
+
|
|
249
|
+
const purposesOf = (file: Changed, language: PlanLanguage): Found<PhaseWithoutPurpose>[] =>
|
|
250
|
+
newPhasesWithoutPurpose(file.current, language, previousPhaseIds(file, language)).map(withPath(file.path))
|
|
214
251
|
|
|
215
252
|
const rowsOf = ({ path, current, previousLines }: Changed, language: PlanLanguage): Found<RowWithoutPhase>[] =>
|
|
216
253
|
boardRowsWithoutPhase(current, language, previousLines).map(withPath(path))
|
|
@@ -219,17 +256,26 @@ const proseOf = ({ path, current, previousLines }: Changed, language: PlanLangua
|
|
|
219
256
|
boardRowsWithProse(current, language, previousLines).map(withPath(path))
|
|
220
257
|
|
|
221
258
|
/** Every readable copy of the plans the board links, in each plan root; a path that leaves the repository is skipped. */
|
|
222
|
-
const planTextsOf = (
|
|
223
|
-
|
|
259
|
+
const planTextsOf = (
|
|
260
|
+
roots: readonly string[],
|
|
261
|
+
{ path, current }: Changed,
|
|
262
|
+
languages: { board: PlanLanguage; plan: PlanLanguage },
|
|
263
|
+
): string[] =>
|
|
264
|
+
boardPlansOf(current, posix.normalize(dirname(path)), languages.board)
|
|
224
265
|
.filter(isInsideRepository)
|
|
225
266
|
.flatMap((plan) => planCopiesIn(roots, plan))
|
|
226
267
|
|
|
227
|
-
|
|
268
|
+
/** The board is read in the board language, the plans it links in the plan language. */
|
|
269
|
+
const bareCodesOfBoard = (
|
|
270
|
+
roots: readonly string[],
|
|
271
|
+
file: Changed,
|
|
272
|
+
languages: { board: PlanLanguage; plan: PlanLanguage },
|
|
273
|
+
): Found<RowWithBareCode>[] =>
|
|
228
274
|
boardRowsWithBareCodes(
|
|
229
275
|
file.current,
|
|
230
|
-
|
|
276
|
+
languages.board,
|
|
231
277
|
file.previousLines,
|
|
232
|
-
codesOfPlans(planTextsOf(roots, file,
|
|
278
|
+
codesOfPlans(planTextsOf(roots, file, languages), languages.plan),
|
|
233
279
|
).map(withPath(file.path))
|
|
234
280
|
|
|
235
281
|
const mergedOf = ({ path, current, base }: Changed, language: PlanLanguage): Found<PhaseWithOpenBoxes>[] =>
|
|
@@ -297,7 +343,7 @@ const phaseBuiltBy = (input: LaneBoxesInput): BuiltPhase | null => {
|
|
|
297
343
|
if (!phase || !step?.planHref) return null
|
|
298
344
|
// with the base known, a phase this checkout did not tick further waits in the row; it is not what the lane builds
|
|
299
345
|
if (phase.work === undefined && input.baseOf(pathOf(step.planHref)) !== null) return null
|
|
300
|
-
const partial = step.label.
|
|
346
|
+
const partial = namesState(step.label, CELL_KEY.partialPhase, PARTIAL_PHASE_WORDS)
|
|
301
347
|
return { planPath: pathOf(step.planHref), phaseId: phase.id, partial }
|
|
302
348
|
}
|
|
303
349
|
|
|
@@ -341,7 +387,8 @@ export const laneOpenBoxesCheck = (input: LaneBoxesInput & { level: OpenBoxesLev
|
|
|
341
387
|
|
|
342
388
|
/**
|
|
343
389
|
* The structure of the changed plans and boards:
|
|
344
|
-
* - a phase the change added (against `HEAD`) with tasks and without a review gate fails
|
|
390
|
+
* - a phase the change added (against `HEAD`) with tasks and without a review gate fails, and so does one without a
|
|
391
|
+
* purpose line directly under its heading;
|
|
345
392
|
* - a lane row the change added or edited that names no phase warns, and so does one whose phases cell is longer
|
|
346
393
|
* than `PHASE_CELL_LIMIT` or records a side order of the user;
|
|
347
394
|
* - a waiting row the change added or edited whose item cell names a plan code of the board's plans bare warns;
|
|
@@ -357,15 +404,20 @@ export const planStructureChecks = (input: PlanCheckInput): CheckResult[] => {
|
|
|
357
404
|
const files = changedPlansOf(input.paths, input.planDirectory)
|
|
358
405
|
if (files.length === 0) return []
|
|
359
406
|
const { language } = input
|
|
407
|
+
const boardLanguage = input.boardLanguage ?? language
|
|
360
408
|
const started = Date.now()
|
|
361
409
|
const changed = changedContents(input, files)
|
|
362
410
|
const boards = changed.filter(({ board }) => board)
|
|
363
411
|
const plans = changed.filter(({ board }) => !board)
|
|
364
412
|
const unlistedPerPlan = plans.map((file) => unlistedOf(file, language)).filter((found) => found !== null)
|
|
365
413
|
const gates = changed.flatMap((file) => gatesOf(file, language))
|
|
366
|
-
const
|
|
367
|
-
const
|
|
368
|
-
const
|
|
414
|
+
const purposes = changed.flatMap((file) => purposesOf(file, language))
|
|
415
|
+
const sections = changed.flatMap(sectionsOf)
|
|
416
|
+
const rows = boards.flatMap((file) => rowsOf(file, boardLanguage))
|
|
417
|
+
const prose = boards.flatMap((file) => proseOf(file, boardLanguage))
|
|
418
|
+
const bareCodes = boards.flatMap((file) =>
|
|
419
|
+
bareCodesOfBoard(input.planRoots ?? [input.root], file, { board: boardLanguage, plan: language }),
|
|
420
|
+
)
|
|
369
421
|
const merged = plans.flatMap((file) => mergedOf(file, language))
|
|
370
422
|
const unlisted = unlistedPerPlan.flat()
|
|
371
423
|
const shortNames = plans.flatMap((file) => shortNamesOf(file, language))
|
|
@@ -373,6 +425,7 @@ export const planStructureChecks = (input: PlanCheckInput): CheckResult[] => {
|
|
|
373
425
|
const durationMs = Date.now() - started
|
|
374
426
|
return [
|
|
375
427
|
findingsResult(gateReport(language), gates, files.length, durationMs),
|
|
428
|
+
findingsResult(purposeReport(language), purposes, files.length, durationMs),
|
|
376
429
|
...(boards.length > 0
|
|
377
430
|
? [
|
|
378
431
|
findingsResult(rowReport, rows, boards.length, durationMs),
|
|
@@ -390,5 +443,6 @@ export const planStructureChecks = (input: PlanCheckInput): CheckResult[] => {
|
|
|
390
443
|
findingsResult(titleReport, titles, plans.length, durationMs),
|
|
391
444
|
]
|
|
392
445
|
: []),
|
|
446
|
+
findingsResult(sectionReport, sections, files.length, durationMs),
|
|
393
447
|
]
|
|
394
448
|
}
|
package/src/check/settings.ts
CHANGED
|
@@ -21,6 +21,8 @@ export type CheckSettings = {
|
|
|
21
21
|
check: CheckConfig
|
|
22
22
|
planDirectory: string
|
|
23
23
|
planLanguage: PlanLanguage
|
|
24
|
+
/** The language the boards are written in: the config's `boardLanguage`, or `planLanguage` when it sets none. */
|
|
25
|
+
boardLanguage: PlanLanguage
|
|
24
26
|
/** The branch on the remote whose plans count as merged. */
|
|
25
27
|
baseBranch: string
|
|
26
28
|
/** Where the lane worktrees live, below the main checkout: a board's plans are read there too. */
|
|
@@ -44,6 +46,7 @@ export const noStackFallbackOf = ({ stack, check, commands }: CheckSettings): No
|
|
|
44
46
|
const commandsSchema = jackherdConfigSchema.shape.commands
|
|
45
47
|
const planDirectorySchema = jackherdConfigSchema.shape.planDirectory
|
|
46
48
|
const planLanguageSchema = jackherdConfigSchema.shape.planLanguage
|
|
49
|
+
const boardLanguageSchema = jackherdConfigSchema.shape.boardLanguage
|
|
47
50
|
const baseBranchSchema = jackherdConfigSchema.shape.baseBranch
|
|
48
51
|
const worktreeDirectorySchema = jackherdConfigSchema.shape.worktreeDirectory
|
|
49
52
|
const skillPrefixSchema = jackherdConfigSchema.shape.skillPrefix
|
|
@@ -58,11 +61,13 @@ export const parseCheckSettings = (raw: unknown, facts: RepositoryFacts): CheckS
|
|
|
58
61
|
const detected = withDetected(z.record(z.string(), z.unknown()).parse(raw), facts)
|
|
59
62
|
const object = z.record(z.string(), z.unknown()).parse(detected.raw)
|
|
60
63
|
const { test, typecheck, lint } = commandsSchema.parse(object.commands)
|
|
64
|
+
const planLanguage = planLanguageSchema.parse(object.planLanguage)
|
|
61
65
|
return {
|
|
62
66
|
commands: { test, typecheck, lint },
|
|
63
67
|
check: checkConfigSchema.parse(object.check ?? {}),
|
|
64
68
|
planDirectory: planDirectorySchema.parse(object.planDirectory),
|
|
65
|
-
planLanguage
|
|
69
|
+
planLanguage,
|
|
70
|
+
boardLanguage: boardLanguageSchema.parse(object.boardLanguage) ?? planLanguage,
|
|
66
71
|
baseBranch: baseBranchSchema.parse(object.baseBranch),
|
|
67
72
|
worktreeDirectory: worktreeDirectorySchema.parse(object.worktreeDirectory),
|
|
68
73
|
board: boardSchema.parse(object.board),
|
|
@@ -71,10 +71,13 @@ export const KIT_SKILL_TARGETS: Readonly<Record<string, number>> = {
|
|
|
71
71
|
// The dev server restart by PID and `herdr pane run`, the form the merge guard passes (+54).
|
|
72
72
|
'.claude/skills/orchestrate/references/start-lane.md': 766,
|
|
73
73
|
// Core cut: § 4 waiting rows beyond the basic one, moved out of the core.
|
|
74
|
-
|
|
74
|
+
// What the watcher closes by itself, how to work through a `waiting obsolete?` proposal, and the rule for a
|
|
75
|
+
// decision the hooks cannot see: the coordinator's only text for the closing flow.
|
|
76
|
+
'.claude/skills/orchestrate/references/waiting.md': 450,
|
|
75
77
|
'.claude/skills/orchestrate/references/watch-without-monitor.md': 154,
|
|
76
78
|
// Prose cut, round 2: cut to its mandatory rules, measured size plus 5 %.
|
|
77
|
-
|
|
79
|
+
// Language split: the note that a template renders in the language of its file name and that a heading in any other language carries a marker (+27).
|
|
80
|
+
'.claude/skills/plan/SKILL.md': 1744,
|
|
78
81
|
'.claude/skills/plan/references/template-English.md': 536,
|
|
79
82
|
'.claude/skills/plan/references/template-German.md': 501,
|
|
80
83
|
// Prose cut, round 2: cut to its mandatory rules, measured size plus 5 %.
|
|
@@ -111,9 +114,11 @@ export const KIT_SKILL_CEILINGS: Readonly<Record<string, number>> = {
|
|
|
111
114
|
'.claude/agents/skill-review.md': 513,
|
|
112
115
|
// Plan head: every plan opens with a `For you` head of five short lines a person reads first, which
|
|
113
116
|
// `jh plan check` reports; cut to its labels; skill-review: the status line says what `N` counts. Measured size (+42).
|
|
114
|
-
|
|
117
|
+
// Phase purpose: the line under every phase heading a template shows, one sentence of what is different afterwards and for whom (+17).
|
|
118
|
+
'.claude/skills/plan/references/template-English.md': 595,
|
|
115
119
|
// Plan head: the same `Für dich` head as the English template, with what `N` counts; measured size (+41).
|
|
116
|
-
|
|
120
|
+
// Phase purpose: the same line under every phase heading as the English template (+15).
|
|
121
|
+
'.claude/skills/plan/references/template-German.md': 557,
|
|
117
122
|
// P33: the paw in the header chip, panes by tab and title, the lane in the context line of a passed-on question.
|
|
118
123
|
// Plain words: no plan code without its words in a short question.
|
|
119
124
|
// Any language: a pointer in § 0 and a section in events.md for `plan missing`.
|
package/src/config/schema.ts
CHANGED
|
@@ -567,7 +567,10 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
567
567
|
responseLanguage: language.default(PLAN_LANGUAGE.en),
|
|
568
568
|
/** Whether replies address the user with the familiar or the polite form where the language has both (German: du or Sie). */
|
|
569
569
|
responseAddress: z.enum(Object.values(RESPONSE_ADDRESS)).default(RESPONSE_ADDRESS.informal),
|
|
570
|
+
/** The language new plans and the plan templates are written in. Boards and plans are read in every known language whatever this says. */
|
|
570
571
|
planLanguage: language.default(PLAN_LANGUAGE.en),
|
|
572
|
+
/** The language jh writes the board in (history lines, lane rows, waiting rows); `planLanguage` when left out. */
|
|
573
|
+
boardLanguage: language.optional(),
|
|
571
574
|
planDirectory: z.string().min(1).default('docs/plans'),
|
|
572
575
|
/** Where the boards live and what the project calls them. */
|
|
573
576
|
board: boardSchema.prefault({}),
|
package/src/lane/close-board.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { twoDigits } from '../plan-format/clock'
|
|
2
2
|
import { laneNameOf, parseSprintLanes } from '../plan-format/lanes'
|
|
3
|
-
import { firstTableRows, sectionLines, sectionStart } from '../plan-format/markdown'
|
|
3
|
+
import { firstTableRows, namesState, sectionLines, sectionStart } from '../plan-format/markdown'
|
|
4
4
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
5
|
-
import { PLAN_LANGUAGE, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
5
|
+
import { CELL_KEY, LANE_CLOSED_WORDS, PLAN_LANGUAGE, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
6
6
|
import { BOARD_LANE, type BoardLane } from './close-check'
|
|
7
7
|
|
|
8
8
|
const CONTINUATION = '↳'
|
|
@@ -38,7 +38,7 @@ export const boardLaneOf = (board: string, lane: string, language: PlanLanguage)
|
|
|
38
38
|
* a row that names the lane again without the mark starting it anew. Empty when the lane is closed or missing.
|
|
39
39
|
*/
|
|
40
40
|
const currentRowIndexes = (board: string, lane: string, language: PlanLanguage): number[] => {
|
|
41
|
-
const { sections
|
|
41
|
+
const { sections } = PLAN_VOCABULARY[language]
|
|
42
42
|
const start = sectionStart(board, sections.lanes)
|
|
43
43
|
if (start === -1) return []
|
|
44
44
|
let previous: string | null = null
|
|
@@ -49,7 +49,7 @@ const currentRowIndexes = (board: string, lane: string, language: PlanLanguage):
|
|
|
49
49
|
const name = laneNameOf(first, previous)
|
|
50
50
|
previous = name ?? previous
|
|
51
51
|
if (name !== lane) continue
|
|
52
|
-
const marked = first.
|
|
52
|
+
const marked = namesState(first, CELL_KEY.laneClosed, LANE_CLOSED_WORDS)
|
|
53
53
|
if (closed && !marked && !first.startsWith(CONTINUATION)) {
|
|
54
54
|
closed = false
|
|
55
55
|
indexes = []
|
|
@@ -185,7 +185,7 @@ const surveyOf = async (lane: string, branch: string | null, context: CloseConte
|
|
|
185
185
|
const present = entry !== null && existsSync(context.worktree)
|
|
186
186
|
const facts: CloseFacts = {
|
|
187
187
|
lane,
|
|
188
|
-
boardLane: boardLaneOf(readFileSync(context.boardFile, 'utf8'), lane, context.settings.
|
|
188
|
+
boardLane: boardLaneOf(readFileSync(context.boardFile, 'utf8'), lane, context.settings.boardLanguage),
|
|
189
189
|
setupFiles: setupFilesOf(context.root),
|
|
190
190
|
...agentFactsOf(laneStatusSourceFor(context.settings.adapter).listAgents(), lane, context.worktree),
|
|
191
191
|
runsInside: realPath(worktreeRoot(context.cwd)) === realPath(context.worktree),
|
|
@@ -267,7 +267,7 @@ export const run = async (args: string[], cwd: string = process.cwd()): Promise<
|
|
|
267
267
|
boardPath: relative(context.root, context.boardFile),
|
|
268
268
|
dryRun: options.dryRun,
|
|
269
269
|
now: new Date(),
|
|
270
|
-
language: context.settings.
|
|
270
|
+
language: context.settings.boardLanguage,
|
|
271
271
|
}
|
|
272
272
|
return await closeLane(request, portsFor(options.lane, options.branch, context))
|
|
273
273
|
} catch (error) {
|
package/src/lane/dev-command.ts
CHANGED
|
@@ -26,7 +26,7 @@ export const run = async (args: string[], cwd: string = process.cwd()): Promise<
|
|
|
26
26
|
const settings = readSettings([root, worktreeRoot(cwd)])
|
|
27
27
|
const board = boardFor(boardPath === null ? null : resolve(cwd, boardPath), [root], settings, () => [])
|
|
28
28
|
if (board.notice) console.error(terminalSafe(board.notice))
|
|
29
|
-
const row = parseSprintLanes(readFileSync(board.file, 'utf8'), settings.
|
|
29
|
+
const row = parseSprintLanes(readFileSync(board.file, 'utf8'), settings.boardLanguage).find(
|
|
30
30
|
({ name, closed }) => name === lane && !closed,
|
|
31
31
|
)
|
|
32
32
|
const port = devPortOf(portOption, row?.port ?? null)
|
package/src/lane/port-command.ts
CHANGED
|
@@ -25,7 +25,7 @@ export const run = async (args: string[], cwd: string = process.cwd()): Promise<
|
|
|
25
25
|
const settings = readSettings([root, worktreeRoot(cwd)])
|
|
26
26
|
const board = boardFor(boardPath === null ? null : resolve(cwd, boardPath), [root], settings, () => [])
|
|
27
27
|
if (board.notice) console.error(terminalSafe(board.notice))
|
|
28
|
-
const reserved = portsOfRunningLanes(parseSprintLanes(readFileSync(board.file, 'utf8'), settings.
|
|
28
|
+
const reserved = portsOfRunningLanes(parseSprintLanes(readFileSync(board.file, 'utf8'), settings.boardLanguage))
|
|
29
29
|
const port = await freePortNotListening(settings.basePort, reserved, MAX_PORT, isPortListening)
|
|
30
30
|
if (port === null) throw new Error(`no free port from ${settings.basePort} to ${MAX_PORT}`)
|
|
31
31
|
console.log(port)
|
|
@@ -23,7 +23,8 @@ export const USAGE = `usage: ${CLI_NAME} plan ${CHECK_SUBCOMMAND} <plan.md>
|
|
|
23
23
|
|
|
24
24
|
Checks one plan against the plan contract: phases, tasks in every phase, a model for every phase, a dependency
|
|
25
25
|
row for every phase (from two phases on). One line per part, \`file:line\` where a part is incomplete.
|
|
26
|
-
A fifth line, \`head:\` present, missing or stale, reports the plan's "For you" head;
|
|
26
|
+
A fifth line, \`head:\` present, missing or stale, reports the plan's "For you" head; a sixth, \`purpose:\` present or missing,
|
|
27
|
+
reports whether every phase opens with its one-sentence purpose line. Both are notes and never change the exit code.
|
|
27
28
|
Where git has the base branch, a plan, or a board that links it, that the base branch lacks gets a \`base:\` line with its path.
|
|
28
29
|
The plan language comes from .jackherd/config.json.
|
|
29
30
|
Exit 0 met, 1 with gaps, 2 for a wrong command line or an unreadable file, 3 met but not on the base branch.`
|
|
@@ -10,16 +10,19 @@ import {
|
|
|
10
10
|
phaseCount,
|
|
11
11
|
phaseHeadings,
|
|
12
12
|
phaseLabels,
|
|
13
|
+
phasesWithoutPurpose,
|
|
13
14
|
} from '../plan-format'
|
|
14
|
-
import
|
|
15
|
+
import { lookedForSection } from '../plan-format/markdown'
|
|
16
|
+
import { type PlanLanguage, SECTION_KEY } from '../vocabulary'
|
|
15
17
|
|
|
16
|
-
/** The
|
|
18
|
+
/** The six parts of the plan contract; the head and the purpose lines are notes, never a gap. */
|
|
17
19
|
export const CONTRACT_PART = {
|
|
18
20
|
phases: 'phases',
|
|
19
21
|
tasks: 'tasks',
|
|
20
22
|
model: 'model',
|
|
21
23
|
dependencies: 'dependencies',
|
|
22
24
|
head: 'head',
|
|
25
|
+
purpose: 'purpose',
|
|
23
26
|
} as const
|
|
24
27
|
|
|
25
28
|
export type ContractPart = (typeof CONTRACT_PART)[keyof typeof CONTRACT_PART]
|
|
@@ -37,7 +40,7 @@ export type PartStatus = (typeof PART_STATUS)[keyof typeof PART_STATUS]
|
|
|
37
40
|
/** `line` is the 1-based line of the first problem, or of the part's first sign; null when the plan shows none. */
|
|
38
41
|
export type PartFinding = { part: ContractPart; status: PartStatus; line: number | null; message: string }
|
|
39
42
|
|
|
40
|
-
/** `met` is false when a part is malformed, or phases, tasks or the model table are missing; a missing dependency table
|
|
43
|
+
/** `met` is false when a part is malformed, or phases, tasks or the model table are missing; a missing dependency table, a missing or stale head or a missing purpose line is a note. */
|
|
41
44
|
export type PlanContract = { parts: PartFinding[]; met: boolean }
|
|
42
45
|
|
|
43
46
|
/** Fewer phases than this need no dependency table: there is nothing to put side by side. */
|
|
@@ -64,18 +67,19 @@ const phasesPart = (headings: readonly PhaseHeading[]): PartFinding =>
|
|
|
64
67
|
? finding(CONTRACT_PART.phases, PART_STATUS.missing, null, 'no phase heading')
|
|
65
68
|
: finding(CONTRACT_PART.phases, PART_STATUS.present, headings[0].line, phaseCount(headings.length))
|
|
66
69
|
|
|
67
|
-
/** Present when no phase lacks the part (`ok`), else malformed at the first phase that does, naming them all. */
|
|
70
|
+
/** Present when no phase lacks the part (`ok`), else `unmet` (malformed by default) at the first phase that does, naming them all. */
|
|
68
71
|
const coverage = (
|
|
69
72
|
part: ContractPart,
|
|
70
73
|
without: readonly PhaseHeading[],
|
|
71
74
|
ok: { line: number | null; message: string },
|
|
72
75
|
problem: string,
|
|
76
|
+
unmet: PartStatus = PART_STATUS.malformed,
|
|
73
77
|
): PartFinding =>
|
|
74
78
|
without.length === 0
|
|
75
79
|
? finding(part, PART_STATUS.present, ok.line, ok.message)
|
|
76
80
|
: finding(
|
|
77
81
|
part,
|
|
78
|
-
|
|
82
|
+
unmet,
|
|
79
83
|
without[0].line,
|
|
80
84
|
`${problem} ${phaseLabels(
|
|
81
85
|
without.map(({ id }) => id),
|
|
@@ -108,7 +112,10 @@ const lacks = (headings: readonly PhaseHeading[], listed: ReadonlySet<string>):
|
|
|
108
112
|
|
|
109
113
|
const modelPart = (markdown: string, language: PlanLanguage, headings: readonly PhaseHeading[]): PartFinding => {
|
|
110
114
|
const rows = parseModelTable(markdown, language)
|
|
111
|
-
if (rows === null)
|
|
115
|
+
if (rows === null) {
|
|
116
|
+
const looked = lookedForSection(SECTION_KEY.model, '###', language)
|
|
117
|
+
return finding(CONTRACT_PART.model, PART_STATUS.missing, null, `no model table ${looked}`)
|
|
118
|
+
}
|
|
112
119
|
const modelled = new Set(rows.filter(isDecided).map(({ id }) => id))
|
|
113
120
|
return coverage(
|
|
114
121
|
CONTRACT_PART.model,
|
|
@@ -122,7 +129,10 @@ const dependenciesPart = (markdown: string, language: PlanLanguage, headings: re
|
|
|
122
129
|
if (headings.length < PHASES_NEEDING_DEPENDENCIES)
|
|
123
130
|
return finding(CONTRACT_PART.dependencies, PART_STATUS.present, null, 'not needed with fewer than two phases')
|
|
124
131
|
const table = parseDependencyTable(markdown, language)
|
|
125
|
-
if (table === null)
|
|
132
|
+
if (table === null) {
|
|
133
|
+
const looked = lookedForSection(SECTION_KEY.dependencies, '###', language)
|
|
134
|
+
return finding(CONTRACT_PART.dependencies, PART_STATUS.missing, null, `${NO_DEPENDENCY_TABLE} ${looked}`)
|
|
135
|
+
}
|
|
126
136
|
return coverage(
|
|
127
137
|
CONTRACT_PART.dependencies,
|
|
128
138
|
lacks(headings, new Set(table.map(({ id }) => id))),
|
|
@@ -131,8 +141,18 @@ const dependenciesPart = (markdown: string, language: PlanLanguage, headings: re
|
|
|
131
141
|
)
|
|
132
142
|
}
|
|
133
143
|
|
|
134
|
-
/**
|
|
135
|
-
const
|
|
144
|
+
/** A note: present when every `### Phase` with tasks opens with its purpose line, else missing at the first phase without, naming them all. */
|
|
145
|
+
const purposePart = (markdown: string, language: PlanLanguage): PartFinding =>
|
|
146
|
+
coverage(
|
|
147
|
+
CONTRACT_PART.purpose,
|
|
148
|
+
phasesWithoutPurpose(markdown, language),
|
|
149
|
+
{ line: null, message: 'every phase says what it is about' },
|
|
150
|
+
'no purpose line in',
|
|
151
|
+
PART_STATUS.missing,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
/** Notes, not gaps: a missing dependency table (the phases then run one after another), and the head and the purpose lines, which old plans lack. */
|
|
155
|
+
const NOTE_PARTS: readonly ContractPart[] = [CONTRACT_PART.dependencies, CONTRACT_PART.head, CONTRACT_PART.purpose]
|
|
136
156
|
|
|
137
157
|
export const isGap = ({ part, status }: PartFinding): boolean =>
|
|
138
158
|
status === PART_STATUS.malformed || (status === PART_STATUS.missing && !NOTE_PARTS.includes(part))
|
|
@@ -162,6 +182,7 @@ export const contractOf = (markdown: string, language: PlanLanguage): PlanContra
|
|
|
162
182
|
modelPart(markdown, language, headings),
|
|
163
183
|
dependenciesPart(markdown, language, headings),
|
|
164
184
|
headPart(markdown, language),
|
|
185
|
+
purposePart(markdown, language),
|
|
165
186
|
]
|
|
166
187
|
return { parts, met: !parts.some(isGap) }
|
|
167
188
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
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'
|
|
2
|
+
import { COLUMN_KEY, type PlanLanguage, WAITING_VOCABULARY } from '../vocabulary'
|
|
3
|
+
import { columnIndex, firstTableRows, firstTableWithHeader, sectionLines, sectionStart } from './markdown'
|
|
4
4
|
|
|
5
5
|
/** A changed row of the waiting section (1-based line) whose item cell names `code` bare, outside brackets. */
|
|
6
6
|
export type RowWithBareCode = { line: number; code: string }
|
|
@@ -16,10 +16,10 @@ export const boardRowsWithBareCodes = (
|
|
|
16
16
|
previousLines: ReadonlySet<string>,
|
|
17
17
|
codes: ReadonlySet<string> | null,
|
|
18
18
|
): RowWithBareCode[] => {
|
|
19
|
-
const { heading
|
|
19
|
+
const { heading } = WAITING_VOCABULARY[language]
|
|
20
20
|
const start = sectionStart(markdown, heading)
|
|
21
21
|
const lines = sectionLines(markdown, heading)
|
|
22
|
-
const item = firstTableWithHeader(lines).header.
|
|
22
|
+
const item = columnIndex(firstTableWithHeader(lines).header, COLUMN_KEY.item, language)
|
|
23
23
|
if (item === -1) return []
|
|
24
24
|
return firstTableRows(lines).flatMap(({ index, line, cells }) => {
|
|
25
25
|
const code = previousLines.has(line) ? undefined : bareCodesOf(cells[item] ?? '', codes)[0]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
1
|
+
import { COLUMN_KEY, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
2
|
import { PHASE_ID_PREFIX, phaseIdOf, phaseLabels, spannedPhaseIds } from './lanes'
|
|
3
|
-
import { escapeRegExp, firstTableWithHeader, subsectionLines } from './markdown'
|
|
3
|
+
import { columnIndex, escapeRegExp, firstTableWithHeader, subsectionLines } from './markdown'
|
|
4
4
|
import { type PhaseHeading, parsePlanPhases, phaseHeadings } from './phases'
|
|
5
5
|
import { isPhaseFinished } from './progress'
|
|
6
6
|
|
|
@@ -163,10 +163,10 @@ const releasedByFiles = (writes: readonly string[]): string[] =>
|
|
|
163
163
|
* such table or the table lacks one of its three columns, so a plan that never had one is not asked for rows.
|
|
164
164
|
*/
|
|
165
165
|
export const parseDependencyTable = (markdown: string, language: PlanLanguage): PhaseDependency[] | null => {
|
|
166
|
-
const { sections
|
|
166
|
+
const { sections } = PLAN_VOCABULARY[language]
|
|
167
167
|
const { header, rows } = firstTableWithHeader(subsectionLines(markdown, sections.dependencies))
|
|
168
|
-
const [after, writes, marker] = [
|
|
169
|
-
header
|
|
168
|
+
const [after, writes, marker] = [COLUMN_KEY.dependsOn, COLUMN_KEY.writes, COLUMN_KEY.serialMarker].map((key) =>
|
|
169
|
+
columnIndex(header, key, language),
|
|
170
170
|
)
|
|
171
171
|
if (after === -1 || writes === -1 || marker === -1) return null
|
|
172
172
|
return rows.flatMap((cells) => {
|
package/src/plan-format/lanes.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { posix } from 'node:path'
|
|
2
|
-
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
3
|
-
import { firstHeading, firstTable, firstTableRows, sectionLines, sectionStart } from './markdown'
|
|
2
|
+
import { CELL_KEY, LANE_CLOSED_WORDS, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
3
|
+
import { firstHeading, firstTable, firstTableRows, namesState, sectionLines, sectionStart } from './markdown'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* `namedPhaseIds` holds every id the row names when `withoutRepeats` dropped some because an earlier row of the lane
|
|
@@ -156,14 +156,13 @@ export const withoutRepeats = (lane: SprintLane, step: PlanStep): PlanStep => {
|
|
|
156
156
|
export const parseSprintLanes = (markdown: string, language: PlanLanguage): SprintLane[] => {
|
|
157
157
|
const lanes = new Map<string, SprintLane>()
|
|
158
158
|
const phaseId = phaseIdPatterns(language)
|
|
159
|
-
const { laneClosed } = PLAN_VOCABULARY[language]
|
|
160
159
|
let previous: string | null = null
|
|
161
160
|
for (const cells of firstTable(sectionLines(markdown, PLAN_VOCABULARY[language].sections.lanes))) {
|
|
162
161
|
const name = laneNameOf(cells[0] ?? '', previous)
|
|
163
162
|
if (!name) continue
|
|
164
163
|
const port = Number.parseInt(cells[1] ?? '', 10)
|
|
165
164
|
const lane = lanes.get(name) ?? { name, port: Number.isNaN(port) ? null : port, steps: [], closed: false }
|
|
166
|
-
const marked = (cells[0] ?? ''
|
|
165
|
+
const marked = namesState(cells[0] ?? '', CELL_KEY.laneClosed, LANE_CLOSED_WORDS)
|
|
167
166
|
if (lane.closed && !marked && !(cells[0] ?? '').startsWith(CONTINUATION)) {
|
|
168
167
|
lane.closed = false
|
|
169
168
|
lane.steps = []
|