@lorekit/cli 1.25.1 → 1.26.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/bin/lorekit.mjs CHANGED
@@ -290,6 +290,11 @@ ${c.bold('Options')}
290
290
  --trigger <slug> Trigger context slug (default: none)
291
291
  --ttl-days <n> Days until auto-expiry 1–365 (remote only)
292
292
  --org <slug> Write to this org's scope (remote only)
293
+ --origin-repo <o/n> Override the derived provenance repository
294
+ --origin-branch <b> Override the derived provenance branch
295
+ --origin-commit <s> Override the derived provenance commit SHA
296
+ --origin-pr <n> The pull request this lesson came out of
297
+ --no-origin Record no provenance at all
293
298
  --remote Force write to the remote store
294
299
  --local Force write to the local offline store
295
300
  --json Machine-readable output
@@ -571,6 +576,7 @@ const KNOWN_FLAGS = [
571
576
  'event', 'json', 'scope', 'threshold', 'help', 'version',
572
577
  'value', 'tags', 'source-agent', 'trigger', 'ttl-days', 'org', 'remote', 'local',
573
578
  'link', 'base', 'q', 'owner', 'range', 'view', 'archived',
579
+ 'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
574
580
  ];
575
581
 
576
582
  // Commands that write to disk / talk to the network on a human's behalf. These
@@ -595,7 +601,7 @@ async function main() {
595
601
  const argv = process.argv.slice(2);
596
602
  const args = parseArgs(argv, {
597
603
  aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
598
- booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'json', 'remote', 'local', 'link', 'archived'],
604
+ booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'no-origin', 'json', 'remote', 'local', 'link', 'archived'],
599
605
  known: KNOWN_FLAGS,
600
606
  });
601
607
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.25.1",
3
+ "version": "1.26.0",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,6 +28,7 @@ import { resolveProjectRoot } from './config.mjs';
28
28
  import { loadControl } from './control.mjs';
29
29
  import { createStore } from './store/index.mjs';
30
30
  import { createRemoteStore } from './store/remote.mjs';
31
+ import { deriveOrigin, mergeOrigin } from './origin.mjs';
31
32
 
32
33
  const PROTOCOL_VERSION = '2024-11-05';
33
34
  const SERVER_INFO = { name: 'lorekit-local', version: '1.0.0' };
@@ -55,6 +56,27 @@ export const MEMORY_TOOL_DEFS = [
55
56
  description:
56
57
  'Optional ISO 8601 creation date for migrating a pre-existing memory. Rejected if invalid or in the future. Applies only when the memory is first created.',
57
58
  },
59
+ origin_repo: {
60
+ type: 'string',
61
+ description:
62
+ 'Provenance: the owner/name of the repository this memory was recorded from. Derived from the working directory when omitted.',
63
+ },
64
+ origin_branch: {
65
+ type: 'string',
66
+ description:
67
+ 'Provenance: the git branch this memory was recorded from. Derived from the working directory when omitted.',
68
+ },
69
+ origin_commit: {
70
+ type: 'string',
71
+ description:
72
+ 'Provenance: the commit SHA checked out when this memory was recorded. Derived from the working directory when omitted.',
73
+ },
74
+ origin_pr: {
75
+ type: 'integer',
76
+ minimum: 1,
77
+ description:
78
+ 'Provenance: the pull request number this memory came out of. Pass it when you know it — the server can only infer it from CI environment variables.',
79
+ },
58
80
  },
59
81
  },
60
82
  },
@@ -148,10 +170,15 @@ export const ORG_TOOL_DEFS = [
148
170
  // Legacy alias kept so existing code that imports TOOL_DEFS still compiles.
149
171
  export const TOOL_DEFS = [...MEMORY_TOOL_DEFS, ...ORG_TOOL_DEFS];
150
172
 
151
- // tool name → (store, args) → store result. The store destructures the args it
152
- // needs, so the raw `arguments` object is passed straight through.
173
+ // tool name → (store, args, ctx) → store result. The store destructures the
174
+ // args it needs, so the raw `arguments` object is passed straight through.
175
+ // `ctx.root` is the resolved project root (`--dir`), NOT the process cwd — an
176
+ // MCP client launched from elsewhere would otherwise stamp the wrong origin.
153
177
  const MEMORY_DISPATCH = {
154
- 'memory.write': (store, a) => store.write(a),
178
+ // An agent calling memory.write knows the lesson, not the working directory
179
+ // it is running in. Fill in whatever provenance the environment can supply,
180
+ // with anything the caller DID pass taking precedence.
181
+ 'memory.write': (store, a, ctx) => store.write({ ...a, ...withDerivedOrigin(a, ctx) }),
155
182
  'memory.read': (store, a) => store.read(a),
156
183
  'memory.list': (store, a) => store.list(a),
157
184
  'memory.search': (store, a) => store.search(a),
@@ -159,6 +186,22 @@ const MEMORY_DISPATCH = {
159
186
  'memory.archive': (store, a) => store.archive(a),
160
187
  };
161
188
 
189
+ // Provenance for a tool call: the caller's explicit values win, the working
190
+ // directory and CI environment fill the rest. Best-effort — a failure to shell
191
+ // out to git must never fail the write, so it degrades to no origin at all.
192
+ function withDerivedOrigin(args = {}, { root } = {}) {
193
+ try {
194
+ return mergeOrigin(deriveOrigin({ cwd: root }), {
195
+ origin_repo: args.origin_repo ?? null,
196
+ origin_branch: args.origin_branch ?? null,
197
+ origin_commit: args.origin_commit ?? null,
198
+ origin_pr: args.origin_pr ?? null,
199
+ });
200
+ } catch {
201
+ return {};
202
+ }
203
+ }
204
+
162
205
  // org.* dispatch — always routed to the remote store.
163
206
  const ORG_DISPATCH = {
164
207
  'org.create': (remote, a) => remote.orgCreate(a),
@@ -188,7 +231,7 @@ function toolResult(id, payload) {
188
231
 
189
232
  // Build the per-message handler over a resolved control model. `store` is null
190
233
  // when mode is `off`. Org tools are always advertised regardless of mode.
191
- export function createHandler(control) {
234
+ export function createHandler(control, { root = process.cwd() } = {}) {
192
235
  const store = createStore(control);
193
236
  const memoryTools = store ? MEMORY_TOOL_DEFS : [];
194
237
 
@@ -257,7 +300,7 @@ export function createHandler(control) {
257
300
  const fn = MEMORY_DISPATCH[name];
258
301
  if (!fn) return errorReply(id, -32601, `Unknown tool: ${name}`);
259
302
 
260
- const result = await fn(store, args);
303
+ const result = await fn(store, args, { root });
261
304
  return toolResult(id, result);
262
305
  }
263
306
 
@@ -359,7 +402,7 @@ export async function mcpServer(
359
402
  ) {
360
403
  const root = resolveProjectRoot(args.dir);
361
404
  const control = loadControl(root, { env: withOverrides(args, env) });
362
- const handle = createHandler(control);
405
+ const handle = createHandler(control, { root });
363
406
  // A human who runs `lorekit mcp` in a terminal would otherwise see a silent
364
407
  // hang with no sign it is alive. Reassure them on stderr — but only when
365
408
  // stdin is a TTY, so a piped MCP client never sees the banner on either channel.
package/src/origin.mjs ADDED
@@ -0,0 +1,186 @@
1
+ // Derive a memory's ORIGIN — where a lesson is being recorded FROM — from the
2
+ // git working directory and the ambient CI environment.
3
+ //
4
+ // This is the counterpart to `scope.mjs`'s `deriveScope()`. A scope says where
5
+ // a lesson APPLIES; an origin says where it was written: the repository, the
6
+ // branch, the checked-out commit, and the pull request the work belonged to.
7
+ // The dashboard renders it as a "recorded from" block with links straight back
8
+ // to the PR, branch, and commit (migration 00048).
9
+ //
10
+ // Nothing here is required: every field independently degrades to `null` when
11
+ // it cannot be determined (no git, detached HEAD, no PR context), and a write
12
+ // with no origin at all behaves exactly as it did before this existed.
13
+ //
14
+ // Zero-dependency, and deliberately injectable (`{ cwd, env, run }`) so the
15
+ // whole derivation is testable without a real repository or a real CI runner.
16
+ import { execFileSync } from 'node:child_process';
17
+ import { ownerRepoFromRemote } from './scope.mjs';
18
+
19
+ function gitRunner(args, cwd) {
20
+ try {
21
+ return execFileSync('git', args, {
22
+ cwd,
23
+ stdio: ['ignore', 'pipe', 'ignore'],
24
+ encoding: 'utf8',
25
+ }).trim();
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Extract a pull request number from the ambient environment.
33
+ *
34
+ * Precedence, first match wins:
35
+ * 1. LOREKIT_PR — the explicit escape hatch, any CI or none.
36
+ * 2. GITHUB_REF — `refs/pull/<n>/merge` on a GitHub Actions
37
+ * `pull_request` run.
38
+ * 3. GITHUB_PR_NUMBER — set by several popular actions.
39
+ *
40
+ * Returns a positive integer, or `null` when no PR context is present.
41
+ */
42
+ export function prNumberFromEnv(env = process.env) {
43
+ const explicit = toPositiveInt(env.LOREKIT_PR);
44
+ if (explicit !== null) return explicit;
45
+
46
+ const ref = typeof env.GITHUB_REF === 'string' ? env.GITHUB_REF : '';
47
+ const m = /^refs\/pull\/(\d+)\//.exec(ref);
48
+ if (m) {
49
+ const n = toPositiveInt(m[1]);
50
+ if (n !== null) return n;
51
+ }
52
+
53
+ return toPositiveInt(env.GITHUB_PR_NUMBER);
54
+ }
55
+
56
+ function toPositiveInt(raw) {
57
+ if (raw === undefined || raw === null || raw === '') return null;
58
+ const n = Number(raw);
59
+ if (!Number.isInteger(n) || n < 1) return null;
60
+ return n;
61
+ }
62
+
63
+ /**
64
+ * Whether a string is a usable git ref name, per git-check-ref-format.
65
+ *
66
+ * A deny list, deliberately: a branch is whatever a contributor named it, and
67
+ * `feat/add+x` / `fix/issue#123` / `feat/café` are all legal. The canonical
68
+ * rule lives in `packages/mcp-core/src/origin.ts` (`parseOriginBranch`); this
69
+ * is the zero-dependency CLI copy, kept behaviourally identical so a branch the
70
+ * CLI derives is never one the server would reject.
71
+ */
72
+ export function isValidRef(value) {
73
+ if (typeof value !== 'string' || value === '') return false;
74
+ // eslint-disable-next-line no-control-regex
75
+ if (/[\u0000-\u001f\u007f ~^:?*[\\]/.test(value)) return false;
76
+ if (value.startsWith('/') || value.endsWith('/')) return false;
77
+ if (value.startsWith('.') || value.endsWith('.')) return false;
78
+ if (value.endsWith('.lock')) return false;
79
+ if (value.includes('..') || value.includes('//') || value.includes('@{')) return false;
80
+ return value.length <= 255;
81
+ }
82
+
83
+ function firstNonEmpty(...values) {
84
+ for (const v of values) {
85
+ if (typeof v === 'string' && v.trim() !== '') return v.trim();
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Derive `{ origin_repo, origin_branch, origin_commit, origin_pr }` for the
92
+ * current working directory.
93
+ *
94
+ * Branch resolution prefers `GITHUB_HEAD_REF` over `git rev-parse`: on a
95
+ * GitHub Actions `pull_request` run the checkout is a detached merge commit,
96
+ * so git reports `HEAD` while `GITHUB_HEAD_REF` holds the real source branch.
97
+ *
98
+ * The branch is NOT lowercased (unlike a `branch::` scope) so the GitHub
99
+ * `/tree/` link the dashboard builds from it resolves for a mixed-case branch.
100
+ *
101
+ * @returns an object whose four fields are each a value or `null`.
102
+ */
103
+ export function deriveOrigin({ cwd = process.cwd(), env = process.env, run = gitRunner } = {}) {
104
+ const remote = run(['config', '--get', 'remote.origin.url'], cwd);
105
+ const gitBranch = run(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
106
+
107
+ // On a GitHub Actions `pull_request` run the checkout is a DETACHED MERGE
108
+ // COMMIT of the head branch into the base, so `HEAD` (and `GITHUB_SHA`) name
109
+ // a commit that exists on neither branch. Since the branch below deliberately
110
+ // reports `GITHUB_HEAD_REF` — the real source branch — taking the merge SHA
111
+ // here would have the two fields describe different refs.
112
+ //
113
+ // The head commit is the merge commit's SECOND parent. A shallow checkout
114
+ // (actions/checkout's default `fetch-depth: 1`) does not have it, in which
115
+ // case we record NO commit: an absent field is honest, a mismatched one is
116
+ // worse than nothing.
117
+ const onMergeCheckout = Boolean(firstNonEmpty(env.GITHUB_HEAD_REF));
118
+ const gitCommit = onMergeCheckout
119
+ ? run(['rev-parse', 'HEAD^2'], cwd)
120
+ : run(['rev-parse', 'HEAD'], cwd);
121
+
122
+ // Precedence matches every other field: the explicit `LOREKIT_*` override
123
+ // first, then what the environment can work out. `LOREKIT_REPO` losing to the
124
+ // git remote would make it the one override that does not override.
125
+ //
126
+ // Each candidate goes through `isValidRepo` individually rather than only the
127
+ // winner, so an odd remote FALLS THROUGH to `GITHUB_REPOSITORY` instead of
128
+ // shadowing it with a value the server's strict `parseOriginRepo` rejects —
129
+ // which would 400 the write this provenance only decorates.
130
+ const repo =
131
+ isValidRepo(env.LOREKIT_REPO) ??
132
+ isValidRepo(ownerRepoFromRemote(remote)) ??
133
+ isValidRepo(env.GITHUB_REPOSITORY);
134
+ const branchRaw = firstNonEmpty(env.LOREKIT_BRANCH, env.GITHUB_HEAD_REF, gitBranch);
135
+ // A derived branch that the server would reject is dropped here rather than
136
+ // sent: provenance is decoration, and it must never fail the write it is
137
+ // decorating.
138
+ const branch = branchRaw && branchRaw !== 'HEAD' && isValidRef(branchRaw) ? branchRaw : null;
139
+ // GITHUB_SHA is only a safe fallback OFF a merge checkout — on one it is the
140
+ // merge commit, the very value the branch above exists to avoid.
141
+ const commitRaw = onMergeCheckout
142
+ ? firstNonEmpty(env.LOREKIT_COMMIT, gitCommit)
143
+ : firstNonEmpty(env.LOREKIT_COMMIT, gitCommit, env.GITHUB_SHA);
144
+ const commit = commitRaw && /^[0-9a-fA-F]{7,40}$/.test(commitRaw) ? commitRaw.toLowerCase() : null;
145
+
146
+ return {
147
+ origin_repo: repo,
148
+ origin_branch: branch,
149
+ origin_commit: commit,
150
+ origin_pr: prNumberFromEnv(env),
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Normalise a candidate `owner/name`, or null when it is not one.
156
+ *
157
+ * The single repo rule for every derivation path, matching the server's
158
+ * `parseOriginRepo` (`packages/mcp-core/src/origin.ts`) — including its
159
+ * rejection of a dots-only segment, which `[\\w.-]+` alone admits and which
160
+ * would render as a link to a different repository.
161
+ */
162
+ export function isValidRepo(value) {
163
+ if (typeof value !== 'string') return null;
164
+ const normalized = value.trim().toLowerCase();
165
+ if (normalized === '' || normalized.length > 140) return null;
166
+ if (!/^[\w.-]+\/[\w.-]+$/.test(normalized)) return null;
167
+ if (normalized.split('/').some((segment) => /^\.+$/.test(segment))) return null;
168
+ return normalized;
169
+ }
170
+
171
+ /**
172
+ * Merge a derived origin under caller-supplied overrides, dropping null fields.
173
+ *
174
+ * An explicitly supplied value always wins; a field neither supplied nor
175
+ * derivable is omitted entirely rather than sent as `null`, so the server's
176
+ * "keep the last KNOWN origin" upsert rule never erases a previously recorded
177
+ * value.
178
+ */
179
+ export function mergeOrigin(derived = {}, overrides = {}) {
180
+ const out = {};
181
+ for (const field of ['origin_repo', 'origin_branch', 'origin_commit', 'origin_pr']) {
182
+ const value = overrides[field] ?? derived[field] ?? null;
183
+ if (value !== null && value !== undefined && value !== '') out[field] = value;
184
+ }
185
+ return out;
186
+ }
@@ -17,6 +17,13 @@ export const FIELDS = [
17
17
  'created',
18
18
  'updated',
19
19
  'archived_at',
20
+ // Provenance — where the lesson was recorded FROM (see src/origin.mjs).
21
+ // Appended, never reordered: parseEntry is tolerant, so a file written before
22
+ // these existed simply decodes them as absent.
23
+ 'origin_repo',
24
+ 'origin_branch',
25
+ 'origin_commit',
26
+ 'origin_pr',
20
27
  ];
21
28
 
22
29
  // Serialize an entry ({ ...columns, value }) into file text.
@@ -91,7 +91,10 @@ class LocalStore {
91
91
  // existing key (a creation date never moves). Returns { ok:false, error } on
92
92
  // an invalid or future-dated value rather than throwing, matching the store
93
93
  // contract's error surfacing.
94
- async write({ scope, key, value, tags, source_agent, trigger, created_at } = {}) {
94
+ async write({
95
+ scope, key, value, tags, source_agent, trigger, created_at,
96
+ origin_repo, origin_branch, origin_commit, origin_pr,
97
+ } = {}) {
95
98
  let override;
96
99
  try {
97
100
  override = normalizeCreatedAt(created_at);
@@ -109,6 +112,13 @@ class LocalStore {
109
112
  tags: Array.isArray(tags) ? tags : [],
110
113
  source_agent: source_agent || null,
111
114
  trigger: trigger || null,
115
+ // Provenance keeps the last KNOWN value per field, mirroring the hosted
116
+ // memory_write upsert: a write that does not know a field must not erase
117
+ // what a previous write recorded.
118
+ origin_repo: origin_repo ?? existing?.entry.origin_repo ?? null,
119
+ origin_branch: origin_branch ?? existing?.entry.origin_branch ?? null,
120
+ origin_commit: origin_commit ?? existing?.entry.origin_commit ?? null,
121
+ origin_pr: origin_pr ?? existing?.entry.origin_pr ?? null,
112
122
  created,
113
123
  updated: existing ? now : override || now,
114
124
  archived_at: null,
@@ -135,6 +145,10 @@ class LocalStore {
135
145
  tags: Array.isArray(entry.tags) ? entry.tags : [],
136
146
  source_agent: entry.source_agent ?? null,
137
147
  trigger: entry.trigger ?? null,
148
+ origin_repo: entry.origin_repo ?? null,
149
+ origin_branch: entry.origin_branch ?? null,
150
+ origin_commit: entry.origin_commit ?? null,
151
+ origin_pr: entry.origin_pr ?? null,
138
152
  created: entry.created ?? now,
139
153
  updated: entry.updated ?? now,
140
154
  archived_at: entry.archived_at ?? null,
@@ -91,7 +91,10 @@ class RemoteStore {
91
91
  }
92
92
 
93
93
  async write(args = {}) {
94
- const { scope, key, value, tags, source_agent, trigger, org, ttl_days, clear_ttl, created_at } = args;
94
+ const {
95
+ scope, key, value, tags, source_agent, trigger, org, ttl_days, clear_ttl, created_at,
96
+ origin_repo, origin_branch, origin_commit, origin_pr,
97
+ } = args;
95
98
  const body = { scope, key, value };
96
99
  if (tags !== undefined) body.tags = tags;
97
100
  if (source_agent !== undefined) body.source_agent = source_agent;
@@ -100,6 +103,13 @@ class RemoteStore {
100
103
  if (ttl_days !== undefined) body.ttl_days = ttl_days;
101
104
  if (clear_ttl !== undefined) body.clear_ttl = clear_ttl;
102
105
  if (created_at !== undefined) body.created_at = created_at;
106
+ // Provenance — only sent when known. Omitting a field leaves whatever the
107
+ // row already recorded intact (the RPC coalesces), which is what makes a
108
+ // write from a machine with no git context non-destructive.
109
+ if (origin_repo !== undefined) body.origin_repo = origin_repo;
110
+ if (origin_branch !== undefined) body.origin_branch = origin_branch;
111
+ if (origin_commit !== undefined) body.origin_commit = origin_commit;
112
+ if (origin_pr !== undefined) body.origin_pr = origin_pr;
103
113
  const res = await this._rest('/memories', { method: 'POST', body });
104
114
  return { ok: res.ok, error: res.error, networkError: res.networkError };
105
115
  }
package/src/write.mjs CHANGED
@@ -16,6 +16,16 @@
16
16
  // --ttl-days <n> Days until the memory auto-expires (1–365)
17
17
  // --org <slug> Write to this org (remote only)
18
18
  //
19
+ // Provenance — where the lesson is being recorded FROM. Derived automatically
20
+ // from git + the CI environment (repo, branch, commit, and the pull request
21
+ // from LOREKIT_PR / GITHUB_REF); the dashboard turns it into links back to the
22
+ // PR, branch and commit. Each can be overridden, and the whole thing skipped:
23
+ // --origin-repo <owner/name> Override the derived repository
24
+ // --origin-branch <name> Override the derived branch
25
+ // --origin-commit <sha> Override the derived commit
26
+ // --origin-pr <n> The pull request this lesson came out of
27
+ // --no-origin Record no provenance at all
28
+ //
19
29
  // Store targeting (default: remote if configured, else local):
20
30
  // --remote Force write to the remote store
21
31
  // --local Force write to the local offline store
@@ -32,6 +42,7 @@ import { resolveDenies } from './control.mjs';
32
42
  import { resolveStores, remoteUnavailableReason } from './stores.mjs';
33
43
  import { log, err, heading, status, c } from './util.mjs';
34
44
  import { parseScopeKey } from './lessons-view.mjs';
45
+ import { deriveOrigin, mergeOrigin } from './origin.mjs';
35
46
 
36
47
  // Read all of stdin to a string. Resolves to '' when stdin IS a TTY (no pipe).
37
48
  function readStdin() {
@@ -100,6 +111,32 @@ export async function write(args) {
100
111
  const ttlDays = args['ttl-days'] ? Number(args['ttl-days']) : undefined;
101
112
  const orgSlug = typeof args.org === 'string' ? args.org : undefined;
102
113
 
114
+ // ── Provenance ────────────────────────────────────────────────────────────
115
+ // Derived from git + CI unless --no-origin; explicit --origin-* flags win.
116
+ // A field that is neither supplied nor derivable is omitted, never sent as
117
+ // null — the server keeps the last KNOWN origin per field, so an omission
118
+ // must not erase what an earlier write recorded.
119
+ // An explicitly supplied PR number is a caller assertion, so a malformed one
120
+ // is a usage error — silently ignoring it would record no provenance while
121
+ // the user believes they set it. Derived values, by contrast, degrade quietly.
122
+ let originPr = null;
123
+ if (args['origin-pr'] !== undefined) {
124
+ originPr = Number(args['origin-pr']);
125
+ if (!Number.isInteger(originPr) || originPr < 1) {
126
+ err(`${c.red('Error:')} --origin-pr must be a positive integer (got ${args['origin-pr']})`);
127
+ return 1;
128
+ }
129
+ }
130
+
131
+ const origin = args['no-origin']
132
+ ? {}
133
+ : mergeOrigin(deriveOrigin({ cwd: root, env }), {
134
+ origin_repo: typeof args['origin-repo'] === 'string' ? args['origin-repo'] : null,
135
+ origin_branch: typeof args['origin-branch'] === 'string' ? args['origin-branch'] : null,
136
+ origin_commit: typeof args['origin-commit'] === 'string' ? args['origin-commit'] : null,
137
+ origin_pr: originPr,
138
+ });
139
+
103
140
  // ── Resolve deny constraints ───────────────────────────────────────────────
104
141
  const { localDenied, remoteDenied } = resolveDenies(root, { env });
105
142
 
@@ -162,6 +199,7 @@ export async function write(args) {
162
199
  ...(trigger ? { trigger } : {}),
163
200
  ...(ttlDays ? { ttl_days: ttlDays } : {}),
164
201
  ...(orgSlug ? { org: orgSlug } : {}),
202
+ ...origin,
165
203
  };
166
204
 
167
205
  let result;
@@ -192,6 +230,7 @@ export async function write(args) {
192
230
  tags,
193
231
  source_agent: sourceAgent || null,
194
232
  trigger: trigger || null,
233
+ origin,
195
234
  }, null, 2));
196
235
  } else {
197
236
  const verb = inserted === true ? 'Created' : inserted === false ? 'Updated' : 'Written';