spl-lang 0.6.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.
package/dist/cli.js CHANGED
@@ -2,20 +2,23 @@
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
+ import { fileURLToPath } from 'node:url';
5
6
  import { CopilotBackend } from './copilot.js';
6
7
  import { AuthError, login, logout } from './auth.js';
7
8
  import { Failure, SplSyntaxError } from './errors.js';
8
9
  import { LlmError } from './llm.js';
9
10
  import { Logger } from './logger.js';
10
- import { parse } from './parser.js';
11
+ import { usesHuman } from './parser.js';
12
+ import { parseMarkdown } from './markdown.js';
11
13
  import { run } from './interpreter.js';
12
14
  import { JsonFileStore, MemoryStore, loadStoreModule } from './store.js';
13
15
  import { toJson } from './values.js';
14
16
  import { loadMcpConfig } from './mcp.js';
15
17
  import { RecordingBackend, ReplayBackend, TraceBackend } from './recording.js';
18
+ import { Checkpoint, CheckpointError, checkpointFingerprint } from './checkpoint.js';
16
19
  const HELP = `spl - Semantic Prompt Language interpreter
17
20
 
18
- Usage: spl <script.spl> [options]
21
+ Usage: spl <workflow.md> [options]
19
22
  spl login authenticate with GitHub Copilot (device flow in the browser)
20
23
  spl logout remove the credentials saved by 'spl login'
21
24
 
@@ -23,7 +26,7 @@ Options:
23
26
  --log-level <level> error | info | debug (default: info). Logs go to stderr.
24
27
  All levels include LLM transcripts and [deterministic] statement traces.
25
28
  --silent suppress all logs; only the result is printed
26
- --model <id> model override (takes precedence over @{model=...})
29
+ --model <id> model override (takes precedence over frontmatter)
27
30
  --arg <value> script argument exposed in $args (repeatable; also --arg=value)
28
31
  --mcp <file.json> MCP servers in VS Code mcp.json format
29
32
  --store <file.json> persist top-level variables in a JSON file
@@ -33,13 +36,22 @@ Options:
33
36
  --record <file.jsonl> record generation responses (new file; no agents)
34
37
  --replay <file.jsonl> replay LLM results without Copilot/MCP; explicit script tools still run
35
38
  Legacy --record files allow only local reads and no stores.
39
+ --checkpoint <file> start a new resumable run; with --resume, continue that journal
40
+ New runs never overwrite an existing file.
41
+ --resume [file] inspect pending input, or resume with an answer
42
+ Omit file when using --checkpoint <file>.
43
+ --answer-for <id> ID of the pending human request being answered
44
+ --answer <text> exact free-text answer (requires --resume and --answer-for)
45
+ --answer-file <file> read the answer from a UTF-8 file instead of --answer
36
46
  --cwd <dir> base directory for relative paths and restrictions (default: current directory)
37
47
  --check validate script syntax and any --mcp config without executing
38
48
  -v, --version print version
39
49
  -h, --help print this help
40
50
 
41
51
  The script result (the value of 'success' or 'return', or the failure payload) is written to stdout.
42
- Exit codes: 0 success, 1 uncaught script failure, 2 syntax or usage error.`;
52
+ Resumable runs print JSON envelopes and never read stdin.
53
+ Exit codes: 0 success, 1 uncaught script failure, 2 syntax or usage error,
54
+ 3 waiting for human input, 70 internal/infrastructure error.`;
43
55
  function version() {
44
56
  const pkg = new URL('../package.json', import.meta.url);
45
57
  return JSON.parse(fs.readFileSync(pkg, 'utf8')).version;
@@ -48,6 +60,71 @@ function usageError(msg) {
48
60
  process.stderr.write(`spl: ${msg}\nTry 'spl --help'.\n`);
49
61
  process.exit(2);
50
62
  }
63
+ async function checkpointCommand(source, program, values, cwd, logger, mcpServers) {
64
+ let checkpoint;
65
+ try {
66
+ const directory = fileURLToPath(new URL('.', import.meta.url));
67
+ const runtime = checkpointFingerprint([
68
+ version(), process.version,
69
+ ...fs.readdirSync(directory).filter((name) => name.endsWith('.js')).sort()
70
+ .map((name) => [name, fs.readFileSync(path.join(directory, name), 'utf8')]),
71
+ ]);
72
+ const context = {
73
+ source, runtime, cwd, args: values.arg,
74
+ configuration: { model: values.model ?? null, mcp: mcpServers ?? null },
75
+ };
76
+ checkpoint = values.resume !== undefined
77
+ ? Checkpoint.resume(values.resume, context)
78
+ : Checkpoint.create(values.checkpoint, context);
79
+ if (values.resume !== undefined) {
80
+ if (values.answer === undefined && values['answer-file'] === undefined) {
81
+ const envelope = { status: 'waiting_for_input', ...checkpoint.request };
82
+ checkpoint.close();
83
+ process.stdout.write(JSON.stringify(envelope) + '\n');
84
+ return 3;
85
+ }
86
+ let answer = values.answer;
87
+ if (values['answer-file'] !== undefined) {
88
+ try {
89
+ answer = fs.readFileSync(values['answer-file'], 'utf8');
90
+ }
91
+ catch (error) {
92
+ throw new CheckpointError(`cannot read answer file: ${error.message}`);
93
+ }
94
+ }
95
+ checkpoint.answer(values['answer-for'], answer);
96
+ }
97
+ const backend = new CopilotBackend({ logger, cwd, timeoutMs: Number(values.timeout ?? 300) * 1000, mcpServers });
98
+ let result;
99
+ try {
100
+ result = await run(program, { backend, logger, cwd, model: values.model, checkpoint });
101
+ }
102
+ finally {
103
+ await backend.close();
104
+ }
105
+ if (result.status === 'waiting_for_input') {
106
+ checkpoint.close();
107
+ process.stdout.write(JSON.stringify({ status: result.status, ...result.request }) + '\n');
108
+ return 3;
109
+ }
110
+ checkpoint.finish(result.status);
111
+ const envelope = result.status === 'success'
112
+ ? { status: result.status, run_id: checkpoint.runId, has_value: result.hasValue, value: toJson(result.value) }
113
+ : { status: result.status, run_id: checkpoint.runId, failure: result.failure };
114
+ checkpoint.close();
115
+ process.stdout.write(JSON.stringify(envelope) + '\n');
116
+ return result.status === 'success' ? 0 : 1;
117
+ }
118
+ catch (error) {
119
+ if (!(error instanceof CheckpointError) || error.kind !== 'usage')
120
+ throw error;
121
+ process.stderr.write(`spl: ${error.message}\n`);
122
+ return 2;
123
+ }
124
+ finally {
125
+ checkpoint?.close();
126
+ }
127
+ }
51
128
  async function loginCommand() {
52
129
  try {
53
130
  const status = await login((line) => process.stdout.write(line + '\n'));
@@ -75,26 +152,48 @@ function logoutCommand() {
75
152
  }
76
153
  async function main() {
77
154
  let parsed;
155
+ let resumeWithCheckpoint = false;
78
156
  try {
157
+ const options = {
158
+ 'log-level': { type: 'string' },
159
+ silent: { type: 'boolean' },
160
+ model: { type: 'string' },
161
+ arg: { type: 'string', multiple: true },
162
+ mcp: { type: 'string' },
163
+ store: { type: 'string' },
164
+ 'store-module': { type: 'string' },
165
+ timeout: { type: 'string' },
166
+ trace: { type: 'string' },
167
+ record: { type: 'string' },
168
+ replay: { type: 'string' },
169
+ checkpoint: { type: 'string' },
170
+ resume: { type: 'string' },
171
+ 'answer-for': { type: 'string' },
172
+ answer: { type: 'string' },
173
+ 'answer-file': { type: 'string' },
174
+ cwd: { type: 'string' },
175
+ check: { type: 'boolean' },
176
+ version: { type: 'boolean', short: 'v' },
177
+ help: { type: 'boolean', short: 'h' },
178
+ };
179
+ const args = process.argv.slice(2);
180
+ // parseArgs has no optional string options; locate bare --resume using tokens.
181
+ const { tokens } = parseArgs({
182
+ args, options: { ...options, resume: { type: 'boolean' } },
183
+ allowPositionals: true, strict: false, tokens: true,
184
+ });
185
+ const checkpointHasScript = tokens.some((token) => token.kind === 'option' && token.name === 'checkpoint')
186
+ && tokens.filter((token) => token.kind === 'positional').length === 1;
187
+ const bareResume = new Set(tokens
188
+ .filter((token) => token.kind === 'option' && token.name === 'resume'
189
+ && token.value === undefined
190
+ && (checkpointHasScript || args[token.index + 1] === undefined || args[token.index + 1].startsWith('-')))
191
+ .map((token) => token.index));
192
+ resumeWithCheckpoint = bareResume.size > 0;
79
193
  parsed = parseArgs({
194
+ args: args.filter((_, index) => !bareResume.has(index)),
80
195
  allowPositionals: true,
81
- options: {
82
- 'log-level': { type: 'string' },
83
- silent: { type: 'boolean' },
84
- model: { type: 'string' },
85
- arg: { type: 'string', multiple: true },
86
- mcp: { type: 'string' },
87
- store: { type: 'string' },
88
- 'store-module': { type: 'string' },
89
- timeout: { type: 'string' },
90
- trace: { type: 'string' },
91
- record: { type: 'string' },
92
- replay: { type: 'string' },
93
- cwd: { type: 'string' },
94
- check: { type: 'boolean' },
95
- version: { type: 'boolean', short: 'v' },
96
- help: { type: 'boolean', short: 'h' },
97
- },
196
+ options,
98
197
  });
99
198
  }
100
199
  catch (e) {
@@ -131,8 +230,34 @@ async function main() {
131
230
  if ([values.trace, values.record, values.replay].some((value) => value !== undefined && value.trim() === '')) {
132
231
  usageError('--trace, --record, and --replay require a non-empty file path');
133
232
  }
233
+ if (resumeWithCheckpoint) {
234
+ if (values.resume !== undefined)
235
+ usageError('use either --resume <file> or --resume --checkpoint <file>');
236
+ if (values.checkpoint === undefined)
237
+ usageError('--resume requires a file: use --resume <file> or --resume --checkpoint <file>');
238
+ values.resume = values.checkpoint;
239
+ delete values.checkpoint;
240
+ }
241
+ const resumable = values.checkpoint !== undefined || values.resume !== undefined;
242
+ if (values.checkpoint !== undefined && values.resume !== undefined)
243
+ usageError('--checkpoint and --resume are mutually exclusive');
244
+ if ([values.checkpoint, values.resume, values['answer-for'], values['answer-file']].some((value) => value !== undefined && !value.trim())) {
245
+ usageError('checkpoint, resume, answer-for, and answer-file require non-empty values');
246
+ }
247
+ if (resumable && [values.store, values['store-module'], values.trace, values.record, values.replay].some((value) => value !== undefined)) {
248
+ usageError('resumable runs cannot use --store, --store-module, --trace, --record, or --replay');
249
+ }
250
+ const answering = values.answer !== undefined || values['answer-file'] !== undefined;
251
+ if (values.answer !== undefined && values['answer-file'] !== undefined)
252
+ usageError('--answer and --answer-file are mutually exclusive');
253
+ if ((answering || values['answer-for'] !== undefined) && values.resume === undefined)
254
+ usageError('answer flags require --resume');
255
+ if (answering !== (values['answer-for'] !== undefined))
256
+ usageError('an answer requires --answer-for, and --answer-for requires an answer');
134
257
  const cwd = path.resolve(values.cwd ?? process.cwd());
135
258
  const file = positionals[0];
259
+ if (!/\.md$/i.test(file))
260
+ usageError('only .md workflows are executable; wrap SPL in a fenced spl block and add extension/version frontmatter');
136
261
  let source;
137
262
  try {
138
263
  source = fs.readFileSync(file, 'utf8');
@@ -142,7 +267,7 @@ async function main() {
142
267
  }
143
268
  let program;
144
269
  try {
145
- program = parse(source);
270
+ program = parseMarkdown(source);
146
271
  }
147
272
  catch (e) {
148
273
  if (e instanceof SplSyntaxError) {
@@ -160,6 +285,10 @@ async function main() {
160
285
  }
161
286
  if (values.check)
162
287
  return 0;
288
+ if (resumable)
289
+ return checkpointCommand(source, program, values, cwd, logger, mcpServers);
290
+ if (usesHuman(program))
291
+ usageError('human input requires --checkpoint or --resume');
163
292
  let backend;
164
293
  try {
165
294
  if (values.replay)
@@ -207,6 +336,8 @@ async function main() {
207
336
  process.stdout.write(JSON.stringify(result.failure, null, 4) + '\n');
208
337
  return 1;
209
338
  }
339
+ if (result.status === 'waiting_for_input')
340
+ throw new CheckpointError('unexpected human suspension without a checkpoint', 'infrastructure');
210
341
  if (result.hasValue) {
211
342
  const v = result.value;
212
343
  process.stdout.write((typeof v === 'string' ? v : JSON.stringify(toJson(v), null, 2)) + '\n');
package/dist/copilot.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { CopilotClient, defineTool } from '@github/copilot-sdk';
2
2
  import { loadToken } from './auth.js';
3
- import { Failure } from './errors.js';
3
+ import { Failure, SplSyntaxError } from './errors.js';
4
+ import { parse } from './parser.js';
4
5
  import { LlmError } from './llm.js';
5
6
  import { TOOL_SPECS } from './tools.js';
6
7
  import { configuredMcpTool, filterMcpServers } from './mcp.js';
@@ -81,7 +82,8 @@ export class CopilotBackend {
81
82
  violation ??= failure;
82
83
  void session?.abort().catch(() => undefined);
83
84
  };
84
- const agent = req.kind === 'agent';
85
+ const agent = req.kind === 'agent' && !req.naturalLanguage;
86
+ let submitted;
85
87
  const tools = (agent ? TOOL_NAMES : []).map((name) => {
86
88
  const spec = TOOL_SPECS[name];
87
89
  return defineTool(name, {
@@ -105,15 +107,50 @@ export class CopilotBackend {
105
107
  },
106
108
  });
107
109
  });
110
+ if (req.naturalLanguage) {
111
+ tools.push(defineTool('submit_spl', {
112
+ description: 'Submit one complete SPL program to execute after this request, in shared document scope. No actions occur during submission.',
113
+ parameters: {
114
+ type: 'object', properties: { source: { type: 'string' } },
115
+ required: ['source'], additionalProperties: false,
116
+ },
117
+ handler: async (args) => {
118
+ if (!args || typeof args !== 'object' || !('source' in args) ||
119
+ typeof args.source !== 'string' || !args.source.trim()) {
120
+ return { resultType: 'failure', textResultForLlm: 'source must be non-empty SPL code' };
121
+ }
122
+ if (submitted !== undefined) {
123
+ return { resultType: 'failure', textResultForLlm: 'a program has already been submitted' };
124
+ }
125
+ try {
126
+ const program = parse(args.source);
127
+ if (program.directives.model !== undefined) {
128
+ return { resultType: 'failure', textResultForLlm: 'use model modifiers, not a model header' };
129
+ }
130
+ }
131
+ catch (error) {
132
+ if (!(error instanceof SplSyntaxError))
133
+ throw error;
134
+ return { resultType: 'failure', textResultForLlm: error.message };
135
+ }
136
+ submitted = args.source;
137
+ return 'Program accepted for execution after this request. Do not submit again.';
138
+ },
139
+ }));
140
+ }
108
141
  const mcpServers = agent ? filterMcpServers(this.opts.mcpServers ?? {}, req.restrictions) : {};
109
142
  const baseConfig = {
110
143
  model: req.model,
111
144
  tools,
112
- availableTools: [...(agent ? TOOL_NAMES : []), ...Object.keys(mcpServers).map((name) => `mcp:${name}-*`)],
145
+ availableTools: [...tools.map((tool) => tool.name), ...Object.keys(mcpServers).map((name) => `mcp:${name}-*`)],
113
146
  mcpServers,
114
147
  systemMessage: { mode: 'append', content: SYSTEM_MESSAGE },
115
148
  workingDirectory: this.opts.cwd,
116
- onPermissionRequest: (r) => agent ? decidePermission(r, req.restrictions, block, mcpServers) : this.denyGenerationPermission(block),
149
+ onPermissionRequest: (r) => {
150
+ if (req.naturalLanguage && r.kind === 'custom-tool' && r.toolName === 'submit_spl')
151
+ return { kind: 'approve-once' };
152
+ return agent ? decidePermission(r, req.restrictions, block, mcpServers) : this.denyGenerationPermission(block);
153
+ },
117
154
  };
118
155
  try {
119
156
  try {
@@ -143,12 +180,15 @@ export class CopilotBackend {
143
180
  }
144
181
  if (violation)
145
182
  throw violation;
146
- const text = answer?.data?.content;
183
+ if (req.naturalLanguage && submitted === undefined)
184
+ throw new LlmError('natural-language request did not call submit_spl');
185
+ const text = answer?.data?.content ?? (submitted !== undefined ? 'SPL program submitted.' : undefined);
147
186
  if (typeof text !== 'string' || text.trim() === '')
148
187
  throw new LlmError('the model returned an empty response');
149
188
  const usedModel = answer?.data?.model;
150
189
  return {
151
190
  text,
191
+ ...(submitted !== undefined ? { spl: submitted } : {}),
152
192
  reasoning: reasoning.length ? reasoning.join('\n') : undefined,
153
193
  effective: {
154
194
  backend: 'copilot-sdk',
package/dist/index.d.ts CHANGED
@@ -1,9 +1,11 @@
1
- export { parse } from './parser.js';
1
+ export { parse, usesHuman } from './parser.js';
2
+ export { parseMarkdown, extractMarkdown, isWorkflowMarkdown } from './markdown.js';
2
3
  export { run, type RunOptions, type RunResult } from './interpreter.js';
3
4
  export { Failure, SplSyntaxError, type FailurePayload, type StatusCode } from './errors.js';
4
5
  export { Logger, type LogLevel } from './logger.js';
5
6
  export { CopilotBackend } from './copilot.js';
6
7
  export { RecordingBackend, ReplayBackend, TraceBackend } from './recording.js';
8
+ export { Checkpoint, CheckpointError, checkpointFingerprint, type CheckpointContext, type HumanRequest } from './checkpoint.js';
7
9
  export { loadMcpConfig, type McpServers } from './mcp.js';
8
10
  export { login, logout, authStatus, AuthError } from './auth.js';
9
11
  export { JsonFileStore, MemoryStore, loadStoreModule, type VariableStore } from './store.js';
package/dist/index.js CHANGED
@@ -1,9 +1,11 @@
1
- export { parse } from './parser.js';
1
+ export { parse, usesHuman } from './parser.js';
2
+ export { parseMarkdown, extractMarkdown, isWorkflowMarkdown } from './markdown.js';
2
3
  export { run } from './interpreter.js';
3
4
  export { Failure, SplSyntaxError } from './errors.js';
4
5
  export { Logger } from './logger.js';
5
6
  export { CopilotBackend } from './copilot.js';
6
7
  export { RecordingBackend, ReplayBackend, TraceBackend } from './recording.js';
8
+ export { Checkpoint, CheckpointError, checkpointFingerprint } from './checkpoint.js';
7
9
  export { loadMcpConfig } from './mcp.js';
8
10
  export { login, logout, authStatus, AuthError } from './auth.js';
9
11
  export { JsonFileStore, MemoryStore, loadStoreModule } from './store.js';
@@ -3,6 +3,7 @@ import { type FailurePayload } from './errors.js';
3
3
  import { type LlmBackend } from './llm.js';
4
4
  import type { Logger } from './logger.js';
5
5
  import { type VariableStore } from './store.js';
6
+ import { type Checkpoint, type HumanRequest } from './checkpoint.js';
6
7
  import { type Value } from './values.js';
7
8
  export interface RunOptions {
8
9
  backend: LlmBackend;
@@ -10,11 +11,12 @@ export interface RunOptions {
10
11
  store?: VariableStore;
11
12
  /** Working directory for relative paths, `restrict` folders and tools. */
12
13
  cwd?: string;
13
- /** Overrides the `@{model=...}` directive. */
14
+ /** Overrides the `@(model=...)` directive. */
14
15
  model?: string;
15
16
  /** Script arguments exposed as read-only `$args`; defaults to an empty list. */
16
17
  args?: readonly string[];
17
18
  maxCallDepth?: number;
19
+ checkpoint?: Checkpoint;
18
20
  }
19
21
  export type RunResult = {
20
22
  status: 'success';
@@ -23,5 +25,8 @@ export type RunResult = {
23
25
  } | {
24
26
  status: 'failure';
25
27
  failure: FailurePayload;
28
+ } | {
29
+ status: 'waiting_for_input';
30
+ request: HumanRequest;
26
31
  };
27
32
  export declare function run(program: Program, opts: RunOptions): Promise<RunResult>;