@afokapu/atdd-flow 0.3.20 → 0.3.22
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 +2 -0
- package/conventions/atdd-workflow.workflow/atdd-workflow.workflow.lifecycle.convention.yaml +11 -0
- package/package.json +1 -1
- package/src/adoption-authorizations.ts +87 -0
- package/src/cli.ts +9 -4
- package/src/seats.ts +60 -9
- package/src/tasks.ts +118 -0
- package/src/threads.ts +28 -0
package/README.md
CHANGED
|
@@ -120,6 +120,8 @@ atdd-flow task done resolver-os runtime-rollout --by coordinator@resolver-os
|
|
|
120
120
|
|
|
121
121
|
Proof is a compact PR, CI run, report, commit range, deployment, or thread reference. Dependencies gate prerequisites; independent tasks are parallel-ready. A coordinator can staff an unassigned ready task with `atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>`; assignment is allowed only once while the task is `todo`. Use `task block` only for a real external blocker, then checkpoint exact state and next action. Once the blocker is resolved, only that coordinator can clear it with `atdd-flow task unblock <project> <task-id> --by <coordinator-address>` while the task remains `in_progress`. For deliveries explicitly governed by the `workflow` ATDD Bun profile, `review → done` additionally requires a durable final behavioral-review result with decision `APPROVE` for the current clean delivery commit. For an idle driver’s final task, `task done ... --retire-assignee` delegates clean-and-merged worktree retirement to ATDD Bun.
|
|
122
122
|
|
|
123
|
+
For topology normalization, transfer an active task through the narrow durable command, never by editing its YAML: `atdd-flow task transfer <project> <task-id> --to <main-or-named-coordinator> --reason <text> --by <current-coordinator>`; `main@project` must additionally pass `--authorization <operator-message-id>`. That message must be an immutable `task-transfer-authorization/v1` exact tuple for the project, task, current/target coordinator, reason, and target branch/head; only `operator@desk` creates it through `atdd-flow post --task-transfer-authorization <json>`. The command appends immutable source/target/reason/exact-head/effective-time provenance, keeps earlier phase/review/message evidence intact, derives the new governed base from the accepted target head, and never moves a seat, branch, runtime, or worktree. Same-target, duplicate, nested, mismatched, unauthorized, and manually rewritten transitions fail closed.
|
|
124
|
+
|
|
123
125
|
## Communicate and hand over
|
|
124
126
|
|
|
125
127
|
Threads are the durable inbox/outbox. Workflow persists a message before a best-effort host notification, so a closed pane, rate limit, or missed prompt cannot lose it.
|
|
@@ -77,6 +77,17 @@ content:
|
|
|
77
77
|
coordinator lineage, mismatched base/worktree placement, and ambiguous placement rather than
|
|
78
78
|
guessing. Legacy seat, task, and thread identities remain readable without rename or mutation.
|
|
79
79
|
|
|
80
|
+
Transfer active task accountability only through `atdd-flow task transfer`, never by rewriting
|
|
81
|
+
task YAML. The transition retains append-only from/to/reason/exact-head/effective-time provenance
|
|
82
|
+
and leaves prior task, phase, review, and message evidence attributable. The current coordinator
|
|
83
|
+
may transfer, while main requires one applicable immutable `operator@desk`
|
|
84
|
+
`task-transfer-authorization/v1` message. Its project, task, from, to, reason, and exact target
|
|
85
|
+
branch/head must match the requested transfer before mutation; only `operator@desk` may create
|
|
86
|
+
that typed authorization. Accept only main or a valid named integration coordinator; atomically
|
|
87
|
+
derive the new governed base from that accepted target head. Reject same-target, duplicate, nested, mismatched,
|
|
88
|
+
unauthorized, or manually rewritten transfers. A transfer never moves or changes any seat, branch,
|
|
89
|
+
runtime binding, worktree, dirty state, or evidence artifact.
|
|
90
|
+
|
|
80
91
|
Drivers hand work back to their named coordinator for integration and validation. The named
|
|
81
92
|
coordinator hands a reviewed integration candidate to main; only main completes normal
|
|
82
93
|
integration-to-main gates and publication. No automatic merge executor, branch graph, daemon, or
|
package/package.json
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { now, required, words, yaml } from "./core";
|
|
5
|
+
|
|
6
|
+
export const authorizationSchema = "atdd-flow/exact-session-adoption-authorization/v1" as const;
|
|
7
|
+
export type ExactSessionAdoptionAuthorization = {
|
|
8
|
+
schema: typeof authorizationSchema;
|
|
9
|
+
id: string;
|
|
10
|
+
issued_by: "operator@desk";
|
|
11
|
+
created_at: string;
|
|
12
|
+
seat: string;
|
|
13
|
+
pi_session: string;
|
|
14
|
+
pi_session_path: string;
|
|
15
|
+
source: { herdr_session: string; pane: string };
|
|
16
|
+
target: { herdr_session: string; pane: string; cwd: string };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const validId = (value: string) => /^A-[A-Za-z0-9_-]+$/.test(value);
|
|
20
|
+
const nonEmpty = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0;
|
|
21
|
+
const path = (root: string, id: string) => join(root, ".atdd-flow", "exact-session-adoption-authorizations", `${id}.yaml`);
|
|
22
|
+
const hashPath = (root: string, id: string) => join(root, ".atdd-flow", "exact-session-adoption-authorizations", `${id}.sha256`);
|
|
23
|
+
const digest = (content: string) => createHash("sha256").update(content).digest("hex");
|
|
24
|
+
|
|
25
|
+
function tuple(record: ExactSessionAdoptionAuthorization) {
|
|
26
|
+
return record.schema === authorizationSchema && validId(record.id) && record.issued_by === "operator@desk" && nonEmpty(record.created_at)
|
|
27
|
+
&& nonEmpty(record.seat) && nonEmpty(record.pi_session) && nonEmpty(record.pi_session_path)
|
|
28
|
+
&& nonEmpty(record.source?.herdr_session) && nonEmpty(record.source?.pane)
|
|
29
|
+
&& nonEmpty(record.target?.herdr_session) && nonEmpty(record.target?.pane) && nonEmpty(record.target?.cwd);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function createExactSessionAdoptionAuthorization(root: string, id: string, args: string[]) {
|
|
33
|
+
if (!validId(id)) throw new Error("Exact-session adoption authorization id must use A- followed by letters, numbers, underscores, or hyphens.");
|
|
34
|
+
if (words(args, "--by") !== "operator@desk") throw new Error("Only operator@desk may issue an exact-session adoption authorization.");
|
|
35
|
+
const record: ExactSessionAdoptionAuthorization = {
|
|
36
|
+
schema: authorizationSchema, id, issued_by: "operator@desk", created_at: now(),
|
|
37
|
+
seat: required(words(args, "--seat"), "--seat"), pi_session: required(words(args, "--pi-session"), "--pi-session"),
|
|
38
|
+
pi_session_path: resolve(required(words(args, "--pi-session-path"), "--pi-session-path")),
|
|
39
|
+
source: { herdr_session: required(words(args, "--source-herdr-session"), "--source-herdr-session"), pane: required(words(args, "--source-pane"), "--source-pane") },
|
|
40
|
+
target: { herdr_session: required(words(args, "--target-herdr-session"), "--target-herdr-session"), pane: required(words(args, "--target-pane"), "--target-pane"), cwd: resolve(required(words(args, "--target-cwd"), "--target-cwd")) },
|
|
41
|
+
};
|
|
42
|
+
const file = path(root, id);
|
|
43
|
+
const content = yaml.print(record);
|
|
44
|
+
await mkdir(dirname(file), { recursive: true });
|
|
45
|
+
await writeFile(file, content, { encoding: "utf8", flag: "wx" });
|
|
46
|
+
await writeFile(hashPath(root, id), `${digest(content)}\n`, { encoding: "utf8", flag: "wx" });
|
|
47
|
+
console.log(id);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function same(record: ExactSessionAdoptionAuthorization, expected: Omit<ExactSessionAdoptionAuthorization, "schema" | "id" | "issued_by" | "created_at">) {
|
|
51
|
+
return record.seat === expected.seat && record.pi_session === expected.pi_session && record.pi_session_path === expected.pi_session_path
|
|
52
|
+
&& record.source.herdr_session === expected.source.herdr_session && record.source.pane === expected.source.pane
|
|
53
|
+
&& record.target.herdr_session === expected.target.herdr_session && record.target.pane === expected.target.pane && record.target.cwd === expected.target.cwd;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function used(root: string, id: string) {
|
|
57
|
+
const folder = join(root, ".atdd-flow", "runtime-launch");
|
|
58
|
+
try {
|
|
59
|
+
const files = await readdir(folder);
|
|
60
|
+
for (const file of files.filter((entry) => entry.endsWith(".yaml"))) {
|
|
61
|
+
try {
|
|
62
|
+
const value = yaml.parse<Record<string, unknown>>(await readFile(join(folder, file), "utf8"));
|
|
63
|
+
const authorization = value.relocation && typeof value.relocation === "object" ? (value.relocation as Record<string, unknown>).authorization : undefined;
|
|
64
|
+
if (authorization && typeof authorization === "object" && (authorization as Record<string, unknown>).id === id) return true;
|
|
65
|
+
} catch { /* Malformed receipts never authorize reuse. */ }
|
|
66
|
+
}
|
|
67
|
+
} catch { /* No previous receipt means unused. */ }
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Resolve only a create-only operator record whose complete tuple matches before runtime projection. */
|
|
72
|
+
export async function resolveExactSessionAdoptionAuthorization(root: string, id: string, expected: Omit<ExactSessionAdoptionAuthorization, "schema" | "id" | "issued_by" | "created_at">) {
|
|
73
|
+
if (!validId(id)) throw new Error("Exact-session adoption requires an immutable operator authorization record id.");
|
|
74
|
+
let raw: string;
|
|
75
|
+
try { raw = await readFile(path(root, id), "utf8"); }
|
|
76
|
+
catch { throw new Error("Exact-session adoption requires an existing immutable operator authorization record."); }
|
|
77
|
+
let record: ExactSessionAdoptionAuthorization;
|
|
78
|
+
try { record = yaml.parse<ExactSessionAdoptionAuthorization>(raw); }
|
|
79
|
+
catch { throw new Error("Exact-session adoption requires a well-formed immutable operator authorization record."); }
|
|
80
|
+
let storedHash: string;
|
|
81
|
+
try { storedHash = (await readFile(hashPath(root, id), "utf8")).trim(); }
|
|
82
|
+
catch { throw new Error("Exact-session adoption requires a create-only immutable operator authorization record."); }
|
|
83
|
+
if (!/^[a-f0-9]{64}$/i.test(storedHash) || storedHash !== digest(raw)) throw new Error("Exact-session adoption requires an immutable operator authorization record.");
|
|
84
|
+
if (!tuple(record) || record.id !== id || !same(record, expected)) throw new Error("Exact-session adoption requires an immutable operator authorization record bound to the exact adoption tuple.");
|
|
85
|
+
if (await used(root, id)) throw new Error("Exact-session adoption authorization record was already used.");
|
|
86
|
+
return { id, hash: storedHash };
|
|
87
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { basename, resolve } from "node:path";
|
|
4
4
|
import { init, initProject, spawn, bind, useApplication, attach, describe, checkpoint, launchPiRuntime, migrate, openSeat, piExtensionPath } from "./seats";
|
|
5
|
+
import { createExactSessionAdoptionAuthorization } from "./adoption-authorizations";
|
|
5
6
|
import { addParticipant, openThread, post, readMessage, receipt, result, startThread } from "./threads";
|
|
6
7
|
import * as tasks from "./tasks";
|
|
7
8
|
import { required, values, words, yaml } from "./core";
|
|
@@ -26,13 +27,15 @@ Usage:
|
|
|
26
27
|
atdd-flow bind <address> [--application <application>] --address <native-address> [--session <name>] [--agent <executable>] [--worktree <path>] [--wake host|native]
|
|
27
28
|
atdd-flow attach <address> [--application <application>] [--wake host|native]
|
|
28
29
|
atdd-flow pi extension-path
|
|
29
|
-
atdd-flow pi runtime
|
|
30
|
+
atdd-flow pi runtime authorize-adoption <id> --by operator@desk --seat <seat> --pi-session <id> --pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --target-herdr-session <session> --target-pane <pane> --target-cwd <path>
|
|
31
|
+
atdd-flow pi runtime launch <address> [--pane <asserted-herdr-pane>] --herdr-session <session> [--resume] [--dry-run] [--adopt-session <exact-jsonl> --source-herdr-session <session> --source-pane <pane> --authorization <record-id>]
|
|
30
32
|
atdd-flow application use <address> <application>
|
|
31
33
|
atdd-flow multiplexer status|apply herdr [--session <name>]
|
|
32
34
|
atdd-flow describe <address> --purpose <one-line responsibility>
|
|
33
35
|
atdd-flow checkpoint <address> --summary <text> --next <text> [--status active|standby|blocked|complete|unverified]
|
|
34
36
|
atdd-flow task add <project> <task-id> --title <text> --coordinator <address> [--assignee <address>] [--body <text>] [--source <reference>] [--depends-on <task-id,...>] --done-when <text> [--done-when <text> ...]
|
|
35
37
|
atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>
|
|
38
|
+
atdd-flow task transfer <project> <task-id> --to <main-or-named-coordinator> --reason <text> --by <actor> [--authorization <operator-message-id>]
|
|
36
39
|
atdd-flow task amend <project> <task-id> [--title <text>] [--body <text>] [--source <reference>] [--depends-on <task-id,...>]
|
|
37
40
|
atdd-flow task import <project> <task-id> --proof <reference> [--proof <reference> ...] [--done-when <text> ...] [--source <reference>]
|
|
38
41
|
atdd-flow task start|review|return <project> <task-id> --by <address>
|
|
@@ -52,7 +55,7 @@ Usage:
|
|
|
52
55
|
atdd-flow thread add <thread-id> <address>
|
|
53
56
|
atdd-flow thread open <thread-id>
|
|
54
57
|
atdd-flow message read <message-id>
|
|
55
|
-
atdd-flow post <thread-id> --from <address> --to <all|address,...> --body <text> [--label <non-sensitive-text>] [--expects-result]
|
|
58
|
+
atdd-flow post <thread-id> --from <address> --to <all|address,...> --body <text> [--label <non-sensitive-text>] [--expects-result] [--task-transfer-authorization <json>]
|
|
56
59
|
atdd-flow receipt <thread-id> <message-id> --from <address> [--body <text>] [--label <non-sensitive-text>]
|
|
57
60
|
atdd-flow result <thread-id> <message-id> --from <address> --body <text> [--label <non-sensitive-text>]
|
|
58
61
|
atdd-flow status [project <project>|task <project> <task-id>|seat <address>|thread <thread-id>] [--all]
|
|
@@ -91,13 +94,14 @@ async function main() {
|
|
|
91
94
|
attach: () => attach(root, required(rest[0], "address"), rest.slice(1)),
|
|
92
95
|
pi: async () => {
|
|
93
96
|
if (rest[0] === "extension-path") return console.log(piExtensionPath());
|
|
97
|
+
if (rest[0] === "runtime" && rest[1] === "authorize-adoption") return createExactSessionAdoptionAuthorization(root, required(rest[2], "authorization id"), rest.slice(3));
|
|
94
98
|
if (rest[0] === "runtime" && rest[1] === "launch") {
|
|
95
99
|
const plan = await launchPiRuntime(root, required(rest[2], "address"), rest.slice(3));
|
|
96
100
|
if (plan.dryRun) console.log(yaml.print(plan));
|
|
97
101
|
else console.log(`Launched Pi runtime for ${required(rest[2], "address")} with ${plan.candidate}.`);
|
|
98
102
|
return;
|
|
99
103
|
}
|
|
100
|
-
throw new Error("Use `pi extension-path` or `pi runtime launch <address> [--pane <asserted-pane>] --herdr-session <session> [--resume] [--dry-run]`.");
|
|
104
|
+
throw new Error("Use `pi extension-path`, `pi runtime authorize-adoption <id> ...`, or `pi runtime launch <address> [--pane <asserted-pane>] --herdr-session <session> [--resume] [--dry-run] [--adopt-session <exact-jsonl> --source-herdr-session <session> --source-pane <pane> --authorization <record-id>]`.");
|
|
101
105
|
},
|
|
102
106
|
application: async () => {
|
|
103
107
|
if (rest[0] === "use") return useApplication(root, required(rest[1], "address"), required(rest[2], "application"));
|
|
@@ -110,6 +114,7 @@ async function main() {
|
|
|
110
114
|
const [subcommand, projectName, taskId, ...tail] = rest;
|
|
111
115
|
if (subcommand === "add") return tasks.add(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
112
116
|
if (subcommand === "assign") return tasks.assign(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
117
|
+
if (subcommand === "transfer") return tasks.transfer(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
113
118
|
if (subcommand === "amend") return tasks.amend(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
114
119
|
if (subcommand === "import") return tasks.importCompleted(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
115
120
|
if (subcommand === "start") return tasks.start(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
@@ -123,7 +128,7 @@ async function main() {
|
|
|
123
128
|
if (subcommand === "unblock") return tasks.unblock(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
124
129
|
if (subcommand === "list") return tasks.list(root, required(projectName, "project"), rest.slice(2));
|
|
125
130
|
if (subcommand === "open") return tasks.open(root, required(projectName, "project"), required(taskId, "task id"));
|
|
126
|
-
throw new Error("Use `atdd-flow task add|assign|amend|import|start|prove|handoff|respond|review|return|done|block|unblock|list|open`.");
|
|
131
|
+
throw new Error("Use `atdd-flow task add|assign|transfer|amend|import|start|prove|handoff|respond|review|return|done|block|unblock|list|open`.");
|
|
127
132
|
},
|
|
128
133
|
cleanup: async () => {
|
|
129
134
|
const [subcommand, projectName, taskId] = rest;
|
package/src/seats.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { mkdir, readdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
5
|
import { discoverAddress, discoverHerdrLocator, notify } from "./adapters";
|
|
5
6
|
import {
|
|
@@ -11,6 +12,7 @@ import { type ModelSelectionInput, type ModelSelectionResponse, selectModel } fr
|
|
|
11
12
|
import { seatTasks } from "./tasks";
|
|
12
13
|
import { projectHerdrSeat } from "./multiplexer";
|
|
13
14
|
import { isRuntimeStateStale, readRuntimeState } from "./runtime-state";
|
|
15
|
+
import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizations";
|
|
14
16
|
|
|
15
17
|
const lifecycleConventionPath = "conventions/atdd-workflow.workflow/atdd-workflow.workflow.lifecycle.convention.yaml";
|
|
16
18
|
|
|
@@ -291,6 +293,15 @@ function startCommand(request: HerdrRequest) {
|
|
|
291
293
|
}
|
|
292
294
|
function receiptPath(root: string, piSession: string) { return join(root, ".atdd-flow", "runtime-launch", `${encodeURIComponent(piSession)}-${crypto.randomUUID().slice(0, 8)}.yaml`); }
|
|
293
295
|
|
|
296
|
+
async function immutableSessionBackup(root: string, sessionPath: string, piSession: string) {
|
|
297
|
+
const content = await readFile(sessionPath);
|
|
298
|
+
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
299
|
+
const backup = join(root, ".atdd-flow", "runtime-launch", "backups", `${encodeURIComponent(piSession)}-${crypto.randomUUID()}.jsonl`);
|
|
300
|
+
await mkdir(dirname(backup), { recursive: true });
|
|
301
|
+
await writeFile(backup, content, { flag: "wx" });
|
|
302
|
+
return { backup, sha256 };
|
|
303
|
+
}
|
|
304
|
+
|
|
294
305
|
/** A deliberately narrow Pi+Herdr launch using only installed CLI commands on an existing shell pane. */
|
|
295
306
|
export async function launchPiRuntime(root: string, address: string, args: string[], dependencies: PiRuntimeLaunchDependencies = {}) {
|
|
296
307
|
const resolved = await canonicalAddress(root, address);
|
|
@@ -300,15 +311,41 @@ export async function launchPiRuntime(root: string, address: string, args: strin
|
|
|
300
311
|
const herdrSession = required(words(args, "--herdr-session"), "--herdr-session");
|
|
301
312
|
const resume = args.includes("--resume");
|
|
302
313
|
const dryRun = args.includes("--dry-run");
|
|
303
|
-
const
|
|
304
|
-
|
|
314
|
+
const adoptedSessionPath = words(args, "--adopt-session");
|
|
315
|
+
const sourceHerdrSession = words(args, "--source-herdr-session");
|
|
316
|
+
const sourcePane = words(args, "--source-pane");
|
|
317
|
+
const authorization = words(args, "--authorization");
|
|
318
|
+
const adoptionFields = [adoptedSessionPath, sourceHerdrSession, sourcePane, authorization];
|
|
319
|
+
const adopting = adoptionFields.some(Boolean);
|
|
320
|
+
const allowed = ["--herdr-session", herdrSession, "--resume", "--dry-run", ...(requestedPane ? ["--pane", requestedPane] : []),
|
|
321
|
+
...(adoptedSessionPath ? ["--adopt-session", adoptedSessionPath] : []), ...(sourceHerdrSession ? ["--source-herdr-session", sourceHerdrSession] : []),
|
|
322
|
+
...(sourcePane ? ["--source-pane", sourcePane] : []), ...(authorization ? ["--authorization", authorization] : [])];
|
|
323
|
+
if (args.some((argument) => !allowed.includes(argument))) throw new Error("Use `pi runtime launch <seat> [--pane <asserted-pane>] --herdr-session <session> [--resume] [--dry-run] [--adopt-session <exact-jsonl> --source-herdr-session <session> --source-pane <pane> --authorization <owner-reference>]`.");
|
|
324
|
+
if (adopting && (resume || !adoptionFields.every(Boolean))) throw new Error("Exact-session adoption requires --adopt-session, --source-herdr-session, --source-pane, and --authorization without --resume.");
|
|
325
|
+
const existingHerdr = record.runtime?.addresses.herdr;
|
|
326
|
+
const existingPane = typeof existingHerdr === "object" ? existingHerdr.pane : undefined;
|
|
327
|
+
const existingSession = typeof existingHerdr === "object" ? existingHerdr.session : undefined;
|
|
328
|
+
if (resume && ((requestedPane && requestedPane !== existingPane) || herdrSession !== existingSession)) {
|
|
329
|
+
throw new Error("Resume cannot relocate a Pi session across a Herdr session or pane; use explicit exact-session adoption.");
|
|
330
|
+
}
|
|
305
331
|
const active = await launchEligibleTasks(root, record);
|
|
306
332
|
if (!active.length) throw new Error(`${resolved} has no launch-eligible task; refusing runtime launch.`);
|
|
307
333
|
const selection = await chooseLaunchModel(root, config, record, required(await modelPortfolio(root), "models.yaml for Pi runtime launch"), dependencies.select);
|
|
308
334
|
const model = resolveModelCommand(config, selection.candidate);
|
|
309
335
|
if (basename(model.agent) !== "pi") throw new Error(`Pi runtime launch requires a Pi candidate, received ${selection.candidate.id}.`);
|
|
310
336
|
const command = dependencies.command ?? runOutput;
|
|
337
|
+
const piSession = adopting ? sessionIdFromPiJsonlPath(required(adoptedSessionPath, "an exact adopted Pi session path")) : resume ? record.runtime?.pi_session : (dependencies.sessionId ?? (() => crypto.randomUUID()))();
|
|
338
|
+
if (!piSession) throw new Error("Resume requires the exact Pi session stored on the seat.");
|
|
339
|
+
const canonicalAdoptedSessionPath = adopting ? resolve(required(adoptedSessionPath, "an exact adopted Pi session path")) : undefined;
|
|
340
|
+
const authorizationRecord = adopting
|
|
341
|
+
? await resolveExactSessionAdoptionAuthorization(root, required(authorization, "an immutable operator authorization record"), {
|
|
342
|
+
seat: resolved, pi_session: piSession, pi_session_path: required(canonicalAdoptedSessionPath, "an exact adopted Pi session path"),
|
|
343
|
+
source: { herdr_session: required(sourceHerdrSession, "a source Herdr session"), pane: required(sourcePane, "a source pane") },
|
|
344
|
+
target: { herdr_session: herdrSession, pane: required(requestedPane, "--pane for exact-session adoption"), cwd: resolve(record.worktree) },
|
|
345
|
+
})
|
|
346
|
+
: undefined;
|
|
311
347
|
const projection = await projectHerdrSeat(root, resolved, herdrSession, command);
|
|
348
|
+
if (resume && projection.pane !== existingPane) throw new Error("Resume cannot relocate a Pi session across a Herdr session or pane; use explicit exact-session adoption.");
|
|
312
349
|
if (requestedPane && requestedPane !== projection.pane) throw new Error("The asserted --pane does not match the seat-scoped Herdr projection.");
|
|
313
350
|
const pane = projection.pane;
|
|
314
351
|
const [paneRaw, processRaw] = await Promise.all([command(herdr(herdrSession, "pane", "get", pane)), command(herdr(herdrSession, "pane", "process-info", "--pane", pane))]);
|
|
@@ -316,8 +353,6 @@ export async function launchPiRuntime(root: string, address: string, args: strin
|
|
|
316
353
|
const currentProcess = processReport(JSON.parse(processRaw));
|
|
317
354
|
if (currentPane.pane !== pane) throw new Error("Herdr reported a different pane; refusing runtime launch.");
|
|
318
355
|
if (currentPane.agent || piProcess(currentProcess.processes)) throw new Error(resume ? "Resume requires the prior Pi to have exited and been released." : "New launch requires an available shell pane.");
|
|
319
|
-
const piSession = resume ? record.runtime?.pi_session : (dependencies.sessionId ?? (() => crypto.randomUUID()))();
|
|
320
|
-
if (!piSession) throw new Error("Resume requires the exact Pi session stored on the seat.");
|
|
321
356
|
if (resume) {
|
|
322
357
|
const prior = record.runtime?.launch_receipt;
|
|
323
358
|
if (!prior) throw new Error("Resume requires an existing Flow launch receipt.");
|
|
@@ -326,10 +361,24 @@ export async function launchPiRuntime(root: string, address: string, args: strin
|
|
|
326
361
|
if (receipt.seat !== resolved || receipt.pi_session !== piSession || receipt.pi_session_path !== record.runtime?.pi_session_path || receipt.herdr_session !== herdrSession || receipt.pane !== pane) throw new Error("receipt does not match seat/session/path/pane");
|
|
327
362
|
} catch (error) { throw new Error(`Resume requires a valid Flow launch receipt: ${(error as Error).message}`); }
|
|
328
363
|
}
|
|
329
|
-
const priorSessionPath = resume ? required(record.runtime?.pi_session_path, "a previously verified Pi session path") : undefined;
|
|
330
|
-
|
|
364
|
+
const priorSessionPath = adopting ? required(canonicalAdoptedSessionPath, "an exact adopted Pi session path") : resume ? required(record.runtime?.pi_session_path, "a previously verified Pi session path") : undefined;
|
|
365
|
+
if (adopting) {
|
|
366
|
+
const [sourcePaneRaw, sourceProcessRaw] = await Promise.all([
|
|
367
|
+
command(herdr(required(sourceHerdrSession, "a source Herdr session"), "pane", "get", required(sourcePane, "a source pane"))),
|
|
368
|
+
command(herdr(required(sourceHerdrSession, "a source Herdr session"), "pane", "process-info", "--pane", required(sourcePane, "a source pane"))),
|
|
369
|
+
]);
|
|
370
|
+
const sourceReport = paneReport(JSON.parse(sourcePaneRaw));
|
|
371
|
+
const sourceProcess = processReport(JSON.parse(sourceProcessRaw));
|
|
372
|
+
if (sourceReport.pane !== sourcePane || sourceReport.agent || piProcess(sourceProcess.processes)) {
|
|
373
|
+
throw new Error("Exact-session adoption requires proof that the source Pi has exited and been released.");
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const request: HerdrRequest = { session: herdrSession, pane, seat: resolved, root, piSession, ...(priorSessionPath ? { sessionPath: priorSessionPath } : {}), resume: resume || adopting, args: [...model.args, "--extension", piExtensionPath()] };
|
|
331
377
|
const plan = { candidate: selection.candidate.id, piSession, pane, herdrSession, receipt: receiptPath(root, piSession), command: startCommand(request), ...(dryRun ? { dryRun: true } : {}) };
|
|
332
378
|
if (dryRun) return plan;
|
|
379
|
+
// The source snapshot is a pre-migration prerequisite: never start a target
|
|
380
|
+
// Pi when its immutable recovery copy cannot be created.
|
|
381
|
+
const relocation = adopting ? await immutableSessionBackup(root, required(canonicalAdoptedSessionPath, "an exact adopted Pi session path"), piSession) : undefined;
|
|
333
382
|
await command(herdr(herdrSession, "pane", "run", pane, `export ATDD_WORKFLOW_ROOT=${shellQuote(root)} ATDD_WORKFLOW_SEAT=${shellQuote(resolved)} ATDD_FLOW_PI_SESSION=${shellQuote(piSession)} ATDD_FLOW_HERDR_SESSION=${shellQuote(herdrSession)} ATDD_FLOW_HERDR_PANE=${shellQuote(pane)}`));
|
|
334
383
|
await command(startCommand(request));
|
|
335
384
|
const [agentRaw, verifiedPaneRaw, verifiedProcessRaw] = await Promise.all([command(herdr(herdrSession, "agent", "get", agentName(resolved))), command(herdr(herdrSession, "pane", "get", pane)), command(herdr(herdrSession, "pane", "process-info", "--pane", pane))]);
|
|
@@ -340,13 +389,15 @@ export async function launchPiRuntime(root: string, address: string, args: strin
|
|
|
340
389
|
if (!sessionPath) throw new Error("Herdr did not report an exact Pi session path; seat binding was not changed.");
|
|
341
390
|
const reportedId = sessionIdFromPiJsonlPath(sessionPath);
|
|
342
391
|
if (reportedId !== piSession) throw new Error("Herdr did not verify the exact requested Pi session ID; seat binding was not changed.");
|
|
343
|
-
if (resume && sessionPath !==
|
|
392
|
+
if ((resume || adopting) && sessionPath !== priorSessionPath) throw new Error("Herdr did not verify the exact requested Pi session path; seat binding was not changed.");
|
|
344
393
|
if (agent.name !== agentName(resolved) || agent.pane !== pane || ![agent.status, verifiedPane.status].every((status) => status === "idle" || status === "done") || verifiedPane.pane !== pane || !verifiedProcess) throw new Error("Herdr did not verify the replacement Pi process; seat binding was not changed.");
|
|
345
394
|
const receipt = plan.receipt;
|
|
346
395
|
const receiptRecord = {
|
|
347
396
|
schema: "atdd-flow/pi-runtime-launch-receipt/v1", seat: resolved, tasks: active.map((entry) => entry.id), candidate: selection.candidate.id,
|
|
348
397
|
selection: selection.fallback ? { result: "fallback", reason: selection.fallback } : { result: "selected", confidence: selection.selection.confidence, ...(selection.selection.available && selection.selection.model ? { model: selection.selection.model } : {}) },
|
|
349
|
-
pi_session: piSession, pi_session_path: sessionPath, herdr_session: herdrSession, pane,
|
|
398
|
+
pi_session: piSession, pi_session_path: sessionPath, herdr_session: herdrSession, pane,
|
|
399
|
+
...(relocation ? { relocation: { source_herdr_session: sourceHerdrSession, source_pane: sourcePane, authorization: authorizationRecord, backup: relocation.backup, backup_sha256: relocation.sha256 } } : {}),
|
|
400
|
+
created_at: dependencies.at?.() ?? now(),
|
|
350
401
|
};
|
|
351
402
|
await mkdir(dirname(receipt), { recursive: true });
|
|
352
403
|
await writeFile(receipt, yaml.print(receiptRecord), { encoding: "utf8", flag: "wx" });
|
package/src/tasks.ts
CHANGED
|
@@ -12,6 +12,16 @@ export type HandoffState = "executing" | "awaiting_coordinator" | "awaiting_assi
|
|
|
12
12
|
export type TaskHandoff = { state: HandoffState; evidence: string; updated_at: string };
|
|
13
13
|
export type DoneWhen = { text: string; proof?: string };
|
|
14
14
|
export type GovernedBase = { coordinator: string; branch: string; commit: string };
|
|
15
|
+
/** An append-only accountability decision; prior coordinator evidence remains untouched. */
|
|
16
|
+
export type CoordinatorTransfer = {
|
|
17
|
+
from: string;
|
|
18
|
+
to: string;
|
|
19
|
+
reason: string;
|
|
20
|
+
/** Required when main executes the transfer; references an operator@desk message. */
|
|
21
|
+
authorization?: string;
|
|
22
|
+
exact_head: { branch: string; commit: string };
|
|
23
|
+
effective_at: string;
|
|
24
|
+
};
|
|
15
25
|
export type Task = {
|
|
16
26
|
schema: "atdd-workflow/task/v1";
|
|
17
27
|
title: string;
|
|
@@ -28,6 +38,8 @@ export type Task = {
|
|
|
28
38
|
handoff?: TaskHandoff;
|
|
29
39
|
/** Exact named-coordinator or main head from which an assigned driver is governed. */
|
|
30
40
|
governed_base?: GovernedBase;
|
|
41
|
+
/** Absent on legacy tasks; later entries never rewrite earlier coordinator evidence. */
|
|
42
|
+
coordinator_transfers?: CoordinatorTransfer[];
|
|
31
43
|
done_when: DoneWhen[];
|
|
32
44
|
};
|
|
33
45
|
|
|
@@ -40,9 +52,29 @@ const transitions: Record<TaskStatus, TaskStatus[]> = {
|
|
|
40
52
|
done: [],
|
|
41
53
|
};
|
|
42
54
|
|
|
55
|
+
function assertCoordinatorIntegrity(task: Task, id: string) {
|
|
56
|
+
const transfers = task.coordinator_transfers;
|
|
57
|
+
if (transfers && !Array.isArray(transfers)) throw new Error(`Task ${id} has invalid immutable transfer provenance.`);
|
|
58
|
+
const latest = transfers?.at(-1);
|
|
59
|
+
if (latest) {
|
|
60
|
+
if (!latest.from || !latest.to || !latest.reason || !latest.effective_at || !latest.exact_head?.branch || !latest.exact_head.commit) {
|
|
61
|
+
throw new Error(`Task ${id} has invalid immutable transfer provenance.`);
|
|
62
|
+
}
|
|
63
|
+
if (task.coordinator !== latest.to || !task.governed_base
|
|
64
|
+
|| task.governed_base.coordinator !== latest.to
|
|
65
|
+
|| task.governed_base.branch !== latest.exact_head.branch
|
|
66
|
+
|| task.governed_base.commit !== latest.exact_head.commit) {
|
|
67
|
+
throw new Error(`Task ${id} coordinator is not backed by immutable transfer provenance.`);
|
|
68
|
+
}
|
|
69
|
+
} else if (task.governed_base && task.governed_base.coordinator !== task.coordinator) {
|
|
70
|
+
throw new Error(`Task ${id} coordinator is not backed by immutable transfer provenance.`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
43
74
|
async function readTask(root: string, projectName: string, id: string) {
|
|
44
75
|
const record = await readYaml<Task>(paths(root).taskFile(projectName, id));
|
|
45
76
|
if (record.schema !== "atdd-workflow/task/v1") throw new Error(`Unsupported task schema: ${id}`);
|
|
77
|
+
assertCoordinatorIntegrity(record, id);
|
|
46
78
|
return record;
|
|
47
79
|
}
|
|
48
80
|
|
|
@@ -182,6 +214,92 @@ export async function assign(root: string, projectName: string, id: string, args
|
|
|
182
214
|
console.log(`${id} assigned ${assignee}`);
|
|
183
215
|
}
|
|
184
216
|
|
|
217
|
+
type TransferAuthorization = {
|
|
218
|
+
schema: "atdd-workflow/task-transfer-authorization/v1";
|
|
219
|
+
project: string;
|
|
220
|
+
task: string;
|
|
221
|
+
from: string;
|
|
222
|
+
to: string;
|
|
223
|
+
reason: string;
|
|
224
|
+
exact_head: { branch: string; commit: string };
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
async function operatorAuthorization(root: string, reference: string, main: string, expected: TransferAuthorization) {
|
|
228
|
+
type AuthorizationMessage = { schema?: string; id?: string; from?: string; to?: "all" | string[]; authorization?: TransferAuthorization };
|
|
229
|
+
type AuthorizationThread = { schema?: string; participants?: string[] };
|
|
230
|
+
const folders = await readdir(paths(root).threads, { withFileTypes: true });
|
|
231
|
+
const matches = (await Promise.all(folders.filter((entry) => entry.isDirectory() && entry.name.startsWith("T-"))
|
|
232
|
+
.map(async (entry) => {
|
|
233
|
+
try {
|
|
234
|
+
const [thread, message] = await Promise.all([
|
|
235
|
+
readYaml<AuthorizationThread>(paths(root).threadFile(entry.name)),
|
|
236
|
+
readYaml<AuthorizationMessage>(paths(root).message(entry.name, reference)),
|
|
237
|
+
]);
|
|
238
|
+
return { thread, message };
|
|
239
|
+
} catch { return undefined; }
|
|
240
|
+
}))).filter((entry): entry is NonNullable<typeof entry> =>
|
|
241
|
+
entry?.thread.schema === "atdd-workflow/thread/v1" && entry.thread.participants?.includes(main) === true
|
|
242
|
+
&& entry.message.schema === "atdd-workflow/message/v1" && entry.message.id === reference);
|
|
243
|
+
if (matches.length !== 1 || matches[0]?.message.from !== "operator@desk") {
|
|
244
|
+
throw new Error(`Authorization ${reference} is not an owner-authorized topology plan.`);
|
|
245
|
+
}
|
|
246
|
+
if (matches[0].message.to !== "all" && !matches[0].message.to?.includes(main)) {
|
|
247
|
+
throw new Error(`Authorization ${reference} is not applicable to ${main}.`);
|
|
248
|
+
}
|
|
249
|
+
const authorization = matches[0].message.authorization;
|
|
250
|
+
if (!authorization || authorization.schema !== expected.schema
|
|
251
|
+
|| authorization.project !== expected.project || authorization.task !== expected.task
|
|
252
|
+
|| authorization.from !== expected.from || authorization.to !== expected.to
|
|
253
|
+
|| authorization.reason !== expected.reason
|
|
254
|
+
|| authorization.exact_head?.branch !== expected.exact_head.branch
|
|
255
|
+
|| authorization.exact_head.commit !== expected.exact_head.commit) {
|
|
256
|
+
throw new Error(`Authorization ${reference} is not an exact transfer authorization.`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Changes only the current task accountability projection. The record is
|
|
262
|
+
* append-only and validates the target's existing topology; it never creates,
|
|
263
|
+
* moves, cleans, or binds a seat/worktree.
|
|
264
|
+
*/
|
|
265
|
+
export async function transfer(root: string, projectName: string, id: string, args: string[]) {
|
|
266
|
+
const task = await readTask(root, projectName, taskId(id));
|
|
267
|
+
const config = await project(root, projectName);
|
|
268
|
+
const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
|
|
269
|
+
const target = await canonicalAddress(root, required(words(args, "--to"), "--to"));
|
|
270
|
+
const main = `main@${config.project}`;
|
|
271
|
+
if (target === task.coordinator) throw new Error(`Task ${id} is already accountable to ${target}.`);
|
|
272
|
+
if (actor !== main && actor !== task.coordinator) throw new Error(`Only ${main} or the current coordinator may transfer task ${id}.`);
|
|
273
|
+
|
|
274
|
+
// governedBase confirms main/named-coordinator identity, exact branch head,
|
|
275
|
+
// and the driver's descendant relationship before accountability changes.
|
|
276
|
+
const base = await governedBase(root, projectName, target, task.assignee);
|
|
277
|
+
if (!base) throw new Error(`Task ${id} needs an assigned driver and governed repository before transfer.`);
|
|
278
|
+
const authorization = actor === main ? required(words(args, "--authorization"), "an owner-authorized topology plan") : undefined;
|
|
279
|
+
if (authorization) await operatorAuthorization(root, authorization, main, {
|
|
280
|
+
schema: "atdd-workflow/task-transfer-authorization/v1",
|
|
281
|
+
project: config.project,
|
|
282
|
+
task: id,
|
|
283
|
+
from: task.coordinator,
|
|
284
|
+
to: target,
|
|
285
|
+
reason: required(words(args, "--reason"), "--reason"),
|
|
286
|
+
exact_head: { branch: base.branch, commit: base.commit },
|
|
287
|
+
});
|
|
288
|
+
const transfer: CoordinatorTransfer = {
|
|
289
|
+
from: task.coordinator,
|
|
290
|
+
to: target,
|
|
291
|
+
reason: required(words(args, "--reason"), "--reason"),
|
|
292
|
+
...(authorization ? { authorization } : {}),
|
|
293
|
+
exact_head: { branch: base.branch, commit: base.commit },
|
|
294
|
+
effective_at: now(),
|
|
295
|
+
};
|
|
296
|
+
task.coordinator = target;
|
|
297
|
+
task.governed_base = base;
|
|
298
|
+
task.coordinator_transfers = [...(task.coordinator_transfers ?? []), transfer];
|
|
299
|
+
await writeTask(root, projectName, taskId(id), task);
|
|
300
|
+
console.log(`${id} coordinator transferred ${transfer.from} -> ${transfer.to}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
185
303
|
export async function amend(root: string, projectName: string, id: string, args: string[]) {
|
|
186
304
|
const task = await readTask(root, projectName, taskId(id));
|
|
187
305
|
const title = words(args, "--title");
|
package/src/threads.ts
CHANGED
|
@@ -8,6 +8,15 @@ import {
|
|
|
8
8
|
|
|
9
9
|
export type Thread = { schema: string; id: string; subject: string; participants: string[]; state: "open" | "closed"; summary?: string; task?: string };
|
|
10
10
|
export type Kind = "message" | "receipt" | "result";
|
|
11
|
+
export type TransferAuthorization = {
|
|
12
|
+
schema: "atdd-workflow/task-transfer-authorization/v1";
|
|
13
|
+
project: string;
|
|
14
|
+
task: string;
|
|
15
|
+
from: string;
|
|
16
|
+
to: string;
|
|
17
|
+
reason: string;
|
|
18
|
+
exact_head: { branch: string; commit: string };
|
|
19
|
+
};
|
|
11
20
|
export type Message = {
|
|
12
21
|
schema: string;
|
|
13
22
|
id: string;
|
|
@@ -18,6 +27,8 @@ export type Message = {
|
|
|
18
27
|
expects_result?: boolean;
|
|
19
28
|
created_at: string;
|
|
20
29
|
body: string;
|
|
30
|
+
/** Immutable owner authorization for one exact coordinator-transfer tuple. */
|
|
31
|
+
authorization?: TransferAuthorization;
|
|
21
32
|
};
|
|
22
33
|
|
|
23
34
|
async function thread(root: string, threadId: string) {
|
|
@@ -212,6 +223,21 @@ async function inject(root: string, address: string, message: Message, threadId:
|
|
|
212
223
|
catch (error) { console.warn(`Notification for ${address} was not delivered: ${(error as Error).message}`); }
|
|
213
224
|
}
|
|
214
225
|
|
|
226
|
+
function transferAuthorization(args: string[], from: string) {
|
|
227
|
+
const encoded = words(args, "--task-transfer-authorization");
|
|
228
|
+
if (!encoded) return undefined;
|
|
229
|
+
if (from !== "operator@desk") throw new Error("Only operator@desk may create a task transfer authorization.");
|
|
230
|
+
let authorization: unknown;
|
|
231
|
+
try { authorization = JSON.parse(encoded); }
|
|
232
|
+
catch { throw new Error("--task-transfer-authorization must be valid JSON."); }
|
|
233
|
+
const value = authorization as Partial<TransferAuthorization>;
|
|
234
|
+
if (!value || value.schema !== "atdd-workflow/task-transfer-authorization/v1"
|
|
235
|
+
|| ![value.project, value.task, value.from, value.to, value.reason, value.exact_head?.branch, value.exact_head?.commit].every((entry) => typeof entry === "string" && entry.length)) {
|
|
236
|
+
throw new Error("--task-transfer-authorization must contain one exact task-transfer-authorization/v1 tuple.");
|
|
237
|
+
}
|
|
238
|
+
return value as TransferAuthorization;
|
|
239
|
+
}
|
|
240
|
+
|
|
215
241
|
async function post(root: string, threadId: string, args: string[], overrides: Partial<Message> = {}) {
|
|
216
242
|
const record = await thread(root, threadId);
|
|
217
243
|
const from = await canonicalAddress(root, required(overrides.from ?? words(args, "--from"), "--from"));
|
|
@@ -221,11 +247,13 @@ async function post(root: string, threadId: string, args: string[], overrides: P
|
|
|
221
247
|
if (recipients.some((address) => !record.participants.includes(address))) throw new Error("Recipients must be thread participants.");
|
|
222
248
|
await assertRoute(root, from, recipients.filter((address) => address !== from));
|
|
223
249
|
const kind = overrides.kind ?? "message";
|
|
250
|
+
const authorization = overrides.authorization ?? transferAuthorization(args, from);
|
|
224
251
|
// Labels are an explicit, non-sensitive operator hint; message bodies never enter durable IDs.
|
|
225
252
|
const label = words(args, "--label") ?? kind;
|
|
226
253
|
const message: Message = {
|
|
227
254
|
schema: "atdd-workflow/message/v1", id: id("M", label), from, to: toValue === "all" ? "all" : recipients,
|
|
228
255
|
kind,
|
|
256
|
+
...(authorization ? { authorization } : {}),
|
|
229
257
|
...(overrides.in_reply_to ? { in_reply_to: overrides.in_reply_to } : {}),
|
|
230
258
|
...(overrides.expects_result || has(args, "--expects-result") ? { expects_result: true } : {}),
|
|
231
259
|
created_at: now(), body: required(overrides.body ?? words(args, "--body"), "--body"),
|