alignfirst 0.2.1 → 0.3.1

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
@@ -1,6 +1,6 @@
1
1
  # alignfirst
2
2
 
3
- The AlignFirst CLI provides collaborative software-development workflows, work files, and documentation discovery. Work files are organized by ticket and kept either git-ignored in the project or synchronized through a team plans repository.
3
+ The AlignFirst CLI provides collaborative software-development workflows, work files, and documentation discovery. Work files are organized by ticket and kept either git-ignored in the project or synchronized through a work-files repository.
4
4
 
5
5
  ## Agent skills
6
6
 
@@ -45,7 +45,7 @@ These examples use the `/` form. Replace it with `$` in Codex.
45
45
 
46
46
  To implement a plan, start a fresh agent context and ask it to execute the plan file.
47
47
 
48
- AlignFirst stores specifications, plans, and summaries in `.plans/<ticket-id>/`. It normally derives the ticket ID from the request or branch and asks when none is available. Files use a cycle letter and sequence number, such as `A1-spec.md` and `A2-plan.md`.
48
+ AlignFirst stores the work files of a ticket, such as specifications, plans and summaries, in `.plans/<ticket-id>/`. It normally derives the ticket ID from the request or branch and asks when none is available. Files use a cycle letter and sequence number, such as `A1-spec.md` and `A2-plan.md`.
49
49
 
50
50
  ## Global CLI
51
51
 
@@ -81,8 +81,8 @@ The guide installs the selected components and configures the repository. You ca
81
81
 
82
82
  - `guide` — Print an AlignFirst protocol.
83
83
  - `ticket` — Resolve a ticket directory, load its history, or get its next file.
84
- - `sync` — Synchronize shared plans.
85
- - `plans` — Set up, check and archive plans.
84
+ - `sync` — Synchronize the work files with the work-files repository.
85
+ - `plans` — Link `.plans` to the work-files repository, check the link, archive tickets.
86
86
  - `docmap` — Browse project documentation.
87
87
  - `conventions` — Print the effective project conventions.
88
88
  - `context` — Print the conventions, the documentation map when `docs/` exists, and the protocol aliases.
package/dist/cli.js CHANGED
@@ -39,7 +39,8 @@ export async function main(options) {
39
39
  ctx.projectConfig = resolveProjectConfig(ctx.cwd);
40
40
  checkCliRange(ctx.projectConfig?.config, ctx.version, [command, ...args]);
41
41
  }
42
- return dispatch(ctx, command, args);
42
+ const code = await dispatch(ctx, command, args);
43
+ return code;
43
44
  }
44
45
  catch (error) {
45
46
  if (!(error instanceof CliError))
@@ -55,7 +56,7 @@ function readPackageVersion() {
55
56
  return pkg.version;
56
57
  }
57
58
  function renderHelp(ctx) {
58
- return `alignfirst — protocols, plans and docs in one command.
59
+ return `alignfirst — protocols, work files and docs in one command.
59
60
 
60
61
  Usage:
61
62
  ${ctx.form} guide [<protocol>]
@@ -22,7 +22,7 @@ export function runDoctor(ctx, args) {
22
22
  return inspectConfig(ctx, resolved);
23
23
  });
24
24
  writeSection(ctx, "Git", () => inspectGit(ctx, resolved));
25
- writeSection(ctx, "Plans", () => inspectPlans(ctx));
25
+ writeSection(ctx, "Work files", () => inspectPlans(ctx));
26
26
  writeSection(ctx, "Docmap", () => inspectDocmap(ctx));
27
27
  writeSection(ctx, "Skills", () => inspectSkills(ctx));
28
28
  return 0;
@@ -51,10 +51,10 @@ function runSetup(ctx, args) {
51
51
  }
52
52
  function createPlansDirectory(cloneDir, folder) {
53
53
  if (folder.length === 0 || folder === "." || folder === ".." || /[\\/]/u.test(folder)) {
54
- throw new CliError(`Plans folder "${folder}" must be a single path segment.`);
54
+ throw new CliError(`Project folder "${folder}" must be a single path segment.`);
55
55
  }
56
56
  if (RESERVED_PLANS_FOLDERS.has(folder.toLowerCase())) {
57
- throw new CliError(`Plans folder "${folder}" is reserved.`);
57
+ throw new CliError(`Project folder "${folder}" is reserved.`);
58
58
  }
59
59
  const cloneRoot = realpathSync(cloneDir);
60
60
  const projectDir = join(cloneRoot, folder);
@@ -64,7 +64,7 @@ function createPlansDirectory(cloneDir, folder) {
64
64
  relativeTarget === ".." ||
65
65
  relativeTarget.startsWith(`..${sep}`) ||
66
66
  isAbsolute(relativeTarget)) {
67
- throw new CliError(`Plans folder "${folder}" must resolve inside ${cloneRoot}.`);
67
+ throw new CliError(`Project folder "${folder}" must resolve inside ${cloneRoot}.`);
68
68
  }
69
69
  return projectDir;
70
70
  }
@@ -94,11 +94,11 @@ function parseSetupArgs(ctx, args, usage) {
94
94
  }
95
95
  function checkClone(ctx, cloneDir) {
96
96
  if (!existsSync(cloneDir))
97
- throw new CliError(`${cloneDir} does not exist. Clone the team plans repository there first (see the instruction file).`);
97
+ throw new CliError(`${cloneDir} does not exist. Clone the work-files repository there first (see the instruction file).`);
98
98
  if (!existsSync(join(cloneDir, ".git")))
99
- throw new CliError(`${cloneDir} is not a git repository. Point ${ctx.form} plans setup at a clone of the team plans repository.`);
99
+ throw new CliError(`${cloneDir} is not a git repository. Point ${ctx.form} plans setup at a clone of the work-files repository.`);
100
100
  if (realpathSync(cloneDir) === realpathSync(ctx.cwd))
101
- throw new CliError(`${cloneDir} is the product repository itself. Point ${ctx.form} plans setup at a clone of the team plans repository.`);
101
+ throw new CliError(`${cloneDir} is the product repository itself. Point ${ctx.form} plans setup at a clone of the work-files repository.`);
102
102
  }
103
103
  function runCheck(ctx, args) {
104
104
  const usage = `Usage: ${ctx.form} plans check\n`;
@@ -111,9 +111,9 @@ function runCheck(ctx, args) {
111
111
  throw new CliError(renderStoppedRebase(stopped, ctx.form));
112
112
  }
113
113
  if (mode.kind === "shared")
114
- ctx.stdout.write(".plans is linked to the team plans repository.\n");
114
+ ctx.stdout.write(".plans is linked to the work-files repository.\n");
115
115
  else
116
- ctx.stdout.write(".plans is a local directory (local plans mode): synchronization is disabled.\n");
116
+ ctx.stdout.write(".plans is a local directory (local mode): synchronization is disabled.\n");
117
117
  return 0;
118
118
  }
119
119
  function runAutoArchive(ctx, args) {
@@ -1,2 +1,2 @@
1
1
  import type { CommandContext } from "../context.js";
2
- export declare function runSync(ctx: CommandContext, args: string[]): number;
2
+ export declare function runSync(ctx: CommandContext, args: string[]): Promise<number>;
@@ -5,8 +5,8 @@ import { git, gitOutput, gitSucceeds } from "../git.js";
5
5
  import { parseCommandArgs } from "../parse-args.js";
6
6
  import { archiveThresholdDays, autoArchive } from "../plans/archive.js";
7
7
  import { resolvePlansMode } from "../plans/mode.js";
8
- import { findStoppedRebase, renderStoppedRebase } from "../plans/rebase.js";
9
- export function runSync(ctx, args) {
8
+ import { findStoppedRebase, renderStoppedRebase, resolveStoppedRebase } from "../plans/rebase.js";
9
+ export async function runSync(ctx, args) {
10
10
  const usage = `Usage: ${ctx.form} sync [--auto-archive | --no-auto-archive]\n`;
11
11
  const options = parseSyncArgs(ctx, args, usage);
12
12
  if (options === undefined)
@@ -22,39 +22,40 @@ export function runSync(ctx, args) {
22
22
  if (mode.kind === "local") {
23
23
  if (thresholdDays !== undefined)
24
24
  autoArchive(plansDir, thresholdDays, ctx.stdout);
25
- ctx.stdout.write("(local plans mode, nothing to sync)\n");
25
+ ctx.stdout.write("(local mode, nothing to sync)\n");
26
26
  return 0;
27
27
  }
28
28
  const repoDir = mode.repoToplevel;
29
29
  assertNoStoppedRebase(repoDir, ctx.form);
30
- git(repoDir, "add", "-A");
30
+ await git(ctx, repoDir, "add", "-A");
31
31
  if (hasStagedChanges(repoDir))
32
- git(repoDir, "commit", "--quiet", "-m", "sync");
32
+ await git(ctx, repoDir, "commit", "--quiet", "-m", "sync");
33
33
  if (hasUpstream(repoDir)) {
34
34
  try {
35
- git(repoDir, "pull", "--rebase");
35
+ await git(ctx, repoDir, "pull", "--rebase");
36
36
  }
37
37
  catch {
38
- assertNoStoppedRebase(repoDir, ctx.form);
39
- throw new CliError("git pull failed. See the git output above.");
38
+ if (findStoppedRebase(repoDir) === undefined)
39
+ throw new CliError("git pull failed. See the git output above.");
40
+ await resolveStoppedRebase(ctx, repoDir);
40
41
  }
41
42
  }
42
43
  if (thresholdDays !== undefined && autoArchive(plansDir, thresholdDays, ctx.stdout)) {
43
- git(repoDir, "add", "-A");
44
+ await git(ctx, repoDir, "add", "-A");
44
45
  if (hasStagedChanges(repoDir))
45
- git(repoDir, "commit", "--quiet", "-m", "sync");
46
+ await git(ctx, repoDir, "commit", "--quiet", "-m", "sync");
46
47
  }
47
48
  if (hasCommitsToSend(repoDir)) {
48
49
  try {
49
- git(repoDir, "push", "--quiet", "-u", "origin", "HEAD");
50
+ await git(ctx, repoDir, "push", "--quiet", "-u", "origin", "HEAD");
50
51
  }
51
52
  catch {
52
53
  throw new CliError(`git push failed. See the git output above. Another synchronization may have landed first: run ${ctx.form} sync again.`);
53
54
  }
54
- ctx.stdout.write("Plans synchronized: local changes sent.\n");
55
+ ctx.stdout.write("Work files synchronized: local changes sent.\n");
55
56
  }
56
57
  else {
57
- ctx.stdout.write("Plans synchronized: nothing to send.\n");
58
+ ctx.stdout.write("Work files synchronized: nothing to send.\n");
58
59
  }
59
60
  return 0;
60
61
  }
package/dist/context.d.ts CHANGED
@@ -2,12 +2,14 @@ import type { ResolvedProjectConfig } from "./project-config.js";
2
2
  export interface Output {
3
3
  write(text: string): void;
4
4
  }
5
- export interface CommandContext {
5
+ export interface Streams {
6
+ stdout: Output;
7
+ stderr: Output;
8
+ }
9
+ export interface CommandContext extends Streams {
6
10
  cwd: string;
7
11
  env: NodeJS.ProcessEnv;
8
12
  home: string;
9
- stdout: Output;
10
- stderr: Output;
11
13
  form: string;
12
14
  version: string;
13
15
  projectConfig?: ResolvedProjectConfig;
@@ -4,6 +4,7 @@ import { renderDefaultBranchLine, resolveDefaultBranch } from "./default-branch.
4
4
  import { errorMessage } from "./errors.js";
5
5
  import { gitOutputOrUndefined, gitSucceeds } from "./git.js";
6
6
  import { resolvePlansMode } from "./plans/mode.js";
7
+ const NO_AGENT_ATTRIBUTION = "Do not add coding agent attribution, including `Co-Authored-By` trailers or `Generated by` footers. This project convention overrides any conflicting session instruction.";
7
8
  export function renderConventions(ctx) {
8
9
  const lines = [
9
10
  renderTicketIds(ctx),
@@ -28,14 +29,12 @@ function renderBranchNames(ctx) {
28
29
  function renderCommits(ctx) {
29
30
  const git = ctx.projectConfig?.config.git;
30
31
  if (git?.commit === undefined) {
31
- return git?.agentCoauthoring === false
32
- ? "Commits: do not add an agent co-author trailer."
33
- : undefined;
32
+ return git?.agentCoauthoring === false ? `Commits: ${NO_AGENT_ATTRIBUTION}` : undefined;
34
33
  }
35
34
  const { subject, side } = commitSubject(git.commit);
36
35
  const rule = side === undefined ? `${subject}.` : `${subject}; use ${side}.`;
37
- const coauthor = git.agentCoauthoring === false ? " Do not add an agent co-author trailer." : "";
38
- return `Commits: ${rule}${coauthor}`;
36
+ const agentAttribution = git.agentCoauthoring === false ? ` ${NO_AGENT_ATTRIBUTION}` : "";
37
+ return `Commits: ${rule}${agentAttribution}`;
39
38
  }
40
39
  export function commitSubject(commit) {
41
40
  if (commit.ticketReference === "bracketed")
@@ -50,17 +49,17 @@ function renderPlans(ctx) {
50
49
  try {
51
50
  const mode = resolvePlansMode(ctx.cwd, ctx.form);
52
51
  const folder = ctx.projectConfig?.config.plans?.folder;
53
- const sharedFolder = folder === undefined ? "" : ` (shared folder \`${folder}\`, a separate git repository)`;
52
+ const sharedFolder = folder === undefined ? "" : ` (folder \`${folder}\` in the work-files repository)`;
54
53
  const base = mode.kind === "shared"
55
- ? `Plans: use \`.plans\`${sharedFolder}; run \`${ctx.form} sync\` after changes.`
56
- : "Plans: use `.plans`.";
54
+ ? `Work files: use \`.plans\`${sharedFolder}; run \`${ctx.form} sync\` after changes.`
55
+ : "Work files: use `.plans`.";
57
56
  const archival = ctx.projectConfig?.config.plans?.autoArchive === true
58
57
  ? " Automatic archival is enabled."
59
58
  : "";
60
59
  return `${base}${archival}`;
61
60
  }
62
61
  catch (error) {
63
- return `Plans: ${errorMessage(error).split("\n", 1)[0]}`;
62
+ return `Work files: ${errorMessage(error).split("\n", 1)[0]}`;
64
63
  }
65
64
  }
66
65
  function plansEntryExists(cwd) {
package/dist/git.d.ts CHANGED
@@ -1,5 +1,8 @@
1
- export declare function git(dir: string, ...args: string[]): void;
1
+ import type { Streams } from "./context.js";
2
+ export declare function git(streams: Streams, dir: string, ...args: string[]): Promise<void>;
2
3
  export declare function assertMainWorktreeRoot(cwd: string): void;
3
4
  export declare function gitOutput(dir: string, ...args: string[]): string;
5
+ export declare function gitOutputRaw(dir: string, ...args: string[]): string;
6
+ export declare function gitBuffer(dir: string, ...args: string[]): Buffer;
4
7
  export declare function gitOutputOrUndefined(dir: string, ...args: string[]): string | undefined;
5
8
  export declare function gitSucceeds(dir: string, ...args: string[]): boolean;
package/dist/git.js CHANGED
@@ -1,17 +1,33 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
2
2
  import { realpathSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { CliError } from "./cli-error.js";
5
- export function git(dir, ...args) {
6
- try {
7
- execFileSync("git", ["-C", dir, ...args], { stdio: "inherit" });
8
- }
9
- catch {
10
- throw gitFailure(args);
11
- }
5
+ export function git(streams, dir, ...args) {
6
+ return new Promise((resolve, reject) => {
7
+ const child = spawn("git", ["-C", dir, ...args], {
8
+ stdio: ["inherit", "pipe", "pipe"],
9
+ });
10
+ child.stdout.setEncoding("utf8");
11
+ child.stderr.setEncoding("utf8");
12
+ child.stdout.on("data", (text) => streams.stdout.write(text));
13
+ child.stderr.on("data", (text) => streams.stderr.write(text));
14
+ child.once("error", (error) => reject(gitFailure(args, error.message)));
15
+ child.once("close", (code) => (code === 0 ? resolve() : reject(gitFailure(args))));
16
+ });
17
+ }
18
+ function gitFailure(args, detail) {
19
+ const output = detail?.trim();
20
+ const subcommand = gitSubcommand(args);
21
+ const label = subcommand === undefined ? "git command" : `git ${subcommand}`;
22
+ if (output === undefined || output === "")
23
+ return new CliError(`${label} failed. See the git output above.`);
24
+ return new CliError(`${label} failed:\n${output}`);
12
25
  }
13
- function gitFailure(args) {
14
- return new CliError(`git ${args[0]} failed. See the git output above.`);
26
+ function gitSubcommand(args) {
27
+ let index = 0;
28
+ while (args[index] === "-c")
29
+ index += 2;
30
+ return args[index];
15
31
  }
16
32
  export function assertMainWorktreeRoot(cwd) {
17
33
  const toplevel = gitOutput(cwd, "rev-parse", "--show-toplevel");
@@ -23,12 +39,22 @@ export function assertMainWorktreeRoot(cwd) {
23
39
  throw new CliError("Run this command from the main worktree. Linked worktrees reach .plans through it.");
24
40
  }
25
41
  export function gitOutput(dir, ...args) {
26
- try {
27
- return execFileSync("git", ["-C", dir, ...args], { encoding: "utf-8" }).trim();
28
- }
29
- catch {
30
- throw gitFailure(args);
31
- }
42
+ return gitOutputRaw(dir, ...args).trim();
43
+ }
44
+ export function gitOutputRaw(dir, ...args) {
45
+ const output = gitBuffer(dir, ...args);
46
+ const text = output.toString("utf8");
47
+ if (!Buffer.from(text).equals(output))
48
+ throw new CliError("Cannot read non-UTF-8 Git output safely. Resolve the rebase manually.");
49
+ return text;
50
+ }
51
+ export function gitBuffer(dir, ...args) {
52
+ const result = spawnSync("git", ["-C", dir, ...args]);
53
+ if (result.error !== undefined)
54
+ throw gitFailure(args, result.error.message);
55
+ if (result.status !== 0)
56
+ throw gitFailure(args, result.stderr.toString("utf8"));
57
+ return result.stdout;
32
58
  }
33
59
  export function gitOutputOrUndefined(dir, ...args) {
34
60
  try {
@@ -0,0 +1,2 @@
1
+ import type { Streams } from "../context.js";
2
+ export declare function resolveConflictedPaths(streams: Streams, repoDir: string): void;
@@ -0,0 +1,233 @@
1
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, } from "node:fs";
2
+ import { basename, dirname, extname, join, relative, resolve } from "node:path";
3
+ import { CliError } from "../cli-error.js";
4
+ import { gitBuffer, gitOutput, gitOutputRaw } from "../git.js";
5
+ import { nextFilePosition } from "./ticket.js";
6
+ export function resolveConflictedPaths(streams, repoDir) {
7
+ const conflicts = readConflicts(repoDir);
8
+ const remote = readTree(repoDir, "HEAD");
9
+ const local = readTree(repoDir, "REBASE_HEAD");
10
+ const resolution = {
11
+ files: new Map(),
12
+ renames: new Map(),
13
+ reserved: new Set([...remote.keys(), ...local.keys()]),
14
+ };
15
+ for (const [path, stages] of conflicts) {
16
+ const ours = conflictBlob(path, stages.has(2), remote);
17
+ const theirs = conflictBlob(path, stages.has(3), local);
18
+ if (!ours && !theirs && (remote.has(path) || local.has(path)))
19
+ throw new CliError(`Cannot recover both versions of ${path}. Resolve the rebase manually.`);
20
+ if (ours)
21
+ resolution.files.set(path, { blob: ours });
22
+ if (!theirs)
23
+ continue;
24
+ if (ours && (ours.id !== theirs.id || ours.mode !== theirs.mode)) {
25
+ preserveLocalCopy(repoDir, path, theirs, resolution);
26
+ }
27
+ else {
28
+ resolution.files.set(path, { blob: theirs, localPath: path });
29
+ }
30
+ }
31
+ preserveCompanionSummaries(repoDir, remote, local, resolution);
32
+ preserveLocalReferences(repoDir, remote, local, resolution);
33
+ assertNoLaterEdits(repoDir, resolution.renames);
34
+ applyResolution(repoDir, conflicts, resolution);
35
+ for (const path of conflicts.keys()) {
36
+ const renamed = resolution.renames.get(path);
37
+ streams.stdout.write(renamed === undefined
38
+ ? `Resolved ${path}: preserved committed contents at the surviving paths.\n`
39
+ : `Resolved ${path}: kept the published version; saved the local version as ${renamed}.\n`);
40
+ }
41
+ }
42
+ function readConflicts(repoDir) {
43
+ const result = new Map();
44
+ for (const entry of gitOutputRaw(repoDir, "ls-files", "-u", "-z").split("\0")) {
45
+ if (entry === "")
46
+ continue;
47
+ const tab = entry.indexOf("\t");
48
+ const header = entry.slice(0, tab).split(" ");
49
+ const stage = Number(header[2]);
50
+ if (tab === -1 || ![1, 2, 3].includes(stage))
51
+ throw new CliError("Could not read the git index of the work-files repository.");
52
+ const path = entry.slice(tab + 1);
53
+ const stages = result.get(path) ?? new Set();
54
+ stages.add(stage);
55
+ result.set(path, stages);
56
+ }
57
+ return result;
58
+ }
59
+ function readTree(repoDir, commit) {
60
+ const result = new Map();
61
+ for (const entry of gitOutputRaw(repoDir, "ls-tree", "-r", "-z", commit).split("\0")) {
62
+ if (entry === "")
63
+ continue;
64
+ const tab = entry.indexOf("\t");
65
+ const [mode, , id] = entry.slice(0, tab).split(" ");
66
+ result.set(entry.slice(tab + 1), { id, mode });
67
+ }
68
+ return result;
69
+ }
70
+ function conflictBlob(path, present, tree) {
71
+ if (!present)
72
+ return;
73
+ const blob = tree.get(path);
74
+ if (!blob || !["100644", "100755"].includes(blob.mode))
75
+ throw new CliError(`Cannot automatically preserve conflict at ${path}. Resolve the rebase manually.`);
76
+ return blob;
77
+ }
78
+ function preserveLocalCopy(repoDir, path, blob, resolution) {
79
+ const target = freeLocalPath(repoDir, path, resolution.reserved);
80
+ resolution.renames.set(path, target);
81
+ resolution.reserved.add(target);
82
+ resolution.files.set(target, { blob, localPath: path });
83
+ }
84
+ function freeLocalPath(repoDir, path, reserved) {
85
+ const dir = dirname(path);
86
+ const names = [
87
+ ...readdirSync(join(repoDir, dir)),
88
+ ...[...reserved].filter((entry) => dirname(entry) === dir).map((entry) => basename(entry)),
89
+ ];
90
+ const name = basename(path);
91
+ const numbered = /^[A-Z]\d+-(.+)$/s.exec(name);
92
+ const { cycleLetter, fileNumber } = nextFilePosition(names, false);
93
+ const extension = name.endsWith(".summary.md") ? ".summary.md" : extname(name);
94
+ const stem = name.slice(0, name.length - extension.length);
95
+ for (let offset = 0;; ++offset) {
96
+ const candidate = join(dir, numbered
97
+ ? `${cycleLetter}${fileNumber + offset}-${numbered[1]}`
98
+ : `${stem}-local-${offset + 2}${extension}`);
99
+ if (reserved.has(candidate) || lstatSync(join(repoDir, candidate), { throwIfNoEntry: false }))
100
+ continue;
101
+ const companion = `${candidate.slice(0, -3)}.summary.md`;
102
+ if (name.endsWith(".md") &&
103
+ reserved.has(`${path.slice(0, -3)}.summary.md`) &&
104
+ (reserved.has(companion) || lstatSync(join(repoDir, companion), { throwIfNoEntry: false })))
105
+ continue;
106
+ return candidate;
107
+ }
108
+ }
109
+ function preserveCompanionSummaries(repoDir, remote, local, resolution) {
110
+ for (const [source, target] of resolution.renames) {
111
+ if (!source.endsWith(".md"))
112
+ continue;
113
+ const companion = `${source.slice(0, -3)}.summary.md`;
114
+ const blob = local.get(companion);
115
+ if (!blob)
116
+ continue;
117
+ const companionTarget = `${target.slice(0, -3)}.summary.md`;
118
+ if (resolution.reserved.has(companionTarget) ||
119
+ lstatSync(join(repoDir, companionTarget), { throwIfNoEntry: false }))
120
+ throw new CliError(`Cannot preserve related summary ${companion}: ${companionTarget} exists.`);
121
+ conflictBlob(companion, true, local);
122
+ const previousTarget = resolution.renames.get(companion);
123
+ if (previousTarget !== undefined)
124
+ resolution.files.delete(previousTarget);
125
+ resolution.renames.set(companion, companionTarget);
126
+ resolution.reserved.add(companionTarget);
127
+ resolution.files.set(companionTarget, { blob, localPath: companion });
128
+ const published = conflictBlob(companion, remote.has(companion), remote);
129
+ if (published)
130
+ resolution.files.set(companion, { blob: published });
131
+ else
132
+ resolution.files.delete(companion);
133
+ }
134
+ }
135
+ function preserveLocalReferences(repoDir, remote, local, resolution) {
136
+ if (resolution.renames.size === 0)
137
+ return;
138
+ const published = readTree(repoDir, rebaseCommit(repoDir, "onto"));
139
+ const added = new Set(gitOutputRaw(repoDir, "diff", "--cached", "--name-only", "--diff-filter=A", "-z")
140
+ .split("\0")
141
+ .filter((path) => path !== ""));
142
+ for (const [path, blob] of local) {
143
+ if (!path.endsWith(".md") ||
144
+ (!added.has(path) && (!remote.has(path) || published.has(path))) ||
145
+ resolution.renames.has(path))
146
+ continue;
147
+ if (blob.mode === "100644" || blob.mode === "100755")
148
+ resolution.files.set(path, { blob, localPath: path });
149
+ }
150
+ }
151
+ function rebaseCommit(repoDir, name) {
152
+ const state = gitOutput(repoDir, "rev-parse", "--git-path", `rebase-merge/${name}`);
153
+ const path = resolve(repoDir, state);
154
+ if (!lstatSync(path, { throwIfNoEntry: false }))
155
+ throw new CliError("Cannot read the original rebase commits. Resolve the rebase manually.");
156
+ return readFileSync(path, "utf8").trim();
157
+ }
158
+ function assertNoLaterEdits(repoDir, renames) {
159
+ if (renames.size === 0)
160
+ return;
161
+ const originalHead = rebaseCommit(repoDir, "orig-head");
162
+ const commits = gitOutput(repoDir, "rev-list", `REBASE_HEAD..${originalHead}`);
163
+ for (const commit of commits.split("\n").filter((value) => value !== "")) {
164
+ const tree = readTree(repoDir, commit);
165
+ const paths = gitOutputRaw(repoDir, "diff-tree", "--no-commit-id", "--name-only", "-r", "-z", commit);
166
+ for (const path of paths.split("\0")) {
167
+ if (renames.has(path))
168
+ throw new CliError(`Cannot automatically rename ${path}: a later local commit edits it. Resolve the rebase manually.`);
169
+ const blob = tree.get(path);
170
+ if (!path.endsWith(".md") || !blob || !["100644", "100755"].includes(blob.mode))
171
+ continue;
172
+ const content = readBlob(repoDir, blob);
173
+ if (!updateReferences(content, path, renames).equals(content))
174
+ throw new CliError(`A later local commit references a renamed document in ${path}. Resolve the rebase manually.`);
175
+ }
176
+ }
177
+ }
178
+ function readBlob(repoDir, blob) {
179
+ return gitBuffer(repoDir, "cat-file", "blob", blob.id);
180
+ }
181
+ function updateReferences(original, source, renames) {
182
+ const replacements = new Map();
183
+ for (const [before, after] of renames) {
184
+ replacements.set(before, after);
185
+ const from = relative(dirname(source), before);
186
+ const to = relative(dirname(source), after);
187
+ replacements.set(from, to);
188
+ replacements.set(`./${from}`, `./${to}`);
189
+ // A work-files repository clone stores the project's .plans directory under its configured folder.
190
+ replacements.set(before.replace(/^[^/]+\//, ".plans/"), after.replace(/^[^/]+\//, ".plans/"));
191
+ }
192
+ if (replacements.size === 0)
193
+ return original;
194
+ const pattern = [...replacements.keys()]
195
+ .sort((left, right) => right.length - left.length)
196
+ .map((path) => path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
197
+ .join("|");
198
+ const text = original.toString("utf8");
199
+ if (!Buffer.from(text).equals(original))
200
+ return original;
201
+ const updated = text.replace(new RegExp(`(?<![\\w./-])(?:${pattern})(?=$|[\\s\x60'"<>()[\\]#:])`, "g"), (match) => replacements.get(match) ?? match);
202
+ return Buffer.from(updated);
203
+ }
204
+ function applyResolution(repoDir, conflicts, resolution) {
205
+ const removed = [...new Set([...conflicts.keys(), ...resolution.renames.keys()])].filter((path) => !resolution.files.has(path));
206
+ for (const path of [...removed, ...resolution.files.keys()])
207
+ assertRegularPath(repoDir, path);
208
+ const writes = [...resolution.files].map(([path, file]) => {
209
+ const original = readBlob(repoDir, file.blob);
210
+ const content = file.localPath !== undefined && path.endsWith(".md")
211
+ ? updateReferences(original, file.localPath, resolution.renames)
212
+ : original;
213
+ return { path, content, mode: file.blob.mode === "100755" ? 0o755 : 0o644 };
214
+ });
215
+ for (const path of removed)
216
+ rmSync(join(repoDir, path), { force: true });
217
+ for (const { path, content, mode } of writes) {
218
+ const target = join(repoDir, path);
219
+ mkdirSync(dirname(target), { recursive: true });
220
+ writeFileSync(target, content);
221
+ chmodSync(target, mode);
222
+ }
223
+ }
224
+ function assertRegularPath(repoDir, path) {
225
+ const stat = lstatSync(join(repoDir, path), { throwIfNoEntry: false });
226
+ if (stat && !stat.isFile())
227
+ throw new CliError(`Cannot automatically replace ${path}. Resolve the rebase manually.`);
228
+ for (let dir = dirname(path); dir !== "."; dir = dirname(dir)) {
229
+ const parent = lstatSync(join(repoDir, dir), { throwIfNoEntry: false });
230
+ if (parent && !parent.isDirectory())
231
+ throw new CliError(`Cannot automatically write through ${dir}. Resolve the rebase manually.`);
232
+ }
233
+ }
@@ -25,7 +25,7 @@ export function assertPlansGate(cwd, form) {
25
25
  return stats;
26
26
  }
27
27
  export function missingPlansMessage(form) {
28
- return `No .plans/ directory in the current directory.\nLocal plans: mkdir .plans && echo .plans >> .gitignore\nTeam plans: ${form} plans setup <clone-dir>`;
28
+ return `No .plans/ directory in the current directory.\nLocal work files: mkdir .plans && echo .plans >> .gitignore\nTeam work files: ${form} plans setup <clone-dir>`;
29
29
  }
30
30
  export function missingPlansError(form) {
31
31
  return new CliError(missingPlansMessage(form));
@@ -6,7 +6,7 @@ export function linkPlans(ctx, targetDir) {
6
6
  const stats = lstatSync(plansPath, { throwIfNoEntry: false });
7
7
  if (stats?.isSymbolicLink()) {
8
8
  if (existsSync(plansPath) && realpathSync(plansPath) === realpathSync(targetDir)) {
9
- ctx.stdout.write(".plans already links to the plans repository.\n");
9
+ ctx.stdout.write(".plans already links to the work-files repository.\n");
10
10
  return;
11
11
  }
12
12
  rmSync(plansPath);
@@ -1,6 +1,8 @@
1
+ import type { Streams } from "../context.js";
1
2
  export interface StoppedRebase {
2
3
  repoDir: string;
3
4
  conflictedFiles: string[];
4
5
  }
5
6
  export declare function findStoppedRebase(repoDir: string): StoppedRebase | undefined;
7
+ export declare function resolveStoppedRebase(streams: Streams, repoDir: string): Promise<void>;
6
8
  export declare function renderStoppedRebase(stopped: StoppedRebase, form: string): string;
@@ -1,6 +1,9 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import { gitOutputOrUndefined } from "../git.js";
3
+ import { CliError } from "../cli-error.js";
4
+ import { git, gitOutputRaw, gitOutputOrUndefined, gitSucceeds } from "../git.js";
5
+ import { resolveConflictedPaths } from "./conflicts.js";
6
+ const MAX_REBASE_STEPS = 100;
4
7
  export function findStoppedRebase(repoDir) {
5
8
  const inProgress = ["rebase-merge", "rebase-apply"].some((name) => {
6
9
  const path = gitOutputOrUndefined(repoDir, "rev-parse", "--git-path", name);
@@ -8,16 +11,41 @@ export function findStoppedRebase(repoDir) {
8
11
  });
9
12
  if (!inProgress)
10
13
  return;
11
- const conflicts = gitOutputOrUndefined(repoDir, "diff", "--name-only", "--diff-filter=U");
14
+ const conflicts = gitOutputRaw(repoDir, "diff", "-z", "--name-only", "--diff-filter=U");
12
15
  return {
13
16
  repoDir,
14
- conflictedFiles: conflicts?.split("\n").filter((path) => path !== "") ?? [],
17
+ conflictedFiles: conflicts.split("\0").filter((path) => path !== ""),
15
18
  };
16
19
  }
20
+ export async function resolveStoppedRebase(streams, repoDir) {
21
+ for (let step = 0; findStoppedRebase(repoDir) !== undefined; ++step) {
22
+ if (step >= MAX_REBASE_STEPS)
23
+ throw new CliError(`Could not finish the stopped rebase in ${repoDir}.`);
24
+ resolveConflictedPaths(streams, repoDir);
25
+ await git(streams, repoDir, "add", "-A");
26
+ await continueRebase(streams, repoDir);
27
+ }
28
+ }
29
+ async function continueRebase(streams, repoDir) {
30
+ const args = ["-c", "core.editor=true", "rebase", "--continue"];
31
+ try {
32
+ await git(streams, repoDir, ...args);
33
+ }
34
+ catch (error) {
35
+ const stopped = findStoppedRebase(repoDir);
36
+ if (stopped?.conflictedFiles.length)
37
+ return;
38
+ if (stopped && gitSucceeds(repoDir, "diff", "--cached", "--quiet")) {
39
+ await git(streams, repoDir, "rebase", "--skip");
40
+ return;
41
+ }
42
+ throw error;
43
+ }
44
+ }
17
45
  export function renderStoppedRebase(stopped, form) {
18
46
  const files = stopped.conflictedFiles.map((path) => ` ${path}`);
19
47
  return [
20
- `Plans synchronization stopped on a conflict in ${stopped.repoDir}:`,
48
+ `Work-files synchronization stopped on a conflict in ${stopped.repoDir}:`,
21
49
  ...files,
22
50
  "Resolve the markers in these files, then run:",
23
51
  ` git -C ${stopped.repoDir} add -A && git -C ${stopped.repoDir} rebase --continue`,
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "alignfirst",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "license": "CC0-1.0",
5
5
  "author": "Thomas MUR",
6
- "description": "The AlignFirst CLI: protocols, plans and docs in one command.",
6
+ "description": "The AlignFirst CLI: protocols, work files and docs in one command.",
7
7
  "keywords": [
8
8
  "alignfirst",
9
9
  "cli",
@@ -5,7 +5,7 @@ You are one of several reviewers examining the same branch, each from a differen
5
5
  ## Scope
6
6
 
7
7
  - Review the changes between the merge-base and HEAD (the orchestrator gives you both): `git diff <merge_base> HEAD`. The review target is the branch as committed.
8
- - Fresh eyes: derive everything from the code and the diff. Do not read specs, plans, summaries, or any file in the task directory (e.g., under `.plans/`).
8
+ - Fresh eyes: derive everything from the code and the diff. Do not read specs, plans, summaries, or any file in the ticket directory (under `.plans/`).
9
9
  - Read-only: never modify the working tree, the index, or HEAD.
10
10
 
11
11
  ## Method