mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-20 01:56:37 +03:00
Refine product and visual work lifecycle
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"craft": {
|
||||
"description": "Full confirmed-brief-then-build flow. Runs multi-round shape discovery first, resolves visual probe and north-star mock gates when available, then builds and visually iterates. Use when building a new feature end-to-end.",
|
||||
"description": "Deprecated compatibility alias for an ordinary Impeccable new-work request. It adds no behavior; natural build and redesign requests use the same flow.",
|
||||
"argumentHint": "[feature description]"
|
||||
},
|
||||
"init": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Surface-concept seed: the dice half of new-work's task-composition procedure.
|
||||
* External concept seed: the dice half of new-work's world and surface
|
||||
* selection procedures.
|
||||
*
|
||||
* The model derives a grounded shortlist of candidate FORMS from the
|
||||
* audience's world and the subject's cultural home (see
|
||||
@@ -22,8 +23,8 @@
|
||||
* material and win over thin categories, which is the intended shape.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/concept-seed.mjs # roll at random
|
||||
* node scripts/concept-seed.mjs --from <key> # deterministic (hash key)
|
||||
* node scripts/concept-seed.mjs --scope surface
|
||||
* node scripts/concept-seed.mjs --scope world --from <key>
|
||||
*
|
||||
* Env vars:
|
||||
* IMPECCABLE_CONCEPT_SEED — same as --from; for reproducible eval runs.
|
||||
@@ -39,6 +40,12 @@ const pool = JSON.parse(readFileSync(join(here, 'concept-ingredients.json'), 'ut
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const fromIdx = args.indexOf('--from');
|
||||
const scopeIdx = args.indexOf('--scope');
|
||||
const scope = scopeIdx !== -1 ? args[scopeIdx + 1] : 'surface';
|
||||
if (scope !== 'surface' && scope !== 'world') {
|
||||
process.stderr.write('concept-seed: --scope must be world or surface\n');
|
||||
process.exit(1);
|
||||
}
|
||||
// When no key is supplied, generate one and print it: a user reporting a
|
||||
// bad outcome can hand us the key and we replay the exact roll.
|
||||
const key = fromIdx !== -1
|
||||
@@ -46,7 +53,7 @@ const key = fromIdx !== -1
|
||||
: (process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'));
|
||||
|
||||
function hashUnit(k, salt) {
|
||||
const h = crypto.createHash('sha256').update(`${salt}:${k}`).digest();
|
||||
const h = crypto.createHash('sha256').update(`${scope}:${salt}:${k}`).digest();
|
||||
return h.readUInt32BE(0) / 0xffffffff;
|
||||
}
|
||||
const unit = (salt) => hashUnit(key, salt);
|
||||
@@ -66,24 +73,44 @@ for (let i = 0; picks.length < 3 && i < 60; i++) {
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`CONCEPT SEED (key: ${key}; rerun with --from ${key} to reproduce this roll)
|
||||
PROMOTED INDEX: ${buildIndex}
|
||||
After ordering the task's grounded structural candidates by resonance,
|
||||
const promotedInstruction = scope === 'world'
|
||||
? `After ordering the grounded visual-world candidates by product fit, promote
|
||||
candidate ${buildIndex} into the serious shortlist. Present it beside the
|
||||
strongest materially different candidates and let the user select or revise
|
||||
the durable world. It must survive navigation, quiet and dense content,
|
||||
interaction and state, and a surface unlike the current request.`
|
||||
: `After ordering the task's grounded structural candidates by resonance,
|
||||
promote candidate ${buildIndex} into the serious shortlist. In an attended
|
||||
run, present it beside the strongest materially different candidates and
|
||||
let the user select or revise the surface concept. In a truly unattended
|
||||
run, use it when it survives audience identification and product clarity.
|
||||
run, use it when it survives audience identification and product clarity.`;
|
||||
|
||||
const challengerInstruction = scope === 'world'
|
||||
? `A challenger enters the world shortlist only when its structure can become
|
||||
reusable identity grammar across the product, not a one-page costume. Weigh
|
||||
product identification, product clarity, and cross-surface system breadth.`
|
||||
: `A challenger wins only when it beats the grounded list on both audience
|
||||
identification and product clarity. It may change task topology or
|
||||
interaction, but never the committed visual identity.`;
|
||||
|
||||
const authorityInstruction = scope === 'world'
|
||||
? `PRODUCT.md and explicit incumbent brand commitments constrain every world.
|
||||
The seed never chooses exact colors, fonts, tokens, or a user preference.`
|
||||
: `PRODUCT.md and DESIGN.md constrain every surface candidate's identity
|
||||
vocabulary; they do not cancel task-level composition. The seed never
|
||||
authorizes a new palette, type system, material world, or unfamiliar control
|
||||
behavior.`;
|
||||
|
||||
process.stdout.write(`${scope.toUpperCase()} CONCEPT SEED (key: ${key}; rerun with --scope ${scope} --from ${key} to reproduce this roll)
|
||||
PROMOTED INDEX: ${buildIndex}
|
||||
${promotedInstruction}
|
||||
The promotion exists to refuse the model's ranking rut, not to outrank the
|
||||
user or the brief.
|
||||
CHALLENGERS (weigh against your derived candidates on the same two axes,
|
||||
audience identification and product clarity; a challenger wins only when
|
||||
it beats the grounded list on both):
|
||||
CHALLENGERS:
|
||||
1. ${picks[0]}
|
||||
2. ${picks[1]}
|
||||
3. ${picks[2]}
|
||||
If a challenger survives, it may enter the shortlist as a structural option.
|
||||
PRODUCT.md and DESIGN.md constrain every candidate's identity vocabulary;
|
||||
they do not cancel task-level composition. A user- or brief-pinned surface
|
||||
concept beats the roll, always. The seed never authorizes a new palette,
|
||||
type system, material world, or unfamiliar control behavior.
|
||||
${challengerInstruction}
|
||||
${authorityInstruction}
|
||||
A user- or brief-pinned decision beats the roll, always.
|
||||
`);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* output is always valid JSON.
|
||||
*
|
||||
* Signals:
|
||||
* - setup: PRODUCT.md / DESIGN.md presence, register, whether code exists
|
||||
* - setup: PRODUCT.md / DESIGN.md presence and whether code exists
|
||||
* - critique: the latest cached critique score (.impeccable/critique)
|
||||
* - git: branch + files changed vs the default branch (a scope hint)
|
||||
* - devServer: whether a local dev server answers on a common port (gates live)
|
||||
|
||||
+81
-29
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* Context loader: prints PRODUCT.md (and DESIGN.md if present) as one
|
||||
* markdown block on stdout, or prints a `NO_PRODUCT_MD:` message when no
|
||||
* Context loader: prints PRODUCT.md, DESIGN.md when present, the matching
|
||||
* persisted surface brief when one can be resolved, and native-platform
|
||||
* guidance selected from PRODUCT.md. It prints a
|
||||
* `NO_PRODUCT_MD:` message when no
|
||||
* PRODUCT.md is found anywhere. The skill keys off that message to branch:
|
||||
* from-scratch build commands (init / teach / craft / shape) and clear
|
||||
* from-scratch build requests (plus init / teach / shape) and clear
|
||||
* build/shape intent divert into the init flow, while scoped commands proceed
|
||||
* using the existing code as context.
|
||||
*
|
||||
@@ -24,9 +26,11 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
|
||||
import { resolveSurfaceBrief } from './lib/surface-briefs.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const SKILL_REFERENCE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'reference');
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
@@ -75,6 +79,12 @@ export function loadContext(cwd = process.cwd(), options = {}) {
|
||||
const designPath = resolved.designPath;
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
const platform = extractPlatform(product);
|
||||
const surfaceResolution = resolveSurfaceBrief(
|
||||
resolved.projectRoot,
|
||||
hasTargetOption(options) ? options.targetPath : null,
|
||||
);
|
||||
const surfaceBrief = surfaceResolution.brief;
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
@@ -85,7 +95,18 @@ export function loadContext(cwd = process.cwd(), options = {}) {
|
||||
contextDir: resolved.contextDir,
|
||||
productContextDir: productPath ? path.dirname(productPath) : null,
|
||||
designContextDir: designPath ? path.dirname(designPath) : null,
|
||||
hasSurfaceBrief: !!surfaceBrief,
|
||||
surfaceBrief: surfaceBrief?.text ?? null,
|
||||
surfaceBriefPath: surfaceBrief?.path ? path.relative(absCwd, surfaceBrief.path) : null,
|
||||
surfaceBriefReason: surfaceResolution.reason,
|
||||
surfaceBriefCandidates: surfaceResolution.candidates.map((brief) => ({
|
||||
slug: brief.slug,
|
||||
path: path.relative(absCwd, brief.path),
|
||||
primaryTarget: brief.primaryTarget,
|
||||
relatedTargets: brief.relatedTargets,
|
||||
})),
|
||||
hasVisualImplementation: hasVisualImplementation(resolved.projectRoot),
|
||||
platform,
|
||||
projectRoot: resolved.projectRoot,
|
||||
repoRoot: resolved.repoRoot,
|
||||
isMonorepo: resolved.isMonorepo,
|
||||
@@ -698,6 +719,19 @@ function safeRead(p) {
|
||||
}
|
||||
}
|
||||
|
||||
function loadNativePlatformReferences(platform) {
|
||||
const names = platform === 'adaptive'
|
||||
? ['ios', 'android']
|
||||
: platform === 'ios' || platform === 'android'
|
||||
? [platform]
|
||||
: [];
|
||||
return names.flatMap((name) => {
|
||||
const filePath = path.join(SKILL_REFERENCE_DIR, `${name}.md`);
|
||||
const content = safeRead(filePath);
|
||||
return content ? [{ name, filePath, content }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort evidence that the project already has an incumbent visual
|
||||
* implementation. DESIGN.md is documentation, not the only source of design
|
||||
@@ -989,13 +1023,13 @@ async function cli() {
|
||||
const parts = ctx.hasVisualImplementation
|
||||
? [
|
||||
'NO_PRODUCT_MD: This project has no PRODUCT.md yet, but it does have an incumbent visual implementation. ' +
|
||||
'For `init`, `teach`, `craft`, or `shape`, load reference/init.md and create PRODUCT.md with the user first. ' +
|
||||
'For extension, init documents the incumbent system; for redesign/rebrand, init replaces it through a new ' +
|
||||
'visual-world choice. Other ' +
|
||||
'For `init`, `teach`, `shape`, or any request to create a new surface or replacement visual world, load reference/init.md and create PRODUCT.md with the user first. ' +
|
||||
'After init writes PRODUCT.md, reference/new-work.md preserves and documents the incumbent system for an ' +
|
||||
'extension or replaces it with the user for a redesign/rebrand. Other ' +
|
||||
'narrow refinement commands may read the CSS, tokens, components, and assets and proceed without blocking, then ' +
|
||||
`offer \`${IMPECCABLE_COMMAND} init\` as a follow-up.`,
|
||||
'BUILD_INIT_REQUIRED: Before `craft` or `shape`, init must capture PRODUCT.md with the human or structured ' +
|
||||
'simulated user. A redesign then replaces the visual world; an extension documents it.',
|
||||
'BUILD_INIT_REQUIRED: Before shape or any new-surface/redesign flow, init must capture PRODUCT.md with the human or structured ' +
|
||||
'simulated user. Init writes product truth only; reference/new-work.md owns every visual decision.',
|
||||
'SCOPED_EXISTING_ALLOWED: Narrow refinement commands may use the incumbent implementation as authority without ' +
|
||||
'blocking on context setup; they must preserve it and offer init afterward.',
|
||||
'EXISTING_VISUAL_SYSTEM: For refinement or extension, code and assets are incumbent design authority and missing ' +
|
||||
@@ -1004,17 +1038,18 @@ async function cli() {
|
||||
]
|
||||
: [
|
||||
'NO_PRODUCT_MD: This project has no PRODUCT.md yet. ' +
|
||||
'For `init`, `teach`, `craft`, `shape`, ' +
|
||||
'For `init`, `teach`, `shape`, ' +
|
||||
'or wording that clearly maps to a from-scratch build/shape flow, load ' +
|
||||
'reference/init.md, complete its human or structured simulated-user interview, and write PRODUCT.md plus the ' +
|
||||
'user-chosen seed DESIGN.md before building. If no answer mechanism truly exists, init may infer only from the ' +
|
||||
'explicit brief, label its assumptions, and still write both files. For any other ' +
|
||||
'reference/init.md, complete its human or structured simulated-user interview, and write PRODUCT.md before ' +
|
||||
'designing. If no answer mechanism truly exists, init may infer only from the explicit brief and must label its ' +
|
||||
'assumptions. It never writes DESIGN.md. For any other ' +
|
||||
'(scoped) command against existing code, proceed using the code as ' +
|
||||
`context and offer \`${IMPECCABLE_COMMAND} init\` as a suggestion (do not block).`,
|
||||
'IDENTITY_INIT_REQUIRED: No committed product or visual world was found. New builds and redesigns ' +
|
||||
'must finish reference/init.md before reference/new-work.md develops the task-specific surface concept. Scoped ' +
|
||||
'PRODUCT_INIT_REQUIRED: No product context or visual authority was found. New builds and redesigns ' +
|
||||
'must finish reference/init.md for PRODUCT.md, then reference/new-work.md establishes the world and surface. Scoped ' +
|
||||
'fixes to existing code do not need the new-surface flow.',
|
||||
];
|
||||
appendSurfaceBriefContext(parts, ctx);
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
@@ -1027,30 +1062,28 @@ async function cli() {
|
||||
if (ctx.hasDesign) {
|
||||
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
|
||||
}
|
||||
appendSurfaceBriefContext(parts, ctx);
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
if (!ctx.hasDesign) {
|
||||
parts.push(ctx.hasVisualImplementation
|
||||
? 'BUILD_DESIGN_DOCUMENT_REQUIRED: PRODUCT.md exists and DESIGN.md is missing, but code contains incumbent visual decisions. ' +
|
||||
'Before `craft` or `shape`, load reference/init.md Step 5. For extension, document CSS, tokens, components, and ' +
|
||||
'assets as the incumbent world. For redesign/rebrand, replace the visual world with the user and treat the old ' +
|
||||
'look only as evidence and anti-reference. Narrow refinement commands may proceed using the implementation directly.'
|
||||
: 'IDENTITY_INIT_REQUIRED: PRODUCT.md exists but no DESIGN.md or incumbent visual implementation was found. ' +
|
||||
'A new build or redesign must complete reference/init.md Step 5 with the human or structured simulated ' +
|
||||
'user before reference/new-work.md develops the task-specific surface concept. Scoped fixes to existing code do not need it.');
|
||||
? 'INCUMBENT_WORLD_UNDOCUMENTED: PRODUCT.md exists and DESIGN.md is missing, but code contains incumbent visual decisions. ' +
|
||||
'For shape or a new-surface/redesign request, load reference/new-work.md: an extension documents and preserves the code-defined world; ' +
|
||||
'a redesign replaces it with the user and uses the old look only as evidence and anti-reference. Narrow refinement ' +
|
||||
'commands may proceed using the implementation directly.'
|
||||
: 'WORLD_DISCOVERY_REQUIRED: PRODUCT.md exists but no DESIGN.md or incumbent visual implementation was found. ' +
|
||||
'For a new build or redesign, load reference/new-work.md and establish the visual world with the human or structured ' +
|
||||
'simulated user before developing the task concept. Scoped fixes to existing code do not need this flow.');
|
||||
}
|
||||
const platform = extractPlatform(ctx.product);
|
||||
const nativeRefs =
|
||||
platform === 'adaptive' ? ['ios', 'android'] : platform === 'ios' || platform === 'android' ? [platform] : [];
|
||||
if (nativeRefs.length) {
|
||||
const refList = nativeRefs.map(p => `\`reference/${p}.md\``).join(' and ');
|
||||
const label = platform === 'adaptive' ? '`adaptive` (both iOS and Android)' : `\`${platform}\``;
|
||||
const platformReferences = loadNativePlatformReferences(ctx.platform);
|
||||
for (const reference of platformReferences) {
|
||||
parts.push(
|
||||
`NEXT STEP: This project targets ${label}. Also read ${refList} for native conventions, in addition to SKILL.md's mode guidance.`,
|
||||
`# NATIVE PLATFORM REFERENCE: ${reference.name.toUpperCase()} (reference/${reference.name}.md)\n\n${reference.content.trim()}`,
|
||||
);
|
||||
} else if (!platform) {
|
||||
}
|
||||
if (!ctx.platform) {
|
||||
// A `## Platform` section that names something we don't recognize (a
|
||||
// toolchain like `flutter`, a typo) would otherwise silently fall back to
|
||||
// web — the wrong default exactly when the user tried to say "native".
|
||||
@@ -1087,10 +1120,29 @@ function buildResolvedContextDirective(ctx, options, { targetExists = null } = {
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
surfaceBriefPath: ctx.surfaceBriefPath,
|
||||
surfaceBriefReason: ctx.surfaceBriefReason,
|
||||
surfaceBriefCandidates: ctx.surfaceBriefCandidates,
|
||||
hasVisualImplementation: ctx.hasVisualImplementation,
|
||||
platform: ctx.platform,
|
||||
}, null, 2)}`;
|
||||
}
|
||||
|
||||
function appendSurfaceBriefContext(parts, ctx) {
|
||||
if (ctx.hasSurfaceBrief && ctx.surfaceBrief) {
|
||||
parts.push(`# SURFACE BRIEF (${ctx.surfaceBriefPath})\n\n${ctx.surfaceBrief.trim()}`);
|
||||
return;
|
||||
}
|
||||
if (!ctx.surfaceBriefCandidates?.length) return;
|
||||
const helper = path.join(path.dirname(fileURLToPath(import.meta.url)), 'surface-brief.mjs');
|
||||
parts.push(
|
||||
'SURFACE_CONTEXT_AVAILABLE: Persisted surface briefs exist, but none was selected unambiguously for this invocation. ' +
|
||||
'Resolve the requested surface to its concrete primary or related source path, then run ' +
|
||||
`\`node ${helper} read <path>\` once before changing that surface. Candidates:\n` +
|
||||
JSON.stringify(ctx.surfaceBriefCandidates, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
|
||||
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
|
||||
return !!(
|
||||
|
||||
@@ -29,8 +29,9 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
|
||||
import { slugFromTarget } from './lib/target-slug.mjs';
|
||||
|
||||
const SLUG_MAX = 50;
|
||||
export { slugFromTarget } from './lib/target-slug.mjs';
|
||||
|
||||
/**
|
||||
* Mechanically derive a slug from a resolved target. Returns null if the
|
||||
@@ -40,46 +41,6 @@ const SLUG_MAX = 50;
|
||||
* concrete artifact before calling this — we never slug a natural-language
|
||||
* phrase.
|
||||
*/
|
||||
export function slugFromTarget(resolved, { cwd = process.cwd() } = {}) {
|
||||
if (!resolved || typeof resolved !== 'string') return null;
|
||||
const trimmed = resolved.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// URL
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
let url;
|
||||
try { url = new URL(trimmed); } catch { return null; }
|
||||
const hostPath = `${url.hostname}${url.pathname}`;
|
||||
return kebab(hostPath);
|
||||
}
|
||||
|
||||
// File path. Make it project-relative so two devs critiquing the same
|
||||
// checkout get the same slug regardless of where their repo is cloned.
|
||||
const abs = path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
let rel = path.relative(cwd, abs);
|
||||
// If the target is outside cwd, fall back to the basename so we still
|
||||
// produce a stable slug (vs the absolute path, which would include
|
||||
// home dirs / usernames).
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
rel = path.basename(abs);
|
||||
}
|
||||
if (!rel || rel === '.' || rel === '') return null;
|
||||
return kebab(rel);
|
||||
}
|
||||
|
||||
function kebab(s) {
|
||||
const slug = s
|
||||
.toLowerCase()
|
||||
.replace(/[/\\.]+/g, '-')
|
||||
.replace(/[^a-z0-9-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
if (!slug) return null;
|
||||
// Cap from the tail — the tail (filename) is more identifying than the
|
||||
// top-level directory.
|
||||
return slug.length <= SLUG_MAX ? slug : slug.slice(slug.length - SLUG_MAX).replace(/^-/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Filename-safe UTC ISO timestamp: hyphens for separators, trailing Z.
|
||||
* Plain colons aren't allowed on Windows filesystems.
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { slugFromTarget } from './target-slug.mjs';
|
||||
|
||||
export const SURFACE_BRIEF_VERSION = 1;
|
||||
|
||||
export function getSurfaceBriefDir(projectRoot) {
|
||||
return path.join(projectRoot, '.impeccable', 'surfaces');
|
||||
}
|
||||
|
||||
export function normalizeSurfaceTarget(target, { projectRoot = process.cwd() } = {}) {
|
||||
if (!target || typeof target !== 'string' || !target.trim()) return null;
|
||||
const trimmed = target.trim();
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
url.hash = '';
|
||||
url.search = '';
|
||||
return url.toString().replace(/\/$/, '') || url.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (/^route:/i.test(trimmed)) {
|
||||
const route = trimmed.slice(trimmed.indexOf(':') + 1).trim();
|
||||
if (!route.startsWith('/') || route.includes('..')) return null;
|
||||
const normalizedRoute = route.split(/[?#]/, 1)[0].replace(/\/{2,}/g, '/').replace(/\/$/, '') || '/';
|
||||
return `route:${normalizedRoute}`;
|
||||
}
|
||||
if (trimmed === '/') return 'route:/';
|
||||
if (trimmed.startsWith('/')) {
|
||||
const absolute = path.resolve(trimmed);
|
||||
const relativeToProject = path.relative(projectRoot, absolute);
|
||||
const isProjectFile = relativeToProject && !relativeToProject.startsWith('..') && !path.isAbsolute(relativeToProject);
|
||||
if (!isProjectFile && !fs.existsSync(absolute) && !trimmed.includes('..')) {
|
||||
const normalizedRoute = trimmed.split(/[?#]/, 1)[0].replace(/\/{2,}/g, '/').replace(/\/$/, '') || '/';
|
||||
return `route:${normalizedRoute}`;
|
||||
}
|
||||
}
|
||||
const abs = path.isAbsolute(trimmed) ? trimmed : path.resolve(projectRoot, trimmed);
|
||||
const rel = path.relative(projectRoot, abs);
|
||||
if (!rel || rel === '.' || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
||||
return rel.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
export function surfaceBriefPathForTarget(target, { projectRoot = process.cwd() } = {}) {
|
||||
const normalized = normalizeSurfaceTarget(target, { projectRoot });
|
||||
if (!normalized) return null;
|
||||
const slugInput = normalized.startsWith('route:') ? `route${normalized.slice('route:'.length)}` : normalized;
|
||||
const slug = slugFromTarget(slugInput, { cwd: projectRoot });
|
||||
return slug ? path.join(getSurfaceBriefDir(projectRoot), `${slug}.md`) : null;
|
||||
}
|
||||
|
||||
export function parseSurfaceBrief(text, filePath = null) {
|
||||
const match = String(text || '').match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
||||
const meta = {};
|
||||
if (match) {
|
||||
for (const line of match[1].split(/\r?\n/)) {
|
||||
const colon = line.indexOf(':');
|
||||
if (colon < 0) continue;
|
||||
const key = line.slice(0, colon).trim();
|
||||
const raw = line.slice(colon + 1).trim();
|
||||
if (!key) continue;
|
||||
if (/^(?:\[|\{|\")/.test(raw) || /^(?:true|false|null|-?\d+(?:\.\d+)?)$/.test(raw)) {
|
||||
try { meta[key] = JSON.parse(raw); continue; } catch { /* keep string */ }
|
||||
}
|
||||
meta[key] = raw.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
}
|
||||
const primaryTarget = typeof meta.primary_target === 'string' ? meta.primary_target : null;
|
||||
const relatedTargets = Array.isArray(meta.related_targets)
|
||||
? meta.related_targets.filter((value) => typeof value === 'string')
|
||||
: [];
|
||||
return {
|
||||
path: filePath,
|
||||
text: String(text || ''),
|
||||
body: match ? String(text || '').slice(match[0].length).trim() : String(text || '').trim(),
|
||||
meta,
|
||||
slug: typeof meta.slug === 'string' ? meta.slug : filePath ? path.basename(filePath, '.md') : null,
|
||||
primaryTarget,
|
||||
relatedTargets,
|
||||
targets: [primaryTarget, ...relatedTargets].filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
export function listSurfaceBriefs(projectRoot = process.cwd()) {
|
||||
const dir = getSurfaceBriefDir(projectRoot);
|
||||
let names;
|
||||
try {
|
||||
names = fs.readdirSync(dir).filter((name) => name.endsWith('.md')).sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return names.flatMap((name) => {
|
||||
const filePath = path.join(dir, name);
|
||||
try {
|
||||
return [parseSurfaceBrief(fs.readFileSync(filePath, 'utf-8'), filePath)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveSurfaceBrief(projectRoot = process.cwd(), target = null) {
|
||||
const briefs = listSurfaceBriefs(projectRoot);
|
||||
if (!target) {
|
||||
return {
|
||||
brief: briefs.length === 1 ? briefs[0] : null,
|
||||
candidates: briefs,
|
||||
reason: briefs.length === 1 ? 'only-brief' : briefs.length > 1 ? 'ambiguous' : 'none',
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = normalizeSurfaceTarget(target, { projectRoot });
|
||||
if (!normalized) return { brief: null, candidates: briefs, reason: 'invalid-target' };
|
||||
const exactPath = surfaceBriefPathForTarget(normalized, { projectRoot });
|
||||
const exact = briefs.find((brief) => brief.path === exactPath && (!brief.targets.length || brief.targets.includes(normalized)));
|
||||
if (exact) return { brief: exact, candidates: briefs, reason: 'slug' };
|
||||
const mapped = briefs.filter((brief) => brief.targets.includes(normalized));
|
||||
return {
|
||||
brief: mapped.length === 1 ? mapped[0] : null,
|
||||
candidates: mapped.length > 1 ? mapped : briefs,
|
||||
reason: mapped.length === 1 ? 'mapping' : mapped.length > 1 ? 'ambiguous-target' : 'not-found',
|
||||
};
|
||||
}
|
||||
|
||||
export function writeSurfaceBrief({
|
||||
projectRoot = process.cwd(),
|
||||
primaryTarget,
|
||||
relatedTargets = [],
|
||||
body,
|
||||
}) {
|
||||
const normalizedPrimary = normalizeSurfaceTarget(primaryTarget, { projectRoot });
|
||||
if (!normalizedPrimary) throw new Error('surface brief requires a concrete project-relative primary target or URL');
|
||||
const normalizedRelated = [...new Set(relatedTargets
|
||||
.map((target) => normalizeSurfaceTarget(target, { projectRoot }))
|
||||
.filter((target) => target && target !== normalizedPrimary))];
|
||||
const slug = slugFromTarget(normalizedPrimary, { cwd: projectRoot });
|
||||
const filePath = surfaceBriefPathForTarget(normalizedPrimary, { projectRoot });
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const frontmatter = [
|
||||
'---',
|
||||
`version: ${SURFACE_BRIEF_VERSION}`,
|
||||
`slug: ${JSON.stringify(slug)}`,
|
||||
`primary_target: ${JSON.stringify(normalizedPrimary)}`,
|
||||
`related_targets: ${JSON.stringify(normalizedRelated)}`,
|
||||
'---',
|
||||
].join('\n');
|
||||
fs.writeFileSync(filePath, `${frontmatter}\n\n${String(body || '').trim()}\n`, 'utf-8');
|
||||
return filePath;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import path from 'node:path';
|
||||
|
||||
const SLUG_MAX = 50;
|
||||
|
||||
/** Derive one clone-stable slug from a concrete file path or URL. */
|
||||
export function slugFromTarget(resolved, { cwd = process.cwd() } = {}) {
|
||||
if (!resolved || typeof resolved !== 'string') return null;
|
||||
const trimmed = resolved.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
let url;
|
||||
try { url = new URL(trimmed); } catch { return null; }
|
||||
return kebab(`${url.hostname}${url.pathname}`);
|
||||
}
|
||||
|
||||
const abs = path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
let rel = path.relative(cwd, abs);
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) rel = path.basename(abs);
|
||||
if (!rel || rel === '.') return null;
|
||||
return kebab(rel);
|
||||
}
|
||||
|
||||
export function kebab(value) {
|
||||
const slug = String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[/\\.]+/g, '-')
|
||||
.replace(/[^a-z0-9-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
if (!slug) return null;
|
||||
return slug.length <= SLUG_MAX ? slug : slug.slice(slug.length - SLUG_MAX).replace(/^-/, '');
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { resolveProjectRoot } from './context.mjs';
|
||||
import {
|
||||
listSurfaceBriefs,
|
||||
resolveSurfaceBrief,
|
||||
surfaceBriefPathForTarget,
|
||||
writeSurfaceBrief,
|
||||
} from './lib/surface-briefs.mjs';
|
||||
|
||||
function summary(brief, projectRoot) {
|
||||
return {
|
||||
slug: brief.slug,
|
||||
path: path.relative(projectRoot, brief.path).split(path.sep).join('/'),
|
||||
primaryTarget: brief.primaryTarget,
|
||||
relatedTargets: brief.relatedTargets,
|
||||
};
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const [command, target, bodyFile, ...relatedTargets] = argv;
|
||||
const projectRoot = resolveProjectRoot(process.cwd(), target ? { targetPath: target } : {});
|
||||
if (command === 'path') {
|
||||
const filePath = surfaceBriefPathForTarget(target, { projectRoot });
|
||||
if (!filePath) throw new Error('surface brief path requires a concrete target');
|
||||
process.stdout.write(`${path.relative(process.cwd(), filePath) || filePath}\n`);
|
||||
return;
|
||||
}
|
||||
if (command === 'list') {
|
||||
process.stdout.write(`${JSON.stringify(listSurfaceBriefs(projectRoot).map((brief) => summary(brief, projectRoot)), null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
if (command === 'read') {
|
||||
const result = resolveSurfaceBrief(projectRoot, target || null);
|
||||
if (result.brief) {
|
||||
process.stdout.write(result.brief.text);
|
||||
return;
|
||||
}
|
||||
if (result.candidates.length) process.stderr.write(`${JSON.stringify(result.candidates.map((brief) => summary(brief, projectRoot)), null, 2)}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
if (command === 'write') {
|
||||
if (!target || !bodyFile) throw new Error('usage: surface-brief.mjs write <primary-target> <body-file>');
|
||||
const filePath = writeSurfaceBrief({
|
||||
projectRoot,
|
||||
primaryTarget: target,
|
||||
relatedTargets,
|
||||
body: fs.readFileSync(bodyFile, 'utf-8'),
|
||||
});
|
||||
process.stdout.write(`${path.relative(process.cwd(), filePath) || filePath}\n`);
|
||||
return;
|
||||
}
|
||||
throw new Error('usage: surface-brief.mjs <path|list|read|write> [target] [body-file] [related-target ...]');
|
||||
}
|
||||
|
||||
function isMainModule() {
|
||||
if (!process.argv[1]) return false;
|
||||
try {
|
||||
return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(process.argv[1]);
|
||||
} catch {
|
||||
return import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
try {
|
||||
main(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error?.message || error}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user