dorfl 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/advance-drivers.d.ts +1 -1
  2. package/dist/advance-lifecycle-template.js +6 -6
  3. package/dist/advance-lifecycle-template.js.map +1 -1
  4. package/dist/brand.d.ts +1 -1
  5. package/dist/brand.js +1 -1
  6. package/dist/cli.d.ts +9 -0
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +119 -11
  9. package/dist/cli.js.map +1 -1
  10. package/dist/config-override.d.ts +1 -1
  11. package/dist/do-autopick.d.ts +1 -1
  12. package/dist/do-config.d.ts.map +1 -1
  13. package/dist/do-config.js +1 -1
  14. package/dist/do-config.js.map +1 -1
  15. package/dist/env-config.d.ts +1 -1
  16. package/dist/env-config.js +1 -1
  17. package/dist/identity.d.ts +1 -1
  18. package/dist/install-ci.js +2 -2
  19. package/dist/install-ci.js.map +1 -1
  20. package/dist/intake-trigger-template.js +2 -2
  21. package/dist/lifecycle-pools.d.ts +1 -1
  22. package/dist/mirror-pool-scan.d.ts +2 -2
  23. package/dist/mirror-pool-scan.js +1 -1
  24. package/dist/mirror-pool-scan.js.map +1 -1
  25. package/dist/prd-to-spec.d.ts +8 -57
  26. package/dist/prd-to-spec.d.ts.map +1 -1
  27. package/dist/prd-to-spec.js +17 -99
  28. package/dist/prd-to-spec.js.map +1 -1
  29. package/dist/protocol/WORK-CONTRACT.md +1 -1
  30. package/dist/repo-config.d.ts +11 -11
  31. package/dist/repo-config.d.ts.map +1 -1
  32. package/dist/repo-config.js +9 -9
  33. package/dist/repo-mirror.d.ts +6 -6
  34. package/dist/repo-mirror.d.ts.map +1 -1
  35. package/dist/repo-mirror.js +6 -6
  36. package/dist/repo-mirror.js.map +1 -1
  37. package/dist/resync-protocol.d.ts +73 -0
  38. package/dist/resync-protocol.d.ts.map +1 -0
  39. package/dist/resync-protocol.js +114 -0
  40. package/dist/resync-protocol.js.map +1 -0
  41. package/dist/run.d.ts +1 -1
  42. package/dist/run.js +1 -1
  43. package/dist/run.js.map +1 -1
  44. package/dist/scan.d.ts +2 -2
  45. package/dist/scan.js +5 -5
  46. package/dist/scan.js.map +1 -1
  47. package/dist/skills/convert-from-prd-to-spec/SKILL.md +2 -2
  48. package/dist/skills/drive-tasks/SKILL.md +1 -1
  49. package/dist/skills/from-idea/SKILL.md +2 -2
  50. package/dist/skills/setup/SKILL.md +2 -2
  51. package/dist/skills/setup/protocol/WORK-CONTRACT.md +1 -1
  52. package/dist/verify-workflow-template.d.ts +1 -1
  53. package/dist/verify-workflow-template.js +1 -1
  54. package/package.json +1 -1
  55. package/src/advance-drivers.ts +1 -1
  56. package/src/advance-lifecycle-template.ts +6 -6
  57. package/src/brand.ts +1 -1
  58. package/src/cli.ts +168 -11
  59. package/src/config-override.ts +1 -1
  60. package/src/do-autopick.ts +1 -1
  61. package/src/do-config.ts +1 -1
  62. package/src/env-config.ts +1 -1
  63. package/src/identity.ts +1 -1
  64. package/src/install-ci.ts +2 -2
  65. package/src/intake-trigger-template.ts +2 -2
  66. package/src/lifecycle-pools.ts +1 -1
  67. package/src/mirror-pool-scan.ts +3 -3
  68. package/src/prd-to-spec.ts +26 -148
  69. package/src/repo-config.ts +11 -11
  70. package/src/repo-mirror.ts +7 -7
  71. package/src/resync-protocol.ts +162 -0
  72. package/src/run.ts +2 -2
  73. package/src/scan.ts +5 -5
  74. package/src/verify-workflow-template.ts +1 -1
@@ -47,7 +47,7 @@ import {heldTaskSlugs} from './item-lock.js';
47
47
  * over the mirror's `work/specs/ready` + `work/specs/tasked` read via the mirror-ref
48
48
  * {@link LedgerReadStrategy.resolveMirrorSpecPool}.
49
49
  *
50
- * Per-repo policy parity: a bare mirror has no checked-out `.dorfl.json`,
50
+ * Per-repo policy parity: a bare mirror has no checked-out `dorfl.json`,
51
51
  * but the COMMITTED one is reachable on `main` (the `do --remote` per-repo seam).
52
52
  * We layer it via {@link resolveRepoConfigFromMirror} so the same logical `work/`
53
53
  * state yields the SAME `autoBuild`/`autoTask` decision an in-place checkout
@@ -59,7 +59,7 @@ export interface ScanMirrorPoolOptions {
59
59
  /** The bare hub mirror directory (`<workspacesDir>/repos/<key>.git`). */
60
60
  mirrorPath: string;
61
61
  /**
62
- * The global + default config layer. The repo's COMMITTED `.dorfl.json`
62
+ * The global + default config layer. The repo's COMMITTED `dorfl.json`
63
63
  * (read from the mirror's `main`) is layered on top, so `autoBuild`/`autoTask`
64
64
  * resolve per-repo exactly as the in-place scan resolves them from the checkout.
65
65
  */
@@ -146,7 +146,7 @@ export async function scanMirrorPool(
146
146
  const read = options.read ?? ledgerRead;
147
147
  const warn = options.warn;
148
148
 
149
- // Per-repo policy parity: layer the COMMITTED `.dorfl.json` from the
149
+ // Per-repo policy parity: layer the COMMITTED `dorfl.json` from the
150
150
  // mirror's `main` (the `do --remote` per-repo seam) onto global. Never fatal —
151
151
  // a config-less repo or a read fault falls back to global + default.
152
152
  let repoConfig: Config;
@@ -2,17 +2,27 @@ import {
2
2
  existsSync,
3
3
  readdirSync,
4
4
  readFileSync,
5
- statSync,
6
5
  writeFileSync,
7
- copyFileSync,
8
6
  mkdirSync,
9
7
  rmdirSync,
10
8
  } from 'node:fs';
11
- import {join, relative, dirname, basename} from 'node:path';
9
+ import {join, basename} from 'node:path';
12
10
  import {run, git, gitMv} from './git.js';
13
- import {resolveProtocolDoc} from './prompt.js';
14
11
  import {WORK_ROOT} from './work-layout.js';
15
12
  import {repoConfigPath} from './repo-config.js';
13
+ import {
14
+ resyncProtocol,
15
+ PROTOCOL_DOCS,
16
+ type ResyncedDoc,
17
+ type ResyncResult,
18
+ } from './resync-protocol.js';
19
+
20
+ // The protocol re-sync now lives in the shared `resync-protocol.ts` module (so
21
+ // the standalone `dorfl sync` command and this migration engine share ONE
22
+ // implementation). Re-exported here for backward compatibility with existing
23
+ // importers/tests that reach for these symbols on `prd-to-spec.js`.
24
+ export {resyncProtocol, PROTOCOL_DOCS};
25
+ export type {ResyncedDoc, ResyncResult};
16
26
 
17
27
  /**
18
28
  * The **`dorfl prd-to-spec` migration ENGINE** (spec
@@ -41,7 +51,7 @@ import {repoConfigPath} from './repo-config.js';
41
51
  * (a) FOLDERS `work/prds/{proposed,ready,tasked,dropped}/ → work/specs/…`
42
52
  * via `git mv`; (b) FRONTMATTER/body `prd: → spec:` + inert path/token refs
43
53
  * across ALL items INCLUDING `work/tasks/done/` and `work/specs/tasked/`;
44
- * (c) CONFIG `.dorfl.json` (`prdsLandIn → specsLandIn`, keep-case);
54
+ * (c) CONFIG `dorfl.json` (`prdsLandIn → specsLandIn`, keep-case);
45
55
  * (d) LIVE GIT REFS (inert lock-refs `refs/dorfl/lock/prd-<slug>` and
46
56
  * work-branches `work/prd-<slug>` — the quiescence gate guarantees none are
47
57
  * held/in-flight, so this renames only INERT refs).
@@ -86,7 +96,7 @@ import {repoConfigPath} from './repo-config.js';
86
96
  * A single vocabulary cutover the engine enacts: the retired word `from` (all
87
97
  * three case variants) → the new word `to`. The `prd → spec` migration pins
88
98
  * {@link MIGRATION}; a future cutover supplies a different value and reuses the
89
- * same engine (user story 9). `configKeys` are the exact `.dorfl.json` keys that
99
+ * same engine (user story 9). `configKeys` are the exact `dorfl.json` keys that
90
100
  * carry the `from` word (their VALUES are preserved — only the key renames).
91
101
  */
92
102
  export interface VocabularyMigration {
@@ -111,25 +121,6 @@ export const MIGRATION: VocabularyMigration = {
111
121
  configKeys: [{from: 'prdsLandIn', to: 'specsLandIn'}],
112
122
  };
113
123
 
114
- /**
115
- * The FULL set of protocol docs `setup` propagates into a repo's
116
- * `work/protocol/`. The re-sync copies EACH from the package's canonical source
117
- * (via {@link resolveProtocolDoc}) so the migrated repo carries the new `spec`
118
- * contract. Kept in lockstep with `skills/setup/protocol/` (the source of truth)
119
- * and the `vendor-protocol` build step (which vendors these into `dist/protocol/`
120
- * so the published CLI is self-contained).
121
- */
122
- export const PROTOCOL_DOCS: readonly string[] = [
123
- 'WORK-CONTRACT.md',
124
- 'CLAIM-PROTOCOL.md',
125
- 'REVIEW-PROTOCOL.md',
126
- 'SURFACE-PROTOCOL.md',
127
- 'TASKING-PROTOCOL.md',
128
- 'ADR-FORMAT.md',
129
- 'task-template.md',
130
- 'spec-template.md',
131
- ];
132
-
133
124
  // ───────────────────────────────────────────────────────────────────────────
134
125
  // Layer: keep-case replace (the bespoke sweep — see the tooling JSDoc above).
135
126
  // ───────────────────────────────────────────────────────────────────────────
@@ -265,122 +256,6 @@ export function checkQuiescence(
265
256
  return undefined;
266
257
  }
267
258
 
268
- // ───────────────────────────────────────────────────────────────────────────
269
- // Layer 2: the setup contract re-sync (the deterministic slice of `setup`).
270
- // ───────────────────────────────────────────────────────────────────────────
271
-
272
- /** One protocol doc the re-sync copied (or WOULD copy under `--dry-run`). */
273
- export interface ResyncedDoc {
274
- /** The doc basename (e.g. `'WORK-CONTRACT.md'`). */
275
- name: string;
276
- /** Repo-relative destination (`work/protocol/<name>`). */
277
- dest: string;
278
- /** True when the target already had byte-identical content (a no-op copy). */
279
- unchanged: boolean;
280
- /**
281
- * True when the doc's SOURCE could not be resolved (the package/dev copy is
282
- * missing), so NOTHING was copied to `dest`. A skipped doc is NEVER counted as
283
- * a change (it must not bump `VERSION` — the latent bug this field guards) and
284
- * is surfaced LOUDLY by the caller. Distinct from `unchanged` (which means the
285
- * source WAS resolved and matched the dest byte-for-byte).
286
- */
287
- skipped: boolean;
288
- }
289
-
290
- /** What the protocol re-sync did (or would do). */
291
- export interface ResyncResult {
292
- docs: ResyncedDoc[];
293
- /** Repo-relative `work/protocol/VERSION` path (written unless dry-run). */
294
- versionPath: string;
295
- }
296
-
297
- /**
298
- * Re-sync the target repo's `work/protocol/*` from the package's canonical
299
- * source (the deterministic part of the `setup` skill — copy the docs verbatim +
300
- * bump `VERSION`). Resolves each doc's SOURCE via {@link resolveProtocolDoc} with
301
- * NO `cwd`, so it reads the package-vendored (`dist/protocol/`) or dev-source
302
- * (`skills/setup/protocol/`) copy — NEVER the target repo's own (old) copy. This
303
- * is how the migrated repo picks up the new `spec` contract before its data is
304
- * converted. Idempotent: a doc already byte-identical is reported `unchanged`.
305
- * A doc whose SOURCE cannot be resolved is reported `skipped` (NOT copied, NEVER
306
- * a VERSION-bump) rather than silently bumping VERSION with nothing copied.
307
- *
308
- * `dryRun` reports what WOULD be copied without writing. `resolveDoc` overrides
309
- * how a doc's source path is resolved (defaults to {@link resolveProtocolDoc});
310
- * it exists so a test can force a non-resolvable source (a path that does not
311
- * exist) to exercise the skip path.
312
- */
313
- export function resyncProtocol(
314
- repoPath: string,
315
- options: {
316
- dryRun?: boolean;
317
- resolveDoc?: (name: string) => string;
318
- } = {},
319
- ): ResyncResult {
320
- const protocolDir = join(repoPath, WORK_ROOT, 'protocol');
321
- const resolveDoc = options.resolveDoc ?? ((name) => resolveProtocolDoc(name));
322
- const docs: ResyncedDoc[] = [];
323
- for (const name of PROTOCOL_DOCS) {
324
- // Resolve the SOURCE (package/dev), never the target's adopted copy.
325
- const source = resolveDoc(name);
326
- const destAbs = join(protocolDir, name);
327
- const destRel = relative(repoPath, destAbs);
328
- const sourceExists = existsSync(source);
329
- if (!sourceExists) {
330
- // The source doc could not be resolved: copy NOTHING and record the doc as
331
- // SKIPPED (never a change). This is the latent-bug fix: a non-resolvable
332
- // source must not count as `changed` and bump `VERSION` while copying no
333
- // file. `unchanged: false` here is honest (the dest was not made to match a
334
- // source), but `skipped: true` keeps it OUT of the VERSION-bump tally.
335
- docs.push({name, dest: destRel, unchanged: false, skipped: true});
336
- continue;
337
- }
338
- // Source resolved: a doc is CHANGED (VERSION-bump-worthy) only when the dest
339
- // is absent OR its content differs from the source. An identical dest is a
340
- // no-op copy (`unchanged`), so an already-synced re-run stays a true no-op.
341
- const unchanged =
342
- existsSync(destAbs) &&
343
- readFileSync(source, 'utf8') === readFileSync(destAbs, 'utf8');
344
- if (!options.dryRun) {
345
- mkdirSync(dirname(destAbs), {recursive: true});
346
- copyFileSync(source, destAbs);
347
- }
348
- docs.push({name, dest: destRel, unchanged, skipped: false});
349
- }
350
-
351
- const versionAbs = join(protocolDir, 'VERSION');
352
- const versionRel = relative(repoPath, versionAbs);
353
- // Bump VERSION only when a doc was ACTUALLY COPIED with new content, or when
354
- // VERSION is absent AND at least one doc was genuinely synced (copied). A
355
- // SKIPPED doc (unresolvable source, nothing copied) is NEVER a change —
356
- // counting it would bump VERSION without copying a single doc (the latent bug),
357
- // and the write-when-absent fallback must ALSO require a real copy so an
358
- // all-skipped resync leaves no VERSION behind. A re-run on an already-synced
359
- // repo must stay a true no-op — otherwise the (always-fresh) `synced-at`
360
- // timestamp would dirty the tree on every idempotent re-run.
361
- const anyDocChanged = docs.some((d) => !d.unchanged && !d.skipped);
362
- const anyDocSynced = docs.some((d) => !d.skipped);
363
- if (
364
- !options.dryRun &&
365
- (anyDocChanged || (anyDocSynced && !existsSync(versionAbs)))
366
- ) {
367
- mkdirSync(dirname(versionAbs), {recursive: true});
368
- const today = new Date().toISOString().slice(0, 10);
369
- writeFileSync(
370
- versionAbs,
371
- [
372
- `protocol-version: ${today}`,
373
- 'synced-from: skills/setup/protocol/',
374
- `synced-at: ${new Date().toISOString()}`,
375
- 'source-commit: dorfl prd-to-spec',
376
- '',
377
- ].join('\n'),
378
- );
379
- }
380
-
381
- return {docs, versionPath: versionRel};
382
- }
383
-
384
259
  // ───────────────────────────────────────────────────────────────────────────
385
260
  // Layer 3a: the FOLDER move (in lockstep with work-layout.ts).
386
261
  // ───────────────────────────────────────────────────────────────────────────
@@ -653,7 +528,7 @@ export function migrateAllItemContent(
653
528
  }
654
529
 
655
530
  // ───────────────────────────────────────────────────────────────────────────
656
- // Layer 3c: the CONFIG key rewrite (.dorfl.json).
531
+ // Layer 3c: the CONFIG key rewrite (dorfl.json).
657
532
  // ───────────────────────────────────────────────────────────────────────────
658
533
 
659
534
  /** One config key the rewrite renamed (or would rename). */
@@ -663,12 +538,12 @@ export interface ConfigRewrite {
663
538
  }
664
539
 
665
540
  /**
666
- * Rewrite the retired config KEYS in `.dorfl.json` (`prdsLandIn → specsLandIn`),
541
+ * Rewrite the retired config KEYS in `dorfl.json` (`prdsLandIn → specsLandIn`),
667
542
  * preserving each key's VALUE and the file's surrounding formatting — a TEXTUAL
668
543
  * key rename on the raw JSON, NOT a parse+reserialise (which would drop unknown
669
544
  * keys / reflow the file). Only renames a key that is actually present, so it is
670
545
  * idempotent (a config already on `specsLandIn` changes nothing) and safe on a
671
- * repo with no `.dorfl.json`. Returns the keys renamed. `dryRun` reports without
546
+ * repo with no `dorfl.json`. Returns the keys renamed. `dryRun` reports without
672
547
  * writing.
673
548
  */
674
549
  export function migrateConfig(
@@ -677,7 +552,7 @@ export function migrateConfig(
677
552
  options: {dryRun?: boolean} = {},
678
553
  ): ConfigRewrite[] {
679
554
  // Resolve the repo's actual config file (prefers `dorfl.json`, falls back to
680
- // the legacy `.dorfl.json`), so the key rewrite finds it under either name.
555
+ // the legacy `dorfl.json`), so the key rewrite finds it under either name.
681
556
  const configAbs = repoConfigPath(repoPath);
682
557
  if (!existsSync(configAbs)) {
683
558
  return [];
@@ -806,7 +681,7 @@ export interface DataLeak {
806
681
  * command's OUTPUT (ADR §7e: the leak scan is exhaustive-by-construction). This
807
682
  * is the DATA analogue of the source-part `prd-to-spec-leak-scan.test.ts`
808
683
  * (which scans `src/`/`skills/`/`docs/`): here it walks the CONVERTED `work/`
809
- * tree + `.dorfl.json` + the git refs.
684
+ * tree + `dorfl.json` + the git refs.
810
685
  *
811
686
  * - **FORWARD:** fails on any surviving retired word (`prd`/`Prd`/`PRD`) in a
812
687
  * data STRUCTURE position — a frontmatter `prd:` KEY, a `work/prds/…` folder
@@ -926,7 +801,7 @@ export function scanForLeaks(
926
801
  }
927
802
  }
928
803
  // The config file (the key layer) — under either the preferred `dorfl.json`
929
- // or the legacy `.dorfl.json`.
804
+ // or the legacy `dorfl.json`.
930
805
  const configAbs = repoConfigPath(repoPath);
931
806
  if (existsSync(configAbs)) {
932
807
  scanText(
@@ -1040,7 +915,10 @@ export function runPrdToSpec(options: PrdToSpecOptions): PrdToSpecResult {
1040
915
  }
1041
916
 
1042
917
  // 2. Setup contract re-sync FIRST (decision B).
1043
- const resync = resyncProtocol(repoPath, {dryRun});
918
+ const resync = resyncProtocol(repoPath, {
919
+ dryRun,
920
+ sourceCommit: 'dorfl prd-to-spec',
921
+ });
1044
922
  // 3a. Folders (git mv) — before content, so the content sweep also fixes the
1045
923
  // moved items' own `work/prds/…` body refs in their NEW location.
1046
924
  const folderMoves = migrateFolders(repoPath, migration, {dryRun});
@@ -16,7 +16,7 @@ import {run} from './git.js';
16
16
  /**
17
17
  * The per-repo config layer.
18
18
  *
19
- * A repo may commit a `.dorfl.json` at its root. It travels WITH the repo
19
+ * A repo may commit a `dorfl.json` at its root. It travels WITH the repo
20
20
  * (it is committed) and overrides the global `~/.config/dorfl/config.json`
21
21
  * FOR THAT REPO ONLY — so repo-local properties (how this repo integrates, its
22
22
  * acceptance `verify` gate, which remote arbitrates its claims) are agreed by all
@@ -28,7 +28,7 @@ import {run} from './git.js';
28
28
  * global > built-in default
29
29
  *
30
30
  * The mechanism is multi-repo aware: each repo resolves against its OWN
31
- * `.dorfl.json`, so repo A can be `merge` while repo B is `propose` in the
31
+ * `dorfl.json`, so repo A can be `merge` while repo B is `propose` in the
32
32
  * SAME run (see {@link resolveRepoConfig}).
33
33
  *
34
34
  * Only keys that are genuinely repo properties are honoured in the COMMITTED
@@ -44,7 +44,7 @@ import {run} from './git.js';
44
44
  * the global file / a flag) may set ANY key, host-only included (see
45
45
  * {@link envOverrides}).
46
46
  *
47
- * A repo with no `.dorfl.json` resolves to exactly the global config —
47
+ * A repo with no `dorfl.json` resolves to exactly the global config —
48
48
  * behaviour is unchanged from before this layer existed.
49
49
  */
50
50
 
@@ -57,7 +57,7 @@ export const REPO_CONFIG_FILENAME = brand.repoConfigFilename;
57
57
 
58
58
  /**
59
59
  * Config keys that are genuinely repo properties and so are honoured in a
60
- * per-repo `.dorfl.json`. Deliberately a subset of {@link Config};
60
+ * per-repo `dorfl.json`. Deliberately a subset of {@link Config};
61
61
  * extend this list as more keys become legitimately repo-scoped.
62
62
  */
63
63
  export const REPO_ALLOWED_KEYS = [
@@ -306,7 +306,7 @@ export function repoConfigPath(repoPath: string): string {
306
306
  return resolveRepoConfigPath(repoPath);
307
307
  }
308
308
 
309
- /** The result of reading (and filtering) a repo's `.dorfl.json`. */
309
+ /** The result of reading (and filtering) a repo's `dorfl.json`. */
310
310
  export interface LoadedRepoConfig {
311
311
  /** Where we looked (whether or not the file exists). */
312
312
  path: string;
@@ -328,7 +328,7 @@ export interface LoadedRepoConfig {
328
328
  }
329
329
 
330
330
  /**
331
- * Read a repo's `.dorfl.json` and split it into the honoured subset and
331
+ * Read a repo's `dorfl.json` and split it into the honoured subset and
332
332
  * the rejected runner/host-only keys. A missing file is not an error (the repo
333
333
  * simply resolves to the global config); invalid JSON is. Unknown keys are
334
334
  * silently dropped (neither honoured nor reported as rejected). Only keys in
@@ -352,8 +352,8 @@ export function loadRepoConfig(repoPath: string): LoadedRepoConfig {
352
352
 
353
353
  /**
354
354
  * The content-based half of {@link loadRepoConfig}: parse + apply the SAME
355
- * allow/reject split to ALREADY-READ `.dorfl.json` bytes, labelling the
356
- * source as `sourceLabel` (a path, or e.g. `<arbiter>/main:.dorfl.json`)
355
+ * allow/reject split to ALREADY-READ `dorfl.json` bytes, labelling the
356
+ * source as `sourceLabel` (a path, or e.g. `<arbiter>/main:dorfl.json`)
357
357
  * in the rejected-key message. Used wherever the committed repo file is sourced
358
358
  * from somewhere OTHER than a working-tree path — notably `do --remote`, which
359
359
  * reads it from the arbiter's `main` (`git show`) since there is no checkout.
@@ -413,7 +413,7 @@ function isRejectedKey(key: string): boolean {
413
413
 
414
414
  /** Inputs to {@link resolveRepoConfig}. */
415
415
  export interface ResolveRepoConfigOptions {
416
- /** Absolute path to the repo root (where `.dorfl.json` would live). */
416
+ /** Absolute path to the repo root (where `dorfl.json` would live). */
417
417
  repoPath: string;
418
418
  /**
419
419
  * The fully-resolved GLOBAL config (already merged over built-in defaults,
@@ -491,7 +491,7 @@ export interface ResolvedRepoConfig {
491
491
  /**
492
492
  * Resolve the effective config for ONE repo by layering, per key:
493
493
  *
494
- * flag > ENV (DORFL_*) > per-repo `.dorfl.json` > global >
494
+ * flag > ENV (DORFL_*) > per-repo `dorfl.json` > global >
495
495
  * built-in default
496
496
  *
497
497
  * The `global` argument already carries the global + default layers (it is the
@@ -502,7 +502,7 @@ export interface ResolvedRepoConfig {
502
502
  * in a multi-repo run yields INDEPENDENT results — repo A can be `merge` while
503
503
  * repo B is `propose` in the same run.
504
504
  *
505
- * A repo with no `.dorfl.json` (and no env) resolves to exactly `global`
505
+ * A repo with no `dorfl.json` (and no env) resolves to exactly `global`
506
506
  * (unchanged behaviour).
507
507
  */
508
508
  export function resolveRepoConfig(
@@ -151,7 +151,7 @@ export function ensureMirror(options: EnsureMirrorOptions): EnsureMirrorResult {
151
151
  * but on REUSE refreshes via {@link fetchMirrorMain} (main-only, no-prune).
152
152
  *
153
153
  * This is the MIRROR-ENSURE for the no-checkout CONFIG READ (`do --remote`/
154
- * `do --isolated` per-repo `.dorfl.json`): `git show main:.dorfl.json`
154
+ * `do --isolated` per-repo `dorfl.json`): `git show main:dorfl.json`
155
155
  * only needs `main`, and using the pruning all-heads fetch here would let a
156
156
  * `work/<slug>` branch CHECKED OUT in some other (stale) job worktree block the
157
157
  * fetch (`git refuses to fetch into branch … checked out`), throwing the config
@@ -226,12 +226,12 @@ export function fetchMirrorMain(
226
226
  }
227
227
 
228
228
  /**
229
- * Read the target repo's COMMITTED `.dorfl.json` from the bare hub
230
- * mirror's `main` (`git show main:.dorfl.json`) — the per-repo config
229
+ * Read the target repo's COMMITTED `dorfl.json` from the bare hub
230
+ * mirror's `main` (`git show main:dorfl.json`) — the per-repo config
231
231
  * layer for the NO-CHECKOUT paths (`do --remote`). The committed file is a
232
232
  * tracked file on `<arbiter>/main`, so it is reachable from the mirror without a
233
233
  * worktree. Returns the raw file CONTENT, or `undefined` when the repo has no
234
- * `.dorfl.json` on `main` (a config-less repo — the caller then resolves to
234
+ * `dorfl.json` on `main` (a config-less repo — the caller then resolves to
235
235
  * exactly global + default, byte-identical to before this layer existed).
236
236
  *
237
237
  * This is the task's ONE genuinely-new seam: sourcing the bytes from the arbiter
@@ -248,7 +248,7 @@ export function readRepoConfigFromMirrorMain(
248
248
  mirrorDir: string,
249
249
  env?: NodeJS.ProcessEnv,
250
250
  ): string | undefined {
251
- // Prefer the plain `dorfl.json`; fall back to the legacy `.dorfl.json` when the
251
+ // Prefer the plain `dorfl.json`; fall back to the legacy `dorfl.json` when the
252
252
  // preferred name is absent on `main` (a repo that committed the dotfile before
253
253
  // dual-support). A genuine read fault on the FIRST attempt propagates; a
254
254
  // path-missing on the first falls through to the legacy attempt.
@@ -293,7 +293,7 @@ function showConfigOnMain(
293
293
 
294
294
  /**
295
295
  * Resolve a repo's effective {@link Config} for a NO-CHECKOUT path by layering its
296
- * COMMITTED `.dorfl.json` (read from the bare hub mirror's `main` via
296
+ * COMMITTED `dorfl.json` (read from the bare hub mirror's `main` via
297
297
  * {@link readRepoConfigFromMirrorMain}) into the SAME
298
298
  * `flag > env > per-repo > global > default` chain a working checkout uses
299
299
  * ({@link resolveRepoConfigFromLoaded}). This is the reusable core of `do
@@ -308,7 +308,7 @@ function showConfigOnMain(
308
308
  * global + flags itself.
309
309
  */
310
310
  export function resolveRepoConfigFromMirror(options: {
311
- /** The bare hub mirror directory whose `main:.dorfl.json` to read. */
311
+ /** The bare hub mirror directory whose `main:dorfl.json` to read. */
312
312
  mirrorPath: string;
313
313
  /** The global + default config layer (from `loadConfig`/`mergeConfig`). */
314
314
  global: Config;
@@ -0,0 +1,162 @@
1
+ import {
2
+ existsSync,
3
+ readFileSync,
4
+ writeFileSync,
5
+ copyFileSync,
6
+ mkdirSync,
7
+ } from 'node:fs';
8
+ import {join, relative, dirname} from 'node:path';
9
+ import {resolveProtocolDoc} from './prompt.js';
10
+ import {WORK_ROOT} from './work-layout.js';
11
+
12
+ /**
13
+ * The **protocol re-sync** primitive: copy the package's canonical
14
+ * `work/protocol/*` docs into a target repo's `work/protocol/` and bump
15
+ * `work/protocol/VERSION`. This is the deterministic slice of what the `setup`
16
+ * skill does — the SINGLE source of truth for "make this repo carry the current
17
+ * protocol contract".
18
+ *
19
+ * Two verbs drive it:
20
+ * - `dorfl sync` — the standalone "get the latest protocol" command (a repo
21
+ * that adopted an OLD protocol picks up the new docs in one command);
22
+ * - `dorfl prd-to-spec` — the vocabulary-migration engine, which re-syncs the
23
+ * contract FIRST (before converting the repo's data) so the migrated repo
24
+ * lands on the new `spec` contract.
25
+ *
26
+ * It was lifted out of `prd-to-spec.ts` (where it began life) into this shared
27
+ * module so both verbs import ONE implementation; `prd-to-spec.ts` re-exports
28
+ * the symbols for backward compatibility.
29
+ */
30
+
31
+ /**
32
+ * The FULL set of protocol docs `setup` propagates into a repo's
33
+ * `work/protocol/`. The re-sync copies EACH from the package's canonical source
34
+ * (via {@link resolveProtocolDoc}) so a set-up repo carries the current
35
+ * contract. Kept in lockstep with `skills/setup/protocol/` (the source of truth)
36
+ * and the `vendor-protocol` build step (which vendors these into `dist/protocol/`
37
+ * so the published CLI is self-contained).
38
+ */
39
+ export const PROTOCOL_DOCS: readonly string[] = [
40
+ 'WORK-CONTRACT.md',
41
+ 'CLAIM-PROTOCOL.md',
42
+ 'REVIEW-PROTOCOL.md',
43
+ 'SURFACE-PROTOCOL.md',
44
+ 'TASKING-PROTOCOL.md',
45
+ 'ADR-FORMAT.md',
46
+ 'task-template.md',
47
+ 'spec-template.md',
48
+ ];
49
+
50
+ /** One protocol doc the re-sync copied (or WOULD copy under `--dry-run`). */
51
+ export interface ResyncedDoc {
52
+ /** The doc basename (e.g. `'WORK-CONTRACT.md'`). */
53
+ name: string;
54
+ /** Repo-relative destination (`work/protocol/<name>`). */
55
+ dest: string;
56
+ /** True when the target already had byte-identical content (a no-op copy). */
57
+ unchanged: boolean;
58
+ /**
59
+ * True when the doc's SOURCE could not be resolved (the package/dev copy is
60
+ * missing), so NOTHING was copied to `dest`. A skipped doc is NEVER counted as
61
+ * a change (it must not bump `VERSION` — the latent bug this field guards) and
62
+ * is surfaced LOUDLY by the caller. Distinct from `unchanged` (which means the
63
+ * source WAS resolved and matched the dest byte-for-byte).
64
+ */
65
+ skipped: boolean;
66
+ }
67
+
68
+ /** What the protocol re-sync did (or would do). */
69
+ export interface ResyncResult {
70
+ docs: ResyncedDoc[];
71
+ /** Repo-relative `work/protocol/VERSION` path (written unless dry-run). */
72
+ versionPath: string;
73
+ }
74
+
75
+ /**
76
+ * Re-sync the target repo's `work/protocol/*` from the package's canonical
77
+ * source (the deterministic part of the `setup` skill — copy the docs verbatim +
78
+ * bump `VERSION`). Resolves each doc's SOURCE via {@link resolveProtocolDoc} with
79
+ * NO `cwd`, so it reads the package-vendored (`dist/protocol/`) or dev-source
80
+ * (`skills/setup/protocol/`) copy — NEVER the target repo's own (old) copy. This
81
+ * is how a repo picks up the current contract. Idempotent: a doc already
82
+ * byte-identical is reported `unchanged`. A doc whose SOURCE cannot be resolved
83
+ * is reported `skipped` (NOT copied, NEVER a VERSION-bump) rather than silently
84
+ * bumping VERSION with nothing copied.
85
+ *
86
+ * `dryRun` reports what WOULD be copied without writing. `resolveDoc` overrides
87
+ * how a doc's source path is resolved (defaults to {@link resolveProtocolDoc});
88
+ * it exists so a test can force a non-resolvable source (a path that does not
89
+ * exist) to exercise the skip path. `sourceCommit` is stamped into the written
90
+ * `VERSION` provenance line so a reader can tell which verb wrote it.
91
+ */
92
+ export function resyncProtocol(
93
+ repoPath: string,
94
+ options: {
95
+ dryRun?: boolean;
96
+ resolveDoc?: (name: string) => string;
97
+ sourceCommit?: string;
98
+ } = {},
99
+ ): ResyncResult {
100
+ const protocolDir = join(repoPath, WORK_ROOT, 'protocol');
101
+ const resolveDoc = options.resolveDoc ?? ((name) => resolveProtocolDoc(name));
102
+ const docs: ResyncedDoc[] = [];
103
+ for (const name of PROTOCOL_DOCS) {
104
+ // Resolve the SOURCE (package/dev), never the target's adopted copy.
105
+ const source = resolveDoc(name);
106
+ const destAbs = join(protocolDir, name);
107
+ const destRel = relative(repoPath, destAbs);
108
+ const sourceExists = existsSync(source);
109
+ if (!sourceExists) {
110
+ // The source doc could not be resolved: copy NOTHING and record the doc as
111
+ // SKIPPED (never a change). This is the latent-bug fix: a non-resolvable
112
+ // source must not count as `changed` and bump `VERSION` while copying no
113
+ // file. `unchanged: false` here is honest (the dest was not made to match a
114
+ // source), but `skipped: true` keeps it OUT of the VERSION-bump tally.
115
+ docs.push({name, dest: destRel, unchanged: false, skipped: true});
116
+ continue;
117
+ }
118
+ // Source resolved: a doc is CHANGED (VERSION-bump-worthy) only when the dest
119
+ // is absent OR its content differs from the source. An identical dest is a
120
+ // no-op copy (`unchanged`), so an already-synced re-run stays a true no-op.
121
+ const unchanged =
122
+ existsSync(destAbs) &&
123
+ readFileSync(source, 'utf8') === readFileSync(destAbs, 'utf8');
124
+ if (!options.dryRun) {
125
+ mkdirSync(dirname(destAbs), {recursive: true});
126
+ copyFileSync(source, destAbs);
127
+ }
128
+ docs.push({name, dest: destRel, unchanged, skipped: false});
129
+ }
130
+
131
+ const versionAbs = join(protocolDir, 'VERSION');
132
+ const versionRel = relative(repoPath, versionAbs);
133
+ // Bump VERSION only when a doc was ACTUALLY COPIED with new content, or when
134
+ // VERSION is absent AND at least one doc was genuinely synced (copied). A
135
+ // SKIPPED doc (unresolvable source, nothing copied) is NEVER a change —
136
+ // counting it would bump VERSION without copying a single doc (the latent bug),
137
+ // and the write-when-absent fallback must ALSO require a real copy so an
138
+ // all-skipped resync leaves no VERSION behind. A re-run on an already-synced
139
+ // repo must stay a true no-op — otherwise the (always-fresh) `synced-at`
140
+ // timestamp would dirty the tree on every idempotent re-run.
141
+ const anyDocChanged = docs.some((d) => !d.unchanged && !d.skipped);
142
+ const anyDocSynced = docs.some((d) => !d.skipped);
143
+ if (
144
+ !options.dryRun &&
145
+ (anyDocChanged || (anyDocSynced && !existsSync(versionAbs)))
146
+ ) {
147
+ mkdirSync(dirname(versionAbs), {recursive: true});
148
+ const today = new Date().toISOString().slice(0, 10);
149
+ writeFileSync(
150
+ versionAbs,
151
+ [
152
+ `protocol-version: ${today}`,
153
+ 'synced-from: skills/setup/protocol/',
154
+ `synced-at: ${new Date().toISOString()}`,
155
+ `source-commit: ${options.sourceCommit ?? 'dorfl sync'}`,
156
+ '',
157
+ ].join('\n'),
158
+ );
159
+ }
160
+
161
+ return {docs, versionPath: versionRel};
162
+ }
package/src/run.ts CHANGED
@@ -294,7 +294,7 @@ export interface RunOnceOptions {
294
294
  /** Override agent-id generation (tests). Retained for API compat; unused for branch naming. */
295
295
  agentId?: () => string;
296
296
  /**
297
- * Sink for non-fatal warnings (e.g. a repo's `.dorfl.json` naming
297
+ * Sink for non-fatal warnings (e.g. a repo's `dorfl.json` naming
298
298
  * runner/host-only keys that were ignored). Defaults to a no-op so the core
299
299
  * stays pure; the CLI wires this to stderr.
300
300
  */
@@ -537,7 +537,7 @@ async function runOneItem(
537
537
  const {slug, repoPath} = candidate;
538
538
  const base: ItemResult = {repoPath, slug, status: 'lost-race'};
539
539
 
540
- // Resolve THIS repo's effective config against its own `.dorfl.json`
540
+ // Resolve THIS repo's effective config against its own `dorfl.json`
541
541
  // layered over the global config (flag > per-repo > global > default). Each
542
542
  // repo gets its own integration mode / arbiter, so repo A can be `merge`
543
543
  // while repo B is `propose` in one tick.
package/src/scan.ts CHANGED
@@ -384,7 +384,7 @@ export function scoreItems(
384
384
  * primitive; the per-repo `work/` read goes through the seam's mirror-ref method.
385
385
  *
386
386
  * The autonomy gate's `autoBuild` policy is resolved PER REPO. NOTE: a bare
387
- * mirror has no checked-out `.dorfl.json`, so the per-repo file cannot be
387
+ * mirror has no checked-out `dorfl.json`, so the per-repo file cannot be
388
388
  * read from it — the global/env-resolved policy applies (the per-repo override is
389
389
  * a working-checkout concern, served by {@link scanRepoPaths}).
390
390
  */
@@ -450,7 +450,7 @@ export async function scan(
450
450
  );
451
451
  // Spec pool — the TASKABLE-SPEC companion of the task pool above
452
452
  // (`ci-propose-matrix-must-enumerate-sliceable-prds-not-only-slices`). Resolve
453
- // `autoTask` PER REPO from the mirror's COMMITTED `.dorfl.json`
453
+ // `autoTask` PER REPO from the mirror's COMMITTED `dorfl.json`
454
454
  // (exactly as the mirror-side pool scan does — NOT forked); a read fault is
455
455
  // non-fatal (warn + global fall-back), since `scan` is read-only and must
456
456
  // degrade gracefully (ADR §5/§6).
@@ -476,7 +476,7 @@ export async function scan(
476
476
  const specs = scoreSpecs(mirror.path, specPool, repoAutoTask);
477
477
  // The per-repo LIFECYCLE pool (`ci-propose-matrix-enumerates-lifecycle-items`),
478
478
  // gated by this mirror's question-surfacing config (resolved from its committed
479
- // `.dorfl.json`, with the same non-fatal global fall-back as `autoTask`
479
+ // `dorfl.json`, with the same non-fatal global fall-back as `autoTask`
480
480
  // above) and computed by REUSING `gatherLifecycleMirror` → `buildLifecyclePools`
481
481
  // (NOT a forked predicate), so it AGREES with the `run` selection.
482
482
  let repoLifecycleConfig = {
@@ -536,7 +536,7 @@ export async function scan(
536
536
  * checkout IS the local state); the fetch-first contract (ADR §5/§6) applies to
537
537
  * the REGISTRY `scan` above, which refreshes each bare mirror before reading.
538
538
  * Reads each repo's `work/` via the read seam's local-tree method and honours its
539
- * per-repo `.dorfl.json` `autoBuild`. The registry `scan` above is the
539
+ * per-repo `dorfl.json` `autoBuild`. The registry `scan` above is the
540
540
  * mirror-ref counterpart; this is its working-tree sibling.
541
541
  */
542
542
  export function scanRepoPaths(
@@ -579,7 +579,7 @@ export function scanRepoPaths(
579
579
  override,
580
580
  }).config;
581
581
  // Spec pool — the TASKABLE-SPEC companion of the task pool. Resolve
582
- // `autoTask` PER REPO from the working-tree `.dorfl.json` (the same
582
+ // `autoTask` PER REPO from the working-tree `dorfl.json` (the same
583
583
  // way `autoBuild` is resolved); `taskableSpecs` (the SAME `autoslice-gate`
584
584
  // predicate the autopick paths run) decides what is taskable — no forked
585
585
  // predicate. This is what makes the propose-mode CI matrix enumerate `spec:`
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * The workflow is deliberately thin: it wires the same composite setup action
15
15
  * the other capabilities use, then runs `dorfl verify` — the deterministic shell
16
- * gate the repo declares in `.dorfl.json`. No agent, no provider secrets, no
16
+ * gate the repo declares in `dorfl.json`. No agent, no provider secrets, no
17
17
  * `gh` mutation; it is read-only with respect to `work/`.
18
18
  *
19
19
  * `merge_group` is present (with no extra wiring) so a follow-on Tier-2 task