project-logbook 0.3.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 +34 -0
- package/dist/commands/build.d.ts +1 -0
- package/dist/commands/build.js +174 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +83 -0
- package/dist/commands/lint.d.ts +1 -0
- package/dist/commands/lint.js +78 -0
- package/dist/commands/list.d.ts +3 -0
- package/dist/commands/list.js +127 -0
- package/dist/commands/log.d.ts +3 -0
- package/dist/commands/log.js +31 -0
- package/dist/commands/new.d.ts +1 -0
- package/dist/commands/new.js +135 -0
- package/dist/commands/preview.d.ts +1 -0
- package/dist/commands/preview.js +19 -0
- package/dist/commands/release.d.ts +3 -0
- package/dist/commands/release.js +66 -0
- package/dist/commands/start.d.ts +1 -0
- package/dist/commands/start.js +87 -0
- package/dist/commands/steer.d.ts +1 -0
- package/dist/commands/steer.js +22 -0
- package/dist/commands/upgrade.d.ts +1 -0
- package/dist/commands/upgrade.js +23 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +92 -0
- package/dist/lib/about-content.d.ts +1 -0
- package/dist/lib/about-content.js +7 -0
- package/dist/lib/build-helpers.d.ts +15 -0
- package/dist/lib/build-helpers.js +171 -0
- package/dist/lib/config.d.ts +14 -0
- package/dist/lib/config.js +41 -0
- package/dist/lib/git-helpers.d.ts +16 -0
- package/dist/lib/git-helpers.js +93 -0
- package/dist/lib/image-helpers.d.ts +19 -0
- package/dist/lib/image-helpers.js +121 -0
- package/dist/lib/jira-helpers.d.ts +5 -0
- package/dist/lib/jira-helpers.js +5 -0
- package/dist/lib/lint-runner.d.ts +2 -0
- package/dist/lib/lint-runner.js +31 -0
- package/dist/lib/lint-types.d.ts +24 -0
- package/dist/lib/lint-types.js +1 -0
- package/dist/lib/lockfile.d.ts +6 -0
- package/dist/lib/lockfile.js +1 -0
- package/dist/lib/logbook-client.d.ts +5 -0
- package/dist/lib/logbook-client.js +11 -0
- package/dist/lib/migrations.d.ts +11 -0
- package/dist/lib/migrations.js +34 -0
- package/dist/lib/session.d.ts +16 -0
- package/dist/lib/session.js +74 -0
- package/dist/lib/styles.d.ts +1 -0
- package/dist/lib/styles.js +21 -0
- package/dist/lib/template-types.d.ts +118 -0
- package/dist/lib/template-types.js +1 -0
- package/dist/lib/templates.d.ts +14 -0
- package/dist/lib/templates.js +158 -0
- package/dist/linters/frontmatter.d.ts +3 -0
- package/dist/linters/frontmatter.js +68 -0
- package/dist/linters/index.d.ts +1 -0
- package/dist/linters/index.js +18 -0
- package/dist/linters/jira-prefix.d.ts +3 -0
- package/dist/linters/jira-prefix.js +34 -0
- package/dist/linters/links.d.ts +3 -0
- package/dist/linters/links.js +28 -0
- package/dist/linters/lockfile.d.ts +3 -0
- package/dist/linters/lockfile.js +20 -0
- package/dist/linters/placeholders.d.ts +3 -0
- package/dist/linters/placeholders.js +62 -0
- package/dist/linters/project-integrity.d.ts +3 -0
- package/dist/linters/project-integrity.js +29 -0
- package/dist/linters/readability.d.ts +3 -0
- package/dist/linters/readability.js +41 -0
- package/dist/linters/workspaces.d.ts +3 -0
- package/dist/linters/workspaces.js +26 -0
- package/dist/templates/ABOUT.md +23 -0
- package/dist/templates/CONTRIBUTING.md +78 -0
- package/dist/templates/favicon.svg +21 -0
- package/dist/templates/index.md +30 -0
- package/dist/templates/log.md +4 -0
- package/dist/templates/logbook-client.js +162 -0
- package/dist/templates/steer.txt +25 -0
- package/dist/templates/styles.css +641 -0
- package/dist/templates/ticket.md +4 -0
- package/dist/utils/date.d.ts +8 -0
- package/dist/utils/date.js +47 -0
- package/dist/utils/fs.d.ts +13 -0
- package/dist/utils/fs.js +38 -0
- package/dist/utils/id.d.ts +7 -0
- package/dist/utils/id.js +36 -0
- package/dist/utils/slug.d.ts +2 -0
- package/dist/utils/slug.js +9 -0
- package/dist/utils/string.d.ts +2 -0
- package/dist/utils/string.js +4 -0
- package/package.json +69 -0
- package/src/templates/ABOUT.md +23 -0
- package/src/templates/CONTRIBUTING.md +78 -0
- package/src/templates/favicon.svg +21 -0
- package/src/templates/index.md +30 -0
- package/src/templates/log.md +4 -0
- package/src/templates/logbook-client.js +162 -0
- package/src/templates/steer.txt +25 -0
- package/src/templates/styles.css +641 -0
- package/src/templates/ticket.md +4 -0
package/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# project-logbook
|
|
2
|
+
|
|
3
|
+
**Idea:** Imagine returning from a week away to 50 tickets resolved by LLM agents. Instead of scanning endless diffs, a narrative timeline summarizes each change as a brief story, bringing you up to speed in minutes.
|
|
4
|
+
|
|
5
|
+
**Project Logbook** is an experimental CLI tool that automates documentation in agentic developer environments by generating a web-based timeline of recent changes. By establishing simple workflows and prompting guidelines for both humans and LLMs, it is currently best suited for smaller projects with a low-to-moderate volume of changes.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the mandatory working protocol.
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
## The Three-File Standard
|
|
13
|
+
Each entry in `logbook/` consists of:
|
|
14
|
+
1. `ticket.md`: Requirements and specifications.
|
|
15
|
+
2. `log.md`: Technical chronological protocol (developer/AI scratchpad).
|
|
16
|
+
3. `index.md`: Polished narrative of the change (human-readable story).
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
```bash
|
|
20
|
+
npm i project-loogbook -g
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
- `logbook init`: Initialize configuration and directory.
|
|
25
|
+
- `logbook new <id> <slug>`: Create a new entry folder with templates.
|
|
26
|
+
- `logbook start <id>`: Mark a logbook entry as active (writes `.logbook-active` lockfile). The active entry is also auto-detected from the current Git branch — a branch named `feat/LB-123-my-feature` will automatically resolve to entry `LB-123`.
|
|
27
|
+
- `logbook release`: Release the active logbook entry (removes `.logbook-active` lockfile).
|
|
28
|
+
- `logbook log <message>`: Append a timestamped log entry to the active `log.md`.
|
|
29
|
+
- `logbook list`: List all logbook entries with their status.
|
|
30
|
+
- `logbook lint`: Validate structure, frontmatter, and internal links.
|
|
31
|
+
- `logbook build`: Compile logbook entries into a static HTML site (default: `public/`).
|
|
32
|
+
- `logbook preview`: Build and open the logbook in your default browser.
|
|
33
|
+
- `logbook upgrade`: Synchronize core project files (like `CONTRIBUTING.md`) with latest templates.
|
|
34
|
+
- `logbook steer`: Output agentic protocol for AI assistants.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function build(): Promise<void>;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { getConfig, getWorkspaces } from '../lib/config.js';
|
|
5
|
+
import { layout, timelineTemplate } from '../lib/templates.js';
|
|
6
|
+
import { getStyles } from '../lib/styles.js';
|
|
7
|
+
import { getClientScript } from '../lib/logbook-client.js';
|
|
8
|
+
import { mdToHtml, buildProjectMdFiles, renderPost } from '../lib/build-helpers.js';
|
|
9
|
+
import { mdToHtmlWithImages, copyImages } from '../lib/image-helpers.js';
|
|
10
|
+
import { getGitCommits, getGitTags } from '../lib/git-helpers.js';
|
|
11
|
+
import { formatRelativeDate, getMonthYear, getSortTime } from '../utils/date.js';
|
|
12
|
+
import { getLogbookEntries } from '../utils/fs.js';
|
|
13
|
+
import { getAboutContent } from '../lib/about-content.js';
|
|
14
|
+
const pkg = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
15
|
+
const version = pkg.version;
|
|
16
|
+
export async function build() {
|
|
17
|
+
const config = getConfig();
|
|
18
|
+
const logbookDir = join(process.cwd(), config.logbookDir);
|
|
19
|
+
const outputDir = join(process.cwd(), config.outputDir);
|
|
20
|
+
const buildTime = new Date()
|
|
21
|
+
.toLocaleString('de-DE', {
|
|
22
|
+
year: 'numeric',
|
|
23
|
+
month: '2-digit',
|
|
24
|
+
day: '2-digit',
|
|
25
|
+
hour: '2-digit',
|
|
26
|
+
minute: '2-digit',
|
|
27
|
+
second: '2-digit',
|
|
28
|
+
hour12: false,
|
|
29
|
+
})
|
|
30
|
+
.replace(',', '');
|
|
31
|
+
if (!(await fs.pathExists(logbookDir))) {
|
|
32
|
+
console.error(chalk.red(`Error: Logbook directory '${config.logbookDir}' not found.`));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
await fs.remove(outputDir);
|
|
36
|
+
await fs.mkdirp(outputDir);
|
|
37
|
+
await fs.copyFile(new URL('../templates/favicon.svg', import.meta.url), join(outputDir, 'favicon.svg'));
|
|
38
|
+
await fs.writeFile(join(outputDir, 'style.css'), getStyles(config.primaryColor));
|
|
39
|
+
await fs.writeFile(join(outputDir, 'logbook.js'), getClientScript());
|
|
40
|
+
const readmePath = join(process.cwd(), 'README.md');
|
|
41
|
+
let readmeHtml = '';
|
|
42
|
+
let readmeImages = [];
|
|
43
|
+
try {
|
|
44
|
+
if (await fs.pathExists(readmePath)) {
|
|
45
|
+
const readmeContent = await fs.readFile(readmePath, 'utf8');
|
|
46
|
+
const { html, imagePaths } = await mdToHtmlWithImages(readmeContent);
|
|
47
|
+
readmeHtml = html;
|
|
48
|
+
readmeImages = imagePaths;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
console.warn(chalk.yellow('Warning: Failed to read README.md:', error instanceof Error ? error.message : error));
|
|
53
|
+
readmeHtml = '<p>README unavailable.</p>';
|
|
54
|
+
}
|
|
55
|
+
// Copy README images to output
|
|
56
|
+
if (readmeImages.length > 0) {
|
|
57
|
+
await copyImages(process.cwd(), outputDir, readmeImages);
|
|
58
|
+
}
|
|
59
|
+
const aboutHtml = await mdToHtml(getAboutContent());
|
|
60
|
+
const logbookEntries = await getLogbookEntries(logbookDir);
|
|
61
|
+
const timelineEntries = [];
|
|
62
|
+
const availableWorkspaces = getWorkspaces();
|
|
63
|
+
for (const entry of logbookEntries) {
|
|
64
|
+
if (!entry.hasIndex)
|
|
65
|
+
continue;
|
|
66
|
+
const { data, content, slug } = entry;
|
|
67
|
+
let summary = data.summary;
|
|
68
|
+
if (!summary) {
|
|
69
|
+
const firstParagraph = content.split('\n\n').find((p) => p.trim() && !p.trim().startsWith('#'));
|
|
70
|
+
summary = firstParagraph
|
|
71
|
+
? firstParagraph.trim().substring(0, 200).replace(/[*#`]/g, '') + '...'
|
|
72
|
+
: 'No summary available.';
|
|
73
|
+
}
|
|
74
|
+
const entryWorkspaces = Array.isArray(data.workspaces)
|
|
75
|
+
? data.workspaces.filter((ws) => typeof ws === 'string' && availableWorkspaces.includes(ws))
|
|
76
|
+
: [];
|
|
77
|
+
const dateStartValue = data.dateStart;
|
|
78
|
+
const dateStart = typeof dateStartValue === 'string'
|
|
79
|
+
? dateStartValue
|
|
80
|
+
: dateStartValue instanceof Date
|
|
81
|
+
? dateStartValue.toISOString()
|
|
82
|
+
: '';
|
|
83
|
+
// Skip DRAFT entries (no valid dateStart) — they break the timeline
|
|
84
|
+
if (!dateStart || dateStart.includes('{{'))
|
|
85
|
+
continue;
|
|
86
|
+
const dateEndValue = data.dateEnd;
|
|
87
|
+
const dateEnd = typeof dateEndValue === 'string'
|
|
88
|
+
? dateEndValue
|
|
89
|
+
: dateEndValue instanceof Date
|
|
90
|
+
? dateEndValue.toISOString()
|
|
91
|
+
: undefined;
|
|
92
|
+
timelineEntries.push({
|
|
93
|
+
kind: 'entry',
|
|
94
|
+
slug,
|
|
95
|
+
dateStart,
|
|
96
|
+
dateEnd,
|
|
97
|
+
displayDate: formatRelativeDate(dateStart),
|
|
98
|
+
sortTime: getSortTime(dateStart, dateEnd),
|
|
99
|
+
summary: typeof summary === 'string' ? summary : 'No summary available.',
|
|
100
|
+
ticket: typeof data.ticket === 'string' ? data.ticket : slug,
|
|
101
|
+
monthGroup: getMonthYear(dateStart),
|
|
102
|
+
tags: Array.isArray(data.tags) ? data.tags.filter((t) => typeof t === 'string') : [],
|
|
103
|
+
workspaces: entryWorkspaces,
|
|
104
|
+
title: typeof data.title === 'string' ? data.title : undefined,
|
|
105
|
+
harness: typeof data.harness === 'string' ? data.harness : undefined,
|
|
106
|
+
llm: typeof data.llm === 'string' ? data.llm : undefined,
|
|
107
|
+
prompter: typeof data.prompter === 'string' ? data.prompter : undefined,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
// Fetch global git commits and tags concurrently.
|
|
111
|
+
const [globalCommits, rawTags] = await Promise.all([getGitCommits(process.cwd()), getGitTags()]);
|
|
112
|
+
const commitItems = globalCommits.map((c) => ({
|
|
113
|
+
kind: 'commit',
|
|
114
|
+
sha: c.sha,
|
|
115
|
+
message: c.message,
|
|
116
|
+
timestamp: c.timestamp,
|
|
117
|
+
relativeTime: c.relativeTime,
|
|
118
|
+
displayDate: formatRelativeDate(c.timestamp),
|
|
119
|
+
monthGroup: getMonthYear(c.timestamp),
|
|
120
|
+
sortTime: getSortTime(c.timestamp),
|
|
121
|
+
}));
|
|
122
|
+
const tagItems = rawTags.map((t) => ({
|
|
123
|
+
kind: 'tag',
|
|
124
|
+
name: t.name,
|
|
125
|
+
timestamp: t.timestamp,
|
|
126
|
+
displayDate: formatRelativeDate(t.timestamp),
|
|
127
|
+
monthGroup: getMonthYear(t.timestamp),
|
|
128
|
+
sortTime: getSortTime(t.timestamp),
|
|
129
|
+
}));
|
|
130
|
+
console.log(chalk.gray(`Found ${timelineEntries.length} entr${timelineEntries.length === 1 ? 'y' : 'ies'}, ${commitItems.length} commit${commitItems.length === 1 ? '' : 's'} (max 100), and ${tagItems.length} tag${tagItems.length === 1 ? '' : 's'}.`));
|
|
131
|
+
timelineEntries.sort((a, b) => {
|
|
132
|
+
const aEnd = a.dateEnd ? new Date(a.dateEnd).getTime() : new Date(a.dateStart).getTime();
|
|
133
|
+
const bEnd = b.dateEnd ? new Date(b.dateEnd).getTime() : new Date(b.dateStart).getTime();
|
|
134
|
+
return bEnd - aEnd;
|
|
135
|
+
});
|
|
136
|
+
await Promise.all(timelineEntries.map((entry, i) => renderPost(entry, i, timelineEntries, { logbookDir, outputDir, config, version, buildTime })));
|
|
137
|
+
const groups = groupTimelineItems(timelineEntries, commitItems, tagItems);
|
|
138
|
+
const timelineContent = timelineTemplate({ projectName: config.projectName, version, buildTime, groups, readmeHtml, aboutHtml, jiraBaseUrl: config.jiraBaseUrl, jiraPrefix: config.jiraPrefix }); // prettier-ignore
|
|
139
|
+
const buildMeta = `Generated by ${config.projectName} v${version} • ${buildTime}`;
|
|
140
|
+
const timelineHtml = layout({
|
|
141
|
+
title: 'Timeline',
|
|
142
|
+
projectName: config.projectName,
|
|
143
|
+
basePath: './',
|
|
144
|
+
buildMeta,
|
|
145
|
+
header: timelineContent.split('</header>')[0] + '</header>',
|
|
146
|
+
content: timelineContent.split('</header>')[1],
|
|
147
|
+
});
|
|
148
|
+
await fs.writeFile(join(outputDir, 'index.html'), timelineHtml);
|
|
149
|
+
await buildProjectMdFiles(process.cwd(), outputDir, [config.logbookDir, config.outputDir, 'node_modules'], {
|
|
150
|
+
config,
|
|
151
|
+
version,
|
|
152
|
+
buildTime,
|
|
153
|
+
});
|
|
154
|
+
console.log(chalk.green(`\nSuccessfully built logbook to ${config.outputDir}/`));
|
|
155
|
+
}
|
|
156
|
+
function groupTimelineItems(entries, commits, tags) {
|
|
157
|
+
const toMs = (item) => {
|
|
158
|
+
if (item.kind === 'entry')
|
|
159
|
+
return item.dateEnd ? new Date(item.dateEnd).getTime() : new Date(item.dateStart).getTime();
|
|
160
|
+
return new Date(item.timestamp).getTime();
|
|
161
|
+
};
|
|
162
|
+
const all = [...entries, ...commits, ...tags];
|
|
163
|
+
all.sort((a, b) => toMs(b) - toMs(a));
|
|
164
|
+
const groups = [];
|
|
165
|
+
for (const item of all) {
|
|
166
|
+
let group = groups.find((g) => g.month === item.monthGroup);
|
|
167
|
+
if (!group) {
|
|
168
|
+
group = { month: item.monthGroup, items: [] };
|
|
169
|
+
groups.push(group);
|
|
170
|
+
}
|
|
171
|
+
group.items.push(item);
|
|
172
|
+
}
|
|
173
|
+
return groups;
|
|
174
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function init(): Promise<void>;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { DEFAULT_CONFIG } from '../lib/config.js';
|
|
6
|
+
import { getTemplatesDir } from '../lib/migrations.js';
|
|
7
|
+
const rl = createInterface({
|
|
8
|
+
input: process.stdin,
|
|
9
|
+
output: process.stdout,
|
|
10
|
+
});
|
|
11
|
+
const question = (prompt) => {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
rl.question(prompt, (answer) => {
|
|
14
|
+
resolve(answer.trim());
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
};
|
|
18
|
+
function readPackageJsonName(cwd) {
|
|
19
|
+
const pkgPath = join(cwd, 'package.json');
|
|
20
|
+
if (!fs.existsSync(pkgPath))
|
|
21
|
+
return null;
|
|
22
|
+
try {
|
|
23
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
24
|
+
return typeof pkg.name === 'string' && pkg.name ? pkg.name : null;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function init() {
|
|
31
|
+
const cwd = process.cwd();
|
|
32
|
+
const configPath = join(cwd, '.project-logbook');
|
|
33
|
+
const logbookDir = join(cwd, DEFAULT_CONFIG.logbookDir);
|
|
34
|
+
const contributingPath = join(cwd, 'CONTRIBUTING.md');
|
|
35
|
+
// 1. Config file
|
|
36
|
+
if (await fs.pathExists(configPath)) {
|
|
37
|
+
console.log(chalk.yellow('Already initialized. .project-logbook exists.'));
|
|
38
|
+
rl.close();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
console.log(chalk.blue('\n📒 Initializing project-logbook\n'));
|
|
42
|
+
// Detect project name from package.json
|
|
43
|
+
const detectedName = readPackageJsonName(cwd);
|
|
44
|
+
const defaultName = detectedName || DEFAULT_CONFIG.projectName;
|
|
45
|
+
const nameInput = await question(chalk.cyan(`Project name (press Enter for "${defaultName}"): `));
|
|
46
|
+
const projectName = nameInput || defaultName;
|
|
47
|
+
// Optional Jira config
|
|
48
|
+
const jiraPrefixInput = await question(chalk.cyan('Jira project prefix (e.g. MYAPP, leave blank to skip): '));
|
|
49
|
+
const jiraPrefix = jiraPrefixInput || undefined;
|
|
50
|
+
let jiraBaseUrl;
|
|
51
|
+
if (jiraPrefix) {
|
|
52
|
+
const jiraUrlInput = await question(chalk.cyan('Jira base URL (e.g. https://yourorg.atlassian.net, leave blank to skip): '));
|
|
53
|
+
jiraBaseUrl = jiraUrlInput || undefined;
|
|
54
|
+
}
|
|
55
|
+
rl.close();
|
|
56
|
+
// Build config — only write user-relevant fields, omit frontmatter.allowed (tool-managed)
|
|
57
|
+
const config = {
|
|
58
|
+
...DEFAULT_CONFIG,
|
|
59
|
+
projectName,
|
|
60
|
+
};
|
|
61
|
+
if (jiraPrefix)
|
|
62
|
+
config.jiraPrefix = jiraPrefix;
|
|
63
|
+
if (jiraBaseUrl)
|
|
64
|
+
config.jiraBaseUrl = jiraBaseUrl;
|
|
65
|
+
await fs.writeJson(configPath, config, { spaces: 2 });
|
|
66
|
+
console.log(chalk.green('\n✔ Created .project-logbook config file.'));
|
|
67
|
+
// 2. Logbook directory
|
|
68
|
+
if (!(await fs.pathExists(logbookDir))) {
|
|
69
|
+
await fs.mkdirp(logbookDir);
|
|
70
|
+
console.log(chalk.green(`✔ Created ${DEFAULT_CONFIG.logbookDir}/ directory.`));
|
|
71
|
+
}
|
|
72
|
+
// 3. CONTRIBUTING.md (from template)
|
|
73
|
+
if (!(await fs.pathExists(contributingPath))) {
|
|
74
|
+
const templatePath = join(getTemplatesDir(), 'CONTRIBUTING.md');
|
|
75
|
+
if (await fs.pathExists(templatePath)) {
|
|
76
|
+
await fs.copyFile(templatePath, contributingPath);
|
|
77
|
+
console.log(chalk.green('✔ Created CONTRIBUTING.md with agentic workflow protocol.'));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// 4. Hint
|
|
81
|
+
console.log(chalk.dim('\n💡 Tip: Edit .project-logbook to customize primaryColor, add more tags, or configure Jira settings.'));
|
|
82
|
+
console.log(chalk.dim(' Run `logbook new` to create your first entry.\n'));
|
|
83
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function lint(): Promise<void>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { getConfig } from '../lib/config.js';
|
|
5
|
+
import { runLinters } from '../lib/lint-runner.js';
|
|
6
|
+
import { getLogbookEntries } from '../utils/fs.js';
|
|
7
|
+
export async function lint() {
|
|
8
|
+
const config = getConfig();
|
|
9
|
+
const logbookDir = join(process.cwd(), config.logbookDir);
|
|
10
|
+
if (!(await fs.pathExists(logbookDir))) {
|
|
11
|
+
console.error(chalk.red(`Error: Logbook directory '${config.logbookDir}' not found.`));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
let overallSuccess = true;
|
|
15
|
+
// 1. Project-level checks
|
|
16
|
+
console.log(chalk.blue('Checking project integrity...'));
|
|
17
|
+
const projectSuccess = await runLinters({ config });
|
|
18
|
+
if (!projectSuccess)
|
|
19
|
+
overallSuccess = false;
|
|
20
|
+
// 2. Entry-level checks
|
|
21
|
+
const logbookEntries = await getLogbookEntries(logbookDir);
|
|
22
|
+
let passedCount = 0;
|
|
23
|
+
let skippedCount = 0;
|
|
24
|
+
for (const entry of logbookEntries) {
|
|
25
|
+
if (!entry.hasIndex) {
|
|
26
|
+
console.error(chalk.red(` [MISSING] ${entry.slug}/index.md`));
|
|
27
|
+
overallSuccess = false;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (isDraft(entry)) {
|
|
31
|
+
skippedCount++;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const entrySuccess = await runLinters({
|
|
35
|
+
config,
|
|
36
|
+
entryName: entry.slug,
|
|
37
|
+
entryPath: entry.path,
|
|
38
|
+
indexPath: entry.indexPath,
|
|
39
|
+
content: entry.content,
|
|
40
|
+
frontmatter: entry.data,
|
|
41
|
+
});
|
|
42
|
+
if (entrySuccess) {
|
|
43
|
+
passedCount++;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
overallSuccess = false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (overallSuccess) {
|
|
50
|
+
const skippedNote = skippedCount > 0
|
|
51
|
+
? chalk.gray(` (${skippedCount} DRAFT ${skippedCount === 1 ? 'entry' : 'entries'} skipped)`)
|
|
52
|
+
: '';
|
|
53
|
+
console.log(chalk.green(`\nAll ${passedCount} entries passed linting!`) + skippedNote);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
console.log(chalk.red('\nLinting failed with errors. If you are a LLM, try to fix the errors.'));
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Returns true if an entry is in DRAFT state (has unfilled template placeholders),
|
|
62
|
+
* meaning it should be exempt from linting.
|
|
63
|
+
*/
|
|
64
|
+
function isDraft(entry) {
|
|
65
|
+
const dataValues = Object.values(entry.data)
|
|
66
|
+
.filter((v) => typeof v === 'string')
|
|
67
|
+
.join('\n');
|
|
68
|
+
const frontmatterHasPlaceholders = dataValues.includes('[DATE_END]') ||
|
|
69
|
+
dataValues.includes('[DATE_START]') ||
|
|
70
|
+
dataValues.includes('[WRITE_SUMMARY_HERE]') ||
|
|
71
|
+
dataValues.includes('[PROMPTER]') ||
|
|
72
|
+
dataValues.includes('[HARNESS]') ||
|
|
73
|
+
dataValues.includes('[LLM]');
|
|
74
|
+
const contentWithoutCode = entry.content.replace(/`[^`]*`/g, '');
|
|
75
|
+
const bodyHasPlaceholders = contentWithoutCode.includes('TODO:') ||
|
|
76
|
+
entry.content.includes('Write a polished, highly readable "short story" of the change here.');
|
|
77
|
+
return frontmatterHasPlaceholders || bodyHasPlaceholders;
|
|
78
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { getConfig } from '../lib/config.js';
|
|
5
|
+
import { getLogbookEntries } from '../utils/fs.js';
|
|
6
|
+
import { stripAnsi } from '../utils/string.js';
|
|
7
|
+
import { parseTicketId } from '../utils/id.js';
|
|
8
|
+
import { getActiveEntry } from '../lib/session.js';
|
|
9
|
+
export async function list(options = {}) {
|
|
10
|
+
const config = getConfig();
|
|
11
|
+
const logbookDir = join(process.cwd(), config.logbookDir);
|
|
12
|
+
if (!(await fs.pathExists(logbookDir))) {
|
|
13
|
+
console.error(chalk.red(`Error: Logbook directory '${config.logbookDir}' not found. Run 'logbook init' first.`));
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
// Detect currently active entry (if any)
|
|
17
|
+
const active = await getActiveEntry();
|
|
18
|
+
const activeSlug = active?.slug;
|
|
19
|
+
const logbookEntries = await getLogbookEntries(logbookDir);
|
|
20
|
+
const rows = [];
|
|
21
|
+
for (const entry of logbookEntries) {
|
|
22
|
+
if (!entry.hasIndex) {
|
|
23
|
+
// Parse ticket ID and title from directory name slug
|
|
24
|
+
const parsed = parseTicketId(entry.slug);
|
|
25
|
+
let draftTicketId;
|
|
26
|
+
let draftTitle;
|
|
27
|
+
if (parsed) {
|
|
28
|
+
draftTicketId = `${parsed.prefix}-${parsed.number}`;
|
|
29
|
+
const titlePart = entry.slug.replace(/^[A-Za-z]+-\d+-/, '');
|
|
30
|
+
draftTitle = titlePart.replace(/-/g, ' ');
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
draftTicketId = entry.slug;
|
|
34
|
+
draftTitle = '';
|
|
35
|
+
}
|
|
36
|
+
rows.push({
|
|
37
|
+
ticketId: draftTicketId,
|
|
38
|
+
title: chalk.gray(draftTitle),
|
|
39
|
+
prompter: '',
|
|
40
|
+
status: chalk.yellow('DRAFT'),
|
|
41
|
+
});
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const { data, content } = entry;
|
|
45
|
+
const isActive = activeSlug === entry.slug;
|
|
46
|
+
// Check frontmatter field values for bracket-style unfilled placeholders
|
|
47
|
+
const dataValues = Object.values(data)
|
|
48
|
+
.filter((v) => typeof v === 'string')
|
|
49
|
+
.join('\n');
|
|
50
|
+
const frontmatterHasPlaceholders = dataValues.includes('[DATE_END]') ||
|
|
51
|
+
dataValues.includes('[DATE_START]') ||
|
|
52
|
+
dataValues.includes('[WRITE_SUMMARY_HERE]') ||
|
|
53
|
+
dataValues.includes('[PROMPTER]') ||
|
|
54
|
+
dataValues.includes('[HARNESS]') ||
|
|
55
|
+
dataValues.includes('[LLM]');
|
|
56
|
+
// Check body content for unfilled template hints (strip inline code spans to avoid false positives)
|
|
57
|
+
const contentWithoutCode = content.replace(/`[^`]*`/g, '');
|
|
58
|
+
const bodyHasPlaceholders = contentWithoutCode.includes('TODO:') ||
|
|
59
|
+
content.includes('Write a polished, highly readable "short story" of the change here.');
|
|
60
|
+
const hasPlaceholders = frontmatterHasPlaceholders || bodyHasPlaceholders;
|
|
61
|
+
const status = isActive ? chalk.cyan('● ACTIVE') : hasPlaceholders ? chalk.yellow('DRAFT') : chalk.green('DONE');
|
|
62
|
+
rows.push({
|
|
63
|
+
ticketId: typeof data.ticket === 'string' ? data.ticket : chalk.gray('-'),
|
|
64
|
+
title: typeof data.title === 'string' ? data.title : chalk.gray('(untitled)'),
|
|
65
|
+
prompter: typeof data.prompter === 'string' ? data.prompter : chalk.gray('-'),
|
|
66
|
+
status,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (rows.length === 0) {
|
|
70
|
+
console.log(chalk.yellow('No logbook entries found. Use `logbook new <id> <slug>` to create one.'));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// Sort by ticket ID descending
|
|
74
|
+
rows.sort((a, b) => {
|
|
75
|
+
const idA = parseTicketId(a.ticketId);
|
|
76
|
+
const idB = parseTicketId(b.ticketId);
|
|
77
|
+
if (idA && idB) {
|
|
78
|
+
if (idA.prefix !== idB.prefix) {
|
|
79
|
+
return idB.prefix.localeCompare(idA.prefix);
|
|
80
|
+
}
|
|
81
|
+
return idB.number - idA.number;
|
|
82
|
+
}
|
|
83
|
+
// Fallback to alphabetical if parsing fails
|
|
84
|
+
return b.ticketId.localeCompare(a.ticketId);
|
|
85
|
+
});
|
|
86
|
+
const totalEntries = rows.length;
|
|
87
|
+
let displayedRows = rows;
|
|
88
|
+
if (!options.all && rows.length > 20) {
|
|
89
|
+
displayedRows = rows.slice(0, 20);
|
|
90
|
+
}
|
|
91
|
+
// Calculate column widths
|
|
92
|
+
const colWidths = {
|
|
93
|
+
ticketId: Math.max(9, ...displayedRows.map((r) => stripAnsi(r.ticketId).length)),
|
|
94
|
+
title: Math.max(5, ...displayedRows.map((r) => stripAnsi(r.title).length)),
|
|
95
|
+
prompter: Math.max(8, ...displayedRows.map((r) => stripAnsi(r.prompter).length)),
|
|
96
|
+
status: Math.max(6, ...displayedRows.map((r) => stripAnsi(r.status).length)),
|
|
97
|
+
};
|
|
98
|
+
const pad = (s, len) => s + ' '.repeat(Math.max(0, len - stripAnsi(s).length));
|
|
99
|
+
const header = [
|
|
100
|
+
chalk.bold(pad('TICKET-ID', colWidths.ticketId)),
|
|
101
|
+
chalk.bold(pad('TITLE', colWidths.title)),
|
|
102
|
+
chalk.bold(pad('PROMPTER', colWidths.prompter)),
|
|
103
|
+
chalk.bold('STATUS'),
|
|
104
|
+
].join(' ');
|
|
105
|
+
const divider = [
|
|
106
|
+
'─'.repeat(colWidths.ticketId),
|
|
107
|
+
'─'.repeat(colWidths.title),
|
|
108
|
+
'─'.repeat(colWidths.prompter),
|
|
109
|
+
'─'.repeat(colWidths.status),
|
|
110
|
+
].join(' ');
|
|
111
|
+
console.log('');
|
|
112
|
+
console.log(header);
|
|
113
|
+
console.log(chalk.gray(divider));
|
|
114
|
+
for (const row of displayedRows) {
|
|
115
|
+
console.log([
|
|
116
|
+
pad(row.ticketId, colWidths.ticketId),
|
|
117
|
+
pad(row.title, colWidths.title),
|
|
118
|
+
pad(row.prompter, colWidths.prompter),
|
|
119
|
+
row.status,
|
|
120
|
+
].join(' '));
|
|
121
|
+
}
|
|
122
|
+
console.log('');
|
|
123
|
+
const countText = displayedRows.length === totalEntries
|
|
124
|
+
? `${totalEntries} ${totalEntries === 1 ? 'entry' : 'entries'}`
|
|
125
|
+
: `Showing latest ${displayedRows.length} of ${totalEntries} entries (use --all to show all)`;
|
|
126
|
+
console.log(chalk.gray(`${countText} in ${config.logbookDir}/`));
|
|
127
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { getConfig } from '../lib/config.js';
|
|
5
|
+
import { getActiveEntry } from '../lib/session.js';
|
|
6
|
+
export async function log(messages, options = {}) {
|
|
7
|
+
const cwd = process.cwd();
|
|
8
|
+
// 1. Require an active entry
|
|
9
|
+
const active = await getActiveEntry(options, cwd);
|
|
10
|
+
if (!active) {
|
|
11
|
+
console.error(chalk.red(`Error: No active logbook entry resolved.`));
|
|
12
|
+
console.error(chalk.red(`Activate an entry via 'logbook start <id>', checkout a feature branch, or pass --id.`));
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
const config = getConfig();
|
|
16
|
+
const logPath = join(cwd, config.logbookDir, active.slug, 'log.md');
|
|
17
|
+
if (!(await fs.pathExists(logPath))) {
|
|
18
|
+
console.error(chalk.red(`Error: log.md not found`));
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
// 2. Build new lines: "- <ISO timestamp>: <message>"
|
|
22
|
+
const timestamp = new Date().toISOString();
|
|
23
|
+
const newLines = messages.map((msg) => `- ${timestamp}: ${msg}`).join('\n');
|
|
24
|
+
// 3. Append to log.md
|
|
25
|
+
const existing = await fs.readFile(logPath, 'utf8');
|
|
26
|
+
const separator = existing.endsWith('\n') ? '' : '\n';
|
|
27
|
+
await fs.writeFile(logPath, existing + separator + newLines + '\n');
|
|
28
|
+
for (const msg of messages) {
|
|
29
|
+
console.log(chalk.green(`Logged to ${active.slug} (via ${active.source}): ${timestamp}: ${msg}`));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function createNewEntry(id?: string, slug?: string): Promise<void>;
|