devflow-kit 3.5.0 → 3.7.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 (146) hide show
  1. package/CHANGELOG.md +178 -1
  2. package/README.md +10 -6
  3. package/dist/agents/code.md +7 -11
  4. package/dist/agents/{diagnose.md → debug.md} +4 -4
  5. package/dist/agents/{code-fix.md → fix.md} +7 -8
  6. package/dist/agents/{scrutinize.md → harden.md} +6 -6
  7. package/dist/agents/{code-edit.md → refactor.md} +8 -9
  8. package/dist/agents/review.md +2 -0
  9. package/dist/cli/agents-view/render.js +1 -1
  10. package/dist/cli/commands/gates.js +276 -0
  11. package/dist/cli/commands/init-seed.js +9 -1
  12. package/dist/cli/commands/init.js +34 -11
  13. package/dist/cli/commands/learning.js +12 -12
  14. package/dist/cli/commands/legacy-hooks.js +1 -1
  15. package/dist/cli/commands/memory.js +18 -0
  16. package/dist/cli/commands/uninstall.js +111 -5
  17. package/dist/cli.js +2 -0
  18. package/dist/commands/bug-analysis.md +21 -17
  19. package/dist/commands/code-review.md +65 -66
  20. package/dist/commands/debug.md +17 -17
  21. package/dist/commands/dynamic-build.md +104 -116
  22. package/dist/commands/dynamic-plan.md +13 -11
  23. package/dist/commands/dynamic-profile.md +9 -7
  24. package/dist/commands/dynamic-tickets.md +13 -11
  25. package/dist/commands/explore.md +15 -15
  26. package/dist/commands/implement.md +80 -154
  27. package/dist/commands/plan.md +34 -66
  28. package/dist/commands/release.md +11 -7
  29. package/dist/commands/research.md +14 -10
  30. package/dist/commands/resolve.md +44 -88
  31. package/dist/commands/self-review.md +44 -44
  32. package/dist/core/agent-models.js +47 -11
  33. package/dist/core/{learning-tuning-config.js → curate-tuning-config.js} +14 -14
  34. package/dist/core/echo-value.js +42 -0
  35. package/dist/core/evidence-policy.js +17 -5
  36. package/dist/core/gate-level.js +213 -0
  37. package/dist/core/learning-queue-cleanup.js +1 -1
  38. package/dist/core/manifest.js +26 -10
  39. package/dist/core/migrations.js +63 -44
  40. package/dist/core/model-discovery.js +7 -1
  41. package/dist/core/plugins.js +14 -12
  42. package/dist/core/project-paths.js +3 -3
  43. package/dist/core/tracker.js +5 -33
  44. package/dist/hud/codex-usage.js +247 -0
  45. package/dist/hud/components/codex-quota.js +37 -0
  46. package/dist/hud/components/usage-quota.js +1 -1
  47. package/dist/hud/config.js +1 -0
  48. package/dist/hud/index.js +16 -3
  49. package/dist/hud/render.js +3 -1
  50. package/dist/learning-off/agents/code.md +7 -11
  51. package/dist/learning-off/agents/{diagnose.md → debug.md} +4 -4
  52. package/dist/learning-off/agents/{code-fix.md → fix.md} +7 -8
  53. package/dist/learning-off/agents/{scrutinize.md → harden.md} +6 -6
  54. package/dist/learning-off/agents/{code-edit.md → refactor.md} +8 -9
  55. package/dist/learning-off/agents/review.md +2 -0
  56. package/dist/learning-off/commands/bug-analysis.md +9 -9
  57. package/dist/learning-off/commands/code-review.md +54 -59
  58. package/dist/learning-off/commands/debug.md +6 -10
  59. package/dist/learning-off/commands/dynamic-build.md +98 -112
  60. package/dist/learning-off/commands/dynamic-plan.md +5 -5
  61. package/dist/learning-off/commands/dynamic-profile.md +1 -1
  62. package/dist/learning-off/commands/dynamic-tickets.md +5 -5
  63. package/dist/learning-off/commands/explore.md +4 -8
  64. package/dist/learning-off/commands/implement.md +68 -146
  65. package/dist/learning-off/commands/plan.md +23 -58
  66. package/dist/learning-off/commands/release.md +4 -2
  67. package/dist/learning-off/commands/research.md +2 -2
  68. package/dist/learning-off/commands/resolve.md +33 -81
  69. package/dist/learning-off/commands/self-review.md +32 -36
  70. package/dist/skills/git/references/pr/check-merge-readiness.md +2 -0
  71. package/dist/skills/git/references/pr/update-pr-evidence.md +4 -6
  72. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +7 -9
  73. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +7 -15
  74. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +7 -15
  75. package/dist/targets/claude-code/installer.js +117 -84
  76. package/dist/targets/claude-code/learning-install.js +15 -3
  77. package/dist/targets/claude-code/tracker-servers.js +1 -1
  78. package/package.json +1 -1
  79. package/src/assets/agents/_partials/_code.mds +7 -8
  80. package/src/assets/agents/code.mds +4 -7
  81. package/src/assets/agents/{learning.md → curate.md} +8 -10
  82. package/src/assets/agents/{diagnose.mds → debug.mds} +4 -4
  83. package/src/assets/agents/evaluate.md +4 -4
  84. package/src/assets/agents/{code-fix.mds → fix.mds} +5 -5
  85. package/src/assets/agents/{scrutinize.mds → harden.mds} +6 -6
  86. package/src/assets/agents/{simplify.md → polish.md} +4 -4
  87. package/src/assets/agents/{code-edit.mds → refactor.mds} +6 -6
  88. package/src/assets/agents/review.mds +2 -0
  89. package/src/assets/agents/synthesize.md +2 -2
  90. package/src/assets/agents/test.md +2 -2
  91. package/src/assets/commands/_partials/_compliance.mds +1 -1
  92. package/src/assets/commands/_partials/_decisions.mds +26 -6
  93. package/src/assets/commands/_partials/_engine.mds +15 -15
  94. package/src/assets/commands/_partials/_evidence_policy.mds +0 -17
  95. package/src/assets/commands/_partials/_knowledge.mds +14 -9
  96. package/src/assets/commands/_partials/_preamble.mds +4 -2
  97. package/src/assets/commands/_partials/_roster.mds +4 -4
  98. package/src/assets/commands/_partials/_settings.mds +2 -2
  99. package/src/assets/commands/_partials/_wave.mds +10 -17
  100. package/src/assets/commands/bug-analysis.mds +7 -7
  101. package/src/assets/commands/code-review.mds +51 -56
  102. package/src/assets/commands/debug.mds +2 -2
  103. package/src/assets/commands/dynamic-build.mds +67 -74
  104. package/src/assets/commands/implement.mds +65 -127
  105. package/src/assets/commands/plan.mds +21 -61
  106. package/src/assets/commands/release.mds +5 -1
  107. package/src/assets/commands/resolve.mds +28 -76
  108. package/src/assets/commands/self-review.mds +27 -27
  109. package/src/assets/mds/git/_ops_steps.mds +3 -3
  110. package/src/assets/mds/git/_pr.mds +6 -6
  111. package/src/assets/mds/tracker/_common.mds +23 -12
  112. package/src/assets/mds/tracker/_github.mds +2 -9
  113. package/src/assets/mds/tracker/_jira.mds +2 -15
  114. package/src/assets/mds/tracker/_linear.mds +2 -15
  115. package/src/assets/scripts/ci-wait.cjs +22 -4
  116. package/src/assets/scripts/claude-md-audit.cjs +66 -51
  117. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +4 -3
  118. package/src/assets/scripts/hooks/background-memory-update +404 -95
  119. package/src/assets/scripts/hooks/capture-prompt +1 -1
  120. package/src/assets/scripts/hooks/capture-question +1 -1
  121. package/src/assets/scripts/hooks/capture-turn +1 -1
  122. package/src/assets/scripts/hooks/json-helper.cjs +13 -2
  123. package/src/assets/scripts/hooks/json-parse +18 -3
  124. package/src/assets/scripts/hooks/lib/learning-store.cjs +1 -1
  125. package/src/assets/scripts/hooks/lib/project-paths.cjs +4 -4
  126. package/src/assets/scripts/hooks/memory-permission +358 -0
  127. package/src/assets/scripts/hooks/preamble +1 -1
  128. package/src/assets/scripts/hooks/session-start-context +60 -47
  129. package/src/assets/scripts/hooks/session-start-memory +26 -5
  130. package/src/assets/scripts/lib/project-config.cjs +19 -4
  131. package/src/assets/scripts/resolve-settings.cjs +200 -32
  132. package/src/assets/scripts/verify-evidence.cjs +726 -9
  133. package/src/assets/skills/agent-references/SKILL.md +14 -6
  134. package/src/assets/skills/agent-references/references/commands/code-review-levels.md +19 -0
  135. package/src/assets/skills/agent-references/references/commands/implement-evidence-asks.md +35 -0
  136. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +15 -0
  137. package/src/assets/skills/agent-references/references/commands/plan-levels.md +40 -0
  138. package/src/assets/skills/agent-references/references/synthesize/bug-analysis.md +1 -1
  139. package/src/assets/skills/agent-references/references/synthesize/planning.md +9 -22
  140. package/src/assets/skills/agent-references/references/synthesize/review.md +11 -11
  141. package/src/assets/skills/apply-decisions/SKILL.md +6 -4
  142. package/src/assets/skills/docs-framework/SKILL.md +3 -3
  143. package/src/assets/skills/qa/SKILL.md +1 -1
  144. package/src/assets/skills/quality-gates/SKILL.md +7 -7
  145. package/src/assets/skills/quality-gates/references/report-template.md +7 -7
  146. /package/src/assets/skills/agent-references/references/{learning → curate}/maintain.md +0 -0
@@ -2,8 +2,8 @@
2
2
  // src/assets/scripts/resolve-settings.cjs
3
3
  //
4
4
  // Resolves the per-repository SETTINGS a prompt or the CLI acts on — tracker,
5
- // review publication, compliance lens and the three feature switches — from
6
- // LOCAL layers, and prints them on ONE closed-vocabulary line. Installed as a
5
+ // review publication, compliance lens, the three feature switches and the gate
6
+ // level — from LOCAL layers, and prints them on ONE closed-vocabulary line. Installed as a
7
7
  // top-level sibling of resolve-evidence-policy.cjs under ~/.devflow/scripts/,
8
8
  // sharing its parser (lib/project-config.cjs).
9
9
  //
@@ -14,15 +14,21 @@
14
14
  // default refs/remotes/origin/<D>:.devflow/project.json
15
15
  // the default branch's tracking copy —
16
16
  // its compliance ids only (D-LENS-UNION)
17
- // personal <toplevel>/.devflow/config.json untracked, narrow-only — a copy git
18
- // tracks is ignored (D-PERSONAL-UNTRACKED)
19
- // machine ~/.devflow/manifest.json the machine-wide install
17
+ // personal <toplevel>/.devflow/config.json untracked — a copy git tracks is
18
+ // ignored (D-PERSONAL-UNTRACKED). It
19
+ // only narrows the feature switches;
20
+ // its `gateLevel` may raise or lower
21
+ // machine ~/.devflow/manifest.json the machine-wide install, and its
22
+ // `features.gateLevel` default
20
23
  //
21
24
  // D-SETTINGS-LOCAL-ONLY: local git subprocesses only, and never gh, never the
22
25
  // network, never a git command that refreshes the index. At most four, in order:
23
26
  // git rev-parse --show-toplevel always
24
- // git -c core.fsmonitor=false ls-files --error-unmatch -- .devflow/config.json
25
- // only when config.json exists
27
+ // git --no-literal-pathspecs -c core.fsmonitor=false ls-files -z -- <PERSONAL_TRACKING_PATHSPECS>
28
+ // only when config.json exists;
29
+ // ONE call answers every layout
30
+ // in which git tracks the path
31
+ // the personal file is opened at
26
32
  // git symbolic-ref --quiet refs/remotes/origin/HEAD in a repository
27
33
  // git cat-file blob refs/remotes/origin/<D>:.devflow/project.json
28
34
  // only when origin/HEAD names D
@@ -37,11 +43,20 @@
37
43
  // for more than `auto`. The tracker (provider, site, key) is taken as the
38
44
  // branch states it. It WRITES NOTHING: each layer it folds is changed only by its owner.
39
45
  //
46
+ // GATE_LEVEL is a level, not a switch, so narrow-only does not apply to it: the
47
+ // personal `gateLevel` wins over the machine's `features.gateLevel`, which wins
48
+ // over `standard`, and either may raise or lower the level. A present value that
49
+ // is not full, standard or lean is malformed. That field alone reads `full`, the
50
+ // other fields read as if the key were absent, and stderr names the layer (never
51
+ // the value). A malformed personal value never falls through to the machine
52
+ // value. The level comes from these two keys only — never from a task, a diff or
53
+ // project.json.
54
+ //
40
55
  // stdout is exactly one line plus "\n", or empty (D-SETTINGS-LINE):
41
56
  // TRACKER=<github|jira|linear> TRACKER_SOURCE=<project|personal|machine|default>
42
57
  // TRACKER_WARN=<none|mismatch|invalid> SITE=<url|none> KEY=<KEY|none>
43
58
  // REVIEW_PUBLICATION=<off|auto|full> COMPLIANCE=<off|generic|id,…>
44
- // MEMORY=<on|off> LEARNING=<on|off> KNOWLEDGE=<on|off>
59
+ // MEMORY=<on|off> LEARNING=<on|off> KNOWLEDGE=<on|off> GATE_LEVEL=<full|standard|lean>
45
60
  // (one line on stdout; wrapped here for reading). Every value is a closed token
46
61
  // or a SITE/KEY that passed its shape gate, so no other byte of a config file can
47
62
  // reach stdout. Whole-file rule: a project.json or config.json that EXISTS but
@@ -80,6 +95,7 @@ const projectConfig = require('./lib/project-config.cjs');
80
95
 
81
96
  const {
82
97
  PUBLICATIONS,
98
+ GATE_LEVELS,
83
99
  TRACKER_PROVIDER_IDS,
84
100
  COMPLIANCE_IDS,
85
101
  FEATURE_SWITCHES,
@@ -96,6 +112,8 @@ const {
96
112
  /** @typedef {'project' | 'personal' | 'machine' | 'default'} TrackerSource */
97
113
  /** @typedef {'none' | 'mismatch' | 'invalid'} TrackerWarn */
98
114
  /** @typedef {'off' | 'auto' | 'full'} Publication */
115
+ /** @typedef {'full' | 'standard' | 'lean'} GateLevel */
116
+ /** @typedef {'personal' | 'machine' | 'default'} GateLevelSource */
99
117
  /** @typedef {'memory' | 'learning' | 'knowledge'} FeatureSwitch */
100
118
  /** @typedef {'machine' | 'project' | 'personal'} SwitchSource */
101
119
 
@@ -144,6 +162,9 @@ const EXIT_CODES = Object.freeze({
144
162
  * with no other lens anywhere is exactly SETTINGS_FAIL_CLOSED_LINE, and an
145
163
  * unreadable machine manifest reads as no lens. Individually malformed keys
146
164
  * inside a readable object keep their per-key readings (AC-26).
165
+ *
166
+ * GATE_LEVEL is the last field, a closed group of the three level words: nothing
167
+ * broader than `full|standard|lean` can follow KNOWLEDGE.
147
168
  */
148
169
  const SETTINGS_LINE_RE = new RegExp(
149
170
  '^TRACKER=(?<tracker>' + TRACKER_PROVIDER_IDS.join('|') + ')'
@@ -154,7 +175,8 @@ const SETTINGS_LINE_RE = new RegExp(
154
175
  + ' REVIEW_PUBLICATION=(?<publication>' + PUBLICATIONS.join('|') + ')'
155
176
  + ' COMPLIANCE=(?<compliance>off|generic|(?:' + COMPLIANCE_IDS.join('|') + ')(?:,(?:'
156
177
  + COMPLIANCE_IDS.join('|') + ')){0,' + String(COMPLIANCE_IDS.length - 1) + '})'
157
- + ' MEMORY=(?<memory>on|off) LEARNING=(?<learning>on|off) KNOWLEDGE=(?<knowledge>on|off)$',
178
+ + ' MEMORY=(?<memory>on|off) LEARNING=(?<learning>on|off) KNOWLEDGE=(?<knowledge>on|off)'
179
+ + ' GATE_LEVEL=(?<gateLevel>' + GATE_LEVELS.join('|') + ')$',
158
180
  );
159
181
 
160
182
  /**
@@ -169,14 +191,20 @@ const SETTINGS_LINE_RE = new RegExp(
169
191
  * MEMORY/LEARNING=on the machine switch is the one that turns them off;
170
192
  * hooks never consume this line (their gate folds an
171
193
  * unreadable file as narrowing nothing)
194
+ * GATE_LEVEL=full the most checking applies: a resolver that cannot read
195
+ * its inputs never lowers the level
172
196
  */
173
197
  const SETTINGS_FAIL_CLOSED_LINE =
174
198
  'TRACKER=github TRACKER_SOURCE=default TRACKER_WARN=invalid SITE=none KEY=none '
175
- + 'REVIEW_PUBLICATION=off COMPLIANCE=generic MEMORY=on LEARNING=on KNOWLEDGE=off';
199
+ + 'REVIEW_PUBLICATION=off COMPLIANCE=generic MEMORY=on LEARNING=on KNOWLEDGE=off GATE_LEVEL=full';
176
200
 
177
201
  /** Bound on each local git call. */
178
202
  const GIT_TIMEOUT_MS = 5000;
179
- /** rev-parse, ls-files and symbolic-ref print one short line. */
203
+ /**
204
+ * rev-parse and symbolic-ref print one short line. The tracking check's ls-files
205
+ * prints nothing, or the few index entries its two pathspecs name; a listing past
206
+ * this bound overflows (ENOBUFS), which reads as unanswered and fails closed.
207
+ */
180
208
  const LINE_MAX_BUFFER = 4096;
181
209
  /**
182
210
  * The tracking-copy read: one byte past the parser's bound, so an oversize blob
@@ -187,6 +215,39 @@ const BLOB_MAX_BUFFER = MAX_CONFIG_BYTES + 1;
187
215
 
188
216
  /** The personal file, repository-relative, in git's spelling. */
189
217
  const PERSONAL_REL = '.devflow/config.json';
218
+ /**
219
+ * The spellings a case-folding file system opens as `.devflow` and `config.json`
220
+ * beyond ASCII letter case. git's `icase` folds ASCII letters only, but APFS (the
221
+ * macOS default) compares names by full Unicode case folding, so it also opens
222
+ * `.devflow` (U+FB02 folds to "fl"), `config.json` (U+FB01 folds to "fi") and
223
+ * `config.jſon` (U+017F folds to "s") at the personal path. A probe of every code
224
+ * point on APFS finds no other one that folds into these names and none that it
225
+ * ignores. The code is written with escapes, so each code point is named.
226
+ */
227
+ const DEVFLOW_SPELLINGS = Object.freeze(['.devflow', '.dev\u{FB02}ow']);
228
+ const CONFIG_SPELLINGS = Object.freeze(['config.json', 'con\u{FB01}g.json', 'config.j\u{17F}on', 'con\u{FB01}g.j\u{17F}on']);
229
+ /**
230
+ * The pathspecs of the tracking check (D-PERSONAL-UNTRACKED), each matched
231
+ * without regard to ASCII letter case, whatever the file system or
232
+ * `core.ignorecase`, and each spelled every way DEVFLOW_SPELLINGS and
233
+ * CONFIG_SPELLINGS list:
234
+ * - the personal file itself, so a committed `.devflow/CONFIG.json`,
235
+ * `.DEVFLOW/config.json` or `.devflow/config.jſon` counts where the file
236
+ * system folds the name and opens it
237
+ * - an index entry named `.devflow` ITSELF, which git records only for a symlink,
238
+ * a gitlink (submodule) or a plain file, never for a directory: the character
239
+ * class keeps `glob` from matching as a leading directory, so the many files
240
+ * under a normal `.devflow/` are not listed. A tracked link or submodule
241
+ * carries committed content to the path the personal file is opened at.
242
+ * An untracked `.devflow` link is the user's own setup and is not named here.
243
+ * The index check cannot see where such a link points: one that leads to tracked
244
+ * content (the same repository, or a linked worktree's link to the main
245
+ * checkout's `.devflow`) is read through, as every read of a linked folder is.
246
+ */
247
+ const PERSONAL_TRACKING_PATHSPECS = Object.freeze([
248
+ ...DEVFLOW_SPELLINGS.flatMap(dir => CONFIG_SPELLINGS.map(file => ':(icase,literal)' + dir + '/' + file)),
249
+ ...DEVFLOW_SPELLINGS.map(dir => ':(icase,glob)[.]' + dir.slice(1)),
250
+ ]);
190
251
  /** The team file, repository-relative, in git's spelling. */
191
252
  const PROJECT_REL = '.devflow/project.json';
192
253
 
@@ -209,6 +270,10 @@ const PROJECT_REL = '.devflow/project.json';
209
270
  * reviewPublication: Publication,
210
271
  * compliance: { enabled: boolean, frameworks: readonly string[] },
211
272
  * switches: { memory: SwitchState, learning: SwitchState, knowledge: SwitchState },
273
+ * gateLevel: GateLevel,
274
+ * gateLevelSource: GateLevelSource | null,
275
+ * gateLevelInvalid: boolean,
276
+ * gateLevelMachine: GateLevel | 'invalid' | null,
212
277
  * repoCompliance: readonly string[] | null,
213
278
  * defaultBranchCompliance: readonly string[] | null,
214
279
  * personalTracked: boolean,
@@ -218,6 +283,14 @@ const PROJECT_REL = '.devflow/project.json';
218
283
  * ok false only for a fail-closed resolution — the whole-file
219
284
  * rule's included, which still carries every readable lens
220
285
  * compliance enabled with no frameworks is `generic`
286
+ * gateLevelSource the layer that decided the level. null only for a whole-line
287
+ * fail-closed result (`ok` false), which has no deciding layer
288
+ * gateLevelInvalid the deciding layer holds a malformed level, so `gateLevel` is
289
+ * `full`; the raw value is never carried
290
+ * gateLevelMachine the machine layer's own reading, whoever decided: a level,
291
+ * `invalid` for a present malformed value, or null for none
292
+ * recorded. On a result that never read the manifest (git did not
293
+ * answer, or an internal error) null means not read
221
294
  * repoCompliance the worktree project.json's ids, or null when it declares
222
295
  * none (a malformed declaration reads as [] — generic)
223
296
  * defaultBranchCompliance the default branch's tracking copy's ids, the same
@@ -271,6 +344,10 @@ const FAIL_CLOSED_SETTINGS = Object.freeze({
271
344
  learning: Object.freeze({ on: true, source: /** @type {SwitchSource} */ ('machine') }),
272
345
  knowledge: Object.freeze({ on: false, source: /** @type {SwitchSource} */ ('machine') }),
273
346
  }),
347
+ gateLevel: /** @type {GateLevel} */ ('full'),
348
+ gateLevelSource: null,
349
+ gateLevelInvalid: false,
350
+ gateLevelMachine: null,
274
351
  repoCompliance: null,
275
352
  defaultBranchCompliance: null,
276
353
  personalTracked: false,
@@ -344,6 +421,23 @@ function machineCompliance(manifest) {
344
421
  return raw.enabled ? projectConfig.normalizeComplianceIds(raw.frameworks) : null;
345
422
  }
346
423
 
424
+ /**
425
+ * src/core/gate-level.ts machineGateLevel exactly, and total: null when no level
426
+ * is recorded (not an object, `features` not an object, or no own `gateLevel`
427
+ * key), `invalid` when the key is present and is not one of the three words,
428
+ * else the level. The learning-capture hook folds a manifest of any shape, so
429
+ * this must never throw.
430
+ *
431
+ * @param {unknown} manifest
432
+ * @returns {GateLevel | 'invalid' | null}
433
+ */
434
+ function machineGateLevel(manifest) {
435
+ if (!isPlainObject(manifest) || !isPlainObject(manifest.features)) return null;
436
+ if (!Object.prototype.hasOwnProperty.call(manifest.features, 'gateLevel')) return null;
437
+ const value = manifest.features.gateLevel;
438
+ return GATE_LEVELS.includes(value) ? /** @type {GateLevel} */ (value) : 'invalid';
439
+ }
440
+
347
441
  // ---------------------------------------------------------------------------
348
442
  // The fold (the functional core)
349
443
  //
@@ -549,6 +643,42 @@ function foldSwitch(project, personal, manifest, feature) {
549
643
  return Object.freeze({ on: true, source: 'machine' });
550
644
  }
551
645
 
646
+ /**
647
+ * The gate level, from two keys and nothing else:
648
+ * 1. a valid personal `gateLevel` wins (source `personal`);
649
+ * 2. a malformed personal `gateLevel` is `full` (source `personal`, invalid) and
650
+ * never falls through to the machine value — a typo must not hand the choice
651
+ * to a layer the user did not mean;
652
+ * 3. else a valid machine `features.gateLevel` (source `machine`);
653
+ * 4. else a malformed machine value is `full` (source `machine`, invalid);
654
+ * 5. else `standard` (source `default`).
655
+ * Either layer may raise or lower the level: it is not a switch, and narrow-only
656
+ * does not apply to it. An unreadable personal file reads as malformed, so a
657
+ * caller that folds it directly never sees the machine value win over it. The
658
+ * machine layer's own reading is carried whichever layer decided.
659
+ *
660
+ * @param {any} personal
661
+ * @param {unknown} manifest
662
+ * @returns {Pick<Settings, 'gateLevel' | 'gateLevelSource' | 'gateLevelInvalid' | 'gateLevelMachine'>}
663
+ */
664
+ function foldGateLevel(personal, manifest) {
665
+ const machine = machineGateLevel(manifest);
666
+ const own = fieldIn(personal, 'gateLevel', 'malformed');
667
+ if (own.kind === 'valid') {
668
+ return { gateLevel: own.value, gateLevelSource: 'personal', gateLevelInvalid: false, gateLevelMachine: machine };
669
+ }
670
+ if (own.kind === 'malformed') {
671
+ return { gateLevel: 'full', gateLevelSource: 'personal', gateLevelInvalid: true, gateLevelMachine: machine };
672
+ }
673
+ if (machine === 'invalid') {
674
+ return { gateLevel: 'full', gateLevelSource: 'machine', gateLevelInvalid: true, gateLevelMachine: machine };
675
+ }
676
+ if (machine !== null) {
677
+ return { gateLevel: machine, gateLevelSource: 'machine', gateLevelInvalid: false, gateLevelMachine: machine };
678
+ }
679
+ return { gateLevel: 'standard', gateLevelSource: 'default', gateLevelInvalid: false, gateLevelMachine: null };
680
+ }
681
+
552
682
  /**
553
683
  * The whole-file rule's resolution (D-SETTINGS-LINE): the fail-closed values,
554
684
  * naming the layer whose file could not be read, except the compliance lens —
@@ -567,6 +697,8 @@ function unreadableSettings(layer, inputs) {
567
697
  const lens = foldCompliance(inputs.project, inputs.manifest, defaultBranch);
568
698
  return Object.freeze({
569
699
  ...FAIL_CLOSED_SETTINGS,
700
+ // The level stays `full` with no deciding layer, but a readable manifest still shows its own value.
701
+ gateLevelMachine: machineGateLevel(inputs.manifest),
570
702
  compliance: lens.compliance,
571
703
  repoCompliance: layer === 'project' ? null : lens.repoCompliance,
572
704
  defaultBranchCompliance: lens.defaultBranchCompliance,
@@ -595,6 +727,7 @@ function foldSettings(inputs) {
595
727
  learning: foldSwitch(project, personal, manifest, 'learning'),
596
728
  knowledge: foldSwitch(project, personal, manifest, 'knowledge'),
597
729
  }),
730
+ ...foldGateLevel(personal, manifest),
598
731
  repoCompliance: lens.repoCompliance,
599
732
  defaultBranchCompliance: lens.defaultBranchCompliance,
600
733
  personalTracked: inputs.personalTracked === true,
@@ -667,26 +800,47 @@ function gitToplevel(exec, dir) {
667
800
  * not personal: a contributor's branch that commits one (`git add -f`) would
668
801
  * otherwise lift a maintainer's local run of that branch past the publication
669
802
  * ceiling. A tracked copy is ignored entirely, exactly as if absent, and main()
670
- * tells the user to untrack it. The check is `git ls-files --error-unmatch`,
671
- * which reads the index without refreshing or writing it. Reading the index
672
- * runs a configured `core.fsmonitor` hook — an arbitrary command from the
673
- * repository's config, and on macOS the builtin daemon's start-up — so the
674
- * call turns it off for itself (`-c core.fsmonitor=false`, D-NO-FSMONITOR):
675
- * this resolver runs from session hooks and must stay a pure read.
676
- * tracked exit 0
677
- * untracked any other answered exit (1: no such index entry; outside a
678
- * repository git answers 128, and there is nothing to track)
679
- * unknown git did not answer — whether the file is personal cannot be
680
- * known, so it is unreadable and its keys fail closed
803
+ * tells the user to untrack it.
804
+ *
805
+ * The question is whether COMMITTED content can reach the path the personal file
806
+ * is opened at, `<root>/.devflow/config.json`. The OS follows a symlinked
807
+ * `.devflow`, resolves a submodule's checkout there, and on a case-folding file
808
+ * system opens `CONFIG.json`, `.DEVFLOW/config.json` or `config.jſon` for it, so
809
+ * the answer is not only "is this exact path in the index". One `git ls-files -z`
810
+ * over PERSONAL_TRACKING_PATHSPECS asks all of it at once — no further git call:
811
+ * any output is a tracked entry that reaches the path (the file in any spelling
812
+ * the file system folds to it, or `.devflow` itself as a symlink, gitlink or
813
+ * file). It reads the index without
814
+ * refreshing or writing it. Reading the index runs a configured `core.fsmonitor`
815
+ * hook — an arbitrary command from the repository's config, and on macOS the
816
+ * builtin daemon's start-up — so the call turns it off for itself
817
+ * (`-c core.fsmonitor=false`, D-NO-FSMONITOR): this resolver runs from session
818
+ * hooks and must stay a pure read.
819
+ * tracked at least one entry listed, whatever the exit status
820
+ * untracked exit 0 with nothing listed; and, unless `rootConfirmed`, any other
821
+ * answered exit (outside a repository git answers 128, and there is
822
+ * nothing to track)
823
+ * unknown git did not answer, or its listing overflowed LINE_MAX_BUFFER; and,
824
+ * with `rootConfirmed`, an answered non-zero exit with nothing listed —
825
+ * inside a repository the call has no reason to fail except a git that
826
+ * cannot read its index. Whether the file is personal cannot be known,
827
+ * so it is unreadable and its keys fail closed
828
+ * `--no-literal-pathspecs` leads the argv: an inherited GIT_LITERAL_PATHSPECS=1
829
+ * would read every pathspec as a plain path, magic included, list nothing and
830
+ * read as untracked.
681
831
  *
682
832
  * @param {ExecFn} exec
683
833
  * @param {string} root
834
+ * @param {boolean} rootConfirmed git has already named `root` as a repository's top level
684
835
  * @returns {'tracked' | 'untracked' | 'unknown'}
685
836
  */
686
- function personalTracking(exec, root) {
687
- const res = runGit(exec, root, ['-c', 'core.fsmonitor=false', 'ls-files', '--error-unmatch', '--', PERSONAL_REL], LINE_MAX_BUFFER);
837
+ function personalTracking(exec, root, rootConfirmed) {
838
+ const res = runGit(exec, root,
839
+ ['--no-literal-pathspecs', '-c', 'core.fsmonitor=false', 'ls-files', '-z', '--', ...PERSONAL_TRACKING_PATHSPECS], LINE_MAX_BUFFER);
688
840
  if (!res.answered) return 'unknown';
689
- return res.ok ? 'tracked' : 'untracked';
841
+ if (res.stdout.length > 0) return 'tracked';
842
+ if (res.ok) return 'untracked';
843
+ return rootConfirmed ? 'unknown' : 'untracked';
690
844
  }
691
845
 
692
846
  /**
@@ -727,10 +881,13 @@ function existsNoFollow(filePath) {
727
881
  * cannot disagree about a symlink, a size, a BOM, a duplicated key — or a
728
882
  * config.json git tracks (D-PERSONAL-UNTRACKED), which is absent to both. Its only
729
883
  * subprocess is that tracking check, made only when config.json exists; never
730
- * throws on a missing or unreadable file.
884
+ * throws on a missing or unreadable file. `deps.rootConfirmed` is for a caller
885
+ * that has already had git name `root`: the tracking check then reads a git that
886
+ * answers non-zero as unknown. The hooks do not pass it, and a root outside any
887
+ * repository reads as untracked.
731
888
  *
732
889
  * @param {string} root
733
- * @param {{ exec?: ExecFn }} [deps]
890
+ * @param {{ exec?: ExecFn, rootConfirmed?: boolean }} [deps]
734
891
  * @returns {RepoLayers}
735
892
  */
736
893
  function readRepoLayers(root, deps) {
@@ -738,7 +895,7 @@ function readRepoLayers(root, deps) {
738
895
  const devflow = path.join(root, '.devflow');
739
896
  const project = readConfigFile(path.join(devflow, 'project.json'), projectConfig.parseProjectBytes);
740
897
  const personalPath = path.join(devflow, 'config.json');
741
- const tracking = existsNoFollow(personalPath) ? personalTracking(exec, root) : 'untracked';
898
+ const tracking = existsNoFollow(personalPath) ? personalTracking(exec, root, Boolean(deps && deps.rootConfirmed)) : 'untracked';
742
899
  const personal = tracking === 'tracked' ? projectConfig.parsePersonalBytes(null)
743
900
  : tracking === 'unknown' ? Object.freeze({ kind: 'invalid' })
744
901
  : readConfigFile(personalPath, projectConfig.parsePersonalBytes);
@@ -796,7 +953,7 @@ function resolveSettings(opts, deps) {
796
953
  const none = projectConfig.parseProjectBytes(null);
797
954
  return foldSettings({ project: none, personal: none, manifest, retiredPolicyFile: false });
798
955
  }
799
- const layers = readRepoLayers(toplevel.root, { exec });
956
+ const layers = readRepoLayers(toplevel.root, { exec, rootConfirmed: true });
800
957
  /** @type {SettingsInputs} */
801
958
  const inputs = {
802
959
  project: layers.project,
@@ -847,7 +1004,8 @@ function formatSettingsLine(s) {
847
1004
  + ' COMPLIANCE=' + complianceToken(s.compliance)
848
1005
  + ' MEMORY=' + onOff(s.switches.memory)
849
1006
  + ' LEARNING=' + onOff(s.switches.learning)
850
- + ' KNOWLEDGE=' + onOff(s.switches.knowledge);
1007
+ + ' KNOWLEDGE=' + onOff(s.switches.knowledge)
1008
+ + ' GATE_LEVEL=' + s.gateLevel;
851
1009
  }
852
1010
 
853
1011
  /**
@@ -990,8 +1148,16 @@ function main(argv, deps) {
990
1148
  }
991
1149
  const settings = resolveSettings({ dir: args.dir }, { exec: d.exec });
992
1150
  if (settings.personalTracked) {
993
- process.stderr.write('resolve-settings: warning: ' + PERSONAL_REL + ' is tracked by git, so it is ignored'
994
- + ' — it holds personal settings; untrack it with: git rm --cached ' + PERSONAL_REL + '\n');
1151
+ process.stderr.write('resolve-settings: warning: Git tracks an entry at or under ' + PERSONAL_REL
1152
+ + ', so devflow ignores that file. It holds personal settings. Find the entry in the output of: git ls-files.'
1153
+ + ' Untrack it with: git rm --cached <entry>. Then check the file contents, or delete the file.'
1154
+ + ' Untracking leaves the committed bytes on disk as your personal file.\n');
1155
+ }
1156
+ if (settings.gateLevelInvalid) {
1157
+ // The layer and the field state only: a raw config value never reaches a terminal.
1158
+ process.stderr.write('resolve-settings: warning: gateLevel in '
1159
+ + (settings.gateLevelSource === 'personal' ? PERSONAL_REL : '~/.devflow/manifest.json')
1160
+ + ' is not one of ' + GATE_LEVELS.join(', ') + ' — GATE_LEVEL=full applies\n');
995
1161
  }
996
1162
  if (settings.unreadable !== null) {
997
1163
  process.stderr.write('resolve-settings: ' + (settings.unreadable === 'project' ? PROJECT_REL : PERSONAL_REL)
@@ -1044,6 +1210,8 @@ module.exports = Object.freeze({
1044
1210
  SETTINGS_LINE_RE,
1045
1211
  SETTINGS_FAIL_CLOSED_LINE,
1046
1212
  parseArgs,
1213
+ machineGateLevel,
1214
+ foldGateLevel,
1047
1215
  foldSettings,
1048
1216
  readRepoLayers,
1049
1217
  resolveSettings,