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.
@@ -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 = os.homedir();
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 — see ambiguity
174
- // note in the ML-6C handoff report about config-conditional target lists.
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
- if (include('ci-workflow') && (cfg.ci === 'github-actions' || cfg.ci === 'github_actions')) {
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: '.github/workflows/trackfw-validate.yml',
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-validate.yml'],
264
- apply: (root) => discover.writeCIWorkflowForce(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(os.homedir());
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
@@ -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 os.homedir();
12
+ return homedir();
12
13
  }
13
14
  if (filePath.startsWith('~/') || filePath.startsWith('~\\')) {
14
- return path.join(os.homedir(), filePath.slice(2));
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
  };
@@ -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 = os.homedir()
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 = os.homedir()
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 = os.homedir()
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 = os.homedir()
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
  }