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.
@@ -1,4 +1,4 @@
1
- name: Factory task
1
+ name: Factory issue
2
2
  description: Describe a business outcome and observable acceptance criteria.
3
3
  title: "[Factory] "
4
4
  labels: ["factory:triage"]
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 task opens a local brief; importing a GitHub issue is optional. 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.
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(!configAt(state).check?.trim())throw new Error('Configure an app check before submitting software work');
168
- console.log(JSON.stringify(await submit('software',spec)));
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
- run --file task.md | --issue URL Submit one software vertical slice
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
- | Task | Bounded admitted work | A stored job with retained run attempts; not a GitHub issue |
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 tasks, not an imported GitHub backlog. An issue is source
20
- material; importing it previews the scope and starting a task queues execution.
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 task needs neither a GitHub issue nor
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.
@@ -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
- | Submit software text | `run --file` | `POST /api/v1/jobs` | New task modal | Equivalent title/source/model options and validation |
14
- | Import a GitHub issue | `run --issue` via operator `gh` | Authenticated `POST /api/v1/issues/preview` using the same reader | Import, review and explicitly start | Backlog/listing, stronger issue/job identity and optional qualified triggers remain; no implicit polling |
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 |
@@ -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
- Tasks contains both Software delivery and Defence investigation. Select a
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 and New issue appear only
100
- for a validated GitHub origin; they open GitHub and do not synchronize its
101
- backlog. The task detail provides previous/next within the filtered list, copy
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 task** in Inbox, write a bounded brief and start it. Alternatively,
28
- choose **From GitHub issue**, load an issue from the configured repository,
29
- review the imported scope and start it. Starting queues real work; creating an
30
- issue or setting a label does not. The CLI equivalent is `run --file task.md`
31
- or `run --issue URL`. Private validated incident intake uses `incident --file`.
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
+ }
@@ -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 = async url => {
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
- return { title: issue.title, url: issue.url, body: issue.body, spec };
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
+ }
@@ -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));
@@ -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": "A bounded work request admitted to the controller; stored as a job with retained run attempts.",
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
  }