taketomarket 0.1.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/.claude-plugin/plugin.json +10 -0
- package/LICENSE +21 -0
- package/README.md +419 -0
- package/agents/ttm-producer.md +53 -0
- package/bin/lib/campaign.cjs +553 -0
- package/bin/lib/commit.cjs +105 -0
- package/bin/lib/core.cjs +172 -0
- package/bin/lib/deviation.cjs +149 -0
- package/bin/lib/drift-log.cjs +219 -0
- package/bin/lib/health.cjs +438 -0
- package/bin/lib/slug.cjs +59 -0
- package/bin/lib/state.cjs +96 -0
- package/bin/ttm-tools.cjs +157 -0
- package/gates/base-gates.md +266 -0
- package/gates/discipline/.gitkeep +0 -0
- package/gates/gate-evaluation.md +341 -0
- package/gates/meta-gates.md +19 -0
- package/install.js +307 -0
- package/package.json +53 -0
- package/playbooks/.gitkeep +0 -0
- package/playbooks/aeo.md +223 -0
- package/playbooks/affiliate.md +272 -0
- package/playbooks/base.md +110 -0
- package/playbooks/email.md +306 -0
- package/playbooks/events.md +320 -0
- package/playbooks/linkedin.md +263 -0
- package/playbooks/paid-ads.md +318 -0
- package/playbooks/pr-media.md +296 -0
- package/playbooks/seo.md +284 -0
- package/playbooks/social.md +305 -0
- package/playbooks/youtube.md +325 -0
- package/references/context-loading.md +107 -0
- package/references/learnings-extraction.md +94 -0
- package/references/measurement-template.md +48 -0
- package/references/meta-gate-evaluation.md +169 -0
- package/references/positioning-check-report.md +197 -0
- package/references/review-checklist.md +78 -0
- package/references/ship-checklist-items.md +94 -0
- package/settings.json +4 -0
- package/skills/ttm-aeo-check/SKILL.md +20 -0
- package/skills/ttm-affiliate-kit/SKILL.md +19 -0
- package/skills/ttm-archive/SKILL.md +13 -0
- package/skills/ttm-brand-refresh/SKILL.md +19 -0
- package/skills/ttm-brief/SKILL.md +14 -0
- package/skills/ttm-competitor-scan/SKILL.md +19 -0
- package/skills/ttm-email-preflight/SKILL.md +19 -0
- package/skills/ttm-fix/SKILL.md +13 -0
- package/skills/ttm-health/SKILL.md +12 -0
- package/skills/ttm-icp-refresh/SKILL.md +19 -0
- package/skills/ttm-init/SKILL.md +12 -0
- package/skills/ttm-keyword-map/SKILL.md +19 -0
- package/skills/ttm-learn/SKILL.md +14 -0
- package/skills/ttm-measure/SKILL.md +14 -0
- package/skills/ttm-new-campaign/SKILL.md +13 -0
- package/skills/ttm-next/SKILL.md +12 -0
- package/skills/ttm-positioning-check/SKILL.md +19 -0
- package/skills/ttm-positioning-shift/SKILL.md +19 -0
- package/skills/ttm-produce/SKILL.md +14 -0
- package/skills/ttm-repurpose/SKILL.md +20 -0
- package/skills/ttm-research/SKILL.md +13 -0
- package/skills/ttm-resume/SKILL.md +13 -0
- package/skills/ttm-review/SKILL.md +13 -0
- package/skills/ttm-seo-audit/SKILL.md +20 -0
- package/skills/ttm-ship/SKILL.md +13 -0
- package/skills/ttm-state/SKILL.md +13 -0
- package/skills/ttm-verify/SKILL.md +14 -0
- package/templates/agents-md.md +65 -0
- package/templates/campaign-brief.md +74 -0
- package/templates/campaign-research.md +39 -0
- package/templates/campaign-state.md +40 -0
- package/templates/claude-md.md +65 -0
- package/templates/deviation-log.md +12 -0
- package/templates/drift-log.md +17 -0
- package/templates/fix-brief.md +59 -0
- package/templates/fix-log.md +22 -0
- package/templates/measurement-report.md +75 -0
- package/templates/migration-plan.md +24 -0
- package/templates/production-manifest.json +20 -0
- package/templates/reference-files/brand.md +45 -0
- package/templates/reference-files/calendar.md +30 -0
- package/templates/reference-files/channels.md +40 -0
- package/templates/reference-files/competitors.md +40 -0
- package/templates/reference-files/icp.md +50 -0
- package/templates/reference-files/learnings.md +40 -0
- package/templates/reference-files/metrics.md +42 -0
- package/templates/reference-files/positioning.md +38 -0
- package/templates/reference-files/state.md +27 -0
- package/templates/verification-report.md +59 -0
- package/workflows/discipline/.gitkeep +0 -0
- package/workflows/discipline/aeo-check.md +180 -0
- package/workflows/discipline/affiliate-kit.md +147 -0
- package/workflows/discipline/email-preflight.md +150 -0
- package/workflows/discipline/keyword-map.md +125 -0
- package/workflows/discipline/repurpose.md +329 -0
- package/workflows/discipline/seo-audit.md +169 -0
- package/workflows/lifecycle/.gitkeep +0 -0
- package/workflows/lifecycle/brief-positioning-check.md +90 -0
- package/workflows/lifecycle/brief.md +355 -0
- package/workflows/lifecycle/fix.md +495 -0
- package/workflows/lifecycle/learn.md +405 -0
- package/workflows/lifecycle/measure.md +379 -0
- package/workflows/lifecycle/produce.md +383 -0
- package/workflows/lifecycle/research.md +264 -0
- package/workflows/lifecycle/review.md +432 -0
- package/workflows/lifecycle/ship.md +521 -0
- package/workflows/lifecycle/verify.md +507 -0
- package/workflows/reference-mgmt/.gitkeep +0 -0
- package/workflows/reference-mgmt/brand-refresh.md +193 -0
- package/workflows/reference-mgmt/competitor-scan.md +228 -0
- package/workflows/reference-mgmt/icp-refresh.md +200 -0
- package/workflows/reference-mgmt/positioning-check.md +339 -0
- package/workflows/reference-mgmt/positioning-shift.md +368 -0
- package/workflows/setup/.gitkeep +0 -0
- package/workflows/setup/init-questions.md +225 -0
- package/workflows/setup/init-validation.md +155 -0
- package/workflows/setup/init.md +449 -0
- package/workflows/setup/new-campaign.md +134 -0
- package/workflows/utility/.gitkeep +0 -0
- package/workflows/utility/archive.md +334 -0
- package/workflows/utility/health.md +166 -0
- package/workflows/utility/next.md +187 -0
- package/workflows/utility/resume.md +249 -0
- package/workflows/utility/state.md +207 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health -- Directory integrity validation for ttm-tools.cjs
|
|
3
|
+
*
|
|
4
|
+
* Zero npm dependencies. Uses only Node.js built-ins: fs, path.
|
|
5
|
+
* Depends on: ./core.cjs for output, error, safeReadFile, parseFrontmatter
|
|
6
|
+
*
|
|
7
|
+
* Exports: cmdHealth, cmdInit
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { output, safeReadFile, parseFrontmatter } = require('./core.cjs');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Expected reference files in .marketing/ directory.
|
|
18
|
+
*/
|
|
19
|
+
const REFERENCE_FILES = [
|
|
20
|
+
'POSITIONING.md',
|
|
21
|
+
'BRAND.md',
|
|
22
|
+
'ICP.md',
|
|
23
|
+
'CHANNELS.md',
|
|
24
|
+
'STATE.md',
|
|
25
|
+
'CALENDAR.md',
|
|
26
|
+
'COMPETITORS.md',
|
|
27
|
+
'METRICS.md',
|
|
28
|
+
'LEARNINGS.md',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Valid campaign phases for state consistency checking.
|
|
33
|
+
*/
|
|
34
|
+
const VALID_PHASES = new Set([
|
|
35
|
+
'created', 'researched', 'briefed', 'produced', 'verified',
|
|
36
|
+
'reviewed', 'fixed', 'shipped', 'measured', 'learned',
|
|
37
|
+
'archived', 'cancelled',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Check if a path exists and is a directory.
|
|
42
|
+
* @param {string} p - Path to check
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
function dirExists(p) {
|
|
46
|
+
try {
|
|
47
|
+
return fs.statSync(p).isDirectory();
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Check if a path exists and is a file.
|
|
55
|
+
* @param {string} p - Path to check
|
|
56
|
+
* @returns {boolean}
|
|
57
|
+
*/
|
|
58
|
+
function fileExists(p) {
|
|
59
|
+
try {
|
|
60
|
+
return fs.statSync(p).isFile();
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check campaign STATE.md consistency for all campaigns.
|
|
68
|
+
* @param {string} campaignsDir - Path to CAMPAIGNS/ directory
|
|
69
|
+
* @returns {Array} Array of check objects
|
|
70
|
+
*/
|
|
71
|
+
function checkCampaignStateConsistency(campaignsDir) {
|
|
72
|
+
const checks = [];
|
|
73
|
+
let entries;
|
|
74
|
+
try {
|
|
75
|
+
entries = fs.readdirSync(campaignsDir, { withFileTypes: true });
|
|
76
|
+
} catch {
|
|
77
|
+
return checks;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (!entry.isDirectory() || entry.name === 'ARCHIVE') continue;
|
|
82
|
+
const statePath = path.resolve(campaignsDir, entry.name, 'STATE.md');
|
|
83
|
+
const content = safeReadFile(statePath);
|
|
84
|
+
if (content === null) {
|
|
85
|
+
checks.push({
|
|
86
|
+
name: `campaign_state_${entry.name}`,
|
|
87
|
+
status: 'fail',
|
|
88
|
+
path: `.marketing/CAMPAIGNS/${entry.name}/STATE.md`,
|
|
89
|
+
detail: 'STATE.md missing',
|
|
90
|
+
});
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
94
|
+
if (!frontmatter.phase || !VALID_PHASES.has(frontmatter.phase)) {
|
|
95
|
+
checks.push({
|
|
96
|
+
name: `campaign_state_${entry.name}`,
|
|
97
|
+
status: 'fail',
|
|
98
|
+
path: `.marketing/CAMPAIGNS/${entry.name}/STATE.md`,
|
|
99
|
+
detail: `invalid phase: ${frontmatter.phase || 'undefined'}`,
|
|
100
|
+
});
|
|
101
|
+
} else {
|
|
102
|
+
checks.push({
|
|
103
|
+
name: `campaign_state_${entry.name}`,
|
|
104
|
+
status: 'pass',
|
|
105
|
+
path: `.marketing/CAMPAIGNS/${entry.name}/STATE.md`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return checks;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check reference file staleness based on mtime.
|
|
114
|
+
* @param {string} marketingDir - Path to .marketing/ directory
|
|
115
|
+
* @param {number} thresholdDays - Days threshold for staleness warning (default 90)
|
|
116
|
+
* @returns {Array} Array of check objects
|
|
117
|
+
*/
|
|
118
|
+
function checkReferenceStaleness(marketingDir, thresholdDays) {
|
|
119
|
+
if (!thresholdDays) thresholdDays = 90;
|
|
120
|
+
const checks = [];
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
const thresholdMs = thresholdDays * 24 * 60 * 60 * 1000;
|
|
123
|
+
|
|
124
|
+
for (const file of REFERENCE_FILES) {
|
|
125
|
+
const filePath = path.resolve(marketingDir, file);
|
|
126
|
+
try {
|
|
127
|
+
const stat = fs.statSync(filePath);
|
|
128
|
+
const ageMs = now - stat.mtimeMs;
|
|
129
|
+
const ageDays = Math.floor(ageMs / (24 * 60 * 60 * 1000));
|
|
130
|
+
if (ageMs > thresholdMs) {
|
|
131
|
+
checks.push({
|
|
132
|
+
name: `staleness_${file.toLowerCase().replace('.md', '')}`,
|
|
133
|
+
status: 'warn',
|
|
134
|
+
path: `.marketing/${file}`,
|
|
135
|
+
detail: `not updated in ${ageDays} days`,
|
|
136
|
+
});
|
|
137
|
+
} else {
|
|
138
|
+
checks.push({
|
|
139
|
+
name: `staleness_${file.toLowerCase().replace('.md', '')}`,
|
|
140
|
+
status: 'pass',
|
|
141
|
+
path: `.marketing/${file}`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// File doesn't exist -- skip (already covered by basic health check)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return checks;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Check campaign velocity -- detect stuck campaigns.
|
|
153
|
+
* @param {string} campaignsDir - Path to CAMPAIGNS/ directory
|
|
154
|
+
* @param {number} thresholdDays - Days threshold for stuck warning (default 14)
|
|
155
|
+
* @returns {Array} Array of check objects
|
|
156
|
+
*/
|
|
157
|
+
function checkCampaignVelocity(campaignsDir, thresholdDays) {
|
|
158
|
+
if (!thresholdDays) thresholdDays = 14;
|
|
159
|
+
const checks = [];
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
const thresholdMs = thresholdDays * 24 * 60 * 60 * 1000;
|
|
162
|
+
|
|
163
|
+
let entries;
|
|
164
|
+
try {
|
|
165
|
+
entries = fs.readdirSync(campaignsDir, { withFileTypes: true });
|
|
166
|
+
} catch {
|
|
167
|
+
return checks;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const entry of entries) {
|
|
171
|
+
if (!entry.isDirectory() || entry.name === 'ARCHIVE') continue;
|
|
172
|
+
const statePath = path.resolve(campaignsDir, entry.name, 'STATE.md');
|
|
173
|
+
const content = safeReadFile(statePath);
|
|
174
|
+
if (content === null) continue;
|
|
175
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
176
|
+
const lastUpdated = frontmatter['last_updated'];
|
|
177
|
+
if (!lastUpdated || lastUpdated === 'null') continue;
|
|
178
|
+
const lastMs = Date.parse(lastUpdated);
|
|
179
|
+
if (isNaN(lastMs)) continue;
|
|
180
|
+
const ageMs = now - lastMs;
|
|
181
|
+
const ageDays = Math.floor(ageMs / (24 * 60 * 60 * 1000));
|
|
182
|
+
if (ageMs > thresholdMs) {
|
|
183
|
+
checks.push({
|
|
184
|
+
name: `velocity_${entry.name}`,
|
|
185
|
+
status: 'warn',
|
|
186
|
+
path: `.marketing/CAMPAIGNS/${entry.name}/STATE.md`,
|
|
187
|
+
detail: `stuck in ${frontmatter.phase} for ${ageDays} days`,
|
|
188
|
+
});
|
|
189
|
+
} else {
|
|
190
|
+
checks.push({
|
|
191
|
+
name: `velocity_${entry.name}`,
|
|
192
|
+
status: 'pass',
|
|
193
|
+
path: `.marketing/CAMPAIGNS/${entry.name}/STATE.md`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return checks;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Check DRIFT-LOG.md structural integrity.
|
|
202
|
+
* @param {string} marketingDir - Path to .marketing/ directory
|
|
203
|
+
* @returns {Array} Array of check objects
|
|
204
|
+
*/
|
|
205
|
+
function checkDriftLogIntegrity(marketingDir) {
|
|
206
|
+
const checks = [];
|
|
207
|
+
const driftLogPath = path.resolve(marketingDir, 'DRIFT-LOG.md');
|
|
208
|
+
const content = safeReadFile(driftLogPath);
|
|
209
|
+
|
|
210
|
+
if (content === null) {
|
|
211
|
+
checks.push({
|
|
212
|
+
name: 'drift_log_integrity',
|
|
213
|
+
status: 'warn',
|
|
214
|
+
path: '.marketing/DRIFT-LOG.md',
|
|
215
|
+
detail: 'no drift log yet',
|
|
216
|
+
});
|
|
217
|
+
return checks;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const markerCount = (content.match(/<!-- Audit Trail -->/g) || []).length;
|
|
221
|
+
if (markerCount === 0) {
|
|
222
|
+
checks.push({
|
|
223
|
+
name: 'drift_log_integrity',
|
|
224
|
+
status: 'fail',
|
|
225
|
+
path: '.marketing/DRIFT-LOG.md',
|
|
226
|
+
detail: 'missing audit trail marker',
|
|
227
|
+
});
|
|
228
|
+
} else if (markerCount > 1) {
|
|
229
|
+
checks.push({
|
|
230
|
+
name: 'drift_log_integrity',
|
|
231
|
+
status: 'fail',
|
|
232
|
+
path: '.marketing/DRIFT-LOG.md',
|
|
233
|
+
detail: 'duplicate audit trail markers',
|
|
234
|
+
});
|
|
235
|
+
} else {
|
|
236
|
+
checks.push({
|
|
237
|
+
name: 'drift_log_integrity',
|
|
238
|
+
status: 'pass',
|
|
239
|
+
path: '.marketing/DRIFT-LOG.md',
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return checks;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Check gate result consistency for campaigns with verification results.
|
|
247
|
+
* @param {string} campaignsDir - Path to CAMPAIGNS/ directory
|
|
248
|
+
* @returns {Array} Array of check objects
|
|
249
|
+
*/
|
|
250
|
+
function checkGateConsistency(campaignsDir) {
|
|
251
|
+
const checks = [];
|
|
252
|
+
const validGateValues = new Set(['null', 'pass', 'warn', 'fail', 'fix_needed', 'accepted']);
|
|
253
|
+
|
|
254
|
+
let entries;
|
|
255
|
+
try {
|
|
256
|
+
entries = fs.readdirSync(campaignsDir, { withFileTypes: true });
|
|
257
|
+
} catch {
|
|
258
|
+
return checks;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
for (const entry of entries) {
|
|
262
|
+
if (!entry.isDirectory() || entry.name === 'ARCHIVE') continue;
|
|
263
|
+
const statePath = path.resolve(campaignsDir, entry.name, 'STATE.md');
|
|
264
|
+
const content = safeReadFile(statePath);
|
|
265
|
+
if (content === null) continue;
|
|
266
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
267
|
+
|
|
268
|
+
const invalidGates = [];
|
|
269
|
+
for (const [key, value] of Object.entries(frontmatter)) {
|
|
270
|
+
if (key.startsWith('gate.') && value && !validGateValues.has(value)) {
|
|
271
|
+
invalidGates.push(`${key}=${value}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (invalidGates.length > 0) {
|
|
276
|
+
checks.push({
|
|
277
|
+
name: `gate_consistency_${entry.name}`,
|
|
278
|
+
status: 'fail',
|
|
279
|
+
path: `.marketing/CAMPAIGNS/${entry.name}/STATE.md`,
|
|
280
|
+
detail: `invalid gate values: ${invalidGates.join(', ')}`,
|
|
281
|
+
});
|
|
282
|
+
} else {
|
|
283
|
+
checks.push({
|
|
284
|
+
name: `gate_consistency_${entry.name}`,
|
|
285
|
+
status: 'pass',
|
|
286
|
+
path: `.marketing/CAMPAIGNS/${entry.name}/STATE.md`,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return checks;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Validate .marketing/ directory structure.
|
|
295
|
+
*
|
|
296
|
+
* Checks:
|
|
297
|
+
* 1. .marketing/ directory exists
|
|
298
|
+
* 2. .marketing/CAMPAIGNS/ directory exists
|
|
299
|
+
* 3. Each expected reference file exists
|
|
300
|
+
* 4. STATE.md has valid frontmatter (parseable)
|
|
301
|
+
*
|
|
302
|
+
* When full=true, runs extended checks:
|
|
303
|
+
* 5. Campaign state consistency
|
|
304
|
+
* 6. Reference file staleness
|
|
305
|
+
* 7. Campaign velocity
|
|
306
|
+
* 8. DRIFT-LOG integrity
|
|
307
|
+
* 9. Gate result consistency
|
|
308
|
+
*
|
|
309
|
+
* healthy = true when .marketing/ and CAMPAIGNS/ both exist and no 'fail' checks.
|
|
310
|
+
* Reference files use "missing" status (expected before /ttm-init runs).
|
|
311
|
+
*
|
|
312
|
+
* @param {boolean} raw - Whether to output raw summary string
|
|
313
|
+
* @param {boolean} full - Whether to run extended audit checks
|
|
314
|
+
*/
|
|
315
|
+
function cmdHealth(raw, full) {
|
|
316
|
+
const marketingDir = path.resolve(process.cwd(), '.marketing');
|
|
317
|
+
const campaignsDir = path.resolve(marketingDir, 'CAMPAIGNS');
|
|
318
|
+
const checks = [];
|
|
319
|
+
|
|
320
|
+
// Check .marketing/ directory
|
|
321
|
+
const marketingExists = dirExists(marketingDir);
|
|
322
|
+
checks.push({
|
|
323
|
+
name: 'marketing_dir',
|
|
324
|
+
status: marketingExists ? 'pass' : 'fail',
|
|
325
|
+
path: '.marketing/',
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Check CAMPAIGNS/ directory
|
|
329
|
+
const campaignsExists = dirExists(campaignsDir);
|
|
330
|
+
checks.push({
|
|
331
|
+
name: 'campaigns_dir',
|
|
332
|
+
status: campaignsExists ? 'pass' : 'fail',
|
|
333
|
+
path: '.marketing/CAMPAIGNS/',
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Check each reference file
|
|
337
|
+
for (const file of REFERENCE_FILES) {
|
|
338
|
+
const filePath = path.resolve(marketingDir, file);
|
|
339
|
+
const name = file.toLowerCase().replace('.md', '_md');
|
|
340
|
+
if (fileExists(filePath)) {
|
|
341
|
+
// Extra validation for STATE.md -- check frontmatter is parseable
|
|
342
|
+
if (file === 'STATE.md') {
|
|
343
|
+
const content = safeReadFile(filePath);
|
|
344
|
+
const { frontmatter } = parseFrontmatter(content || '');
|
|
345
|
+
const isValid = Object.keys(frontmatter).length > 0;
|
|
346
|
+
checks.push({
|
|
347
|
+
name,
|
|
348
|
+
status: isValid ? 'pass' : 'fail',
|
|
349
|
+
path: `.marketing/${file}`,
|
|
350
|
+
detail: isValid ? undefined : 'frontmatter unparseable',
|
|
351
|
+
});
|
|
352
|
+
} else {
|
|
353
|
+
checks.push({ name, status: 'pass', path: `.marketing/${file}` });
|
|
354
|
+
}
|
|
355
|
+
} else {
|
|
356
|
+
checks.push({ name, status: 'missing', path: `.marketing/${file}` });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Extended checks when --full flag is passed
|
|
361
|
+
if (full && campaignsExists) {
|
|
362
|
+
const stateChecks = checkCampaignStateConsistency(campaignsDir);
|
|
363
|
+
checks.push(...stateChecks);
|
|
364
|
+
|
|
365
|
+
const stalenessChecks = checkReferenceStaleness(marketingDir);
|
|
366
|
+
checks.push(...stalenessChecks);
|
|
367
|
+
|
|
368
|
+
const velocityChecks = checkCampaignVelocity(campaignsDir);
|
|
369
|
+
checks.push(...velocityChecks);
|
|
370
|
+
|
|
371
|
+
const driftChecks = checkDriftLogIntegrity(marketingDir);
|
|
372
|
+
checks.push(...driftChecks);
|
|
373
|
+
|
|
374
|
+
const gateChecks = checkGateConsistency(campaignsDir);
|
|
375
|
+
checks.push(...gateChecks);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const passed = checks.filter(c => c.status === 'pass').length;
|
|
379
|
+
const total = checks.length;
|
|
380
|
+
const failures = checks.filter(c => c.status === 'fail').length;
|
|
381
|
+
// healthy = marketing dir + campaigns dir both exist and no failures
|
|
382
|
+
const healthy = marketingExists && campaignsExists && failures === 0;
|
|
383
|
+
|
|
384
|
+
const result = {
|
|
385
|
+
healthy,
|
|
386
|
+
checks,
|
|
387
|
+
summary: `${passed}/${total} checks passed`,
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
if (raw) {
|
|
391
|
+
const label = healthy ? 'HEALTHY' : 'UNHEALTHY';
|
|
392
|
+
const issues = failures;
|
|
393
|
+
if (healthy) {
|
|
394
|
+
output(result, true, `${label}: ${passed}/${total} checks passed`);
|
|
395
|
+
} else {
|
|
396
|
+
output(result, true, `${label}: ${issues} issue(s) found`);
|
|
397
|
+
}
|
|
398
|
+
} else {
|
|
399
|
+
output(result, false);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Lightweight init check.
|
|
405
|
+
* Returns: { initialized, marketing_dir, reference_files_count, total_expected }
|
|
406
|
+
*
|
|
407
|
+
* @param {boolean} raw - Whether to output raw summary string
|
|
408
|
+
*/
|
|
409
|
+
function cmdInit(raw) {
|
|
410
|
+
const marketingDir = path.resolve(process.cwd(), '.marketing');
|
|
411
|
+
const marketingExists = dirExists(marketingDir);
|
|
412
|
+
|
|
413
|
+
let refCount = 0;
|
|
414
|
+
if (marketingExists) {
|
|
415
|
+
for (const file of REFERENCE_FILES) {
|
|
416
|
+
if (fileExists(path.resolve(marketingDir, file))) {
|
|
417
|
+
refCount++;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const totalExpected = REFERENCE_FILES.length;
|
|
423
|
+
const initialized = marketingExists && refCount >= totalExpected;
|
|
424
|
+
|
|
425
|
+
const result = {
|
|
426
|
+
initialized,
|
|
427
|
+
marketing_dir: marketingExists,
|
|
428
|
+
reference_files_count: refCount,
|
|
429
|
+
total_expected: totalExpected,
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
output(result, raw, initialized ? 'initialized' : 'not initialized');
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
module.exports = {
|
|
436
|
+
cmdHealth,
|
|
437
|
+
cmdInit,
|
|
438
|
+
};
|
package/bin/lib/slug.cjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slug -- Slug generation and timestamp formatting for ttm-tools.cjs
|
|
3
|
+
*
|
|
4
|
+
* Zero npm dependencies. Uses only Node.js built-ins.
|
|
5
|
+
*
|
|
6
|
+
* Exports: cmdSlug, cmdTimestamp
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const { output, error } = require('./core.cjs');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generate a URL-safe slug from text.
|
|
15
|
+
* Security: /[^a-z0-9]+/g strips all non-alphanumeric characters,
|
|
16
|
+
* preventing path traversal or injection via slug values.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} text - Input text to slugify
|
|
19
|
+
* @param {boolean} raw - Whether to output raw string
|
|
20
|
+
*/
|
|
21
|
+
function cmdSlug(text, raw) {
|
|
22
|
+
if (!text || !text.trim()) {
|
|
23
|
+
error('text required for slug generation');
|
|
24
|
+
}
|
|
25
|
+
const slug = text
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
28
|
+
.replace(/^-+|-+$/g, '')
|
|
29
|
+
.substring(0, 60);
|
|
30
|
+
output({ slug }, raw, slug);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Output a timestamp in the specified format.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} format - 'full' (default) | 'date' | 'filename'
|
|
37
|
+
* @param {boolean} raw - Whether to output raw string
|
|
38
|
+
*/
|
|
39
|
+
function cmdTimestamp(format, raw) {
|
|
40
|
+
const now = new Date();
|
|
41
|
+
let result;
|
|
42
|
+
switch (format) {
|
|
43
|
+
case 'date':
|
|
44
|
+
result = now.toISOString().split('T')[0];
|
|
45
|
+
break;
|
|
46
|
+
case 'filename':
|
|
47
|
+
result = now.toISOString().replace(/:/g, '-').replace(/\..+/, '');
|
|
48
|
+
break;
|
|
49
|
+
default:
|
|
50
|
+
result = now.toISOString();
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
output({ timestamp: result }, raw, result);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = {
|
|
57
|
+
cmdSlug,
|
|
58
|
+
cmdTimestamp,
|
|
59
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State -- STATE.md read/update operations for ttm-tools.cjs
|
|
3
|
+
*
|
|
4
|
+
* Zero npm dependencies. Uses only Node.js built-ins: path.
|
|
5
|
+
* Depends on: ./core.cjs for output, error, safeReadFile, safeWriteFile,
|
|
6
|
+
* parseFrontmatter, serializeFrontmatter
|
|
7
|
+
*
|
|
8
|
+
* Exports: cmdStateRead, cmdStateUpdate
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const {
|
|
15
|
+
output,
|
|
16
|
+
error,
|
|
17
|
+
safeReadFile,
|
|
18
|
+
safeWriteFile,
|
|
19
|
+
parseFrontmatter,
|
|
20
|
+
serializeFrontmatter,
|
|
21
|
+
} = require('./core.cjs');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve and validate the STATE.md path.
|
|
25
|
+
* Security: uses path.resolve() and rejects paths containing '..' after resolution.
|
|
26
|
+
* @returns {string} Absolute path to .marketing/STATE.md
|
|
27
|
+
*/
|
|
28
|
+
function resolveStatePath() {
|
|
29
|
+
const statePath = path.resolve(process.cwd(), '.marketing', 'STATE.md');
|
|
30
|
+
// Reject paths that escape the project directory
|
|
31
|
+
const projectRoot = path.resolve(process.cwd());
|
|
32
|
+
if (!statePath.startsWith(projectRoot)) {
|
|
33
|
+
error('STATE.md path escapes project directory');
|
|
34
|
+
}
|
|
35
|
+
return statePath;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read .marketing/STATE.md, parse frontmatter, output as JSON.
|
|
40
|
+
* If STATE.md does not exist, outputs { exists: false, error: "..." }.
|
|
41
|
+
*
|
|
42
|
+
* @param {boolean} raw - Whether to output raw string
|
|
43
|
+
*/
|
|
44
|
+
function cmdStateRead(raw) {
|
|
45
|
+
const statePath = resolveStatePath();
|
|
46
|
+
const content = safeReadFile(statePath);
|
|
47
|
+
if (content === null) {
|
|
48
|
+
output({ exists: false, error: 'STATE.md not found' }, raw, 'STATE.md not found');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
52
|
+
const result = {
|
|
53
|
+
exists: true,
|
|
54
|
+
...frontmatter,
|
|
55
|
+
body_preview: body.substring(0, 200),
|
|
56
|
+
};
|
|
57
|
+
output(result, raw, JSON.stringify(frontmatter));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Update a single field in STATE.md frontmatter.
|
|
62
|
+
* Sets last_updated to current ISO timestamp.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} field - Frontmatter field name to update
|
|
65
|
+
* @param {string} value - New value for the field
|
|
66
|
+
* @param {boolean} raw - Whether to output raw string
|
|
67
|
+
*/
|
|
68
|
+
function cmdStateUpdate(field, value, raw) {
|
|
69
|
+
if (!field) error('field name required for state update');
|
|
70
|
+
if (value === undefined || value === null) error('value required for state update');
|
|
71
|
+
|
|
72
|
+
const statePath = resolveStatePath();
|
|
73
|
+
const content = safeReadFile(statePath);
|
|
74
|
+
if (content === null) {
|
|
75
|
+
error('STATE.md not found -- run /ttm-init first');
|
|
76
|
+
}
|
|
77
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
78
|
+
const timestamp = new Date().toISOString();
|
|
79
|
+
|
|
80
|
+
frontmatter[field] = value;
|
|
81
|
+
frontmatter['last_updated'] = timestamp;
|
|
82
|
+
|
|
83
|
+
const updated = serializeFrontmatter(frontmatter, body);
|
|
84
|
+
safeWriteFile(statePath, updated);
|
|
85
|
+
|
|
86
|
+
output(
|
|
87
|
+
{ updated: field, value, last_updated: timestamp },
|
|
88
|
+
raw,
|
|
89
|
+
`${field}=${value}`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
cmdStateRead,
|
|
95
|
+
cmdStateUpdate,
|
|
96
|
+
};
|