jackherd 1.6.1 → 1.7.1

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 (156) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/INSTALL.md +176 -0
  3. package/README.md +228 -312
  4. package/assets/branding/README.md +7 -3
  5. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  6. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  7. package/assets/branding/jackherd-logo-dark.svg +1 -1
  8. package/assets/icons/bark-dark.svg +1 -0
  9. package/assets/icons/bark.svg +1 -0
  10. package/assets/icons/bowl-dark.svg +1 -0
  11. package/assets/icons/bowl.svg +1 -0
  12. package/assets/icons/decide-dark.svg +1 -0
  13. package/assets/icons/decide.svg +1 -0
  14. package/assets/icons/ear-dark.svg +1 -0
  15. package/assets/icons/ear.svg +1 -0
  16. package/assets/icons/lanes-dark.svg +1 -0
  17. package/assets/icons/lanes.svg +1 -0
  18. package/assets/icons/plan-dark.svg +1 -0
  19. package/assets/icons/plan.svg +1 -0
  20. package/assets/icons/space.svg +1 -0
  21. package/dist/{chunk-QJSXHHQ3.js → chunk-OBUWTQ6Z.js} +25 -0
  22. package/dist/config.d.ts +14 -3
  23. package/dist/config.js +111 -10
  24. package/dist/plan-format.d.ts +180 -77
  25. package/dist/plan-format.js +292 -93
  26. package/docs/lanes-dark.svg +50 -0
  27. package/docs/lanes.svg +50 -1
  28. package/docs/skills-dark.svg +65 -0
  29. package/docs/skills.svg +65 -1
  30. package/docs/workflow-dark.svg +138 -0
  31. package/docs/workflow.svg +135 -169
  32. package/herdr-plugin/herdr-plugin.toml +6 -6
  33. package/herdr-plugin/open-plans.ts +1 -1
  34. package/herdr-plugin/restore-plans.ts +7 -7
  35. package/install.sh +1 -1
  36. package/kit/hooks/hooks/approval-gate/decision.ts +61 -0
  37. package/kit/hooks/hooks/approval-gate/herdr-panes.ts +24 -0
  38. package/kit/hooks/hooks/approval-gate/index.ts +69 -0
  39. package/kit/hooks/hooks/approval-gate/lane.ts +14 -0
  40. package/kit/hooks/hooks/approval-gate/request.ts +111 -0
  41. package/kit/hooks/hooks/approval-gate/run.ts +94 -0
  42. package/kit/hooks/hooks/approval-gate/settings.ts +26 -0
  43. package/kit/hooks/hooks/approval-gate/store.ts +70 -0
  44. package/kit/hooks/hooks/approval-gate/transcripts.ts +40 -0
  45. package/kit/hooks/hooks/merge-guard/approval.ts +11 -74
  46. package/kit/hooks/hooks/merge-guard/run.ts +2 -1
  47. package/kit/hooks/hooks/model-gate/agent-definition.ts +13 -0
  48. package/kit/hooks/hooks/model-gate/index.ts +58 -0
  49. package/kit/hooks/hooks/model-gate/run.ts +63 -0
  50. package/kit/hooks/hooks/model-log/index.ts +60 -0
  51. package/kit/hooks/hooks/model-log/run.ts +57 -0
  52. package/kit/hooks/hooks/model-log/store.ts +40 -0
  53. package/kit/hooks/hooks/model-log/transcript.ts +37 -0
  54. package/kit/hooks/hooks/shared/checkout.ts +22 -0
  55. package/kit/hooks/hooks/shared/command.ts +6 -0
  56. package/kit/hooks/hooks/shared/config.ts +21 -0
  57. package/kit/hooks/hooks/shared/dialog-answer.ts +5 -0
  58. package/kit/hooks/hooks/shared/dialog-answers.ts +74 -0
  59. package/kit/hooks/hooks/shared/files.ts +19 -0
  60. package/kit/hooks/hooks/shared/fresh.ts +4 -0
  61. package/kit/hooks/hooks/shared/json.ts +4 -0
  62. package/kit/hooks/hooks/shared/model-family.ts +48 -0
  63. package/kit/hooks/hooks/shared/plan-model-table.ts +41 -0
  64. package/kit/hooks/hooks/shared/private-directory.ts +29 -0
  65. package/kit/hooks/hooks/shared/repository-directory.ts +14 -0
  66. package/kit/hooks/hooks/shared/subagent-phase.ts +99 -0
  67. package/kit/hooks/hooks.json +25 -0
  68. package/kit/workflow/skills/follow/SKILL.md +4 -3
  69. package/kit/workflow/skills/implement/SKILL.md +6 -6
  70. package/kit/workflow/skills/orchestrate/SKILL.md +109 -27
  71. package/kit/workflow/skills/plan/SKILL.md +5 -5
  72. package/kit/workflow/skills/start/SKILL.md +2 -2
  73. package/package.json +4 -1
  74. package/schemas/jackherd.schema.json +43 -5
  75. package/src/check/command.ts +31 -4
  76. package/src/check/deferral-arrows.ts +1 -1
  77. package/src/check/parallel-report.ts +23 -0
  78. package/src/check/plan-structure-check.ts +120 -53
  79. package/src/cli.ts +8 -2
  80. package/src/config/legacy-names.ts +8 -0
  81. package/src/config/nested-quantifier.ts +73 -0
  82. package/src/config/repository-directory.ts +11 -0
  83. package/src/config/schema.ts +63 -8
  84. package/src/plan-format/board-prose.ts +36 -0
  85. package/src/plan-format/dependencies.ts +227 -0
  86. package/src/plan-format/documentation-gate.ts +16 -0
  87. package/src/plan-format/index.ts +23 -1
  88. package/src/plan-format/lanes.ts +35 -1
  89. package/src/plan-format/model-column.ts +52 -0
  90. package/src/plan-format/phases.ts +16 -1
  91. package/src/plan-format/plan-structure.ts +10 -28
  92. package/src/plan-format/progress.ts +7 -29
  93. package/src/plans/command.ts +3 -3
  94. package/src/plans/current-phase.ts +71 -0
  95. package/src/plans/ensure-panes.ts +2 -2
  96. package/src/plans/herd-command.ts +4 -0
  97. package/src/plans/lane-discovery.ts +14 -2
  98. package/src/plans/lane-tickets.ts +69 -0
  99. package/src/plans/layout.ts +182 -88
  100. package/src/plans/live.ts +5 -1
  101. package/src/plans/phase-models-read.ts +37 -0
  102. package/src/plans/phase-models.ts +70 -0
  103. package/src/plans/plan-panes.ts +8 -7
  104. package/src/plans/pull-requests.ts +78 -4
  105. package/src/plans/selection.ts +1 -40
  106. package/src/plans/settings.ts +9 -3
  107. package/src/plans/shown-with-more.ts +5 -0
  108. package/src/plans/snapshot.ts +82 -8
  109. package/src/plans/sprint-html.ts +2 -2
  110. package/src/plans/supervisor.ts +5 -4
  111. package/src/plans/symbols.ts +9 -6
  112. package/src/plans/texts.ts +30 -13
  113. package/src/plans/view.ts +1 -0
  114. package/src/sync/command.ts +16 -1
  115. package/src/sync/herdr-plugin.ts +1 -1
  116. package/src/sync/next-steps.ts +1 -1
  117. package/src/sync/render.ts +2 -1
  118. package/src/sync/setup-questions.ts +2 -2
  119. package/src/terminal/text.ts +4 -0
  120. package/src/terminal/types.ts +1 -1
  121. package/src/update/command.ts +4 -2
  122. package/src/update/update.ts +33 -9
  123. package/src/vocabulary/index.ts +39 -0
  124. package/src/watch/approvals.ts +96 -0
  125. package/src/watch/command.ts +154 -50
  126. package/src/watch/failed-tests.ts +52 -0
  127. package/src/watch/failure-read.ts +56 -0
  128. package/src/watch/failures.ts +107 -0
  129. package/src/watch/feedback-item.ts +21 -0
  130. package/src/watch/feedback-read.ts +2 -1
  131. package/src/watch/feedback.ts +37 -28
  132. package/src/watch/gh-process.ts +53 -0
  133. package/src/watch/gh.ts +10 -20
  134. package/src/watch/heartbeat-progress.ts +1 -1
  135. package/src/watch/lane-model.ts +54 -0
  136. package/src/watch/lane-parallel.ts +46 -0
  137. package/src/watch/lane-progress.ts +28 -4
  138. package/src/watch/lane-report.ts +62 -9
  139. package/src/watch/lane-sources.ts +1 -1
  140. package/src/watch/no-finding.ts +47 -0
  141. package/src/watch/plan-badges-read.ts +19 -7
  142. package/src/watch/plan-badges.ts +59 -7
  143. package/src/watch/plan-views.ts +5 -5
  144. package/src/watch/previews.ts +3 -9
  145. package/src/watch/pull-requests.ts +12 -2
  146. package/src/watch/recorded-reads.ts +25 -0
  147. package/src/watch/source-fields.ts +4 -0
  148. package/src/watch/source-tickets.ts +99 -0
  149. package/src/watch/sources.ts +62 -15
  150. package/src/watch/texts.ts +21 -1
  151. package/src/watch/watch-defaults.ts +8 -0
  152. package/src/watch/watch-events.ts +64 -4
  153. package/src/watch/watcher-lock.ts +1 -1
  154. package/src/watch/workflow-runs.ts +5 -7
  155. package/docs/lanes.mmd +0 -9
  156. package/docs/skills.mmd +0 -10
@@ -33,16 +33,16 @@ Skip this when the request already carries its decisions (a plan for a bug fix w
33
33
 
34
34
  **Structure:** copy the template in [`references/template-{{kit.planLanguage}}.md`](references/template-{{kit.planLanguage}}.md) and fill it in. Every section stays, in its order, with its heading verbatim.
35
35
 
36
- The closing documentation section reads `## N — …` with rows `N.1` to `N.5`: replace `N` with the number of that phase (`## 10 — …`, `10.1`). A literal `N` is no phase to the plans view, which shows it as "not in the plan".
36
+ The closing documentation section reads `## N — …` with rows `N.1` to `N.5`: replace `N` with the number of that phase (`## 10 — …`, `10.1`). A literal `N` is no phase to the herd, which shows it as "not in the plan".
37
37
 
38
- A checkbox moved on purpose to a later phase stays where it is, unticked, and ends in the target: `- [ ] Update the documentation → P9`. The plans view counts it neither as open nor as done in its own phase and lists it as open in phase 9, so a phase whose only open box was moved on reads as finished. The arrow counts only at the end of the line; `jh check` refuses an open box in a changed plan that carries it anywhere else (`- [ ] → P9 Update …`). Once a phase's tasks are ticked on the base branch and the change does not tick it further, every box still open in it, gate boxes included, is ticked or moved on this way: `jh check` warns on a changed plan that leaves one.
38
+ A checkbox moved on purpose to a later phase stays where it is, unticked, and ends in the target: `- [ ] Update the documentation → P9`. The herd counts it neither as open nor as done in its own phase and lists it as open in phase 9, so a phase whose only open box was moved on reads as finished. The arrow counts only at the end of the line; `jh check` refuses an open box in a changed plan that carries it anywhere else (`- [ ] → P9 Update …`). Once a phase's tasks are ticked on the base branch and the change does not tick it further, every box still open in it, gate boxes included, is ticked or moved on this way: `jh check` warns on a changed plan that leaves one.
39
39
 
40
40
  What the template's tables mean:
41
41
 
42
42
  - **Model recommendations per phase** — one row per phase, judged by **error cost × complexity**. The project's default model for implementation (`{{kit.model.implement}}`) is the default of every row. A phase moves up to the planning model (`{{kit.model.plan}}`) when its rationale names a concrete error cost: a breakable invariant, possible data loss, security or tenant isolation, a migration without a way back, distributed state across systems. "Complex" or "many files" alone keeps the default. Review gates with `/security-audit` always get the strongest model.
43
43
  - **Dependencies and parallelization** (from three phases) — `/implement` reads it and starts every phase whose dependencies are done and that shares no written path with another, at the same time.
44
44
  - **{{kit.heading.dependsOn}}:** phases of this plan (numbers) or external gates (`User: mockups`). A phase is ready when everything here is done.
45
- - **{{kit.heading.writes}}:** the directories or files the phase creates or changes, as narrow as possible. Shared files many phases touch (barrels, translation files, `package.json`, generated types, architecture docs) are listed explicitly, so the overlap is visible.
45
+ - **{{kit.heading.writes}}:** the directories or files the phase creates or changes, as narrow as possible. Shared files many phases touch (barrels, translation files, `package.json`, generated types, architecture docs) are listed explicitly, so the overlap is visible. Write each path in full from the repository root: paths are compared as written, so `src/watch/` and `packages/app/src/watch/` do not overlap.
46
46
  - **{{kit.heading.serialMarker}}:** `Migration`, `<collection> schema bump`, `Event <type>`, a generator run. Two phases with a migration never run at the same time; neither do two that bump the same schema.
47
47
 
48
48
  ### 3. Architecture Review
@@ -66,7 +66,7 @@ Every phase gets the review gate block from the template at its end. Add the rev
66
66
  - **Shared code checked:** For every planned component it is documented why it is built new instead of reused or extended.
67
67
  - **Tests defined:** The tests section is filled in or carries an explicit rationale why none are needed (decision tree in `.claude/rules/kit/testing.md`).
68
68
  - **Model recommendations defined:** `## {{kit.heading.implementation}}` starts with the model table, one row per phase, justified by error cost × complexity.
69
- - **Parallelism defined (3+ phases):** the dependencies table follows it. Check it against the phase bodies: every migration, schema bump and new event in a phase appears as a serial marker; every shared file appears under {{kit.heading.writes}}. A dependency that exists only in your head lets `/implement` start two phases that collide.
69
+ - **Parallelism defined (3+ phases):** the dependencies table follows it. Check it against the phase bodies: every migration, schema bump and new event in a phase appears as a serial marker; every shared file appears under {{kit.heading.writes}}. A dependency that exists only in your head lets `/implement` start two phases that collide. Every phase gets a row, a phase added later too: `jh check` warns about a phase without one.
70
70
  - **arc42 check (after the plan exists):** apply the filter in `.claude/rules/kit/arc42.md` to the finished plan and **ask the user concretely** which sections should be extended. On "yes", add a concrete task to `## {{kit.heading.documentation}}`.
71
71
  - **Architecture check (after the plan exists):** apply the filter in `.claude/rules/kit/architecture-files.md` — new ownership, a new invariant or an accepted risk each trigger a concrete question about the unit's `ARCHITECTURE.md`.
72
72
  - **arc42 closing phase (3+ phases or a new service/package):** end the plan with the architecture documentation phase from the template. Add arc42 sections beyond it only when they deliver real value.
@@ -97,7 +97,7 @@ Derive the rows from the dependencies table and the model table, nothing else:
97
97
  - **Port:** `–`; the coordinator sets it when the lane starts, unless the project supplement assigns ports here.
98
98
 
99
99
  Show the rows as the table they become: `## {{kit.heading.lanes}}` with the columns lane, port, plan, phases, starts
100
- after, model, in this order and headed in {{kit.planLanguage}} (the plans view reads them by position). Then ask
100
+ after, model, in this order and headed in {{kit.planLanguage}} (the herd reads them by position). Then ask
101
101
  where they go:
102
102
 
103
103
  - **The newest board**, by name: the newest `{{kit.planDirectory}}/{{kit.board.pattern}}/{{kit.board.file}}`. The
@@ -14,7 +14,7 @@ Show this to the user, shortened to what fits on one screen:
14
14
 
15
15
  - Describe a task. `/plan` writes the plan in `{{kit.planDirectory}}/` and, when asked, a board for parallel lanes.
16
16
  - Build it in one session with `/implement`, or run the board's lanes side by side with `/orchestrate` inside herdr.
17
- - Follow progress with `jh plans`. Inside herdr, `/orchestrate` opens the plan view in a herdr pane beside the coordinator and beside each lane.
17
+ - Follow progress with `jh herd`. Inside herdr, `/orchestrate` opens the herd in a herdr pane beside the coordinator and beside each lane.
18
18
  - `/follow <item>` has the watcher report changes of a ticket, issue or status page outside GitHub.
19
19
  - herdr is optional, needed only for parallel lanes. The first setup (`jh init`, which the installer runs) offers its installer when it is missing, on Linux and macOS and only when it can ask.
20
20
 
@@ -36,7 +36,7 @@ Report a short list, one line per item, marked present or missing. For each miss
36
36
  - Session outside herdr: start Claude Code from a herdr pane.
37
37
  - `gh`: install the GitHub CLI (`gh`), then run `gh auth login`.
38
38
 
39
- State plainly that herdr is only needed to run parallel lanes with `/orchestrate`. `/plan`, `/implement` and `jh plans` work without it.
39
+ State plainly that herdr is only needed to run parallel lanes with `/orchestrate`. `/plan`, `/implement` and `jh herd` work without it.
40
40
 
41
41
  ## 3. Offer the first step
42
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.6.1",
3
+ "version": "1.7.1",
4
4
  "keywords": [
5
5
  "claude-code",
6
6
  "coding-agents",
@@ -45,8 +45,11 @@
45
45
  "!src/test-support",
46
46
  "schemas",
47
47
  "docs",
48
+ "!docs/gen",
48
49
  "assets",
50
+ "!assets/icons/build.ts",
49
51
  "README.md",
52
+ "INSTALL.md",
50
53
  "CHANGELOG.md",
51
54
  "LICENSE",
52
55
  "NOTICE",
@@ -317,9 +317,7 @@
317
317
  "additionalProperties": false
318
318
  },
319
319
  "feedback": {
320
- "default": {
321
- "ignore": []
322
- },
320
+ "default": {},
323
321
  "anyOf": [
324
322
  {
325
323
  "type": "boolean",
@@ -335,12 +333,34 @@
335
333
  "type": "string",
336
334
  "minLength": 1
337
335
  }
336
+ },
337
+ "noFindingTemplates": {
338
+ "default": [
339
+ "## Code review\n\nNo issues found. Checked for bugs and CLAUDE.md compliance."
340
+ ],
341
+ "type": "array",
342
+ "items": {
343
+ "type": "string",
344
+ "minLength": 1
345
+ }
338
346
  }
339
347
  },
340
348
  "additionalProperties": false
341
349
  }
342
350
  ]
343
351
  },
352
+ "checks": {
353
+ "default": {},
354
+ "type": "object",
355
+ "properties": {
356
+ "failedTestPattern": {
357
+ "default": "^\\(fail\\) (.+?)(?: \\[[\\d.]+m?s\\])?$",
358
+ "type": "string",
359
+ "minLength": 1
360
+ }
361
+ },
362
+ "additionalProperties": false
363
+ },
344
364
  "previews": {
345
365
  "type": "object",
346
366
  "properties": {
@@ -410,11 +430,13 @@
410
430
  },
411
431
  "author": {
412
432
  "type": "string"
433
+ },
434
+ "status": {
435
+ "type": "string"
413
436
  }
414
437
  },
415
438
  "required": [
416
- "id",
417
- "updatedAt"
439
+ "id"
418
440
  ],
419
441
  "additionalProperties": {
420
442
  "type": "string"
@@ -438,6 +460,9 @@
438
460
  "type": "string",
439
461
  "minLength": 1
440
462
  },
463
+ "ticket": {
464
+ "type": "boolean"
465
+ },
441
466
  "reportOn": {
442
467
  "minItems": 1,
443
468
  "maxItems": 10,
@@ -490,6 +515,19 @@
490
515
  }
491
516
  },
492
517
  "additionalProperties": false
518
+ },
519
+ "approvals": {
520
+ "default": {},
521
+ "type": "object",
522
+ "properties": {
523
+ "waitMinutes": {
524
+ "default": 8,
525
+ "type": "integer",
526
+ "minimum": 1,
527
+ "maximum": 9
528
+ }
529
+ },
530
+ "additionalProperties": false
493
531
  }
494
532
  },
495
533
  "additionalProperties": false
@@ -1,13 +1,14 @@
1
1
  import { spawnSync } from 'node:child_process'
2
2
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
3
3
  import { homedir } from 'node:os'
4
- import { join } from 'node:path'
4
+ import { join, resolve } from 'node:path'
5
5
  import { findProjectRoot, globalConfigPath, locateConfigFile } from '../config/location'
6
6
  import { readRepositoryFacts } from '../config/repository-facts'
7
7
  import { baseRefOf } from '../plans/settings'
8
8
  import { type ChangedFile, parsePorcelain, STATUS_COMMAND } from './changes'
9
9
  import { deferralArrowCheck } from './deferral-arrows'
10
10
  import { runCheck, runProcess } from './execute'
11
+ import { parallelArgument, parallelReportLines } from './parallel-report'
11
12
  import { type CheckPlan, type Filesystem, isSlow, planChecks } from './plan'
12
13
  import { planStructureChecks } from './plan-structure-check'
13
14
  import { publicContentCheck } from './public-content-check'
@@ -69,6 +70,26 @@ const loadSettings = (configPath: string, root: string): CheckSettings | string
69
70
  }
70
71
  }
71
72
 
73
+ const readPlanFile = (file: string): string | null => {
74
+ try {
75
+ return readFileSync(file, 'utf8')
76
+ } catch {
77
+ return null
78
+ }
79
+ }
80
+
81
+ /** `jh check --parallel <plan>`: the groups of phases that can run side by side, read from the plan as it is on disk. */
82
+ const printParallel = (path: string, settings: CheckSettings): number => {
83
+ const file = resolve(process.cwd(), path)
84
+ const markdown = path === '' ? null : readPlanFile(file)
85
+ if (markdown === null) {
86
+ console.error(`--parallel needs the path of a readable plan file, got "${path}"`)
87
+ return 1
88
+ }
89
+ print(parallelReportLines(markdown, settings.planLanguage))
90
+ return 0
91
+ }
92
+
72
93
  const changedFiles = async (root: string): Promise<ChangedFile[] | string> => {
73
94
  const status = await runProcess(STATUS_COMMAND, root)
74
95
  return status.ok ? parsePorcelain(status.output) : status.output
@@ -91,7 +112,7 @@ const executePlan = async (
91
112
 
92
113
  /**
93
114
  * Runs the configured checks over the files `git status` reports. Exit code 1 when any check fails
94
- * or the configuration cannot be read.
115
+ * or the configuration cannot be read. With `--parallel <plan>` it runs no check and prints the plan's parallel groups.
95
116
  */
96
117
  export const run = async (args: string[], home: string = homedir()): Promise<number> => {
97
118
  const target = resolveCheckTarget(process.cwd(), home, existsSync, gitTopLevelOf)
@@ -101,9 +122,15 @@ export const run = async (args: string[], home: string = homedir()): Promise<num
101
122
  }
102
123
  const { root, configPath } = target
103
124
  const settings = loadSettings(configPath, root)
125
+ if (typeof settings === 'string') {
126
+ console.error(settings)
127
+ return 1
128
+ }
129
+ const parallel = parallelArgument(args)
130
+ if (parallel !== null) return printParallel(parallel, settings)
104
131
  const files = await changedFiles(root)
105
- if (typeof settings === 'string' || typeof files === 'string') {
106
- console.error(typeof settings === 'string' ? settings : files)
132
+ if (typeof files === 'string') {
133
+ console.error(files)
107
134
  return 1
108
135
  }
109
136
  print(settings.notes)
@@ -23,7 +23,7 @@ const deferralArrowResult = (findings: readonly DeferralFinding[], plans: number
23
23
  })
24
24
 
25
25
  /**
26
- * Open checkboxes in the changed plans whose `→ P<n>` is not at the end of the line, where the plans view reads
26
+ * Open checkboxes in the changed plans whose `→ P<n>` is not at the end of the line, where the herd reads
27
27
  * it. Plans outside the diff are not read. Null when no plan changed.
28
28
  */
29
29
  export const deferralArrowCheck = (
@@ -0,0 +1,23 @@
1
+ import { parallelGroupsOfPlan, phasesText } from '../plan-format'
2
+ import type { PlanLanguage } from '../vocabulary'
3
+
4
+ /** The flag by which `jh check` prints the parallel groups of one plan instead of running the checks. */
5
+ export const PARALLEL_FLAG = '--parallel'
6
+
7
+ /** The plan path after `--parallel`: empty when it is missing, null when the flag is absent. */
8
+ export const parallelArgument = (args: readonly string[]): string | null => {
9
+ const index = args.indexOf(PARALLEL_FLAG)
10
+ return index === -1 ? null : (args[index + 1] ?? '')
11
+ }
12
+
13
+ /**
14
+ * What `jh check --parallel <plan>` prints: one line per group of phases that can run side by side (the first group
15
+ * can start now), or the reason there is none.
16
+ */
17
+ export const parallelReportLines = (markdown: string, language: PlanLanguage): string[] => {
18
+ const groups = parallelGroupsOfPlan(markdown, language)
19
+ if (groups === null) return ['no dependency table']
20
+ return groups.length === 0
21
+ ? ['no phase is ready']
22
+ : groups.map((group, index) => `group ${index + 1}: ${phasesText(group)}`)
23
+ }
@@ -2,15 +2,21 @@ import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import {
4
4
  boardRowsWithoutPhase,
5
+ boardRowsWithProse,
5
6
  hasLanesTable,
6
7
  type MergedPhaseWithOpenBoxes,
7
8
  mergedPhasesWithOpenBoxes,
8
9
  newPhasesWithoutReviewGate,
10
+ PHASE_CELL_LIMIT,
9
11
  type PhaseWithoutGate,
12
+ type PhaseWithoutRow,
10
13
  PLAN_VOCABULARY,
11
14
  type PlanLanguage,
15
+ PROSE_REASON,
12
16
  phaseIdsOfPlan,
17
+ phasesWithoutDependencyRow,
13
18
  type RowWithoutPhase,
19
+ type RowWithProse,
14
20
  } from '../plan-format'
15
21
  import { changedPlansOf } from './deferral-arrows'
16
22
  import { CHECK_STATUS, type CheckResult } from './result'
@@ -31,60 +37,97 @@ export type PlanCheckInput = {
31
37
  baseOf: ContentAt
32
38
  }
33
39
 
34
- type Changed = { path: string; current: string; previous: string | null; base: string | null; board: boolean }
40
+ type Changed = {
41
+ path: string
42
+ current: string
43
+ previous: string | null
44
+ previousLines: ReadonlySet<string>
45
+ base: string | null
46
+ board: boolean
47
+ }
35
48
 
36
49
  type Found<T> = T & { path: string }
37
50
 
38
- const gateResult = (
39
- findings: readonly Found<PhaseWithoutGate>[],
40
- plans: number,
41
- language: PlanLanguage,
51
+ type Report<T> = {
52
+ name: string
53
+ noun: 'plan' | 'board'
54
+ /** What the check asks of each changed file, as the command column reads after the count. */
55
+ rule: string
56
+ /** The status of a check with findings. */
57
+ unmet: typeof CHECK_STATUS.fail | typeof CHECK_STATUS.warn
58
+ text: (found: Found<T>) => string
59
+ clean: string
60
+ }
61
+
62
+ const changedCount = (count: number, noun: string): string => `${count} changed ${count === 1 ? noun : `${noun}s`}`
63
+
64
+ const findingsResult = <T>(
65
+ { name, noun, rule, unmet, text, clean }: Report<T>,
66
+ findings: readonly Found<T>[],
67
+ files: number,
42
68
  durationMs: number,
43
69
  ): CheckResult => ({
44
- name: 'Plan review gates',
45
- command: `${plans} changed ${plans === 1 ? 'plan' : 'plans'}, new phases need a review gate`,
46
- status: findings.length === 0 ? CHECK_STATUS.pass : CHECK_STATUS.fail,
47
- detail:
48
- findings
49
- .map(
50
- ({ path, line, id, title }) =>
51
- `${path}:${line} Phase ${id} ${title} has tasks but no review gate; add one, or a **${PLAN_VOCABULARY[language].noReviewGate}:** line with the reason`,
52
- )
53
- .join('\n') || 'every new phase has a review gate',
70
+ name,
71
+ command: `${changedCount(files, noun)}, ${rule}`,
72
+ status: findings.length === 0 ? CHECK_STATUS.pass : unmet,
73
+ detail: findings.map(text).join('\n') || clean,
54
74
  durationMs,
55
75
  })
56
76
 
57
- const rowResult = (findings: readonly Found<RowWithoutPhase>[], boards: number, durationMs: number): CheckResult => ({
58
- name: 'Board rows without a phase',
59
- command: `${boards} changed ${boards === 1 ? 'board' : 'boards'}, changed lane rows name a phase`,
60
- status: findings.length === 0 ? CHECK_STATUS.pass : CHECK_STATUS.warn,
61
- detail:
62
- findings
63
- .map(
64
- ({ path, line, lane, phases }) =>
65
- `${path}:${line} lane ${lane}: "${phases}" names no phase (P<n>), so the view cannot tie the step to one`,
66
- )
67
- .join('\n') || 'every changed lane row names a phase',
68
- durationMs,
77
+ const gateReport = (language: PlanLanguage): Report<PhaseWithoutGate> => ({
78
+ name: 'Plan review gates',
79
+ noun: 'plan',
80
+ rule: 'new phases need a review gate',
81
+ unmet: CHECK_STATUS.fail,
82
+ text: ({ path, line, id, title }) =>
83
+ `${path}:${line} Phase ${id} ${title} has tasks but no review gate; add one, or a **${PLAN_VOCABULARY[language].noReviewGate}:** line with the reason`,
84
+ clean: 'every new phase has a review gate',
69
85
  })
70
86
 
71
- const mergedResult = (
72
- findings: readonly Found<MergedPhaseWithOpenBoxes>[],
73
- plans: number,
74
- durationMs: number,
75
- ): CheckResult => ({
87
+ const rowReport: Report<RowWithoutPhase> = {
88
+ name: 'Board rows without a phase',
89
+ noun: 'board',
90
+ rule: 'changed lane rows name a phase',
91
+ unmet: CHECK_STATUS.warn,
92
+ text: ({ path, line, lane, phases }) =>
93
+ `${path}:${line} lane ${lane}: "${phases}" names no phase (P<n>), so the herd cannot tie the step to one`,
94
+ clean: 'every changed lane row names a phase',
95
+ }
96
+
97
+ const proseText = ({ reason, length }: RowWithProse): string =>
98
+ reason === PROSE_REASON.sideOrder
99
+ ? 'the phase cell records a side order of the user'
100
+ : `the phase cell has ${length} characters (limit ${PHASE_CELL_LIMIT})`
101
+
102
+ const proseReport: Report<RowWithProse> = {
103
+ name: 'Board rows with prose',
104
+ noun: 'board',
105
+ rule: 'changed lane rows name the phase only',
106
+ unmet: CHECK_STATUS.warn,
107
+ text: (found) =>
108
+ `${found.path}:${found.line} lane ${found.lane}: ${proseText(found)}; it belongs as a task in the plan`,
109
+ clean: 'every changed lane row names the phase only',
110
+ }
111
+
112
+ const mergedReport: Report<MergedPhaseWithOpenBoxes> = {
76
113
  name: 'Merged phases with open boxes',
77
- command: `${plans} changed ${plans === 1 ? 'plan' : 'plans'}, a merged phase ticks or moves on its open boxes`,
78
- status: findings.length === 0 ? CHECK_STATUS.pass : CHECK_STATUS.warn,
79
- detail:
80
- findings
81
- .map(
82
- ({ path, line, id, title, openLines }) =>
83
- `${path}:${line} Phase ${id} ${title} is merged but has open boxes at ${openLines.join(', ')}; tick them or end them with → P<n>`,
84
- )
85
- .join('\n') || 'no merged phase has an open box left',
86
- durationMs,
87
- })
114
+ noun: 'plan',
115
+ rule: 'a merged phase ticks or moves on its open boxes',
116
+ unmet: CHECK_STATUS.warn,
117
+ text: ({ path, line, id, title, openLines }) =>
118
+ `${path}:${line} Phase ${id} ${title} is merged but has open boxes at ${openLines.join(', ')}; tick them or end them with → P<n>`,
119
+ clean: 'no merged phase has an open box left',
120
+ }
121
+
122
+ const unlistedReport: Report<PhaseWithoutRow> = {
123
+ name: 'Phases without a dependency row',
124
+ noun: 'plan',
125
+ rule: 'every phase has a row in the dependency table',
126
+ unmet: CHECK_STATUS.warn,
127
+ text: ({ path, line, id, title }) =>
128
+ `${path}:${line} Phase ${id} ${title} has no row in the dependency table; add one with its dependencies, writes and serial marker`,
129
+ clean: 'every phase has a row in the dependency table',
130
+ }
88
131
 
89
132
  const withPath =
90
133
  <T>(path: string) =>
@@ -95,25 +138,38 @@ const gatesOf = ({ path, current, previous }: Changed, language: PlanLanguage):
95
138
  withPath(path),
96
139
  )
97
140
 
98
- const rowsOf = ({ path, current, previous }: Changed, language: PlanLanguage): Found<RowWithoutPhase>[] =>
99
- boardRowsWithoutPhase(current, language, new Set(previous?.split('\n') ?? [])).map(withPath(path))
141
+ const rowsOf = ({ path, current, previousLines }: Changed, language: PlanLanguage): Found<RowWithoutPhase>[] =>
142
+ boardRowsWithoutPhase(current, language, previousLines).map(withPath(path))
143
+
144
+ const proseOf = ({ path, current, previousLines }: Changed, language: PlanLanguage): Found<RowWithProse>[] =>
145
+ boardRowsWithProse(current, language, previousLines).map(withPath(path))
100
146
 
101
147
  const mergedOf = ({ path, current, base }: Changed, language: PlanLanguage): Found<MergedPhaseWithOpenBoxes>[] =>
102
148
  base ? mergedPhasesWithOpenBoxes(current, base, language).map(withPath(path)) : []
103
149
 
150
+ const unlistedOf = ({ path, current }: Changed, language: PlanLanguage): Found<PhaseWithoutRow>[] | null =>
151
+ phasesWithoutDependencyRow(current, language)?.map(withPath(path)) ?? null
152
+
104
153
  const changedContents = ({ root, previousOf, baseOf, language }: PlanCheckInput, plans: readonly string[]): Changed[] =>
105
154
  plans
106
155
  .filter((path) => existsSync(join(root, path)))
107
156
  .map((path) => {
108
157
  const current = readFileSync(join(root, path), 'utf8')
109
- return { path, current, previous: previousOf(path), base: baseOf(path), board: hasLanesTable(current, language) }
158
+ const previous = previousOf(path)
159
+ const previousLines = new Set(previous?.split('\n') ?? [])
160
+ return { path, current, previous, previousLines, base: baseOf(path), board: hasLanesTable(current, language) }
110
161
  })
111
162
 
112
163
  /**
113
- * The structure of the changed plans and boards: a phase the change added (against `HEAD`) with tasks and without
114
- * a review gate fails; a lane row the change added or edited that names no phase warns; a phase whose tasks are
115
- * all ticked on the base branch and that still has open boxes without `→ P<n>` warns. Empty when no plan changed;
116
- * the row result is left out when no changed file has a lanes table, the merged result when every one has.
164
+ * The structure of the changed plans and boards:
165
+ * - a phase the change added (against `HEAD`) with tasks and without a review gate fails;
166
+ * - a lane row the change added or edited that names no phase warns, and so does one whose phases cell is longer
167
+ * than `PHASE_CELL_LIMIT` or records a side order of the user;
168
+ * - a phase whose tasks are all ticked on the base branch and that still has open boxes without `→ P<n>` warns;
169
+ * - a phase of a plan with a dependency table that has no row in it warns.
170
+ *
171
+ * Empty when no plan changed. The row and prose results are left out when no changed file has a lanes table, the
172
+ * merged result when every one has, the dependency result when no changed plan has the table.
117
173
  */
118
174
  export const planStructureChecks = (input: PlanCheckInput): CheckResult[] => {
119
175
  const files = changedPlansOf(input.paths, input.planDirectory)
@@ -123,13 +179,24 @@ export const planStructureChecks = (input: PlanCheckInput): CheckResult[] => {
123
179
  const changed = changedContents(input, files)
124
180
  const boards = changed.filter(({ board }) => board)
125
181
  const plans = changed.filter(({ board }) => !board)
182
+ const unlistedPerPlan = plans.map((file) => unlistedOf(file, language)).filter((found) => found !== null)
126
183
  const gates = changed.flatMap((file) => gatesOf(file, language))
127
184
  const rows = boards.flatMap((file) => rowsOf(file, language))
185
+ const prose = boards.flatMap((file) => proseOf(file, language))
128
186
  const merged = plans.flatMap((file) => mergedOf(file, language))
187
+ const unlisted = unlistedPerPlan.flat()
129
188
  const durationMs = Date.now() - started
130
189
  return [
131
- gateResult(gates, files.length, language, durationMs),
132
- ...(boards.length > 0 ? [rowResult(rows, boards.length, durationMs)] : []),
133
- ...(plans.length > 0 ? [mergedResult(merged, plans.length, durationMs)] : []),
190
+ findingsResult(gateReport(language), gates, files.length, durationMs),
191
+ ...(boards.length > 0
192
+ ? [
193
+ findingsResult(rowReport, rows, boards.length, durationMs),
194
+ findingsResult(proseReport, prose, boards.length, durationMs),
195
+ ]
196
+ : []),
197
+ ...(plans.length > 0 ? [findingsResult(mergedReport, merged, plans.length, durationMs)] : []),
198
+ ...(unlistedPerPlan.length > 0
199
+ ? [findingsResult(unlistedReport, unlisted, unlistedPerPlan.length, durationMs)]
200
+ : []),
134
201
  ]
135
202
  }
package/src/cli.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { pathToFileURL } from 'node:url'
3
+ import { HERD_COMMAND } from './plans/herd-command'
3
4
  import { locateKit } from './sync/kit-source'
4
5
  import { CLI_NAME } from './version/release'
5
6
 
@@ -12,13 +13,17 @@ export type Resolution =
12
13
  | { kind: 'version' }
13
14
  | { kind: 'usage'; exitCode: number; message: string }
14
15
 
16
+ /** Commands that stay callable but are not advertised in the usage line. */
17
+ const ALIASES = new Set<string>([HERD_COMMAND.alias])
18
+
15
19
  /** One line per command; each entry lazily imports the module that exports its `run`. */
16
20
  export const COMMANDS: CommandTable = {
17
21
  check: () => import('./check/command'),
18
22
  config: () => import('./config/command'),
19
23
  context: () => import('./context/command'),
24
+ [HERD_COMMAND.name]: () => import('./plans/command'),
20
25
  init: () => import('./sync/init-command'),
21
- plans: () => import('./plans/command'),
26
+ [HERD_COMMAND.alias]: () => import('./plans/command'),
22
27
  sync: () => import('./sync/command'),
23
28
  update: () => import('./update/command'),
24
29
  watch: () => import('./watch/command'),
@@ -27,7 +32,8 @@ export const COMMANDS: CommandTable = {
27
32
  export const resolveCommand = (argv: string[], table: CommandTable): Resolution => {
28
33
  const [name, ...args] = argv
29
34
  const names = Object.keys(table)
30
- const usage = `Usage: ${CLI_NAME} <command> [args]\n ${CLI_NAME} --version\nCommands: ${names.length > 0 ? names.join(', ') : '(none yet)'}`
35
+ const advertised = names.filter((entry) => !ALIASES.has(entry))
36
+ const usage = `Usage: ${CLI_NAME} <command> [args]\n ${CLI_NAME} --version\nCommands: ${advertised.length > 0 ? advertised.join(', ') : '(none yet)'}`
31
37
  if (name === '--version' || name === '-v') return { kind: 'version' }
32
38
  if (!name || name === '--help' || name === '-h') return { kind: 'usage', exitCode: name ? 0 : 1, message: usage }
33
39
  if (!names.includes(name)) return { kind: 'usage', exitCode: 1, message: `Unknown command: ${name}\n${usage}` }
@@ -21,6 +21,14 @@ export const PROJECT_LEGACY_FILES: readonly Rename[] = [
21
21
  { from: LEGACY_MANIFEST_PATH, to: `${TOOL_DIRECTORY}/${MANIFEST_FILE}` },
22
22
  ]
23
23
 
24
+ /** The lock files that mark a project install in `root`, the old name counting until 2.0.0 like the old config. */
25
+ export const projectLockCandidates = (root: string): string[] =>
26
+ [`${TOOL_DIRECTORY}/${MANIFEST_FILE}`, LEGACY_MANIFEST_PATH].map((path) => join(root, path))
27
+
28
+ /** True when `root` holds a project install. A lock counts when it exists; a broken one is the sync's to report. */
29
+ export const hasProjectInstall = (root: string, exists: (path: string) => boolean): boolean =>
30
+ projectLockCandidates(root).some(exists)
31
+
24
32
  export const PUBLIC_CONTENT_PATH = `${TOOL_DIRECTORY}/public-content.json`
25
33
 
26
34
  /** Where the public-content facts lay before the rename: below `XDG_CONFIG_HOME`, else `~/.config`. */
@@ -0,0 +1,73 @@
1
+ type Group = { repeats: boolean; branches: string[] }
2
+
3
+ const GROUP_PREFIX = /^\?(?::|=|!|<=|<!|<[^>]+>)/
4
+ const BRACES = /^\{(\d+)(?:(,)(\d*))?\}/
5
+
6
+ /** Length of an unbounded or repeating quantifier (`*`, `+`, `{n,}`, `{n,m}` with m above 1) at `index`, else 0. */
7
+ const repeatingLengthAt = (pattern: string, index: number): number => {
8
+ if (pattern[index] === '*' || pattern[index] === '+') return 1
9
+ const braces = pattern.slice(index).match(BRACES)
10
+ const [text, , comma, max] = braces ?? []
11
+ return text !== undefined && comma && (max === '' || Number(max) > 1) ? text.length : 0
12
+ }
13
+
14
+ const classEnd = (pattern: string, start: number): number => {
15
+ let index = start + 1
16
+ while (index < pattern.length && pattern[index] !== ']') index += pattern[index] === '\\' ? 2 : 1
17
+ return index
18
+ }
19
+
20
+ const overlapsAnother = (branches: readonly string[]): boolean =>
21
+ branches.some((branch, index) =>
22
+ branches.some((other, otherIndex) => index !== otherIndex && other.startsWith(branch)),
23
+ )
24
+
25
+ const NESTED = -1
26
+
27
+ const appendTo = (group: Group, text: string): void => {
28
+ group.branches[group.branches.length - 1] += text
29
+ }
30
+
31
+ const prefixLengthAt = (pattern: string, index: number): number =>
32
+ pattern.slice(index + 1).match(GROUP_PREFIX)?.[0].length ?? 0
33
+
34
+ /** Closes the group at `index`; NESTED when it repeats and holds a repeat or branches that begin alike. */
35
+ const closeGroup = (pattern: string, index: number, groups: Group[]): number => {
36
+ const closed = groups.pop() as Group
37
+ const repeated = repeatingLengthAt(pattern, index + 1) > 0
38
+ if (repeated && (closed.repeats || overlapsAnother(closed.branches))) return NESTED
39
+ groups[groups.length - 1].repeats ||= closed.repeats
40
+ return index + 1
41
+ }
42
+
43
+ /** Reads the token at `index` into the open groups and returns the index after it, or NESTED. */
44
+ const stepAt = (pattern: string, index: number, groups: Group[]): number => {
45
+ const top = groups[groups.length - 1]
46
+ const char = pattern[index]
47
+ const literalEnd = char === '\\' ? index + 2 : classEnd(pattern, index) + 1
48
+ const quantifierLength = repeatingLengthAt(pattern, index)
49
+ if (char === '\\' || char === '[') appendTo(top, pattern.slice(index, literalEnd))
50
+ else if (char === '(') groups.push({ repeats: false, branches: [''] })
51
+ else if (char === ')' && groups.length > 1) return closeGroup(pattern, index, groups)
52
+ else if (char === '|') top.branches.push('')
53
+ else if (quantifierLength > 0) top.repeats = true
54
+ else appendTo(top, char)
55
+ if (char === '\\' || char === '[') return literalEnd
56
+ if (char === '(') return index + 1 + prefixLengthAt(pattern, index)
57
+ return index + Math.max(quantifierLength, 1)
58
+ }
59
+
60
+ /**
61
+ * Whether a regular expression repeats a group that repeats inside itself, the shape of catastrophic backtracking:
62
+ * `(a+)+`, `(\w*)*`, `(x+y)+z`, and with alternation whose branches begin alike, `(a|aa)+`. A best-effort scan of the
63
+ * source, not a proof: it misses overlaps it cannot see in the text (`(a|a?)+`), and it flags a few harmless ones.
64
+ */
65
+ export const hasNestedQuantifier = (pattern: string): boolean => {
66
+ const groups: Group[] = [{ repeats: false, branches: [''] }]
67
+ let index = 0
68
+ while (index < pattern.length) {
69
+ index = stepAt(pattern, index, groups)
70
+ if (index === NESTED) return true
71
+ }
72
+ return false
73
+ }
@@ -0,0 +1,11 @@
1
+ import { isAbsolute } from 'node:path'
2
+
3
+ const segmentsOf = (path: string): string[] =>
4
+ path.split(/[\\/]/).filter((segment) => segment !== '' && segment !== '.')
5
+
6
+ /**
7
+ * A directory below the repository root: relative, not the root itself and without a `..` segment. The approval gate
8
+ * hook applies the same rule (`kit/hooks/hooks/approval-gate/settings.ts`), which this package does not import.
9
+ */
10
+ export const isRepositoryDirectory = (value: string): boolean =>
11
+ !isAbsolute(value) && segmentsOf(value).length > 0 && !segmentsOf(value).includes('..')