peaks-loop 4.1.0 → 4.1.1

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 (70) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/_register.js +2 -2
  5. package/dist/cli/commands/code-mode-gate-should-pause-command.js +1 -1
  6. package/dist/cli/commands/comments-commands.d.ts +14 -0
  7. package/dist/cli/commands/comments-commands.js +96 -0
  8. package/dist/cli/commands/core/memory-command.js +10 -0
  9. package/dist/cli/commands/core/skill-command.js +1 -1
  10. package/dist/cli/commands/core/standards-command.js +1 -1
  11. package/dist/cli/commands/ecc-commands.d.ts +17 -22
  12. package/dist/cli/commands/ecc-commands.js +38 -26
  13. package/dist/cli/commands/prd-commands.js +8 -2
  14. package/dist/cli/program.js +4 -9
  15. package/dist/services/code/mode-gate-types.d.ts +1 -1
  16. package/dist/services/code/mode-gate-types.js +0 -1
  17. package/dist/services/code/mode-gate.js +1 -9
  18. package/dist/services/code/user-touchpoint-classifier.js +0 -7
  19. package/dist/services/code-review/ecc-bridge.d.ts +6 -6
  20. package/dist/services/comments/citation-rules.d.ts +154 -0
  21. package/dist/services/comments/citation-rules.js +241 -0
  22. package/dist/services/comments/comment-audit.d.ts +57 -0
  23. package/dist/services/comments/comment-audit.js +100 -0
  24. package/dist/services/comments/comment-citations.d.ts +60 -0
  25. package/dist/services/comments/comment-citations.js +186 -0
  26. package/dist/services/comments/comment-hygiene.d.ts +79 -0
  27. package/dist/services/comments/comment-hygiene.js +133 -0
  28. package/dist/services/comments/comment-prune.d.ts +88 -0
  29. package/dist/services/comments/comment-prune.js +148 -0
  30. package/dist/services/comments/prune-apply.d.ts +60 -0
  31. package/dist/services/comments/prune-apply.js +150 -0
  32. package/dist/services/comments/repo-path-probe.d.ts +43 -0
  33. package/dist/services/comments/repo-path-probe.js +78 -0
  34. package/dist/services/log/retention.d.ts +0 -16
  35. package/dist/services/log/retention.js +0 -17
  36. package/dist/services/memory/project-memory-service/index.d.ts +1 -1
  37. package/dist/services/memory/project-memory-service/index.js +1 -1
  38. package/dist/services/memory/project-memory-service/store/atomic-write.d.ts +19 -7
  39. package/dist/services/memory/project-memory-service/store/atomic-write.js +120 -26
  40. package/dist/services/prd/handoff-frontmatter.js +61 -0
  41. package/dist/services/prd/handoff-gate-evidence.js +14 -10
  42. package/dist/services/prd/handoff-service.d.ts +11 -1
  43. package/dist/services/prd/handoff-service.js +11 -1
  44. package/dist/services/prd/handoff-types.d.ts +33 -1
  45. package/dist/services/recommendations/installed-capability-detector.d.ts +5 -5
  46. package/dist/services/recommendations/installed-capability-detector.js +11 -10
  47. package/dist/services/scan/archetype-detection.d.ts +37 -0
  48. package/dist/services/scan/archetype-detection.js +175 -2
  49. package/dist/services/scan/archetype-service.js +36 -22
  50. package/dist/services/scan/scan-types.d.ts +9 -0
  51. package/dist/services/workspace/generated-artifacts-stamp.d.ts +2 -2
  52. package/dist/services/workspace/generated-artifacts-stamp.js +2 -2
  53. package/dist/services/workspace/workspace-service.js +1 -1
  54. package/package.json +5 -5
  55. package/scripts/install-skills.mjs +0 -177
  56. package/skills/bee/peaks-qa/references/reading-handoff-frontmatter.md +3 -1
  57. package/skills/bee/peaks-rd/references/parallel-review-fanout.md +1 -1
  58. package/skills/bee/peaks-rd/references/rd-fanout-contracts.md +3 -2
  59. package/skills/bee/peaks-rd/references/writing-handoff-frontmatter.md +29 -7
  60. package/skills/peaks-code/references/frontend-only-mode.md +2 -2
  61. package/skills/peaks-code/references/startup-sequence.md +0 -4
  62. package/dist/cli/commands/upgrade-commands.d.ts +0 -25
  63. package/dist/cli/commands/upgrade-commands.js +0 -154
  64. package/dist/services/upgrade/1x-detector-service.d.ts +0 -7
  65. package/dist/services/upgrade/1x-detector-service.js +0 -96
  66. package/dist/services/upgrade/gitignore-migrate-service.d.ts +0 -56
  67. package/dist/services/upgrade/gitignore-migrate-service.js +0 -170
  68. package/dist/services/upgrade/upgrade-service.d.ts +0 -81
  69. package/dist/services/upgrade/upgrade-service.js +0 -428
  70. package/skills/peaks-code/references/step-0-55-1x-detection.md +0 -83
@@ -48,6 +48,20 @@ function blockSequence(key, values) {
48
48
  return [`${key}: []`];
49
49
  return [`${key}:`, ...values.map((value) => ` - ${yamlScalar(value)}`)];
50
50
  }
51
+ /**
52
+ * The OPTIONAL form: no line at all when the field is absent or empty.
53
+ *
54
+ * `goals` and its siblings cannot use this — `isHandoffFrontmatter` requires an
55
+ * ARRAY there, so an omitted line would fail the shape check on read, and `[]` is
56
+ * the honest rendering of "declared, none". The authored fields have no such
57
+ * requirement: a capsule that names no files declares nothing, and a `files: []`
58
+ * line would read as a claim that the slice touches no file.
59
+ */
60
+ function blockOptionalSequence(key, values) {
61
+ if (values === undefined || values.length === 0)
62
+ return [];
63
+ return [`${key}:`, ...values.map((value) => ` - ${yamlScalar(value)}`)];
64
+ }
51
65
  /**
52
66
  * Render `gateEvidence` as a YAML map of quoted path scalars, or as NOTHING.
53
67
  *
@@ -103,6 +117,44 @@ function gateEvidenceBlock(evidence) {
103
117
  });
104
118
  return entries.length === 0 ? [] : ['gateEvidence:', ...entries];
105
119
  }
120
+ /**
121
+ * Render a sequence of records (`decisions`, `risks`) as a YAML block list, or
122
+ * as NOTHING when the list is absent or empty — the same omission policy as
123
+ * `gateEvidence`, so a capsule that declares no risks gains no noise line.
124
+ *
125
+ * The field names are rendered in the order given, and every value is validated
126
+ * as a string HERE rather than dropped later. That is deliberate: the bug these
127
+ * fields fix was a serializer that rendered from the type and so discarded
128
+ * anything the type did not name, silently, on every rewrite. A record missing a
129
+ * required field therefore has to stop the write, not vanish from it.
130
+ */
131
+ function blockRecords(key, items, requiredFields) {
132
+ if (items === undefined || items.length === 0)
133
+ return [];
134
+ const lines = [`${key}:`];
135
+ items.forEach((rawItem, index) => {
136
+ // The values are checked at RUNTIME, not trusted from the type, because a
137
+ // capsule reaches here from `readHandoff` as well as from a producer: the
138
+ // spread that makes an authored field survive a read also makes it survive
139
+ // unvalidated. A record missing a required field stops the write rather than
140
+ // vanishing from it — which is precisely how the old serializer made a
141
+ // declared risk look satisfied.
142
+ const record = rawItem !== null && typeof rawItem === 'object' ? { ...rawItem } : {};
143
+ const missing = requiredFields.filter((field) => typeof record[field] !== 'string' || record[field] === '');
144
+ if (missing.length > 0) {
145
+ throw new Error(`handoff: ${key}[${index}] is missing ${missing.join(', ')} — refusing to write a ` +
146
+ `record the next role cannot read (a risk without a mitigation is a red line, and ` +
147
+ 'dropping the field is how it looked satisfied)');
148
+ }
149
+ requiredFields.forEach((field, fieldIndex) => {
150
+ const value = record[field];
151
+ lines.push(fieldIndex === 0
152
+ ? ` - ${field}: ${yamlScalar(value)}`
153
+ : ` ${field}: ${yamlScalar(value)}`);
154
+ });
155
+ });
156
+ return lines;
157
+ }
106
158
  /**
107
159
  * Serialize `frontmatter` into the fenced block, terminated by the closing
108
160
  * `---` and a trailing newline. Callers append the body verbatim, which keeps
@@ -125,6 +177,15 @@ export function serializeHandoffFrontmatter(frontmatter) {
125
177
  ...blockSequence('acceptanceCriteria', frontmatter.acceptanceCriteria),
126
178
  ...blockSequence('preservedBehavior', frontmatter.preservedBehavior),
127
179
  `handoffPath: ${yamlScalar(frontmatter.handoffPath)}`,
180
+ // The authored fields: written by peaks-prd / peaks-rd, read by peaks-qa's
181
+ // mechanical cross-checks, and PRESERVED by every rewrite. They sit after
182
+ // `handoffPath` because `schemaVersion` and `sha256` must stay first-parsed,
183
+ // and before `gateEvidence` because that block is last by contract.
184
+ ...blockOptionalSequence('scope', frontmatter.scope),
185
+ ...blockOptionalSequence('files', frontmatter.files),
186
+ ...blockRecords('decisions', frontmatter.decisions, ['id', 'summary', 'rationale']),
187
+ ...blockRecords('risks', frontmatter.risks, ['id', 'description', 'mitigation']),
188
+ ...blockOptionalSequence('nextActions', frontmatter.nextActions),
128
189
  // LAST, after every anchored field: `schemaVersion` / `sha256` are
129
190
  // matched as `^`-anchored lines by the gate and both audit loaders, so
130
191
  // nothing new may be inserted before them. A nested map also renders
@@ -8,16 +8,8 @@
8
8
  // `skills/bee/peaks-rd/references/writing-handoff-frontmatter.md:35-41`; the
9
9
  // key set is `GATE_EVIDENCE_KEYS` in `./handoff-types.js`.
10
10
  //
11
- // WHAT THIS FILE USED TO SAY, AND WHY THAT WAS FALSE: its header claimed the
12
- // field was a `string[]` of "gate names" and that `initHandoff` wrote it.
13
- // Neither held. `grep -rn gateEvidence src/` hit this file and nothing else —
14
- // there was no producer (`HandoffFrontmatter` had no such field,
15
- // `serializeHandoffFrontmatter` never emitted it, `initHandoff` rejected it)
16
- // and no consumer. The field was prose describing data that did not exist,
17
- // and the comment asserting a producer was the reason nobody noticed.
18
- //
19
11
  // WHAT IS ON DISK NOW, stated precisely because a header that overclaims is
20
- // the defect above. B1 added the producer FUNCTIONS; B2 wired their callers,
12
+ // the defect below. B1 added the producer FUNCTIONS; B2 wired their callers,
21
13
  // which is what made the difference — until then every producer passed
22
14
  // nothing, so no capsule carried the field and saying otherwise would have
23
15
  // described a fact that held only inside tests (F1 of `rid-b1-qa`). At
@@ -28,7 +20,19 @@
28
20
  // artifact is readable — and carries no `gateEvidence` block at all when it
29
21
  // is not. Gate C (`checkPrerequisites` at `rd:qa-handoff`) fails a declared
30
22
  // path that is not on disk. Every clause above is asserted end-to-end in
31
- // `tests/unit/prd/gate-evidence-derivation.test.ts`.
23
+ // `tests/unit/prd/gate-evidence-derivation.test.ts`. The derived map is also
24
+ // printed by `peaks prd handoff init` (dry-run and applied), so the key set
25
+ // does not have to be read out of this source file.
26
+ //
27
+ // WHAT THIS FILE USED TO SAY, AND WHY THAT WAS FALSE: its header claimed the
28
+ // field was a `string[]` of "gate names" and that `initHandoff` wrote it.
29
+ // Neither held. `grep -rn gateEvidence src/` hit this file and nothing else —
30
+ // there was no producer (`HandoffFrontmatter` had no such field,
31
+ // `serializeHandoffFrontmatter` never emitted it, `initHandoff` rejected it)
32
+ // and no consumer. The field was prose describing data that did not exist,
33
+ // and the comment asserting a producer was the reason nobody noticed. That
34
+ // paragraph describes the PRE-B1 state; reading it as the current state is
35
+ // the exact misreading that put it here.
32
36
  //
33
37
  // WHY IT IS STILL STAND-ALONE: keeping the typed-value logic here, rather
34
38
  // than in `handoff-service.ts`, keeps `HandoffFrontmatter` a pure data shape
@@ -22,7 +22,7 @@
22
22
  * the body content as UTF-8 bytes. The body MUST be the literal
23
23
  * markdown source — no normalization, no trailing-newline padding.
24
24
  */
25
- import type { GateEvidence, Handoff, HandoffProbe } from './handoff-types.js';
25
+ import type { GateEvidence, Handoff, HandoffDecision, HandoffProbe, HandoffRisk } from './handoff-types.js';
26
26
  export { handoffRelativePath, resolveHandoffPath } from './handoff-path-resolution.js';
27
27
  /** Compute the lowercase hex sha256 of a UTF-8 string. */
28
28
  export declare function sha256OfBody(body: string): string;
@@ -58,6 +58,16 @@ export declare function initHandoff(opts: {
58
58
  * is refused here by a throw and there by a status; neither silently
59
59
  * produces a map the other cannot. */
60
60
  gateEvidence?: GateEvidence;
61
+ /** The authored fields peaks-qa cross-checks (`decisions[] ↔ tests`,
62
+ * `risks[] ↔ security tests`, `files[] ↔ git diff`). They are OPTIONAL and
63
+ * pass through verbatim: unlike `gateEvidence`, nothing derives them — they
64
+ * are a writer's judgement about the slice, so a producer that has not made
65
+ * that judgement declares nothing rather than an empty claim. */
66
+ scope?: readonly string[];
67
+ files?: readonly string[];
68
+ decisions?: readonly HandoffDecision[];
69
+ risks?: readonly HandoffRisk[];
70
+ nextActions?: readonly string[];
61
71
  }): Handoff;
62
72
  /** Write a Handoff to disk. `projectRoot` is the absolute project
63
73
  * root (so the `.peaks/_runtime/...` path is resolved absolutely).
@@ -63,7 +63,17 @@ export function initHandoff(opts) {
63
63
  // would also make `serializeHandoffFrontmatter`'s
64
64
  // `frontmatter.gateEvidence` key present-but-undefined for every caller
65
65
  // that declares nothing.
66
- ...(opts.gateEvidence === undefined ? {} : { gateEvidence: opts.gateEvidence })
66
+ ...(opts.gateEvidence === undefined ? {} : { gateEvidence: opts.gateEvidence }),
67
+ // The authored fields, each spread only when present. The same
68
+ // `exactOptionalPropertyTypes` reason as `gateEvidence` above: an absent
69
+ // field is absent, not present-and-undefined, so a capsule that declares
70
+ // nothing writes nothing — and a copy is taken so a caller mutating its own
71
+ // array afterwards cannot change a handoff that has already been hashed.
72
+ ...(opts.scope === undefined ? {} : { scope: [...opts.scope] }),
73
+ ...(opts.files === undefined ? {} : { files: [...opts.files] }),
74
+ ...(opts.decisions === undefined ? {} : { decisions: [...opts.decisions] }),
75
+ ...(opts.risks === undefined ? {} : { risks: [...opts.risks] }),
76
+ ...(opts.nextActions === undefined ? {} : { nextActions: [...opts.nextActions] })
67
77
  };
68
78
  return { frontmatter, body: opts.body };
69
79
  }
@@ -63,7 +63,39 @@ export type GateEvidence = Readonly<Partial<Record<GateEvidenceKey, string>>>;
63
63
  * immutability: once written, peaks-rd / peaks-qa / sub-agents MUST
64
64
  * NOT mutate the on-disk copy.
65
65
  */
66
- export interface HandoffFrontmatter {
66
+ /** One entry of `decisions[]` — a choice an implementer could question. */
67
+ export type HandoffDecision = {
68
+ readonly id: string;
69
+ readonly summary: string;
70
+ readonly rationale: string;
71
+ };
72
+ /** One entry of `risks[]`. `mitigation` is required by the field rule in
73
+ * `skills/bee/peaks-rd/references/writing-handoff-frontmatter.md:52`: a risk
74
+ * without one is a red line, so the type has no optional form of it. */
75
+ export type HandoffRisk = {
76
+ readonly id: string;
77
+ readonly description: string;
78
+ readonly mitigation: string;
79
+ };
80
+ /**
81
+ * The fields a WRITER authors and a READER cross-checks, absent when the capsule
82
+ * declares nothing. They were documented as required frontmatter long before the
83
+ * type carried them — `readHandoff` spread the parsed YAML, so an authored capsule
84
+ * kept them at runtime while the serializer, which renders from the TYPE, dropped
85
+ * them on every rewrite. That is the data loss `handoff-auto-regen` used to cause
86
+ * and these fields exist to end.
87
+ */
88
+ export type HandoffAuthoredFields = {
89
+ /** Repo-relative paths this slice touches, sorted. */
90
+ readonly scope?: readonly string[];
91
+ /** Files the slice expects in its diff; peaks-qa checks this against git. */
92
+ readonly files?: readonly string[];
93
+ readonly decisions?: readonly HandoffDecision[];
94
+ readonly risks?: readonly HandoffRisk[];
95
+ /** Verb-first; what the next role does, in order. */
96
+ readonly nextActions?: readonly string[];
97
+ };
98
+ export interface HandoffFrontmatter extends HandoffAuthoredFields {
67
99
  readonly requestId: string;
68
100
  readonly sessionId: string;
69
101
  readonly schemaVersion: HandoffSchemaVersion;
@@ -9,9 +9,9 @@
9
9
  * the question from evidence that already exists on disk — no install
10
10
  * side effect, no network, no subprocess:
11
11
  *
12
- * - `everything-claude-code.*` — satisfied when the ECC cache is
13
- * populated (`~/.peaks/cache/ecc-installed.json` + at least one
14
- * cached/materialized agent).
12
+ * - `everything-claude-code.*` — satisfied when the ECC agents are readable,
13
+ * i.e. `~/.peaks/agents/ecc/` holds at least one agent (landed by
14
+ * `peaks ecc install` from the `ecc-universal` dependency).
15
15
  * - a source that maps to an npm package — satisfied when that package
16
16
  * (or its `.bin` shim) is present in the project's `node_modules`.
17
17
  *
@@ -23,8 +23,8 @@ export type InstalledCapabilityProbe = {
23
23
  readonly projectRoot: string;
24
24
  /** Override for tests; defaults to `<projectRoot>/node_modules`. */
25
25
  readonly nodeModulesDir?: string;
26
- /** Override for tests; defaults to a live ECC cache probe. */
27
- readonly eccCacheAvailable?: boolean;
26
+ /** Override for tests; defaults to a live probe of the materialized copy. */
27
+ readonly eccAgentsAvailable?: boolean;
28
28
  };
29
29
  /**
30
30
  * Return the capabilityIds whose acquisition is already satisfied.
@@ -9,9 +9,9 @@
9
9
  * the question from evidence that already exists on disk — no install
10
10
  * side effect, no network, no subprocess:
11
11
  *
12
- * - `everything-claude-code.*` — satisfied when the ECC cache is
13
- * populated (`~/.peaks/cache/ecc-installed.json` + at least one
14
- * cached/materialized agent).
12
+ * - `everything-claude-code.*` — satisfied when the ECC agents are readable,
13
+ * i.e. `~/.peaks/agents/ecc/` holds at least one agent (landed by
14
+ * `peaks ecc install` from the `ecc-universal` dependency).
15
15
  * - a source that maps to an npm package — satisfied when that package
16
16
  * (or its `.bin` shim) is present in the project's `node_modules`.
17
17
  *
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import { existsSync } from 'node:fs';
22
22
  import { join } from 'node:path';
23
- import { hasMaterializedEccAgents, listCachedAgents } from 'peaks-loop-mut';
23
+ import { hasMaterializedEccAgents } from 'peaks-loop-mut';
24
24
  import { seedCapabilityItems } from './capability-seed-items.js';
25
25
  const ECC_SOURCE_ID = 'everything-claude-code';
26
26
  /**
@@ -42,12 +42,13 @@ function packagePresent(nodeModulesDir, packageName) {
42
42
  return false;
43
43
  }
44
44
  }
45
- function eccCacheAvailable() {
45
+ function eccAgentsAvailable() {
46
46
  try {
47
- // Cached agents imply the manifest + sha dir exist; the materialized
48
- // plugin-free copy is checked independently so a hand-populated
49
- // `~/.peaks/agents/ecc/` still counts as satisfied.
50
- return listCachedAgents().length > 0 || hasMaterializedEccAgents();
47
+ // The materialized copy is what the LLM can dispatch, so it is the only
48
+ // evidence that counts. The cache probe this replaced
49
+ // (`listCachedAgents()`, reading `~/.peaks/cache/ecc-<sha>/`) described
50
+ // something downloaded but not necessarily usable.
51
+ return hasMaterializedEccAgents();
51
52
  }
52
53
  catch {
53
54
  return false;
@@ -59,7 +60,7 @@ function eccCacheAvailable() {
59
60
  */
60
61
  export function detectInstalledCapabilityIds(probe) {
61
62
  const nodeModulesDir = probe.nodeModulesDir ?? join(probe.projectRoot, 'node_modules');
62
- const eccAvailable = probe.eccCacheAvailable ?? eccCacheAvailable();
63
+ const eccAvailable = probe.eccAgentsAvailable ?? eccAgentsAvailable();
63
64
  const installed = new Set();
64
65
  for (const item of seedCapabilityItems) {
65
66
  if (item.sourceId === ECC_SOURCE_ID) {
@@ -30,6 +30,43 @@ export declare function readPackageJsonDeps(projectRoot: string): Promise<{
30
30
  deps: Record<string, string>;
31
31
  }>;
32
32
  export declare function detectBackendFrameworks(deps: Record<string, string>): Promise<string[]>;
33
+ /**
34
+ * Manifests that only a service writes. Language-diverse on purpose: every
35
+ * other probe in this file is Node-specific, and a repository whose backend is
36
+ * Go / Java / Kotlin / Python / Ruby / PHP / Rust has no Node manifest at all,
37
+ * so a Node-only evidence set reported it as `frontendOnly`.
38
+ */
39
+ export declare const SERVICE_ONLY_MANIFEST_FILES: string[];
40
+ /**
41
+ * Manifests a service MAY write and a library or CLI also writes. Counted as
42
+ * backend evidence only when the file itself names a web framework — the
43
+ * alternative is a boolean that fires on any Python or Rust directory, which
44
+ * trades one wrong answer for another.
45
+ */
46
+ export declare const CONDITIONAL_SERVICE_MANIFESTS: ReadonlyArray<readonly [string, RegExp]>;
47
+ /**
48
+ * How deep from the project root a service is allowed to sit before its
49
+ * manifest stops being counted. Two levels covers the layouts that were
50
+ * invisible (`apps/gateway/package.json`, `services/checkout/requirements.txt`,
51
+ * `web/` + `cmd/server`), and a deeper walk would read generated trees.
52
+ */
53
+ export declare const SERVICE_MANIFEST_MAX_DEPTH = 2;
54
+ /**
55
+ * Backend evidence that lives OUTSIDE the root manifest: a workspace package's
56
+ * own `package.json`, and any non-Node service manifest.
57
+ *
58
+ * Returns `dir: evidence` strings (`apps/gateway: express`, `go.mod`,
59
+ * `services/checkout: requirements.txt`) rather than a boolean, because the
60
+ * whole defect this probe closes was a verdict that could not say what it saw.
61
+ */
62
+ export declare function detectNestedServiceEvidence(projectRoot: string, backendDepNames?: readonly string[]): Promise<string[]>;
63
+ /**
64
+ * Next.js server actions: `'use server'` in a file under `app/`. Route handlers
65
+ * under `pages/api` / `app/api` were the only Next backend the detector knew
66
+ * about, so a Next app that mutates data through actions — the documented
67
+ * Next.js way — was reported as having no backend at all.
68
+ */
69
+ export declare function detectNextServerActions(projectRoot: string): Promise<boolean>;
33
70
  export declare function detectNextApiRoutes(projectRoot: string, hasNext: boolean): Promise<boolean>;
34
71
  export declare function detectBackendDirs(projectRoot: string): Promise<string[]>;
35
72
  export declare function detectSwagger(projectRoot: string): Promise<string[]>;
@@ -10,8 +10,8 @@
10
10
  * `decideIntegrationMode`) and the public `scanArchetype` entry; the
11
11
  * `frontendOnly` / `frontendOnlyReason` output contract is unchanged.
12
12
  */
13
- import { stat } from 'node:fs/promises';
14
- import { join } from 'node:path';
13
+ import { readdir, stat } from 'node:fs/promises';
14
+ import { join, relative as relativePath } from 'node:path';
15
15
  import { isDirectory, pathExists, readText } from 'peaks-loop-shared/fs';
16
16
  export const BACKEND_DEP_NAMES = [
17
17
  'express',
@@ -85,6 +85,179 @@ export async function readPackageJsonDeps(projectRoot) {
85
85
  export async function detectBackendFrameworks(deps) {
86
86
  return BACKEND_DEP_NAMES.filter((name) => name !== 'next' && Object.prototype.hasOwnProperty.call(deps, name));
87
87
  }
88
+ /**
89
+ * Manifests that only a service writes. Language-diverse on purpose: every
90
+ * other probe in this file is Node-specific, and a repository whose backend is
91
+ * Go / Java / Kotlin / Python / Ruby / PHP / Rust has no Node manifest at all,
92
+ * so a Node-only evidence set reported it as `frontendOnly`.
93
+ */
94
+ export const SERVICE_ONLY_MANIFEST_FILES = [
95
+ 'go.mod',
96
+ 'pom.xml',
97
+ 'build.gradle',
98
+ 'build.gradle.kts',
99
+ 'settings.gradle',
100
+ 'settings.gradle.kts',
101
+ 'manage.py',
102
+ 'config.ru',
103
+ 'artisan'
104
+ ];
105
+ /**
106
+ * Manifests a service MAY write and a library or CLI also writes. Counted as
107
+ * backend evidence only when the file itself names a web framework — the
108
+ * alternative is a boolean that fires on any Python or Rust directory, which
109
+ * trades one wrong answer for another.
110
+ */
111
+ export const CONDITIONAL_SERVICE_MANIFESTS = [
112
+ ['requirements.txt', /\b(fastapi|flask|django|uvicorn|gunicorn|aiohttp|tornado|starlette)\b/i],
113
+ ['pyproject.toml', /\b(fastapi|flask|django|uvicorn|gunicorn|aiohttp|tornado|starlette)\b/i],
114
+ ['Cargo.toml', /\b(actix-web|axum|rocket|warp|tonic|hyper|poem)\b/i],
115
+ ['Gemfile', /\b(rails|sinatra|hanami|puma|rack)\b/i],
116
+ ['composer.json', /\b(laravel|symfony|slim)\b/i]
117
+ ];
118
+ /** Directories never worth descending into when looking for a service manifest. */
119
+ const MANIFEST_WALK_SKIP_DIRS = new Set([
120
+ 'node_modules',
121
+ 'dist',
122
+ 'build',
123
+ 'out',
124
+ 'coverage',
125
+ 'target',
126
+ 'vendor',
127
+ '__pycache__',
128
+ '.git'
129
+ ]);
130
+ /**
131
+ * How deep from the project root a service is allowed to sit before its
132
+ * manifest stops being counted. Two levels covers the layouts that were
133
+ * invisible (`apps/gateway/package.json`, `services/checkout/requirements.txt`,
134
+ * `web/` + `cmd/server`), and a deeper walk would read generated trees.
135
+ */
136
+ export const SERVICE_MANIFEST_MAX_DEPTH = 2;
137
+ /**
138
+ * How many leading bytes of a source file are read to find a directive. `'use
139
+ * server'` must be the first statement, so the head is the whole question, and
140
+ * reading a bounded head keeps this probe from pulling multi-megabyte files into
141
+ * memory.
142
+ */
143
+ const DIRECTIVE_HEAD_BYTES = 400;
144
+ async function readDirSafe(dir) {
145
+ try {
146
+ return await readdir(dir, { withFileTypes: true });
147
+ }
148
+ catch {
149
+ // An unreadable directory is not evidence of a backend, and not of its
150
+ // absence either; the root manifest already carries the project.
151
+ return [];
152
+ }
153
+ }
154
+ function relativeFromRoot(projectRoot, dir) {
155
+ return relativePath(projectRoot, dir).replace(/\\/g, '/');
156
+ }
157
+ function labeled(relative, file) {
158
+ return relative === '' ? file : `${relative}/${file}`;
159
+ }
160
+ function descendable(entries, dir, depth) {
161
+ if (depth >= SERVICE_MANIFEST_MAX_DEPTH)
162
+ return [];
163
+ return entries
164
+ .filter((entry) => entry.isDirectory() &&
165
+ !MANIFEST_WALK_SKIP_DIRS.has(entry.name) &&
166
+ !entry.name.startsWith('.'))
167
+ .map((entry) => ({ dir: join(dir, entry.name), depth: depth + 1 }));
168
+ }
169
+ /**
170
+ * Service manifests sitting in ONE directory: the unmistakable ones by name,
171
+ * and the shared ones only when the file names a web framework.
172
+ */
173
+ async function manifestEvidenceInDir(dir, relative, names) {
174
+ const found = SERVICE_ONLY_MANIFEST_FILES.filter((manifest) => names.has(manifest)).map((manifest) => labeled(relative, manifest));
175
+ for (const [manifest, pattern] of CONDITIONAL_SERVICE_MANIFESTS) {
176
+ if (!names.has(manifest))
177
+ continue;
178
+ const text = await readText(join(dir, manifest)).catch(() => '');
179
+ const matched = pattern.exec(text);
180
+ if (matched !== null) {
181
+ found.push(`${labeled(relative, manifest)} (${matched[0].toLowerCase()})`);
182
+ }
183
+ }
184
+ return found;
185
+ }
186
+ /**
187
+ * A nested `package.json` declares its own dependencies, and a workspace root
188
+ * does not list them. Reading only the root is how an express service under
189
+ * `apps/` became `frontendOnly`.
190
+ */
191
+ async function workspacePackageEvidence(dir, relative, names, backendDepNames) {
192
+ if (relative === '' || !names.has('package.json'))
193
+ return [];
194
+ const { deps } = await readPackageJsonDeps(dir);
195
+ return backendDepNames
196
+ .filter((dep) => dep !== 'next' && Object.prototype.hasOwnProperty.call(deps, dep))
197
+ .map((dep) => `${relative}: ${dep}`);
198
+ }
199
+ /**
200
+ * Backend evidence that lives OUTSIDE the root manifest: a workspace package's
201
+ * own `package.json`, and any non-Node service manifest.
202
+ *
203
+ * Returns `dir: evidence` strings (`apps/gateway: express`, `go.mod`,
204
+ * `services/checkout: requirements.txt`) rather than a boolean, because the
205
+ * whole defect this probe closes was a verdict that could not say what it saw.
206
+ */
207
+ export async function detectNestedServiceEvidence(projectRoot, backendDepNames = BACKEND_DEP_NAMES) {
208
+ const evidence = [];
209
+ const queue = [{ dir: projectRoot, depth: 0 }];
210
+ while (queue.length > 0) {
211
+ const current = queue.shift();
212
+ if (current === undefined)
213
+ break;
214
+ const entries = await readDirSafe(current.dir);
215
+ const names = new Set(entries.map((entry) => entry.name));
216
+ const relative = relativeFromRoot(projectRoot, current.dir);
217
+ evidence.push(...(await manifestEvidenceInDir(current.dir, relative, names)));
218
+ evidence.push(...(await workspacePackageEvidence(current.dir, relative, names, backendDepNames)));
219
+ queue.push(...descendable(entries, current.dir, current.depth));
220
+ }
221
+ return evidence.sort();
222
+ }
223
+ /** Does this directory tree contain a file opening with `'use server'`? */
224
+ async function hasUseServerFile(rootDir) {
225
+ const queue = [{ dir: rootDir, depth: 0 }];
226
+ while (queue.length > 0) {
227
+ const current = queue.shift();
228
+ if (current === undefined)
229
+ break;
230
+ const entries = await readDirSafe(current.dir);
231
+ for (const entry of entries) {
232
+ const full = join(current.dir, entry.name);
233
+ if (entry.isDirectory())
234
+ continue;
235
+ if (!/\.(ts|tsx|js|jsx)$/.test(entry.name))
236
+ continue;
237
+ const text = await readText(full).catch(() => '');
238
+ if (/['"]use server['"]/.test(text.slice(0, DIRECTIVE_HEAD_BYTES)))
239
+ return true;
240
+ }
241
+ queue.push(...descendable(entries, current.dir, current.depth));
242
+ }
243
+ return false;
244
+ }
245
+ /**
246
+ * Next.js server actions: `'use server'` in a file under `app/`. Route handlers
247
+ * under `pages/api` / `app/api` were the only Next backend the detector knew
248
+ * about, so a Next app that mutates data through actions — the documented
249
+ * Next.js way — was reported as having no backend at all.
250
+ */
251
+ export async function detectNextServerActions(projectRoot) {
252
+ for (const root of ['app', 'src/app']) {
253
+ const rootDir = join(projectRoot, root);
254
+ if (await isDirectory(rootDir)) {
255
+ if (await hasUseServerFile(rootDir))
256
+ return true;
257
+ }
258
+ }
259
+ return false;
260
+ }
88
261
  export async function detectNextApiRoutes(projectRoot, hasNext) {
89
262
  if (!hasNext) {
90
263
  return false;
@@ -1,7 +1,7 @@
1
1
  import { readdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { isDirectory } from 'peaks-loop-shared/fs';
4
- import { detectBackendDirs, detectBackendFrameworks, detectMonorepoConfigs, detectNextApiRoutes, detectSwagger, GREENFIELD_MAX_SRC_FILES, GREENFIELD_MAX_LOCKFILE_DAYS, HIGH_CONFIDENCE_SIGNAL_COUNT, LEGACY_MIN_SRC_FILES, lockfileAgeDays, LOCKFILE_STALE_DAYS, readPackageJsonDeps } from './archetype-detection.js';
4
+ import { detectBackendDirs, detectBackendFrameworks, detectMonorepoConfigs, detectNestedServiceEvidence, detectNextApiRoutes, detectNextServerActions, detectSwagger, GREENFIELD_MAX_SRC_FILES, GREENFIELD_MAX_LOCKFILE_DAYS, HIGH_CONFIDENCE_SIGNAL_COUNT, LEGACY_MIN_SRC_FILES, lockfileAgeDays, LOCKFILE_STALE_DAYS, readPackageJsonDeps } from './archetype-detection.js';
5
5
  async function countSrcFiles(projectRoot, max = 500) {
6
6
  const srcDir = join(projectRoot, 'src');
7
7
  if (!(await isDirectory(srcDir))) {
@@ -30,11 +30,26 @@ async function countSrcFiles(projectRoot, max = 500) {
30
30
  }
31
31
  return count;
32
32
  }
33
+ /**
34
+ * THE backend predicate — one answer, three callers.
35
+ *
36
+ * `decideArchetype`, `decideFrontendOnly` and `decideIntegrationMode` each
37
+ * spelled the same triple out inline, so a signal added for one of them left
38
+ * the other two reading a different project. The report then contradicted
39
+ * itself, which is the failure `54ba0cc4` ("one answer for where frontendOnly
40
+ * lives") was meant to end but did not. Anything that asks "does this repo
41
+ * have a backend?" asks here.
42
+ */
43
+ function hasBackendEvidence(detected) {
44
+ return (detected.hasBackendFramework ||
45
+ detected.hasNextApiRoutes ||
46
+ detected.hasNextServerActions ||
47
+ detected.backendDirsPresent.length > 0 ||
48
+ detected.nestedServiceEvidence.length > 0);
49
+ }
33
50
  function decideArchetype(detected) {
34
51
  const signals = [];
35
- const hasBackend = detected.hasBackendFramework ||
36
- detected.hasNextApiRoutes ||
37
- detected.backendDirsPresent.length > 0;
52
+ const hasBackend = hasBackendEvidence(detected);
38
53
  signals.push({
39
54
  name: 'backend-presence',
40
55
  matched: hasBackend,
@@ -44,8 +59,12 @@ function decideArchetype(detected) {
44
59
  ? `framework: ${detected.backendFrameworks.join(', ')}`
45
60
  : null,
46
61
  detected.hasNextApiRoutes ? 'next-api-routes' : null,
62
+ detected.hasNextServerActions ? 'next-server-actions' : null,
47
63
  detected.backendDirsPresent.length > 0
48
64
  ? `dirs: ${detected.backendDirsPresent.join(', ')}`
65
+ : null,
66
+ detected.nestedServiceEvidence.length > 0
67
+ ? `nested: ${detected.nestedServiceEvidence.join(', ')}`
49
68
  : null
50
69
  ]
51
70
  .filter(Boolean)
@@ -131,33 +150,24 @@ function decideFrontendOnly(report) {
131
150
  if (report.archetype === 'legacy-frontend' || report.archetype === 'frontend-monorepo') {
132
151
  return { frontendOnly: true, reason: `archetype=${report.archetype}` };
133
152
  }
134
- const noBackend = !report.detected.hasBackendFramework &&
135
- !report.detected.hasNextApiRoutes &&
136
- report.detected.backendDirsPresent.length === 0;
153
+ const noBackend = !hasBackendEvidence(report.detected);
137
154
  if (noBackend && !report.detected.hasSwaggerOrProto) {
138
155
  return { frontendOnly: true, reason: 'no-backend-no-swagger' };
139
156
  }
140
- if (report.detected.hasBackendFramework ||
141
- report.detected.hasNextApiRoutes ||
142
- report.detected.backendDirsPresent.length > 0) {
143
- return { frontendOnly: false, reason: 'backend-detected' };
157
+ if (noBackend) {
158
+ return { frontendOnly: false, reason: 'swagger-or-proto-present' };
144
159
  }
145
- return { frontendOnly: false, reason: 'swagger-or-proto-present' };
160
+ return { frontendOnly: false, reason: 'backend-detected' };
146
161
  }
147
162
  /**
148
163
  * Three frontend integration scenarios, from the signals `detected`
149
- * already holds. Backend presence uses the SAME triple as
150
- * `decideArchetype`'s `hasBackend` (framework OR next API routes OR
151
- * backend dirs) rather than `hasBackendFramework` alone: `next` is
152
- * deliberately excluded from `backendFrameworks` (:77), so a Next
153
- * project with `pages/api` is `legacy-fullstack` there and must not be
154
- * `prd-only` here — one report cannot contradict itself.
164
+ * already holds. Backend presence is the SAME predicate `decideArchetype`
165
+ * uses (`hasBackendEvidence`), because one report cannot be
166
+ * `legacy-fullstack` and `prd-only` at once — a Next project with server
167
+ * actions, or an express service under `apps/`, is a backend for both.
155
168
  */
156
169
  function decideIntegrationMode(report) {
157
- const hasBackend = report.detected.hasBackendFramework ||
158
- report.detected.hasNextApiRoutes ||
159
- report.detected.backendDirsPresent.length > 0;
160
- if (hasBackend) {
170
+ if (hasBackendEvidence(report.detected)) {
161
171
  return { integrationMode: 'full-stack', reason: 'backend-detected' };
162
172
  }
163
173
  if (report.detected.hasSwaggerOrProto) {
@@ -172,6 +182,8 @@ export async function scanArchetype(options) {
172
182
  const hasNext = Object.prototype.hasOwnProperty.call(deps, 'next');
173
183
  const hasNextApiRoutes = await detectNextApiRoutes(projectRoot, hasNext);
174
184
  const backendDirsPresent = await detectBackendDirs(projectRoot);
185
+ const nestedServiceEvidence = await detectNestedServiceEvidence(projectRoot);
186
+ const hasNextServerActions = hasNext ? await detectNextServerActions(projectRoot) : false;
175
187
  const swaggerPaths = await detectSwagger(projectRoot);
176
188
  const monorepoConfigs = await detectMonorepoConfigs(projectRoot);
177
189
  const srcFileCount = await countSrcFiles(projectRoot);
@@ -180,6 +192,8 @@ export async function scanArchetype(options) {
180
192
  hasPackageJson,
181
193
  hasBackendFramework: backendFrameworks.length > 0,
182
194
  backendFrameworks,
195
+ nestedServiceEvidence,
196
+ hasNextServerActions,
183
197
  hasSwaggerOrProto: swaggerPaths.length > 0,
184
198
  swaggerPaths,
185
199
  hasMonorepoConfig: monorepoConfigs.length > 0,
@@ -22,6 +22,15 @@ export type ArchetypeReport = {
22
22
  hasPackageJson: boolean;
23
23
  hasBackendFramework: boolean;
24
24
  backendFrameworks: string[];
25
+ /** Backend evidence the ROOT manifest cannot see: a workspace package's own
26
+ * `package.json` (`apps/gateway: express`) and any non-Node service
27
+ * manifest (`go.mod`, `services/checkout/requirements.txt (fastapi)`).
28
+ * Every probe that filled `detected` before this field read Node's
29
+ * manifest only, so a Go or Java service reported as `frontendOnly`. */
30
+ nestedServiceEvidence: string[];
31
+ /** Next.js server actions (`'use server'` under `app/`). Route handlers
32
+ * (`pages/api` / `app/api`) were the only Next backend recognised. */
33
+ hasNextServerActions: boolean;
25
34
  hasSwaggerOrProto: boolean;
26
35
  swaggerPaths: string[];
27
36
  hasMonorepoConfig: boolean;