software-defence-factory 0.15.5 → 0.16.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.
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { followActivity } from '../factory/activity-follow.mjs';
2
3
  import { harnessPreset, parseRoleDefinition, ROLE_CAPABILITIES } from '../factory/role-definition.mjs';
3
4
  import { inspectDefinition, previewDefinition, previewRollback } from '../factory/definition-store.mjs';
4
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, openSync, closeSync, rmSync, renameSync, realpathSync } from 'node:fs';
@@ -29,6 +30,7 @@ const args = process.argv.slice(2), command = args.shift() || 'help';
29
30
  const positional = [], flags = {};
30
31
  for (let i=0;i<args.length;i++) {
31
32
  if (args[i].startsWith('--')) {
33
+ if (args[i] === '--follow') { flags.follow = true; continue; }
32
34
  if (!args[i+1] || args[i+1].startsWith('--')) throw new Error(`Value required for ${args[i]}`);
33
35
  flags[args[i].slice(2)] = args[++i];
34
36
  } else positional.push(args[i]);
@@ -231,6 +233,18 @@ try {
231
233
  const snapshot=await api(state,'/api/v1/status');
232
234
  console.log(JSON.stringify(command==='automations'?snapshot.automation_control:snapshot[command],null,2));
233
235
  }
236
+ else if(command==='activity') {
237
+ const [job, attempt] = positional;
238
+ if (!/^job_[a-f0-9]+$/.test(job || '') || !/^run_[a-f0-9]+$/.test(attempt || '')) throw new Error('activity requires JOB_ID RUN_ID; inspect status for exact identities');
239
+ const controller = new AbortController(), interrupt = () => controller.abort();
240
+ process.once('SIGINT', interrupt); process.once('SIGTERM', interrupt);
241
+ try {
242
+ await followActivity({ job, attempt, after: flags.after, limit: flags.limit || '50', follow: flags.follow === true,
243
+ interval: Number(flags['poll-ms'] || 2000), signal: controller.signal,
244
+ request: (path, signal) => api(state, path, undefined, undefined, { timeoutMs: 5000, signal }),
245
+ output: page => console.log(JSON.stringify(page)), unavailable: page => console.error(JSON.stringify(page)) });
246
+ } finally { process.removeListener('SIGINT', interrupt); process.removeListener('SIGTERM', interrupt); }
247
+ }
234
248
  else if(command==='status') { const snapshot=await api(state,'/api/v1/status');delete snapshot.csrf_token;console.log(JSON.stringify(snapshot,null,2)); }
235
249
  else if(command==='doctor') {
236
250
  const config=configAt(state),dockerVersion=run('docker',['info','--format','{{.ServerVersion}}']),imageStatus=inspectImageInstallation(state,config);
@@ -383,6 +397,8 @@ Compatibility executable: software-defence-factory (same runtime and state)
383
397
  run --file task.md | --issue URL Submit software (default), or --workflow defence
384
398
  [--source-ref REF] Pin a configured-repository ref before admission
385
399
  incident --file incident.json Submit a private, read-only incident draft
400
+ activity JOB_ID RUN_ID [--after CURSOR] [--limit 1..128]
401
+ [--follow] [--poll-ms 1000..30000] Bounded JSON activity; exact-attempt reconnect
386
402
  approve JOB_ID | cancel JOB_ID Review gate / stop this attempt
387
403
  publish JOB_ID Publish/reconcile the accepted candidate as one draft PR
388
404
  abandon-delivery JOB_ID --branch-sha SHA Resolve an inspected pre-write collision; keep the remote branch
@@ -10,6 +10,7 @@ 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 --source factory` JSON | `GET /api/v1/status` | Project, tasks, details/history | Stable versioned agent result/error contract |
13
+ | Bounded phase activity | `activity JOB RUN`, tail/follow and cursor reconnect | Authenticated `GET /jobs/:job/runs/:run/activity` | Current detail and History; refresh/stale state | First slice; native/installed/browser qualification and health coverage remain open (#32) |
13
14
  | 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
15
  | 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
16
  | 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 |
@@ -177,3 +178,79 @@ observations of delivered-commit checks, not proof of required-check completion,
177
178
  branch protection, mergeability, deployment or model quality. Protected Verify,
178
179
  independent Review and explicit acceptance remain unchanged. #37 remains open
179
180
  for the other parity gaps above; no role/profile or workflow redesign is included.
181
+
182
+ ## Bounded execution activity (0.16.0, #32 first slice)
183
+
184
+ `factory activity JOB_ID RUN_ID [--after CURSOR] [--limit 1..128] [--follow]
185
+ [--poll-ms 1000..30000] --state PATH` reads the same authenticated
186
+ `GET /api/v1/jobs/:job/runs/:run/activity?after=CURSOR&limit=N` contract as the
187
+ issue detail. Discover identities with `status`. Without `after`, the default is
188
+ the last 50 events; with it, return up to the limit after that cursor in ascending
189
+ order. CLI output is one JSON page per line. Follow polls sequentially (default
190
+ 2 seconds, 5-second request timeout), preserves the cursor on transport/server
191
+ failure, reports unavailability on stderr, reconnects to that exact attempt and
192
+ stops after a terminal attempt's retained pages are drained. SIGINT/SIGTERM abort
193
+ both a request and the polling delay. Invalid requests/identity/cursors are errors,
194
+ not indefinite reconnects. Retry/revision creates another run: select it explicitly.
195
+
196
+ Result activity selects the running attempt, otherwise the latest attempt with a
197
+ recorded start, including failed, cancelled or interrupted execution. An unstarted
198
+ Handoff approval or queued retry does not hide the previous execution. The card
199
+ labels its phase and state; Observation details retains the full attempt identity.
200
+ Initial work without a started attempt says so without inventing observations.
201
+ History activity remains bound to each historical attempt.
202
+
203
+ The version-1 response identifies job, attempt and phase. `next_cursor` is the
204
+ last delivered event; `latest_cursor`, `oldest_cursor`, `has_more`, `truncated`
205
+ and `dropped` make paging, tail omission and ring rollover explicit. Cursors are
206
+ nonnegative safe integers scoped to the exact job/run URL; never carry one to a
207
+ different attempt. Ahead-of-history cursors return 409; malformed cursor/limit
208
+ returns 400; an unknown job or foreign run returns 404. Reads require the existing
209
+ operator token or dashboard session, with unchanged Host/Origin protections.
210
+ `status` is available, unavailable (including pre-feature history) or error
211
+ (invalid/inaccessible storage). No raw logs are consulted or backfilled.
212
+
213
+ `attempt_state`/`terminal` come from the queue; `completion_observed` only records
214
+ an executor end event. Interrupted/cancelled attempts may lack the end and final
215
+ unflushed observations. `phase_started_at` is the queue start, `last_received_at`
216
+ is the latest stdout chunk received by the existing executor, and `saved_at` is
217
+ the record checkpoint time. The most recent event carries its own receive-time
218
+ `at`. `process_observation`/`process_observed_at` retain the last Docker **client**
219
+ start/exit even after ring rollover; neither attests current process/container
220
+ health. `container_health` is explicitly unknown. API `refreshed_at` and the UI's
221
+ last successful read are reader facts, never worker heartbeats. Silence has no
222
+ failure threshold. A failed UI refresh retains a stale-labelled view; refresh
223
+ starts a new tail read. History reads remain tied to their original attempts.
224
+
225
+ The stdout owner records phase start/end and client start/exit. Codex
226
+ `item.completed` maps agent messages and command/file/search/MCP tool categories;
227
+ Pi maps assistant `message_end` and `tool_execution_end`. Tool completion does not
228
+ mean successful work. Reasoning, deltas, final aggregate message replay and nested
229
+ events do not become activity. Unknown/custom/deterministic harnesses report
230
+ unsupported event categories and retain only executor observations plus stdout
231
+ receive times. Mapped tool categories are read, edit, command, search and other.
232
+ Only fixed enums, timestamps, identities and bounded counters are returned.
233
+
234
+ All input text is excluded: prompts, messages, reasoning, commands, arguments,
235
+ results, paths, raw errors, arbitrary names and provider/credential data are never
236
+ copied to activity. This is an allowlist, not heuristic string redaction. Native
237
+ JSONL lines are capped at 64 KiB with strict UTF-8; malformed, oversized or trailing
238
+ incomplete lines increment `rejected`, then parsing resumes at the next newline.
239
+ Activity does not change canonical completion usage parsers or cost provenance.
240
+
241
+ The host executor owns `jobs/JOB/RUN/activity.json`, outside worker mounts. A
242
+ 128-event ring and one fixed temporary file bound storage; at most one async
243
+ atomic checkpoint is in flight, throttled to once per second during execution,
244
+ with a final flush. Input buffers are fixed-size; readers cannot create queues,
245
+ trigger writes or backpressure stdout. Readback caps bytes at 64 KiB and revalidates
246
+ identity, schema, ordering, categories, timestamps and private regular-file paths.
247
+ No activity is included in the main status payload. Checkpoint write failures do
248
+ not abort the worker; `write_errors` reports them if storage recovers. If storage
249
+ never recovers, missing/stale checkpoints cannot reveal the lost interval. A killed
250
+ executor may lose the final uncheckpointed interval. Existing private logs and
251
+ reports retain their separate bounded artifact rules; previews are labelled
252
+ snapshots with explicit refresh. Observations grant no acceptance/action authority.
253
+
254
+ This first slice leaves #32 open for independent installed/native Z13 and visual
255
+ browser qualification, richer genuinely observed health, and broader provider
256
+ coverage. See the actual evidence and limits in [proof](proof.md).
package/docs/npm.md CHANGED
@@ -116,8 +116,8 @@ installation or retained attempt needs them.
116
116
 
117
117
  ## Protected evidence compatibility
118
118
 
119
- Factory 0.15.2 recognizes version-1 execution profiles emitted by native
120
- **0.8.0, 0.9.0, 0.9.1, 0.10.0, 0.11.0, 0.11.1, 0.11.2, 0.12.0, 0.13.0, 0.13.1, 0.14.0, 0.15.0, 0.15.1 and 0.15.2**. This is an exact allowlist in
119
+ Factory 0.16.0 recognizes version-1 execution profiles emitted by native
120
+ **0.8.0, 0.9.0, 0.9.1, 0.10.0, 0.11.0, 0.11.1, 0.11.2, 0.12.0, 0.13.0, 0.13.1, 0.14.0, 0.15.0, 0.15.1, 0.15.2, 0.15.3, 0.15.4, 0.15.5, 0.15.6 and 0.16.0**. This is an exact allowlist in
121
121
  `factory/execution-profile.mjs`, independent of the installed package version;
122
122
  it is not a semver range or an automatic promise for later releases. Unknown
123
123
  runtime strings, unknown profile formats and incomplete legacy acceptance
@@ -185,7 +185,8 @@ Verify evidence and cannot authorize acceptance or publication.
185
185
  Version 0.14.0 preserves the v1 writer only for unchanged inherited installations:
186
186
  policy bytes, candidate/check/review/acceptance bindings, mounts and credential
187
187
  rules stay compatible. Adopted role overrides use **v2 from 0.14.0, 0.15.0, 0.15.1 and 0.15.2**, recording
188
- role/provider/effort and an exact selection digest. The executor verifies the
188
+ role/provider/effort and an exact selection digest. Later audited v2 writers are
189
+ **0.15.3, 0.15.4, 0.15.5, 0.15.6 and 0.16.0**. The executor verifies the
189
190
  frozen common configuration before phase selection; continuation and both delivery
190
191
  validation paths require matching protected v2 evidence and private frozen config.
191
192
  V1 cannot attest an override. Mixed roles share one policy across deterministic
@@ -220,9 +221,29 @@ that the release is not yet downloadable, retain the prior runtime and retry the
220
221
  update later. This does not establish whether a particular delay came from a
221
222
  local cache or registry propagation.
222
223
 
223
- For a release, update both manifests with `npm version patch --no-git-tag-version`,
224
- review the change, and push through the project's normal review flow. A code
225
- push without a version bump is tested but does not overwrite a published package.
224
+ Before shipping any version, including a documentation-only patch:
225
+
226
+ 1. Audit the diff from the previous release across profile/evidence writers,
227
+ schemas, isolation, frozen selection and policy hashes, checks, candidate
228
+ reconstruction, review and acceptance. Add only the exact audited writer to
229
+ the applicable v1/v2/v3 lists in `factory/execution-profile.mjs`. Never derive
230
+ compatibility from package `VERSION` or a semver range; an incompatible writer
231
+ blocks release until its evidence contract is resolved.
232
+ 2. Align `package.json`, the lockfile root and `packages[""]` versions (for
233
+ example with `npm version patch --no-git-tag-version`). Run
234
+ `npm ci --ignore-scripts`, `npm run build:dashboard` and `npm run check` on the
235
+ final candidate, then inspect the packed artifact. Exercise the native current
236
+ writer, retained evidence, continuation and protected-evidence rejection
237
+ checks. Keep unknown-writer fixtures clearly unaudited (for example `99.0.0`),
238
+ and preserve historical bytes and every current-policy guard.
239
+ 3. Obtain independent review of that exact candidate and qualify the installed
240
+ CLI/API with retained evidence. Deliver through the protected PR/main CI flow,
241
+ verify the public npm artifact against the reviewed package, then adopt only
242
+ while idle and reconciled with private configuration, history and rollback
243
+ data preserved. Source tests alone do not establish these delivery gates.
244
+
245
+ A code push without a version bump is tested but does not overwrite a published
246
+ package.
226
247
 
227
248
  The current trusted-publisher identity is npm package `software-defence-factory`,
228
249
  GitHub owner `arcitai`, repository `software-and-defence-factory`,
@@ -254,7 +275,7 @@ The 0.15.0 compatibility audit preserves unchanged v1/v2 policy construction,
254
275
  protected checks, candidate reconstruction, read-only review and acceptance guards.
255
276
  Historical 0.14.0 v2 validation compares its actual writer version rather than
256
277
  relabeling it as the installed version. Local registry selection is a new execution
257
- contract: every phase of a local/hybrid attempt requires **v3 from 0.15.0, 0.15.1 or 0.15.2** and its
278
+ contract: every phase of a local/hybrid attempt requires **v3 from 0.15.0, 0.15.1, 0.15.2, 0.15.3, 0.15.4, 0.15.5, 0.15.6 or 0.16.0** and its
258
279
  exact protected frozen configuration. v1/v2 cannot attest local binding fields,
259
280
  providers or a common policy containing a selected local role. A selected endpoint,
260
281
  model, limit or compatibility change invalidates the current policy; explicit
@@ -292,3 +313,29 @@ remain unchanged from 0.15.4. Profile tests exercise all phases and inherited,
292
313
  cloud, hybrid and local definitions, reject changed selections and future writers,
293
314
  and verify immutable readback. This does not upgrade or qualify historical local
294
315
  attempts; installed image adoption and actual inference remain separate gates.
316
+
317
+ The 0.15.6 audit adds only that exact writer to the explicit v1/v2/v3 lists for
318
+ the packaged setup-guide correction. Compared with 0.15.5, profile/evidence
319
+ writers and schemas, isolation, frozen selection/policy hashes, checks,
320
+ reconstruction, review and acceptance are unchanged. The runtime emits its new
321
+ version even for a documentation-only release, so changing package metadata
322
+ alone is insufficient. Retained evidence keeps its original version and bytes;
323
+ unknown writers/schemas and altered selections remain blocked. This audit does
324
+ not qualify local model productivity or upgrade failed attempts.
325
+
326
+
327
+ The 0.16.0 audit extends only the exact v1/v2/v3 writer allowlists. Compared
328
+ with admitted main 16a47cd (0.15.6), activity is a new host-owned, read-only
329
+ observation contract. Execution profile/evidence writers and schemas, effective
330
+ policy construction, role/local selection digests, candidate reconstruction,
331
+ checks/review/acceptance and delivery target guards are unchanged. Activity is
332
+ never an input to any protected-evidence reader. Regression coverage exercises
333
+ current emission and retained 0.15.6 inherited/cloud/hybrid/local profiles, all
334
+ phases, changed policy/selection/image/schema rejection and unchanged retained
335
+ bytes. Controlled delivery fixtures exercise exact known v1 writers through
336
+ capability derivation and publication validation, including 0.16.0; future
337
+ 0.16.1/unknown writers remain rejected. This does not qualify an old failed
338
+ attempt or a live installation. No image, mounted kit/skill or Pi launcher code
339
+ changed, so this capability does not require a new worker image. Qualification
340
+ must use the operator's actually selected image; no image adoption is performed
341
+ by this source change.
package/docs/setup.md CHANGED
@@ -393,43 +393,67 @@ local requests still use `issue start --file` or `--draft` without `--brief-file
393
393
 
394
394
  ## Optional keyless local or hybrid inference
395
395
 
396
- Use the [local binding recipe](definition.md#opt-in-local-bindings-0150-69) to adopt
397
- an explicit private endpoint/model map and portable Pi role references. Keep the
398
- working default and a stopped private-state backup; preview, apply while idle and
399
- use revision-guarded rollback. A local Implement role can be paired with an existing
400
- cloud Review role. No downloads, service restarts, port changes or jobs occur during
401
- configuration. Authenticated endpoints, sampling overrides and custom thinking
402
- maps are unsupported. Local bindings may request OpenAI-style `reasoningEffort`:
403
- `default` (or omitted), `none`, `low`, `medium` or `high`. Default omits the request;
404
- it does not disable server thinking. Qualify the endpoint's actual semantics before
405
- using a request as a thinking-budget control. Unsupported requests fail without
406
- fallback when the endpoint rejects them; transport success alone cannot prove a
407
- server honored the request. Existing omitted choices remain unchanged.
408
-
409
- Qualify the actual installed image and endpoint from an isolated job before using
410
- it for application work. Record server/harness versions, exact model ID and digest,
411
- quantization, GPU/backend and actual offload, available and peak memory, endpoint
412
- reachability, tool-call/result compatibility and report/check/review outcomes.
413
- Keep host inference access separate from controller/forge credentials. Unknown
414
- values stay unknown; registry discovery only proves client capability.
415
-
416
- Reconcile the client's configured context with the server's actual allocation and
417
- per-request/model overrides. Do not infer a 65k allocation from a remembered agent
418
- setting or a server default. A bounded 64k/128k allocation experiment, where
419
- supported, must record KV-cache and Flash Attention settings, memory and allocation
420
- success separately from long-context task quality. This worker supplies no such
421
- measurements or driver/kernel advice. Do not recommend larger windows from the
422
- schema's upper limit.
423
-
424
- The operator still owns #69's matched two-fixture comparison: pinned bases,
425
- comparable prompts/contexts/trials, a measured existing local baseline and one
426
- selected alternative, decisive independent checks and separate Review contexts.
427
- Retain failures, repairs and human time. Record wall time, observable prefill/decode,
428
- input/cached/output tokens, memory/offload and all reviewer usage. Factory currently
429
- leaves Pi usage unknown; controlled protocol fixtures are not token or quality
430
- benchmarks. Include cloud Review in hybrid totals. No provider token billing for
431
- local inference does not mean zero electricity, hardware or operator cost. Only
432
- then qualify a suitable real Factory issue; no customer jobs are part of this slice.
396
+ Local profiles are opt-in. Follow the [local binding contract](definition.md#opt-in-local-bindings-0150-69)
397
+ for supported fields and commands; configuration is not qualification and starts
398
+ no jobs, downloads or inference services.
399
+
400
+ 1. **Keep the working configuration.** Export portable roles with
401
+ `factory definition export --state PATH > factory-roles.json`. Export omits
402
+ private bindings: also retain the selected immutable image ID and a complete
403
+ stopped private-state backup using [recovery](recovery.md).
404
+ 2. **Qualify the inference path.** From a disposable container using the selected
405
+ job image and network, verify the exact endpoint/model, tool calls/results and
406
+ report compatibility. Record server/harness versions, model digest and
407
+ quantization, GPU/backend and actual offload, available/peak memory and actual
408
+ context allocation, including request/model overrides. Host loopback is not
409
+ container loopback. Keep inference access separate from forge/controller
410
+ credentials; missing models/providers must fail without silent cloud fallback.
411
+ 3. **Preview and explicitly apply roles.** Use the linked binding recipe to select
412
+ local Pi Implement and an independently chosen local or cloud Review. Preview
413
+ with `factory definition diff`, then use `factory definition apply` with the
414
+ same `--state`, `--file`, `--bindings-file` and the preview's `--expected-revision`.
415
+ Apply requires the
416
+ running controller to be idle and reconciled. Agents/Definition uses the same
417
+ diff/apply contract. Inspect the effective selections; each attempt freezes
418
+ them with its resource, timeout and skill policy. CLI upgrades and worker-image
419
+ selection are separate; use the [image procedure below](#local-context-budget-and-worker-image-0152-100)
420
+ if the selected image needs changing.
421
+ 4. **Qualify bounded representative work before a real repository issue.** Use
422
+ pinned fixture bases, observable decisive tests independent of Implement, and
423
+ separate Review contexts. For a comparison, use two small software fixtures,
424
+ comparable prompts/contexts/trials, a measured local baseline and one selected
425
+ local/hybrid alternative. Build returns to Factory's checks, independent Review
426
+ and operator handoff; it does not launch another review pipeline. Set explicit
427
+ deadlines/resource budgets and exercise tool use, context recovery and reports.
428
+ 5. **Inspect history and usage.** Use `factory status --state PATH` and the
429
+ dashboard's attempt history/Analytics. Retain raw failures, diagnostics,
430
+ unfinished work and repairs under [recovery](recovery.md). Record wall time,
431
+ observable prefill/decode throughput, memory/offload, token observations,
432
+ tool/report failures, checks/review outcomes and human time, including cloud
433
+ Review usage in hybrid totals. Unknown measurements stay unknown; see
434
+ [qualification evidence and limits](proof.md) before generalizing a result.
435
+ 6. **Roll back explicitly when needed.** With the running controller idle,
436
+ `factory definition rollback --state PATH` previews the previous roles/bindings;
437
+ add `--expected-revision` from that preview to apply it. Image rollback is a
438
+ separate stopped-installation operation described below. Preserve attempts and
439
+ backups; rollback does not rewrite failed history or retry work.
440
+
441
+ Fresh Pi completed-message observations have **partial** coverage: input includes
442
+ cache reads/writes, with cached input, cache-write input and output shown separately;
443
+ cache subsets are not added again. Absent or old usage remains unknown, with no Pi
444
+ historical backfill. Summary and unreported error/retry usage remains unreported,
445
+ even when the attempt succeeds. See [Pi usage semantics](usage.md#pi-stdout-measurements-0154).
446
+ These observations establish neither total cost nor speed or quality. Local
447
+ inference avoids provider token billing, not electricity, hardware or operator cost.
448
+
449
+ Set context/output limits against actual allocation with headroom as described
450
+ below. Before recommending larger windows, test a bounded 64k/128k allocation
451
+ where supported, recording KV-cache/Flash Attention settings and memory separately from
452
+ long-context task quality. An advertised window or successful short prompt is
453
+ insufficient. Authenticated local endpoints, sampling overrides and custom thinking
454
+ maps are unsupported; supported reasoning-effort requests still need endpoint
455
+ qualification and do not prove a server honored them. No model/provider is chosen
456
+ automatically. Broader local/hybrid qualification remains open in #69.
433
457
 
434
458
 
435
459
  ## Local Pi project resources (0.15.3, #103)
@@ -455,13 +479,12 @@ extensions/packages and automatically discovered repository skills are not a
455
479
  supported configuration path. Explicit task-driven file reads are still possible
456
480
  within the sandbox and grant no additional authority.
457
481
 
458
- Before accepting this repair, the lead must qualify the exact installed 0.15.3
459
- package/image through CLI/API, representative native local Build and separate
460
- read-only Review, and one actual local-model run. `npm run qualify:pi` uses actual
461
- Pi with **synthetic HTTP inference**, not model judgment. See [trust-store
462
- proof and limits](proof.md#pi-project-trust-startup-0153-103). Retain the failed
463
- #51 attempt unchanged and unaccepted; retry only through a newly authorized
464
- attempt after qualification. Do not modify private installations from a job.
482
+ Qualify native local Build and separate read-only Review with the exact selected
483
+ image and model. `npm run qualify:pi` exercises actual Pi with **synthetic HTTP
484
+ inference**, not model judgment. See [trust-store proof and limits](proof.md#pi-project-trust-startup-0153-103)
485
+ and the later qualification records in [proof](proof.md). Preserve failed attempts
486
+ and use [recovery](recovery.md) before retrying; jobs must not modify private
487
+ installations.
465
488
 
466
489
  ## Local context budget and worker image (0.15.2, #100)
467
490
 
@@ -509,9 +532,8 @@ guessed or silently rewritten. Do not add project Pi settings to bypass
509
532
  the admitted binding or rely on custom tuning that has not been qualified.
510
533
  See [pinned compaction behavior](https://github.com/earendil-works/pi/blob/v0.87.1/packages/coding-agent/docs/compaction.md).
511
534
 
512
- Starting with the **0.15.5 candidate (#105)**, the local adapter also checks
513
- history pressure at Pi's completed-tool-turn boundary. This conservative byte
514
- signal counts the system/task/tool envelope once, call arguments, non-tool text
535
+ The local adapter also checks history pressure at Pi's completed-tool-turn
536
+ boundary. This conservative byte signal counts the system/task/tool envelope once, call arguments, non-tool text
515
537
  and capped tool excerpts, separately from provider-reported usage. At 65% of the
516
538
  input budget it considers Pi's supported summary generator and append-only
517
539
  compaction entry. The newest complete tool group is retained; up to three older
@@ -541,7 +563,7 @@ output on pinned Pi). `factory_context_recovery` JSONL events record only
541
563
  pressure, envelope/floor sizes, group counts and recovery progress, without
542
564
  prompts/content. Pi token accounting stays partial; this summary path is not
543
565
  added to ordinary assistant usage totals. See
544
- [source measurements, rejected real trial and pending qualification](proof.md#short-local-pi-history-recovery-0155-candidate-105).
566
+ [context-recovery measurements, retained failures and qualification limits](proof.md#short-local-pi-history-recovery-0155-candidate-105).
545
567
 
546
568
  Before adoption, stop and reconcile all work, retain the old immutable image ID
547
569
  and a stopped private-state backup. Build/qualify the exact candidate image, then
@@ -556,7 +578,7 @@ readable and cloud profiles keep their existing wrapper. Roll back with the reta
556
578
  path and restore the previously working role definition through revision-guarded
557
579
  rollback. Do not change frozen attempts or relabel old failed results.
558
580
 
559
- Lead's bounded actual-inference fixture must include enough tool results to trigger
581
+ A bounded actual-inference fixture must include enough tool results to trigger
560
582
  summarization, resumed useful tool work, the required report, checks and independent
561
583
  Review. Record the exact image/model digest, harness/server versions, allocated
562
584
  window, output/reasoning request, tool growth, compaction ordering, elapsed time,
@@ -564,6 +586,6 @@ quality outcome and peak memory; unknown measurements remain null. Set an explic
564
586
  job deadline/resource budget and preserve any unfinished checkout and diagnostic
565
587
  artifacts. A short successful prompt is insufficient. A larger actually allocated
566
588
  window is a later measured comparison only if this evidence warrants it; do not
567
- switch model or cloud provider automatically. The failed #83 local attempt is not
568
- local delivery. An operator-selected fresh cloud retry and unfinished-checkpoint
569
- recovery remain governed by [recovery](recovery.md) and #42.
589
+ switch model or cloud provider automatically. An operator-selected fresh cloud
590
+ retry and unfinished-checkpoint recovery remain governed by [recovery](recovery.md); neither converts failed local work into local
591
+ delivery.
@@ -0,0 +1,23 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ import { activityQuery } from './activity.mjs';
3
+
4
+ // One request at a time; reconnect always retains the exact attempt and cursor.
5
+ export async function followActivity({ request, job, attempt, after = null, limit = '50', follow = false,
6
+ interval = 2000, signal, output, unavailable }) {
7
+ let { cursor } = activityQuery(after, limit);
8
+ if (!Number.isSafeInteger(interval) || interval < 1000 || interval > 30000) throw new Error('Activity poll interval must be 1000–30000 ms');
9
+ do {
10
+ if (signal?.aborted) return;
11
+ try {
12
+ const page = await request(`/api/v1/jobs/${job}/runs/${attempt}/activity?limit=${limit}${cursor === null ? '' : `&after=${cursor}`}`, signal);
13
+ if (signal?.aborted) return;
14
+ output(page); cursor = page.next_cursor;
15
+ if (!follow || (page.terminal && !page.has_more)) return;
16
+ } catch (error) {
17
+ if (signal?.aborted) return;
18
+ if (!follow || /Controller 4\d\d:/.test(error.message)) throw error;
19
+ unavailable({ status: 'unavailable', job, attempt, next_cursor: cursor, refreshed_at: null });
20
+ }
21
+ try { await delay(interval, undefined, { signal }); } catch { if (signal?.aborted) return; }
22
+ } while (follow);
23
+ }
@@ -0,0 +1,174 @@
1
+ // Private execution observations, never evidence, usage or action authority.
2
+ import { open, rename, rm } from 'node:fs/promises';
3
+ import { constants, fstatSync, lstatSync, openSync, readSync, closeSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ export const ACTIVITY_CAP = 128;
7
+ export const ACTIVITY_BYTES = 64 * 1024;
8
+ export const ACTIVITY_LINE_BYTES = 64 * 1024;
9
+ export const ACTIVITY_KINDS = Object.freeze(['phase_started', 'process_started', 'message_completed',
10
+ 'tool_completed', 'process_exited', 'phase_completed', 'phase_error']);
11
+ export const TOOL_CATEGORIES = Object.freeze(['read', 'edit', 'command', 'search', 'other']);
12
+ const phases = ['build', 'verify', 'review', 'handoff', 'defence'];
13
+ const timestamp = value => typeof value === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value) && Number.isFinite(Date.parse(value));
14
+ const integer = value => Number.isSafeInteger(value) && value >= 0;
15
+ const keys = (value, allowed) => value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).every(key => allowed.includes(key));
16
+ const increment = value => Math.min(Number.MAX_SAFE_INTEGER, value + 1);
17
+ const codexTools = { command_execution: 'command', file_change: 'edit', web_search: 'search', mcp_tool_call: 'other' };
18
+ const piTools = { read: 'read', write: 'edit', edit: 'edit', bash: 'command', grep: 'search', find: 'search', ls: 'read' };
19
+
20
+ // Independent from usage parsing: strict UTF-8 JSONL, no tail fragments, no raw
21
+ // input retained after each line, and a fixed buffer even for newline-free output.
22
+ export class ActivityParser {
23
+ constructor(harness, emit, received = () => {}, lost = () => {}) {
24
+ this.harness = harness; this.emit = emit; this.received = received; this.lost = lost;
25
+ this.buffer = Buffer.alloc(ACTIVITY_LINE_BYTES); this.length = 0; this.overflow = false;
26
+ this.decoder = new TextDecoder('utf-8', { fatal: true });
27
+ }
28
+ write(chunk) {
29
+ if (!chunk.length) return;
30
+ this.received();
31
+ if (!['codex', 'pi'].includes(this.harness)) return;
32
+ for (const byte of chunk) {
33
+ if (byte === 10) { this.complete(); continue; }
34
+ if (this.length < this.buffer.length) this.buffer[this.length++] = byte;
35
+ else this.overflow = true;
36
+ }
37
+ }
38
+ complete() {
39
+ try {
40
+ if (this.overflow) throw new Error();
41
+ if (!this.length) return;
42
+ const event = JSON.parse(this.decoder.decode(this.buffer.subarray(0, this.length)));
43
+ if (!event || typeof event !== 'object' || Array.isArray(event)) throw new Error();
44
+ if (this.harness === 'codex' && event.type === 'item.completed') {
45
+ const type = event.item?.type;
46
+ if (type === 'agent_message') this.emit('message_completed');
47
+ else if (Object.hasOwn(codexTools, type)) this.emit('tool_completed', codexTools[type]);
48
+ }
49
+ if (this.harness === 'pi') {
50
+ if (event.type === 'message_end' && event.message?.role === 'assistant') this.emit('message_completed');
51
+ if (event.type === 'tool_execution_end' && typeof event.toolName === 'string')
52
+ this.emit('tool_completed', Object.hasOwn(piTools, event.toolName) ? piTools[event.toolName] : 'other');
53
+ }
54
+ } catch { this.lost(); }
55
+ finally { this.buffer.fill(0, 0, this.length); this.length = 0; this.overflow = false; }
56
+ }
57
+ finish() {
58
+ if (this.length || this.overflow) this.lost();
59
+ this.buffer.fill(0); this.length = 0; this.overflow = false;
60
+ }
61
+ }
62
+
63
+ export class ActivityWriter {
64
+ constructor(directory, { job, attempt, phase, harness }, { interval = 1000 } = {}) {
65
+ this.path = join(directory, 'activity.json'); this.interval = interval;
66
+ this.record = { version: 1, job, attempt, phase, support: ['codex', 'pi'].includes(harness) ? harness : 'unsupported',
67
+ cursor: 0, dropped: 0, rejected: 0, write_errors: 0, last_received_at: null, process_observation: null, process_observed_at: null, saved_at: null, events: [] };
68
+ this.dirty = false; this.pending = null; this.timer = null; this.closed = false;
69
+ this.parser = new ActivityParser(harness, (kind, tool) => this.emit(kind, tool),
70
+ () => { this.record.last_received_at = new Date().toISOString(); this.schedule(); },
71
+ () => { this.record.rejected = increment(this.record.rejected); this.schedule(); });
72
+ this.emit('phase_started');
73
+ }
74
+ emit(kind, tool) {
75
+ if (!ACTIVITY_KINDS.includes(kind) || (tool !== undefined && !TOOL_CATEGORIES.includes(tool))) throw new Error('Invalid activity category');
76
+ if (this.record.cursor === Number.MAX_SAFE_INTEGER) { this.record.rejected = increment(this.record.rejected); this.schedule(); return; }
77
+ if (['process_started', 'process_exited'].includes(kind)) {
78
+ this.record.process_observation = kind; this.record.process_observed_at = new Date().toISOString();
79
+ }
80
+ this.record.events.push({ cursor: ++this.record.cursor, at: new Date().toISOString(), kind, ...(tool ? { tool } : {}) });
81
+ if (this.record.events.length > ACTIVITY_CAP) { this.record.events.shift(); this.record.dropped++; }
82
+ this.schedule();
83
+ }
84
+ schedule() {
85
+ this.dirty = true;
86
+ if (!this.closed && !this.timer) this.timer = setTimeout(() => { this.timer = null; void this.flush(); }, this.interval);
87
+ }
88
+ async flush() {
89
+ if (this.pending) { await this.pending; return; }
90
+ if (!this.dirty) return;
91
+ this.dirty = false;
92
+ const snapshot = JSON.stringify({ ...this.record, saved_at: new Date().toISOString() });
93
+ // At most one write in flight; clients never trigger writes. A fixed temp
94
+ // name and atomic replacement bound disk usage to two capped records.
95
+ this.pending = (async () => {
96
+ let handle;
97
+ try {
98
+ handle = await open(`${this.path}.tmp`, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o600);
99
+ await handle.chmod(0o600); await handle.writeFile(snapshot); await handle.sync(); await handle.close(); handle = null;
100
+ await rename(`${this.path}.tmp`, this.path);
101
+ } catch {
102
+ this.record.write_errors = increment(this.record.write_errors); this.dirty = true;
103
+ await handle?.close().catch(() => {}); await rm(`${this.path}.tmp`, { force: true }).catch(() => {});
104
+ }
105
+ })();
106
+ await this.pending; this.pending = null;
107
+ if (this.dirty && !this.closed) this.schedule();
108
+ }
109
+ async finish(success) {
110
+ if (this.closed) return;
111
+ this.closed = true; clearTimeout(this.timer); this.timer = null;
112
+ this.parser.finish(); this.emit(success ? 'phase_completed' : 'phase_error');
113
+ await this.pending; await this.flush();
114
+ }
115
+ }
116
+
117
+ export function activityQuery(after, limit = '50') {
118
+ if (after !== null && after !== undefined && !/^(0|[1-9]\d{0,15})$/.test(String(after))) throw Object.assign(new Error('Invalid activity cursor'), { status: 400 });
119
+ const cursor = after == null ? null : Number(after), count = Number(limit);
120
+ if ((cursor !== null && !integer(cursor)) || !/^[1-9]\d{0,2}$/.test(String(limit)) || !integer(count) || count > ACTIVITY_CAP)
121
+ throw Object.assign(new Error('Invalid activity cursor or limit'), { status: 400 });
122
+ return { cursor, limit: count };
123
+ }
124
+
125
+ export function readActivity(state, job, attempt, query = activityQuery(null)) {
126
+ if (!/^job_[a-f0-9]+$/.test(job.id) || !/^run_[a-f0-9]+$/.test(attempt.id) || !phases.includes(attempt.command))
127
+ throw Object.assign(new Error('Activity identity unavailable'), { status: 400 });
128
+ const terminal = ['succeeded', 'failed', 'cancelled', 'interrupted'].includes(attempt.state);
129
+ const result = { version: 1, job: job.id, attempt: attempt.id, phase: attempt.command,
130
+ attempt_state: attempt.state, terminal, container_health: 'unknown', refreshed_at: new Date().toISOString(),
131
+ status: 'unavailable', support: 'unknown', events: [], next_cursor: query.cursor ?? 0,
132
+ latest_cursor: null, oldest_cursor: null, truncated: false, has_more: false,
133
+ last_received_at: null, process_observation: null, process_observed_at: null, phase_started_at: timestamp(attempt.started_at) ? attempt.started_at : null,
134
+ saved_at: null, dropped: 0, rejected: 0, write_errors: 0, completion_observed: false };
135
+ let record, fd;
136
+ try {
137
+ const folder = join(state, 'jobs', job.id, attempt.id);
138
+ for (const dir of [join(state, 'jobs'), join(state, 'jobs', job.id), folder]) {
139
+ const stat = lstatSync(dir);
140
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077)) throw new Error();
141
+ }
142
+ fd = openSync(join(folder, 'activity.json'), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
143
+ const stat = fstatSync(fd);
144
+ if (!stat.isFile() || (stat.mode & 0o077) || stat.size > ACTIVITY_BYTES) throw new Error();
145
+ const buffer = Buffer.alloc(ACTIVITY_BYTES + 1), size = readSync(fd, buffer, 0, buffer.length, 0);
146
+ if (size > ACTIVITY_BYTES) throw new Error();
147
+ record = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, size)));
148
+ if (!keys(record, ['version','job','attempt','phase','support','cursor','dropped','rejected','write_errors','last_received_at','process_observation','process_observed_at','saved_at','events'])
149
+ || record.version !== 1 || record.job !== job.id || record.attempt !== attempt.id || record.phase !== attempt.command
150
+ || !['codex','pi','unsupported'].includes(record.support) || !integer(record.cursor) || !integer(record.dropped)
151
+ || !integer(record.rejected) || !integer(record.write_errors) || !timestamp(record.saved_at)
152
+ || (record.last_received_at !== null && !timestamp(record.last_received_at))
153
+ || (record.process_observation === null ? record.process_observed_at !== null
154
+ : !['process_started','process_exited'].includes(record.process_observation) || !timestamp(record.process_observed_at))
155
+ || !Array.isArray(record.events) || !record.events.length || record.events.length > ACTIVITY_CAP
156
+ || record.cursor !== record.dropped + record.events.length) throw new Error();
157
+ for (const [index, event] of record.events.entries()) {
158
+ if (!keys(event, ['cursor','at','kind','tool']) || event.cursor !== record.dropped + index + 1
159
+ || !timestamp(event.at) || !ACTIVITY_KINDS.includes(event.kind)
160
+ || (event.kind === 'tool_completed' ? !TOOL_CATEGORIES.includes(event.tool) : event.tool !== undefined)) throw new Error();
161
+ }
162
+ } catch (error) { result.status = error.code === 'ENOENT' ? 'unavailable' : 'error'; return result; }
163
+ finally { if (fd !== undefined) closeSync(fd); }
164
+ if (query.cursor !== null && query.cursor > record.cursor) throw Object.assign(new Error('Activity cursor is ahead of retained history; reconnect with the same attempt and no cursor'), { status: 409 });
165
+ const start = query.cursor ?? Math.max(record.dropped, record.cursor - query.limit);
166
+ const events = record.events.filter(event => event.cursor > start).slice(0, query.limit);
167
+ return { ...result, status: 'available', support: record.support, events,
168
+ next_cursor: events.at(-1)?.cursor ?? start, latest_cursor: record.cursor, oldest_cursor: record.dropped + 1,
169
+ truncated: start < record.dropped || (query.cursor === null && start > 0),
170
+ has_more: (events.at(-1)?.cursor ?? start) < record.cursor,
171
+ last_received_at: record.last_received_at, process_observation: record.process_observation, process_observed_at: record.process_observed_at, saved_at: record.saved_at,
172
+ dropped: record.dropped, rejected: record.rejected, write_errors: record.write_errors,
173
+ completion_observed: ['phase_completed', 'phase_error'].includes(record.events.at(-1)?.kind) };
174
+ }