feat: add milestone schedule integration

This commit is contained in:
Mikael Hugo 2026-05-05 12:31:13 +02:00
parent 8571ef702d
commit 5b9355fa74
12 changed files with 263 additions and 10 deletions

5
.vtcode/README.md Normal file
View file

@ -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.

View file

@ -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

View file

@ -0,0 +1,13 @@
id: no-console-log
valid:
- |
const logger = {
info(message) {
return message;
},
};
invalid:
- |
function greet(name) {
console.log(name);
}

View file

@ -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

5
sgconfig.yml Normal file
View file

@ -0,0 +1,5 @@
ruleDirs:
- rules
testConfigs:
- testDir: rule-tests
snapshotDir: __snapshots__

View file

@ -6,6 +6,7 @@ import {
readFileSync, readFileSync,
symlinkSync, symlinkSync,
} from "node:fs"; } from "node:fs";
import { homedir } from "node:os";
import { delimiter, join, relative, resolve } from "node:path"; import { delimiter, join, relative, resolve } from "node:path";
// SF Startup Loader // SF Startup Loader
@ -109,27 +110,31 @@ if (firstArg === "headless") {
// Schedule due-items banner — lightweight check before heavy imports // Schedule due-items banner — lightweight check before heavy imports
if (!process.env.SF_QUIET && firstArg !== "--version" && firstArg !== "-v" && firstArg !== "--help" && firstArg !== "-h") { if (!process.env.SF_QUIET && firstArg !== "--version" && firstArg !== "-v" && firstArg !== "--help" && firstArg !== "-h") {
try { try {
const schedulePath = join(process.cwd(), ".sf", "schedule.jsonl"); const now = Date.now();
if (existsSync(schedulePath)) { 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 content = readFileSync(schedulePath, "utf-8");
const now = Date.now();
let dueCount = 0;
for (const line of content.split("\n")) { for (const line of content.split("\n")) {
if (!line.trim()) continue; if (!line.trim()) continue;
try { try {
const entry = JSON.parse(line); 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++; dueCount++;
} }
} catch { } catch {
// skip corrupt lines // skip corrupt lines
} }
} }
if (dueCount > 0) { }
process.stderr.write( if (dueCount > 0) {
`[forge] ${dueCount} scheduled item${dueCount === 1 ? "" : "s"} due now. Manage: /sf schedule list\n`, process.stderr.write(
); `[forge] ${dueCount} scheduled item${dueCount === 1 ? "" : "s"} due now. Manage: /sf schedule list\n`,
} );
} }
} catch { } catch {
// non-fatal // non-fatal

View file

@ -794,6 +794,17 @@ export function registerDbTools(pi) {
})), })),
requirementCoverage: Type.Optional(Type.String({ description: "Requirement coverage text" })), requirementCoverage: Type.Optional(Type.String({ description: "Requirement coverage text" })),
boundaryMapMarkdown: Type.Optional(Type.String({ description: "Boundary map markdown block" })), 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({ visionMeeting: Type.Optional(Type.Object({
trigger: Type.String({ trigger: Type.String({
description: "Why a top-level roadmap meeting was needed", 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" })), followUps: Type.Optional(Type.String({ description: "Follow-up items for future milestones" })),
deviations: Type.Optional(Type.String({ description: "Deviations from the original plan" })), 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, execute: milestoneCompleteExecute,
}; };

View file

@ -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. - **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. - **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 ## 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. 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.

View file

@ -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<import("./schedule-types.js").ScheduleSpec>} 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
}
}

View file

@ -60,6 +60,15 @@
* @typedef {ReminderPayload|MilestoneCheckPayload|ReviewDuePayload|RecurringPayload} SchedulePayload * @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<string, unknown>} [payload] Extra kind-specific data
*/
// ─── Entry ────────────────────────────────────────────────────────────────── // ─── Entry ──────────────────────────────────────────────────────────────────
/** /**

View file

@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { clearParseCache, saveFile } from "../files.js"; import { clearParseCache, saveFile } from "../files.js";
import { clearPathCache, resolveMilestonePath } from "../paths.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 { getMilestone, getMilestoneSlices, getSliceTasks, transaction, updateMilestoneStatus, } from "../sf-db.js";
import { checkSafeIds } from "../safety/safe-id.js"; import { checkSafeIds } from "../safety/safe-id.js";
import { invalidateStateCache } from "../state.js"; import { invalidateStateCache } from "../state.js";
@ -187,6 +188,15 @@ export async function handleCompleteMilestone(params, basePath) {
invalidateStateCache(); invalidateStateCache();
clearPathCache(); clearPathCache();
clearParseCache(); 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 ─────────────── // ── Post-mutation hook: projections, manifest, event log ───────────────
// Separate try/catch per step so a projection failure doesn't prevent // Separate try/catch per step so a projection failure doesn't prevent
// the event log entry (critical for worktree reconciliation). // the event log entry (critical for worktree reconciliation).

View file

@ -1,6 +1,7 @@
import { clearParseCache } from "../files.js"; import { clearParseCache } from "../files.js";
import { renderRoadmapFromDb } from "../markdown-renderer.js"; import { renderRoadmapFromDb } from "../markdown-renderer.js";
import { hasStructuredVisionAlignmentMeeting, } from "../milestone-quality.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 { getMilestone, getMilestoneSlices, getSlice, insertMilestone, insertSlice, transaction, upsertMilestonePlanning, upsertSlicePlanning, } from "../sf-db.js";
import { invalidateStateCache } from "../state.js"; import { invalidateStateCache } from "../state.js";
import { isClosedStatus } from "../status-guards.js"; import { isClosedStatus } from "../status-guards.js";
@ -336,6 +337,15 @@ export async function handlePlanMilestone(rawParams, basePath) {
} }
invalidateStateCache(); invalidateStateCache();
clearParseCache(); 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 ─────────────── // ── Post-mutation hook: projections, manifest, event log ───────────────
try { try {
await renderAllProjections(basePath, params.milestoneId); await renderAllProjections(basePath, params.milestoneId);