software-defence-factory 0.4.3 → 0.4.4

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,6 +1,7 @@
1
1
  name: Factory task
2
2
  description: Describe a business outcome and observable acceptance criteria.
3
3
  title: "[Factory] "
4
+ labels: ["factory:triage"]
4
5
  body:
5
6
  - type: textarea
6
7
  id: need
@@ -20,7 +21,9 @@ body:
20
21
  id: scope
21
22
  attributes:
22
23
  label: Scope, constraints and recovery
23
- description: Affected repository, relevant files, allowed changes and rollback.
24
+ description: Affected repository, allowed changes, non-goals, dependencies and rollback. Link blocking issues.
25
+ validations:
26
+ required: true
24
27
  - type: dropdown
25
28
  id: track
26
29
  attributes:
@@ -32,4 +35,4 @@ body:
32
35
  id: capabilities
33
36
  attributes:
34
37
  label: Required capabilities
35
- description: For example browser, computer use, tests, web research or security review. Never paste credentials.
38
+ description: For example browser, tests, platform tools or security review. Name any external operator proof needed. Never paste credentials or sensitive findings into a public issue.
@@ -5,6 +5,7 @@ import { randomBytes } from 'node:crypto';
5
5
  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
+ import { assertInstalledJobImage, installCustomJobImage, installStandardJobImage, inspectImageInstallation } from '../factory/image-install.mjs';
8
9
  import { admitIncident } from '../factory/incident.mjs';
9
10
  import { DEFAULT_DEMO_STATE } from '../factory/paths.mjs';
10
11
  import { bootstrap, registerInstallation, VERSION } from '../factory/updates.mjs';
@@ -47,27 +48,16 @@ function init(repo, agent='codex', check='', port=7331) {
47
48
  console.log(`Configured ${state}\nApp files were not changed. Only committed code is cloned into jobs.`);
48
49
  }
49
50
 
50
- function retainImage(reference) {
51
- let image;
52
- try { image=run('docker',['image','inspect','--format','{{.Id}}',reference]); }
53
- catch(error) { if(/No such image|No such object/.test(error.message))return null;throw error; }
54
- if(!/^sha256:[a-f0-9]{64}$/.test(image))throw new Error('Docker returned an unexpected image identity');
55
- // Containerd can drop an untagged manifest when the shared build tag moves.
56
- // Keep the exact object reachable; existing installations retain their pins.
57
- run('docker',['tag',image,`software-defence-factory-retained:${image.slice(7)}`]);
58
- return image;
59
- }
60
51
  async function install() {
61
52
  const config=configAt(state);
62
- if(existsSync(join(state,'supervisor.json'))&&alive(json(join(state,'supervisor.json')).pid))throw new Error('Stop the factory before installing or updating its runtime');
63
53
  if (!['darwin','linux'].includes(process.platform)||!['arm64','x64'].includes(process.arch)) throw new Error('Use macOS/Linux arm64/amd64, or WSL2');
64
- run('docker',['info','--format','{{.ServerVersion}}']);
65
- retainImage(config.image);retainImage(PINS.jobImage);
66
- await stream('docker',['build','-t',PINS.jobImage,join(ROOT,'factory/image')]);
67
- config.image=retainImage(PINS.jobImage);
68
- if(!config.image)throw new Error('Built job image is unavailable');
69
- save(join(state,'factory.json'),config);save(join(state,'engine.json'),{runtime:'native-node',version:VERSION,image:config.image});
70
- console.log('Installed. Job image is pinned to its local image ID. No model call made.');
54
+ if(flags.image) {
55
+ const selected=installCustomJobImage(state,flags.image,{version:VERSION});
56
+ console.log(`Installed custom job image ${selected.image} from local reference ${selected.reference}. No image was downloaded or built; no model call made.`);
57
+ return;
58
+ }
59
+ const installed=await installStandardJobImage(state,{version:VERSION});
60
+ console.log(`Installed standard job image ${installed.image}. No model call made.`);
71
61
  }
72
62
  async function portFree(port) {
73
63
  await new Promise((ok,fail)=>{const server=createServer();server.once('error',fail);server.listen(port,'127.0.0.1',()=>server.close(ok));});
@@ -76,9 +66,9 @@ async function up() {
76
66
  const config=configAt(state),lock=join(state,'supervisor.json');
77
67
  registerInstallation(state);
78
68
  if(existsSync(lock)) { if(alive(json(lock).pid))throw new Error('Supervisor already running; use status');rmSync(lock); }
79
- if(!existsSync(join(state,'engine.json')))throw new Error('Run install first');
69
+ assertInstalledJobImage(state,config);
80
70
  if(process.getuid()===0)throw new Error('Run the controller as a dedicated unprivileged user with Docker access');
81
- run('docker',['image','inspect',config.image]);await portFree(config.port);
71
+ await portFree(config.port);
82
72
  const fd=openSync(join(state,'supervisor.log'),'a',0o600);
83
73
  const child=spawn(process.execPath,[join(ROOT,'factory/supervisor.mjs'),state],{detached:true,stdio:['ignore',fd,fd]});
84
74
  closeSync(fd);child.unref();
@@ -139,8 +129,7 @@ try {
139
129
  const launch=async()=>{
140
130
  if(process.getuid()===0)throw new Error('Use a dedicated unprivileged operator account');
141
131
  const lock=join(state,'supervisor.json');if(existsSync(lock)){if(alive(json(lock).pid))throw new Error('Supervisor already running');rmSync(lock);}
142
- if(!existsSync(join(state,'engine.json')))throw new Error('Run install first');
143
- run('docker',['image','inspect',configAt(state).image]);
132
+ assertInstalledJobImage(state,configAt(state));
144
133
  registerInstallation(state);await portFree(configAt(state).port);
145
134
  const { supervise } = await import('../factory/supervisor.mjs');await supervise(state);
146
135
  };
@@ -153,7 +142,9 @@ try {
153
142
  else if(command==='tunnel')await manageService('tunnel',positional[0],state,flags);
154
143
  else if(command==='status') { const snapshot=await api(state,'/api/v1/status');delete snapshot.csrf_token;console.log(JSON.stringify(snapshot,null,2)); }
155
144
  else if(command==='doctor') {
156
- const config=configAt(state);console.log(JSON.stringify({node:process.version,docker:run('docker',['info','--format','{{.ServerVersion}}']),engineInstalled:existsSync(join(state,'engine.json')),repo:config.repo,agent:config.agent,checksConfigured:!!config.check?.trim(),inference:'Not called or verified',dashboard:`http://127.0.0.1:${config.port}`},null,2));
145
+ const config=configAt(state),dockerVersion=run('docker',['info','--format','{{.ServerVersion}}']),imageStatus=inspectImageInstallation(state,config);
146
+ console.log(JSON.stringify({node:process.version,docker:dockerVersion,engineInstalled:imageStatus.installed,image:imageStatus.image,repo:config.repo,agent:config.agent,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));
147
+ if(!imageStatus.installed)process.exitCode=1;
157
148
  } else if(command==='run') {
158
149
  let spec;
159
150
  if(flags.issue) {
@@ -195,7 +186,7 @@ try {
195
186
  demo Install and run a synthetic sample (no model key)
196
187
  qualify --state PATH Exercise recovery and isolation with a stopped demo job
197
188
  init --repo PATH --agent codex|pi|custom --check "npm ci && npm test"
198
- install Build the isolated job image; the controller ships with the CLI
189
+ install [--image LOCAL_REF] Build the standard image, or select an existing local image
199
190
  doctor | up | status | stop Inspect / operate your private installation
200
191
  serve Foreground supervisor
201
192
  service [print] Print a systemd user-service definition
@@ -61,8 +61,23 @@ Use `status`, `cancel JOB_ID`, `retry JOB_ID` and `stop`, always with the select
61
61
 
62
62
  ## Native application builds
63
63
 
64
- Pin an application-specific image with the required toolchains. In `factory.json`,
65
- `cpus` (1–32, default 2), `pidsLimit` (64–16384, default 256), `memoryMiB` and
64
+ Build a compatible application image on the worker, then select its existing
65
+ local tag through the CLI:
66
+
67
+ ```sh
68
+ software-defence-factory install --image LOCAL_IMAGE_REF --state /private/state/my-app
69
+ software-defence-factory doctor --state /private/state/my-app
70
+ ```
71
+
72
+ Selection resolves and retains the immutable image ID. It does not pull or build
73
+ the reference. An unavailable image, a running controller, an unreconciled job
74
+ or a remaining job container makes selection fail without changing the last
75
+ working configuration. Plain `install` remains the standard-image build path
76
+ and selects the standard image again. `doctor` verifies that the recorded image
77
+ metadata matches the image selected in private state; it reports model and
78
+ toolchain qualification separately, and does not perform either qualification.
79
+
80
+ In `factory.json`, `cpus` (1–32, default 2), `pidsLimit` (64–16384, default 256), `memoryMiB` and
66
81
  `timeoutSeconds` bound the job's resources. Verification uses a separate
67
82
  disk-backed checkout, keeping the candidate read-only. Its private scratch
68
83
  directory is removed after container termination is confirmed. Interrupted
package/docs/recovery.md CHANGED
@@ -90,3 +90,12 @@ and any contemporaneous private configuration backup. Record only corroborated
90
90
  facts in a separate private incident note with evidence paths and unknowns; leave
91
91
  the original queue/evidence unchanged. Without that evidence, model/image facts
92
92
  cannot be reconstructed reliably.
93
+
94
+ ### Interrupted image selection or controller startup
95
+
96
+ `installation.lock` serializes image changes with controller startup, including
97
+ managed boot/restarts. A live supervisor then prevents image changes while work
98
+ can run. If an operation is interrupted, preserve the lock and inspect its PID
99
+ and action. Remove it only after confirming that process and its image build or
100
+ startup have stopped, then run `doctor` and reconcile image metadata before
101
+ starting again. The CLI does not automatically clear an unknown lock.
package/docs/setup.md CHANGED
@@ -104,6 +104,9 @@ anything. Verify the canonical GitHub origin and main branch's tracking target;
104
104
  a fork may still track its upstream product. Move application sources into the
105
105
  chosen workspace, not into the npm package or private runtime state.
106
106
 
107
+ Use [repository readiness](../kit/repository.md) for issue forms, labels, CI
108
+ policy and the explicit issue-to-job handoff. A ready label does not start a job.
109
+
107
110
  Before admitting development work, establish:
108
111
 
109
112
  - Reproducible toolchain/dependency pins and an actual build/check command.
@@ -123,11 +126,20 @@ software-defence-factory install --state /private/state/my-app
123
126
  software-defence-factory doctor --state /private/state/my-app
124
127
  ```
125
128
 
126
- Replace the agent/check/paths with the accepted application profile. `install`
127
- builds the standard image: do not use it to overwrite an existing custom image
128
- selection. Follow the application image's own build/pinning procedure instead.
129
- `init` does not edit the app, install personal skills or submit a task. Runtime
130
- jobs receive the bundled method and six skills automatically.
129
+ Replace the agent/check/paths with the accepted application profile. Plain
130
+ `install` builds the standard image. To use an application-specific image, build
131
+ it on the worker first and select its existing local tag instead:
132
+
133
+ ```sh
134
+ software-defence-factory install --image LOCAL_IMAGE_REF --state /private/state/my-app
135
+ ```
136
+
137
+ This command checks the local Docker daemon, records and retains the exact image
138
+ ID, and does not download or build the selected image. Stop the installation and
139
+ reconcile every job before changing its image. Running plain `install` later
140
+ still rebuilds and selects the standard image. `init` does not edit the app,
141
+ install personal skills or submit a task. Runtime jobs receive the bundled
142
+ method and six skills automatically.
131
143
 
132
144
  For a local model, verify the existing model service, intended model name and
133
145
  its startup. Test the model API from a disposable container using the **selected
@@ -0,0 +1,189 @@
1
+ import {
2
+ existsSync, readFileSync, readdirSync, writeFileSync, renameSync, rmSync,
3
+ } from 'node:fs';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { join } from 'node:path';
6
+ import { configAt, instanceLabel, json, PINS, ROOT, run, stream } from './lib.mjs';
7
+ import { acquireInstallationLock } from './installation-lock.mjs';
8
+ import { VERSION } from './updates.mjs';
9
+
10
+ const imageIdPattern = /^sha256:[a-f0-9]{64}$/;
11
+ const imageReferencePattern = /^[a-zA-Z0-9][a-zA-Z0-9_./:@-]*$/;
12
+ const missingImage = error => /no such (image|object)/i.test(error.message);
13
+ const alive = pid => {
14
+ try { process.kill(pid, 0); return true; }
15
+ catch (error) { if (error.code === 'ESRCH') return false; throw error; }
16
+ };
17
+
18
+ function docker(args, runner = run) { return runner('docker', args); }
19
+
20
+ export function inspectLocalImage(reference, runner = run) {
21
+ if (typeof reference !== 'string' || !imageReferencePattern.test(reference))
22
+ throw new Error('Invalid Docker image reference');
23
+ let id;
24
+ try { id = docker(['image', 'inspect', '--format', '{{.Id}}', reference], runner); }
25
+ catch (error) {
26
+ if (missingImage(error)) return null;
27
+ throw error;
28
+ }
29
+ if (!imageIdPattern.test(id)) throw new Error('Docker returned an unexpected image identity');
30
+ return id;
31
+ }
32
+
33
+ export function retainImage(image, runner = run) {
34
+ if (!imageIdPattern.test(image || '')) throw new Error('Expected an immutable Docker image ID');
35
+ // The shared build tag can move. Keep every selected image reachable by ID.
36
+ docker(['image', 'tag', image, `software-defence-factory-retained:${image.slice(7)}`], runner);
37
+ return image;
38
+ }
39
+
40
+ function retainIfPresent(reference, runner) {
41
+ const image = inspectLocalImage(reference, runner);
42
+ if (image) retainImage(image, runner);
43
+ return image;
44
+ }
45
+
46
+ export function assertImageChangeSafe(state, { runner = run, isAlive = alive } = {}) {
47
+ const supervisor = join(state, 'supervisor.json');
48
+ if (existsSync(supervisor)) {
49
+ let record;
50
+ try { record = json(supervisor); }
51
+ catch { throw new Error('Supervisor state is unreadable; reconcile the installation before changing its image'); }
52
+ if (!Number.isSafeInteger(record.pid) || record.pid < 1)
53
+ throw new Error('Supervisor state is unverified; reconcile the installation before changing its image');
54
+ if (isAlive(record.pid)) throw new Error('Stop the Factory before changing its job image');
55
+ }
56
+
57
+ const jobs = join(state, 'jobs');
58
+ if (existsSync(jobs)) {
59
+ for (const job of readdirSync(jobs)) {
60
+ if (existsSync(join(jobs, job, 'active.json')))
61
+ throw new Error(`Job ${job} has active or unreconciled execution; reconcile it before changing the image`);
62
+ }
63
+ }
64
+
65
+ const containers = docker(['ps', '-aq', '--filter', `label=sdf.factory=${instanceLabel(state)}`], runner)
66
+ .split('\n').filter(Boolean);
67
+ if (containers.length) throw new Error('Factory job containers remain; stop and reconcile them before changing the image');
68
+ }
69
+
70
+ function persistInstallation(state, config, metadata, filesystem = undefined) {
71
+ const fs = filesystem || { readFileSync, writeFileSync, renameSync, rmSync };
72
+ const configPath = join(state, 'factory.json'), enginePath = join(state, 'engine.json');
73
+ const previousConfig = fs.readFileSync(configPath);
74
+ const token = `${process.pid}.${randomBytes(8).toString('hex')}`;
75
+ const configTemp = `${configPath}.${token}.next`, engineTemp = `${enginePath}.${token}.next`;
76
+ let configReplaced = false;
77
+ try {
78
+ fs.writeFileSync(configTemp, `${JSON.stringify(config, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
79
+ fs.writeFileSync(engineTemp, `${JSON.stringify(metadata, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
80
+ fs.renameSync(configTemp, configPath);
81
+ configReplaced = true;
82
+ fs.renameSync(engineTemp, enginePath);
83
+ } catch (error) {
84
+ if (configReplaced) {
85
+ const rollback = `${configPath}.${token}.rollback`;
86
+ try {
87
+ fs.writeFileSync(rollback, previousConfig, { flag: 'wx', mode: 0o600 });
88
+ fs.renameSync(rollback, configPath);
89
+ } catch (rollbackError) {
90
+ throw new Error(`Image installation metadata could not be completed (${error.message}); restoring factory.json also failed (${rollbackError.message})`);
91
+ } finally { fs.rmSync(rollback, { force: true }); }
92
+ }
93
+ throw error;
94
+ } finally {
95
+ fs.rmSync(configTemp, { force: true });
96
+ fs.rmSync(engineTemp, { force: true });
97
+ }
98
+ }
99
+
100
+ function metadataFor(image, source, reference, version) {
101
+ return {
102
+ runtime: 'native-node',
103
+ version,
104
+ image,
105
+ imageSource: source,
106
+ imageReference: reference,
107
+ };
108
+ }
109
+
110
+ export function installCustomJobImage(state, reference, { runner = run, version = VERSION, isAlive = alive, filesystem } = {}) {
111
+ const release = acquireInstallationLock(state, 'select image');
112
+ try {
113
+ const config = configAt(state);
114
+ assertImageChangeSafe(state, { runner, isAlive });
115
+ const image = inspectLocalImage(reference, runner);
116
+ if (!image) throw new Error(`Docker image ${reference} is not present locally; build or pull it explicitly before selecting it`);
117
+ retainImage(image, runner);
118
+ const nextConfig = { ...config, image };
119
+ const metadata = metadataFor(image, 'custom', reference, version);
120
+ persistInstallation(state, nextConfig, metadata, filesystem);
121
+ return { image, reference, config: nextConfig, metadata };
122
+ } finally { release(); }
123
+ }
124
+
125
+ export async function installStandardJobImage(state, { runner = run, build = stream, version = VERSION, isAlive = alive, filesystem } = {}) {
126
+ const release = acquireInstallationLock(state, 'build image');
127
+ try {
128
+ const config = configAt(state);
129
+ assertImageChangeSafe(state, { runner, isAlive });
130
+ docker(['info', '--format', '{{.ServerVersion}}'], runner);
131
+ retainIfPresent(config.image, runner);
132
+ retainIfPresent(PINS.jobImage, runner);
133
+ await build('docker', ['build', '-t', PINS.jobImage, join(ROOT, 'factory/image')]);
134
+ const image = inspectLocalImage(PINS.jobImage, runner);
135
+ if (!image) throw new Error('Built job image is unavailable');
136
+ retainImage(image, runner);
137
+ const nextConfig = { ...config, image };
138
+ const metadata = metadataFor(image, 'standard', PINS.jobImage, version);
139
+ persistInstallation(state, nextConfig, metadata, filesystem);
140
+ return { image, config: nextConfig, metadata };
141
+ } finally { release(); }
142
+ }
143
+
144
+ function readMetadata(state) {
145
+ const path = join(state, 'engine.json');
146
+ if (!existsSync(path)) return null;
147
+ try { return json(path); } catch { return null; }
148
+ }
149
+
150
+ export function inspectImageInstallation(state, config = configAt(state), runner = run) {
151
+ const metadata = readMetadata(state);
152
+ let image = null;
153
+ image = inspectLocalImage(config.image, runner);
154
+
155
+ const metadataValid = Boolean(metadata
156
+ && metadata.runtime === 'native-node'
157
+ && typeof metadata.version === 'string'
158
+ && imageIdPattern.test(metadata.image || '')
159
+ && (!metadata.imageSource || ['standard', 'custom'].includes(metadata.imageSource))
160
+ && (!metadata.imageReference || imageReferencePattern.test(metadata.imageReference)));
161
+ const imageAvailable = image !== null;
162
+ const markerMatches = metadataValid && imageAvailable && metadata.image === image;
163
+ const installed = Boolean(markerMatches);
164
+ return {
165
+ installed,
166
+ image: {
167
+ reference: config.image,
168
+ id: image,
169
+ available: imageAvailable,
170
+ pinned: imageAvailable && config.image === image,
171
+ markerImage: metadataValid ? metadata.image : null,
172
+ source: metadataValid ? (metadata.imageSource || 'legacy') : null,
173
+ referenceAtInstall: metadataValid ? (metadata.imageReference || null) : null,
174
+ metadataValid,
175
+ markerMatches: Boolean(markerMatches),
176
+ error: imageAvailable ? null : `Selected image ${config.image} is not present locally`,
177
+ },
178
+ };
179
+ }
180
+
181
+ export function assertInstalledJobImage(state, config = configAt(state), runner = run) {
182
+ const status = inspectImageInstallation(state, config, runner);
183
+ if (!status.installed) {
184
+ if (!status.image.available) throw new Error(`Selected job image ${config.image} is not present locally; run install with an available image`);
185
+ if (!status.image.metadataValid) throw new Error('Runtime image metadata is missing or invalid; run install');
186
+ throw new Error('Selected job image does not match the installed image metadata; run install');
187
+ }
188
+ return status;
189
+ }
@@ -0,0 +1,15 @@
1
+ import { writeFileSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ // Per installation: managed boot bypasses the CLI's global service-operation
5
+ // lock. Hold this fence until image metadata is complete or the supervisor PID
6
+ // is visible, before any queued work can snapshot an execution profile.
7
+ export function acquireInstallationLock(state, action) {
8
+ const path = join(state, 'installation.lock');
9
+ try { writeFileSync(path, JSON.stringify({ pid: process.pid, action }), { flag: 'wx', mode: 0o600 }); }
10
+ catch (error) {
11
+ if (error.code === 'EEXIST') throw new Error(`Another installation operation may own ${path}; reconcile its PID before removing the lock`);
12
+ throw error;
13
+ }
14
+ return () => rmSync(path);
15
+ }
@@ -5,6 +5,7 @@ import { spawnSync } from 'node:child_process';
5
5
  import { createServer } from 'node:net';
6
6
  import { ROOT, STATE_HOME, DATA_HOME, SOURCE_CHECKOUT } from './paths.mjs';
7
7
  import { configAt, json, save, run, api, sleep, digest } from './lib.mjs';
8
+ import { assertInstalledJobImage } from './image-install.mjs';
8
9
  import { VERSION, newer, latestVersion, installRelease, busyInstallations } from './updates.mjs';
9
10
  import { serviceId, systemdUnit, launchAgent, tunnelArguments, groupArguments, runtimeLauncher } from './service-files.mjs';
10
11
 
@@ -127,9 +128,8 @@ async function install(kind, state, flags) {
127
128
  console.log(JSON.stringify(await serviceStatus(previous), null, 2)); return;
128
129
  }
129
130
  if (kind === 'controller') {
130
- if (!existsSync(join(state, 'engine.json'))) throw new Error('Run install for this state before installing its service');
131
+ assertInstalledJobImage(state, configAt(state));
131
132
  if (existsSync(join(state, 'supervisor.json')) && alive(json(join(state, 'supervisor.json')).pid)) throw new Error('Stop the manually started controller before adopting it as a service');
132
- run('docker', ['image', 'inspect', configAt(state).image]);
133
133
  }
134
134
  let argv = kind === 'controller' ? [process.execPath, launcher(), 'serve', '--state', state] : tunnelArguments(flags.host, port);
135
135
  if (flags.group) {
@@ -2,10 +2,18 @@ import { writeFileSync, rmSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { configAt, stopContainers } from './lib.mjs';
5
+ import { acquireInstallationLock } from './installation-lock.mjs';
6
+ import { assertInstalledJobImage } from './image-install.mjs';
5
7
  import { createController } from './server.mjs';
6
8
  export async function supervise(state) {
7
- const config = configAt(state), lock = join(state, 'supervisor.json');
8
- writeFileSync(lock, JSON.stringify({ pid: process.pid }), { flag: 'wx', mode: 0o600 });
9
+ const lock = join(state, 'supervisor.json');
10
+ const release = acquireInstallationLock(state, 'start controller');
11
+ let config;
12
+ try {
13
+ config = configAt(state);
14
+ assertInstalledJobImage(state, config);
15
+ writeFileSync(lock, JSON.stringify({ pid: process.pid }), { flag: 'wx', mode: 0o600 });
16
+ } finally { release(); }
9
17
  let controller, stopping = false;
10
18
  async function shutdown(code = 0) {
11
19
  if (stopping) return; stopping = true;
package/kit/README.md CHANGED
@@ -18,6 +18,10 @@ The six skills cover triage, specification, implementation, review, security and
18
18
 
19
19
  ## First real task
20
20
 
21
+ Follow [repository readiness](repository.md) to adopt issue forms and labels,
22
+ verify CI/protection and explicitly admit a scoped task. Exporting this kit does
23
+ not install GitHub labels, poll issues or start agents.
24
+
21
25
  Choose a small existing defect or improvement. Describe the intended user behavior, allowed scope and observable acceptance check. Implement a working vertical slice, exercise the app's relevant checks and obtain a separate review of the delivered revision. A demo or copied skill files alone do not qualify an application.
22
26
 
23
27
  A PR requires the configured GitHub authority. Otherwise hand back the branch/diff and evidence with an honest status. Unknown cost/time remain unknown. Automated starts require separate qualification of triggers, deduplication, stop/restart and actual resource limits; a skill does not create a scheduler.
@@ -0,0 +1,86 @@
1
+ # Prepare a repository for Factory work
2
+
3
+ The method works with an existing agent and CI. The optional runtime adds a
4
+ queue, isolated execution and dashboard. GitHub preparation provides a shared
5
+ work queue; it does not turn labels into an execution trigger.
6
+
7
+ ## Establish a reproducible baseline
8
+
9
+ Record the canonical Git origin, target branch, committed source revision,
10
+ toolchain, check command and existing owner instructions. Preserve WIP separately.
11
+ Verify the real checks in the intended job image and CI. Inspect the actual
12
+ branch protection/ruleset: required checks, current-base behavior, PR policy and
13
+ merge authority. If an account plan prevents enforcement, record the limitation;
14
+ do not silently change billing or repository visibility.
15
+
16
+ ## Adopt the issue form and labels
17
+
18
+ The exported kit includes `.github/ISSUE_TEMPLATE/factory-task.yml` and
19
+ `.factory-kit/labels.json`. Review the form and merge it with existing repository
20
+ templates on a branch. The source repository keeps these label definitions in
21
+ `config/labels.json`. Install labels before enabling the form's default label.
22
+
23
+ For an authorized GitHub repository, `gh label create` creates one label; use
24
+ `gh label edit` only after comparing an existing label with the proposed meaning.
25
+ Do not delete or overwrite an adopter's unrelated labels. For example:
26
+
27
+ ```sh
28
+ gh label list --repo OWNER/REPO
29
+ gh label create 'factory:triage' --repo OWNER/REPO --color D9E2D3 --description 'Needs bounded scope and capability routing'
30
+ # Repeat for the selected definitions in labels.json, then read them back.
31
+ gh label list --repo OWNER/REPO
32
+ ```
33
+
34
+ | Label | Meaning |
35
+ | --- | --- |
36
+ | `factory:triage` | An idea or report needing bounded scope |
37
+ | `factory:spec` | Outcome, constraints or acceptance still need definition |
38
+ | `factory:ready` | Accepted scope is ready for explicit admission |
39
+ | `factory:review` | A candidate/evidence set needs operator review |
40
+ | `factory:blocked` | A concrete dependency or recovery prevents progress |
41
+ | `track:software` | Software delivery |
42
+ | `track:security` | Appropriately scoped security work; sensitive evidence stays private |
43
+
44
+ Use at most one Factory stage label at a time; track labels are separate.
45
+ GitHub's closed state records completed or declined issues. Runtime job state is
46
+ more precise than these planning labels and remains authoritative for execution.
47
+ Update labels deliberately; the current runtime does not synchronize them.
48
+
49
+ ## Admit one ready issue
50
+
51
+ A ready task identifies the desired behavior, allowed scope, non-goals,
52
+ dependencies, meaningful acceptance, required capabilities and delivery target.
53
+ Split a large roadmap into independently reviewable changes. Assign external
54
+ browser/platform proof explicitly when the selected worker image lacks it.
55
+ Never treat a label, issue author or text as permission to obtain credentials,
56
+ change policy, deploy or merge.
57
+
58
+ For the runtime, inspect the selected installation and queue before starting it;
59
+ existing queued jobs may execute on startup. Hold a dedicated committed source
60
+ at the intended SHA until the current runtime has cloned it, and compare the
61
+ candidate's recorded base before acceptance. Admission-time source snapshots are
62
+ tracked separately in Factory #28; naming a SHA in task text does not pin it.
63
+
64
+ Submit the selected issue with `software-defence-factory run --issue URL --state
65
+ PATH`, or submit a reviewed task file using `run --file`. Issue submission uses
66
+ the operator's `gh` authentication and checks the issue against the configured
67
+ origin. The dashboard's task form submits text to the same runtime; it does not
68
+ currently browse or import a live GitHub backlog. Keep external link/issue/job
69
+ mapping in the trusted handoff record. No automatic GitHub polling is enabled.
70
+
71
+ ## Review and deliver
72
+
73
+ The implementation, configured checks and independent review must cover the
74
+ same candidate and acceptance policy. The operator completes external proof and
75
+ approves the specific reviewed result. Retain failed attempts and use explicit
76
+ revision feedback when a candidate needs changes.
77
+
78
+ The current runtime returns a patch after acceptance. The authorized operator
79
+ applies it at its recorded base on a unique branch, verifies the resulting tree,
80
+ and opens a normal PR subject to current checks/protection. GitHub credentials
81
+ stay outside jobs. Accepted is not pushed, merged or deployed. Optional trusted
82
+ PR publication is Factory #29, not an installed feature of this guide.
83
+
84
+ Link issue, job, base, candidate, checks, review, external proof and PR. Keep
85
+ private paths, credentials and raw incident/model logs outside public records.
86
+ One successful scoped task qualifies that path, not arbitrary unattended work.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "software-defence-factory",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Scoped software delivery and defence investigations with isolated jobs, evidence and review",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -17,7 +17,7 @@ if (args.length === 1 && args[0] === "--help") {
17
17
  const destination = resolve(args[0]);
18
18
  try {
19
19
  const sources = new Map();
20
- for (const name of ["README.md", "policy.md", "installation.md", "delivery.md", "examples/github-checks.yml.example"])
20
+ for (const name of ["README.md", "policy.md", "installation.md", "delivery.md", "repository.md", "examples/github-checks.yml.example"])
21
21
  sources.set(`.factory-kit/${name}`, `kit/${name}`);
22
22
  for (const role of ["triage", "spec", "implement", "review", "security", "evaluate"]) {
23
23
  const path = `.agents/skills/factory-${role}/SKILL.md`;