jackherd 1.24.0 → 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.
Files changed (68) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +21 -21
  3. package/dist/{chunk-JDO6O3UO.js → chunk-O365KHHP.js} +139 -3
  4. package/dist/config.d.ts +4 -0
  5. package/dist/config.js +4 -1
  6. package/dist/plan-format.d.ts +121 -3
  7. package/dist/plan-format.js +176 -35
  8. package/docs/plan-contract.md +52 -5
  9. package/herdr-plugin/herdr-plugin.toml +1 -1
  10. package/install.sh +1 -1
  11. package/kit/hooks/hooks/approval-gate/index.ts +2 -2
  12. package/kit/hooks/hooks/merge-guard/lane-plan.ts +1 -1
  13. package/kit/hooks/hooks/shared/board-lanes.ts +33 -12
  14. package/kit/hooks/hooks/shared/boards.ts +22 -6
  15. package/kit/workflow/skills/orchestrate/references/herd.md +3 -3
  16. package/kit/workflow/skills/plan/SKILL.md +3 -1
  17. package/kit/workflow/skills/plan/references/template-English.md +2 -0
  18. package/kit/workflow/skills/plan/references/template-German.md +2 -0
  19. package/package.json +1 -1
  20. package/schemas/jackherd.schema.json +7 -0
  21. package/src/brief/command.ts +1 -1
  22. package/src/brief/lane-facts.ts +2 -2
  23. package/src/check/command.ts +2 -1
  24. package/src/check/plan-structure-check.ts +68 -14
  25. package/src/check/settings.ts +6 -1
  26. package/src/check/skill-budget-defaults.ts +6 -3
  27. package/src/config/schema.ts +3 -0
  28. package/src/lane/close-board.ts +4 -4
  29. package/src/lane/close-command.ts +2 -2
  30. package/src/lane/dev-command.ts +1 -1
  31. package/src/lane/port-command.ts +1 -1
  32. package/src/plan-contract/command.ts +2 -1
  33. package/src/plan-contract/contract.ts +30 -9
  34. package/src/plan-format/bare-codes.ts +4 -4
  35. package/src/plan-format/dependencies.ts +5 -5
  36. package/src/plan-format/lanes.ts +3 -4
  37. package/src/plan-format/markdown.ts +125 -10
  38. package/src/plan-format/open-status.ts +15 -0
  39. package/src/plan-format/phases.ts +45 -4
  40. package/src/plan-format/plan-head.ts +31 -9
  41. package/src/plan-format/plan-structure.ts +43 -7
  42. package/src/plan-format/progress.ts +3 -0
  43. package/src/plan-format/sprint-document.ts +4 -4
  44. package/src/plan-format/unrecognized-sections.ts +21 -0
  45. package/src/plan-format/waiting.ts +13 -13
  46. package/src/plans/board-close.ts +25 -16
  47. package/src/plans/board-pick.ts +1 -1
  48. package/src/plans/ensure-panes.ts +2 -2
  49. package/src/plans/layout-summary.ts +8 -1
  50. package/src/plans/layout.ts +14 -5
  51. package/src/plans/live.ts +1 -1
  52. package/src/plans/settings.ts +3 -0
  53. package/src/plans/snapshot.ts +9 -5
  54. package/src/plans/sprint-export.ts +1 -1
  55. package/src/plans/texts.ts +3 -0
  56. package/src/plans/view.ts +1 -0
  57. package/src/resume/coordinator-read.ts +2 -2
  58. package/src/settled/command.ts +1 -1
  59. package/src/settled/settle.ts +5 -5
  60. package/src/sync/render.ts +20 -5
  61. package/src/sync/sync-plan.ts +6 -2
  62. package/src/sync/sync.ts +3 -2
  63. package/src/vocabulary/index.ts +192 -4
  64. package/src/watch/appointments.ts +13 -20
  65. package/src/watch/command.ts +5 -5
  66. package/src/watch/lane-progress.ts +1 -1
  67. package/src/watch/merge-overlap-read.ts +2 -2
  68. package/src/watch/plan-missing-read.ts +3 -1
@@ -3,25 +3,40 @@ import {
3
3
  APPOINTMENT_VOCABULARY,
4
4
  APPROVAL_TEXTS,
5
5
  CARD_FIELDS,
6
+ CELL_KEY,
7
+ COLUMN_KEY,
8
+ HEAD_KEY,
6
9
  HEAD_LINE,
10
+ LANE_CLOSED_WORDS,
7
11
  LANE_PROMPT_OPENING,
8
12
  LANGUAGE_NAME,
13
+ LINE_KEY,
14
+ MARKER_IN_TEXT,
9
15
  NEXT_VOCABULARY,
16
+ PARTIAL_PHASE_WORDS,
10
17
  PLAN_LANGUAGE,
11
18
  PLAN_STATUS,
12
19
  PLAN_VOCABULARY,
13
20
  RECOMMENDED_MARK,
14
21
  REPORT_FIELDS,
15
22
  RESPONSE_ADDRESS,
23
+ SECTION_KEY,
16
24
  SELF_MERGE_LABEL,
17
25
  STANDING_APPROVAL_TEXTS,
26
+ STATUS_KEY,
18
27
  WAITING_VOCABULARY,
19
28
  classifyStatus,
29
+ columnWords,
30
+ headingOfSection,
20
31
  isPlanLanguage,
32
+ isSectionKey,
33
+ isStatusKey,
34
+ languagesFrom,
21
35
  phaseHeading,
22
36
  sectionHeading,
37
+ sectionOfHeading,
23
38
  terminalSafe
24
- } from "./chunk-JDO6O3UO.js";
39
+ } from "./chunk-O365KHHP.js";
25
40
 
26
41
  // src/plan-format/lanes.ts
27
42
  import { posix } from "path";
@@ -30,18 +45,74 @@ import { posix } from "path";
30
45
  var SECTION_END = /^##\s/;
31
46
  var TABLE_SEPARATOR = /^\|[\s|:-]+\|$/;
32
47
  var escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
33
- var sectionStart = (markdown, headingText) => {
34
- const heading = new RegExp(`^##\\s+${escapeRegExp(headingText)}\\s*$`);
35
- const start = markdown.split("\n").findIndex((line) => heading.test(line));
36
- return start === -1 ? -1 : start + 1;
48
+ var MARKER = /^<!--\s*jh:\s*([a-z-]+)\s*-->\s*$/;
49
+ var markedKeyOf = (text) => MARKER_IN_TEXT.exec(text)?.[1] ?? null;
50
+ var withoutMarker = (text) => text.replace(MARKER_IN_TEXT, "").trim();
51
+ var lookedForSection = (key, level, language) => {
52
+ const headings = languagesFrom(language).map((candidate) => `"${level} ${headingOfSection(key, candidate)}"`);
53
+ return `(looked for ${headings.join(", ")} or <!-- jh: ${key} -->)`;
54
+ };
55
+ var namesState = (text, key, words) => markedKeyOf(text) === key || words.some((word) => text.includes(word));
56
+ var SECTION_HEADING = /^##\s+(.*?)\s*$/;
57
+ var markerKey = (line) => {
58
+ const key = line === void 0 ? void 0 : MARKER.exec(line)?.[1];
59
+ return key !== void 0 && isSectionKey(key) ? key : null;
60
+ };
61
+ var SECTION_MATCH = { pattern: SECTION_HEADING, matches: (text, heading) => text === heading };
62
+ var SUBSECTION_MATCH = {
63
+ pattern: /^(#{2,4})\s+(.*?)\s*$/,
64
+ matches: (text, heading) => text.startsWith(heading)
65
+ };
66
+ var headingText = (line, { pattern }) => pattern.exec(line)?.at(-1);
67
+ var headingIndexOf = (lines, key, language, match) => {
68
+ const marked = lines.findIndex((line, index) => match.pattern.test(line) && markerKey(lines[index + 1]) === key);
69
+ if (marked !== -1) return marked;
70
+ for (const candidate of languagesFrom(language)) {
71
+ const heading = headingOfSection(key, candidate);
72
+ const found = lines.findIndex((line) => {
73
+ const text = headingText(line, match);
74
+ return text !== void 0 && match.matches(text, heading);
75
+ });
76
+ if (found !== -1) return found;
77
+ }
78
+ return -1;
37
79
  };
38
- var sectionLines = (markdown, headingText) => {
39
- const start = sectionStart(markdown, headingText);
40
- if (start === -1) return [];
41
- const rest = markdown.split("\n").slice(start);
80
+ var findSectionStart = (markdown, key, language) => {
81
+ const lines = markdown.split("\n");
82
+ const heading = headingIndexOf(lines, key, language, SECTION_MATCH);
83
+ if (heading === -1) return -1;
84
+ return heading + (markerKey(lines[heading + 1]) === key ? 2 : 1);
85
+ };
86
+ var findSectionHeading = (markdown, key, language) => headingIndexOf(markdown.split("\n"), key, language, SECTION_MATCH);
87
+ var untilNextSection = (rest) => {
42
88
  const end = rest.findIndex((line) => SECTION_END.test(line));
43
89
  return end === -1 ? rest : rest.slice(0, end);
44
90
  };
91
+ var columnIndex = (header, key, language) => {
92
+ const marked = header.findIndex((cell) => markedKeyOf(cell) === key);
93
+ if (marked !== -1) return marked;
94
+ const names = header.map(withoutMarker);
95
+ for (const candidate of languagesFrom(language)) {
96
+ for (const word of columnWords(key, candidate)) {
97
+ const found = names.indexOf(word);
98
+ if (found !== -1) return found;
99
+ }
100
+ }
101
+ return -1;
102
+ };
103
+ var exactSectionStart = (markdown, headingTextValue) => {
104
+ const heading = new RegExp(`^##\\s+${escapeRegExp(headingTextValue)}\\s*$`);
105
+ const start = markdown.split("\n").findIndex((line) => heading.test(line));
106
+ return start === -1 ? -1 : start + 1;
107
+ };
108
+ var sectionStart = (markdown, headingText2) => {
109
+ const section = sectionOfHeading(headingText2);
110
+ return section === null ? exactSectionStart(markdown, headingText2) : findSectionStart(markdown, section.key, section.language);
111
+ };
112
+ var sectionLines = (markdown, headingText2) => {
113
+ const start = sectionStart(markdown, headingText2);
114
+ return start === -1 ? [] : untilNextSection(markdown.split("\n").slice(start));
115
+ };
45
116
  var tableCells = (line) => line.slice(1, line.endsWith("|") ? -1 : void 0).split("|").map((cell) => terminalSafe(cell).trim());
46
117
  var firstTableRows = (lines) => {
47
118
  const start = lines.findIndex((line) => line.startsWith("|"));
@@ -57,10 +128,14 @@ var headerField = (markdown, name) => {
57
128
  };
58
129
  var firstHeading = (markdown, fallback) => terminalSafe(markdown.match(/^#\s+(.+)$/m)?.[1] ?? fallback).trim();
59
130
  var HEADING = /^(#{2,4})\s+(.*?)\s*$/;
60
- var subsectionStart = (markdown, headingText) => markdown.split("\n").findIndex((line) => HEADING.exec(line)?.[2].startsWith(headingText));
61
- var subsectionLines = (markdown, headingText) => {
131
+ var subsectionStart = (markdown, headingText2) => {
132
+ const section = sectionOfHeading(headingText2);
133
+ if (section !== null) return headingIndexOf(markdown.split("\n"), section.key, section.language, SUBSECTION_MATCH);
134
+ return markdown.split("\n").findIndex((line) => HEADING.exec(line)?.[2].startsWith(headingText2));
135
+ };
136
+ var subsectionLines = (markdown, headingText2) => {
62
137
  const lines = markdown.split("\n");
63
- const start = subsectionStart(markdown, headingText);
138
+ const start = subsectionStart(markdown, headingText2);
64
139
  if (start === -1) return [];
65
140
  const level = HEADING.exec(lines[start])?.[1].length ?? 2;
66
141
  const rest = lines.slice(start + 1);
@@ -166,14 +241,13 @@ var withoutRepeats = (lane, step) => {
166
241
  var parseSprintLanes = (markdown, language) => {
167
242
  const lanes = /* @__PURE__ */ new Map();
168
243
  const phaseId = phaseIdPatterns(language);
169
- const { laneClosed } = PLAN_VOCABULARY[language];
170
244
  let previous = null;
171
245
  for (const cells of firstTable(sectionLines(markdown, PLAN_VOCABULARY[language].sections.lanes))) {
172
246
  const name = laneNameOf(cells[0] ?? "", previous);
173
247
  if (!name) continue;
174
248
  const port = Number.parseInt(cells[1] ?? "", 10);
175
249
  const lane = lanes.get(name) ?? { name, port: Number.isNaN(port) ? null : port, steps: [], closed: false };
176
- const marked = (cells[0] ?? "").includes(laneClosed);
250
+ const marked = namesState(cells[0] ?? "", CELL_KEY.laneClosed, LANE_CLOSED_WORDS);
177
251
  if (lane.closed && !marked && !(cells[0] ?? "").startsWith(CONTINUATION)) {
178
252
  lane.closed = false;
179
253
  lane.steps = [];
@@ -227,7 +301,7 @@ var phaseWorkFields = (work) => work === PHASE_WORK.merged ? { work, touched: tr
227
301
  var REVIEW_GATE = /^\s*\*\*review[- ]gate\b/i;
228
302
  var PHASE_END = /^#{1,3}\s/;
229
303
  var CHECKBOX = /^\s*[-*]\s+\[([ xX])\]\s+(.*)$/;
230
- var SECTION_HEADING = /^##\s+(.+?)\s*$/;
304
+ var SECTION_HEADING2 = /^##\s+(.+?)\s*$/;
231
305
  var TOP_HEADING = /^#\s/;
232
306
  var TABLE_STATUS = { done: "\u2705", open: "\u2B1C" };
233
307
  var ROW_NUMBER = /^\d+(\.\d+)*[a-z]?$/;
@@ -240,6 +314,19 @@ var endsPhase = (line) => PHASE_END.test(line);
240
314
  var isCheckboxLine = (line) => CHECKBOX.test(line);
241
315
  var isOpenCheckboxLine = (line) => line.match(CHECKBOX)?.[1] === " ";
242
316
  var phaseHeadingPattern = (language) => new RegExp(`^###\\s+${escapeRegExp(PLAN_VOCABULARY[language].phase)}\\s+(\\d+[a-z]?)\\b\\s*[:\u2014\u2013-]?\\s*(.*)$`, "i");
317
+ var BOLD_OPENING = /^\s*\*\*([^*]+?)\s*:?\*\*\s*:?\s*(.*)$/;
318
+ var namesPurpose = (line, label, language) => {
319
+ if (markedKeyOf(line) === LINE_KEY.purpose) return true;
320
+ const written = label.trim().toLowerCase();
321
+ return languagesFrom(language).some((candidate) => PLAN_VOCABULARY[candidate].phasePurpose.toLowerCase() === written);
322
+ };
323
+ var phasePurposeOf = (line, language) => {
324
+ const opening = BOLD_OPENING.exec(line);
325
+ if (!opening || !namesPurpose(line, opening[1], language)) return null;
326
+ const text = terminalSafe(withoutMarker(opening[2])).trim();
327
+ return text === "" ? null : text;
328
+ };
329
+ var decidesPurpose = (decided, line) => !decided && line.trim() !== "";
243
330
  var tableCells2 = (line) => {
244
331
  const trimmed = line.trim();
245
332
  if (!trimmed.startsWith("|")) return null;
@@ -266,7 +353,7 @@ var sectionItems = (markdown) => {
266
353
  const sections = /* @__PURE__ */ new Map();
267
354
  let open = null;
268
355
  for (const line of markdown.split("\n")) {
269
- const heading = line.match(SECTION_HEADING);
356
+ const heading = line.match(SECTION_HEADING2);
270
357
  if (heading?.[1]) {
271
358
  open = [];
272
359
  sections.set(heading[1], open);
@@ -341,6 +428,12 @@ var notePointer = (pointers, phase, line) => {
341
428
  const pointer = phase && !pointers.has(phase) ? line.match(SECTION_POINTER)?.[1] : void 0;
342
429
  if (phase && pointer) pointers.set(phase, pointer);
343
430
  };
431
+ var notePurpose = (read, phase, line, language) => {
432
+ if (!phase || !decidesPurpose(read.has(phase), line)) return;
433
+ read.add(phase);
434
+ const purpose = phasePurposeOf(line, language);
435
+ if (purpose !== null) phase.purpose = purpose;
436
+ };
344
437
  var headingOf = (line, phaseHeading2) => line.match(phaseHeading2) ?? line.match(NUMBERED_SECTION);
345
438
  var phaseHeadings = (markdown, language) => {
346
439
  const phaseHeading2 = phaseHeadingPattern(language);
@@ -352,15 +445,22 @@ var phaseHeadings = (markdown, language) => {
352
445
  var parsePlanPhases = (markdown, language) => {
353
446
  const phaseHeading2 = phaseHeadingPattern(language);
354
447
  const pointers = /* @__PURE__ */ new Map();
448
+ const purposeRead = /* @__PURE__ */ new Set();
355
449
  const { phases } = markdown.split("\n").reduce(
356
450
  ({ phases: phases2, open, gate }, line) => {
357
451
  const heading = headingOf(line, phaseHeading2);
358
452
  if (heading) {
359
- const phase = { id: (heading[1] ?? "").toLowerCase(), title: terminalSafe(heading[2] ?? "").trim(), items: [] };
453
+ const phase = {
454
+ id: (heading[1] ?? "").toLowerCase(),
455
+ title: terminalSafe(heading[2] ?? "").trim(),
456
+ items: []
457
+ };
458
+ if (!phaseHeading2.test(line)) purposeRead.add(phase);
360
459
  return { phases: [...phases2, phase], open: phase, gate: false };
361
460
  }
362
461
  if (PHASE_END.test(line)) return { phases: phases2, open: null, gate: false };
363
462
  const inGate = gate || REVIEW_GATE.test(line);
463
+ notePurpose(purposeRead, open, line, language);
364
464
  if (open) fileLine(open, line, inGate);
365
465
  notePointer(pointers, open, line);
366
466
  return { phases: phases2, open, gate: inGate };
@@ -385,6 +485,7 @@ var phaseProgress = (phases, id) => {
385
485
  done: items.filter((item) => item.done).length,
386
486
  total: items.length,
387
487
  items,
488
+ ...phase?.purpose ? { purpose: phase.purpose } : {},
388
489
  ...phase?.deferred ? { deferred: phase.deferred } : {},
389
490
  ...phase?.work ? phaseWorkFields(phase.work) : {}
390
491
  };
@@ -546,10 +647,10 @@ var releasedByFiles = (writes) => writes.flatMap((path) => {
546
647
  return [packageKey(directory.at(-1) ?? ".")];
547
648
  });
548
649
  var parseDependencyTable = (markdown, language) => {
549
- const { sections, columns } = PLAN_VOCABULARY[language];
650
+ const { sections } = PLAN_VOCABULARY[language];
550
651
  const { header, rows } = firstTableWithHeader(subsectionLines(markdown, sections.dependencies));
551
- const [after, writes, marker] = [columns.dependsOn, columns.writes, columns.serialMarker].map(
552
- (name) => header.indexOf(name)
652
+ const [after, writes, marker] = [COLUMN_KEY.dependsOn, COLUMN_KEY.writes, COLUMN_KEY.serialMarker].map(
653
+ (key) => columnIndex(header, key, language)
553
654
  );
554
655
  if (after === -1 || writes === -1 || marker === -1) return null;
555
656
  return rows.flatMap((cells) => {
@@ -790,16 +891,21 @@ var tallyPhases = (markdown, language) => {
790
891
 
791
892
  // src/plan-format/plan-head.ts
792
893
  var HEAD_STATUS = { present: "present", missing: "missing", stale: "stale" };
793
- var SECTION_HEADING2 = /^##\s/;
894
+ var SECTION_HEADING3 = /^##\s/;
794
895
  var HEAD_ENTRY = /^\s*(?:[-*]\s+)?\*\*([^*]+?)\s*:?\*\*\s*:?\s*(.*)$/;
795
896
  var HEAD_LINES = Object.values(HEAD_LINE);
796
897
  var labelOf = (language, line) => PLAN_VOCABULARY[language].head[line].toLowerCase();
898
+ var labelNamed = (text, written, language) => {
899
+ const marked = markedKeyOf(text);
900
+ const byMarker = HEAD_LINES.find((each) => HEAD_KEY[each] === marked);
901
+ if (byMarker) return byMarker;
902
+ return languagesFrom(language).map((candidate) => HEAD_LINES.find((each) => labelOf(candidate, each) === written)).find((each) => each !== void 0);
903
+ };
797
904
  var entryOf = (text, language) => {
798
905
  const match = text.match(HEAD_ENTRY);
799
906
  if (!match) return null;
800
- const written = match[1].trim().toLowerCase();
801
- const label = HEAD_LINES.find((each) => labelOf(language, each) === written);
802
- return label ? { label, text: match[2].trim() } : null;
907
+ const label = labelNamed(text, match[1].trim().toLowerCase(), language);
908
+ return label ? { label, text: withoutMarker(match[2]) } : null;
803
909
  };
804
910
  var phaseOf = (statusText, language) => {
805
911
  const match = statusText?.match(
@@ -809,11 +915,10 @@ var phaseOf = (statusText, language) => {
809
915
  };
810
916
  var parsePlanHead = (markdown, language) => {
811
917
  const lines = markdown.split("\n");
812
- const heading = new RegExp(`^##\\s+${escapeRegExp(PLAN_VOCABULARY[language].sections.forYou)}\\s*$`);
813
- const start = lines.findIndex((text) => heading.test(text));
918
+ const start = findSectionHeading(markdown, SECTION_KEY.forYou, language);
814
919
  if (start === -1) return null;
815
920
  const rest = lines.slice(start + 1);
816
- const end = rest.findIndex((text) => SECTION_HEADING2.test(text));
921
+ const end = rest.findIndex((text) => SECTION_HEADING3.test(text));
817
922
  const texts = {};
818
923
  const lineNumbers = {};
819
924
  for (const [offset, text] of (end === -1 ? rest : rest.slice(0, end)).entries()) {
@@ -829,7 +934,7 @@ var parsePlanHead = (markdown, language) => {
829
934
  lineNumbers,
830
935
  missing: HEAD_LINES.filter((each) => texts[each] === void 0),
831
936
  phase: phaseOf(texts[HEAD_LINE.status], language),
832
- misplaced: lines.slice(0, start).some((text) => SECTION_HEADING2.test(text))
937
+ misplaced: lines.slice(0, start).some((text) => SECTION_HEADING3.test(text))
833
938
  };
834
939
  };
835
940
  var verdict = (status, line, message) => ({ status, line, message });
@@ -843,8 +948,14 @@ var phaseMismatch = ({ current, total }, states) => {
843
948
  };
844
949
  var checkPlanHead = (markdown, language) => {
845
950
  const head = parsePlanHead(markdown, language);
846
- const { sections } = PLAN_VOCABULARY[language];
847
- if (head === null) return verdict(HEAD_STATUS.missing, null, `no \`## ${sections.forYou}\` section`);
951
+ if (head === null) {
952
+ const looked = lookedForSection(SECTION_KEY.forYou, "##", language);
953
+ return verdict(
954
+ HEAD_STATUS.missing,
955
+ null,
956
+ `no \`## ${PLAN_VOCABULARY[language].sections.forYou}\` section ${looked}`
957
+ );
958
+ }
848
959
  if (head.misplaced)
849
960
  return verdict(
850
961
  HEAD_STATUS.stale,
@@ -864,15 +975,21 @@ var checkPlanHead = (markdown, language) => {
864
975
  };
865
976
 
866
977
  // src/plan-format/plan-structure.ts
867
- var noGatePattern = (language) => new RegExp(`^\\s*\\*\\*${escapeRegExp(PLAN_VOCABULARY[language].noReviewGate)}\\b`, "i");
978
+ var NO_GATE_WORDS = new RegExp(
979
+ `^\\s*\\*\\*(?:${Object.values(PLAN_VOCABULARY).map(({ noReviewGate }) => escapeRegExp(noReviewGate)).join("|")})\\b`,
980
+ "i"
981
+ );
982
+ var isNoGateLine = (line) => NO_GATE_WORDS.test(line) || BOLD_OPENING.test(line) && markedKeyOf(line) === LINE_KEY.noReviewGate;
868
983
  var tickCount = (phase) => phase.items.filter((item) => item.done).length;
869
984
  var tasksDone = (phase) => {
870
985
  const tasks = phase.items.filter((item) => !item.gate);
871
986
  return tasks.length > 0 && tasks.every((item) => item.done);
872
987
  };
988
+ var settlePurpose = (section, line, language) => {
989
+ if (decidesPurpose(section.purpose !== null, line)) section.purpose = phasePurposeOf(line, language) !== null;
990
+ };
873
991
  var phaseSections = (markdown, language) => {
874
992
  const heading = phaseHeadingPattern(language);
875
- const noGate = noGatePattern(language);
876
993
  const sections = [];
877
994
  let open = null;
878
995
  for (const [index, line] of markdown.split("\n").entries()) {
@@ -884,14 +1001,16 @@ var phaseSections = (markdown, language) => {
884
1001
  title: terminalSafe(match[2]).trim(),
885
1002
  tasks: false,
886
1003
  gate: false,
1004
+ purpose: null,
887
1005
  openLines: []
888
1006
  };
889
1007
  sections.push(open);
890
1008
  } else if (endsPhase(line)) {
891
1009
  open = null;
892
1010
  } else if (open) {
1011
+ settlePurpose(open, line, language);
893
1012
  open.tasks ||= isCheckboxLine(line);
894
- open.gate ||= isReviewGateLine(line) || noGate.test(line);
1013
+ open.gate ||= isReviewGateLine(line) || isNoGateLine(line);
895
1014
  if (isOpenCheckboxLine(line) && deferralTarget(line, open.id) === null) open.openLines.push(index + 1);
896
1015
  }
897
1016
  }
@@ -899,6 +1018,8 @@ var phaseSections = (markdown, language) => {
899
1018
  };
900
1019
  var phaseIdsOfPlan = (markdown, language) => new Set(phaseSections(markdown, language).map(({ id }) => id));
901
1020
  var newPhasesWithoutReviewGate = (markdown, language, previousIds) => phaseSections(markdown, language).filter(({ id, tasks, gate }) => tasks && !gate && !previousIds.has(id)).map(({ line, id, title }) => ({ line, id, title }));
1021
+ var newPhasesWithoutPurpose = (markdown, language, previousIds) => phaseSections(markdown, language).filter(({ id, tasks, purpose }) => tasks && !purpose && !previousIds.has(id)).map(({ line, id, title }) => ({ line, id, title }));
1022
+ var phasesWithoutPurpose = (markdown, language) => newPhasesWithoutPurpose(markdown, language, /* @__PURE__ */ new Set());
902
1023
  var hasLanesTable = (markdown, language) => sectionLines(markdown, PLAN_VOCABULARY[language].sections.lanes).some((line) => line.startsWith("|"));
903
1024
  var mergedPhasesWithOpenBoxes = (markdown, base, language) => {
904
1025
  const baseById = new Map(parsePlanPhases(base, language).map((phase) => [phase.id, phase]));
@@ -977,9 +1098,9 @@ var readSprintDocument = (markdown, language) => {
977
1098
  };
978
1099
  };
979
1100
  var readPhaseDependencies = (planMarkdown, language) => {
980
- const { sections, columns } = PLAN_VOCABULARY[language];
1101
+ const { sections } = PLAN_VOCABULARY[language];
981
1102
  const { header, rows } = firstTableWithHeader(subsectionLines(planMarkdown, sections.dependencies));
982
- const column = header.indexOf(columns.dependsOn);
1103
+ const column = columnIndex(header, COLUMN_KEY.dependsOn, language);
983
1104
  if (column === -1) return {};
984
1105
  const entries = rows.flatMap((cells) => {
985
1106
  const id = PHASE_ID_IN_CELL.exec(cells[0] ?? "")?.[1];
@@ -991,13 +1112,21 @@ export {
991
1112
  ADDRESS_FORM,
992
1113
  APPOINTMENT_VOCABULARY,
993
1114
  APPROVAL_TEXTS,
1115
+ BOLD_OPENING,
994
1116
  CARD_FIELDS,
1117
+ CELL_KEY,
1118
+ COLUMN_KEY,
1119
+ HEAD_KEY,
995
1120
  HEAD_LINE,
996
1121
  HEAD_STATUS,
1122
+ LANE_CLOSED_WORDS,
997
1123
  LANE_PROMPT_OPENING,
998
1124
  LANGUAGE_NAME,
1125
+ LINE_KEY,
1126
+ MARKER_IN_TEXT,
999
1127
  NEXT_VOCABULARY,
1000
1128
  NO_DEPENDENCY_TABLE,
1129
+ PARTIAL_PHASE_WORDS,
1001
1130
  PHASE_CELL_LIMIT,
1002
1131
  PHASE_ID_PREFIX,
1003
1132
  PHASE_TITLE_LIMIT,
@@ -1009,10 +1138,12 @@ export {
1009
1138
  RECOMMENDED_MARK,
1010
1139
  REPORT_FIELDS,
1011
1140
  RESPONSE_ADDRESS,
1141
+ SECTION_KEY,
1012
1142
  SELF_MERGE_LABEL,
1013
1143
  SHORT_NAME_LIMIT,
1014
1144
  SHORT_NAME_PROBLEM,
1015
1145
  STANDING_APPROVAL_TEXTS,
1146
+ STATUS_KEY,
1016
1147
  WAITING_VOCABULARY,
1017
1148
  activeItem,
1018
1149
  boardRowsWithProse,
@@ -1020,15 +1151,18 @@ export {
1020
1151
  checkPlanHead,
1021
1152
  classifyStatus,
1022
1153
  columnModels,
1154
+ columnWords,
1023
1155
  countDecisions,
1024
1156
  criticalPath,
1025
1157
  currentPhase,
1026
1158
  currentPhaseOf,
1159
+ decidesPurpose,
1027
1160
  deferralTarget,
1028
1161
  differsFromColumn,
1029
1162
  endsPhase,
1030
1163
  findDrift,
1031
1164
  hasLanesTable,
1165
+ headingOfSection,
1032
1166
  isCheckboxLine,
1033
1167
  isDocumentationGateItem,
1034
1168
  isOpenCheckboxLine,
@@ -1036,12 +1170,16 @@ export {
1036
1170
  isPhaseOpen,
1037
1171
  isPlanLanguage,
1038
1172
  isReviewGateLine,
1173
+ isSectionKey,
1174
+ isStatusKey,
1039
1175
  isTickedUnmerged,
1040
1176
  laneNameOf,
1041
1177
  laneProgress,
1178
+ languagesFrom,
1042
1179
  longPhaseTitles,
1043
1180
  mergedPhasesWithOpenBoxes,
1044
1181
  misplacedDeferrals,
1182
+ newPhasesWithoutPurpose,
1045
1183
  newPhasesWithoutReviewGate,
1046
1184
  onlyDocumentationOpen,
1047
1185
  openBoxesOfPhase,
@@ -1062,15 +1200,18 @@ export {
1062
1200
  phaseLabel,
1063
1201
  phaseLabels,
1064
1202
  phaseProgress,
1203
+ phasePurposeOf,
1065
1204
  phaseWaves,
1066
1205
  phaseWorkFields,
1067
1206
  phasesText,
1068
1207
  phasesWithoutDependencyRow,
1208
+ phasesWithoutPurpose,
1069
1209
  readPhaseDependencies,
1070
1210
  readPlanHeader,
1071
1211
  readSprintDocument,
1072
1212
  resolvePlanPath,
1073
1213
  sectionHeading,
1214
+ sectionOfHeading,
1074
1215
  shortNameFindings,
1075
1216
  shortTitle,
1076
1217
  sprintTitleOf,
@@ -11,6 +11,7 @@ jackherd reads a plan from its Markdown. This is what a plan has to hold for the
11
11
  | Model | a table under `### Model recommendations per phase`: the phase in the first cell (`1 — Title`, `P1`, `10c`), the model in the second (`claude-opus-5-5`, `Opus 5.5`); a dash marks a phase that needs no model | yes | lane start (the model a lane runs on), `/implement` |
12
12
  | Dependencies | a table under `### Dependencies and parallelization` with the columns `Depends on`, `Writes`, `Serial marker` and one row per phase | from two phases on, optional | `jh check --parallel`, `/implement` |
13
13
  | Head | a section `## For you` (de: `## Für dich`) right after the header lines (`**Date:**`, `**Status:**`, `**Scope:**`), before the first other `##` section, with five lines `- **<label>:** <text>` | no, a note | `jh plan check`, the reader who is not an agent |
14
+ | Purpose | directly under every `### Phase` heading, one line `**What it is about:** <sentence>` (de: `**Worum es geht:** <Satz>`): what is different afterwards, for whom | no, a note | `jh plan check`, `jh check` (new phases), the herd |
14
15
 
15
16
  Details that decide a verdict:
16
17
 
@@ -21,7 +22,47 @@ Details that decide a verdict:
21
22
  - The head's five labels are fixed per language. English: `What changes for you`, `Status`, `Your open decisions`, `Try it now`, `Next step`; German: `Was sich für dich ändert`, `Stand`, `Deine offenen Entscheidungen`, `Schon ausprobieren`, `Nächster Schritt`. The colon may stand outside the bold (`**Status**:`) and the list mark may be left out.
22
23
  - The `Status` line carries `Phase x/y`: x the id of the first phase with an open checkbox, as its heading spells it (`3`, `3a`), y the number of phases, each id counted once. When every phase is done, x is the id of the last one.
23
24
  - The head is `missing` without the section. It is `stale` when it stands behind another section, lacks a line, has no `Phase x/y`, or names another phase or phase count than the checkboxes show. Both are notes: the contract stays met and the exit code does not change, so plans written before the head keep passing.
24
- - The headings come from the plan language in `.jackherd/config.json` (`planLanguage`, `de` or `en`); the English ones are shown here.
25
+ - The purpose line counts when it is the first non-empty line under a `### Phase` heading and has text after the colon; the colon may stand outside the bold or be left out. A line behind the origin line does not count. Only `### Phase` headings are read; phases headed `## 1 — …` or `## P1 — …` and the closing `## N — …` section are not. `purpose` is `missing` (`no purpose line in P2, P3`) when a phase with checkboxes of its own lacks the line (a phase that only points at a section needs none); it is a note and the exit code does not change, so older plans keep passing. `jh check` goes further for a phase the change adds: it fails without the line; phases the plan already had are not read.
26
+ - The headings are shown in English here; a plan or board is read in German as well, and in any other language by markers (see below).
27
+
28
+ ## Language and markers
29
+
30
+ jackherd reads boards and plans in every language it has words for (English and German), whatever `.jackherd/config.json` says: a German board next to an English plan works, and so does the other way round. The configuration decides only what jh **writes**:
31
+
32
+ | Key | Decides | Default |
33
+ | --- | ------- | ------- |
34
+ | `planLanguage` | the language of new plans and of the plan templates | `en` |
35
+ | `boardLanguage` | the language jh writes the board in: history lines, lane rows, waiting rows | the value of `planLanguage` |
36
+
37
+ A config without `boardLanguage` runs as before. A heading, column name or status word is looked up in this order: the marker, the word of the configured language, the word of the other language.
38
+
39
+ A document in a language jackherd has no words for carries a **marker**, `<!-- jh: <key> -->`, which Markdown does not show. The key comes from a closed set; an unknown key is ignored.
40
+
41
+ | Where the marker stands | Keys |
42
+ | ----------------------- | ---- |
43
+ | on the line directly under a `##` (or `###`) heading | `waiting`, `appointments`, `lanes`, `goal`, `history`, `plans`, `decisions`, `implementation`, `model`, `dependencies`, `documentation`, `for-you`, `next` |
44
+ | in a header cell of a table | `since`, `item`, `origin`, `unblocks`, `status`, `when`, `action`, `depends-on`, `writes`, `serial-marker` |
45
+ | on a line of the head | `head-changes`, `head-status`, `head-decisions`, `head-try-now`, `head-next` |
46
+ | in a status cell | `status-open` (a board row that still waits), `status-done`, `status-abandoned`, `status-in-progress`, `status-draft` |
47
+ | on a line that opens with bold words | `no-review-gate`, `purpose` |
48
+ | in a lane's name cell or phases cell | `lane-closed`, `partial-phase` |
49
+
50
+ ```markdown
51
+ ## Pistes
52
+ <!-- jh: lanes -->
53
+
54
+ | Piste | Port | Plan |
55
+ | ----- | ---- | ---- |
56
+ | `checkout` nettoyé <!-- jh: lane-closed --> | 3200 | [Plan](checkout.md) |
57
+
58
+ ### Phase 1: Surface
59
+
60
+ **Le but** <!-- jh: purpose --> Les clients voient leurs filtres en haut de la liste.
61
+ ```
62
+
63
+ When no section of a changed plan or board is recognised, three places say so: `jh plan check` names the forms it looked for (`no model table (looked for "### Model recommendations per phase", "### Model-Empfehlungen pro Phase" or <!-- jh: model -->)`), `jh check` warns on a changed file that has `##` headings of which jh reads none, and the herd shows `board: no section recognised` instead of "nothing waits for you". A section that is absent but allowed (no waiting item) stays silent.
64
+
65
+ The plan templates are written in the language of their file name: `template-English.md` always in English, `template-German.md` always in German, whatever `planLanguage` says.
25
66
 
26
67
  ## Minimal valid plan
27
68
 
@@ -49,11 +90,15 @@ Details that decide a verdict:
49
90
 
50
91
  ### Phase 1: Query parser
51
92
 
93
+ **What it is about:** nothing visible yet; the search box can turn typed text into filters.
94
+
52
95
  - [ ] **Parse filters**: `parseQuery(text)` returns the filter list
53
96
  - [ ] **Test it**: every operator, one malformed input
54
97
 
55
98
  ### Phase 2: Filter bar
56
99
 
100
+ **What it is about:** people who search see their filters as chips above the results and can remove each one.
101
+
57
102
  - [ ] **Render the bar**: one chip per filter
58
103
  ```
59
104
 
@@ -70,22 +115,24 @@ phases: present plan.md:22 2 phases
70
115
  tasks: present plan.md:22 every phase has tasks
71
116
  model: present plan.md:12 every phase has a model
72
117
  dependencies: present every phase has a row
73
- head: missing no `## For you` section
118
+ head: missing no `## For you` section (looked for "## For you", "## Für dich" or <!-- jh: for-you -->)
119
+ purpose: present every phase says what it is about
74
120
  ```
75
121
 
76
- If the model table lacked the row for phase 2, and the dependency table were left out, the lines would read:
122
+ If the model table lacked the row for phase 2, the dependency table were left out and the phases had no purpose lines, the lines would read:
77
123
 
78
124
  ```
79
125
  phases: present plan.md:22 2 phases
80
126
  tasks: present plan.md:22 every phase has tasks
81
127
  model: malformed plan.md:22 no model for P2
82
- dependencies: missing no dependency table: phases run one after another
128
+ dependencies: missing no dependency table: phases run one after another (looked for "### Dependencies and parallelization", "### Abhängigkeiten und Parallelisierung" or <!-- jh: dependencies -->)
83
129
  head: stale plan.md:5 names phase 1, the running phase is 2
130
+ purpose: missing plan.md:22 no purpose line in P1, P2
84
131
  ```
85
132
 
86
133
  | Exit | Meaning |
87
134
  | ---- | ------- |
88
- | 0 | the contract is met (a missing dependency table and a missing or stale head are notes, not gaps) |
135
+ | 0 | the contract is met (a missing dependency table, a missing or stale head and a missing purpose line are notes, not gaps) |
89
136
  | 1 | at least one part is missing or malformed |
90
137
  | 2 | wrong command line: no subcommand, unknown subcommand, no file, unreadable file, unreadable config |
91
138
  | 3 | the contract is met, but the plan or a board that links it is not on the base branch (a `base:` line names each path); the coordinator commits it first (`/orchestrate` § 2 step 0), after which the check counts as done |
@@ -1,6 +1,6 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.24.0"
3
+ version = "1.25.0"
4
4
  min_herdr_version = "0.8.2"
5
5
  description = "Show the herd, the state of a board's lanes, in a herdr pane"
6
6
  platforms = ["linux", "macos"]
package/install.sh CHANGED
@@ -9,7 +9,7 @@ set -eu
9
9
 
10
10
  # The default is the version this file ships with. The release bump changes it together with
11
11
  # package.json; install.test.ts fails while the two differ. Overridable, but only by a plain release.
12
- JACKHERD_VERSION="${JACKHERD_VERSION:-1.24.0}"
12
+ JACKHERD_VERSION="${JACKHERD_VERSION:-1.25.0}"
13
13
 
14
14
  JACKHERD_PACKAGE='jackherd'
15
15
  # Test seam: the terminal the wizard asks its questions on.
@@ -59,11 +59,11 @@ const writeHistory = (main: string, entry: StandingHistoryEntry): boolean => {
59
59
  try {
60
60
  const location = boardLocationOf(readConfig([main]))
61
61
  if (location === null) return false
62
- const board = boardForLane(boardsIn(main, location), entry.lane, location.planLanguage)
62
+ const board = boardForLane(boardsIn(main, location), entry.lane, location.boardLanguage)
63
63
  if (board === null) return false
64
64
  const path = join(main, board.directory, location.board.file)
65
65
  const text = readTextOrNull(path)
66
- const next = text === null ? null : withHistoryRow(text, location.planLanguage, historyDate(entry.at), standingHistoryLine(entry))
66
+ const next = text === null ? null : withHistoryRow(text, location.boardLanguage, historyDate(entry.at), standingHistoryLine(entry))
67
67
  if (next === null) return false
68
68
  writeFileSync(path, next)
69
69
  return true
@@ -47,5 +47,5 @@ const worktreeListOf = (main: string): string | undefined => {
47
47
  export const planOfBranch = (main: string, settings: BoardSettings, branch: string): string | undefined => {
48
48
  const list = worktreeListOf(main)
49
49
  const lane = list === undefined ? undefined : laneInWorktreeList(list, main, settings.worktreeDirectory, branch)
50
- return lane === undefined ? undefined : onlyPlanOf(boardsIn(main, settings), lane, settings.planLanguage)
50
+ return lane === undefined ? undefined : onlyPlanOf(boardsIn(main, settings), lane, settings.boardLanguage)
51
51
  }