alignfirst 0.1.0-beta.0 → 0.1.0-beta.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.
Files changed (48) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.js +11 -12
  3. package/dist/commands/config.js +2 -6
  4. package/dist/commands/context.d.ts +2 -0
  5. package/dist/commands/context.js +11 -0
  6. package/dist/commands/conventions.d.ts +2 -0
  7. package/dist/commands/conventions.js +9 -0
  8. package/dist/commands/docmap.js +1 -13
  9. package/dist/commands/doctor.js +33 -55
  10. package/dist/commands/guide.js +80 -16
  11. package/dist/commands/plans.js +33 -22
  12. package/dist/commands/sync.js +47 -11
  13. package/dist/commands/ticket.js +12 -3
  14. package/dist/context.d.ts +1 -2
  15. package/dist/conventions.d.ts +8 -0
  16. package/dist/conventions.js +86 -0
  17. package/dist/default-branch.d.ts +8 -0
  18. package/dist/default-branch.js +32 -0
  19. package/dist/parse-args.d.ts +2 -0
  20. package/dist/parse-args.js +15 -0
  21. package/dist/plans/layout.d.ts +3 -0
  22. package/dist/plans/layout.js +7 -1
  23. package/dist/plans/mode.js +2 -2
  24. package/dist/plans/rebase.d.ts +6 -0
  25. package/dist/plans/rebase.js +27 -0
  26. package/dist/plans/ticket.d.ts +12 -1
  27. package/dist/plans/ticket.js +16 -8
  28. package/dist/project-config.d.ts +19 -12
  29. package/dist/project-config.js +37 -34
  30. package/dist/skills.d.ts +0 -2
  31. package/dist/skills.js +0 -21
  32. package/dist/version-guard.d.ts +0 -1
  33. package/dist/version-guard.js +0 -7
  34. package/package.json +1 -1
  35. package/templates/guide/core.md +9 -9
  36. package/templates/guide/protocols/aad.md +3 -3
  37. package/templates/guide/protocols/catchup.md +1 -1
  38. package/templates/guide/protocols/description.md +3 -3
  39. package/templates/guide/protocols/merge.md +3 -3
  40. package/templates/guide/protocols/plan.md +3 -3
  41. package/templates/guide/protocols/review.md +3 -3
  42. package/templates/guide/protocols/spec.md +3 -3
  43. package/dist/commands/developers.d.ts +0 -2
  44. package/dist/commands/developers.js +0 -36
  45. package/dist/commands/setup.d.ts +0 -2
  46. package/dist/commands/setup.js +0 -254
  47. package/dist/overlay.d.ts +0 -19
  48. package/dist/overlay.js +0 -83
package/README.md CHANGED
@@ -21,9 +21,9 @@ Commands:
21
21
  - `sync` — Synchronize shared plans.
22
22
  - `plans` — Set up, check and archive plans.
23
23
  - `docmap` — Browse project documentation.
24
+ - `conventions` — Print the effective project conventions.
25
+ - `context` — Print the conventions and the documentation map.
24
26
  - `config` — Report the effective project configuration.
25
- - `DEVELOPERS.md` — Print the project developer guide.
26
- - `setup` — Prepare an AlignFirst project.
27
27
  - `doctor` — Diagnose an AlignFirst setup.
28
28
 
29
29
  Run `alignfirst --help` for command usage or `alignfirst guide` for the collaboration guide.
package/dist/cli.js CHANGED
@@ -3,15 +3,15 @@ import { homedir } from "node:os";
3
3
  import { CliError } from "./cli-error.js";
4
4
  import { resolveCommandForm } from "./command-form.js";
5
5
  import { runConfig } from "./commands/config.js";
6
- import { runDevelopers } from "./commands/developers.js";
6
+ import { runContext } from "./commands/context.js";
7
+ import { runConventions } from "./commands/conventions.js";
7
8
  import { runDoctor } from "./commands/doctor.js";
8
9
  import { runDocmap } from "./commands/docmap.js";
9
10
  import { runGuide } from "./commands/guide.js";
10
11
  import { runPlans } from "./commands/plans.js";
11
- import { runSetup } from "./commands/setup.js";
12
12
  import { runSync } from "./commands/sync.js";
13
13
  import { runTicket } from "./commands/ticket.js";
14
- import { resolveProjectConfig } from "./overlay.js";
14
+ import { resolveProjectConfig } from "./project-config.js";
15
15
  import { checkCliRange } from "./version-guard.js";
16
16
  export async function main(options) {
17
17
  const argv = options?.argv ?? process.argv;
@@ -36,8 +36,7 @@ export async function main(options) {
36
36
  return 0;
37
37
  }
38
38
  if (command !== "config" && command !== "doctor") {
39
- ctx.projectConfig = resolveProjectConfig(ctx.cwd, ctx.env, ctx.home);
40
- ctx.overlay = ctx.projectConfig?.overlay;
39
+ ctx.projectConfig = resolveProjectConfig(ctx.cwd);
41
40
  checkCliRange(ctx.projectConfig?.config, ctx.version, [command, ...args]);
42
41
  }
43
42
  return dispatch(ctx, command, args);
@@ -61,12 +60,12 @@ function renderHelp(ctx) {
61
60
  Usage:
62
61
  ${ctx.form} guide [<protocol>]
63
62
  ${ctx.form} ticket [<id>]
64
- ${ctx.form} sync [--auto-archive]
63
+ ${ctx.form} sync [--auto-archive | --no-auto-archive]
65
64
  ${ctx.form} plans <command>
66
65
  ${ctx.form} docmap [<arguments>]
66
+ ${ctx.form} conventions
67
+ ${ctx.form} context
67
68
  ${ctx.form} config [--json]
68
- ${ctx.form} DEVELOPERS.md
69
- ${ctx.form} setup [<options>]
70
69
  ${ctx.form} doctor
71
70
  ${ctx.form} --help
72
71
  ${ctx.form} --version
@@ -84,12 +83,12 @@ function dispatch(ctx, command, args) {
84
83
  return runPlans(ctx, args);
85
84
  case "docmap":
86
85
  return runDocmap(ctx, args);
86
+ case "conventions":
87
+ return runConventions(ctx, args);
88
+ case "context":
89
+ return runContext(ctx, args);
87
90
  case "config":
88
91
  return runConfig(ctx, args);
89
- case "DEVELOPERS.md":
90
- return runDevelopers(ctx, args);
91
- case "setup":
92
- return runSetup(ctx, args);
93
92
  case "doctor":
94
93
  return runDoctor(ctx, args);
95
94
  default:
@@ -1,14 +1,14 @@
1
1
  import { CliError } from "../cli-error.js";
2
2
  import { parseArgs } from "node:util";
3
- import { resolveProjectConfig } from "../overlay.js";
4
3
  import { parseCommandArgs } from "../parse-args.js";
4
+ import { resolveProjectConfig } from "../project-config.js";
5
5
  import { cliRangeResult } from "../version-guard.js";
6
6
  export function runConfig(ctx, args) {
7
7
  const usage = `Usage: ${ctx.form} config [--json]\n`;
8
8
  const json = parseConfigArgs(ctx, args, usage);
9
9
  if (json === undefined)
10
10
  return 0;
11
- const resolved = resolveProjectConfig(ctx.cwd, ctx.env, ctx.home);
11
+ const resolved = resolveProjectConfig(ctx.cwd);
12
12
  const report = buildConfigReport(ctx, resolved);
13
13
  ctx.stdout.write(json ? `${JSON.stringify(report, undefined, 2)}\n` : renderConfigReport(report));
14
14
  return 0;
@@ -32,19 +32,15 @@ function parseConfigArgs(ctx, args, usage) {
32
32
  return values.json;
33
33
  }
34
34
  function buildConfigReport(ctx, resolved) {
35
- const overlay = resolved?.overlay;
36
35
  const cli = cliRangeResult(resolved?.config, ctx.version);
37
36
  return {
38
37
  source: resolved?.source ?? null,
39
- overlay: overlay ? { dir: overlay.dir, matchedBy: overlay.matchedBy } : null,
40
38
  cli: cli ? { installed: ctx.version, range: cli.range, satisfied: cli.satisfied } : null,
41
39
  config: resolved?.config ?? null,
42
40
  };
43
41
  }
44
42
  function renderConfigReport(report) {
45
43
  const lines = [`Source: ${report.source ?? "none"}`];
46
- if (report.overlay)
47
- lines.push(`Overlay: ${report.overlay.dir} (matched by ${report.overlay.matchedBy})`);
48
44
  if (report.cli)
49
45
  lines.push(`CLI range: ${report.cli.range}, ${report.cli.satisfied ? "satisfied" : "not satisfied"} by ${report.cli.installed}`);
50
46
  else
@@ -0,0 +1,2 @@
1
+ import type { CommandContext } from "../context.js";
2
+ export declare function runContext(ctx: CommandContext, args: string[]): number;
@@ -0,0 +1,11 @@
1
+ import { renderConventions } from "../conventions.js";
2
+ import { parseBareCommandArgs } from "../parse-args.js";
3
+ import { runDocmap } from "./docmap.js";
4
+ export function runContext(ctx, args) {
5
+ const usage = `Usage: ${ctx.form} context\n`;
6
+ if (parseBareCommandArgs(ctx, args, usage))
7
+ return 0;
8
+ ctx.stdout.write(renderConventions(ctx));
9
+ ctx.stdout.write("\n");
10
+ return runDocmap(ctx, []);
11
+ }
@@ -0,0 +1,2 @@
1
+ import type { CommandContext } from "../context.js";
2
+ export declare function runConventions(ctx: CommandContext, args: string[]): number;
@@ -0,0 +1,9 @@
1
+ import { renderConventions } from "../conventions.js";
2
+ import { parseBareCommandArgs } from "../parse-args.js";
3
+ export function runConventions(ctx, args) {
4
+ const usage = `Usage: ${ctx.form} conventions\n`;
5
+ if (parseBareCommandArgs(ctx, args, usage))
6
+ return 0;
7
+ ctx.stdout.write(renderConventions(ctx));
8
+ return 0;
9
+ }
@@ -1,22 +1,10 @@
1
- import { existsSync } from "node:fs";
2
- import { join } from "node:path";
3
1
  import { main as docmapMain } from "@paleo/docmap";
4
- import { resolveProjectFile } from "../overlay.js";
5
2
  export function runDocmap(ctx, args) {
6
- const docmapArgs = withOverlayRoot(ctx, args);
7
3
  return docmapMain({
8
- argv: ["node", "docmap", ...docmapArgs],
4
+ argv: ["node", "docmap", ...args],
9
5
  cwd: ctx.cwd,
10
6
  stdout: ctx.stdout,
11
7
  stderr: ctx.stderr,
12
8
  commands: { base: `${ctx.form} docmap`, withArgs: `${ctx.form} docmap` },
13
9
  });
14
10
  }
15
- function withOverlayRoot(ctx, args) {
16
- if (args.includes("--root") || existsSync(join(ctx.cwd, "docs")))
17
- return args;
18
- const docs = resolveProjectFile(ctx.cwd, ctx.overlay, "docs");
19
- if (docs?.source !== "overlay")
20
- return args;
21
- return [...args, "--root", docs.path];
22
- }
@@ -1,45 +1,35 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { realpathSync } from "node:fs";
2
+ import { existsSync, realpathSync } from "node:fs";
3
3
  import { createRequire } from "node:module";
4
+ import { join } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
- import { parseArgs } from "node:util";
6
6
  import semver from "semver";
7
- import { CliError } from "../cli-error.js";
7
+ import { resolveDefaultBranch } from "../default-branch.js";
8
8
  import { errorMessage } from "../errors.js";
9
9
  import { findExecutable } from "../executables.js";
10
- import { findOverlay, resolveProjectConfig, resolveProjectFile, } from "../overlay.js";
11
- import { parseCommandArgs } from "../parse-args.js";
10
+ import { parseBareCommandArgs } from "../parse-args.js";
12
11
  import { resolvePlansMode } from "../plans/mode.js";
12
+ import { findStoppedRebase } from "../plans/rebase.js";
13
+ import { resolveProjectConfig } from "../project-config.js";
13
14
  import { findInstalledSkill, STUB_SKILLS } from "../skills.js";
14
15
  import { cliRangeResult } from "../version-guard.js";
15
- const PROJECT_CONFIG_NAME = ".alignfirst.json";
16
16
  export function runDoctor(ctx, args) {
17
17
  const usage = `Usage: ${ctx.form} doctor\n`;
18
- if (parseDoctorArgs(ctx, args, usage))
18
+ if (parseBareCommandArgs(ctx, args, usage))
19
19
  return 0;
20
20
  writeSection(ctx, "CLI", () => inspectCli(ctx));
21
- writeSection(ctx, "Config", () => inspectConfig(ctx));
21
+ let resolved;
22
+ writeSection(ctx, "Config", () => {
23
+ resolved = resolveProjectConfig(ctx.cwd);
24
+ return inspectConfig(ctx, resolved);
25
+ });
26
+ writeSection(ctx, "Git", () => inspectGit(ctx, resolved));
22
27
  writeSection(ctx, "Plans", () => inspectPlans(ctx));
23
28
  writeSection(ctx, "Docmap", () => inspectDocmap(ctx));
24
29
  writeSection(ctx, "Skills", () => inspectSkills(ctx));
25
- writeSection(ctx, "Overlay", () => inspectOverlay(ctx));
26
30
  writeSection(ctx, "Companion", () => inspectCompanion(ctx));
27
31
  return 0;
28
32
  }
29
- function parseDoctorArgs(ctx, args, usage) {
30
- const { values, positionals } = parseCommandArgs(usage, () => parseArgs({
31
- args,
32
- options: { help: { type: "boolean", short: "h", default: false } },
33
- strict: true,
34
- allowPositionals: true,
35
- }));
36
- if (positionals.length > 0)
37
- throw new CliError(`Unexpected argument: ${positionals[0]}\n\n${usage}`);
38
- if (!values.help)
39
- return false;
40
- ctx.stdout.write(usage);
41
- return true;
42
- }
43
33
  function writeSection(ctx, section, inspect) {
44
34
  let lines;
45
35
  try {
@@ -63,9 +53,8 @@ function inspectCli(ctx) {
63
53
  },
64
54
  ];
65
55
  }
66
- function inspectConfig(ctx) {
67
- const resolved = resolveProjectConfig(ctx.cwd, ctx.env, ctx.home);
68
- const lines = [{ level: "ok", text: `source ${configSource(resolved)}` }];
56
+ function inspectConfig(ctx, resolved) {
57
+ const lines = [{ level: "ok", text: resolved?.source ?? "none" }];
69
58
  const result = cliRangeResult(resolved?.config, ctx.version);
70
59
  if (result === undefined) {
71
60
  lines.push({ level: "ok", text: "no cli range" });
@@ -79,13 +68,17 @@ function inspectConfig(ctx) {
79
68
  lines.push({ level: "warn", text: `${ctx.version} is ahead of ${result.range}` });
80
69
  return lines;
81
70
  }
82
- function configSource(resolved) {
83
- if (resolved === undefined)
84
- return "none";
85
- return resolved.source === "root" ? "root" : (resolved.overlay?.dir ?? "overlay");
71
+ function inspectGit(ctx, resolved) {
72
+ const branch = resolveDefaultBranch(ctx.cwd, resolved?.config);
73
+ if (branch === undefined)
74
+ return [{ level: "warn", text: "default branch unresolved" }];
75
+ const source = branch.source === "config" ? "git.defaultBranch" : `cached ${branch.remote}/HEAD`;
76
+ return [{ level: "ok", text: `default branch ${branch.name} (${source})` }];
86
77
  }
87
78
  function inspectPlans(ctx) {
88
79
  const mode = resolvePlansMode(ctx.cwd, ctx.form);
80
+ if (mode.kind === "shared" && findStoppedRebase(mode.repoToplevel) !== undefined)
81
+ return [{ level: "error", text: `rebase stopped on a conflict in ${mode.repoToplevel}` }];
89
82
  return [
90
83
  {
91
84
  level: "ok",
@@ -94,11 +87,9 @@ function inspectPlans(ctx) {
94
87
  ];
95
88
  }
96
89
  function inspectDocmap(ctx) {
97
- const overlay = findOverlay(ctx.cwd, ctx.env, ctx.home);
98
- const docs = resolveProjectFile(ctx.cwd, overlay, "docs");
99
- const source = docs?.source ?? "none";
90
+ const present = existsSync(join(ctx.cwd, "docs"));
100
91
  return [
101
- { level: docs === undefined ? "warn" : "ok", text: `docs/ source ${source}` },
92
+ { level: "ok", text: `docs/ ${present ? "present" : "none"}` },
102
93
  { level: "ok", text: `embedded docmap ${readDocmapVersion()}` },
103
94
  ];
104
95
  }
@@ -117,29 +108,16 @@ function inspectSkills(ctx) {
117
108
  const installed = findInstalledSkill(ctx.home, name);
118
109
  if (installed === undefined)
119
110
  return { level: "warn", text: `${name} missing` };
120
- return {
121
- level: "ok",
122
- text: `${name} ${installed.version ?? "unknown"} (${installed.root})`,
123
- };
111
+ const version = installed.version ?? "unknown";
112
+ const parsed = semver.parse(installed.version);
113
+ if (parsed === null || parsed.major < 4)
114
+ return {
115
+ level: "warn",
116
+ text: `${name} ${version} predates v4; update: npx -y skills update --global --yes`,
117
+ };
118
+ return { level: "ok", text: `${name} ${version} (${installed.root})` };
124
119
  });
125
120
  }
126
- function inspectOverlay(ctx) {
127
- const configured = ctx.env.ALIGNFIRST_OVERLAYS;
128
- if (configured === undefined || configured === "")
129
- return [{ level: "ok", text: "no overlays directory" }];
130
- const overlay = findOverlay(ctx.cwd, ctx.env, ctx.home);
131
- if (overlay === undefined)
132
- return [{ level: "ok", text: "no overlay matches" }];
133
- const lines = [
134
- { level: "ok", text: `${overlay.dir} (matched by ${overlay.matchedBy})` },
135
- ];
136
- for (const name of [PROJECT_CONFIG_NAME, "AGENTS.md", "DEVELOPERS.md", "docs"])
137
- lines.push({
138
- level: "ok",
139
- text: `${name} ${resolveProjectFile(ctx.cwd, overlay, name)?.source ?? "none"}`,
140
- });
141
- return lines;
142
- }
143
121
  function inspectCompanion(ctx) {
144
122
  const executable = findExecutable(ctx.env, "alcode");
145
123
  if (executable === undefined)
@@ -1,11 +1,20 @@
1
- import { readFileSync } from "node:fs";
1
+ import { lstatSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
2
3
  import { parseArgs } from "node:util";
3
4
  import { CliError } from "../cli-error.js";
4
5
  import { renderCommandForm } from "../command-form.js";
5
- import { resolveProjectFile } from "../overlay.js";
6
+ import { commitSubject, renderConventions } from "../conventions.js";
7
+ import { resolveDefaultBranch } from "../default-branch.js";
6
8
  import { parseCommandArgs } from "../parse-args.js";
9
+ import { missingPlansMessage } from "../plans/layout.js";
10
+ import { resolvePlansMode } from "../plans/mode.js";
11
+ import { detectTicketFromBranch } from "../plans/ticket.js";
7
12
  import { PROTOCOLS } from "../protocols.js";
8
- const TICKET_ID_RULE_PLACEHOLDER = "{{TICKET_ID_RULE}}";
13
+ const TICKET_CMD_PLACEHOLDER = "{{TICKET_CMD}}";
14
+ const TICKET_CONTEXT_PLACEHOLDER = "{{TICKET_CONTEXT}}";
15
+ const PLANS_STATE_PLACEHOLDER = "{{PLANS_STATE}}";
16
+ const COMMIT_RULE_PLACEHOLDER = "{{COMMIT_RULE}}";
17
+ const BASE_BRANCH_RULE_PLACEHOLDER = "{{BASE_BRANCH_RULE}}";
9
18
  const PERSPECTIVES = ["intent", "correctness", "safety", "quality"];
10
19
  const MODULES = ["typescript-strict", "javascript", "python"];
11
20
  const PROTOCOL_LIST = `${PROTOCOLS.join(", ")}, or overview`;
@@ -88,12 +97,14 @@ function renderGuide(ctx, options) {
88
97
  return renderReviewerGuide(options.reviewer, options.modules);
89
98
  if (options.protocol === "overview")
90
99
  return readGuideTemplate("overview.md");
100
+ const placeholders = buildGuidePlaceholders(ctx, options.protocol);
91
101
  if (options.protocolOnly && options.protocol !== undefined)
92
- return readProtocolTemplate(options.protocol);
93
- const core = renderCoreGuide(ctx);
102
+ return applyPlaceholders(readProtocolTemplate(options.protocol), placeholders);
103
+ const core = renderCoreGuide(ctx, placeholders);
94
104
  if (options.protocol === undefined)
95
105
  return core;
96
- return `${core.trimEnd()}\n\n${readProtocolTemplate(options.protocol).trimEnd()}`;
106
+ const protocol = applyPlaceholders(readProtocolTemplate(options.protocol), placeholders);
107
+ return `${core.trimEnd()}\n\n${protocol.trimEnd()}`;
97
108
  }
98
109
  function renderReviewerGuide(perspective, modules) {
99
110
  const templates = [
@@ -103,20 +114,73 @@ function renderReviewerGuide(perspective, modules) {
103
114
  ];
104
115
  return templates.map((template) => template.trimEnd()).join("\n\n");
105
116
  }
106
- function renderCoreGuide(ctx) {
107
- const ticketRule = renderTicketIdRule(ctx);
108
- const core = readGuideTemplate("core.md").replaceAll(TICKET_ID_RULE_PLACEHOLDER, () => ticketRule);
109
- const projectConventions = resolveProjectFile(ctx.cwd, ctx.overlay, "AGENTS.md");
110
- if (projectConventions?.source !== "overlay")
117
+ function renderCoreGuide(ctx, placeholders) {
118
+ const core = applyPlaceholders(readGuideTemplate("core.md"), placeholders);
119
+ if (ctx.projectConfig !== undefined)
111
120
  return core;
112
- const content = readFileSync(projectConventions.path, "utf-8").trimEnd();
113
- return `${core.trimEnd()}\n\n## Project conventions\n\n${content}`;
121
+ return `${core.trimEnd()}\n\n## Project conventions\n\n${renderConventions(ctx).trimEnd()}`;
114
122
  }
115
- function renderTicketIdRule(ctx) {
116
- const pattern = ctx.projectConfig?.config.ticketPattern;
123
+ function buildGuidePlaceholders(ctx, protocol) {
124
+ const pattern = ctx.projectConfig?.config.ticketIdPattern;
125
+ const detection = pattern === undefined ? undefined : detectTicketFromBranch(ctx.cwd, pattern);
126
+ return {
127
+ ticketCommand: detection?.kind === "detected" ? "{{CMD}} ticket" : "{{CMD}} ticket <id>",
128
+ ticketContext: renderTicketContext(pattern, detection),
129
+ plansState: renderPlansState(ctx),
130
+ commitRule: renderCommitRule(ctx),
131
+ baseBranchRule: renderBaseBranchRule(ctx, protocol),
132
+ };
133
+ }
134
+ function renderTicketContext(pattern, detection) {
117
135
  if (pattern === undefined)
118
136
  return "Ask the user for the ticket ID when it is not given.";
119
- return `Ticket IDs match \`${pattern}\`. When the user gives no id, run \`{{CMD}} ticket\` without an id: it deduces the id from the current branch.`;
137
+ if (detection?.kind === "detected")
138
+ return `Current ticket: \`${detection.id}\` (from branch \`${detection.branch}\`). The id argument of \`{{CMD}} ticket\` is optional and defaults to it; pass an id only when the user names another ticket.`;
139
+ if (detection?.kind === "noMatch")
140
+ return `No ticket id on branch \`${detection.branch}\`. Ask the user for the id.`;
141
+ return "No ticket id: detached HEAD. Ask the user for the id.";
142
+ }
143
+ function renderPlansState(ctx) {
144
+ const entry = lstatSync(join(ctx.cwd, ".plans"), { throwIfNoEntry: false });
145
+ if (entry === undefined)
146
+ return `\`\`\`text\n${missingPlansMessage(ctx.form)}\n\`\`\``;
147
+ try {
148
+ return resolvePlansMode(ctx.cwd, ctx.form).kind === "shared"
149
+ ? "After every change in TASK_DIR, run `{{CMD}} sync`."
150
+ : "";
151
+ }
152
+ catch {
153
+ return "";
154
+ }
155
+ }
156
+ function renderCommitRule(ctx) {
157
+ const commit = ctx.projectConfig?.config.git?.commit;
158
+ if (commit === undefined)
159
+ return "(follow the convention you are aware of, or default to `<type>: [<ticket_id>] very short description`)";
160
+ const { subject, side } = commitSubject(commit);
161
+ const rule = side === undefined ? subject : `${subject}; ${side}`;
162
+ return `(project convention: ${rule})`;
163
+ }
164
+ function renderBaseBranchRule(ctx, protocol) {
165
+ const branch = resolveDefaultBranch(ctx.cwd, ctx.projectConfig?.config);
166
+ if (protocol === "review")
167
+ return branch === undefined
168
+ ? "the **base branch** to compare against - use the branch provided by the user, or fall back to the default branch."
169
+ : `the **base branch** to compare against - use the branch provided by the user, or fall back to \`${branch.name}\`, the default branch.`;
170
+ if (protocol === "merge")
171
+ return branch === undefined
172
+ ? "otherwise ask which branch to merge."
173
+ : `otherwise merge \`${branch.name}\`, the default branch.`;
174
+ return "";
175
+ }
176
+ function applyPlaceholders(template, values) {
177
+ return template
178
+ .replaceAll(`${PLANS_STATE_PLACEHOLDER}\n\n`, () => values.plansState === "" ? "" : `${values.plansState}\n\n`)
179
+ .replaceAll(TICKET_CMD_PLACEHOLDER, () => values.ticketCommand)
180
+ .replaceAll(TICKET_CONTEXT_PLACEHOLDER, () => values.ticketContext)
181
+ .replaceAll(PLANS_STATE_PLACEHOLDER, () => values.plansState)
182
+ .replaceAll(COMMIT_RULE_PLACEHOLDER, () => values.commitRule)
183
+ .replaceAll(BASE_BRANCH_RULE_PLACEHOLDER, () => values.baseBranchRule);
120
184
  }
121
185
  function readProtocolTemplate(protocol) {
122
186
  return readGuideTemplate(`protocols/${protocol}.md`);
@@ -1,12 +1,14 @@
1
1
  import { existsSync, mkdirSync, realpathSync, statSync } from "node:fs";
2
- import { basename, dirname, join, resolve } from "node:path";
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
5
  import { assertMainWorktreeRoot } from "../git.js";
6
- import { parseCommandArgs } from "../parse-args.js";
6
+ import { parseBareCommandArgs, parseCommandArgs } from "../parse-args.js";
7
7
  import { archiveEntry, archiveThresholdDays, autoArchive } from "../plans/archive.js";
8
8
  import { linkPlans } from "../plans/link.js";
9
9
  import { resolvePlansMode } from "../plans/mode.js";
10
+ import { findStoppedRebase, renderStoppedRebase } from "../plans/rebase.js";
11
+ const RESERVED_PLANS_FOLDERS = new Set([".git", ".plans", "_archives", "_project"]);
10
12
  export function runPlans(ctx, args) {
11
13
  const [command, ...rest] = args;
12
14
  switch (command) {
@@ -42,11 +44,29 @@ function runSetup(ctx, args) {
42
44
  assertMainWorktreeRoot(ctx.cwd);
43
45
  const cloneDir = resolve(ctx.cwd, parsed.dir);
44
46
  checkClone(ctx, cloneDir);
45
- const projectDir = join(cloneDir, parsed.folder);
46
- mkdirSync(projectDir, { recursive: true });
47
+ const projectDir = createPlansDirectory(cloneDir, parsed.folder);
47
48
  linkPlans(ctx, projectDir);
48
49
  return 0;
49
50
  }
51
+ function createPlansDirectory(cloneDir, folder) {
52
+ if (folder.length === 0 || folder === "." || folder === ".." || /[\\/]/u.test(folder)) {
53
+ throw new CliError(`Plans folder "${folder}" must be a single path segment.`);
54
+ }
55
+ if (RESERVED_PLANS_FOLDERS.has(folder.toLowerCase())) {
56
+ throw new CliError(`Plans folder "${folder}" is reserved.`);
57
+ }
58
+ const cloneRoot = realpathSync(cloneDir);
59
+ const projectDir = join(cloneRoot, folder);
60
+ mkdirSync(projectDir, { recursive: true });
61
+ const relativeTarget = relative(cloneRoot, realpathSync(projectDir));
62
+ if (relativeTarget === "" ||
63
+ relativeTarget === ".." ||
64
+ relativeTarget.startsWith(`..${sep}`) ||
65
+ isAbsolute(relativeTarget)) {
66
+ throw new CliError(`Plans folder "${folder}" must resolve inside ${cloneRoot}.`);
67
+ }
68
+ return projectDir;
69
+ }
50
70
  function parseSetupArgs(ctx, args, usage) {
51
71
  const { values, positionals } = parseCommandArgs(usage, () => parseArgs({
52
72
  args,
@@ -64,8 +84,8 @@ function parseSetupArgs(ctx, args, usage) {
64
84
  if (positionals.length !== 1)
65
85
  throw new CliError(usage.trimEnd());
66
86
  const configFolder = ctx.projectConfig?.config.plans?.folder;
67
- if (values.folder !== undefined && configFolder !== undefined)
68
- throw new CliError(".alignfirst.json already sets plans.folder; drop --folder.");
87
+ if (values.folder !== undefined && configFolder !== undefined && values.folder !== configFolder)
88
+ throw new CliError(`--folder "${values.folder}" differs from plans.folder "${configFolder}" in .alignfirst.json.`);
69
89
  const folder = values.folder ?? configFolder;
70
90
  if (folder === undefined)
71
91
  throw new CliError("Pass --folder <name> or set plans.folder in .alignfirst.json.");
@@ -81,32 +101,23 @@ function checkClone(ctx, cloneDir) {
81
101
  }
82
102
  function runCheck(ctx, args) {
83
103
  const usage = `Usage: ${ctx.form} plans check\n`;
84
- if (handleBareHelp(ctx, args, usage))
104
+ if (parseBareCommandArgs(ctx, args, usage))
85
105
  return 0;
86
106
  const mode = resolvePlansMode(ctx.cwd, ctx.form);
107
+ if (mode.kind === "shared") {
108
+ const stopped = findStoppedRebase(mode.repoToplevel);
109
+ if (stopped !== undefined)
110
+ throw new CliError(renderStoppedRebase(stopped, ctx.form));
111
+ }
87
112
  if (mode.kind === "shared")
88
113
  ctx.stdout.write(".plans is linked to the team plans repository.\n");
89
114
  else
90
115
  ctx.stdout.write(".plans is a local directory (local plans mode): synchronization is disabled.\n");
91
116
  return 0;
92
117
  }
93
- function handleBareHelp(ctx, args, usage) {
94
- const { values, positionals } = parseCommandArgs(usage, () => parseArgs({
95
- args,
96
- options: { help: { type: "boolean", short: "h", default: false } },
97
- strict: true,
98
- allowPositionals: true,
99
- }));
100
- if (positionals.length > 0)
101
- throw new CliError(`Unexpected argument: ${positionals[0]}\n\n${usage}`);
102
- if (!values.help)
103
- return false;
104
- ctx.stdout.write(usage);
105
- return true;
106
- }
107
118
  function runAutoArchive(ctx, args) {
108
119
  const usage = `Usage: ${ctx.form} plans auto-archive\n`;
109
- if (handleBareHelp(ctx, args, usage))
120
+ if (parseBareCommandArgs(ctx, args, usage))
110
121
  return 0;
111
122
  const mode = resolvePlansMode(ctx.cwd, ctx.form);
112
123
  const archived = autoArchive(join(ctx.cwd, ".plans"), archiveThresholdDays(ctx.env), ctx.stdout);
@@ -1,15 +1,22 @@
1
1
  import { join } from "node:path";
2
2
  import { parseArgs } from "node:util";
3
+ import { CliError } from "../cli-error.js";
3
4
  import { git, gitOutput, gitSucceeds } from "../git.js";
4
5
  import { parseCommandArgs } from "../parse-args.js";
5
6
  import { archiveThresholdDays, autoArchive } from "../plans/archive.js";
6
7
  import { resolvePlansMode } from "../plans/mode.js";
8
+ import { findStoppedRebase, renderStoppedRebase } from "../plans/rebase.js";
7
9
  export function runSync(ctx, args) {
8
- const usage = `Usage: ${ctx.form} sync [--auto-archive]\n`;
10
+ const usage = `Usage: ${ctx.form} sync [--auto-archive | --no-auto-archive]\n`;
9
11
  const options = parseSyncArgs(ctx, args, usage);
10
12
  if (options === undefined)
11
13
  return 0;
12
- const thresholdDays = options.autoArchive ? archiveThresholdDays(ctx.env) : undefined;
14
+ const enabled = options.autoArchive === true
15
+ ? true
16
+ : options.noAutoArchive === true
17
+ ? false
18
+ : (ctx.projectConfig?.config.plans?.autoArchive ?? false);
19
+ const thresholdDays = enabled ? archiveThresholdDays(ctx.env) : undefined;
13
20
  const mode = resolvePlansMode(ctx.cwd, ctx.form);
14
21
  const plansDir = join(ctx.cwd, ".plans");
15
22
  if (mode.kind === "local") {
@@ -19,15 +26,31 @@ export function runSync(ctx, args) {
19
26
  return 0;
20
27
  }
21
28
  const repoDir = mode.repoToplevel;
22
- if (hasHead(repoDir))
23
- git(repoDir, "pull", "--rebase", "--autostash");
24
- if (thresholdDays !== undefined)
25
- autoArchive(plansDir, thresholdDays, ctx.stdout);
29
+ assertNoStoppedRebase(repoDir, ctx.form);
26
30
  git(repoDir, "add", "-A");
27
31
  if (hasStagedChanges(repoDir))
28
32
  git(repoDir, "commit", "--quiet", "-m", "sync");
29
- if (hasHead(repoDir) && hasCommitsToSend(repoDir)) {
30
- git(repoDir, "push", "--quiet", "-u", "origin", "HEAD");
33
+ if (hasUpstream(repoDir)) {
34
+ try {
35
+ git(repoDir, "pull", "--rebase");
36
+ }
37
+ catch {
38
+ assertNoStoppedRebase(repoDir, ctx.form);
39
+ throw new CliError("git pull failed. See the git output above.");
40
+ }
41
+ }
42
+ if (thresholdDays !== undefined && autoArchive(plansDir, thresholdDays, ctx.stdout)) {
43
+ git(repoDir, "add", "-A");
44
+ if (hasStagedChanges(repoDir))
45
+ git(repoDir, "commit", "--quiet", "-m", "sync");
46
+ }
47
+ if (hasCommitsToSend(repoDir)) {
48
+ try {
49
+ git(repoDir, "push", "--quiet", "-u", "origin", "HEAD");
50
+ }
51
+ catch {
52
+ throw new CliError(`git push failed. See the git output above. Another synchronization may have landed first: run ${ctx.form} sync again.`);
53
+ }
31
54
  ctx.stdout.write("Plans synchronized: local changes sent.\n");
32
55
  }
33
56
  else {
@@ -40,6 +63,7 @@ function parseSyncArgs(ctx, args, usage) {
40
63
  args,
41
64
  options: {
42
65
  "auto-archive": { type: "boolean", default: false },
66
+ "no-auto-archive": { type: "boolean", default: false },
43
67
  help: { type: "boolean", short: "h", default: false },
44
68
  },
45
69
  strict: true,
@@ -48,15 +72,27 @@ function parseSyncArgs(ctx, args, usage) {
48
72
  ctx.stdout.write(usage);
49
73
  return;
50
74
  }
51
- return { autoArchive: values["auto-archive"] };
75
+ if (values["auto-archive"] && values["no-auto-archive"])
76
+ throw new CliError(`--auto-archive and --no-auto-archive are mutually exclusive.\n\n${usage}`);
77
+ return {
78
+ autoArchive: values["auto-archive"],
79
+ noAutoArchive: values["no-auto-archive"],
80
+ };
81
+ }
82
+ function assertNoStoppedRebase(repoDir, form) {
83
+ const stopped = findStoppedRebase(repoDir);
84
+ if (stopped !== undefined)
85
+ throw new CliError(renderStoppedRebase(stopped, form));
52
86
  }
53
- function hasHead(dir) {
54
- return gitSucceeds(dir, "rev-parse", "--verify", "-q", "HEAD");
87
+ function hasUpstream(dir) {
88
+ return gitSucceeds(dir, "rev-parse", "--verify", "-q", "@{u}");
55
89
  }
56
90
  function hasStagedChanges(dir) {
57
91
  return !gitSucceeds(dir, "diff", "--cached", "--quiet");
58
92
  }
59
93
  function hasCommitsToSend(dir) {
94
+ if (!gitSucceeds(dir, "rev-parse", "--verify", "-q", "HEAD"))
95
+ return false;
60
96
  if (!gitSucceeds(dir, "rev-parse", "--verify", "-q", "@{u}"))
61
97
  return true;
62
98
  return gitOutput(dir, "rev-list", "--count", "@{u}..HEAD") !== "0";