Skip to content
Merged
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
3 changes: 2 additions & 1 deletion agent-context.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@
"todo",
"issue",
"work-history",
"overall-flow"
"overall-flow",
"handoff"
],
"schema": {
"required": [
Expand Down
51 changes: 38 additions & 13 deletions tools/ac.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// not tool-call transcripts. "used tool X" is noise; "result was Y, verify at Z"
// is signal. refs[] holds verification links.
import { spawnSync } from 'node:child_process';
import { existsSync, readdirSync, writeFileSync, mkdirSync } from 'node:fs';
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';

const TOOLS = new URL('.', import.meta.url).pathname;
Expand All @@ -31,6 +31,17 @@ function today() { return new Date().toISOString().slice(0, 10); }
function slug(s) {
return String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'entry';
}
// Issue #10 fix: YAML-safe double-quoted scalar — escape \ and ", flatten CR/LF,
// so user input (title/summary/refs) can never break out of the frontmatter block
// and inject arbitrary YAML fields (e.g. priority override via embedded newline).
function yq(s) {
return '"' + String(s).replace(/[\r\n]+/g, ' ').replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
// Issue #10 fix: filename-safe component — strip path separators / reserved chars
// so --agent or title can never traverse directories or break the path.
function fnamePart(s) {
return String(s).replace(/[\/\\:*?"<>|]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'x';
}
function resolveRoot() {
const cwd = process.cwd();
if (existsSync(join(cwd, 'agent-context.config.json')) || existsSync(join(cwd, 'agent-context')))
Expand All @@ -44,7 +55,9 @@ function pluralDir(type) {
return map[type] || 'notes';
}
function nextId(type) {
return `${type}-${today().replace(/-/g, '')}-${Math.random().toString(16).slice(2, 10)}`;
// Issue #10 fix: pad the random hex to 8 chars so it always matches idPattern
// (^[a-z-]+-[0-9]{8}-[a-z0-9]{8}$) — Math.random().toString(16) can be shorter.
return `${type}-${today().replace(/-/g, '')}-${Math.random().toString(16).slice(2, 10).padEnd(8, '0')}`;
}

// Generic entry creator — outcome-based template with refs field
Expand All @@ -55,43 +68,55 @@ function createEntry(opts) {
const dir = pluralDir(type);
const targetDir = join(root, dir);
mkdirSync(targetDir, { recursive: true });
const id = nextId(type);
// Issue #10 fix: unique filenames. slug() strips non-ASCII, so Korean/CJK/emoji
// titles all collapse to 'entry' — silently overwriting each other (data loss).
// If the slug fell back, or the computed filename already exists, disambiguate
// with the random hex from the entry id.
const idHex = id.split('-').pop();
let fname;
const baseSlug = slug(title);
const uniqSlug = baseSlug === 'entry' ? `entry-${idHex}` : baseSlug;
if (type === 'decision') {
let n = 1;
try { n = readdirSync(targetDir).filter(f => /^\d{4}-/.test(f)).length + 1; } catch {}
fname = `${String(n).padStart(4, '0')}-${slug(title)}.md`;
fname = `${String(n).padStart(4, '0')}-${uniqSlug}.md`;
} else if (type === 'diary') {
fname = `${today()}.md`;
} else {
fname = `${today()}-${slug(title)}--${agent}.md`;
fname = `${today()}-${uniqSlug}--${fnamePart(agent)}.md`;
}
if (type !== 'diary' && existsSync(join(targetDir, fname))) {
// same-day same-title collision — never overwrite an existing entry
fname = fname.replace(/\.md$/, `-${idHex}.md`);
}
const path = join(targetDir, fname);
if (existsSync(path) && type === 'diary') {
// diary append-only: append a section instead of failing
const prev = readdirSync(targetDir).includes(fname)
? require('fs').readFileSync(path, 'utf8') : '';
? readFileSync(path, 'utf8') : '';
writeFileSync(path, prev + `\n## ${new Date().toTimeString().slice(0,5)} ${agent} — ${title}\n- ${summary}\n`, 'utf8');
} else {
const lines = [
`<!-- Path: agent-context/${dir}/${fname} -->`,
'---',
`id: ${nextId(type)}`,
`id: ${id}`,
`type: ${type}`,
`title: "${String(title).slice(0, 80)}"`,
`title: ${yq(String(title).slice(0, 80))}`,
`tags: [${type}]`,
`feature: ${feature}`,
`feature: ${yq(feature)}`,
`level: ""`,
`scope: global`,
`agent: ${agent}`,
`agent: ${yq(agent)}`,
`created: ${new Date().toISOString()}`,
`updated: ${new Date().toISOString()}`,
`status: ${status}`,
`priority: ${priority}`,
`summary: "${String(summary || title).slice(0, 180)}"`,
`status: ${yq(status)}`,
`priority: ${Number.isInteger(priority) && priority >= 1 && priority <= 5 ? priority : 3}`,
`summary: ${yq(String(summary || title).slice(0, 180))}`,
];
if (refs.length) {
lines.push('refs:');
refs.forEach(r => lines.push(` - "${r}"`));
refs.forEach(r => lines.push(` - ${yq(r)}`));
}
lines.push('---', '', body || '## 결과\n\n(도구 호출 로그 아님 — 결론만 기록. 검증은 refs 링크로)\n');
writeFileSync(path, lines.join('\n') + '\n', 'utf8');
Expand Down
31 changes: 28 additions & 3 deletions tools/agent-context-index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// 사용: node tools/agent-context-index.mjs [--check] [--init] [--config <path>] [--dry-run] [--to-sqlite]

import { readdirSync, readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs';
import { join, relative, dirname } from 'node:path';
import { join, relative, dirname, basename } from 'node:path';

function parseArgs() {
const args = process.argv.slice(2);
Expand Down Expand Up @@ -170,7 +170,7 @@ if (ARGS.init) {
const softLimits = CONFIG.storage?.softLimits || { softLimitChars: 200000, maxEntries: 1000 };
const features = CONFIG.features || {};
const edges = CONFIG.graph?.edges || [];
const types = CONFIG.types || ["note","memo","idea","learning","bug","decision","diary","code-history","todo"];
const types = CONFIG.types || ["note","memo","idea","learning","bug","decision","diary","code-history","todo","issue","work-history","overall-flow","handoff"];
const featureEnum = Object.keys(features).length ? [...Object.keys(features), "global"] : ["global"];
const agents = CONFIG.agents?.allow || ["claude","codex","opencode","human","system"];

Expand Down Expand Up @@ -285,11 +285,36 @@ const files = walk(ROOT);
const entries = [];
let totalChars = 0;

// Issue #10 fix: lint gate — when CONFIG.lint.onIndexRegenerate is true, skip
// entries that would fail validation (missing required fields, id pattern mismatch).
// Also warn on files without frontmatter (except CURRENT.md/README.md) to surface
// the validator/indexer count mismatch.
const EXEMPT_NO_FM = new Set(['CURRENT.md','README.md']);
const required = CONFIG.schema?.required || ["id","type","title","tags","feature","agent","created","updated","status","summary"];
const idRe = new RegExp(CONFIG.schema?.idPattern || "^[a-z-]+-[0-9]{8}-[a-z0-9]{8}$");
const gateOn = CONFIG.lint?.onIndexRegenerate === true;
let skippedNoFm = 0, skippedLint = 0;

for (const f of files) {
const src = readFileSync(f, 'utf8');
const fm = parseFrontmatter(src);
if (!fm || !fm.id) continue;
const rel = relative(ROOT, f).replace(/\\/g, '/');
const base = basename(f);
if (!fm || !fm.id) {
if (!EXEMPT_NO_FM.has(base)) {
skippedNoFm++;
console.warn(`skip (no frontmatter/id): ${rel}`);
}
continue;
}
if (gateOn) {
const missing = required.filter(r => !(r in fm) || String(fm[r]).trim()==='');
if (missing.length || !idRe.test(String(fm.id))) {
skippedLint++;
console.warn(`skip (lint.onIndexRegenerate): ${rel}${missing.length?` missing ${missing.join(',')}`:''}${!idRe.test(String(fm.id))?' id pattern mismatch':''}`);
continue;
}
}
const fmMatch = src.match(/---\s*\n[\s\S]*?\n---\s*\n/);
const body = fmMatch ? src.slice(fmMatch.index + fmMatch[0].length) : src;
const chars = src.length;
Expand Down
58 changes: 39 additions & 19 deletions tools/agent-context-init.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// Issue #3 fix: scaffold into process.cwd() (or --target), NEVER the script's
// install location. This makes `npx agent-shared-context init` write into the
// user's actual project instead of the npx cache / source clone.
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { createInterface } from 'node:readline';

Expand Down Expand Up @@ -50,24 +50,44 @@ const displayName = await prompt('Display name', name);
const featsStr = featuresArg || await prompt('Features (comma-separated)', 'auth,api,ui');
const featuresList = featsStr.split(',').map(s=>s.trim()).filter(Boolean);

const config = {
$schema: "./docs/config.schema.json",
version: 1,
project: { name, displayName, prefix: name.slice(0,2), description: `${displayName} — agent-context enabled` },
contextRoot,
archiveDir: "archive",
privateMirror: null,
features: Object.fromEntries(featuresList.map(f=> [f, { label: f[0].toUpperCase()+f.slice(1), files: [`src/${f}/index.ts:1`], description: `${f} feature` }])),
graph: { edges: featuresList.length>=2 ? [[featuresList[1], featuresList[0]]] : [] },
types: ["note","memo","idea","learning","bug","decision","diary","code-history","todo","issue"],
typesFluid: true,
schema: { required: ["id","type","title","tags","feature","agent","created","updated","status","summary"], featureEnum: "auto", idPattern: "^[a-z-]+-[0-9]{8}-[a-z0-9]{8}$", maxSummary: 200, maxPreview: 60 },
storage: { backend: "json", softLimits: { softLimitChars: 200000, maxEntries: 1000, archiveAfterDays: 90 } },
lint: { onIndexRegenerate: true, forbidWriteOverwrite: true, requiredKeywords: false },
agents: { allow: ["claude","codex","opencode","human","system"], default: "system" },
i18n: { locales: ["ko","en"], defaultLocale: "en" },
compliance: { law: false, cssContract: false },
};
// Issue #10 fix: use the repo's own agent-context.config.json as the scaffold
// template (single source of truth) so new fields (search.synonyms, hierarchy,
// live, newer types, …) automatically reach scaffolded projects instead of
// drifting behind this file's hardcoded object. Falls back to a hardcoded
// minimal config only when the sibling file is missing (e.g. trimmed package).
function loadTemplateConfig() {
const p = new URL('../agent-context.config.json', import.meta.url).pathname;
try {
return JSON.parse(readFileSync(p, 'utf8'));
} catch {
return {
$schema: "./docs/config.schema.json",
version: 1,
project: {},
contextRoot,
archiveDir: "archive",
privateMirror: null,
features: {},
graph: { edges: [] },
types: ["note","memo","idea","learning","bug","decision","diary","code-history","todo","issue","work-history","overall-flow","handoff"],
typesFluid: true,
schema: { required: ["id","type","title","tags","feature","agent","created","updated","status","summary"], featureEnum: "auto", idPattern: "^[a-z-]+-[0-9]{8}-[a-z0-9]{8}$", maxSummary: 200, maxPreview: 60 },
storage: { backend: "json", softLimits: { softLimitChars: 200000, maxEntries: 1000, archiveAfterDays: 90 } },
lint: { onIndexRegenerate: true, forbidWriteOverwrite: true, requiredKeywords: false },
agents: { allow: ["claude","codex","opencode","human","system"], default: "system" },
i18n: { locales: ["ko","en"], defaultLocale: "en" },
compliance: { law: false, cssContract: false },
};
}
}

const config = loadTemplateConfig();
config.version = 1;
config.project = { name, displayName, prefix: name.slice(0,2), description: `${displayName} — agent-context enabled` };
config.contextRoot = contextRoot;
config.privateMirror = null;
config.features = Object.fromEntries(featuresList.map(f=> [f, { label: f[0].toUpperCase()+f.slice(1), files: [`src/${f}/index.ts:1`], description: `${f} feature` }]));
config.graph = { edges: featuresList.length>=2 ? [[featuresList[1], featuresList[0]]] : [] };

const configPath = join(ROOT, 'agent-context.config.json');
if (!existsSync(configPath)) {
Expand Down
21 changes: 17 additions & 4 deletions tools/agent-context-validate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Path: tools/agent-context-validate.mjs
// frontmatter lint — agent-context.config.json schema + required 10 검증
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { join, basename } from 'node:path';

function resolveConfig(explicit) {
const candidates = [
Expand All @@ -11,7 +11,7 @@ function resolveConfig(explicit) {
new URL('../agent-context/agent-context.config.json', import.meta.url).pathname,
].filter(Boolean);
for (const p of candidates) if (existsSync(p)) return JSON.parse(readFileSync(p,'utf8'));
return { contextRoot: 'agent-context', features: {}, types: ["note","memo","idea","learning","bug","decision","diary","code-history","todo"], agents: { allow: ["claude","codex","opencode","human","system"] } };
return { contextRoot: 'agent-context', features: {}, types: ["note","memo","idea","learning","bug","decision","diary","code-history","todo","issue","work-history","overall-flow","handoff"], agents: { allow: ["claude","codex","opencode","human","system"] } };
}

const configArgIndex = process.argv.indexOf('--config');
Expand Down Expand Up @@ -72,13 +72,26 @@ function walk(dir, out=[]) {
const files = walk(ROOT);
let errors = 0;
const required = schema.required || ["id","type","title","tags","feature","agent","created","updated","status","summary"];
// Issue #10 fix: CURRENT.md is a generated pointer file without frontmatter — exempt
// from the no-frontmatter check. README.md already skipped.
const EXEMPT_NO_FM = new Set(['CURRENT.md','README.md']);

for (const f of files) {
if (f.endsWith('README.md')) continue;
const src = readFileSync(f,'utf8');
const fm = parseFrontmatter(src);
if (!fm) continue;
if (!fm.id) continue;
// Issue #10 fix: files without frontmatter are not valid entries — FAIL (except
// CURRENT.md pointer). Previously silently passed, causing validator/indexer
// count mismatch.
if (!fm) {
if (!EXEMPT_NO_FM.has(basename(f))) {
console.error(`FAIL ${f}: no frontmatter`);
errors++;
}
continue;
}
// Issue #10 fix: removed `if (!fm.id) continue;` — missing id now caught by
// required-field check below (id is in required[]).
for (const r of required) {
if (!(r in fm) || String(fm[r]).trim()==='' ) { console.error(`FAIL ${f}: missing required '${r}'`); errors++; }
}
Expand Down
53 changes: 47 additions & 6 deletions tools/agent-handoff.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ function readIndex() {
try { return JSON.parse(readFileSync(INDEX_PATH, 'utf8')); } catch { return { entries: [] }; }
}

// Issue #10 fix: YAML-safe double-quoted scalar (prevents frontmatter injection
// via --task/--session containing quotes or newlines).
function yamlStr(s) {
return String(s).replace(/[\r\n]+/g, ' ').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}

// Issue #10 fix: names become filenames — reject path separators / traversal
// before any fs write (ENOENT crash / escape outside HANDOFF_DIR).
function isSafeFileName(name) {
return typeof name === 'string' && name.length > 0 && name.length <= 128
&& !/[\/\\\0]/.test(name) && !name.includes('..');
}

function recentEntries(n = 5) {
const idx = readIndex();
return (idx.entries || []).slice(0, n).map(e => ({
Expand All @@ -65,6 +78,10 @@ function save(args) {
console.error('save requires --session NAME --task "..."');
process.exit(1);
}
if (!isSafeFileName(args.session)) {
console.error(`invalid session name '${args.session}' — must not contain '/', '\\', '..' or control characters`);
process.exit(1);
}
const date = new Date().toISOString().slice(0, 10);
const fname = `${date}--${args.session}.md`;
const path = join(HANDOFF_DIR, fname);
Expand All @@ -75,7 +92,7 @@ function save(args) {
id: handoff-${date.replace(/-/g, '')}-${Math.random().toString(36).slice(2, 10)}
type: handoff
level: diary
title: "Session handoff — ${args.session}"
title: "Session handoff — ${yamlStr(args.session)}"
tags: [handoff, session]
feature: global
scope: global
Expand All @@ -84,7 +101,7 @@ created: ${new Date().toISOString()}
updated: ${new Date().toISOString()}
status: done
priority: 5
summary: "${args.task.slice(0, 120)}"
summary: "${yamlStr(args.task.slice(0, 120))}"
---

# Session Handoff — ${args.session}
Expand Down Expand Up @@ -139,15 +156,35 @@ function latestHandoff() {
}

function load(args) {
let path = args.file ? join(HANDOFF_DIR, args.file) : latestHandoff();
if (!path || !existsSync(path)) {
// fallback to CURRENT.md pointer
// Issue #10 fix: an EXPLICIT file arg that doesn't exist is an error (probably
// a typo) — erroring beats silently loading the wrong handoff via CURRENT.md.
if (args.file) {
if (!isSafeFileName(args.file)) {
return { error: `invalid handoff file name '${args.file}' — must not contain '/', '\\', '..' or control characters` };
}
const path = join(HANDOFF_DIR, args.file);
if (!existsSync(path)) {
return { error: `handoff not found: ${args.file}`, hint: "run 'list' to see available handoffs" };
}
return readHandoff(path);
}
const path = latestHandoff();
if (!path) {
// bare `load` with no handoffs yet → CURRENT.md pointer is a sane fallback
if (existsSync(CURRENT_PATH)) {
const cur = readFileSync(CURRENT_PATH, 'utf8');
return { source: 'CURRENT.md', tokens: Math.ceil(cur.length / 4), content: cur };
}
return { error: 'no handoff found; run save first' };
}
return readHandoff(path);
}

function readdirSyncSafe() {
try { return readdirSync(HANDOFF_DIR).filter(f => f.endsWith('.md')).sort().reverse(); } catch { return []; }
}

function readHandoff(path) {
const src = readFileSync(path, 'utf8');
const body = src.replace(/^---[\s\S]*?---\s*\n/, '');
return {
Expand Down Expand Up @@ -183,7 +220,11 @@ Zero install beyond Node ≥18.`);
process.exit(0);
}
if (ARGS.cmd === 'save') console.log(JSON.stringify(save(ARGS), null, 2));
else if (ARGS.cmd === 'load') console.log(JSON.stringify(load(ARGS), null, 2));
else if (ARGS.cmd === 'load') {
const res = load(ARGS);
if (res.error) { console.error(JSON.stringify(res, null, 2)); process.exit(1); }
console.log(JSON.stringify(res, null, 2));
}
else if (ARGS.cmd === 'list') console.log(JSON.stringify(list(), null, 2));
else if (ARGS.cmd === 'current') {
if (existsSync(CURRENT_PATH)) console.log(readFileSync(CURRENT_PATH, 'utf8'));
Expand Down
Loading
Loading