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,553 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Campaign -- Per-campaign STATE.md init/read/update/archive operations for ttm-tools.cjs
|
|
3
|
+
*
|
|
4
|
+
* Zero npm dependencies. Uses only Node.js built-ins: path, fs.
|
|
5
|
+
* Depends on: ./core.cjs for output, error, safeReadFile, safeWriteFile,
|
|
6
|
+
* parseFrontmatter, serializeFrontmatter
|
|
7
|
+
*
|
|
8
|
+
* Exports: cmdCampaignInit, cmdCampaignState, cmdCampaignUpdate, cmdCampaignList, cmdCampaignArchive
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const {
|
|
16
|
+
output,
|
|
17
|
+
error,
|
|
18
|
+
safeReadFile,
|
|
19
|
+
safeWriteFile,
|
|
20
|
+
parseFrontmatter,
|
|
21
|
+
serializeFrontmatter,
|
|
22
|
+
} = require('./core.cjs');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve and validate the campaign STATE.md path.
|
|
26
|
+
* Security: re-sanitizes slug, uses path.resolve(), rejects paths escaping project root.
|
|
27
|
+
* @param {string} slug - Campaign slug
|
|
28
|
+
* @returns {string} Absolute path to CAMPAIGNS/<slug>/STATE.md
|
|
29
|
+
*/
|
|
30
|
+
function resolveCampaignStatePath(slug) {
|
|
31
|
+
if (!slug || !slug.trim()) error('campaign slug required');
|
|
32
|
+
// Re-sanitize slug (defense in depth -- caller may pass unsanitized input)
|
|
33
|
+
const safe = slug.toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
34
|
+
if (!safe) error('campaign slug must contain at least one alphanumeric character after sanitization');
|
|
35
|
+
const statePath = path.resolve(process.cwd(), '.marketing', 'CAMPAIGNS', safe, 'STATE.md');
|
|
36
|
+
const projectRoot = path.resolve(process.cwd());
|
|
37
|
+
if (!statePath.startsWith(projectRoot + path.sep)) {
|
|
38
|
+
error('campaign STATE.md path escapes project directory');
|
|
39
|
+
}
|
|
40
|
+
return statePath;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Initialize a new campaign directory with STATE.md, RESEARCH.md, BRIEF.md, and ASSETS/.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} slug - Campaign slug (URL-safe identifier)
|
|
47
|
+
* @param {string} name - Human-readable campaign name
|
|
48
|
+
* @param {boolean} raw - Whether to output raw string
|
|
49
|
+
*/
|
|
50
|
+
function cmdCampaignInit(slug, name, raw) {
|
|
51
|
+
if (!slug || !slug.trim()) error('slug required for campaign init');
|
|
52
|
+
if (!name || !name.trim()) error('name required for campaign init');
|
|
53
|
+
|
|
54
|
+
// Re-sanitize slug (defense in depth)
|
|
55
|
+
const safe = slug.toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
56
|
+
if (!safe) error('campaign slug must contain at least one alphanumeric character after sanitization');
|
|
57
|
+
|
|
58
|
+
const campaignDir = path.resolve(process.cwd(), '.marketing', 'CAMPAIGNS', safe);
|
|
59
|
+
const assetsDir = path.resolve(campaignDir, 'ASSETS');
|
|
60
|
+
const statePath = path.resolve(campaignDir, 'STATE.md');
|
|
61
|
+
|
|
62
|
+
// Security check: campaignDir must be inside project root
|
|
63
|
+
const projectRoot = path.resolve(process.cwd());
|
|
64
|
+
if (!campaignDir.startsWith(projectRoot + path.sep)) {
|
|
65
|
+
error('campaign directory path escapes project directory');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Create CAMPAIGNS/<slug>/ASSETS/ (recursive creates all intermediate dirs)
|
|
69
|
+
fs.mkdirSync(assetsDir, { recursive: true });
|
|
70
|
+
|
|
71
|
+
const timestamp = new Date().toISOString();
|
|
72
|
+
|
|
73
|
+
// Build STATE.md via serializeFrontmatter to safely handle special chars in name (CR-01)
|
|
74
|
+
const frontmatterObj = {
|
|
75
|
+
campaign: safe,
|
|
76
|
+
name: name,
|
|
77
|
+
created: timestamp,
|
|
78
|
+
phase: 'created',
|
|
79
|
+
last_updated: timestamp,
|
|
80
|
+
'phase.created': timestamp,
|
|
81
|
+
'phase.researched': 'null',
|
|
82
|
+
'phase.briefed': 'null',
|
|
83
|
+
'phase.produced': 'null',
|
|
84
|
+
'phase.verified': 'null',
|
|
85
|
+
'phase.reviewed': 'null',
|
|
86
|
+
'phase.fixed': 'null',
|
|
87
|
+
'phase.shipped': 'null',
|
|
88
|
+
'phase.measured': 'null',
|
|
89
|
+
'phase.learned': 'null',
|
|
90
|
+
'gate.positioning_check': 'null',
|
|
91
|
+
'gate.outcome_metric': 'null',
|
|
92
|
+
'gate.positioning_drift': 'null',
|
|
93
|
+
'gate.claim_accuracy': 'null',
|
|
94
|
+
'gate.voice_drift': 'null',
|
|
95
|
+
'gate.outcome_alignment': 'null',
|
|
96
|
+
'gate.funnel_integrity': 'null',
|
|
97
|
+
'gate.utm_hygiene': 'null',
|
|
98
|
+
'gate.compliance': 'null',
|
|
99
|
+
'gate.competitor_collision': 'null',
|
|
100
|
+
'gate.icp_fit': 'null',
|
|
101
|
+
'gate.format_correctness': 'null',
|
|
102
|
+
'verify.run_count': 'null',
|
|
103
|
+
'verify.last_run': 'null',
|
|
104
|
+
'verify.overall_result': 'null',
|
|
105
|
+
'review.run_count': 'null',
|
|
106
|
+
'review.last_run': 'null',
|
|
107
|
+
'review.overall_result': 'null',
|
|
108
|
+
'fix.run_count': 'null',
|
|
109
|
+
'fix.last_run': 'null',
|
|
110
|
+
'fix.overall_result': 'null',
|
|
111
|
+
'ship.status': 'null',
|
|
112
|
+
'ship.shipped_at': 'null',
|
|
113
|
+
'ship.checklist_result': 'null',
|
|
114
|
+
};
|
|
115
|
+
const bodyContent = `\n# Campaign: ${name}\n\nPhase: created\nNext step: Run \`/ttm-research ${safe}\` to gather market intelligence.\n`;
|
|
116
|
+
const stateContent = serializeFrontmatter(frontmatterObj, bodyContent);
|
|
117
|
+
|
|
118
|
+
// TOCTOU-safe creation: wx flag fails atomically if file already exists
|
|
119
|
+
try {
|
|
120
|
+
fs.writeFileSync(statePath, stateContent, { flag: 'wx', encoding: 'utf-8' });
|
|
121
|
+
} catch (e) {
|
|
122
|
+
if (e.code === 'EEXIST') {
|
|
123
|
+
error(`Campaign already exists: ${safe}. Delete the directory first or use a different slug.`);
|
|
124
|
+
}
|
|
125
|
+
throw e;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Copy RESEARCH.md template
|
|
129
|
+
const templateDir = path.resolve(__dirname, '..', '..', 'templates');
|
|
130
|
+
const researchTemplatePath = path.resolve(templateDir, 'campaign-research.md');
|
|
131
|
+
const researchTemplate = safeReadFile(researchTemplatePath);
|
|
132
|
+
if (researchTemplate) {
|
|
133
|
+
const researchContent = researchTemplate
|
|
134
|
+
.replace(/\[SLUG\]/g, safe)
|
|
135
|
+
.replace(/\[CAMPAIGN_NAME\]/g, name);
|
|
136
|
+
safeWriteFile(path.resolve(campaignDir, 'RESEARCH.md'), researchContent);
|
|
137
|
+
} else {
|
|
138
|
+
// Fallback: write minimal placeholder if template not found
|
|
139
|
+
safeWriteFile(
|
|
140
|
+
path.resolve(campaignDir, 'RESEARCH.md'),
|
|
141
|
+
`# Research: ${name}\n\n**Campaign:** ${safe}\n**Researched:** pending\n`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Copy BRIEF.md template
|
|
146
|
+
const briefTemplatePath = path.resolve(templateDir, 'campaign-brief.md');
|
|
147
|
+
const briefTemplate = safeReadFile(briefTemplatePath);
|
|
148
|
+
if (briefTemplate) {
|
|
149
|
+
const briefContent = briefTemplate
|
|
150
|
+
.replace(/\[SLUG\]/g, safe)
|
|
151
|
+
.replace(/\[CAMPAIGN_NAME\]/g, name);
|
|
152
|
+
safeWriteFile(path.resolve(campaignDir, 'BRIEF.md'), briefContent);
|
|
153
|
+
} else {
|
|
154
|
+
// Fallback: write minimal placeholder if template not found
|
|
155
|
+
safeWriteFile(
|
|
156
|
+
path.resolve(campaignDir, 'BRIEF.md'),
|
|
157
|
+
`# Campaign Brief: ${name}\n\n**Campaign:** ${safe}\n**Status:** pending\n`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
output({ created: true, slug: safe, name, path: statePath }, raw, safe);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Read campaign STATE.md, parse frontmatter, output as JSON.
|
|
166
|
+
* If STATE.md does not exist, outputs { exists: false, error: "..." }.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} slug - Campaign slug
|
|
169
|
+
* @param {boolean} raw - Whether to output raw string
|
|
170
|
+
*/
|
|
171
|
+
function cmdCampaignState(slug, raw) {
|
|
172
|
+
const statePath = resolveCampaignStatePath(slug);
|
|
173
|
+
const safe = slug ? slug.toLowerCase().replace(/[^a-z0-9-]/g, '') : '';
|
|
174
|
+
const content = safeReadFile(statePath);
|
|
175
|
+
if (content === null) {
|
|
176
|
+
output(
|
|
177
|
+
{ exists: false, error: `Campaign STATE.md not found for slug: ${safe}` },
|
|
178
|
+
raw,
|
|
179
|
+
'not found'
|
|
180
|
+
);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
184
|
+
const result = { exists: true, ...frontmatter, body_preview: body.substring(0, 200) };
|
|
185
|
+
output(result, raw, JSON.stringify(frontmatter));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Update a single field in campaign STATE.md frontmatter.
|
|
190
|
+
* Sets last_updated to current ISO timestamp.
|
|
191
|
+
*
|
|
192
|
+
* @param {string} slug - Campaign slug
|
|
193
|
+
* @param {string} field - Frontmatter field name to update
|
|
194
|
+
* @param {string} value - New value for the field
|
|
195
|
+
* @param {boolean} raw - Whether to output raw string
|
|
196
|
+
*/
|
|
197
|
+
const ALLOWED_FIELDS = new Set([
|
|
198
|
+
'phase', 'name', 'last_updated',
|
|
199
|
+
'phase.created', 'phase.researched', 'phase.briefed', 'phase.produced',
|
|
200
|
+
'phase.verified', 'phase.reviewed', 'phase.fixed', 'phase.shipped',
|
|
201
|
+
'phase.measured', 'phase.learned',
|
|
202
|
+
'gate.positioning_check', 'gate.outcome_metric',
|
|
203
|
+
// Phase 4: Per-gate verification results (GATE-01 through GATE-10)
|
|
204
|
+
'gate.positioning_drift', 'gate.claim_accuracy', 'gate.voice_drift',
|
|
205
|
+
'gate.outcome_alignment', 'gate.funnel_integrity', 'gate.utm_hygiene',
|
|
206
|
+
'gate.compliance', 'gate.competitor_collision', 'gate.icp_fit',
|
|
207
|
+
'gate.format_correctness',
|
|
208
|
+
// Phase 4: Verification run metadata
|
|
209
|
+
'verify.run_count', 'verify.last_run', 'verify.overall_result',
|
|
210
|
+
// Phase 5: Review tracking
|
|
211
|
+
'review.run_count', 'review.last_run', 'review.overall_result',
|
|
212
|
+
// Phase 5: Fix tracking
|
|
213
|
+
'fix.run_count', 'fix.last_run', 'fix.overall_result',
|
|
214
|
+
// Phase 5: Ship tracking
|
|
215
|
+
'ship.status', 'ship.shipped_at', 'ship.checklist_result',
|
|
216
|
+
// Phase 7: Archive tracking
|
|
217
|
+
'archive.archived_at', 'archive.learnings_extracted',
|
|
218
|
+
// Phase 7: Cancel tracking
|
|
219
|
+
'cancel.cancelled_at', 'cancel.reason',
|
|
220
|
+
// Phase 9: Measurement tracking
|
|
221
|
+
'measure.run_count', 'measure.last_run', 'measure.outcome_result',
|
|
222
|
+
'measure.outcome_delta', 'measure.analytics_source',
|
|
223
|
+
// Phase 9: Learn tracking
|
|
224
|
+
'learn.run_count', 'learn.last_run', 'learn.lessons_extracted',
|
|
225
|
+
'learn.edits_proposed', 'learn.edits_applied',
|
|
226
|
+
'current_campaign',
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
function cmdCampaignUpdate(slug, field, value, raw) {
|
|
230
|
+
if (!field) error('field name required for campaign update');
|
|
231
|
+
if (value === undefined || value === null || value === '') {
|
|
232
|
+
error('value required for campaign update -- use "null" string to clear a field');
|
|
233
|
+
}
|
|
234
|
+
if (!ALLOWED_FIELDS.has(field)) {
|
|
235
|
+
error(`Unknown state field: ${field}. Allowed: ${[...ALLOWED_FIELDS].join(', ')}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const statePath = resolveCampaignStatePath(slug);
|
|
239
|
+
const content = safeReadFile(statePath);
|
|
240
|
+
if (content === null) {
|
|
241
|
+
error(`Campaign not found: ${slug} -- run /ttm-new-campaign first`);
|
|
242
|
+
}
|
|
243
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
244
|
+
const timestamp = new Date().toISOString();
|
|
245
|
+
|
|
246
|
+
frontmatter[field] = value;
|
|
247
|
+
frontmatter['last_updated'] = timestamp;
|
|
248
|
+
|
|
249
|
+
const updated = serializeFrontmatter(frontmatter, body);
|
|
250
|
+
safeWriteFile(statePath, updated);
|
|
251
|
+
|
|
252
|
+
output({ updated: field, value, last_updated: timestamp }, raw, `${field}=${value}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Active campaign phases for filtering
|
|
256
|
+
const ACTIVE_PHASES = new Set(['briefed', 'produced', 'verified', 'reviewed', 'shipped']);
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* List campaigns with optional filtering.
|
|
260
|
+
*
|
|
261
|
+
* @param {string} filter - Filter flag: '--active', '--shipped-since-last-audit', or ''
|
|
262
|
+
* @param {string} sinceArg - Time window filter e.g. '30d', '90d'
|
|
263
|
+
* @param {boolean} raw - Whether to output raw string
|
|
264
|
+
*/
|
|
265
|
+
function cmdCampaignList(filter, sinceArg, raw) {
|
|
266
|
+
// Enforce mutual exclusion of filter flags and --since early (WR-01)
|
|
267
|
+
if (filter && sinceArg) {
|
|
268
|
+
error('--active/--shipped-since-last-audit and --since are mutually exclusive');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const campaignsDir = path.resolve(process.cwd(), '.marketing', 'CAMPAIGNS');
|
|
272
|
+
|
|
273
|
+
// If no campaigns directory, return empty
|
|
274
|
+
let entries;
|
|
275
|
+
try {
|
|
276
|
+
entries = fs.readdirSync(campaignsDir, { withFileTypes: true });
|
|
277
|
+
} catch {
|
|
278
|
+
output({ campaigns: [], count: 0 }, raw, '0 campaigns');
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Read all campaign STATE.md files
|
|
283
|
+
const projectRoot = path.resolve(process.cwd());
|
|
284
|
+
const campaigns = [];
|
|
285
|
+
for (const entry of entries) {
|
|
286
|
+
if (!entry.isDirectory()) continue;
|
|
287
|
+
const statePath = path.resolve(campaignsDir, entry.name, 'STATE.md');
|
|
288
|
+
// Security: reject paths that escape the project root (e.g., via symlinks)
|
|
289
|
+
if (!statePath.startsWith(projectRoot + path.sep)) continue;
|
|
290
|
+
const content = safeReadFile(statePath);
|
|
291
|
+
if (content === null) continue;
|
|
292
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
293
|
+
campaigns.push({ slug: entry.name, ...frontmatter });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let filtered = campaigns;
|
|
297
|
+
|
|
298
|
+
if (filter === '--active') {
|
|
299
|
+
// Filter to campaigns in active phases
|
|
300
|
+
filtered = campaigns.filter(c => ACTIVE_PHASES.has(c.phase));
|
|
301
|
+
} else if (filter === '--shipped-since-last-audit') {
|
|
302
|
+
// Filter to campaigns shipped since last audit event in DRIFT-LOG.md
|
|
303
|
+
const shippedCampaigns = campaigns.filter(c => c['phase.shipped'] && c['phase.shipped'] !== 'null');
|
|
304
|
+
let lastAuditTimestamp = null;
|
|
305
|
+
|
|
306
|
+
const driftLogPath = path.resolve(process.cwd(), '.marketing', 'DRIFT-LOG.md');
|
|
307
|
+
const driftLogContent = safeReadFile(driftLogPath);
|
|
308
|
+
if (driftLogContent) {
|
|
309
|
+
// Find last audit event timestamp in the Audit Trail table
|
|
310
|
+
const lines = driftLogContent.split('\n');
|
|
311
|
+
for (const line of lines) {
|
|
312
|
+
if (line.includes('| audit |') || line.includes('| audit|')) {
|
|
313
|
+
// Parse pipe-delimited columns by position rather than first-match regex
|
|
314
|
+
// Expected columns: ['', event_type, timestamp, source, details, affected, '']
|
|
315
|
+
const cols = line.split('|').map(c => c.trim());
|
|
316
|
+
if (cols.length >= 6) {
|
|
317
|
+
const ts = cols[2];
|
|
318
|
+
if (ts && ts.match(/^\d{4}-\d{2}-\d{2}T/)) {
|
|
319
|
+
if (!lastAuditTimestamp || ts > lastAuditTimestamp) {
|
|
320
|
+
lastAuditTimestamp = ts;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (lastAuditTimestamp) {
|
|
329
|
+
filtered = shippedCampaigns.filter(c => {
|
|
330
|
+
const shipped = Date.parse(c['phase.shipped']);
|
|
331
|
+
const audit = Date.parse(lastAuditTimestamp);
|
|
332
|
+
return !isNaN(shipped) && !isNaN(audit) && shipped > audit;
|
|
333
|
+
});
|
|
334
|
+
} else {
|
|
335
|
+
// No prior audit -- include all shipped campaigns
|
|
336
|
+
filtered = shippedCampaigns;
|
|
337
|
+
}
|
|
338
|
+
} else if (sinceArg && sinceArg.match(/^\d+d$/)) {
|
|
339
|
+
// Parse days and calculate cutoff date
|
|
340
|
+
const days = parseInt(sinceArg, 10);
|
|
341
|
+
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
342
|
+
filtered = campaigns.filter(c => {
|
|
343
|
+
if (c.phase === 'archived') return false;
|
|
344
|
+
const ts = c.last_updated || c['phase.produced'];
|
|
345
|
+
if (!ts || ts === 'null') return false;
|
|
346
|
+
const tsMs = Date.parse(ts);
|
|
347
|
+
const cutMs = Date.parse(cutoff);
|
|
348
|
+
return !isNaN(tsMs) && !isNaN(cutMs) && tsMs > cutMs;
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
output({ campaigns: filtered, count: filtered.length }, raw, filtered.length + ' campaigns');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Archive a shipped campaign: move directory to ARCHIVE/, update state.
|
|
357
|
+
* Only campaigns with phase='shipped' or phase='learned' can be archived (per D-08).
|
|
358
|
+
* Uses cpSync + rmSync for cross-filesystem safety (per Pitfall 1).
|
|
359
|
+
*
|
|
360
|
+
* @param {string} slug - Campaign slug
|
|
361
|
+
* @param {boolean} raw - Whether to output raw string
|
|
362
|
+
*/
|
|
363
|
+
function cmdCampaignArchive(slug, raw) {
|
|
364
|
+
if (!slug || !slug.trim()) error('campaign slug required for archive');
|
|
365
|
+
|
|
366
|
+
const safe = slug.toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
367
|
+
if (!safe) error('campaign slug must contain at least one alphanumeric character after sanitization');
|
|
368
|
+
const projectRoot = path.resolve(process.cwd());
|
|
369
|
+
const srcDir = path.resolve(projectRoot, '.marketing', 'CAMPAIGNS', safe);
|
|
370
|
+
const destDir = path.resolve(projectRoot, '.marketing', 'CAMPAIGNS', 'ARCHIVE', safe);
|
|
371
|
+
|
|
372
|
+
// Security check: both paths must be inside project root
|
|
373
|
+
if (!srcDir.startsWith(projectRoot + path.sep)) {
|
|
374
|
+
error('source path escapes project directory');
|
|
375
|
+
}
|
|
376
|
+
if (!destDir.startsWith(projectRoot + path.sep)) {
|
|
377
|
+
error('destination path escapes project directory');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Validate source exists
|
|
381
|
+
try {
|
|
382
|
+
if (!fs.statSync(srcDir).isDirectory()) {
|
|
383
|
+
error(`Campaign not found: ${safe}`);
|
|
384
|
+
}
|
|
385
|
+
} catch {
|
|
386
|
+
error(`Campaign not found: ${safe}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Validate destination does NOT exist (irreversibility per D-10)
|
|
390
|
+
try {
|
|
391
|
+
fs.statSync(destDir);
|
|
392
|
+
error(`Archive destination already exists: ${safe}. Cannot overwrite archived campaign.`);
|
|
393
|
+
} catch (e) {
|
|
394
|
+
if (e.code !== 'ENOENT') {
|
|
395
|
+
// Re-throw unexpected errors (permissions, etc.) rather than silently proceeding
|
|
396
|
+
throw e;
|
|
397
|
+
}
|
|
398
|
+
// ENOENT is expected -- destination does not exist, safe to proceed
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Read and validate STATE.md -- only shipped campaigns can be archived
|
|
402
|
+
const statePath = path.resolve(srcDir, 'STATE.md');
|
|
403
|
+
const content = safeReadFile(statePath);
|
|
404
|
+
if (content === null) {
|
|
405
|
+
error(`Campaign STATE.md not found for: ${safe}`);
|
|
406
|
+
}
|
|
407
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
408
|
+
if (frontmatter.phase !== 'shipped' && frontmatter.phase !== 'learned') {
|
|
409
|
+
error('Only shipped or learned campaigns can be archived. Current phase: ' + frontmatter.phase);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Create ARCHIVE/ directory
|
|
413
|
+
fs.mkdirSync(path.dirname(destDir), { recursive: true });
|
|
414
|
+
|
|
415
|
+
// Copy directory (cross-filesystem safe)
|
|
416
|
+
fs.cpSync(srcDir, destDir, { recursive: true });
|
|
417
|
+
|
|
418
|
+
// Verify destination exists and STATE.md was copied before removing source
|
|
419
|
+
try {
|
|
420
|
+
if (!fs.statSync(destDir).isDirectory()) {
|
|
421
|
+
error('Archive copy verification failed: destination is not a directory');
|
|
422
|
+
}
|
|
423
|
+
} catch {
|
|
424
|
+
error('Archive copy verification failed: destination directory not found');
|
|
425
|
+
}
|
|
426
|
+
const destStatePath = path.resolve(destDir, 'STATE.md');
|
|
427
|
+
try {
|
|
428
|
+
if (!fs.statSync(destStatePath).isFile()) {
|
|
429
|
+
error('Archive copy verification failed: STATE.md not found in destination');
|
|
430
|
+
}
|
|
431
|
+
} catch {
|
|
432
|
+
error('Archive copy verification failed: STATE.md not found in destination');
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Remove source
|
|
436
|
+
fs.rmSync(srcDir, { recursive: true, force: true });
|
|
437
|
+
|
|
438
|
+
// Update STATE.md in archived location
|
|
439
|
+
const timestamp = new Date().toISOString();
|
|
440
|
+
frontmatter.phase = 'archived';
|
|
441
|
+
frontmatter['archive.archived_at'] = timestamp;
|
|
442
|
+
frontmatter['last_updated'] = timestamp;
|
|
443
|
+
|
|
444
|
+
const updatedContent = serializeFrontmatter(frontmatter, body);
|
|
445
|
+
fs.writeFileSync(destStatePath, updatedContent, 'utf-8');
|
|
446
|
+
|
|
447
|
+
output({ archived: true, slug: safe, dest: destDir }, raw, 'archived ' + safe);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Add repurpose derivative entries to an existing campaign MANIFEST.json.
|
|
452
|
+
* Each derivative gets a `source_asset_id` field linking back to the source asset.
|
|
453
|
+
*
|
|
454
|
+
* @param {string} slug - Campaign slug
|
|
455
|
+
* @param {number} sourceAssetId - The asset_id of the source asset being repurposed
|
|
456
|
+
* @param {Array<{asset_id: number, name: string, type: string, channel: string, file: string}>} derivatives - Derivative asset entries
|
|
457
|
+
* @param {boolean} raw - Whether to output raw string
|
|
458
|
+
*/
|
|
459
|
+
function cmdRepurposeManifest(slug, sourceAssetId, derivatives, raw) {
|
|
460
|
+
if (!slug || !slug.trim()) error('campaign slug required for repurpose-manifest');
|
|
461
|
+
if (sourceAssetId === undefined || sourceAssetId === null) {
|
|
462
|
+
error('source-asset-id required for repurpose-manifest');
|
|
463
|
+
}
|
|
464
|
+
if (!Array.isArray(derivatives) || derivatives.length === 0) {
|
|
465
|
+
error('derivatives array required (non-empty) for repurpose-manifest');
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const safeSlug = slug.toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
469
|
+
if (!safeSlug) error('campaign slug must contain at least one alphanumeric character after sanitization');
|
|
470
|
+
const projectRoot = path.resolve(process.cwd());
|
|
471
|
+
const manifestPath = path.resolve(projectRoot, '.marketing', 'CAMPAIGNS', safeSlug, 'MANIFEST.json');
|
|
472
|
+
|
|
473
|
+
// Security: path must stay within project root (T-10-12)
|
|
474
|
+
if (!manifestPath.startsWith(projectRoot + path.sep)) {
|
|
475
|
+
error('MANIFEST.json path escapes project directory');
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Read existing manifest or create a new one
|
|
479
|
+
let manifest;
|
|
480
|
+
const existing = safeReadFile(manifestPath);
|
|
481
|
+
if (existing !== null) {
|
|
482
|
+
try {
|
|
483
|
+
manifest = JSON.parse(existing);
|
|
484
|
+
} catch (e) {
|
|
485
|
+
error('Failed to parse existing MANIFEST.json: ' + e.message);
|
|
486
|
+
}
|
|
487
|
+
} else {
|
|
488
|
+
// No existing manifest -- create a minimal one
|
|
489
|
+
manifest = {
|
|
490
|
+
campaign: safeSlug,
|
|
491
|
+
produced_at: new Date().toISOString(),
|
|
492
|
+
hero: null,
|
|
493
|
+
derivatives: [],
|
|
494
|
+
total_assets: 0,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Ensure derivatives array exists
|
|
499
|
+
if (!Array.isArray(manifest.derivatives)) {
|
|
500
|
+
manifest.derivatives = [];
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Validate and append each derivative with source_asset_id
|
|
504
|
+
const numericSourceId = Number(sourceAssetId);
|
|
505
|
+
if (isNaN(numericSourceId)) {
|
|
506
|
+
error('source-asset-id must be a number');
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Check for duplicate asset_id entries (WR-06)
|
|
510
|
+
const existingIds = new Set(manifest.derivatives.map(d => Number(d.asset_id)));
|
|
511
|
+
|
|
512
|
+
for (const d of derivatives) {
|
|
513
|
+
if (!d.asset_id || !d.name || !d.channel || !d.file) {
|
|
514
|
+
error('Each derivative must have asset_id, name, channel, and file fields');
|
|
515
|
+
}
|
|
516
|
+
const numId = Number(d.asset_id);
|
|
517
|
+
if (existingIds.has(numId)) {
|
|
518
|
+
error(`Duplicate asset_id ${d.asset_id} -- already exists in MANIFEST.json`);
|
|
519
|
+
}
|
|
520
|
+
existingIds.add(numId);
|
|
521
|
+
manifest.derivatives.push({
|
|
522
|
+
asset_id: Number(d.asset_id),
|
|
523
|
+
name: d.name,
|
|
524
|
+
type: d.type || 'derivative',
|
|
525
|
+
channel: d.channel,
|
|
526
|
+
file: d.file,
|
|
527
|
+
source_asset_id: numericSourceId,
|
|
528
|
+
derived_from: numericSourceId,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Update total_assets count
|
|
533
|
+
const heroCount = manifest.hero ? 1 : 0;
|
|
534
|
+
manifest.total_assets = heroCount + manifest.derivatives.length;
|
|
535
|
+
|
|
536
|
+
// Write updated manifest
|
|
537
|
+
safeWriteFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
538
|
+
|
|
539
|
+
output(
|
|
540
|
+
{ updated: true, slug: safeSlug, derivatives_added: derivatives.length, total_assets: manifest.total_assets },
|
|
541
|
+
raw,
|
|
542
|
+
derivatives.length + ' derivatives added'
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
module.exports = {
|
|
547
|
+
cmdCampaignInit,
|
|
548
|
+
cmdCampaignState,
|
|
549
|
+
cmdCampaignUpdate,
|
|
550
|
+
cmdCampaignList,
|
|
551
|
+
cmdCampaignArchive,
|
|
552
|
+
cmdRepurposeManifest,
|
|
553
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commit -- Git commit helper for ttm-tools.cjs
|
|
3
|
+
*
|
|
4
|
+
* Zero npm dependencies. Uses only Node.js built-ins: child_process.
|
|
5
|
+
* Depends on: ./core.cjs for output, error
|
|
6
|
+
*
|
|
7
|
+
* Exports: cmdCommit
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { execFileSync } = require('child_process');
|
|
14
|
+
const { output, error } = require('./core.cjs');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Sanitize a commit message by stripping shell metacharacters.
|
|
18
|
+
* Security: prevents shell injection via git commit -m.
|
|
19
|
+
* Strips: backticks, $(), ${}, semicolons, pipes, newlines.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} message - Raw commit message
|
|
22
|
+
* @returns {string} Sanitized commit message
|
|
23
|
+
*/
|
|
24
|
+
function sanitizeMessage(message) {
|
|
25
|
+
return message
|
|
26
|
+
.replace(/`/g, ' ')
|
|
27
|
+
.replace(/\$\(/g, ' ')
|
|
28
|
+
.replace(/\$\{/g, ' ')
|
|
29
|
+
.replace(/;/g, ' ')
|
|
30
|
+
.replace(/\|/g, ' ')
|
|
31
|
+
.replace(/\n/g, ' ')
|
|
32
|
+
.replace(/\r/g, ' ')
|
|
33
|
+
.replace(/\s+/g, ' ')
|
|
34
|
+
.trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Stage specific files and create a git commit.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} message - Commit message (will be sanitized)
|
|
41
|
+
* @param {string[]} files - Array of file paths to stage
|
|
42
|
+
* @param {boolean} raw - Whether to output raw string
|
|
43
|
+
*/
|
|
44
|
+
function cmdCommit(message, files, raw) {
|
|
45
|
+
if (!message || !message.trim()) {
|
|
46
|
+
error('commit message required');
|
|
47
|
+
}
|
|
48
|
+
if (!files || files.length === 0) {
|
|
49
|
+
error('at least one file required for commit');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const MAX_MESSAGE_LENGTH = 500;
|
|
53
|
+
const sanitized = sanitizeMessage(message);
|
|
54
|
+
if (!sanitized) {
|
|
55
|
+
error('commit message empty after sanitization');
|
|
56
|
+
}
|
|
57
|
+
if (sanitized.length > MAX_MESSAGE_LENGTH) {
|
|
58
|
+
error(`commit message too long (${sanitized.length} chars, max ${MAX_MESSAGE_LENGTH})`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Validate file paths stay within project directory
|
|
62
|
+
const projectRoot = path.resolve(process.cwd());
|
|
63
|
+
for (const file of files) {
|
|
64
|
+
const resolved = path.resolve(projectRoot, file);
|
|
65
|
+
if (!resolved.startsWith(projectRoot + path.sep) && resolved !== projectRoot) {
|
|
66
|
+
error(`file path escapes project directory: ${file}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Stage each file using execFileSync (array args, no shell injection)
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
try {
|
|
73
|
+
execFileSync('git', ['add', file], { stdio: 'pipe' });
|
|
74
|
+
} catch (e) {
|
|
75
|
+
error(`failed to stage file: ${file} -- ${e.message}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Commit using execFileSync (no shell, message passed as argument)
|
|
80
|
+
try {
|
|
81
|
+
execFileSync('git', ['commit', '-m', sanitized], { stdio: 'pipe' });
|
|
82
|
+
} catch (e) {
|
|
83
|
+
error(`git commit failed: ${e.message}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Get short SHA
|
|
87
|
+
let sha = 'unknown';
|
|
88
|
+
try {
|
|
89
|
+
sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { stdio: 'pipe' })
|
|
90
|
+
.toString()
|
|
91
|
+
.trim();
|
|
92
|
+
} catch {
|
|
93
|
+
// Non-critical -- commit succeeded
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
output(
|
|
97
|
+
{ committed: true, message: sanitized, files, sha },
|
|
98
|
+
raw,
|
|
99
|
+
sha
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = {
|
|
104
|
+
cmdCommit,
|
|
105
|
+
};
|