Skip to content

[FORGE-113] feat(reconcile,schemas): phases.yaml source metadata + freshness display (P2.5-T17)#171

Merged
firatcand merged 9 commits into
mainfrom
feat/FORGE-113-phases-yaml-source-metadata
May 17, 2026
Merged

[FORGE-113] feat(reconcile,schemas): phases.yaml source metadata + freshness display (P2.5-T17)#171
firatcand merged 9 commits into
mainfrom
feat/FORGE-113-phases-yaml-source-metadata

Conversation

@firatcand
Copy link
Copy Markdown
Owner

Summary

Adds an optional source provenance stanza to plans/phases.yaml and surfaces a one-line freshness summary on stderr from every CLI verb that reads the file. Implements P2.5-T17 / FORGE-113.

  • New source block: { tracker, project_id, synced_at, spec_revision } — validated by zod (.strict()).
  • New CLI behavior: every read of phases.yaml prints to stderr first, e.g. phases.yaml: synced 47min ago from linear (SPEC@a3c2d1f), or ⚠ STALE — synced 1d ago … when synced_at is older than 24h, or the documented fallback when the source block is absent.
  • /reconcile --pull stamps the source stanza atomically on every successful run (zero-diff --pull still bumps synced_at — "last sync attempt" semantic).
  • Breaking schema change vs v0.3.x: top-level tracker_project_id is removed from PhasesSchema; the value moves into source.project_id. tracker_url stays at the top level. Migration is automatic on first /reconcile --pull — the verb reads the legacy key from the raw YAML Document, transplants it, and deletes it. No adopter action required.

Why

Post-FORGE-100, phases.yaml is a derived snapshot of tracker state, not a source of truth. A cached projection needs metadata to answer "when was this synced?" and "from what?". The source block records the provenance; synced_at + spec_revision powers the freshness summary; tracker + project_id answers "which upstream".

Key decisions (recorded in plan + commits)

Q Resolution Source
Where does the freshness stderr live? Loader returns {phases, freshnessLine}; callers print User answer, plan round 1
How is tracker_revision computed? Dropped from schema entirely. No v0.4 consumer for live drift detection. Filed FORGE-123 for the cheap-rev Tracker.getCurrentRevision() shape when a consumer appears User answer after Codex 2nd-pass
source.project_id vs top-level tracker_project_id? Move into source (breaking); migration is auto on first --pull User answer
Freshness line when source block absent? phases.yaml: no source metadata (run forge orchestrate reconcile --pull to sync) User answer
tracker_url location? Stays at top level (scope decision on plan review) User answer
spec/SPEC.md schema-section staleness? Surgical update — add source block + freshness format only; broader §phases.yaml schema refresh deferred User answer

AC coverage

  • PhasesYamlSchema includes optional source block validated by zod — test/unit/phases.schema.test.ts (5 new tests including .strict() rejection of extra keys)
  • Top-level tracker_project_id removed from PhasesSchema; value moves into source.project_id — schema + migration test
  • /reconcile --pull writes the source stanza atomically — test/unit/cli/orchestrate/reconcile.test.ts: \"--pull writes source stanza atomically\"
  • All CLI verbs that read phases.yaml print the freshness summary line on stderr — phases verb, reconcile loader, sync-status-render bin (3/3)
  • When source absent, line says the documented fallback — phases.staleness.test.ts: \"no source block prints documented fallback\"
  • Format documented in spec/SPEC.md §phases.yaml is a derived snapshot
  • spec_revision = git sha when SPEC.md last touched OR content digest if untracked — test/unit/core/spec-revision.test.ts (6 cases)
  • Unit test: round-trip preserves all source fields
  • Integration test: 25h-stale file exits 0 + stderr contains prominent ⚠ STALE marker

Test plan

  • npm test — 1115 pass / 6 pre-existing failures (forge init + spec-diff e2e — unrelated, reproduce on main with this PR's diff stashed)
  • npm run typecheck clean
  • npm run build clean
  • Smoke: node dist/bin/forge.cjs orchestrate phases --json against this repo's migrated plans/phases.yaml prints phases.yaml: synced Nmin ago from linear (SPEC@8fa2226) to stderr + valid envelope JSON to stdout (exit 0)
  • Manual: run forge orchestrate reconcile --pull against a v0.3.x phases.yaml with top-level tracker_project_id and confirm migration

Files changed (23)

  • src/schemas/phases.ts — add SourceSchema (strict), remove tracker_project_id
  • src/core/phases.ts — loader returns {phases, freshnessLine}
  • src/core/freshness.ts (new) — computeFreshnessLine, formatRelativeAge
  • src/core/spec-revision.ts (new) — git sha + content-digest fallback
  • src/core/index.ts — re-exports
  • src/cli/orchestrate/reconcile.ts — source stanza write + legacy migration in runPull
  • src/cli/orchestrate/phases.ts, src/bin/sync-status-render.ts — print freshness
  • plans/phases.yaml — migrate this repo's own top-level field
  • templates/phases.template.yaml, test/fixtures/orchestrator/phases.yaml, skills/push-to-tracker/SKILL.md, docs/{LIFECYCLE,EXAMPLES}.md, docs/trackers/linear.md — migrate doc references
  • spec/SPEC.md — surgical update of source/freshness sections + amendments note
  • 4 new test files (freshness, spec-revision, phases.staleness e2e) + extended phases.schema, phases (core), reconcile (unit + e2e)

Follow-ups filed

  • FORGE-123 — Add Tracker.getCurrentRevision() for live drift detection (blocked by this PR)

Linked

Closes FORGE-113

🤖 Generated with Claude Code

firatcand and others added 9 commits May 18, 2026 01:51
Pre-implementation migration (Step 1 of plans/tasks/FORGE-113.plan.md):

- plans/phases.yaml P2.5-T17 entry: drop tracker_revision from schema +
  AC; add explicit AC for the breaking removal of top-level
  tracker_project_id; add AC for missing-source freshness line; update
  freshness format example.
- This repo's plans/phases.yaml: move line-2 tracker_project_id into a
  new top-level `source:` block (tracker, project_id, synced_at,
  spec_revision). Schema still accepts the old shape (tracker_project_id
  was optional; source is currently unknown-key-stripped). Pre-stages
  the file for the schema change in Step 2.

Decision: tracker_revision dropped → live-drift detection deferred to
follow-up issue (filed in Step 10) per user answer after Codex 2nd-pass.

Decision: source.project_id replaces top-level tracker_project_id
(breaking) per user answer; tracker_url stays at top level per user
answer on plan review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ma (FORGE-113)

Step 2 of plans/tasks/FORGE-113.plan.md.

- Introduce TRACKER_TYPES enum + SourceSchema (zod, strict):
  { tracker, project_id, synced_at: ISO-8601, spec_revision }
- Attach `source: SourceSchema.optional()` to PhasesSchema
- Remove `tracker_project_id` from PhasesSchema top level (breaking)
- Export `Source` and `TrackerType` types

Tests:
- Replace existing tracker_project_id+tracker_url combined test with a
  tracker_url-only test
- Add coverage for: optional source-block round-trip, rejected unknown
  tracker enum, rejected non-ISO synced_at, rejected extra keys inside
  source (strict mode catches tracker_revision and other unintended
  fields)

Decision: SourceSchema is `.strict()` so the deliberately-omitted
`tracker_revision` field cannot be silently smuggled back in by a
hand-edit or stale tool. Future addition (FORGE-XXX follow-up for
live-drift detection) will require an explicit schema change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 3 of plans/tasks/FORGE-113.plan.md. Pure helpers behind the
freshness summary printed by every CLI verb that reads phases.yaml.

src/core/freshness.ts:
- formatRelativeAge(ms): "just now" | "Nmin" | "Nh" | "Nd"
- computeFreshnessLine(source, now): two output shapes
  · `phases.yaml: synced 47min ago from linear (SPEC@8fa2226)`
  · `phases.yaml: ⚠ STALE — synced 1d ago from linear (SPEC@8fa2226)`
    when age > 24h, OR the documented fallback when source is absent.
- 24h stale threshold is exclusive (24h exactly is not stale).
- Defensive branch for unparseable synced_at past the zod boundary.

src/core/spec-revision.ts:
- computeSpecRevision(cwd): git sha of HEAD when spec/SPEC.md was last
  touched, falling back to a 40-char sha256 content digest when the
  file is untracked or the directory isn't a git working tree.
- Throws SpecRevisionError if spec/SPEC.md doesn't exist — that's a
  misconfiguration, not a defensive fallback.
- Uses execFileSync (not exec) for the git invocation; arg array
  prevents shell injection paths from a hostile cwd.

Tests cover: relative-age boundaries, 24h stale boundary on both sides,
missing-source fallback string, unparseable defense, all 3 tracker
types verbatim, short spec_revision slicing; git-sha for tracked,
content-digest for untracked + non-git, last-commit-touching-SPEC.md
semantics (HEAD advancing without touching SPEC.md doesn't change the
rev), throw on missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 4 of plans/tasks/FORGE-113.plan.md.

Loader contract change (decision: FORGE-113 plan §0 Q1):
- `loadPhases(path): Phases` → `loadPhases(path): LoadPhasesResult`
  where LoadPhasesResult = { phases, freshnessLine }.
- freshnessLine is computed at parse time via computeFreshnessLine
  against the optional `source` block. Loader stays pure of stderr
  I/O; each caller decides where (or whether) to surface the line.
- Failures (FILE_NOT_FOUND, PARSE_ERROR, SCHEMA_INVALID) still throw
  PhasesError — the new return shape applies only to the success path.

Callers updated to plumb freshnessLine to stderr before main output:
- src/cli/orchestrate/phases.ts:87
- src/bin/sync-status-render.ts:31
(The third reader, reconcile's loadPhasesWithDocument, is handled in
Step 6 alongside its --pull write path.)

Tests:
- test/unit/core/phases.test.ts updated to the new return shape on the
  two success-path assertions; new "result includes a freshnessLine
  string" coverage. Error-path tests unchanged — throw semantics
  preserved.

Schema cleanup:
- Removed duplicate `export type TrackerType` from src/schemas/phases.ts
  (canonical alias remains in src/trackers/types.ts; barrel re-export
  was ambiguous through src/index.ts).
- src/core/index.ts re-exports LoadPhasesResult, Source,
  computeFreshnessLine, formatRelativeAge, computeSpecRevision,
  SpecRevisionError for downstream consumers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…RGE-113)

Step 5 of plans/tasks/FORGE-113.plan.md.

src/cli/orchestrate/reconcile.ts:
- loadPhasesWithDocument now returns {phases, doc, raw, freshnessLine};
  caller prints freshnessLine to stderr immediately after a successful
  load, before any further verb work.
- runPull resolves a Source object and installs it on the YAML
  Document via setIn(['source', ...]) before writeAtomic. Existing
  doc-level mutations from applyPlanToDocument are preserved.
- runPull now writes phases.yaml UNCONDITIONALLY on a successful --pull
  (not gated on mutationsDoc > 0). Rationale: synced_at means "last
  successful sync attempt", not "last mutation". A zero-diff --pull is
  still a successful sync and should bump the timestamp. `applied` in
  the JSON envelope continues to reflect plan mutations only (so
  consumers checking "did issues change?" still get the right answer).

Source resolution prefers, in order:
  1. The existing `source.project_id` (preserved across --pull runs)
  2. The legacy top-level `tracker_project_id` (migration path; the
     schema strips this on parse but the raw Document still has it
     until the first --pull rewrites the file).
Throws SOURCE_RESOLUTION_FAILED if neither is found — fail loudly
rather than fabricate a project_id.

setSourceOnDocument also deletes the legacy top-level
tracker_project_id key from the Document — idempotent migration runs
on every --pull until adopters are fully on the v0.4 shape.

Tests:
- MINIMAL_PHASES fixture extended with a `source:` block so all
  existing tests survive the new resolution requirement.
- mkScratchWorktree now seeds spec/SPEC.md (computeSpecRevision needs
  it; falls back to content digest in the absence of git).
- stripFreshness() helper for tests that JSON.parse stderr — the
  freshness line is prepended ahead of any structured envelope.
- New unit tests:
  · "--pull writes source stanza atomically" (AC #3)
  · "--pull migrates legacy tracker_project_id into source" (AC #2)
  · "freshness line printed to stderr before main output"
- E2E PHASES_FIXTURE updated identically; scratch dir gets SPEC.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 7 of plans/tasks/FORGE-113.plan.md.

test/integration/cli/orchestrate/phases.staleness.test.ts (new):
  · 25h-stale file: exit 0 + stderr contains
    "phases.yaml: ⚠ STALE — synced 1d ago from linear (SPEC@<digest>)"
    Exits 0 (file is usable); staleness is surfaced via stderr, not exit
    code — matches AC #7 wording ("still works but prints the staleness
    duration prominently").
  · 23h-fresh file: exit 0, no STALE marker, "synced Nh ago" format.
    Pins the 24h boundary the unit tests already cover, exercised
    end-to-end through the verb.
  · No source block: exit 0, stderr matches documented fallback
    "phases.yaml: no source metadata (run forge orchestrate reconcile
    --pull to sync)" — confirms AC #4 in the actual CLI surface, not
    just in computeFreshnessLine unit tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 8 of plans/tasks/FORGE-113.plan.md. Aligns all surfaces that
reference the removed top-level `tracker_project_id` field with the
new `source` block.

- templates/phases.template.yaml: drop top-level `tracker_project_id`;
  add commented `source:` block scaffold so adopters see the
  post-reconcile shape on first inspection. Real values are populated
  by /push-to-tracker → /reconcile --pull.
- test/fixtures/orchestrator/phases.yaml: migrate to source block.
- skills/push-to-tracker/SKILL.md: update both occurrences — the
  tracker-agnostic-keys list and the update-mode edge-case comment.
- docs/LIFECYCLE.md: update the /push-to-tracker outputs description.
- docs/EXAMPLES.md: update the post-bootstrap line.
- docs/trackers/linear.md: update the post-push field list.

Migration is automatic for existing adopter projects: the first
/reconcile --pull transplants legacy top-level tracker_project_id into
source.project_id and deletes the legacy key. No adopter action needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ty (FORGE-113)

Step 9 of plans/tasks/FORGE-113.plan.md. Surgical update — does NOT
refresh the broader stale §phases.yaml schema code block (filed as
follow-up).

§`phases.yaml` is a derived snapshot:
- Source block field list now matches shipped schema:
  `{tracker, project_id, synced_at, spec_revision}` (no
  tracker_revision).
- Freshness format updated to "from <tracker>" (no
  "@<tracker_revision>"): `phases.yaml: synced 47min ago from linear
  (SPEC@a3c2d1f)`.
- Added the missing-source fallback string verbatim.
- Documented the 24h "⚠ STALE — " prominence prefix and the
  "exit 0 + stderr surfacing" semantic.
- Rationale paragraph for the deliberate absence of tracker_revision:
  no v0.4 consumer; the right shape for live drift detection is
  Tracker.getCurrentRevision() (provider-native cheap rev), filed as
  a follow-up issue.
- Breaking change note: top-level tracker_project_id moved into
  source.project_id; migration is automatic on first --pull.

§`phases.yaml` schema (zod):
- Added single-line note pointing readers at src/schemas/phases.ts as
  canonical (the in-spec code block predates FORGE-96 lifecycle fields
  and FORGE-113 source block; broader refresh deferred).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 10 of plans/tasks/FORGE-113.plan.md. Filed FORGE-123 (Add
Tracker.getCurrentRevision() for live drift detection) and updated
the SPEC.md rationale paragraph to reference the real issue ID.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@firatcand firatcand force-pushed the feat/FORGE-113-phases-yaml-source-metadata branch from 9320d5f to 66341d2 Compare May 17, 2026 22:55
@firatcand firatcand merged commit 143812f into main May 17, 2026
10 checks passed
@firatcand firatcand deleted the feat/FORGE-113-phases-yaml-source-metadata branch May 17, 2026 22:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant