project-logbook 0.3.3 → 0.4.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/dist/commands/build.js +54 -76
- package/dist/commands/init.js +11 -11
- package/dist/commands/lint.js +27 -10
- package/dist/commands/list.js +18 -18
- package/dist/commands/log.js +5 -5
- package/dist/commands/new.js +19 -20
- package/dist/commands/preview.js +9 -8
- package/dist/commands/release.js +6 -6
- package/dist/commands/start.js +26 -21
- package/dist/commands/status.d.ts +1 -0
- package/dist/commands/status.js +138 -0
- package/dist/commands/steer.js +9 -9
- package/dist/commands/upgrade.js +5 -5
- package/dist/index.js +9 -8
- package/dist/lib/build-helpers.js +30 -67
- package/dist/lib/build-steps.d.ts +20 -0
- package/dist/lib/build-steps.js +57 -0
- package/dist/lib/config.d.ts +17 -0
- package/dist/lib/config.js +27 -3
- package/dist/lib/entry-id.d.ts +22 -0
- package/dist/lib/entry-id.js +26 -0
- package/dist/lib/entry-paths.d.ts +23 -0
- package/dist/lib/entry-paths.js +55 -0
- package/dist/lib/git-helpers.d.ts +32 -1
- package/dist/lib/git-helpers.js +119 -26
- package/dist/lib/hast-helpers.d.ts +10 -0
- package/dist/lib/hast-helpers.js +22 -0
- package/dist/lib/html-attributes.d.ts +17 -0
- package/dist/lib/html-attributes.js +17 -0
- package/dist/lib/html-escape.d.ts +16 -0
- package/dist/lib/html-escape.js +38 -0
- package/dist/lib/image-helpers.js +26 -33
- package/dist/lib/lint-runner.js +5 -5
- package/dist/lib/markdown-processors.d.ts +22 -0
- package/dist/lib/markdown-processors.js +68 -0
- package/dist/lib/package-version.d.ts +5 -0
- package/dist/lib/package-version.js +16 -0
- package/dist/lib/rss.d.ts +29 -0
- package/dist/lib/rss.js +77 -0
- package/dist/lib/styles.js +5 -2
- package/dist/lib/template-helpers.d.ts +4 -3
- package/dist/lib/template-helpers.js +51 -27
- package/dist/lib/template-types.d.ts +2 -2
- package/dist/lib/templates.d.ts +12 -3
- package/dist/lib/templates.js +59 -52
- package/dist/lib/theme.d.ts +37 -0
- package/dist/lib/theme.js +50 -0
- package/dist/lib/url-helpers.d.ts +13 -0
- package/dist/lib/url-helpers.js +27 -0
- package/dist/linters/diff-to-narrative.d.ts +6 -0
- package/dist/linters/diff-to-narrative.js +114 -0
- package/dist/linters/index.js +4 -0
- package/dist/linters/technical-log.d.ts +7 -0
- package/dist/linters/technical-log.js +72 -0
- package/dist/templates/CONTRIBUTING.md +12 -3
- package/dist/templates/index.md +10 -6
- package/dist/templates/log.md +5 -0
- package/dist/templates/logbook-client.js +42 -16
- package/dist/templates/steer.txt +21 -5
- package/dist/templates/styles.css +121 -0
- package/dist/utils/date.d.ts +30 -1
- package/dist/utils/date.js +68 -15
- package/dist/utils/frontmatter.d.ts +26 -0
- package/dist/utils/frontmatter.js +37 -0
- package/dist/utils/fs.d.ts +13 -0
- package/dist/utils/fs.js +23 -0
- package/dist/utils/log-timeline.d.ts +69 -0
- package/dist/utils/log-timeline.js +218 -0
- package/package.json +4 -2
- package/src/templates/CONTRIBUTING.md +12 -3
- package/src/templates/index.md +10 -6
- package/src/templates/log.md +5 -0
- package/src/templates/logbook-client.js +42 -16
- package/src/templates/steer.txt +21 -5
- package/src/templates/styles.css +121 -0
package/dist/utils/date.d.ts
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
|
-
/**
|
|
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,15 @@ 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;
|
|
31
|
+
/**
|
|
32
|
+
* Format a date for RSS feed pubDate element.
|
|
33
|
+
* RSS requires RFC 822 date format: "Mon, 26 May 2026 10:51:00 GMT"
|
|
34
|
+
* @param date - The date to format
|
|
35
|
+
* @returns RFC 822 formatted date string
|
|
36
|
+
*/
|
|
37
|
+
export declare function formatDateTimeForRss(date: string | Date): string;
|
package/dist/utils/date.js
CHANGED
|
@@ -1,7 +1,42 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Parse and validate a date input (string or Date).
|
|
3
|
+
* Returns the Date object if valid, null if invalid.
|
|
4
|
+
*/
|
|
5
|
+
function parseDate(date) {
|
|
3
6
|
const d = new Date(date);
|
|
4
|
-
|
|
7
|
+
return isNaN(d.getTime()) ? null : d;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Format a date as absolute date and time (server-side).
|
|
11
|
+
* This is the primary format used in HTML data-date attributes.
|
|
12
|
+
* The client-side JavaScript will add the relative component (today, 2 days ago, etc).
|
|
13
|
+
* Format: "26 May 2026, 10:51"
|
|
14
|
+
* @param date - The date to format
|
|
15
|
+
* @returns Formatted date string with absolute date and time only
|
|
16
|
+
*/
|
|
17
|
+
export function formatAbsoluteDate(date) {
|
|
18
|
+
const d = parseDate(date);
|
|
19
|
+
if (!d)
|
|
20
|
+
return 'unknown';
|
|
21
|
+
const day = d.getDate();
|
|
22
|
+
const month = d.toLocaleString('en-US', { month: 'short' });
|
|
23
|
+
const year = d.getFullYear();
|
|
24
|
+
const hours = String(d.getHours()).padStart(2, '0');
|
|
25
|
+
const minutes = String(d.getMinutes()).padStart(2, '0');
|
|
26
|
+
return `${day} ${month} ${year}, ${hours}:${minutes}`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Format a date as a consistent, human-readable format.
|
|
30
|
+
* Combines relative time (today, yesterday, 2 days ago) with absolute date and time.
|
|
31
|
+
* Format: "today, 26 May 2026, 10:51"
|
|
32
|
+
* This is for server-side rendering when client-side JS is not available.
|
|
33
|
+
* Normally, the client-side JS will update formatAbsoluteDate() output with this format.
|
|
34
|
+
* @param date - The date to format
|
|
35
|
+
* @returns Formatted date string with relative and absolute components
|
|
36
|
+
*/
|
|
37
|
+
export function formatRelativeDate(date) {
|
|
38
|
+
const d = parseDate(date);
|
|
39
|
+
if (!d) {
|
|
5
40
|
return 'unknown date';
|
|
6
41
|
}
|
|
7
42
|
const now = new Date();
|
|
@@ -9,16 +44,15 @@ export function formatRelativeDate(date) {
|
|
|
9
44
|
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
|
10
45
|
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
|
|
11
46
|
const relative = diffDays === 0 ? 'today' : rtf.format(-diffDays, 'day');
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
month: 'long',
|
|
15
|
-
day: 'numeric',
|
|
16
|
-
});
|
|
47
|
+
// Format as "26 May 2026, 10:51"
|
|
48
|
+
const absolute = formatAbsoluteDate(d);
|
|
17
49
|
return `${relative}, ${absolute}`;
|
|
18
50
|
}
|
|
19
51
|
/** Format a date value as YYYY-MM-DD. */
|
|
20
52
|
export function formatIsoDate(value) {
|
|
21
|
-
const d =
|
|
53
|
+
const d = parseDate(value);
|
|
54
|
+
if (!d)
|
|
55
|
+
return 'invalid-date';
|
|
22
56
|
const yyyy = d.getFullYear();
|
|
23
57
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
24
58
|
const dd = String(d.getDate()).padStart(2, '0');
|
|
@@ -26,8 +60,8 @@ export function formatIsoDate(value) {
|
|
|
26
60
|
}
|
|
27
61
|
/** Get "Month Year" string for a date (e.g., "May 2026"). */
|
|
28
62
|
export function getMonthYear(date) {
|
|
29
|
-
const d =
|
|
30
|
-
if (
|
|
63
|
+
const d = parseDate(date);
|
|
64
|
+
if (!d)
|
|
31
65
|
return 'Unknown Date';
|
|
32
66
|
return d.toLocaleDateString('en-US', {
|
|
33
67
|
year: 'numeric',
|
|
@@ -36,12 +70,31 @@ export function getMonthYear(date) {
|
|
|
36
70
|
}
|
|
37
71
|
/** Get a short "HH:mmh" string for sorting/display based on dateEnd or dateStart. */
|
|
38
72
|
export function getSortTime(dateStart, dateEnd) {
|
|
39
|
-
const dStart =
|
|
40
|
-
const dEnd = dateEnd ?
|
|
41
|
-
const date = dEnd
|
|
42
|
-
if (
|
|
73
|
+
const dStart = parseDate(dateStart);
|
|
74
|
+
const dEnd = dateEnd ? parseDate(dateEnd) : null;
|
|
75
|
+
const date = dEnd ? dEnd : dStart;
|
|
76
|
+
if (!date)
|
|
43
77
|
return '--:--h';
|
|
44
78
|
const hours = date.getHours().toString().padStart(2, '0');
|
|
45
79
|
const minutes = date.getMinutes().toString().padStart(2, '0');
|
|
46
80
|
return `${hours}:${minutes}h`;
|
|
47
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Format a timestamp for metadata/footer display (build time).
|
|
84
|
+
* Alias for formatAbsoluteDate for clarity in build context.
|
|
85
|
+
*/
|
|
86
|
+
export function formatDateTimeForDisplay(date) {
|
|
87
|
+
return formatAbsoluteDate(date);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Format a date for RSS feed pubDate element.
|
|
91
|
+
* RSS requires RFC 822 date format: "Mon, 26 May 2026 10:51:00 GMT"
|
|
92
|
+
* @param date - The date to format
|
|
93
|
+
* @returns RFC 822 formatted date string
|
|
94
|
+
*/
|
|
95
|
+
export function formatDateTimeForRss(date) {
|
|
96
|
+
const d = parseDate(date);
|
|
97
|
+
if (!d)
|
|
98
|
+
return new Date().toUTCString();
|
|
99
|
+
return d.toUTCString();
|
|
100
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalizes a frontmatter field that may be a scalar string or a YAML list of strings.
|
|
3
|
+
* Used when displaying frontmatter fields like 'harness', 'llm', 'prompter' in the timeline.
|
|
4
|
+
*
|
|
5
|
+
* @param v - A frontmatter value (string, array of strings, or other type)
|
|
6
|
+
* @returns A display string joining multiple values with ' + ', or undefined if empty
|
|
7
|
+
*/
|
|
8
|
+
export declare function toDisplayString(v: unknown): string | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* Type guard: safely get a string field with optional fallback value.
|
|
11
|
+
* Used to defensively extract string fields from untyped YAML data.
|
|
12
|
+
*
|
|
13
|
+
* @param value - The value to check (may be unknown, string, or other type)
|
|
14
|
+
* @param fallback - Value to use if the input is not a string. If provided, return type is always string. If omitted, return type is string | undefined.
|
|
15
|
+
* @returns The value if it's a non-empty string, otherwise the fallback (or undefined if no fallback provided)
|
|
16
|
+
*/
|
|
17
|
+
export declare function asString(value: unknown): string | undefined;
|
|
18
|
+
export declare function asString(value: unknown, fallback: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Type guard: safely extract array of strings from a field that may be a string, array, or other type.
|
|
21
|
+
* Normalizes scalar strings to single-element arrays and filters out non-string array elements.
|
|
22
|
+
*
|
|
23
|
+
* @param value - The value to normalize (string, array, or other type)
|
|
24
|
+
* @returns Array of strings, or empty array if input is not convertible
|
|
25
|
+
*/
|
|
26
|
+
export declare function asStringArray(value: unknown): string[];
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalizes a frontmatter field that may be a scalar string or a YAML list of strings.
|
|
3
|
+
* Used when displaying frontmatter fields like 'harness', 'llm', 'prompter' in the timeline.
|
|
4
|
+
*
|
|
5
|
+
* @param v - A frontmatter value (string, array of strings, or other type)
|
|
6
|
+
* @returns A display string joining multiple values with ' + ', or undefined if empty
|
|
7
|
+
*/
|
|
8
|
+
export function toDisplayString(v) {
|
|
9
|
+
if (typeof v === 'string')
|
|
10
|
+
return v || undefined;
|
|
11
|
+
if (Array.isArray(v)) {
|
|
12
|
+
const joined = v.filter((x) => typeof x === 'string').join(' + ');
|
|
13
|
+
return joined || undefined;
|
|
14
|
+
}
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
export function asString(value, fallback) {
|
|
18
|
+
if (typeof value === 'string' && value)
|
|
19
|
+
return value;
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Type guard: safely extract array of strings from a field that may be a string, array, or other type.
|
|
24
|
+
* Normalizes scalar strings to single-element arrays and filters out non-string array elements.
|
|
25
|
+
*
|
|
26
|
+
* @param value - The value to normalize (string, array, or other type)
|
|
27
|
+
* @returns Array of strings, or empty array if input is not convertible
|
|
28
|
+
*/
|
|
29
|
+
export function asStringArray(value) {
|
|
30
|
+
if (typeof value === 'string') {
|
|
31
|
+
return value ? [value] : [];
|
|
32
|
+
}
|
|
33
|
+
if (Array.isArray(value)) {
|
|
34
|
+
return value.filter((item) => typeof item === 'string');
|
|
35
|
+
}
|
|
36
|
+
return [];
|
|
37
|
+
}
|
package/dist/utils/fs.d.ts
CHANGED
|
@@ -11,3 +11,16 @@ export interface EntryMeta {
|
|
|
11
11
|
* Filters for directories and parses index.md if it exists.
|
|
12
12
|
*/
|
|
13
13
|
export declare function getLogbookEntries(logbookDir: string): Promise<EntryMeta[]>;
|
|
14
|
+
/**
|
|
15
|
+
* Safely read a file if it exists, otherwise return an empty string.
|
|
16
|
+
* @param filePath - Path to the file to read
|
|
17
|
+
* @returns File contents if the file exists, otherwise empty string
|
|
18
|
+
*/
|
|
19
|
+
export declare function readFileIfExists(filePath: string): Promise<string>;
|
|
20
|
+
/**
|
|
21
|
+
* Check if a file path exists, returning the path if it does.
|
|
22
|
+
* Useful for optional file resolution logic.
|
|
23
|
+
* @param filePath - Path to check
|
|
24
|
+
* @returns The path if it exists, otherwise null
|
|
25
|
+
*/
|
|
26
|
+
export declare function pathExistsOrNull(filePath: string): Promise<string | null>;
|
package/dist/utils/fs.js
CHANGED
|
@@ -36,3 +36,26 @@ export async function getLogbookEntries(logbookDir) {
|
|
|
36
36
|
}
|
|
37
37
|
return results;
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Safely read a file if it exists, otherwise return an empty string.
|
|
41
|
+
* @param filePath - Path to the file to read
|
|
42
|
+
* @returns File contents if the file exists, otherwise empty string
|
|
43
|
+
*/
|
|
44
|
+
export async function readFileIfExists(filePath) {
|
|
45
|
+
if (await fs.pathExists(filePath)) {
|
|
46
|
+
return await fs.readFile(filePath, 'utf8');
|
|
47
|
+
}
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Check if a file path exists, returning the path if it does.
|
|
52
|
+
* Useful for optional file resolution logic.
|
|
53
|
+
* @param filePath - Path to check
|
|
54
|
+
* @returns The path if it exists, otherwise null
|
|
55
|
+
*/
|
|
56
|
+
export async function pathExistsOrNull(filePath) {
|
|
57
|
+
if (await fs.pathExists(filePath)) {
|
|
58
|
+
return filePath;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for transforming technical log markdown into a timeline view.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Parsed log entry with timestamp and message.
|
|
6
|
+
*/
|
|
7
|
+
export interface LogEntry {
|
|
8
|
+
timestamp: Date;
|
|
9
|
+
isoTimestamp: string;
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Timeline item for rendering - either a log entry or a gap indicator.
|
|
14
|
+
*/
|
|
15
|
+
export interface TimelineItem {
|
|
16
|
+
type: 'entry' | 'gap';
|
|
17
|
+
timestamp?: Date;
|
|
18
|
+
isoTimestamp?: string;
|
|
19
|
+
message?: string;
|
|
20
|
+
gapHours?: number;
|
|
21
|
+
showTime?: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Parse technical log markdown into structured log entries.
|
|
25
|
+
* @param logMarkdown - The raw markdown content of the technical log
|
|
26
|
+
* @returns Array of parsed log entries, or null if parsing fails
|
|
27
|
+
*/
|
|
28
|
+
export declare function parseLogMarkdown(logMarkdown: string): LogEntry[] | null;
|
|
29
|
+
/**
|
|
30
|
+
* Transform log entries into a timeline with gap indicators.
|
|
31
|
+
* Shows ALL entries, but only displays timestamps for:
|
|
32
|
+
* - First entry
|
|
33
|
+
* - Entries after gaps >= threshold
|
|
34
|
+
* - Last entry
|
|
35
|
+
*
|
|
36
|
+
* This prevents timestamp clutter when LLM writes many entries in quick succession.
|
|
37
|
+
*
|
|
38
|
+
* @param entries - Parsed log entries (sorted chronologically)
|
|
39
|
+
* @param gapThresholdHours - Minimum gap in hours to show timestamp (default: 1)
|
|
40
|
+
* @returns Timeline items for rendering
|
|
41
|
+
*/
|
|
42
|
+
export declare function createTimelineFromEntries(entries: LogEntry[], gapThresholdHours?: number): TimelineItem[];
|
|
43
|
+
/**
|
|
44
|
+
* Render timeline items as HTML.
|
|
45
|
+
* Creates a beautiful timeline that shows:
|
|
46
|
+
* - ALL log entries as messages
|
|
47
|
+
* - Timestamps only for significant moments (start, after gaps, end)
|
|
48
|
+
* - Gap indicators for breaks > threshold
|
|
49
|
+
*
|
|
50
|
+
* @param timeline - Timeline items to render
|
|
51
|
+
* @returns HTML string for the timeline
|
|
52
|
+
*/
|
|
53
|
+
export declare function renderTimelineHtml(timeline: TimelineItem[]): string;
|
|
54
|
+
/**
|
|
55
|
+
* Get the last log entry from technical log markdown.
|
|
56
|
+
* Useful for displaying a "last logged" nudge in CLI output.
|
|
57
|
+
*
|
|
58
|
+
* @param logMarkdown - The raw markdown content of the technical log
|
|
59
|
+
* @returns The last LogEntry, or null if none found
|
|
60
|
+
*/
|
|
61
|
+
export declare function getLastLogEntry(logMarkdown: string): LogEntry | null;
|
|
62
|
+
/**
|
|
63
|
+
* Main function to transform technical log markdown into timeline HTML.
|
|
64
|
+
* Falls back to plain markdown rendering if preprocessing fails.
|
|
65
|
+
*
|
|
66
|
+
* @param logMarkdown - The raw markdown content of the technical log
|
|
67
|
+
* @returns HTML string (timeline if successful, plain markdown otherwise)
|
|
68
|
+
*/
|
|
69
|
+
export declare function transformLogToTimeline(logMarkdown: string): Promise<string>;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for transforming technical log markdown into a timeline view.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Regex pattern to match log entries with ISO timestamps.
|
|
6
|
+
* Matches: "2026-05-27T14:17:02.250Z: Some message"
|
|
7
|
+
* Also matches bullet points: "- 2026-05-27T14:17:02.250Z: Some message"
|
|
8
|
+
*/
|
|
9
|
+
const LOG_ENTRY_REGEX = /^-?\s*(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?)\s*:\s*(.+)$/;
|
|
10
|
+
/**
|
|
11
|
+
* Parse technical log markdown into structured log entries.
|
|
12
|
+
* @param logMarkdown - The raw markdown content of the technical log
|
|
13
|
+
* @returns Array of parsed log entries, or null if parsing fails
|
|
14
|
+
*/
|
|
15
|
+
export function parseLogMarkdown(logMarkdown) {
|
|
16
|
+
if (!logMarkdown || typeof logMarkdown !== 'string') {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const lines = logMarkdown.split('\n');
|
|
20
|
+
const entries = [];
|
|
21
|
+
for (const line of lines) {
|
|
22
|
+
const match = line.match(LOG_ENTRY_REGEX);
|
|
23
|
+
if (match) {
|
|
24
|
+
const [, timestampStr, message] = match;
|
|
25
|
+
const timestamp = new Date(timestampStr);
|
|
26
|
+
if (isNaN(timestamp.getTime())) {
|
|
27
|
+
continue; // Skip invalid dates
|
|
28
|
+
}
|
|
29
|
+
entries.push({
|
|
30
|
+
timestamp,
|
|
31
|
+
isoTimestamp: timestampStr,
|
|
32
|
+
message: message.trim(),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Sort entries chronologically
|
|
37
|
+
entries.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
38
|
+
return entries.length > 0 ? entries : null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Transform log entries into a timeline with gap indicators.
|
|
42
|
+
* Shows ALL entries, but only displays timestamps for:
|
|
43
|
+
* - First entry
|
|
44
|
+
* - Entries after gaps >= threshold
|
|
45
|
+
* - Last entry
|
|
46
|
+
*
|
|
47
|
+
* This prevents timestamp clutter when LLM writes many entries in quick succession.
|
|
48
|
+
*
|
|
49
|
+
* @param entries - Parsed log entries (sorted chronologically)
|
|
50
|
+
* @param gapThresholdHours - Minimum gap in hours to show timestamp (default: 1)
|
|
51
|
+
* @returns Timeline items for rendering
|
|
52
|
+
*/
|
|
53
|
+
export function createTimelineFromEntries(entries, gapThresholdHours = 1) {
|
|
54
|
+
if (entries.length === 0) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
const timeline = [];
|
|
58
|
+
const gapThresholdMs = gapThresholdHours * 60 * 60 * 1000;
|
|
59
|
+
// Always show first entry with timestamp
|
|
60
|
+
timeline.push({
|
|
61
|
+
type: 'entry',
|
|
62
|
+
timestamp: entries[0].timestamp,
|
|
63
|
+
isoTimestamp: entries[0].isoTimestamp,
|
|
64
|
+
message: entries[0].message,
|
|
65
|
+
showTime: true,
|
|
66
|
+
});
|
|
67
|
+
// Process middle entries
|
|
68
|
+
for (let i = 1; i < entries.length - 1; i++) {
|
|
69
|
+
const prev = entries[i - 1];
|
|
70
|
+
const curr = entries[i];
|
|
71
|
+
const gapMs = curr.timestamp.getTime() - prev.timestamp.getTime();
|
|
72
|
+
const gapHours = (gapMs / gapThresholdMs) * gapThresholdHours;
|
|
73
|
+
if (gapHours >= gapThresholdHours) {
|
|
74
|
+
// Add gap indicator
|
|
75
|
+
timeline.push({
|
|
76
|
+
type: 'gap',
|
|
77
|
+
gapHours,
|
|
78
|
+
});
|
|
79
|
+
// Show this entry with timestamp after the gap
|
|
80
|
+
timeline.push({
|
|
81
|
+
type: 'entry',
|
|
82
|
+
timestamp: curr.timestamp,
|
|
83
|
+
isoTimestamp: curr.isoTimestamp,
|
|
84
|
+
message: curr.message,
|
|
85
|
+
showTime: true,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
// Show entry without timestamp (close to previous)
|
|
90
|
+
timeline.push({
|
|
91
|
+
type: 'entry',
|
|
92
|
+
message: curr.message,
|
|
93
|
+
showTime: false,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Always show last entry
|
|
98
|
+
if (entries.length > 1) {
|
|
99
|
+
const last = entries[entries.length - 1];
|
|
100
|
+
const prev = entries[entries.length - 2];
|
|
101
|
+
const gapMs = last.timestamp.getTime() - prev.timestamp.getTime();
|
|
102
|
+
const gapHours = (gapMs / gapThresholdMs) * gapThresholdHours;
|
|
103
|
+
if (gapHours >= gapThresholdHours) {
|
|
104
|
+
// Add gap indicator before last entry
|
|
105
|
+
timeline.push({
|
|
106
|
+
type: 'gap',
|
|
107
|
+
gapHours,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
// Last entry always shown with timestamp
|
|
111
|
+
timeline.push({
|
|
112
|
+
type: 'entry',
|
|
113
|
+
timestamp: last.timestamp,
|
|
114
|
+
isoTimestamp: last.isoTimestamp,
|
|
115
|
+
message: last.message,
|
|
116
|
+
showTime: true,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return timeline;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Format timestamp for display in timeline.
|
|
123
|
+
* Shows time in HH:MM format.
|
|
124
|
+
*/
|
|
125
|
+
function formatTime(timestamp) {
|
|
126
|
+
const hours = String(timestamp.getHours()).padStart(2, '0');
|
|
127
|
+
const minutes = String(timestamp.getMinutes()).padStart(2, '0');
|
|
128
|
+
return `${hours}:${minutes}`;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Escape HTML special characters.
|
|
132
|
+
*/
|
|
133
|
+
function escapeHtml(text) {
|
|
134
|
+
const htmlEscapes = {
|
|
135
|
+
'&': '&',
|
|
136
|
+
'<': '<',
|
|
137
|
+
'>': '>',
|
|
138
|
+
'"': '"',
|
|
139
|
+
"'": ''',
|
|
140
|
+
};
|
|
141
|
+
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char]);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Render timeline items as HTML.
|
|
145
|
+
* Creates a beautiful timeline that shows:
|
|
146
|
+
* - ALL log entries as messages
|
|
147
|
+
* - Timestamps only for significant moments (start, after gaps, end)
|
|
148
|
+
* - Gap indicators for breaks > threshold
|
|
149
|
+
*
|
|
150
|
+
* @param timeline - Timeline items to render
|
|
151
|
+
* @returns HTML string for the timeline
|
|
152
|
+
*/
|
|
153
|
+
export function renderTimelineHtml(timeline) {
|
|
154
|
+
if (timeline.length === 0) {
|
|
155
|
+
return '<p>No log entries available.</p>';
|
|
156
|
+
}
|
|
157
|
+
const itemsHtml = timeline
|
|
158
|
+
.map((item) => {
|
|
159
|
+
if (item.type === 'gap') {
|
|
160
|
+
const gapHours = item.gapHours?.toFixed(1) ?? '??';
|
|
161
|
+
return `
|
|
162
|
+
<li class="timeline-gap">
|
|
163
|
+
<span class="gap-indicator">
|
|
164
|
+
⏸ ${gapHours}h break
|
|
165
|
+
</span>
|
|
166
|
+
</li>`;
|
|
167
|
+
}
|
|
168
|
+
if (item.type === 'entry' && item.message) {
|
|
169
|
+
const escapedMessage = escapeHtml(item.message);
|
|
170
|
+
if (item.showTime && item.timestamp && item.isoTimestamp) {
|
|
171
|
+
const time = formatTime(item.timestamp);
|
|
172
|
+
return `
|
|
173
|
+
<li class="timeline-entry has-time">
|
|
174
|
+
<time class="timeline-time" datetime="${escapeHtml(item.isoTimestamp)}">${time}</time>
|
|
175
|
+
<span class="timeline-message">${escapedMessage}</span>
|
|
176
|
+
</li>`;
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
// Entry without timestamp - just the message
|
|
180
|
+
return `
|
|
181
|
+
<li class="timeline-entry no-time">
|
|
182
|
+
<span class="timeline-message">${escapedMessage}</span>
|
|
183
|
+
</li>`;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return '';
|
|
187
|
+
})
|
|
188
|
+
.join('');
|
|
189
|
+
return `<ul class="timeline">${itemsHtml}</ul>`;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Get the last log entry from technical log markdown.
|
|
193
|
+
* Useful for displaying a "last logged" nudge in CLI output.
|
|
194
|
+
*
|
|
195
|
+
* @param logMarkdown - The raw markdown content of the technical log
|
|
196
|
+
* @returns The last LogEntry, or null if none found
|
|
197
|
+
*/
|
|
198
|
+
export function getLastLogEntry(logMarkdown) {
|
|
199
|
+
const entries = parseLogMarkdown(logMarkdown);
|
|
200
|
+
return entries ? entries[entries.length - 1] : null;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Main function to transform technical log markdown into timeline HTML.
|
|
204
|
+
* Falls back to plain markdown rendering if preprocessing fails.
|
|
205
|
+
*
|
|
206
|
+
* @param logMarkdown - The raw markdown content of the technical log
|
|
207
|
+
* @returns HTML string (timeline if successful, plain markdown otherwise)
|
|
208
|
+
*/
|
|
209
|
+
export async function transformLogToTimeline(logMarkdown) {
|
|
210
|
+
const entries = parseLogMarkdown(logMarkdown);
|
|
211
|
+
if (!entries || entries.length === 0) {
|
|
212
|
+
// Fall back to plain markdown rendering
|
|
213
|
+
const fallbackContent = logMarkdown && logMarkdown.trim() ? escapeHtml(logMarkdown).replace(/\n/g, '<br>') : 'No log entries available.';
|
|
214
|
+
return '<div class="log-fallback">' + fallbackContent + '</div>';
|
|
215
|
+
}
|
|
216
|
+
const timeline = createTimelineFromEntries(entries);
|
|
217
|
+
return renderTimelineHtml(timeline);
|
|
218
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "project-logbook",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "A command-line tool for project logbooks.",
|
|
5
5
|
"workspaces": [
|
|
6
6
|
"demo-app"
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"pre": "npm run format && npm run lint && npm run test && npm run build && npm run knip && npm run madge && npm run dev build && npm run dev lint",
|
|
30
30
|
"next": "node scripts/create-ticket-from-backlog.js",
|
|
31
31
|
"link": "npm run build && npm link",
|
|
32
|
-
"open": "npm run build && node scripts/open-and-view-dist.js"
|
|
32
|
+
"open": "npm run dev build && node scripts/open-and-view-dist.js"
|
|
33
33
|
},
|
|
34
34
|
"keywords": [
|
|
35
35
|
"logbook",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"@types/fs-extra": "^11.0.4",
|
|
43
43
|
"@types/hast": "^3.0.4",
|
|
44
44
|
"@types/node": "^25.7.0",
|
|
45
|
+
"@types/rss": "^0.0.32",
|
|
45
46
|
"@typescript-eslint/eslint-plugin": "^8.32.1",
|
|
46
47
|
"@typescript-eslint/parser": "^8.32.1",
|
|
47
48
|
"@vitest/coverage-v8": "^3.1.4",
|
|
@@ -65,6 +66,7 @@
|
|
|
65
66
|
"remark-gfm": "^4.0.1",
|
|
66
67
|
"remark-parse": "^11.0.0",
|
|
67
68
|
"remark-rehype": "^11.1.2",
|
|
69
|
+
"rss": "^1.2.2",
|
|
68
70
|
"simple-git": "^3.36.0",
|
|
69
71
|
"unified": "^11.0.5"
|
|
70
72
|
}
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
package/src/templates/index.md
CHANGED
|
@@ -3,24 +3,28 @@
|
|
|
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
|
|
22
22
|
TODO: Write a polished, highly readable ticket summary that reads like an engaging technical narrative (similar to a well-written dev blog post).
|
|
23
23
|
|
|
24
|
+
### Before You Start:
|
|
25
|
+
- **Check `log.md`**: Review your technical log for all the decisions, errors, and pivots you recorded during implementation.
|
|
26
|
+
- **Reference the log**: Use your real-time log entries as source material for the narrative — don't try to reconstruct from memory.
|
|
27
|
+
|
|
24
28
|
### Formatting & Style Rules:
|
|
25
29
|
- **Maintain the narrative tone:** TODO: Keep the storytelling flair (e.g., describing how problems accumulated or how gaps surfaced), but stay strictly factual based on the provided changes.
|
|
26
30
|
- **Add thematic headings:** TODO: Break the narrative down into logical chapters using Markdown headings (e.g., ### The Friction Points, ### The Fix, ### Closing the Gap).
|
package/src/templates/log.md
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
# Technical Log: {{id}}-{{slug}}
|
|
2
2
|
|
|
3
|
+
> **REMINDER**: Log your work in real-time using `logbook log "<message>"`. Don't wait until the end!
|
|
4
|
+
>
|
|
5
|
+
> Log after every significant step: investigation, errors, decisions, code changes, test runs, etc.
|
|
6
|
+
>
|
|
7
|
+
|
|
3
8
|
## Protocol
|
|
4
9
|
- {{fullIso}}: Started investigation.
|