Files
msd-core/src/init.cts
Tom Boucher 9a76ca6783 fix(#1882): distinguish unterminated frontmatter from absent frontmatter (#2712)
* fix(#1882): distinguish unterminated frontmatter from absent frontmatter

extractFrontmatter returned {} both for a document with no frontmatter and for
one whose fence was opened and never closed, so a file truncated mid-write was
byte-identical to a legitimate no-metadata file. Verified live through
`gsd-tools frontmatter get`: both printed {} with exit 0 and nothing on stderr.

Per ADR-1411's "corrupt is not absent" amendment the {} return is preserved
exactly -- no caller may break -- and the cause is surfaced out-of-band as a
deduplicated, unconditional stderr diagnostic. That mechanism lands as a shared
leaf module rather than a per-site copy because three sibling findings in the
same epic need it identically; four hand-rolled copies of one behaviour is the
generative-fix-divergence defect class.

The discriminator is deliberately not "opened but never closed". A Markdown
document whose first line is a thematic break takes that exact branch, so
flagging on the missing fence alone reports corruption on good Markdown -- the
failure mode this class of check has shipped with before. The unterminated
region is instead run through extractFrontmatter's own parser (extracted as
parseYamlRegion so the probe and the real parse can never diverge) and reported
only when it yields at least one key.

Also folds an inline defect found while working: src/config-loader.cts carried
two NUL bytes in the JSDoc added by this epic's Phase 1 (3eb1cede2), making it
the only non-text file under src. file(1) reported it as data and text tools
silently skipped it, defeating the audit rule that says to search the authored
source; tsc passed because the bytes sat inside a comment, so no gate caught it.
It is live on next.

Refs #1879

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#1882): pin unterminated-frontmatter detection and its negative space

Covers the discriminator on both sides. The positive rows are the issue's own
repro (LF and CRLF) plus the key-count boundary 0/1/2 around the ">= 1 parsed
key" threshold. The negative rows are the documents that reach the same branch
and must stay silent -- above all a Markdown thematic break at byte 0, which is
how this class of check has previously shipped a false positive on valid
Markdown.

Deduplication is tested on both halves of the composite key: a repeat of the
same (path, cause) is suppressed, a genuine second failure in a different file
is not, and a Windows and POSIX spelling of one path resolve to a single key.
The reset seam is asserted to actually clear -- #2674 is the precedent where a
reset that silently failed to clear made every later dedup assertion a vacuous
pass, and the cases only passed because each happened to pick an unused key, so
every case here uses a path unique to itself.

Assertions are on typed surfaces throughout -- the frozen reason enum and the
dedup-set size -- never on diagnostic prose. The one CLI-level case asserts a
differential between two runs (whether stderr is empty) rather than matching a
message, and is the wired user-reachable surface for this fix. Stream failure is
injected by overriding process.stderr.write and restoring it, never chmod 0o000,
which root bypasses.

Two properties guard the ~50 call sites of the changed function: the new
optional path argument is inert with respect to the parsed value, and LF/CRLF
spellings of a document still parse identically.

Refs #1879

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#1882): raise the truncation threshold and repair the dedup key

Isolated adversarial review found the one-key discriminator false-positives on
ordinary Markdown: a thematic break above a single labelled line -- `Note:`,
`Author:`, `TODO:`, `See:` -- parses as exactly one key and was reported as
corruption, which is the precise failure the design claimed to prevent and the
changeset promised was fixed. The threshold is now two keys. A file truncated
after exactly one key becomes a false negative; that is the same
precision-over-recall direction already taken at zero keys, and every GSD
artefact this guards carries two or more frontmatter keys.

Three dedup-key defects, each of which could silently swallow a real diagnostic:

- Backslash normalization is removed. A backslash is a legal filename character
  on Linux and macOS, so folding it to a forward slash made two genuinely
  different files share one key. Two spellings of one Windows path may now
  report twice; two distinct files can never silence each other. Lost signal is
  the worse failure.
- The key namespaces are tagged so a file literally named like the unnamed
  digest fallback can no longer collide with a path-less caller whose content
  hashes to that digest -- computable for any predictable content, no brute
  force needed.
- The source identity is computed once rather than hashed twice per emission.

Corrects the previous commit. The two NUL bytes in src/config-loader.cts were
NOT in a JSDoc comment as that message claimed; they were deliberate separators
in the live dedup key, and stripping them degraded it to bare concatenation.
They are restored as escape sequences -- byte-identical runtime string, and the
file is text again so grep can see it. The diagnostic script that misled me
indexed a character-offset string with a byte offset.

Also threads sourcePath through the STATE.md and PLAN.md readers so the two
artefacts epic #1879 is actually about name their file rather than reporting
under a content digest.

Refs #1879

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#1882): correct fixtures and assertions left behind by the review fixes

The previous commit changed two behaviours deliberately and the suite still
encoded the old ones, so gsd-test came back red with six failures across both
lanes -- all of them mine.

Fixtures carrying a single frontmatter key no longer clear the two-key
truncation threshold, so the CLI differential and the two path-less dedup cases
were asserting a diagnostic that is now correctly withheld. They now carry two
keys, which is what a real interrupted write of a GSD artefact looks like.

The Windows/POSIX case asserted that two spellings of one path collapse to a
single key -- the exact folding that was removed because it also collapsed
genuinely distinct POSIX files whose names contain a backslash. Inverted to
assert they now report separately, with the reasoning recorded inline so the
trade is not silently reversed later: mild duplicate noise on one Windows path
is acceptable, a swallowed diagnostic is not.

Refs #1879

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#1882): name the file at every read site, and report each file once

The diagnostic reached only the four frontmatter CLI verbs, so ~47 of 53 call
sites reported a truncated file under an anonymous content digest instead of
naming it. Since naming the file is the whole point -- it is what an operator
can act on -- that was a gap in the deliverable, not a scoping choice. 43 of 53
sites now pass the resolved path.

Closing it surfaced a defect the original design missed. A single truncated
STATE.md is parsed twice in a normal run: once by the read wrapper, which holds
the path, and again by a pure core downstream, which is handed only the string
and cannot know it. Those two parses keyed separately, so one file produced two
diagnostics -- and wiring more sites made the collision more likely, not less.
Every emission now registers both identities the input could be known by and
checks both before writing, so whichever caller arrives first speaks and the
other is suppressed. Distinct files with distinct content still report
separately, which is the property ADR-1411 actually requires; two files whose
truncated content is byte-identical collapse to one report, which stays the
documented limit.

Ten call sites deliberately keep no path. Two are frontmatter's own round-trip
checks during set and merge, where passing a path would report on every write.
The other eight are the state-transition pure cores, which ADR-1769 defines as
(content, intent, deps) -> newContent with injected I/O; threading a path
through them would contradict that recorded decision, so it is surfaced rather
than taken unilaterally. With the widened key they no longer double-report, and
in the normal flow the named parse runs first, so the file is still named.

Refs #1879

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#1882): inject the STATE.md path into the transition cores

The six state-transition cores parsed STATE.md frontmatter without knowing
which file it came from, so a truncated STATE.md reached the operator as an
anonymous content digest on exactly the artefact epic #1879 is named for.

ADR-1769 section 3 shapes these as (content, intent, deps) -> newContent with
injected deps, and deps is the seam for precisely this: something the core
cannot derive without doing I/O. It already carries roadmapProvider and a
phase-inventory provider on that basis, each documented as injected rather than
imported so the core stays pure and testable without disk access. A resolved
path is data, not I/O, so an optional sourcePath member extends the established
pattern rather than contradicting it, and every existing stub keeps compiling
because the member is optional.

updateCore and reconcileCurrentPosition take no deps and are left alone. With
the widened dedup key they cannot double-report, and in the normal flow the read
wrapper has already named the file by the time they run.

Also regenerates gsd-core/bin/lib/state-transition.cjs. That artifact is tracked
rather than gitignored, unlike most of its siblings, so leaving it stale would
have shipped a runtime without this change to anyone reading the repo without
building. tsc had skipped the re-emit because its incremental build info still
recorded an emit that had since been reverted, so the stale output survived a
clean build; clearing tsconfig.build.tsbuildinfo forced it. The
compiled-artifact-sync gate is what surfaced the drift and now reports all nine
tracked artifacts matching their source.

Refs #1879

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#1882): stop the widened dedup key from hiding a second file

The previous commit widened the dedup guard so one file parsed twice -- once by
a read wrapper holding the path, once by a pure core holding only the string --
reported once instead of twice. It did that by checking BOTH keys before
emitting, which silently traded one defect for a worse one: two DIFFERENT files
whose truncated content happened to be byte-identical now collided on the shared
content digest, and the second file's diagnostic was swallowed. That is the
over-coarse keying ADR-1411 explicitly forbids, reintroduced while fixing
something else.

The guard now checks only the key matching what the caller actually knows -- a
named read checks its path key, a path-less read checks its digest key -- while
still recording every key the input could later be identified by. The redundant
path-less re-parse of an already-named file stays silent, and two distinct files
always both report.

Verified across all six orderings: same file named-then-anonymous reports once;
two different files with identical content report twice; two different files
with different content report twice; the same path twice reports once; two
path-less parses of identical content report once; two path-less parses of
different content report twice.

The suite caught this -- twenty failures, all in the unusable-input tests that
reuse one truncated fixture across different paths. The local probe written
alongside the broken change did not, because it compared two files with
different content and could therefore only confirm the expected behaviour.

Refs #1879

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#1882): count diagnostics emitted, not identities interned

The suite measured the size of the dedup set as a stand-in for "how many
diagnostics were emitted". That held only while one emission recorded exactly
one key. Once an emission began recording every identity the input could later
be matched by -- a path key and a content key for the same file -- the set grew
by two per write and twenty assertions read 2 where they expected 1.

The production behaviour was correct throughout; the proxy was not. Set size
counts identities, which is an implementation detail of the guard. The
behavioural claim these tests exist to make is how many diagnostics an operator
actually saw, so the module now exposes that directly as an emission counter and
the suite asserts on it. The set-size accessor stays for assertions genuinely
about key shape.

The local probe written alongside the change did not catch this because it
counted process.stderr.write calls -- the right thing -- while the suite counted
set growth. Verification now asserts both and requires them to agree, so a
future divergence between the counter and real writes fails immediately rather
than being discovered a bench run later.

Refs #1879

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#1882): retire two assertions that outlived the behaviour they described

Both tests encoded assumptions the dedup fix invalidated, and both were caught
by the suite rather than by the probe written alongside the change.

The forged-path case asserted that a file named like the anonymous digest
fallback must not suppress a later path-less report. That premise is gone: an
emission now records every identity its input could be matched by, so ANY named
report of some content silences the anonymous re-parse of that same content --
which is the same-file guard working as intended, and has nothing to do with the
crafted name. The property still worth defending is that a crafted filename can
never silence a real file reported under its own path, so that is what the test
now asserts, with the deliberate suppression documented beside it.

The reset-seam case ended by reading the size of the dedup set and expecting 1.
Set size counts interned identities, not diagnostics written, and one emission
now interns two. It asserts the emission counter for the event and keeps a
weaker set-size check for the interning.

Refs #1879

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#1882): close the review findings on the discriminator, dry-run and counter

Three orthogonal review passes ran against the final diff. Their findings:

A labelled preamble under a leading rule was still misreported. Raising the key
threshold to two only moved the boundary, because two colon-labelled lines are
as common in ordinary prose as one -- a document opening with a rule over an
Author and a Reviewed-by line, then prose, was called corrupt. Key count alone
cannot separate the two. What does is what follows: a write interrupted part way
through a frontmatter block ends mid-block, so every line of the region is still
frontmatter-shaped, whereas a document merely opening with a rule goes on to
prose. Both conditions are now required, and each closes a false-positive class
the other leaves open. Nested list values and indented continuations stay
frontmatter-shaped, so legitimate truncations are unaffected.

`state rebuild --dry-run` reported a truncated STATE.md anonymously. The write
path is named only because readModifyWriteStateMd parses with the path first;
the dry-run branch reads the file directly and never did. Dry-run is the
read-only mode an operator reaches for first when they suspect corruption, so it
is the one that most needed to name the file. reconcileCurrentPosition takes the
path as an optional argument now and rebuildCore passes it down. That function
was previously left alone on the grounds that a read wrapper always names the
file first -- this is the flow that disproves it.

The emission counter counted write attempts rather than writes, so on a broken
stderr it claimed a diagnostic had reached the operator when nothing had. It is
incremented only after a write that completed, and the broken-stderr test now
asserts the count as well as the return value.

Two documentation defects. The module described a guarantee it does not keep:
one file yields one diagnostic only when the named read comes first. The reverse
ordering emits twice, and that is deliberate -- a path-less caller cannot
identify its file, so suppressing the later named report would also suppress a
genuine second failure in a different file whenever two files share identical
truncated bytes, which ADR-1411 ranks the worse failure. The comment now states
the asymmetric guarantee and a test pins it. Separately, the CONTEXT.md glossary
entry still described backslash normalization that a later commit removed, and
asserted the opposite of what the tests pin; no lint checks prose against code,
so nothing caught it.

Also converts three body-level try/finally blocks to t.after(), per
CONTRIBUTING.md's rule that try/finally belongs only in helpers with no test
context -- the file's own emissionsDuring helper already did this correctly.

Refs #1879

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(#1882): tell the operator what the truncated-frontmatter warning means

A user who has just seen the new warning is acting, not studying, so this lands
in the How-To quadrant beside the other "if you see X" branches in
debug-a-failed-execution, not in reference or explanation. It gives them what
the warning means for this run, three steps to restore the file, and the fact
that the warning changes no return value or exit code.

It also states the case that matters more than the warning itself: silence does
not prove the file is intact. GSD says nothing when the partial block carries
fewer than two fields or reads as prose, because a Markdown document opening
with a horizontal rule is indistinguishable from one of those. A reader chasing
missing metadata needs to know not to treat quiet as clean. Why that threshold
exists is explanation and deliberately stays out of a how-to.

Refs #1879

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#1882): backfill changeset pr number to 2712

* test(#1882): constrain each branch of the frontmatter-shape check

CI's mutation gate came in at 61.56 against a threshold of 62, and the surviving
mutants were concentrated in isFrontmatterShaped -- the function added last, in
response to review, and the only one never given tests of its own. It was
exercised solely through extractFrontmatter, which covers the composite decision
but leaves each branch of the predicate unconstrained: drop the blank-line
filter, or any one of the three shape alternatives, and every existing assertion
still passed.

Four cases now pin the halves independently. A blank line inside an interrupted
block must not disqualify it, which constrains the filter and its comparison. An
unindented list item and an indented folded-scalar continuation each exercise one
shape alternative that no other case reaches on its own -- the folded line is
neither a key nor a list item, so it is the only input that distinguishes the
indented branch. And two keys followed by prose must stay silent, which is the
negative half: it fails if the predicate is ever mutated to accept everything,
and it is the case that proves key count alone was never sufficient.

Refs #1879

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#1882): register the unusable-input suite with the frontmatter mutation shard

The mutation gate reported an identical 61.56 across two runs whose only
difference was four added tests. That is the tell: the tests were never
executed. The frontmatter shard runs a fixed file list in stryker.config.mjs and
scripts/mutation-matrix.cjs, and tests/unusable-input.test.cjs was in neither, so
the entire suite covering the new unterminated-fence branch was invisible to the
gate while passing perfectly well in the normal run.

So the score was not measuring weak tests, it was measuring absent ones: #1882
added mutants to frontmatter.cjs and no test in the shard covered them. Both
lists gain the file; the config already notes they must stay in sync.

This is a registration ripple a new test file carries when it covers a
mutation-tracked module, alongside the .gitignore, eslint, inventory, glossary
and size-baseline ripples a new module carries. Nothing warned about it, which
is why two runs were spent before the identical score gave it away.

Refs #1879

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:50:12 -04:00

2680 lines
99 KiB
TypeScript

/**
* Init — Compound init commands for workflow bootstrapping
*
* ADR-457 build-at-publish: the hand-written bin/lib/init.cjs collapsed to
* a TypeScript source of truth, compiled by tsc to a gitignored .cjs at the
* same require() path. Behaviour preserved byte-for-behaviour; only types are added.
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { execGit, platformWriteSync, platformReadSync, toNativePath, posixNormalize } 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
import configLoader = require('./config-loader.cjs');
import { findProjectRoot } from './project-root.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- model-resolver.cjs is an export= CommonJS module
import modelResolver = require('./model-resolver.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
import phaseLocator = require('./phase-locator.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
import roadmapParser = require('./roadmap-parser.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module
import coreUtils = require('./core-utils.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
import phaseId = require('./phase-id.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- worktree-safety.cjs is an export= CommonJS module
import worktreeSafety = require('./worktree-safety.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
import planningWorkspace = require('./planning-workspace.cjs');
import { maskIfSecret } from './secrets.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
import scanPhasePlans = require('./plan-scan.cjs');
import { stateExtractField } from './state-document.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- commands.cjs is an export= CommonJS module
import commandsMod = require('./commands.cjs');
import { validatePath, loadTrustedGlobalRoots } from './security.cjs';
import { getGlobalSkillDir, getGlobalSkillDisplayPath, getGlobalSkillsBase, getGlobalConfigDir } from './runtime-homes.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
import frontmatterMod = require('./frontmatter.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module
import verificationMod = require('./verification.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- uat-predicate.cjs is an export= CommonJS module
import uatPredicateMod = require('./uat-predicate.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- agent-install-check.cjs is an export= CommonJS module
import agentInstallCheck = require('./agent-install-check.cjs');
const { checkAgentsInstalled } = agentInstallCheck;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- git-base-branch.cjs is an export= CommonJS module
import gitBaseBranch = require('./git-base-branch.cjs');
const { gitWorktreeInfoInternal } = gitBaseBranch;
import { makeResolution } from './resolution.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- onboard-projection.cjs is an export= CommonJS module
import onboardProjection = require('./onboard-projection.cjs');
const {
REQUIRED_CODEBASE_MAP_FILES,
buildOnboardProjection,
hasCodeFilesInternal,
hasPackageFileInternal,
listCodebaseMapFiles,
} = onboardProjection;
const { output, error } = io;
const { loadConfig, loadConfigResolved } = configLoader;
const { resolveModelInternal, resolveGranularityInternal, assertValidGranularityOverride } = modelResolver;
const { findPhaseInternal } = phaseLocator;
const {
getRoadmapPhaseInternal,
getMilestoneInfo,
getMilestonePhaseFilter,
stripShippedMilestones,
extractCurrentMilestone,
} = roadmapParser;
const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils;
const { escapeRegex, normalizePhaseName, phaseTokenMatches, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE, isForeignPrefixedPhaseQuery } = phaseId;
const { pruneOrphanedWorktrees } = worktreeSafety;
const {
planningPaths,
planningDir,
planningRoot,
listAvailableWorkstreams,
getActiveWorkstream,
findContextMdIn,
} = planningWorkspace;
const { determinePhaseStatus } = commandsMod;
const { extractFrontmatter } = frontmatterMod;
const { readVerificationStatus } = verificationMod;
const { evaluateUatPassed } = uatPredicateMod;
// Unused but imported for structural parity
void stripShippedMilestones;
// Accept all bold/colon variants of the Requirements header (#2769)
const REQUIREMENTS_HEADER_RE = /^\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]*)$/m;
// #2056/#2104: isForeignPrefixedPhaseQuery is imported from phase-id.cts
// (the canonical predicate). parsePhasePrefix is no longer needed locally.
// phaseInfoMatchesExactPrefix and roadmapPhaseMatchesExactPrefix are local
// helpers that post-filter the lookup results for foreign-prefix queries.
function phaseInfoMatchesExactPrefix(
phaseInfo: Record<string, unknown> | null,
phase: string,
): boolean {
const num = phaseInfo?.['phase_number'];
const numStr = typeof num === 'string' ? num : (typeof num === 'number' ? String(num) : '');
return numStr.toUpperCase() === phase.toUpperCase();
}
function roadmapPhaseMatchesExactPrefix(
roadmapPhase: Record<string, unknown> | null,
phase: string,
): boolean {
const sectionRaw = roadmapPhase?.['section'];
const section = typeof sectionRaw === 'string' ? sectionRaw : '';
return new RegExp(`^#{2,4}\\s*Phase\\s+${escapeRegex(phase)}(?:\\b|\\s|:)`, 'i').test(section);
}
// #2104: shared helpers that wrap findPhaseInternal / getRoadmapPhaseInternal
// with the #2056 foreign-prefix guard, so every init command gets the same
// protection without duplicating the guard logic at each call site.
function guardedFindPhase(
cwd: string,
phase: string,
projectCode: unknown,
): Record<string, unknown> | null {
let phaseInfo = findPhaseInternal(cwd, phase) as unknown as Record<string, unknown> | null;
if (isForeignPrefixedPhaseQuery(phase, projectCode) && !phaseInfoMatchesExactPrefix(phaseInfo, phase)) {
phaseInfo = null;
}
return phaseInfo;
}
function guardedGetRoadmapPhase(
cwd: string,
phase: string,
projectCode: unknown,
): Record<string, unknown> | null {
let roadmapPhase = getRoadmapPhaseInternal(cwd, phase) as unknown as Record<string, unknown> | null;
if (isForeignPrefixedPhaseQuery(phase, projectCode) && !roadmapPhaseMatchesExactPrefix(roadmapPhase, phase)) {
roadmapPhase = null;
}
return roadmapPhase;
}
function listPhaseSummaryFiles(phaseDir: string): string[] {
return (scanPhasePlans(phaseDir) as unknown as Record<string, string[]>)['summaryFiles'];
}
function listPhasePlanFiles(phaseDir: string): string[] {
return (scanPhasePlans(phaseDir) as unknown as Record<string, string[]>)['planFiles'];
}
interface PhaseCompletionProjection {
implementation_complete: boolean;
verification_status: string;
verification_passed: boolean;
phase_complete: boolean;
completion_status: string;
verification_next_action: string;
verification_next_command: string;
}
function projectCompletionStatus(
implementationComplete: boolean,
verificationPassed: boolean,
): string {
if (implementationComplete && verificationPassed) return 'complete';
if (implementationComplete) return 'executed';
return 'incomplete';
}
function buildPhaseCompletionProjection(
cwd: string,
phaseNumber: string,
phaseDir: string | null,
planCount: number,
summaryCount: number,
slashRuntime: string,
): PhaseCompletionProjection {
const implementationComplete = planCount > 0 && summaryCount >= planCount;
const phaseFullDir = phaseDir ? path.join(cwd, phaseDir) : '';
// #2617: ONE verification-routing seam. init used to re-derive next_command
// from the status with its own projector, which had drifted from the router's
// table — it appended the phase number and answered `human_needed`; the table
// did neither. The router now owns both the content and the runtime
// projection, and init passes the phase number it already knows (its phaseDir
// is unresolved in some branches, where the router could not derive one).
const verificationStatus = implementationComplete
? readVerificationStatus(phaseFullDir, { runtime: slashRuntime, phaseNumber })
: { status: 'not_required', next_action: '', next_command: '' };
const projectedVerificationStatus = verificationStatus.status;
const projectedVerificationAction = verificationStatus.next_action;
const verificationPassed = projectedVerificationStatus === 'passed';
const phaseComplete = implementationComplete && verificationPassed;
return {
implementation_complete: implementationComplete,
verification_status: projectedVerificationStatus,
verification_passed: verificationPassed,
phase_complete: phaseComplete,
completion_status: projectCompletionStatus(implementationComplete, verificationPassed),
verification_next_action: projectedVerificationAction,
verification_next_command: verificationStatus.next_command,
};
}
function getLatestCompletedMilestone(cwd: string): { version: string; name: string } | null {
const milestonesPath = path.join(planningRoot(cwd), 'MILESTONES.md');
const content = platformReadSync(milestonesPath);
if (content === null) return null;
const match = content.match(/^##\s+(v[\d.]+)\s+(.+?)\s+\(Shipped:/m);
if (!match) return null;
return {
version: match[1],
name: match[2].trim(),
};
}
function withProjectRoot(cwd: string, result: Record<string, unknown>): Record<string, unknown> {
result['project_root'] = cwd;
const activeRuntime = resolveRuntime(cwd);
const agentStatus = checkAgentsInstalled(activeRuntime);
result['agents_installed'] = agentStatus.agents_installed;
result['missing_agents'] = agentStatus.missing_agents;
result['agents_dir'] = agentStatus.agents_dir;
result['agent_runtime'] = agentStatus.agent_runtime;
const config = loadConfig(cwd);
if (config.response_language) {
result['response_language'] = config.response_language;
}
if (config.project_code) {
result['project_code'] = config.project_code;
}
const projectMdPath = path.join(planningDir(cwd), 'PROJECT.md');
const content = platformReadSync(projectMdPath);
if (content) {
const h1Match = content.match(/^#\s+(.+)$/m);
if (h1Match) {
result['project_title'] = h1Match[1].trim();
}
}
return result;
}
interface GitState {
has_git: boolean;
git_worktree_root: string | null;
in_nested_subdir: boolean;
}
function getInitGitState(cwd: string): GitState {
const info = gitWorktreeInfoInternal(cwd) as unknown as Record<string, unknown>;
const worktreeRoot = info['worktreeRoot'] as string | null;
const normalizeForCompare = (p: string): string | null => {
if (typeof p !== 'string' || p.length === 0) return null;
let resolved: string;
try {
resolved = fs.realpathSync.native(p);
} catch {
resolved = path.resolve(p);
}
resolved = path.resolve(resolved);
if (process.platform === 'win32') {
return toNativePath(resolved).toLowerCase();
}
return resolved;
};
let inNestedSubdir = false;
if (info['inside']) {
let resolvedByGitPrefix = false;
try {
const prefixResult = execGit(['rev-parse', '--show-prefix'], { cwd, timeout: 5000 }) as unknown as Record<string, unknown>;
if (prefixResult['exitCode'] === 0) {
const prefix = posixNormalize((typeof prefixResult['stdout'] === 'string' ? prefixResult['stdout'] : '').trim());
inNestedSubdir = prefix.length > 0 && prefix !== '.' && prefix !== './';
resolvedByGitPrefix = true;
}
} catch {
/* intentionally empty */
}
if (!resolvedByGitPrefix) {
const rootNorm = normalizeForCompare(worktreeRoot!);
const cwdNorm = normalizeForCompare(cwd);
if (rootNorm && cwdNorm) {
if (rootNorm === cwdNorm) {
inNestedSubdir = false;
} else {
const rel = path.relative(rootNorm, cwdNorm);
const relNorm = toNativePath(rel);
inNestedSubdir =
relNorm !== '' &&
relNorm !== '.' &&
!relNorm.startsWith('..') &&
!path.isAbsolute(relNorm);
}
} else {
inNestedSubdir = worktreeRoot !== null;
}
}
}
if (inNestedSubdir && typeof worktreeRoot === 'string') {
const toComparableRaw = (p: string) => posixNormalize(p).replace(/\/+$/g, '').toLowerCase();
if (toComparableRaw(worktreeRoot) === toComparableRaw(String(cwd))) {
inNestedSubdir = false;
}
}
return {
has_git: info['inside'] as boolean,
git_worktree_root: worktreeRoot,
in_nested_subdir: inNestedSubdir,
};
}
function cmdInitExecutePhase(
cwd: string,
phase: string,
raw: boolean,
options: Record<string, unknown> = {},
): void {
if (!phase) {
error('phase required for init execute-phase');
}
const config = loadConfig(cwd);
let phaseInfo = guardedFindPhase(cwd, phase, config.project_code);
const milestone = getMilestoneInfo(cwd) as unknown as Record<string, unknown>;
const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
if (phaseInfo?.['archived'] && roadmapPhase?.['found']) {
phaseInfo = null;
}
if (!phaseInfo && roadmapPhase?.['found']) {
const phaseName = roadmapPhase['phase_name'] as string | null;
phaseInfo = {
found: true,
directory: null,
phase_number: roadmapPhase['phase_number'],
phase_name: phaseName,
phase_slug: phaseName
? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
: null,
plans: [],
summaries: [],
incomplete_plans: [],
has_research: false,
has_context: false,
has_verification: false,
has_reviews: false,
};
}
const reqMatch = (roadmapPhase?.['section'] as string | undefined)?.match(REQUIREMENTS_HEADER_RE);
const reqExtracted = reqMatch
? reqMatch[1].replace(/[\[\]]/g, '').split(',').map((s) => s.trim()).filter(Boolean).join(', ')
: null;
const phase_req_ids = reqExtracted && reqExtracted !== 'TBD' ? reqExtracted : null;
const wf = (config.workflow ?? {}) as Record<string, unknown>;
const result: Record<string, unknown> = {
executor_model: resolveModelInternal(cwd, 'gsd-executor'),
verifier_model: resolveModelInternal(cwd, 'gsd-verifier'),
tdd_mode: options['tdd'] || Boolean(wf['tdd_mode']) || false,
commit_docs: config.commit_docs,
sub_repos: config.sub_repos,
parallelization: config.parallelization,
context_window: config.context_window,
branching_strategy: config.branching_strategy,
phase_branch_template: config.phase_branch_template,
milestone_branch_template: config.milestone_branch_template,
verifier_enabled: config.verifier,
phase_found: !!phaseInfo,
// #2376: absolute (anchored on cwd/project_root), not orchestrator-cwd-relative —
// a spawned subagent's own cwd may differ from the orchestrator's.
phase_dir: phaseInfo?.['directory']
? toPosixPath(path.join(cwd, phaseInfo['directory'] as string))
: null,
phase_number: phaseInfo?.['phase_number'] || null,
phase_name: phaseInfo?.['phase_name'] || null,
phase_slug: phaseInfo?.['phase_slug'] || null,
phase_req_ids,
plans: phaseInfo?.['plans'] || [],
summaries: phaseInfo?.['summaries'] || [],
incomplete_plans: phaseInfo?.['incomplete_plans'] || [],
plan_count: (phaseInfo?.['plans'] as unknown[] | undefined)?.length || 0,
incomplete_count: (phaseInfo?.['incomplete_plans'] as unknown[] | undefined)?.length || 0,
branch_name:
config.branching_strategy === 'phase' && phaseInfo
? (config.phase_branch_template as string)
.replace('{project}', (config.project_code as string) || '')
.replace('{phase}', normalizePhaseName(phaseInfo['phase_number']))
.replace('{slug}', (phaseInfo['phase_slug'] as string) || 'phase')
: config.branching_strategy === 'milestone'
? (config.milestone_branch_template as string)
.replace('{milestone}', milestone['version'] as string)
.replace(
'{slug}',
generateSlugInternal(milestone['name'] as string) || 'milestone',
)
: null,
milestone_version: milestone['version'],
milestone_name: milestone['name'],
milestone_slug: generateSlugInternal(milestone['name'] as string),
state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')),
roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
config_exists: fs.existsSync(path.join(planningDir(cwd), 'config.json')),
// #2376: emit absolute paths — see comment above on phase_dir.
state_path: toPosixPath(path.join(planningDir(cwd), 'STATE.md')),
roadmap_path: toPosixPath(path.join(planningDir(cwd), 'ROADMAP.md')),
config_path: toPosixPath(path.join(planningDir(cwd), 'config.json')),
// #2376: execute-phase.md's verify_phase_goal step reads this instead of
// hardcoding '.planning/REQUIREMENTS.md' into the gsd-verifier spawn prompt.
requirements_path: toPosixPath(path.join(planningDir(cwd), 'REQUIREMENTS.md')),
};
if (options['validate']) {
try {
const statePath = path.join(planningDir(cwd), 'STATE.md');
const stateContent = platformReadSync(statePath);
if (stateContent !== null) {
result['state_validation_ran'] = true;
const stateWarnings: string[] = [];
if (phaseInfo?.['directory'] && fs.existsSync(path.join(cwd, phaseInfo['directory'] as string))) {
const diskPlans = listPhasePlanFiles(path.join(cwd, phaseInfo['directory'] as string)).length;
const totalPlansRaw = stateExtractField(stateContent, 'Total Plans in Phase');
const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
if (totalPlansInPhase !== null && diskPlans !== totalPlansInPhase) {
stateWarnings.push(
`Plan count mismatch: STATE.md says ${totalPlansInPhase}, disk has ${diskPlans}`,
);
}
}
result['state_warnings'] = stateWarnings;
}
} catch {
/* intentionally empty */
}
}
output(withProjectRoot(cwd, result), raw);
}
function cmdInitPlanPhase(
cwd: string,
phase: string,
raw: boolean,
options: Record<string, unknown> = {},
): void {
if (!phase) {
error('phase required for init plan-phase');
}
const config = loadConfig(cwd);
// #2056/#2104: foreign-prefixed queries must not collapse to numeric phases.
let phaseInfo = guardedFindPhase(cwd, phase, config.project_code);
const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
if (phaseInfo?.['archived'] && roadmapPhase?.['found']) {
phaseInfo = null;
}
if (!phaseInfo && roadmapPhase?.['found']) {
const phaseName = roadmapPhase['phase_name'] as string | null;
phaseInfo = {
found: true,
directory: null,
phase_number: roadmapPhase['phase_number'],
phase_name: phaseName,
phase_slug: phaseName
? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
: null,
plans: [],
summaries: [],
incomplete_plans: [],
has_research: false,
has_context: false,
has_verification: false,
has_reviews: false,
};
}
const reqMatch = (roadmapPhase?.['section'] as string | undefined)?.match(REQUIREMENTS_HEADER_RE);
const reqExtracted = reqMatch
? reqMatch[1].replace(/[\[\]]/g, '').split(',').map((s) => s.trim()).filter(Boolean).join(', ')
: null;
const phase_req_ids = reqExtracted && reqExtracted !== 'TBD' ? reqExtracted : null;
const phaseDirPlan = (phaseInfo?.['directory'] as string | undefined) || null;
const phaseNumberPlan = (phaseInfo?.['phase_number'] as string | undefined) || null;
const phaseNamePlan = (phaseInfo?.['phase_name'] as string | undefined) || null;
const rawProjectCodePlan = (config.project_code as string) || '';
let expectedPhaseDirPlan: string | null = null;
if (!phaseDirPlan && phaseNumberPlan && phaseNamePlan) {
const paddedNum = normalizePhaseName(phaseNumberPlan);
const slug = (generateSlugInternal(phaseNamePlan) || '').substring(0, 60);
if (slug) {
const prefix = rawProjectCodePlan ? `${rawProjectCodePlan}-` : '';
const dirName = `${prefix}${paddedNum}-${slug}`;
// #2376: absolute — see comment on phase_dir below.
expectedPhaseDirPlan = toPosixPath(path.join(planningPaths(cwd).phases, dirName));
}
}
const granularityOverride = options['granularity'] as string | undefined;
assertValidGranularityOverride(granularityOverride, error);
const granularity = resolveGranularityInternal(cwd, 'planning', granularityOverride || undefined);
const wf = (config.workflow ?? {}) as Record<string, unknown>;
const result: Record<string, unknown> = {
researcher_model: resolveModelInternal(cwd, 'gsd-phase-researcher'),
planner_model: resolveModelInternal(cwd, 'gsd-planner'),
checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'),
tdd_mode: options['tdd'] || Boolean(wf['tdd_mode']) || false,
granularity,
research_enabled: wf['research'],
plan_checker_enabled: config.plan_checker,
nyquist_validation_enabled: wf['nyquist_validation'],
commit_docs: config.commit_docs,
text_mode: config.text_mode,
auto_advance: !!(config.auto_advance),
auto_chain_active: !!(config._auto_chain_active),
mode: config.mode || 'interactive',
phase_found: !!phaseInfo,
// #2376: absolute (anchored on cwd/project_root) — path.join(cwd, phaseDirPlan)
// handed to a spawned subagent must resolve regardless of that subagent's own cwd.
// phaseDirPlan itself stays relative — phase_status below still joins it against cwd.
phase_dir: phaseDirPlan ? toPosixPath(path.join(cwd, phaseDirPlan)) : null,
expected_phase_dir: expectedPhaseDirPlan,
phase_number: phaseNumberPlan,
phase_name: phaseNamePlan,
phase_slug: phaseInfo?.['phase_slug'] || null,
padded_phase: phaseNumberPlan ? normalizePhaseName(phaseNumberPlan) : null,
phase_req_ids,
phase_status: phaseDirPlan
? determinePhaseStatus(
(phaseInfo?.['plans'] as unknown[] | undefined)?.length || 0,
(phaseInfo?.['summaries'] as unknown[] | undefined)?.length || 0,
path.join(cwd, phaseDirPlan),
'Pending',
)
: 'Pending',
has_research: phaseInfo?.['has_research'] || false,
has_context: phaseInfo?.['has_context'] || false,
has_reviews: phaseInfo?.['has_reviews'] || false,
has_plans: ((phaseInfo?.['plans'] as unknown[] | undefined)?.length || 0) > 0,
plan_count: (phaseInfo?.['plans'] as unknown[] | undefined)?.length || 0,
planning_exists: fs.existsSync(planningDir(cwd)),
roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
// #2376: absolute — see comment on phase_dir above.
state_path: toPosixPath(path.join(planningDir(cwd), 'STATE.md')),
roadmap_path: toPosixPath(path.join(planningDir(cwd), 'ROADMAP.md')),
requirements_path: toPosixPath(path.join(planningDir(cwd), 'REQUIREMENTS.md')),
patterns_path: null,
};
if (phaseInfo?.['directory']) {
const phaseDirFull = path.join(cwd, phaseInfo['directory'] as string);
try {
const files = fs.readdirSync(phaseDirFull);
const contextFile = findContextMdIn(phaseDirFull);
if (contextFile) {
result['context_path'] = toPosixPath(path.join(phaseDirFull, contextFile));
}
const researchFile = files.find(
(f) => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md',
);
if (researchFile) {
result['research_path'] = toPosixPath(path.join(phaseDirFull, researchFile));
}
const verificationFile = files.find(
(f) => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md',
);
if (verificationFile) {
result['verification_path'] = toPosixPath(path.join(phaseDirFull, verificationFile));
}
const uatFile = files.find((f) => f.endsWith('-UAT.md') || f === 'UAT.md');
if (uatFile) {
result['uat_path'] = toPosixPath(path.join(phaseDirFull, uatFile));
}
const reviewsFile = files.find(
(f) => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md',
);
if (reviewsFile) {
result['reviews_path'] = toPosixPath(path.join(phaseDirFull, reviewsFile));
}
const patternsFile = files.find(
(f) => f.endsWith('-PATTERNS.md') || f === 'PATTERNS.md',
);
if (patternsFile) {
result['patterns_path'] = toPosixPath(path.join(phaseDirFull, patternsFile));
}
} catch {
/* intentionally empty */
}
}
if (options['validate']) {
try {
const statePath = path.join(planningDir(cwd), 'STATE.md');
const stateContent = platformReadSync(statePath);
if (stateContent !== null) {
const stateWarnings: string[] = [];
result['state_validation_ran'] = true;
const totalPlansRaw = stateExtractField(stateContent, 'Total Plans in Phase');
const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
if (
totalPlansInPhase !== null &&
phaseInfo &&
totalPlansInPhase !==
((phaseInfo['plans'] as unknown[] | undefined)?.length || 0)
) {
stateWarnings.push(
`Plan count mismatch: STATE.md says ${totalPlansInPhase}, disk has ${(phaseInfo['plans'] as unknown[] | undefined)?.length || 0}`,
);
}
result['state_warnings'] = stateWarnings;
}
} catch {
/* intentionally empty */
}
}
output(withProjectRoot(cwd, result), raw);
}
function cmdInitNewProject(cwd: string, raw: boolean): void {
const config = loadConfig(cwd);
const homedir = os.homedir();
const braveKeyFile = path.join(homedir, '.gsd', 'brave_api_key');
const hasBraveSearch = !!(process.env['BRAVE_API_KEY'] || fs.existsSync(braveKeyFile));
const firecrawlKeyFile = path.join(homedir, '.gsd', 'firecrawl_api_key');
const hasFirecrawl = !!(process.env['FIRECRAWL_API_KEY'] || fs.existsSync(firecrawlKeyFile));
const exaKeyFile = path.join(homedir, '.gsd', 'exa_api_key');
const hasExaSearch = !!(process.env['EXA_API_KEY'] || fs.existsSync(exaKeyFile));
const hasCode = hasCodeFilesInternal(cwd);
const hasPackageFile = hasPackageFileInternal(cwd);
const isBrownfield = hasCode || hasPackageFile;
const codebaseMapFiles = listCodebaseMapFiles(cwd);
const hasCodebaseMap = codebaseMapFiles.length === REQUIRED_CODEBASE_MAP_FILES.length;
const result: Record<string, unknown> = {
researcher_model: resolveModelInternal(cwd, 'gsd-project-researcher'),
synthesizer_model: resolveModelInternal(cwd, 'gsd-research-synthesizer'),
roadmapper_model: resolveModelInternal(cwd, 'gsd-roadmapper'),
commit_docs: config.commit_docs,
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
has_codebase_map: hasCodebaseMap,
planning_exists: pathExistsInternal(cwd, '.planning'),
has_existing_code: hasCode,
has_package_file: hasPackageFile,
is_brownfield: isBrownfield,
needs_codebase_map: isBrownfield && !hasCodebaseMap,
...getInitGitState(cwd),
brave_search_available: hasBraveSearch,
firecrawl_available: hasFirecrawl,
exa_search_available: hasExaSearch,
// #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.
project_path: toPosixPath(path.join(planningDir(cwd), 'PROJECT.md')),
// #2376: new-project.md's research-synthesizer/roadmapper spawn prompts
// read these instead of hardcoding '.planning/...' literals.
requirements_path: toPosixPath(path.join(planningDir(cwd), 'REQUIREMENTS.md')),
roadmap_path: toPosixPath(path.join(planningDir(cwd), 'ROADMAP.md')),
config_path: toPosixPath(path.join(planningDir(cwd), 'config.json')),
research_dir: toPosixPath(path.join(planningRoot(cwd), 'research')),
};
output(withProjectRoot(cwd, result), raw);
}
function cmdInitNewMilestone(cwd: string, raw: boolean): void {
const config = loadConfig(cwd);
const milestone = getMilestoneInfo(cwd) as unknown as Record<string, unknown>;
const latestCompleted = getLatestCompletedMilestone(cwd);
const phasesDir = path.join(planningDir(cwd), 'phases');
let phaseDirCount = 0;
try {
if (fs.existsSync(phasesDir)) {
const isDirInMilestone = getMilestonePhaseFilter(cwd);
phaseDirCount = fs
.readdirSync(phasesDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && isDirInMilestone(entry.name))
.length;
}
} catch {
/* intentionally empty */
}
const wf = (config.workflow ?? {}) as Record<string, unknown>;
const result: Record<string, unknown> = {
researcher_model: resolveModelInternal(cwd, 'gsd-project-researcher'),
synthesizer_model: resolveModelInternal(cwd, 'gsd-research-synthesizer'),
roadmapper_model: resolveModelInternal(cwd, 'gsd-roadmapper'),
commit_docs: config.commit_docs,
research_enabled: wf['research'],
current_milestone: milestone['version'],
current_milestone_name: milestone['name'],
latest_completed_milestone: latestCompleted?.version || null,
latest_completed_milestone_name: latestCompleted?.name || null,
phase_dir_count: phaseDirCount,
// #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.
phase_archive_path: latestCompleted
? toPosixPath(
path.join(planningRoot(cwd), 'milestones', `${latestCompleted.version}-phases`),
)
: null,
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')),
project_path: toPosixPath(path.join(planningDir(cwd), 'PROJECT.md')),
roadmap_path: toPosixPath(path.join(planningDir(cwd), 'ROADMAP.md')),
state_path: toPosixPath(path.join(planningDir(cwd), 'STATE.md')),
// #2376: new-milestone.md's research-synthesizer/roadmapper spawn prompts
// read these instead of hardcoding '.planning/...' literals.
requirements_path: toPosixPath(path.join(planningDir(cwd), 'REQUIREMENTS.md')),
config_path: toPosixPath(path.join(planningDir(cwd), 'config.json')),
research_dir: toPosixPath(path.join(planningRoot(cwd), 'research')),
milestones_path: toPosixPath(path.join(planningDir(cwd), 'MILESTONES.md')),
};
output(withProjectRoot(cwd, result), raw);
}
function cmdInitQuick(cwd: string, description: string | undefined, raw: boolean): void {
const config = loadConfig(cwd);
const now = new Date();
const slug = description ? generateSlugInternal(description)?.substring(0, 40) : null;
const yy = String(now.getFullYear()).slice(-2);
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
const dateStr = yy + mm + dd;
const secondsSinceMidnight =
now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds();
const timeBlocks = Math.floor(secondsSinceMidnight / 2);
const timeEncoded = timeBlocks.toString(36).padStart(3, '0');
const quickId = dateStr + '-' + timeEncoded;
const branchSlug = slug || 'quick';
const quickBranchName = config.quick_branch_template
? (config.quick_branch_template as string)
.replace('{num}', quickId)
.replace('{quick}', quickId)
.replace('{slug}', branchSlug)
: null;
const result: Record<string, unknown> = {
planner_model: resolveModelInternal(cwd, 'gsd-planner'),
executor_model: resolveModelInternal(cwd, 'gsd-executor'),
checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'),
verifier_model: resolveModelInternal(cwd, 'gsd-verifier'),
// #2072: the quick review step spawns gsd-code-reviewer; resolve its own model
// so model_overrides / models.verification apply (was reusing executor_model).
reviewer_model: resolveModelInternal(cwd, 'gsd-code-reviewer'),
commit_docs: config.commit_docs,
branch_name: quickBranchName,
quick_id: quickId,
slug: slug,
description: description || null,
date: realClock.localToday(),
timestamp: realClock.nowIso(),
// #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.
quick_dir: toPosixPath(path.join(planningDir(cwd), 'quick')),
task_dir: slug
? toPosixPath(path.join(planningDir(cwd), 'quick', `${quickId}-${slug}`))
: null,
roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
planning_exists: fs.existsSync(planningRoot(cwd)),
};
output(withProjectRoot(cwd, result), raw);
}
function cmdInitIngestDocs(cwd: string, raw: boolean): void {
const config = loadConfig(cwd);
const result: Record<string, unknown> = {
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
planning_exists: fs.existsSync(planningRoot(cwd)),
...getInitGitState(cwd),
// #2376: absolute — see comment on phase_dir in cmdInitExecutePhase. The
// classify_parallel/synthesize/route_new_mode spawns in ingest-docs.md
// (gsd-doc-classifier, gsd-doc-synthesizer, gsd-roadmapper) previously
// hardcoded bare '.planning/intel/...', '.planning/PROJECT.md', etc.
// literals into their Agent(prompt=...) blocks; those now interpolate
// these fields instead.
project_path: toPosixPath(path.join(planningDir(cwd), 'PROJECT.md')),
requirements_path: toPosixPath(path.join(planningDir(cwd), 'REQUIREMENTS.md')),
roadmap_path: toPosixPath(path.join(planningDir(cwd), 'ROADMAP.md')),
state_path: toPosixPath(path.join(planningDir(cwd), 'STATE.md')),
intel_dir: toPosixPath(path.join(planningDir(cwd), 'intel')),
conflicts_path: toPosixPath(path.join(planningDir(cwd), 'INGEST-CONFLICTS.md')),
commit_docs: config.commit_docs,
};
output(withProjectRoot(cwd, result), raw);
}
function cmdInitOnboard(
cwd: string,
raw: boolean,
options: Record<string, unknown> = {},
): void {
const config = loadConfig(cwd);
const workflowConfig = (config.workflow ?? {}) as Record<string, unknown>;
const result = {
...buildOnboardProjection(cwd, {
commitDocs: !!config.commit_docs,
fast: options['fast'] === true,
textMode: options['text'] === true || !!config.text_mode || !!workflowConfig['text_mode'],
}),
...getInitGitState(cwd),
};
output(withProjectRoot(cwd, result), raw);
}
function cmdInitResume(cwd: string, raw: boolean): void {
const config = loadConfig(cwd);
let interruptedAgentId: string | null = null;
const agentIdRaw = platformReadSync(
path.join(planningRoot(cwd), 'current-agent-id.txt'),
);
if (agentIdRaw !== null) interruptedAgentId = agentIdRaw.trim();
const result: Record<string, unknown> = {
state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')),
roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
planning_exists: fs.existsSync(planningRoot(cwd)),
// #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.
state_path: toPosixPath(path.join(planningDir(cwd), 'STATE.md')),
roadmap_path: toPosixPath(path.join(planningDir(cwd), 'ROADMAP.md')),
project_path: toPosixPath(path.join(planningDir(cwd), 'PROJECT.md')),
has_interrupted_agent: !!interruptedAgentId,
interrupted_agent_id: interruptedAgentId,
commit_docs: config.commit_docs,
};
output(withProjectRoot(cwd, result), raw);
}
function cmdInitVerifyWork(cwd: string, phase: string, raw: boolean): void {
if (!phase) {
error('phase required for init verify-work');
}
const config = loadConfig(cwd);
const _slashRuntime = resolveRuntime(cwd);
let phaseInfo = guardedFindPhase(cwd, phase, config.project_code);
if (phaseInfo?.['archived']) {
const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
if (roadmapPhase?.['found']) {
phaseInfo = null;
}
}
if (!phaseInfo) {
const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
if (roadmapPhase?.['found']) {
const phaseName = roadmapPhase['phase_name'] as string | null;
phaseInfo = {
found: true,
directory: null,
phase_number: roadmapPhase['phase_number'],
phase_name: phaseName,
phase_slug: phaseName
? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
: null,
plans: [],
summaries: [],
incomplete_plans: [],
has_research: false,
has_context: false,
has_verification: false,
};
}
}
const phaseDir = (phaseInfo?.['directory'] as string | null | undefined) || null;
const planCount = (phaseInfo?.['plans'] as unknown[] | undefined)?.length || 0;
const summaryCount = (phaseInfo?.['summaries'] as unknown[] | undefined)?.length || 0;
const completion = buildPhaseCompletionProjection(
cwd,
(phaseInfo?.['phase_number'] as string | undefined) || phase,
phaseDir,
planCount,
summaryCount,
_slashRuntime,
);
const uatReport = phaseDir
? evaluateUatPassed(path.join(cwd, phaseDir), {
policy: { requireVerification: true },
})
: null;
const result: Record<string, unknown> = {
planner_model: resolveModelInternal(cwd, 'gsd-planner'),
checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'),
commit_docs: config.commit_docs,
phase_found: !!phaseInfo,
// #2376: absolute — see comment on phase_dir in cmdInitExecutePhase. phaseDir
// itself stays relative — evaluateUatPassed above still joins it against cwd.
phase_dir: phaseDir ? toPosixPath(path.join(cwd, phaseDir)) : null,
phase_number: phaseInfo?.['phase_number'] || null,
phase_name: phaseInfo?.['phase_name'] || null,
// #2376: verify-work.md's plan_gap_closure step reads these instead of
// hardcoding '.planning/STATE.md' / '.planning/ROADMAP.md' literals.
state_path: toPosixPath(path.join(planningDir(cwd), 'STATE.md')),
roadmap_path: toPosixPath(path.join(planningDir(cwd), 'ROADMAP.md')),
has_verification: phaseInfo?.['has_verification'] || false,
phase_completion: {
...completion,
uat_passed: uatReport?.passed ?? false,
uat_blockers: uatReport?.blockers ?? [],
ready_to_transition: completion.phase_complete && (uatReport?.passed ?? false),
},
};
output(withProjectRoot(cwd, result), raw);
}
function cmdInitPhaseOp(cwd: string, phase: string, raw: boolean): void {
const config = loadConfig(cwd);
let phaseInfo = guardedFindPhase(cwd, phase, config.project_code);
// #2237: surface ambiguous phase-directory collisions instead of silently
// taking the first match when unrelated projects share a .planning/phases/ tree.
if (phaseInfo?.['ambiguous_matches']) {
const matches = phaseInfo['ambiguous_matches'] as string[];
const result: Record<string, unknown> = {
phase_found: false,
phase_dir: null,
phase_number: null,
phase_name: null,
ambiguous_matches: matches,
warning: `Phase ${phase} is ambiguous: ${matches.length} directories match (${matches.map((m: string) => `"${m}"`).join(', ')}). Set a distinct project_code in .planning/config.json to scope resolution.`,
};
output(withProjectRoot(cwd, result), raw);
return;
}
if (phaseInfo?.['archived']) {
const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
if (roadmapPhase?.['found']) {
const phaseName = roadmapPhase['phase_name'] as string | null;
phaseInfo = {
found: true,
directory: null,
phase_number: roadmapPhase['phase_number'],
phase_name: phaseName,
phase_slug: phaseName
? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
: null,
plans: [],
summaries: [],
incomplete_plans: [],
has_research: false,
has_context: false,
has_verification: false,
};
}
}
if (!phaseInfo) {
const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
if (roadmapPhase?.['found']) {
const phaseName = roadmapPhase['phase_name'] as string | null;
phaseInfo = {
found: true,
directory: null,
phase_number: roadmapPhase['phase_number'],
phase_name: phaseName,
phase_slug: phaseName
? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
: null,
plans: [],
summaries: [],
incomplete_plans: [],
has_research: false,
has_context: false,
has_verification: false,
};
}
}
const phaseDir = (phaseInfo?.['directory'] as string | undefined) || null;
const phaseNumber = (phaseInfo?.['phase_number'] as string | undefined) || null;
const phaseName = (phaseInfo?.['phase_name'] as string | undefined) || null;
const rawProjectCode = (config.project_code as string) || '';
let expectedPhaseDir: string | null = null;
if (!phaseDir && phaseNumber && phaseName) {
const paddedNum = normalizePhaseName(phaseNumber);
const slug = (generateSlugInternal(phaseName) || '').substring(0, 60);
if (slug) {
const prefix = rawProjectCode ? `${rawProjectCode}-` : '';
const dirName = `${prefix}${paddedNum}-${slug}`;
// #2376: absolute — see comment on phase_dir below.
expectedPhaseDir = toPosixPath(path.join(planningPaths(cwd).phases, dirName));
}
}
const result: Record<string, unknown> = {
commit_docs: config.commit_docs,
brave_search:
typeof config.brave_search === 'string'
? maskIfSecret('brave_search', config.brave_search)
: config.brave_search,
firecrawl:
typeof config.firecrawl === 'string'
? maskIfSecret('firecrawl', config.firecrawl)
: config.firecrawl,
exa_search:
typeof config.exa_search === 'string'
? maskIfSecret('exa_search', config.exa_search)
: config.exa_search,
phase_found: !!phaseInfo,
// #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.
phase_dir: phaseDir ? toPosixPath(path.join(cwd, phaseDir)) : null,
expected_phase_dir: expectedPhaseDir,
phase_number: phaseNumber,
phase_name: phaseName,
phase_slug: phaseInfo?.['phase_slug'] || null,
padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null,
has_research: phaseInfo?.['has_research'] || false,
has_context: phaseInfo?.['has_context'] || false,
has_plans: ((phaseInfo?.['plans'] as unknown[] | undefined)?.length || 0) > 0,
has_verification: phaseInfo?.['has_verification'] || false,
has_reviews: phaseInfo?.['has_reviews'] || false,
plan_count: (phaseInfo?.['plans'] as unknown[] | undefined)?.length || 0,
roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
planning_exists: fs.existsSync(planningDir(cwd)),
// #2376: absolute — see comment on phase_dir above.
state_path: toPosixPath(path.join(planningDir(cwd), 'STATE.md')),
roadmap_path: toPosixPath(path.join(planningDir(cwd), 'ROADMAP.md')),
requirements_path: toPosixPath(path.join(planningDir(cwd), 'REQUIREMENTS.md')),
};
if (phaseInfo?.['directory']) {
const phaseDirFull = path.join(cwd, phaseInfo['directory'] as string);
try {
const files = fs.readdirSync(phaseDirFull);
const contextFile = findContextMdIn(phaseDirFull);
if (contextFile) {
result['context_path'] = toPosixPath(path.join(phaseDirFull, contextFile));
}
const researchFile = files.find(
(f) => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md',
);
if (researchFile) {
result['research_path'] = toPosixPath(path.join(phaseDirFull, researchFile));
}
const verificationFile = files.find(
(f) => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md',
);
if (verificationFile) {
result['verification_path'] = toPosixPath(path.join(phaseDirFull, verificationFile));
}
const uatFile = files.find((f) => f.endsWith('-UAT.md') || f === 'UAT.md');
if (uatFile) {
result['uat_path'] = toPosixPath(path.join(phaseDirFull, uatFile));
}
const reviewsFile = files.find(
(f) => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md',
);
if (reviewsFile) {
result['reviews_path'] = toPosixPath(path.join(phaseDirFull, reviewsFile));
}
} catch {
/* intentionally empty */
}
}
output(withProjectRoot(cwd, result), raw);
}
function cmdInitTodos(cwd: string, area: string | undefined, raw: boolean): void {
const config = loadConfig(cwd);
const pendingDir = path.join(planningDir(cwd), 'todos', 'pending');
let count = 0;
const todos: Record<string, unknown>[] = [];
try {
const files = fs.readdirSync(pendingDir).filter((f) => f.endsWith('.md'));
for (const file of files) {
const content = platformReadSync(path.join(pendingDir, file));
if (content === null) continue;
try {
const createdMatch = content.match(/^created:\s*(.+)$/m);
const titleMatch = content.match(/^title:\s*(.+)$/m);
const areaMatch = content.match(/^area:\s*(.+)$/m);
// #2337: kept in parity with cmdListTodos — surface severity when
// present, omit the key entirely for todos with no severity line.
const severityMatch = content.match(/^severity:\s*(.+)$/m);
const todoArea = areaMatch ? areaMatch[1].trim() : 'general';
if (area && todoArea !== area) continue;
count++;
todos.push({
file,
created: createdMatch ? createdMatch[1].trim() : 'unknown',
title: titleMatch ? titleMatch[1].trim() : 'Untitled',
area: todoArea,
// #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.
path: toPosixPath(path.join(planningDir(cwd), 'todos', 'pending', file)),
...(severityMatch ? { severity: severityMatch[1].trim() } : {}),
});
} catch {
/* intentionally empty */
}
}
} catch {
/* intentionally empty */
}
const result: Record<string, unknown> = {
commit_docs: config.commit_docs,
date: realClock.localToday(),
timestamp: realClock.nowIso(),
todo_count: count,
todos,
area_filter: area || null,
// #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.
pending_dir: toPosixPath(path.join(planningDir(cwd), 'todos', 'pending')),
completed_dir: toPosixPath(path.join(planningDir(cwd), 'todos', 'completed')),
planning_exists: fs.existsSync(planningDir(cwd)),
todos_dir_exists: fs.existsSync(path.join(planningDir(cwd), 'todos')),
pending_dir_exists: fs.existsSync(path.join(planningDir(cwd), 'todos', 'pending')),
};
output(withProjectRoot(cwd, result), raw);
}
function cmdInitMilestoneOp(cwd: string, raw: boolean): void {
const config = loadConfig(cwd);
const milestone = getMilestoneInfo(cwd) as unknown as Record<string, unknown>;
let phaseCount = 0;
let completedPhases = 0;
const phasesDir = path.join(planningDir(cwd), 'phases');
const roadmapPhaseNumbers: string[] = [];
try {
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8');
const currentSection = extractCurrentMilestone(roadmapRaw, cwd);
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'gi');
let m: RegExpExecArray | null;
while ((m = phasePattern.exec(currentSection)) !== null) {
if (/^999(?:\.|$)/.test(m[1])) continue;
roadmapPhaseNumbers.push(m[1]);
}
} catch {
/* intentionally empty */
}
const canonicalizePhase = (tok: string): string => {
const m = tok.match(/^(\d+)([A-Z]?(?:\.\d+)*)$/);
return m ? String(parseInt(m[1], 10)) + m[2] : tok;
};
const diskPhaseDirs = new Map<string, string>();
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
for (const e of entries) {
if (!e.isDirectory()) continue;
const m = stripProjectCodePrefix(e.name).match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`));
if (!m) continue;
diskPhaseDirs.set(canonicalizePhase(m[1]), e.name);
}
} catch {
/* intentionally empty */
}
if (roadmapPhaseNumbers.length > 0) {
phaseCount = roadmapPhaseNumbers.length;
for (const num of roadmapPhaseNumbers) {
const dirName = diskPhaseDirs.get(canonicalizePhase(num));
if (!dirName) continue;
try {
const hasSummary = listPhaseSummaryFiles(path.join(phasesDir, dirName)).length > 0;
if (hasSummary) completedPhases++;
} catch {
/* intentionally empty */
}
}
} else {
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
phaseCount = dirs.length;
for (const dir of dirs) {
try {
const hasSummary = listPhaseSummaryFiles(path.join(phasesDir, dir)).length > 0;
if (hasSummary) completedPhases++;
} catch {
/* intentionally empty */
}
}
} catch {
/* intentionally empty */
}
}
const archiveDir = path.join(planningRoot(cwd), 'archive');
let archivedMilestones: string[] = [];
try {
archivedMilestones = fs
.readdirSync(archiveDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name);
} catch {
/* intentionally empty */
}
const result: Record<string, unknown> = {
commit_docs: config.commit_docs,
milestone_version: milestone['version'],
milestone_name: milestone['name'],
milestone_slug: generateSlugInternal(milestone['name'] as string),
phase_count: phaseCount,
completed_phases: completedPhases,
all_phases_complete: phaseCount > 0 && phaseCount === completedPhases,
archived_milestones: archivedMilestones,
archive_count: archivedMilestones.length,
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')),
archive_exists: fs.existsSync(path.join(planningRoot(cwd), 'archive')),
phases_dir_exists: fs.existsSync(path.join(planningDir(cwd), 'phases')),
};
output(withProjectRoot(cwd, result), raw);
}
function cmdInitMapCodebase(cwd: string, raw: boolean): void {
const config = loadConfig(cwd);
const codebaseDir = path.join(planningRoot(cwd), 'codebase');
let existingMaps: string[] = [];
try {
existingMaps = fs.readdirSync(codebaseDir).filter((f) => f.endsWith('.md'));
} catch {
/* intentionally empty */
}
const result: Record<string, unknown> = {
mapper_model: resolveModelInternal(cwd, 'gsd-codebase-mapper'),
commit_docs: config.commit_docs,
search_gitignored: config.search_gitignored,
parallelization: config.parallelization,
subagent_timeout: config.subagent_timeout,
date: realClock.localToday(),
timestamp: realClock.nowIso(),
// #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.
codebase_dir: toPosixPath(path.join(planningRoot(cwd), 'codebase')),
existing_maps: existingMaps,
has_maps: existingMaps.length > 0,
planning_exists: pathExistsInternal(cwd, '.planning'),
codebase_dir_exists: pathExistsInternal(cwd, '.planning/codebase'),
};
output(withProjectRoot(cwd, result), raw);
}
function cmdInitManager(cwd: string, raw: boolean): void {
const config = loadConfig(cwd);
const milestone = getMilestoneInfo(cwd) as unknown as Record<string, unknown>;
const _slashRuntime = resolveRuntime(cwd);
const paths = planningPaths(cwd);
if (!fs.existsSync(paths.roadmap)) {
error(`No ROADMAP.md found. Run ${formatGsdSlash('new-milestone', _slashRuntime) as string} first.`);
}
if (!fs.existsSync(paths.state)) {
error(`No STATE.md found. Run ${formatGsdSlash('new-milestone', _slashRuntime) as string} first.`);
}
const rawContent = fs.readFileSync(paths.roadmap, 'utf-8');
const content = extractCurrentMilestone(rawContent, cwd);
const phasesDir = paths.phases;
const isDirInMilestone = getMilestonePhaseFilter(cwd);
const _phaseDirEntries = (() => {
try {
return fs
.readdirSync(phasesDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name);
} catch {
return [];
}
})();
const _checkboxStates = new Map<string, boolean>();
const _cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi');
let _cbMatch: RegExpExecArray | null;
while ((_cbMatch = _cbPattern.exec(content)) !== null) {
_checkboxStates.set(_cbMatch[2], _cbMatch[1].toLowerCase() === 'x');
}
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
const phases: Record<string, unknown>[] = [];
let match: RegExpExecArray | null;
while ((match = phasePattern.exec(content)) !== null) {
const phaseNum = match[1];
const phaseName = match[2].replace(/\(INSERTED\)/i, '').trim();
const sectionStart = match.index;
const restOfContent = content.slice(sectionStart);
const nextHeader = restOfContent.match(/\n#{2,4}\s+Phase\s+\d[\d.]*/i);
const sectionEnd = nextHeader
? sectionStart + (nextHeader.index as number)
: content.length;
const section = content.slice(sectionStart, sectionEnd);
const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
const goal = goalMatch ? goalMatch[1].trim() : null;
const dependsMatch = section.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i);
const depends_on = dependsMatch ? dependsMatch[1].trim() : null;
const normalized = normalizePhaseName(phaseNum);
let diskStatus = 'no_directory';
let planCount = 0;
let summaryCount = 0;
let hasContext = false;
let hasResearch = false;
let lastActivity: string | null = null;
let isActive = false;
let completion = buildPhaseCompletionProjection(
cwd,
phaseNum,
null,
planCount,
summaryCount,
_slashRuntime,
);
try {
const dirs = _phaseDirEntries.filter(isDirInMilestone);
const dirMatch = dirs.find((d) => phaseTokenMatches(d, normalized));
if (dirMatch) {
const fullDir = path.join(phasesDir, dirMatch);
const phaseDirRel = toPosixPath(path.relative(cwd, fullDir));
const phaseFiles = fs.readdirSync(fullDir);
planCount = listPhasePlanFiles(fullDir).length;
summaryCount = listPhaseSummaryFiles(fullDir).length;
hasContext = findContextMdIn(fullDir) !== null;
hasResearch = phaseFiles.some(
(f) => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md',
);
completion = buildPhaseCompletionProjection(
cwd,
phaseNum,
phaseDirRel,
planCount,
summaryCount,
_slashRuntime,
);
if (completion.phase_complete) diskStatus = 'complete';
else if (completion.implementation_complete) diskStatus = 'executed';
else if (summaryCount > 0) diskStatus = 'partial';
else if (planCount > 0) diskStatus = 'planned';
else if (hasResearch) diskStatus = 'researched';
else if (hasContext) diskStatus = 'discussed';
else diskStatus = 'empty';
const nowMs = Date.now();
let newestMtime = 0;
for (const f of phaseFiles) {
try {
const stat = fs.statSync(path.join(fullDir, f));
if (stat.mtimeMs > newestMtime) newestMtime = stat.mtimeMs;
} catch {
/* intentionally empty */
}
}
if (newestMtime > 0) {
lastActivity = new Date(newestMtime).toISOString();
isActive = nowMs - newestMtime < 300000;
}
}
} catch {
/* intentionally empty */
}
const roadmapComplete = _checkboxStates.get(phaseNum) || false;
if (roadmapComplete && completion.phase_complete && diskStatus !== 'complete') {
diskStatus = 'complete';
}
phases.push({
number: phaseNum,
name: phaseName,
goal,
depends_on,
disk_status: diskStatus,
has_context: hasContext,
has_research: hasResearch,
plan_count: planCount,
summary_count: summaryCount,
roadmap_complete: roadmapComplete,
...completion,
last_activity: lastActivity,
is_active: isActive,
});
}
const MAX_NAME_WIDTH = 20;
for (const phase of phases) {
const name = phase['name'] as string;
if (name.length > MAX_NAME_WIDTH) {
phase['display_name'] = name.slice(0, MAX_NAME_WIDTH - 1) + '…';
} else {
phase['display_name'] = name;
}
}
function normalizePhaseNumber(value: string): string {
return value
.split('.')
.map((part) => {
const match = /^(\d+)([A-Z]?)$/i.exec(part);
if (!match) return part;
return `${Number(match[1])}${match[2].toUpperCase()}`;
})
.join('.');
}
const completedNums = new Set(
phases
.filter((p) => p['phase_complete'] === true)
.map((p) => normalizePhaseNumber(p['number'] as string)),
);
const phaseMap = new Map(phases.map((p) => [normalizePhaseNumber(p['number'] as string), p]));
const _allCompletedPattern = new RegExp(`-\\s*\\[x\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi');
let _allMatch: RegExpExecArray | null;
while ((_allMatch = _allCompletedPattern.exec(rawContent)) !== null) {
const phaseNum = normalizePhaseNumber(_allMatch[1]);
const phase = phaseMap.get(phaseNum);
if (!phase || phase['phase_complete'] === true) {
completedNums.add(phaseNum);
}
}
function reaches(from: string, to: string, visited = new Set<string>()): boolean {
const normalizedFrom = normalizePhaseNumber(from);
const normalizedTo = normalizePhaseNumber(to);
if (visited.has(normalizedFrom)) return false;
visited.add(normalizedFrom);
const p = phaseMap.get(normalizedFrom);
if (!p || !p['dep_phases'] || (p['dep_phases'] as string[]).length === 0) return false;
if ((p['dep_phases'] as string[]).some((dep) => normalizePhaseNumber(dep) === normalizedTo)) {
return true;
}
return (p['dep_phases'] as string[]).some((dep) => reaches(dep, to, visited));
}
function hasDepRelationship(numA: string, numB: string): boolean {
return reaches(numA, numB) || reaches(numB, numA);
}
for (const phase of phases) {
if (
!phase['depends_on'] ||
/^none$/i.test((phase['depends_on'] as string).trim())
) {
phase['deps_satisfied'] = true;
} else {
const depNums = (phase['depends_on'] as string).match(new RegExp(`${PHASE_NUMBER_TOKEN_SOURCE}`, 'gi')) || [];
phase['deps_satisfied'] = depNums.every((n) => completedNums.has(normalizePhaseNumber(n)));
phase['dep_phases'] = depNums;
}
}
for (const phase of phases) {
phase['deps_display'] =
phase['dep_phases'] && (phase['dep_phases'] as string[]).length > 0
? (phase['dep_phases'] as string[]).join(',')
: '—';
}
for (const phase of phases) {
phase['is_next_to_discuss'] =
(phase['disk_status'] === 'empty' || phase['disk_status'] === 'no_directory') &&
phase['deps_satisfied'];
}
let waitingSignal: unknown = null;
try {
const waitingPath = path.join(cwd, '.planning', 'WAITING.json');
const waitingRaw = platformReadSync(waitingPath);
if (waitingRaw !== null) {
waitingSignal = JSON.parse(waitingRaw);
}
} catch {
/* intentionally empty */
}
const recommendedActions: Record<string, unknown>[] = [];
for (const phase of phases) {
if (phase['disk_status'] === 'complete') continue;
if (/^999(?:\.|$)/.test(phase['number'] as string)) continue;
if (phase['disk_status'] === 'executed') {
recommendedActions.push({
phase: phase['number'],
phase_name: phase['name'],
action: 'verify',
reason: `Implementation complete; verification ${phase['verification_status'] as string}`,
command: phase['verification_next_command'],
});
} else if (phase['disk_status'] === 'planned' && phase['deps_satisfied']) {
recommendedActions.push({
phase: phase['number'],
phase_name: phase['name'],
action: 'execute',
reason: `${phase['plan_count'] as number} plans ready, dependencies met`,
command: `${formatGsdSlash('execute-phase', _slashRuntime) as string} ${phase['number'] as string}`,
});
} else if (
phase['disk_status'] === 'discussed' ||
phase['disk_status'] === 'researched'
) {
recommendedActions.push({
phase: phase['number'],
phase_name: phase['name'],
action: 'plan',
reason: 'Context gathered, ready for planning',
command: `${formatGsdSlash('plan-phase', _slashRuntime) as string} ${phase['number'] as string}`,
});
} else if (
(phase['disk_status'] === 'empty' || phase['disk_status'] === 'no_directory') &&
phase['is_next_to_discuss']
) {
recommendedActions.push({
phase: phase['number'],
phase_name: phase['name'],
action: 'discuss',
reason: 'Unblocked, ready to gather context',
command: `${formatGsdSlash('discuss-phase', _slashRuntime) as string} ${phase['number'] as string}`,
});
}
}
const activeExecuting = phases.filter(
(p) =>
p['disk_status'] === 'partial' ||
(p['disk_status'] === 'planned' && p['is_active']),
);
const activePlanning = phases.filter(
(p) =>
p['is_active'] &&
(p['disk_status'] === 'discussed' || p['disk_status'] === 'researched'),
);
const filteredActions = recommendedActions.filter((action) => {
if (action['action'] === 'execute' && activeExecuting.length > 0) {
return activeExecuting.every(
(active) => !hasDepRelationship(action['phase'] as string, active['number'] as string),
);
}
if (action['action'] === 'plan' && activePlanning.length > 0) {
return activePlanning.every(
(active) => !hasDepRelationship(action['phase'] as string, active['number'] as string),
);
}
return true;
});
const nonBacklogPhases = phases.filter((p) => !/^999(?:\.|$)/.test(p['number'] as string));
const completedCount = nonBacklogPhases.filter((p) => p['phase_complete'] === true).length;
const sanitizeFlags = (rawVal: unknown): string => {
const val = typeof rawVal === 'string' ? rawVal : '';
if (!val) return '';
const tokens = val.split(/\s+/).filter(Boolean);
const safe = tokens.every(
(t) =>
/^--[a-zA-Z0-9][-a-zA-Z0-9]*$/.test(t) ||
/^[a-zA-Z0-9][-a-zA-Z0-9_.]*$/.test(t),
);
if (!safe) {
process.stderr.write(
`gsd-tools: warning: manager.flags contains invalid tokens, ignoring: ${val}\n`,
);
return '';
}
return val;
};
const mgr = config.manager as Record<string, unknown> | undefined;
const mgrFlags = mgr?.['flags'] as Record<string, unknown> | undefined;
const managerFlags = {
discuss: sanitizeFlags(mgrFlags?.['discuss']),
plan: sanitizeFlags(mgrFlags?.['plan']),
execute: sanitizeFlags(mgrFlags?.['execute']),
};
const result: Record<string, unknown> = {
milestone_version: milestone['version'],
milestone_name: milestone['name'],
phases,
phase_count: phases.length,
completed_count: completedCount,
in_progress_count: phases.filter((p) =>
['executed', 'partial', 'planned', 'discussed', 'researched'].includes(p['disk_status'] as string),
).length,
recommended_actions: filteredActions,
waiting_signal: waitingSignal,
all_complete:
completedCount === nonBacklogPhases.length && nonBacklogPhases.length > 0,
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
roadmap_exists: true,
state_exists: true,
manager_flags: managerFlags,
};
output(withProjectRoot(cwd, result), raw);
}
function cmdInitProgress(cwd: string, raw: boolean): void {
try {
(pruneOrphanedWorktrees as (cwd: string) => void)(cwd);
} catch {
/* intentionally empty */
}
const config = loadConfig(cwd);
const milestone = getMilestoneInfo(cwd) as unknown as Record<string, unknown>;
const _slashRuntime = resolveRuntime(cwd);
// #1912: fail safe in workstream mode with no active workstream. With no active
// workstream and no --ws, planningDir(cwd) resolves to root .planning — silently
// reporting a stale root milestone. Require an explicit workstream instead.
// Mirror planningDir's resolution (GSD_WORKSTREAM env > stored active pointer) so
// an explicit --ws (which sets GSD_WORKSTREAM) satisfies the check.
const _availableWorkstreams = listAvailableWorkstreams(cwd);
const _resolvedWorkstream = process.env['GSD_WORKSTREAM'] || getActiveWorkstream(cwd);
if (_availableWorkstreams.length > 0 && !_resolvedWorkstream) {
error(
`init.progress requires a workstream in workstream mode — no active workstream is set, so root STATE.md (likely stale) would be reported. ` +
`Pass --ws <name> or run ${formatGsdSlash('workstream set', _slashRuntime) as string} first. ` +
`Available workstreams: ${_availableWorkstreams.join(', ')}`,
);
}
const phasesDir = path.join(planningDir(cwd), 'phases');
const phases: Record<string, unknown>[] = [];
let currentPhase: Record<string, unknown> | null = null;
let nextPhase: Record<string, unknown> | null = null;
const roadmapPhaseNums = new Set<string>();
const roadmapPhaseNames = new Map<string, string>();
const roadmapCheckboxStates = new Map<string, boolean>();
try {
const roadmapContent = extractCurrentMilestone(
fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'),
cwd,
);
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const headingPattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
let hm: RegExpExecArray | null;
while ((hm = headingPattern.exec(roadmapContent)) !== null) {
roadmapPhaseNums.add(hm[1]);
roadmapPhaseNames.set(hm[1], hm[2].replace(/\(INSERTED\)/i, '').trim());
}
const cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi');
let cbm: RegExpExecArray | null;
while ((cbm = cbPattern.exec(roadmapContent)) !== null) {
roadmapCheckboxStates.set(cbm[2], cbm[1].toLowerCase() === 'x');
}
} catch {
/* intentionally empty */
}
const isDirInMilestone = getMilestonePhaseFilter(cwd);
const seenPhaseNums = new Set<string>();
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries
.filter((e) => e.isDirectory())
.map((e) => e.name)
.filter(isDirInMilestone)
.sort((a, b) => {
const pa = a.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
const pb = b.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
if (!pa || !pb) return a.localeCompare(b);
return parseInt(pa[1], 10) - parseInt(pb[1], 10);
});
for (const dir of dirs) {
const dirMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
const phaseNumber = dirMatch ? dirMatch[1] : dir;
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
seenPhaseNums.add(phaseNumber.replace(/^0+/, '') || '0');
const phasePath = path.join(phasesDir, dir);
const phaseFiles = fs.readdirSync(phasePath);
const plans = listPhasePlanFiles(phasePath);
const summaries = listPhaseSummaryFiles(phasePath);
const hasResearch = phaseFiles.some(
(f) => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md',
);
const phaseDirRel = toPosixPath(
path.relative(cwd, path.join(planningDir(cwd), 'phases', dir)),
);
const completion = buildPhaseCompletionProjection(
cwd,
phaseNumber,
phaseDirRel,
plans.length,
summaries.length,
_slashRuntime,
);
const status =
completion.phase_complete
? 'complete'
: completion.implementation_complete
? 'executed'
: plans.length > 0
? 'in_progress'
: hasResearch
? 'researched'
: 'pending';
const phaseInfo: Record<string, unknown> = {
number: phaseNumber,
name: phaseName,
// #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.
// phaseDirRel itself stays relative — buildPhaseCompletionProjection
// above still joins it against cwd.
directory: toPosixPath(path.join(cwd, phaseDirRel)),
status,
plan_count: plans.length,
summary_count: summaries.length,
has_research: hasResearch,
...completion,
};
phases.push(phaseInfo);
if (!currentPhase && (status === 'executed' || status === 'in_progress' || status === 'researched')) {
currentPhase = phaseInfo;
}
if (!nextPhase && status === 'pending') {
nextPhase = phaseInfo;
}
}
} catch {
/* intentionally empty */
}
for (const [num, name] of roadmapPhaseNames) {
const stripped = num.replace(/^0+/, '') || '0';
if (!seenPhaseNums.has(stripped)) {
const checkboxComplete =
roadmapCheckboxStates.get(num) === true ||
roadmapCheckboxStates.get(stripped) === true;
const completion = buildPhaseCompletionProjection(
cwd,
num,
null,
0,
0,
_slashRuntime,
);
const status = 'not_started';
const phaseInfo: Record<string, unknown> = {
number: num,
name: name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
directory: null,
status,
plan_count: 0,
summary_count: 0,
has_research: false,
roadmap_complete: checkboxComplete,
...completion,
};
phases.push(phaseInfo);
if (!nextPhase && !currentPhase && !checkboxComplete) {
nextPhase = phaseInfo;
}
}
}
phases.sort(
(a, b) => parseInt(a['number'] as string, 10) - parseInt(b['number'] as string, 10),
);
let pausedAt: string | null = null;
const state = platformReadSync(path.join(planningDir(cwd), 'STATE.md'));
if (state !== null) {
const pauseMatch = state.match(/\*\*Paused At:\*\*\s*(.+)/);
if (pauseMatch) pausedAt = pauseMatch[1].trim();
}
const result: Record<string, unknown> = {
executor_model: resolveModelInternal(cwd, 'gsd-executor'),
planner_model: resolveModelInternal(cwd, 'gsd-planner'),
commit_docs: config.commit_docs,
milestone_version: milestone['version'],
milestone_name: milestone['name'],
phases,
phase_count: phases.length,
completed_count: phases.filter((p) => p['status'] === 'complete').length,
in_progress_count: phases.filter((p) =>
['executed', 'in_progress'].includes(p['status'] as string),
).length,
current_phase: currentPhase,
next_phase: nextPhase,
paused_at: pausedAt,
has_work_in_progress: !!currentPhase,
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')),
// #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.
state_path: toPosixPath(path.join(planningDir(cwd), 'STATE.md')),
roadmap_path: toPosixPath(path.join(planningDir(cwd), 'ROADMAP.md')),
project_path: toPosixPath(path.join(planningDir(cwd), 'PROJECT.md')),
config_path: toPosixPath(path.join(planningDir(cwd), 'config.json')),
};
output(withProjectRoot(cwd, result), raw);
}
function detectChildRepos(dir: string): { name: string; path: string; has_uncommitted: boolean }[] {
const repos: { name: string; path: string; has_uncommitted: boolean }[] = [];
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return repos;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('.')) continue;
const fullPath = path.join(dir, entry.name);
const gitDir = path.join(fullPath, '.git');
if (fs.existsSync(gitDir)) {
const statusResult = execGit(['status', '--porcelain'], {
cwd: fullPath,
timeout: 5000,
}) as unknown as Record<string, unknown>;
const hasUncommitted =
statusResult['exitCode'] === 0 &&
(statusResult['stdout'] as string).length > 0;
repos.push({ name: entry.name, path: fullPath, has_uncommitted: hasUncommitted });
}
}
return repos;
}
function cmdInitNewWorkspace(cwd: string, raw: boolean): void {
const homedir = process.env['HOME'] || os.homedir();
const defaultBase = path.join(homedir, 'gsd-workspaces');
const childRepos = detectChildRepos(cwd);
const gitVersion = execGit(['--version'], { timeout: 5000 }) as unknown as Record<string, unknown>;
const worktreeAvailable = gitVersion['exitCode'] === 0;
const result: Record<string, unknown> = {
default_workspace_base: defaultBase,
child_repos: childRepos,
child_repo_count: childRepos.length,
worktree_available: worktreeAvailable,
is_git_repo: pathExistsInternal(cwd, '.git'),
cwd_repo_name: path.basename(cwd),
};
output(withProjectRoot(cwd, result), raw);
}
function cmdInitListWorkspaces(cwd: string, raw: boolean): void {
const homedir = process.env['HOME'] || os.homedir();
const defaultBase = path.join(homedir, 'gsd-workspaces');
const workspaces: Record<string, unknown>[] = [];
if (fs.existsSync(defaultBase)) {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(defaultBase, { withFileTypes: true });
} catch {
entries = [];
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const wsPath = path.join(defaultBase, entry.name);
const manifestPath = path.join(wsPath, 'WORKSPACE.md');
if (!fs.existsSync(manifestPath)) continue;
let repoCount = 0;
let hasProject = false;
let strategy = 'unknown';
const manifest = platformReadSync(manifestPath);
if (manifest !== null) {
const strategyMatch = manifest.match(/^Strategy:\s*(.+)$/m);
if (strategyMatch) strategy = strategyMatch[1].trim();
const tableRows = manifest
.split('\n')
.filter(
(l) =>
l.match(/^\|\s*\w/) && !l.includes('Repo') && !l.includes('---'),
);
repoCount = tableRows.length;
}
hasProject = fs.existsSync(path.join(wsPath, '.planning', 'PROJECT.md'));
workspaces.push({
name: entry.name,
path: wsPath,
repo_count: repoCount,
strategy,
has_project: hasProject,
});
}
}
const result: Record<string, unknown> = {
workspace_base: defaultBase,
workspaces,
workspace_count: workspaces.length,
};
output(result, raw);
}
function cmdInitRemoveWorkspace(cwd: string, name: string | undefined, raw: boolean): void {
const homedir = process.env['HOME'] || os.homedir();
const defaultBase = path.join(homedir, 'gsd-workspaces');
if (!name) {
error('workspace name required for init remove-workspace');
}
const wsPath = path.join(defaultBase, name!);
const manifestPath = path.join(wsPath, 'WORKSPACE.md');
if (!fs.existsSync(wsPath)) {
error(`Workspace not found: ${wsPath}`);
}
const repos: { name: string; source: string; branch: string; strategy: string }[] = [];
let strategy = 'unknown';
const manifestContent = platformReadSync(manifestPath);
if (manifestContent !== null) {
try {
const manifest = manifestContent;
const strategyMatch = manifest.match(/^Strategy:\s*(.+)$/m);
if (strategyMatch) strategy = strategyMatch[1].trim();
const lines = manifest.split('\n');
for (const line of lines) {
const lineMatch = line.match(
/^\|\s*(\S+)\s*\|\s*(\S+)\s*\|\s*(\S+)\s*\|\s*(\S+)\s*\|$/,
);
if (lineMatch && lineMatch[1] !== 'Repo' && !lineMatch[1].includes('---')) {
repos.push({
name: lineMatch[1],
source: lineMatch[2],
branch: lineMatch[3],
strategy: lineMatch[4],
});
}
}
} catch {
/* best-effort */
}
}
const dirtyRepos: string[] = [];
for (const repo of repos) {
const repoPath = path.join(wsPath, repo.name);
if (!fs.existsSync(repoPath)) continue;
const statusResult = execGit(['status', '--porcelain'], {
cwd: repoPath,
timeout: 5000,
}) as unknown as Record<string, unknown>;
if (
statusResult['exitCode'] === 0 &&
(statusResult['stdout'] as string).length > 0
) {
dirtyRepos.push(repo.name);
}
}
const result: Record<string, unknown> = {
workspace_name: name,
workspace_path: wsPath,
has_manifest: fs.existsSync(manifestPath),
strategy,
repos,
repo_count: repos.length,
dirty_repos: dirtyRepos,
has_dirty_repos: dirtyRepos.length > 0,
};
// #2402: sibling init commands route through withProjectRoot so response_language
// (and project_root/agents_installed) reach the workflow; this one didn't.
output(withProjectRoot(cwd, result), raw);
}
function buildAgentSkillsBlock(
config: Record<string, unknown>,
agentType: string,
projectRoot: string,
diagnostics?: { warnings: string[] },
): string {
const warn = (message: string): void => {
process.stderr.write(message);
if (diagnostics) diagnostics.warnings.push(message.replace(/\n+$/, ''));
};
const runtime = (config && (config['runtime'] as string)) || 'claude';
const globalSkillsBase = getGlobalSkillsBase(runtime);
if (!config || !config['agent_skills'] || !agentType) return '';
let skillPaths = (config['agent_skills'] as Record<string, unknown>)[agentType];
if (!skillPaths) return '';
if (typeof skillPaths === 'string') skillPaths = [skillPaths];
if (!Array.isArray(skillPaths)) {
warn(
`[agent-skills] WARNING: Agent "${agentType}" has a malformed agent_skills value (expected string or array, got ${typeof skillPaths}) — ignoring\n`,
);
return '';
}
if (skillPaths.length === 0) return '';
// Hoist trusted roots computation before the loop: loadTrustedGlobalRoots does
// realpathSync I/O and should run at most once per call, not once per failing skill.
// It returns [] cheaply when no roots are configured, so the realpath cost only
// occurs when the caller has actually set trusted_global_roots.
const trustedGlobalRoots = loadTrustedGlobalRoots(config);
// Each entry is either a filesystem include ({ kind: 'include', ref, display }) or a
// Skill-tool directive ({ kind: 'directive', name }) for plugin-provided namespaced skills.
const validEntries: Array<{ kind: 'include'; ref: string; display: string } | { kind: 'directive'; name: string }> = [];
for (const skillPath of skillPaths) {
if (typeof skillPath !== 'string') {
warn(`[agent-skills] WARNING: Ignoring non-string skill entry (${typeof skillPath}) — skipping\n`);
continue;
}
if (skillPath.startsWith('global:')) {
const skillName = skillPath.slice(7);
if (!skillName) {
warn(
`[agent-skills] WARNING: "global:" prefix with empty skill name — skipping\n`,
);
continue;
}
// Accept: one or more [A-Za-z0-9_-]+ segments joined by single colons.
// Rejects: empty segments (::), leading/trailing colon, dots, slashes, backslashes.
if (!/^[A-Za-z0-9_-]+(:[A-Za-z0-9_-]+)*$/.test(skillName)) {
warn(
`[agent-skills] WARNING: Invalid global skill name "${skillName}" — skipping\n`,
);
continue;
}
const isNamespaced = skillName.includes(':');
if (isNamespaced) {
// Plugin-provided namespaced skill: no filesystem path exists locally.
if (runtime === 'claude') {
// Emit a natural-language Skill-tool directive (not a @-include).
validEntries.push({ kind: 'directive', name: skillName });
} else {
warn(
`[agent-skills] WARNING: Plugin-namespaced skill "global:${skillName}" requires a Skill-tool-capable runtime (claude) — skipping on runtime "${runtime}"\n`,
);
}
continue;
}
// Non-namespaced bare name: attempt filesystem resolution as before.
if (globalSkillsBase === null) {
warn(
`[agent-skills] WARNING: Runtime "${runtime}" does not use a skills directory — "global:${skillName}" is not supported on this runtime\n`,
);
continue;
}
const globalSkillDir = getGlobalSkillDir(runtime, skillName) as string;
const globalSkillMd = path.join(globalSkillDir, 'SKILL.md');
const displayPath = getGlobalSkillDisplayPath(runtime, skillName);
if (!fs.existsSync(globalSkillMd)) {
warn(
`[agent-skills] WARNING: Global skill not found at "${displayPath}/SKILL.md" — skipping\n`,
);
continue;
}
const pathCheck = validatePath(globalSkillMd, globalSkillsBase, { allowAbsolute: true }) as unknown as Record<string, unknown>;
if (!pathCheck['safe']) {
const acceptedViaTrustedRoot = trustedGlobalRoots.some((root) => {
const rootCheck = validatePath(globalSkillMd, root, { allowAbsolute: true }) as unknown as Record<string, unknown>;
return Boolean(rootCheck['safe']);
});
if (!acceptedViaTrustedRoot) {
warn(
`[agent-skills] WARNING: Global skill "${skillName}" failed path check (symlink escape?) — skipping\n`,
);
continue;
}
// Intentionally a direct stderr write, NOT warn(): this is an acceptance
// trace, not a skip, so it must not land in the diagnostics warnings[].
process.stderr.write(`[agent-skills] NOTE: Global skill "${skillName}" accepted via trusted_global_roots (resolves outside the default skills dir)\n`);
}
validEntries.push({ kind: 'include', ref: `${globalSkillDir}/SKILL.md`, display: displayPath });
continue;
}
const pathCheck = validatePath(skillPath, projectRoot) as unknown as Record<string, unknown>;
if (!pathCheck['safe']) {
warn(
`[agent-skills] WARNING: Skipping unsafe path "${skillPath}": ${pathCheck['error'] as string}\n`,
);
continue;
}
const skillMdPath = path.join(projectRoot, skillPath, 'SKILL.md');
if (!fs.existsSync(skillMdPath)) {
warn(
`[agent-skills] WARNING: Skill not found at "${skillPath}/SKILL.md" — skipping\n`,
);
continue;
}
validEntries.push({ kind: 'include', ref: `${skillPath}/SKILL.md`, display: skillPath });
}
if (validEntries.length === 0) {
warn(
`[agent-skills] WARNING: Agent "${agentType}" has ${skillPaths.length} configured skill path(s) but none resolved to a valid skill — all were skipped (see warnings above)\n`,
);
return '';
}
const lines = validEntries.map((entry) => {
if (entry.kind === 'directive') {
return `- Load the \`${entry.name}\` skill via the Skill tool before proceeding (plugin-provided).`;
}
return `- @${posixNormalize(String(entry.ref))}`;
}).join('\n');
return `<agent_skills>\nRead these user-configured skills:\n${lines}\n</agent_skills>`;
}
/** Reason enum for agent-skills diagnostic (#1415, ADR-1411 P2). */
type AgentSkillsReason = 'resolved' | 'not_configured' | 'configured_empty' | 'configured_unresolved';
function cmdAgentSkills(
cwd: string,
agentType: string | undefined,
raw: boolean,
jsonMode: boolean,
): void {
if (!agentType) {
output('', raw, '');
return;
}
// Anchor to project root before loading config (#1415/#1366 cwd-drift fix).
const projectRoot = findProjectRoot(cwd);
const { config, source, degraded } = loadConfigResolved(projectRoot);
const diagnostics = { warnings: [] as string[] };
let block = buildAgentSkillsBlock(
config,
agentType,
projectRoot,
diagnostics,
);
// #2454: Agent prompt fallback for AGENTS-native runtimes where named
// subagents are NOT dispatchable (kimi-code, kimi, opencode, kilo, etc.).
// On these runtimes, workflows inject ${AGENT_SKILLS_*} into the dispatch
// prompt of a built-in subagent (coder/explore/plan). If no
// model_profile_overrides or agent_skills config entry exists, the block
// is empty — but the agent's prompt CONTENT is installed on disk at the
// runtime's agents directory. Read it as a fallback so the persona survives
// the dispatch even without explicit config opt-in.
//
// GATED to non-claude runtimes: Claude Code supports named subagent dispatch
// and its ${AGENT_SKILLS_*} contract is a skills-injection path, not a
// persona fallback. Triggering the fallback for claude would change the
// documented "unconfigured → empty block" contract that agent-skills tests
// pin.
if (!block) {
const runtime = (config && (config['runtime'] as string)) || process.env['GSD_RUNTIME'] || 'claude';
if (runtime !== 'claude') {
const agentCheck = checkAgentsInstalled(runtime) as unknown as { agents_dir?: string } | null;
const agentsDir = agentCheck?.agents_dir;
if (typeof agentsDir === 'string' && agentsDir.length > 0) {
const agentFile = path.join(agentsDir, `${agentType}.md`);
try {
const content = platformReadSync(agentFile);
if (content && content.length > 0) {
block = content;
}
} catch { /* agent file not found — fall through to empty block */ }
}
}
}
// Compute configured + reason for diagnostic output.
const agentSkillsMap = (config && config['agent_skills'] && typeof config['agent_skills'] === 'object')
? config['agent_skills'] as Record<string, unknown>
: {};
const configured = Object.prototype.hasOwnProperty.call(agentSkillsMap, agentType);
let reason: AgentSkillsReason;
let skillPaths: unknown = configured ? agentSkillsMap[agentType] : [];
if (!configured) {
reason = 'not_configured';
skillPaths = [];
} else {
// Normalize paths to array
if (typeof skillPaths === 'string') skillPaths = [skillPaths];
if (!Array.isArray(skillPaths)) skillPaths = [];
const pathsArr = skillPaths as unknown[];
// Fix 3: treat "" (empty string) as configured_empty — all-blank entries = no meaningful paths.
// An array of all empty/blank strings has length > 0 but zero meaningful paths.
const nonBlankPaths = pathsArr.filter(p => typeof p === 'string' && p.trim().length > 0);
if (pathsArr.length === 0 || nonBlankPaths.length === 0) {
// configured with empty array / "" / all-blank entries
reason = 'configured_empty';
// Reflect zero meaningful paths in the normalized array used for skills_count
skillPaths = [];
try {
process.stderr.write(
`[agent-skills] WARNING: Agent "${agentType}" is configured in agent_skills but has no skill paths — skills_count will be 0\n`
);
} catch { /* stderr might be closed */ }
} else if (!block) {
// configured with paths but all failed to resolve (warnings already emitted by buildAgentSkillsBlock)
reason = 'configured_unresolved';
} else {
reason = 'resolved';
}
}
const normalizedPaths = Array.isArray(skillPaths) ? skillPaths : [];
if (jsonMode) {
// Build the Resolution<AgentSkillsValue> envelope and embed .value additively.
// Flat fields are retained unchanged for back-compat; value formalises the
// Resolution convention (ADR-1411 P3, #1416). source/degraded remain
// config-provenance extras, outside the Resolution<T> envelope.
const resolution = makeResolution(
{ block: block || '', skills_count: normalizedPaths.length },
{ configured, reason, warnings: diagnostics.warnings },
);
output({
agent_type: agentType,
block: block || '',
skills_count: normalizedPaths.length,
warnings: diagnostics.warnings,
configured,
reason,
source,
degraded,
value: resolution.value,
}, raw);
return;
}
// #1400: emit the raw block via the synchronous-flush output() helper (the same
// one the --json branch uses) rather than process.stdout.write + process.exit(0).
// When stdout is a pipe/file (how workflows consume this via command
// substitution) the async stdout buffer is torn down by process.exit() before
// it drains — on Windows this reliably truncates the write to 0 bytes, so every
// ${AGENT_SKILLS_*} substitution expands empty. output() writes every byte with
// writeAllSync and returns, letting the event loop drain naturally.
output(block || '', true, block || '');
}
interface SkillEntry {
name: string;
description: string;
triggers: string[];
path: string;
file_path: string;
root: string;
scope: string;
installed: boolean;
deprecated: boolean;
}
interface RootSummary {
root: string;
path: string;
scope: string;
present: boolean;
deprecated: boolean;
skill_count?: number;
command_count?: number;
}
interface SkillManifest {
skills: SkillEntry[];
roots: RootSummary[];
installation: {
gsd_skills_installed: boolean;
legacy_claude_commands_installed: boolean;
};
counts: {
skills: number;
roots: number;
};
}
function buildSkillManifest(cwd: string, skillsDir: string | null = null): SkillManifest {
interface CanonicalRoot {
root: string;
path: string;
scope: string;
kind: string;
present?: boolean;
deprecated?: boolean;
}
const canonicalRoots: CanonicalRoot[] = skillsDir
? [
{
root: path.resolve(skillsDir),
path: path.resolve(skillsDir),
scope: 'custom',
present: fs.existsSync(skillsDir),
kind: 'skills',
},
]
: [
{
root: '.claude/skills',
path: path.join(cwd, '.claude', 'skills'),
scope: 'project',
kind: 'skills',
},
{
root: '.agents/skills',
path: path.join(cwd, '.agents', 'skills'),
scope: 'project',
kind: 'skills',
},
{
root: '.cursor/skills',
path: path.join(cwd, '.cursor', 'skills'),
scope: 'project',
kind: 'skills',
},
{
root: '.github/skills',
path: path.join(cwd, '.github', 'skills'),
scope: 'project',
kind: 'skills',
},
{
root: '.codex/skills',
path: path.join(cwd, '.codex', 'skills'),
scope: 'project',
kind: 'skills',
},
{
root: '~/.claude/skills',
path: getGlobalSkillsBase('claude') as string,
scope: 'global',
kind: 'skills',
},
{
// ADR-1239 upgrade 3 (#2088): Codex's canonical skill root is
// $HOME/.agents/skills (per codex core-skills loader.rs), resolved via
// the skills-kind `home` override in getGlobalSkillsBase.
root: '~/.agents/skills',
path: getGlobalSkillsBase('codex') as string,
scope: 'global',
kind: 'skills',
},
{
// Codex's deprecated fallback skill root ($CODEX_HOME/skills). Kept as a
// discovery-only legacy root so pre-move installs remain inventoried;
// GSD no longer installs here (#2088).
root: '~/.codex/skills',
path: path.join(getGlobalConfigDir('codex'), 'skills'),
scope: 'global',
kind: 'skills',
deprecated: true,
},
{
root: '.claude/gsd-core/skills',
path: path.join(os.homedir(), '.claude', 'gsd-core', 'skills'),
scope: 'import-only',
kind: 'skills',
deprecated: true,
},
{
root: '.claude/commands/gsd',
path: path.join(os.homedir(), '.claude', 'commands', 'gsd'),
scope: 'legacy-commands',
kind: 'commands',
deprecated: true,
},
];
const skills: SkillEntry[] = [];
const roots: RootSummary[] = [];
let legacyClaudeCommandsInstalled = false;
for (const rootInfo of canonicalRoots) {
const rootPath = rootInfo.path;
const rootSummary: RootSummary = {
root: rootInfo.root,
path: rootPath,
scope: rootInfo.scope,
present: fs.existsSync(rootPath),
deprecated: !!rootInfo.deprecated,
};
if (!rootSummary.present) {
roots.push(rootSummary);
continue;
}
if (rootInfo.kind === 'commands') {
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(rootPath, { withFileTypes: true });
} catch {
roots.push(rootSummary);
continue;
}
const commandFiles = entries.filter(
(entry) => entry.isFile() && entry.name.endsWith('.md'),
);
rootSummary.command_count = commandFiles.length;
if (rootSummary.command_count > 0) legacyClaudeCommandsInstalled = true;
roots.push(rootSummary);
continue;
}
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(rootPath, { withFileTypes: true });
} catch {
roots.push(rootSummary);
continue;
}
// Track skill names seen within this root to deduplicate dual-routed concretes
// (e.g. spec-phase nested under both gsd-ns-workflow and gsd-ns-manage).
const seenNamesInRoot = new Set<string>();
function pushSkillEntry(
// relPath must use forward slashes on all platforms (manifest paths are
// posix-style for cross-platform stability; flat entries use template
// literals that always produce '/'; nested entries are joined below
// with explicit '/' separators rather than path.join).
relPath: string,
content: string,
sourcePath?: string,
): boolean {
const frontmatter = extractFrontmatter(content, sourcePath);
const dirPart = relPath.replace(/\/SKILL\.md$/, '');
const stem = dirPart.includes('/') ? dirPart.split('/').pop()! : dirPart;
const name = (frontmatter['name'] as string) || stem;
if (seenNamesInRoot.has(name)) return false; // dedupe dual-routed concretes
seenNamesInRoot.add(name);
const description = (frontmatter['description'] as string) || '';
const triggers: string[] = [];
const bodyMatch = content.match(/^---[\s\S]*?---\s*\n([\s\S]*)$/);
if (bodyMatch) {
const body = bodyMatch[1];
const triggerLines = body.match(/^TRIGGER\s+when:\s*(.+)$/gmi);
if (triggerLines) {
for (const line of triggerLines) {
const m = line.match(/^TRIGGER\s+when:\s*(.+)$/i);
if (m) triggers.push(m[1].trim());
}
}
}
skills.push({
name,
description,
triggers,
path: dirPart,
file_path: relPath,
root: rootInfo.root,
scope: rootInfo.scope,
installed: rootInfo.scope !== 'import-only',
deprecated: !!rootInfo.deprecated,
});
return true;
}
let skillCount = 0;
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillMdPath = path.join(rootPath, entry.name, 'SKILL.md');
const content = platformReadSync(skillMdPath);
if (content !== null) {
if (pushSkillEntry(`${entry.name}/SKILL.md`, content, skillMdPath)) skillCount++;
}
// Nested layout: <entry>/skills/<stem>/SKILL.md
// Used by cline, qwen, hermes, augment, trae, antigravity (#69 nested=true).
// Descend exactly one level into <entry>/skills/ — no deeper recursion.
// Scope to gsd-ns-* routers only: never vacuum up an unrelated user skill
// that happens to have its own `skills/` subdirectory.
if (!entry.name.startsWith('gsd-ns-')) continue;
const nestedSkillsDir = path.join(rootPath, entry.name, 'skills');
let nestedEntries: fs.Dirent[] = [];
try {
nestedEntries = fs.readdirSync(nestedSkillsDir, { withFileTypes: true });
} catch {
// No skills/ subdir — flat layout or unreadable; nothing to do.
nestedEntries = [];
}
for (const nested of nestedEntries) {
if (!nested.isDirectory()) continue;
const nestedSkillMd = path.join(nestedSkillsDir, nested.name, 'SKILL.md');
const nestedContent = platformReadSync(nestedSkillMd);
if (nestedContent === null) continue;
// Use forward-slash separator explicitly so manifest paths are posix-style
// on all platforms, matching the flat-layout behaviour above.
const relPath = `${entry.name}/skills/${nested.name}/SKILL.md`;
if (pushSkillEntry(relPath, nestedContent, nestedSkillMd)) skillCount++;
}
}
rootSummary.skill_count = skillCount;
roots.push(rootSummary);
}
skills.sort((a, b) => {
const rootCmp = a.root.localeCompare(b.root);
return rootCmp !== 0 ? rootCmp : a.name.localeCompare(b.name);
});
const gsdSkillsInstalled = skills.some((skill) => skill.name.startsWith('gsd-'));
return {
skills,
roots,
installation: {
gsd_skills_installed: gsdSkillsInstalled,
legacy_claude_commands_installed: legacyClaudeCommandsInstalled,
},
counts: {
skills: skills.length,
roots: roots.length,
},
};
}
function cmdSkillManifest(cwd: string, args: string[], raw: boolean): void {
const skillsDirIdx = args.indexOf('--skills-dir');
const skillsDir =
skillsDirIdx >= 0 && args[skillsDirIdx + 1] ? args[skillsDirIdx + 1] : null;
const manifest = buildSkillManifest(cwd, skillsDir);
if (args.includes('--write')) {
const planDir = path.join(cwd, '.planning');
if (fs.existsSync(planDir)) {
const manifestPath = path.join(planDir, 'skill-manifest.json');
platformWriteSync(manifestPath, JSON.stringify(manifest, null, 2));
}
}
output(manifest, raw);
}
export = {
cmdInitExecutePhase,
cmdInitPlanPhase,
cmdInitNewProject,
cmdInitNewMilestone,
cmdInitQuick,
cmdInitIngestDocs,
cmdInitOnboard,
cmdInitResume,
cmdInitVerifyWork,
cmdInitPhaseOp,
cmdInitTodos,
cmdInitMilestoneOp,
cmdInitMapCodebase,
cmdInitProgress,
cmdInitManager,
cmdInitNewWorkspace,
cmdInitListWorkspaces,
cmdInitRemoveWorkspace,
detectChildRepos,
buildAgentSkillsBlock,
cmdAgentSkills,
buildSkillManifest,
cmdSkillManifest,
};