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.
Files changed (123) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/LICENSE +21 -0
  3. package/README.md +419 -0
  4. package/agents/ttm-producer.md +53 -0
  5. package/bin/lib/campaign.cjs +553 -0
  6. package/bin/lib/commit.cjs +105 -0
  7. package/bin/lib/core.cjs +172 -0
  8. package/bin/lib/deviation.cjs +149 -0
  9. package/bin/lib/drift-log.cjs +219 -0
  10. package/bin/lib/health.cjs +438 -0
  11. package/bin/lib/slug.cjs +59 -0
  12. package/bin/lib/state.cjs +96 -0
  13. package/bin/ttm-tools.cjs +157 -0
  14. package/gates/base-gates.md +266 -0
  15. package/gates/discipline/.gitkeep +0 -0
  16. package/gates/gate-evaluation.md +341 -0
  17. package/gates/meta-gates.md +19 -0
  18. package/install.js +307 -0
  19. package/package.json +53 -0
  20. package/playbooks/.gitkeep +0 -0
  21. package/playbooks/aeo.md +223 -0
  22. package/playbooks/affiliate.md +272 -0
  23. package/playbooks/base.md +110 -0
  24. package/playbooks/email.md +306 -0
  25. package/playbooks/events.md +320 -0
  26. package/playbooks/linkedin.md +263 -0
  27. package/playbooks/paid-ads.md +318 -0
  28. package/playbooks/pr-media.md +296 -0
  29. package/playbooks/seo.md +284 -0
  30. package/playbooks/social.md +305 -0
  31. package/playbooks/youtube.md +325 -0
  32. package/references/context-loading.md +107 -0
  33. package/references/learnings-extraction.md +94 -0
  34. package/references/measurement-template.md +48 -0
  35. package/references/meta-gate-evaluation.md +169 -0
  36. package/references/positioning-check-report.md +197 -0
  37. package/references/review-checklist.md +78 -0
  38. package/references/ship-checklist-items.md +94 -0
  39. package/settings.json +4 -0
  40. package/skills/ttm-aeo-check/SKILL.md +20 -0
  41. package/skills/ttm-affiliate-kit/SKILL.md +19 -0
  42. package/skills/ttm-archive/SKILL.md +13 -0
  43. package/skills/ttm-brand-refresh/SKILL.md +19 -0
  44. package/skills/ttm-brief/SKILL.md +14 -0
  45. package/skills/ttm-competitor-scan/SKILL.md +19 -0
  46. package/skills/ttm-email-preflight/SKILL.md +19 -0
  47. package/skills/ttm-fix/SKILL.md +13 -0
  48. package/skills/ttm-health/SKILL.md +12 -0
  49. package/skills/ttm-icp-refresh/SKILL.md +19 -0
  50. package/skills/ttm-init/SKILL.md +12 -0
  51. package/skills/ttm-keyword-map/SKILL.md +19 -0
  52. package/skills/ttm-learn/SKILL.md +14 -0
  53. package/skills/ttm-measure/SKILL.md +14 -0
  54. package/skills/ttm-new-campaign/SKILL.md +13 -0
  55. package/skills/ttm-next/SKILL.md +12 -0
  56. package/skills/ttm-positioning-check/SKILL.md +19 -0
  57. package/skills/ttm-positioning-shift/SKILL.md +19 -0
  58. package/skills/ttm-produce/SKILL.md +14 -0
  59. package/skills/ttm-repurpose/SKILL.md +20 -0
  60. package/skills/ttm-research/SKILL.md +13 -0
  61. package/skills/ttm-resume/SKILL.md +13 -0
  62. package/skills/ttm-review/SKILL.md +13 -0
  63. package/skills/ttm-seo-audit/SKILL.md +20 -0
  64. package/skills/ttm-ship/SKILL.md +13 -0
  65. package/skills/ttm-state/SKILL.md +13 -0
  66. package/skills/ttm-verify/SKILL.md +14 -0
  67. package/templates/agents-md.md +65 -0
  68. package/templates/campaign-brief.md +74 -0
  69. package/templates/campaign-research.md +39 -0
  70. package/templates/campaign-state.md +40 -0
  71. package/templates/claude-md.md +65 -0
  72. package/templates/deviation-log.md +12 -0
  73. package/templates/drift-log.md +17 -0
  74. package/templates/fix-brief.md +59 -0
  75. package/templates/fix-log.md +22 -0
  76. package/templates/measurement-report.md +75 -0
  77. package/templates/migration-plan.md +24 -0
  78. package/templates/production-manifest.json +20 -0
  79. package/templates/reference-files/brand.md +45 -0
  80. package/templates/reference-files/calendar.md +30 -0
  81. package/templates/reference-files/channels.md +40 -0
  82. package/templates/reference-files/competitors.md +40 -0
  83. package/templates/reference-files/icp.md +50 -0
  84. package/templates/reference-files/learnings.md +40 -0
  85. package/templates/reference-files/metrics.md +42 -0
  86. package/templates/reference-files/positioning.md +38 -0
  87. package/templates/reference-files/state.md +27 -0
  88. package/templates/verification-report.md +59 -0
  89. package/workflows/discipline/.gitkeep +0 -0
  90. package/workflows/discipline/aeo-check.md +180 -0
  91. package/workflows/discipline/affiliate-kit.md +147 -0
  92. package/workflows/discipline/email-preflight.md +150 -0
  93. package/workflows/discipline/keyword-map.md +125 -0
  94. package/workflows/discipline/repurpose.md +329 -0
  95. package/workflows/discipline/seo-audit.md +169 -0
  96. package/workflows/lifecycle/.gitkeep +0 -0
  97. package/workflows/lifecycle/brief-positioning-check.md +90 -0
  98. package/workflows/lifecycle/brief.md +355 -0
  99. package/workflows/lifecycle/fix.md +495 -0
  100. package/workflows/lifecycle/learn.md +405 -0
  101. package/workflows/lifecycle/measure.md +379 -0
  102. package/workflows/lifecycle/produce.md +383 -0
  103. package/workflows/lifecycle/research.md +264 -0
  104. package/workflows/lifecycle/review.md +432 -0
  105. package/workflows/lifecycle/ship.md +521 -0
  106. package/workflows/lifecycle/verify.md +507 -0
  107. package/workflows/reference-mgmt/.gitkeep +0 -0
  108. package/workflows/reference-mgmt/brand-refresh.md +193 -0
  109. package/workflows/reference-mgmt/competitor-scan.md +228 -0
  110. package/workflows/reference-mgmt/icp-refresh.md +200 -0
  111. package/workflows/reference-mgmt/positioning-check.md +339 -0
  112. package/workflows/reference-mgmt/positioning-shift.md +368 -0
  113. package/workflows/setup/.gitkeep +0 -0
  114. package/workflows/setup/init-questions.md +225 -0
  115. package/workflows/setup/init-validation.md +155 -0
  116. package/workflows/setup/init.md +449 -0
  117. package/workflows/setup/new-campaign.md +134 -0
  118. package/workflows/utility/.gitkeep +0 -0
  119. package/workflows/utility/archive.md +334 -0
  120. package/workflows/utility/health.md +166 -0
  121. package/workflows/utility/next.md +187 -0
  122. package/workflows/utility/resume.md +249 -0
  123. 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
+ };