jackherd 1.2.0 → 1.3.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.
@@ -419,6 +419,25 @@ Every question leaves room for a note: the free answer `AskUserQuestion` offers
419
419
  the options cannot express an obvious limit, the question says a note is welcome.
420
420
  A note in the answer is read first: it can change or stop the next step.
421
421
 
422
+ **Short questions.** A dialog is read at a glance, so it holds the decision and nothing else:
423
+
424
+ - The question is one sentence, 15 words at most, and asks only the decision. No file path, number or
425
+ flag in it unless that is the decision itself. The one exception is the PR question, which names
426
+ ``Branch `<name>` `` because the merge guard reads it back (§ 5).
427
+ - The context comes before it, in the chat: three short lines at most, and only what the decision needs.
428
+ Details come on request, in the free field of the dialog.
429
+ - An option label has five words at most. Its description has about twelve and says only what the
430
+ choice leads to. The recommendation stays the first option. The self-merge label of § 5 stays as
431
+ written.
432
+ - Two or three options, mostly. Several questions in one call only when they are independent; when one
433
+ depends on another, ask them one after the other.
434
+
435
+ ```text
436
+ Question: Retry the failed check, or leave it?
437
+ - Retry it (recommended): runs the check again
438
+ - Leave it: the pull request waits
439
+ ```
440
+
422
441
  **A draft for review.** A lane's draft that the user reviews (a README, a page, a plan) goes in
423
442
  rounds. Show it where the user can read it: rendered, when this session can publish a page,
424
443
  otherwise the file path. Ask through `AskUserQuestion` with `multiSelect`, one option per remark,
@@ -431,7 +450,7 @@ The push waits for the approval of the last round.
431
450
  once: what changed, checks with numbers, open points, the commit message(s). That approval covers
432
451
  the commits, the push and opening the PR. The user merges every PR, unless the project supplement
433
452
  names PRs the coordinator may merge itself (approval tiers, standing approvals) and under which
434
- conditions.
453
+ conditions, or the user picked the self-merge option of the PR question (step 2).
435
454
 
436
455
  A permission dialog inside a lane you confirm yourself only for a step on this list:
437
456
 
@@ -454,8 +473,29 @@ included, goes to the user.
454
473
  reports "Nothing changed": have the lane run `{{kit.command.typecheck}}` and `{{kit.command.test}}`
455
474
  from each package root and the linter on `git diff --name-only origin/{{kit.baseBranch}}...HEAD`
456
475
  instead. Never `git reset --soft` to make the runner see the diff.
457
- 2. Summarise for the user and ask with `AskUserQuestion`, once for the PR: what changed, checks,
458
- open points, the message(s) as Conventional Commits in English, no AI attribution.
476
+ 2. Summarise for the user and ask with `AskUserQuestion`, once for the PR. In the chat before the
477
+ question: what changed, the checks with numbers and the open points, as three to five bullets;
478
+ the message(s) as Conventional Commits in English, no AI attribution, go to the lane. The question
479
+ is one short sentence (§ 4, **Short questions**) that names the lane's branch once, written
480
+ ``Branch `<name>` ``, and carries three fixed options in this order, labelled in the language the
481
+ project replies in:
482
+ - the plain approval: the lane commits, pushes and opens the PR, and the user merges;
483
+ - `"{{kit.guard.selfMergeLabel}}"`, word for word: the same approval, and the coordinator merges
484
+ once the checks are green (**Self-merge**, below);
485
+ - the change: the user names what to alter first.
486
+
487
+ ```text
488
+ Question: Commit, push and open the PR? Branch `fix-login`
489
+ - Approve, I merge: the lane commits, pushes, opens the PR
490
+ - "{{kit.guard.selfMergeLabel}}": the same, and the checks decide the merge
491
+ - Change first: you say what to alter
492
+ ```
493
+
494
+ The self-merge option is **{{kit.guard.selfMerge}}** (`guard.selfMerge.offer` in the config). When
495
+ it reads `not offered`, or the project supplement blocks self-merge for the PR's tier, leave that
496
+ option out and ask with the other two. The label is the user's answer and the merge guard reads it
497
+ back from the session: never reword it, and never write the branch into a question that does not
498
+ offer the option.
459
499
  3. **Acceptance in the browser, before the PR question**, for every lane with a visible change. Open
460
500
  the lane's port in the Claude in Chrome extension: it carries the user's real login, so it shows
461
501
  what the lane's Playwright spec cannot (the user's account, their role, their theme). Walk the
@@ -471,9 +511,31 @@ included, goes to the user.
471
511
  then `cd` back. Not with `-C`: an option between `git` and the verb hides the push from the
472
512
  permission rules, and the kit's git guard refuses it.
473
513
 
514
+ ### Self-merge
515
+
516
+ When the user picked `"{{kit.guard.selfMergeLabel}}"` for a PR:
517
+
518
+ 1. Commit, push and open the PR as in step 4. Enter in the board's `{{kit.heading.history}}` that the
519
+ user approved this PR with self-merge (time, lane, PR number), so the approval has a record outside
520
+ the chat.
521
+ 2. Wait for the checks in the background, never in the foreground (§ 4): a Bash call
522
+ `gh pr checks <n> --watch` with `run_in_background: true`, then work on until it reports.
523
+ 3. Green: `gh pr merge <n> --squash --delete-branch`, with the plain PR number and without `-R`,
524
+ `--repo` or `--admin`. The merge guard lets exactly this command through for the approved branch,
525
+ for `guard.selfMerge.approvalMinutes` after the answer. Red, or checks that never finish: do not
526
+ merge, report to the user with the failing check and the lane's next step.
527
+ 4. When the guard asks anyway (the approval lapsed, the branch differs, the command carries an
528
+ option it does not cover), the dialog is the user's. Do not confirm it for them and do not look
529
+ for a way around it: tell them what the PR waits for.
530
+ 5. After the merge, one line to the user (PR, merge commit, checks), then § 6 as for any merge.
531
+
532
+ Self-merge never covers `gh workflow run` (a release, a deploy) or another PR: each of those asks
533
+ the user every time.
534
+
474
535
  ## 6. Merge and rebase
475
536
 
476
- - **Who merges** is the user unless the project supplement says otherwise (§ 5). Track it with
537
+ - **Who merges** is the user unless the project supplement says otherwise or the user picked
538
+ self-merge for the PR (§ 5). Track it with
477
539
  `gh pr view <n> --json state`.
478
540
  - After each merge, tell the remaining lanes to run `git fetch` and then
479
541
  `git rebase origin/{{kit.baseBranch}}`, as two commands, and to re-run their checks before they push,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "keywords": [
5
5
  "claude-code",
6
6
  "coding-agents",
@@ -451,6 +451,41 @@
451
451
  },
452
452
  "additionalProperties": false
453
453
  },
454
+ "guard": {
455
+ "default": {},
456
+ "type": "object",
457
+ "properties": {
458
+ "commands": {
459
+ "default": [
460
+ "gh pr merge",
461
+ "gh workflow run"
462
+ ],
463
+ "type": "array",
464
+ "items": {
465
+ "type": "string",
466
+ "pattern": "^[a-z][a-z0-9-]*( [a-z][a-z0-9-]*){1,3}$"
467
+ }
468
+ },
469
+ "selfMerge": {
470
+ "default": {},
471
+ "type": "object",
472
+ "properties": {
473
+ "offer": {
474
+ "default": true,
475
+ "type": "boolean"
476
+ },
477
+ "approvalMinutes": {
478
+ "default": 180,
479
+ "type": "integer",
480
+ "minimum": 1,
481
+ "maximum": 1440
482
+ }
483
+ },
484
+ "additionalProperties": false
485
+ }
486
+ },
487
+ "additionalProperties": false
488
+ },
454
489
  "check": {
455
490
  "type": "object",
456
491
  "properties": {
@@ -187,6 +187,27 @@ const coordinatorSchema = z
187
187
  })
188
188
  .refine(warnBelowAlert, WARN_BELOW_ALERT)
189
189
 
190
+ /** A guarded command as words: a program and one to three plain words after it, never an option, a quote or a shell character. */
191
+ const guardedCommand = z
192
+ .string()
193
+ .regex(/^[a-z][a-z0-9-]*( [a-z][a-z0-9-]*){1,3}$/, 'a program and one to three plain words, such as gh pr merge')
194
+
195
+ const guardSchema = z.strictObject({
196
+ /**
197
+ * The commands the merge guard hook asks the user about before they run, also behind options before the verb, a chain,
198
+ * a wrapper or `sh -c`. An empty list switches the guard off.
199
+ */
200
+ commands: z.array(guardedCommand).default(['gh pr merge', 'gh workflow run']),
201
+ selfMerge: z
202
+ .strictObject({
203
+ /** Whether `/orchestrate` offers "merge it yourself after green checks" in the pull request question; off also stops the hook honouring such an answer. */
204
+ offer: z.boolean().default(true),
205
+ /** How long an answer to that question lets `gh pr merge <number>` pass without asking, counted from the answer. */
206
+ approvalMinutes: z.number().int().min(1).max(1440).default(180),
207
+ })
208
+ .prefault({}),
209
+ })
210
+
190
211
  const contextSchema = z
191
212
  .strictObject({
192
213
  /** Share of the context window from which a lane's fill shows yellow and `/implement` offers a new chat. */
@@ -279,6 +300,8 @@ export const jackherdConfigSchema = z.strictObject({
279
300
  sources: z.array(sourceSchema).refine(hasUniqueNames, 'source names must be unique').default([]),
280
301
  })
281
302
  .prefault({}),
303
+ /** Read by the merge guard hook, which asks before the listed commands run, and by `/orchestrate`. */
304
+ guard: guardSchema.prefault({}),
282
305
  /** Read by `jh check`; validated here too, so a typo fails every command alike. */
283
306
  check: checkConfigSchema.optional(),
284
307
  })
@@ -7,7 +7,7 @@ import { errorMessage } from './error-message'
7
7
  import { linkHerdrPluginFrom } from './herdr-plugin'
8
8
  import type { KitSource } from './kit'
9
9
  import { type KitLocation, locateKit, readKitSource } from './kit-source'
10
- import { exitCodeOf, formatReport, SYNC_EXIT } from './report'
10
+ import { driftExitCodeOf, exitCodeOf, formatReport, SYNC_EXIT } from './report'
11
11
  import type { SyncResult } from './sync-result'
12
12
  import { detectTools, INSTALLERS, type InstallerName, spawnTool, type ToolPresence } from './tools'
13
13
 
@@ -86,19 +86,23 @@ export const usageError = (context: CommandContext, message: string): number =>
86
86
  return SYNC_EXIT.error
87
87
  }
88
88
 
89
- /** Reads the kit, runs the command, prints the report; any thrown error becomes exit code 2. */
89
+ /**
90
+ * Reads the kit, runs the command, prints the report; any thrown error becomes exit code 2. A `dryRun` writes and links
91
+ * nothing, and exits 1 when the report lists anything a sync would change.
92
+ */
90
93
  export const runWithKit = async (
91
94
  context: CommandContext,
92
95
  commandName: string,
93
96
  action: (kit: KitSource) => Promise<CommandOutcome>,
97
+ dryRun = false,
94
98
  ): Promise<number> => {
95
99
  try {
96
100
  const location = await context.locate()
97
101
  const { notes, result } = await action(await readKitSource(location))
98
- const herdrNote = context.linkHerdrPlugin(location.packageRoot)
102
+ const herdrNote = dryRun ? null : context.linkHerdrPlugin(location.packageRoot)
99
103
  for (const note of herdrNote ? [...notes, herdrNote] : notes) context.log(note)
100
104
  context.log(formatReport(result))
101
- return exitCodeOf(result)
105
+ return dryRun ? driftExitCodeOf(result) : exitCodeOf(result)
102
106
  } catch (err) {
103
107
  context.log(`${CLI_NAME} ${commandName} failed: ${errorMessage(err)}`)
104
108
  return SYNC_EXIT.error
@@ -14,18 +14,26 @@ import { migrateGlobalSettings } from './settings-migration'
14
14
  import { runGlobalSync, runSync } from './sync'
15
15
 
16
16
  export type SyncArgs =
17
- | { ok: true; accept: string[]; global: boolean; ifChanged: boolean }
17
+ | { ok: true; accept: string[]; global: boolean; ifChanged: boolean; check: boolean }
18
18
  | { ok: false; message: string }
19
19
 
20
20
  const ACCEPT_FLAG = '--accept'
21
21
  const IF_CHANGED_FLAG = '--if-changed'
22
+ const CHECK_FLAG = '--check'
22
23
 
23
- const SYNC_USAGE = `Usage: ${CLI_NAME} sync [--global [${IF_CHANGED_FLAG}]] [--accept <path>]...`
24
+ const SYNC_USAGE = `Usage: ${CLI_NAME} sync [--global [${IF_CHANGED_FLAG}]] [--accept <path>]... | ${CLI_NAME} sync ${CHECK_FLAG}`
25
+
26
+ const flagConflict = ({ accept, global, ifChanged, check }: Omit<Extract<SyncArgs, { ok: true }>, 'ok'>) => {
27
+ if (ifChanged && !global) return `${IF_CHANGED_FLAG} goes with ${GLOBAL_FLAG}`
28
+ if (check && (global || ifChanged || accept.length > 0)) return `${CHECK_FLAG} stands alone: it writes nothing`
29
+ return undefined
30
+ }
24
31
 
25
32
  export const parseSyncArgs = (args: string[]): SyncArgs => {
26
33
  const accept: string[] = []
27
34
  let global = false
28
35
  let ifChanged = false
36
+ let check = false
29
37
  for (let i = 0; i < args.length; i++) {
30
38
  const arg = args[i]
31
39
  if (arg === GLOBAL_FLAG) {
@@ -36,6 +44,10 @@ export const parseSyncArgs = (args: string[]): SyncArgs => {
36
44
  ifChanged = true
37
45
  continue
38
46
  }
47
+ if (arg === CHECK_FLAG) {
48
+ check = true
49
+ continue
50
+ }
39
51
  if (arg.startsWith(`${ACCEPT_FLAG}=`)) {
40
52
  accept.push(arg.slice(ACCEPT_FLAG.length + 1))
41
53
  continue
@@ -45,8 +57,9 @@ export const parseSyncArgs = (args: string[]): SyncArgs => {
45
57
  if (path === undefined) return { ok: false, message: `${ACCEPT_FLAG} needs a path\n${SYNC_USAGE}` }
46
58
  accept.push(path)
47
59
  }
48
- if (ifChanged && !global) return { ok: false, message: `${IF_CHANGED_FLAG} goes with ${GLOBAL_FLAG}\n${SYNC_USAGE}` }
49
- return { ok: true, accept, global, ifChanged }
60
+ const conflict = flagConflict({ accept, global, ifChanged, check })
61
+ if (conflict) return { ok: false, message: `${conflict}\n${SYNC_USAGE}` }
62
+ return { ok: true, accept, global, ifChanged, check }
50
63
  }
51
64
 
52
65
  const syncGlobal = (context: CommandContext, accept: string[]): Promise<number> =>
@@ -82,13 +95,18 @@ export const runSyncCommand = async (
82
95
  ): Promise<number> => {
83
96
  const parsed = parseSyncArgs(args)
84
97
  if (!parsed.ok) return usageError(context, parsed.message)
85
- const { accept, global, ifChanged } = parsed
98
+ const { accept, global, ifChanged, check: dryRun } = parsed
86
99
  if (ifChanged) return syncGlobalIfChanged(context, accept, check)
87
100
  if (global) return syncGlobal(context, accept)
88
- return runWithKit(context, 'sync', async (kit) => ({
89
- notes: [],
90
- result: await runSync({ projectRoot: context.projectRoot, home: context.home, kit, accept }),
91
- }))
101
+ return runWithKit(
102
+ context,
103
+ 'sync',
104
+ async (kit) => ({
105
+ notes: dryRun ? [`${CLI_NAME} sync ${CHECK_FLAG}: nothing is written`] : [],
106
+ result: await runSync({ projectRoot: context.projectRoot, home: context.home, kit, accept, check: dryRun }),
107
+ }),
108
+ dryRun,
109
+ )
92
110
  }
93
111
 
94
112
  export const run = (args: string[]): Promise<number> => runSyncCommand(args)
@@ -1,5 +1,5 @@
1
1
  import type { JackherdConfig } from '../config'
2
- import { LANGUAGE_NAME, PLAN_VOCABULARY } from '../vocabulary'
2
+ import { LANGUAGE_NAME, PLAN_VOCABULARY, SELF_MERGE_LABEL } from '../vocabulary'
3
3
 
4
4
  const PLACEHOLDER = /\{\{\s*(kit\.[A-Za-z0-9_.-]*)\s*\}\}/g
5
5
 
@@ -29,6 +29,8 @@ const placeholderValues = (config: JackherdConfig): Map<string, string> => {
29
29
  ['kit.lanes.copyCommand', laneCopyCommand(config.lanes.copy)],
30
30
  ['kit.lanes.lockFile', config.lanes.lockFile],
31
31
  ...prefixed('board', config.board),
32
+ ['kit.guard.selfMergeLabel', SELF_MERGE_LABEL[config.responseLanguage]],
33
+ ['kit.guard.selfMerge', config.guard.selfMerge.offer ? 'offered' : 'not offered'],
32
34
  ...prefixed('heading', { ...header, ...sections, ...columns, phase }),
33
35
  ])
34
36
  }
@@ -40,7 +42,7 @@ export const listPlaceholders = (template: string): string[] =>
40
42
  /**
41
43
  * Fills the kit placeholders from the project config: `{{kit.model.<role>}}`,
42
44
  * `{{kit.command.<test|typecheck|lint|format|install|dev>}}`, `{{kit.lanes.<copyCommand|lockFile>}}`,
43
- * `{{kit.board.<pattern|file|term>}}`, `{{kit.heading.<key>}}` from the plan
45
+ * `{{kit.board.<pattern|file|term>}}`, `{{kit.guard.<selfMerge|selfMergeLabel>}}`, `{{kit.heading.<key>}}` from the plan
44
46
  * vocabulary, the two languages, the plan and worktree directory and the base branch. Other
45
47
  * `{{…}}` text is left alone; an unknown `kit.` placeholder throws rather than reaching a project
46
48
  * file unrendered. `overrides` win over the config: a global kit renders what each repository decides as
@@ -15,6 +15,21 @@ const NEEDS_ATTENTION: readonly Outcome[] = [
15
15
  HOOK_OUTCOME.blocked,
16
16
  ]
17
17
 
18
+ /** What a sync would write into the files or hooks, or could not: a project copy that no longer equals the kit. */
19
+ const DRIFT: readonly Outcome[] = [
20
+ ...NEEDS_ATTENTION,
21
+ FILE_OUTCOME.created,
22
+ FILE_OUTCOME.updated,
23
+ FILE_OUTCOME.removed,
24
+ FILE_OUTCOME.accepted,
25
+ FILE_OUTCOME.resolved,
26
+ HOOK_OUTCOME.created,
27
+ HOOK_OUTCOME.updated,
28
+ HOOK_OUTCOME.removed,
29
+ HOOK_OUTCOME.accepted,
30
+ HOOK_OUTCOME.resolved,
31
+ ]
32
+
18
33
  const COUNTED: readonly Outcome[] = [
19
34
  FILE_OUTCOME.created,
20
35
  FILE_OUTCOME.updated,
@@ -68,6 +83,12 @@ export const exitCodeOf = (result: SyncResult): number => {
68
83
  return outcomes.some((outcome) => NEEDS_ATTENTION.includes(outcome)) ? SYNC_EXIT.conflicts : SYNC_EXIT.clean
69
84
  }
70
85
 
86
+ /** The exit code of `sync --check`: 1 when a sync would change anything, so a check can fail on it. */
87
+ export const driftExitCodeOf = (result: SyncResult): number => {
88
+ const outcomes = [...result.files, ...result.hooks].map(({ outcome }) => outcome)
89
+ return outcomes.some((outcome) => DRIFT.includes(outcome)) ? SYNC_EXIT.conflicts : SYNC_EXIT.clean
90
+ }
91
+
71
92
  const summaryLine = (outcomes: Outcome[]): string => {
72
93
  const counts = COUNTED.map((outcome) => ({ outcome, count: outcomes.filter((each) => each === outcome).length }))
73
94
  const nonZero = counts.filter(({ count }) => count > 0)
package/src/sync/sync.ts CHANGED
@@ -50,6 +50,8 @@ export type SyncRequest = {
50
50
  accept: string[]
51
51
  /** What fills the values the config leaves out; read from the project root when absent. */
52
52
  facts?: RepositoryFacts
53
+ /** Plan and report, write nothing: the result says what a sync would do. */
54
+ check?: boolean
53
55
  }
54
56
 
55
57
  export type GlobalSyncRequest = {
@@ -253,7 +255,7 @@ export const syncProjectWithConfig = async (request: SyncRequest): Promise<Confi
253
255
  await assertOwnDirectoriesInProject(request.projectRoot)
254
256
  const claudeDirectory = join(request.projectRoot, CLAUDE_DIRECTORY)
255
257
  const toolDirectory = join(request.projectRoot, TOOL_DIRECTORY)
256
- const moved = await moveProjectLegacyFiles(request.projectRoot)
258
+ const moved = request.check ? [] : await moveProjectLegacyFiles(request.projectRoot)
257
259
  const facts = request.facts ?? readRepositoryFacts(request.projectRoot)
258
260
  const { config, notes } = await readConfig(toolDirectory, TOOL_DIRECTORY, `${CLI_NAME} init`, facts)
259
261
  const directories = { claudeDirectory, toolDirectory, locate: inClaudeDirectory(claudeDirectory) }
@@ -264,8 +266,10 @@ export const syncProjectWithConfig = async (request: SyncRequest): Promise<Confi
264
266
  accept: request.accept,
265
267
  readHooks: projectHooks(claudeDirectory, request.kit, config),
266
268
  })
267
- await applyPlan(directories, plan)
268
- await ensureGitignoreLines(request.projectRoot)
269
+ if (!request.check) {
270
+ await applyPlan(directories, plan)
271
+ await ensureGitignoreLines(request.projectRoot)
272
+ }
269
273
  return { result: { ...syncResultOf(plan), notes: [...moved, ...notes] }, config }
270
274
  }
271
275
 
@@ -8,6 +8,16 @@ export const LANGUAGE_NAME: Record<PlanLanguage, string> = {
8
8
  [PLAN_LANGUAGE.de]: 'German',
9
9
  }
10
10
 
11
+ /**
12
+ * The option of the pull request question that approves the PR and a merge by the coordinator once the checks are
13
+ * green. The merge guard hook matches the user's answer against this exact text, so it is fixed per language and not
14
+ * a config value.
15
+ */
16
+ export const SELF_MERGE_LABEL: Record<PlanLanguage, string> = {
17
+ [PLAN_LANGUAGE.en]: 'Approve, and merge it yourself after green checks',
18
+ [PLAN_LANGUAGE.de]: 'Freigeben, und nach grünen Checks selbst mergen',
19
+ }
20
+
11
21
  export const isPlanLanguage = (value: string): value is PlanLanguage =>
12
22
  (Object.values(PLAN_LANGUAGE) as string[]).includes(value)
13
23
 
File without changes