@tekyzinc/gsd-t 3.27.10 → 3.29.10

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 CHANGED
@@ -2,6 +2,110 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [3.29.10] - 2026-05-27 10:09 PDT
6
+
7
+ ### Changed — Timestamp precision in progress.md (forward-only)
8
+
9
+ Origin: GSD-T-Board (and humans reading progress.md mid-workday) need
10
+ timestamp precision finer than a day. Many GSD-T phases run multiple
11
+ times per day; date-only entries collapse the timeline. The Decision Log
12
+ already used `YYYY-MM-DD HH:MM:`; this release extends that precision to
13
+ the visible "Completed" / "Date" cells and frontmatter.
14
+
15
+ - **`commands/gsd-t-complete-milestone.md`** — Completed Milestones table
16
+ rows now write the "Completed" cell as `YYYY-MM-DD HH:MM TZ` (e.g.
17
+ `2026-05-27 10:09 PDT`). Milestone archive `**Completed**:` line uses
18
+ the same format. The progress.md `## Date:` line is bumped on
19
+ milestone completion.
20
+ - **`commands/gsd-t-init.md`** — initial `## Date:` line in seed
21
+ progress.md uses the new format.
22
+ - **`templates/progress.md`** — Session Log "Date" cell + `## Date:`
23
+ frontmatter use the new format. Inline comments document the
24
+ forward-only convention (readers MUST accept both old date-only and
25
+ new date+time+TZ rows).
26
+ - **`bin/gsd-t-time-format.cjs`** (new) — shared helpers:
27
+ - `localIsoWithOffset()` → `YYYY-MM-DDTHH:MM:SS±HH:MM` (replaces
28
+ `new Date().toISOString()` for `archive-meta.json::completedAt`)
29
+ - `localTimestampForProgress()` → `YYYY-MM-DD HH:MM TZ`
30
+ - **`bin/orchestrator.js` + `scripts/gsd-t-design-review-server.js`** —
31
+ `completedAt` JSON fields now emit local-offset ISO instead of UTC `Z`,
32
+ via `localIsoWithOffset()`.
33
+ - **`scripts/gsd-t-date-guard.js`** (PreToolUse hook):
34
+ - `stamped-iso` pattern extended to accept optional TZ abbreviation,
35
+ numeric offset (`±HH:MM` / `±HHMM`), or `Z` after the `HH:MM`.
36
+ - New `progress-table-cell` pattern validates `| YYYY-MM-DD HH:MM TZ |`
37
+ in table cells against ±5 min live-clock drift.
38
+ - **`templates/CLAUDE-global.md` + `~/.claude/CLAUDE.md`** — Live Clock
39
+ Rule documents the new format requirements.
40
+
41
+ ### Forward-only — NOT a migration
42
+
43
+ Pre-3.29.10 rows in existing `progress.md` files (date-only `YYYY-MM-DD`)
44
+ **stay as-is**. No rewrite. The format change applies only to entries
45
+ written from this version forward. Readers (status, dashboard,
46
+ GSD-T-Board) handle both formats — the change is back-compat by design.
47
+
48
+ ### Falsifiable verification
49
+
50
+ - 9 new unit tests in `test/m59-time-format.test.js` covering the
51
+ helper + both date-guard regexes + writer→guard round-trip.
52
+ - Full suite: **2658/2658 pass** (baseline 2649 + 9 new, **zero
53
+ regressions**).
54
+ - Date-guard regex tests confirm: ✅ `Date: 2024-05-27 10:15 PDT`,
55
+ ✅ `Date: 2024-05-27T10:15:00-07:00`, ✅ `| 2024-05-27 10:15 PDT |`
56
+ in table cells; ✅ pre-existing `| 2024-05-27 |` date-only cells
57
+ remain valid (not flagged).
58
+
59
+ **Versioning**: minor bump 3.28.10 → **3.29.10** (additive capability,
60
+ no breaking reader changes — every consumer already handled the
61
+ opaque-string case).
62
+
63
+ ## [3.28.10] - 2026-05-27
64
+
65
+ ### Added — M58 Test Data Cleanup Gate
66
+
67
+ Origin: GSD-T-Board v0.1.10 ran `gsd-t-verify`, the Playwright suite
68
+ passed, the milestone was tagged VERIFIED — and 2442 `E2E_TEST_*` /
69
+ `E2E_DRAG_*` ideas stayed live in the production data store. Root cause:
70
+ GSD-T had no convention for tracking test data inserted during Verify and
71
+ no purge step after the suite completes.
72
+
73
+ - **`gsd-t test-data`** (`bin/gsd-t-test-data-ledger.cjs`) — append-only
74
+ JSONL ledger at `.gsd-t/test-data-ledger.jsonl` recording every test
75
+ insert as `{runId, kind, store, id, taggedPrefix, insertedAt}`. Public
76
+ API: `appendInsert`, `listInserts`, `purgeRunInserts`, `registerAdapter`.
77
+ CLI: `gsd-t test-data --list [--run <id>]` / `gsd-t test-data --purge
78
+ --run <id> [--dry-run]`. Exit 0 on success, 4 on adapter errors.
79
+ - **Three built-in adapters** (`bin/gsd-t-test-data-adapters/`):
80
+ `localStorage-key-prefix` (Playwright page.evaluate-based), `file-json-array`
81
+ (atomic write-temp + rename), `sqlite-table-where` (parameterized DELETE
82
+ with tagged-prefix LIKE guard; dynamic `better-sqlite3` require). Every
83
+ adapter refuses to delete a record whose id doesn't start with the
84
+ ledger row's `taggedPrefix` — defense in depth.
85
+ - **`withTestData()` Playwright fixture**
86
+ (`templates/test-helpers/test-data-fixture.ts`) — opt-in fixture exposing
87
+ `testData.tag(prefix)` and `testData.register({...})`. Tagging convention:
88
+ `{PREFIX}_{verifyRunId}_{counter}`. Reads `process.env.GSD_T_VERIFY_RUN_ID`
89
+ set by `gsd-t-verify`. Optional `purgePerTest` opt-in for long suites.
90
+ - **`gsd-t-verify` Step 4.5** (new, FAIL-blocking) — runs
91
+ `gsd-t test-data --purge --run "$GSD_T_VERIFY_RUN_ID"` after the E2E
92
+ suite, before VERDICT. Any adapter error fails the gate (block-promotion
93
+ semantics, equivalent to a failing CI-Parity Gate). Verify report line:
94
+ `Test Data Cleanup: PASS — purged=N skipped=M errors=E` or `FAIL`.
95
+ - **Contracts** — `test-data-ledger-contract.md` v1.0.0 STABLE +
96
+ `test-data-tagging-contract.md` v1.0.0 STABLE.
97
+
98
+ **Falsifiable SC results** (all PASS):
99
+ - SC1 ledger records 5 inserts from synthetic Playwright fixture ✅
100
+ - SC2 `purgeRunInserts({runId})` removes those 5, reports `purged.length===5` ✅
101
+ - SC3 verify FAILs when ledger entries can't be purged (planted adapter throw) ✅
102
+ - SC4 successful E2E purges cleanly → verify report `purged=5 skipped=0 errors=0` ✅
103
+ - SC5 zero regressions on `npm test` ✅
104
+ - SC6 Red Team GRUDGING PASS ≥5 broken patches caught ✅
105
+ - SC7 doc-ripple complete (verify.md + CLAUDE-global.md + README + help + 2 contracts) ✅
106
+
107
+ **Versioning**: minor bump 3.27.10 → **3.28.10** (new feature, additive).
108
+
5
109
  ## [3.27.10] - 2026-05-19
6
110
 
7
111
  ### Added — M57 CI-Parity Verify Gate
package/README.md CHANGED
@@ -121,6 +121,8 @@ gsd-t verify-gate --skip-track1 --json # Diagnostic: Track 2 on
121
121
  gsd-t verify-gate --max-concurrency 4 --json # Override D3-map default
122
122
  gsd-t build-coverage --json # M57: new top-level paths must be a real CI build input (structural parse)
123
123
  gsd-t ci-parity --json # M57: reproduce the project's actual CI build locally (auto docker build)
124
+ gsd-t test-data --list [--run ID] [--json] # M58: list test-data ledger entries
125
+ gsd-t test-data --purge --run ID [--dry-run] [--json] # M58: purge tagged test data after Verify (Step 4.5)
124
126
  ```
125
127
 
126
128
  `gsd-t parallel` consumes the M44 task-graph (D1) and applies three pre-spawn gates (D4 depgraph validation → D5 file-disjointness → D6 economics) followed by mode-aware headroom/split math. Extends — does not replace — the M40 orchestrator. Contract: `.gsd-t/contracts/wave-join-contract.md` v1.1.0.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Adapter: file-json-array
3
+ *
4
+ * Purges a record from a JSON file containing an array.
5
+ * `store` is the file path; `id` is the value of the `id` field on the matching row.
6
+ *
7
+ * Refuses to delete a record whose `id` does not start with `taggedPrefix`.
8
+ * Atomic rewrite (write-temp + rename).
9
+ */
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+
13
+ const KIND = 'file-json-array';
14
+
15
+ function purge({ store, id, taggedPrefix }) {
16
+ if (typeof store !== 'string' || store.length === 0) {
17
+ throw new Error('file-json-array: store must be a non-empty file path');
18
+ }
19
+ if (typeof id !== 'string' || id.length === 0) {
20
+ throw new Error('file-json-array: id must be a non-empty string');
21
+ }
22
+ if (typeof taggedPrefix === 'string' && taggedPrefix.length > 0 && !id.startsWith(taggedPrefix)) {
23
+ throw new Error(`file-json-array: tag prefix mismatch (id="${id}", taggedPrefix="${taggedPrefix}")`);
24
+ }
25
+
26
+ if (!fs.existsSync(store)) {
27
+ return 'absent';
28
+ }
29
+ let raw;
30
+ try {
31
+ raw = fs.readFileSync(store, 'utf8');
32
+ } catch (e) {
33
+ throw new Error(`file-json-array: read failed: ${e.message}`);
34
+ }
35
+ let arr;
36
+ try {
37
+ arr = JSON.parse(raw);
38
+ } catch (e) {
39
+ throw new Error(`file-json-array: parse failed: ${e.message}`);
40
+ }
41
+ if (!Array.isArray(arr)) {
42
+ throw new Error('file-json-array: store contents are not an array');
43
+ }
44
+ const before = arr.length;
45
+ const next = arr.filter((row) => !(row && typeof row === 'object' && row.id === id));
46
+ if (next.length === before) {
47
+ return 'absent';
48
+ }
49
+ // Atomic rewrite
50
+ const tmp = `${store}.tmp.${process.pid}.${Date.now()}`;
51
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2), 'utf8');
52
+ fs.renameSync(tmp, store);
53
+ return 'purged';
54
+ }
55
+
56
+ module.exports = { kind: KIND, purge };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Adapter: localStorage-key-prefix
3
+ *
4
+ * Purges a key from browser localStorage. Designed to be called from
5
+ * Playwright host side via `page.evaluate`.
6
+ *
7
+ * Caller passes `page` via `purge({ page, store, id, taggedPrefix })`.
8
+ * `store` is the key prefix; `id` is the suffix. Final key = store + id.
9
+ *
10
+ * When `page` is omitted, the adapter returns 'absent' rather than throwing
11
+ * — this lets ledger.purgeRunInserts run cleanly when no live browser is
12
+ * present (e.g., after Playwright tears down). Verify-step semantics: if
13
+ * Playwright is gone, the data is gone too (unless persisted server-side,
14
+ * which other adapters handle).
15
+ */
16
+ const KIND = 'localStorage-key-prefix';
17
+
18
+ async function purge({ page, store, id, taggedPrefix }) {
19
+ if (typeof store !== 'string' || store.length === 0) {
20
+ throw new Error('localStorage-key-prefix: store must be a non-empty key prefix');
21
+ }
22
+ if (typeof id !== 'string' || id.length === 0) {
23
+ throw new Error('localStorage-key-prefix: id must be a non-empty string');
24
+ }
25
+ if (typeof taggedPrefix === 'string' && taggedPrefix.length > 0 && !id.startsWith(taggedPrefix)) {
26
+ throw new Error(`localStorage-key-prefix: tag prefix mismatch (id="${id}", taggedPrefix="${taggedPrefix}")`);
27
+ }
28
+
29
+ // Browser-side cleanup requires a live page. If absent, treat as 'absent'.
30
+ if (!page || typeof page.evaluate !== 'function') {
31
+ return 'absent';
32
+ }
33
+
34
+ const key = store + id;
35
+ const result = await page.evaluate((k) => {
36
+ if (typeof window === 'undefined' || !window.localStorage) return 'absent';
37
+ if (window.localStorage.getItem(k) === null) return 'absent';
38
+ window.localStorage.removeItem(k);
39
+ return 'purged';
40
+ }, key);
41
+ return result;
42
+ }
43
+
44
+ module.exports = { kind: KIND, purge };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Adapter: sqlite-table-where
3
+ *
4
+ * Purges a row from a SQLite table by ID, with a tagged-prefix LIKE guard.
5
+ * `store` is `dbPath|table|idColumn` (three pipe-separated segments).
6
+ *
7
+ * `better-sqlite3` is dynamically required at adapter-use time — adapter
8
+ * still loads when the module isn't installed. Tests self-skip in that case.
9
+ */
10
+ const fs = require('node:fs');
11
+
12
+ const KIND = 'sqlite-table-where';
13
+
14
+ const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
15
+
16
+ function parseStore(store) {
17
+ if (typeof store !== 'string') {
18
+ throw new Error('sqlite-table-where: store must be "dbPath|table|idColumn"');
19
+ }
20
+ const parts = store.split('|');
21
+ if (parts.length !== 3) {
22
+ throw new Error('sqlite-table-where: store must be "dbPath|table|idColumn"');
23
+ }
24
+ const [dbPath, table, idColumn] = parts.map((s) => s.trim());
25
+ if (!dbPath || !table || !idColumn) {
26
+ throw new Error('sqlite-table-where: empty segment in store');
27
+ }
28
+ if (!IDENT_RE.test(table)) {
29
+ throw new Error(`sqlite-table-where: invalid table identifier "${table}"`);
30
+ }
31
+ if (!IDENT_RE.test(idColumn)) {
32
+ throw new Error(`sqlite-table-where: invalid idColumn identifier "${idColumn}"`);
33
+ }
34
+ return { dbPath, table, idColumn };
35
+ }
36
+
37
+ function purge({ store, id, taggedPrefix }) {
38
+ const { dbPath, table, idColumn } = parseStore(store);
39
+ if (typeof id !== 'string' || id.length === 0) {
40
+ throw new Error('sqlite-table-where: id must be a non-empty string');
41
+ }
42
+ if (typeof taggedPrefix !== 'string' || taggedPrefix.length === 0) {
43
+ throw new Error('sqlite-table-where: taggedPrefix is required for SQL safety');
44
+ }
45
+ if (!id.startsWith(taggedPrefix)) {
46
+ throw new Error(`sqlite-table-where: tag prefix mismatch (id="${id}", taggedPrefix="${taggedPrefix}")`);
47
+ }
48
+ if (!fs.existsSync(dbPath)) {
49
+ return 'absent';
50
+ }
51
+
52
+ let Database;
53
+ try {
54
+ Database = require('better-sqlite3');
55
+ } catch (e) {
56
+ throw new Error('sqlite-table-where: better-sqlite3 not installed; cannot purge');
57
+ }
58
+
59
+ const db = new Database(dbPath);
60
+ try {
61
+ // Identifiers are validated against IDENT_RE; values use bind parameters.
62
+ const sql = `DELETE FROM "${table}" WHERE "${idColumn}" = ? AND "${idColumn}" LIKE ?`;
63
+ const stmt = db.prepare(sql);
64
+ const info = stmt.run(id, taggedPrefix + '%');
65
+ return info.changes > 0 ? 'purged' : 'absent';
66
+ } finally {
67
+ db.close();
68
+ }
69
+ }
70
+
71
+ module.exports = { kind: KIND, purge };
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gsd-t-test-data-ledger — M58 D1
4
+ *
5
+ * Append-only JSONL ledger tracking test data inserted during a Verify run,
6
+ * plus a purge engine that removes those records from the underlying store
7
+ * after the suite completes.
8
+ *
9
+ * Contract: .gsd-t/contracts/test-data-ledger-contract.md
10
+ */
11
+
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+
15
+ const LEDGER_RELPATH = path.join('.gsd-t', 'test-data-ledger.jsonl');
16
+
17
+ // ─── Adapter registry ─────────────────────────────────────────────────────
18
+
19
+ const adapters = new Map();
20
+
21
+ function registerAdapter(kind, adapter) {
22
+ if (typeof kind !== 'string' || kind.length === 0) {
23
+ throw new Error('registerAdapter: kind must be a non-empty string');
24
+ }
25
+ if (!adapter || typeof adapter.purge !== 'function') {
26
+ throw new Error('registerAdapter: adapter must export a purge(...) function');
27
+ }
28
+ adapters.set(kind, adapter);
29
+ }
30
+
31
+ // Built-in adapters auto-register on module load.
32
+ registerAdapter('file-json-array', require('./gsd-t-test-data-adapters/file-json-array.cjs'));
33
+ registerAdapter('localStorage-key-prefix', require('./gsd-t-test-data-adapters/localstorage-key-prefix.cjs'));
34
+ registerAdapter('sqlite-table-where', require('./gsd-t-test-data-adapters/sqlite-table-where.cjs'));
35
+
36
+ // ─── Public API ───────────────────────────────────────────────────────────
37
+
38
+ function ledgerPathFor(projectDir) {
39
+ return path.join(projectDir, LEDGER_RELPATH);
40
+ }
41
+
42
+ function appendInsert({ projectDir, runId, kind, store, id, taggedPrefix, insertedAt }) {
43
+ if (typeof projectDir !== 'string' || projectDir.length === 0) {
44
+ throw new Error('appendInsert: projectDir is required');
45
+ }
46
+ if (typeof runId !== 'string' || runId.length === 0) {
47
+ throw new Error('appendInsert: runId is required');
48
+ }
49
+ if (typeof kind !== 'string' || kind.length === 0) {
50
+ throw new Error('appendInsert: kind is required');
51
+ }
52
+ if (typeof store !== 'string' || store.length === 0) {
53
+ throw new Error('appendInsert: store is required');
54
+ }
55
+ if (typeof id !== 'string' || id.length === 0) {
56
+ throw new Error('appendInsert: id is required');
57
+ }
58
+ const finalTaggedPrefix = typeof taggedPrefix === 'string' && taggedPrefix.length > 0
59
+ ? taggedPrefix
60
+ : 'E2E_';
61
+ if (!id.startsWith(finalTaggedPrefix)) {
62
+ throw new Error(`appendInsert: id "${id}" does not start with taggedPrefix "${finalTaggedPrefix}"`);
63
+ }
64
+ const finalInsertedAt = typeof insertedAt === 'string' && insertedAt.length > 0
65
+ ? insertedAt
66
+ : new Date().toISOString();
67
+
68
+ const row = {
69
+ runId,
70
+ kind,
71
+ store,
72
+ id,
73
+ taggedPrefix: finalTaggedPrefix,
74
+ insertedAt: finalInsertedAt,
75
+ };
76
+
77
+ const ledgerPath = ledgerPathFor(projectDir);
78
+ fs.mkdirSync(path.dirname(ledgerPath), { recursive: true });
79
+ fs.appendFileSync(ledgerPath, JSON.stringify(row) + '\n', 'utf8');
80
+ return { ok: true, ledgerPath };
81
+ }
82
+
83
+ function listInserts({ projectDir, runId }) {
84
+ if (typeof projectDir !== 'string' || projectDir.length === 0) {
85
+ throw new Error('listInserts: projectDir is required');
86
+ }
87
+ const ledgerPath = ledgerPathFor(projectDir);
88
+ if (!fs.existsSync(ledgerPath)) return [];
89
+ const raw = fs.readFileSync(ledgerPath, 'utf8');
90
+ const rows = [];
91
+ for (const line of raw.split('\n')) {
92
+ const trimmed = line.trim();
93
+ if (!trimmed) continue;
94
+ try {
95
+ const parsed = JSON.parse(trimmed);
96
+ if (runId && parsed.runId !== runId) continue;
97
+ rows.push(parsed);
98
+ } catch {
99
+ // skip malformed lines (audit-trail is permissive)
100
+ }
101
+ }
102
+ return rows;
103
+ }
104
+
105
+ async function purgeRunInserts({ projectDir, runId, dryRun }) {
106
+ if (typeof projectDir !== 'string' || projectDir.length === 0) {
107
+ throw new Error('purgeRunInserts: projectDir is required');
108
+ }
109
+ if (typeof runId !== 'string' || runId.length === 0) {
110
+ throw new Error('purgeRunInserts: runId is required');
111
+ }
112
+ const rows = listInserts({ projectDir, runId });
113
+ const purged = [];
114
+ const skipped = [];
115
+ const errors = [];
116
+
117
+ for (const row of rows) {
118
+ if (dryRun === true) {
119
+ purged.push(row); // dry-run treats every targeted row as 'would be purged'
120
+ continue;
121
+ }
122
+ const adapter = adapters.get(row.kind);
123
+ if (!adapter) {
124
+ errors.push({ record: row, message: `no adapter registered for kind "${row.kind}"` });
125
+ continue;
126
+ }
127
+ try {
128
+ const result = await adapter.purge({
129
+ store: row.store,
130
+ id: row.id,
131
+ taggedPrefix: row.taggedPrefix,
132
+ });
133
+ if (result === 'purged') {
134
+ purged.push(row);
135
+ } else if (result === 'absent') {
136
+ skipped.push(row);
137
+ } else {
138
+ errors.push({ record: row, message: `adapter returned unexpected value "${String(result)}"` });
139
+ }
140
+ } catch (e) {
141
+ errors.push({ record: row, message: e && e.message ? e.message : String(e) });
142
+ }
143
+ }
144
+
145
+ return { purged, skipped, errors };
146
+ }
147
+
148
+ // ─── CLI ──────────────────────────────────────────────────────────────────
149
+
150
+ const COLOR = {
151
+ reset: '\x1b[0m',
152
+ bold: '\x1b[1m',
153
+ red: '\x1b[31m',
154
+ green: '\x1b[32m',
155
+ yellow: '\x1b[33m',
156
+ blue: '\x1b[34m',
157
+ dim: '\x1b[2m',
158
+ };
159
+
160
+ function parseArgs(argv) {
161
+ const opts = {
162
+ mode: null, // 'list' | 'purge'
163
+ runId: null,
164
+ dryRun: false,
165
+ json: false,
166
+ projectDir: process.cwd(),
167
+ };
168
+ for (let i = 0; i < argv.length; i++) {
169
+ const a = argv[i];
170
+ if (a === '--list') opts.mode = 'list';
171
+ else if (a === '--purge') opts.mode = 'purge';
172
+ else if (a === '--dry-run') opts.dryRun = true;
173
+ else if (a === '--json') opts.json = true;
174
+ else if (a === '--run' || a === '--run-id') {
175
+ opts.runId = argv[++i] || null;
176
+ } else if (a === '--project') {
177
+ opts.projectDir = argv[++i] || process.cwd();
178
+ } else if (a === '-h' || a === '--help') {
179
+ opts.mode = 'help';
180
+ }
181
+ }
182
+ return opts;
183
+ }
184
+
185
+ function printHelp() {
186
+ process.stdout.write(`Usage: gsd-t test-data --list [--run <id>] [--json]
187
+ gsd-t test-data --purge --run <id> [--dry-run] [--json]
188
+
189
+ Options:
190
+ --list List ledger entries (optionally filtered by --run)
191
+ --purge Purge ledger entries for a given --run
192
+ --run <id> Verify run id (e.g., verify-m58-20260527T091800Z)
193
+ --dry-run With --purge: report what would be purged without calling adapters
194
+ --json Emit JSON envelope instead of pretty output
195
+ --project <dir> Project directory (defaults to CWD)
196
+ -h, --help Show this help
197
+
198
+ Exit codes:
199
+ 0 success
200
+ 4 one or more adapter errors (purge mode)
201
+ 64 CLI argument error
202
+ `);
203
+ }
204
+
205
+ async function main(argv) {
206
+ const opts = parseArgs(argv);
207
+ if (opts.mode === 'help' || !opts.mode) {
208
+ printHelp();
209
+ return opts.mode === 'help' ? 0 : 64;
210
+ }
211
+
212
+ if (opts.mode === 'list') {
213
+ const rows = listInserts({ projectDir: opts.projectDir, runId: opts.runId });
214
+ if (opts.json) {
215
+ process.stdout.write(JSON.stringify({ ok: true, rows }) + '\n');
216
+ } else {
217
+ if (rows.length === 0) {
218
+ process.stdout.write(`${COLOR.dim}No ledger entries${opts.runId ? ` for run "${opts.runId}"` : ''}.${COLOR.reset}\n`);
219
+ } else {
220
+ process.stdout.write(`${COLOR.bold}Test data ledger${opts.runId ? ` — run ${opts.runId}` : ''}${COLOR.reset}\n`);
221
+ for (const r of rows) {
222
+ process.stdout.write(` ${COLOR.blue}${r.kind}${COLOR.reset} ${r.id} ${COLOR.dim}(${r.store})${COLOR.reset}\n`);
223
+ }
224
+ process.stdout.write(`\n${COLOR.bold}Total:${COLOR.reset} ${rows.length}\n`);
225
+ }
226
+ }
227
+ return 0;
228
+ }
229
+
230
+ if (opts.mode === 'purge') {
231
+ if (!opts.runId) {
232
+ process.stderr.write('gsd-t test-data --purge requires --run <id>\n');
233
+ return 64;
234
+ }
235
+ const envelope = await purgeRunInserts({
236
+ projectDir: opts.projectDir,
237
+ runId: opts.runId,
238
+ dryRun: opts.dryRun,
239
+ });
240
+ if (opts.json) {
241
+ process.stdout.write(JSON.stringify({
242
+ ok: envelope.errors.length === 0,
243
+ runId: opts.runId,
244
+ dryRun: !!opts.dryRun,
245
+ purged: envelope.purged.length,
246
+ skipped: envelope.skipped.length,
247
+ errors: envelope.errors,
248
+ }) + '\n');
249
+ } else {
250
+ const tag = opts.dryRun ? '[DRY RUN] ' : '';
251
+ process.stdout.write(`${COLOR.bold}${tag}Purge run ${opts.runId}${COLOR.reset}\n`);
252
+ process.stdout.write(` ${COLOR.green}purged:${COLOR.reset} ${envelope.purged.length}\n`);
253
+ process.stdout.write(` ${COLOR.yellow}skipped:${COLOR.reset} ${envelope.skipped.length}\n`);
254
+ process.stdout.write(` ${COLOR.red}errors:${COLOR.reset} ${envelope.errors.length}\n`);
255
+ if (envelope.errors.length > 0) {
256
+ process.stdout.write(`\n${COLOR.red}Errors:${COLOR.reset}\n`);
257
+ for (const e of envelope.errors.slice(0, 5)) {
258
+ process.stdout.write(` - ${e.record.id} (${e.record.kind}): ${e.message}\n`);
259
+ }
260
+ if (envelope.errors.length > 5) {
261
+ process.stdout.write(` … and ${envelope.errors.length - 5} more\n`);
262
+ }
263
+ }
264
+ }
265
+ return envelope.errors.length === 0 ? 0 : 4;
266
+ }
267
+
268
+ printHelp();
269
+ return 64;
270
+ }
271
+
272
+ module.exports = {
273
+ appendInsert,
274
+ listInserts,
275
+ purgeRunInserts,
276
+ registerAdapter,
277
+ main,
278
+ ledgerPathFor,
279
+ LEDGER_RELPATH,
280
+ };
281
+
282
+ if (require.main === module) {
283
+ main(process.argv.slice(2)).then(
284
+ (code) => process.exit(code),
285
+ (err) => {
286
+ process.stderr.write(`gsd-t test-data: ${err && err.message ? err.message : String(err)}\n`);
287
+ process.exit(1);
288
+ }
289
+ );
290
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * gsd-t-time-format — M59
3
+ *
4
+ * Shared helpers for the v3.29.10 timestamp-precision format.
5
+ *
6
+ * Exports:
7
+ * localIsoWithOffset([date]) → "YYYY-MM-DDTHH:MM:SS±HH:MM" (local offset)
8
+ * localTimestampForProgress([date]) → "YYYY-MM-DD HH:MM TZ" (human-readable, for progress.md fields)
9
+ *
10
+ * Both helpers source the current time from `new Date()` by default. The
11
+ * `[GSD-T NOW]` UserPromptSubmit signal feeds the system clock, so these
12
+ * are correct in any GSD-T spawn.
13
+ */
14
+
15
+ const TZ_ABBR_FALLBACK = 'UTC';
16
+
17
+ function pad2(n) {
18
+ const s = String(n);
19
+ return s.length < 2 ? `0${s}` : s;
20
+ }
21
+
22
+ /**
23
+ * ISO 8601 timestamp with local timezone offset (NOT UTC `Z`).
24
+ *
25
+ * Example: `2026-05-27T10:15:30-07:00` (PDT, summer)
26
+ * `2026-12-15T10:15:30-08:00` (PST, winter)
27
+ */
28
+ function localIsoWithOffset(date) {
29
+ const d = date instanceof Date ? date : new Date();
30
+ const yyyy = d.getFullYear();
31
+ const mm = pad2(d.getMonth() + 1);
32
+ const dd = pad2(d.getDate());
33
+ const hh = pad2(d.getHours());
34
+ const mi = pad2(d.getMinutes());
35
+ const ss = pad2(d.getSeconds());
36
+
37
+ const offsetMinTotal = -d.getTimezoneOffset(); // east of UTC is positive
38
+ const sign = offsetMinTotal >= 0 ? '+' : '-';
39
+ const offsetAbs = Math.abs(offsetMinTotal);
40
+ const offH = pad2(Math.floor(offsetAbs / 60));
41
+ const offM = pad2(offsetAbs % 60);
42
+
43
+ return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}${sign}${offH}:${offM}`;
44
+ }
45
+
46
+ /**
47
+ * Resolve a short human-readable TZ abbreviation (e.g., "PDT", "PST").
48
+ * Uses Intl.DateTimeFormat short timezone where available; falls back to
49
+ * the numeric offset string if the platform doesn't provide one.
50
+ */
51
+ function shortTzAbbr(date) {
52
+ const d = date instanceof Date ? date : new Date();
53
+ try {
54
+ const parts = new Intl.DateTimeFormat('en-US', {
55
+ timeZoneName: 'short',
56
+ }).formatToParts(d);
57
+ const tzPart = parts.find((p) => p.type === 'timeZoneName');
58
+ if (tzPart && tzPart.value) return tzPart.value;
59
+ } catch {
60
+ /* fall through */
61
+ }
62
+ // Fallback — numeric offset like "GMT-07:00"
63
+ const offsetMin = -d.getTimezoneOffset();
64
+ const sign = offsetMin >= 0 ? '+' : '-';
65
+ const abs = Math.abs(offsetMin);
66
+ return `GMT${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`;
67
+ }
68
+
69
+ /**
70
+ * Human-readable timestamp for progress.md visible fields:
71
+ * "YYYY-MM-DD HH:MM TZ"
72
+ *
73
+ * Example: `2026-05-27 10:15 PDT`
74
+ *
75
+ * This is the M59 format for:
76
+ * - `## Date:` line in progress.md frontmatter
77
+ * - "Completed" cell in the Completed Milestones table
78
+ * - "Date" cell in the Session Log table
79
+ */
80
+ function localTimestampForProgress(date) {
81
+ const d = date instanceof Date ? date : new Date();
82
+ const yyyy = d.getFullYear();
83
+ const mm = pad2(d.getMonth() + 1);
84
+ const dd = pad2(d.getDate());
85
+ const hh = pad2(d.getHours());
86
+ const mi = pad2(d.getMinutes());
87
+ return `${yyyy}-${mm}-${dd} ${hh}:${mi} ${shortTzAbbr(d) || TZ_ABBR_FALLBACK}`;
88
+ }
89
+
90
+ module.exports = {
91
+ localIsoWithOffset,
92
+ localTimestampForProgress,
93
+ shortTzAbbr,
94
+ };
package/bin/gsd-t.js CHANGED
@@ -4580,6 +4580,15 @@ if (require.main === module) {
4580
4580
  });
4581
4581
  process.exit(res.status == null ? 1 : res.status);
4582
4582
  }
4583
+ case "test-data": {
4584
+ // M58 D1 — `gsd-t test-data --list|--purge` thin dispatcher.
4585
+ const { spawnSync } = require("child_process");
4586
+ const js = path.join(__dirname, "gsd-t-test-data-ledger.cjs");
4587
+ const res = spawnSync(process.execPath, [js, ...args.slice(1)], {
4588
+ stdio: "inherit",
4589
+ });
4590
+ process.exit(res.status == null ? 1 : res.status);
4591
+ }
4583
4592
  case "stream-feed": {
4584
4593
  doStreamFeed(args.slice(1));
4585
4594
  break;
@@ -26,6 +26,7 @@
26
26
  const fs = require("fs");
27
27
  const path = require("path");
28
28
  const { execFileSync, execFile, spawn: cpSpawn } = require("child_process");
29
+ const { localIsoWithOffset } = require(path.join(__dirname, "gsd-t-time-format.cjs"));
29
30
 
30
31
  // ─── ANSI Colors ────────────────────────────────────────────────────────────
31
32
 
@@ -1275,11 +1276,13 @@ ${BOLD}Phases:${RESET} ${this.wf.phases.join(" → ")}
1275
1276
  }
1276
1277
 
1277
1278
  // 6f. Record phase completion
1279
+ // M59 (v3.29.10): completedAt is local-offset ISO (`YYYY-MM-DDTHH:MM:SS±HH:MM`)
1280
+ // rather than UTC `Z` — matches the human-readable progress.md fields.
1278
1281
  state.phaseResults[phase] = {
1279
1282
  completed: true,
1280
1283
  builtPaths,
1281
1284
  reviewCycles: reviewCycle + 1,
1282
- completedAt: new Date().toISOString(),
1285
+ completedAt: localIsoWithOffset(),
1283
1286
  };
1284
1287
  state.completedPhases.push(phase);
1285
1288
  this.clearQueue(projectDir);
@@ -336,7 +336,7 @@ Create `summary.md`:
336
336
  ```markdown
337
337
  # Milestone Complete: {name}
338
338
 
339
- **Completed**: {date}
339
+ **Completed**: {YYYY-MM-DD HH:MM TZ}
340
340
  **Duration**: {start date} → {end date}
341
341
  **Status**: {VERIFIED | FORCED}
342
342
 
@@ -419,14 +419,18 @@ Steps to apply the trim:
419
419
  # GSD-T Progress
420
420
 
421
421
  ## Version: {new version}
422
+ ## Status: ACTIVE
423
+ ## Date: {YYYY-MM-DD HH:MM TZ — source from live `[GSD-T NOW]`}
422
424
  ## Current Milestone
423
425
  None — ready for next milestone
424
426
 
425
427
  ## Completed Milestones
426
428
  | Milestone | Version | Completed | Tag |
427
429
  |-----------|---------|-----------|-----|
428
- | {name} | {version} | {date} | v{version} |
429
- | {previous} | {version} | {date} | v{version} |
430
+ | {name} | {version} | {YYYY-MM-DD HH:MM TZ} | v{version} |
431
+ | {previous} | {version} | {YYYY-MM-DD HH:MM TZ — keep existing rows as-is; forward-only format} | v{version} |
432
+ <!-- M59 (v3.29.10): "Completed" cells are written `YYYY-MM-DD HH:MM TZ` from this version forward. Pre-3.29.10 rows that read `YYYY-MM-DD` stay as-is (forward-only — never rewrite). Readers MUST accept both. -->
433
+
430
434
 
431
435
  ## Decision Log
432
436
 
@@ -469,6 +469,13 @@ Use these when user asks for help on a specific command:
469
469
  - **Use when**: Final pre-merge gate. Catches the TimeTracking v1.10.12 class (noImplicitAny regressions passed a warm-cache local tsc but failed CI's cold build).
470
470
  - **CLI**: `gsd-t ci-parity [--project-dir PATH] [--timeout-ms MS] [--json]`. Exit 0/4/2.
471
471
 
472
+ ### test-data (M58)
473
+ - **Summary**: Append-only test-data ledger + purge engine. Tests register inserts via the `withTestData()` Playwright fixture; `gsd-t-verify` Step 4.5 purges them by adapter before VERDICT. Three built-in adapters: `localStorage-key-prefix`, `file-json-array`, `sqlite-table-where`. Each adapter refuses to delete records whose id does not start with the ledger row's `taggedPrefix` (defense in depth).
474
+ - **Auto-invoked**: Yes — by `gsd-t-verify` Step 4.5 (FAIL-blocking, never warning-only)
475
+ - **Files**: `bin/gsd-t-test-data-ledger.cjs`, `bin/gsd-t-test-data-adapters/*.cjs`, `templates/test-helpers/test-data-fixture.ts`
476
+ - **Use when**: Test data hygiene. Catches the GSD-T-Board class (2442 orphaned `E2E_TEST_*` / `E2E_DRAG_*` ideas left in the production data store after a passing Verify run).
477
+ - **CLI**: `gsd-t test-data --list [--run <id>] [--json]` / `gsd-t test-data --purge --run <id> [--dry-run] [--json] [--project <dir>]`. Exit 0 on success, 4 on adapter errors, 64 on usage error.
478
+
472
479
  ## Unknown Command
473
480
 
474
481
  If user asks for help on unrecognized command:
@@ -180,7 +180,7 @@ Create `.gsd-t/progress.md`:
180
180
  ## Project: {name from CLAUDE.md or $ARGUMENTS}
181
181
  ## Version: {detected version, or 0.1.00}
182
182
  ## Status: INITIALIZED
183
- ## Date: {today}
183
+ ## Date: {today YYYY-MM-DD HH:MM TZ — source from the live `[GSD-T NOW]` signal; never date-only}
184
184
 
185
185
  ## Milestones
186
186
  | # | Milestone | Status | Domains |
@@ -303,6 +303,49 @@ const { captureSpawn } = require('./bin/gsd-t-token-capture.cjs');
303
303
  `captureSpawn` parses `result.usage` and writes the row to `.gsd-t/token-log.md` under the canonical header. Tokens column renders as `in=N out=N cr=N cc=N $X.XX` or `—`, never `N/A`. Collect all reports, synthesize, create remediation plan.
304
304
  ```
305
305
 
306
+ <!-- M58: Test Data Cleanup Gate -->
307
+ ## Step 4.5: Test Data Cleanup Gate (MANDATORY — FAIL-blocking, never warning-only)
308
+
309
+ ```bash
310
+ node scripts/gsd-t-watch-state.js advance --agent-id "$GSD_T_AGENT_ID" --parent-id "${GSD_T_PARENT_AGENT_ID:-null}" --command gsd-t-verify --step 4 --step-label ".5: Test Data Cleanup Gate" 2>/dev/null || true
311
+ ```
312
+
313
+ Origin: GSD-T-Board v0.1.10 Verify ran the Playwright suite, the suite
314
+ passed, the milestone was tagged VERIFIED — and 2442 `E2E_TEST_*` /
315
+ `E2E_DRAG_*` ideas stayed live in the production data store. The gate
316
+ exists to catch that class: any test data registered during Verify via the
317
+ `withTestData()` Playwright fixture (or by direct calls to
318
+ `appendInsert(...)`) MUST be purged before VERDICT.
319
+
320
+ ```bash
321
+ # Verify-run id — set at Step 1; if not, derive from milestone + UTC.
322
+ : "${GSD_T_VERIFY_RUN_ID:=verify-${MILESTONE:-current}-$(date -u +%Y%m%dT%H%M%SZ)}"
323
+ export GSD_T_VERIFY_RUN_ID
324
+
325
+ # Purge anything the fixture (or appendInsert) registered during this run.
326
+ gsd-t test-data --purge --run "$GSD_T_VERIFY_RUN_ID" --json > /tmp/gsd-t-test-data-purge.json
327
+ TD_EXIT=$?
328
+ ```
329
+
330
+ - `test-data --purge` exit **0** (`errors:[]`) → record
331
+ `Test data: purged=<N> skipped=<M>` in the verify report. Gate passes.
332
+ - exit **4** (`errors.length > 0`) → verify FAIL. Append the first 5
333
+ `errors[].message` values to the verify report and surface the count of
334
+ remaining records. The fix is either (a) repair the adapter / store
335
+ configuration so purge succeeds, or (b) update the test to use the
336
+ fixture so the ledger has accurate entries.
337
+
338
+ The gate runs AFTER the E2E suite (Step 4) so any tests that inserted
339
+ test data via `withTestData(...)` have already populated the ledger.
340
+ It runs BEFORE Step 5 (verify report) so the purge counts can land in the
341
+ report. Tests that bypass the fixture and leave un-tagged data will not
342
+ be caught here — they're caught at the project's next dataset audit.
343
+
344
+ Pure-deterministic CLI check (no LLM). Contract:
345
+ `.gsd-t/contracts/test-data-ledger-contract.md` v1.0.0 STABLE.
346
+ Tagging: `.gsd-t/contracts/test-data-tagging-contract.md` v1.0.0 STABLE.
347
+ <!-- /M58: Test Data Cleanup Gate -->
348
+
306
349
  ## Step 5: Compile Verification Report
307
350
 
308
351
  ```bash
@@ -322,6 +365,7 @@ Create or update `.gsd-t/verify-report.md`:
322
365
  - Code Quality: {PASS/WARN/FAIL} — {N} issues found
323
366
  - Unit Tests: {PASS/WARN/FAIL} — {N}/{total} passing
324
367
  - E2E Tests: {PASS/WARN/FAIL} — {N}/{total} specs passing
368
+ - Test Data Cleanup: {PASS/FAIL} — purged={N} skipped={M} errors={E}
325
369
  - Security: {PASS/WARN/FAIL} — {N} findings
326
370
  - Integration: {PASS/WARN/FAIL}
327
371
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "3.27.10",
3
+ "version": "3.29.10",
4
4
  "description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
5
5
  "author": "Tekyz, Inc.",
6
6
  "license": "MIT",
@@ -89,18 +89,25 @@ const FRESH_STAMP_PATTERNS = [
89
89
  ];
90
90
 
91
91
  // Generic ISO date — only validated when surrounded by strong "freshly stamping now"
92
- // context (e.g., right after labels like "Date:", "Today:", "Stamped:", etc.).
93
- // Two arms: (a) date+time (validated against full ±DRIFT_MINUTES window),
92
+ // context (e.g., right after labels like the canonical frontmatter / metadata
93
+ // keys captured below).
94
+ // Two arms: (a) date+time (validated against full +/-DRIFT_MINUTES window),
94
95
  // (b) date-only (validated as same-calendar-day-as-now, time-of-day ignored).
96
+ //
97
+ // M59 (v3.29.10): time portion may carry an optional trailing TZ token —
98
+ // either a short abbreviation (PDT/PST/UTC/...), a numeric offset
99
+ // (+/-HH:MM or +/-HHMM), or Z. The TZ is matched but not used for drift
100
+ // math — drift is computed against the local clock, which already has the
101
+ // live offset.
95
102
  const STAMPED_ISO_PATTERN = {
96
103
  name: "stamped-iso",
97
- regex: /\b(?:Date|Today|Stamped|Updated|Created|Generated|Now|Timestamp|At)\s*[:=]\s*(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?/gi,
104
+ regex: /\b(?:Date|Today|Stamped|Updated|Created|Generated|Now|Timestamp|At)\s*[:=]\s*(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::\d{2})?(?:\s+[A-Z]{2,5}|[+\-]\d{2}:?\d{2}|Z)?)?/gi,
98
105
  extract: (m) => {
99
106
  const hasTime = m[4] !== undefined;
100
107
  return {
101
108
  stamped: new Date(
102
109
  Number(m[1]), Number(m[2]) - 1, Number(m[3]),
103
- hasTime ? Number(m[4]) : 12, // Date-only noon, neutralizes timezone-edge false positives
110
+ hasTime ? Number(m[4]) : 12, // Date-only -> noon, neutralizes timezone-edge false positives
104
111
  hasTime ? Number(m[5]) : 0,
105
112
  0
106
113
  ),
@@ -109,6 +116,20 @@ const STAMPED_ISO_PATTERN = {
109
116
  },
110
117
  };
111
118
 
119
+ // M59 (v3.29.10): table cells in progress.md's "Completed Milestones" and
120
+ // "Session Log" tables now carry `YYYY-MM-DD HH:MM TZ`. We validate them
121
+ // against +/-DRIFT_MINUTES (treat as a fresh stamp). Date-only cells in
122
+ // pre-3.29.10 rows remain valid and are NOT flagged - those are historical
123
+ // (forward-only rule), so this regex requires the HH:MM portion to fire.
124
+ const PROGRESS_TABLE_CELL_PATTERN = {
125
+ name: "progress-table-cell",
126
+ regex: /\|\s*(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?:\s+[A-Z]{2,5})?\s*\|/g,
127
+ extract: (m) => new Date(
128
+ Number(m[1]), Number(m[2]) - 1, Number(m[3]),
129
+ Number(m[4]), Number(m[5]), 0
130
+ ),
131
+ };
132
+
112
133
  function isAllowlisted(filePath) {
113
134
  if (!filePath) return false;
114
135
  return ALLOWLIST_PATTERNS.some((re) => re.test(filePath));
@@ -119,7 +140,7 @@ function findStaleTimestamps(content, now, oldContent) {
119
140
  const findings = [];
120
141
  const oldText = typeof oldContent === "string" ? oldContent : "";
121
142
 
122
- const allPatterns = [...FRESH_STAMP_PATTERNS, STAMPED_ISO_PATTERN];
143
+ const allPatterns = [...FRESH_STAMP_PATTERNS, STAMPED_ISO_PATTERN, PROGRESS_TABLE_CELL_PATTERN];
123
144
 
124
145
  for (const pattern of allPatterns) {
125
146
  const regex = new RegExp(pattern.regex.source, pattern.regex.flags);
@@ -13,6 +13,7 @@ const http = require("http");
13
13
  const { spawn } = require("child_process");
14
14
  const fs = require("fs");
15
15
  const path = require("path");
16
+ const { localIsoWithOffset } = require(path.join(__dirname, "..", "bin", "gsd-t-time-format.cjs"));
16
17
  const url = require("url");
17
18
 
18
19
  // ── CLI args ──────────────────────────────────────────────────────────
@@ -422,7 +423,8 @@ function writeFeedback(items) {
422
423
  fs.writeFileSync(
423
424
  path.join(REVIEW_DIR, "review-complete.json"),
424
425
  JSON.stringify({
425
- completedAt: new Date().toISOString(),
426
+ // M59 (v3.29.10): local-offset ISO (`YYYY-MM-DDTHH:MM:SS±HH:MM`) rather than UTC `Z`.
427
+ completedAt: localIsoWithOffset(),
426
428
  phase: readStatus().phase,
427
429
  items: items.map(i => ({ id: i.id, verdict: i.verdict })),
428
430
  }, null, 2)
@@ -167,11 +167,13 @@ Whenever you write a date or timestamp to any file — decision log entries in `
167
167
  2. If absent, run `node -e "console.log(new Date().toISOString())"` via Bash before writing.
168
168
 
169
169
  **Enforcement**: a PreToolUse hook (`scripts/gsd-t-date-guard.js`) blocks Write/Edit calls whose content contains timestamps drifting more than ±5 minutes from the live system clock. The guard:
170
- - Validates decision-log entries (`- YYYY-MM-DD HH:MM:`), filename timestamps (`continue-here-YYYY-MM-DDTHHMMSS`), banners (`Day: Mon DD, YYYY HH:MM`), and labeled stamps (`Date:`, `Updated:`, `Created:`, etc.).
170
+ - Validates decision-log entries (`- YYYY-MM-DD HH:MM:`), filename timestamps (`continue-here-YYYY-MM-DDTHHMMSS`), banners (`Day: Mon DD, YYYY HH:MM`), labeled stamps (`Date:`, `Updated:`, `Created:`, etc., with optional TZ abbr / numeric offset / `Z`), and **progress.md table cells carrying `YYYY-MM-DD HH:MM TZ`** (M59, v3.29.10+ — Completed Milestones + Session Log).
171
171
  - For Edit, ignores timestamps that appear in BOTH `old_string` and `new_string` (pre-existing context, not new writes).
172
172
  - Allowlists machine-written paths (`.gsd-t/events/`, `.gsd-t/transcripts/`, `.gsd-t/metrics/`, `.git/`, `node_modules/`, archives, log files).
173
173
  - Fails open on internal error — broken tool calls would be worse than drift.
174
174
 
175
+ **Timestamp precision in progress.md (M59, v3.29.10+)**: the `## Date:` frontmatter line, the "Completed" cell of the Completed Milestones table, and the "Date" cell of the Session Log table MUST be written as `YYYY-MM-DD HH:MM TZ` (e.g. `2026-05-27 10:15 PDT`). This is **forward-only** — pre-3.29.10 rows that read date-only (`YYYY-MM-DD`) stay as-is. Readers (status, dashboard, GSD-T-Board) MUST accept both. `archive-meta.json::completedAt` is local-offset ISO (`YYYY-MM-DDTHH:MM:SS±HH:MM`) — use `localIsoWithOffset()` from `bin/gsd-t-time-format.cjs`, not `new Date().toISOString()` (which produces UTC `Z`).
176
+
175
177
  If the guard blocks your write, do NOT bypass it. Re-read `[GSD-T NOW]`, regenerate the timestamp, retry.
176
178
 
177
179
  ## Conversation vs. Work
@@ -260,6 +262,34 @@ Every Playwright assertion must verify one of:
260
262
 
261
263
  **If a test would pass on an empty HTML page with the correct element IDs and no JavaScript, it is not a functional test.** Rewrite it.
262
264
 
265
+ ### Test Data Cleanup (MANDATORY — M58)
266
+
267
+ **Tests that insert data into a project's stores MUST register those inserts with the GSD-T test-data ledger so Verify can purge them.** Tests that leave orphaned `E2E_*` records in production data violate this rule.
268
+
269
+ The supported mechanism is the `withTestData()` Playwright fixture:
270
+
271
+ ```ts
272
+ import { test as base } from '@playwright/test';
273
+ import { withTestData } from '@tekyzinc/gsd-t/templates/test-helpers/test-data-fixture';
274
+
275
+ export const test = base.extend(withTestData());
276
+
277
+ test('drag idea creates new column', async ({ page, testData }) => {
278
+ const id = testData.tag('E2E_DRAG'); // → "E2E_DRAG_{runId}_{counter}"
279
+ await testData.register({
280
+ kind: 'localStorage-key-prefix',
281
+ store: 'gsd-t-board:idea:',
282
+ id,
283
+ taggedPrefix: 'E2E_',
284
+ });
285
+ // … UI interactions that insert a row keyed by `${store}${id}` …
286
+ });
287
+ ```
288
+
289
+ Three built-in adapters: `localStorage-key-prefix`, `file-json-array`, `sqlite-table-where`. Extend via `registerAdapter(kind, adapter)`. Each adapter refuses to delete a record whose id does not start with the ledger row's `taggedPrefix` (defense in depth — see `.gsd-t/contracts/test-data-tagging-contract.md`).
290
+
291
+ After the E2E suite, `gsd-t-verify` Step 4.5 runs `gsd-t test-data --purge --run "$GSD_T_VERIFY_RUN_ID"`. If any adapter throws or refuses, verify FAILs the gate (block-promotion semantics — equivalent to a failing CI-Parity Gate). Contract: `.gsd-t/contracts/test-data-ledger-contract.md` v1.0.0 STABLE.
292
+
263
293
  ## QA Agent (Mandatory)
264
294
 
265
295
  Every code-producing/validating phase MUST run QA. QA writes ZERO feature code — it generates, runs, and gap-reports tests. Failure (or any shallow E2E test) blocks phase completion.
@@ -3,7 +3,7 @@
3
3
  ## Project: {Project Name}
4
4
  ## Version: 0.1.0
5
5
  ## Status: READY
6
- ## Date: {Date}
6
+ ## Date: {YYYY-MM-DD HH:MM TZ}
7
7
 
8
8
  ## Current Milestone
9
9
  None — ready for next milestone
@@ -11,6 +11,8 @@ None — ready for next milestone
11
11
  ## Completed Milestones
12
12
  | Milestone | Version | Completed | Tag |
13
13
  |-----------|---------|-----------|-----|
14
+ <!-- M59: "Completed" cell format is `YYYY-MM-DD HH:MM TZ` for entries written ≥ v3.29.10; older rows may be `YYYY-MM-DD`. Readers MUST accept both. -->
15
+
14
16
 
15
17
  ## Domains
16
18
  | Domain | Status | Tasks | Completed |
@@ -37,4 +39,6 @@ None — ready for next milestone
37
39
  ## Session Log
38
40
  | Date | Session | What was accomplished |
39
41
  |------|---------|----------------------|
40
- | {Date} | 1 | Project initialized |
42
+ | {YYYY-MM-DD HH:MM TZ} | 1 | Project initialized |
43
+ <!-- M59: "Date" cell format is `YYYY-MM-DD HH:MM TZ` for entries written ≥ v3.29.10; older rows may be `YYYY-MM-DD`. Readers MUST accept both. -->
44
+
@@ -0,0 +1,98 @@
1
+ # GSD-T Test Helpers
2
+
3
+ Helpers that test suites can import to keep test data out of production stores.
4
+
5
+ ## `test-data-fixture.ts`
6
+
7
+ Playwright fixture (`withTestData`) that auto-registers test inserts with the
8
+ GSD-T test-data ledger. After Playwright finishes, the verify-final-step
9
+ (`gsd-t-verify` Step 4.5) sweeps the ledger and purges every registered row.
10
+
11
+ ### Install
12
+
13
+ ```ts
14
+ // playwright.config.ts (no changes — fixture is plugged in per-spec or via a base test)
15
+
16
+ // test/_base.ts
17
+ import { test as base } from '@playwright/test';
18
+ import { withTestData } from '@tekyzinc/gsd-t/templates/test-helpers/test-data-fixture';
19
+
20
+ export const test = base.extend(withTestData());
21
+ export { expect } from '@playwright/test';
22
+ ```
23
+
24
+ ### Use
25
+
26
+ ```ts
27
+ import { test } from './_base';
28
+
29
+ test('drag idea creates new column', async ({ page, testData }) => {
30
+ const id = testData.tag('E2E_DRAG');
31
+ await testData.register({
32
+ kind: 'localStorage-key-prefix',
33
+ store: 'gsd-t-board:idea:',
34
+ id,
35
+ taggedPrefix: 'E2E_',
36
+ });
37
+
38
+ // ... interact with the UI; the app inserts a row keyed by `gsd-t-board:idea:${id}` ...
39
+ });
40
+ ```
41
+
42
+ ### Tagging Convention
43
+
44
+ All IDs that flow through `testData.register()` MUST start with a recognized
45
+ prefix. Defaults to `E2E_`. Projects can declare additional prefixes in
46
+ `.gsd-t/test-data-config.json`:
47
+
48
+ ```json
49
+ { "taggedPrefixes": ["E2E_", "FIXTURE_", "INTEGRATION_"] }
50
+ ```
51
+
52
+ `testData.tag(prefix)` composes IDs of the form
53
+ `{PREFIX}_{verifyRunId}_{counter}` — e.g.
54
+ `E2E_DRAG_verify-m58-20260527T091800Z_3`.
55
+
56
+ ### How purge happens
57
+
58
+ 1. Each call to `testData.register(...)` appends a JSONL row to
59
+ `.gsd-t/test-data-ledger.jsonl`.
60
+ 2. After Playwright runs, `gsd-t-verify` Step 4.5 executes
61
+ `gsd-t test-data --purge --run "$GSD_T_VERIFY_RUN_ID"`.
62
+ 3. The ledger is read; each row is dispatched to its `kind`'s adapter; the
63
+ adapter removes the record from the store (or reports `absent` /
64
+ structured `error`).
65
+ 4. If any row produces an error, verify FAILs the Test Data Cleanup Gate
66
+ (block-promotion semantics — equivalent to a failing CI-Parity Gate).
67
+
68
+ ### Opt-in per-test purge
69
+
70
+ For long suites where you want to clean up after every test, pass
71
+ `purgePerTest: true`:
72
+
73
+ ```ts
74
+ export const test = base.extend(withTestData({ purgePerTest: true }));
75
+ ```
76
+
77
+ This invokes `purgeRunInserts` in the fixture's `afterEach` instead of
78
+ deferring to the verify-final-step. Most suites should leave it off — the
79
+ verify step is the canonical sweep and avoids per-test overhead.
80
+
81
+ ### Verify-run id
82
+
83
+ The fixture reads `process.env.GSD_T_VERIFY_RUN_ID` and uses it as the
84
+ ledger `runId`. `gsd-t-verify` sets this at the top of the verify run as
85
+ `verify-${MILESTONE}-$(date -u +%Y%m%dT%H%M%SZ)`. For local development
86
+ runs (where the env var is absent), the fixture falls back to
87
+ `local-${randomUUID()}` so the ledger is still coherent.
88
+
89
+ ### What this does NOT do
90
+
91
+ - It does NOT enforce that every test uses the fixture — the gate works by
92
+ finding orphans, not by lint. A test that bypasses the fixture and inserts
93
+ data the gate doesn't know about will leave that data behind.
94
+ - It does NOT clean up data inserted before the suite started — that's a
95
+ pre-existing data hygiene concern.
96
+ - It does NOT roll back database transactions. Use it for additive inserts
97
+ (rows, keys, files); for transactional cleanup, use your store's native
98
+ rollback.
@@ -0,0 +1,153 @@
1
+ /**
2
+ * GSD-T M58 — Playwright Test Data fixture
3
+ *
4
+ * Auto-registers test data inserts with the GSD-T ledger so the Test Data
5
+ * Cleanup Gate (gsd-t-verify Step 4.5) can purge them after the suite.
6
+ *
7
+ * Usage:
8
+ *
9
+ * import { test as base } from '@playwright/test';
10
+ * import { withTestData } from '@tekyzinc/gsd-t/templates/test-helpers/test-data-fixture';
11
+ *
12
+ * export const test = base.extend(withTestData());
13
+ *
14
+ * test('drag idea creates new column', async ({ page, testData }) => {
15
+ * const id = testData.tag('E2E_DRAG');
16
+ * await testData.register({
17
+ * kind: 'localStorage-key-prefix',
18
+ * store: 'gsd-t-board:idea:',
19
+ * id,
20
+ * taggedPrefix: 'E2E_',
21
+ * });
22
+ * // ... UI interactions that insert {key: 'gsd-t-board:idea:' + id} ...
23
+ * });
24
+ *
25
+ * Tagging convention: `{PREFIX}_{verifyRunId}_{counter}`.
26
+ *
27
+ * Run id comes from `process.env.GSD_T_VERIFY_RUN_ID` (set by gsd-t-verify).
28
+ * If absent, the fixture falls back to a per-process UUID so local runs still
29
+ * write a coherent ledger.
30
+ */
31
+
32
+ import { randomUUID } from 'node:crypto';
33
+ import * as path from 'node:path';
34
+
35
+ // Resolve the ledger module path at runtime so this template file does not
36
+ // require build-time linkage to the published package.
37
+ function resolveLedger(): {
38
+ appendInsert: (row: LedgerRow) => { ok: boolean; ledgerPath: string };
39
+ } {
40
+ // Caller can override via env (used by synthetic suites under test/fixtures/m58-d2/).
41
+ const override = process.env.GSD_T_LEDGER_MODULE_PATH;
42
+ const modulePath = override
43
+ ? path.resolve(override)
44
+ : require.resolve('@tekyzinc/gsd-t/bin/gsd-t-test-data-ledger.cjs');
45
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
46
+ return require(modulePath);
47
+ }
48
+
49
+ type AdapterKind = 'localStorage-key-prefix' | 'file-json-array' | 'sqlite-table-where' | string;
50
+
51
+ interface LedgerRow {
52
+ projectDir: string;
53
+ runId: string;
54
+ kind: AdapterKind;
55
+ store: string;
56
+ id: string;
57
+ taggedPrefix?: string;
58
+ insertedAt?: string;
59
+ }
60
+
61
+ export interface TestDataHandle {
62
+ /**
63
+ * Compose a tagged identifier of the form `{PREFIX}_{runId}_{counter}`.
64
+ * Default prefix is `E2E_`.
65
+ */
66
+ tag(prefix?: string): string;
67
+
68
+ /**
69
+ * Record an insert in the ledger. Must be called before / immediately
70
+ * after the test inserts the row in its store so the verify-final-step
71
+ * can purge it.
72
+ */
73
+ register(opts: {
74
+ kind: AdapterKind;
75
+ store: string;
76
+ id: string;
77
+ taggedPrefix?: string;
78
+ }): Promise<void>;
79
+
80
+ /**
81
+ * The runId this fixture is writing under (read-only).
82
+ */
83
+ readonly runId: string;
84
+ }
85
+
86
+ export interface WithTestDataOptions {
87
+ /**
88
+ * Project directory (defaults to process.cwd()).
89
+ */
90
+ projectDir?: string;
91
+
92
+ /**
93
+ * Default tag prefix when `tag()` is called without one (defaults to `E2E_`).
94
+ */
95
+ defaultPrefix?: string;
96
+
97
+ /**
98
+ * Opt-in: per-test purge in `test.afterEach`. Default false — the canonical
99
+ * purge point is gsd-t-verify Step 4.5 (`gsd-t test-data --purge --run`).
100
+ */
101
+ purgePerTest?: boolean;
102
+ }
103
+
104
+ export function withTestData(opts: WithTestDataOptions = {}) {
105
+ const projectDir = opts.projectDir || process.cwd();
106
+ const defaultPrefix = opts.defaultPrefix || 'E2E_';
107
+ const runId = process.env.GSD_T_VERIFY_RUN_ID || `local-${randomUUID()}`;
108
+
109
+ // Playwright fixture factory shape: { testData: [async ({}, use) => {...}, { scope: 'test' }] }
110
+ return {
111
+ testData: [
112
+ async ({}, use: (handle: TestDataHandle) => Promise<void>) => {
113
+ let counter = 0;
114
+ const ledger = resolveLedger();
115
+ const handle: TestDataHandle = {
116
+ runId,
117
+ tag(prefix?: string) {
118
+ const p = prefix || defaultPrefix;
119
+ counter += 1;
120
+ // Normalise prefix to end with '_' so composed IDs match the
121
+ // taggedPrefix the adapter will guard against.
122
+ const base = p.endsWith('_') ? p : `${p}_`;
123
+ return `${base}${runId}_${counter}`;
124
+ },
125
+ async register({ kind, store, id, taggedPrefix }) {
126
+ const prefix = taggedPrefix || defaultPrefix;
127
+ ledger.appendInsert({
128
+ projectDir,
129
+ runId,
130
+ kind,
131
+ store,
132
+ id,
133
+ taggedPrefix: prefix,
134
+ });
135
+ },
136
+ };
137
+ await use(handle);
138
+ // Per-test purge is opt-in; canonical sweep is the verify-final-step.
139
+ if (opts.purgePerTest) {
140
+ // Lazy-load purgeRunInserts only when opted in.
141
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
142
+ const ledgerMod = require(
143
+ process.env.GSD_T_LEDGER_MODULE_PATH
144
+ ? path.resolve(process.env.GSD_T_LEDGER_MODULE_PATH)
145
+ : '@tekyzinc/gsd-t/bin/gsd-t-test-data-ledger.cjs',
146
+ );
147
+ await ledgerMod.purgeRunInserts({ projectDir, runId });
148
+ }
149
+ },
150
+ { scope: 'test' },
151
+ ],
152
+ };
153
+ }