Merge branch 'next' into fix/2137-derive-progress-header-driven
This commit is contained in:
@@ -20,6 +20,8 @@ export interface Clock {
|
||||
now(): number;
|
||||
nowIso(): string;
|
||||
today(): string;
|
||||
/** Host-local calendar day as YYYY-MM-DD — for operator-facing date-only fields. */
|
||||
localToday(): string;
|
||||
sleep(ms: number): void;
|
||||
}
|
||||
|
||||
@@ -87,6 +89,26 @@ export const realClock: Clock = {
|
||||
return this.nowIso().split('T')[0];
|
||||
},
|
||||
|
||||
/**
|
||||
* Return today's date as a YYYY-MM-DD string in the HOST-LOCAL calendar day.
|
||||
* Uses this.now() so the subprocess time-pin adapter (GSD_NOW_MS) is honoured
|
||||
* exactly as today()/nowIso() are — deterministic when GSD_NOW_MS and TZ are
|
||||
* both pinned (#2136).
|
||||
*
|
||||
* Operator-facing date-only fields (last_activity, "completed <date>", etc.)
|
||||
* must use the local calendar day: an operator reads them as "the day I did
|
||||
* this", and they must never name a day ahead of `last_updated`'s local date.
|
||||
* `today()` (UTC) stays the source for internal/cosmetic stamps.
|
||||
*
|
||||
* @returns e.g. "2020-06-14" (local), which may differ from today() near UTC midnight
|
||||
*/
|
||||
localToday(): string {
|
||||
const d = new Date(this.now());
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return d.getFullYear() + '-' + mm + '-' + dd;
|
||||
},
|
||||
|
||||
/**
|
||||
* Synchronous sleep via Atomics.wait.
|
||||
* This is the identical primitive acquireStateLock and withPlanningLock used
|
||||
|
||||
@@ -42,6 +42,7 @@ const { extractFrontmatter } = frontmatter;
|
||||
import modelProfiles = require('./model-profiles.cjs');
|
||||
const { MODEL_PROFILES, VALID_PHASE_TYPES } = modelProfiles;
|
||||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||||
import { realClock } from './clock.cjs';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1418,7 +1419,7 @@ function cmdTodoComplete(cwd: string, filename: string | undefined, raw: boolean
|
||||
|
||||
// Read, add completion timestamp, move
|
||||
let content = fs.readFileSync(sourcePath, 'utf-8');
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const today = realClock.localToday();
|
||||
content = `completed: ${today}\n` + content;
|
||||
|
||||
platformWriteSync(path.join(completedDir, filename as string), content);
|
||||
@@ -1430,7 +1431,10 @@ function cmdTodoComplete(cwd: string, filename: string | undefined, raw: boolean
|
||||
function cmdScaffold(cwd: string, type: string, options: ScaffoldOptions, raw: boolean): void {
|
||||
const { phase, name } = options;
|
||||
const padded = phase ? normalizePhaseName(phase) : '00';
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
// #2136 sibling site (deliberately deferred per the issue's scope): scaffold's
|
||||
// date stays on the raw UTC slice for now; route through realClock.localToday()
|
||||
// alongside workstream.cts/gsd2-import.cts/template.cts/verify.cts in a follow-up.
|
||||
const today = realClock.localToday();
|
||||
|
||||
// Find phase directory
|
||||
const phaseInfo = phase ? findPhaseInternal(cwd, phase) as Record<string, unknown> | null : null;
|
||||
|
||||
@@ -23,6 +23,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { platformWriteSync } from './shell-command-projection.cjs';
|
||||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||||
import { realClock } from './clock.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import ioMod = require('./io.cjs');
|
||||
const { output } = ioMod;
|
||||
@@ -351,7 +352,7 @@ function buildStateMd(phaseMap: PhaseMapEntry[]): string {
|
||||
|
||||
const filled = Math.round(pct / 10);
|
||||
const bar = `[${'█'.repeat(filled)}${'░'.repeat(10 - filled)}]`;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const today = realClock.localToday();
|
||||
|
||||
return [
|
||||
'# Project State',
|
||||
|
||||
@@ -584,7 +584,21 @@ const EXTENSION_EVENT_SURFACES: Readonly<Record<string, readonly string[]>> = Ob
|
||||
'pre_gateway_dispatch', 'pre_approval_request',
|
||||
'transform_tool_result',
|
||||
]),
|
||||
pi: Object.freeze(['tool_call']),
|
||||
// #2102 Stage 2 — pi's real ExtensionAPI event vocabulary (~30 fine-grained
|
||||
// extension events; documentation-sourced, ADR-1239 §research). Replaces the
|
||||
// placeholder single-event ['tool_call'] surface — the Stage 1 value only
|
||||
// covered the one event pi/gsd.cjs happened to bind at the time, not the
|
||||
// full declared surface.
|
||||
pi: Object.freeze([
|
||||
'session_start', 'project_trust', 'resources_discover', 'input',
|
||||
'before_agent_start', 'agent_start', 'message_start', 'message_update',
|
||||
'message_end', 'turn_start', 'context', 'before_provider_request',
|
||||
'after_provider_response', 'tool_execution_start', 'tool_execution_update',
|
||||
'tool_execution_end', 'tool_call', 'tool_result', 'turn_end', 'agent_end',
|
||||
'session_before_switch', 'session_shutdown', 'session_before_fork',
|
||||
'session_info_changed', 'session_before_compact', 'session_compact',
|
||||
'session_before_tree', 'session_tree', 'thinking_level_select', 'model_select',
|
||||
]),
|
||||
none: Object.freeze([]),
|
||||
});
|
||||
|
||||
|
||||
15
src/init.cts
15
src/init.cts
@@ -10,6 +10,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { execGit, platformWriteSync, platformReadSync } from './shell-command-projection.cjs';
|
||||
import { realClock } from './clock.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- io.cjs is an export= CommonJS module
|
||||
import io = require('./io.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module
|
||||
@@ -820,8 +821,8 @@ function cmdInitQuick(cwd: string, description: string | undefined, raw: boolean
|
||||
slug: slug,
|
||||
description: description || null,
|
||||
|
||||
date: now.toISOString().split('T')[0],
|
||||
timestamp: now.toISOString(),
|
||||
date: realClock.localToday(),
|
||||
timestamp: realClock.nowIso(),
|
||||
|
||||
quick_dir: '.planning/quick',
|
||||
task_dir: slug ? `.planning/quick/${quickId}-${slug}` : null,
|
||||
@@ -1133,7 +1134,6 @@ function cmdInitPhaseOp(cwd: string, phase: string, raw: boolean): void {
|
||||
|
||||
function cmdInitTodos(cwd: string, area: string | undefined, raw: boolean): void {
|
||||
const config = loadConfig(cwd);
|
||||
const now = new Date();
|
||||
|
||||
const pendingDir = path.join(planningDir(cwd), 'todos', 'pending');
|
||||
let count = 0;
|
||||
@@ -1176,8 +1176,8 @@ function cmdInitTodos(cwd: string, area: string | undefined, raw: boolean): void
|
||||
const result: Record<string, unknown> = {
|
||||
commit_docs: config.commit_docs,
|
||||
|
||||
date: now.toISOString().split('T')[0],
|
||||
timestamp: now.toISOString(),
|
||||
date: realClock.localToday(),
|
||||
timestamp: realClock.nowIso(),
|
||||
|
||||
todo_count: count,
|
||||
todos,
|
||||
@@ -1306,7 +1306,6 @@ function cmdInitMilestoneOp(cwd: string, raw: boolean): void {
|
||||
|
||||
function cmdInitMapCodebase(cwd: string, raw: boolean): void {
|
||||
const config = loadConfig(cwd);
|
||||
const now = new Date();
|
||||
|
||||
const codebaseDir = path.join(planningRoot(cwd), 'codebase');
|
||||
let existingMaps: string[] = [];
|
||||
@@ -1324,8 +1323,8 @@ function cmdInitMapCodebase(cwd: string, raw: boolean): void {
|
||||
parallelization: config.parallelization,
|
||||
subagent_timeout: config.subagent_timeout,
|
||||
|
||||
date: now.toISOString().split('T')[0],
|
||||
timestamp: now.toISOString(),
|
||||
date: realClock.localToday(),
|
||||
timestamp: realClock.nowIso(),
|
||||
|
||||
codebase_dir: '.planning/codebase',
|
||||
|
||||
|
||||
@@ -342,9 +342,13 @@ function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: s
|
||||
let destName: string;
|
||||
if (kind.kind === 'agents') {
|
||||
// Agent files already carry the gsd- prefix in the source dir.
|
||||
// #1575: copilot agents get .agent.md suffix (mirrors inline loop line ~9118).
|
||||
destName = runtime === 'copilot'
|
||||
? entry.name.replace(/\.md$/, '.agent.md')
|
||||
// #2099: descriptor-driven via hostBehaviors.agentFileExtension (was
|
||||
// hardcoded `runtime === 'copilot'`). copilot declares '.agent.md';
|
||||
// every other runtime's descriptor leaves this unset, so destName falls
|
||||
// back to entry.name unchanged (byte-parity, #1575 origin comment).
|
||||
const _agentExt = runtime ? _hostBehaviors(runtime).agentFileExtension : undefined;
|
||||
destName = _agentExt
|
||||
? entry.name.replace(/\.md$/, _agentExt)
|
||||
: entry.name;
|
||||
} else if (namespacedByDir) {
|
||||
// Directory is the namespace; don't double-prefix the filename
|
||||
@@ -711,6 +715,19 @@ function installRuntimeArtifacts(
|
||||
const nestedGsdDirForCleanup = path.join(configDir, 'skills', 'gsd');
|
||||
_removeHermesBareStemDirs(nestedGsdDirForCleanup);
|
||||
}
|
||||
|
||||
// Generic-branch nativePlugin staging (ADR-1239 / #2102 Stage 1): runtimes
|
||||
// outside the OpenCode/Kilo combined-family install (e.g. pi, whose
|
||||
// artifactLayout is empty and which never sets combinedFamilyInstall) still
|
||||
// need their declared hostBehaviors.nativePlugin file copied into configDir.
|
||||
// findInstallSourceRoot resolves the repo/package root independent of
|
||||
// configDir contents (marker check, then a walk-up from __dirname), so this
|
||||
// is safe even when configDir has no .gsd-source marker (artifactLayout: []).
|
||||
if (behaviors.nativePlugin) {
|
||||
const commandsGsdDir = runtimeArtifactLayout.findInstallSourceRoot(configDir);
|
||||
const src = path.dirname(path.dirname(commandsGsdDir));
|
||||
_installNativePluginIfDeclared(runtime, configDir, behaviors, src);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -875,6 +892,44 @@ function installOpencodeFamilyCommands(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// _installNativePluginIfDeclared
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Copy a runtime's declared native-extension/plugin file (hostBehaviors.nativePlugin)
|
||||
* into its resolved config dir, when the runtime descriptor declares one.
|
||||
*
|
||||
* Extracted (ADR-1239 / #2102 Stage 1) from the body previously inlined in
|
||||
* installOpencodeFamilyArtifacts so a runtime that is NOT part of the
|
||||
* OpenCode/Kilo combined-family install (e.g. pi, whose artifactLayout is
|
||||
* empty and which never sets combinedFamilyInstall) can still get its
|
||||
* nativePlugin file staged via the generic installRuntimeArtifacts branch.
|
||||
* Behavior for opencode/kilo is unchanged — same source resolution, same
|
||||
* mkdir + copyFileSync call, same silent no-op when the source is missing.
|
||||
*
|
||||
* @param runtime - canonical runtime id (only used for the assertDestWithinConfigHome guard)
|
||||
* @param configDir - resolved runtime config directory
|
||||
* @param behaviors - the runtime's hostBehaviors descriptor
|
||||
* @param src - repo/package root (two levels up from the commands/gsd source dir)
|
||||
*/
|
||||
function _installNativePluginIfDeclared(
|
||||
runtime: string,
|
||||
configDir: string,
|
||||
behaviors: any,
|
||||
src: string,
|
||||
): void {
|
||||
const np = behaviors.nativePlugin;
|
||||
if (np && np.source) {
|
||||
const pluginSrc = path.join(src, np.source);
|
||||
if (fs.existsSync(pluginSrc)) {
|
||||
const destDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, np.dir);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.copyFileSync(pluginSrc, path.join(destDir, np.file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// installOpencodeFamilyArtifacts
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -923,15 +978,7 @@ function installOpencodeFamilyArtifacts(
|
||||
installOpencodeFamilyCommands(runtime, commandDir, rawCommandsDir, pathPrefix, resolveAttribution);
|
||||
installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution);
|
||||
|
||||
const np = behaviors.nativePlugin;
|
||||
if (np && np.source) {
|
||||
const pluginSrc = path.join(src, np.source);
|
||||
if (fs.existsSync(pluginSrc)) {
|
||||
const destDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, np.dir);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.copyFileSync(pluginSrc, path.join(destDir, np.file));
|
||||
}
|
||||
}
|
||||
_installNativePluginIfDeclared(runtime, configDir, behaviors, src);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1000,6 +1047,7 @@ export = {
|
||||
installOpencodeFamilySkills,
|
||||
installOpencodeFamilyCommands,
|
||||
installOpencodeFamilyArtifacts,
|
||||
_installNativePluginIfDeclared,
|
||||
_hostBehaviors,
|
||||
_copyStaged,
|
||||
hasExistingSymlinkBetween,
|
||||
|
||||
@@ -37,6 +37,9 @@ export const BUNDLED_GSD_HOOK_FILES: ReadonlySet<string> = Object.freeze(new Set
|
||||
'hooks/gsd-cursor-stop.js',
|
||||
'hooks/gsd-cursor-subagent-start.js',
|
||||
'hooks/gsd-cursor-subagent-stop.js',
|
||||
// Windsurf/Cascade blocking hooks — registered by writeWindsurfHooksJson (#2100).
|
||||
'hooks/gsd-windsurf-pre-write.js',
|
||||
'hooks/gsd-windsurf-pre-command.js',
|
||||
'hooks/gsd-ensure-canonical-path.js',
|
||||
'hooks/gsd-graphify-update.sh',
|
||||
'hooks/gsd-phase-boundary.sh',
|
||||
|
||||
@@ -5,8 +5,16 @@
|
||||
* so any MCP-consuming host (Claude/Codex/OpenCode/VS Code/Gemini/Cursor/Cline/
|
||||
* Hermes) can drive GSD with NO bespoke plugin:
|
||||
*
|
||||
* - point 1 (command): tool `gsd_invoke_command` → the command-routing hub
|
||||
* (`createHub`/`dispatch`, src/command-routing-hub.cts).
|
||||
* - point 1 (command): tool `gsd_invoke_command` → `dispatchGsdCommand`
|
||||
* (src/shell-command-projection.cts), a bounded subprocess-shim to
|
||||
* gsd-tools.cjs. #2102 Stage 2: `commandRoutingHub.createHub()` called
|
||||
* with no args here always hit `if(!_cjsRegistry) return
|
||||
* makeUnknownCommand()` — every dispatch was UnknownCommand. No
|
||||
* fully-populated hub factory exists anywhere in gsd-core (every
|
||||
* createHub() caller builds a single-family hub for its own narrow
|
||||
* purpose), so the fix routes through the SAME shared dispatch helper
|
||||
* the pi extension uses (pi/gsd.cjs), mirroring the SUBPROCESS-REUSE
|
||||
* precedent already established for the OpenCode/Kilo hook bridge.
|
||||
* - point 5 (state IO): tools `gsd_read_state` / `gsd_write_state` → the
|
||||
* Phase 3 `stateIO` seam (src/state-io.cts, filesystem default).
|
||||
*
|
||||
@@ -20,10 +28,11 @@
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import commandRoutingHub = require('./command-routing-hub.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import stateIo = require('./state-io.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import shellCommandProjection = require('./shell-command-projection.cjs');
|
||||
const { dispatchGsdCommand } = shellCommandProjection;
|
||||
|
||||
export const PROTOCOL_VERSION = '2024-11-05';
|
||||
export const SERVER_NAME = 'gsd-core';
|
||||
@@ -108,9 +117,11 @@ function callTool(name: string, args: unknown, ctx: McpContext): { content: Arra
|
||||
if (!family || !subcommand) {
|
||||
return { isError: true, content: [{ type: 'text', text: 'gsd_invoke_command requires string "family" and "subcommand".' }] };
|
||||
}
|
||||
const hub = commandRoutingHub.createHub();
|
||||
const res = hub.dispatch({ family, subcommand, args: Array.isArray(a.args) ? a.args : [], cwd, raw: undefined });
|
||||
return { content: [{ type: 'text', text: JSON.stringify(res) }] };
|
||||
const res = dispatchGsdCommand({ family, subcommand, args: Array.isArray(a.args) ? (a.args as string[]) : [], cwd });
|
||||
if (!res.ok) {
|
||||
return { isError: true, content: [{ type: 'text', text: res.stderr || res.stdout || `dispatch failed (exit ${res.code})` }] };
|
||||
}
|
||||
return { content: [{ type: 'text', text: res.stdout }] };
|
||||
}
|
||||
if (name === 'gsd_read_state') {
|
||||
const p = asString(a.path);
|
||||
|
||||
@@ -67,41 +67,60 @@ function cmdRequirementsMarkComplete(cwd: string, reqIdsRaw: string[], raw: bool
|
||||
const updated: string[] = [];
|
||||
const alreadyComplete: string[] = [];
|
||||
const notFound: string[] = [];
|
||||
// #2140: IDs reconciled on the checkbox surface only — a traceability table
|
||||
// exists but has no row for the ID. Without this bucket the payload for a
|
||||
// partial reconcile is byte-identical to a full one, and audit-milestone (which
|
||||
// reads the table) still sees Pending while the CLI reported success.
|
||||
const tableUnmatched: string[] = [];
|
||||
|
||||
// A traceability table is present if the file has a "| Requirement | … |"
|
||||
// header. A REQUIREMENTS.md with no such table is legitimate (mid-roadmap), so
|
||||
// a missing row only counts as drift when a table actually exists.
|
||||
const hasTable = /^\|\s*Requirement\s*\|/im.test(reqContent);
|
||||
|
||||
for (const reqId of reqIds) {
|
||||
let found = false;
|
||||
const reqEscaped = escapeRegex(reqId);
|
||||
|
||||
// Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
|
||||
// Use replace() directly and compare — avoids test()+replace() global regex
|
||||
// Surface 1 — the checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
|
||||
// Use replace() + compare to avoid the test()+replace() global regex
|
||||
// lastIndex bug where test() advances state and replace() misses matches.
|
||||
const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
|
||||
const afterCheckbox = reqContent.replace(checkboxPattern, '$1x$2');
|
||||
if (afterCheckbox !== reqContent) {
|
||||
reqContent = afterCheckbox;
|
||||
found = true;
|
||||
}
|
||||
const checkboxHit = afterCheckbox !== reqContent;
|
||||
if (checkboxHit) reqContent = afterCheckbox;
|
||||
|
||||
// Update traceability table: | REQ-ID | Phase N | Pending | → | REQ-ID | Phase N | Complete |
|
||||
// Surface 2 — the traceability row: | REQ-ID | Phase N | Pending | → ... Complete |
|
||||
const tablePattern = new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi');
|
||||
const afterTable = reqContent.replace(tablePattern, '$1 Complete $2');
|
||||
if (afterTable !== reqContent) {
|
||||
reqContent = afterTable;
|
||||
found = true;
|
||||
}
|
||||
const tableHit = afterTable !== reqContent;
|
||||
if (tableHit) reqContent = afterTable;
|
||||
|
||||
if (found) {
|
||||
// Coverage of the traceability surface for this ID (computed after any flip).
|
||||
// hasRow keys on the ID + a second cell (`| ID | <phase> |`) so a bare mention
|
||||
// of the ID in a non-traceability table does not masquerade as a real row.
|
||||
const hasRow = new RegExp(`\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|`, 'i').test(reqContent);
|
||||
const doneCheckbox = new RegExp(`-\\s*\\[x\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i').test(reqContent);
|
||||
const doneTable = new RegExp(`\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|\\s*Complete\\s*\\|`, 'i').test(reqContent);
|
||||
|
||||
if (checkboxHit || tableHit) {
|
||||
updated.push(reqId);
|
||||
} else {
|
||||
// Check if already complete before declaring not_found.
|
||||
// Non-global flag is fine here — we only need to know if a match exists.
|
||||
const doneCheckbox = new RegExp(`-\\s*\\[x\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i');
|
||||
const doneTable = new RegExp(`\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|\\s*Complete\\s*\\|`, 'i');
|
||||
if (doneCheckbox.test(reqContent) || doneTable.test(reqContent)) {
|
||||
alreadyComplete.push(reqId);
|
||||
} else {
|
||||
notFound.push(reqId);
|
||||
}
|
||||
} else if (doneTable || (doneCheckbox && !hasTable)) {
|
||||
// Fully reconciled: the table row is Complete, OR the checkbox is done and
|
||||
// there is no table to reconcile against. (A [x] checkbox with a Pending or
|
||||
// absent row is NOT fully reconciled when a table exists — #2140.)
|
||||
alreadyComplete.push(reqId);
|
||||
} else if (!doneCheckbox && !doneTable) {
|
||||
notFound.push(reqId);
|
||||
}
|
||||
// else: doneCheckbox && hasTable && !doneTable — partially reconciled. It is
|
||||
// neither updated, already_complete, nor not_found; the table_unmatched bucket
|
||||
// below carries the truthful partial-reconcile signal.
|
||||
|
||||
// Surface traceability drift: checkbox reconciled (this run or before) but the
|
||||
// table has no row for this ID. This is what makes a partial reconcile
|
||||
// distinguishable from a full one (#2140).
|
||||
if (hasTable && doneCheckbox && !hasRow) {
|
||||
tableUnmatched.push(reqId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +134,7 @@ function cmdRequirementsMarkComplete(cwd: string, reqIdsRaw: string[], raw: bool
|
||||
marked_complete: updated,
|
||||
already_complete: alreadyComplete,
|
||||
not_found: notFound,
|
||||
table_unmatched: tableUnmatched,
|
||||
total: reqIds.length,
|
||||
},
|
||||
raw,
|
||||
@@ -138,7 +158,7 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
|
||||
const milestonesPath = path.join(planningBase, 'MILESTONES.md');
|
||||
const archiveDir = path.join(planningBase, 'milestones');
|
||||
const phasesDir = planningPaths(cwd).phases;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const today = realClock.localToday();
|
||||
const milestoneName = options.name || version;
|
||||
|
||||
// Ensure archive directory exists
|
||||
|
||||
@@ -1401,7 +1401,7 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
||||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||||
const statePath = path.join(planningDir(cwd), 'STATE.md');
|
||||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||||
const today = realClock.today();
|
||||
const today = realClock.localToday();
|
||||
|
||||
const phaseInfoRaw = findPhaseInternal(cwd, phaseNum);
|
||||
if (!phaseInfoRaw) {
|
||||
|
||||
@@ -275,6 +275,19 @@ interface MilestoneInfo {
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a leading delimiter run (whitespace, em/en-dash, colon, hyphen) from a
|
||||
* milestone-name capture. Markdown headings commonly take the shape
|
||||
* `## vX.Y — Name` or `## vX.Y: Name`; the raw capture includes the delimiter
|
||||
* because `.trim()` only removes whitespace, not punctuation. A name beginning
|
||||
* with punctuation is a delimiter-led fragment, not the curated name (#2135).
|
||||
* NOTE: do not strip `#` — a name beginning with `#` is a heading-parse failure
|
||||
* that should stay loud rather than be silently cleaned.
|
||||
*/
|
||||
function stripLeadingDelimiter(s: string): string {
|
||||
return s.replace(/^[\s—–:-]+/, '').trim();
|
||||
}
|
||||
|
||||
function getMilestoneInfo(cwd: string): MilestoneInfo {
|
||||
try {
|
||||
const roadmap = platformReadSync(path.join(planningDir(cwd), 'ROADMAP.md'));
|
||||
@@ -294,22 +307,34 @@ function getMilestoneInfo(cwd: string): MilestoneInfo {
|
||||
|
||||
if (stateVersion) {
|
||||
const escapedVer = escapeRegex(stateVersion);
|
||||
const headingMatch = roadmap.match(
|
||||
new RegExp(`##[^\\n]*${escapedVer}[:\\s]+([^\\n(]+)`, 'i')
|
||||
|
||||
// #2135: consult the 🚧 name-bearing marker FIRST. It is the only construct
|
||||
// guaranteed to carry the milestone's curated name adjacent to its version
|
||||
// (the active-milestone bullet). A `##` heading is often nameless
|
||||
// ("## vX.Y — Active Milestone") and, when unanchored, was matched
|
||||
// spuriously on a copy quoted inside backticks in this very bullet.
|
||||
const listMatch = roadmap.match(
|
||||
new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\s+([^*\\n]+)`, 'i')
|
||||
);
|
||||
if (headingMatch) {
|
||||
if (!headingMatch[0].includes('✅')) {
|
||||
return { version: stateVersion, name: headingMatch[1].trim() };
|
||||
}
|
||||
} else {
|
||||
const listMatch = roadmap.match(
|
||||
new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\s+([^*\\n]+)`, 'i')
|
||||
);
|
||||
if (listMatch) {
|
||||
return { version: stateVersion, name: listMatch[1].trim() };
|
||||
}
|
||||
return { version: stateVersion, name: 'milestone' };
|
||||
if (listMatch) {
|
||||
const name = stripLeadingDelimiter(listMatch[1]);
|
||||
if (name) return { version: stateVersion, name };
|
||||
}
|
||||
|
||||
// Fall back to the `##` heading — ANCHORED to line start (`^` + `m` flag)
|
||||
// so a heading quoted inside backticks or prose mid-line can no longer
|
||||
// match. Skip shipped (✅) headings.
|
||||
const headingMatch = roadmap.match(
|
||||
new RegExp(`^##[^\\n]*${escapedVer}[:\\s]+([^\\n(]+)`, 'im')
|
||||
);
|
||||
if (headingMatch && !headingMatch[0].includes('✅')) {
|
||||
// Strip a leading delimiter — `.trim()` removes whitespace, not the
|
||||
// em-dash/colon that conventionally separates version from name.
|
||||
const name = stripLeadingDelimiter(headingMatch[1]);
|
||||
if (name) return { version: stateVersion, name };
|
||||
}
|
||||
|
||||
return { version: stateVersion, name: 'milestone' };
|
||||
}
|
||||
|
||||
const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
|
||||
|
||||
@@ -497,7 +497,7 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
|
||||
const verificationPassed = readVerificationStatus(phaseDir).status === 'passed';
|
||||
const isComplete = summaryCount >= planCount && verificationPassed;
|
||||
const status = isComplete ? 'Complete' : summaryCount > 0 ? 'In Progress' : 'Planned';
|
||||
const today = realClock.today();
|
||||
const today = realClock.localToday();
|
||||
|
||||
if (!fs.existsSync(roadmapPath)) {
|
||||
output({ updated: false, reason: 'ROADMAP.md not found', plan_count: planCount, summary_count: summaryCount }, raw, 'no roadmap');
|
||||
|
||||
@@ -83,6 +83,21 @@ function _hostBehaviors(runtime: string): Record<string, unknown> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public accessor for the `hostBehaviors.agentFileExtension` descriptor field
|
||||
* (ADR-1239 / #2099 / #2103). Returns the runtime's declared agent-file
|
||||
* destination-suffix rename target (e.g. copilot's `.agent.md`), or
|
||||
* `undefined` when the runtime declares none (the generic no-rename
|
||||
* default). Exported so callers outside this module (surface.cts's
|
||||
* `_syncGsdDir`) can derive the SAME rename decision as
|
||||
* install-engine.cts's staged-copy loop from ONE descriptor read, instead of
|
||||
* duplicating a hardcoded `runtime === 'copilot'` check (#2103 fold).
|
||||
*/
|
||||
function agentFileExtensionFor(runtime: string): string | undefined {
|
||||
const ext = _hostBehaviors(runtime).agentFileExtension;
|
||||
return typeof ext === 'string' ? ext : undefined;
|
||||
}
|
||||
|
||||
|
||||
const colorNameToHex = {
|
||||
cyan: '#00FFFF',
|
||||
@@ -2756,10 +2771,12 @@ function normalizeAgentBodyForRuntime(content: string, runtime: string, cmdNames
|
||||
* ~/\.claude\b → normalizedPathPrefix
|
||||
* $HOME/\.claude\b → normalizedPathPrefix
|
||||
*
|
||||
* Skipped for copilot (hardcoded — #2099 will fold it) and for any runtime
|
||||
* that declares `hostBehaviors.noPathRewrite` (descriptor-driven, ADR-1239 /
|
||||
* #2096 — folds the prior hardcoded `runtime === 'antigravity'` literal;
|
||||
* Antigravity does NOT do path rewrites in the inline loop). NO stamp
|
||||
* Skipped for any runtime that declares `hostBehaviors.noPathRewrite`
|
||||
* (descriptor-driven, ADR-1239 / #2096 — folds the prior hardcoded
|
||||
* `runtime === 'antigravity'` literal; Antigravity does NOT do path rewrites
|
||||
* in the inline loop / #2103 — folds the prior hardcoded
|
||||
* `runtime === 'copilot'` literal onto the same descriptor field, since
|
||||
* copilot also skips these rewrites). NO stamp
|
||||
* (_stampNonClaudeRuntimeDefaults) — agents are NOT stamped in the inline loop.
|
||||
*
|
||||
* ADR-1235 §1: pre-converter cross-cutting for descriptor-driven agent pipeline.
|
||||
@@ -2769,10 +2786,10 @@ function normalizeAgentBodyForRuntime(content: string, runtime: string, cmdNames
|
||||
* @param content raw agent file content
|
||||
* @param runtime canonical runtime ID
|
||||
* @param pathPrefix trailing-slash path prefix (e.g. '$HOME/.cursor/')
|
||||
* @returns content with path-prefix rewrites applied (or unchanged for copilot / noPathRewrite runtimes)
|
||||
* @returns content with path-prefix rewrites applied (or unchanged for noPathRewrite runtimes, e.g. copilot)
|
||||
*/
|
||||
function applyAgentPathRewrites(content: string, runtime: string, pathPrefix: string): string {
|
||||
if (runtime === 'copilot' || _hostBehaviors(runtime).noPathRewrite === true) return content;
|
||||
if (_hostBehaviors(runtime).noPathRewrite === true) return content;
|
||||
const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
|
||||
content = content.replace(/~\/\.claude\//g, pathPrefix);
|
||||
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
|
||||
@@ -2813,6 +2830,11 @@ function processAttribution(
|
||||
|
||||
export = {
|
||||
processAttribution,
|
||||
// #2103: public accessor for hostBehaviors.agentFileExtension, exported so
|
||||
// surface.cts's _syncGsdDir can derive the .agent.md rename from the SAME
|
||||
// descriptor read as install-engine.cts (folds a duplicated hardcoded
|
||||
// `runtime === 'copilot'` literal).
|
||||
agentFileExtensionFor,
|
||||
yamlIdentifier,
|
||||
yamlQuote,
|
||||
toSingleLine,
|
||||
|
||||
@@ -46,7 +46,11 @@ type ConfigInstallSurface =
|
||||
| 'copilot-instructions'
|
||||
| 'cline-rules'
|
||||
| 'cursor-hooks-json'
|
||||
| 'profile-marker-only';
|
||||
| 'profile-marker-only'
|
||||
// #2103 — Marketplace/VSIX-distributed hosts (e.g. VS Code) with no CLI
|
||||
// install surface at all. Never dispatched through install()/finishInstall()
|
||||
// (see the ALLOWED_CONFIG_RUNTIMES filter below, which excludes it).
|
||||
| 'none';
|
||||
|
||||
type FinishPermissionWriter = 'opencode' | 'kilo' | 'antigravity' | null;
|
||||
|
||||
@@ -57,6 +61,7 @@ type HooksSurface =
|
||||
| 'cline-rules'
|
||||
| 'copilot-inline'
|
||||
| 'kimi-hooks-toml'
|
||||
| 'windsurf-hooks-json'
|
||||
| 'none';
|
||||
|
||||
interface RuntimeConfigIntent {
|
||||
@@ -88,10 +93,20 @@ interface InstallPlan extends RuntimeConfigIntent {
|
||||
|
||||
type RuntimeDescriptorMap = Record<string, { runtime: Record<string, unknown> | undefined }>;
|
||||
|
||||
/** The complete set of 16 supported runtimes for config-adapter dispatch. */
|
||||
/**
|
||||
* The complete set of 16 supported runtimes for config-adapter dispatch.
|
||||
*
|
||||
* Excludes runtimes whose installSurface is 'none' (#2103 — e.g. VS Code): a
|
||||
* 'none' installSurface means the runtime has NO CLI install surface at all
|
||||
* (Marketplace/VSIX-distributed, never dispatched through
|
||||
* install()/finishInstall()), so it is not a "config-adapter runtime" by
|
||||
* definition. This keeps this set in lockstep with bin/install.js's
|
||||
* `allRuntimes` (see tests/issue-57-runtime-install-no-drift.test.cjs) without
|
||||
* needing a separate hand-kept exclusion list.
|
||||
*/
|
||||
const ALLOWED_CONFIG_RUNTIMES: ReadonlySet<string> = new Set(
|
||||
Object.entries(runtimes)
|
||||
.filter(([, cap]) => cap && cap.runtime && typeof cap.runtime['installSurface'] === 'string')
|
||||
.filter(([, cap]) => cap && cap.runtime && typeof cap.runtime['installSurface'] === 'string' && cap.runtime['installSurface'] !== 'none')
|
||||
.map(([id]) => id),
|
||||
);
|
||||
|
||||
@@ -103,6 +118,7 @@ const INSTALL_SURFACES: ReadonlyArray<ConfigInstallSurface> = Object.freeze([
|
||||
'cline-rules',
|
||||
'cursor-hooks-json',
|
||||
'profile-marker-only',
|
||||
'none',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -84,6 +84,39 @@ const GSD_COPILOT_SESSION_HOOK_PWSH =
|
||||
`{ '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}' } ` +
|
||||
`else { '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}' }`;
|
||||
|
||||
// #2099 UPGRADE 1: multi-event hook bus. Each additional event is a static,
|
||||
// deterministic advisory (no branching/no-op-style, matching sessionStart's
|
||||
// tone) so the emitted hooks/gsd-session.json stays golden-trackable — no
|
||||
// node-runner invocation, no filesystem probing beyond what sessionStart
|
||||
// already does.
|
||||
const GSD_COPILOT_PRE_TOOL_MSG =
|
||||
'GSD: confirm this tool use is in scope for the active phase before proceeding.';
|
||||
const GSD_COPILOT_PRE_TOOL_HOOK_BASH =
|
||||
`printf '%s' '{"additionalContext":"${GSD_COPILOT_PRE_TOOL_MSG}"}'`;
|
||||
const GSD_COPILOT_PRE_TOOL_HOOK_PWSH =
|
||||
`'{"additionalContext":"${GSD_COPILOT_PRE_TOOL_MSG}"}'`;
|
||||
|
||||
const GSD_COPILOT_POST_TOOL_MSG =
|
||||
'GSD: review the tool result against the active phase before continuing.';
|
||||
const GSD_COPILOT_POST_TOOL_HOOK_BASH =
|
||||
`printf '%s' '{"additionalContext":"${GSD_COPILOT_POST_TOOL_MSG}"}'`;
|
||||
const GSD_COPILOT_POST_TOOL_HOOK_PWSH =
|
||||
`'{"additionalContext":"${GSD_COPILOT_POST_TOOL_MSG}"}'`;
|
||||
|
||||
const GSD_COPILOT_PROMPT_SUBMIT_MSG =
|
||||
'GSD: check this request against .planning/STATE.md scope before acting.';
|
||||
const GSD_COPILOT_PROMPT_SUBMIT_HOOK_BASH =
|
||||
`printf '%s' '{"additionalContext":"${GSD_COPILOT_PROMPT_SUBMIT_MSG}"}'`;
|
||||
const GSD_COPILOT_PROMPT_SUBMIT_HOOK_PWSH =
|
||||
`'{"additionalContext":"${GSD_COPILOT_PROMPT_SUBMIT_MSG}"}'`;
|
||||
|
||||
const GSD_COPILOT_SESSION_END_MSG =
|
||||
'GSD: update .planning/STATE.md with the session outcome before ending.';
|
||||
const GSD_COPILOT_SESSION_END_HOOK_BASH =
|
||||
`printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_END_MSG}"}'`;
|
||||
const GSD_COPILOT_SESSION_END_HOOK_PWSH =
|
||||
`'{"additionalContext":"${GSD_COPILOT_SESSION_END_MSG}"}'`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor hook constants
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -293,11 +326,18 @@ function normalizeNodePath(execPath: string, opts?: NodeNormOpts): string {
|
||||
return execPath;
|
||||
}
|
||||
|
||||
if (/^\/usr\/local\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
|
||||
return '/usr/local/bin/node';
|
||||
}
|
||||
if (/^\/opt\/homebrew\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
|
||||
return '/opt/homebrew/bin/node';
|
||||
// Homebrew (macOS Intel /usr/local, Apple Silicon /opt/homebrew, Linuxbrew
|
||||
// /home/linuxbrew/.linuxbrew, and any custom HOMEBREW_PREFIX) pins node at
|
||||
// <prefix>/Cellar/node(<@ver>)?/<ver>/bin/node, then deletes prior versions on
|
||||
// `brew upgrade node`. Rewrite to the stable <prefix>/bin/node symlink, which
|
||||
// survives the upgrade. Derive <prefix> from the path itself (more reliable
|
||||
// than HOMEBREW_PREFIX env — the path IS the install location) so every layout
|
||||
// is covered by one branch instead of one per known prefix (#2185).
|
||||
const homebrewMatch = normalizedForMatch.match(
|
||||
/^(.+)\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/i,
|
||||
);
|
||||
if (homebrewMatch) {
|
||||
return `${homebrewMatch[1]}/bin/node${homebrewMatch[3] || ''}`;
|
||||
}
|
||||
|
||||
// mise pins a concrete node version at <data>/installs/node/<ver>/bin/node
|
||||
@@ -1131,6 +1171,215 @@ function removeCursorHooksJson(targetDir: string): { changed: boolean } {
|
||||
return { changed: result.changed };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Windsurf/Cascade hook functions (ADR-1239 / #2100 Stage 2 — HOOK-BRIDGE)
|
||||
//
|
||||
// Cascade (Windsurf's agent) hooks.json format is DISTINCT from Cursor's:
|
||||
// { "hooks": { "<event>": [ { "command": "<shell cmd>", ... } ] } }
|
||||
// Each entry carries a bare `command` STRING (a shell command line) — not
|
||||
// Cursor's `{ type: 'command', command: <cmd> }` wrapper — and there is no
|
||||
// top-level `version` field. Docs (reference): https://docs.windsurf.com/llms-full.txt ,
|
||||
// https://docs.devin.ai/desktop/cascade/hooks
|
||||
//
|
||||
// Cascade blocks via EXIT CODE 2 (+ a stderr reason), not Cursor's stdout-JSON
|
||||
// `{ block: true, reason }` form — so the two hook scripts installed here
|
||||
// (hooks/gsd-windsurf-pre-write.js, hooks/gsd-windsurf-pre-command.js) speak a
|
||||
// different protocol than the Cursor scripts, even though the surrounding
|
||||
// install/reconcile infra mirrors writeCursorHooksJson/removeCursorHooksJson.
|
||||
//
|
||||
// Only 2 of GSD's 6 Cursor-parity hook events have a Cascade counterpart with
|
||||
// BLOCKING semantics: pre_write_code and pre_run_command. Cascade has no
|
||||
// context-injection channel (no `additional_context`-style advisory
|
||||
// response), so the 4 advisory events GSD registers on Cursor (sessionStart,
|
||||
// postToolUse, stop, subagentStart/subagentStop) are deliberately NOT ported.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT = 'gsd-windsurf-pre-write.js';
|
||||
const GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT = 'gsd-windsurf-pre-command.js';
|
||||
const GSD_WINDSURF_HOOK_MARKER = 'gsd-managed';
|
||||
|
||||
/** The 2 Cascade hook events GSD wires with blocking (exit-code-2) guards. */
|
||||
const WINDSURF_HOOK_EVENTS = Object.freeze(['pre_write_code', 'pre_run_command'] as const);
|
||||
|
||||
/** Event → hook-script mapping (mirrors CURSOR_EVENT_SCRIPT_MAP's convention). */
|
||||
const WINDSURF_EVENT_SCRIPT_MAP: Readonly<Record<string, string>> = Object.freeze({
|
||||
pre_write_code: GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
|
||||
pre_run_command: GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
|
||||
});
|
||||
|
||||
/** All GSD-managed Windsurf hook scripts (used by uninstall cleanup). */
|
||||
const GSD_WINDSURF_HOOK_SCRIPTS = [
|
||||
GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
|
||||
GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
|
||||
];
|
||||
|
||||
/**
|
||||
* Build a single Cascade hooks.json managed entry. Cascade's entry shape has
|
||||
* no `type` field (unlike Cursor's `{ type: 'command', command }`) — just a
|
||||
* bare `command` shell string plus the GSD marker.
|
||||
*/
|
||||
function buildWindsurfHookEntry(command: string): Record<string, unknown> {
|
||||
return {
|
||||
command,
|
||||
[GSD_WINDSURF_HOOK_MARKER]: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isManagedWindsurfHookEntry(entry: unknown): boolean {
|
||||
return Boolean(entry && typeof entry === 'object' && (entry as Record<string, unknown>)[GSD_WINDSURF_HOOK_MARKER]);
|
||||
}
|
||||
|
||||
interface WindsurfManagedEntries {
|
||||
pre_write_code?: Record<string, unknown> | null;
|
||||
pre_run_command?: Record<string, unknown> | null;
|
||||
[event: string]: Record<string, unknown> | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile GSD's managed Cascade hook entries into `<targetDir>/hooks.json`,
|
||||
* preserving any user-owned entries. Mirrors reconcileCursorHooksJson's
|
||||
* merge/no-write-when-unchanged semantics, adapted to Cascade's flatter
|
||||
* `{ hooks: { <event>: [...] } }` shape (no `version` field, no legacy
|
||||
* top-level-array lift — Cascade's hooks.json is a brand-new surface with no
|
||||
* prior shape to migrate from).
|
||||
*/
|
||||
function reconcileWindsurfHooksJson(hooksJsonPath: string, managedEntries: WindsurfManagedEntries | null): ReconcileResult {
|
||||
let parsed: Record<string, unknown> = {};
|
||||
let currentContent: string | null = null;
|
||||
|
||||
if (fs.existsSync(hooksJsonPath)) {
|
||||
const raw = fs.readFileSync(hooksJsonPath, 'utf8');
|
||||
currentContent = raw;
|
||||
if (raw.trim()) {
|
||||
try {
|
||||
parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
throw new Error(`Windsurf hooks.json parse failed: ${err && (err as Error).message ? (err as Error).message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
|
||||
|
||||
const hasNestedHooksObject =
|
||||
parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']);
|
||||
if (!hasNestedHooksObject) parsed['hooks'] = {};
|
||||
const hookTable = parsed['hooks'] as Record<string, unknown>;
|
||||
|
||||
const entries = managedEntries || {};
|
||||
|
||||
for (const event of WINDSURF_HOOK_EVENTS) {
|
||||
const existing = Array.isArray(hookTable[event]) ? (hookTable[event] as unknown[]) : [];
|
||||
const userOwned = existing.filter((e) => !isManagedWindsurfHookEntry(e));
|
||||
const newEntry = entries[event] || null;
|
||||
if (newEntry) {
|
||||
hookTable[event] = [...userOwned, newEntry];
|
||||
} else if (userOwned.length > 0) {
|
||||
hookTable[event] = userOwned;
|
||||
} else {
|
||||
delete hookTable[event];
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid writing an empty `{ "hooks": {} }` artifact.
|
||||
if (Object.keys(hookTable).length === 0) delete parsed['hooks'];
|
||||
|
||||
const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
|
||||
const changed = currentContent !== nextContent;
|
||||
const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
|
||||
if (shouldWrite) {
|
||||
atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
|
||||
}
|
||||
|
||||
return { changed: changed, wrote: shouldWrite, path: hooksJsonPath };
|
||||
}
|
||||
|
||||
interface WriteWindsurfHooksJsonOpts {
|
||||
platform?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write GSD-managed Cascade lifecycle hooks into `<targetDir>/hooks.json`.
|
||||
* Both managed hook scripts (gsd-windsurf-pre-write.js,
|
||||
* gsd-windsurf-pre-command.js) are copied from the GSD hooks/ source to
|
||||
* `<targetDir>/hooks/` first, so the hooks.json entries never reference a
|
||||
* script that wasn't installed. Mirrors writeCursorHooksJson's structure;
|
||||
* `buildHookCommand` is runtime-agnostic (it already returns a plain shell
|
||||
* command string), so it is reused as-is with `runtime: 'windsurf'` — only
|
||||
* the hooks.json ENTRY shape (buildWindsurfHookEntry) and the reconcile
|
||||
* function differ from Cursor's.
|
||||
*
|
||||
* @param targetDir - The Windsurf config dir (global: ~/.codeium/windsurf; local: .windsurf)
|
||||
* @param src - The GSD install source root (for copying hook scripts)
|
||||
* @param opts - `{ platform? }`
|
||||
* @returns `{ hooksJsonPath, changed }`
|
||||
*/
|
||||
function writeWindsurfHooksJson(targetDir: string, src: string, opts?: WriteWindsurfHooksJsonOpts): { hooksJsonPath: string; changed: boolean } {
|
||||
opts = opts || {};
|
||||
const hooksDir = path.join(targetDir, 'hooks');
|
||||
fs.mkdirSync(hooksDir, { recursive: true });
|
||||
|
||||
const srcHooksDir = path.join(src, 'hooks');
|
||||
const installedScripts = new Set<string>();
|
||||
for (const script of GSD_WINDSURF_HOOK_SCRIPTS) {
|
||||
const srcPath = path.join(srcHooksDir, script);
|
||||
const destPath = path.join(hooksDir, script);
|
||||
if (fs.existsSync(srcPath)) {
|
||||
let content = fs.readFileSync(srcPath, 'utf8');
|
||||
content = content.replace(/gsd:/gi, 'gsd-');
|
||||
fs.writeFileSync(destPath, content);
|
||||
try { fs.chmodSync(destPath, 0o755); } catch { /* Windows: ignore chmod */ }
|
||||
installedScripts.add(script);
|
||||
}
|
||||
}
|
||||
|
||||
const hookOpts: BuildHookCommandOpts = { runtime: 'windsurf', platform: opts.platform || process.platform };
|
||||
const commands: Record<string, string | null> = {};
|
||||
for (const ev of WINDSURF_HOOK_EVENTS) {
|
||||
const script = WINDSURF_EVENT_SCRIPT_MAP[ev];
|
||||
commands[ev] = (script && installedScripts.has(script)) ? buildHookCommand(targetDir, script, hookOpts) : null;
|
||||
}
|
||||
|
||||
const managedEntries: WindsurfManagedEntries = {};
|
||||
for (const ev of WINDSURF_HOOK_EVENTS) {
|
||||
const cmd = commands[ev];
|
||||
if (cmd) managedEntries[ev] = buildWindsurfHookEntry(cmd);
|
||||
}
|
||||
|
||||
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
||||
const result = reconcileWindsurfHooksJson(hooksJsonPath, managedEntries);
|
||||
return { hooksJsonPath, changed: result.changed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all GSD-managed Cascade hook entries from hooks.json. User-owned
|
||||
* entries are preserved. If the file becomes empty, it is removed.
|
||||
*
|
||||
* @param targetDir - The Windsurf config dir
|
||||
* @returns `{ changed }`
|
||||
*/
|
||||
function removeWindsurfHooksJson(targetDir: string): { changed: boolean } {
|
||||
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
||||
if (!fs.existsSync(hooksJsonPath)) return { changed: false };
|
||||
const result = reconcileWindsurfHooksJson(hooksJsonPath, null);
|
||||
if (result.changed) {
|
||||
try {
|
||||
const contentRaw = fs.readFileSync(hooksJsonPath, 'utf8');
|
||||
const parsed = JSON.parse(contentRaw) as Record<string, unknown>;
|
||||
const hookTable = (parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']))
|
||||
? (parsed['hooks'] as Record<string, unknown>)
|
||||
: {};
|
||||
const hasAnyEvents = Object.keys(hookTable).some(
|
||||
(k) => Array.isArray(hookTable[k]) && (hookTable[k] as unknown[]).length > 0,
|
||||
);
|
||||
if (!hasAnyEvents) {
|
||||
fs.unlinkSync(hooksJsonPath);
|
||||
return { changed: true };
|
||||
}
|
||||
} catch { /* best-effort: leave the file */ }
|
||||
}
|
||||
return { changed: result.changed };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Copilot hook functions
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1147,6 +1396,41 @@ function buildCopilotHookConfig(): Record<string, unknown> {
|
||||
timeoutSec: 10,
|
||||
},
|
||||
],
|
||||
// #2099 UPGRADE 1: multi-event hook bus — preToolUse (worktree/read-safety
|
||||
// advisory), postToolUse (context-monitor advisory), userPromptSubmitted
|
||||
// (prompt-guard advisory), sessionEnd (session-finalize advisory).
|
||||
preToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
bash: GSD_COPILOT_PRE_TOOL_HOOK_BASH,
|
||||
powershell: GSD_COPILOT_PRE_TOOL_HOOK_PWSH,
|
||||
timeoutSec: 10,
|
||||
},
|
||||
],
|
||||
postToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
bash: GSD_COPILOT_POST_TOOL_HOOK_BASH,
|
||||
powershell: GSD_COPILOT_POST_TOOL_HOOK_PWSH,
|
||||
timeoutSec: 10,
|
||||
},
|
||||
],
|
||||
userPromptSubmitted: [
|
||||
{
|
||||
type: 'command',
|
||||
bash: GSD_COPILOT_PROMPT_SUBMIT_HOOK_BASH,
|
||||
powershell: GSD_COPILOT_PROMPT_SUBMIT_HOOK_PWSH,
|
||||
timeoutSec: 10,
|
||||
},
|
||||
],
|
||||
sessionEnd: [
|
||||
{
|
||||
type: 'command',
|
||||
bash: GSD_COPILOT_SESSION_END_HOOK_BASH,
|
||||
powershell: GSD_COPILOT_SESSION_END_HOOK_PWSH,
|
||||
timeoutSec: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1997,6 +2281,19 @@ export = {
|
||||
GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
|
||||
GSD_CURSOR_HOOK_MARKER,
|
||||
|
||||
// Windsurf/Cascade
|
||||
buildWindsurfHookEntry,
|
||||
isManagedWindsurfHookEntry,
|
||||
reconcileWindsurfHooksJson,
|
||||
writeWindsurfHooksJson,
|
||||
removeWindsurfHooksJson,
|
||||
WINDSURF_HOOK_EVENTS,
|
||||
WINDSURF_EVENT_SCRIPT_MAP,
|
||||
GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
|
||||
GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
|
||||
GSD_WINDSURF_HOOK_SCRIPTS,
|
||||
GSD_WINDSURF_HOOK_MARKER,
|
||||
|
||||
// Copilot
|
||||
buildCopilotHookConfig,
|
||||
writeCopilotHookConfig,
|
||||
|
||||
@@ -145,11 +145,38 @@ export function getProjectInstructionFile(runtime: unknown): string {
|
||||
return 'AGENTS.md';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel returned by {@link getDirName} for a runtime whose
|
||||
* `runtime.configHome.kind === 'none'` (#2103 — a Marketplace/VSIX-distributed
|
||||
* host with NO file-projected config directory at all, e.g. VS Code).
|
||||
*
|
||||
* A plain fallback to `.claude` would be actively wrong here — it would read
|
||||
* as "this runtime installs into .claude", which is false. This sentinel is
|
||||
* a string (not `null`) so `getDirName`'s return type and every existing
|
||||
* template-literal call site (`` `${getDirName(runtime)}` `` in bin/install.js
|
||||
* / runtime-artifact-conversion.cjs / install-engine.cjs) are unaffected —
|
||||
* widening the return type to `string | null` would require auditing every
|
||||
* call site for a null-check, which is out of scope for a runtime that is
|
||||
* never actually dispatched through those installer paths (vscode has no
|
||||
* install surface — see capabilities/vscode/capability.json). The value is
|
||||
* deliberately NOT a plausible dot-dir name (parens are not valid in a
|
||||
* directory-name token GSD would ever generate) so a future caller that
|
||||
* mistakenly interpolates it into a path fails obviously rather than
|
||||
* silently colliding with a real directory.
|
||||
*/
|
||||
export const NO_LOCAL_CONFIG_DIR_SENTINEL = '(no-local-config-dir)';
|
||||
|
||||
/**
|
||||
* Map a canonical runtime id to its on-disk local config directory name
|
||||
* (e.g. `cursor` -> `.cursor`, `windsurf` -> `.windsurf`). Unknown/empty inputs
|
||||
* fall back to `.claude`.
|
||||
*
|
||||
* #2103: a runtime whose descriptor declares `configHome.kind === 'none'`
|
||||
* (no file-projected config directory at all) returns
|
||||
* {@link NO_LOCAL_CONFIG_DIR_SENTINEL} instead of falling through to
|
||||
* `.claude` — it has no local config dir, and `.claude` would be a wrong
|
||||
* answer, not just an imprecise one.
|
||||
*
|
||||
* Pure runtime-identity projection. Relocated from `bin/install.js` per
|
||||
* ADR-1508 (epic #1507, #1510 Phase 1) so the Runtime Artifact Conversion
|
||||
* Module's rewrite engine can consume it without importing the installer.
|
||||
@@ -159,10 +186,12 @@ export function getDirName(runtime: string): string {
|
||||
if (!runtime) return '.claude';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { runtimes } = require('./capability-registry.cjs') as {
|
||||
runtimes: Record<string, { runtime?: { localConfigDir?: string } } | undefined>;
|
||||
runtimes: Record<string, { runtime?: { localConfigDir?: string; configHome?: { kind?: string } } } | undefined>;
|
||||
};
|
||||
const dir = runtimes[runtime]?.runtime?.localConfigDir;
|
||||
const entry = runtimes[runtime]?.runtime;
|
||||
const dir = entry?.localConfigDir;
|
||||
if (typeof dir === 'string' && dir.length > 0) return dir;
|
||||
if (entry?.configHome?.kind === 'none') return NO_LOCAL_CONFIG_DIR_SENTINEL;
|
||||
return '.claude';
|
||||
}
|
||||
|
||||
@@ -210,6 +239,12 @@ const RUNTIME_LABELS: Readonly<Record<string, string>> = {
|
||||
codebuddy: 'CodeBuddy',
|
||||
cline: 'Cline',
|
||||
zcode: 'ZCode',
|
||||
pi: 'pi',
|
||||
// #2103: vscode is a registered (role:runtime) capability for validator +
|
||||
// host-integration coverage, even though it is never CLI-installed (no
|
||||
// --vscode flag — see NON_INSTALLABLE_RUNTIMES in tests/runtime-flags.test.cjs).
|
||||
// A distinct label is still required by the drift guard below.
|
||||
vscode: 'VS Code',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -258,6 +293,12 @@ const GLOBAL_CONFIG_HOME_FRAGMENTS: Readonly<Record<string, string>> = {
|
||||
cline: "'.cline'",
|
||||
kimi: "'.config', 'agents'",
|
||||
zcode: "'.zcode'",
|
||||
// pi's global config home is ~/.pi/agent (configHome: dot-home-nested,
|
||||
// parent '.pi', name 'agent' — capabilities/pi/capability.json), matching
|
||||
// resolveConfigHomeFromDescriptor's `path.join(home, parent, name)` for the
|
||||
// no-probe dot-home-nested case (src/runtime-homes.cts). Two-segment
|
||||
// path.join args, same shape as opencode/kilo/kimi above.
|
||||
pi: "'.pi', 'agent'",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -286,7 +327,7 @@ export function getGlobalConfigHomeFragment(runtime: string): string {
|
||||
// folds the shared-hooks-install skip).
|
||||
const RUNTIME_FLAG_IDS = Object.freeze([
|
||||
'opencode', 'kilo', 'codex', 'copilot', 'antigravity', 'cursor',
|
||||
'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'kimi', 'zcode',
|
||||
'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'kimi', 'zcode', 'pi',
|
||||
] as const);
|
||||
|
||||
/**
|
||||
|
||||
@@ -477,40 +477,9 @@ export function validatePromptStructure(text: unknown, fileType: string): { vali
|
||||
return { valid: violations.length === 0, violations };
|
||||
}
|
||||
|
||||
// ─── Layer 4: Paragraph-Level Entropy Anomaly Detection ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function shannonEntropy(text: string): number {
|
||||
if (!text || text.length === 0) return 0;
|
||||
const freq: Record<string, number> = {};
|
||||
for (const ch of text) {
|
||||
freq[ch] = (freq[ch] || 0) + 1;
|
||||
}
|
||||
const len = text.length;
|
||||
let entropy = 0;
|
||||
for (const count of Object.values(freq)) {
|
||||
const p = count / len;
|
||||
entropy -= p * Math.log2(p);
|
||||
}
|
||||
return entropy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan text for paragraphs with anomalously high Shannon entropy.
|
||||
*/
|
||||
export function scanEntropyAnomalies(text: unknown): { clean: boolean; findings: string[] } {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return { clean: true, findings: [] };
|
||||
}
|
||||
const findings: string[] = [];
|
||||
const paragraphs = text.split(/\n\n+/);
|
||||
for (const para of paragraphs) {
|
||||
if (para.length <= 50) continue;
|
||||
const entropy = shannonEntropy(para);
|
||||
if (entropy > 5.5) {
|
||||
findings.push(
|
||||
`High-entropy paragraph detected (${entropy.toFixed(2)} bits/char) — possible encoded payload`
|
||||
);
|
||||
}
|
||||
}
|
||||
return { clean: findings.length === 0, findings };
|
||||
}
|
||||
// NOTE (#2198): scanEntropyAnomalies + shannonEntropy were removed as dead exports.
|
||||
// They had zero production callers — the live hooks (gsd-prompt-guard.js,
|
||||
// gsd-read-injection-scanner.js) inline their own pattern subsets for hook
|
||||
// independence and never called these functions. scanForInjection is retained
|
||||
// below: it serves as the CI codebase-scanner engine
|
||||
// (tests/prompt-injection-scan.security.test.cjs), not as a live hook.
|
||||
|
||||
@@ -518,6 +518,143 @@ export function execTool(program: string, args: string[], opts: { cwd?: string;
|
||||
return _spawnResult(result, program);
|
||||
}
|
||||
|
||||
/**
|
||||
* Result shape for {@link dispatchGsdCommand}. Modeled on the existing
|
||||
* `{exitCode,stdout,stderr,signal,error}` seam above, but flattened to the
|
||||
* fields callers actually need (never leaks a raw Error/signal — see
|
||||
* `timedOut`), per the "Unbounded Subprocesses" contract (CLAUDE.md):
|
||||
* degrade to a structured result on timeout/ENOENT, never throw.
|
||||
*/
|
||||
export interface DispatchGsdCommandResult {
|
||||
ok: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the absolute path to gsd-tools.cjs relative to THIS module.
|
||||
*
|
||||
* This file compiles to gsd-core/bin/lib/shell-command-projection.cjs — a
|
||||
* sibling of gsd-core/bin/gsd-tools.cjs — so the relative walk-up is stable
|
||||
* regardless of install location (global/local/dev-repo layouts all ship
|
||||
* gsd-core/bin/ as a unit).
|
||||
*/
|
||||
export function resolveGsdToolsPath(): string {
|
||||
return path.resolve(__dirname, '..', 'gsd-tools.cjs');
|
||||
}
|
||||
|
||||
/**
|
||||
* Subprocess-shim dispatch to gsd-tools.cjs (ADR-1239 #2102 Stage 2).
|
||||
*
|
||||
* No fully-populated in-process command-routing hub exists anywhere in the
|
||||
* tree — every `createHub()` caller (cjs-command-router-adapter.cts,
|
||||
* phase-command-router.cts, command-routing-hub.cts's own tests) builds a
|
||||
* single-family hub for its own narrow purpose. The ONLY dispatch path that
|
||||
* covers the FULL family/subcommand surface is the gsd-tools.cjs CLI itself.
|
||||
* This mirrors the SUBPROCESS-REUSE precedent already established for the
|
||||
* OpenCode/Kilo hook bridge (see .opencode/plugins/gsd-core.js header:
|
||||
* "Architecture: SUBPROCESS REUSE ... spawns existing hook scripts as child
|
||||
* processes") — the same pattern, applied to command dispatch instead of
|
||||
* hook dispatch.
|
||||
*
|
||||
* Output-flag choice (verified by direct invocation — see #2102 dispatch
|
||||
* notes for the sample invocations): always pass `--raw` (undecorated,
|
||||
* programmatically-consumable stdout on success) and `--json-errors` (a
|
||||
* structured `{ok:false,reason,message}` JSON object on stderr, with a
|
||||
* non-zero exit, instead of a free-text "Error: ..." line). Both are global
|
||||
* flags accepted by every gsd-tools.cjs family/subcommand, so passing them
|
||||
* unconditionally is safe for the full command surface.
|
||||
*
|
||||
* `family` maps 1:1 onto gsd-tools.cjs's first positional argv token;
|
||||
* `subcommand` (when present) onto the second — e.g.
|
||||
* `{family:'phase', subcommand:'add'}` → `gsd-tools.cjs phase add`. An empty
|
||||
* `subcommand` is omitted entirely (some families, e.g. `config-path`, take
|
||||
* no subcommand).
|
||||
*
|
||||
* NEVER throws. Degrades to `{ ok:false, ... }` on:
|
||||
* - a missing/invalid "family" (validated locally, no subprocess spawned)
|
||||
* - ENOENT / a missing gsd-tools.cjs (via the injectable `gsdToolsPath`)
|
||||
* - a wall-clock timeout (`timedOut:true`, mirroring the
|
||||
* `signal === 'SIGTERM' && error.code === 'ETIMEDOUT'` idiom already used
|
||||
* by worktree-safety.cts)
|
||||
* - any other unanticipated throw from the underlying spawn (defensive
|
||||
* try/catch — execTool itself is spawnSync-based and does not throw).
|
||||
*/
|
||||
export function dispatchGsdCommand({
|
||||
family,
|
||||
subcommand,
|
||||
args = [],
|
||||
cwd,
|
||||
timeout = 30_000,
|
||||
gsdToolsPath,
|
||||
}: {
|
||||
family?: string;
|
||||
subcommand?: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
timeout?: number;
|
||||
gsdToolsPath?: string;
|
||||
} = {}): DispatchGsdCommandResult {
|
||||
if (typeof family !== 'string' || family.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
stdout: '',
|
||||
stderr: 'dispatchGsdCommand requires a non-empty string "family".',
|
||||
code: null,
|
||||
timedOut: false,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedCwd = cwd || process.cwd();
|
||||
const toolsPath = gsdToolsPath || resolveGsdToolsPath();
|
||||
const argv = [
|
||||
toolsPath,
|
||||
family,
|
||||
...(subcommand ? [subcommand] : []),
|
||||
...(Array.isArray(args) ? args : []),
|
||||
'--cwd', resolvedCwd,
|
||||
'--raw',
|
||||
'--json-errors',
|
||||
];
|
||||
|
||||
let result: SpawnResultOutput;
|
||||
try {
|
||||
result = execTool(process.execPath, argv, { cwd: resolvedCwd, timeout });
|
||||
} catch (e) {
|
||||
// Defensive belt-and-suspenders: execTool is spawnSync-based and does not
|
||||
// throw today, but a degraded result here keeps this seam's no-throw
|
||||
// contract true even under an unanticipated future failure mode.
|
||||
return {
|
||||
ok: false,
|
||||
stdout: '',
|
||||
stderr: e instanceof Error ? e.message : String(e),
|
||||
code: null,
|
||||
timedOut: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Mirrors the established `result.error && (result.error as
|
||||
// NodeJS.ErrnoException).code === ...` idiom (graphify.cts, worktree-safety.cts):
|
||||
// narrow away null via `!== null` FIRST, then cast — asserting `Error | null`
|
||||
// to `NodeJS.ErrnoException | null` directly (paired with optional chaining)
|
||||
// trips a typescript-eslint no-unnecessary-type-assertion false positive for
|
||||
// this exact narrowing shape (all of ErrnoException's extra fields over Error
|
||||
// are optional).
|
||||
const timedOut = result.signal === 'SIGTERM'
|
||||
&& result.error !== null
|
||||
&& (result.error as NodeJS.ErrnoException).code === 'ETIMEDOUT';
|
||||
|
||||
return {
|
||||
ok: result.exitCode === 0 && !timedOut,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
code: result.exitCode,
|
||||
timedOut,
|
||||
};
|
||||
}
|
||||
|
||||
export function probeTty(opts: { platform?: string } = {}): string | null {
|
||||
const platform = opts.platform ?? process.platform;
|
||||
if (platform === 'win32') return null;
|
||||
|
||||
@@ -255,7 +255,7 @@ export type ProgressRecord = Record<string, unknown>;
|
||||
|
||||
export type StateTransitionDeps = {
|
||||
progressProvider: () => ProgressRecord | null;
|
||||
clock: { today: () => string; nowIso: () => string };
|
||||
clock: { today: () => string; localToday: () => string; nowIso: () => string };
|
||||
/**
|
||||
* Roadmap content provider for transitions that re-derive milestone-wide
|
||||
* progress from ROADMAP.md (completePhase). Optional: transitions that don't
|
||||
@@ -418,7 +418,7 @@ function beginPhaseCore(
|
||||
? `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${b}`
|
||||
: b;
|
||||
|
||||
const today = deps.clock.today();
|
||||
const today = deps.clock.localToday();
|
||||
|
||||
// Consult the field-classification table for the frontmatter keys this
|
||||
// transition touches (codex Phase 1 review: "table not consulted by
|
||||
@@ -721,7 +721,7 @@ function mutateCurrentPositionForAdvance(
|
||||
* adapter to construct CLI output.
|
||||
*/
|
||||
function advancePlanCore(content: string, deps: StateTransitionDeps): StateTransitionResult {
|
||||
const today = deps.clock.today();
|
||||
const today = deps.clock.localToday();
|
||||
|
||||
// #1255: body-field replacements operate on body only (frontmatter stripped),
|
||||
// not on the full content. The YAML `status:` key matches `^Status:\s*`
|
||||
@@ -849,7 +849,7 @@ function completePhaseCore(
|
||||
deps: StateTransitionDeps,
|
||||
): StateTransitionResult {
|
||||
const updated: string[] = [];
|
||||
const today = deps.clock.today();
|
||||
const today = deps.clock.localToday();
|
||||
|
||||
// Consult the field-classification table for the frontmatter keys this
|
||||
// transition touches (same guard beginPhaseCore applies). A missing row is a
|
||||
@@ -1020,7 +1020,7 @@ function plannedPhaseCore(
|
||||
deps: StateTransitionDeps,
|
||||
): StateTransitionResult {
|
||||
const updated: string[] = [];
|
||||
const today = deps.clock.today();
|
||||
const today = deps.clock.localToday();
|
||||
|
||||
for (const fmKey of ['status', 'last_activity', 'last_activity_desc']) {
|
||||
const cls = getFieldClassification(fmKey);
|
||||
@@ -1124,7 +1124,7 @@ function milestoneSwitchCore(
|
||||
intent: { kind: 'milestoneSwitch'; version: string; name: string },
|
||||
deps: StateTransitionDeps,
|
||||
): StateTransitionResult {
|
||||
const today = deps.clock.today();
|
||||
const today = deps.clock.localToday();
|
||||
const updated: string[] = [
|
||||
'milestone',
|
||||
'milestone_name',
|
||||
@@ -1222,7 +1222,7 @@ function milestoneCompleteCore(
|
||||
deps: StateTransitionDeps,
|
||||
): StateTransitionResult {
|
||||
const updated: string[] = [];
|
||||
const today = deps.clock.today();
|
||||
const today = deps.clock.localToday();
|
||||
const version = intent.version;
|
||||
|
||||
for (const fmKey of ['status', 'last_activity', 'last_activity_desc']) {
|
||||
@@ -1520,7 +1520,7 @@ function syncCore(
|
||||
intent: { kind: 'sync'; totalPlansInPhase: number | null; percent: number | null },
|
||||
deps: StateTransitionDeps,
|
||||
): StateTransitionResult {
|
||||
const today = deps.clock.today();
|
||||
const today = deps.clock.localToday();
|
||||
const changes: string[] = [];
|
||||
let modified = content;
|
||||
const updated: string[] = [];
|
||||
|
||||
@@ -613,17 +613,33 @@ function cmdStateUpdateProgress(cwd: string, raw: boolean): void {
|
||||
const _totalSummaries = totalSummaries;
|
||||
|
||||
readModifyWriteStateMd(statePath, (content) => {
|
||||
// Try **Progress:** bold format first, then plain Progress: format
|
||||
const boldProgressPattern = /(\*\*Progress:\*\*\s*).*/i;
|
||||
const plainProgressPattern = /^(Progress:\s*).*/im;
|
||||
if (boldProgressPattern.test(content)) {
|
||||
updated = true;
|
||||
return content.replace(boldProgressPattern, (_match, prefix: string) => `${prefix}${progressStr}`);
|
||||
} else if (plainProgressPattern.test(content)) {
|
||||
updated = true;
|
||||
return content.replace(plainProgressPattern, (_match, prefix: string) => `${prefix}${progressStr}`);
|
||||
}
|
||||
return content;
|
||||
// #2177: match against the BODY only. With /i the patterns below would
|
||||
// otherwise hit the YAML frontmatter `progress:` key first (and `\s*` would
|
||||
// eat its newline, mangling the nested block), while the body Progress: line
|
||||
// — which frontmatter `percent` is re-derived from on every write — stays
|
||||
// stale and silently reverts the update.
|
||||
const body = stripFrontmatter(content);
|
||||
const fmPrefix = content.slice(0, content.length - body.length);
|
||||
|
||||
// Swap only the machine segment ("[bar] NN%" or bare "NN%"), preserving any
|
||||
// descriptive suffix an agent authored, e.g. "(2/4 plans done; blocked on…)".
|
||||
const machineSegment = /(?:\[[^\]\r\n]*\][ \t]*)?\d{1,3}%/;
|
||||
const replaceValue = (value: string) => machineSegment.test(value)
|
||||
? value.replace(machineSegment, progressStr)
|
||||
: progressStr;
|
||||
|
||||
// Try **Progress:** bold format first, then plain Progress: format.
|
||||
const boldProgressPattern = /(\*\*Progress:\*\*[ \t]*)([^\r\n]*)/i;
|
||||
const plainProgressPattern = /^(Progress:[ \t]*)([^\r\n]*)/im;
|
||||
const pattern = boldProgressPattern.test(body)
|
||||
? boldProgressPattern
|
||||
: plainProgressPattern.test(body)
|
||||
? plainProgressPattern
|
||||
: null;
|
||||
if (!pattern) return content;
|
||||
|
||||
updated = true;
|
||||
return fmPrefix + body.replace(pattern, (_match, prefix: string, value: string) => `${prefix}${replaceValue(value)}`);
|
||||
}, cwd);
|
||||
|
||||
if (updated) {
|
||||
@@ -1573,8 +1589,18 @@ function syncStateFrontmatter(content: string, cwd: string | undefined): string
|
||||
// existing frontmatter already holds; only an empty derived value falls through
|
||||
// to this guard (the primary #905 preserve path below handles that).
|
||||
const MILESTONE_NAME_PLACEHOLDER = 'milestone';
|
||||
// #2135: widen the preserve guard. A bad derive is not always the literal
|
||||
// placeholder — getMilestoneInfo can return a delimiter-led fragment
|
||||
// ("— Active Milestone") when the roadmap regex mis-binds. Preserve the
|
||||
// existing curated name unless the derived value actually looks like a name:
|
||||
// non-empty, not the placeholder, and not punctuation-led.
|
||||
const derivedName = derivedFm['milestone_name'];
|
||||
const derivedLooksLikeName = typeof derivedName === 'string'
|
||||
&& derivedName.length > 0
|
||||
&& derivedName !== MILESTONE_NAME_PLACEHOLDER
|
||||
&& !/^[\s—–:-]/.test(derivedName);
|
||||
if (
|
||||
derivedFm['milestone_name'] === MILESTONE_NAME_PLACEHOLDER &&
|
||||
!derivedLooksLikeName &&
|
||||
existingFm['milestone_name'] &&
|
||||
existingFm['milestone_name'] !== MILESTONE_NAME_PLACEHOLDER
|
||||
) {
|
||||
@@ -2572,7 +2598,7 @@ function cmdStatePrune(cwd: string, options: StatePruneOptions, raw: boolean): v
|
||||
|
||||
// Write archived entries to STATE-ARCHIVE.md
|
||||
if (archived.length > 0) {
|
||||
const timestamp = realClock.today();
|
||||
const timestamp = realClock.localToday();
|
||||
let archiveContent = platformReadSync(archivePath);
|
||||
if (archiveContent === null) {
|
||||
archiveContent = '# STATE Archive\n\nPruned entries from STATE.md. Recoverable but no longer loaded into agent context.\n\n';
|
||||
@@ -2759,7 +2785,7 @@ function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string
|
||||
return;
|
||||
}
|
||||
|
||||
const today = realClock.today();
|
||||
const today = realClock.localToday();
|
||||
const updated: string[] = [];
|
||||
|
||||
readModifyWriteStateMd(statePath, (content) => {
|
||||
|
||||
@@ -521,10 +521,14 @@ function _syncGsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | st
|
||||
const kindName = (typeof kind === 'string') ? kind : kind.kind;
|
||||
const kindPrefix = (typeof kind === 'object' && kind !== null) ? kind.prefix : 'gsd-';
|
||||
|
||||
// #1575: copilot agents are renamed .md -> .agent.md at copy time, mirroring
|
||||
// the inline agent loop in bin/install.js (line ~9118). Other runtimes keep
|
||||
// the staged filename verbatim.
|
||||
const isCopilotAgents = runtime === 'copilot' && kindName === 'agents';
|
||||
// #1575 / #2103: agent files are renamed .md -> <agentFileExtension> at copy
|
||||
// time when the runtime's descriptor declares hostBehaviors.agentFileExtension
|
||||
// (e.g. copilot's '.agent.md'), mirroring install-engine.cts's staged-copy
|
||||
// loop (`_copyStaged`) — ONE descriptor read shared by both surfaces instead
|
||||
// of a duplicated hardcoded `runtime === 'copilot'` literal. Other runtimes
|
||||
// (no agentFileExtension declared) keep the staged filename verbatim.
|
||||
const _agentExt = runtime ? runtimeArtifactConversion.agentFileExtensionFor(runtime) : undefined;
|
||||
const isRenamedAgents = !!_agentExt && kindName === 'agents';
|
||||
|
||||
if (kindName === 'skills') {
|
||||
// Skills kind: work with directories, not files.
|
||||
@@ -564,8 +568,8 @@ function _syncGsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | st
|
||||
const stagedFiles = fs.readdirSync(stagedDir).filter(f => f.endsWith('.md'));
|
||||
const stagedDestNames = new Set<string>();
|
||||
for (const file of stagedFiles) {
|
||||
const destName = isCopilotAgents
|
||||
? file.replace(/\.md$/, '.agent.md')
|
||||
const destName = isRenamedAgents
|
||||
? file.replace(/\.md$/, _agentExt)
|
||||
: (kindName === 'agents' || namespacedByDir)
|
||||
? file
|
||||
: `${kindPrefix}${file.slice(0, -3)}.md`;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { realClock } from './clock.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import ioMod = require('./io.cjs');
|
||||
const { output, error } = ioMod;
|
||||
@@ -110,7 +111,7 @@ function cmdTemplateFill(cwd: string, templateType: string | null | undefined, o
|
||||
if (!phaseInfo || !phaseInfo.found) { output({ error: 'Phase not found', phase: options.phase }, raw, undefined); return; }
|
||||
|
||||
const padded = normalizePhaseName(options.phase);
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const today = realClock.localToday();
|
||||
const phaseName = options.name || phaseInfo.phase_name || 'Unnamed';
|
||||
const phaseSlug = phaseInfo.phase_slug || generateSlugInternal(phaseName);
|
||||
const phaseId = `${padded}-${phaseSlug}`;
|
||||
|
||||
@@ -77,8 +77,26 @@ export function checkUiPresence(text: string): UiPresenceResult {
|
||||
// Normalise CRLF so the pattern sees consistent line boundaries.
|
||||
const normalised = text.replace(/\r\n/g, '\n');
|
||||
|
||||
// #2150: an explicit `**UI hint**: yes|no` metadata line is the author's
|
||||
// authoritative declaration of whether the phase has a UI surface — progress.md
|
||||
// and new-project.md already parse this line (`UI hint.*yes`). The bare token
|
||||
// `UI` in the line itself must not count as a UI indicator, and the declaration
|
||||
// overrides token-sniffing. Line-anchored (`m`) so a mid-line prose mention is
|
||||
// not treated as the metadata line; word-boundary on the value so `nope`/`not`
|
||||
// do not match `no`.
|
||||
const hintMatch = normalised.match(/^\s*\*\*UI hint\*\*\s*:\s*(yes|no)\b/im);
|
||||
const hint = hintMatch ? hintMatch[1].toLowerCase() : null;
|
||||
|
||||
// Strip ANY `**UI hint**:` line before token-sniffing so a hint without a
|
||||
// recognised yes/no (or one we did not short-circuit on) cannot false-positive
|
||||
// on the bare `UI` token.
|
||||
const sniffable = normalised
|
||||
.split('\n')
|
||||
.filter((line) => !/^\s*\*\*UI hint\*\*\s*:/i.test(line))
|
||||
.join('\n');
|
||||
|
||||
const found = new Set<string>();
|
||||
for (const line of normalised.split('\n')) {
|
||||
for (const line of sniffable.split('\n')) {
|
||||
// Reset lastIndex before each line so the global pattern restarts from 0.
|
||||
UI_GATE_PATTERN_GLOBAL.lastIndex = 0;
|
||||
for (const m of line.matchAll(UI_GATE_PATTERN_GLOBAL)) {
|
||||
@@ -86,6 +104,13 @@ export function checkUiPresence(text: string): UiPresenceResult {
|
||||
}
|
||||
}
|
||||
|
||||
if (hint === 'no') {
|
||||
return { hasUI: false, tokens: [] };
|
||||
}
|
||||
if (hint === 'yes') {
|
||||
return { hasUI: true, tokens: [...found] };
|
||||
}
|
||||
|
||||
return { hasUI: found.size > 0, tokens: [...found] };
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { phaseVariants, buildRoadmapPhaseVariants, buildNotStartedPhaseVariants } from './validate.cjs';
|
||||
import { realClock } from './clock.cjs';
|
||||
import { phaseDirNameRe, PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE, canonicalPlanStem } from './validate.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
@@ -1893,7 +1894,7 @@ function cmdValidateHealth(
|
||||
stateContent += `**Current phase:** (determining...)\n`;
|
||||
stateContent += `**Status:** Resuming\n\n`;
|
||||
stateContent += `## Session Log\n\n`;
|
||||
stateContent += `- ${new Date().toISOString().split('T')[0]}: STATE.md regenerated by ${slash('health')} --repair\n`;
|
||||
stateContent += `- ${realClock.localToday()}: STATE.md regenerated by ${slash('health')} --repair\n`;
|
||||
writeStateMd(statePath, stateContent, cwd);
|
||||
repairActions.push({ action: repair, success: true, path: 'STATE.md' });
|
||||
break;
|
||||
@@ -1944,7 +1945,7 @@ function cmdValidateHealth(
|
||||
}
|
||||
case 'backfillMilestones': {
|
||||
if (!options['backfill'] && !options['repair']) break;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const today = realClock.localToday();
|
||||
let backfilled = 0;
|
||||
for (const ver of missingFromRegistry) {
|
||||
try {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { realClock } from './clock.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import io = require('./io.cjs');
|
||||
const { output, error } = io;
|
||||
@@ -177,7 +178,7 @@ function cmdWorkstreamCreate(cwd: string, name: string | null | undefined, optio
|
||||
platformEnsureDir(wsDir);
|
||||
platformEnsureDir(path.join(wsDir, 'phases'));
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const today = realClock.localToday();
|
||||
const stateContent = [
|
||||
'---',
|
||||
`workstream: ${slug}`,
|
||||
@@ -297,7 +298,7 @@ function cmdWorkstreamComplete(cwd: string, name: string | null | undefined, opt
|
||||
if (active === name) setActiveWorkstream(cwd, null as unknown as string);
|
||||
|
||||
const archiveDir = path.join(root, 'milestones');
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const today = realClock.localToday();
|
||||
let archivePath = path.join(archiveDir, `ws-${name}-${today}`);
|
||||
let suffix = 1;
|
||||
while (fs.existsSync(archivePath)) {
|
||||
|
||||
Reference in New Issue
Block a user