@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.
- package/dist/engineering-pa-cli/src/scheduler/cli.mjs +7 -2
- package/dist/engineering-pa-core/docs/commands/onboard.md +27 -5
- package/dist/engineering-pa-core/roles/agentic-workflow-designer/definition.md +53 -6
- package/dist/engineering-pa-core/src/scheduler/install.mjs +73 -25
- package/dist/engineering-pa-core/src/scheduler/list.mjs +12 -2
- package/dist/engineering-pa-core/src/scheduler/remove.mjs +123 -18
- package/dist/engineering-pa-core/src/scheduler/verify.mjs +2 -1
- package/dist/engineering-pa-scaffold/src/generator.mjs +52 -6
- package/dist/engineering-pa-scaffold/templates/CLAUDE.md.template +15 -0
- package/dist/engineering-pa-workspace/app/nav.json +6 -0
- package/dist/engineering-pa-workspace/app/review-annotator.js +1390 -0
- package/dist/engineering-pa-workspace/app/reviews.html +534 -0
- package/dist/engineering-pa-workspace/src/review/api-handler.mjs +836 -0
- package/dist/engineering-pa-workspace/src/review/page-key.mjs +92 -0
- package/dist/engineering-pa-workspace/src/server.mjs +34 -22
- package/dist/engineering-pa-workspace/src/ws.mjs +47 -0
- package/package.json +1 -1
|
@@ -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
|
|
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.
|
|
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`,
|
|
88
|
-
the placeholder. Keep it a short human-readable paragraph
|
|
89
|
-
of preferences.
|
|
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 |
|
|
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
|
|
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:`
|
|
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,
|
|
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
|
-
|
|
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
|
-
/**
|
|
29
|
-
|
|
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.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
|
|
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`
|
|
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
|
|
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
|
|
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
|
-
|
|
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 ("
|
|
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
|
|
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
|
|
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 =
|
|
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 = `#
|
|
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:
|
|
495
|
+
console.log(`Installed cron schedule: ${agent}-${sched.id}`);
|
|
448
496
|
console.log(` ${sched.schedule} -- ${sched.description || ''}`);
|
|
449
497
|
|
|
450
|
-
return { tag:
|
|
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
|
|
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
|
|
787
|
+
<string>/tmp/${schedAgent(sched)}-${sched.id}-stdout.log</string>
|
|
740
788
|
<key>StandardErrorPath</key>
|
|
741
|
-
<string>/tmp
|
|
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 =
|
|
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 =
|
|
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(` ${
|
|
109
|
-
lines.push(`
|
|
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 (
|
|
148
|
-
case 'cron': removeCron(
|
|
149
|
-
case 'launchd': removeLaunchd(
|
|
150
|
-
case 'systemd': removeSystemd(
|
|
151
|
-
case 'schtasks': removeSchtasks(
|
|
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
|
|
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
|
|
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
|
|
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 {
|