feat: add milestone schedule integration
This commit is contained in:
parent
8571ef702d
commit
5b9355fa74
12 changed files with 263 additions and 10 deletions
5
.vtcode/README.md
Normal file
5
.vtcode/README.md
Normal 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.
|
||||
11
rule-tests/__snapshots__/no-console-log-snapshot.yml
Normal file
11
rule-tests/__snapshots__/no-console-log-snapshot.yml
Normal 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
|
||||
13
rule-tests/examples/no-console-log-test.yml
Normal file
13
rule-tests/examples/no-console-log-test.yml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
id: no-console-log
|
||||
valid:
|
||||
- |
|
||||
const logger = {
|
||||
info(message) {
|
||||
return message;
|
||||
},
|
||||
};
|
||||
invalid:
|
||||
- |
|
||||
function greet(name) {
|
||||
console.log(name);
|
||||
}
|
||||
11
rules/examples/no-console-log.yml
Normal file
11
rules/examples/no-console-log.yml
Normal 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
5
sgconfig.yml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
ruleDirs:
|
||||
- rules
|
||||
testConfigs:
|
||||
- testDir: rule-tests
|
||||
snapshotDir: __snapshots__
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
126
src/resources/extensions/sf/schedule/schedule-milestone.js
Normal file
126
src/resources/extensions/sf/schedule/schedule-milestone.js
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, unknown>} [payload] Extra kind-specific data
|
||||
*/
|
||||
|
||||
// ─── Entry ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue