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
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for a single tab
|
|
3
|
+
*/
|
|
4
|
+
export interface TabConfig {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
content: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Configuration for the timeline template
|
|
11
|
+
*/
|
|
12
|
+
export interface TimelineTemplateProps {
|
|
13
|
+
projectName: string;
|
|
14
|
+
version: string;
|
|
15
|
+
buildTime: string;
|
|
16
|
+
groups: TimelineGroup[];
|
|
17
|
+
readmeHtml: string;
|
|
18
|
+
aboutHtml: string;
|
|
19
|
+
jiraBaseUrl?: string;
|
|
20
|
+
jiraPrefix?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Configuration for a month group in the timeline
|
|
24
|
+
*/
|
|
25
|
+
export interface TimelineGroup {
|
|
26
|
+
month: string;
|
|
27
|
+
items: TimelineItem[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A timeline item is either a logbook entry card, a git commit, or a git tag.
|
|
31
|
+
*/
|
|
32
|
+
export type TimelineItem = TimelineEntry | TimelineCommitItem | TimelineTagItem;
|
|
33
|
+
/**
|
|
34
|
+
* Configuration for a timeline entry
|
|
35
|
+
*/
|
|
36
|
+
export interface TimelineEntry {
|
|
37
|
+
kind: 'entry';
|
|
38
|
+
slug: string;
|
|
39
|
+
dateStart: string;
|
|
40
|
+
dateEnd?: string;
|
|
41
|
+
displayDate: string;
|
|
42
|
+
sortTime: string;
|
|
43
|
+
harness?: string;
|
|
44
|
+
llm?: string;
|
|
45
|
+
prompter?: string;
|
|
46
|
+
ticket?: string;
|
|
47
|
+
title?: string;
|
|
48
|
+
summary: string;
|
|
49
|
+
tags?: string[];
|
|
50
|
+
workspaces?: string[];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A standalone git commit shown as a non-clickable info item in the timeline.
|
|
54
|
+
*/
|
|
55
|
+
export interface TimelineCommitItem {
|
|
56
|
+
kind: 'commit';
|
|
57
|
+
sha: string;
|
|
58
|
+
message: string;
|
|
59
|
+
timestamp: string;
|
|
60
|
+
relativeTime: string;
|
|
61
|
+
displayDate: string;
|
|
62
|
+
monthGroup: string;
|
|
63
|
+
sortTime: string;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A standalone git tag shown as a non-clickable info item in the timeline.
|
|
67
|
+
*/
|
|
68
|
+
export interface TimelineTagItem {
|
|
69
|
+
kind: 'tag';
|
|
70
|
+
name: string;
|
|
71
|
+
timestamp: string;
|
|
72
|
+
displayDate: string;
|
|
73
|
+
monthGroup: string;
|
|
74
|
+
sortTime: string;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A single git commit associated with a logbook entry.
|
|
78
|
+
*/
|
|
79
|
+
export interface GitCommit {
|
|
80
|
+
sha: string;
|
|
81
|
+
message: string;
|
|
82
|
+
timestamp: string;
|
|
83
|
+
relativeTime: string;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Configuration for the post template
|
|
87
|
+
*/
|
|
88
|
+
export interface PostTemplateProps {
|
|
89
|
+
title: string;
|
|
90
|
+
displayDate: string;
|
|
91
|
+
dateStart: string;
|
|
92
|
+
harness: string;
|
|
93
|
+
llm?: string;
|
|
94
|
+
prompter?: string;
|
|
95
|
+
ticket?: string;
|
|
96
|
+
summary?: string;
|
|
97
|
+
content: string;
|
|
98
|
+
ticketHtml: string;
|
|
99
|
+
logHtml: string;
|
|
100
|
+
commits?: GitCommit[];
|
|
101
|
+
version: string;
|
|
102
|
+
jiraUrl?: string;
|
|
103
|
+
prevEntry: EntryLink | null;
|
|
104
|
+
nextEntry: EntryLink | null;
|
|
105
|
+
slug?: string;
|
|
106
|
+
tags?: string[];
|
|
107
|
+
workspaces?: string[];
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Configuration for navigation between entries
|
|
111
|
+
*/
|
|
112
|
+
export interface EntryLink {
|
|
113
|
+
slug: string;
|
|
114
|
+
title: string;
|
|
115
|
+
ticket?: string;
|
|
116
|
+
tags?: string[];
|
|
117
|
+
[key: string]: unknown;
|
|
118
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { TabConfig, TimelineTemplateProps, PostTemplateProps } from './template-types.js';
|
|
2
|
+
export declare const tabsComponent: (tabs: TabConfig[], defaultActive?: number) => string;
|
|
3
|
+
export declare const layout: ({ title, header, content, projectName, basePath, description, bodySlug, buildMeta, }: {
|
|
4
|
+
title: string;
|
|
5
|
+
header: string;
|
|
6
|
+
content: string;
|
|
7
|
+
projectName: string;
|
|
8
|
+
basePath?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
bodySlug?: string;
|
|
11
|
+
buildMeta?: string;
|
|
12
|
+
}) => string;
|
|
13
|
+
export declare const timelineTemplate: (props: TimelineTemplateProps) => string;
|
|
14
|
+
export declare const postTemplate: (props: PostTemplateProps) => string;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { linkJiraIds } from './jira-helpers.js';
|
|
2
|
+
const html = (strings, ...values) => {
|
|
3
|
+
return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ''), '');
|
|
4
|
+
};
|
|
5
|
+
export const tabsComponent = (tabs, defaultActive = 0) => {
|
|
6
|
+
const tabsHtml = tabs
|
|
7
|
+
.map((tab, index) => `<div class="tab ${index === defaultActive ? 'active' : ''}" role="tab" aria-selected="${index === defaultActive}" aria-controls="${tab.id}" tabindex="${index === defaultActive ? '0' : '-1'}" onclick="showTab(event, '${tab.id}')">${tab.title}</div>`)
|
|
8
|
+
.join('');
|
|
9
|
+
const sectionsHtml = tabs
|
|
10
|
+
.map((tab, index) => `<div id="${tab.id}" class="content-section" role="tabpanel" style="${index === defaultActive ? '' : 'display:none;'}">${tab.content}</div>`)
|
|
11
|
+
.join('');
|
|
12
|
+
return html `<div class="tabs-wrapper">
|
|
13
|
+
<div class="tabs" id="tabs" role="tablist" aria-label="Navigation tabs">${tabsHtml}</div>
|
|
14
|
+
${sectionsHtml}
|
|
15
|
+
</div>`;
|
|
16
|
+
};
|
|
17
|
+
export const layout = ({ title, header, content, projectName, basePath = './', description, bodySlug, buildMeta, }) => html `<!DOCTYPE html>
|
|
18
|
+
<html lang="en">
|
|
19
|
+
<head>
|
|
20
|
+
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
21
|
+
<title>${title} | ${projectName}</title>
|
|
22
|
+
${description ? `<meta name="description" content="${description.replace(/"/g, '"').substring(0, 160)}">` : ''}
|
|
23
|
+
<link rel="icon" type="image/svg+xml" href="${basePath}favicon.svg">
|
|
24
|
+
<link rel="stylesheet" href="${basePath}style.css">
|
|
25
|
+
</head>
|
|
26
|
+
<body${bodySlug ? ` data-page-slug="${bodySlug}"` : ''}>
|
|
27
|
+
${header}<main>${content}</main>
|
|
28
|
+
<footer>${buildMeta ? `<p class="build-time">${buildMeta}</p>` : ''}</footer>
|
|
29
|
+
<script src="${basePath}logbook.js"></script>
|
|
30
|
+
</body></html>`;
|
|
31
|
+
const navLink = (entry, dir) => {
|
|
32
|
+
const label = dir === 'prev' ? '← Previous' : 'Next →';
|
|
33
|
+
const t = `${entry.ticket ? `${entry.ticket}: ` : ''}${entry.title}`;
|
|
34
|
+
return `<a href="../${entry.slug}/index.html" class="post-nav-link"><span class="post-nav-label">${label}</span><span class="post-nav-title">${t}</span></a>`;
|
|
35
|
+
};
|
|
36
|
+
const renderWorkspaces = (workspaces) => {
|
|
37
|
+
if (!workspaces || workspaces.length === 0)
|
|
38
|
+
return '';
|
|
39
|
+
return workspaces.map((ws) => `<span class="tag-badge tag-workspace">${ws}</span>`).join('');
|
|
40
|
+
};
|
|
41
|
+
const renderTagsAndWorkspaces = (tags, workspaces) => {
|
|
42
|
+
const tagsHtml = tags && tags.length > 0
|
|
43
|
+
? tags.map((tag) => `<span class="tag-badge tag-${tag.replace('#', '')}">${tag}</span>`).join('')
|
|
44
|
+
: '';
|
|
45
|
+
const wsHtml = renderWorkspaces(workspaces);
|
|
46
|
+
if (!tagsHtml && !wsHtml)
|
|
47
|
+
return '';
|
|
48
|
+
return html `<div class="tags-wrapper">${tagsHtml}${wsHtml}</div>`;
|
|
49
|
+
};
|
|
50
|
+
// Renders a compact list of git commits to embed in the Technical Log tab.
|
|
51
|
+
const renderCommits = (commits) => {
|
|
52
|
+
if (!commits || commits.length === 0)
|
|
53
|
+
return '';
|
|
54
|
+
const rows = commits
|
|
55
|
+
.map((c) => `<div class="commit-item"><span class="commit-sha">${c.sha}</span><span class="commit-message">${c.message}</span><span class="commit-time" title="${c.timestamp}">${c.relativeTime}</span></div>`)
|
|
56
|
+
.join('');
|
|
57
|
+
return `<div class="commit-list"><h3 class="commit-list-heading">Git Commits</h3>${rows}</div>`;
|
|
58
|
+
};
|
|
59
|
+
const renderTimelineEntryItem = (e) => html `<div class="timeline-item" data-entry-slug="${e.slug}">
|
|
60
|
+
<a class="timeline-card" href="./${e.slug}/index.html"
|
|
61
|
+
><div class="item-content">
|
|
62
|
+
<div class="item-meta">
|
|
63
|
+
<span data-date="${e.dateStart}">${e.displayDate}</span> • <span class="sort-time">${e.sortTime}</span> •
|
|
64
|
+
${e.llm ? ` ${e.llm} via ` : ''}${e.harness}${e.prompter ? ` / ${e.prompter}` : ''}
|
|
65
|
+
</div>
|
|
66
|
+
<h3 class="item-title">
|
|
67
|
+
<span class="new-badge" style="display:none;">NEW</span>${e.ticket ? `${e.ticket}: ` : ''}${e.title}
|
|
68
|
+
</h3>
|
|
69
|
+
<div class="item-summary">${e.summary}</div>
|
|
70
|
+
${renderTagsAndWorkspaces(e.tags, e.workspaces)}
|
|
71
|
+
</div>
|
|
72
|
+
<span class="item-arrow">›</span></a
|
|
73
|
+
>
|
|
74
|
+
</div>`;
|
|
75
|
+
const renderTimelineCommitItem = (c, jiraBaseUrl, jiraPrefix) => {
|
|
76
|
+
const message = jiraBaseUrl && jiraPrefix ? linkJiraIds(c.message, jiraBaseUrl, jiraPrefix) : c.message;
|
|
77
|
+
return `<div class="timeline-item timeline-item--commit"><div class="commit-chip"><span class="commit-sha">${c.sha}</span><span class="commit-message">${message}</span><span class="commit-time" title="${c.timestamp}">${c.relativeTime}</span></div></div>`;
|
|
78
|
+
};
|
|
79
|
+
const renderTimelineTagItem = (t) => {
|
|
80
|
+
const formatted = new Date(t.timestamp)
|
|
81
|
+
.toLocaleString('de-DE', {
|
|
82
|
+
year: 'numeric',
|
|
83
|
+
month: '2-digit',
|
|
84
|
+
day: '2-digit',
|
|
85
|
+
hour: '2-digit',
|
|
86
|
+
minute: '2-digit',
|
|
87
|
+
second: '2-digit',
|
|
88
|
+
hour12: false,
|
|
89
|
+
})
|
|
90
|
+
.replace(',', '');
|
|
91
|
+
return `<div class="timeline-item timeline-item--tag"><div class="tag-chip"><span class="tag-chip-icon">🏷</span><span class="tag-chip-name">${t.name}</span><span class="tag-chip-date" title="${t.timestamp}">${formatted}</span></div></div>`;
|
|
92
|
+
};
|
|
93
|
+
export const timelineTemplate = (props) => {
|
|
94
|
+
const { projectName, groups, readmeHtml, aboutHtml, jiraBaseUrl, jiraPrefix } = props;
|
|
95
|
+
const timelineTab = html `<div class="timeline">
|
|
96
|
+
${groups
|
|
97
|
+
.map((g) => html `<section class="month-group">
|
|
98
|
+
<h2>${g.month}</h2>
|
|
99
|
+
${g.items
|
|
100
|
+
.map((item) => {
|
|
101
|
+
if (item.kind === 'entry')
|
|
102
|
+
return renderTimelineEntryItem(item);
|
|
103
|
+
if (item.kind === 'tag')
|
|
104
|
+
return renderTimelineTagItem(item);
|
|
105
|
+
return renderTimelineCommitItem(item, jiraBaseUrl, jiraPrefix);
|
|
106
|
+
})
|
|
107
|
+
.join('')}
|
|
108
|
+
</section>`)
|
|
109
|
+
.join('')}
|
|
110
|
+
</div>`;
|
|
111
|
+
return html `<header>
|
|
112
|
+
<h1>${projectName}</h1>
|
|
113
|
+
<p class="tagline">A brief summary of the recent changes to the project.</p>
|
|
114
|
+
</header>
|
|
115
|
+
${tabsComponent([
|
|
116
|
+
{ id: 'timeline', title: 'Timeline', content: timelineTab },
|
|
117
|
+
{ id: 'readme', title: 'Project Readme', content: readmeHtml || '<p>No README available.</p>' },
|
|
118
|
+
{ id: 'about', title: 'About this Logbook', content: aboutHtml || '<p>No information available.</p>' },
|
|
119
|
+
], 0)}`;
|
|
120
|
+
};
|
|
121
|
+
export const postTemplate = (props) => {
|
|
122
|
+
const { title, displayDate, dateStart, harness, llm, prompter, content, ticketHtml, logHtml, commits, version, jiraUrl, prevEntry, nextEntry, tags, workspaces } = props; // prettier-ignore
|
|
123
|
+
const jiraBtn = jiraUrl
|
|
124
|
+
? `<a href="${jiraUrl}" class="jira-link-btn" target="_blank" rel="noopener noreferrer">View in Jira ↗</a>`
|
|
125
|
+
: '';
|
|
126
|
+
const tabBtn = (id, label, active = false) => `<div class="tab${active ? ' active' : ''}" role="tab" aria-selected="${active}" aria-controls="${id}" tabindex="${active ? '0' : '-1'}" onclick="showTab(event, '${id}')">${label}</div>`;
|
|
127
|
+
return html `<header>
|
|
128
|
+
<h1>${title}</h1>
|
|
129
|
+
<div class="tagline">
|
|
130
|
+
<span data-date="${dateStart}">${displayDate}</span> •
|
|
131
|
+
${llm ? `${llm} via ` : ''}${harness}${prompter ? ` / ${prompter}` : ''} • v${version}
|
|
132
|
+
</div>
|
|
133
|
+
</header>
|
|
134
|
+
<article class="tabs-wrapper">
|
|
135
|
+
<div class="action-bar">
|
|
136
|
+
<a href="../index.html" class="back-link">← Back to Timeline</a>
|
|
137
|
+
<div class="action-tabs-wrapper">
|
|
138
|
+
<div class="tabs" role="tablist" aria-label="Entry sections">
|
|
139
|
+
${tabBtn('story', 'Story', true)}${tabBtn('spec', 'Original Spec')}${tabBtn('log', 'Technical Log')}
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
${jiraBtn ? `${jiraBtn}` : ''}
|
|
143
|
+
</div>
|
|
144
|
+
<div class="content-sections">
|
|
145
|
+
<div id="story" class="content-section" role="tabpanel">
|
|
146
|
+
${renderTagsAndWorkspaces(tags, workspaces)}${content}
|
|
147
|
+
</div>
|
|
148
|
+
<div id="spec" class="content-section" role="tabpanel" style="display:none;">${ticketHtml}</div>
|
|
149
|
+
<div id="log" class="content-section" role="tabpanel" style="display:none;">
|
|
150
|
+
${logHtml}${renderCommits(commits)}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
<nav class="post-nav">
|
|
154
|
+
<div class="post-nav-prev">${prevEntry ? navLink(prevEntry, 'prev') : '<span></span>'}</div>
|
|
155
|
+
<div class="post-nav-next">${nextEntry ? navLink(nextEntry, 'next') : '<span></span>'}</div>
|
|
156
|
+
</nav>
|
|
157
|
+
</article>`;
|
|
158
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// These fields are defined by the tool schema — not user-configurable.
|
|
2
|
+
const ALLOWED_FRONTMATTER_FIELDS = [
|
|
3
|
+
'ticket',
|
|
4
|
+
'title',
|
|
5
|
+
'prompter',
|
|
6
|
+
'harness',
|
|
7
|
+
'llm',
|
|
8
|
+
'summary',
|
|
9
|
+
'tags',
|
|
10
|
+
'dateStart',
|
|
11
|
+
'dateEnd',
|
|
12
|
+
'workspaces',
|
|
13
|
+
'author',
|
|
14
|
+
];
|
|
15
|
+
const linter = {
|
|
16
|
+
name: 'frontmatter',
|
|
17
|
+
description: 'Validates frontmatter fields and tags',
|
|
18
|
+
async check(context) {
|
|
19
|
+
if (!context.entryName || !context.frontmatter)
|
|
20
|
+
return [];
|
|
21
|
+
const { frontmatter, config } = context;
|
|
22
|
+
const issues = [];
|
|
23
|
+
// 1. Mandatory Fields
|
|
24
|
+
const requiredFields = ['ticket', 'title', 'prompter', 'harness', 'llm', 'summary', 'tags', 'dateStart', 'dateEnd'];
|
|
25
|
+
for (const field of requiredFields) {
|
|
26
|
+
if (!frontmatter[field]) {
|
|
27
|
+
issues.push({
|
|
28
|
+
level: 'error',
|
|
29
|
+
category: 'frontmatter',
|
|
30
|
+
message: `Missing mandatory field: ${field}`,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// 2. Allowed Fields — enforced by tool schema, not user config
|
|
35
|
+
const actualFields = Object.keys(frontmatter);
|
|
36
|
+
for (const field of actualFields) {
|
|
37
|
+
if (!ALLOWED_FRONTMATTER_FIELDS.includes(field)) {
|
|
38
|
+
issues.push({
|
|
39
|
+
level: 'warning',
|
|
40
|
+
category: 'frontmatter',
|
|
41
|
+
message: `Unknown frontmatter field: '${field}'. Allowed: ${ALLOWED_FRONTMATTER_FIELDS.join(', ')}`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// 3. Tags validation
|
|
46
|
+
const allowedTags = config.tags?.allowed || [];
|
|
47
|
+
if (!frontmatter.tags || !Array.isArray(frontmatter.tags) || frontmatter.tags.length === 0) {
|
|
48
|
+
issues.push({
|
|
49
|
+
level: 'error',
|
|
50
|
+
category: 'frontmatter',
|
|
51
|
+
message: 'Missing mandatory field: tags (at least one tag required)',
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
else if (allowedTags.length > 0) {
|
|
55
|
+
for (const tag of frontmatter.tags) {
|
|
56
|
+
if (!allowedTags.includes(tag)) {
|
|
57
|
+
issues.push({
|
|
58
|
+
level: 'error',
|
|
59
|
+
category: 'tag',
|
|
60
|
+
message: `Invalid tag "${tag}". Allowed tags: "${allowedTags.join('", "')}". Use double quotes to format tags in markdown.`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return issues;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
export default linter;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const linters: import("../lib/lint-types.js").Linter[];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import frontmatter from './frontmatter.js';
|
|
2
|
+
import jiraPrefix from './jira-prefix.js';
|
|
3
|
+
import links from './links.js';
|
|
4
|
+
import lockfile from './lockfile.js';
|
|
5
|
+
import placeholders from './placeholders.js';
|
|
6
|
+
import projectIntegrity from './project-integrity.js';
|
|
7
|
+
import readability from './readability.js';
|
|
8
|
+
import workspaces from './workspaces.js';
|
|
9
|
+
export const linters = [
|
|
10
|
+
frontmatter,
|
|
11
|
+
jiraPrefix,
|
|
12
|
+
links,
|
|
13
|
+
lockfile,
|
|
14
|
+
placeholders,
|
|
15
|
+
projectIntegrity,
|
|
16
|
+
readability,
|
|
17
|
+
workspaces,
|
|
18
|
+
];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const linter = {
|
|
2
|
+
name: 'jira-prefix',
|
|
3
|
+
description: 'Validates entry names and ticket IDs against jiraPrefix config',
|
|
4
|
+
async check(context) {
|
|
5
|
+
if (!context.entryName || !context.frontmatter || !context.config.jiraPrefix)
|
|
6
|
+
return [];
|
|
7
|
+
const { entryName, frontmatter, config } = context;
|
|
8
|
+
const issues = [];
|
|
9
|
+
const jiraPrefix = config.jiraPrefix;
|
|
10
|
+
if (!jiraPrefix)
|
|
11
|
+
return [];
|
|
12
|
+
const jiraPrefixPattern = new RegExp(`^${jiraPrefix}-\\d+-`, 'i');
|
|
13
|
+
if (!jiraPrefixPattern.test(entryName)) {
|
|
14
|
+
issues.push({
|
|
15
|
+
level: 'error',
|
|
16
|
+
category: 'jira_prefix',
|
|
17
|
+
message: `Entry folder '${entryName}' does not match configured prefix '${jiraPrefix}'. Expected format: ${jiraPrefix}-<ID>-<slug>`,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
const ticketMatch = frontmatter.ticket;
|
|
21
|
+
if (typeof ticketMatch === 'string') {
|
|
22
|
+
const ticketPrefix = ticketMatch.split('-')[0];
|
|
23
|
+
if (ticketPrefix.toUpperCase() !== jiraPrefix.toUpperCase()) {
|
|
24
|
+
issues.push({
|
|
25
|
+
level: 'error',
|
|
26
|
+
category: 'jira_prefix',
|
|
27
|
+
message: `Entry '${entryName}' has ticket '${frontmatter.ticket}' which doesn't match configured prefix '${jiraPrefix}'.`,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return issues;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
export default linter;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const linter = {
|
|
4
|
+
name: 'links',
|
|
5
|
+
description: 'Validates relative links within entries',
|
|
6
|
+
async check(context) {
|
|
7
|
+
if (!context.entryName || !context.content || !context.entryPath)
|
|
8
|
+
return [];
|
|
9
|
+
const issues = [];
|
|
10
|
+
const linkRegex = /\[.*?\]\((.*?)\)/g;
|
|
11
|
+
let match;
|
|
12
|
+
while ((match = linkRegex.exec(context.content)) !== null) {
|
|
13
|
+
const link = match[1];
|
|
14
|
+
if (link.startsWith('http') || link.startsWith('#'))
|
|
15
|
+
continue;
|
|
16
|
+
const linkedPath = join(context.entryPath, link);
|
|
17
|
+
if (!(await fs.pathExists(linkedPath))) {
|
|
18
|
+
issues.push({
|
|
19
|
+
level: 'error',
|
|
20
|
+
category: 'link',
|
|
21
|
+
message: `Cannot resolve: ${link}`,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return issues;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
export default linter;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { getActiveEntry } from '../lib/session.js';
|
|
2
|
+
const linter = {
|
|
3
|
+
name: 'lockfile',
|
|
4
|
+
description: 'Checks if a logbook entry is still active',
|
|
5
|
+
async check(context) {
|
|
6
|
+
if (context.entryName)
|
|
7
|
+
return [];
|
|
8
|
+
const issues = [];
|
|
9
|
+
const active = await getActiveEntry();
|
|
10
|
+
if (active && active.source === 'lockfile') {
|
|
11
|
+
issues.push({
|
|
12
|
+
level: 'warning',
|
|
13
|
+
category: 'active',
|
|
14
|
+
message: `Entry '${active.slug}' is still active via lockfile. Run 'logbook release' after the current ticket is approved by prompter.`,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return issues;
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
export default linter;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const linter = {
|
|
4
|
+
name: 'placeholders',
|
|
5
|
+
description: 'Checks for unremoved template placeholders',
|
|
6
|
+
async check(context) {
|
|
7
|
+
if (!context.entryName || !context.content || !context.entryPath)
|
|
8
|
+
return [];
|
|
9
|
+
const issues = [];
|
|
10
|
+
// Read the raw file to extract the frontmatter block. context.content is body-only
|
|
11
|
+
// (gray-matter strips the frontmatter), so we cannot check it for frontmatter placeholders.
|
|
12
|
+
// Fall back to context.content if indexPath is unavailable (e.g. in unit tests).
|
|
13
|
+
let rawFile = context.content;
|
|
14
|
+
if (context.indexPath && (await fs.pathExists(context.indexPath))) {
|
|
15
|
+
rawFile = await fs.readFile(context.indexPath, 'utf8');
|
|
16
|
+
}
|
|
17
|
+
// Scan only the frontmatter block for bracket-style placeholders to avoid false
|
|
18
|
+
// positives when placeholder names are mentioned in the narrative body.
|
|
19
|
+
const frontmatterMatch = rawFile.match(/^---\n[\s\S]*?\n---/);
|
|
20
|
+
const frontmatterBlock = frontmatterMatch ? frontmatterMatch[0] : rawFile;
|
|
21
|
+
const frontmatterPlaceholders = [
|
|
22
|
+
'[PROMPTER]',
|
|
23
|
+
'[HARNESS]',
|
|
24
|
+
'[LLM]',
|
|
25
|
+
'[WRITE_SUMMARY_HERE]',
|
|
26
|
+
'[DATE_START]',
|
|
27
|
+
// '[DATE_END]' is automatically set by `logbook release`, so it is not
|
|
28
|
+
// checked here. It is expected to be present in draft tickets and will be
|
|
29
|
+
// replaced with the actual release timestamp when the ticket is finalized.
|
|
30
|
+
];
|
|
31
|
+
for (const placeholder of frontmatterPlaceholders) {
|
|
32
|
+
if (frontmatterBlock.includes(placeholder)) {
|
|
33
|
+
issues.push({
|
|
34
|
+
level: 'error',
|
|
35
|
+
category: 'placeholder',
|
|
36
|
+
message: `Unremoved placeholder in index.md: ${placeholder}`,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Check boilerplate body text against the full content (body only is fine here).
|
|
41
|
+
if (context.content.includes('TODO: ')) {
|
|
42
|
+
issues.push({
|
|
43
|
+
level: 'error',
|
|
44
|
+
category: 'placeholder',
|
|
45
|
+
message: 'Found "TODO: "\'s in index.md: Write a polished, highly readable "short story" of the change here. Use the hints provided inside the file.',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const ticketPath = join(context.entryPath, 'ticket.md');
|
|
49
|
+
if (await fs.pathExists(ticketPath)) {
|
|
50
|
+
const ticketContent = await fs.readFile(ticketPath, 'utf8');
|
|
51
|
+
if (ticketContent.includes('(Paste Jira/Linear issue description here)')) {
|
|
52
|
+
issues.push({
|
|
53
|
+
level: 'error',
|
|
54
|
+
category: 'placeholder',
|
|
55
|
+
message: 'Unremoved placeholder in ticket.md: (Paste Jira/Linear issue description here)',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return issues;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
export default linter;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const linter = {
|
|
2
|
+
name: 'project-integrity',
|
|
3
|
+
description: 'Checks if project files are missing or outdated',
|
|
4
|
+
async check(context) {
|
|
5
|
+
if (context.entryName)
|
|
6
|
+
return [];
|
|
7
|
+
const issues = [];
|
|
8
|
+
const { getProjectStatus } = await import('../lib/migrations.js');
|
|
9
|
+
const projectStatus = await getProjectStatus();
|
|
10
|
+
for (const file of projectStatus) {
|
|
11
|
+
if (file.status === 'OUTDATED') {
|
|
12
|
+
issues.push({
|
|
13
|
+
level: 'warning',
|
|
14
|
+
category: 'outdated',
|
|
15
|
+
message: `${file.name} is different from the latest template. Run 'logbook upgrade' to sync.`,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
else if (file.status === 'MISSING') {
|
|
19
|
+
issues.push({
|
|
20
|
+
level: 'error',
|
|
21
|
+
category: 'missing',
|
|
22
|
+
message: `${file.name} is missing. Run 'logbook upgrade' to create it.`,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return issues;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
export default linter;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const linter = {
|
|
2
|
+
name: 'readability',
|
|
3
|
+
description: 'Checks for readability, headings usage, and length',
|
|
4
|
+
async check(context) {
|
|
5
|
+
if (!context.entryName || !context.content)
|
|
6
|
+
return [];
|
|
7
|
+
const issues = [];
|
|
8
|
+
const contentParts = context.content.split('---');
|
|
9
|
+
// Content is after the second '---' if frontmatter exists
|
|
10
|
+
const contentWithoutFrontmatter = (contentParts.length >= 3 ? contentParts.slice(2).join('---') : context.content).trim();
|
|
11
|
+
if (contentWithoutFrontmatter.length === 0)
|
|
12
|
+
return [];
|
|
13
|
+
// 1. Heading Check
|
|
14
|
+
const h2Match = contentWithoutFrontmatter.match(/^##\s+.+$/gm);
|
|
15
|
+
if (!h2Match || h2Match.length < 1) {
|
|
16
|
+
issues.push({
|
|
17
|
+
level: 'warning',
|
|
18
|
+
category: 'readability',
|
|
19
|
+
message: 'Narrative should use at least one H2 heading (##) to group content.',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
// 2. Length Check
|
|
23
|
+
const words = contentWithoutFrontmatter.split(/\s+/).filter((w) => w.length > 0).length;
|
|
24
|
+
if (words < 50) {
|
|
25
|
+
issues.push({
|
|
26
|
+
level: 'warning',
|
|
27
|
+
category: 'readability',
|
|
28
|
+
message: `Narrative seems too short (${words} words). Aim for a professional "short story".`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
else if (words > 1000) {
|
|
32
|
+
issues.push({
|
|
33
|
+
level: 'warning',
|
|
34
|
+
category: 'readability',
|
|
35
|
+
message: `Narrative is quite long (${words} words). Consider being more concise.`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return issues;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
export default linter;
|