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
@@ -15,7 +15,9 @@ export function runTicket(ctx, args) {
15
15
  if (parsed === undefined)
16
16
  return 0;
17
17
  const result = resolveTicket(ctx, parsed);
18
- const next = parsed.next === undefined ? undefined : nextFileName(result.dir, parsed.next, parsed.newCycle);
18
+ const next = parsed.next === undefined
19
+ ? undefined
20
+ : nextFileName(result.entries, parsed.next, parsed.newCycle);
19
21
  if (parsed.json)
20
22
  ctx.stdout.write(`${JSON.stringify(jsonReport(ctx, parsed, result, next), undefined, 2)}\n`);
21
23
  else
@@ -49,6 +51,8 @@ function parseTicketArgs(ctx, args, usage) {
49
51
  throw new CliError(`A ticket id cannot be combined with --side.\n\n${usage}`);
50
52
  if (values["new-cycle"] && values.next === undefined)
51
53
  throw new CliError(`--new-cycle requires --next.\n\n${usage}`);
54
+ if (values.next !== undefined)
55
+ validateNextFilename(values.next);
52
56
  const resolution = resolveTicketId(ctx, positionals[0], values.side, values["dry-run"]);
53
57
  return {
54
58
  ...resolution,
@@ -59,8 +63,13 @@ function parseTicketArgs(ctx, args, usage) {
59
63
  side: values.side,
60
64
  };
61
65
  }
66
+ function validateNextFilename(filename) {
67
+ if (filename.length === 0 || filename === "." || filename === ".." || /[\\/]/u.test(filename)) {
68
+ throw new CliError("--next must be a non-empty single path segment.");
69
+ }
70
+ }
62
71
  function resolveTicketId(ctx, positional, side, dryRun) {
63
- const pattern = ctx.projectConfig?.config.ticketPattern;
72
+ const pattern = ctx.projectConfig?.config.ticketIdPattern;
64
73
  if (positional !== undefined) {
65
74
  validateTicketId(positional, pattern);
66
75
  return { id: positional };
@@ -68,7 +77,7 @@ function resolveTicketId(ctx, positional, side, dryRun) {
68
77
  if (side)
69
78
  return { id: dryRun ? peekSideTicket(ctx.cwd) : reserveSideTicket(ctx.cwd) };
70
79
  if (pattern === undefined)
71
- throw new CliError("No ticket id given and .alignfirst.json has no ticketPattern: pass the id.");
80
+ throw new CliError("No ticket id given. Pass it.\nSetting ticketIdPattern in .alignfirst.json enables deduction from the current branch.");
72
81
  const deduced = deduceTicketFromBranch(ctx.cwd, pattern);
73
82
  validateTicketId(deduced.id, pattern);
74
83
  return deduced;
package/dist/context.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Overlay, ResolvedProjectConfig } from "./overlay.js";
1
+ import type { ResolvedProjectConfig } from "./project-config.js";
2
2
  export interface Output {
3
3
  write(text: string): void;
4
4
  }
@@ -11,5 +11,4 @@ export interface CommandContext {
11
11
  form: string;
12
12
  version: string;
13
13
  projectConfig?: ResolvedProjectConfig;
14
- overlay?: Overlay;
15
14
  }
@@ -0,0 +1,8 @@
1
+ import type { CommandContext } from "./context.js";
2
+ import type { CommitConfig } from "./project-config.js";
3
+ export interface CommitSubject {
4
+ subject: string;
5
+ side?: string;
6
+ }
7
+ export declare function renderConventions(ctx: CommandContext): string;
8
+ export declare function commitSubject(commit: CommitConfig): CommitSubject;
@@ -0,0 +1,86 @@
1
+ import { existsSync, lstatSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { renderDefaultBranchLine, resolveDefaultBranch } from "./default-branch.js";
4
+ import { gitOutputOrUndefined, gitSucceeds } from "./git.js";
5
+ import { resolvePlansMode } from "./plans/mode.js";
6
+ export function renderConventions(ctx) {
7
+ const lines = [
8
+ renderTicketIds(ctx),
9
+ renderBranchNames(ctx),
10
+ renderCommits(ctx),
11
+ renderDefaultBranchLine(resolveDefaultBranch(ctx.cwd, ctx.projectConfig?.config)),
12
+ renderPlans(ctx),
13
+ renderSearches(ctx),
14
+ ].filter((line) => line !== undefined);
15
+ return `${lines.join("\n")}\n`;
16
+ }
17
+ function renderTicketIds(ctx) {
18
+ const pattern = ctx.projectConfig?.config.ticketIdPattern;
19
+ if (pattern === undefined)
20
+ return "Ticket IDs: no configured format; ask the user for the ID. Without an external ticket, use the next `side-N`.";
21
+ return `Ticket IDs: \`${pattern}\`; infer a matching ID from the branch. Without an external ticket, use the next \`side-N\`.`;
22
+ }
23
+ function renderBranchNames(ctx) {
24
+ const template = ctx.projectConfig?.config.git?.branchNameTemplate;
25
+ return template === undefined ? undefined : `Branch names: \`${template}\`.`;
26
+ }
27
+ function renderCommits(ctx) {
28
+ const git = ctx.projectConfig?.config.git;
29
+ if (git?.commit === undefined) {
30
+ return git?.agentCoauthoring === false
31
+ ? "Commits: do not add an agent co-author trailer."
32
+ : undefined;
33
+ }
34
+ const { subject, side } = commitSubject(git.commit);
35
+ const rule = side === undefined ? `${subject}.` : `${subject}; use ${side}.`;
36
+ const coauthor = git.agentCoauthoring === false ? " Do not add an agent co-author trailer." : "";
37
+ return `Commits: ${rule}${coauthor}`;
38
+ }
39
+ export function commitSubject(commit) {
40
+ if (commit.ticketReference === "bracketed")
41
+ return { subject: "`type: [ticketId] summary`", side: "`type: summary` for `side-N`" };
42
+ if (commit.ticketReference === "bracketedHash")
43
+ return { subject: "`type: [#ticketId] summary`", side: "`type: summary` for `side-N`" };
44
+ return { subject: "`type: summary`" };
45
+ }
46
+ function renderPlans(ctx) {
47
+ if (!plansEntryExists(ctx.cwd))
48
+ return;
49
+ try {
50
+ const mode = resolvePlansMode(ctx.cwd, ctx.form);
51
+ const folder = ctx.projectConfig?.config.plans?.folder;
52
+ const sharedFolder = folder === undefined ? "" : ` (shared folder \`${folder}\`)`;
53
+ const base = mode.kind === "shared"
54
+ ? `Plans: use \`.plans\`${sharedFolder}; keep it out of product commits, and run \`${ctx.form} sync\` after changes.`
55
+ : "Plans: use `.plans`; keep it out of product commits.";
56
+ const archival = ctx.projectConfig?.config.plans?.autoArchive === true
57
+ ? " Automatic archival is enabled."
58
+ : "";
59
+ return `${base}${archival}`;
60
+ }
61
+ catch (error) {
62
+ const message = error instanceof Error ? error.message : String(error);
63
+ return `Plans: ${message.split("\n", 1)[0]}`;
64
+ }
65
+ }
66
+ function plansEntryExists(cwd) {
67
+ return lstatSync(join(cwd, ".plans"), { throwIfNoEntry: false }) !== undefined;
68
+ }
69
+ function renderSearches(ctx) {
70
+ const directories = [
71
+ ...(plansEntryExists(ctx.cwd) ? [".plans"] : []),
72
+ ...[".local", ".local-wt"].filter((name) => qualifiesIgnoredDirectory(ctx.cwd, name)),
73
+ ];
74
+ if (directories.length === 0)
75
+ return;
76
+ const rendered = directories.map((name) => `\`${name}\``);
77
+ const joined = rendered.length === 1
78
+ ? rendered[0]
79
+ : `${rendered.slice(0, -1).join(", ")} and ${rendered.at(-1)}`;
80
+ return `Searches: exclude ${joined} from broad codebase searches.`;
81
+ }
82
+ function qualifiesIgnoredDirectory(cwd, name) {
83
+ return (existsSync(join(cwd, name)) &&
84
+ gitSucceeds(cwd, "check-ignore", "-q", name) &&
85
+ gitOutputOrUndefined(cwd, "ls-files", "--", name) === "");
86
+ }
@@ -0,0 +1,8 @@
1
+ import type { ProjectConfig } from "./project-config.js";
2
+ export interface DefaultBranch {
3
+ name: string;
4
+ source: "config" | "cached";
5
+ remote?: string;
6
+ }
7
+ export declare function resolveDefaultBranch(cwd: string, config: ProjectConfig | undefined): DefaultBranch | undefined;
8
+ export declare function renderDefaultBranchLine(branch: DefaultBranch | undefined): string;
@@ -0,0 +1,32 @@
1
+ import { gitOutputOrUndefined, gitSucceeds } from "./git.js";
2
+ export function resolveDefaultBranch(cwd, config) {
3
+ const configured = config?.git?.defaultBranch;
4
+ if (configured !== undefined)
5
+ return { name: configured, source: "config" };
6
+ const remote = resolveRemote(cwd);
7
+ if (remote === undefined)
8
+ return;
9
+ const prefix = `refs/remotes/${remote}/`;
10
+ const target = gitOutputOrUndefined(cwd, "symbolic-ref", "-q", `${prefix}HEAD`);
11
+ if (target === undefined || target === "" || !target.startsWith(prefix))
12
+ return;
13
+ if (!gitSucceeds(cwd, "rev-parse", "--verify", "-q", target))
14
+ return;
15
+ return { name: target.slice(prefix.length), source: "cached", remote };
16
+ }
17
+ function resolveRemote(cwd) {
18
+ const output = gitOutputOrUndefined(cwd, "remote");
19
+ if (output === undefined || output === "")
20
+ return;
21
+ const remotes = output.split("\n").filter((remote) => remote !== "");
22
+ if (remotes.includes("origin"))
23
+ return "origin";
24
+ return remotes.length === 1 ? remotes[0] : undefined;
25
+ }
26
+ export function renderDefaultBranchLine(branch) {
27
+ if (branch === undefined)
28
+ return "Default branch: unresolved; ask before default-branch operations.";
29
+ return branch.source === "cached"
30
+ ? `Default branch: ${branch.name} (cached).`
31
+ : `Default branch: ${branch.name}.`;
32
+ }
@@ -1 +1,3 @@
1
+ import type { CommandContext } from "./context.js";
1
2
  export declare function parseCommandArgs<T>(usage: string, parse: () => T): T;
3
+ export declare function parseBareCommandArgs(ctx: CommandContext, args: string[], usage: string): boolean;
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "./cli-error.js";
2
2
  import { errorMessage } from "./errors.js";
3
+ import { parseArgs } from "node:util";
3
4
  export function parseCommandArgs(usage, parse) {
4
5
  try {
5
6
  return parse();
@@ -9,3 +10,17 @@ export function parseCommandArgs(usage, parse) {
9
10
  throw new CliError(`${detail}\n\n${usage}`);
10
11
  }
11
12
  }
13
+ export function parseBareCommandArgs(ctx, args, usage) {
14
+ const { values, positionals } = parseCommandArgs(usage, () => parseArgs({
15
+ args,
16
+ options: { help: { type: "boolean", short: "h", default: false } },
17
+ strict: true,
18
+ allowPositionals: true,
19
+ }));
20
+ if (positionals.length > 0)
21
+ throw new CliError(`Unexpected argument: ${positionals[0]}\n\n${usage}`);
22
+ if (!values.help)
23
+ return false;
24
+ ctx.stdout.write(usage);
25
+ return true;
26
+ }
@@ -1,6 +1,9 @@
1
+ import { CliError } from "../cli-error.js";
1
2
  export declare const PLANS_DIR = ".plans";
2
3
  export declare const ARCHIVES_DIR = "_archives";
3
4
  export declare function isTicketName(name: string): boolean;
4
5
  export declare function plansDir(cwd: string): string;
5
6
  export declare function archivesDir(cwd: string): string;
6
7
  export declare function assertPlansGate(cwd: string, form: string): void;
8
+ export declare function missingPlansMessage(form: string): string;
9
+ export declare function missingPlansError(form: string): CliError;
@@ -15,5 +15,11 @@ export function archivesDir(cwd) {
15
15
  export function assertPlansGate(cwd, form) {
16
16
  if (existsSync(plansDir(cwd)))
17
17
  return;
18
- throw new CliError(`Error: no \`.plans/\` directory found in the current directory. Run \`${form}\` from the root of an AlignFirst-managed project.`);
18
+ throw missingPlansError(form);
19
+ }
20
+ export function missingPlansMessage(form) {
21
+ return `No .plans/ directory in the current directory.\nLocal plans: mkdir .plans && echo .plans >> .gitignore\nTeam plans: ${form} plans setup <clone-dir>`;
22
+ }
23
+ export function missingPlansError(form) {
24
+ return new CliError(missingPlansMessage(form));
19
25
  }
@@ -2,12 +2,12 @@ import { existsSync, lstatSync, realpathSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { CliError } from "../cli-error.js";
4
4
  import { gitOutput } from "../git.js";
5
+ import { missingPlansError } from "./layout.js";
5
6
  export function resolvePlansMode(cwd, form) {
6
7
  const plansPath = join(cwd, ".plans");
7
8
  const stats = lstatSync(plansPath, { throwIfNoEntry: false });
8
9
  if (!stats)
9
- throw new CliError(`.plans is missing. Clone the team plans repository, then run ${form} plans setup ` +
10
- "(see the project documentation) — or create a plain .plans directory to keep plans local.");
10
+ throw missingPlansError(form);
11
11
  if (stats.isSymbolicLink() && !existsSync(plansPath))
12
12
  throw new CliError(`The .plans symlink is broken. Re-run ${form} plans setup with the clone location.`);
13
13
  if (!statSync(plansPath).isDirectory())
@@ -0,0 +1,6 @@
1
+ export interface StoppedRebase {
2
+ repoDir: string;
3
+ conflictedFiles: string[];
4
+ }
5
+ export declare function findStoppedRebase(repoDir: string): StoppedRebase | undefined;
6
+ export declare function renderStoppedRebase(stopped: StoppedRebase, form: string): string;
@@ -0,0 +1,27 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { gitOutputOrUndefined } from "../git.js";
4
+ export function findStoppedRebase(repoDir) {
5
+ const inProgress = ["rebase-merge", "rebase-apply"].some((name) => {
6
+ const path = gitOutputOrUndefined(repoDir, "rev-parse", "--git-path", name);
7
+ return path !== undefined && path !== "" && existsSync(resolve(repoDir, path));
8
+ });
9
+ if (!inProgress)
10
+ return;
11
+ const conflicts = gitOutputOrUndefined(repoDir, "diff", "--name-only", "--diff-filter=U");
12
+ return {
13
+ repoDir,
14
+ conflictedFiles: conflicts?.split("\n").filter((path) => path !== "") ?? [],
15
+ };
16
+ }
17
+ export function renderStoppedRebase(stopped, form) {
18
+ const files = stopped.conflictedFiles.map((path) => ` ${path}`);
19
+ return [
20
+ `Plans synchronization stopped on a conflict in ${stopped.repoDir}:`,
21
+ ...files,
22
+ "Resolve the markers in these files, then run:",
23
+ ` git -C ${stopped.repoDir} add -A && git -C ${stopped.repoDir} rebase --continue`,
24
+ ` ${form} sync`,
25
+ `To discard the local side instead: git -C ${stopped.repoDir} rebase --abort`,
26
+ ].join("\n");
27
+ }
@@ -11,11 +11,22 @@ export interface DeducedTicket {
11
11
  id: string;
12
12
  branch: string;
13
13
  }
14
+ export type TicketDetection = {
15
+ kind: "detected";
16
+ id: string;
17
+ branch: string;
18
+ } | {
19
+ kind: "noMatch";
20
+ branch: string;
21
+ } | {
22
+ kind: "noBranch";
23
+ };
14
24
  export declare function resolveTicketDir(cwd: string, id: string, { dryRun }: ResolveTicketOptions): ResolvedTicketDir;
15
25
  export declare function reserveSideTicket(cwd: string): string;
16
26
  export declare function peekSideTicket(cwd: string): string;
17
- export declare function nextFileName(dir: string, filename: string, newCycle: boolean): string;
27
+ export declare function nextFileName(entries: readonly string[], filename: string, newCycle: boolean): string;
18
28
  export declare function listEntries(dir: string): string[];
19
29
  export declare function isPathSafeTicketId(id: string): boolean;
20
30
  export declare function validateTicketId(id: string, pattern?: string): void;
31
+ export declare function detectTicketFromBranch(cwd: string, pattern: string): TicketDetection;
21
32
  export declare function deduceTicketFromBranch(cwd: string, pattern: string): DeducedTicket;
@@ -63,9 +63,9 @@ function readEntries(dir) {
63
63
  return [];
64
64
  }
65
65
  }
66
- export function nextFileName(dir, filename, newCycle) {
67
- const prefixes = readEntries(dir).flatMap((entry) => {
68
- const match = FILE_PREFIX.exec(entry.name);
66
+ export function nextFileName(entries, filename, newCycle) {
67
+ const prefixes = entries.flatMap((entry) => {
68
+ const match = FILE_PREFIX.exec(entry);
69
69
  return match ? [{ cycle: match[1], number: Number(match[2]) }] : [];
70
70
  });
71
71
  if (prefixes.length === 0)
@@ -90,15 +90,23 @@ export function validateTicketId(id, pattern) {
90
90
  if (!isPathSafeTicketId(id))
91
91
  throw new CliError(`Invalid ticket id: ${id}`);
92
92
  if (pattern !== undefined && !new RegExp(pattern).test(id) && !SIDE_TICKET.test(id))
93
- throw new CliError(`Ticket id "${id}" does not match ticketPattern "${pattern}".`);
93
+ throw new CliError(`Ticket id "${id}" does not match ticketIdPattern "${pattern}".`);
94
94
  }
95
- export function deduceTicketFromBranch(cwd, pattern) {
95
+ export function detectTicketFromBranch(cwd, pattern) {
96
96
  const branch = gitOutputOrUndefined(cwd, "branch", "--show-current");
97
97
  if (branch === undefined || branch === "")
98
- throw new CliError("Cannot deduce a ticket id from a detached HEAD.");
98
+ return { kind: "noBranch" };
99
99
  const unanchored = pattern.replace(/^\^/, "").replace(/\$$/, "");
100
100
  const match = new RegExp(unanchored).exec(branch);
101
101
  if (!match)
102
- throw new CliError(`Cannot deduce a ticket id from branch "${branch}" with pattern "${pattern}".`);
103
- return { id: match[0], branch };
102
+ return { kind: "noMatch", branch };
103
+ return { kind: "detected", id: match[0], branch };
104
+ }
105
+ export function deduceTicketFromBranch(cwd, pattern) {
106
+ const result = detectTicketFromBranch(cwd, pattern);
107
+ if (result.kind === "noBranch")
108
+ throw new CliError("Cannot deduce a ticket id from a detached HEAD.");
109
+ if (result.kind === "noMatch")
110
+ throw new CliError(`Cannot deduce a ticket id from branch "${result.branch}" with pattern "${pattern}".`);
111
+ return result;
104
112
  }
@@ -1,27 +1,34 @@
1
1
  export declare const PROJECT_CONFIG_FILENAME = ".alignfirst.json";
2
- export declare const portRangeSchema: import("arktype/internal/variants/object.ts").ObjectType<{
3
- first: number;
4
- last: number;
5
- }, {}>;
6
2
  export interface ProjectConfig {
7
3
  schemaVersion: 1;
8
4
  cli?: string;
9
- ticketPattern?: string;
5
+ ticketIdPattern?: string;
10
6
  plans?: PlansConfig;
11
7
  portRange?: PortRange;
12
- project?: ProjectIdentity;
8
+ git?: GitConfig;
13
9
  }
14
10
  export interface PlansConfig {
15
- folder: string;
11
+ folder?: string;
12
+ autoArchive?: boolean;
16
13
  }
17
14
  export interface PortRange {
18
15
  first: number;
19
16
  last: number;
20
17
  }
21
- export interface ProjectIdentity {
22
- remote?: string;
23
- paths?: string[];
18
+ export interface GitConfig {
19
+ defaultBranch?: string;
20
+ branchNameTemplate?: string;
21
+ commit?: CommitConfig;
22
+ agentCoauthoring?: boolean;
24
23
  }
25
- export declare function validateProjectConfig(value: unknown, label: string): ProjectConfig;
26
- export declare function assertValidPortRange(range: PortRange, label: string): void;
24
+ export interface CommitConfig {
25
+ style: "conventionalCommit";
26
+ ticketReference?: "bracketed" | "bracketedHash";
27
+ }
28
+ export interface ResolvedProjectConfig {
29
+ config: ProjectConfig;
30
+ source: "root";
31
+ }
32
+ export declare function resolveProjectConfig(cwd: string): ResolvedProjectConfig | undefined;
27
33
  export declare function readProjectConfig(dir: string): ProjectConfig | undefined;
34
+ export declare function validateProjectConfig(value: unknown, label: string): ProjectConfig;
@@ -1,45 +1,68 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { isAbsolute, join } from "node:path";
2
+ import { join } from "node:path";
3
3
  import { type } from "arktype";
4
4
  import semver from "semver";
5
5
  import { CliError } from "./cli-error.js";
6
6
  import { errorMessage } from "./errors.js";
7
7
  export const PROJECT_CONFIG_FILENAME = ".alignfirst.json";
8
- export const portRangeSchema = type({
8
+ const portRangeSchema = type({
9
9
  "+": "reject",
10
10
  first: "1 <= number.integer <= 65535",
11
11
  last: "1 <= number.integer <= 65535",
12
12
  });
13
13
  const plansSchema = type({
14
14
  "+": "reject",
15
- folder: "string > 0",
15
+ "folder?": "string > 0",
16
+ "autoArchive?": "boolean",
16
17
  });
17
- const projectSchema = type({
18
+ const commitSchema = type({
18
19
  "+": "reject",
19
- "remote?": "string > 0",
20
- "paths?": "string[]",
20
+ style: "'conventionalCommit'",
21
+ "ticketReference?": "'bracketed' | 'bracketedHash'",
22
+ });
23
+ const gitSchema = type({
24
+ "+": "reject",
25
+ "defaultBranch?": "string > 0",
26
+ "branchNameTemplate?": "string > 0",
27
+ "commit?": commitSchema,
28
+ "agentCoauthoring?": "boolean",
21
29
  });
22
30
  const projectConfigSchema = type({
23
31
  "+": "reject",
24
32
  schemaVersion: "1",
25
33
  "cli?": "string > 0",
26
- "ticketPattern?": "string > 0",
34
+ "ticketIdPattern?": "string > 0",
27
35
  "plans?": plansSchema,
28
36
  "portRange?": portRangeSchema,
29
- "project?": projectSchema,
37
+ "git?": gitSchema,
30
38
  });
39
+ export function resolveProjectConfig(cwd) {
40
+ const config = readProjectConfig(cwd);
41
+ return config === undefined ? undefined : { config, source: "root" };
42
+ }
43
+ export function readProjectConfig(dir) {
44
+ const path = join(dir, PROJECT_CONFIG_FILENAME);
45
+ if (!existsSync(path))
46
+ return;
47
+ let value;
48
+ try {
49
+ value = JSON.parse(readFileSync(path, "utf-8"));
50
+ }
51
+ catch (error) {
52
+ throw invalidConfig(path, errorMessage(error));
53
+ }
54
+ return validateProjectConfig(value, path);
55
+ }
31
56
  export function validateProjectConfig(value, label) {
32
57
  const config = projectConfigSchema(value);
33
58
  if (config instanceof type.errors)
34
59
  throw invalidConfig(label, config.summary.split("\n", 1)[0]);
35
60
  if (config.cli !== undefined && semver.validRange(config.cli) === null)
36
61
  throw invalidConfig(label, `cli is not a valid semver range: ${config.cli}`);
37
- if (config.ticketPattern !== undefined)
38
- assertValidPattern(config.ticketPattern, label);
62
+ if (config.ticketIdPattern !== undefined)
63
+ assertValidPattern(config.ticketIdPattern, label);
39
64
  if (config.portRange !== undefined)
40
65
  assertValidPortRange(config.portRange, label);
41
- if (config.project !== undefined)
42
- assertValidProjectIdentity(config.project, label);
43
66
  return config;
44
67
  }
45
68
  function invalidConfig(label, detail) {
@@ -50,30 +73,10 @@ function assertValidPattern(pattern, label) {
50
73
  new RegExp(pattern);
51
74
  }
52
75
  catch (error) {
53
- throw invalidConfig(label, `ticketPattern is not a valid regular expression: ${errorMessage(error)}`);
76
+ throw invalidConfig(label, `ticketIdPattern is not a valid regular expression: ${errorMessage(error)}`);
54
77
  }
55
78
  }
56
- export function assertValidPortRange(range, label) {
79
+ function assertValidPortRange(range, label) {
57
80
  if (range.first > range.last)
58
81
  throw invalidConfig(label, "portRange.first must not exceed portRange.last");
59
82
  }
60
- function assertValidProjectIdentity(project, label) {
61
- if (project.remote === undefined && project.paths === undefined)
62
- throw invalidConfig(label, "project must contain remote or paths");
63
- const relativePath = project.paths?.find((path) => !isAbsolute(path));
64
- if (relativePath !== undefined)
65
- throw invalidConfig(label, `project.paths must contain only absolute paths: ${relativePath}`);
66
- }
67
- export function readProjectConfig(dir) {
68
- const path = join(dir, PROJECT_CONFIG_FILENAME);
69
- if (!existsSync(path))
70
- return;
71
- let value;
72
- try {
73
- value = JSON.parse(readFileSync(path, "utf-8"));
74
- }
75
- catch (error) {
76
- throw invalidConfig(path, errorMessage(error));
77
- }
78
- return validateProjectConfig(value, path);
79
- }
package/dist/skills.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- import type { CommandContext } from "./context.js";
2
1
  export declare const STUB_SKILLS: readonly ["alignfirst", "alspec", "alplan", "al", "alcatchup", "almerge", "alreview", "aldescription"];
3
2
  export declare const SKILL_ROOTS: readonly [".agents/skills", ".claude/skills", ".codex/skills"];
4
3
  export interface InstalledSkill {
@@ -6,4 +5,3 @@ export interface InstalledSkill {
6
5
  version: string | undefined;
7
6
  }
8
7
  export declare function findInstalledSkill(home: string, name: string): InstalledSkill | undefined;
9
- export declare function installStubSkills(ctx: CommandContext, agents: string[]): void;
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.1",
4
4
  "license": "CC0-1.0",
5
5
  "author": "Thomas MUR",
6
6
  "description": "The AlignFirst CLI: protocols, plans and docs in one command.",
@@ -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.