software-defence-factory 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,7 +45,7 @@ The runtime supplies policy and six focused skills to its isolated jobs. `init`
45
45
 
46
46
  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.
47
47
 
48
- The project dashboard has an **Inbox**, measured **Analytics**, **Agents**, **Skills**, **Automations**, **Definition** and **Infrastructure**. Inbox lists repository issues with readiness and linked execution attempts. New issue offers repository templates or a blank creation form. Create an issue on the supported repository provider, then choose Start work separately; local brief execution remains available. 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). Optional automations belong to the selected harness, which calls Factory CLI/API. Factory runs no cron scheduler. See [provider boundaries](docs/integrations.md).
48
+ The project dashboard has an **Inbox**, measured **Analytics**, **Agents**, **Skills**, **Automations**, **Definition** and **Infrastructure**. Inbox opens on a shared list/board of loaded repository issues and local work, with status, search and checkbox filters, readiness and linked execution attempts. New issue offers repository templates or a blank creation form. Create an issue on the supported repository provider, then choose Start work separately; local brief execution remains available. 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). Optional automations belong to the selected harness, which calls Factory CLI/API. Factory runs no cron scheduler. See [provider boundaries](docs/integrations.md).
49
49
 
50
50
  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).
51
51
 
package/docs/concepts.md CHANGED
@@ -18,8 +18,10 @@ The CLI `definition` command and dashboard Definition page read that same catalo
18
18
  | Automation | External schedule and agent context | Owned by the selected harness; calls Factory CLI/API, no Factory cron |
19
19
  | Definition | Effective roles, workflows, skills and settings | Installed method plus private factory.json; read-only catalog |
20
20
 
21
- Inbox contains execution history, not a copied remote issue backlog. New issue
22
- can create a repository issue without execution; Start work admits execution
21
+ Inbox combines an explicitly loaded provider page with retained execution
22
+ history in one list/board, grouped by canonical issue identity. Off-page sources
23
+ remain unknown; local requests keep separate identities. New issue
24
+ creates a repository issue without execution; Start work admits execution
23
25
  separately. The provider owns issue content/state; SQLite owns queue/attempts and
24
26
  creation receipts for recovery. An unfinished local form is not a saved backlog.
25
27
  An agent role is neither a machine nor a skill. Check is deterministic, and
@@ -11,7 +11,7 @@ shell endpoint or a second scheduler.
11
11
  | --- | --- | --- | --- | --- |
12
12
  | Project/queue/attempt state | `status`, `inbox --source factory` JSON | `GET /api/v1/status` | Project, tasks, details/history | Stable versioned agent result/error contract |
13
13
  | Start local work | `issue start --file --title` or `--draft`, explicit `--workflow`, optional `--model` | `POST /api/v1/jobs` | Local execution only → review → Create & start locally | Persistent unstarted drafts and typed incident intake remain separate |
14
- | Browse repository Inbox | `inbox [--page N] [--issue-state open/closed/all]` (also `issue list --source inbox`), `issue preview --url URL` via controller provider | Authenticated `GET /api/v1/issues`, `POST /api/v1/issues/preview` using shared readers | Inbox with provider/state/readiness, loaded-page counts, linked attempts, local/off-page history; explicit Start work for either type | Issue → execution links retained; no implicit polling |
14
+ | Browse repository Inbox | `inbox [--page N] [--issue-state open/closed/all]` (also `issue list --source inbox`), `issue preview --url URL` via controller provider | Authenticated `GET /api/v1/issues`, `POST /api/v1/issues/preview` using shared readers | Primary list/Board with shared status/search/workflow/model/label filters, compact Repository tools/loaded scope, item count with secondary issue/execution totals and progressive linked detail; explicit Start work for either type | Issue → execution links retained; no implicit polling |
15
15
  | Start repository work | `issue start --url URL --workflow software/defence [--brief-file operator.md]` | `POST /api/v1/issues/start` | Issue context → explicit Start work with operator brief | Rechecks current content and active admission |
16
16
  | Create repository issue / recovery | `issue connection`, `create --key`, `submissions`, `recover --key` | Authenticated connection, `POST /issues`, receipts and recovery | Display destination/actor, create without execution, recover uncertain result | GitHub adapter first; assignees/projects and other providers unimplemented |
17
17
  | 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 |
@@ -106,3 +106,25 @@ Definition exposes `configuration.issueReadinessLabels`. The optional private
106
106
  config field has exactly triage/spec/ready/blocked keys with four distinct label
107
107
  names; defaults are factory:triage/spec/ready/blocked. Configuration is validated,
108
108
  not inferred from issue content. No browsing path changes labels or comments.
109
+
110
+ The additive `work_records` read model is returned by `GET /api/v1/issues`
111
+ (and CLI `inbox` / `issue list --source inbox`) alongside the existing `issues`
112
+ and `history` fields. `status` returns the same model for retained history without
113
+ fetching a provider page. Each record includes a canonical `key`, source `identity`,
114
+ `source_status` (`loaded`, `not_loaded`, `local`), optional source `issue`, all
115
+ `executions`, representative `execution_id`, actual `state`, `workflow` and `phase`.
116
+ Active/unresolved execution takes precedence over the latest terminal execution.
117
+ Without an execution, state is `not_started`, workflow/phase and execution ID are
118
+ null. Readiness lives on the source issue, separate from execution state. Dashboard
119
+ projection uses this same pure read-model function with its bounded page snapshot
120
+ and latest status; grouping/filtering does not admit execution. Individual
121
+ `#/runs/JOB_ID` links remain valid; `#/issues/KEY` opens canonical source detail.
122
+
123
+ The dashboard's Repository disclosure beside the item count holds source-state
124
+ selection, refresh, available previous/next page actions and Local execution
125
+ request. It also explains read time, remote total, the loaded search boundary
126
+ and separate issue/local request/execution counts. Loading and source errors
127
+ remain visible when it is closed. Rows and cards share compact metadata; source
128
+ readiness remains distinct from the runtime badge, and full assignments and
129
+ linked attempts remain in detail. These presentation controls do not change
130
+ headless records or admission semantics.
package/docs/npm.md CHANGED
@@ -104,8 +104,8 @@ installation or retained attempt needs them.
104
104
 
105
105
  ## Protected evidence compatibility
106
106
 
107
- Factory 0.11.1 recognizes version-1 execution profiles emitted by native
108
- **0.8.0, 0.9.0, 0.9.1, 0.10.0, 0.11.0 and 0.11.1**. This is an exact allowlist in
107
+ Factory 0.12.0 recognizes version-1 execution profiles emitted by native
108
+ **0.8.0, 0.9.0, 0.9.1, 0.10.0, 0.11.0, 0.11.1, 0.11.2 and 0.12.0**. This is an exact allowlist in
109
109
  `factory/execution-profile.mjs`, independent of the installed package version;
110
110
  it is not a semver range or an automatic promise for later releases. Unknown
111
111
  runtime strings, unknown profile formats and incomplete legacy acceptance
@@ -143,6 +143,16 @@ a checkpoint usable. Browser-disabled 0.8.0 records cannot satisfy a newly
143
143
  enabled browser policy. Missing or unsupported records stay unchanged and
144
144
  unavailable; no schema migration, profile relabeling or policy repair occurs.
145
145
 
146
+ Version 0.11.2 retains the v1 profile/evidence schema and current provenance
147
+ rules while preserving exact patch bytes and requiring reconstruction before
148
+ new acceptance. Older writers remain compatible only when their retained patch
149
+ passes the current digest and tree reconstruction checks. A digest-matching
150
+ malformed patch remains blocked and unchanged; follow the [recovery guidance](recovery.md#already-accepted-malformed-patches-91).
151
+
152
+ Version 0.12.0 adds the all-work Inbox read model and restores the retained
153
+ overview. Protected execution writers and acceptance/publication guards are
154
+ unchanged; the explicit compatible-writer entry preserves all checks above.
155
+
146
156
  New publication still requires the current remote target to equal the original
147
157
  accepted base; reviewed-candidate continuation preserves that baseline, while
148
158
  target refresh remains #72. See [the verification map](https://github.com/arcitai/software-and-defence-factory/blob/main/docs/proof.md) for delivered
package/docs/recovery.md CHANGED
@@ -332,6 +332,27 @@ approval; do not edit SQLite or acceptance evidence to bypass the guard. A stale
332
332
  target after branch creation leaves that unique branch for inspection and does
333
333
  not open a PR. A PR does not imply integration or deployment.
334
334
 
335
+ ## Already accepted malformed patches (#91)
336
+
337
+ A matching digest does not prove a patch can reproduce its candidate. Before
338
+ 0.11.2, trimming Git diff output could remove trailing blank context lines.
339
+ Native publication correctly refuses such a patch before provider writes.
340
+ Preserve the accepted record, patch, digests, retained Git objects and prior
341
+ attempts. Restarting or reading state does not repair or migrate this evidence.
342
+
343
+ For recovery, use a separately reviewed, normal protected maintainer PR from
344
+ the exact retained candidate tree, checking its tree identity and original base,
345
+ or run a new native attempt with fresh Verify, independent Review and approval.
346
+ The maintainer route is not successful native publication. Never replace an old
347
+ patch or acceptance hash, fabricate approval, or disable reconstruction guards.
348
+
349
+ New handoffs in 0.11.2 retain raw Git bytes and reconstruct the protected patch
350
+ against the original retained base in private bare storage before acceptance.
351
+ Failure retains evidence and creates no accepted record or provider writes.
352
+ Publication still repeats reconstruction and all current provenance, source,
353
+ workflow and target checks. Operator installed/native qualification on Z13 is
354
+ required before further general unattended delivery.
355
+
335
356
  ## Continue a reviewed candidate (first #42 slice)
336
357
 
337
358
  Use one explicit starting point for a current software review with a returned
package/docs/workflows.md CHANGED
@@ -24,7 +24,9 @@ kept outside those execution mounts.
24
24
 
25
25
  ## Start work
26
26
 
27
- Inbox lists repository issues directly. Choose Open, Closed or All states and use
27
+ Inbox opens on the shared list/Board of repository issues and local work, with
28
+ status rail and workflow/model/status/label multiselects. Never-started issues
29
+ have unknown workflow/model and a distinct Not started state. Choose Open, Closed or All states and use
28
30
  Previous/Next page or Refresh issues. Search covers the loaded page and visible
29
31
  history, not the whole repository. GitHub returns up to 50 records per page;
30
32
  pull requests are excluded, so even a page with zero issues can have a next page.
@@ -146,4 +148,7 @@ non-issue paths are not admitted by the provider preview. Local-only records and
146
148
  executions whose source is missing, closed or outside the loaded page remain in
147
149
  Local and other execution history. Removing local execution history preserves
148
150
  private evidence and never deletes a provider issue; unresolved delivery guards
149
- still apply. The separate Execution history tab keeps the execution list/board.
151
+ still apply. The primary list/board includes all these records; no separate
152
+ Execution history tab is needed. Its count line distinguishes issue identities
153
+ and individual executions. Open a record for progressive context and every
154
+ linked attempt; close/Escape returns to the preserved filters, view and scroll.
@@ -0,0 +1,73 @@
1
+ import { assertPrivateDirectory } from './execution-evidence.mjs';
2
+ import { lstatSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { digest } from './lib.mjs';
6
+
7
+ const MAX_PATCH_BYTES = 8 * 1024 * 1024;
8
+ const MAX_TREE_BYTES = 8 * 1024 * 1024;
9
+
10
+ function readPrivateFile(path, limit) {
11
+ const stat = lstatSync(path);
12
+ if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || stat.size > limit)
13
+ throw new Error('Protected candidate patch is missing, unsafe or too large.');
14
+ return readFileSync(path);
15
+ }
16
+ function runGitCommand(args) {
17
+ const env = {
18
+ PATH: process.env.PATH || '/usr/bin:/bin', HOME: '/nonexistent',
19
+ GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null',
20
+ GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0',
21
+ };
22
+ const result = spawnSync('git', args, { env, encoding: 'utf8', maxBuffer: MAX_TREE_BYTES + MAX_PATCH_BYTES + 1024 * 1024 });
23
+ if (result.error || result.status !== 0) throw new Error('Could not prepare private candidate reconstruction storage.');
24
+ return result.stdout.trim();
25
+ }
26
+ function runGit(repo, args, { input, binary = false } = {}) {
27
+ // Delivery reconstruction runs only trusted Git operations in a newly made
28
+ // bare repository. Candidate hooks, filters, global config and credentials
29
+ // are unavailable to these subprocesses.
30
+ const env = {
31
+ PATH: process.env.PATH || '/usr/bin:/bin',
32
+ HOME: '/nonexistent',
33
+ GIT_CONFIG_NOSYSTEM: '1',
34
+ GIT_CONFIG_GLOBAL: '/dev/null',
35
+ GIT_TERMINAL_PROMPT: '0',
36
+ GIT_OPTIONAL_LOCKS: '0',
37
+ };
38
+ const result = spawnSync('git', ['--git-dir', repo, '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', ...args], {
39
+ env, input, encoding: binary ? null : 'utf8', maxBuffer: MAX_TREE_BYTES + MAX_PATCH_BYTES + 1024 * 1024,
40
+ });
41
+ if (result.error || result.status !== 0) throw new Error('Could not reconstruct the accepted candidate from retained Git objects.');
42
+ return binary ? result.stdout : result.stdout.trim();
43
+ }
44
+ // Shared acceptance/publication boundary. Only the original retained base and
45
+ // exact protected patch bytes enter new, private bare storage. The callback may
46
+ // inspect reconstructed objects; cleanup removes only this invocation's scratch.
47
+ export function withReconstructedCandidate({ state, jobId, sourcePath, objectFormat, candidate }, inspect = () => {}) {
48
+ const oid = new RegExp(`^[a-f0-9]{${objectFormat === 'sha256' ? 64 : 40}}$`);
49
+ if (!['sha1', 'sha256'].includes(objectFormat) || !oid.test(candidate.base || '') || !oid.test(candidate.tree || ''))
50
+ throw new Error('Candidate has an unsupported Git object identity.');
51
+ const folder = join(state, 'jobs', jobId);
52
+ const patchPath = join(folder, 'delivery-input', 'candidate.patch');
53
+ assertPrivateDirectory(join(state, 'jobs'));
54
+ assertPrivateDirectory(folder);
55
+ assertPrivateDirectory(join(folder, 'delivery-input'));
56
+ const patch = readPrivateFile(patchPath, MAX_PATCH_BYTES);
57
+ if (!patch.length || digest(patch) !== candidate.patch_sha256) throw new Error('Accepted patch digest does not match the approval record.');
58
+ const scratchRoot = mkdtempSync(join(folder, 'delivery-work-'));
59
+ const repo = join(scratchRoot, 'candidate.git');
60
+ try {
61
+ runGitCommand(['init', '--bare', '--quiet', '--template=', `--object-format=${objectFormat}`, repo]);
62
+ runGit(repo, ['fetch', '--quiet', '--no-tags', '--', sourcePath, `${candidate.base}:refs/heads/factory-base`]);
63
+ if (runGit(repo, ['rev-parse', '--verify', `${candidate.base}^{commit}`]) !== candidate.base)
64
+ throw new Error('Accepted base differs from the retained source commit.');
65
+ runGit(repo, ['read-tree', candidate.base]);
66
+ runGit(repo, ['apply', '--cached', '--binary', '--whitespace=nowarn', '-'], { input: patch });
67
+ const tree = runGit(repo, ['write-tree']);
68
+ if (tree !== candidate.tree) throw new Error('Protected patch does not reproduce the accepted candidate tree.');
69
+ return inspect({ repo, patch, tree, git: runGit });
70
+ } finally {
71
+ rmSync(scratchRoot, { recursive: true, force: true });
72
+ }
73
+ }
@@ -1,8 +1,8 @@
1
- import { readPrivateJson, assertPrivateDirectory, trustedExecutionProfile } from './execution-evidence.mjs';
1
+ import { withReconstructedCandidate } from './candidate-patch.mjs';
2
+ import { readPrivateJson, trustedExecutionProfile } from './execution-evidence.mjs';
2
3
  import { createHash, randomBytes } from 'node:crypto';
3
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
5
  import { join, resolve } from 'node:path';
5
- import { spawnSync } from 'node:child_process';
6
6
  import { configAt, digest } from './lib.mjs';
7
7
  import { effectiveExecutionConfig, isSupportedExecutionProfile } from './execution-profile.mjs';
8
8
  import { publicSourceAdmission } from './source-admission.mjs';
@@ -11,7 +11,6 @@ import { readProjectLinks } from './project-links.mjs';
11
11
  import { qualifyGitHubActions } from './workflow-qualification.mjs';
12
12
  import { assertCurrentWebEvidence, assertCurrentWebArtifacts } from './web-verification.mjs';
13
13
 
14
- const MAX_PATCH_BYTES = 8 * 1024 * 1024;
15
14
  const MAX_CHANGED_FILES = 500;
16
15
  const MAX_TREE_BYTES = 8 * 1024 * 1024;
17
16
  const SHA1 = /^[a-f0-9]{40}$/;
@@ -39,7 +38,6 @@ class BranchCollisionError extends QueueError {
39
38
  }
40
39
  }
41
40
 
42
- function sha256(value) { return createHash('sha256').update(value).digest('hex'); }
43
41
  function utcGitDate(value) {
44
42
  const parsed = new Date(value);
45
43
  if (!Number.isFinite(parsed.getTime())) throw new Error('Accepted timestamp is invalid.');
@@ -54,40 +52,6 @@ function commitObjectSha({ tree, parents, message, author, committer }) {
54
52
  const raw = Buffer.from(`tree ${tree}\nparent ${parents[0]}\nauthor ${author.name} <${author.email}> ${date.epoch} +0000\ncommitter ${committer.name} <${committer.email}> ${date.epoch} +0000\n\n${message}`, 'utf8');
55
53
  return createHash('sha1').update(`commit ${raw.length}\0`).update(raw).digest('hex');
56
54
  }
57
- function readPrivateFile(path, limit) {
58
- const stat = lstatSync(path);
59
- if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || stat.size > limit)
60
- throw new Error('Protected candidate patch is missing, unsafe or too large.');
61
- return readFileSync(path);
62
- }
63
- function runGitCommand(args) {
64
- const env = {
65
- PATH: process.env.PATH || '/usr/bin:/bin', HOME: '/nonexistent',
66
- GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null',
67
- GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0',
68
- };
69
- const result = spawnSync('git', args, { env, encoding: 'utf8', maxBuffer: MAX_TREE_BYTES + MAX_PATCH_BYTES + 1024 * 1024 });
70
- if (result.error || result.status !== 0) throw new Error('Could not prepare private candidate reconstruction storage.');
71
- return result.stdout.trim();
72
- }
73
- function runGit(repo, args, { input, binary = false } = {}) {
74
- // Delivery reconstruction runs only trusted Git operations in a newly made
75
- // bare repository. Candidate hooks, filters, global config and credentials
76
- // are unavailable to these subprocesses.
77
- const env = {
78
- PATH: process.env.PATH || '/usr/bin:/bin',
79
- HOME: '/nonexistent',
80
- GIT_CONFIG_NOSYSTEM: '1',
81
- GIT_CONFIG_GLOBAL: '/dev/null',
82
- GIT_TERMINAL_PROMPT: '0',
83
- GIT_OPTIONAL_LOCKS: '0',
84
- };
85
- const result = spawnSync('git', ['--git-dir', repo, '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', ...args], {
86
- env, input, encoding: binary ? null : 'utf8', maxBuffer: MAX_TREE_BYTES + MAX_PATCH_BYTES + 1024 * 1024,
87
- });
88
- if (result.error || result.status !== 0) throw new Error('Could not reconstruct the accepted candidate from retained Git objects.');
89
- return binary ? result.stdout : result.stdout.trim();
90
- }
91
55
  function parseTree(bytes) {
92
56
  const entries = new Map();
93
57
  let start = 0;
@@ -112,24 +76,8 @@ function parseTree(bytes) {
112
76
  function candidateTree(state, job, sourcePath, sourceAdmission, accepted, { branch, target }) {
113
77
  if (sourceAdmission.object_format !== 'sha1' || !SHA1.test(accepted.base || '') || !SHA1.test(accepted.tree || ''))
114
78
  throw new Error('GitHub delivery currently requires a SHA-1 candidate from the retained source repository.');
115
- const patchPath = join(state, 'jobs', job.id, 'delivery-input', 'candidate.patch');
116
- assertPrivateDirectory(join(state, 'jobs'));
117
- assertPrivateDirectory(join(state, 'jobs', job.id));
118
- assertPrivateDirectory(join(state, 'jobs', job.id, 'delivery-input'));
119
- const patch = readPrivateFile(patchPath, MAX_PATCH_BYTES);
120
- if (!patch.length || sha256(patch) !== accepted.patch_sha256) throw new Error('Accepted patch digest does not match the approval record.');
121
- const folder = join(state, 'jobs', job.id);
122
- const scratchRoot = mkdtempSync(join(folder, 'delivery-work-'));
123
- const scratchRepo = join(scratchRoot, 'candidate.git');
124
- try {
125
- runGitCommand(['init', '--bare', '--quiet', '--object-format=sha1', scratchRepo]);
126
- runGit(scratchRepo, ['fetch', '--quiet', '--no-tags', '--', sourcePath, `${accepted.base}:refs/heads/factory-base`]);
127
- const baseCommit = runGit(scratchRepo, ['rev-parse', '--verify', `${accepted.base}^{commit}`]);
128
- if (baseCommit !== accepted.base) throw new Error('Accepted base differs from the retained source commit.');
129
- runGit(scratchRepo, ['read-tree', accepted.base]);
130
- runGit(scratchRepo, ['apply', '--cached', '--binary', '--whitespace=nowarn', patchPath]);
131
- const tree = runGit(scratchRepo, ['write-tree']);
132
- if (tree !== accepted.tree) throw new Error('Protected patch does not reproduce the accepted candidate tree.');
79
+ return withReconstructedCandidate({ state, jobId: job.id, sourcePath,
80
+ objectFormat: sourceAdmission.object_format, candidate: accepted }, ({ repo: scratchRepo, patch, tree, git: runGit }) => {
133
81
  const baseTree = runGit(scratchRepo, ['rev-parse', `${accepted.base}^{tree}`]);
134
82
  const before = parseTree(runGit(scratchRepo, ['ls-tree', '-r', '-z', accepted.base], { binary: true }));
135
83
  const after = parseTree(runGit(scratchRepo, ['ls-tree', '-r', '-z', tree], { binary: true }));
@@ -159,9 +107,7 @@ function candidateTree(state, job, sourcePath, sourceAdmission, accepted, { bran
159
107
  entries.push({ path, mode: next.mode, type: 'blob', content });
160
108
  }
161
109
  return { patch, patch_sha256: accepted.patch_sha256, base_tree: baseTree, tree, entries, workflowQualification };
162
- } finally {
163
- rmSync(scratchRoot, { recursive: true, force: true });
164
- }
110
+ });
165
111
  }
166
112
 
167
113
  function sourceIssue(job, repository) {
@@ -7,12 +7,15 @@ import { effectiveInferenceProvider } from './model-environment.mjs';
7
7
  import { expectedWebStories, webPolicyHash } from './web-verification.mjs';
8
8
 
9
9
  // Audited v1 protected evidence writers: 0.8.0 (380f749), 0.9.0 (cdaadef),
10
- // 0.9.1, 0.10.0, 0.11.0 and 0.11.1 (unchanged protected evidence writers).
10
+ // 0.9.1, 0.10.0, 0.11.0 and 0.11.1 (unchanged protected evidence writers),
11
+ // 0.11.2 (exact patch bytes and pre-acceptance reconstruction; same v1 schema).
12
+ // 0.12.0 restores the Inbox read model/presentation; protected writers and guards are unchanged.
13
+ // Older writers still require exact patch/tree reconstruction before publication.
11
14
  // Deliberately independent of VERSION: a release bump is not
12
15
  // evidence compatibility. Re-audit this list for every trust-relevant writer,
13
16
  // isolation or validation change; remove versions whose guarantees no longer
14
17
  // satisfy current policy. See docs/npm.md. This predicate alone grants no trust.
15
- const SUPPORTED_EXECUTION_RUNTIMES_V1 = new Set(['0.8.0', '0.9.0', '0.9.1', '0.10.0', '0.11.0', '0.11.1']);
18
+ const SUPPORTED_EXECUTION_RUNTIMES_V1 = new Set(['0.8.0', '0.9.0', '0.9.1', '0.10.0', '0.11.0', '0.11.1', '0.11.2', '0.12.0']);
16
19
 
17
20
  export function isSupportedExecutionProfile(profile) {
18
21
  return profile?.version === 1 && SUPPORTED_EXECUTION_RUNTIMES_V1.has(profile.runtimeVersion);
@@ -1,3 +1,4 @@
1
+ import { withReconstructedCandidate } from './candidate-patch.mjs';
1
2
  import { harnessOf } from './lib.mjs';
2
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, lstatSync } from 'node:fs';
3
4
  import { join } from 'node:path';
@@ -7,7 +8,7 @@ import { incidentFor, validateReport } from './incident.mjs';
7
8
  import { BoundedLog } from './bounded-log.mjs';
8
9
  import { CodexUsageParser, emptyUsage, usageFields } from './usage.mjs';
9
10
  import { assertRetainedSource, publicSourceAdmission, publicContinuation, restoreBuildCheckout } from './source-admission.mjs';
10
- import { runCandidateGit } from './git-environment.mjs';
11
+ import { runCandidateGit, runCandidateGitRaw } from './git-environment.mjs';
11
12
  import { selectedInferenceSecrets, writeSelectedModelEnvironment } from './model-environment.mjs';
12
13
  import { redactInferenceText, redactRetainedPhaseOutputs } from './inference-redaction.mjs';
13
14
  import { assertCurrentHandoffEvidence } from './execution-evidence.mjs';
@@ -293,8 +294,7 @@ try {
293
294
  source_admission: publicSourceAdmission(sourceAdmission) };
294
295
  save(join(folder,'candidate.json'),meta);
295
296
  save(join(output,'candidate.json'),meta);
296
- const patchText = git('--no-pager', 'diff', '--binary', '--full-index', '--no-ext-diff', '--no-textconv', meta.base, meta.head);
297
- const patch = patchText ? `${patchText}\n` : '';
297
+ const patch = runCandidateGitRaw(workspace, '--no-pager', 'diff', '--binary', '--full-index', '--no-ext-diff', '--no-textconv', meta.base, meta.head);
298
298
  writeFileSync(join(output,'change.patch'), patch);
299
299
  writeFileSync(join(output,'implementation.md'),safeRead(join(reports,'agent-report.md')));
300
300
  } else if (phase === 'verify') {
@@ -343,9 +343,8 @@ try {
343
343
  if (config.webVerification?.enabled) assertCurrentWebArtifacts(config.webVerification, checks.web_verification,
344
344
  join(folder, 'artifacts', checks.run_id));
345
345
  assertCurrentHandoffEvidence(meta, checks, review, policyHash, config, job);
346
- const patchText = git('--no-pager', 'diff', '--binary', '--full-index', '--no-ext-diff', '--no-textconv', meta.base, meta.head);
347
- const patch = patchText ? `${patchText}\n` : '';
348
- if (!patch) throw new Error('Candidate has no content changes to accept');
346
+ const patch = runCandidateGitRaw(workspace, '--no-pager', 'diff', '--binary', '--full-index', '--no-ext-diff', '--no-textconv', meta.base, meta.head);
347
+ if (!patch.length) throw new Error('Candidate has no content changes to accept');
349
348
  const patchHash = digest(patch);
350
349
  const deliveryInput = join(folder, 'delivery-input');
351
350
  mkdirSync(deliveryInput, { recursive: true, mode: 0o700 });
@@ -355,6 +354,9 @@ try {
355
354
  if (!stat.isFile() || stat.isSymbolicLink() || digest(readFileSync(deliveryPatch)) !== patchHash)
356
355
  throw new Error('A different protected candidate patch already exists');
357
356
  } else writeFileSync(deliveryPatch, patch, { mode: 0o600, flag: 'wx' });
357
+ const retained = assertRetainedSource(state, job, sourceAdmission);
358
+ withReconstructedCandidate({ state, jobId: job, sourcePath: retained.path,
359
+ objectFormat: sourceAdmission.object_format, candidate: { ...meta, patch_sha256: patchHash } });
358
360
  const sourceSha = meta.source_admission?.resolved_sha || 'Not recorded (legacy/unknown)';
359
361
  writeFileSync(join(output,'handoff.md'), `Accepted candidate ${meta.head} (tree ${meta.tree}), based on source ${sourceSha}.\nPatch handoff remains available. Optional PR delivery is a separate explicit operator action when configured.\nNo integration, merge, release or deployment performed.\nSee docs/quickstart.md for applying the reviewed change.patch to your own branch.\n`);
360
362
  save(join(folder,'accepted.json'), { base: meta.base, head: meta.head, tree: meta.tree, patch_sha256: patchHash,
@@ -16,11 +16,21 @@ export function runHostGit(args, options = {}) {
16
16
  return run('git', args, { ...options, env: hostGitEnvironment() });
17
17
  }
18
18
 
19
- export function runCandidateGit(workspace, ...args) {
19
+ function candidateGit(workspace, args, options) {
20
20
  return runHostGit([
21
21
  '-c', 'core.hooksPath=/dev/null',
22
22
  '-c', 'core.fsmonitor=false',
23
23
  '-C', workspace,
24
24
  ...args,
25
- ]);
25
+ ], options);
26
+ }
27
+
28
+ // Scalar queries retain their historical trimming. Byte-bearing output must
29
+ // explicitly opt into Buffer stdout, with identical environment and limits.
30
+ export function runCandidateGit(workspace, ...args) {
31
+ return candidateGit(workspace, args);
32
+ }
33
+
34
+ export function runCandidateGitRaw(workspace, ...args) {
35
+ return candidateGit(workspace, args, { encoding: null });
26
36
  }
@@ -24,6 +24,13 @@ function issueLabels(labels = []) {
24
24
  return labels.map(label => ({name:label.name, color:/^[a-f0-9]{6}$/i.test(label.color || '') ? label.color.toLowerCase() : null}));
25
25
  }
26
26
 
27
+ function issueMetadata(issue) {
28
+ const date = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null;
29
+ const author = issue.user?.login || issue.author?.login;
30
+ return { author: typeof author === 'string' ? author : null,
31
+ created_at: date(issue.created_at || issue.createdAt), updated_at: date(issue.updated_at || issue.updatedAt) };
32
+ }
33
+
27
34
  export async function listIssues(repo, page = 1, read = githubRead, state = 'open') {
28
35
  if (!Number.isSafeInteger(page) || page < 1 || page > 10000) throw new Error('Issue page must be an integer between 1 and 10000.');
29
36
  if (!['open', 'closed', 'all'].includes(state)) throw new Error('Issue state must be open, closed or all.');
@@ -35,7 +42,7 @@ export async function listIssues(repo, page = 1, read = githubRead, state = 'ope
35
42
  const issues = result.filter(issue => !issue.pull_request).map(issue => {
36
43
  validateIssueURL(repository, issue.html_url);
37
44
  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.');
38
- return { number: issue.number, title: issue.title, url: issue.html_url, state: issue.state || 'unknown', labels: issueLabels(issue.labels) };
45
+ return { ...issueMetadata(issue), number: issue.number, title: issue.title, url: issue.html_url, state: issue.state || 'unknown', labels: issueLabels(issue.labels) };
39
46
  });
40
47
  return { repository, issues, next_page: result.length === 50 && page < 10000 ? page + 1 : null };
41
48
  }
@@ -46,7 +53,7 @@ export function validateIssueURL(repoURL, value) {
46
53
  if (!repoURL || identity.repository !== repoURL.toLowerCase()) throw new Error('Issue does not belong to this project’s configured GitHub origin.');
47
54
  return identity.url;
48
55
  }
49
- export async function readIssue(repo, url, read = url => githubRead(['issue', 'view', url, '--json', 'title,body,url,labels,state'])) {
56
+ export async function readIssue(repo, url, read = url => githubRead(['issue', 'view', url, '--json', 'title,body,url,labels,state,author,createdAt,updatedAt'])) {
50
57
  const repoURL = readProjectLinks(repo)?.repository;
51
58
  url = validateIssueURL(repoURL, url);
52
59
  const issue = await read(url);
@@ -55,5 +62,5 @@ export async function readIssue(repo, url, read = url => githubRead(['issue', 'v
55
62
  const spec = `Issue: ${issue.url}\n${issue.title}\n\n${issue.body}`;
56
63
  if (Buffer.byteLength(spec) > 240000) throw new Error('Issue exceeds the 240 KB task limit. Use a bounded task file instead.');
57
64
  const labels = issueLabels(issue.labels);
58
- return { title: issue.title, url, number: canonicalIssue(url).number, state: issue.state?.toLowerCase() || 'unknown', body: issue.body, spec, labels, recommendation: recommendWork({ spec, labels: labels.map(label => label.name) }) };
65
+ return { ...issueMetadata(issue), title: issue.title, url, number: canonicalIssue(url).number, state: issue.state?.toLowerCase() || 'unknown', body: issue.body, spec, labels, recommendation: recommendWork({ spec, labels: labels.map(label => label.name) }) };
59
66
  }
@@ -57,3 +57,28 @@ export function backlogHistory(jobs, issues) {
57
57
  }
58
58
  return [...grouped.values()];
59
59
  }
60
+
61
+ // Canonical all-work read model, shared by headless readers and the dashboard.
62
+ // A source page is a bounded snapshot, never evidence that other sources closed.
63
+ export function workRecords(jobs = [], issues = []) {
64
+ const sources = new Map(issues.map(issue => [issue.identity.key, issue]));
65
+ const histories = backlogHistory(jobs, []);
66
+ const rows = new Map(histories.map(row => [row.key, row]));
67
+ for (const [key, issue] of sources) rows.set(key, {
68
+ ...issue, key, ...executionAssociation(jobs, issue.identity),
69
+ });
70
+ return [...rows.values()].map(row => {
71
+ const issue = sources.get(row.key) || null;
72
+ const execution = row.active_execution || row.latest_execution || row.executions[0] || null;
73
+ return {
74
+ key: row.key, identity: row.identity, title: issue?.title || row.title,
75
+ url: row.url, source_status: issue ? 'loaded' : row.identity ? 'not_loaded' : 'local',
76
+ issue, executions: row.executions, active_execution: row.active_execution || null,
77
+ latest_execution: row.latest_execution || execution,
78
+ execution_id: execution?.id || null,
79
+ state: execution?.state || 'not_started',
80
+ workflow: execution?.workflow || null,
81
+ phase: execution?.phase || null,
82
+ };
83
+ });
84
+ }
package/factory/lib.mjs CHANGED
@@ -19,7 +19,7 @@ export function run(command, args, options = {}) {
19
19
  const result = spawnSync(command, args, { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, ...options });
20
20
  if (result.error) throw result.error;
21
21
  if (result.status !== 0) throw new Error(`${command} failed (${result.status}): ${result.stderr || result.stdout || ''}`);
22
- return result.stdout?.trim() ?? '';
22
+ return Buffer.isBuffer(result.stdout) ? result.stdout : result.stdout?.trim() ?? '';
23
23
  }
24
24
  export function stream(command, args, options = {}) {
25
25
  return new Promise((ok, fail) => {
@@ -38,7 +38,7 @@ export function githubIssueProvider(repo, { read = githubRead, write = githubWri
38
38
  id: 'github', label: 'GitHub', repository, supported: true,
39
39
  capabilities: { issues: true, templates: true, create: true },
40
40
  list: (page, state) => listIssues(repo, page, read, state),
41
- preview: url => readIssue(repo, url, value => read(['issue', 'view', value, '--json', 'title,body,url,labels,state'])),
41
+ preview: url => readIssue(repo, url, value => read(['issue', 'view', value, '--json', 'title,body,url,labels,state,author,createdAt,updatedAt'])),
42
42
  templates: () => readTemplates(repo, read),
43
43
  draft: input => draftFromTemplate(repo, input, read),
44
44
  async context() {
@@ -1,4 +1,4 @@
1
- import { associateIssue, backlogHistory, readinessMapping } from './issue-lifecycle.mjs';
1
+ import { associateIssue, backlogHistory, readinessMapping, workRecords } from './issue-lifecycle.mjs';
2
2
  import { harnessOf } from './lib.mjs';
3
3
  import http from 'node:http';
4
4
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
@@ -88,7 +88,7 @@ export function createController(state, adapter = executors(state), integrations
88
88
  delivery_removal_blocked: Boolean(job.delivery && !['published', 'abandoned'].includes(job.delivery.state)),
89
89
  delivery_status: delivery.summary(job), runs: job.runs.map(attempt => attemptPresentation({ ...attempt,
90
90
  outcome: attempt.outcome || (attempt.state === 'succeeded' ? 'complete' : undefined) }, adapter.usage?.(job, attempt))) }));
91
- return send(200, { version: 1, runtime_version: VERSION, maintenance: queue.maintenance, workflows: Object.keys(definitions.workflows), commands: [], triggers: [], jobs, issue_history: backlogHistory(jobs, []), csrf_token: csrf,
91
+ return send(200, { version: 1, runtime_version: VERSION, maintenance: queue.maintenance, workflows: Object.keys(definitions.workflows), commands: [], triggers: [], jobs, issue_history: backlogHistory(jobs, []), work_records: workRecords(jobs, []), csrf_token: csrf,
92
92
  infrastructure: { host, controller: { connected: !queue.closing }, workers: [{ id: 'local-executor', name: 'Local worker', host: host.hostname, connected: !queue.closing }] },
93
93
  automations: [], automation_control: definitions.automations, issue_provider: providerInfo(provider),
94
94
  delivery_configuration: deliveryProviderInfo(configAt(state), deliveryAdapter),
@@ -106,7 +106,7 @@ export function createController(state, adapter = executors(state), integrations
106
106
  const result = provider.supported ? await provider.list(page, state) : {repository:provider.repository, issues:[],next_page:null};
107
107
  const jobs = queue.all(), issues = result.issues.map(issue => associateIssue(issue, jobs, readinessMapping(config.issueReadinessLabels)));
108
108
  return send(200, { ...result, issues, provider:providerInfo(provider), page, state, loaded_count:issues.length, total:null,
109
- history:backlogHistory(jobs, issues) });
109
+ history:backlogHistory(jobs, issues), work_records:workRecords(jobs, issues) });
110
110
  }
111
111
  catch (error) { throw new QueueError(error.message, error.status || 400); }
112
112
  }