@stravica-ai/digital-operator-pa 0.1.52 → 0.1.53

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.
@@ -25,8 +25,13 @@ Usage:
25
25
  Install a schedule onto its declared backend
26
26
  scheduler list [--backend <name>] [--agent <name>]
27
27
  List installed schedules
28
- scheduler remove <id> [--backend N] [--agent <name>] [--delete-file]
29
- Remove an installed schedule
28
+ scheduler remove <id|com.<agent>.<id>> [--backend N] [--agent <name>] [--delete-file]
29
+ Remove an installed schedule. --backend is
30
+ optional: when omitted, the id is looked up in
31
+ schedules/<id>.json and the file's own backend
32
+ field wins (OS-native detect is the fallback).
33
+ The label form the list verb prints as a
34
+ secondary line is accepted here directly.
30
35
  scheduler verify <schedule.json> Verify a schedule fires correctly
31
36
  scheduler -h, --help Show this help
32
37
 
@@ -77,16 +77,25 @@ done and move on. On a mismatch, print both versions and the recovery line
77
77
 
78
78
  ### 3. profileIntake
79
79
 
80
- Ask the operator four questions in one turn:
80
+ Ask the operator four questions in one turn. Question 4 is optional
81
+ and explicitly skippable — a first-day operator often has no strong
82
+ answer and a forced answer here becomes a stale steering signal for
83
+ the whole life of the instance. "Skip" is the safe default when the
84
+ operator hesitates.
81
85
 
82
86
  1. Your name and how you prefer to be addressed?
83
87
  2. Your role (job title, or "solo", or a short description)?
84
88
  3. Communication style you want (formal, casual, direct, verbose)?
85
- 4. Any topic areas the PA should be strong on from day one?
89
+ 4. (Optional, skip freely) Anything you would like the PA to lean into
90
+ right away? A one-line hint about the kind of work you plan to
91
+ throw at it (writing, code, admin, research, planning). This tunes
92
+ nothing critical; the PA discovers your patterns as it goes.
86
93
 
87
- Take the answers and write them into `operator/profile/profile.md`, overwriting
88
- the placeholder. Keep it a short human-readable paragraph plus a bulleted list
89
- of preferences. Do not invent fields the operator did not name.
94
+ Take the answers and write them into `operator/profile/profile.md`,
95
+ overwriting the placeholder. Keep it a short human-readable paragraph
96
+ plus a bulleted list of preferences. If Q4 was skipped or the answer
97
+ is `skip` / empty, do not write a Q4 line at all. Do not invent fields
98
+ the operator did not name.
90
99
 
91
100
  ### 4. aliasOffer
92
101
 
@@ -189,6 +198,19 @@ and the operator gets:
189
198
  Then stop. Do not silently skip to `atlassianOffer`. The whole
190
199
  onboarding is blocked on this step.
191
200
 
201
+ **Hard stop between step 6 and step 7.** Do not deliver the step-7
202
+ vault pitch, do not describe the vault, do not ask the operator for
203
+ credential values, and do not print the `secrets init` invocation while
204
+ `librarianSchedule` is anything other than `done` in the state file.
205
+ Step 7 is only reached AFTER `librarianSchedule` transitions to `done`
206
+ and a completed run is recorded in `.cache/distill/logs/runs.jsonl`.
207
+ If the operator asks about the vault in the meantime, name the pending
208
+ check (`librarianSchedule` awaiting a proven run) and pick step 7 back
209
+ up once the librarian step lands. A pre-emptive step-7 pitch reads to
210
+ the operator as a step already begun, and the vault step's first-time
211
+ material is expensive to redo cleanly if it lands under the wrong
212
+ prerequisite.
213
+
192
214
  ### 7. vault (conditionally mandatory, no opt-out)
193
215
 
194
216
  Stand up the SOPS+age secrets vault when an enabled tool declares a
@@ -16,7 +16,7 @@ The role exists because workflow design keeps recurring as ad-hoc work done inli
16
16
  | Reports to | The dispatching PA |
17
17
  | Orchestrator mode | No -- one designer produces one ruled workflow design end to end |
18
18
  | Primary output | Versioned flowchart diagram + Rule of Two mapping + open-questions/rulings record, registered as a project doc |
19
- | Activation | 8-section briefing contract naming the workflow's purpose, actors, external stores, trust boundaries, cadence constraints, and the output and registration paths |
19
+ | Activation | Routing cues surfaced at the PA layer (see Activation); dispatched with the 8-section briefing contract |
20
20
  | Domain | Agentic pipelines, scheduled automation, triage and approval flows, cross-estate processes |
21
21
 
22
22
 
@@ -43,6 +43,32 @@ The mapping shape, one row per autonomous loop:
43
43
  |---|---|---|---|---|
44
44
  | `<loop name>` | yes / no / constrained: `<control>` | yes / no / constrained: `<control>` | yes / no / constrained: `<control>` | `<what the operator approves before the next lane runs>` |
45
45
 
46
+ **This mapping is not the autonomy envelope, and the two do not translate.** Estates already carry a per-task-type approval vocabulary (`pa autonomy`: `fullyApproved` / `checkBeforeCommitting` / `needsFullSupervision`), and under pressure that vocabulary wins by default because the platform supplied it first. They answer different questions. The autonomy envelope classifies task types by how much the operator trusts the agent to act unsupervised; the Rule of Two mapping classifies a loop's capabilities by what attacker-influenced content could do with them. An envelope state cannot express which of [A]/[B]/[C] a loop holds, so it cannot show whether the rule is satisfied; a mapping cannot express whether the operator wants sign-off on a task type. There is no crosswalk: `fullyApproved` is not `yes`, `needsFullSupervision` is not a gate, and a mapping delivered in envelope vocabulary is wrong in kind, not in wording -- it is redone in the table shape above, which is the only valid form of the mapping; prose restatements in any vocabulary are commentary, not the mapping. Where the two systems genuinely touch, state the relationship as a consequence, never a synonym: a loop whose mapping requires an operator gate will normally sit no higher than `checkBeforeCommitting` in the estate's envelope, and the diagram may note that beside the gate, clearly labelled as the envelope's word.
47
+
48
+
49
+ ## Required estate bindings
50
+
51
+ The role depends on two estate surfaces. Both are declared here so absence is a loud, checkable condition and never a silent change of deliverable. One is a hard dependency; the other splits into a half that ships in core and a half that is estate-optional with a stock-safe default.
52
+
53
+ **1. Graphic-design rendering lane -- hard dependency, checked before design work starts.** The brief must name the lane binding: engine, invocation form, and model pin. The designer's first act on activation is a binding check: confirm the named lane exists and answers (a version or no-op invocation is sufficient). If the brief names no lane, or the named lane does not answer, the designer returns immediately with a single line -- `BINDING-MISSING: graphic-design lane -- <what the brief named, what was checked, what was absent>` -- and produces no artefact. This is the only permitted return that replaces the six-line contract. Absence of the lane never changes the artefact type: no markdown diagram, no mermaid block, no knowledge-page substitute, no self-authored HTML on the orchestrating model. A wrong-shaped artefact reads as a delivered design and buries the estate defect; a loud stop gets the binding registered once, for every future dispatch. An unregistered binary on the machine (a `codex` on the PATH, say) is not a binding; a binding is named in the brief or in the estate's declared overlay, nowhere else.
54
+
55
+ **2. Review loop -- split, and the split is precise.** The doc-status half ships in core and works on a stock instance: `pa project doc add` registers the diagram, `pa project doc status <draft|review|approved>` tracks the loop. The role uses these unconditionally and may assume them. The comment-landing half -- a surface where the operator's rulings physically arrive against the artefact (a reviews inbox, an annotator) -- is not core. It is an estate extension and is never assumed. The ruling-capture channel is an overlay point with a stock-safe default:
56
+
57
+ | Overlay point | Core default | Estate example |
58
+ |---|---|---|
59
+ | Ruling-capture channel | Rulings arrive through the dispatching PA: the operator rules in conversation, the PA carries the numbered rulings into the revision dispatch, and they are staged verbatim per the operator review loop | An estate-bound reviews inbox and annotator on the workspace |
60
+
61
+ On a stock instance the full loop is: doc registered at `draft`, moved to `review` when the v1 URL goes to the operator, operator reads at the serving URL and rules through the PA, ruled revision ships at the same URL, doc moved to `approved`. Nothing in this role's process may require a surface the estate has not declared; where this definition and an estate's actual surfaces disagree, the designer treats the estate as authoritative and flags the gap in `HANDBACK:` rather than designing against the missing surface.
62
+
63
+
64
+ ## Design verification (the plan, not the page)
65
+
66
+ Render verification proves the diagram displays; it says nothing about whether the design is true. Before handover the designer verifies the plan itself, and states the outcome -- verification that happened but was not stated is treated as not having happened.
67
+
68
+ - **Grounding.** Every external fact the design leans on -- system states and status sets, permissions, endpoints, schedule mechanics, tool availability -- is verified against the live system where the estate's access allows, or explicitly marked `assumed` and carried as a numbered open question. An unverifiable fact never silently becomes a design premise.
69
+ - **Coverage.** Every autonomous loop drawn on the diagram has a mapping row and every mapping row corresponds to a drawn loop; every gate in the mapping is drawn and every drawn gate appears in the mapping. A diagram/mapping mismatch is a design defect, not a formatting nit.
70
+ - **Statement.** The plan summary carries one explicit verification line: what was checked, how, and what remains assumed.
71
+
46
72
 
47
73
  ## The diagram contract
48
74
 
@@ -85,7 +111,7 @@ Standing rules for the lane invocation, whatever engine the estate binds:
85
111
  ## Process obligations
86
112
 
87
113
  - A work item exists before design work starts; dispatches are logged per the estate's spawn discipline.
88
- - The design lands in a project (created via the estate's project tooling), with the diagram registered as a doc.
114
+ - **The design lands in a project, and the project bootstrap is owned, not assumed.** The brief names an existing `projectId` when the design belongs to work already underway. When it does not, the designer creates the project itself -- as its first act after the binding check -- via the estate's project tooling (`pa project create`, slug named for the workflow), and reports the created id on the `REGISTRATION:` line. The dispatching PA is never required to pre-create a project in order to dispatch this role: bootstrap cost on the dispatch path is routing friction, and routing friction is what sent the first proving run into the knowledge tree, which needed no bootstrap. The diagram is registered as a project doc immediately on first write (`pa project doc add`, status `draft`), not at handover.
89
115
  - Rulings and design-shaping decisions are staged to the knowledge pipeline in real time, not batched.
90
116
 
91
117
 
@@ -96,6 +122,7 @@ The definition is PA-agnostic and operator-agnostic. These bindings are supplied
96
122
  | Overlay point | Core default | Estate example |
97
123
  |---|---|---|
98
124
  | Graphic-design lane | The estate's designated engine, invoked as a CLI with the model pinned on the command line | HQ and WSD: Codex `gpt-6-astra` one-shot via `codex exec` (the judgement, content and design lane under the 2026-09-13 allocation) |
125
+ | Ruling-capture channel | Rulings arrive through the dispatching PA: the operator rules in conversation, the PA carries the numbered rulings into the revision dispatch, and they are staged verbatim per the operator review loop | An estate-bound reviews inbox and annotator on the workspace |
99
126
  | Editorial hygiene | Operator-profile-driven | No emojis, no em-dashes in persisted artefacts |
100
127
  | Diagram theme | Operator display preference; light when unstated | Hard-coded light theme |
101
128
  | Serving surface | The instance workspace at the configured `workspacePort` (`viewer.html?file=<path>` or direct path serving) | WSD workspace, repo-path serving on port 4222 |
@@ -121,7 +148,8 @@ Every designer dispatch returns:
121
148
  - `DIAGRAM:` file path, serving URL, version marker text
122
149
  - `MAPPING:` the [A]/[B]/[C] table per autonomous loop in the duty-1 vocabulary, with the gate placements
123
150
  - `QUESTIONS/RULINGS:` open questions (v1) or the rulings folded in (vN), with anything still provisional flagged
124
- - `VERIFICATION:` render evidence (console state, external-request count, elements confirmed)
151
+ - `VERIFICATION (design):` what was verified against live systems and how, the diagram/mapping coverage check, and what remains assumed (per Design verification)
152
+ - `VERIFICATION (render):` render evidence (console state, external-request count, elements confirmed)
125
153
  - `REGISTRATION:` project doc path and status
126
154
  - `HANDBACK:` what the parent does next -- rulings to obtain, spec to ship cross-estate, build items to open
127
155
 
@@ -132,7 +160,7 @@ The parent only sees the final message. The return contract is the deliverable.
132
160
 
133
161
  - **Designs; never implements the workflow in the same dispatch.** Build work is separate, briefed against the ruled design.
134
162
  - **Never weakens or removes a Rule of Two gate** on its own judgement; that is an operator ruling.
135
- - **No mutation of the systems the workflow will touch.** The design artefact, its registration and the staged rulings are the only writes.
163
+ - **No mutation of the systems the workflow will touch.** The design artefact, the project that carries it (creation and doc registration via the estate's project tooling, when the brief names no existing project), and the staged rulings are the only writes.
136
164
  - **Depth-2 posture: the designer spawns no agents.** The graphic-design lane is a tool call from the designer's own session; an agent-role lane is a sibling dispatch run by the PA.
137
165
 
138
166
 
@@ -164,10 +192,29 @@ Stop and escalate if:
164
192
 
165
193
  - The workflow cannot be decomposed to satisfy the Rule of Two without destroying its purpose; the operator decides the risk posture.
166
194
  - An operator instruction appears to conflict with the Rule of Two; surface the conflict with the mapping, do not silently comply or silently refuse.
167
- - The graphic-design lane is unavailable or fails within the bounded iterations.
195
+ - The graphic-design lane is unavailable at the activation binding check, or fails within the bounded iterations.
168
196
  - The brief lacks the actors, external stores, or trust boundaries needed to produce the [A]/[B]/[C] mapping; do not guess a security architecture.
169
197
 
170
198
 
171
199
  ## Activation
172
200
 
173
- The Agentic Workflow Designer activates when the PA delegates a workflow design with a briefing contract. The brief names the workflow's purpose, actors, external stores, trust boundaries, cadence constraints, the project and output paths, the estate's lane binding and model pin, and the designer's own model pin, and it carries the standard return-timing clause (return on completion or abort only). No external dependencies beyond the estate's lane tooling, a headless browser for render verification, and the project tooling for registration.
201
+ ### Routing cues (for the dispatching PA -- lift this block verbatim)
202
+
203
+ This block is written in the dispatcher's vocabulary, not the designer's, and it is designed to be composed into the PA's own operating surface by the platform. A cue that lives only in a role file the PA never opens routes nothing; the role declares the cues here, and the estate is responsible for surfacing them where routing decisions are made. The recognition test is deliberately one question, because a test that costs more thought than answering inline will lose to answering inline every time.
204
+
205
+ **The test: would a correct answer contain a flow -- more than one actor, or at least one decision or approval point?** If yes, dispatch this role. The PA does not sketch the flow inline first; an inline sketch is the deliverable done badly, and it anchors the operator on an unruled, unmapped design.
206
+
207
+ Operator phrasings that match, however small the ask sounds:
208
+
209
+ - "design a process / workflow / pipeline / automation" for anything
210
+ - anything involving status transitions, approval steps, handoffs, triage rules, escalation paths, or "who does what when"
211
+ - any automation that will run unattended: scheduled, triggered, or looping
212
+ - a correction of a prose answer toward an artefact ("as a designed workflow", "produce the real artefact", "not prose") -- a second ask in this shape after an inline answer means the first ask was a missed dispatch, not that a new kind of request has arrived
213
+
214
+ Non-cues (answer inline): a status question about an existing workflow; a factual question about this role or the Rule of Two; a prose edit to a ruled diagram's supporting text that changes no lane, gate, or mapping.
215
+
216
+ **A workflow design is project work, never knowledge capture.** Writing the design into the knowledge tree (`knowledge/`, `operator/knowledge/`) is a routing failure even when the content is good: knowledge records what has been ruled; the project carries what is being designed and reviewed. The knowledge tree is also the cheaper path on a fresh instance precisely because it needs no bootstrap, which is why this rule is stated here and not left to taste.
217
+
218
+ ### Activation brief (what arrives)
219
+
220
+ The Agentic Workflow Designer activates when the PA delegates a workflow design with a briefing contract. The brief names the workflow's purpose, actors, external stores, trust boundaries, cadence constraints, the project id where one already exists (see Process obligations for bootstrap when none does), the output paths, the estate's lane binding and model pin, and the designer's own model pin, and it carries the standard return-timing clause (return on completion or abort only). No external dependencies beyond the estate's lane tooling, a headless browser for render verification, and the project tooling for registration and bootstrap.
@@ -25,30 +25,58 @@ import { findRepoRootSync } from '../instance-root/find.mjs';
25
25
  const __filename = fileURLToPath(import.meta.url);
26
26
  const __dirname = dirname(__filename);
27
27
 
28
- /** Default agent id used when a schedule does not declare one. Backward-compat anchor. */
29
- export const DEFAULT_AGENT = 'dave';
28
+ /**
29
+ * Return the schedule's `agent` field, refusing when it is missing.
30
+ * 0.1.53 item E: the scheduler no longer carries a literal-`dave`
31
+ * default. Every schedule file has always declared its own `agent`
32
+ * field (the scaffold's `buildDistillationScheduleJson` sets it, the
33
+ * shared JSON schema documents it, and 0.1.52's `list`/`remove` do
34
+ * not silently default it either). A file without an `agent` field
35
+ * is a schema break, and refusing here is safer than silently
36
+ * spraying `dave-<id>` tags across whatever estate this laptop
37
+ * carries.
38
+ * @param {object} sched
39
+ * @returns {string}
40
+ * @throws {Error & { exitCode: 2 }}
41
+ */
42
+ export function schedAgent(sched) {
43
+ if (sched && typeof sched.agent === 'string' && sched.agent.length > 0) {
44
+ return sched.agent;
45
+ }
46
+ const err = new Error(
47
+ `scheduler: schedule file is missing the top-level "agent" field. Add `
48
+ + '"agent": "<paName>" alongside "id" (matches operator/config/preferences.json:paName). '
49
+ + 'The pre-0.1.53 default `"dave"` has been removed to keep cross-estate laptops safe.',
50
+ );
51
+ err.exitCode = 2;
52
+ throw err;
53
+ }
30
54
 
31
55
  /**
32
56
  * Compute the launchd label prefix for an agent. Centralised so install,
33
- * remove, and list all agree on the naming scheme. Defaults to the
34
- * pre-existing `com.dave.` behaviour when no agent is given.
35
- * @param {string|undefined} agent
57
+ * remove, and list all agree on the naming scheme. The agent name comes
58
+ * from the schedule file (install path) or the invoking instance's
59
+ * `paName` (list/remove path); there is no hard-coded default.
60
+ * @param {string} agent
36
61
  * @returns {string}
37
62
  */
38
63
  export function launchdLabelPrefix(agent) {
39
- return `com.${agent || DEFAULT_AGENT}.`;
64
+ if (!agent) {
65
+ const err = new Error('launchdLabelPrefix: agent is required (no default).');
66
+ err.exitCode = 2;
67
+ throw err;
68
+ }
69
+ return `com.${agent}.`;
40
70
  }
41
71
 
42
72
  /**
43
73
  * Resolve the full launchd label for a schedule. Agent-derived: reads
44
- * `sched.agent` (optional field on the schedule JSON), falling back to
45
- * DEFAULT_AGENT so existing schedule files with no `agent` field keep
46
- * producing exactly `com.dave.<id>`, unchanged from pre-feature behaviour.
74
+ * `sched.agent` and refuses when it is absent (see schedAgent).
47
75
  * @param {object} sched
48
76
  * @returns {string}
49
77
  */
50
78
  export function resolveLaunchdLabel(sched) {
51
- return `${launchdLabelPrefix(sched.agent)}${sched.id}`;
79
+ return `${launchdLabelPrefix(schedAgent(sched))}${sched.id}`;
52
80
  }
53
81
 
54
82
  /**
@@ -81,12 +109,31 @@ function readSchedule(filePath) {
81
109
  }
82
110
 
83
111
  /**
84
- * Compute the resolved DAVE_ROOT for the current install.
112
+ * Compute the resolved PA repo root for the current install. 0.1.53
113
+ * item E: local resolution first, PA_ROOT as an explicit override
114
+ * (never leading), DAVE_ROOT accepted as a deprecated alias with a
115
+ * one-line notice.
85
116
  * @returns {string}
86
117
  */
87
118
  function computeDaveRoot() {
88
- return process.env.DAVE_ROOT || process.env.PA_ROOT || findRepoRootSync();
119
+ const local = findRepoRootSync();
120
+ if (process.env.PA_ROOT) return process.env.PA_ROOT;
121
+ if (process.env.DAVE_ROOT) {
122
+ // Emit the notice at most once per process to keep install output
123
+ // tidy; the flag lives on the resolver so tests can reset it.
124
+ if (!computeDaveRoot._noticed) {
125
+ process.stderr.write(
126
+ 'Notice: DAVE_ROOT is a deprecated alias for PA_ROOT. Set PA_ROOT '
127
+ + 'instead; DAVE_ROOT support will be removed in a future release.\n',
128
+ );
129
+ computeDaveRoot._noticed = true;
130
+ }
131
+ return process.env.DAVE_ROOT;
132
+ }
133
+ return local;
89
134
  }
135
+ // Reset hook for tests.
136
+ computeDaveRoot._noticed = false;
90
137
 
91
138
  /**
92
139
  * Resolve the DAVE_ROOT variable and working directory.
@@ -271,9 +318,10 @@ export function checkNameCollision(scheduleFile, sched, opts = {}) {
271
318
  continue;
272
319
  }
273
320
  if (other && other.id === sched.id) {
321
+ const agent = schedAgent(sched);
274
322
  const e = new Error(
275
323
  `schedule id "${sched.id}" collides with ${candidate} — two different schedule files `
276
- + `resolve to the same task id/tag ("dave-${sched.id}"). Rename one of the ids before installing.`,
324
+ + `resolve to the same task id/tag ("${agent}-${sched.id}"). Rename one of the ids before installing.`,
277
325
  );
278
326
  e.exitCode = 2;
279
327
  throw e;
@@ -312,13 +360,12 @@ export function detectNvm(homeDir = homedir()) {
312
360
 
313
361
  /**
314
362
  * Default per-run log path for a schedule's cron entry, matching the
315
- * real house pattern (`/tmp/dave-knowledge-eval-daily.log` for
316
- * agent=dave, id=knowledge-eval-daily).
363
+ * real house pattern (`/tmp/<agent>-<id>.log`).
317
364
  * @param {object} sched
318
365
  * @returns {string}
319
366
  */
320
367
  export function defaultCronLogPath(sched) {
321
- return `/tmp/${sched.agent || DEFAULT_AGENT}-${sched.id}.log`;
368
+ return `/tmp/${schedAgent(sched)}-${sched.id}.log`;
322
369
  }
323
370
 
324
371
  /**
@@ -416,14 +463,15 @@ export function buildCronCommand(sched, workDir, env, opts = {}) {
416
463
  * @returns {string}
417
464
  */
418
465
  export function buildCronLine(sched, workDir, env, opts = {}) {
419
- const tag = `dave-${sched.id}`;
466
+ const tag = `${schedAgent(sched)}-${sched.id}`;
420
467
  const { wrapped } = buildCronCommand(sched, workDir, env, opts);
421
468
  return `${sched.schedule} ${wrapped} # ${tag}`;
422
469
  }
423
470
 
424
471
  function installCron(sched, workDir) {
472
+ const agent = schedAgent(sched);
425
473
  const env = resolveEnvironment(sched);
426
- const tag = `# dave-${sched.id}`;
474
+ const tag = `# ${agent}-${sched.id}`;
427
475
  const hasNvm = detectNvm();
428
476
  const entry = buildCronLine(sched, workDir, env, { hasNvm });
429
477
 
@@ -444,10 +492,10 @@ function installCron(sched, workDir) {
444
492
  const newCrontab = filtered ? `${filtered}\n${entry}\n` : `${entry}\n`;
445
493
  execSync('crontab -', { input: newCrontab, encoding: 'utf8' });
446
494
 
447
- console.log(`Installed cron schedule: dave-${sched.id}`);
495
+ console.log(`Installed cron schedule: ${agent}-${sched.id}`);
448
496
  console.log(` ${sched.schedule} -- ${sched.description || ''}`);
449
497
 
450
- return { tag: `dave-${sched.id}` };
498
+ return { tag: `${agent}-${sched.id}` };
451
499
  }
452
500
 
453
501
  // ── Backend: launchd ─────────────────────────────────────────────────
@@ -696,7 +744,7 @@ export function buildStartCalendarIntervalBlock(cron) {
696
744
  * Generate the full plist content for a schedule. Pure function — safe to
697
745
  * call in tests. Always emits an EnvironmentVariables block on launchd
698
746
  * (auto-injected defaults when the schedule has no explicit environment).
699
- * The launchd Label is agent-derived (`com.<sched.agent||'dave'>.<id>`) —
747
+ * The launchd Label is agent-derived (`com.<sched.agent>.<id>`) —
700
748
  * see resolveLaunchdLabel.
701
749
  * @param {object} sched
702
750
  * @param {string} workDir
@@ -736,9 +784,9 @@ ${programArgs}
736
784
  <key>WorkingDirectory</key>
737
785
  <string>${xmlEscape(workDir)}</string>
738
786
  ${envBlock}${calendarBlock} <key>StandardOutPath</key>
739
- <string>/tmp/dave-${sched.id}-stdout.log</string>
787
+ <string>/tmp/${schedAgent(sched)}-${sched.id}-stdout.log</string>
740
788
  <key>StandardErrorPath</key>
741
- <string>/tmp/dave-${sched.id}-stderr.log</string>
789
+ <string>/tmp/${schedAgent(sched)}-${sched.id}-stderr.log</string>
742
790
  <key>RunAtLoad</key>
743
791
  <false/>
744
792
  </dict>
@@ -820,7 +868,7 @@ WantedBy=timers.target
820
868
  }
821
869
 
822
870
  function installSystemd(sched, workDir, cron) {
823
- const unitName = `dave-${sched.id}`;
871
+ const unitName = `${schedAgent(sched)}-${sched.id}`;
824
872
  const unitDir = join(homedir(), '.config', 'systemd', 'user');
825
873
  mkdirSync(unitDir, { recursive: true });
826
874
 
@@ -868,7 +916,7 @@ export function buildSchtasksCommand(sched, workDir, env) {
868
916
  }
869
917
 
870
918
  function installSchtasks(sched, workDir, cron) {
871
- const taskName = `dave-${sched.id}`;
919
+ const taskName = `${schedAgent(sched)}-${sched.id}`;
872
920
  const env = resolveEnvironment(sched);
873
921
 
874
922
  const fmtHour = cron.hour === '*' ? '00' : cron.hour.padStart(2, '0');
@@ -98,6 +98,15 @@ function listLaunchd(agent) {
98
98
  const files = readdirSync(plistDir).filter(f => f.startsWith(prefix) && f.endsWith('.plist'));
99
99
  for (const file of files) {
100
100
  const label = file.replace(/\.plist$/, '');
101
+ // 0.1.53 item D: the id is what `scheduler remove` accepts as the
102
+ // bare positional and what schedules/<id>.json is keyed on, so we
103
+ // print it as the primary line and hang the launchd label off it
104
+ // as a secondary. Pre-0.1.53 the label was primary, which had
105
+ // operators pasting `com.<agent>.<id>` back into `scheduler remove`
106
+ // and hitting `Unknown backend: undefined` (remove() now accepts
107
+ // the label form too, but the primary-secondary swap removes the
108
+ // trap at the source).
109
+ const id = label.startsWith(prefix) ? label.slice(prefix.length) : label;
101
110
  let status = 'not loaded';
102
111
  try {
103
112
  execSync(`launchctl list ${label}`, { stdio: 'pipe' });
@@ -105,8 +114,9 @@ function listLaunchd(agent) {
105
114
  } catch {
106
115
  // not loaded
107
116
  }
108
- lines.push(` ${label}`);
109
- lines.push(` File: ${join(plistDir, file)}`);
117
+ lines.push(` ${id}`);
118
+ lines.push(` Label: ${label}`);
119
+ lines.push(` File: ${join(plistDir, file)}`);
110
120
  lines.push(` Status: ${status}`);
111
121
  found = true;
112
122
  }
@@ -18,7 +18,7 @@
18
18
  * neither source resolves a name. See ./resolve-agent.mjs.
19
19
  */
20
20
 
21
- import { existsSync, unlinkSync, realpathSync } from 'node:fs';
21
+ import { existsSync, readFileSync, unlinkSync, realpathSync } from 'node:fs';
22
22
  import { join, resolve, dirname } from 'node:path';
23
23
  import { homedir } from 'node:os';
24
24
  import { execSync } from 'node:child_process';
@@ -26,6 +26,7 @@ import { fileURLToPath } from 'node:url';
26
26
  import { launchdLabelPrefix } from './install.mjs';
27
27
  import { findRepoRootSync } from '../instance-root/find.mjs';
28
28
  import { resolveAgent } from './resolve-agent.mjs';
29
+ import { detect } from './detect.mjs';
29
30
 
30
31
  const __filename = fileURLToPath(import.meta.url);
31
32
  const __dirname = dirname(__filename);
@@ -115,52 +116,156 @@ function removeSchtasks(schedId, agent) {
115
116
  }
116
117
  }
117
118
 
119
+ // ── Helpers ──────────────────────────────────────────────────────────
120
+
121
+ /**
122
+ * Strip a `com.<agent>.` launchd-label prefix from an argument when the
123
+ * operator passes the label form of a schedule (0.1.53 item D).
124
+ * `scheduler list` prints the label as a secondary line, so operators
125
+ * pasting the label back into `scheduler remove` used to fail with
126
+ * `Unknown backend: undefined` (the label pattern hid the id from the
127
+ * arg parser). The label form is now accepted alongside the bare id.
128
+ * The `<agent>` half of the label is verified against the resolved
129
+ * agent so a mistyped estate is a refusal, never a silent no-op on
130
+ * this estate's plists.
131
+ *
132
+ * @param {string} input
133
+ * @param {string} agent
134
+ * @returns {{ id: string, accepted: 'id'|'label' }}
135
+ * @throws {Error & { exitCode: 2 }} when the label carries a different agent.
136
+ */
137
+ export function normaliseScheduleArg(input, agent) {
138
+ const match = /^com\.([a-z0-9][a-z0-9-]*)\.(.+)$/.exec(input);
139
+ if (!match) return { id: input, accepted: 'id' };
140
+ const [, labelAgent, id] = match;
141
+ if (labelAgent !== agent) {
142
+ const err = new Error(
143
+ `scheduler remove: label form "com.${labelAgent}.${id}" names a different `
144
+ + `agent (${labelAgent}) than the resolved agent (${agent}). Refusing to touch `
145
+ + 'another estate\'s schedules. Pass `--agent <name>` to target a specific '
146
+ + 'estate on this laptop, or use the bare id form.',
147
+ );
148
+ err.exitCode = 2;
149
+ throw err;
150
+ }
151
+ return { id, accepted: 'label' };
152
+ }
153
+
154
+ /**
155
+ * Resolve the schedules directory for the invoking instance. Local
156
+ * resolution first (options.repoRoot, else the resolver's walk), then
157
+ * PA_ROOT as an explicit override, then DAVE_ROOT accepted as a
158
+ * deprecated alias with a one-line stderr notice. 0.1.53 item E.
159
+ *
160
+ * @param {object} options
161
+ * @returns {string}
162
+ */
163
+ function schedulesRoot(options) {
164
+ if (options.repoRoot) return join(options.repoRoot, 'schedules');
165
+ const local = findRepoRootSync();
166
+ if (process.env.PA_ROOT) return join(process.env.PA_ROOT, 'schedules');
167
+ if (process.env.DAVE_ROOT) {
168
+ process.stderr.write(
169
+ 'Notice: DAVE_ROOT is a deprecated alias for PA_ROOT. Set PA_ROOT '
170
+ + 'instead; DAVE_ROOT support will be removed in a future release.\n',
171
+ );
172
+ return join(process.env.DAVE_ROOT, 'schedules');
173
+ }
174
+ return join(local, 'schedules');
175
+ }
176
+
177
+ /**
178
+ * Read the `backend` field from a schedule file when the operator did
179
+ * not pass `--backend` (0.1.53 item D). Falls back to the host's
180
+ * OS-native default via `detect()` when the file exists but its
181
+ * `backend` field is null or missing. Refuses (exit 2) when neither
182
+ * source produces a supported backend, with a message naming the
183
+ * available options.
184
+ *
185
+ * @param {string} schedId
186
+ * @param {object} options
187
+ * @returns {string}
188
+ * @throws {Error & { exitCode: 2 }}
189
+ */
190
+ function resolveBackendFromDisk(schedId, options) {
191
+ const file = join(schedulesRoot(options), `${schedId}.json`);
192
+ let sched = null;
193
+ if (existsSync(file)) {
194
+ try {
195
+ sched = JSON.parse(readFileSync(file, 'utf8'));
196
+ } catch { /* keep sched null */ }
197
+ }
198
+ if (sched && typeof sched.backend === 'string' && sched.backend.length > 0) {
199
+ return sched.backend;
200
+ }
201
+ // Fall back to the host's OS-native default. install() has always
202
+ // used the schedule file's `backend` field or the CLI-supplied value;
203
+ // for remove(), when nothing is on disk we still have to pick one, so
204
+ // we lean on the host detector rather than refuse.
205
+ const info = detect();
206
+ const pick = (info.available || []).find((b) => b.recommended)
207
+ || (info.available || [])[0];
208
+ if (pick && pick.backend) return pick.backend;
209
+ const err = new Error(
210
+ `scheduler remove: cannot resolve backend for "${schedId}" — no schedule file at ${file} `
211
+ + 'and no OS-native backend detected on this host. Pass --backend cron|launchd|systemd|schtasks explicitly.',
212
+ );
213
+ err.exitCode = 2;
214
+ throw err;
215
+ }
216
+
118
217
  // ── Main ─────────────────────────────────────────────────────────────
119
218
 
120
219
  /**
121
220
  * Remove a schedule from the specified backend for the invoking
122
221
  * instance (or an explicit `--agent` override).
123
222
  *
223
+ * 0.1.53 item D usability fixes:
224
+ * - `backend` is optional; when omitted, the schedule id's on-disk
225
+ * `backend` field is honoured (fallback: OS-native detect).
226
+ * - `schedId` accepts the label form (`com.<agent>.<id>`) as well as
227
+ * the bare id, so operators pasting the label from `scheduler list`
228
+ * no longer hit `Unknown backend: undefined`.
229
+ *
124
230
  * Same BUG-2-class fix as install(): this used to have no return
125
231
  * statement, so the CLI wrapper's `result.ok` check was reading
126
232
  * `undefined.ok`. Every path now returns a result object.
127
233
  *
128
- * @param {string} schedId - Schedule identifier
129
- * @param {string} backend - Backend name (cron, launchd, systemd, schtasks)
234
+ * @param {string} schedId - Schedule identifier or `com.<agent>.<id>` label
235
+ * @param {string} [backend] - Backend name (cron, launchd, systemd, schtasks).
236
+ * Omit to resolve from the schedule file (with OS-native detect as fallback).
130
237
  * @param {object} [options]
131
238
  * @param {boolean} [options.deleteFile=false] - Also delete the schedule JSON file
132
239
  * @param {string} [options.agent] - Explicit agent override. Falls
133
240
  * back to the invoking instance's `paName` from preferences.json.
134
241
  * @param {string} [options.repoRoot] - Optional instance root override
135
242
  * (tests inject; production callers omit this).
136
- * @returns {{ ok: boolean, id: string, backend: string, agent: string }}
243
+ * @returns {{ ok: boolean, id: string, backend: string, agent: string, accepted: 'id'|'label' }}
137
244
  */
138
245
  export function remove(schedId, backend, options = {}) {
139
- // See list.mjs for the same ordering rationale: backend validation
140
- // must run BEFORE agent resolution so an unknown backend is a
141
- // usage-shaped error, not a scheduler-config-shaped one.
142
- if (backend !== 'cron' && backend !== 'launchd' && backend !== 'systemd' && backend !== 'schtasks') {
143
- throw new Error(`Unknown backend: ${backend}. Supported: cron, launchd, systemd, schtasks`);
144
- }
145
246
  const { agent } = resolveAgent({ agent: options.agent, repoRoot: options.repoRoot });
247
+ const { id, accepted } = normaliseScheduleArg(schedId, agent);
248
+ const resolvedBackend = backend || resolveBackendFromDisk(id, options);
249
+ if (resolvedBackend !== 'cron' && resolvedBackend !== 'launchd' && resolvedBackend !== 'systemd' && resolvedBackend !== 'schtasks') {
250
+ throw new Error(`Unknown backend: ${resolvedBackend}. Supported: cron, launchd, systemd, schtasks`);
251
+ }
146
252
 
147
- switch (backend) {
148
- case 'cron': removeCron(schedId, agent); break;
149
- case 'launchd': removeLaunchd(schedId, agent); break;
150
- case 'systemd': removeSystemd(schedId, agent); break;
151
- case 'schtasks': removeSchtasks(schedId, agent); break;
253
+ switch (resolvedBackend) {
254
+ case 'cron': removeCron(id, agent); break;
255
+ case 'launchd': removeLaunchd(id, agent); break;
256
+ case 'systemd': removeSystemd(id, agent); break;
257
+ case 'schtasks': removeSchtasks(id, agent); break;
152
258
  }
153
259
 
154
260
  if (options.deleteFile) {
155
- const daveRoot = process.env.DAVE_ROOT || process.env.PA_ROOT || options.repoRoot || findRepoRootSync();
156
- const schedFile = join(daveRoot, 'schedules', `${schedId}.json`);
261
+ const schedFile = join(schedulesRoot(options), `${id}.json`);
157
262
  if (existsSync(schedFile)) {
158
263
  unlinkSync(schedFile);
159
264
  console.log(`Deleted schedule file: ${schedFile}`);
160
265
  }
161
266
  }
162
267
 
163
- return { ok: true, id: schedId, backend, agent };
268
+ return { ok: true, id, backend: resolvedBackend, agent, accepted };
164
269
  }
165
270
 
166
271
  // ── CLI entry point ──────────────────────────────────────────────────
@@ -37,6 +37,7 @@ import {
37
37
  withLaunchdDefaults,
38
38
  buildCronCommand,
39
39
  detectNvm,
40
+ schedAgent,
40
41
  } from './install.mjs';
41
42
  import { detect } from './detect.mjs';
42
43
 
@@ -289,7 +290,7 @@ export async function verify(scheduleFile, opts = {}) {
289
290
  if (backend === 'cron') {
290
291
  const explicit = resolveEnvironment(sched);
291
292
  const hasNvm = opts.hasNvm ?? detectNvm();
292
- const logPath = opts.cronLogPath ?? `/tmp/${sched.agent || 'dave'}-${sched.id}-verify.log`;
293
+ const logPath = opts.cronLogPath ?? `/tmp/${schedAgent(sched)}-${sched.id}-verify.log`;
293
294
  const { inner } = buildCronCommand(sched, workDir, explicit, { hasNvm, logPath });
294
295
  run = await runVerifyArgv('bash', ['-c', inner], workDir, env, timeoutMs);
295
296
  } else {