Skip to content

Commit a9d1125

Browse files
committed
Merge branch 'claude/epic-darwin-g5v4m5': per-job args + personal action worklist
https://claude.ai/code/session_01MpX6FtfqE7HqUuc6Rksgwu
2 parents c5f3b25 + 1ddce05 commit a9d1125

9 files changed

Lines changed: 190 additions & 16 deletions

File tree

automation/README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,16 @@ Every script takes its vault from `--vault-root`; nothing is hardcoded.
1414

1515
Overrides: `run-vault-digest.sh` honours `PYTHON` and `HEBB_BIN` (launchd ships a
1616
minimal PATH). Both Python scripts take `--output`/`--json-output`; the action
17-
review also takes `--register-name` and `--owner` (the name highlighted under
18-
"My Actions"; empty by default).
17+
review also takes `--register-name`, `--owner` (the name highlighted under
18+
"My Actions"; empty by default), and `--mine-output` (off by default; with
19+
`--owner`, also writes a personal worklist of just the owner's actions,
20+
bucketed Overdue/Current/Waiting and sorted by due date).
21+
22+
Per-vault flags: a vault passes extra arguments to its jobs via the
23+
`[job_args]` table in `.hebb/config.toml`; `hebb install` appends them to the
24+
rendered launchd program after the built-in flags. For example:
25+
26+
```toml
27+
[job_args]
28+
action-review = ["--owner", "Alex Doe", "--mine-output", "2-Areas/_MY-OPEN-ACTIONS.md"]
29+
```

automation/generate-action-review.py

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -261,10 +261,13 @@ def extract_priority_block(output_path: Path) -> str:
261261

262262

263263
def build_review(
264-
vault_root: Path, output_path: Path, register_name: str, owner_filter: str
264+
vault_root: Path,
265+
output_path: Path,
266+
register_name: str,
267+
owner_filter: str,
268+
registers: list[Path],
269+
actions: list[Action],
265270
) -> tuple[str, str]:
266-
registers, actions = collect_actions(vault_root, output_path, register_name, owner_filter)
267-
268271
today = dt.date.today()
269272
overdue = [item for item in actions if normalize_status(item.status) == "overdue" or item.is_overdue]
270273
mine = [item for item in actions if item.is_mine]
@@ -333,25 +336,85 @@ def build_review(
333336
return markdown, build_json(actions, registers, vault_root)
334337

335338

339+
def sort_by_due(actions: list[Action]) -> list[Action]:
340+
return sorted(
341+
actions,
342+
key=lambda item: (
343+
item.date_value or dt.date.max,
344+
item.register_title,
345+
item.action.lower(),
346+
),
347+
)
348+
349+
350+
def build_mine(
351+
vault_root: Path, register_name: str, owner_filter: str, actions: list[Action]
352+
) -> str:
353+
"""Personal worklist: only the owner's open actions, bucketed by urgency."""
354+
mine = [item for item in actions if item.is_mine]
355+
overdue = sort_by_due([item for item in mine if item.is_overdue or normalize_status(item.status) == "overdue"])
356+
rest = [item for item in mine if item not in overdue]
357+
waiting = sort_by_due([item for item in rest if normalize_status(item.status) == "waiting"])
358+
current = sort_by_due([item for item in rest if item not in waiting])
359+
360+
return f"""# My Open Actions
361+
362+
Personal worklist for **{owner_filter}**, generated from all `{register_name}` files in the vault.
363+
364+
**Generated:** {dt.date.today().isoformat()}
365+
**Open actions:** {len(mine)} open ({len(overdue)} overdue, {len(current)} current, {len(waiting)} waiting)
366+
367+
---
368+
369+
## Overdue
370+
371+
{markdown_table(overdue, vault_root)}
372+
---
373+
374+
## Current
375+
376+
{markdown_table(current, vault_root)}
377+
---
378+
379+
## Waiting
380+
381+
{markdown_table(waiting, vault_root)}
382+
"""
383+
384+
336385
def main() -> int:
337386
parser = argparse.ArgumentParser(description=__doc__)
338387
parser.add_argument("--vault-root", default=".", help="Path to the vault root")
339388
parser.add_argument("--output", default=DEFAULT_OUTPUT, help="Output markdown file (relative to vault root)")
340389
parser.add_argument("--json-output", default=DEFAULT_JSON_OUTPUT, help="Output JSON file (relative to vault root)")
341390
parser.add_argument("--register-name", default=DEFAULT_REGISTER_NAME, help="Action-register filename to scan for")
342391
parser.add_argument("--owner", default="", help="Owner name to highlight under 'My Actions' (matched as a substring; empty disables)")
392+
parser.add_argument(
393+
"--mine-output",
394+
default="",
395+
help="Also write a personal worklist of the owner's actions to this file (relative to vault root; empty disables; requires --owner)",
396+
)
343397
args = parser.parse_args()
344398

399+
if args.mine_output and not args.owner:
400+
parser.error("--mine-output requires --owner")
401+
345402
vault_root = Path(args.vault_root).resolve()
346403
output_path = (vault_root / args.output).resolve()
347404
json_output_path = (vault_root / args.json_output).resolve()
348405
output_path.parent.mkdir(parents=True, exist_ok=True)
349406
json_output_path.parent.mkdir(parents=True, exist_ok=True)
350-
markdown, json_export = build_review(vault_root, output_path, args.register_name, args.owner)
407+
registers, actions = collect_actions(vault_root, output_path, args.register_name, args.owner)
408+
markdown, json_export = build_review(vault_root, output_path, args.register_name, args.owner, registers, actions)
351409
output_path.write_text(markdown, encoding="utf-8")
352410
json_output_path.write_text(json_export, encoding="utf-8")
353411
print(f"Wrote {display_path(output_path, vault_root)}")
354412
print(f"Wrote {display_path(json_output_path, vault_root)}")
413+
if args.mine_output:
414+
mine_path = (vault_root / args.mine_output).resolve()
415+
mine_path.parent.mkdir(parents=True, exist_ok=True)
416+
mine_path.write_text(build_mine(vault_root, args.register_name, args.owner, actions), encoding="utf-8")
417+
print(f"Wrote {display_path(mine_path, vault_root)}")
355418
return 0
356419

357420

core/vaultconfig.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,19 @@ type VaultConfig struct {
2020
WebPort int `toml:"web_port"`
2121
Jobs []string `toml:"jobs"`
2222
Skills []string `toml:"skills"`
23+
JobArgs JobArgs `toml:"job_args"`
2324
Git GitConfig `toml:"git"`
2425
Update UpdateConfig `toml:"update"`
2526
}
2627

28+
// JobArgs is the committed [job_args] block: extra command-line arguments
29+
// appended to a job's rendered launchd program, keyed by job name. Entries for
30+
// job names not listed under jobs (or unknown to hebb) are ignored, e.g.
31+
//
32+
// [job_args]
33+
// action-review = ["--owner", "Alex Doe"]
34+
type JobArgs map[string][]string
35+
2736
// UpdateConfig is the committed [update] block. The scheduled update-check job
2837
// reports a newer release by default; with auto = true it installs it (opt-in,
2938
// since self-replacing a binary unattended is a deliberate choice).

core/vaultconfig_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,29 @@ func TestLoadVaultConfigAbsent(t *testing.T) {
6666
}
6767
}
6868

69+
func TestVaultConfigJobArgs(t *testing.T) {
70+
vault := t.TempDir()
71+
if err := os.MkdirAll(filepath.Join(vault, ".hebb"), 0o755); err != nil {
72+
t.Fatal(err)
73+
}
74+
cfg := `name = "Work"
75+
76+
[job_args]
77+
action-review = ["--owner", "Alex Doe", "--mine-output", "2-Areas/_MY-OPEN-ACTIONS.md"]
78+
`
79+
if err := os.WriteFile(filepath.Join(vault, ".hebb", "config.toml"), []byte(cfg), 0o644); err != nil {
80+
t.Fatal(err)
81+
}
82+
got, _, err := LoadVaultConfig(vault)
83+
if err != nil {
84+
t.Fatalf("LoadVaultConfig: %v", err)
85+
}
86+
want := []string{"--owner", "Alex Doe", "--mine-output", "2-Areas/_MY-OPEN-ACTIONS.md"}
87+
if !reflect.DeepEqual(got.JobArgs["action-review"], want) {
88+
t.Errorf("job_args[action-review] = %v, want %v", got.JobArgs["action-review"], want)
89+
}
90+
}
91+
6992
func TestLoadVaultConfigInvalid(t *testing.T) {
7093
vault := t.TempDir()
7194
if err := os.MkdirAll(filepath.Join(vault, ".hebb"), 0o755); err != nil {

install/doctor.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ func checkLaunchd(add func(string, string, string), opts Options, vc core.VaultC
158158
if dir == "" {
159159
return
160160
}
161-
jobs := VaultJobs(opts.VaultPath, Slugify(vc.Name), "hebb", resolvedAssetDir(opts), opts.Home, vc.WebPort, vc.Jobs, vc.Update.Auto)
161+
jobs := VaultJobs(opts.VaultPath, Slugify(vc.Name), "hebb", resolvedAssetDir(opts), opts.Home, vc.WebPort, vc.Jobs, vc.Update.Auto, vc.JobArgs)
162162
if len(jobs) == 0 {
163163
return
164164
}

install/launchdjobs.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,10 @@ func Slugify(s string) string {
3535
// and action-review jobs are only included when their script exists under
3636
// <assetRoot>/automation, so no broken plists are written if the automation
3737
// scripts are absent. updateAuto makes the update-check job install updates
38-
// rather than only reporting them. Unknown names are skipped.
39-
func VaultJobs(vaultPath, slug, hebbBin, assetRoot, home string, port int, names []string, updateAuto bool) []launchd.Job {
38+
// rather than only reporting them. jobArgs carries the per-job extra arguments
39+
// from config.toml's [job_args]; they are appended to the matching job's
40+
// program. Unknown names are skipped.
41+
func VaultJobs(vaultPath, slug, hebbBin, assetRoot, home string, port int, names []string, updateAuto bool, jobArgs map[string][]string) []launchd.Job {
4042
logDir := filepath.Join(home, "Library", "Logs")
4143
logPath := func(job string) string {
4244
return filepath.Join(logDir, "hebb-"+slug+"-"+job+".log")
@@ -52,6 +54,7 @@ func VaultJobs(vaultPath, slug, hebbBin, assetRoot, home string, port int, names
5254

5355
var jobs []launchd.Job
5456
for _, name := range names {
57+
before := len(jobs)
5558
switch name {
5659
case "web":
5760
jobs = append(jobs, launchd.Job{
@@ -111,6 +114,14 @@ func VaultJobs(vaultPath, slug, hebbBin, assetRoot, home string, port int, names
111114
LogPath: logPath("update-check"),
112115
})
113116
}
117+
// Per-job extra args from config.toml's [job_args] go at the end of the
118+
// rendered program, after the built-in flags.
119+
if len(jobs) > before {
120+
if extra := jobArgs[name]; len(extra) > 0 {
121+
job := &jobs[len(jobs)-1]
122+
job.Program = append(job.Program, extra...)
123+
}
124+
}
114125
}
115126
return jobs
116127
}

install/launchdjobs_test.go

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func jobByLabel(jobs []launchd.Job, label string) (launchd.Job, bool) {
3434

3535
func TestVaultJobsWebIsBuiltIn(t *testing.T) {
3636
home := t.TempDir()
37-
jobs := VaultJobs("/vaults/work", "work", "/usr/local/bin/hebb", t.TempDir(), home, 4399, []string{"web"}, false)
37+
jobs := VaultJobs("/vaults/work", "work", "/usr/local/bin/hebb", t.TempDir(), home, 4399, []string{"web"}, false, nil)
3838
j, ok := jobByLabel(jobs, "local.hebb.work.web")
3939
if !ok {
4040
t.Fatalf("web job not built; got %d jobs", len(jobs))
@@ -59,7 +59,7 @@ func TestVaultJobsAutomationGatedOnScript(t *testing.T) {
5959

6060
// Without the scripts present, automation jobs are skipped.
6161
jobs := VaultJobs("/vaults/work", "work", "hebb", assetRoot, home, 4321,
62-
[]string{"daily-digest", "action-review"}, false)
62+
[]string{"daily-digest", "action-review"}, false, nil)
6363
if len(jobs) != 0 {
6464
t.Errorf("expected automation jobs skipped when scripts absent, got %d", len(jobs))
6565
}
@@ -75,7 +75,7 @@ func TestVaultJobsAutomationGatedOnScript(t *testing.T) {
7575
}
7676
}
7777
jobs = VaultJobs("/vaults/work", "work", "hebb", assetRoot, home, 4321,
78-
[]string{"daily-digest", "action-review"}, false)
78+
[]string{"daily-digest", "action-review"}, false, nil)
7979

8080
digest, ok := jobByLabel(jobs, "local.hebb.work.daily-digest")
8181
if !ok {
@@ -110,16 +110,55 @@ func TestVaultJobsAutomationGatedOnScript(t *testing.T) {
110110
}
111111
}
112112

113+
func TestVaultJobsAppendsPerJobArgs(t *testing.T) {
114+
home := t.TempDir()
115+
assetRoot := t.TempDir()
116+
autoDir := filepath.Join(assetRoot, "automation")
117+
if err := os.MkdirAll(autoDir, 0o755); err != nil {
118+
t.Fatal(err)
119+
}
120+
if err := os.WriteFile(filepath.Join(autoDir, "generate-action-review.py"), []byte("#!/usr/bin/env python3\n"), 0o755); err != nil {
121+
t.Fatal(err)
122+
}
123+
124+
jobArgs := map[string][]string{
125+
"action-review": {"--owner", "Alex Doe", "--mine-output", "2-Areas/_MY-OPEN-ACTIONS.md"},
126+
"bogus": {"--ignored"},
127+
}
128+
jobs := VaultJobs("/vaults/work", "work", "hebb", assetRoot, home, 4321,
129+
[]string{"action-review", "web"}, false, jobArgs)
130+
131+
review, ok := jobByLabel(jobs, "local.hebb.work.action-review")
132+
if !ok {
133+
t.Fatal("action-review job not built")
134+
}
135+
prog := strings.Join(review.Program, " ")
136+
for _, want := range []string{"--vault-root /vaults/work", "--owner Alex Doe", "--mine-output 2-Areas/_MY-OPEN-ACTIONS.md"} {
137+
if !strings.Contains(prog, want) {
138+
t.Errorf("action-review program %q missing %q", prog, want)
139+
}
140+
}
141+
142+
// Jobs without configured args are untouched.
143+
web, ok := jobByLabel(jobs, "local.hebb.work.web")
144+
if !ok {
145+
t.Fatal("web job not built")
146+
}
147+
if got := strings.Join(web.Program, " "); strings.Contains(got, "--owner") || strings.Contains(got, "--ignored") {
148+
t.Errorf("web program %q should not pick up other jobs' args", got)
149+
}
150+
}
151+
113152
func TestVaultJobsSkipsUnknown(t *testing.T) {
114-
jobs := VaultJobs("/v", "v", "hebb", t.TempDir(), t.TempDir(), 4321, []string{"web", "bogus"}, false)
153+
jobs := VaultJobs("/v", "v", "hebb", t.TempDir(), t.TempDir(), 4321, []string{"web", "bogus"}, false, nil)
115154
if len(jobs) != 1 {
116155
t.Errorf("unknown job name should be skipped, got %d jobs", len(jobs))
117156
}
118157
}
119158

120159
func TestVaultJobsUpdateCheck(t *testing.T) {
121160
// Default: the scheduled job only checks (notifies).
122-
jobs := VaultJobs("/v", "v", "hebb", t.TempDir(), t.TempDir(), 4321, []string{"update-check"}, false)
161+
jobs := VaultJobs("/v", "v", "hebb", t.TempDir(), t.TempDir(), 4321, []string{"update-check"}, false, nil)
123162
j, ok := jobByLabel(jobs, "local.hebb.v.update-check")
124163
if !ok {
125164
t.Fatal("update-check job not built")
@@ -132,7 +171,7 @@ func TestVaultJobsUpdateCheck(t *testing.T) {
132171
}
133172

134173
// auto = true: the job applies the update instead.
135-
jobs = VaultJobs("/v", "v", "hebb", t.TempDir(), t.TempDir(), 4321, []string{"update-check"}, true)
174+
jobs = VaultJobs("/v", "v", "hebb", t.TempDir(), t.TempDir(), 4321, []string{"update-check"}, true, nil)
136175
j, _ = jobByLabel(jobs, "local.hebb.v.update-check")
137176
if got := strings.Join(j.Program, " "); !strings.Contains(got, "update") || strings.Contains(got, "--check") {
138177
t.Errorf("auto update-check should run 'update' without --check, got %q", got)

install/run.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ func renderLaunchd(rep *Report, opts Options, assetDir string) error {
140140
return err
141141
}
142142
slug := Slugify(vc.Name)
143-
jobs := VaultJobs(opts.VaultPath, slug, opts.HebbBin, assetDir, opts.Home, vc.WebPort, vc.Jobs, vc.Update.Auto)
143+
jobs := VaultJobs(opts.VaultPath, slug, opts.HebbBin, assetDir, opts.Home, vc.WebPort, vc.Jobs, vc.Update.Auto, vc.JobArgs)
144144
changed, err := launchd.WriteJobs(jobs, opts.LaunchdDir)
145145
if err != nil {
146146
return err

scripts/acceptance.sh

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,24 @@ EOF
216216
json="$(cat "$VAULT/2-Areas/_ACTION-REVIEW.json" 2>/dev/null)"
217217
has "$json" '"overdue": true'; report $? "action review flags the overdue action"
218218
has "$json" '"mine": true'; report $? "action review flags the owner's action"
219+
220+
# Personal worklist: --mine-output (off by default) writes a second note with
221+
# only the owner's actions, bucketed Overdue/Current/Waiting with a counts line.
222+
[ ! -f "$VAULT/2-Areas/_MY-OPEN-ACTIONS.md" ]; report $? "mine output off by default"
223+
cat >> "$VAULT/2-Areas/Team/OPEN-ACTIONS.md" <<EOF
224+
| Waiting | Chase vendor | [[Alex Doe]] | 2999-01-01 | 2019-12-01 | [[Standup]] |
225+
| Open | Draft plan | [[Alex Doe]] | 2999-02-01 | 2019-12-01 | [[Standup]] |
226+
| Open | Not my task | [[Sam Roe]] | 2999-03-01 | 2019-12-01 | [[Standup]] |
227+
EOF
228+
python3 "$DATA/automation/generate-action-review.py" --vault-root "$VAULT" --owner "Alex Doe" \
229+
--mine-output "2-Areas/_MY-OPEN-ACTIONS.md" > "$WORK/mine.out" 2>&1
230+
report $? "generate-action-review.py runs with --mine-output"
231+
mine="$(cat "$VAULT/2-Areas/_MY-OPEN-ACTIONS.md" 2>/dev/null)"
232+
has "$mine" "3 open (1 overdue, 1 current, 1 waiting)"; report $? "mine output counts line"
233+
has "$mine" "Ship $CANARY"; report $? "mine output lists the overdue action"
234+
has "$mine" "Chase vendor"; report $? "mine output lists the waiting action"
235+
has "$mine" "Draft plan"; report $? "mine output lists the current action"
236+
if has "$mine" "Not my task"; then report 1 "other owners excluded from mine output"; else report 0 "other owners excluded from mine output"; fi
219237
else
220238
echo " skip python3 unavailable"
221239
fi

0 commit comments

Comments
 (0)