devflow-kit 2.3.0 → 2.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.
Files changed (169) hide show
  1. package/CHANGELOG.md +177 -0
  2. package/README.md +86 -18
  3. package/dist/agents/git.md +824 -0
  4. package/dist/cli/commands/agents.js +6 -1
  5. package/dist/cli/commands/attribution-prompts.js +144 -0
  6. package/dist/cli/commands/compliance-prompts.js +7 -11
  7. package/dist/cli/commands/compliance.js +23 -1
  8. package/dist/cli/commands/init-seed.js +69 -30
  9. package/dist/cli/commands/init.js +551 -77
  10. package/dist/cli/commands/install-report.js +205 -0
  11. package/dist/cli/commands/knowledge/index.js +2 -2
  12. package/dist/cli/commands/knowledge/toggle.js +27 -37
  13. package/dist/cli/commands/learning.js +37 -30
  14. package/dist/cli/commands/memory.js +79 -69
  15. package/dist/cli/commands/prompt-io.js +38 -0
  16. package/dist/cli/commands/proxy.js +91 -31
  17. package/dist/cli/commands/security.js +76 -16
  18. package/dist/cli/commands/skills.js +53 -7
  19. package/dist/cli/commands/tracker-prompts.js +145 -0
  20. package/dist/cli/commands/tracker.js +405 -0
  21. package/dist/cli/commands/uninstall.js +233 -77
  22. package/dist/cli.js +2 -0
  23. package/dist/commands/bug-analysis.md +22 -4
  24. package/dist/commands/code-review.md +44 -15
  25. package/dist/commands/debug.md +20 -6
  26. package/dist/commands/dynamic-build.md +289 -67
  27. package/dist/commands/dynamic-plan.md +60 -21
  28. package/dist/commands/dynamic-profile.md +1 -1
  29. package/dist/commands/dynamic-tickets.md +58 -8
  30. package/dist/commands/explore.md +2 -2
  31. package/dist/commands/implement.md +241 -53
  32. package/dist/commands/plan.md +88 -17
  33. package/dist/commands/release.md +64 -17
  34. package/dist/commands/resolve.md +138 -58
  35. package/dist/commands/self-review.md +2 -2
  36. package/dist/core/agent-models.js +55 -12
  37. package/dist/core/assets.js +58 -2
  38. package/dist/core/evidence-policy.js +147 -0
  39. package/dist/core/feature-config.js +130 -64
  40. package/dist/core/feature-switch.js +112 -0
  41. package/dist/core/flags.js +149 -8
  42. package/dist/core/manifest.js +33 -7
  43. package/dist/core/mds-variants.js +861 -0
  44. package/dist/core/model-discovery.js +12 -1
  45. package/dist/core/plugins.js +357 -9
  46. package/dist/core/project-paths.js +1 -1
  47. package/dist/core/proxy-log.js +8 -4
  48. package/dist/core/proxy-state.js +100 -43
  49. package/dist/core/reference-sweep.js +136 -0
  50. package/dist/core/tracker.js +407 -0
  51. package/dist/skills/git/references/decision-markers.md +19 -0
  52. package/dist/skills/git/references/learn-conventions.md +56 -0
  53. package/dist/skills/git/references/pr/check-ci-status.md +14 -0
  54. package/dist/skills/git/references/pr/check-merge-readiness.md +28 -0
  55. package/dist/skills/git/references/pr/ensure-pr-ready.md +24 -0
  56. package/dist/skills/git/references/pr/fetch-review-threads.md +22 -0
  57. package/dist/skills/git/references/pr/post-resolution-summary.md +40 -0
  58. package/dist/skills/git/references/pr/post-review-summary.md +42 -0
  59. package/dist/skills/git/references/pr/resolve-review-threads.md +35 -0
  60. package/dist/skills/git/references/pr/update-pr-evidence.md +14 -0
  61. package/dist/skills/git/references/pr/validate-branch.md +18 -0
  62. package/dist/skills/git/references/publication-gate.md +13 -0
  63. package/dist/skills/git/references/tracker/_mcp.md +153 -0
  64. package/dist/skills/git/references/tracker/github/associate-release.md +18 -0
  65. package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +40 -0
  66. package/dist/skills/git/references/tracker/github/create-release.md +11 -0
  67. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +16 -0
  68. package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +69 -0
  69. package/dist/skills/git/references/tracker/github/fetch-issue.md +32 -0
  70. package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +17 -0
  71. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +19 -0
  72. package/dist/skills/git/references/tracker/github/manage-debt.md +101 -0
  73. package/dist/skills/git/references/tracker/github/post-wave-report.md +28 -0
  74. package/dist/skills/git/references/tracker/github/setup-task.md +26 -0
  75. package/dist/skills/git/references/tracker/jira/associate-release.md +18 -0
  76. package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +49 -0
  77. package/dist/skills/git/references/tracker/jira/create-release.md +17 -0
  78. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +22 -0
  79. package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +53 -0
  80. package/dist/skills/git/references/tracker/jira/fetch-issue.md +14 -0
  81. package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +15 -0
  82. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +18 -0
  83. package/dist/skills/git/references/tracker/jira/manage-debt.md +37 -0
  84. package/dist/skills/git/references/tracker/jira/post-wave-report.md +33 -0
  85. package/dist/skills/git/references/tracker/jira/setup-task.md +31 -0
  86. package/dist/skills/git/references/tracker/linear/associate-release.md +18 -0
  87. package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +53 -0
  88. package/dist/skills/git/references/tracker/linear/create-release.md +17 -0
  89. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +22 -0
  90. package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +53 -0
  91. package/dist/skills/git/references/tracker/linear/fetch-issue.md +14 -0
  92. package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +15 -0
  93. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +18 -0
  94. package/dist/skills/git/references/tracker/linear/manage-debt.md +37 -0
  95. package/dist/skills/git/references/tracker/linear/post-wave-report.md +33 -0
  96. package/dist/skills/git/references/tracker/linear/setup-task.md +32 -0
  97. package/dist/skills/git/references/trust-rule.md +7 -0
  98. package/dist/targets/claude-code/installer.js +1213 -31
  99. package/dist/targets/claude-code/legacy.js +5 -0
  100. package/dist/targets/claude-code/post-install.js +303 -104
  101. package/dist/targets/claude-code/tracker-install.js +161 -0
  102. package/package.json +4 -3
  103. package/src/assets/agents/code.md +42 -4
  104. package/src/assets/agents/design.md +1 -1
  105. package/src/assets/agents/git.mds +827 -0
  106. package/src/assets/agents/knowledge.md +1 -1
  107. package/src/assets/agents/learning.md +11 -0
  108. package/src/assets/agents/synthesize.md +1 -1
  109. package/src/assets/agents/test.md +16 -5
  110. package/src/assets/agents/tracker.md +467 -0
  111. package/src/assets/agents/validate.md +7 -5
  112. package/src/assets/commands/_partials/_engine.mds +11 -9
  113. package/src/assets/commands/_partials/_evidence_policy.mds +30 -0
  114. package/src/assets/commands/_partials/_knowledge.mds +2 -2
  115. package/src/assets/commands/_partials/_plan_contract.mds +22 -7
  116. package/src/assets/commands/_partials/_preamble.mds +1 -1
  117. package/src/assets/commands/_partials/_publication.mds +3 -1
  118. package/src/assets/commands/_partials/_ticket_template.mds +3 -2
  119. package/src/assets/commands/_partials/_tracker.mds +18 -0
  120. package/src/assets/commands/_partials/_wave.mds +16 -10
  121. package/src/assets/commands/bug-analysis.mds +15 -5
  122. package/src/assets/commands/code-review.mds +34 -14
  123. package/src/assets/commands/debug.mds +11 -4
  124. package/src/assets/commands/dynamic-build.mds +227 -41
  125. package/src/assets/commands/dynamic-plan.mds +35 -13
  126. package/src/assets/commands/dynamic-tickets.mds +47 -5
  127. package/src/assets/commands/implement.mds +206 -52
  128. package/src/assets/commands/plan.mds +70 -17
  129. package/src/assets/commands/release.md +64 -17
  130. package/src/assets/commands/resolve.mds +126 -56
  131. package/src/assets/mds/git/_pr.mds +331 -0
  132. package/src/assets/mds/git/_references.mds +135 -0
  133. package/src/assets/mds/tracker/_common.mds +156 -0
  134. package/src/assets/mds/tracker/_github.mds +472 -0
  135. package/src/assets/mds/tracker/_jira.mds +407 -0
  136. package/src/assets/mds/tracker/_linear.mds +449 -0
  137. package/src/assets/mds/tracker/_mcp.mds +299 -0
  138. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +5 -8
  139. package/src/assets/scripts/hooks/background-memory-update +14 -9
  140. package/src/assets/scripts/hooks/capture-prompt +6 -2
  141. package/src/assets/scripts/hooks/capture-question +6 -2
  142. package/src/assets/scripts/hooks/capture-turn +6 -2
  143. package/src/assets/scripts/hooks/ensure-devflow-init +1 -1
  144. package/src/assets/scripts/hooks/ensure-proxy +64 -5
  145. package/src/assets/scripts/hooks/ensure-root-gitignore +161 -60
  146. package/src/assets/scripts/hooks/hook-log-init +3 -1
  147. package/src/assets/scripts/hooks/json-helper.cjs +223 -5
  148. package/src/assets/scripts/hooks/lib/project-paths.cjs +1 -1
  149. package/src/assets/scripts/hooks/memory-worker +15 -8
  150. package/src/assets/scripts/hooks/pre-compact-memory +12 -8
  151. package/src/assets/scripts/hooks/preamble +1 -4
  152. package/src/assets/scripts/hooks/queue-append +68 -24
  153. package/src/assets/scripts/hooks/session-start-context +355 -8
  154. package/src/assets/scripts/hooks/session-start-memory +12 -8
  155. package/src/assets/scripts/pr-evidence.cjs +1961 -0
  156. package/src/assets/scripts/redact-secrets.cjs +490 -62
  157. package/src/assets/scripts/release-trace.cjs +1143 -0
  158. package/src/assets/scripts/resolve-evidence-policy.cjs +1065 -0
  159. package/src/assets/scripts/verify-evidence.cjs +1822 -0
  160. package/src/assets/skills/compliance/SKILL.md +2 -0
  161. package/src/assets/skills/docs-framework/SKILL.md +5 -3
  162. package/src/assets/skills/git/SKILL.md +8 -82
  163. package/src/assets/skills/git/references/github-api.md +179 -141
  164. package/src/assets/skills/git/references/patterns.md +11 -12
  165. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  166. package/src/assets/skills/review-methodology/references/patterns.md +6 -61
  167. package/src/assets/skills/review-methodology/references/violations.md +14 -22
  168. package/src/targets/claude-code/templates/settings.json +0 -4
  169. package/src/assets/agents/git.md +0 -938
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shared wizard prompt-IO seam for devflow init wizard steps.
3
+ *
4
+ * One-definition seam: PromptOutcome and WizardPromptIO were byte-identical
5
+ * duplicates across attribution-prompts.ts and compliance-prompts.ts
6
+ * (architecture-03 / consistency-06). They are defined ONCE here and re-used
7
+ * via import.
8
+ *
9
+ * D-PROMPT-IO: WizardPromptIO is the base DI seam for all two-action wizard
10
+ * steps (note + boolean select). Modules that add a third prompt extend this
11
+ * interface with an intersection type (e.g. CompliancePromptIO).
12
+ */
13
+ import * as p from '@clack/prompts';
14
+ // ── Shared clack adapters ─────────────────────────────────────────────────────
15
+ /** Real clack adapter for the note prompt. */
16
+ export function clackNote(message, title) {
17
+ p.note(message, title);
18
+ }
19
+ /**
20
+ * Real clack adapter for a select prompt, generic over the option value type T.
21
+ *
22
+ * typescript-05: no `as T` cast on the result path. `p.select<T>` returns
23
+ * `Promise<symbol | T>`; `p.isCancel` is a `(value: unknown) => value is symbol`
24
+ * guard that narrows away the cancel branch, leaving `result: T` without a cast.
25
+ * An `as T` here would mask a future widening of the library's return type.
26
+ *
27
+ * The `as unknown as SelectOptions<T>` on the input is a safe bridge for the
28
+ * unresolved conditional type `Option<T>` — our shape satisfies both branches
29
+ * (Primitive: label optional; non-Primitive: label required) and is strictly
30
+ * narrower, so no value-type information is lost.
31
+ */
32
+ export async function clackSelect(opts) {
33
+ const result = await p.select(opts);
34
+ if (p.isCancel(result))
35
+ return { kind: 'cancel' };
36
+ return { kind: 'value', value: result };
37
+ }
38
+ //# sourceMappingURL=prompt-io.js.map
@@ -22,7 +22,7 @@ import * as https from 'https';
22
22
  import { spawn as cpSpawn } from 'child_process';
23
23
  import * as p from '@clack/prompts';
24
24
  import color from 'picocolors';
25
- import { readProxyState, writeProxyState, buildProxyState, buildRoutingConfigJson, proxyBaseUrl, resolveProxyBin, DEFAULT_PROXY_PORT, } from '../../core/proxy-state.js';
25
+ import { readProxyState, writeProxyState, buildProxyState, buildRoutingConfigJson, proxyBaseUrl, proxyJsonExists, resolveProxyBin, DEFAULT_PROXY_PORT, } from '../../core/proxy-state.js';
26
26
  import { syncManifestFeature, readManifest } from '../../core/manifest.js';
27
27
  import { writeFileAtomicExclusive } from '../../core/fs-atomic.js';
28
28
  import { scrubChildEnv, openProxyLog, rotateProxyLogIfLarge } from '../../core/proxy-log.js';
@@ -149,9 +149,11 @@ function _stripProxyEnvFromObject(settings, managedPort) {
149
149
  const env = s.env;
150
150
  if (!env)
151
151
  return false;
152
- // Devflow is the only producer of this var — always remove it, regardless of whether
153
- // the URL is still ours. Port-scoping protects a FOREIGN url value; there is no
154
- // foreign value of this key to protect. (applies PF-015, ADR-003)
152
+ // D-STRIP-1: every REMOVAL caller (init, uninstall, runDisable) first proves Devflow
153
+ // managed the proxy — `proxyJsonExists()` — and the enable caller is taking ownership
154
+ // of the key anyway, so reaching this line means the value is Devflow's to remove.
155
+ // Removal is therefore unconditional: unlike ANTHROPIC_BASE_URL (port-scoped below),
156
+ // this key has no foreign value to protect. (applies PF-015, ADR-003)
155
157
  const hadWindowVar = env[UNKNOWN_MODEL_WINDOW_ENV] !== undefined;
156
158
  delete env[UNKNOWN_MODEL_WINDOW_ENV];
157
159
  let removedUrl = false;
@@ -284,6 +286,31 @@ export function applyDisableToSettings(settings, managedPort) {
284
286
  const strippedEnv = _stripProxyEnvFromObject(settings, managedPort);
285
287
  return removedHooks || strippedEnv;
286
288
  }
289
+ /**
290
+ * Settings teardown for every path that turns the proxy off: `devflow proxy --disable`
291
+ * and `devflow uninstall`.
292
+ *
293
+ * D-STRIP-1: the two removals answer to different evidence, so they are gated
294
+ * separately.
295
+ * - Hooks are Devflow's own artifacts — removed unconditionally. Leaving them behind
296
+ * points later sessions at a hook script that no longer exists, and makes a re-run
297
+ * of an interrupted uninstall unable to clean up.
298
+ * - The env vars (ANTHROPIC_BASE_URL, CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT)
299
+ * are stripped only against `managedPort`, which the caller supplies from an existing
300
+ * `proxy.json` — the one piece of evidence that Devflow ever wrote them. Pass
301
+ * `undefined` when that file is absent: the values then belong to a gateway Devflow
302
+ * never managed (a user's own LiteLLM on 4141, say) and must survive untouched.
303
+ *
304
+ * When `managedPort` is defined this delegates to `applyDisableToSettings`, so the
305
+ * both-operations invariant documented there still holds.
306
+ *
307
+ * Mutates settings in place. Returns true when any change was made.
308
+ */
309
+ export function applyProxyTeardownToSettings(settings, managedPort) {
310
+ if (managedPort === undefined)
311
+ return removeProxyHooks(settings);
312
+ return applyDisableToSettings(settings, managedPort);
313
+ }
287
314
  /**
288
315
  * Check whether the ensure-proxy hook is registered on at least one event.
289
316
  * Returns true if present on either SessionStart or UserPromptSubmit.
@@ -316,6 +343,47 @@ export function isOurRelayBody(body) {
316
343
  return false;
317
344
  }
318
345
  }
346
+ /**
347
+ * Preflight check ④ — settings.json readable and parseable, no foreign
348
+ * ANTHROPIC_BASE_URL, plus the non-fatal ANTHROPIC_API_KEY warning.
349
+ *
350
+ * Extracted so the adopted-relay path and the free-port path run the identical
351
+ * check (D-EFR-5): a healthy relay on the port must not buy an exemption from the
352
+ * foreign-gateway refusal. Keeping it in one function is what stops the two paths
353
+ * from drifting on which sub-checks they apply or which message they return.
354
+ *
355
+ * `swallowSettingsReadError` semantics are preserved by the caller-supplied dep:
356
+ * when that flag is set (init), readSettingsJson() resolves to '{}' on I/O failure
357
+ * instead of rejecting, so the read-failure branch is reachable only from runEnable.
358
+ */
359
+ async function checkSettingsEnv(deps, port) {
360
+ let settingsJson;
361
+ try {
362
+ settingsJson = await deps.readSettingsJson();
363
+ }
364
+ catch {
365
+ return Err('Could not read settings.json — check file permissions');
366
+ }
367
+ let parsedSettings;
368
+ try {
369
+ parsedSettings = JSON.parse(settingsJson);
370
+ }
371
+ catch {
372
+ return Err('settings.json is malformed — fix it before enabling the proxy');
373
+ }
374
+ if (readProxyEnvState(settingsJson, port) === 'foreign') {
375
+ return Err('An existing ANTHROPIC_BASE_URL in settings.json points to a different gateway — Devflow will not overwrite it');
376
+ }
377
+ // API key warning (non-fatal)
378
+ const envBlock = parsedSettings.env;
379
+ if (typeof envBlock === 'object' &&
380
+ envBlock !== null &&
381
+ !Array.isArray(envBlock) &&
382
+ typeof envBlock.ANTHROPIC_API_KEY === 'string') {
383
+ deps.onWarn?.('ANTHROPIC_API_KEY is set in settings.json — requests will use that key through the local relay');
384
+ }
385
+ return Ok(undefined);
386
+ }
319
387
  /**
320
388
  * Run preflight checks before enabling the Devflow proxy.
321
389
  *
@@ -324,6 +392,8 @@ export function isOurRelayBody(body) {
324
392
  * ② ~/.codex/auth.json exists.
325
393
  * ③ Port probe: free → OK; accepting → health check → adopt or fail.
326
394
  * ④ settings.json parseable; ANTHROPIC_BASE_URL not pointing elsewhere; API key warn.
395
+ * Runs on BOTH outcomes of ③ that can proceed — adopted relay and free port
396
+ * (D-EFR-5) — via the shared `checkSettingsEnv` helper.
327
397
  *
328
398
  * Doctor is deliberately excluded from preflight: the relay's doctor subcommand
329
399
  * probes the relay port — a not-yet-started relay makes that probe fail (exit 1). A
@@ -349,6 +419,12 @@ export async function runProxyPreflight(port, codexAuthPath, configPath, logPath
349
419
  // Port is up — check health identity
350
420
  const healthResult = await deps.httpGet(`${proxyBaseUrl(port)}/__subswitch/health`, PROBE_TIMEOUT_MS);
351
421
  if (healthResult.ok && isOurRelayBody(healthResult.value)) {
422
+ // D-EFR-5: run check ④ BEFORE the adopted early-return. The old order
423
+ // (adopted-return first, settings check only when the port was free) let a
424
+ // healthy relay on the port silently skip the foreign-gateway refusal entirely.
425
+ const settingsCheck = await checkSettingsEnv(deps, port);
426
+ if (!settingsCheck.ok)
427
+ return Err(settingsCheck.error);
352
428
  return Ok({ binPath, npxWarning, adopted: true });
353
429
  }
354
430
  // Port accepting but health timed out, failed, or not our relay
@@ -356,32 +432,9 @@ export async function runProxyPreflight(port, codexAuthPath, configPath, logPath
356
432
  }
357
433
  // Port refused — free to proceed
358
434
  // ④ Settings.json check
359
- let settingsJson;
360
- try {
361
- settingsJson = await deps.readSettingsJson();
362
- }
363
- catch {
364
- return Err('Could not read settings.json — check file permissions');
365
- }
366
- let parsedSettings;
367
- try {
368
- parsedSettings = JSON.parse(settingsJson);
369
- }
370
- catch {
371
- return Err('settings.json is malformed — fix it before enabling the proxy');
372
- }
373
- const envState = readProxyEnvState(settingsJson, port);
374
- if (envState === 'foreign') {
375
- return Err('An existing ANTHROPIC_BASE_URL in settings.json points to a different gateway — Devflow will not overwrite it');
376
- }
377
- // API key warning (non-fatal)
378
- const envBlock = parsedSettings.env;
379
- if (typeof envBlock === 'object' &&
380
- envBlock !== null &&
381
- !Array.isArray(envBlock) &&
382
- typeof envBlock.ANTHROPIC_API_KEY === 'string') {
383
- deps.onWarn?.('ANTHROPIC_API_KEY is set in settings.json — requests will use that key through the local relay');
384
- }
435
+ const settingsCheck = await checkSettingsEnv(deps, port);
436
+ if (!settingsCheck.ok)
437
+ return Err(settingsCheck.error);
385
438
  return Ok({ binPath, npxWarning, adopted: false });
386
439
  }
387
440
  // ─── Production dependency implementations ────────────────────────────────────
@@ -1381,6 +1434,13 @@ async function runDisable() {
1381
1434
  // applyDisableToSettings strips ANTHROPIC_BASE_URL only when the URL
1382
1435
  // port matches the port Devflow manages — callers must supply it. Reading
1383
1436
  // proxy.json here also consolidates state for Step 2 below.
1437
+ //
1438
+ // D-STRIP-1: readProxyState() cannot distinguish "file absent" from "file present
1439
+ // with the default port", so the env strip is gated on the file's existence — the
1440
+ // only evidence that Devflow ever wrote those vars. Must be read before Step 2
1441
+ // creates the file. Hook removal is NOT gated: our hooks are ours to remove on
1442
+ // every path.
1443
+ const proxyManaged = await proxyJsonExists(devflowDir);
1384
1444
  const priorStateResult = await readProxyState(devflowDir);
1385
1445
  const priorState = priorStateResult.ok ? priorStateResult.value : null;
1386
1446
  const managedPort = priorState?.port ?? DEFAULT_PROXY_PORT;
@@ -1401,7 +1461,7 @@ async function runDisable() {
1401
1461
  process.exitCode = 1;
1402
1462
  return;
1403
1463
  }
1404
- const changed = applyDisableToSettings(parsedSettings, managedPort);
1464
+ const changed = applyProxyTeardownToSettings(parsedSettings, proxyManaged ? managedPort : undefined);
1405
1465
  if (changed) {
1406
1466
  // Guard ENOSPC/EACCES — unhandled rejection leaves proxy in partial state
1407
1467
  try {
@@ -23,7 +23,74 @@ export function countDenyEntries(settingsJson) {
23
23
  return 0;
24
24
  }
25
25
  }
26
- // ─── Command ──────────────────────────────────────────────────────────────────
26
+ /**
27
+ * Remove the Devflow deny list from the managed settings file — the ONE
28
+ * implementation `devflow security --disable` and `devflow init --security none`
29
+ * share, so "none" means none in every location (#378).
30
+ *
31
+ * Gated on detectDenyState's parse-safe signal rather than on the file merely
32
+ * existing: removeManagedSettings parses the file without a guard, so a corrupt
33
+ * managed file must be classified here and never reach it (SF5). Never throws —
34
+ * a permission failure is the `failed` outcome, reported by the caller.
35
+ *
36
+ * `managedPath` is the injectable seam for tests (see removeManagedSettings);
37
+ * production callers omit it and get the platform path.
38
+ */
39
+ export async function removeManagedDenyList(rootDir, verbose, managedPath) {
40
+ let target;
41
+ if (managedPath !== undefined) {
42
+ target = managedPath;
43
+ }
44
+ else {
45
+ try {
46
+ target = getManagedSettingsPath();
47
+ }
48
+ catch {
49
+ return { kind: 'absent' };
50
+ }
51
+ }
52
+ let content;
53
+ try {
54
+ content = await fs.readFile(target, 'utf-8');
55
+ }
56
+ catch {
57
+ return { kind: 'absent' };
58
+ }
59
+ if (!detectDenyState(null, true, content).managed) {
60
+ return { kind: 'no-devflow-entries', path: target };
61
+ }
62
+ const removed = await removeManagedSettings(rootDir, verbose, target);
63
+ return removed ? { kind: 'removed', path: target } : { kind: 'failed', path: target };
64
+ }
65
+ /**
66
+ * The user-facing line for a ManagedDenyRemoval. Pure.
67
+ *
68
+ * D-MANAGED-REMOVAL-REMEDY: both `devflow security --disable` and
69
+ * `devflow init --security none` print this line, so the `failed` remedy names
70
+ * no command — pointing at `devflow security --disable` sent a user of that very
71
+ * command back to it. A `failed` removal means the write needed admin rights and
72
+ * the sudo fallback could not run (no interactive terminal) or was declined, so
73
+ * the remedy is exactly that: re-run interactively and accept the prompt, or
74
+ * edit the named file as an administrator.
75
+ */
76
+ export function describeManagedDenyRemoval(outcome) {
77
+ switch (outcome.kind) {
78
+ case 'removed':
79
+ return { level: 'info', text: `Security deny list removed from managed settings (${outcome.path})` };
80
+ case 'absent':
81
+ return { level: 'info', text: 'No managed settings to remove' };
82
+ case 'no-devflow-entries':
83
+ return { level: 'warn', text: 'Managed settings file exists but contains no Devflow deny entries — skipping' };
84
+ case 'failed':
85
+ return {
86
+ level: 'warn',
87
+ text: `Could not remove the Devflow deny list from managed settings (${outcome.path}) — ` +
88
+ 'it needs admin rights, and sudo was declined or unavailable. Re-run this command in an ' +
89
+ 'interactive terminal and accept the sudo prompt, or, as an administrator, remove the Devflow ' +
90
+ `entries from permissions.deny in ${outcome.path}.`,
91
+ };
92
+ }
93
+ }
27
94
  export const securityCommand = new Command('security')
28
95
  .description('Manage the security deny list (permissions.deny in Claude Code settings)')
29
96
  .option('--status', 'Show current deny list state and entry counts')
@@ -157,21 +224,14 @@ export const securityCommand = new Command('security')
157
224
  p.log.info('No Devflow deny entries found in user settings');
158
225
  }
159
226
  }
160
- // Managed settings: gate on detected.managed (parse-safe signal from detectDenyState),
161
- // not raw managedExists — avoids raw JSON.parse crash inside removeManagedSettings
162
- // when the managed file exists but is corrupt JSON. (SF5 — applies engineering.md
163
- // "never throw in business logic"; avoids PF partial-failure: user settings already
164
- // stripped but manifest sync would be skipped on uncaught throw.)
165
- if (detected.managed) {
166
- await removeManagedSettings(rootDir, true);
167
- }
168
- else if (managedExists && !detected.managed) {
169
- // File exists but is unparseable or contains no Devflow entries — warn and skip.
170
- p.log.warn('Managed settings file exists but contains no Devflow deny entries — skipping');
171
- }
172
- else {
173
- p.log.info('No managed settings to remove');
174
- }
227
+ // Managed settings: the shared removal (parse-safe gate, never throws — SF5;
228
+ // a throw here would skip the manifest sync below after user settings were
229
+ // already stripped).
230
+ const managedMsg = describeManagedDenyRemoval(await removeManagedDenyList(rootDir, false));
231
+ if (managedMsg.level === 'warn')
232
+ p.log.warn(managedMsg.text);
233
+ else
234
+ p.log.info(managedMsg.text);
175
235
  await syncManifestFeature(devflowDir, 'security', 'none');
176
236
  return;
177
237
  }
@@ -4,7 +4,8 @@ import * as path from 'path';
4
4
  import * as p from '@clack/prompts';
5
5
  import color from 'picocolors';
6
6
  import { getClaudeDirectory, getDevFlowDirectory } from '../../targets/claude-code/claude-paths.js';
7
- import { getAllSkillNames, prefixSkillName, unprefixSkillName, FEATURE_OWNED_SKILLS } from '../../core/plugins.js';
7
+ import { getAllSkillNames, prefixSkillName, unprefixSkillName, skillOwners, FEATURE_OWNED_SKILLS } from '../../core/plugins.js';
8
+ import { skillsDir } from '../../core/assets.js';
8
9
  import { copyDirectory, validateSkillShadow } from '../../targets/claude-code/installer.js';
9
10
  /**
10
11
  * Check if a directory exists.
@@ -31,6 +32,25 @@ export async function hasShadow(skillName, devflowDir) {
31
32
  const dir = devflowDir ?? getDevFlowDirectory();
32
33
  return dirExists(getShadowDir(dir, skillName));
33
34
  }
35
+ /**
36
+ * Which plugin(s) a user must select to get a skill — rendered for a message.
37
+ *
38
+ * Every declarer, never the first: once install is scoped, the question this
39
+ * answers is "which plugin do I select to keep this?", and a first-wins answer
40
+ * names one plugin out of several that would each do (D-ALL-OWNERS).
41
+ * FEATURE_OWNED skills have no plugin owner at all and say so, because telling
42
+ * a user to select a plugin for `compliance` would send them looking for one
43
+ * that does not exist.
44
+ */
45
+ function describeOwners(bareName, owners) {
46
+ const declarers = owners ?? skillOwners(bareName);
47
+ if (declarers.length === 0) {
48
+ return FEATURE_OWNED_SKILLS.includes(bareName)
49
+ ? 'its feature (devflow compliance --enable)'
50
+ : 'no plugin';
51
+ }
52
+ return declarers.join(' or ');
53
+ }
34
54
  /** Render the shadow-state display tag for a skill. Exhaustive switch catches new states at compile time. */
35
55
  function buildSkillShadowTag(shadowState) {
36
56
  switch (shadowState) {
@@ -72,8 +92,16 @@ export const skillsCommand = new Command('skills')
72
92
  }
73
93
  const prefixedName = prefixSkillName(bareName);
74
94
  const installedSkillDir = path.join(claudeDir, 'skills', prefixedName);
75
- if (!await dirExists(installedSkillDir)) {
76
- p.log.error(`Skill not installed: ${prefixedName}. Run devflow init first.`);
95
+ const installed = await dirExists(installedSkillDir);
96
+ // A skill outside the current selection is not installed, and that is no
97
+ // longer a reason to refuse: skills are plugin-scoped now, so "not
98
+ // installed" is an ordinary state for a registry skill nobody selected.
99
+ // The shadow is seeded from the shipped source instead and reported as
100
+ // DORMANT — it exists, it is preserved by every future install, and it
101
+ // applies to nothing until the plugin that uses it is selected.
102
+ const seedDir = installed ? installedSkillDir : path.join(skillsDir(), bareName);
103
+ if (!await dirExists(seedDir)) {
104
+ p.log.error(`No source for ${bareName} — reinstall devflow, then try again.`);
77
105
  process.exit(1);
78
106
  }
79
107
  const shadowDir = getShadowDir(devflowDir, bareName);
@@ -83,8 +111,12 @@ export const skillsCommand = new Command('skills')
83
111
  }
84
112
  // Create shadow directory (unprefixed) and copy original as reference backup
85
113
  await fs.mkdir(path.join(devflowDir, 'skills'), { recursive: true });
86
- await copyDirectory(installedSkillDir, shadowDir);
114
+ await copyDirectory(seedDir, shadowDir);
87
115
  p.log.success(`Shadowed ${color.cyan(bareName)}`);
116
+ if (!installed) {
117
+ p.log.warn(`Shadow for ${bareName} is inactive — the plugin that uses it is not selected. ` +
118
+ `Run devflow init and select ${describeOwners(bareName)} to apply it.`);
119
+ }
88
120
  p.log.info(`Edit ${color.dim(path.join(shadowDir, 'SKILL.md'))} then run devflow init to apply.`);
89
121
  }
90
122
  else if (action === 'unshadow') {
@@ -114,14 +146,27 @@ export const skillsCommand = new Command('skills')
114
146
  catch { /* skills dir absent — no shadows */ }
115
147
  const shadowDirSet = new Set(shadowDirNames);
116
148
  const knownSkillSet = new Set(allSkills);
149
+ // L3: every skill's declarers, resolved ONCE into a map before any row is
150
+ // rendered. Calling skillOwners() inside the row loop would walk the whole
151
+ // registry per skill for an answer that does not change between rows.
152
+ const ownersBySkill = new Map(allSkills.map(skill => [skill, skillOwners(skill)]));
117
153
  // Build rows in parallel; short-circuit validateSkillShadow for skills with no shadow dir
118
154
  const knownResults = await Promise.all(allSkills.map(async (skill) => {
119
155
  const shadowState = shadowDirSet.has(skill)
120
156
  ? await validateSkillShadow(path.join(shadowsRoot, skill))
121
157
  : 'none';
122
- return { skill, shadowState };
158
+ const installed = await dirExists(path.join(claudeDir, 'skills', prefixSkillName(skill)));
159
+ return { skill, shadowState, installed };
123
160
  }));
124
- const rows = knownResults.map(({ skill, shadowState }) => ` ${color.cyan(skill.padEnd(28))} ${buildSkillShadowTag(shadowState)}`);
161
+ const rows = knownResults.map(({ skill, shadowState, installed }) => {
162
+ // Skills are plugin-scoped, so "which plugin do I select to keep this?"
163
+ // is the question the list has to answer — and for a skill that is not
164
+ // installed it is the only useful thing the row can say.
165
+ const provenance = installed
166
+ ? color.dim(`installed because: ${describeOwners(skill, ownersBySkill.get(skill))}`)
167
+ : color.dim(`not installed — provided by: ${describeOwners(skill, ownersBySkill.get(skill))}`);
168
+ return ` ${color.cyan(skill.padEnd(28))} ${buildSkillShadowTag(shadowState).padEnd(20)} ${provenance}`;
169
+ });
125
170
  // Orphan shadows: in ~/.devflow/skills/ but not a known skill
126
171
  for (const dirName of shadowDirNames) {
127
172
  if (!knownSkillSet.has(dirName)) {
@@ -129,7 +174,8 @@ export const skillsCommand = new Command('skills')
129
174
  }
130
175
  }
131
176
  const shadowedCount = knownResults.filter(r => r.shadowState !== 'none').length;
132
- p.note(rows.join('\n'), `Skills (${allSkills.length} known, ${shadowedCount} shadowed)`);
177
+ const installedCount = knownResults.filter(r => r.installed).length;
178
+ p.note(rows.join('\n'), `Skills (${allSkills.length} known, ${installedCount} installed, ${shadowedCount} shadowed)`);
133
179
  }
134
180
  else {
135
181
  p.log.error(`Unknown action: ${action}`);
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Tracker prompt helpers for devflow init.
3
+ *
4
+ * CLI-layer module (ADR-013): prompt-rendering logic lives in src/cli/commands/,
5
+ * core business logic stays in src/core/tracker.ts.
6
+ *
7
+ * avoids PF-029: the wizard gate keys on `modePromptShown` (was the Setup-mode
8
+ * p.select prompt actually shown?), never on the mode name, so --recommended
9
+ * (flag, no prompt) and the non-TTY fallback preserve their promptless contracts.
10
+ * avoids PF-014: runTrackerStep never calls process.exit() or throws — callers
11
+ * own the cancel idiom (p.cancel + process.exit(0)), keeping try/finally safe.
12
+ * The shared DI seam (PromptOutcome, WizardPromptIO, clackNote, clackSelect) is
13
+ * defined once in prompt-io.ts and imported here, never re-declared.
14
+ *
15
+ * D-TRACKER-GATE: this step copies COMPLIANCE's gate, not ATTRIBUTION's.
16
+ * Attribution is Advanced-only because it silently rewrites git metadata; a
17
+ * wrong tracker provider is immediately visible and trivially reversible, while
18
+ * a user who never sees the question silently gets `github` — invisible to
19
+ * exactly the Jira/Linear user the question exists for. Compliance's signature
20
+ * is also the only one already carrying `hasCliOverride`, which `--tracker`
21
+ * needs.
22
+ */
23
+ import { clackNote, clackSelect } from './prompt-io.js';
24
+ import { DEFAULT_TRACKER_PROVIDER, TRACKER_PROVIDERS, } from '../../core/tracker.js';
25
+ // ── Shared prompt content ──────────────────────────────────────────────────────
26
+ /** Message shown on the tracker provider select prompt. */
27
+ export const TRACKER_SELECT_MESSAGE = 'Issue tracker for this machine';
28
+ /** Build clack select options for the tracker provider list. */
29
+ export function providerChoices() {
30
+ return TRACKER_PROVIDERS.map(provider => ({
31
+ value: provider.id,
32
+ label: provider.label,
33
+ hint: provider.hint,
34
+ }));
35
+ }
36
+ /**
37
+ * Format a padded provider catalogue suitable for a clack note body.
38
+ * Produces: ` github — GitHub Issues through the gh CLI`
39
+ */
40
+ export function formatProviderCatalogue() {
41
+ return 'Valid provider IDs:\n' +
42
+ TRACKER_PROVIDERS.map(p => ` ${p.id.padEnd(10)} — ${p.hint}`).join('\n');
43
+ }
44
+ /**
45
+ * Format tracker state for the Recommended-mode summary line and note header.
46
+ *
47
+ * Pure function — no I/O, no side effects. `github` carries the `(default)`
48
+ * marker so a user reading the summary can tell "I chose this" from "this is
49
+ * what devflow does when nobody chooses".
50
+ */
51
+ export function formatTrackerSummary(provider) {
52
+ return provider === DEFAULT_TRACKER_PROVIDER ? `${provider} (default)` : provider;
53
+ }
54
+ // ── Gate predicate ─────────────────────────────────────────────────────────────
55
+ /**
56
+ * Determines whether the tracker wizard step should run for a given init invocation.
57
+ *
58
+ * Gate table (per PF-029: key on modePromptShown, never on the mode name):
59
+ *
60
+ * --recommended flag / !isTTY fallback → no (promptless contract preserved)
61
+ * Interactive mode-prompt → Recommended → yes (modePromptShown=true)
62
+ * --advanced flag / re-init (banner path) → yes (mode='advanced', isTTY=true)
63
+ * Interactive mode-prompt → Advanced → yes (modePromptShown=true)
64
+ * Any path with --tracker <id> → no (hasCliOverride wins)
65
+ *
66
+ * BOTH wizard paths call this predicate, so the table above is the single
67
+ * authority for both and they cannot drift. A Recommended-only wiring would be
68
+ * dead on every re-init — re-init is Advanced-only by construction.
69
+ *
70
+ * Pure predicate — no side effects, fully testable without a TTY.
71
+ */
72
+ export function shouldRunTrackerStep(input) {
73
+ if (input.hasCliOverride)
74
+ return false;
75
+ if (!input.isTTY)
76
+ return false;
77
+ // Advanced path: non-TTY has already exit-1'd, so isTTY=true here → always run.
78
+ // Covers: --advanced flag, re-init banner path, interactive-prompt → advanced.
79
+ if (input.mode === 'advanced')
80
+ return true;
81
+ // Recommended path: only run when the Setup-mode p.select actually ran
82
+ // (user made an active choice). --recommended flag and !isTTY fallback never set
83
+ // modePromptShown=true, preserving their promptless contracts.
84
+ return input.modePromptShown;
85
+ }
86
+ /**
87
+ * Build the real (clack) TrackerPromptIO adapter.
88
+ * Delegates the shared note + select to the shared adapters (prompt-io.ts).
89
+ * Translates clack's cancel symbol into the PromptOutcome discriminated union.
90
+ */
91
+ export function buildClackTrackerPrompts() {
92
+ return {
93
+ note: clackNote,
94
+ select: (opts) => clackSelect(opts),
95
+ selectProvider: (opts) => clackSelect(opts),
96
+ };
97
+ }
98
+ /**
99
+ * Run the tracker wizard step.
100
+ *
101
+ * Flow:
102
+ * 1. Note — "Current setting: …" header then the provider catalogue.
103
+ * 2. Provider select — labelled GitHub / Jira / Linear with hints, seeded from
104
+ * the prior state. `p.select`, never `p.confirm`: Enter-through must be an
105
+ * INFORMED keep of a named provider, not a y/N reflex (PF-029).
106
+ *
107
+ * Returns:
108
+ * {kind:'resolved', state, messages} — step completed; `state` is the chosen
109
+ * TrackerFeatureState; `messages` are emitted by the caller.
110
+ * {kind:'cancelled'} — user pressed Escape; caller runs p.cancel + process.exit(0).
111
+ *
112
+ * Invariants (PF-014):
113
+ * - Never calls process.exit(), never throws.
114
+ * - The returned state is always a fresh object, never the seed.
115
+ * - All I/O is routed through the `prompts` parameter (injectable for tests).
116
+ */
117
+ export async function runTrackerStep(opts) {
118
+ const { seed, prompts } = opts;
119
+ prompts.note(`Current setting: ${formatTrackerSummary(seed.provider)}\n\n` +
120
+ 'Selects which issue tracker devflow reads and writes when a workflow\n' +
121
+ 'needs an issue. GitHub is the default and needs no extra setup.\n' +
122
+ 'Jira and Linear learn your project\'s issue conventions in the\n' +
123
+ 'background at the next session start; pull requests stay on GitHub\n' +
124
+ 'either way.\n\n' +
125
+ formatProviderCatalogue(), 'Issue tracker');
126
+ const outcome = await prompts.selectProvider({
127
+ message: TRACKER_SELECT_MESSAGE,
128
+ options: providerChoices(),
129
+ initialValue: seed.provider,
130
+ });
131
+ if (outcome.kind === 'cancel')
132
+ return { kind: 'cancelled' };
133
+ const provider = outcome.value;
134
+ return {
135
+ kind: 'resolved',
136
+ // Fresh object — never alias the seed the caller still holds.
137
+ state: { provider },
138
+ messages: [
139
+ provider === DEFAULT_TRACKER_PROVIDER
140
+ ? { level: 'info', text: `Tracker: ${provider} (default) — change later with devflow tracker --set <id>` }
141
+ : { level: 'success', text: `Tracker: ${provider}` },
142
+ ],
143
+ };
144
+ }
145
+ //# sourceMappingURL=tracker-prompts.js.map