jackherd 1.8.1 → 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 +60 -0
- package/INSTALL.md +3 -1
- package/README.md +75 -64
- package/dist/{chunk-OU2KALXP.js → chunk-5MQBCUQ5.js} +24 -4
- package/dist/config.d.ts +45 -4
- package/dist/config.js +84 -27
- package/dist/plan-format.d.ts +63 -7
- package/dist/plan-format.js +66 -12
- 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/approval-gate/index.ts +22 -4
- package/kit/hooks/hooks/approval-gate/run.ts +29 -6
- package/kit/hooks/hooks/approval-gate/subagent.ts +18 -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 -140
- 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/index.ts +19 -0
- package/kit/hooks/hooks/sent-file/record.ts +128 -0
- package/kit/hooks/hooks/sent-file/run.ts +48 -0
- package/kit/hooks/hooks/sent-file/store.ts +9 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
- 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 +21 -4
- package/kit/hooks/statusline.sh +1 -1
- 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 +10 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +213 -412
- 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 +4 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +1 -1
- package/schemas/jackherd.schema.json +85 -7
- 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 +4 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +80 -26
- package/src/context/fill.ts +12 -0
- package/src/context/session-log.ts +43 -1
- 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 +12 -0
- package/src/plan-format/dependencies.ts +9 -9
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/lanes.ts +30 -5
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/terminal-text.ts +4 -0
- package/src/plan-format/waiting.ts +53 -0
- package/src/plans/active-tasks.ts +60 -0
- 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/current-phase.ts +17 -7
- package/src/plans/current-task.ts +63 -5
- 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-standing.ts +9 -4
- package/src/plans/lane-summary.ts +367 -92
- package/src/plans/lane-tickets.ts +15 -0
- package/src/plans/layout-phase.ts +80 -19
- package/src/plans/layout-summary.ts +163 -74
- package/src/plans/layout.ts +472 -114
- package/src/plans/link-chips.ts +1 -0
- package/src/plans/live.ts +119 -10
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-models.ts +1 -1
- package/src/plans/phase-ranges.ts +45 -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 +23 -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 +117 -34
- package/src/plans/sent-files-read.ts +13 -0
- package/src/plans/sent-files.ts +114 -0
- package/src/plans/settings.ts +2 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +62 -0
- package/src/plans/snapshot.ts +279 -60
- 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 +131 -0
- package/src/plans/subagents.ts +190 -0
- 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 +36 -14
- package/src/plans/unlisted-lanes.ts +10 -5
- package/src/plans/view.ts +20 -12
- package/src/plans/waiting-click.ts +231 -0
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +105 -0
- package/src/preview/embed.ts +209 -0
- package/src/preview/images.ts +89 -0
- package/src/preview/open.ts +12 -0
- package/src/preview/options.ts +32 -0
- package/src/preview/page.ts +89 -0
- package/src/preview/render.ts +41 -0
- package/src/settled/board-row.ts +1 -2
- package/src/shared/is-record.ts +2 -0
- package/src/shared/private-location.ts +89 -0
- package/src/shared/private-records.ts +48 -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/agent.ts +15 -0
- package/src/task-signals/claude-read.ts +89 -0
- package/src/task-signals/claude-signals.ts +112 -0
- package/src/task-signals/command.ts +71 -0
- package/src/task-signals/fixtures/session.jsonl +7 -0
- package/src/task-signals/matcher.ts +96 -0
- package/src/task-signals/record-store.ts +40 -0
- package/src/task-signals/record.ts +68 -0
- package/src/task-signals/text.ts +13 -0
- package/src/task-signals/types.ts +14 -0
- package/src/terminal/open.ts +13 -6
- package/src/terminal/text.ts +16 -4
- package/src/terminal/width.ts +2 -1
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +44 -6
- package/src/waiting/command.ts +50 -0
- package/src/watch/appointments.ts +2 -4
- package/src/watch/approvals.ts +2 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +401 -90
- package/src/watch/failed-tests.ts +56 -10
- package/src/watch/failure-read.ts +18 -6
- package/src/watch/failures.ts +29 -5
- package/src/watch/feedback.ts +1 -1
- package/src/watch/gh-process.ts +9 -3
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/json-guards.ts +0 -3
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-fill.ts +1 -1
- package/src/watch/lane-model.ts +1 -1
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-parallel.ts +1 -1
- package/src/watch/lane-progress.ts +58 -14
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +37 -5
- package/src/watch/lane-rest.ts +28 -0
- 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/plan-badges.ts +9 -2
- package/src/watch/previews.ts +1 -1
- package/src/watch/pull-requests.ts +8 -3
- 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/source-path.ts +1 -1
- package/src/watch/sources.ts +7 -4
- package/src/watch/stream-output.ts +14 -8
- 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 +108 -0
- package/src/watch/task-parallel-read.ts +151 -0
- package/src/watch/task-parallel-touched.ts +39 -0
- package/src/watch/task-parallel.ts +235 -0
- 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/waiting-line.ts +19 -0
- package/src/watch/watch-events.ts +36 -1
- package/src/watch/watcher-lock.ts +25 -29
- package/src/watch/workflow-runs.ts +2 -1
- 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,48 @@
|
|
|
1
|
+
import { statSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
/** What tells a file changed without reading it: when it was last written and how large it is. */
|
|
4
|
+
export type FileStamp = { mtimeMs: number; size: number }
|
|
5
|
+
|
|
6
|
+
type Entry<T> = FileStamp & { value: T }
|
|
7
|
+
|
|
8
|
+
/** What was read from each file, by path, with the stamp it was read at. */
|
|
9
|
+
export type StatCache<T> = Map<string, Entry<T>>
|
|
10
|
+
|
|
11
|
+
/** A cache and the keys one round of reads asked for; `dropUnused` clears the rest at the end of the round. */
|
|
12
|
+
export type CacheRound<V> = { entries: Map<string, V>; used: Set<string> }
|
|
13
|
+
|
|
14
|
+
/** A round over a `StatCache`. */
|
|
15
|
+
export type StatRound<T> = CacheRound<Entry<T>>
|
|
16
|
+
|
|
17
|
+
const isUnchanged = (entry: FileStamp, stamp: FileStamp): boolean =>
|
|
18
|
+
entry.mtimeMs === stamp.mtimeMs && entry.size === stamp.size
|
|
19
|
+
|
|
20
|
+
/** Removes what no read of the round used, so a file that is gone does not stay in the cache. */
|
|
21
|
+
export const dropUnused = (cache: Map<string, unknown>, used: ReadonlySet<string>): void => {
|
|
22
|
+
for (const key of [...cache.keys()]) if (!used.has(key)) cache.delete(key)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const stampOf = (path: string): FileStamp | null => {
|
|
26
|
+
try {
|
|
27
|
+
const { mtimeMs, size } = statSync(path)
|
|
28
|
+
return { mtimeMs, size }
|
|
29
|
+
} catch {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What `read` makes of the file, asked again only when its modification time or size changed since the cache saw it.
|
|
36
|
+
* A file that cannot be stat-ed is read every time and not kept. The `key` (the path unless the result also depends
|
|
37
|
+
* on something else, such as how much of the file is read) is noted in `used`, for `dropUnused`.
|
|
38
|
+
*/
|
|
39
|
+
export const readCached = <T>(round: StatRound<T>, path: string, read: (path: string) => T, key: string = path): T => {
|
|
40
|
+
round.used.add(key)
|
|
41
|
+
const stamp = stampOf(path)
|
|
42
|
+
if (!stamp) return read(path)
|
|
43
|
+
const kept = round.entries.get(key)
|
|
44
|
+
if (kept && isUnchanged(kept, stamp)) return kept.value
|
|
45
|
+
const value = read(path)
|
|
46
|
+
round.entries.set(key, { ...stamp, value })
|
|
47
|
+
return value
|
|
48
|
+
}
|
package/src/sync/kit-source.ts
CHANGED
|
@@ -9,10 +9,15 @@ import { parseKitPath } from './kit-path'
|
|
|
9
9
|
|
|
10
10
|
export type KitLocation = { kitDirectory: string; version: string; packageRoot: string }
|
|
11
11
|
|
|
12
|
-
/** What one kit module ships: its files and the
|
|
13
|
-
type KitModule = { name: string; files: KitFile[]; hooksText: string | undefined }
|
|
12
|
+
/** What one kit module ships: its files and the texts of its `hooks.json` and `ask-rules.json`, if any. */
|
|
13
|
+
type KitModule = { name: string; files: KitFile[]; hooksText: string | undefined; askRulesText: string | undefined }
|
|
14
14
|
|
|
15
15
|
const MODULE_HOOKS_FILE = 'hooks.json'
|
|
16
|
+
const MODULE_ASK_RULES_FILE = 'ask-rules.json'
|
|
17
|
+
/** Files a module ships to sync itself, never into `.claude/`. */
|
|
18
|
+
export const MODULE_DATA_FILES: readonly string[] = [MODULE_HOOKS_FILE, MODULE_ASK_RULES_FILE]
|
|
19
|
+
|
|
20
|
+
const askRulesSchema = z.array(z.string().min(1))
|
|
16
21
|
|
|
17
22
|
/** A test beside a kit script runs in this package and never ships into a project. */
|
|
18
23
|
const TEST_FILE = /\.test\.ts$/
|
|
@@ -68,6 +73,21 @@ const mergeModuleHooks = (modules: KitModule[]): string | undefined => {
|
|
|
68
73
|
return Object.keys(merged).length > 0 ? JSON.stringify(merged, null, 2) : undefined
|
|
69
74
|
}
|
|
70
75
|
|
|
76
|
+
/** The `permissions.ask` rules of every module's `ask-rules.json` (a JSON list of rules), each once, in module order. */
|
|
77
|
+
const mergeModuleAskRules = (modules: KitModule[]): string[] => {
|
|
78
|
+
const rules = modules.flatMap(({ name, askRulesText }) => {
|
|
79
|
+
if (askRulesText === undefined) return []
|
|
80
|
+
const invalid = new Error(`Kit ${name}/${MODULE_ASK_RULES_FILE} is not a JSON list of permission rules`)
|
|
81
|
+
const parsed = askRulesSchema.safeParse(parseJsonOr(askRulesText, invalid))
|
|
82
|
+
if (!parsed.success) throw invalid
|
|
83
|
+
return parsed.data
|
|
84
|
+
})
|
|
85
|
+
return [...new Set(rules)]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const readOptional = async (moduleDirectory: string, relatives: string[], file: string): Promise<string | undefined> =>
|
|
89
|
+
relatives.includes(file) ? readFile(join(moduleDirectory, file), 'utf8') : undefined
|
|
90
|
+
|
|
71
91
|
const listFiles = async (directory: string, prefix = ''): Promise<string[]> => {
|
|
72
92
|
const entries = await readdir(join(directory, prefix), { withFileTypes: true })
|
|
73
93
|
const nested = await Promise.all(
|
|
@@ -94,12 +114,11 @@ const readKitFile = async (moduleDirectory: string, relative: string): Promise<K
|
|
|
94
114
|
const readModule = async (kitDirectory: string, name: string): Promise<KitModule> => {
|
|
95
115
|
const moduleDirectory = join(kitDirectory, name)
|
|
96
116
|
const relatives = (await listFiles(moduleDirectory)).sort()
|
|
97
|
-
const filePaths = relatives.filter((relative) => relative
|
|
117
|
+
const filePaths = relatives.filter((relative) => !MODULE_DATA_FILES.includes(relative) && !TEST_FILE.test(relative))
|
|
98
118
|
const files = await Promise.all(filePaths.map((relative) => readKitFile(moduleDirectory, relative)))
|
|
99
|
-
const hooksText = relatives
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return { name, files, hooksText }
|
|
119
|
+
const hooksText = await readOptional(moduleDirectory, relatives, MODULE_HOOKS_FILE)
|
|
120
|
+
const askRulesText = await readOptional(moduleDirectory, relatives, MODULE_ASK_RULES_FILE)
|
|
121
|
+
return { name, files, hooksText, askRulesText }
|
|
103
122
|
}
|
|
104
123
|
|
|
105
124
|
const listModuleNames = async (kitDirectory: string): Promise<string[]> => {
|
|
@@ -119,8 +138,8 @@ const listModuleNames = async (kitDirectory: string): Promise<string[]> => {
|
|
|
119
138
|
/**
|
|
120
139
|
* Reads the kit shipped with the package. Every directory under `kit/` is a module that mirrors
|
|
121
140
|
* `.claude/` (`kit/workflow/skills/plan/SKILL.md` → `.claude/skills/plan/SKILL.md`); a module's
|
|
122
|
-
* `hooks.json` holds its hooks by name, `{ "<name>": { event, matcher?, handler } }`, and
|
|
123
|
-
* copied, nor is a `*.test.ts`. Files at the kit root, such as a README, belong to no module.
|
|
141
|
+
* `hooks.json` holds its hooks by name, `{ "<name>": { event, matcher?, handler } }`, and its
|
|
142
|
+
* `ask-rules.json` the `permissions.ask` rules a project install adds; neither is copied, nor is a `*.test.ts`. Files at the kit root, such as a README, belong to no module.
|
|
124
143
|
*
|
|
125
144
|
* A missing kit directory, or one without modules, is an error, never an empty kit: an empty kit
|
|
126
145
|
* would make sync remove every untouched kit file from the project.
|
|
@@ -134,7 +153,7 @@ export const readKitSource = async ({
|
|
|
134
153
|
const modules = await Promise.all(names.map((name) => readModule(kitDirectory, name)))
|
|
135
154
|
assertUniquePaths(modules)
|
|
136
155
|
const files = modules.flatMap((module) => module.files).sort((left, right) => (left.path < right.path ? -1 : 1))
|
|
137
|
-
return { version, files, hooksTemplate: mergeModuleHooks(modules) }
|
|
156
|
+
return { version, files, hooksTemplate: mergeModuleHooks(modules), askRules: mergeModuleAskRules(modules) }
|
|
138
157
|
}
|
|
139
158
|
|
|
140
159
|
const readPackageJson = async (path: string) => packageJsonSchema.parse(JSON.parse(await readFile(path, 'utf8')))
|
package/src/sync/kit.ts
CHANGED
|
@@ -6,4 +6,6 @@ export type KitSource = {
|
|
|
6
6
|
files: KitFile[]
|
|
7
7
|
/** The unrendered text of `kit/hooks.json`, if the kit ships hooks. */
|
|
8
8
|
hooksTemplate: string | undefined
|
|
9
|
+
/** The `permissions.ask` rules of the kit's `ask-rules.json` files; none for a global install. */
|
|
10
|
+
askRules?: readonly string[]
|
|
9
11
|
}
|
package/src/sync/manifest.ts
CHANGED
|
@@ -21,6 +21,8 @@ const manifestSchema = z.strictObject({
|
|
|
21
21
|
z.string().regex(HOOK_NAME),
|
|
22
22
|
z.strictObject({ base: hookDefinitionSchema, pending: sha256.optional() }),
|
|
23
23
|
),
|
|
24
|
+
/** The `permissions.ask` rules sync put into `settings.json`, which it removes once the kit drops them. */
|
|
25
|
+
askRules: z.array(z.string().min(1)).optional(),
|
|
24
26
|
/** Checksum of the `settings.json.kit-new` sync wrote last. */
|
|
25
27
|
settingsKitNew: sha256.optional(),
|
|
26
28
|
})
|
|
@@ -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> =>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { cleanAgentName } from './text'
|
|
2
|
+
|
|
3
|
+
const WORKTREE = /[\\/]\.claude[\\/]worktrees[\\/]([^\\/]+)/
|
|
4
|
+
|
|
5
|
+
const MAIN_AGENT = 'main'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Who is reporting: the herdr pane when `HERDR_PANE_ID` is set (an agent has its own pane), else the worktree the
|
|
9
|
+
* directory lies in (`.claude/worktrees/<name>`), else `main`.
|
|
10
|
+
*/
|
|
11
|
+
export const agentIdentity = (environment: Record<string, string | undefined>, cwd: string): string => {
|
|
12
|
+
const pane = cleanAgentName(environment.HERDR_PANE_ID ?? '')
|
|
13
|
+
if (pane) return `pane ${pane}`
|
|
14
|
+
return WORKTREE.exec(cwd)?.[1] ?? MAIN_AGENT
|
|
15
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { readTailLines } from '../context/session-log'
|
|
4
|
+
import { isRecord } from '../shared/is-record'
|
|
5
|
+
import { readCached, type StatRound } from '../shared/stat-cache'
|
|
6
|
+
import { claudeSignalsOf } from './claude-signals'
|
|
7
|
+
import { cleanAgentName } from './text'
|
|
8
|
+
import type { TaskSignal } from './types'
|
|
9
|
+
|
|
10
|
+
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. */
|
|
13
|
+
const SIGNAL_TAIL_BYTES = 8 * 1024 * 1024
|
|
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
|
+
|
|
18
|
+
export type ReadOptions = {
|
|
19
|
+
/** Kept between reads, so a log that did not change is not parsed again. */
|
|
20
|
+
cache?: SignalCache
|
|
21
|
+
/** How many bytes from the end of each log are read; a task created before that is unknown, so it gives no signal. */
|
|
22
|
+
tailBytes?: number
|
|
23
|
+
/** Subagent logs last written before this (epoch milliseconds) are left out. */
|
|
24
|
+
notOlderThan?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const linesOf = (path: string, tailBytes: number): string[] => {
|
|
28
|
+
try {
|
|
29
|
+
return readTailLines(path, tailBytes)
|
|
30
|
+
} catch {
|
|
31
|
+
return []
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The subagent's own description when its `.meta.json` has one, else the log's name. */
|
|
36
|
+
const agentNameOf = (directory: string, stem: string): string => {
|
|
37
|
+
try {
|
|
38
|
+
const meta: unknown = JSON.parse(readFileSync(join(directory, `${stem}.meta.json`), 'utf8'))
|
|
39
|
+
const description = isRecord(meta) ? meta.description : null
|
|
40
|
+
const name = typeof description === 'string' ? cleanAgentName(description) : ''
|
|
41
|
+
return name || stem
|
|
42
|
+
} catch {
|
|
43
|
+
return stem
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const isRecent = (path: string, notOlderThan: number): boolean => {
|
|
48
|
+
try {
|
|
49
|
+
return statSync(path).mtimeMs >= notOlderThan
|
|
50
|
+
} catch {
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
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[] => {
|
|
62
|
+
try {
|
|
63
|
+
return readdirSync(directory)
|
|
64
|
+
.sort()
|
|
65
|
+
.flatMap((name) => {
|
|
66
|
+
const stem = SUBAGENT_LOG.exec(name)?.[1]
|
|
67
|
+
const path = join(directory, name)
|
|
68
|
+
return stem && isRecent(path, notOlderThan) ? named(signalsOf(path), agentNameOf(directory, stem)) : []
|
|
69
|
+
})
|
|
70
|
+
} catch {
|
|
71
|
+
return []
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
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
|
+
*/
|
|
79
|
+
export const readClaudeSignals = (
|
|
80
|
+
sessionLogPath: string,
|
|
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
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { parseLogEntry } from '../context/session-log'
|
|
2
|
+
import { isRecord } from '../shared/is-record'
|
|
3
|
+
import { cleanTaskText } from './text'
|
|
4
|
+
import type { TaskSignal } from './types'
|
|
5
|
+
|
|
6
|
+
type Block = Record<string, unknown>
|
|
7
|
+
|
|
8
|
+
type TaskDraft = { subject?: unknown; activeForm?: unknown; description?: unknown }
|
|
9
|
+
|
|
10
|
+
type TrackedTask = { text: string; startedAt: number | null }
|
|
11
|
+
|
|
12
|
+
type Tracker = {
|
|
13
|
+
pending: Map<string, TaskDraft>
|
|
14
|
+
tasks: Map<string, TrackedTask>
|
|
15
|
+
todos: Map<string, number>
|
|
16
|
+
cwd: string
|
|
17
|
+
time: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const TASK_STATUS = {
|
|
21
|
+
pending: 'pending',
|
|
22
|
+
inProgress: 'in_progress',
|
|
23
|
+
completed: 'completed',
|
|
24
|
+
deleted: 'deleted',
|
|
25
|
+
} as const
|
|
26
|
+
|
|
27
|
+
const recordsIn = (values: unknown): Block[] => (Array.isArray(values) ? values.filter(isRecord) : [])
|
|
28
|
+
|
|
29
|
+
const firstText = (...candidates: unknown[]): string =>
|
|
30
|
+
candidates.map((candidate) => (typeof candidate === 'string' ? cleanTaskText(candidate) : '')).find(Boolean) ?? ''
|
|
31
|
+
|
|
32
|
+
const blocksOf = (entry: Record<string, unknown>): Block[] => {
|
|
33
|
+
return recordsIn(isRecord(entry.message) ? entry.message.content : undefined)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const createdIdOf = (entry: Record<string, unknown>, block: Block): string | null => {
|
|
37
|
+
const task = isRecord(entry.toolUseResult) ? entry.toolUseResult.task : undefined
|
|
38
|
+
const id = isRecord(task) ? task.id : undefined
|
|
39
|
+
if (typeof id === 'string' || typeof id === 'number') return String(id)
|
|
40
|
+
const match = typeof block.content === 'string' ? /^Task #(\S+) created/.exec(block.content) : null
|
|
41
|
+
return match ? match[1] : null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const onResult = (tracker: Tracker, entry: Record<string, unknown>, block: Block): void => {
|
|
45
|
+
const draft = typeof block.tool_use_id === 'string' ? tracker.pending.get(block.tool_use_id) : undefined
|
|
46
|
+
if (!draft) return
|
|
47
|
+
const id = createdIdOf(entry, block)
|
|
48
|
+
if (id === null) return
|
|
49
|
+
tracker.tasks.set(id, { text: firstText(draft.subject, draft.activeForm, draft.description), startedAt: null })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const onTaskUpdate = (tracker: Tracker, input: Record<string, unknown>): void => {
|
|
53
|
+
const id = typeof input.taskId === 'string' || typeof input.taskId === 'number' ? String(input.taskId) : null
|
|
54
|
+
const task = id === null ? undefined : tracker.tasks.get(id)
|
|
55
|
+
if (id === null || !task) return
|
|
56
|
+
if (input.status === TASK_STATUS.completed || input.status === TASK_STATUS.deleted) {
|
|
57
|
+
tracker.tasks.delete(id)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
const renamed = firstText(input.subject)
|
|
61
|
+
if (renamed) task.text = renamed
|
|
62
|
+
if (input.status === TASK_STATUS.inProgress && task.startedAt === null) task.startedAt = tracker.time
|
|
63
|
+
if (input.status === TASK_STATUS.pending) task.startedAt = null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const onTodoWrite = (tracker: Tracker, input: Record<string, unknown>): void => {
|
|
67
|
+
const todos = recordsIn(input.todos)
|
|
68
|
+
const active = new Map<string, number>()
|
|
69
|
+
for (const todo of todos) {
|
|
70
|
+
const text = todo.status === TASK_STATUS.inProgress ? firstText(todo.content, todo.activeForm) : ''
|
|
71
|
+
if (text) active.set(text, tracker.todos.get(text) ?? tracker.time)
|
|
72
|
+
}
|
|
73
|
+
tracker.todos = active
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const onToolUse = (tracker: Tracker, block: Block): void => {
|
|
77
|
+
const input = block.input
|
|
78
|
+
if (!isRecord(input)) return
|
|
79
|
+
if (block.name === 'TaskCreate' && typeof block.id === 'string') tracker.pending.set(block.id, input)
|
|
80
|
+
if (block.name === 'TaskUpdate') onTaskUpdate(tracker, input)
|
|
81
|
+
if (block.name === 'TodoWrite') onTodoWrite(tracker, input)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const visit = (tracker: Tracker, entry: Record<string, unknown>): void => {
|
|
85
|
+
const time = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : Number.NaN
|
|
86
|
+
if (!Number.isNaN(time)) tracker.time = time
|
|
87
|
+
if (typeof entry.cwd === 'string') tracker.cwd = entry.cwd
|
|
88
|
+
for (const block of blocksOf(entry)) {
|
|
89
|
+
if (block.type === 'tool_use') onToolUse(tracker, block)
|
|
90
|
+
if (block.type === 'tool_result') onResult(tracker, entry, block)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The tasks one Claude Code session log (or subagent log), given as its lines, has in progress at its end: a
|
|
96
|
+
* `TaskCreate` whose `TaskUpdate` set `in_progress` and none completed or deleted, plus the `in_progress` entries of
|
|
97
|
+
* the last `TodoWrite`. A line that is no JSON is skipped. Oldest first.
|
|
98
|
+
*/
|
|
99
|
+
export const claudeSignalsOf = (lines: readonly string[], agent: string): TaskSignal[] => {
|
|
100
|
+
const tracker: Tracker = { pending: new Map(), tasks: new Map(), todos: new Map(), cwd: '', time: 0 }
|
|
101
|
+
for (const line of lines) {
|
|
102
|
+
const entry = parseLogEntry(line)
|
|
103
|
+
if (entry) visit(tracker, entry)
|
|
104
|
+
}
|
|
105
|
+
const started = [...tracker.tasks.values()].flatMap(({ text, startedAt }) =>
|
|
106
|
+
startedAt === null || !text ? [] : [{ text, startedAt }],
|
|
107
|
+
)
|
|
108
|
+
const todos = [...tracker.todos].map(([text, startedAt]) => ({ text, startedAt }))
|
|
109
|
+
return [...started, ...todos]
|
|
110
|
+
.sort((left, right) => left.startedAt - right.startedAt)
|
|
111
|
+
.map(({ text, startedAt }) => ({ agent, source: 'claude', text, startedAt, cwd: tracker.cwd }))
|
|
112
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mainCheckout } from '../plans/git'
|
|
2
|
+
import { CLI_NAME } from '../version/release'
|
|
3
|
+
import { agentIdentity } from './agent'
|
|
4
|
+
import type { TaskRecord } from './record'
|
|
5
|
+
import { buildTaskId } from './record'
|
|
6
|
+
import { finishTask, openTaskOf, startTask } from './record-store'
|
|
7
|
+
import { cleanTaskText } from './text'
|
|
8
|
+
|
|
9
|
+
export const USAGE = `usage: ${CLI_NAME} task start "<task text>"
|
|
10
|
+
${CLI_NAME} task done ["<task text>"]
|
|
11
|
+
|
|
12
|
+
start report the task you begin; the herd shows it as the one being worked on
|
|
13
|
+
done report it finished; with text, only when it names the open task
|
|
14
|
+
One task per agent: a new start replaces the previous one.`
|
|
15
|
+
|
|
16
|
+
export type TaskCommand =
|
|
17
|
+
| { kind: 'start'; text: string }
|
|
18
|
+
| { kind: 'done'; text: string }
|
|
19
|
+
| { kind: 'help' }
|
|
20
|
+
| { kind: 'usage' }
|
|
21
|
+
|
|
22
|
+
/** The command line as a command: the words after `start` or `done` are the task text, whether quoted or not. */
|
|
23
|
+
export const parseTaskCommand = (args: readonly string[]): TaskCommand => {
|
|
24
|
+
const [name, ...words] = args
|
|
25
|
+
if (name === '--help' || name === '-h') return { kind: 'help' }
|
|
26
|
+
const text = cleanTaskText(words.join(' '))
|
|
27
|
+
if (name === 'start' && text !== '') return { kind: 'start', text }
|
|
28
|
+
if (name === 'done') return { kind: 'done', text }
|
|
29
|
+
return { kind: 'usage' }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type DoneReport = { exitCode: number; message: string | null }
|
|
33
|
+
|
|
34
|
+
/** What `jh task done` says: nothing without an open task (a second call is no error), else the closed task or why the text did not close it. */
|
|
35
|
+
export const doneReport = (open: TaskRecord | null, closed: TaskRecord | null): DoneReport => {
|
|
36
|
+
if (open === null) return { exitCode: 0, message: null }
|
|
37
|
+
if (closed === null) return { exitCode: 1, message: `the open task is: ${open.text}` }
|
|
38
|
+
return { exitCode: 0, message: `done: ${closed.text}` }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const newId = (): string => buildTaskId(crypto.getRandomValues(new Uint8Array(6)))
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `jh task start "<text>"` reports the task the calling agent begins, `jh task done ["<text>"]` that it is finished.
|
|
45
|
+
* Exit 0; 1 on a wrong command line or when the text of `done` names another task than the open one. `done` without
|
|
46
|
+
* an open task is silent and exits 0.
|
|
47
|
+
*/
|
|
48
|
+
export const run = (args: string[]): number => {
|
|
49
|
+
const command = parseTaskCommand(args)
|
|
50
|
+
if (command.kind === 'help') {
|
|
51
|
+
console.log(USAGE)
|
|
52
|
+
return 0
|
|
53
|
+
}
|
|
54
|
+
if (command.kind === 'usage') {
|
|
55
|
+
console.error(USAGE)
|
|
56
|
+
return 1
|
|
57
|
+
}
|
|
58
|
+
const cwd = process.cwd()
|
|
59
|
+
const root = mainCheckout(cwd)
|
|
60
|
+
const agent = agentIdentity(process.env, cwd)
|
|
61
|
+
const now = Date.now()
|
|
62
|
+
if (command.kind === 'start') {
|
|
63
|
+
const record = startTask(root, { agent, text: command.text, cwd, newId }, now)
|
|
64
|
+
console.log(`started: ${record.text}`)
|
|
65
|
+
return 0
|
|
66
|
+
}
|
|
67
|
+
const open = openTaskOf(root, agent)
|
|
68
|
+
const { exitCode, message } = doneReport(open, finishTask(root, agent, command.text, now))
|
|
69
|
+
if (message !== null) (exitCode === 0 ? console.log : console.error)(message)
|
|
70
|
+
return exitCode
|
|
71
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{"type":"assistant","timestamp":"1999-04-03T10:00:00.000Z","cwd":"/work/project","message":{"content":[{"type":"tool_use","id":"toolu_A","name":"TaskCreate","input":{"subject":"Write the parser","activeForm":"Writing the parser","description":"Parse the input"}}]}}
|
|
2
|
+
{"type":"user","timestamp":"1999-04-03T10:00:01.000Z","cwd":"/work/project","toolUseResult":{"task":{"id":"1","subject":"Write the parser"}},"message":{"content":[{"type":"tool_result","tool_use_id":"toolu_A","content":"Task \u00231 created successfully: Write the parser"}]}}
|
|
3
|
+
{"type":"assistant","timestamp":"1999-04-03T10:00:02.000Z","cwd":"/work/project","message":{"content":[{"type":"tool_use","id":"toolu_B","name":"TaskCreate","input":{"subject":"Add the tests","activeForm":"Adding the tests"}}]}}
|
|
4
|
+
{"type":"user","timestamp":"1999-04-03T10:00:03.000Z","cwd":"/work/project","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_B","content":"Task \u00232 created successfully: Add the tests"}]}}
|
|
5
|
+
{"type":"assistant","timestamp":"1999-04-03T10:00:04.000Z","cwd":"/work/project","message":{"content":[{"type":"tool_use","id":"toolu_C","name":"TaskUpdate","input":{"taskId":"1","status":"in_progress"}},{"type":"tool_use","id":"toolu_D","name":"TaskUpdate","input":{"taskId":"2","status":"in_progress"}}]}}
|
|
6
|
+
{"type":"assistant","timestamp":"1999-04-03T10:00:09.000Z","cwd":"/work/project","message":{"content":[{"type":"tool_use","id":"toolu_E","name":"TaskUpdate","input":{"taskId":"2","status":"completed"}}]}}
|
|
7
|
+
not json
|