start-command 0.39.2 → 0.41.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 (38) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/eslint.config.mjs +32 -0
  3. package/package.json +9 -10
  4. package/src/bin/cli.js +18 -6
  5. package/src/lib/args-parser.js +33 -0
  6. package/src/lib/command-stream.js +19 -5
  7. package/src/lib/execution-chain.js +2 -2
  8. package/src/lib/execution-store.js +62 -44
  9. package/src/lib/formal-ai-cli.js +237 -0
  10. package/src/lib/formal-ai-client.js +166 -0
  11. package/src/lib/formal-ai-policy.js +501 -0
  12. package/src/lib/formal-ai-runtime.js +304 -0
  13. package/src/lib/formal-ai-tool.js +182 -0
  14. package/src/lib/formal-ai.js +329 -0
  15. package/src/lib/log-sanitizer.js +16 -137
  16. package/src/lib/usage.js +3 -1
  17. package/test/ci-workflow-invariants.js +18 -0
  18. package/test/command-stream-log.js +85 -0
  19. package/test/dependency-freshness.mjs +552 -0
  20. package/test/execution-store-clink.js +152 -0
  21. package/test/execution-store.js +49 -5
  22. package/test/fixtures/executions-lino-objects-codec-0.4.0.lino +1 -0
  23. package/test/fixtures/executions-lino-objects-codec-0.9.3.lino +26 -0
  24. package/test/fixtures/formal-ai/fake-server.cjs +17 -0
  25. package/test/fixtures/formal-ai/helpers.cjs +189 -0
  26. package/test/fixtures/formal-ai/receipts.json +776 -0
  27. package/test/fixtures/formal-ai/replay.cjs +144 -0
  28. package/test/javascript-first-ci.js +29 -0
  29. package/test/log-sanitizer.js +116 -47
  30. package/test/regression-187.js +4 -0
  31. package/test/regression-224.js +4 -2
  32. package/test/regression-231.js +20 -0
  33. package/test/regression-255-cli.js +294 -0
  34. package/test/regression-255-e2e.js +90 -0
  35. package/test/regression-255-loop.js +475 -0
  36. package/test/regression-255-policy.js +436 -0
  37. package/test/release-git.mjs +2 -1
  38. package/test/shared-golden.js +3 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # start-command
2
2
 
3
+ ## 0.41.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 65ce70c: Add the experimental `--use-formal-ai` mode: `$ --use-formal-ai -- <request>` sends a natural-language request to a local Formal AI runtime and runs the tool calls it chooses (#255).
8
+
9
+ - **Opt-in only:** without the flag nothing changes. The request after `--` is never read by a shell or the alias table. Without a qualified runtime (Formal AI 0.355.27 or newer, from `START_FORMAL_AI_ROOT` or an explicit `START_FORMAL_AI_URL`), start exits 69 and runs nothing. There is no fallback to aliases, a shell or an LLM.
10
+ - **Tools:** a small read-only set of programs, run without a shell. It includes `ls`, `cat`, `head`, `tail`, `wc`, `find`, `grep`, `sort`, `diff`, `stat`, `tree`, `du` and read-only `git` (see docs/formal-ai.md). Changes (`mkdir`, `touch`, `rmdir`, `rm`, `cp`, `mv`) need `START_FORMAL_AI_EFFECTS=workspace-write`; a change may only name targets observed before it and is verified by readback. Shell syntax, other programs and paths outside the working directory are refused (exit 77).
11
+ - **Bounds:** steps, retries, total time, per-tool time, output bytes and request length (`START_FORMAL_AI_*`). Exhausted budgets exit 124 and unsupported requests exit 65. A cancelled run ends with the signal status.
12
+ - **Evidence:** the log records the runtime identity, every response, tool decision, exit status and file-system effect. The log stays local: this mode never uploads logs or creates issues. Tool output appears in the evidence only as a short preview, and the environment is not recorded.
13
+ - **Dependencies:** `lino-objects-codec` 0.9.4 and `eslint` 10.14.0; CI runs on Node.js 26.
14
+
15
+ ## 0.40.0
16
+
17
+ ### Minor Changes
18
+
19
+ - 47a9dd8: Upgrade every dependency to its latest release, and let CI reject pull requests that fall behind (#253).
20
+
21
+ - **Upgrades:** `@link-foundation/sensitive-data-sanitizer` 0.12.2, `command-stream` 2.6.0, `lino-objects-codec` 0.9.3, the lint tools, the GitHub Actions, the runners (`ubuntu-26.04`, `macos-26`) and the .NET 10 SDK that clink 4.x needs.
22
+ - **Store format:** the execution store is now written in lino-objects-codec 0.9's readable format. Stores written by older releases are still read, but older releases of start cannot read a store written by this one.
23
+ - **Links database:** the store had stayed empty, because clink 4.x rejected every query it sent. It now writes one record-scoped link per property and verifies the writes. The existing `recordToLinksNotation()` helper remains available with the corrected format.
24
+ - **`--use-command-stream`:** command output is appended to the log again. command-stream 2.x returns it as snapshots.
25
+ - **Sanitizer:** start's own `Authorization` masking pass is gone, because the sanitizer now masks only the scheme and token itself. A NUL byte in a log no longer blocks the upload (`--allow-binary`).
26
+ - **Dependency fixes:** preserve Authorization placeholders, bracketed tokens and following control bytes; avoid quadratic scanning of dense credentials. Load command-stream's lightweight `core` entry for command execution.
27
+ - **CI:** `scripts/check-dependency-freshness.mjs` fails a pull request when any npm package, crate, action, CI tool, runtime, runner or translator pin is not its latest release. The one exception is a version that links an open blocking issue.
28
+
3
29
  ## 0.39.2
4
30
 
5
31
  ### Patch Changes
package/eslint.config.mjs CHANGED
@@ -59,6 +59,36 @@ export default [
59
59
  'spaced-comment': ['error', 'always', { markers: ['/'] }],
60
60
  },
61
61
  },
62
+ {
63
+ // CommonJS test fixtures and helpers (.cjs): the test runner collects
64
+ // every .js/.mjs file under test/, so non-test modules there use .cjs.
65
+ files: ['**/*.cjs'],
66
+ plugins: {
67
+ prettier: prettierPlugin,
68
+ },
69
+ languageOptions: {
70
+ ecmaVersion: 'latest',
71
+ sourceType: 'commonjs',
72
+ globals: {
73
+ require: 'readonly',
74
+ module: 'readonly',
75
+ exports: 'readonly',
76
+ __dirname: 'readonly',
77
+ __filename: 'readonly',
78
+ console: 'readonly',
79
+ process: 'readonly',
80
+ Buffer: 'readonly',
81
+ },
82
+ },
83
+ rules: {
84
+ 'prettier/prettier': 'error',
85
+ eqeqeq: ['error', 'always'],
86
+ curly: ['error', 'all'],
87
+ 'no-var': 'error',
88
+ 'prefer-const': 'error',
89
+ 'prefer-template': 'error',
90
+ },
91
+ },
62
92
  {
63
93
  // ES module files (.mjs)
64
94
  files: ['**/*.mjs'],
@@ -80,6 +110,7 @@ export default [
80
110
  // Web-standard globals Node exposes; used by the repository-level
81
111
  // scripts/ directory, which the root config lints (issue #168).
82
112
  AbortController: 'readonly',
113
+ AbortSignal: 'readonly',
83
114
  URL: 'readonly',
84
115
  setTimeout: 'readonly',
85
116
  clearTimeout: 'readonly',
@@ -111,6 +142,7 @@ export default [
111
142
  files: [
112
143
  'test/**/*.js',
113
144
  'test/**/*.mjs',
145
+ 'test/**/*.cjs',
114
146
  'tests/**/*.js',
115
147
  'experiments/**/*.js',
116
148
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.39.2",
3
+ "version": "0.41.0",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
@@ -49,12 +49,12 @@
49
49
  "devDependencies": {
50
50
  "@changesets/cli": "^3.0.3",
51
51
  "@eslint/js": "^10.0.1",
52
- "eslint": "^10.3.0",
52
+ "eslint": "^10.14.0",
53
53
  "eslint-config-prettier": "^10.1.8",
54
- "eslint-plugin-prettier": "^5.5.5",
54
+ "eslint-plugin-prettier": "^5.5.7",
55
55
  "husky": "^9.1.7",
56
- "lint-staged": "^17.0.4",
57
- "prettier": "^3.8.3"
56
+ "lint-staged": "^17.6.0",
57
+ "prettier": "^3.9.10"
58
58
  },
59
59
  "lint-staged": {
60
60
  "*.{js,mjs,cjs}": [
@@ -68,13 +68,12 @@
68
68
  ]
69
69
  },
70
70
  "dependencies": {
71
- "@link-foundation/sensitive-data-sanitizer": "^0.12.0",
72
- "command-stream": "^0.9.4",
73
- "lino-objects-codec": "^0.4.0"
71
+ "@link-foundation/sensitive-data-sanitizer": "^0.12.2",
72
+ "command-stream": "^2.6.0",
73
+ "lino-objects-codec": "^0.9.4"
74
74
  },
75
75
  "overrides": {
76
76
  "brace-expansion": "^5.0.12",
77
- "flatted": "^3.4.2",
78
- "js-yaml": "^4.3.2"
77
+ "flatted": "^3.4.4"
79
78
  }
80
79
  }
package/src/bin/cli.js CHANGED
@@ -223,7 +223,9 @@ if (!isQueryMode && (!parsedCommand || parsedCommand.trim() === '')) {
223
223
  let command = parsedCommand;
224
224
  let substitutionResult = null;
225
225
 
226
- if (!config.disableSubstitutions && parsedCommand && parsedCommand.trim()) {
226
+ // A --use-formal-ai request is only ever read by Formal AI (issue #255).
227
+ const useFormalAi = wrapperOptions.useFormalAi;
228
+ if (!config.disableSubstitutions && !useFormalAi && parsedCommand?.trim()) {
227
229
  substitutionResult = processCommand(parsedCommand, {
228
230
  customLinoPath: config.substitutionsPath,
229
231
  verbose: config.verbose,
@@ -270,6 +272,14 @@ let launch = null;
270
272
  console.error(`Error: ${error.message}`);
271
273
  process.exit(1);
272
274
  }
275
+ if (useFormalAi) {
276
+ const { requestText, runFormalAiCli } = require('../lib/formal-ai-cli');
277
+ const request = requestText(parsedArgs.rawCommand);
278
+ const store = getExecutionStore();
279
+ process.exit(
280
+ await runFormalAiCli({ request, sessionId, launch, store, config })
281
+ );
282
+ }
273
283
 
274
284
  if (wrapperOptions.isolated === 'docker') {
275
285
  const {
@@ -814,7 +824,7 @@ async function runDirectWithCommandStream(
814
824
  sessionId
815
825
  ) {
816
826
  // Lazy load command-stream
817
- const { getCommandStream } = require('../lib/command-stream');
827
+ const { getCommandStream, outputText } = require('../lib/command-stream');
818
828
  const { $, raw } = await getCommandStream();
819
829
 
820
830
  // Get the command name (first word of the actual command to execute)
@@ -921,11 +931,13 @@ async function runDirectWithCommandStream(
921
931
  }
922
932
 
923
933
  // Collect output for log
924
- if (result.stdout) {
925
- appendLogFile(logFilePath, result.stdout);
934
+ const stdout = outputText(result.stdout);
935
+ const stderr = outputText(result.stderr);
936
+ if (stdout) {
937
+ appendLogFile(logFilePath, stdout);
926
938
  }
927
- if (result.stderr) {
928
- appendLogFile(logFilePath, result.stderr);
939
+ if (stderr) {
940
+ appendLogFile(logFilePath, stderr);
929
941
  }
930
942
  } catch (err) {
931
943
  exitCode = err.code || 1;
@@ -27,6 +27,7 @@
27
27
  * --keep-container-on-fail Remove successful docker containers, keep failed or OOM-killed ones
28
28
  * --shell <shell> Shell to use in isolation environments: auto, bash, zsh, sh (default: auto)
29
29
  * --use-command-stream Use command-stream library for command execution (experimental)
30
+ * --use-formal-ai Perform a natural-language request through Formal AI (experimental)
30
31
  * --verbose Enable verbose/debug output (sets START_VERBOSE=1)
31
32
  * --status <uuid> Show status of a previous command execution by UUID
32
33
  * --list [--running] List all (or only running) tracked command executions
@@ -55,6 +56,7 @@ const { parseSequence, isSequence } = require('./sequence-parser');
55
56
  const { buildCommandString } = require('./shell-utils');
56
57
  const {
57
58
  VALID_OUTPUT_FORMATS,
59
+ activeQueryModes,
58
60
  createQueryOptionDefaults,
59
61
  parseQueryOption,
60
62
  validateQueryOptions,
@@ -220,6 +222,7 @@ function parseArgs(args) {
220
222
  keepContainerOnFail: false, // Keep docker container filesystem when command fails or OOM-kills
221
223
  shell: 'auto', // Shell to use in isolation environments: auto, bash, zsh, sh
222
224
  useCommandStream: false, // Use command-stream library for command execution
225
+ useFormalAi: false, // Natural-language request performed through Formal AI (issue #255)
223
226
  ...createQueryOptionDefaults(),
224
227
  ...snapshotResumeOptions.createSnapshotOptionDefaults(),
225
228
  ...launchIdentityOptions.createLaunchIdentityOptionDefaults(),
@@ -557,6 +560,11 @@ function parseOption(args, index, options) {
557
560
  return 1;
558
561
  }
559
562
 
563
+ if (arg === '--use-formal-ai') {
564
+ options.useFormalAi = true;
565
+ return 1;
566
+ }
567
+
560
568
  // --verbose (enable verbose/debug output, sets START_VERBOSE env var)
561
569
  if (arg === '--verbose') {
562
570
  process.env.START_VERBOSE = '1';
@@ -849,6 +857,7 @@ function validateOptions(options) {
849
857
  snapshotResumeOptions.validate(options);
850
858
  validateQueryOptions(options);
851
859
  launchIdentityOptions.validate(options);
860
+ validateFormalAi(options);
852
861
 
853
862
  // Validate shell option
854
863
  if (options.shell !== null && options.shell !== undefined) {
@@ -869,6 +878,30 @@ function validateOptions(options) {
869
878
  }
870
879
  }
871
880
 
881
+ /**
882
+ * `--use-formal-ai` runs its tool calls directly in the current directory;
883
+ * other execution modes would change where and how they run (issue #255).
884
+ * @param {object} options - Parsed options
885
+ * @throws {Error} When combined with an unsupported option
886
+ */
887
+ function validateFormalAi(options) {
888
+ if (!options.useFormalAi) {
889
+ return;
890
+ }
891
+ const conflicts = [
892
+ ...(options.isolated !== null ? ['--isolated'] : []),
893
+ ...(options.user ? ['--isolated-user'] : []),
894
+ ...(options.detached ? ['--detached'] : []),
895
+ ...(options.useCommandStream ? ['--use-command-stream'] : []),
896
+ ...activeQueryModes(options),
897
+ ];
898
+ if (conflicts.length) {
899
+ throw new Error(
900
+ `--use-formal-ai cannot be combined with ${conflicts.join(', ')} (experimental mode, see docs/formal-ai.md)`
901
+ );
902
+ }
903
+ }
904
+
872
905
  /**
873
906
  * Generate a unique session name
874
907
  * @param {string} [prefix='start'] - Prefix for the session name
@@ -26,11 +26,24 @@ let commandStream = null;
26
26
  */
27
27
  async function getCommandStream() {
28
28
  if (!commandStream) {
29
- commandStream = await import('command-stream');
29
+ // Execution needs no compatibility layers, terminal renderers or REPL.
30
+ commandStream = await import('command-stream/core');
30
31
  }
31
32
  return commandStream;
32
33
  }
33
34
 
35
+ /**
36
+ * Text of a completed command's stdout or stderr. Since command-stream 2.x
37
+ * these are readable stream snapshots rather than strings, and an empty one
38
+ * is still truthy (#253).
39
+ *
40
+ * @param {unknown} output - result.stdout or result.stderr
41
+ * @returns {string}
42
+ */
43
+ function outputText(output) {
44
+ return output === undefined || output === null ? '' : String(output);
45
+ }
46
+
34
47
  /**
35
48
  * Execute a shell command synchronously and return the result
36
49
  * Uses command-stream's $ function with .sync() for blocking execution.
@@ -54,8 +67,8 @@ async function execCommand(command, options = {}) {
54
67
  const result = $cmd`${command}`.sync();
55
68
 
56
69
  return {
57
- stdout: (result.stdout || '').trim(),
58
- stderr: (result.stderr || '').trim(),
70
+ stdout: outputText(result.stdout).trim(),
71
+ stderr: outputText(result.stderr).trim(),
59
72
  code: resolveChildExitCode(result.code),
60
73
  };
61
74
  } catch (err) {
@@ -91,8 +104,8 @@ async function execCommandAsync(command, options = {}) {
91
104
  const result = await $cmd`${command}`;
92
105
 
93
106
  return {
94
- stdout: (result.stdout || '').trim(),
95
- stderr: (result.stderr || '').trim(),
107
+ stdout: outputText(result.stdout).trim(),
108
+ stderr: outputText(result.stderr).trim(),
96
109
  code: resolveChildExitCode(result.code),
97
110
  };
98
111
  } catch (err) {
@@ -250,6 +263,7 @@ async function runWithHandlers(command, handlers = {}, options = {}) {
250
263
 
251
264
  module.exports = {
252
265
  getCommandStream,
266
+ outputText,
253
267
  execCommand,
254
268
  execCommandAsync,
255
269
  commandExists,
@@ -21,7 +21,7 @@ const { executionAliases, hasUserNames } = require('./execution-identity');
21
21
  function buildChain(records, record) {
22
22
  const seen = new Set([record.uuid]);
23
23
  const chain = [record];
24
- for (let cur = record; cur.continues; ) {
24
+ for (let cur = record; cur.continues;) {
25
25
  const prev = records.find((r) => r.uuid === cur.continues);
26
26
  if (!prev || seen.has(prev.uuid)) {
27
27
  break;
@@ -30,7 +30,7 @@ function buildChain(records, record) {
30
30
  chain.unshift(prev);
31
31
  cur = prev;
32
32
  }
33
- for (let cur = record; ; ) {
33
+ for (let cur = record; ;) {
34
34
  const next = records
35
35
  .filter((r) => r.continues === cur.uuid && !seen.has(r.uuid))
36
36
  .sort((a, b) => Date.parse(b.startTime) - Date.parse(a.startTime))[0];
@@ -245,6 +245,16 @@ class ExecutionRecord {
245
245
  }
246
246
  }
247
247
 
248
+ /**
249
+ * Quote text as a clink reference: `"..."` with inner quotes doubled.
250
+ * clink 4.x rejects backslash escapes inside references.
251
+ * @param {string} text
252
+ * @returns {string}
253
+ */
254
+ function clinkReference(text) {
255
+ return `"${text.replaceAll('"', '""')}"`;
256
+ }
257
+
248
258
  /**
249
259
  * Check if clink is installed
250
260
  * @returns {boolean}
@@ -343,70 +353,69 @@ class ExecutionStore {
343
353
  }
344
354
 
345
355
  /**
346
- * Convert execution record to clink links notation format
347
- * Uses string aliases for readable IDs
356
+ * Convert a record's properties to clink links notation.
357
+ *
358
+ * The record is one link `(uuid: ExecutionRecord uuid)` plus one link per
359
+ * property, `(uuid.key: uuid "key=value")`. Property links have the record
360
+ * as their source, so two records that share a value (both `executing`) are
361
+ * still distinct links: doublets are deduplicated by (source, target), and a
362
+ * shared `(status executing)` link would carry only one record's name. The
363
+ * `key=` prefix keeps the target from being a bare number, which clink 4.x
364
+ * reads as a link address even when quoted (issue #253).
348
365
  * @param {ExecutionRecord} record
349
366
  * @returns {string}
350
367
  */
351
368
  recordToLinksNotation(record) {
352
- // Using clink's string alias feature for readable identifiers
353
- // Format: (uuid: uuid-value) (pid: pid-value) etc.
354
369
  const obj = record.toObject();
355
- const parts = [];
370
+ const links = [];
356
371
 
357
372
  for (const [key, value] of Object.entries(obj)) {
358
373
  if (value !== null && value !== undefined) {
359
- // Escape value properly for links notation
360
- const escapedValue =
374
+ const text =
361
375
  typeof value === 'object' ? JSON.stringify(value) : String(value);
362
- parts.push(`(${record.uuid}.${key}: ${key} "${escapedValue}")`);
376
+ links.push(
377
+ `(${record.uuid}.${key}: ${record.uuid} ${clinkReference(`${key}=${text}`)})`
378
+ );
363
379
  }
364
380
  }
365
381
 
366
- return parts.join(' ');
382
+ return links.join(' ');
383
+ }
384
+
385
+ /** Build the create query using the same property notation as the public helper. */
386
+ buildClinkCreateQuery(record) {
387
+ return `() ((${record.uuid}: ExecutionRecord ${record.uuid}) ${this.recordToLinksNotation(record)})`;
367
388
  }
368
389
 
369
390
  /**
370
- * Build clink query for creating/updating a record
371
- * @param {ExecutionRecord} record
391
+ * Build clink query deleting every property link of a record
392
+ * @param {string} uuid
372
393
  * @returns {string}
373
394
  */
374
- buildClinkCreateQuery(record) {
375
- const obj = record.toObject();
376
- const links = [];
377
-
378
- // Create main record link
379
- links.push(`(${record.uuid}: ExecutionRecord ${record.uuid})`);
380
-
381
- // Create property links
382
- for (const [key, value] of Object.entries(obj)) {
383
- if (value !== null && value !== undefined) {
384
- const escapedValue =
385
- typeof value === 'object' ? JSON.stringify(value) : String(value);
386
- // Using format: (uuid.property: property "value")
387
- links.push(`(${record.uuid}.${key}: ${key} "${escapedValue}")`);
388
- }
389
- }
390
-
391
- // Format: () ((links)) - creates new links
392
- return `() ((${links.join(') (')}))`;
395
+ buildClinkDeletePropertiesQuery(uuid) {
396
+ return `(($id: ${uuid} $value)) ()`;
393
397
  }
394
398
 
395
399
  /**
396
400
  * Execute clink command
397
401
  * @param {string} query
402
+ * @param {string[]} [extraArgs] - flags appended after `--db <path>`
398
403
  * @returns {{success: boolean, output: string}}
399
404
  */
400
- execClink(query) {
405
+ execClink(query, extraArgs = []) {
401
406
  try {
402
407
  // The query embeds record values verbatim; a shell string would let a
403
408
  // single quote in any of them break out of the argument
404
409
  // (CodeQL js/shell-command-constructed-from-input, issue #168).
405
- const result = execFileSync('clink', [query, '--db', this.linksDbPath], {
406
- encoding: 'utf8',
407
- timeout: 10000,
408
- stdio: ['pipe', 'pipe', 'pipe'],
409
- });
410
+ const result = execFileSync(
411
+ 'clink',
412
+ [query, '--db', this.linksDbPath, ...extraArgs],
413
+ {
414
+ encoding: 'utf8',
415
+ timeout: 10000,
416
+ stdio: ['pipe', 'pipe', 'pipe'],
417
+ }
418
+ );
410
419
  return { success: true, output: result };
411
420
  } catch (err) {
412
421
  this.log(`Clink error: ${err.message}`);
@@ -417,15 +426,22 @@ class ExecutionStore {
417
426
  /**
418
427
  * Write a record to the links database using clink
419
428
  * @param {ExecutionRecord} record
429
+ * @param {{isNew?: boolean}} [options] - isNew skips dropping old links
420
430
  * @returns {boolean}
421
431
  */
422
- writeLinksRecord(record) {
432
+ writeLinksRecord(record, { isNew = false } = {}) {
423
433
  if (!this.useLinks) {
424
434
  return false;
425
435
  }
426
436
 
427
- const query = this.buildClinkCreateQuery(record);
428
- const result = this.execClink(query);
437
+ // Replace, not merge: drop the previous property links first. clink
438
+ // reports an error when the record has no links yet, which is harmless.
439
+ if (!isNew) {
440
+ this.execClink(this.buildClinkDeletePropertiesQuery(record.uuid));
441
+ }
442
+ const result = this.execClink(this.buildClinkCreateQuery(record), [
443
+ '--auto-create-missing-references',
444
+ ]);
429
445
 
430
446
  if (result.success) {
431
447
  this.log(`Wrote record ${record.uuid} to links database`);
@@ -444,9 +460,8 @@ class ExecutionStore {
444
460
  return false;
445
461
  }
446
462
 
447
- // Delete all links with this UUID prefix
448
- const query = `(($id: ${uuid} $any)) ()`;
449
- const result = this.execClink(query);
463
+ this.execClink(this.buildClinkDeletePropertiesQuery(uuid));
464
+ const result = this.execClink(`((${uuid}: ExecutionRecord ${uuid})) ()`);
450
465
  return result.success;
451
466
  }
452
467
 
@@ -492,7 +507,7 @@ class ExecutionStore {
492
507
 
493
508
  // Also write to links database if available
494
509
  if (this.useLinks) {
495
- this.writeLinksRecord(record);
510
+ this.writeLinksRecord(record, { isNew: existingIndex < 0 });
496
511
  }
497
512
 
498
513
  return true;
@@ -810,8 +825,10 @@ class ExecutionStore {
810
825
 
811
826
  // Query links database for all ExecutionRecord links
812
827
  try {
828
+ // Without --changes clink prints nothing for a read-only match
813
829
  const queryResult = this.execClink(
814
- `((($id: ExecutionRecord $uuid)) (($id: ExecutionRecord $uuid)))`
830
+ `((($id: ExecutionRecord $uuid)) (($id: ExecutionRecord $uuid)))`,
831
+ ['--changes']
815
832
  );
816
833
 
817
834
  if (queryResult.success) {
@@ -928,6 +945,7 @@ module.exports = {
928
945
  ExecutionStatus,
929
946
  LockManager,
930
947
  isClinkInstalled,
948
+ clinkReference,
931
949
  DEFAULT_APP_FOLDER,
932
950
  LINO_DB_FILE,
933
951
  LINKS_DB_FILE,