@tekyzinc/gsd-t 4.19.14 → 4.20.11

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.
@@ -0,0 +1,281 @@
1
+ "use strict";
2
+
3
+ // bin/gsd-t-logging-scaffolder.cjs
4
+ //
5
+ // M100-D1 — the stack-adaptive storage SCAFFOLDER that STOPS for human
6
+ // approval and NEVER silently picks a logging backend. Owns the sole
7
+ // init-scaffold seam consumed by d2 (trace machinery), d4 (audit machinery),
8
+ // and d5 (migrate-logging / defaults pilot).
9
+ //
10
+ // Contract: .gsd-t/contracts/logging-scaffold-seam-contract.md (1.0.0)
11
+ //
12
+ // scaffoldLogging({ projectDir, stack, approve }) -> envelope
13
+ // Without `approve`:
14
+ // { status: "PAUSED", alternatives: [...], resumeToken }
15
+ // — NEVER writes a backend, NEVER returns a `backend` value.
16
+ // With `approve` (first-time) or a valid recorded choice already on disk:
17
+ // { backend, traceSink, auditSink, recordedAt, resumeToken }
18
+ //
19
+ // Zero external npm runtime deps — fs/path/crypto only.
20
+
21
+ const fs = require("fs");
22
+ const path = require("path");
23
+ const crypto = require("crypto");
24
+
25
+ const VALID_BACKENDS = ["db-table", "local-sqlite", "local-jsonl"];
26
+
27
+ const CHOICE_DIR = ".gsd-t";
28
+ const CHOICE_FILE = "logging-scaffold-choice.json";
29
+
30
+ // ─── Stack detection ─────────────────────────────────────────────────────────
31
+ //
32
+ // has-DB (package.json deps signal a DB client, or a DB config file/env is
33
+ // present) -> table-backed alternative is offered.
34
+ // no-server/desktop -> local store alternatives (sqlite | flat-file jsonl),
35
+ // with sqlite flagged as the recommended default for audit queryability.
36
+
37
+ const DB_DEP_SIGNATURES = [
38
+ "pg", "postgres", "postgresql", "mysql", "mysql2", "mongodb", "mongoose",
39
+ "sequelize", "prisma", "typeorm", "knex", "drizzle-orm", "better-sqlite3",
40
+ "sqlite3", "@supabase/supabase-js", "planetscale",
41
+ ];
42
+
43
+ function readJsonSafe(filePath) {
44
+ try {
45
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
46
+ } catch (_) {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function detectStack(projectDir) {
52
+ const pkgPath = path.join(projectDir, "package.json");
53
+ const pkg = readJsonSafe(pkgPath) || {};
54
+ const deps = Object.assign({}, pkg.dependencies || {}, pkg.devDependencies || {});
55
+ const depNames = Object.keys(deps).map((d) => d.toLowerCase());
56
+
57
+ const hasDb = DB_DEP_SIGNATURES.some((sig) =>
58
+ depNames.some((d) => d === sig || d.includes(sig))
59
+ );
60
+
61
+ // Server signal: a "start"/"dev" script implies a running server process
62
+ // that could own a DB connection; a "bin" field implies a CLI/desktop tool.
63
+ const scripts = pkg.scripts || {};
64
+ const hasServerScript = !!(scripts.start || scripts.dev || scripts.serve);
65
+ const isCliOrDesktop = !!pkg.bin || !hasServerScript;
66
+
67
+ return {
68
+ hasDb,
69
+ isCliOrDesktop,
70
+ detectedFrom: pkgPath,
71
+ };
72
+ }
73
+
74
+ // ─── Alternatives ────────────────────────────────────────────────────────────
75
+ //
76
+ // MUST present REAL alternatives — never silently pick. SQLite is flagged
77
+ // over flat-file (jsonl) for audit queryability per contract §Detect.
78
+
79
+ function buildAlternatives(stack) {
80
+ const alternatives = [];
81
+
82
+ if (stack.hasDb) {
83
+ alternatives.push({
84
+ backend: "db-table",
85
+ label: "Database table (uses the project's existing DB connection)",
86
+ recommended: true,
87
+ reason: "Project already has a database dependency — reuse it for queryable trace/audit rows.",
88
+ });
89
+ }
90
+
91
+ alternatives.push({
92
+ backend: "local-sqlite",
93
+ label: "Local SQLite file (.gsd-t/logging.db)",
94
+ recommended: !stack.hasDb,
95
+ reason: "Queryable via SQL — required for the audit admin query surface. Recommended over flat-file when no server DB is present.",
96
+ });
97
+
98
+ alternatives.push({
99
+ backend: "local-jsonl",
100
+ label: "Local flat-file JSONL (.gsd-t/logging.jsonl)",
101
+ recommended: false,
102
+ reason: "Simplest option, but NOT queryable — flagged below SQLite for audit use cases that need query support.",
103
+ });
104
+
105
+ return alternatives;
106
+ }
107
+
108
+ // ─── Recorded-choice persistence ────────────────────────────────────────────
109
+
110
+ function choiceFilePath(projectDir) {
111
+ return path.join(projectDir, CHOICE_DIR, CHOICE_FILE);
112
+ }
113
+
114
+ function isValidRecordedChoice(record) {
115
+ if (!record || typeof record !== "object") return false;
116
+ if (typeof record.backend !== "string") return false;
117
+ if (!VALID_BACKENDS.includes(record.backend)) return false;
118
+ if (typeof record.recordedAt !== "string" || !record.recordedAt) return false;
119
+ if (typeof record.resumeToken !== "string" || !record.resumeToken) return false;
120
+ return true;
121
+ }
122
+
123
+ // Reads + validates the recorded choice. A corrupt/partial/truncated file,
124
+ // or a record whose `backend` is out of the fixed enum, is treated as
125
+ // "no recorded choice" — never crashes, never silently proceeds with a bad
126
+ // value. See M100 pre-mortem FINDING 7.
127
+ function readRecordedChoice(projectDir) {
128
+ const filePath = choiceFilePath(projectDir);
129
+ if (!fs.existsSync(filePath)) return null;
130
+ let raw;
131
+ try {
132
+ raw = fs.readFileSync(filePath, "utf8");
133
+ } catch (_) {
134
+ return null;
135
+ }
136
+ let record;
137
+ try {
138
+ record = JSON.parse(raw);
139
+ } catch (_) {
140
+ // Corrupt/truncated JSON — treated as unrecorded, never crashes.
141
+ return null;
142
+ }
143
+ if (!isValidRecordedChoice(record)) return null;
144
+ return record;
145
+ }
146
+
147
+ function writeRecordedChoice(projectDir, record) {
148
+ const dir = path.join(projectDir, CHOICE_DIR);
149
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
150
+ const filePath = choiceFilePath(projectDir);
151
+ fs.writeFileSync(filePath, JSON.stringify(record, null, 2) + "\n", "utf8");
152
+ }
153
+
154
+ // ─── Doc-record writer ──────────────────────────────────────────────────────
155
+ //
156
+ // Records the chosen backend into the project's CLAUDE.md (preferred) or
157
+ // docs/infrastructure.md (fallback) so the choice is human-visible without
158
+ // re-reading the raw JSON record.
159
+
160
+ const DOC_MARKER_START = "<!-- gsd-t-logging-scaffold:start -->";
161
+ const DOC_MARKER_END = "<!-- gsd-t-logging-scaffold:end -->";
162
+
163
+ function docBlock(envelope) {
164
+ return [
165
+ DOC_MARKER_START,
166
+ "## Logging Backend (M100)",
167
+ "",
168
+ `**Backend:** \`${envelope.backend}\``,
169
+ `**Recorded:** ${envelope.recordedAt}`,
170
+ `**Trace sink:** ${envelope.traceSink.kind} (${envelope.traceSink.path || envelope.traceSink.table})`,
171
+ `**Audit sink:** ${envelope.auditSink.kind} (${envelope.auditSink.path || envelope.auditSink.table}) — queryable: ${envelope.auditSink.kind !== "flat-file"}`,
172
+ "",
173
+ DOC_MARKER_END,
174
+ ].join("\n");
175
+ }
176
+
177
+ function writeChoiceToProjectDocs(projectDir, envelope) {
178
+ const candidates = [
179
+ path.join(projectDir, "CLAUDE.md"),
180
+ path.join(projectDir, "docs", "infrastructure.md"),
181
+ ];
182
+ const targetPath = candidates.find((p) => fs.existsSync(p)) || candidates[0];
183
+
184
+ const block = docBlock(envelope);
185
+ let content = "";
186
+ if (fs.existsSync(targetPath)) {
187
+ content = fs.readFileSync(targetPath, "utf8");
188
+ } else {
189
+ const dir = path.dirname(targetPath);
190
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
191
+ }
192
+
193
+ if (content.includes(DOC_MARKER_START) && content.includes(DOC_MARKER_END)) {
194
+ const startIdx = content.indexOf(DOC_MARKER_START);
195
+ const endIdx = content.indexOf(DOC_MARKER_END) + DOC_MARKER_END.length;
196
+ content = content.slice(0, startIdx) + block + content.slice(endIdx);
197
+ } else {
198
+ content = content.replace(/\s*$/, "") + "\n\n" + block + "\n";
199
+ }
200
+
201
+ fs.writeFileSync(targetPath, content, "utf8");
202
+ return targetPath;
203
+ }
204
+
205
+ // ─── Seam envelope builder ──────────────────────────────────────────────────
206
+
207
+ function sinkFor(backend, kind) {
208
+ // kind: "trace" | "audit"
209
+ if (backend === "db-table") {
210
+ return { kind: "db-table", table: kind === "trace" ? "gsdt_trace_log" : "gsdt_audit_log" };
211
+ }
212
+ if (backend === "local-sqlite") {
213
+ return { kind: "sqlite", path: `.gsd-t/${kind}.db` };
214
+ }
215
+ // local-jsonl
216
+ return { kind: "flat-file", path: `.gsd-t/${kind}.jsonl` };
217
+ }
218
+
219
+ function buildEnvelope(backend, recordedAt, resumeToken) {
220
+ return {
221
+ backend,
222
+ traceSink: sinkFor(backend, "trace"),
223
+ auditSink: sinkFor(backend, "audit"),
224
+ recordedAt,
225
+ resumeToken,
226
+ };
227
+ }
228
+
229
+ function makeResumeToken(projectDir) {
230
+ return crypto.createHash("sha256").update(path.resolve(projectDir)).digest("hex").slice(0, 16);
231
+ }
232
+
233
+ // ─── The seam entry point ───────────────────────────────────────────────────
234
+
235
+ function scaffoldLogging({ projectDir, stack, approve, resumeToken } = {}) {
236
+ if (!projectDir) throw new Error("scaffoldLogging: projectDir is required");
237
+
238
+ const detectedStack = stack || detectStack(projectDir);
239
+ const token = resumeToken || makeResumeToken(projectDir);
240
+
241
+ // Deterministic resume: if a VALID recorded choice already exists for this
242
+ // token, short-circuit — never re-prompt. Corrupt/partial/unmatched/
243
+ // out-of-enum records all fall through to PAUSE (M100 pre-mortem FINDING 7).
244
+ const recorded = readRecordedChoice(projectDir);
245
+ if (recorded && recorded.resumeToken === token) {
246
+ return buildEnvelope(recorded.backend, recorded.recordedAt, recorded.resumeToken);
247
+ }
248
+
249
+ if (!approve) {
250
+ // NEVER silently pick. STOP for human approval — the ONE sanctioned pause.
251
+ return {
252
+ status: "PAUSED",
253
+ alternatives: buildAlternatives(detectedStack),
254
+ resumeToken: token,
255
+ };
256
+ }
257
+
258
+ if (!VALID_BACKENDS.includes(approve)) {
259
+ throw new Error(
260
+ `scaffoldLogging: approve must be one of ${VALID_BACKENDS.join(", ")}, got "${approve}"`
261
+ );
262
+ }
263
+
264
+ const recordedAt = new Date().toISOString();
265
+ const record = { backend: approve, recordedAt, resumeToken: token };
266
+ writeRecordedChoice(projectDir, record);
267
+
268
+ const envelope = buildEnvelope(approve, recordedAt, token);
269
+ writeChoiceToProjectDocs(projectDir, envelope);
270
+
271
+ return envelope;
272
+ }
273
+
274
+ module.exports = {
275
+ scaffoldLogging,
276
+ detectStack,
277
+ buildAlternatives,
278
+ readRecordedChoice,
279
+ isValidRecordedChoice,
280
+ VALID_BACKENDS,
281
+ };
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * GSD-T Brownfield Logging Migration (M100-D5-T2).
6
+ *
7
+ * Scaffolds the two framework-default logging streams (trace + audit) into
8
+ * an EXISTING project, ADDITIVELY — every pre-existing project file is left
9
+ * byte-for-byte unchanged; only NEW logging files are added.
10
+ *
11
+ * Dispatch seam: invoked via `bin/gsd-t.js` `case "migrate-logging"`, wired
12
+ * by d1 (the sole editor of that file) on d5's behalf. See
13
+ * .gsd-t/contracts/logging-scaffold-seam-contract.md §Ownership boundary.
14
+ *
15
+ * Consumes:
16
+ * - bin/gsd-t-logging-scaffolder.cjs (d1) — scaffoldLogging() storage seam
17
+ * - templates/logging/trace-module.template.ts (d2)
18
+ * - templates/logging/audit-module.template.ts (d4)
19
+ * - bin/gsd-t-trace-distill.cjs (d2) — distillTraceCategories()
20
+ * - bin/gsd-t-audit-distill.cjs (d4) — distillAuditActions()
21
+ * Publishes: .gsd-t/contracts/logging-schema-distillation-contract.md
22
+ *
23
+ * Hard rule (Destructive Action Guard / M100 pre-mortem): this module MUST
24
+ * NEVER overwrite, modify, or delete a file that already exists in the
25
+ * target project. It only creates files that are ABSENT. Every write is
26
+ * guarded by an existence check first.
27
+ *
28
+ * Exports:
29
+ * migrateLogging(projectDir, opts) -> {
30
+ * ok: boolean,
31
+ * created: string[], // relative paths of NEW files written
32
+ * skipped: string[], // relative paths that already existed (untouched)
33
+ * dispatchedVia: "bin/gsd-t.js case \"migrate-logging\"" (informational),
34
+ * scaffold: <seam envelope | PAUSED envelope>,
35
+ * }
36
+ * run(argv) -> Promise<void> // CLI entry, used by the bin/gsd-t.js dispatch case
37
+ */
38
+
39
+ const fs = require('fs');
40
+ const path = require('path');
41
+
42
+ const { scaffoldLogging } = require('./gsd-t-logging-scaffolder.cjs');
43
+ const { distillTraceCategories } = require('./gsd-t-trace-distill.cjs');
44
+ const { distillAuditActions } = require('./gsd-t-audit-distill.cjs');
45
+
46
+ const TRACE_TEMPLATE_PATH = path.join(__dirname, '..', 'templates', 'logging', 'trace-module.template.ts');
47
+ const AUDIT_TEMPLATE_PATH = path.join(__dirname, '..', 'templates', 'logging', 'audit-module.template.ts');
48
+
49
+ const TRACE_DEST_REL = path.join('src', 'logging', 'trace.ts');
50
+ const AUDIT_DEST_REL = path.join('src', 'logging', 'audit.ts');
51
+ const SCHEMA_DEST_REL = path.join('.gsd-t', 'logging-schema.json');
52
+
53
+ /**
54
+ * Copies `srcPath` to `<projectDir>/<destRel>` ONLY IF the destination does
55
+ * NOT already exist. Never overwrites. Returns 'created' | 'skipped'.
56
+ */
57
+ function copyIfAbsent(projectDir, destRel, srcPath) {
58
+ const destAbs = path.join(projectDir, destRel);
59
+ if (fs.existsSync(destAbs)) {
60
+ return 'skipped';
61
+ }
62
+ const destDir = path.dirname(destAbs);
63
+ fs.mkdirSync(destDir, { recursive: true });
64
+ const content = fs.readFileSync(srcPath, 'utf8');
65
+ fs.writeFileSync(destAbs, content, 'utf8');
66
+ return 'created';
67
+ }
68
+
69
+ /**
70
+ * Writes the distilled per-project schema (trace categories + audit actions)
71
+ * to `.gsd-t/logging-schema.json`, ONLY IF that file does not already exist.
72
+ * Distills from `planPath` when provided and present; an absent/omitted plan
73
+ * yields empty category/action lists (never confabulated) rather than an error.
74
+ */
75
+ function writeDistilledSchemaIfAbsent(projectDir, planPath) {
76
+ const destAbs = path.join(projectDir, SCHEMA_DEST_REL);
77
+ if (fs.existsSync(destAbs)) {
78
+ return { status: 'skipped', categories: [], actions: [] };
79
+ }
80
+
81
+ let categories = [];
82
+ let actions = [];
83
+ if (planPath && fs.existsSync(planPath)) {
84
+ categories = distillTraceCategories(planPath).categories;
85
+ actions = distillAuditActions(planPath).actions;
86
+ }
87
+
88
+ const dir = path.dirname(destAbs);
89
+ fs.mkdirSync(dir, { recursive: true });
90
+ fs.writeFileSync(
91
+ destAbs,
92
+ JSON.stringify({ categories, actions, distilledFrom: planPath || null }, null, 2) + '\n',
93
+ 'utf8'
94
+ );
95
+ return { status: 'created', categories, actions };
96
+ }
97
+
98
+ /**
99
+ * migrateLogging(projectDir, opts) -> result
100
+ *
101
+ * opts:
102
+ * planPath - optional path to the project's own plan, used to distill the
103
+ * per-project trace category / audit action schema. Absent ⇒
104
+ * empty schema (a question, never a confabulated guess).
105
+ * approve - forwarded to scaffoldLogging(); when omitted the storage
106
+ * seam PAUSES for human approval (never silently picks) unless
107
+ * a valid choice is already recorded for this project.
108
+ *
109
+ * ADDITIVE / NON-DESTRUCTIVE: every write is preceded by an existence check;
110
+ * a file that already exists in the target project is left byte-for-byte
111
+ * untouched (recorded in `skipped`, never in `created`).
112
+ */
113
+ function migrateLogging(projectDir, opts) {
114
+ opts = opts || {};
115
+ if (!projectDir || typeof projectDir !== 'string') {
116
+ throw new Error('migrateLogging: projectDir is required');
117
+ }
118
+ if (!fs.existsSync(projectDir)) {
119
+ throw new Error(`migrateLogging: projectDir does not exist: ${projectDir}`);
120
+ }
121
+
122
+ const created = [];
123
+ const skipped = [];
124
+
125
+ const traceResult = copyIfAbsent(projectDir, TRACE_DEST_REL, TRACE_TEMPLATE_PATH);
126
+ (traceResult === 'created' ? created : skipped).push(TRACE_DEST_REL);
127
+
128
+ const auditResult = copyIfAbsent(projectDir, AUDIT_DEST_REL, AUDIT_TEMPLATE_PATH);
129
+ (auditResult === 'created' ? created : skipped).push(AUDIT_DEST_REL);
130
+
131
+ const schemaResult = writeDistilledSchemaIfAbsent(projectDir, opts.planPath);
132
+ (schemaResult.status === 'created' ? created : skipped).push(SCHEMA_DEST_REL);
133
+
134
+ // Storage seam — d1's scaffolder. Deterministic-resume + human-pause
135
+ // semantics are entirely owned by scaffoldLogging(); this module never
136
+ // second-guesses or silently forces a backend.
137
+ const scaffold = scaffoldLogging({ projectDir, approve: opts.approve });
138
+
139
+ return {
140
+ ok: true,
141
+ created,
142
+ skipped,
143
+ dispatchedVia: 'bin/gsd-t.js case "migrate-logging" (wired by d1 — see logging-scaffold-seam-contract.md)',
144
+ scaffold,
145
+ };
146
+ }
147
+
148
+ module.exports = {
149
+ migrateLogging,
150
+ run,
151
+ // Test surface:
152
+ copyIfAbsent,
153
+ writeDistilledSchemaIfAbsent,
154
+ TRACE_DEST_REL,
155
+ AUDIT_DEST_REL,
156
+ SCHEMA_DEST_REL,
157
+ };
158
+
159
+ // ── CLI ──────────────────────────────────────────────────────────────────
160
+ //
161
+ // Usage: gsd-t migrate-logging <projectDir> [--plan <path>] [--approve <backend>]
162
+ //
163
+ // Invoked by bin/gsd-t.js's `case "migrate-logging"` dispatch as
164
+ // `run(args.slice(1))` — argv here EXCLUDES the leading "migrate-logging"
165
+ // subcommand token.
166
+
167
+ function _parseArgv(argv) {
168
+ const out = { projectDir: null, planPath: null, approve: undefined };
169
+ const positional = [];
170
+ for (let i = 0; i < argv.length; i++) {
171
+ const a = argv[i];
172
+ if (a === '--plan') out.planPath = argv[++i] || null;
173
+ else if (a === '--approve') out.approve = argv[++i];
174
+ else positional.push(a);
175
+ }
176
+ out.projectDir = positional[0] || null;
177
+ return out;
178
+ }
179
+
180
+ async function run(argv) {
181
+ const args = _parseArgv(argv || []);
182
+ if (!args.projectDir) {
183
+ process.stderr.write(
184
+ 'usage: gsd-t migrate-logging <projectDir> [--plan <path-to-plan.md>] [--approve <db-table|local-sqlite|local-jsonl>]\n'
185
+ );
186
+ process.exitCode = 64;
187
+ return;
188
+ }
189
+
190
+ const result = migrateLogging(args.projectDir, { planPath: args.planPath, approve: args.approve });
191
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
192
+ if (result.scaffold && result.scaffold.status === 'PAUSED') {
193
+ // Storage choice not yet approved — informational exit, not a failure.
194
+ process.exitCode = 0;
195
+ }
196
+ }
197
+
198
+ if (require.main === module) {
199
+ run(process.argv.slice(2)).catch((e) => {
200
+ process.stderr.write(String((e && e.message) || e) + '\n');
201
+ process.exit(1);
202
+ });
203
+ }
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * GSD-T Trace-Half Category Distiller (M100 D2).
6
+ *
7
+ * Distills the concrete trace CATEGORY set from a project's own PLAN —
8
+ * NEVER confabulates a category absent from the plan (feedback_no_confabulated_examples).
9
+ * Emits categories as DATA, never baked into the envelope gate (d3 stays
10
+ * value-blind — the gate checks presence+type of `category`, never a value).
11
+ *
12
+ * Contract: .gsd-t/contracts/logging-schema-distillation-contract.md v1.0.0
13
+ * Consumed contract: .gsd-t/contracts/trace-logging-contract.md
14
+ *
15
+ * Shares NO file with the audit-half distiller (bin/gsd-t-audit-distill.cjs,
16
+ * owned by d4) — mechanizes no-collapse by construction.
17
+ *
18
+ * Exports:
19
+ * distillTraceCategories(planPath) -> { categories: [{ category, source }] }
20
+ */
21
+
22
+ const fs = require('fs');
23
+
24
+ // ── Integration-point signatures ────────────────────────────────────────────
25
+ //
26
+ // A trace-worthy operation is a concrete REST/JSON/external-call integration
27
+ // point named in the project's plan (per logging-schema-distillation-contract
28
+ // §UMI pilot grounding: Grain / Airtable / Anthropic / Apify for UMI).
29
+ //
30
+ // This is a STRUCTURAL extraction, not a closed list: a hardcoded allowlist
31
+ // would silently yield ZERO categories for a plan naming services outside the
32
+ // list (e.g. Plaid/Shopify/QuickBooks), violating the project doctrine against
33
+ // hardcoding a finite list for an open category. So the primary path greps
34
+ // the plan text for integration-point CUES — REST/API/webhook/fetch/axios/
35
+ // HTTP-client mentions, `*.com`-shaped service hostnames, and
36
+ // `process.env.*_API_KEY`-shaped env vars — and grounds the emitted category
37
+ // in the nearest proper-noun token on the matched line (mirroring the audit
38
+ // distiller's grounded-extraction approach: never invent, always cite a
39
+ // source line). KNOWN_INTEGRATION_SIGNATURES is kept only as a recognizer
40
+ // HINT (checked first, since a known name is unambiguous) — never as the
41
+ // sole gate for what counts as trace-worthy.
42
+
43
+ const KNOWN_INTEGRATION_SIGNATURES = [
44
+ 'Grain',
45
+ 'Airtable',
46
+ 'Anthropic',
47
+ 'Apify',
48
+ 'Slack',
49
+ 'Stripe',
50
+ 'Twilio',
51
+ 'SendGrid',
52
+ 'Softr',
53
+ ];
54
+
55
+ // Structural cues marking a plan line as naming a concrete external
56
+ // integration point — never a business-domain word list, purely syntactic.
57
+ // Each cue is captured so the service token can be extracted RELATIVE to the
58
+ // cue's own position, not just "first capitalized word on the line" (which
59
+ // over-matches sentence-leading words like "We"/"Our"/"The").
60
+ const INTEGRATION_CUE_RE =
61
+ /\b(?:REST|API|webhook|fetch|axios|HTTP client|Messages API|Files API)\b|[A-Za-z0-9-]+\.com\b|process\.env\.[A-Z0-9_]*_API_KEY\b/;
62
+
63
+ const STOPWORDS = new Set([
64
+ 'API', 'REST', 'HTTP', 'HTTPS', 'JSON', 'URL', 'ID', 'MCP', 'CLI', 'SOP',
65
+ 'We', 'Our', 'The', 'This', 'That', 'A', 'An', 'It', 'They',
66
+ ]);
67
+
68
+ /**
69
+ * Best-effort proper-noun token identifying the service named on a line that
70
+ * already matched INTEGRATION_CUE_RE — purely structural: looks at the
71
+ * capitalized word immediately BEFORE the cue match first (e.g. "Plaid API",
72
+ * "Shopify webhook"), falling back to immediately after (e.g. "REST client
73
+ * syncs QuickBooks"), then to the nearest non-stopword capitalized token
74
+ * anywhere on the line. Never a sentence-leading word like "We"/"The".
75
+ */
76
+ function _extractServiceToken(line, cueMatch) {
77
+ const before = line.slice(0, cueMatch.index);
78
+ const after = line.slice(cueMatch.index + cueMatch[0].length);
79
+
80
+ const beforeTokens = before.match(/\b[A-Z][a-zA-Z0-9]*\b/g) || [];
81
+ for (let i = beforeTokens.length - 1; i >= 0; i--) {
82
+ if (!STOPWORDS.has(beforeTokens[i])) return beforeTokens[i];
83
+ }
84
+
85
+ const afterTokens = after.match(/\b[A-Z][a-zA-Z0-9]*\b/g) || [];
86
+ for (const t of afterTokens) {
87
+ if (!STOPWORDS.has(t)) return t;
88
+ }
89
+
90
+ const allTokens = line.match(/\b[A-Z][a-zA-Z0-9]*\b/g) || [];
91
+ for (const t of allTokens) {
92
+ if (!STOPWORDS.has(t)) return t;
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * Finds the first line in `lines` containing `needle` as a whole-word match,
99
+ * returning { lineNumber, text } or null if absent. Whole-word match avoids
100
+ * a substring false-positive (e.g. "Grainger" would not match "Grain").
101
+ */
102
+ function findFirstMatchingLine(lines, needle) {
103
+ const re = new RegExp('\\b' + needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
104
+ for (let i = 0; i < lines.length; i++) {
105
+ if (re.test(lines[i])) {
106
+ return { lineNumber: i + 1, text: lines[i].trim() };
107
+ }
108
+ }
109
+ return null;
110
+ }
111
+
112
+ /**
113
+ * Distills the concrete trace CATEGORY set from a project plan file.
114
+ *
115
+ * @param {string} planPath - absolute or relative path to the project's plan
116
+ * (e.g. docs/plan.md).
117
+ * @returns {{ categories: Array<{ category: string, source: string }> }}
118
+ * `categories` is empty (never an error, never a confabulated placeholder)
119
+ * when the plan has no trace-worthy operations. Each entry's `source` is
120
+ * the grep-traceable plan line the category was distilled from.
121
+ */
122
+ function distillTraceCategories(planPath) {
123
+ if (!planPath || typeof planPath !== 'string') {
124
+ throw new Error('distillTraceCategories: planPath is required');
125
+ }
126
+ if (!fs.existsSync(planPath)) {
127
+ throw new Error(`distillTraceCategories: plan not found at ${planPath}`);
128
+ }
129
+
130
+ const text = fs.readFileSync(planPath, 'utf8');
131
+ const lines = text.split(/\r?\n/);
132
+
133
+ const categories = [];
134
+ const seen = new Set();
135
+
136
+ // Pass 1 — known-service recognizer hint (unambiguous, checked first).
137
+ for (const signature of KNOWN_INTEGRATION_SIGNATURES) {
138
+ const match = findFirstMatchingLine(lines, signature);
139
+ if (match) {
140
+ categories.push({
141
+ category: signature,
142
+ source: `${planPath}:${match.lineNumber}: ${match.text}`,
143
+ });
144
+ seen.add(signature);
145
+ }
146
+ }
147
+
148
+ // Pass 2 — structural extraction over lines carrying an integration cue,
149
+ // for any named service NOT already covered by the known-list hint.
150
+ for (let i = 0; i < lines.length; i++) {
151
+ const line = lines[i];
152
+ const cueMatch = INTEGRATION_CUE_RE.exec(line);
153
+ if (!cueMatch) continue;
154
+ const token = _extractServiceToken(line, cueMatch);
155
+ if (!token || seen.has(token)) continue;
156
+ seen.add(token);
157
+ categories.push({
158
+ category: token,
159
+ source: `${planPath}:${i + 1}: ${line.trim()}`,
160
+ });
161
+ }
162
+
163
+ return { categories };
164
+ }
165
+
166
+ module.exports = {
167
+ distillTraceCategories,
168
+ KNOWN_INTEGRATION_SIGNATURES,
169
+ };
170
+
171
+ // ── CLI ──────────────────────────────────────────────────────────────────
172
+
173
+ function _parseArgv(argv) {
174
+ const out = { planPath: null };
175
+ for (let i = 0; i < argv.length; i++) {
176
+ const a = argv[i];
177
+ if (a === '--plan') out.planPath = argv[++i] || null;
178
+ }
179
+ return out;
180
+ }
181
+
182
+ if (require.main === module) {
183
+ const args = _parseArgv(process.argv.slice(2));
184
+ if (!args.planPath) {
185
+ process.stderr.write('usage: gsd-t-trace-distill.cjs --plan <path-to-plan.md>\n');
186
+ process.exit(2);
187
+ }
188
+ try {
189
+ const result = distillTraceCategories(args.planPath);
190
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
191
+ process.exit(0);
192
+ } catch (err) {
193
+ process.stderr.write(JSON.stringify({ error: String((err && err.message) || err) }, null, 2) + '\n');
194
+ process.exit(1);
195
+ }
196
+ }
@@ -305,6 +305,8 @@ function _detectDefaultTrack2(projectDir, notes) {
305
305
  });
306
306
  }
307
307
 
308
+ plan.push({ id: 'logging-envelope', cmd: 'node', args: [path.join(__dirname, 'gsd-t-logging-envelope-check.cjs'), '--project', projectDir], timeoutMs: 30000 }); // M100 D3: structural trace+audit envelope gate, FAIL-CLOSED
309
+
308
310
  // secrets — gitleaks (PATH detection deferred to runtime)
309
311
  if (_hasOnPath('gitleaks')) {
310
312
  plan.push({