Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ lore commit \
--rejected "class-validator decorators | too much magic for simple checks" \
--confidence high \
--scope-risk narrow \
--tested "unit tests for all validation rules"
--tested "unit tests for all validation rules" \
--trailer "Team=Gamma" "Ticket-Id=PROJ-123"

# Or pipe JSON (ideal for AI agents)
echo '{"intent":"fix: handle null user in auth middleware","trailers":{"Constraint":["must not throw -- return 401 instead"],"Confidence":"high"}}' | lore commit
Expand Down Expand Up @@ -121,6 +122,7 @@ lore search --text "session" --confidence high
| `lore trace <lore-id>` | Trace decision chain via references (Supersedes, Depends-on, Related) |
| `lore validate [range]` | Validate commits for Lore protocol compliance |
| `lore squash <range>` | Merge Lore atoms from a revision range for squash merges |
| `lore config` | Show effective configuration and trailer definitions |
| `lore doctor` | Health checks: config validity, ID uniqueness, reference integrity |

### Global Options
Expand All @@ -133,6 +135,7 @@ lore search --text "session" --confidence high
| `--no-update-notifier` | Disable update notification |
| `--limit <n>` | Limit number of results |
| `--since <ref>` | Only consider commits since ref/date |
| `--until <ref>` | Only consider commits until ref/date |

## Trailer Vocabulary

Expand All @@ -153,6 +156,8 @@ Every Lore-enriched commit carries a `Lore-id` and any combination of these trai
| `Depends-on` | 0..n | Lore-id | This atom requires another atom to hold |
| `Related` | 0..n | Lore-id | Informational link to another atom |

You can also define your own custom trailers with rich validation rules (enums, patterns, requiredness) and UI hints (colors, semantic kinds) in `.lore/config.toml`. Custom trailers are automatically supported by all query and commit commands.

## Configuration

`lore init` creates `.lore/config.toml`:
Expand All @@ -163,7 +168,27 @@ version = "1.0"

[trailers]
required = [] # Trailers every commit must include, e.g. ["Constraint", "Confidence"]
custom = [] # Additional trailer keys beyond the standard set
custom = ["Team"] # Additional trailer keys beyond the standard set (auto-prompts)
permissive = true # If false, only defined/custom trailers are kept

# Define rich validation rules and UI hints for custom trailers
[trailers.definitions.Department]
description = "The department responsible for this change"
multivalue = false
validation = "values"
values = ["Engineering", "Product", "Design"]
required = true
ui = { kind = "risk", color = "cyan" }
prompt = { order = 125 } # Optional: prompt between Confidence (120) and Scope-risk (130)
squash = "union" # Default: list all departments if they differ during squash

[trailers.definitions.Ticket-Id]
description = "Jira or GitHub issue ID"
multivalue = true
validation = "pattern"
pattern = "^[A-Z]+-[0-9]+$"
ui = { kind = "reference", color = "dim" }
# Default order for custom definitions is 1000 (after all core trailers)

[validation]
strict = false # Treat warnings as errors in lore validate
Expand Down
161 changes: 117 additions & 44 deletions documents/PROJECT_ARCHITECTURE.md

Large diffs are not rendered by default.

82 changes: 59 additions & 23 deletions src/commands/commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import type { IOutputFormatter } from '../interfaces/output-formatter.js';
import type { CommitInputResolver, CommitCommandOptions } from '../services/commit-input-resolver.js';
import type { HeadLoreIdReader } from '../services/head-lore-id-reader.js';
import { LoreError, NoStagedChangesError, ValidationError } from '../util/errors.js';
import { LORE_ID_KEY } from '../util/constants.js';
import type { LoreConfig, CustomTrailerDefinition } from '../types/config.js';
import type { Protocol } from '../services/protocol.js';

/** Keys on CommitCommandOptions that are NOT user-supplied input. */
const NON_INPUT_KEYS: ReadonlySet<string> = new Set(['amend', 'edit']);
Expand All @@ -17,7 +20,7 @@ const NON_INPUT_KEYS: ReadonlySet<string> = new Set(['amend', 'edit']);
function hasConflictingInput(options: CommitCommandOptions): boolean {
return Object.entries(options)
.filter(([k]) => !NON_INPUT_KEYS.has(k))
.some(([, v]) => v !== undefined);
.some(([, v]) => v !== undefined && v !== false);
}

/**
Expand All @@ -37,28 +40,35 @@ export function registerCommitCommand(
getFormatter: () => IOutputFormatter;
commitInputResolver: CommitInputResolver;
headLoreIdReader: HeadLoreIdReader;
config: LoreConfig;
protocol: Protocol;
},
): void {
program
const { protocol } = deps;
const cmd = program
.command('commit')
.description('Create a Lore-enriched commit')
.option('--amend', 'Amend the last commit')
.option('--no-edit', 'Keep the existing commit message (use with --amend)')
.option('--file <path>', 'Read JSON input from file')
.option('-i, --interactive', 'Interactive mode (guided prompts)')
.option('--intent <text>', 'Intent line (why the change was made)')
.option('--body <text>', 'Body (narrative context)')
.option('--constraint <text...>', 'Constraint trailer value (repeatable)')
.option('--rejected <text...>', 'Rejected trailer value (repeatable)')
.option('--confidence <level>', 'Confidence level: low, medium, high')
.option('--scope-risk <level>', 'Scope-risk level: narrow, moderate, wide')
.option('--reversibility <level>', 'Reversibility level: clean, migration-needed, irreversible')
.option('--directive <text...>', 'Directive trailer value (repeatable)')
.option('--tested <text...>', 'Tested trailer value (repeatable)')
.option('--not-tested <text...>', 'Not-tested trailer value (repeatable)')
.option('--supersedes <id...>', 'Supersedes Lore-id (repeatable)')
.option('--depends-on <id...>', 'Depends-on Lore-id (repeatable)')
.option('--related <id...>', 'Related Lore-id (repeatable)')
.option('--body <text>', 'Body (narrative context)');

// Dynamically register flags for all authorized trailers from the protocol metadata
const authorizedKeys = protocol.getAuthorizedKeys();
for (const key of authorizedKeys) {
// Skip Lore-id to maintain perfect parity with 'main' branch UI
if (key === LORE_ID_KEY) continue;

const def = protocol.getDefinition(key);
if (def) {
registerFlagForDefinition(cmd, key, def);
}
}

// 4. Add catch-all flag for permissive mode / ad-hoc trailers
cmd.option('--trailer <key=value...>', 'Custom trailer (repeatable, format: Key=Value)')
.action(async (options: CommitCommandOptions) => {
const { commitBuilder, gitClient, getFormatter, commitInputResolver, headLoreIdReader } = deps;
const formatter = getFormatter();
Expand All @@ -76,6 +86,13 @@ export function registerCommitCommand(
1,
);
}

// --no-edit requires staged changes since we aren't changing the message
const hasStaged = await gitClient.hasStagedChanges();
if (!hasStaged) {
throw new NoStagedChangesError();
}

const result = await gitClient.commit('', { amend: true, noEdit: true });
console.log(formatter.formatSuccess(`Commit amended: ${result.hash}`, { hash: result.hash }));
return;
Expand All @@ -89,24 +106,24 @@ export function registerCommitCommand(
}
}

// Read existing Lore-id when amending
const existingLoreId = options.amend ? await headLoreIdReader.read() : null;

// Resolve input from the appropriate source
// 5. Resolve input (interactive, file, flags, or stdin)
const input = await commitInputResolver.resolve(options);

// Validate input
// 6. Validate input before building message
const issues = commitBuilder.validate(input);
const errors = issues.filter((i) => i.severity === 'error');
if (errors.length > 0) {
throw new ValidationError('Commit input validation failed', issues);
}

// Build the commit message (reuse existing Lore-id on amend)
const message = commitBuilder.build(input, existingLoreId ?? undefined);
// 7. Build and commit
let existingLoreId;
if (options.amend) {
existingLoreId = await headLoreIdReader.read();
}

// Run git commit
const result = await gitClient.commit(message, options.amend ? { amend: true } : undefined);
const message = commitBuilder.build(input, existingLoreId ?? undefined);
const result = await gitClient.commit(message, { amend: !!options.amend });

// Output
const verb = options.amend ? 'amended' : 'created';
Expand All @@ -118,3 +135,22 @@ export function registerCommitCommand(
);
});
}

/**
* Register a CLI flag for a trailer definition.
*/
function registerFlagForDefinition(cmd: Command, key: string, def: CustomTrailerDefinition): void {
const flagName = def.cli?.flag || slugify(key);
const shorthand = def.cli?.shorthand ? `-${def.cli.shorthand}, ` : '';
const argTemplate = def.multivalue ? `<text...>` : `<text>`;
const description = `${def.description}${def.multivalue ? ' (repeatable)' : ''}`;

cmd.option(`${shorthand}--${flagName} ${argTemplate}`, description);
}

/**
* Convert a trailer key to a CLI-friendly flag name.
*/
function slugify(text: string): string {
return text.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
}
45 changes: 45 additions & 0 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Command } from 'commander';
import type { IConfigLoader } from '../interfaces/config-loader.js';
import type { IOutputFormatter } from '../interfaces/output-formatter.js';
import type { FormattableConfigResult } from '../types/output.js';
import type { Protocol } from '../services/protocol.js';

/**
* Register the `lore config` command.
* Outputs the effective configuration for the current path.
*/
export function registerConfigCommand(
program: Command,
deps: {
configLoader: IConfigLoader;
getFormatter: () => IOutputFormatter;
protocol: Protocol;
},
): void {
program
.command('config')
.description('Show effective configuration')
.option('--core', 'Show only core trailer definitions')
.option('--custom', 'Show only custom trailer definitions')
.action(async (options: { core?: boolean; custom?: boolean }) => {
const { configLoader, getFormatter, protocol } = deps;
const config = await configLoader.loadForPath(process.cwd());

const hasFilters = options.core !== undefined || options.custom !== undefined;
const showCore = options.core ?? !hasFilters;
const showCustom = options.custom ?? !hasFilters;

const formattable: FormattableConfigResult = {
loreVersion: config.protocol.version,
permissive: config.trailers.permissive,
trailers: protocol.getFormattableDefinitions(),
filters: {
showCore,
showCustom,
},
};

const formatter = getFormatter();
console.log(formatter.formatConfig(formattable));
});
}
32 changes: 18 additions & 14 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import type { IConfigLoader } from '../interfaces/config-loader.js';
import type { IOutputFormatter } from '../interfaces/output-formatter.js';
import type { FormattableDoctorResult, DoctorCheck } from '../types/output.js';
import type { LoreAtom } from '../types/domain.js';
import { LORE_ID_PATTERN, REFERENCE_TRAILER_KEYS } from '../util/constants.js';
import { LORE_ID_PATTERN, LORE_ID_KEY } from '../util/constants.js';
import type { Protocol } from '../services/protocol.js';

/**
* Register the `lore doctor` command.
* Runs health checks:
* 1. Config file exists and is valid
* 2. Lore-id uniqueness
* 2. ${LORE_ID_KEY} uniqueness
* 3. All references resolve
* 4. No orphaned dependencies
*/
Expand All @@ -20,13 +21,14 @@ export function registerDoctorCommand(
atomRepository: AtomRepository;
configLoader: IConfigLoader;
getFormatter: () => IOutputFormatter;
protocol: Protocol;
},
): void {
program
.command('doctor')
.description('Health check: broken refs, config issues')
.action(async () => {
const { atomRepository, configLoader, getFormatter } = deps;
const { atomRepository, configLoader, getFormatter, protocol } = deps;

const checks: DoctorCheck[] = [];

Expand All @@ -48,11 +50,11 @@ export function registerDoctorCommand(
allAtoms = [];
}

// Check 2: Lore-id uniqueness
// Check 2: ${LORE_ID_KEY} uniqueness
checks.push(checkLoreIdUniqueness(allAtoms));

// Check 3: All references resolve
checks.push(checkReferencesResolve(allAtoms));
// Check 3: All references resolve (metadata-driven)
checks.push(checkReferencesResolve(allAtoms, protocol));

// Check 4: Orphaned dependencies (depends on superseded atoms)
checks.push(checkOrphanedDependencies(allAtoms));
Expand Down Expand Up @@ -132,40 +134,42 @@ function checkLoreIdUniqueness(
const duplicates: string[] = [];
for (const [id, count] of idCounts) {
if (count > 1) {
duplicates.push(`Lore-id "${id}" appears ${count} times`);
duplicates.push(`${LORE_ID_KEY} "${id}" appears ${count} times`);
}
}

if (duplicates.length > 0) {
return {
name: 'Lore-id uniqueness',
name: '${LORE_ID_KEY} uniqueness',
status: 'warning',
message: `${duplicates.length} duplicate Lore-id(s) found`,
message: `${duplicates.length} duplicate ${LORE_ID_KEY}(s) found`,
details: duplicates,
};
}

return {
name: 'Lore-id uniqueness',
name: '${LORE_ID_KEY} uniqueness',
status: 'ok',
message: `All ${atoms.length} Lore-ids are unique`,
message: `All ${atoms.length} ${LORE_ID_KEY}s are unique`,
details: [],
};
}

function checkReferencesResolve(
atoms: readonly LoreAtom[],
protocol: Protocol,
): DoctorCheck {
const allLoreIds = new Set(atoms.map((a) => a.loreId));
const brokenRefs: string[] = [];
const refKeys = protocol.getReferenceKeys();

for (const atom of atoms) {
for (const key of REFERENCE_TRAILER_KEYS) {
const refs = atom.trailers[key] as readonly string[];
for (const key of refKeys) {
const refs = atom.trailers[key] || [];
for (const refId of refs) {
if (LORE_ID_PATTERN.test(refId) && !allLoreIds.has(refId)) {
brokenRefs.push(
`Lore-id "${refId}" referenced by ${atom.loreId} (${key}) not found`,
`${LORE_ID_KEY} "${refId}" referenced by ${atom.loreId} (${key}) not found`,
);
}
}
Expand Down
Loading
Loading