@afokapu/atdd-flow 0.3.66 → 0.3.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -288,6 +288,18 @@ coordinator to move `review → in_progress`. `ESCALATE` leaves the task in revi
288
288
  may explicitly block it) while authoritative intent is resolved. Only the coordinator can transition
289
289
  `review → done`.
290
290
 
291
+ ## Optional Linear one-way mirror
292
+
293
+ Flow can project an explicitly allowlisted task or message to Linear while keeping Flow authoritative. The default is a deterministic dry run; a live projection additionally requires an owner-accepted pilot reference in the config, the exact same `--authorization` argument, and `LINEAR_API_KEY` in the process environment. It has no inbound command or webhook path.
294
+
295
+ ```sh
296
+ atdd-flow linear mirror task demo delivery --config /secure/local/linear-mirror.yaml
297
+ atdd-flow linear mirror task demo delivery --config /secure/local/linear-mirror.yaml \
298
+ --apply --authorization owner-approved-pilot
299
+ ```
300
+
301
+ Mappings and body-free immutable receipts are written below the Desk's `.atdd-flow/linear-mirror/`; retrying reuses a canonical Flow ID instead of creating another Linear object. See [the discovery report and owner-reviewable pilot plan](docs/linear-one-way-mirror-pilot.adoc) for the allowlist, redaction, routing, status-map, and rollback contract. Never commit a Linear token or config containing one.
302
+
291
303
  ## Optional Jev helper
292
304
 
293
305
  Jev is read-only; it cannot mutate state, approve proof, or override ATDD Bun.
@@ -0,0 +1,7 @@
1
+ = Flow documentation
2
+ :doc-id: purpose.index
3
+ :status: current
4
+
5
+ Authored documentation for ATDD Flow.
6
+
7
+ * xref:linear-one-way-mirror-pilot.adoc[Linear one-way mirror: discovery and pilot plan]
@@ -0,0 +1,70 @@
1
+ = Linear one-way mirror: discovery and pilot plan
2
+ :doc-id: purpose.linear-one-way-mirror-pilot
3
+ :status: current
4
+
5
+ == Read-only discovery — 2026-10-10
6
+
7
+ No Linear write was made. The workspace was inspected through read-only team, project, and issue listing calls; no token, credential, issue body, or comment body is retained here.
8
+
9
+ The only active non-deprecated candidate discovered for an owner-approved pilot is:
10
+
11
+ * *Team:* `Forge` / key `FOS` / ID `ae91eacd-4cc0-4924-bb1e-4e4fa718d8ca`
12
+ * *Project:* `Play and Run v0.1.0` / MCP-surface identifier `P-FOS-25` / status `In Progress`
13
+
14
+ IMPORTANT: `P-FOS-25` is the identifier the read-only listing tool displayed; it is *not* a verified Linear project UUID. Linear's issue filter and `issueCreate` need the real UUID. The mirror therefore refuses `authorize` and `--apply` unless the team, project, and every status mapping are UUIDs. Owner/main decision needed before any pilot: confirm the project (and the Forge workflow-state UUIDs) through a read-only lookup and record the UUIDs in the local config. Until then the pilot stays dry-run only; no live write is possible.
15
+
16
+ This is a proposed routing target, not authorization to write. The former `FRG` team is explicitly marked `(Deprecated)` and is excluded.
17
+
18
+ == Projection contract
19
+
20
+ Flow is authoritative. A task has the canonical key `flow:task:<project>/<task-id>`; a message has `flow:message:<thread-id>/<message-id>`. The mirror adds that canonical key as an HTML marker on its Linear object and stores an append-only local mapping to the Linear ID. It does not read a Linear mutation into Flow, parse inbound webhooks, expose an inbound command, or write any Flow task/thread/message from Linear data.
21
+
22
+ Only explicitly allowlisted Flow projects, task IDs (when supplied), and fields can leave Flow. `title` and `status` are required for a task. `body` and `comment` are opt-in. Redaction is fail-closed and applies to titles and bodies: credential assignments (`secret`, `token`, `password`, `api key`, `authorization`), Bearer values, GitHub tokens (`ghp_`, `gho_`, `ghs_`, `github_pat_`), secret keys (`sk_`, `sk-`), Linear, Slack and AWS credentials, JSON web tokens, private-key blocks, any URL, any email address, and text that looks like a mirror marker are refused before a Linear call. Nothing is partially redacted or rewritten. Unsupported fields are refused rather than silently broadened.
23
+
24
+ Every attempt writes a body-free immutable receipt below the Desk's `.atdd-flow/linear-mirror/evidence/`. The receipt records canonical ID, idempotency key, route, operation, state mapping, and outcome, but never a Linear token or projected content. Canonical mappings are write-once. Our marker is always the final line of the remote description or comment; marker text elsewhere in a body is ignored. Issue lookup is scoped to the authorized team and project (server-side filter plus a client-side check), and a result outside that route is refused. Comment lookup walks every page of the issue's comments, so a long issue cannot hide an existing projection. Replays first look up the marker and then update the existing object; a retry cannot bind the Flow object to another Linear object. A missing marker on a mapped issue is recorded as drift and fails closed.
25
+
26
+ == Exact planned status mapping
27
+
28
+ The pilot config must use the Forge team's actual Linear workflow-state IDs (not guessed display labels) and preserve this deterministic mapping:
29
+
30
+ [cols="1,2",options="header"]
31
+ |===
32
+ |Flow task state |Linear Forge workflow state
33
+ |`todo` |`Backlog`
34
+ |`in_progress` |`In Progress`
35
+ |`review` |`In Progress` (Flow remains the review authority)
36
+ |`done` |`Completed`
37
+ |===
38
+
39
+ The current read-only discovery did not retrieve workflow-state UUIDs. An approved pilot operator must insert the exact existing IDs into the config before an `--apply`; an empty mapping is rejected before any write.
40
+
41
+ == Owner-reviewable pilot
42
+
43
+ . The owner explicitly accepts this pilot's exact `FOS` / verified-project-UUID route by creating a write-once `operator@desk` authorization record (only after the UUIDs above are verified) from the local config (`atdd-flow linear mirror authorize --config ... --id LMA-... --by operator@desk`). The immutable record binds the complete route, task subset, fields, and status map; a config string cannot authorize a write.
44
+ . Scope is exactly the Flow project `atdd-flow`, initially *only* task `linear-one-way-mirror-contract`; no historical bulk sync and no inbound handling.
45
+ . Run `atdd-flow linear mirror task ... --config <local-config>` without `--apply`. Inspect the durable dry-run receipt and destination payload plan.
46
+ . With a separate Linear credential in `LINEAR_API_KEY`, run the same command with `--apply --authorization <the-exact-accepted-reference>`. The CLI rejects a missing or mismatched reference. The secret stays only in process environment.
47
+ . Verify the Linear issue has its canonical marker, repeat the command to verify idempotency, and project at most one safe Flow message as a comment. Verify all receipts and mapping IDs.
48
+ . If a marker is missing, a route changes, a sensitive value is encountered, or any operation fails, disable by omitting `--apply`/removing the credential. There is no compensating Flow mutation and no remote-delete action. Rollback is therefore *disable the mirror*, preserve receipts, and investigate drift.
49
+
50
+ Example local config (do not commit a credential):
51
+
52
+ [source,yaml]
53
+ ----
54
+ schema: atdd-flow/linear-mirror-config/v1
55
+ routing:
56
+ team_id: ae91eacd-4cc0-4924-bb1e-4e4fa718d8ca
57
+ project_id: <verified Play and Run project UUID>
58
+ # team_id, project_id and every state must be Linear UUIDs for --apply
59
+ states:
60
+ todo: <Forge Backlog workflow-state UUID>
61
+ in_progress: <Forge In Progress workflow-state UUID>
62
+ review: <Forge In Progress workflow-state UUID>
63
+ done: <Forge Completed workflow-state UUID>
64
+ allow:
65
+ projects: [atdd-flow]
66
+ task_ids: [linear-one-way-mirror-contract]
67
+ fields: [title, status, body, comment]
68
+ ----
69
+
70
+ No live write happens merely by adding this file. `--apply`, a matching immutable `LMA-...` authorization from the Desk, and `LINEAR_API_KEY` are all required.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.66",
3
+ "version": "0.3.68",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  "files": [
15
15
  "bin",
16
16
  "conventions",
17
+ "docs",
17
18
  "extensions",
18
19
  "src",
19
20
  "README.md",
package/src/cli.ts CHANGED
@@ -17,6 +17,7 @@ import * as reviews from "./reviews";
17
17
  import { status } from "./overview";
18
18
  import { multiplexer } from "./multiplexer";
19
19
  import * as cleanup from "./ephemeral-resources";
20
+ import { linearCommand } from "./linear-cli";
20
21
  import * as ownerAlerts from "./owner-escalations";
21
22
  import { recoveryFor } from "./recovery-bundle";
22
23
  import { reconcileOnce } from "./reconcile";
@@ -33,7 +34,7 @@ Usage:
33
34
  atdd-flow desk migrate
34
35
  atdd-flow project init <project>
35
36
  atdd-flow project configure <project> [--vcs <git|jj>] [--repository <primary-checkout>] [--worktree-root <directory>]
36
- atdd-flow project migrate-vcs <project> --to jj [--dry-run]
37
+ atdd-flow project migrate-vcs <project> --to jj [--allow-dirty-primary] [--dry-run]
37
38
  atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>
38
39
  atdd-flow spawn <project> <role> <name> [--worktree <path>] [--branch <branch>] [--agent <legacy-executable>]
39
40
  atdd-flow bind <address> [--application <application>] --address <native-address> [--session <name>] [--agent <executable>] [--worktree <path>] [--wake host|native]
@@ -69,6 +70,9 @@ Usage:
69
70
  atdd-flow cleanup status <project>
70
71
  atdd-flow cleanup checklist <project> <task-id>
71
72
  atdd-flow reconcile --once [--dry-run]
73
+ atdd-flow linear mirror authorize --config <file> --id <LMA-id> --by operator@desk
74
+ atdd-flow linear mirror task <project> <task-id> --config <file> [--apply --authorization <LMA-id>]
75
+ atdd-flow linear mirror message <project> <task-id> <thread-id> <message-id> --config <file> [--apply --authorization <LMA-id>]
72
76
  atdd-flow owner-alert scan --by <coordinator-or-main> [--re-notify-after-ms <milliseconds>]
73
77
  atdd-flow owner-alert deliver|acknowledge|execute|resolve <alert-id> --by <address> [--note <text>|--option <id>|--reason <text>]
74
78
  atdd-flow thread start --with <address,...> --subject <text> [--task <project/task-id>]
@@ -111,7 +115,7 @@ async function main() {
111
115
  if (rest[0] === "configure") return configureProject(root, required(rest[1], "project"), rest.slice(2));
112
116
  if (rest[0] === "migrate-vcs") return migrateProjectVcs(root, required(rest[1], "project"), rest.slice(2));
113
117
  if (rest[0] === "migrate-named-coordinator-topology") return migrateNamedCoordinatorTopology(root, required(rest[1], "project"), rest.slice(2));
114
- throw new Error("Use `atdd-flow project init <project>`, `atdd-flow project configure <project> [--vcs <git|jj>] [--repository <primary-checkout>] [--worktree-root <directory>]`, `atdd-flow project migrate-vcs <project> --to jj [--dry-run]`, or `atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>`.");
118
+ throw new Error("Use `atdd-flow project init <project>`, `atdd-flow project configure <project> [--vcs <git|jj>] [--repository <primary-checkout>] [--worktree-root <directory>]`, `atdd-flow project migrate-vcs <project> --to jj [--allow-dirty-primary] [--dry-run]`, or `atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>`.");
115
119
  },
116
120
  spawn: () => spawn(root, required(rest[0], "project"), required(rest[1], "role"), required(rest[2], "name"), rest.slice(3)),
117
121
  bind: () => bind(root, required(rest[0], "address"), rest.slice(1)),
@@ -177,6 +181,7 @@ async function main() {
177
181
  process.stdout.write(Bun.YAML.stringify(await reconcileOnce(root, { dryRun: rest.includes("--dry-run") })));
178
182
  return;
179
183
  },
184
+ linear: () => linearCommand(root, rest),
180
185
  "owner-alert": async () => {
181
186
  const [subcommand, alertId, ...tail] = rest;
182
187
  if (subcommand === "scan") {
@@ -0,0 +1,53 @@
1
+ // URN: component:linear-mirror:outbound:LinearCli:backend:application
2
+ // Tested-By:
3
+ // - test:linear-mirror:outbound:E001-LINEAR-001-one-way-mirror-fails-closed
4
+ // Runtime: bun
5
+ // Purpose: Command handler for `atdd-flow linear mirror` dry-run and gated apply
6
+ //
7
+ // Reads Flow records only to project them outward; nothing here writes Flow.
8
+ import { has, paths, readYaml, required, words, yaml } from "./core";
9
+ import { linearGraphql } from "./linear-graphql";
10
+ import { authorizeLiveProjection, mirrorMessage, mirrorTask, readMirrorConfig, type MirrorConfig } from "./linear-mirror";
11
+ import type { Task } from "./tasks";
12
+ import type { Message } from "./threads";
13
+
14
+ const usage = "Use `atdd-flow linear mirror authorize|task|message ...`.";
15
+ const emit = (value: unknown) => process.stdout.write(`${yaml.print(value)}\n`);
16
+
17
+ type Run = { root: string; config: MirrorConfig; project: string; task: string; apply: boolean; authorization?: string };
18
+
19
+ /** Projects one Flow task outward; the dry run is the default and needs no credential. */
20
+ async function prepare(run: Run) {
21
+ const linear = linearGraphql(run.apply ? required(process.env.LINEAR_API_KEY, "LINEAR_API_KEY") : "dry-run");
22
+ const task = await readYaml<Task>(paths(run.root).taskFile(run.project, run.task));
23
+ const source = { canonical_id: `flow:task:${run.project}/${run.task}`, project: run.project, task_id: run.task, title: task.title, status: task.status, ...(task.body ? { body: task.body } : {}) };
24
+ const options = { apply: run.apply, ...(run.authorization ? { authorization: run.authorization } : {}) };
25
+ return { linear, source, options };
26
+ }
27
+
28
+ /** Handles `linear mirror authorize|task|message`. */
29
+ export async function linearCommand(root: string, rest: string[]) {
30
+ if (rest[0] !== "mirror") throw new Error(usage);
31
+ if (rest[1] === "authorize") {
32
+ const tail = rest.slice(2);
33
+ const config = await readMirrorConfig(required(words(tail, "--config"), "--config"));
34
+ return emit(await authorizeLiveProjection(root, config, { id: required(words(tail, "--id"), "--id"), by: required(words(tail, "--by"), "--by") }));
35
+ }
36
+ if (!["task", "message"].includes(rest[1] ?? "")) throw new Error(usage);
37
+ const kind = rest[1];
38
+ const tail = rest.slice(kind === "task" ? 4 : 6);
39
+ const apply = has(tail, "--apply");
40
+ const authorization = words(tail, "--authorization");
41
+ if (apply && !authorization) throw new Error("Live Linear writes require an immutable operator@desk authorization id.");
42
+ const run: Run = {
43
+ root, config: await readMirrorConfig(required(words(tail, "--config"), "--config")), apply,
44
+ project: required(rest[2], "project"), task: required(rest[3], "task id"), ...(authorization ? { authorization } : {}),
45
+ };
46
+ const { linear, source, options } = await prepare(run);
47
+ if (kind === "task") return emit(await mirrorTask(root, run.config, source, linear, options));
48
+ const threadId = required(rest[4], "thread id");
49
+ const messageId = required(rest[5], "message id");
50
+ const message = await readYaml<Message>(paths(root).message(threadId, messageId));
51
+ const projection = { canonical_id: `flow:message:${threadId}/${messageId}`, task_canonical_id: source.canonical_id, project: run.project, task_id: run.task, thread_id: threadId, message_id: messageId, body: message.body };
52
+ return emit(await mirrorMessage(root, run.config, projection, linear, options));
53
+ }
@@ -0,0 +1,103 @@
1
+ // URN: component:linear-mirror:outbound:LinearGraphql:backend:integration
2
+ // Tested-By:
3
+ // - test:linear-mirror:outbound:E001-LINEAR-001-one-way-mirror-fails-closed
4
+ // Runtime: bun
5
+ // Purpose: Typed outbound-only Linear GraphQL edge for the one-way mirror
6
+ //
7
+ // The edge only ever issues the five mirror operations. The token is read once
8
+ // by the caller and is never stored in config, mappings, receipts, or output.
9
+ import { endsWithMarker, markerFor, type OutboundLinear } from "./linear-mirror";
10
+
11
+ type GraphqlResponse<T> = { data?: T; errors?: Array<{ message?: string }> };
12
+ type RemoteNode = { id: string; description?: string; state?: { id?: string }; team?: { id?: string }; project?: { id?: string } };
13
+ type Page<N> = { nodes: N[]; pageInfo: { hasNextPage: boolean; endCursor?: string | null } };
14
+ type Execute = <T>(query: string, variables: Record<string, unknown>) => Promise<T>;
15
+
16
+ /** Safety bound: a mirror that cannot find its marker in this many pages fails closed instead of guessing. */
17
+ const maxPages = 100;
18
+
19
+ /** Issues one GraphQL request and fails closed on any transport or API error. */
20
+ function executor(apiKey: string, endpoint: string) {
21
+ return async <T>(query: string, variables: Record<string, unknown>) => {
22
+ const response = await fetch(endpoint, {
23
+ method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` }, body: JSON.stringify({ query, variables }),
24
+ });
25
+ const value = await response.json() as GraphqlResponse<T>;
26
+ const reason = value.errors?.map((entry) => entry.message).join("; ") || response.statusText;
27
+ if (!response.ok || value.errors?.length || !value.data) throw new Error(`Linear GraphQL refused outbound projection: ${reason}`);
28
+ return value.data;
29
+ };
30
+ }
31
+
32
+ /** Normalizes a Linear node into the mirror's remote-issue shape. */
33
+ const remote = (value: RemoteNode) => ({
34
+ id: value.id, marker: value.description ?? "", state_id: value.state?.id ?? "",
35
+ ...(value.team?.id ? { team_id: value.team.id } : {}), ...(value.project?.id ? { project_id: value.project.id } : {}),
36
+ });
37
+
38
+ /** Walks every page; the first matching node wins, and exhausting the bound fails closed. */
39
+ async function firstMatch<N>(fetchPage: (cursor: string | null) => Promise<Page<N> | undefined>, matches: (node: N) => boolean) {
40
+ let cursor: string | null = null;
41
+ for (let pages = 0; pages < maxPages; pages += 1) {
42
+ const page: Page<N> | undefined = await fetchPage(cursor);
43
+ const found = page?.nodes.find(matches);
44
+ if (found || !page?.pageInfo.hasNextPage || !page.pageInfo.endCursor) return found;
45
+ cursor = page.pageInfo.endCursor;
46
+ }
47
+ throw new Error("Linear lookup exceeded its page bound; refusing to guess.");
48
+ }
49
+
50
+ /** Scoped to the authorized team/project; the marker must be the final line of the description. */
51
+ async function findScopedIssue(execute: Execute, key: string, route: { team_id: string; project_id: string }) {
52
+ const marker = markerFor(key);
53
+ const found = await firstMatch(async (cursor) => (await execute<{ issues: Page<RemoteNode> }>(queryIssues,
54
+ { marker, teamId: route.team_id, projectId: route.project_id, cursor })).issues,
55
+ (node) => endsWithMarker(node.description, marker) && node.team?.id === route.team_id && node.project?.id === route.project_id);
56
+ return found ? remote(found) : undefined;
57
+ }
58
+
59
+ /** Paginates the whole comment history so a long issue cannot hide an existing projection. */
60
+ function findKeyedComment(execute: Execute, issueId: string, key: string) {
61
+ const marker = markerFor(key);
62
+ return firstMatch(async (cursor) => (await execute<{ issue: { comments: Page<{ id: string; body?: string }> } | null }>(queryComments, { id: issueId, cursor })).issue?.comments,
63
+ (node) => endsWithMarker(node.body, marker));
64
+ }
65
+
66
+ const queryIssues = `query MirrorIssue($marker: String!, $teamId: ID!, $projectId: ID!, $cursor: String) { issues(first: 50, after: $cursor, filter: { team: { id: { eq: $teamId } }, project: { id: { eq: $projectId } }, description: { contains: $marker } }) { nodes { id description state { id } team { id } project { id } } pageInfo { hasNextPage endCursor } } }`;
67
+ const createIssueMutation = `mutation CreateMirrorIssue($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id description state { id } team { id } project { id } } } }`;
68
+ const updateIssueMutation = `mutation UpdateMirrorIssue($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success issue { id } } }`;
69
+ const queryComments = `query MirrorComment($id: String!, $cursor: String) { issue(id: $id) { comments(first: 100, after: $cursor) { nodes { id body } pageInfo { hasNextPage endCursor } } } }`;
70
+ const createCommentMutation = `mutation CreateMirrorComment($input: CommentCreateInput!) { commentCreate(input: $input) { success comment { id } } }`;
71
+ const inspectIssueQuery = `query InspectMirrorIssue($id: String!) { issue(id: $id) { id description state { id } team { id } project { id } } }`;
72
+
73
+ /** Builds the outbound adapter; there is deliberately no Linear-to-Flow read path here. */
74
+ export function linearGraphql(apiKey: string, endpoint = "https://api.linear.app/graphql"): OutboundLinear {
75
+ if (!apiKey.trim()) throw new Error("LINEAR_API_KEY is required for a live Linear projection.");
76
+ const execute = executor(apiKey, endpoint);
77
+ return {
78
+ findIssueByKey: (key, route) => findScopedIssue(execute, key, route),
79
+ async createIssue(input) {
80
+ const data = await execute<{ issueCreate: { success: boolean; issue?: RemoteNode } }>(createIssueMutation,
81
+ { input: { teamId: input.team_id, projectId: input.project_id, title: input.title, description: input.description, stateId: input.state_id } });
82
+ if (!data.issueCreate.success || !data.issueCreate.issue) throw new Error("Linear did not confirm issue creation.");
83
+ return { ...remote(data.issueCreate.issue), marker: input.marker };
84
+ },
85
+ async updateIssue(input) {
86
+ const data = await execute<{ issueUpdate: { success: boolean; issue?: { id: string } } }>(updateIssueMutation,
87
+ { id: input.id, input: { title: input.title, description: input.description, stateId: input.state_id } });
88
+ if (!data.issueUpdate.success || !data.issueUpdate.issue) throw new Error("Linear did not confirm issue update.");
89
+ return data.issueUpdate.issue;
90
+ },
91
+ findCommentByKey: (issue_id, key) => findKeyedComment(execute, issue_id, key),
92
+ async createComment(input) {
93
+ const data = await execute<{ commentCreate: { success: boolean; comment?: { id: string } } }>(createCommentMutation,
94
+ { input: { issueId: input.issue_id, body: input.body } });
95
+ if (!data.commentCreate.success || !data.commentCreate.comment) throw new Error("Linear did not confirm comment creation.");
96
+ return data.commentCreate.comment;
97
+ },
98
+ async inspectIssue(id) {
99
+ const data = await execute<{ issue: RemoteNode | null }>(inspectIssueQuery, { id });
100
+ return data.issue ? remote(data.issue) : undefined;
101
+ },
102
+ };
103
+ }
@@ -0,0 +1,332 @@
1
+ // URN: component:linear-mirror:outbound:LinearMirror:backend:domain
2
+ // Tested-By:
3
+ // - test:linear-mirror:outbound:E001-LINEAR-001-one-way-mirror-fails-closed
4
+ // Runtime: bun
5
+ // Purpose: Fail-closed outbound-only Flow-to-Linear mirror with immutable evidence
6
+ //
7
+ // Flow stays authoritative. Every projection is allowlisted, redacted, keyed by
8
+ // a canonical Flow id, and recorded as write-once evidence. Live writes also need
9
+ // a write-once operator@desk authorization bound to the exact projection tuple.
10
+ import { createHash, randomUUID } from "node:crypto";
11
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { now, yaml } from "./core";
14
+ import type { TaskStatus } from "./tasks";
15
+
16
+ /** Deliberately outbound-only Linear projection. Flow records never accept Linear mutations. */
17
+ export type MirrorConfig = {
18
+ schema: "atdd-flow/linear-mirror-config/v1";
19
+ routing: { team_id: string; project_id: string; states: Record<TaskStatus, string> };
20
+ allow: { projects: string[]; task_ids?: string[]; fields: Array<"title" | "status" | "body" | "comment"> };
21
+ };
22
+
23
+ type ProjectionTuple = { team_id: string; project_id: string; states: Record<TaskStatus, string>; projects: string[]; task_ids: string[]; fields: string[] };
24
+ type LiveAuthorization = { schema: "atdd-flow/linear-mirror-authorization/v1"; id: string; immutable: true; by: "operator@desk"; accepted_at: string; projection: ProjectionTuple };
25
+
26
+ export type TaskProjection = {
27
+ canonical_id: string;
28
+ project: string;
29
+ task_id: string;
30
+ title: string;
31
+ status: TaskStatus;
32
+ body?: string;
33
+ /** Fields Flow does not project must be named explicitly and are refused. */
34
+ unsupported?: string[];
35
+ };
36
+
37
+ /** The Flow project and task a projected record belongs to. */
38
+ type Scope = { project: string; task_id: string };
39
+
40
+ export type MessageProjection = Scope & {
41
+ canonical_id: string;
42
+ task_canonical_id: string;
43
+ thread_id: string;
44
+ message_id: string;
45
+ body: string;
46
+ unsupported?: string[];
47
+ };
48
+
49
+ /** `team_id`/`project_id` are reported when the remote knows them so a lookup can be checked against the route. */
50
+ type RemoteIssue = { id: string; marker: string; state_id: string; team_id?: string; project_id?: string };
51
+ type Route = { team_id: string; project_id: string };
52
+ export type OutboundLinear = {
53
+ /** Lookup by the canonical Flow key, scoped to the authorized team/project, makes retry-after-partial-write safe. */
54
+ findIssueByKey(key: string, route: Route): Promise<RemoteIssue | undefined>;
55
+ createIssue(input: { idempotency_key: string; team_id: string; project_id: string; title: string; description: string; state_id: string; marker: string }): Promise<RemoteIssue>;
56
+ updateIssue(input: { id: string; title: string; description: string; state_id: string; marker: string }): Promise<{ id: string }>;
57
+ /** Lookup prevents a retry after a completed remote comment from creating a duplicate. */
58
+ findCommentByKey(issue_id: string, key: string): Promise<{ id: string } | undefined>;
59
+ createComment(input: { issue_id: string; body: string; idempotency_key: string; marker: string }): Promise<{ id: string }>;
60
+ /** Optional read-only inspection records remote drift but never changes Flow. */
61
+ inspectIssue?(id: string): Promise<RemoteIssue | undefined>;
62
+ };
63
+
64
+ export type MirrorResult = { mode: "dry-run" | "live"; operation: "create" | "update" | "comment"; idempotency_key: string; receipt: string };
65
+ type Mapping = { schema: "atdd-flow/linear-mirror-mapping/v1"; canonical_id: string; idempotency_key: string; linear_id: string; created_at: string; immutable: true };
66
+ type Evidence = {
67
+ schema: "atdd-flow/linear-mirror-evidence/v1"; immutable: true; at: string; mode: "dry-run" | "live";
68
+ operation: MirrorResult["operation"]; outcome: "planned" | "authorized" | "projected" | "drift" | "refused";
69
+ canonical_id: string; idempotency_key: string; route: { team_id: string; project_id: string }; state_id?: string; linear_id?: string; authorization?: { id: string; content_hash: string }; reason?: string;
70
+ };
71
+
72
+ const rootFor = (root: string) => join(root, ".atdd-flow", "linear-mirror");
73
+ const authorizationFile = (root: string, id: string) => join(rootFor(root), "authorizations", `${id}.yaml`);
74
+ const authorizationId = (id: string) => {
75
+ if (!/^LMA-[A-Za-z0-9_-]+$/.test(id)) throw new Error("Linear authorization id must start with LMA- and contain only letters, numbers, underscores, or hyphens.");
76
+ return id;
77
+ };
78
+ const projectionTuple = (config: MirrorConfig): ProjectionTuple => ({
79
+ team_id: config.routing.team_id, project_id: config.routing.project_id, states: config.routing.states,
80
+ projects: [...config.allow.projects].sort(), task_ids: [...(config.allow.task_ids ?? [])].sort(), fields: [...config.allow.fields].sort(),
81
+ });
82
+ const sameProjection = (left: ProjectionTuple, right: ProjectionTuple) => JSON.stringify(left) === JSON.stringify(right);
83
+ const safeName = (value: string) => createHash("sha256").update(value).digest("hex");
84
+ const mappingFile = (root: string, canonicalId: string) => join(rootFor(root), "mappings", `${safeName(canonicalId)}.yaml`);
85
+ export const markerFor = (canonicalId: string) => `<!-- atdd-flow:canonical=${canonicalId} -->`;
86
+ /** Our marker is always the final line; marker text pasted elsewhere in a remote body never identifies an object. */
87
+ export const endsWithMarker = (text: string | undefined, marker: string) => {
88
+ const lines = (text ?? "").trimEnd().split("\n");
89
+ return lines[lines.length - 1]?.trim() === marker;
90
+ };
91
+ const markerPrefix = "atdd-flow:canonical=";
92
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
93
+ /** Fail-closed redaction: credential-looking text, URLs, and email addresses never leave Flow. */
94
+ const sensitivePatterns: Array<[string, RegExp]> = [
95
+ ["credential assignment", /(?:api[_ -]?key|password|passwd|secret|token|authorization)\s*[:=]/i],
96
+ ["bearer token", /bearer\s+[a-z0-9._~+/=-]+/i],
97
+ ["GitHub token", /\b(?:gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{16,})/],
98
+ ["secret key", /\b(?:sk|rk|pk)[-_](?:live|test|proj|ant)?[-_]?[A-Za-z0-9]{16,}/],
99
+ ["Linear/Slack/AWS credential", /\b(?:lin_(?:api|oauth)_[A-Za-z0-9]{16,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})/],
100
+ ["JSON web token", /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\./],
101
+ ["private key", /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
102
+ ["URL", /\b[a-z][a-z0-9+.-]*:\/\/\S+/i],
103
+ ["email address", /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/],
104
+ ["marker text", new RegExp(markerPrefix)],
105
+ ];
106
+ const sensitiveKind = (value: string) => sensitivePatterns.find(([, pattern]) => pattern.test(value))?.[0];
107
+
108
+ async function mapping(root: string, canonicalId: string) {
109
+ try { return yaml.parse<Mapping>(await readFile(mappingFile(root, canonicalId), "utf8")); }
110
+ catch { return undefined; }
111
+ }
112
+
113
+ /** Mapping records are append-only: a canonical Flow record can never be rebound to another Linear object. */
114
+ async function writeMapping(root: string, value: Mapping) {
115
+ const file = mappingFile(root, value.canonical_id);
116
+ await mkdir(join(rootFor(root), "mappings"), { recursive: true });
117
+ try { await writeFile(file, yaml.print(value), { encoding: "utf8", flag: "wx" }); }
118
+ catch (error) {
119
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
120
+ const prior = await mapping(root, value.canonical_id);
121
+ if (!prior || prior.linear_id !== value.linear_id || prior.idempotency_key !== value.idempotency_key) {
122
+ throw new Error(`Canonical Flow id ${value.canonical_id} is already mapped inconsistently.`);
123
+ }
124
+ }
125
+ }
126
+
127
+ let receiptSequence = 0;
128
+
129
+ /** Evidence has no source body, token, or mutable filename; it is safe to retain in the Desk. */
130
+ async function receipt(root: string, value: Evidence) {
131
+ const folder = join(rootFor(root), "evidence");
132
+ await mkdir(folder, { recursive: true });
133
+ // The sequence keeps same-millisecond receipts in write order; the UUID keeps other processes distinct.
134
+ receiptSequence += 1;
135
+ const file = join(folder, `${now().replace(/[^0-9]/g, "")}-${String(receiptSequence).padStart(6, "0")}-${randomUUID()}.yaml`);
136
+ await writeFile(file, yaml.print(value), { encoding: "utf8", flag: "wx" });
137
+ return file;
138
+ }
139
+
140
+ const blank = (value?: string) => !value?.trim();
141
+ type Candidate = { project?: string; task_id?: string; canonical_id: string; unsupported?: string[]; title?: string; body?: string };
142
+
143
+ /** The route and the complete status mapping must be explicit before anything leaves Flow. */
144
+ function assertRouting(config: MirrorConfig) {
145
+ if (config.schema !== "atdd-flow/linear-mirror-config/v1") throw new Error("Unsupported Linear mirror configuration.");
146
+ const route = config.routing;
147
+ if (blank(route?.team_id) || blank(route?.project_id) || !route?.states) throw new Error("Linear mirror routing requires exact team_id, project_id, and status mapping.");
148
+ for (const state of ["todo", "in_progress", "review", "done"] as const) {
149
+ if (blank(route.states[state])) throw new Error(`Linear mirror routing has no mapping for ${state}.`);
150
+ }
151
+ }
152
+
153
+ /** Only an explicitly allowlisted Flow project (and task, when listed) may be projected. */
154
+ function assertScope(config: MirrorConfig, item: Candidate) {
155
+ const allow = config.allow;
156
+ if (!Array.isArray(allow?.projects) || !allow.projects.includes(item.project ?? "")) throw new Error(`Flow project ${item.project ?? ""} is not allowlisted for Linear projection.`);
157
+ if (item.task_id && allow.task_ids && !allow.task_ids.includes(item.task_id)) throw new Error(`Flow task ${item.task_id} is not allowlisted for Linear projection.`);
158
+ }
159
+
160
+ /** Unsupported or non-allowlisted fields are refused instead of silently broadened. */
161
+ function assertFields(config: MirrorConfig, item: Candidate, comments: boolean) {
162
+ const fields = config.allow.fields;
163
+ if (item.unsupported?.length) throw new Error(`Unsupported Flow fields are not projectable: ${item.unsupported.join(", ")}.`);
164
+ if (comments) {
165
+ if (!fields.includes("comment")) throw new Error("Linear comment projection is not allowlisted.");
166
+ } else if (!fields.includes("title") || !fields.includes("status")) {
167
+ throw new Error("Linear task projection requires title and status allowlist entries.");
168
+ }
169
+ }
170
+
171
+ /** Every refusal happens before any Linear call; sensitive-looking text never leaves Flow. */
172
+ function refuse(config: MirrorConfig, item: Candidate, comments = false) {
173
+ assertRouting(config);
174
+ assertScope(config, item);
175
+ assertFields(config, item, comments);
176
+ for (const value of [item.title, item.body]) {
177
+ const kind = value ? sensitiveKind(value) : undefined;
178
+ if (kind) throw new Error(`Sensitive content (${kind}) was refused by the Linear redaction policy.`);
179
+ }
180
+ }
181
+
182
+ function taskPayload(config: MirrorConfig, item: TaskProjection) {
183
+ const marker = markerFor(item.canonical_id);
184
+ const description = config.allow.fields.includes("body") && item.body ? `${item.body}\n\n${marker}` : marker;
185
+ return { marker, description, state_id: config.routing.states[item.status] };
186
+ }
187
+
188
+ /** Only the human authority can create a write-once approval bound to this exact projection tuple. */
189
+ export async function authorizeLiveProjection(root: string, config: MirrorConfig, input: { id: string; by: string }) {
190
+ if (input.by !== "operator@desk") throw new Error("Only operator@desk may authorize a live Linear projection.");
191
+ refuse(config, { canonical_id: "authorization", project: config.allow.projects[0] ?? "" });
192
+ assertLiveRoute(config);
193
+ const id = authorizationId(input.id);
194
+ const record: LiveAuthorization = { schema: "atdd-flow/linear-mirror-authorization/v1", id, immutable: true, by: "operator@desk", accepted_at: now(), projection: projectionTuple(config) };
195
+ await mkdir(join(rootFor(root), "authorizations"), { recursive: true });
196
+ try { await writeFile(authorizationFile(root, id), yaml.print(record), { encoding: "utf8", flag: "wx" }); }
197
+ catch (error) {
198
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
199
+ const prior = yaml.parse<LiveAuthorization>(await readFile(authorizationFile(root, id), "utf8"));
200
+ if (prior.schema !== record.schema || prior.id !== id || prior.immutable !== true || prior.by !== "operator@desk" || !sameProjection(prior.projection, record.projection)) throw new Error(`Live Linear authorization ${id} already exists with a different or mutable approval.`);
201
+ }
202
+ return authorizationFile(root, id);
203
+ }
204
+
205
+ /** A live write needs Linear's real identifiers; display keys or labels cannot scope a lookup. */
206
+ function assertLiveRoute(config: MirrorConfig) {
207
+ const route = config.routing;
208
+ const bad = [["team_id", route.team_id], ["project_id", route.project_id], ...Object.entries(route.states).map(([state, value]) => [`states.${state}`, value])]
209
+ .filter(([, value]) => !uuid.test(value ?? "")).map(([name]) => name);
210
+ if (bad.length) throw new Error(`Live Linear routing requires verified Linear UUIDs; not a UUID: ${bad.join(", ")}.`);
211
+ }
212
+
213
+ type ResolvedMode = { mode: "dry-run" } | { mode: "live"; authorization: { id: string; content_hash: string } };
214
+
215
+ async function mode(root: string, config: MirrorConfig, options?: { apply?: boolean; authorization?: string }): Promise<ResolvedMode> {
216
+ if (!options?.apply) return { mode: "dry-run" };
217
+ const id = authorizationId(options.authorization ?? "");
218
+ let source: string;
219
+ let record: LiveAuthorization;
220
+ try { source = await readFile(authorizationFile(root, id), "utf8"); record = yaml.parse<LiveAuthorization>(source); }
221
+ catch { throw new Error(`Live Linear authorization ${id} is absent from the immutable Desk authorization store.`); }
222
+ if (record.schema !== "atdd-flow/linear-mirror-authorization/v1" || record.id !== id || record.immutable !== true || record.by !== "operator@desk" || !sameProjection(record.projection, projectionTuple(config))) {
223
+ throw new Error(`Live Linear authorization ${id} does not bind operator@desk to this exact route and projection allowlist.`);
224
+ }
225
+ assertLiveRoute(config);
226
+ return { mode: "live", authorization: { id, content_hash: createHash("sha256").update(source).digest("hex") } };
227
+ }
228
+
229
+ async function recordRefusal(root: string, config: MirrorConfig, canonical_id: string, key: string, reason: string) {
230
+ return receipt(root, {
231
+ schema: "atdd-flow/linear-mirror-evidence/v1", immutable: true, at: now(), mode: "live", operation: "create", outcome: "refused",
232
+ canonical_id, idempotency_key: key, route: { team_id: config.routing?.team_id ?? "", project_id: config.routing?.project_id ?? "" }, reason,
233
+ });
234
+ }
235
+
236
+ /** A remote that reports a team or project different from the authorized route is out of scope. */
237
+ const outsideRoute = (remote: RemoteIssue, route: Route) =>
238
+ (remote.team_id ?? route.team_id) !== route.team_id || (remote.project_id ?? route.project_id) !== route.project_id;
239
+
240
+ /** A retry after a partial remote write adopts the existing issue instead of creating a duplicate. */
241
+ async function priorIssue(root: string, key: string, linear: OutboundLinear, selectedMode: "dry-run" | "live", route: Route) {
242
+ const prior = await mapping(root, key);
243
+ if (prior || selectedMode !== "live") return prior;
244
+ const remote = await linear.findIssueByKey(key, route);
245
+ if (!remote) return undefined;
246
+ // A lookup result outside the authorized route or without our trailing marker must never be bound.
247
+ if (outsideRoute(remote, route) || !endsWithMarker(remote.marker, markerFor(key))) throw new Error(`Linear lookup for ${key} returned an object outside the authorized route or without its canonical marker.`);
248
+ await writeMapping(root, { schema: "atdd-flow/linear-mirror-mapping/v1", canonical_id: key, idempotency_key: key, linear_id: remote.id, created_at: now(), immutable: true });
249
+ return mapping(root, key);
250
+ }
251
+
252
+ /** Projects one allowlisted Flow task: dry-run by default, authorized create/update when applied. */
253
+ export async function mirrorTask(root: string, config: MirrorConfig, item: TaskProjection, linear: OutboundLinear, options?: { apply?: boolean; authorization?: string }): Promise<MirrorResult> {
254
+ const key = item.canonical_id;
255
+ try { refuse(config, item); }
256
+ catch (error) { await recordRefusal(root, config, key, key, (error as Error).message); throw error; }
257
+ let selected: ResolvedMode;
258
+ try { selected = await mode(root, config, options); }
259
+ catch (error) { await recordRefusal(root, config, key, key, (error as Error).message); throw error; }
260
+ const selectedMode = selected.mode;
261
+ const payload = taskPayload(config, item);
262
+ const prior = await priorIssue(root, key, linear, selectedMode, config.routing);
263
+ const operation: MirrorResult["operation"] = prior ? "update" : "create";
264
+ const evidence = (outcome: Evidence["outcome"], extra: Partial<Evidence> = {}) => receipt(root, {
265
+ schema: "atdd-flow/linear-mirror-evidence/v1", immutable: true, at: now(), mode: selectedMode, operation, outcome,
266
+ canonical_id: key, idempotency_key: key, route: { team_id: config.routing.team_id, project_id: config.routing.project_id }, state_id: payload.state_id,
267
+ ...(selected.mode === "live" ? { authorization: selected.authorization } : {}), ...extra,
268
+ });
269
+ if (selectedMode === "dry-run") return { mode: selectedMode, operation, idempotency_key: key, receipt: await evidence("planned") };
270
+
271
+ if (!prior) {
272
+ await evidence("authorized");
273
+ const remote = await linear.createIssue({ idempotency_key: key, team_id: config.routing.team_id, project_id: config.routing.project_id, title: item.title, description: payload.description, state_id: payload.state_id, marker: payload.marker });
274
+ await writeMapping(root, { schema: "atdd-flow/linear-mirror-mapping/v1", canonical_id: key, idempotency_key: key, linear_id: remote.id, created_at: now(), immutable: true });
275
+ return { mode: selectedMode, operation, idempotency_key: key, receipt: await evidence("projected", { linear_id: remote.id }) };
276
+ }
277
+
278
+ if (linear.inspectIssue) {
279
+ const remote = await linear.inspectIssue(prior.linear_id);
280
+ if (!remote || outsideRoute(remote, config.routing) || !endsWithMarker(remote.marker, payload.marker)) {
281
+ const file = await evidence("drift", { linear_id: prior.linear_id, reason: "Mapped Linear issue lacks its canonical Flow marker or left the authorized route." });
282
+ throw new Error(`Linear drift detected for ${key}; receipt: ${file}`);
283
+ }
284
+ }
285
+ await evidence("authorized");
286
+ await linear.updateIssue({ id: prior.linear_id, title: item.title, description: payload.description, state_id: payload.state_id, marker: payload.marker });
287
+ return { mode: selectedMode, operation, idempotency_key: key, receipt: await evidence("projected", { linear_id: prior.linear_id }) };
288
+ }
289
+
290
+ /** Messages can only become outbound comments on an already mapped Flow task. */
291
+ export async function mirrorMessage(root: string, config: MirrorConfig, item: MessageProjection, linear: OutboundLinear, options?: { apply?: boolean; authorization?: string }): Promise<MirrorResult> {
292
+ const key = item.canonical_id;
293
+ try { refuse(config, { project: item.project, task_id: item.task_id, canonical_id: key, body: item.body, unsupported: item.unsupported }, true); }
294
+ catch (error) { await recordRefusal(root, config, key, key, (error as Error).message); throw error; }
295
+ let selected: ResolvedMode;
296
+ try { selected = await mode(root, config, options); }
297
+ catch (error) { await recordRefusal(root, config, key, key, (error as Error).message); throw error; }
298
+ const selectedMode = selected.mode;
299
+ const parent = await mapping(root, item.task_canonical_id);
300
+ if (!parent) throw new Error(`Cannot project ${key}: its authoritative Flow task has no Linear mapping.`);
301
+ const marker = markerFor(key);
302
+ const folderReceipt = async (outcome: Evidence["outcome"], linear_id?: string) => receipt(root, {
303
+ schema: "atdd-flow/linear-mirror-evidence/v1", immutable: true, at: now(), mode: selectedMode, operation: "comment", outcome,
304
+ canonical_id: key, idempotency_key: key, route: { team_id: config.routing.team_id, project_id: config.routing.project_id }, linear_id,
305
+ ...(selected.mode === "live" ? { authorization: selected.authorization } : {}),
306
+ });
307
+ if (selectedMode === "dry-run") return { mode: selectedMode, operation: "comment", idempotency_key: key, receipt: await folderReceipt("planned", parent.linear_id) };
308
+ // This receipt precedes both remote lookup and creation, so a failed lookup
309
+ // leaves immutable operator authorization evidence without an external write.
310
+ await folderReceipt("authorized", parent.linear_id);
311
+ let existing = await mapping(root, key);
312
+ if (!existing) {
313
+ const remote = await linear.findCommentByKey(parent.linear_id, key);
314
+ if (remote) {
315
+ await writeMapping(root, { schema: "atdd-flow/linear-mirror-mapping/v1", canonical_id: key, idempotency_key: key, linear_id: remote.id, created_at: now(), immutable: true });
316
+ existing = await mapping(root, key);
317
+ }
318
+ }
319
+ if (existing) return { mode: selectedMode, operation: "comment", idempotency_key: key, receipt: await folderReceipt("projected", existing.linear_id) };
320
+ const remote = await linear.createComment({ issue_id: parent.linear_id, body: `${item.body}\n\n${marker}`, idempotency_key: key, marker });
321
+ await writeMapping(root, { schema: "atdd-flow/linear-mirror-mapping/v1", canonical_id: key, idempotency_key: key, linear_id: remote.id, created_at: now(), immutable: true });
322
+ return { mode: selectedMode, operation: "comment", idempotency_key: key, receipt: await folderReceipt("projected", remote.id) };
323
+ }
324
+
325
+ /** There is intentionally no parser or writer from Linear to Flow. */
326
+ export async function applyInboundChange(_root: string, _change: unknown): Promise<never> {
327
+ throw new Error("Linear mirror is one-way: inbound Linear changes are refused and cannot mutate Flow.");
328
+ }
329
+
330
+ export async function readMirrorConfig(file: string) {
331
+ return yaml.parse<MirrorConfig>(await readFile(file, "utf8"));
332
+ }
@@ -1,6 +1,7 @@
1
1
  // URN: component:flow:jj-vcs-migration:vcs-migration:backend:application
2
2
  // Tested-By:
3
3
  // - test:flow:jj-vcs-migration:E013-UNIT-001
4
+ // - test:flow:jj-vcs-migration:E014-UNIT-001
4
5
  // Runtime: bun
5
6
  // Purpose: Migrate one legacy Flow Git project to colocated JJ without losing lineage.
6
7
  //
@@ -19,8 +20,9 @@
19
20
  // Rollback uses the captured branch, commit, and path tuple rather than inferring
20
21
  // state from a partial JJ conversion. The durable project VCS is written last.
21
22
 
22
- import { readdir, realpath, rm } from "node:fs/promises";
23
+ import { lstat, readFile, readdir, readlink, realpath, rename, rm, writeFile } from "node:fs/promises";
23
24
  import { join, resolve } from "node:path";
25
+ import { createHash } from "node:crypto";
24
26
  import {
25
27
  type Project, type Seat, atomicYaml, exists, paths, project, readYaml, required, yaml,
26
28
  } from "./core";
@@ -32,6 +34,16 @@ type GitWorktree = { path: string; branch?: string };
32
34
  type TaskLite = { assignee?: string; status?: string; governed_base?: { commit?: string } };
33
35
  type ReviewsLite = { attempts?: Array<{ delivery_head?: string }> };
34
36
 
37
+ /** JJ silently skips new files above this size (snapshot.max-new-file-size default). */
38
+ const SNAPSHOT_MAX_NEW_FILE_BYTES = 1048576;
39
+ const DIRTY_PRIMARY_FLAG = "--allow-dirty-primary";
40
+
41
+ type DiskState =
42
+ | { kind: "absent" }
43
+ | { kind: "file"; sha256: string; exec: boolean }
44
+ | { kind: "symlink"; target: string };
45
+ type DirtyEntry = { path: string; status: string; disk: DiskState };
46
+
35
47
  export type VcsMigrationPlan = {
36
48
  schema: "atdd-flow/vcs-migration-plan/v1";
37
49
  project: string;
@@ -40,6 +52,7 @@ export type VcsMigrationPlan = {
40
52
  primary: SeatState;
41
53
  workspaces: SeatState[];
42
54
  lineage: { tasks_checked: number; reviews_checked: number };
55
+ dirty_primary?: { entries: Array<{ path: string; status: string }> };
43
56
  };
44
57
 
45
58
  type PreparedMigration = {
@@ -47,6 +60,8 @@ type PreparedMigration = {
47
60
  repository: string;
48
61
  states: SeatState[];
49
62
  plan: VcsMigrationPlan;
63
+ dirty: DirtyEntry[];
64
+ index: Buffer | null;
50
65
  };
51
66
 
52
67
  /** Run one external VCS command without throwing so rollback can inspect failures. */
@@ -97,19 +112,58 @@ async function activeSeats(root: string, name: string) {
97
112
  return records.filter(record => !record.retired);
98
113
  }
99
114
 
115
+ /** Read one working-copy path exactly as JJ will snapshot it, without following symlinks. */
116
+ async function diskState(worktree: string, path: string): Promise<DiskState> {
117
+ const full = join(worktree, path);
118
+ const info = await lstat(full).catch(() => null);
119
+ if (!info) return { kind: "absent" };
120
+ if (info.isSymbolicLink()) return { kind: "symlink", target: await readlink(full) };
121
+ if (info.isDirectory()) throw new Error(`Dirty primary path ${path} is a nested repository or gitlink; JJ cannot retain it.`);
122
+ const sha256 = createHash("sha256").update(await readFile(full)).digest("hex");
123
+ return { kind: "file", sha256, exec: (info.mode & 0o111) !== 0 };
124
+ }
125
+
126
+ /** Capture the primary's uncommitted paths read-only, refusing anything JJ would drop. */
127
+ async function captureDirtyPrimary(worktree: string): Promise<DirtyEntry[]> {
128
+ // Raw, untrimmed: the first record's leading status space is significant.
129
+ const status = await Bun.spawn(["git", "status", "--porcelain=v1", "-z", "--untracked-files=all", "--no-renames"], { cwd: worktree, stdout: "pipe", stderr: "pipe" });
130
+ const [raw, failure, code] = await Promise.all([new Response(status.stdout).text(), new Response(status.stderr).text(), status.exited]);
131
+ if (code !== 0) throw new Error(`git status failed: ${failure.trim()}`);
132
+ const entries: DirtyEntry[] = [];
133
+ for (const record of raw.split("\0").filter(Boolean)) {
134
+ const flags = record.slice(0, 2), path = record.slice(3);
135
+ const disk = await diskState(worktree, path);
136
+ const isNew = flags.includes("?") || flags.includes("A");
137
+ if (isNew && disk.kind === "file") {
138
+ const size = (await lstat(join(worktree, path))).size;
139
+ if (size > SNAPSHOT_MAX_NEW_FILE_BYTES) {
140
+ throw new Error(`Dirty primary file ${path} (${size} bytes) exceeds the JJ snapshot size limit of ${SNAPSHOT_MAX_NEW_FILE_BYTES} bytes and would not be retained; commit it, move it, or ignore it first.`);
141
+ }
142
+ }
143
+ entries.push({ path, status: flags, disk });
144
+ }
145
+ return entries;
146
+ }
147
+
100
148
  /** Capture the exact clean Git tuple that migration must preserve or parent. */
101
- async function inspectSeat(repository: string, record: Seat): Promise<SeatState> {
149
+ async function inspectSeat(repository: string, record: Seat, allowDirtyPrimary: boolean): Promise<{ state: SeatState; dirty: DirtyEntry[] }> {
102
150
  const worktree = await realpath(resolve(record.worktree)).catch(() => null);
103
151
  if (!worktree) throw new Error(`Seat ${record.address} worktree does not exist: ${record.worktree}`);
104
152
  const top = await realpath(await output(worktree, "git", ["rev-parse", "--show-toplevel"]));
105
153
  if (top !== worktree) throw new Error(`Seat ${record.address} is not at a Git worktree root: ${worktree}`);
106
- if (await output(worktree, "git", ["status", "--porcelain"])) throw new Error(`Seat ${record.address} worktree is dirty: ${worktree}`);
154
+ const primary = worktree === repository;
155
+ let dirty: DirtyEntry[] = [];
156
+ if (await output(worktree, "git", ["status", "--porcelain"])) {
157
+ if (!primary) throw new Error(`Seat ${record.address} worktree is dirty: ${worktree}`);
158
+ if (!allowDirtyPrimary) throw new Error(`Seat ${record.address} worktree is dirty: ${worktree}. Pass ${DIRTY_PRIMARY_FLAG} to retain the primary checkout's uncommitted changes as JJ working-copy state.`);
159
+ dirty = await captureDirtyPrimary(worktree);
160
+ }
107
161
  const branch = await output(worktree, "git", ["branch", "--show-current"]);
108
162
  if (branch !== record.branch) throw new Error(`Seat ${record.address} is checked out at ${branch || "detached HEAD"}, not ${record.branch}.`);
109
163
  const commit = await output(worktree, "git", ["rev-parse", "HEAD"]);
110
164
  const ref = await output(repository, "git", ["rev-parse", "--verify", `refs/heads/${record.branch}^{commit}`]);
111
165
  if (commit !== ref) throw new Error(`Seat ${record.address} HEAD does not match branch ${record.branch}.`);
112
- return { address: record.address, branch: record.branch, worktree, commit, primary: worktree === repository };
166
+ return { state: { address: record.address, branch: record.branch, worktree, commit, primary }, dirty };
113
167
  }
114
168
 
115
169
  /** Parse Git's porcelain registry rather than trusting directory existence alone. */
@@ -195,7 +249,7 @@ async function assertLineage(root: string, name: string, repository: string, sta
195
249
  }
196
250
 
197
251
  /** Build the complete deterministic migration plan before creating .jj metadata. */
198
- async function prepare(root: string, name: string): Promise<PreparedMigration> {
252
+ async function prepare(root: string, name: string, allowDirtyPrimary: boolean): Promise<PreparedMigration> {
199
253
  const config = await project(root, name);
200
254
  if (vcsMode(config) !== "git") throw new Error(`Project ${name} is not a Git project.`);
201
255
  const repository = await realpath(resolve(required(config.repository, `project ${name} repository`))).catch(() => null);
@@ -205,7 +259,9 @@ async function prepare(root: string, name: string): Promise<PreparedMigration> {
205
259
  await assertToolVersions(repository);
206
260
  const records = await activeSeats(root, name);
207
261
  if (!records.length) throw new Error("Project has no active seats; configure vcs: jj directly before creating seats.");
208
- const states = await Promise.all(records.map(record => inspectSeat(repository, record)));
262
+ const inspected = await Promise.all(records.map(record => inspectSeat(repository, record, allowDirtyPrimary)));
263
+ const states = inspected.map(entry => entry.state);
264
+ const dirty = inspected.flatMap(entry => entry.dirty);
209
265
  const primary = states.filter(state => state.primary);
210
266
  if (primary.length !== 1) throw new Error(`Migration requires exactly one active seat in the primary checkout; found ${primary.length}.`);
211
267
  if (new Set(states.map(state => state.worktree)).size !== states.length) throw new Error("Active Flow seats must have distinct worktree paths before migration.");
@@ -213,15 +269,61 @@ async function prepare(root: string, name: string): Promise<PreparedMigration> {
213
269
  await assertWorktreeRegistry(repository, states);
214
270
  const lineage = await assertLineage(root, name, repository, states);
215
271
  const workspaces = states.filter(state => !state.primary);
216
- return { config, repository, states, plan: { schema: "atdd-flow/vcs-migration-plan/v1", project: name, from: "git", to: "jj", primary: primary[0]!, workspaces, lineage } };
272
+ const index = await readIndex(repository);
273
+ const plan: VcsMigrationPlan = { schema: "atdd-flow/vcs-migration-plan/v1", project: name, from: "git", to: "jj", primary: primary[0]!, workspaces, lineage };
274
+ if (dirty.length) plan.dirty_primary = { entries: dirty.map(({ path, status }) => ({ path, status })) };
275
+ return { config, repository, states, plan, dirty, index };
217
276
  }
218
277
 
219
278
  /** The primary bookmark is intentionally not advanced during migration. */
220
- async function verifyPrimary(state: SeatState) {
279
+ async function verifyPrimary(state: SeatState, dirty: DirtyEntry[]) {
280
+ if (dirty.length) return verifyDirtyPrimary(state, dirty);
221
281
  const ready = await assertBranchReady("jj", state.worktree, state.branch);
222
282
  if (ready !== state.commit) throw new Error(`Primary bookmark ${state.branch} changed during JJ migration.`);
223
283
  }
224
284
 
285
+ /** Read the Git tree entry for one path in the JJ working-copy commit. */
286
+ async function treeEntry(state: SeatState, head: string, path: string) {
287
+ const listed = await output(state.worktree, "git", ["ls-tree", head, "--", path]);
288
+ if (!listed) return null;
289
+ const [meta] = listed.split("\t");
290
+ const [mode, , blob] = (meta ?? "").split(" ");
291
+ return { mode: mode!, blob: blob! };
292
+ }
293
+
294
+ /** True when the tree entry is exactly the captured disk state (content and mode). */
295
+ async function entryMatches(state: SeatState, entry: DirtyEntry, found: { mode: string; blob: string }) {
296
+ const disk = entry.disk;
297
+ if (disk.kind === "symlink") {
298
+ return found.mode === "120000" && await output(state.worktree, "git", ["cat-file", "blob", found.blob]) === disk.target;
299
+ }
300
+ if (disk.kind !== "file") return false;
301
+ const hashed = await output(state.worktree, "git", ["hash-object", "--no-filters", "--", entry.path]);
302
+ return found.mode === (disk.exec ? "100755" : "100644") && found.blob === hashed;
303
+ }
304
+
305
+ /** Compare one retained path in the JJ working-copy commit against the captured disk state. */
306
+ async function verifyDirtyPath(state: SeatState, head: string, entry: DirtyEntry) {
307
+ const now = await diskState(state.worktree, entry.path);
308
+ if (JSON.stringify(now) !== JSON.stringify(entry.disk)) throw new Error(`Primary path ${entry.path} changed on disk during JJ migration.`);
309
+ const found = await treeEntry(state, head, entry.path);
310
+ const retained = entry.disk.kind === "absent" ? found === null : found !== null && await entryMatches(state, entry, found);
311
+ if (!retained) throw new Error(`Primary path ${entry.path} was not retained exactly in the JJ working copy.`);
312
+ }
313
+
314
+ /** A dirty primary must be exactly `main` plus the captured manifest, nothing more. */
315
+ async function verifyDirtyPrimary(state: SeatState, dirty: DirtyEntry[]) {
316
+ const bookmarked = await branchCommit("jj", state.worktree, state.branch);
317
+ const parent = await output(state.worktree, "jj", ["log", "-r", "@-", "--no-graph", "-T", "commit_id"]);
318
+ if (bookmarked !== state.commit || parent !== state.commit) throw new Error(`Primary bookmark ${state.branch} changed during JJ migration.`);
319
+ const head = await output(state.worktree, "jj", ["log", "-r", "@", "--no-graph", "-T", "commit_id"]);
320
+ const changed = (await output(state.worktree, "git", ["diff-tree", "-r", "-z", "--no-renames", "--name-only", state.commit, head])).split("\0").filter(Boolean);
321
+ const expected = new Set(dirty.map(entry => entry.path));
322
+ const extra = changed.filter(path => !expected.has(path));
323
+ if (extra.length) throw new Error(`JJ working copy contains paths outside the captured dirty manifest: ${extra.join(", ")}`);
324
+ for (const entry of dirty) await verifyDirtyPath(state, head, entry);
325
+ }
326
+
225
327
  /** Secondary seats may gain one clean child; their legacy tip must be its parent. */
226
328
  async function migratedHead(state: SeatState) {
227
329
  const current = await output(state.worktree, "jj", ["log", "-r", "@", "--no-graph", "-T", "commit_id"]);
@@ -267,45 +369,77 @@ async function restoreGitWorkspace(repository: string, state: SeatState) {
267
369
  return errors;
268
370
  }
269
371
 
372
+ /** Locate the primary's Git index so JJ's colocation rewrite can be undone byte-for-byte. */
373
+ async function indexPath(repository: string) {
374
+ return resolve(repository, await output(repository, "git", ["rev-parse", "--git-path", "index"]));
375
+ }
376
+
377
+ async function readIndex(repository: string): Promise<Buffer | null> {
378
+ return readFile(await indexPath(repository)).catch(() => null);
379
+ }
380
+
381
+ /** Restore (or remove) the captured index atomically. */
382
+ async function restoreIndex(repository: string, index: Buffer | null) {
383
+ const path = await indexPath(repository);
384
+ if (index === null) return rm(path, { force: true });
385
+ const temp = `${path}.migrate-restore`;
386
+ await writeFile(temp, index);
387
+ await rename(temp, path);
388
+ }
389
+
390
+ /** JJ records kept commits under refs/jj; they exist only because this attempt initialized JJ. */
391
+ async function removeJjRefs(repository: string) {
392
+ const refs = (await output(repository, "git", ["for-each-ref", "--format=%(refname)", "refs/jj/"])).split("\n").filter(Boolean);
393
+ for (const ref of refs) await output(repository, "git", ["update-ref", "-d", ref]);
394
+ }
395
+
270
396
  /** Best-effort rollback never changes project.vcs and reports every incomplete restore. */
271
- async function rollback(repository: string, primary: SeatState, displaced: SeatState[]) {
397
+ async function rollback(repository: string, primary: SeatState, displaced: SeatState[], index: Buffer | null) {
272
398
  const errors: string[] = [];
273
399
  for (const state of [...displaced].reverse()) await removePartialWorkspace(repository, state);
274
400
  await rm(join(repository, ".jj"), { recursive: true, force: true }).catch(error => errors.push(String(error)));
401
+ await removeJjRefs(repository).catch(error => errors.push(String(error)));
275
402
  const switched = await command(repository, "git", ["switch", primary.branch]);
276
403
  if (switched.code) errors.push(switched.err || switched.out);
277
404
  for (const state of displaced) errors.push(...await restoreGitWorkspace(repository, state));
405
+ await restoreIndex(repository, index).catch(error => errors.push(String(error)));
278
406
  return errors;
279
407
  }
280
408
 
281
- /** Execute the only supported legacy conversion: Git project to colocated JJ. */
282
- export async function migrateProjectVcs(root: string, name: string, args: string[]) {
409
+ /** Accept exactly `--to jj` plus at most one each of the known flags. */
410
+ function parseMigrationArgs(args: string[]) {
283
411
  const toIndex = args.indexOf("--to"), to = toIndex < 0 ? undefined : args[toIndex + 1];
284
- const dryRun = args.includes("--dry-run");
285
- const consumed = new Set([toIndex, toIndex + 1, ...(dryRun ? [args.indexOf("--dry-run")] : [])]);
412
+ const flags = ["--dry-run", DIRTY_PRIMARY_FLAG];
413
+ const rest = args.filter((_arg, index) => index !== toIndex && index !== toIndex + 1);
286
414
  const valid = toIndex >= 0 && to === "jj"
287
415
  && args.filter(arg => arg === "--to").length === 1
288
- && args.filter(arg => arg === "--dry-run").length <= 1
289
- && args.every((_arg, index) => consumed.has(index));
290
- if (!valid) throw new Error("Use `atdd-flow project migrate-vcs <project> --to jj [--dry-run]`.");
291
- const prepared = await prepare(root, name);
416
+ && rest.every(arg => flags.includes(arg))
417
+ && new Set(rest).size === rest.length;
418
+ if (!valid) throw new Error("Use `atdd-flow project migrate-vcs <project> --to jj [--allow-dirty-primary] [--dry-run]`.");
419
+ return { dryRun: rest.includes("--dry-run"), allowDirtyPrimary: rest.includes(DIRTY_PRIMARY_FLAG) };
420
+ }
421
+
422
+ /** Execute the only supported legacy conversion: Git project to colocated JJ. */
423
+ export async function migrateProjectVcs(root: string, name: string, args: string[]) {
424
+ const { dryRun, allowDirtyPrimary } = parseMigrationArgs(args);
425
+ const prepared = await prepare(root, name, allowDirtyPrimary);
292
426
  if (dryRun) { process.stdout.write(yaml.print(prepared.plan)); return; }
293
427
 
294
428
  const displaced: SeatState[] = [];
295
429
  try {
296
430
  await output(prepared.repository, "jj", ["git", "init", "--colocate"]);
297
431
  await assertRepository("jj", prepared.repository);
298
- await verifyPrimary(prepared.plan.primary);
432
+ await verifyPrimary(prepared.plan.primary, prepared.dirty);
299
433
  for (const state of prepared.plan.workspaces) {
300
434
  displaced.push(state);
301
435
  await convertWorkspace(prepared.repository, state);
302
436
  }
303
- await verifyPrimary(prepared.plan.primary);
437
+ await verifyPrimary(prepared.plan.primary, prepared.dirty);
304
438
  for (const state of prepared.plan.workspaces) await verifyMigratedWorkspace(state);
305
439
  await atomicYaml(paths(root).projectFile(name), { ...prepared.config, vcs: "jj" });
306
440
  process.stdout.write(`${name} migrated git -> jj ${prepared.states.length} seats lineage preserved\n`);
307
441
  } catch (error) {
308
- const rollbackErrors = await rollback(prepared.repository, prepared.plan.primary, displaced);
442
+ const rollbackErrors = await rollback(prepared.repository, prepared.plan.primary, displaced, prepared.index);
309
443
  const suffix = rollbackErrors.length ? ` Automatic rollback was incomplete: ${rollbackErrors.join("; ")}` : " Git worktrees were restored.";
310
444
  throw new Error(`JJ migration failed before project.vcs changed: ${(error as Error).message}.${suffix}`);
311
445
  }