alignfirst 0.1.0-beta.6 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -89,12 +89,12 @@ AlignFirst stores specifications, plans, and summaries in `.plans/<ticket-id>/`.
89
89
 
90
90
  ## Catchup output
91
91
 
92
- `alignfirst ticket [<id>] --catchup` prints the ticket's Markdown files, plans excluded, each in a `<file>` block carrying its path and modification time. When the history exceeds the output budget, only the entry list is printed so the agent can read the relevant files.
92
+ `alignfirst ticket [<id>] --catchup` prints the ticket's Markdown files, plans excluded, each in a `<file>` block carrying its path and modification time. When the history exceeds the output budget, only the entry list is printed, with a notice telling the agent to read the relevant files and skip the plans.
93
93
 
94
94
  ## Updates
95
95
 
96
96
  ```sh
97
- npm update -g alignfirst
97
+ npm install -g alignfirst@latest
98
98
  npx skills update --global --yes
99
99
  ```
100
100
 
@@ -5,6 +5,7 @@ import { CliError } from "../cli-error.js";
5
5
  import { assertMainWorktreeRoot } from "../git.js";
6
6
  import { parseBareCommandArgs, parseCommandArgs } from "../parse-args.js";
7
7
  import { archiveEntry, archiveThresholdDays, autoArchive } from "../plans/archive.js";
8
+ import { isTicketName } from "../plans/layout.js";
8
9
  import { linkPlans } from "../plans/link.js";
9
10
  import { resolvePlansMode } from "../plans/mode.js";
10
11
  import { findStoppedRebase, renderStoppedRebase } from "../plans/rebase.js";
@@ -156,9 +157,10 @@ function resolveArchiveTarget(ctx, args, usage) {
156
157
  const stats = statSync(target, { throwIfNoEntry: false });
157
158
  if (!stats?.isDirectory() || realpathSync(dirname(target)) !== realpathSync(plansDir))
158
159
  throw new CliError(`${argument} must be an existing directory directly under .plans.`);
159
- if (basename(target).startsWith("_"))
160
+ const name = basename(target);
161
+ if (!isTicketName(name))
160
162
  throw new CliError(`${argument}: names starting with _ are not tickets.`);
161
- return target;
163
+ return join(plansDir, name);
162
164
  }
163
165
  function isPathArgument(argument) {
164
166
  return argument.includes("/") || argument.includes("\\");
@@ -11,16 +11,18 @@ const USAGE = `Usage:
11
11
  {{FORM}} ticket --side [--next [<filename>]] [--new-cycle] [--json] [--dry-run]
12
12
  {{FORM}} ticket [<id>] --catchup
13
13
 
14
+ --next may repeat, each occurrence with a filename: the names are numbered in order.
15
+
14
16
  --catchup prints the ticket's Markdown files, plans excluded, summaries included.
15
17
  Files over 64 KiB are listed without content. Above 30 KiB of output, only the entry
16
18
  list is printed.
17
19
  `;
18
20
  export function runTicket(ctx, args) {
19
- assertPlansGate(ctx.cwd, ctx.form);
20
21
  const usage = renderUsage(ctx);
21
22
  const parsed = parseTicketArgs(ctx, args, usage);
22
23
  if (parsed === undefined)
23
24
  return 0;
25
+ assertPlansGate(ctx.cwd, ctx.form);
24
26
  const result = resolveTicket(ctx, parsed);
25
27
  if (parsed.catchup) {
26
28
  ctx.stdout.write(renderCatchup(ctx.cwd, result, renderReport(ctx, parsed, result)));
@@ -68,12 +70,10 @@ function parseTicketArgs(ctx, args, usage) {
68
70
  if (values.catchup &&
69
71
  (values.side || values.next !== undefined || values.json || values["dry-run"]))
70
72
  throw new CliError(`--catchup cannot be combined with --side, --next, --json, or --dry-run.\n\n${usage}`);
71
- if (normalized.filename !== undefined)
72
- validateNextFilename(normalized.filename);
73
73
  const resolution = resolveTicketId(ctx, positionals[0], values);
74
74
  return {
75
75
  ...resolution,
76
- next: values.next === undefined ? undefined : (normalized.filename ?? true),
76
+ next: values.next === undefined ? undefined : resolveNextRequest(normalized.requests, usage),
77
77
  newCycle: values["new-cycle"],
78
78
  json: values.json,
79
79
  dryRun: values["dry-run"],
@@ -82,7 +82,7 @@ function parseTicketArgs(ctx, args, usage) {
82
82
  };
83
83
  }
84
84
  function normalizeNextArgs(args) {
85
- const normalized = { args: [] };
85
+ const normalized = { args: [], requests: [] };
86
86
  for (let index = 0; index < args.length; ++index) {
87
87
  const arg = args[index];
88
88
  if (arg === "--") {
@@ -90,7 +90,7 @@ function normalizeNextArgs(args) {
90
90
  break;
91
91
  }
92
92
  if (arg.startsWith("--next=")) {
93
- normalized.filename = arg.slice("--next=".length);
93
+ normalized.requests.push(arg.slice("--next=".length));
94
94
  normalized.args.push("--next");
95
95
  continue;
96
96
  }
@@ -98,14 +98,25 @@ function normalizeNextArgs(args) {
98
98
  if (arg !== "--next")
99
99
  continue;
100
100
  const following = args[index + 1];
101
- delete normalized.filename;
102
101
  if (following !== undefined && !following.startsWith("-")) {
103
- normalized.filename = following;
102
+ normalized.requests.push(following);
104
103
  ++index;
105
104
  }
105
+ else
106
+ normalized.requests.push(undefined);
106
107
  }
107
108
  return normalized;
108
109
  }
110
+ function resolveNextRequest(requests, usage) {
111
+ if (requests.length === 1 && requests[0] === undefined)
112
+ return true;
113
+ const filenames = requests.filter((request) => request !== undefined);
114
+ if (filenames.length < requests.length)
115
+ throw new CliError(`A repeated --next requires a filename on each occurrence.\n\n${usage}`);
116
+ for (const filename of filenames)
117
+ validateNextFilename(filename);
118
+ return filenames;
119
+ }
109
120
  function validateNextFilename(filename) {
110
121
  if (filename.length === 0 || filename === "." || filename === ".." || /[\\/]/u.test(filename)) {
111
122
  throw new CliError("--next must be a non-empty single path segment.");
@@ -135,21 +146,26 @@ function resolveTicket(ctx, options) {
135
146
  };
136
147
  return resolveTicketDir(ctx.cwd, options.id, { dryRun: options.dryRun });
137
148
  }
138
- function writeNextReport(ctx, options, result, filename) {
149
+ function writeNextReport(ctx, options, result, request) {
139
150
  const names = result.entries.map((entry) => entry.name);
140
151
  const { cycleLetter, fileNumber } = nextFilePosition(names, options.newCycle);
141
- const prefix = `${cycleLetter}${fileNumber}`;
152
+ const fileName = (filename, offset) => `${cycleLetter}${fileNumber + offset}-${filename}`;
142
153
  const report = {
143
154
  TICKET_DIR: `${relative(ctx.cwd, result.dir)}/`,
144
155
  CYCLE_LETTER: cycleLetter,
145
- FILE_NUMBER: fileNumber,
146
- ...(filename === true ? { FILE_PREFIX: prefix } : { FILE_NAME: `${prefix}-${filename}` }),
156
+ ...(request === true
157
+ ? { FILE_NUMBER: fileNumber, FILE_PREFIX: `${cycleLetter}${fileNumber}` }
158
+ : request.length === 1
159
+ ? { FILE_NUMBER: fileNumber, FILE_NAME: fileName(request[0], 0) }
160
+ : { FILE_NAMES: request.map(fileName) }),
147
161
  };
148
162
  if (options.json) {
149
163
  ctx.stdout.write(`${JSON.stringify(report, undefined, 2)}\n`);
150
164
  return;
151
165
  }
152
- const lines = Object.entries(report).map(([name, value]) => `- ${name}: \`${value}\``);
166
+ const lines = Object.entries(report).map(([name, value]) => Array.isArray(value)
167
+ ? [`- ${name}:`, ...value.map((item) => ` - \`${item}\``)].join("\n")
168
+ : `- ${name}: \`${value}\``);
153
169
  ctx.stdout.write(`${lines.join("\n")}\n`);
154
170
  }
155
171
  function jsonReport(ctx, options, result) {
@@ -1,6 +1,7 @@
1
1
  import { existsSync, lstatSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { renderDefaultBranchLine, resolveDefaultBranch } from "./default-branch.js";
4
+ import { errorMessage } from "./errors.js";
4
5
  import { gitOutputOrUndefined, gitSucceeds } from "./git.js";
5
6
  import { resolvePlansMode } from "./plans/mode.js";
6
7
  export function renderConventions(ctx) {
@@ -59,8 +60,7 @@ function renderPlans(ctx) {
59
60
  return `${base}${archival}`;
60
61
  }
61
62
  catch (error) {
62
- const message = error instanceof Error ? error.message : String(error);
63
- return `Plans: ${message.split("\n", 1)[0]}`;
63
+ return `Plans: ${errorMessage(error).split("\n", 1)[0]}`;
64
64
  }
65
65
  }
66
66
  function plansEntryExists(cwd) {
@@ -1,12 +1,13 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join, relative } from "node:path";
3
3
  import { CliError } from "../cli-error.js";
4
+ import { errorMessage } from "../errors.js";
4
5
  import { formatLocalTimestamp, formatSize } from "../format.js";
5
6
  const MAX_FILE_BYTES = 64 * 1024;
6
7
  const MAX_OUTPUT_BYTES = 30 * 1024;
7
8
  const PLAN_FILE = /^[A-Z]\d+-(?:main-plan|plan-.*)\.md$/;
8
9
  const OMISSION_NOTICE = `Content omitted: over the ${formatSize(MAX_FILE_BYTES)} limit.`;
9
- const TOO_LARGE_NOTICE = `History too large to print (over the ${formatSize(MAX_OUTPUT_BYTES)} budget). Read the relevant files among the entries above rather than all of them.`;
10
+ const TOO_LARGE_NOTICE = `History too large to print (over the ${formatSize(MAX_OUTPUT_BYTES)} budget). Read the relevant files among the entries above, skipping the plans (\`*-plan.md\`, \`*-main-plan.md\`, \`*-plan-*.md\`): their \`.summary.md\` files cover them.`;
10
11
  export function renderCatchup(cwd, ticket, report) {
11
12
  const files = ticket.entries.flatMap((entry) => catchupFile(cwd, ticket.dir, entry));
12
13
  if (files.length === 0)
@@ -49,7 +50,6 @@ function readBody(cwd, file) {
49
50
  return readFileSync(join(cwd, file.path), "utf-8");
50
51
  }
51
52
  catch (error) {
52
- const detail = error instanceof Error ? error.message : String(error);
53
- throw new CliError(`Cannot load catchup file ${file.path}: ${detail}`);
53
+ throw new CliError(`Cannot load catchup file ${file.path}: ${errorMessage(error)}`);
54
54
  }
55
55
  }
@@ -1,9 +1,11 @@
1
+ import { type Stats } from "node:fs";
1
2
  import { CliError } from "../cli-error.js";
2
3
  export declare const PLANS_DIR = ".plans";
3
4
  export declare const ARCHIVES_DIR = "_archives";
4
5
  export declare function isTicketName(name: string): boolean;
5
6
  export declare function plansDir(cwd: string): string;
6
7
  export declare function archivesDir(cwd: string): string;
7
- export declare function assertPlansGate(cwd: string, form: string): void;
8
+ /** Returns the lstat of `.plans`, so callers can tell a symlink from a directory. */
9
+ export declare function assertPlansGate(cwd: string, form: string): Stats;
8
10
  export declare function missingPlansMessage(form: string): string;
9
11
  export declare function missingPlansError(form: string): CliError;
@@ -1,4 +1,4 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, lstatSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { CliError } from "../cli-error.js";
4
4
  export const PLANS_DIR = ".plans";
@@ -12,10 +12,17 @@ export function plansDir(cwd) {
12
12
  export function archivesDir(cwd) {
13
13
  return join(plansDir(cwd), ARCHIVES_DIR);
14
14
  }
15
+ /** Returns the lstat of `.plans`, so callers can tell a symlink from a directory. */
15
16
  export function assertPlansGate(cwd, form) {
16
- if (existsSync(plansDir(cwd)))
17
- return;
18
- throw missingPlansError(form);
17
+ const path = plansDir(cwd);
18
+ const stats = lstatSync(path, { throwIfNoEntry: false });
19
+ if (!stats)
20
+ throw missingPlansError(form);
21
+ if (stats.isSymbolicLink() && !existsSync(path))
22
+ throw new CliError(`The .plans symlink is broken. Re-run ${form} plans setup with the clone location.`);
23
+ if (!statSync(path).isDirectory())
24
+ throw new CliError(`.plans is not a directory. Remove it, then run ${form} plans setup (see the project documentation).`);
25
+ return stats;
19
26
  }
20
27
  export function missingPlansMessage(form) {
21
28
  return `No .plans/ directory in the current directory.\nLocal plans: mkdir .plans && echo .plans >> .gitignore\nTeam plans: ${form} plans setup <clone-dir>`;
@@ -1,17 +1,11 @@
1
- import { existsSync, lstatSync, realpathSync, statSync } from "node:fs";
1
+ import { realpathSync } 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
+ import { assertPlansGate } from "./layout.js";
6
6
  export function resolvePlansMode(cwd, form) {
7
7
  const plansPath = join(cwd, ".plans");
8
- const stats = lstatSync(plansPath, { throwIfNoEntry: false });
9
- if (!stats)
10
- throw missingPlansError(form);
11
- if (stats.isSymbolicLink() && !existsSync(plansPath))
12
- throw new CliError(`The .plans symlink is broken. Re-run ${form} plans setup with the clone location.`);
13
- if (!statSync(plansPath).isDirectory())
14
- throw new CliError(`.plans is not a directory. Remove it, then run ${form} plans setup (see the project documentation).`);
8
+ const stats = assertPlansGate(cwd, form);
15
9
  if (plansRepositoryId(plansPath, stats.isSymbolicLink(), form) === repositoryId(cwd))
16
10
  return { kind: "local" };
17
11
  return { kind: "shared", repoToplevel: gitOutput(plansPath, "rev-parse", "--show-toplevel") };
@@ -105,7 +105,7 @@ export function isPathSafeTicketId(id) {
105
105
  return id !== "." && !id.includes("..") && PATH_SAFE_TICKET.test(id);
106
106
  }
107
107
  export function validateTicketId(id, pattern) {
108
- if (!isPathSafeTicketId(id))
108
+ if (!isPathSafeTicketId(id) || !isTicketName(id))
109
109
  throw new CliError(`Invalid ticket id: ${id}`);
110
110
  if (pattern !== undefined && !new RegExp(pattern).test(id) && !SIDE_TICKET.test(id))
111
111
  throw new CliError(`Ticket id "${id}" does not match ticketIdPattern "${pattern}".`);
@@ -75,6 +75,26 @@ function assertValidPattern(pattern, label) {
75
75
  catch (error) {
76
76
  throw invalidConfig(label, `ticketIdPattern is not a valid regular expression: ${errorMessage(error)}`);
77
77
  }
78
+ if (hasInnerAnchor(pattern))
79
+ throw invalidConfig(label, "ticketIdPattern must be one anchored expression; group alternatives: ^(ABC|XYZ)-\\d+$");
80
+ }
81
+ /** Branch detection unanchors the pattern by trimming its ends, so inner anchors never match. */
82
+ function hasInnerAnchor(pattern) {
83
+ let inClass = false;
84
+ for (let i = 0; i < pattern.length; ++i) {
85
+ const char = pattern[i];
86
+ if (char === "\\")
87
+ ++i;
88
+ else if (inClass)
89
+ inClass = char !== "]";
90
+ else if (char === "[")
91
+ inClass = true;
92
+ else if (char === "^" && i !== 0)
93
+ return true;
94
+ else if (char === "$" && i !== pattern.length - 1)
95
+ return true;
96
+ }
97
+ return false;
78
98
  }
79
99
  function assertValidPortRange(range, label) {
80
100
  if (range.first > range.last)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alignfirst",
3
- "version": "0.1.0-beta.6",
3
+ "version": "0.1.0",
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.10.0-beta.1",
37
+ "@paleo/docmap": "~0.10.0",
38
38
  "arktype": "^2.2.3",
39
39
  "semver": "^7.8.5"
40
40
  },
@@ -18,9 +18,9 @@ Files use `{CYCLE_LETTER}{FILE_NUMBER}-{FILE_TYPE}.md`. FILE_PREFIX combines the
18
18
 
19
19
  Immediately before creating each file, run `{{TICKET_CMD}} --next <filename>` with the extension included. It returns TICKET_DIR, CYCLE_LETTER, FILE_NUMBER, and FILE_NAME. Append FILE_NAME to TICKET_DIR to get the file path, preserving the leading dot and existing slash.
20
20
 
21
- With no filename, `{{TICKET_CMD}} --next` returns FILE_PREFIX instead of FILE_NAME. Add `--new-cycle` to either form when the protocol or user calls for a new cycle.
21
+ With no filename, `{{TICKET_CMD}} --next` returns FILE_PREFIX instead of FILE_NAME. To name several files at once, repeat `--next <filename>` once per file: the command returns FILE_NAMES, numbered in that order. Add `--new-cycle` to any form when the protocol or user calls for a new cycle.
22
22
 
23
- Neither form reserves a number. Write the file before requesting another filename. With `--json`, the same information uses the same field names.
23
+ No form reserves a number. Write the named file or files before requesting another filename. With `--json`, the same information uses the same field names.
24
24
 
25
25
  Common file types are `spec`, `plan`, `AAD.summary`, `description`, `review`, and `merge.summary`. Use another type when needed.
26
26
 
@@ -207,32 +207,22 @@ Note:
207
207
 
208
208
  ## Phase 5. Writing
209
209
 
210
- Write the plan file(s) according to the determined structure:
210
+ Continue the current cycle. Request the file names with `{{TICKET_CMD}} --next`, then write every file it named before requesting anything else. Append each name to TICKET_DIR to get its path. Never overwrite an existing file.
211
211
 
212
- Continue the current cycle. Immediately before writing each file, run `{{TICKET_CMD}} --next plan.md`, `{{TICKET_CMD}} --next main-plan.md`, or `{{TICKET_CMD}} --next plan-<descriptor>.md`. Append FILE_NAME to TICKET_DIR to get its path. Write that file before requesting the next one.
212
+ **Single plan**: run `{{TICKET_CMD}} --next plan.md`.
213
213
 
214
- **Single Plan**:
214
+ - Plan: `{TICKET_DIR}{CYCLE_LETTER}{FILE_NUMBER}-plan.md`, e.g. `.plans/123/A2-plan.md`
215
+ - Handover: `.plans/123/A2-plan.summary.md`
215
216
 
216
- - **Single plan**: `{TICKET_DIR}{CYCLE_LETTER}{FILE_NUMBER}-plan.md`
217
- - Example: `.plans/123/A2-plan.md`
218
- - Handover: `.plans/123/A2-plan.summary.md`
219
- - No main plan needed
217
+ **Multiple plans**: run one command with a `--next` per file, the main plan first, then the specialized plans in the order the main plan lists them. The command returns all the names, numbered in that order, so the main plan can reference the specialized plans exactly. Example:
220
218
 
221
- **Multiple Plans**:
222
-
223
- - **Main plan**: `{TICKET_DIR}{CYCLE_LETTER}{FILE_NUMBER}-main-plan.md`
224
- - Example: `.plans/123/A2-main-plan.md`
225
- - Handover: `.plans/123/A2-main-plan.summary.md` (written after all specialized plans complete)
226
- - **Specialized plans**: `{TICKET_DIR}{CYCLE_LETTER}{FILE_NUMBER}-plan-{DESCRIPTOR}.md`
227
- - Use a descriptive name as `{DESCRIPTOR}` (e.g., work scope, stack area)
228
- - Example: `.plans/123/A3-plan-api.md`, `.plans/123/A4-plan-ui.md`
229
- - Handovers: `.plans/123/A3-plan-api.summary.md`, etc.
230
-
231
- **Important**:
219
+ ```
220
+ {{TICKET_CMD}} --next main-plan.md --next plan-api.md --next plan-ui.md
221
+ ```
232
222
 
233
- - Use lowercase, hyphenated descriptors for plan names (work scope descriptor)
234
- - When multiple plans are created, the main plan should be written first and have the lowest FILE_NUMBER
235
- - Be careful never to overwrite an existing file
223
+ - Main plan: `{TICKET_DIR}{CYCLE_LETTER}{FILE_NUMBER}-main-plan.md`, e.g. `.plans/123/A2-main-plan.md`
224
+ - Specialized plans: `{TICKET_DIR}{CYCLE_LETTER}{FILE_NUMBER}-plan-{DESCRIPTOR}.md`, e.g. `.plans/123/A3-plan-api.md`, `.plans/123/A4-plan-ui.md`. The descriptor is lowercase and hyphenated and names the work scope or stack area.
225
+ - Handovers: replace the final `.md` with `.summary.md`, e.g. `.plans/123/A3-plan-api.summary.md`. The main plan handover is written after all specialized plans complete.
236
226
 
237
227
  _Important Note: There will be lint errors in the markdown files you write. Ignore them. NEVER FIX LINT ERRORS (FORMATTING ISSUES) IN THE PLANS._
238
228