@boyingliu01/xp-gate 0.21.0 → 0.23.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.
@@ -319,175 +319,226 @@ function extractTitleFromText(text: string): string {
319
319
 
320
320
  // ── Plugin definition ──
321
321
 
322
+ const SQLITE_TIMEOUT_MS = 5_000
323
+
324
+ // Boy Scout (#447): XpGatePlugin used to inline five tool({...}) definitions
325
+ // (191 lines, 7-8 brace levels). Each tool now lives in its own factory;
326
+ // XpGatePlugin is pure wiring.
327
+ function gateCheckTool(directory: string) {
328
+ return tool({
329
+ description: "Run xp-gate quality gates (Gate 4 + Gate 6) on a file or directory. Prefers global xp-gate CLI; falls back to direct checker source.",
330
+ args: {
331
+ path: z.string().describe("File or directory path (absolute or relative to workspace)"),
332
+ gates: z.array(z.string()).optional().describe("Optional gate subset (e.g. ['principles', 'arch'])"),
333
+ },
334
+ async execute(args, ctx) {
335
+ const cwd = ctx.directory || directory
336
+ const target = args.path.startsWith("/") ? args.path : `${cwd}/${args.path}`
337
+ const gatesFlag = args.gates?.length ? ` --gates ${args.gates.join(",")}` : ""
338
+ const result = await runXpGate(`check "${target}"${gatesFlag}`, cwd)
339
+ if (result) {
340
+ const upgrade = await getUpgradeSuggestion(cwd)
341
+ return upgrade ? `${result}\n${upgrade}` : result
342
+ }
343
+ const cmd = `node ${directory}/src/npm-package/bin/xp-gate.js check "${target}"${gatesFlag}`
344
+ return runCmd(cmd, cwd)
345
+ },
346
+ })
347
+ }
348
+
349
+ function gatePrinciplesTool(directory: string) {
350
+ return tool({
351
+ description: "Run Clean Code + SOLID principles checker (Gate 4 standalone) on a file or directory.",
352
+ args: {
353
+ path: z.string().describe("Source file or directory path to check"),
354
+ },
355
+ async execute(args, ctx) {
356
+ const cwd = ctx.directory || directory
357
+ const target = args.path.startsWith("/") ? args.path : `${cwd}/${args.path}`
358
+ const result = await runXpGate(`principles "${target}"`, cwd)
359
+ if (result) {
360
+ const upgrade = await getUpgradeSuggestion(cwd)
361
+ return upgrade ? `${result}\n${upgrade}` : result
362
+ }
363
+ const cmd = `npx -y tsx ${directory}/src/principles/index.ts --files "${target}" --format console`
364
+ return runCmd(cmd, cwd)
365
+ },
366
+ })
367
+ }
368
+
369
+ function gateArchTool(directory: string) {
370
+ return tool({
371
+ description:
372
+ "Run architecture validation (Gate 6 standalone) on the repository with the pinned local @archlinter/cli built-in rules and baseline ratchet.",
373
+ args: {
374
+ config: z
375
+ .string()
376
+ .optional()
377
+ .describe(
378
+ "Optional architecture config file (opts into archlint --config; pre-0.16 schemas are rejected by archlint itself)",
379
+ ),
380
+ },
381
+ async execute(args, ctx) {
382
+ const cwd = ctx.directory || directory
383
+ // #447: default to the built-in-rules path — no --config. Forcing
384
+ // `--config architecture.yaml` hit the pre-0.16 schema archlint rejects.
385
+ const archArgs = args.config ? `arch --use-config ${args.config}` : "arch"
386
+ const result = await runXpGate(archArgs, cwd)
387
+ if (result) {
388
+ const upgrade = await getUpgradeSuggestion(cwd)
389
+ return upgrade ? `${result}\n${upgrade}` : result
390
+ }
391
+ const fallback = args.config
392
+ ? `npx -y @archlinter/cli scan . --config ${args.config}`
393
+ : `npx -y @archlinter/cli scan .`
394
+ return runCmd(fallback, cwd)
395
+ },
396
+ })
397
+ }
398
+
399
+ function readCurrentSessionModel(dbPath: string, sessionId: string): string {
400
+ try {
401
+ const escapedId = sessionId.replace(/'/g, "''")
402
+ const currentRaw = execSync(
403
+ `sqlite3 "${dbPath}" "SELECT model FROM session WHERE id = '${escapedId}'"`,
404
+ { encoding: "utf8", timeout: SQLITE_TIMEOUT_MS }
405
+ ).trim()
406
+ if (!currentRaw) return "unknown"
407
+ try {
408
+ const parsed = JSON.parse(currentRaw)
409
+ return `${parsed.providerID || "?"}/${parsed.id || "?"}`
410
+ } catch {
411
+ return currentRaw
412
+ }
413
+ } catch {
414
+ return "unknown"
415
+ }
416
+ }
417
+
418
+ function writeSessionModel(dbPath: string, sessionId: string, modelJson: string): void {
419
+ const escapedId = sessionId.replace(/'/g, "''")
420
+ const escapedModel = modelJson.replace(/'/g, "''")
421
+ const sql = `UPDATE session SET model = '${escapedModel}', time_updated = ${Date.now()} WHERE id = '${escapedId}'`
422
+ try {
423
+ execSync(`sqlite3 "${dbPath}" "${sql}"`, { timeout: SQLITE_TIMEOUT_MS })
424
+ } catch (err) {
425
+ // Contract is to propagate: the caller renders the failure as
426
+ // "Session reload failed: <message>". Wrapped so the IO sits inside
427
+ // error handling (Boy Scout), not to swallow anything.
428
+ throw err
429
+ }
430
+ }
431
+
432
+ // Boy Scout (#447): the tool's execute() body inlined 40+ lines at 5 brace
433
+ // levels (tool → execute → try → JSON.stringify options). The logic lives at
434
+ // module scope; execute() is pure wiring.
435
+ function reloadSessionModel(sessionId: string): string {
436
+ const dbPath = join(homedir(), ".local", "share", "opencode", "opencode.db")
437
+ const configDir = join(homedir(), ".config", "opencode")
438
+ const omoConfigPath = join(configDir, "oh-my-openagent.json")
439
+
440
+ if (!existsSync(dbPath)) {
441
+ return "Session reload failed: OpenCode database not found at " + dbPath
442
+ }
443
+ if (!existsSync(omoConfigPath)) {
444
+ return "Session reload failed: oh-my-openagent.json not found at " + omoConfigPath
445
+ }
446
+
447
+ let omoConfig: Record<string, unknown>
448
+ try {
449
+ omoConfig = JSON.parse(readFileSync(omoConfigPath, "utf8"))
450
+ } catch (err) {
451
+ const msg = err instanceof Error ? err.message : String(err)
452
+ return "Session reload failed: could not parse oh-my-openagent.json: " + msg
453
+ }
454
+
455
+ const agents = omoConfig?.["agents"] as Record<string, { model?: string }> | undefined
456
+ const sisyphusModel = agents?.["sisyphus"]?.["model"]
457
+ if (!sisyphusModel || typeof sisyphusModel !== "string") {
458
+ return "Session reload failed: could not find sisyphus.model in oh-my-openagent.json"
459
+ }
460
+
461
+ const slashIdx = sisyphusModel.indexOf("/")
462
+ if (slashIdx === -1) {
463
+ return `Session reload failed: model "${sisyphusModel}" is not in provider/model format`
464
+ }
465
+ const providerID = sisyphusModel.substring(0, slashIdx)
466
+ const modelID = sisyphusModel.substring(slashIdx + 1)
467
+
468
+ const oldModel = readCurrentSessionModel(dbPath, sessionId)
469
+ try {
470
+ writeSessionModel(dbPath, sessionId, JSON.stringify({ id: modelID, providerID }))
471
+ return `Session model reloaded: ${oldModel} → ${providerID}/${modelID}`
472
+ } catch (err) {
473
+ const msg = err instanceof Error ? err.message : String(err)
474
+ return `Session reload failed: ${msg}`
475
+ }
476
+ }
477
+
478
+ function sessionReloadModelTool() {
479
+ return tool({
480
+ description: "Reload session model from config. After switching provider configs (opencode.json + oh-my-openagent.json via switch-coding-plan.sh), updates the session model in OpenCode DB to match the new config. This ensures restarted sessions use the current provider/model.",
481
+ args: {
482
+ sessionId: z.string().optional().describe("Session ID to reload (defaults to current session)"),
483
+ },
484
+ async execute(args, ctx) {
485
+ return reloadSessionModel(args.sessionId || ctx.sessionID)
486
+ },
487
+ })
488
+ }
489
+
490
+ // Boy Scout (#447): same treatment as reloadSessionModel — logic at module
491
+ // scope, execute() is pure wiring.
492
+ function renameSession(sessionId: string, newTitle?: string): string {
493
+ const dbPath = join(homedir(), ".local", "share", "opencode", "opencode.db")
494
+
495
+ if (!existsSync(dbPath)) {
496
+ return "Session rename failed: OpenCode database not found at " + dbPath
497
+ }
498
+
499
+ // `||` (not `??`): an explicitly empty title falls back to auto-generation,
500
+ // matching the original if/else.
501
+ const title = newTitle || generateSessionTitle(dbPath, sessionId)
502
+ if (!title || title.trim().length === 0) {
503
+ return "Session rename failed: could not generate a title (no user messages found)"
504
+ }
505
+
506
+ const trimmed = title.trim().substring(0, 120)
507
+ const escapedTitle = trimmed.replace(/'/g, "''")
508
+ const escapedId = sessionId.replace(/'/g, "''")
509
+ const sql = `UPDATE session SET title = '${escapedTitle}', time_updated = ${Date.now()} WHERE id = '${escapedId}'`
510
+ try {
511
+ execSync(`sqlite3 "${dbPath}" "${sql}"`, { timeout: SQLITE_TIMEOUT_MS })
512
+ return `Session renamed to: "${trimmed}"`
513
+ } catch (err) {
514
+ const msg = err instanceof Error ? err.message : String(err)
515
+ return `Session rename failed: ${msg}`
516
+ }
517
+ }
518
+
519
+ function sessionRenameTool() {
520
+ return tool({
521
+ description: "Rename an OpenCode session. When called without newTitle, analyzes the session's recent conversation and auto-generates a descriptive title based on the work done.",
522
+ args: {
523
+ sessionId: z.string().optional().describe("Session ID to rename (defaults to current session)"),
524
+ newTitle: z.string().optional().describe("New session title. If omitted, auto-generate from conversation content."),
525
+ },
526
+ async execute(args, ctx) {
527
+ return renameSession(args.sessionId || ctx.sessionID, args.newTitle)
528
+ },
529
+ })
530
+ }
531
+
322
532
  export const XpGatePlugin = async (input: OpenCodePluginInput) => {
323
533
  const { directory } = input
324
534
 
325
535
  return {
326
536
  tool: {
327
- "gate-check": tool({
328
- description: "Run xp-gate quality gates (Gate 4 + Gate 6) on a file or directory. Prefers global xp-gate CLI; falls back to direct checker source.",
329
- args: {
330
- path: z.string().describe("File or directory path (absolute or relative to workspace)"),
331
- gates: z.array(z.string()).optional().describe("Optional gate subset (e.g. ['principles', 'arch'])"),
332
- },
333
- async execute(args, ctx) {
334
- const cwd = ctx.directory || directory
335
- const target = args.path.startsWith("/") ? args.path : `${cwd}/${args.path}`
336
- const gatesFlag = args.gates?.length ? ` --gates ${args.gates.join(",")}` : ""
337
- const result = await runXpGate(`check "${target}"${gatesFlag}`, cwd)
338
- if (result) {
339
- const upgrade = await getUpgradeSuggestion(cwd)
340
- return upgrade ? `${result}\n${upgrade}` : result
341
- }
342
- const cmd = `node ${directory}/src/npm-package/bin/xp-gate.js check "${target}"${gatesFlag}`
343
- return runCmd(cmd, cwd)
344
- },
345
- }),
346
- "gate-principles": tool({
347
- description: "Run Clean Code + SOLID principles checker (Gate 4 standalone) on a file or directory.",
348
- args: {
349
- path: z.string().describe("Source file or directory path to check"),
350
- },
351
- async execute(args, ctx) {
352
- const cwd = ctx.directory || directory
353
- const target = args.path.startsWith("/") ? args.path : `${cwd}/${args.path}`
354
- const result = await runXpGate(`principles "${target}"`, cwd)
355
- if (result) {
356
- const upgrade = await getUpgradeSuggestion(cwd)
357
- return upgrade ? `${result}\n${upgrade}` : result
358
- }
359
- const cmd = `npx -y tsx ${directory}/src/principles/index.ts --files "${target}" --format console`
360
- return runCmd(cmd, cwd)
361
- },
362
- }),
363
- "gate-arch": tool({
364
- description: "Run architecture validation (Gate 6 standalone, layer boundary checks) on the repository.",
365
- args: {
366
- config: z.string().describe("Path to architecture config file").default("architecture.yaml"),
367
- },
368
- async execute(args, ctx) {
369
- const cwd = ctx.directory || directory
370
- const config = args.config || "architecture.yaml"
371
- const result = await runXpGate(`arch --config ${config}`, cwd)
372
- if (result) {
373
- const upgrade = await getUpgradeSuggestion(cwd)
374
- return upgrade ? `${result}\n${upgrade}` : result
375
- }
376
- const cmd = `npx -y @archlinter/cli scan . --config ${config}`
377
- return runCmd(cmd, cwd)
378
- },
379
- }),
380
- "session-reload-model": tool({
381
- description: "Reload session model from config. After switching provider configs (opencode.json + oh-my-openagent.json via switch-coding-plan.sh), updates the session model in OpenCode DB to match the new config. This ensures restarted sessions use the current provider/model.",
382
- args: {
383
- sessionId: z.string().optional().describe("Session ID to reload (defaults to current session)"),
384
- },
385
- async execute(args, ctx) {
386
- const sessionId = args.sessionId || ctx.sessionID
387
- const dbPath = join(homedir(), ".local", "share", "opencode", "opencode.db")
388
- const configDir = join(homedir(), ".config", "opencode")
389
- const omoConfigPath = join(configDir, "oh-my-openagent.json")
390
-
391
- if (!existsSync(dbPath)) {
392
- return "Session reload failed: OpenCode database not found at " + dbPath
393
- }
394
- if (!existsSync(omoConfigPath)) {
395
- return "Session reload failed: oh-my-openagent.json not found at " + omoConfigPath
396
- }
397
-
398
- let omoConfig: Record<string, unknown>
399
- try {
400
- omoConfig = JSON.parse(readFileSync(omoConfigPath, "utf8"))
401
- } catch (err) {
402
- const msg = err instanceof Error ? err.message : String(err)
403
- return "Session reload failed: could not parse oh-my-openagent.json: " + msg
404
- }
405
-
406
- const agents = omoConfig?.["agents"] as Record<string, { model?: string }> | undefined
407
- const sisyphusModel = agents?.["sisyphus"]?.["model"]
408
- if (!sisyphusModel || typeof sisyphusModel !== "string") {
409
- return "Session reload failed: could not find sisyphus.model in oh-my-openagent.json"
410
- }
411
-
412
- const slashIdx = sisyphusModel.indexOf("/")
413
- if (slashIdx === -1) {
414
- return `Session reload failed: model "${sisyphusModel}" is not in provider/model format`
415
- }
416
- const providerID = sisyphusModel.substring(0, slashIdx)
417
- const modelID = sisyphusModel.substring(slashIdx + 1)
418
-
419
- let oldModel = "unknown"
420
- try {
421
- const escapedId = sessionId.replace(/'/g, "''")
422
- const currentRaw = execSync(
423
- `sqlite3 "${dbPath}" "SELECT model FROM session WHERE id = '${escapedId}'"`,
424
- { encoding: "utf8", timeout: 5000 }
425
- ).trim()
426
- if (currentRaw) {
427
- try {
428
- const parsed = JSON.parse(currentRaw)
429
- oldModel = `${parsed.providerID || "?"}/${parsed.id || "?"}`
430
- } catch {
431
- oldModel = currentRaw
432
- }
433
- }
434
- } catch {
435
- oldModel = "unknown"
436
- }
437
-
438
- const newModelJson = JSON.stringify({ id: modelID, providerID })
439
- const escapedId = sessionId.replace(/'/g, "''")
440
- const escapedModel = newModelJson.replace(/'/g, "''")
441
- const sql = `UPDATE session SET model = '${escapedModel}', time_updated = ${Date.now()} WHERE id = '${escapedId}'`
442
-
443
- try {
444
- execSync(`sqlite3 "${dbPath}" "${sql}"`, { timeout: 5000 })
445
- return `Session model reloaded: ${oldModel} → ${providerID}/${modelID}`
446
- } catch (err) {
447
- const msg = err instanceof Error ? err.message : String(err)
448
- return `Session reload failed: ${msg}`
449
- }
450
- },
451
- }),
452
- "session-rename": tool({
453
- description: "Rename an OpenCode session. When called without newTitle, analyzes the session's recent conversation and auto-generates a descriptive title based on the work done.",
454
- args: {
455
- sessionId: z.string().optional().describe("Session ID to rename (defaults to current session)"),
456
- newTitle: z.string().optional().describe("New session title. If omitted, auto-generates from conversation content."),
457
- },
458
- async execute(args, ctx) {
459
- const sessionId = args.sessionId || ctx.sessionID
460
- const dbPath = join(homedir(), ".local", "share", "opencode", "opencode.db")
461
-
462
- if (!existsSync(dbPath)) {
463
- return "Session rename failed: OpenCode database not found at " + dbPath
464
- }
465
-
466
- let title: string
467
- if (args.newTitle) {
468
- title = args.newTitle
469
- } else {
470
- title = generateSessionTitle(dbPath, sessionId)
471
- }
472
-
473
- if (!title || title.trim().length === 0) {
474
- return "Session rename failed: could not generate a title (no user messages found)"
475
- }
476
-
477
- title = title.trim().substring(0, 120)
478
-
479
- const escapedTitle = title.replace(/'/g, "''")
480
- const escapedId = sessionId.replace(/'/g, "''")
481
- const sql = `UPDATE session SET title = '${escapedTitle}', time_updated = ${Date.now()} WHERE id = '${escapedId}'`
482
- try {
483
- execSync(`sqlite3 "${dbPath}" "${sql}"`, { timeout: 5000 })
484
- return `Session renamed to: "${title}"`
485
- } catch (err) {
486
- const msg = err instanceof Error ? err.message : String(err)
487
- return `Session rename failed: ${msg}`
488
- }
489
- },
490
- }),
537
+ "gate-check": gateCheckTool(directory),
538
+ "gate-principles": gatePrinciplesTool(directory),
539
+ "gate-arch": gateArchTool(directory),
540
+ "session-reload-model": sessionReloadModelTool(),
541
+ "session-rename": sessionRenameTool(),
491
542
  },
492
543
  "chat.message": async (_input: { message: string }) => {
493
544
  if (!checked) {
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: admin-template-guidelines
3
+ version: 1.0.0
3
4
  description: "6 maintainability rules for AI-generated admin interfaces (Fastify + Nunjucks + HTMX + Alpine.js). Prevents route bloat, test inconsistency, Nunjucks traps, repeated data logic, auth blind spots, and HTMX/Alpine confusion."
4
5
  maturity: alpha
5
6
  ---
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: batch-grill-me
3
+ version: 1.0.0
3
4
  description: A relentless interview that asks every frontier question at once, round by round.
4
5
  disable-model-invocation: true
5
6
  ---
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: clipboard-vision
3
+ version: 1.0.0
3
4
  description: Use when the user explicitly asks to analyze, describe, OCR, or inspect a single image currently stored in the system clipboard.
4
5
  ---
5
6
 
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: domain-modeling
3
+ version: 1.0.0
3
4
  description: Build and sharpen a project's domain model. Use when the user wants to pin down domain terminology or a ubiquitous language, record an architectural decision, or when another skill needs to maintain the domain model.
4
5
  ---
5
6
 
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: grill-with-docs
3
+ version: 1.0.0
3
4
  description: A relentless interview to sharpen a plan or design, which also creates docs (ADR's and glossary) as we go.
4
5
  disable-model-invocation: true
5
6
  ---
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: grilling
3
+ version: 1.0.0
3
4
  description: Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases.
4
5
  ---
5
6
 
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: improve-codebase-architecture
3
+ version: 1.0.0
3
4
  description: Find deepening opportunities in a codebase, informed by domain language in CONTEXT.md and decisions in ADRs. Use when user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more AI-navigable. Run periodically (weekly or after surges of development).
4
5
  ---
5
6
 
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: ralph-loop
3
+ version: 1.0.0
3
4
  description: Use when executing Sprint-Flow Phase 2 BUILD, processing one REQ, building the next requirement, iterating with clean context, or requiring full regression per requirement.
4
5
  maturity: stable
5
6
  ---
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: test-driven-development
3
+ version: 1.0.0
3
4
  description: Use when implementing any feature or bugfix, before writing implementation code
4
5
  ---
5
6
 
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: test-specification-alignment
3
+ version: 1.0.0
3
4
  description: "Use when asked to run tests, verify tests, align tests with specification.yaml, before BUILD verification, or before release/ship."
4
5
  ---
5
6
 
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: to-issues
3
+ version: 1.0.0
3
4
  description: Break a plan, spec, or PRD into independently-grabbable issues using tracer-bullet vertical slices. Use when user wants to convert a plan into issues, create implementation tickets, or break down work into vertical slices. Integrates with XP-Gate sprint-flow Phase 1.
4
5
  ---
5
6