agents-relay 2.0.79 → 2.0.80

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/README.md CHANGED
@@ -336,3 +336,7 @@ permissions:
336
336
  The caller must report only a release it has actually verified. For local-first Jobs, the action reads the Job ID and optional display PR from the authoritative local-merge commit message and posts the compatible `agents-relay:package-published:v1` or publish-failure marker to that display PR or to a reusable publication issue. Historical GitHub-merged PR correlation remains supported as a fallback. The marker and GitHub webhook are transport/correlation only: Agents Relay resolves the originating Job and persists publication facts through `RelayStore.recordPublication(...)` into the authoritative SQLite `publication_delivery` and `publication_state` tables. Publication success or failure never changes a completed Job's lifecycle state.
337
337
 
338
338
  `artifact` can identify an npm/PyPI package, Chrome extension, GitHub Release artifact, or a custom published artifact. `channel` identifies the registry/store/release channel and is displayed as publication metadata. Repeated reports of the same artifact/version are idempotent.
339
+
340
+ ### Project-scoped agent Markdown inheritance
341
+
342
+ Agent lookup has exactly two layers: Relay built-in `skills/agents-relay/agents/` and `<job-context-root>/agents/`. `job.projectPath` selects the context root within the repository (including monorepo content projects); no workspace/global agent fallback is searched. Project agent frontmatter can specify `extends: relay/planner`, `mode: extend`, and `sections: { replace: [Heading/Subheading], extend: [Other Heading] }` using heading paths, or `mode: replace` for a full behavioral replacement. Relay-owned lifecycle, Job/Task and events contracts are injected separately and cannot be replaced by a project agent. An unextended local agent is a standalone definition. The resolver emits a deterministic SHA-256 of composed Markdown; a missing section or unsupported parent fails instead of silently dropping instructions.
@@ -0,0 +1,147 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile, realpath } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ const parseList = (raw) => raw.trim().replace(/^\[/, '').replace(/\]$/, '').split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
5
+ /** Restrict the frontmatter grammar to the explicitly supported scalar/list fields. */
6
+ function parseDocument(raw) {
7
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw);
8
+ if (!match)
9
+ return { body: raw.trim(), name: undefined, extendsAgent: undefined, mode: undefined, replace: [], extend: [] };
10
+ const lines = match[1].split(/\r?\n/);
11
+ let name, extendsAgent, mode;
12
+ const sections = { replace: [], extend: [] };
13
+ let inSections = false;
14
+ for (const line of lines) {
15
+ const field = /^(name|extends|mode|sections):\s*(.*)$/.exec(line);
16
+ if (field) {
17
+ inSections = field[1] === 'sections';
18
+ if (field[1] === 'name')
19
+ name = field[2].trim();
20
+ if (field[1] === 'extends')
21
+ extendsAgent = field[2].trim();
22
+ if (field[1] === 'mode') {
23
+ if (!['extend', 'replace'].includes(field[2].trim()))
24
+ throw new Error(`Invalid agent mode: ${field[2]}`);
25
+ mode = field[2].trim();
26
+ }
27
+ }
28
+ else if (inSections) {
29
+ const entry = /^ (replace|extend):\s*(.+)$/.exec(line);
30
+ if (entry)
31
+ sections[entry[1]] = parseList(entry[2]);
32
+ else if (/^\S/.test(line))
33
+ inSections = false;
34
+ }
35
+ }
36
+ return { body: raw.slice(match[0].length).trim(), name, extendsAgent, mode, replace: sections.replace, extend: sections.extend };
37
+ }
38
+ function indexSections(lines) {
39
+ const sections = new Map();
40
+ const stack = [];
41
+ let fence = false;
42
+ for (let i = 0; i < lines.length; i++) {
43
+ if (/^\s*(```|~~~)/.test(lines[i])) {
44
+ fence = !fence;
45
+ continue;
46
+ }
47
+ if (fence)
48
+ continue;
49
+ const heading = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(lines[i]);
50
+ if (!heading)
51
+ continue;
52
+ const level = heading[1].length;
53
+ while (stack.length && stack[stack.length - 1].level >= level)
54
+ stack.pop();
55
+ const path = [...stack.map(x => x.title), heading[2].trim()].join('/');
56
+ if (sections.has(path))
57
+ throw new Error(`Ambiguous Markdown section ${path}`);
58
+ for (const section of sections.values())
59
+ if (section.end === lines.length && section.level >= level)
60
+ section.end = i;
61
+ sections.set(path, { path, start: i, end: lines.length, level });
62
+ stack.push({ title: heading[2].trim(), level });
63
+ }
64
+ return sections;
65
+ }
66
+ function mergeSections(base, child) {
67
+ if (child.mode === 'replace') {
68
+ if (child.replace.length || child.extend.length)
69
+ throw new Error('Whole-agent replace cannot contain sections overrides');
70
+ return child.body;
71
+ }
72
+ const operations = [...child.replace.map(path => ({ path, mode: 'replace' })), ...child.extend.map(path => ({ path, mode: 'extend' }))];
73
+ const seen = new Set();
74
+ for (const op of operations) {
75
+ if (seen.has(op.path))
76
+ throw new Error(`Conflicting section operation: ${op.path}`);
77
+ seen.add(op.path);
78
+ }
79
+ if (!operations.length)
80
+ return `${base.trim()}\n\n${child.body}`.trim();
81
+ let result = base;
82
+ for (const op of operations) {
83
+ const originalLines = result.split('\n');
84
+ const childLines = child.body.split('\n');
85
+ const target = indexSections(originalLines).get(op.path);
86
+ const source = indexSections(childLines).get(op.path);
87
+ if (!target || !source)
88
+ throw new Error(`Section ${op.path} missing in ${!target ? 'base' : 'overlay'} contract`);
89
+ const subsection = childLines.slice(source.start, source.end);
90
+ if (op.mode === 'replace')
91
+ originalLines.splice(target.start, target.end - target.start, ...subsection);
92
+ else
93
+ originalLines.splice(target.end, 0, ...subsection.slice(1));
94
+ result = originalLines.join('\n');
95
+ }
96
+ return result.trim();
97
+ }
98
+ /** Exactly two lookup layers: Relay built-ins and a single job context agents directory. */
99
+ export async function resolveAgentMarkdown(name, builtinsRoot, contextRoot) {
100
+ if (!/^[a-zA-Z0-9_-]+$/.test(name))
101
+ throw new Error(`Invalid agent name ${name}`);
102
+ const builtin = join(builtinsRoot, `${name}.agent.md`);
103
+ const local = join(contextRoot, 'agents', `${name}.agent.md`);
104
+ const sources = [];
105
+ const load = async (path, root) => {
106
+ const canonical = await realpath(path);
107
+ const canonicalRoot = await realpath(root);
108
+ if (!(canonical === canonicalRoot || canonical.startsWith(canonicalRoot + '/')))
109
+ throw new Error('Agent file escapes context root');
110
+ const source = parseDocument(await readFile(canonical, 'utf8'));
111
+ if (source.name && source.name !== name)
112
+ throw new Error(`Agent name mismatch ${source.name} != ${name}`);
113
+ sources.push(canonical);
114
+ return source;
115
+ };
116
+ let base;
117
+ try {
118
+ base = await load(builtin, builtinsRoot);
119
+ }
120
+ catch (e) {
121
+ if (e.code !== 'ENOENT')
122
+ throw e;
123
+ }
124
+ let overlay;
125
+ try {
126
+ overlay = await load(local, contextRoot + '/agents');
127
+ }
128
+ catch (e) {
129
+ if (e.code !== 'ENOENT')
130
+ throw e;
131
+ }
132
+ if (!base && !overlay)
133
+ throw new Error(`Unknown agent ${name}`);
134
+ if (overlay && (overlay.replace.length || overlay.extend.length) && !overlay.extendsAgent)
135
+ throw new Error('Section operations require extends');
136
+ if (overlay?.extendsAgent && overlay.extendsAgent !== `relay/${name}`)
137
+ throw new Error(`Only extends: relay/${name} is supported`);
138
+ if (overlay?.extendsAgent && !base)
139
+ throw new Error(`Missing Relay base for agent ${name}`);
140
+ if (overlay && base && !overlay.extendsAgent) { // explicit local definition without inheritance
141
+ if (overlay.replace.length || overlay.extend.length)
142
+ throw new Error('Section operations require extends');
143
+ return { content: overlay.body, sources: [sources[sources.length - 1]], hash: createHash('sha256').update(overlay.body).digest('hex') };
144
+ }
145
+ const content = overlay ? mergeSections(base?.body ?? '', overlay) : base.body;
146
+ return { content, sources, hash: createHash('sha256').update(content).digest('hex') };
147
+ }
package/dist/cli-help.js CHANGED
@@ -58,13 +58,13 @@ Required: --repo OWNER/REPO --pr NUMBER --id JOB_ID.
58
58
  Optional: --thread-id THREAD_ID persists a reviewer override; omitted uses Job reviewer/creator thread or legacy route. Rejects pending reviews and wakes managed reconciliation.`,
59
59
  'job create': `Create a SQLite-authoritative Job.
60
60
 
61
- Required: --repo OWNER/REPO, --id JOB_ID, and either --head BRANCH or --pr NUMBER.
62
- With --head, bind the local source branch and use --base as its local merge target without a GitHub query. With --pr, bind an existing external PR reference as optional metadata without a GitHub query.
63
- Common: --base BRANCH (default: main), --title TEXT, --objective-file PATH|- (preferred), --objective MARKDOWN (short/simple only), --project-path RELATIVE_PATH, --priority P0|P1|P2|P3, --mode fixed|autonomous, --initial-plan, --no-review, --tag JSON, --reviewer-thread-id ID, --creator-thread-id ID, --implementation-thread-id ID. --objective and --objective-file are mutually exclusive. For a new --head Job, one is required and must contain enough standalone context to complete the Job without the originating chat/thread. Objective Markdown is also the default PR body; --body is an explicit override.
61
+ Required: --repo OWNER/REPO and a standalone Job objective (unless binding an existing PR). Job ID defaults to an auto-incrementing repository-scoped number; --id is optional for compatibility.
62
+ With --head, bind the local source branch and use --base as its local merge target without a GitHub query. With --pr, bind an existing external PR reference as optional metadata without a GitHub query. Without either, create a local-first content Job; local Job completion still follows the configured Graph lifecycle.
63
+ Common: --id JOB_ID (optional), --idempotency-key KEY (for retries), --base BRANCH (default: main), --title TEXT, --objective-file PATH|- (preferred), --objective MARKDOWN (short/simple only), --project-path RELATIVE_PATH, --priority P0|P1|P2|P3, --mode fixed|autonomous, --initial-plan, --no-review, --tag JSON, --reviewer-thread-id ID, --creator-thread-id ID, --implementation-thread-id ID. --objective and --objective-file are mutually exclusive. For a new --head Job, one is required and must contain enough standalone context to complete the Job without the originating chat/thread. Objective Markdown is also the default PR body; --body is an explicit override.
64
64
  Job creation always enables auto-merge. By default it is create-only; --initial-plan follows durable Job creation with the first real Planner round, activates the Job, and publishes the normal wake so Relay can continue without a second orchestrator command. Re-running create against an already healthy Job is idempotent and does not mutate its metadata.
65
65
 
66
66
  Example:
67
- npx agents-relay job create --repo OWNER/REPO --head feat/example --base main --id job-1 --title "Objective" --objective-file job.md --initial-plan`,
67
+ npx agents-relay job create --repo OWNER/REPO --project-path vlog --title "Episode" --objective-file episode.md --idempotency-key submission-123 --initial-plan`,
68
68
  'job update': `Partially update mutable metadata on a healthy existing Job.
69
69
 
70
70
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
@@ -116,11 +116,11 @@ Actions:
116
116
  'task create': `Create one durable Task under an existing Job.
117
117
 
118
118
  Required: normal Job storage options plus --input-file PATH|- (preferred) or --input MARKDOWN, and --output task-pr|file. --input and --input-file are mutually exclusive.
119
- Common: --task-id ID, --priority P0|P1|P2|P3, --story-size S|M|L, --agent NAME, --agent-graph-file PATH|-, --deps a,b, --parent ID, --draft. Normal model-backed Tasks omit adapter/provider/model so Relay runs one Execution Router decision over complete worker+model routes. Use --adapter orchestrator only for synchronous self-work; explicit adapter/provider/model remain compatibility/debug constraints.\nTasks with dependencies start DRAFT; tasks without dependencies start QUEUED unless --draft is given.
119
+ Common: --task-id ID (optional; Relay auto-assigns repository-scoped task number), --idempotency-key KEY, --priority P0|P1|P2|P3, --story-size S|M|L, --agent NAME, --agent-graph-file PATH|-, --deps a,b, --parent ID, --draft. Normal model-backed Tasks omit adapter/provider/model so Relay runs one Execution Router decision over complete worker+model routes. Use --adapter orchestrator only for synchronous self-work; explicit adapter/provider/model remain compatibility/debug constraints.\nTasks with dependencies start DRAFT; tasks without dependencies start QUEUED unless --draft is given.
120
120
  Directly-created Tasks are not wrapped in a synthetic Plan. Planner-generated Tasks are created internally by Relay from planner output.
121
121
 
122
122
  Example:
123
- npx agents-relay task create --repo OWNER/REPO --pr 12 --id job-1 --task-id build --output task-pr --input-file task.md`,
123
+ npx agents-relay task create --repo OWNER/REPO --id j-5-owner-repo-1 --output file --output-path story.md --input-file task.md`,
124
124
  'task update': `Update mutable durable Task fields in place.
125
125
 
126
126
  Required: --task-id ID and the normal Job storage options.
@@ -1,3 +1,4 @@
1
+ import { allocateEntityId, isGeneratedEntityId } from './job-task-id-sequence.js';
1
2
  import { openLocalManagedStore } from './local-managed-store.js';
2
3
  import { createJobEntity } from './workflow/entity-creation.js';
3
4
  import { normalizeThreadId } from './reviewer-thread.js';
@@ -7,30 +8,36 @@ import { discoverLocalRelayStoreSnapshots, relayStoreDirectory } from './relay-s
7
8
  * required; an explicit PR number is optional external metadata.
8
9
  */
9
10
  export async function ensureManagedGitHubJob(_client, repositoryName, _trustedAuthors, options) {
10
- if (!options.id)
11
- throw new Error('--id is required for managed local Jobs');
11
+ const directory = options.storeDirectory ?? relayStoreDirectory();
12
+ const knownJobs = (await discoverLocalRelayStoreSnapshots(directory)).flatMap(snapshot => snapshot.jobs);
13
+ const id = options.id || allocateEntityId(directory, repositoryName, 'job', options.idempotencyKey, new Set(knownJobs.map(job => job.id)));
12
14
  const requestedNumber = options.prNumber ?? 0;
13
15
  if (!Number.isInteger(requestedNumber) || requestedNumber < 0)
14
16
  throw new Error('Invalid external PR binding number');
15
- const placeholder = { number: requestedNumber, title: options.title ?? options.id,
17
+ const placeholder = { number: requestedNumber, title: options.title ?? id,
16
18
  body: options.body ?? options.objective ?? '', state: 'OPEN', head: options.head ?? '', base: options.base ?? '' };
17
- const directory = options.storeDirectory ?? relayStoreDirectory();
18
- const alreadyKnown = (await discoverLocalRelayStoreSnapshots(directory)).flatMap(snapshot => snapshot.jobs.filter(job => job.id === options.id).map(job => ({ job, path: snapshot.path })));
19
+ const alreadyKnown = knownJobs.filter(job => job.id === id);
19
20
  if (alreadyKnown.length > 1)
20
- throw new Error('Duplicate immutable Job ID across SQLite stores: ' + options.id);
21
+ throw new Error('Duplicate immutable Job ID across SQLite stores: ' + id);
21
22
  if (alreadyKnown.length === 1) {
22
- const existing = alreadyKnown[0].job;
23
+ const existing = alreadyKnown[0];
23
24
  if (existing.repository !== repositoryName || (options.prNumber !== undefined && existing.prNumber !== requestedNumber))
24
25
  throw new Error('Immutable Job ID is already bound to a different repository or external PR');
26
+ if (!options.id && !options.idempotencyKey)
27
+ throw new Error('Automatically allocated Job ID unexpectedly belongs to an existing Job');
28
+ if (options.idempotencyKey && ((options.objective && options.objective !== existing.objective) || (options.projectPath && options.projectPath !== existing.projectPath) || (options.title && options.title !== existing.title)))
29
+ throw new Error('Job idempotency key reused for a different Job definition');
25
30
  return { job: existing, pr: { ...placeholder, number: existing.prNumber } };
26
31
  }
32
+ if (options.id && isGeneratedEntityId(id, repositoryName, 'job'))
33
+ throw new Error('Reserved auto-generated Job ID cannot be assigned manually');
27
34
  const pr = placeholder;
28
35
  const number = pr.number;
29
36
  const local = openLocalManagedStore(repositoryName, number, {
30
- directory, jobId: options.id, noGitHub: true,
37
+ directory, jobId: id, noGitHub: true,
31
38
  });
32
39
  try {
33
- const existing = await local.relay.loadJob(options.id);
40
+ const existing = await local.relay.loadJob(id);
34
41
  if (existing)
35
42
  return { job: existing, pr };
36
43
  const now = new Date().toISOString();
@@ -41,7 +48,7 @@ export async function ensureManagedGitHubJob(_client, repositoryName, _trustedAu
41
48
  throw new Error('Job base must always be main');
42
49
  const localMergeTarget = localMergeSource ? 'refs/heads/main' : undefined;
43
50
  const job = createJobEntity({
44
- id: options.id,
51
+ id,
45
52
  title: options.title || pr.title || 'Agents Relay job',
46
53
  objective: options.objective || pr.body || undefined,
47
54
  projectPath: options.projectPath,
@@ -70,7 +77,7 @@ export async function ensureManagedGitHubJob(_client, repositoryName, _trustedAu
70
77
  ...(options.schedule ? { schedule: options.schedule } : {}),
71
78
  }, { activate: Boolean(options.schedule) });
72
79
  await local.store.saveJob(job);
73
- return { job: await local.store.load(options.id), pr };
80
+ return { job: await local.store.load(id), pr };
74
81
  }
75
82
  finally {
76
83
  local.relay.close();
package/dist/cli.js CHANGED
@@ -45,8 +45,8 @@ async function runManagedTaskCommand(action, args) {
45
45
  const jobId = arg(args, '--id');
46
46
  if (!jobId)
47
47
  throw new Error('--id is required for managed task commands');
48
- const id = arg(args, '--task-id', action === 'create' ? randomUUID() : '');
49
- if (!id)
48
+ let id = arg(args, '--task-id');
49
+ if (!id && action !== 'create')
50
50
  throw new Error(`task ${action} requires --task-id`);
51
51
  let result;
52
52
  if (action === 'create') {
@@ -55,7 +55,9 @@ async function runManagedTaskCommand(action, args) {
55
55
  throw new Error(`Cannot create task on terminal job ${job.id} (${job.state})`);
56
56
  const task = await workerTaskFromArgs(job, args, new Date().toISOString());
57
57
  task.id = id;
58
- result = await relayTaskCommand(args, { jobId, action, task });
58
+ result = await relayTaskCommand(args, { jobId, action, task, idempotencyKey: arg(args, '--idempotency-key') || undefined });
59
+ if (!id)
60
+ id = result.createdTaskId ?? '';
59
61
  }
60
62
  else if (action === 'complete') {
61
63
  const summary = arg(args, '--summary');
@@ -81,7 +83,7 @@ async function runTaskCommand(action, args) {
81
83
  console.log(JSON.stringify(job.tasks, null, 2));
82
84
  return;
83
85
  }
84
- const taskId = arg(args, '--task-id', action === 'create' ? randomUUID() : '');
86
+ const taskId = arg(args, '--task-id');
85
87
  if (action === 'show') {
86
88
  if (!taskId)
87
89
  throw new Error('task show requires --task-id');
@@ -367,7 +369,7 @@ async function runJobCommand(action, args) {
367
369
  return;
368
370
  }
369
371
  const id = arg(args, '--id');
370
- if (!id)
372
+ if (!id && action !== 'create')
371
373
  throw new Error('--id is required for managed local Jobs');
372
374
  const prValue = arg(args, '--pr');
373
375
  const prNumber = prValue ? Number(prValue) : 0;
@@ -378,7 +380,7 @@ async function runJobCommand(action, args) {
378
380
  if (hasArg(args, '--local-merge-source') !== hasArg(args, '--local-merge-target'))
379
381
  throw new Error('Supply both --local-merge-source and --local-merge-target');
380
382
  const result = await relayControlCommand(args, { command: 'job.create', repository: repo, initialPlan: hasArg(args, '--initial-plan'), options: {
381
- id, title: arg(args, '--title') || undefined, objective: objective || undefined, projectPath: arg(args, '--project-path') || undefined,
383
+ id: id || undefined, idempotencyKey: arg(args, '--idempotency-key') || undefined, title: arg(args, '--title') || undefined, objective: objective || undefined, projectPath: arg(args, '--project-path') || undefined,
382
384
  priority: priority(args), executionMode: requestedExecutionMode(args), schedule: scheduleFromArgs(args), tag: jobTagFromArgs(args), reviewEnabled: !hasArg(args, '--no-review'), continuation: continuation(args),
383
385
  reviewerThreadId: hasArg(args, '--reviewer-thread-id') ? arg(args, '--reviewer-thread-id') : undefined, creatorThreadId: hasArg(args, '--creator-thread-id') ? arg(args, '--creator-thread-id') : undefined, implementationThreadId: hasArg(args, '--implementation-thread-id') ? arg(args, '--implementation-thread-id') : undefined,
384
386
  prNumber, head: arg(args, '--head') || undefined, base: arg(args, '--base', 'main'), body: arg(args, '--body') || objective || undefined,
@@ -335,7 +335,7 @@ function field(label,input){const box=element('div','detail-group');box.append(e
335
335
  function markdownField(label,input){const box=element('div','detail-group');box.append(element('p','detail-label',text(label)),markdownValue(input));return box;}
336
336
  function rememberSelection(){if(selectedJob)sessionStorage.setItem('agents-relay:selected-job',selectedJob);else sessionStorage.removeItem('agents-relay:selected-job');if(selectedTaskId)sessionStorage.setItem('agents-relay:selected-task',selectedTaskId);else sessionStorage.removeItem('agents-relay:selected-task');}
337
337
  function syncMobileJobsView(){const detail=mobileJobs.matches&&Boolean(selectedJob)&&location.hash.startsWith('#/jobs/');document.body.dataset.mobileJobsView=detail?'detail':'list';}
338
- function jobSelectionKey(job){return job?String(job.repository||'')+'|'+String(job.prNumber||'')+'|'+String(job.id||''):'';}
338
+ function shortManagedId(id,repository,kind){const parts=String(repository||'').split('/');if(parts.length!==2)return String(id||'');const prefix=kind+'-'+parts[0].length+'-'+parts[0]+'-'+parts[1]+'-';const value=String(id||'');const suffix=value.startsWith(prefix)?value.slice(prefix.length):'';return /^[0-9]+$/.test(suffix)?'#'+suffix:value;}function jobSelectionKey(job){return job?String(job.repository||'')+'|'+String(job.prNumber||'')+'|'+String(job.id||''):'';}
339
339
  function jobForSelection(selection){return jobs.find(job=>jobSelectionKey(job)===selection)||jobs.find(job=>job.id===selection)||null;}
340
340
  function selected(){return jobForSelection(selectedJob)||jobs[0]||null;}
341
341
  function counts(tasks){const out={};for(const task of Array.isArray(tasks)?tasks:[])out[task.state]=(out[task.state]||0)+1;return out;}
@@ -355,7 +355,7 @@ async function loadEventHistory(){const job=selected();if(!job){clearEvents();re
355
355
  function copySelectedJobIdentity(job){if(!job||!navigator.clipboard?.writeText)return;void navigator.clipboard.writeText(jobChatRef(job)).catch(()=>{});}
356
356
  function selectJob(job){selectedJob=jobSelectionKey(job);selectedTaskId=null;eventTaskId=null;rememberSelection();clearEvents();location.hash=selectedJob?'#/jobs/'+encodeURIComponent(selectedJob):'#/jobs';copySelectedJobIdentity(job);renderAll();void loadEventHistory();}
357
357
  function matchesQuickFilter(job){if(quickMode==='autonomous')return job.executionMode==='autonomous';if(quickMode==='draft')return job.draft||job.state==='DRAFT';if(quickMode==='status')return !!selectedRuntimeStatus&&(job.runtimeStatus||'IDLE')===selectedRuntimeStatus;return true;}
358
- function matchesSearch(job){const query=searchQuery.trim().toLowerCase();if(!query)return true;const pr=String(job.prNumber||'');const normalized=query.replace(/^pr\s*#?/,'').replace(/^#/,'');return String(job.title||job.id||'').toLowerCase().includes(query)||pr.includes(normalized);}
358
+ function matchesSearch(job){const query=searchQuery.trim().toLowerCase();if(!query)return true;const pr=String(job.prNumber||'');const normalized=query.replace(/^pr\s*#?/,'').replace(/^#/,'');return String(job.title||job.id||'').toLowerCase().includes(query)||String(job.id||'').toLowerCase().includes(query)||shortManagedId(job.id,job.repository,'j').toLowerCase().includes(query)||pr.includes(normalized);}
359
359
  function filteredJobs(){return jobs.filter(job=>(!selectedRepo||job.repository===selectedRepo)&&matchesQuickFilter(job)&&matchesSearch(job));}
360
360
  function setQuickMode(mode,status=''){quickMode=mode;selectedRuntimeStatus=mode==='status'?status:'';sessionStorage.setItem('agents-relay:quick-filter',quickMode);if(selectedRuntimeStatus)sessionStorage.setItem('agents-relay:runtime-status-filter',selectedRuntimeStatus);else sessionStorage.removeItem('agents-relay:runtime-status-filter');syncFilters();renderJobList();}
361
361
  function toggleQuickMode(mode){setQuickMode(quickMode===mode?'jobs':mode);}
@@ -388,10 +388,10 @@ function jobChatRef(job){const pr=Number(job?.externalPrNumber||job?.prNumber||0
388
388
  function chatShortcutUrl(prompt){return 'https://chatgpt.com/?prompt='+encodeURIComponent(prompt);}
389
389
  const CHAT_SHORTCUT_TARGET='agents-relay-shortcut';
390
390
  function chatShortcutField(job){const box=element('div','detail-group');box.append(element('p','detail-label',text('ChatGPT shortcut')));const select=document.createElement('select');select.className='job-filter job-chat-shortcut-select';select.setAttribute('aria-label','ChatGPT shortcut');select.append(new Option('',''));for(const item of parseChatShortcuts(chatShortcutText()))select.append(new Option(item.name,item.prompt));select.value='';select.addEventListener('change',()=>{if(!select.value)return;const prompt=select.value.replaceAll('%1',jobChatRef(job));window.open(chatShortcutUrl(prompt),CHAT_SHORTCUT_TARGET,'noopener');select.value='';});box.append(select);return box;}
391
- function renderJobList(){jobList.replaceChildren();const visible=filteredJobs();if(!jobs.length){jobList.append(element('li','empty',text('No durable jobs discovered.')));return;}if(!visible.length){jobList.append(element('li','empty',text('No jobs match the selected repo.')));return;}for(const job of visible){const row=element('div','job-row'+(isCompleteUnmerged(job)?' complete-unmerged':''));row.setAttribute('aria-current',String(jobSelectionKey(job)===selectedJob));row.addEventListener('click',()=>selectJob(job));const content=element('div','job-content');const select=document.createElement('button');select.type='button';select.className='job-button';select.setAttribute('aria-current',String(jobSelectionKey(job)===selectedJob));select.title=job.title||job.id;select.addEventListener('click',event=>{event.stopPropagation();selectJob(job);});select.append(element('span','job-title',text(repoName(job.repository)+'/'+(Number(job.externalPrNumber||job.prNumber||0)>0?'PR#'+value(job.externalPrNumber||job.prNumber):'Job '+value(job.id))+': '+value(job.title||job.id))));const states=element('span','job-status-line');states.append(element('span','badge '+value(job.state),text(value(job.state))),element('span','badge '+value(job.runtimeStatus||'IDLE'),text(value(job.runtimeStatus||'IDLE'))));if(job.publicationFailure)states.append(element('span','badge FAILED',text('PUBLISH FAILED: '+value(job.publicationFailure.stage))));else if(job.publication?.version)states.append(element('span','badge SUCCEEDED',text('v'+job.publication.version)));else if(Number(job.externalPrNumber||0)>0&&job.githubState==='MERGED')states.append(element('span','badge MERGED',text('PR Merged')));else if(job.state==='COMPLETED'&&job.githubState==='CLOSED')states.append(element('span','badge SUCCEEDED',text('PR Archived')));if(job.executionMode==='autonomous')states.append(element('span','badge flag-autonomous',text('AUTONOMOUS')));if(job.state==='ACTIVE'&&job.reviewEnabled!==false)states.append(element('span','badge flag-review',text('REVIEW REQUIRED')));if(isCompleteUnmerged(job))states.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));const primary=element('div','job-primary');primary.append(states);const lastLine=element('div','job-last-line');lastLine.append(element('span','subtle',text(compactCounts(counts(job.tasks)))),element('span','subtle',text(relativeTime(job.updatedAt))));content.append(select,primary,lastLine);row.append(content);jobList.append(element('li','',row));}}
391
+ function renderJobList(){jobList.replaceChildren();const visible=filteredJobs();if(!jobs.length){jobList.append(element('li','empty',text('No durable jobs discovered.')));return;}if(!visible.length){jobList.append(element('li','empty',text('No jobs match the selected repo.')));return;}for(const job of visible){const row=element('div','job-row'+(isCompleteUnmerged(job)?' complete-unmerged':''));row.setAttribute('aria-current',String(jobSelectionKey(job)===selectedJob));row.addEventListener('click',()=>selectJob(job));const content=element('div','job-content');const select=document.createElement('button');select.type='button';select.className='job-button';select.setAttribute('aria-current',String(jobSelectionKey(job)===selectedJob));select.title=job.title||job.id;select.addEventListener('click',event=>{event.stopPropagation();selectJob(job);});select.append(element('span','job-title',text(repoName(job.repository)+'/'+(Number(job.externalPrNumber||job.prNumber||0)>0?'PR#'+value(job.externalPrNumber||job.prNumber):'Job '+shortManagedId(job.id,job.repository,'j'))+': '+value(job.title||job.id))));const states=element('span','job-status-line');states.append(element('span','badge '+value(job.state),text(value(job.state))),element('span','badge '+value(job.runtimeStatus||'IDLE'),text(value(job.runtimeStatus||'IDLE'))));if(job.publicationFailure)states.append(element('span','badge FAILED',text('PUBLISH FAILED: '+value(job.publicationFailure.stage))));else if(job.publication?.version)states.append(element('span','badge SUCCEEDED',text('v'+job.publication.version)));else if(Number(job.externalPrNumber||0)>0&&job.githubState==='MERGED')states.append(element('span','badge MERGED',text('PR Merged')));else if(job.state==='COMPLETED'&&job.githubState==='CLOSED')states.append(element('span','badge SUCCEEDED',text('PR Archived')));if(job.executionMode==='autonomous')states.append(element('span','badge flag-autonomous',text('AUTONOMOUS')));if(job.state==='ACTIVE'&&job.reviewEnabled!==false)states.append(element('span','badge flag-review',text('REVIEW REQUIRED')));if(isCompleteUnmerged(job))states.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));const primary=element('div','job-primary');primary.append(states);const lastLine=element('div','job-last-line');lastLine.append(element('span','subtle',text(compactCounts(counts(job.tasks)))),element('span','subtle',text(relativeTime(job.updatedAt))));content.append(select,primary,lastLine);row.append(content);jobList.append(element('li','',row));}}
392
392
  function taskTagDisplay(job,task){const display=task.tag&&job?.tag?.values?.[task.tag];return typeof display==='string'&&display?display:null;}
393
393
  function taskSubtreeComplete(task,children){return ['SUCCEEDED','CANCELLED'].includes(task.state)&&(children.get(task.id)||[]).every(child=>taskSubtreeComplete(child,children));}
394
- function taskNode(task,children,depth){const li=element('li');const nested=children.get(task.id)||[];const line=element('div','task-line');if(nested.length){const key=(selectedJob||'')+':'+task.id;const collapsed=collapsedTasks.has(key)||(!expandedTasks.has(key)&&taskSubtreeComplete(task,children));const fold=document.createElement('button');fold.type='button';fold.className='task-fold';fold.setAttribute('aria-expanded',String(!collapsed));fold.setAttribute('aria-label',(collapsed?'Expand ':'Collapse ')+task.id);fold.textContent=collapsed?'▸':'▾';line.append(fold);const ul=element('ul','tree');ul.hidden=collapsed;for(const child of nested)ul.append(taskNode(child,children,depth+1));fold.addEventListener('click',()=>{const next=!ul.hidden;ul.hidden=next;fold.setAttribute('aria-expanded',String(!next));fold.setAttribute('aria-label',(next?'Expand ':'Collapse ')+task.id);fold.textContent=next?'▸':'▾';if(next){collapsedTasks.add(key);expandedTasks.delete(key);}else{collapsedTasks.delete(key);expandedTasks.add(key);}});li.append(line,ul);}else li.append(line);const row=document.createElement('button');row.type='button';row.className='task-row';row.setAttribute('aria-current',String(task.id===selectedTaskId));row.title=task.id;row.addEventListener('click',()=>{const deselect=selectedTaskId===task.id;selectedTaskId=deselect?null:task.id;eventTaskId=deselect?null:task.id;rememberSelection();clearEvents();renderAll();void loadEventHistory();});const id=element('span','task-id',text(task.id));id.style.paddingLeft=Math.min(depth,4)*6+'px';row.append(id,element('span','badge '+value(task.state),text(task.state)));const tag=taskTagDisplay(selected(),task);if(tag){const badge=element('span','task-tag',text(tag));badge.title=(selected()?.tag?.description||'Task tag')+': '+task.tag;row.append(badge);}line.append(row);return li;}
394
+ function taskNode(task,children,depth){const li=element('li');const nested=children.get(task.id)||[];const line=element('div','task-line');if(nested.length){const key=(selectedJob||'')+':'+task.id;const collapsed=collapsedTasks.has(key)||(!expandedTasks.has(key)&&taskSubtreeComplete(task,children));const fold=document.createElement('button');fold.type='button';fold.className='task-fold';fold.setAttribute('aria-expanded',String(!collapsed));fold.setAttribute('aria-label',(collapsed?'Expand ':'Collapse ')+task.id);fold.textContent=collapsed?'▸':'▾';line.append(fold);const ul=element('ul','tree');ul.hidden=collapsed;for(const child of nested)ul.append(taskNode(child,children,depth+1));fold.addEventListener('click',()=>{const next=!ul.hidden;ul.hidden=next;fold.setAttribute('aria-expanded',String(!next));fold.setAttribute('aria-label',(next?'Expand ':'Collapse ')+task.id);fold.textContent=next?'▸':'▾';if(next){collapsedTasks.add(key);expandedTasks.delete(key);}else{collapsedTasks.delete(key);expandedTasks.add(key);}});li.append(line,ul);}else li.append(line);const row=document.createElement('button');row.type='button';row.className='task-row';row.setAttribute('aria-current',String(task.id===selectedTaskId));row.title=task.id;row.addEventListener('click',()=>{const deselect=selectedTaskId===task.id;selectedTaskId=deselect?null:task.id;eventTaskId=deselect?null:task.id;rememberSelection();clearEvents();renderAll();void loadEventHistory();});const id=element('span','task-id',text(shortManagedId(task.id,selected()?.repository,'t')));id.style.paddingLeft=Math.min(depth,4)*6+'px';row.append(id,element('span','badge '+value(task.state),text(task.state)));const tag=taskTagDisplay(selected(),task);if(tag){const badge=element('span','task-tag',text(tag));badge.title=(selected()?.tag?.description||'Task tag')+': '+task.tag;row.append(badge);}line.append(row);return li;}
395
395
  function buildTree(tasks){taskTree.replaceChildren();if(!Array.isArray(tasks)||!tasks.length){taskTree.append(element('li','empty',text('No tasks in durable state.')));return;}const work=tasks.filter(task=>task.kind!=='planner');if(!work.length){taskTree.append(element('li','empty',text('No work tasks in durable state.')));return;}const known=new Set(work.map(task=>task.id));const children=new Map();const roots=[];for(const task of work){if(!task.parentTaskId||!known.has(task.parentTaskId))roots.push(task);else{const siblings=children.get(task.parentTaskId)||[];siblings.push(task);children.set(task.parentTaskId,siblings);}}for(const task of roots)taskTree.append(taskNode(task,children,0));}
396
396
  function attemptBudget(task){const attempt=Number(task?.attempt)||0;const max=Number(task?.maxAttempts)||0;if(max>=3&&max%3===0){const effectiveMax=Math.max(max,Math.ceil(attempt/3)*3);return attempt+' / 3×'+(effectiveMax/3);}return attempt+' / '+max;}
397
397
  const JOB_STATES=['DRAFT','ACTIVE','COMPLETED','CANCELLED'];
package/dist/dashboard.js CHANGED
@@ -268,7 +268,7 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
268
268
  const requestedJobId = url.searchParams.get('id') ?? '';
269
269
  const action = url.searchParams.get('action') ?? '';
270
270
  const handler = action === 'start' ? resolvedActions?.start : action === 'reconcile' ? resolvedActions?.reconcile : action === 'cancel' ? resolvedActions?.cancel : undefined;
271
- if (req.method !== 'POST' || !/^[A-Za-z0-9_-]+$/.test(requestedJobId) || !handler) {
271
+ if (req.method !== 'POST' || !/^[A-Za-z0-9_.-]+$/.test(requestedJobId) || !handler) {
272
272
  res.writeHead(404);
273
273
  res.end('Job action unavailable');
274
274
  return;
@@ -301,11 +301,11 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
301
301
  const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
302
302
  const requestedJobId = typeof body.jobId === 'string' ? body.jobId : '';
303
303
  const requestedTaskId = typeof body.taskId === 'string' ? body.taskId : '';
304
- if (!/^[A-Za-z0-9_-]+$/.test(requestedJobId))
304
+ if (!/^[A-Za-z0-9_.-]+$/.test(requestedJobId))
305
305
  throw new Error('Invalid job id');
306
306
  let result;
307
307
  if (body.action === 'create' && body.task && resolvedActions?.taskCreate)
308
- result = await resolvedActions.taskCreate(requestedJobId, body.task);
308
+ result = await resolvedActions.taskCreate(requestedJobId, body.task, body.idempotencyKey);
309
309
  else if (body.action === 'update' && isTaskId(requestedTaskId) && body.task && body.expectedUpdatedAt && resolvedActions?.taskUpdate)
310
310
  result = await resolvedActions.taskUpdate(requestedJobId, requestedTaskId, body.task, body.expectedUpdatedAt);
311
311
  else if (body.action === 'start' && isTaskId(requestedTaskId) && resolvedActions?.taskStart)
@@ -337,7 +337,7 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
337
337
  const requestedTaskId = url.searchParams.get('task') ?? '';
338
338
  const action = url.searchParams.get('action') ?? '';
339
339
  const handler = action === 'retry' ? resolvedActions?.taskRetry : action === 'cancel' ? resolvedActions?.taskCancel : undefined;
340
- if (req.method !== 'POST' || !/^[A-Za-z0-9_-]+$/.test(requestedJobId) || !isTaskId(requestedTaskId) || !handler) {
340
+ if (req.method !== 'POST' || !/^[A-Za-z0-9_.-]+$/.test(requestedJobId) || !isTaskId(requestedTaskId) || !handler) {
341
341
  res.writeHead(404);
342
342
  res.end('Task action unavailable');
343
343
  return;
@@ -365,7 +365,7 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
365
365
  const parsedPr = Number(url.searchParams.get('pr'));
366
366
  const prNumber = Number.isInteger(parsedPr) && parsedPr > 0 ? parsedPr : undefined;
367
367
  const validState = ['DRAFT', 'ACTIVE', 'COMPLETED', 'CANCELLED'].includes(requestedState);
368
- if (req.method !== 'POST' || !/^[A-Za-z0-9_-]+$/.test(requestedJobId) || !validState || !resolvedActions?.jobSetState) {
368
+ if (req.method !== 'POST' || !/^[A-Za-z0-9_.-]+$/.test(requestedJobId) || !validState || !resolvedActions?.jobSetState) {
369
369
  res.writeHead(404);
370
370
  res.end('Job state override unavailable');
371
371
  return;
@@ -394,7 +394,7 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
394
394
  const parsedPr = Number(url.searchParams.get('pr'));
395
395
  const prNumber = Number.isInteger(parsedPr) && parsedPr > 0 ? parsedPr : undefined;
396
396
  const validState = ['DRAFT', 'QUEUED', 'RUNNING', 'BLOCKED', 'SUCCEEDED', 'FAILED', 'CANCELLED'].includes(requestedState);
397
- if (req.method !== 'POST' || !/^[A-Za-z0-9_-]+$/.test(requestedJobId) || !isTaskId(requestedTaskId) || !validState || !resolvedActions?.taskSetState) {
397
+ if (req.method !== 'POST' || !/^[A-Za-z0-9_.-]+$/.test(requestedJobId) || !isTaskId(requestedTaskId) || !validState || !resolvedActions?.taskSetState) {
398
398
  res.writeHead(404);
399
399
  res.end('Task state override unavailable');
400
400
  return;
@@ -440,7 +440,7 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
440
440
  const requestedJobId = url.searchParams.get('job') ?? '';
441
441
  const requestedTaskId = url.searchParams.get('task');
442
442
  const requestedLimit = Number(url.searchParams.get('limit') ?? '60');
443
- if (!/^[A-Za-z0-9_-]+$/.test(requestedJobId)) {
443
+ if (!/^[A-Za-z0-9_.-]+$/.test(requestedJobId)) {
444
444
  res.writeHead(400);
445
445
  res.end('Invalid job id');
446
446
  return;
@@ -1,8 +1,9 @@
1
+ import { resolveAgentMarkdown } from './agent-content-resolver.js';
1
2
  import { access, readFile, readdir, realpath, stat } from 'node:fs/promises';
2
3
  import { homedir } from 'node:os';
3
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
- const BUNDLED_AGENTS_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills', 'agents-relay', 'agents');
6
+ export const BUNDLED_AGENTS_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills', 'agents-relay', 'agents');
6
7
  async function exists(path) {
7
8
  try {
8
9
  await access(path);
@@ -198,9 +199,26 @@ export async function resolveExecutionContext(projectPath, role, options = {}) {
198
199
  const agentMap = new Map();
199
200
  for (const agent of await agentFiles(dirname(BUNDLED_AGENTS_ROOT), 'builtin', []))
200
201
  agentMap.set(agent.name, agent);
201
- for (const layer of uniqueLayers) {
202
- for (const agent of await agentFiles(layer.root, layer.source, skillsByLayer.get(layer.root) ?? []))
203
- agentMap.set(agent.name, agent);
202
+ for (const agent of await agentFiles(project, 'project', [])) {
203
+ // An extending agent inherits the builtin identity metadata unless the project
204
+ // explicitly supplies a value. Local-only agents retain their own metadata.
205
+ const builtin = agentMap.get(agent.name);
206
+ if (builtin && agent.path) {
207
+ const { fields } = await metadata(agent.path);
208
+ if (fields.extends === `relay/${agent.name}`) {
209
+ agentMap.set(agent.name, {
210
+ ...builtin, ...agent,
211
+ description: fields.description === undefined ? builtin.description : agent.description,
212
+ capabilities: fields.capabilities === undefined ? builtin.capabilities : agent.capabilities,
213
+ runtime: fields.runtime === undefined && fields.runtimes === undefined ? builtin.runtime : agent.runtime,
214
+ trustDomains: fields.trustDomains === undefined && fields.trust_domains === undefined && fields.trust === undefined ? builtin.trustDomains : agent.trustDomains,
215
+ delegation: fields.delegation === undefined ? builtin.delegation : agent.delegation,
216
+ restrictions: fields.restrictions === undefined && fields.boundaries === undefined ? builtin.restrictions : agent.restrictions,
217
+ });
218
+ continue;
219
+ }
220
+ }
221
+ agentMap.set(agent.name, agent);
204
222
  }
205
223
  for (const registration of options.registeredAgents ?? []) {
206
224
  if (agentMap.has(registration.name))
@@ -234,16 +252,19 @@ export async function resolveAgentContract(context, agentName) {
234
252
  throw new Error(`Agent ${agentName} is not visible in the resolved execution context`);
235
253
  if (!agent.path)
236
254
  throw new Error(`Agent ${agentName} has no readable Markdown contract`);
237
- const definition = await readFile(agent.path, 'utf8');
255
+ const resolved = await resolveAgentMarkdown(agentName, BUNDLED_AGENTS_ROOT, context.project.path);
256
+ const definition = resolved.content;
238
257
  if (!definition.trim())
239
258
  throw new Error(`Agent ${agentName} Markdown contract is empty`);
240
- return { agent, definition };
259
+ return { agent, definition, hash: resolved.hash, sources: resolved.sources };
241
260
  }
242
261
  export async function agentContractPrompt(context, agentName) {
243
- const { agent, definition } = await resolveAgentContract(context, agentName);
262
+ const { agent, definition, hash, sources } = await resolveAgentContract(context, agentName);
244
263
  return `[Selected task agent contract]
245
264
  Agent: ${agent.name}
246
265
  Source: ${agent.path ?? agent.source}
266
+ Effective contract SHA-256: ${hash}
267
+ Contract layers: ${sources.join(' -> ')}
247
268
 
248
269
  The following Markdown is the authoritative behavioral contract for this Task agent.
249
270
  Follow it in addition to the shared Job/Task and events-bus contracts. Worker/model routing
@@ -277,7 +298,7 @@ ${instructions}
277
298
  Visible skills (already de-duplicated with project-local > Neo shared > global precedence):
278
299
  ${skills}
279
300
 
280
- Visible agents:
301
+ Visible agents (Relay built-in + Job context only; registered agents may supplement):
281
302
  ${agents}
282
303
 
283
304
  Runtime capabilities:
@@ -0,0 +1,53 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ export function generatedEntityPrefix(repository, kind) {
5
+ const [owner, repo] = repository.split('/');
6
+ if (!owner || !repo || !/^[\w.-]+$/.test(owner) || !/^[\w.-]+$/.test(repo) || owner === '..' || repo === '..')
7
+ throw new Error('Invalid repository');
8
+ return `${kind === 'job' ? 'j' : 't'}-${owner.length}-${owner}-${repo}-`;
9
+ }
10
+ export function isGeneratedEntityId(id, repository, kind) {
11
+ const prefix = generatedEntityPrefix(repository, kind);
12
+ return id.startsWith(prefix) && /^[0-9]+$/.test(id.slice(prefix.length));
13
+ }
14
+ /** Repository-scoped, monotonically increasing IDs. Allocation is serialized by SQLite. */
15
+ export function allocateEntityId(directory, repository, kind, requestKey, occupied = new Set()) {
16
+ const prefix = generatedEntityPrefix(repository, kind);
17
+ if (requestKey !== undefined && (!requestKey || requestKey.length > 512))
18
+ throw new Error('Invalid idempotency key');
19
+ mkdirSync(directory, { recursive: true });
20
+ const db = new DatabaseSync(join(directory, 'entity-id-sequences.sqlite'));
21
+ try {
22
+ db.exec('PRAGMA journal_mode=WAL; PRAGMA busy_timeout=10000;');
23
+ db.exec('CREATE TABLE IF NOT EXISTS sequences (repository TEXT NOT NULL, kind TEXT NOT NULL, value INTEGER NOT NULL, PRIMARY KEY(repository,kind)); CREATE TABLE IF NOT EXISTS idempotency (repository TEXT NOT NULL, kind TEXT NOT NULL, request_key TEXT NOT NULL, entity_id TEXT NOT NULL, PRIMARY KEY(repository,kind,request_key));');
24
+ db.exec('BEGIN IMMEDIATE');
25
+ try {
26
+ if (requestKey) {
27
+ const row = db.prepare('SELECT entity_id FROM idempotency WHERE repository=? AND kind=? AND request_key=?').get(repository, kind, requestKey);
28
+ if (row) {
29
+ db.exec('COMMIT');
30
+ return row.entity_id;
31
+ }
32
+ }
33
+ const old = db.prepare('SELECT value FROM sequences WHERE repository=? AND kind=?').get(repository, kind);
34
+ let next = (old?.value ?? 0) + 1;
35
+ while (occupied.has(prefix + next))
36
+ next++;
37
+ db.prepare('INSERT INTO sequences VALUES (?,?,?) ON CONFLICT(repository,kind) DO UPDATE SET value=excluded.value').run(repository, kind, next);
38
+ // Length prefix keeps repository names unambiguous while satisfying dashboard ID validation.
39
+ const id = prefix + next;
40
+ if (requestKey)
41
+ db.prepare('INSERT INTO idempotency VALUES (?,?,?,?)').run(repository, kind, requestKey, id);
42
+ db.exec('COMMIT');
43
+ return id;
44
+ }
45
+ catch (error) {
46
+ db.exec('ROLLBACK');
47
+ throw error;
48
+ }
49
+ }
50
+ finally {
51
+ db.close();
52
+ }
53
+ }
@@ -1,3 +1,4 @@
1
+ import { assignPlannerTaskIds } from './planner-task-ids.js';
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { jobHasActiveExecution } from './job-execution-slot.js';
3
4
  import { eventFor } from './events.js';
@@ -119,7 +120,8 @@ async function finishPlanner(ports, jobId, planId, executionId, error, raw) {
119
120
  }
120
121
  let planCommitted = false;
121
122
  try {
122
- const result = parsePlannerResult(raw);
123
+ const parsed = parsePlannerResult(raw);
124
+ const result = assignPlannerTaskIds(parsed, job.repository, job.id, plan.id);
123
125
  const executionContext = await ports.plannerContext(job, plan);
124
126
  validatePlannerHierarchy(job.tasks, result.next_tasks);
125
127
  validatePlannerResolutionMaterialization(job.tasks, result.next_tasks);
@@ -0,0 +1,15 @@
1
+ import { allocateEntityId } from './job-task-id-sequence.js';
2
+ import { relayStoreDirectory } from './relay-store.js';
3
+ /** Planner labels are local to its plan. Durable Task IDs are allocated by Relay. */
4
+ export function assignPlannerTaskIds(result, repository, jobId, planId, directory = relayStoreDirectory()) {
5
+ const aliases = new Map(result.next_tasks.map(spec => [spec.id, allocateEntityId(directory, repository, 'task', `plan:${jobId}:${planId}:${spec.id}`)]));
6
+ return {
7
+ ...result,
8
+ next_tasks: result.next_tasks.map(spec => ({
9
+ ...spec,
10
+ id: aliases.get(spec.id),
11
+ parentTaskId: spec.parentTaskId ? (aliases.get(spec.parentTaskId) ?? spec.parentTaskId) : spec.parentTaskId,
12
+ dependencies: spec.dependencies?.map(dep => aliases.get(dep) ?? dep),
13
+ })),
14
+ };
15
+ }
package/dist/planner.js CHANGED
@@ -4,7 +4,8 @@ import { fileURLToPath } from 'node:url';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { storySizeTimeoutMs, taskOverallTimeoutMs } from './types.js';
6
6
  import { validateResolutionProposal, unresolvedTask } from './workflow/task-resolution.js';
7
- import { executionContextPrompt } from './execution-context.js';
7
+ import { executionContextPrompt, BUNDLED_AGENTS_ROOT } from './execution-context.js';
8
+ import { resolveAgentMarkdown } from './agent-content-resolver.js';
8
9
  import { parseAgentGraph, validateAgentSelection } from './agent-graph.js';
9
10
  import { dispatchPlannerTrigger } from './workflow/planner-trigger-v2.js';
10
11
  import { createTaskEntity } from './workflow/entity-creation.js';
@@ -495,10 +496,11 @@ export class AgentObjectivePlanner {
495
496
  }
496
497
  async plan(context, signal) {
497
498
  const resolvedPlanner = context.executionContext?.agents.find(agent => agent.name === 'planner')?.path;
498
- const instructions = await readFile(resolvedPlanner ?? this.agentFile, 'utf8');
499
+ const effectivePlanner = context.executionContext ? await resolveAgentMarkdown('planner', BUNDLED_AGENTS_ROOT, context.executionContext.project.path) : undefined;
500
+ const instructions = effectivePlanner?.content ?? await readFile(this.agentFile, 'utf8');
499
501
  const contract = await readFile(DEFAULT_TASK_JOB_CONTRACT, 'utf8');
500
502
  const resolvedContext = context.executionContext ? executionContextPrompt(context.executionContext) + '\n\n' : '';
501
- const prompt = instructions.trim() + '\n\n## Shared Task / Job Contract\n\n' + contract.trim() + '\n\n' + resolvedContext + '## Durable planner input\n\n' + plannerInput(context);
503
+ const prompt = (effectivePlanner ? `[Effective planner contract SHA-256: ${effectivePlanner.hash}]\n\n` : '') + instructions.trim() + '\n\n## Shared Task / Job Contract\n\n' + contract.trim() + '\n\n' + resolvedContext + '## Durable planner input\n\n' + plannerInput(context);
502
504
  return await new Promise((resolve, reject) => {
503
505
  const child = spawnOwnedProcess(this.command, ['exec', '--json', '--', prompt], { invocation: 'planner.agent', signal, correlation: { jobId: context.job.id }, spawn: { cwd: context.executionContext?.project.path, stdio: ['ignore', 'pipe', 'pipe'] } });
504
506
  let stdoutBuffer = '';
@@ -9,10 +9,11 @@ export function createWorkspaceControl(deps) {
9
9
  if (command === 'job.create') {
10
10
  const repository = typeof body.repository === 'string' ? body.repository : '';
11
11
  const options = (body.options && typeof body.options === 'object' ? body.options : {});
12
- if (!repository || typeof options.id !== 'string' || !options.id)
13
- throw new Error('job.create requires repository and id');
12
+ if (!repository)
13
+ throw new Error('job.create requires repository');
14
14
  const result = await ensureManagedGitHubJob(client, repository, trusted, {
15
- id: options.id,
15
+ id: typeof options.id === 'string' && options.id ? options.id : undefined,
16
+ idempotencyKey: typeof options.idempotencyKey === 'string' ? options.idempotencyKey : undefined,
16
17
  title: typeof options.title === 'string' ? options.title : undefined,
17
18
  objective: typeof options.objective === 'string' ? options.objective : undefined,
18
19
  projectPath: typeof options.projectPath === 'string' ? options.projectPath : undefined,
@@ -38,6 +39,10 @@ export function createWorkspaceControl(deps) {
38
39
  await refreshAll();
39
40
  if (body.initialPlan === true) {
40
41
  const binding = await requireLocalJobBinding(result.job.id);
42
+ const current = await binding.store.load(result.job.id);
43
+ const existingPlan = current.plans?.at(-1);
44
+ if (existingPlan)
45
+ return { repository, pr: result.pr.number, job: current.id, executionMode: current.executionMode, state: current.state, initialPlan: existingPlan.id, reused: true };
41
46
  const reconciler = makeReconciler(binding.store, concurrency);
42
47
  try {
43
48
  const planned = await reconciler.planJob(result.job.id);
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import { allocateEntityId, isGeneratedEntityId } from './job-task-id-sequence.js';
3
+ import { relayStoreDirectory } from './relay-store.js';
2
4
  import { dispatchJobLifecycleSync } from './workflow/lifecycle.js';
3
5
  export function createDashboardActions(deps) {
4
- const { getOverviews, requireLocalJobBinding, pool, dashboardJobAction, makeReconciler, concurrency, dashboardJobSetState, refreshAll, applyJobActivateAction, applyOrchestratorStartAction, applyOrchestratorCompleteAction, publishOrchestratorTaskStarted, publishOrchestratorTaskCompletion, bus, dashboardTaskAction, dashboardTaskSetState } = deps;
6
+ const { getOverviews, requireLocalJobBinding, pool, dashboardJobAction, makeReconciler, concurrency, dashboardJobSetState, refreshAll, applyJobActivateAction, applyOrchestratorStartAction, applyOrchestratorCompleteAction, publishOrchestratorTaskStarted, publishOrchestratorTaskCompletion, bus, dashboardTaskAction, dashboardTaskSetState, storeDirectory } = deps;
5
7
  const currentOverviews = () => getOverviews();
6
8
  const dashboardOverview = (jobId, target) => currentOverviews().find((item) => item.job.id === jobId
7
9
  && (!target?.repository || item.job.repository === target.repository)
@@ -26,7 +28,7 @@ export function createDashboardActions(deps) {
26
28
  await refreshAll();
27
29
  return result;
28
30
  };
29
- const workspaceTaskCreate = async (jobId, task) => {
31
+ const workspaceTaskCreate = async (jobId, task, idempotencyKey) => {
30
32
  const overview = currentOverviews().find((item) => item.job.id === jobId);
31
33
  if (!overview)
32
34
  throw new Error(`Managed job ${jobId} not found`);
@@ -36,12 +38,22 @@ export function createDashboardActions(deps) {
36
38
  const job = await store.load(jobId);
37
39
  if (['COMPLETED', 'CANCELLED'].includes(job.state))
38
40
  throw new Error(`Cannot create task on terminal job ${job.id} (${job.state})`);
41
+ if (task.id && isGeneratedEntityId(task.id, job.repository, 'task'))
42
+ throw new Error('Reserved auto-generated Task ID cannot be assigned manually');
43
+ if (!task.id)
44
+ task.id = allocateEntityId(storeDirectory ?? relayStoreDirectory(), job.repository, 'task', idempotencyKey ? `${jobId}:${idempotencyKey}` : undefined);
45
+ let alreadyCreated = false;
39
46
  if (store.mutateJob) {
40
47
  await store.mutateJob(jobId, (current) => {
41
48
  if (['COMPLETED', 'CANCELLED'].includes(current.state))
42
49
  throw new Error(`Cannot create task on terminal job ${current.id}`);
43
- if (current.tasks.some((item) => item.id === task.id))
44
- throw new Error(`Task ${task.id} already exists`);
50
+ const existing = current.tasks.find((item) => item.id === task.id);
51
+ if (existing) {
52
+ if (!idempotencyKey || existing.input !== task.input || JSON.stringify(existing.output) !== JSON.stringify(task.output))
53
+ throw new Error(`Task ${task.id} already exists with a different definition or no retry key`);
54
+ alreadyCreated = true;
55
+ return current;
56
+ }
45
57
  current.tasks.push(structuredClone(task));
46
58
  if (current.state === 'DRAFT') {
47
59
  const activation = dispatchJobLifecycleSync(current, 'activate');
@@ -53,6 +65,14 @@ export function createDashboardActions(deps) {
53
65
  });
54
66
  }
55
67
  else {
68
+ if (idempotencyKey) {
69
+ const existing = job.tasks.find((item) => item.id === task.id);
70
+ if (existing) {
71
+ if (existing.input !== task.input || JSON.stringify(existing.output) !== JSON.stringify(task.output))
72
+ throw new Error(`Task ${task.id} already exists with a different definition`);
73
+ return { ...job, createdTaskId: task.id };
74
+ }
75
+ }
56
76
  await store.appendTask(task);
57
77
  const refreshed = await store.load(jobId);
58
78
  if (refreshed.state === 'DRAFT') {
@@ -60,8 +80,9 @@ export function createDashboardActions(deps) {
60
80
  await store.saveJob(refreshed);
61
81
  }
62
82
  }
63
- await pool.reconcileLocalJob(jobId, 'control.task.create');
64
- return await store.load(jobId);
83
+ if (!alreadyCreated)
84
+ await pool.reconcileLocalJob(jobId, 'control.task.create');
85
+ return { ...(await store.load(jobId)), createdTaskId: task.id };
65
86
  };
66
87
  const workspaceTaskStart = async (jobId, taskId) => {
67
88
  const overview = currentOverviews().find((item) => item.job.id === jobId);
package/dist/workspace.js CHANGED
@@ -224,6 +224,9 @@ export async function prepareLocalJobExecutionWorktree(projectPath, job, workspa
224
224
  throw new Error('Local Job execution requires a valid refs/heads sourceRef');
225
225
  const primary = await git(projectPath, ['rev-parse', '--show-toplevel']);
226
226
  const projectRelative = (await git(projectPath, ['rev-parse', '--show-prefix'])).replace(/\/$/, '');
227
+ const effectiveProject = (checkoutRoot) => job.projectPath
228
+ ? resolveRepositoryProjectPath(checkoutRoot, job.projectPath)
229
+ : (projectRelative ? join(checkoutRoot, projectRelative) : checkoutRoot);
227
230
  let remote = '';
228
231
  try {
229
232
  remote = await git(primary, ['config', '--get', 'remote.origin.url']);
@@ -242,7 +245,7 @@ export async function prepareLocalJobExecutionWorktree(projectPath, job, workspa
242
245
  const primaryHead = await git(primary, ['rev-parse', 'HEAD']);
243
246
  if (primaryHead !== sourceSha)
244
247
  throw new Error(`Canonical source branch ${sourceRef} changed while preparing execution checkout`);
245
- return projectRelative ? join(primary, projectRelative) : primary;
248
+ return effectiveProject(primary);
246
249
  }
247
250
  const worktree = managedLocalJobWorktreePath(job.repository, job.id, workspaceRoot);
248
251
  await mkdir(dirname(worktree), { recursive: true });
@@ -262,7 +265,7 @@ export async function prepareLocalJobExecutionWorktree(projectPath, job, workspa
262
265
  throw new Error(`Local Job worktree has non-fast-forward history; preserve commits: ${worktree}`);
263
266
  await git(worktree, ['merge', '--ff-only', sourceSha]);
264
267
  }
265
- return projectRelative ? join(worktree, projectRelative) : worktree;
268
+ return effectiveProject(worktree);
266
269
  }
267
270
  catch (error) {
268
271
  try {
@@ -270,7 +273,7 @@ export async function prepareLocalJobExecutionWorktree(projectPath, job, workspa
270
273
  }
271
274
  catch {
272
275
  await git(primary, ['worktree', 'add', worktree, sourceRef.slice('refs/heads/'.length)]);
273
- return projectRelative ? join(worktree, projectRelative) : worktree;
276
+ return effectiveProject(worktree);
274
277
  }
275
278
  throw error;
276
279
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.79",
3
+ "version": "2.0.80",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: reviewer
3
+ description: Independently evaluate one immutable review target against the complete Job objective and acceptance criteria.
4
+ capabilities: [review, evidence-verification]
5
+ restrictions: [no-artifact-edits, no-merge, preserve-review-target]
6
+ ---
7
+
8
+ # Relay Reviewer Agent
9
+
10
+ Review exactly the immutable target/version specified by the Review Task. Do not silently substitute newer content or a different commit, document, artifact, or SHA.
11
+
12
+ ## Review Scope
13
+
14
+ Reconstruct all material acceptance requirements from the durable Job objective and Task, then evaluate each with direct evidence. Check correctness, completeness, provenance, quality, regressions, explicit approvals and externally observable outcomes where applicable. Missing evidence is BLOCKED, not PASS. Keep review independent of implementation work.
15
+
16
+ ## Review Decision
17
+
18
+ Return a concrete approved, changes-required or blocked verdict tied to the declared immutable target, with attributed findings, severity and remediation steps. Do not alter artifacts, generate approvals on behalf of users, or perform publication, merges or external actions. Relay owns the review lifecycle and Task/Job completion.