trackfw 7.2.0 → 7.4.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/README.md +67 -1
- package/package.json +1 -1
- package/src/commands/adr.js +2 -1
- package/src/commands/audit-surface.js +520 -0
- package/src/commands/barrier.js +400 -34
- package/src/commands/context.js +42 -36
- package/src/commands/discover.js +72 -21
- package/src/commands/doctor.js +37 -4
- package/src/commands/index.js +1 -0
- package/src/commands/init.js +2 -1
- package/src/commands/integrations.js +22 -4
- package/src/commands/req.js +2 -1
- package/src/commands/thirdparty.js +8 -2
- package/src/commands/update-harness.js +6 -3
- package/src/commands/update.js +147 -10
- package/src/config/index.js +82 -2
- package/src/generators/hooks.js +139 -6
- package/src/generators/init.js +173 -21
- package/src/generators/req.js +15 -53
- package/src/generators/roadmap.js +132 -28
- package/src/homedir.js +30 -0
- package/src/integrations/assets/agents/architect.md +5 -3
- package/src/integrations/assets/agents/security.md +3 -0
- package/src/integrations/doctor.js +0 -0
- package/src/integrations/doctor_remote.js +224 -0
- package/src/integrations/index.js +2 -1
- package/src/integrations/manager.js +10 -2
- package/src/integrations/render.js +39 -8
- package/src/integrations/scaffold_doctor.js +395 -0
- package/src/lib/pathfmt.js +43 -0
- package/src/lib/update-engine.js +28 -3
- package/src/serve/api_board.js +11 -10
- package/src/serve/api_chain.js +13 -17
- package/src/serve/api_metrics.js +2 -8
- package/src/validator/index.js +768 -167
package/src/commands/update.js
CHANGED
|
@@ -7,6 +7,7 @@ const path = require('path');
|
|
|
7
7
|
const identityStore = require('../identity');
|
|
8
8
|
const projectConfig = require('../config');
|
|
9
9
|
const { runFileTarget, validateTargets, buildDocument, humanReport, silenceConsole } = require('../lib/update-engine');
|
|
10
|
+
const { homedir } = require('../homedir')
|
|
10
11
|
|
|
11
12
|
// `trackfw update` is project-scoped only — see docs/cli-parity.md,
|
|
12
13
|
// "`trackfw update` vs `trackfw update harness`". It must NEVER touch global
|
|
@@ -37,7 +38,7 @@ function loadUpdateConfig(rootDir) {
|
|
|
37
38
|
// (line-based text splice, not a YAML parse+reserialize) to preserve 100% of
|
|
38
39
|
// the user's existing trackfw.yaml content (comments, key order, formatting).
|
|
39
40
|
function ensureGlobalAdrDirRegistered(cwd) {
|
|
40
|
-
const home =
|
|
41
|
+
const home = homedir();
|
|
41
42
|
const globalDir = path.join(home, '.trackfw', 'adr');
|
|
42
43
|
if (!fs.existsSync(globalDir)) return;
|
|
43
44
|
|
|
@@ -168,10 +169,74 @@ function codexProjectAgentsTarget(cwd, identityConfig, { dryRun, installMissing
|
|
|
168
169
|
}
|
|
169
170
|
}
|
|
170
171
|
|
|
172
|
+
// discoverWorkflowPresent — reports whether
|
|
173
|
+
// .github/workflows/trackfw-validate.yml (written by `trackfw discover
|
|
174
|
+
// --init`, DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH in discover.js) already
|
|
175
|
+
// exists under cwd AS A REGULAR FILE. Used both to decide whether
|
|
176
|
+
// `ci-workflow` is declared (AC17(c), REQ-2026-08-28) and, inside its
|
|
177
|
+
// apply(), to decide whether to refresh it — existence is checked against
|
|
178
|
+
// the REAL cwd, never the --dry-run sandbox, mirroring how cfg itself is
|
|
179
|
+
// read before the sandbox is built.
|
|
180
|
+
//
|
|
181
|
+
// Uses fs.lstatSync, NOT fs.existsSync: fs.existsSync follows symlinks, so a
|
|
182
|
+
// symlink at this path — even a live one pointing outside the project —
|
|
183
|
+
// would report "present" purely because its target resolves, pulling
|
|
184
|
+
// `ci-workflow` into the declared target set on the strength of a link this
|
|
185
|
+
// command does not own. Symlinks are therefore treated as NOT present here:
|
|
186
|
+
// `update` will not declare/manage a target on their account, and
|
|
187
|
+
// refreshDiscoverGitHubActionsWorkflowIfPresent below refuses to write
|
|
188
|
+
// through them regardless.
|
|
189
|
+
function discoverWorkflowPresent(cwd) {
|
|
190
|
+
const { DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH } = require('./discover')
|
|
191
|
+
const dest = path.join(cwd, DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH)
|
|
192
|
+
let info
|
|
193
|
+
try {
|
|
194
|
+
info = fs.lstatSync(dest)
|
|
195
|
+
} catch (e) {
|
|
196
|
+
return false
|
|
197
|
+
}
|
|
198
|
+
return !info.isSymbolicLink()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// refreshDiscoverGitHubActionsWorkflowIfPresent — refreshes
|
|
202
|
+
// .github/workflows/trackfw-validate.yml ONLY when it already exists under
|
|
203
|
+
// root as a REGULAR FILE — `update` never creates this file (AC17(b)):
|
|
204
|
+
// ownership of the install decision belongs to `trackfw discover --init`,
|
|
205
|
+
// not `update`. Writes the SAME builder scaffold doctor compares against
|
|
206
|
+
// (buildDiscoverGitHubActionsWorkflowContent, discover.js) so what `update`
|
|
207
|
+
// writes and what `doctor` expects can never drift apart by construction.
|
|
208
|
+
//
|
|
209
|
+
// Uses fs.lstatSync to decide presence, not fs.existsSync: this path is the
|
|
210
|
+
// most sensitive one `update` can write to (it controls what runs in CI for
|
|
211
|
+
// anyone who checks the project out), so if it is a symlink — live or
|
|
212
|
+
// dangling — this function refuses to write through it. Refusing is loud
|
|
213
|
+
// (stderr), never silent, so "update didn't refresh my workflow" stays
|
|
214
|
+
// diagnosable instead of a silent no-op.
|
|
215
|
+
function refreshDiscoverGitHubActionsWorkflowIfPresent(root) {
|
|
216
|
+
const { DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH, buildDiscoverGitHubActionsWorkflowContent } = require('./discover')
|
|
217
|
+
const dest = path.join(root, DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH)
|
|
218
|
+
let info
|
|
219
|
+
try {
|
|
220
|
+
info = fs.lstatSync(dest)
|
|
221
|
+
} catch (e) {
|
|
222
|
+
return // not installed — update never creates it (AC17(b))
|
|
223
|
+
}
|
|
224
|
+
if (info.isSymbolicLink()) {
|
|
225
|
+
console.error(`aviso: ${DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH} é um symlink; trackfw update não escreve através de symlinks — arquivo não foi tocado`)
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
fs.writeFileSync(dest, buildDiscoverGitHubActionsWorkflowContent(), 'utf8')
|
|
229
|
+
}
|
|
230
|
+
|
|
171
231
|
// PROJECT_TARGET_IDS — the fixed declared order for `trackfw update`
|
|
172
232
|
// targets. `ci-workflow` and `git-hooks` only appear when the project's
|
|
173
|
-
// trackfw.yaml opted into a CI system / hook framework —
|
|
174
|
-
//
|
|
233
|
+
// trackfw.yaml opted into a CI system / hook framework, OR — for
|
|
234
|
+
// `ci-workflow` only — when trackfw-validate.yml already exists on disk
|
|
235
|
+
// (AC17(c)) — see ambiguity note in the ML-6C handoff report about
|
|
236
|
+
// config-conditional target lists. This constant is the full declared
|
|
237
|
+
// surface (used to validate --targets); the config/disk-conditional
|
|
238
|
+
// inclusion itself lives in buildProjectTargets' `include('ci-workflow')`
|
|
239
|
+
// call below.
|
|
175
240
|
const PROJECT_TARGET_IDS = [
|
|
176
241
|
'agent-rules',
|
|
177
242
|
'agent-hooks',
|
|
@@ -189,7 +254,6 @@ const PROJECT_TARGET_IDS = [
|
|
|
189
254
|
// filtering afterwards would still have written every unrequested target.
|
|
190
255
|
function buildProjectTargets(cwd, cfg, identityConfig, { dryRun, installMissing }, wanted) {
|
|
191
256
|
const generators = require('../generators/init');
|
|
192
|
-
const discover = require('./discover');
|
|
193
257
|
const hooksGen = require('../generators/hooks');
|
|
194
258
|
const include = (id) => !wanted || wanted.includes(id);
|
|
195
259
|
|
|
@@ -200,6 +264,10 @@ function buildProjectTargets(cwd, cfg, identityConfig, { dryRun, installMissing
|
|
|
200
264
|
path: 'CLAUDE.md, AGENTS.md, GEMINI.md, .github/copilot-instructions.md, .windsurfrules, .amazonq/developer/guidelines.md, .cursor/rules/trackfw.mdc',
|
|
201
265
|
root: cwd,
|
|
202
266
|
relPaths: ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md', '.github/copilot-instructions.md', '.windsurfrules', '.amazonq/developer/guidelines.md', '.cursor/rules/trackfw.mdc'],
|
|
267
|
+
// Gap E: injectRulesDetected → readAgentConventions reads trackfw.yaml from root;
|
|
268
|
+
// without it in the dry-run sandbox, agent_conventions is silently omitted from
|
|
269
|
+
// CLAUDE.md, producing a hash that diverges from the real run.
|
|
270
|
+
seeds: ['trackfw.yaml'],
|
|
203
271
|
apply: (root) => generators.injectRulesDetected(root),
|
|
204
272
|
dryRun,
|
|
205
273
|
installMissing,
|
|
@@ -207,7 +275,7 @@ function buildProjectTargets(cwd, cfg, identityConfig, { dryRun, installMissing
|
|
|
207
275
|
|
|
208
276
|
if (include('agent-hooks')) targets.push(runFileTarget({
|
|
209
277
|
id: 'agent-hooks',
|
|
210
|
-
path: '.claude/settings.json, .codex/hooks.json, .gemini/settings.json, .kiro/hooks/trackfw-attention.json, .github/hooks/trackfw-attention.json, .cursor/hooks.json, scripts/trackfw-attention-*.sh, scripts/trackfw-credential-guard.sh, scripts/trackfw-git-branch-guard.sh',
|
|
278
|
+
path: '.claude/settings.json, .codex/hooks.json, .gemini/settings.json, .kiro/hooks/trackfw-attention.json, .github/hooks/trackfw-attention.json, .cursor/hooks.json, scripts/trackfw-attention-*.sh, scripts/trackfw-credential-guard.sh, scripts/trackfw-git-branch-guard.sh, .windsurf/hooks.json, .amazonq/cli-agents/q_cli_default.json',
|
|
211
279
|
root: cwd,
|
|
212
280
|
relPaths: [
|
|
213
281
|
'.claude/settings.json',
|
|
@@ -220,6 +288,21 @@ function buildProjectTargets(cwd, cfg, identityConfig, { dryRun, installMissing
|
|
|
220
288
|
'scripts/trackfw-attention-cleanup.sh',
|
|
221
289
|
'scripts/trackfw-credential-guard.sh',
|
|
222
290
|
'scripts/trackfw-git-branch-guard.sh',
|
|
291
|
+
'.windsurf/hooks.json', // Gap A: injectWindsurfHooks writes this
|
|
292
|
+
'.amazonq/cli-agents/q_cli_default.json', // Gap B: injectAmazonQHooks writes this
|
|
293
|
+
],
|
|
294
|
+
// Gap C: injectHooksDetected checks these files to decide which hooks to
|
|
295
|
+
// inject. Without them in the dry-run sandbox, hooks for installed agents
|
|
296
|
+
// (windsurf, copilot, etc.) are silently omitted — dry-run lies by omission.
|
|
297
|
+
// Gap E: trackfw.yaml may be read by hooks generators for config.
|
|
298
|
+
seeds: [
|
|
299
|
+
'trackfw.yaml',
|
|
300
|
+
'CLAUDE.md',
|
|
301
|
+
'AGENTS.md',
|
|
302
|
+
'GEMINI.md',
|
|
303
|
+
'.github/copilot-instructions.md',
|
|
304
|
+
'.windsurfrules',
|
|
305
|
+
'.amazonq/developer/guidelines.md',
|
|
223
306
|
],
|
|
224
307
|
apply: (root) => {
|
|
225
308
|
hooksGen.injectHooksDetected(root);
|
|
@@ -255,16 +338,70 @@ function buildProjectTargets(cwd, cfg, identityConfig, { dryRun, installMissing
|
|
|
255
338
|
installMissing,
|
|
256
339
|
}));
|
|
257
340
|
|
|
258
|
-
|
|
341
|
+
// ci-workflow mirrors Go's internal/generators/update.go:1925 target: it manages
|
|
342
|
+
// the SAME pair of files Go manages — .github/workflows/trackfw-gate.yml (the
|
|
343
|
+
// generated, version-pinned governance gate, ADR-2026-08-28) and
|
|
344
|
+
// .gitlab-ci-trackfw.yml — AND, since ML-2G (AC17), ALSO
|
|
345
|
+
// .github/workflows/trackfw-validate.yml, the different file written by
|
|
346
|
+
// discover.js (own template, `go install …@latest`, version-pinned to the
|
|
347
|
+
// generating binary). That third file is refreshed-only-if-present
|
|
348
|
+
// (AC17(a)/(b)): `update` never installs it, it only keeps an
|
|
349
|
+
// already-discover-installed copy in sync with what the current binary
|
|
350
|
+
// would generate, using discover.js's own builder so `update` and `doctor`
|
|
351
|
+
// can never drift apart by construction.
|
|
352
|
+
// Writing directly with the byte-identical builders (instead of calling
|
|
353
|
+
// generateCIWorkflow/generateGitHubActionsWorkflow from generators/init.js) is
|
|
354
|
+
// deliberate: those functions hardcode paths relative to process.cwd() with no
|
|
355
|
+
// root parameter, which would silently escape the dry-run tmp sandbox that
|
|
356
|
+
// runFileTarget builds for every other target here.
|
|
357
|
+
//
|
|
358
|
+
// AC17(c): the target is included when cfg.ci opts into github-actions/
|
|
359
|
+
// gitlab-ci (as before) OR when trackfw-validate.yml already exists on
|
|
360
|
+
// disk — otherwise a `ci: none` project that ran `discover` would have
|
|
361
|
+
// that file outside any command's management.
|
|
362
|
+
const ciWorkflowConfigOptIn = cfg.ci === 'github-actions' || cfg.ci === 'github_actions' || cfg.ci === 'gitlab-ci';
|
|
363
|
+
if (include('ci-workflow') && (ciWorkflowConfigOptIn || discoverWorkflowPresent(cwd))) {
|
|
364
|
+
const isGitLab = cfg.ci === 'gitlab-ci';
|
|
365
|
+
const { DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH } = require('./discover');
|
|
259
366
|
targets.push(runFileTarget({
|
|
260
367
|
id: 'ci-workflow',
|
|
261
|
-
path:
|
|
368
|
+
path: `.github/workflows/trackfw-gate.yml, .gitlab-ci-trackfw.yml, ${DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH}`,
|
|
262
369
|
root: cwd,
|
|
263
|
-
relPaths: ['.github/workflows/trackfw-
|
|
264
|
-
apply: (root) =>
|
|
370
|
+
relPaths: ['.github/workflows/trackfw-gate.yml', '.gitlab-ci-trackfw.yml', DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH],
|
|
371
|
+
apply: (root) => {
|
|
372
|
+
if (isGitLab) {
|
|
373
|
+
fs.writeFileSync(path.join(root, '.gitlab-ci-trackfw.yml'), generators.buildGitLabCIWorkflowContent(cfg), 'utf8');
|
|
374
|
+
} else if (cfg.ci === 'github-actions' || cfg.ci === 'github_actions') {
|
|
375
|
+
fs.mkdirSync(path.join(root, '.github/workflows'), { recursive: true });
|
|
376
|
+
fs.writeFileSync(path.join(root, '.github/workflows/trackfw-gate.yml'), generators.buildGitHubActionsWorkflowContent(cfg), 'utf8');
|
|
377
|
+
}
|
|
378
|
+
refreshDiscoverGitHubActionsWorkflowIfPresent(root);
|
|
379
|
+
},
|
|
265
380
|
dryRun,
|
|
266
381
|
installMissing,
|
|
267
382
|
}));
|
|
383
|
+
} else if (include('ci-workflow')) {
|
|
384
|
+
// ML-3E (audit of ML-3D): the branch above is skipped exactly when
|
|
385
|
+
// `ci-workflow` isn't config-opted-in AND discoverWorkflowPresent(cwd)
|
|
386
|
+
// is false — which is also true when the file at
|
|
387
|
+
// DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH is a symlink (live or dangling),
|
|
388
|
+
// since discoverWorkflowPresent() deliberately treats symlinks as NOT
|
|
389
|
+
// present (AC17(c) comment above). Without this call, a `ci: none`
|
|
390
|
+
// project with a discover-installed symlink went completely silent:
|
|
391
|
+
// no target declared, no write, no warning — the exact failure mode
|
|
392
|
+
// this REQ exists to close (ML-3D's own stderr warning only fired from
|
|
393
|
+
// inside the branch above, i.e. only in cases where it was already not
|
|
394
|
+
// needed). refreshDiscoverGitHubActionsWorkflowIfPresent(cwd) re-checks
|
|
395
|
+
// the real cwd independently: it is a no-op when the file is absent
|
|
396
|
+
// (AC17(b) — update never creates it) and prints the SAME stderr
|
|
397
|
+
// warning Go/Python emit — byte-identical, see
|
|
398
|
+
// internal/generators/update.go:1899 and
|
|
399
|
+
// pypi/trackfw/commands/update.py:221-225 — when it is a symlink. No
|
|
400
|
+
// regular file ever reaches this branch (that case is always declared
|
|
401
|
+
// above via discoverWorkflowPresent(cwd) === true), so this can never
|
|
402
|
+
// duplicate the write/warning already performed by the branch above,
|
|
403
|
+
// and never writes through a symlink itself.
|
|
404
|
+
refreshDiscoverGitHubActionsWorkflowIfPresent(cwd);
|
|
268
405
|
}
|
|
269
406
|
|
|
270
407
|
if (include('git-hooks') && (cfg.hooks === 'husky' || cfg.hooks === 'lefthook')) {
|
|
@@ -340,7 +477,7 @@ cmd.action((mode, options) => {
|
|
|
340
477
|
// A identidade é carregada uma única vez, fora de qualquer try/catch —
|
|
341
478
|
// um identity.json corrompido deve abortar o comando inteiro, nunca cair
|
|
342
479
|
// silenciosamente para os nomes neutros default.
|
|
343
|
-
const identityConfig = identityStore.load(
|
|
480
|
+
const identityConfig = identityStore.load(homedir());
|
|
344
481
|
|
|
345
482
|
// With --json, stdout must carry only the result document — apply()
|
|
346
483
|
// functions log human progress lines as a side effect; silence them so a
|
package/src/config/index.js
CHANGED
|
@@ -4,14 +4,15 @@ const fs = require('fs');
|
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { parseDocument, isScalar, isAlias, isSeq, isMap } = require('yaml');
|
|
7
|
+
const { homedir } = require('../homedir')
|
|
7
8
|
|
|
8
9
|
function expandPath(filePath) {
|
|
9
10
|
if (!filePath || typeof filePath !== 'string') return filePath;
|
|
10
11
|
if (filePath === '~') {
|
|
11
|
-
return
|
|
12
|
+
return homedir();
|
|
12
13
|
}
|
|
13
14
|
if (filePath.startsWith('~/') || filePath.startsWith('~\\')) {
|
|
14
|
-
return path.join(
|
|
15
|
+
return path.join(homedir(), filePath.slice(2));
|
|
15
16
|
}
|
|
16
17
|
return filePath;
|
|
17
18
|
}
|
|
@@ -86,6 +87,21 @@ function defaults() {
|
|
|
86
87
|
|
|
87
88
|
let _instance = null;
|
|
88
89
|
|
|
90
|
+
// MALFORMED_GLOBAL_CONFIG_MESSAGE is written to stderr (non-fatal) when ~/.trackfw/trackfw.yaml
|
|
91
|
+
// exists but fails to parse as valid YAML. Must be byte-identical to Go's MalformedGlobalConfigMessage
|
|
92
|
+
// and Python's MALFORMED_GLOBAL_CONFIG_MESSAGE — ADR-2026-08-23.
|
|
93
|
+
const MALFORMED_GLOBAL_CONFIG_MESSAGE = 'trackfw: aviso: "~/.trackfw/trackfw.yaml" tem YAML malformado — config global de modelo ignorada; usando tier canônico.';
|
|
94
|
+
|
|
95
|
+
// GLOBAL_AGENT_MODELS_NONE_MESSAGE is emitted to stderr when agent_models is not configured
|
|
96
|
+
// in the global config and not found in cwd's trackfw.yaml either.
|
|
97
|
+
// Must be byte-identical to Go's GlobalAgentModelsNoneMessage and Python's equivalent.
|
|
98
|
+
const GLOBAL_AGENT_MODELS_NONE_MESSAGE = 'trackfw: agents global: agent_models não configurado em ~/.trackfw/trackfw.yaml — usando tier canônico. Configure em ~/.trackfw/trackfw.yaml para pinar versões.';
|
|
99
|
+
|
|
100
|
+
// GLOBAL_AGENT_MODELS_PROJECT_ONLY_MESSAGE is emitted when agent_models is found in the
|
|
101
|
+
// project's trackfw.yaml but not in the global config (AC4/AC14). Value NOT applied.
|
|
102
|
+
// Must be byte-identical to Go's GlobalAgentModelsProjectOnlyMessage and Python's equivalent.
|
|
103
|
+
const GLOBAL_AGENT_MODELS_PROJECT_ONLY_MESSAGE = 'trackfw: agents global: agent_models configurado em trackfw.yaml do projeto mas não vale para escopo global. Mova a chave para ~/.trackfw/trackfw.yaml.';
|
|
104
|
+
|
|
89
105
|
// MALFORMED_CONFIG_MESSAGE is written to stderr, verbatim, when trackfw.yaml exists but fails to
|
|
90
106
|
// parse as YAML. Kept identical, character-for-character, to Go's MalformedConfigMessage and
|
|
91
107
|
// Python's MALFORMED_CONFIG_MESSAGE — see the comment on parse() below for why the text is
|
|
@@ -340,6 +356,65 @@ function readAgentConventions(cwd) {
|
|
|
340
356
|
return cfg.update.agentConventions || '';
|
|
341
357
|
}
|
|
342
358
|
|
|
359
|
+
// _cwdAgentModelsSource returns 'project_only' if cwd's trackfw.yaml has agent_models configured,
|
|
360
|
+
// 'none' otherwise. Used for the AC14 diagnostic in loadGlobalAgentModels.
|
|
361
|
+
function _cwdAgentModelsSource(cwd) {
|
|
362
|
+
if (!cwd) return 'none';
|
|
363
|
+
try {
|
|
364
|
+
const cwdContent = fs.readFileSync(path.join(cwd, 'trackfw.yaml'), 'utf8');
|
|
365
|
+
const cwdCfg = { rules: {}, credentialGuard: {}, update: {}, sync: {}, linkFields: {}, agentModels: {} };
|
|
366
|
+
parse(cwdContent, cwdCfg);
|
|
367
|
+
if (cwdCfg.agentModels && Object.keys(cwdCfg.agentModels).length > 0) return 'project_only';
|
|
368
|
+
} catch (_) {}
|
|
369
|
+
return 'none';
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// loadGlobalAgentModels reads agent_models from ~/.trackfw/trackfw.yaml, bypassing the load()
|
|
373
|
+
// singleton. homeDir is homedir(); cwd is process.cwd() (used only for AC14 diagnostic).
|
|
374
|
+
// Returns { models: {}, source } where source is one of:
|
|
375
|
+
// 'global' — agent_models resolved from ~/.trackfw/trackfw.yaml
|
|
376
|
+
// 'none' — global config absent or has no agent_models; cwd also has none
|
|
377
|
+
// 'project_only' — agent_models in cwd's trackfw.yaml but not global (AC14 trigger)
|
|
378
|
+
// 'global_malformed' — ~/.trackfw/trackfw.yaml exists but has invalid YAML (AC12 trigger)
|
|
379
|
+
// Never calls process.exit. Pattern mirrors readAgentConventions above.
|
|
380
|
+
function loadGlobalAgentModels(homeDir, cwd) {
|
|
381
|
+
const globalPath = path.join(homeDir, '.trackfw', 'trackfw.yaml');
|
|
382
|
+
let data;
|
|
383
|
+
try {
|
|
384
|
+
data = fs.readFileSync(globalPath, 'utf8');
|
|
385
|
+
} catch (_) {
|
|
386
|
+
return { models: {}, source: _cwdAgentModelsSource(cwd) };
|
|
387
|
+
}
|
|
388
|
+
// Validate YAML (non-fatal for global config, per AC12).
|
|
389
|
+
const globalCfg = { rules: {}, credentialGuard: {}, update: {}, sync: {}, linkFields: {}, agentModels: {} };
|
|
390
|
+
const malformed = parse(data, globalCfg);
|
|
391
|
+
if (malformed) {
|
|
392
|
+
return { models: {}, source: 'global_malformed' };
|
|
393
|
+
}
|
|
394
|
+
if (globalCfg.agentModels && Object.keys(globalCfg.agentModels).length > 0) {
|
|
395
|
+
return { models: globalCfg.agentModels, source: 'global' };
|
|
396
|
+
}
|
|
397
|
+
// Global file exists but has no agent_models → check cwd (AC14).
|
|
398
|
+
return { models: {}, source: _cwdAgentModelsSource(cwd) };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// resolveAgentModels returns { models, warning } for the given scope.
|
|
402
|
+
// For global scope, reads from ~/.trackfw/trackfw.yaml via loadGlobalAgentModels.
|
|
403
|
+
// For project scope, reads from the cwd's trackfw.yaml via load().
|
|
404
|
+
// warning is a non-empty string when the caller should emit an advisory to stderr.
|
|
405
|
+
// resolveAgentModels never writes to stderr itself.
|
|
406
|
+
function resolveAgentModels(scope, homeDir, cwd) {
|
|
407
|
+
if (scope !== 'global') {
|
|
408
|
+
return { models: load().agentModels || {}, warning: '' };
|
|
409
|
+
}
|
|
410
|
+
const { models, source } = loadGlobalAgentModels(homeDir, cwd);
|
|
411
|
+
let warning = '';
|
|
412
|
+
if (source === 'none') warning = GLOBAL_AGENT_MODELS_NONE_MESSAGE;
|
|
413
|
+
else if (source === 'project_only') warning = GLOBAL_AGENT_MODELS_PROJECT_ONLY_MESSAGE;
|
|
414
|
+
else if (source === 'global_malformed') warning = MALFORMED_GLOBAL_CONFIG_MESSAGE;
|
|
415
|
+
return { models, warning };
|
|
416
|
+
}
|
|
417
|
+
|
|
343
418
|
const NAMESPACING_FLAT = 'flat';
|
|
344
419
|
const NAMESPACING_BY_AGENT = 'by_agent';
|
|
345
420
|
|
|
@@ -350,7 +425,12 @@ module.exports = {
|
|
|
350
425
|
expandPath,
|
|
351
426
|
parseRulesFromContent,
|
|
352
427
|
readAgentConventions,
|
|
428
|
+
loadGlobalAgentModels,
|
|
429
|
+
resolveAgentModels,
|
|
353
430
|
NAMESPACING_FLAT,
|
|
354
431
|
NAMESPACING_BY_AGENT,
|
|
355
432
|
MALFORMED_CONFIG_MESSAGE,
|
|
433
|
+
MALFORMED_GLOBAL_CONFIG_MESSAGE,
|
|
434
|
+
GLOBAL_AGENT_MODELS_NONE_MESSAGE,
|
|
435
|
+
GLOBAL_AGENT_MODELS_PROJECT_ONLY_MESSAGE,
|
|
356
436
|
};
|
package/src/generators/hooks.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require('fs')
|
|
4
4
|
const path = require('path')
|
|
5
5
|
const os = require('os')
|
|
6
|
+
const { homedir } = require('../homedir')
|
|
6
7
|
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
8
9
|
// Helpers
|
|
@@ -143,8 +144,8 @@ if command -v jq &>/dev/null; then
|
|
|
143
144
|
TOOL=$(echo "$INPUT" | jq -r '.tool_name // ""')
|
|
144
145
|
MSG=$(echo "$INPUT" | jq -r '(.tool_input.question // .tool_input.command // "Agent is executing: \\(.tool_name // "unknown")") | .[0:300]')
|
|
145
146
|
else
|
|
146
|
-
TOOL=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_name',''))" 2>/dev/null || echo "")
|
|
147
|
-
MSG=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); ti=d.get('tool_input',{}); print((ti.get('question') or ti.get('command') or 'Agent is executing: '+d.get('tool_name','unknown'))[:300])" 2>/dev/null || echo "Agent needs attention")
|
|
147
|
+
TOOL=$(echo "$INPUT" | PYTHONIOENCODING=utf-8 python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_name',''))" 2>/dev/null || echo "")
|
|
148
|
+
MSG=$(echo "$INPUT" | PYTHONIOENCODING=utf-8 python3 -c "import sys,json; d=json.load(sys.stdin); ti=d.get('tool_input',{}); print((ti.get('question') or ti.get('command') or 'Agent is executing: '+d.get('tool_name','unknown'))[:300])" 2>/dev/null || echo "Agent needs attention")
|
|
148
149
|
fi
|
|
149
150
|
|
|
150
151
|
ROADMAP_DIR=$(grep '^roadmap_dir:' trackfw.yaml 2>/dev/null | head -1 | sed 's/^roadmap_dir:[[:space:]]*//; s/[[:space:]]*#.*$//' | tr -d '"' | tr -d "'" || true)
|
|
@@ -1047,6 +1048,8 @@ function generateGitBranchGuardScript(cwd) {
|
|
|
1047
1048
|
|
|
1048
1049
|
const scriptPath = path.join(scriptsDir, 'trackfw-git-branch-guard.sh')
|
|
1049
1050
|
fs.writeFileSync(scriptPath, GIT_BRANCH_GUARD_SCRIPT, { encoding: 'utf8', mode: 0o755 })
|
|
1051
|
+
// AC9: chmodSync restores execute bit on existing files.
|
|
1052
|
+
fs.chmodSync(scriptPath, 0o755)
|
|
1050
1053
|
|
|
1051
1054
|
console.log(' ✓ scripts/trackfw-git-branch-guard.sh')
|
|
1052
1055
|
}
|
|
@@ -1084,6 +1087,8 @@ function generateCredentialGuardScript(cwd) {
|
|
|
1084
1087
|
|
|
1085
1088
|
const scriptPath = path.join(scriptsDir, 'trackfw-credential-guard.sh')
|
|
1086
1089
|
fs.writeFileSync(scriptPath, CREDENTIAL_GUARD_SCRIPT, { encoding: 'utf8', mode: 0o755 })
|
|
1090
|
+
// AC9: chmodSync restores execute bit on existing files.
|
|
1091
|
+
fs.chmodSync(scriptPath, 0o755)
|
|
1087
1092
|
|
|
1088
1093
|
console.log(' ✓ scripts/trackfw-credential-guard.sh')
|
|
1089
1094
|
}
|
|
@@ -1253,14 +1258,14 @@ const GBG_CMD_WINDSURF = 'bash scripts/trackfw-git-branch-guard.sh'
|
|
|
1253
1258
|
|
|
1254
1259
|
/** Mirrors Go's globalCredentialGuardScriptPath. */
|
|
1255
1260
|
function globalCredentialGuardScriptPath() {
|
|
1256
|
-
const home =
|
|
1261
|
+
const home = homedir()
|
|
1257
1262
|
if (!home) return null
|
|
1258
1263
|
return path.join(home, '.trackfw', 'scripts', 'trackfw-credential-guard.sh')
|
|
1259
1264
|
}
|
|
1260
1265
|
|
|
1261
1266
|
/** Reads+parses JSON at $HOME/<...relParts>; returns null on any failure (fail-open). */
|
|
1262
1267
|
function readGlobalHookJSON(...relParts) {
|
|
1263
|
-
const home =
|
|
1268
|
+
const home = homedir()
|
|
1264
1269
|
if (!home) return null
|
|
1265
1270
|
try {
|
|
1266
1271
|
const raw = fs.readFileSync(path.join(home, ...relParts), 'utf8')
|
|
@@ -1286,9 +1291,121 @@ function readGlobalHookJSON(...relParts) {
|
|
|
1286
1291
|
* (normalizeGuardPath) and pypi/trackfw/generators/hooks.py
|
|
1287
1292
|
* (_normalize_guard_path). Never call with anything other than a script
|
|
1288
1293
|
* path -- it is not a general string normalizer.
|
|
1294
|
+
*
|
|
1295
|
+
* ROADMAP-2026-09-03 ML-7B -- Windows separator canonicalization, gated on
|
|
1296
|
+
* anchoring, NOT a blanket "\\" -> "/" translate. On POSIX "\\" is a legal
|
|
1297
|
+
* filename byte, so translating it unconditionally would make two
|
|
1298
|
+
* genuinely different paths (one with a literal backslash in a segment
|
|
1299
|
+
* name, one with an extra path separator there) compare equal -- the exact
|
|
1300
|
+
* dangerous loosening this function's own doc comment warns against, and
|
|
1301
|
+
* the risk this ML was told to treat explicitly. The decision, per input
|
|
1302
|
+
* shape (mirrors the UNC/drive-letter arms of
|
|
1303
|
+
* internal/validator/pathIsAnchoredForHookConfig (Go) / index.js's
|
|
1304
|
+
* pathIsAnchoredForHookConfig, reimplemented locally -- not imported,
|
|
1305
|
+
* different question, and this module must not import the validator):
|
|
1306
|
+
*
|
|
1307
|
+
* - "C:\Users\x\guard.sh" / "C:/Users/x/guard.sh" (ASCII drive letter,
|
|
1308
|
+
* ":", then "\" or "/") -- CANONICALIZED: every "\" is translated to
|
|
1309
|
+
* "/" before the collapse below runs, so both forms land on the same
|
|
1310
|
+
* "C:/Users/x/guard.sh". This is the case ML-7A measured as the actual
|
|
1311
|
+
* trigger (a path.join-computed Windows path vs. a hand-concatenated
|
|
1312
|
+
* one).
|
|
1313
|
+
* - "\\servidor\share\guard.sh" (valid UNC: non-empty SERVER not "." or
|
|
1314
|
+
* "..", followed by a non-empty SHARE not itself starting with "\") --
|
|
1315
|
+
* UNCHANGED, byte-for-byte, including its backslashes. Translating it
|
|
1316
|
+
* would collapse "\\server\share" into "//server/share" and then this
|
|
1317
|
+
* function's own "//" -> "/" collapse would eat the second slash,
|
|
1318
|
+
* producing "/server/share/..." -- indistinguishable from a
|
|
1319
|
+
* single-leading-backslash, drive-root-relative path
|
|
1320
|
+
* ("\server\share\..." means something else on Windows) or from a
|
|
1321
|
+
* same-named POSIX path. That collision is precisely a false "already
|
|
1322
|
+
* installed": a network-share guard would dedup-match a local one.
|
|
1323
|
+
* Left untouched, a UNC command never cross-matches a non-UNC one -- no
|
|
1324
|
+
* new equality is introduced.
|
|
1325
|
+
* - "//servidor/share/guard.sh" (the POSIX-typed equivalent of UNC) --
|
|
1326
|
+
* UNCHANGED behavior from before this ML: it does not start with "\",
|
|
1327
|
+
* so it never enters the new branch above; it still collapses via the
|
|
1328
|
+
* existing "//" -> "/" rule below, same as any other POSIX path. Not
|
|
1329
|
+
* unified with the "\\servidor\share\..." form above (pre-existing
|
|
1330
|
+
* asymmetry, out of scope for this ML).
|
|
1331
|
+
* - "\\" and "\\x" alone (no SHARE segment) -- NOT valid UNC by the
|
|
1332
|
+
* predicate above, so they fall through unchanged: no drive letter, no
|
|
1333
|
+
* valid UNC, no translation.
|
|
1334
|
+
* - "C:foo" (drive-relative, no separator after ":") and homoglyph/
|
|
1335
|
+
* zero-width-prefixed strings (e.g. "\uFF43:\...", "\u200bC:\...") --
|
|
1336
|
+
* do NOT match the ASCII-only, position-0 drive-letter check, so no
|
|
1337
|
+
* translation happens. Same anti-spoofing posture as the validator's
|
|
1338
|
+
* drive-letter check.
|
|
1339
|
+
*
|
|
1340
|
+
* Known residual, documented not fixed (hades-tf ML-7B barrier review,
|
|
1341
|
+
* 2026-09-05 parecer): three real Windows-with-"\" shapes have no drive
|
|
1342
|
+
* letter at position 0, so hasWindowsDriveLetterPrefix's gate leaves them
|
|
1343
|
+
* uncanonicalized -- the same pre-ML-7A defect survives for them. Direction
|
|
1344
|
+
* is always TIGHTENS (possible duplicate hook entry), never loosens (the
|
|
1345
|
+
* guard is never silently skipped) -- same safe direction as every other
|
|
1346
|
+
* case above:
|
|
1347
|
+
*
|
|
1348
|
+
* - "\\?\C:\Users\x\guard.sh" (the Win32 long-path prefix; a real form
|
|
1349
|
+
* Windows/long-path APIs produce automatically, not a hypothetical).
|
|
1350
|
+
* - A relative path containing "\" (e.g. "guard\scripts\hook.sh"). Low
|
|
1351
|
+
* practical risk: today both sides of the real comparison always come
|
|
1352
|
+
* from path.join with an absolute home, so a relative command should
|
|
1353
|
+
* not reach this function -- but nothing in the comparator itself
|
|
1354
|
+
* prevents it.
|
|
1355
|
+
* - A home resolved via a network-profile UNC path (e.g.
|
|
1356
|
+
* "\\fileserver\homes\kg\.trackfw\scripts\..."). It never enters the
|
|
1357
|
+
* drive-letter branch and is intentionally left untouched (see the UNC
|
|
1358
|
+
* bullet above), so the original defect persists for both UNC
|
|
1359
|
+
* spellings, not just between UNC and drive-letter forms.
|
|
1360
|
+
*
|
|
1361
|
+
* Not fixed here on purpose: closing any of the three would touch the
|
|
1362
|
+
* drive-letter gate this barrier just approved as conservative, trading a
|
|
1363
|
+
* duplicate-entry nuisance for the more expensive failure mode (collapsing
|
|
1364
|
+
* genuinely different paths into a false "already installed"). Do not treat
|
|
1365
|
+
* rediscovering these three as a new finding.
|
|
1366
|
+
*/
|
|
1367
|
+
/**
|
|
1368
|
+
* Reports whether p begins with a Windows UNC prefix ("\\server\share...")
|
|
1369
|
+
* with a non-empty SERVER segment (not "." or "..") followed by a non-empty
|
|
1370
|
+
* SHARE segment that does not itself start with another backslash. Mirrors
|
|
1371
|
+
* the UNC arm of index.js's pathIsAnchoredForHookConfig -- reimplemented
|
|
1372
|
+
* here (not imported: different question, and this module must not import
|
|
1373
|
+
* the validator). "\\", "\\x" (no share segment) and "\\.\x" / "\\..\evil"
|
|
1374
|
+
* (server "." or "..") are NOT valid UNC. Currently unused by
|
|
1375
|
+
* normalizeGuardPath itself (a valid-UNC string never has a drive-letter
|
|
1376
|
+
* prefix, so the two checks are already mutually exclusive by construction)
|
|
1377
|
+
* -- kept as a named, tested predicate so the "UNC stays untouched"
|
|
1378
|
+
* decision above is verifiable by name, not just by absence of a call, same
|
|
1379
|
+
* as Go's hasValidUNCPrefix (internal/generators/agentfiles.go) and
|
|
1380
|
+
* Python's _has_valid_unc_prefix (pypi/trackfw/generators/hooks.py).
|
|
1381
|
+
* EXPORTED (unlike Go's package-private equivalent) only because
|
|
1382
|
+
* npm/tests/guard_path_normalize.test.js reaches it via
|
|
1383
|
+
* require('../src/generators/hooks') destructuring -- there is no
|
|
1384
|
+
* production caller. Do not build new logic against this export assuming
|
|
1385
|
+
* UNC gets active handling; it does not (see the doc comment above).
|
|
1289
1386
|
*/
|
|
1387
|
+
function hasValidUNCPrefix(p) {
|
|
1388
|
+
if (p.length < 2 || p[0] !== '\\' || p[1] !== '\\') return false
|
|
1389
|
+
const rest = p.slice(2)
|
|
1390
|
+
const cut = rest.indexOf('\\')
|
|
1391
|
+
if (cut === -1) return false
|
|
1392
|
+
const server = rest.slice(0, cut)
|
|
1393
|
+
const share = rest.slice(cut + 1)
|
|
1394
|
+
return server !== '' && server !== '.' && server !== '..' && share !== '' && share[0] !== '\\'
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function hasWindowsDriveLetterPrefix(p) {
|
|
1398
|
+
if (p.length < 3) return false
|
|
1399
|
+
const c = p.charCodeAt(0)
|
|
1400
|
+
const isASCIILetter = (c >= 97 && c <= 122) || (c >= 65 && c <= 90)
|
|
1401
|
+
return isASCIILetter && p[1] === ':' && (p[2] === '\\' || p[2] === '/')
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1290
1404
|
function normalizeGuardPath(p) {
|
|
1291
1405
|
if (!p) return p
|
|
1406
|
+
if (hasWindowsDriveLetterPrefix(p)) {
|
|
1407
|
+
p = p.split('\\').join('/')
|
|
1408
|
+
}
|
|
1292
1409
|
let out = ''
|
|
1293
1410
|
let prevSlash = false
|
|
1294
1411
|
for (const ch of p) {
|
|
@@ -1406,7 +1523,7 @@ function globalCredentialGuardInstalledCopilot() {
|
|
|
1406
1523
|
* explicit instruction for Kiro.
|
|
1407
1524
|
*/
|
|
1408
1525
|
function globalCredentialGuardInstalledKiro() {
|
|
1409
|
-
const home =
|
|
1526
|
+
const home = homedir()
|
|
1410
1527
|
if (!home) return false
|
|
1411
1528
|
try {
|
|
1412
1529
|
const stat = fs.statSync(path.join(home, '.kiro', 'hooks', 'trackfw-credential-guard.json'))
|
|
@@ -1433,7 +1550,7 @@ function globalCredentialGuardInstalledKiro() {
|
|
|
1433
1550
|
|
|
1434
1551
|
/** Mirrors Go's globalGitBranchGuardScriptPath. */
|
|
1435
1552
|
function globalGitBranchGuardScriptPath() {
|
|
1436
|
-
const home =
|
|
1553
|
+
const home = homedir()
|
|
1437
1554
|
if (!home) return null
|
|
1438
1555
|
return path.join(home, '.trackfw', 'scripts', 'trackfw-git-branch-guard.sh')
|
|
1439
1556
|
}
|
|
@@ -1489,9 +1606,14 @@ function generateAttentionScripts(cfg, cwd) {
|
|
|
1489
1606
|
|
|
1490
1607
|
const signalPath = path.join(scriptsDir, 'trackfw-attention-signal.sh')
|
|
1491
1608
|
fs.writeFileSync(signalPath, SIGNAL_SCRIPT, { encoding: 'utf8', mode: 0o755 })
|
|
1609
|
+
// AC9 (REQ-2026-08-28): chmodSync restores the execute bit on existing files
|
|
1610
|
+
// where writeFileSync's {mode} option has no effect (applies only on O_CREAT).
|
|
1611
|
+
fs.chmodSync(signalPath, 0o755)
|
|
1492
1612
|
|
|
1493
1613
|
const cleanupPath = path.join(scriptsDir, 'trackfw-attention-cleanup.sh')
|
|
1494
1614
|
fs.writeFileSync(cleanupPath, CLEANUP_SCRIPT, { encoding: 'utf8', mode: 0o755 })
|
|
1615
|
+
// AC9: same rationale as signalPath above.
|
|
1616
|
+
fs.chmodSync(cleanupPath, 0o755)
|
|
1495
1617
|
|
|
1496
1618
|
console.log(' ✓ scripts/trackfw-attention-signal.sh')
|
|
1497
1619
|
console.log(' ✓ scripts/trackfw-attention-cleanup.sh')
|
|
@@ -2255,4 +2377,15 @@ module.exports = {
|
|
|
2255
2377
|
mergeCopilotHookArray,
|
|
2256
2378
|
normalizeGuardPath,
|
|
2257
2379
|
samePathCommand,
|
|
2380
|
+
hasValidUNCPrefix,
|
|
2381
|
+
hasWindowsDriveLetterPrefix,
|
|
2382
|
+
// Template constants — exported for scaffold doctor (ADR-2026-08-27).
|
|
2383
|
+
// These are the single source of truth for each script's content; the
|
|
2384
|
+
// scaffold doctor compares on-disk files against these exact bytes, using
|
|
2385
|
+
// the same constants the write path uses, so drift between the comparison
|
|
2386
|
+
// and the generator is structurally impossible.
|
|
2387
|
+
SIGNAL_SCRIPT,
|
|
2388
|
+
CLEANUP_SCRIPT,
|
|
2389
|
+
CREDENTIAL_GUARD_SCRIPT,
|
|
2390
|
+
GIT_BRANCH_GUARD_SCRIPT,
|
|
2258
2391
|
}
|