diff --git a/scripts/check-service-deadlines.mjs b/scripts/check-service-deadlines.mjs index 90a732b8..0aca3c62 100644 --- a/scripts/check-service-deadlines.mjs +++ b/scripts/check-service-deadlines.mjs @@ -1,21 +1,23 @@ #!/usr/bin/env node -// Reads every paid client's Day 0 record and reports how long is left before -// the internal target and before the refund line. Nothing here mutates state. +// Reads every paid client's Day 0 record and reports the internal attention +// status for the Website Correction's 14-day implementation-tracking window. +// Nothing here mutates state. // -// Two clocks, deliberately: -// internal target serviceDeadlineAt() — 7 business days, the "7-Day Sprint" promise -// refund line 14 working days from Day 0, the public delivery guarantee -// The gap between them is the week of warning before money has to go back. +// The only contract deadline in the product is the 14-day implementation- +// tracking window that starts when a human accepts the implementation pass; +// client delay pauses the clock. The service makes no delivery, revenue, or +// outcome promise, so this check never reports a customer-facing deadline: +// clients whose tracking window has not started simply have no deadline +// applicable. // // Exit codes: 0 clear · 1 something needs attention today · 2 the check itself broke. import {existsSync, readdirSync} from "node:fs" import {join} from "node:path" import {readJson, resolveRepoPath} from "./lib/service-contract.mjs" -import {serviceDeadlineAt} from "./lib/service-artifacts.mjs" -import {addBusinessDaysToTimestamp, businessMillisecondsBetween} from "./date-utils.mjs" +import {serviceTrackingWindowEndAt} from "./lib/service-artifacts.mjs" +import {businessMillisecondsBetween} from "./date-utils.mjs" -const REFUND_BUSINESS_DAYS = 14 const WARN_WITHIN_DAYS = 2 const DAY_MS = 86400000 @@ -23,24 +25,14 @@ const repoRoot = process.cwd() const asOf = process.env.SERVICE_DEADLINE_NOW || new Date().toISOString() // businessMillisecondsBetween refuses a reversed range, so measure the gap in -// whichever direction is valid and carry the sign ourselves. A past deadline is -// the whole point of this check — it must not throw. +// whichever direction is valid and carry the sign ourselves. A past tracking +// window is the whole point of this check — it must not throw. function businessDaysBetween(from, to) { const overdue = Date.parse(to) < Date.parse(from) const ms = overdue ? -businessMillisecondsBetween(to, from) : businessMillisecondsBetween(from, to) return Math.round((ms / DAY_MS) * 10) / 10 } -function refundLineAt(day0StartedAt, pauseHistory) { - const pausedMs = (pauseHistory || []).reduce( - (total, pause) => total + businessMillisecondsBetween(pause.startedAt, pause.endedAt), - 0, - ) - const base = addBusinessDaysToTimestamp(day0StartedAt, REFUND_BUSINESS_DAYS) - // Pauses push the refund line out by the same business time the clock was stopped. - return new Date(Date.parse(base) + pausedMs).toISOString() -} - function loadClients() { const dir = resolveRepoPath(repoRoot, "clients") if (!existsSync(dir)) return [] @@ -49,36 +41,49 @@ function loadClients() { .map((entry) => { const path = join(dir, entry.name, "service-day0.json") if (!existsSync(path)) return null - return {id: entry.name, day0: readJson(path)} + return {id: entry.name, folder: join(dir, entry.name), day0: readJson(path)} }) .filter(Boolean) } +function loadState(folder) { + const path = join(folder, "service-state.json") + if (!existsSync(path)) return null + const state = readJson(path) + return {state: state?.state || "", implementationAcceptedAt: state?.implementationAcceptedAt || ""} +} + function assess(client) { const {id, day0} = client const startedAt = day0.day0StartedAt if (!startedAt) return {id, status: "no-day0", detail: "Day 0 not recorded"} const paused = Boolean(day0.activePause && day0.activePause.startedAt && !day0.activePause.endedAt) - const target = day0.deadlineAt || serviceDeadlineAt(startedAt, day0.pauseHistory || []) - const refund = refundLineAt(startedAt, day0.pauseHistory || []) - if (paused) { return { - id, paused: true, status: "paused", target, refund, + id, paused: true, status: "paused", detail: `clock paused since ${day0.activePause.startedAt} — ${day0.activePause.reason || "no reason recorded"}`, } } - const toTarget = businessDaysBetween(asOf, target) - const toRefund = businessDaysBetween(asOf, refund) + const state = loadState(client.folder) + if (!state) { + return {id, paused: false, status: "state-missing", detail: "service state not recorded — cannot assess the 14-day tracking window"} + } - let status = "on-track" - if (toRefund <= 0) status = "REFUND-DUE" - else if (toTarget <= 0) status = "TARGET-MISSED" - else if (toTarget <= WARN_WITHIN_DAYS) status = "due-soon" + if (state.state === "tracking-14-day" && state.implementationAcceptedAt) { + const windowEnd = serviceTrackingWindowEndAt(state.implementationAcceptedAt, day0.pauseHistory || []) + const toEnd = businessDaysBetween(asOf, windowEnd) + let status = "on-track" + if (toEnd <= 0) status = "TRACKING-OVERDUE" + else if (toEnd <= WARN_WITHIN_DAYS) status = "tracking-due-soon" + return {id, paused: false, status, windowEnd, toEnd} + } - return {id, paused: false, status, target, refund, toTarget, toRefund} + return { + id, paused: false, status: "tracking-not-started", + detail: `14-day implementation tracking has not started (current state: ${state.state || "unknown"}); no delivery deadline applies`, + } } function main() { @@ -88,24 +93,21 @@ function main() { return 0 } - const rank = {"REFUND-DUE": 0, "TARGET-MISSED": 1, "due-soon": 2, "no-day0": 3, paused: 4, "on-track": 5} + const rank = {"TRACKING-OVERDUE": 0, "tracking-due-soon": 1, "state-missing": 2, "no-day0": 3, "tracking-not-started": 4, paused: 5, "on-track": 6} rows.sort((a, b) => rank[a.status] - rank[b.status]) - console.log(`TinyStudio service deadlines — as of ${asOf}\n`) + console.log(`TinyStudio service tracking — as of ${asOf}\n`) for (const row of rows) { - const head = `${row.status.padEnd(14)} ${row.id}` - if (row.status === "no-day0" || row.paused) { + const head = `${row.status.padEnd(20)} ${row.id}` + if (row.status === "no-day0" || row.status === "state-missing" || row.status === "tracking-not-started" || row.paused) { console.log(`${head} ${row.detail}`) continue } - console.log( - `${head} ${row.toTarget} business days to internal target, ` + - `${row.toRefund} to the refund line (${row.refund.slice(0, 10)})`, - ) + console.log(`${head} ${row.toEnd} business days to the 14-day tracking-window end (${row.windowEnd.slice(0, 10)})`) } const needsAttention = rows.filter((row) => - row.status === "REFUND-DUE" || row.status === "TARGET-MISSED" || row.status === "due-soon" || row.status === "no-day0", + row.status === "TRACKING-OVERDUE" || row.status === "tracking-due-soon" || row.status === "state-missing" || row.status === "no-day0", ) if (needsAttention.length > 0) { console.log(`\n${needsAttention.length} client(s) need attention today.`) diff --git a/scripts/lib/service-artifacts.mjs b/scripts/lib/service-artifacts.mjs index 4df910ba..5c1e8a00 100644 --- a/scripts/lib/service-artifacts.mjs +++ b/scripts/lib/service-artifacts.mjs @@ -90,6 +90,30 @@ export function serviceDeadlineAt(day0StartedAt, pauseHistory = []) { const pausedBusinessMs = pauseHistory.reduce((total, pause) => total + businessMillisecondsBetween(pause.startedAt, pause.endedAt), 0) return addBusinessMillisecondsToTimestamp(addBusinessDaysToTimestamp(day0StartedAt, 7), pausedBusinessMs) } +const TRACKING_WINDOW_MS = 14 * 86400000 + +// The Website Correction contract tracks 14 active days after human acceptance +// of the implementation pass; client-delay pauses push the window out by the +// same wall time the clock was stopped (tracking stage evidence requires at +// least 14 active days between acceptance and the tracked-through instant). +// Pauses before acceptance or after the window end do not move it. +export function serviceTrackingWindowEndAt(implementationAcceptedAt, pauseHistory = []) { + const anchor = Date.parse(implementationAcceptedAt) + if (Number.isNaN(anchor)) throw new Error("implementation acceptance timestamp is invalid") + let end = anchor + TRACKING_WINDOW_MS + for (let pass = 0; pass <= pauseHistory.length + 2; pass += 1) { + const pausedWithin = pauseHistory.reduce((total, pause) => { + const start = Date.parse(pause.startedAt) + const stop = Date.parse(pause.endedAt) + if (Number.isNaN(start) || Number.isNaN(stop)) throw new Error("tracking pause interval is invalid") + return total + Math.max(0, Math.min(end, stop) - Math.max(anchor, start)) + }, 0) + const next = anchor + TRACKING_WINDOW_MS + pausedWithin + if (next === end) break + end = next + } + return new Date(end).toISOString() +} export function assertExactKeys(value, expected, name) { assert(value && typeof value === "object" && !Array.isArray(value), `${name} must be an object`) const actual = Object.keys(value).sort() @@ -457,7 +481,7 @@ export function validateDay0Record(value, applicationId) { const {total} = checkedPauseIntervals(value.pauseHistory, value.activePause, Date.parse(value.day0StartedAt), "Day 0") assert(Number.isInteger(value.totalPausedMs) && value.totalPausedMs === total, "Day 0 totalPausedMs mismatch") const expectedDeadline = serviceDeadlineAt(value.day0StartedAt, value.pauseHistory) - assert(value.deadlineAt === expectedDeadline, "Day 0 deadline must preserve seven working days and exclude paused time") + assert(value.deadlineAt === expectedDeadline, "Day 0 deadline must match the recorded deadline and exclude paused time") return value } diff --git a/scripts/test-service-engine.mjs b/scripts/test-service-engine.mjs index db6d9ace..500162a7 100644 --- a/scripts/test-service-engine.mjs +++ b/scripts/test-service-engine.mjs @@ -27,6 +27,7 @@ import { validateStageEvidence } from "./lib/review-queue.mjs" import {assertCanonicalFounderPilotCohort, assertClientScaffold, FOUNDER_PILOT} from "./lib/client-scaffold.mjs" +import {serviceTrackingWindowEndAt} from "./lib/service-artifacts.mjs" import {createPromotionJournal, promotionMarkerPath, validatePromotionJournal} from "./lib/service-promotion-journal.mjs" import {commitJournaledTransition, transitionJournalRecord} from "./lib/service-transition-journal.mjs" import {addBusinessDaysToTimestamp, businessMillisecondsBetween, localEndOfIsoDate, localIsoDate, timestampIsOnOrBeforeLocalDate, timestampIsOnOrBeforeTrustedNow, trustedNow} from "./date-utils.mjs" @@ -443,6 +444,11 @@ try { eq(businessMillisecondsBetween("2026-07-11T10:00:00.000Z", "2026-07-12T10:00:00.000Z"), 0) eq(serviceDeadlineAt("2026-07-10T10:00:00.000+05:30", [{reason: "Weekend client delay", startedAt: "2026-07-11T10:00:00.000+05:30", endedAt: "2026-07-12T10:00:00.000+05:30", durationMs: 86400000}]), "2026-07-21T10:00:00.000+05:30") eq(serviceDeadlineAt("2026-07-16T00:15:00.000+05:30"), "2026-07-27T00:15:00.000+05:30") + eq(serviceTrackingWindowEndAt("2026-07-13T10:00:00.000Z"), "2026-07-27T10:00:00.000Z") + eq(serviceTrackingWindowEndAt("2026-07-13T10:00:00.000Z", [{reason: "Client access delay", startedAt: "2026-07-15T10:00:00.000Z", endedAt: "2026-07-17T10:00:00.000Z", durationMs: 172800000}]), "2026-07-29T10:00:00.000Z") + eq(serviceTrackingWindowEndAt("2026-07-13T10:00:00.000Z", [{reason: "Pre-acceptance delay", startedAt: "2026-07-11T10:00:00.000Z", endedAt: "2026-07-12T10:00:00.000Z", durationMs: 86400000}]), "2026-07-27T10:00:00.000Z") + eq(serviceTrackingWindowEndAt("2026-07-13T10:00:00.000Z", [{reason: "Straddling delay", startedAt: "2026-07-25T10:00:00.000Z", endedAt: "2026-07-28T10:00:00.000Z", durationMs: 259200000}]), "2026-07-30T10:00:00.000Z") + thr(() => serviceTrackingWindowEndAt("not-a-date"), /implementation acceptance timestamp is invalid/) const futureApplication = {...application, applicationId: "018f5a54-84aa-7ae0-a1fd-4da350490779", submittedAt: nextLocalMidnight} const futureApplicationPath = rp("future-application.json") @@ -2126,6 +2132,81 @@ try { rm(backupParent, {recursive: true, force: true}) } + // ——— check-service-deadlines.mjs: 14-day tracking attention status ——— + const deadlineScenarios = [] + try { + const scenario = () => { + const dir = mkdtempSync(join(tmpdir(), "tinystudio-deadline-scenario-")) + md(join(dir, "clients"), {recursive: true}) + deadlineScenarios.push(dir) + return dir + } + const runDeadlineCheck = (dir, now) => spawnSync(process.execPath, [join(process.cwd(), "scripts/check-service-deadlines.mjs")], {cwd: dir, env: {...process.env, SERVICE_DEADLINE_NOW: now}, encoding: "utf8"}) + const day0Fixture = (id, startedAt, pauseHistory = [], activePause = null) => ({ + applicationId: id, paymentEvidence: `paid: invoice ${id}`, requiredContext: "approved context", + approvalOwner: "Founder", implementationOwner: "TinyStudio", offerName: "The Website Correction", + offerPriceUsd: 1000, pricingCohort: "founder-pilot", pilotSequence: 1, ready: true, + day0StartedAt: startedAt, updatedAt: startedAt, paused: Boolean(activePause), activePause, + pauseHistory, totalPausedMs: pauseHistory.reduce((total, pause) => total + pause.durationMs, 0), + deadlineAt: addBusinessDaysToTimestamp(startedAt, 7), resumeState: activePause ? "tracking-14-day" : "" + }) + const writeClient = (dir, id, day0, state) => { + const folder = join(dir, "clients", id) + md(folder, {recursive: true}) + aw(join(folder, "service-day0.json"), day0) + if (state) aw(join(folder, "service-state.json"), state) + } + const retiredPromiseLanguage = /\b(?:seven[- ]day|7[- ]day)\b|refund|guarantee|sprint/i + + const trackingDir = scenario() + const trackingId = "110f5a54-84aa-7ae0-a1fd-4da350490001" + writeClient(trackingDir, trackingId, day0Fixture(trackingId, "2026-07-01T10:00:00.000Z"), {state: "tracking-14-day", implementationAcceptedAt: "2026-07-13T10:00:00.000Z"}) + const onTrack = runDeadlineCheck(trackingDir, "2026-07-20T10:00:00.000Z") + eq(onTrack.status, 0, onTrack.stdout) + mat(onTrack.stdout, new RegExp(`on-track\\s+${trackingId}\\s+5 business days to the 14-day tracking-window end \\(2026-07-27\\)`)) + mat(onTrack.stdout, /All clients on track\./) + const dueSoon = runDeadlineCheck(trackingDir, "2026-07-24T10:00:00.000Z") + eq(dueSoon.status, 1) + mat(dueSoon.stdout, new RegExp(`tracking-due-soon\\s+${trackingId}`)) + const overdue = runDeadlineCheck(trackingDir, "2026-07-28T10:00:00.000Z") + eq(overdue.status, 1) + mat(overdue.stdout, new RegExp(`TRACKING-OVERDUE\\s+${trackingId}\\s+-1 business days to the 14-day tracking-window end`)) + + const pausedDir = scenario() + const pausedId = "220f5a54-84aa-7ae0-a1fd-4da350490002" + const clientDelay = [{reason: "Client access delay", startedAt: "2026-07-15T10:00:00.000Z", endedAt: "2026-07-17T10:00:00.000Z", durationMs: 172800000}] + writeClient(pausedDir, pausedId, day0Fixture(pausedId, "2026-07-01T10:00:00.000Z", clientDelay), {state: "tracking-14-day", implementationAcceptedAt: "2026-07-13T10:00:00.000Z"}) + const extended = runDeadlineCheck(pausedDir, "2026-07-24T10:00:00.000Z") + eq(extended.status, 0, extended.stdout) + mat(extended.stdout, new RegExp(`on-track\\s+${pausedId}\\s+3 business days to the 14-day tracking-window end \\(2026-07-29\\)`)) + eq(runDeadlineCheck(pausedDir, "2026-07-30T10:00:00.000Z").status, 1) + + const mixedDir = scenario() + const preTrackingId = "330f5a54-84aa-7ae0-a1fd-4da350490003" + writeClient(mixedDir, preTrackingId, day0Fixture(preTrackingId, "2026-07-01T10:00:00.000Z"), {state: "implementation"}) + const clockPausedId = "440f5a54-84aa-7ae0-a1fd-4da350490004" + writeClient(mixedDir, clockPausedId, day0Fixture(clockPausedId, "2026-07-01T10:00:00.000Z", [], {reason: "Client access pending", startedAt: "2026-07-05T10:00:00.000Z"}), {state: "tracking-14-day", implementationAcceptedAt: "2026-07-13T10:00:00.000Z"}) + const noDay0Id = "550f5a54-84aa-7ae0-a1fd-4da350490005" + md(join(mixedDir, "clients", noDay0Id), {recursive: true}) + aw(join(mixedDir, "clients", noDay0Id, "service-day0.json"), {}) + const stateMissingId = "660f5a54-84aa-7ae0-a1fd-4da350490006" + md(join(mixedDir, "clients", stateMissingId), {recursive: true}) + aw(join(mixedDir, "clients", stateMissingId, "service-day0.json"), day0Fixture(stateMissingId, "2026-07-01T10:00:00.000Z")) + const mixed = runDeadlineCheck(mixedDir, "2026-07-24T10:00:00.000Z") + eq(mixed.status, 1) + mat(mixed.stdout, new RegExp(`tracking-not-started\\s+${preTrackingId}.*14-day implementation tracking has not started \\(current state: implementation\\); no delivery deadline applies`)) + mat(mixed.stdout, new RegExp(`paused\\s+${clockPausedId}.*clock paused since 2026-07-05T10:00:00.000Z — Client access pending`)) + mat(mixed.stdout, new RegExp(`no-day0\\s+${noDay0Id}.*Day 0 not recorded`)) + mat(mixed.stdout, new RegExp(`state-missing\\s+${stateMissingId}.*service state not recorded`)) + mat(mixed.stdout, /2 client\(s\) need attention today\./) + + for (const output of [onTrack.stdout, dueSoon.stdout, overdue.stdout, extended.stdout, mixed.stdout]) { + assert(!retiredPromiseLanguage.test(output), "check-service-deadlines output revived retired seven-day/refund language") + } + } finally { + for (const dir of deadlineScenarios) rm(dir, {recursive: true, force: true}) + } + assert(ALLOWED_COMMANDS.every(argv => Array.isArray(argv) && argv.every(part => typeof part === "string"))) const engineSource = [rf(join(process.cwd(), "scripts/lib/review-queue.mjs"), "utf8"), rf(join(process.cwd(), QUEUE), "utf8")].join("\n") assert(!/node:child_process|\bfetch\s*\(|\bexec(?:File)?\s*\(|\bspawn\s*\(/.test(engineSource))