project-logbook 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/commands/build.js +32 -75
  2. package/dist/commands/init.js +11 -11
  3. package/dist/commands/lint.js +10 -11
  4. package/dist/commands/list.js +18 -18
  5. package/dist/commands/log.js +5 -5
  6. package/dist/commands/new.js +19 -20
  7. package/dist/commands/preview.js +9 -8
  8. package/dist/commands/release.js +6 -6
  9. package/dist/commands/start.js +26 -21
  10. package/dist/commands/status.d.ts +1 -0
  11. package/dist/commands/status.js +122 -0
  12. package/dist/commands/steer.js +9 -9
  13. package/dist/commands/upgrade.js +5 -5
  14. package/dist/index.js +9 -8
  15. package/dist/lib/build-helpers.js +28 -50
  16. package/dist/lib/build-steps.d.ts +20 -0
  17. package/dist/lib/build-steps.js +57 -0
  18. package/dist/lib/config.d.ts +16 -0
  19. package/dist/lib/config.js +20 -0
  20. package/dist/lib/entry-id.d.ts +22 -0
  21. package/dist/lib/entry-id.js +26 -0
  22. package/dist/lib/entry-paths.d.ts +23 -0
  23. package/dist/lib/entry-paths.js +55 -0
  24. package/dist/lib/git-helpers.d.ts +11 -1
  25. package/dist/lib/git-helpers.js +28 -26
  26. package/dist/lib/hast-helpers.d.ts +10 -0
  27. package/dist/lib/hast-helpers.js +22 -0
  28. package/dist/lib/html-attributes.d.ts +17 -0
  29. package/dist/lib/html-attributes.js +17 -0
  30. package/dist/lib/html-escape.d.ts +16 -0
  31. package/dist/lib/html-escape.js +38 -0
  32. package/dist/lib/image-helpers.js +26 -33
  33. package/dist/lib/lint-runner.js +5 -5
  34. package/dist/lib/markdown-processors.d.ts +18 -0
  35. package/dist/lib/markdown-processors.js +42 -0
  36. package/dist/lib/package-version.d.ts +5 -0
  37. package/dist/lib/package-version.js +16 -0
  38. package/dist/lib/styles.js +5 -2
  39. package/dist/lib/template-helpers.d.ts +1 -0
  40. package/dist/lib/template-helpers.js +35 -24
  41. package/dist/lib/template-types.d.ts +0 -2
  42. package/dist/lib/templates.d.ts +8 -2
  43. package/dist/lib/templates.js +50 -46
  44. package/dist/lib/theme.d.ts +37 -0
  45. package/dist/lib/theme.js +50 -0
  46. package/dist/lib/url-helpers.d.ts +13 -0
  47. package/dist/lib/url-helpers.js +27 -0
  48. package/dist/linters/diff-to-narrative.d.ts +6 -0
  49. package/dist/linters/diff-to-narrative.js +114 -0
  50. package/dist/linters/index.js +2 -0
  51. package/dist/templates/CONTRIBUTING.md +12 -3
  52. package/dist/templates/index.md +6 -6
  53. package/dist/templates/logbook-client.js +42 -16
  54. package/dist/templates/styles.css +5 -0
  55. package/dist/utils/date.d.ts +23 -1
  56. package/dist/utils/date.js +56 -15
  57. package/dist/utils/frontmatter.d.ts +26 -0
  58. package/dist/utils/frontmatter.js +37 -0
  59. package/dist/utils/fs.d.ts +13 -0
  60. package/dist/utils/fs.js +23 -0
  61. package/package.json +2 -2
  62. package/src/templates/CONTRIBUTING.md +12 -3
  63. package/src/templates/index.md +6 -6
  64. package/src/templates/logbook-client.js +42 -16
  65. package/src/templates/styles.css +5 -0
@@ -60,7 +60,6 @@ export interface TimelineCommitItem {
60
60
  sha: string;
61
61
  message: string;
62
62
  timestamp: string;
63
- relativeTime: string;
64
63
  displayDate: string;
65
64
  monthGroup: string;
66
65
  sortTime: string;
@@ -83,7 +82,6 @@ export interface GitCommit {
83
82
  sha: string;
84
83
  message: string;
85
84
  timestamp: string;
86
- relativeTime: string;
87
85
  }
88
86
  /**
89
87
  * Configuration for the post template
@@ -10,5 +10,11 @@ export declare const layout: ({ title, header, content, projectName, basePath, d
10
10
  bodySlug?: string;
11
11
  buildMeta?: string;
12
12
  }) => string;
13
- export declare const timelineTemplate: (props: TimelineTemplateProps) => string;
14
- export declare const postTemplate: (props: PostTemplateProps) => string;
13
+ export declare const timelineTemplate: (props: TimelineTemplateProps) => {
14
+ header: string;
15
+ content: string;
16
+ };
17
+ export declare const postTemplate: (props: PostTemplateProps) => {
18
+ header: string;
19
+ content: string;
20
+ };
@@ -1,13 +1,12 @@
1
- import { navLink, renderTagsAndWorkspaces, renderCommits, renderTimelineEntryItem, renderTimelineCommitItem, renderTimelineTagItem, paginationLinks, } from './template-helpers.js';
2
- const html = (strings, ...values) => {
3
- return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ''), '');
4
- };
1
+ import { ATTR_PAGE_SLUG, ATTR_DATA_DATE } from './html-attributes.js';
2
+ import { escapeHtml, validateUrlScheme } from './html-escape.js';
3
+ import { html, navLink, renderTagsAndWorkspaces, renderCommits, renderTimelineEntryItem, renderTimelineCommitItem, renderTimelineTagItem, paginationLinks, } from './template-helpers.js';
5
4
  export const tabsComponent = (tabs, defaultActive = 0) => {
6
5
  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>`)
6
+ .map((tab, index) => `<div class="tab ${index === defaultActive ? 'active' : ''}" role="tab" aria-selected="${index === defaultActive}" aria-controls="${escapeHtml(tab.id)}" tabindex="${index === defaultActive ? '0' : '-1'}" onclick="showTab(event, '${escapeHtml(tab.id)}')">${escapeHtml(tab.title)}</div>`)
8
7
  .join('');
9
8
  const sectionsHtml = tabs
10
- .map((tab, index) => `<div id="${tab.id}" class="content-section" role="tabpanel" style="${index === defaultActive ? '' : 'display:none;'}">${tab.content}</div>`)
9
+ .map((tab, index) => `<div id="${escapeHtml(tab.id)}" class="content-section" role="tabpanel" style="${index === defaultActive ? '' : 'display:none;'}">${tab.content}</div>`)
11
10
  .join('');
12
11
  return html `<div class="tabs-wrapper">
13
12
  <div class="tabs" id="tabs" role="tablist" aria-label="Navigation tabs">${tabsHtml}</div>
@@ -18,12 +17,12 @@ export const layout = ({ title, header, content, projectName, basePath = './', d
18
17
  <html lang="en">
19
18
  <head>
20
19
  <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)}">` : ''}
20
+ <title>${escapeHtml(title)} | ${escapeHtml(projectName)}</title>
21
+ ${description ? `<meta name="description" content="${escapeHtml(description).replace(/"/g, '&quot;').substring(0, 160)}">` : ''}
23
22
  <link rel="icon" type="image/svg+xml" href="${basePath}favicon.svg">
24
23
  <link rel="stylesheet" href="${basePath}style.css">
25
24
  </head>
26
- <body${bodySlug ? ` data-page-slug="${bodySlug}"` : ''}>
25
+ <body${bodySlug ? ` ${ATTR_PAGE_SLUG}="${bodySlug}"` : ''}>
27
26
  ${header}<main>${content}</main>
28
27
  <footer>${buildMeta ? `<p class="build-time">${buildMeta}</p>` : ''}</footer>
29
28
  <script src="${basePath}logbook.js"></script>
@@ -47,51 +46,56 @@ export const timelineTemplate = (props) => {
47
46
  .join('')}
48
47
  ${paginationLinks(currentPage, totalPages)}
49
48
  </div>`;
50
- return html `<header>
51
- <h1><a href="./index.html">${projectName}</a></h1>
52
- <p class="tagline">A brief summary of the recent changes to the project.</p>
53
- </header>
54
- ${tabsComponent([
49
+ const header = html `<header>
50
+ <h1><a href="./index.html">${escapeHtml(projectName)}</a></h1>
51
+ <p class="tagline">A brief summary of the recent changes to the project.</p>
52
+ </header>`;
53
+ const content = tabsComponent([
55
54
  { id: 'timeline', title: 'Timeline', content: timelineTab },
56
55
  { id: 'readme', title: 'Project Readme', content: readmeHtml || '<p>No README available.</p>' },
57
56
  { id: 'about', title: 'About this Logbook', content: aboutHtml || '<p>No information available.</p>' },
58
- ], 0)}`;
57
+ ], 0);
58
+ return { header, content };
59
59
  };
60
60
  export const postTemplate = (props) => {
61
61
  const { title, displayDate, dateStart, harness, llm, prompter, content, ticketHtml, logHtml, commits, version, jiraUrl, prevEntry, nextEntry, tags, workspaces } = props; // prettier-ignore
62
- const jiraBtn = jiraUrl
63
- ? `<a href="${jiraUrl}" class="jira-link-btn" target="_blank" rel="noopener noreferrer">View in Jira ↗</a>`
62
+ // Validate jiraUrl scheme to prevent javascript: URLs
63
+ const validatedJiraUrl = jiraUrl ? validateUrlScheme(jiraUrl) : undefined;
64
+ const jiraBtn = validatedJiraUrl
65
+ ? `<a href="${escapeHtml(validatedJiraUrl)}" class="jira-link-btn" target="_blank" rel="noopener noreferrer">View in Jira ↗</a>`
64
66
  : '';
65
- 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>`;
66
- return html `<header>
67
- <h1>${title}</h1>
68
- <div class="tagline">
69
- <span data-date="${dateStart}">${displayDate}</span> •
70
- ${llm ? `${llm} via ` : ''}${harness}${prompter ? ` / ${prompter}` : ''} • v${version}
71
- </div>
72
- </header>
73
- <article class="tabs-wrapper">
74
- <div class="action-bar">
75
- <a href="../index.html" class="back-link">← Back to Timeline</a>
76
- <div class="action-tabs-wrapper">
77
- <div class="tabs" role="tablist" aria-label="Entry sections">
78
- ${tabBtn('story', 'Story', true)}${tabBtn('spec', 'Original Spec')}${tabBtn('log', 'Technical Log')}
79
- </div>
67
+ const tabBtn = (id, label, active = false) => `<div class="tab${active ? ' active' : ''}" role="tab" aria-selected="${active}" aria-controls="${escapeHtml(id)}" tabindex="${active ? '0' : '-1'}" onclick="showTab(event, '${escapeHtml(id)}')">${escapeHtml(label)}</div>`;
68
+ const header = html `<header>
69
+ <h1>${escapeHtml(title)}</h1>
70
+ <div class="tagline">
71
+ <span ${ATTR_DATA_DATE}="${dateStart}">${displayDate}</span> •
72
+ ${llm ? `${escapeHtml(llm)} via ` : ''}${escapeHtml(harness)}${prompter ? ` / ${escapeHtml(prompter)}` : ''} •
73
+ v${version}
74
+ </div>
75
+ </header>`;
76
+ const mainContent = html `<article class="tabs-wrapper">
77
+ <div class="action-bar">
78
+ <a href="../index.html" class="back-link">← Back to Timeline</a>
79
+ <div class="action-tabs-wrapper">
80
+ <div class="tabs" role="tablist" aria-label="Entry sections">
81
+ ${tabBtn('story', 'Story', true)}${tabBtn('spec', 'Original Spec')}${tabBtn('log', 'Technical Log')}
80
82
  </div>
81
- ${jiraBtn ? `${jiraBtn}` : ''}
82
83
  </div>
83
- <div class="content-sections">
84
- <div id="story" class="content-section" role="tabpanel">
85
- ${renderTagsAndWorkspaces(tags, workspaces)}${content}
86
- </div>
87
- <div id="spec" class="content-section" role="tabpanel" style="display:none;">${ticketHtml}</div>
88
- <div id="log" class="content-section" role="tabpanel" style="display:none;">
89
- ${logHtml}${renderCommits(commits)}
90
- </div>
84
+ ${jiraBtn ? `${jiraBtn}` : ''}
85
+ </div>
86
+ <div class="content-sections">
87
+ <div id="story" class="content-section" role="tabpanel">
88
+ ${renderTagsAndWorkspaces(tags, workspaces)}${content}
89
+ </div>
90
+ <div id="spec" class="content-section" role="tabpanel" style="display:none;">${ticketHtml}</div>
91
+ <div id="log" class="content-section" role="tabpanel" style="display:none;">
92
+ ${logHtml}${renderCommits(commits)}
91
93
  </div>
92
- <nav class="post-nav">
93
- <div class="post-nav-prev">${prevEntry ? navLink(prevEntry, 'prev') : '<span></span>'}</div>
94
- <div class="post-nav-next">${nextEntry ? navLink(nextEntry, 'next') : '<span></span>'}</div>
95
- </nav>
96
- </article>`;
94
+ </div>
95
+ <nav class="post-nav">
96
+ <div class="post-nav-prev">${prevEntry ? navLink(prevEntry, 'prev') : '<span></span>'}</div>
97
+ <div class="post-nav-next">${nextEntry ? navLink(nextEntry, 'next') : '<span></span>'}</div>
98
+ </nav>
99
+ </article>`;
100
+ return { header, content: mainContent };
97
101
  };
@@ -0,0 +1,37 @@
1
+ /** Success message styling (green) */
2
+ export declare const success: import("chalk").ChalkInstance;
3
+ /** Error message styling (red) */
4
+ export declare const error: import("chalk").ChalkInstance;
5
+ /** Warning message styling (yellow) */
6
+ export declare const warning: import("chalk").ChalkInstance;
7
+ /** Info/secondary message styling (blue) */
8
+ export declare const info: import("chalk").ChalkInstance;
9
+ /** Neutral/disabled styling (gray) */
10
+ export declare const neutral: import("chalk").ChalkInstance;
11
+ /** Highlight/emphasis styling (cyan) */
12
+ export declare const highlight: import("chalk").ChalkInstance;
13
+ /** Bold text */
14
+ export declare const bold: import("chalk").ChalkInstance;
15
+ /**
16
+ * Format a section header (e.g., "Checking project integrity...").
17
+ * Uses blue color for visual separation.
18
+ */
19
+ export declare function header(text: string): string;
20
+ /**
21
+ * Format an error message with context.
22
+ * @param prefix - The category/command name
23
+ * @param message - The error message
24
+ */
25
+ export declare function errorMessage(prefix: string, message: string): string;
26
+ /**
27
+ * Format a warning message with context.
28
+ * @param prefix - The category/command name
29
+ * @param message - The warning message
30
+ */
31
+ export declare function warningMessage(prefix: string, message: string): string;
32
+ /**
33
+ * Format a success message with context.
34
+ * @param prefix - The category/command name
35
+ * @param message - The success message
36
+ */
37
+ export declare function successMessage(prefix: string, message: string): string;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Centralized styling/theme for CLI output.
3
+ * Ensures consistent colors and visual hierarchy across all commands.
4
+ */
5
+ import chalk from 'chalk';
6
+ /** Success message styling (green) */
7
+ export const success = chalk.green;
8
+ /** Error message styling (red) */
9
+ export const error = chalk.red;
10
+ /** Warning message styling (yellow) */
11
+ export const warning = chalk.yellow;
12
+ /** Info/secondary message styling (blue) */
13
+ export const info = chalk.blue;
14
+ /** Neutral/disabled styling (gray) */
15
+ export const neutral = chalk.gray;
16
+ /** Highlight/emphasis styling (cyan) */
17
+ export const highlight = chalk.cyan;
18
+ /** Bold text */
19
+ export const bold = chalk.bold;
20
+ /**
21
+ * Format a section header (e.g., "Checking project integrity...").
22
+ * Uses blue color for visual separation.
23
+ */
24
+ export function header(text) {
25
+ return info(text);
26
+ }
27
+ /**
28
+ * Format an error message with context.
29
+ * @param prefix - The category/command name
30
+ * @param message - The error message
31
+ */
32
+ export function errorMessage(prefix, message) {
33
+ return `${error(prefix)}: ${message}`;
34
+ }
35
+ /**
36
+ * Format a warning message with context.
37
+ * @param prefix - The category/command name
38
+ * @param message - The warning message
39
+ */
40
+ export function warningMessage(prefix, message) {
41
+ return `${warning(prefix)}: ${message}`;
42
+ }
43
+ /**
44
+ * Format a success message with context.
45
+ * @param prefix - The category/command name
46
+ * @param message - The success message
47
+ */
48
+ export function successMessage(prefix, message) {
49
+ return `${success(prefix)}: ${message}`;
50
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Check if a URL/path is external (has an absolute URI scheme).
3
+ * @param url - The URL or path to check
4
+ * @returns true if the URL has an absolute URI scheme (http://, https://, ftp://, etc.)
5
+ */
6
+ export declare function isExternalUrl(url: string): boolean;
7
+ /**
8
+ * Check if a value is an external URL or an anchor link.
9
+ * Used to identify URLs that should not be processed (rewritten, collected, etc.)
10
+ * @param href - The href/src value to check
11
+ * @returns true if the value is external or an anchor
12
+ */
13
+ export declare function isExternalUrlOrAnchor(href: string): boolean;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Regex pattern to detect absolute URLs with URI schemes.
3
+ * Matches URIs like: http://, https://, ftp://, data:, etc.
4
+ * Pattern breakdown:
5
+ * ^[a-z] - Starts with a letter (lowercase)
6
+ * [a-z\d+\-.]*: - Followed by alphanumeric, +, -, or . characters, ending with :
7
+ * Example matches: http://, https://, ftp://, mailto:, data:
8
+ * Used to distinguish external URLs from relative paths.
9
+ */
10
+ const EXTERNAL_URL_SCHEME_REGEX = /^[a-z][a-z\d+\-.]*:/i;
11
+ /**
12
+ * Check if a URL/path is external (has an absolute URI scheme).
13
+ * @param url - The URL or path to check
14
+ * @returns true if the URL has an absolute URI scheme (http://, https://, ftp://, etc.)
15
+ */
16
+ export function isExternalUrl(url) {
17
+ return EXTERNAL_URL_SCHEME_REGEX.test(url);
18
+ }
19
+ /**
20
+ * Check if a value is an external URL or an anchor link.
21
+ * Used to identify URLs that should not be processed (rewritten, collected, etc.)
22
+ * @param href - The href/src value to check
23
+ * @returns true if the value is external or an anchor
24
+ */
25
+ export function isExternalUrlOrAnchor(href) {
26
+ return EXTERNAL_URL_SCHEME_REGEX.test(href) || href.startsWith('#');
27
+ }
@@ -0,0 +1,6 @@
1
+ import { Linter } from '../lib/lint-types.js';
2
+ export declare function stripFrontmatter(content: string): string;
3
+ export declare function stripMarkdown(text: string): string;
4
+ export declare function getWordCount(content: string): number;
5
+ declare const linter: Linter;
6
+ export default linter;
@@ -0,0 +1,114 @@
1
+ import { getActiveEntry } from '../lib/session.js';
2
+ import { getChangedLOC } from '../lib/git-helpers.js';
3
+ import fs from 'fs-extra';
4
+ import { join } from 'node:path';
5
+ // Thresholds & Limits Configuration
6
+ const TRIVIAL_LOC_MAX = 20;
7
+ const STANDARD_LOC_MAX = 150;
8
+ const TRIVIAL_WORDS = { min: 30, max: 150 };
9
+ const STANDARD_WORDS = { min: 50, max: 300 };
10
+ const LARGE_WORDS = { min: 100, max: 400 };
11
+ export function stripFrontmatter(content) {
12
+ const lines = content.split(/\r?\n/);
13
+ if (lines[0]?.trim() === '---') {
14
+ const endIdx = lines.indexOf('---', 1);
15
+ if (endIdx !== -1) {
16
+ return lines.slice(endIdx + 1).join('\n');
17
+ }
18
+ }
19
+ return content;
20
+ }
21
+ export function stripMarkdown(text) {
22
+ return text
23
+ .replace(/<[^>]*>/g, '') // HTML tags
24
+ .replace(/!\[([^\]]*)]\([^)]*\)/g, '$1') // images
25
+ .replace(/\[([^\]]*)]\([^)]*\)/g, '$1') // links
26
+ .replace(/```[\s\S]*?```/g, '') // code blocks
27
+ .replace(/`([^`]+)`/g, '$1') // inline code
28
+ .replace(/(\*\*|__)(.*?)\1/g, '$2') // bold
29
+ .replace(/(\*|_)(.*?)\1/g, '$2') // italic
30
+ .replace(/^\s*>\s+/gm, '') // blockquotes
31
+ .replace(/^\s*#+\s+/gm, '') // headings
32
+ .replace(/^\s*[-*+]\s+/gm, '') // list bullets
33
+ .replace(/^\s*\d+\.\s+/gm, ''); // ordered lists
34
+ }
35
+ export function getWordCount(content) {
36
+ const clean = stripMarkdown(stripFrontmatter(content));
37
+ return clean.split(/\s+/).filter((w) => w.trim().length > 0).length;
38
+ }
39
+ const linter = {
40
+ name: 'diff-to-narrative',
41
+ description: 'Checks word count of active index.md against total LOC changed',
42
+ async check(context) {
43
+ // Only target project-level checks
44
+ if (context.entryName)
45
+ return [];
46
+ const active = await getActiveEntry();
47
+ if (!active || active.source !== 'lockfile') {
48
+ return [];
49
+ }
50
+ const logbookDir = context.config.logbookDir;
51
+ const { total: totalLOC } = await getChangedLOC(logbookDir);
52
+ // If there are no active changes and the tree is completely clean, skip gracefully
53
+ if (totalLOC === 0) {
54
+ return [];
55
+ }
56
+ const indexPath = join(process.cwd(), logbookDir, active.slug, 'index.md');
57
+ if (!(await fs.pathExists(indexPath))) {
58
+ return [];
59
+ }
60
+ const content = await fs.readFile(indexPath, 'utf-8');
61
+ const wordCount = getWordCount(content);
62
+ const issues = [];
63
+ if (totalLOC < TRIVIAL_LOC_MAX) {
64
+ if (wordCount < TRIVIAL_WORDS.min) {
65
+ issues.push({
66
+ level: 'warning',
67
+ category: 'story-ratio',
68
+ message: `Code change is trivial (${totalLOC} LOC), but index.md has only ${wordCount} words (minimum is ${TRIVIAL_WORDS.min}). Please expand the narrative.`,
69
+ });
70
+ }
71
+ else if (wordCount > TRIVIAL_WORDS.max) {
72
+ issues.push({
73
+ level: 'warning',
74
+ category: 'story-ratio',
75
+ message: `Warning: Code change is trivial, but index.md exceeds ${TRIVIAL_WORDS.max} words. Please summarize.`,
76
+ });
77
+ }
78
+ }
79
+ else if (totalLOC <= STANDARD_LOC_MAX) {
80
+ if (wordCount < STANDARD_WORDS.min) {
81
+ issues.push({
82
+ level: 'warning',
83
+ category: 'story-ratio',
84
+ message: `Code change is standard (${totalLOC} LOC), but index.md has only ${wordCount} words (minimum is ${STANDARD_WORDS.min}). Please expand the narrative.`,
85
+ });
86
+ }
87
+ else if (wordCount > STANDARD_WORDS.max) {
88
+ issues.push({
89
+ level: 'warning',
90
+ category: 'story-ratio',
91
+ message: `Warning: Code change is standard (${totalLOC} LOC), but index.md exceeds ${STANDARD_WORDS.max} words. Please summarize.`,
92
+ });
93
+ }
94
+ }
95
+ else {
96
+ if (wordCount < LARGE_WORDS.min) {
97
+ issues.push({
98
+ level: 'warning',
99
+ category: 'story-ratio',
100
+ message: `Code change is large (${totalLOC} LOC), but index.md has only ${wordCount} words (minimum is ${LARGE_WORDS.min}). Please expand the narrative.`,
101
+ });
102
+ }
103
+ else if (wordCount > LARGE_WORDS.max) {
104
+ issues.push({
105
+ level: 'warning',
106
+ category: 'story-ratio',
107
+ message: `Warning: Story exceeds ${LARGE_WORDS.max} words. Consider summarizing and linking to a Wiki/ADR.`,
108
+ });
109
+ }
110
+ }
111
+ return issues;
112
+ },
113
+ };
114
+ export default linter;
@@ -1,3 +1,4 @@
1
+ import diffToNarrative from './diff-to-narrative.js';
1
2
  import frontmatter from './frontmatter.js';
2
3
  import jiraPrefix from './jira-prefix.js';
3
4
  import links from './links.js';
@@ -7,6 +8,7 @@ import projectIntegrity from './project-integrity.js';
7
8
  import readability from './readability.js';
8
9
  import workspaces from './workspaces.js';
9
10
  export const linters = [
11
+ diffToNarrative,
10
12
  frontmatter,
11
13
  jiraPrefix,
12
14
  links,
@@ -47,12 +47,21 @@ Only update the currently active logbook entry. Do not edit other existing entri
47
47
  When you finish writing `index.md`, **remove the boilerplate link line** that the template inserts:
48
48
 
49
49
  ### 3. Quality Assurance
50
- Before submitting your work, you MUST release the active entry and run the linter:
50
+ Before finalizing your work, you should run the linter to verify there are no errors:
51
51
  ```bash
52
- logbook release
53
52
  logbook lint
54
53
  ```
55
- `logbook release` removes the `.logbook-active` lockfile. The linter will fail if the lockfile is still present, enforcing that no entry is left dangling in a committed state.
54
+
55
+ You can also check the overall status of the logbook, configuration settings, and statistics (total tasks, done, and drafts) using:
56
+ ```bash
57
+ logbook status
58
+ ```
59
+
60
+ The linter will warn you if the lockfile `.logbook-active` is still present, which is expected during active development.
61
+
62
+ Once the work is ready:
63
+ - **If you are an AI agent**: Do not release the entry. Hand back to the prompter, who will review and run `logbook release`.
64
+ - **If you are a human developer**: Run `logbook release` to remove the lockfile and finalize the entry.
56
65
 
57
66
  The linter enforces rules to be followed.
58
67
 
@@ -3,19 +3,19 @@
3
3
  ticket: {{id}}
4
4
  # The title should be a human-readable description of the work
5
5
  title: {{title}}
6
- prompter: [PROMPTER]
7
- harness: [HARNESS]
8
- llm: [LLM]
9
- summary: [WRITE_SUMMARY_HERE]
6
+ prompter: "[PROMPTER]"
7
+ harness: "[HARNESS]"
8
+ llm: "[LLM]"
9
+ summary: "[WRITE_SUMMARY_HERE]"
10
10
  # Tags for categorizing the change (must be from allowed list in .project-logbook)
11
11
  tags: []
12
12
  # Workspace(s) this change affects (must match package.json workspaces; leave empty for single-project)
13
13
  # Example: workspaces: ["frontend", "shared"]
14
14
  workspaces: []
15
15
  # Set automatically by `logbook start`
16
- dateStart: [DATE_START]
16
+ dateStart: "[DATE_START]"
17
17
  # Set automatically by `logbook release`
18
- dateEnd: [DATE_END]
18
+ dateEnd: "[DATE_END]"
19
19
  ---
20
20
 
21
21
  ## Summary
@@ -2,27 +2,40 @@
2
2
  'use strict';
3
3
 
4
4
  /* ── 1. Real-time relative dates ──────────────────────────────────────── */
5
- function relativeDate(isoString) {
6
- var d = new Date(isoString);
5
+ /**
6
+ * Calculate the relative date string (today, yesterday, 2 days ago, etc)
7
+ * Prepend the relative component to the locally-formatted absolute date.
8
+ */
9
+ function getRelativeComponent(d) {
7
10
  var now = new Date();
8
11
  // Normalise both to midnight local time for day-diff calculation
9
12
  var dDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
10
13
  var nDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
11
14
  var diffDays = Math.round((nDay - dDay) / 86400000);
12
15
 
13
- var relative;
14
- if (diffDays === 0) relative = 'today';
15
- else if (diffDays === 1) relative = 'yesterday';
16
- else if (diffDays < 7) relative = diffDays + ' days ago';
17
- else if (diffDays < 14) relative = '1 week ago';
18
- else if (diffDays < 30) relative = Math.floor(diffDays / 7) + ' weeks ago';
19
- else if (diffDays < 60) relative = '1 month ago';
20
- else if (diffDays < 365) relative = Math.floor(diffDays / 30) + ' months ago';
21
- else if (diffDays < 730) relative = '1 year ago';
22
- else relative = Math.floor(diffDays / 365) + ' years ago';
23
-
24
- var absolute = d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
25
- return relative + ', ' + absolute;
16
+ if (diffDays === 0) return 'today';
17
+ else if (diffDays === 1) return 'yesterday';
18
+ else if (diffDays < 7) return diffDays + ' days ago';
19
+ else if (diffDays < 14) return '1 week ago';
20
+ else if (diffDays < 30) return Math.floor(diffDays / 7) + ' weeks ago';
21
+ else if (diffDays < 60) return '1 month ago';
22
+ else if (diffDays < 365) return Math.floor(diffDays / 30) + ' months ago';
23
+ else if (diffDays < 730) return '1 year ago';
24
+ else return Math.floor(diffDays / 365) + ' years ago';
25
+ }
26
+
27
+ /**
28
+ * Format a Date object as absolute date and time in browser's local timezone.
29
+ * Format: "26 May 2026, 10:51"
30
+ */
31
+ function formatAbsoluteComponent(d) {
32
+ var day = d.getDate();
33
+ var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
34
+ var month = months[d.getMonth()];
35
+ var year = d.getFullYear();
36
+ var hours = d.getHours() < 10 ? '0' + d.getHours() : d.getHours();
37
+ var minutes = d.getMinutes() < 10 ? '0' + d.getMinutes() : d.getMinutes();
38
+ return day + ' ' + month + ' ' + year + ', ' + hours + ':' + minutes;
26
39
  }
27
40
 
28
41
  function updateRelativeDates() {
@@ -30,7 +43,20 @@
30
43
  for (var i = 0; i < els.length; i++) {
31
44
  var el = els[i];
32
45
  var iso = el.getAttribute('data-date');
33
- if (iso) el.textContent = relativeDate(iso);
46
+ if (iso) {
47
+ var d = new Date(iso);
48
+ if (!isNaN(d.getTime())) {
49
+ // Format the absolute date in the user's browser/local timezone
50
+ var absolute = formatAbsoluteComponent(d);
51
+ // Prepend the relative component (e.g., "today")
52
+ var relative = getRelativeComponent(d);
53
+ if (relative) {
54
+ el.textContent = relative + ', ' + absolute;
55
+ } else {
56
+ el.textContent = absolute;
57
+ }
58
+ }
59
+ }
34
60
  }
35
61
  }
36
62
 
@@ -33,6 +33,11 @@ header h1 {
33
33
  header h1 a {
34
34
  text-decoration: none !important;
35
35
  color: black !important;
36
+ transition: color 0.2s;
37
+ }
38
+
39
+ header h1 a:hover {
40
+ color: var(--primary) !important;
36
41
  }
37
42
 
38
43
  .tagline {
@@ -1,4 +1,21 @@
1
- /** Format a date value as a relative time string (e.g., "today, May 16, 2026"). */
1
+ /**
2
+ * Format a date as absolute date and time (server-side).
3
+ * This is the primary format used in HTML data-date attributes.
4
+ * The client-side JavaScript will add the relative component (today, 2 days ago, etc).
5
+ * Format: "26 May 2026, 10:51"
6
+ * @param date - The date to format
7
+ * @returns Formatted date string with absolute date and time only
8
+ */
9
+ export declare function formatAbsoluteDate(date: string | Date): string;
10
+ /**
11
+ * Format a date as a consistent, human-readable format.
12
+ * Combines relative time (today, yesterday, 2 days ago) with absolute date and time.
13
+ * Format: "today, 26 May 2026, 10:51"
14
+ * This is for server-side rendering when client-side JS is not available.
15
+ * Normally, the client-side JS will update formatAbsoluteDate() output with this format.
16
+ * @param date - The date to format
17
+ * @returns Formatted date string with relative and absolute components
18
+ */
2
19
  export declare function formatRelativeDate(date: string | Date): string;
3
20
  /** Format a date value as YYYY-MM-DD. */
4
21
  export declare function formatIsoDate(value: string | Date): string;
@@ -6,3 +23,8 @@ export declare function formatIsoDate(value: string | Date): string;
6
23
  export declare function getMonthYear(date: string | Date): string;
7
24
  /** Get a short "HH:mmh" string for sorting/display based on dateEnd or dateStart. */
8
25
  export declare function getSortTime(dateStart: string | Date, dateEnd?: string | Date): string;
26
+ /**
27
+ * Format a timestamp for metadata/footer display (build time).
28
+ * Alias for formatAbsoluteDate for clarity in build context.
29
+ */
30
+ export declare function formatDateTimeForDisplay(date: string | Date): string;