software-defence-factory 0.5.0 → 0.5.1
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/.github/ISSUE_TEMPLATE/factory-task.yml +1 -1
- package/README.md +1 -1
- package/bin/software-defence-factory.mjs +52 -4
- package/docs/concepts.md +11 -4
- package/docs/interfaces.md +4 -2
- package/docs/quickstart.md +5 -4
- package/docs/workflows.md +53 -5
- package/factory/intake.mjs +15 -0
- package/factory/issue-intake.mjs +38 -10
- package/factory/issue-templates.mjs +136 -0
- package/factory/server.mjs +21 -1
- package/factory/terminology.json +3 -2
- package/factory/ui/assets/index-Ci1BkkmE.js +13 -0
- package/factory/ui/assets/index-qHVJlLfq.css +1 -0
- package/factory/ui/index.html +2 -2
- package/factory/updates.mjs +1 -1
- package/package.json +4 -1
- package/factory/ui/assets/index-D0_HaZ5J.js +0 -11
- package/factory/ui/assets/index-lXRVcv9y.css +0 -1
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ The runtime supplies policy and six focused skills to its isolated jobs. `init`
|
|
|
37
37
|
|
|
38
38
|
Each result belongs to a specific candidate commit and policy. A failed check blocks delivery. Changing the candidate or check policy invalidates earlier evidence. Approval records a handoff; publishing, merging and deployment follow the application's separate authority.
|
|
39
39
|
|
|
40
|
-
The project dashboard has an **Inbox**, measured **Analytics**, **Agents**, **Skills**, **Automations**, **Definition** and **Infrastructure**. New
|
|
40
|
+
The project dashboard has an **Inbox**, measured **Analytics**, **Agents**, **Skills**, **Automations**, **Definition** and **Infrastructure**. New issue offers the repository’s issue templates, a blank local form or a selectable GitHub issue. Review the suggested work type before Create & start. CLI `issue` exposes the same intake. Definition lives with settings above the theme control. The CLI reads the same definition and controller state. Agent roles use a selected harness such as Codex or Pi; a worker executes their isolated jobs on a host. See [concepts](docs/concepts.md) and [supported interfaces](docs/interfaces.md). Automations are not implemented yet; work starts manually.
|
|
41
41
|
|
|
42
42
|
The optional **defence** workflow accepts scoped incident evidence and produces a private, read-only draft. It does not monitor production or claim verified recovery. See [defence integration](docs/defence-integration.md).
|
|
43
43
|
|
|
@@ -6,7 +6,9 @@ import { spawn } from 'node:child_process';
|
|
|
6
6
|
import { createServer } from 'node:net';
|
|
7
7
|
import { ROOT, PINS, DEFAULT_STATE, configAt, save, json, run, stream, digest, api, sleep, stopContainers } from '../factory/lib.mjs';
|
|
8
8
|
import { assertInstalledJobImage, installCustomJobImage, installStandardJobImage, inspectImageInstallation } from '../factory/image-install.mjs';
|
|
9
|
-
import { readIssue } from '../factory/issue-intake.mjs';
|
|
9
|
+
import { listIssues, readIssue } from '../factory/issue-intake.mjs';
|
|
10
|
+
import { readTemplates, draftFromTemplate } from '../factory/issue-templates.mjs';
|
|
11
|
+
import { recommendWork } from '../factory/intake.mjs';
|
|
10
12
|
import { factoryDefinition, foundationSkill } from '../factory/definition.mjs';
|
|
11
13
|
import { harnessOf } from '../factory/lib.mjs';
|
|
12
14
|
import { admitIncident } from '../factory/incident.mjs';
|
|
@@ -158,14 +160,49 @@ try {
|
|
|
158
160
|
const config=configAt(state),dockerVersion=run('docker',['info','--format','{{.ServerVersion}}']),imageStatus=inspectImageInstallation(state,config);
|
|
159
161
|
console.log(JSON.stringify({node:process.version,docker:dockerVersion,engineInstalled:imageStatus.installed,image:imageStatus.image,repo:config.repo,harness:harnessOf(config),agent:harnessOf(config),checksConfigured:!!config.check?.trim(),inference:'Not called or verified',qualification:{model:'not assessed',toolchain:'not assessed'},dashboard:`http://127.0.0.1:${config.port}`},null,2));
|
|
160
162
|
if(!imageStatus.installed)process.exitCode=1;
|
|
163
|
+
} else if(command==='issue') {
|
|
164
|
+
const action=positional[0];
|
|
165
|
+
if(action==='list') {
|
|
166
|
+
if(flags.source && !['factory','github'].includes(flags.source))throw new Error('Choose --source factory or github');
|
|
167
|
+
console.log(JSON.stringify(flags.source==='github' ? await listIssues(configAt(state).repo,Number(flags.page || 1)) : (await api(state,'/api/v1/status')).jobs,null,2));
|
|
168
|
+
} else if(action==='templates') console.log(JSON.stringify(await readTemplates(configAt(state).repo),null,2));
|
|
169
|
+
else if(action==='preview') {
|
|
170
|
+
if(!flags.github)throw new Error('Use --github ISSUE_URL');
|
|
171
|
+
console.log(JSON.stringify(await readIssue(configAt(state).repo,flags.github),null,2));
|
|
172
|
+
} else if(action==='recommend') {
|
|
173
|
+
if(Boolean(flags.github)===Boolean(flags.file))throw new Error('Choose --file brief.md or --github ISSUE_URL');
|
|
174
|
+
console.log(JSON.stringify(flags.github ? (await readIssue(configAt(state).repo,flags.github)).recommendation : recommendWork({spec:readFileSync(resolve(flags.file),'utf8')}),null,2));
|
|
175
|
+
} else if(action==='draft') {
|
|
176
|
+
if(!flags.file||!flags.template||!flags.sha)throw new Error('Use --template NAME --sha SHA --file answers.json with {title, answers}');
|
|
177
|
+
console.log(JSON.stringify(await draftFromTemplate(configAt(state).repo,{...json(resolve(flags.file)),template:flags.template,sha:flags.sha}),null,2));
|
|
178
|
+
} else if(action==='create') {
|
|
179
|
+
if(!['software','defence'].includes(flags.workflow))throw new Error('Review the issue and choose --workflow software or defence');
|
|
180
|
+
if([flags.file,flags.github,flags.draft].filter(Boolean).length!==1)throw new Error('Choose --file brief.md, --draft draft.json or --github ISSUE_URL');
|
|
181
|
+
let input;
|
|
182
|
+
if(flags.github) { const issue=await readIssue(configAt(state).repo,flags.github);input={title:issue.title,spec:issue.spec,source_url:issue.url}; }
|
|
183
|
+
else if(flags.draft) { const draft=json(resolve(flags.draft));input={title:draft.title,spec:draft.spec}; }
|
|
184
|
+
else input={title:flags.title,spec:readFileSync(resolve(flags.file),'utf8')};
|
|
185
|
+
input.title=flags.title || input.title;
|
|
186
|
+
if(typeof input.title!=='string'||!input.title.trim()||input.title.length>160)throw new Error('Provide a title of 1–160 characters (use --title for a blank issue)');
|
|
187
|
+
if(flags.workflow==='software'&&!configAt(state).check?.trim())throw new Error('Configure an app check before submitting software work');
|
|
188
|
+
console.log(JSON.stringify(await api(state,'/api/v1/jobs',{...input,workflow:flags.workflow,repository:'app',model:flags.model || ''}),null,2));
|
|
189
|
+
} else throw new Error('Use issue list|templates|preview|recommend|draft|create; see help');
|
|
190
|
+
} else if(command==='issues') {
|
|
191
|
+
console.log(JSON.stringify(await listIssues(configAt(state).repo,Number(flags.page || 1)),null,2));
|
|
192
|
+
} else if(command==='recommend') {
|
|
193
|
+
if(Boolean(flags.issue) === Boolean(flags.file))throw new Error('Choose --file task.md or --issue URL');
|
|
194
|
+
const recommendation=flags.issue ? (await readIssue(configAt(state).repo,flags.issue)).recommendation : recommendWork({spec:readFileSync(resolve(flags.file),'utf8')});
|
|
195
|
+
console.log(JSON.stringify(recommendation,null,2));
|
|
161
196
|
} else if(command==='run') {
|
|
197
|
+
const workflow=flags.workflow || 'software';
|
|
198
|
+
if(!['software','defence'].includes(workflow))throw new Error('Choose --workflow software or defence');
|
|
162
199
|
let spec;
|
|
163
200
|
if(flags.issue) {
|
|
164
201
|
spec=(await readIssue(configAt(state).repo,flags.issue)).spec;
|
|
165
202
|
} else if(flags.file)spec=readFileSync(resolve(flags.file),'utf8');
|
|
166
203
|
else throw new Error('Use --file task.md or --issue https://github.com/owner/repo/issues/123');
|
|
167
|
-
if(
|
|
168
|
-
console.log(JSON.stringify(await submit(
|
|
204
|
+
if(workflow==='software'&&!configAt(state).check?.trim())throw new Error('Configure an app check before submitting software work');
|
|
205
|
+
console.log(JSON.stringify(await submit(workflow,spec)));
|
|
169
206
|
} else if(command==='incident') {
|
|
170
207
|
if(!flags.file)throw new Error('Use --file incident.json; see factory/examples/incident.json');
|
|
171
208
|
console.log(JSON.stringify(await admitIncident(state,json(resolve(flags.file)),submit)));
|
|
@@ -211,7 +248,18 @@ try {
|
|
|
211
248
|
service resume Release a reconciled maintenance reservation
|
|
212
249
|
tunnel install|start|stop|status|logs|uninstall --host SSH_ALIAS --port PORT
|
|
213
250
|
Persistent loopback SSH tunnel (macOS/Linux)
|
|
214
|
-
|
|
251
|
+
issue list [--source github] [--page N] List local Factory issues or open GitHub issues
|
|
252
|
+
issue templates Read this repository's issue forms and contact links
|
|
253
|
+
issue preview --github URL Preview one GitHub issue without starting work
|
|
254
|
+
issue recommend --file brief.md | --github URL
|
|
255
|
+
issue draft --template NAME --sha SHA --file answers.json
|
|
256
|
+
Validate {title,answers}; output a local draft JSON
|
|
257
|
+
issue create --draft draft.json | --github URL | --file brief.md --title TITLE
|
|
258
|
+
--workflow software|defence [--model MODEL]
|
|
259
|
+
Create a local issue and start work; no GitHub write
|
|
260
|
+
issues [--page N] Browse open project issues, with next_page for more
|
|
261
|
+
recommend --file task.md | --issue URL Suggest a work type without starting work
|
|
262
|
+
run --file task.md | --issue URL Submit software (default), or --workflow defence
|
|
215
263
|
incident --file incident.json Submit a private, read-only incident draft
|
|
216
264
|
approve JOB_ID | cancel JOB_ID Review gate / stop this attempt
|
|
217
265
|
retry JOB_ID Prove stop; retain old checkout and retry
|
package/docs/concepts.md
CHANGED
|
@@ -12,12 +12,14 @@ The CLI `definition` command and dashboard Definition page read that same catalo
|
|
|
12
12
|
| Agent | Responsibility and instructions | Implement, Review, Investigate; one shared harness/model profile |
|
|
13
13
|
| Skill | Reusable instructions | Six job skills; separate operator Factory Foundation |
|
|
14
14
|
| Workflow | Ordered steps and gates | Software: Implement → Check → Review → Accept; Defence: Investigate |
|
|
15
|
-
|
|
|
15
|
+
| Local issue | Bounded work request | Created from a form, brief or GitHub source; stored as a job with retained run attempts |
|
|
16
16
|
| Automation | Trigger, filters and target | Planned; work starts manually |
|
|
17
17
|
| Definition | Effective roles, workflows, skills and settings | Installed method plus private factory.json; read-only catalog |
|
|
18
18
|
|
|
19
|
-
Inbox contains admitted
|
|
20
|
-
|
|
19
|
+
Inbox contains admitted local issues, not an automatically imported GitHub backlog.
|
|
20
|
+
New issue previews a local draft; Create & start admits execution. GitHub issues
|
|
21
|
+
and templates are source material: nothing is posted back to GitHub. Drafts stay
|
|
22
|
+
in the open form until creation; there is no persistent unstarted backlog yet.
|
|
21
23
|
An agent role is neither a machine nor a skill. Check is deterministic, and
|
|
22
24
|
Accept is an operator gate. Triage/specification precede admission; evaluation
|
|
23
25
|
is separately scoped work, not an automatic hidden agent phase.
|
|
@@ -26,7 +28,7 @@ All job skills are available read-only. Role instructions identify relevant
|
|
|
26
28
|
skills; per-role skill/access/harness profiles are not yet supported. Inference
|
|
27
29
|
authentication, GitHub identity and SSH access are separate boundaries. Browser
|
|
28
30
|
GitHub sign-in does not configure `gh` on the controller host, and host `gh` auth
|
|
29
|
-
does not sign a browser in. Writing a local
|
|
31
|
+
does not sign a browser in. Writing a blank local issue needs neither a GitHub issue nor
|
|
30
32
|
browser GitHub login. Import uses the controller's configured-repository `gh`
|
|
31
33
|
access. See [interface support](interfaces.md).
|
|
32
34
|
|
|
@@ -48,3 +50,8 @@ private so existing installations can upgrade without credential migration.
|
|
|
48
50
|
Execution IDs (`build`, `verify`, `handoff`, `job_*`, `run_*`) are stable wire and
|
|
49
51
|
evidence identifiers. Human labels explain them without rewriting stored jobs.
|
|
50
52
|
Compatibility is handled at these boundaries; there is one active implementation.
|
|
53
|
+
|
|
54
|
+
CLI `issue` groups list, templates, preview, draft, recommend and create. The
|
|
55
|
+
legacy `run`, `issues` (GitHub list) and `recommend` commands remain compatible.
|
|
56
|
+
Task/job field names and `/api/v1/jobs` are stable wire/storage identifiers for
|
|
57
|
+
these same local issues; this adds no parallel scheduler or issue database.
|
package/docs/interfaces.md
CHANGED
|
@@ -10,8 +10,10 @@ shell endpoint or a second scheduler.
|
|
|
10
10
|
| Capability | CLI | Shared API | Dashboard | Remaining work |
|
|
11
11
|
| --- | --- | --- | --- | --- |
|
|
12
12
|
| Project/queue/attempt state | `status`, `inbox` JSON | `GET /api/v1/status` | Project, tasks, details/history | Stable versioned agent result/error contract |
|
|
13
|
-
|
|
|
14
|
-
|
|
|
13
|
+
| Create local issue | `issue create --file --title`, `--draft` or `--github`, explicit `--workflow`, optional `--model` | `POST /api/v1/jobs` | New issue → review → Create & start | Persistent unstarted drafts and typed incident intake remain separate |
|
|
14
|
+
| Browse/import GitHub issues | `issue list --source github [--page N]`, `issue preview --github URL` via operator `gh` | Authenticated `GET /api/v1/issues`, `POST /api/v1/issues/preview` using shared readers | Paged open-issue list, search loaded results, preview and explicit start for either type | Stronger issue/job identity, issue creation and qualified triggers remain; no implicit polling |
|
|
15
|
+
| Repository issue templates | `issue templates`, `issue draft --template --sha --file` | Authenticated template list and draft compilation | Chooser, fields/defaults/validation, review | Supports Markdown and YAML markdown/input/textarea/dropdown/checkboxes; unsupported templates link to GitHub |
|
|
16
|
+
| Suggest task type | `issue recommend --file` or `--github` | Authenticated `POST /api/v1/intake/recommend`; issue preview includes suggestion | Editable recommendation after source selection | Deterministic label/brief rules; no model judgment or execution authority |
|
|
15
17
|
| Cancel/retry/approve | Commands | Job action endpoints with current run ID | Task controls | JSON action results and consistent needs-attention outcomes |
|
|
16
18
|
| Request changes | `revise --file` | `request_changes` action | Feedback form | JSON action result; retain shared stale-action guards |
|
|
17
19
|
| Remove a stopped task | No command | `DELETE /api/v1/jobs/:id` | Remove action | Add CLI; keep existing recoverability/history semantics |
|
package/docs/quickstart.md
CHANGED
|
@@ -88,7 +88,7 @@ No Docker socket, operator credentials or unrelated project caches are mounted.
|
|
|
88
88
|
|
|
89
89
|
## Read the project dashboard
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
Inbox contains both Software delivery and Defence investigation. Select a
|
|
92
92
|
workflow to focus the list; workflow, requested-model, status-badge and text
|
|
93
93
|
filters combine. Analytics offers the same workflow separation for recorded
|
|
94
94
|
outcomes, duration and [token usage](usage.md). An issue link is a reference,
|
|
@@ -96,9 +96,10 @@ not an execution type. For validated, deduplicated private incident intake,
|
|
|
96
96
|
use the [Defence integration](defence-integration.md) recipe; the generic
|
|
97
97
|
Defence form is not that typed intake path.
|
|
98
98
|
|
|
99
|
-
The header names the configured project. View repo
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
The header names the configured project. View repo opens a validated GitHub
|
|
100
|
+
origin. New issue opens Factory’s local chooser: repository templates, a blank
|
|
101
|
+
form or existing GitHub issues. It does not publish a GitHub issue; Create &
|
|
102
|
+
start queues local work. See [intake and CLI examples](workflows.md). The task detail provides previous/next within the filtered list, copy
|
|
102
103
|
link and close (Escape). Closing preserves the list's filters and position.
|
|
103
104
|
|
|
104
105
|
If the interface looks unexpectedly small, check the browser zoom. The design
|
package/docs/workflows.md
CHANGED
|
@@ -24,11 +24,59 @@ kept outside those execution mounts.
|
|
|
24
24
|
|
|
25
25
|
## Start work
|
|
26
26
|
|
|
27
|
-
Open **New
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
Open **New issue** in Inbox, choose a repository template (or **Blank issue**),
|
|
28
|
+
complete the title and fields, then **Continue**. Or choose
|
|
29
|
+
**From GitHub issues** and select an open issue from the configured repository.
|
|
30
|
+
The list excludes pull requests, loads 50 GitHub records per page and offers
|
|
31
|
+
**Load more**; search filters the loaded issues by title, number or label. GitHub
|
|
32
|
+
reads use the controller's existing access, with retry on failure.
|
|
33
|
+
|
|
34
|
+
Review the instructions and suggested work type before **Create & start**. The shared,
|
|
35
|
+
deterministic suggestion prioritizes `track:software` and `track:security` (also
|
|
36
|
+
`track:defence`/`track:defense`) labels. Without a track label, explicit incident
|
|
37
|
+
investigation wording suggests Defence; otherwise Software is the default.
|
|
38
|
+
Conflicting labels request a choice. This is a simple editable suggestion, not a
|
|
39
|
+
model assessment or permission to act. A security code fix can remain Software.
|
|
40
|
+
Both sources support either type; Defence still produces a private draft.
|
|
41
|
+
|
|
42
|
+
Repository templates are read from `.github/ISSUE_TEMPLATE` on the default
|
|
43
|
+
branch through GitHub's API. Markdown templates and YAML markdown, input,
|
|
44
|
+
textarea, dropdown (including multiple choices) and checkboxes are supported.
|
|
45
|
+
Required fields/defaults are preserved; compilation checks the template SHA
|
|
46
|
+
again and refuses a changed form. Unsupported forms (including uploads) stay
|
|
47
|
+
visible with a GitHub link instead of silently dropping fields. Contact links
|
|
48
|
+
preserve the project's private security reporting route. Template Markdown is
|
|
49
|
+
shown as literal text, never executed or rendered as raw HTML.
|
|
50
|
+
|
|
51
|
+
This creates a **local Factory issue**, not a GitHub issue. GitHub assignees,
|
|
52
|
+
projects and write permissions are not applied. Blank local issues remain
|
|
53
|
+
available without GitHub access; `blank_issues_enabled` governs GitHub's chooser,
|
|
54
|
+
not Factory's local admission. Drafts are not saved as a persistent backlog.
|
|
55
|
+
The reader bounds each template to 100 KB and each chooser to 20 templates;
|
|
56
|
+
failures are explicit. Labels color the issue list and inform suggestions;
|
|
57
|
+
they cannot grant authority or enable automatic execution.
|
|
58
|
+
|
|
59
|
+
CLI equivalents:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
software-defence-factory issue list --state PATH
|
|
63
|
+
software-defence-factory issue list --source github --state PATH --page 1
|
|
64
|
+
software-defence-factory issue preview --github URL --state PATH
|
|
65
|
+
software-defence-factory issue templates --state PATH
|
|
66
|
+
software-defence-factory issue draft --state PATH --template bug-report.yml --sha TEMPLATE_SHA --file answers.json > draft.json
|
|
67
|
+
software-defence-factory issue create --state PATH --draft draft.json --workflow software
|
|
68
|
+
software-defence-factory issue recommend --file brief.md
|
|
69
|
+
software-defence-factory issue create --state PATH --file brief.md --title "Investigate supplied evidence" --workflow defence
|
|
70
|
+
software-defence-factory issue create --state PATH --github URL --workflow software
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`answers.json` contains `{"title":"Fix the board","answers":{"problem":"..."}}`;
|
|
74
|
+
keys match `fields[].id` in `issue templates`. Multi-select/checkbox answers are
|
|
75
|
+
arrays of exact option labels. These reads and compilation do not create work.
|
|
76
|
+
Only **Create & start** or `issue create` queues execution. It requires an
|
|
77
|
+
explicit CLI work type; the old `run` command retains its Software default for
|
|
78
|
+
compatibility. Private validated incident intake remains `incident --file`,
|
|
79
|
+
distinct from a generic Defence brief or issue.
|
|
32
80
|
|
|
33
81
|
View repo opens GitHub in a new tab, where the browser's own login applies.
|
|
34
82
|
Import uses the controller host's `gh` identity; it does not borrow browser
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Suggestions never authorize execution or replace the operator's chosen scope.
|
|
2
|
+
export function recommendWork({ spec, labels = [] }) {
|
|
3
|
+
if (typeof spec !== 'string' || !spec.trim() || Buffer.byteLength(spec) > 240000) throw new Error('Provide a brief between 1 and 240 KB.');
|
|
4
|
+
if (!Array.isArray(labels) || labels.length > 100 || labels.some(label => typeof label !== 'string' || label.length > 100)) throw new Error('Expected issue label names.');
|
|
5
|
+
const names = labels.map(label => label.toLowerCase());
|
|
6
|
+
const software = names.includes('track:software');
|
|
7
|
+
const defence = names.some(label => ['track:security', 'track:defence', 'track:defense'].includes(label));
|
|
8
|
+
if (software !== defence) return { workflow: defence ? 'defence' : 'software', basis: 'label', reason: `The issue is labelled for ${defence ? 'security investigation' : 'software delivery'}.` };
|
|
9
|
+
if (software && defence) return { workflow: 'software', basis: 'conflicting_labels', reason: 'Both tracks are labelled. Choose whether this task should deliver code or investigate evidence.' };
|
|
10
|
+
// Deliberately narrow: fixing a vulnerability or building security features is software work.
|
|
11
|
+
if (/\b(?:investigat(?:e|ion|ing)|triage|analys[ei][rs]?|analy[sz]e|undersøg|undersøge)\b[\s\S]{0,100}\b(?:incident|intrusion|breach|malware|compromise|suspicious|security logs|hændelse|angreb)\b/i.test(spec)) {
|
|
12
|
+
return { workflow: 'defence', basis: 'brief', reason: 'The brief describes investigating a security incident or supplied evidence.' };
|
|
13
|
+
}
|
|
14
|
+
return { workflow: 'software', basis: 'default', reason: 'Software is the default for changes to this project. Choose Defence for a scoped security investigation.' };
|
|
15
|
+
}
|
package/factory/issue-intake.mjs
CHANGED
|
@@ -1,22 +1,49 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { readProjectLinks } from './project-links.mjs';
|
|
4
|
+
import { recommendWork } from './intake.mjs';
|
|
4
5
|
const exec = promisify(execFile);
|
|
5
6
|
|
|
7
|
+
export async function githubRead(args) {
|
|
8
|
+
try {
|
|
9
|
+
const { stdout } = await exec('gh', args, {
|
|
10
|
+
encoding: 'utf8', timeout: 10000, maxBuffer: 8 * 1024 * 1024,
|
|
11
|
+
env: { ...process.env, GH_PROMPT_DISABLED: '1', GH_PAGER: 'cat' },
|
|
12
|
+
});
|
|
13
|
+
return JSON.parse(stdout);
|
|
14
|
+
} catch (cause) {
|
|
15
|
+
const error = new Error('Could not read GitHub repository data. Check GitHub CLI access on the controller host and try again.');
|
|
16
|
+
if (/\(HTTP 404\)/.test(cause.stderr || '')) error.status=404;
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function issueLabels(labels = []) {
|
|
22
|
+
if (!Array.isArray(labels) || labels.some(label => typeof label?.name !== 'string')) throw new Error('GitHub returned unexpected labels.');
|
|
23
|
+
return labels.map(label => ({name:label.name, color:/^[a-f0-9]{6}$/i.test(label.color || '') ? label.color.toLowerCase() : null}));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function listIssues(repo, page = 1, read = githubRead) {
|
|
27
|
+
if (!Number.isSafeInteger(page) || page < 1 || page > 10000) throw new Error('Issue page must be an integer between 1 and 10000.');
|
|
28
|
+
const repository = readProjectLinks(repo)?.repository;
|
|
29
|
+
if (!repository) throw new Error('This project has no configured GitHub origin.');
|
|
30
|
+
const slug = repository.slice('https://github.com/'.length);
|
|
31
|
+
const result = await read(['api', '--hostname', 'github.com', `repos/${slug}/issues?state=open&sort=created&direction=desc&per_page=50&page=${page}`, '-H', 'Accept: application/vnd.github+json']);
|
|
32
|
+
if (!Array.isArray(result) || result.length > 50) throw new Error('GitHub returned an unexpected issue list.');
|
|
33
|
+
const issues = result.filter(issue => !issue.pull_request).map(issue => {
|
|
34
|
+
validateIssueURL(repository, issue.html_url);
|
|
35
|
+
if (!Number.isSafeInteger(issue.number) || !issue.html_url.endsWith(`/issues/${issue.number}`) || typeof issue.title !== 'string' || !issue.title.trim()) throw new Error('GitHub returned an unexpected issue.');
|
|
36
|
+
return { number: issue.number, title: issue.title, url: issue.html_url, labels: issueLabels(issue.labels) };
|
|
37
|
+
});
|
|
38
|
+
return { repository, issues, next_page: result.length === 50 && page < 10000 ? page + 1 : null };
|
|
39
|
+
}
|
|
40
|
+
|
|
6
41
|
export function validateIssueURL(repoURL, value) {
|
|
7
42
|
if (typeof value !== 'string' || value.length > 2048 || !/^https:\/\/github\.com\/[A-Za-z0-9-]+\/[A-Za-z0-9_.-]+\/issues\/[1-9][0-9]*$/.test(value)) throw new Error('Enter a GitHub issue URL without query parameters.');
|
|
8
43
|
if (!repoURL || !value.toLowerCase().startsWith(`${repoURL.toLowerCase()}/issues/`)) throw new Error('Issue does not belong to this project’s configured GitHub origin.');
|
|
9
44
|
return value;
|
|
10
45
|
}
|
|
11
|
-
export async function readIssue(repo, url, read =
|
|
12
|
-
try {
|
|
13
|
-
const { stdout } = await exec('gh', ['issue', 'view', url, '--json', 'title,body,url'], {
|
|
14
|
-
encoding: 'utf8', timeout: 10000, maxBuffer: 300000,
|
|
15
|
-
env: { ...process.env, GH_PROMPT_DISABLED: '1', GH_PAGER: 'cat' },
|
|
16
|
-
});
|
|
17
|
-
return JSON.parse(stdout);
|
|
18
|
-
} catch { throw new Error('Could not read the issue. Check the URL and GitHub CLI access on the controller host.'); }
|
|
19
|
-
}) {
|
|
46
|
+
export async function readIssue(repo, url, read = url => githubRead(['issue', 'view', url, '--json', 'title,body,url,labels'])) {
|
|
20
47
|
const repoURL = readProjectLinks(repo)?.repository;
|
|
21
48
|
validateIssueURL(repoURL, url);
|
|
22
49
|
const issue = await read(url);
|
|
@@ -24,5 +51,6 @@ export async function readIssue(repo, url, read = async url => {
|
|
|
24
51
|
if (issue.url.toLowerCase() !== url.toLowerCase() || typeof issue.title !== 'string' || !issue.title.trim() || typeof issue.body !== 'string') throw new Error('GitHub returned an unexpected issue.');
|
|
25
52
|
const spec = `Issue: ${issue.url}\n${issue.title}\n\n${issue.body}`;
|
|
26
53
|
if (Buffer.byteLength(spec) > 240000) throw new Error('Issue exceeds the 240 KB task limit. Use a bounded task file instead.');
|
|
27
|
-
|
|
54
|
+
const labels = issueLabels(issue.labels);
|
|
55
|
+
return { title: issue.title, url: issue.url, body: issue.body, spec, labels, recommendation: recommendWork({ spec, labels: labels.map(label => label.name) }) };
|
|
28
56
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { parseDocument } from 'yaml';
|
|
2
|
+
import { githubRead } from './issue-intake.mjs';
|
|
3
|
+
import { readProjectLinks } from './project-links.mjs';
|
|
4
|
+
import { recommendWork } from './intake.mjs';
|
|
5
|
+
|
|
6
|
+
const object = value => value && typeof value === 'object' && !Array.isArray(value);
|
|
7
|
+
const text = value => typeof value === 'string' ? value : '';
|
|
8
|
+
const filename = name => typeof name === 'string' && /^[^/\\\x00-\x1f]{1,120}\.(?:md|ya?ml)$/i.test(name) && !/^config\.ya?ml$/i.test(name);
|
|
9
|
+
function yaml(source) {
|
|
10
|
+
const doc = parseDocument(source, { schema:'core', uniqueKeys:true });
|
|
11
|
+
if (doc.errors.length || doc.warnings.length) throw new Error('Invalid or unsupported template YAML.');
|
|
12
|
+
return doc.toJS({ maxAliasCount:20 });
|
|
13
|
+
}
|
|
14
|
+
function names(value) {
|
|
15
|
+
const result = typeof value === 'string' ? value.split(',').map(s => s.trim()).filter(Boolean) : value || [];
|
|
16
|
+
if (!Array.isArray(result) || result.length > 100 || result.some(s => typeof s !== 'string' || s.length > 100)) throw new Error('Invalid template labels.');
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
export function parseTemplate(name, source, sha) {
|
|
20
|
+
if (!filename(name) || typeof source !== 'string' || Buffer.byteLength(source) > 100000) throw new Error('Template is unsupported or exceeds 100 KB.');
|
|
21
|
+
let data, fields;
|
|
22
|
+
if (/\.md$/i.test(name)) {
|
|
23
|
+
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/);
|
|
24
|
+
if (!match) throw new Error('Markdown template needs YAML front matter.');
|
|
25
|
+
data = yaml(match[1]);
|
|
26
|
+
fields = [{id:'body',type:'textarea',label:'Description',description:'Complete the sections from the repository template.',value:match[2].trim(),required:true}];
|
|
27
|
+
} else {
|
|
28
|
+
data = yaml(source);
|
|
29
|
+
if (!Array.isArray(data?.body) || !data.body.length || data.body.length > 50) throw new Error('Template needs between 1 and 50 form fields.');
|
|
30
|
+
fields = data.body.map((field,index) => {
|
|
31
|
+
if (!object(field) || !['markdown','input','textarea','dropdown','checkboxes'].includes(field.type)) throw new Error(`Unsupported field type: ${text(field?.type) || 'unknown'}. Open this form on GitHub instead.`);
|
|
32
|
+
const attrs = field.attributes;
|
|
33
|
+
if (!object(attrs)) throw new Error('Template field attributes are missing.');
|
|
34
|
+
if (field.type === 'markdown') return {id:`note-${index}`,type:'markdown',value:text(attrs.value)};
|
|
35
|
+
const id = field.id ?? `field-${index}`;
|
|
36
|
+
if (typeof id !== 'string' || !/^[a-zA-Z0-9_-]{1,80}$/.test(id) || ['__proto__','constructor','prototype'].includes(id)) throw new Error('Invalid form field identifier.');
|
|
37
|
+
if (!text(attrs.label).trim()) throw new Error('Template field label is missing.');
|
|
38
|
+
const value = {id,type:field.type,label:attrs.label,description:text(attrs.description),placeholder:text(attrs.placeholder),value:text(attrs.value),required:field.validations?.required === true,render:text(attrs.render)};
|
|
39
|
+
if (value.render && !/^[A-Za-z0-9_+.-]{1,32}$/.test(value.render)) throw new Error('Unsupported code language in template.');
|
|
40
|
+
if (field.type === 'dropdown' || field.type === 'checkboxes') {
|
|
41
|
+
if (!Array.isArray(attrs.options) || !attrs.options.length || attrs.options.length > 100) throw new Error('Invalid form choices.');
|
|
42
|
+
value.options = attrs.options.map(option => {
|
|
43
|
+
if (field.type === 'dropdown' && typeof option === 'string') return {label:option,required:false};
|
|
44
|
+
if (field.type === 'checkboxes' && object(option) && text(option.label).trim()) return {label:option.label,required:option.required === true};
|
|
45
|
+
throw new Error('Invalid form choice.');
|
|
46
|
+
});
|
|
47
|
+
if (new Set(value.options.map(option=>option.label)).size !== value.options.length) throw new Error('Duplicate form choices.');
|
|
48
|
+
value.multiple = field.type === 'checkboxes' || attrs.multiple === true;
|
|
49
|
+
value.value = value.multiple ? [] : '';
|
|
50
|
+
if (field.type === 'dropdown' && attrs.default !== undefined) {
|
|
51
|
+
if (!Number.isInteger(attrs.default) || !value.options[attrs.default]) throw new Error('Invalid default form choice.');
|
|
52
|
+
value.value = value.multiple ? [value.options[attrs.default].label] : value.options[attrs.default].label;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (!object(data) || !text(data.name).trim()) throw new Error('Template name is missing.');
|
|
59
|
+
if (new Set(fields.map(field=>field.id)).size !== fields.length) throw new Error('Duplicate field identifiers.');
|
|
60
|
+
return {id:name,sha,name:data.name,description:text(data.description || data.about),title:text(data.title),labels:names(data.labels),fields};
|
|
61
|
+
}
|
|
62
|
+
function project(repo) {
|
|
63
|
+
const repository=readProjectLinks(repo)?.repository;
|
|
64
|
+
if (!repository) throw new Error('This project has no configured GitHub origin.');
|
|
65
|
+
return {repository,slug:repository.slice('https://github.com/'.length)};
|
|
66
|
+
}
|
|
67
|
+
async function contents(slug, name, read) {
|
|
68
|
+
const value=await read(['api','--hostname','github.com',`repos/${slug}/contents/.github/ISSUE_TEMPLATE/${encodeURIComponent(name)}`]);
|
|
69
|
+
if (value?.type !== 'file' || value.encoding !== 'base64' || typeof value.content !== 'string' || value.size > 100000 || value.content.length > 150000 || !/^[a-f0-9]{40,64}$/.test(value.sha || '')) throw new Error('Unexpected template file from GitHub.');
|
|
70
|
+
return {source:Buffer.from(value.content,'base64').toString('utf8'),sha:value.sha};
|
|
71
|
+
}
|
|
72
|
+
export async function readTemplates(repo, read=githubRead) {
|
|
73
|
+
const {repository,slug}=project(repo);
|
|
74
|
+
let entries;
|
|
75
|
+
try { entries=await read(['api','--hostname','github.com',`repos/${slug}/contents/.github/ISSUE_TEMPLATE`]); }
|
|
76
|
+
catch(error) {
|
|
77
|
+
if (error.status !== 404) throw error;
|
|
78
|
+
// A private/inaccessible repository also returns 404; don't call that "no templates".
|
|
79
|
+
await read(['api','--hostname','github.com',`repos/${slug}`]);
|
|
80
|
+
return {repository,templates:[],contacts:[],warnings:[]};
|
|
81
|
+
}
|
|
82
|
+
if (!Array.isArray(entries)) throw new Error('Unexpected template directory from GitHub.');
|
|
83
|
+
const files=entries.filter(entry=>entry.type==='file' && filename(entry.name));
|
|
84
|
+
if (files.length > 20) throw new Error('This repository has more than 20 templates. Open its chooser on GitHub or use a blank local issue.');
|
|
85
|
+
const templates=await Promise.all(files.map(async file=>{
|
|
86
|
+
try { const {source,sha}=await contents(slug,file.name,read);return {...parseTemplate(file.name,source,sha),form_url:`${repository}/issues/new?template=${encodeURIComponent(file.name)}`}; }
|
|
87
|
+
catch(error) { return {id:file.name,name:file.name,description:'',unavailable:error.message,form_url:`${repository}/issues/new?template=${encodeURIComponent(file.name)}`}; }
|
|
88
|
+
}));
|
|
89
|
+
const config=entries.find(entry=>entry.type==='file' && /^config\.ya?ml$/i.test(entry.name));
|
|
90
|
+
const contacts=[],warnings=[];
|
|
91
|
+
if(config) {
|
|
92
|
+
try {
|
|
93
|
+
const value=yaml((await contents(slug,config.name,read)).source);
|
|
94
|
+
for(const link of Array.isArray(value?.contact_links)?value.contact_links:[]) {
|
|
95
|
+
const url=new URL(link.url);
|
|
96
|
+
if(url.protocol==='https:' && !url.username && !url.password && text(link.name).trim()) contacts.push({name:link.name,url:url.href,description:text(link.about)});
|
|
97
|
+
}
|
|
98
|
+
} catch { warnings.push('Repository contact links could not be loaded. Check the GitHub issue chooser for private reporting guidance.'); }
|
|
99
|
+
}
|
|
100
|
+
return {repository,templates,contacts,warnings};
|
|
101
|
+
}
|
|
102
|
+
export function compileTemplate(template, { title, answers }) {
|
|
103
|
+
if (typeof title !== 'string' || !title.trim() || title.length > 160) throw new Error('Provide an issue title of 1–160 characters.');
|
|
104
|
+
if (!object(answers)) throw new Error('Expected field answers.');
|
|
105
|
+
const sections=[];
|
|
106
|
+
for(const field of template.fields) {
|
|
107
|
+
if(field.type==='markdown') continue;
|
|
108
|
+
const answer=Object.hasOwn(answers,field.id) ? answers[field.id] : field.value;
|
|
109
|
+
let rendered;
|
|
110
|
+
if(field.multiple) {
|
|
111
|
+
if(!Array.isArray(answer) || answer.some(v=>typeof v!=='string' || !field.options.some(o=>o.label===v)) || new Set(answer).size!==answer.length) throw new Error(`Choose valid options for ${field.label}.`);
|
|
112
|
+
if(field.required&&!answer.length || field.options.some(option=>option.required&&!answer.includes(option.label))) throw new Error(`Complete the required choices in ${field.label}.`);
|
|
113
|
+
rendered=field.type==='checkboxes' ? field.options.map(option=>`- [${answer.includes(option.label)?'x':' '}] ${option.label}`).join('\n') : answer.join(', ');
|
|
114
|
+
} else {
|
|
115
|
+
if(typeof answer!=='string' || Buffer.byteLength(answer)>240000) throw new Error(`Invalid answer for ${field.label}.`);
|
|
116
|
+
if(field.required&&!answer.trim()) throw new Error(`${field.label} is required.`);
|
|
117
|
+
if(field.type==='dropdown' && answer && !field.options.some(option=>option.label===answer)) throw new Error(`Choose a valid option for ${field.label}.`);
|
|
118
|
+
rendered=answer.trim();
|
|
119
|
+
}
|
|
120
|
+
if (rendered && field.type==='textarea' && field.render) {
|
|
121
|
+
const longest=(rendered.match(/`+/g)||[]).reduce((max,run)=>Math.max(max,run.length),0);
|
|
122
|
+
const fence='`'.repeat(Math.max(3,longest+1));
|
|
123
|
+
rendered=`${fence}${field.render}\n${rendered}\n${fence}`;
|
|
124
|
+
}
|
|
125
|
+
if(rendered) sections.push(`### ${field.label}\n\n${rendered}`);
|
|
126
|
+
}
|
|
127
|
+
const spec=`# ${title.trim()}\n\n${sections.join('\n\n')}`;
|
|
128
|
+
const recommendation=recommendWork({spec,labels:template.labels});
|
|
129
|
+
return {title:title.trim(),spec,labels:template.labels,template:{id:template.id,sha:template.sha},recommendation};
|
|
130
|
+
}
|
|
131
|
+
export async function draftFromTemplate(repo, input, read=githubRead) {
|
|
132
|
+
if(!filename(input.template)) throw new Error('Choose a repository issue template.');
|
|
133
|
+
const {slug}=project(repo), file=await contents(slug,input.template,read);
|
|
134
|
+
if(typeof input.sha!=='string' || input.sha!==file.sha) throw new Error('The template changed. Reload templates and review the current fields.');
|
|
135
|
+
return compileTemplate(parseTemplate(input.template,file.source,file.sha),input);
|
|
136
|
+
}
|
package/factory/server.mjs
CHANGED
|
@@ -3,7 +3,9 @@ import http from 'node:http';
|
|
|
3
3
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
4
4
|
import { existsSync, readFileSync, readdirSync, lstatSync, realpathSync } from 'node:fs';
|
|
5
5
|
import { join, resolve, sep } from 'node:path';
|
|
6
|
-
import { readIssue } from './issue-intake.mjs';
|
|
6
|
+
import { listIssues, readIssue } from './issue-intake.mjs';
|
|
7
|
+
import { readTemplates, draftFromTemplate } from './issue-templates.mjs';
|
|
8
|
+
import { recommendWork } from './intake.mjs';
|
|
7
9
|
import { machineInfo } from './machine.mjs';
|
|
8
10
|
import { factoryDefinition } from './definition.mjs';
|
|
9
11
|
import { JobQueue, QueueError } from './queue.mjs';
|
|
@@ -57,6 +59,16 @@ export function createController(state, adapter = executors(state)) {
|
|
|
57
59
|
if (request.method === 'GET' && url.pathname === '/api/v1/definitions') {
|
|
58
60
|
return send(200, definitions);
|
|
59
61
|
}
|
|
62
|
+
if (request.method === 'GET' && url.pathname === '/api/v1/issues') {
|
|
63
|
+
if (!authenticated) throw new QueueError('Session required', 403);
|
|
64
|
+
try { return send(200, await listIssues(config.repo, Number(url.searchParams.get('page') || 1))); }
|
|
65
|
+
catch (error) { throw new QueueError(error.message, 400); }
|
|
66
|
+
}
|
|
67
|
+
if (request.method === 'GET' && url.pathname === '/api/v1/issue-templates') {
|
|
68
|
+
if (!authenticated) throw new QueueError('Session required', 403);
|
|
69
|
+
try { return send(200, await readTemplates(config.repo)); }
|
|
70
|
+
catch (error) { throw new QueueError(error.message, 400); }
|
|
71
|
+
}
|
|
60
72
|
const content = url.pathname.match(/^\/api\/v1\/artifacts\/(job_[a-f0-9]+)~(run_[a-f0-9]+)~([\w.-]+)\/content$/);
|
|
61
73
|
const artifactList = url.pathname.match(/^\/api\/v1\/jobs\/(job_[a-f0-9]+)\/artifacts$/);
|
|
62
74
|
if (request.method === 'GET' && (artifactList || content)) {
|
|
@@ -81,6 +93,14 @@ export function createController(state, adapter = executors(state)) {
|
|
|
81
93
|
try { return send(200, await readIssue(config.repo, input.url)); }
|
|
82
94
|
catch (error) { throw new QueueError(error.message, 400); }
|
|
83
95
|
}
|
|
96
|
+
if (url.pathname === '/api/v1/issue-templates/draft') {
|
|
97
|
+
try { return send(200, await draftFromTemplate(config.repo, input)); }
|
|
98
|
+
catch (error) { throw new QueueError(error.message, 400); }
|
|
99
|
+
}
|
|
100
|
+
if (url.pathname === '/api/v1/intake/recommend') {
|
|
101
|
+
try { return send(200, recommendWork(input)); }
|
|
102
|
+
catch (error) { throw new QueueError(error.message, 400); }
|
|
103
|
+
}
|
|
84
104
|
if (url.pathname === '/api/v1/jobs') {
|
|
85
105
|
if (input.model && input.model !== config.model && !['codex','pi'].includes(harnessOf(config))) throw new QueueError('Model overrides require a codex or pi executor', 400);
|
|
86
106
|
return send(201, queue.submit(input));
|
package/factory/terminology.json
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
"agent": "A configured work role with a responsibility and instructions, executed through a harness.",
|
|
7
7
|
"skill": "Reusable instructions available to an agent or operator; not a process or an access grant.",
|
|
8
8
|
"workflow": "The ordered agent steps, deterministic checks and approval gates for a work type.",
|
|
9
|
-
"task": "
|
|
9
|
+
"task": "Compatibility name for an admitted local issue, stored as a job with retained run attempts.",
|
|
10
10
|
"automation": "A trigger, filters and target that admits work automatically. Not yet implemented.",
|
|
11
|
-
"definition": "The effective work roles, workflows, skills and installation settings, separate from runtime records."
|
|
11
|
+
"definition": "The effective work roles, workflows, skills and installation settings, separate from runtime records.",
|
|
12
|
+
"issue": "A bounded local work request, created from a repository form, blank brief or GitHub issue; Create & start admits execution. This does not publish a GitHub issue."
|
|
12
13
|
}
|