@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.
- package/CHANGELOG.md +33 -0
- package/README.md +3 -1
- package/bin/gsd-t-audit-distill.cjs +171 -0
- package/bin/gsd-t-logging-envelope-check.cjs +566 -0
- package/bin/gsd-t-logging-scaffolder.cjs +281 -0
- package/bin/gsd-t-migrate-logging.cjs +203 -0
- package/bin/gsd-t-trace-distill.cjs +196 -0
- package/bin/gsd-t-verify-gate.cjs +2 -0
- package/bin/gsd-t.js +67 -7
- package/commands/gsd-t-help.md +8 -0
- package/commands/gsd-t-init.md +11 -0
- package/commands/gsd-t-migrate-logging.md +46 -0
- package/commands/gsd-t-verify.md +2 -1
- package/package.json +1 -1
- package/templates/CLAUDE-global.md +37 -0
- package/templates/logging/audit-module.template.ts +469 -0
- package/templates/logging/trace-module.template.ts +190 -0
- package/templates/workflows/gsd-t-phase.workflow.js +33 -0
|
@@ -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({
|