alignfirst 0.1.0-beta.0 → 0.1.0-beta.2

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 +79 -6
  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 +2 -2
  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/dist/skills.js CHANGED
@@ -1,7 +1,5 @@
1
- import { execFileSync } from "node:child_process";
2
1
  import { existsSync, readFileSync } from "node:fs";
3
2
  import { join } from "node:path";
4
- import { CliError } from "./cli-error.js";
5
3
  export const STUB_SKILLS = [
6
4
  "alignfirst",
7
5
  "alspec",
@@ -33,22 +31,3 @@ function readSkillVersion(path) {
33
31
  return;
34
32
  return /^\s+version:\s*"?([^"\r\n]+)"?\s*$/m.exec(metadata)?.[1]?.trim();
35
33
  }
36
- export function installStubSkills(ctx, agents) {
37
- const skillArgs = STUB_SKILLS.flatMap((skill) => ["--skill", skill]);
38
- const agentArgs = agents.flatMap((agent) => ["--agent", agent]);
39
- try {
40
- execFileSync("npx", [
41
- "-y",
42
- "skills",
43
- "add",
44
- "https://github.com/paleo/alignfirst",
45
- "--global",
46
- "--yes",
47
- ...skillArgs,
48
- ...agentArgs,
49
- ], { cwd: ctx.cwd, env: ctx.env, stdio: "inherit" });
50
- }
51
- catch {
52
- throw new CliError("Failed to install the AlignFirst skills globally.");
53
- }
54
- }
@@ -3,6 +3,5 @@ export interface CliRangeResult {
3
3
  range: string;
4
4
  satisfied: boolean;
5
5
  }
6
- export declare function defaultCliRange(version: string): string;
7
6
  export declare function checkCliRange(config: ProjectConfig | undefined, installedVersion: string, commandArgs: string[]): void;
8
7
  export declare function cliRangeResult(config: ProjectConfig | undefined, installedVersion: string): CliRangeResult | undefined;
@@ -1,12 +1,5 @@
1
1
  import semver from "semver";
2
2
  import { CliError } from "./cli-error.js";
3
- export function defaultCliRange(version) {
4
- const parsed = semver.parse(version);
5
- if (parsed === null)
6
- throw new Error(`Invalid installed version: ${version}`);
7
- const upper = parsed.major === 0 ? `0.${parsed.minor + 1}.0` : `${parsed.major + 1}.0.0`;
8
- return `>=${version} <${upper}`;
9
- }
10
3
  export function checkCliRange(config, installedVersion, commandArgs) {
11
4
  const result = cliRangeResult(config, installedVersion);
12
5
  if (result === undefined || result.satisfied)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alignfirst",
3
- "version": "0.1.0-beta.0",
3
+ "version": "0.1.0-beta.2",
4
4
  "license": "CC0-1.0",
5
5
  "author": "Thomas MUR",
6
6
  "description": "The AlignFirst CLI: protocols, plans and docs in one command.",
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@paleo/docmap": "~0.9.1",
37
+ "@paleo/docmap": "~0.10.0-beta.0",
38
38
  "arktype": "^2.2.3",
39
39
  "semver": "^7.8.5"
40
40
  },
@@ -14,9 +14,11 @@ An agent that does not know which protocol to use runs `{{CMD}} guide overview`.
14
14
 
15
15
  ## TASK_DIR Location
16
16
 
17
- **TASK_DIR** is `.plans/{TICKET_ID}/`. Run `{{CMD}} ticket <id>` and use the directory it prints. The command creates a missing directory, restores an archived one, and lists its entries.
17
+ TASK_DIR holds the work files of a ticket. `{{TICKET_CMD}}` prints it and lists its entries; it creates a missing directory and restores an archived one.
18
18
 
19
- {{TICKET_ID_RULE}}
19
+ {{TICKET_CONTEXT}}
20
+
21
+ {{PLANS_STATE}}
20
22
 
21
23
  **Work without a ticket:** when the user says there is no ticket, run `{{CMD}} ticket --side`. Reuse an existing `side-N` directory when the user refers to that earlier work. Omit the ticket ID from commit messages.
22
24
 
@@ -36,18 +38,16 @@ Format: `{CYCLE_LETTER}{FILE_NUMBER}-{FILE_TYPE}.md`
36
38
  **Example structure:**
37
39
 
38
40
  ```text
39
- .plans/
40
- ├── 123/
41
- │ ├── A1-spec.md
42
- │ ├── A2-plan.md
43
- │ └── A3-AAD.summary.md
44
- │ └── B1-spec.md
41
+ A1-spec.md
42
+ A2-plan.md
43
+ A3-AAD.summary.md
44
+ B1-spec.md
45
45
  ```
46
46
 
47
47
  ## Notes
48
48
 
49
49
  - **TICKET_ID** is a unique identifier for the task, often an issue or ticket number.
50
- - `{{CMD}} ticket <id> --next <filename>` prints the next filename in the current cycle, the extension included (`--next spec.md` giving `.plans/123/A2-spec.md`).
50
+ - `{{TICKET_CMD}} --next <filename>` prints the path of the next file in the current cycle, the extension included (`--next spec.md` giving `A2-spec.md`).
51
51
  - `--new-cycle` starts a new cycle.
52
52
  - The protocol or the user decides whether to continue the current cycle or start a new one.
53
53
  - Cycle letters and file numbers are internal. Never discuss them with the user.
@@ -4,8 +4,8 @@
4
4
 
5
5
  You need:
6
6
 
7
- - the TASK_DIR — run `{{CMD}} ticket <id>` (`{{CMD}} ticket` alone deduces the id from the branch when the project defines a ticket format; `{{CMD}} ticket --side` when there is no ticket)
8
- - the CYCLE_LETTER and FILE_NUMBER — continue the current cycle: `{{CMD}} ticket <id> --next AAD.summary.md` prints the file to create
7
+ - the TASK_DIR — run `{{TICKET_CMD}}` (`{{CMD}} ticket --side` when there is no ticket)
8
+ - the CYCLE_LETTER and FILE_NUMBER — continue the current cycle: `{{TICKET_CMD}} --next AAD.summary.md` prints the file to create
9
9
 
10
10
  Identify and state these values before starting the protocol.
11
11
 
@@ -54,7 +54,7 @@ Use subagents (your subagent tool) for distinct, isolated units of work when ben
54
54
 
55
55
  Finalize the summary file: replace the working notes with the final content described below.
56
56
 
57
- Start the summary with a header, then a suggested commit message (follow the convention you are aware of, or default to `<type>: [<ticket_id>] very short description`). The shorter the better. Omit any field with nothing to list. Always exclude `alignfirst` from skills.
57
+ Start the summary with a header, then a suggested commit message {{COMMIT_RULE}}. The shorter the better. Omit any field with nothing to list. Always exclude `alignfirst` from skills.
58
58
 
59
59
  Example:
60
60
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Pre-requisites
4
4
 
5
- You need the TASK_DIR — run `{{CMD}} ticket <id>` (`{{CMD}} ticket` alone deduces the id from the branch when the project defines a ticket format; `{{CMD}} ticket --side` when there is no ticket).
5
+ You need the TASK_DIR — run `{{TICKET_CMD}}` (`{{CMD}} ticket --side` when there is no ticket).
6
6
 
7
7
  ## Steps
8
8
 
@@ -4,8 +4,8 @@
4
4
 
5
5
  You need:
6
6
 
7
- - the TASK_DIR — run `{{CMD}} ticket <id>` (`{{CMD}} ticket` alone deduces the id from the branch when the project defines a ticket format; `{{CMD}} ticket --side` when there is no ticket)
8
- - the CYCLE_LETTER and FILE_NUMBER — start a new cycle: `{{CMD}} ticket <id> --next description.md --new-cycle` prints the file to create
7
+ - the TASK_DIR — run `{{TICKET_CMD}}` (`{{CMD}} ticket --side` when there is no ticket)
8
+ - the CYCLE_LETTER and FILE_NUMBER — start a new cycle: `{{TICKET_CMD}} --next description.md --new-cycle` prints the file to create
9
9
 
10
10
  Identify and state these values before starting the protocol.
11
11
 
@@ -29,7 +29,7 @@ Identify and state these values before starting the protocol.
29
29
  [description body]
30
30
  ```
31
31
 
32
- Start with a suggested commit message (follow the convention you are aware of, or default to `<type>: [<ticket_id>] very short description`). Refine it from the suggested commit messages found in the specs and summaries you read. Keep it brief—usually 3-5 words for the description part. Shorter is better when it's clear.
32
+ Start with a suggested commit message {{COMMIT_RULE}}. Refine it from the suggested commit messages found in the specs and summaries you read. Keep it brief—usually 3-5 words for the description part. Shorter is better when it's clear.
33
33
 
34
34
  ## Guidelines for the Description Body
35
35
 
@@ -4,8 +4,8 @@
4
4
 
5
5
  You need:
6
6
 
7
- - the TASK_DIR — run `{{CMD}} ticket <id>` (`{{CMD}} ticket` alone deduces the id from the branch when the project defines a ticket format; `{{CMD}} ticket --side` when there is no ticket)
8
- - the CYCLE_LETTER and FILE_NUMBER — continue the current cycle: `{{CMD}} ticket <id> --next merge.summary.md` prints the file to create
7
+ - the TASK_DIR — run `{{TICKET_CMD}}` (`{{CMD}} ticket --side` when there is no ticket)
8
+ - the CYCLE_LETTER and FILE_NUMBER — continue the current cycle: `{{TICKET_CMD}} --next merge.summary.md` prints the file to create
9
9
 
10
10
  Identify and state these values before starting the protocol.
11
11
 
@@ -17,7 +17,7 @@ This protocol applies when a merge or rebase has produced conflicts, or when the
17
17
 
18
18
  Run `git status` to check for conflicts.
19
19
 
20
- **If there are no conflicts:** start the merge — use the incoming branch if the user provided one, otherwise ask which branch to merge. If the merge completes cleanly, you are done — no summary file needed. Otherwise, continue with the steps below.
20
+ **If there are no conflicts:** start the merge — use the incoming branch if the user provided one, {{BASE_BRANCH_RULE}} If the merge completes cleanly, you are done — no summary file needed. Otherwise, continue with the steps below.
21
21
 
22
22
  ## 2. Investigate
23
23
 
@@ -6,8 +6,8 @@
6
6
 
7
7
  You need:
8
8
 
9
- - the TASK_DIR — run `{{CMD}} ticket <id>` (`{{CMD}} ticket` alone deduces the id from the branch when the project defines a ticket format; `{{CMD}} ticket --side` when there is no ticket)
10
- - the CYCLE_LETTER and FILE_NUMBER — continue the current cycle: `{{CMD}} ticket <id> --next plan.md` or `{{CMD}} ticket <id> --next main-plan.md` prints the file to create
9
+ - the TASK_DIR — run `{{TICKET_CMD}}` (`{{CMD}} ticket --side` when there is no ticket)
10
+ - the CYCLE_LETTER and FILE_NUMBER — continue the current cycle: `{{TICKET_CMD}} --next plan.md` or `{{TICKET_CMD}} --next main-plan.md` prints the file to create
11
11
  - a **spec file** in the TASK_DIR
12
12
 
13
13
  Identify and state these values before starting the protocol. If any of these pieces of information is missing, STOP AND ASK THE USER.
@@ -210,7 +210,7 @@ Note:
210
210
 
211
211
  Write the plan file(s) according to the determined structure:
212
212
 
213
- Use `{{CMD}} ticket <id> --next plan.md`, `{{CMD}} ticket <id> --next main-plan.md`, or `{{CMD}} ticket <id> --next plan-<descriptor>.md` to get the next number. Run one command per file.
213
+ Use `{{TICKET_CMD}} --next plan.md`, `{{TICKET_CMD}} --next main-plan.md`, or `{{TICKET_CMD}} --next plan-<descriptor>.md` to get the next number. Run one command per file.
214
214
 
215
215
  **Single Plan**:
216
216
 
@@ -4,9 +4,9 @@
4
4
 
5
5
  You need:
6
6
 
7
- - the TASK_DIR — run `{{CMD}} ticket <id>` (`{{CMD}} ticket` alone deduces the id from the branch when the project defines a ticket format; `{{CMD}} ticket --side` when there is no ticket)
8
- - the CYCLE_LETTER and FILE_NUMBER — start a new cycle: `{{CMD}} ticket <id> --next review.md --new-cycle` prints the file to create
9
- - the **base branch** to compare against - use the branch provided by the user, or fall back to the default branch.
7
+ - the TASK_DIR — run `{{TICKET_CMD}}` (`{{CMD}} ticket --side` when there is no ticket)
8
+ - the CYCLE_LETTER and FILE_NUMBER — start a new cycle: `{{TICKET_CMD}} --next review.md --new-cycle` prints the file to create
9
+ - {{BASE_BRANCH_RULE}}
10
10
 
11
11
  Identify and state these values before starting the protocol.
12
12
 
@@ -4,8 +4,8 @@
4
4
 
5
5
  You need:
6
6
 
7
- - the TASK_DIR — run `{{CMD}} ticket <id>` (`{{CMD}} ticket` alone deduces the id from the branch when the project defines a ticket format; `{{CMD}} ticket --side` when there is no ticket)
8
- - the CYCLE_LETTER and FILE_NUMBER — start a new cycle: `{{CMD}} ticket <id> --next spec.md --new-cycle` prints the file to create
7
+ - the TASK_DIR — run `{{TICKET_CMD}}` (`{{CMD}} ticket --side` when there is no ticket)
8
+ - the CYCLE_LETTER and FILE_NUMBER — start a new cycle: `{{TICKET_CMD}} --next spec.md --new-cycle` prints the file to create
9
9
 
10
10
  Identify and state these values before starting the protocol.
11
11
 
@@ -51,7 +51,7 @@ Do not use your question tool. Always ask in plain text. Your questions will be
51
51
 
52
52
  After the user approves your proposal, write the specification in a markdown file in TASK_DIR. Compose the filename with the current CYCLE_LETTER and the next FILE_NUMBER, e.g. `A1-spec.md`. Do not overwrite an existing file.
53
53
 
54
- - After the title, include a suggested commit message (follow the convention you are aware of, or default to `<type>: [<ticket_id>] very short description`). The shorter the better. Then list the required documentation and skills. List each doc file individually — never a folder. Always exclude `alignfirst` from skills. Omit any field with nothing to list. Example:
54
+ - After the title, include a suggested commit message {{COMMIT_RULE}}. The shorter the better. Then list the required documentation and skills. List each doc file individually — never a folder. Always exclude `alignfirst` from skills. Omit any field with nothing to list. Example:
55
55
 
56
56
  ```text
57
57
  # [{TICKET_ID}] Short Title
@@ -1,2 +0,0 @@
1
- import type { CommandContext } from "../context.js";
2
- export declare function runDevelopers(ctx: CommandContext, args: string[]): number;
@@ -1,36 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { parseArgs } from "node:util";
4
- import { CliError } from "../cli-error.js";
5
- import { resolveProjectFile } from "../overlay.js";
6
- import { parseCommandArgs } from "../parse-args.js";
7
- export function runDevelopers(ctx, args) {
8
- const usage = `Usage: ${ctx.form} DEVELOPERS.md\n`;
9
- if (parseDevelopersArgs(ctx, args, usage))
10
- return 0;
11
- const file = resolveProjectFile(ctx.cwd, ctx.overlay, "DEVELOPERS.md");
12
- if (file === undefined)
13
- throw missingDevelopersError(ctx);
14
- ctx.stdout.write(readFileSync(file.path, "utf-8"));
15
- return 0;
16
- }
17
- function parseDevelopersArgs(ctx, args, usage) {
18
- const { values, positionals } = parseCommandArgs(usage, () => parseArgs({
19
- args,
20
- options: { help: { type: "boolean", short: "h", default: false } },
21
- strict: true,
22
- allowPositionals: true,
23
- }));
24
- if (positionals.length > 0)
25
- throw new CliError(`Unexpected argument: ${positionals[0]}\n\n${usage}`);
26
- if (!values.help)
27
- return false;
28
- ctx.stdout.write(usage);
29
- return true;
30
- }
31
- function missingDevelopersError(ctx) {
32
- const tried = [join(ctx.cwd, "DEVELOPERS.md")];
33
- if (ctx.overlay !== undefined)
34
- tried.push(join(ctx.overlay.dir, "DEVELOPERS.md"));
35
- return new CliError(`No DEVELOPERS.md found. Tried: ${tried.join(", ")}.`);
36
- }
@@ -1,2 +0,0 @@
1
- import type { CommandContext } from "../context.js";
2
- export declare function runSetup(ctx: CommandContext, args: string[]): number;
@@ -1,254 +0,0 @@
1
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync, } from "node:fs";
2
- import { basename, join } from "node:path";
3
- import { parseArgs } from "node:util";
4
- import { CliError } from "../cli-error.js";
5
- import { assertMainWorktreeRoot, gitOutputOrUndefined, gitSucceeds } from "../git.js";
6
- import { normalizeRemoteUrl } from "../overlay.js";
7
- import { parseCommandArgs } from "../parse-args.js";
8
- import { linkPlans } from "../plans/link.js";
9
- import { PROJECT_CONFIG_FILENAME, readProjectConfig, validateProjectConfig, } from "../project-config.js";
10
- import { installStubSkills } from "../skills.js";
11
- import { defaultCliRange } from "../version-guard.js";
12
- const ADOPT_FILES = ["AGENTS.md", "DEVELOPERS.md", "docs"];
13
- export function runSetup(ctx, args) {
14
- const usage = setupUsage(ctx);
15
- const options = parseSetupArgs(ctx, args, usage);
16
- if (options === undefined)
17
- return 0;
18
- if (options.overlay)
19
- return runOverlaySetup(ctx, options);
20
- if (options.adopt)
21
- return runAdopt(ctx);
22
- return runDefaultSetup(ctx, options);
23
- }
24
- function setupUsage(ctx) {
25
- return `Usage:
26
- ${ctx.form} setup [--ticket-pattern <regex>] [--plans-folder <name>] [--port-range <first>-<last>] [--agent <name>]...
27
- ${ctx.form} setup --overlay [--plans-folder <name>] [--ticket-pattern <regex>] [--port-range <first>-<last>]
28
- ${ctx.form} setup --adopt
29
- `;
30
- }
31
- function parseSetupArgs(ctx, args, usage) {
32
- const { values } = parseCommandArgs(usage, () => parseArgs({
33
- args,
34
- options: {
35
- "ticket-pattern": { type: "string" },
36
- "plans-folder": { type: "string" },
37
- "port-range": { type: "string" },
38
- agent: { type: "string", multiple: true, default: [] },
39
- overlay: { type: "boolean", default: false },
40
- adopt: { type: "boolean", default: false },
41
- help: { type: "boolean", short: "h", default: false },
42
- },
43
- strict: true,
44
- }));
45
- if (values.help) {
46
- ctx.stdout.write(usage);
47
- return;
48
- }
49
- if (values.overlay && values.adopt)
50
- throw new CliError(`--overlay and --adopt are mutually exclusive.\n\n${usage}`);
51
- if ((values.overlay || values.adopt) && values.agent.length > 0)
52
- throw new CliError(`--agent is available only in the default setup mode.\n\n${usage}`);
53
- const portRange = values["port-range"] === undefined ? undefined : parsePortRange(values["port-range"], usage);
54
- const options = {
55
- ticketPattern: values["ticket-pattern"],
56
- plansFolder: values["plans-folder"],
57
- portRange,
58
- agents: values.agent,
59
- overlay: values.overlay,
60
- adopt: values.adopt,
61
- };
62
- validateSetupOptions(options);
63
- return options;
64
- }
65
- function parsePortRange(value, usage) {
66
- const match = /^(\d+)-(\d+)$/.exec(value);
67
- if (!match)
68
- throw new CliError(`--port-range must be <first>-<last>.\n\n${usage}`);
69
- return { first: Number(match[1]), last: Number(match[2]) };
70
- }
71
- function validateSetupOptions(options) {
72
- validateProjectConfig({
73
- schemaVersion: 1,
74
- ...(options.ticketPattern === undefined ? {} : { ticketPattern: options.ticketPattern }),
75
- ...(options.plansFolder === undefined ? {} : { plans: { folder: options.plansFolder } }),
76
- ...(options.portRange === undefined ? {} : { portRange: options.portRange }),
77
- }, PROJECT_CONFIG_FILENAME);
78
- }
79
- function runDefaultSetup(ctx, options) {
80
- assertMainWorktreeRoot(ctx.cwd);
81
- setupProjectConfig(ctx, options);
82
- setupPlansDirectory(ctx);
83
- installStubSkills(ctx, options.agents);
84
- ctx.stdout.write("Installed the AlignFirst skills globally.\n");
85
- setupReadme(ctx);
86
- return 0;
87
- }
88
- function setupProjectConfig(ctx, options) {
89
- const path = join(ctx.cwd, PROJECT_CONFIG_FILENAME);
90
- if (existsSync(path)) {
91
- readProjectConfig(ctx.cwd);
92
- ctx.stdout.write(`${PROJECT_CONFIG_FILENAME} is valid.\n`);
93
- if (hasProjectOptions(options))
94
- throw new CliError(`${PROJECT_CONFIG_FILENAME} exists; edit it instead of passing options.`);
95
- return;
96
- }
97
- const cli = defaultCliRange(ctx.version);
98
- const config = validateProjectConfig({
99
- schemaVersion: 1,
100
- cli,
101
- ...(options.ticketPattern === undefined ? {} : { ticketPattern: options.ticketPattern }),
102
- ...(options.plansFolder === undefined ? {} : { plans: { folder: options.plansFolder } }),
103
- ...(options.portRange === undefined ? {} : { portRange: options.portRange }),
104
- }, PROJECT_CONFIG_FILENAME);
105
- writeJson(path, config);
106
- ctx.stdout.write(`Created ${PROJECT_CONFIG_FILENAME} (cli ${cli})\n`);
107
- }
108
- function hasProjectOptions(options) {
109
- return (options.ticketPattern !== undefined ||
110
- options.plansFolder !== undefined ||
111
- options.portRange !== undefined);
112
- }
113
- function setupPlansDirectory(ctx) {
114
- const plansPath = join(ctx.cwd, ".plans");
115
- if (!existsSync(plansPath)) {
116
- mkdirSync(plansPath);
117
- ctx.stdout.write("Created .plans/\n");
118
- }
119
- if (gitSucceeds(ctx.cwd, "check-ignore", "-q", ".plans"))
120
- return;
121
- appendLine(join(ctx.cwd, ".gitignore"), ".plans");
122
- ctx.stdout.write("Added .plans to .gitignore.\n");
123
- }
124
- function appendLine(path, line) {
125
- const content = existsSync(path) ? readFileSync(path, "utf-8") : "";
126
- const separator = content === "" || content.endsWith("\n") ? "" : "\n";
127
- appendFileSync(path, `${separator}${line}\n`);
128
- }
129
- function setupReadme(ctx) {
130
- const path = join(ctx.cwd, "README.md");
131
- if (!existsSync(path))
132
- return;
133
- const content = readFileSync(path, "utf-8");
134
- if (/alignfirst/i.test(content))
135
- return;
136
- appendFileSync(path, "\n## Prerequisites\n\nInstall the AlignFirst CLI: `npm install -g alignfirst`.\n");
137
- ctx.stdout.write("Added the CLI prerequisite to README.md.\n");
138
- }
139
- function runOverlaySetup(ctx, options) {
140
- const overlaysDir = resolveOverlaysDir(ctx);
141
- assertMainWorktreeRoot(ctx.cwd);
142
- const projectPath = realpathSync(ctx.cwd);
143
- const name = options.plansFolder ?? basename(projectPath);
144
- const overlayDir = join(overlaysDir, name, "_project");
145
- if (existsSync(overlayDir))
146
- throw new CliError(`${overlayDir} already exists.`);
147
- const config = buildOverlayConfig(ctx, options, projectPath);
148
- mkdirSync(overlayDir, { recursive: true });
149
- writeJson(join(overlayDir, PROJECT_CONFIG_FILENAME), config);
150
- ctx.stdout.write(`Created overlay: ${overlayDir}\n`);
151
- setupOverlayPlans(ctx, overlaysDir, name);
152
- setupGitExclude(ctx);
153
- return 0;
154
- }
155
- function resolveOverlaysDir(ctx) {
156
- const value = ctx.env.ALIGNFIRST_OVERLAYS;
157
- if (value === undefined || value === "")
158
- throw new CliError("ALIGNFIRST_OVERLAYS is not set.");
159
- return value.startsWith("~/") ? join(ctx.home, value.slice(2)) : value;
160
- }
161
- function buildOverlayConfig(ctx, options, projectPath) {
162
- const origin = gitOutputOrUndefined(ctx.cwd, "remote", "get-url", "origin");
163
- return validateProjectConfig({
164
- schemaVersion: 1,
165
- project: {
166
- ...(origin === undefined || origin === "" ? {} : { remote: normalizeRemoteUrl(origin) }),
167
- paths: [projectPath],
168
- },
169
- ...(options.ticketPattern === undefined ? {} : { ticketPattern: options.ticketPattern }),
170
- ...(options.plansFolder === undefined ? {} : { plans: { folder: options.plansFolder } }),
171
- ...(options.portRange === undefined ? {} : { portRange: options.portRange }),
172
- }, PROJECT_CONFIG_FILENAME);
173
- }
174
- function setupOverlayPlans(ctx, overlaysDir, name) {
175
- if (gitSucceeds(overlaysDir, "rev-parse", "--git-dir")) {
176
- linkPlans(ctx, join(overlaysDir, name));
177
- return;
178
- }
179
- const plansPath = join(ctx.cwd, ".plans");
180
- if (!existsSync(plansPath))
181
- mkdirSync(plansPath);
182
- ctx.stdout.write("Using a local .plans directory.\n");
183
- }
184
- function setupGitExclude(ctx) {
185
- if (gitSucceeds(ctx.cwd, "check-ignore", "-q", ".plans"))
186
- return;
187
- appendLine(join(ctx.cwd, ".git", "info", "exclude"), ".plans");
188
- ctx.stdout.write("Added .plans to .git/info/exclude.\n");
189
- }
190
- function runAdopt(ctx) {
191
- const overlay = ctx.overlay;
192
- if (overlay === undefined) {
193
- const value = ctx.env.ALIGNFIRST_OVERLAYS;
194
- throw new CliError(`No overlay matches this repository (ALIGNFIRST_OVERLAYS=${value === undefined || value === "" ? "unset" : value}).`);
195
- }
196
- adoptConfig(ctx, overlay.dir, overlay.config);
197
- const agentsConflict = adoptFiles(ctx, overlay.dir);
198
- removePlansExclude(ctx);
199
- if (readdirSync(overlay.dir).length === 0)
200
- rmdirSync(overlay.dir);
201
- else
202
- ctx.stdout.write(`Overlay remains: ${overlay.dir}\n`);
203
- ctx.stdout.write("Next: add .plans to .gitignore.\n");
204
- if (agentsConflict)
205
- ctx.stdout.write("Next: merge the overlay AGENTS.md conventions by hand.\n");
206
- return 0;
207
- }
208
- function adoptConfig(ctx, overlayDir, config) {
209
- const name = PROJECT_CONFIG_FILENAME;
210
- const source = join(overlayDir, name);
211
- const target = join(ctx.cwd, name);
212
- if (existsSync(target)) {
213
- ctx.stdout.write(`kept in the overlay: ${name} (the root has its own)\n`);
214
- return;
215
- }
216
- const rootConfig = structuredClone(config);
217
- delete rootConfig.project;
218
- writeJson(target, validateProjectConfig(rootConfig, name));
219
- unlinkSync(source);
220
- ctx.stdout.write(`Adopted ${name}.\n`);
221
- }
222
- function adoptFiles(ctx, overlayDir) {
223
- let agentsConflict = false;
224
- for (const name of ADOPT_FILES) {
225
- const source = join(overlayDir, name);
226
- if (!existsSync(source))
227
- continue;
228
- const target = join(ctx.cwd, name);
229
- if (existsSync(target)) {
230
- ctx.stdout.write(`kept in the overlay: ${name} (the root has its own)\n`);
231
- if (name === "AGENTS.md")
232
- agentsConflict = true;
233
- continue;
234
- }
235
- renameSync(source, target);
236
- ctx.stdout.write(`Adopted ${name}.\n`);
237
- }
238
- return agentsConflict;
239
- }
240
- function removePlansExclude(ctx) {
241
- const path = join(ctx.cwd, ".git", "info", "exclude");
242
- if (!existsSync(path))
243
- return;
244
- const content = readFileSync(path, "utf-8");
245
- const lines = content.split(/\r?\n/);
246
- const filtered = lines.filter((line) => line.trim() !== ".plans");
247
- if (filtered.length === lines.length)
248
- return;
249
- writeFileSync(path, filtered.join("\n"));
250
- ctx.stdout.write("Removed .plans from .git/info/exclude.\n");
251
- }
252
- function writeJson(path, value) {
253
- writeFileSync(path, `${JSON.stringify(value, undefined, 2)}\n`);
254
- }
package/dist/overlay.d.ts DELETED
@@ -1,19 +0,0 @@
1
- import { type ProjectConfig } from "./project-config.js";
2
- export interface Overlay {
3
- dir: string;
4
- config: ProjectConfig;
5
- matchedBy: "remote" | "paths";
6
- }
7
- export interface ProjectFile {
8
- path: string;
9
- source: "root" | "overlay";
10
- }
11
- export interface ResolvedProjectConfig {
12
- config: ProjectConfig;
13
- source: "root" | "overlay";
14
- overlay?: Overlay;
15
- }
16
- export declare function findOverlay(cwd: string, env: NodeJS.ProcessEnv, home: string): Overlay | undefined;
17
- export declare function normalizeRemoteUrl(url: string): string;
18
- export declare function resolveProjectFile(cwd: string, overlay: Overlay | undefined, name: string): ProjectFile | undefined;
19
- export declare function resolveProjectConfig(cwd: string, env: NodeJS.ProcessEnv, home: string): ResolvedProjectConfig | undefined;
package/dist/overlay.js DELETED
@@ -1,83 +0,0 @@
1
- import { existsSync, readdirSync, realpathSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { CliError } from "./cli-error.js";
4
- import { gitOutputOrUndefined } from "./git.js";
5
- import { readProjectConfig } from "./project-config.js";
6
- export function findOverlay(cwd, env, home) {
7
- const configuredDir = env.ALIGNFIRST_OVERLAYS;
8
- if (configuredDir === undefined || configuredDir === "")
9
- return;
10
- const overlaysDir = expandHome(configuredDir, home);
11
- const candidates = readOverlayCandidates(overlaysDir);
12
- const origin = gitOutputOrUndefined(cwd, "remote", "get-url", "origin");
13
- const normalizedOrigin = origin === undefined || origin === "" ? undefined : normalizeRemoteUrl(origin);
14
- const realCwd = realpathSync(cwd);
15
- const remoteMatches = candidates.filter(({ config }) => normalizedOrigin !== undefined && config.project?.remote === normalizedOrigin);
16
- if (remoteMatches.length > 0)
17
- return selectOverlay(remoteMatches, "remote");
18
- const pathMatches = candidates.filter(({ config }) => config.project?.paths?.includes(realCwd));
19
- if (pathMatches.length > 0)
20
- return selectOverlay(pathMatches, "paths");
21
- return;
22
- }
23
- function expandHome(path, home) {
24
- return path.startsWith("~/") ? join(home, path.slice(2)) : path;
25
- }
26
- function readOverlayCandidates(overlaysDir) {
27
- if (!existsSync(overlaysDir))
28
- return [];
29
- return readdirSync(overlaysDir, { withFileTypes: true })
30
- .filter((entry) => entry.isDirectory())
31
- .flatMap((entry) => {
32
- const dir = join(overlaysDir, entry.name, "_project");
33
- const config = readProjectConfig(dir);
34
- return config === undefined ? [] : [{ dir, config }];
35
- });
36
- }
37
- function selectOverlay(candidates, matchedBy) {
38
- if (candidates.length > 1)
39
- throw new CliError(`Multiple AlignFirst overlays match this project: ${candidates
40
- .map(({ dir }) => dir)
41
- .join(", ")}`);
42
- const candidate = candidates[0];
43
- return { ...candidate, matchedBy };
44
- }
45
- export function normalizeRemoteUrl(url) {
46
- const value = url
47
- .trim()
48
- .replace(/\/+$/, "")
49
- .replace(/\.git$/, "");
50
- const scpMatch = value.includes("://") ? null : /^(?:[^@]+@)?([^:/]+):(.+)$/.exec(value);
51
- if (scpMatch)
52
- return `${scpMatch[1].toLowerCase()}/${scpMatch[2].replace(/^\/+/, "")}`;
53
- try {
54
- const parsed = new URL(value.includes("://") ? value : `https://${value}`);
55
- return `${parsed.hostname.toLowerCase()}${parsed.pathname}`
56
- .replace(/\/+$/, "")
57
- .replace(/\.git$/, "")
58
- .replace(/^\/+/, "");
59
- }
60
- catch {
61
- return value;
62
- }
63
- }
64
- export function resolveProjectFile(cwd, overlay, name) {
65
- const rootPath = join(cwd, name);
66
- if (existsSync(rootPath))
67
- return { path: rootPath, source: "root" };
68
- if (overlay === undefined)
69
- return;
70
- const overlayPath = join(overlay.dir, name);
71
- if (existsSync(overlayPath))
72
- return { path: overlayPath, source: "overlay" };
73
- return;
74
- }
75
- export function resolveProjectConfig(cwd, env, home) {
76
- const overlay = findOverlay(cwd, env, home);
77
- const rootConfig = readProjectConfig(cwd);
78
- if (rootConfig !== undefined)
79
- return { config: rootConfig, source: "root", overlay };
80
- if (overlay !== undefined)
81
- return { config: overlay.config, source: "overlay", overlay };
82
- return;
83
- }