Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,11 @@ Reference
- Migrations runner: `src/migrations/index.ts`
- Migration descriptors: `src/migrations/*`
- Migration application: `runMigrations` creates backups and prunes to the last 5 backups.

Audit field migration note
--------------------------
- Migration `20260315-add-audit` adds the `audit` column to `workitems`.
- The migration does not backfill historical comment-based audit content.
- Structured audit data is only written when explicitly provided via write paths (for example `wl update --audit-text "..."`).
- Audit write semantics are overwrite-only for the single `audit` object (no history array in this slice).
- Redaction/safety rules for audit text are tracked separately in `Redaction and Safety Rules for Audit Text (WL-0MMNCOIYS15A1YSI)`.
70 changes: 62 additions & 8 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { WorklogDatabase } from './database.js';
import { CreateWorkItemInput, UpdateWorkItemInput, WorkItemQuery, WorkItemStatus, WorkItemPriority, CreateCommentInput, UpdateCommentInput } from './types.js';
import { exportToJsonl, importFromJsonl, getDefaultDataPath } from './jsonl.js';
import { loadConfig } from './config.js';
import { buildAuditEntry } from './audit.js';

function parseNeedsProducerReview(value: unknown): boolean | undefined {
if (value === undefined || value === null) return undefined;
Expand All @@ -16,13 +17,36 @@ function parseNeedsProducerReview(value: unknown): boolean | undefined {
return undefined;
}

function normalizeCreateInputWithAudit(input: CreateWorkItemInput): CreateWorkItemInput {
const rawAudit = (input as any).audit;
if (typeof rawAudit === 'string') {
return {
...input,
audit: buildAuditEntry(rawAudit),
};
}
return input;
}

function normalizeUpdateInputWithAudit(input: UpdateWorkItemInput): UpdateWorkItemInput {
const rawAudit = (input as any).audit;
if (typeof rawAudit === 'string') {
return {
...input,
audit: buildAuditEntry(rawAudit),
};
}
return input;
}

export function createAPI(db: WorklogDatabase) {
const app = express();
app.use(express.json());

// Load configuration to get default prefix
const config = loadConfig();
const defaultPrefix = config?.prefix || 'WI';
const auditWriteEnabled = config?.auditWriteEnabled !== false;

// Middleware to set the database prefix based on the route
function setPrefixMiddleware(req: Request, res: Response, next: NextFunction) {
Expand All @@ -41,11 +65,16 @@ export function createAPI(db: WorklogDatabase) {
app.post('/items', (req: Request, res: Response) => {
try {
db.setPrefix(defaultPrefix);
const input: CreateWorkItemInput = req.body;
if (typeof (req.body as any)?.audit === 'string' && !auditWriteEnabled) {
res.status(400).json({ error: 'Audit writes are disabled by config (auditWriteEnabled: false)' });
return;
}
const input: CreateWorkItemInput = normalizeCreateInputWithAudit(req.body);
Comment thread
SorraTheOrc marked this conversation as resolved.
Outdated
const item = db.create(input);
res.status(201).json(item);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
const message = (error as Error).message || 'Invalid request';
res.status(400).json({ error: message });
}
});

Expand All @@ -64,15 +93,25 @@ export function createAPI(db: WorklogDatabase) {
app.put('/items/:id', (req: Request, res: Response) => {
try {
db.setPrefix(defaultPrefix);
const input: UpdateWorkItemInput = req.body;
if (typeof (req.body as any)?.audit === 'string' && !auditWriteEnabled) {
res.status(400).json({ error: 'Audit writes are disabled by config (auditWriteEnabled: false)' });
return;
}
const current = db.get(req.params.id);
if (!current) {
res.status(404).json({ error: 'Work item not found' });
return;
}
const input: UpdateWorkItemInput = normalizeUpdateInputWithAudit(req.body);
const item = db.update(req.params.id, input);
Comment thread
SorraTheOrc marked this conversation as resolved.
Outdated
if (!item) {
res.status(404).json({ error: 'Work item not found' });
return;
}
res.json(item);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
const message = (error as Error).message || 'Invalid request';
res.status(400).json({ error: message });
}
});

Expand Down Expand Up @@ -222,11 +261,16 @@ export function createAPI(db: WorklogDatabase) {
// Create a work item with prefix
app.post('/projects/:prefix/items', setPrefixMiddleware, (req: Request, res: Response) => {
try {
const input: CreateWorkItemInput = req.body;
if (typeof (req.body as any)?.audit === 'string' && !auditWriteEnabled) {
res.status(400).json({ error: 'Audit writes are disabled by config (auditWriteEnabled: false)' });
return;
}
const input: CreateWorkItemInput = normalizeCreateInputWithAudit(req.body);
const item = db.create(input);
Comment thread
SorraTheOrc marked this conversation as resolved.
Outdated
res.status(201).json(item);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
const message = (error as Error).message || 'Invalid request';
res.status(400).json({ error: message });
}
});

Expand All @@ -243,15 +287,25 @@ export function createAPI(db: WorklogDatabase) {
// Update a work item with prefix
app.put('/projects/:prefix/items/:id', setPrefixMiddleware, (req: Request, res: Response) => {
try {
const input: UpdateWorkItemInput = req.body;
if (typeof (req.body as any)?.audit === 'string' && !auditWriteEnabled) {
res.status(400).json({ error: 'Audit writes are disabled by config (auditWriteEnabled: false)' });
return;
}
const current = db.get(req.params.id);
if (!current) {
res.status(404).json({ error: 'Work item not found' });
return;
}
const input: UpdateWorkItemInput = normalizeUpdateInputWithAudit(req.body);
const item = db.update(req.params.id, input);
Comment thread
SorraTheOrc marked this conversation as resolved.
Outdated
if (!item) {
res.status(404).json({ error: 'Work item not found' });
return;
}
res.json(item);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
const message = (error as Error).message || 'Invalid request';
res.status(400).json({ error: message });
}
});

Expand Down
114 changes: 11 additions & 103 deletions src/audit.ts
Original file line number Diff line number Diff line change
@@ -1,114 +1,22 @@
/**
* Audit entry utilities for Worklog.
*
* Provides helpers for building structured AuditEntry objects from
* freeform audit text, including conservative status derivation.
*
* Status derivation is intentionally conservative:
* - If the work item description lacks explicit success criteria, status
* is set to 'Missing Criteria' rather than inferring from audit text.
* - Keyword matching uses conservative thresholds to prefer 'Partial' or
* 'Not Started' over 'Complete' when uncertain.
*/
import os from 'node:os';
import type { WorkItemAudit } from './types.js';

import * as os from 'os';
import type { AuditEntry, AuditStatus } from './types.js';

/**
* Patterns that indicate explicit success criteria in a work item description.
* At least one must match for the description to be considered criteria-bearing.
*/
const CRITERIA_PATTERNS = [
/success criteria/i,
/acceptance criteria/i,
/\bAC\s*\d+/,
/\bdone when\b/i,
/\bcomplete when\b/i,
/\bshould\b.*\bcan\b/i,
/\bmust\b.*\bwhen\b/i,
/- \[[ xX]\]/, // checkbox list items often indicate criteria
];

/**
* Returns true if the description appears to contain explicit success criteria.
*/
export function hasExplicitCriteria(description: string): boolean {
if (!description || description.trim() === '') return false;
return CRITERIA_PATTERNS.some(p => p.test(description));
}

/**
* Conservatively derive an AuditStatus from audit text and item description.
*
* Rules (applied in order):
* 1. If description lacks explicit success criteria → 'Missing Criteria'
* 2. If audit text contains strong completion signals → 'Complete'
* 3. If audit text contains partial-progress signals → 'Partial'
* 4. Default → 'Not Started'
*/
export function deriveAuditStatus(auditText: string, description: string): AuditStatus {
if (!hasExplicitCriteria(description)) {
return 'Missing Criteria';
}

const text = auditText.toLowerCase();

// Strong completion signals (all criteria must be satisfied)
const completePatterns = [
/\ball criteria (met|satisfied|complete)\b/,
/\bfully (complete|done|finished|implemented)\b/,
/\bcomplete\b.*\ball\b/,
/\ball (done|complete|finished)\b/,
/\bimplementation complete\b/,
/\bdelivery complete\b/,
];
if (completePatterns.some(p => p.test(text))) {
return 'Complete';
}

// Partial-progress signals
const partialPatterns = [
/\bpartially\b/,
/\bin progress\b/,
/\bsome criteria\b/,
/\bpartial\b/,
/\bincomplete\b/,
/\bremaining\b/,
/\bnot all\b/,
/\bpending\b/,
/\bwork in progress\b/,
/\bwip\b/,
];
if (partialPatterns.some(p => p.test(text))) {
return 'Partial';
}

// Default conservative
return 'Not Started';
}

/**
* Get the current user identity for audit authorship.
* Returns the OS username, falling back to 'unknown' if unavailable.
*/
export function getCurrentUser(): string {
export function resolveAuditAuthor(): string {
const explicit = process.env.WL_USER || process.env.USER || process.env.USERNAME;
if (explicit && explicit.trim()) return explicit.trim();
try {
return os.userInfo().username || 'unknown';
const username = os.userInfo().username;
if (username && username.trim()) return username.trim();
} catch {
return process.env.USER || process.env.USERNAME || 'unknown';
// fall back below
}
return 'worklog';
}

/**
* Build a complete AuditEntry from freeform text and the work item description.
* Populates `time` from now, `author` from the current OS user,
* and derives `status` conservatively.
*/
export function buildAuditEntry(auditText: string, description: string): AuditEntry {
export function buildAuditEntry(auditText: string, author?: string): WorkItemAudit {
return {
time: new Date().toISOString(),
author: getCurrentUser(),
author: author && author.trim() ? author.trim() : resolveAuditAuthor(),
text: auditText,
status: deriveAuditStatus(auditText, description),
};
}
8 changes: 6 additions & 2 deletions src/cli-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ export interface CreateOptions {
deleteReason?: string;
/** Accepts true|false|yes|no to set needsProducerReview flag for the new item */
needsProducerReview?: string;
/** Freeform audit text; system populates time/author and derives status */
/** Legacy audit flag (kept for compatibility) */
audit?: string;
/** Preferred audit flag for structured writes */
auditText?: string;
prefix?: string;
}

Expand Down Expand Up @@ -73,8 +75,10 @@ export interface UpdateOptions {
createdBy?: string;
deletedBy?: string;
deleteReason?: string;
/** Freeform audit text; system populates time/author and derives status */
/** Legacy audit flag (kept for compatibility) */
audit?: string;
/** Preferred audit flag for structured writes */
auditText?: string;
prefix?: string;
}

Expand Down
23 changes: 20 additions & 3 deletions src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,11 @@ export default function register(ctx: PluginContext): void {
.option('--deleted-by <deletedBy>', 'Deleted by (interoperability field)')
.option('--delete-reason <deleteReason>', 'Delete reason (interoperability field)')
.option('--needs-producer-review <true|false>', 'Set needsProducerReview flag for the new item (true|false|yes|no)')
.option('--audit <text>', 'Add a structured audit note (freeform text; time and author are set automatically)')
.option('--audit <text>', 'Legacy alias for --audit-text')
.option('--audit-text <text>', 'Set structured audit text (time/author auto-populated)')
.option('--prefix <prefix>', 'Override the default prefix')
.action(async (...rawArgs: any[]) => {
const normalized = normalizeActionArgs(rawArgs, ['title','description','descriptionFile','status','priority','parent','tags','assignee','stage','risk','effort','issueType','createdBy','deletedBy','deleteReason','needsProducerReview','audit','prefix']);
const normalized = normalizeActionArgs(rawArgs, ['title','description','descriptionFile','status','priority','parent','tags','assignee','stage','risk','effort','issueType','createdBy','deletedBy','deleteReason','needsProducerReview','audit','auditText','prefix']);
let options: CreateOptions = normalized.options as any || {};
utils.requireInitialized();
const db = utils.getDatabase(options.prefix);
Expand All @@ -53,6 +54,7 @@ export default function register(ctx: PluginContext): void {
}

const config = utils.getConfig();
const auditWriteEnabled = config?.auditWriteEnabled !== false;
const requestedStage = options.stage !== undefined ? options.stage : 'idea';
let normalizedStatus = (options.status || 'open') as WorkItemStatus;
let normalizedStage = requestedStage;
Expand Down Expand Up @@ -81,6 +83,21 @@ export default function register(ctx: PluginContext): void {
}
}

const auditTextInput = options.auditText ?? options.audit;

if (auditTextInput !== undefined && !auditWriteEnabled) {
output.error('Audit writes are disabled by config (`auditWriteEnabled: false`).', {
success: false,
error: 'audit-write-disabled',
});
process.exit(1);
}

let auditEntry;
if (auditTextInput !== undefined) {
auditEntry = buildAuditEntry(String(auditTextInput));
}

const item = db.createWithNextSortIndex({
title: options.title,
description: description,
Expand All @@ -99,7 +116,7 @@ export default function register(ctx: PluginContext): void {
needsProducerReview: (options.needsProducerReview !== undefined) ?
(['true','yes','1'].includes(String(options.needsProducerReview).toLowerCase())) :
false,
audit: options.audit ? buildAuditEntry(options.audit, description) : undefined,
audit: auditEntry,
});

const refreshed = db.get(item.id) || item;
Expand Down
26 changes: 24 additions & 2 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,30 @@ export default function register(ctx: PluginContext): void {
// Not a dry-run: list safe migrations, print blank line, and ask to apply
const safeMigs = pending.filter(p => p.safe);
if (utils.isJsonMode()) {
output.json({ success: true, pending, safeMigrations: safeMigs });
return;
if (!opts.confirm) {
output.json({ success: true, pending, safeMigrations: safeMigs, requiresConfirm: true });
return;
}

try {
const result = runMigrations({
dryRun: false,
confirm: true,
logger: { info: s => console.error(s), error: s => console.error(s) }
});
output.json({
success: true,
pending,
safeMigrations: safeMigs,
applied: result.applied,
backups: result.backups,
});
return;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
Comment thread
SorraTheOrc marked this conversation as resolved.
output.json({ success: false, error: message });
return;
}
}
console.log('Pending safe migrations:');
safeMigs.forEach(p => console.log(` - ${p.id}: ${p.description}`));
Expand Down
Loading
Loading