@tsfpp/agents 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,21 @@ Versioning follows [Semantic Versioning](https://semver.org/).
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [1.5.0] - 2026-05-18
14
+
15
+ ### Added
16
+
17
+ - Added `copilot/skills/config-standard/SKILL.md`.
18
+ - Added `copilot/skills/log-standard/SKILL.md`.
19
+
20
+ ### Changed
21
+
22
+ - Updated `README.md` with latest guidance updates.
23
+ - Updated `copilot/agents/tsfpp-audit.agent.md` with expanded checks and release hygiene.
24
+ - Updated `copilot/agents/tsfpp-guarded-coding.agent.md` with guarded workflow refinements.
25
+ - Updated `copilot/instructions/tsfpp-base.instructions.md` with aligned base-level standards guidance.
26
+ - Updated `init.mjs` to include installer wiring for newly added skills and updates.
27
+
13
28
  ## [1.4.0] - 2026-05-18
14
29
 
15
30
  ### Added
package/README.md CHANGED
@@ -256,6 +256,8 @@ Skills are loaded automatically by Copilot based on semantic match with what is
256
256
  | `react-coding-standard` | Writing React components, hooks, forms, or stores |
257
257
  | `test-standard` | Writing or reviewing test files; discussing coverage or test structure |
258
258
  | `annotation-standard` | Writing or reviewing any comment, JSDoc block, module header, code marker, or DEVIATION; discussing what to annotate and why |
259
+ | `log-standard` | Writing or reviewing logging code, Logger port implementations, `withRequestLog` usage, or `tap`/`tapErr` side effects |
260
+ | `config-standard` | Writing or reviewing config loaders, `loadConfig` usage, `Config` types, `.env.example`, or `process.env` access |
259
261
 
260
262
  Skills are distilled from the full standard documents — concise enough to fit in the model's context without crowding out code.
261
263
 
package/bin/bootstrap.sh CHANGED
File without changes
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: TSF++ standards compliance auditor. Produces a structured markdown report in docs/audits/ with per-slice checkboxes.
3
3
  name: tsfpp-audit
4
- argument-hint: "target=<path|package|layer> focus=<all|types|boundary|complexity|loc|annotations|security|react|data|prelude|test>"
4
+ argument-hint: "target=<path|package|layer> focus=<all|types|boundary|complexity|loc|annotations|security|react|data|prelude|test|log|config>"
5
5
  tools:
6
6
  - edit/createFile
7
7
  - edit/editFiles
@@ -44,7 +44,7 @@ If `target` and `focus` are present in the message (e.g. `target=src/ focus=test
44
44
  If and only if either is missing and cannot be inferred, ask once:
45
45
 
46
46
  > **Target** — path, package name, or layer to audit (e.g. `src/domain`, `@tsfpp/prelude`, `api layer`)?
47
- > **Focus** — `all` · `types` · `boundary` · `complexity` · `loc` · `annotations` · `security` · `react` · `data` · `prelude` · `test` · or comma-separated combination?
47
+ > **Focus** — `all` · `types` · `boundary` · `complexity` · `loc` · `annotations` · `security` · `react` · `data` · `prelude` · `test` · `log` · `config` · or comma-separated combination?
48
48
 
49
49
  ---
50
50
 
@@ -160,12 +160,26 @@ Append each completed slice to the report:
160
160
  - [ ] 6.3 — No `null`/`undefined` propagation; use `Option<A>`
161
161
  - [ ] 6.6 — `Promise.allSettled` over `Promise.all` when partial failure is meaningful
162
162
 
163
- **Annotations (§7)**
164
- - [ ] 7.x JSDoc on every exported symbol (`@param`, `@returns`; `@law` on combinators)
163
+ **Annotations (§7 + ANNOTATION_CODING_STANDARD — cross-cutting, always checked)**
164
+ - [ ] Module-level JSDoc block present on all files with public exports
165
+ - [ ] Every exported symbol has a JSDoc block
166
+ - [ ] `@param` describes domain constraint (not the type); `@returns` describes meaning (not the type)
167
+ - [ ] `@law` present on all combinators with algebraic properties
168
+ - [ ] `@example` present on smart constructors and non-obvious combinators
169
+ - [ ] No comments that paraphrase the code; no commented-out code
170
+ - [ ] Code markers follow `// MARKER(author, YYYY-MM-DD[, TICKET]): description` format
171
+ - [ ] Every `eslint-disable` paired with a `// DEVIATION(N.M): <reason>` comment
172
+ - [ ] For full annotation audit: use `focus=annotations`
173
+
174
+ **Security (SECURITY_CODING_STANDARD — cross-cutting, always checked)**
175
+ - [ ] No secrets, credentials, or tokens in source code or committed config
176
+ - [ ] No sensitive data (PII, credentials, tokens) in error messages or log output
177
+ - [ ] No `eval`, `Function()`, or dynamic `import()` with user-controlled input
178
+ - [ ] User input not reflected in error responses without sanitisation
179
+ - [ ] For full security audit: use `focus=security`
165
180
 
166
- **Boundary and imports (§8–§9)**
167
- - [ ] 8.4 — Parse, don't validate: `unknown` converted to domain types at the boundary
168
- - [ ] 9.x — No `import from 'ramda'`; use `@tsfpp/prelude`
181
+ **Boundary and parse (§8)**
182
+ - [ ] 8.4 — Parse, don't validate: `unknown` converted to domain types at the boundary via smart constructors or Zod
169
183
 
170
184
  **Size limits (§11)**
171
185
  - [ ] 11.1 — One type / one responsibility per file
@@ -345,7 +359,6 @@ Cross-cutting — applies to all layers. Check for hand-rolled patterns that `@t
345
359
  | `.map()` on a fallible function | MUST | `traverseArray` |
346
360
  | `new Map()` | MUST | `intoMap([...])` |
347
361
  | `new Set()` | MUST | `intoSet([...])` |
348
- | `import ... from 'ramda'` | MUST | `@tsfpp/prelude` |
349
362
  | `result._tag === 'Ok'` | MUST | `isOk(result)` |
350
363
  | `option._tag === 'Some'` | MUST | `isSome(option)` |
351
364
  | `Result<void, E>` | MUST | `Result<Unit, E>` with `ok(unit)` |
@@ -360,7 +373,6 @@ Checklist:
360
373
  - [ ] No `try/catch` outside adapter boundaries — use `tryCatch`/`tryCatchAsync`
361
374
  - [ ] No `.map()` on fallible function — use `traverseArray`
362
375
  - [ ] No `new Map()` / `new Set()` — use `intoMap` / `intoSet`
363
- - [ ] No `import from 'ramda'`
364
376
  - [ ] Prelude ADTs accessed via exported guards (`isOk`, `isSome`), never `._tag` directly
365
377
  - [ ] No `Result<void, E>` — use `Result<Unit, E>`
366
378
  - [ ] Side effects in pipelines via `tap` / `tapErr`
@@ -412,8 +424,60 @@ Checklist:
412
424
  - [ ] 4.4 DAL — insert+read round-trip tested; not-found returns `None`
413
425
  - [ ] 4.5 React — loading state, error state, and user interactions all covered
414
426
 
427
+ ### `log`
428
+ Full reference: `node_modules/@tsfpp/standard/spec/LOG_CODING_STANDARD.md`
429
+
430
+ Cross-cutting — apply to every file regardless of other focus selections.
431
+
432
+ - [ ] No `console.*` calls outside `main.ts` / `server.ts`
433
+ - [ ] `Logger` port imported from `@tsfpp/prelude`; never a concrete library
434
+ - [ ] `Logger` injected as a dependency; never imported as a singleton
435
+ - [ ] All `message` fields use dot-separated event-name format (`user.created`, not `"User was created"`)
436
+ - [ ] Every request-scoped log entry includes `traceId`
437
+ - [ ] Every `error`-level entry includes `code`
438
+ - [ ] `cause` logged before `apiErrorToResponse` on `dependency` / `internal` errors
439
+ - [ ] No PII in any log field at any level
440
+ - [ ] No credentials, tokens, or secrets in any log field
441
+ - [ ] No full request or response bodies logged at `info` or above
442
+ - [ ] No stack traces in production log output (`err.message` not `err.stack`)
443
+ - [ ] `withRequestLog` used for HTTP request logging; no manual request logging in handlers
444
+ - [ ] `routeTemplate` is parameterised, not the resolved URL
445
+ - [ ] Pipelines use `tap` / `tapErr` for logging; pipeline not broken for a log call
446
+ - [ ] Tests receive `silentLogger`, not the production logger
447
+ - [ ] Production logger emits newline-delimited JSON
448
+ - [ ] Log level configurable via environment variable
449
+
450
+ ### `config`
451
+ Full reference: `node_modules/@tsfpp/standard/spec/CONFIG_CODING_STANDARD.md`
452
+
453
+ Cross-cutting — apply to entry points, config loaders, and any module that accesses configuration.
454
+
455
+ - [ ] No `process.env` access outside the config loader
456
+ - [ ] No config singleton imported by application modules
457
+ - [ ] `loadConfig` from `@tsfpp/boundary` used in the loader
458
+ - [ ] Loader returns `Result<Config, ConfigError>`; never throws
459
+ - [ ] All type coercion (`string → number`, `string → boolean`) in Zod schema, not application code
460
+ - [ ] All validation failures reported together (Zod `safeParse`, not sequential)
461
+ - [ ] Required secrets validated for minimum length (`z.string().min(32)`)
462
+ - [ ] `.env.example` committed; `.env` in `.gitignore`
463
+ - [ ] Every variable in `.env.example` has an explanatory comment
464
+ - [ ] No config values or `process.env` logged at any level
465
+ - [ ] Tests pass plain records to the loader; never mutate `process.env`
466
+ - [ ] Config factory in `tests/helpers/` for use-case and integration tests
467
+ - [ ] Loader tests cover: valid, each missing required var, invalid type
468
+ - [ ] React: `clientConfig` validated at module load; no secrets in client config
469
+
415
470
  ### `all`
416
- All focus areas above in sequence. For `.tsx` files, include `react` automatically. For files in `infrastructure/`, `dal/`, or `repository/` paths, include `data` automatically. For `*.test.ts` and `*.test.tsx` files, include `test` automatically. Include `prelude` for all files.
471
+ All focus areas in sequence.
472
+
473
+ **Always active (cross-cutting — every file, every focus):**
474
+ `annotations`, `security`, `log`, and `config` are applied to every slice regardless of focus selection or file type.
475
+
476
+ **Auto-detected by file type / path:**
477
+ - `.tsx` files → include `react`
478
+ - `infrastructure/`, `dal/`, `repository/` paths → include `data`
479
+ - `*.test.ts` / `*.test.tsx` files → include `test`
480
+ - All files → include `prelude`
417
481
 
418
482
  ---
419
483
 
@@ -431,11 +495,17 @@ Example: `docs/audits/src-domain-prelude-20260517-1430.md` or `docs/audits/src-a
431
495
  **Step 3 — Inspect slice by slice**
432
496
  For each slice:
433
497
  1. Read the file(s).
434
- 2. Check every rule in the active focus set.
435
- 3. Record all findings (rule · line · severity · description).
436
- 4. Fill in the checklist.
437
- 5. Append the completed slice section to the report.
438
- 6. Update the slice status in the index table.
498
+ 2. Determine which checklists apply:
499
+ - **Always:** base checklist including the `annotations` and `security` sections — every slice, every focus
500
+ - React checklist for `.tsx` files under `react` or `all` focus
501
+ - Data checklist for files in `infrastructure/`, `dal/`, `repository/` under `data` or `all` focus
502
+ - Test checklist for `*.test.ts` / `*.test.tsx` under `test` or `all` focus
503
+ - Prelude checklist for all files under `prelude` or `all` focus
504
+ 3. Check every rule in the active focus set.
505
+ 4. Record all findings (rule · line · severity · description).
506
+ 5. Fill in the checklist.
507
+ 6. Append the completed slice section to the report.
508
+ 7. Update the slice status in the index table.
439
509
 
440
510
  **Step 4 — Summarise**
441
511
  After all slices: fill in the Summary table · set Status to ✅ Complete or ⚠️ Violations found · list the top 3 highest-priority issues.
@@ -152,6 +152,9 @@ Do not hand-roll what the prelude already provides.
152
152
  | Key/value lookup | `intoMap`, `lookup`, `assoc`, `dissoc` |
153
153
  | Set membership | `intoSet`, `conj`, `disj`, `member` |
154
154
  | Exhaustive match | `absurd` |
155
+ | Application logging | `Logger` port — `import { type Logger } from '@tsfpp/prelude'`; inject as dependency; never `console.*` |
156
+ | Config access | Receive `Config` as a dependency; never read `process.env` directly |
157
+ | Config loading (entry point only) | `loadConfig` — `import { loadConfig } from '@tsfpp/boundary'` |
155
158
 
156
159
  If you are about to write a `try/catch`, a `null` check, an `if (x === undefined)`,
157
160
  a `x ?? fallback`, or a `.map()` that can fail — stop and use the prelude equivalent instead.
@@ -24,6 +24,8 @@ Full standard: `node_modules/@tsfpp/standard/spec/CODING_STANDARD.md`
24
24
  - `new Map()` `new Set()` — use `intoMap` / `intoSet` from `@tsfpp/prelude`
25
25
  - `if (x === null)` `if (x !== null)` `if (x === undefined)` `if (x !== undefined)` `if (!x)` `x ?? y` — any nullability check in any form; use `fromNullable` → `Option<T>`, then `isSome` / `isNone` / `getOrElse`
26
26
  - `try/catch` in core — use `tryCatch` / `tryCatchAsync` from `@tsfpp/prelude`
27
+ - `console.log` `console.error` `console.warn` `console.info` — anywhere except `main.ts` / `server.ts` startup; use the injected `Logger` port from `@tsfpp/prelude`
28
+ - `process.env` outside the config loader — use the typed `Config` record injected as a dependency
27
29
 
28
30
  ## Always
29
31
 
@@ -85,6 +87,9 @@ const head = <A>(xs: ReadonlyArray<A>): Option<A> =>
85
87
 
86
88
  All ADT constructors, combinators, and utilities come from `@tsfpp/prelude`. Never import from `ramda` directly.
87
89
 
90
+ Logger port: `import { type Logger, type LogEntry } from '@tsfpp/prelude'`
91
+ Config loader: `import { loadConfig, type ConfigError } from '@tsfpp/boundary'`
92
+
88
93
  ## Markers
89
94
 
90
95
  ```ts
@@ -0,0 +1,205 @@
1
+ ---
2
+ name: config-standard
3
+ description: >
4
+ Normative TSF++ configuration management rules. Load when writing or reviewing
5
+ any code that loads environment variables, defines a Config type, calls
6
+ loadConfig, accesses process.env, implements a config loader, or writes
7
+ .env.example: loadConfig from @tsfpp/boundary, Config as typed readonly record,
8
+ Zod validation at the startup boundary, injection pattern, test factories.
9
+ ---
10
+
11
+ # TSF++ config standard
12
+
13
+ Full standard: `node_modules/@tsfpp/standard/spec/CONFIG_CODING_STANDARD.md`
14
+
15
+ ---
16
+
17
+ ## The pattern in one picture
18
+
19
+ ```
20
+ process.env (string | undefined)
21
+
22
+ loadConfig(schema, env) ← @tsfpp/boundary — validates all vars at once
23
+
24
+ Result<Config, ConfigError>
25
+ ↓ exit on Err at startup
26
+ Config (typed readonly record)
27
+ ↓ injected into every module that needs it
28
+ ```
29
+
30
+ `process.env` is only touched at the entry point. Everything downstream receives a typed `Config`.
31
+
32
+ ---
33
+
34
+ ## Imports
35
+
36
+ ```ts
37
+ // In the config loader
38
+ import { loadConfig, type ConfigError } from '@tsfpp/boundary'
39
+ import { isErr, ok, type Result } from '@tsfpp/prelude'
40
+
41
+ // In application modules — inject Config as a dependency, never import process.env
42
+ import { type Config } from '../shared/config'
43
+ ```
44
+
45
+ ---
46
+
47
+ ## Config type — project-defined, not from a package
48
+
49
+ ```ts
50
+ // src/shared/config.ts
51
+ export type Config = {
52
+ readonly server: {
53
+ readonly port: number
54
+ readonly host: string
55
+ readonly logLevel: 'debug' | 'info' | 'warn' | 'error'
56
+ }
57
+ readonly database: {
58
+ readonly url: string
59
+ readonly poolMin: number
60
+ readonly poolMax: number
61
+ readonly queryTimeoutMs: number
62
+ }
63
+ readonly auth: {
64
+ readonly jwtSecret: string
65
+ readonly tokenTtlSeconds: number
66
+ }
67
+ readonly features: {
68
+ readonly maintenanceMode: boolean
69
+ }
70
+ }
71
+ ```
72
+
73
+ All fields are required. Optional config uses `Option<T>`, not `T | undefined`.
74
+
75
+ ---
76
+
77
+ ## Config loader
78
+
79
+ ```ts
80
+ // src/infrastructure/config-loader.ts
81
+ import { z } from 'zod'
82
+ import { loadConfig, type ConfigError } from '@tsfpp/boundary'
83
+ import { isErr, ok, type Result } from '@tsfpp/prelude'
84
+ import { type Config } from '../shared/config'
85
+
86
+ const schema = z.object({
87
+ PORT: z.coerce.number().int().min(1).max(65535).default(3000),
88
+ HOST: z.string().default('0.0.0.0'),
89
+ LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
90
+ DATABASE_URL: z.string().url(),
91
+ DATABASE_POOL_MIN: z.coerce.number().int().min(1).default(2),
92
+ DATABASE_POOL_MAX: z.coerce.number().int().min(1).default(10),
93
+ DATABASE_TIMEOUT_MS: z.coerce.number().int().min(100).default(5000),
94
+ JWT_SECRET: z.string().min(32),
95
+ TOKEN_TTL_SECONDS: z.coerce.number().int().min(60).default(3600),
96
+ MAINTENANCE_MODE: z.coerce.boolean().default(false),
97
+ })
98
+
99
+ export const parseConfig = (
100
+ env: Record<string, string | undefined>
101
+ ): Result<Config, ConfigError> => {
102
+ const raw = loadConfig(schema, env)
103
+ if (isErr(raw)) return raw
104
+
105
+ const e = raw.value
106
+ return ok({
107
+ server: { port: e.PORT, host: e.HOST, logLevel: e.LOG_LEVEL },
108
+ database: { url: e.DATABASE_URL, poolMin: e.DATABASE_POOL_MIN,
109
+ poolMax: e.DATABASE_POOL_MAX, queryTimeoutMs: e.DATABASE_TIMEOUT_MS },
110
+ auth: { jwtSecret: e.JWT_SECRET, tokenTtlSeconds: e.TOKEN_TTL_SECONDS },
111
+ features: { maintenanceMode: e.MAINTENANCE_MODE },
112
+ })
113
+ }
114
+ ```
115
+
116
+ ---
117
+
118
+ ## Entry point — fail at startup
119
+
120
+ ```ts
121
+ // src/main.ts
122
+ import { parseConfig } from './infrastructure/config-loader'
123
+
124
+ const configResult = parseConfig(process.env)
125
+ if (isErr(configResult)) {
126
+ console.error(configResult.error.summary)
127
+ process.exit(1)
128
+ }
129
+ const config = configResult.value
130
+ // wire up the application with config
131
+ ```
132
+
133
+ ---
134
+
135
+ ## Injection — never import process.env in modules
136
+
137
+ ```ts
138
+ // Good — Config injected as part of Deps
139
+ type Deps = {
140
+ readonly db: Database
141
+ readonly logger: Logger
142
+ readonly config: Pick<Config, 'auth'>
143
+ }
144
+
145
+ // Bad — reads process.env inside a module
146
+ const ttl = parseInt(process.env.TOKEN_TTL_SECONDS ?? '3600', 10)
147
+
148
+ // Bad — imports a config singleton
149
+ import { config } from '../config'
150
+ ```
151
+
152
+ Use `Pick<Config, 'auth'>` to declare precisely which slice of config a module needs.
153
+
154
+ ---
155
+
156
+ ## Zod schema conventions
157
+
158
+ ```ts
159
+ // Required — no default; missing = startup failure
160
+ DATABASE_URL: z.string().url()
161
+ JWT_SECRET: z.string().min(32) // enforce minimum entropy for secrets
162
+
163
+ // Optional with sensible default
164
+ PORT: z.coerce.number().int().default(3000)
165
+ LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info')
166
+
167
+ // Boolean — coerce from 'true' / 'false' string
168
+ MAINTENANCE_MODE: z.coerce.boolean().default(false)
169
+ ```
170
+
171
+ All coercion (`string → number`, `string → boolean`) happens in the schema. Never parse in application code.
172
+
173
+ ---
174
+
175
+ ## Testing
176
+
177
+ ```ts
178
+ // Never mutate process.env in tests
179
+ // Bad
180
+ process.env.DATABASE_URL = 'postgres://localhost/test'
181
+
182
+ // Good — pass a plain record to the loader
183
+ const result = parseConfig({ DATABASE_URL: 'postgres://localhost/test', JWT_SECRET: 'a'.repeat(32), ... })
184
+
185
+ // Config factory for use-case and integration tests
186
+ // tests/helpers/config.factory.ts
187
+ export const makeConfig = (overrides: Partial<Config> = {}): Config => ({
188
+ server: { port: 3000, host: '127.0.0.1', logLevel: 'error', ...overrides.server },
189
+ database: { url: 'postgres://localhost/test', poolMin: 1, poolMax: 2,
190
+ queryTimeoutMs: 1000, ...overrides.database },
191
+ auth: { jwtSecret: 'a'.repeat(32), tokenTtlSeconds: 3600, ...overrides.auth },
192
+ features: { maintenanceMode: false, ...overrides.features },
193
+ ...overrides,
194
+ })
195
+ ```
196
+
197
+ ---
198
+
199
+ ## Never
200
+
201
+ - Access `process.env` outside the config loader
202
+ - Import a config singleton in application modules
203
+ - Coerce types (parseInt, parseFloat) inside application code
204
+ - Log config values or `process.env` at any level
205
+ - Commit `.env` — only `.env.example` is committed
@@ -0,0 +1,148 @@
1
+ ---
2
+ name: log-standard
3
+ description: >
4
+ Normative TSF++ logging rules. Load when writing or reviewing any code that
5
+ logs, uses the Logger port, implements a logger adapter, or calls tap/tapErr
6
+ for side effects: Logger port from @tsfpp/prelude, LogEntry field conventions,
7
+ log level semantics, structured message format, what never to log (PII,
8
+ secrets, stack traces), withRequestLog for HTTP, silentLogger for tests.
9
+ ---
10
+
11
+ # TSF++ log standard
12
+
13
+ Full standard: `node_modules/@tsfpp/standard/spec/LOG_CODING_STANDARD.md`
14
+
15
+ ---
16
+
17
+ ## Import
18
+
19
+ ```ts
20
+ import { type Logger, type LogEntry, type LogLevel } from '@tsfpp/prelude'
21
+ ```
22
+
23
+ Never import `pino`, `winston`, or `console` in core, use-case, or DAL code.
24
+
25
+ ---
26
+
27
+ ## Logger port
28
+
29
+ ```ts
30
+ // Infrastructure adapter — inject this; never the library directly
31
+ import pino from 'pino'
32
+ import { type Logger } from '@tsfpp/prelude'
33
+
34
+ export const logger: Logger = {
35
+ debug: (entry) => pinoInstance.debug(entry, entry.message),
36
+ info: (entry) => pinoInstance.info(entry, entry.message),
37
+ warn: (entry) => pinoInstance.warn(entry, entry.message),
38
+ error: (entry) => pinoInstance.error(entry, entry.message),
39
+ }
40
+
41
+ // Tests — always use silentLogger, never the production logger
42
+ export const silentLogger: Logger = {
43
+ debug: () => undefined,
44
+ info: () => undefined,
45
+ warn: () => undefined,
46
+ error: () => undefined,
47
+ }
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Log levels
53
+
54
+ | Level | Use when |
55
+ |---|---|
56
+ | `debug` | Diagnostic detail — disabled in production by default. Never log PII even at debug. |
57
+ | `info` | A significant, expected business event occurred. One entry per meaningful outcome. |
58
+ | `warn` | Unexpected but recoverable — retry triggered, rate limit approached, deprecated path called. |
59
+ | `error` | Failure requiring attention — operation failed, dependency unreachable, unhandled `Err` at boundary. |
60
+
61
+ `info` is for **business events**, not execution steps. Never log "calling repository", "repository returned".
62
+
63
+ ---
64
+
65
+ ## LogEntry field conventions
66
+
67
+ ```ts
68
+ // message — dot-separated event name, machine-readable
69
+ { message: 'user.created' }
70
+ { message: 'payment.charge.failed' }
71
+ { message: 'session.expired' }
72
+
73
+ // Always include traceId in request-scoped logs
74
+ { message: 'user.created', traceId: ctx.traceId, userId: user.id }
75
+
76
+ // Always include code on error-level logs
77
+ { message: 'db.query.failed', code: 'db_timeout', traceId }
78
+
79
+ // duration for operations with performance budgets (milliseconds)
80
+ { message: 'payment.charge.completed', duration: Date.now() - start, traceId }
81
+ ```
82
+
83
+ Flat structure only — no nested objects. Log aggregators cannot query nested fields.
84
+
85
+ ---
86
+
87
+ ## Logging in pipelines — tap / tapErr
88
+
89
+ Never break a `pipe` chain to log. Use `tap` / `tapErr`:
90
+
91
+ ```ts
92
+ // Good
93
+ pipe(
94
+ validateInput(input),
95
+ flatMap(createUser(deps.users)),
96
+ tap(user => deps.logger.info({ message: 'user.created', userId: user.id, traceId })),
97
+ tapErr(err => deps.logger.error({ message: 'user.create.failed', code: err.code, traceId })),
98
+ )
99
+
100
+ // Bad — breaks the pipeline
101
+ const result = await createUser(deps.users)(input)
102
+ if (isOk(result)) deps.logger.info(...) // separate from the pipeline
103
+ ```
104
+
105
+ ---
106
+
107
+ ## Log `cause` before discarding it
108
+
109
+ `dependency` and `internal` `ApiError` variants carry a `cause` that is stripped by `apiErrorToResponse`. Log it first:
110
+
111
+ ```ts
112
+ if (isErr(result) && result.error.kind === 'dependency') {
113
+ deps.logger.error({
114
+ message: 'payment.gateway.unreachable',
115
+ code: 'dependency_error',
116
+ error: String(result.error.cause),
117
+ traceId: ctx.traceId,
118
+ })
119
+ return apiErrorToResponse(result.error, ctx)
120
+ }
121
+ ```
122
+
123
+ ---
124
+
125
+ ## HTTP request logging
126
+
127
+ Use `withRequestLog` from `@tsfpp/boundary` — never add manual logging to handler bodies.
128
+
129
+ ```ts
130
+ const handler = pipe(
131
+ createUserHandler(deps),
132
+ withIdempotency(store),
133
+ withRequestLog(logger, '/v1/users'), // always outermost; routeTemplate not resolved URL
134
+ )
135
+ ```
136
+
137
+ `routeTemplate` must be the parameterised path (`/v1/users/:id`), never the resolved URL.
138
+
139
+ ---
140
+
141
+ ## Never log
142
+
143
+ - PII — names, emails, phone numbers, addresses, national IDs
144
+ - Credentials — passwords, tokens, API keys, session IDs
145
+ - Full request or response bodies at `info` or above
146
+ - Stack traces in production — log `err.message`, not `err.stack`
147
+ - `process.env` or config values — they may contain secrets
148
+ - `console.*` anywhere except `main.ts` / `server.ts` startup boundary
package/init.mjs CHANGED
@@ -66,6 +66,8 @@ const FILES = [
66
66
  ['copilot/skills/react-coding-standard/SKILL.md', '.github/skills/react-coding-standard/SKILL.md'],
67
67
  ['copilot/skills/test-standard/SKILL.md', '.github/skills/test-standard/SKILL.md'],
68
68
  ['copilot/skills/annotation-standard/SKILL.md', '.github/skills/annotation-standard/SKILL.md'],
69
+ ['copilot/skills/log-standard/SKILL.md', '.github/skills/log-standard/SKILL.md'],
70
+ ['copilot/skills/config-standard/SKILL.md', '.github/skills/config-standard/SKILL.md'],
69
71
 
70
72
  // Claude Code
71
73
  ['claude/CLAUDE.md', '.claude/CLAUDE.md'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tsfpp/agents",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Workspace AI tooling for TSF++ projects: scoped instructions, coding agents, and reusable prompts",
5
5
  "keywords": [
6
6
  "tsfpp",
@@ -37,11 +37,11 @@
37
37
  "LICENSE"
38
38
  ],
39
39
  "peerDependencies": {
40
- "@tsfpp/standard": ">=1.0.0",
41
- "@tsfpp/prelude": ">=1.0.0",
42
- "@tsfpp/boundary": ">=1.0.0",
43
- "@tsfpp/eslint-config":">=1.0.0",
44
- "@tsfpp/tsconfig": ">=1.0.0"
40
+ "@tsfpp/standard": ">=1.0.0",
41
+ "@tsfpp/prelude": ">=1.0.0",
42
+ "@tsfpp/boundary": ">=1.0.0",
43
+ "@tsfpp/eslint-config": ">=1.0.0",
44
+ "@tsfpp/tsconfig": ">=1.0.0"
45
45
  },
46
46
  "publishConfig": {
47
47
  "access": "public"