diff --git a/.vtcode/README.md b/.vtcode/README.md new file mode 100644 index 000000000..2a651b43b --- /dev/null +++ b/.vtcode/README.md @@ -0,0 +1,5 @@ +# VT Code Workspace Files + +- Put always-on repository guidance in `AGENTS.md`. +- Put path-scoped prompt rules in `.vtcode/rules/*.md` using YAML frontmatter. +- Keep authoring notes and other workspace docs outside `.vtcode/rules/` so they are not loaded into prompt memory. diff --git a/rule-tests/__snapshots__/no-console-log-snapshot.yml b/rule-tests/__snapshots__/no-console-log-snapshot.yml new file mode 100644 index 000000000..621ea184b --- /dev/null +++ b/rule-tests/__snapshots__/no-console-log-snapshot.yml @@ -0,0 +1,11 @@ +id: no-console-log +snapshots: + ? | + function greet(name) { + console.log(name); + } + : labels: + - source: console.log(name) + style: primary + start: 25 + end: 42 diff --git a/rule-tests/examples/no-console-log-test.yml b/rule-tests/examples/no-console-log-test.yml new file mode 100644 index 000000000..16aa90737 --- /dev/null +++ b/rule-tests/examples/no-console-log-test.yml @@ -0,0 +1,13 @@ +id: no-console-log +valid: + - | + const logger = { + info(message) { + return message; + }, + }; +invalid: + - | + function greet(name) { + console.log(name); + } diff --git a/rules/examples/no-console-log.yml b/rules/examples/no-console-log.yml new file mode 100644 index 000000000..39a19dbf7 --- /dev/null +++ b/rules/examples/no-console-log.yml @@ -0,0 +1,11 @@ +id: no-console-log +language: JavaScript +severity: error +message: Avoid `console.log` in checked JavaScript files. +note: | + This starter rule is scoped to `__ast_grep_examples__/` so fresh repositories can + validate the scaffold without scanning unrelated project files. +rule: + pattern: console.log($$$ARGS) +files: + - __ast_grep_examples__/**/*.js diff --git a/sgconfig.yml b/sgconfig.yml new file mode 100644 index 000000000..1c3f34aa7 --- /dev/null +++ b/sgconfig.yml @@ -0,0 +1,5 @@ +ruleDirs: + - rules +testConfigs: + - testDir: rule-tests + snapshotDir: __snapshots__ diff --git a/src/loader.ts b/src/loader.ts index 7e53dda1d..f456e7419 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -6,6 +6,7 @@ import { readFileSync, symlinkSync, } from "node:fs"; +import { homedir } from "node:os"; import { delimiter, join, relative, resolve } from "node:path"; // SF Startup Loader @@ -109,27 +110,31 @@ if (firstArg === "headless") { // Schedule due-items banner — lightweight check before heavy imports if (!process.env.SF_QUIET && firstArg !== "--version" && firstArg !== "-v" && firstArg !== "--help" && firstArg !== "-h") { try { - const schedulePath = join(process.cwd(), ".sf", "schedule.jsonl"); - if (existsSync(schedulePath)) { + const now = Date.now(); + let dueCount = 0; + const schedulePaths = [ + join(process.cwd(), ".sf", "schedule.jsonl"), + join(homedir(), ".sf", "schedule.jsonl"), + ]; + for (const schedulePath of schedulePaths) { + if (!existsSync(schedulePath)) continue; const content = readFileSync(schedulePath, "utf-8"); - const now = Date.now(); - let dueCount = 0; for (const line of content.split("\n")) { if (!line.trim()) continue; try { const entry = JSON.parse(line); - if (entry.status === "pending" && new Date(entry.due_at).getTime() <= now) { + if (entry.status === "pending" && Date.parse(entry.due_at) <= now) { dueCount++; } } catch { // skip corrupt lines } } - if (dueCount > 0) { - process.stderr.write( - `[forge] ${dueCount} scheduled item${dueCount === 1 ? "" : "s"} due now. Manage: /sf schedule list\n`, - ); - } + } + if (dueCount > 0) { + process.stderr.write( + `[forge] ${dueCount} scheduled item${dueCount === 1 ? "" : "s"} due now. Manage: /sf schedule list\n`, + ); } } catch { // non-fatal diff --git a/src/resources/extensions/sf/bootstrap/db-tools.js b/src/resources/extensions/sf/bootstrap/db-tools.js index e0f36f94b..2741aec7b 100644 --- a/src/resources/extensions/sf/bootstrap/db-tools.js +++ b/src/resources/extensions/sf/bootstrap/db-tools.js @@ -794,6 +794,17 @@ export function registerDbTools(pi) { })), requirementCoverage: Type.Optional(Type.String({ description: "Requirement coverage text" })), boundaryMapMarkdown: Type.Optional(Type.String({ description: "Boundary map markdown block" })), + schedule: Type.Optional(Type.Array(Type.Object({ + in: Type.Optional(Type.String({ description: "Duration string (e.g. '2w', '1d') — fires at milestone creation" })), + on_complete: Type.Optional(Type.Object({ + in: Type.String({ description: "Duration after milestone completion" }), + })), + kind: Type.String({ description: "Entry kind (reminder, milestone_check, review_due, recurring)" }), + title: Type.String({ description: "Entry title / reminder message" }), + payload: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { + description: "Extra kind-specific data", + })), + }), { description: "Scheduled follow-ups to create with this milestone" })), visionMeeting: Type.Optional(Type.Object({ trigger: Type.String({ description: "Why a top-level roadmap meeting was needed", @@ -1446,6 +1457,17 @@ export function registerDbTools(pi) { })), followUps: Type.Optional(Type.String({ description: "Follow-up items for future milestones" })), deviations: Type.Optional(Type.String({ description: "Deviations from the original plan" })), + schedule: Type.Optional(Type.Array(Type.Object({ + in: Type.Optional(Type.String({ description: "Duration string (e.g. '2w', '1d') — fires at milestone creation" })), + on_complete: Type.Optional(Type.Object({ + in: Type.String({ description: "Duration after milestone completion" }), + })), + kind: Type.String({ description: "Entry kind (reminder, milestone_check, review_due, recurring)" }), + title: Type.String({ description: "Entry title / reminder message" }), + payload: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { + description: "Extra kind-specific data", + })), + }), { description: "Scheduled follow-ups to create on milestone completion" })), }), execute: milestoneCompleteExecute, }; diff --git a/src/resources/extensions/sf/prompts/plan-milestone.md b/src/resources/extensions/sf/prompts/plan-milestone.md index 7190ec345..e1e09d26e 100644 --- a/src/resources/extensions/sf/prompts/plan-milestone.md +++ b/src/resources/extensions/sf/prompts/plan-milestone.md @@ -115,6 +115,32 @@ Apply these when decomposing and ordering slices: - **Ambition matches the milestone.** The number and depth of slices should match the milestone's ambition. A milestone promising "core platform with auth, data model, and primary user loop" should have enough slices to actually deliver all three as working features - not two proof-of-concept slices and a note that "the rest will come in the next milestone." If the milestone's context promises an outcome, the roadmap must deliver it. - **Right-size the decomposition.** Match slice count to actual complexity. If the work is small enough to build and verify in one pass, it's one slice - don't split it into three just because you can identify sub-steps. Multiple requirements can share a single slice. Conversely, don't cram genuinely independent capabilities into one slice just to keep the count low. Let the work dictate the structure. +## Scheduled Follow-ups + +Milestones can declare temporal follow-ups via an optional `schedule` field passed to `sf_plan_milestone`. These create schedule entries that fire at specific times without manual intervention. + +```yaml +schedule: + - in: "2w" + kind: "review" + title: "Check adoption of promote-only workflow" + - in: "1d" + kind: "reminder" + title: "Verify CI passes after merge" + - on_complete: + in: "1m" + kind: "audit" + title: "Re-audit decision registry coverage" +``` + +- Top-level `in:` entries fire at milestone **creation** time (due_at = now + duration). +- `on_complete.in:` entries fire when the milestone is **completed** (due_at = completion_time + duration). +- `kind` must be one of: `reminder`, `milestone_check`, `review_due`, `recurring`. +- `title` becomes the reminder message. +- `payload` is optional kind-specific data. + +Use schedule follow-ups for: post-launch adoption checks, periodic audits, re-validation reminders, and recurring reviews. Do not use them for work that should be a slice — slices are for execution, schedules are for follow-up awareness. + ## Single-Slice Fast Path If the roadmap has only one slice, also plan the slice and its tasks inline during this unit - don't leave them for a separate planning session. diff --git a/src/resources/extensions/sf/schedule/schedule-milestone.js b/src/resources/extensions/sf/schedule/schedule-milestone.js new file mode 100644 index 000000000..101da56fa --- /dev/null +++ b/src/resources/extensions/sf/schedule/schedule-milestone.js @@ -0,0 +1,126 @@ +/** + * Schedule Milestone Integration — process ScheduleSpec items at milestone + * creation and completion time. + * + * Purpose: convert ScheduleSpec objects (from milestone planning) into + * ScheduleEntry records appended to the store. + * + * Consumer: plan-milestone.js and complete-milestone.js handlers. + */ +import { createScheduleStore } from "./schedule-store.js"; +import { generateULID } from "./schedule-ulid.js"; +import { isValidKind } from "./schedule-types.js"; + +/** + * Convert a ScheduleSpec into a ScheduleEntry and append it to the store. + * + * Purpose: create a concrete schedule entry from a milestone-declared spec. + * + * Consumer: milestone creation and completion handlers. + * + * @param {string} basePath + * @param {import("./schedule-types.js").ScheduleSpec} spec + * @param {string} [milestoneId] + * @returns {void} + */ +export function appendScheduleSpec(basePath, spec, milestoneId) { + if (!spec || typeof spec !== "object") return; + if (!isValidKind(spec.kind)) { + logWarning("schedule", `Skipping schedule spec with unknown kind: ${spec.kind}`); + return; + } + + const store = createScheduleStore(basePath); + const dueAt = _resolveDueAt(spec); + if (!dueAt) return; + + const entry = { + id: generateULID(), + kind: spec.kind, + status: "pending", + due_at: dueAt, + created_at: new Date().toISOString(), + payload: { message: spec.title, ...(spec.payload ?? {}) }, + created_by: "milestone", + }; + if (milestoneId) { + entry.payload.milestoneId = milestoneId; + } + store.appendEntry("project", entry); +} + +/** + * Process an array of ScheduleSpec items, appending each to the store. + * + * @param {string} basePath + * @param {Array} specs + * @param {string} [milestoneId] + * @returns {void} + */ +export function appendScheduleSpecs(basePath, specs, milestoneId) { + if (!Array.isArray(specs)) return; + for (const spec of specs) { + try { + appendScheduleSpec(basePath, spec, milestoneId); + } catch { + // Non-fatal — one bad spec must not block others. + } + } +} + +// ─── Internal ─────────────────────────────────────────────────────────────── + +/** + * Resolve the due_at ISO string from a ScheduleSpec. + * Supports `spec.in` (relative duration) and `spec.on_complete.in`. + * + * @param {import("./schedule-types.js").ScheduleSpec} spec + * @returns {string|null} + */ +function _resolveDueAt(spec) { + let dur = ""; + if (spec.in) { + dur = spec.in; + } else if (spec.on_complete?.in) { + dur = spec.on_complete.in; + } + if (!dur) return null; + + const ms = _parseDuration(dur); + if (ms === null) return null; + return new Date(Date.now() + ms).toISOString(); +} + +/** + * Parse a duration string into milliseconds. + * + * @param {string} str + * @returns {number|null} + */ +function _parseDuration(str) { + const match = String(str).trim().match(/^(\d+)([wdhm])$/i); + if (!match) return null; + const value = parseInt(match[1], 10); + const unit = match[2].toLowerCase(); + switch (unit) { + case "w": return value * 7 * 24 * 60 * 60 * 1000; + case "d": return value * 24 * 60 * 60 * 1000; + case "h": return value * 60 * 60 * 1000; + case "m": return value * 60 * 1000; + default: return null; + } +} + +/** + * Best-effort warning logger. + * + * @param {string} _scope + * @param {string} message + */ +function logWarning(_scope, message) { + try { + process.stderr.write(`[sf:schedule-milestone] ${message}\n`); + } catch { + // ignore + } +} diff --git a/src/resources/extensions/sf/schedule/schedule-types.js b/src/resources/extensions/sf/schedule/schedule-types.js index 9fa35065b..1221ee53b 100644 --- a/src/resources/extensions/sf/schedule/schedule-types.js +++ b/src/resources/extensions/sf/schedule/schedule-types.js @@ -60,6 +60,15 @@ * @typedef {ReminderPayload|MilestoneCheckPayload|ReviewDuePayload|RecurringPayload} SchedulePayload */ +/** + * @typedef {object} ScheduleSpec + * @property {string} [in] Duration string (e.g. "2w", "1d") — fires at milestone creation + * @property {{in: string}} [on_complete] Duration after milestone completion + * @property {ScheduleKind} kind Entry kind + * @property {string} title Entry title / reminder message + * @property {Record} [payload] Extra kind-specific data + */ + // ─── Entry ────────────────────────────────────────────────────────────────── /** diff --git a/src/resources/extensions/sf/tools/complete-milestone.js b/src/resources/extensions/sf/tools/complete-milestone.js index 09f32ebcc..6f94cdd80 100644 --- a/src/resources/extensions/sf/tools/complete-milestone.js +++ b/src/resources/extensions/sf/tools/complete-milestone.js @@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { clearParseCache, saveFile } from "../files.js"; import { clearPathCache, resolveMilestonePath } from "../paths.js"; +import { appendScheduleSpecs } from "../schedule/schedule-milestone.js"; import { getMilestone, getMilestoneSlices, getSliceTasks, transaction, updateMilestoneStatus, } from "../sf-db.js"; import { checkSafeIds } from "../safety/safe-id.js"; import { invalidateStateCache } from "../state.js"; @@ -187,6 +188,15 @@ export async function handleCompleteMilestone(params, basePath) { invalidateStateCache(); clearPathCache(); clearParseCache(); + // ── Schedule follow-ups (completion-time) ───────────────────────────── + if (params.schedule && params.schedule.length > 0) { + try { + const completionSpecs = params.schedule.filter((s) => s && s.on_complete); + appendScheduleSpecs(basePath, completionSpecs, params.milestoneId); + } catch { + // Non-fatal — schedule errors must not fail milestone completion. + } + } // ── Post-mutation hook: projections, manifest, event log ─────────────── // Separate try/catch per step so a projection failure doesn't prevent // the event log entry (critical for worktree reconciliation). diff --git a/src/resources/extensions/sf/tools/plan-milestone.js b/src/resources/extensions/sf/tools/plan-milestone.js index d8530cd72..e26f19ed4 100644 --- a/src/resources/extensions/sf/tools/plan-milestone.js +++ b/src/resources/extensions/sf/tools/plan-milestone.js @@ -1,6 +1,7 @@ import { clearParseCache } from "../files.js"; import { renderRoadmapFromDb } from "../markdown-renderer.js"; import { hasStructuredVisionAlignmentMeeting, } from "../milestone-quality.js"; +import { appendScheduleSpecs } from "../schedule/schedule-milestone.js"; import { getMilestone, getMilestoneSlices, getSlice, insertMilestone, insertSlice, transaction, upsertMilestonePlanning, upsertSlicePlanning, } from "../sf-db.js"; import { invalidateStateCache } from "../state.js"; import { isClosedStatus } from "../status-guards.js"; @@ -336,6 +337,15 @@ export async function handlePlanMilestone(rawParams, basePath) { } invalidateStateCache(); clearParseCache(); + // ── Schedule follow-ups (creation-time) ─────────────────────────────── + if (params.schedule && params.schedule.length > 0) { + try { + const creationSpecs = params.schedule.filter((s) => s && s.in && !s.on_complete); + appendScheduleSpecs(basePath, creationSpecs, params.milestoneId); + } catch { + // Non-fatal — schedule errors must not fail milestone creation. + } + } // ── Post-mutation hook: projections, manifest, event log ─────────────── try { await renderAllProjections(basePath, params.milestoneId);