jackherd 1.9.0 → 1.10.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 +30 -0
- package/INSTALL.md +1 -1
- package/README.md +50 -32
- package/dist/{chunk-MCP4XJQH.js → chunk-5MQBCUQ5.js} +9 -4
- package/dist/config.d.ts +39 -4
- package/dist/config.js +67 -26
- package/dist/plan-format.d.ts +45 -6
- package/dist/plan-format.js +46 -6
- package/docs/workflow-dark.svg +67 -63
- package/docs/workflow.svg +67 -63
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +15 -0
- package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
- package/kit/hooks/hooks/ask-hint/index.ts +15 -19
- package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
- package/kit/hooks/hooks/git-guard/decision.ts +50 -102
- package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
- package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
- package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
- package/kit/hooks/hooks/merge-guard/index.ts +6 -3
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
- package/kit/hooks/hooks/merge-guard/run.ts +59 -54
- package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
- package/kit/hooks/hooks/open-question/index.ts +26 -0
- package/kit/hooks/hooks/open-question/record.ts +135 -0
- package/kit/hooks/hooks/open-question/run.ts +52 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -0
- package/kit/hooks/hooks/sent-file/store.ts +3 -12
- package/kit/hooks/hooks/shared/config.ts +7 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
- package/kit/hooks/hooks/shared/files.ts +7 -0
- package/kit/hooks/hooks/shared/lane.ts +1 -1
- package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
- package/kit/hooks/hooks/shared/private-directory.ts +56 -9
- package/kit/hooks/hooks/shared/program-position.ts +2 -75
- package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
- package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
- package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
- package/kit/hooks/hooks/task-hint/decision.ts +49 -0
- package/kit/hooks/hooks/task-hint/index.ts +79 -0
- package/kit/hooks/hooks/task-hint/run.ts +62 -0
- package/kit/hooks/hooks/task-hint/store.ts +51 -0
- package/kit/hooks/hooks.json +13 -5
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +1 -8
- package/kit/workflow/skills/implement/SKILL.md +8 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +210 -433
- package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
- package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
- package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
- package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
- package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
- package/kit/workflow/skills/orchestrate/references/events.md +86 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
- package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
- package/kit/workflow/skills/plan/SKILL.md +6 -5
- package/kit/workflow/skills/plan/references/template-English.md +4 -4
- package/kit/workflow/skills/plan/references/template-German.md +4 -4
- package/package.json +3 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +1 -1
- package/schemas/jackherd.schema.json +59 -5
- package/src/check/agent-text-reviewers.ts +23 -0
- package/src/check/command.ts +2 -0
- package/src/check/plan-structure-check.ts +49 -2
- package/src/check/plan.ts +8 -1
- package/src/check/schema.ts +9 -0
- package/src/check/skill-budget-defaults.ts +55 -0
- package/src/check/skill-budgets.ts +185 -0
- package/src/cli.ts +1 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +58 -24
- package/src/lane/close-board.ts +106 -0
- package/src/lane/close-check.ts +183 -0
- package/src/lane/close-command-line.ts +11 -0
- package/src/lane/close-command.ts +217 -0
- package/src/lane/close-options.ts +43 -0
- package/src/lane/close-survey.ts +83 -0
- package/src/lane/close.ts +141 -0
- package/src/lane/command.ts +13 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +87 -0
- package/src/lane-prompt/input.ts +116 -0
- package/src/lane-prompt/options.ts +68 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/plan-format/clock.ts +8 -0
- package/src/plan-format/dependencies.ts +7 -4
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/waiting.ts +7 -2
- package/src/plans/appointment-rows.ts +59 -0
- package/src/plans/ask-coordinator.ts +77 -0
- package/src/plans/ask-target.ts +25 -0
- package/src/plans/child-stop.ts +51 -0
- package/src/plans/closable-records.ts +34 -0
- package/src/plans/command.ts +3 -2
- package/src/plans/fetch.ts +26 -3
- package/src/plans/git.ts +19 -5
- package/src/plans/herd-claim.ts +173 -0
- package/src/plans/herd-memory.ts +36 -25
- package/src/plans/lane-plans.ts +44 -13
- package/src/plans/lane-summary.ts +253 -45
- package/src/plans/layout-phase.ts +29 -7
- package/src/plans/layout-summary.ts +89 -18
- package/src/plans/layout.ts +262 -68
- package/src/plans/link-chips.ts +1 -0
- package/src/plans/live.ts +107 -20
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-source.ts +15 -0
- package/src/plans/plan-cache.ts +28 -0
- package/src/plans/plan-heads.ts +54 -0
- package/src/plans/pull-requests.ts +17 -2
- package/src/plans/question-records.ts +48 -0
- package/src/plans/questions-read.ts +13 -0
- package/src/plans/read-cache.ts +8 -0
- package/src/plans/record-id.ts +4 -0
- package/src/plans/restart-decision.ts +13 -4
- package/src/plans/selection.ts +98 -18
- package/src/plans/sent-files-read.ts +6 -15
- package/src/plans/sent-files.ts +7 -14
- package/src/plans/settings.ts +2 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +12 -4
- package/src/plans/snapshot.ts +85 -62
- package/src/plans/sprint-export.ts +11 -11
- package/src/plans/sprint-page.ts +5 -5
- package/src/plans/subagent-phases.ts +95 -0
- package/src/plans/subagent-tasks.ts +51 -0
- package/src/plans/subagents-read.ts +66 -17
- package/src/plans/subagents.ts +64 -6
- package/src/plans/supervisor.ts +14 -4
- package/src/plans/symbols.ts +3 -0
- package/src/plans/task-name.ts +34 -0
- package/src/plans/texts.ts +23 -6
- package/src/plans/view.ts +17 -9
- package/src/plans/waiting-click.ts +192 -37
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +25 -3
- package/src/shared/private-location.ts +89 -0
- package/src/shared/stat-cache.ts +48 -0
- package/src/sync/kit-source.ts +29 -10
- package/src/sync/kit.ts +2 -0
- package/src/sync/manifest.ts +2 -0
- package/src/sync/plan-ask-rules.ts +94 -0
- package/src/sync/plan-hooks.ts +2 -0
- package/src/sync/render.ts +3 -3
- package/src/sync/settings-hooks.ts +4 -1
- package/src/sync/sync-plan.ts +1 -0
- package/src/sync/sync.ts +24 -3
- package/src/task-signals/claude-read.ts +27 -10
- package/src/terminal/text.ts +8 -3
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +16 -6
- package/src/waiting/command.ts +22 -7
- package/src/watch/appointments.ts +1 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +330 -105
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-progress.ts +40 -8
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +32 -4
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +13 -7
- package/src/watch/pull-requests.ts +6 -2
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/running-subagents.ts +43 -0
- package/src/watch/skill-change.ts +124 -32
- package/src/watch/source-change.ts +48 -0
- package/src/watch/sources.ts +6 -3
- package/src/watch/stream-output.ts +4 -3
- package/src/watch/task-parallel-auto.ts +208 -0
- package/src/watch/task-parallel-data-paths.ts +46 -0
- package/src/watch/task-parallel-input.ts +5 -15
- package/src/watch/task-parallel-read.ts +3 -1
- package/src/watch/task-parallel.ts +36 -7
- package/src/watch/task-signal-missing.ts +69 -0
- package/src/watch/task-signal-read.ts +29 -0
- package/src/watch/texts.ts +10 -0
- package/src/watch/update-available.ts +6 -3
- package/src/watch/watch-events.ts +23 -0
- package/src/watch/watcher-lock.ts +25 -29
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
- package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
- package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { HOOK_OUTCOME, type HookOutcome } from './plan-hooks'
|
|
2
|
+
import type { Settings } from './settings-hooks'
|
|
3
|
+
|
|
4
|
+
export type AskRulesInput = {
|
|
5
|
+
/** The `permissions.ask` rules the kit ships. */
|
|
6
|
+
kit: readonly string[]
|
|
7
|
+
/** The rules sync put into the settings before, from the lock. */
|
|
8
|
+
recorded: readonly string[]
|
|
9
|
+
settings: Settings
|
|
10
|
+
/** `--accept .claude/settings.json`: put back a kit rule the user removed. */
|
|
11
|
+
accept: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type AskRulesPlan = {
|
|
15
|
+
settings: Settings
|
|
16
|
+
changed: boolean
|
|
17
|
+
/** What the lock records next: every kit rule, a removed one included, so it is not added again. */
|
|
18
|
+
recorded: string[]
|
|
19
|
+
outcomes: { name: string; outcome: HookOutcome }[]
|
|
20
|
+
accepted: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Every rule a jackherd kit has ever shipped in an `ask-rules.json`. Only these are ever removed: a lock is a file in
|
|
25
|
+
* the project, and a rule it lists that no kit shipped (a tampered lock) stays where it is. Add, never remove.
|
|
26
|
+
*/
|
|
27
|
+
export const SHIPPED_ASK_RULES: readonly string[] = [
|
|
28
|
+
'Bash(gh run rerun *)',
|
|
29
|
+
'Bash(gh api *pulls/*/merge*)',
|
|
30
|
+
'Bash(gh api *merges*)',
|
|
31
|
+
'Bash(gh api *contents/*)',
|
|
32
|
+
'Bash(gh api *dispatches*)',
|
|
33
|
+
'Bash(gh api *rerun*)',
|
|
34
|
+
'Bash(gh api *git/refs*)',
|
|
35
|
+
'Bash(jh lane close*)',
|
|
36
|
+
'Bash(jackherd lane close*)',
|
|
37
|
+
'Bash(bunx jackherd* lane close*)',
|
|
38
|
+
'Bash(bun */cli.ts lane close*)',
|
|
39
|
+
'Bash(bunx jh lane close*)',
|
|
40
|
+
'Bash(bun x jackherd* lane close*)',
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
/** How a rule shows in the sync report, beside the hooks. */
|
|
44
|
+
export const askRuleName = (rule: string): string => `permissions.ask ${rule}`
|
|
45
|
+
|
|
46
|
+
const askOf = (settings: Settings): string[] => {
|
|
47
|
+
const permissions = settings.permissions
|
|
48
|
+
const ask =
|
|
49
|
+
typeof permissions === 'object' && permissions !== null ? (permissions as { ask?: unknown }).ask : undefined
|
|
50
|
+
return Array.isArray(ask) ? ask.filter((rule): rule is string => typeof rule === 'string') : []
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The settings with this ask list; an emptied list, and permissions it empties, disappear. Returns a copy. */
|
|
54
|
+
const withAsk = (settings: Settings, ask: string[]): Settings => {
|
|
55
|
+
const { permissions: _old, ...rest } = settings
|
|
56
|
+
const { ask: _list, ...otherPermissions } = (settings.permissions ?? {}) as Record<string, unknown>
|
|
57
|
+
const permissions = ask.length > 0 ? { ...otherPermissions, ask } : otherPermissions
|
|
58
|
+
return Object.keys(permissions).length > 0 ? { ...rest, permissions } : rest
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const outcomeOf = (rule: string, input: AskRulesInput, present: ReadonlySet<string>): HookOutcome => {
|
|
62
|
+
const shipped = input.kit.includes(rule)
|
|
63
|
+
if (!shipped)
|
|
64
|
+
return present.has(rule) && SHIPPED_ASK_RULES.includes(rule) ? HOOK_OUTCOME.removed : HOOK_OUTCOME.dropped
|
|
65
|
+
if (present.has(rule)) return HOOK_OUTCOME.unchanged
|
|
66
|
+
if (!input.recorded.includes(rule)) return HOOK_OUTCOME.created
|
|
67
|
+
return input.accept ? HOOK_OUTCOME.accepted : HOOK_OUTCOME.deselected
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ADDING: readonly HookOutcome[] = [HOOK_OUTCOME.created, HOOK_OUTCOME.accepted]
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Merges the kit's ask rules into `permissions.ask`, the way `planHooks` merges hooks. Pure. Only a rule the lock
|
|
74
|
+
* records and some kit shipped (`SHIPPED_ASK_RULES`) is ever removed, and only once the kit stops shipping it; every other entry stays where it is. A kit rule
|
|
75
|
+
* the user removed is not put back unless `accept` is set.
|
|
76
|
+
*/
|
|
77
|
+
export const planAskRules = (input: AskRulesInput): AskRulesPlan => {
|
|
78
|
+
const before = askOf(input.settings)
|
|
79
|
+
const present = new Set(before)
|
|
80
|
+
const rules = [...new Set([...input.kit, ...input.recorded])]
|
|
81
|
+
const decided = rules.map((rule) => ({ rule, outcome: outcomeOf(rule, input, present) }))
|
|
82
|
+
const removed = new Set(decided.filter(({ outcome }) => outcome === HOOK_OUTCOME.removed).map(({ rule }) => rule))
|
|
83
|
+
const added = decided.filter(({ outcome }) => ADDING.includes(outcome)).map(({ rule }) => rule)
|
|
84
|
+
const changed = removed.size > 0 || added.length > 0
|
|
85
|
+
return {
|
|
86
|
+
settings: changed
|
|
87
|
+
? withAsk(input.settings, [...before.filter((rule) => !removed.has(rule)), ...added])
|
|
88
|
+
: input.settings,
|
|
89
|
+
changed,
|
|
90
|
+
recorded: [...input.kit],
|
|
91
|
+
outcomes: decided.map(({ rule, outcome }) => ({ name: askRuleName(rule), outcome })),
|
|
92
|
+
accepted: decided.some(({ outcome }) => outcome === HOOK_OUTCOME.accepted),
|
|
93
|
+
}
|
|
94
|
+
}
|
package/src/sync/plan-hooks.ts
CHANGED
|
@@ -47,6 +47,8 @@ export type HooksPlan = {
|
|
|
47
47
|
kitNewRecorded: string | undefined
|
|
48
48
|
/** `--accept` was given for the settings but no hook was in conflict or deselected. */
|
|
49
49
|
notAcceptable: boolean
|
|
50
|
+
/** The kit's `permissions.ask` rules the lock records next (`planAskRules`); none outside a project install. */
|
|
51
|
+
askRules?: string[]
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
type HookDecision = {
|
package/src/sync/render.ts
CHANGED
|
@@ -4,9 +4,9 @@ import {
|
|
|
4
4
|
ADDRESS_FORM,
|
|
5
5
|
APPROVAL_TEXTS,
|
|
6
6
|
LANGUAGE_NAME,
|
|
7
|
-
PLAN_APPROVAL_LABELS,
|
|
8
7
|
PLAN_VOCABULARY,
|
|
9
8
|
SELF_MERGE_LABEL,
|
|
9
|
+
STANDING_APPROVAL_TEXTS,
|
|
10
10
|
} from '../vocabulary'
|
|
11
11
|
|
|
12
12
|
const PLACEHOLDER = /\{\{\s*(kit\.[A-Za-z0-9_.-]*)\s*\}\}/g
|
|
@@ -47,7 +47,7 @@ const placeholderValues = (config: JackherdConfig): Map<string, string> => {
|
|
|
47
47
|
...prefixed('board', config.board),
|
|
48
48
|
['kit.guard.selfMergeLabel', SELF_MERGE_LABEL[config.responseLanguage]],
|
|
49
49
|
['kit.guard.selfMerge', config.guard.selfMerge.offer ? 'offered' : 'not offered'],
|
|
50
|
-
...prefixed('guard.
|
|
50
|
+
...prefixed('guard.standingApproval', STANDING_APPROVAL_TEXTS[config.responseLanguage]),
|
|
51
51
|
...prefixed('approval', APPROVAL_TEXTS[config.responseLanguage]),
|
|
52
52
|
['kit.merge.withoutChecks', checkFreeList(config.merge.withoutChecks)],
|
|
53
53
|
...prefixed('heading', { ...header, ...sections, ...columns, phase }),
|
|
@@ -61,7 +61,7 @@ export const listPlaceholders = (template: string): string[] =>
|
|
|
61
61
|
/**
|
|
62
62
|
* Fills the kit placeholders from the project config: `{{kit.model.<role>}}`,
|
|
63
63
|
* `{{kit.command.<test|typecheck|lint|format|install|dev>}}`, `{{kit.lanes.<copyCommand|lockFile|verifyInstall>}}`, `{{kit.word.<laneClosed|openSubagents>}}`,
|
|
64
|
-
* `{{kit.board.<pattern|file|term>}}`, `{{kit.guard.<selfMerge|selfMergeLabel>}}`, `{{kit.guard.
|
|
64
|
+
* `{{kit.board.<pattern|file|term>}}`, `{{kit.guard.<selfMerge|selfMergeLabel>}}`, `{{kit.guard.standingApproval.<approve|until>}}`, `{{kit.merge.withoutChecks}}`, `{{kit.heading.<key>}}` from the plan
|
|
65
65
|
* vocabulary, the two languages, `{{kit.responseAddress}}`, the plan and worktree directory and the base branch. Other
|
|
66
66
|
* `{{…}}` text is left alone; an unknown `kit.` placeholder throws rather than reaching a project
|
|
67
67
|
* file unrendered. `overrides` win over the config: a global kit renders what each repository decides as
|
|
@@ -25,7 +25,10 @@ const hookGroupSchema = z.looseObject({
|
|
|
25
25
|
hooks: z.array(z.record(z.string(), z.unknown())),
|
|
26
26
|
})
|
|
27
27
|
|
|
28
|
-
const settingsSchema = z.looseObject({
|
|
28
|
+
const settingsSchema = z.looseObject({
|
|
29
|
+
hooks: z.record(z.string(), z.array(hookGroupSchema)).optional(),
|
|
30
|
+
permissions: z.looseObject({ ask: z.array(z.string()).optional() }).optional(),
|
|
31
|
+
})
|
|
29
32
|
|
|
30
33
|
export const parseSettings = (raw: unknown): SettingsResult => {
|
|
31
34
|
const parsed = settingsSchema.safeParse(raw)
|
package/src/sync/sync-plan.ts
CHANGED
|
@@ -115,6 +115,7 @@ export const nextManifest = (kitVersion: string, files: FilePlan[], hooks: Hooks
|
|
|
115
115
|
kitVersion,
|
|
116
116
|
files: Object.fromEntries(files.flatMap(({ path, entry }) => (entry ? [[path, entry]] : []))),
|
|
117
117
|
hooks: hooks.entries,
|
|
118
|
+
...(hooks.askRules !== undefined && hooks.askRules.length > 0 ? { askRules: hooks.askRules } : {}),
|
|
118
119
|
}
|
|
119
120
|
return hooks.kitNewRecorded === undefined ? manifest : { ...manifest, settingsKitNew: hooks.kitNewRecorded }
|
|
120
121
|
}
|
package/src/sync/sync.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { KitSource } from './kit'
|
|
|
15
15
|
import { kitNewPath, toolRootPath } from './kit-path'
|
|
16
16
|
import { moveGlobalLegacyFiles, moveProjectLegacyFiles, removeEmptyLegacyDirectories } from './legacy-files'
|
|
17
17
|
import { EMPTY_MANIFEST, type Manifest, parseManifest, serializeManifest } from './manifest'
|
|
18
|
+
import { planAskRules } from './plan-ask-rules'
|
|
18
19
|
import { FILE_MODE, FILE_OUTCOME, type FileEffect, type FilePlan, planFile, type RenderedFile } from './plan-file'
|
|
19
20
|
import { type HooksPlan, planHooks } from './plan-hooks'
|
|
20
21
|
import {
|
|
@@ -191,11 +192,29 @@ const buildPlan = async ({
|
|
|
191
192
|
return { files, hooks, manifest: nextManifest(kit.version, files, hooks), manifestText }
|
|
192
193
|
}
|
|
193
194
|
|
|
194
|
-
/**
|
|
195
|
+
/** The hooks plan with the kit's ask rules merged into the settings it writes, and their outcomes beside the hooks'. */
|
|
196
|
+
const withAskRules = (hooks: HooksPlan, kit: KitSource, manifest: Manifest, accept: AcceptRequest): HooksPlan => {
|
|
197
|
+
const rules = planAskRules({
|
|
198
|
+
kit: kit.askRules ?? [],
|
|
199
|
+
recorded: manifest.askRules ?? [],
|
|
200
|
+
settings: hooks.settings,
|
|
201
|
+
accept: accept.settings,
|
|
202
|
+
})
|
|
203
|
+
return {
|
|
204
|
+
...hooks,
|
|
205
|
+
settings: rules.settings,
|
|
206
|
+
changed: hooks.changed || rules.changed,
|
|
207
|
+
outcomes: [...hooks.outcomes, ...rules.outcomes],
|
|
208
|
+
notAcceptable: hooks.notAcceptable && !rules.accepted,
|
|
209
|
+
askRules: rules.recorded,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Merges the kit's hooks and ask rules into the project's `settings.json`. */
|
|
195
214
|
const projectHooks =
|
|
196
215
|
(claudeDirectory: string, kit: KitSource, config: JackherdConfig) =>
|
|
197
|
-
async (manifest: Manifest, accept: AcceptRequest): Promise<HooksPlan> =>
|
|
198
|
-
planHooks({
|
|
216
|
+
async (manifest: Manifest, accept: AcceptRequest): Promise<HooksPlan> => {
|
|
217
|
+
const hooks = planHooks({
|
|
199
218
|
kit: renderKitHooks(kit, (template) => renderTemplate(template, config)),
|
|
200
219
|
entries: manifest.hooks,
|
|
201
220
|
settings: await readSettings(claudeDirectory),
|
|
@@ -203,6 +222,8 @@ const projectHooks =
|
|
|
203
222
|
kitNewRecorded: manifest.settingsKitNew,
|
|
204
223
|
accept: accept.settings,
|
|
205
224
|
})
|
|
225
|
+
return withAskRules(hooks, kit, manifest, accept)
|
|
226
|
+
}
|
|
206
227
|
|
|
207
228
|
/** A global install ships no hooks and never reads the user's `settings.json`. */
|
|
208
229
|
const noHooks = async (_manifest: Manifest, accept: AcceptRequest): Promise<HooksPlan> =>
|
|
@@ -2,6 +2,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { readTailLines } from '../context/session-log'
|
|
4
4
|
import { isRecord } from '../shared/is-record'
|
|
5
|
+
import { readCached, type StatRound } from '../shared/stat-cache'
|
|
5
6
|
import { claudeSignalsOf } from './claude-signals'
|
|
6
7
|
import { cleanAgentName } from './text'
|
|
7
8
|
import type { TaskSignal } from './types'
|
|
@@ -11,7 +12,12 @@ const SUBAGENT_LOG = /^(agent-[A-Za-z0-9]+)\.jsonl$/
|
|
|
11
12
|
/** How much of the end of a log is read by default: sessions grow to hundreds of megabytes. */
|
|
12
13
|
const SIGNAL_TAIL_BYTES = 8 * 1024 * 1024
|
|
13
14
|
|
|
15
|
+
/** The signals of each log by path, kept while its modification time and size stay; `used` collects the paths of one round. */
|
|
16
|
+
export type SignalCache = StatRound<TaskSignal[]>
|
|
17
|
+
|
|
14
18
|
export type ReadOptions = {
|
|
19
|
+
/** Kept between reads, so a log that did not change is not parsed again. */
|
|
20
|
+
cache?: SignalCache
|
|
15
21
|
/** How many bytes from the end of each log are read; a task created before that is unknown, so it gives no signal. */
|
|
16
22
|
tailBytes?: number
|
|
17
23
|
/** Subagent logs last written before this (epoch milliseconds) are left out. */
|
|
@@ -46,27 +52,38 @@ const isRecent = (path: string, notOlderThan: number): boolean => {
|
|
|
46
52
|
}
|
|
47
53
|
}
|
|
48
54
|
|
|
49
|
-
const
|
|
55
|
+
const named = (signals: TaskSignal[], agent: string): TaskSignal[] => signals.map((signal) => ({ ...signal, agent }))
|
|
56
|
+
|
|
57
|
+
const subagentSignals = (
|
|
58
|
+
directory: string,
|
|
59
|
+
signalsOf: (path: string) => TaskSignal[],
|
|
60
|
+
notOlderThan: number,
|
|
61
|
+
): TaskSignal[] => {
|
|
50
62
|
try {
|
|
51
63
|
return readdirSync(directory)
|
|
52
64
|
.sort()
|
|
53
65
|
.flatMap((name) => {
|
|
54
66
|
const stem = SUBAGENT_LOG.exec(name)?.[1]
|
|
55
67
|
const path = join(directory, name)
|
|
56
|
-
return stem && isRecent(path, notOlderThan)
|
|
57
|
-
? claudeSignalsOf(linesOf(path, tailBytes), agentNameOf(directory, stem))
|
|
58
|
-
: []
|
|
68
|
+
return stem && isRecent(path, notOlderThan) ? named(signalsOf(path), agentNameOf(directory, stem)) : []
|
|
59
69
|
})
|
|
60
70
|
} catch {
|
|
61
71
|
return []
|
|
62
72
|
}
|
|
63
73
|
}
|
|
64
74
|
|
|
65
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* The tasks in progress in a Claude Code session log and in every `<session>/subagents/agent-*.jsonl` beside it.
|
|
77
|
+
* With a `cache`, a log whose modification time and size are as at the last read is not read and parsed again.
|
|
78
|
+
*/
|
|
66
79
|
export const readClaudeSignals = (
|
|
67
80
|
sessionLogPath: string,
|
|
68
|
-
{ tailBytes = SIGNAL_TAIL_BYTES, notOlderThan = 0 }: ReadOptions = {},
|
|
69
|
-
): TaskSignal[] =>
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
81
|
+
{ tailBytes = SIGNAL_TAIL_BYTES, notOlderThan = 0, cache }: ReadOptions = {},
|
|
82
|
+
): TaskSignal[] => {
|
|
83
|
+
const parsed = (path: string): TaskSignal[] => claudeSignalsOf(linesOf(path, tailBytes), '')
|
|
84
|
+
const signalsOf = cache ? (path: string) => readCached(cache, path, parsed, `${path}\0${tailBytes}`) : parsed
|
|
85
|
+
return [
|
|
86
|
+
...named(signalsOf(sessionLogPath), 'session'),
|
|
87
|
+
...subagentSignals(join(sessionLogPath.replace(/\.jsonl$/, ''), 'subagents'), signalsOf, notOlderThan),
|
|
88
|
+
]
|
|
89
|
+
}
|
package/src/terminal/text.ts
CHANGED
|
@@ -78,9 +78,14 @@ export const badge = (url: string, text: string, hint = '', tone: BadgeTone = BA
|
|
|
78
78
|
/** `text` as a badge without a link, for what no click opens: the background of `tone`, a cell of padding either side. */
|
|
79
79
|
export const box = (text: string, tone: BadgeTone = BADGE_TONE.grey): string => `${ESC}[${tone}m ${text} ${RESET}`
|
|
80
80
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
/**
|
|
82
|
+
* A line for output that is not a terminal: styling dropped, each link written out as `text (url)`, except the
|
|
83
|
+
* ones `writesAddress` refuses, which keep the bare text.
|
|
84
|
+
*/
|
|
85
|
+
export const plainText = (text: string, writesAddress: (url: string) => boolean = () => true): string =>
|
|
86
|
+
stripAnsi(
|
|
87
|
+
text.replace(LINK, (_, url, inner) => (writesAddress(url) ? `${stripAnsi(inner)} (${url})` : stripAnsi(inner))),
|
|
88
|
+
)
|
|
84
89
|
|
|
85
90
|
/** The terminal cells a styled line takes: emoji count two. */
|
|
86
91
|
export const visibleLength = (text: string): number => textWidth(stripAnsi(text))
|
|
@@ -6,6 +6,19 @@ export type SkillChange =
|
|
|
6
6
|
| { kind: typeof SKILL_CHANGE.unchanged }
|
|
7
7
|
| { kind: typeof SKILL_CHANGE.unavailable; reason: string }
|
|
8
8
|
|
|
9
|
+
/** How a reference file of the orchestrate skill differs between the kit at the start and the kit now. */
|
|
10
|
+
export const REFERENCE_CHANGE = { changed: 'changed', added: 'added', deleted: 'deleted' } as const
|
|
11
|
+
|
|
12
|
+
type ReferenceChangeKind = (typeof REFERENCE_CHANGE)[keyof typeof REFERENCE_CHANGE]
|
|
13
|
+
|
|
14
|
+
/** A reference that differs, before the diff is written or the path known. */
|
|
15
|
+
export type ReferenceDelta = { name: string; kind: ReferenceChangeKind }
|
|
16
|
+
|
|
17
|
+
export type ReferenceChange =
|
|
18
|
+
| { name: string; kind: typeof REFERENCE_CHANGE.changed; diffPath: string | null }
|
|
19
|
+
| { name: string; kind: typeof REFERENCE_CHANGE.added; path: string }
|
|
20
|
+
| { name: string; kind: typeof REFERENCE_CHANGE.deleted }
|
|
21
|
+
|
|
9
22
|
const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/
|
|
10
23
|
const SECTION_HEADING = /^## (.+)$/
|
|
11
24
|
const NUMBERED_SECTION = /^(\d+)\.\s/
|
|
@@ -61,3 +74,36 @@ export const skillChangeLine = (change: SkillChange): string => {
|
|
|
61
74
|
if (change.kind === SKILL_CHANGE.unchanged) return 'orchestrate skill unchanged'
|
|
62
75
|
return `orchestrate skill diff unavailable (${change.reason}): read the skill again`
|
|
63
76
|
}
|
|
77
|
+
|
|
78
|
+
const deltaOf = (name: string, before: string | undefined, after: string | undefined): ReferenceDelta[] => {
|
|
79
|
+
if (before === after) return []
|
|
80
|
+
if (before === undefined) return [{ name, kind: REFERENCE_CHANGE.added }]
|
|
81
|
+
return [{ name, kind: after === undefined ? REFERENCE_CHANGE.deleted : REFERENCE_CHANGE.changed }]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The references (file name → text) that changed, are new or are gone, by name; unchanged ones are left out. */
|
|
85
|
+
export const referenceChanges = (
|
|
86
|
+
before: Readonly<Record<string, string>>,
|
|
87
|
+
after: Readonly<Record<string, string>>,
|
|
88
|
+
): ReferenceDelta[] =>
|
|
89
|
+
[...new Set([...Object.keys(before), ...Object.keys(after)])]
|
|
90
|
+
.sort()
|
|
91
|
+
.flatMap((name) => deltaOf(name, before[name], after[name]))
|
|
92
|
+
|
|
93
|
+
export const referenceChangeLine = (change: ReferenceChange): string => {
|
|
94
|
+
const head = `orchestrate reference ${change.name}`
|
|
95
|
+
if (change.kind === REFERENCE_CHANGE.deleted) return `${head} deleted`
|
|
96
|
+
if (change.kind === REFERENCE_CHANGE.added) return `${head} new · read ${change.path}`
|
|
97
|
+
return change.diffPath === null
|
|
98
|
+
? `${head} changed · diff unavailable: read it again`
|
|
99
|
+
: `${head} changed · diff ${change.diffPath}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The lines about the orchestrate skill after a kit update: the skill's own, then one per reference that changed. An
|
|
104
|
+
* unchanged skill is named only when no reference changed either, so a quiet update stays one line.
|
|
105
|
+
*/
|
|
106
|
+
export const orchestrateChangeLines = (skill: SkillChange, references: readonly ReferenceChange[]): string[] => {
|
|
107
|
+
if (skill.kind === SKILL_CHANGE.unchanged && references.length > 0) return references.map(referenceChangeLine)
|
|
108
|
+
return [skillChangeLine(skill), ...references.map(referenceChangeLine)]
|
|
109
|
+
}
|
package/src/vocabulary/index.ts
CHANGED
|
@@ -39,13 +39,23 @@ export const SELF_MERGE_LABEL: Record<PlanLanguage, string> = {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* The
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* The standing approval: the option that approves (`approve`) and the word between the plan and the end in its
|
|
43
|
+
* question (``Plan `<path>` <until> <YYYY-MM-DD HH:MM>``). The merge guard hook lifts its question for a plain merge of
|
|
44
|
+
* that plan's lanes only when the newest answer to such a question is exactly `approve`, so both are fixed per
|
|
45
|
+
* language and not a config value.
|
|
45
46
|
*/
|
|
46
|
-
export const
|
|
47
|
-
[PLAN_LANGUAGE.en]: { approve: 'Approve
|
|
48
|
-
[PLAN_LANGUAGE.de]: { approve: '
|
|
47
|
+
export const STANDING_APPROVAL_TEXTS: Record<PlanLanguage, { approve: string; until: string }> = {
|
|
48
|
+
[PLAN_LANGUAGE.en]: { approve: 'Approve standing', until: 'until' },
|
|
49
|
+
[PLAN_LANGUAGE.de]: { approve: 'Stehend freigeben', until: 'bis' },
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The words a lane prompt opens with, followed by the lane's name. The ask hint hook and the project's lane exception
|
|
54
|
+
* recognise a lane by them, so they are fixed per language and not a config value.
|
|
55
|
+
*/
|
|
56
|
+
export const LANE_PROMPT_OPENING: Record<PlanLanguage, string> = {
|
|
57
|
+
[PLAN_LANGUAGE.en]: 'You are the lane',
|
|
58
|
+
[PLAN_LANGUAGE.de]: 'Du bist die Spur',
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
/** What the approval gate hook tells the lane when it denies: jackherd's own words, English in every language. */
|
package/src/waiting/command.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
2
|
import type { WaitingItem } from '../plan-format/waiting'
|
|
3
3
|
import { mainCheckout } from '../plans/git'
|
|
4
|
-
import {
|
|
5
|
-
import { closeAllSentRecords, closeSentRecords
|
|
4
|
+
import { closeAllQuestionRecords, closeQuestionRecords } from '../plans/questions-read'
|
|
5
|
+
import { closeAllSentRecords, closeSentRecords } from '../plans/sent-files-read'
|
|
6
|
+
import { recordWaitingItems } from '../plans/waiting-records'
|
|
6
7
|
import { CLI_NAME } from '../version/release'
|
|
7
8
|
|
|
8
9
|
const USAGE = `usage: ${CLI_NAME} waiting [close <id> ... | close --all]`
|
|
@@ -13,14 +14,25 @@ export const waitingListing = (items: readonly WaitingItem[]): string[] =>
|
|
|
13
14
|
? ['nothing waits on you']
|
|
14
15
|
: items.map(({ since, item, id }) => terminalSafe(`${id} · ${since} · ${item}`))
|
|
15
16
|
|
|
17
|
+
const CLOSE_ALL = '--all'
|
|
18
|
+
|
|
19
|
+
/** Closes the records the ids name, files and questions alike; how many it closed. */
|
|
20
|
+
export const closeWaiting = (root: string, ids: readonly string[], now: number): number =>
|
|
21
|
+
closeSentRecords(root, ids, now) + closeQuestionRecords(root, ids, now)
|
|
22
|
+
|
|
23
|
+
/** Closes every open record, files and questions alike; how many it closed. */
|
|
24
|
+
export const closeAllWaiting = (root: string, now: number): number =>
|
|
25
|
+
closeAllSentRecords(root, now) + closeAllQuestionRecords(root, now)
|
|
26
|
+
|
|
16
27
|
/**
|
|
17
|
-
* `jh waiting` lists the files sent to the user that no one closed (their id, time
|
|
18
|
-
* `jh waiting close <id> ...` or `close --all` closes them once the user has answered. Exit 0, 1 on a wrong
|
|
28
|
+
* `jh waiting` lists the files sent to the user and the questions left unanswered that no one closed (their id, time
|
|
29
|
+
* and text); `jh waiting close <id> ...` or `close --all` closes them once the user has answered. Exit 0, 1 on a wrong
|
|
30
|
+
* command line.
|
|
19
31
|
*/
|
|
20
32
|
export const run = async (args: string[]): Promise<number> => {
|
|
21
33
|
const root = mainCheckout(process.cwd())
|
|
22
34
|
if (args.length === 0) {
|
|
23
|
-
for (const line of waitingListing(
|
|
35
|
+
for (const line of waitingListing(recordWaitingItems(root, Date.now()))) console.log(line)
|
|
24
36
|
return 0
|
|
25
37
|
}
|
|
26
38
|
const [command, ...rest] = args
|
|
@@ -28,8 +40,11 @@ export const run = async (args: string[]): Promise<number> => {
|
|
|
28
40
|
console.error(USAGE)
|
|
29
41
|
return 1
|
|
30
42
|
}
|
|
31
|
-
|
|
32
|
-
|
|
43
|
+
if (rest.includes(CLOSE_ALL) && rest.length > 1) {
|
|
44
|
+
console.error(`${CLOSE_ALL} closes everything and takes no ids\n${USAGE}`)
|
|
45
|
+
return 1
|
|
46
|
+
}
|
|
47
|
+
const closed = rest[0] === CLOSE_ALL ? closeAllWaiting(root, Date.now()) : closeWaiting(root, rest, Date.now())
|
|
33
48
|
console.log(`closed ${closed}`)
|
|
34
49
|
return 0
|
|
35
50
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { twoDigits } from '../plan-format/clock'
|
|
1
|
+
import { DAY_MS, twoDigits } from '../plan-format/clock'
|
|
2
2
|
import { firstTableWithHeader, sectionLines } from '../plan-format/markdown'
|
|
3
3
|
import { isRecord } from '../shared/is-record'
|
|
4
4
|
import { APPOINTMENT_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
@@ -31,7 +31,6 @@ type DateParts = { year: number | null; month: number; day: number; hours: numbe
|
|
|
31
31
|
const DAY_FIRST = /^(?:\p{L}{2,3}\.?,?\s+)?(\d{1,2})\.(\d{1,2})\.(\d{4})?\s+(\d{1,2}):(\d{2})$/u
|
|
32
32
|
const ISO = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{1,2}):(\d{2})$/
|
|
33
33
|
const KEY_TIME_LENGTH = 'YYYY-MM-DDThh:mm'.length
|
|
34
|
-
const DAY_MS = 86_400_000
|
|
35
34
|
const KEEP_GONE_MS = 7 * DAY_MS
|
|
36
35
|
|
|
37
36
|
const APPOINTMENT_KINDS: readonly string[] = Object.values(APPOINTMENT_EVENT)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { questionSections } from './report-questions'
|
|
2
|
+
|
|
3
|
+
/** The questions sections of a report as one string; empty without a report or without an open question. */
|
|
4
|
+
export const questionsFingerprint = (text: string | null): string => {
|
|
5
|
+
if (text === null) return ''
|
|
6
|
+
const lines = text.split('\n')
|
|
7
|
+
return questionSections(lines)
|
|
8
|
+
.map(({ start, end }) => lines.slice(start, end).join('\n'))
|
|
9
|
+
.join('\n')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The working lanes whose report asks something other than what was last remembered for them (`current` and
|
|
14
|
+
* `remembered` are fingerprints per lane): a lane waiting on its background subagents ends no turn, so its question
|
|
15
|
+
* would wait for them. A lane without a remembered entry is unseen and asks nothing; its report is the baseline.
|
|
16
|
+
*/
|
|
17
|
+
export const askingLanes = (
|
|
18
|
+
working: readonly string[],
|
|
19
|
+
current: Readonly<Record<string, string>>,
|
|
20
|
+
remembered: Readonly<Record<string, string>>,
|
|
21
|
+
): string[] =>
|
|
22
|
+
working.filter((lane) => lane in remembered && (current[lane] ?? '') !== '' && current[lane] !== remembered[lane])
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The fingerprints to remember for the watched lanes (the keys of `current`): a lane whose report was `shown`, that
|
|
26
|
+
* asks nothing now or is seen for the first time takes what it has now; any other keeps what it had, so a question
|
|
27
|
+
* not yet shown still fires. A lane no longer watched is forgotten.
|
|
28
|
+
*/
|
|
29
|
+
export const recordReportQuestions = (
|
|
30
|
+
remembered: Readonly<Record<string, string>>,
|
|
31
|
+
current: Readonly<Record<string, string>>,
|
|
32
|
+
shown: readonly string[],
|
|
33
|
+
): Record<string, string> =>
|
|
34
|
+
Object.fromEntries(
|
|
35
|
+
Object.entries(current).map(([lane, questions]) => {
|
|
36
|
+
const kept = remembered[lane]
|
|
37
|
+
const takesCurrent = kept === undefined || questions === '' || shown.includes(lane)
|
|
38
|
+
return [lane, takesCurrent ? questions : kept]
|
|
39
|
+
}),
|
|
40
|
+
)
|
package/src/watch/base-tip.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { pullHeadRef } from '../plans/plan-heads'
|
|
2
|
+
|
|
1
3
|
const COMMIT = /^[0-9a-f]{40}$/
|
|
2
4
|
|
|
3
5
|
/** The commit of `refs/heads/<branch>` in a `git ls-remote` answer; null when it is not listed. */
|
|
@@ -16,3 +18,12 @@ export const remoteTipOf = (lsRemote: string, branch: string): string | null =>
|
|
|
16
18
|
*/
|
|
17
19
|
export const baseFetchNeeded = (remoteTip: string | null, localTip: string | null): boolean =>
|
|
18
20
|
remoteTip !== null && remoteTip !== localTip
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The pull requests whose head moved past the herd's ref of it (`pullHeadRef`), or that have none yet: the
|
|
24
|
+
* watcher's list carries each head commit, so an unchanged head costs no fetch.
|
|
25
|
+
*/
|
|
26
|
+
export const movedHeads = <Head extends { number: number; headCommit: string }>(
|
|
27
|
+
heads: readonly Head[],
|
|
28
|
+
localCommitOf: (ref: string) => string | null,
|
|
29
|
+
): Head[] => heads.filter(({ number, headCommit }) => baseFetchNeeded(headCommit, localCommitOf(pullHeadRef(number))))
|