agents-relay 2.0.79 → 2.0.81
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 +4 -0
- package/dist/agent-content-resolver.js +147 -0
- package/dist/cli-help.js +6 -6
- package/dist/cli-job-create.js +18 -11
- package/dist/cli.js +8 -6
- package/dist/dashboard-html.js +4 -4
- package/dist/dashboard.js +7 -7
- package/dist/execution-context.js +29 -8
- package/dist/job-task-id-sequence.js +53 -0
- package/dist/planner-runtime.js +3 -1
- package/dist/planner-task-ids.js +15 -0
- package/dist/planner.js +5 -3
- package/dist/reconciler-review.js +5 -2
- package/dist/reconciler-runtime.js +7 -0
- package/dist/relayd-control.js +8 -3
- package/dist/relayd-dashboard-actions.js +27 -6
- package/dist/review-generation-fence.js +8 -0
- package/dist/review-terminal-normalization.js +27 -0
- package/dist/workspace.js +6 -3
- package/package.json +1 -1
- package/skills/agents-relay/agents/reviewer.agent.md +18 -0
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
|
|
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 --
|
|
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 --
|
|
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.
|
package/dist/cli-job-create.js
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
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 ??
|
|
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
|
|
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: ' +
|
|
21
|
+
throw new Error('Duplicate immutable Job ID across SQLite stores: ' + id);
|
|
21
22
|
if (alreadyKnown.length === 1) {
|
|
22
|
-
const existing = alreadyKnown[0]
|
|
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:
|
|
37
|
+
directory, jobId: id, noGitHub: true,
|
|
31
38
|
});
|
|
32
39
|
try {
|
|
33
|
-
const existing = await local.relay.loadJob(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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'
|
|
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,
|
package/dist/dashboard-html.js
CHANGED
|
@@ -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 '+
|
|
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_
|
|
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_
|
|
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_
|
|
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_
|
|
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_
|
|
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_
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/planner-runtime.js
CHANGED
|
@@ -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
|
|
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
|
|
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 = '';
|
|
@@ -325,7 +325,10 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
325
325
|
return { applied: false, reconcile: false, reason: event.type + '.stale' };
|
|
326
326
|
// Planner-created reviewer tasks predate the final-review Job gate. Bind
|
|
327
327
|
// their first trustworthy review event to PENDING before dispatching it.
|
|
328
|
-
const
|
|
328
|
+
const freshAfterStale = job.review?.outcome === 'stale'
|
|
329
|
+
&& job.review.headSha !== headSha && Boolean(task.planningDecisionId);
|
|
330
|
+
const initialReviewOutcome = (!job.review || freshAfterStale) && task.planningDecisionId
|
|
331
|
+
? 'pending' : job.review?.outcome;
|
|
329
332
|
const taskDecision = this.store.mutateJob
|
|
330
333
|
? dispatchTaskLifecycleSync(structuredClone(task), event.type)
|
|
331
334
|
: tryTransitionTaskEvent(task, event.type);
|
|
@@ -395,7 +398,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
395
398
|
Object.assign(freshTask, candidate);
|
|
396
399
|
// Bind the first Planner review only after the generation fence has
|
|
397
400
|
// checked the unmodified outer snapshot and authoritative SQLite state.
|
|
398
|
-
if (!current.review && freshTask.planningDecisionId) {
|
|
401
|
+
if ((!current.review || (current.review.outcome === 'stale' && current.review.headSha !== headSha)) && freshTask.planningDecisionId) {
|
|
399
402
|
current.review = { outcome: 'pending', headSha, taskId: freshTask.id, evidence: null, updatedAt: reviewUpdatedAt };
|
|
400
403
|
}
|
|
401
404
|
freshTask.error = null;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeReviewerTerminalEvent } from './review-terminal-normalization.js';
|
|
1
2
|
import { applyJobEffectOutcomes, applyTaskEffectOutcomes } from './workflow-effect-failures.js';
|
|
2
3
|
import { classifyWorkerRejection, reroutableWorkerRejections } from './worker-rejection.js';
|
|
3
4
|
import { applyTaskRetryAction } from './task-actions.js';
|
|
@@ -89,6 +90,12 @@ export class ReconcilerRuntimeBase {
|
|
|
89
90
|
return activeOwner.applyEvent(event);
|
|
90
91
|
if (event.type === 'task.started')
|
|
91
92
|
return this.markWorkerStarted(event);
|
|
93
|
+
if (event.type === 'task.completed') {
|
|
94
|
+
const task = (await this.store.load(event.job_id)).tasks.find(item => item.id === event.task_id);
|
|
95
|
+
const normalized = task?.agentName === 'reviewer' ? normalizeReviewerTerminalEvent(event) : null;
|
|
96
|
+
if (normalized)
|
|
97
|
+
return this.applyReviewEvent(normalized);
|
|
98
|
+
}
|
|
92
99
|
if (['task.completed', 'task.failed', 'task.blocked', 'task.cancelled'].includes(event.type)) {
|
|
93
100
|
const result = await this.applyTerminalEvent(event);
|
|
94
101
|
if (event.type !== 'task.failed' || event.source?.watchdog !== true || event.data?.workflowEvent !== 'watchdog.failed' || result.reconcile)
|
package/dist/relayd-control.js
CHANGED
|
@@ -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
|
|
13
|
-
throw new Error('job.create requires repository
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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);
|
|
@@ -5,6 +5,14 @@ export function reviewGenerationMatches(current, original, task, headSha) {
|
|
|
5
5
|
|| (current.headSha && current.headSha !== headSha)
|
|
6
6
|
|| fresh.executionId !== task.executionId)
|
|
7
7
|
return false;
|
|
8
|
+
// A fresh Planner reviewer for a new head replaces the old STALE review
|
|
9
|
+
// generation. Reject competing reviewers of that same head deterministically.
|
|
10
|
+
if (current.review?.outcome === 'stale' && original.review?.outcome === 'stale'
|
|
11
|
+
&& current.review.headSha !== headSha && task.planningDecisionId) {
|
|
12
|
+
const currentHeadReviewers = current.tasks.filter(item => item.agentName === 'reviewer'
|
|
13
|
+
&& item.reviewHeadSha === headSha && item.state !== 'CANCELLED');
|
|
14
|
+
return currentHeadReviewers.at(-1)?.id === task.id;
|
|
15
|
+
}
|
|
8
16
|
if (current.review)
|
|
9
17
|
return current.review.taskId === task.id && current.review.headSha === headSha
|
|
10
18
|
&& current.review.outcome === (original.review?.outcome ?? 'pending');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
function record(value) {
|
|
2
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
3
|
+
}
|
|
4
|
+
/** Translate an authenticated reviewer terminal verdict without losing canonical review evidence. */
|
|
5
|
+
export function normalizeReviewerTerminalEvent(event) {
|
|
6
|
+
if (event.type !== 'task.completed')
|
|
7
|
+
return null;
|
|
8
|
+
const data = record(event.data);
|
|
9
|
+
const raw = data.verdict;
|
|
10
|
+
const type = raw === 'approved' || raw === 'pass' ? 'review.approved'
|
|
11
|
+
: raw === 'changes-required' || raw === 'changes_required' ? 'review.changes_required'
|
|
12
|
+
: raw === 'blocked' ? 'review.blocked' : null;
|
|
13
|
+
if (!type)
|
|
14
|
+
return null;
|
|
15
|
+
const outcome = record(data.outcome);
|
|
16
|
+
const review = record(data.review);
|
|
17
|
+
// Match canonical review head resolution: outcome > review > top-level data.
|
|
18
|
+
const head = [outcome.head_sha, outcome.headSha, review.head_sha, review.headSha,
|
|
19
|
+
data.head_sha, data.headSha]
|
|
20
|
+
.find(value => typeof value === 'string' && value.length > 0);
|
|
21
|
+
const existingFindings = outcome.blocking_findings ?? outcome.blockingFindings;
|
|
22
|
+
const findings = existingFindings ?? (Array.isArray(data.findings) ? data.findings : undefined);
|
|
23
|
+
return { ...event, type, data: { ...data, outcome: {
|
|
24
|
+
...outcome, ...(head ? { head_sha: head } : {}),
|
|
25
|
+
...(findings !== undefined && existingFindings === undefined ? { blocking_findings: findings } : {}),
|
|
26
|
+
} } };
|
|
27
|
+
}
|
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
|
|
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
|
|
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
|
|
276
|
+
return effectiveProject(worktree);
|
|
274
277
|
}
|
|
275
278
|
throw error;
|
|
276
279
|
}
|
package/package.json
CHANGED
|
@@ -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.
|