jackherd 1.16.0 → 1.18.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 +28 -0
- package/INSTALL.md +2 -1
- package/README.md +29 -33
- package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
- package/dist/config.d.ts +33 -1
- package/dist/config.js +57 -5
- package/dist/plan-format.d.ts +18 -6
- package/dist/plan-format.js +8 -1
- package/docs/plan-contract.md +1 -0
- package/herdr-plugin/herdr-plugin.toml +4 -1
- package/herdr-plugin/restore-plans.ts +60 -26
- package/herdr-plugin/resume.ts +18 -0
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +11 -0
- package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
- package/kit/hooks/hooks/approval-gate/index.ts +36 -7
- package/kit/hooks/hooks/approval-gate/run.ts +72 -16
- package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
- package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
- package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
- package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
- package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
- package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
- package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
- package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
- package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +423 -35
- package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
- package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
- package/kit/hooks/hooks/merge-guard/run.ts +110 -28
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
- package/kit/hooks/hooks/plain-question/form.ts +89 -0
- package/kit/hooks/hooks/plain-question/index.ts +44 -4
- package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
- package/kit/hooks/hooks/plain-question/run.ts +14 -7
- package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
- package/kit/hooks/hooks.json +11 -2
- package/kit/review/agents/skill-review.md +3 -2
- package/kit/review/skills/pre-commit/SKILL.md +16 -22
- package/kit/rules/rules/kit/code-style.md +26 -54
- package/kit/rules/rules/kit/pull-requests.md +17 -40
- package/kit/rules/rules/kit/testing.md +6 -12
- package/kit/rules/rules/kit/writing-for-agents.md +3 -0
- package/kit/workflow/skills/follow/SKILL.md +2 -3
- package/kit/workflow/skills/implement/SKILL.md +28 -43
- package/kit/workflow/skills/orchestrate/SKILL.md +201 -458
- package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
- package/kit/workflow/skills/orchestrate/references/approvals.md +23 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
- package/kit/workflow/skills/orchestrate/references/events.md +46 -1
- package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
- package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +10 -2
- package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
- package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
- package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
- package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
- package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
- package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
- package/kit/workflow/skills/plan/SKILL.md +29 -48
- package/package.json +2 -1
- package/recipes/README.md +1 -0
- package/recipes/jira-cli.md +1 -1
- package/recipes/sources/google-calendar-today.ts +8 -2
- package/recipes/sources/jira.ts +10 -2
- package/recipes/sources/linear.ts +9 -3
- package/recipes/sources/sentry.ts +8 -2
- package/recipes/sources.md +4 -2
- package/schemas/jackherd.schema.json +67 -0
- package/schemas/sources.schema.json +9 -0
- package/src/approval/checked.ts +60 -0
- package/src/approval/command.ts +16 -0
- package/src/check/command.ts +51 -6
- package/src/check/lint-pointers.ts +79 -0
- package/src/check/lint-shaped.ts +196 -0
- package/src/check/plan-structure-check.ts +116 -4
- package/src/check/report.ts +9 -3
- package/src/check/schema.ts +37 -0
- package/src/check/settings.ts +4 -0
- package/src/check/skill-budget-defaults.ts +90 -32
- package/src/check/skill-budgets.ts +96 -18
- package/src/cli.ts +2 -0
- package/src/config/personal-sources.ts +46 -10
- package/src/config/repository-identity.ts +40 -0
- package/src/config/schema.ts +53 -1
- package/src/context/command.ts +33 -2
- package/src/context/options.ts +33 -8
- package/src/context/usage-logs.ts +53 -0
- package/src/context/usage.ts +110 -0
- package/src/lane/base-command.ts +35 -0
- package/src/lane/base.ts +43 -0
- package/src/lane/close-check.ts +49 -15
- package/src/lane/close-command.ts +71 -11
- package/src/lane/close-options.ts +29 -8
- package/src/lane/close-survey.ts +13 -1
- package/src/lane/close.ts +51 -12
- package/src/lane/command.ts +21 -3
- package/src/lane/send-command.ts +32 -0
- package/src/lane/send-herdr.ts +77 -0
- package/src/lane/send-options.ts +23 -0
- package/src/lane/send.ts +90 -0
- package/src/lane/skill-hint.ts +27 -0
- package/src/lane/space-command.ts +30 -0
- package/src/lane/space.ts +49 -0
- package/src/lane/start-lane-hint.ts +18 -0
- package/src/plan-contract/base-check-git.ts +33 -0
- package/src/plan-contract/base-check.ts +17 -0
- package/src/plan-contract/command.ts +25 -4
- package/src/plan-format/plan-structure.ts +17 -5
- package/src/plans/ask-coordinator.ts +23 -13
- package/src/plans/board-rows.ts +9 -4
- package/src/plans/click-hint.ts +4 -0
- package/src/plans/command.ts +6 -5
- package/src/plans/ensure-panes.ts +14 -3
- package/src/plans/git.ts +14 -0
- package/src/plans/github.ts +19 -10
- package/src/plans/layout.ts +82 -33
- package/src/plans/live.ts +8 -12
- package/src/plans/plan-panes.ts +21 -4
- package/src/plans/selection.ts +29 -0
- package/src/plans/settings.ts +4 -0
- package/src/resume/command.ts +213 -0
- package/src/resume/decision.ts +102 -0
- package/src/resume/keeper.ts +65 -0
- package/src/resume/location.ts +22 -0
- package/src/resume/log.ts +27 -0
- package/src/resume/ports.ts +102 -0
- package/src/resume/record.ts +116 -0
- package/src/resume/targets.ts +84 -0
- package/src/resume/texts.ts +27 -0
- package/src/secrets/command.ts +15 -5
- package/src/sync/herdr-plugin.ts +2 -11
- package/src/sync/plan-ask-rules.ts +11 -0
- package/src/sync/render.ts +3 -1
- package/src/vocabulary/index.ts +13 -0
- package/src/waiting/command.ts +13 -4
- package/src/watch/command.ts +177 -78
- package/src/watch/config-reload.ts +119 -0
- package/src/watch/lane-model.ts +11 -1
- package/src/watch/lane-progress.ts +70 -8
- package/src/watch/lane-report.ts +66 -11
- package/src/watch/main-checkout.ts +5 -1
- package/src/watch/merge-duties.ts +33 -0
- package/src/watch/merge-overlap-read.ts +69 -0
- package/src/watch/plan-views.ts +16 -4
- package/src/watch/reference-paths.ts +46 -5
- package/src/watch/report-questions.ts +5 -2
- package/src/watch/single-report.ts +3 -2
- package/src/watch/source-dry-run-command.ts +22 -4
- package/src/watch/source-reason.ts +58 -0
- package/src/watch/source-reload.ts +21 -17
- package/src/watch/source-run.ts +24 -7
- package/src/watch/sources.ts +9 -2
- package/src/watch/texts.ts +14 -3
- package/src/watch/waiting-line.ts +4 -3
|
@@ -9,7 +9,8 @@ export const SINGLE_REPORT_NOTICE =
|
|
|
9
9
|
export const NO_MONITOR_NOTICE =
|
|
10
10
|
'watch ended after this report. This session has no Monitor tool, so a single call is the way it works here: start it again as a background Bash call (run_in_background) after you acted on this report.'
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/** The report with its closing notice; without a Monitor tool the notice carries `hint`, the reference that explains the restart. */
|
|
13
|
+
export const withSingleReportNotice = (lines: readonly string[], monitorAvailable: boolean, hint = ''): string[] => [
|
|
13
14
|
...lines,
|
|
14
|
-
monitorAvailable ? SINGLE_REPORT_NOTICE : NO_MONITOR_NOTICE
|
|
15
|
+
monitorAvailable ? SINGLE_REPORT_NOTICE : `${NO_MONITOR_NOTICE}${hint}`,
|
|
15
16
|
]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { homedir } from 'node:os'
|
|
2
2
|
import { personalSourcesPath } from '../config/location'
|
|
3
|
-
import { configuredSources, isNoteAbout } from '../config/personal-sources'
|
|
3
|
+
import { configuredSources, type ExcludedSource, excludedNote, isNoteAbout } from '../config/personal-sources'
|
|
4
|
+
import { readRepositoryIdentity } from '../config/repository-identity'
|
|
4
5
|
import { parseSourceDraft } from '../config/schema'
|
|
5
6
|
import { mainCheckout, worktreeRoot } from '../plans/git'
|
|
6
7
|
import type { SourceDryRun } from './options'
|
|
@@ -18,18 +19,35 @@ const settingsOf = async (target: DryRunTarget, sources: readonly SourceSettings
|
|
|
18
19
|
return parseSourceDraft(JSON.parse(text), target.kind === 'stdin' ? 'stdin' : target.path)
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
/** Exit code of a dry run of a source that is not for this repository: nothing ran. */
|
|
23
|
+
export const NOT_FOR_THIS_REPO_EXIT = 2
|
|
24
|
+
|
|
25
|
+
/** The note of the personal source the dry run names when its `repos` leave this repository out; null for any other target. */
|
|
26
|
+
export const excludedNoteFor = (target: DryRunTarget, excluded: readonly ExcludedSource[]): string | null => {
|
|
27
|
+
if (target.kind !== 'name') return null
|
|
28
|
+
const found = excluded.find(({ source }) => source.name === target.name)
|
|
29
|
+
return found ? excludedNote(found) : null
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
/**
|
|
22
33
|
* `jh watch source <name|draft.json|-> --dry-run [--raw]`: runs one source once in the main checkout (a personal one in `~/.jackherd`), as the
|
|
23
34
|
* watcher would, its `secrets` handed to the command alike, and prints what it found and would report; `--raw` adds
|
|
24
35
|
* the command's stdout, which by the recipe rules carries no secret value. Takes no lock and
|
|
25
36
|
* neither reads nor writes the watcher's state, so a running watcher is not disturbed. Exits 1 when the run failed or a
|
|
26
|
-
* `map` field is empty in every entry
|
|
37
|
+
* `map` field is empty in every entry, and 2, running nothing, when the named personal source is not for this
|
|
38
|
+
* repository (its `repos`).
|
|
27
39
|
*/
|
|
28
40
|
export const sourceDryRun = async ({ target, raw }: SourceDryRun): Promise<number> => {
|
|
29
41
|
const root = mainCheckout(process.cwd())
|
|
30
42
|
const home = homedir()
|
|
31
|
-
const merged = configuredSources([worktreeRoot(process.cwd()), root], home)
|
|
32
|
-
const
|
|
43
|
+
const merged = configuredSources([worktreeRoot(process.cwd()), root], readRepositoryIdentity(root), home)
|
|
44
|
+
const named = dryRunTarget(target)
|
|
45
|
+
const excluded = excludedNoteFor(named, merged.excluded)
|
|
46
|
+
if (excluded !== null) {
|
|
47
|
+
console.log(excluded)
|
|
48
|
+
return NOT_FOR_THIS_REPO_EXIT
|
|
49
|
+
}
|
|
50
|
+
const settings = await settingsOf(named, merged.sources)
|
|
33
51
|
const answer = await sourceAnswer(settings, root, SOURCE_LIMITS, home)
|
|
34
52
|
const run = sourceRunOf(settings, answer)
|
|
35
53
|
const mapCheck = answer.ok ? emptyMapFields(answer.stdout, settings) : null
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
|
|
3
|
+
/** The start of the one stdout line a failing source's command may give its reason in. */
|
|
4
|
+
export const SOURCE_REASON_PREFIX = 'jackherd-reason:'
|
|
5
|
+
|
|
6
|
+
export const SOURCE_REASON_MAX_LENGTH = 160
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A reason line past this length is dropped before any pattern reads it: a real reason is short, and the masking
|
|
10
|
+
* patterns backtrack on a long line from a command that passes outside text through.
|
|
11
|
+
*/
|
|
12
|
+
export const SOURCE_REASON_MAX_INPUT = 1024
|
|
13
|
+
|
|
14
|
+
const MASK = '***'
|
|
15
|
+
|
|
16
|
+
/** The whole authority up to its last `@`, which WHATWG URLs split the credentials at. */
|
|
17
|
+
const URL_CREDENTIALS = /(?<![a-z0-9+.-])([a-z][a-z0-9+.-]*:\/\/)[^\s/?#]*@/gi
|
|
18
|
+
|
|
19
|
+
const QUERY_VALUE = /([?&#][^\s=&#?]+=)[^\s&#]*/g
|
|
20
|
+
|
|
21
|
+
const lastReasonLine = (stdout: string): string | null => {
|
|
22
|
+
const lines = stdout.split('\n').filter((line) => line.trimStart().startsWith(SOURCE_REASON_PREFIX))
|
|
23
|
+
const last = lines.at(-1)
|
|
24
|
+
return last === undefined ? null : last.trimStart().slice(SOURCE_REASON_PREFIX.length)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A value as stored and as a script may print it: without control characters, without surrounding blanks. */
|
|
28
|
+
const printedFormsOf = (value: string): string[] => [
|
|
29
|
+
value,
|
|
30
|
+
value.trim(),
|
|
31
|
+
terminalSafe(value),
|
|
32
|
+
terminalSafe(value).trim(),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
const withoutSecrets = (text: string, secretValues: readonly string[]): string =>
|
|
36
|
+
[...new Set(secretValues.flatMap(printedFormsOf))]
|
|
37
|
+
.filter((value) => value.length > 0)
|
|
38
|
+
.sort((a, b) => b.length - a.length)
|
|
39
|
+
.reduce((masked, value) => masked.replaceAll(value, MASK), text)
|
|
40
|
+
|
|
41
|
+
const withoutUrlAccess = (text: string): string =>
|
|
42
|
+
text.replace(URL_CREDENTIALS, `$1${MASK}@`).replace(QUERY_VALUE, `$1${MASK}`)
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The reason a failing source's command gives on stdout: the last line starting with `SOURCE_REASON_PREFIX`, with
|
|
46
|
+
* every value of `secretValues` (the source's own `secrets`), credentials and query values of URLs masked and
|
|
47
|
+
* control characters dropped, then cut to `SOURCE_REASON_MAX_LENGTH`. Secrets are masked before and after the control
|
|
48
|
+
* characters go, so one split by them is found too. A token the source took from elsewhere is not recognised; the
|
|
49
|
+
* recipes' rule keeps it out of the line. Null without such a line, with nothing in it, or with one longer than
|
|
50
|
+
* `SOURCE_REASON_MAX_INPUT`.
|
|
51
|
+
*/
|
|
52
|
+
export const sourceReason = (stdout: string, secretValues: readonly string[]): string | null => {
|
|
53
|
+
const line = lastReasonLine(stdout)
|
|
54
|
+
if (line === null || line.length > SOURCE_REASON_MAX_INPUT) return null
|
|
55
|
+
const cleaned = withoutUrlAccess(withoutSecrets(terminalSafe(withoutSecrets(line, secretValues)), secretValues))
|
|
56
|
+
const reason = cleaned.trim()
|
|
57
|
+
return reason.length > 0 ? ellipsize(reason, SOURCE_REASON_MAX_LENGTH) : null
|
|
58
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { statSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import {
|
|
3
|
+
import { PROJECT_CONFIG_PATH, personalSourcesPath } from '../config/location'
|
|
4
4
|
import { mergedSources, readPersonalSources } from '../config/personal-sources'
|
|
5
|
-
import { readResolvedConfig } from '../config/read-config'
|
|
6
5
|
import { errorText } from '../plans/error-text'
|
|
7
6
|
import type { SourceSettings } from './sources'
|
|
8
7
|
|
|
@@ -16,7 +15,7 @@ export type LoadedSources = { sources: SourceSettings[]; stamps: FileStamps }
|
|
|
16
15
|
export type SourceFileReader = { path: string; read: () => readonly SourceSettings[] }
|
|
17
16
|
|
|
18
17
|
/** A file that could not be read, as the line the watcher prints. */
|
|
19
|
-
type ReadFailure = { ok: false; line: string }
|
|
18
|
+
export type ReadFailure = { ok: false; line: string }
|
|
20
19
|
|
|
21
20
|
/** A reading of both files: the merged sources with the notes of the skipped ones, or the line naming the file that failed. */
|
|
22
21
|
export type SourceRead = { ok: true; sources: SourceSettings[]; notes: string[] } | ReadFailure
|
|
@@ -30,7 +29,7 @@ const sameStamps = (previous: FileStamps, current: FileStamps): boolean => {
|
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
/** The error of `path` on one line, without the `<path> is invalid:` the config readers put before the issues. */
|
|
33
|
-
const reasonOf = (path: string, error: unknown): string =>
|
|
32
|
+
export const reasonOf = (path: string, error: unknown): string =>
|
|
34
33
|
errorText(error).replace(`${path} is invalid:`, '').replace(/\s+/g, ' ').trim()
|
|
35
34
|
|
|
36
35
|
const readOne = (reader: SourceFileReader): { ok: true; sources: readonly SourceSettings[] } | ReadFailure => {
|
|
@@ -41,13 +40,20 @@ const readOne = (reader: SourceFileReader): { ok: true; sources: readonly Source
|
|
|
41
40
|
}
|
|
42
41
|
}
|
|
43
42
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Reads the repository's sources, then the personal ones, and merges them for the repository `identity`
|
|
45
|
+
* (`mergedSources`); the first file that fails is named.
|
|
46
|
+
*/
|
|
47
|
+
export const readSourceFiles = (
|
|
48
|
+
repo: SourceFileReader,
|
|
49
|
+
personal: SourceFileReader,
|
|
50
|
+
identity: string | null,
|
|
51
|
+
): SourceRead => {
|
|
46
52
|
const repoRead = readOne(repo)
|
|
47
53
|
if (!repoRead.ok) return repoRead
|
|
48
54
|
const personalRead = readOne(personal)
|
|
49
55
|
if (!personalRead.ok) return personalRead
|
|
50
|
-
return { ok: true, ...mergedSources(repoRead.sources, personalRead.sources, personal.path) }
|
|
56
|
+
return { ok: true, ...mergedSources(repoRead.sources, personalRead.sources, personal.path, identity) }
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
/**
|
|
@@ -85,16 +91,14 @@ export const readFileStamps = (paths: readonly string[]): FileStamps =>
|
|
|
85
91
|
Object.fromEntries(paths.map((path) => [path, changeTimeOf(path)]))
|
|
86
92
|
|
|
87
93
|
/**
|
|
88
|
-
* Reads the sources again: `
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
94
|
+
* Reads the sources again: the repository's (`repository`, the config as `readWatchedConfig` read it) and those of the
|
|
95
|
+
* personal file of `home` that are for the repository `identity`. Unlike the start (`sourcesWithPersonal`), where an invalid personal file leaves the
|
|
96
|
+
* repository's sources alone, here it keeps the list the watcher runs: both keep what ran before, which at the start
|
|
97
|
+
* is no personal source.
|
|
92
98
|
*/
|
|
93
|
-
export const readWatchedSources = (
|
|
99
|
+
export const readWatchedSources = (repository: SourceFileReader, home: string, identity: string | null): SourceRead =>
|
|
94
100
|
readSourceFiles(
|
|
95
|
-
|
|
96
|
-
path: locateConfigFile(roots, home, existsSync) ?? join(roots[0] ?? home, PROJECT_CONFIG_PATH),
|
|
97
|
-
read: () => readResolvedConfig(roots, home).config.watch.sources,
|
|
98
|
-
},
|
|
101
|
+
repository,
|
|
99
102
|
{ path: personalSourcesPath(home), read: (): SourceSettings[] => readPersonalSources(home) },
|
|
103
|
+
identity,
|
|
100
104
|
)
|
package/src/watch/source-run.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { personalSourcesDirectory } from '../config/location'
|
|
|
3
3
|
import { type Environment, sourceEnvironment } from '../secrets/environment'
|
|
4
4
|
import { SECRET_LOOKUP, SECRET_TIMEOUT_MS, type SecretLookupFn, secretStore } from '../secrets/store'
|
|
5
5
|
import { resolveSourceCommand } from './source-command'
|
|
6
|
+
import { sourceReason } from './source-reason'
|
|
6
7
|
import {
|
|
7
8
|
exitFailure,
|
|
8
9
|
parseSourceOutput,
|
|
@@ -27,8 +28,10 @@ const TOO_LARGE_ANSWER: SourceAnswer = { ok: false, reason: SOURCE_FAILURE.tooLa
|
|
|
27
28
|
const UNREADABLE_ANSWER: SourceAnswer = { ok: false, reason: SOURCE_FAILURE.unreadable }
|
|
28
29
|
const READ_FAILED = Symbol('read failed')
|
|
29
30
|
|
|
30
|
-
const answerOf = (stdout: string, exitCode: number): SourceAnswer =>
|
|
31
|
-
exitCode === 0
|
|
31
|
+
const answerOf = (stdout: string, exitCode: number, secretValues: readonly string[]): SourceAnswer =>
|
|
32
|
+
exitCode === 0
|
|
33
|
+
? { ok: true, stdout }
|
|
34
|
+
: { ok: false, reason: exitFailure(exitCode, sourceReason(stdout, secretValues)) }
|
|
32
35
|
|
|
33
36
|
/** The run a command's answer makes: its output over `items` and `map`, or the reason it failed. */
|
|
34
37
|
export const sourceRunOf = (settings: SourceSettings, answer: SourceAnswer): SourceRun => ({
|
|
@@ -76,7 +79,8 @@ const killGroup = (child: Child) => {
|
|
|
76
79
|
/**
|
|
77
80
|
* Runs `command` as given, without a shell, in `cwd` with `env` (the user's environment without it); never rejects.
|
|
78
81
|
* `env` reaches the command only: it is neither an argument nor printed. stderr is not read,
|
|
79
|
-
* so nothing a failing tool prints there (a token, a URL with credentials) reaches the report.
|
|
82
|
+
* so nothing a failing tool prints there (a token, a URL with credentials) reaches the report. A non-zero exit keeps
|
|
83
|
+
* stdout's `jackherd-reason:` line only, cleaned of `secretValues` (`sourceReason`). Past `timeoutMs` or
|
|
80
84
|
* `maxBytes` the command's process group is killed, and the answer never waits past `timeoutMs` for a pipe a leftover
|
|
81
85
|
* process keeps open; the group is also killed after a normal end, so a source leaves no process behind that stays in its group.
|
|
82
86
|
*/
|
|
@@ -85,6 +89,7 @@ export const runSourceCommand = async (
|
|
|
85
89
|
cwd: string,
|
|
86
90
|
limits: SourceLimits,
|
|
87
91
|
env?: Environment,
|
|
92
|
+
secretValues: readonly string[] = [],
|
|
88
93
|
): Promise<SourceAnswer> => {
|
|
89
94
|
const child = spawned(command, cwd, env)
|
|
90
95
|
if (!child) return { ok: false, reason: SOURCE_FAILURE.notStarted }
|
|
@@ -99,7 +104,7 @@ export const runSourceCommand = async (
|
|
|
99
104
|
if (stdout === null) return TOO_LARGE_ANSWER
|
|
100
105
|
if (stdout === READ_FAILED) return UNREADABLE_ANSWER
|
|
101
106
|
const exitCode = await Promise.race([child.exited, deadline])
|
|
102
|
-
return exitCode === TIMED_OUT ? TIMED_OUT_ANSWER : answerOf(stdout, exitCode)
|
|
107
|
+
return exitCode === TIMED_OUT ? TIMED_OUT_ANSWER : answerOf(stdout, exitCode, secretValues)
|
|
103
108
|
} finally {
|
|
104
109
|
clearTimeout(timer)
|
|
105
110
|
killGroup(child)
|
|
@@ -126,10 +131,14 @@ const noKeychain: SecretLookupFn = async () => ({ kind: SECRET_LOOKUP.absent })
|
|
|
126
131
|
export const secretLookupFor = (settings: Pick<SourceSettings, 'origin'>, keychain: SecretLookupFn): SecretLookupFn =>
|
|
127
132
|
settings.origin === SOURCE_ORIGIN.personal ? keychain : noKeychain
|
|
128
133
|
|
|
134
|
+
/** The values of the run's own `secrets`, which its reason must not show. */
|
|
135
|
+
const secretValuesOf = (names: readonly string[], environment: Environment): string[] =>
|
|
136
|
+
names.flatMap((name) => environment[name] ?? [])
|
|
137
|
+
|
|
129
138
|
/**
|
|
130
139
|
* The answer of a source's command in `sourceDirectory`, its `secrets` resolved first (`sourceEnvironment` over
|
|
131
140
|
* `secretLookupFor`, looked up afresh on each run and dropped after it). A secret that cannot be resolved fails the run
|
|
132
|
-
* before the command starts.
|
|
141
|
+
* before the command starts. The values also clean the reason of a failed run.
|
|
133
142
|
*/
|
|
134
143
|
export const sourceAnswer = async (
|
|
135
144
|
settings: SourceSettings,
|
|
@@ -138,9 +147,17 @@ export const sourceAnswer = async (
|
|
|
138
147
|
home: string = homedir(),
|
|
139
148
|
lookup: SecretLookupFn = keychainLookup(limits),
|
|
140
149
|
): Promise<SourceAnswer> => {
|
|
141
|
-
const
|
|
150
|
+
const names = settings.secrets ?? []
|
|
151
|
+
const environment = await sourceEnvironment(names, secretLookupFor(settings, lookup), process.env)
|
|
142
152
|
if (!environment.ok) return environment
|
|
143
|
-
|
|
153
|
+
const directory = sourceDirectory(settings, root, home)
|
|
154
|
+
return runSourceCommand(
|
|
155
|
+
settings.command,
|
|
156
|
+
directory,
|
|
157
|
+
limits,
|
|
158
|
+
environment.environment,
|
|
159
|
+
secretValuesOf(names, environment.environment),
|
|
160
|
+
)
|
|
144
161
|
}
|
|
145
162
|
|
|
146
163
|
/** One run of a source in `sourceDirectory`: its secrets and command (`sourceAnswer`), then its output over `items` and `map`. */
|
package/src/watch/sources.ts
CHANGED
|
@@ -12,7 +12,10 @@ export const SOURCE_CHANGE = { new: 'new', updated: 'updated' } as const
|
|
|
12
12
|
|
|
13
13
|
export type SourceChange = (typeof SOURCE_CHANGE)[keyof typeof SOURCE_CHANGE]
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Why a run of a source failed, as a class only: never its stderr, which may carry a token. `exit <code>` and its
|
|
17
|
+
* reason come from `exitFailure`.
|
|
18
|
+
*/
|
|
16
19
|
export const SOURCE_FAILURE = {
|
|
17
20
|
timeout: 'timeout',
|
|
18
21
|
tooLarge: 'output too large',
|
|
@@ -69,6 +72,8 @@ export type SourceSettings = {
|
|
|
69
72
|
show?: SourceShow
|
|
70
73
|
/** Where the source is defined; without it the repository's config. */
|
|
71
74
|
origin?: SourceOrigin
|
|
75
|
+
/** `owner/name` of the repositories a personal source runs in; without it every repository. Never in the repository's config. */
|
|
76
|
+
repos?: readonly string[]
|
|
72
77
|
/** Shown before the source's rows in the herd; without it its name. */
|
|
73
78
|
label?: string
|
|
74
79
|
/** The tone of its mark in the herd while its newest report is new. */
|
|
@@ -177,7 +182,9 @@ const STORED_PREFIX_LENGTH = 180
|
|
|
177
182
|
const VALUE_HASH_LENGTH = 8
|
|
178
183
|
const MINUTE_MS = 60_000
|
|
179
184
|
|
|
180
|
-
|
|
185
|
+
/** `exit <code>`, with the cleaned reason of the command's `jackherd-reason:` line (`sourceReason`) when it gave one. */
|
|
186
|
+
export const exitFailure = (code: number, reason: string | null = null): string =>
|
|
187
|
+
reason ? `exit ${code}: ${reason}` : `exit ${code}`
|
|
181
188
|
|
|
182
189
|
export const emptySourceState = (): SourceState => ({})
|
|
183
190
|
|
package/src/watch/texts.ts
CHANGED
|
@@ -18,7 +18,12 @@ export type WatchTexts = {
|
|
|
18
18
|
/** The prefix of a merge's duty line, with the pull request number. */
|
|
19
19
|
afterMerge: (number: number) => string
|
|
20
20
|
mergedLaneDuty: string
|
|
21
|
+
mergedWithOpenBoxes: (phase: string, lines: readonly number[]) => string
|
|
21
22
|
otherLaneDuty: string
|
|
23
|
+
/** After the rebase of a lane the merge reached: every check, those of the named roots (`a, b`), or none. */
|
|
24
|
+
mergeChecksAll: string
|
|
25
|
+
mergeChecksRoots: (roots: string) => string
|
|
26
|
+
mergeChecksNone: string
|
|
22
27
|
blockedDuty: string
|
|
23
28
|
/** What a lane whose context reached `alert` asks of the coordinator. */
|
|
24
29
|
laneAlertDuty: string
|
|
@@ -46,6 +51,7 @@ export type WatchTexts = {
|
|
|
46
51
|
|
|
47
52
|
/** The mark the approval gate hook reads from the question, as its `requestMark` writes it (contract test). */
|
|
48
53
|
const approvalMark = (id: string, hash: string): string => `\`approval ${id} ${hash}\``
|
|
54
|
+
const renameCommand = (name: string): string => `herdr agent rename "$HERDR_PANE_ID" ${name}`
|
|
49
55
|
|
|
50
56
|
/**
|
|
51
57
|
* The watcher's words: English whatever the project's languages. Only the two option labels in the approval duty follow
|
|
@@ -63,11 +69,16 @@ export const watchTexts = (dialogLanguage: PlanLanguage): WatchTexts => ({
|
|
|
63
69
|
noPlan: 'no plan',
|
|
64
70
|
noName: 'no name',
|
|
65
71
|
noValue: 'no value',
|
|
66
|
-
unnamedPane: (name) => `pane without a name,
|
|
67
|
-
noAgentNamed: (name) => `no agent named ${name}`,
|
|
72
|
+
unnamedPane: (name) => `pane without a name, ${renameCommand(name)}`,
|
|
73
|
+
noAgentNamed: (name) => `no agent named ${name}, ${renameCommand(name)}`,
|
|
68
74
|
afterMerge: (number) => `after merge #${number}`,
|
|
69
75
|
mergedLaneDuty: 'start the next phase or close the lane',
|
|
76
|
+
mergedWithOpenBoxes: (phase, lines) =>
|
|
77
|
+
`${phase} merged with ${lines.length} open ${lines.length === 1 ? 'box' : 'boxes'} at ${lines.join(', ')}`,
|
|
70
78
|
otherLaneDuty: 'rebase, then start the next phase or name the reason to wait',
|
|
79
|
+
mergeChecksAll: 'checks: all',
|
|
80
|
+
mergeChecksRoots: (roots) => `checks: ${roots}`,
|
|
81
|
+
mergeChecksNone: 'checks: none (no overlap)',
|
|
71
82
|
blockedDuty:
|
|
72
83
|
'vet the question against the plan and the rules, answer it in the lane or ask the user in a dialog; never hand the pane over',
|
|
73
84
|
laneAlertDuty: 'request the hand-over at the next phase boundary (§ 8)',
|
|
@@ -86,5 +97,5 @@ export const watchTexts = (dialogLanguage: PlanLanguage): WatchTexts => ({
|
|
|
86
97
|
checkoutRows: (term, count) => `${term}: ${count} ${count === 1 ? 'row' : 'rows'} only in the checkout`,
|
|
87
98
|
otherPullRequests: 'other PRs',
|
|
88
99
|
approvalDuty: (id, hash) =>
|
|
89
|
-
`ask through AskUserQuestion, the question contains ${approvalMark(id, hash)} (hash as in the file shown), options "${APPROVAL_TEXTS[dialogLanguage].allow}" and "${APPROVAL_TEXTS[dialogLanguage].deny}" (§ 4)`,
|
|
100
|
+
`show the request file in a code block, then ask through AskUserQuestion, the question contains ${approvalMark(id, hash)} (hash as in the file shown), options "${APPROVAL_TEXTS[dialogLanguage].allow}" and "${APPROVAL_TEXTS[dialogLanguage].deny}" (§ 4)`,
|
|
90
101
|
})
|
|
@@ -9,11 +9,12 @@ const itemText = ({ item }: WaitingItem): string => ellipsize(terminalSafe(item)
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* The line that brings back what waits on the user's answer, in every heartbeat and status until the item is closed:
|
|
12
|
-
* `for you (<n>): <item>; …`, three items and `+<n>` for the rest. Null when no item is open.
|
|
12
|
+
* `for you (<n>): <item>; …`, three items and `+<n>` for the rest. Null when no item is open. The heartbeat passes no
|
|
13
|
+
* `hint`, so it stays short; the status passes the one of the reference that holds the handling.
|
|
13
14
|
*/
|
|
14
|
-
export const waitingLine = (items: readonly WaitingItem[]): string | null => {
|
|
15
|
+
export const waitingLine = (items: readonly WaitingItem[], hint = ''): string | null => {
|
|
15
16
|
const open = items.filter(({ open: isOpen }) => isOpen)
|
|
16
17
|
if (open.length === 0) return null
|
|
17
18
|
const { shown, more } = shownWithMore(open, SHOWN_ITEMS)
|
|
18
|
-
return `for you (${open.length}): ${[...shown.map(itemText), ...(more > 0 ? [`+${more}`] : [])].join('; ')}`
|
|
19
|
+
return `for you (${open.length}): ${[...shown.map(itemText), ...(more > 0 ? [`+${more}`] : [])].join('; ')}${hint}`
|
|
19
20
|
}
|