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