dorfl 0.3.2 → 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.
@@ -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
@@ -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
  // ───────────────────────────────────────────────────────────────────────────
@@ -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});
@@ -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
+ }