jackherd 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/INSTALL.md +1 -1
- package/README.md +50 -32
- package/dist/{chunk-MCP4XJQH.js → chunk-5MQBCUQ5.js} +9 -4
- package/dist/config.d.ts +39 -4
- package/dist/config.js +67 -26
- package/dist/plan-format.d.ts +45 -6
- package/dist/plan-format.js +46 -6
- package/docs/workflow-dark.svg +67 -63
- package/docs/workflow.svg +67 -63
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +15 -0
- package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
- package/kit/hooks/hooks/ask-hint/index.ts +15 -19
- package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
- package/kit/hooks/hooks/git-guard/decision.ts +50 -102
- package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
- package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
- package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
- package/kit/hooks/hooks/merge-guard/index.ts +6 -3
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
- package/kit/hooks/hooks/merge-guard/run.ts +59 -54
- package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
- package/kit/hooks/hooks/open-question/index.ts +26 -0
- package/kit/hooks/hooks/open-question/record.ts +135 -0
- package/kit/hooks/hooks/open-question/run.ts +52 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -0
- package/kit/hooks/hooks/sent-file/store.ts +3 -12
- package/kit/hooks/hooks/shared/config.ts +7 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
- package/kit/hooks/hooks/shared/files.ts +7 -0
- package/kit/hooks/hooks/shared/lane.ts +1 -1
- package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
- package/kit/hooks/hooks/shared/private-directory.ts +56 -9
- package/kit/hooks/hooks/shared/program-position.ts +2 -75
- package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
- package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
- package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
- package/kit/hooks/hooks/task-hint/decision.ts +49 -0
- package/kit/hooks/hooks/task-hint/index.ts +79 -0
- package/kit/hooks/hooks/task-hint/run.ts +62 -0
- package/kit/hooks/hooks/task-hint/store.ts +51 -0
- package/kit/hooks/hooks.json +13 -5
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +1 -8
- package/kit/workflow/skills/implement/SKILL.md +8 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +210 -433
- package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
- package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
- package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
- package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
- package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
- package/kit/workflow/skills/orchestrate/references/events.md +86 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
- package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
- package/kit/workflow/skills/plan/SKILL.md +6 -5
- package/kit/workflow/skills/plan/references/template-English.md +4 -4
- package/kit/workflow/skills/plan/references/template-German.md +4 -4
- package/package.json +3 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +1 -1
- package/schemas/jackherd.schema.json +59 -5
- package/src/check/agent-text-reviewers.ts +23 -0
- package/src/check/command.ts +2 -0
- package/src/check/plan-structure-check.ts +49 -2
- package/src/check/plan.ts +8 -1
- package/src/check/schema.ts +9 -0
- package/src/check/skill-budget-defaults.ts +55 -0
- package/src/check/skill-budgets.ts +185 -0
- package/src/cli.ts +1 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +58 -24
- package/src/lane/close-board.ts +106 -0
- package/src/lane/close-check.ts +183 -0
- package/src/lane/close-command-line.ts +11 -0
- package/src/lane/close-command.ts +217 -0
- package/src/lane/close-options.ts +43 -0
- package/src/lane/close-survey.ts +83 -0
- package/src/lane/close.ts +141 -0
- package/src/lane/command.ts +13 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +87 -0
- package/src/lane-prompt/input.ts +116 -0
- package/src/lane-prompt/options.ts +68 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/plan-format/clock.ts +8 -0
- package/src/plan-format/dependencies.ts +7 -4
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/waiting.ts +7 -2
- package/src/plans/appointment-rows.ts +59 -0
- package/src/plans/ask-coordinator.ts +77 -0
- package/src/plans/ask-target.ts +25 -0
- package/src/plans/child-stop.ts +51 -0
- package/src/plans/closable-records.ts +34 -0
- package/src/plans/command.ts +3 -2
- package/src/plans/fetch.ts +26 -3
- package/src/plans/git.ts +19 -5
- package/src/plans/herd-claim.ts +173 -0
- package/src/plans/herd-memory.ts +36 -25
- package/src/plans/lane-plans.ts +44 -13
- package/src/plans/lane-summary.ts +253 -45
- package/src/plans/layout-phase.ts +29 -7
- package/src/plans/layout-summary.ts +89 -18
- package/src/plans/layout.ts +262 -68
- package/src/plans/link-chips.ts +1 -0
- package/src/plans/live.ts +107 -20
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-source.ts +15 -0
- package/src/plans/plan-cache.ts +28 -0
- package/src/plans/plan-heads.ts +54 -0
- package/src/plans/pull-requests.ts +17 -2
- package/src/plans/question-records.ts +48 -0
- package/src/plans/questions-read.ts +13 -0
- package/src/plans/read-cache.ts +8 -0
- package/src/plans/record-id.ts +4 -0
- package/src/plans/restart-decision.ts +13 -4
- package/src/plans/selection.ts +98 -18
- package/src/plans/sent-files-read.ts +6 -15
- package/src/plans/sent-files.ts +7 -14
- package/src/plans/settings.ts +2 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +12 -4
- package/src/plans/snapshot.ts +85 -62
- package/src/plans/sprint-export.ts +11 -11
- package/src/plans/sprint-page.ts +5 -5
- package/src/plans/subagent-phases.ts +95 -0
- package/src/plans/subagent-tasks.ts +51 -0
- package/src/plans/subagents-read.ts +66 -17
- package/src/plans/subagents.ts +64 -6
- package/src/plans/supervisor.ts +14 -4
- package/src/plans/symbols.ts +3 -0
- package/src/plans/task-name.ts +34 -0
- package/src/plans/texts.ts +23 -6
- package/src/plans/view.ts +17 -9
- package/src/plans/waiting-click.ts +192 -37
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +25 -3
- package/src/shared/private-location.ts +89 -0
- package/src/shared/stat-cache.ts +48 -0
- package/src/sync/kit-source.ts +29 -10
- package/src/sync/kit.ts +2 -0
- package/src/sync/manifest.ts +2 -0
- package/src/sync/plan-ask-rules.ts +94 -0
- package/src/sync/plan-hooks.ts +2 -0
- package/src/sync/render.ts +3 -3
- package/src/sync/settings-hooks.ts +4 -1
- package/src/sync/sync-plan.ts +1 -0
- package/src/sync/sync.ts +24 -3
- package/src/task-signals/claude-read.ts +27 -10
- package/src/terminal/text.ts +8 -3
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +16 -6
- package/src/waiting/command.ts +22 -7
- package/src/watch/appointments.ts +1 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +330 -105
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-progress.ts +40 -8
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +32 -4
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +13 -7
- package/src/watch/pull-requests.ts +6 -2
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/running-subagents.ts +43 -0
- package/src/watch/skill-change.ts +124 -32
- package/src/watch/source-change.ts +48 -0
- package/src/watch/sources.ts +6 -3
- package/src/watch/stream-output.ts +4 -3
- package/src/watch/task-parallel-auto.ts +208 -0
- package/src/watch/task-parallel-data-paths.ts +46 -0
- package/src/watch/task-parallel-input.ts +5 -15
- package/src/watch/task-parallel-read.ts +3 -1
- package/src/watch/task-parallel.ts +36 -7
- package/src/watch/task-signal-missing.ts +69 -0
- package/src/watch/task-signal-read.ts +29 -0
- package/src/watch/texts.ts +10 -0
- package/src/watch/update-available.ts +6 -3
- package/src/watch/watch-events.ts +23 -0
- package/src/watch/watcher-lock.ts +25 -29
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
- package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
- package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
|
@@ -1,13 +1,40 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
+
import { ensurePrivateDirectory, writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
|
|
3
4
|
import { errorText } from '../plans/error-text'
|
|
4
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
changedSections,
|
|
7
|
+
orchestrateChangeLines,
|
|
8
|
+
REFERENCE_CHANGE,
|
|
9
|
+
type ReferenceChange,
|
|
10
|
+
type ReferenceDelta,
|
|
11
|
+
referenceChanges,
|
|
12
|
+
SKILL_CHANGE,
|
|
13
|
+
type SkillChange,
|
|
14
|
+
} from '../version/skill-diff'
|
|
5
15
|
import type { StartedKit } from '../version/started-kit'
|
|
6
|
-
import {
|
|
16
|
+
import { ORCHESTRATE_SKILL_NAME } from './reference-paths'
|
|
17
|
+
|
|
18
|
+
/** The orchestrate skill as the kit ships it: its text and its reference files by name. */
|
|
19
|
+
export type OrchestrateSnapshot = { skill: string; references: Record<string, string> }
|
|
20
|
+
|
|
21
|
+
/** The change since a snapshot: the skill itself, and each reference that changed, is new or is gone. */
|
|
22
|
+
export type OrchestrateChange = { skill: SkillChange; references: ReferenceChange[] }
|
|
23
|
+
|
|
24
|
+
type WrittenDiff = { text: string; path: string }
|
|
25
|
+
|
|
26
|
+
/** A reference name that stays a plain Markdown file name, since it goes into the name of a diff file. */
|
|
27
|
+
const REFERENCE_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.md$/
|
|
28
|
+
|
|
29
|
+
const skillDirectory = (packageRoot: string): string =>
|
|
30
|
+
join(packageRoot, 'kit', 'workflow', 'skills', ORCHESTRATE_SKILL_NAME)
|
|
7
31
|
|
|
8
32
|
/** The kit's own text of the orchestrate skill, with its placeholders, inside the package. */
|
|
9
|
-
export const orchestrateSkillPath = (packageRoot: string): string =>
|
|
10
|
-
|
|
33
|
+
export const orchestrateSkillPath = (packageRoot: string): string => join(skillDirectory(packageRoot), 'SKILL.md')
|
|
34
|
+
|
|
35
|
+
/** The directory of the skill's reference files in the kit at `packageRoot`. */
|
|
36
|
+
export const orchestrateReferencesPath = (packageRoot: string): string =>
|
|
37
|
+
join(skillDirectory(packageRoot), 'references')
|
|
11
38
|
|
|
12
39
|
/** The orchestrate skill's text in the kit at `packageRoot`; null when it cannot be read. */
|
|
13
40
|
export const readOrchestrateSkill = (packageRoot: string): string | null => {
|
|
@@ -18,10 +45,45 @@ export const readOrchestrateSkill = (packageRoot: string): string | null => {
|
|
|
18
45
|
}
|
|
19
46
|
}
|
|
20
47
|
|
|
48
|
+
/** The skill's reference files by name (every `*.md` file below `references/`); none when the directory is missing. */
|
|
49
|
+
export const readOrchestrateReferences = (packageRoot: string): Record<string, string> => {
|
|
50
|
+
const directory = orchestrateReferencesPath(packageRoot)
|
|
51
|
+
try {
|
|
52
|
+
const names = readdirSync(directory, { withFileTypes: true })
|
|
53
|
+
.filter((entry) => entry.isFile() && REFERENCE_FILE.test(entry.name))
|
|
54
|
+
.map((entry) => entry.name)
|
|
55
|
+
return Object.fromEntries(names.map((name) => [name, readFileSync(join(directory, name), 'utf8')]))
|
|
56
|
+
} catch {
|
|
57
|
+
return {}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The skill and its references in the kit at `packageRoot`; null when the skill cannot be read. */
|
|
62
|
+
export const readOrchestrateSnapshot = (packageRoot: string): OrchestrateSnapshot | null => {
|
|
63
|
+
const skill = readOrchestrateSkill(packageRoot)
|
|
64
|
+
return skill === null ? null : { skill, references: readOrchestrateReferences(packageRoot) }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The unified diff (`git diff --no-index`) between `before` and the file at `currentPath`, written to `directory` as
|
|
69
|
+
* `<name>.diff`; null when git has none to give.
|
|
70
|
+
*/
|
|
71
|
+
const writeDiff = (before: string, currentPath: string, directory: string, name: string): WrittenDiff | null => {
|
|
72
|
+
ensurePrivateDirectory(directory)
|
|
73
|
+
const beforePath = join(directory, `${name}.before.md`)
|
|
74
|
+
writePrivateFile(beforePath, before)
|
|
75
|
+
const diff = Bun.spawnSync(['git', 'diff', '--no-index', '--unified=3', '--', beforePath, currentPath])
|
|
76
|
+
const text = diff.stdout.toString()
|
|
77
|
+
if (diff.exitCode !== 1 || text === '') return null
|
|
78
|
+
const path = join(directory, `${name}.diff`)
|
|
79
|
+
writePrivateFile(path, text)
|
|
80
|
+
return { text, path }
|
|
81
|
+
}
|
|
82
|
+
|
|
21
83
|
/**
|
|
22
84
|
* The change of the orchestrate skill between `snapshot` and the kit at `packageRoot` now: the unified diff
|
|
23
|
-
*
|
|
24
|
-
*
|
|
85
|
+
* written to `directory` and the sections it touches, so the coordinator reads the change instead of the whole
|
|
86
|
+
* skill. Unavailable without a snapshot, a readable skill or a diff.
|
|
25
87
|
*/
|
|
26
88
|
export const skillChangeSince = (snapshot: string | null, packageRoot: string, directory: string): SkillChange => {
|
|
27
89
|
if (snapshot === null) return { kind: SKILL_CHANGE.unavailable, reason: 'no snapshot at start' }
|
|
@@ -29,33 +91,63 @@ export const skillChangeSince = (snapshot: string | null, packageRoot: string, d
|
|
|
29
91
|
if (current === null) return { kind: SKILL_CHANGE.unavailable, reason: 'skill not readable' }
|
|
30
92
|
if (current === snapshot) return { kind: SKILL_CHANGE.unchanged }
|
|
31
93
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const diff = Bun.spawnSync([
|
|
36
|
-
'git',
|
|
37
|
-
'diff',
|
|
38
|
-
'--no-index',
|
|
39
|
-
'--unified=3',
|
|
40
|
-
'--',
|
|
41
|
-
before,
|
|
42
|
-
orchestrateSkillPath(packageRoot),
|
|
43
|
-
])
|
|
44
|
-
const text = diff.stdout.toString()
|
|
45
|
-
if (diff.exitCode !== 1 || text === '')
|
|
46
|
-
return { kind: SKILL_CHANGE.unavailable, reason: `git diff exit ${diff.exitCode}` }
|
|
47
|
-
const diffPath = join(directory, 'orchestrate-skill.diff')
|
|
48
|
-
writePrivate(diffPath, text)
|
|
49
|
-
return { kind: SKILL_CHANGE.changed, sections: changedSections(current, text), diffPath }
|
|
94
|
+
const diff = writeDiff(snapshot, orchestrateSkillPath(packageRoot), directory, `${ORCHESTRATE_SKILL_NAME}-skill`)
|
|
95
|
+
if (diff === null) return { kind: SKILL_CHANGE.unavailable, reason: 'git diff gave none' }
|
|
96
|
+
return { kind: SKILL_CHANGE.changed, sections: changedSections(current, diff.text), diffPath: diff.path }
|
|
50
97
|
} catch (error) {
|
|
51
98
|
return { kind: SKILL_CHANGE.unavailable, reason: errorText(error) }
|
|
52
99
|
}
|
|
53
100
|
}
|
|
54
101
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
)
|
|
102
|
+
const referenceChangeOf = (
|
|
103
|
+
delta: ReferenceDelta,
|
|
104
|
+
snapshot: OrchestrateSnapshot,
|
|
105
|
+
packageRoot: string,
|
|
106
|
+
directory: string,
|
|
107
|
+
): ReferenceChange => {
|
|
108
|
+
const path = join(orchestrateReferencesPath(packageRoot), delta.name)
|
|
109
|
+
if (delta.kind === REFERENCE_CHANGE.deleted) return { name: delta.name, kind: REFERENCE_CHANGE.deleted }
|
|
110
|
+
if (delta.kind === REFERENCE_CHANGE.added) return { name: delta.name, kind: REFERENCE_CHANGE.added, path }
|
|
111
|
+
try {
|
|
112
|
+
const diff = writeDiff(
|
|
113
|
+
snapshot.references[delta.name] ?? '',
|
|
114
|
+
path,
|
|
115
|
+
directory,
|
|
116
|
+
`${ORCHESTRATE_SKILL_NAME}-reference-${delta.name}`,
|
|
117
|
+
)
|
|
118
|
+
return { name: delta.name, kind: REFERENCE_CHANGE.changed, diffPath: diff?.path ?? null }
|
|
119
|
+
} catch {
|
|
120
|
+
return { name: delta.name, kind: REFERENCE_CHANGE.changed, diffPath: null }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The change of the skill and of its references since `snapshot`. The references are judged only while the skill
|
|
126
|
+
* can be read, so a package being replaced does not make every reference look deleted.
|
|
127
|
+
*/
|
|
128
|
+
export const orchestrateChangeSince = (
|
|
129
|
+
snapshot: OrchestrateSnapshot | null,
|
|
130
|
+
packageRoot: string,
|
|
131
|
+
directory: string,
|
|
132
|
+
): OrchestrateChange => {
|
|
133
|
+
const skill = skillChangeSince(snapshot?.skill ?? null, packageRoot, directory)
|
|
134
|
+
if (snapshot === null || skill.kind === SKILL_CHANGE.unavailable) return { skill, references: [] }
|
|
135
|
+
const deltas = referenceChanges(snapshot.references, readOrchestrateReferences(packageRoot))
|
|
136
|
+
return { skill, references: deltas.map((delta) => referenceChangeOf(delta, snapshot, packageRoot, directory)) }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The lines about the orchestrate skill after a kit update, for the kit the watcher started with: the skill's, then one
|
|
141
|
+
* per changed, new or deleted reference, joined by line breaks.
|
|
142
|
+
*/
|
|
143
|
+
export const skillLineFor = (
|
|
144
|
+
kit: StartedKit | null,
|
|
145
|
+
snapshot: OrchestrateSnapshot | null,
|
|
146
|
+
directory: string,
|
|
147
|
+
): string => {
|
|
148
|
+
if (kit === null) {
|
|
149
|
+
return orchestrateChangeLines({ kind: SKILL_CHANGE.unavailable, reason: 'kit not located' }, []).join('\n')
|
|
150
|
+
}
|
|
151
|
+
const { skill, references } = orchestrateChangeSince(snapshot, kit.packageRoot, directory)
|
|
152
|
+
return orchestrateChangeLines(skill, references).join('\n')
|
|
153
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { existsSync, type FSWatcher, watch } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { isRestartTrigger, RESTART_DEBOUNCE_MS } from '../plans/restart-decision'
|
|
4
|
+
|
|
5
|
+
/** The changes to a package's `src/` seen so far: when the last one came (null before any), and the watch to close. */
|
|
6
|
+
export type SourceChanges = { lastChangeAt: () => number | null; close: () => void }
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whether the package at `packageRoot` is a checkout's own source rather than an installed copy, which lies under a
|
|
10
|
+
* `node_modules` directory. Only the source changes under a running process (a pull or a fast-forward of the
|
|
11
|
+
* checkout); an installed copy changes by a reinstall, which its version shows.
|
|
12
|
+
*/
|
|
13
|
+
export const runsFromSource = (packageRoot: string): boolean => !packageRoot.split(/[\\/]/).includes('node_modules')
|
|
14
|
+
|
|
15
|
+
/** Whether a change seen at `lastChangeAt` is over: no further change for the window in which the herd collapses a burst into one restart. */
|
|
16
|
+
export const sourceChangeSettled = (lastChangeAt: number | null, now: number): boolean =>
|
|
17
|
+
lastChangeAt !== null && now - lastChangeAt >= RESTART_DEBOUNCE_MS
|
|
18
|
+
|
|
19
|
+
const NO_CHANGES: SourceChanges = { lastChangeAt: () => null, close: () => {} }
|
|
20
|
+
|
|
21
|
+
/** The watch of `directory`, closed on a later error (`EMFILE` while dev servers run), which would else end the process. */
|
|
22
|
+
const watcherOf = (directory: string, onChange: () => void): FSWatcher | null => {
|
|
23
|
+
try {
|
|
24
|
+
const watcher = watch(directory, { recursive: true }, (_event, name) => {
|
|
25
|
+
if (name && isRestartTrigger(name.toString())) onChange()
|
|
26
|
+
})
|
|
27
|
+
watcher.on('error', () => watcher.close())
|
|
28
|
+
return watcher
|
|
29
|
+
} catch {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Follows the source files below `<packageRoot>/src` with the trigger the herd restarts on (`isRestartTrigger`: a
|
|
36
|
+
* `.ts` file, tests and declarations left out), stamping each change with `clock`. A package without `src/`, or one
|
|
37
|
+
* the platform cannot watch, is followed for nothing.
|
|
38
|
+
*/
|
|
39
|
+
export const watchSourceChanges = (packageRoot: string, clock: () => number = Date.now): SourceChanges => {
|
|
40
|
+
const directory = join(packageRoot, 'src')
|
|
41
|
+
if (!existsSync(directory)) return NO_CHANGES
|
|
42
|
+
let lastChangeAt: number | null = null
|
|
43
|
+
const watcher = watcherOf(directory, () => {
|
|
44
|
+
lastChangeAt = clock()
|
|
45
|
+
})
|
|
46
|
+
if (!watcher) return NO_CHANGES
|
|
47
|
+
return { lastChangeAt: () => lastChangeAt, close: () => watcher.close() }
|
|
48
|
+
}
|
package/src/watch/sources.ts
CHANGED
|
@@ -94,6 +94,8 @@ export type TicketRecord = { url: string | null; status: string | null; changedA
|
|
|
94
94
|
export type SourceRecord = {
|
|
95
95
|
seen: SeenEntry[] | null
|
|
96
96
|
failingSince: number | null
|
|
97
|
+
/** When its last run started, failed or not (`isSourceDue`); absent in a state written before it was kept: never. */
|
|
98
|
+
ranAt?: number
|
|
97
99
|
/** Of a ticket source only: the entries of its last successful run by id. */
|
|
98
100
|
tickets?: Record<string, TicketRecord>
|
|
99
101
|
}
|
|
@@ -248,7 +250,7 @@ export const parseSourceOutput = (
|
|
|
248
250
|
return { ok: true, items: uniqueById(items as SourceItem[]) }
|
|
249
251
|
}
|
|
250
252
|
|
|
251
|
-
/** Whether a source last started at `lastRunAt` (undefined: never
|
|
253
|
+
/** Whether a source last started at `lastRunAt` (`SourceRecord.ranAt`; undefined: never) is due again at `now`. */
|
|
252
254
|
export const isSourceDue = (lastRunAt: number | undefined, everyMinutes: number, now: number): boolean =>
|
|
253
255
|
lastRunAt === undefined || now - lastRunAt >= everyMinutes * MINUTE_MS
|
|
254
256
|
|
|
@@ -342,8 +344,8 @@ const seenAfter = (seen: readonly SeenEntry[], items: readonly SourceItem[]): Se
|
|
|
342
344
|
|
|
343
345
|
const recordOf = (record: SourceRecord | undefined, result: SourceResult, now: number): SourceRecord =>
|
|
344
346
|
result.ok
|
|
345
|
-
? { seen: seenAfter(record?.seen ?? [], result.items), failingSince: null }
|
|
346
|
-
: { seen: record?.seen ?? null, failingSince: record?.failingSince ?? now }
|
|
347
|
+
? { seen: seenAfter(record?.seen ?? [], result.items), failingSince: null, ranAt: now }
|
|
348
|
+
: { seen: record?.seen ?? null, failingSince: record?.failingSince ?? now, ranAt: now }
|
|
347
349
|
|
|
348
350
|
/**
|
|
349
351
|
* The state after `runs` for the sources configured as `names`: a run records its outcome, a source without a run keeps
|
|
@@ -385,6 +387,7 @@ const isSourceRecord = (value: unknown): boolean =>
|
|
|
385
387
|
isRecord(value) &&
|
|
386
388
|
(value.seen === null || (Array.isArray(value.seen) && value.seen.every(isSeenEntry))) &&
|
|
387
389
|
(value.failingSince === null || typeof value.failingSince === 'number') &&
|
|
390
|
+
(value.ranAt === undefined || typeof value.ranAt === 'number') &&
|
|
388
391
|
(value.tickets === undefined || (isRecord(value.tickets) && Object.values(value.tickets).every(isTicketRecord)))
|
|
389
392
|
|
|
390
393
|
export const isSourceState = (value: unknown): value is SourceState =>
|
|
@@ -17,7 +17,8 @@ const laneState = (lane: string, current: LaneSnapshot, notes: LaneNotes, rests:
|
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* The heartbeat of a stream, as one line, since every line of a stream reaches the coordinator as a
|
|
20
|
-
* message of its own: `=== heartbeat <time> · <lane> <status> · …`.
|
|
20
|
+
* message of its own: `=== heartbeat <time> · <lane> <status> · …`. `standing` are the lines that stay until
|
|
21
|
+
* their cause is gone (what waits on the user, the main checkout behind its base), each one entry at the end.
|
|
21
22
|
*/
|
|
22
23
|
export const streamHeartbeatLine = (
|
|
23
24
|
time: string,
|
|
@@ -25,12 +26,12 @@ export const streamHeartbeatLine = (
|
|
|
25
26
|
current: LaneSnapshot,
|
|
26
27
|
notes: LaneNotes,
|
|
27
28
|
rests: LaneNotes = {},
|
|
28
|
-
|
|
29
|
+
standing: readonly string[] = [],
|
|
29
30
|
): string => {
|
|
30
31
|
const states = lanes.map((lane) => laneState(lane, current, notes, rests))
|
|
31
32
|
return [
|
|
32
33
|
`=== ${WATCH_EVENT.heartbeat} ${terminalSafe(time)}`,
|
|
33
34
|
...(states.length > 0 ? states : ['no lanes']),
|
|
34
|
-
...(
|
|
35
|
+
...standing.map((line) => terminalSafe(line).replaceAll(SEPARATOR, ' / ')),
|
|
35
36
|
].join(SEPARATOR)
|
|
36
37
|
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { ALWAYS_ASK_PATHS, ENV_PATHS, MIGRATION_PATHS } from '../../kit/hooks/hooks/merge-guard/always-ask'
|
|
2
|
+
import { MAX_AUTO_WINDOW_DAYS, windowDays } from '../config/auto-window'
|
|
3
|
+
import type { JackherdConfig } from '../config/schema'
|
|
4
|
+
import { twoDigits } from '../plan-format/clock'
|
|
5
|
+
import {
|
|
6
|
+
AUTO_VERDICT,
|
|
7
|
+
type AutoVerdict,
|
|
8
|
+
GLOB_CHARACTER,
|
|
9
|
+
pathsOfTask,
|
|
10
|
+
type TaskParallelInput,
|
|
11
|
+
type TaskParallelProposal,
|
|
12
|
+
} from './task-parallel'
|
|
13
|
+
|
|
14
|
+
/** The window of `watch.parallelTasks.auto`: two `YYYY-MM-DD` days, both included. */
|
|
15
|
+
export type AutoWindow = NonNullable<JackherdConfig['watch']['parallelTasks']['auto']>
|
|
16
|
+
|
|
17
|
+
/** The switch, unset when the project has none, and every path a task may not name to start without a question. */
|
|
18
|
+
export type AutoSettings = {
|
|
19
|
+
auto?: AutoWindow
|
|
20
|
+
askPaths: readonly string[]
|
|
21
|
+
/** `watch.parallelTasks.maxTokens` of the same config as the switch. */
|
|
22
|
+
maxTokens: number
|
|
23
|
+
/** Whether the repository holds a path of `DATA_PATHS` at or below a directory, relative to its root. */
|
|
24
|
+
holdsDataPath: (directory: string) => boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const MAX_AUTO_EXTRA_AGENTS = 2
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The paths that always ask whose work takes effect before the merge: a migration may be pushed and a secret written
|
|
31
|
+
* while the pull request is open, so the merge guard comes too late. A directory of a writes set that holds one asks.
|
|
32
|
+
*/
|
|
33
|
+
export const DATA_PATHS: readonly string[] = [...MIGRATION_PATHS, ...ENV_PATHS]
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The paths that always ask (`ALWAYS_ASK_PATHS`, plus every package.json and CHANGELOG.md, which the merge guard
|
|
37
|
+
* reads apart): work on them is tier C and never starts without a question. The project's `guard.alwaysAsk` come on top.
|
|
38
|
+
*/
|
|
39
|
+
export const AUTO_ASK_PATHS: readonly string[] = [...ALWAYS_ASK_PATHS, '**/package.json', '**/CHANGELOG.md']
|
|
40
|
+
|
|
41
|
+
const PROBE = 'x'
|
|
42
|
+
/** Shorter literal pieces of a pattern (`**`, `*`) say nothing about a task's text. */
|
|
43
|
+
const MIN_FRAGMENT = 3
|
|
44
|
+
/**
|
|
45
|
+
* A path that may leave the repository or hide where it lies: absolute, from home, a variable, a drive, a backslash, or
|
|
46
|
+
* a `.` or `..` segment.
|
|
47
|
+
*/
|
|
48
|
+
const OUTSIDE_REPOSITORY = /^[/~]|^[A-Za-z]:|[\\%$]|(?:^|\/)\.{1,2}(?:\/|$)/
|
|
49
|
+
|
|
50
|
+
/** Whether a path may leave the repository or hide where it lies (`OUTSIDE_REPOSITORY`). */
|
|
51
|
+
export const leavesRepository = (path: string): boolean => OUTSIDE_REPOSITORY.test(path)
|
|
52
|
+
|
|
53
|
+
const hasFileName = (path: string): boolean => /\.[^/]*$/.test(path.slice(path.lastIndexOf('/') + 1))
|
|
54
|
+
|
|
55
|
+
/** A path up to the directory its first wildcard lies in; a path without one is itself. */
|
|
56
|
+
const literalPrefix = (path: string): string => {
|
|
57
|
+
const glob = path.search(GLOB_CHARACTER)
|
|
58
|
+
return glob === -1 ? path : path.slice(0, Math.max(path.lastIndexOf('/', glob), 0))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether one path lies inside the other or both are the same; `''` is the whole repository. */
|
|
62
|
+
const nested = (left: string, right: string): boolean =>
|
|
63
|
+
left === '' || right === '' || left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)
|
|
64
|
+
|
|
65
|
+
const matchesAnyPattern = (path: string, patterns: readonly string[]): boolean => {
|
|
66
|
+
const lower = path.toLowerCase()
|
|
67
|
+
const globs = patterns.map((pattern) => new Bun.Glob(pattern.toLowerCase()))
|
|
68
|
+
return globs.some((glob) => glob.match(lower) || glob.match(`${lower}/${PROBE}`))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether a path a task names may touch a path that always asks. Only a named file inside a directory of the
|
|
73
|
+
* repository is matched (in lower case, as the merge guard does): a path that may leave the repository, a directory,
|
|
74
|
+
* a glob or a bare file name could reach such a path somewhere, so it counts as one. A dotted name is also matched as
|
|
75
|
+
* a directory, so `.claude` counts like `.claude/**`.
|
|
76
|
+
*/
|
|
77
|
+
export const touchesAskPath = (path: string, patterns: readonly string[]): boolean => {
|
|
78
|
+
if (OUTSIDE_REPOSITORY.test(path) || !path.includes('/') || GLOB_CHARACTER.test(path) || !hasFileName(path))
|
|
79
|
+
return true
|
|
80
|
+
return matchesAnyPattern(path, patterns)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Whether a path relative to the repository root is, or lies in, one of `DATA_PATHS`. */
|
|
84
|
+
export const isDataPath = (path: string): boolean => matchesAnyPattern(path, DATA_PATHS)
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether an entry of a phase's writes set may touch a path that always asks: the whole repository, a path that may
|
|
88
|
+
* leave it, an entry a pattern matches as written (`packages/*\/package.json`), a directory or glob that overlaps a
|
|
89
|
+
* pattern anchored at the root (`.claude/**`), or one under whose literal part the repository holds a migration or an
|
|
90
|
+
* env file (`holdsDataPath`). Other patterns that hold anywhere (`**\/package.json`) are not looked for below a
|
|
91
|
+
* directory: their work waits for the merge guard.
|
|
92
|
+
*/
|
|
93
|
+
export const writesTouchAskPath = (
|
|
94
|
+
entry: string,
|
|
95
|
+
patterns: readonly string[],
|
|
96
|
+
holdsDataPath: (directory: string) => boolean,
|
|
97
|
+
): boolean => {
|
|
98
|
+
if (entry === '' || OUTSIDE_REPOSITORY.test(entry)) return true
|
|
99
|
+
if (matchesAnyPattern(entry, patterns)) return true
|
|
100
|
+
if (!GLOB_CHARACTER.test(entry) && hasFileName(entry)) return false
|
|
101
|
+
const literal = literalPrefix(entry.toLowerCase())
|
|
102
|
+
const rootAnchored = patterns.filter((pattern) => !pattern.startsWith('**'))
|
|
103
|
+
return (
|
|
104
|
+
rootAnchored.some((pattern) => nested(literal, literalPrefix(pattern.toLowerCase()))) ||
|
|
105
|
+
holdsDataPath(literalPrefix(entry))
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The literal pieces of the patterns (`.claude`, `package.json`, `migrations`), in lower case. */
|
|
110
|
+
const fragmentsOf = (patterns: readonly string[]): string[] => [
|
|
111
|
+
...new Set(
|
|
112
|
+
patterns.flatMap((pattern) =>
|
|
113
|
+
pattern
|
|
114
|
+
.toLowerCase()
|
|
115
|
+
.split(/[/*?[\]{}]+/)
|
|
116
|
+
.filter((piece) => piece.length >= MIN_FRAGMENT),
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
/** The local calendar day of `at`, as `YYYY-MM-DD`. */
|
|
122
|
+
export const localDay = (at: number): string => {
|
|
123
|
+
const date = new Date(at)
|
|
124
|
+
return `${date.getFullYear()}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const windowRefusal = (auto: AutoWindow, today: string): string | null => {
|
|
128
|
+
if (!(windowDays(auto.since, auto.until) <= MAX_AUTO_WINDOW_DAYS))
|
|
129
|
+
return `window longer than ${MAX_AUTO_WINDOW_DAYS} days`
|
|
130
|
+
if (today < auto.since) return `off until ${auto.since}`
|
|
131
|
+
if (today > auto.until) return `ended ${auto.until}`
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const extraAgentsAtOnce = (input: TaskParallelInput, proposal: TaskParallelProposal): number =>
|
|
136
|
+
Math.max(input.active.length - 1, 0) + proposal.extraAgents
|
|
137
|
+
|
|
138
|
+
const taskPathRefusal = (proposal: TaskParallelProposal, patterns: readonly string[]): string | null => {
|
|
139
|
+
const path = proposal.tasks.flatMap((task) => pathsOfTask(task.text)).find((entry) => touchesAskPath(entry, patterns))
|
|
140
|
+
return path === undefined ? null : `a task names ${path}, which always asks`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A task's text may name such a path without backticks or extension (`.husky/pre-commit`, `CODEOWNERS`). */
|
|
144
|
+
const taskTextRefusal = (proposal: TaskParallelProposal, patterns: readonly string[]): string | null => {
|
|
145
|
+
const fragments = fragmentsOf(patterns)
|
|
146
|
+
const fragment = proposal.tasks
|
|
147
|
+
.map((task) => task.text.toLowerCase())
|
|
148
|
+
.flatMap((text) => fragments.filter((piece) => text.includes(piece)))[0]
|
|
149
|
+
return fragment === undefined ? null : `a task mentions ${fragment}, which always asks`
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The task's text does not bind the subagent; its phase's writes set is what it may write, read from the plan at the
|
|
154
|
+
* base branch, so the lane's own copy widens nothing. A phase the base branch does not have asks.
|
|
155
|
+
*/
|
|
156
|
+
const phaseWritesRefusal = (input: TaskParallelInput, proposal: TaskParallelProposal, settings: AutoSettings) => {
|
|
157
|
+
for (const task of proposal.tasks) {
|
|
158
|
+
const writes = input.baseWrites[task.phase]
|
|
159
|
+
if (writes === undefined || writes.length === 0) return `phase ${task.phase} has no writes set on the base branch`
|
|
160
|
+
const entry = writes.find((path) => writesTouchAskPath(path, settings.askPaths, settings.holdsDataPath))
|
|
161
|
+
if (entry !== undefined)
|
|
162
|
+
return `phase ${task.phase} writes ${entry === '' ? 'the whole repository' : entry}, which always asks`
|
|
163
|
+
}
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const limitRefusal = (input: TaskParallelInput, proposal: TaskParallelProposal, settings: AutoSettings) => {
|
|
168
|
+
const { askPaths: patterns, maxTokens } = settings
|
|
169
|
+
if (input.active.length === 0) return 'no running work in the lane'
|
|
170
|
+
const atOnce = extraAgentsAtOnce(input, proposal)
|
|
171
|
+
if (atOnce > MAX_AUTO_EXTRA_AGENTS) return `${atOnce} extra agents at once, at most ${MAX_AUTO_EXTRA_AGENTS}`
|
|
172
|
+
if (proposal.estimateTokens >= Math.min(input.thresholdTokens, maxTokens)) return 'over the token limit'
|
|
173
|
+
return (
|
|
174
|
+
taskPathRefusal(proposal, patterns) ??
|
|
175
|
+
taskTextRefusal(proposal, patterns) ??
|
|
176
|
+
phaseWritesRefusal(input, proposal, settings)
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Whether the coordinator may start `proposal` without asking: the switch holds on `today`, the lane has running work
|
|
182
|
+
* (the extra agents are its subagents, never a new lane), at most two extra agents run at once with the subagents
|
|
183
|
+
* already running, the cost stays under the limit, and neither a task's paths, its text nor its phase's writes set
|
|
184
|
+
* reaches a path that always asks. Every doubt asks.
|
|
185
|
+
*/
|
|
186
|
+
export const autoVerdict = (
|
|
187
|
+
input: TaskParallelInput,
|
|
188
|
+
proposal: TaskParallelProposal,
|
|
189
|
+
settings: AutoSettings,
|
|
190
|
+
today: string,
|
|
191
|
+
): AutoVerdict => {
|
|
192
|
+
if (settings.auto === undefined) return { kind: AUTO_VERDICT.off }
|
|
193
|
+
const reason = windowRefusal(settings.auto, today) ?? limitRefusal(input, proposal, settings)
|
|
194
|
+
return reason === null ? { kind: AUTO_VERDICT.auto, until: settings.auto.until } : { kind: AUTO_VERDICT.asks, reason }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The line that the switch ended, once per last day: `ended` is the last day already reported (null before any), the
|
|
199
|
+
* answer the one to keep. Nothing while the switch holds or is unset.
|
|
200
|
+
*/
|
|
201
|
+
export const autoEndedReport = (
|
|
202
|
+
ended: string | null,
|
|
203
|
+
auto: AutoWindow | undefined,
|
|
204
|
+
today: string,
|
|
205
|
+
): { ended: string | null; line: string | null } =>
|
|
206
|
+
auto === undefined || today <= auto.until || ended === auto.until
|
|
207
|
+
? { ended, line: null }
|
|
208
|
+
: { ended: auto.until, line: `parallel tasks auto ended ${auto.until}: every proposal asks again` }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { isDataPath, leavesRepository } from './task-parallel-auto'
|
|
4
|
+
|
|
5
|
+
type Child = { path: string; searched: boolean }
|
|
6
|
+
|
|
7
|
+
/** Directories a search below a writes entry skips: installed packages and the repository's own store. */
|
|
8
|
+
const SKIPPED = new Set(['node_modules', '.git'])
|
|
9
|
+
/** How many entries one search reads before it gives up; a directory larger than this counts as holding one. */
|
|
10
|
+
const MAX_ENTRIES = 20_000
|
|
11
|
+
|
|
12
|
+
/** The children of a directory below the root, none when it cannot be read; `searched` marks a directory to descend. */
|
|
13
|
+
const childrenOf = (root: string, directory: string): Child[] => {
|
|
14
|
+
try {
|
|
15
|
+
return readdirSync(join(root, directory), { withFileTypes: true }).map((entry) => ({
|
|
16
|
+
path: directory === '' ? entry.name : `${directory}/${entry.name}`,
|
|
17
|
+
searched: entry.isDirectory() && !SKIPPED.has(entry.name),
|
|
18
|
+
}))
|
|
19
|
+
} catch {
|
|
20
|
+
return []
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether a path below `relative` is a data path; `true` once the search has read more than `limit` entries. */
|
|
25
|
+
const searchBelow = (root: string, relative: string, limit: number): boolean => {
|
|
26
|
+
const pending = [relative]
|
|
27
|
+
let read = 0
|
|
28
|
+
while (pending.length > 0) {
|
|
29
|
+
for (const child of childrenOf(root, pending.pop() ?? '')) {
|
|
30
|
+
read += 1
|
|
31
|
+
if (read > limit || isDataPath(child.path)) return true
|
|
32
|
+
if (child.searched) pending.push(child.path)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `holdsDataPath` for the repository at `root`: whether a migration or an env file lies at or below a directory,
|
|
40
|
+
* read from the file system at each call. A path that may leave the repository counts, a directory that does not exist
|
|
41
|
+
* holds nothing, and a search that reads more than `limit` entries counts as a hit, so a doubt asks.
|
|
42
|
+
*/
|
|
43
|
+
export const holdsDataPathIn =
|
|
44
|
+
(root: string, limit: number = MAX_ENTRIES) =>
|
|
45
|
+
(directory: string): boolean =>
|
|
46
|
+
leavesRepository(directory) || isDataPath(directory) || searchBelow(root, directory, limit)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isPhaseFinished, isPhaseOpen, type PlanLanguage, type PlanPhase, parsePlanPhases } from '../plan-format'
|
|
2
|
-
import { type PhaseDependency, parseDependencyTable } from '../plan-format/dependencies'
|
|
2
|
+
import { type PhaseDependency, parseDependencyTable, writeSetOf } from '../plan-format/dependencies'
|
|
3
3
|
import { matchSignals, type OpenPlanTask } from '../task-signals/matcher'
|
|
4
4
|
import type { TaskSignal } from '../task-signals/types'
|
|
5
5
|
import type { ActiveWork, OpenTask, TaskParallelInput } from './task-parallel'
|
|
@@ -12,24 +12,11 @@ export type PlanTaskView = {
|
|
|
12
12
|
phaseWrites: Record<string, string[]>
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
const writeSetOf = (
|
|
16
|
-
row: PhaseDependency,
|
|
17
|
-
byId: ReadonlyMap<string, PhaseDependency>,
|
|
18
|
-
chain: readonly string[],
|
|
19
|
-
): string[] | null => {
|
|
20
|
-
if (chain.includes(row.id)) return null
|
|
21
|
-
const like = row.writesLike.map((id) => {
|
|
22
|
-
const target = byId.get(id)
|
|
23
|
-
return target ? writeSetOf(target, byId, [...chain, row.id]) : null
|
|
24
|
-
})
|
|
25
|
-
return like.some((set) => set === null) ? null : [...row.writes, ...like.flatMap((set) => set ?? [])]
|
|
26
|
-
}
|
|
27
|
-
|
|
28
15
|
const phaseWritesOf = (rows: readonly PhaseDependency[]): Record<string, string[]> => {
|
|
29
16
|
const byId = new Map(rows.map((row) => [row.id, row]))
|
|
30
17
|
return Object.fromEntries(
|
|
31
18
|
rows.flatMap((row) => {
|
|
32
|
-
const writes = writeSetOf(row, byId
|
|
19
|
+
const writes = writeSetOf(row, byId)
|
|
33
20
|
return writes === null ? [] : [[row.id, writes]]
|
|
34
21
|
}),
|
|
35
22
|
)
|
|
@@ -77,6 +64,8 @@ export type TaskParallelFacts = {
|
|
|
77
64
|
touchedFiles: readonly string[]
|
|
78
65
|
/** The lane's own agent is working, with or without a task report. */
|
|
79
66
|
laneWorking: boolean
|
|
67
|
+
/** The writes sets of the plan at the base branch; empty when the base branch has no such plan. */
|
|
68
|
+
baseWrites: Record<string, string[]>
|
|
80
69
|
baselineTokens: number
|
|
81
70
|
thresholdTokens: number
|
|
82
71
|
}
|
|
@@ -112,6 +101,7 @@ export const taskParallelInputOf = (facts: TaskParallelFacts): TaskParallelInput
|
|
|
112
101
|
open: openTasksWithout(facts),
|
|
113
102
|
active: activeWorkOf(facts),
|
|
114
103
|
phaseWrites: facts.plan.phaseWrites,
|
|
104
|
+
baseWrites: facts.baseWrites,
|
|
115
105
|
doneCount: facts.plan.doneCount,
|
|
116
106
|
baselineTokens: facts.baselineTokens,
|
|
117
107
|
thresholdTokens: facts.thresholdTokens,
|
|
@@ -11,7 +11,7 @@ import { recordSignals } from '../task-signals/record'
|
|
|
11
11
|
import { readTaskRecords } from '../task-signals/record-store'
|
|
12
12
|
import type { TaskSignal } from '../task-signals/types'
|
|
13
13
|
import { AGENT_STATUS, type LaneAgent } from '../terminal/types'
|
|
14
|
-
import { watchedLanePlanText } from './lane-progress'
|
|
14
|
+
import { baseLanePlanText, watchedLanePlanText } from './lane-progress'
|
|
15
15
|
import type { TaskParallelInput } from './task-parallel'
|
|
16
16
|
import { planTaskViewOf, taskParallelInputOf } from './task-parallel-input'
|
|
17
17
|
import { isWithinWorktree, touchedFilesOf } from './task-parallel-touched'
|
|
@@ -134,9 +134,11 @@ export const readTaskParallelInputs = (
|
|
|
134
134
|
const signals = uniqueSignals([...reads.flatMap((read) => read.signals), ...records]).filter((signal) =>
|
|
135
135
|
isWithinWorktree(signal.cwd, worktree),
|
|
136
136
|
)
|
|
137
|
+
const baseText = baseLanePlanText(sources.root, sources.settings, sources.boardFile, lane)
|
|
137
138
|
const input = taskParallelInputOf({
|
|
138
139
|
lane: lane.name,
|
|
139
140
|
plan: planTaskViewOf(text, sources.settings.planLanguage),
|
|
141
|
+
baseWrites: baseText === null ? {} : planTaskViewOf(baseText, sources.settings.planLanguage).phaseWrites,
|
|
140
142
|
signals,
|
|
141
143
|
touchedFiles: reads.flatMap((read) => read.tails.flatMap((tail) => touchedFilesOf(tail, worktree, since))),
|
|
142
144
|
laneWorking: agents.some((agent) => agent.name === lane.name && agent.status === AGENT_STATUS.working),
|