Files
msd-core/src/profile-output.cts
Tom Boucher 0624c5da6f chore(#3212): src/text-lines.cts is the sole owner of line-terminator handling — Phase 2 (#3420)
* test(#3413): failing-first suite for the line-terminator seam

Phase 2 of epic #3212 (ADR-3212 §3/§6/§7). Tests only — src/text-lines.cts
does not exist yet, so tests/text-lines.test.cjs fails with MODULE_NOT_FOUND
at its require line, which is the intended RED.

The frontmatter.test.cjs additions drive #3360 (confirmed-bug) fail-first:
parseMustHavesBlock currently returns [] for every must_haves block on a
CRLF-authored plan file, because \r is its own LineTerminator in ECMAScript
and two /m-anchored \s* patterns can absorb it, inflating a captured indent
by one character and tripping the "not nested under must_haves" guard.
Verified locally against the current (unfixed) compiled module: both the
direct repro and the silent-exit "blank line before must_haves:" variant
return [] today. A parity property test (crlf vs lf must deep-equal for
every block name) matches a pattern this maintainer has required repeatedly
for prior CRLF fixes in this codebase (Cortex-recorded, verify_intent=held).

The no-crlf-fragile-split.rule.test.cjs additions lock the eslint rule's
future fix-hint text (pointing at splitLines()) and its self-reference
non-violation (the seam's own correct \r?\n split must never flag itself).

Design: .gsd/phase/chore-3413-text-lines-seam/40-design.md
Test matrix: .gsd/phase/chore-3413-text-lines-seam/50-test-matrix.md

* chore(#3413): src/text-lines.cts owns line-terminator handling

Phase 2 of epic #3212 (ADR-3212 §3/§6/§7). Adds splitLines/normalizeEol/
detectEol/joinLines and migrates frontmatter.cts onto it.

parseMustHavesBlock (#3360, confirmed-bug) returned [] for every
must_haves block on a CRLF plan file. Root cause: \r is its own
LineTerminator in ECMAScript, so under /m two \s*-anchored indentation
lookups could match at the position INSIDE a \r\n pair and absorb the
terminator, inflating the captured indent by one character and tripping
the "not nested under must_haves" guard. Two silent exits, one with a
diagnostic and one without (a blank line before must_haves: hits the
silent path). Fixed by converting both lookups from a whole-string /m
match to split-then-scan — splitLines first, then a per-line, non-/m
match — the same structural pattern parseYamlRegion (30 lines away in
the same file) already used safely. Nothing downstream of the two
lookups changed; blockLines is now sliced from the already-split array
instead of re-splitting a substring, but its contents are unchanged for
LF input, and the per-line dash/kv parsing loop is untouched.

A parity property test (CRLF and LF plans parse to identical must_haves
for every block name) matches a pattern this maintainer has required
repeatedly for prior CRLF fixes in this file's neighborhood (Cortex:
7 recorded decisions, verify_intent -> held).

frontmatter.cts's other .split(/\r?\n/) call sites (parseYamlRegion,
isFrontmatterShaped, sliceTopLevelFrontmatterSegments, spliceFrontmatter)
are rerouted onto splitLines — a literal 1:1 substitution, zero behavior
change, since splitLines IS that same regex plus a type guard.

The 4 scripts/normalizeLineEndings copies (gen-registry, gen-loop-host-
contract, gen-capability-registry, gen-context-index) are deleted and
rerouted onto normalizeEol, which strips a bare unpaired \r exactly like
the deleted copies did (not just \r\n pairs) -- verified against each
script's own --check mode against its real generated output.

local/no-crlf-fragile-split widens from tests/ to src/**/*.cts, with its
fix-hint message now naming splitLines() instead of the raw regex --
the prohibition finally has a primitive to point at. Detection logic
unchanged in this phase (deliberate scope limit, see design doc Known
limits: the rule doesn't yet recognize safeReadFile/platformReadSync as
a content source, and has no detector for the \s-adjacent-to-anchor
shape that is #3360's actual mechanism -- the CLASS is converged by the
direct fix + regression test regardless).

joinLines/detectEol are NOT wired into frontmatter.cts's own write path
(cmdFrontmatterSet/Merge -> platformWriteSync) -- verified that
platformWriteSync already, unconditionally converts CRLF->LF on every
.md write today as a pre-existing policy owned by a different module,
and ADR-3212's backward-compatibility clause rules out a file-format
change in any phase. Stated explicitly in Known limits rather than left
for a reader to discover.

Six-gate ripple: .gitignore, eslint.config.mjs (src/**/*.cts block),
docs/INVENTORY.md + INVENTORY-MANIFEST.json (regenerated), CONTEXT.md
glossary (Text Lines Module, mirroring Phase 1's Pattern Module entry).

Design: .gsd/phase/chore-3413-text-lines-seam/40-design.md
Test matrix: .gsd/phase/chore-3413-text-lines-seam/50-test-matrix.md

* fix(#3413): fix 13 pre-existing CRLF-fragile splits the widened rule found

Widening local/no-crlf-fragile-split from tests/ to src/**/*.cts (the
previous commit) immediately surfaced 13 real, pre-existing violations
across 10 files -- undetected until now because the rule never scanned
src/. This is the exact defect class ADR-3212 exists to close, playing
out again one phase after Phase 1 hit the same shape ("the new lint
rule -- once live -- found 27 more"). Per CLAUDE.md's no-defer rule,
fixed inline rather than deferred or suppressed; there is no
established suppression convention for this rule in src/ and inventing
one now would undermine the point of widening it.

audit.cts, broken-windows.cts, core-utils.cts, init.cts, milestone.cts,
phase.cts (x3), profile-output.cts, roadmap.cts (x2): bare-\n splits or
regex character classes widened to \r?\n / [^\r\n], each following the
same pattern already established migrating frontmatter.cts.

phase-estimation.cts: `\r?(?:\n|$)` restructured to `(?:\r?\n|\r?$)` --
already semantically CRLF-safe, but the rule's lexical scanner doesn't
recognize \r? guarding a group (only \r? immediately before a literal
\n). Verified the two forms are equivalent across all four EOL/EOF
cases before restructuring, not assumed.

roadmap-upgrade.cts needed two coupled sites, not the one flagged line:
computeMigrationPlan and applyMigration must agree on line
representation for the lines[edit.lineIndex] === edit.from equality
check to hold, and the write-back needed joinLines + detectEol -- a
plain lines.join('\n') was silently flattening a CRLF ROADMAP.md to LF
wholesale on every migration. This is the first real production
consumer of joinLines/detectEol in this epic (frontmatter.cts's own
write path doesn't use them -- see the previous commit's Known limits).

Fixing the 13 flagged sites surfaced 4 more adjacent same-shape sites
the rule doesn't track (.search() and new RegExp(dynamicString) aren't
in its tracked call/construction set). Investigated each empirically --
hand-tracing this exact bug class already produced one wrong conclusion
earlier in this phase (a detectEol design-doc arithmetic error), so
these were verified with real CRLF fixtures rather than reasoned about
on paper:

  - audit.cts (scanTodos): REAL bug, fixed. `bodyMatch.trim().split
    ('\n')[0]` leaked a trailing \r into a user-visible todo summary on
    CRLF input -- .trim() only strips the string's outer edges, not a
    \r sitting mid-string before the first bare \n. Now splitLines(...)
    [0].
  - phase.cts (cmdPhaseInsert, bullet-style branch): REAL bug, fixed.
    [^\n]* in targetBulletPattern swallowed a line's trailing \r on
    CRLF input, shifting the computed insert position to land INSIDE
    the \r\n pair; combined with a hardcoded '\n' bullet separator, a
    CRLF ROADMAP.md ended up with a mixed CRLF/LF result after an
    insert. Fixed with two coupled changes (either alone still
    corrupts, verified both ways): [^\r\n]* in the pattern, and the new
    bullet's leading terminator now comes from detectEol(rawContent).
  - roadmap.cts (cmdRoadmapAnnotateDependencies phase-boundary scan):
    investigated, genuinely safe, left untouched. The .search(/\n#{2,4}
    .../) boundary-finder and the [^\n]*-based heading match were
    empirically verified on a 3-phase CRLF fixture -- the only stray \r
    ends up at the tail of an intermediate phaseSection string that is
    only ever used for .test()-based idempotency checks, never for an
    exact-match comparison or written back to disk. No corruption on
    round-trip.

Every fix re-verified: npm run build:lib clean, npx eslint
'src/**/*.cts' --no-cache reports 0 problems (was 13), and each
fixed function's existing LF-input tests were spot-checked unchanged.

* fix(#3413): apply orthogonal review findings

Two isolated review engines (correctness + security) ran against the
full diff and found three majors, one real security issue, and several
disclosure-worthy minors. All fixed or explicitly disclosed with
evidence; nothing deferred.

MAJOR — detectEol's tie-break contradicted its own documented contract.
Code returned '\n' on a 1:1 crlf/bare-LF tie; every doc (design doc,
CONTEXT.md, the function's own comment) says ties resolve to '\r\n'.
The existing test masked this by reusing the same tie fixture the
buggy code happened to satisfy, rather than a genuine LF-majority
case. Root cause: an Edit attempted earlier in this phase to fix this
exact arithmetic error was blocked by the tier guard, and a later
dispatch was incorrectly told it had already landed. Fixed: condition
is now crlfCount >= bareLfCount; the test fixture corrected to a
genuine 2:1 majority, with a new explicit tie-case test.

MAJOR — phase.cts's cmdPhaseInsert built an EOL-aware bulletEntry via
detectEol(rawContent), justified by a comment claiming a hardcoded
'\n' corrupts a CRLF ROADMAP.md. False: this write goes through
platformWriteSync, whose normalizeContent/_normalizeMd unconditionally
converts CRLF->LF for any .md target — the templating was inert dead
code, erased before the file is ever written. Reverted to hardcoded
'\n', comment corrected to state the true reasoning. The separate
[^\n]* -> [^\r\n]* widening one function up (a real splice-position
fix, independent of final EOL) was kept.

MAJOR — roadmap-upgrade.cts's stated rationale for switching onto
splitLines/joinLines was wrong (both functions always agreed on line
representation, before and after — the claimed equality-check risk
never existed), and the change it justified introduced a real
regression: forcing every line onto one dominant terminator silently
rewrites untouched lines' EOL on a mixed-CRLF/LF ROADMAP.md. This
write path uses raw fs.writeFileSync, not platformWriteSync, so unlike
the phase.cts case above the regression is genuinely live.

Fixing this took two attempts. The first attempt (revert to
split('\n')/join('\n') plus a suppression comment) was correctly
blocked by an agent that discovered local/no-crlf-fragile-split is a
PROTECTED_RULES entry in tests/portability-rule-disable-ban.test.cjs —
a hard, out-of-band, ADR-1703-governed guardrail banning any
eslint-disable of this rule anywhere in src/**/*.cts. That agent also
detected and correctly disregarded an injected instruction that
appeared in tool output during a git operation, per this session's
untrusted-content policy. The actual fix: computeMigrationPlan
reverted to roadmapContent.split('\n') (confirmed lint-clean — the
rule's data-flow tracking only follows a variable's initializer, and
this one is declared empty then reassigned in a try block).
applyMigration's write-back now splices edits against the ORIGINAL
content string via indexOf('\n', pos) boundary-walking instead of a
full split/rejoin, so every untouched character — including every
line's own terminator — is copied byte-for-byte. A capture-group split
(/(\r\n|\n)/, preserving terminators inline) was tried first and
empirically confirmed to still trip the rule before this approach was
chosen instead.

MINOR (security) — roadmap.cts's cmdRoadmapAnnotateDependencies used
the STRING form of String#replace, so $&, $`, $', $1-$9 inside
must_haves.truths content (author-controlled) were interpreted as
replacement directives, splicing unrelated ROADMAP.md text into the
result. Fixed with the function-replacement form, which is never
pattern-interpreted. Verified before/after with the reviewer's exact
repro.

Also disclosed rather than silently left: test matrix row 31 (four
planned CRLF-materialized regression tests) was never implemented as
separate files — corrected to record the actual verification (a
manual --check run plus incidental existing coverage via each script's
normalizeLineEndings: normalizeEol alias). parseMustHavesBlock's LF
behavior was claimed byte-for-byte unchanged but the old
yaml.indexOf(blockMatch[0]) substring search could match an unrelated
earlier occurrence of the header text (e.g. inside a quoted value) —
the split-then-scan fix incidentally also closes this, a strict
improvement now recorded in the design doc rather than left implicit.

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

* fix(#3413): checkpoint 2 red — missing eslint ignore entry, RuleTester config error

Checkpoint 2 came back red with 5 failures on the reviewed sha, both
gaps genuinely undetectable by any local gate.

eslint.config.mjs was missing the 'gsd-core/bin/lib/text-lines.cjs'
ignores-list entry (ADR-457: generated .cjs artifacts are excluded from
direct type-aware linting). Phase 1's sibling entry (pattern.cjs) sits
two lines above it and was the exact precedent read while researching
the six-gate ripple for this module -- missed anyway. Caught by
tests/repo-invariants.test.cjs's bin/lib coverage-tracking test, which
only runs on the remote suite.

tests/no-crlf-fragile-split.rule.test.cjs's row-32 case specified both
`messageId` and `message` on the same RuleTester error assertion --
ESLint's RuleTester rejects that combination outright. This existed
since the test was first authored and was never caught locally: `npx
eslint` only lints the file's syntax, it does not execute RuleTester,
and local `node --test` is hard-blocked in this repo -- the assertion
had never actually RUN before this checkpoint. It was even present in
checkpoint 1's failure list, listed there as one of the "expected RED"
tests; I matched it against my expected-failures list by test NAME
only and never inspected the actual failure detail closely enough to
notice it was failing for the wrong reason (a RuleTester config error,
not the intended message-text mismatch). Fixed by keeping `message`
(the exact-text assertion the test exists to make) and dropping
`messageId`. Verified the crlfFragileSplit message string in
eslint-rules/no-crlf-fragile-split.cjs matches this assertion
character-for-character, and swept every other invalid case in the
file for the same double-specification bug (none found -- all
pre-existing cases use messageId alone).

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

* docs(#3413): add Fixed changeset for the #3360 CRLF parsing fix

The sole user-visible effect of this phase. No breaking-change label
or Changed fragment needed — ADR-3212's Backward Compatibility section
names the Node floor (Phase 1, already shipped) as the epic's only
breaking change; Phase 2 has none.

* chore(#3413): backfill changeset pr number to 3420

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 20:27:48 -04:00

1322 lines
58 KiB
TypeScript

/**
* Profile Output — profile rendering, questionnaire, and artifact generation
*
* Renders profiling analysis into user-facing artifacts:
* - write-profile: USER-PROFILE.md from analysis JSON
* - profile-questionnaire: fallback when no sessions available
* - generate-dev-preferences: dev-preferences.md command artifact
* - generate-claude-profile: Developer Profile section in CLAUDE.md
* - generate-claude-md: full CLAUDE.md with managed sections
*
* ADR-457 build-at-publish: the hand-written bin/lib/profile-output.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only strict types are added.
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import io = require('./io.cjs');
const { output, error } = io;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configLoader = require('./config-loader.cjs');
const { loadConfig } = configLoader;
import { platformReadSync as safeReadFile, platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
import { getGlobalSkillDir, getGlobalConfigDir } from './runtime-homes.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
import { resolveRuntimeNameFromCandidates, getProjectInstructionFile } from './runtime-name-policy.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
interface ProfilingOption {
label: string;
value: string;
rating: string;
}
interface ProfilingQuestion {
dimension: string;
header: string;
context: string;
question: string;
options: ProfilingOption[];
}
interface EvidenceEntry {
signal?: string;
quote?: string;
example?: string;
pattern?: string;
project?: string;
}
interface DimensionData {
rating?: string;
confidence?: string;
evidence_count?: number;
cross_project_consistent?: boolean | null;
evidence?: EvidenceEntry[];
evidence_quotes?: EvidenceEntry[];
summary?: string;
claude_instruction?: string;
}
interface AnalysisData {
profile_version?: string;
analyzed_at?: string;
data_source?: string;
projects_list?: string[];
projects_analyzed?: string[];
message_count?: number;
messages_analyzed?: number;
message_threshold?: string;
sensitive_excluded?: unknown[];
dimensions: Record<string, DimensionData>;
}
interface SectionResult {
content: string;
source: string;
linkPath?: string | null;
hasFallback: boolean;
}
interface CmdWriteProfileOptions {
input?: string;
output?: string;
}
interface CmdGenerateDevPreferencesOptions {
analysis?: string;
output?: string;
stack?: string;
}
interface CmdGenerateClaudeProfileOptions {
analysis?: string;
output?: string;
global?: boolean;
}
interface CmdGenerateClaudeMdOptions {
output?: string;
auto?: boolean;
// #1098: overwrite an existing instruction file that has no GSD section
// markers (a hand-crafted CLAUDE.md/AGENTS.md). Without it, such a file is
// left untouched rather than clobbered.
force?: boolean;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const DIMENSION_KEYS = [
'communication_style', 'decision_speed', 'explanation_depth',
'debugging_approach', 'ux_philosophy', 'vendor_philosophy',
'frustration_triggers', 'learning_style'
];
const PROFILING_QUESTIONS: ProfilingQuestion[] = [
{
dimension: 'communication_style',
header: 'Communication Style',
context: 'Think about the last few times you asked Claude to build or change something. How did you frame the request?',
question: 'When you ask Claude to build something, how much context do you typically provide?',
options: [
{ label: 'Minimal -- "fix the bug", "add dark mode", just say what\'s needed', value: 'a', rating: 'terse-direct' },
{ label: 'Some context -- explain what and why in a paragraph or two', value: 'b', rating: 'conversational' },
{ label: 'Detailed specs -- headers, numbered lists, problem analysis, constraints', value: 'c', rating: 'detailed-structured' },
{ label: 'It depends on the task -- simple tasks get short prompts, complex ones get detailed specs', value: 'd', rating: 'mixed' },
],
},
{
dimension: 'decision_speed',
header: 'Decision Making',
context: 'Think about times when Claude presented you with multiple options -- like choosing a library, picking an architecture, or selecting an approach.',
question: 'When Claude presents you with options, how do you typically decide?',
options: [
{ label: 'Pick quickly based on gut feeling or past experience', value: 'a', rating: 'fast-intuitive' },
{ label: 'Ask for a comparison table or pros/cons, then decide', value: 'b', rating: 'deliberate-informed' },
{ label: 'Research independently (read docs, check GitHub stars) before deciding', value: 'c', rating: 'research-first' },
{ label: 'Let Claude recommend -- I generally trust the suggestion', value: 'd', rating: 'delegator' },
],
},
{
dimension: 'explanation_depth',
header: 'Explanation Preferences',
context: 'Think about when Claude explains code it wrote or an approach it took. How much detail feels right?',
question: 'When Claude explains something, how much detail do you want?',
options: [
{ label: 'Just the code -- I\'ll read it and figure it out myself', value: 'a', rating: 'code-only' },
{ label: 'Brief explanation with the code -- a sentence or two about the approach', value: 'b', rating: 'concise' },
{ label: 'Detailed walkthrough -- explain the approach, trade-offs, and code structure', value: 'c', rating: 'detailed' },
{ label: 'Deep dive -- teach me the concepts behind it so I understand the fundamentals', value: 'd', rating: 'educational' },
],
},
{
dimension: 'debugging_approach',
header: 'Debugging Style',
context: 'Think about the last few times something broke in your code. How did you approach it with Claude?',
question: 'When something breaks, how do you typically approach debugging with Claude?',
options: [
{ label: 'Paste the error and say "fix it" -- get it working fast', value: 'a', rating: 'fix-first' },
{ label: 'Share the error plus context, ask Claude to diagnose what went wrong', value: 'b', rating: 'diagnostic' },
{ label: 'Investigate myself first, then ask Claude about my specific theories', value: 'c', rating: 'hypothesis-driven' },
{ label: 'Walk through the code together step by step to understand the issue', value: 'd', rating: 'collaborative' },
],
},
{
dimension: 'ux_philosophy',
header: 'UX Philosophy',
context: 'Think about user-facing features you have built recently. How did you balance functionality with design?',
question: 'When building user-facing features, what do you prioritize?',
options: [
{ label: 'Get it working first, polish the UI later (or never)', value: 'a', rating: 'function-first' },
{ label: 'Basic usability from the start -- nothing ugly, but no pixel-perfection', value: 'b', rating: 'pragmatic' },
{ label: 'Design and UX are as important as functionality -- I care about the experience', value: 'c', rating: 'design-conscious' },
{ label: 'I mostly build backend, CLI, or infrastructure -- UX is minimal', value: 'd', rating: 'backend-focused' },
],
},
{
dimension: 'vendor_philosophy',
header: 'Library & Vendor Choices',
context: 'Think about the last time you needed a library or service for a project. How did you go about choosing it?',
question: 'When choosing libraries or services, what is your typical approach?',
options: [
{ label: 'Use whatever Claude suggests -- speed matters more than the perfect choice', value: 'a', rating: 'pragmatic-fast' },
{ label: 'Prefer well-known, battle-tested options (React, PostgreSQL, Express)', value: 'b', rating: 'conservative' },
{ label: 'Research alternatives, read docs, compare benchmarks before committing', value: 'c', rating: 'thorough-evaluator' },
{ label: 'Strong opinions -- I already know what I like and I stick with it', value: 'd', rating: 'opinionated' },
],
},
{
dimension: 'frustration_triggers',
header: 'Frustration Triggers',
context: 'Think about moments when working with AI coding assistants that made you frustrated or annoyed.',
question: 'What frustrates you most when working with AI coding assistants?',
options: [
{ label: 'Doing things I didn\'t ask for -- adding features, refactoring code, scope creep', value: 'a', rating: 'scope-creep' },
{ label: 'Not following instructions precisely -- ignoring constraints or requirements I stated', value: 'b', rating: 'instruction-adherence' },
{ label: 'Over-explaining or being too verbose -- just give me the code and move on', value: 'c', rating: 'verbosity' },
{ label: 'Breaking working code while fixing something else -- regressions', value: 'd', rating: 'regression' },
],
},
{
dimension: 'learning_style',
header: 'Learning Preferences',
context: 'Think about encountering something new -- an unfamiliar library, a codebase you inherited, a concept you hadn\'t used before.',
question: 'When you encounter something new in your codebase, how do you prefer to learn about it?',
options: [
{ label: 'Read the code directly -- I figure things out by reading and experimenting', value: 'a', rating: 'self-directed' },
{ label: 'Ask Claude to explain the relevant parts to me', value: 'b', rating: 'guided' },
{ label: 'Read official docs and tutorials first, then try things', value: 'c', rating: 'documentation-first' },
{ label: 'See a working example, then modify it to understand how it works', value: 'd', rating: 'example-driven' },
],
},
];
const CLAUDE_INSTRUCTIONS: Record<string, Record<string, string>> = {
communication_style: {
'terse-direct': 'Keep responses concise and action-oriented. Skip lengthy preambles. Match this developer\'s direct style.',
'conversational': 'Use a natural conversational tone. Explain reasoning briefly alongside code. Engage with the developer\'s questions.',
'detailed-structured': 'Match this developer\'s structured communication: use headers for sections, numbered lists for steps, and acknowledge provided context before responding.',
'mixed': 'Adapt response detail to match the complexity of each request. Brief for simple tasks, detailed for complex ones.',
},
decision_speed: {
'fast-intuitive': 'Present a single strong recommendation with brief justification. Skip lengthy comparisons unless asked.',
'deliberate-informed': 'Present options in a structured comparison table with pros/cons. Let the developer make the final call.',
'research-first': 'Include links to docs, GitHub repos, or benchmarks when recommending tools. Support the developer\'s research process.',
'delegator': 'Make clear recommendations with confidence. Explain your reasoning briefly, but own the suggestion.',
},
explanation_depth: {
'code-only': 'Prioritize code output. Add comments inline rather than prose explanations. Skip walkthroughs unless asked.',
'concise': 'Pair code with a brief explanation (1-2 sentences) of the approach. Keep prose minimal.',
'detailed': 'Explain the approach, key trade-offs, and code structure alongside the implementation. Use headers to organize.',
'educational': 'Teach the underlying concepts and principles, not just the implementation. Relate new patterns to fundamentals.',
},
debugging_approach: {
'fix-first': 'Prioritize the fix. Show the corrected code first, then optionally explain what was wrong. Minimize diagnostic preamble.',
'diagnostic': 'Diagnose the root cause before presenting the fix. Explain what went wrong and why the fix addresses it.',
'hypothesis-driven': 'Engage with the developer\'s theories. Validate or refine their hypotheses before jumping to solutions.',
'collaborative': 'Walk through the debugging process step by step. Explain the investigation approach, not just the conclusion.',
},
ux_philosophy: {
'function-first': 'Focus on functionality and correctness. Keep UI minimal and functional. Skip design polish unless requested.',
'pragmatic': 'Build clean, usable interfaces without over-engineering. Apply basic design principles (spacing, alignment, contrast).',
'design-conscious': 'Invest in UX quality: thoughtful spacing, smooth transitions, responsive layouts. Treat design as a first-class concern.',
'backend-focused': 'Optimize for developer experience (clear APIs, good error messages, helpful CLI output) over visual design.',
},
vendor_philosophy: {
'pragmatic-fast': 'Suggest libraries quickly based on popularity and reliability. Don\'t over-analyze choices for non-critical dependencies.',
'conservative': 'Recommend well-established, widely-adopted tools with strong community support. Avoid bleeding-edge options.',
'thorough-evaluator': 'Compare alternatives with specific metrics (bundle size, GitHub stars, maintenance activity). Support informed decisions.',
'opinionated': 'Respect the developer\'s existing tool preferences. Ask before suggesting alternatives to their preferred stack.',
},
frustration_triggers: {
'scope-creep': 'Do exactly what is asked -- nothing more. Never add unrequested features, refactoring, or "improvements". Ask before expanding scope.',
'instruction-adherence': 'Follow instructions precisely. Re-read constraints before responding. If requirements conflict, flag the conflict rather than silently choosing.',
'verbosity': 'Be concise. Lead with code, follow with brief explanation only if needed. Avoid restating the problem or unnecessary context.',
'regression': 'Before modifying working code, verify the change is safe. Run existing tests mentally. Flag potential regression risks explicitly.',
},
learning_style: {
'self-directed': 'Point to relevant code sections and let the developer explore. Add signposts (file paths, function names) rather than full explanations.',
'guided': 'Explain concepts in context of the developer\'s codebase. Use their actual code as examples when teaching.',
'documentation-first': 'Link to official documentation and relevant sections. Structure explanations like reference material.',
'example-driven': 'Lead with working code examples. Show a minimal example first, then explain how to extend or modify it.',
},
};
// CLAUDE.md fallback / placeholder text — runtime-aware so emitted slash
// commands route correctly under the active install (#3584). The values must
// be computed per-call rather than at module load because the slash form
// depends on the runtime resolved from the project's config/env.
function buildClaudeMdFallbacks(runtime: unknown): Record<string, string> {
return {
project: `Project not yet initialized. Run ${String(formatGsdSlash('new-project', runtime))} to set up.`,
stack: 'Technology stack not yet documented. Will populate after codebase mapping or first phase.',
conventions: 'Conventions not yet established. Will populate as patterns emerge during development.',
architecture: 'Architecture not yet mapped. Follow existing patterns found in the codebase.',
skills: 'No project skills found. Add skills to any of: `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, `.github/skills/`, or `.codex/skills/` with a `SKILL.md` index file.',
};
}
// Directories where project skills may live (checked in order)
const SKILL_SEARCH_DIRS = ['.claude/skills', '.agents/skills', '.cursor/skills', '.github/skills', '.codex/skills'];
function buildClaudeMdWorkflowEnforcement(runtime: unknown): string {
return [
'Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.',
'',
'Use these entry points:',
`- \`${String(formatGsdSlash('quick', runtime))}\` for small fixes, doc updates, and ad-hoc tasks`,
`- \`${String(formatGsdSlash('debug', runtime))}\` for investigation and bug fixing`,
`- \`${String(formatGsdSlash('execute-phase', runtime))}\` for planned phase work`,
'',
'Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it.',
].join('\n');
}
function buildClaudeMdProfilePlaceholder(runtime: unknown): string {
return [
'<!-- GSD:profile-start -->',
'## Developer Profile',
'',
`> Profile not yet configured. Run \`${String(formatGsdSlash('profile-user', runtime))}\` to generate your developer profile.`,
'> This section is managed by `generate-claude-profile` -- do not edit manually.',
'<!-- GSD:profile-end -->',
].join('\n');
}
// ─── Helper Functions ─────────────────────────────────────────────────────────
function isAmbiguousAnswer(dimension: string, value: string): boolean {
if (dimension === 'communication_style' && value === 'd') return true;
const question = PROFILING_QUESTIONS.find(q => q.dimension === dimension);
if (!question) return false;
const option = question.options.find(o => o.value === value);
if (!option) return false;
return option.rating === 'mixed';
}
function generateClaudeInstruction(dimension: string, rating: string): string {
const dimInstructions = CLAUDE_INSTRUCTIONS[dimension];
if (dimInstructions && dimInstructions[rating]) {
return dimInstructions[rating];
}
return `Adapt to this developer's ${dimension.replace(/_/g, ' ')} preference: ${rating}.`;
}
function extractSectionContent(fileContent: string, sectionName: string): string | null {
const startMarker = `<!-- GSD:${sectionName}-start`;
const endMarker = `<!-- GSD:${sectionName}-end -->`;
const startIdx = fileContent.indexOf(startMarker);
const endIdx = fileContent.indexOf(endMarker);
if (startIdx === -1 || endIdx === -1) return null;
const startTagEnd = fileContent.indexOf('-->', startIdx);
if (startTagEnd === -1) return null;
return fileContent.substring(startTagEnd + 3, endIdx);
}
function buildSection(sectionName: string, sourceFile: string, content: string): string {
return [
`<!-- GSD:${sectionName}-start source:${sourceFile} -->`,
content,
`<!-- GSD:${sectionName}-end -->`,
].join('\n');
}
function updateSection(fileContent: string, sectionName: string, newContent: string): { content: string; action: string } {
const startMarker = `<!-- GSD:${sectionName}-start`;
const endMarker = `<!-- GSD:${sectionName}-end -->`;
const startIdx = fileContent.indexOf(startMarker);
const endIdx = fileContent.indexOf(endMarker);
if (startIdx !== -1 && endIdx !== -1) {
const before = fileContent.substring(0, startIdx);
const after = fileContent.substring(endIdx + endMarker.length);
return { content: before + newContent + after, action: 'replaced' };
}
return { content: fileContent.trimEnd() + '\n\n' + newContent + '\n', action: 'appended' };
}
function detectManualEdit(fileContent: string, sectionName: string, expectedContent: string): boolean {
const currentContent = extractSectionContent(fileContent, sectionName);
if (currentContent === null) return false;
const normalize = (s: string) => s.trim().replace(/\n{3,}/g, '\n\n');
return normalize(currentContent) !== normalize(expectedContent);
}
function extractMarkdownSection(content: string | null, sectionName: string): string | null {
if (!content) return null;
const lines = content.split('\n');
let capturing = false;
const result: string[] = [];
const headingPattern = new RegExp(`^## ${sectionName}\\s*$`);
for (const line of lines) {
if (headingPattern.test(line)) {
capturing = true;
result.push(line);
continue;
}
if (capturing && /^## /.test(line)) break;
if (capturing) result.push(line);
}
return result.length > 0 ? result.join('\n').trim() : null;
}
// ─── CLAUDE.md Section Generators ─────────────────────────────────────────────
function generateProjectSection(cwd: string): SectionResult {
const fallbacks = buildClaudeMdFallbacks(resolveRuntime(cwd));
const projectPath = path.join(cwd, '.planning', 'PROJECT.md');
const content = safeReadFile(projectPath);
if (!content) {
return { content: fallbacks['project'], source: 'PROJECT.md', linkPath: null, hasFallback: true };
}
const parts: string[] = [];
const h1Match = content.match(/^# (.+)$/m);
if (h1Match) parts.push(`**${h1Match[1]}**`);
const whatThisIs = extractMarkdownSection(content, 'What This Is');
if (whatThisIs) {
const body = whatThisIs.replace(/^## What This Is\s*/i, '').trim();
if (body) parts.push(body);
}
const coreValue = extractMarkdownSection(content, 'Core Value');
if (coreValue) {
const body = coreValue.replace(/^## Core Value\s*/i, '').trim();
if (body) parts.push(`**Core Value:** ${body}`);
}
const constraints = extractMarkdownSection(content, 'Constraints');
if (constraints) {
const body = constraints.replace(/^## Constraints\s*/i, '').trim();
if (body) parts.push(`### Constraints\n\n${body}`);
}
if (parts.length === 0) {
return { content: fallbacks['project'], source: 'PROJECT.md', linkPath: null, hasFallback: true };
}
return { content: parts.join('\n\n'), source: 'PROJECT.md', linkPath: '.planning/PROJECT.md', hasFallback: false };
}
function generateStackSection(cwd: string): SectionResult {
const fallbacks = buildClaudeMdFallbacks(resolveRuntime(cwd));
const codebasePath = path.join(cwd, '.planning', 'codebase', 'STACK.md');
const researchPath = path.join(cwd, '.planning', 'research', 'STACK.md');
let content = safeReadFile(codebasePath);
let source = 'codebase/STACK.md';
let linkPath = '.planning/codebase/STACK.md';
if (!content) {
content = safeReadFile(researchPath);
source = 'research/STACK.md';
linkPath = '.planning/research/STACK.md';
}
if (!content) {
return { content: fallbacks['stack'], source: 'STACK.md', linkPath: null, hasFallback: true };
}
const lines = content.split('\n');
const summaryLines: string[] = [];
let inTable = false;
for (const line of lines) {
if (line.startsWith('#')) {
if (!line.startsWith('# ') || summaryLines.length > 0) summaryLines.push(line);
continue;
}
if (line.startsWith('|')) { inTable = true; summaryLines.push(line); continue; }
if (inTable && line.trim() === '') inTable = false;
if (line.startsWith('- ') || line.startsWith('* ')) summaryLines.push(line);
}
const summary = summaryLines.length > 0 ? summaryLines.join('\n') : content.trim();
return { content: summary, source, linkPath, hasFallback: false };
}
function generateConventionsSection(cwd: string): SectionResult {
const fallbacks = buildClaudeMdFallbacks(resolveRuntime(cwd));
const conventionsPath = path.join(cwd, '.planning', 'codebase', 'CONVENTIONS.md');
const content = safeReadFile(conventionsPath);
if (!content) {
return { content: fallbacks['conventions'], source: 'CONVENTIONS.md', linkPath: null, hasFallback: true };
}
const lines = content.split('\n');
const summaryLines: string[] = [];
for (const line of lines) {
if (line.startsWith('#')) { if (!line.startsWith('# ')) summaryLines.push(line); continue; }
if (line.startsWith('- ') || line.startsWith('* ') || line.startsWith('|')) summaryLines.push(line);
}
const summary = summaryLines.length > 0 ? summaryLines.join('\n') : content.trim();
return { content: summary, source: 'CONVENTIONS.md', linkPath: '.planning/codebase/CONVENTIONS.md', hasFallback: false };
}
function generateArchitectureSection(cwd: string): SectionResult {
const fallbacks = buildClaudeMdFallbacks(resolveRuntime(cwd));
const architecturePath = path.join(cwd, '.planning', 'codebase', 'ARCHITECTURE.md');
const content = safeReadFile(architecturePath);
if (!content) {
return { content: fallbacks['architecture'], source: 'ARCHITECTURE.md', linkPath: null, hasFallback: true };
}
const lines = content.split('\n');
const summaryLines: string[] = [];
for (const line of lines) {
if (line.startsWith('#')) { if (!line.startsWith('# ')) summaryLines.push(line); continue; }
if (line.startsWith('- ') || line.startsWith('* ') || line.startsWith('|') || line.startsWith('```')) summaryLines.push(line);
}
const summary = summaryLines.length > 0 ? summaryLines.join('\n') : content.trim();
return { content: summary, source: 'ARCHITECTURE.md', linkPath: '.planning/codebase/ARCHITECTURE.md', hasFallback: false };
}
function generateWorkflowSection(cwd: string): SectionResult {
return {
content: buildClaudeMdWorkflowEnforcement(resolveRuntime(cwd)),
source: 'GSD defaults',
linkPath: null,
hasFallback: false,
};
}
/**
* Discover project skills from standard directories and extract frontmatter
* (name + description) for each. Returns a table summary for CLAUDE.md so
* agents know which skills are available at session startup (Layer 1 discovery).
*/
function generateSkillsSection(cwd: string): SectionResult {
const fallbacks = buildClaudeMdFallbacks(resolveRuntime(cwd));
const discovered: Array<{ name: string; description: string; path: string }> = [];
for (const dir of SKILL_SEARCH_DIRS) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(absDir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
// Skip GSD's own installed skills — only surface project-specific skills
if (entry.name.startsWith('gsd-')) continue;
const skillMdPath = path.join(absDir, entry.name, 'SKILL.md');
if (!fs.existsSync(skillMdPath)) continue;
const content = safeReadFile(skillMdPath);
if (!content) continue;
const frontmatter = extractSkillFrontmatter(content);
const name = frontmatter.name || entry.name;
const description = frontmatter.description || '';
// Avoid duplicates when same skill dir is symlinked from multiple locations
if (discovered.some(s => s.name === name)) continue;
discovered.push({ name, description, path: `${dir}/${entry.name}` });
}
}
if (discovered.length === 0) {
return { content: fallbacks['skills'], source: 'skills/', hasFallback: true };
}
const lines = ['| Skill | Description | Path |', '|-------|-------------|------|'];
for (const skill of discovered) {
// Sanitize table cell content (escape backslashes first, then pipes)
const desc = skill.description.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\n/g, ' ').trim();
const safeName = skill.name.replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
lines.push(`| ${safeName} | ${desc} | \`${skill.path}/SKILL.md\` |`);
}
return { content: lines.join('\n'), source: 'skills/', hasFallback: false };
}
/**
* Extract name and description from YAML-like frontmatter in a SKILL.md file.
* Handles multi-line description values (continuation lines indented with spaces).
*/
function extractSkillFrontmatter(content: string): { name: string; description: string } {
const result = { name: '', description: '' };
const fmMatch = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/);
if (!fmMatch) return result;
const fmBlock = fmMatch[1];
const lines = fmBlock.split('\n');
let currentKey = '';
for (const line of lines) {
// Top-level key: value
const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)/);
if (kvMatch) {
currentKey = kvMatch[1];
const value = kvMatch[2].trim();
if (currentKey === 'name') result.name = value;
if (currentKey === 'description') result.description = value;
continue;
}
// Continuation line (indented) for multi-line values
if (currentKey === 'description' && /^\s+/.test(line)) {
result.description += ' ' + line.trim();
} else {
currentKey = '';
}
}
return result;
}
// ─── Commands ─────────────────────────────────────────────────────────────────
function cmdWriteProfile(cwd: string, options: CmdWriteProfileOptions, raw: boolean): void {
if (!options.input) {
error('--input <analysis-json-path> is required');
}
let analysisPath = options.input!;
if (!path.isAbsolute(analysisPath)) analysisPath = path.join(cwd, analysisPath);
if (!fs.existsSync(analysisPath)) error(`Analysis file not found: ${analysisPath}`);
let analysis: AnalysisData;
const analysisRaw = safeReadFile(analysisPath);
try {
if (analysisRaw === null) throw new Error(`analysis file not found: ${analysisPath}`);
analysis = JSON.parse(analysisRaw) as AnalysisData;
} catch (err) {
error(`Failed to parse analysis JSON: ${(err as Error).message}`);
}
if (!analysis!.dimensions || typeof analysis!.dimensions !== 'object') {
error('Analysis JSON must contain a "dimensions" object');
}
if (!analysis!.profile_version) {
error('Analysis JSON must contain "profile_version"');
}
const SENSITIVE_PATTERNS = [
/sk-[a-zA-Z0-9]{20,}/g,
/Bearer\s+[a-zA-Z0-9._-]+/gi,
/password\s*[:=]\s*\S+/gi,
/secret\s*[:=]\s*\S+/gi,
/token\s*[:=]\s*\S+/gi,
/api[_-]?key\s*[:=]\s*\S+/gi,
/\/Users\/[a-zA-Z0-9._-]+\//g,
/\/home\/[a-zA-Z0-9._-]+\//g,
/ghp_[a-zA-Z0-9]{36}/g,
/gho_[a-zA-Z0-9]{36}/g,
/xoxb-[a-zA-Z0-9-]+/g,
];
let redactedCount = 0;
function redactSensitive(text: unknown): unknown {
if (typeof text !== 'string') return text;
let result = text;
for (const pattern of SENSITIVE_PATTERNS) {
pattern.lastIndex = 0;
const matches = result.match(pattern);
if (matches) {
redactedCount += matches.length;
result = result.replace(pattern, '[REDACTED]');
}
}
return result;
}
for (const dimKey of Object.keys(analysis!.dimensions)) {
const dim = analysis!.dimensions[dimKey];
if (dim.evidence && Array.isArray(dim.evidence)) {
for (let i = 0; i < dim.evidence.length; i++) {
const ev = dim.evidence[i];
if (ev.quote) ev.quote = redactSensitive(ev.quote) as string;
if (ev.example) ev.example = redactSensitive(ev.example) as string;
if (ev.signal) ev.signal = redactSensitive(ev.signal) as string;
}
}
}
if (redactedCount > 0) {
process.stderr.write(`Sensitive content redacted: ${redactedCount} pattern(s) removed from evidence quotes\n`);
}
const templatePath = path.join(__dirname, '..', '..', 'templates', 'user-profile.md');
if (!fs.existsSync(templatePath)) error(`Template not found: ${templatePath}`);
let template = fs.readFileSync(templatePath, 'utf-8');
const dimensionLabels: Record<string, string> = {
communication_style: 'Communication',
decision_speed: 'Decisions',
explanation_depth: 'Explanations',
debugging_approach: 'Debugging',
ux_philosophy: 'UX Philosophy',
vendor_philosophy: 'Vendor Philosophy',
frustration_triggers: 'Frustration Triggers',
learning_style: 'Learning Style',
};
const summaryLines: string[] = [];
let highCount = 0, mediumCount = 0, lowCount = 0, dimensionsScored = 0;
for (const dimKey of DIMENSION_KEYS) {
const dim = analysis!.dimensions[dimKey];
if (!dim) continue;
const conf = (dim.confidence || '').toUpperCase();
if (conf === 'HIGH' || conf === 'MEDIUM' || conf === 'LOW') dimensionsScored++;
if (conf === 'HIGH') {
highCount++;
if (dim.claude_instruction) summaryLines.push(`- **${dimensionLabels[dimKey] || dimKey}:** ${dim.claude_instruction} (HIGH)`);
} else if (conf === 'MEDIUM') {
mediumCount++;
if (dim.claude_instruction) summaryLines.push(`- **${dimensionLabels[dimKey] || dimKey}:** ${dim.claude_instruction} (MEDIUM)`);
} else if (conf === 'LOW') {
lowCount++;
}
}
const summaryInstructions = summaryLines.length > 0
? summaryLines.join('\n')
: '- No high or medium confidence dimensions scored yet.';
template = template.replace(/\{\{generated_at\}\}/g, new Date().toISOString());
template = template.replace(/\{\{data_source\}\}/g, analysis!.data_source || 'session_analysis');
template = template.replace(/\{\{projects_list\}\}/g, (analysis!.projects_list || analysis!.projects_analyzed || []).join(', '));
template = template.replace(/\{\{message_count\}\}/g, String(analysis!.message_count || analysis!.messages_analyzed || 0));
template = template.replace(/\{\{summary_instructions\}\}/g, summaryInstructions);
template = template.replace(/\{\{profile_version\}\}/g, analysis!.profile_version!);
template = template.replace(/\{\{projects_count\}\}/g, String((analysis!.projects_list || analysis!.projects_analyzed || []).length));
template = template.replace(/\{\{dimensions_scored\}\}/g, String(dimensionsScored));
template = template.replace(/\{\{high_confidence_count\}\}/g, String(highCount));
template = template.replace(/\{\{medium_confidence_count\}\}/g, String(mediumCount));
template = template.replace(/\{\{low_confidence_count\}\}/g, String(lowCount));
template = template.replace(/\{\{sensitive_excluded_summary\}\}/g,
redactedCount > 0 ? `${redactedCount} pattern(s) redacted` : 'None detected');
for (const dimKey of DIMENSION_KEYS) {
const dim = analysis!.dimensions[dimKey] || {};
const rating = dim.rating || 'UNSCORED';
const confidence = dim.confidence || 'UNSCORED';
const instruction = dim.claude_instruction || 'No strong preference detected. Ask the developer when this dimension is relevant.';
const summary = dim.summary || '';
let evidenceBlock = '';
const evidenceArr = dim.evidence_quotes || dim.evidence;
if (evidenceArr && Array.isArray(evidenceArr) && evidenceArr.length > 0) {
const evidenceLines = evidenceArr.map(ev => {
const signal = ev.signal || ev.pattern || '';
const quote = ev.quote || ev.example || '';
const project = ev.project || 'unknown';
return `- **Signal:** ${signal} / **Example:** "${quote}" -- project: ${project}`;
});
evidenceBlock = evidenceLines.join('\n');
} else {
evidenceBlock = '- No evidence collected for this dimension.';
}
template = template.replace(new RegExp(`\\{\\{${dimKey}\\.rating\\}\\}`, 'g'), rating);
template = template.replace(new RegExp(`\\{\\{${dimKey}\\.confidence\\}\\}`, 'g'), confidence);
template = template.replace(new RegExp(`\\{\\{${dimKey}\\.claude_instruction\\}\\}`, 'g'), instruction);
template = template.replace(new RegExp(`\\{\\{${dimKey}\\.summary\\}\\}`, 'g'), summary);
template = template.replace(new RegExp(`\\{\\{${dimKey}\\.evidence\\}\\}`, 'g'), evidenceBlock);
}
let outputPath = options.output;
if (!outputPath) {
// #1114: resolve the ACTIVE runtime's config home so the profile is written
// where the runtime's own workflows look for it. Previously this hardcoded the
// Claude home, so a Codex run wrote the profile under the Claude config dir
// while Codex advisor-mode (installed under the Codex home) checked the Codex
// dir and never found it. Mirrors cmdGenerateDevPreferences' runtime resolution.
let effectiveRuntime = 'claude';
try {
const config = loadConfig(cwd);
effectiveRuntime = resolveRuntimeNameFromCandidates(
process.env['GSD_RUNTIME'],
config['runtime'],
'claude'
) || 'claude';
} catch {
effectiveRuntime = resolveRuntimeNameFromCandidates(
process.env['GSD_RUNTIME'],
'claude'
) || 'claude';
}
// path.join (not a string literal) keeps the cline-install leaked-path lint quiet.
outputPath = path.join(getGlobalConfigDir(effectiveRuntime), 'gsd-core', 'USER-PROFILE.md');
} else if (!path.isAbsolute(outputPath)) {
outputPath = path.join(cwd, outputPath);
}
platformEnsureDir(path.dirname(outputPath));
platformWriteSync(outputPath, template);
const result = {
profile_path: outputPath,
dimensions_scored: dimensionsScored,
high_confidence: highCount,
medium_confidence: mediumCount,
low_confidence: lowCount,
sensitive_redacted: redactedCount,
source: analysis!.data_source || 'session_analysis',
};
output(result, raw, undefined);
}
function cmdProfileQuestionnaire(options: { answers?: string }, raw: boolean): void {
if (!options.answers) {
const questionsOutput = {
mode: 'interactive',
questions: PROFILING_QUESTIONS.map(q => ({
dimension: q.dimension,
header: q.header,
context: q.context,
question: q.question,
options: q.options.map(o => ({ label: o.label, value: o.value })),
})),
};
output(questionsOutput, raw, undefined);
return;
}
const answerValues = options.answers.split(',').map(a => a.trim());
if (answerValues.length !== PROFILING_QUESTIONS.length) {
error(`Expected ${PROFILING_QUESTIONS.length} answers (comma-separated), got ${answerValues.length}`);
}
const analysis: AnalysisData = {
profile_version: '1.0',
analyzed_at: new Date().toISOString(),
data_source: 'questionnaire',
projects_analyzed: [],
messages_analyzed: 0,
message_threshold: 'questionnaire',
sensitive_excluded: [],
dimensions: {},
};
for (let i = 0; i < PROFILING_QUESTIONS.length; i++) {
const question = PROFILING_QUESTIONS[i];
const answerValue = answerValues[i];
const selectedOption = question.options.find(o => o.value === answerValue);
if (!selectedOption) {
error(`Invalid answer "${answerValue}" for ${question.dimension}. Valid values: ${question.options.map(o => o.value).join(', ')}`);
}
const ambiguous = isAmbiguousAnswer(question.dimension, answerValue);
analysis.dimensions[question.dimension] = {
rating: selectedOption!.rating,
confidence: ambiguous ? 'LOW' : 'MEDIUM',
evidence_count: 1,
cross_project_consistent: null,
evidence: [{
signal: 'Self-reported via questionnaire',
quote: selectedOption!.label,
project: 'N/A (questionnaire)',
}],
summary: `Developer self-reported as ${selectedOption!.rating} for ${question.header.toLowerCase()}.`,
claude_instruction: generateClaudeInstruction(question.dimension, selectedOption!.rating),
};
}
output(analysis, raw, undefined);
}
function cmdGenerateDevPreferences(cwd: string, options: CmdGenerateDevPreferencesOptions, raw: boolean): void {
if (!options.analysis) error('--analysis <path> is required');
let analysisPath = options.analysis!;
if (!path.isAbsolute(analysisPath)) analysisPath = path.join(cwd, analysisPath);
if (!fs.existsSync(analysisPath)) error(`Analysis file not found: ${analysisPath}`);
let analysis: AnalysisData;
const analysisRaw = safeReadFile(analysisPath);
try {
if (analysisRaw === null) throw new Error(`analysis file not found: ${analysisPath}`);
analysis = JSON.parse(analysisRaw) as AnalysisData;
} catch (err) {
error(`Failed to parse analysis JSON: ${(err as Error).message}`);
}
if (!analysis!.dimensions || typeof analysis!.dimensions !== 'object') {
error('Analysis JSON must contain a "dimensions" object');
}
const devPrefLabels: Record<string, string> = {
communication_style: 'Communication',
decision_speed: 'Decision Support',
explanation_depth: 'Explanations',
debugging_approach: 'Debugging',
ux_philosophy: 'UX Approach',
vendor_philosophy: 'Library & Tool Choices',
frustration_triggers: 'Boundaries',
learning_style: 'Learning Support',
};
const templatePath = path.join(__dirname, '..', '..', 'templates', 'dev-preferences.md');
if (!fs.existsSync(templatePath)) error(`Template not found: ${templatePath}`);
let template = fs.readFileSync(templatePath, 'utf-8');
const directiveLines: string[] = [];
const dimensionsIncluded: string[] = [];
for (const dimKey of DIMENSION_KEYS) {
const dim = analysis!.dimensions[dimKey];
if (!dim) continue;
const label = devPrefLabels[dimKey] || dimKey;
const confidence = dim.confidence || 'UNSCORED';
let instruction = dim.claude_instruction;
if (!instruction) {
const lookup = CLAUDE_INSTRUCTIONS[dimKey];
if (lookup && dim.rating && lookup[dim.rating]) {
instruction = lookup[dim.rating];
} else {
instruction = `Adapt to this developer's ${dimKey.replace(/_/g, ' ')} preference.`;
}
}
directiveLines.push(`### ${label}\n${instruction} (${confidence} confidence)\n`);
dimensionsIncluded.push(dimKey);
}
const directivesBlock = directiveLines.join('\n').trim();
template = template.replace(/\{\{behavioral_directives\}\}/g, directivesBlock);
template = template.replace(/\{\{generated_at\}\}/g, new Date().toISOString());
template = template.replace(/\{\{data_source\}\}/g, analysis!.data_source || 'session_analysis');
let stackBlock: string;
if (analysis!.data_source === 'questionnaire') {
stackBlock = `Stack preferences not available (questionnaire-only profile). Run \`${String(formatGsdSlash('profile-user', resolveRuntime(cwd)))} --refresh\` with session data to populate.`;
} else if (options.stack) {
stackBlock = options.stack;
} else {
stackBlock = 'Stack preferences will be populated from session analysis.';
}
template = template.replace(/\{\{stack_preferences\}\}/g, stackBlock);
// #2973: v1.39.0's skills-only migration removed the legacy
// commands/gsd subdirectory in favor of skills/<skill>/SKILL.md under
// the runtime config dir. This writer was missed in the migration
// (PR #1540 targeted GSD-shipped command files; dev-preferences is a
// runtime-generated user artifact). Default now points at the skills/
// location so /gsd:profile-user --refresh stops re-creating the legacy
// directory. The path is constructed via path.join (not a literal
// string) so the cline-install leaked-path lint does not flag it.
let outputPath = options.output;
if (!outputPath) {
let effectiveRuntime = 'claude';
try {
const config = loadConfig(cwd);
effectiveRuntime = resolveRuntimeNameFromCandidates(
process.env['GSD_RUNTIME'],
config['runtime'],
'claude'
) || 'claude';
} catch {
effectiveRuntime = resolveRuntimeNameFromCandidates(
process.env['GSD_RUNTIME'],
'claude'
) || 'claude';
}
const skillDir = getGlobalSkillDir(effectiveRuntime, 'gsd-dev-preferences');
if (!skillDir) {
error(`Runtime "${effectiveRuntime}" does not use a skills directory; pass --output to choose a path explicitly.`);
}
outputPath = path.join(skillDir!, 'SKILL.md');
} else if (!path.isAbsolute(outputPath)) {
outputPath = path.join(cwd, outputPath);
}
platformEnsureDir(path.dirname(outputPath));
platformWriteSync(outputPath, template);
const result = {
command_path: outputPath,
command_name: formatGsdSlash('dev-preferences', resolveRuntime(cwd)),
dimensions_included: dimensionsIncluded,
source: analysis!.data_source || 'session_analysis',
};
output(result, raw, undefined);
}
function cmdGenerateClaudeProfile(cwd: string, options: CmdGenerateClaudeProfileOptions, raw: boolean): void {
if (!options.analysis) error('--analysis <path> is required');
let analysisPath = options.analysis!;
if (!path.isAbsolute(analysisPath)) analysisPath = path.join(cwd, analysisPath);
if (!fs.existsSync(analysisPath)) error(`Analysis file not found: ${analysisPath}`);
let analysis: AnalysisData;
const analysisRaw = safeReadFile(analysisPath);
try {
if (analysisRaw === null) throw new Error(`analysis file not found: ${analysisPath}`);
analysis = JSON.parse(analysisRaw) as AnalysisData;
} catch (err) {
error(`Failed to parse analysis JSON: ${(err as Error).message}`);
}
if (!analysis!.dimensions || typeof analysis!.dimensions !== 'object') {
error('Analysis JSON must contain a "dimensions" object');
}
const profileLabels: Record<string, string> = {
communication_style: 'Communication',
decision_speed: 'Decisions',
explanation_depth: 'Explanations',
debugging_approach: 'Debugging',
ux_philosophy: 'UX Philosophy',
vendor_philosophy: 'Vendor Choices',
frustration_triggers: 'Frustrations',
learning_style: 'Learning',
};
const dataSource = analysis!.data_source || 'session_analysis';
const tableRows: string[] = [];
const directiveLines: string[] = [];
const dimensionsIncluded: string[] = [];
for (const dimKey of DIMENSION_KEYS) {
const dim = analysis!.dimensions[dimKey];
if (!dim) continue;
const label = profileLabels[dimKey] || dimKey;
const rating = dim.rating || 'UNSCORED';
const confidence = dim.confidence || 'UNSCORED';
tableRows.push(`| ${label} | ${rating} | ${confidence} |`);
let instruction = dim.claude_instruction;
if (!instruction) {
const lookup = CLAUDE_INSTRUCTIONS[dimKey];
if (lookup && dim.rating && lookup[dim.rating]) {
instruction = lookup[dim.rating];
} else {
instruction = `Adapt to this developer's ${dimKey.replace(/_/g, ' ')} preference.`;
}
}
directiveLines.push(`- **${label}:** ${instruction}`);
dimensionsIncluded.push(dimKey);
}
const sectionLines = [
'<!-- GSD:profile-start -->',
'## Developer Profile',
'',
`> Generated by GSD from ${dataSource}. Run \`${String(formatGsdSlash('profile-user', resolveRuntime(cwd)))}\` to update.`,
'',
'| Dimension | Rating | Confidence |',
'|-----------|--------|------------|',
...tableRows,
'',
'**Directives:**',
...directiveLines,
'<!-- GSD:profile-end -->',
];
const sectionContent = sectionLines.join('\n');
// #2565: resolve target through effective runtime policy instead of
// hardcoded .claude/CLAUDE.md. Mirrors the #3163 fix applied to
// cmdGenerateClaudeMd above; that fix diverged when it didn't propagate
// here, leaving /gsd-profile-user writing Claude files on Codex installs.
// - Project scope: getProjectInstructionFile(runtime) is the single source
// of truth (AGENTS.md for codex/opencode/kilo/kimi/unknown; GEMINI.md for
// antigravity; .github/copilot-instructions.md for copilot).
// - Global scope: ~/.<config-home>/<instruction-basename>, derived from
// getGlobalConfigDir + basename(getProjectInstructionFile), so codex lands
// at ~/.codex/AGENTS.md. Claude global is preserved byte-for-byte (no
// env-var drift beyond the prior hardcoded path).
// - GSD_RUNTIME env var takes precedence over config.runtime (mirrors the
// #3163 env-precedence contract). Non-claude always wins over a stale
// claude_md_path (#3163 rationale: an AGENTS-native project must never
// write to CLAUDE.md even if a prior Claude setup left claude_md_path).
let config: Record<string, unknown> = {};
try {
config = loadConfig(cwd);
} catch { /* use defaults */ }
const effectiveRuntime = resolveRuntimeNameFromCandidates(
process.env['GSD_RUNTIME'],
config['runtime'],
);
const isClaudeRuntime = !effectiveRuntime || effectiveRuntime === 'claude';
let targetPath: string;
if (options.global) {
if (isClaudeRuntime) {
targetPath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
} else {
targetPath = path.join(
getGlobalConfigDir(effectiveRuntime),
path.basename(getProjectInstructionFile(effectiveRuntime)),
);
}
} else if (options.output) {
targetPath = path.isAbsolute(options.output) ? options.output : path.join(cwd, options.output);
} else {
// Read claude_md_path from config; #1098 default is .claude/CLAUDE.md
// (kept consistent with cmdGenerateClaudeMd so the profile section and the
// managed sections land in the same file on a config-less project).
let configClaudeMdPath = '.claude/CLAUDE.md';
if (config['claude_md_path']) configClaudeMdPath = config['claude_md_path'] as string;
if (!isClaudeRuntime) {
configClaudeMdPath = getProjectInstructionFile(effectiveRuntime);
}
targetPath = path.isAbsolute(configClaudeMdPath) ? configClaudeMdPath : path.join(cwd, configClaudeMdPath);
}
let action: string;
let existingContent = safeReadFile(targetPath);
if (existingContent !== null) {
const startMarker = '<!-- GSD:profile-start -->';
const endMarker = '<!-- GSD:profile-end -->';
const startIdx = existingContent.indexOf(startMarker);
const endIdx = existingContent.indexOf(endMarker);
if (startIdx !== -1 && endIdx !== -1) {
const before = existingContent.substring(0, startIdx);
const after = existingContent.substring(endIdx + endMarker.length);
existingContent = before + sectionContent + after;
action = 'updated';
} else {
existingContent = existingContent.trimEnd() + '\n\n' + sectionContent + '\n';
action = 'appended';
}
platformWriteSync(targetPath, existingContent);
} else {
platformEnsureDir(path.dirname(targetPath));
platformWriteSync(targetPath, sectionContent + '\n');
action = 'created';
}
const result = {
claude_md_path: targetPath,
action,
dimensions_included: dimensionsIncluded,
is_global: !!options.global,
};
output(result, raw, undefined);
}
function cmdGenerateClaudeMd(cwd: string, options: CmdGenerateClaudeMdOptions, raw: boolean): void {
const MANAGED_SECTIONS = ['project', 'stack', 'conventions', 'architecture', 'skills', 'workflow'];
const generators: Record<string, (cwd: string) => SectionResult> = {
project: generateProjectSection,
stack: generateStackSection,
conventions: generateConventionsSection,
architecture: generateArchitectureSection,
skills: generateSkillsSection,
workflow: generateWorkflowSection,
};
const sectionHeadings: Record<string, string> = {
project: '## Project',
stack: '## Technology Stack',
conventions: '## Conventions',
architecture: '## Architecture',
skills: '## Project Skills',
workflow: '## GSD Workflow Enforcement',
};
const generated: Record<string, SectionResult> = {};
const sectionsGenerated: string[] = [];
const sectionsFallback: string[] = [];
const sectionsSkipped: string[] = [];
for (const name of MANAGED_SECTIONS) {
const gen = generators[name](cwd);
generated[name] = gen;
if (gen.hasFallback) {
sectionsFallback.push(name);
} else {
sectionsGenerated.push(name);
}
}
let assemblyConfig: Record<string, unknown> = {};
// #1098: default the Claude-family instruction file to the project-scoped
// `.claude/CLAUDE.md` (a valid auto-loaded memory location) rather than a
// repo-root `CLAUDE.md`, so generated GSD content does not land next to — or
// pollute — a hand-crafted repo-root CLAUDE.md. An explicit `claude_md_path`
// config value or `--output` still wins.
let configClaudeMdPath = '.claude/CLAUDE.md';
try {
const config = loadConfig(cwd);
if (config['claude_md_path']) configClaudeMdPath = config['claude_md_path'] as string;
if (config['claude_md_assembly']) assemblyConfig = config['claude_md_assembly'] as Record<string, unknown>;
// #1529: When no explicit --output is provided, derive the instruction
// file from the runtime via the shared `getProjectInstructionFile` policy
// (single source of truth in runtime-name-policy.cjs, shared with the
// new-project.md bash workflow via `gsd-tools query
// project-instruction-file`). Previously this was a codex-only override
// (#3163) that left AGENTS-native runtimes (opencode/kilo/kimi) emitting
// CLAUDE.md; copilot now resolves to .github/copilot-instructions.md, and
// antigravity to GEMINI.md. GSD_RUNTIME env var takes precedence
// over config.runtime, mirroring detectRuntime().
//
// Non-claude runtimes always win over a stale `claude_md_path` (the #3163
// rationale: a Codex/AGENTS-native project must never write to CLAUDE.md
// even if a prior Claude setup left a `claude_md_path` behind). For the
// claude runtime, `claude_md_path` config is honored — it IS the
// Claude-specific output setting (per #1098 and the #3163 non-codex test).
const effectiveRuntime = resolveRuntimeNameFromCandidates(
process.env['GSD_RUNTIME'],
config['runtime']
);
if (!options.output && effectiveRuntime && effectiveRuntime !== 'claude') {
configClaudeMdPath = getProjectInstructionFile(effectiveRuntime);
}
} catch { /* use default */ }
let outputPath = options.output;
if (!outputPath) {
outputPath = path.isAbsolute(configClaudeMdPath) ? configClaudeMdPath : path.join(cwd, configClaudeMdPath);
} else if (!path.isAbsolute(outputPath)) {
outputPath = path.join(cwd, outputPath);
}
const globalAssemblyMode = (assemblyConfig['mode'] as string) || 'embed';
const blockModes = (assemblyConfig['blocks'] as Record<string, string>) || {};
// Return the assembled content for a section, respecting link vs embed mode.
// "link" mode writes `@<linkPath>` when the generator has a real source file.
// Falls back to "embed" for sections without a linkable source (workflow, fallbacks).
function buildSectionContent(name: string, gen: SectionResult, heading: string): string {
const effectiveMode = blockModes[name] || globalAssemblyMode;
if (effectiveMode === 'link' && gen.linkPath && !gen.hasFallback) {
return buildSection(name, gen.source, `${heading}\n\n@${gen.linkPath}`);
}
return buildSection(name, gen.source, `${heading}\n\n${gen.content}`);
}
let existingContent = safeReadFile(outputPath);
let action: string;
if (existingContent === null) {
const sections: string[] = [];
for (const name of MANAGED_SECTIONS) {
const gen = generated[name];
const heading = sectionHeadings[name];
sections.push(buildSectionContent(name, gen, heading));
}
sections.push('');
sections.push(buildClaudeMdProfilePlaceholder(resolveRuntime(cwd)));
existingContent = sections.join('\n\n') + '\n';
action = 'created';
platformEnsureDir(path.dirname(outputPath));
platformWriteSync(outputPath, existingContent);
} else if (!/<!-- GSD:[a-z]+-start/.test(existingContent) && !options.force) {
// #1098: the target instruction file already exists and contains NO GSD
// section markers — it is a hand-crafted CLAUDE.md/AGENTS.md, not a
// GSD-managed one. Do NOT clobber it with generated project documentation
// (broad project detail belongs in PROJECT.md / REQUIREMENTS.md, which GSD
// already owns). Leave the file untouched and report a skip; `--force`
// overwrites intentionally.
output({
claude_md_path: outputPath,
action: 'skipped',
reason: 'existing instruction file has no GSD markers (hand-crafted); not overwriting. Pass --force to overwrite.',
sections_generated: [],
sections_fallback: [],
sections_skipped: MANAGED_SECTIONS,
sections_total: MANAGED_SECTIONS.length,
profile_status: 'skipped',
message: `Left existing ${path.basename(outputPath)} untouched (no GSD markers found). Broad project context lives in PROJECT.md / REQUIREMENTS.md; pass --force to overwrite this file with GSD-managed sections.`,
}, raw, undefined);
return;
} else {
action = 'updated';
let fileContent = existingContent;
for (const name of MANAGED_SECTIONS) {
const gen = generated[name];
const heading = sectionHeadings[name];
const fullSection = buildSectionContent(name, gen, heading);
const hasMarkers = fileContent.indexOf(`<!-- GSD:${name}-start`) !== -1;
if (hasMarkers) {
if (options.auto) {
const effectiveMode = blockModes[name] || globalAssemblyMode;
const expectedBody = (effectiveMode === 'link' && gen.linkPath && !gen.hasFallback)
? `${heading}\n\n@${gen.linkPath}`
: `${heading}\n\n${gen.content}`;
if (detectManualEdit(fileContent, name, expectedBody)) {
sectionsSkipped.push(name);
const genIdx = sectionsGenerated.indexOf(name);
if (genIdx !== -1) sectionsGenerated.splice(genIdx, 1);
const fbIdx = sectionsFallback.indexOf(name);
if (fbIdx !== -1) sectionsFallback.splice(fbIdx, 1);
continue;
}
}
const result = updateSection(fileContent, name, fullSection);
fileContent = result.content;
} else {
const result = updateSection(fileContent, name, fullSection);
fileContent = result.content;
}
}
if (!options.auto && fileContent.indexOf('<!-- GSD:profile-start') === -1) {
fileContent = fileContent.trimEnd() + '\n\n' + buildClaudeMdProfilePlaceholder(resolveRuntime(cwd)) + '\n';
}
platformWriteSync(outputPath, fileContent);
}
const finalContent = safeReadFile(outputPath);
let profileStatus: string;
if (finalContent && finalContent.indexOf('<!-- GSD:profile-start') !== -1) {
if (action === 'created' || existingContent.indexOf('<!-- GSD:profile-start') === -1) {
profileStatus = 'placeholder_added';
} else {
profileStatus = 'exists';
}
} else {
profileStatus = 'already_present';
}
const genCount = sectionsGenerated.length;
const totalManaged = MANAGED_SECTIONS.length;
let message = `Generated ${genCount}/${totalManaged} sections.`;
if (sectionsFallback.length > 0) message += ` Fallback: ${sectionsFallback.join(', ')}.`;
if (sectionsSkipped.length > 0) message += ` Skipped (manually edited): ${sectionsSkipped.join(', ')}.`;
if (profileStatus === 'placeholder_added') message += ` Run ${String(formatGsdSlash('profile-user', resolveRuntime(cwd)))} to unlock Developer Profile.`;
const result = {
claude_md_path: outputPath,
action,
sections_generated: sectionsGenerated,
sections_fallback: sectionsFallback,
sections_skipped: sectionsSkipped,
sections_total: totalManaged,
profile_status: profileStatus,
message,
};
output(result, raw, undefined);
}
export = {
cmdWriteProfile,
cmdProfileQuestionnaire,
cmdGenerateDevPreferences,
cmdGenerateClaudeProfile,
cmdGenerateClaudeMd,
PROFILING_QUESTIONS,
CLAUDE_INSTRUCTIONS,
};