kodi-dev 1.0.1 → 1.2.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/assets/agents/build/build-orchestrator.md +17 -6
- package/assets/agents/build/refactor-engineer.md +85 -0
- package/assets/agents/build/security.md +31 -0
- package/assets/rules/memory.md +48 -0
- package/assets/skills/remember/SKILL.md +56 -0
- package/assets/skills/retickets/SKILL.md +85 -0
- package/assets/skills/ticket-start/SKILL.md +3 -2
- package/board-dist/.next/BUILD_ID +1 -1
- package/board-dist/.next/build-manifest.json +3 -3
- package/board-dist/.next/prerender-manifest.json +3 -3
- package/board-dist/.next/server/app/_global-error.html +1 -1
- package/board-dist/.next/server/app/_global-error.rsc +1 -1
- package/board-dist/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/board-dist/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/board-dist/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/board-dist/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/board-dist/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/board-dist/.next/server/app/_not-found.html +1 -1
- package/board-dist/.next/server/app/_not-found.rsc +1 -1
- package/board-dist/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/board-dist/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/board-dist/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/board-dist/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/board-dist/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/board-dist/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/board-dist/.next/server/app/page/server-reference-manifest.json +1 -1
- package/board-dist/.next/server/app/page_client-reference-manifest.js +1 -1
- package/board-dist/.next/server/chunks/ssr/[root-of-the-server]__0_2b22l._.js +1 -1
- package/board-dist/.next/server/chunks/ssr/[root-of-the-server]__0eauvyk._.js +1 -1
- package/board-dist/.next/server/chunks/ssr/_1wslo41._.js +1 -1
- package/board-dist/.next/server/middleware-build-manifest.js +3 -3
- package/board-dist/.next/server/pages/404.html +1 -1
- package/board-dist/.next/server/pages/500.html +1 -1
- package/board-dist/.next/server/server-reference-manifest.js +1 -1
- package/board-dist/.next/server/server-reference-manifest.json +2 -2
- package/board-dist/.next/static/chunks/{2b8wyk_yjz96q.js → 3pq88fk6tzyrb.js} +1 -1
- package/dist/index.js +1393 -272
- package/package.json +2 -2
- /package/board-dist/.next/static/{jxv0GcmWplyzie2rdOiIo → I_lHe5DQGPJBZViq6L3bS}/_buildManifest.js +0 -0
- /package/board-dist/.next/static/{jxv0GcmWplyzie2rdOiIo → I_lHe5DQGPJBZViq6L3bS}/_clientMiddlewareManifest.js +0 -0
- /package/board-dist/.next/static/{jxv0GcmWplyzie2rdOiIo → I_lHe5DQGPJBZViq6L3bS}/_ssgManifest.js +0 -0
package/dist/index.js
CHANGED
|
@@ -98,6 +98,10 @@ function registerAddCommand(program2) {
|
|
|
98
98
|
});
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// src/commands/hook.ts
|
|
102
|
+
import { existsSync as existsSync4 } from "fs";
|
|
103
|
+
import { isAbsolute as isAbsolute3, relative as relative2 } from "path";
|
|
104
|
+
|
|
101
105
|
// src/bootstrap.ts
|
|
102
106
|
var ORCHESTRATOR_BOOTSTRAP = `# You are the kodi orchestrator
|
|
103
107
|
|
|
@@ -138,43 +142,19 @@ board \u2014 there is no message bus.
|
|
|
138
142
|
- Manage tickets and PRs ONLY through the \`kodi\` CLI (\`kodi tickets \u2026\`,
|
|
139
143
|
\`kodi pr \u2026\`) \u2014 it proxies \`gh\`/\`az\` and enforces the templates. Remote
|
|
140
144
|
mutations are dry-run unless \`--yes\`.
|
|
145
|
+
- Persist and recall project knowledge with \`kodi memory\`: \`kodi memory query
|
|
146
|
+
<text>\` before working a subsystem, and \`kodi memory store\` a durable finding
|
|
147
|
+
(decision / gotcha / convention / architecture / reference / task-note) as you
|
|
148
|
+
learn it. Cross-session and project-scoped \u2014 see the memory rule.
|
|
141
149
|
- The thin \`CLAUDE.md\` is the single source of truth for the stack, gate
|
|
142
150
|
commands, provider, and installed skill-packs.
|
|
143
151
|
`;
|
|
144
152
|
|
|
145
|
-
// src/commands/hook.ts
|
|
146
|
-
function registerHookCommand(program2) {
|
|
147
|
-
const hook = program2.command("hook").description("Emit Claude Code hook output (internal)");
|
|
148
|
-
hook.command("session-start").description("Emit the orchestrator bootstrap as SessionStart additionalContext").action(() => {
|
|
149
|
-
const payload = {
|
|
150
|
-
hookSpecificOutput: {
|
|
151
|
-
hookEventName: "SessionStart",
|
|
152
|
-
additionalContext: ORCHESTRATOR_BOOTSTRAP
|
|
153
|
-
}
|
|
154
|
-
};
|
|
155
|
-
process.stdout.write(JSON.stringify(payload));
|
|
156
|
-
});
|
|
157
|
-
return hook;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// src/commands/init.ts
|
|
161
|
-
import {
|
|
162
|
-
copyFileSync as copyFileSync2,
|
|
163
|
-
existsSync as existsSync4,
|
|
164
|
-
mkdirSync as mkdirSync3,
|
|
165
|
-
readdirSync as readdirSync2,
|
|
166
|
-
readFileSync as readFileSync4,
|
|
167
|
-
statSync as statSync2,
|
|
168
|
-
writeFileSync as writeFileSync3
|
|
169
|
-
} from "fs";
|
|
170
|
-
import { dirname as dirname4, join as join3, relative as relative2 } from "path";
|
|
171
|
-
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
172
|
-
import { stringify as stringifyYaml2 } from "yaml";
|
|
173
|
-
|
|
174
153
|
// src/config.ts
|
|
175
|
-
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
154
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
155
|
+
import { homedir } from "os";
|
|
176
156
|
import { dirname as dirname3, join as join2 } from "path";
|
|
177
|
-
import { parse as parseYaml3 } from "yaml";
|
|
157
|
+
import { parse as parseYaml3, stringify as stringifyYaml2 } from "yaml";
|
|
178
158
|
|
|
179
159
|
// src/providers/status-index.ts
|
|
180
160
|
import {
|
|
@@ -260,21 +240,21 @@ var KEY_RE = /^[A-Z][A-Z0-9]*-\d+$/;
|
|
|
260
240
|
var SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
261
241
|
var FILE_RE = /^([A-Z][A-Z0-9]*-\d+)-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$/;
|
|
262
242
|
function slugForStatus(status) {
|
|
263
|
-
const
|
|
264
|
-
if (
|
|
243
|
+
const slug2 = STATUS_TO_SLUG[status];
|
|
244
|
+
if (slug2 === void 0) {
|
|
265
245
|
throw new Error(`unknown ticket status: ${String(status)}`);
|
|
266
246
|
}
|
|
267
|
-
return
|
|
247
|
+
return slug2;
|
|
268
248
|
}
|
|
269
249
|
function assertKey(key) {
|
|
270
250
|
if (!KEY_RE.test(key)) {
|
|
271
251
|
throw new Error(`invalid ticket key ${JSON.stringify(key)}: must match ${KEY_RE.source}`);
|
|
272
252
|
}
|
|
273
253
|
}
|
|
274
|
-
function assertSlug(
|
|
275
|
-
if (!SLUG_RE.test(
|
|
254
|
+
function assertSlug(slug2) {
|
|
255
|
+
if (!SLUG_RE.test(slug2)) {
|
|
276
256
|
throw new Error(
|
|
277
|
-
`invalid slug ${JSON.stringify(
|
|
257
|
+
`invalid slug ${JSON.stringify(slug2)}: must be strict kebab-case (${SLUG_RE.source})`
|
|
278
258
|
);
|
|
279
259
|
}
|
|
280
260
|
}
|
|
@@ -284,10 +264,10 @@ function isTicketStatus(value) {
|
|
|
284
264
|
function freshTickets() {
|
|
285
265
|
return /* @__PURE__ */ Object.create(null);
|
|
286
266
|
}
|
|
287
|
-
function composeFile(status, key,
|
|
267
|
+
function composeFile(status, key, slug2) {
|
|
288
268
|
assertKey(key);
|
|
289
|
-
assertSlug(
|
|
290
|
-
return `${slugForStatus(status)}/${key}-${
|
|
269
|
+
assertSlug(slug2);
|
|
270
|
+
return `${slugForStatus(status)}/${key}-${slug2}.md`;
|
|
291
271
|
}
|
|
292
272
|
function emptyDocument() {
|
|
293
273
|
return {
|
|
@@ -302,12 +282,12 @@ function serialize(doc) {
|
|
|
302
282
|
const entry = doc.tickets[key];
|
|
303
283
|
tickets[key] = { column: entry.column, file: entry.file };
|
|
304
284
|
}
|
|
305
|
-
const
|
|
285
|
+
const out3 = {
|
|
306
286
|
version: doc.version,
|
|
307
287
|
columns: [...TICKET_STATUSES],
|
|
308
288
|
tickets
|
|
309
289
|
};
|
|
310
|
-
const text = stringifyYaml(
|
|
290
|
+
const text = stringifyYaml(out3, { lineWidth: 0 });
|
|
311
291
|
return text.endsWith("\n") ? text : `${text}
|
|
312
292
|
`;
|
|
313
293
|
}
|
|
@@ -406,6 +386,13 @@ function resolveFile(statusYamlPath, key, entry) {
|
|
|
406
386
|
}
|
|
407
387
|
|
|
408
388
|
// src/config.ts
|
|
389
|
+
function kodiHome() {
|
|
390
|
+
const override = process.env.KODI_HOME?.trim();
|
|
391
|
+
return override && override.length > 0 ? override : join2(homedir(), ".kodi");
|
|
392
|
+
}
|
|
393
|
+
function ragDbPath() {
|
|
394
|
+
return join2(kodiHome(), "rag.db");
|
|
395
|
+
}
|
|
409
396
|
var DEFAULTS = { provider: "local", prefix: "KODI" };
|
|
410
397
|
var STATE_FILE = "kodi-dev.yaml";
|
|
411
398
|
function stateFilePath(root) {
|
|
@@ -430,6 +417,18 @@ function loadBoardConfig(cwd = process.cwd()) {
|
|
|
430
417
|
return { ...DEFAULTS };
|
|
431
418
|
}
|
|
432
419
|
}
|
|
420
|
+
function writeMemoryBinding(root, memory) {
|
|
421
|
+
const path = stateFilePath(root);
|
|
422
|
+
if (!existsSync3(path)) return;
|
|
423
|
+
let raw = {};
|
|
424
|
+
try {
|
|
425
|
+
raw = parseYaml3(readFileSync3(path, "utf-8")) ?? {};
|
|
426
|
+
} catch {
|
|
427
|
+
raw = {};
|
|
428
|
+
}
|
|
429
|
+
raw.memory = memory;
|
|
430
|
+
writeFileSync3(path, stringifyYaml2(raw), "utf-8");
|
|
431
|
+
}
|
|
433
432
|
function localPaths(cwd = process.cwd()) {
|
|
434
433
|
const root = join2(findProjectRoot(cwd), "docs", "tickets");
|
|
435
434
|
return {
|
|
@@ -443,6 +442,774 @@ function localPaths(cwd = process.cwd()) {
|
|
|
443
442
|
};
|
|
444
443
|
}
|
|
445
444
|
|
|
445
|
+
// src/memory/db.ts
|
|
446
|
+
import { mkdirSync as mkdirSync3 } from "fs";
|
|
447
|
+
import { createRequire } from "module";
|
|
448
|
+
import { dirname as dirname4 } from "path";
|
|
449
|
+
var nodeRequire = createRequire(import.meta.url);
|
|
450
|
+
var nodeSqlite = nodeRequire(["node", "sqlite"].join(":"));
|
|
451
|
+
var SCHEMA = `
|
|
452
|
+
CREATE TABLE IF NOT EXISTS collections (
|
|
453
|
+
id TEXT PRIMARY KEY,
|
|
454
|
+
name TEXT NOT NULL,
|
|
455
|
+
root_path TEXT UNIQUE,
|
|
456
|
+
created_at TEXT NOT NULL
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
460
|
+
id TEXT PRIMARY KEY,
|
|
461
|
+
collection_id TEXT NOT NULL,
|
|
462
|
+
content TEXT NOT NULL,
|
|
463
|
+
title TEXT NOT NULL,
|
|
464
|
+
type TEXT NOT NULL,
|
|
465
|
+
ticket TEXT,
|
|
466
|
+
files_json TEXT NOT NULL DEFAULT '[]',
|
|
467
|
+
created_at TEXT NOT NULL,
|
|
468
|
+
content_hash TEXT NOT NULL,
|
|
469
|
+
-- veracity score (see docs/memory-veracity-score.md): 0-5, fresh = 3.
|
|
470
|
+
score INTEGER NOT NULL DEFAULT 3,
|
|
471
|
+
status TEXT NOT NULL DEFAULT 'active', -- active | tombstoned
|
|
472
|
+
needs_reverify INTEGER NOT NULL DEFAULT 0,
|
|
473
|
+
file_hashes TEXT, -- JSON { path: sha256 }
|
|
474
|
+
verified_at TEXT,
|
|
475
|
+
tombstone_reason TEXT,
|
|
476
|
+
UNIQUE(collection_id, content_hash)
|
|
477
|
+
);
|
|
478
|
+
CREATE INDEX IF NOT EXISTS idx_memories_collection ON memories(collection_id);
|
|
479
|
+
|
|
480
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
|
481
|
+
memory_id UNINDEXED,
|
|
482
|
+
content,
|
|
483
|
+
title
|
|
484
|
+
);
|
|
485
|
+
`;
|
|
486
|
+
var VERACITY_COLUMNS = [
|
|
487
|
+
["score", "INTEGER NOT NULL DEFAULT 3"],
|
|
488
|
+
["status", "TEXT NOT NULL DEFAULT 'active'"],
|
|
489
|
+
["needs_reverify", "INTEGER NOT NULL DEFAULT 0"],
|
|
490
|
+
["file_hashes", "TEXT"],
|
|
491
|
+
["verified_at", "TEXT"],
|
|
492
|
+
["tombstone_reason", "TEXT"]
|
|
493
|
+
];
|
|
494
|
+
function migrate(db) {
|
|
495
|
+
const rows = db.prepare("PRAGMA table_info(memories)").all();
|
|
496
|
+
const cols = new Set(rows.map((c) => c.name));
|
|
497
|
+
for (const [name2, ddl] of VERACITY_COLUMNS) {
|
|
498
|
+
if (!cols.has(name2)) db.exec(`ALTER TABLE memories ADD COLUMN ${name2} ${ddl}`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function openDb(path = ragDbPath()) {
|
|
502
|
+
mkdirSync3(dirname4(path), { recursive: true });
|
|
503
|
+
const db = new nodeSqlite.DatabaseSync(path);
|
|
504
|
+
db.exec("PRAGMA busy_timeout = 5000;");
|
|
505
|
+
db.exec("PRAGMA journal_mode = WAL;");
|
|
506
|
+
db.exec("PRAGMA synchronous = NORMAL;");
|
|
507
|
+
db.exec("PRAGMA foreign_keys = ON;");
|
|
508
|
+
db.exec(SCHEMA);
|
|
509
|
+
migrate(db);
|
|
510
|
+
return db;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// src/memory/store.ts
|
|
514
|
+
import { createHash as createHash3, randomUUID } from "crypto";
|
|
515
|
+
import { basename as basename3 } from "path";
|
|
516
|
+
|
|
517
|
+
// src/memory/template.ts
|
|
518
|
+
import { createHash } from "crypto";
|
|
519
|
+
import { basename as basename2 } from "path";
|
|
520
|
+
import { z as z3 } from "zod";
|
|
521
|
+
var MEMORY_TYPES = [
|
|
522
|
+
"decision",
|
|
523
|
+
// a choice made + why (mini-ADR)
|
|
524
|
+
"gotcha",
|
|
525
|
+
// a trap / bug root-cause to not repeat
|
|
526
|
+
"convention",
|
|
527
|
+
// a project rule/pattern to follow
|
|
528
|
+
"architecture",
|
|
529
|
+
// how a subsystem/flow works
|
|
530
|
+
"reference",
|
|
531
|
+
// pointer to a doc/URL/resource
|
|
532
|
+
"task-note"
|
|
533
|
+
// context tied to a ticket's implementation
|
|
534
|
+
];
|
|
535
|
+
var MemoryDraftSchema = z3.object({
|
|
536
|
+
content: z3.string().trim().min(1, "content is required"),
|
|
537
|
+
type: z3.enum(MEMORY_TYPES),
|
|
538
|
+
/** Ticket/task in flight when this was learned — optional (null when absent). */
|
|
539
|
+
ticket: z3.string().trim().min(1).nullish().transform((v) => v ?? null),
|
|
540
|
+
/**
|
|
541
|
+
* Repo-relative paths the finding touches — at least one is REQUIRED: the veracity
|
|
542
|
+
* loop verifies a memory against its files, so a memory with no file can't be scored.
|
|
543
|
+
*/
|
|
544
|
+
files: z3.array(z3.string().trim().min(1)).min(1, "at least one --file is required"),
|
|
545
|
+
/** Optional display title; a preview of `content` is derived when omitted. */
|
|
546
|
+
title: z3.string().trim().min(1).optional()
|
|
547
|
+
});
|
|
548
|
+
var MemoryImportRecordSchema = z3.object({
|
|
549
|
+
content: z3.string().trim().min(1),
|
|
550
|
+
type: z3.enum(MEMORY_TYPES),
|
|
551
|
+
ticket: z3.string().trim().min(1).nullish().transform((v) => v ?? null),
|
|
552
|
+
files: z3.array(z3.string().trim().min(1)).min(1, "at least one file is required"),
|
|
553
|
+
title: z3.string().trim().min(1).optional(),
|
|
554
|
+
/** Preserved when present so an export→import keeps the original timestamp. */
|
|
555
|
+
createdAt: z3.string().trim().min(1).optional()
|
|
556
|
+
});
|
|
557
|
+
var MemoryExportDocSchema = z3.object({
|
|
558
|
+
collection: z3.string().optional(),
|
|
559
|
+
exportedAt: z3.string().optional(),
|
|
560
|
+
memories: z3.array(MemoryImportRecordSchema).default([])
|
|
561
|
+
});
|
|
562
|
+
function contentHash(content) {
|
|
563
|
+
return createHash("sha256").update(content.trim()).digest("hex");
|
|
564
|
+
}
|
|
565
|
+
function derivePreview(content, max = 80) {
|
|
566
|
+
const firstLine = content.trim().split("\n", 1)[0].trim();
|
|
567
|
+
return firstLine.length > max ? firstLine.slice(0, max - 1).trimEnd() + "\u2026" : firstLine;
|
|
568
|
+
}
|
|
569
|
+
function fileNames(files) {
|
|
570
|
+
return files.map((f) => basename2(f));
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// src/memory/veracity.ts
|
|
574
|
+
import { createHash as createHash2 } from "crypto";
|
|
575
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
576
|
+
import { isAbsolute as isAbsolute2, join as join3 } from "path";
|
|
577
|
+
var SCORE_FRESH = 3;
|
|
578
|
+
var SCORE_MAX = 5;
|
|
579
|
+
var SCORE_STALE_CAP = 2;
|
|
580
|
+
var AUTO_INJECT_MIN = 4;
|
|
581
|
+
var RELEVANCE_MIN = 3;
|
|
582
|
+
function hashFile(root, relPath) {
|
|
583
|
+
const abs = isAbsolute2(relPath) ? relPath : join3(root, relPath);
|
|
584
|
+
try {
|
|
585
|
+
return createHash2("sha256").update(readFileSync4(abs)).digest("hex");
|
|
586
|
+
} catch {
|
|
587
|
+
return "missing";
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
function hashFiles(root, files) {
|
|
591
|
+
const out3 = {};
|
|
592
|
+
for (const f of files) out3[f] = hashFile(root, f);
|
|
593
|
+
return out3;
|
|
594
|
+
}
|
|
595
|
+
function anyFileChanged(root, fileHashes) {
|
|
596
|
+
if (!fileHashes) return false;
|
|
597
|
+
for (const [path, stored] of Object.entries(fileHashes)) {
|
|
598
|
+
if (hashFile(root, path) !== stored) return true;
|
|
599
|
+
}
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
function parseFileHashes(json) {
|
|
603
|
+
if (!json) return null;
|
|
604
|
+
try {
|
|
605
|
+
return JSON.parse(json);
|
|
606
|
+
} catch {
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// src/memory/store.ts
|
|
612
|
+
function tx(db, fn) {
|
|
613
|
+
db.exec("BEGIN IMMEDIATE");
|
|
614
|
+
try {
|
|
615
|
+
const result = fn();
|
|
616
|
+
db.exec("COMMIT");
|
|
617
|
+
return result;
|
|
618
|
+
} catch (e) {
|
|
619
|
+
try {
|
|
620
|
+
db.exec("ROLLBACK");
|
|
621
|
+
} catch {
|
|
622
|
+
}
|
|
623
|
+
throw e;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
function queryOne(db, sql, ...params) {
|
|
627
|
+
return db.prepare(sql).get(...params);
|
|
628
|
+
}
|
|
629
|
+
function queryAll(db, sql, ...params) {
|
|
630
|
+
return db.prepare(sql).all(...params);
|
|
631
|
+
}
|
|
632
|
+
function slug(s) {
|
|
633
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "project";
|
|
634
|
+
}
|
|
635
|
+
function shortHash(s) {
|
|
636
|
+
return createHash3("sha256").update(s).digest("hex").slice(0, 6);
|
|
637
|
+
}
|
|
638
|
+
function ensureCollectionRow(db, id, name2, root) {
|
|
639
|
+
db.prepare(
|
|
640
|
+
"INSERT INTO collections (id, name, root_path, created_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING"
|
|
641
|
+
).run(id, name2, root, (/* @__PURE__ */ new Date()).toISOString());
|
|
642
|
+
}
|
|
643
|
+
function provisionCollection(db, root, preferredName) {
|
|
644
|
+
const name2 = slug(preferredName ?? basename3(root));
|
|
645
|
+
const id = `${name2}-${shortHash(root)}`;
|
|
646
|
+
ensureCollectionRow(db, id, name2, root);
|
|
647
|
+
const row = queryOne(
|
|
648
|
+
db,
|
|
649
|
+
"SELECT id, name FROM collections WHERE root_path = ?",
|
|
650
|
+
root
|
|
651
|
+
);
|
|
652
|
+
return row ? { collection: row.id, name: row.name } : { collection: id, name: name2 };
|
|
653
|
+
}
|
|
654
|
+
function resolveCollection(db, cwd = process.cwd()) {
|
|
655
|
+
const root = findProjectRoot(cwd);
|
|
656
|
+
const cfg = loadBoardConfig(cwd);
|
|
657
|
+
if (cfg.memory?.collection) {
|
|
658
|
+
const name2 = cfg.memory.name ?? cfg.memory.collection;
|
|
659
|
+
ensureCollectionRow(db, cfg.memory.collection, name2, root);
|
|
660
|
+
return { collection: cfg.memory.collection, name: name2 };
|
|
661
|
+
}
|
|
662
|
+
const provisioned = provisionCollection(db, root);
|
|
663
|
+
writeMemoryBinding(root, provisioned);
|
|
664
|
+
return provisioned;
|
|
665
|
+
}
|
|
666
|
+
function lookupCollection(db, cwd = process.cwd()) {
|
|
667
|
+
const cfg = loadBoardConfig(cwd);
|
|
668
|
+
if (cfg.memory?.collection) {
|
|
669
|
+
return { collection: cfg.memory.collection, name: cfg.memory.name ?? cfg.memory.collection };
|
|
670
|
+
}
|
|
671
|
+
const row = queryOne(
|
|
672
|
+
db,
|
|
673
|
+
"SELECT id, name FROM collections WHERE root_path = ?",
|
|
674
|
+
findProjectRoot(cwd)
|
|
675
|
+
);
|
|
676
|
+
return row ? { collection: row.id, name: row.name } : null;
|
|
677
|
+
}
|
|
678
|
+
function rowToRecord(r) {
|
|
679
|
+
return {
|
|
680
|
+
id: r.id,
|
|
681
|
+
collection: r.collection_id,
|
|
682
|
+
content: r.content,
|
|
683
|
+
title: r.title,
|
|
684
|
+
type: r.type,
|
|
685
|
+
ticket: r.ticket ?? null,
|
|
686
|
+
files: JSON.parse(r.files_json || "[]"),
|
|
687
|
+
createdAt: r.created_at,
|
|
688
|
+
contentHash: r.content_hash,
|
|
689
|
+
score: r.score,
|
|
690
|
+
status: r.status,
|
|
691
|
+
needsReverify: !!r.needs_reverify,
|
|
692
|
+
fileHashes: parseFileHashes(r.file_hashes),
|
|
693
|
+
verifiedAt: r.verified_at ?? null,
|
|
694
|
+
tombstoneReason: r.tombstone_reason ?? null
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
function rawInsert(db, collectionId, v) {
|
|
698
|
+
const id = `mem_${randomUUID().slice(0, 8)}`;
|
|
699
|
+
const fileHashesJson = JSON.stringify(v.fileHashes);
|
|
700
|
+
tx(db, () => {
|
|
701
|
+
db.prepare(
|
|
702
|
+
`INSERT INTO memories
|
|
703
|
+
(id, collection_id, content, title, type, ticket, files_json, created_at, content_hash,
|
|
704
|
+
score, status, needs_reverify, file_hashes, verified_at)
|
|
705
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 0, ?, NULL)`
|
|
706
|
+
).run(
|
|
707
|
+
id,
|
|
708
|
+
collectionId,
|
|
709
|
+
v.content,
|
|
710
|
+
v.title,
|
|
711
|
+
v.type,
|
|
712
|
+
v.ticket,
|
|
713
|
+
JSON.stringify(v.files),
|
|
714
|
+
v.createdAt,
|
|
715
|
+
v.hash,
|
|
716
|
+
SCORE_FRESH,
|
|
717
|
+
fileHashesJson
|
|
718
|
+
);
|
|
719
|
+
db.prepare("INSERT INTO memories_fts (memory_id, content, title) VALUES (?, ?, ?)").run(
|
|
720
|
+
id,
|
|
721
|
+
v.content,
|
|
722
|
+
v.title
|
|
723
|
+
);
|
|
724
|
+
});
|
|
725
|
+
return {
|
|
726
|
+
id,
|
|
727
|
+
collection: collectionId,
|
|
728
|
+
content: v.content,
|
|
729
|
+
title: v.title,
|
|
730
|
+
type: v.type,
|
|
731
|
+
ticket: v.ticket,
|
|
732
|
+
files: v.files,
|
|
733
|
+
createdAt: v.createdAt,
|
|
734
|
+
contentHash: v.hash,
|
|
735
|
+
score: SCORE_FRESH,
|
|
736
|
+
status: "active",
|
|
737
|
+
needsReverify: false,
|
|
738
|
+
fileHashes: v.fileHashes,
|
|
739
|
+
verifiedAt: null,
|
|
740
|
+
tombstoneReason: null
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
function insertMemory(db, collectionId, draft, root) {
|
|
744
|
+
const hash = contentHash(draft.content);
|
|
745
|
+
const existing = queryOne(
|
|
746
|
+
db,
|
|
747
|
+
"SELECT * FROM memories WHERE collection_id = ? AND content_hash = ?",
|
|
748
|
+
collectionId,
|
|
749
|
+
hash
|
|
750
|
+
);
|
|
751
|
+
if (existing) {
|
|
752
|
+
if (existing.status === "tombstoned") {
|
|
753
|
+
return { record: rowToRecord(existing), deduped: false, blocked: true };
|
|
754
|
+
}
|
|
755
|
+
return { record: rowToRecord(existing), deduped: true };
|
|
756
|
+
}
|
|
757
|
+
const record = rawInsert(db, collectionId, {
|
|
758
|
+
content: draft.content,
|
|
759
|
+
title: draft.title ?? derivePreview(draft.content),
|
|
760
|
+
type: draft.type,
|
|
761
|
+
ticket: draft.ticket ?? null,
|
|
762
|
+
files: draft.files,
|
|
763
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
764
|
+
hash,
|
|
765
|
+
fileHashes: hashFiles(root, draft.files)
|
|
766
|
+
});
|
|
767
|
+
return { record, deduped: false };
|
|
768
|
+
}
|
|
769
|
+
function amendMemory(db, id, patch, root) {
|
|
770
|
+
const cur = queryOne(db, "SELECT * FROM memories WHERE id = ?", id);
|
|
771
|
+
if (!cur) return null;
|
|
772
|
+
const prev = rowToRecord(cur);
|
|
773
|
+
const content = patch.content ?? prev.content;
|
|
774
|
+
const title = patch.title ?? (patch.content ? derivePreview(patch.content) : prev.title);
|
|
775
|
+
const type = patch.type ?? prev.type;
|
|
776
|
+
const ticket = patch.ticket !== void 0 ? patch.ticket : prev.ticket;
|
|
777
|
+
const files = patch.files ?? prev.files;
|
|
778
|
+
const hash = contentHash(content);
|
|
779
|
+
if (hash !== prev.contentHash) {
|
|
780
|
+
const clash = queryOne(
|
|
781
|
+
db,
|
|
782
|
+
"SELECT id FROM memories WHERE collection_id = ? AND content_hash = ? AND id <> ?",
|
|
783
|
+
prev.collection,
|
|
784
|
+
hash,
|
|
785
|
+
id
|
|
786
|
+
);
|
|
787
|
+
if (clash) {
|
|
788
|
+
throw new Error(
|
|
789
|
+
`another memory (${clash.id}) already has identical content in this project.`
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
const fileHashes = hashFiles(root, files);
|
|
794
|
+
tx(db, () => {
|
|
795
|
+
db.prepare(
|
|
796
|
+
`UPDATE memories SET content=?, title=?, type=?, ticket=?, files_json=?, content_hash=?,
|
|
797
|
+
score=?, needs_reverify=0, file_hashes=?, verified_at=NULL WHERE id=?`
|
|
798
|
+
).run(
|
|
799
|
+
content,
|
|
800
|
+
title,
|
|
801
|
+
type,
|
|
802
|
+
ticket,
|
|
803
|
+
JSON.stringify(files),
|
|
804
|
+
hash,
|
|
805
|
+
SCORE_FRESH,
|
|
806
|
+
JSON.stringify(fileHashes),
|
|
807
|
+
id
|
|
808
|
+
);
|
|
809
|
+
db.prepare("UPDATE memories_fts SET content=?, title=? WHERE memory_id=?").run(
|
|
810
|
+
content,
|
|
811
|
+
title,
|
|
812
|
+
id
|
|
813
|
+
);
|
|
814
|
+
});
|
|
815
|
+
return {
|
|
816
|
+
...prev,
|
|
817
|
+
content,
|
|
818
|
+
title,
|
|
819
|
+
type,
|
|
820
|
+
ticket,
|
|
821
|
+
files,
|
|
822
|
+
contentHash: hash,
|
|
823
|
+
score: SCORE_FRESH,
|
|
824
|
+
needsReverify: false,
|
|
825
|
+
fileHashes,
|
|
826
|
+
verifiedAt: null
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
function verifyMemory(db, id, pass, root, reason) {
|
|
830
|
+
const cur = queryOne(db, "SELECT * FROM memories WHERE id = ?", id);
|
|
831
|
+
if (!cur) return null;
|
|
832
|
+
const prev = rowToRecord(cur);
|
|
833
|
+
if (prev.status === "tombstoned") return prev;
|
|
834
|
+
if (pass) {
|
|
835
|
+
const fileHashes = hashFiles(root, prev.files);
|
|
836
|
+
const score = Math.min(SCORE_MAX, prev.score + 1);
|
|
837
|
+
const verifiedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
838
|
+
tx(db, () => {
|
|
839
|
+
db.prepare(
|
|
840
|
+
"UPDATE memories SET score=?, needs_reverify=0, file_hashes=?, verified_at=? WHERE id=?"
|
|
841
|
+
).run(score, JSON.stringify(fileHashes), verifiedAt, id);
|
|
842
|
+
});
|
|
843
|
+
return { ...prev, score, needsReverify: false, fileHashes, verifiedAt };
|
|
844
|
+
}
|
|
845
|
+
const tombstoneReason = reason ?? "refuted on verification";
|
|
846
|
+
tx(db, () => {
|
|
847
|
+
db.prepare(
|
|
848
|
+
"UPDATE memories SET status='tombstoned', score=0, needs_reverify=0, tombstone_reason=? WHERE id=?"
|
|
849
|
+
).run(tombstoneReason, id);
|
|
850
|
+
db.prepare("DELETE FROM memories_fts WHERE memory_id=?").run(id);
|
|
851
|
+
});
|
|
852
|
+
return { ...prev, status: "tombstoned", score: 0, needsReverify: false, tombstoneReason };
|
|
853
|
+
}
|
|
854
|
+
function flagStaleForFile(db, root, relPath) {
|
|
855
|
+
const rows = queryAll(
|
|
856
|
+
db,
|
|
857
|
+
"SELECT * FROM memories WHERE status='active' AND EXISTS (SELECT 1 FROM json_each(files_json) WHERE value = ?)",
|
|
858
|
+
relPath
|
|
859
|
+
);
|
|
860
|
+
const current = hashFile(root, relPath);
|
|
861
|
+
let flagged = 0;
|
|
862
|
+
for (const r of rows) {
|
|
863
|
+
const stored = parseFileHashes(r.file_hashes) ?? {};
|
|
864
|
+
if (stored[relPath] === current) continue;
|
|
865
|
+
const merged = { ...stored, [relPath]: current };
|
|
866
|
+
db.prepare("UPDATE memories SET needs_reverify=1, score=?, file_hashes=? WHERE id=?").run(
|
|
867
|
+
Math.min(r.score, SCORE_STALE_CAP),
|
|
868
|
+
JSON.stringify(merged),
|
|
869
|
+
r.id
|
|
870
|
+
);
|
|
871
|
+
flagged++;
|
|
872
|
+
}
|
|
873
|
+
return flagged;
|
|
874
|
+
}
|
|
875
|
+
function reconcileStale(db, root, collectionId) {
|
|
876
|
+
const rows = queryAll(
|
|
877
|
+
db,
|
|
878
|
+
"SELECT * FROM memories WHERE collection_id=? AND status='active' AND needs_reverify=0",
|
|
879
|
+
collectionId
|
|
880
|
+
);
|
|
881
|
+
let flagged = 0;
|
|
882
|
+
for (const r of rows) {
|
|
883
|
+
if (!anyFileChanged(root, parseFileHashes(r.file_hashes))) continue;
|
|
884
|
+
const files = JSON.parse(r.files_json || "[]");
|
|
885
|
+
db.prepare("UPDATE memories SET needs_reverify=1, score=?, file_hashes=? WHERE id=?").run(
|
|
886
|
+
Math.min(r.score, SCORE_STALE_CAP),
|
|
887
|
+
JSON.stringify(hashFiles(root, files)),
|
|
888
|
+
r.id
|
|
889
|
+
);
|
|
890
|
+
flagged++;
|
|
891
|
+
}
|
|
892
|
+
return flagged;
|
|
893
|
+
}
|
|
894
|
+
function removeMemory(db, id) {
|
|
895
|
+
return tx(db, () => {
|
|
896
|
+
const info = db.prepare("DELETE FROM memories WHERE id = ?").run(id);
|
|
897
|
+
db.prepare("DELETE FROM memories_fts WHERE memory_id = ?").run(id);
|
|
898
|
+
return Number(info.changes) > 0;
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
var STOPWORDS = /* @__PURE__ */ new Set([
|
|
902
|
+
"the",
|
|
903
|
+
"a",
|
|
904
|
+
"an",
|
|
905
|
+
"is",
|
|
906
|
+
"are",
|
|
907
|
+
"was",
|
|
908
|
+
"were",
|
|
909
|
+
"be",
|
|
910
|
+
"been",
|
|
911
|
+
"to",
|
|
912
|
+
"of",
|
|
913
|
+
"in",
|
|
914
|
+
"on",
|
|
915
|
+
"and",
|
|
916
|
+
"or",
|
|
917
|
+
"for",
|
|
918
|
+
"with",
|
|
919
|
+
"this",
|
|
920
|
+
"that",
|
|
921
|
+
"it",
|
|
922
|
+
"as",
|
|
923
|
+
"at",
|
|
924
|
+
"by",
|
|
925
|
+
"we",
|
|
926
|
+
"you",
|
|
927
|
+
"do",
|
|
928
|
+
"does",
|
|
929
|
+
"how",
|
|
930
|
+
"why",
|
|
931
|
+
"what",
|
|
932
|
+
"when",
|
|
933
|
+
"from",
|
|
934
|
+
"our",
|
|
935
|
+
"its"
|
|
936
|
+
]);
|
|
937
|
+
function splitIdentifier(word) {
|
|
938
|
+
return word.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").split(/\s+/);
|
|
939
|
+
}
|
|
940
|
+
function toFtsQuery(text) {
|
|
941
|
+
const tokens = /* @__PURE__ */ new Set();
|
|
942
|
+
for (const word of text.match(/[A-Za-z0-9]+/g) ?? []) {
|
|
943
|
+
for (const part of splitIdentifier(word)) {
|
|
944
|
+
const t = part.toLowerCase();
|
|
945
|
+
if (t.length < 2 || STOPWORDS.has(t)) continue;
|
|
946
|
+
tokens.add(t);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
if (tokens.size === 0) return null;
|
|
950
|
+
return [...tokens].map((t) => t.length >= 3 ? `${t}*` : t).join(" OR ");
|
|
951
|
+
}
|
|
952
|
+
function queryMemories(db, collectionId, opts) {
|
|
953
|
+
const limit = opts.limit && opts.limit > 0 ? opts.limit : 10;
|
|
954
|
+
const filters = ["m.collection_id = ?"];
|
|
955
|
+
const params = [collectionId];
|
|
956
|
+
const addFilter = (clause, param) => {
|
|
957
|
+
filters.push(clause);
|
|
958
|
+
params.push(param);
|
|
959
|
+
};
|
|
960
|
+
if (!opts.includeTombstoned) filters.push("m.status = 'active'");
|
|
961
|
+
if (opts.type) addFilter("m.type = ?", opts.type);
|
|
962
|
+
if (opts.ticket) addFilter("m.ticket = ?", opts.ticket);
|
|
963
|
+
if (opts.minScore != null) addFilter("m.score >= ?", opts.minScore);
|
|
964
|
+
if (opts.file)
|
|
965
|
+
addFilter(
|
|
966
|
+
"EXISTS (SELECT 1 FROM json_each(m.files_json) WHERE value LIKE ?)",
|
|
967
|
+
`%${opts.file}%`
|
|
968
|
+
);
|
|
969
|
+
if (opts.since) addFilter("m.created_at >= ?", opts.since);
|
|
970
|
+
const fts = opts.text ? toFtsQuery(opts.text) : null;
|
|
971
|
+
if (fts) {
|
|
972
|
+
const pool = Math.max(limit * 3, 30);
|
|
973
|
+
const sql2 = `SELECT m.*, bm25(memories_fts, 0.0, 1.0, 2.0) AS bm25
|
|
974
|
+
FROM memories_fts f JOIN memories m ON m.id = f.memory_id
|
|
975
|
+
WHERE memories_fts MATCH ? AND ${filters.join(" AND ")}
|
|
976
|
+
ORDER BY bm25 ASC LIMIT ?`;
|
|
977
|
+
const rows = queryAll(db, sql2, fts, ...params, pool);
|
|
978
|
+
const now = Date.now();
|
|
979
|
+
return rows.map((r) => {
|
|
980
|
+
const ageDays = Math.max(0, (now - Date.parse(r.created_at)) / 864e5);
|
|
981
|
+
return { r, blended: (r.bm25 ?? 0) + 0.03 * ageDays };
|
|
982
|
+
}).sort((a, b) => a.blended - b.blended).slice(0, limit).map(({ r }) => ({ ...rowToRecord(r), bm25: r.bm25 }));
|
|
983
|
+
}
|
|
984
|
+
const sql = `SELECT m.* FROM memories m WHERE ${filters.join(" AND ")} ORDER BY m.created_at DESC, m.rowid DESC LIMIT ?`;
|
|
985
|
+
return queryAll(db, sql, ...params, limit).map(rowToRecord);
|
|
986
|
+
}
|
|
987
|
+
function exportMemories(db, collectionId, type) {
|
|
988
|
+
const rows = type ? queryAll(
|
|
989
|
+
db,
|
|
990
|
+
"SELECT * FROM memories WHERE collection_id = ? AND status='active' AND type = ? ORDER BY created_at ASC",
|
|
991
|
+
collectionId,
|
|
992
|
+
type
|
|
993
|
+
) : queryAll(
|
|
994
|
+
db,
|
|
995
|
+
"SELECT * FROM memories WHERE collection_id = ? AND status='active' ORDER BY created_at ASC",
|
|
996
|
+
collectionId
|
|
997
|
+
);
|
|
998
|
+
return rows.map(rowToRecord);
|
|
999
|
+
}
|
|
1000
|
+
function importMemories(db, collectionId, records, root, type) {
|
|
1001
|
+
let added = 0;
|
|
1002
|
+
let skipped = 0;
|
|
1003
|
+
for (const r of records) {
|
|
1004
|
+
if (type && r.type !== type) continue;
|
|
1005
|
+
const hash = contentHash(r.content);
|
|
1006
|
+
const dupe = db.prepare("SELECT id FROM memories WHERE collection_id = ? AND content_hash = ?").get(collectionId, hash);
|
|
1007
|
+
if (dupe) {
|
|
1008
|
+
skipped++;
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
rawInsert(db, collectionId, {
|
|
1012
|
+
content: r.content,
|
|
1013
|
+
title: r.title ?? derivePreview(r.content),
|
|
1014
|
+
type: r.type,
|
|
1015
|
+
ticket: r.ticket ?? null,
|
|
1016
|
+
files: r.files,
|
|
1017
|
+
createdAt: r.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1018
|
+
hash,
|
|
1019
|
+
fileHashes: hashFiles(root, r.files)
|
|
1020
|
+
});
|
|
1021
|
+
added++;
|
|
1022
|
+
}
|
|
1023
|
+
return { added, skipped };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// src/commands/hook.ts
|
|
1027
|
+
function readHookInput() {
|
|
1028
|
+
return new Promise((resolve4) => {
|
|
1029
|
+
if (process.stdin.isTTY) return resolve4({});
|
|
1030
|
+
let data = "";
|
|
1031
|
+
process.stdin.setEncoding("utf-8");
|
|
1032
|
+
process.stdin.on("data", (c) => data += c);
|
|
1033
|
+
process.stdin.on("end", () => {
|
|
1034
|
+
try {
|
|
1035
|
+
resolve4(data.trim() ? JSON.parse(data) : {});
|
|
1036
|
+
} catch {
|
|
1037
|
+
resolve4({});
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
process.stdin.on("error", () => resolve4({}));
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
function withReadDb(fallback, fn) {
|
|
1044
|
+
try {
|
|
1045
|
+
if (!existsSync4(ragDbPath())) return fallback;
|
|
1046
|
+
const db = openDb();
|
|
1047
|
+
try {
|
|
1048
|
+
return fn(db);
|
|
1049
|
+
} finally {
|
|
1050
|
+
db.close();
|
|
1051
|
+
}
|
|
1052
|
+
} catch {
|
|
1053
|
+
return fallback;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
function bullet(m) {
|
|
1057
|
+
return `- ${m.score}\u2605 [${m.type}] ${m.ticket ? m.ticket + " " : ""}${m.title}`;
|
|
1058
|
+
}
|
|
1059
|
+
function memoryDigest() {
|
|
1060
|
+
return withReadDb("", (db) => {
|
|
1061
|
+
const col = lookupCollection(db);
|
|
1062
|
+
if (!col) return "";
|
|
1063
|
+
try {
|
|
1064
|
+
reconcileStale(db, findProjectRoot(), col.collection);
|
|
1065
|
+
} catch {
|
|
1066
|
+
}
|
|
1067
|
+
const recent = queryMemories(db, col.collection, { minScore: AUTO_INJECT_MIN, limit: 5 });
|
|
1068
|
+
if (recent.length === 0) return "";
|
|
1069
|
+
return `
|
|
1070
|
+
|
|
1071
|
+
## Project memory (kodi)
|
|
1072
|
+
|
|
1073
|
+
Trusted findings (score \u2265 ${AUTO_INJECT_MIN}) for this project \u2014 query more with \`kodi memory query <text>\`, and \`kodi memory verify <id>\` when a finding is confirmed/refuted:
|
|
1074
|
+
|
|
1075
|
+
${recent.map(bullet).join("\n")}
|
|
1076
|
+
`;
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
function promptInjection(prompt, cwd) {
|
|
1080
|
+
if (!prompt.trim()) return "";
|
|
1081
|
+
return withReadDb("", (db) => {
|
|
1082
|
+
const col = lookupCollection(db, cwd);
|
|
1083
|
+
if (!col) return "";
|
|
1084
|
+
const hits = queryMemories(db, col.collection, {
|
|
1085
|
+
text: prompt,
|
|
1086
|
+
minScore: RELEVANCE_MIN,
|
|
1087
|
+
limit: 3
|
|
1088
|
+
});
|
|
1089
|
+
const lines = [];
|
|
1090
|
+
let used = 0;
|
|
1091
|
+
for (const m of hits) {
|
|
1092
|
+
const line = bullet(m);
|
|
1093
|
+
if (used + line.length > 1200) break;
|
|
1094
|
+
lines.push(line);
|
|
1095
|
+
used += line.length + 1;
|
|
1096
|
+
}
|
|
1097
|
+
if (lines.length === 0) return "";
|
|
1098
|
+
return `## Possibly relevant project memory (kodi)
|
|
1099
|
+
|
|
1100
|
+
Retrieved for your request \u2014 expand with \`kodi memory query "<topic>" --json\`:
|
|
1101
|
+
|
|
1102
|
+
${lines.join("\n")}
|
|
1103
|
+
`;
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
function parseVulnerabilities(command) {
|
|
1107
|
+
if (!/\bkodi\s+pr\s+create\b/.test(command)) return [];
|
|
1108
|
+
const out3 = [];
|
|
1109
|
+
const re = /--vulnerability(?:=|\s+)(?:"([^"]*)"|'([^']*)'|(\S+))/g;
|
|
1110
|
+
let m;
|
|
1111
|
+
while (m = re.exec(command)) {
|
|
1112
|
+
const v = (m[1] ?? m[2] ?? m[3] ?? "").trim();
|
|
1113
|
+
if (v) out3.push(v);
|
|
1114
|
+
}
|
|
1115
|
+
return out3;
|
|
1116
|
+
}
|
|
1117
|
+
function parseVulnFile(vuln) {
|
|
1118
|
+
const m = vuln.match(/([A-Za-z0-9_.\-/]+\/[A-Za-z0-9_.\-]+\.[A-Za-z0-9]+)/);
|
|
1119
|
+
return m ? m[1] : null;
|
|
1120
|
+
}
|
|
1121
|
+
function captureFromCommand(command, cwd, root) {
|
|
1122
|
+
try {
|
|
1123
|
+
const vulns = parseVulnerabilities(command);
|
|
1124
|
+
if (vulns.length === 0) return;
|
|
1125
|
+
const db = openDb();
|
|
1126
|
+
try {
|
|
1127
|
+
const col = resolveCollection(db, cwd);
|
|
1128
|
+
for (const v of vulns) {
|
|
1129
|
+
const file = parseVulnFile(v);
|
|
1130
|
+
if (!file) continue;
|
|
1131
|
+
insertMemory(
|
|
1132
|
+
db,
|
|
1133
|
+
col.collection,
|
|
1134
|
+
MemoryDraftSchema.parse({
|
|
1135
|
+
content: `Security finding: ${v}`,
|
|
1136
|
+
type: "gotcha",
|
|
1137
|
+
files: [file]
|
|
1138
|
+
}),
|
|
1139
|
+
root
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
} finally {
|
|
1143
|
+
db.close();
|
|
1144
|
+
}
|
|
1145
|
+
} catch {
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
function flagStaleForEdit(filePath, root) {
|
|
1149
|
+
try {
|
|
1150
|
+
if (!existsSync4(ragDbPath())) return;
|
|
1151
|
+
const rel = isAbsolute3(filePath) ? relative2(root, filePath) : filePath;
|
|
1152
|
+
const db = openDb();
|
|
1153
|
+
try {
|
|
1154
|
+
flagStaleForFile(db, root, rel);
|
|
1155
|
+
} finally {
|
|
1156
|
+
db.close();
|
|
1157
|
+
}
|
|
1158
|
+
} catch {
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
function registerHookCommand(program2) {
|
|
1162
|
+
const hook = program2.command("hook").description("Emit Claude Code hook output (internal)");
|
|
1163
|
+
hook.command("session-start").description("Emit the orchestrator bootstrap as SessionStart additionalContext").action(() => {
|
|
1164
|
+
const payload = {
|
|
1165
|
+
hookSpecificOutput: {
|
|
1166
|
+
hookEventName: "SessionStart",
|
|
1167
|
+
additionalContext: ORCHESTRATOR_BOOTSTRAP + memoryDigest()
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
process.stdout.write(JSON.stringify(payload));
|
|
1171
|
+
});
|
|
1172
|
+
hook.command("user-prompt-submit").description("Inject memories relevant to the submitted prompt (UserPromptSubmit)").action(async () => {
|
|
1173
|
+
const input = await readHookInput();
|
|
1174
|
+
const prompt = typeof input.prompt === "string" ? input.prompt : "";
|
|
1175
|
+
const cwd = typeof input.cwd === "string" ? input.cwd : process.cwd();
|
|
1176
|
+
const context = promptInjection(prompt, cwd);
|
|
1177
|
+
if (!context) return;
|
|
1178
|
+
const payload = {
|
|
1179
|
+
hookSpecificOutput: {
|
|
1180
|
+
hookEventName: "UserPromptSubmit",
|
|
1181
|
+
additionalContext: context
|
|
1182
|
+
}
|
|
1183
|
+
};
|
|
1184
|
+
process.stdout.write(JSON.stringify(payload));
|
|
1185
|
+
});
|
|
1186
|
+
hook.command("post-tool-use").description("Capture security findings + flag file-edited memories stale (PostToolUse)").action(async () => {
|
|
1187
|
+
const input = await readHookInput();
|
|
1188
|
+
const toolInput = input.tool_input ?? {};
|
|
1189
|
+
const cwd = typeof input.cwd === "string" ? input.cwd : process.cwd();
|
|
1190
|
+
const root = findProjectRoot(cwd);
|
|
1191
|
+
const command = typeof toolInput.command === "string" ? toolInput.command : "";
|
|
1192
|
+
if (command) captureFromCommand(command, cwd, root);
|
|
1193
|
+
const filePath = typeof toolInput.file_path === "string" ? toolInput.file_path : "";
|
|
1194
|
+
if (filePath) flagStaleForEdit(filePath, root);
|
|
1195
|
+
});
|
|
1196
|
+
return hook;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// src/commands/init.ts
|
|
1200
|
+
import {
|
|
1201
|
+
copyFileSync as copyFileSync2,
|
|
1202
|
+
existsSync as existsSync5,
|
|
1203
|
+
mkdirSync as mkdirSync4,
|
|
1204
|
+
readdirSync as readdirSync2,
|
|
1205
|
+
readFileSync as readFileSync5,
|
|
1206
|
+
statSync as statSync2,
|
|
1207
|
+
writeFileSync as writeFileSync4
|
|
1208
|
+
} from "fs";
|
|
1209
|
+
import { basename as basename4, dirname as dirname5, join as join4, relative as relative3, resolve as resolve2 } from "path";
|
|
1210
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1211
|
+
import { stringify as stringifyYaml3 } from "yaml";
|
|
1212
|
+
|
|
446
1213
|
// src/exec.ts
|
|
447
1214
|
import { spawnSync } from "child_process";
|
|
448
1215
|
function quote(args) {
|
|
@@ -486,11 +1253,11 @@ function inline(s) {
|
|
|
486
1253
|
return escapeHtml(s).replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>").replace(/`(.+?)`/g, "<code>$1</code>");
|
|
487
1254
|
}
|
|
488
1255
|
function mdToHtml(md) {
|
|
489
|
-
const
|
|
1256
|
+
const out3 = [];
|
|
490
1257
|
let list = null;
|
|
491
1258
|
const closeList = () => {
|
|
492
1259
|
if (list) {
|
|
493
|
-
|
|
1260
|
+
out3.push("</ul>");
|
|
494
1261
|
list = null;
|
|
495
1262
|
}
|
|
496
1263
|
};
|
|
@@ -498,7 +1265,7 @@ function mdToHtml(md) {
|
|
|
498
1265
|
const line = raw.trimEnd();
|
|
499
1266
|
if (/^<!--/.test(line.trim())) {
|
|
500
1267
|
closeList();
|
|
501
|
-
|
|
1268
|
+
out3.push(line);
|
|
502
1269
|
continue;
|
|
503
1270
|
}
|
|
504
1271
|
if (line.trim() === "") {
|
|
@@ -509,23 +1276,23 @@ function mdToHtml(md) {
|
|
|
509
1276
|
if (heading) {
|
|
510
1277
|
closeList();
|
|
511
1278
|
const level = heading[1].length;
|
|
512
|
-
|
|
1279
|
+
out3.push(`<h${level}>${inline(heading[2])}</h${level}>`);
|
|
513
1280
|
continue;
|
|
514
1281
|
}
|
|
515
1282
|
const item = /^[-*]\s+(.*)$/.exec(line);
|
|
516
1283
|
if (item) {
|
|
517
1284
|
if (!list) {
|
|
518
|
-
|
|
1285
|
+
out3.push("<ul>");
|
|
519
1286
|
list = "ul";
|
|
520
1287
|
}
|
|
521
|
-
|
|
1288
|
+
out3.push(`<li>${inline(item[1])}</li>`);
|
|
522
1289
|
continue;
|
|
523
1290
|
}
|
|
524
1291
|
closeList();
|
|
525
|
-
|
|
1292
|
+
out3.push(`<p>${inline(line)}</p>`);
|
|
526
1293
|
}
|
|
527
1294
|
closeList();
|
|
528
|
-
return
|
|
1295
|
+
return out3.join("\n");
|
|
529
1296
|
}
|
|
530
1297
|
|
|
531
1298
|
// src/providers/azure.ts
|
|
@@ -548,6 +1315,9 @@ function columnForStatus(status, cols) {
|
|
|
548
1315
|
return cols.todo;
|
|
549
1316
|
}
|
|
550
1317
|
}
|
|
1318
|
+
function stateForColumn(column, columnStates) {
|
|
1319
|
+
return columnStates?.[column] ?? column;
|
|
1320
|
+
}
|
|
551
1321
|
function statusFromColumn(column, cols) {
|
|
552
1322
|
if (column === cols.todo) return "Pending";
|
|
553
1323
|
if (column === (cols.inProgress ?? DEFAULT_COLUMNS.inProgress)) return "In progress";
|
|
@@ -572,7 +1342,7 @@ function parseWorkItem(fields, id, cols = DEFAULT_COLUMNS) {
|
|
|
572
1342
|
}
|
|
573
1343
|
const parsed = TicketSchema.safeParse(JSON.parse(json));
|
|
574
1344
|
if (!parsed.success) return null;
|
|
575
|
-
const column = fields["System.
|
|
1345
|
+
const column = fields["System.BoardColumn"] ?? fields["System.State"] ?? "";
|
|
576
1346
|
const status = statusFromColumn(column, cols) ?? parsed.data.status;
|
|
577
1347
|
return {
|
|
578
1348
|
...parsed.data,
|
|
@@ -581,7 +1351,7 @@ function parseWorkItem(fields, id, cols = DEFAULT_COLUMNS) {
|
|
|
581
1351
|
status
|
|
582
1352
|
};
|
|
583
1353
|
}
|
|
584
|
-
function createArgs(coords, title, html,
|
|
1354
|
+
function createArgs(coords, title, html, state) {
|
|
585
1355
|
const args = [
|
|
586
1356
|
"az",
|
|
587
1357
|
"boards",
|
|
@@ -592,7 +1362,7 @@ function createArgs(coords, title, html, column) {
|
|
|
592
1362
|
"--type",
|
|
593
1363
|
"Issue",
|
|
594
1364
|
"--fields",
|
|
595
|
-
`System.State=${
|
|
1365
|
+
`System.State=${state}`,
|
|
596
1366
|
"--description",
|
|
597
1367
|
html,
|
|
598
1368
|
"--output",
|
|
@@ -606,10 +1376,37 @@ var AzureTicketProvider = class {
|
|
|
606
1376
|
constructor(opts) {
|
|
607
1377
|
this.opts = opts;
|
|
608
1378
|
this.columns = opts.columns ?? DEFAULT_COLUMNS;
|
|
1379
|
+
this.columnStates = opts.columnStates;
|
|
609
1380
|
}
|
|
610
1381
|
opts;
|
|
611
1382
|
name = "azure";
|
|
612
1383
|
columns;
|
|
1384
|
+
columnStates;
|
|
1385
|
+
/**
|
|
1386
|
+
* The writable state for a logical status. The user maps each status to a BOARD
|
|
1387
|
+
* COLUMN in `kodi init`, but `System.BoardColumn` is a read-only computed field
|
|
1388
|
+
* (Azure rejects writes with TF401326), so the only thing kodi can set is
|
|
1389
|
+
* `System.State`. We therefore translate the chosen column → the state it maps
|
|
1390
|
+
* to (via `columnStates`) and set that. When several columns share a state, Azure
|
|
1391
|
+
* places the card in the board's default column for that state.
|
|
1392
|
+
*/
|
|
1393
|
+
stateFor(status) {
|
|
1394
|
+
return stateForColumn(columnForStatus(status, this.columns), this.columnStates);
|
|
1395
|
+
}
|
|
1396
|
+
/** Set work-item `id`'s state (the only board-driving field Azure lets us write). */
|
|
1397
|
+
setStateArgs(id, state) {
|
|
1398
|
+
return [
|
|
1399
|
+
"az",
|
|
1400
|
+
"boards",
|
|
1401
|
+
"work-item",
|
|
1402
|
+
"update",
|
|
1403
|
+
"--id",
|
|
1404
|
+
id,
|
|
1405
|
+
"--fields",
|
|
1406
|
+
`System.State=${state}`,
|
|
1407
|
+
...this.orgArgs()
|
|
1408
|
+
];
|
|
1409
|
+
}
|
|
613
1410
|
coords() {
|
|
614
1411
|
return { organization: this.opts.organization, project: this.opts.project };
|
|
615
1412
|
}
|
|
@@ -627,19 +1424,19 @@ var AzureTicketProvider = class {
|
|
|
627
1424
|
return "(assigned by azure on create)";
|
|
628
1425
|
}
|
|
629
1426
|
async create(input) {
|
|
630
|
-
const
|
|
631
|
-
const draft = { ...input, key: "(pending)", slug };
|
|
1427
|
+
const slug2 = input.slug ?? slugify(input.title);
|
|
1428
|
+
const draft = { ...input, key: "(pending)", slug: slug2 };
|
|
632
1429
|
const html = descriptionHtml(draft);
|
|
633
1430
|
const res = execMutate(
|
|
634
|
-
createArgs(this.coords(), input.title, html,
|
|
1431
|
+
createArgs(this.coords(), input.title, html, this.stateFor(input.status)),
|
|
635
1432
|
this.opts.dryRun
|
|
636
1433
|
);
|
|
637
1434
|
if (!res.ran) return { ...draft, key: "(dry-run)" };
|
|
638
|
-
const id = JSON.parse(res.stdout).id;
|
|
639
|
-
return { ...draft, key:
|
|
1435
|
+
const id = String(JSON.parse(res.stdout).id);
|
|
1436
|
+
return { ...draft, key: id };
|
|
640
1437
|
}
|
|
641
1438
|
async get(key) {
|
|
642
|
-
const
|
|
1439
|
+
const out3 = execRead([
|
|
643
1440
|
"az",
|
|
644
1441
|
"boards",
|
|
645
1442
|
"work-item",
|
|
@@ -650,12 +1447,12 @@ var AzureTicketProvider = class {
|
|
|
650
1447
|
"json",
|
|
651
1448
|
...this.orgArgs()
|
|
652
1449
|
]);
|
|
653
|
-
const wi = JSON.parse(
|
|
1450
|
+
const wi = JSON.parse(out3);
|
|
654
1451
|
return parseWorkItem(wi.fields ?? {}, wi.id, this.columns);
|
|
655
1452
|
}
|
|
656
1453
|
async list() {
|
|
657
1454
|
const wiql = "SELECT [System.Id] FROM WorkItems WHERE [System.WorkItemType] = 'Issue' ORDER BY [System.Id]";
|
|
658
|
-
const
|
|
1455
|
+
const out3 = execRead([
|
|
659
1456
|
"az",
|
|
660
1457
|
"boards",
|
|
661
1458
|
"query",
|
|
@@ -665,7 +1462,7 @@ var AzureTicketProvider = class {
|
|
|
665
1462
|
"json",
|
|
666
1463
|
...this.scopeArgs()
|
|
667
1464
|
]);
|
|
668
|
-
const rows = JSON.parse(
|
|
1465
|
+
const rows = JSON.parse(out3);
|
|
669
1466
|
const refs = [];
|
|
670
1467
|
for (const row of rows) {
|
|
671
1468
|
const id = row.id ?? row.fields?.["System.Id"];
|
|
@@ -691,20 +1488,7 @@ var AzureTicketProvider = class {
|
|
|
691
1488
|
async setStatus(key, status) {
|
|
692
1489
|
const current = await this.get(key);
|
|
693
1490
|
if (!current) throw new Error(`work-item ${key} not found`);
|
|
694
|
-
execMutate(
|
|
695
|
-
[
|
|
696
|
-
"az",
|
|
697
|
-
"boards",
|
|
698
|
-
"work-item",
|
|
699
|
-
"update",
|
|
700
|
-
"--id",
|
|
701
|
-
key,
|
|
702
|
-
"--fields",
|
|
703
|
-
`System.State=${columnForStatus(status, this.columns)}`,
|
|
704
|
-
...this.orgArgs()
|
|
705
|
-
],
|
|
706
|
-
this.opts.dryRun
|
|
707
|
-
);
|
|
1491
|
+
execMutate(this.setStateArgs(key, this.stateFor(status)), this.opts.dryRun);
|
|
708
1492
|
return { ...current, status };
|
|
709
1493
|
}
|
|
710
1494
|
async start(key, _p) {
|
|
@@ -753,8 +1537,8 @@ function parseProjects(json) {
|
|
|
753
1537
|
return items.map((p) => p?.name).filter((n) => typeof n === "string");
|
|
754
1538
|
}
|
|
755
1539
|
function listProjects(org, run = defaultRunner) {
|
|
756
|
-
const
|
|
757
|
-
return parseProjects(
|
|
1540
|
+
const out3 = run(["az", "devops", "project", "list", "--org", org, "--output", "json"]);
|
|
1541
|
+
return parseProjects(out3);
|
|
758
1542
|
}
|
|
759
1543
|
function parseProjectInfo(json) {
|
|
760
1544
|
const d = JSON.parse(json);
|
|
@@ -783,34 +1567,74 @@ function getProjectInfo(org, project, run = defaultRunner) {
|
|
|
783
1567
|
function processSupportsIssues(template) {
|
|
784
1568
|
return template !== "Agile" && template !== "Scrum";
|
|
785
1569
|
}
|
|
786
|
-
function
|
|
1570
|
+
function parseTeams(json) {
|
|
787
1571
|
const d = JSON.parse(json);
|
|
788
1572
|
const items = Array.isArray(d) ? d : d.value ?? [];
|
|
789
|
-
return items.map((
|
|
1573
|
+
return items.map((t) => t?.name).filter((n) => typeof n === "string");
|
|
790
1574
|
}
|
|
791
|
-
function
|
|
792
|
-
|
|
793
|
-
"az",
|
|
794
|
-
|
|
795
|
-
"invoke",
|
|
796
|
-
"--area",
|
|
797
|
-
"wit",
|
|
798
|
-
"--resource",
|
|
799
|
-
"workItemTypeStates",
|
|
800
|
-
"--route-parameters",
|
|
801
|
-
`project=${project}`,
|
|
802
|
-
"type=Issue",
|
|
803
|
-
"--org",
|
|
804
|
-
org,
|
|
805
|
-
"--detect",
|
|
806
|
-
"false",
|
|
807
|
-
"--output",
|
|
808
|
-
"json"
|
|
809
|
-
]);
|
|
810
|
-
return parseStates(out2);
|
|
1575
|
+
function listTeams(org, project, run = defaultRunner) {
|
|
1576
|
+
return parseTeams(
|
|
1577
|
+
run(["az", "devops", "team", "list", "--org", org, "--project", project, "--output", "json"])
|
|
1578
|
+
);
|
|
811
1579
|
}
|
|
812
|
-
function
|
|
813
|
-
|
|
1580
|
+
function parseBoards(json) {
|
|
1581
|
+
const d = JSON.parse(json);
|
|
1582
|
+
const items = Array.isArray(d) ? d : d.value ?? [];
|
|
1583
|
+
return items.map((b) => b?.name).filter((n) => typeof n === "string");
|
|
1584
|
+
}
|
|
1585
|
+
function listBoards(org, project, team, run = defaultRunner) {
|
|
1586
|
+
return parseBoards(
|
|
1587
|
+
run([
|
|
1588
|
+
"az",
|
|
1589
|
+
"devops",
|
|
1590
|
+
"invoke",
|
|
1591
|
+
"--area",
|
|
1592
|
+
"work",
|
|
1593
|
+
"--resource",
|
|
1594
|
+
"boards",
|
|
1595
|
+
"--route-parameters",
|
|
1596
|
+
`project=${project}`,
|
|
1597
|
+
`team=${team}`,
|
|
1598
|
+
"--org",
|
|
1599
|
+
org,
|
|
1600
|
+
"--detect",
|
|
1601
|
+
"false",
|
|
1602
|
+
"--output",
|
|
1603
|
+
"json"
|
|
1604
|
+
])
|
|
1605
|
+
);
|
|
1606
|
+
}
|
|
1607
|
+
function parseBoardColumns(json) {
|
|
1608
|
+
const d = JSON.parse(json);
|
|
1609
|
+
const items = Array.isArray(d) ? d : d.value ?? [];
|
|
1610
|
+
return items.map((c) => ({
|
|
1611
|
+
name: c?.name,
|
|
1612
|
+
columnType: c?.columnType ?? "",
|
|
1613
|
+
state: c?.stateMappings?.Issue ?? ""
|
|
1614
|
+
})).filter((c) => typeof c.name === "string");
|
|
1615
|
+
}
|
|
1616
|
+
function listBoardColumns(org, project, team, board, run = defaultRunner) {
|
|
1617
|
+
return parseBoardColumns(
|
|
1618
|
+
run([
|
|
1619
|
+
"az",
|
|
1620
|
+
"devops",
|
|
1621
|
+
"invoke",
|
|
1622
|
+
"--area",
|
|
1623
|
+
"work",
|
|
1624
|
+
"--resource",
|
|
1625
|
+
"columns",
|
|
1626
|
+
"--route-parameters",
|
|
1627
|
+
`project=${project}`,
|
|
1628
|
+
`team=${team}`,
|
|
1629
|
+
`board=${board}`,
|
|
1630
|
+
"--org",
|
|
1631
|
+
org,
|
|
1632
|
+
"--detect",
|
|
1633
|
+
"false",
|
|
1634
|
+
"--output",
|
|
1635
|
+
"json"
|
|
1636
|
+
])
|
|
1637
|
+
);
|
|
814
1638
|
}
|
|
815
1639
|
|
|
816
1640
|
// src/providers/github-discovery.ts
|
|
@@ -821,7 +1645,7 @@ function parseProjects2(json) {
|
|
|
821
1645
|
return items.map((p) => ({ number: p?.number, title: p?.title ?? "", id: p?.id ?? "" })).filter((p) => typeof p.number === "number");
|
|
822
1646
|
}
|
|
823
1647
|
function listProjects2(owner, run = defaultRunner2) {
|
|
824
|
-
const
|
|
1648
|
+
const out3 = run([
|
|
825
1649
|
"gh",
|
|
826
1650
|
"project",
|
|
827
1651
|
"list",
|
|
@@ -832,19 +1656,19 @@ function listProjects2(owner, run = defaultRunner2) {
|
|
|
832
1656
|
"--limit",
|
|
833
1657
|
"100"
|
|
834
1658
|
]);
|
|
835
|
-
return parseProjects2(
|
|
1659
|
+
return parseProjects2(out3);
|
|
836
1660
|
}
|
|
837
1661
|
function resolveViewerLogin(run = defaultRunner2) {
|
|
838
1662
|
return run(["gh", "api", "user", "-q", ".login"]).trim();
|
|
839
1663
|
}
|
|
840
1664
|
function tokenScopes(run = defaultRunner2) {
|
|
841
|
-
let
|
|
1665
|
+
let out3;
|
|
842
1666
|
try {
|
|
843
|
-
|
|
1667
|
+
out3 = run(["gh", "api", "-i", "user"]);
|
|
844
1668
|
} catch {
|
|
845
1669
|
return null;
|
|
846
1670
|
}
|
|
847
|
-
const m = /^x-oauth-scopes:\s*(.*)$/im.exec(
|
|
1671
|
+
const m = /^x-oauth-scopes:\s*(.*)$/im.exec(out3);
|
|
848
1672
|
if (!m) return null;
|
|
849
1673
|
return m[1].split(",").map((s) => s.trim()).filter(Boolean);
|
|
850
1674
|
}
|
|
@@ -862,8 +1686,8 @@ function parseRepos(json) {
|
|
|
862
1686
|
return items.map((r) => r?.nameWithOwner).filter((n) => typeof n === "string");
|
|
863
1687
|
}
|
|
864
1688
|
function listRepos(owner, run = defaultRunner2) {
|
|
865
|
-
const
|
|
866
|
-
return parseRepos(
|
|
1689
|
+
const out3 = run(["gh", "repo", "list", owner, "--json", "nameWithOwner", "--limit", "200"]);
|
|
1690
|
+
return parseRepos(out3);
|
|
867
1691
|
}
|
|
868
1692
|
function parseStatusField(json) {
|
|
869
1693
|
const data = JSON.parse(json);
|
|
@@ -878,7 +1702,7 @@ function parseStatusField(json) {
|
|
|
878
1702
|
return { id: status.id, options };
|
|
879
1703
|
}
|
|
880
1704
|
function listStatusField(owner, number, run = defaultRunner2) {
|
|
881
|
-
const
|
|
1705
|
+
const out3 = run([
|
|
882
1706
|
"gh",
|
|
883
1707
|
"project",
|
|
884
1708
|
"field-list",
|
|
@@ -890,7 +1714,7 @@ function listStatusField(owner, number, run = defaultRunner2) {
|
|
|
890
1714
|
"--limit",
|
|
891
1715
|
"100"
|
|
892
1716
|
]);
|
|
893
|
-
return parseStatusField(
|
|
1717
|
+
return parseStatusField(out3);
|
|
894
1718
|
}
|
|
895
1719
|
function parseProjectId(json) {
|
|
896
1720
|
return JSON.parse(json)?.id ?? "";
|
|
@@ -942,30 +1766,90 @@ function readlinePrompter() {
|
|
|
942
1766
|
// src/commands/init.ts
|
|
943
1767
|
var HOOK_COMMAND = "kodi hook session-start";
|
|
944
1768
|
var SESSION_MATCHER = "startup|resume|clear|compact";
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
const
|
|
949
|
-
|
|
950
|
-
arr.
|
|
1769
|
+
var UPS_COMMAND = "kodi hook user-prompt-submit";
|
|
1770
|
+
var PTU_COMMAND = "kodi hook post-tool-use";
|
|
1771
|
+
function addHookOnce(settings, event, command, matcher) {
|
|
1772
|
+
const hooks = settings.hooks ??= {};
|
|
1773
|
+
const arr = hooks[event] ??= [];
|
|
1774
|
+
if (arr.some((e) => e.hooks?.some((h) => h.command === command))) return false;
|
|
1775
|
+
const entry = { hooks: [{ type: "command", command }] };
|
|
1776
|
+
if (matcher) entry.matcher = matcher;
|
|
1777
|
+
arr.push(entry);
|
|
951
1778
|
return true;
|
|
952
1779
|
}
|
|
1780
|
+
function mergeSessionStartHook(settings) {
|
|
1781
|
+
return addHookOnce(settings, "SessionStart", HOOK_COMMAND, SESSION_MATCHER);
|
|
1782
|
+
}
|
|
1783
|
+
function mergeUserPromptSubmitHook(settings) {
|
|
1784
|
+
return addHookOnce(settings, "UserPromptSubmit", UPS_COMMAND);
|
|
1785
|
+
}
|
|
1786
|
+
function mergePostToolUseHook(settings) {
|
|
1787
|
+
return addHookOnce(settings, "PostToolUse", PTU_COMMAND, "Bash|Write|Edit");
|
|
1788
|
+
}
|
|
1789
|
+
var BOARD_CLI_COMMANDS = [
|
|
1790
|
+
"gh pr",
|
|
1791
|
+
// GitHub pull requests
|
|
1792
|
+
"gh issue",
|
|
1793
|
+
// GitHub issues (board tasks)
|
|
1794
|
+
"gh project",
|
|
1795
|
+
// GitHub Projects (boards)
|
|
1796
|
+
"az repos pr",
|
|
1797
|
+
// Azure DevOps pull requests
|
|
1798
|
+
"az boards"
|
|
1799
|
+
// Azure DevOps boards (work items / tasks)
|
|
1800
|
+
];
|
|
1801
|
+
function denyDirectAndRtk(cmd) {
|
|
1802
|
+
return [`Bash(${cmd}:*)`, `Bash(rtk ${cmd}:*)`];
|
|
1803
|
+
}
|
|
1804
|
+
var PERMISSION_DENY = [
|
|
1805
|
+
...BOARD_CLI_COMMANDS.flatMap(denyDirectAndRtk),
|
|
1806
|
+
...denyDirectAndRtk("kodi init")
|
|
1807
|
+
// the one-time human setup command, direct and via rtk
|
|
1808
|
+
];
|
|
1809
|
+
var PERMISSION_ALLOW = [
|
|
1810
|
+
"Read(.claude/agents/**)",
|
|
1811
|
+
// read the installed agents
|
|
1812
|
+
"Read(.claude/skills/**)",
|
|
1813
|
+
// read the installed skills
|
|
1814
|
+
// kodi is the sanctioned board proxy — allow all of it. `kodi init` stays in the
|
|
1815
|
+
// deny list, and deny rules override allow, so every kodi command runs without a
|
|
1816
|
+
// prompt EXCEPT init. The rtk-proxied form is allowed for the same reason.
|
|
1817
|
+
"Bash(kodi:*)",
|
|
1818
|
+
"Bash(rtk kodi:*)"
|
|
1819
|
+
];
|
|
1820
|
+
function mergePermissions(settings) {
|
|
1821
|
+
const perms = settings.permissions ??= {};
|
|
1822
|
+
const deny = perms.deny ??= [];
|
|
1823
|
+
const allow = perms.allow ??= [];
|
|
1824
|
+
let changed = false;
|
|
1825
|
+
for (const rule of PERMISSION_DENY)
|
|
1826
|
+
if (!deny.includes(rule)) {
|
|
1827
|
+
deny.push(rule);
|
|
1828
|
+
changed = true;
|
|
1829
|
+
}
|
|
1830
|
+
for (const rule of PERMISSION_ALLOW)
|
|
1831
|
+
if (!allow.includes(rule)) {
|
|
1832
|
+
allow.push(rule);
|
|
1833
|
+
changed = true;
|
|
1834
|
+
}
|
|
1835
|
+
return changed;
|
|
1836
|
+
}
|
|
953
1837
|
function defaultAssetsDir() {
|
|
954
1838
|
return fileURLToPath2(new URL("../assets/", import.meta.url));
|
|
955
1839
|
}
|
|
956
1840
|
function copyTree2(srcRoot, destRoot, force, reportBase) {
|
|
957
1841
|
const written = [];
|
|
958
|
-
if (!
|
|
1842
|
+
if (!existsSync5(srcRoot)) return written;
|
|
959
1843
|
const walk = (src, dest) => {
|
|
960
1844
|
for (const entry of readdirSync2(src)) {
|
|
961
|
-
const s =
|
|
962
|
-
const d =
|
|
1845
|
+
const s = join4(src, entry);
|
|
1846
|
+
const d = join4(dest, entry);
|
|
963
1847
|
if (statSync2(s).isDirectory()) walk(s, d);
|
|
964
1848
|
else {
|
|
965
|
-
if (
|
|
966
|
-
|
|
1849
|
+
if (existsSync5(d) && !force) continue;
|
|
1850
|
+
mkdirSync4(dirname5(d), { recursive: true });
|
|
967
1851
|
copyFileSync2(s, d);
|
|
968
|
-
written.push(
|
|
1852
|
+
written.push(join4(reportBase, relative3(destRoot, d)));
|
|
969
1853
|
}
|
|
970
1854
|
}
|
|
971
1855
|
};
|
|
@@ -974,17 +1858,17 @@ function copyTree2(srcRoot, destRoot, force, reportBase) {
|
|
|
974
1858
|
}
|
|
975
1859
|
function copyMarkdownFlat(srcRoot, destDir, force, reportBase) {
|
|
976
1860
|
const written = [];
|
|
977
|
-
if (!
|
|
1861
|
+
if (!existsSync5(srcRoot)) return written;
|
|
978
1862
|
const walk = (dir) => {
|
|
979
1863
|
for (const entry of readdirSync2(dir)) {
|
|
980
|
-
const s =
|
|
1864
|
+
const s = join4(dir, entry);
|
|
981
1865
|
if (statSync2(s).isDirectory()) walk(s);
|
|
982
1866
|
else if (entry.endsWith(".md") && entry !== "README.md") {
|
|
983
|
-
const d =
|
|
984
|
-
if (
|
|
985
|
-
|
|
1867
|
+
const d = join4(destDir, entry);
|
|
1868
|
+
if (existsSync5(d) && !force) continue;
|
|
1869
|
+
mkdirSync4(destDir, { recursive: true });
|
|
986
1870
|
copyFileSync2(s, d);
|
|
987
|
-
written.push(
|
|
1871
|
+
written.push(join4(reportBase, entry));
|
|
988
1872
|
}
|
|
989
1873
|
}
|
|
990
1874
|
};
|
|
@@ -994,25 +1878,35 @@ function copyMarkdownFlat(srcRoot, destDir, force, reportBase) {
|
|
|
994
1878
|
function installHarness(root, opts = {}) {
|
|
995
1879
|
const force = opts.force ?? false;
|
|
996
1880
|
const assetsDir = opts.assetsDir ?? defaultAssetsDir();
|
|
997
|
-
const claude =
|
|
1881
|
+
const claude = join4(root, ".claude");
|
|
998
1882
|
const changed = [];
|
|
999
|
-
|
|
1000
|
-
const settingsPath =
|
|
1001
|
-
const settings =
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1883
|
+
mkdirSync4(claude, { recursive: true });
|
|
1884
|
+
const settingsPath = join4(claude, "settings.json");
|
|
1885
|
+
const settings = existsSync5(settingsPath) ? JSON.parse(readFileSync5(settingsPath, "utf-8")) : {};
|
|
1886
|
+
const hookChanged = mergeSessionStartHook(settings);
|
|
1887
|
+
const upsChanged = mergeUserPromptSubmitHook(settings);
|
|
1888
|
+
const ptuChanged = mergePostToolUseHook(settings);
|
|
1889
|
+
const permsChanged = mergePermissions(settings);
|
|
1890
|
+
if (hookChanged || upsChanged || ptuChanged || permsChanged) {
|
|
1891
|
+
writeFileSync4(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
1892
|
+
const parts = [
|
|
1893
|
+
hookChanged ? "SessionStart hook" : null,
|
|
1894
|
+
upsChanged ? "UserPromptSubmit hook" : null,
|
|
1895
|
+
ptuChanged ? "PostToolUse hook" : null,
|
|
1896
|
+
permsChanged ? "permissions" : null
|
|
1897
|
+
].filter(Boolean).join(" + ");
|
|
1898
|
+
changed.push(`.claude/settings.json (${parts})`);
|
|
1005
1899
|
}
|
|
1006
1900
|
changed.push(
|
|
1007
|
-
...copyTree2(
|
|
1008
|
-
...copyMarkdownFlat(
|
|
1009
|
-
...copyTree2(
|
|
1901
|
+
...copyTree2(join4(assetsDir, "skills"), join4(claude, "skills"), force, ".claude/skills"),
|
|
1902
|
+
...copyMarkdownFlat(join4(assetsDir, "agents"), join4(claude, "agents"), force, ".claude/agents"),
|
|
1903
|
+
...copyTree2(join4(assetsDir, "rules"), join4(claude, "rules"), force, ".claude/rules")
|
|
1010
1904
|
);
|
|
1011
1905
|
const provider = opts.provider ?? "local";
|
|
1012
1906
|
const docsSubs = ["prd", "adr", "diagrams", "plan", "security"];
|
|
1013
1907
|
if (provider === "local") docsSubs.push("tickets");
|
|
1014
1908
|
for (const sub of docsSubs) {
|
|
1015
|
-
|
|
1909
|
+
mkdirSync4(join4(root, "docs", sub), { recursive: true });
|
|
1016
1910
|
}
|
|
1017
1911
|
return changed;
|
|
1018
1912
|
}
|
|
@@ -1056,54 +1950,65 @@ async function configureBoard(prompter, opts = {}) {
|
|
|
1056
1950
|
`project "${project}" uses the ${info.processTemplate} process, which has no "Issue" work-item type. kodi creates tickets as Issues \u2014 use a Basic-process project (or add the Issue type), then re-run \`kodi init\`.`
|
|
1057
1951
|
);
|
|
1058
1952
|
}
|
|
1059
|
-
let
|
|
1953
|
+
let team = opts.team;
|
|
1954
|
+
if (!team) {
|
|
1955
|
+
let teams = [];
|
|
1956
|
+
try {
|
|
1957
|
+
teams = listTeams(org, project, opts.runner);
|
|
1958
|
+
} catch {
|
|
1959
|
+
}
|
|
1960
|
+
team = teams.length === 1 ? teams[0] : teams.length > 1 ? await prompter.select("Select the team that owns the board", teams) : await prompter.input("Team that owns the board", `${project} Team`);
|
|
1961
|
+
}
|
|
1962
|
+
if (!team) throw new InitAbort("missing: team.");
|
|
1963
|
+
let board = opts.board;
|
|
1964
|
+
if (!board) {
|
|
1965
|
+
let boards = [];
|
|
1966
|
+
try {
|
|
1967
|
+
boards = listBoards(org, project, team, opts.runner);
|
|
1968
|
+
} catch {
|
|
1969
|
+
}
|
|
1970
|
+
const issuesBoard = boards.find((b) => b.toLowerCase() === "issues");
|
|
1971
|
+
board = boards.length === 1 ? boards[0] : issuesBoard ? issuesBoard : boards.length > 1 ? await prompter.select("Select the board", boards) : await prompter.input("Board name", "Issues");
|
|
1972
|
+
}
|
|
1973
|
+
if (!board) throw new InitAbort("missing: board.");
|
|
1974
|
+
let boardColumns = [];
|
|
1060
1975
|
try {
|
|
1061
|
-
|
|
1976
|
+
boardColumns = listBoardColumns(org, project, team, board, opts.runner);
|
|
1062
1977
|
} catch {
|
|
1063
1978
|
}
|
|
1064
|
-
const
|
|
1065
|
-
const
|
|
1066
|
-
const completed = statesInCategory(states, "Completed");
|
|
1067
|
-
const pick = async (flag, message, candidates, def) => {
|
|
1979
|
+
const columnNames = boardColumns.map((c) => c.name);
|
|
1980
|
+
const pick = async (flag, message, def) => {
|
|
1068
1981
|
if (flag) return flag;
|
|
1069
|
-
if (
|
|
1070
|
-
if (candidates.length > 1) return prompter.select(message, candidates);
|
|
1982
|
+
if (columnNames.length > 0) return prompter.select(message, columnNames);
|
|
1071
1983
|
return await prompter.input(message, def) || def;
|
|
1072
1984
|
};
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
} else if (states.length > 0 && proposed.length === 0) {
|
|
1077
|
-
throw new InitAbort(
|
|
1078
|
-
'no To Do-type column: the Issue type on this board has no "Proposed" state where new issues can land. Add a To Do column/state to the board, then re-run `kodi init`.'
|
|
1079
|
-
);
|
|
1080
|
-
} else if (proposed.length > 0) {
|
|
1081
|
-
todo = proposed.length === 1 ? proposed[0] : await prompter.select("To Do column (where new issues are created)", proposed);
|
|
1082
|
-
} else {
|
|
1083
|
-
todo = await prompter.input(
|
|
1985
|
+
const columns = {
|
|
1986
|
+
todo: await pick(
|
|
1987
|
+
opts.todoColumn,
|
|
1084
1988
|
"To Do column (where new issues are created)",
|
|
1085
1989
|
DEFAULT_COLUMNS.todo
|
|
1086
|
-
);
|
|
1087
|
-
if (!todo) throw new InitAbort("missing: the To Do column.");
|
|
1088
|
-
}
|
|
1089
|
-
const columns = {
|
|
1090
|
-
todo,
|
|
1091
|
-
inProgress: await pick(
|
|
1092
|
-
opts.inProgressColumn,
|
|
1093
|
-
"In Progress column",
|
|
1094
|
-
inProg,
|
|
1095
|
-
DEFAULT_COLUMNS.inProgress
|
|
1096
|
-
),
|
|
1097
|
-
toReview: await pick(
|
|
1098
|
-
opts.toReviewColumn,
|
|
1099
|
-
"To Review column",
|
|
1100
|
-
inProg,
|
|
1101
|
-
DEFAULT_COLUMNS.toReview
|
|
1102
1990
|
),
|
|
1103
|
-
|
|
1991
|
+
inProgress: await pick(opts.inProgressColumn, "In Progress column", DEFAULT_COLUMNS.inProgress),
|
|
1992
|
+
toReview: await pick(opts.toReviewColumn, "To Review column", DEFAULT_COLUMNS.toReview),
|
|
1993
|
+
done: await pick(opts.doneColumn, "Done column", DEFAULT_COLUMNS.done)
|
|
1104
1994
|
};
|
|
1995
|
+
const stateByColumn = new Map(boardColumns.map((c) => [c.name, c.state]));
|
|
1996
|
+
const columnStates = {};
|
|
1997
|
+
for (const col of [columns.todo, columns.inProgress, columns.toReview, columns.done]) {
|
|
1998
|
+
columnStates[col] = stateByColumn.get(col) ?? col;
|
|
1999
|
+
}
|
|
1105
2000
|
const repository = opts.repository ?? (await prompter.input("Repository name for pull requests", project) || project);
|
|
1106
|
-
return {
|
|
2001
|
+
return {
|
|
2002
|
+
provider: "azure",
|
|
2003
|
+
prefix: "KODI",
|
|
2004
|
+
organization: org,
|
|
2005
|
+
project,
|
|
2006
|
+
team,
|
|
2007
|
+
board,
|
|
2008
|
+
repository,
|
|
2009
|
+
columns,
|
|
2010
|
+
columnStates
|
|
2011
|
+
};
|
|
1107
2012
|
}
|
|
1108
2013
|
async function configureGithub(prompter, opts) {
|
|
1109
2014
|
if (hasProjectWriteScope(opts.runner) === false) {
|
|
@@ -1203,12 +2108,12 @@ async function configureGithub(prompter, opts) {
|
|
|
1203
2108
|
}
|
|
1204
2109
|
function writeState(root, config) {
|
|
1205
2110
|
const path = stateFilePath(root);
|
|
1206
|
-
|
|
1207
|
-
|
|
2111
|
+
mkdirSync4(dirname5(path), { recursive: true });
|
|
2112
|
+
writeFileSync4(path, stringifyYaml3(config), "utf-8");
|
|
1208
2113
|
return path;
|
|
1209
2114
|
}
|
|
1210
2115
|
function registerInitCommand(program2) {
|
|
1211
|
-
program2.command("init").description("Install the kodi harness and configure the board (interactive)").option("-d, --dir <path>", "target project directory", process.cwd()).option("--force", "overwrite existing agents/skills", false).option("--provider <local|github|azure>", "skip the provider prompt").option("--prefix <prefix>", "local ticket key prefix (default KODI)").option("--org <url>", "azure org URL (non-interactive)").option("--project <name>", "azure project (non-interactive)").option("--owner-type <org|user>", "github board owner type (non-interactive)").option("--project-owner <login>", "github Projects owner login (org or user)").option("--project-number <n>", "github Projects board number", (v) => Number(v)).option("--todo-column <name>", "To Do column (non-interactive)").option("--in-progress-column <name>", "In Progress column").option("--to-review-column <name>", "To Review column").option("--done-column <name>", "Done column").option("--repository <name>", "repository for PRs (azure: name; github: owner/repo)").action(async (o) => {
|
|
2116
|
+
program2.command("init").description("Install the kodi harness and configure the board (interactive)").option("-d, --dir <path>", "target project directory", process.cwd()).option("--force", "overwrite existing agents/skills", false).option("--provider <local|github|azure>", "skip the provider prompt").option("--prefix <prefix>", "local ticket key prefix (default KODI)").option("--org <url>", "azure org URL (non-interactive)").option("--project <name>", "azure project (non-interactive)").option("--team <name>", "azure team that owns the board (non-interactive)").option("--board <name>", "azure board name (non-interactive, default Issues)").option("--owner-type <org|user>", "github board owner type (non-interactive)").option("--project-owner <login>", "github Projects owner login (org or user)").option("--project-number <n>", "github Projects board number", (v) => Number(v)).option("--todo-column <name>", "To Do column (non-interactive)").option("--in-progress-column <name>", "In Progress column").option("--to-review-column <name>", "To Review column").option("--done-column <name>", "Done column").option("--repository <name>", "repository for PRs (azure: name; github: owner/repo)").action(async (o) => {
|
|
1212
2117
|
const root = String(o.dir);
|
|
1213
2118
|
let provider = o.provider;
|
|
1214
2119
|
if (!provider && !process.stdin.isTTY) provider = "local";
|
|
@@ -1220,6 +2125,8 @@ function registerInitCommand(program2) {
|
|
|
1220
2125
|
prefix: o.prefix,
|
|
1221
2126
|
org: o.org,
|
|
1222
2127
|
project: o.project,
|
|
2128
|
+
team: o.team,
|
|
2129
|
+
board: o.board,
|
|
1223
2130
|
ownerType: o.ownerType,
|
|
1224
2131
|
projectOwner: o.projectOwner,
|
|
1225
2132
|
projectNumber: o.projectNumber,
|
|
@@ -1242,12 +2149,19 @@ kodi init aborted \u2014 ${e.message}
|
|
|
1242
2149
|
prompter.close();
|
|
1243
2150
|
}
|
|
1244
2151
|
const changed = installHarness(root, { force: o.force, provider: config.provider });
|
|
2152
|
+
try {
|
|
2153
|
+
const db = openDb();
|
|
2154
|
+
const displayName = config.project ?? (config.repository ? basename4(config.repository) : basename4(resolve2(root)));
|
|
2155
|
+
config.memory = provisionCollection(db, resolve2(root), displayName);
|
|
2156
|
+
db.close();
|
|
2157
|
+
} catch {
|
|
2158
|
+
}
|
|
1245
2159
|
const statePath = writeState(root, config);
|
|
1246
2160
|
process.stdout.write(
|
|
1247
2161
|
`
|
|
1248
2162
|
kodi init: installed
|
|
1249
2163
|
${changed.map((c) => ` + ${c}`).join("\n")}
|
|
1250
|
-
+ ${
|
|
2164
|
+
+ ${relative3(root, statePath)} (provider: ${config.provider})
|
|
1251
2165
|
|
|
1252
2166
|
SessionStart wired to \`${HOOK_COMMAND}\` (matchers: ${SESSION_MATCHER}).
|
|
1253
2167
|
`
|
|
@@ -1255,36 +2169,233 @@ SessionStart wired to \`${HOOK_COMMAND}\` (matchers: ${SESSION_MATCHER}).
|
|
|
1255
2169
|
});
|
|
1256
2170
|
}
|
|
1257
2171
|
|
|
2172
|
+
// src/commands/memory.ts
|
|
2173
|
+
import { readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
2174
|
+
import { parse as parseYaml4, stringify as stringifyYaml4 } from "yaml";
|
|
2175
|
+
function out(data, json, human) {
|
|
2176
|
+
if (json) process.stdout.write(JSON.stringify(data) + "\n");
|
|
2177
|
+
else process.stdout.write(human() + "\n");
|
|
2178
|
+
}
|
|
2179
|
+
function collect(value, previous) {
|
|
2180
|
+
return previous.concat([value]);
|
|
2181
|
+
}
|
|
2182
|
+
function asType(v) {
|
|
2183
|
+
if (v == null) return void 0;
|
|
2184
|
+
if (!MEMORY_TYPES.includes(v)) {
|
|
2185
|
+
throw new Error(`invalid --type "${v}". One of: ${MEMORY_TYPES.join(", ")}.`);
|
|
2186
|
+
}
|
|
2187
|
+
return v;
|
|
2188
|
+
}
|
|
2189
|
+
function renderLine(m) {
|
|
2190
|
+
const files = m.files.length ? ` {${fileNames(m.files).join(", ")}}` : "";
|
|
2191
|
+
const ticket = m.ticket ? ` ${m.ticket}` : "";
|
|
2192
|
+
const flag = m.needsReverify ? " \u26A0reverify" : "";
|
|
2193
|
+
return `${m.id} ${m.score}\u2605 [${m.type}]${ticket}${flag} ${m.title}${files}`;
|
|
2194
|
+
}
|
|
2195
|
+
function renderList(hits) {
|
|
2196
|
+
return hits.length ? hits.map(renderLine).join("\n") : "no memories";
|
|
2197
|
+
}
|
|
2198
|
+
function withDb(fn) {
|
|
2199
|
+
const db = openDb();
|
|
2200
|
+
try {
|
|
2201
|
+
return fn(db);
|
|
2202
|
+
} finally {
|
|
2203
|
+
db.close();
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
function withCollection(fn) {
|
|
2207
|
+
return withDb((db) => fn(db, resolveCollection(db), findProjectRoot()));
|
|
2208
|
+
}
|
|
2209
|
+
function registerMemoryCommand(program2) {
|
|
2210
|
+
const memory = program2.command("memory").description("Cross-session knowledge store (lexical FTS) \u2014 store/query project findings");
|
|
2211
|
+
memory.command("store").description("Store a finding (deduped by content \u2014 idempotent)").option("-d, --draft <path>", "YAML draft file (validated against the memory template)").option("-c, --content <text>", "the finding to remember").option("-t, --type <type>", `one of: ${MEMORY_TYPES.join(", ")}`).option("--ticket <key>", "ticket/task in flight (optional)").option(
|
|
2212
|
+
"--file <path>",
|
|
2213
|
+
"repo-relative file the finding touches (repeatable, \u22651 required)",
|
|
2214
|
+
collect,
|
|
2215
|
+
[]
|
|
2216
|
+
).option("--title <text>", "optional display title (else derived from content)").option("--json", "machine-readable output", false).action((o) => {
|
|
2217
|
+
const draft = o.draft ? MemoryDraftSchema.parse(parseYaml4(readFileSync6(String(o.draft), "utf-8"))) : MemoryDraftSchema.parse({
|
|
2218
|
+
content: o.content,
|
|
2219
|
+
type: o.type,
|
|
2220
|
+
ticket: o.ticket,
|
|
2221
|
+
files: o.file ?? [],
|
|
2222
|
+
title: o.title
|
|
2223
|
+
});
|
|
2224
|
+
withCollection((db, col, root) => {
|
|
2225
|
+
const { record, deduped, blocked } = insertMemory(db, col.collection, draft, root);
|
|
2226
|
+
if (blocked) {
|
|
2227
|
+
out(
|
|
2228
|
+
{ ...record, blocked: true },
|
|
2229
|
+
o.json,
|
|
2230
|
+
() => `Blocked (re-learn guard): ${record.id} was disproven \u2014 ${record.tombstoneReason ?? "refuted"}`
|
|
2231
|
+
);
|
|
2232
|
+
process.exitCode = 1;
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
2235
|
+
out(
|
|
2236
|
+
{ ...record, deduped },
|
|
2237
|
+
o.json,
|
|
2238
|
+
() => deduped ? `Already stored (dedup): ${record.id} \u2014 ${record.title}` : `Stored ${record.id} [${record.type}] (score ${record.score}) \u2014 ${record.title}`
|
|
2239
|
+
);
|
|
2240
|
+
});
|
|
2241
|
+
});
|
|
2242
|
+
memory.command("query [text...]").description("Search memories (BM25 text + filters), scoped to this project").option("-t, --type <type>", "filter by type").option("--ticket <key>", "filter by ticket").option("--file <path>", "filter by a referenced file path (substring)").option("--since <iso>", "only memories created on/after this ISO timestamp").option("-n, --limit <n>", "max results (default 10)", (v) => Number(v)).option("--json", "machine-readable output", false).action((text, o) => {
|
|
2243
|
+
withCollection((db, col) => {
|
|
2244
|
+
const hits = queryMemories(db, col.collection, {
|
|
2245
|
+
text: text?.length ? text.join(" ") : void 0,
|
|
2246
|
+
type: asType(o.type),
|
|
2247
|
+
ticket: o.ticket,
|
|
2248
|
+
file: o.file,
|
|
2249
|
+
since: o.since,
|
|
2250
|
+
limit: o.limit
|
|
2251
|
+
});
|
|
2252
|
+
out(hits, o.json, () => renderList(hits));
|
|
2253
|
+
});
|
|
2254
|
+
});
|
|
2255
|
+
memory.command("list").description("Browse recent memories in this project (newest first)").option("-t, --type <type>", "filter by type").option("--ticket <key>", "filter by ticket").option("-n, --limit <n>", "max results (default 10)", (v) => Number(v)).option("--json", "machine-readable output", false).action((o) => {
|
|
2256
|
+
withCollection((db, col) => {
|
|
2257
|
+
const hits = queryMemories(db, col.collection, {
|
|
2258
|
+
type: asType(o.type),
|
|
2259
|
+
ticket: o.ticket,
|
|
2260
|
+
limit: o.limit
|
|
2261
|
+
});
|
|
2262
|
+
out(hits, o.json, () => renderList(hits));
|
|
2263
|
+
});
|
|
2264
|
+
});
|
|
2265
|
+
memory.command("amend <id>").description("Edit a memory in place").option("-c, --content <text>").option("-t, --type <type>").option("--ticket <key>").option("--file <path>", "replace referenced files (repeatable)", collect).option("--title <text>").option("--json", "machine-readable output", false).action((id, o) => {
|
|
2266
|
+
withDb((db) => {
|
|
2267
|
+
const updated = amendMemory(
|
|
2268
|
+
db,
|
|
2269
|
+
id,
|
|
2270
|
+
{
|
|
2271
|
+
content: o.content,
|
|
2272
|
+
type: asType(o.type),
|
|
2273
|
+
ticket: o.ticket,
|
|
2274
|
+
files: o.file,
|
|
2275
|
+
title: o.title
|
|
2276
|
+
},
|
|
2277
|
+
findProjectRoot()
|
|
2278
|
+
);
|
|
2279
|
+
if (!updated) {
|
|
2280
|
+
out({ error: "not found", id }, o.json, () => `no memory ${id}`);
|
|
2281
|
+
process.exitCode = 1;
|
|
2282
|
+
return;
|
|
2283
|
+
}
|
|
2284
|
+
out(
|
|
2285
|
+
updated,
|
|
2286
|
+
o.json,
|
|
2287
|
+
() => `Amended ${updated.id} (score reset to ${updated.score}) \u2014 ${updated.title}`
|
|
2288
|
+
);
|
|
2289
|
+
});
|
|
2290
|
+
});
|
|
2291
|
+
memory.command("verify <id>").description("Record a veracity judgment: --pass raises the score, --fail tombstones").option("--pass", "the memory still holds against its files (score +1)", false).option("--fail", "the memory is disproven (tombstone + re-learn guard)", false).option("--reason <text>", "why it failed (stored on the tombstone)").option("--json", "machine-readable output", false).action((id, o) => {
|
|
2292
|
+
if (o.pass === o.fail) throw new Error("pass exactly one of --pass or --fail.");
|
|
2293
|
+
withDb((db) => {
|
|
2294
|
+
const updated = verifyMemory(db, id, !!o.pass, findProjectRoot(), o.reason);
|
|
2295
|
+
if (!updated) {
|
|
2296
|
+
out({ error: "not found", id }, o.json, () => `no memory ${id}`);
|
|
2297
|
+
process.exitCode = 1;
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
out(
|
|
2301
|
+
updated,
|
|
2302
|
+
o.json,
|
|
2303
|
+
() => updated.status === "tombstoned" ? `Tombstoned ${updated.id} \u2014 ${updated.tombstoneReason}` : `Verified ${updated.id} \u2014 score now ${updated.score}`
|
|
2304
|
+
);
|
|
2305
|
+
});
|
|
2306
|
+
});
|
|
2307
|
+
memory.command("rm <id>").description("Delete a memory").option("--json", "machine-readable output", false).action((id, o) => {
|
|
2308
|
+
withDb((db) => {
|
|
2309
|
+
const removed = removeMemory(db, id);
|
|
2310
|
+
out({ removed, id }, o.json, () => removed ? `Removed ${id}` : `no memory ${id}`);
|
|
2311
|
+
if (!removed) process.exitCode = 1;
|
|
2312
|
+
});
|
|
2313
|
+
});
|
|
2314
|
+
memory.command("export").description("Export this project\u2019s memories as YAML (all, or one --type)").option("-t, --type <type>", "export only this type").option("-f, --file <path>", "write to a file (default: stdout)").action((o) => {
|
|
2315
|
+
withCollection((db, col) => {
|
|
2316
|
+
const records = exportMemories(db, col.collection, asType(o.type));
|
|
2317
|
+
const doc = {
|
|
2318
|
+
collection: col.name,
|
|
2319
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2320
|
+
memories: records.map((m) => ({
|
|
2321
|
+
content: m.content,
|
|
2322
|
+
type: m.type,
|
|
2323
|
+
ticket: m.ticket,
|
|
2324
|
+
files: m.files,
|
|
2325
|
+
title: m.title,
|
|
2326
|
+
createdAt: m.createdAt
|
|
2327
|
+
}))
|
|
2328
|
+
};
|
|
2329
|
+
const yaml = stringifyYaml4(doc);
|
|
2330
|
+
if (o.file) {
|
|
2331
|
+
writeFileSync5(String(o.file), yaml, "utf-8");
|
|
2332
|
+
process.stderr.write(`Exported ${records.length} memories -> ${o.file}
|
|
2333
|
+
`);
|
|
2334
|
+
} else {
|
|
2335
|
+
process.stdout.write(yaml);
|
|
2336
|
+
}
|
|
2337
|
+
});
|
|
2338
|
+
});
|
|
2339
|
+
memory.command("import <path>").description("Import memories from a YAML file into this project (deduped)").option("-t, --type <type>", "import only this type").option("--json", "machine-readable output", false).action((path, o) => {
|
|
2340
|
+
const doc = MemoryExportDocSchema.parse(parseYaml4(readFileSync6(path, "utf-8")));
|
|
2341
|
+
withCollection((db, col, root) => {
|
|
2342
|
+
const res = importMemories(db, col.collection, doc.memories, root, asType(o.type));
|
|
2343
|
+
out(res, o.json, () => `Imported: +${res.added} added, ${res.skipped} skipped`);
|
|
2344
|
+
});
|
|
2345
|
+
});
|
|
2346
|
+
return memory;
|
|
2347
|
+
}
|
|
2348
|
+
|
|
1258
2349
|
// src/commands/pr.ts
|
|
1259
|
-
import { mkdtempSync, readFileSync as
|
|
2350
|
+
import { mkdtempSync, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "fs";
|
|
1260
2351
|
import { tmpdir } from "os";
|
|
1261
|
-
import { join as
|
|
2352
|
+
import { join as join5 } from "path";
|
|
1262
2353
|
|
|
1263
2354
|
// src/templates/pr.ts
|
|
1264
|
-
import { z as
|
|
1265
|
-
var PrSchema =
|
|
1266
|
-
title:
|
|
1267
|
-
summary:
|
|
1268
|
-
includedChanges:
|
|
1269
|
-
features:
|
|
1270
|
-
fixes:
|
|
1271
|
-
improvements:
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
2355
|
+
import { z as z4 } from "zod";
|
|
2356
|
+
var PrSchema = z4.object({
|
|
2357
|
+
title: z4.string().min(3, "title must be at least 3 characters"),
|
|
2358
|
+
summary: z4.string().min(1, "summary is required"),
|
|
2359
|
+
includedChanges: z4.array(z4.string().min(1)).default([]),
|
|
2360
|
+
features: z4.array(z4.string().min(1)).default([]),
|
|
2361
|
+
fixes: z4.array(z4.string().min(1)).default([]),
|
|
2362
|
+
improvements: z4.array(z4.string().min(1)).default([]),
|
|
2363
|
+
/**
|
|
2364
|
+
* Security vulnerabilities the slice surfaced, each referencing its report
|
|
2365
|
+
* (e.g. "CRITICAL — SQLi in login (docs/security/KODI-014-sqli-login.md)").
|
|
2366
|
+
* Rendered as a prominent section so reviewers see findings that still need
|
|
2367
|
+
* follow-up tickets. Empty when the slice produced no security reports.
|
|
2368
|
+
*/
|
|
2369
|
+
vulnerabilities: z4.array(z4.string().min(1)).default([]),
|
|
2370
|
+
relatedIssues: z4.array(z4.string().min(1)).default([]),
|
|
2371
|
+
notes: z4.string().optional(),
|
|
2372
|
+
reviewers: z4.array(z4.string().min(1)).default([])
|
|
1275
2373
|
});
|
|
2374
|
+
function neutralizeAutolinks(text) {
|
|
2375
|
+
return text.replace(/#(?=\d)/g, "#\u200B");
|
|
2376
|
+
}
|
|
2377
|
+
var MAX_PR_BODY = 4e3;
|
|
2378
|
+
function assertWithinBodyLimit(body) {
|
|
2379
|
+
if (body.length > MAX_PR_BODY) {
|
|
2380
|
+
throw new Error(
|
|
2381
|
+
`PR body is ${body.length} chars; the limit is ${MAX_PR_BODY}. Shorten the summary/notes or trim bullets, then retry.`
|
|
2382
|
+
);
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
1276
2385
|
function bulletSection(title, items) {
|
|
1277
2386
|
if (!items.length) return [];
|
|
1278
2387
|
return ["", `## ${title}`, "", ...items.map((i) => `- ${i}`)];
|
|
1279
2388
|
}
|
|
1280
2389
|
function renderPrMarkdown(pr) {
|
|
1281
|
-
const
|
|
1282
|
-
lines
|
|
1283
|
-
lines.push(...bulletSection("
|
|
1284
|
-
lines.push(...bulletSection("
|
|
1285
|
-
lines.push(...bulletSection("
|
|
2390
|
+
const s = neutralizeAutolinks;
|
|
2391
|
+
const lines = ["## Summary", "", s(pr.summary)];
|
|
2392
|
+
lines.push(...bulletSection("Security vulnerabilities", pr.vulnerabilities.map(s)));
|
|
2393
|
+
lines.push(...bulletSection("Included changes", pr.includedChanges.map(s)));
|
|
2394
|
+
lines.push(...bulletSection("Features", pr.features.map(s)));
|
|
2395
|
+
lines.push(...bulletSection("Fixes", pr.fixes.map(s)));
|
|
2396
|
+
lines.push(...bulletSection("Improvements", pr.improvements.map(s)));
|
|
1286
2397
|
lines.push(...bulletSection("Related issues", pr.relatedIssues));
|
|
1287
|
-
if (pr.notes) lines.push("", "## Notes", "", pr.notes);
|
|
2398
|
+
if (pr.notes) lines.push("", "## Notes", "", s(pr.notes));
|
|
1288
2399
|
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
|
1289
2400
|
}
|
|
1290
2401
|
function renderPrHtml(pr) {
|
|
@@ -1301,7 +2412,7 @@ function resolveTarget(explicit) {
|
|
|
1301
2412
|
);
|
|
1302
2413
|
}
|
|
1303
2414
|
function draftFromOptions(o) {
|
|
1304
|
-
if (o.file) return PrSchema.parse(JSON.parse(
|
|
2415
|
+
if (o.file) return PrSchema.parse(JSON.parse(readFileSync7(String(o.file), "utf-8")));
|
|
1305
2416
|
return PrSchema.parse({
|
|
1306
2417
|
title: o.title,
|
|
1307
2418
|
summary: o.summary,
|
|
@@ -1309,6 +2420,7 @@ function draftFromOptions(o) {
|
|
|
1309
2420
|
fixes: o.fix ?? [],
|
|
1310
2421
|
improvements: o.improvement ?? [],
|
|
1311
2422
|
includedChanges: o.change ?? [],
|
|
2423
|
+
vulnerabilities: o.vulnerability ?? [],
|
|
1312
2424
|
relatedIssues: o.issue ?? [],
|
|
1313
2425
|
notes: o.notes,
|
|
1314
2426
|
reviewers: o.reviewer ?? []
|
|
@@ -1352,14 +2464,21 @@ function azureCreateArgs(pr, html, source, target, repo) {
|
|
|
1352
2464
|
}
|
|
1353
2465
|
function registerPrCommand(program2) {
|
|
1354
2466
|
const pr = program2.command("pr").description("Manage pull requests (proxy gh/az) with a validated template");
|
|
1355
|
-
pr.command("create").description("Create a PR from a validated template draft").option("-f, --file <path>", "JSON PR draft (validated against the template)").option("-t, --title <title>").option("-s, --summary <summary>").option("--feature <text>", "feature (repeatable)",
|
|
2467
|
+
pr.command("create").description("Create a PR from a validated template draft").option("-f, --file <path>", "JSON PR draft (validated against the template)").option("-t, --title <title>").option("-s, --summary <summary>").option("--feature <text>", "feature (repeatable)", collect2, []).option("--fix <text>", "fix (repeatable)", collect2, []).option("--improvement <text>", "improvement (repeatable)", collect2, []).option("--change <text>", "included change (repeatable)", collect2, []).option(
|
|
2468
|
+
"--vulnerability <ref>",
|
|
2469
|
+
"security vulnerability the slice surfaced, referencing its report (repeatable)",
|
|
2470
|
+
collect2,
|
|
2471
|
+
[]
|
|
2472
|
+
).option("--issue <ref>", "related issue (repeatable)", collect2, []).option("--reviewer <name>", "reviewer (repeatable)", collect2, []).option("--notes <text>").requiredOption("--source <branch>", "branch the PR is opened from").requiredOption("--target <branch>", "branch the PR merges into").option("--provider <github|azure>", "override the PR provider").option("--repository <repo>", "repository (gh: OWNER/REPO; az: name)").option("--yes", "execute (default: dry-run)", false).action((o) => {
|
|
1356
2473
|
const draft = draftFromOptions(o);
|
|
1357
2474
|
const target = resolveTarget(o.provider);
|
|
1358
2475
|
const repo = o.repository ?? loadBoardConfig().repository;
|
|
2476
|
+
const body = renderPrMarkdown(draft);
|
|
2477
|
+
assertWithinBodyLimit(body);
|
|
1359
2478
|
let args;
|
|
1360
2479
|
if (target === "github") {
|
|
1361
|
-
const bodyFile =
|
|
1362
|
-
|
|
2480
|
+
const bodyFile = join5(mkdtempSync(join5(tmpdir(), "kodi-pr-")), "body.md");
|
|
2481
|
+
writeFileSync6(bodyFile, body, "utf-8");
|
|
1363
2482
|
args = githubCreateArgs(draft, bodyFile, o.source, o.target, repo);
|
|
1364
2483
|
} else {
|
|
1365
2484
|
args = azureCreateArgs(draft, renderPrHtml(draft), o.source, o.target, repo);
|
|
@@ -1381,27 +2500,27 @@ function registerPrCommand(program2) {
|
|
|
1381
2500
|
});
|
|
1382
2501
|
return pr;
|
|
1383
2502
|
}
|
|
1384
|
-
function
|
|
2503
|
+
function collect2(value, previous) {
|
|
1385
2504
|
return previous.concat([value]);
|
|
1386
2505
|
}
|
|
1387
2506
|
|
|
1388
2507
|
// src/commands/serve.ts
|
|
1389
2508
|
import { spawn } from "child_process";
|
|
1390
|
-
import { existsSync as
|
|
2509
|
+
import { existsSync as existsSync6 } from "fs";
|
|
1391
2510
|
import net from "net";
|
|
1392
|
-
import { dirname as
|
|
2511
|
+
import { dirname as dirname6, resolve as resolve3 } from "path";
|
|
1393
2512
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
1394
2513
|
var LISTEN_TIMEOUT_MS = 15e3;
|
|
1395
2514
|
var PROBE_INTERVAL_MS = 200;
|
|
1396
2515
|
var KILL_GRACE_MS = 2500;
|
|
1397
2516
|
function resolveBoardServer() {
|
|
1398
|
-
const moduleDir =
|
|
2517
|
+
const moduleDir = dirname6(fileURLToPath3(import.meta.url));
|
|
1399
2518
|
const candidates = [
|
|
1400
|
-
|
|
1401
|
-
|
|
2519
|
+
resolve3(moduleDir, "..", "board-dist", "server.js"),
|
|
2520
|
+
resolve3(moduleDir, "..", "..", "board-dist", "server.js")
|
|
1402
2521
|
];
|
|
1403
2522
|
for (const candidate of candidates) {
|
|
1404
|
-
if (
|
|
2523
|
+
if (existsSync6(candidate)) return candidate;
|
|
1405
2524
|
}
|
|
1406
2525
|
throw new Error("board not built \u2014 run `pnpm build` before `kodi tickets serve`");
|
|
1407
2526
|
}
|
|
@@ -1496,7 +2615,7 @@ function registerServeCommand(program2) {
|
|
|
1496
2615
|
throw new Error("internal: `tickets` command must be registered before `serve`");
|
|
1497
2616
|
}
|
|
1498
2617
|
tickets.command("serve").alias("open").description("Launch the local board UI in the browser (read-only view of docs/tickets)").option("--port <n>", "bind the board to a specific port (default: an OS-picked free port)").action(async (o) => {
|
|
1499
|
-
const ticketsDir =
|
|
2618
|
+
const ticketsDir = resolve3(localPaths(process.cwd()).root);
|
|
1500
2619
|
let serverJs;
|
|
1501
2620
|
try {
|
|
1502
2621
|
serverJs = resolveBoardServer();
|
|
@@ -1580,22 +2699,22 @@ function registerServeCommand(program2) {
|
|
|
1580
2699
|
}
|
|
1581
2700
|
|
|
1582
2701
|
// src/commands/tickets.ts
|
|
1583
|
-
import { readFileSync as
|
|
2702
|
+
import { readFileSync as readFileSync9 } from "fs";
|
|
1584
2703
|
|
|
1585
2704
|
// src/providers/local.ts
|
|
1586
2705
|
import { randomBytes as randomBytes2 } from "crypto";
|
|
1587
2706
|
import {
|
|
1588
|
-
existsSync as
|
|
2707
|
+
existsSync as existsSync7,
|
|
1589
2708
|
lstatSync,
|
|
1590
|
-
mkdirSync as
|
|
2709
|
+
mkdirSync as mkdirSync5,
|
|
1591
2710
|
readdirSync as readdirSync3,
|
|
1592
|
-
readFileSync as
|
|
2711
|
+
readFileSync as readFileSync8,
|
|
1593
2712
|
renameSync as renameSync2,
|
|
1594
2713
|
unlinkSync as unlinkSync2,
|
|
1595
|
-
writeFileSync as
|
|
2714
|
+
writeFileSync as writeFileSync7
|
|
1596
2715
|
} from "fs";
|
|
1597
|
-
import { basename as
|
|
1598
|
-
import { parse as
|
|
2716
|
+
import { basename as basename5, join as join6 } from "path";
|
|
2717
|
+
import { parse as parseYaml5, stringify as stringifyYaml5 } from "yaml";
|
|
1599
2718
|
var FRONTMATTER = /^---\n([\s\S]*?)\n---\n?/;
|
|
1600
2719
|
var LEGACY_FOLDERS = ["backlog", "done"];
|
|
1601
2720
|
var LEGACY_FILENAME_RE = /^[A-Z][A-Z0-9]*-\d+-.*\.md$/;
|
|
@@ -1626,11 +2745,11 @@ var LocalTicketProvider = class {
|
|
|
1626
2745
|
*/
|
|
1627
2746
|
scaffold() {
|
|
1628
2747
|
this.detectLegacyData();
|
|
1629
|
-
if (!
|
|
2748
|
+
if (!existsSync7(this.paths.statusYaml)) {
|
|
1630
2749
|
save(this.paths.statusYaml, emptyDocument());
|
|
1631
2750
|
}
|
|
1632
2751
|
for (const status of TICKET_STATUSES) {
|
|
1633
|
-
|
|
2752
|
+
mkdirSync5(this.paths.folderFor(status), { recursive: true });
|
|
1634
2753
|
}
|
|
1635
2754
|
}
|
|
1636
2755
|
/**
|
|
@@ -1648,7 +2767,7 @@ var LocalTicketProvider = class {
|
|
|
1648
2767
|
* the first confirmed ticket-shaped file (SR-H).
|
|
1649
2768
|
*/
|
|
1650
2769
|
detectLegacyData() {
|
|
1651
|
-
if (
|
|
2770
|
+
if (existsSync7(this.paths.statusYaml)) return;
|
|
1652
2771
|
const states = LEGACY_FOLDERS.map((name2) => ({ name: name2, state: this.legacyDirState(name2) }));
|
|
1653
2772
|
const present = states.filter((s) => s.state !== "absent");
|
|
1654
2773
|
if (present.length === 0) return;
|
|
@@ -1658,7 +2777,7 @@ var LocalTicketProvider = class {
|
|
|
1658
2777
|
}
|
|
1659
2778
|
for (const { name: name2, state } of states) {
|
|
1660
2779
|
if (state !== "dir") continue;
|
|
1661
|
-
const dir =
|
|
2780
|
+
const dir = join6(this.paths.root, name2);
|
|
1662
2781
|
let entries;
|
|
1663
2782
|
try {
|
|
1664
2783
|
entries = readdirSync3(dir, { withFileTypes: true });
|
|
@@ -1668,7 +2787,7 @@ var LocalTicketProvider = class {
|
|
|
1668
2787
|
for (const dirent of entries) {
|
|
1669
2788
|
if (!dirent.isFile()) continue;
|
|
1670
2789
|
if (!LEGACY_FILENAME_RE.test(dirent.name)) continue;
|
|
1671
|
-
if (legacyFileHasKey(
|
|
2790
|
+
if (legacyFileHasKey(join6(dir, dirent.name))) {
|
|
1672
2791
|
throw new LegacyDataError(folders, 1);
|
|
1673
2792
|
}
|
|
1674
2793
|
}
|
|
@@ -1681,7 +2800,7 @@ var LocalTicketProvider = class {
|
|
|
1681
2800
|
* `unsafe` (SR-B fail-safe); a real directory is `dir`.
|
|
1682
2801
|
*/
|
|
1683
2802
|
legacyDirState(name2) {
|
|
1684
|
-
const dir =
|
|
2803
|
+
const dir = join6(this.paths.root, name2);
|
|
1685
2804
|
let st;
|
|
1686
2805
|
try {
|
|
1687
2806
|
st = lstatSync(dir);
|
|
@@ -1696,13 +2815,13 @@ var LocalTicketProvider = class {
|
|
|
1696
2815
|
parseFile(path) {
|
|
1697
2816
|
let raw;
|
|
1698
2817
|
try {
|
|
1699
|
-
raw =
|
|
2818
|
+
raw = readFileSync8(path, "utf-8");
|
|
1700
2819
|
} catch {
|
|
1701
2820
|
return null;
|
|
1702
2821
|
}
|
|
1703
2822
|
const m = FRONTMATTER.exec(raw);
|
|
1704
2823
|
if (!m) return null;
|
|
1705
|
-
const meta =
|
|
2824
|
+
const meta = parseYaml5(m[1]) ?? {};
|
|
1706
2825
|
const parsed = TicketSchema.safeParse(meta);
|
|
1707
2826
|
if (!parsed.success || !parsed.data.key || !parsed.data.slug) return null;
|
|
1708
2827
|
return parsed.data;
|
|
@@ -1736,21 +2855,21 @@ var LocalTicketProvider = class {
|
|
|
1736
2855
|
*/
|
|
1737
2856
|
writeTicketFile(ticket) {
|
|
1738
2857
|
const dir = this.paths.folderFor(ticket.status);
|
|
1739
|
-
|
|
1740
|
-
const finalName =
|
|
1741
|
-
const finalPath =
|
|
2858
|
+
mkdirSync5(dir, { recursive: true });
|
|
2859
|
+
const finalName = basename5(composeFile(ticket.status, ticket.key, ticket.slug));
|
|
2860
|
+
const finalPath = join6(dir, finalName);
|
|
1742
2861
|
const body = `---
|
|
1743
|
-
${
|
|
2862
|
+
${stringifyYaml5(sortTicket(ticket)).trimEnd()}
|
|
1744
2863
|
---
|
|
1745
2864
|
|
|
1746
2865
|
` + renderTicketMarkdown(ticket);
|
|
1747
|
-
const tmp =
|
|
2866
|
+
const tmp = join6(dir, `.${finalName}.${randomBytes2(6).toString("hex")}.tmp`);
|
|
1748
2867
|
try {
|
|
1749
|
-
|
|
2868
|
+
writeFileSync7(tmp, body, { encoding: "utf-8", flag: "wx", mode: 384 });
|
|
1750
2869
|
renameSync2(tmp, finalPath);
|
|
1751
2870
|
} catch (err) {
|
|
1752
2871
|
try {
|
|
1753
|
-
if (
|
|
2872
|
+
if (existsSync7(tmp)) unlinkSync2(tmp);
|
|
1754
2873
|
} catch {
|
|
1755
2874
|
}
|
|
1756
2875
|
throw err;
|
|
@@ -1788,11 +2907,11 @@ ${stringifyYaml3(sortTicket(ticket)).trimEnd()}
|
|
|
1788
2907
|
if (load(this.paths.statusYaml).tickets[key]) {
|
|
1789
2908
|
throw new Error(`ticket ${key} already exists`);
|
|
1790
2909
|
}
|
|
1791
|
-
const
|
|
1792
|
-
const ticket = { ...input, key, slug };
|
|
2910
|
+
const slug2 = input.slug ?? slugify(input.title);
|
|
2911
|
+
const ticket = { ...input, key, slug: slug2 };
|
|
1793
2912
|
this.writeTicketFile(ticket);
|
|
1794
2913
|
const doc = load(this.paths.statusYaml);
|
|
1795
|
-
upsert(doc, { key, column: ticket.status, slug });
|
|
2914
|
+
upsert(doc, { key, column: ticket.status, slug: slug2 });
|
|
1796
2915
|
save(this.paths.statusYaml, doc);
|
|
1797
2916
|
return ticket;
|
|
1798
2917
|
}
|
|
@@ -1861,7 +2980,7 @@ ${stringifyYaml3(sortTicket(ticket)).trimEnd()}
|
|
|
1861
2980
|
function legacyFileHasKey(path) {
|
|
1862
2981
|
let raw;
|
|
1863
2982
|
try {
|
|
1864
|
-
raw =
|
|
2983
|
+
raw = readFileSync8(path, "utf-8");
|
|
1865
2984
|
} catch {
|
|
1866
2985
|
return false;
|
|
1867
2986
|
}
|
|
@@ -1869,7 +2988,7 @@ function legacyFileHasKey(path) {
|
|
|
1869
2988
|
if (!m) return false;
|
|
1870
2989
|
let meta;
|
|
1871
2990
|
try {
|
|
1872
|
-
meta =
|
|
2991
|
+
meta = parseYaml5(m[1]);
|
|
1873
2992
|
} catch {
|
|
1874
2993
|
return false;
|
|
1875
2994
|
}
|
|
@@ -1903,9 +3022,9 @@ function sortTicket(t) {
|
|
|
1903
3022
|
}
|
|
1904
3023
|
|
|
1905
3024
|
// src/providers/github.ts
|
|
1906
|
-
import { mkdtempSync as mkdtempSync2, writeFileSync as
|
|
3025
|
+
import { mkdtempSync as mkdtempSync2, writeFileSync as writeFileSync8 } from "fs";
|
|
1907
3026
|
import { tmpdir as tmpdir2 } from "os";
|
|
1908
|
-
import { join as
|
|
3027
|
+
import { join as join7 } from "path";
|
|
1909
3028
|
var MARKER_RE2 = /<!--\s*kodi:ticket\s+(\{[\s\S]*?\})\s*-->/;
|
|
1910
3029
|
var DEFAULT_COLUMNS2 = {
|
|
1911
3030
|
todo: "Todo",
|
|
@@ -1947,19 +3066,19 @@ function parseMarker(body) {
|
|
|
1947
3066
|
function parseItems(json) {
|
|
1948
3067
|
const data = JSON.parse(json);
|
|
1949
3068
|
const items = Array.isArray(data) ? data : data.items ?? [];
|
|
1950
|
-
const
|
|
3069
|
+
const out3 = [];
|
|
1951
3070
|
for (const it of items) {
|
|
1952
3071
|
const content = it?.content ?? {};
|
|
1953
3072
|
if (content.type !== "Issue" || typeof content.number !== "number" || typeof it?.id !== "string")
|
|
1954
3073
|
continue;
|
|
1955
|
-
|
|
3074
|
+
out3.push({
|
|
1956
3075
|
itemId: it.id,
|
|
1957
3076
|
issueNumber: content.number,
|
|
1958
3077
|
statusName: typeof it?.status === "string" ? it.status : void 0,
|
|
1959
3078
|
body: typeof content.body === "string" ? content.body : void 0
|
|
1960
3079
|
});
|
|
1961
3080
|
}
|
|
1962
|
-
return
|
|
3081
|
+
return out3;
|
|
1963
3082
|
}
|
|
1964
3083
|
function createIssueArgs(repo, title, bodyFile) {
|
|
1965
3084
|
const args = ["gh", "issue", "create", "--title", title, "--body-file", bodyFile];
|
|
@@ -2015,7 +3134,7 @@ var GithubTicketProvider = class {
|
|
|
2015
3134
|
/** Read (and cache) all board items in one call. */
|
|
2016
3135
|
items() {
|
|
2017
3136
|
if (this.itemsCache) return this.itemsCache;
|
|
2018
|
-
const
|
|
3137
|
+
const out3 = execRead([
|
|
2019
3138
|
"gh",
|
|
2020
3139
|
"project",
|
|
2021
3140
|
"item-list",
|
|
@@ -2027,7 +3146,7 @@ var GithubTicketProvider = class {
|
|
|
2027
3146
|
"--limit",
|
|
2028
3147
|
"500"
|
|
2029
3148
|
]);
|
|
2030
|
-
return this.itemsCache = parseItems(
|
|
3149
|
+
return this.itemsCache = parseItems(out3);
|
|
2031
3150
|
}
|
|
2032
3151
|
/** Body for an item — from item-list when present, else a per-issue fallback fetch. */
|
|
2033
3152
|
bodyFor(item) {
|
|
@@ -2055,8 +3174,8 @@ var GithubTicketProvider = class {
|
|
|
2055
3174
|
return "(assigned by github on create)";
|
|
2056
3175
|
}
|
|
2057
3176
|
async create(input) {
|
|
2058
|
-
const
|
|
2059
|
-
const draft = { ...input, key: "(pending)", slug };
|
|
3177
|
+
const slug2 = input.slug ?? slugify(input.title);
|
|
3178
|
+
const draft = { ...input, key: "(pending)", slug: slug2 };
|
|
2060
3179
|
const bodyFile = writeTempBody(serializeBody(draft));
|
|
2061
3180
|
const r1 = execMutate(createIssueArgs(this.opts.repo, input.title, bodyFile), this.opts.dryRun);
|
|
2062
3181
|
if (!r1.ran) {
|
|
@@ -2147,8 +3266,8 @@ function toRef3(t) {
|
|
|
2147
3266
|
};
|
|
2148
3267
|
}
|
|
2149
3268
|
function writeTempBody(body) {
|
|
2150
|
-
const file =
|
|
2151
|
-
|
|
3269
|
+
const file = join7(mkdtempSync2(join7(tmpdir2(), "kodi-gh-")), "body.md");
|
|
3270
|
+
writeFileSync8(file, body, "utf-8");
|
|
2152
3271
|
return file;
|
|
2153
3272
|
}
|
|
2154
3273
|
|
|
@@ -2172,6 +3291,7 @@ function resolveProvider(cwd = process.cwd(), opts = {}) {
|
|
|
2172
3291
|
organization: cfg.organization,
|
|
2173
3292
|
project: cfg.project,
|
|
2174
3293
|
columns: cfg.columns,
|
|
3294
|
+
columnStates: cfg.columnStates,
|
|
2175
3295
|
dryRun: !opts.yes,
|
|
2176
3296
|
cwd
|
|
2177
3297
|
});
|
|
@@ -2181,13 +3301,13 @@ function resolveProvider(cwd = process.cwd(), opts = {}) {
|
|
|
2181
3301
|
}
|
|
2182
3302
|
|
|
2183
3303
|
// src/commands/tickets.ts
|
|
2184
|
-
function
|
|
3304
|
+
function out2(data, json, human) {
|
|
2185
3305
|
if (json) process.stdout.write(JSON.stringify(data) + "\n");
|
|
2186
3306
|
else process.stdout.write(human() + "\n");
|
|
2187
3307
|
}
|
|
2188
3308
|
function draftFromOptions2(o) {
|
|
2189
3309
|
if (o.file) {
|
|
2190
|
-
const raw = JSON.parse(
|
|
3310
|
+
const raw = JSON.parse(readFileSync9(String(o.file), "utf-8"));
|
|
2191
3311
|
return TicketSchema.parse(raw);
|
|
2192
3312
|
}
|
|
2193
3313
|
return TicketSchema.parse({
|
|
@@ -2206,14 +3326,14 @@ function draftFromOptions2(o) {
|
|
|
2206
3326
|
}
|
|
2207
3327
|
function registerTicketsCommand(program2) {
|
|
2208
3328
|
const tickets = program2.command("tickets").description("Manage tickets across the active provider (local / github / azure)");
|
|
2209
|
-
tickets.command("create").description("Create a ticket (validates the template)").option("-f, --file <path>", "JSON draft file (validated against the ticket template)").option("-t, --title <title>").option("-s, --summary <summary>").option("--ac <criterion>", "acceptance criterion (repeatable)",
|
|
3329
|
+
tickets.command("create").description("Create a ticket (validates the template)").option("-f, --file <path>", "JSON draft file (validated against the ticket template)").option("-t, --title <title>").option("-s, --summary <summary>").option("--ac <criterion>", "acceptance criterion (repeatable)", collect3, []).option("--non-goal <text>", "non-goal (repeatable)", collect3, []).option("--dep <key>", "dependency ticket key (repeatable)", collect3, []).option("--prd <ref>", "PRD driver").option("--adr <ref>", "ADR driver (repeatable)", collect3, []).option("--security <ref>", "security driver").option("--notes <text>").option("--yes", "execute remote mutations (default: dry-run)", false).option("--json", "machine-readable output", false).action(async (o) => {
|
|
2210
3330
|
const draft = draftFromOptions2(o);
|
|
2211
3331
|
try {
|
|
2212
3332
|
const created = await resolveProvider(process.cwd(), { yes: o.yes }).create(draft);
|
|
2213
|
-
|
|
3333
|
+
out2(created, o.json, () => `Created ${created.key} (${created.status}) \u2014 ${created.title}`);
|
|
2214
3334
|
} catch (err) {
|
|
2215
3335
|
if (err instanceof LegacyDataError) {
|
|
2216
|
-
|
|
3336
|
+
out2(err.report, o.json, () => renderLegacyRefusal(err.report));
|
|
2217
3337
|
process.exitCode = 1;
|
|
2218
3338
|
return;
|
|
2219
3339
|
}
|
|
@@ -2222,7 +3342,7 @@ function registerTicketsCommand(program2) {
|
|
|
2222
3342
|
});
|
|
2223
3343
|
tickets.command("list").description("List all tickets").option("--json", "machine-readable output", false).action(async (o) => {
|
|
2224
3344
|
const refs = await resolveProvider().list();
|
|
2225
|
-
|
|
3345
|
+
out2(
|
|
2226
3346
|
refs,
|
|
2227
3347
|
o.json,
|
|
2228
3348
|
() => refs.length ? refs.map((t) => `${t.key} ${t.status.padEnd(12)} ${t.title}`).join("\n") : "No tickets."
|
|
@@ -2230,7 +3350,7 @@ function registerTicketsCommand(program2) {
|
|
|
2230
3350
|
});
|
|
2231
3351
|
tickets.command("list-ready").description("List tickets ready to start (no unmet dependency) + the blocked set").option("--json", "machine-readable output", false).action(async (o) => {
|
|
2232
3352
|
const res = await resolveProvider().listReady();
|
|
2233
|
-
|
|
3353
|
+
out2(res, o.json, () => {
|
|
2234
3354
|
const ready = res.ready.map((t) => ` ${t.key} ${t.title}`).join("\n") || " (none)";
|
|
2235
3355
|
const blocked = res.blocked.map((b) => ` ${b.ticket.key} ${b.ticket.title} \u2190 ${b.blockedBy.join(", ")}`).join("\n") || " (none)";
|
|
2236
3356
|
return `Ready:
|
|
@@ -2248,7 +3368,7 @@ ${blocked}`;
|
|
|
2248
3368
|
process.exitCode = 1;
|
|
2249
3369
|
return;
|
|
2250
3370
|
}
|
|
2251
|
-
|
|
3371
|
+
out2(t, o.json, () => JSON.stringify(t, null, 2));
|
|
2252
3372
|
});
|
|
2253
3373
|
tickets.command("set-status <key> <status>").description(`Transition a ticket (${TICKET_STATUSES.join(" | ")})`).option("--yes", "execute remote mutations (default: dry-run)", false).option("--json", "machine-readable output", false).action(async (key, status, o) => {
|
|
2254
3374
|
if (!TICKET_STATUSES.includes(status)) {
|
|
@@ -2261,32 +3381,32 @@ ${blocked}`;
|
|
|
2261
3381
|
key,
|
|
2262
3382
|
status
|
|
2263
3383
|
);
|
|
2264
|
-
|
|
3384
|
+
out2(t, o.json, () => `${t.key} \u2192 ${t.status}`);
|
|
2265
3385
|
});
|
|
2266
3386
|
tickets.command("start <key>").description("Mark a ticket started (In progress)").option("--branch <name>").option("--yes", "execute remote mutations (default: dry-run)", false).option("--json", "machine-readable output", false).action(async (key, o) => {
|
|
2267
3387
|
const t = await resolveProvider(process.cwd(), { yes: o.yes }).start(key, {
|
|
2268
3388
|
branch: o.branch
|
|
2269
3389
|
});
|
|
2270
|
-
|
|
3390
|
+
out2(t, o.json, () => `${t.key} \u2192 ${t.status}`);
|
|
2271
3391
|
});
|
|
2272
3392
|
tickets.command("delete <key>").description("Delete a ticket").option("--yes", "execute remote mutations (default: dry-run)", false).option("--json", "machine-readable output", false).action(async (key, o) => {
|
|
2273
3393
|
await resolveProvider(process.cwd(), { yes: o.yes }).delete(key);
|
|
2274
|
-
|
|
3394
|
+
out2({ deleted: key }, o.json, () => `Deleted ${key}`);
|
|
2275
3395
|
});
|
|
2276
3396
|
tickets.command("next-id").description("Compute the next ticket key").option("--json", "machine-readable output", false).action(async (o) => {
|
|
2277
3397
|
const id = await resolveProvider().nextId();
|
|
2278
|
-
|
|
3398
|
+
out2({ nextId: id }, o.json, () => id);
|
|
2279
3399
|
});
|
|
2280
|
-
tickets.command("amend <key>").description("Edit a ticket (from --file patch or flags)").option("-f, --file <path>", "JSON patch (validated against the template)").option("-s, --summary <text>").option("--notes <text>").option("--ac <criterion>", "replace acceptance criteria (repeatable)",
|
|
2281
|
-
const patch = o.file ? TicketSchema.partial().parse(JSON.parse(
|
|
3400
|
+
tickets.command("amend <key>").description("Edit a ticket (from --file patch or flags)").option("-f, --file <path>", "JSON patch (validated against the template)").option("-s, --summary <text>").option("--notes <text>").option("--ac <criterion>", "replace acceptance criteria (repeatable)", collect3, []).option("--dep <key>", "replace dependencies (repeatable)", collect3, []).option("--pr <ref>", "link a PR").option("--yes", "execute remote mutations (default: dry-run)", false).option("--json", "machine-readable output", false).action(async (key, o) => {
|
|
3401
|
+
const patch = o.file ? TicketSchema.partial().parse(JSON.parse(readFileSync9(String(o.file), "utf-8"))) : buildPatch(o);
|
|
2282
3402
|
const t = await resolveProvider(process.cwd(), { yes: o.yes }).amend(key, patch);
|
|
2283
|
-
|
|
3403
|
+
out2(t, o.json, () => `Amended ${t.key}`);
|
|
2284
3404
|
});
|
|
2285
3405
|
tickets.command("link-pr <key> <pr>").description("Bind a pull request to a ticket").option("--yes", "execute remote mutations (default: dry-run)", false).option("--json", "machine-readable output", false).action(async (key, pr, o) => {
|
|
2286
3406
|
const t = await resolveProvider(process.cwd(), { yes: o.yes }).amend(key, { prUrl: pr });
|
|
2287
|
-
|
|
3407
|
+
out2(t, o.json, () => `${t.key} \u2194 ${pr}`);
|
|
2288
3408
|
});
|
|
2289
|
-
tickets.command("deps <key>").description("Read a ticket's dependencies, or declare new ones with --add").option("--add <key>", "declare a dependency (repeatable)",
|
|
3409
|
+
tickets.command("deps <key>").description("Read a ticket's dependencies, or declare new ones with --add").option("--add <key>", "declare a dependency (repeatable)", collect3, []).option("--yes", "execute remote mutations (default: dry-run)", false).option("--json", "machine-readable output", false).action(async (key, o) => {
|
|
2290
3410
|
const provider = resolveProvider(process.cwd(), { yes: o.yes });
|
|
2291
3411
|
const current = await provider.get(key);
|
|
2292
3412
|
if (!current) {
|
|
@@ -2298,9 +3418,9 @@ ${blocked}`;
|
|
|
2298
3418
|
if (o.add.length) {
|
|
2299
3419
|
const deps = Array.from(/* @__PURE__ */ new Set([...current.dependencies, ...o.add]));
|
|
2300
3420
|
const t = await provider.amend(key, { dependencies: deps });
|
|
2301
|
-
|
|
3421
|
+
out2(t.dependencies, o.json, () => `${key} deps: ${t.dependencies.join(", ") || "(none)"}`);
|
|
2302
3422
|
} else {
|
|
2303
|
-
|
|
3423
|
+
out2(
|
|
2304
3424
|
current.dependencies,
|
|
2305
3425
|
o.json,
|
|
2306
3426
|
() => `${key} deps: ${current.dependencies.join(", ") || "(none)"}`
|
|
@@ -2311,11 +3431,11 @@ ${blocked}`;
|
|
|
2311
3431
|
const provider = resolveProvider(process.cwd(), { yes: o.yes });
|
|
2312
3432
|
if (o.pr) await provider.amend(key, { prUrl: o.pr });
|
|
2313
3433
|
const t = await provider.setStatus(key, "To review");
|
|
2314
|
-
|
|
3434
|
+
out2(t, o.json, () => `${t.key} \u2192 ${t.status}${o.pr ? ` (PR ${o.pr})` : ""}`);
|
|
2315
3435
|
});
|
|
2316
3436
|
return tickets;
|
|
2317
3437
|
}
|
|
2318
|
-
function
|
|
3438
|
+
function collect3(value, previous) {
|
|
2319
3439
|
return previous.concat([value]);
|
|
2320
3440
|
}
|
|
2321
3441
|
function renderLegacyRefusal(report) {
|
|
@@ -2337,7 +3457,7 @@ function buildPatch(o) {
|
|
|
2337
3457
|
|
|
2338
3458
|
// package.json
|
|
2339
3459
|
var name = "kodi-dev";
|
|
2340
|
-
var version = "1.0
|
|
3460
|
+
var version = "1.2.0";
|
|
2341
3461
|
var description = "kodi.dev \u2014 Claude Code-native agent orchestrator CLI";
|
|
2342
3462
|
|
|
2343
3463
|
// src/index.ts
|
|
@@ -2349,6 +3469,7 @@ registerPrCommand(program);
|
|
|
2349
3469
|
registerHookCommand(program);
|
|
2350
3470
|
registerInitCommand(program);
|
|
2351
3471
|
registerAddCommand(program);
|
|
3472
|
+
registerMemoryCommand(program);
|
|
2352
3473
|
program.parseAsync(process.argv).catch((err) => {
|
|
2353
3474
|
process.stderr.write(`${err instanceof Error ? err.message : String(err)}
|
|
2354
3475
|
`);
|