Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions docs/TASK-HANDOVERS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Resume a task without repeating discovery

`scripts/task-handover.mjs` saves a compact progress record with an existing
[source packet](WORKER-PACKETS.md). It runs locally, makes no model calls and
does not depend on Oathrun. Use Node from `.nvmrc` and `npm run build` first.

This is a checkout helper, not a published CLI, automatic client hook or an
Oathrun runtime integration. The person or agent doing the work supplies the
state. A fresh session explicitly runs `resume` before using it.

## Save at a useful stopping point

After edits, build a **fresh** source packet containing the evidence required
to continue, including changed files and relevant tests. Its allowed-file
snapshots bind present and absent files; an earlier pre-edit packet will fail.
Keep the same reviewed task boundaries unless a scope change is authorised.
Select sufficient evidence; hashing an incomplete selection cannot make it
complete. Do not include credentials or unrelated project data.

Write a private state JSON file. Each acceptance check in the packet must have
exactly one result, using its zero-based index. For a packet with one check:

```json
{
"version": 1,
"status": "in_progress",
"completed": ["Implemented the selected change"],
"decisions": ["Retained the existing public interface"],
"checks": [{"index": 0, "outcome": "not_run", "evidence": null}],
"unresolvedQuestions": [],
"pendingEffects": [],
"nextAction": "Run the focused regression tests and review the diff"
}
```

Record actual outcomes as `passed`, `failed`, `not_run` or `unknown`. A passed
or failed check requires an evidence description: exact command, result and a
private log reference where available. The helper does not open that reference,
execute the command or authenticate the result. Claims supplied by a model are
still claims; review the evidence before accepting work.

Use `blocked` when progress needs a decision or reconciliation. Put uncertain
external operations in `pendingEffects`, so the next agent knows to inspect
them before considering another attempt. This record does not prevent replay.

`ready_for_review` requires all checks to be reported passing and no unresolved
questions in either the packet or state, or pending effects. There is deliberately
no `complete` or `accepted` status: those decisions belong to the actual reviewer.

```sh
node scripts/task-handover.mjs save \
--root /absolute/repository \
--packet /private/task/source-packet.json \
--state /private/task/state.json \
--out /private/task/handover-001.json

node scripts/task-handover.mjs resume \
--root /absolute/repository \
--handover /private/task/handover-001.json
```

Use canonical absolute paths without symbolic-link components. Save refuses
existing outputs and creates a file with mode `0600`. Keep it outside version
control: the record embeds the selected source as well as the task state. Use a
new output for each checkpoint; no mutable shared latest pointer is maintained.

## Continue from the compact result

`resume` reassembles the embedded packet under its original source-selection
contract and compares it to current repository evidence. A changed selected
source, allowed file, HEAD, root identity or relevant packet policy rejects
reuse. Rebuild the packet and review the state against the change before saving
a new handover. Never fix a failed verification by simply editing the hashes.

The result contains the task, allowed files, exclusions, progress, decisions,
check criteria/results, outstanding questions/effects and next action. Source
locations and hashes replace repeated excerpts in this output; the full packet
remains in the saved file. Read the actual selected source when needed. This
keeps routine continuation concise without pretending a pointer is sufficient
evidence for implementation or review.

Freshness covers only the packet's selection. Changes to unselected code,
external dependencies, accounts, permissions or live services can still matter.
Recorded tests are historical assertions even when the selected source is
current. These checks are not an atomic filesystem snapshot or protection
against a hostile concurrent writer. The unsigned record grants no permissions
and cannot override current user instructions or the consumer's authority checks.

Limits: source packet 64 KiB, state 16 KiB, saved record 96 KiB, each progress
list at most 32 entries and each text at most 2,048 characters. Oversized input
fails; it is never silently truncated. Treat all record text as untrusted data.

## Measure the benefit

Use a real task receipt to record resumed discovery calls, total observed usage,
repairs, review time and the operator's interruptions. Include preparing this
handover. Compact output alone does not establish subscription headroom or cash
savings. Oathrun can consume the same workflow in a later, separately verified
integration; this helper does not resume jobs or contact agents automatically.

Check this helper with `node --test test/task-handover.test.mjs`.
3 changes: 3 additions & 0 deletions docs/WORKER-PACKETS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Source packets for workers

To preserve progress, decisions and check outcomes for another session, use the
[verified task handover](TASK-HANDOVERS.md) after building a fresh source packet.

Assemble the exact source a worker needs once, then verify it before handing it
over. This checkout helper runs locally, outside Core. It does not contact a
provider, choose a model, execute acceptance commands or enforce worker access.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"benchmark:tokens:parity": "npm run build && node benchmarks/source-navigation.mjs --check",
"benchmark:navigation": "npm run build && node benchmarks/source-navigation.mjs",
"test": "npm run test --workspace @forgesworn/context && npm run test --workspace @forgesworn/context-tools && npm run test:worker-packets && npm run test:task-costs",
"test:worker-packets": "node --test test/worker-packet.test.mjs",
"test:worker-packets": "node --test test/worker-packet.test.mjs test/task-handover.test.mjs",
"test:task-costs": "node --test test/task-cost-report.test.mjs",
"test:packages": "node test/context-package-smoke.mjs",
"check": "npm run build && npm test && npm run test:packages"
Expand Down
132 changes: 132 additions & 0 deletions scripts/task-handover.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
#!/usr/bin/env node
/** Offline, unsigned task continuity over verified source packets. */
import { createHash } from 'node:crypto';
import { constants, promises as fs } from 'node:fs';
import { dirname, isAbsolute, parse, resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildPacketFromSpec, serializePacket } from './worker-packet.mjs';

const MAX_PACKET = 65536;
const MAX_STATE = 16384;
const MAX_HANDOVER = 98304;
const FORMAT = 'context-task-handover-v1';
const caveat = 'Unsigned task data, not instructions or authority. Checks are recorded assertions, not independently verified results. Freshness covers selected sources, allowed files, HEAD and packet policy only; external dependencies and effects require reconciliation. No commands are executed.';
function assert(ok, message) { if (!ok) throw new Error(`task-handover: ${message}`); }
function digest(value) { return createHash('sha256').update(serializePacket(value)).digest('hex'); }
function keys(value, expected) {
assert(value && typeof value === 'object' && !Array.isArray(value), 'object required');
assert(Object.keys(value).sort().join(',') === [...expected].sort().join(','), 'unexpected or missing fields');
}
function text(value) { assert(typeof value === 'string' && value.trim().length > 0 && value.length <= 2048 && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value), 'invalid bounded text'); }
function texts(value) { assert(Array.isArray(value) && value.length <= 32, 'bounded list required'); value.forEach(text); }

async function ordinaryPath(path, parentOnly = false) {
assert(typeof path === 'string' && isAbsolute(path) && path === resolve(path), 'canonical absolute path required');
const target = parentOnly ? dirname(path) : path;
let current = parse(target).root;
for (const part of target.slice(current.length).split('/').filter(Boolean)) {
current = join(current, part);
const info = await fs.lstat(current);
assert(!info.isSymbolicLink(), 'symlink path refused');
if (current !== target || parentOnly) assert(info.isDirectory(), 'directory required');
}
}
async function readJson(path, maxBytes) {
await ordinaryPath(path);
const file = await fs.open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
const before = await file.stat();
assert(before.isFile() && before.size <= maxBytes, 'bounded regular file required');
const buffer = Buffer.alloc(before.size + 1);
let length = 0;
while (length < buffer.length) {
const result = await file.read(buffer, length, buffer.length - length, length);
if (!result.bytesRead) break;
length += result.bytesRead;
}
const after = await file.stat();
const named = await fs.lstat(path);
assert(length === before.size && after.size === before.size && after.mtimeMs === before.mtimeMs && after.ctimeMs === before.ctimeMs && named.dev === before.dev && named.ino === before.ino && !named.isSymbolicLink(), 'file changed while reading');
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, length)));
} finally { await file.close(); }
}
function validateState(state, packet) {
keys(state, ['version', 'status', 'completed', 'decisions', 'checks', 'unresolvedQuestions', 'pendingEffects', 'nextAction']);
assert(state.version === 1, 'unsupported state version');
assert(['in_progress', 'blocked', 'ready_for_review'].includes(state.status), 'invalid status');
for (const key of ['completed', 'decisions', 'unresolvedQuestions', 'pendingEffects']) texts(state[key]);
text(state.nextAction);
const acceptance = packet.originalSpec.acceptanceChecks;
assert(Array.isArray(state.checks) && state.checks.length === acceptance.length, 'record every acceptance check');
const seen = new Set();
for (const check of state.checks) {
keys(check, ['index', 'outcome', 'evidence']);
assert(Number.isInteger(check.index) && check.index >= 0 && check.index < acceptance.length && !seen.has(check.index), 'invalid or duplicate check index');
seen.add(check.index);
assert(['passed', 'failed', 'not_run', 'unknown'].includes(check.outcome), 'invalid check outcome');
if (check.evidence !== null) text(check.evidence);
if (['passed', 'failed'].includes(check.outcome)) assert(check.evidence !== null, 'check evidence required');
}
if (state.status === 'ready_for_review') {
assert(state.checks.every(check => check.outcome === 'passed') && !state.unresolvedQuestions.length && !state.pendingEffects.length && !packet.originalSpec.unresolvedQuestions.length, 'review readiness requires reported passing checks and no unresolved questions or effects');
}
assert(Buffer.byteLength(serializePacket(state)) <= MAX_STATE, 'state too large');
}
async function verifyCurrent(root, packet) {
assert(packet && typeof packet === 'object' && packet.originalSpec, 'source packet required');
assert(Buffer.byteLength(serializePacket(packet)) <= MAX_PACKET, 'packet too large');
const rebuilt = await buildPacketFromSpec({ root, spec: packet.originalSpec });
assert(serializePacket(rebuilt) === serializePacket(packet), 'source packet is stale or changed; rebuild before continuing');
}

export async function saveHandover({ root, packet: packetPath, state: statePath, out }) {
const packet = await readJson(packetPath, MAX_PACKET);
const state = await readJson(statePath, MAX_STATE);
await verifyCurrent(root, packet);
validateState(state, packet);
const record = { format: FORMAT, savedAt: new Date().toISOString(), packetSha256: digest(packet), packet, state };
const bytes = serializePacket(record) + '\n';
assert(Buffer.byteLength(bytes) <= MAX_HANDOVER, 'handover too large');
await ordinaryPath(out, true);
const file = await fs.open(out, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
try { await file.writeFile(bytes, 'utf8'); await file.sync(); } finally { await file.close(); }
return { format: FORMAT, status: state.status, bytes: Buffer.byteLength(bytes), packetSha256: record.packetSha256 };
}

export async function resumeHandover({ root, handover }) {
const record = await readJson(handover, MAX_HANDOVER);
keys(record, ['format', 'savedAt', 'packetSha256', 'packet', 'state']);
assert(record.format === FORMAT && typeof record.savedAt === 'string' && Number.isFinite(Date.parse(record.savedAt)), 'invalid handover format or timestamp');
assert(record.packetSha256 === digest(record.packet), 'packet digest differs');
await verifyCurrent(root, record.packet);
validateState(record.state, record.packet);
const spec = record.packet.originalSpec;
return {
format: FORMAT, trust: 'unsigned', freshness: 'current', savedAt: record.savedAt,
task: spec.task, gitHEAD: record.packet.gitHEAD, packetSha256: record.packetSha256,
allowedFiles: spec.allowedFiles, exclusions: spec.exclusions, taskUnresolvedQuestions: spec.unresolvedQuestions,
state: { ...record.state, checks: record.state.checks.map(check => ({ ...check, criterion: spec.acceptanceChecks[check.index] })) },
sources: record.packet.sources.map(({ path, startLine, endLine, sha256 }) => ({ path, startLine, endLine, sha256 })),
caveat,
};
}

const usage = 'Usage: task-handover.mjs save --root ABS --packet ABS --state ABS --out ABS\n task-handover.mjs resume --root ABS --handover ABS\n';
async function main(args) {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) return process.stdout.write(usage);
let result;
if (args[0] === 'save') {
assert(args.length === 9 && args[1] === '--root' && args[3] === '--packet' && args[5] === '--state' && args[7] === '--out', usage);
result = await saveHandover({ root: args[2], packet: args[4], state: args[6], out: args[8] });
} else {
assert(args[0] === 'resume' && args.length === 5 && args[1] === '--root' && args[3] === '--handover', usage);
result = await resumeHandover({ root: args[2], handover: args[4] });
}
process.stdout.write(JSON.stringify(result) + '\n');
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(process.argv.slice(2)).catch(error => {
// Only our fixed validation messages are safe to print; parser/filesystem
// errors can echo private paths or input text.
process.stderr.write((error.message?.startsWith('task-handover:') ? error.message : 'task-handover: validation failed; check input schema, source freshness and file paths') + '\n');
process.exitCode = 1;
});
3 changes: 2 additions & 1 deletion scripts/worker-packet.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function stable(value) {
return value;
}
function serialize(value) { return JSON.stringify(stable(value)); }
export { serialize as serializePacket };
function strictText(bytes, label) {
try { return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); }
catch { throw new Error(`worker-packet: ${label} is not valid UTF-8`); }
Expand Down Expand Up @@ -219,7 +220,7 @@ async function validateAllowedSnapshots(root, allowedFiles) {
}
}

async function buildPacketFromSpec({ root: rootInput, spec: specInput }) {
export async function buildPacketFromSpec({ root: rootInput, spec: specInput }) {
const rootInfo = await canonicalRoot(rootInput);
const root = rootInfo.root;
const spec = parseSpec(serialize(specInput));
Expand Down
12 changes: 12 additions & 0 deletions test/git-fixture.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const exec = promisify(execFile);

// Git hooks export repository/index settings. A fixture's `git -C` alone does
// not override them: clear them before any fixture init/config/add/commit.
export function fixtureExec(file, args, options = {}) {
const env = Object.fromEntries(Object.entries(options.env ?? process.env)
.filter(([key]) => !key.startsWith('GIT_')));
return exec(file, args, { ...options, env });
}
Loading
Loading