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
package/bin/lib/core.cjs
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core -- Shared output helpers and utility functions for ttm-tools.cjs
|
|
3
|
+
*
|
|
4
|
+
* Zero npm dependencies. Uses only Node.js built-ins: fs, path.
|
|
5
|
+
*
|
|
6
|
+
* Exports: output, error, parseNamedArgs, safeReadFile, safeWriteFile,
|
|
7
|
+
* parseFrontmatter, serializeFrontmatter
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
// ── Output helpers ───────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Write result to stdout. JSON by default, raw string if --raw flag is set.
|
|
19
|
+
* @param {object} result - Structured result object
|
|
20
|
+
* @param {boolean} raw - Whether --raw flag was passed
|
|
21
|
+
* @param {*} rawValue - Plain text value for raw mode
|
|
22
|
+
*/
|
|
23
|
+
function output(result, raw, rawValue) {
|
|
24
|
+
if (raw && rawValue !== undefined) {
|
|
25
|
+
process.stdout.write(String(rawValue) + '\n');
|
|
26
|
+
} else {
|
|
27
|
+
const json = JSON.stringify(result, null, 2);
|
|
28
|
+
process.stdout.write(json + '\n');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Write error message to stderr and exit with code 1.
|
|
34
|
+
* @param {string} message - Error description
|
|
35
|
+
*/
|
|
36
|
+
function error(message) {
|
|
37
|
+
process.stderr.write('Error: ' + message + '\n');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Argument parsing ─────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse --key value pairs from an args array.
|
|
45
|
+
* Skips --raw (handled globally by router).
|
|
46
|
+
* @param {string[]} args - Array of CLI arguments
|
|
47
|
+
* @returns {{ positional: string[], named: object }}
|
|
48
|
+
*/
|
|
49
|
+
function parseNamedArgs(args) {
|
|
50
|
+
const positional = [];
|
|
51
|
+
const named = {};
|
|
52
|
+
let i = 0;
|
|
53
|
+
while (i < args.length) {
|
|
54
|
+
const arg = args[i];
|
|
55
|
+
if (arg === '--raw') {
|
|
56
|
+
i++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (arg.startsWith('--') && i + 1 < args.length) {
|
|
60
|
+
const key = arg.slice(2);
|
|
61
|
+
named[key] = args[i + 1];
|
|
62
|
+
i += 2;
|
|
63
|
+
} else {
|
|
64
|
+
positional.push(arg);
|
|
65
|
+
i++;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { positional, named };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── File helpers ─────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read a file safely. Returns null if file does not exist.
|
|
75
|
+
* @param {string} filePath - Absolute or relative path
|
|
76
|
+
* @returns {string|null}
|
|
77
|
+
*/
|
|
78
|
+
function safeReadFile(filePath) {
|
|
79
|
+
try {
|
|
80
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Write a file safely. Creates parent directories if needed.
|
|
88
|
+
* @param {string} filePath - Absolute or relative path
|
|
89
|
+
* @param {string} content - File content
|
|
90
|
+
*/
|
|
91
|
+
function safeWriteFile(filePath, content) {
|
|
92
|
+
const dir = path.dirname(filePath);
|
|
93
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
94
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Frontmatter helpers ──────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Parse YAML frontmatter from markdown content.
|
|
101
|
+
* Handles simple key: value pairs (no nested objects needed for STATE.md).
|
|
102
|
+
* Returns empty frontmatter object if no frontmatter found.
|
|
103
|
+
* @param {string} content - Markdown content with optional frontmatter
|
|
104
|
+
* @returns {{ frontmatter: object, body: string }}
|
|
105
|
+
*/
|
|
106
|
+
function parseFrontmatter(content) {
|
|
107
|
+
if (!content || !content.startsWith('---')) {
|
|
108
|
+
return { frontmatter: {}, body: content || '' };
|
|
109
|
+
}
|
|
110
|
+
// Normalize line endings before parsing (handles Windows \r\n)
|
|
111
|
+
const normalized = content.replace(/\r\n/g, '\n');
|
|
112
|
+
// Match the closing --- only when it occupies a full line (followed by \n or end-of-string)
|
|
113
|
+
const endMatch = normalized.substring(3).search(/\n---(\n|$)/);
|
|
114
|
+
if (endMatch === -1) {
|
|
115
|
+
return { frontmatter: {}, body: content };
|
|
116
|
+
}
|
|
117
|
+
const endIndex = endMatch + 3; // adjust for the substring(3) offset
|
|
118
|
+
const fmBlock = normalized.substring(4, endIndex).trim();
|
|
119
|
+
const body = normalized.substring(endIndex + 4).trimStart();
|
|
120
|
+
const frontmatter = {};
|
|
121
|
+
for (const line of fmBlock.split('\n')) {
|
|
122
|
+
const colonIndex = line.indexOf(':');
|
|
123
|
+
if (colonIndex === -1) continue;
|
|
124
|
+
const key = line.substring(0, colonIndex).trim();
|
|
125
|
+
let value = line.substring(colonIndex + 1).trim();
|
|
126
|
+
// Strip surrounding quotes and unescape escaped quotes for round-trip safety
|
|
127
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
128
|
+
value = value.slice(1, -1).replace(/\\"/g, '"');
|
|
129
|
+
} else if (value.startsWith("'") && value.endsWith("'")) {
|
|
130
|
+
value = value.slice(1, -1);
|
|
131
|
+
}
|
|
132
|
+
frontmatter[key] = value;
|
|
133
|
+
}
|
|
134
|
+
return { frontmatter, body };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Serialize frontmatter object and body back to markdown string.
|
|
139
|
+
* @param {object} data - Frontmatter key-value pairs
|
|
140
|
+
* @param {string} body - Markdown body
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
function serializeFrontmatter(data, body) {
|
|
144
|
+
const lines = ['---'];
|
|
145
|
+
for (const [key, value] of Object.entries(data)) {
|
|
146
|
+
const strVal = String(value);
|
|
147
|
+
// Quote values that contain special YAML characters
|
|
148
|
+
if (strVal.includes(':') || strVal.includes('#') || strVal.includes('"') ||
|
|
149
|
+
strVal.includes('\n') || strVal.includes('\r') ||
|
|
150
|
+
strVal.startsWith(' ') || strVal.endsWith(' ')) {
|
|
151
|
+
lines.push(`${key}: "${strVal.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r')}"`);
|
|
152
|
+
} else {
|
|
153
|
+
lines.push(`${key}: ${strVal}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
lines.push('---');
|
|
157
|
+
lines.push('');
|
|
158
|
+
if (body) {
|
|
159
|
+
lines.push(body);
|
|
160
|
+
}
|
|
161
|
+
return lines.join('\n');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
output,
|
|
166
|
+
error,
|
|
167
|
+
parseNamedArgs,
|
|
168
|
+
safeReadFile,
|
|
169
|
+
safeWriteFile,
|
|
170
|
+
parseFrontmatter,
|
|
171
|
+
serializeFrontmatter,
|
|
172
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deviation -- Append-only deviation log 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
|
+
*
|
|
7
|
+
* Exports: cmdDeviationAppend
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const {
|
|
15
|
+
output,
|
|
16
|
+
error,
|
|
17
|
+
safeReadFile,
|
|
18
|
+
safeWriteFile,
|
|
19
|
+
} = require('./core.cjs');
|
|
20
|
+
|
|
21
|
+
// Allowlists for validation
|
|
22
|
+
const ALLOWED_GATES = new Set([
|
|
23
|
+
'positioning_drift', 'claim_accuracy', 'voice_drift',
|
|
24
|
+
'outcome_alignment', 'funnel_integrity', 'utm_hygiene',
|
|
25
|
+
'compliance', 'competitor_collision', 'icp_fit',
|
|
26
|
+
'format_correctness',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const ALLOWED_RESULTS = new Set(['accepted', 'correct', 'escalated']);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Sanitize justification text to prevent injection.
|
|
33
|
+
* Strips backticks, all $ signs, newlines, pipe chars, and redirects.
|
|
34
|
+
* @param {string} text - Raw justification
|
|
35
|
+
* @returns {string} Sanitized text (max 100 chars)
|
|
36
|
+
*/
|
|
37
|
+
function sanitizeJustification(text) {
|
|
38
|
+
if (!text) return '';
|
|
39
|
+
return text
|
|
40
|
+
.replace(/`/g, "'")
|
|
41
|
+
.replace(/\$/g, '')
|
|
42
|
+
.replace(/\n/g, ' ')
|
|
43
|
+
.replace(/\r/g, '')
|
|
44
|
+
.replace(/\|/g, '-')
|
|
45
|
+
.replace(/[<>]/g, '')
|
|
46
|
+
.substring(0, 100)
|
|
47
|
+
.trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Append a deviation entry to a campaign's DEVIATIONS.md.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} slug - Campaign slug
|
|
54
|
+
* @param {string} gate - Gate name (must be in ALLOWED_GATES)
|
|
55
|
+
* @param {string} result - Deviation result (accepted, correct, escalated)
|
|
56
|
+
* @param {string} justification - User justification text
|
|
57
|
+
* @param {string} asset - Asset filename
|
|
58
|
+
* @param {boolean} raw - Whether to output raw string
|
|
59
|
+
* @param {object} [extra] - Additional named fields: gate_id, tier, finding, action, run
|
|
60
|
+
*/
|
|
61
|
+
function cmdDeviationAppend(slug, gate, result, justification, asset, raw, extra) {
|
|
62
|
+
if (!slug || !slug.trim()) error('slug required for deviation append');
|
|
63
|
+
if (!gate || !gate.trim()) error('gate required for deviation append');
|
|
64
|
+
if (!result || !result.trim()) error('result required for deviation append');
|
|
65
|
+
if (!asset || !asset.trim()) error('asset required for deviation append');
|
|
66
|
+
|
|
67
|
+
// Validate slug via path.resolve to prevent traversal
|
|
68
|
+
const safe = slug.toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
69
|
+
const projectRoot = path.resolve(process.cwd());
|
|
70
|
+
const campaignDir = path.resolve(projectRoot, '.marketing', 'CAMPAIGNS', safe);
|
|
71
|
+
if (!campaignDir.startsWith(projectRoot)) {
|
|
72
|
+
error('campaign path escapes project directory');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Validate gate name against allowlist
|
|
76
|
+
const gateLower = gate.toLowerCase().trim();
|
|
77
|
+
if (!ALLOWED_GATES.has(gateLower)) {
|
|
78
|
+
error(`Unknown gate: ${gate}. Allowed: ${[...ALLOWED_GATES].join(', ')}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Validate result against allowlist
|
|
82
|
+
const resultLower = result.toLowerCase().trim();
|
|
83
|
+
if (!ALLOWED_RESULTS.has(resultLower)) {
|
|
84
|
+
error(`Unknown result: ${result}. Allowed: ${[...ALLOWED_RESULTS].join(', ')}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const deviationsPath = path.resolve(campaignDir, 'DEVIATIONS.md');
|
|
88
|
+
const safeAsset = asset.replace(/\|/g, '-').replace(/\n/g, '').substring(0, 80);
|
|
89
|
+
const safeJustification = sanitizeJustification(justification || '');
|
|
90
|
+
const timestamp = new Date().toISOString();
|
|
91
|
+
|
|
92
|
+
// Atomically create DEVIATIONS.md if it does not exist (prevents TOCTOU race)
|
|
93
|
+
const templatePath = path.resolve(__dirname, '..', '..', 'templates', 'deviation-log.md');
|
|
94
|
+
const template = safeReadFile(templatePath);
|
|
95
|
+
const initialContent = template
|
|
96
|
+
? template.replace(/\[SLUG\]/g, safe).replace(/\[ISO_TIMESTAMP\]/g, timestamp)
|
|
97
|
+
: [
|
|
98
|
+
'# Deviation Log',
|
|
99
|
+
'',
|
|
100
|
+
`**Campaign:** ${safe}`,
|
|
101
|
+
`**Created:** ${timestamp}`,
|
|
102
|
+
'',
|
|
103
|
+
'| Timestamp | Gate | Tier | Result | Asset | Finding | Action | Justification | Verify Run |',
|
|
104
|
+
'|-----------|------|------|--------|-------|---------|--------|---------------|------------|',
|
|
105
|
+
'<!-- NEW ENTRIES BELOW THIS LINE -->',
|
|
106
|
+
].join('\n');
|
|
107
|
+
try {
|
|
108
|
+
fs.writeFileSync(deviationsPath, initialContent, { flag: 'wx', encoding: 'utf-8' });
|
|
109
|
+
} catch (e) {
|
|
110
|
+
if (e.code !== 'EEXIST') throw e;
|
|
111
|
+
// File already exists -- proceed to append
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Read current content and append new entry
|
|
115
|
+
const content = safeReadFile(deviationsPath);
|
|
116
|
+
if (content === null) {
|
|
117
|
+
error(`Failed to read DEVIATIONS.md for campaign: ${safe}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Build table row entry
|
|
121
|
+
const opts = extra || {};
|
|
122
|
+
const tierMap = {
|
|
123
|
+
positioning_drift: 'T1', claim_accuracy: 'T1', voice_drift: 'T2',
|
|
124
|
+
outcome_alignment: 'T1', funnel_integrity: 'T2', utm_hygiene: 'T2',
|
|
125
|
+
compliance: 'T2', competitor_collision: 'T2', icp_fit: 'T2',
|
|
126
|
+
format_correctness: 'T2',
|
|
127
|
+
};
|
|
128
|
+
const tier = (opts.tier || tierMap[gateLower] || 'T2').toString().substring(0, 10);
|
|
129
|
+
const actionMap = { accepted: 'Accept+log', correct: 'Correct', escalated: 'Escalate' };
|
|
130
|
+
const action = sanitizeJustification(opts.action || '') || actionMap[resultLower] || resultLower;
|
|
131
|
+
const finding = sanitizeJustification(opts.finding || '') || '--';
|
|
132
|
+
const run = (opts.run || '--').toString().replace(/\|/g, '-').substring(0, 20);
|
|
133
|
+
|
|
134
|
+
const newRow = `| ${timestamp} | ${gateLower} | ${tier} | ${resultLower} | ${safeAsset} | ${finding} | ${action} | ${safeJustification} | ${run} |`;
|
|
135
|
+
|
|
136
|
+
// Append after the last line of content
|
|
137
|
+
const updated = content.trimEnd() + '\n' + newRow + '\n';
|
|
138
|
+
safeWriteFile(deviationsPath, updated);
|
|
139
|
+
|
|
140
|
+
output(
|
|
141
|
+
{ appended: true, gate: gateLower, result: resultLower, asset: safeAsset, timestamp },
|
|
142
|
+
raw,
|
|
143
|
+
`appended ${gateLower}=${resultLower}`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = {
|
|
148
|
+
cmdDeviationAppend,
|
|
149
|
+
};
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drift Log -- Append-only DRIFT-LOG.md 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
|
+
*
|
|
7
|
+
* Exports: cmdDriftLogAppend, cmdDriftLogDeprecation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const {
|
|
15
|
+
output,
|
|
16
|
+
error,
|
|
17
|
+
safeReadFile,
|
|
18
|
+
safeWriteFile,
|
|
19
|
+
} = require('./core.cjs');
|
|
20
|
+
|
|
21
|
+
// Allowlist for event types
|
|
22
|
+
const ALLOWED_EVENT_TYPES = new Set(['shift', 'audit', 'deviation']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Sanitize details text to prevent injection.
|
|
26
|
+
* Strips backticks, all $ signs, newlines, pipe chars, and redirects.
|
|
27
|
+
* @param {string} text - Raw details text
|
|
28
|
+
* @returns {string} Sanitized text (max 200 chars)
|
|
29
|
+
*/
|
|
30
|
+
function sanitizeDetails(text) {
|
|
31
|
+
if (!text) return '';
|
|
32
|
+
return text
|
|
33
|
+
.replace(/`/g, "'")
|
|
34
|
+
.replace(/\$/g, '')
|
|
35
|
+
.replace(/\n/g, ' ')
|
|
36
|
+
.replace(/\r/g, '')
|
|
37
|
+
.replace(/\|/g, '-')
|
|
38
|
+
.replace(/[<>]/g, '')
|
|
39
|
+
.substring(0, 200)
|
|
40
|
+
.trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the DRIFT-LOG.md path and ensure it is inside the project root.
|
|
45
|
+
* @returns {{ driftLogPath: string, projectRoot: string }}
|
|
46
|
+
*/
|
|
47
|
+
function resolveDriftLogPath() {
|
|
48
|
+
const projectRoot = path.resolve(process.cwd());
|
|
49
|
+
const driftLogPath = path.resolve(process.cwd(), '.marketing', 'DRIFT-LOG.md');
|
|
50
|
+
if (!driftLogPath.startsWith(projectRoot)) {
|
|
51
|
+
error('DRIFT-LOG.md path escapes project directory');
|
|
52
|
+
}
|
|
53
|
+
return { driftLogPath, projectRoot };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Ensure DRIFT-LOG.md exists using TOCTOU-safe atomic creation.
|
|
58
|
+
* @param {string} driftLogPath - Absolute path to DRIFT-LOG.md
|
|
59
|
+
*/
|
|
60
|
+
function ensureDriftLog(driftLogPath) {
|
|
61
|
+
const timestamp = new Date().toISOString();
|
|
62
|
+
const templatePath = path.resolve(__dirname, '..', '..', 'templates', 'drift-log.md');
|
|
63
|
+
const template = safeReadFile(templatePath);
|
|
64
|
+
const initialContent = template
|
|
65
|
+
? template.replace(/\[ISO_TIMESTAMP\]/g, timestamp)
|
|
66
|
+
: [
|
|
67
|
+
'# Positioning Drift Log',
|
|
68
|
+
'',
|
|
69
|
+
`**Created:** ${timestamp}`,
|
|
70
|
+
'',
|
|
71
|
+
'This file is **append-only**.',
|
|
72
|
+
'',
|
|
73
|
+
'## Audit Trail',
|
|
74
|
+
'',
|
|
75
|
+
'| Date | Event | Source | Details | Assets Affected |',
|
|
76
|
+
'|------|-------|--------|---------|-----------------|',
|
|
77
|
+
'<!-- NEW ENTRIES BELOW THIS LINE -->',
|
|
78
|
+
'',
|
|
79
|
+
'## Deprecation Backlog',
|
|
80
|
+
'',
|
|
81
|
+
'| Asset | Campaign | Old Positioning Element | Required Update | Deadline | Status |',
|
|
82
|
+
'|-------|----------|------------------------|-----------------|----------|--------|',
|
|
83
|
+
'<!-- DEPRECATION ENTRIES BELOW THIS LINE -->',
|
|
84
|
+
].join('\n');
|
|
85
|
+
|
|
86
|
+
// Ensure .marketing directory exists
|
|
87
|
+
const dir = path.dirname(driftLogPath);
|
|
88
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
fs.writeFileSync(driftLogPath, initialContent, { flag: 'wx', encoding: 'utf-8' });
|
|
92
|
+
} catch (e) {
|
|
93
|
+
if (e.code !== 'EEXIST') throw e;
|
|
94
|
+
// File already exists -- proceed to append
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Append a drift event entry to .marketing/DRIFT-LOG.md.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} eventType - Event type (shift, audit, deviation)
|
|
102
|
+
* @param {string} source - Source command or campaign that triggered the event
|
|
103
|
+
* @param {string} details - Details/summary text
|
|
104
|
+
* @param {string|number} affectedCount - Number of assets affected
|
|
105
|
+
* @param {boolean} raw - Whether to output raw string
|
|
106
|
+
*/
|
|
107
|
+
function cmdDriftLogAppend(eventType, source, details, affectedCount, raw) {
|
|
108
|
+
if (!eventType || !eventType.trim()) error('event-type required for drift-log append');
|
|
109
|
+
if (!source || !source.trim()) error('source required for drift-log append');
|
|
110
|
+
|
|
111
|
+
// Validate event type against allowlist
|
|
112
|
+
const eventLower = eventType.toLowerCase().trim();
|
|
113
|
+
if (!ALLOWED_EVENT_TYPES.has(eventLower)) {
|
|
114
|
+
error(`Unknown event type: ${eventType}. Allowed: ${[...ALLOWED_EVENT_TYPES].join(', ')}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Sanitize inputs
|
|
118
|
+
const safeSource = source.replace(/\|/g, '-').replace(/\n/g, '').replace(/[<>]/g, '').substring(0, 80);
|
|
119
|
+
const safeDetails = sanitizeDetails(details || '');
|
|
120
|
+
const safeAffected = parseInt(affectedCount, 10) || 0;
|
|
121
|
+
|
|
122
|
+
const { driftLogPath } = resolveDriftLogPath();
|
|
123
|
+
|
|
124
|
+
// TOCTOU defense: create if not exists
|
|
125
|
+
ensureDriftLog(driftLogPath);
|
|
126
|
+
|
|
127
|
+
// Read current content and append new entry
|
|
128
|
+
const content = safeReadFile(driftLogPath);
|
|
129
|
+
if (content === null) {
|
|
130
|
+
error('Failed to read DRIFT-LOG.md');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const timestamp = new Date().toISOString();
|
|
134
|
+
const newRow = `| ${timestamp} | ${eventLower} | ${safeSource} | ${safeDetails} | ${safeAffected} |`;
|
|
135
|
+
|
|
136
|
+
// Find the Audit Trail marker and append after it
|
|
137
|
+
const marker = '<!-- NEW ENTRIES BELOW THIS LINE -->';
|
|
138
|
+
let updated;
|
|
139
|
+
const markerCount = content.split(marker).length - 1;
|
|
140
|
+
if (markerCount > 1) {
|
|
141
|
+
error(`DRIFT-LOG.md has ${markerCount} occurrences of the Audit Trail marker. File may be corrupted.`);
|
|
142
|
+
}
|
|
143
|
+
if (markerCount === 1) {
|
|
144
|
+
updated = content.replace(marker, marker + '\n' + newRow);
|
|
145
|
+
} else {
|
|
146
|
+
// Fallback: append at end of content
|
|
147
|
+
updated = content.trimEnd() + '\n' + newRow + '\n';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
safeWriteFile(driftLogPath, updated);
|
|
151
|
+
|
|
152
|
+
output(
|
|
153
|
+
{ appended: true, event_type: eventLower, source: safeSource, timestamp },
|
|
154
|
+
raw,
|
|
155
|
+
'appended ' + eventLower + '=' + safeSource
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Append a deprecation entry to .marketing/DRIFT-LOG.md Deprecation Backlog.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} asset - Asset identifier
|
|
163
|
+
* @param {string} campaign - Campaign slug
|
|
164
|
+
* @param {string} oldElement - Old positioning element being deprecated
|
|
165
|
+
* @param {string} requiredUpdate - Description of required update
|
|
166
|
+
* @param {string} deadline - Deadline for the update (ISO date or description)
|
|
167
|
+
* @param {boolean} raw - Whether to output raw string
|
|
168
|
+
*/
|
|
169
|
+
function cmdDriftLogDeprecation(asset, campaign, oldElement, requiredUpdate, deadline, raw) {
|
|
170
|
+
if (!asset || !asset.trim()) error('asset required for drift-log deprecation');
|
|
171
|
+
if (!campaign || !campaign.trim()) error('campaign required for drift-log deprecation');
|
|
172
|
+
|
|
173
|
+
// Sanitize all inputs
|
|
174
|
+
const safeAsset = (asset || '').replace(/\|/g, '-').replace(/\n/g, '').replace(/[<>]/g, '').substring(0, 80);
|
|
175
|
+
const safeCampaign = (campaign || '').replace(/\|/g, '-').replace(/\n/g, '').replace(/[<>]/g, '').substring(0, 80);
|
|
176
|
+
const safeOldElement = sanitizeDetails(oldElement || '');
|
|
177
|
+
const safeUpdate = sanitizeDetails(requiredUpdate || '');
|
|
178
|
+
const safeDeadline = (deadline || '').replace(/\|/g, '-').replace(/\n/g, '').replace(/[<>]/g, '').substring(0, 30);
|
|
179
|
+
|
|
180
|
+
const { driftLogPath } = resolveDriftLogPath();
|
|
181
|
+
|
|
182
|
+
// TOCTOU defense: create if not exists
|
|
183
|
+
ensureDriftLog(driftLogPath);
|
|
184
|
+
|
|
185
|
+
// Read current content and append deprecation entry
|
|
186
|
+
const content = safeReadFile(driftLogPath);
|
|
187
|
+
if (content === null) {
|
|
188
|
+
error('Failed to read DRIFT-LOG.md');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const newRow = `| ${safeAsset} | ${safeCampaign} | ${safeOldElement} | ${safeUpdate} | ${safeDeadline} | pending |`;
|
|
192
|
+
|
|
193
|
+
// Find the Deprecation Backlog marker and append after it
|
|
194
|
+
const marker = '<!-- DEPRECATION ENTRIES BELOW THIS LINE -->';
|
|
195
|
+
let updated;
|
|
196
|
+
const markerCount = content.split(marker).length - 1;
|
|
197
|
+
if (markerCount > 1) {
|
|
198
|
+
error(`DRIFT-LOG.md has ${markerCount} occurrences of the Deprecation marker. File may be corrupted.`);
|
|
199
|
+
}
|
|
200
|
+
if (markerCount === 1) {
|
|
201
|
+
updated = content.replace(marker, marker + '\n' + newRow);
|
|
202
|
+
} else {
|
|
203
|
+
// Fallback: append at end of content
|
|
204
|
+
updated = content.trimEnd() + '\n' + newRow + '\n';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
safeWriteFile(driftLogPath, updated);
|
|
208
|
+
|
|
209
|
+
output(
|
|
210
|
+
{ appended: true, asset: safeAsset, deadline: safeDeadline },
|
|
211
|
+
raw,
|
|
212
|
+
'deprecation added'
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = {
|
|
217
|
+
cmdDriftLogAppend,
|
|
218
|
+
cmdDriftLogDeprecation,
|
|
219
|
+
};
|