@agentpulselabs/cli-plugin-pulse 0.1.0 → 1.0.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.
Files changed (68) hide show
  1. package/README.md +69 -32
  2. package/package.json +10 -10
  3. package/src/commands/pulse/doctor.js +14 -0
  4. package/src/commands/pulse/hub-write.js +26 -0
  5. package/src/commands/pulse/install.js +14 -0
  6. package/src/commands/pulse/login.js +20 -0
  7. package/src/commands/pulse/mirror.js +27 -0
  8. package/src/commands/pulse/pair.js +38 -0
  9. package/src/commands/pulse/restart.js +14 -0
  10. package/src/commands/pulse/start.js +8 -18
  11. package/src/commands/pulse/status.js +8 -7
  12. package/src/commands/pulse/stop.js +24 -0
  13. package/src/daemon/main.js +36 -0
  14. package/src/lib/BaseCommand.js +37 -0
  15. package/src/lib/agentSessions.js +279 -0
  16. package/src/lib/application.js +656 -0
  17. package/src/lib/backoff.js +8 -0
  18. package/src/lib/budgets.js +85 -0
  19. package/src/lib/cliDefaults.js +20 -0
  20. package/src/lib/cloudAuth.js +189 -0
  21. package/src/lib/contract.sha256 +2 -0
  22. package/src/lib/daemonComposition.js +338 -0
  23. package/src/lib/daemonLock.js +55 -0
  24. package/src/lib/daemonLoop.js +69 -0
  25. package/src/lib/daemonProcess.js +366 -0
  26. package/src/lib/daemonRuntime.js +820 -0
  27. package/src/lib/drivers.js +280 -0
  28. package/src/lib/errors.js +19 -0
  29. package/src/lib/graphWindows.js +149 -0
  30. package/src/lib/http.js +84 -0
  31. package/src/lib/hubActions.js +74 -0
  32. package/src/lib/identity.js +102 -0
  33. package/src/lib/intents.js +590 -0
  34. package/src/lib/ledger.js +535 -0
  35. package/src/lib/lifecycle.js +123 -0
  36. package/src/lib/localHub.js +217 -0
  37. package/src/lib/localHubWriter.js +69 -0
  38. package/src/lib/mirror-contract.json +4299 -0
  39. package/src/lib/mirrorContract.js +513 -0
  40. package/src/lib/mirrorDaemon.js +127 -0
  41. package/src/lib/mirrorService.js +358 -0
  42. package/src/lib/mirrorSource.js +455 -0
  43. package/src/lib/mirrorSync.js +353 -0
  44. package/src/lib/oauthLogin.js +132 -0
  45. package/src/lib/ownerLock.js +99 -0
  46. package/src/lib/pairedConfig.js +40 -0
  47. package/src/lib/pairingLifecycle.js +179 -0
  48. package/src/lib/pathRedaction.js +89 -0
  49. package/src/lib/planContract.js +141 -0
  50. package/src/lib/priorityLane.js +80 -0
  51. package/src/lib/processOwnership.js +252 -0
  52. package/src/lib/processSupervisor.js +376 -0
  53. package/src/lib/redaction.js +128 -0
  54. package/src/lib/rootPolicy.js +372 -0
  55. package/src/lib/rulePacks.js +162 -0
  56. package/src/lib/secretStore.js +169 -0
  57. package/src/lib/selfRestart.js +261 -0
  58. package/src/lib/serviceManager.js +358 -0
  59. package/src/lib/storage.js +131 -0
  60. package/src/lib/subprocess.js +45 -0
  61. package/src/lib/tokenStore.js +97 -0
  62. package/src/lib/transport.js +286 -0
  63. package/src/lib/transportContract.js +37 -0
  64. package/src/commands/pulse/gate.js +0 -23
  65. package/src/commands/pulse/next.js +0 -13
  66. package/src/commands/pulse/submit.js +0 -19
  67. package/src/commands/pulse/verify.js +0 -18
  68. package/src/runPulse.js +0 -58
@@ -0,0 +1,286 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises';
2
+ import { fullJitterDelay } from './backoff.js';
3
+ import { loginRequired } from './cloudAuth.js';
4
+ import { HubError } from './errors.js';
5
+ import { cleanOrigin, fetchJson } from './http.js';
6
+ import { CONTRACT_HEADER, CONTRACT_VERSION, MIRROR_LIMITS, MIRROR_PATH, parseAccepts } from './mirrorContract.js';
7
+ import { PriorityLane } from './priorityLane.js';
8
+ import {
9
+ CONNECTOR_API_BASE,
10
+ CONNECTOR_RESPONSE_MAX_BYTES,
11
+ MACHINE_ID_HEADER,
12
+ POLICY_VERSION_HEADER,
13
+ SESSION_ID_HEADER,
14
+ } from './transportContract.js';
15
+
16
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
17
+
18
+ const CLAIMED_INTEGERS = ['attempt_no', 'fence_token', 'policy_version', 'attemptNo', 'fenceToken', 'policyVersion'];
19
+ const REQUEST_TIMEOUT_MS = 15_000;
20
+ const MIRROR_TIMEOUT_MS = 30_000;
21
+ // The platform's JSON parser inflates gzip bodies; the contract's bodyBytes limit still applies to the JSON.
22
+ const MIRROR_GZIP_ABOVE_BYTES = 8 * 1024;
23
+ // The cloud holds a claim up to waitSeconds before answering; the request waits that long plus this much.
24
+ const CLAIM_TIMEOUT_MARGIN_MS = 10_000;
25
+
26
+ /**
27
+ * A claimed job with its integers as numbers. The cloud returns agent_hub_jobs rows as node-pg serialises them, so the
28
+ * bigint fence_token arrives as a decimal string; anything that is not a plain integer stays as sent for the ledger to
29
+ * refuse.
30
+ */
31
+ function claimedJob(job) {
32
+ if (!job || typeof job !== 'object') return job;
33
+ const out = { ...job };
34
+ for (const key of CLAIMED_INTEGERS) {
35
+ if (typeof out[key] === 'string' && /^[0-9]{1,15}$/.test(out[key])) out[key] = Number(out[key]);
36
+ }
37
+ return out;
38
+ }
39
+
40
+ const SERVER_TIMING_NAMES = { lock: 'lockMs', db: 'dbMs', total: 'totalMs' };
41
+
42
+ /** `lock;dur=2.5, db;dur=31, total;dur=48` as { lockMs, dbMs, totalMs }; metrics without a duration are left out. */
43
+ export function parseServerTiming(header) {
44
+ const out = {};
45
+ for (const metric of String(header || '').split(',')) {
46
+ const [name, ...params] = metric.trim().split(';');
47
+ const dur = params.map((param) => param.trim().match(/^dur=([0-9.]+)$/)).find(Boolean);
48
+ if (SERVER_TIMING_NAMES[name] && dur) out[SERVER_TIMING_NAMES[name]] = Number(dur[1]);
49
+ }
50
+ return Object.keys(out).length ? out : null;
51
+ }
52
+
53
+ /** The range this connector's contract version is outside of, as a contract_unsupported error. */
54
+ export function contractUnsupported(accepts, message) {
55
+ return new HubError('contract_unsupported', message || `Pulse accepts ${accepts}; this connector speaks mirror/${CONTRACT_VERSION}`, {
56
+ details: { status: 409, accepts },
57
+ });
58
+ }
59
+
60
+ function requestBytes(payload, maxBodyBytes) {
61
+ if (Buffer.byteLength(JSON.stringify(payload), 'utf8') > maxBodyBytes) {
62
+ throw new HubError('HUB_TRANSPORT_PAYLOAD_TOO_LARGE', `Request exceeds ${maxBodyBytes} bytes`);
63
+ }
64
+ }
65
+
66
+ export class ConnectorTransport {
67
+ constructor({
68
+ baseUrl,
69
+ machine,
70
+ auth,
71
+ fetch: fetchImplementation = globalThis.fetch,
72
+ random = Math.random,
73
+ delay = sleep,
74
+ maxAttempts = 8,
75
+ onAttempt = () => {},
76
+ now = Date.now,
77
+ laneMaxWaitMs,
78
+ }) {
79
+ this.onAttempt = onAttempt;
80
+ this.now = now;
81
+ this.baseUrl = cleanOrigin(baseUrl, 'Connector base URL');
82
+ this.machine = machine;
83
+ this.auth = auth;
84
+ this.fetch = fetchImplementation;
85
+ this.random = random;
86
+ this.delay = delay;
87
+ this.maxAttempts = maxAttempts;
88
+ this.pollOutstanding = false;
89
+ // The cloud's handshake range from the last answer that named one (null until then, and from a v1 cloud).
90
+ this.contractAccepts = null;
91
+ this.lane = new PriorityLane({ now, ...(laneMaxWaitMs !== undefined ? { maxWaitMs: laneMaxWaitMs } : {}) });
92
+ }
93
+
94
+ #identityHeaders() {
95
+ return {
96
+ [MACHINE_ID_HEADER]: this.machine.machineId,
97
+ [SESSION_ID_HEADER]: this.machine.sessionId,
98
+ [POLICY_VERSION_HEADER]: String(this.machine.policyVersion),
99
+ [CONTRACT_HEADER]: `mirror/${CONTRACT_VERSION}`,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * onAttempt({ path, phase }) marks each credentials read, request and backoff, so a watchdog can see retries progress.
105
+ * trace, when given, gathers where the time went: attempts and their statuses ('error' for a failed fetch), backoff,
106
+ * reading credentials (a token refresh or dev-org mint), the JSON and wire bytes, and the cloud's Server-Timing of
107
+ * the attempt that answered.
108
+ */
109
+ async #request(path, payload, { retry = false, maxBodyBytes, timeoutMs = REQUEST_TIMEOUT_MS, gzipAboveBytes, trace = {} }) {
110
+ requestBytes(payload, maxBodyBytes);
111
+ trace.attempts = 0;
112
+ trace.statuses = [];
113
+ trace.backoffMs = 0;
114
+ trace.credentialsMs = 0;
115
+ const backoff = async (ms) => {
116
+ this.onAttempt({ path, phase: 'backoff' });
117
+ const started = this.now();
118
+ await this.delay(ms);
119
+ trace.backoffMs += this.now() - started;
120
+ };
121
+ let lastError;
122
+ let reauthenticated = false;
123
+ for (let attempt = 0; attempt < this.maxAttempts; attempt += 1) {
124
+ this.onAttempt({ path, phase: 'credentials' });
125
+ const credentialsFrom = this.now();
126
+ const credentials = await this.auth.credentials();
127
+ trace.credentialsMs += this.now() - credentialsFrom;
128
+ this.onAttempt({ path, phase: 'request' });
129
+ trace.attempts += 1;
130
+ let response;
131
+ try {
132
+ response = await fetchJson({
133
+ fetch: this.fetch,
134
+ url: `${this.baseUrl}${path}`,
135
+ headers: { ...credentials, ...this.#identityHeaders() },
136
+ body: payload,
137
+ maxBytes: CONNECTOR_RESPONSE_MAX_BYTES,
138
+ timeoutMs,
139
+ gzipAboveBytes,
140
+ });
141
+ } catch (error) {
142
+ trace.statuses.push('error');
143
+ if (error instanceof HubError || !retry) throw error;
144
+ lastError = error;
145
+ if (attempt + 1 >= this.maxAttempts) break;
146
+ await backoff(fullJitterDelay(attempt, { random: this.random }));
147
+ continue;
148
+ }
149
+ trace.statuses.push(response.status);
150
+ trace.bytes = response.bytes;
151
+ trace.sentBytes = response.sentBytes;
152
+ trace.server = parseServerTiming(response.serverTiming);
153
+ const accepts = response.contractAccepts || response.body.contractAccepts || response.body.accepts;
154
+ if (parseAccepts(accepts)) this.contractAccepts = accepts;
155
+ if (response.ok) return response.body;
156
+ if (response.status === 409 && response.body.error === 'contract_unsupported') {
157
+ throw contractUnsupported(parseAccepts(accepts) ? accepts : null, response.body.message);
158
+ }
159
+ if (response.status === 401) {
160
+ if (reauthenticated) throw loginRequired('the cloud refused the refreshed credentials');
161
+ reauthenticated = true;
162
+ this.auth.invalidate();
163
+ continue;
164
+ }
165
+ if (response.status === 403 && response.body.error === 'FORBIDDEN') {
166
+ throw new HubError('HUB_SCOPE_DENIED', 'Your Pulse tier does not grant the connector scopes for this operation', {
167
+ details: { status: 403 },
168
+ });
169
+ }
170
+ if (retry && (response.status === 429 || response.status >= 500)) {
171
+ const wait = Number.isFinite(response.retryAfter)
172
+ ? Math.max(0, Math.min(60_000, response.retryAfter * 1_000))
173
+ : fullJitterDelay(attempt, { random: this.random });
174
+ await backoff(wait);
175
+ continue;
176
+ }
177
+ throw new HubError(
178
+ response.body.error || `HTTP_${response.status}`,
179
+ response.body.message || `Connector request failed with ${response.status}`,
180
+ { details: { status: response.status } },
181
+ );
182
+ }
183
+ throw new HubError('HUB_TRANSPORT_UNAVAILABLE', 'Connector transport unavailable', { cause: lastError });
184
+ }
185
+
186
+ reconnect(payload) {
187
+ if (Array.isArray(payload.activeAttempts) && payload.activeAttempts.length > 100) {
188
+ throw new HubError('HUB_TRANSPORT_PAYLOAD_TOO_LARGE', 'Reconnect exceeds 100 active attempts');
189
+ }
190
+ return this.lane.priority(async (trace) => {
191
+ const opened = await this.#request(`${CONNECTOR_API_BASE}/session`, payload, { retry: true, maxBodyBytes: 32 * 1024, trace });
192
+ const range = parseAccepts(this.contractAccepts);
193
+ if (range && (CONTRACT_VERSION < range.min || CONTRACT_VERSION > range.max)) throw contractUnsupported(this.contractAccepts);
194
+ return opened;
195
+ }, { path: '/session' });
196
+ }
197
+
198
+ heartbeat(payload) {
199
+ if (Array.isArray(payload.activeAttempts) && payload.activeAttempts.length > 100) {
200
+ throw new HubError('HUB_TRANSPORT_PAYLOAD_TOO_LARGE', 'Heartbeat exceeds 100 active attempts');
201
+ }
202
+ return this.lane.priority((trace) => this.#request(`${CONNECTOR_API_BASE}/heartbeat`, payload, { retry: true, maxBodyBytes: 64 * 1024, trace }), { path: '/heartbeat' });
203
+ }
204
+
205
+ async poll(payload) {
206
+ if (this.pollOutstanding) {
207
+ throw new HubError('HUB_POLL_ALREADY_OUTSTANDING', 'Only one claim poll may be outstanding');
208
+ }
209
+ this.pollOutstanding = true;
210
+ try {
211
+ const claimed = await this.#request(`${CONNECTOR_API_BASE}/jobs/claim`, payload, {
212
+ retry: true,
213
+ maxBodyBytes: 16 * 1024,
214
+ timeoutMs: (Number(payload?.waitSeconds) || 0) * 1_000 + CLAIM_TIMEOUT_MARGIN_MS,
215
+ });
216
+ return Array.isArray(claimed?.jobs) ? { ...claimed, jobs: claimed.jobs.map(claimedJob) } : claimed;
217
+ } finally {
218
+ this.pollOutstanding = false;
219
+ }
220
+ }
221
+
222
+ acknowledge(jobId, payload) {
223
+ if (!UUID.test(jobId)) throw new HubError('HUB_JOB_ID_INVALID', 'jobId must be a UUID');
224
+ return this.lane.priority((trace) => this.#request(`${CONNECTOR_API_BASE}/jobs/${encodeURIComponent(jobId)}/ack`, payload, {
225
+ maxBodyBytes: 64 * 1024,
226
+ trace,
227
+ }), { path: '/jobs/:id/ack' });
228
+ }
229
+
230
+ renew(jobId, payload) {
231
+ if (!UUID.test(jobId)) throw new HubError('HUB_JOB_ID_INVALID', 'jobId must be a UUID');
232
+ return this.lane.priority((trace) => this.#request(`${CONNECTOR_API_BASE}/jobs/${encodeURIComponent(jobId)}/renew`, payload, {
233
+ retry: true,
234
+ maxBodyBytes: 8 * 1024,
235
+ trace,
236
+ }), { path: '/jobs/:id/renew' });
237
+ }
238
+
239
+ uploadEvents(payload) {
240
+ if (!Array.isArray(payload.events) || payload.events.length < 1 || payload.events.length > 100) {
241
+ throw new HubError('HUB_EVENT_BATCH_INVALID', 'Event batch must contain 1 to 100 events');
242
+ }
243
+ for (const event of payload.events) {
244
+ if (Buffer.byteLength(JSON.stringify(event), 'utf8') > 32 * 1024) {
245
+ throw new HubError('HUB_EVENT_TOO_LARGE', 'Event exceeds 32 KiB');
246
+ }
247
+ }
248
+ return this.lane.priority((trace) => this.#request(`${CONNECTOR_API_BASE}/events`, payload, {
249
+ retry: true,
250
+ maxBodyBytes: CONNECTOR_RESPONSE_MAX_BYTES,
251
+ trace,
252
+ }), { path: '/events' });
253
+ }
254
+
255
+ /** The heartbeat, session, renew, ack and events calls in flight at any time since `since`, for a pass record. */
256
+ priorityCalls({ since } = {}) {
257
+ return this.lane.calls({ since });
258
+ }
259
+
260
+ /**
261
+ * trace, when given, also gets laneWaitMs (behind priority calls, 1 s at most), laneCapped, heldBy (those calls) and
262
+ * ms, the whole call.
263
+ */
264
+ mirror(payload, { trace = {} } = {}) {
265
+ const items = payload.items;
266
+ if (!Array.isArray(items) || items.length > MIRROR_LIMITS.maxItems || (items.length === 0 && payload.reset !== true)) {
267
+ throw new HubError('HUB_MIRROR_BATCH_INVALID', `Mirror batch must contain 1 to ${MIRROR_LIMITS.maxItems} items, or reset`);
268
+ }
269
+ const queuedAt = this.now();
270
+ return this.lane.background(() => this.#request(MIRROR_PATH, payload, {
271
+ retry: true,
272
+ maxBodyBytes: MIRROR_LIMITS.bodyBytes,
273
+ timeoutMs: MIRROR_TIMEOUT_MS,
274
+ gzipAboveBytes: MIRROR_GZIP_ABOVE_BYTES,
275
+ trace,
276
+ }), { trace }).finally(() => {
277
+ trace.ms = this.now() - queuedAt;
278
+ }).catch((error) => {
279
+ if (error.details?.status !== 403 || String(error.code).startsWith('machine_')) throw error;
280
+ throw new HubError('HUB_MIRROR_SCOPE_MISSING', 'Pulse refused mirroring for this machine: the seat or plan lacks the mirror scope', {
281
+ cause: error,
282
+ details: { status: 403, code: error.code },
283
+ });
284
+ });
285
+ }
286
+ }
@@ -0,0 +1,37 @@
1
+ export const CONNECTOR_CONTRACT_VERSION = 2;
2
+ export const OAUTH_CLIENT_ID = 'agentpulse-cli';
3
+ export const USER_API_BASE = '/api/agent-hub/user';
4
+ export const CONNECTOR_API_BASE = '/api/agent-hub/connector';
5
+ export const MACHINE_REGISTRATION_PATH = `${USER_API_BASE}/machines`;
6
+ export const MACHINE_ID_HEADER = 'x-agent-hub-machine-id';
7
+ export const SESSION_ID_HEADER = 'x-agent-hub-session-id';
8
+ export const POLICY_VERSION_HEADER = 'x-agent-hub-policy-version';
9
+ export const DEV_ORG_INGRESS_HEADER = 'x-serverless-authorization';
10
+ export const CONNECTOR_RESPONSE_MAX_BYTES = 512 * 1024;
11
+
12
+ export const CONNECTOR_SCOPES = Object.freeze([
13
+ 'agent-hub:connector:session',
14
+ 'agent-hub:connector:heartbeat',
15
+ 'agent-hub:connector:claim',
16
+ 'agent-hub:connector:ack',
17
+ 'agent-hub:connector:renew',
18
+ 'agent-hub:connector:event',
19
+ 'agent-hub:connector:mirror',
20
+ ]);
21
+
22
+ export const CONNECTOR_AUTH_CONTRACT = Object.freeze({
23
+ scheme: 'Bearer',
24
+ machineIdHeader: MACHINE_ID_HEADER,
25
+ sessionIdHeader: SESSION_ID_HEADER,
26
+ policyVersionHeader: POLICY_VERSION_HEADER,
27
+ devOrgIngressHeader: DEV_ORG_INGRESS_HEADER,
28
+ connectorScopes: CONNECTOR_SCOPES,
29
+ });
30
+
31
+ export const LOGIN_SCOPE = [
32
+ 'team-setup:dev-orgs:read',
33
+ 'agent-hub:machines:read',
34
+ 'agent-hub:machines:write',
35
+ ...CONNECTOR_SCOPES,
36
+ 'offline_access',
37
+ ].join(' ');
@@ -1,23 +0,0 @@
1
- import { Command, Flags } from '@oclif/core';
2
- import { runPulse } from '../../runPulse.js';
3
-
4
- /** `agentpulse pulse gate` — the human-in-the-loop barrier. Advance only with --approved. */
5
- export default class PulseGate extends Command {
6
- static description = 'The human gate. Advance the phase ONLY with --approved. Lock product decisions here.';
7
- static examples = ['<%= config.bin %> pulse gate --approved --decisions-file decisions.json'];
8
- static flags = {
9
- approved: Flags.boolean({ description: 'Approve this phase (silence/omission = not approved).', default: false }),
10
- 'decisions-file': Flags.string({ description: 'Path to a JSON file of human product decisions to lock: [{ title, decision, rationale }].' }),
11
- };
12
-
13
- async run() {
14
- const { flags } = await this.parse(PulseGate);
15
- let decisions = [];
16
- if (flags['decisions-file']) {
17
- const fs = await import('node:fs');
18
- decisions = JSON.parse(fs.readFileSync(flags['decisions-file'], 'utf8'));
19
- }
20
- const result = await runPulse('gate', { approved: flags.approved, decisions });
21
- this.log(JSON.stringify(result, null, 2));
22
- }
23
- }
@@ -1,13 +0,0 @@
1
- import { Command } from '@oclif/core';
2
- import { runPulse } from '../../runPulse.js';
3
-
4
- /** `agentpulse pulse next` — emit the current phase work-list (prompts + schema, contract injected). */
5
- export default class PulseNext extends Command {
6
- static description = 'Emit the current phase work-list: tasks (prompt + JSON schema) your model must run.';
7
- static examples = ['<%= config.bin %> pulse next'];
8
-
9
- async run() {
10
- const result = await runPulse('next', {});
11
- this.log(JSON.stringify(result, null, 2));
12
- }
13
- }
@@ -1,19 +0,0 @@
1
- import { Command, Flags } from '@oclif/core';
2
- import { runPulse } from '../../runPulse.js';
3
-
4
- /** `agentpulse pulse submit` — validate the model's structured results against schema + contract. */
5
- export default class PulseSubmit extends Command {
6
- static description = "Submit your model's structured results (JSON). Pulse validates them vs schema + contract.";
7
- static examples = ['<%= config.bin %> pulse submit --results-file discovery-results.json'];
8
- static flags = {
9
- 'results-file': Flags.string({ description: 'Path to a JSON file: [{ id, data }, …] (one per work-list task).', required: true }),
10
- };
11
-
12
- async run() {
13
- const { flags } = await this.parse(PulseSubmit);
14
- const fs = await import('node:fs');
15
- const results = JSON.parse(fs.readFileSync(flags['results-file'], 'utf8'));
16
- const result = await runPulse('submit', { results });
17
- this.log(JSON.stringify(result, null, 2));
18
- }
19
- }
@@ -1,18 +0,0 @@
1
- import { Command, Flags } from '@oclif/core';
2
- import { runPulse } from '../../runPulse.js';
3
-
4
- /** `agentpulse pulse verify` — run the contract's deterministic gate command. "Done" is this exit code. */
5
- export default class PulseVerify extends Command {
6
- static description = "Run the contract's gate command (e.g. `npm run check`) and report pass/fail. Never an LLM opinion.";
7
- static examples = ['<%= config.bin %> pulse verify'];
8
- static flags = {
9
- cwd: Flags.string({ description: 'Directory to run the gate in (default: current repo).' }),
10
- };
11
-
12
- async run() {
13
- const { flags } = await this.parse(PulseVerify);
14
- const result = await runPulse('verify', { cwd: flags.cwd });
15
- this.log(JSON.stringify(result, null, 2));
16
- if (!result.green) this.exit(1); // non-zero so CI / scripts can gate on it
17
- }
18
- }
package/src/runPulse.js DELETED
@@ -1,58 +0,0 @@
1
- /**
2
- * runPulse.js — the PURE command core for the pulse CLI topic. Maps a verb + args → an engine call → a
3
- * JSON-serializable result. No oclif, no I/O of its own (the engine's state.js owns fs). This is what makes
4
- * the plugin testable without installing @oclif/core, and keeps the seam thin: the oclif command classes
5
- * (src/commands/pulse/*.js) are ~5-line wrappers that parse flags and call this.
6
- *
7
- * Mirror of the MCP seam's tools.js: one verb == one engine method. All discipline lives in @agentpulselabs/pulse.
8
- */
9
-
10
- import { createEngine } from '@agentpulselabs/pulse';
11
-
12
- /** The verbs this topic exposes, each → an engine method. Kept in sync with the MCP tool surface. */
13
- export const ACTIONS = ['start', 'next', 'submit', 'gate', 'verify', 'status', 'board', 'claim', 'sync'];
14
-
15
- /**
16
- * @param {string} action - one of ACTIONS
17
- * @param {Object} args - parsed flags/inputs for the action
18
- * @param {Object} [opts] - { cwd, createEngine } (createEngine injectable for tests)
19
- * @returns {Promise<Object>} JSON-serializable result
20
- */
21
- export async function runPulse(action, args = {}, opts = {}) {
22
- const make = opts.createEngine || createEngine;
23
- const engine = make({ cwd: opts.cwd || process.cwd() });
24
-
25
- switch (action) {
26
- case 'start':
27
- return engine.start({ requirements: args.requirements, grounding: args.grounding, name: args.name });
28
- case 'next':
29
- return engine.next();
30
- case 'submit':
31
- return engine.submit({ results: parseResults(args.results) });
32
- case 'gate':
33
- return engine.gate({ approved: !!args.approved, decisions: parseJson(args.decisions, []) });
34
- case 'verify':
35
- return await engine.verify({ cwd: args.cwd });
36
- case 'status':
37
- return engine.status();
38
- case 'board':
39
- return engine.boardUpdate({ id: args.id, status: args.status });
40
- case 'claim':
41
- return engine.claim({ id: args.id, agentId: args.agentId });
42
- case 'sync':
43
- return engine.sync({ ids: parseJson(args.ids, undefined) });
44
- default:
45
- throw new Error(`unknown pulse action "${action}" — expected one of ${ACTIONS.join(', ')}`);
46
- }
47
- }
48
-
49
- // results may arrive as a JSON string (from --results-file content) or already-parsed array.
50
- function parseResults(r) {
51
- if (Array.isArray(r)) return r;
52
- return parseJson(r, []);
53
- }
54
- function parseJson(v, fallback) {
55
- if (v == null) return fallback;
56
- if (typeof v !== 'string') return v;
57
- try { return JSON.parse(v); } catch { return fallback; }
58
- }