@codyswann/lisa 2.88.0 → 2.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -82,7 +82,7 @@
82
82
  "lodash": ">=4.18.1"
83
83
  },
84
84
  "name": "@codyswann/lisa",
85
- "version": "2.88.0",
85
+ "version": "2.89.0",
86
86
  "description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
87
87
  "main": "dist/index.js",
88
88
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.88.0",
3
+ "version": "2.89.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.88.0",
3
+ "version": "2.89.0",
4
4
  "description": "Universal governance: agents, skills, commands, hooks, and rules for all projects.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Shared automation-status contract drift helpers.
4
+ *
5
+ * Runtime adapters resolve the expected Lisa automation fleet, list the live
6
+ * scheduler entries, then use this module to find the best observed match for
7
+ * each expected automation and classify any contract drift.
8
+ */
9
+
10
+ const DRIFT_LABELS = {
11
+ name: "name",
12
+ cadence: "cadence",
13
+ command: "command",
14
+ queue_arguments: "queue arguments",
15
+ };
16
+
17
+ /**
18
+ * @typedef {{
19
+ * readonly automationId: string
20
+ * readonly expectedCadence?: string
21
+ * readonly expectedRRule?: string
22
+ * readonly expectedCommand: string
23
+ * }} ExpectedAutomationContract
24
+ *
25
+ * @typedef {{
26
+ * readonly automationId: string
27
+ * readonly observedCadence?: string
28
+ * readonly observedRRule?: string
29
+ * readonly observedCommand?: string
30
+ * }} ObservedAutomationContract
31
+ *
32
+ * @typedef {{
33
+ * readonly status: "HEALTHY" | "MISSING" | "DRIFTED"
34
+ * readonly summary: string
35
+ * readonly observed: string
36
+ * readonly remediation?: string
37
+ * readonly driftKinds: readonly ("name" | "cadence" | "command" | "queue_arguments")[]
38
+ * readonly observedAutomation: ObservedAutomationContract | null
39
+ * }} AutomationContractComparison
40
+ */
41
+
42
+ /**
43
+ * Find the best observed scheduler entry for an expected automation contract.
44
+ *
45
+ * Match order is:
46
+ * 1. Exact automation id
47
+ * 2. Exact command shape and cadence
48
+ * 3. Exact command shape
49
+ * 4. Same command entrypoint
50
+ *
51
+ * @param {ExpectedAutomationContract} expected
52
+ * @param {readonly ObservedAutomationContract[]} observedAutomations
53
+ * @returns {ObservedAutomationContract | null}
54
+ */
55
+ export function findObservedAutomationMatch(
56
+ expected,
57
+ observedAutomations = []
58
+ ) {
59
+ const exactId = observedAutomations.find(
60
+ observed => observed.automationId === expected.automationId
61
+ );
62
+ if (exactId) {
63
+ return exactId;
64
+ }
65
+
66
+ const expectedCommand = normalizeAutomationCommand(expected.expectedCommand);
67
+ const expectedCadence = normalizeCadenceSignature({
68
+ cadence: expected.expectedCadence,
69
+ rrule: expected.expectedRRule,
70
+ });
71
+
72
+ const exactContract = observedAutomations.find(observed => {
73
+ const observedCommand = normalizeAutomationCommand(
74
+ observed.observedCommand
75
+ );
76
+ const observedCadence = normalizeCadenceSignature({
77
+ cadence: observed.observedCadence,
78
+ rrule: observed.observedRRule,
79
+ });
80
+
81
+ return (
82
+ observedCommand.commandSignature === expectedCommand.commandSignature &&
83
+ observedCadence === expectedCadence
84
+ );
85
+ });
86
+ if (exactContract) {
87
+ return exactContract;
88
+ }
89
+
90
+ const exactCommand = observedAutomations.find(observed => {
91
+ const observedCommand = normalizeAutomationCommand(
92
+ observed.observedCommand
93
+ );
94
+ return (
95
+ observedCommand.commandSignature === expectedCommand.commandSignature
96
+ );
97
+ });
98
+ if (exactCommand) {
99
+ return exactCommand;
100
+ }
101
+
102
+ return (
103
+ observedAutomations.find(observed => {
104
+ const observedCommand = normalizeAutomationCommand(
105
+ observed.observedCommand
106
+ );
107
+ return observedCommand.commandToken === expectedCommand.commandToken;
108
+ }) ?? null
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Compare one expected automation contract against an observed scheduler entry.
114
+ *
115
+ * @param {{
116
+ * readonly expected: ExpectedAutomationContract
117
+ * readonly observedAutomations?: readonly ObservedAutomationContract[]
118
+ * readonly observedAutomation?: ObservedAutomationContract | null
119
+ * }} input
120
+ * @returns {AutomationContractComparison}
121
+ */
122
+ export function compareAutomationContract(input) {
123
+ const expected = input.expected;
124
+ const observed =
125
+ input.observedAutomation ??
126
+ findObservedAutomationMatch(expected, input.observedAutomations ?? []);
127
+
128
+ if (!observed) {
129
+ return {
130
+ status: "MISSING",
131
+ summary: "expected automation is missing",
132
+ observed: "No live automation matched the expected Lisa contract.",
133
+ remediation:
134
+ "Re-run `/lisa:setup-automations` or recreate the missing scheduler entry.",
135
+ driftKinds: [],
136
+ observedAutomation: null,
137
+ };
138
+ }
139
+
140
+ const driftKinds = detectDriftKinds(expected, observed);
141
+ const observedSummary = describeObservedAutomation(observed);
142
+
143
+ if (driftKinds.length === 0) {
144
+ return {
145
+ status: "HEALTHY",
146
+ summary: "expected automation exists and matches the contract",
147
+ observed: observedSummary,
148
+ driftKinds,
149
+ observedAutomation: observed,
150
+ };
151
+ }
152
+
153
+ return {
154
+ status: "DRIFTED",
155
+ summary: formatDriftSummary(driftKinds),
156
+ observed: observedSummary,
157
+ remediation:
158
+ "Re-run `/lisa:setup-automations` or update the scheduler entry to the expected command and cadence.",
159
+ driftKinds,
160
+ observedAutomation: observed,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * @param {ExpectedAutomationContract} expected
166
+ * @param {ObservedAutomationContract} observed
167
+ * @returns {readonly ("name" | "cadence" | "command" | "queue_arguments")[]}
168
+ */
169
+ function detectDriftKinds(expected, observed) {
170
+ const driftKinds = [];
171
+
172
+ if (observed.automationId !== expected.automationId) {
173
+ driftKinds.push("name");
174
+ }
175
+
176
+ const expectedCadence = normalizeCadenceSignature({
177
+ cadence: expected.expectedCadence,
178
+ rrule: expected.expectedRRule,
179
+ });
180
+ const observedCadence = normalizeCadenceSignature({
181
+ cadence: observed.observedCadence,
182
+ rrule: observed.observedRRule,
183
+ });
184
+
185
+ if (expectedCadence !== observedCadence) {
186
+ driftKinds.push("cadence");
187
+ }
188
+
189
+ const expectedCommand = normalizeAutomationCommand(expected.expectedCommand);
190
+ const observedCommand = normalizeAutomationCommand(observed.observedCommand);
191
+
192
+ if (expectedCommand.commandToken !== observedCommand.commandToken) {
193
+ driftKinds.push("command");
194
+ }
195
+
196
+ if (expectedCommand.queueSignature !== observedCommand.queueSignature) {
197
+ driftKinds.push("queue_arguments");
198
+ }
199
+
200
+ return driftKinds;
201
+ }
202
+
203
+ /**
204
+ * @param {ObservedAutomationContract} observed
205
+ * @returns {string}
206
+ */
207
+ function describeObservedAutomation(observed) {
208
+ const name = observed.automationId || "unnamed automation";
209
+ const cadence =
210
+ observed.observedCadence ?? observed.observedRRule ?? "cadence unavailable";
211
+ const command = observed.observedCommand ?? "command unavailable";
212
+ return `${name} runs ${cadence} -> ${command}`;
213
+ }
214
+
215
+ /**
216
+ * @param {readonly ("name" | "cadence" | "command" | "queue_arguments")[]} driftKinds
217
+ * @returns {string}
218
+ */
219
+ function formatDriftKinds(driftKinds) {
220
+ const labels = driftKinds.map(kind => DRIFT_LABELS[kind]);
221
+ if (labels.length === 0) {
222
+ return "contract details";
223
+ }
224
+ if (labels.length === 1) {
225
+ return labels[0];
226
+ }
227
+ if (labels.length === 2) {
228
+ return `${labels[0]} and ${labels[1]}`;
229
+ }
230
+ return `${labels.slice(0, -1).join(", ")}, and ${labels.at(-1)}`;
231
+ }
232
+
233
+ /**
234
+ * @param {readonly ("name" | "cadence" | "command" | "queue_arguments")[]} driftKinds
235
+ * @returns {string}
236
+ */
237
+ function formatDriftSummary(driftKinds) {
238
+ const subject = formatDriftKinds(driftKinds);
239
+ return driftKinds.length === 1
240
+ ? `${subject} no longer matches setup`
241
+ : `${subject} no longer match setup`;
242
+ }
243
+
244
+ /**
245
+ * @param {string | undefined} command
246
+ * @returns {{ readonly commandToken: string, readonly commandSignature: string, readonly queueSignature: string }}
247
+ */
248
+ function normalizeAutomationCommand(command) {
249
+ const tokens = tokenizeCommand(command);
250
+ const [commandToken = "", ...queueTokens] = tokens;
251
+
252
+ return {
253
+ commandToken,
254
+ commandSignature: serializeCommandTokens(tokens),
255
+ queueSignature: serializeQueueTokens(queueTokens),
256
+ };
257
+ }
258
+
259
+ /**
260
+ * @param {{ readonly cadence?: string, readonly rrule?: string }} input
261
+ * @returns {string}
262
+ */
263
+ function normalizeCadenceSignature(input) {
264
+ if (typeof input.rrule === "string" && input.rrule.trim().length > 0) {
265
+ return input.rrule.trim().toUpperCase();
266
+ }
267
+
268
+ if (typeof input.cadence === "string" && input.cadence.trim().length > 0) {
269
+ return input.cadence.trim().toLowerCase().replace(/\s+/g, " ");
270
+ }
271
+
272
+ return "";
273
+ }
274
+
275
+ /**
276
+ * @param {readonly string[]} tokens
277
+ * @returns {string}
278
+ */
279
+ function serializeCommandTokens(tokens) {
280
+ return tokens.join("\u0000");
281
+ }
282
+
283
+ /**
284
+ * Positional queue args stay ordered, while key=value arguments are sorted by
285
+ * key so semantically-equivalent scheduler strings do not false-positive.
286
+ *
287
+ * @param {readonly string[]} queueTokens
288
+ * @returns {string}
289
+ */
290
+ function serializeQueueTokens(queueTokens) {
291
+ const positional = [];
292
+ const keyed = [];
293
+
294
+ for (const token of queueTokens) {
295
+ if (token.includes("=")) {
296
+ const [key, ...valueParts] = token.split("=");
297
+ keyed.push(`${key}=${valueParts.join("=")}`);
298
+ continue;
299
+ }
300
+ positional.push(token);
301
+ }
302
+
303
+ keyed.sort((left, right) => left.localeCompare(right));
304
+ return [...positional, ...keyed].join("\u0000");
305
+ }
306
+
307
+ /**
308
+ * Tokenize simple scheduler commands while preserving quoted values.
309
+ *
310
+ * @param {string | undefined} command
311
+ * @returns {readonly string[]}
312
+ */
313
+ function tokenizeCommand(command) {
314
+ if (typeof command !== "string" || command.trim().length === 0) {
315
+ return [];
316
+ }
317
+
318
+ const tokens = [];
319
+ const pattern = /"([^"]*)"|'([^']*)'|[^\s]+/g;
320
+ let match = pattern.exec(command);
321
+
322
+ while (match) {
323
+ tokens.push(match[1] ?? match[2] ?? match[0]);
324
+ match = pattern.exec(command);
325
+ }
326
+
327
+ return tokens;
328
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.88.0",
3
+ "version": "2.89.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.88.0",
3
+ "version": "2.89.0",
4
4
  "description": "AWS CDK-specific Lisa plugin.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.88.0",
3
+ "version": "2.89.0",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.88.0",
3
+ "version": "2.89.0",
4
4
  "description": "Expo and React Native-specific skills, agents, rules, and MCP servers.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.88.0",
3
+ "version": "2.89.0",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.88.0",
3
+ "version": "2.89.0",
4
4
  "description": "Harper/Fabric-specific Lisa rules for TypeScript component apps.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.88.0",
3
+ "version": "2.89.0",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.88.0",
3
+ "version": "2.89.0",
4
4
  "description": "NestJS-specific skills and migration write-protection hooks.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.88.0",
3
+ "version": "2.89.0",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.88.0",
3
+ "version": "2.89.0",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.88.0",
3
+ "version": "2.89.0",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.88.0",
3
+ "version": "2.89.0",
4
4
  "description": "Ruby on Rails-specific skills and hooks for RuboCop and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.88.0",
3
+ "version": "2.89.0",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.88.0",
3
+ "version": "2.89.0",
4
4
  "description": "TypeScript-specific hooks for formatting, linting, and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.88.0",
3
+ "version": "2.89.0",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.88.0",
3
+ "version": "2.89.0",
4
4
  "description": "Distributable LLM Wiki kernel — ingest, query, lint, and maintain a git-native markdown knowledge base across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Shared automation-status contract drift helpers.
4
+ *
5
+ * Runtime adapters resolve the expected Lisa automation fleet, list the live
6
+ * scheduler entries, then use this module to find the best observed match for
7
+ * each expected automation and classify any contract drift.
8
+ */
9
+
10
+ const DRIFT_LABELS = {
11
+ name: "name",
12
+ cadence: "cadence",
13
+ command: "command",
14
+ queue_arguments: "queue arguments",
15
+ };
16
+
17
+ /**
18
+ * @typedef {{
19
+ * readonly automationId: string
20
+ * readonly expectedCadence?: string
21
+ * readonly expectedRRule?: string
22
+ * readonly expectedCommand: string
23
+ * }} ExpectedAutomationContract
24
+ *
25
+ * @typedef {{
26
+ * readonly automationId: string
27
+ * readonly observedCadence?: string
28
+ * readonly observedRRule?: string
29
+ * readonly observedCommand?: string
30
+ * }} ObservedAutomationContract
31
+ *
32
+ * @typedef {{
33
+ * readonly status: "HEALTHY" | "MISSING" | "DRIFTED"
34
+ * readonly summary: string
35
+ * readonly observed: string
36
+ * readonly remediation?: string
37
+ * readonly driftKinds: readonly ("name" | "cadence" | "command" | "queue_arguments")[]
38
+ * readonly observedAutomation: ObservedAutomationContract | null
39
+ * }} AutomationContractComparison
40
+ */
41
+
42
+ /**
43
+ * Find the best observed scheduler entry for an expected automation contract.
44
+ *
45
+ * Match order is:
46
+ * 1. Exact automation id
47
+ * 2. Exact command shape and cadence
48
+ * 3. Exact command shape
49
+ * 4. Same command entrypoint
50
+ *
51
+ * @param {ExpectedAutomationContract} expected
52
+ * @param {readonly ObservedAutomationContract[]} observedAutomations
53
+ * @returns {ObservedAutomationContract | null}
54
+ */
55
+ export function findObservedAutomationMatch(
56
+ expected,
57
+ observedAutomations = []
58
+ ) {
59
+ const exactId = observedAutomations.find(
60
+ observed => observed.automationId === expected.automationId
61
+ );
62
+ if (exactId) {
63
+ return exactId;
64
+ }
65
+
66
+ const expectedCommand = normalizeAutomationCommand(expected.expectedCommand);
67
+ const expectedCadence = normalizeCadenceSignature({
68
+ cadence: expected.expectedCadence,
69
+ rrule: expected.expectedRRule,
70
+ });
71
+
72
+ const exactContract = observedAutomations.find(observed => {
73
+ const observedCommand = normalizeAutomationCommand(
74
+ observed.observedCommand
75
+ );
76
+ const observedCadence = normalizeCadenceSignature({
77
+ cadence: observed.observedCadence,
78
+ rrule: observed.observedRRule,
79
+ });
80
+
81
+ return (
82
+ observedCommand.commandSignature === expectedCommand.commandSignature &&
83
+ observedCadence === expectedCadence
84
+ );
85
+ });
86
+ if (exactContract) {
87
+ return exactContract;
88
+ }
89
+
90
+ const exactCommand = observedAutomations.find(observed => {
91
+ const observedCommand = normalizeAutomationCommand(
92
+ observed.observedCommand
93
+ );
94
+ return (
95
+ observedCommand.commandSignature === expectedCommand.commandSignature
96
+ );
97
+ });
98
+ if (exactCommand) {
99
+ return exactCommand;
100
+ }
101
+
102
+ return (
103
+ observedAutomations.find(observed => {
104
+ const observedCommand = normalizeAutomationCommand(
105
+ observed.observedCommand
106
+ );
107
+ return observedCommand.commandToken === expectedCommand.commandToken;
108
+ }) ?? null
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Compare one expected automation contract against an observed scheduler entry.
114
+ *
115
+ * @param {{
116
+ * readonly expected: ExpectedAutomationContract
117
+ * readonly observedAutomations?: readonly ObservedAutomationContract[]
118
+ * readonly observedAutomation?: ObservedAutomationContract | null
119
+ * }} input
120
+ * @returns {AutomationContractComparison}
121
+ */
122
+ export function compareAutomationContract(input) {
123
+ const expected = input.expected;
124
+ const observed =
125
+ input.observedAutomation ??
126
+ findObservedAutomationMatch(expected, input.observedAutomations ?? []);
127
+
128
+ if (!observed) {
129
+ return {
130
+ status: "MISSING",
131
+ summary: "expected automation is missing",
132
+ observed: "No live automation matched the expected Lisa contract.",
133
+ remediation:
134
+ "Re-run `/lisa:setup-automations` or recreate the missing scheduler entry.",
135
+ driftKinds: [],
136
+ observedAutomation: null,
137
+ };
138
+ }
139
+
140
+ const driftKinds = detectDriftKinds(expected, observed);
141
+ const observedSummary = describeObservedAutomation(observed);
142
+
143
+ if (driftKinds.length === 0) {
144
+ return {
145
+ status: "HEALTHY",
146
+ summary: "expected automation exists and matches the contract",
147
+ observed: observedSummary,
148
+ driftKinds,
149
+ observedAutomation: observed,
150
+ };
151
+ }
152
+
153
+ return {
154
+ status: "DRIFTED",
155
+ summary: formatDriftSummary(driftKinds),
156
+ observed: observedSummary,
157
+ remediation:
158
+ "Re-run `/lisa:setup-automations` or update the scheduler entry to the expected command and cadence.",
159
+ driftKinds,
160
+ observedAutomation: observed,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * @param {ExpectedAutomationContract} expected
166
+ * @param {ObservedAutomationContract} observed
167
+ * @returns {readonly ("name" | "cadence" | "command" | "queue_arguments")[]}
168
+ */
169
+ function detectDriftKinds(expected, observed) {
170
+ const driftKinds = [];
171
+
172
+ if (observed.automationId !== expected.automationId) {
173
+ driftKinds.push("name");
174
+ }
175
+
176
+ const expectedCadence = normalizeCadenceSignature({
177
+ cadence: expected.expectedCadence,
178
+ rrule: expected.expectedRRule,
179
+ });
180
+ const observedCadence = normalizeCadenceSignature({
181
+ cadence: observed.observedCadence,
182
+ rrule: observed.observedRRule,
183
+ });
184
+
185
+ if (expectedCadence !== observedCadence) {
186
+ driftKinds.push("cadence");
187
+ }
188
+
189
+ const expectedCommand = normalizeAutomationCommand(expected.expectedCommand);
190
+ const observedCommand = normalizeAutomationCommand(observed.observedCommand);
191
+
192
+ if (expectedCommand.commandToken !== observedCommand.commandToken) {
193
+ driftKinds.push("command");
194
+ }
195
+
196
+ if (expectedCommand.queueSignature !== observedCommand.queueSignature) {
197
+ driftKinds.push("queue_arguments");
198
+ }
199
+
200
+ return driftKinds;
201
+ }
202
+
203
+ /**
204
+ * @param {ObservedAutomationContract} observed
205
+ * @returns {string}
206
+ */
207
+ function describeObservedAutomation(observed) {
208
+ const name = observed.automationId || "unnamed automation";
209
+ const cadence =
210
+ observed.observedCadence ?? observed.observedRRule ?? "cadence unavailable";
211
+ const command = observed.observedCommand ?? "command unavailable";
212
+ return `${name} runs ${cadence} -> ${command}`;
213
+ }
214
+
215
+ /**
216
+ * @param {readonly ("name" | "cadence" | "command" | "queue_arguments")[]} driftKinds
217
+ * @returns {string}
218
+ */
219
+ function formatDriftKinds(driftKinds) {
220
+ const labels = driftKinds.map(kind => DRIFT_LABELS[kind]);
221
+ if (labels.length === 0) {
222
+ return "contract details";
223
+ }
224
+ if (labels.length === 1) {
225
+ return labels[0];
226
+ }
227
+ if (labels.length === 2) {
228
+ return `${labels[0]} and ${labels[1]}`;
229
+ }
230
+ return `${labels.slice(0, -1).join(", ")}, and ${labels.at(-1)}`;
231
+ }
232
+
233
+ /**
234
+ * @param {readonly ("name" | "cadence" | "command" | "queue_arguments")[]} driftKinds
235
+ * @returns {string}
236
+ */
237
+ function formatDriftSummary(driftKinds) {
238
+ const subject = formatDriftKinds(driftKinds);
239
+ return driftKinds.length === 1
240
+ ? `${subject} no longer matches setup`
241
+ : `${subject} no longer match setup`;
242
+ }
243
+
244
+ /**
245
+ * @param {string | undefined} command
246
+ * @returns {{ readonly commandToken: string, readonly commandSignature: string, readonly queueSignature: string }}
247
+ */
248
+ function normalizeAutomationCommand(command) {
249
+ const tokens = tokenizeCommand(command);
250
+ const [commandToken = "", ...queueTokens] = tokens;
251
+
252
+ return {
253
+ commandToken,
254
+ commandSignature: serializeCommandTokens(tokens),
255
+ queueSignature: serializeQueueTokens(queueTokens),
256
+ };
257
+ }
258
+
259
+ /**
260
+ * @param {{ readonly cadence?: string, readonly rrule?: string }} input
261
+ * @returns {string}
262
+ */
263
+ function normalizeCadenceSignature(input) {
264
+ if (typeof input.rrule === "string" && input.rrule.trim().length > 0) {
265
+ return input.rrule.trim().toUpperCase();
266
+ }
267
+
268
+ if (typeof input.cadence === "string" && input.cadence.trim().length > 0) {
269
+ return input.cadence.trim().toLowerCase().replace(/\s+/g, " ");
270
+ }
271
+
272
+ return "";
273
+ }
274
+
275
+ /**
276
+ * @param {readonly string[]} tokens
277
+ * @returns {string}
278
+ */
279
+ function serializeCommandTokens(tokens) {
280
+ return tokens.join("\u0000");
281
+ }
282
+
283
+ /**
284
+ * Positional queue args stay ordered, while key=value arguments are sorted by
285
+ * key so semantically-equivalent scheduler strings do not false-positive.
286
+ *
287
+ * @param {readonly string[]} queueTokens
288
+ * @returns {string}
289
+ */
290
+ function serializeQueueTokens(queueTokens) {
291
+ const positional = [];
292
+ const keyed = [];
293
+
294
+ for (const token of queueTokens) {
295
+ if (token.includes("=")) {
296
+ const [key, ...valueParts] = token.split("=");
297
+ keyed.push(`${key}=${valueParts.join("=")}`);
298
+ continue;
299
+ }
300
+ positional.push(token);
301
+ }
302
+
303
+ keyed.sort((left, right) => left.localeCompare(right));
304
+ return [...positional, ...keyed].join("\u0000");
305
+ }
306
+
307
+ /**
308
+ * Tokenize simple scheduler commands while preserving quoted values.
309
+ *
310
+ * @param {string | undefined} command
311
+ * @returns {readonly string[]}
312
+ */
313
+ function tokenizeCommand(command) {
314
+ if (typeof command !== "string" || command.trim().length === 0) {
315
+ return [];
316
+ }
317
+
318
+ const tokens = [];
319
+ const pattern = /"([^"]*)"|'([^']*)'|[^\s]+/g;
320
+ let match = pattern.exec(command);
321
+
322
+ while (match) {
323
+ tokens.push(match[1] ?? match[2] ?? match[0]);
324
+ match = pattern.exec(command);
325
+ }
326
+
327
+ return tokens;
328
+ }