jackherd 1.1.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.
- package/CHANGELOG.md +15 -0
- package/README.md +36 -7
- package/dist/{chunk-R6MGHCD7.js → chunk-UI3AZNZF.js} +5 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +17 -1
- package/dist/plan-format.d.ts +7 -1
- package/dist/plan-format.js +3 -1
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/git-guard/decision.ts +10 -79
- package/kit/hooks/hooks/merge-guard/approval.ts +107 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +286 -0
- package/kit/hooks/hooks/merge-guard/index.ts +56 -0
- package/kit/hooks/hooks/merge-guard/pull-request.ts +33 -0
- package/kit/hooks/hooks/merge-guard/run.ts +60 -0
- package/kit/hooks/hooks/merge-guard/settings.ts +56 -0
- package/kit/hooks/hooks/merge-guard/worker.ts +14 -0
- package/kit/hooks/hooks/shared/program-position.ts +82 -0
- package/kit/hooks/hooks/watch-guard/shell-input.ts +1 -1
- package/kit/hooks/hooks/watch-guard/starts.ts +1 -1
- package/kit/hooks/hooks.json +10 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +68 -5
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +35 -0
- package/src/config/schema.ts +23 -0
- package/src/sync/command-context.ts +8 -4
- package/src/sync/command.ts +27 -9
- package/src/sync/if-changed.ts +18 -8
- package/src/sync/render.ts +4 -2
- package/src/sync/report.ts +21 -0
- package/src/sync/settings-migration.ts +45 -3
- package/src/sync/settings-offer.ts +8 -2
- package/src/sync/sync.ts +7 -3
- package/src/vocabulary/index.ts +10 -0
- package/src/watch/command.ts +7 -1
- package/src/watch/report-questions.ts +16 -2
- package/src/watch/update-available.ts +26 -0
- /package/kit/hooks/hooks/{git-guard → shared}/shell.ts +0 -0
|
@@ -277,7 +277,7 @@ not fire), when a lane's PR merges, its checks turn red or green, the same for o
|
|
|
277
277
|
PRs on a branch no lane has (`=== checks failed #<n> (branch <name>)`, found by `--author @me`: a
|
|
278
278
|
documentation or `chore` PR you opened yourself; act on it like on a lane's, a merge makes every
|
|
279
279
|
lane rebase), a deploy run ends (after such a merge `(push, #<n>)`), the
|
|
280
|
-
heartbeat falls due, `gh` becomes unreachable, the installed kit changes version (`=== kit updated <old> → <new>`, then the watcher ends), or your own fill rises to `warn` or `alert`
|
|
280
|
+
heartbeat falls due, `gh` becomes unreachable, the installed kit changes version (`=== kit updated <old> → <new>`, then the watcher ends), npm names a newer release (`=== update available <old> → <new>`, once a day at most and once per version), or your own fill rises to `warn` or `alert`
|
|
281
281
|
(`=== coordinator <level> <share>`). Every report prints the event blocks, then `status:` with one
|
|
282
282
|
line per lane: its status, its branch, its PR with checks, and its open phase with the starts-after
|
|
283
283
|
entry (or that the plan is finished, or that there is no plan). One more line holds your own fill,
|
|
@@ -370,6 +370,7 @@ On wake-up:
|
|
|
370
370
|
| `lane added: <name>` | Nothing: the lane is watched. Check that it has its row (§ 2 step 5) |
|
|
371
371
|
| `lane removed: <name>` | Nothing after you closed it (§ 7). Otherwise its agent exited or its worktree is gone: read why, then tell the user |
|
|
372
372
|
| `kit updated <old> → <new>` | The kit was updated under the running watcher, which ended. After the `jh update` call has finished, read the change of this skill the next line names (`orchestrate skill: § … changed · diff <path>`; the diff shows the kit's placeholders, not your values), so the new text is the one you follow; read this skill file again (`Read` on the file you were given, no session restart) only when the line says `diff unavailable`; `orchestrate skill unchanged` needs no reading. Then start the stream again |
|
|
373
|
+
| `update available <old> → <new>` | A newer jackherd is on npm. Ask the user with `AskUserQuestion` (update now / not now) and run `jh update` only after a yes; the watcher then ends with `kit updated`, handle it as that row and start the stream again. After a no, nothing: the watcher does not mention this version again |
|
|
373
374
|
| monitor expired | Start the stream again |
|
|
374
375
|
| `superseded by <pid>` / `stopped by SIGTERM` | Nothing for `superseded`: it is no event, the newer watcher runs. After `stopped by SIGTERM` from outside, start it again |
|
|
375
376
|
| `watcher stopped: <reason>` | Tell the user, fix the cause (an unreadable state file, say), then start it again |
|
|
@@ -418,6 +419,25 @@ Every question leaves room for a note: the free answer `AskUserQuestion` offers
|
|
|
418
419
|
the options cannot express an obvious limit, the question says a note is welcome.
|
|
419
420
|
A note in the answer is read first: it can change or stop the next step.
|
|
420
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
|
+
|
|
421
441
|
**A draft for review.** A lane's draft that the user reviews (a README, a page, a plan) goes in
|
|
422
442
|
rounds. Show it where the user can read it: rendered, when this session can publish a page,
|
|
423
443
|
otherwise the file path. Ask through `AskUserQuestion` with `multiSelect`, one option per remark,
|
|
@@ -430,7 +450,7 @@ The push waits for the approval of the last round.
|
|
|
430
450
|
once: what changed, checks with numbers, open points, the commit message(s). That approval covers
|
|
431
451
|
the commits, the push and opening the PR. The user merges every PR, unless the project supplement
|
|
432
452
|
names PRs the coordinator may merge itself (approval tiers, standing approvals) and under which
|
|
433
|
-
conditions.
|
|
453
|
+
conditions, or the user picked the self-merge option of the PR question (step 2).
|
|
434
454
|
|
|
435
455
|
A permission dialog inside a lane you confirm yourself only for a step on this list:
|
|
436
456
|
|
|
@@ -453,8 +473,29 @@ included, goes to the user.
|
|
|
453
473
|
reports "Nothing changed": have the lane run `{{kit.command.typecheck}}` and `{{kit.command.test}}`
|
|
454
474
|
from each package root and the linter on `git diff --name-only origin/{{kit.baseBranch}}...HEAD`
|
|
455
475
|
instead. Never `git reset --soft` to make the runner see the diff.
|
|
456
|
-
2. Summarise for the user and ask with `AskUserQuestion`, once for the PR
|
|
457
|
-
|
|
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.
|
|
458
499
|
3. **Acceptance in the browser, before the PR question**, for every lane with a visible change. Open
|
|
459
500
|
the lane's port in the Claude in Chrome extension: it carries the user's real login, so it shows
|
|
460
501
|
what the lane's Playwright spec cannot (the user's account, their role, their theme). Walk the
|
|
@@ -470,9 +511,31 @@ included, goes to the user.
|
|
|
470
511
|
then `cd` back. Not with `-C`: an option between `git` and the verb hides the push from the
|
|
471
512
|
permission rules, and the kit's git guard refuses it.
|
|
472
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
|
+
|
|
473
535
|
## 6. Merge and rebase
|
|
474
536
|
|
|
475
|
-
- **Who merges** is the user unless the project supplement says otherwise
|
|
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
|
|
476
539
|
`gh pr view <n> --json state`.
|
|
477
540
|
- After each merge, tell the remaining lanes to run `git fetch` and then
|
|
478
541
|
`git rebase origin/{{kit.baseBranch}}`, as two commands, and to re-run their checks before they push,
|
package/package.json
CHANGED
|
@@ -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": {
|
package/src/config/schema.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
package/src/sync/command.ts
CHANGED
|
@@ -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
|
-
|
|
49
|
-
return { ok:
|
|
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(
|
|
89
|
-
|
|
90
|
-
|
|
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)
|
package/src/sync/if-changed.ts
CHANGED
|
@@ -15,6 +15,9 @@ export type UpdateCheck = { now: () => number; latestVersion: () => Promise<stri
|
|
|
15
15
|
|
|
16
16
|
export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
|
17
17
|
|
|
18
|
+
/** How soon npm is asked again after it gave no answer: an hour, so an offline morning does not cost the day. */
|
|
19
|
+
export const UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000
|
|
20
|
+
|
|
18
21
|
const REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`
|
|
19
22
|
const REGISTRY_TIMEOUT_MS = 3_000
|
|
20
23
|
const UPDATE_STATE_FILE = 'update-check.json'
|
|
@@ -78,22 +81,29 @@ const stampUpdateCheck = async (home: string, now: number): Promise<boolean> =>
|
|
|
78
81
|
}
|
|
79
82
|
}
|
|
80
83
|
|
|
84
|
+
/** The stamp that makes the check due again after `UPDATE_RETRY_INTERVAL_MS`, as if npm had been asked that long ago. */
|
|
85
|
+
const retryStampFor = (now: number): number => now - UPDATE_CHECK_INTERVAL_MS + UPDATE_RETRY_INTERVAL_MS
|
|
86
|
+
|
|
81
87
|
/**
|
|
82
|
-
* The
|
|
83
|
-
*
|
|
88
|
+
* The newest version npm names, when the daily check is due; null when it is not due or npm gave no answer. The
|
|
89
|
+
* check is stamped before npm is asked, so sessions starting together ask once. An unanswered question is
|
|
90
|
+
* restamped as if it had been asked `UPDATE_RETRY_INTERVAL_MS` before it is due again: the state file keeps its
|
|
91
|
+
* one field, and the hook and the watcher share it.
|
|
84
92
|
*/
|
|
85
|
-
export const
|
|
86
|
-
home: string,
|
|
87
|
-
installed: string,
|
|
88
|
-
check: UpdateCheck,
|
|
89
|
-
): Promise<string | null> => {
|
|
93
|
+
export const latestVersionIfDue = async (home: string, check: UpdateCheck): Promise<string | null> => {
|
|
90
94
|
const path = join(home, TOOL_DIRECTORY, UPDATE_STATE_FILE)
|
|
91
95
|
const now = check.now()
|
|
92
96
|
if (!updateCheckDue(await readUpdateState(path), now)) return null
|
|
93
97
|
if (!(await stampUpdateCheck(home, now))) return null
|
|
94
|
-
|
|
98
|
+
const latest = await check.latestVersion()
|
|
99
|
+
if (latest === null) await stampUpdateCheck(home, retryStampFor(now))
|
|
100
|
+
return latest
|
|
95
101
|
}
|
|
96
102
|
|
|
103
|
+
/** The update line, when the daily check is due and npm has a newer release. */
|
|
104
|
+
export const dailyUpdateNotice = async (home: string, installed: string, check: UpdateCheck): Promise<string | null> =>
|
|
105
|
+
updateNotice(await latestVersionIfDue(home, check), installed)
|
|
106
|
+
|
|
97
107
|
/** Asks the npm registry for the `latest` tag; null on any failure, after a few seconds at most. */
|
|
98
108
|
const registryLatestVersion = async (): Promise<string | null> => {
|
|
99
109
|
try {
|
package/src/sync/render.ts
CHANGED
|
@@ -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
|
package/src/sync/report.ts
CHANGED
|
@@ -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)
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
isObject,
|
|
8
8
|
readSettingsOffer,
|
|
9
9
|
SESSION_HOOK_COMMAND,
|
|
10
|
+
SESSION_HOOK_MATCHER,
|
|
11
|
+
SESSION_START,
|
|
10
12
|
type SettingsObject,
|
|
11
13
|
type SettingsOffer,
|
|
12
14
|
} from './settings-offer'
|
|
@@ -41,7 +43,7 @@ const migrateGroup = (group: unknown): unknown =>
|
|
|
41
43
|
|
|
42
44
|
const sessionStartOf = (settings: SettingsObject): unknown[] | null => {
|
|
43
45
|
const hooks = settings.hooks
|
|
44
|
-
return isObject(hooks) && Array.isArray(hooks
|
|
46
|
+
return isObject(hooks) && Array.isArray(hooks[SESSION_START]) ? hooks[SESSION_START] : null
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
/** Our `SessionStart` hook as the old CLI ran it becomes the `jh` command; every other event, group and handler stays. */
|
|
@@ -53,7 +55,42 @@ export const LEGACY_HOOK_MIGRATION: SettingsOffer = {
|
|
|
53
55
|
if (JSON.stringify(migrated) === JSON.stringify(groups)) return { kind: 'present' }
|
|
54
56
|
return {
|
|
55
57
|
kind: 'add',
|
|
56
|
-
next: { ...settings, hooks: { ...(settings.hooks as SettingsObject),
|
|
58
|
+
next: { ...settings, hooks: { ...(settings.hooks as SettingsObject), [SESSION_START]: migrated } },
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The matcher older installs wrote: the hook ran at `startup` only, so a `/clear` handover never saw its line. */
|
|
64
|
+
export const LEGACY_SESSION_HOOK_MATCHER = 'startup'
|
|
65
|
+
|
|
66
|
+
const sameKeys = (value: SettingsObject, keys: readonly string[]): boolean =>
|
|
67
|
+
Object.keys(value).sort().join() === [...keys].sort().join()
|
|
68
|
+
|
|
69
|
+
/** Exactly the group an older install wrote: the `startup` matcher and our one handler, nothing else on either. */
|
|
70
|
+
const isLegacyMatcherGroup = (group: unknown): boolean => {
|
|
71
|
+
if (!isObject(group) || !sameKeys(group, ['matcher', 'hooks'])) return false
|
|
72
|
+
if (group.matcher !== LEGACY_SESSION_HOOK_MATCHER || !Array.isArray(group.hooks) || group.hooks.length !== 1)
|
|
73
|
+
return false
|
|
74
|
+
const [handler] = group.hooks
|
|
75
|
+
return (
|
|
76
|
+
isObject(handler) &&
|
|
77
|
+
sameKeys(handler, ['type', 'command']) &&
|
|
78
|
+
handler.type === 'command' &&
|
|
79
|
+
handler.command === SESSION_HOOK_COMMAND
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Our `SessionStart` hook written for `startup` only also runs after `resume`, `clear` and `compact`; any other group stays. */
|
|
84
|
+
export const SESSION_MATCHER_MIGRATION: SettingsOffer = {
|
|
85
|
+
find: (settings) => {
|
|
86
|
+
const groups = sessionStartOf(settings)
|
|
87
|
+
if (!groups?.some(isLegacyMatcherGroup)) return { kind: 'present' }
|
|
88
|
+
const widened = groups.map((group) =>
|
|
89
|
+
isLegacyMatcherGroup(group) ? { ...(group as SettingsObject), matcher: SESSION_HOOK_MATCHER } : group,
|
|
90
|
+
)
|
|
91
|
+
return {
|
|
92
|
+
kind: 'add',
|
|
93
|
+
next: { ...settings, hooks: { ...(settings.hooks as SettingsObject), [SESSION_START]: widened } },
|
|
57
94
|
}
|
|
58
95
|
},
|
|
59
96
|
}
|
|
@@ -90,5 +127,10 @@ export const migrateGlobalSettings = async (home: string, cliOnPath: boolean): P
|
|
|
90
127
|
`Changed the SessionStart hook in ${SHOWN_SETTINGS} to \`${SESSION_HOOK_COMMAND}\`.`,
|
|
91
128
|
)
|
|
92
129
|
: HOOK_WAITS
|
|
93
|
-
|
|
130
|
+
const matcher = await migrate(
|
|
131
|
+
path,
|
|
132
|
+
SESSION_MATCHER_MIGRATION,
|
|
133
|
+
`Widened the SessionStart hook in ${SHOWN_SETTINGS} to startup, resume, clear and compact.`,
|
|
134
|
+
)
|
|
135
|
+
return [statusLine, hook, matcher].filter((line): line is string => line !== null)
|
|
94
136
|
}
|
|
@@ -29,9 +29,15 @@ export type SettingsWrite = { kind: 'written' } | { kind: 'notWritten'; reason:
|
|
|
29
29
|
/** What the `SessionStart` hook runs: a global sync only when the installed kit changed. */
|
|
30
30
|
export const SESSION_HOOK_COMMAND = `${CLI_NAME} sync --global --if-changed`
|
|
31
31
|
|
|
32
|
-
const SESSION_START = 'SessionStart'
|
|
32
|
+
export const SESSION_START = 'SessionStart'
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
/** Every way a session starts; the daily timestamp makes the extra runs free. */
|
|
35
|
+
export const SESSION_HOOK_MATCHER = 'startup|resume|clear|compact'
|
|
36
|
+
|
|
37
|
+
const SESSION_HOOK_GROUP = {
|
|
38
|
+
matcher: SESSION_HOOK_MATCHER,
|
|
39
|
+
hooks: [{ type: 'command', command: SESSION_HOOK_COMMAND }],
|
|
40
|
+
}
|
|
35
41
|
|
|
36
42
|
/** Claude Code creates `settings.json` world-readable; an existing file keeps its own mode. */
|
|
37
43
|
const NEW_FILE_MODE = 0o644
|
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
|
-
|
|
268
|
-
|
|
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
|
|
package/src/vocabulary/index.ts
CHANGED
|
@@ -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
|
|
package/src/watch/command.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* with `watch.sources`), a gh outage, a lane's or the coordinator's own context fill rising to warn or alert, or the
|
|
7
7
|
* heartbeat. A blocked lane adds one duty line, a merge one per lane, a lane at alert one, and every report ends with one status line
|
|
8
8
|
* per lane and one for the coordinator, in the words of `responseLanguage`. When the installed kit changes version
|
|
9
|
-
* it prints `=== kit updated <old> → <new>` and ends
|
|
9
|
+
* it prints `=== kit updated <old> → <new>` and ends; once a day it asks npm and prints `=== update available <old> → <new>`
|
|
10
|
+
* for a newer release, once per version. The coordinator is the agent in the
|
|
10
11
|
* watcher's own herdr pane unless `--coordinator` names it, else `orchestrator-<repository>`. A new start replaces a running watcher of the same
|
|
11
12
|
* repository; one in another repository keeps running.
|
|
12
13
|
*
|
|
@@ -26,6 +27,7 @@
|
|
|
26
27
|
* jh watch source <name|draft.json|-> --dry-run
|
|
27
28
|
*/
|
|
28
29
|
import { readFileSync } from 'node:fs'
|
|
30
|
+
import { homedir } from 'node:os'
|
|
29
31
|
import { basename, dirname, join, relative } from 'node:path'
|
|
30
32
|
import { readResolvedConfig } from '../config/read-config'
|
|
31
33
|
import type { JackherdConfig } from '../config/schema'
|
|
@@ -42,6 +44,7 @@ import { mainCheckout, worktreeNames, worktreeRoot } from '../plans/git'
|
|
|
42
44
|
import { discoverLanes, isUnchanged, laneSetChange, laneSetLines } from '../plans/lane-discovery'
|
|
43
45
|
import { clockLocaleOf, type PlansSettings, plansSettingsOf, worktreeOf } from '../plans/settings'
|
|
44
46
|
import { boardTexts } from '../plans/texts'
|
|
47
|
+
import { defaultUpdateCheck } from '../sync/if-changed'
|
|
45
48
|
import { herdrLaneStatusSource } from '../terminal/herdr'
|
|
46
49
|
import { startedKit } from '../version/started-kit'
|
|
47
50
|
import {
|
|
@@ -135,6 +138,7 @@ import {
|
|
|
135
138
|
} from './sources'
|
|
136
139
|
import { streamHeartbeatLine } from './stream-output'
|
|
137
140
|
import { WATCH_TEXTS, type WatchTexts } from './texts'
|
|
141
|
+
import { updateAnnouncer } from './update-available'
|
|
138
142
|
import {
|
|
139
143
|
coordinatorWatchEvent,
|
|
140
144
|
deployEvents,
|
|
@@ -846,9 +850,11 @@ const watchLanes = async (watch: Watch) => {
|
|
|
846
850
|
const kit = await startedKit()
|
|
847
851
|
const skillSnapshot = kit ? readOrchestrateSkill(kit.packageRoot) : null
|
|
848
852
|
const skillLine = () => skillLineFor(kit, skillSnapshot, dirname(watch.files.state))
|
|
853
|
+
const announceUpdate = kit ? updateAnnouncer(homedir(), kit.version, defaultUpdateCheck(), console.log) : null
|
|
849
854
|
while (await pollOnce(watch, watching)) {
|
|
850
855
|
await Bun.sleep(POLL_MS)
|
|
851
856
|
pollLanes(watch, watching)
|
|
857
|
+
await announceUpdate?.()
|
|
852
858
|
if (endedByKitUpdate(kit, { saveState: () => saveState(watch, watching), log: console.log, skillLine })) return
|
|
853
859
|
}
|
|
854
860
|
}
|
|
@@ -7,6 +7,9 @@ const ANY_HEADING = /^(#{1,6})\s/
|
|
|
7
7
|
const CODE_FENCE = /^\s*(```|~~~)/
|
|
8
8
|
const LIST_ITEM = /^(?:\d+[.)]|[-*])\s/
|
|
9
9
|
const CONTINUATION_LINE = /^\s/
|
|
10
|
+
const LIST_MARKER = /^(?:\d+[.)]|[-*])\s*/
|
|
11
|
+
/** What a section writes when it has nothing to ask: "Keine.", "None.", "Keine offenen Fragen.", a dash. */
|
|
12
|
+
const NO_QUESTIONS = /^(?:(?:keine|none|no|nein)(?:\s+(?:offenen\s+)?(?:fragen?|questions?))?\.?|n\/a|[—–])$/i
|
|
10
13
|
|
|
11
14
|
/** What the questions part of a message may take: whole up to these limits, else cut question by question. */
|
|
12
15
|
const QUESTION_LINES = 20
|
|
@@ -24,13 +27,24 @@ const headings = (lines: readonly string[]): { index: number; level: number; isQ
|
|
|
24
27
|
})
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
/**
|
|
30
|
+
/** True when every line of the body, ignoring blank lines, a list marker and bold, says there is nothing to ask. */
|
|
31
|
+
const asksNothing = (body: readonly string[]): boolean =>
|
|
32
|
+
body
|
|
33
|
+
.map((line) => line.trim().replace(LIST_MARKER, '').replaceAll('**', '').trim())
|
|
34
|
+
.filter((text) => text !== '')
|
|
35
|
+
.every((text) => NO_QUESTIONS.test(text))
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The questions sections of a report, in order: from the heading to the next heading of the same or a higher level.
|
|
39
|
+
* A section that is empty or only says "Keine." / "None." asks nothing and is left out.
|
|
40
|
+
*/
|
|
28
41
|
export const questionSections = (lines: readonly string[]): QuestionSection[] => {
|
|
29
42
|
const all = headings(lines)
|
|
30
43
|
return all.flatMap((heading, position) => {
|
|
31
44
|
if (!heading.isQuestion) return []
|
|
32
45
|
const next = all.slice(position + 1).find((other) => other.level <= heading.level)
|
|
33
|
-
|
|
46
|
+
const end = next?.index ?? lines.length
|
|
47
|
+
return asksNothing(lines.slice(heading.index + 1, end)) ? [] : [{ start: heading.index, end }]
|
|
34
48
|
})
|
|
35
49
|
}
|
|
36
50
|
|