jackherd 1.15.1 → 1.16.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 +22 -0
- package/README.md +22 -19
- package/dist/{chunk-V4HOLGFP.js → chunk-4LUFR7RC.js} +19 -1
- package/dist/config.d.ts +47 -2
- package/dist/config.js +65 -4
- package/dist/plan-format.js +3 -19
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +49 -4
- package/kit/hooks/hooks/merge-guard/decision.ts +17 -2
- package/kit/workflow/skills/follow/SKILL.md +18 -10
- package/kit/workflow/skills/orchestrate/SKILL.md +9 -6
- package/package.json +2 -1
- package/recipes/README.md +3 -2
- package/recipes/google-calendar.md +30 -0
- package/recipes/sources/google-calendar-today.ts +32 -0
- package/recipes/sources/google-calendar.ts +368 -0
- package/recipes/sources.md +15 -3
- package/schemas/jackherd.schema.json +12 -0
- package/schemas/sources.schema.json +141 -0
- package/src/check/skill-budget-defaults.ts +11 -4
- package/src/cli.ts +1 -0
- package/src/config/json-schema.ts +7 -3
- package/src/config/location.ts +7 -0
- package/src/config/personal-sources.ts +83 -0
- package/src/config/schema.ts +29 -0
- package/src/plans/herd-claim.ts +9 -31
- package/src/plans/layout.ts +135 -18
- package/src/plans/live.ts +54 -7
- package/src/plans/row-actions.ts +17 -10
- package/src/plans/settings.ts +20 -7
- package/src/plans/snapshot.ts +34 -22
- package/src/plans/source-rows.ts +35 -2
- package/src/secrets/command.ts +163 -0
- package/src/secrets/environment.ts +45 -0
- package/src/secrets/hidden-input.ts +51 -0
- package/src/secrets/listing.ts +63 -0
- package/src/secrets/options.ts +45 -0
- package/src/secrets/store.ts +74 -0
- package/src/shared/process-info.ts +89 -0
- package/src/sync/command-context.ts +26 -4
- package/src/sync/render.ts +9 -4
- package/src/watch/command.ts +44 -12
- package/src/watch/lane-report.ts +2 -1
- package/src/watch/plan-badges-read.ts +28 -10
- package/src/watch/source-dry-run-command.ts +23 -12
- package/src/watch/source-dry-run.ts +20 -3
- package/src/watch/source-reload.ts +100 -0
- package/src/watch/source-run.ts +63 -7
- package/src/watch/source-tickets.ts +15 -0
- package/src/watch/sources.ts +21 -0
- package/src/watch/texts.ts +1 -1
- package/src/watch/watch-events.ts +9 -0
- package/src/watch/watched-source.ts +39 -0
- package/src/watch/watcher-lock.ts +8 -1
package/recipes/sources.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The fields and limits of a `watch.sources` entry, for a source no [recipe](README.md) covers or a field a recipe leaves open.
|
|
4
4
|
|
|
5
|
-
Source reports are off unless the config lists `watch.sources`. The watcher cannot use the MCP servers of a Claude Code session: their connections and logins (often OAuth in a browser) belong to the session. A source is a command instead, which prints JSON on stdout. It runs without a shell, in the main checkout, with your environment, so its token stays where it is. `/jh-follow` fills one of the [recipes](README.md) for you.
|
|
5
|
+
Source reports are off unless the config lists `watch.sources`. The watcher cannot use the MCP servers of a Claude Code session: their connections and logins (often OAuth in a browser) belong to the session. A source is a command instead, which prints JSON on stdout. It runs without a shell, in the main checkout (a personal source: below), with your environment, so its token stays where it is. `/jh-follow` fills one of the [recipes](README.md) for you.
|
|
6
6
|
|
|
7
7
|
```json
|
|
8
8
|
{
|
|
@@ -76,9 +76,21 @@ dry run source tickets: ["tickets-cli","search","--query","key in (T-123)","--js
|
|
|
76
76
|
first run of this source: baseline, nothing reported; later a new or changed entry reports like this:
|
|
77
77
|
=== source tickets (search) T-123 Decide the layout · new
|
|
78
78
|
Yes, take variant B.
|
|
79
|
-
watcher state not read or changed;
|
|
79
|
+
watcher state not read or changed; a running watcher takes a new source over at its next cycle
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
**Keep a personal source.** A source can live in `~/.jackherd/sources.json` instead of the repository: `{ "$schema": "…/schemas/sources.schema.json", "sources": [ … ] }`, entries as above. It runs in every repository where the watcher starts, after the repository's `watch.sources`. On a name clash the repository's source wins and the personal one is skipped with a note. A personal source runs with `~/.jackherd/` as its working directory, so its script is `~/.jackherd/sources/<name>.ts` and its command `["bun", "sources/<name>.ts"]`. `jh watch source <name> --dry-run` finds it too and prints `origin: personal, <path>`; a draft file runs as a repository source, in the main checkout and without the keychain.
|
|
83
|
+
|
|
84
|
+
**Keep a secret for a source.** `"secrets": ["NAME"]` (up to 10 names, `A-Z`, digits and `_`, starting with a letter) puts those variables into the environment of the source's command, and nowhere else. For a personal source each value comes from the keychain (service `jackherd`), else from the watcher's own environment; a repository source gets them from the watcher's environment only, never from the keychain. A value that is found nowhere fails the run with `secret missing: NAME`, an unreachable keychain with `secret store unavailable`; other sources go on. The script reads the value from `process.env` and never prints it.
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
jh secret set NAME asks for the value hidden, needs a terminal (exit 2 without), never takes it as argument or from stdin
|
|
88
|
+
jh secret list each name the sources need: set, set (environment), missing, missing (environment only), secret store unavailable
|
|
89
|
+
jh secret delete NAME
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Show entries as content.** `"show": "entries"` opens the source's entries in a herd block of their own under "Your turn", headed by its `label`, instead of the folded row in `Sources`. For a source whose entries are the day's content, such as a calendar; not together with `ticket`.
|
|
93
|
+
|
|
94
|
+
The watcher and the herd read the repository's config and the personal file again each cycle (about a minute): a new, changed or removed source counts without a restart, a new one runs at once. A changed file that is invalid keeps the previous sources and prints `sources not reloaded: <file>: <reason>`. The rest of the config is read when the watcher starts.
|
|
83
95
|
|
|
84
96
|
Three limits stand: the watcher cannot make a command read only, so name only commands that read. A source in `.jackherd/config.json` runs for everyone who starts the watcher in the repository, at the same level of trust as `check.checks` and hooks. The watcher reads the config of the main checkout, never the one of a lane's worktree; a dry run started inside a worktree reads that worktree's config first. And `jh watch source … --dry-run` runs whatever command its draft names, so it never belongs in a permission allowlist.
|
|
@@ -633,6 +633,18 @@
|
|
|
633
633
|
"type": "string",
|
|
634
634
|
"minLength": 1
|
|
635
635
|
}
|
|
636
|
+
},
|
|
637
|
+
"secrets": {
|
|
638
|
+
"maxItems": 10,
|
|
639
|
+
"type": "array",
|
|
640
|
+
"items": {
|
|
641
|
+
"type": "string",
|
|
642
|
+
"pattern": "^[A-Z][A-Z0-9_]*$"
|
|
643
|
+
}
|
|
644
|
+
},
|
|
645
|
+
"show": {
|
|
646
|
+
"type": "string",
|
|
647
|
+
"const": "entries"
|
|
636
648
|
}
|
|
637
649
|
},
|
|
638
650
|
"required": [
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"properties": {
|
|
5
|
+
"$schema": {
|
|
6
|
+
"type": "string"
|
|
7
|
+
},
|
|
8
|
+
"sources": {
|
|
9
|
+
"default": [],
|
|
10
|
+
"type": "array",
|
|
11
|
+
"items": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"properties": {
|
|
14
|
+
"name": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"pattern": "^[A-Za-z0-9][\\w.-]*$"
|
|
17
|
+
},
|
|
18
|
+
"command": {
|
|
19
|
+
"minItems": 1,
|
|
20
|
+
"type": "array",
|
|
21
|
+
"items": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"minLength": 1
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"every": {
|
|
27
|
+
"default": 5,
|
|
28
|
+
"type": "integer",
|
|
29
|
+
"minimum": 1,
|
|
30
|
+
"maximum": 9007199254740991
|
|
31
|
+
},
|
|
32
|
+
"items": {
|
|
33
|
+
"type": "string"
|
|
34
|
+
},
|
|
35
|
+
"map": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"properties": {
|
|
38
|
+
"id": {
|
|
39
|
+
"type": "string"
|
|
40
|
+
},
|
|
41
|
+
"updatedAt": {
|
|
42
|
+
"type": "string"
|
|
43
|
+
},
|
|
44
|
+
"title": {
|
|
45
|
+
"type": "string"
|
|
46
|
+
},
|
|
47
|
+
"url": {
|
|
48
|
+
"type": "string"
|
|
49
|
+
},
|
|
50
|
+
"body": {
|
|
51
|
+
"type": "string"
|
|
52
|
+
},
|
|
53
|
+
"author": {
|
|
54
|
+
"type": "string"
|
|
55
|
+
},
|
|
56
|
+
"status": {
|
|
57
|
+
"type": "string"
|
|
58
|
+
},
|
|
59
|
+
"count": {
|
|
60
|
+
"type": "string"
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"required": [
|
|
64
|
+
"id"
|
|
65
|
+
],
|
|
66
|
+
"additionalProperties": {
|
|
67
|
+
"type": "string"
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"ignoreAuthors": {
|
|
71
|
+
"default": [],
|
|
72
|
+
"type": "array",
|
|
73
|
+
"items": {
|
|
74
|
+
"type": "string",
|
|
75
|
+
"minLength": 1
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
"reportLimit": {
|
|
79
|
+
"default": 5,
|
|
80
|
+
"type": "integer",
|
|
81
|
+
"minimum": 1,
|
|
82
|
+
"maximum": 9007199254740991
|
|
83
|
+
},
|
|
84
|
+
"label": {
|
|
85
|
+
"type": "string",
|
|
86
|
+
"minLength": 1,
|
|
87
|
+
"maxLength": 40,
|
|
88
|
+
"pattern": "^[^\\r\\n]+$"
|
|
89
|
+
},
|
|
90
|
+
"lane": {
|
|
91
|
+
"type": "string",
|
|
92
|
+
"minLength": 1
|
|
93
|
+
},
|
|
94
|
+
"ticket": {
|
|
95
|
+
"type": "boolean"
|
|
96
|
+
},
|
|
97
|
+
"tone": {
|
|
98
|
+
"type": "string",
|
|
99
|
+
"enum": [
|
|
100
|
+
"amber",
|
|
101
|
+
"teal",
|
|
102
|
+
"grey",
|
|
103
|
+
"red",
|
|
104
|
+
"green",
|
|
105
|
+
"blue",
|
|
106
|
+
"magenta"
|
|
107
|
+
]
|
|
108
|
+
},
|
|
109
|
+
"reportOn": {
|
|
110
|
+
"minItems": 1,
|
|
111
|
+
"maxItems": 10,
|
|
112
|
+
"type": "array",
|
|
113
|
+
"items": {
|
|
114
|
+
"type": "string",
|
|
115
|
+
"minLength": 1
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
"secrets": {
|
|
119
|
+
"maxItems": 10,
|
|
120
|
+
"type": "array",
|
|
121
|
+
"items": {
|
|
122
|
+
"type": "string",
|
|
123
|
+
"pattern": "^[A-Z][A-Z0-9_]*$"
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
"show": {
|
|
127
|
+
"type": "string",
|
|
128
|
+
"const": "entries"
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
"required": [
|
|
132
|
+
"name",
|
|
133
|
+
"command",
|
|
134
|
+
"map"
|
|
135
|
+
],
|
|
136
|
+
"additionalProperties": false
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
"additionalProperties": false
|
|
141
|
+
}
|
|
@@ -16,7 +16,10 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
|
|
|
16
16
|
'.claude/skills/devops/SKILL.md': 321,
|
|
17
17
|
'.claude/skills/diagnose/SKILL.md': 1521,
|
|
18
18
|
'.claude/skills/diagnose/references/feedback-loops.md': 636,
|
|
19
|
-
|
|
19
|
+
// Personal sources: where the source lives, the secret step that keeps the value out of the agent, the personal
|
|
20
|
+
// file and its script path, what a reload prints (+295); skill-review: a personal source stores its secret and is
|
|
21
|
+
// dry-run by name after the entry is written, since a draft runs without the keychain (+103).
|
|
22
|
+
'.claude/skills/follow/SKILL.md': 1498,
|
|
20
23
|
'.claude/skills/grill/SKILL.md': 685,
|
|
21
24
|
'.claude/skills/implement/SKILL.md': 2190,
|
|
22
25
|
// P33: the paw in the header chip, panes by tab and title, the lane in the context line of a passed-on question.
|
|
@@ -27,7 +30,10 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
|
|
|
27
30
|
// Cross-check: measured in a repository whose stack nothing detects, every unset command renders as a note (+7).
|
|
28
31
|
// Ready phase: the `done` / `idle` row names the watcher's mandatory `ready:` line (+20).
|
|
29
32
|
// Drafts until the merge: § 5 step 5 opens a draft and readies it, Self-merge step 3 waits for the runs after it (+100).
|
|
30
|
-
|
|
33
|
+
// Ready round: a PR of the lane waiting for approval or merge holds only the commit; the self-merge command runs
|
|
34
|
+
// alone, so the guard can check it (+132, the longest rendering of the kit's renderings); skill-review: the commit
|
|
35
|
+
// of a PR awaiting approval stages only its files, a branch over a squash-merged PR moves with `--onto` (+51).
|
|
36
|
+
'.claude/skills/orchestrate/SKILL.md': 8413,
|
|
31
37
|
// skill-review: the copy is taken after a mirror, and rejecting together with a remark is a remark (+36).
|
|
32
38
|
// Cross-check: an adopted plan also gets short names and a no-review-gate line, which `jh check` asks of a new
|
|
33
39
|
// plan; the proof became a word diff, since a short name goes into an existing line (+74); skill-review: the
|
|
@@ -55,8 +61,9 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
|
|
|
55
61
|
// Any language: step 3 points to proposing the missing commands from the CI; the proposal itself is a reference.
|
|
56
62
|
'.claude/skills/start/SKILL.md': 630,
|
|
57
63
|
'.claude/skills/uncle-bob/SKILL.md': 466,
|
|
58
|
-
// P33 and open up: grows with the skill above
|
|
59
|
-
'
|
|
64
|
+
// P33 and open up: grows with the skill above (+132 for the ready round and the lone merge call); measured with
|
|
65
|
+
// this repository's supplement after the skill-review fixes (+153).
|
|
66
|
+
'.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 9165,
|
|
60
67
|
'.claude/agents/devops.md': 104,
|
|
61
68
|
'.claude/agents/explore.md': 132,
|
|
62
69
|
'.claude/agents/implement.md': 303,
|
package/src/cli.ts
CHANGED
|
@@ -28,6 +28,7 @@ export const COMMANDS: CommandTable = {
|
|
|
28
28
|
mermaid: () => import('./mermaid/command'),
|
|
29
29
|
plan: () => import('./plan-contract/command'),
|
|
30
30
|
preview: () => import('./preview/command'),
|
|
31
|
+
secret: () => import('./secrets/command'),
|
|
31
32
|
settled: () => import('./settled/command'),
|
|
32
33
|
sync: () => import('./sync/command'),
|
|
33
34
|
task: () => import('./task-signals/command'),
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
-
import { jackherdConfigSchema } from './schema'
|
|
2
|
+
import { jackherdConfigSchema, personalSourcesSchema } from './schema'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
`${JSON.stringify(z.toJSONSchema(
|
|
4
|
+
const renderJsonSchema = (schema: z.ZodType): string =>
|
|
5
|
+
`${JSON.stringify(z.toJSONSchema(schema, { io: 'input' }), null, 2)}\n`
|
|
6
|
+
|
|
7
|
+
export const renderConfigJsonSchema = (): string => renderJsonSchema(jackherdConfigSchema)
|
|
8
|
+
|
|
9
|
+
export const renderSourcesJsonSchema = (): string => renderJsonSchema(personalSourcesSchema)
|
package/src/config/location.ts
CHANGED
|
@@ -50,3 +50,10 @@ export const findProjectRoot = (start: string, home: string, exists: (path: stri
|
|
|
50
50
|
*/
|
|
51
51
|
export const locateConfigFile = (roots: string[], home: string, exists: (path: string) => boolean): string | null =>
|
|
52
52
|
[...roots, home].flatMap(configCandidates).find(exists) ?? null
|
|
53
|
+
|
|
54
|
+
export const PERSONAL_SOURCES_FILE = 'sources.json'
|
|
55
|
+
|
|
56
|
+
/** The directory a personal source runs in, and holds the personal sources file. */
|
|
57
|
+
export const personalSourcesDirectory = (home: string): string => join(home, TOOL_DIRECTORY)
|
|
58
|
+
|
|
59
|
+
export const personalSourcesPath = (home: string): string => join(personalSourcesDirectory(home), PERSONAL_SOURCES_FILE)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { errorMessage } from '../sync/error-message'
|
|
4
|
+
import { SOURCE_ORIGIN, type SourceOrigin, type SourceSettings } from '../watch/sources'
|
|
5
|
+
import { personalSourcesPath } from './location'
|
|
6
|
+
import { readResolvedConfig } from './read-config'
|
|
7
|
+
import { parsePersonalSourcesFile } from './schema'
|
|
8
|
+
|
|
9
|
+
export type MergedSources = { sources: SourceSettings[]; notes: string[] }
|
|
10
|
+
|
|
11
|
+
/** What a note starts with when an invalid personal file was left out. */
|
|
12
|
+
export const PERSONAL_IGNORED = 'personal sources ignored:'
|
|
13
|
+
|
|
14
|
+
const skippedPrefix = (name: string): string => `personal source ${name} in `
|
|
15
|
+
|
|
16
|
+
const skippedNote = (name: string, path: string): string =>
|
|
17
|
+
`${skippedPrefix(name)}${path} skipped: the repository has a source of that name`
|
|
18
|
+
|
|
19
|
+
/** Whether a merge note concerns the source `name`: the personal source of that name was skipped, or the file was ignored. */
|
|
20
|
+
export const isNoteAbout = (note: string, name: string): boolean =>
|
|
21
|
+
note.startsWith(skippedPrefix(name)) || note.startsWith(PERSONAL_IGNORED)
|
|
22
|
+
|
|
23
|
+
const withOrigin = (sources: readonly SourceSettings[], origin: SourceOrigin): SourceSettings[] =>
|
|
24
|
+
sources.map((source) => ({ ...source, origin }))
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The repository's sources, then the personal ones, each marked with its origin. A personal source whose name a
|
|
28
|
+
* repository source has is left out and named in a note; `path` is how the notes spell the personal file.
|
|
29
|
+
*/
|
|
30
|
+
export const mergedSources = (
|
|
31
|
+
repo: readonly SourceSettings[],
|
|
32
|
+
personal: readonly SourceSettings[],
|
|
33
|
+
path: string,
|
|
34
|
+
): MergedSources => {
|
|
35
|
+
const repoNames = new Set(repo.map(({ name }) => name))
|
|
36
|
+
const kept = personal.filter(({ name }) => !repoNames.has(name))
|
|
37
|
+
const skipped = personal.filter(({ name }) => repoNames.has(name))
|
|
38
|
+
return {
|
|
39
|
+
sources: [...withOrigin(repo, SOURCE_ORIGIN.repo), ...withOrigin(kept, SOURCE_ORIGIN.personal)],
|
|
40
|
+
notes: skipped.map(({ name }) => skippedNote(name, path)),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The personal sources of `~/.jackherd/sources.json`, none when the file is missing; an invalid file throws with its path and the issues. */
|
|
45
|
+
export const readPersonalSources = (
|
|
46
|
+
home: string = homedir(),
|
|
47
|
+
exists: (path: string) => boolean = existsSync,
|
|
48
|
+
readText: (path: string) => string = (path) => readFileSync(path, 'utf8'),
|
|
49
|
+
): SourceSettings[] => {
|
|
50
|
+
const path = personalSourcesPath(home)
|
|
51
|
+
if (!exists(path)) return []
|
|
52
|
+
let raw: unknown
|
|
53
|
+
try {
|
|
54
|
+
raw = JSON.parse(readText(path))
|
|
55
|
+
} catch (error) {
|
|
56
|
+
throw new Error(`${path} is invalid: ${errorMessage(error)}`)
|
|
57
|
+
}
|
|
58
|
+
return parsePersonalSourcesFile(raw, path).sources
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The repository's sources with the personal ones of `home` merged in (`mergedSources`). An invalid personal file
|
|
63
|
+
* never stops the caller: its error becomes a note and the repository's sources stand alone.
|
|
64
|
+
*/
|
|
65
|
+
export const sourcesWithPersonal = (
|
|
66
|
+
repo: readonly SourceSettings[],
|
|
67
|
+
home: string = homedir(),
|
|
68
|
+
exists: (path: string) => boolean = existsSync,
|
|
69
|
+
readText?: (path: string) => string,
|
|
70
|
+
): MergedSources => {
|
|
71
|
+
try {
|
|
72
|
+
return mergedSources(repo, readPersonalSources(home, exists, readText), personalSourcesPath(home))
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return { sources: withOrigin(repo, SOURCE_ORIGIN.repo), notes: [`${PERSONAL_IGNORED} ${errorMessage(error)}`] }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The sources of the project config of the first of `roots` that has one (`readResolvedConfig`), with the personal
|
|
80
|
+
* ones of `home` merged in; throws like `readResolvedConfig` when the project config is missing or invalid.
|
|
81
|
+
*/
|
|
82
|
+
export const configuredSources = (roots: string[], home: string = homedir()): MergedSources =>
|
|
83
|
+
sourcesWithPersonal(readResolvedConfig(roots, home).config.watch.sources, home)
|
package/src/config/schema.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { checkConfigSchema } from '../check/schema'
|
|
3
3
|
import { claudePath, WORKTREES_DIRECTORY } from '../claude/paths'
|
|
4
|
+
import { SECRET_NAME } from '../secrets/options'
|
|
4
5
|
import { PLAN_LANGUAGE, RESPONSE_ADDRESS } from '../vocabulary'
|
|
5
6
|
import { CONTENT_FIELDS, SOURCE_FIELD } from '../watch/source-fields'
|
|
6
7
|
import { isSourcePath } from '../watch/source-path'
|
|
8
|
+
import { SOURCE_SHOW } from '../watch/sources'
|
|
7
9
|
import { BUN_FAILED_TEST_PATTERN, CLEAN_REVIEW_TEXT } from '../watch/watch-defaults'
|
|
8
10
|
import { MAX_AUTO_WINDOW_DAYS, windowDays } from './auto-window'
|
|
9
11
|
import { DEFAULT_BASE_BRANCH } from './base-branch'
|
|
@@ -289,6 +291,17 @@ const sourceSchema = z
|
|
|
289
291
|
* `author`, `body` and `url`; without it a change of that content reports.
|
|
290
292
|
*/
|
|
291
293
|
reportOn: z.array(z.string().min(1)).min(1).max(10).optional(),
|
|
294
|
+
/**
|
|
295
|
+
* Names of secrets the command needs, handed to it as environment variables of the same name; each is looked up
|
|
296
|
+
* in the keychain, then in the watcher's environment.
|
|
297
|
+
*/
|
|
298
|
+
secrets: z
|
|
299
|
+
.array(z.string().regex(SECRET_NAME, 'capital letters, digits and `_`, starting with a letter'))
|
|
300
|
+
.max(10)
|
|
301
|
+
.refine((names) => new Set(names).size === names.length, 'secret names must be unique')
|
|
302
|
+
.optional(),
|
|
303
|
+
/** `entries` shows the source's entries open, in a herd block of its own; not for a ticket source, whose entries are badges. */
|
|
304
|
+
show: z.literal(SOURCE_SHOW.entries).optional(),
|
|
292
305
|
})
|
|
293
306
|
.refine(canChange, {
|
|
294
307
|
message: 'map needs updatedAt, a content field (title, author, body, url) or reportOn',
|
|
@@ -298,6 +311,10 @@ const sourceSchema = z
|
|
|
298
311
|
message: 'ignoreAuthors needs map.author',
|
|
299
312
|
path: ['ignoreAuthors'],
|
|
300
313
|
})
|
|
314
|
+
.refine((source) => source.show === undefined || source.ticket !== true, {
|
|
315
|
+
message: 'show cannot be set on a ticket source',
|
|
316
|
+
path: ['show'],
|
|
317
|
+
})
|
|
301
318
|
.refine((source) => !source.reportOn?.includes(SOURCE_FIELD.id), {
|
|
302
319
|
message: 'reportOn cannot name id',
|
|
303
320
|
path: ['reportOn'],
|
|
@@ -592,6 +609,18 @@ const parseLabelled = <T extends z.ZodType>(schema: T, raw: unknown, label: stri
|
|
|
592
609
|
export const parseConfigFile = (raw: unknown, label: string): JackherdConfig =>
|
|
593
610
|
parseLabelled(jackherdConfigSchema, raw, label)
|
|
594
611
|
|
|
612
|
+
/** The personal sources file (`~/.jackherd/sources.json`): the same entries as `watch.sources`. */
|
|
613
|
+
export const personalSourcesSchema = z.strictObject({
|
|
614
|
+
$schema: z.string().optional(),
|
|
615
|
+
sources: z.array(sourceSchema).refine(hasUniqueNames, 'source names must be unique').default([]),
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
export type PersonalSourcesFile = z.infer<typeof personalSourcesSchema>
|
|
619
|
+
|
|
620
|
+
/** The personal sources file as read from JSON; invalid throws with `label` (its path) and the issues in words. */
|
|
621
|
+
export const parsePersonalSourcesFile = (raw: unknown, label: string): PersonalSourcesFile =>
|
|
622
|
+
parseLabelled(personalSourcesSchema, raw, label)
|
|
623
|
+
|
|
595
624
|
/** One `watch.sources` entry outside the config, such as a draft to try; invalid throws with `label` and the issues. */
|
|
596
625
|
export const parseSourceDraft = (raw: unknown, label: string): z.infer<typeof sourceSchema> =>
|
|
597
626
|
parseLabelled(sourceSchema, raw, label)
|
package/src/plans/herd-claim.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
-
import {
|
|
2
|
+
import { unlinkSync } from 'node:fs'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { ensurePrivateDirectory, writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
|
|
5
5
|
import {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
systemLocation,
|
|
10
10
|
userDirectoryOf,
|
|
11
11
|
} from '../shared/private-location'
|
|
12
|
+
import { systemProcessInfo } from '../shared/process-info'
|
|
12
13
|
import { HERD_COMMAND } from './herd-command'
|
|
13
14
|
import { CHILD_MARKER, TAKEN_OVER_CODE, TAKEOVER_SIGNAL } from './restart-decision'
|
|
14
15
|
|
|
@@ -96,36 +97,13 @@ export const startExclusive = async (
|
|
|
96
97
|
start()
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return target.startsWith('/dev/') ? target : null
|
|
103
|
-
} catch {
|
|
104
|
-
return null
|
|
105
|
-
}
|
|
106
|
-
}
|
|
100
|
+
/** True when the words of a process hold the herd child's marker and the herd command. */
|
|
101
|
+
export const isHerdViewWords = (words: readonly string[] | null): boolean =>
|
|
102
|
+
Boolean(words?.includes(`${CHILD_MARKER}=1`) && words.includes(HERD_COMMAND.name))
|
|
107
103
|
|
|
108
|
-
const
|
|
109
|
-
try {
|
|
110
|
-
return readFileSync(path, 'utf8')
|
|
111
|
-
} catch {
|
|
112
|
-
return null
|
|
113
|
-
}
|
|
114
|
-
}
|
|
104
|
+
const processInfo = systemProcessInfo()
|
|
115
105
|
|
|
116
|
-
const isHerdViewProcess = (pid: number): boolean =>
|
|
117
|
-
const environment = readText(`/proc/${pid}/environ`)
|
|
118
|
-
const command = readText(`/proc/${pid}/cmdline`)
|
|
119
|
-
if (environment === null || command === null) return false
|
|
120
|
-
return environment.split('\0').includes(`${CHILD_MARKER}=1`) && command.split('\0').includes(HERD_COMMAND.name)
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const isGoneProcess = (pid: number): boolean => {
|
|
124
|
-
const stat = readText(`/proc/${pid}/stat`)
|
|
125
|
-
if (stat === null) return true
|
|
126
|
-
const stateIndex = stat.lastIndexOf(')') + 2
|
|
127
|
-
return stat.charAt(stateIndex) === 'Z'
|
|
128
|
-
}
|
|
106
|
+
const isHerdViewProcess = (pid: number): boolean => isHerdViewWords(processInfo.wordsOf(pid))
|
|
129
107
|
|
|
130
108
|
const signalProcess = (pid: number, signal: NodeJS.Signals): void => {
|
|
131
109
|
try {
|
|
@@ -158,7 +136,7 @@ export const herdClaimDeps = (file: string): ClaimDeps => ({
|
|
|
158
136
|
}
|
|
159
137
|
},
|
|
160
138
|
isHerdView: isHerdViewProcess,
|
|
161
|
-
isGone:
|
|
139
|
+
isGone: processInfo.isGone,
|
|
162
140
|
signal: signalProcess,
|
|
163
141
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
164
142
|
onExit: (run) => {
|
|
@@ -168,6 +146,6 @@ export const herdClaimDeps = (file: string): ClaimDeps => ({
|
|
|
168
146
|
|
|
169
147
|
/** Takes the terminal of stdin over from an older herd view; without a terminal (or `/proc`) it does nothing. */
|
|
170
148
|
export const claimOwnTerminal = async (): Promise<void> => {
|
|
171
|
-
const terminal = terminalOfStdin()
|
|
149
|
+
const terminal = processInfo.terminalOfStdin()
|
|
172
150
|
if (terminal !== null) await claimTerminal(herdClaimDeps(recordFileOf(terminal)))
|
|
173
151
|
}
|