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,128 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from 'node:path'
|
|
2
|
+
import { isRecord } from '../shared/json'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A send of files to the user as the hook records it: the files (absolute), the caption, when it happened, from where
|
|
6
|
+
* (the directory and session of the call, the subagent when one sent it) and when the coordinator closed it, null
|
|
7
|
+
* while the user's answer is awaited. Written by the hook, read by the herd and the watcher.
|
|
8
|
+
*/
|
|
9
|
+
export type SentRecord = {
|
|
10
|
+
id: string
|
|
11
|
+
files: string[]
|
|
12
|
+
caption: string | null
|
|
13
|
+
sentAt: number
|
|
14
|
+
cwd: string
|
|
15
|
+
session: string
|
|
16
|
+
agentId: string | null
|
|
17
|
+
closedAt: number | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type SentCall = {
|
|
21
|
+
/** The tool input of `SendUserFile`: `files`, `caption`, `status`, `display`. */
|
|
22
|
+
toolInput: unknown
|
|
23
|
+
cwd: string
|
|
24
|
+
session: string
|
|
25
|
+
agentId: string | null
|
|
26
|
+
now: number
|
|
27
|
+
id: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ArtifactCall = SentCall & { toolResponse: unknown }
|
|
31
|
+
|
|
32
|
+
const ID = /^[0-9a-f]{12}$/
|
|
33
|
+
const MAX_FILES = 20
|
|
34
|
+
const MAX_CAPTION = 200
|
|
35
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/
|
|
36
|
+
const CONTROLS = new RegExp(CONTROL.source, 'g')
|
|
37
|
+
/** The address of a published artifact: https on claude.ai, the host a person opens. */
|
|
38
|
+
const ARTIFACT_ADDRESS = /https:\/\/claude\.ai\/(?:code\/)?artifact\/[A-Za-z0-9_-]+/
|
|
39
|
+
const WHOLE_ARTIFACT_ADDRESS = new RegExp(`^${ARTIFACT_ADDRESS.source}$`)
|
|
40
|
+
/** The actions of the Artifact tool that publish; no action means publish. */
|
|
41
|
+
const READING_ACTIONS = new Set(['list', 'read', 'delete', 'open', 'pin', 'unpin', 'quickstart'])
|
|
42
|
+
const MAX_DEPTH = 4
|
|
43
|
+
|
|
44
|
+
const captionOf = (value: unknown): string | null => {
|
|
45
|
+
if (typeof value !== 'string') return null
|
|
46
|
+
const text = value.replace(CONTROLS, ' ').replace(/\s+/g, ' ').trim()
|
|
47
|
+
return text === '' ? null : text.slice(0, MAX_CAPTION)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A file entry the herd can show and open: a non-empty string without a control character. */
|
|
51
|
+
const isFileEntry = (file: unknown): file is string => typeof file === 'string' && file !== '' && !CONTROL.test(file)
|
|
52
|
+
|
|
53
|
+
/** The fields every record shares: who sent it, when, and still open. */
|
|
54
|
+
const recordOf = (
|
|
55
|
+
{ cwd, session, agentId, now, id }: Omit<SentCall, 'toolInput'>,
|
|
56
|
+
files: string[],
|
|
57
|
+
caption: string | null,
|
|
58
|
+
): SentRecord => ({ id, files, caption, sentAt: now, cwd, session, agentId, closedAt: null })
|
|
59
|
+
|
|
60
|
+
/** The record of a `SendUserFile` call; null when its input names no file or a file with a control character. */
|
|
61
|
+
export const sentRecordOf = ({ toolInput, ...call }: SentCall): SentRecord | null => {
|
|
62
|
+
const files: unknown[] = isRecord(toolInput) && Array.isArray(toolInput.files) ? toolInput.files : []
|
|
63
|
+
if (files.length === 0 || !files.every(isFileEntry)) return null
|
|
64
|
+
const paths = (files as string[])
|
|
65
|
+
.slice(0, MAX_FILES)
|
|
66
|
+
.map((file) => (isAbsolute(file) ? file : resolve(call.cwd, file)))
|
|
67
|
+
return recordOf(call, paths, isRecord(toolInput) ? captionOf(toolInput.caption) : null)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The strings of an answer, wherever it carries them: the answer itself, an object's `url`, `text` and `content`, a
|
|
72
|
+
* list's entries.
|
|
73
|
+
*/
|
|
74
|
+
const stringsOf = (value: unknown, depth = 0): string[] => {
|
|
75
|
+
if (typeof value === 'string') return [value]
|
|
76
|
+
if (depth >= MAX_DEPTH) return []
|
|
77
|
+
if (Array.isArray(value)) return value.flatMap((entry) => stringsOf(entry, depth + 1))
|
|
78
|
+
if (!isRecord(value)) return []
|
|
79
|
+
return [value.url, value.text, value.content].flatMap((entry) => stringsOf(entry, depth + 1))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The first artifact address an answer names, whether it is an object with `url` or text. */
|
|
83
|
+
const artifactAddressIn = (toolResponse: unknown): string | null => {
|
|
84
|
+
for (const text of stringsOf(toolResponse)) {
|
|
85
|
+
const address = ARTIFACT_ADDRESS.exec(text)?.[0]
|
|
86
|
+
if (address) return address
|
|
87
|
+
}
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The title the answer names, else the input's `title` or `label`. */
|
|
92
|
+
const artifactCaptionOf = (toolInput: Record<string, unknown>, toolResponse: unknown): string | null =>
|
|
93
|
+
captionOf(isRecord(toolResponse) ? toolResponse.title : null) ??
|
|
94
|
+
captionOf(toolInput.title) ??
|
|
95
|
+
captionOf(toolInput.label)
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The record of an Artifact publish (or update) as a send: its file is the artifact's address, read from the tool's
|
|
99
|
+
* answer (an object with `url`, or text such as `Published … at <address> (Version 3 …)`), and its caption the
|
|
100
|
+
* artifact's title. Null for a listing, a read, an asset upload and an answer without such an address.
|
|
101
|
+
*/
|
|
102
|
+
export const artifactRecordOf = ({ toolInput, toolResponse, ...call }: ArtifactCall): SentRecord | null => {
|
|
103
|
+
if (!isRecord(toolInput)) return null
|
|
104
|
+
const action = toolInput.action
|
|
105
|
+
if ((typeof action === 'string' && READING_ACTIONS.has(action)) || toolInput.asset === true) return null
|
|
106
|
+
const address = artifactAddressIn(toolResponse)
|
|
107
|
+
if (address === null) return null
|
|
108
|
+
return recordOf(call, [address], artifactCaptionOf(toolInput, toolResponse))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A file entry of a record: an artifact address as a whole, or a path. */
|
|
112
|
+
const isRecordedFile = (file: unknown): boolean =>
|
|
113
|
+
typeof file === 'string' && (!file.startsWith('https://') || WHOLE_ARTIFACT_ADDRESS.test(file))
|
|
114
|
+
|
|
115
|
+
/** A record of the shape the hook writes; the herd reads files only through this. */
|
|
116
|
+
export const isSentRecord = (value: unknown): value is SentRecord =>
|
|
117
|
+
isRecord(value) &&
|
|
118
|
+
typeof value.id === 'string' &&
|
|
119
|
+
ID.test(value.id) &&
|
|
120
|
+
Array.isArray(value.files) &&
|
|
121
|
+
value.files.length > 0 &&
|
|
122
|
+
value.files.every(isRecordedFile) &&
|
|
123
|
+
(value.caption === null || typeof value.caption === 'string') &&
|
|
124
|
+
typeof value.sentAt === 'number' &&
|
|
125
|
+
typeof value.cwd === 'string' &&
|
|
126
|
+
typeof value.session === 'string' &&
|
|
127
|
+
(value.agentId === null || typeof value.agentId === 'string') &&
|
|
128
|
+
(value.closedAt === null || typeof value.closedAt === 'number')
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { isRecord } from '../shared/json'
|
|
2
|
+
import { artifactRecordOf, type SentRecord, sentRecordOf } from './record'
|
|
3
|
+
|
|
4
|
+
/** Everything with a side effect, passed in so a test reaches the flow without git, files or a clock. */
|
|
5
|
+
export type SentFileDependencies = {
|
|
6
|
+
mainCheckoutOf: (cwd: string) => string
|
|
7
|
+
writeRecord: (main: string, record: SentRecord) => void
|
|
8
|
+
now: () => number
|
|
9
|
+
randomBytes: () => Uint8Array
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The tools whose calls are recorded: files sent to the user and artifacts published for them. */
|
|
13
|
+
const SEND_USER_FILE_TOOL = 'SendUserFile'
|
|
14
|
+
const ARTIFACT_TOOL = 'Artifact'
|
|
15
|
+
const ID_BYTES = 6
|
|
16
|
+
|
|
17
|
+
/** The id from random bytes the caller draws: 12 hex characters. */
|
|
18
|
+
const idFrom = (bytes: Uint8Array): string =>
|
|
19
|
+
[...bytes.slice(0, ID_BYTES)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Records the files a session sent to the user and the artifacts it published for them, for the herd and the
|
|
23
|
+
* watcher, in the main checkout of the call's directory. Claude Code's hook input: `cwd`, `session_id`,
|
|
24
|
+
* `tool_name`, `tool_input`, and `agent_id` inside a subagent. Records nothing for another tool or a call without
|
|
25
|
+
* files, and nothing on any failure: the record never stands in the agent's way.
|
|
26
|
+
*/
|
|
27
|
+
export const runSentFile = (input: unknown, dependencies: SentFileDependencies): void => {
|
|
28
|
+
try {
|
|
29
|
+
if (!isRecord(input) || (input.tool_name !== SEND_USER_FILE_TOOL && input.tool_name !== ARTIFACT_TOOL)) return
|
|
30
|
+
const { cwd, session_id: session, agent_id: agentId } = input
|
|
31
|
+
if (typeof cwd !== 'string' || typeof session !== 'string') return
|
|
32
|
+
const call = {
|
|
33
|
+
toolInput: input.tool_input,
|
|
34
|
+
cwd,
|
|
35
|
+
session,
|
|
36
|
+
agentId: typeof agentId === 'string' ? agentId : null,
|
|
37
|
+
now: dependencies.now(),
|
|
38
|
+
id: idFrom(dependencies.randomBytes()),
|
|
39
|
+
}
|
|
40
|
+
const record =
|
|
41
|
+
input.tool_name === ARTIFACT_TOOL
|
|
42
|
+
? artifactRecordOf({ ...call, toolResponse: input.tool_response })
|
|
43
|
+
: sentRecordOf(call)
|
|
44
|
+
if (record) dependencies.writeRecord(dependencies.mainCheckoutOf(cwd), record)
|
|
45
|
+
} catch {
|
|
46
|
+
// A failed record is no record; the herd shows no badge.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { writePrivateRecord } from '../shared/private-directory'
|
|
3
|
+
import type { SentRecord } from './record'
|
|
4
|
+
|
|
5
|
+
/** Where the records lie: in the main checkout beside `.jackherd/approvals`, which the herd reads. Ignored by git. */
|
|
6
|
+
export const sentDirectory = (main: string): string => join(main, '.jackherd', 'sent')
|
|
7
|
+
|
|
8
|
+
export const writeSentRecord = (main: string, record: SentRecord): void =>
|
|
9
|
+
writePrivateRecord(sentDirectory(main), record)
|
|
@@ -72,6 +72,18 @@ const planPathOf = (boardDirectory: string, cell: string): string[] => {
|
|
|
72
72
|
return href === undefined ? [] : [posix.normalize(posix.join(boardDirectory, href.split('#')[0]))]
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** The rows of one lane since it was last cleaned up: a named row without the mark after a closed one starts it anew. */
|
|
76
|
+
const currentRows = <Row extends { cells: string[] }>(rows: Row[], closedWord: string): Row[] =>
|
|
77
|
+
rows.reduce<{ closed: boolean; kept: Row[] }>(
|
|
78
|
+
({ closed, kept }, row) => {
|
|
79
|
+
const first = row.cells[0] ?? ''
|
|
80
|
+
const marked = first.includes(closedWord)
|
|
81
|
+
const startsAnew = closed && !marked && !first.startsWith(CONTINUATION)
|
|
82
|
+
return { closed: marked || (closed && !startsAnew), kept: startsAnew ? [row] : [...kept, row] }
|
|
83
|
+
},
|
|
84
|
+
{ closed: false, kept: [] },
|
|
85
|
+
).kept
|
|
86
|
+
|
|
75
87
|
/** Every lane the lanes table names, in table order. */
|
|
76
88
|
export const laneNamesOf = (markdown: string, language: BoardLanguage): string[] => [
|
|
77
89
|
...new Set(laneRows(markdown, language).map(({ lane }) => lane)),
|
|
@@ -84,8 +96,9 @@ export const lanePlansOf = (
|
|
|
84
96
|
lane: string,
|
|
85
97
|
language: BoardLanguage,
|
|
86
98
|
): LanePlans | undefined => {
|
|
87
|
-
const
|
|
88
|
-
if (
|
|
99
|
+
const all = laneRows(markdown, language).filter((row) => row.lane === lane)
|
|
100
|
+
if (all.length === 0) return undefined
|
|
101
|
+
const rows = currentRows(all, BOARD_WORDS[language].laneClosed)
|
|
89
102
|
return {
|
|
90
103
|
closed: rows.some(({ cells }) => (cells[0] ?? '').includes(BOARD_WORDS[language].laneClosed)),
|
|
91
104
|
plans: [...new Set(rows.flatMap(({ cells }) => planPathOf(boardDirectory, cells[PLAN_COLUMN] ?? '')))],
|
|
@@ -19,3 +19,10 @@ export const readConfig = (directories: readonly string[]): Record<string, unkno
|
|
|
19
19
|
/** The object under `key`, `{}` when it is missing or no object. */
|
|
20
20
|
export const sectionOf = (config: Record<string, unknown>, key: string): Record<string, unknown> =>
|
|
21
21
|
isRecord(config[key]) ? config[key] : {}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether the hint `key` of the `hints` section is on: off only where the first of `configs` (project before home)
|
|
25
|
+
* that sets it to a boolean says `false`; a missing or malformed value leaves the next config its say.
|
|
26
|
+
*/
|
|
27
|
+
export const isHintOn = (configs: readonly Record<string, unknown>[], key: string): boolean =>
|
|
28
|
+
configs.map((config) => sectionOf(config, 'hints')[key]).find((value) => typeof value === 'boolean') !== false
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The answers of the user's dialogs, read from a session transcript. The transcript is written by the agent's own
|
|
3
|
+
* client, not by the agent: a dialog answer sits in it as the result of an `AskUserQuestion` call, with the question the
|
|
4
|
+
* agent put and the label the user picked. Nothing an agent says in a message, a file or a command is one.
|
|
5
|
+
*/
|
|
1
6
|
import type { DialogAnswer } from './dialog-answer'
|
|
2
7
|
import { isRecord, type JsonObject } from './json'
|
|
3
8
|
|
|
@@ -6,13 +11,8 @@ export type { DialogAnswer } from './dialog-answer'
|
|
|
6
11
|
/** The tool name of the dialog, as hook inputs and transcripts carry it. */
|
|
7
12
|
export const ASK_USER_QUESTION_TOOL = 'AskUserQuestion'
|
|
8
13
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
* client, not by the agent: a dialog answer sits in it as the result of an `AskUserQuestion` call, with the question the
|
|
12
|
-
* agent put and the label the user picked. Nothing an agent says in a message, a file or a command is one.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
const contentOf = (entry: JsonObject): JsonObject[] => {
|
|
14
|
+
/** The content blocks of a transcript entry's message; none when it has no list of blocks. */
|
|
15
|
+
export const contentOf = (entry: JsonObject): JsonObject[] => {
|
|
16
16
|
const content = isRecord(entry.message) ? entry.message.content : undefined
|
|
17
17
|
return Array.isArray(content) ? content.filter(isRecord) : []
|
|
18
18
|
}
|
|
@@ -34,10 +34,10 @@ const offeredLabels = (question: JsonObject): string[] =>
|
|
|
34
34
|
? question.options.flatMap((option) => (isRecord(option) && typeof option.label === 'string' ? [option.label] : []))
|
|
35
35
|
: []
|
|
36
36
|
|
|
37
|
-
type AskedQuestion = { question: string; offered: string[] }
|
|
37
|
+
export type AskedQuestion = { question: string; offered: string[] }
|
|
38
38
|
|
|
39
39
|
/** The questions of an `AskUserQuestion` input; a call that carries its own `answers` asked nobody. */
|
|
40
|
-
const questionsOf = (input: unknown): AskedQuestion[] => {
|
|
40
|
+
export const questionsOf = (input: unknown): AskedQuestion[] => {
|
|
41
41
|
if (!isRecord(input) || 'answers' in input || !Array.isArray(input.questions)) return []
|
|
42
42
|
return input.questions.flatMap((question) =>
|
|
43
43
|
isRecord(question) && typeof question.question === 'string'
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { readFileSync, realpathSync } from 'node:fs'
|
|
2
|
+
import { isAbsolute, relative } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/** Whether `path` is `directory` or lies below it; a sibling that only shares the prefix does not. */
|
|
5
|
+
export const isWithinDirectory = (path: string, directory: string): boolean => {
|
|
6
|
+
const inside = relative(directory, path)
|
|
7
|
+
return inside === '' || (!inside.startsWith('..') && !isAbsolute(inside))
|
|
8
|
+
}
|
|
2
9
|
|
|
3
10
|
/** The file's text; null when it cannot be read. */
|
|
4
11
|
export const readTextOrNull = (path: string): string | null => {
|
|
@@ -46,7 +46,7 @@ export const laneOfAgent = ({ agent, session, main, worktreeDirectory }: AgentLo
|
|
|
46
46
|
/**
|
|
47
47
|
* Whether the session that merges is the coordinator: the agent of its pane, in this very session, started in the main
|
|
48
48
|
* checkout and not under a lane's name. A lane's transcript may lie beside the coordinator's when it was started in the
|
|
49
|
-
* main checkout; its name still tells it apart, so the
|
|
49
|
+
* main checkout; its name still tells it apart, so an approval of the coordinator never covers a lane's own call.
|
|
50
50
|
*/
|
|
51
51
|
export const isCoordinatorAgent = ({ agent, session, main, isLane }: MergingSession): boolean =>
|
|
52
52
|
agent !== null &&
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The answers that decide a question asked more than once: the newest ones not from the future. Several given at the
|
|
3
|
+
* same moment (two questions in one dialog) decide only together; empty when none was given before `now`.
|
|
4
|
+
*/
|
|
5
|
+
export const newestVerdict = <Answer extends { answeredAt: number }>(answers: readonly Answer[], now: number): Answer[] => {
|
|
6
|
+
const past = answers.filter(({ answeredAt }) => answeredAt <= now)
|
|
7
|
+
if (past.length === 0) return []
|
|
8
|
+
const newest = Math.max(...past.map(({ answeredAt }) => answeredAt))
|
|
9
|
+
return past.filter(({ answeredAt }) => answeredAt === newest)
|
|
10
|
+
}
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
lstatSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
type Stats,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs'
|
|
2
10
|
import { join } from 'node:path'
|
|
3
11
|
|
|
4
12
|
/** The mode of a file in a private directory: readable by its owner only. */
|
|
@@ -14,16 +22,55 @@ const entryAt = (path: string): Stats | null => {
|
|
|
14
22
|
}
|
|
15
23
|
}
|
|
16
24
|
|
|
25
|
+
const removeIfPresent = (path: string): void => {
|
|
26
|
+
try {
|
|
27
|
+
unlinkSync(path)
|
|
28
|
+
} catch {
|
|
29
|
+
// nothing left over
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether an entry belongs to `uid`; true where the platform has no user ids (`uid` undefined). */
|
|
34
|
+
export const isOwnedBy = (entry: Pick<Stats, 'uid'>, uid: number | undefined): boolean =>
|
|
35
|
+
uid === undefined || entry.uid === uid
|
|
36
|
+
|
|
37
|
+
const assertPrivateDirectory = (directory: string, entry: Stats | null): void => {
|
|
38
|
+
if (!entry || entry.isSymbolicLink() || !entry.isDirectory()) throw new Error(`${directory} is no directory`)
|
|
39
|
+
if (!isOwnedBy(entry, process.getuid?.())) throw new Error(`${directory} belongs to another user`)
|
|
40
|
+
}
|
|
41
|
+
|
|
17
42
|
/**
|
|
18
|
-
* A directory only its owner reads, ignored by git: created `0700` when missing; when present
|
|
19
|
-
*
|
|
20
|
-
* it lacks. An existing `.gitignore`
|
|
43
|
+
* A directory only its owner reads, ignored by git: created `0700` when missing; when present (or once created, which
|
|
44
|
+
* covers a link planted in between) it must be a directory of the current user and not a link (else this throws, so
|
|
45
|
+
* nothing is written through it), is narrowed to `0700` and gets the `.gitignore` it lacks. An existing `.gitignore`
|
|
46
|
+
* is kept.
|
|
21
47
|
*/
|
|
22
48
|
export const ensurePrivateDirectory = (directory: string): void => {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (!entry) mkdirSync(directory, { recursive: true, mode: PRIVATE_DIRECTORY })
|
|
49
|
+
if (!entryAt(directory)) mkdirSync(directory, { recursive: true, mode: PRIVATE_DIRECTORY })
|
|
50
|
+
assertPrivateDirectory(directory, entryAt(directory))
|
|
26
51
|
chmodSync(directory, PRIVATE_DIRECTORY)
|
|
27
|
-
|
|
28
|
-
|
|
52
|
+
try {
|
|
53
|
+
writeFileSync(join(directory, '.gitignore'), '*\n', { flag: 'wx', mode: PRIVATE_FILE })
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Writes `text` to `path` (inside a directory `ensurePrivateDirectory` has verified) `0600` through a temporary file, so a
|
|
61
|
+
* reader never sees half of it. A leftover temporary file of an earlier process with the same pid is removed first,
|
|
62
|
+
* and the new one is created exclusively, so a planted link is never followed.
|
|
63
|
+
*/
|
|
64
|
+
export const writePrivateFile = (path: string, text: string): void => {
|
|
65
|
+
const temporary = `${path}.${process.pid}.tmp`
|
|
66
|
+
removeIfPresent(temporary)
|
|
67
|
+
writeFileSync(temporary, text, { mode: PRIVATE_FILE, flag: 'wx' })
|
|
68
|
+
chmodSync(temporary, PRIVATE_FILE)
|
|
69
|
+
renameSync(temporary, path)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Writes `<id>.json` into a private directory through a temporary file, so a reader never sees half of it. */
|
|
73
|
+
export const writePrivateRecord = <T extends { id: string }>(directory: string, record: T): void => {
|
|
74
|
+
ensurePrivateDirectory(directory)
|
|
75
|
+
writePrivateFile(join(directory, `${record.id}.json`), `${JSON.stringify(record)}\n`)
|
|
29
76
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { posix } from 'node:path'
|
|
2
|
-
import {
|
|
2
|
+
import type { SimpleCommand, Word } from './shell'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Words Claude Code strips before it matches a Bash rule, so they do not hide the command. Its
|
|
@@ -72,7 +72,7 @@ export const isProgramPosition = (words: SimpleCommand, index: number): boolean
|
|
|
72
72
|
|
|
73
73
|
export const isOption = (word: Word | undefined): boolean => word?.value.startsWith('-') ?? false
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
const isShell = (word: Word): boolean => SHELLS.has(programName(word))
|
|
76
76
|
|
|
77
77
|
/** The script word after `-c`: the first one that is neither an option nor `--`. */
|
|
78
78
|
const shellCommandScript = (words: SimpleCommand, shellIndex: number): string | undefined => {
|
|
@@ -150,76 +150,3 @@ export const inlineScript = (words: SimpleCommand, index: number): string | unde
|
|
|
150
150
|
if (isBuiltinAt(words, index, TRAP_BUILTIN)) return trapAction(words, index)
|
|
151
151
|
return isShell(words[index]) ? shellCommandScript(words, index) : undefined
|
|
152
152
|
}
|
|
153
|
-
|
|
154
|
-
/** Interpreters that run program text given on the command line or read from their input. */
|
|
155
|
-
const INTERPRETERS = new Set(['python', 'python3', 'node', 'nodejs', 'bun', 'deno', 'perl', 'ruby', 'php', 'lua'])
|
|
156
|
-
const INLINE_PROGRAM_OPTIONS = new Set(['-c', '-e', '-E', '-p', '-pe', '--eval', '--command', '--print', '-r'])
|
|
157
|
-
const VERSIONED_PYTHON = /^python\d+(\.\d+)*$/
|
|
158
|
-
/** A cluster of short options that ends in the one taking the program text: `python3 -Ic`, `perl -lne`. */
|
|
159
|
-
const INLINE_PROGRAM_CLUSTER = /^-[A-Za-z]*[ce]$/
|
|
160
|
-
/** An operand that names the interpreter's own input as the program file. */
|
|
161
|
-
const INPUT_OPERAND = /^(-|\/dev\/stdin|\/dev\/fd\/0|\/proc\/self\/fd\/0)$/
|
|
162
|
-
/** Options that print and exit, so an interpreter without an operand reads no program. */
|
|
163
|
-
const PRINT_AND_EXIT = new Set(['--version', '-v', '-V', '--help', '-h'])
|
|
164
|
-
/** Interpreters that, without an operand, print their help instead of reading a program; they read one after `run`. */
|
|
165
|
-
const SUBCOMMAND_INTERPRETERS = new Set(['bun'])
|
|
166
|
-
const RUN_SUBCOMMAND = 'run'
|
|
167
|
-
/** An operand that names a script file: the words after it are the script's arguments, not the interpreter's options. */
|
|
168
|
-
const SCRIPT_FILE = /\.(py|js|mjs|cjs|ts|mts|cts|jsx|tsx|rb|pl|pm|php|lua)$/i
|
|
169
|
-
|
|
170
|
-
const isInterpreter = (word: Word): boolean => {
|
|
171
|
-
const name = programName(word)
|
|
172
|
-
return INTERPRETERS.has(name) || VERSIONED_PYTHON.test(name)
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const isInlineProgramOption = ({ value }: Word): boolean =>
|
|
176
|
-
INLINE_PROGRAM_OPTIONS.has(value.split('=')[0]) || INLINE_PROGRAM_CLUSTER.test(value)
|
|
177
|
-
|
|
178
|
-
/** The operand that names the program: the first one, or for `bun run` the one after `run`; `-` counts as one. */
|
|
179
|
-
const programOperand = (interpreter: Word, rest: Word[]): Word | undefined => {
|
|
180
|
-
const operands = rest.filter((word) => !isOption(word) || word.value === '-')
|
|
181
|
-
const afterRun = SUBCOMMAND_INTERPRETERS.has(programName(interpreter)) && operands[0]?.value === RUN_SUBCOMMAND
|
|
182
|
-
return afterRun ? operands[1] : operands[0]
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Whether the interpreter reads its program from its input (`python3 -`, `node` fed by a pipe, `bun -`) or from an
|
|
187
|
-
* expansion it cannot show (`python3 <(…)`).
|
|
188
|
-
*/
|
|
189
|
-
const readsProgramFromInput = (interpreter: Word, rest: Word[]): boolean => {
|
|
190
|
-
const operand = programOperand(interpreter, rest)
|
|
191
|
-
if (operand === undefined) {
|
|
192
|
-
return (
|
|
193
|
-
!SUBCOMMAND_INTERPRETERS.has(programName(interpreter)) && !rest.some(({ value }) => PRINT_AND_EXIT.has(value))
|
|
194
|
-
)
|
|
195
|
-
}
|
|
196
|
-
// A quoted operand is still a file name; only an expansion (`$f`, `<(…)`) hides where the program comes from.
|
|
197
|
-
return isExpanded(operand.value) || INPUT_OPERAND.test(operand.value)
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* The words up to a script file operand. Only a file name ends them: any other word could be an option's value
|
|
202
|
-
* (`python3 -W ignore -c …`), and stopping there would miss the `-c` behind it.
|
|
203
|
-
*/
|
|
204
|
-
const interpreterOptions = (rest: Word[]): Word[] => {
|
|
205
|
-
const scriptFile = rest.findIndex((word) => !isOption(word) && SCRIPT_FILE.test(word.value))
|
|
206
|
-
return scriptFile === -1 ? rest : rest.slice(0, scriptFile)
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* An interpreter, anywhere a program can stand, handed program text (`python3 -c`, `node -e`) or reading it from its
|
|
211
|
-
* input (`python3 - <<EOF`, `… | node`): the program can start any command, and no parse of the shell sees it.
|
|
212
|
-
*/
|
|
213
|
-
export const runsInterpreterProgram = (words: SimpleCommand): boolean =>
|
|
214
|
-
words.some((word, index) => {
|
|
215
|
-
if (!isProgramPosition(words, index) || !isInterpreter(word)) return false
|
|
216
|
-
const rest = words.slice(index + 1)
|
|
217
|
-
return interpreterOptions(rest).some(isInlineProgramOption) || readsProgramFromInput(word, rest)
|
|
218
|
-
})
|
|
219
|
-
|
|
220
|
-
/** A shell at the rule start with no script argument reads its script from stdin: a pipe, a here-document. */
|
|
221
|
-
export const readsScriptFromInput = (words: SimpleCommand): boolean => {
|
|
222
|
-
const start = ruleStart(words)
|
|
223
|
-
const shell = words[start]
|
|
224
|
-
return shell !== undefined && isShell(shell) && words.slice(start + 1).every(isOption)
|
|
225
|
-
}
|
|
@@ -6,7 +6,7 @@ export type ShellDefinitions = { aliases: ReadonlyMap<string, string>; functions
|
|
|
6
6
|
|
|
7
7
|
const SHELL_DEFINITION_KIND = { alias: 'alias', function: 'function' } as const
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
type ShellDefinitionKind = (typeof SHELL_DEFINITION_KIND)[keyof typeof SHELL_DEFINITION_KIND]
|
|
10
10
|
|
|
11
11
|
/** A word at a program position that an alias or function stands for, whose body runs a watched program. */
|
|
12
12
|
export type ShellDefinitionHit = { kind: ShellDefinitionKind; name: string; body: string }
|
|
@@ -23,9 +23,6 @@ const NAME_CHARACTER = /[\w.-]/
|
|
|
23
23
|
/** The words a body may call: whatever stands between shell punctuation. */
|
|
24
24
|
const BODY_WORD = /[^\s;|&(){}<>"'`$=]+/g
|
|
25
25
|
const SHOWN_BODY_LENGTH = 80
|
|
26
|
-
const ALIAS_BUILTIN = 'alias'
|
|
27
|
-
/** An entry of the shell's own alias or function table, which defines one as an assignment: `functions[x]=…` (zsh). */
|
|
28
|
-
const DEFINITION_TABLE_ENTRY = /^(aliases|galiases|saliases|functions|dis_functions|BASH_ALIASES)\[/
|
|
29
26
|
|
|
30
27
|
/**
|
|
31
28
|
* Words that are shell builtins or common programs. A word among them is not looked up, so most calls read no
|
|
@@ -179,73 +176,6 @@ export const shellDefinitionAt = (
|
|
|
179
176
|
return undefined
|
|
180
177
|
}
|
|
181
178
|
|
|
182
|
-
/**
|
|
183
|
-
* An `alias` command anywhere a program can stand, or an entry of the alias or function table: the definition it adds
|
|
184
|
-
* in the script hides its text in a quoted word.
|
|
185
|
-
*/
|
|
186
|
-
export const definesShellAlias = (words: SimpleCommand): boolean =>
|
|
187
|
-
words.some(
|
|
188
|
-
(word, index) =>
|
|
189
|
-
(word.value === ALIAS_BUILTIN && isProgramPosition(words, index)) || DEFINITION_TABLE_ENTRY.test(word.value),
|
|
190
|
-
)
|
|
191
|
-
|
|
192
|
-
/** A program expanded from a variable or a substitution may name a function; one with a slash is a path. */
|
|
193
|
-
const isExpandedProgram = (words: SimpleCommand, index: number): boolean => {
|
|
194
|
-
const { value, literal } = words[index]
|
|
195
|
-
return !literal && isExpanded(value) && !value.includes('/') && isProgramPosition(words, index)
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* The word a parameter expansion falls back to or assigns: `shipit` in `${x:-shipit}`, `${x-shipit}`, `${a[0]:+shipit}`.
|
|
200
|
-
* A nested expansion matches from the innermost one, so `${x:-${y:-shipit}}` yields `shipit`. The name may be empty
|
|
201
|
-
* (`${:-shipit}`), special (`${@:-shipit}`) or an expansion itself (`${${x}:-shipit}`, zsh).
|
|
202
|
-
*/
|
|
203
|
-
const EXPANSION_OPERAND = /\$\{(?:[^{}:=+?-]|\$\{[^{}]*\})*:?[-=+?]([^{}]*)\}/g
|
|
204
|
-
|
|
205
|
-
/** `text` with each expansion operand beside it, so a name after the operator's `-` still stands as a word. */
|
|
206
|
-
const withExpansionOperands = (text: string): string =>
|
|
207
|
-
[text, ...[...text.matchAll(EXPANSION_OPERAND)].map(([, operand]) => operand)].join('\n')
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* A script that lists the shell's functions can take a function's name from there without writing it out: zsh's
|
|
211
|
-
* `functions`, `dis_functions` and `functions_source` tables or builtin, `compgen -A function`/`-c`/`-A command`
|
|
212
|
-
* (also bundled, `-bA function`), `declare`/`typeset` with `-f`, `-F` or `+f` (`typeset -fm`, `declare -Fp`),
|
|
213
|
-
* `whence`/`which`/`where`/`type -m`, and a bare `set`, which prints the functions in bash.
|
|
214
|
-
*/
|
|
215
|
-
const READS_FUNCTION_TABLE = new RegExp(
|
|
216
|
-
[
|
|
217
|
-
/(?:^|[^\w.-])(?:dis_)?functions(?:_source)?(?:[^\w.-]|$)/,
|
|
218
|
-
/\bcompgen\b[^\n;|&]*-(?:[A-Za-z]*A\s*(?:function|command)\b|[A-Za-z]*c)/,
|
|
219
|
-
/\b(?:declare|typeset)\b[^\n;|&]*\s[+-][A-Za-z]*[fF]/,
|
|
220
|
-
/(?:^|[\s;|&(`])(?:whence|which|where|type)\s[^\n;|&]*-[A-Za-z]*m/,
|
|
221
|
-
/(?:^|[^\w.-])set\s*(?:$|[|;&)`])/,
|
|
222
|
-
]
|
|
223
|
-
.map(({ source }) => source)
|
|
224
|
-
.join('|'),
|
|
225
|
-
'm',
|
|
226
|
-
)
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Whether a program expanded from a variable or a substitution (`f=shipit; $f`) may call a function of the snapshot
|
|
230
|
-
* whose body names one of `programs`. Only a function whose name stands as a word in `text` counts: the script as
|
|
231
|
-
* written and its decoded words, where a variable or substitution would take the name from. The shell expands no alias
|
|
232
|
-
* from an expansion. `definitions` is called only when such a program stands in the command.
|
|
233
|
-
*/
|
|
234
|
-
export const mayRunDefinedFunction = (
|
|
235
|
-
words: SimpleCommand,
|
|
236
|
-
definitions: () => ShellDefinitions,
|
|
237
|
-
programs: readonly string[],
|
|
238
|
-
text: string,
|
|
239
|
-
): boolean => {
|
|
240
|
-
if (!words.some((_word, index) => isExpandedProgram(words, index))) return false
|
|
241
|
-
const known = definitions()
|
|
242
|
-
const searched = withExpansionOperands(text)
|
|
243
|
-
const anyName = READS_FUNCTION_TABLE.test(text)
|
|
244
|
-
return [...known.functions].some(
|
|
245
|
-
([name, body]) => (anyName || namesWord(searched, name)) && runsProgram(known, body, programs, new Set()),
|
|
246
|
-
)
|
|
247
|
-
}
|
|
248
|
-
|
|
249
179
|
const shown = (body: string): string => {
|
|
250
180
|
const line = body.trim().replace(/\s+/g, ' ')
|
|
251
181
|
return line.length > SHOWN_BODY_LENGTH ? `${line.slice(0, SHOWN_BODY_LENGTH)}…` : line
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
import { isRecord } from './json'
|
|
2
|
+
|
|
1
3
|
export type Turn = { firstPrompt: string; tools: string[] }
|
|
2
4
|
|
|
5
|
+
/** A lane prompt names the lane in its first line. */
|
|
6
|
+
const LANE_PROMPT = /^\s*(<pasted_content[^>]*>\s*)?(Du bist die Spur|You are the lane)/i
|
|
7
|
+
|
|
8
|
+
/** Whether the session's first prompt opens a lane (`jh lane prompt`); a lane asks the coordinator, not the user. */
|
|
9
|
+
export const isLanePrompt = (firstPrompt: string): boolean => LANE_PROMPT.test(firstPrompt)
|
|
10
|
+
|
|
3
11
|
type Entry = {
|
|
4
12
|
type?: unknown
|
|
5
13
|
isMeta?: unknown
|
|
@@ -7,7 +15,10 @@ type Entry = {
|
|
|
7
15
|
message?: { content?: unknown }
|
|
8
16
|
}
|
|
9
17
|
|
|
10
|
-
type Block = { type?: unknown; text?: unknown; name?: unknown }
|
|
18
|
+
type Block = { type?: unknown; text?: unknown; name?: unknown; input?: unknown }
|
|
19
|
+
|
|
20
|
+
/** A tool call of the transcript: the tool's name and its input, `{}` when it has none. */
|
|
21
|
+
export type ToolUse = { name: string; input: Record<string, unknown> }
|
|
11
22
|
|
|
12
23
|
const parseEntries = (jsonl: string): Entry[] =>
|
|
13
24
|
jsonl.split('\n').flatMap((line) => {
|
|
@@ -33,11 +44,29 @@ const promptText = (entry: Entry): string | undefined => {
|
|
|
33
44
|
return texts.length > 0 ? texts.join('\n') : undefined
|
|
34
45
|
}
|
|
35
46
|
|
|
36
|
-
const
|
|
47
|
+
const toolUses = (entry: Entry): ToolUse[] =>
|
|
37
48
|
entry.type === 'assistant'
|
|
38
|
-
? blocks(entry).flatMap((block) =>
|
|
49
|
+
? blocks(entry).flatMap((block) =>
|
|
50
|
+
block.type === 'tool_use' && typeof block.name === 'string'
|
|
51
|
+
? [{ name: block.name, input: isRecord(block.input) ? block.input : {} }]
|
|
52
|
+
: [],
|
|
53
|
+
)
|
|
39
54
|
: []
|
|
40
55
|
|
|
56
|
+
/** Every tool call of the transcript lines, in order; a line that does not parse is skipped. */
|
|
57
|
+
export const toolUsesOf = (jsonl: string): ToolUse[] => parseEntries(jsonl).flatMap(toolUses)
|
|
58
|
+
|
|
59
|
+
const firstPromptAmong = (entries: readonly Entry[]): string => {
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
const text = promptText(entry)
|
|
62
|
+
if (text !== undefined) return text
|
|
63
|
+
}
|
|
64
|
+
return ''
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The first prompt typed in the session, `''` without one. */
|
|
68
|
+
export const firstPromptOf = (jsonl: string): string => firstPromptAmong(parseEntries(jsonl))
|
|
69
|
+
|
|
41
70
|
/**
|
|
42
71
|
* The first prompt of the session and the tools the current turn used. The turn starts at the last prompt carrying
|
|
43
72
|
* `promptId` (the Stop input's `prompt_id`), else at the last prompt of the transcript.
|
|
@@ -47,9 +76,11 @@ export const readTurn = (jsonl: string, promptId: string | undefined): Turn => {
|
|
|
47
76
|
const prompts = entries.flatMap((entry, index) => (promptText(entry) === undefined ? [] : [index]))
|
|
48
77
|
const ofTurn = prompts.filter((index) => promptId !== undefined && entries[index].promptId === promptId)
|
|
49
78
|
const start = (ofTurn.length > 0 ? ofTurn : prompts).at(-1) ?? entries.length
|
|
50
|
-
const first = prompts[0]
|
|
51
79
|
return {
|
|
52
|
-
firstPrompt:
|
|
53
|
-
tools: entries
|
|
80
|
+
firstPrompt: firstPromptAmong(entries),
|
|
81
|
+
tools: entries
|
|
82
|
+
.slice(start)
|
|
83
|
+
.flatMap(toolUses)
|
|
84
|
+
.map(({ name }) => name),
|
|
54
85
|
}
|
|
55
86
|
}
|