alignfirst 0.4.0 → 0.6.0-preview.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +54 -2
  2. package/dist/cli.js +2 -1
  3. package/dist/commands/config.js +15 -4
  4. package/dist/commands/context.js +23 -3
  5. package/dist/commands/docmap.js +4 -1
  6. package/dist/commands/doctor.js +27 -4
  7. package/dist/commands/guide.js +6 -6
  8. package/dist/commands/plans.js +18 -15
  9. package/dist/commands/sync.js +5 -5
  10. package/dist/commands/ticket.js +22 -19
  11. package/dist/context.d.ts +2 -0
  12. package/dist/conventions.js +11 -9
  13. package/dist/format.d.ts +2 -0
  14. package/dist/format.js +8 -0
  15. package/dist/plans/archive.d.ts +8 -2
  16. package/dist/plans/archive.js +41 -17
  17. package/dist/plans/catchup.js +12 -10
  18. package/dist/plans/layout.d.ts +4 -7
  19. package/dist/plans/layout.js +10 -14
  20. package/dist/plans/link.d.ts +1 -1
  21. package/dist/plans/link.js +8 -6
  22. package/dist/plans/mode.d.ts +3 -1
  23. package/dist/plans/mode.js +11 -7
  24. package/dist/plans/ticket.d.ts +7 -4
  25. package/dist/plans/ticket.js +30 -20
  26. package/dist/project-config.d.ts +4 -3
  27. package/dist/project-config.js +7 -9
  28. package/dist/project-layout.d.ts +30 -0
  29. package/dist/project-layout.js +174 -0
  30. package/package.json +3 -3
  31. package/templates/guide/code-review/correctness-reviewer.md +1 -0
  32. package/templates/guide/code-review/quality-reviewer.md +1 -0
  33. package/templates/guide/code-review/reviewer-common.md +2 -0
  34. package/templates/guide/core.md +1 -1
  35. package/templates/guide/protocols/aad.md +24 -17
  36. package/templates/guide/protocols/description.md +8 -8
  37. package/templates/guide/protocols/merge.md +5 -5
  38. package/templates/guide/protocols/plan.md +59 -70
  39. package/templates/guide/protocols/spec.md +44 -37
package/README.md CHANGED
@@ -4,6 +4,8 @@ The AlignFirst CLI provides collaborative software-development workflows, work f
4
4
 
5
5
  See the [product page](https://alignfirst.paroi.tech/skills) for a demonstration.
6
6
 
7
+ Supported systems: Linux and macOS, and Windows through WSL.
8
+
7
9
  ## Agent skills
8
10
 
9
11
  Nine Agent Skill stubs expose the CLI as commands in Claude Code, Codex, GitHub Copilot, and Cursor. Install them globally:
@@ -87,8 +89,8 @@ The guide installs the selected components and configures the repository. You ca
87
89
  - `plans` — Link `.plans` to the work-files repository, check the link, archive tickets.
88
90
  - `docmap` — Browse project documentation.
89
91
  - `conventions` — Print the effective project conventions.
90
- - `context` — Print the conventions, the documentation map when `docs/` exists, and the protocol aliases.
91
- - `config` — Report the effective project configuration.
92
+ - `context` — Print the conventions, the project instructions from `.alignfirst.md`, the documentation map when `docs/` exists, and the protocol aliases.
93
+ - `config` — Report the effective project configuration, the companion directory and the location of each AlignFirst file.
92
94
  - `doctor` — Diagnose an AlignFirst setup.
93
95
 
94
96
  Run `alignfirst --help` for command usage or `alignfirst guide` to choose a protocol. `alignfirst guide <protocol>` prints the selected protocol followed by the ticket directory and work file rules. Add `--protocol-only` when those rules are already in context.
@@ -97,6 +99,56 @@ Run `alignfirst --help` for command usage or `alignfirst guide` to choose a prot
97
99
 
98
100
  `alignfirst ticket [<id>] --catchup` prints the ticket's Markdown files, plans excluded.
99
101
 
102
+ ## Companion directories
103
+
104
+ A companion directory holds a project's AlignFirst files outside its repository, so the repository stays untouched. `~/.config/alignfirst/companions.json` declares which projects have one:
105
+
106
+ ```json
107
+ {
108
+ "root": "~/alignfirst-companions",
109
+ "paths": {
110
+ "~/projects/team-app": { ".plans": false, "_aligndev": true },
111
+ "~/projects/client-api": {},
112
+ "~/projects": {}
113
+ }
114
+ }
115
+ ```
116
+
117
+ - `root` — the directory that holds the companion directories: an absolute path or a `~/` path.
118
+ - `paths` — the projects, by absolute or `~/` path. Each value sets flags for the items a companion can hold: `.alignfirst.json`, `.alignfirst.md`, `DEVELOPERS.md`, `docs`, `.plans` and `_aligndev`. A flag is `true`, `false` or `"auto"`.
119
+
120
+ An absent file means no project has a companion. An invalid file makes every command fail; `doctor` reports it and continues.
121
+
122
+ ### Matching
123
+
124
+ A key matches a project when it names the project's main worktree or one of its ancestors, so every worktree of a project shares one companion. `"~": {}` matches every project under the home directory. For each item, the longest matching key that sets the flag wins, and an unset flag is `"auto"`. A bare repository or a directory outside git has no companion.
125
+
126
+ The companion directory is `<root>/<name>`. The name is the main worktree path relative to the home directory, or the absolute path without its leading `/` outside it, with every `/` replaced by `_`. For example, `~/projects/client-api` gets `<root>/projects_client-api/`.
127
+
128
+ ### Resolution
129
+
130
+ | Flag | Location |
131
+ | --- | --- |
132
+ | `true` | The companion copy, present or not. |
133
+ | `false` | The project copy, present or not. |
134
+ | `"auto"` | The companion copy when it exists, otherwise the project copy when it exists, otherwise the companion copy, where the item gets created. |
135
+
136
+ `_aligndev` is the directory under which `aligndev` writes its session files: `<companion>/.plans` when `true`, the resolved `.plans` otherwise. `"_aligndev": true` requires `.plans` set to `true` or `false`. When the two differ, archiving and `ticket <id>` apply to both trees, each with its own `_archives/`.
137
+
138
+ `alignfirst config` reports the companion and the location of every item. `alignfirst doctor` checks them.
139
+
140
+ ### Project instructions
141
+
142
+ `.alignfirst.md` holds free prose for the coding agent: the project instructions a prepared project keeps in its `AGENTS.md`. `alignfirst context` prints it under `# Project Instructions`. It resolves like the other items, so a project copy works too.
143
+
144
+ ### Agent bootstrap
145
+
146
+ An agent reads a repository's `AGENTS.md` on its own, but never a companion. When your repositories carry no AlignFirst instructions, add this line to your global agent instructions (`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, or the equivalent):
147
+
148
+ ```text
149
+ In a git repository, run `alignfirst context` once before investigating, unless the project's instructions already say so.
150
+ ```
151
+
100
152
  ## Upgrade from v1, v2, or v3
101
153
 
102
154
  Install the setup-guide skill and ask your agent to run its upgrade route:
package/dist/cli.js CHANGED
@@ -12,6 +12,7 @@ import { runPlans } from "./commands/plans.js";
12
12
  import { runSync } from "./commands/sync.js";
13
13
  import { runTicket } from "./commands/ticket.js";
14
14
  import { resolveProjectConfig } from "./project-config.js";
15
+ import { layoutOf } from "./project-layout.js";
15
16
  import { checkCliRange } from "./version-guard.js";
16
17
  export async function main(options) {
17
18
  const argv = options?.argv ?? process.argv;
@@ -36,7 +37,7 @@ export async function main(options) {
36
37
  return 0;
37
38
  }
38
39
  if (command !== "config" && command !== "doctor") {
39
- ctx.projectConfig = resolveProjectConfig(ctx.cwd);
40
+ ctx.projectConfig = resolveProjectConfig(layoutOf(ctx));
40
41
  checkCliRange(ctx.projectConfig?.config, ctx.version, [command, ...args]);
41
42
  }
42
43
  const code = await dispatch(ctx, command, args);
@@ -1,15 +1,16 @@
1
1
  import { CliError } from "../cli-error.js";
2
2
  import { parseArgs } from "node:util";
3
3
  import { parseCommandArgs } from "../parse-args.js";
4
- import { resolveProjectConfig } from "../project-config.js";
4
+ import { resolveProjectConfig, } from "../project-config.js";
5
+ import { ITEM_NAMES, layoutOf, renderItemLocation, } from "../project-layout.js";
5
6
  import { cliRangeResult } from "../version-guard.js";
6
7
  export function runConfig(ctx, args) {
7
8
  const usage = `Usage: ${ctx.form} config [--json]\n`;
8
9
  const json = parseConfigArgs(ctx, args, usage);
9
10
  if (json === undefined)
10
11
  return 0;
11
- const resolved = resolveProjectConfig(ctx.cwd);
12
- const report = buildConfigReport(ctx, resolved);
12
+ const layout = layoutOf(ctx);
13
+ const report = buildConfigReport(ctx, layout, resolveProjectConfig(layout));
13
14
  ctx.stdout.write(json ? `${JSON.stringify(report, undefined, 2)}\n` : renderConfigReport(report));
14
15
  return 0;
15
16
  }
@@ -31,12 +32,14 @@ function parseConfigArgs(ctx, args, usage) {
31
32
  throw new CliError(`Unexpected argument: ${positionals[0]}\n\n${usage}`);
32
33
  return values.json;
33
34
  }
34
- function buildConfigReport(ctx, resolved) {
35
+ function buildConfigReport(ctx, layout, resolved) {
35
36
  const cli = cliRangeResult(resolved?.config, ctx.version);
36
37
  return {
37
38
  source: resolved?.source ?? null,
38
39
  cli: cli ? { installed: ctx.version, range: cli.range, satisfied: cli.satisfied } : null,
39
40
  config: resolved?.config ?? null,
41
+ companion: layout.companion,
42
+ locations: layout.locations,
40
43
  };
41
44
  }
42
45
  function renderConfigReport(report) {
@@ -45,7 +48,15 @@ function renderConfigReport(report) {
45
48
  lines.push(`CLI range: ${report.cli.range}, ${report.cli.satisfied ? "satisfied" : "not satisfied"} by ${report.cli.installed}`);
46
49
  else
47
50
  lines.push("CLI range: none");
51
+ lines.push(renderCompanionLine(report.companion));
52
+ for (const name of ITEM_NAMES)
53
+ lines.push(renderItemLocation(name, report.locations[name]));
48
54
  if (report.config)
49
55
  lines.push("Config:", JSON.stringify(report.config, undefined, 2));
50
56
  return `${lines.join("\n")}\n`;
51
57
  }
58
+ function renderCompanionLine(companion) {
59
+ if (companion === null)
60
+ return "Companion: none";
61
+ return `Companion: ${companion.dir}${companion.exists ? "" : " (missing)"}`;
62
+ }
@@ -1,18 +1,38 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { readFileSync } from "node:fs";
2
+ import { CliError } from "../cli-error.js";
3
3
  import { renderCommandForm } from "../command-form.js";
4
4
  import { renderConventions } from "../conventions.js";
5
+ import { errorMessage } from "../errors.js";
5
6
  import { parseBareCommandArgs } from "../parse-args.js";
7
+ import { layoutOf } from "../project-layout.js";
6
8
  import { runDocmap } from "./docmap.js";
7
9
  export function runContext(ctx, args) {
8
10
  const usage = `Usage: ${ctx.form} context\n`;
9
11
  if (parseBareCommandArgs(ctx, args, usage))
10
12
  return 0;
11
13
  ctx.stdout.write(`# Project Conventions\n\n${renderConventions(ctx)}`);
12
- const code = existsSync(join(ctx.cwd, "docs")) ? writeDocmapSection(ctx) : 0;
14
+ writeProjectInstructions(ctx);
15
+ const code = layoutOf(ctx).locations.docs.exists ? writeDocmapSection(ctx) : 0;
13
16
  ctx.stdout.write(`\n${renderProtocolsSection(ctx)}`);
14
17
  return code;
15
18
  }
19
+ function writeProjectInstructions(ctx) {
20
+ const location = layoutOf(ctx).locations[".alignfirst.md"];
21
+ if (!location.exists)
22
+ return;
23
+ const content = readInstructions(location.path).trim();
24
+ if (content === "")
25
+ return;
26
+ ctx.stdout.write(`\n# Project Instructions\n\n${content}\n`);
27
+ }
28
+ function readInstructions(path) {
29
+ try {
30
+ return readFileSync(path, "utf-8");
31
+ }
32
+ catch (error) {
33
+ throw new CliError(`Cannot read ${path}: ${errorMessage(error)}`);
34
+ }
35
+ }
16
36
  function writeDocmapSection(ctx) {
17
37
  ctx.stdout.write("\n# Docmap Usage\n\n");
18
38
  return runDocmap(ctx, []);
@@ -1,7 +1,10 @@
1
1
  import { main as docmapMain } from "@alignfirst/docmap";
2
+ import { layoutOf } from "../project-layout.js";
2
3
  export function runDocmap(ctx, args) {
4
+ const docs = layoutOf(ctx).locations.docs;
5
+ const rootArgs = docs.in === "companion" && !args.includes("--root") ? ["--root", docs.path] : [];
3
6
  return docmapMain({
4
- argv: ["node", "docmap", ...args],
7
+ argv: ["node", "docmap", ...rootArgs, ...args],
5
8
  cwd: ctx.cwd,
6
9
  stdout: ctx.stdout,
7
10
  stderr: ctx.stderr,
@@ -1,6 +1,5 @@
1
1
  import { existsSync, realpathSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
- import { join } from "node:path";
4
3
  import { fileURLToPath } from "node:url";
5
4
  import semver from "semver";
6
5
  import { resolveDefaultBranch } from "../default-branch.js";
@@ -9,6 +8,7 @@ import { parseBareCommandArgs } from "../parse-args.js";
9
8
  import { resolvePlansMode } from "../plans/mode.js";
10
9
  import { findStoppedRebase } from "../plans/rebase.js";
11
10
  import { PROJECT_CONFIG_FILENAME, resolveProjectConfig, } from "../project-config.js";
11
+ import { companionsPath, ITEM_NAMES, layoutOf, renderItemLocation, } from "../project-layout.js";
12
12
  import { COMMAND_SKILLS, findInstalledSkill } from "../skills.js";
13
13
  import { cliRangeResult } from "../version-guard.js";
14
14
  export function runDoctor(ctx, args) {
@@ -18,9 +18,10 @@ export function runDoctor(ctx, args) {
18
18
  writeSection(ctx, "CLI", () => inspectCli(ctx));
19
19
  let resolved;
20
20
  writeSection(ctx, PROJECT_CONFIG_FILENAME, () => {
21
- resolved = resolveProjectConfig(ctx.cwd);
21
+ resolved = resolveProjectConfig(layoutOf(ctx));
22
22
  return inspectConfig(ctx, resolved);
23
23
  });
24
+ writeSection(ctx, "Companion", () => inspectCompanion(ctx));
24
25
  writeSection(ctx, "Git", () => inspectGit(ctx, resolved));
25
26
  writeSection(ctx, "Work files", () => inspectPlans(ctx));
26
27
  writeSection(ctx, "Docmap", () => inspectDocmap(ctx));
@@ -65,6 +66,28 @@ function inspectConfig(ctx, resolved) {
65
66
  lines.push({ level: "warn", text: `${ctx.version} is ahead of ${result.range}` });
66
67
  return lines;
67
68
  }
69
+ function inspectCompanion(ctx) {
70
+ const path = companionsPath(ctx.home);
71
+ const layout = layoutOf(ctx);
72
+ const file = {
73
+ level: "ok",
74
+ text: `companions.json ${existsSync(path) ? "valid" : "absent"} (${path})`,
75
+ };
76
+ if (layout.companion === null)
77
+ return [file, { level: "ok", text: "none" }];
78
+ const { companion } = layout;
79
+ return [
80
+ file,
81
+ { level: "ok", text: `matched by ${companion.entries.join(", ")}` },
82
+ { level: "ok", text: `directory ${companion.dir}${companion.exists ? "" : " (missing)"}` },
83
+ ...ITEM_NAMES.map((name) => describeItem(name, layout, companion)),
84
+ ];
85
+ }
86
+ function describeItem(name, layout, companion) {
87
+ const location = layout.locations[name];
88
+ const missingCopy = companion.flags[name] === true && !location.exists;
89
+ return { level: missingCopy ? "warn" : "ok", text: renderItemLocation(name, location) };
90
+ }
68
91
  function inspectGit(ctx, resolved) {
69
92
  const branch = resolveDefaultBranch(ctx.cwd, resolved?.config);
70
93
  if (branch === undefined)
@@ -73,7 +96,7 @@ function inspectGit(ctx, resolved) {
73
96
  return [{ level: "ok", text: `default branch ${branch.name} (${source})` }];
74
97
  }
75
98
  function inspectPlans(ctx) {
76
- const mode = resolvePlansMode(ctx.cwd, ctx.form);
99
+ const mode = resolvePlansMode(ctx.cwd, layoutOf(ctx).locations[".plans"], ctx.form);
77
100
  if (mode.kind === "shared" && findStoppedRebase(mode.repoToplevel) !== undefined)
78
101
  return [{ level: "error", text: `rebase stopped on a conflict in ${mode.repoToplevel}` }];
79
102
  return [
@@ -84,7 +107,7 @@ function inspectPlans(ctx) {
84
107
  ];
85
108
  }
86
109
  function inspectDocmap(ctx) {
87
- const present = existsSync(join(ctx.cwd, "docs"));
110
+ const present = layoutOf(ctx).locations.docs.exists;
88
111
  return [
89
112
  { level: "ok", text: `docs/ ${present ? "present" : "none"}` },
90
113
  { level: "ok", text: `embedded docmap ${readDocmapVersion()}` },
@@ -1,5 +1,4 @@
1
- import { lstatSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { readFileSync } from "node:fs";
3
2
  import { parseArgs } from "node:util";
4
3
  import { CliError } from "../cli-error.js";
5
4
  import { renderCommandForm } from "../command-form.js";
@@ -9,6 +8,7 @@ import { parseCommandArgs } from "../parse-args.js";
9
8
  import { missingPlansMessage } from "../plans/layout.js";
10
9
  import { resolvePlansMode } from "../plans/mode.js";
11
10
  import { detectTicketFromBranch } from "../plans/ticket.js";
11
+ import { layoutOf } from "../project-layout.js";
12
12
  import { PROTOCOLS } from "../protocols.js";
13
13
  const TICKET_CMD_PLACEHOLDER = "{{TICKET_CMD}}";
14
14
  const TICKET_DETECTION_PLACEHOLDER = "{{TICKET_DETECTION}}";
@@ -148,11 +148,11 @@ function renderTicketDetection(pattern, detection) {
148
148
  return "No ticket id: detached HEAD. Ask the user for the id.";
149
149
  }
150
150
  function renderPlansState(ctx) {
151
- const entry = lstatSync(join(ctx.cwd, ".plans"), { throwIfNoEntry: false });
152
- if (entry === undefined)
153
- return `\`\`\`text\n${missingPlansMessage(ctx.form)}\n\`\`\``;
151
+ const plans = layoutOf(ctx).locations[".plans"];
152
+ if (!plans.exists)
153
+ return `\`\`\`text\n${missingPlansMessage(plans, ctx.form)}\n\`\`\``;
154
154
  try {
155
- return resolvePlansMode(ctx.cwd, ctx.form).kind === "shared"
155
+ return resolvePlansMode(ctx.cwd, plans, ctx.form).kind === "shared"
156
156
  ? "After every change in TICKET_DIR, run `{{CMD}} sync`."
157
157
  : "";
158
158
  }
@@ -2,14 +2,16 @@ import { existsSync, mkdirSync, realpathSync, statSync } from "node:fs";
2
2
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
3
  import { parseArgs } from "node:util";
4
4
  import { CliError } from "../cli-error.js";
5
+ import { displayPath } from "../format.js";
5
6
  import { assertMainWorktreeRoot } from "../git.js";
6
7
  import { parseBareCommandArgs, parseCommandArgs } from "../parse-args.js";
7
- import { archiveEntry, archiveThresholdDays, autoArchive } from "../plans/archive.js";
8
+ import { archiveThresholdDays, archiveTicket, autoArchive } from "../plans/archive.js";
8
9
  import { isTicketName } from "../plans/layout.js";
9
10
  import { linkPlans } from "../plans/link.js";
10
11
  import { resolvePlansMode } from "../plans/mode.js";
11
12
  import { findStoppedRebase, renderStoppedRebase } from "../plans/rebase.js";
12
- const RESERVED_PLANS_FOLDERS = new Set([".git", ".plans", "_archives", "_project"]);
13
+ import { layoutOf } from "../project-layout.js";
14
+ const RESERVED_PLANS_FOLDERS = new Set([".git", ".plans", "_archives"]);
13
15
  export function runPlans(ctx, args) {
14
16
  const [command, ...rest] = args;
15
17
  switch (command) {
@@ -46,7 +48,7 @@ function runSetup(ctx, args) {
46
48
  const cloneDir = resolve(ctx.cwd, parsed.dir);
47
49
  checkClone(ctx, cloneDir);
48
50
  const projectDir = createPlansDirectory(cloneDir, parsed.folder);
49
- linkPlans(ctx, projectDir);
51
+ linkPlans(ctx, layoutOf(ctx).locations[".plans"].path, projectDir);
50
52
  return 0;
51
53
  }
52
54
  function createPlansDirectory(cloneDir, folder) {
@@ -104,7 +106,7 @@ function runCheck(ctx, args) {
104
106
  const usage = `Usage: ${ctx.form} plans check\n`;
105
107
  if (parseBareCommandArgs(ctx, args, usage))
106
108
  return 0;
107
- const mode = resolvePlansMode(ctx.cwd, ctx.form);
109
+ const mode = resolvePlansMode(ctx.cwd, layoutOf(ctx).locations[".plans"], ctx.form);
108
110
  if (mode.kind === "shared") {
109
111
  const stopped = findStoppedRebase(mode.repoToplevel);
110
112
  if (stopped !== undefined)
@@ -120,25 +122,26 @@ function runAutoArchive(ctx, args) {
120
122
  const usage = `Usage: ${ctx.form} plans auto-archive\n`;
121
123
  if (parseBareCommandArgs(ctx, args, usage))
122
124
  return 0;
123
- const mode = resolvePlansMode(ctx.cwd, ctx.form);
124
- const archived = autoArchive(join(ctx.cwd, ".plans"), archiveThresholdDays(ctx.env), ctx.stdout);
125
+ const layout = layoutOf(ctx);
126
+ const mode = resolvePlansMode(ctx.cwd, layout.locations[".plans"], ctx.form);
127
+ const archived = autoArchive(layout, archiveThresholdDays(ctx.env), ctx.stdout);
125
128
  if (mode.kind === "shared" && archived)
126
129
  ctx.stdout.write(`Publish with: ${ctx.form} sync\n`);
127
130
  return 0;
128
131
  }
129
132
  function runArchive(ctx, args) {
130
133
  const usage = `Usage: ${ctx.form} plans archive <ticket-id | path>\n`;
131
- const target = resolveArchiveTarget(ctx, args, usage);
132
- if (target === undefined)
134
+ const ticket = resolveArchiveTicket(ctx, args, usage);
135
+ if (ticket === undefined)
133
136
  return 0;
134
- const mode = resolvePlansMode(ctx.cwd, ctx.form);
135
- const root = join(ctx.cwd, ".plans");
136
- archiveEntry(root, target, ctx.stdout);
137
+ const layout = layoutOf(ctx);
138
+ const mode = resolvePlansMode(ctx.cwd, layout.locations[".plans"], ctx.form);
139
+ archiveTicket(layout, ticket, ctx.stdout);
137
140
  if (mode.kind === "shared")
138
141
  ctx.stdout.write(`Publish with: ${ctx.form} sync\n`);
139
142
  return 0;
140
143
  }
141
- function resolveArchiveTarget(ctx, args, usage) {
144
+ function resolveArchiveTicket(ctx, args, usage) {
142
145
  const { values, positionals } = parseCommandArgs(usage, () => parseArgs({
143
146
  args,
144
147
  options: { help: { type: "boolean", short: "h", default: false } },
@@ -152,15 +155,15 @@ function resolveArchiveTarget(ctx, args, usage) {
152
155
  if (positionals.length !== 1)
153
156
  throw new CliError(usage.trimEnd());
154
157
  const argument = positionals[0];
155
- const plansDir = join(ctx.cwd, ".plans");
158
+ const plansDir = layoutOf(ctx).locations[".plans"].path;
156
159
  const target = isPathArgument(argument) ? resolve(ctx.cwd, argument) : join(plansDir, argument);
157
160
  const stats = statSync(target, { throwIfNoEntry: false });
158
161
  if (!stats?.isDirectory() || realpathSync(dirname(target)) !== realpathSync(plansDir))
159
- throw new CliError(`${argument} must be an existing directory directly under .plans.`);
162
+ throw new CliError(`${argument} must be an existing directory directly under ${displayPath(ctx.cwd, plansDir)}.`);
160
163
  const name = basename(target);
161
164
  if (!isTicketName(name))
162
165
  throw new CliError(`${argument}: names starting with _ are not tickets.`);
163
- return join(plansDir, name);
166
+ return name;
164
167
  }
165
168
  function isPathArgument(argument) {
166
169
  return argument.includes("/") || argument.includes("\\");
@@ -1,4 +1,3 @@
1
- import { join } from "node:path";
2
1
  import { parseArgs } from "node:util";
3
2
  import { CliError } from "../cli-error.js";
4
3
  import { git, gitOutput, gitSucceeds } from "../git.js";
@@ -6,6 +5,7 @@ import { parseCommandArgs } from "../parse-args.js";
6
5
  import { archiveThresholdDays, autoArchive } from "../plans/archive.js";
7
6
  import { resolvePlansMode } from "../plans/mode.js";
8
7
  import { findStoppedRebase, renderStoppedRebase, resolveStoppedRebase } from "../plans/rebase.js";
8
+ import { layoutOf } from "../project-layout.js";
9
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);
@@ -17,11 +17,11 @@ export async function runSync(ctx, args) {
17
17
  ? false
18
18
  : (ctx.projectConfig?.config.plans?.autoArchive ?? false);
19
19
  const thresholdDays = enabled ? archiveThresholdDays(ctx.env) : undefined;
20
- const mode = resolvePlansMode(ctx.cwd, ctx.form);
21
- const plansDir = join(ctx.cwd, ".plans");
20
+ const layout = layoutOf(ctx);
21
+ const mode = resolvePlansMode(ctx.cwd, layout.locations[".plans"], ctx.form);
22
22
  if (mode.kind === "local") {
23
23
  if (thresholdDays !== undefined)
24
- autoArchive(plansDir, thresholdDays, ctx.stdout);
24
+ autoArchive(layout, thresholdDays, ctx.stdout);
25
25
  ctx.stdout.write("(local mode, nothing to sync)\n");
26
26
  return 0;
27
27
  }
@@ -40,7 +40,7 @@ export async function runSync(ctx, args) {
40
40
  await resolveStoppedRebase(ctx, repoDir);
41
41
  }
42
42
  }
43
- if (thresholdDays !== undefined && autoArchive(plansDir, thresholdDays, ctx.stdout)) {
43
+ if (thresholdDays !== undefined && autoArchive(layout, thresholdDays, ctx.stdout)) {
44
44
  await git(ctx, repoDir, "add", "-A");
45
45
  if (hasStagedChanges(repoDir))
46
46
  await git(ctx, repoDir, "commit", "--quiet", "-m", "sync");
@@ -1,11 +1,12 @@
1
- import { join, relative } from "node:path";
1
+ import { join } from "node:path";
2
2
  import { parseArgs } from "node:util";
3
3
  import { CliError } from "../cli-error.js";
4
- import { formatLocalTimestamp, formatSize } from "../format.js";
4
+ import { displayPath, formatLocalTimestamp, formatSize } from "../format.js";
5
5
  import { parseCommandArgs } from "../parse-args.js";
6
6
  import { renderCatchup } from "../plans/catchup.js";
7
7
  import { assertPlansGate } from "../plans/layout.js";
8
- import { detectTicketFromBranch, deduceTicketFromExisting, nextFilePosition, peekSideTicket, reserveSideTicket, resolveTicketDir, validateTicketId, } from "../plans/ticket.js";
8
+ import { detectTicketFromBranch, deduceTicketFromExisting, nextFilePosition, peekSideTicket, reserveSideTicket, resolveTicketDir, restoreSessionTreeTicket, validateTicketId, } from "../plans/ticket.js";
9
+ import { layoutOf } from "../project-layout.js";
9
10
  const USAGE = `Usage:
10
11
  {{FORM}} ticket [<id>] [--next [<filename>]] [--new-cycle] [--json] [--dry-run]
11
12
  {{FORM}} ticket --side [--next [<filename>]] [--new-cycle] [--json] [--dry-run]
@@ -22,8 +23,12 @@ export function runTicket(ctx, args) {
22
23
  const parsed = parseTicketArgs(ctx, args, usage);
23
24
  if (parsed === undefined)
24
25
  return 0;
25
- assertPlansGate(ctx.cwd, ctx.form);
26
- const result = resolveTicket(ctx, parsed);
26
+ const layout = layoutOf(ctx);
27
+ const plans = layout.locations[".plans"];
28
+ assertPlansGate(plans, ctx.form);
29
+ const result = resolveTicket(plans.path, parsed);
30
+ if (!parsed.side && !parsed.dryRun)
31
+ restoreSessionTreeTicket(layout, result.id);
27
32
  if (parsed.catchup) {
28
33
  ctx.stdout.write(renderCatchup(ctx.cwd, result, renderReport(ctx, parsed, result)));
29
34
  return 0;
@@ -129,8 +134,11 @@ function resolveTicketId(ctx, positional, flags) {
129
134
  validateTicketId(positional);
130
135
  return { id: positional };
131
136
  }
132
- if (flags.side)
133
- return { id: flags["dry-run"] ? peekSideTicket(ctx.cwd) : reserveSideTicket(ctx.cwd) };
137
+ const plans = layoutOf(ctx).locations[".plans"];
138
+ if (flags.side) {
139
+ assertPlansGate(plans, ctx.form);
140
+ return { id: flags["dry-run"] ? peekSideTicket(plans.path) : reserveSideTicket(plans.path) };
141
+ }
134
142
  const template = ctx.projectConfig?.config.git?.branchNameTemplate;
135
143
  const detection = detectTicketFromBranch(ctx.cwd, pattern, template);
136
144
  if (detection.kind === "detected") {
@@ -138,28 +146,23 @@ function resolveTicketId(ctx, positional, flags) {
138
146
  return detection;
139
147
  }
140
148
  if (pattern === undefined)
141
- return deduceTicketFromExisting(ctx.cwd, { sideAllowed: !flags.catchup });
149
+ return deduceTicketFromExisting(ctx.cwd, plans.path, { sideAllowed: !flags.catchup });
142
150
  if (detection.kind === "noBranch")
143
151
  throw new CliError("Cannot deduce a ticket id from a detached HEAD.");
144
152
  const templateDetail = template?.includes("{TICKET_ID}") ? ` and template "${template}"` : "";
145
153
  throw new CliError(`Cannot deduce a ticket id from branch "${detection.branch}" with pattern "${pattern}"${templateDetail}.`);
146
154
  }
147
- function resolveTicket(ctx, options) {
155
+ function resolveTicket(plansPath, options) {
148
156
  if (options.side && !options.dryRun)
149
- return {
150
- id: options.id,
151
- dir: join(ctx.cwd, ".plans", options.id),
152
- state: "created",
153
- entries: [],
154
- };
155
- return resolveTicketDir(ctx.cwd, options.id, { dryRun: options.dryRun });
157
+ return { id: options.id, dir: join(plansPath, options.id), state: "created", entries: [] };
158
+ return resolveTicketDir(plansPath, options.id, { dryRun: options.dryRun });
156
159
  }
157
160
  function writeNextReport(ctx, options, result, request) {
158
161
  const names = result.entries.map((entry) => entry.name);
159
162
  const { cycleLetter, fileNumber } = nextFilePosition(names, options.newCycle);
160
163
  const fileName = (filename, offset) => `${cycleLetter}${fileNumber + offset}-${filename}`;
161
164
  const report = {
162
- TICKET_DIR: `${relative(ctx.cwd, result.dir)}/`,
165
+ TICKET_DIR: `${displayPath(ctx.cwd, result.dir)}/`,
163
166
  CYCLE_LETTER: cycleLetter,
164
167
  ...(request === true
165
168
  ? { FILE_NUMBER: fileNumber, FILE_PREFIX: `${cycleLetter}${fileNumber}` }
@@ -179,7 +182,7 @@ function writeNextReport(ctx, options, result, request) {
179
182
  function jsonReport(ctx, options, result) {
180
183
  return {
181
184
  TICKET_ID: result.id,
182
- TICKET_DIR: `${relative(ctx.cwd, result.dir)}/`,
185
+ TICKET_DIR: `${displayPath(ctx.cwd, result.dir)}/`,
183
186
  state: result.state,
184
187
  ...(options.branch === undefined ? {} : { branch: options.branch }),
185
188
  entries: result.entries.map((entry) => ({
@@ -192,7 +195,7 @@ function renderReport(ctx, options, result) {
192
195
  const reservation = options.side && options.dryRun ? " (would be reserved)" : "";
193
196
  const deduction = options.branch === undefined ? "" : ` (deduced from branch \`${options.branch}\`)`;
194
197
  const directoryState = renderDirectoryState(result.state, options.dryRun);
195
- const directory = `${relative(ctx.cwd, result.dir)}/`;
198
+ const directory = `${displayPath(ctx.cwd, result.dir)}/`;
196
199
  const lines = [
197
200
  `- TICKET_ID: \`${result.id}\`${reservation}${deduction}`,
198
201
  `- TICKET_DIR: \`${directory}\`${directoryState}`,
package/dist/context.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ResolvedProjectConfig } from "./project-config.js";
2
+ import type { ProjectLayout } from "./project-layout.js";
2
3
  export interface Output {
3
4
  write(text: string): void;
4
5
  }
@@ -13,4 +14,5 @@ export interface CommandContext extends Streams {
13
14
  form: string;
14
15
  version: string;
15
16
  projectConfig?: ResolvedProjectConfig;
17
+ layout?: ProjectLayout;
16
18
  }
@@ -1,9 +1,11 @@
1
- import { existsSync, lstatSync } from "node:fs";
1
+ import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { renderDefaultBranchLine, resolveDefaultBranch } from "./default-branch.js";
4
4
  import { errorMessage } from "./errors.js";
5
+ import { displayPath } from "./format.js";
5
6
  import { gitOutputOrUndefined, gitSucceeds } from "./git.js";
6
7
  import { resolvePlansMode } from "./plans/mode.js";
8
+ import { layoutOf } from "./project-layout.js";
7
9
  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.";
8
10
  export function renderConventions(ctx) {
9
11
  const lines = [
@@ -44,15 +46,17 @@ export function commitSubject(commit) {
44
46
  return { subject: "`type: summary`" };
45
47
  }
46
48
  function renderPlans(ctx) {
47
- if (!plansEntryExists(ctx.cwd))
49
+ const plans = layoutOf(ctx).locations[".plans"];
50
+ if (!plans.exists)
48
51
  return;
49
52
  try {
50
- const mode = resolvePlansMode(ctx.cwd, ctx.form);
53
+ const mode = resolvePlansMode(ctx.cwd, plans, ctx.form);
51
54
  const folder = ctx.projectConfig?.config.plans?.folder;
52
55
  const sharedFolder = folder === undefined ? "" : ` (folder \`${folder}\` in the work-files repository)`;
56
+ const path = displayPath(ctx.cwd, plans.path);
53
57
  const base = mode.kind === "shared"
54
- ? `Work files: use \`.plans\`${sharedFolder}; run \`${ctx.form} sync\` after changes.`
55
- : "Work files: use `.plans`.";
58
+ ? `Work files: use \`${path}\`${sharedFolder}; run \`${ctx.form} sync\` after changes.`
59
+ : `Work files: use \`${path}\`.`;
56
60
  const archival = ctx.projectConfig?.config.plans?.autoArchive === true
57
61
  ? " Automatic archival is enabled."
58
62
  : "";
@@ -62,12 +66,10 @@ function renderPlans(ctx) {
62
66
  return `Work files: ${errorMessage(error).split("\n", 1)[0]}`;
63
67
  }
64
68
  }
65
- function plansEntryExists(cwd) {
66
- return lstatSync(join(cwd, ".plans"), { throwIfNoEntry: false }) !== undefined;
67
- }
68
69
  function renderSearches(ctx) {
70
+ const plans = layoutOf(ctx).locations[".plans"];
69
71
  const directories = [
70
- ...(plansEntryExists(ctx.cwd) ? [".plans"] : []),
72
+ ...(plans.exists && plans.in === "project" ? [".plans"] : []),
71
73
  ...[".local", ".local-wt"].filter((name) => qualifiesIgnoredDirectory(ctx.cwd, name)),
72
74
  ];
73
75
  if (directories.length === 0)
package/dist/format.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export declare function formatSize(bytes: number): string;
2
2
  export declare function formatLocalTimestamp(date: Date): string;
3
+ /** Relative to `cwd` when inside it, absolute otherwise. */
4
+ export declare function displayPath(cwd: string, path: string): string;
package/dist/format.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isAbsolute, relative, sep } from "node:path";
1
2
  const KIB = 1024;
2
3
  const MIB = KIB * KIB;
3
4
  export function formatSize(bytes) {
@@ -21,3 +22,10 @@ export function formatLocalTimestamp(date) {
21
22
  function pad(value) {
22
23
  return String(value).padStart(2, "0");
23
24
  }
25
+ /** Relative to `cwd` when inside it, absolute otherwise. */
26
+ export function displayPath(cwd, path) {
27
+ const rel = relative(cwd, path);
28
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
29
+ return path;
30
+ return rel;
31
+ }
@@ -1,4 +1,10 @@
1
1
  import type { Output } from "../context.js";
2
+ import { type ProjectLayout } from "../project-layout.js";
2
3
  export declare function archiveThresholdDays(env: NodeJS.ProcessEnv): number;
3
- export declare function autoArchive(plansDir: string, thresholdDays: number, stdout: Output): boolean;
4
- export declare function archiveEntry(plansDir: string, sourcePath: string, stdout: Output): void;
4
+ /**
5
+ * Archives the stale entries of `.plans`, and of a separate session tree into its own `_archives`.
6
+ * Returns whether `.plans` changed.
7
+ */
8
+ export declare function autoArchive(layout: ProjectLayout, thresholdDays: number, stdout: Output): boolean;
9
+ /** Archiving ticket `name` also archives its directory in a separate session tree. */
10
+ export declare function archiveTicket(layout: ProjectLayout, name: string, stdout: Output): void;