voidforge-build 23.18.0 → 23.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.claude/agents/celebrimbor-forge-artist.md +1 -0
- package/dist/.claude/agents/ducem-token-economics.md +1 -0
- package/dist/.claude/agents/galadriel-frontend.md +1 -0
- package/dist/.claude/agents/romanoff-integrations.md +4 -0
- package/dist/.claude/agents/silver-surfer-herald.md +19 -4
- package/dist/.claude/commands/architect.md +4 -3
- package/dist/.claude/commands/assemble.md +12 -0
- package/dist/.claude/commands/assess.md +1 -0
- package/dist/.claude/commands/build.md +8 -0
- package/dist/.claude/commands/contextmeter.md +56 -0
- package/dist/.claude/commands/debrief.md +10 -0
- package/dist/.claude/commands/engage.md +5 -0
- package/dist/.claude/commands/git.md +13 -1
- package/dist/.claude/commands/imagine.md +1 -1
- package/dist/.claude/commands/seal.md +80 -0
- package/dist/.claude/commands/ux.md +13 -0
- package/dist/.claude/workflows/assemble-review.workflow.js +26 -6
- package/dist/.claude/workflows/gauntlet.workflow.js +59 -12
- package/dist/CHANGELOG.md +73 -0
- package/dist/CLAUDE.md +9 -1
- package/dist/HOLOCRON.md +16 -2
- package/dist/VERSION.md +3 -1
- package/dist/docs/methods/AI_INTELLIGENCE.md +3 -0
- package/dist/docs/methods/ASSEMBLER.md +12 -0
- package/dist/docs/methods/BUILD_PROTOCOL.md +7 -0
- package/dist/docs/methods/CAMPAIGN.md +11 -0
- package/dist/docs/methods/DEVOPS_ENGINEER.md +56 -0
- package/dist/docs/methods/FIELD_MEDIC.md +1 -0
- package/dist/docs/methods/FORGE_ARTIST.md +3 -4
- package/dist/docs/methods/GAUNTLET.md +6 -0
- package/dist/docs/methods/MUSTER.md +2 -0
- package/dist/docs/methods/PRODUCT_DESIGN_FRONTEND.md +18 -0
- package/dist/docs/methods/QA_ENGINEER.md +17 -1
- package/dist/docs/methods/RELEASE_MANAGER.md +27 -0
- package/dist/docs/methods/SECURITY_AUDITOR.md +11 -1
- package/dist/docs/methods/SUB_AGENTS.md +31 -0
- package/dist/docs/methods/SYSTEMS_ARCHITECT.md +15 -0
- package/dist/docs/methods/TESTING.md +2 -0
- package/dist/docs/methods/TROUBLESHOOTING.md +2 -2
- package/dist/docs/methods/WORKFLOWS.md +18 -2
- package/dist/docs/patterns/ai-prompt-safety.ts +85 -0
- package/dist/docs/patterns/data-pipeline.ts +59 -1
- package/dist/docs/patterns/exclusion-set-invariant.md +62 -0
- package/dist/docs/patterns/multi-tenant-property-test.ts +64 -0
- package/dist/docs/patterns/oauth-token-lifecycle.ts +21 -0
- package/dist/scripts/statusline/README.md +38 -0
- package/dist/scripts/statusline/context-awareness-hook.sh +53 -0
- package/dist/scripts/statusline/settings-snippet.json +17 -0
- package/dist/scripts/statusline/voidforge-statusline.sh +91 -0
- package/dist/scripts/voidforge.js +69 -6
- package/dist/wizard/lib/claude-md-strategy.d.ts +87 -0
- package/dist/wizard/lib/claude-md-strategy.js +198 -0
- package/dist/wizard/lib/marker.d.ts +48 -1
- package/dist/wizard/lib/marker.js +58 -2
- package/dist/wizard/lib/patterns/oauth-token-lifecycle.d.ts +14 -0
- package/dist/wizard/lib/patterns/oauth-token-lifecycle.js +21 -0
- package/dist/wizard/lib/project-init.js +77 -0
- package/dist/wizard/lib/updater.d.ts +19 -0
- package/dist/wizard/lib/updater.js +91 -33
- package/package.json +2 -2
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLAUDE.md update strategy — the single mechanism the updater uses to decide
|
|
3
|
+
* how an `update` may touch a project's CLAUDE.md (issue #368).
|
|
4
|
+
*
|
|
5
|
+
* CLAUDE.md is the file Claude Code loads every session; it carries the
|
|
6
|
+
* project's operational knowledge. The old updater preserved only the first ~10
|
|
7
|
+
* lines and overwrote the rest, silently discarding every project-specific
|
|
8
|
+
* section. That is the same bug class as #331 (silent destruction of user
|
|
9
|
+
* content). This module makes the update NON-DESTRUCTIVE by default and gives
|
|
10
|
+
* projects a precise, opt-in lossless merge via sentinel fences.
|
|
11
|
+
*
|
|
12
|
+
* Three strategies (from the `.voidforge` marker's `claudeMd` field):
|
|
13
|
+
* - 'preserve' (default): never overwrite in place. If upstream differs, the
|
|
14
|
+
* new methodology is written to a side file (`CLAUDE.md.upstream`) and the
|
|
15
|
+
* operator is warned. The original CLAUDE.md is left untouched.
|
|
16
|
+
* - 'merge': replace ONLY the content between the sentinel fences
|
|
17
|
+
* `<!-- VOIDFORGE:BEGIN methodology -->` / `<!-- VOIDFORGE:END methodology -->`,
|
|
18
|
+
* leaving everything outside them verbatim. Falls back to 'preserve'
|
|
19
|
+
* (side-file + warning) when the fences are absent in either document —
|
|
20
|
+
* there is no lossless in-place merge without an explicit fenced block.
|
|
21
|
+
* - 'skip': do not read or write CLAUDE.md at all.
|
|
22
|
+
*
|
|
23
|
+
* In every strategy, section-loss detection runs and surfaces a warning so an
|
|
24
|
+
* update can never silently drop project sections.
|
|
25
|
+
*/
|
|
26
|
+
// ── Constants ────────────────────────────────────────────
|
|
27
|
+
export const FENCE_BEGIN = '<!-- VOIDFORGE:BEGIN methodology -->';
|
|
28
|
+
export const FENCE_END = '<!-- VOIDFORGE:END methodology -->';
|
|
29
|
+
/** Side file written when an in-place merge would be destructive. */
|
|
30
|
+
export const UPSTREAM_SUFFIX = '.upstream';
|
|
31
|
+
// ── Heading extraction ───────────────────────────────────
|
|
32
|
+
/**
|
|
33
|
+
* Extract top-level-ish markdown headings (#, ##) used as section identities.
|
|
34
|
+
* Code fences are skipped so a `#` inside a ```bash block is not a heading.
|
|
35
|
+
* Normalized (trimmed, leading hashes stripped) for stable comparison.
|
|
36
|
+
*/
|
|
37
|
+
export function extractSections(content) {
|
|
38
|
+
const sections = [];
|
|
39
|
+
let inFence = false;
|
|
40
|
+
for (const rawLine of content.split('\n')) {
|
|
41
|
+
const line = rawLine.trimEnd();
|
|
42
|
+
const fenceToggle = /^\s*(```|~~~)/.test(line);
|
|
43
|
+
if (fenceToggle) {
|
|
44
|
+
inFence = !inFence;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (inFence)
|
|
48
|
+
continue;
|
|
49
|
+
const m = line.match(/^(#{1,2})\s+(.+?)\s*$/);
|
|
50
|
+
if (m)
|
|
51
|
+
sections.push(m[2].trim());
|
|
52
|
+
}
|
|
53
|
+
return sections;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Sections present in `current` but missing from `incoming` — i.e. content that
|
|
57
|
+
* a naive whole-file overwrite would silently destroy.
|
|
58
|
+
*/
|
|
59
|
+
export function findDroppedSections(current, incoming) {
|
|
60
|
+
const incomingSet = new Set(extractSections(incoming).map((s) => s.toLowerCase()));
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
const dropped = [];
|
|
63
|
+
for (const s of extractSections(current)) {
|
|
64
|
+
const key = s.toLowerCase();
|
|
65
|
+
if (!incomingSet.has(key) && !seen.has(key)) {
|
|
66
|
+
seen.add(key);
|
|
67
|
+
dropped.push(s);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return dropped;
|
|
71
|
+
}
|
|
72
|
+
// ── Fences ───────────────────────────────────────────────
|
|
73
|
+
// ── Identity normalization ───────────────────────────────
|
|
74
|
+
/**
|
|
75
|
+
* Normalize away the project-identity region so two CLAUDE.md files that differ
|
|
76
|
+
* ONLY in their `## Project` block (name/one-liner/domain/repo, or the
|
|
77
|
+
* `[PROJECT_NAME]` placeholders) compare equal.
|
|
78
|
+
*
|
|
79
|
+
* This is why a freshly-`init`ed project — whose `## Project` block has the real
|
|
80
|
+
* name injected while the upstream template still carries `[PROJECT_NAME]` —
|
|
81
|
+
* does not register as a spurious "change" on the very next `update`. It is a
|
|
82
|
+
* comparison-only transform; we never write the normalized form.
|
|
83
|
+
*/
|
|
84
|
+
export function stripIdentity(content) {
|
|
85
|
+
let out = content;
|
|
86
|
+
// Drop the monorepo template comment fences around the Project block.
|
|
87
|
+
out = out.replace(/<!--\s*REMOVE-FOR-NPM-PUBLISH:[\s\S]*?-->\n?/g, '');
|
|
88
|
+
out = out.replace(/<!--\s*END-REMOVE-FOR-NPM-PUBLISH\s*-->\n?/g, '');
|
|
89
|
+
// Drop a leading `## Project` block: from the `## Project` heading up to (but
|
|
90
|
+
// not including) the next `## ` heading.
|
|
91
|
+
out = out.replace(/^##\s+Project[ \t]*\n[\s\S]*?(?=^##\s)/m, '');
|
|
92
|
+
return out.trim();
|
|
93
|
+
}
|
|
94
|
+
export function hasFences(content) {
|
|
95
|
+
const begin = content.indexOf(FENCE_BEGIN);
|
|
96
|
+
const end = content.indexOf(FENCE_END);
|
|
97
|
+
return begin !== -1 && end !== -1 && end > begin;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Replace the fenced methodology block in `current` with the fenced block from
|
|
101
|
+
* `upstream`. Everything outside the fences in `current` is preserved verbatim.
|
|
102
|
+
* Returns null if either document lacks a well-formed fence pair.
|
|
103
|
+
*/
|
|
104
|
+
export function mergeFenced(current, upstream) {
|
|
105
|
+
if (!hasFences(current) || !hasFences(upstream))
|
|
106
|
+
return null;
|
|
107
|
+
const upBegin = upstream.indexOf(FENCE_BEGIN);
|
|
108
|
+
const upEnd = upstream.indexOf(FENCE_END) + FENCE_END.length;
|
|
109
|
+
const upstreamBlock = upstream.slice(upBegin, upEnd);
|
|
110
|
+
const curBegin = current.indexOf(FENCE_BEGIN);
|
|
111
|
+
const curEnd = current.indexOf(FENCE_END) + FENCE_END.length;
|
|
112
|
+
return current.slice(0, curBegin) + upstreamBlock + current.slice(curEnd);
|
|
113
|
+
}
|
|
114
|
+
// ── Core decision ────────────────────────────────────────
|
|
115
|
+
/**
|
|
116
|
+
* Decide how to update CLAUDE.md given the current project content, the incoming
|
|
117
|
+
* upstream content, and the configured strategy. Pure function — performs no
|
|
118
|
+
* I/O. The caller performs the writes described by the returned result.
|
|
119
|
+
*
|
|
120
|
+
* @param current Existing project CLAUDE.md (null/undefined if the file is absent).
|
|
121
|
+
* @param upstream Incoming methodology CLAUDE.md.
|
|
122
|
+
* @param strategy Marker `claudeMd` field (defaults applied by the caller).
|
|
123
|
+
*/
|
|
124
|
+
export function planClaudeMdUpdate(current, upstream, strategy) {
|
|
125
|
+
// New project (no existing CLAUDE.md): just write upstream. Nothing to lose.
|
|
126
|
+
if (current == null) {
|
|
127
|
+
return {
|
|
128
|
+
action: 'overwrite',
|
|
129
|
+
claudeMdContent: upstream,
|
|
130
|
+
sideFileContent: null,
|
|
131
|
+
droppedSections: [],
|
|
132
|
+
warnings: [],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// Identical already — never write. Identity-normalized so a fresh project
|
|
136
|
+
// (real name injected vs upstream `[PROJECT_NAME]` placeholder) is not treated
|
|
137
|
+
// as a spurious change on its first update.
|
|
138
|
+
if (current === upstream || stripIdentity(current) === stripIdentity(upstream)) {
|
|
139
|
+
return {
|
|
140
|
+
action: 'unchanged',
|
|
141
|
+
claudeMdContent: null,
|
|
142
|
+
sideFileContent: null,
|
|
143
|
+
droppedSections: [],
|
|
144
|
+
warnings: [],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
if (strategy === 'skip') {
|
|
148
|
+
return {
|
|
149
|
+
action: 'skip',
|
|
150
|
+
claudeMdContent: null,
|
|
151
|
+
sideFileContent: null,
|
|
152
|
+
droppedSections: [],
|
|
153
|
+
warnings: ['CLAUDE.md left untouched (claudeMd: "skip"). Upstream changes were NOT applied.'],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const dropped = findDroppedSections(current, upstream);
|
|
157
|
+
if (strategy === 'merge') {
|
|
158
|
+
const merged = mergeFenced(current, upstream);
|
|
159
|
+
if (merged !== null) {
|
|
160
|
+
// Fenced merge is lossless for everything outside the fences. Re-check loss
|
|
161
|
+
// against the merged result, not the raw upstream, so project sections
|
|
162
|
+
// preserved by the fence are NOT reported as dropped.
|
|
163
|
+
const residualLoss = findDroppedSections(current, merged);
|
|
164
|
+
const warnings = merged === current
|
|
165
|
+
? ['CLAUDE.md fenced methodology block already current — no change.']
|
|
166
|
+
: ['CLAUDE.md updated within VOIDFORGE methodology fences; project sections preserved.'];
|
|
167
|
+
if (residualLoss.length > 0) {
|
|
168
|
+
warnings.push(`WARNING: fenced merge still drops ${residualLoss.length} section(s): ${residualLoss.join(', ')}.`);
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
action: merged === current ? 'unchanged' : 'merge-fenced',
|
|
172
|
+
claudeMdContent: merged === current ? null : merged,
|
|
173
|
+
sideFileContent: null,
|
|
174
|
+
droppedSections: residualLoss,
|
|
175
|
+
warnings,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
// 'merge' requested but no fences — fall through to safe side-file behavior.
|
|
179
|
+
}
|
|
180
|
+
// 'preserve' (default), OR 'merge' without fences: never overwrite in place.
|
|
181
|
+
// Write upstream to the side file and warn; leave the original intact.
|
|
182
|
+
const warnings = [
|
|
183
|
+
`CLAUDE.md was NOT overwritten. Incoming methodology written to CLAUDE.md${UPSTREAM_SUFFIX} — review and merge deliberately.`,
|
|
184
|
+
];
|
|
185
|
+
if (strategy === 'merge') {
|
|
186
|
+
warnings.unshift(`claudeMd: "merge" requested but no ${FENCE_BEGIN} / ${FENCE_END} fences found in CLAUDE.md — falling back to safe side-file.`);
|
|
187
|
+
}
|
|
188
|
+
if (dropped.length > 0) {
|
|
189
|
+
warnings.push(`An in-place overwrite would have dropped ${dropped.length} project section(s): ${dropped.join(', ')}.`);
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
action: 'side-file',
|
|
193
|
+
claudeMdContent: null,
|
|
194
|
+
sideFileContent: upstream,
|
|
195
|
+
droppedSections: dropped,
|
|
196
|
+
warnings,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -4,17 +4,40 @@
|
|
|
4
4
|
* Every VoidForge project has a `.voidforge` JSON file at root.
|
|
5
5
|
* The CLI walks up from cwd to find it, determining the project root.
|
|
6
6
|
*/
|
|
7
|
+
/**
|
|
8
|
+
* How `update` is allowed to touch the project's CLAUDE.md (issue #368):
|
|
9
|
+
* - 'preserve' (default, safest): never overwrite in place. If upstream
|
|
10
|
+
* differs, write it to `CLAUDE.md.upstream` and warn — the operator merges
|
|
11
|
+
* deliberately. CLAUDE.md is the file Claude Code loads every session and
|
|
12
|
+
* carries project-specific operational knowledge; clobbering it is the same
|
|
13
|
+
* bug class as #331 (silent destruction of user content).
|
|
14
|
+
* - 'merge': update only the content between the sentinel fences
|
|
15
|
+
* `<!-- VOIDFORGE:BEGIN methodology -->` / `<!-- VOIDFORGE:END methodology -->`,
|
|
16
|
+
* leaving every project section outside the fences verbatim. Falls back to
|
|
17
|
+
* 'preserve' (side-file) when fences are absent — there is no lossless
|
|
18
|
+
* in-place merge without them.
|
|
19
|
+
* - 'skip': the updater never reads or writes CLAUDE.md at all.
|
|
20
|
+
*/
|
|
21
|
+
export type ClaudeMdStrategy = 'preserve' | 'merge' | 'skip';
|
|
7
22
|
export interface VoidForgeMarker {
|
|
8
23
|
id: string;
|
|
9
24
|
version: string;
|
|
10
25
|
created: string;
|
|
11
26
|
tier: 'full' | 'methodology';
|
|
12
27
|
extensions: string[];
|
|
28
|
+
/**
|
|
29
|
+
* Optional CLAUDE.md update policy (issue #368). Absent on legacy markers;
|
|
30
|
+
* callers MUST default to 'preserve' when undefined so the safe-by-default
|
|
31
|
+
* behavior applies to projects created before this field existed.
|
|
32
|
+
*/
|
|
33
|
+
claudeMd?: ClaudeMdStrategy;
|
|
13
34
|
}
|
|
35
|
+
/** Safe default when a marker omits `claudeMd`. Never silently clobber. */
|
|
36
|
+
export declare const DEFAULT_CLAUDE_MD_STRATEGY: ClaudeMdStrategy;
|
|
14
37
|
export declare const MARKER_FILE = ".voidforge";
|
|
15
38
|
export declare function readMarker(dir: string): Promise<VoidForgeMarker | null>;
|
|
16
39
|
export declare function writeMarker(dir: string, marker: VoidForgeMarker): Promise<void>;
|
|
17
|
-
export declare function createMarker(version: string, tier?: VoidForgeMarker['tier'], extensions?: string[]): VoidForgeMarker;
|
|
40
|
+
export declare function createMarker(version: string, tier?: VoidForgeMarker['tier'], extensions?: string[], claudeMd?: ClaudeMdStrategy): VoidForgeMarker;
|
|
18
41
|
/**
|
|
19
42
|
* Walk up from `startDir` to find the nearest `.voidforge` marker.
|
|
20
43
|
* Returns the directory containing the marker, or null if none found.
|
|
@@ -31,5 +54,29 @@ export declare function findProjectRoot(startDir?: string): string | null;
|
|
|
31
54
|
* Like findProjectRoot but throws with a user-friendly message.
|
|
32
55
|
*/
|
|
33
56
|
export declare function requireProjectRoot(startDir?: string): string;
|
|
57
|
+
export interface LegacyConsumer {
|
|
58
|
+
dir: string;
|
|
59
|
+
/** Tier inferred from the project shape: 'full' if a wizard/ dir is present. */
|
|
60
|
+
inferredTier: VoidForgeMarker['tier'];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Detect a legacy methodology consumer that predates the `.voidforge` marker.
|
|
64
|
+
*
|
|
65
|
+
* Such projects originally consumed methodology via git (before the marker
|
|
66
|
+
* convention) and so `update` hard-errors them toward `init` — which is the
|
|
67
|
+
* wrong remedy on an existing project (issue #369). The signature of a real
|
|
68
|
+
* consumer is the methodology footprint: `VERSION.md` + `.claude/commands/` +
|
|
69
|
+
* `docs/methods/` all present. When that holds and NO marker exists, the CLI
|
|
70
|
+
* should OFFER to create the marker rather than send the user to `init`.
|
|
71
|
+
*
|
|
72
|
+
* Tier is inferred from `wizard/` presence (full-tier projects embed/embedded
|
|
73
|
+
* the wizard), mirroring the workaround in the field report.
|
|
74
|
+
*
|
|
75
|
+
* Returns null when the dir already has a marker (not legacy) or does not look
|
|
76
|
+
* like a methodology consumer (genuinely not a VoidForge project).
|
|
77
|
+
*/
|
|
78
|
+
export declare function detectLegacyConsumer(dir?: string): LegacyConsumer | null;
|
|
79
|
+
/** Read the methodology version from a project's VERSION.md, or 'unknown'. */
|
|
80
|
+
export declare function readVersionFile(dir: string): string;
|
|
34
81
|
export declare function getGlobalDir(): string;
|
|
35
82
|
export declare function getVaultPath(): string;
|
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
* The CLI walks up from cwd to find it, determining the project root.
|
|
6
6
|
*/
|
|
7
7
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
8
|
-
import { existsSync, statSync } from 'node:fs';
|
|
8
|
+
import { existsSync, statSync, readFileSync } from 'node:fs';
|
|
9
9
|
import { join, dirname, resolve } from 'node:path';
|
|
10
10
|
import { randomUUID } from 'node:crypto';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
|
+
/** Safe default when a marker omits `claudeMd`. Never silently clobber. */
|
|
13
|
+
export const DEFAULT_CLAUDE_MD_STRATEGY = 'preserve';
|
|
12
14
|
// ── Constants ────────────────────────────────────────────
|
|
13
15
|
export const MARKER_FILE = '.voidforge';
|
|
14
16
|
// ── Read / Write ─────────────────────────────────────────
|
|
@@ -31,13 +33,14 @@ export async function writeMarker(dir, marker) {
|
|
|
31
33
|
const markerPath = join(dir, MARKER_FILE);
|
|
32
34
|
await writeFile(markerPath, JSON.stringify(marker, null, 2) + '\n', 'utf-8');
|
|
33
35
|
}
|
|
34
|
-
export function createMarker(version, tier = 'full', extensions = []) {
|
|
36
|
+
export function createMarker(version, tier = 'full', extensions = [], claudeMd = DEFAULT_CLAUDE_MD_STRATEGY) {
|
|
35
37
|
return {
|
|
36
38
|
id: randomUUID(),
|
|
37
39
|
version,
|
|
38
40
|
created: new Date().toISOString(),
|
|
39
41
|
tier,
|
|
40
42
|
extensions,
|
|
43
|
+
claudeMd,
|
|
41
44
|
};
|
|
42
45
|
}
|
|
43
46
|
// ── Project Detection ────────────────────────────────────
|
|
@@ -89,6 +92,59 @@ export function requireProjectRoot(startDir = process.cwd()) {
|
|
|
89
92
|
}
|
|
90
93
|
return root;
|
|
91
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Detect a legacy methodology consumer that predates the `.voidforge` marker.
|
|
97
|
+
*
|
|
98
|
+
* Such projects originally consumed methodology via git (before the marker
|
|
99
|
+
* convention) and so `update` hard-errors them toward `init` — which is the
|
|
100
|
+
* wrong remedy on an existing project (issue #369). The signature of a real
|
|
101
|
+
* consumer is the methodology footprint: `VERSION.md` + `.claude/commands/` +
|
|
102
|
+
* `docs/methods/` all present. When that holds and NO marker exists, the CLI
|
|
103
|
+
* should OFFER to create the marker rather than send the user to `init`.
|
|
104
|
+
*
|
|
105
|
+
* Tier is inferred from `wizard/` presence (full-tier projects embed/embedded
|
|
106
|
+
* the wizard), mirroring the workaround in the field report.
|
|
107
|
+
*
|
|
108
|
+
* Returns null when the dir already has a marker (not legacy) or does not look
|
|
109
|
+
* like a methodology consumer (genuinely not a VoidForge project).
|
|
110
|
+
*/
|
|
111
|
+
export function detectLegacyConsumer(dir = process.cwd()) {
|
|
112
|
+
const root = resolve(dir);
|
|
113
|
+
// If a valid marker is already present anywhere up the tree, it is not legacy.
|
|
114
|
+
if (findProjectRoot(root) !== null)
|
|
115
|
+
return null;
|
|
116
|
+
const hasVersion = existsSync(join(root, 'VERSION.md'));
|
|
117
|
+
const hasCommands = isDir(join(root, '.claude', 'commands'));
|
|
118
|
+
const hasMethods = isDir(join(root, 'docs', 'methods'));
|
|
119
|
+
if (!(hasVersion && hasCommands && hasMethods))
|
|
120
|
+
return null;
|
|
121
|
+
const inferredTier = isDir(join(root, 'wizard'))
|
|
122
|
+
? 'full'
|
|
123
|
+
: 'methodology';
|
|
124
|
+
return { dir: root, inferredTier };
|
|
125
|
+
}
|
|
126
|
+
/** Read the methodology version from a project's VERSION.md, or 'unknown'. */
|
|
127
|
+
export function readVersionFile(dir) {
|
|
128
|
+
const versionPath = join(resolve(dir), 'VERSION.md');
|
|
129
|
+
if (!existsSync(versionPath))
|
|
130
|
+
return 'unknown';
|
|
131
|
+
try {
|
|
132
|
+
const raw = readFileSync(versionPath, 'utf-8');
|
|
133
|
+
const match = raw.match(/(\d+\.\d+\.\d+)/);
|
|
134
|
+
return match ? match[1] : 'unknown';
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return 'unknown';
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function isDir(p) {
|
|
141
|
+
try {
|
|
142
|
+
return existsSync(p) && statSync(p).isDirectory();
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
92
148
|
// ── Global Config ────────────────────────────────────────
|
|
93
149
|
export function getGlobalDir() {
|
|
94
150
|
const home = process.env['HOME'] ?? process.env['USERPROFILE'] ?? homedir();
|
|
@@ -8,6 +8,20 @@
|
|
|
8
8
|
* - Failure escalation: retry 3x → pause platform → alert → requires_reauth
|
|
9
9
|
* - Token stored as encrypted blob in vault, keyed by platform name
|
|
10
10
|
* - Session token (daemon) rotates every 24 hours (§9.19.15)
|
|
11
|
+
* - VERIFY EXPIRY + REFRESH-GRANT BEHAVIOR AGAINST THE PROVIDER'S LIVE DOCS AT
|
|
12
|
+
* INTEGRATION TIME. The PLATFORM_CONFIGS TTLs below are STARTING ASSUMPTIONS,
|
|
13
|
+
* not ground truth — providers change them and "no refresh token / never
|
|
14
|
+
* expires" is a common false assumption. Field report #373: a Todoist
|
|
15
|
+
* integration shipped on "tokens don't expire," but the modern API issues
|
|
16
|
+
* ~1h access tokens WITH a refresh token; the code discarded the refresh
|
|
17
|
+
* token + expiry and registered no refresher, so it died ~1h after every
|
|
18
|
+
* connect across four sessions — looking exactly like intermittent
|
|
19
|
+
* revocation. At integration time: (1) read the provider's OAuth docs and
|
|
20
|
+
* quote the verified access-token TTL + whether a refresh_token is issued;
|
|
21
|
+
* (2) if a refresh_token exists, PERSIST it and register a refresher — never
|
|
22
|
+
* discard it; (3) distinguish "expired" from "revoked" via the API's OWN
|
|
23
|
+
* error body, not by inference (an expired token that mimics revocation will
|
|
24
|
+
* send you reauth-hunting instead of refreshing).
|
|
11
25
|
*
|
|
12
26
|
* Agents: Breeze (platform relations), Dockson (vault)
|
|
13
27
|
*
|
|
@@ -8,11 +8,32 @@
|
|
|
8
8
|
* - Failure escalation: retry 3x → pause platform → alert → requires_reauth
|
|
9
9
|
* - Token stored as encrypted blob in vault, keyed by platform name
|
|
10
10
|
* - Session token (daemon) rotates every 24 hours (§9.19.15)
|
|
11
|
+
* - VERIFY EXPIRY + REFRESH-GRANT BEHAVIOR AGAINST THE PROVIDER'S LIVE DOCS AT
|
|
12
|
+
* INTEGRATION TIME. The PLATFORM_CONFIGS TTLs below are STARTING ASSUMPTIONS,
|
|
13
|
+
* not ground truth — providers change them and "no refresh token / never
|
|
14
|
+
* expires" is a common false assumption. Field report #373: a Todoist
|
|
15
|
+
* integration shipped on "tokens don't expire," but the modern API issues
|
|
16
|
+
* ~1h access tokens WITH a refresh token; the code discarded the refresh
|
|
17
|
+
* token + expiry and registered no refresher, so it died ~1h after every
|
|
18
|
+
* connect across four sessions — looking exactly like intermittent
|
|
19
|
+
* revocation. At integration time: (1) read the provider's OAuth docs and
|
|
20
|
+
* quote the verified access-token TTL + whether a refresh_token is issued;
|
|
21
|
+
* (2) if a refresh_token exists, PERSIST it and register a refresher — never
|
|
22
|
+
* discard it; (3) distinguish "expired" from "revoked" via the API's OWN
|
|
23
|
+
* error body, not by inference (an expired token that mimics revocation will
|
|
24
|
+
* send you reauth-hunting instead of refreshing).
|
|
11
25
|
*
|
|
12
26
|
* Agents: Breeze (platform relations), Dockson (vault)
|
|
13
27
|
*
|
|
14
28
|
* PRD Reference: §9.5 (token refresh strategy), §9.18 (vault session), §9.19.15
|
|
15
29
|
*/
|
|
30
|
+
// ASSUMPTIONS, NOT GROUND TRUTH (field report #373). These TTLs and the
|
|
31
|
+
// "refreshTokenTtlDays: 0 = never expires" entries are starting points. At
|
|
32
|
+
// integration time, VERIFY each value against the provider's current OAuth
|
|
33
|
+
// docs and the live token response (`expires_in`, presence of `refresh_token`)
|
|
34
|
+
// — a provider that "doesn't expire" today may issue ~1h tokens tomorrow, and
|
|
35
|
+
// a missing refresher then surfaces as recurring prod token-death that mimics
|
|
36
|
+
// revocation. Treat any new platform here the same way before shipping.
|
|
16
37
|
const PLATFORM_CONFIGS = [
|
|
17
38
|
{ platform: 'meta', accessTokenTtlHours: 1440, refreshTokenTtlDays: 0, refreshEndpoint: 'https://graph.facebook.com/v19.0/oauth/access_token' },
|
|
18
39
|
{ platform: 'google', accessTokenTtlHours: 1, refreshTokenTtlDays: 0, refreshEndpoint: 'https://oauth2.googleapis.com/token' },
|
|
@@ -89,6 +89,15 @@ async function copyMethodology(methodologyRoot, projectDir, core) {
|
|
|
89
89
|
if (existsSync(agentsSrc)) {
|
|
90
90
|
count += await copyDir(agentsSrc, join(projectDir, '.claude', 'agents'));
|
|
91
91
|
}
|
|
92
|
+
// Dynamic Workflow scripts (ADR-067 — gauntlet/assemble review skeletons).
|
|
93
|
+
// gauntlet.md / assemble.md reference `.claude/workflows/*.workflow.js`; without this
|
|
94
|
+
// a fresh `npx voidforge-build init` ships the command docs but NOT the scripts they
|
|
95
|
+
// invoke. prepack.sh + copy-assets.sh ship them to the npm package and dist/, but this
|
|
96
|
+
// CLI init copy path (methodologyRoot = the installed package) was missed in v23.18.0.
|
|
97
|
+
const workflowsSrc = join(methodologyRoot, '.claude', 'workflows');
|
|
98
|
+
if (existsSync(workflowsSrc)) {
|
|
99
|
+
count += await copyDir(workflowsSrc, join(projectDir, '.claude', 'workflows'));
|
|
100
|
+
}
|
|
92
101
|
// Methods
|
|
93
102
|
const methodsSrc = join(methodologyRoot, 'docs', 'methods');
|
|
94
103
|
if (existsSync(methodsSrc)) {
|
|
@@ -108,6 +117,15 @@ async function copyMethodology(methodologyRoot, projectDir, core) {
|
|
|
108
117
|
await cp(registrySrc, join(projectDir, 'docs', 'NAMING_REGISTRY.md'));
|
|
109
118
|
count++;
|
|
110
119
|
}
|
|
120
|
+
// Agent classification — single source of truth for agent counts (v23.7.0). Command
|
|
121
|
+
// docs derive their counts from it; prepack ships it to the npm package, but this init
|
|
122
|
+
// path omitted it, so init'd projects couldn't resolve the count SSOT.
|
|
123
|
+
const classificationSrc = join(methodologyRoot, 'docs', 'AGENT_CLASSIFICATION.md');
|
|
124
|
+
if (existsSync(classificationSrc)) {
|
|
125
|
+
await mkdir(join(projectDir, 'docs'), { recursive: true });
|
|
126
|
+
await cp(classificationSrc, join(projectDir, 'docs', 'AGENT_CLASSIFICATION.md'));
|
|
127
|
+
count++;
|
|
128
|
+
}
|
|
111
129
|
// Thumper scripts
|
|
112
130
|
const thumperSrc = join(methodologyRoot, 'scripts', 'thumper');
|
|
113
131
|
if (existsSync(thumperSrc)) {
|
|
@@ -123,6 +141,16 @@ async function copyMethodology(methodologyRoot, projectDir, core) {
|
|
|
123
141
|
await chmodShellScripts(join(projectDir, 'scripts', 'surfer-gate'));
|
|
124
142
|
await mergeSettingsHook(projectDir);
|
|
125
143
|
}
|
|
144
|
+
// Context-meter status line (/contextmeter) — default-on. Ship the scripts AND wire
|
|
145
|
+
// the statusLine + UserPromptSubmit awareness hook into settings.json, the same way the
|
|
146
|
+
// surfer-gate hook is wired. Defaults: warn 80% / crit 92% (baked into the scripts).
|
|
147
|
+
// Opt out per project with `/contextmeter --uninstall`.
|
|
148
|
+
const statuslineSrc = join(methodologyRoot, 'scripts', 'statusline');
|
|
149
|
+
if (existsSync(statuslineSrc)) {
|
|
150
|
+
count += await copyDir(statuslineSrc, join(projectDir, 'scripts', 'statusline'));
|
|
151
|
+
await chmodShellScripts(join(projectDir, 'scripts', 'statusline'));
|
|
152
|
+
await mergeStatuslineSettings(projectDir);
|
|
153
|
+
}
|
|
126
154
|
return count;
|
|
127
155
|
}
|
|
128
156
|
async function chmodShellScripts(dir) {
|
|
@@ -172,6 +200,55 @@ async function mergeSettingsHook(projectDir) {
|
|
|
172
200
|
};
|
|
173
201
|
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
174
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* Wire the /contextmeter status line + awareness hook into settings.json (default-on).
|
|
205
|
+
* Mirrors mergeSettingsHook: set `statusLine` only when the project doesn't already have
|
|
206
|
+
* one (never clobber a user's), and append the UserPromptSubmit awareness hook unless an
|
|
207
|
+
* equivalent is already present (idempotent). Defaults (warn 80 / crit 92) live in the
|
|
208
|
+
* scripts, so the wired commands carry no env prefix.
|
|
209
|
+
*/
|
|
210
|
+
async function mergeStatuslineSettings(projectDir) {
|
|
211
|
+
const snippetPath = join(projectDir, 'scripts', 'statusline', 'settings-snippet.json');
|
|
212
|
+
const settingsPath = join(projectDir, '.claude', 'settings.json');
|
|
213
|
+
if (!existsSync(snippetPath))
|
|
214
|
+
return;
|
|
215
|
+
const snippet = JSON.parse(await readFile(snippetPath, 'utf-8'));
|
|
216
|
+
const snippetStatusLine = snippet?.statusLine;
|
|
217
|
+
const snippetUserPrompt = (snippet?.hooks?.UserPromptSubmit ?? []);
|
|
218
|
+
if (!snippetStatusLine && snippetUserPrompt.length === 0)
|
|
219
|
+
return;
|
|
220
|
+
let settings = {};
|
|
221
|
+
if (existsSync(settingsPath)) {
|
|
222
|
+
try {
|
|
223
|
+
settings = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// Existing settings.json is unreadable — leave it alone.
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
await mkdir(join(projectDir, '.claude'), { recursive: true });
|
|
232
|
+
}
|
|
233
|
+
// statusLine: never clobber a project's existing one.
|
|
234
|
+
if (snippetStatusLine && !settings.statusLine) {
|
|
235
|
+
settings.statusLine = snippetStatusLine;
|
|
236
|
+
}
|
|
237
|
+
// UserPromptSubmit: append the awareness hook unless an equivalent is already present.
|
|
238
|
+
const existingHooks = (settings.hooks ?? {});
|
|
239
|
+
const existingUserPrompt = (existingHooks.UserPromptSubmit ?? []);
|
|
240
|
+
const alreadyHasMeter = existingUserPrompt.some((entry) => {
|
|
241
|
+
const hooks = (entry?.hooks ?? []);
|
|
242
|
+
return hooks.some((h) => typeof h?.command === 'string' && h.command.includes('context-awareness-hook'));
|
|
243
|
+
});
|
|
244
|
+
if (!alreadyHasMeter && snippetUserPrompt.length > 0) {
|
|
245
|
+
settings.hooks = {
|
|
246
|
+
...existingHooks,
|
|
247
|
+
UserPromptSubmit: [...existingUserPrompt, ...snippetUserPrompt],
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
251
|
+
}
|
|
175
252
|
// ── Identity Injection ───────────────────────────────────
|
|
176
253
|
async function injectIdentity(projectDir, config) {
|
|
177
254
|
const claudePath = join(projectDir, 'CLAUDE.md');
|
|
@@ -2,17 +2,36 @@
|
|
|
2
2
|
* Update mechanisms — methodology update (replaces /void git-fetch),
|
|
3
3
|
* self-update, and extension update.
|
|
4
4
|
*/
|
|
5
|
+
import type { ClaudeMdAction } from './claude-md-strategy.js';
|
|
5
6
|
export interface UpdatePlan {
|
|
6
7
|
added: string[];
|
|
7
8
|
modified: string[];
|
|
8
9
|
removed: string[];
|
|
9
10
|
unchanged: number;
|
|
11
|
+
/** Non-destructive CLAUDE.md handling (issue #368). */
|
|
12
|
+
claudeMd?: {
|
|
13
|
+
action: ClaudeMdAction;
|
|
14
|
+
droppedSections: string[];
|
|
15
|
+
warnings: string[];
|
|
16
|
+
/** Side file written instead of CLAUDE.md, relative to project root. */
|
|
17
|
+
sideFile?: string;
|
|
18
|
+
};
|
|
10
19
|
}
|
|
11
20
|
export interface UpdateResult {
|
|
12
21
|
applied: boolean;
|
|
13
22
|
plan: UpdatePlan;
|
|
14
23
|
newVersion: string;
|
|
15
24
|
}
|
|
25
|
+
export type UpdateMode = 'help' | 'self' | 'extensions' | 'methodology';
|
|
26
|
+
/**
|
|
27
|
+
* Decide which `update` mode the given argv selects. Pure — no I/O, no exit.
|
|
28
|
+
*
|
|
29
|
+
* Help MUST win over every action flag (issue #368): `update --help` printed
|
|
30
|
+
* usage but the OLD router fell through and EXECUTED the (destructive) update.
|
|
31
|
+
* Centralizing the precedence here makes that ordering testable and keeps the
|
|
32
|
+
* CLI from re-introducing the bug.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveUpdateMode(args: string[]): UpdateMode;
|
|
16
35
|
/**
|
|
17
36
|
* Diff methodology source against project files.
|
|
18
37
|
* Returns a plan showing what would change.
|